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]
|
||||
|
||||
## [v1.3.0] - 2020-12-26
|
||||
### Added
|
||||
- Viewer: quick scale (aka one finger zoom)
|
||||
- Viewer: optional checkered background for transparent images
|
||||
|
||||
### Changed
|
||||
- Viewer: changed panning inertia
|
||||
|
||||
### Fixed
|
||||
- Viewer: fixed scaling focus when zooming by double-tap or pinch
|
||||
- Viewer: fixed panning during scaling
|
||||
|
||||
## [v1.2.9] - 2020-12-12
|
||||
### Added
|
||||
- Collection: identify 360 photos/videos, GeoTIFF
|
||||
|
|
|
@ -26,7 +26,6 @@ Aves is a gallery and metadata explorer app. It is built for Android, with Flutt
|
|||
|
||||
## Known Issues
|
||||
|
||||
- gesture: double tap on image does not zoom on tapped area (cf [photo_view issue #82](https://github.com/renancaraujo/photo_view/issues/82))
|
||||
- performance: image info page stutters the first time it loads a Google Maps view (cf [flutter issue #28493](https://github.com/flutter/flutter/issues/28493))
|
||||
- SVG: unsupported `currentColor` (cf [flutter_svg issue #31](https://github.com/dnfield/flutter_svg/issues/31))
|
||||
- SVG: unsupported out of order defs/references (cf [flutter_svg issue #102](https://github.com/dnfield/flutter_svg/issues/102))
|
||||
|
|
|
@ -53,7 +53,7 @@ android {
|
|||
|
||||
defaultConfig {
|
||||
applicationId "deckers.thibault.aves"
|
||||
// TODO TLAD try minSdkVersion 23 when kotlin migration is done
|
||||
// TODO TLAD try minSdkVersion 23
|
||||
minSdkVersion 24
|
||||
targetSdkVersion 30 // same as compileSdkVersion
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
|
@ -99,7 +99,7 @@ repositories {
|
|||
dependencies {
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9'
|
||||
implementation 'androidx.core:core-ktx:1.5.0-alpha05' // v1.5.0-alpha02+ for ShortcutManagerCompat.setDynamicShortcuts
|
||||
implementation 'androidx.exifinterface:exifinterface:1.3.1'
|
||||
implementation 'androidx.exifinterface:exifinterface:1.3.2'
|
||||
implementation 'com.commonsware.cwac:document:0.4.1'
|
||||
implementation 'com.drewnoakes:metadata-extractor:2.15.0'
|
||||
// as of v0.9.8.7, `Android-TiffBitmapFactory` master branch is set up to release and distribute via Bintray
|
||||
|
|
|
@ -17,6 +17,7 @@ import deckers.thibault.aves.utils.LogUtils.createTag
|
|||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
@ -27,8 +28,8 @@ import kotlin.math.roundToInt
|
|||
class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"getAppIcon" -> GlobalScope.launch { getAppIcon(call, Coresult(result)) }
|
||||
"getAppNames" -> GlobalScope.launch { getAppNames(Coresult(result)) }
|
||||
"getAppIcon" -> GlobalScope.launch(Dispatchers.IO) { getAppIcon(call, Coresult(result)) }
|
||||
"getAppNames" -> GlobalScope.launch(Dispatchers.IO) { getAppNames(Coresult(result)) }
|
||||
"edit" -> {
|
||||
val title = call.argument<String>("title")
|
||||
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.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
@ -20,7 +21,7 @@ class AppShortcutHandler(private val context: Context) : MethodCallHandler {
|
|||
when (call.method) {
|
||||
"canPin" -> result.success(canPin())
|
||||
"pin" -> {
|
||||
GlobalScope.launch { pin(call) }
|
||||
GlobalScope.launch(Dispatchers.IO) { pin(call) }
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
|
|
|
@ -25,6 +25,7 @@ import deckers.thibault.aves.utils.StorageUtils
|
|||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
||||
|
@ -36,12 +37,12 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
|||
when (call.method) {
|
||||
"getContextDirs" -> result.success(getContextDirs())
|
||||
"getEnv" -> result.success(System.getenv())
|
||||
"getBitmapFactoryInfo" -> GlobalScope.launch { getBitmapFactoryInfo(call, Coresult(result)) }
|
||||
"getContentResolverMetadata" -> GlobalScope.launch { getContentResolverMetadata(call, Coresult(result)) }
|
||||
"getExifInterfaceMetadata" -> GlobalScope.launch { getExifInterfaceMetadata(call, Coresult(result)) }
|
||||
"getMediaMetadataRetrieverMetadata" -> GlobalScope.launch { getMediaMetadataRetrieverMetadata(call, Coresult(result)) }
|
||||
"getMetadataExtractorSummary" -> GlobalScope.launch { getMetadataExtractorSummary(call, Coresult(result)) }
|
||||
"getTiffStructure" -> GlobalScope.launch { getTiffStructure(call, Coresult(result)) }
|
||||
"getBitmapFactoryInfo" -> GlobalScope.launch(Dispatchers.IO) { getBitmapFactoryInfo(call, Coresult(result)) }
|
||||
"getContentResolverMetadata" -> GlobalScope.launch(Dispatchers.IO) { getContentResolverMetadata(call, Coresult(result)) }
|
||||
"getExifInterfaceMetadata" -> GlobalScope.launch(Dispatchers.IO) { getExifInterfaceMetadata(call, Coresult(result)) }
|
||||
"getMediaMetadataRetrieverMetadata" -> GlobalScope.launch(Dispatchers.IO) { getMediaMetadataRetrieverMetadata(call, Coresult(result)) }
|
||||
"getMetadataExtractorSummary" -> GlobalScope.launch(Dispatchers.IO) { getMetadataExtractorSummary(call, Coresult(result)) }
|
||||
"getTiffStructure" -> GlobalScope.launch(Dispatchers.IO) { getTiffStructure(call, Coresult(result)) }
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,12 +26,12 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
|||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"getObsoleteEntries" -> GlobalScope.launch { getObsoleteEntries(call, Coresult(result)) }
|
||||
"getImageEntry" -> GlobalScope.launch { getImageEntry(call, Coresult(result)) }
|
||||
"getThumbnail" -> GlobalScope.launch { getThumbnail(call, Coresult(result)) }
|
||||
"getRegion" -> GlobalScope.launch { getRegion(call, Coresult(result)) }
|
||||
"getObsoleteEntries" -> GlobalScope.launch(Dispatchers.IO) { getObsoleteEntries(call, Coresult(result)) }
|
||||
"getImageEntry" -> GlobalScope.launch(Dispatchers.IO) { getImageEntry(call, Coresult(result)) }
|
||||
"getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { getThumbnail(call, Coresult(result)) }
|
||||
"getRegion" -> GlobalScope.launch(Dispatchers.IO) { getRegion(call, Coresult(result)) }
|
||||
"clearSizedThumbnailDiskCache" -> {
|
||||
GlobalScope.launch { Glide.get(activity).clearDiskCache() }
|
||||
GlobalScope.launch(Dispatchers.IO) { Glide.get(activity).clearDiskCache() }
|
||||
result.success(null)
|
||||
}
|
||||
"rename" -> GlobalScope.launch(Dispatchers.IO) { rename(call, Coresult(result)) }
|
||||
|
|
|
@ -61,6 +61,7 @@ import deckers.thibault.aves.utils.StorageUtils
|
|||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
@ -70,12 +71,12 @@ import kotlin.math.roundToLong
|
|||
class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"getAllMetadata" -> GlobalScope.launch { getAllMetadata(call, Coresult(result)) }
|
||||
"getCatalogMetadata" -> GlobalScope.launch { getCatalogMetadata(call, Coresult(result)) }
|
||||
"getOverlayMetadata" -> GlobalScope.launch { getOverlayMetadata(call, Coresult(result)) }
|
||||
"getEmbeddedPictures" -> GlobalScope.launch { getEmbeddedPictures(call, Coresult(result)) }
|
||||
"getExifThumbnails" -> GlobalScope.launch { getExifThumbnails(call, Coresult(result)) }
|
||||
"extractXmpDataProp" -> GlobalScope.launch { extractXmpDataProp(call, Coresult(result)) }
|
||||
"getAllMetadata" -> GlobalScope.launch(Dispatchers.IO) { getAllMetadata(call, Coresult(result)) }
|
||||
"getCatalogMetadata" -> GlobalScope.launch(Dispatchers.IO) { getCatalogMetadata(call, Coresult(result)) }
|
||||
"getOverlayMetadata" -> GlobalScope.launch(Dispatchers.IO) { getOverlayMetadata(call, Coresult(result)) }
|
||||
"getEmbeddedPictures" -> GlobalScope.launch(Dispatchers.IO) { getEmbeddedPictures(call, Coresult(result)) }
|
||||
"getExifThumbnails" -> GlobalScope.launch(Dispatchers.IO) { getExifThumbnails(call, Coresult(result)) }
|
||||
"extractXmpDataProp" -> GlobalScope.launch(Dispatchers.IO) { extractXmpDataProp(call, Coresult(result)) }
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
@ -588,7 +589,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
"mimeType" to embedMimeType,
|
||||
)
|
||||
if (isImage(embedMimeType) || isVideo(embedMimeType)) {
|
||||
GlobalScope.launch {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
FileImageProvider().fetchSingle(context, embedUri, embedMimeType, object : ImageProvider.ImageOpCallback {
|
||||
override fun onSuccess(fields: FieldMap) {
|
||||
embedFields.putAll(fields)
|
||||
|
|
|
@ -11,6 +11,7 @@ import deckers.thibault.aves.utils.StorageUtils.getVolumePaths
|
|||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
@ -32,7 +33,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
|||
"getGrantedDirectories" -> result.success(ArrayList(PermissionManager.getGrantedDirs(context)))
|
||||
"getInaccessibleDirectories" -> getInaccessibleDirectories(call, result)
|
||||
"revokeDirectoryAccess" -> revokeDirectoryAccess(call, result)
|
||||
"scanFile" -> GlobalScope.launch { scanFile(call, Coresult(result)) }
|
||||
"scanFile" -> GlobalScope.launch(Dispatchers.IO) { scanFile(call, Coresult(result)) }
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@ import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
|
|||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.EventChannel.EventSink
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
||||
|
@ -32,7 +33,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
|||
this.eventSink = eventSink
|
||||
handler = Handler(Looper.getMainLooper())
|
||||
|
||||
GlobalScope.launch { streamImage() }
|
||||
GlobalScope.launch(Dispatchers.IO) { streamImage() }
|
||||
}
|
||||
|
||||
override fun onCancel(o: Any) {}
|
||||
|
|
|
@ -7,6 +7,7 @@ import deckers.thibault.aves.model.provider.FieldMap
|
|||
import deckers.thibault.aves.model.provider.MediaStoreImageProvider
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.EventChannel.EventSink
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
@ -27,7 +28,7 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E
|
|||
this.eventSink = eventSink
|
||||
handler = Handler(Looper.getMainLooper())
|
||||
|
||||
GlobalScope.launch { fetchAll() }
|
||||
GlobalScope.launch(Dispatchers.IO) { fetchAll() }
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.4.20'
|
||||
ext.kotlin_version = '1.4.21'
|
||||
repositories {
|
||||
google()
|
||||
jcenter()
|
||||
|
|
|
@ -125,7 +125,5 @@ class RegionProviderKey {
|
|||
);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'RegionProviderKey(uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, sampleSize=$sampleSize, regionRect=$regionRect, imageSize=$imageSize, scale=$scale)';
|
||||
}
|
||||
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, sampleSize=$sampleSize, regionRect=$regionRect, imageSize=$imageSize, scale=$scale}';
|
||||
}
|
||||
|
|
|
@ -116,7 +116,5 @@ class ThumbnailProviderKey {
|
|||
);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ThumbnailProviderKey{uri=$uri, mimeType=$mimeType, dateModifiedSecs=$dateModifiedSecs, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, extent=$extent, scale=$scale}';
|
||||
}
|
||||
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, dateModifiedSecs=$dateModifiedSecs, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, extent=$extent, scale=$scale}';
|
||||
}
|
||||
|
|
|
@ -80,5 +80,5 @@ class UriImage extends ImageProvider<UriImage> {
|
|||
int get hashCode => hashValues(uri, scale);
|
||||
|
||||
@override
|
||||
String toString() => '${objectRuntimeType(this, 'UriImage')}(uri=$uri, mimeType=$mimeType, scale=$scale)';
|
||||
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, scale=$scale}';
|
||||
}
|
||||
|
|
|
@ -54,5 +54,5 @@ class UriPicture extends PictureProvider<UriPicture> {
|
|||
int get hashCode => hashValues(uri, colorFilter);
|
||||
|
||||
@override
|
||||
String toString() => '${objectRuntimeType(this, 'UriPicture')}(uri=$uri, mimeType=$mimeType, colorFilter=$colorFilter)';
|
||||
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, colorFilter=$colorFilter}';
|
||||
}
|
||||
|
|
|
@ -87,7 +87,5 @@ class AlbumFilter extends CollectionFilter {
|
|||
int get hashCode => hashValues(type, album);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '$runtimeType#${shortHash(this)}{album=$album}';
|
||||
}
|
||||
String toString() => '$runtimeType#${shortHash(this)}{album=$album}';
|
||||
}
|
||||
|
|
|
@ -62,9 +62,7 @@ class LocationFilter extends CollectionFilter {
|
|||
int get hashCode => hashValues(type, level, _location);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '$runtimeType#${shortHash(this)}{level=$level, location=$_location}';
|
||||
}
|
||||
String toString() => '$runtimeType#${shortHash(this)}{level=$level, location=$_location}';
|
||||
|
||||
// U+0041 Latin Capital letter A
|
||||
// U+1F1E6 🇦 REGIONAL INDICATOR SYMBOL LETTER A
|
||||
|
|
|
@ -88,7 +88,5 @@ class MimeFilter extends CollectionFilter {
|
|||
int get hashCode => hashValues(type, mime);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '$runtimeType#${shortHash(this)}{mime=$mime}';
|
||||
}
|
||||
String toString() => '$runtimeType#${shortHash(this)}{mime=$mime}';
|
||||
}
|
||||
|
|
|
@ -71,7 +71,5 @@ class QueryFilter extends CollectionFilter {
|
|||
int get hashCode => hashValues(type, query);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '$runtimeType#${shortHash(this)}{query=$query}';
|
||||
}
|
||||
String toString() => '$runtimeType#${shortHash(this)}{query=$query}';
|
||||
}
|
||||
|
|
|
@ -48,7 +48,5 @@ class TagFilter extends CollectionFilter {
|
|||
int get hashCode => hashValues(type, tag);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '$runtimeType#${shortHash(this)}{tag=$tag}';
|
||||
}
|
||||
String toString() => '$runtimeType#${shortHash(this)}{tag=$tag}';
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import 'package:aves/model/metadata_db.dart';
|
|||
import 'package:aves/services/image_file_service.dart';
|
||||
import 'package:aves/services/metadata_service.dart';
|
||||
import 'package:aves/services/service_policy.dart';
|
||||
import 'package:aves/services/svg_metadata_service.dart';
|
||||
import 'package:aves/utils/change_notifier.dart';
|
||||
import 'package:aves/utils/math_utils.dart';
|
||||
import 'package:aves/utils/time_utils.dart';
|
||||
|
@ -24,8 +25,6 @@ class ImageEntry {
|
|||
String _path, _directory, _filename, _extension;
|
||||
int contentId;
|
||||
final String sourceMimeType;
|
||||
|
||||
// TODO TLAD use SVG viewport as width/height
|
||||
int width;
|
||||
int height;
|
||||
int sourceRotationDegrees;
|
||||
|
@ -64,6 +63,8 @@ class ImageEntry {
|
|||
|
||||
bool get canDecode => !undecodable.contains(mimeType);
|
||||
|
||||
bool get canHaveAlpha => MimeTypes.alphaImages.contains(mimeType);
|
||||
|
||||
ImageEntry copyWith({
|
||||
@required String uri,
|
||||
@required String path,
|
||||
|
@ -134,9 +135,7 @@ class ImageEntry {
|
|||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ImageEntry{uri=$uri, path=$path}';
|
||||
}
|
||||
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, path=$path}';
|
||||
|
||||
set path(String path) {
|
||||
_path = path;
|
||||
|
@ -238,10 +237,24 @@ class ImageEntry {
|
|||
// but it would take space and time, so a basic workaround will do.
|
||||
bool get isPortrait => rotationDegrees % 180 == 90 && (catalogMetadata?.rotationDegrees == null || width > height);
|
||||
|
||||
static const ratioSeparator = '\u2236';
|
||||
static const resolutionSeparator = ' \u00D7 ';
|
||||
|
||||
String get resolutionText {
|
||||
final w = width ?? '?';
|
||||
final h = height ?? '?';
|
||||
return isPortrait ? '$h × $w' : '$w × $h';
|
||||
return isPortrait ? '$h$resolutionSeparator$w' : '$w$resolutionSeparator$h';
|
||||
}
|
||||
|
||||
String get aspectRatioText {
|
||||
if (width != null && height != null && width > 0 && height > 0) {
|
||||
final gcd = width.gcd(height);
|
||||
final w = width ~/ gcd;
|
||||
final h = height ~/ gcd;
|
||||
return isPortrait ? '$h$ratioSeparator$w' : '$w$ratioSeparator$h';
|
||||
} else {
|
||||
return '?$ratioSeparator?';
|
||||
}
|
||||
}
|
||||
|
||||
double get displayAspectRatio {
|
||||
|
@ -321,7 +334,7 @@ class ImageEntry {
|
|||
String _bestTitle;
|
||||
|
||||
String get bestTitle {
|
||||
_bestTitle ??= (isCatalogued && _catalogMetadata.xmpTitleDescription.isNotEmpty) ? _catalogMetadata.xmpTitleDescription : sourceTitle;
|
||||
_bestTitle ??= (isCatalogued && _catalogMetadata.xmpTitleDescription?.isNotEmpty == true) ? _catalogMetadata.xmpTitleDescription : sourceTitle;
|
||||
return _bestTitle;
|
||||
}
|
||||
|
||||
|
@ -352,8 +365,21 @@ class ImageEntry {
|
|||
|
||||
Future<void> catalog({bool background = false}) async {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
AddressDetails get addressDetails => _addressDetails;
|
||||
|
||||
|
@ -449,6 +475,12 @@ class ImageEntry {
|
|||
this.sourceTitle = sourceTitle;
|
||||
_bestTitle = null;
|
||||
}
|
||||
|
||||
final width = newFields['width'];
|
||||
if (width is int) this.width = width;
|
||||
final height = newFields['height'];
|
||||
if (height is int) this.height = height;
|
||||
|
||||
final dateModifiedSecs = newFields['dateModifiedSecs'];
|
||||
if (dateModifiedSecs is int) this.dateModifiedSecs = dateModifiedSecs;
|
||||
final rotationDegrees = newFields['rotationDegrees'];
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:geocoder/model.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
@ -23,9 +24,7 @@ class DateMetadata {
|
|||
};
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'DateMetadata{contentId=$contentId, dateMillis=$dateMillis}';
|
||||
}
|
||||
String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, dateMillis=$dateMillis}';
|
||||
}
|
||||
|
||||
class CatalogMetadata {
|
||||
|
@ -47,10 +46,10 @@ class CatalogMetadata {
|
|||
this.contentId,
|
||||
this.mimeType,
|
||||
this.dateMillis,
|
||||
this.isAnimated,
|
||||
this.isFlipped,
|
||||
this.isGeotiff,
|
||||
this.is360,
|
||||
this.isAnimated = false,
|
||||
this.isFlipped = false,
|
||||
this.isGeotiff = false,
|
||||
this.is360 = false,
|
||||
this.rotationDegrees,
|
||||
this.xmpSubjects,
|
||||
this.xmpTitleDescription,
|
||||
|
@ -117,9 +116,7 @@ class CatalogMetadata {
|
|||
};
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'CatalogMetadata{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, rotationDegrees=$rotationDegrees, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}';
|
||||
}
|
||||
String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, rotationDegrees=$rotationDegrees, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}';
|
||||
}
|
||||
|
||||
class OverlayMetadata {
|
||||
|
@ -150,9 +147,7 @@ class OverlayMetadata {
|
|||
bool get isEmpty => aperture == null && exposureTime == null && focalLength == null && iso == null;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'OverlayMetadata{aperture=$aperture, exposureTime=$exposureTime, focalLength=$focalLength, iso=$iso}';
|
||||
}
|
||||
String toString() => '$runtimeType#${shortHash(this)}{aperture=$aperture, exposureTime=$exposureTime, focalLength=$focalLength, iso=$iso}';
|
||||
}
|
||||
|
||||
class AddressDetails {
|
||||
|
@ -200,9 +195,7 @@ class AddressDetails {
|
|||
};
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AddressDetails{contentId=$contentId, countryCode=$countryCode, countryName=$countryName, adminArea=$adminArea, locality=$locality}';
|
||||
}
|
||||
String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, countryCode=$countryCode, countryName=$countryName, adminArea=$adminArea, locality=$locality}';
|
||||
}
|
||||
|
||||
@immutable
|
||||
|
@ -237,7 +230,5 @@ class FavouriteRow {
|
|||
int get hashCode => hashValues(contentId, path);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'FavouriteRow{contentId=$contentId, path=$path}';
|
||||
}
|
||||
String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, path=$path}';
|
||||
}
|
||||
|
|
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/settings/coordinate_format.dart';
|
||||
import 'package:aves/model/settings/entry_background.dart';
|
||||
import 'package:aves/model/settings/home_page.dart';
|
||||
import 'package:aves/model/settings/screen_on.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/location_section.dart';
|
||||
|
@ -54,7 +55,8 @@ class Settings extends ChangeNotifier {
|
|||
static const coordinateFormatKey = 'coordinates_format';
|
||||
|
||||
// rendering
|
||||
static const svgBackgroundKey = 'svg_background';
|
||||
static const rasterBackgroundKey = 'raster_background';
|
||||
static const vectorBackgroundKey = 'vector_background';
|
||||
|
||||
// search
|
||||
static const saveSearchHistoryKey = 'save_search_history';
|
||||
|
@ -184,9 +186,13 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
// rendering
|
||||
|
||||
int get svgBackground => _prefs.getInt(svgBackgroundKey) ?? 0xFFFFFFFF;
|
||||
EntryBackground get rasterBackground => getEnumOrDefault(rasterBackgroundKey, EntryBackground.transparent, EntryBackground.values);
|
||||
|
||||
set svgBackground(int newValue) => setAndNotify(svgBackgroundKey, newValue);
|
||||
set rasterBackground(EntryBackground newValue) => setAndNotify(rasterBackgroundKey, newValue.toString());
|
||||
|
||||
EntryBackground get vectorBackground => getEnumOrDefault(vectorBackgroundKey, EntryBackground.white, EntryBackground.values);
|
||||
|
||||
set vectorBackground(EntryBackground newValue) => setAndNotify(vectorBackgroundKey, newValue.toString());
|
||||
|
||||
// search
|
||||
|
||||
|
|
|
@ -23,7 +23,7 @@ mixin TagMixin on SourceBase {
|
|||
|
||||
Future<void> catalogEntries() async {
|
||||
// final stopwatch = Stopwatch()..start();
|
||||
final todo = rawEntries.where((entry) => !entry.isCatalogued && !entry.isSvg).toList();
|
||||
final todo = rawEntries.where((entry) => !entry.isCatalogued).toList();
|
||||
if (todo.isEmpty) return;
|
||||
|
||||
var progressDone = 0;
|
||||
|
|
|
@ -1,15 +1,17 @@
|
|||
class MimeTypes {
|
||||
static const anyImage = 'image/*';
|
||||
|
||||
static const bmp = 'image/bmp';
|
||||
static const gif = 'image/gif';
|
||||
static const heic = 'image/heic';
|
||||
static const heif = 'image/heif';
|
||||
static const ico = 'image/x-icon';
|
||||
static const jpeg = 'image/jpeg';
|
||||
static const png = 'image/png';
|
||||
static const svg = 'image/svg+xml';
|
||||
static const tiff = 'image/tiff';
|
||||
static const webp = 'image/webp';
|
||||
|
||||
static const tiff = 'image/tiff';
|
||||
static const psd = 'image/vnd.adobe.photoshop';
|
||||
|
||||
static const arw = 'image/x-sony-arw';
|
||||
|
@ -40,6 +42,10 @@ class MimeTypes {
|
|||
static const mp4 = 'video/mp4';
|
||||
|
||||
// groups
|
||||
|
||||
// formats that support transparency
|
||||
static const List<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 bool isImage(String mimeType) => mimeType.startsWith('image');
|
||||
|
|
|
@ -15,6 +15,7 @@ class XMP {
|
|||
'exifEX': 'Exif Ex',
|
||||
'GettyImagesGIFT': 'Getty Images',
|
||||
'GIMP': 'GIMP',
|
||||
'GCamera': 'Google Camera',
|
||||
'GFocus': 'Google Focus',
|
||||
'GPano': 'Google Panorama',
|
||||
'illustrator': 'Illustrator',
|
||||
|
|
|
@ -300,9 +300,7 @@ class ImageOpEvent {
|
|||
int get hashCode => hashValues(success, uri);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ImageOpEvent{success=$success, uri=$uri}';
|
||||
}
|
||||
String toString() => '$runtimeType#${shortHash(this)}{success=$success, uri=$uri}';
|
||||
}
|
||||
|
||||
class MoveOpEvent extends ImageOpEvent {
|
||||
|
@ -323,9 +321,7 @@ class MoveOpEvent extends ImageOpEvent {
|
|||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'MoveOpEvent{success=$success, uri=$uri, newFields=$newFields}';
|
||||
}
|
||||
String toString() => '$runtimeType#${shortHash(this)}{success=$success, uri=$uri, newFields=$newFields}';
|
||||
}
|
||||
|
||||
// cf flutter/foundation `consolidateHttpClientResponseBytes`
|
||||
|
|
|
@ -10,7 +10,10 @@ class ServicePolicy {
|
|||
final StreamController<QueueState> _queueStreamController = StreamController<QueueState>.broadcast();
|
||||
final Map<Object, Tuple2<int, _Task>> _paused = {};
|
||||
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;
|
||||
|
||||
|
@ -23,6 +26,7 @@ class ServicePolicy {
|
|||
Object key,
|
||||
}) {
|
||||
_Task task;
|
||||
key ??= platformCall.hashCode;
|
||||
final priorityTask = _paused.remove(key);
|
||||
if (priorityTask != null) {
|
||||
debugPrint('resume task with key=$key');
|
||||
|
@ -39,7 +43,7 @@ class ServicePolicy {
|
|||
completer.completeError(error, stackTrace);
|
||||
}
|
||||
if (debugLabel != null) debugPrint('$runtimeType $debugLabel completed');
|
||||
_running = null;
|
||||
_runningQueue.removeWhere((task) => task.key == key);
|
||||
_pickNext();
|
||||
},
|
||||
completer,
|
||||
|
@ -64,10 +68,13 @@ class ServicePolicy {
|
|||
|
||||
void _pickNext() {
|
||||
_notifyQueueState();
|
||||
if (_running != null) return;
|
||||
if (_runningQueue.length >= concurrentTaskMax) return;
|
||||
final queue = _queues.entries.firstWhere((kv) => kv.value.isNotEmpty, orElse: () => null)?.value;
|
||||
_running = queue?.removeFirst();
|
||||
_running?.callback?.call();
|
||||
final task = queue?.removeFirst();
|
||||
if (task != null) {
|
||||
_runningQueue.addLast(task);
|
||||
task.callback();
|
||||
}
|
||||
}
|
||||
|
||||
bool _takeOut(Object key, Iterable<int> priorities, void Function(int priority, _Task task) action) {
|
||||
|
@ -99,7 +106,7 @@ class ServicePolicy {
|
|||
if (!_queueStreamController.hasListener) return;
|
||||
|
||||
final queueByPriority = Map.fromEntries(_queues.entries.map((kv) => MapEntry(kv.key, kv.value.length)));
|
||||
_queueStreamController.add(QueueState(queueByPriority));
|
||||
_queueStreamController.add(QueueState(queueByPriority, _runningQueue.length));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -124,6 +131,7 @@ class ServiceCallPriority {
|
|||
|
||||
class QueueState {
|
||||
final Map<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',
|
||||
sourceUrl: 'https://github.com/Baseflow/flutter-permission-handler',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Photo View',
|
||||
license: 'MIT',
|
||||
licenseUrl: 'https://github.com/renancaraujo/photo_view/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/renancaraujo/photo_view',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Printing',
|
||||
license: 'Apache 2.0',
|
||||
|
|
|
@ -4,9 +4,9 @@ import 'package:aves/model/source/collection_lens.dart';
|
|||
import 'package:aves/services/viewer_service.dart';
|
||||
import 'package:aves/widgets/collection/grid/list_known_extent.dart';
|
||||
import 'package:aves/widgets/collection/grid/list_section_layout.dart';
|
||||
import 'package:aves/widgets/common/scaling.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail/decorated.dart';
|
||||
import 'package:aves/widgets/common/behaviour/routes.dart';
|
||||
import 'package:aves/widgets/common/scaling.dart';
|
||||
import 'package:aves/widgets/fullscreen/fullscreen_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
|
|
@ -43,7 +43,7 @@ class DecoratedThumbnail extends StatelessWidget {
|
|||
);
|
||||
|
||||
child = Stack(
|
||||
fit: StackFit.passthrough,
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
child,
|
||||
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/model/image_entry.dart';
|
||||
import 'package:aves/model/settings/entry_background.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/widgets/common/fx/checkered_decoration.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
@ -19,15 +21,36 @@ class ThumbnailVectorImage extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final child = Container(
|
||||
// center `SvgPicture` inside `Container` with the thumbnail dimensions
|
||||
// so that `SvgPicture` doesn't get aligned by the `Stack` like the overlay icons
|
||||
width: extent,
|
||||
height: extent,
|
||||
child: Selector<Settings, int>(
|
||||
selector: (context, s) => s.svgBackground,
|
||||
builder: (context, svgBackground, child) {
|
||||
final colorFilter = ColorFilter.mode(Color(svgBackground), BlendMode.dstOver);
|
||||
final child = Selector<Settings, EntryBackground>(
|
||||
selector: (context, s) => s.vectorBackground,
|
||||
builder: (context, background, child) {
|
||||
const fit = BoxFit.contain;
|
||||
if (background == EntryBackground.checkered) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final availableSize = constraints.biggest;
|
||||
final fitSize = applyBoxFit(fit, entry.displaySize, availableSize).destination;
|
||||
final offset = fitSize / 2 - availableSize / 2;
|
||||
final child = DecoratedBox(
|
||||
decoration: CheckeredDecoration(checkSize: extent / 8, offset: offset),
|
||||
child: SvgPicture(
|
||||
UriPicture(
|
||||
uri: entry.uri,
|
||||
mimeType: entry.mimeType,
|
||||
),
|
||||
width: fitSize.width,
|
||||
height: fitSize.height,
|
||||
fit: fit,
|
||||
),
|
||||
);
|
||||
// the thumbnail is centered for correct decoration sizing
|
||||
// when constraints are tight during hero animation
|
||||
return constraints.isTight ? Center(child: child) : child;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final colorFilter = background.isColor ? ColorFilter.mode(background.color, BlendMode.dstOver) : null;
|
||||
return SvgPicture(
|
||||
UriPicture(
|
||||
uri: entry.uri,
|
||||
|
@ -36,9 +59,9 @@ class ThumbnailVectorImage extends StatelessWidget {
|
|||
),
|
||||
width: extent,
|
||||
height: extent,
|
||||
fit: fit,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
return heroTag == null
|
||||
? 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});
|
||||
|
||||
@override
|
||||
HighlightDecorationPainter createBoxPainter([VoidCallback onChanged]) {
|
||||
return HighlightDecorationPainter(this, onChanged);
|
||||
_HighlightDecorationPainter createBoxPainter([VoidCallback onChanged]) {
|
||||
return _HighlightDecorationPainter(this, onChanged);
|
||||
}
|
||||
}
|
||||
|
||||
class HighlightDecorationPainter extends BoxPainter {
|
||||
class _HighlightDecorationPainter extends BoxPainter {
|
||||
final HighlightDecoration decoration;
|
||||
|
||||
const HighlightDecorationPainter(this.decoration, VoidCallback onChanged) : super(onChanged);
|
||||
const _HighlightDecorationPainter(this.decoration, VoidCallback onChanged) : super(onChanged);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) {
|
||||
|
|
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,
|
||||
builder: (context, snapshot) {
|
||||
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));
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
|
|
@ -177,9 +177,9 @@ class _AlbumFilterBarState extends State<AlbumFilterBar> {
|
|||
),
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(minWidth: 16),
|
||||
child: AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) => AnimatedSwitcher(
|
||||
child: ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: _controller,
|
||||
builder: (context, value, child) => AnimatedSwitcher(
|
||||
duration: Durations.appBarActionChangeAnimation,
|
||||
transitionBuilder: (child, animation) => FadeTransition(
|
||||
opacity: animation,
|
||||
|
@ -189,7 +189,7 @@ class _AlbumFilterBarState extends State<AlbumFilterBar> {
|
|||
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/scheduler.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:pdf/pdf.dart';
|
||||
import 'package:pdf/widgets.dart' as pdf;
|
||||
import 'package:pedantic/pedantic.dart';
|
||||
import 'package:printing/printing.dart';
|
||||
|
@ -100,34 +98,32 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
|||
final documentName = entry.bestTitle ?? 'Aves';
|
||||
final doc = pdf.Document(title: documentName);
|
||||
|
||||
PdfImage pdfImage;
|
||||
pdf.Widget pdfChild;
|
||||
if (entry.isSvg) {
|
||||
final bytes = await ImageFileService.getImage(uri, mimeType, entry.rotationDegrees, entry.isFlipped);
|
||||
if (bytes != null && bytes.isNotEmpty) {
|
||||
final svgRoot = await svg.fromSvgBytes(bytes, uri);
|
||||
final viewBox = svgRoot.viewport.viewBox;
|
||||
// 1000 is arbitrary, but large enough to look ok in the print preview
|
||||
final targetSize = viewBox * 1000 / viewBox.longestSide;
|
||||
final picture = svgRoot.toPicture(size: targetSize);
|
||||
final uiImage = await picture.toImage(targetSize.width.ceil(), targetSize.height.ceil());
|
||||
pdfImage = await pdfImageFromImage(
|
||||
pdf: doc.document,
|
||||
image: uiImage,
|
||||
);
|
||||
pdfChild = pdf.SvgImage(svg: utf8.decode(bytes));
|
||||
}
|
||||
} else {
|
||||
pdfImage = await pdfImageFromImageProvider(
|
||||
pdf: doc.document,
|
||||
image: UriImage(
|
||||
pdfChild = pdf.Image.provider(await flutterImageProvider(
|
||||
UriImage(
|
||||
uri: uri,
|
||||
mimeType: mimeType,
|
||||
rotationDegrees: rotationDegrees,
|
||||
isFlipped: isFlipped,
|
||||
),
|
||||
);
|
||||
));
|
||||
}
|
||||
if (pdfImage != null) {
|
||||
doc.addPage(pdf.Page(build: (context) => pdf.Center(child: pdf.Image(pdfImage)))); // Page
|
||||
if (pdfChild != null) {
|
||||
doc.addPage(pdf.Page(
|
||||
orientation: entry.isPortrait ? pdf.PageOrientation.portrait : pdf.PageOrientation.landscape,
|
||||
build: (context) => pdf.FullPage(
|
||||
ignoreMargins: true,
|
||||
child: pdf.Center(
|
||||
child: pdfChild,
|
||||
),
|
||||
),
|
||||
)); // Page
|
||||
unawaited(Printing.layoutPdf(
|
||||
onLayout: (format) => doc.save(),
|
||||
name: documentName,
|
||||
|
|
|
@ -8,6 +8,7 @@ import 'package:aves/model/source/collection_lens.dart';
|
|||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/utils/change_notifier.dart';
|
||||
import 'package:aves/widgets/collection/collection_page.dart';
|
||||
import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart';
|
||||
import 'package:aves/widgets/fullscreen/entry_action_delegate.dart';
|
||||
import 'package:aves/widgets/fullscreen/image_page.dart';
|
||||
import 'package:aves/widgets/fullscreen/image_view.dart';
|
||||
|
@ -22,7 +23,6 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:screen/screen.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
@ -557,7 +557,7 @@ class _FullscreenVerticalPageViewState extends State<FullscreenVerticalPageView>
|
|||
key: Key('vertical-pageview'),
|
||||
scrollDirection: Axis.vertical,
|
||||
controller: widget.verticalPager,
|
||||
physics: PhotoViewPageViewScrollPhysics(parent: PageScrollPhysics()),
|
||||
physics: MagnifierScrollerPhysics(parent: PageScrollPhysics()),
|
||||
onPageChanged: (page) {
|
||||
widget.onVerticalPageChanged(page);
|
||||
_infoPageVisibleNotifier.value = page == pages.length - 1;
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/widgets/common/magnifier/pan/gesture_detector_scope.dart';
|
||||
import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart';
|
||||
import 'package:aves/widgets/fullscreen/image_view.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class MultiImagePage extends StatefulWidget {
|
||||
|
@ -34,13 +35,13 @@ class MultiImagePageState extends State<MultiImagePage> with AutomaticKeepAliveC
|
|||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
|
||||
return PhotoViewGestureDetectorScope(
|
||||
return MagnifierGestureDetectorScope(
|
||||
axis: [Axis.horizontal, Axis.vertical],
|
||||
child: PageView.builder(
|
||||
key: Key('horizontal-pageview'),
|
||||
scrollDirection: Axis.horizontal,
|
||||
controller: widget.pageController,
|
||||
physics: PhotoViewPageViewScrollPhysics(parent: BouncingScrollPhysics()),
|
||||
physics: MagnifierScrollerPhysics(parent: BouncingScrollPhysics()),
|
||||
onPageChanged: widget.onPageChanged,
|
||||
itemBuilder: (context, index) {
|
||||
final entry = entries[index];
|
||||
|
@ -49,7 +50,7 @@ class MultiImagePageState extends State<MultiImagePage> with AutomaticKeepAliveC
|
|||
key: Key('imageview'),
|
||||
entry: entry,
|
||||
heroTag: widget.collection.heroTag(entry),
|
||||
onTap: widget.onTap,
|
||||
onTap: (_) => widget.onTap?.call(),
|
||||
videoControllers: widget.videoControllers,
|
||||
onDisposed: () => widget.onViewDisposed?.call(entry.uri),
|
||||
),
|
||||
|
@ -84,11 +85,11 @@ class SingleImagePageState extends State<SingleImagePage> with AutomaticKeepAliv
|
|||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
|
||||
return PhotoViewGestureDetectorScope(
|
||||
return MagnifierGestureDetectorScope(
|
||||
axis: [Axis.vertical],
|
||||
child: ImageView(
|
||||
entry: widget.entry,
|
||||
onTap: widget.onTap,
|
||||
onTap: (_) => widget.onTap?.call(),
|
||||
videoControllers: widget.videoControllers,
|
||||
),
|
||||
);
|
||||
|
|
|
@ -1,29 +1,35 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:aves/image_providers/thumbnail_provider.dart';
|
||||
import 'package:aves/image_providers/uri_image_provider.dart';
|
||||
import 'package:aves/image_providers/uri_picture_provider.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/settings/entry_background.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/collection/empty.dart';
|
||||
import 'package:aves/widgets/common/fx/checkered_decoration.dart';
|
||||
import 'package:aves/widgets/common/magnifier/controller/controller.dart';
|
||||
import 'package:aves/widgets/common/magnifier/controller/state.dart';
|
||||
import 'package:aves/widgets/common/magnifier/magnifier.dart';
|
||||
import 'package:aves/widgets/common/magnifier/scale/scale_boundaries.dart';
|
||||
import 'package:aves/widgets/common/magnifier/scale/scale_level.dart';
|
||||
import 'package:aves/widgets/common/magnifier/scale/state.dart';
|
||||
import 'package:aves/widgets/fullscreen/tiled_view.dart';
|
||||
import 'package:aves/widgets/fullscreen/video_view.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class ImageView extends StatefulWidget {
|
||||
final ImageEntry entry;
|
||||
final Object heroTag;
|
||||
final VoidCallback onTap;
|
||||
final MagnifierTapCallback onTap;
|
||||
final List<Tuple2<String, IjkMediaController>> videoControllers;
|
||||
final VoidCallback onDisposed;
|
||||
|
||||
static const decorationCheckSize = 20.0;
|
||||
|
||||
const ImageView({
|
||||
Key key,
|
||||
@required this.entry,
|
||||
|
@ -38,32 +44,30 @@ class ImageView extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _ImageViewState extends State<ImageView> {
|
||||
final PhotoViewController _photoViewController = PhotoViewController();
|
||||
final PhotoViewScaleStateController _photoViewScaleStateController = PhotoViewScaleStateController();
|
||||
final MagnifierController _magnifierController = MagnifierController();
|
||||
final ValueNotifier<ViewState> _viewStateNotifier = ValueNotifier(ViewState.zero);
|
||||
StreamSubscription<PhotoViewControllerValue> _subscription;
|
||||
Size _photoViewChildSize;
|
||||
final List<StreamSubscription> _subscriptions = [];
|
||||
|
||||
static const backgroundDecoration = BoxDecoration(color: Colors.transparent);
|
||||
static const maxScale = 2.0;
|
||||
static const initialScale = ScaleLevel(ref: ScaleReference.contained);
|
||||
static const minScale = ScaleLevel(ref: ScaleReference.contained);
|
||||
static const maxScale = ScaleLevel(factor: 2.0);
|
||||
|
||||
ImageEntry get entry => widget.entry;
|
||||
|
||||
VoidCallback get onTap => widget.onTap;
|
||||
MagnifierTapCallback get onTap => widget.onTap;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_subscription = _photoViewController.outputStateStream.listen(_onViewChanged);
|
||||
if (entry.isVideo || (!entry.isSvg && entry.canDecode && useTile)) {
|
||||
_photoViewChildSize = entry.displaySize;
|
||||
}
|
||||
_subscriptions.add(_magnifierController.stateStream.listen(_onViewStateChanged));
|
||||
_subscriptions.add(_magnifierController.scaleBoundariesStream.listen(_onViewScaleBoundariesChanged));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subscription.cancel();
|
||||
_subscription = null;
|
||||
_subscriptions
|
||||
..forEach((sub) => sub.cancel())
|
||||
..clear();
|
||||
widget.onDisposed?.call();
|
||||
super.dispose();
|
||||
}
|
||||
|
@ -78,19 +82,9 @@ class _ImageViewState extends State<ImageView> {
|
|||
} else if (entry.isSvg) {
|
||||
child = _buildSvgView();
|
||||
} else if (entry.canDecode) {
|
||||
if (useTile) {
|
||||
child = _buildTiledImageView();
|
||||
} else {
|
||||
child = _buildImageView();
|
||||
child = _buildRasterView();
|
||||
}
|
||||
}
|
||||
child ??= _buildError();
|
||||
|
||||
// if the hero tag is defined in the `loadingBuilder` and also set by the `heroAttributes`,
|
||||
// the route transition becomes visible if the final image is loaded before the hero animation is done.
|
||||
|
||||
// if the hero tag wraps the whole `PhotoView` and the `loadingBuilder` is not provided,
|
||||
// there's a black frame between the hero animation and the final image, even when it's cached.
|
||||
child ??= ErrorChild(onTap: () => onTap?.call(null));
|
||||
|
||||
// no hero for videos, as a typical video first frame is different from its thumbnail
|
||||
return widget.heroTag != null && !entry.isVideo
|
||||
|
@ -102,103 +96,30 @@ class _ImageViewState extends State<ImageView> {
|
|||
: child;
|
||||
}
|
||||
|
||||
// the images loaded by `PhotoView` cannot have a width or height larger than 8192
|
||||
// so the reported offset and scale does not match expected values derived from the original dimensions
|
||||
// besides, large images should be tiled to be memory-friendly
|
||||
bool get useTile => entry.canTile && (entry.width > 4096 || entry.height > 4096);
|
||||
|
||||
ImageProvider get fastThumbnailProvider => ThumbnailProvider(ThumbnailProviderKey.fromEntry(entry));
|
||||
|
||||
// this loading builder shows a transition image until the final image is ready
|
||||
// if the image is already in the cache it will show the final image, otherwise the thumbnail
|
||||
// in any case, we should use `Center` + `AspectRatio` + `BoxFit.fill` so that the transition image
|
||||
// appears as the final image with `PhotoViewComputedScale.contained` for `initialScale`
|
||||
Widget _loadingBuilder(BuildContext context, ImageProvider imageProvider) {
|
||||
return Center(
|
||||
child: AspectRatio(
|
||||
// enforce original aspect ratio, as some thumbnails aspect ratios slightly differ
|
||||
aspectRatio: entry.displayAspectRatio,
|
||||
child: Image(
|
||||
image: imageProvider,
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImageView() {
|
||||
final uriImage = UriImage(
|
||||
uri: entry.uri,
|
||||
mimeType: entry.mimeType,
|
||||
rotationDegrees: entry.rotationDegrees,
|
||||
isFlipped: entry.isFlipped,
|
||||
expectedContentLength: entry.sizeBytes,
|
||||
);
|
||||
return PhotoView(
|
||||
Widget _buildRasterView() {
|
||||
return Magnifier(
|
||||
// key includes size and orientation to refresh when the image is rotated
|
||||
key: ValueKey('${entry.rotationDegrees}_${entry.isFlipped}_${entry.width}_${entry.height}_${entry.path}'),
|
||||
imageProvider: uriImage,
|
||||
// when the full image is ready, we use it in the `loadingBuilder`
|
||||
// we still provide a `loadingBuilder` in that case to avoid a black frame after hero animation
|
||||
loadingBuilder: (context, event) => _loadingBuilder(
|
||||
context,
|
||||
imageCache.statusForKey(uriImage).keepAlive ? uriImage : fastThumbnailProvider,
|
||||
),
|
||||
loadFailedChild: _buildError(),
|
||||
backgroundDecoration: backgroundDecoration,
|
||||
imageSizedCallback: (size) {
|
||||
// do not directly update the `ViewState` notifier as this callback is called during build
|
||||
_photoViewChildSize = size;
|
||||
},
|
||||
controller: _photoViewController,
|
||||
maxScale: maxScale,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
initialScale: PhotoViewComputedScale.contained,
|
||||
onTapUp: (tapContext, details, value) => onTap?.call(),
|
||||
filterQuality: FilterQuality.low,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTiledImageView() {
|
||||
return PhotoView.customChild(
|
||||
// key includes size and orientation to refresh when the image is rotated
|
||||
key: ValueKey('${entry.rotationDegrees}_${entry.isFlipped}_${entry.width}_${entry.height}_${entry.path}'),
|
||||
child: Selector<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(
|
||||
child: TiledImageView(
|
||||
entry: entry,
|
||||
viewportSize: mqSize,
|
||||
viewStateNotifier: _viewStateNotifier,
|
||||
baseChild: _loadingBuilder(context, fastThumbnailProvider),
|
||||
errorBuilder: (context, error, stackTrace) => _buildError(),
|
||||
);
|
||||
},
|
||||
errorBuilder: (context, error, stackTrace) => ErrorChild(onTap: () => onTap?.call(null)),
|
||||
),
|
||||
childSize: entry.displaySize,
|
||||
backgroundDecoration: backgroundDecoration,
|
||||
controller: _photoViewController,
|
||||
scaleStateController: _photoViewScaleStateController,
|
||||
controller: _magnifierController,
|
||||
maxScale: maxScale,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
initialScale: PhotoViewComputedScale.contained,
|
||||
onTapUp: (tapContext, details, value) => onTap?.call(),
|
||||
filterQuality: FilterQuality.low,
|
||||
minScale: minScale,
|
||||
initialScale: initialScale,
|
||||
onTap: (c, d, s, childPosition) => onTap?.call(childPosition),
|
||||
applyScale: false,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSvgView() {
|
||||
final colorFilter = ColorFilter.mode(Color(settings.svgBackground), BlendMode.dstOver);
|
||||
return PhotoView.customChild(
|
||||
final background = settings.vectorBackground;
|
||||
final colorFilter = background.isColor ? ColorFilter.mode(background.color, BlendMode.dstOver) : null;
|
||||
|
||||
Widget child = Magnifier(
|
||||
child: SvgPicture(
|
||||
UriPicture(
|
||||
uri: entry.uri,
|
||||
|
@ -206,17 +127,54 @@ class _ImageViewState extends State<ImageView> {
|
|||
colorFilter: colorFilter,
|
||||
),
|
||||
),
|
||||
backgroundDecoration: backgroundDecoration,
|
||||
controller: _photoViewController,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
initialScale: PhotoViewComputedScale.contained,
|
||||
onTapUp: (tapContext, details, value) => onTap?.call(),
|
||||
childSize: entry.displaySize,
|
||||
controller: _magnifierController,
|
||||
minScale: minScale,
|
||||
initialScale: initialScale,
|
||||
scaleStateCycle: _vectorScaleStateCycle,
|
||||
onTap: (c, d, s, childPosition) => onTap?.call(childPosition),
|
||||
);
|
||||
|
||||
if (background == EntryBackground.checkered) {
|
||||
child = ValueListenableBuilder<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() {
|
||||
final videoController = widget.videoControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2;
|
||||
return PhotoView.customChild(
|
||||
return Magnifier(
|
||||
child: videoController != null
|
||||
? AvesVideo(
|
||||
entry: entry,
|
||||
|
@ -224,16 +182,69 @@ class _ImageViewState extends State<ImageView> {
|
|||
)
|
||||
: SizedBox(),
|
||||
childSize: entry.displaySize,
|
||||
backgroundDecoration: backgroundDecoration,
|
||||
controller: _photoViewController,
|
||||
controller: _magnifierController,
|
||||
maxScale: maxScale,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
initialScale: PhotoViewComputedScale.contained,
|
||||
onTapUp: (tapContext, details, value) => onTap?.call(),
|
||||
minScale: minScale,
|
||||
initialScale: initialScale,
|
||||
onTap: (c, d, s, childPosition) => onTap?.call(childPosition),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildError() => GestureDetector(
|
||||
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(),
|
||||
// use a `Container` with a dummy color to make it expand
|
||||
// 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 {
|
||||
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}';
|
||||
}
|
||||
}
|
||||
typedef MagnifierTapCallback = void Function(Offset childPosition);
|
||||
|
|
|
@ -3,6 +3,7 @@ import 'dart:collection';
|
|||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/ref/brand_colors.dart';
|
||||
import 'package:aves/services/metadata_service.dart';
|
||||
import 'package:aves/services/svg_metadata_service.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/color_utils.dart';
|
||||
|
@ -10,8 +11,8 @@ import 'package:aves/utils/constants.dart';
|
|||
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/common.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/metadata/metadata_thumbnail.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/metadata/svg_tile.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/metadata/xmp_tile.dart';
|
||||
import 'package:aves/widgets/fullscreen/source_viewer_page.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
@ -91,9 +92,9 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
|||
// cancel notification bubbling so that the info page
|
||||
// does not misinterpret content scrolling for page scrolling
|
||||
onNotification: (notification) => true,
|
||||
child: AnimatedBuilder(
|
||||
animation: _loadedMetadataUri,
|
||||
builder: (context, child) {
|
||||
child: ValueListenableBuilder<String>(
|
||||
valueListenable: _loadedMetadataUri,
|
||||
builder: (context, uri, child) {
|
||||
Widget content;
|
||||
if (_metadata.isEmpty) {
|
||||
content = SizedBox.shrink();
|
||||
|
@ -118,7 +119,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
|||
return AnimationLimiter(
|
||||
// we update the limiter key after fetching the metadata of a new entry,
|
||||
// in order to restart the staggered animation of the metadata section
|
||||
key: Key(_loadedMetadataUri.value),
|
||||
key: Key(uri),
|
||||
child: content,
|
||||
);
|
||||
},
|
||||
|
@ -175,7 +176,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
|||
child: InfoRowGroup(
|
||||
dir.tags,
|
||||
maxValueLength: Constants.infoGroupMaxValueLength,
|
||||
linkHandlers: dirName == SvgMetadata.metadataDirectory ? SvgMetadata.getLinkHandlers(dir.tags) : null,
|
||||
linkHandlers: dirName == SvgMetadataService.metadataDirectory ? getSvgLinkHandlers(dir.tags) : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
@ -192,7 +193,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
|||
if (entry == null) return;
|
||||
if (_loadedMetadataUri.value == entry.uri) return;
|
||||
if (isVisible) {
|
||||
final rawMetadata = await (entry.isSvg ? SvgMetadata.getAllMetadata(entry) : MetadataService.getAllMetadata(entry)) ?? {};
|
||||
final rawMetadata = await (entry.isSvg ? SvgMetadataService.getAllMetadata(entry) : MetadataService.getAllMetadata(entry)) ?? {};
|
||||
final directories = rawMetadata.entries.map((dirKV) {
|
||||
var directoryName = dirKV.key as String ?? '';
|
||||
|
||||
|
@ -230,6 +231,25 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
|||
_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
|
||||
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;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '$runtimeType#${shortHash(this)}{namespace=$namespace}';
|
||||
}
|
||||
String toString() => '$runtimeType#${shortHash(this)}{namespace=$namespace}';
|
||||
}
|
||||
|
||||
class XmpProp {
|
||||
|
@ -116,9 +114,7 @@ class XmpProp {
|
|||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '$runtimeType#${shortHash(this)}{path=$path, value=$value}';
|
||||
}
|
||||
String toString() => '$runtimeType#${shortHash(this)}{path=$path, value=$value}';
|
||||
}
|
||||
|
||||
class OpenEmbeddedDataNotification extends Notification {
|
||||
|
@ -131,7 +127,5 @@ class OpenEmbeddedDataNotification extends Notification {
|
|||
});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '$runtimeType#${shortHash(this)}{propPath=$propPath, mimeType=$mimeType}';
|
||||
}
|
||||
String toString() => '$runtimeType#${shortHash(this)}{propPath=$propPath, mimeType=$mimeType}';
|
||||
}
|
||||
|
|
|
@ -278,7 +278,7 @@ class _DateRow extends StatelessWidget {
|
|||
DecoratedIcon(AIcons.date, shadows: [Constants.embossShadow], size: _iconSize),
|
||||
SizedBox(width: _iconPadding),
|
||||
Expanded(flex: 3, child: Text(dateText, strutStyle: Constants.overflowStrutStyle)),
|
||||
if (!entry.isSvg) Expanded(flex: 2, child: Text(entry.resolutionText, strutStyle: Constants.overflowStrutStyle)),
|
||||
Expanded(flex: 2, child: Text(entry.isSvg ? entry.aspectRatioText : entry.resolutionText, strutStyle: Constants.overflowStrutStyle)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
@ -4,7 +4,6 @@ import 'package:aves/model/image_entry.dart';
|
|||
import 'package:aves/widgets/fullscreen/image_view.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class Minimap extends StatelessWidget {
|
||||
final ImageEntry entry;
|
||||
|
@ -21,25 +20,24 @@ class Minimap extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Selector<MediaQueryData, Size>(
|
||||
selector: (context, mq) => mq.size,
|
||||
builder: (context, mqSize, child) {
|
||||
return AnimatedBuilder(
|
||||
animation: viewStateNotifier,
|
||||
builder: (context, child) {
|
||||
final viewState = viewStateNotifier.value;
|
||||
return IgnorePointer(
|
||||
child: ValueListenableBuilder<ViewState>(
|
||||
valueListenable: viewStateNotifier,
|
||||
builder: (context, viewState, child) {
|
||||
final viewportSize = viewState.viewportSize;
|
||||
if (viewportSize == null) return SizedBox.shrink();
|
||||
return CustomPaint(
|
||||
painter: MinimapPainter(
|
||||
viewportSize: mqSize,
|
||||
entrySize: viewState.size ?? entry.displaySize,
|
||||
viewportSize: viewportSize,
|
||||
entrySize: entry.displaySize,
|
||||
viewCenterOffset: viewState.position,
|
||||
viewScale: viewState.scale,
|
||||
minimapBorderColor: Colors.white30,
|
||||
),
|
||||
size: size,
|
||||
);
|
||||
});
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,24 +1,26 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/utils/math_utils.dart';
|
||||
import 'package:aves/image_providers/region_provider.dart';
|
||||
import 'package:aves/image_providers/thumbnail_provider.dart';
|
||||
import 'package:aves/image_providers/uri_image_provider.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/settings/entry_background.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/utils/math_utils.dart';
|
||||
import 'package:aves/widgets/common/fx/checkered_decoration.dart';
|
||||
import 'package:aves/widgets/fullscreen/image_view.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class TiledImageView extends StatefulWidget {
|
||||
final ImageEntry entry;
|
||||
final Size viewportSize;
|
||||
final ValueNotifier<ViewState> viewStateNotifier;
|
||||
final Widget baseChild;
|
||||
final ImageErrorWidgetBuilder errorBuilder;
|
||||
|
||||
const TiledImageView({
|
||||
@required this.entry,
|
||||
@required this.viewportSize,
|
||||
@required this.viewStateNotifier,
|
||||
@required this.baseChild,
|
||||
@required this.errorBuilder,
|
||||
});
|
||||
|
||||
|
@ -27,86 +29,274 @@ class TiledImageView extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _TiledImageViewState extends State<TiledImageView> {
|
||||
double _tileSide, _initialScale;
|
||||
bool _isTilingInitialized = false;
|
||||
int _maxSampleSize;
|
||||
Matrix4 _transform;
|
||||
double _tileSide;
|
||||
Matrix4 _tileTransform;
|
||||
ImageStream _fullImageStream;
|
||||
ImageStreamListener _fullImageListener;
|
||||
final ValueNotifier<bool> _fullImageLoaded = ValueNotifier(false);
|
||||
|
||||
ImageEntry get entry => widget.entry;
|
||||
|
||||
Size get viewportSize => widget.viewportSize;
|
||||
|
||||
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
|
||||
static const scaleFactor = 2.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_init();
|
||||
_fullImageListener = ImageStreamListener(_onFullImageCompleted);
|
||||
if (!useTiles) _registerFullImage();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(TiledImageView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
if (oldWidget.viewportSize != widget.viewportSize || oldWidget.entry.displaySize != widget.entry.displaySize) {
|
||||
_init();
|
||||
final oldViewState = oldWidget.viewStateNotifier.value;
|
||||
final viewState = widget.viewStateNotifier.value;
|
||||
if (oldWidget.entry != widget.entry || oldViewState.viewportSize != viewState.viewportSize) {
|
||||
_isTilingInitialized = false;
|
||||
_fullImageLoaded.value = false;
|
||||
_unregisterFullImage();
|
||||
}
|
||||
}
|
||||
|
||||
void _init() {
|
||||
_tileSide = viewportSize.shortestSide * scaleFactor;
|
||||
_initialScale = min(viewportSize.width / entry.displaySize.width, viewportSize.height / entry.displaySize.height);
|
||||
_maxSampleSize = _sampleSizeForScale(_initialScale);
|
||||
|
||||
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);
|
||||
@override
|
||||
void dispose() {
|
||||
_unregisterFullImage();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _registerFullImage() {
|
||||
_fullImageStream = fullImageProvider.resolve(ImageConfiguration.empty);
|
||||
_fullImageStream.addListener(_fullImageListener);
|
||||
}
|
||||
|
||||
void _unregisterFullImage() {
|
||||
_fullImageStream?.removeListener(_fullImageListener);
|
||||
_fullImageStream = null;
|
||||
}
|
||||
|
||||
void _onFullImageCompleted(ImageInfo image, bool synchronousCall) {
|
||||
_unregisterFullImage();
|
||||
_fullImageLoaded.value = true;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (viewStateNotifier == null) return SizedBox.shrink();
|
||||
|
||||
final displayWidth = entry.displaySize.width.round();
|
||||
final displayHeight = entry.displaySize.height.round();
|
||||
return ValueListenableBuilder<ViewState>(
|
||||
valueListenable: viewStateNotifier,
|
||||
builder: (context, viewState, child) {
|
||||
final viewportSize = viewState.viewportSize;
|
||||
final viewportSized = viewportSize != null;
|
||||
if (viewportSized && useTiles && !_isTilingInitialized) _initTiling(viewportSize);
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: viewStateNotifier,
|
||||
builder: (context, child) {
|
||||
final viewState = viewStateNotifier.value;
|
||||
var scale = viewState.scale;
|
||||
if (scale == 0.0) {
|
||||
// for initial scale as `PhotoViewComputedScale.contained`
|
||||
scale = _initialScale;
|
||||
return SizedBox.fromSize(
|
||||
size: entry.displaySize * viewState.scale,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
if (useBackground && viewportSized) _buildBackground(viewState),
|
||||
_buildLoading(viewState),
|
||||
if (useTiles) ..._getTiles(viewState),
|
||||
if (!useTiles)
|
||||
Image(
|
||||
image: fullImageProvider,
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: widget.errorBuilder,
|
||||
width: (entry.displaySize * viewState.scale).width,
|
||||
fit: BoxFit.contain,
|
||||
filterQuality: FilterQuality.medium,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final centerOffset = viewState.position;
|
||||
final viewOrigin = Offset(
|
||||
((displayWidth * scale - viewportSize.width) / 2 - centerOffset.dx),
|
||||
((displayHeight * scale - viewportSize.height) / 2 - centerOffset.dy),
|
||||
void _initTiling(Size viewportSize) {
|
||||
final displaySize = entry.displaySize;
|
||||
_tileSide = viewportSize.shortestSide * scaleFactor;
|
||||
// scale for initial state `contained`
|
||||
final containedScale = min(viewportSize.width / displaySize.width, viewportSize.height / displaySize.height);
|
||||
_maxSampleSize = _sampleSizeForScale(containedScale);
|
||||
|
||||
final rotationDegrees = entry.rotationDegrees;
|
||||
final isFlipped = entry.isFlipped;
|
||||
_tileTransform = null;
|
||||
if (rotationDegrees != 0 || isFlipped) {
|
||||
_tileTransform = Matrix4.identity()
|
||||
..translate(entry.width / 2.0, entry.height / 2.0)
|
||||
..scale(isFlipped ? -1.0 : 1.0, 1.0, 1.0)
|
||||
..rotateZ(-toRadians(rotationDegrees.toDouble()))
|
||||
..translate(-displaySize.width / 2.0, -displaySize.height / 2.0);
|
||||
}
|
||||
_isTilingInitialized = true;
|
||||
_registerFullImage();
|
||||
}
|
||||
|
||||
Widget _buildLoading(ViewState viewState) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: _fullImageLoaded,
|
||||
builder: (context, fullImageLoaded, child) {
|
||||
if (fullImageLoaded) return SizedBox.shrink();
|
||||
|
||||
return Center(
|
||||
child: AspectRatio(
|
||||
// enforce original aspect ratio, as some thumbnails aspect ratios slightly differ
|
||||
aspectRatio: entry.displayAspectRatio,
|
||||
child: Image(
|
||||
image: thumbnailProvider,
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
);
|
||||
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>[];
|
||||
var minSampleSize = min(_sampleSizeForScale(scale), _maxSampleSize);
|
||||
for (var sampleSize = _maxSampleSize; sampleSize >= minSampleSize; sampleSize = (sampleSize / 2).floor()) {
|
||||
// for the largest sample size (matching the initial scale), the whole image is in view
|
||||
// so we subsample the whole image instead of splitting it in tiles
|
||||
final useTiles = sampleSize != _maxSampleSize;
|
||||
// so we subsample the whole image without tiling
|
||||
final fullImageRegion = sampleSize == _maxSampleSize;
|
||||
final regionSide = (_tileSide * sampleSize).round();
|
||||
final layerRegionWidth = useTiles ? regionSide : displayWidth;
|
||||
final layerRegionHeight = useTiles ? regionSide : displayHeight;
|
||||
final layerRegionWidth = fullImageRegion ? displayWidth : regionSide;
|
||||
final layerRegionHeight = fullImageRegion ? displayHeight : regionSide;
|
||||
for (var x = 0; x < displayWidth; x += layerRegionWidth) {
|
||||
for (var y = 0; y < displayHeight; y += layerRegionHeight) {
|
||||
final rects = _getTileRects(
|
||||
x: x,
|
||||
y: y,
|
||||
layerRegionWidth: layerRegionWidth,
|
||||
layerRegionHeight: layerRegionHeight,
|
||||
displayWidth: displayWidth,
|
||||
displayHeight: displayHeight,
|
||||
scale: scale,
|
||||
viewRect: viewRect,
|
||||
);
|
||||
if (rects != null) {
|
||||
tiles.add(RegionTile(
|
||||
entry: entry,
|
||||
tileRect: rects.item1,
|
||||
regionRect: rects.item2,
|
||||
sampleSize: sampleSize,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
|
||||
Rect _getViewRect(ViewState viewState, int displayWidth, int displayHeight) {
|
||||
final scale = viewState.scale;
|
||||
final centerOffset = viewState.position;
|
||||
final viewportSize = viewState.viewportSize;
|
||||
final viewOrigin = Offset(
|
||||
((displayWidth * scale - viewportSize.width) / 2 - centerOffset.dx),
|
||||
((displayHeight * scale - viewportSize.height) / 2 - centerOffset.dy),
|
||||
);
|
||||
return viewOrigin & viewportSize;
|
||||
}
|
||||
|
||||
Tuple2<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 nextY = y + layerRegionHeight;
|
||||
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);
|
||||
|
||||
// only build visible tiles
|
||||
if (viewRect.overlaps(tileRect)) {
|
||||
Rectangle<int> regionRect;
|
||||
if (!viewRect.overlaps(tileRect)) return null;
|
||||
|
||||
if (_transform != null) {
|
||||
Rectangle<int> regionRect;
|
||||
if (_tileTransform != null) {
|
||||
// apply EXIF orientation
|
||||
final regionRectDouble = Rect.fromLTWH(x.toDouble(), y.toDouble(), thisRegionWidth.toDouble(), thisRegionHeight.toDouble());
|
||||
final tl = MatrixUtils.transformPoint(_transform, regionRectDouble.topLeft);
|
||||
final br = MatrixUtils.transformPoint(_transform, regionRectDouble.bottomRight);
|
||||
final tl = MatrixUtils.transformPoint(_tileTransform, regionRectDouble.topLeft);
|
||||
final br = MatrixUtils.transformPoint(_tileTransform, regionRectDouble.bottomRight);
|
||||
regionRect = Rectangle<int>.fromPoints(
|
||||
Point<int>(tl.dx.round(), tl.dy.round()),
|
||||
Point<int>(br.dx.round(), br.dy.round()),
|
||||
|
@ -129,30 +319,7 @@ class _TiledImageViewState extends State<TiledImageView> {
|
|||
} else {
|
||||
regionRect = Rectangle<int>(x, y, thisRegionWidth, thisRegionHeight);
|
||||
}
|
||||
|
||||
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,
|
||||
],
|
||||
);
|
||||
});
|
||||
return Tuple2<Rect, Rectangle<int>>(tileRect, regionRect);
|
||||
}
|
||||
|
||||
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/dialogs/aves_selection_dialog.dart';
|
||||
import 'package:aves/widgets/settings/access_grants.dart';
|
||||
import 'package:aves/widgets/settings/svg_background.dart';
|
||||
import 'package:aves/widgets/settings/entry_background.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
@ -115,8 +115,18 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text('SVG background'),
|
||||
trailing: SvgBackgroundSelector(),
|
||||
title: Text('Raster image background'),
|
||||
trailing: EntryBackgroundSelector(
|
||||
getter: () => settings.rasterBackground,
|
||||
setter: (value) => settings.rasterBackground = value,
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Vector image background'),
|
||||
trailing: EntryBackgroundSelector(
|
||||
getter: () => settings.vectorBackground,
|
||||
setter: (value) => settings.vectorBackground = value,
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Coordinate format'),
|
||||
|
|
|
@ -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);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '[$runtimeType#${shortHash(this)}: mimeType=$mimeType, displayText=$displayText, entryCount=$entryCount]';
|
||||
}
|
||||
String toString() => '$runtimeType#${shortHash(this)}{mimeType=$mimeType, displayText=$displayText, entryCount=$entryCount}';
|
||||
}
|
||||
|
|
32
pubspec.lock
32
pubspec.lock
|
@ -63,7 +63,7 @@ packages:
|
|||
name: cached_network_image
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
version: "2.5.0"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -282,7 +282,7 @@ packages:
|
|||
name: flutter_cache_manager
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
version: "2.1.0"
|
||||
flutter_cube:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -605,7 +605,7 @@ packages:
|
|||
name: panorama
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.1.0"
|
||||
version: "0.1.2"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -668,7 +668,7 @@ packages:
|
|||
name: pdf
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.12.0"
|
||||
version: "1.13.0"
|
||||
pedantic:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -682,7 +682,7 @@ packages:
|
|||
name: percent_indicator
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
version: "2.1.9"
|
||||
permission_handler:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -704,15 +704,6 @@ packages:
|
|||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
photo_view:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: HEAD
|
||||
resolved-ref: aa6400bbc85bf6ce953c4609d126796cdb4ca3c2
|
||||
url: "git://github.com/deckerst/photo_view.git"
|
||||
source: git
|
||||
version: "0.9.2"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -747,7 +738,7 @@ packages:
|
|||
name: printing
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.7.1"
|
||||
version: "3.7.2"
|
||||
process:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -796,7 +787,7 @@ packages:
|
|||
name: rxdart
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.24.1"
|
||||
version: "0.25.0"
|
||||
screen:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -866,7 +857,7 @@ packages:
|
|||
name: shelf_static
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.2.8"
|
||||
version: "0.2.9+1"
|
||||
shelf_web_socket:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -1061,13 +1052,6 @@ packages:
|
|||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.0.1+3"
|
||||
utf:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: utf
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.9.0+5"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
@ -3,7 +3,7 @@ description: Aves is a gallery and metadata explorer app, built for Android.
|
|||
|
||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
|
||||
version: 1.2.9+35
|
||||
version: 1.3.0+36
|
||||
|
||||
# brendan-duncan/image (as of v2.1.19):
|
||||
# - does not support TIFF with JPEG compression (issue #184)
|
||||
|
@ -70,10 +70,6 @@ dependencies:
|
|||
pedantic:
|
||||
percent_indicator:
|
||||
permission_handler:
|
||||
photo_view:
|
||||
# path: ../photo_view
|
||||
git:
|
||||
url: git://github.com/deckerst/photo_view.git
|
||||
printing:
|
||||
provider:
|
||||
screen:
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
Thanks for using Aves!
|
||||
v1.2.9:
|
||||
- identify 360 photos/videos, GeoTIFF
|
||||
- open panoramas (360 photos)
|
||||
- open GImage/GAudio/GDepth media and thumbnails embedded in XMP
|
||||
- improved large TIFF handling
|
||||
v1.3.0:
|
||||
- added quick scale (aka one finger zoom) gesture to the viewer
|
||||
- fixed zoom focus with double-tap or pinch-to-zoom gestures
|
||||
Full changelog available on Github
|
Loading…
Reference in a new issue