diff --git a/CHANGELOG.md b/CHANGELOG.md index bf5bc06bc..1ad950ee9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [v1.3.0] - 2020-12-26 +### Added +- Viewer: quick scale (aka one finger zoom) +- Viewer: optional checkered background for transparent images + +### Changed +- Viewer: changed panning inertia + +### Fixed +- Viewer: fixed scaling focus when zooming by double-tap or pinch +- Viewer: fixed panning during scaling + ## [v1.2.9] - 2020-12-12 ### Added - Collection: identify 360 photos/videos, GeoTIFF diff --git a/README.md b/README.md index 0604bccb9..52ed6d473 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,6 @@ Aves is a gallery and metadata explorer app. It is built for Android, with Flutt ## Known Issues -- gesture: double tap on image does not zoom on tapped area (cf [photo_view issue #82](https://github.com/renancaraujo/photo_view/issues/82)) - performance: image info page stutters the first time it loads a Google Maps view (cf [flutter issue #28493](https://github.com/flutter/flutter/issues/28493)) - SVG: unsupported `currentColor` (cf [flutter_svg issue #31](https://github.com/dnfield/flutter_svg/issues/31)) - SVG: unsupported out of order defs/references (cf [flutter_svg issue #102](https://github.com/dnfield/flutter_svg/issues/102)) diff --git a/android/app/build.gradle b/android/app/build.gradle index 775910c2c..d84d5d96b 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -53,7 +53,7 @@ android { defaultConfig { applicationId "deckers.thibault.aves" - // TODO TLAD try minSdkVersion 23 when kotlin migration is done + // TODO TLAD try minSdkVersion 23 minSdkVersion 24 targetSdkVersion 30 // same as compileSdkVersion versionCode flutterVersionCode.toInteger() @@ -99,7 +99,7 @@ repositories { dependencies { implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9' implementation 'androidx.core:core-ktx:1.5.0-alpha05' // v1.5.0-alpha02+ for ShortcutManagerCompat.setDynamicShortcuts - implementation 'androidx.exifinterface:exifinterface:1.3.1' + 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 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 c134c5c16..3efc65b51 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 @@ -17,6 +17,7 @@ import deckers.thibault.aves.utils.LogUtils.createTag import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import java.io.File @@ -27,8 +28,8 @@ import kotlin.math.roundToInt class AppAdapterHandler(private val context: Context) : MethodCallHandler { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { - "getAppIcon" -> GlobalScope.launch { getAppIcon(call, Coresult(result)) } - "getAppNames" -> GlobalScope.launch { getAppNames(Coresult(result)) } + "getAppIcon" -> GlobalScope.launch(Dispatchers.IO) { getAppIcon(call, Coresult(result)) } + "getAppNames" -> GlobalScope.launch(Dispatchers.IO) { getAppNames(Coresult(result)) } "edit" -> { val title = call.argument("title") val uri = call.argument("uri")?.let { Uri.parse(it) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppShortcutHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppShortcutHandler.kt index 591459ba9..1adcba3bc 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppShortcutHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppShortcutHandler.kt @@ -12,6 +12,7 @@ import deckers.thibault.aves.utils.BitmapUtils.centerSquareCrop import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch @@ -20,7 +21,7 @@ class AppShortcutHandler(private val context: Context) : MethodCallHandler { when (call.method) { "canPin" -> result.success(canPin()) "pin" -> { - GlobalScope.launch { pin(call) } + GlobalScope.launch(Dispatchers.IO) { pin(call) } result.success(null) } else -> result.notImplemented() 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 171a5469f..91f0486d0 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 @@ -25,6 +25,7 @@ import deckers.thibault.aves.utils.StorageUtils import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import org.beyka.tiffbitmapfactory.TiffBitmapFactory @@ -36,12 +37,12 @@ class DebugHandler(private val context: Context) : MethodCallHandler { when (call.method) { "getContextDirs" -> result.success(getContextDirs()) "getEnv" -> result.success(System.getenv()) - "getBitmapFactoryInfo" -> GlobalScope.launch { getBitmapFactoryInfo(call, Coresult(result)) } - "getContentResolverMetadata" -> GlobalScope.launch { getContentResolverMetadata(call, Coresult(result)) } - "getExifInterfaceMetadata" -> GlobalScope.launch { getExifInterfaceMetadata(call, Coresult(result)) } - "getMediaMetadataRetrieverMetadata" -> GlobalScope.launch { getMediaMetadataRetrieverMetadata(call, Coresult(result)) } - "getMetadataExtractorSummary" -> GlobalScope.launch { getMetadataExtractorSummary(call, Coresult(result)) } - "getTiffStructure" -> GlobalScope.launch { getTiffStructure(call, Coresult(result)) } + "getBitmapFactoryInfo" -> GlobalScope.launch(Dispatchers.IO) { getBitmapFactoryInfo(call, Coresult(result)) } + "getContentResolverMetadata" -> GlobalScope.launch(Dispatchers.IO) { getContentResolverMetadata(call, Coresult(result)) } + "getExifInterfaceMetadata" -> GlobalScope.launch(Dispatchers.IO) { getExifInterfaceMetadata(call, Coresult(result)) } + "getMediaMetadataRetrieverMetadata" -> GlobalScope.launch(Dispatchers.IO) { getMediaMetadataRetrieverMetadata(call, Coresult(result)) } + "getMetadataExtractorSummary" -> GlobalScope.launch(Dispatchers.IO) { getMetadataExtractorSummary(call, Coresult(result)) } + "getTiffStructure" -> GlobalScope.launch(Dispatchers.IO) { getTiffStructure(call, Coresult(result)) } else -> result.notImplemented() } } 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 6af5ad179..5d8555a5c 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 @@ -26,12 +26,12 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { - "getObsoleteEntries" -> GlobalScope.launch { getObsoleteEntries(call, Coresult(result)) } - "getImageEntry" -> GlobalScope.launch { getImageEntry(call, Coresult(result)) } - "getThumbnail" -> GlobalScope.launch { getThumbnail(call, Coresult(result)) } - "getRegion" -> GlobalScope.launch { getRegion(call, Coresult(result)) } + "getObsoleteEntries" -> GlobalScope.launch(Dispatchers.IO) { getObsoleteEntries(call, Coresult(result)) } + "getImageEntry" -> GlobalScope.launch(Dispatchers.IO) { getImageEntry(call, Coresult(result)) } + "getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { getThumbnail(call, Coresult(result)) } + "getRegion" -> GlobalScope.launch(Dispatchers.IO) { getRegion(call, Coresult(result)) } "clearSizedThumbnailDiskCache" -> { - GlobalScope.launch { Glide.get(activity).clearDiskCache() } + GlobalScope.launch(Dispatchers.IO) { Glide.get(activity).clearDiskCache() } result.success(null) } "rename" -> GlobalScope.launch(Dispatchers.IO) { rename(call, Coresult(result)) } 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 0b016d473..0d2d41135 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 @@ -61,6 +61,7 @@ import deckers.thibault.aves.utils.StorageUtils import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import java.io.File @@ -70,12 +71,12 @@ import kotlin.math.roundToLong class MetadataHandler(private val context: Context) : MethodCallHandler { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { - "getAllMetadata" -> GlobalScope.launch { getAllMetadata(call, Coresult(result)) } - "getCatalogMetadata" -> GlobalScope.launch { getCatalogMetadata(call, Coresult(result)) } - "getOverlayMetadata" -> GlobalScope.launch { getOverlayMetadata(call, Coresult(result)) } - "getEmbeddedPictures" -> GlobalScope.launch { getEmbeddedPictures(call, Coresult(result)) } - "getExifThumbnails" -> GlobalScope.launch { getExifThumbnails(call, Coresult(result)) } - "extractXmpDataProp" -> GlobalScope.launch { extractXmpDataProp(call, Coresult(result)) } + "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)) } + "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)) } else -> result.notImplemented() } } @@ -588,7 +589,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { "mimeType" to embedMimeType, ) if (isImage(embedMimeType) || isVideo(embedMimeType)) { - GlobalScope.launch { + GlobalScope.launch(Dispatchers.IO) { FileImageProvider().fetchSingle(context, embedUri, embedMimeType, object : ImageProvider.ImageOpCallback { override fun onSuccess(fields: FieldMap) { embedFields.putAll(fields) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt index ab2147817..6ff8babbb 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt @@ -11,6 +11,7 @@ import deckers.thibault.aves.utils.StorageUtils.getVolumePaths import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import java.io.File @@ -32,7 +33,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler { "getGrantedDirectories" -> result.success(ArrayList(PermissionManager.getGrantedDirs(context))) "getInaccessibleDirectories" -> getInaccessibleDirectories(call, result) "revokeDirectoryAccess" -> revokeDirectoryAccess(call, result) - "scanFile" -> GlobalScope.launch { scanFile(call, Coresult(result)) } + "scanFile" -> GlobalScope.launch(Dispatchers.IO) { scanFile(call, Coresult(result)) } else -> result.notImplemented() } } 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 df6e5177e..eceb88bce 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 @@ -18,6 +18,7 @@ import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide import deckers.thibault.aves.utils.StorageUtils import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel.EventSink +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import org.beyka.tiffbitmapfactory.TiffBitmapFactory @@ -32,7 +33,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen this.eventSink = eventSink handler = Handler(Looper.getMainLooper()) - GlobalScope.launch { streamImage() } + GlobalScope.launch(Dispatchers.IO) { streamImage() } } override fun onCancel(o: Any) {} 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 ec2aa52c5..311589a9a 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 @@ -7,6 +7,7 @@ import deckers.thibault.aves.model.provider.FieldMap import deckers.thibault.aves.model.provider.MediaStoreImageProvider import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel.EventSink +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch @@ -27,7 +28,7 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E this.eventSink = eventSink handler = Handler(Looper.getMainLooper()) - GlobalScope.launch { fetchAll() } + GlobalScope.launch(Dispatchers.IO) { fetchAll() } } override fun onCancel(arguments: Any?) {} diff --git a/android/build.gradle b/android/build.gradle index 11444be0a..65df263a7 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,6 +1,6 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext.kotlin_version = '1.4.20' + ext.kotlin_version = '1.4.21' repositories { google() jcenter() diff --git a/lib/image_providers/region_provider.dart b/lib/image_providers/region_provider.dart index d99a2217d..d1a21313d 100644 --- a/lib/image_providers/region_provider.dart +++ b/lib/image_providers/region_provider.dart @@ -125,7 +125,5 @@ class RegionProviderKey { ); @override - String toString() { - return 'RegionProviderKey(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, 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 e726ad0b0..e0cebca55 100644 --- a/lib/image_providers/thumbnail_provider.dart +++ b/lib/image_providers/thumbnail_provider.dart @@ -116,7 +116,5 @@ class ThumbnailProviderKey { ); @override - String toString() { - return 'ThumbnailProviderKey{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, extent=$extent, scale=$scale}'; } diff --git a/lib/image_providers/uri_image_provider.dart b/lib/image_providers/uri_image_provider.dart index 66f3bd8fb..1368f890c 100644 --- a/lib/image_providers/uri_image_provider.dart +++ b/lib/image_providers/uri_image_provider.dart @@ -80,5 +80,5 @@ class UriImage extends ImageProvider { int get hashCode => hashValues(uri, scale); @override - String toString() => '${objectRuntimeType(this, 'UriImage')}(uri=$uri, mimeType=$mimeType, scale=$scale)'; + String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, scale=$scale}'; } diff --git a/lib/image_providers/uri_picture_provider.dart b/lib/image_providers/uri_picture_provider.dart index 9165fc3f1..913c78690 100644 --- a/lib/image_providers/uri_picture_provider.dart +++ b/lib/image_providers/uri_picture_provider.dart @@ -54,5 +54,5 @@ class UriPicture extends PictureProvider { int get hashCode => hashValues(uri, colorFilter); @override - String toString() => '${objectRuntimeType(this, 'UriPicture')}(uri=$uri, mimeType=$mimeType, colorFilter=$colorFilter)'; + String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, colorFilter=$colorFilter}'; } diff --git a/lib/model/filters/album.dart b/lib/model/filters/album.dart index b5062e80e..82f3bcc56 100644 --- a/lib/model/filters/album.dart +++ b/lib/model/filters/album.dart @@ -87,7 +87,5 @@ class AlbumFilter extends CollectionFilter { int get hashCode => hashValues(type, album); @override - String toString() { - return '$runtimeType#${shortHash(this)}{album=$album}'; - } + String toString() => '$runtimeType#${shortHash(this)}{album=$album}'; } diff --git a/lib/model/filters/location.dart b/lib/model/filters/location.dart index 2c4a4a7cc..cf46da1b2 100644 --- a/lib/model/filters/location.dart +++ b/lib/model/filters/location.dart @@ -62,9 +62,7 @@ class LocationFilter extends CollectionFilter { int get hashCode => hashValues(type, level, _location); @override - String toString() { - return '$runtimeType#${shortHash(this)}{level=$level, location=$_location}'; - } + String toString() => '$runtimeType#${shortHash(this)}{level=$level, location=$_location}'; // U+0041 Latin Capital letter A // U+1F1E6 🇦 REGIONAL INDICATOR SYMBOL LETTER A diff --git a/lib/model/filters/mime.dart b/lib/model/filters/mime.dart index 9b9cb7213..2b3342140 100644 --- a/lib/model/filters/mime.dart +++ b/lib/model/filters/mime.dart @@ -88,7 +88,5 @@ class MimeFilter extends CollectionFilter { int get hashCode => hashValues(type, mime); @override - String toString() { - return '$runtimeType#${shortHash(this)}{mime=$mime}'; - } + String toString() => '$runtimeType#${shortHash(this)}{mime=$mime}'; } diff --git a/lib/model/filters/query.dart b/lib/model/filters/query.dart index 23794cdd0..bb880cb2f 100644 --- a/lib/model/filters/query.dart +++ b/lib/model/filters/query.dart @@ -71,7 +71,5 @@ class QueryFilter extends CollectionFilter { int get hashCode => hashValues(type, query); @override - String toString() { - return '$runtimeType#${shortHash(this)}{query=$query}'; - } + String toString() => '$runtimeType#${shortHash(this)}{query=$query}'; } diff --git a/lib/model/filters/tag.dart b/lib/model/filters/tag.dart index 3188d583c..ff9e94611 100644 --- a/lib/model/filters/tag.dart +++ b/lib/model/filters/tag.dart @@ -48,7 +48,5 @@ class TagFilter extends CollectionFilter { int get hashCode => hashValues(type, tag); @override - String toString() { - return '$runtimeType#${shortHash(this)}{tag=$tag}'; - } + String toString() => '$runtimeType#${shortHash(this)}{tag=$tag}'; } diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index 56251265e..4cd6f3cd3 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -7,6 +7,7 @@ import 'package:aves/model/metadata_db.dart'; import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/metadata_service.dart'; import 'package:aves/services/service_policy.dart'; +import 'package:aves/services/svg_metadata_service.dart'; import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/math_utils.dart'; import 'package:aves/utils/time_utils.dart'; @@ -24,8 +25,6 @@ class ImageEntry { String _path, _directory, _filename, _extension; int contentId; final String sourceMimeType; - - // TODO TLAD use SVG viewport as width/height int width; int height; int sourceRotationDegrees; @@ -64,6 +63,8 @@ class ImageEntry { bool get canDecode => !undecodable.contains(mimeType); + bool get canHaveAlpha => MimeTypes.alphaImages.contains(mimeType); + ImageEntry copyWith({ @required String uri, @required String path, @@ -134,9 +135,7 @@ class ImageEntry { } @override - String toString() { - return 'ImageEntry{uri=$uri, path=$path}'; - } + String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, path=$path}'; set path(String path) { _path = path; @@ -238,10 +237,24 @@ class ImageEntry { // but it would take space and time, so a basic workaround will do. bool get isPortrait => rotationDegrees % 180 == 90 && (catalogMetadata?.rotationDegrees == null || width > height); + static const ratioSeparator = '\u2236'; + static const resolutionSeparator = ' \u00D7 '; + String get resolutionText { final w = width ?? '?'; final h = height ?? '?'; - return isPortrait ? '$h × $w' : '$w × $h'; + return isPortrait ? '$h$resolutionSeparator$w' : '$w$resolutionSeparator$h'; + } + + String get aspectRatioText { + if (width != null && height != null && width > 0 && height > 0) { + final gcd = width.gcd(height); + final w = width ~/ gcd; + final h = height ~/ gcd; + return isPortrait ? '$h$ratioSeparator$w' : '$w$ratioSeparator$h'; + } else { + return '?$ratioSeparator?'; + } } double get displayAspectRatio { @@ -321,7 +334,7 @@ class ImageEntry { String _bestTitle; String get bestTitle { - _bestTitle ??= (isCatalogued && _catalogMetadata.xmpTitleDescription.isNotEmpty) ? _catalogMetadata.xmpTitleDescription : sourceTitle; + _bestTitle ??= (isCatalogued && _catalogMetadata.xmpTitleDescription?.isNotEmpty == true) ? _catalogMetadata.xmpTitleDescription : sourceTitle; return _bestTitle; } @@ -352,7 +365,20 @@ class ImageEntry { Future catalog({bool background = false}) async { if (isCatalogued) return; - catalogMetadata = await MetadataService.getCatalogMetadata(this, background: background); + if (isSvg) { + // vector image sizing is not essential, so we should not spend time for it during loading + // but it is useful anyway (for aspect ratios etc.) so we size them during cataloguing + final size = await SvgMetadataService.getSize(this); + if (size != null) { + await _applyNewFields({ + 'width': size.width.round(), + 'height': size.height.round(), + }); + } + catalogMetadata = CatalogMetadata(contentId: contentId); + } else { + catalogMetadata = await MetadataService.getCatalogMetadata(this, background: background); + } } AddressDetails get addressDetails => _addressDetails; @@ -449,6 +475,12 @@ class ImageEntry { this.sourceTitle = sourceTitle; _bestTitle = null; } + + final width = newFields['width']; + if (width is int) this.width = width; + final height = newFields['height']; + if (height is int) this.height = height; + final dateModifiedSecs = newFields['dateModifiedSecs']; if (dateModifiedSecs is int) this.dateModifiedSecs = dateModifiedSecs; final rotationDegrees = newFields['rotationDegrees']; diff --git a/lib/model/image_metadata.dart b/lib/model/image_metadata.dart index ac10537d9..04086161c 100644 --- a/lib/model/image_metadata.dart +++ b/lib/model/image_metadata.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:geocoder/model.dart'; import 'package:intl/intl.dart'; @@ -23,9 +24,7 @@ class DateMetadata { }; @override - String toString() { - return 'DateMetadata{contentId=$contentId, dateMillis=$dateMillis}'; - } + String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, dateMillis=$dateMillis}'; } class CatalogMetadata { @@ -47,10 +46,10 @@ class CatalogMetadata { this.contentId, this.mimeType, this.dateMillis, - this.isAnimated, - this.isFlipped, - this.isGeotiff, - this.is360, + this.isAnimated = false, + this.isFlipped = false, + this.isGeotiff = false, + this.is360 = false, this.rotationDegrees, this.xmpSubjects, this.xmpTitleDescription, @@ -117,9 +116,7 @@ class CatalogMetadata { }; @override - String toString() { - return 'CatalogMetadata{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, rotationDegrees=$rotationDegrees, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}'; } class OverlayMetadata { @@ -150,9 +147,7 @@ class OverlayMetadata { bool get isEmpty => aperture == null && exposureTime == null && focalLength == null && iso == null; @override - String toString() { - return 'OverlayMetadata{aperture=$aperture, exposureTime=$exposureTime, focalLength=$focalLength, iso=$iso}'; - } + String toString() => '$runtimeType#${shortHash(this)}{aperture=$aperture, exposureTime=$exposureTime, focalLength=$focalLength, iso=$iso}'; } class AddressDetails { @@ -200,9 +195,7 @@ class AddressDetails { }; @override - String toString() { - return 'AddressDetails{contentId=$contentId, countryCode=$countryCode, countryName=$countryName, adminArea=$adminArea, locality=$locality}'; - } + String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, countryCode=$countryCode, countryName=$countryName, adminArea=$adminArea, locality=$locality}'; } @immutable @@ -237,7 +230,5 @@ class FavouriteRow { int get hashCode => hashValues(contentId, path); @override - String toString() { - return 'FavouriteRow{contentId=$contentId, path=$path}'; - } + String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, path=$path}'; } diff --git a/lib/model/settings/entry_background.dart b/lib/model/settings/entry_background.dart new file mode 100644 index 000000000..ee0ffe4c1 --- /dev/null +++ b/lib/model/settings/entry_background.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +enum EntryBackground { black, white, transparent, checkered } + +extension ExtraEntryBackground on EntryBackground { + bool get isColor { + switch (this) { + case EntryBackground.black: + case EntryBackground.white: + return true; + default: + return false; + } + } + + Color get color { + switch (this) { + case EntryBackground.black: + return Colors.black; + case EntryBackground.white: + return Colors.white; + default: + return null; + } + } +} diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index 87f6e0380..7e39a0ca8 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -1,5 +1,6 @@ import 'package:aves/model/filters/filters.dart'; 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'; @@ -54,7 +55,8 @@ class Settings extends ChangeNotifier { static const coordinateFormatKey = 'coordinates_format'; // rendering - static const svgBackgroundKey = 'svg_background'; + static const rasterBackgroundKey = 'raster_background'; + static const vectorBackgroundKey = 'vector_background'; // search static const saveSearchHistoryKey = 'save_search_history'; @@ -184,9 +186,13 @@ class Settings extends ChangeNotifier { // rendering - int get svgBackground => _prefs.getInt(svgBackgroundKey) ?? 0xFFFFFFFF; + EntryBackground get rasterBackground => getEnumOrDefault(rasterBackgroundKey, EntryBackground.transparent, EntryBackground.values); - set svgBackground(int newValue) => setAndNotify(svgBackgroundKey, newValue); + set rasterBackground(EntryBackground newValue) => setAndNotify(rasterBackgroundKey, newValue.toString()); + + EntryBackground get vectorBackground => getEnumOrDefault(vectorBackgroundKey, EntryBackground.white, EntryBackground.values); + + set vectorBackground(EntryBackground newValue) => setAndNotify(vectorBackgroundKey, newValue.toString()); // search diff --git a/lib/model/source/tag.dart b/lib/model/source/tag.dart index 28a879ab3..55a1053ca 100644 --- a/lib/model/source/tag.dart +++ b/lib/model/source/tag.dart @@ -23,7 +23,7 @@ mixin TagMixin on SourceBase { Future catalogEntries() async { // final stopwatch = Stopwatch()..start(); - final todo = rawEntries.where((entry) => !entry.isCatalogued && !entry.isSvg).toList(); + final todo = rawEntries.where((entry) => !entry.isCatalogued).toList(); if (todo.isEmpty) return; var progressDone = 0; diff --git a/lib/ref/mime_types.dart b/lib/ref/mime_types.dart index fe95d5309..69dfe3800 100644 --- a/lib/ref/mime_types.dart +++ b/lib/ref/mime_types.dart @@ -1,15 +1,17 @@ class MimeTypes { static const anyImage = 'image/*'; + static const bmp = 'image/bmp'; static const gif = 'image/gif'; static const heic = 'image/heic'; static const heif = 'image/heif'; + static const ico = 'image/x-icon'; static const jpeg = 'image/jpeg'; static const png = 'image/png'; static const svg = 'image/svg+xml'; + static const tiff = 'image/tiff'; static const webp = 'image/webp'; - static const tiff = 'image/tiff'; static const psd = 'image/vnd.adobe.photoshop'; static const arw = 'image/x-sony-arw'; @@ -40,6 +42,10 @@ class MimeTypes { static const mp4 = 'video/mp4'; // groups + + // formats that support transparency + static const List alphaImages = [bmp, gif, ico, png, svg, tiff, webp]; + static const List rawImages = [arw, cr2, crw, dcr, dng, erf, k25, kdc, mrw, nef, nrw, orf, pef, raf, raw, rw2, sr2, srf, srw, x3f]; static bool isImage(String mimeType) => mimeType.startsWith('image'); diff --git a/lib/ref/xmp.dart b/lib/ref/xmp.dart index ebff07696..02a3bfa9e 100644 --- a/lib/ref/xmp.dart +++ b/lib/ref/xmp.dart @@ -15,6 +15,7 @@ class XMP { 'exifEX': 'Exif Ex', 'GettyImagesGIFT': 'Getty Images', 'GIMP': 'GIMP', + 'GCamera': 'Google Camera', 'GFocus': 'Google Focus', 'GPano': 'Google Panorama', 'illustrator': 'Illustrator', diff --git a/lib/services/image_file_service.dart b/lib/services/image_file_service.dart index c27d72d30..71d9d9bb1 100644 --- a/lib/services/image_file_service.dart +++ b/lib/services/image_file_service.dart @@ -300,9 +300,7 @@ class ImageOpEvent { int get hashCode => hashValues(success, uri); @override - String toString() { - return 'ImageOpEvent{success=$success, uri=$uri}'; - } + String toString() => '$runtimeType#${shortHash(this)}{success=$success, uri=$uri}'; } class MoveOpEvent extends ImageOpEvent { @@ -323,9 +321,7 @@ class MoveOpEvent extends ImageOpEvent { } @override - String toString() { - return 'MoveOpEvent{success=$success, uri=$uri, newFields=$newFields}'; - } + String toString() => '$runtimeType#${shortHash(this)}{success=$success, uri=$uri, newFields=$newFields}'; } // cf flutter/foundation `consolidateHttpClientResponseBytes` diff --git a/lib/services/service_policy.dart b/lib/services/service_policy.dart index 6794a75e5..c4f8fa043 100644 --- a/lib/services/service_policy.dart +++ b/lib/services/service_policy.dart @@ -10,7 +10,10 @@ class ServicePolicy { final StreamController _queueStreamController = StreamController.broadcast(); final Map> _paused = {}; final SplayTreeMap> _queues = SplayTreeMap(); - _Task _running; + final Queue<_Task> _runningQueue = Queue(); + + // magic number + static const concurrentTaskMax = 4; Stream get queueStream => _queueStreamController.stream; @@ -23,6 +26,7 @@ class ServicePolicy { Object key, }) { _Task task; + key ??= platformCall.hashCode; final priorityTask = _paused.remove(key); if (priorityTask != null) { debugPrint('resume task with key=$key'); @@ -39,7 +43,7 @@ class ServicePolicy { completer.completeError(error, stackTrace); } if (debugLabel != null) debugPrint('$runtimeType $debugLabel completed'); - _running = null; + _runningQueue.removeWhere((task) => task.key == key); _pickNext(); }, completer, @@ -64,10 +68,13 @@ class ServicePolicy { void _pickNext() { _notifyQueueState(); - if (_running != null) return; + if (_runningQueue.length >= concurrentTaskMax) return; final queue = _queues.entries.firstWhere((kv) => kv.value.isNotEmpty, orElse: () => null)?.value; - _running = queue?.removeFirst(); - _running?.callback?.call(); + final task = queue?.removeFirst(); + if (task != null) { + _runningQueue.addLast(task); + task.callback(); + } } bool _takeOut(Object key, Iterable priorities, void Function(int priority, _Task task) action) { @@ -99,7 +106,7 @@ class ServicePolicy { if (!_queueStreamController.hasListener) return; final queueByPriority = Map.fromEntries(_queues.entries.map((kv) => MapEntry(kv.key, kv.value.length))); - _queueStreamController.add(QueueState(queueByPriority)); + _queueStreamController.add(QueueState(queueByPriority, _runningQueue.length)); } } @@ -124,6 +131,7 @@ class ServiceCallPriority { class QueueState { final Map queueByPriority; + final int runningQueue; - const QueueState(this.queueByPriority); + const QueueState(this.queueByPriority, this.runningQueue); } diff --git a/lib/services/svg_metadata_service.dart b/lib/services/svg_metadata_service.dart new file mode 100644 index 000000000..8d09750bc --- /dev/null +++ b/lib/services/svg_metadata_service.dart @@ -0,0 +1,86 @@ +import 'dart:convert'; + +import 'package:aves/model/image_entry.dart'; +import 'package:aves/services/image_file_service.dart'; +import 'package:aves/utils/string_utils.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:xml/xml.dart'; + +class SvgMetadataService { + static const docDirectory = 'Document'; + static const metadataDirectory = 'Metadata'; + + static const _attributes = ['x', 'y', 'width', 'height', 'preserveAspectRatio', 'viewBox']; + static const _textElements = ['title', 'desc']; + static const _metadataElement = 'metadata'; + + static Future getSize(ImageEntry entry) async { + try { + final data = await ImageFileService.getImage(entry.uri, entry.mimeType, 0, false); + + final document = XmlDocument.parse(utf8.decode(data)); + final root = document.rootElement; + + String getAttribute(String attributeName) => root.attributes.firstWhere((a) => a.name.qualified == attributeName, orElse: () => null)?.value; + double tryParseWithoutUnit(String s) => s == null ? null : double.tryParse(s.replaceAll(RegExp(r'[a-z%]'), '')); + + final width = tryParseWithoutUnit(getAttribute('width')); + final height = tryParseWithoutUnit(getAttribute('height')); + if (width != null && height != null) { + return Size(width, height); + } + + final viewBox = getAttribute('viewBox'); + if (viewBox != null) { + final parts = viewBox.split(RegExp(r'[\s,]+')); + if (parts.length == 4) { + final vbWidth = tryParseWithoutUnit(parts[2]); + final vbHeight = tryParseWithoutUnit(parts[3]); + if (vbWidth > 0 && vbHeight > 0) { + return Size(vbWidth, vbHeight); + } + } + } + } catch (exception, stack) { + debugPrint('failed to parse XML from SVG with exception=$exception\n$stack'); + } + return null; + } + + static Future>> getAllMetadata(ImageEntry entry) async { + String formatKey(String key) { + switch (key) { + case 'desc': + return 'Description'; + default: + return key.toSentenceCase(); + } + } + + try { + final data = await ImageFileService.getImage(entry.uri, entry.mimeType, 0, false); + + final document = XmlDocument.parse(utf8.decode(data)); + final root = document.rootElement; + + final docDir = Map.fromEntries([ + ...root.attributes.where((a) => _attributes.contains(a.name.qualified)).map((a) => MapEntry(formatKey(a.name.qualified), a.value)), + ..._textElements.map((name) => MapEntry(formatKey(name), root.getElement(name)?.text)).where((kv) => kv.value != null), + ]); + + final metadata = root.getElement(_metadataElement); + final metadataDir = Map.fromEntries([ + if (metadata != null) MapEntry('Metadata', metadata.toXmlString(pretty: true)), + ]); + + return { + if (docDir.isNotEmpty) docDirectory: docDir, + if (metadataDir.isNotEmpty) metadataDirectory: metadataDir, + }; + } catch (exception, stack) { + debugPrint('failed to parse XML from SVG with exception=$exception\n$stack'); + return null; + } + } +} diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index a86fbfee5..b31208632 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -239,12 +239,6 @@ class Constants { licenseUrl: 'https://github.com/Baseflow/flutter-permission-handler/blob/develop/permission_handler/LICENSE', sourceUrl: 'https://github.com/Baseflow/flutter-permission-handler', ), - Dependency( - name: 'Photo View', - license: 'MIT', - licenseUrl: 'https://github.com/renancaraujo/photo_view/blob/master/LICENSE', - sourceUrl: 'https://github.com/renancaraujo/photo_view', - ), Dependency( name: 'Printing', license: 'Apache 2.0', diff --git a/lib/widgets/collection/grid/list_sliver.dart b/lib/widgets/collection/grid/list_sliver.dart index 91089845c..d01c7800e 100644 --- a/lib/widgets/collection/grid/list_sliver.dart +++ b/lib/widgets/collection/grid/list_sliver.dart @@ -4,9 +4,9 @@ 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/common/scaling.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'; diff --git a/lib/widgets/collection/thumbnail/decorated.dart b/lib/widgets/collection/thumbnail/decorated.dart index 71dd0377c..073656a6a 100644 --- a/lib/widgets/collection/thumbnail/decorated.dart +++ b/lib/widgets/collection/thumbnail/decorated.dart @@ -43,7 +43,7 @@ class DecoratedThumbnail extends StatelessWidget { ); child = Stack( - fit: StackFit.passthrough, + alignment: Alignment.center, children: [ child, Positioned( diff --git a/lib/widgets/collection/thumbnail/vector.dart b/lib/widgets/collection/thumbnail/vector.dart index ed238e02e..384fae2e8 100644 --- a/lib/widgets/collection/thumbnail/vector.dart +++ b/lib/widgets/collection/thumbnail/vector.dart @@ -1,6 +1,8 @@ -import 'package:aves/model/image_entry.dart'; -import 'package:aves/model/settings/settings.dart'; import 'package:aves/image_providers/uri_picture_provider.dart'; +import 'package:aves/model/image_entry.dart'; +import 'package:aves/model/settings/entry_background.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/widgets/common/fx/checkered_decoration.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:provider/provider.dart'; @@ -19,26 +21,47 @@ class ThumbnailVectorImage extends StatelessWidget { @override Widget build(BuildContext context) { - final child = Container( - // center `SvgPicture` inside `Container` with the thumbnail dimensions - // so that `SvgPicture` doesn't get aligned by the `Stack` like the overlay icons - width: extent, - height: extent, - child: Selector( - selector: (context, s) => s.svgBackground, - builder: (context, svgBackground, child) { - final colorFilter = ColorFilter.mode(Color(svgBackground), BlendMode.dstOver); - return SvgPicture( - UriPicture( - uri: entry.uri, - mimeType: entry.mimeType, - colorFilter: colorFilter, - ), - width: extent, - height: extent, + final child = Selector( + selector: (context, s) => s.vectorBackground, + builder: (context, background, child) { + const fit = BoxFit.contain; + if (background == EntryBackground.checkered) { + return LayoutBuilder( + builder: (context, constraints) { + final availableSize = constraints.biggest; + final fitSize = applyBoxFit(fit, entry.displaySize, availableSize).destination; + final offset = fitSize / 2 - availableSize / 2; + final child = DecoratedBox( + decoration: CheckeredDecoration(checkSize: extent / 8, offset: offset), + child: SvgPicture( + UriPicture( + uri: entry.uri, + mimeType: entry.mimeType, + ), + width: fitSize.width, + height: fitSize.height, + fit: fit, + ), + ); + // the thumbnail is centered for correct decoration sizing + // when constraints are tight during hero animation + return constraints.isTight ? Center(child: child) : child; + }, ); - }, - ), + } + + final colorFilter = background.isColor ? ColorFilter.mode(background.color, BlendMode.dstOver) : null; + return SvgPicture( + UriPicture( + uri: entry.uri, + mimeType: entry.mimeType, + colorFilter: colorFilter, + ), + width: extent, + height: extent, + fit: fit, + ); + }, ); return heroTag == null ? child diff --git a/lib/widgets/common/fx/checkered_decoration.dart b/lib/widgets/common/fx/checkered_decoration.dart new file mode 100644 index 000000000..4d541eaee --- /dev/null +++ b/lib/widgets/common/fx/checkered_decoration.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; + +class CheckeredDecoration extends Decoration { + final Color light, dark; + final double checkSize; + final Offset offset; + + const CheckeredDecoration({ + this.light = const Color(0xFF999999), + this.dark = const Color(0xFF666666), + this.checkSize = 20, + this.offset = Offset.zero, + }); + + @override + _CheckeredDecorationPainter createBoxPainter([VoidCallback onChanged]) { + return _CheckeredDecorationPainter(this, onChanged); + } +} + +class _CheckeredDecorationPainter extends BoxPainter { + final CheckeredDecoration decoration; + + const _CheckeredDecorationPainter(this.decoration, VoidCallback onChanged) : super(onChanged); + + @override + void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) { + final size = configuration.size; + var dx = offset.dx; + var dy = offset.dy; + + final lightPaint = Paint()..color = decoration.light; + final darkPaint = Paint()..color = decoration.dark; + final checkSize = decoration.checkSize; + + // save/restore because of the clip + canvas.save(); + canvas.clipRect(Rect.fromLTWH(dx, dy, size.width, size.height)); + + canvas.drawPaint(lightPaint); + + dx += decoration.offset.dx % (decoration.checkSize * 2); + dy += decoration.offset.dy % (decoration.checkSize * 2); + + final xMax = size.width / checkSize; + final yMax = size.height / checkSize; + for (var x = -2; x < xMax; x++) { + for (var y = -2; y < yMax; y++) { + if ((x + y) % 2 == 0) { + final rect = Rect.fromLTWH(dx + x * checkSize, dy + y * checkSize, checkSize, checkSize); + canvas.drawRect(rect, darkPaint); + } + } + } + canvas.restore(); + } +} diff --git a/lib/widgets/common/fx/highlight_decoration.dart b/lib/widgets/common/fx/highlight_decoration.dart index 123e186a4..096d139cd 100644 --- a/lib/widgets/common/fx/highlight_decoration.dart +++ b/lib/widgets/common/fx/highlight_decoration.dart @@ -6,15 +6,15 @@ class HighlightDecoration extends Decoration { const HighlightDecoration({@required this.color}); @override - HighlightDecorationPainter createBoxPainter([VoidCallback onChanged]) { - return HighlightDecorationPainter(this, onChanged); + _HighlightDecorationPainter createBoxPainter([VoidCallback onChanged]) { + return _HighlightDecorationPainter(this, onChanged); } } -class HighlightDecorationPainter extends BoxPainter { +class _HighlightDecorationPainter extends BoxPainter { final HighlightDecoration decoration; - const HighlightDecorationPainter(this.decoration, VoidCallback onChanged) : super(onChanged); + const _HighlightDecorationPainter(this.decoration, VoidCallback onChanged) : super(onChanged); @override void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) { diff --git a/lib/widgets/common/magnifier/controller/controller.dart b/lib/widgets/common/magnifier/controller/controller.dart new file mode 100644 index 000000000..d4c5b37f9 --- /dev/null +++ b/lib/widgets/common/magnifier/controller/controller.dart @@ -0,0 +1,128 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:aves/widgets/common/magnifier/controller/state.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:flutter/widgets.dart'; + +class MagnifierController { + final StreamController _stateStreamController = StreamController.broadcast(); + final StreamController _scaleBoundariesStreamController = StreamController.broadcast(); + final StreamController _scaleStateChangeStreamController = StreamController.broadcast(); + + MagnifierState _currentState, initial, previousState; + ScaleBoundaries _scaleBoundaries; + ScaleStateChange _currentScaleState, previousScaleState; + + MagnifierController({ + Offset initialPosition = Offset.zero, + }) : super() { + initial = MagnifierState( + position: initialPosition, + scale: null, + source: ChangeSource.internal, + ); + previousState = initial; + _setState(initial); + + final _initialScaleState = ScaleStateChange(state: ScaleState.initial, source: ChangeSource.internal); + previousScaleState = _initialScaleState; + _setScaleState(_initialScaleState); + } + + Stream get stateStream => _stateStreamController.stream; + + Stream get scaleBoundariesStream => _scaleBoundariesStreamController.stream; + + Stream get scaleStateChangeStream => _scaleStateChangeStreamController.stream; + + MagnifierState get currentState => _currentState; + + Offset get position => currentState.position; + + double get scale => currentState.scale; + + ScaleBoundaries get scaleBoundaries => _scaleBoundaries; + + ScaleStateChange get scaleState => _currentScaleState; + + bool get hasScaleSateChanged => previousScaleState != scaleState; + + bool get isZooming => scaleState.state == ScaleState.zoomedIn || scaleState.state == ScaleState.zoomedOut; + + /// Closes streams and removes eventual listeners. + void dispose() { + _stateStreamController.close(); + _scaleBoundariesStreamController.close(); + _scaleStateChangeStreamController.close(); + } + + void update({ + Offset position, + double scale, + @required ChangeSource source, + }) { + position = position ?? this.position; + scale = scale ?? this.scale; + if (this.position == position && this.scale == scale) return; + + previousState = currentState; + _setState(MagnifierState( + position: position, + scale: scale, + source: source, + )); + } + + void setScaleState(ScaleState newValue, ChangeSource source, {Offset childFocalPoint}) { + if (_currentScaleState.state == newValue) return; + + previousScaleState = _currentScaleState; + _currentScaleState = ScaleStateChange(state: newValue, source: source, childFocalPoint: childFocalPoint); + _scaleStateChangeStreamController.sink.add(scaleState); + } + + void _setState(MagnifierState state) { + if (_currentState == state) return; + _currentState = state; + _stateStreamController.sink.add(state); + } + + void setScaleBoundaries(ScaleBoundaries scaleBoundaries) { + if (_scaleBoundaries == scaleBoundaries) return; + _scaleBoundaries = scaleBoundaries; + _scaleBoundariesStreamController.sink.add(scaleBoundaries); + + if (!isZooming) { + update( + scale: getScaleForScaleState(_currentScaleState.state), + source: ChangeSource.internal, + ); + } + } + + void _setScaleState(ScaleStateChange scaleState) { + if (_currentScaleState == scaleState) return; + _currentScaleState = scaleState; + _scaleStateChangeStreamController.sink.add(_currentScaleState); + } + + double getScaleForScaleState(ScaleState scaleState) { + double _clamp(double scale, ScaleBoundaries boundaries) => scale.clamp(boundaries.minScale, boundaries.maxScale); + + switch (scaleState) { + case ScaleState.initial: + case ScaleState.zoomedIn: + case ScaleState.zoomedOut: + return _clamp(scaleBoundaries.initialScale, scaleBoundaries); + case ScaleState.covering: + return _clamp(ScaleLevel.scaleForCovering(scaleBoundaries.viewportSize, scaleBoundaries.childSize), scaleBoundaries); + case ScaleState.originalSize: + return _clamp(1.0, scaleBoundaries); + default: + return null; + } + } +} diff --git a/lib/widgets/common/magnifier/controller/controller_delegate.dart b/lib/widgets/common/magnifier/controller/controller_delegate.dart new file mode 100644 index 000000000..4169dced5 --- /dev/null +++ b/lib/widgets/common/magnifier/controller/controller_delegate.dart @@ -0,0 +1,196 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:aves/widgets/common/magnifier/controller/controller.dart'; +import 'package:aves/widgets/common/magnifier/controller/state.dart'; +import 'package:aves/widgets/common/magnifier/core/core.dart'; +import 'package:aves/widgets/common/magnifier/scale/scale_boundaries.dart'; +import 'package:aves/widgets/common/magnifier/scale/state.dart'; +import 'package:flutter/widgets.dart'; + +/// A class to hold internal layout logic to sync both controller states +/// +/// It reacts to layout changes (eg: enter landscape or widget resize) and syncs the two controllers. +mixin MagnifierControllerDelegate on State { + MagnifierController get controller => widget.controller; + + ScaleBoundaries get scaleBoundaries => controller.scaleBoundaries; + + ScaleStateCycle get scaleStateCycle => widget.scaleStateCycle; + + Alignment get basePosition => Alignment.center; + + Function(double prevScale, double nextScale, Offset nextPosition) _animateScale; + + /// Mark if scale need recalculation, useful for scale boundaries changes. + bool markNeedsScaleRecalc = true; + + final List _subscriptions = []; + + void startListeners() { + _subscriptions.add(controller.stateStream.listen(_onMagnifierStateChange)); + _subscriptions.add(controller.scaleStateChangeStream.listen(_onScaleStateChange)); + } + + void _onScaleStateChange(ScaleStateChange scaleStateChange) { + if (scaleStateChange.source == ChangeSource.internal) return; + if (!controller.hasScaleSateChanged) return; + + if (_animateScale == null || controller.isZooming) { + controller.update(scale: scale, source: scaleStateChange.source); + return; + } + + final nextScaleState = scaleStateChange.state; + final nextScale = controller.getScaleForScaleState(nextScaleState); + var nextPosition = Offset.zero; + if (nextScaleState == ScaleState.covering || nextScaleState == ScaleState.originalSize) { + final childFocalPoint = scaleStateChange.childFocalPoint; + if (childFocalPoint != null) { + nextPosition = scaleBoundaries.childToStatePosition(nextScale, childFocalPoint); + } + } + + final prevScale = controller.scale ?? controller.getScaleForScaleState(controller.previousScaleState.state); + _animateScale(prevScale, nextScale, nextPosition); + } + + void setScaleStateUpdateAnimation(void Function(double prevScale, double nextScale, Offset nextPosition) animateScale) { + _animateScale = animateScale; + } + + void _onMagnifierStateChange(MagnifierState state) { + controller.update(position: clampPosition(), source: state.source); + if (controller.scale == controller.previousState.scale) return; + + if (state.source == ChangeSource.internal || state.source == ChangeSource.animation) return; + final newScaleState = (scale > scaleBoundaries.initialScale) ? ScaleState.zoomedIn : ScaleState.zoomedOut; + controller.setScaleState(newScaleState, state.source); + } + + Offset get position => controller.position; + + double get scale { + final scaleState = controller.scaleState.state; + final needsRecalc = markNeedsScaleRecalc && !(scaleState == ScaleState.zoomedIn || scaleState == ScaleState.zoomedOut); + final scaleExistsOnController = controller.scale != null; + if (needsRecalc || !scaleExistsOnController) { + final newScale = controller.getScaleForScaleState(scaleState); + markNeedsScaleRecalc = false; + setScale(newScale, ChangeSource.internal); + return newScale; + } + return controller.scale; + } + + void setScale(double scale, ChangeSource source) => controller.update(scale: scale, source: source); + + void updateMultiple({ + @required Offset position, + @required double scale, + @required ChangeSource source, + }) { + controller.update(position: position, scale: scale, source: source); + } + + void updateScaleStateFromNewScale(double newScale, ChangeSource source) { + var newScaleState = ScaleState.initial; + if (scale != scaleBoundaries.initialScale) { + newScaleState = (newScale > scaleBoundaries.initialScale) ? ScaleState.zoomedIn : ScaleState.zoomedOut; + } + controller.setScaleState(newScaleState, source); + } + + void nextScaleState(ChangeSource source, {Offset childFocalPoint}) { + final scaleState = controller.scaleState.state; + if (scaleState == ScaleState.zoomedIn || scaleState == ScaleState.zoomedOut) { + controller.setScaleState(scaleStateCycle(scaleState), source, childFocalPoint: childFocalPoint); + return; + } + final originalScale = controller.getScaleForScaleState(scaleState); + + var prevScale = originalScale; + var prevScaleState = scaleState; + var nextScale = originalScale; + var nextScaleState = scaleState; + + do { + prevScale = nextScale; + prevScaleState = nextScaleState; + nextScaleState = scaleStateCycle(prevScaleState); + nextScale = controller.getScaleForScaleState(nextScaleState); + } while (prevScale == nextScale && scaleState != nextScaleState); + + if (originalScale == nextScale) return; + controller.setScaleState(nextScaleState, source, childFocalPoint: childFocalPoint); + } + + CornersRange cornersX({double scale}) { + final _scale = scale ?? this.scale; + + final computedWidth = scaleBoundaries.childSize.width * _scale; + final screenWidth = scaleBoundaries.viewportSize.width; + + final positionX = basePosition.x; + final widthDiff = computedWidth - screenWidth; + + final minX = ((positionX - 1).abs() / 2) * widthDiff * -1; + final maxX = ((positionX + 1).abs() / 2) * widthDiff; + return CornersRange(minX, maxX); + } + + CornersRange cornersY({double scale}) { + final _scale = scale ?? this.scale; + + final computedHeight = scaleBoundaries.childSize.height * _scale; + final screenHeight = scaleBoundaries.viewportSize.height; + + final positionY = basePosition.y; + final heightDiff = computedHeight - screenHeight; + + final minY = ((positionY - 1).abs() / 2) * heightDiff * -1; + final maxY = ((positionY + 1).abs() / 2) * heightDiff; + return CornersRange(minY, maxY); + } + + Offset clampPosition({Offset position, double scale}) { + final _scale = scale ?? this.scale; + final _position = position ?? this.position; + + final computedWidth = scaleBoundaries.childSize.width * _scale; + final computedHeight = scaleBoundaries.childSize.height * _scale; + + final screenWidth = scaleBoundaries.viewportSize.width; + final screenHeight = scaleBoundaries.viewportSize.height; + + var finalX = 0.0; + if (screenWidth < computedWidth) { + final cornersX = this.cornersX(scale: _scale); + finalX = _position.dx.clamp(cornersX.min, cornersX.max); + } + + var finalY = 0.0; + if (screenHeight < computedHeight) { + final cornersY = this.cornersY(scale: _scale); + finalY = _position.dy.clamp(cornersY.min, cornersY.max); + } + + return Offset(finalX, finalY); + } + + @override + void dispose() { + _animateScale = null; + _subscriptions.forEach((sub) => sub.cancel()); + _subscriptions.clear(); + super.dispose(); + } +} + +/// Simple class to store a min and a max value +class CornersRange { + const CornersRange(this.min, this.max); + + final double min; + final double max; +} diff --git a/lib/widgets/common/magnifier/controller/state.dart b/lib/widgets/common/magnifier/controller/state.dart new file mode 100644 index 000000000..6185a1707 --- /dev/null +++ b/lib/widgets/common/magnifier/controller/state.dart @@ -0,0 +1,28 @@ +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +@immutable +class MagnifierState { + const MagnifierState({ + @required this.position, + @required this.scale, + @required this.source, + }); + + final Offset position; + final double scale; + final ChangeSource source; + + @override + bool operator ==(Object other) => identical(this, other) || other is MagnifierState && runtimeType == other.runtimeType && position == other.position && scale == other.scale; + + @override + int get hashCode => hashValues(position, scale, source); + + @override + String toString() => '$runtimeType#${shortHash(this)}{position: $position, scale: $scale, source: $source}'; +} + +enum ChangeSource { internal, gesture, animation } diff --git a/lib/widgets/common/magnifier/core/core.dart b/lib/widgets/common/magnifier/core/core.dart new file mode 100644 index 000000000..735434a42 --- /dev/null +++ b/lib/widgets/common/magnifier/core/core.dart @@ -0,0 +1,320 @@ +import 'package:aves/widgets/common/magnifier/controller/controller.dart'; +import 'package:aves/widgets/common/magnifier/controller/controller_delegate.dart'; +import 'package:aves/widgets/common/magnifier/controller/state.dart'; +import 'package:aves/widgets/common/magnifier/core/gesture_detector.dart'; +import 'package:aves/widgets/common/magnifier/magnifier.dart'; +import 'package:aves/widgets/common/magnifier/pan/corner_hit_detector.dart'; +import 'package:aves/widgets/common/magnifier/scale/scale_boundaries.dart'; +import 'package:aves/widgets/common/magnifier/scale/state.dart'; +import 'package:flutter/widgets.dart'; + +/// Internal widget in which controls all animations lifecycle, core responses +/// to user gestures, updates to the controller state and mounts the entire Layout +class MagnifierCore extends StatefulWidget { + const MagnifierCore({ + Key key, + @required this.child, + @required this.onTap, + @required this.gestureDetectorBehavior, + @required this.controller, + @required this.scaleStateCycle, + @required this.applyScale, + this.panInertia = .2, + }) : super(key: key); + + final Widget child; + + final MagnifierController controller; + final ScaleStateCycle scaleStateCycle; + + final MagnifierTapCallback onTap; + + final HitTestBehavior gestureDetectorBehavior; + final bool applyScale; + final double panInertia; + + @override + State createState() { + return MagnifierCoreState(); + } +} + +class MagnifierCoreState extends State with TickerProviderStateMixin, MagnifierControllerDelegate, CornerHitDetector { + Offset _startFocalPoint, _lastViewportFocalPosition; + double _startScale, _quickScaleLastY, _quickScaleLastDistance; + bool _doubleTap, _quickScaleMoved; + DateTime _lastScaleGestureDate; + + AnimationController _scaleAnimationController; + Animation _scaleAnimation; + + AnimationController _positionAnimationController; + Animation _positionAnimation; + + ScaleBoundaries cachedScaleBoundaries; + + void handleScaleAnimation() { + setScale(_scaleAnimation.value, ChangeSource.animation); + } + + void handlePositionAnimate() { + controller.update(position: _positionAnimation.value, source: ChangeSource.animation); + } + + void onScaleStart(ScaleStartDetails details, bool doubleTap) { + _startScale = scale; + _startFocalPoint = details.localFocalPoint; + _lastViewportFocalPosition = _startFocalPoint; + _doubleTap = doubleTap; + _quickScaleLastDistance = null; + _quickScaleLastY = _startFocalPoint.dy; + _quickScaleMoved = false; + + _scaleAnimationController.stop(); + _positionAnimationController.stop(); + } + + void onScaleUpdate(ScaleUpdateDetails details) { + double newScale; + if (_doubleTap) { + // quick scale, aka one finger zoom + // magic numbers from `davemorrissey/subsampling-scale-image-view` + final focalPointY = details.focalPoint.dy; + final distance = (focalPointY - _startFocalPoint.dy).abs() * 2 + 20; + _quickScaleLastDistance ??= distance; + final spanDiff = (1 - (distance / _quickScaleLastDistance)).abs() * .5; + _quickScaleMoved |= spanDiff > .03; + final factor = _quickScaleMoved ? (focalPointY > _quickScaleLastY ? (1 + spanDiff) : (1 - spanDiff)) : 1; + _quickScaleLastDistance = distance; + _quickScaleLastY = focalPointY; + newScale = scale * factor; + } else { + newScale = _startScale * details.scale; + } + final scaleFocalPoint = _doubleTap ? _startFocalPoint : details.focalPoint; + + final panPositionDelta = scaleFocalPoint - _lastViewportFocalPosition; + final scalePositionDelta = scaleBoundaries.viewportToStatePosition(controller, scaleFocalPoint) * (scale / newScale - 1); + final newPosition = position + panPositionDelta + scalePositionDelta; + + updateScaleStateFromNewScale(newScale, ChangeSource.gesture); + updateMultiple( + scale: newScale, + position: newPosition, + source: ChangeSource.gesture, + ); + + _lastViewportFocalPosition = scaleFocalPoint; + } + + void onScaleEnd(ScaleEndDetails details) { + final _position = controller.position; + final _scale = controller.scale; + final maxScale = scaleBoundaries.maxScale; + final minScale = scaleBoundaries.minScale; + + // animate back to min/max scale if gesture yielded a scale exceeding them + if (_scale > maxScale || _scale < minScale) { + final newScale = _scale.clamp(minScale, maxScale); + final newPosition = clampPosition(position: _position * newScale / _scale, scale: newScale); + animateScale(_scale, newScale); + animatePosition(_position, newPosition); + return; + } + + // The gesture recognizer triggers a new `onScaleStart` every time a pointer/finger is added or removed. + // Following a pinch-to-zoom gesture, a new panning gesture may start if the user does not lift both fingers at the same time, + // so we dismiss such panning gestures when it looks like it followed a scaling gesture. + final isPanning = _scale == _startScale && (DateTime.now().difference(_lastScaleGestureDate)).inMilliseconds > 100; + + // animate position only when panning without scaling + if (isPanning) { + final pps = details.velocity.pixelsPerSecond; + if (pps != Offset.zero) { + final newPosition = clampPosition(position: _position + pps * widget.panInertia); + final tween = Tween(begin: _position, end: newPosition); + const curve = Curves.easeOutCubic; + _positionAnimation = tween.animate(CurvedAnimation(parent: _positionAnimationController, curve: curve)); + _positionAnimationController + ..duration = _getAnimationDurationForVelocity(curve: curve, tween: tween, targetPixelPerSecond: pps) + ..forward(from: 0.0); + } + } + + if (_scale != _startScale) { + _lastScaleGestureDate = DateTime.now(); + } + } + + Duration _getAnimationDurationForVelocity({ + Cubic curve, + Tween tween, + Offset targetPixelPerSecond, + }) { + assert(targetPixelPerSecond != Offset.zero); + // find initial animation velocity over the first 20% of the specified curve + const t = 0.2; + final animationVelocity = (tween.end - tween.begin).distance * curve.transform(t) / t; + final gestureVelocity = targetPixelPerSecond.distance; + return Duration(milliseconds: (animationVelocity / gestureVelocity * 1000).round()); + } + + void onTap(TapUpDetails details) { + if (widget.onTap == null) return; + + final viewportTapPosition = details.localPosition; + final childTapPosition = scaleBoundaries.viewportToChildPosition(controller, viewportTapPosition); + widget.onTap.call(context, details, controller.currentState, childTapPosition); + } + + void onDoubleTap(TapDownDetails details) { + final viewportTapPosition = details?.localPosition; + final childTapPosition = scaleBoundaries.viewportToChildPosition(controller, viewportTapPosition); + nextScaleState(ChangeSource.gesture, childFocalPoint: childTapPosition); + } + + void animateScale(double from, double to) { + _scaleAnimation = Tween( + begin: from, + end: to, + ).animate(_scaleAnimationController); + _scaleAnimationController + ..value = 0.0 + ..fling(velocity: 0.4); + } + + void animatePosition(Offset from, Offset to) { + _positionAnimation = Tween(begin: from, end: to).animate(_positionAnimationController); + _positionAnimationController + ..value = 0.0 + ..fling(velocity: 0.4); + } + + void onAnimationStatus(AnimationStatus status) { + if (status == AnimationStatus.completed) { + onAnimationStatusCompleted(); + } + } + + /// Check if scale is equal to initial after scale animation update + void onAnimationStatusCompleted() { + if (controller.scaleState.state != ScaleState.initial && scale == scaleBoundaries.initialScale) { + controller.setScaleState(ScaleState.initial, ChangeSource.animation); + } + } + + @override + void initState() { + super.initState(); + _scaleAnimationController = AnimationController(vsync: this)..addListener(handleScaleAnimation); + _scaleAnimationController.addStatusListener(onAnimationStatus); + + _positionAnimationController = AnimationController(vsync: this)..addListener(handlePositionAnimate); + + startListeners(); + setScaleStateUpdateAnimation(animateOnScaleStateUpdate); + + cachedScaleBoundaries = widget.controller.scaleBoundaries; + } + + void animateOnScaleStateUpdate(double prevScale, double nextScale, Offset nextPosition) { + animateScale(prevScale, nextScale); + animatePosition(controller.position, nextPosition); + } + + @override + void dispose() { + _scaleAnimationController.removeStatusListener(onAnimationStatus); + _scaleAnimationController.dispose(); + _positionAnimationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // Check if we need a recalc on the scale + if (widget.controller.scaleBoundaries != cachedScaleBoundaries) { + markNeedsScaleRecalc = true; + cachedScaleBoundaries = widget.controller.scaleBoundaries; + } + + return StreamBuilder( + stream: controller.stateStream, + initialData: controller.previousState, + builder: (context, snapshot) { + if (!snapshot.hasData) return Container(); + + final magnifierState = snapshot.data; + final position = magnifierState.position; + final applyScale = widget.applyScale; + + Widget child = CustomSingleChildLayout( + delegate: _CenterWithOriginalSizeDelegate( + scaleBoundaries.childSize, + basePosition, + applyScale, + ), + child: widget.child, + ); + + child = Transform( + transform: Matrix4.identity() + ..translate(position.dx, position.dy) + ..scale(applyScale ? scale : 1.0), + alignment: basePosition, + child: child, + ); + + return MagnifierGestureDetector( + child: child, + onDoubleTap: onDoubleTap, + onScaleStart: onScaleStart, + onScaleUpdate: onScaleUpdate, + onScaleEnd: onScaleEnd, + hitDetector: this, + onTapUp: widget.onTap == null ? null : onTap, + ); + }); + } +} + +class _CenterWithOriginalSizeDelegate extends SingleChildLayoutDelegate { + const _CenterWithOriginalSizeDelegate( + this.subjectSize, + this.basePosition, + this.applyScale, + ); + + final Size subjectSize; + final Alignment basePosition; + final bool applyScale; + + @override + Offset getPositionForChild(Size size, Size childSize) { + final childWidth = applyScale ? subjectSize.width : childSize.width; + final childHeight = applyScale ? subjectSize.height : childSize.height; + + final halfWidth = (size.width - childWidth) / 2; + final halfHeight = (size.height - childHeight) / 2; + + final offsetX = halfWidth * (basePosition.x + 1); + final offsetY = halfHeight * (basePosition.y + 1); + return Offset(offsetX, offsetY); + } + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) { + return applyScale ? BoxConstraints.tight(subjectSize) : BoxConstraints(); + } + + @override + bool shouldRelayout(_CenterWithOriginalSizeDelegate oldDelegate) { + return oldDelegate != this; + } + + @override + bool operator ==(Object other) => identical(this, other) || other is _CenterWithOriginalSizeDelegate && runtimeType == other.runtimeType && subjectSize == other.subjectSize && basePosition == other.basePosition && applyScale == other.applyScale; + + @override + int get hashCode => hashValues(subjectSize, basePosition, applyScale); +} diff --git a/lib/widgets/common/magnifier/core/gesture_detector.dart b/lib/widgets/common/magnifier/core/gesture_detector.dart new file mode 100644 index 000000000..b709725ec --- /dev/null +++ b/lib/widgets/common/magnifier/core/gesture_detector.dart @@ -0,0 +1,94 @@ +import 'package:aves/widgets/common/magnifier/core/scale_gesture_recognizer.dart'; +import 'package:aves/widgets/common/magnifier/pan/gesture_detector_scope.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; + +import '../pan/corner_hit_detector.dart'; + +class MagnifierGestureDetector extends StatefulWidget { + const MagnifierGestureDetector({ + Key key, + this.hitDetector, + this.onScaleStart, + this.onScaleUpdate, + this.onScaleEnd, + this.onTapDown, + this.onTapUp, + this.onDoubleTap, + this.behavior, + this.child, + }) : super(key: key); + + final CornerHitDetector hitDetector; + final void Function(ScaleStartDetails details, bool doubleTap) onScaleStart; + final GestureScaleUpdateCallback onScaleUpdate; + final GestureScaleEndCallback onScaleEnd; + + final GestureTapDownCallback onTapDown; + final GestureTapUpCallback onTapUp; + final GestureTapDownCallback onDoubleTap; + + final HitTestBehavior behavior; + final Widget child; + + @override + _MagnifierGestureDetectorState createState() => _MagnifierGestureDetectorState(); +} + +class _MagnifierGestureDetectorState extends State { + final ValueNotifier doubleTapDetails = ValueNotifier(null); + + @override + Widget build(BuildContext context) { + final scope = MagnifierGestureDetectorScope.of(context); + + final axis = scope?.axis; + final touchSlopFactor = scope?.touchSlopFactor; + + final gestures = {}; + + if (widget.onTapDown != null || widget.onTapUp != null) { + gestures[TapGestureRecognizer] = GestureRecognizerFactoryWithHandlers( + () => TapGestureRecognizer(debugOwner: this), + (instance) { + instance + ..onTapDown = widget.onTapDown + ..onTapUp = widget.onTapUp; + }, + ); + } + + gestures[MagnifierGestureRecognizer] = GestureRecognizerFactoryWithHandlers( + () => MagnifierGestureRecognizer( + hitDetector: widget.hitDetector, + debugOwner: this, + validateAxis: axis, + touchSlopFactor: touchSlopFactor, + doubleTapDetails: doubleTapDetails, + ), + (instance) { + instance.onStart = (details) => widget.onScaleStart(details, doubleTapDetails.value != null); + instance.onUpdate = widget.onScaleUpdate; + instance.onEnd = widget.onScaleEnd; + }, + ); + + gestures[DoubleTapGestureRecognizer] = GestureRecognizerFactoryWithHandlers( + () => DoubleTapGestureRecognizer(debugOwner: this), + (instance) { + instance.onDoubleTapCancel = () => doubleTapDetails.value = null; + instance.onDoubleTapDown = (details) => doubleTapDetails.value = details; + instance.onDoubleTap = () { + widget.onDoubleTap(doubleTapDetails.value); + doubleTapDetails.value = null; + }; + }, + ); + + return RawGestureDetector( + child: widget.child, + gestures: gestures, + behavior: widget.behavior ?? HitTestBehavior.translucent, + ); + } +} diff --git a/lib/widgets/common/magnifier/core/scale_gesture_recognizer.dart b/lib/widgets/common/magnifier/core/scale_gesture_recognizer.dart new file mode 100644 index 000000000..37db8bbc6 --- /dev/null +++ b/lib/widgets/common/magnifier/core/scale_gesture_recognizer.dart @@ -0,0 +1,145 @@ +import 'dart:math'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; + +import '../pan/corner_hit_detector.dart'; + +class MagnifierGestureRecognizer extends ScaleGestureRecognizer { + final CornerHitDetector hitDetector; + final List validateAxis; + final double touchSlopFactor; + final ValueNotifier doubleTapDetails; + + MagnifierGestureRecognizer({ + Object debugOwner, + PointerDeviceKind kind, + this.hitDetector, + this.validateAxis, + this.touchSlopFactor = 2, + this.doubleTapDetails, + }) : super(debugOwner: debugOwner, kind: kind); + + Map _pointerLocations = {}; + + Offset _initialFocalPoint; + Offset _currentFocalPoint; + double _initialSpan; + double _currentSpan; + + bool ready = true; + + @override + void addAllowedPointer(PointerEvent event) { + if (ready) { + ready = false; + _initialSpan = 0.0; + _currentSpan = 0.0; + _pointerLocations = {}; + } + super.addAllowedPointer(event); + } + + @override + void didStopTrackingLastPointer(int pointer) { + ready = true; + super.didStopTrackingLastPointer(pointer); + } + + @override + void handleEvent(PointerEvent event) { + if (validateAxis != null && validateAxis.isNotEmpty) { + var didChangeConfiguration = false; + if (event is PointerMoveEvent) { + if (!event.synthesized) { + _pointerLocations[event.pointer] = event.position; + } + } else if (event is PointerDownEvent) { + _pointerLocations[event.pointer] = event.position; + didChangeConfiguration = true; + } else if (event is PointerUpEvent || event is PointerCancelEvent) { + _pointerLocations.remove(event.pointer); + didChangeConfiguration = true; + } + + _updateDistances(); + + if (didChangeConfiguration) { + // cf super._reconfigure + _initialFocalPoint = _currentFocalPoint; + _initialSpan = _currentSpan; + } + + _decideIfWeAcceptEvent(event); + } + super.handleEvent(event); + } + + void _updateDistances() { + // cf super._update + final count = _pointerLocations.keys.length; + + // Compute the focal point + var focalPoint = Offset.zero; + for (final pointer in _pointerLocations.keys) { + focalPoint += _pointerLocations[pointer]; + } + _currentFocalPoint = count > 0 ? focalPoint / count.toDouble() : Offset.zero; + + // Span is the average deviation from focal point. Horizontal and vertical + // spans are the average deviations from the focal point's horizontal and + // vertical coordinates, respectively. + var totalDeviation = 0.0; + for (final pointer in _pointerLocations.keys) { + totalDeviation += (_currentFocalPoint - _pointerLocations[pointer]).distance; + } + _currentSpan = count > 0 ? totalDeviation / count : 0.0; + } + + void _decideIfWeAcceptEvent(PointerEvent event) { + if (!(event is PointerMoveEvent)) return; + + if (_pointerLocations.keys.length >= 2) { + // when there are multiple pointers, we always accept the gesture to scale + // as this is not competing with single taps or other drag gestures + acceptGesture(event.pointer); + return; + } + + final move = _initialFocalPoint - _currentFocalPoint; + var shouldMove = false; + if (validateAxis.length == 2) { + // the image is the descendant of gesture detector(s) handling drag in both directions + final shouldMoveX = validateAxis.contains(Axis.horizontal) && hitDetector.shouldMoveX(move); + final shouldMoveY = validateAxis.contains(Axis.vertical) && hitDetector.shouldMoveY(move); + if (shouldMoveX == shouldMoveY) { + // consistently can/cannot pan the image in both direction the same way + shouldMove = shouldMoveX; + } else { + // can pan the image in one direction, but should yield to an ascendant gesture detector in the other one + final d = move.direction; + // the gesture direction angle is in ]-pi, pi], cf `Offset` doc for details + final xPan = (-pi / 4 < d && d < pi / 4) || (3 / 4 * pi < d && d <= pi) || (-pi < d && d < -3 / 4 * pi); + final yPan = (pi / 4 < d && d < 3 / 4 * pi) || (-3 / 4 * pi < d && d < -pi / 4); + shouldMove = (xPan && shouldMoveX) || (yPan && shouldMoveY); + } + } else { + // the image is the descendant of a gesture detector handling drag in one direction + shouldMove = validateAxis.contains(Axis.vertical) ? hitDetector.shouldMoveY(move) : hitDetector.shouldMoveX(move); + } + + final doubleTap = doubleTapDetails?.value != null; + if (shouldMove || doubleTap) { + final spanDelta = (_currentSpan - _initialSpan).abs(); + final focalPointDelta = (_currentFocalPoint - _initialFocalPoint).distance; + // warning: do not compare `focalPointDelta` to `kPanSlop` + // `ScaleGestureRecognizer` uses `kPanSlop`, but `HorizontalDragGestureRecognizer` uses `kTouchSlop` + // and the magnifier recognizer may compete with the `HorizontalDragGestureRecognizer` from a containing `PageView` + // setting `touchSlopFactor` to 2 restores default `ScaleGestureRecognizer` behaviour as `kPanSlop = kTouchSlop * 2.0` + // setting `touchSlopFactor` in [0, 1] will allow this recognizer to accept the gesture before the one from `PageView` + if (spanDelta > kScaleSlop || focalPointDelta > kTouchSlop * touchSlopFactor) { + acceptGesture(event.pointer); + } + } + } +} diff --git a/lib/widgets/common/magnifier/magnifier.dart b/lib/widgets/common/magnifier/magnifier.dart new file mode 100644 index 000000000..f5e18a5d3 --- /dev/null +++ b/lib/widgets/common/magnifier/magnifier.dart @@ -0,0 +1,140 @@ +import 'package:aves/widgets/common/magnifier/controller/controller.dart'; +import 'package:aves/widgets/common/magnifier/controller/state.dart'; +import 'package:aves/widgets/common/magnifier/core/core.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:flutter/material.dart'; + +/// `Magnifier` is derived from `photo_view` package v0.9.2: +/// - removed image related aspects to focus on a general purpose pan/scale viewer (à la `InteractiveViewer`) +/// - removed rotation and many customization parameters +/// - removed ignorable/ignoring partial notifiers +/// - formatted, renamed and reorganized +/// - fixed gesture recognizers when used inside a scrollable widget like `PageView` +/// - fixed corner hit detection when in containers scrollable in both axes +/// - fixed corner hit detection issues due to imprecise double comparisons +/// - added single & double tap position feedback +/// - fixed focus when scaling by double-tap/pinch +class Magnifier extends StatefulWidget { + const Magnifier({ + Key key, + @required this.child, + this.childSize, + this.controller, + this.maxScale, + this.minScale, + this.initialScale, + this.scaleStateCycle, + this.onTap, + this.gestureDetectorBehavior, + this.applyScale, + }) : super(key: key); + + final Widget child; + + /// The size of the custom [child]. This value is used to compute the relation between the child and the container's size to calculate the scale value. + final Size childSize; + + /// Defines the maximum size in which the image will be allowed to assume, it is proportional to the original image size. + final ScaleLevel maxScale; + + /// Defines the minimum size in which the image will be allowed to assume, it is proportional to the original image size. + final ScaleLevel minScale; + + /// Defines the size the image will assume when the component is initialized, it is proportional to the original image size. + final ScaleLevel initialScale; + + final MagnifierController controller; + final ScaleStateCycle scaleStateCycle; + final MagnifierTapCallback onTap; + final HitTestBehavior gestureDetectorBehavior; + final bool applyScale; + + @override + State createState() { + return _MagnifierState(); + } +} + +class _MagnifierState extends State { + Size _childSize; + + bool _controlledController; + MagnifierController _controller; + + void _setChildSize(Size childSize) { + _childSize = childSize.isEmpty ? null : childSize; + } + + @override + void initState() { + super.initState(); + _setChildSize(widget.childSize); + if (widget.controller == null) { + _controlledController = true; + _controller = MagnifierController(); + } else { + _controlledController = false; + _controller = widget.controller; + } + } + + @override + void didUpdateWidget(Magnifier oldWidget) { + if (oldWidget.childSize != widget.childSize && widget.childSize != null) { + setState(() { + _setChildSize(widget.childSize); + }); + } + if (widget.controller == null) { + if (!_controlledController) { + _controlledController = true; + _controller = MagnifierController(); + } + } else { + _controlledController = false; + _controller = widget.controller; + } + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + if (_controlledController) { + _controller.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + _controller.setScaleBoundaries(ScaleBoundaries( + widget.minScale ?? 0.0, + widget.maxScale ?? ScaleLevel(factor: double.infinity), + widget.initialScale ?? ScaleLevel(ref: ScaleReference.contained), + constraints.biggest, + _childSize ?? constraints.biggest, + )); + + return MagnifierCore( + child: widget.child, + controller: _controller, + scaleStateCycle: widget.scaleStateCycle ?? defaultScaleStateCycle, + onTap: widget.onTap, + gestureDetectorBehavior: widget.gestureDetectorBehavior, + applyScale: widget.applyScale ?? true, + ); + }, + ); + } +} + +typedef MagnifierTapCallback = Function( + BuildContext context, + TapUpDetails details, + MagnifierState state, + Offset childTapPosition, +); diff --git a/lib/widgets/common/magnifier/pan/corner_hit_detector.dart b/lib/widgets/common/magnifier/pan/corner_hit_detector.dart new file mode 100644 index 000000000..482b39f5b --- /dev/null +++ b/lib/widgets/common/magnifier/pan/corner_hit_detector.dart @@ -0,0 +1,76 @@ +import 'package:aves/widgets/common/magnifier/controller/controller_delegate.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +mixin CornerHitDetector on MagnifierControllerDelegate { + _AxisHit hitAxis() => _AxisHit(_hitCornersX(), _hitCornersY()); + + // the child width/height is not accurate for some image size & scale combos + // e.g. 3580.0 * 0.1005586592178771 yields 360.0 + // but 4764.0 * 0.07556675062972293 yields 360.00000000000006 + // so be sure to compare with `precisionErrorTolerance` + + _CornerHit _hitCornersX() { + final childWidth = scaleBoundaries.childSize.width * scale; + final viewportWidth = scaleBoundaries.viewportSize.width; + if (viewportWidth + precisionErrorTolerance >= childWidth) { + return _CornerHit(true, true); + } + final x = -position.dx; + final cornersX = this.cornersX(); + return _CornerHit(x <= cornersX.min, x >= cornersX.max); + } + + _CornerHit _hitCornersY() { + final childHeight = scaleBoundaries.childSize.height * scale; + final viewportHeight = scaleBoundaries.viewportSize.height; + if (viewportHeight + precisionErrorTolerance >= childHeight) { + return _CornerHit(true, true); + } + final y = -position.dy; + final cornersY = this.cornersY(); + return _CornerHit(y <= cornersY.min, y >= cornersY.max); + } + + bool shouldMoveX(Offset move) { + final hitCornersX = _hitCornersX(); + if (hitCornersX.hasHitAny && move != Offset.zero) { + if (hitCornersX.hasHitBoth) return false; + if (hitCornersX.hasHitMax) return move.dx < 0; + return move.dx > 0; + } + return true; + } + + bool shouldMoveY(Offset move) { + final hitCornersY = _hitCornersY(); + if (hitCornersY.hasHitAny && move != Offset.zero) { + if (hitCornersY.hasHitBoth) return false; + if (hitCornersY.hasHitMax) return move.dy < 0; + return move.dy > 0; + } + return true; + } +} + +class _AxisHit { + _AxisHit(this.hasHitX, this.hasHitY); + + final _CornerHit hasHitX; + final _CornerHit hasHitY; + + bool get hasHitAny => hasHitX.hasHitAny || hasHitY.hasHitAny; + + bool get hasHitBoth => hasHitX.hasHitBoth && hasHitY.hasHitBoth; +} + +class _CornerHit { + const _CornerHit(this.hasHitMin, this.hasHitMax); + + final bool hasHitMin; + final bool hasHitMax; + + bool get hasHitAny => hasHitMin || hasHitMax; + + bool get hasHitBoth => hasHitMin && hasHitMax; +} diff --git a/lib/widgets/common/magnifier/pan/gesture_detector_scope.dart b/lib/widgets/common/magnifier/pan/gesture_detector_scope.dart new file mode 100644 index 000000000..8eaee4f69 --- /dev/null +++ b/lib/widgets/common/magnifier/pan/gesture_detector_scope.dart @@ -0,0 +1,33 @@ +import 'package:flutter/widgets.dart'; + +/// When a `Magnifier` is wrapped in this inherited widget, +/// it will check whether the zoomed content has hit edges, +/// and if so, will let parent gesture detectors win the gesture arena +/// +/// Useful when placing Magnifier inside a gesture sensitive context, +/// such as [PageView], [Dismissible], [BottomSheet]. +class MagnifierGestureDetectorScope extends InheritedWidget { + const MagnifierGestureDetectorScope({ + this.axis, + this.touchSlopFactor = .8, + @required Widget child, + }) : super(child: child); + + static MagnifierGestureDetectorScope of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType(); + return scope; + } + + final List axis; + + // in [0, 1[ + // 0: most reactive but will not let tap recognizers accept gestures + // <1: less reactive but gives the most leeway to other recognizers + // 1: will not be able to compete with a `HorizontalDragGestureRecognizer` up the widget tree + final double touchSlopFactor; + + @override + bool updateShouldNotify(MagnifierGestureDetectorScope oldWidget) { + return axis != oldWidget.axis && touchSlopFactor != oldWidget.touchSlopFactor; + } +} diff --git a/lib/widgets/common/magnifier/pan/scroll_physics.dart b/lib/widgets/common/magnifier/pan/scroll_physics.dart new file mode 100644 index 000000000..9f8e14d13 --- /dev/null +++ b/lib/widgets/common/magnifier/pan/scroll_physics.dart @@ -0,0 +1,29 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; + +// `PageView` contains a `Scrollable` which sets up a `HorizontalDragGestureRecognizer` +// this recognizer will win in the gesture arena when the drag distance reaches `kTouchSlop` +// we cannot change that, but we can prevent the scrollable from panning until this threshold is reached +// and let other recognizers accept the gesture instead +class MagnifierScrollerPhysics extends ScrollPhysics { + const MagnifierScrollerPhysics({ + this.touchSlopFactor = 1, + ScrollPhysics parent, + }) : super(parent: parent); + + // in [0, 1] + // 0: most reactive but will not let Magnifier recognizers accept gestures + // 1: less reactive but gives the most leeway to Magnifier recognizers + final double touchSlopFactor; + + @override + MagnifierScrollerPhysics applyTo(ScrollPhysics ancestor) { + return MagnifierScrollerPhysics( + touchSlopFactor: touchSlopFactor, + parent: buildParent(ancestor), + ); + } + + @override + double get dragStartDistanceMotionThreshold => kTouchSlop * touchSlopFactor; +} diff --git a/lib/widgets/common/magnifier/scale/scale_boundaries.dart b/lib/widgets/common/magnifier/scale/scale_boundaries.dart new file mode 100644 index 000000000..b5f565fb4 --- /dev/null +++ b/lib/widgets/common/magnifier/scale/scale_boundaries.dart @@ -0,0 +1,67 @@ +import 'dart:ui'; + +import 'package:aves/widgets/common/magnifier/controller/controller.dart'; +import 'package:aves/widgets/common/magnifier/scale/scale_level.dart'; +import 'package:flutter/foundation.dart'; + +/// Internal class to wrap custom scale boundaries (min, max and initial) +/// Also, stores values regarding the two sizes: the container and the child. +class ScaleBoundaries { + const ScaleBoundaries( + this._minScale, + this._maxScale, + this._initialScale, + this.viewportSize, + this.childSize, + ); + + final ScaleLevel _minScale; + final ScaleLevel _maxScale; + final ScaleLevel _initialScale; + final Size viewportSize; + final Size childSize; + + double _scaleForLevel(ScaleLevel level) { + final factor = level.factor; + switch (level.ref) { + case ScaleReference.contained: + return factor * ScaleLevel.scaleForContained(viewportSize, childSize); + case ScaleReference.covered: + return factor * ScaleLevel.scaleForCovering(viewportSize, childSize); + case ScaleReference.absolute: + default: + return factor; + } + } + + double get minScale => _scaleForLevel(_minScale); + + double get maxScale => _scaleForLevel(_maxScale).clamp(minScale, double.infinity); + + double get initialScale => _scaleForLevel(_initialScale).clamp(minScale, maxScale); + + Offset get _viewportCenter => viewportSize.center(Offset.zero); + + Offset get _childCenter => childSize.center(Offset.zero); + + Offset viewportToStatePosition(MagnifierController controller, Offset viewportPosition) { + return viewportPosition - _viewportCenter - controller.position; + } + + Offset viewportToChildPosition(MagnifierController controller, Offset viewportPosition) { + return viewportToStatePosition(controller, viewportPosition) / controller.scale + _childCenter; + } + + Offset childToStatePosition(double scale, Offset childPosition) { + return (_childCenter - childPosition) * scale; + } + + @override + bool operator ==(Object other) => identical(this, other) || other is ScaleBoundaries && runtimeType == other.runtimeType && _minScale == other._minScale && _maxScale == other._maxScale && _initialScale == other._initialScale && viewportSize == other.viewportSize && childSize == other.childSize; + + @override + int get hashCode => hashValues(_minScale, _maxScale, _initialScale, viewportSize, childSize); + + @override + String toString() => '$runtimeType#${shortHash(this)}{viewportSize=$viewportSize, childSize=$childSize, initialScale=$initialScale, minScale=$minScale, maxScale=$maxScale}'; +} diff --git a/lib/widgets/common/magnifier/scale/scale_level.dart b/lib/widgets/common/magnifier/scale/scale_level.dart new file mode 100644 index 000000000..ac7b5b1a4 --- /dev/null +++ b/lib/widgets/common/magnifier/scale/scale_level.dart @@ -0,0 +1,32 @@ +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; + +class ScaleLevel { + final ScaleReference ref; + final double factor; + + const ScaleLevel({ + this.ref = ScaleReference.absolute, + this.factor = 1.0, + }); + + static double scaleForContained(Size containerSize, Size childSize) => min(containerSize.width / childSize.width, containerSize.height / childSize.height); + + static double scaleForCovering(Size containerSize, Size childSize) => max(containerSize.width / childSize.width, containerSize.height / childSize.height); + + @override + String toString() => '$runtimeType#${shortHash(this)}{ref=$ref, factor=$factor}'; + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) return false; + return other is ScaleLevel && other.ref == ref && other.factor == factor; + } + + @override + int get hashCode => hashValues(ref, factor); +} + +enum ScaleReference { absolute, contained, covered } diff --git a/lib/widgets/common/magnifier/scale/state.dart b/lib/widgets/common/magnifier/scale/state.dart new file mode 100644 index 000000000..81595109e --- /dev/null +++ b/lib/widgets/common/magnifier/scale/state.dart @@ -0,0 +1,53 @@ +import 'dart:ui'; + +import 'package:aves/widgets/common/magnifier/controller/state.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +@immutable +class ScaleStateChange { + const ScaleStateChange({ + @required this.state, + @required this.source, + this.childFocalPoint, + }); + + final ScaleState state; + final ChangeSource source; + final Offset childFocalPoint; + + @override + bool operator ==(Object other) => identical(this, other) || other is ScaleStateChange && runtimeType == other.runtimeType && state == other.state && childFocalPoint == other.childFocalPoint; + + @override + int get hashCode => hashValues(state, source, childFocalPoint); + + @override + String toString() => '$runtimeType#${shortHash(this)}{scaleState: $state, source: $source, childFocalPoint: $childFocalPoint}'; +} + +enum ScaleState { + initial, + covering, + originalSize, + zoomedIn, + zoomedOut, +} + +ScaleState defaultScaleStateCycle(ScaleState actual) { + switch (actual) { + case ScaleState.initial: + return ScaleState.covering; + case ScaleState.covering: + return ScaleState.originalSize; + case ScaleState.originalSize: + return ScaleState.initial; + case ScaleState.zoomedIn: + case ScaleState.zoomedOut: + return ScaleState.initial; + default: + return ScaleState.initial; + } +} + +typedef ScaleStateCycle = ScaleState Function(ScaleState actual); diff --git a/lib/widgets/debug/overlay.dart b/lib/widgets/debug/overlay.dart index 6d90d9038..9c0081c0e 100644 --- a/lib/widgets/debug/overlay.dart +++ b/lib/widgets/debug/overlay.dart @@ -20,7 +20,12 @@ class DebugTaskQueueOverlay extends StatelessWidget { stream: servicePolicy.queueStream, builder: (context, snapshot) { if (snapshot.hasError) return SizedBox.shrink(); - final queuedEntries = (snapshot.hasData ? snapshot.data.queueByPriority.entries.toList() : []); + final queuedEntries = >[]; + if (snapshot.hasData) { + final state = snapshot.data; + queuedEntries.add(MapEntry('run', state.runningQueue)); + queuedEntries.addAll(state.queueByPriority.entries.map((kv) => MapEntry(kv.key.toString(), kv.value))); + } queuedEntries.sort((a, b) => a.key.compareTo(b.key)); return Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/widgets/filter_grids/album_pick.dart b/lib/widgets/filter_grids/album_pick.dart index 2828a8266..c1ce9da15 100644 --- a/lib/widgets/filter_grids/album_pick.dart +++ b/lib/widgets/filter_grids/album_pick.dart @@ -177,9 +177,9 @@ class _AlbumFilterBarState extends State { ), ConstrainedBox( constraints: BoxConstraints(minWidth: 16), - child: AnimatedBuilder( - animation: _controller, - builder: (context, child) => AnimatedSwitcher( + child: ValueListenableBuilder( + valueListenable: _controller, + builder: (context, value, child) => AnimatedSwitcher( duration: Durations.appBarActionChangeAnimation, transitionBuilder: (child, animation) => FadeTransition( opacity: animation, @@ -189,7 +189,7 @@ class _AlbumFilterBarState extends State { child: child, ), ), - child: _controller.text.isNotEmpty ? clearButton : SizedBox.shrink(), + child: value.text.isNotEmpty ? clearButton : SizedBox.shrink(), ), ), ) diff --git a/lib/widgets/fullscreen/entry_action_delegate.dart b/lib/widgets/fullscreen/entry_action_delegate.dart index 0ee1078ed..b6102bf1d 100644 --- a/lib/widgets/fullscreen/entry_action_delegate.dart +++ b/lib/widgets/fullscreen/entry_action_delegate.dart @@ -15,8 +15,6 @@ import 'package:aves/widgets/fullscreen/source_viewer_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_svg/flutter_svg.dart'; -import 'package:pdf/pdf.dart'; import 'package:pdf/widgets.dart' as pdf; import 'package:pedantic/pedantic.dart'; import 'package:printing/printing.dart'; @@ -100,34 +98,32 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin { final documentName = entry.bestTitle ?? 'Aves'; final doc = pdf.Document(title: documentName); - PdfImage pdfImage; + pdf.Widget pdfChild; if (entry.isSvg) { final bytes = await ImageFileService.getImage(uri, mimeType, entry.rotationDegrees, entry.isFlipped); if (bytes != null && bytes.isNotEmpty) { - final svgRoot = await svg.fromSvgBytes(bytes, uri); - final viewBox = svgRoot.viewport.viewBox; - // 1000 is arbitrary, but large enough to look ok in the print preview - final targetSize = viewBox * 1000 / viewBox.longestSide; - final picture = svgRoot.toPicture(size: targetSize); - final uiImage = await picture.toImage(targetSize.width.ceil(), targetSize.height.ceil()); - pdfImage = await pdfImageFromImage( - pdf: doc.document, - image: uiImage, - ); + pdfChild = pdf.SvgImage(svg: utf8.decode(bytes)); } } else { - pdfImage = await pdfImageFromImageProvider( - pdf: doc.document, - image: UriImage( + pdfChild = pdf.Image.provider(await flutterImageProvider( + UriImage( uri: uri, mimeType: mimeType, rotationDegrees: rotationDegrees, isFlipped: isFlipped, ), - ); + )); } - if (pdfImage != null) { - doc.addPage(pdf.Page(build: (context) => pdf.Center(child: pdf.Image(pdfImage)))); // Page + 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, diff --git a/lib/widgets/fullscreen/fullscreen_body.dart b/lib/widgets/fullscreen/fullscreen_body.dart index 1363a2ec2..e7b3791bd 100644 --- a/lib/widgets/fullscreen/fullscreen_body.dart +++ b/lib/widgets/fullscreen/fullscreen_body.dart @@ -8,6 +8,7 @@ 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/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'; @@ -22,7 +23,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:flutter_ijkplayer/flutter_ijkplayer.dart'; -import 'package:photo_view/photo_view.dart'; import 'package:provider/provider.dart'; import 'package:screen/screen.dart'; import 'package:tuple/tuple.dart'; @@ -557,7 +557,7 @@ class _FullscreenVerticalPageViewState extends State key: Key('vertical-pageview'), scrollDirection: Axis.vertical, controller: widget.verticalPager, - physics: PhotoViewPageViewScrollPhysics(parent: PageScrollPhysics()), + physics: MagnifierScrollerPhysics(parent: PageScrollPhysics()), onPageChanged: (page) { widget.onVerticalPageChanged(page); _infoPageVisibleNotifier.value = page == pages.length - 1; diff --git a/lib/widgets/fullscreen/image_page.dart b/lib/widgets/fullscreen/image_page.dart index a95fc1b1d..7046661e5 100644 --- a/lib/widgets/fullscreen/image_page.dart +++ b/lib/widgets/fullscreen/image_page.dart @@ -1,9 +1,10 @@ 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:photo_view/photo_view.dart'; import 'package:tuple/tuple.dart'; class MultiImagePage extends StatefulWidget { @@ -34,13 +35,13 @@ class MultiImagePageState extends State with AutomaticKeepAliveC Widget build(BuildContext context) { super.build(context); - return PhotoViewGestureDetectorScope( + return MagnifierGestureDetectorScope( axis: [Axis.horizontal, Axis.vertical], child: PageView.builder( key: Key('horizontal-pageview'), scrollDirection: Axis.horizontal, controller: widget.pageController, - physics: PhotoViewPageViewScrollPhysics(parent: BouncingScrollPhysics()), + physics: MagnifierScrollerPhysics(parent: BouncingScrollPhysics()), onPageChanged: widget.onPageChanged, itemBuilder: (context, index) { final entry = entries[index]; @@ -49,7 +50,7 @@ class MultiImagePageState extends State with AutomaticKeepAliveC key: Key('imageview'), entry: entry, heroTag: widget.collection.heroTag(entry), - onTap: widget.onTap, + onTap: (_) => widget.onTap?.call(), videoControllers: widget.videoControllers, onDisposed: () => widget.onViewDisposed?.call(entry.uri), ), @@ -84,11 +85,11 @@ class SingleImagePageState extends State with AutomaticKeepAliv Widget build(BuildContext context) { super.build(context); - return PhotoViewGestureDetectorScope( + return MagnifierGestureDetectorScope( axis: [Axis.vertical], child: ImageView( entry: widget.entry, - onTap: widget.onTap, + onTap: (_) => widget.onTap?.call(), videoControllers: widget.videoControllers, ), ); diff --git a/lib/widgets/fullscreen/image_view.dart b/lib/widgets/fullscreen/image_view.dart index 3a9b22719..b3c5e1ada 100644 --- a/lib/widgets/fullscreen/image_view.dart +++ b/lib/widgets/fullscreen/image_view.dart @@ -1,29 +1,35 @@ import 'dart:async'; -import 'package:aves/image_providers/thumbnail_provider.dart'; -import 'package:aves/image_providers/uri_image_provider.dart'; import 'package:aves/image_providers/uri_picture_provider.dart'; import 'package:aves/model/image_entry.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:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_ijkplayer/flutter_ijkplayer.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import 'package:photo_view/photo_view.dart'; -import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; class ImageView extends StatefulWidget { final ImageEntry entry; final Object heroTag; - final VoidCallback onTap; + final MagnifierTapCallback onTap; final List> videoControllers; final VoidCallback onDisposed; + static const decorationCheckSize = 20.0; + const ImageView({ Key key, @required this.entry, @@ -38,32 +44,30 @@ class ImageView extends StatefulWidget { } class _ImageViewState extends State { - final PhotoViewController _photoViewController = PhotoViewController(); - final PhotoViewScaleStateController _photoViewScaleStateController = PhotoViewScaleStateController(); + final MagnifierController _magnifierController = MagnifierController(); final ValueNotifier _viewStateNotifier = ValueNotifier(ViewState.zero); - StreamSubscription _subscription; - Size _photoViewChildSize; + final List _subscriptions = []; - static const backgroundDecoration = BoxDecoration(color: Colors.transparent); - static const maxScale = 2.0; + 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; - VoidCallback get onTap => widget.onTap; + MagnifierTapCallback get onTap => widget.onTap; @override void initState() { super.initState(); - _subscription = _photoViewController.outputStateStream.listen(_onViewChanged); - if (entry.isVideo || (!entry.isSvg && entry.canDecode && useTile)) { - _photoViewChildSize = entry.displaySize; - } + _subscriptions.add(_magnifierController.stateStream.listen(_onViewStateChanged)); + _subscriptions.add(_magnifierController.scaleBoundariesStream.listen(_onViewScaleBoundariesChanged)); } @override void dispose() { - _subscription.cancel(); - _subscription = null; + _subscriptions + ..forEach((sub) => sub.cancel()) + ..clear(); widget.onDisposed?.call(); super.dispose(); } @@ -78,19 +82,9 @@ class _ImageViewState extends State { } else if (entry.isSvg) { child = _buildSvgView(); } else if (entry.canDecode) { - if (useTile) { - child = _buildTiledImageView(); - } else { - child = _buildImageView(); - } + child = _buildRasterView(); } - child ??= _buildError(); - - // if the hero tag is defined in the `loadingBuilder` and also set by the `heroAttributes`, - // the route transition becomes visible if the final image is loaded before the hero animation is done. - - // if the hero tag wraps the whole `PhotoView` and the `loadingBuilder` is not provided, - // there's a black frame between the hero animation and the final image, even when it's cached. + child ??= ErrorChild(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 @@ -102,103 +96,30 @@ class _ImageViewState extends State { : child; } - // the images loaded by `PhotoView` cannot have a width or height larger than 8192 - // so the reported offset and scale does not match expected values derived from the original dimensions - // besides, large images should be tiled to be memory-friendly - bool get useTile => entry.canTile && (entry.width > 4096 || entry.height > 4096); - - ImageProvider get fastThumbnailProvider => ThumbnailProvider(ThumbnailProviderKey.fromEntry(entry)); - - // this loading builder shows a transition image until the final image is ready - // if the image is already in the cache it will show the final image, otherwise the thumbnail - // in any case, we should use `Center` + `AspectRatio` + `BoxFit.fill` so that the transition image - // appears as the final image with `PhotoViewComputedScale.contained` for `initialScale` - Widget _loadingBuilder(BuildContext context, ImageProvider imageProvider) { - return Center( - child: AspectRatio( - // enforce original aspect ratio, as some thumbnails aspect ratios slightly differ - aspectRatio: entry.displayAspectRatio, - child: Image( - image: imageProvider, - fit: BoxFit.fill, - ), - ), - ); - } - - Widget _buildImageView() { - final uriImage = UriImage( - uri: entry.uri, - mimeType: entry.mimeType, - rotationDegrees: entry.rotationDegrees, - isFlipped: entry.isFlipped, - expectedContentLength: entry.sizeBytes, - ); - return PhotoView( + 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}'), - imageProvider: uriImage, - // when the full image is ready, we use it in the `loadingBuilder` - // we still provide a `loadingBuilder` in that case to avoid a black frame after hero animation - loadingBuilder: (context, event) => _loadingBuilder( - context, - imageCache.statusForKey(uriImage).keepAlive ? uriImage : fastThumbnailProvider, - ), - loadFailedChild: _buildError(), - backgroundDecoration: backgroundDecoration, - imageSizedCallback: (size) { - // do not directly update the `ViewState` notifier as this callback is called during build - _photoViewChildSize = size; - }, - controller: _photoViewController, - maxScale: maxScale, - minScale: PhotoViewComputedScale.contained, - initialScale: PhotoViewComputedScale.contained, - onTapUp: (tapContext, details, value) => onTap?.call(), - filterQuality: FilterQuality.low, - ); - } - - Widget _buildTiledImageView() { - return PhotoView.customChild( - // key includes size and orientation to refresh when the image is rotated - key: ValueKey('${entry.rotationDegrees}_${entry.isFlipped}_${entry.width}_${entry.height}_${entry.path}'), - child: Selector( - selector: (context, mq) => mq.size, - builder: (context, mqSize, child) { - // When the scale state is cycled to be in its `initial` state (i.e. `contained`), and the device is rotated, - // `PhotoView` keeps the scale state as `contained`, but the controller does not update or notify the new scale value. - // We cannot use `scaleStateChangedCallback` as a workaround, because the scale state is updated before animating the scale change, - // so we keep receiving scale updates after the scale state update. - // Instead we check the scale state here when the constraints change, so we can reset the obsolete scale value. - if (_photoViewScaleStateController.scaleState == PhotoViewScaleState.initial) { - final value = PhotoViewControllerValue(position: Offset.zero, scale: 0, rotation: 0, rotationFocusPoint: null); - WidgetsBinding.instance.addPostFrameCallback((_) => _onViewChanged(value)); - } - return TiledImageView( - entry: entry, - viewportSize: mqSize, - viewStateNotifier: _viewStateNotifier, - baseChild: _loadingBuilder(context, fastThumbnailProvider), - errorBuilder: (context, error, stackTrace) => _buildError(), - ); - }, + child: TiledImageView( + entry: entry, + viewStateNotifier: _viewStateNotifier, + errorBuilder: (context, error, stackTrace) => ErrorChild(onTap: () => onTap?.call(null)), ), childSize: entry.displaySize, - backgroundDecoration: backgroundDecoration, - controller: _photoViewController, - scaleStateController: _photoViewScaleStateController, + controller: _magnifierController, maxScale: maxScale, - minScale: PhotoViewComputedScale.contained, - initialScale: PhotoViewComputedScale.contained, - onTapUp: (tapContext, details, value) => onTap?.call(), - filterQuality: FilterQuality.low, + minScale: minScale, + initialScale: initialScale, + onTap: (c, d, s, childPosition) => onTap?.call(childPosition), + applyScale: false, ); } Widget _buildSvgView() { - final colorFilter = ColorFilter.mode(Color(settings.svgBackground), BlendMode.dstOver); - return PhotoView.customChild( + final background = settings.vectorBackground; + final colorFilter = background.isColor ? ColorFilter.mode(background.color, BlendMode.dstOver) : null; + + Widget child = Magnifier( child: SvgPicture( UriPicture( uri: entry.uri, @@ -206,17 +127,54 @@ class _ImageViewState extends State { colorFilter: colorFilter, ), ), - backgroundDecoration: backgroundDecoration, - controller: _photoViewController, - minScale: PhotoViewComputedScale.contained, - initialScale: PhotoViewComputedScale.contained, - onTapUp: (tapContext, details, value) => onTap?.call(), + childSize: entry.displaySize, + controller: _magnifierController, + minScale: minScale, + initialScale: 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: child, + ); + } + return child; } Widget _buildVideoView() { final videoController = widget.videoControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2; - return PhotoView.customChild( + return Magnifier( child: videoController != null ? AvesVideo( entry: entry, @@ -224,49 +182,49 @@ class _ImageViewState extends State { ) : SizedBox(), childSize: entry.displaySize, - backgroundDecoration: backgroundDecoration, - controller: _photoViewController, + controller: _magnifierController, maxScale: maxScale, - minScale: PhotoViewComputedScale.contained, - initialScale: PhotoViewComputedScale.contained, - onTapUp: (tapContext, details, value) => onTap?.call(), + minScale: minScale, + initialScale: initialScale, + onTap: (c, d, s, childPosition) => onTap?.call(childPosition), ); } - Widget _buildError() => 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, - ), - ), - ); - - void _onViewChanged(PhotoViewControllerValue v) { - final viewState = ViewState(v.position, v.scale, _photoViewChildSize); + void _onViewStateChanged(MagnifierState v) { + final current = _viewStateNotifier.value; + final viewState = ViewState(v.position, v.scale, current.viewportSize); _viewStateNotifier.value = viewState; ViewStateNotification(entry.uri, viewState).dispatch(context); } + + void _onViewScaleBoundariesChanged(ScaleBoundaries v) { + final current = _viewStateNotifier.value; + final viewState = ViewState(current.position, current.scale, v.viewportSize); + _viewStateNotifier.value = viewState; + ViewStateNotification(entry.uri, viewState).dispatch(context); + } + + static ScaleState _vectorScaleStateCycle(ScaleState actual) { + switch (actual) { + case ScaleState.initial: + return ScaleState.covering; + default: + return ScaleState.initial; + } + } } class ViewState { final Offset position; final double scale; - final Size size; + final Size viewportSize; - static const ViewState zero = ViewState(Offset(0.0, 0.0), 0, null); + static const ViewState zero = ViewState(Offset.zero, 0, null); - const ViewState(this.position, this.scale, this.size); + const ViewState(this.position, this.scale, this.viewportSize); @override - String toString() { - return '$runtimeType#${shortHash(this)}{position=$position, scale=$scale, size=$size}'; - } + String toString() => '$runtimeType#${shortHash(this)}{position=$position, scale=$scale, viewportSize=$viewportSize}'; } class ViewStateNotification extends Notification { @@ -276,7 +234,30 @@ class ViewStateNotification extends Notification { const ViewStateNotification(this.uri, this.viewState); @override - String toString() { - return '$runtimeType#${shortHash(this)}{uri=$uri, viewState=$viewState}'; + 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/fullscreen/info/metadata/metadata_section.dart b/lib/widgets/fullscreen/info/metadata/metadata_section.dart index a74f09743..e12c8822d 100644 --- a/lib/widgets/fullscreen/info/metadata/metadata_section.dart +++ b/lib/widgets/fullscreen/info/metadata/metadata_section.dart @@ -3,6 +3,7 @@ import 'dart:collection'; import 'package:aves/model/image_entry.dart'; import 'package:aves/ref/brand_colors.dart'; import 'package:aves/services/metadata_service.dart'; +import 'package:aves/services/svg_metadata_service.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/color_utils.dart'; @@ -10,8 +11,8 @@ 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_thumbnail.dart'; -import 'package:aves/widgets/fullscreen/info/metadata/svg_tile.dart'; import 'package:aves/widgets/fullscreen/info/metadata/xmp_tile.dart'; +import 'package:aves/widgets/fullscreen/source_viewer_page.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -91,9 +92,9 @@ class _MetadataSectionSliverState extends State with Auto // cancel notification bubbling so that the info page // does not misinterpret content scrolling for page scrolling onNotification: (notification) => true, - child: AnimatedBuilder( - animation: _loadedMetadataUri, - builder: (context, child) { + child: ValueListenableBuilder( + valueListenable: _loadedMetadataUri, + builder: (context, uri, child) { Widget content; if (_metadata.isEmpty) { content = SizedBox.shrink(); @@ -118,7 +119,7 @@ class _MetadataSectionSliverState extends State with Auto return AnimationLimiter( // we update the limiter key after fetching the metadata of a new entry, // in order to restart the staggered animation of the metadata section - key: Key(_loadedMetadataUri.value), + key: Key(uri), child: content, ); }, @@ -175,7 +176,7 @@ class _MetadataSectionSliverState extends State with Auto child: InfoRowGroup( dir.tags, maxValueLength: Constants.infoGroupMaxValueLength, - linkHandlers: dirName == SvgMetadata.metadataDirectory ? SvgMetadata.getLinkHandlers(dir.tags) : null, + linkHandlers: dirName == SvgMetadataService.metadataDirectory ? getSvgLinkHandlers(dir.tags) : null, ), ), ], @@ -192,7 +193,7 @@ class _MetadataSectionSliverState extends State with Auto if (entry == null) return; if (_loadedMetadataUri.value == entry.uri) return; if (isVisible) { - final rawMetadata = await (entry.isSvg ? SvgMetadata.getAllMetadata(entry) : MetadataService.getAllMetadata(entry)) ?? {}; + final rawMetadata = await (entry.isSvg ? SvgMetadataService.getAllMetadata(entry) : MetadataService.getAllMetadata(entry)) ?? {}; final directories = rawMetadata.entries.map((dirKV) { var directoryName = dirKV.key as String ?? ''; @@ -230,6 +231,25 @@ class _MetadataSectionSliverState extends State with Auto _expandedDirectoryNotifier.value = null; } + static Map getSvgLinkHandlers(SplayTreeMap tags) { + return { + 'Metadata': InfoLinkHandler( + linkText: 'View XML', + onTap: (context) { + Navigator.push( + context, + MaterialPageRoute( + settings: RouteSettings(name: SourceViewerPage.routeName), + builder: (context) => SourceViewerPage( + loader: () => SynchronousFuture(tags['Metadata']), + ), + ), + ); + }, + ), + }; + } + @override bool get wantKeepAlive => true; } diff --git a/lib/widgets/fullscreen/info/metadata/svg_tile.dart b/lib/widgets/fullscreen/info/metadata/svg_tile.dart deleted file mode 100644 index b56140a8a..000000000 --- a/lib/widgets/fullscreen/info/metadata/svg_tile.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'dart:collection'; -import 'dart:convert'; - -import 'package:aves/model/image_entry.dart'; -import 'package:aves/services/image_file_service.dart'; -import 'package:aves/utils/string_utils.dart'; -import 'package:aves/widgets/fullscreen/info/common.dart'; -import 'package:aves/widgets/fullscreen/source_viewer_page.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:xml/xml.dart'; - -class SvgMetadata { - static const docDirectory = 'Document'; - static const metadataDirectory = 'Metadata'; - - static const _attributes = ['x', 'y', 'width', 'height', 'preserveAspectRatio', 'viewBox']; - static const _textElements = ['title', 'desc']; - static const _metadataElement = 'metadata'; - - static Future>> getAllMetadata(ImageEntry entry) async { - try { - final data = await ImageFileService.getImage(entry.uri, entry.mimeType, 0, false); - - final document = XmlDocument.parse(utf8.decode(data)); - final root = document.rootElement; - - final docDir = Map.fromEntries([ - ...root.attributes.where((a) => _attributes.contains(a.name.qualified)).map((a) => MapEntry(_formatKey(a.name.qualified), a.value)), - ..._textElements.map((name) => MapEntry(_formatKey(name), root.getElement(name)?.text)).where((kv) => kv.value != null), - ]); - - final metadata = root.getElement(_metadataElement); - final metadataDir = Map.fromEntries([ - if (metadata != null) MapEntry('Metadata', metadata.toXmlString(pretty: true)), - ]); - - return { - if (docDir.isNotEmpty) docDirectory: docDir, - if (metadataDir.isNotEmpty) metadataDirectory: metadataDir, - }; - } catch (exception, stack) { - debugPrint('failed to parse XML from SVG with exception=$exception\n$stack'); - return null; - } - } - - static Map getLinkHandlers(SplayTreeMap tags) { - return { - 'Metadata': InfoLinkHandler( - linkText: 'View XML', - onTap: (context) { - Navigator.push( - context, - MaterialPageRoute( - settings: RouteSettings(name: SourceViewerPage.routeName), - builder: (context) => SourceViewerPage( - loader: () => SynchronousFuture(tags['Metadata']), - ), - ), - ); - }, - ), - }; - } - - static String _formatKey(String key) { - switch (key) { - case 'desc': - return 'Description'; - default: - return key.toSentenceCase(); - } - } -} diff --git a/lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart b/lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart index 0d9a5c23d..459676466 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart +++ b/lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart @@ -95,9 +95,7 @@ class XmpNamespace { int get hashCode => namespace.hashCode; @override - String toString() { - return '$runtimeType#${shortHash(this)}{namespace=$namespace}'; - } + String toString() => '$runtimeType#${shortHash(this)}{namespace=$namespace}'; } class XmpProp { @@ -116,9 +114,7 @@ class XmpProp { } @override - String toString() { - return '$runtimeType#${shortHash(this)}{path=$path, value=$value}'; - } + String toString() => '$runtimeType#${shortHash(this)}{path=$path, value=$value}'; } class OpenEmbeddedDataNotification extends Notification { @@ -131,7 +127,5 @@ class OpenEmbeddedDataNotification extends Notification { }); @override - String toString() { - return '$runtimeType#${shortHash(this)}{propPath=$propPath, mimeType=$mimeType}'; - } + String toString() => '$runtimeType#${shortHash(this)}{propPath=$propPath, mimeType=$mimeType}'; } diff --git a/lib/widgets/fullscreen/overlay/bottom.dart b/lib/widgets/fullscreen/overlay/bottom.dart index 577c2ad43..b4d87a4cf 100644 --- a/lib/widgets/fullscreen/overlay/bottom.dart +++ b/lib/widgets/fullscreen/overlay/bottom.dart @@ -278,7 +278,7 @@ class _DateRow extends StatelessWidget { DecoratedIcon(AIcons.date, shadows: [Constants.embossShadow], size: _iconSize), SizedBox(width: _iconPadding), Expanded(flex: 3, child: Text(dateText, strutStyle: Constants.overflowStrutStyle)), - if (!entry.isSvg) Expanded(flex: 2, child: Text(entry.resolutionText, strutStyle: Constants.overflowStrutStyle)), + Expanded(flex: 2, child: Text(entry.isSvg ? entry.aspectRatioText : entry.resolutionText, strutStyle: Constants.overflowStrutStyle)), ], ); } diff --git a/lib/widgets/fullscreen/overlay/minimap.dart b/lib/widgets/fullscreen/overlay/minimap.dart index d8a33446f..561cc3971 100644 --- a/lib/widgets/fullscreen/overlay/minimap.dart +++ b/lib/widgets/fullscreen/overlay/minimap.dart @@ -4,7 +4,6 @@ import 'package:aves/model/image_entry.dart'; import 'package:aves/widgets/fullscreen/image_view.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; class Minimap extends StatelessWidget { final ImageEntry entry; @@ -21,25 +20,24 @@ class Minimap extends StatelessWidget { @override Widget build(BuildContext context) { - return Selector( - selector: (context, mq) => mq.size, - builder: (context, mqSize, child) { - return AnimatedBuilder( - animation: viewStateNotifier, - builder: (context, child) { - final viewState = viewStateNotifier.value; - return CustomPaint( - painter: MinimapPainter( - viewportSize: mqSize, - entrySize: viewState.size ?? entry.displaySize, - viewCenterOffset: viewState.position, - viewScale: viewState.scale, - minimapBorderColor: Colors.white30, - ), - size: size, - ); - }); - }); + 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, + ); + }), + ); } } diff --git a/lib/widgets/fullscreen/tiled_view.dart b/lib/widgets/fullscreen/tiled_view.dart index ad123a960..c64fcea6f 100644 --- a/lib/widgets/fullscreen/tiled_view.dart +++ b/lib/widgets/fullscreen/tiled_view.dart @@ -1,24 +1,26 @@ import 'dart:math'; -import 'package:aves/model/image_entry.dart'; -import 'package:aves/utils/math_utils.dart'; 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/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:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:tuple/tuple.dart'; class TiledImageView extends StatefulWidget { final ImageEntry entry; - final Size viewportSize; final ValueNotifier viewStateNotifier; - final Widget baseChild; final ImageErrorWidgetBuilder errorBuilder; const TiledImageView({ @required this.entry, - @required this.viewportSize, @required this.viewStateNotifier, - @required this.baseChild, @required this.errorBuilder, }); @@ -27,132 +29,297 @@ class TiledImageView extends StatefulWidget { } class _TiledImageViewState extends State { - double _tileSide, _initialScale; + bool _isTilingInitialized = false; int _maxSampleSize; - Matrix4 _transform; + double _tileSide; + Matrix4 _tileTransform; + ImageStream _fullImageStream; + ImageStreamListener _fullImageListener; + final ValueNotifier _fullImageLoaded = ValueNotifier(false); ImageEntry get entry => widget.entry; - Size get viewportSize => widget.viewportSize; - 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); + + ImageProvider get thumbnailProvider => ThumbnailProvider(ThumbnailProviderKey.fromEntry(entry)); + + ImageProvider get fullImageProvider { + if (useTiles) { + assert(_isTilingInitialized); + final displayWidth = entry.displaySize.width.round(); + final displayHeight = entry.displaySize.height.round(); + final viewState = viewStateNotifier.value; + final regionRect = _getTileRects( + x: 0, + y: 0, + layerRegionWidth: displayWidth, + layerRegionHeight: displayHeight, + displayWidth: displayWidth, + displayHeight: displayHeight, + scale: viewState.scale, + viewRect: _getViewRect(viewState, displayWidth, displayHeight), + ).item2; + return RegionProvider(RegionProviderKey.fromEntry( + entry, + sampleSize: _maxSampleSize, + rect: regionRect, + )); + } else { + return UriImage( + uri: entry.uri, + mimeType: entry.mimeType, + rotationDegrees: entry.rotationDegrees, + isFlipped: entry.isFlipped, + expectedContentLength: entry.sizeBytes, + ); + } + } + // magic number used to derive sample size from scale static const scaleFactor = 2.0; @override void initState() { super.initState(); - _init(); + _fullImageListener = ImageStreamListener(_onFullImageCompleted); + if (!useTiles) _registerFullImage(); } @override void didUpdateWidget(TiledImageView oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.viewportSize != widget.viewportSize || oldWidget.entry.displaySize != widget.entry.displaySize) { - _init(); + final oldViewState = oldWidget.viewStateNotifier.value; + final viewState = widget.viewStateNotifier.value; + if (oldWidget.entry != widget.entry || oldViewState.viewportSize != viewState.viewportSize) { + _isTilingInitialized = false; + _fullImageLoaded.value = false; + _unregisterFullImage(); } } - void _init() { - _tileSide = viewportSize.shortestSide * scaleFactor; - _initialScale = min(viewportSize.width / entry.displaySize.width, viewportSize.height / entry.displaySize.height); - _maxSampleSize = _sampleSizeForScale(_initialScale); + @override + void dispose() { + _unregisterFullImage(); + super.dispose(); + } - final rotationDegrees = entry.rotationDegrees; - final isFlipped = entry.isFlipped; - _transform = null; - if (rotationDegrees != 0 || isFlipped) { - _transform = Matrix4.identity() - ..translate(entry.width / 2.0, entry.height / 2.0) - ..scale(isFlipped ? -1.0 : 1.0, 1.0, 1.0) - ..rotateZ(-toRadians(rotationDegrees.toDouble())) - ..translate(-entry.displaySize.width / 2.0, -entry.displaySize.height / 2.0); - } + void _registerFullImage() { + _fullImageStream = fullImageProvider.resolve(ImageConfiguration.empty); + _fullImageStream.addListener(_fullImageListener); + } + + void _unregisterFullImage() { + _fullImageStream?.removeListener(_fullImageListener); + _fullImageStream = null; + } + + void _onFullImageCompleted(ImageInfo image, bool synchronousCall) { + _unregisterFullImage(); + _fullImageLoaded.value = true; } @override Widget build(BuildContext context) { if (viewStateNotifier == null) return SizedBox.shrink(); - final displayWidth = entry.displaySize.width.round(); - final displayHeight = entry.displaySize.height.round(); + return ValueListenableBuilder( + valueListenable: viewStateNotifier, + builder: (context, viewState, child) { + final viewportSize = viewState.viewportSize; + final viewportSized = viewportSize != null; + if (viewportSized && useTiles && !_isTilingInitialized) _initTiling(viewportSize); - return AnimatedBuilder( - animation: viewStateNotifier, - builder: (context, child) { - final viewState = viewStateNotifier.value; - var scale = viewState.scale; - if (scale == 0.0) { - // for initial scale as `PhotoViewComputedScale.contained` - scale = _initialScale; - } - - final centerOffset = viewState.position; - final viewOrigin = Offset( - ((displayWidth * scale - viewportSize.width) / 2 - centerOffset.dx), - ((displayHeight * scale - viewportSize.height) / 2 - centerOffset.dy), - ); - final viewRect = viewOrigin & viewportSize; - - final tiles = []; - var minSampleSize = min(_sampleSizeForScale(scale), _maxSampleSize); - for (var sampleSize = _maxSampleSize; sampleSize >= minSampleSize; sampleSize = (sampleSize / 2).floor()) { - // for the largest sample size (matching the initial scale), the whole image is in view - // so we subsample the whole image instead of splitting it in tiles - final useTiles = sampleSize != _maxSampleSize; - final regionSide = (_tileSide * sampleSize).round(); - final layerRegionWidth = useTiles ? regionSide : displayWidth; - final layerRegionHeight = useTiles ? regionSide : displayHeight; - for (var x = 0; x < displayWidth; x += layerRegionWidth) { - for (var y = 0; y < displayHeight; y += layerRegionHeight) { - final nextX = x + layerRegionWidth; - final nextY = y + layerRegionHeight; - final thisRegionWidth = layerRegionWidth - (nextX >= displayWidth ? nextX - displayWidth : 0); - final thisRegionHeight = layerRegionHeight - (nextY >= displayHeight ? nextY - displayHeight : 0); - final tileRect = Rect.fromLTWH(x * scale, y * scale, thisRegionWidth * scale, thisRegionHeight * scale); - - // only build visible tiles - if (viewRect.overlaps(tileRect)) { - Rectangle regionRect; - - if (_transform != null) { - // apply EXIF orientation - final regionRectDouble = Rect.fromLTWH(x.toDouble(), y.toDouble(), thisRegionWidth.toDouble(), thisRegionHeight.toDouble()); - final tl = MatrixUtils.transformPoint(_transform, regionRectDouble.topLeft); - final br = MatrixUtils.transformPoint(_transform, regionRectDouble.bottomRight); - regionRect = Rectangle.fromPoints( - Point(tl.dx.round(), tl.dy.round()), - Point(br.dx.round(), br.dy.round()), - ); - } else { - regionRect = Rectangle(x, y, thisRegionWidth, thisRegionHeight); - } - - tiles.add(RegionTile( - entry: entry, - tileRect: tileRect, - regionRect: regionRect, - sampleSize: sampleSize, - )); - } - } - } - } - - return Stack( + return SizedBox.fromSize( + size: entry.displaySize * viewState.scale, + child: Stack( alignment: Alignment.center, children: [ - SizedBox( - width: displayWidth * scale, - height: displayHeight * scale, - child: widget.baseChild, - ), - ...tiles, + if (useBackground && viewportSized) _buildBackground(viewState), + _buildLoading(viewState), + if (useTiles) ..._getTiles(viewState), + if (!useTiles) + Image( + image: fullImageProvider, + gaplessPlayback: true, + errorBuilder: widget.errorBuilder, + width: (entry.displaySize * viewState.scale).width, + fit: BoxFit.contain, + filterQuality: FilterQuality.medium, + ), ], + ), + ); + }, + ); + } + + 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); + _maxSampleSize = _sampleSizeForScale(containedScale); + + final rotationDegrees = entry.rotationDegrees; + final isFlipped = entry.isFlipped; + _tileTransform = null; + if (rotationDegrees != 0 || isFlipped) { + _tileTransform = Matrix4.identity() + ..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); + } + _isTilingInitialized = true; + _registerFullImage(); + } + + Widget _buildLoading(ViewState viewState) { + return ValueListenableBuilder( + valueListenable: _fullImageLoaded, + builder: (context, fullImageLoaded, child) { + if (fullImageLoaded) return SizedBox.shrink(); + + return Center( + child: AspectRatio( + // enforce original aspect ratio, as some thumbnails aspect ratios slightly differ + aspectRatio: entry.displayAspectRatio, + child: Image( + image: thumbnailProvider, + fit: BoxFit.fill, + ), + ), + ); + }, + ); + } + + Widget _buildBackground(ViewState viewState) { + final viewportSize = viewState.viewportSize; + assert(viewportSize != null); + + final viewSize = entry.displaySize * viewState.scale; + final decorationOffset = ((viewSize - viewportSize) as Offset) / 2 - viewState.position; + final decorationSize = applyBoxFit(BoxFit.none, viewSize, viewportSize).source; + + Decoration decoration; + final background = settings.rasterBackground; + if (background == EntryBackground.checkered) { + final side = viewportSize.shortestSide; + final checkSize = side / ((side / ImageView.decorationCheckSize).round()); + final offset = ((decorationSize - viewportSize) as Offset) / 2; + decoration = CheckeredDecoration( + checkSize: checkSize, + offset: offset, + ); + } else { + decoration = BoxDecoration( + color: background.color, + ); + } + return Positioned( + left: decorationOffset.dx >= 0 ? decorationOffset.dx : null, + top: decorationOffset.dy >= 0 ? decorationOffset.dy : null, + width: decorationSize.width, + height: decorationSize.height, + child: DecoratedBox( + decoration: decoration, + ), + ); + } + + List _getTiles(ViewState viewState) { + if (!_isTilingInitialized) return []; + + final displayWidth = entry.displaySize.width.round(); + final displayHeight = entry.displaySize.height.round(); + final viewRect = _getViewRect(viewState, displayWidth, displayHeight); + final scale = viewState.scale; + + final tiles = []; + var minSampleSize = min(_sampleSizeForScale(scale), _maxSampleSize); + for (var sampleSize = _maxSampleSize; sampleSize >= minSampleSize; sampleSize = (sampleSize / 2).floor()) { + // for the largest sample size (matching the initial scale), the whole image is in view + // so we subsample the whole image without tiling + final fullImageRegion = sampleSize == _maxSampleSize; + final regionSide = (_tileSide * sampleSize).round(); + final layerRegionWidth = fullImageRegion ? displayWidth : regionSide; + final layerRegionHeight = fullImageRegion ? displayHeight : regionSide; + for (var x = 0; x < displayWidth; x += layerRegionWidth) { + for (var y = 0; y < displayHeight; y += layerRegionHeight) { + final rects = _getTileRects( + x: x, + y: y, + layerRegionWidth: layerRegionWidth, + layerRegionHeight: layerRegionHeight, + displayWidth: displayWidth, + displayHeight: displayHeight, + scale: scale, + viewRect: viewRect, ); - }); + if (rects != null) { + tiles.add(RegionTile( + entry: entry, + tileRect: rects.item1, + regionRect: rects.item2, + sampleSize: sampleSize, + )); + } + } + } + } + return tiles; + } + + Rect _getViewRect(ViewState viewState, int displayWidth, int displayHeight) { + final scale = viewState.scale; + final centerOffset = viewState.position; + final viewportSize = viewState.viewportSize; + final viewOrigin = Offset( + ((displayWidth * scale - viewportSize.width) / 2 - centerOffset.dx), + ((displayHeight * scale - viewportSize.height) / 2 - centerOffset.dy), + ); + return viewOrigin & viewportSize; + } + + Tuple2> _getTileRects({ + @required int x, + @required int y, + @required int layerRegionWidth, + @required int layerRegionHeight, + @required int displayWidth, + @required int displayHeight, + @required double scale, + @required Rect viewRect, + }) { + final nextX = x + layerRegionWidth; + final nextY = y + layerRegionHeight; + final thisRegionWidth = layerRegionWidth - (nextX >= displayWidth ? nextX - displayWidth : 0); + final thisRegionHeight = layerRegionHeight - (nextY >= displayHeight ? nextY - displayHeight : 0); + final tileRect = Rect.fromLTWH(x * scale, y * scale, thisRegionWidth * scale, thisRegionHeight * scale); + + // only build visible tiles + if (!viewRect.overlaps(tileRect)) return null; + + Rectangle regionRect; + if (_tileTransform != null) { + // apply EXIF orientation + final regionRectDouble = Rect.fromLTWH(x.toDouble(), y.toDouble(), thisRegionWidth.toDouble(), thisRegionHeight.toDouble()); + final tl = MatrixUtils.transformPoint(_tileTransform, regionRectDouble.topLeft); + final br = MatrixUtils.transformPoint(_tileTransform, regionRectDouble.bottomRight); + regionRect = Rectangle.fromPoints( + Point(tl.dx.round(), tl.dy.round()), + Point(br.dx.round(), br.dy.round()), + ); + } else { + regionRect = Rectangle(x, y, thisRegionWidth, thisRegionHeight); + } + return Tuple2>(tileRect, regionRect); } int _sampleSizeForScale(double scale) { diff --git a/lib/widgets/settings/entry_background.dart b/lib/widgets/settings/entry_background.dart new file mode 100644 index 000000000..ade54d894 --- /dev/null +++ b/lib/widgets/settings/entry_background.dart @@ -0,0 +1,78 @@ +import 'package:aves/model/settings/entry_background.dart'; +import 'package:aves/widgets/common/fx/borders.dart'; +import 'package:aves/widgets/common/fx/checkered_decoration.dart'; +import 'package:flutter/material.dart'; + +class EntryBackgroundSelector extends StatefulWidget { + final ValueGetter getter; + final ValueSetter setter; + + const EntryBackgroundSelector({ + @required this.getter, + @required this.setter, + }); + + @override + _EntryBackgroundSelectorState createState() => _EntryBackgroundSelectorState(); +} + +class _EntryBackgroundSelectorState extends State { + @override + Widget build(BuildContext context) { + return DropdownButtonHideUnderline( + child: DropdownButton( + items: _buildItems(context), + value: widget.getter(), + onChanged: (selected) { + widget.setter(selected); + setState(() {}); + }, + ), + ); + } + + List> _buildItems(BuildContext context) { + const radius = 12.0; + return [ + EntryBackground.white, + EntryBackground.black, + EntryBackground.checkered, + EntryBackground.transparent, + ].map((selected) { + Widget child; + switch (selected) { + case EntryBackground.transparent: + child = Icon( + Icons.clear, + size: 20, + color: Colors.white30, + ); + break; + case EntryBackground.checkered: + child = ClipOval( + child: DecoratedBox( + decoration: CheckeredDecoration( + checkSize: radius, + ), + ), + ); + break; + default: + break; + } + return DropdownMenuItem( + value: selected, + child: Container( + height: radius * 2, + width: radius * 2, + decoration: BoxDecoration( + color: selected.isColor ? selected.color : null, + border: AvesCircleBorder.build(context), + shape: BoxShape.circle, + ), + child: child, + ), + ); + }).toList(); + } +} diff --git a/lib/widgets/settings/settings_page.dart b/lib/widgets/settings/settings_page.dart index a6e52de38..65c320102 100644 --- a/lib/widgets/settings/settings_page.dart +++ b/lib/widgets/settings/settings_page.dart @@ -8,7 +8,7 @@ import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/settings/access_grants.dart'; -import 'package:aves/widgets/settings/svg_background.dart'; +import 'package:aves/widgets/settings/entry_background.dart'; import 'package:flutter/material.dart'; import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; import 'package:provider/provider.dart'; @@ -115,8 +115,18 @@ class _SettingsPageState extends State { }, ), ListTile( - title: Text('SVG background'), - trailing: SvgBackgroundSelector(), + title: Text('Raster image background'), + trailing: EntryBackgroundSelector( + getter: () => settings.rasterBackground, + setter: (value) => settings.rasterBackground = value, + ), + ), + ListTile( + title: Text('Vector image background'), + trailing: EntryBackgroundSelector( + getter: () => settings.vectorBackground, + setter: (value) => settings.vectorBackground = value, + ), ), ListTile( title: Text('Coordinate format'), diff --git a/lib/widgets/settings/svg_background.dart b/lib/widgets/settings/svg_background.dart deleted file mode 100644 index 929b5a8ad..000000000 --- a/lib/widgets/settings/svg_background.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'package:aves/model/settings/settings.dart'; -import 'package:aves/widgets/common/fx/borders.dart'; -import 'package:flutter/material.dart'; - -class SvgBackgroundSelector extends StatefulWidget { - @override - _SvgBackgroundSelectorState createState() => _SvgBackgroundSelectorState(); -} - -class _SvgBackgroundSelectorState extends State { - @override - Widget build(BuildContext context) { - const radius = 24.0; - return DropdownButtonHideUnderline( - child: DropdownButton( - items: [0xFFFFFFFF, 0xFF000000, 0x00000000].map((selected) { - return DropdownMenuItem( - value: selected, - child: Container( - height: radius, - width: radius, - decoration: BoxDecoration( - color: Color(selected), - border: AvesCircleBorder.build(context), - shape: BoxShape.circle, - ), - child: selected == 0 - ? Icon( - Icons.clear, - size: 20, - color: Colors.white30, - ) - : null, - ), - ); - }).toList(), - value: settings.svgBackground, - onChanged: (selected) { - settings.svgBackground = selected; - setState(() {}); - }, - ), - ); - } -} diff --git a/lib/widgets/stats/stats.dart b/lib/widgets/stats/stats.dart index 96abd7807..2907bcb96 100644 --- a/lib/widgets/stats/stats.dart +++ b/lib/widgets/stats/stats.dart @@ -295,7 +295,5 @@ class EntryByMimeDatum { Color get color => stringToColor(displayText); @override - String toString() { - return '[$runtimeType#${shortHash(this)}: mimeType=$mimeType, displayText=$displayText, entryCount=$entryCount]'; - } + String toString() => '$runtimeType#${shortHash(this)}{mimeType=$mimeType, displayText=$displayText, entryCount=$entryCount}'; } diff --git a/pubspec.lock b/pubspec.lock index 59280dd12..b3090e230 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -63,7 +63,7 @@ packages: name: cached_network_image url: "https://pub.dartlang.org" source: hosted - version: "2.4.1" + version: "2.5.0" characters: dependency: transitive description: @@ -282,7 +282,7 @@ packages: name: flutter_cache_manager url: "https://pub.dartlang.org" source: hosted - version: "2.0.0" + version: "2.1.0" flutter_cube: dependency: transitive description: @@ -605,7 +605,7 @@ packages: name: panorama url: "https://pub.dartlang.org" source: hosted - version: "0.1.0" + version: "0.1.2" path: dependency: transitive description: @@ -668,7 +668,7 @@ packages: name: pdf url: "https://pub.dartlang.org" source: hosted - version: "1.12.0" + version: "1.13.0" pedantic: dependency: "direct main" description: @@ -682,7 +682,7 @@ packages: name: percent_indicator url: "https://pub.dartlang.org" source: hosted - version: "2.1.8" + version: "2.1.9" permission_handler: dependency: "direct main" description: @@ -704,15 +704,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.1.0" - photo_view: - dependency: "direct main" - description: - path: "." - ref: HEAD - resolved-ref: aa6400bbc85bf6ce953c4609d126796cdb4ca3c2 - url: "git://github.com/deckerst/photo_view.git" - source: git - version: "0.9.2" platform: dependency: transitive description: @@ -747,7 +738,7 @@ packages: name: printing url: "https://pub.dartlang.org" source: hosted - version: "3.7.1" + version: "3.7.2" process: dependency: transitive description: @@ -796,7 +787,7 @@ packages: name: rxdart url: "https://pub.dartlang.org" source: hosted - version: "0.24.1" + version: "0.25.0" screen: dependency: "direct main" description: @@ -866,7 +857,7 @@ packages: name: shelf_static url: "https://pub.dartlang.org" source: hosted - version: "0.2.8" + version: "0.2.9+1" shelf_web_socket: dependency: transitive description: @@ -1061,13 +1052,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.0.1+3" - utf: - dependency: transitive - description: - name: utf - url: "https://pub.dartlang.org" - source: hosted - version: "0.9.0+5" uuid: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 46b5c29f8..7a6aeed7f 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.2.9+35 +version: 1.3.0+36 # brendan-duncan/image (as of v2.1.19): # - does not support TIFF with JPEG compression (issue #184) @@ -70,10 +70,6 @@ dependencies: pedantic: percent_indicator: permission_handler: - photo_view: -# path: ../photo_view - git: - url: git://github.com/deckerst/photo_view.git printing: provider: screen: diff --git a/whatsnew/whatsnew-en-US b/whatsnew/whatsnew-en-US index e7bbf696e..b6ade0151 100644 --- a/whatsnew/whatsnew-en-US +++ b/whatsnew/whatsnew-en-US @@ -1,7 +1,5 @@ Thanks for using Aves! -v1.2.9: -- identify 360 photos/videos, GeoTIFF -- open panoramas (360 photos) -- open GImage/GAudio/GDepth media and thumbnails embedded in XMP -- improved large TIFF handling +v1.3.0: +- added quick scale (aka one finger zoom) gesture to the viewer +- fixed zoom focus with double-tap or pinch-to-zoom gestures Full changelog available on Github \ No newline at end of file