Merge branch 'develop'
This commit is contained in:
commit
5769cf1579
69 changed files with 2287 additions and 611 deletions
12
CHANGELOG.md
12
CHANGELOG.md
|
@ -3,6 +3,18 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [v1.2.9] - 2020-12-12
|
||||||
### Added
|
### Added
|
||||||
- Collection: identify 360 photos/videos, GeoTIFF
|
- Collection: identify 360 photos/videos, GeoTIFF
|
||||||
|
|
|
@ -26,7 +26,6 @@ Aves is a gallery and metadata explorer app. It is built for Android, with Flutt
|
||||||
|
|
||||||
## Known Issues
|
## 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))
|
- 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 `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))
|
- SVG: unsupported out of order defs/references (cf [flutter_svg issue #102](https://github.com/dnfield/flutter_svg/issues/102))
|
||||||
|
|
|
@ -53,7 +53,7 @@ android {
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "deckers.thibault.aves"
|
applicationId "deckers.thibault.aves"
|
||||||
// TODO TLAD try minSdkVersion 23 when kotlin migration is done
|
// TODO TLAD try minSdkVersion 23
|
||||||
minSdkVersion 24
|
minSdkVersion 24
|
||||||
targetSdkVersion 30 // same as compileSdkVersion
|
targetSdkVersion 30 // same as compileSdkVersion
|
||||||
versionCode flutterVersionCode.toInteger()
|
versionCode flutterVersionCode.toInteger()
|
||||||
|
@ -99,7 +99,7 @@ repositories {
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9'
|
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.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.commonsware.cwac:document:0.4.1'
|
||||||
implementation 'com.drewnoakes:metadata-extractor:2.15.0'
|
implementation 'com.drewnoakes:metadata-extractor:2.15.0'
|
||||||
// as of v0.9.8.7, `Android-TiffBitmapFactory` master branch is set up to release and distribute via Bintray
|
// as of v0.9.8.7, `Android-TiffBitmapFactory` master branch is set up to release and distribute via Bintray
|
||||||
|
|
|
@ -17,6 +17,7 @@ import deckers.thibault.aves.utils.LogUtils.createTag
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
@ -27,8 +28,8 @@ import kotlin.math.roundToInt
|
||||||
class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"getAppIcon" -> GlobalScope.launch { getAppIcon(call, Coresult(result)) }
|
"getAppIcon" -> GlobalScope.launch(Dispatchers.IO) { getAppIcon(call, Coresult(result)) }
|
||||||
"getAppNames" -> GlobalScope.launch { getAppNames(Coresult(result)) }
|
"getAppNames" -> GlobalScope.launch(Dispatchers.IO) { getAppNames(Coresult(result)) }
|
||||||
"edit" -> {
|
"edit" -> {
|
||||||
val title = call.argument<String>("title")
|
val title = call.argument<String>("title")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||||
|
|
|
@ -12,6 +12,7 @@ import deckers.thibault.aves.utils.BitmapUtils.centerSquareCrop
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@ -20,7 +21,7 @@ class AppShortcutHandler(private val context: Context) : MethodCallHandler {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"canPin" -> result.success(canPin())
|
"canPin" -> result.success(canPin())
|
||||||
"pin" -> {
|
"pin" -> {
|
||||||
GlobalScope.launch { pin(call) }
|
GlobalScope.launch(Dispatchers.IO) { pin(call) }
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
|
|
|
@ -25,6 +25,7 @@ import deckers.thibault.aves.utils.StorageUtils
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
||||||
|
@ -36,12 +37,12 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"getContextDirs" -> result.success(getContextDirs())
|
"getContextDirs" -> result.success(getContextDirs())
|
||||||
"getEnv" -> result.success(System.getenv())
|
"getEnv" -> result.success(System.getenv())
|
||||||
"getBitmapFactoryInfo" -> GlobalScope.launch { getBitmapFactoryInfo(call, Coresult(result)) }
|
"getBitmapFactoryInfo" -> GlobalScope.launch(Dispatchers.IO) { getBitmapFactoryInfo(call, Coresult(result)) }
|
||||||
"getContentResolverMetadata" -> GlobalScope.launch { getContentResolverMetadata(call, Coresult(result)) }
|
"getContentResolverMetadata" -> GlobalScope.launch(Dispatchers.IO) { getContentResolverMetadata(call, Coresult(result)) }
|
||||||
"getExifInterfaceMetadata" -> GlobalScope.launch { getExifInterfaceMetadata(call, Coresult(result)) }
|
"getExifInterfaceMetadata" -> GlobalScope.launch(Dispatchers.IO) { getExifInterfaceMetadata(call, Coresult(result)) }
|
||||||
"getMediaMetadataRetrieverMetadata" -> GlobalScope.launch { getMediaMetadataRetrieverMetadata(call, Coresult(result)) }
|
"getMediaMetadataRetrieverMetadata" -> GlobalScope.launch(Dispatchers.IO) { getMediaMetadataRetrieverMetadata(call, Coresult(result)) }
|
||||||
"getMetadataExtractorSummary" -> GlobalScope.launch { getMetadataExtractorSummary(call, Coresult(result)) }
|
"getMetadataExtractorSummary" -> GlobalScope.launch(Dispatchers.IO) { getMetadataExtractorSummary(call, Coresult(result)) }
|
||||||
"getTiffStructure" -> GlobalScope.launch { getTiffStructure(call, Coresult(result)) }
|
"getTiffStructure" -> GlobalScope.launch(Dispatchers.IO) { getTiffStructure(call, Coresult(result)) }
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,12 +26,12 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
||||||
|
|
||||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"getObsoleteEntries" -> GlobalScope.launch { getObsoleteEntries(call, Coresult(result)) }
|
"getObsoleteEntries" -> GlobalScope.launch(Dispatchers.IO) { getObsoleteEntries(call, Coresult(result)) }
|
||||||
"getImageEntry" -> GlobalScope.launch { getImageEntry(call, Coresult(result)) }
|
"getImageEntry" -> GlobalScope.launch(Dispatchers.IO) { getImageEntry(call, Coresult(result)) }
|
||||||
"getThumbnail" -> GlobalScope.launch { getThumbnail(call, Coresult(result)) }
|
"getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { getThumbnail(call, Coresult(result)) }
|
||||||
"getRegion" -> GlobalScope.launch { getRegion(call, Coresult(result)) }
|
"getRegion" -> GlobalScope.launch(Dispatchers.IO) { getRegion(call, Coresult(result)) }
|
||||||
"clearSizedThumbnailDiskCache" -> {
|
"clearSizedThumbnailDiskCache" -> {
|
||||||
GlobalScope.launch { Glide.get(activity).clearDiskCache() }
|
GlobalScope.launch(Dispatchers.IO) { Glide.get(activity).clearDiskCache() }
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
"rename" -> GlobalScope.launch(Dispatchers.IO) { rename(call, Coresult(result)) }
|
"rename" -> GlobalScope.launch(Dispatchers.IO) { rename(call, Coresult(result)) }
|
||||||
|
|
|
@ -61,6 +61,7 @@ import deckers.thibault.aves.utils.StorageUtils
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
@ -70,12 +71,12 @@ import kotlin.math.roundToLong
|
||||||
class MetadataHandler(private val context: Context) : MethodCallHandler {
|
class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"getAllMetadata" -> GlobalScope.launch { getAllMetadata(call, Coresult(result)) }
|
"getAllMetadata" -> GlobalScope.launch(Dispatchers.IO) { getAllMetadata(call, Coresult(result)) }
|
||||||
"getCatalogMetadata" -> GlobalScope.launch { getCatalogMetadata(call, Coresult(result)) }
|
"getCatalogMetadata" -> GlobalScope.launch(Dispatchers.IO) { getCatalogMetadata(call, Coresult(result)) }
|
||||||
"getOverlayMetadata" -> GlobalScope.launch { getOverlayMetadata(call, Coresult(result)) }
|
"getOverlayMetadata" -> GlobalScope.launch(Dispatchers.IO) { getOverlayMetadata(call, Coresult(result)) }
|
||||||
"getEmbeddedPictures" -> GlobalScope.launch { getEmbeddedPictures(call, Coresult(result)) }
|
"getEmbeddedPictures" -> GlobalScope.launch(Dispatchers.IO) { getEmbeddedPictures(call, Coresult(result)) }
|
||||||
"getExifThumbnails" -> GlobalScope.launch { getExifThumbnails(call, Coresult(result)) }
|
"getExifThumbnails" -> GlobalScope.launch(Dispatchers.IO) { getExifThumbnails(call, Coresult(result)) }
|
||||||
"extractXmpDataProp" -> GlobalScope.launch { extractXmpDataProp(call, Coresult(result)) }
|
"extractXmpDataProp" -> GlobalScope.launch(Dispatchers.IO) { extractXmpDataProp(call, Coresult(result)) }
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -588,7 +589,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
"mimeType" to embedMimeType,
|
"mimeType" to embedMimeType,
|
||||||
)
|
)
|
||||||
if (isImage(embedMimeType) || isVideo(embedMimeType)) {
|
if (isImage(embedMimeType) || isVideo(embedMimeType)) {
|
||||||
GlobalScope.launch {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
FileImageProvider().fetchSingle(context, embedUri, embedMimeType, object : ImageProvider.ImageOpCallback {
|
FileImageProvider().fetchSingle(context, embedUri, embedMimeType, object : ImageProvider.ImageOpCallback {
|
||||||
override fun onSuccess(fields: FieldMap) {
|
override fun onSuccess(fields: FieldMap) {
|
||||||
embedFields.putAll(fields)
|
embedFields.putAll(fields)
|
||||||
|
|
|
@ -11,6 +11,7 @@ import deckers.thibault.aves.utils.StorageUtils.getVolumePaths
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
@ -32,7 +33,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
||||||
"getGrantedDirectories" -> result.success(ArrayList(PermissionManager.getGrantedDirs(context)))
|
"getGrantedDirectories" -> result.success(ArrayList(PermissionManager.getGrantedDirs(context)))
|
||||||
"getInaccessibleDirectories" -> getInaccessibleDirectories(call, result)
|
"getInaccessibleDirectories" -> getInaccessibleDirectories(call, result)
|
||||||
"revokeDirectoryAccess" -> revokeDirectoryAccess(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()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,6 +18,7 @@ import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
|
||||||
import deckers.thibault.aves.utils.StorageUtils
|
import deckers.thibault.aves.utils.StorageUtils
|
||||||
import io.flutter.plugin.common.EventChannel
|
import io.flutter.plugin.common.EventChannel
|
||||||
import io.flutter.plugin.common.EventChannel.EventSink
|
import io.flutter.plugin.common.EventChannel.EventSink
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
||||||
|
@ -32,7 +33,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
||||||
this.eventSink = eventSink
|
this.eventSink = eventSink
|
||||||
handler = Handler(Looper.getMainLooper())
|
handler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
GlobalScope.launch { streamImage() }
|
GlobalScope.launch(Dispatchers.IO) { streamImage() }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCancel(o: Any) {}
|
override fun onCancel(o: Any) {}
|
||||||
|
|
|
@ -7,6 +7,7 @@ import deckers.thibault.aves.model.provider.FieldMap
|
||||||
import deckers.thibault.aves.model.provider.MediaStoreImageProvider
|
import deckers.thibault.aves.model.provider.MediaStoreImageProvider
|
||||||
import io.flutter.plugin.common.EventChannel
|
import io.flutter.plugin.common.EventChannel
|
||||||
import io.flutter.plugin.common.EventChannel.EventSink
|
import io.flutter.plugin.common.EventChannel.EventSink
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@ -27,7 +28,7 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E
|
||||||
this.eventSink = eventSink
|
this.eventSink = eventSink
|
||||||
handler = Handler(Looper.getMainLooper())
|
handler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
GlobalScope.launch { fetchAll() }
|
GlobalScope.launch(Dispatchers.IO) { fetchAll() }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCancel(arguments: Any?) {}
|
override fun onCancel(arguments: Any?) {}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
buildscript {
|
buildscript {
|
||||||
ext.kotlin_version = '1.4.20'
|
ext.kotlin_version = '1.4.21'
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
jcenter()
|
jcenter()
|
||||||
|
|
|
@ -125,7 +125,5 @@ class RegionProviderKey {
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, sampleSize=$sampleSize, regionRect=$regionRect, imageSize=$imageSize, scale=$scale}';
|
||||||
return 'RegionProviderKey(uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, sampleSize=$sampleSize, regionRect=$regionRect, imageSize=$imageSize, scale=$scale)';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -116,7 +116,5 @@ class ThumbnailProviderKey {
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, dateModifiedSecs=$dateModifiedSecs, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, extent=$extent, scale=$scale}';
|
||||||
return 'ThumbnailProviderKey{uri=$uri, mimeType=$mimeType, dateModifiedSecs=$dateModifiedSecs, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, extent=$extent, scale=$scale}';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -80,5 +80,5 @@ class UriImage extends ImageProvider<UriImage> {
|
||||||
int get hashCode => hashValues(uri, scale);
|
int get hashCode => hashValues(uri, scale);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => '${objectRuntimeType(this, 'UriImage')}(uri=$uri, mimeType=$mimeType, scale=$scale)';
|
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, scale=$scale}';
|
||||||
}
|
}
|
||||||
|
|
|
@ -54,5 +54,5 @@ class UriPicture extends PictureProvider<UriPicture> {
|
||||||
int get hashCode => hashValues(uri, colorFilter);
|
int get hashCode => hashValues(uri, colorFilter);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => '${objectRuntimeType(this, 'UriPicture')}(uri=$uri, mimeType=$mimeType, colorFilter=$colorFilter)';
|
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, colorFilter=$colorFilter}';
|
||||||
}
|
}
|
||||||
|
|
|
@ -87,7 +87,5 @@ class AlbumFilter extends CollectionFilter {
|
||||||
int get hashCode => hashValues(type, album);
|
int get hashCode => hashValues(type, album);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() => '$runtimeType#${shortHash(this)}{album=$album}';
|
||||||
return '$runtimeType#${shortHash(this)}{album=$album}';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -62,9 +62,7 @@ class LocationFilter extends CollectionFilter {
|
||||||
int get hashCode => hashValues(type, level, _location);
|
int get hashCode => hashValues(type, level, _location);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() => '$runtimeType#${shortHash(this)}{level=$level, location=$_location}';
|
||||||
return '$runtimeType#${shortHash(this)}{level=$level, location=$_location}';
|
|
||||||
}
|
|
||||||
|
|
||||||
// U+0041 Latin Capital letter A
|
// U+0041 Latin Capital letter A
|
||||||
// U+1F1E6 🇦 REGIONAL INDICATOR SYMBOL LETTER A
|
// U+1F1E6 🇦 REGIONAL INDICATOR SYMBOL LETTER A
|
||||||
|
|
|
@ -88,7 +88,5 @@ class MimeFilter extends CollectionFilter {
|
||||||
int get hashCode => hashValues(type, mime);
|
int get hashCode => hashValues(type, mime);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() => '$runtimeType#${shortHash(this)}{mime=$mime}';
|
||||||
return '$runtimeType#${shortHash(this)}{mime=$mime}';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -71,7 +71,5 @@ class QueryFilter extends CollectionFilter {
|
||||||
int get hashCode => hashValues(type, query);
|
int get hashCode => hashValues(type, query);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() => '$runtimeType#${shortHash(this)}{query=$query}';
|
||||||
return '$runtimeType#${shortHash(this)}{query=$query}';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -48,7 +48,5 @@ class TagFilter extends CollectionFilter {
|
||||||
int get hashCode => hashValues(type, tag);
|
int get hashCode => hashValues(type, tag);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() => '$runtimeType#${shortHash(this)}{tag=$tag}';
|
||||||
return '$runtimeType#${shortHash(this)}{tag=$tag}';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@ import 'package:aves/model/metadata_db.dart';
|
||||||
import 'package:aves/services/image_file_service.dart';
|
import 'package:aves/services/image_file_service.dart';
|
||||||
import 'package:aves/services/metadata_service.dart';
|
import 'package:aves/services/metadata_service.dart';
|
||||||
import 'package:aves/services/service_policy.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/change_notifier.dart';
|
||||||
import 'package:aves/utils/math_utils.dart';
|
import 'package:aves/utils/math_utils.dart';
|
||||||
import 'package:aves/utils/time_utils.dart';
|
import 'package:aves/utils/time_utils.dart';
|
||||||
|
@ -24,8 +25,6 @@ class ImageEntry {
|
||||||
String _path, _directory, _filename, _extension;
|
String _path, _directory, _filename, _extension;
|
||||||
int contentId;
|
int contentId;
|
||||||
final String sourceMimeType;
|
final String sourceMimeType;
|
||||||
|
|
||||||
// TODO TLAD use SVG viewport as width/height
|
|
||||||
int width;
|
int width;
|
||||||
int height;
|
int height;
|
||||||
int sourceRotationDegrees;
|
int sourceRotationDegrees;
|
||||||
|
@ -64,6 +63,8 @@ class ImageEntry {
|
||||||
|
|
||||||
bool get canDecode => !undecodable.contains(mimeType);
|
bool get canDecode => !undecodable.contains(mimeType);
|
||||||
|
|
||||||
|
bool get canHaveAlpha => MimeTypes.alphaImages.contains(mimeType);
|
||||||
|
|
||||||
ImageEntry copyWith({
|
ImageEntry copyWith({
|
||||||
@required String uri,
|
@required String uri,
|
||||||
@required String path,
|
@required String path,
|
||||||
|
@ -134,9 +135,7 @@ class ImageEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, path=$path}';
|
||||||
return 'ImageEntry{uri=$uri, path=$path}';
|
|
||||||
}
|
|
||||||
|
|
||||||
set path(String path) {
|
set path(String path) {
|
||||||
_path = path;
|
_path = path;
|
||||||
|
@ -238,10 +237,24 @@ class ImageEntry {
|
||||||
// but it would take space and time, so a basic workaround will do.
|
// but it would take space and time, so a basic workaround will do.
|
||||||
bool get isPortrait => rotationDegrees % 180 == 90 && (catalogMetadata?.rotationDegrees == null || width > height);
|
bool get isPortrait => rotationDegrees % 180 == 90 && (catalogMetadata?.rotationDegrees == null || width > height);
|
||||||
|
|
||||||
|
static const ratioSeparator = '\u2236';
|
||||||
|
static const resolutionSeparator = ' \u00D7 ';
|
||||||
|
|
||||||
String get resolutionText {
|
String get resolutionText {
|
||||||
final w = width ?? '?';
|
final w = width ?? '?';
|
||||||
final h = height ?? '?';
|
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 {
|
double get displayAspectRatio {
|
||||||
|
@ -321,7 +334,7 @@ class ImageEntry {
|
||||||
String _bestTitle;
|
String _bestTitle;
|
||||||
|
|
||||||
String get bestTitle {
|
String get bestTitle {
|
||||||
_bestTitle ??= (isCatalogued && _catalogMetadata.xmpTitleDescription.isNotEmpty) ? _catalogMetadata.xmpTitleDescription : sourceTitle;
|
_bestTitle ??= (isCatalogued && _catalogMetadata.xmpTitleDescription?.isNotEmpty == true) ? _catalogMetadata.xmpTitleDescription : sourceTitle;
|
||||||
return _bestTitle;
|
return _bestTitle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -352,8 +365,21 @@ class ImageEntry {
|
||||||
|
|
||||||
Future<void> catalog({bool background = false}) async {
|
Future<void> catalog({bool background = false}) async {
|
||||||
if (isCatalogued) return;
|
if (isCatalogued) return;
|
||||||
|
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);
|
catalogMetadata = await MetadataService.getCatalogMetadata(this, background: background);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
AddressDetails get addressDetails => _addressDetails;
|
AddressDetails get addressDetails => _addressDetails;
|
||||||
|
|
||||||
|
@ -449,6 +475,12 @@ class ImageEntry {
|
||||||
this.sourceTitle = sourceTitle;
|
this.sourceTitle = sourceTitle;
|
||||||
_bestTitle = null;
|
_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'];
|
final dateModifiedSecs = newFields['dateModifiedSecs'];
|
||||||
if (dateModifiedSecs is int) this.dateModifiedSecs = dateModifiedSecs;
|
if (dateModifiedSecs is int) this.dateModifiedSecs = dateModifiedSecs;
|
||||||
final rotationDegrees = newFields['rotationDegrees'];
|
final rotationDegrees = newFields['rotationDegrees'];
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:geocoder/model.dart';
|
import 'package:geocoder/model.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
@ -23,9 +24,7 @@ class DateMetadata {
|
||||||
};
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, dateMillis=$dateMillis}';
|
||||||
return 'DateMetadata{contentId=$contentId, dateMillis=$dateMillis}';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class CatalogMetadata {
|
class CatalogMetadata {
|
||||||
|
@ -47,10 +46,10 @@ class CatalogMetadata {
|
||||||
this.contentId,
|
this.contentId,
|
||||||
this.mimeType,
|
this.mimeType,
|
||||||
this.dateMillis,
|
this.dateMillis,
|
||||||
this.isAnimated,
|
this.isAnimated = false,
|
||||||
this.isFlipped,
|
this.isFlipped = false,
|
||||||
this.isGeotiff,
|
this.isGeotiff = false,
|
||||||
this.is360,
|
this.is360 = false,
|
||||||
this.rotationDegrees,
|
this.rotationDegrees,
|
||||||
this.xmpSubjects,
|
this.xmpSubjects,
|
||||||
this.xmpTitleDescription,
|
this.xmpTitleDescription,
|
||||||
|
@ -117,9 +116,7 @@ class CatalogMetadata {
|
||||||
};
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
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}';
|
||||||
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}';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class OverlayMetadata {
|
class OverlayMetadata {
|
||||||
|
@ -150,9 +147,7 @@ class OverlayMetadata {
|
||||||
bool get isEmpty => aperture == null && exposureTime == null && focalLength == null && iso == null;
|
bool get isEmpty => aperture == null && exposureTime == null && focalLength == null && iso == null;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() => '$runtimeType#${shortHash(this)}{aperture=$aperture, exposureTime=$exposureTime, focalLength=$focalLength, iso=$iso}';
|
||||||
return 'OverlayMetadata{aperture=$aperture, exposureTime=$exposureTime, focalLength=$focalLength, iso=$iso}';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class AddressDetails {
|
class AddressDetails {
|
||||||
|
@ -200,9 +195,7 @@ class AddressDetails {
|
||||||
};
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, countryCode=$countryCode, countryName=$countryName, adminArea=$adminArea, locality=$locality}';
|
||||||
return 'AddressDetails{contentId=$contentId, countryCode=$countryCode, countryName=$countryName, adminArea=$adminArea, locality=$locality}';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@immutable
|
@immutable
|
||||||
|
@ -237,7 +230,5 @@ class FavouriteRow {
|
||||||
int get hashCode => hashValues(contentId, path);
|
int get hashCode => hashValues(contentId, path);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, path=$path}';
|
||||||
return 'FavouriteRow{contentId=$contentId, path=$path}';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
26
lib/model/settings/entry_background.dart
Normal file
26
lib/model/settings/entry_background.dart
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
import 'package:aves/model/settings/coordinate_format.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/home_page.dart';
|
||||||
import 'package:aves/model/settings/screen_on.dart';
|
import 'package:aves/model/settings/screen_on.dart';
|
||||||
import 'package:aves/widgets/fullscreen/info/location_section.dart';
|
import 'package:aves/widgets/fullscreen/info/location_section.dart';
|
||||||
|
@ -54,7 +55,8 @@ class Settings extends ChangeNotifier {
|
||||||
static const coordinateFormatKey = 'coordinates_format';
|
static const coordinateFormatKey = 'coordinates_format';
|
||||||
|
|
||||||
// rendering
|
// rendering
|
||||||
static const svgBackgroundKey = 'svg_background';
|
static const rasterBackgroundKey = 'raster_background';
|
||||||
|
static const vectorBackgroundKey = 'vector_background';
|
||||||
|
|
||||||
// search
|
// search
|
||||||
static const saveSearchHistoryKey = 'save_search_history';
|
static const saveSearchHistoryKey = 'save_search_history';
|
||||||
|
@ -184,9 +186,13 @@ class Settings extends ChangeNotifier {
|
||||||
|
|
||||||
// rendering
|
// 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
|
// search
|
||||||
|
|
||||||
|
|
|
@ -23,7 +23,7 @@ mixin TagMixin on SourceBase {
|
||||||
|
|
||||||
Future<void> catalogEntries() async {
|
Future<void> catalogEntries() async {
|
||||||
// final stopwatch = Stopwatch()..start();
|
// 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;
|
if (todo.isEmpty) return;
|
||||||
|
|
||||||
var progressDone = 0;
|
var progressDone = 0;
|
||||||
|
|
|
@ -1,15 +1,17 @@
|
||||||
class MimeTypes {
|
class MimeTypes {
|
||||||
static const anyImage = 'image/*';
|
static const anyImage = 'image/*';
|
||||||
|
|
||||||
|
static const bmp = 'image/bmp';
|
||||||
static const gif = 'image/gif';
|
static const gif = 'image/gif';
|
||||||
static const heic = 'image/heic';
|
static const heic = 'image/heic';
|
||||||
static const heif = 'image/heif';
|
static const heif = 'image/heif';
|
||||||
|
static const ico = 'image/x-icon';
|
||||||
static const jpeg = 'image/jpeg';
|
static const jpeg = 'image/jpeg';
|
||||||
static const png = 'image/png';
|
static const png = 'image/png';
|
||||||
static const svg = 'image/svg+xml';
|
static const svg = 'image/svg+xml';
|
||||||
|
static const tiff = 'image/tiff';
|
||||||
static const webp = 'image/webp';
|
static const webp = 'image/webp';
|
||||||
|
|
||||||
static const tiff = 'image/tiff';
|
|
||||||
static const psd = 'image/vnd.adobe.photoshop';
|
static const psd = 'image/vnd.adobe.photoshop';
|
||||||
|
|
||||||
static const arw = 'image/x-sony-arw';
|
static const arw = 'image/x-sony-arw';
|
||||||
|
@ -40,6 +42,10 @@ class MimeTypes {
|
||||||
static const mp4 = 'video/mp4';
|
static const mp4 = 'video/mp4';
|
||||||
|
|
||||||
// groups
|
// groups
|
||||||
|
|
||||||
|
// formats that support transparency
|
||||||
|
static const List<String> alphaImages = [bmp, gif, ico, png, svg, tiff, webp];
|
||||||
|
|
||||||
static const List<String> rawImages = [arw, cr2, crw, dcr, dng, erf, k25, kdc, mrw, nef, nrw, orf, pef, raf, raw, rw2, sr2, srf, srw, x3f];
|
static const List<String> 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');
|
static bool isImage(String mimeType) => mimeType.startsWith('image');
|
||||||
|
|
|
@ -15,6 +15,7 @@ class XMP {
|
||||||
'exifEX': 'Exif Ex',
|
'exifEX': 'Exif Ex',
|
||||||
'GettyImagesGIFT': 'Getty Images',
|
'GettyImagesGIFT': 'Getty Images',
|
||||||
'GIMP': 'GIMP',
|
'GIMP': 'GIMP',
|
||||||
|
'GCamera': 'Google Camera',
|
||||||
'GFocus': 'Google Focus',
|
'GFocus': 'Google Focus',
|
||||||
'GPano': 'Google Panorama',
|
'GPano': 'Google Panorama',
|
||||||
'illustrator': 'Illustrator',
|
'illustrator': 'Illustrator',
|
||||||
|
|
|
@ -300,9 +300,7 @@ class ImageOpEvent {
|
||||||
int get hashCode => hashValues(success, uri);
|
int get hashCode => hashValues(success, uri);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() => '$runtimeType#${shortHash(this)}{success=$success, uri=$uri}';
|
||||||
return 'ImageOpEvent{success=$success, uri=$uri}';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class MoveOpEvent extends ImageOpEvent {
|
class MoveOpEvent extends ImageOpEvent {
|
||||||
|
@ -323,9 +321,7 @@ class MoveOpEvent extends ImageOpEvent {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() => '$runtimeType#${shortHash(this)}{success=$success, uri=$uri, newFields=$newFields}';
|
||||||
return 'MoveOpEvent{success=$success, uri=$uri, newFields=$newFields}';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// cf flutter/foundation `consolidateHttpClientResponseBytes`
|
// cf flutter/foundation `consolidateHttpClientResponseBytes`
|
||||||
|
|
|
@ -10,7 +10,10 @@ class ServicePolicy {
|
||||||
final StreamController<QueueState> _queueStreamController = StreamController<QueueState>.broadcast();
|
final StreamController<QueueState> _queueStreamController = StreamController<QueueState>.broadcast();
|
||||||
final Map<Object, Tuple2<int, _Task>> _paused = {};
|
final Map<Object, Tuple2<int, _Task>> _paused = {};
|
||||||
final SplayTreeMap<int, Queue<_Task>> _queues = SplayTreeMap();
|
final SplayTreeMap<int, Queue<_Task>> _queues = SplayTreeMap();
|
||||||
_Task _running;
|
final Queue<_Task> _runningQueue = Queue();
|
||||||
|
|
||||||
|
// magic number
|
||||||
|
static const concurrentTaskMax = 4;
|
||||||
|
|
||||||
Stream<QueueState> get queueStream => _queueStreamController.stream;
|
Stream<QueueState> get queueStream => _queueStreamController.stream;
|
||||||
|
|
||||||
|
@ -23,6 +26,7 @@ class ServicePolicy {
|
||||||
Object key,
|
Object key,
|
||||||
}) {
|
}) {
|
||||||
_Task task;
|
_Task task;
|
||||||
|
key ??= platformCall.hashCode;
|
||||||
final priorityTask = _paused.remove(key);
|
final priorityTask = _paused.remove(key);
|
||||||
if (priorityTask != null) {
|
if (priorityTask != null) {
|
||||||
debugPrint('resume task with key=$key');
|
debugPrint('resume task with key=$key');
|
||||||
|
@ -39,7 +43,7 @@ class ServicePolicy {
|
||||||
completer.completeError(error, stackTrace);
|
completer.completeError(error, stackTrace);
|
||||||
}
|
}
|
||||||
if (debugLabel != null) debugPrint('$runtimeType $debugLabel completed');
|
if (debugLabel != null) debugPrint('$runtimeType $debugLabel completed');
|
||||||
_running = null;
|
_runningQueue.removeWhere((task) => task.key == key);
|
||||||
_pickNext();
|
_pickNext();
|
||||||
},
|
},
|
||||||
completer,
|
completer,
|
||||||
|
@ -64,10 +68,13 @@ class ServicePolicy {
|
||||||
|
|
||||||
void _pickNext() {
|
void _pickNext() {
|
||||||
_notifyQueueState();
|
_notifyQueueState();
|
||||||
if (_running != null) return;
|
if (_runningQueue.length >= concurrentTaskMax) return;
|
||||||
final queue = _queues.entries.firstWhere((kv) => kv.value.isNotEmpty, orElse: () => null)?.value;
|
final queue = _queues.entries.firstWhere((kv) => kv.value.isNotEmpty, orElse: () => null)?.value;
|
||||||
_running = queue?.removeFirst();
|
final task = queue?.removeFirst();
|
||||||
_running?.callback?.call();
|
if (task != null) {
|
||||||
|
_runningQueue.addLast(task);
|
||||||
|
task.callback();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _takeOut(Object key, Iterable<int> priorities, void Function(int priority, _Task task) action) {
|
bool _takeOut(Object key, Iterable<int> priorities, void Function(int priority, _Task task) action) {
|
||||||
|
@ -99,7 +106,7 @@ class ServicePolicy {
|
||||||
if (!_queueStreamController.hasListener) return;
|
if (!_queueStreamController.hasListener) return;
|
||||||
|
|
||||||
final queueByPriority = Map.fromEntries(_queues.entries.map((kv) => MapEntry(kv.key, kv.value.length)));
|
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 {
|
class QueueState {
|
||||||
final Map<int, int> queueByPriority;
|
final Map<int, int> queueByPriority;
|
||||||
|
final int runningQueue;
|
||||||
|
|
||||||
const QueueState(this.queueByPriority);
|
const QueueState(this.queueByPriority, this.runningQueue);
|
||||||
}
|
}
|
||||||
|
|
86
lib/services/svg_metadata_service.dart
Normal file
86
lib/services/svg_metadata_service.dart
Normal file
|
@ -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<Size> 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<Map<String, Map<String, String>>> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -239,12 +239,6 @@ class Constants {
|
||||||
licenseUrl: 'https://github.com/Baseflow/flutter-permission-handler/blob/develop/permission_handler/LICENSE',
|
licenseUrl: 'https://github.com/Baseflow/flutter-permission-handler/blob/develop/permission_handler/LICENSE',
|
||||||
sourceUrl: 'https://github.com/Baseflow/flutter-permission-handler',
|
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(
|
Dependency(
|
||||||
name: 'Printing',
|
name: 'Printing',
|
||||||
license: 'Apache 2.0',
|
license: 'Apache 2.0',
|
||||||
|
|
|
@ -4,9 +4,9 @@ import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/services/viewer_service.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_known_extent.dart';
|
||||||
import 'package:aves/widgets/collection/grid/list_section_layout.dart';
|
import 'package:aves/widgets/collection/grid/list_section_layout.dart';
|
||||||
import 'package:aves/widgets/common/scaling.dart';
|
|
||||||
import 'package:aves/widgets/collection/thumbnail/decorated.dart';
|
import 'package:aves/widgets/collection/thumbnail/decorated.dart';
|
||||||
import 'package:aves/widgets/common/behaviour/routes.dart';
|
import 'package:aves/widgets/common/behaviour/routes.dart';
|
||||||
|
import 'package:aves/widgets/common/scaling.dart';
|
||||||
import 'package:aves/widgets/fullscreen/fullscreen_page.dart';
|
import 'package:aves/widgets/fullscreen/fullscreen_page.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
|
@ -43,7 +43,7 @@ class DecoratedThumbnail extends StatelessWidget {
|
||||||
);
|
);
|
||||||
|
|
||||||
child = Stack(
|
child = Stack(
|
||||||
fit: StackFit.passthrough,
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
child,
|
child,
|
||||||
Positioned(
|
Positioned(
|
||||||
|
|
|
@ -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/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/material.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
@ -19,15 +21,36 @@ class ThumbnailVectorImage extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final child = Container(
|
final child = Selector<Settings, EntryBackground>(
|
||||||
// center `SvgPicture` inside `Container` with the thumbnail dimensions
|
selector: (context, s) => s.vectorBackground,
|
||||||
// so that `SvgPicture` doesn't get aligned by the `Stack` like the overlay icons
|
builder: (context, background, child) {
|
||||||
width: extent,
|
const fit = BoxFit.contain;
|
||||||
height: extent,
|
if (background == EntryBackground.checkered) {
|
||||||
child: Selector<Settings, int>(
|
return LayoutBuilder(
|
||||||
selector: (context, s) => s.svgBackground,
|
builder: (context, constraints) {
|
||||||
builder: (context, svgBackground, child) {
|
final availableSize = constraints.biggest;
|
||||||
final colorFilter = ColorFilter.mode(Color(svgBackground), BlendMode.dstOver);
|
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(
|
return SvgPicture(
|
||||||
UriPicture(
|
UriPicture(
|
||||||
uri: entry.uri,
|
uri: entry.uri,
|
||||||
|
@ -36,9 +59,9 @@ class ThumbnailVectorImage extends StatelessWidget {
|
||||||
),
|
),
|
||||||
width: extent,
|
width: extent,
|
||||||
height: extent,
|
height: extent,
|
||||||
|
fit: fit,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
|
||||||
);
|
);
|
||||||
return heroTag == null
|
return heroTag == null
|
||||||
? child
|
? child
|
||||||
|
|
57
lib/widgets/common/fx/checkered_decoration.dart
Normal file
57
lib/widgets/common/fx/checkered_decoration.dart
Normal file
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,15 +6,15 @@ class HighlightDecoration extends Decoration {
|
||||||
const HighlightDecoration({@required this.color});
|
const HighlightDecoration({@required this.color});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
HighlightDecorationPainter createBoxPainter([VoidCallback onChanged]) {
|
_HighlightDecorationPainter createBoxPainter([VoidCallback onChanged]) {
|
||||||
return HighlightDecorationPainter(this, onChanged);
|
return _HighlightDecorationPainter(this, onChanged);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class HighlightDecorationPainter extends BoxPainter {
|
class _HighlightDecorationPainter extends BoxPainter {
|
||||||
final HighlightDecoration decoration;
|
final HighlightDecoration decoration;
|
||||||
|
|
||||||
const HighlightDecorationPainter(this.decoration, VoidCallback onChanged) : super(onChanged);
|
const _HighlightDecorationPainter(this.decoration, VoidCallback onChanged) : super(onChanged);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) {
|
void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) {
|
||||||
|
|
128
lib/widgets/common/magnifier/controller/controller.dart
Normal file
128
lib/widgets/common/magnifier/controller/controller.dart
Normal file
|
@ -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<MagnifierState> _stateStreamController = StreamController.broadcast();
|
||||||
|
final StreamController<ScaleBoundaries> _scaleBoundariesStreamController = StreamController.broadcast();
|
||||||
|
final StreamController<ScaleStateChange> _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<MagnifierState> get stateStream => _stateStreamController.stream;
|
||||||
|
|
||||||
|
Stream<ScaleBoundaries> get scaleBoundariesStream => _scaleBoundariesStreamController.stream;
|
||||||
|
|
||||||
|
Stream<ScaleStateChange> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
196
lib/widgets/common/magnifier/controller/controller_delegate.dart
Normal file
196
lib/widgets/common/magnifier/controller/controller_delegate.dart
Normal file
|
@ -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<MagnifierCore> {
|
||||||
|
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<StreamSubscription> _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;
|
||||||
|
}
|
28
lib/widgets/common/magnifier/controller/state.dart
Normal file
28
lib/widgets/common/magnifier/controller/state.dart
Normal file
|
@ -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 }
|
320
lib/widgets/common/magnifier/core/core.dart
Normal file
320
lib/widgets/common/magnifier/core/core.dart
Normal file
|
@ -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<StatefulWidget> createState() {
|
||||||
|
return MagnifierCoreState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MagnifierCoreState extends State<MagnifierCore> with TickerProviderStateMixin, MagnifierControllerDelegate, CornerHitDetector {
|
||||||
|
Offset _startFocalPoint, _lastViewportFocalPosition;
|
||||||
|
double _startScale, _quickScaleLastY, _quickScaleLastDistance;
|
||||||
|
bool _doubleTap, _quickScaleMoved;
|
||||||
|
DateTime _lastScaleGestureDate;
|
||||||
|
|
||||||
|
AnimationController _scaleAnimationController;
|
||||||
|
Animation<double> _scaleAnimation;
|
||||||
|
|
||||||
|
AnimationController _positionAnimationController;
|
||||||
|
Animation<Offset> _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<Offset>(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<Offset> 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<double>(
|
||||||
|
begin: from,
|
||||||
|
end: to,
|
||||||
|
).animate(_scaleAnimationController);
|
||||||
|
_scaleAnimationController
|
||||||
|
..value = 0.0
|
||||||
|
..fling(velocity: 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
void animatePosition(Offset from, Offset to) {
|
||||||
|
_positionAnimation = Tween<Offset>(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<MagnifierState>(
|
||||||
|
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);
|
||||||
|
}
|
94
lib/widgets/common/magnifier/core/gesture_detector.dart
Normal file
94
lib/widgets/common/magnifier/core/gesture_detector.dart
Normal file
|
@ -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<MagnifierGestureDetector> {
|
||||||
|
final ValueNotifier<TapDownDetails> doubleTapDetails = ValueNotifier(null);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scope = MagnifierGestureDetectorScope.of(context);
|
||||||
|
|
||||||
|
final axis = scope?.axis;
|
||||||
|
final touchSlopFactor = scope?.touchSlopFactor;
|
||||||
|
|
||||||
|
final gestures = <Type, GestureRecognizerFactory>{};
|
||||||
|
|
||||||
|
if (widget.onTapDown != null || widget.onTapUp != null) {
|
||||||
|
gestures[TapGestureRecognizer] = GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
|
||||||
|
() => TapGestureRecognizer(debugOwner: this),
|
||||||
|
(instance) {
|
||||||
|
instance
|
||||||
|
..onTapDown = widget.onTapDown
|
||||||
|
..onTapUp = widget.onTapUp;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
gestures[MagnifierGestureRecognizer] = GestureRecognizerFactoryWithHandlers<MagnifierGestureRecognizer>(
|
||||||
|
() => 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>(
|
||||||
|
() => 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
145
lib/widgets/common/magnifier/core/scale_gesture_recognizer.dart
Normal file
145
lib/widgets/common/magnifier/core/scale_gesture_recognizer.dart
Normal file
|
@ -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<Axis> validateAxis;
|
||||||
|
final double touchSlopFactor;
|
||||||
|
final ValueNotifier<TapDownDetails> doubleTapDetails;
|
||||||
|
|
||||||
|
MagnifierGestureRecognizer({
|
||||||
|
Object debugOwner,
|
||||||
|
PointerDeviceKind kind,
|
||||||
|
this.hitDetector,
|
||||||
|
this.validateAxis,
|
||||||
|
this.touchSlopFactor = 2,
|
||||||
|
this.doubleTapDetails,
|
||||||
|
}) : super(debugOwner: debugOwner, kind: kind);
|
||||||
|
|
||||||
|
Map<int, Offset> _pointerLocations = <int, Offset>{};
|
||||||
|
|
||||||
|
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 = <int, Offset>{};
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
140
lib/widgets/common/magnifier/magnifier.dart
Normal file
140
lib/widgets/common/magnifier/magnifier.dart
Normal file
|
@ -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<StatefulWidget> createState() {
|
||||||
|
return _MagnifierState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MagnifierState extends State<Magnifier> {
|
||||||
|
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,
|
||||||
|
);
|
76
lib/widgets/common/magnifier/pan/corner_hit_detector.dart
Normal file
76
lib/widgets/common/magnifier/pan/corner_hit_detector.dart
Normal file
|
@ -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;
|
||||||
|
}
|
33
lib/widgets/common/magnifier/pan/gesture_detector_scope.dart
Normal file
33
lib/widgets/common/magnifier/pan/gesture_detector_scope.dart
Normal file
|
@ -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<MagnifierGestureDetectorScope>();
|
||||||
|
return scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<Axis> 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;
|
||||||
|
}
|
||||||
|
}
|
29
lib/widgets/common/magnifier/pan/scroll_physics.dart
Normal file
29
lib/widgets/common/magnifier/pan/scroll_physics.dart
Normal file
|
@ -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;
|
||||||
|
}
|
67
lib/widgets/common/magnifier/scale/scale_boundaries.dart
Normal file
67
lib/widgets/common/magnifier/scale/scale_boundaries.dart
Normal file
|
@ -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}';
|
||||||
|
}
|
32
lib/widgets/common/magnifier/scale/scale_level.dart
Normal file
32
lib/widgets/common/magnifier/scale/scale_level.dart
Normal file
|
@ -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 }
|
53
lib/widgets/common/magnifier/scale/state.dart
Normal file
53
lib/widgets/common/magnifier/scale/state.dart
Normal file
|
@ -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);
|
|
@ -20,7 +20,12 @@ class DebugTaskQueueOverlay extends StatelessWidget {
|
||||||
stream: servicePolicy.queueStream,
|
stream: servicePolicy.queueStream,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.hasError) return SizedBox.shrink();
|
if (snapshot.hasError) return SizedBox.shrink();
|
||||||
final queuedEntries = (snapshot.hasData ? snapshot.data.queueByPriority.entries.toList() : []);
|
final queuedEntries = <MapEntry<dynamic, int>>[];
|
||||||
|
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));
|
queuedEntries.sort((a, b) => a.key.compareTo(b.key));
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
|
|
@ -177,9 +177,9 @@ class _AlbumFilterBarState extends State<AlbumFilterBar> {
|
||||||
),
|
),
|
||||||
ConstrainedBox(
|
ConstrainedBox(
|
||||||
constraints: BoxConstraints(minWidth: 16),
|
constraints: BoxConstraints(minWidth: 16),
|
||||||
child: AnimatedBuilder(
|
child: ValueListenableBuilder<TextEditingValue>(
|
||||||
animation: _controller,
|
valueListenable: _controller,
|
||||||
builder: (context, child) => AnimatedSwitcher(
|
builder: (context, value, child) => AnimatedSwitcher(
|
||||||
duration: Durations.appBarActionChangeAnimation,
|
duration: Durations.appBarActionChangeAnimation,
|
||||||
transitionBuilder: (child, animation) => FadeTransition(
|
transitionBuilder: (child, animation) => FadeTransition(
|
||||||
opacity: animation,
|
opacity: animation,
|
||||||
|
@ -189,7 +189,7 @@ class _AlbumFilterBarState extends State<AlbumFilterBar> {
|
||||||
child: child,
|
child: child,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: _controller.text.isNotEmpty ? clearButton : SizedBox.shrink(),
|
child: value.text.isNotEmpty ? clearButton : SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
@ -15,8 +15,6 @@ import 'package:aves/widgets/fullscreen/source_viewer_page.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:flutter/services.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:pdf/widgets.dart' as pdf;
|
||||||
import 'package:pedantic/pedantic.dart';
|
import 'package:pedantic/pedantic.dart';
|
||||||
import 'package:printing/printing.dart';
|
import 'package:printing/printing.dart';
|
||||||
|
@ -100,34 +98,32 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
||||||
final documentName = entry.bestTitle ?? 'Aves';
|
final documentName = entry.bestTitle ?? 'Aves';
|
||||||
final doc = pdf.Document(title: documentName);
|
final doc = pdf.Document(title: documentName);
|
||||||
|
|
||||||
PdfImage pdfImage;
|
pdf.Widget pdfChild;
|
||||||
if (entry.isSvg) {
|
if (entry.isSvg) {
|
||||||
final bytes = await ImageFileService.getImage(uri, mimeType, entry.rotationDegrees, entry.isFlipped);
|
final bytes = await ImageFileService.getImage(uri, mimeType, entry.rotationDegrees, entry.isFlipped);
|
||||||
if (bytes != null && bytes.isNotEmpty) {
|
if (bytes != null && bytes.isNotEmpty) {
|
||||||
final svgRoot = await svg.fromSvgBytes(bytes, uri);
|
pdfChild = pdf.SvgImage(svg: utf8.decode(bytes));
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
pdfImage = await pdfImageFromImageProvider(
|
pdfChild = pdf.Image.provider(await flutterImageProvider(
|
||||||
pdf: doc.document,
|
UriImage(
|
||||||
image: UriImage(
|
|
||||||
uri: uri,
|
uri: uri,
|
||||||
mimeType: mimeType,
|
mimeType: mimeType,
|
||||||
rotationDegrees: rotationDegrees,
|
rotationDegrees: rotationDegrees,
|
||||||
isFlipped: isFlipped,
|
isFlipped: isFlipped,
|
||||||
),
|
),
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
if (pdfImage != null) {
|
if (pdfChild != null) {
|
||||||
doc.addPage(pdf.Page(build: (context) => pdf.Center(child: pdf.Image(pdfImage)))); // Page
|
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(
|
unawaited(Printing.layoutPdf(
|
||||||
onLayout: (format) => doc.save(),
|
onLayout: (format) => doc.save(),
|
||||||
name: documentName,
|
name: documentName,
|
||||||
|
|
|
@ -8,6 +8,7 @@ import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/theme/durations.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
import 'package:aves/utils/change_notifier.dart';
|
import 'package:aves/utils/change_notifier.dart';
|
||||||
import 'package:aves/widgets/collection/collection_page.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/entry_action_delegate.dart';
|
||||||
import 'package:aves/widgets/fullscreen/image_page.dart';
|
import 'package:aves/widgets/fullscreen/image_page.dart';
|
||||||
import 'package:aves/widgets/fullscreen/image_view.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/scheduler.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
|
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
|
||||||
import 'package:photo_view/photo_view.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:screen/screen.dart';
|
import 'package:screen/screen.dart';
|
||||||
import 'package:tuple/tuple.dart';
|
import 'package:tuple/tuple.dart';
|
||||||
|
@ -557,7 +557,7 @@ class _FullscreenVerticalPageViewState extends State<FullscreenVerticalPageView>
|
||||||
key: Key('vertical-pageview'),
|
key: Key('vertical-pageview'),
|
||||||
scrollDirection: Axis.vertical,
|
scrollDirection: Axis.vertical,
|
||||||
controller: widget.verticalPager,
|
controller: widget.verticalPager,
|
||||||
physics: PhotoViewPageViewScrollPhysics(parent: PageScrollPhysics()),
|
physics: MagnifierScrollerPhysics(parent: PageScrollPhysics()),
|
||||||
onPageChanged: (page) {
|
onPageChanged: (page) {
|
||||||
widget.onVerticalPageChanged(page);
|
widget.onVerticalPageChanged(page);
|
||||||
_infoPageVisibleNotifier.value = page == pages.length - 1;
|
_infoPageVisibleNotifier.value = page == pages.length - 1;
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/image_entry.dart';
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
|
import 'package:aves/widgets/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:aves/widgets/fullscreen/image_view.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
|
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
|
||||||
import 'package:photo_view/photo_view.dart';
|
|
||||||
import 'package:tuple/tuple.dart';
|
import 'package:tuple/tuple.dart';
|
||||||
|
|
||||||
class MultiImagePage extends StatefulWidget {
|
class MultiImagePage extends StatefulWidget {
|
||||||
|
@ -34,13 +35,13 @@ class MultiImagePageState extends State<MultiImagePage> with AutomaticKeepAliveC
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context);
|
super.build(context);
|
||||||
|
|
||||||
return PhotoViewGestureDetectorScope(
|
return MagnifierGestureDetectorScope(
|
||||||
axis: [Axis.horizontal, Axis.vertical],
|
axis: [Axis.horizontal, Axis.vertical],
|
||||||
child: PageView.builder(
|
child: PageView.builder(
|
||||||
key: Key('horizontal-pageview'),
|
key: Key('horizontal-pageview'),
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
controller: widget.pageController,
|
controller: widget.pageController,
|
||||||
physics: PhotoViewPageViewScrollPhysics(parent: BouncingScrollPhysics()),
|
physics: MagnifierScrollerPhysics(parent: BouncingScrollPhysics()),
|
||||||
onPageChanged: widget.onPageChanged,
|
onPageChanged: widget.onPageChanged,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final entry = entries[index];
|
final entry = entries[index];
|
||||||
|
@ -49,7 +50,7 @@ class MultiImagePageState extends State<MultiImagePage> with AutomaticKeepAliveC
|
||||||
key: Key('imageview'),
|
key: Key('imageview'),
|
||||||
entry: entry,
|
entry: entry,
|
||||||
heroTag: widget.collection.heroTag(entry),
|
heroTag: widget.collection.heroTag(entry),
|
||||||
onTap: widget.onTap,
|
onTap: (_) => widget.onTap?.call(),
|
||||||
videoControllers: widget.videoControllers,
|
videoControllers: widget.videoControllers,
|
||||||
onDisposed: () => widget.onViewDisposed?.call(entry.uri),
|
onDisposed: () => widget.onViewDisposed?.call(entry.uri),
|
||||||
),
|
),
|
||||||
|
@ -84,11 +85,11 @@ class SingleImagePageState extends State<SingleImagePage> with AutomaticKeepAliv
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context);
|
super.build(context);
|
||||||
|
|
||||||
return PhotoViewGestureDetectorScope(
|
return MagnifierGestureDetectorScope(
|
||||||
axis: [Axis.vertical],
|
axis: [Axis.vertical],
|
||||||
child: ImageView(
|
child: ImageView(
|
||||||
entry: widget.entry,
|
entry: widget.entry,
|
||||||
onTap: widget.onTap,
|
onTap: (_) => widget.onTap?.call(),
|
||||||
videoControllers: widget.videoControllers,
|
videoControllers: widget.videoControllers,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,29 +1,35 @@
|
||||||
import 'dart:async';
|
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/image_providers/uri_picture_provider.dart';
|
||||||
import 'package:aves/model/image_entry.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/model/settings/settings.dart';
|
||||||
import 'package:aves/theme/icons.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
import 'package:aves/widgets/collection/empty.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/tiled_view.dart';
|
||||||
import 'package:aves/widgets/fullscreen/video_view.dart';
|
import 'package:aves/widgets/fullscreen/video_view.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
|
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.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';
|
import 'package:tuple/tuple.dart';
|
||||||
|
|
||||||
class ImageView extends StatefulWidget {
|
class ImageView extends StatefulWidget {
|
||||||
final ImageEntry entry;
|
final ImageEntry entry;
|
||||||
final Object heroTag;
|
final Object heroTag;
|
||||||
final VoidCallback onTap;
|
final MagnifierTapCallback onTap;
|
||||||
final List<Tuple2<String, IjkMediaController>> videoControllers;
|
final List<Tuple2<String, IjkMediaController>> videoControllers;
|
||||||
final VoidCallback onDisposed;
|
final VoidCallback onDisposed;
|
||||||
|
|
||||||
|
static const decorationCheckSize = 20.0;
|
||||||
|
|
||||||
const ImageView({
|
const ImageView({
|
||||||
Key key,
|
Key key,
|
||||||
@required this.entry,
|
@required this.entry,
|
||||||
|
@ -38,32 +44,30 @@ class ImageView extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ImageViewState extends State<ImageView> {
|
class _ImageViewState extends State<ImageView> {
|
||||||
final PhotoViewController _photoViewController = PhotoViewController();
|
final MagnifierController _magnifierController = MagnifierController();
|
||||||
final PhotoViewScaleStateController _photoViewScaleStateController = PhotoViewScaleStateController();
|
|
||||||
final ValueNotifier<ViewState> _viewStateNotifier = ValueNotifier(ViewState.zero);
|
final ValueNotifier<ViewState> _viewStateNotifier = ValueNotifier(ViewState.zero);
|
||||||
StreamSubscription<PhotoViewControllerValue> _subscription;
|
final List<StreamSubscription> _subscriptions = [];
|
||||||
Size _photoViewChildSize;
|
|
||||||
|
|
||||||
static const backgroundDecoration = BoxDecoration(color: Colors.transparent);
|
static const initialScale = ScaleLevel(ref: ScaleReference.contained);
|
||||||
static const maxScale = 2.0;
|
static const minScale = ScaleLevel(ref: ScaleReference.contained);
|
||||||
|
static const maxScale = ScaleLevel(factor: 2.0);
|
||||||
|
|
||||||
ImageEntry get entry => widget.entry;
|
ImageEntry get entry => widget.entry;
|
||||||
|
|
||||||
VoidCallback get onTap => widget.onTap;
|
MagnifierTapCallback get onTap => widget.onTap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_subscription = _photoViewController.outputStateStream.listen(_onViewChanged);
|
_subscriptions.add(_magnifierController.stateStream.listen(_onViewStateChanged));
|
||||||
if (entry.isVideo || (!entry.isSvg && entry.canDecode && useTile)) {
|
_subscriptions.add(_magnifierController.scaleBoundariesStream.listen(_onViewScaleBoundariesChanged));
|
||||||
_photoViewChildSize = entry.displaySize;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_subscription.cancel();
|
_subscriptions
|
||||||
_subscription = null;
|
..forEach((sub) => sub.cancel())
|
||||||
|
..clear();
|
||||||
widget.onDisposed?.call();
|
widget.onDisposed?.call();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
@ -78,19 +82,9 @@ class _ImageViewState extends State<ImageView> {
|
||||||
} else if (entry.isSvg) {
|
} else if (entry.isSvg) {
|
||||||
child = _buildSvgView();
|
child = _buildSvgView();
|
||||||
} else if (entry.canDecode) {
|
} else if (entry.canDecode) {
|
||||||
if (useTile) {
|
child = _buildRasterView();
|
||||||
child = _buildTiledImageView();
|
|
||||||
} else {
|
|
||||||
child = _buildImageView();
|
|
||||||
}
|
}
|
||||||
}
|
child ??= ErrorChild(onTap: () => onTap?.call(null));
|
||||||
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.
|
|
||||||
|
|
||||||
// no hero for videos, as a typical video first frame is different from its thumbnail
|
// no hero for videos, as a typical video first frame is different from its thumbnail
|
||||||
return widget.heroTag != null && !entry.isVideo
|
return widget.heroTag != null && !entry.isVideo
|
||||||
|
@ -102,103 +96,30 @@ class _ImageViewState extends State<ImageView> {
|
||||||
: child;
|
: child;
|
||||||
}
|
}
|
||||||
|
|
||||||
// the images loaded by `PhotoView` cannot have a width or height larger than 8192
|
Widget _buildRasterView() {
|
||||||
// so the reported offset and scale does not match expected values derived from the original dimensions
|
return Magnifier(
|
||||||
// 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(
|
|
||||||
// key includes size and orientation to refresh when the image is rotated
|
// key includes size and orientation to refresh when the image is rotated
|
||||||
key: ValueKey('${entry.rotationDegrees}_${entry.isFlipped}_${entry.width}_${entry.height}_${entry.path}'),
|
key: ValueKey('${entry.rotationDegrees}_${entry.isFlipped}_${entry.width}_${entry.height}_${entry.path}'),
|
||||||
imageProvider: uriImage,
|
child: TiledImageView(
|
||||||
// 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<MediaQueryData, Size>(
|
|
||||||
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,
|
entry: entry,
|
||||||
viewportSize: mqSize,
|
|
||||||
viewStateNotifier: _viewStateNotifier,
|
viewStateNotifier: _viewStateNotifier,
|
||||||
baseChild: _loadingBuilder(context, fastThumbnailProvider),
|
errorBuilder: (context, error, stackTrace) => ErrorChild(onTap: () => onTap?.call(null)),
|
||||||
errorBuilder: (context, error, stackTrace) => _buildError(),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
childSize: entry.displaySize,
|
childSize: entry.displaySize,
|
||||||
backgroundDecoration: backgroundDecoration,
|
controller: _magnifierController,
|
||||||
controller: _photoViewController,
|
|
||||||
scaleStateController: _photoViewScaleStateController,
|
|
||||||
maxScale: maxScale,
|
maxScale: maxScale,
|
||||||
minScale: PhotoViewComputedScale.contained,
|
minScale: minScale,
|
||||||
initialScale: PhotoViewComputedScale.contained,
|
initialScale: initialScale,
|
||||||
onTapUp: (tapContext, details, value) => onTap?.call(),
|
onTap: (c, d, s, childPosition) => onTap?.call(childPosition),
|
||||||
filterQuality: FilterQuality.low,
|
applyScale: false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSvgView() {
|
Widget _buildSvgView() {
|
||||||
final colorFilter = ColorFilter.mode(Color(settings.svgBackground), BlendMode.dstOver);
|
final background = settings.vectorBackground;
|
||||||
return PhotoView.customChild(
|
final colorFilter = background.isColor ? ColorFilter.mode(background.color, BlendMode.dstOver) : null;
|
||||||
|
|
||||||
|
Widget child = Magnifier(
|
||||||
child: SvgPicture(
|
child: SvgPicture(
|
||||||
UriPicture(
|
UriPicture(
|
||||||
uri: entry.uri,
|
uri: entry.uri,
|
||||||
|
@ -206,17 +127,54 @@ class _ImageViewState extends State<ImageView> {
|
||||||
colorFilter: colorFilter,
|
colorFilter: colorFilter,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
backgroundDecoration: backgroundDecoration,
|
childSize: entry.displaySize,
|
||||||
controller: _photoViewController,
|
controller: _magnifierController,
|
||||||
minScale: PhotoViewComputedScale.contained,
|
minScale: minScale,
|
||||||
initialScale: PhotoViewComputedScale.contained,
|
initialScale: initialScale,
|
||||||
onTapUp: (tapContext, details, value) => onTap?.call(),
|
scaleStateCycle: _vectorScaleStateCycle,
|
||||||
|
onTap: (c, d, s, childPosition) => onTap?.call(childPosition),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (background == EntryBackground.checkered) {
|
||||||
|
child = ValueListenableBuilder<ViewState>(
|
||||||
|
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() {
|
Widget _buildVideoView() {
|
||||||
final videoController = widget.videoControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2;
|
final videoController = widget.videoControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2;
|
||||||
return PhotoView.customChild(
|
return Magnifier(
|
||||||
child: videoController != null
|
child: videoController != null
|
||||||
? AvesVideo(
|
? AvesVideo(
|
||||||
entry: entry,
|
entry: entry,
|
||||||
|
@ -224,16 +182,69 @@ class _ImageViewState extends State<ImageView> {
|
||||||
)
|
)
|
||||||
: SizedBox(),
|
: SizedBox(),
|
||||||
childSize: entry.displaySize,
|
childSize: entry.displaySize,
|
||||||
backgroundDecoration: backgroundDecoration,
|
controller: _magnifierController,
|
||||||
controller: _photoViewController,
|
|
||||||
maxScale: maxScale,
|
maxScale: maxScale,
|
||||||
minScale: PhotoViewComputedScale.contained,
|
minScale: minScale,
|
||||||
initialScale: PhotoViewComputedScale.contained,
|
initialScale: initialScale,
|
||||||
onTapUp: (tapContext, details, value) => onTap?.call(),
|
onTap: (c, d, s, childPosition) => onTap?.call(childPosition),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildError() => GestureDetector(
|
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 viewportSize;
|
||||||
|
|
||||||
|
static const ViewState zero = ViewState(Offset.zero, 0, null);
|
||||||
|
|
||||||
|
const ViewState(this.position, this.scale, this.viewportSize);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => '$runtimeType#${shortHash(this)}{position=$position, scale=$scale, viewportSize=$viewportSize}';
|
||||||
|
}
|
||||||
|
|
||||||
|
class ViewStateNotification extends Notification {
|
||||||
|
final String uri;
|
||||||
|
final ViewState viewState;
|
||||||
|
|
||||||
|
const ViewStateNotification(this.uri, this.viewState);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, viewState=$viewState}';
|
||||||
|
}
|
||||||
|
|
||||||
|
class ErrorChild extends StatelessWidget {
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const ErrorChild({@required this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
onTap: () => onTap?.call(),
|
onTap: () => onTap?.call(),
|
||||||
// use a `Container` with a dummy color to make it expand
|
// use a `Container` with a dummy color to make it expand
|
||||||
// so that we can also detect taps around the title `Text`
|
// so that we can also detect taps around the title `Text`
|
||||||
|
@ -246,37 +257,7 @@ class _ImageViewState extends State<ImageView> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
void _onViewChanged(PhotoViewControllerValue v) {
|
|
||||||
final viewState = ViewState(v.position, v.scale, _photoViewChildSize);
|
|
||||||
_viewStateNotifier.value = viewState;
|
|
||||||
ViewStateNotification(entry.uri, viewState).dispatch(context);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ViewState {
|
typedef MagnifierTapCallback = void Function(Offset childPosition);
|
||||||
final Offset position;
|
|
||||||
final double scale;
|
|
||||||
final Size size;
|
|
||||||
|
|
||||||
static const ViewState zero = ViewState(Offset(0.0, 0.0), 0, null);
|
|
||||||
|
|
||||||
const ViewState(this.position, this.scale, this.size);
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() {
|
|
||||||
return '$runtimeType#${shortHash(this)}{position=$position, scale=$scale, size=$size}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ViewStateNotification extends Notification {
|
|
||||||
final String uri;
|
|
||||||
final ViewState viewState;
|
|
||||||
|
|
||||||
const ViewStateNotification(this.uri, this.viewState);
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() {
|
|
||||||
return '$runtimeType#${shortHash(this)}{uri=$uri, viewState=$viewState}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -3,6 +3,7 @@ import 'dart:collection';
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/image_entry.dart';
|
||||||
import 'package:aves/ref/brand_colors.dart';
|
import 'package:aves/ref/brand_colors.dart';
|
||||||
import 'package:aves/services/metadata_service.dart';
|
import 'package:aves/services/metadata_service.dart';
|
||||||
|
import 'package:aves/services/svg_metadata_service.dart';
|
||||||
import 'package:aves/theme/durations.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
import 'package:aves/theme/icons.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
import 'package:aves/utils/color_utils.dart';
|
import 'package:aves/utils/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/common/identity/aves_expansion_tile.dart';
|
||||||
import 'package:aves/widgets/fullscreen/info/common.dart';
|
import 'package:aves/widgets/fullscreen/info/common.dart';
|
||||||
import 'package:aves/widgets/fullscreen/info/metadata/metadata_thumbnail.dart';
|
import 'package:aves/widgets/fullscreen/info/metadata/metadata_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/info/metadata/xmp_tile.dart';
|
||||||
|
import 'package:aves/widgets/fullscreen/source_viewer_page.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
@ -91,9 +92,9 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
||||||
// cancel notification bubbling so that the info page
|
// cancel notification bubbling so that the info page
|
||||||
// does not misinterpret content scrolling for page scrolling
|
// does not misinterpret content scrolling for page scrolling
|
||||||
onNotification: (notification) => true,
|
onNotification: (notification) => true,
|
||||||
child: AnimatedBuilder(
|
child: ValueListenableBuilder<String>(
|
||||||
animation: _loadedMetadataUri,
|
valueListenable: _loadedMetadataUri,
|
||||||
builder: (context, child) {
|
builder: (context, uri, child) {
|
||||||
Widget content;
|
Widget content;
|
||||||
if (_metadata.isEmpty) {
|
if (_metadata.isEmpty) {
|
||||||
content = SizedBox.shrink();
|
content = SizedBox.shrink();
|
||||||
|
@ -118,7 +119,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
||||||
return AnimationLimiter(
|
return AnimationLimiter(
|
||||||
// we update the limiter key after fetching the metadata of a new entry,
|
// we update the limiter key after fetching the metadata of a new entry,
|
||||||
// in order to restart the staggered animation of the metadata section
|
// in order to restart the staggered animation of the metadata section
|
||||||
key: Key(_loadedMetadataUri.value),
|
key: Key(uri),
|
||||||
child: content,
|
child: content,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
@ -175,7 +176,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
||||||
child: InfoRowGroup(
|
child: InfoRowGroup(
|
||||||
dir.tags,
|
dir.tags,
|
||||||
maxValueLength: Constants.infoGroupMaxValueLength,
|
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<MetadataSectionSliver> with Auto
|
||||||
if (entry == null) return;
|
if (entry == null) return;
|
||||||
if (_loadedMetadataUri.value == entry.uri) return;
|
if (_loadedMetadataUri.value == entry.uri) return;
|
||||||
if (isVisible) {
|
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) {
|
final directories = rawMetadata.entries.map((dirKV) {
|
||||||
var directoryName = dirKV.key as String ?? '';
|
var directoryName = dirKV.key as String ?? '';
|
||||||
|
|
||||||
|
@ -230,6 +231,25 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
||||||
_expandedDirectoryNotifier.value = null;
|
_expandedDirectoryNotifier.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Map<String, InfoLinkHandler> getSvgLinkHandlers(SplayTreeMap<String, String> tags) {
|
||||||
|
return {
|
||||||
|
'Metadata': InfoLinkHandler(
|
||||||
|
linkText: 'View XML',
|
||||||
|
onTap: (context) {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
settings: RouteSettings(name: SourceViewerPage.routeName),
|
||||||
|
builder: (context) => SourceViewerPage(
|
||||||
|
loader: () => SynchronousFuture(tags['Metadata']),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get wantKeepAlive => true;
|
bool get wantKeepAlive => true;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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<Map<String, Map<String, String>>> 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<String, InfoLinkHandler> getLinkHandlers(SplayTreeMap<String, String> tags) {
|
|
||||||
return {
|
|
||||||
'Metadata': InfoLinkHandler(
|
|
||||||
linkText: 'View XML',
|
|
||||||
onTap: (context) {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
settings: RouteSettings(name: SourceViewerPage.routeName),
|
|
||||||
builder: (context) => SourceViewerPage(
|
|
||||||
loader: () => SynchronousFuture(tags['Metadata']),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
static String _formatKey(String key) {
|
|
||||||
switch (key) {
|
|
||||||
case 'desc':
|
|
||||||
return 'Description';
|
|
||||||
default:
|
|
||||||
return key.toSentenceCase();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -95,9 +95,7 @@ class XmpNamespace {
|
||||||
int get hashCode => namespace.hashCode;
|
int get hashCode => namespace.hashCode;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() => '$runtimeType#${shortHash(this)}{namespace=$namespace}';
|
||||||
return '$runtimeType#${shortHash(this)}{namespace=$namespace}';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class XmpProp {
|
class XmpProp {
|
||||||
|
@ -116,9 +114,7 @@ class XmpProp {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() => '$runtimeType#${shortHash(this)}{path=$path, value=$value}';
|
||||||
return '$runtimeType#${shortHash(this)}{path=$path, value=$value}';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class OpenEmbeddedDataNotification extends Notification {
|
class OpenEmbeddedDataNotification extends Notification {
|
||||||
|
@ -131,7 +127,5 @@ class OpenEmbeddedDataNotification extends Notification {
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() => '$runtimeType#${shortHash(this)}{propPath=$propPath, mimeType=$mimeType}';
|
||||||
return '$runtimeType#${shortHash(this)}{propPath=$propPath, mimeType=$mimeType}';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -278,7 +278,7 @@ class _DateRow extends StatelessWidget {
|
||||||
DecoratedIcon(AIcons.date, shadows: [Constants.embossShadow], size: _iconSize),
|
DecoratedIcon(AIcons.date, shadows: [Constants.embossShadow], size: _iconSize),
|
||||||
SizedBox(width: _iconPadding),
|
SizedBox(width: _iconPadding),
|
||||||
Expanded(flex: 3, child: Text(dateText, strutStyle: Constants.overflowStrutStyle)),
|
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)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,6 @@ import 'package:aves/model/image_entry.dart';
|
||||||
import 'package:aves/widgets/fullscreen/image_view.dart';
|
import 'package:aves/widgets/fullscreen/image_view.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
|
|
||||||
class Minimap extends StatelessWidget {
|
class Minimap extends StatelessWidget {
|
||||||
final ImageEntry entry;
|
final ImageEntry entry;
|
||||||
|
@ -21,25 +20,24 @@ class Minimap extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Selector<MediaQueryData, Size>(
|
return IgnorePointer(
|
||||||
selector: (context, mq) => mq.size,
|
child: ValueListenableBuilder<ViewState>(
|
||||||
builder: (context, mqSize, child) {
|
valueListenable: viewStateNotifier,
|
||||||
return AnimatedBuilder(
|
builder: (context, viewState, child) {
|
||||||
animation: viewStateNotifier,
|
final viewportSize = viewState.viewportSize;
|
||||||
builder: (context, child) {
|
if (viewportSize == null) return SizedBox.shrink();
|
||||||
final viewState = viewStateNotifier.value;
|
|
||||||
return CustomPaint(
|
return CustomPaint(
|
||||||
painter: MinimapPainter(
|
painter: MinimapPainter(
|
||||||
viewportSize: mqSize,
|
viewportSize: viewportSize,
|
||||||
entrySize: viewState.size ?? entry.displaySize,
|
entrySize: entry.displaySize,
|
||||||
viewCenterOffset: viewState.position,
|
viewCenterOffset: viewState.position,
|
||||||
viewScale: viewState.scale,
|
viewScale: viewState.scale,
|
||||||
minimapBorderColor: Colors.white30,
|
minimapBorderColor: Colors.white30,
|
||||||
),
|
),
|
||||||
size: size,
|
size: size,
|
||||||
);
|
);
|
||||||
});
|
}),
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,24 +1,26 @@
|
||||||
import 'dart:math';
|
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/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:aves/widgets/fullscreen/image_view.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:tuple/tuple.dart';
|
||||||
|
|
||||||
class TiledImageView extends StatefulWidget {
|
class TiledImageView extends StatefulWidget {
|
||||||
final ImageEntry entry;
|
final ImageEntry entry;
|
||||||
final Size viewportSize;
|
|
||||||
final ValueNotifier<ViewState> viewStateNotifier;
|
final ValueNotifier<ViewState> viewStateNotifier;
|
||||||
final Widget baseChild;
|
|
||||||
final ImageErrorWidgetBuilder errorBuilder;
|
final ImageErrorWidgetBuilder errorBuilder;
|
||||||
|
|
||||||
const TiledImageView({
|
const TiledImageView({
|
||||||
@required this.entry,
|
@required this.entry,
|
||||||
@required this.viewportSize,
|
|
||||||
@required this.viewStateNotifier,
|
@required this.viewStateNotifier,
|
||||||
@required this.baseChild,
|
|
||||||
@required this.errorBuilder,
|
@required this.errorBuilder,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -27,86 +29,274 @@ class TiledImageView extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TiledImageViewState extends State<TiledImageView> {
|
class _TiledImageViewState extends State<TiledImageView> {
|
||||||
double _tileSide, _initialScale;
|
bool _isTilingInitialized = false;
|
||||||
int _maxSampleSize;
|
int _maxSampleSize;
|
||||||
Matrix4 _transform;
|
double _tileSide;
|
||||||
|
Matrix4 _tileTransform;
|
||||||
|
ImageStream _fullImageStream;
|
||||||
|
ImageStreamListener _fullImageListener;
|
||||||
|
final ValueNotifier<bool> _fullImageLoaded = ValueNotifier(false);
|
||||||
|
|
||||||
ImageEntry get entry => widget.entry;
|
ImageEntry get entry => widget.entry;
|
||||||
|
|
||||||
Size get viewportSize => widget.viewportSize;
|
|
||||||
|
|
||||||
ValueNotifier<ViewState> get viewStateNotifier => widget.viewStateNotifier;
|
ValueNotifier<ViewState> 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
|
// magic number used to derive sample size from scale
|
||||||
static const scaleFactor = 2.0;
|
static const scaleFactor = 2.0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_init();
|
_fullImageListener = ImageStreamListener(_onFullImageCompleted);
|
||||||
|
if (!useTiles) _registerFullImage();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(TiledImageView oldWidget) {
|
void didUpdateWidget(TiledImageView oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
|
|
||||||
if (oldWidget.viewportSize != widget.viewportSize || oldWidget.entry.displaySize != widget.entry.displaySize) {
|
final oldViewState = oldWidget.viewStateNotifier.value;
|
||||||
_init();
|
final viewState = widget.viewStateNotifier.value;
|
||||||
|
if (oldWidget.entry != widget.entry || oldViewState.viewportSize != viewState.viewportSize) {
|
||||||
|
_isTilingInitialized = false;
|
||||||
|
_fullImageLoaded.value = false;
|
||||||
|
_unregisterFullImage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _init() {
|
@override
|
||||||
_tileSide = viewportSize.shortestSide * scaleFactor;
|
void dispose() {
|
||||||
_initialScale = min(viewportSize.width / entry.displaySize.width, viewportSize.height / entry.displaySize.height);
|
_unregisterFullImage();
|
||||||
_maxSampleSize = _sampleSizeForScale(_initialScale);
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (viewStateNotifier == null) return SizedBox.shrink();
|
if (viewStateNotifier == null) return SizedBox.shrink();
|
||||||
|
|
||||||
final displayWidth = entry.displaySize.width.round();
|
return ValueListenableBuilder<ViewState>(
|
||||||
final displayHeight = entry.displaySize.height.round();
|
valueListenable: viewStateNotifier,
|
||||||
|
builder: (context, viewState, child) {
|
||||||
|
final viewportSize = viewState.viewportSize;
|
||||||
|
final viewportSized = viewportSize != null;
|
||||||
|
if (viewportSized && useTiles && !_isTilingInitialized) _initTiling(viewportSize);
|
||||||
|
|
||||||
return AnimatedBuilder(
|
return SizedBox.fromSize(
|
||||||
animation: viewStateNotifier,
|
size: entry.displaySize * viewState.scale,
|
||||||
builder: (context, child) {
|
child: Stack(
|
||||||
final viewState = viewStateNotifier.value;
|
alignment: Alignment.center,
|
||||||
var scale = viewState.scale;
|
children: [
|
||||||
if (scale == 0.0) {
|
if (useBackground && viewportSized) _buildBackground(viewState),
|
||||||
// for initial scale as `PhotoViewComputedScale.contained`
|
_buildLoading(viewState),
|
||||||
scale = _initialScale;
|
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final centerOffset = viewState.position;
|
void _initTiling(Size viewportSize) {
|
||||||
final viewOrigin = Offset(
|
final displaySize = entry.displaySize;
|
||||||
((displayWidth * scale - viewportSize.width) / 2 - centerOffset.dx),
|
_tileSide = viewportSize.shortestSide * scaleFactor;
|
||||||
((displayHeight * scale - viewportSize.height) / 2 - centerOffset.dy),
|
// 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
final viewRect = viewOrigin & viewportSize;
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Widget> _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 = <RegionTile>[];
|
final tiles = <RegionTile>[];
|
||||||
var minSampleSize = min(_sampleSizeForScale(scale), _maxSampleSize);
|
var minSampleSize = min(_sampleSizeForScale(scale), _maxSampleSize);
|
||||||
for (var sampleSize = _maxSampleSize; sampleSize >= minSampleSize; sampleSize = (sampleSize / 2).floor()) {
|
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
|
// 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
|
// so we subsample the whole image without tiling
|
||||||
final useTiles = sampleSize != _maxSampleSize;
|
final fullImageRegion = sampleSize == _maxSampleSize;
|
||||||
final regionSide = (_tileSide * sampleSize).round();
|
final regionSide = (_tileSide * sampleSize).round();
|
||||||
final layerRegionWidth = useTiles ? regionSide : displayWidth;
|
final layerRegionWidth = fullImageRegion ? displayWidth : regionSide;
|
||||||
final layerRegionHeight = useTiles ? regionSide : displayHeight;
|
final layerRegionHeight = fullImageRegion ? displayHeight : regionSide;
|
||||||
for (var x = 0; x < displayWidth; x += layerRegionWidth) {
|
for (var x = 0; x < displayWidth; x += layerRegionWidth) {
|
||||||
for (var y = 0; y < displayHeight; y += layerRegionHeight) {
|
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<Rect, Rectangle<int>> _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 nextX = x + layerRegionWidth;
|
||||||
final nextY = y + layerRegionHeight;
|
final nextY = y + layerRegionHeight;
|
||||||
final thisRegionWidth = layerRegionWidth - (nextX >= displayWidth ? nextX - displayWidth : 0);
|
final thisRegionWidth = layerRegionWidth - (nextX >= displayWidth ? nextX - displayWidth : 0);
|
||||||
|
@ -114,14 +304,14 @@ class _TiledImageViewState extends State<TiledImageView> {
|
||||||
final tileRect = Rect.fromLTWH(x * scale, y * scale, thisRegionWidth * scale, thisRegionHeight * scale);
|
final tileRect = Rect.fromLTWH(x * scale, y * scale, thisRegionWidth * scale, thisRegionHeight * scale);
|
||||||
|
|
||||||
// only build visible tiles
|
// only build visible tiles
|
||||||
if (viewRect.overlaps(tileRect)) {
|
if (!viewRect.overlaps(tileRect)) return null;
|
||||||
Rectangle<int> regionRect;
|
|
||||||
|
|
||||||
if (_transform != null) {
|
Rectangle<int> regionRect;
|
||||||
|
if (_tileTransform != null) {
|
||||||
// apply EXIF orientation
|
// apply EXIF orientation
|
||||||
final regionRectDouble = Rect.fromLTWH(x.toDouble(), y.toDouble(), thisRegionWidth.toDouble(), thisRegionHeight.toDouble());
|
final regionRectDouble = Rect.fromLTWH(x.toDouble(), y.toDouble(), thisRegionWidth.toDouble(), thisRegionHeight.toDouble());
|
||||||
final tl = MatrixUtils.transformPoint(_transform, regionRectDouble.topLeft);
|
final tl = MatrixUtils.transformPoint(_tileTransform, regionRectDouble.topLeft);
|
||||||
final br = MatrixUtils.transformPoint(_transform, regionRectDouble.bottomRight);
|
final br = MatrixUtils.transformPoint(_tileTransform, regionRectDouble.bottomRight);
|
||||||
regionRect = Rectangle<int>.fromPoints(
|
regionRect = Rectangle<int>.fromPoints(
|
||||||
Point<int>(tl.dx.round(), tl.dy.round()),
|
Point<int>(tl.dx.round(), tl.dy.round()),
|
||||||
Point<int>(br.dx.round(), br.dy.round()),
|
Point<int>(br.dx.round(), br.dy.round()),
|
||||||
|
@ -129,30 +319,7 @@ class _TiledImageViewState extends State<TiledImageView> {
|
||||||
} else {
|
} else {
|
||||||
regionRect = Rectangle<int>(x, y, thisRegionWidth, thisRegionHeight);
|
regionRect = Rectangle<int>(x, y, thisRegionWidth, thisRegionHeight);
|
||||||
}
|
}
|
||||||
|
return Tuple2<Rect, Rectangle<int>>(tileRect, regionRect);
|
||||||
tiles.add(RegionTile(
|
|
||||||
entry: entry,
|
|
||||||
tileRect: tileRect,
|
|
||||||
regionRect: regionRect,
|
|
||||||
sampleSize: sampleSize,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Stack(
|
|
||||||
alignment: Alignment.center,
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: displayWidth * scale,
|
|
||||||
height: displayHeight * scale,
|
|
||||||
child: widget.baseChild,
|
|
||||||
),
|
|
||||||
...tiles,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
int _sampleSizeForScale(double scale) {
|
int _sampleSizeForScale(double scale) {
|
||||||
|
|
78
lib/widgets/settings/entry_background.dart
Normal file
78
lib/widgets/settings/entry_background.dart
Normal file
|
@ -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<EntryBackground> getter;
|
||||||
|
final ValueSetter<EntryBackground> setter;
|
||||||
|
|
||||||
|
const EntryBackgroundSelector({
|
||||||
|
@required this.getter,
|
||||||
|
@required this.setter,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
_EntryBackgroundSelectorState createState() => _EntryBackgroundSelectorState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EntryBackgroundSelectorState extends State<EntryBackgroundSelector> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return DropdownButtonHideUnderline(
|
||||||
|
child: DropdownButton<EntryBackground>(
|
||||||
|
items: _buildItems(context),
|
||||||
|
value: widget.getter(),
|
||||||
|
onChanged: (selected) {
|
||||||
|
widget.setter(selected);
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<DropdownMenuItem<EntryBackground>> _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<EntryBackground>(
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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/common/providers/media_query_data_provider.dart';
|
||||||
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
|
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
|
||||||
import 'package:aves/widgets/settings/access_grants.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/material.dart';
|
||||||
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
@ -115,8 +115,18 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
title: Text('SVG background'),
|
title: Text('Raster image background'),
|
||||||
trailing: SvgBackgroundSelector(),
|
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(
|
ListTile(
|
||||||
title: Text('Coordinate format'),
|
title: Text('Coordinate format'),
|
||||||
|
|
|
@ -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<SvgBackgroundSelector> {
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
const radius = 24.0;
|
|
||||||
return DropdownButtonHideUnderline(
|
|
||||||
child: DropdownButton<int>(
|
|
||||||
items: [0xFFFFFFFF, 0xFF000000, 0x00000000].map((selected) {
|
|
||||||
return DropdownMenuItem<int>(
|
|
||||||
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(() {});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -295,7 +295,5 @@ class EntryByMimeDatum {
|
||||||
Color get color => stringToColor(displayText);
|
Color get color => stringToColor(displayText);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() => '$runtimeType#${shortHash(this)}{mimeType=$mimeType, displayText=$displayText, entryCount=$entryCount}';
|
||||||
return '[$runtimeType#${shortHash(this)}: mimeType=$mimeType, displayText=$displayText, entryCount=$entryCount]';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
32
pubspec.lock
32
pubspec.lock
|
@ -63,7 +63,7 @@ packages:
|
||||||
name: cached_network_image
|
name: cached_network_image
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.1"
|
version: "2.5.0"
|
||||||
characters:
|
characters:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -282,7 +282,7 @@ packages:
|
||||||
name: flutter_cache_manager
|
name: flutter_cache_manager
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.1.0"
|
||||||
flutter_cube:
|
flutter_cube:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -605,7 +605,7 @@ packages:
|
||||||
name: panorama
|
name: panorama
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.1.0"
|
version: "0.1.2"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -668,7 +668,7 @@ packages:
|
||||||
name: pdf
|
name: pdf
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.12.0"
|
version: "1.13.0"
|
||||||
pedantic:
|
pedantic:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -682,7 +682,7 @@ packages:
|
||||||
name: percent_indicator
|
name: percent_indicator
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.8"
|
version: "2.1.9"
|
||||||
permission_handler:
|
permission_handler:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -704,15 +704,6 @@ packages:
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.0"
|
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:
|
platform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -747,7 +738,7 @@ packages:
|
||||||
name: printing
|
name: printing
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.7.1"
|
version: "3.7.2"
|
||||||
process:
|
process:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -796,7 +787,7 @@ packages:
|
||||||
name: rxdart
|
name: rxdart
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.24.1"
|
version: "0.25.0"
|
||||||
screen:
|
screen:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -866,7 +857,7 @@ packages:
|
||||||
name: shelf_static
|
name: shelf_static
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.8"
|
version: "0.2.9+1"
|
||||||
shelf_web_socket:
|
shelf_web_socket:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -1061,13 +1052,6 @@ packages:
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.0.1+3"
|
version: "0.0.1+3"
|
||||||
utf:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: utf
|
|
||||||
url: "https://pub.dartlang.org"
|
|
||||||
source: hosted
|
|
||||||
version: "0.9.0+5"
|
|
||||||
uuid:
|
uuid:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
@ -3,7 +3,7 @@ description: Aves is a gallery and metadata explorer app, built for Android.
|
||||||
|
|
||||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||||
|
|
||||||
version: 1.2.9+35
|
version: 1.3.0+36
|
||||||
|
|
||||||
# brendan-duncan/image (as of v2.1.19):
|
# brendan-duncan/image (as of v2.1.19):
|
||||||
# - does not support TIFF with JPEG compression (issue #184)
|
# - does not support TIFF with JPEG compression (issue #184)
|
||||||
|
@ -70,10 +70,6 @@ dependencies:
|
||||||
pedantic:
|
pedantic:
|
||||||
percent_indicator:
|
percent_indicator:
|
||||||
permission_handler:
|
permission_handler:
|
||||||
photo_view:
|
|
||||||
# path: ../photo_view
|
|
||||||
git:
|
|
||||||
url: git://github.com/deckerst/photo_view.git
|
|
||||||
printing:
|
printing:
|
||||||
provider:
|
provider:
|
||||||
screen:
|
screen:
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
Thanks for using Aves!
|
Thanks for using Aves!
|
||||||
v1.2.9:
|
v1.3.0:
|
||||||
- identify 360 photos/videos, GeoTIFF
|
- added quick scale (aka one finger zoom) gesture to the viewer
|
||||||
- open panoramas (360 photos)
|
- fixed zoom focus with double-tap or pinch-to-zoom gestures
|
||||||
- open GImage/GAudio/GDepth media and thumbnails embedded in XMP
|
|
||||||
- improved large TIFF handling
|
|
||||||
Full changelog available on Github
|
Full changelog available on Github
|
Loading…
Reference in a new issue