diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dfec2fc4..3a089b539 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [v1.3.2] - 2021-01-17 +### Added +Collection: identify multipage TIFF & multitrack HEIC/HEIF +Viewer: support for multipage TIFF +Viewer: support for cropped panoramas +Albums: grouping options + +### Changed +upgraded libtiff to 4.2.0 for TIFF decoding + +### Fixed +- prevent scrolling when using Android Q style gesture navigation + ## [v1.3.1] - 2021-01-04 ### Added - Collection: long press and move to select/deselect multiple entries diff --git a/android/app/build.gradle b/android/app/build.gradle index d84d5d96b..7fd1f982e 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -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//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' diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt index 3efc65b51..6930273bc 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt @@ -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" } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt index 1119e62e3..22e4c84ca 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt @@ -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) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt index 5d8555a5c..de0ee5245 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt @@ -58,6 +58,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler { val isFlipped = call.argument("isFlipped") val widthDip = call.argument("widthDip") val heightDip = call.argument("heightDip") + val page = call.argument("page") val defaultSizeDip = call.argument("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("uri")?.let { Uri.parse(it) } val mimeType = call.argument("mimeType") + val page = call.argument("page") val sampleSize = call.argument("sampleSize") val x = call.argument("regionX") val y = call.argument("regionY") @@ -102,7 +105,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler { uri, sampleSize, regionRect, - page = 0, + page = page ?: 0, result, ) else -> regionFetcher.fetch( diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt index 33fc09a69..f0a95b2d6 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt @@ -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() + 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 { - val metadataMap = HashMap() - - var flags = 0 + private fun getCatalogMetadataByMetadataExtractor( + uri: Uri, + mimeType: String, + path: String?, + sizeBytes: Long?, + metadataMap: HashMap, + ) { + 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 { - val metadataMap = HashMap() - val retriever = StorageUtils.openMetadataRetriever(context, uri) ?: return metadataMap + private fun getMultimediaCatalogMetadataByMediaMetadataRetriever( + uri: Uri, + mimeType: String, + metadataMap: HashMap, + ) { + 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("mimeType") + val uri = call.argument("uri")?.let { Uri.parse(it) } + if (mimeType == null || uri == null) { + result.error("getMultiPageInfo-args", "failed because of missing arguments", null) + return + } + + val pages = HashMap() + if (mimeType == MimeTypes.TIFF) { + fun toMap(options: TiffBitmapFactory.Options): Map { + 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("mimeType") + val uri = call.argument("uri")?.let { Uri.parse(it) } + val sizeBytes = call.argument("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("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 diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ThumbnailFetcher.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ThumbnailFetcher.kt index 0f1b4c31e..3728fa209 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ThumbnailFetcher.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ThumbnailFetcher.kt @@ -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) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt index d14ab3a4e..04087ca04 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt @@ -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 diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt index ead0d7ade..503fad11e 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt @@ -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 newFields]} private fun success(result: Map) { - 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" } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt index 311589a9a..f2892cfb2 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt @@ -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" } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt index 98d66f4e1..77321a004 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt @@ -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" } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/TiffThumbnailGlideModule.kt b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/TiffThumbnailGlideModule.kt index 4ee15e9b4..30c3627c2 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/TiffThumbnailGlideModule.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/TiffThumbnailGlideModule.kt @@ -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 { override fun buildLoadData(model: TiffThumbnail, width: Int, height: Int, options: Options): ModelLoader.LoadData { @@ -46,6 +46,7 @@ internal class TiffThumbnailFetcher(val model: TiffThumbnail, val width: Int, va override fun loadData(priority: Priority, callback: DataCallback) { 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()) } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Geotiff.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Geotiff.kt deleted file mode 100644 index 28a09ef25..000000000 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Geotiff.kt +++ /dev/null @@ -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] - } -} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt index 74c63bf4a..18b23f6d8 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt @@ -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 diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/TiffTags.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/TiffTags.kt new file mode 100644 index 000000000..0f6dce703 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/TiffTags.kt @@ -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] + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt index 48c027ba6..1e67bdfa4 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt @@ -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, ) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt index c1eeca279..8814ee5c7 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt @@ -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) } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt index 5c285de53..7329453a0 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt @@ -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 diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/BitmapUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/BitmapUtils.kt index c8ef015e5..cb70f3347 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/BitmapUtils.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/BitmapUtils.kt @@ -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? { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt index c65025e0e..9f7766d36 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt @@ -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" diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt index 2ba59174a..3e07b7c70 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt @@ -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 diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt index ab26c9932..e3f644b90 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt @@ -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 diff --git a/lib/image_providers/region_provider.dart b/lib/image_providers/region_provider.dart index d1a21313d..ce08b6967 100644 --- a/lib/image_providers/region_provider.dart +++ b/lib/image_providers/region_provider.dart @@ -40,6 +40,7 @@ class RegionProvider extends ImageProvider { key.sampleSize, key.regionRect, key.imageSize, + page: key.page, taskKey: key, ); if (bytes == null) { @@ -63,7 +64,7 @@ class RegionProvider extends ImageProvider { class RegionProviderKey { final String uri, mimeType; - final int rotationDegrees, sampleSize; + final int rotationDegrees, sampleSize, page; final bool isFlipped; final Rectangle 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 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}'; } diff --git a/lib/image_providers/thumbnail_provider.dart b/lib/image_providers/thumbnail_provider.dart index e0cebca55..62546bbae 100644 --- a/lib/image_providers/thumbnail_provider.dart +++ b/lib/image_providers/thumbnail_provider.dart @@ -41,6 +41,7 @@ class ThumbnailProvider extends ImageProvider { key.isFlipped, key.extent, key.extent, + page: key.page, taskKey: key, ); if (bytes == null) { @@ -64,7 +65,7 @@ class ThumbnailProvider extends ImageProvider { 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}'; } diff --git a/lib/image_providers/uri_image_provider.dart b/lib/image_providers/uri_image_provider.dart index 1368f890c..5290913f9 100644 --- a/lib/image_providers/uri_image_provider.dart +++ b/lib/image_providers/uri_image_provider.dart @@ -7,9 +7,15 @@ import 'package:flutter/material.dart'; import 'package:pedantic/pedantic.dart'; class UriImage extends ImageProvider { + 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 { }) : assert(uri != null), assert(scale != null); - final String uri, mimeType; - final int rotationDegrees, expectedContentLength; - final bool isFlipped; - final double scale; - @override Future obtainKey(ImageConfiguration configuration) { return SynchronousFuture(this); @@ -50,6 +51,7 @@ class UriImage extends ImageProvider { mimeType, rotationDegrees, isFlipped, + page: page, expectedContentLength: expectedContentLength, onBytesReceived: (cumulative, total) { chunkEvents.add(ImageChunkEvent( @@ -73,12 +75,19 @@ class UriImage extends ImageProvider { @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}'; } diff --git a/lib/model/actions/chip_actions.dart b/lib/model/actions/chip_actions.dart index f5cfd1a6e..b2bfb1689 100644 --- a/lib/model/actions/chip_actions.dart +++ b/lib/model/actions/chip_actions.dart @@ -2,6 +2,7 @@ import 'package:aves/theme/icons.dart'; import 'package:flutter/widgets.dart'; enum ChipSetAction { + group, sort, refresh, stats, diff --git a/lib/model/entry_cache.dart b/lib/model/entry_cache.dart index b0ef230cc..a940821e0 100644 --- a/lib/model/entry_cache.dart +++ b/lib/model/entry_cache.dart @@ -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()); } diff --git a/lib/model/filters/filters.dart b/lib/model/filters/filters.dart index 5e3b2b14c..e70f39dd8 100644 --- a/lib/model/filters/filters.dart +++ b/lib/model/filters/filters.dart @@ -74,3 +74,20 @@ abstract class CollectionFilter implements Comparable { 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 { + 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); +} diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index 4cd6f3cd3..1d1e6f697 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -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; diff --git a/lib/model/image_metadata.dart b/lib/model/image_metadata.dart index 04086161c..ccbbad3c0 100644 --- a/lib/model/image_metadata.dart +++ b/lib/model/image_metadata.dart @@ -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 { diff --git a/lib/model/multipage.dart b/lib/model/multipage.dart new file mode 100644 index 000000000..7ca616792 --- /dev/null +++ b/lib/model/multipage.dart @@ -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 pages; + + int get pageCount => pages.length; + + MultiPageInfo({ + this.pages, + }); + + factory MultiPageInfo.fromMap(Map map) { + final pages = {}; + 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}'; +} diff --git a/lib/model/panorama.dart b/lib/model/panorama.dart new file mode 100644 index 000000000..0bebe9501 --- /dev/null +++ b/lib/model/panorama.dart @@ -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}'; +} diff --git a/lib/model/settings/screen_on.dart b/lib/model/settings/screen_on.dart index c07924d73..dfb11e633 100644 --- a/lib/model/settings/screen_on.dart +++ b/lib/model/settings/screen_on.dart @@ -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'; diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index 7e39a0ca8..3e5fe5246 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -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); diff --git a/lib/model/source/album.dart b/lib/model/source/album.dart index c9256ed4f..0dce9f30f 100644 --- a/lib/model/source/album.dart +++ b/lib/model/source/album.dart @@ -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 getAlbumEntries() { diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index 9b54aa18f..f56893da9 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -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 _filteredEntries; List _subscriptions = []; - Map> sections = Map.unmodifiable({}); + Map> 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(_filteredEntries, (entry) => entry.directory); + sections = groupBy(_filteredEntries, (entry) => EntryAlbumSectionKey(entry.directory)); break; case EntryGroupFactor.month: - sections = groupBy(_filteredEntries, (entry) => entry.monthTaken); + sections = groupBy(_filteredEntries, (entry) => EntryDateSectionKey(entry.monthTaken)); break; case EntryGroupFactor.day: - sections = groupBy(_filteredEntries, (entry) => entry.dayTaken); + sections = groupBy(_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(_filteredEntries, (entry) => entry.directory); - sections = SplayTreeMap>.of(byAlbum, source.compareAlbumsByName); + final byAlbum = groupBy(_filteredEntries, (entry) => EntryAlbumSectionKey(entry.directory)); + sections = SplayTreeMap>.of(byAlbum, (a, b) => source.compareAlbumsByName(a.folderPath, b.folderPath)); break; } sections = Map.unmodifiable(sections); diff --git a/lib/model/source/enums.dart b/lib/model/source/enums.dart index 65b52917b..a1ba59bb3 100644 --- a/lib/model/source/enums.dart +++ b/lib/model/source/enums.dart @@ -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 } diff --git a/lib/model/source/section_keys.dart b/lib/model/source/section_keys.dart new file mode 100644 index 000000000..072ddcbb3 --- /dev/null +++ b/lib/model/source/section_keys.dart @@ -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}'; +} diff --git a/lib/services/image_file_service.dart b/lib/services/image_file_service.dart index 71d9d9bb1..5ad41ffb2 100644 --- a/lib/services/image_file_service.dart +++ b/lib/services/image_file_service.dart @@ -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 regionRect, Size imageSize, { + int page = 0, Object taskKey, int priority, }) { @@ -132,6 +135,7 @@ class ImageFileService { final result = await platform.invokeMethod('getRegion', { '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 move(Iterable entries, {@required bool copy, @required String destinationAlbum}) { - debugPrint('move ${entries.length} entries'); try { return opChannel.receiveBroadcastStream({ 'op': 'move', diff --git a/lib/services/metadata_service.dart b/lib/services/metadata_service.dart index 268bf1c17..9a53587c6 100644 --- a/lib/services/metadata_service.dart +++ b/lib/services/metadata_service.dart @@ -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 getMultiPageInfo(ImageEntry entry) async { + try { + final result = await platform.invokeMethod('getMultiPageInfo', { + '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 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', { + '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> getEmbeddedPictures(String uri) async { try { final result = await platform.invokeMethod('getEmbeddedPictures', { diff --git a/lib/theme/durations.dart b/lib/theme/durations.dart index 4dc281fdc..59a0f2a64 100644 --- a/lib/theme/durations.dart +++ b/lib/theme/durations.dart @@ -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); diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index a50c1c169..5cdf7f2b5 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -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; diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index 2beeb4360..091f38a24 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -68,7 +68,7 @@ class _CollectionAppBarState extends State with SingleTickerPr } @override - void didUpdateWidget(CollectionAppBar oldWidget) { + void didUpdateWidget(covariant CollectionAppBar oldWidget) { super.didUpdateWidget(oldWidget); _unregisterWidget(oldWidget); _registerWidget(widget); diff --git a/lib/widgets/collection/collection_page.dart b/lib/widgets/collection/collection_page.dart index 7a52c08f2..ba71b34c9 100644 --- a/lib/widgets/collection/collection_page.dart +++ b/lib/widgets/collection/collection_page.dart @@ -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( diff --git a/lib/widgets/collection/filter_bar.dart b/lib/widgets/collection/filter_bar.dart index 712f21fa3..230e1cfea 100644 --- a/lib/widgets/collection/filter_bar.dart +++ b/lib/widgets/collection/filter_bar.dart @@ -29,7 +29,7 @@ class _FilterBarState extends State { CollectionFilter _userRemovedFilter; @override - void didUpdateWidget(FilterBar oldWidget) { + void didUpdateWidget(covariant FilterBar oldWidget) { super.didUpdateWidget(oldWidget); final current = widget.filters; final existing = oldWidget.filters; diff --git a/lib/widgets/collection/grid/header_album.dart b/lib/widgets/collection/grid/headers/album.dart similarity index 52% rename from lib/widgets/collection/grid/header_album.dart rename to lib/widgets/collection/grid/headers/album.dart index d5ef85b6a..f6db50107 100644 --- a/lib/widgets/collection/grid/header_album.dart +++ b/lib/widgets/collection/grid/headers/album.dart @@ -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), + ); + } } diff --git a/lib/widgets/collection/grid/headers/any.dart b/lib/widgets/collection/grid/headers/any.dart new file mode 100644 index 000000000..9a21b47ce --- /dev/null +++ b/lib/widgets/collection/grid/headers/any.dart @@ -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; + } +} diff --git a/lib/widgets/collection/grid/header_date.dart b/lib/widgets/collection/grid/headers/date.dart similarity index 87% rename from lib/widgets/collection/grid/header_date.dart rename to lib/widgets/collection/grid/headers/date.dart index d9dcc3533..8de36ce61 100644 --- a/lib/widgets/collection/grid/header_date.dart +++ b/lib/widgets/collection/grid/headers/date.dart @@ -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, ); } diff --git a/lib/widgets/collection/grid/list_section_layout.dart b/lib/widgets/collection/grid/list_section_layout.dart deleted file mode 100644 index 368b3bbeb..000000000 --- a/lib/widgets/collection/grid/list_section_layout.dart +++ /dev/null @@ -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( - update: (context, __) => _updateLayouts(context), - child: child, - ); - } - - SectionedListLayout _updateLayouts(BuildContext context) { -// debugPrint('$runtimeType _updateLayouts entries=${collection.entryCount} columnCount=$columnCount tileExtent=$tileExtent'); - final sectionLayouts = []; - 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 = []; - 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 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}'; -} diff --git a/lib/widgets/collection/grid/list_sliver.dart b/lib/widgets/collection/grid/list_sliver.dart deleted file mode 100644 index 9d3189382..000000000 --- a/lib/widgets/collection/grid/list_sliver.dart +++ /dev/null @@ -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(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 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, - ), - ), - ); - } -} diff --git a/lib/widgets/collection/grid/section_layout.dart b/lib/widgets/collection/grid/section_layout.dart new file mode 100644 index 000000000..3dad0d735 --- /dev/null +++ b/lib/widgets/collection/grid/section_layout.dart @@ -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 { + 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> 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, + ); + } +} diff --git a/lib/widgets/collection/grid/selector.dart b/lib/widgets/collection/grid/selector.dart index 8e7b5e4a3..83c38e506 100644 --- a/lib/widgets/collection/grid/selector.dart +++ b/lib/widgets/collection/grid/selector.dart @@ -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 { - bool _pressing, _selecting; + bool _pressing = false, _selecting; int _fromIndex, _lastToIndex; Offset _localPosition; EdgeInsets _scrollableInsets; @@ -135,7 +135,8 @@ class _GridSelectionGestureDetectorState extends State().getEntryAt(offset); + final sectionedListLayout = context.read>(); + return sectionedListLayout.getItemAt(offset); } void _toggleSelectionToIndex(int toIndex) { diff --git a/lib/widgets/collection/grid/thumbnail.dart b/lib/widgets/collection/grid/thumbnail.dart new file mode 100644 index 000000000..64866df0b --- /dev/null +++ b/lib/widgets/collection/grid/thumbnail.dart @@ -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 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, + ), + ), + ); + } +} diff --git a/lib/widgets/collection/thumbnail/decorated.dart b/lib/widgets/collection/thumbnail/decorated.dart index 073656a6a..724000503 100644 --- a/lib/widgets/collection/thumbnail/decorated.dart +++ b/lib/widgets/collection/thumbnail/decorated.dart @@ -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, diff --git a/lib/widgets/collection/thumbnail/overlay.dart b/lib/widgets/collection/thumbnail/overlay.dart index 29b464c74..771faf505 100644 --- a/lib/widgets/collection/thumbnail/overlay.dart +++ b/lib/widgets/collection/thumbnail/overlay.dart @@ -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) diff --git a/lib/widgets/collection/thumbnail/raster.dart b/lib/widgets/collection/thumbnail/raster.dart index 68471ae2b..660a1159d 100644 --- a/lib/widgets/collection/thumbnail/raster.dart +++ b/lib/widgets/collection/thumbnail/raster.dart @@ -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 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 { +class _RasterImageThumbnailState extends State { 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 { } @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 { 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 { 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 { final imageProvider = UriImage( uri: entry.uri, mimeType: entry.mimeType, + page: page, rotationDegrees: entry.rotationDegrees, isFlipped: entry.isFlipped, expectedContentLength: entry.sizeBytes, diff --git a/lib/widgets/collection/thumbnail/vector.dart b/lib/widgets/collection/thumbnail/vector.dart index 384fae2e8..54cb811b9 100644 --- a/lib/widgets/collection/thumbnail/vector.dart +++ b/lib/widgets/collection/thumbnail/vector.dart @@ -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), diff --git a/lib/widgets/collection/thumbnail_collection.dart b/lib/widgets/collection/thumbnail_collection.dart index d58c2a8ca..f5530347b 100644 --- a/lib/widgets/collection/thumbnail_collection.dart +++ b/lib/widgets/collection/thumbnail_collection.dart @@ -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( 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( @@ -98,7 +103,7 @@ class ThumbnailCollection extends StatelessWidget { highlightable: false, ), getScaledItemTileRect: (context, entry) { - final sectionedListLayout = Provider.of(context, listen: false); + final sectionedListLayout = context.read>(); return sectionedListLayout.getTileRect(entry) ?? Rect.zero; }, onScaled: (entry) => Provider.of(context, listen: false).add(entry), @@ -115,12 +120,12 @@ class ThumbnailCollection extends StatelessWidget { final sectionedListLayoutProvider = ValueListenableBuilder( 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 { } @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 { child: _buildEmptyCollectionPlaceholder(collection), hasScrollBody: false, ) - : CollectionListSliver(), + : SectionedListSliver(), SliverToBoxAdapter( child: Selector( selector: (context, mq) => mq.viewInsets.bottom, diff --git a/lib/widgets/common/basic/multi_cross_fader.dart b/lib/widgets/common/basic/multi_cross_fader.dart index 5c14dd458..22d75f822 100644 --- a/lib/widgets/common/basic/multi_cross_fader.dart +++ b/lib/widgets/common/basic/multi_cross_fader.dart @@ -30,7 +30,7 @@ class _MultiCrossFaderState extends State { } @override - void didUpdateWidget(MultiCrossFader oldWidget) { + void didUpdateWidget(covariant MultiCrossFader oldWidget) { super.didUpdateWidget(oldWidget); if (_first == oldWidget.child) { _second = widget.child; diff --git a/lib/widgets/common/fx/blurred.dart b/lib/widgets/common/fx/blurred.dart index a3f606ede..d14d3e214 100644 --- a/lib/widgets/common/fx/blurred.dart +++ b/lib/widgets/common/fx/blurred.dart @@ -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; } } diff --git a/lib/widgets/common/fx/sweeper.dart b/lib/widgets/common/fx/sweeper.dart index 9a44bc053..0e9be29ab 100644 --- a/lib/widgets/common/fx/sweeper.dart +++ b/lib/widgets/common/fx/sweeper.dart @@ -57,7 +57,7 @@ class _SweeperState extends State with SingleTickerProviderStateMixin { } @override - void didUpdateWidget(Sweeper oldWidget) { + void didUpdateWidget(covariant Sweeper oldWidget) { super.didUpdateWidget(oldWidget); _unregisterWidget(oldWidget); _registerWidget(widget); diff --git a/lib/widgets/common/fx/transition_image.dart b/lib/widgets/common/fx/transition_image.dart index 1f1117206..2a62162b7 100644 --- a/lib/widgets/common/fx/transition_image.dart +++ b/lib/widgets/common/fx/transition_image.dart @@ -57,7 +57,7 @@ class _TransitionImageState extends State { } @override - void didUpdateWidget(TransitionImage oldWidget) { + void didUpdateWidget(covariant TransitionImage oldWidget) { super.didUpdateWidget(oldWidget); if (_isListeningToStream) { _imageStream.removeListener(_getListener()); diff --git a/lib/widgets/common/gesture_area_protector.dart b/lib/widgets/common/gesture_area_protector.dart new file mode 100644 index 000000000..33a736d6b --- /dev/null +++ b/lib/widgets/common/gesture_area_protector.dart @@ -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(), + ], + ); + } +} diff --git a/lib/widgets/collection/grid/header_generic.dart b/lib/widgets/common/grid/header.dart similarity index 60% rename from lib/widgets/collection/grid/header_generic.dart rename to lib/widgets/common/grid/header.dart index 2396cf2c3..0bc1db5ad 100644 --- a/lib/widgets/collection/grid/header_generic.dart +++ b/lib/widgets/common/grid/header.dart @@ -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(context); return ValueListenableBuilder( 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); } diff --git a/lib/widgets/common/grid/section_layout.dart b/lib/widgets/common/grid/section_layout.dart new file mode 100644 index 000000000..a49a2e241 --- /dev/null +++ b/lib/widgets/common/grid/section_layout.dart @@ -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 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>( + update: (context, __) => _updateLayouts(context), + child: child, + ); + } + + SectionedListLayout _updateLayouts(BuildContext context) { + final _showHeaders = showHeaders; + final _sections = sections; + final sectionKeys = _sections.keys.toList(); + + final sectionLayouts = []; + 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( + sections: _sections, + showHeaders: _showHeaders, + columnCount: columnCount, + tileExtent: tileExtent, + spacing: spacing, + sectionLayouts: sectionLayouts, + ); + } + + Widget _buildInSection( + BuildContext context, + List 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 = []; + 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> get sections; + + double getHeaderExtent(BuildContext context, SectionKey sectionKey); + + Widget buildHeader(BuildContext context, SectionKey sectionKey, double headerExtent); +} + +class SectionedListLayout { + final Map> sections; + final bool showHeaders; + final int columnCount; + final double tileExtent, spacing; + final List 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}'; +} diff --git a/lib/widgets/collection/grid/list_known_extent.dart b/lib/widgets/common/grid/sliver.dart similarity index 80% rename from lib/widgets/collection/grid/list_known_extent.dart rename to lib/widgets/common/grid/sliver.dart index 6ea65c1f8..d5effa5f8 100644 --- a/lib/widgets/collection/grid/list_known_extent.dart +++ b/lib/widgets/common/grid/sliver.dart @@ -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 extends StatelessWidget { + const SectionedListSliver(); + + @override + Widget build(BuildContext context) { + final sectionLayouts = context.watch>().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 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 _sectionLayouts; List get sectionLayouts => _sectionLayouts; @@ -38,7 +65,7 @@ class RenderSliverKnownExtentBoxAdaptor extends RenderSliverMultiBoxAdaptor { markNeedsLayout(); } - RenderSliverKnownExtentBoxAdaptor({ + _RenderSliverKnownExtentBoxAdaptor({ @required RenderSliverBoxChildManager childManager, @required List 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, ); diff --git a/lib/widgets/common/identity/aves_filter_chip.dart b/lib/widgets/common/identity/aves_filter_chip.dart index 5351522a1..6d6b9a092 100644 --- a/lib/widgets/common/identity/aves_filter_chip.dart +++ b/lib/widgets/common/identity/aves_filter_chip.dart @@ -66,7 +66,7 @@ class _AvesFilterChipState extends State { } @override - void didUpdateWidget(AvesFilterChip oldWidget) { + void didUpdateWidget(covariant AvesFilterChip oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.filter != filter) { _initColorLoader(); diff --git a/lib/widgets/common/identity/aves_icons.dart b/lib/widgets/common/identity/aves_icons.dart index 4ac616a21..adbf3fa8e 100644 --- a/lib/widgets/common/identity/aves_icons.dart +++ b/lib/widgets/common/identity/aves_icons.dart @@ -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; diff --git a/lib/widgets/common/magnifier/core/core.dart b/lib/widgets/common/magnifier/core/core.dart index 441fe2c99..047a1a6eb 100644 --- a/lib/widgets/common/magnifier/core/core.dart +++ b/lib/widgets/common/magnifier/core/core.dart @@ -35,11 +35,11 @@ class MagnifierCore extends StatefulWidget { @override State createState() { - return MagnifierCoreState(); + return _MagnifierCoreState(); } } -class MagnifierCoreState extends State with TickerProviderStateMixin, MagnifierControllerDelegate, CornerHitDetector { +class _MagnifierCoreState extends State with TickerProviderStateMixin, MagnifierControllerDelegate, CornerHitDetector { Offset _startFocalPoint, _lastViewportFocalPosition; double _startScale, _quickScaleLastY, _quickScaleLastDistance; bool _doubleTap, _quickScaleMoved; diff --git a/lib/widgets/common/magnifier/magnifier.dart b/lib/widgets/common/magnifier/magnifier.dart index f5e18a5d3..e73bebbde 100644 --- a/lib/widgets/common/magnifier/magnifier.dart +++ b/lib/widgets/common/magnifier/magnifier.dart @@ -58,19 +58,14 @@ class Magnifier extends StatefulWidget { } class _MagnifierState extends State { - 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 { } @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 { _controlledController = false; _controller = widget.controller; } - super.didUpdateWidget(oldWidget); } @override @@ -116,7 +106,7 @@ class _MagnifierState extends State { 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( diff --git a/lib/widgets/debug/android_dirs.dart b/lib/widgets/debug/android_dirs.dart index 9f2f02129..3731170fe 100644 --- a/lib/widgets/debug/android_dirs.dart +++ b/lib/widgets/debug/android_dirs.dart @@ -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 { diff --git a/lib/widgets/debug/android_env.dart b/lib/widgets/debug/android_env.dart index b831572b0..0600393dd 100644 --- a/lib/widgets/debug/android_env.dart +++ b/lib/widgets/debug/android_env.dart @@ -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 { diff --git a/lib/widgets/debug/app_debug_page.dart b/lib/widgets/debug/app_debug_page.dart index dd4fe617e..09626c721 100644 --- a/lib/widgets/debug/app_debug_page.dart +++ b/lib/widgets/debug/app_debug_page.dart @@ -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 createState() => AppDebugPageState(); + State createState() => _AppDebugPageState(); } -class AppDebugPageState extends State { +class _AppDebugPageState extends State { List get entries => widget.source.rawEntries; static OverlayEntry _taskQueueOverlayEntry; diff --git a/lib/widgets/debug/firebase.dart b/lib/widgets/debug/firebase.dart index 9551cbfe1..c1069f07e 100644 --- a/lib/widgets/debug/firebase.dart +++ b/lib/widgets/debug/firebase.dart @@ -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'; diff --git a/lib/widgets/debug/settings.dart b/lib/widgets/debug/settings.dart index d3a1a7ce4..c6ff629c0 100644 --- a/lib/widgets/debug/settings.dart +++ b/lib/widgets/debug/settings.dart @@ -4,7 +4,7 @@ import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/filter_grids/countries_page.dart'; import 'package:aves/widgets/filter_grids/tags_page.dart'; -import 'package:aves/widgets/fullscreen/info/common.dart'; +import 'package:aves/widgets/viewer/info/common.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; diff --git a/lib/widgets/debug/storage.dart b/lib/widgets/debug/storage.dart index 2c22b29f9..d684157bd 100644 --- a/lib/widgets/debug/storage.dart +++ b/lib/widgets/debug/storage.dart @@ -2,7 +2,7 @@ import 'package:aves/services/android_file_service.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/file_utils.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; -import 'package:aves/widgets/fullscreen/info/common.dart'; +import 'package:aves/widgets/viewer/info/common.dart'; import 'package:flutter/material.dart'; class DebugStorageSection extends StatefulWidget { diff --git a/lib/widgets/filter_grids/album_pick.dart b/lib/widgets/filter_grids/album_pick.dart index 965d9841e..6b33d36d9 100644 --- a/lib/widgets/filter_grids/album_pick.dart +++ b/lib/widgets/filter_grids/album_pick.dart @@ -49,11 +49,12 @@ class _AlbumPickPageState extends State { return FilterGridPage( 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( diff --git a/lib/widgets/filter_grids/albums_page.dart b/lib/widgets/filter_grids/albums_page.dart index 4c78eac83..a50328f3f 100644 --- a/lib/widgets/filter_grids/albums_page.dart +++ b/lib/widgets/filter_grids/albums_page.dart @@ -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>>( - selector: (context, s) => Tuple2(s.albumSortFactor, s.pinnedFilters), + return Selector>>( + 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( 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 getAlbumEntries(CollectionSource source) { - final pinned = settings.pinnedFilters.whereType(); - final entriesByDate = source.sortedEntriesForFilterList; - - AlbumFilter _buildFilter(String album) => AlbumFilter(album, source.getUniqueAlbumName(album)); - + static Map>> 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 = [], regularAlbums = [], appAlbums = [], specialAlbums = []; - 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, bool>(allMapEntries, (e) => pinned.contains(e.key)); + static Map>> _group(Iterable> sortedMapEntries) { + final pinned = settings.pinnedFilters.whereType(); + final byPin = groupBy, 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 = >>{}; + switch (settings.albumGroupFactor) { + case AlbumChipGroupFactor.importance: + sections = groupBy, 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, 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; } } diff --git a/lib/widgets/filter_grids/common/chip_set_action_delegate.dart b/lib/widgets/filter_grids/common/chip_set_action_delegate.dart index 4e2db7de1..08e9e32d0 100644 --- a/lib/widgets/filter_grids/common/chip_set_action_delegate.dart +++ b/lib/widgets/filter_grids/common/chip_set_action_delegate.dart @@ -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 _showGroupDialog(BuildContext context) async { + final factor = await showDialog( + context: context, + builder: (context) => AvesSelectionDialog( + 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 { diff --git a/lib/widgets/filter_grids/common/decorated_filter_chip.dart b/lib/widgets/filter_grids/common/decorated_filter_chip.dart index 385b4a37b..5842967ee 100644 --- a/lib/widgets/filter_grids/common/decorated_filter_chip.dart +++ b/lib/widgets/filter_grids/common/decorated_filter_chip.dart @@ -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; } diff --git a/lib/widgets/filter_grids/common/filter_grid_page.dart b/lib/widgets/filter_grids/common/filter_grid_page.dart index 84151ff22..b03997e9b 100644 --- a/lib/widgets/filter_grids/common/filter_grid_page.dart +++ b/lib/widgets/filter_grids/common/filter_grid_page.dart @@ -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 extends StatelessWidget { final CollectionSource source; final Widget appBar; - final Map filterEntries; + final Map>> filterSections; + final bool showHeaders; final ValueNotifier queryNotifier; final Widget Function() emptyBuilder; final String settingsRouteKey; - final Iterable Function(Iterable filters, String query) applyQuery; + final Iterable> Function(Iterable> filters, String query) applyQuery; final FilterCallback onTap; final OffsetFilterCallback onLongPress; @@ -45,7 +49,8 @@ class FilterGridPage 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 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( - valueListenable: _tileExtentNotifier, - builder: (context, tileExtent, child) { - final columnCount = tileExtentManager.getEffectiveColumnCountForExtent(viewportSize, tileExtent); + final pinnedFilters = settings.pinnedFilters; + return ValueListenableBuilder( + valueListenable: queryNotifier, + builder: (context, query, child) { + Map>> 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( - 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( - 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>( + 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>>(); + return sectionedListLayout.getTileRect(item) ?? Rect.zero; + }, + onScaled: (item) => Provider.of(context, listen: false).add(item.filter), + child: scrollView, + ); + + final sectionedListLayoutProvider = ValueListenableBuilder( + valueListenable: _tileExtentNotifier, + builder: (context, tileExtent, child) => SectionedFilterListLayoutProvider( + 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(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(context, listen: false).add(item.filter), - child: scrollView, - ); - }, - ); - }, - ); - }, + child: scaler, + ), + ); + return sectionedListLayoutProvider; + }, + ); + }, + ), ), ), ), @@ -173,81 +207,42 @@ class FilterGridPage extends StatelessWidget { ); } - ScrollView _buildScrollView(BuildContext context, int columnCount, List visibleFilters) { - final pinnedFilters = settings.pinnedFilters; + ScrollView _buildScrollView(BuildContext context, bool empty) { + Widget content; + if (empty) { + content = SliverFillRemaining( + child: Selector( + selector: (context, mq) => mq.viewInsets.bottom, + builder: (context, mqViewInsetsBottom, child) { + return Padding( + padding: EdgeInsets.only(bottom: mqViewInsetsBottom), + child: emptyBuilder(), + ); + }, + ), + hasScrollBody: false, + ); + } else { + content = SectionedListSliver>(); + } + + final padding = SliverToBoxAdapter( + child: Selector( + 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( - 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( - selector: (context, mq) => mq.viewInsets.bottom, - builder: (context, mqViewInsetsBottom, child) { - return SizedBox(height: mqViewInsetsBottom); - }, - ), - ), + content, + padding, ], ); } } - -class FilterGridItem { - final T filter; - final ImageEntry entry; - - const FilterGridItem(this.filter, this.entry); -} diff --git a/lib/widgets/filter_grids/common/filter_nav_page.dart b/lib/widgets/filter_grids/common/filter_nav_page.dart index 525a5bc5d..64f3a5656 100644 --- a/lib/widgets/filter_grids/common/filter_nav_page.dart +++ b/lib/widgets/filter_grids/common/filter_nav_page.dart @@ -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 extends StatelessWidget { final CollectionSource source; final String title; final ChipSetActionDelegate chipSetActionDelegate; + final bool groupable, showHeaders; final ChipActionDelegate chipActionDelegate; - final Map filterEntries; - final Widget Function() emptyBuilder; final List Function(T filter) chipActionsBuilder; + final Map>> 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 extends StatelessWidget { titleSpacing: 0, floating: true, ), - filterEntries: filterEntries, + filterSections: filterSections, + showHeaders: showHeaders, queryNotifier: ValueNotifier(''), emptyBuilder: () => ValueListenableBuilder( valueListenable: source.stateNotifier, @@ -114,6 +119,11 @@ class FilterNavigationPage 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 extends StatelessWidget { )); } - static int compareChipsByDate(MapEntry a, MapEntry b) { - final c = b.value.bestDate?.compareTo(a.value.bestDate) ?? -1; - return c != 0 ? c : a.key.compareTo(b.key); + static int compareFiltersByDate(FilterGridItem a, FilterGridItem b) { + final c = b.entry.bestDate?.compareTo(a.entry.bestDate) ?? -1; + return c != 0 ? c : a.filter.compareTo(b.filter); } - static int compareChipsByEntryCount(MapEntry a, MapEntry b) { + static int compareFiltersByEntryCount(MapEntry a, MapEntry b) { final c = b.value.compareTo(a.value) ?? -1; return c != 0 ? c : a.key.compareTo(b.key); } + + static Iterable> sort(ChipSortFactor sortFactor, CollectionSource source, Iterable filters) { + Iterable> toGridItem(CollectionSource source, Iterable filters) { + final entriesByDate = source.sortedEntriesForFilterList; + return filters.map((filter) => FilterGridItem( + filter, + entriesByDate.firstWhere(filter.filter, orElse: () => null), + )); + } + + Iterable> 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; + } } diff --git a/lib/widgets/filter_grids/common/section_header.dart b/lib/widgets/filter_grids/common/section_header.dart new file mode 100644 index 000000000..90dab210f --- /dev/null +++ b/lib/widgets/filter_grids/common/section_header.dart @@ -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; + } +} diff --git a/lib/widgets/filter_grids/common/section_keys.dart b/lib/widgets/filter_grids/common/section_keys.dart new file mode 100644 index 000000000..9828bf51b --- /dev/null +++ b/lib/widgets/filter_grids/common/section_keys.dart @@ -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; +} diff --git a/lib/widgets/filter_grids/common/section_layout.dart b/lib/widgets/filter_grids/common/section_layout.dart new file mode 100644 index 000000000..4c1d690a8 --- /dev/null +++ b/lib/widgets/filter_grids/common/section_layout.dart @@ -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 extends SectionedListLayoutProvider> { + const SectionedFilterListLayoutProvider({ + @required this.sections, + @required this.showHeaders, + @required double scrollableWidth, + @required int columnCount, + double spacing = 0, + @required double tileExtent, + @required Widget Function(FilterGridItem gridItem) tileBuilder, + @required Widget child, + }) : super( + scrollableWidth: scrollableWidth, + columnCount: columnCount, + spacing: spacing, + tileExtent: tileExtent, + tileBuilder: tileBuilder, + child: child, + ); + + @override + final Map>> 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, + ); + } +} diff --git a/lib/widgets/filter_grids/countries_page.dart b/lib/widgets/filter_grids/countries_page.dart index 5f3dd6ce4..64da80407 100644 --- a/lib/widgets/filter_grids/countries_page.dart +++ b/lib/widgets/filter_grids/countries_page.dart @@ -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(), - builder: (context, snapshot) => FilterNavigationPage( + builder: (context, snapshot) => FilterNavigationPage( 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 _getCountryEntries() { - final pinned = settings.pinnedFilters.whereType(); - - final entriesByDate = source.sortedEntriesForFilterList; + Map>> _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, bool>(allMapEntries, (e) => pinned.contains(e.key)); + final sorted = FilterNavigationPage.sort(settings.countrySortFactor, source, filters); + return _group(sorted); + } + + static Map>> _group(Iterable> sortedMapEntries) { + final pinned = settings.pinnedFilters.whereType(); + final byPin = groupBy, 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, + ], + }; } } diff --git a/lib/widgets/filter_grids/tags_page.dart b/lib/widgets/filter_grids/tags_page.dart index 7d29f6f7c..122a2c4b9 100644 --- a/lib/widgets/filter_grids/tags_page.dart +++ b/lib/widgets/filter_grids/tags_page.dart @@ -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(), - builder: (context, snapshot) => FilterNavigationPage( + builder: (context, snapshot) => FilterNavigationPage( 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 _getTagEntries() { - final pinned = settings.pinnedFilters.whereType(); - - final entriesByDate = source.sortedEntriesForFilterList; + Map>> _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, bool>(allMapEntries, (e) => pinned.contains(e.key)); + final sorted = FilterNavigationPage.sort(settings.tagSortFactor, source, filters); + return _group(sorted); + } + + static Map>> _group(Iterable> sortedMapEntries) { + final pinned = settings.pinnedFilters.whereType(); + final byPin = groupBy, 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, + ], + }; } } diff --git a/lib/widgets/fullscreen/image_page.dart b/lib/widgets/fullscreen/image_page.dart deleted file mode 100644 index 7046661e5..000000000 --- a/lib/widgets/fullscreen/image_page.dart +++ /dev/null @@ -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 onPageChanged; - final VoidCallback onTap; - final List> videoControllers; - final void Function(String uri) onViewDisposed; - - const MultiImagePage({ - this.collection, - this.pageController, - this.onPageChanged, - this.onTap, - this.videoControllers, - this.onViewDisposed, - }); - - @override - State createState() => MultiImagePageState(); -} - -class MultiImagePageState extends State with AutomaticKeepAliveClientMixin { - List 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> videoControllers; - - const SingleImagePage({ - this.entry, - this.onTap, - this.videoControllers, - }); - - @override - State createState() => SingleImagePageState(); -} - -class SingleImagePageState extends State 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; -} diff --git a/lib/widgets/fullscreen/overlay/bottom.dart b/lib/widgets/fullscreen/overlay/bottom.dart deleted file mode 100644 index b4d87a4cf..000000000 --- a/lib/widgets/fullscreen/overlay/bottom.dart +++ /dev/null @@ -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 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 createState() => _FullscreenBottomOverlayState(); -} - -class _FullscreenBottomOverlayState extends State { - Future _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>( - 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( - 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( - 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 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>((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, - ), - ); - } -} diff --git a/lib/widgets/fullscreen/panorama_page.dart b/lib/widgets/fullscreen/panorama_page.dart deleted file mode 100644 index d7f18b491..000000000 --- a/lib/widgets/fullscreen/panorama_page.dart +++ /dev/null @@ -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, - ); - } -} diff --git a/lib/widgets/home_page.dart b/lib/widgets/home_page.dart index d2d19c90d..e7ba1d8ef 100644 --- a/lib/widgets/home_page.dart +++ b/lib/widgets/home_page.dart @@ -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 { 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), ); } diff --git a/lib/widgets/search/expandable_filter_row.dart b/lib/widgets/search/expandable_filter_row.dart index db02cadeb..5702fc9fb 100644 --- a/lib/widgets/search/expandable_filter_row.dart +++ b/lib/widgets/search/expandable_filter_row.dart @@ -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, diff --git a/lib/widgets/search/search_page.dart b/lib/widgets/search/search_page.dart index e0e464e51..ef3c3e933 100644 --- a/lib/widgets/search/search_page.dart +++ b/lib/widgets/search/search_page.dart @@ -52,7 +52,7 @@ class _SearchPageState extends State { } @override - void didUpdateWidget(SearchPage oldWidget) { + void didUpdateWidget(covariant SearchPage oldWidget) { super.didUpdateWidget(oldWidget); if (widget.delegate != oldWidget.delegate) { oldWidget.delegate.queryTextController.removeListener(_onQueryChanged); diff --git a/lib/widgets/settings/settings_page.dart b/lib/widgets/settings/settings_page.dart index 65c320102..e82cab105 100644 --- a/lib/widgets/settings/settings_page.dart +++ b/lib/widgets/settings/settings_page.dart @@ -25,33 +25,42 @@ class _SettingsPageState extends State { @override Widget build(BuildContext context) { + final theme = Theme.of(context); return MediaQueryDataProvider( child: Scaffold( appBar: AppBar( title: Text('Settings'), ), - body: SafeArea( - child: Consumer( - 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( + 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 { 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'), ), ], diff --git a/lib/widgets/fullscreen/debug/db.dart b/lib/widgets/viewer/debug/db.dart similarity index 98% rename from lib/widgets/fullscreen/debug/db.dart rename to lib/widgets/viewer/debug/db.dart index 745368d1d..641ee2709 100644 --- a/lib/widgets/fullscreen/debug/db.dart +++ b/lib/widgets/viewer/debug/db.dart @@ -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 { diff --git a/lib/widgets/fullscreen/debug/metadata.dart b/lib/widgets/viewer/debug/metadata.dart similarity index 98% rename from lib/widgets/fullscreen/debug/metadata.dart rename to lib/widgets/viewer/debug/metadata.dart index fe655bdff..075cde487 100644 --- a/lib/widgets/fullscreen/debug/metadata.dart +++ b/lib/widgets/viewer/debug/metadata.dart @@ -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 { diff --git a/lib/widgets/fullscreen/fullscreen_debug_page.dart b/lib/widgets/viewer/debug_page.dart similarity index 92% rename from lib/widgets/fullscreen/fullscreen_debug_page.dart rename to lib/widgets/viewer/debug_page.dart index 0abf248a0..21b2aa728 100644 --- a/lib/widgets/fullscreen/fullscreen_debug_page.dart +++ b/lib/widgets/viewer/debug_page.dart @@ -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({ diff --git a/lib/widgets/fullscreen/entry_action_delegate.dart b/lib/widgets/viewer/entry_action_delegate.dart similarity index 76% rename from lib/widgets/fullscreen/entry_action_delegate.dart rename to lib/widgets/viewer/entry_action_delegate.dart index b6102bf1d..1b95e87bd 100644 --- a/lib/widgets/fullscreen/entry_action_delegate.dart +++ b/lib/widgets/viewer/entry_action_delegate.dart @@ -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 _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 _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), ), ); } diff --git a/lib/widgets/viewer/entry_scroller.dart b/lib/widgets/viewer/entry_scroller.dart new file mode 100644 index 000000000..58b799a9d --- /dev/null +++ b/lib/widgets/viewer/entry_scroller.dart @@ -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 onPageChanged; + final VoidCallback onTap; + final List> videoControllers; + final List> 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 createState() => _MultiEntryScrollerState(); +} + +class _MultiEntryScrollerState extends State with AutomaticKeepAliveClientMixin { + List 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( + future: multiPageController.info, + builder: (context, snapshot) { + final multiPageInfo = snapshot.data; + return ValueListenableBuilder( + 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> videoControllers; + final List> multiPageControllers; + + const SingleEntryScroller({ + this.entry, + this.onTap, + this.videoControllers, + this.multiPageControllers, + }); + + @override + State createState() => _SingleEntryScrollerState(); +} + +class _SingleEntryScrollerState extends State 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( + future: multiPageController.info, + builder: (context, snapshot) { + final multiPageInfo = snapshot.data; + return ValueListenableBuilder( + 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; +} diff --git a/lib/widgets/fullscreen/fullscreen_page.dart b/lib/widgets/viewer/entry_viewer_page.dart similarity index 74% rename from lib/widgets/fullscreen/fullscreen_page.dart rename to lib/widgets/viewer/entry_viewer_page.dart index 37abf6a17..65d35279b 100644 --- a/lib/widgets/fullscreen/fullscreen_page.dart +++ b/lib/widgets/viewer/entry_viewer_page.dart @@ -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, diff --git a/lib/widgets/fullscreen/fullscreen_body.dart b/lib/widgets/viewer/entry_viewer_stack.dart similarity index 84% rename from lib/widgets/fullscreen/fullscreen_body.dart rename to lib/widgets/viewer/entry_viewer_stack.dart index e7b3791bd..116befdc2 100644 --- a/lib/widgets/fullscreen/fullscreen_body.dart +++ b/lib/widgets/viewer/entry_viewer_stack.dart @@ -8,16 +8,18 @@ import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/utils/change_notifier.dart'; import 'package:aves/widgets/collection/collection_page.dart'; +import 'package:aves/widgets/common/gesture_area_protector.dart'; import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart'; -import 'package:aves/widgets/fullscreen/entry_action_delegate.dart'; -import 'package:aves/widgets/fullscreen/image_page.dart'; -import 'package:aves/widgets/fullscreen/image_view.dart'; -import 'package:aves/widgets/fullscreen/info/info_page.dart'; -import 'package:aves/widgets/fullscreen/info/notifications.dart'; -import 'package:aves/widgets/fullscreen/overlay/bottom.dart'; -import 'package:aves/widgets/fullscreen/overlay/panorama.dart'; -import 'package:aves/widgets/fullscreen/overlay/top.dart'; -import 'package:aves/widgets/fullscreen/overlay/video.dart'; +import 'package:aves/widgets/viewer/entry_action_delegate.dart'; +import 'package:aves/widgets/viewer/entry_scroller.dart'; +import 'package:aves/widgets/viewer/info/info_page.dart'; +import 'package:aves/widgets/viewer/info/notifications.dart'; +import 'package:aves/widgets/viewer/multipage.dart'; +import 'package:aves/widgets/viewer/overlay/bottom.dart'; +import 'package:aves/widgets/viewer/overlay/panorama.dart'; +import 'package:aves/widgets/viewer/overlay/top.dart'; +import 'package:aves/widgets/viewer/overlay/video.dart'; +import 'package:aves/widgets/viewer/visual/state.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; @@ -27,21 +29,21 @@ import 'package:provider/provider.dart'; import 'package:screen/screen.dart'; import 'package:tuple/tuple.dart'; -class FullscreenBody extends StatefulWidget { +class EntryViewerStack extends StatefulWidget { final CollectionLens collection; final ImageEntry initialEntry; - const FullscreenBody({ + const EntryViewerStack({ Key key, this.collection, this.initialEntry, }) : super(key: key); @override - FullscreenBodyState createState() => FullscreenBodyState(); + _EntryViewerStackState createState() => _EntryViewerStackState(); } -class FullscreenBodyState extends State with SingleTickerProviderStateMixin, WidgetsBindingObserver { +class _EntryViewerStackState extends State with SingleTickerProviderStateMixin, WidgetsBindingObserver { final ValueNotifier _entryNotifier = ValueNotifier(null); int _currentHorizontalPage; ValueNotifier _currentVerticalPage; @@ -54,6 +56,7 @@ class FullscreenBodyState extends State with SingleTickerProvide EdgeInsets _frozenViewInsets, _frozenViewPadding; EntryActionDelegate _actionDelegate; final List> _videoControllers = []; + final List> _multiPageControllers = []; final List>> _viewStateNotifiers = []; CollectionLens get collection => widget.collection; @@ -78,7 +81,7 @@ class FullscreenBodyState extends State with SingleTickerProvide _horizontalPager = PageController(initialPage: _currentHorizontalPage); _verticalPager = PageController(initialPage: _currentVerticalPage.value)..addListener(_onVerticalPageControllerChange); _overlayAnimationController = AnimationController( - duration: Durations.fullscreenOverlayAnimation, + duration: Durations.viewerOverlayAnimation, vsync: this, ); _topOverlayScale = CurvedAnimation( @@ -104,13 +107,13 @@ class FullscreenBodyState extends State with SingleTickerProvide _registerWidget(widget); WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addPostFrameCallback((_) => _initOverlay()); - if (settings.keepScreenOn == KeepScreenOn.fullscreenOnly) { + if (settings.keepScreenOn == KeepScreenOn.viewerOnly) { Screen.keepOn(true); } } @override - void didUpdateWidget(FullscreenBody oldWidget) { + void didUpdateWidget(covariant EntryViewerStack oldWidget) { super.didUpdateWidget(oldWidget); _unregisterWidget(oldWidget); _registerWidget(widget); @@ -122,17 +125,19 @@ class FullscreenBodyState extends State with SingleTickerProvide _overlayVisible.removeListener(_onOverlayVisibleChange); _videoControllers.forEach((kv) => kv.item2.dispose()); _videoControllers.clear(); + _multiPageControllers.forEach((kv) => kv.item2.dispose()); + _multiPageControllers.clear(); _verticalPager.removeListener(_onVerticalPageControllerChange); WidgetsBinding.instance.removeObserver(this); _unregisterWidget(widget); super.dispose(); } - void _registerWidget(FullscreenBody widget) { + void _registerWidget(EntryViewerStack widget) { widget.collection?.addListener(_onCollectionChange); } - void _unregisterWidget(FullscreenBody widget) { + void _unregisterWidget(EntryViewerStack widget) { widget.collection?.removeListener(_onCollectionChange); } @@ -166,10 +171,11 @@ class FullscreenBodyState extends State with SingleTickerProvide }, child: Stack( children: [ - FullscreenVerticalPageView( + ViewerVerticalPageView( collection: collection, entryNotifier: _entryNotifier, videoControllers: _videoControllers, + multiPageControllers: _multiPageControllers, verticalPager: _verticalPager, horizontalPager: _horizontalPager, onVerticalPageChanged: _onVerticalPageChanged, @@ -180,6 +186,7 @@ class FullscreenBodyState extends State with SingleTickerProvide ), _buildTopOverlay(), _buildBottomOverlay(), + BottomGestureAreaProtector(), ], ), ), @@ -196,8 +203,11 @@ class FullscreenBodyState extends State with SingleTickerProvide valueListenable: _entryNotifier, builder: (context, entry, child) { if (entry == null) return SizedBox.shrink(); + + final multiPageController = _getMultiPageController(entry); + final viewStateNotifier = _viewStateNotifiers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2; - return FullscreenTopOverlay( + return ViewerTopOverlay( entry: entry, scale: _topOverlayScale, canToggleFavourite: hasCollection, @@ -205,6 +215,7 @@ class FullscreenBodyState extends State with SingleTickerProvide viewPadding: _frozenViewPadding, onActionSelected: (action) => _actionDelegate.onActionSelected(context, entry, action), viewStateNotifier: viewStateNotifier, + multiPageController: multiPageController, ); }, ); @@ -226,6 +237,8 @@ class FullscreenBodyState extends State with SingleTickerProvide builder: (context, entry, child) { if (entry == null) return SizedBox.shrink(); + final multiPageController = _getMultiPageController(entry); + Widget extraBottomOverlay; if (entry.isVideo) { final videoController = _videoControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2; @@ -253,12 +266,13 @@ class FullscreenBodyState extends State with SingleTickerProvide ), SlideTransition( position: _bottomOverlayOffset, - child: FullscreenBottomOverlay( + child: ViewerBottomOverlay( entries: entries, index: _currentHorizontalPage, showPosition: hasCollection, viewInsets: _frozenViewInsets, viewPadding: _frozenViewPadding, + multiPageController: multiPageController, ), ), ], @@ -285,6 +299,8 @@ class FullscreenBodyState extends State with SingleTickerProvide return AnimatedBuilder( animation: _verticalScrollNotifier, builder: (context, child) => Positioned( + // TODO TLAD replace when using Flutter version adapted for null safety + // bottom: (_verticalPager.position.hasPixels ? _verticalPager.offset : 0) - mqHeight, bottom: (_verticalPager.offset ?? 0) - mqHeight, child: child, ), @@ -296,6 +312,10 @@ class FullscreenBodyState extends State with SingleTickerProvide return bottomOverlay; } + MultiPageController _getMultiPageController(ImageEntry entry) { + return entry.isMultipage ? _multiPageControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2 : null; + } + void _onVerticalPageControllerChange() { _verticalScrollNotifier.notifyListeners(); } @@ -315,7 +335,7 @@ class FullscreenBodyState extends State with SingleTickerProvide Future _goToVerticalPage(int page) { return _verticalPager.animateToPage( page, - duration: Durations.fullscreenPageAnimation, + duration: Durations.viewerPageAnimation, curve: Curves.easeInOut, ); } @@ -356,11 +376,11 @@ class FullscreenBodyState extends State with SingleTickerProvide void _onLeave() { if (Navigator.canPop(context)) { _showSystemUI(); - if (settings.keepScreenOn == KeepScreenOn.fullscreenOnly) { + if (settings.keepScreenOn == KeepScreenOn.viewerOnly) { Screen.keepOn(false); } } else { - // exit app when trying to pop a fullscreen page that is a viewer for a single entry + // exit app when trying to pop a viewer page for a single entry SystemNavigator.pop(); } } @@ -428,6 +448,14 @@ class FullscreenBodyState extends State with SingleTickerProvide (_) => _.dispose(), ); } + if (entry.isMultipage) { + _initViewSpecificController( + uri, + _multiPageControllers, + () => MultiPageController(entry), + (_) => _.dispose(), + ); + } setState(() {}); } @@ -448,19 +476,21 @@ class FullscreenBodyState extends State with SingleTickerProvide void _pauseVideoControllers() => _videoControllers.forEach((e) => e.item2.pause()); } -class FullscreenVerticalPageView extends StatefulWidget { +class ViewerVerticalPageView extends StatefulWidget { final CollectionLens collection; final ValueNotifier entryNotifier; final List> videoControllers; + final List> multiPageControllers; final PageController horizontalPager, verticalPager; final void Function(int page) onVerticalPageChanged, onHorizontalPageChanged; final VoidCallback onImageTap, onImagePageRequested; final void Function(String uri) onViewDisposed; - const FullscreenVerticalPageView({ + const ViewerVerticalPageView({ @required this.collection, @required this.entryNotifier, @required this.videoControllers, + @required this.multiPageControllers, @required this.verticalPager, @required this.horizontalPager, @required this.onVerticalPageChanged, @@ -471,10 +501,10 @@ class FullscreenVerticalPageView extends StatefulWidget { }); @override - _FullscreenVerticalPageViewState createState() => _FullscreenVerticalPageViewState(); + _ViewerVerticalPageViewState createState() => _ViewerVerticalPageViewState(); } -class _FullscreenVerticalPageViewState extends State { +class _ViewerVerticalPageViewState extends State { final ValueNotifier _backgroundColorNotifier = ValueNotifier(Colors.black); final ValueNotifier _infoPageVisibleNotifier = ValueNotifier(false); ImageEntry _oldEntry; @@ -492,7 +522,7 @@ class _FullscreenVerticalPageViewState extends State } @override - void didUpdateWidget(FullscreenVerticalPageView oldWidget) { + void didUpdateWidget(covariant ViewerVerticalPageView oldWidget) { super.didUpdateWidget(oldWidget); _unregisterWidget(oldWidget); _registerWidget(widget); @@ -504,13 +534,13 @@ class _FullscreenVerticalPageViewState extends State super.dispose(); } - void _registerWidget(FullscreenVerticalPageView widget) { + void _registerWidget(ViewerVerticalPageView widget) { widget.verticalPager.addListener(_onVerticalPageControllerChanged); widget.entryNotifier.addListener(_onEntryChanged); if (_oldEntry != entry) _onEntryChanged(); } - void _unregisterWidget(FullscreenVerticalPageView widget) { + void _unregisterWidget(ViewerVerticalPageView widget) { widget.verticalPager.removeListener(_onVerticalPageControllerChanged); widget.entryNotifier.removeListener(_onEntryChanged); _oldEntry?.imageChangeNotifier?.removeListener(_onImageChanged); @@ -519,21 +549,23 @@ class _FullscreenVerticalPageViewState extends State @override Widget build(BuildContext context) { final pages = [ - // fake page for opacity transition between collection and fullscreen views + // fake page for opacity transition between collection and viewer SizedBox(), hasCollection - ? MultiImagePage( + ? MultiEntryScroller( collection: collection, pageController: widget.horizontalPager, onTap: widget.onImageTap, onPageChanged: widget.onHorizontalPageChanged, videoControllers: widget.videoControllers, + multiPageControllers: widget.multiPageControllers, onViewDisposed: widget.onViewDisposed, ) - : SingleImagePage( + : SingleEntryScroller( entry: entry, onTap: widget.onImageTap, videoControllers: widget.videoControllers, + multiPageControllers: widget.multiPageControllers, ), NotificationListener( onNotification: (notification) { diff --git a/lib/widgets/fullscreen/info/basic_section.dart b/lib/widgets/viewer/info/basic_section.dart similarity index 90% rename from lib/widgets/fullscreen/info/basic_section.dart rename to lib/widgets/viewer/info/basic_section.dart index c14b92088..bab656c2b 100644 --- a/lib/widgets/fullscreen/info/basic_section.dart +++ b/lib/widgets/viewer/info/basic_section.dart @@ -9,7 +9,7 @@ import 'package:aves/ref/mime_types.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/utils/file_utils.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; -import 'package:aves/widgets/fullscreen/info/common.dart'; +import 'package:aves/widgets/viewer/info/common.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; @@ -26,12 +26,16 @@ class BasicSection extends StatelessWidget { @required this.onFilter, }) : super(key: key); + int get megaPixels => entry.megaPixels; + + bool get showMegaPixels => entry.isPhoto && megaPixels != null && megaPixels > 0; + + String get rasterResolutionText => '${entry.getResolutionText()}${showMegaPixels ? ' ($megaPixels MP)' : ''}'; + @override Widget build(BuildContext context) { final date = entry.bestDate; final dateText = date != null ? '${DateFormat.yMMMd().format(date)} • ${DateFormat.Hm().format(date)}' : Constants.infoUnknown; - final showMegaPixels = entry.isPhoto && entry.megaPixels != null && entry.megaPixels > 0; - final resolutionText = '${entry.resolutionText}${showMegaPixels ? ' (${entry.megaPixels} MP)' : ''}'; // TODO TLAD line break on all characters for the following fields when this is fixed: https://github.com/flutter/flutter/issues/61081 // inserting ZWSP (\u200B) between characters does help, but it messes with width and height computation (another Flutter issue) @@ -46,7 +50,7 @@ class BasicSection extends StatelessWidget { 'Title': title, 'Date': dateText, if (entry.isVideo) ..._buildVideoRows(), - if (!entry.isSvg) 'Resolution': resolutionText, + if (!entry.isSvg) 'Resolution': rasterResolutionText, 'Size': entry.sizeBytes != null ? formatFilesize(entry.sizeBytes) : Constants.infoUnknown, 'URI': uri, if (path != null) 'Path': path, diff --git a/lib/widgets/fullscreen/info/common.dart b/lib/widgets/viewer/info/common.dart similarity index 100% rename from lib/widgets/fullscreen/info/common.dart rename to lib/widgets/viewer/info/common.dart diff --git a/lib/widgets/fullscreen/info/info_app_bar.dart b/lib/widgets/viewer/info/info_app_bar.dart similarity index 90% rename from lib/widgets/fullscreen/info/info_app_bar.dart rename to lib/widgets/viewer/info/info_app_bar.dart index a993b40cb..df0eee399 100644 --- a/lib/widgets/fullscreen/info/info_app_bar.dart +++ b/lib/widgets/viewer/info/info_app_bar.dart @@ -1,8 +1,8 @@ import 'package:aves/model/image_entry.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/app_bar_title.dart'; -import 'package:aves/widgets/fullscreen/info/info_search.dart'; -import 'package:aves/widgets/fullscreen/info/metadata/metadata_section.dart'; +import 'package:aves/widgets/viewer/info/info_search.dart'; +import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart'; import 'package:flutter/material.dart'; class InfoAppBar extends StatelessWidget { diff --git a/lib/widgets/fullscreen/info/info_page.dart b/lib/widgets/viewer/info/info_page.dart similarity index 73% rename from lib/widgets/fullscreen/info/info_page.dart rename to lib/widgets/viewer/info/info_page.dart index 8117cdea2..7f6bf741c 100644 --- a/lib/widgets/fullscreen/info/info_page.dart +++ b/lib/widgets/viewer/info/info_page.dart @@ -2,12 +2,13 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/image_entry.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/theme/durations.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/fullscreen/info/basic_section.dart'; -import 'package:aves/widgets/fullscreen/info/info_app_bar.dart'; -import 'package:aves/widgets/fullscreen/info/location_section.dart'; -import 'package:aves/widgets/fullscreen/info/metadata/metadata_section.dart'; -import 'package:aves/widgets/fullscreen/info/notifications.dart'; +import 'package:aves/widgets/viewer/info/basic_section.dart'; +import 'package:aves/widgets/viewer/info/info_app_bar.dart'; +import 'package:aves/widgets/viewer/info/location_section.dart'; +import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart'; +import 'package:aves/widgets/viewer/info/notifications.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; @@ -25,10 +26,10 @@ class InfoPage extends StatefulWidget { }) : super(key: key); @override - State createState() => InfoPageState(); + State createState() => _InfoPageState(); } -class InfoPageState extends State { +class _InfoPageState extends State { final ScrollController _scrollController = ScrollController(); bool _scrollStartFromTop = false; @@ -40,31 +41,33 @@ class InfoPageState extends State { Widget build(BuildContext context) { return MediaQueryDataProvider( child: Scaffold( - body: SafeArea( - child: NotificationListener( - onNotification: _handleTopScroll, - child: Selector>( - selector: (c, mq) => Tuple2(mq.size.width, mq.viewInsets.bottom), - builder: (c, mq, child) { - final mqWidth = mq.item1; - final mqViewInsetsBottom = mq.item2; - return ValueListenableBuilder( - valueListenable: widget.entryNotifier, - builder: (context, entry, child) { - return entry != null - ? _InfoPageContent( - collection: collection, - entry: entry, - visibleNotifier: widget.visibleNotifier, - scrollController: _scrollController, - split: mqWidth > 400, - mqViewInsetsBottom: mqViewInsetsBottom, - goToViewer: _goToViewer, - ) - : SizedBox.shrink(); - }, - ); - }, + body: GestureAreaProtectorStack( + child: SafeArea( + child: NotificationListener( + onNotification: _handleTopScroll, + child: Selector>( + selector: (c, mq) => Tuple2(mq.size.width, mq.viewInsets.bottom), + builder: (c, mq, child) { + final mqWidth = mq.item1; + final mqViewInsetsBottom = mq.item2; + return ValueListenableBuilder( + valueListenable: widget.entryNotifier, + builder: (context, entry, child) { + return entry != null + ? _InfoPageContent( + collection: collection, + entry: entry, + visibleNotifier: widget.visibleNotifier, + scrollController: _scrollController, + split: mqWidth > 400, + mqViewInsetsBottom: mqViewInsetsBottom, + goToViewer: _goToViewer, + ) + : SizedBox.shrink(); + }, + ); + }, + ), ), ), ), @@ -99,7 +102,7 @@ class InfoPageState extends State { BackUpNotification().dispatch(context); _scrollController.animateTo( 0, - duration: Durations.fullscreenPageAnimation, + duration: Durations.viewerPageAnimation, curve: Curves.easeInOut, ); } diff --git a/lib/widgets/fullscreen/info/info_search.dart b/lib/widgets/viewer/info/info_search.dart similarity index 95% rename from lib/widgets/fullscreen/info/info_search.dart rename to lib/widgets/viewer/info/info_search.dart index d28c49a6a..0e12b4276 100644 --- a/lib/widgets/fullscreen/info/info_search.dart +++ b/lib/widgets/viewer/info/info_search.dart @@ -1,8 +1,8 @@ import 'package:aves/model/image_entry.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/collection/empty.dart'; -import 'package:aves/widgets/fullscreen/info/metadata/metadata_dir_tile.dart'; -import 'package:aves/widgets/fullscreen/info/metadata/metadata_section.dart'; +import 'package:aves/widgets/viewer/info/metadata/metadata_dir_tile.dart'; +import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; diff --git a/lib/widgets/fullscreen/info/location_section.dart b/lib/widgets/viewer/info/location_section.dart similarity index 94% rename from lib/widgets/fullscreen/info/location_section.dart rename to lib/widgets/viewer/info/location_section.dart index e6d1475d7..3341d3d54 100644 --- a/lib/widgets/fullscreen/info/location_section.dart +++ b/lib/widgets/viewer/info/location_section.dart @@ -6,11 +6,11 @@ import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; -import 'package:aves/widgets/fullscreen/info/common.dart'; -import 'package:aves/widgets/fullscreen/info/maps/common.dart'; -import 'package:aves/widgets/fullscreen/info/maps/google_map.dart'; -import 'package:aves/widgets/fullscreen/info/maps/leaflet_map.dart'; -import 'package:aves/widgets/fullscreen/info/maps/marker.dart'; +import 'package:aves/widgets/viewer/info/common.dart'; +import 'package:aves/widgets/viewer/info/maps/common.dart'; +import 'package:aves/widgets/viewer/info/maps/google_map.dart'; +import 'package:aves/widgets/viewer/info/maps/leaflet_map.dart'; +import 'package:aves/widgets/viewer/info/maps/marker.dart'; import 'package:flutter/material.dart'; import 'package:tuple/tuple.dart'; @@ -51,7 +51,7 @@ class _LocationSectionState extends State with TickerProviderSt } @override - void didUpdateWidget(LocationSection oldWidget) { + void didUpdateWidget(covariant LocationSection oldWidget) { super.didUpdateWidget(oldWidget); _unregisterWidget(oldWidget); _registerWidget(widget); diff --git a/lib/widgets/fullscreen/info/maps/common.dart b/lib/widgets/viewer/info/maps/common.dart similarity index 97% rename from lib/widgets/fullscreen/info/maps/common.dart rename to lib/widgets/viewer/info/maps/common.dart index 452d6ef00..fb8fc89c1 100644 --- a/lib/widgets/fullscreen/info/maps/common.dart +++ b/lib/widgets/viewer/info/maps/common.dart @@ -6,8 +6,8 @@ import 'package:aves/widgets/common/fx/blurred.dart'; import 'package:aves/widgets/common/fx/borders.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; -import 'package:aves/widgets/fullscreen/info/location_section.dart'; -import 'package:aves/widgets/fullscreen/overlay/common.dart'; +import 'package:aves/widgets/viewer/info/location_section.dart'; +import 'package:aves/widgets/viewer/overlay/common.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; diff --git a/lib/widgets/fullscreen/info/maps/google_map.dart b/lib/widgets/viewer/info/maps/google_map.dart similarity index 90% rename from lib/widgets/fullscreen/info/maps/google_map.dart rename to lib/widgets/viewer/info/maps/google_map.dart index 2d70b77f1..0c6e05bd7 100644 --- a/lib/widgets/fullscreen/info/maps/google_map.dart +++ b/lib/widgets/viewer/info/maps/google_map.dart @@ -2,9 +2,9 @@ import 'dart:async'; import 'dart:typed_data'; import 'package:aves/model/settings/settings.dart'; -import 'package:aves/widgets/fullscreen/info/location_section.dart'; -import 'package:aves/widgets/fullscreen/info/maps/common.dart'; -import 'package:aves/widgets/fullscreen/info/maps/marker.dart'; +import 'package:aves/widgets/viewer/info/location_section.dart'; +import 'package:aves/widgets/viewer/info/maps/common.dart'; +import 'package:aves/widgets/viewer/info/maps/marker.dart'; import 'package:flutter/material.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; import 'package:tuple/tuple.dart'; @@ -27,10 +27,10 @@ class EntryGoogleMap extends StatefulWidget { super(key: key); @override - State createState() => EntryGoogleMapState(); + State createState() => _EntryGoogleMapState(); } -class EntryGoogleMapState extends State with AutomaticKeepAliveClientMixin { +class _EntryGoogleMapState extends State with AutomaticKeepAliveClientMixin { GoogleMapController _controller; Completer _markerLoaderCompleter; @@ -41,7 +41,7 @@ class EntryGoogleMapState extends State with AutomaticKeepAliveC } @override - void didUpdateWidget(EntryGoogleMap oldWidget) { + void didUpdateWidget(covariant EntryGoogleMap oldWidget) { super.didUpdateWidget(oldWidget); if (widget.latLng != oldWidget.latLng && _controller != null) { _controller.moveCamera(CameraUpdate.newLatLng(widget.latLng)); diff --git a/lib/widgets/fullscreen/info/maps/leaflet_map.dart b/lib/widgets/viewer/info/maps/leaflet_map.dart similarity index 93% rename from lib/widgets/fullscreen/info/maps/leaflet_map.dart rename to lib/widgets/viewer/info/maps/leaflet_map.dart index 84c8bc203..02f53dbd8 100644 --- a/lib/widgets/fullscreen/info/maps/leaflet_map.dart +++ b/lib/widgets/viewer/info/maps/leaflet_map.dart @@ -1,6 +1,6 @@ import 'package:aves/model/settings/settings.dart'; -import 'package:aves/widgets/fullscreen/info/maps/common.dart'; -import 'package:aves/widgets/fullscreen/info/maps/scale_layer.dart'; +import 'package:aves/widgets/viewer/info/maps/common.dart'; +import 'package:aves/widgets/viewer/info/maps/scale_layer.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; @@ -28,14 +28,14 @@ class EntryLeafletMap extends StatefulWidget { }) : super(key: key); @override - State createState() => EntryLeafletMapState(); + State createState() => _EntryLeafletMapState(); } -class EntryLeafletMapState extends State with AutomaticKeepAliveClientMixin, TickerProviderStateMixin { +class _EntryLeafletMapState extends State with AutomaticKeepAliveClientMixin, TickerProviderStateMixin { final MapController _mapController = MapController(); @override - void didUpdateWidget(EntryLeafletMap oldWidget) { + void didUpdateWidget(covariant EntryLeafletMap oldWidget) { super.didUpdateWidget(oldWidget); if (widget.latLng != oldWidget.latLng && _mapController != null) { _mapController.move(widget.latLng, settings.infoMapZoom); diff --git a/lib/widgets/fullscreen/info/maps/marker.dart b/lib/widgets/viewer/info/maps/marker.dart similarity index 97% rename from lib/widgets/fullscreen/info/maps/marker.dart rename to lib/widgets/viewer/info/maps/marker.dart index a640ae731..9e3a42629 100644 --- a/lib/widgets/fullscreen/info/maps/marker.dart +++ b/lib/widgets/viewer/info/maps/marker.dart @@ -27,11 +27,11 @@ class ImageMarker extends StatelessWidget { @override Widget build(BuildContext context) { final thumbnail = entry.isSvg - ? ThumbnailVectorImage( + ? VectorImageThumbnail( entry: entry, extent: extent, ) - : ThumbnailRasterImage( + : RasterImageThumbnail( entry: entry, extent: extent, ); @@ -116,7 +116,7 @@ class MarkerPointerPainter extends CustomPainter { } @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => false; + bool shouldRepaint(CustomPainter oldDelegate) => false; } // generate bitmap from widget, for Google Maps diff --git a/lib/widgets/fullscreen/info/maps/scale_layer.dart b/lib/widgets/viewer/info/maps/scale_layer.dart similarity index 100% rename from lib/widgets/fullscreen/info/maps/scale_layer.dart rename to lib/widgets/viewer/info/maps/scale_layer.dart diff --git a/lib/widgets/fullscreen/info/maps/scalebar_utils.dart b/lib/widgets/viewer/info/maps/scalebar_utils.dart similarity index 100% rename from lib/widgets/fullscreen/info/maps/scalebar_utils.dart rename to lib/widgets/viewer/info/maps/scalebar_utils.dart diff --git a/lib/widgets/fullscreen/info/metadata/metadata_dir_tile.dart b/lib/widgets/viewer/info/metadata/metadata_dir_tile.dart similarity index 91% rename from lib/widgets/fullscreen/info/metadata/metadata_dir_tile.dart rename to lib/widgets/viewer/info/metadata/metadata_dir_tile.dart index af980220f..0ea17ab08 100644 --- a/lib/widgets/fullscreen/info/metadata/metadata_dir_tile.dart +++ b/lib/widgets/viewer/info/metadata/metadata_dir_tile.dart @@ -7,11 +7,11 @@ import 'package:aves/theme/icons.dart'; import 'package:aves/utils/color_utils.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; -import 'package:aves/widgets/fullscreen/info/common.dart'; -import 'package:aves/widgets/fullscreen/info/metadata/metadata_section.dart'; -import 'package:aves/widgets/fullscreen/info/metadata/metadata_thumbnail.dart'; -import 'package:aves/widgets/fullscreen/info/metadata/xmp_tile.dart'; -import 'package:aves/widgets/fullscreen/source_viewer_page.dart'; +import 'package:aves/widgets/viewer/info/common.dart'; +import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart'; +import 'package:aves/widgets/viewer/info/metadata/metadata_thumbnail.dart'; +import 'package:aves/widgets/viewer/info/metadata/xmp_tile.dart'; +import 'package:aves/widgets/viewer/source_viewer_page.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; diff --git a/lib/widgets/fullscreen/info/metadata/metadata_section.dart b/lib/widgets/viewer/info/metadata/metadata_section.dart similarity index 97% rename from lib/widgets/fullscreen/info/metadata/metadata_section.dart rename to lib/widgets/viewer/info/metadata/metadata_section.dart index 96a6475b5..6cba39d94 100644 --- a/lib/widgets/fullscreen/info/metadata/metadata_section.dart +++ b/lib/widgets/viewer/info/metadata/metadata_section.dart @@ -5,8 +5,8 @@ import 'package:aves/services/metadata_service.dart'; import 'package:aves/services/svg_metadata_service.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; -import 'package:aves/widgets/fullscreen/info/common.dart'; -import 'package:aves/widgets/fullscreen/info/metadata/metadata_dir_tile.dart'; +import 'package:aves/widgets/viewer/info/common.dart'; +import 'package:aves/widgets/viewer/info/metadata/metadata_dir_tile.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -52,7 +52,7 @@ class _MetadataSectionSliverState extends State with Auto } @override - void didUpdateWidget(MetadataSectionSliver oldWidget) { + void didUpdateWidget(covariant MetadataSectionSliver oldWidget) { super.didUpdateWidget(oldWidget); _unregisterWidget(oldWidget); _registerWidget(widget); diff --git a/lib/widgets/fullscreen/info/metadata/metadata_thumbnail.dart b/lib/widgets/viewer/info/metadata/metadata_thumbnail.dart similarity index 100% rename from lib/widgets/fullscreen/info/metadata/metadata_thumbnail.dart rename to lib/widgets/viewer/info/metadata/metadata_thumbnail.dart diff --git a/lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart b/lib/widgets/viewer/info/metadata/xmp_namespaces.dart similarity index 98% rename from lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart rename to lib/widgets/viewer/info/metadata/xmp_namespaces.dart index 459676466..fd8f4f4c9 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart +++ b/lib/widgets/viewer/info/metadata/xmp_namespaces.dart @@ -3,7 +3,7 @@ import 'package:aves/ref/xmp.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/utils/string_utils.dart'; import 'package:aves/widgets/common/identity/highlight_title.dart'; -import 'package:aves/widgets/fullscreen/info/common.dart'; +import 'package:aves/widgets/viewer/info/common.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; diff --git a/lib/widgets/fullscreen/info/metadata/xmp_ns/exif.dart b/lib/widgets/viewer/info/metadata/xmp_ns/exif.dart similarity index 97% rename from lib/widgets/fullscreen/info/metadata/xmp_ns/exif.dart rename to lib/widgets/viewer/info/metadata/xmp_ns/exif.dart index 168401ce5..76ac8c865 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_ns/exif.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/exif.dart @@ -1,5 +1,5 @@ import 'package:aves/ref/exif.dart'; -import 'package:aves/widgets/fullscreen/info/metadata/xmp_namespaces.dart'; +import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart'; // cf https://github.com/adobe/xmp-docs/blob/master/XMPNamespaces/exif.md class XmpExifNamespace extends XmpNamespace { diff --git a/lib/widgets/fullscreen/info/metadata/xmp_ns/google.dart b/lib/widgets/viewer/info/metadata/xmp_ns/google.dart similarity index 93% rename from lib/widgets/fullscreen/info/metadata/xmp_ns/google.dart rename to lib/widgets/viewer/info/metadata/xmp_ns/google.dart index ee9154591..cccb66342 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_ns/google.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/google.dart @@ -1,5 +1,5 @@ -import 'package:aves/widgets/fullscreen/info/common.dart'; -import 'package:aves/widgets/fullscreen/info/metadata/xmp_namespaces.dart'; +import 'package:aves/widgets/viewer/info/common.dart'; +import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart'; import 'package:tuple/tuple.dart'; abstract class XmpGoogleNamespace extends XmpNamespace { diff --git a/lib/widgets/fullscreen/info/metadata/xmp_ns/iptc.dart b/lib/widgets/viewer/info/metadata/xmp_ns/iptc.dart similarity index 82% rename from lib/widgets/fullscreen/info/metadata/xmp_ns/iptc.dart rename to lib/widgets/viewer/info/metadata/xmp_ns/iptc.dart index 5876ce30c..7ccd3feaf 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_ns/iptc.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/iptc.dart @@ -1,5 +1,5 @@ -import 'package:aves/widgets/fullscreen/info/metadata/xmp_namespaces.dart'; -import 'package:aves/widgets/fullscreen/info/metadata/xmp_structs.dart'; +import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart'; +import 'package:aves/widgets/viewer/info/metadata/xmp_structs.dart'; import 'package:flutter/material.dart'; class XmpIptcCoreNamespace extends XmpNamespace { diff --git a/lib/widgets/fullscreen/info/metadata/xmp_ns/photoshop.dart b/lib/widgets/viewer/info/metadata/xmp_ns/photoshop.dart similarity index 93% rename from lib/widgets/fullscreen/info/metadata/xmp_ns/photoshop.dart rename to lib/widgets/viewer/info/metadata/xmp_ns/photoshop.dart index b8241caa9..0b0399bab 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_ns/photoshop.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/photoshop.dart @@ -1,6 +1,6 @@ // cf photoshop:ColorMode // cf https://github.com/adobe/xmp-docs/blob/master/XMPNamespaces/photoshop.md -import 'package:aves/widgets/fullscreen/info/metadata/xmp_namespaces.dart'; +import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart'; class XmpPhotoshopNamespace extends XmpNamespace { static const ns = 'photoshop'; diff --git a/lib/widgets/fullscreen/info/metadata/xmp_ns/tiff.dart b/lib/widgets/viewer/info/metadata/xmp_ns/tiff.dart similarity index 92% rename from lib/widgets/fullscreen/info/metadata/xmp_ns/tiff.dart rename to lib/widgets/viewer/info/metadata/xmp_ns/tiff.dart index 6083d5a39..fbf712f6e 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_ns/tiff.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/tiff.dart @@ -1,5 +1,5 @@ import 'package:aves/ref/exif.dart'; -import 'package:aves/widgets/fullscreen/info/metadata/xmp_namespaces.dart'; +import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart'; // cf https://github.com/adobe/xmp-docs/blob/master/XMPNamespaces/tiff.md class XmpTiffNamespace extends XmpNamespace { diff --git a/lib/widgets/fullscreen/info/metadata/xmp_ns/xmp.dart b/lib/widgets/viewer/info/metadata/xmp_ns/xmp.dart similarity index 94% rename from lib/widgets/fullscreen/info/metadata/xmp_ns/xmp.dart rename to lib/widgets/viewer/info/metadata/xmp_ns/xmp.dart index 2c73826c6..70a57de2c 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_ns/xmp.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/xmp.dart @@ -1,7 +1,7 @@ import 'package:aves/ref/mime_types.dart'; -import 'package:aves/widgets/fullscreen/info/common.dart'; -import 'package:aves/widgets/fullscreen/info/metadata/xmp_namespaces.dart'; -import 'package:aves/widgets/fullscreen/info/metadata/xmp_structs.dart'; +import 'package:aves/widgets/viewer/info/common.dart'; +import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart'; +import 'package:aves/widgets/viewer/info/metadata/xmp_structs.dart'; import 'package:flutter/material.dart'; class XmpBasicNamespace extends XmpNamespace { diff --git a/lib/widgets/fullscreen/info/metadata/xmp_structs.dart b/lib/widgets/viewer/info/metadata/xmp_structs.dart similarity index 98% rename from lib/widgets/fullscreen/info/metadata/xmp_structs.dart rename to lib/widgets/viewer/info/metadata/xmp_structs.dart index efb9aceba..8bd946184 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_structs.dart +++ b/lib/widgets/viewer/info/metadata/xmp_structs.dart @@ -5,7 +5,7 @@ import 'package:aves/theme/icons.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/basic/multi_cross_fader.dart'; import 'package:aves/widgets/common/identity/highlight_title.dart'; -import 'package:aves/widgets/fullscreen/info/common.dart'; +import 'package:aves/widgets/viewer/info/common.dart'; import 'package:flutter/material.dart'; class XmpStructArrayCard extends StatefulWidget { diff --git a/lib/widgets/fullscreen/info/metadata/xmp_tile.dart b/lib/widgets/viewer/info/metadata/xmp_tile.dart similarity index 85% rename from lib/widgets/fullscreen/info/metadata/xmp_tile.dart rename to lib/widgets/viewer/info/metadata/xmp_tile.dart index b0f8f0bf5..cbd1c5c1a 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_tile.dart +++ b/lib/widgets/viewer/info/metadata/xmp_tile.dart @@ -9,14 +9,14 @@ import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/behaviour/routes.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; -import 'package:aves/widgets/fullscreen/fullscreen_page.dart'; -import 'package:aves/widgets/fullscreen/info/metadata/xmp_namespaces.dart'; -import 'package:aves/widgets/fullscreen/info/metadata/xmp_ns/exif.dart'; -import 'package:aves/widgets/fullscreen/info/metadata/xmp_ns/google.dart'; -import 'package:aves/widgets/fullscreen/info/metadata/xmp_ns/iptc.dart'; -import 'package:aves/widgets/fullscreen/info/metadata/xmp_ns/photoshop.dart'; -import 'package:aves/widgets/fullscreen/info/metadata/xmp_ns/tiff.dart'; -import 'package:aves/widgets/fullscreen/info/metadata/xmp_ns/xmp.dart'; +import 'package:aves/widgets/viewer/entry_viewer_page.dart'; +import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart'; +import 'package:aves/widgets/viewer/info/metadata/xmp_ns/exif.dart'; +import 'package:aves/widgets/viewer/info/metadata/xmp_ns/google.dart'; +import 'package:aves/widgets/viewer/info/metadata/xmp_ns/iptc.dart'; +import 'package:aves/widgets/viewer/info/metadata/xmp_ns/photoshop.dart'; +import 'package:aves/widgets/viewer/info/metadata/xmp_ns/tiff.dart'; +import 'package:aves/widgets/viewer/info/metadata/xmp_ns/xmp.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:pedantic/pedantic.dart'; @@ -127,8 +127,8 @@ class _XmpDirTileState extends State with FeedbackMixin { unawaited(Navigator.push( context, TransparentMaterialPageRoute( - settings: RouteSettings(name: SingleFullscreenPage.routeName), - pageBuilder: (c, a, sa) => SingleFullscreenPage(entry: embedEntry), + settings: RouteSettings(name: SingleEntryViewerPage.routeName), + pageBuilder: (c, a, sa) => SingleEntryViewerPage(entry: embedEntry), ), )); } diff --git a/lib/widgets/fullscreen/info/notifications.dart b/lib/widgets/viewer/info/notifications.dart similarity index 100% rename from lib/widgets/fullscreen/info/notifications.dart rename to lib/widgets/viewer/info/notifications.dart diff --git a/lib/widgets/viewer/multipage.dart b/lib/widgets/viewer/multipage.dart new file mode 100644 index 000000000..004ff7a49 --- /dev/null +++ b/lib/widgets/viewer/multipage.dart @@ -0,0 +1,24 @@ +import 'dart:async'; + +import 'package:aves/model/image_entry.dart'; +import 'package:aves/model/multipage.dart'; +import 'package:aves/services/metadata_service.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class MultiPageController extends ChangeNotifier { + final Future info; + final ValueNotifier pageNotifier = ValueNotifier(0); + + MultiPageController(ImageEntry entry) : info = MetadataService.getMultiPageInfo(entry); + + int get page => pageNotifier.value; + + set page(int page) => pageNotifier.value = page; + + @override + void dispose() { + pageNotifier.dispose(); + super.dispose(); + } +} diff --git a/lib/widgets/viewer/overlay/bottom.dart b/lib/widgets/viewer/overlay/bottom.dart new file mode 100644 index 000000000..9863c3793 --- /dev/null +++ b/lib/widgets/viewer/overlay/bottom.dart @@ -0,0 +1,456 @@ +import 'dart:math'; + +import 'package:aves/model/image_entry.dart'; +import 'package:aves/model/image_metadata.dart'; +import 'package:aves/model/multipage.dart'; +import 'package:aves/model/settings/coordinate_format.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/services/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/viewer/multipage.dart'; +import 'package:aves/widgets/viewer/overlay/common.dart'; +import 'package:aves/widgets/viewer/overlay/multipage.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 ViewerBottomOverlay extends StatefulWidget { + final List entries; + final int index; + final bool showPosition; + final EdgeInsets viewInsets, viewPadding; + final MultiPageController multiPageController; + + const ViewerBottomOverlay({ + Key key, + @required this.entries, + @required this.index, + @required this.showPosition, + this.viewInsets, + this.viewPadding, + @required this.multiPageController, + }) : super(key: key); + + @override + State createState() => _ViewerBottomOverlayState(); +} + +class _ViewerBottomOverlayState extends State { + Future _detailLoader; + ImageEntry _lastEntry; + OverlayMetadata _lastDetails; + + ImageEntry get entry { + final entries = widget.entries; + final index = widget.index; + return index < entries.length ? entries[index] : null; + } + + MultiPageController get multiPageController => widget.multiPageController; + + @override + void initState() { + super.initState(); + _initDetailLoader(); + } + + @override + void didUpdateWidget(covariant ViewerBottomOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + if (entry != _lastEntry) { + _initDetailLoader(); + } + } + + void _initDetailLoader() { + _detailLoader = MetadataService.getOverlayMetadata(entry); + } + + @override + Widget build(BuildContext context) { + final hasEdgeContent = settings.showOverlayInfo || multiPageController != null; + return BlurredRect( + enabled: hasEdgeContent, + child: Selector>( + 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 availableWidth = mqWidth - viewPadding.horizontal; + + return Container( + color: hasEdgeContent ? kOverlayBackgroundColor : Colors.transparent, + padding: viewInsets + viewPadding.copyWith(top: 0), + child: FutureBuilder( + future: _detailLoader, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done && !snapshot.hasError) { + _lastDetails = snapshot.data; + _lastEntry = entry; + } + return _lastEntry == null + ? SizedBox.shrink() + : _BottomOverlayContent( + entry: _lastEntry, + details: _lastDetails, + position: widget.showPosition ? '${widget.index + 1}/${widget.entries.length}' : null, + availableWidth: availableWidth, + multiPageController: multiPageController, + ); + }, + ), + ); + }, + ), + ); + } +} + +const double _iconPadding = 8.0; +const double _iconSize = 16.0; +const double _interRowPadding = 2.0; +const double _subRowMinWidth = 300.0; + +class _BottomOverlayContent extends AnimatedWidget { + final ImageEntry entry; + final OverlayMetadata details; + final String position; + final double availableWidth; + final MultiPageController multiPageController; + + static const infoPadding = EdgeInsets.symmetric(vertical: 4, horizontal: 8); + + _BottomOverlayContent({ + Key key, + this.entry, + this.details, + this.position, + this.availableWidth, + this.multiPageController, + }) : 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: availableWidth, + child: Selector( + selector: (c, mq) => mq.orientation, + builder: (c, orientation, child) { + Widget infoColumn; + + if (settings.showOverlayInfo) { + infoColumn = _buildInfoColumn(orientation); + } + + if (multiPageController != null) { + infoColumn = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MultiPageOverlay( + entry: entry, + controller: multiPageController, + availableWidth: availableWidth, + ), + if (infoColumn != null) infoColumn, + ], + ); + } + + return infoColumn ?? SizedBox(); + }, + ), + ), + ); + } + + Widget _buildInfoColumn(Orientation orientation) { + final infoMaxWidth = availableWidth - infoPadding.horizontal; + final twoColumns = orientation == Orientation.landscape && infoMaxWidth / 2 > _subRowMinWidth; + final subRowWidth = twoColumns ? min(_subRowMinWidth, infoMaxWidth / 2) : infoMaxWidth; + final positionTitle = _PositionTitleRow(entry: entry, collectionPosition: position, multiPageController: multiPageController); + final hasShootingDetails = details != null && !details.isEmpty && settings.showOverlayShootingDetails; + + return Padding( + padding: infoPadding, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (positionTitle.isNotEmpty) positionTitle, + _buildSoloLocationRow(), + if (twoColumns) + Padding( + padding: EdgeInsets.only(top: _interRowPadding), + child: Row( + children: [ + Container( + width: subRowWidth, + child: _DateRow( + entry: entry, + multiPageController: multiPageController, + )), + _buildDuoShootingRow(subRowWidth, hasShootingDetails), + ], + ), + ) + else ...[ + Container( + padding: EdgeInsets.only(top: _interRowPadding), + width: subRowWidth, + child: _DateRow( + entry: entry, + multiPageController: multiPageController, + ), + ), + _buildSoloShootingRow(subRowWidth, hasShootingDetails), + ], + ], + ), + ); + } + + Widget _buildSoloLocationRow() => AnimatedSwitcher( + duration: Durations.viewerOverlayChangeAnimation, + 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.viewerOverlayChangeAnimation, + 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.viewerOverlayChangeAnimation, + 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 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 _PositionTitleRow extends StatelessWidget { + final ImageEntry entry; + final String collectionPosition; + final MultiPageController multiPageController; + + const _PositionTitleRow({ + @required this.entry, + @required this.collectionPosition, + @required this.multiPageController, + }); + + String get title => entry.bestTitle; + + bool get isNotEmpty => collectionPosition != null || multiPageController != null || title != null; + + @override + Widget build(BuildContext context) { + Text toText({String pagePosition}) => Text( + [ + if (collectionPosition != null) collectionPosition, + if (pagePosition != null) pagePosition, + if (title != null) title, + ].join(' • '), + strutStyle: Constants.overflowStrutStyle); + + if (multiPageController == null) return toText(); + + return FutureBuilder( + future: multiPageController.info, + builder: (context, snapshot) { + final multiPageInfo = snapshot.data; + final pageCount = multiPageInfo?.pageCount; + // page count may be 0 when we know an entry to have multiple pages + // but fail to get information about these pages + final missingInfo = pageCount == 0; + return ValueListenableBuilder( + valueListenable: multiPageController.pageNotifier, + builder: (context, page, child) { + return toText(pagePosition: missingInfo ? null : '${page + 1}/${pageCount ?? '?'}'); + }, + ); + }, + ); + } +} + +class _DateRow extends StatelessWidget { + final ImageEntry entry; + final MultiPageController multiPageController; + + const _DateRow({ + @required this.entry, + @required this.multiPageController, + }); + + @override + Widget build(BuildContext context) { + final date = entry.bestDate; + final dateText = date != null ? '${DateFormat.yMMMd().format(date)} • ${DateFormat.Hm().format(date)}' : Constants.overlayUnknown; + + Text toText({MultiPageInfo multiPageInfo, int page}) => Text( + entry.isSvg + ? entry.aspectRatioText + : entry.getResolutionText( + multiPageInfo: multiPageInfo, + page: page, + ), + strutStyle: Constants.overflowStrutStyle, + ); + + Widget resolutionText; + if (multiPageController != null) { + resolutionText = FutureBuilder( + future: multiPageController.info, + builder: (context, snapshot) { + final multiPageInfo = snapshot.data; + return ValueListenableBuilder( + valueListenable: multiPageController.pageNotifier, + builder: (context, page, child) { + return toText(multiPageInfo: multiPageInfo, page: page); + }, + ); + }, + ); + } else { + resolutionText = toText(); + } + 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: resolutionText), + ], + ); + } +} + +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>((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, + ), + ); + } +} diff --git a/lib/widgets/fullscreen/overlay/common.dart b/lib/widgets/viewer/overlay/common.dart similarity index 100% rename from lib/widgets/fullscreen/overlay/common.dart rename to lib/widgets/viewer/overlay/common.dart diff --git a/lib/widgets/fullscreen/overlay/minimap.dart b/lib/widgets/viewer/overlay/minimap.dart similarity index 62% rename from lib/widgets/fullscreen/overlay/minimap.dart rename to lib/widgets/viewer/overlay/minimap.dart index 561cc3971..ce9c6b101 100644 --- a/lib/widgets/fullscreen/overlay/minimap.dart +++ b/lib/widgets/viewer/overlay/minimap.dart @@ -1,13 +1,16 @@ import 'dart:math'; import 'package:aves/model/image_entry.dart'; -import 'package:aves/widgets/fullscreen/image_view.dart'; +import 'package:aves/model/multipage.dart'; +import 'package:aves/widgets/viewer/multipage.dart'; +import 'package:aves/widgets/viewer/visual/state.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; class Minimap extends StatelessWidget { final ImageEntry entry; final ValueNotifier viewStateNotifier; + final MultiPageController multiPageController; final Size size; static const defaultSize = Size(96, 96); @@ -15,30 +18,48 @@ class Minimap extends StatelessWidget { const Minimap({ @required this.entry, @required this.viewStateNotifier, + @required this.multiPageController, this.size = defaultSize, }); @override Widget build(BuildContext context) { return IgnorePointer( - child: ValueListenableBuilder( - valueListenable: viewStateNotifier, - builder: (context, viewState, child) { - final viewportSize = viewState.viewportSize; - if (viewportSize == null) return SizedBox.shrink(); - return CustomPaint( - painter: MinimapPainter( - viewportSize: viewportSize, - entrySize: entry.displaySize, - viewCenterOffset: viewState.position, - viewScale: viewState.scale, - minimapBorderColor: Colors.white30, - ), - size: size, - ); - }), + child: multiPageController != null + ? FutureBuilder( + future: multiPageController.info, + builder: (context, snapshot) { + final multiPageInfo = snapshot.data; + if (multiPageInfo == null) return SizedBox.shrink(); + return ValueListenableBuilder( + valueListenable: multiPageController.pageNotifier, + builder: (context, page, child) { + return _buildForEntrySize(entry.getDisplaySize(multiPageInfo: multiPageInfo, page: page)); + }, + ); + }) + : _buildForEntrySize(entry.getDisplaySize()), ); } + + Widget _buildForEntrySize(Size entrySize) { + return ValueListenableBuilder( + valueListenable: viewStateNotifier, + builder: (context, viewState, child) { + final viewportSize = viewState.viewportSize; + if (viewportSize == null) return SizedBox.shrink(); + return CustomPaint( + painter: MinimapPainter( + viewportSize: viewportSize, + entrySize: entrySize, + viewCenterOffset: viewState.position, + viewScale: viewState.scale, + minimapBorderColor: Colors.white30, + ), + size: size, + ); + }); + } } class MinimapPainter extends CustomPainter { diff --git a/lib/widgets/viewer/overlay/multipage.dart b/lib/widgets/viewer/overlay/multipage.dart new file mode 100644 index 000000000..a5569967d --- /dev/null +++ b/lib/widgets/viewer/overlay/multipage.dart @@ -0,0 +1,177 @@ +import 'dart:math'; + +import 'package:aves/model/image_entry.dart'; +import 'package:aves/model/multipage.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/widgets/collection/thumbnail/raster.dart'; +import 'package:aves/widgets/viewer/multipage.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class MultiPageOverlay extends StatefulWidget { + final ImageEntry entry; + final MultiPageController controller; + final double availableWidth; + + const MultiPageOverlay({ + Key key, + @required this.entry, + @required this.controller, + @required this.availableWidth, + }) : super(key: key); + + @override + _MultiPageOverlayState createState() => _MultiPageOverlayState(); +} + +class _MultiPageOverlayState extends State { + ScrollController _scrollController; + bool _syncScroll = true; + + static const double extent = 48; + static const double separatorWidth = 2; + + ImageEntry get entry => widget.entry; + + MultiPageController get controller => widget.controller; + + double get availableWidth => widget.availableWidth; + + @override + void initState() { + super.initState(); + _registerWidget(); + } + + @override + void didUpdateWidget(covariant MultiPageOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.controller != controller) { + _unregisterWidget(); + _registerWidget(); + } + } + + @override + void dispose() { + _unregisterWidget(); + super.dispose(); + } + + void _registerWidget() { + final scrollOffset = pageToScrollOffset(controller.page); + _scrollController = ScrollController(initialScrollOffset: scrollOffset); + _scrollController.addListener(_onScrollChange); + } + + void _unregisterWidget() { + _scrollController.removeListener(_onScrollChange); + _scrollController.dispose(); + } + + @override + Widget build(BuildContext context) { + final marginWidth = max(0, (availableWidth - extent) / 2 - separatorWidth); + final horizontalMargin = SizedBox(width: marginWidth); + final separator = SizedBox(width: separatorWidth); + final shade = IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.black38, + ), + ), + ); + + return FutureBuilder( + future: controller.info, + builder: (context, snapshot) { + final multiPageInfo = snapshot.data; + if ((multiPageInfo?.pageCount ?? 0) <= 1) return SizedBox.shrink(); + return Container( + height: extent + separatorWidth * 2, + child: Stack( + children: [ + Positioned( + top: separatorWidth, + width: availableWidth, + height: extent, + child: ListView.separated( + key: ValueKey(entry), + scrollDirection: Axis.horizontal, + controller: _scrollController, + // default padding in scroll direction matches `MediaQuery.viewPadding`, + // but we already accommodate for it, so make sure horizontal padding is 0 + padding: EdgeInsets.zero, + itemBuilder: (context, index) { + if (index == 0 || index == multiPageInfo.pageCount + 1) return horizontalMargin; + final page = index - 1; + return GestureDetector( + onTap: () async { + _syncScroll = false; + controller.page = page; + await _scrollController.animateTo( + pageToScrollOffset(page), + duration: Durations.viewerOverlayPageChooserAnimation, + curve: Curves.easeOutCubic, + ); + _syncScroll = true; + }, + child: Container( + width: extent, + height: extent, + child: RasterImageThumbnail( + entry: entry, + extent: extent, + page: page, + ), + ), + ); + }, + separatorBuilder: (context, index) => separator, + itemCount: multiPageInfo.pageCount + 2, + ), + ), + Positioned( + left: 0, + top: separatorWidth, + width: marginWidth + separatorWidth, + height: extent, + child: shade, + ), + Positioned( + top: separatorWidth, + right: 0, + width: marginWidth + separatorWidth, + height: extent, + child: shade, + ), + Positioned( + top: 0, + width: availableWidth, + height: separatorWidth, + child: shade, + ), + Positioned( + bottom: 0, + width: availableWidth, + height: separatorWidth, + child: shade, + ), + ], + ), + ); + }, + ); + } + + void _onScrollChange() { + if (_syncScroll) { + controller.page = scrollOffsetToPage(_scrollController.offset); + } + } + + double pageToScrollOffset(int page) => page * (extent + separatorWidth); + + int scrollOffsetToPage(double offset) => (offset / (extent + separatorWidth)).round(); +} diff --git a/lib/widgets/fullscreen/overlay/panorama.dart b/lib/widgets/viewer/overlay/panorama.dart similarity index 57% rename from lib/widgets/fullscreen/overlay/panorama.dart rename to lib/widgets/viewer/overlay/panorama.dart index 386752e5f..9356a8206 100644 --- a/lib/widgets/fullscreen/overlay/panorama.dart +++ b/lib/widgets/viewer/overlay/panorama.dart @@ -1,7 +1,9 @@ import 'package:aves/model/image_entry.dart'; -import 'package:aves/widgets/fullscreen/overlay/common.dart'; -import 'package:aves/widgets/fullscreen/panorama_page.dart'; +import 'package:aves/services/metadata_service.dart'; +import 'package:aves/widgets/viewer/overlay/common.dart'; +import 'package:aves/widgets/viewer/panorama_page.dart'; import 'package:flutter/material.dart'; +import 'package:pedantic/pedantic.dart'; class PanoramaOverlay extends StatelessWidget { final ImageEntry entry; @@ -21,14 +23,18 @@ class PanoramaOverlay extends StatelessWidget { OverlayTextButton( scale: scale, text: 'Open Panorama', - onPressed: () { - Navigator.push( + onPressed: () async { + final info = await MetadataService.getPanoramaInfo(entry); + unawaited(Navigator.push( context, MaterialPageRoute( settings: RouteSettings(name: PanoramaPage.routeName), - builder: (context) => PanoramaPage(entry: entry), + builder: (context) => PanoramaPage( + entry: entry, + info: info, + ), ), - ); + )); }, ) ], diff --git a/lib/widgets/fullscreen/overlay/top.dart b/lib/widgets/viewer/overlay/top.dart similarity index 95% rename from lib/widgets/fullscreen/overlay/top.dart rename to lib/widgets/viewer/overlay/top.dart index 5626c06f5..a3effb3cd 100644 --- a/lib/widgets/fullscreen/overlay/top.dart +++ b/lib/widgets/viewer/overlay/top.dart @@ -8,22 +8,24 @@ import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/basic/menu_row.dart'; import 'package:aves/widgets/common/fx/sweeper.dart'; -import 'package:aves/widgets/fullscreen/image_view.dart'; -import 'package:aves/widgets/fullscreen/overlay/common.dart'; -import 'package:aves/widgets/fullscreen/overlay/minimap.dart'; +import 'package:aves/widgets/viewer/multipage.dart'; +import 'package:aves/widgets/viewer/overlay/common.dart'; +import 'package:aves/widgets/viewer/overlay/minimap.dart'; +import 'package:aves/widgets/viewer/visual/state.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; -class FullscreenTopOverlay extends StatelessWidget { +class ViewerTopOverlay extends StatelessWidget { final ImageEntry entry; final Animation scale; final EdgeInsets viewInsets, viewPadding; final Function(EntryAction value) onActionSelected; final bool canToggleFavourite; final ValueNotifier viewStateNotifier; + final MultiPageController multiPageController; static const double padding = 8; @@ -31,7 +33,7 @@ class FullscreenTopOverlay extends StatelessWidget { static const int portraitActionCount = 2; - const FullscreenTopOverlay({ + const ViewerTopOverlay({ Key key, @required this.entry, @required this.scale, @@ -39,7 +41,8 @@ class FullscreenTopOverlay extends StatelessWidget { @required this.viewInsets, @required this.viewPadding, @required this.onActionSelected, - this.viewStateNotifier, + @required this.viewStateNotifier, + @required this.multiPageController, }) : super(key: key); @override @@ -85,6 +88,7 @@ class FullscreenTopOverlay extends StatelessWidget { child: Minimap( entry: entry, viewStateNotifier: viewStateNotifier, + multiPageController: multiPageController, ), ) ], @@ -320,7 +324,7 @@ class _FavouriteTogglerState extends State<_FavouriteToggler> { } @override - void didUpdateWidget(_FavouriteToggler oldWidget) { + void didUpdateWidget(covariant _FavouriteToggler oldWidget) { super.didUpdateWidget(oldWidget); _onChanged(); } diff --git a/lib/widgets/fullscreen/overlay/video.dart b/lib/widgets/viewer/overlay/video.dart similarity index 96% rename from lib/widgets/fullscreen/overlay/video.dart rename to lib/widgets/viewer/overlay/video.dart index 1e0b20dbf..eee35e659 100644 --- a/lib/widgets/fullscreen/overlay/video.dart +++ b/lib/widgets/viewer/overlay/video.dart @@ -7,7 +7,7 @@ import 'package:aves/theme/icons.dart'; import 'package:aves/utils/time_utils.dart'; import 'package:aves/widgets/common/fx/blurred.dart'; import 'package:aves/widgets/common/fx/borders.dart'; -import 'package:aves/widgets/fullscreen/overlay/common.dart'; +import 'package:aves/widgets/viewer/overlay/common.dart'; import 'package:flutter/material.dart'; import 'package:flutter_ijkplayer/flutter_ijkplayer.dart'; @@ -24,10 +24,10 @@ class VideoControlOverlay extends StatefulWidget { }) : super(key: key); @override - State createState() => VideoControlOverlayState(); + State createState() => _VideoControlOverlayState(); } -class VideoControlOverlayState extends State with SingleTickerProviderStateMixin { +class _VideoControlOverlayState extends State with SingleTickerProviderStateMixin { final GlobalKey _progressBarKey = GlobalKey(); bool _playingOnDragStart = false; AnimationController _playPauseAnimation; @@ -65,7 +65,7 @@ class VideoControlOverlayState extends State with SingleTic } @override - void didUpdateWidget(VideoControlOverlay oldWidget) { + void didUpdateWidget(covariant VideoControlOverlay oldWidget) { super.didUpdateWidget(oldWidget); _unregisterWidget(oldWidget); _registerWidget(widget); diff --git a/lib/widgets/viewer/panorama_page.dart b/lib/widgets/viewer/panorama_page.dart new file mode 100644 index 000000000..d1e44385b --- /dev/null +++ b/lib/widgets/viewer/panorama_page.dart @@ -0,0 +1,171 @@ +import 'package:aves/image_providers/uri_image_provider.dart'; +import 'package:aves/model/image_entry.dart'; +import 'package:aves/model/panorama.dart'; +import 'package:aves/theme/icons.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/viewer/overlay/common.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'package:panorama/panorama.dart'; +import 'package:provider/provider.dart'; + +class PanoramaPage extends StatefulWidget { + static const routeName = '/viewer/panorama'; + + final ImageEntry entry; + final int page; + final PanoramaInfo info; + + const PanoramaPage({ + @required this.entry, + this.page = 0, + @required this.info, + }); + + @override + _PanoramaPageState createState() => _PanoramaPageState(); +} + +class _PanoramaPageState extends State { + final ValueNotifier _overlayVisible = ValueNotifier(true); + final ValueNotifier _sensorControl = ValueNotifier(SensorControl.None); + + ImageEntry get entry => widget.entry; + + PanoramaInfo get info => widget.info; + + @override + void initState() { + super.initState(); + _overlayVisible.addListener(_onOverlayVisibleChange); + WidgetsBinding.instance.addPostFrameCallback((_) => _initOverlay()); + } + + @override + void dispose() { + _overlayVisible.removeListener(_onOverlayVisibleChange); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () { + _onLeave(); + return SynchronousFuture(true); + }, + child: MediaQueryDataProvider( + child: Scaffold( + body: Stack( + children: [ + ValueListenableBuilder( + valueListenable: _sensorControl, + builder: (context, sensorControl, child) { + return Panorama( + sensorControl: sensorControl, + croppedArea: info.hasCroppedArea ? info.croppedAreaRect : Rect.fromLTWH(0.0, 0.0, 1.0, 1.0), + croppedFullWidth: info.hasCroppedArea ? info.fullPanoSize.width : 1.0, + croppedFullHeight: info.hasCroppedArea ? info.fullPanoSize.height : 1.0, + onTap: (longitude, latitude, tilt) => _overlayVisible.value = !_overlayVisible.value, + child: child, + ); + }, + child: Image( + image: UriImage( + uri: entry.uri, + mimeType: entry.mimeType, + page: widget.page, + rotationDegrees: entry.rotationDegrees, + isFlipped: entry.isFlipped, + expectedContentLength: entry.sizeBytes, + ), + ), + ), + Positioned( + bottom: 0, + right: 0, + child: TooltipTheme( + data: TooltipTheme.of(context).copyWith( + preferBelow: false, + ), + child: ValueListenableBuilder( + valueListenable: _overlayVisible, + builder: (context, overlayVisible, child) { + return Visibility( + visible: overlayVisible, + child: Selector( + selector: (c, mq) => mq.padding + mq.viewInsets, + builder: (c, mqViewInsets, child) { + return Padding( + padding: EdgeInsets.all(8) + EdgeInsets.only(right: mqViewInsets.right, bottom: mqViewInsets.bottom), + child: OverlayButton( + scale: kAlwaysCompleteAnimation, + child: ValueListenableBuilder( + valueListenable: _sensorControl, + builder: (context, sensorControl, child) { + return IconButton( + icon: Icon(sensorControl == SensorControl.None ? AIcons.sensorControl : AIcons.sensorControlOff), + onPressed: _toggleSensor, + tooltip: sensorControl == SensorControl.None ? 'Enable sensor control' : 'Disable sensor control', + ); + }), + ), + ); + }, + ), + ); + }, + ), + ), + ), + BottomGestureAreaProtector(), + ], + ), + resizeToAvoidBottomInset: false, + ), + ), + ); + } + + void _toggleSensor() { + switch (_sensorControl.value) { + case SensorControl.None: + _sensorControl.value = SensorControl.AbsoluteOrientation; + break; + case SensorControl.AbsoluteOrientation: + case SensorControl.Orientation: + _sensorControl.value = SensorControl.None; + break; + } + } + + void _onLeave() { + _showSystemUI(); + } + + // system UI + + static void _showSystemUI() => SystemChrome.setEnabledSystemUIOverlays(SystemUiOverlay.values); + + static void _hideSystemUI() => SystemChrome.setEnabledSystemUIOverlays([]); + + // overlay + + Future _initOverlay() async { + // wait for MaterialPageRoute.transitionDuration + // to show overlay after page animation is complete + await Future.delayed(ModalRoute.of(context).transitionDuration * timeDilation); + await _onOverlayVisibleChange(); + } + + Future _onOverlayVisibleChange() async { + if (_overlayVisible.value) { + _showSystemUI(); + } else { + _hideSystemUI(); + } + } +} diff --git a/lib/widgets/viewer/printer.dart b/lib/widgets/viewer/printer.dart new file mode 100644 index 000000000..00824a099 --- /dev/null +++ b/lib/widgets/viewer/printer.dart @@ -0,0 +1,84 @@ +import 'dart:convert'; + +import 'package:aves/image_providers/uri_image_provider.dart'; +import 'package:aves/model/image_entry.dart'; +import 'package:aves/services/image_file_service.dart'; +import 'package:aves/services/metadata_service.dart'; +import 'package:pdf/widgets.dart' as pdf; +import 'package:pedantic/pedantic.dart'; +import 'package:printing/printing.dart'; + +class EntryPrinter { + final ImageEntry entry; + + const EntryPrinter(this.entry); + + Future print() async { + final documentName = entry.bestTitle ?? 'Aves'; + final doc = pdf.Document(title: documentName); + + final pages = await _buildPages(); + if (pages.isNotEmpty) { + pages.forEach(doc.addPage); // Page + unawaited(Printing.layoutPdf( + onLayout: (format) => doc.save(), + name: documentName, + )); + } + } + + Future> _buildPages() async { + final pages = []; + + void _addPdfPage(pdf.Widget pdfChild) { + if (pdfChild == null) return; + pages.add(pdf.Page( + orientation: entry.isPortrait ? pdf.PageOrientation.portrait : pdf.PageOrientation.landscape, + build: (context) => pdf.FullPage( + ignoreMargins: true, + child: pdf.Center( + child: pdfChild, + ), + ), + )); + } + + if (entry.isMultipage) { + final multiPageInfo = await MetadataService.getMultiPageInfo(entry); + if (multiPageInfo.pageCount > 1) { + for (final kv in multiPageInfo.pages.entries) { + _addPdfPage(await _buildPageImage(page: kv.key)); + } + } + } + if (pages.isEmpty) { + _addPdfPage(await _buildPageImage()); + } + return pages; + } + + Future _buildPageImage({page = 0}) async { + final uri = entry.uri; + final mimeType = entry.mimeType; + final rotationDegrees = entry.rotationDegrees; + final isFlipped = entry.isFlipped; + + if (entry.isSvg) { + final bytes = await ImageFileService.getImage(uri, mimeType, rotationDegrees, isFlipped); + if (bytes != null && bytes.isNotEmpty) { + return pdf.SvgImage(svg: utf8.decode(bytes)); + } + } else { + return pdf.Image(await flutterImageProvider( + UriImage( + uri: uri, + mimeType: mimeType, + page: page, + rotationDegrees: rotationDegrees, + isFlipped: isFlipped, + ), + )); + } + return null; + } +} diff --git a/lib/widgets/fullscreen/source_viewer_page.dart b/lib/widgets/viewer/source_viewer_page.dart similarity index 97% rename from lib/widgets/fullscreen/source_viewer_page.dart rename to lib/widgets/viewer/source_viewer_page.dart index 08d91b8a1..75bd97973 100644 --- a/lib/widgets/fullscreen/source_viewer_page.dart +++ b/lib/widgets/viewer/source_viewer_page.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_highlight/themes/darcula.dart'; class SourceViewerPage extends StatefulWidget { - static const routeName = '/fullscreen/source'; + static const routeName = '/viewer/source'; final Future Function() loader; diff --git a/lib/widgets/fullscreen/image_view.dart b/lib/widgets/viewer/visual/entry_page_view.dart similarity index 60% rename from lib/widgets/fullscreen/image_view.dart rename to lib/widgets/viewer/visual/entry_page_view.dart index b3c5e1ada..ae4638de8 100644 --- a/lib/widgets/fullscreen/image_view.dart +++ b/lib/widgets/viewer/visual/entry_page_view.dart @@ -2,37 +2,45 @@ import 'dart:async'; import 'package:aves/image_providers/uri_picture_provider.dart'; import 'package:aves/model/image_entry.dart'; +import 'package:aves/model/multipage.dart'; import 'package:aves/model/settings/entry_background.dart'; import 'package:aves/model/settings/settings.dart'; -import 'package:aves/theme/icons.dart'; -import 'package:aves/widgets/collection/empty.dart'; -import 'package:aves/widgets/common/fx/checkered_decoration.dart'; import 'package:aves/widgets/common/magnifier/controller/controller.dart'; import 'package:aves/widgets/common/magnifier/controller/state.dart'; import 'package:aves/widgets/common/magnifier/magnifier.dart'; import 'package:aves/widgets/common/magnifier/scale/scale_boundaries.dart'; import 'package:aves/widgets/common/magnifier/scale/scale_level.dart'; import 'package:aves/widgets/common/magnifier/scale/state.dart'; -import 'package:aves/widgets/fullscreen/tiled_view.dart'; -import 'package:aves/widgets/fullscreen/video_view.dart'; +import 'package:aves/widgets/viewer/visual/error.dart'; +import 'package:aves/widgets/viewer/visual/raster.dart'; +import 'package:aves/widgets/viewer/visual/state.dart'; +import 'package:aves/widgets/viewer/visual/vector.dart'; +import 'package:aves/widgets/viewer/visual/video.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_ijkplayer/flutter_ijkplayer.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:tuple/tuple.dart'; -class ImageView extends StatefulWidget { +class EntryPageView extends StatefulWidget { final ImageEntry entry; + final MultiPageInfo multiPageInfo; + final int page; final Object heroTag; final MagnifierTapCallback onTap; final List> videoControllers; final VoidCallback onDisposed; static const decorationCheckSize = 20.0; + static const initialScale = ScaleLevel(ref: ScaleReference.contained); + static const minScale = ScaleLevel(ref: ScaleReference.contained); + static const maxScale = ScaleLevel(factor: 2.0); - const ImageView({ + const EntryPageView({ Key key, @required this.entry, + this.multiPageInfo, + this.page = 0, this.heroTag, @required this.onTap, @required this.videoControllers, @@ -40,22 +48,24 @@ class ImageView extends StatefulWidget { }) : super(key: key); @override - _ImageViewState createState() => _ImageViewState(); + _EntryPageViewState createState() => _EntryPageViewState(); } -class _ImageViewState extends State { +class _EntryPageViewState extends State { final MagnifierController _magnifierController = MagnifierController(); final ValueNotifier _viewStateNotifier = ValueNotifier(ViewState.zero); final List _subscriptions = []; - static const initialScale = ScaleLevel(ref: ScaleReference.contained); - static const minScale = ScaleLevel(ref: ScaleReference.contained); - static const maxScale = ScaleLevel(factor: 2.0); - ImageEntry get entry => widget.entry; + MultiPageInfo get multiPageInfo => widget.multiPageInfo; + + int get page => widget.page; + MagnifierTapCallback get onTap => widget.onTap; + Size get pageDisplaySize => entry.getDisplaySize(multiPageInfo: multiPageInfo, page: page); + @override void initState() { super.initState(); @@ -84,7 +94,7 @@ class _ImageViewState extends State { } else if (entry.canDecode) { child = _buildRasterView(); } - child ??= ErrorChild(onTap: () => onTap?.call(null)); + child ??= ErrorView(onTap: () => onTap?.call(null)); // no hero for videos, as a typical video first frame is different from its thumbnail return widget.heroTag != null && !entry.isVideo @@ -99,17 +109,19 @@ class _ImageViewState extends State { Widget _buildRasterView() { return Magnifier( // key includes size and orientation to refresh when the image is rotated - key: ValueKey('${entry.rotationDegrees}_${entry.isFlipped}_${entry.width}_${entry.height}_${entry.path}'), + key: ValueKey('${page}_${entry.rotationDegrees}_${entry.isFlipped}_${entry.width}_${entry.height}_${entry.path}'), child: TiledImageView( entry: entry, + multiPageInfo: multiPageInfo, + page: page, viewStateNotifier: _viewStateNotifier, - errorBuilder: (context, error, stackTrace) => ErrorChild(onTap: () => onTap?.call(null)), + errorBuilder: (context, error, stackTrace) => ErrorView(onTap: () => onTap?.call(null)), ), - childSize: entry.displaySize, + childSize: pageDisplaySize, controller: _magnifierController, - maxScale: maxScale, - minScale: minScale, - initialScale: initialScale, + maxScale: EntryPageView.maxScale, + minScale: EntryPageView.minScale, + initialScale: EntryPageView.initialScale, onTap: (c, d, s, childPosition) => onTap?.call(childPosition), applyScale: false, ); @@ -127,45 +139,18 @@ class _ImageViewState extends State { colorFilter: colorFilter, ), ), - childSize: entry.displaySize, + childSize: pageDisplaySize, controller: _magnifierController, - minScale: minScale, - initialScale: initialScale, + minScale: EntryPageView.minScale, + initialScale: EntryPageView.initialScale, scaleStateCycle: _vectorScaleStateCycle, onTap: (c, d, s, childPosition) => onTap?.call(childPosition), ); if (background == EntryBackground.checkered) { - child = ValueListenableBuilder( - valueListenable: _viewStateNotifier, - builder: (context, viewState, child) { - final viewportSize = viewState.viewportSize; - if (viewportSize == null) return child; - - final side = viewportSize.shortestSide; - final checkSize = side / ((side / ImageView.decorationCheckSize).round()); - - final viewSize = entry.displaySize * viewState.scale; - final decorationSize = applyBoxFit(BoxFit.none, viewSize, viewportSize).source; - final offset = ((decorationSize - viewportSize) as Offset) / 2; - - return Stack( - alignment: Alignment.center, - children: [ - Positioned( - width: decorationSize.width, - height: decorationSize.height, - child: DecoratedBox( - decoration: CheckeredDecoration( - checkSize: checkSize, - offset: offset, - ), - ), - ), - child, - ], - ); - }, + child = VectorViewCheckeredBackground( + displaySize: pageDisplaySize, + viewStateNotifier: _viewStateNotifier, child: child, ); } @@ -181,11 +166,11 @@ class _ImageViewState extends State { controller: videoController, ) : SizedBox(), - childSize: entry.displaySize, + childSize: pageDisplaySize, controller: _magnifierController, - maxScale: maxScale, - minScale: minScale, - initialScale: initialScale, + maxScale: EntryPageView.maxScale, + minScale: EntryPageView.minScale, + initialScale: EntryPageView.initialScale, onTap: (c, d, s, childPosition) => onTap?.call(childPosition), ); } @@ -214,50 +199,4 @@ class _ImageViewState extends State { } } -class ViewState { - final Offset position; - final double scale; - final Size viewportSize; - - static const ViewState zero = ViewState(Offset.zero, 0, null); - - const ViewState(this.position, this.scale, this.viewportSize); - - @override - String toString() => '$runtimeType#${shortHash(this)}{position=$position, scale=$scale, viewportSize=$viewportSize}'; -} - -class ViewStateNotification extends Notification { - final String uri; - final ViewState viewState; - - const ViewStateNotification(this.uri, this.viewState); - - @override - String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, viewState=$viewState}'; -} - -class ErrorChild extends StatelessWidget { - final VoidCallback onTap; - - const ErrorChild({@required this.onTap}); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: () => onTap?.call(), - // use a `Container` with a dummy color to make it expand - // so that we can also detect taps around the title `Text` - child: Container( - color: Colors.transparent, - child: EmptyContent( - icon: AIcons.error, - text: 'Oops!', - alignment: Alignment.center, - ), - ), - ); - } -} - typedef MagnifierTapCallback = void Function(Offset childPosition); diff --git a/lib/widgets/viewer/visual/error.dart b/lib/widgets/viewer/visual/error.dart new file mode 100644 index 000000000..fbee7fb3b --- /dev/null +++ b/lib/widgets/viewer/visual/error.dart @@ -0,0 +1,27 @@ +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/collection/empty.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class ErrorView extends StatelessWidget { + final VoidCallback onTap; + + const ErrorView({@required this.onTap}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => onTap?.call(), + // use a `Container` with a dummy color to make it expand + // so that we can also detect taps around the title `Text` + child: Container( + color: Colors.transparent, + child: EmptyContent( + icon: AIcons.error, + text: 'Oops!', + alignment: Alignment.center, + ), + ), + ); + } +} diff --git a/lib/widgets/fullscreen/tiled_view.dart b/lib/widgets/viewer/visual/raster.dart similarity index 88% rename from lib/widgets/fullscreen/tiled_view.dart rename to lib/widgets/viewer/visual/raster.dart index c64fcea6f..cba13a263 100644 --- a/lib/widgets/fullscreen/tiled_view.dart +++ b/lib/widgets/viewer/visual/raster.dart @@ -4,22 +4,28 @@ import 'package:aves/image_providers/region_provider.dart'; import 'package:aves/image_providers/thumbnail_provider.dart'; import 'package:aves/image_providers/uri_image_provider.dart'; import 'package:aves/model/image_entry.dart'; +import 'package:aves/model/multipage.dart'; import 'package:aves/model/settings/entry_background.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/utils/math_utils.dart'; import 'package:aves/widgets/common/fx/checkered_decoration.dart'; -import 'package:aves/widgets/fullscreen/image_view.dart'; +import 'package:aves/widgets/viewer/visual/entry_page_view.dart'; +import 'package:aves/widgets/viewer/visual/state.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:tuple/tuple.dart'; class TiledImageView extends StatefulWidget { final ImageEntry entry; + final MultiPageInfo multiPageInfo; + final int page; final ValueNotifier viewStateNotifier; final ImageErrorWidgetBuilder errorBuilder; const TiledImageView({ @required this.entry, + this.multiPageInfo, + this.page = 0, @required this.viewStateNotifier, @required this.errorBuilder, }); @@ -29,6 +35,7 @@ class TiledImageView extends StatefulWidget { } class _TiledImageViewState extends State { + Size _displaySize; bool _isTilingInitialized = false; int _maxSampleSize; double _tileSide; @@ -39,19 +46,23 @@ class _TiledImageViewState extends State { ImageEntry get entry => widget.entry; + int get page => widget.page; + ValueNotifier get viewStateNotifier => widget.viewStateNotifier; bool get useBackground => entry.canHaveAlpha && settings.rasterBackground != EntryBackground.transparent; - bool get useTiles => entry.canTile && (entry.width > 4096 || entry.height > 4096); + // as of panorama v0.3.1, the `Panorama` widget throws on initialization when the image is already resolved + // so we use tiles for panoramas as a workaround to not collide with the `panorama` package resolution + bool get useTiles => entry.canTile && (entry.getDisplaySize(multiPageInfo: widget.multiPageInfo, page: page).longestSide > 4096 || entry.is360); - ImageProvider get thumbnailProvider => ThumbnailProvider(ThumbnailProviderKey.fromEntry(entry)); + ImageProvider get thumbnailProvider => ThumbnailProvider(ThumbnailProviderKey.fromEntry(entry, page: page)); ImageProvider get fullImageProvider { if (useTiles) { assert(_isTilingInitialized); - final displayWidth = entry.displaySize.width.round(); - final displayHeight = entry.displaySize.height.round(); + final displayWidth = _displaySize.width.round(); + final displayHeight = _displaySize.height.round(); final viewState = viewStateNotifier.value; final regionRect = _getTileRects( x: 0, @@ -62,9 +73,10 @@ class _TiledImageViewState extends State { displayHeight: displayHeight, scale: viewState.scale, viewRect: _getViewRect(viewState, displayWidth, displayHeight), - ).item2; + )?.item2; return RegionProvider(RegionProviderKey.fromEntry( entry, + page: page, sampleSize: _maxSampleSize, rect: regionRect, )); @@ -72,6 +84,7 @@ class _TiledImageViewState extends State { return UriImage( uri: entry.uri, mimeType: entry.mimeType, + page: page, rotationDegrees: entry.rotationDegrees, isFlipped: entry.isFlipped, expectedContentLength: entry.sizeBytes, @@ -85,17 +98,18 @@ class _TiledImageViewState extends State { @override void initState() { super.initState(); + _displaySize = entry.getDisplaySize(multiPageInfo: widget.multiPageInfo, page: page); _fullImageListener = ImageStreamListener(_onFullImageCompleted); if (!useTiles) _registerFullImage(); } @override - void didUpdateWidget(TiledImageView oldWidget) { + void didUpdateWidget(covariant TiledImageView oldWidget) { super.didUpdateWidget(oldWidget); final oldViewState = oldWidget.viewStateNotifier.value; final viewState = widget.viewStateNotifier.value; - if (oldWidget.entry != widget.entry || oldViewState.viewportSize != viewState.viewportSize) { + if (oldWidget.entry != widget.entry || oldViewState.viewportSize != viewState.viewportSize || oldWidget.page != page) { _isTilingInitialized = false; _fullImageLoaded.value = false; _unregisterFullImage(); @@ -135,7 +149,7 @@ class _TiledImageViewState extends State { if (viewportSized && useTiles && !_isTilingInitialized) _initTiling(viewportSize); return SizedBox.fromSize( - size: entry.displaySize * viewState.scale, + size: _displaySize * viewState.scale, child: Stack( alignment: Alignment.center, children: [ @@ -147,7 +161,7 @@ class _TiledImageViewState extends State { image: fullImageProvider, gaplessPlayback: true, errorBuilder: widget.errorBuilder, - width: (entry.displaySize * viewState.scale).width, + width: (_displaySize * viewState.scale).width, fit: BoxFit.contain, filterQuality: FilterQuality.medium, ), @@ -159,10 +173,9 @@ class _TiledImageViewState extends State { } void _initTiling(Size viewportSize) { - final displaySize = entry.displaySize; _tileSide = viewportSize.shortestSide * scaleFactor; // scale for initial state `contained` - final containedScale = min(viewportSize.width / displaySize.width, viewportSize.height / displaySize.height); + final containedScale = min(viewportSize.width / _displaySize.width, viewportSize.height / _displaySize.height); _maxSampleSize = _sampleSizeForScale(containedScale); final rotationDegrees = entry.rotationDegrees; @@ -173,7 +186,7 @@ class _TiledImageViewState extends State { ..translate(entry.width / 2.0, entry.height / 2.0) ..scale(isFlipped ? -1.0 : 1.0, 1.0, 1.0) ..rotateZ(-toRadians(rotationDegrees.toDouble())) - ..translate(-displaySize.width / 2.0, -displaySize.height / 2.0); + ..translate(-_displaySize.width / 2.0, -_displaySize.height / 2.0); } _isTilingInitialized = true; _registerFullImage(); @@ -203,7 +216,7 @@ class _TiledImageViewState extends State { final viewportSize = viewState.viewportSize; assert(viewportSize != null); - final viewSize = entry.displaySize * viewState.scale; + final viewSize = _displaySize * viewState.scale; final decorationOffset = ((viewSize - viewportSize) as Offset) / 2 - viewState.position; final decorationSize = applyBoxFit(BoxFit.none, viewSize, viewportSize).source; @@ -211,7 +224,7 @@ class _TiledImageViewState extends State { final background = settings.rasterBackground; if (background == EntryBackground.checkered) { final side = viewportSize.shortestSide; - final checkSize = side / ((side / ImageView.decorationCheckSize).round()); + final checkSize = side / ((side / EntryPageView.decorationCheckSize).round()); final offset = ((decorationSize - viewportSize) as Offset) / 2; decoration = CheckeredDecoration( checkSize: checkSize, @@ -236,8 +249,8 @@ class _TiledImageViewState extends State { List _getTiles(ViewState viewState) { if (!_isTilingInitialized) return []; - final displayWidth = entry.displaySize.width.round(); - final displayHeight = entry.displaySize.height.round(); + final displayWidth = _displaySize.width.round(); + final displayHeight = _displaySize.height.round(); final viewRect = _getViewRect(viewState, displayWidth, displayHeight); final scale = viewState.scale; @@ -265,6 +278,7 @@ class _TiledImageViewState extends State { if (rects != null) { tiles.add(RegionTile( entry: entry, + page: page, tileRect: rects.item1, regionRect: rects.item2, sampleSize: sampleSize, @@ -333,6 +347,7 @@ class _TiledImageViewState extends State { class RegionTile extends StatefulWidget { final ImageEntry entry; + final int page; // `tileRect` uses Flutter view coordinates // `regionRect` uses the raw image pixel coordinates @@ -342,6 +357,7 @@ class RegionTile extends StatefulWidget { const RegionTile({ @required this.entry, + @required this.page, @required this.tileRect, @required this.regionRect, @required this.sampleSize, @@ -363,7 +379,7 @@ class _RegionTileState extends State { } @override - void didUpdateWidget(RegionTile oldWidget) { + void didUpdateWidget(covariant RegionTile oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.entry != widget.entry || oldWidget.tileRect != widget.tileRect || oldWidget.sampleSize != widget.sampleSize || oldWidget.sampleSize != widget.sampleSize) { _unregisterWidget(oldWidget); @@ -390,6 +406,7 @@ class _RegionTileState extends State { _provider = RegionProvider(RegionProviderKey.fromEntry( entry, + page: widget.page, sampleSize: widget.sampleSize, rect: widget.regionRect, )); diff --git a/lib/widgets/viewer/visual/state.dart b/lib/widgets/viewer/visual/state.dart new file mode 100644 index 000000000..97760372f --- /dev/null +++ b/lib/widgets/viewer/visual/state.dart @@ -0,0 +1,25 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +class ViewState { + final Offset position; + final double scale; + final Size viewportSize; + + static const ViewState zero = ViewState(Offset.zero, 0, null); + + const ViewState(this.position, this.scale, this.viewportSize); + + @override + String toString() => '$runtimeType#${shortHash(this)}{position=$position, scale=$scale, viewportSize=$viewportSize}'; +} + +class ViewStateNotification extends Notification { + final String uri; + final ViewState viewState; + + const ViewStateNotification(this.uri, this.viewState); + + @override + String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, viewState=$viewState}'; +} diff --git a/lib/widgets/viewer/visual/vector.dart b/lib/widgets/viewer/visual/vector.dart new file mode 100644 index 000000000..443b01602 --- /dev/null +++ b/lib/widgets/viewer/visual/vector.dart @@ -0,0 +1,53 @@ +import 'package:aves/widgets/common/fx/checkered_decoration.dart'; +import 'package:aves/widgets/viewer/visual/entry_page_view.dart'; +import 'package:aves/widgets/viewer/visual/state.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class VectorViewCheckeredBackground extends StatelessWidget { + final Size displaySize; + final ValueNotifier viewStateNotifier; + final Widget child; + + const VectorViewCheckeredBackground({ + @required this.displaySize, + @required this.viewStateNotifier, + @required this.child, + }); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: viewStateNotifier, + builder: (context, viewState, child) { + final viewportSize = viewState.viewportSize; + if (viewportSize == null) return child; + + final side = viewportSize.shortestSide; + final checkSize = side / ((side / EntryPageView.decorationCheckSize).round()); + + final viewSize = displaySize * viewState.scale; + final decorationSize = applyBoxFit(BoxFit.none, viewSize, viewportSize).source; + final offset = ((decorationSize - viewportSize) as Offset) / 2; + + return Stack( + alignment: Alignment.center, + children: [ + Positioned( + width: decorationSize.width, + height: decorationSize.height, + child: DecoratedBox( + decoration: CheckeredDecoration( + checkSize: checkSize, + offset: offset, + ), + ), + ), + child, + ], + ); + }, + child: child, + ); + } +} diff --git a/lib/widgets/fullscreen/video_view.dart b/lib/widgets/viewer/visual/video.dart similarity index 94% rename from lib/widgets/fullscreen/video_view.dart rename to lib/widgets/viewer/visual/video.dart index 7007ebb22..51c257072 100644 --- a/lib/widgets/fullscreen/video_view.dart +++ b/lib/widgets/viewer/visual/video.dart @@ -17,10 +17,10 @@ class AvesVideo extends StatefulWidget { }) : super(key: key); @override - State createState() => AvesVideoState(); + State createState() => _AvesVideoState(); } -class AvesVideoState extends State { +class _AvesVideoState extends State { final List _subscriptions = []; ImageEntry get entry => widget.entry; @@ -34,7 +34,7 @@ class AvesVideoState extends State { } @override - void didUpdateWidget(AvesVideo oldWidget) { + void didUpdateWidget(covariant AvesVideo oldWidget) { super.didUpdateWidget(oldWidget); _unregisterWidget(oldWidget); _registerWidget(widget); @@ -101,6 +101,7 @@ class AvesVideoState extends State { image: UriImage( uri: entry.uri, mimeType: entry.mimeType, + page: 0, rotationDegrees: entry.rotationDegrees, isFlipped: entry.isFlipped, expectedContentLength: entry.sizeBytes, diff --git a/lib/widgets/welcome_page.dart b/lib/widgets/welcome_page.dart index e56ea9eaf..a99b8c64f 100644 --- a/lib/widgets/welcome_page.dart +++ b/lib/widgets/welcome_page.dart @@ -186,6 +186,7 @@ class _WelcomePageState extends State { child = AnimationConfiguration.staggeredList( position: index, duration: duration, + delay: delay, child: childAnimationBuilder(child), ); child = widget is Flexible ? Flexible(child: child) : child; diff --git a/pubspec.lock b/pubspec.lock index b3090e230..fdff631b9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -207,7 +207,7 @@ packages: name: firebase_analytics url: "https://pub.dartlang.org" source: hosted - version: "6.3.0" + version: "7.0.1" firebase_analytics_platform_interface: dependency: transitive description: @@ -228,35 +228,35 @@ packages: name: firebase_core url: "https://pub.dartlang.org" source: hosted - version: "0.5.3" + version: "0.7.0" firebase_core_platform_interface: dependency: transitive description: name: firebase_core_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "3.0.1" firebase_core_web: dependency: transitive description: name: firebase_core_web url: "https://pub.dartlang.org" source: hosted - version: "0.2.1+1" + version: "0.2.1+3" firebase_crashlytics: dependency: "direct main" description: name: firebase_crashlytics url: "https://pub.dartlang.org" source: hosted - version: "0.2.4" + version: "0.4.0+1" firebase_crashlytics_platform_interface: dependency: transitive description: name: firebase_crashlytics_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "1.1.4" + version: "1.1.6" flushbar: dependency: "direct main" description: @@ -282,7 +282,7 @@ packages: name: flutter_cache_manager url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "2.1.1" flutter_cube: dependency: transitive description: @@ -331,7 +331,7 @@ packages: name: flutter_markdown url: "https://pub.dartlang.org" source: hosted - version: "0.5.1" + version: "0.5.2" flutter_native_timezone: dependency: "direct main" description: @@ -395,14 +395,14 @@ packages: name: google_maps_flutter url: "https://pub.dartlang.org" source: hosted - version: "1.0.6" + version: "1.1.1" google_maps_flutter_platform_interface: dependency: transitive description: name: google_maps_flutter_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "1.0.5" + version: "1.1.0" highlight: dependency: transitive description: @@ -507,7 +507,7 @@ packages: name: material_design_icons_flutter url: "https://pub.dartlang.org" source: hosted - version: "4.0.5755" + version: "4.0.5855" meta: dependency: transitive description: @@ -535,7 +535,7 @@ packages: name: motion_sensors url: "https://pub.dartlang.org" source: hosted - version: "0.0.4" + version: "0.0.5" nested: dependency: transitive description: @@ -605,7 +605,7 @@ packages: name: panorama url: "https://pub.dartlang.org" source: hosted - version: "0.1.2" + version: "0.3.1" path: dependency: transitive description: @@ -633,7 +633,7 @@ packages: name: path_provider url: "https://pub.dartlang.org" source: hosted - version: "1.6.24" + version: "1.6.27" path_provider_linux: dependency: transitive description: @@ -647,7 +647,7 @@ packages: name: path_provider_macos url: "https://pub.dartlang.org" source: hosted - version: "0.0.4+6" + version: "0.0.4+8" path_provider_platform_interface: dependency: transitive description: @@ -668,7 +668,7 @@ packages: name: pdf url: "https://pub.dartlang.org" source: hosted - version: "1.13.0" + version: "2.0.0" pedantic: dependency: "direct main" description: @@ -738,7 +738,7 @@ packages: name: printing url: "https://pub.dartlang.org" source: hosted - version: "3.7.2" + version: "4.0.0" process: dependency: transitive description: @@ -759,7 +759,7 @@ packages: name: provider url: "https://pub.dartlang.org" source: hosted - version: "4.3.2+3" + version: "4.3.2+4" pub_semver: dependency: transitive description: @@ -897,14 +897,14 @@ packages: name: sqflite url: "https://pub.dartlang.org" source: hosted - version: "1.3.2+1" + version: "1.3.2+2" sqflite_common: dependency: transitive description: name: sqflite_common url: "https://pub.dartlang.org" source: hosted - version: "1.0.2+1" + version: "1.0.3" stack_trace: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 6fddcb04f..60130769b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: Aves is a gallery and metadata explorer app, built for Android. publish_to: 'none' # Remove this line if you wish to publish to pub.dev -version: 1.3.1+37 +version: 1.3.2+38 # brendan-duncan/image (as of v2.1.19): # - does not support TIFF with JPEG compression (issue #184) diff --git a/test/utils/string_utils_test.dart b/test/utils/string_utils_test.dart index 1b903eccf..8b5d1a3da 100644 --- a/test/utils/string_utils_test.dart +++ b/test/utils/string_utils_test.dart @@ -1,5 +1,5 @@ -import 'package:test/test.dart'; import 'package:aves/utils/string_utils.dart'; +import 'package:test/test.dart'; void main() { test('Sentence case', () { diff --git a/test/utils/time_utils_test.dart b/test/utils/time_utils_test.dart index e5f171b23..bf911d522 100644 --- a/test/utils/time_utils_test.dart +++ b/test/utils/time_utils_test.dart @@ -1,5 +1,5 @@ -import 'package:test/test.dart'; import 'package:aves/utils/time_utils.dart'; +import 'package:test/test.dart'; void main() { test('Comparison extension functions', () { diff --git a/test_driver/app_test.dart b/test_driver/app_test.dart index d549c6e54..999f2c77b 100644 --- a/test_driver/app_test.dart +++ b/test_driver/app_test.dart @@ -36,7 +36,7 @@ void main() { groupCollection(); selectFirstAlbum(); searchAlbum(); - showFullscreen(); + showViewer(); toggleOverlay(); zoom(); showInfoMetadata(); @@ -145,8 +145,8 @@ void searchAlbum() { }); } -void showFullscreen() { - test('[collection] show fullscreen', () async { +void showViewer() { + test('[collection] show viewer', () async { await driver.tap(find.byType('DecoratedThumbnail')); await driver.waitUntilNoTransientCallbacks(); await Future.delayed(Duration(seconds: 2)); @@ -154,7 +154,7 @@ void showFullscreen() { } void toggleOverlay() { - test('[fullscreen] toggle overlay', () async { + test('[viewer] toggle overlay', () async { final imageView = find.byValueKey('imageview'); print('* hide overlay'); @@ -168,7 +168,7 @@ void toggleOverlay() { } void zoom() { - test('[fullscreen] zoom cycle', () async { + test('[viewer] zoom cycle', () async { final imageView = find.byValueKey('imageview'); await driver.doubleTap(imageView); @@ -183,7 +183,7 @@ void zoom() { } void showInfoMetadata() { - test('[fullscreen] show info', () async { + test('[viewer] show info', () async { final verticalPageView = find.byValueKey('vertical-pageview'); print('* scroll down to info'); @@ -214,7 +214,7 @@ void showInfoMetadata() { } void scrollOffImage() { - test('[fullscreen] scroll off', () async { + test('[viewer] scroll off', () async { await driver.scroll(find.byValueKey('imageview'), 0, 800, Duration(milliseconds: 600)); await Future.delayed(Duration(seconds: 1)); }); diff --git a/whatsnew/whatsnew-en-US b/whatsnew/whatsnew-en-US index 55c3fd7e3..5dac30365 100644 --- a/whatsnew/whatsnew-en-US +++ b/whatsnew/whatsnew-en-US @@ -1,6 +1,6 @@ Thanks for using Aves! -v1.3.1: -- long press and move to select/deselect multiple entries -- metadata search in the Info page -- fixed crash when opening a collection with TIFF files on Android 11 +v1.3.2: +- multi-page TIFF support +- cropped panorama support +- album grouping options Full changelog available on Github \ No newline at end of file