Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2020-12-26 20:48:30 +09:00
commit 5769cf1579
69 changed files with 2287 additions and 611 deletions

View file

@ -3,6 +3,18 @@ All notable changes to this project will be documented in this file.
## [Unreleased] ## [Unreleased]
## [v1.3.0] - 2020-12-26
### Added
- Viewer: quick scale (aka one finger zoom)
- Viewer: optional checkered background for transparent images
### Changed
- Viewer: changed panning inertia
### Fixed
- Viewer: fixed scaling focus when zooming by double-tap or pinch
- Viewer: fixed panning during scaling
## [v1.2.9] - 2020-12-12 ## [v1.2.9] - 2020-12-12
### Added ### Added
- Collection: identify 360 photos/videos, GeoTIFF - Collection: identify 360 photos/videos, GeoTIFF

View file

@ -26,7 +26,6 @@ Aves is a gallery and metadata explorer app. It is built for Android, with Flutt
## Known Issues ## Known Issues
- gesture: double tap on image does not zoom on tapped area (cf [photo_view issue #82](https://github.com/renancaraujo/photo_view/issues/82))
- performance: image info page stutters the first time it loads a Google Maps view (cf [flutter issue #28493](https://github.com/flutter/flutter/issues/28493)) - performance: image info page stutters the first time it loads a Google Maps view (cf [flutter issue #28493](https://github.com/flutter/flutter/issues/28493))
- SVG: unsupported `currentColor` (cf [flutter_svg issue #31](https://github.com/dnfield/flutter_svg/issues/31)) - SVG: unsupported `currentColor` (cf [flutter_svg issue #31](https://github.com/dnfield/flutter_svg/issues/31))
- SVG: unsupported out of order defs/references (cf [flutter_svg issue #102](https://github.com/dnfield/flutter_svg/issues/102)) - SVG: unsupported out of order defs/references (cf [flutter_svg issue #102](https://github.com/dnfield/flutter_svg/issues/102))

View file

@ -53,7 +53,7 @@ android {
defaultConfig { defaultConfig {
applicationId "deckers.thibault.aves" applicationId "deckers.thibault.aves"
// TODO TLAD try minSdkVersion 23 when kotlin migration is done // TODO TLAD try minSdkVersion 23
minSdkVersion 24 minSdkVersion 24
targetSdkVersion 30 // same as compileSdkVersion targetSdkVersion 30 // same as compileSdkVersion
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
@ -99,7 +99,7 @@ repositories {
dependencies { dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9'
implementation 'androidx.core:core-ktx:1.5.0-alpha05' // v1.5.0-alpha02+ for ShortcutManagerCompat.setDynamicShortcuts implementation 'androidx.core:core-ktx:1.5.0-alpha05' // v1.5.0-alpha02+ for ShortcutManagerCompat.setDynamicShortcuts
implementation 'androidx.exifinterface:exifinterface:1.3.1' implementation 'androidx.exifinterface:exifinterface:1.3.2'
implementation 'com.commonsware.cwac:document:0.4.1' implementation 'com.commonsware.cwac:document:0.4.1'
implementation 'com.drewnoakes:metadata-extractor:2.15.0' implementation 'com.drewnoakes:metadata-extractor:2.15.0'
// as of v0.9.8.7, `Android-TiffBitmapFactory` master branch is set up to release and distribute via Bintray // as of v0.9.8.7, `Android-TiffBitmapFactory` master branch is set up to release and distribute via Bintray

View file

@ -17,6 +17,7 @@ import deckers.thibault.aves.utils.LogUtils.createTag
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File import java.io.File
@ -27,8 +28,8 @@ import kotlin.math.roundToInt
class AppAdapterHandler(private val context: Context) : MethodCallHandler { class AppAdapterHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) { when (call.method) {
"getAppIcon" -> GlobalScope.launch { getAppIcon(call, Coresult(result)) } "getAppIcon" -> GlobalScope.launch(Dispatchers.IO) { getAppIcon(call, Coresult(result)) }
"getAppNames" -> GlobalScope.launch { getAppNames(Coresult(result)) } "getAppNames" -> GlobalScope.launch(Dispatchers.IO) { getAppNames(Coresult(result)) }
"edit" -> { "edit" -> {
val title = call.argument<String>("title") val title = call.argument<String>("title")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.let { Uri.parse(it) }

View file

@ -12,6 +12,7 @@ import deckers.thibault.aves.utils.BitmapUtils.centerSquareCrop
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -20,7 +21,7 @@ class AppShortcutHandler(private val context: Context) : MethodCallHandler {
when (call.method) { when (call.method) {
"canPin" -> result.success(canPin()) "canPin" -> result.success(canPin())
"pin" -> { "pin" -> {
GlobalScope.launch { pin(call) } GlobalScope.launch(Dispatchers.IO) { pin(call) }
result.success(null) result.success(null)
} }
else -> result.notImplemented() else -> result.notImplemented()

View file

@ -25,6 +25,7 @@ import deckers.thibault.aves.utils.StorageUtils
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.beyka.tiffbitmapfactory.TiffBitmapFactory import org.beyka.tiffbitmapfactory.TiffBitmapFactory
@ -36,12 +37,12 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
when (call.method) { when (call.method) {
"getContextDirs" -> result.success(getContextDirs()) "getContextDirs" -> result.success(getContextDirs())
"getEnv" -> result.success(System.getenv()) "getEnv" -> result.success(System.getenv())
"getBitmapFactoryInfo" -> GlobalScope.launch { getBitmapFactoryInfo(call, Coresult(result)) } "getBitmapFactoryInfo" -> GlobalScope.launch(Dispatchers.IO) { getBitmapFactoryInfo(call, Coresult(result)) }
"getContentResolverMetadata" -> GlobalScope.launch { getContentResolverMetadata(call, Coresult(result)) } "getContentResolverMetadata" -> GlobalScope.launch(Dispatchers.IO) { getContentResolverMetadata(call, Coresult(result)) }
"getExifInterfaceMetadata" -> GlobalScope.launch { getExifInterfaceMetadata(call, Coresult(result)) } "getExifInterfaceMetadata" -> GlobalScope.launch(Dispatchers.IO) { getExifInterfaceMetadata(call, Coresult(result)) }
"getMediaMetadataRetrieverMetadata" -> GlobalScope.launch { getMediaMetadataRetrieverMetadata(call, Coresult(result)) } "getMediaMetadataRetrieverMetadata" -> GlobalScope.launch(Dispatchers.IO) { getMediaMetadataRetrieverMetadata(call, Coresult(result)) }
"getMetadataExtractorSummary" -> GlobalScope.launch { getMetadataExtractorSummary(call, Coresult(result)) } "getMetadataExtractorSummary" -> GlobalScope.launch(Dispatchers.IO) { getMetadataExtractorSummary(call, Coresult(result)) }
"getTiffStructure" -> GlobalScope.launch { getTiffStructure(call, Coresult(result)) } "getTiffStructure" -> GlobalScope.launch(Dispatchers.IO) { getTiffStructure(call, Coresult(result)) }
else -> result.notImplemented() else -> result.notImplemented()
} }
} }

View file

@ -26,12 +26,12 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) { when (call.method) {
"getObsoleteEntries" -> GlobalScope.launch { getObsoleteEntries(call, Coresult(result)) } "getObsoleteEntries" -> GlobalScope.launch(Dispatchers.IO) { getObsoleteEntries(call, Coresult(result)) }
"getImageEntry" -> GlobalScope.launch { getImageEntry(call, Coresult(result)) } "getImageEntry" -> GlobalScope.launch(Dispatchers.IO) { getImageEntry(call, Coresult(result)) }
"getThumbnail" -> GlobalScope.launch { getThumbnail(call, Coresult(result)) } "getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { getThumbnail(call, Coresult(result)) }
"getRegion" -> GlobalScope.launch { getRegion(call, Coresult(result)) } "getRegion" -> GlobalScope.launch(Dispatchers.IO) { getRegion(call, Coresult(result)) }
"clearSizedThumbnailDiskCache" -> { "clearSizedThumbnailDiskCache" -> {
GlobalScope.launch { Glide.get(activity).clearDiskCache() } GlobalScope.launch(Dispatchers.IO) { Glide.get(activity).clearDiskCache() }
result.success(null) result.success(null)
} }
"rename" -> GlobalScope.launch(Dispatchers.IO) { rename(call, Coresult(result)) } "rename" -> GlobalScope.launch(Dispatchers.IO) { rename(call, Coresult(result)) }

View file

@ -61,6 +61,7 @@ import deckers.thibault.aves.utils.StorageUtils
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File import java.io.File
@ -70,12 +71,12 @@ import kotlin.math.roundToLong
class MetadataHandler(private val context: Context) : MethodCallHandler { class MetadataHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) { when (call.method) {
"getAllMetadata" -> GlobalScope.launch { getAllMetadata(call, Coresult(result)) } "getAllMetadata" -> GlobalScope.launch(Dispatchers.IO) { getAllMetadata(call, Coresult(result)) }
"getCatalogMetadata" -> GlobalScope.launch { getCatalogMetadata(call, Coresult(result)) } "getCatalogMetadata" -> GlobalScope.launch(Dispatchers.IO) { getCatalogMetadata(call, Coresult(result)) }
"getOverlayMetadata" -> GlobalScope.launch { getOverlayMetadata(call, Coresult(result)) } "getOverlayMetadata" -> GlobalScope.launch(Dispatchers.IO) { getOverlayMetadata(call, Coresult(result)) }
"getEmbeddedPictures" -> GlobalScope.launch { getEmbeddedPictures(call, Coresult(result)) } "getEmbeddedPictures" -> GlobalScope.launch(Dispatchers.IO) { getEmbeddedPictures(call, Coresult(result)) }
"getExifThumbnails" -> GlobalScope.launch { getExifThumbnails(call, Coresult(result)) } "getExifThumbnails" -> GlobalScope.launch(Dispatchers.IO) { getExifThumbnails(call, Coresult(result)) }
"extractXmpDataProp" -> GlobalScope.launch { extractXmpDataProp(call, Coresult(result)) } "extractXmpDataProp" -> GlobalScope.launch(Dispatchers.IO) { extractXmpDataProp(call, Coresult(result)) }
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
@ -588,7 +589,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
"mimeType" to embedMimeType, "mimeType" to embedMimeType,
) )
if (isImage(embedMimeType) || isVideo(embedMimeType)) { if (isImage(embedMimeType) || isVideo(embedMimeType)) {
GlobalScope.launch { GlobalScope.launch(Dispatchers.IO) {
FileImageProvider().fetchSingle(context, embedUri, embedMimeType, object : ImageProvider.ImageOpCallback { FileImageProvider().fetchSingle(context, embedUri, embedMimeType, object : ImageProvider.ImageOpCallback {
override fun onSuccess(fields: FieldMap) { override fun onSuccess(fields: FieldMap) {
embedFields.putAll(fields) embedFields.putAll(fields)

View file

@ -11,6 +11,7 @@ import deckers.thibault.aves.utils.StorageUtils.getVolumePaths
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File import java.io.File
@ -32,7 +33,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
"getGrantedDirectories" -> result.success(ArrayList(PermissionManager.getGrantedDirs(context))) "getGrantedDirectories" -> result.success(ArrayList(PermissionManager.getGrantedDirs(context)))
"getInaccessibleDirectories" -> getInaccessibleDirectories(call, result) "getInaccessibleDirectories" -> getInaccessibleDirectories(call, result)
"revokeDirectoryAccess" -> revokeDirectoryAccess(call, result) "revokeDirectoryAccess" -> revokeDirectoryAccess(call, result)
"scanFile" -> GlobalScope.launch { scanFile(call, Coresult(result)) } "scanFile" -> GlobalScope.launch(Dispatchers.IO) { scanFile(call, Coresult(result)) }
else -> result.notImplemented() else -> result.notImplemented()
} }
} }

View file

@ -18,6 +18,7 @@ import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.StorageUtils
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.EventChannel.EventSink import io.flutter.plugin.common.EventChannel.EventSink
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.beyka.tiffbitmapfactory.TiffBitmapFactory import org.beyka.tiffbitmapfactory.TiffBitmapFactory
@ -32,7 +33,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
this.eventSink = eventSink this.eventSink = eventSink
handler = Handler(Looper.getMainLooper()) handler = Handler(Looper.getMainLooper())
GlobalScope.launch { streamImage() } GlobalScope.launch(Dispatchers.IO) { streamImage() }
} }
override fun onCancel(o: Any) {} override fun onCancel(o: Any) {}

View file

@ -7,6 +7,7 @@ import deckers.thibault.aves.model.provider.FieldMap
import deckers.thibault.aves.model.provider.MediaStoreImageProvider import deckers.thibault.aves.model.provider.MediaStoreImageProvider
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.EventChannel.EventSink import io.flutter.plugin.common.EventChannel.EventSink
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -27,7 +28,7 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E
this.eventSink = eventSink this.eventSink = eventSink
handler = Handler(Looper.getMainLooper()) handler = Handler(Looper.getMainLooper())
GlobalScope.launch { fetchAll() } GlobalScope.launch(Dispatchers.IO) { fetchAll() }
} }
override fun onCancel(arguments: Any?) {} override fun onCancel(arguments: Any?) {}

View file

@ -1,6 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript { buildscript {
ext.kotlin_version = '1.4.20' ext.kotlin_version = '1.4.21'
repositories { repositories {
google() google()
jcenter() jcenter()

View file

@ -125,7 +125,5 @@ class RegionProviderKey {
); );
@override @override
String toString() { String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, sampleSize=$sampleSize, regionRect=$regionRect, imageSize=$imageSize, scale=$scale}';
return 'RegionProviderKey(uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, sampleSize=$sampleSize, regionRect=$regionRect, imageSize=$imageSize, scale=$scale)';
}
} }

View file

@ -116,7 +116,5 @@ class ThumbnailProviderKey {
); );
@override @override
String toString() { String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, dateModifiedSecs=$dateModifiedSecs, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, extent=$extent, scale=$scale}';
return 'ThumbnailProviderKey{uri=$uri, mimeType=$mimeType, dateModifiedSecs=$dateModifiedSecs, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, extent=$extent, scale=$scale}';
}
} }

View file

@ -80,5 +80,5 @@ class UriImage extends ImageProvider<UriImage> {
int get hashCode => hashValues(uri, scale); int get hashCode => hashValues(uri, scale);
@override @override
String toString() => '${objectRuntimeType(this, 'UriImage')}(uri=$uri, mimeType=$mimeType, scale=$scale)'; String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, scale=$scale}';
} }

View file

@ -54,5 +54,5 @@ class UriPicture extends PictureProvider<UriPicture> {
int get hashCode => hashValues(uri, colorFilter); int get hashCode => hashValues(uri, colorFilter);
@override @override
String toString() => '${objectRuntimeType(this, 'UriPicture')}(uri=$uri, mimeType=$mimeType, colorFilter=$colorFilter)'; String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, colorFilter=$colorFilter}';
} }

View file

@ -87,7 +87,5 @@ class AlbumFilter extends CollectionFilter {
int get hashCode => hashValues(type, album); int get hashCode => hashValues(type, album);
@override @override
String toString() { String toString() => '$runtimeType#${shortHash(this)}{album=$album}';
return '$runtimeType#${shortHash(this)}{album=$album}';
}
} }

View file

@ -62,9 +62,7 @@ class LocationFilter extends CollectionFilter {
int get hashCode => hashValues(type, level, _location); int get hashCode => hashValues(type, level, _location);
@override @override
String toString() { String toString() => '$runtimeType#${shortHash(this)}{level=$level, location=$_location}';
return '$runtimeType#${shortHash(this)}{level=$level, location=$_location}';
}
// U+0041 Latin Capital letter A // U+0041 Latin Capital letter A
// U+1F1E6 🇦 REGIONAL INDICATOR SYMBOL LETTER A // U+1F1E6 🇦 REGIONAL INDICATOR SYMBOL LETTER A

View file

@ -88,7 +88,5 @@ class MimeFilter extends CollectionFilter {
int get hashCode => hashValues(type, mime); int get hashCode => hashValues(type, mime);
@override @override
String toString() { String toString() => '$runtimeType#${shortHash(this)}{mime=$mime}';
return '$runtimeType#${shortHash(this)}{mime=$mime}';
}
} }

View file

@ -71,7 +71,5 @@ class QueryFilter extends CollectionFilter {
int get hashCode => hashValues(type, query); int get hashCode => hashValues(type, query);
@override @override
String toString() { String toString() => '$runtimeType#${shortHash(this)}{query=$query}';
return '$runtimeType#${shortHash(this)}{query=$query}';
}
} }

View file

@ -48,7 +48,5 @@ class TagFilter extends CollectionFilter {
int get hashCode => hashValues(type, tag); int get hashCode => hashValues(type, tag);
@override @override
String toString() { String toString() => '$runtimeType#${shortHash(this)}{tag=$tag}';
return '$runtimeType#${shortHash(this)}{tag=$tag}';
}
} }

View file

@ -7,6 +7,7 @@ import 'package:aves/model/metadata_db.dart';
import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/metadata_service.dart'; import 'package:aves/services/metadata_service.dart';
import 'package:aves/services/service_policy.dart'; import 'package:aves/services/service_policy.dart';
import 'package:aves/services/svg_metadata_service.dart';
import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/change_notifier.dart';
import 'package:aves/utils/math_utils.dart'; import 'package:aves/utils/math_utils.dart';
import 'package:aves/utils/time_utils.dart'; import 'package:aves/utils/time_utils.dart';
@ -24,8 +25,6 @@ class ImageEntry {
String _path, _directory, _filename, _extension; String _path, _directory, _filename, _extension;
int contentId; int contentId;
final String sourceMimeType; final String sourceMimeType;
// TODO TLAD use SVG viewport as width/height
int width; int width;
int height; int height;
int sourceRotationDegrees; int sourceRotationDegrees;
@ -64,6 +63,8 @@ class ImageEntry {
bool get canDecode => !undecodable.contains(mimeType); bool get canDecode => !undecodable.contains(mimeType);
bool get canHaveAlpha => MimeTypes.alphaImages.contains(mimeType);
ImageEntry copyWith({ ImageEntry copyWith({
@required String uri, @required String uri,
@required String path, @required String path,
@ -134,9 +135,7 @@ class ImageEntry {
} }
@override @override
String toString() { String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, path=$path}';
return 'ImageEntry{uri=$uri, path=$path}';
}
set path(String path) { set path(String path) {
_path = path; _path = path;
@ -238,10 +237,24 @@ class ImageEntry {
// but it would take space and time, so a basic workaround will do. // but it would take space and time, so a basic workaround will do.
bool get isPortrait => rotationDegrees % 180 == 90 && (catalogMetadata?.rotationDegrees == null || width > height); bool get isPortrait => rotationDegrees % 180 == 90 && (catalogMetadata?.rotationDegrees == null || width > height);
static const ratioSeparator = '\u2236';
static const resolutionSeparator = ' \u00D7 ';
String get resolutionText { String get resolutionText {
final w = width ?? '?'; final w = width ?? '?';
final h = height ?? '?'; final h = height ?? '?';
return isPortrait ? '$h × $w' : '$w × $h'; return isPortrait ? '$h$resolutionSeparator$w' : '$w$resolutionSeparator$h';
}
String get aspectRatioText {
if (width != null && height != null && width > 0 && height > 0) {
final gcd = width.gcd(height);
final w = width ~/ gcd;
final h = height ~/ gcd;
return isPortrait ? '$h$ratioSeparator$w' : '$w$ratioSeparator$h';
} else {
return '?$ratioSeparator?';
}
} }
double get displayAspectRatio { double get displayAspectRatio {
@ -321,7 +334,7 @@ class ImageEntry {
String _bestTitle; String _bestTitle;
String get bestTitle { String get bestTitle {
_bestTitle ??= (isCatalogued && _catalogMetadata.xmpTitleDescription.isNotEmpty) ? _catalogMetadata.xmpTitleDescription : sourceTitle; _bestTitle ??= (isCatalogued && _catalogMetadata.xmpTitleDescription?.isNotEmpty == true) ? _catalogMetadata.xmpTitleDescription : sourceTitle;
return _bestTitle; return _bestTitle;
} }
@ -352,8 +365,21 @@ class ImageEntry {
Future<void> catalog({bool background = false}) async { Future<void> catalog({bool background = false}) async {
if (isCatalogued) return; if (isCatalogued) return;
if (isSvg) {
// vector image sizing is not essential, so we should not spend time for it during loading
// but it is useful anyway (for aspect ratios etc.) so we size them during cataloguing
final size = await SvgMetadataService.getSize(this);
if (size != null) {
await _applyNewFields({
'width': size.width.round(),
'height': size.height.round(),
});
}
catalogMetadata = CatalogMetadata(contentId: contentId);
} else {
catalogMetadata = await MetadataService.getCatalogMetadata(this, background: background); catalogMetadata = await MetadataService.getCatalogMetadata(this, background: background);
} }
}
AddressDetails get addressDetails => _addressDetails; AddressDetails get addressDetails => _addressDetails;
@ -449,6 +475,12 @@ class ImageEntry {
this.sourceTitle = sourceTitle; this.sourceTitle = sourceTitle;
_bestTitle = null; _bestTitle = null;
} }
final width = newFields['width'];
if (width is int) this.width = width;
final height = newFields['height'];
if (height is int) this.height = height;
final dateModifiedSecs = newFields['dateModifiedSecs']; final dateModifiedSecs = newFields['dateModifiedSecs'];
if (dateModifiedSecs is int) this.dateModifiedSecs = dateModifiedSecs; if (dateModifiedSecs is int) this.dateModifiedSecs = dateModifiedSecs;
final rotationDegrees = newFields['rotationDegrees']; final rotationDegrees = newFields['rotationDegrees'];

View file

@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:geocoder/model.dart'; import 'package:geocoder/model.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
@ -23,9 +24,7 @@ class DateMetadata {
}; };
@override @override
String toString() { String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, dateMillis=$dateMillis}';
return 'DateMetadata{contentId=$contentId, dateMillis=$dateMillis}';
}
} }
class CatalogMetadata { class CatalogMetadata {
@ -47,10 +46,10 @@ class CatalogMetadata {
this.contentId, this.contentId,
this.mimeType, this.mimeType,
this.dateMillis, this.dateMillis,
this.isAnimated, this.isAnimated = false,
this.isFlipped, this.isFlipped = false,
this.isGeotiff, this.isGeotiff = false,
this.is360, this.is360 = false,
this.rotationDegrees, this.rotationDegrees,
this.xmpSubjects, this.xmpSubjects,
this.xmpTitleDescription, this.xmpTitleDescription,
@ -117,9 +116,7 @@ class CatalogMetadata {
}; };
@override @override
String toString() { String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, rotationDegrees=$rotationDegrees, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}';
return 'CatalogMetadata{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, rotationDegrees=$rotationDegrees, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}';
}
} }
class OverlayMetadata { class OverlayMetadata {
@ -150,9 +147,7 @@ class OverlayMetadata {
bool get isEmpty => aperture == null && exposureTime == null && focalLength == null && iso == null; bool get isEmpty => aperture == null && exposureTime == null && focalLength == null && iso == null;
@override @override
String toString() { String toString() => '$runtimeType#${shortHash(this)}{aperture=$aperture, exposureTime=$exposureTime, focalLength=$focalLength, iso=$iso}';
return 'OverlayMetadata{aperture=$aperture, exposureTime=$exposureTime, focalLength=$focalLength, iso=$iso}';
}
} }
class AddressDetails { class AddressDetails {
@ -200,9 +195,7 @@ class AddressDetails {
}; };
@override @override
String toString() { String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, countryCode=$countryCode, countryName=$countryName, adminArea=$adminArea, locality=$locality}';
return 'AddressDetails{contentId=$contentId, countryCode=$countryCode, countryName=$countryName, adminArea=$adminArea, locality=$locality}';
}
} }
@immutable @immutable
@ -237,7 +230,5 @@ class FavouriteRow {
int get hashCode => hashValues(contentId, path); int get hashCode => hashValues(contentId, path);
@override @override
String toString() { String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, path=$path}';
return 'FavouriteRow{contentId=$contentId, path=$path}';
}
} }

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

View file

@ -1,5 +1,6 @@
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/coordinate_format.dart'; import 'package:aves/model/settings/coordinate_format.dart';
import 'package:aves/model/settings/entry_background.dart';
import 'package:aves/model/settings/home_page.dart'; import 'package:aves/model/settings/home_page.dart';
import 'package:aves/model/settings/screen_on.dart'; import 'package:aves/model/settings/screen_on.dart';
import 'package:aves/widgets/fullscreen/info/location_section.dart'; import 'package:aves/widgets/fullscreen/info/location_section.dart';
@ -54,7 +55,8 @@ class Settings extends ChangeNotifier {
static const coordinateFormatKey = 'coordinates_format'; static const coordinateFormatKey = 'coordinates_format';
// rendering // rendering
static const svgBackgroundKey = 'svg_background'; static const rasterBackgroundKey = 'raster_background';
static const vectorBackgroundKey = 'vector_background';
// search // search
static const saveSearchHistoryKey = 'save_search_history'; static const saveSearchHistoryKey = 'save_search_history';
@ -184,9 +186,13 @@ class Settings extends ChangeNotifier {
// rendering // rendering
int get svgBackground => _prefs.getInt(svgBackgroundKey) ?? 0xFFFFFFFF; EntryBackground get rasterBackground => getEnumOrDefault(rasterBackgroundKey, EntryBackground.transparent, EntryBackground.values);
set svgBackground(int newValue) => setAndNotify(svgBackgroundKey, newValue); set rasterBackground(EntryBackground newValue) => setAndNotify(rasterBackgroundKey, newValue.toString());
EntryBackground get vectorBackground => getEnumOrDefault(vectorBackgroundKey, EntryBackground.white, EntryBackground.values);
set vectorBackground(EntryBackground newValue) => setAndNotify(vectorBackgroundKey, newValue.toString());
// search // search

View file

@ -23,7 +23,7 @@ mixin TagMixin on SourceBase {
Future<void> catalogEntries() async { Future<void> catalogEntries() async {
// final stopwatch = Stopwatch()..start(); // final stopwatch = Stopwatch()..start();
final todo = rawEntries.where((entry) => !entry.isCatalogued && !entry.isSvg).toList(); final todo = rawEntries.where((entry) => !entry.isCatalogued).toList();
if (todo.isEmpty) return; if (todo.isEmpty) return;
var progressDone = 0; var progressDone = 0;

View file

@ -1,15 +1,17 @@
class MimeTypes { class MimeTypes {
static const anyImage = 'image/*'; static const anyImage = 'image/*';
static const bmp = 'image/bmp';
static const gif = 'image/gif'; static const gif = 'image/gif';
static const heic = 'image/heic'; static const heic = 'image/heic';
static const heif = 'image/heif'; static const heif = 'image/heif';
static const ico = 'image/x-icon';
static const jpeg = 'image/jpeg'; static const jpeg = 'image/jpeg';
static const png = 'image/png'; static const png = 'image/png';
static const svg = 'image/svg+xml'; static const svg = 'image/svg+xml';
static const tiff = 'image/tiff';
static const webp = 'image/webp'; static const webp = 'image/webp';
static const tiff = 'image/tiff';
static const psd = 'image/vnd.adobe.photoshop'; static const psd = 'image/vnd.adobe.photoshop';
static const arw = 'image/x-sony-arw'; static const arw = 'image/x-sony-arw';
@ -40,6 +42,10 @@ class MimeTypes {
static const mp4 = 'video/mp4'; static const mp4 = 'video/mp4';
// groups // groups
// formats that support transparency
static const List<String> alphaImages = [bmp, gif, ico, png, svg, tiff, webp];
static const List<String> rawImages = [arw, cr2, crw, dcr, dng, erf, k25, kdc, mrw, nef, nrw, orf, pef, raf, raw, rw2, sr2, srf, srw, x3f]; static const List<String> rawImages = [arw, cr2, crw, dcr, dng, erf, k25, kdc, mrw, nef, nrw, orf, pef, raf, raw, rw2, sr2, srf, srw, x3f];
static bool isImage(String mimeType) => mimeType.startsWith('image'); static bool isImage(String mimeType) => mimeType.startsWith('image');

View file

@ -15,6 +15,7 @@ class XMP {
'exifEX': 'Exif Ex', 'exifEX': 'Exif Ex',
'GettyImagesGIFT': 'Getty Images', 'GettyImagesGIFT': 'Getty Images',
'GIMP': 'GIMP', 'GIMP': 'GIMP',
'GCamera': 'Google Camera',
'GFocus': 'Google Focus', 'GFocus': 'Google Focus',
'GPano': 'Google Panorama', 'GPano': 'Google Panorama',
'illustrator': 'Illustrator', 'illustrator': 'Illustrator',

View file

@ -300,9 +300,7 @@ class ImageOpEvent {
int get hashCode => hashValues(success, uri); int get hashCode => hashValues(success, uri);
@override @override
String toString() { String toString() => '$runtimeType#${shortHash(this)}{success=$success, uri=$uri}';
return 'ImageOpEvent{success=$success, uri=$uri}';
}
} }
class MoveOpEvent extends ImageOpEvent { class MoveOpEvent extends ImageOpEvent {
@ -323,9 +321,7 @@ class MoveOpEvent extends ImageOpEvent {
} }
@override @override
String toString() { String toString() => '$runtimeType#${shortHash(this)}{success=$success, uri=$uri, newFields=$newFields}';
return 'MoveOpEvent{success=$success, uri=$uri, newFields=$newFields}';
}
} }
// cf flutter/foundation `consolidateHttpClientResponseBytes` // cf flutter/foundation `consolidateHttpClientResponseBytes`

View file

@ -10,7 +10,10 @@ class ServicePolicy {
final StreamController<QueueState> _queueStreamController = StreamController<QueueState>.broadcast(); final StreamController<QueueState> _queueStreamController = StreamController<QueueState>.broadcast();
final Map<Object, Tuple2<int, _Task>> _paused = {}; final Map<Object, Tuple2<int, _Task>> _paused = {};
final SplayTreeMap<int, Queue<_Task>> _queues = SplayTreeMap(); final SplayTreeMap<int, Queue<_Task>> _queues = SplayTreeMap();
_Task _running; final Queue<_Task> _runningQueue = Queue();
// magic number
static const concurrentTaskMax = 4;
Stream<QueueState> get queueStream => _queueStreamController.stream; Stream<QueueState> get queueStream => _queueStreamController.stream;
@ -23,6 +26,7 @@ class ServicePolicy {
Object key, Object key,
}) { }) {
_Task task; _Task task;
key ??= platformCall.hashCode;
final priorityTask = _paused.remove(key); final priorityTask = _paused.remove(key);
if (priorityTask != null) { if (priorityTask != null) {
debugPrint('resume task with key=$key'); debugPrint('resume task with key=$key');
@ -39,7 +43,7 @@ class ServicePolicy {
completer.completeError(error, stackTrace); completer.completeError(error, stackTrace);
} }
if (debugLabel != null) debugPrint('$runtimeType $debugLabel completed'); if (debugLabel != null) debugPrint('$runtimeType $debugLabel completed');
_running = null; _runningQueue.removeWhere((task) => task.key == key);
_pickNext(); _pickNext();
}, },
completer, completer,
@ -64,10 +68,13 @@ class ServicePolicy {
void _pickNext() { void _pickNext() {
_notifyQueueState(); _notifyQueueState();
if (_running != null) return; if (_runningQueue.length >= concurrentTaskMax) return;
final queue = _queues.entries.firstWhere((kv) => kv.value.isNotEmpty, orElse: () => null)?.value; final queue = _queues.entries.firstWhere((kv) => kv.value.isNotEmpty, orElse: () => null)?.value;
_running = queue?.removeFirst(); final task = queue?.removeFirst();
_running?.callback?.call(); if (task != null) {
_runningQueue.addLast(task);
task.callback();
}
} }
bool _takeOut(Object key, Iterable<int> priorities, void Function(int priority, _Task task) action) { bool _takeOut(Object key, Iterable<int> priorities, void Function(int priority, _Task task) action) {
@ -99,7 +106,7 @@ class ServicePolicy {
if (!_queueStreamController.hasListener) return; if (!_queueStreamController.hasListener) return;
final queueByPriority = Map.fromEntries(_queues.entries.map((kv) => MapEntry(kv.key, kv.value.length))); final queueByPriority = Map.fromEntries(_queues.entries.map((kv) => MapEntry(kv.key, kv.value.length)));
_queueStreamController.add(QueueState(queueByPriority)); _queueStreamController.add(QueueState(queueByPriority, _runningQueue.length));
} }
} }
@ -124,6 +131,7 @@ class ServiceCallPriority {
class QueueState { class QueueState {
final Map<int, int> queueByPriority; final Map<int, int> queueByPriority;
final int runningQueue;
const QueueState(this.queueByPriority); const QueueState(this.queueByPriority, this.runningQueue);
} }

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

View file

@ -239,12 +239,6 @@ class Constants {
licenseUrl: 'https://github.com/Baseflow/flutter-permission-handler/blob/develop/permission_handler/LICENSE', licenseUrl: 'https://github.com/Baseflow/flutter-permission-handler/blob/develop/permission_handler/LICENSE',
sourceUrl: 'https://github.com/Baseflow/flutter-permission-handler', sourceUrl: 'https://github.com/Baseflow/flutter-permission-handler',
), ),
Dependency(
name: 'Photo View',
license: 'MIT',
licenseUrl: 'https://github.com/renancaraujo/photo_view/blob/master/LICENSE',
sourceUrl: 'https://github.com/renancaraujo/photo_view',
),
Dependency( Dependency(
name: 'Printing', name: 'Printing',
license: 'Apache 2.0', license: 'Apache 2.0',

View file

@ -4,9 +4,9 @@ import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/services/viewer_service.dart'; import 'package:aves/services/viewer_service.dart';
import 'package:aves/widgets/collection/grid/list_known_extent.dart'; import 'package:aves/widgets/collection/grid/list_known_extent.dart';
import 'package:aves/widgets/collection/grid/list_section_layout.dart'; import 'package:aves/widgets/collection/grid/list_section_layout.dart';
import 'package:aves/widgets/common/scaling.dart';
import 'package:aves/widgets/collection/thumbnail/decorated.dart'; import 'package:aves/widgets/collection/thumbnail/decorated.dart';
import 'package:aves/widgets/common/behaviour/routes.dart'; import 'package:aves/widgets/common/behaviour/routes.dart';
import 'package:aves/widgets/common/scaling.dart';
import 'package:aves/widgets/fullscreen/fullscreen_page.dart'; import 'package:aves/widgets/fullscreen/fullscreen_page.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';

View file

@ -43,7 +43,7 @@ class DecoratedThumbnail extends StatelessWidget {
); );
child = Stack( child = Stack(
fit: StackFit.passthrough, alignment: Alignment.center,
children: [ children: [
child, child,
Positioned( Positioned(

View file

@ -1,6 +1,8 @@
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/image_providers/uri_picture_provider.dart'; import 'package:aves/image_providers/uri_picture_provider.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings/entry_background.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/widgets/common/fx/checkered_decoration.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -19,15 +21,36 @@ class ThumbnailVectorImage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final child = Container( final child = Selector<Settings, EntryBackground>(
// center `SvgPicture` inside `Container` with the thumbnail dimensions selector: (context, s) => s.vectorBackground,
// so that `SvgPicture` doesn't get aligned by the `Stack` like the overlay icons builder: (context, background, child) {
width: extent, const fit = BoxFit.contain;
height: extent, if (background == EntryBackground.checkered) {
child: Selector<Settings, int>( return LayoutBuilder(
selector: (context, s) => s.svgBackground, builder: (context, constraints) {
builder: (context, svgBackground, child) { final availableSize = constraints.biggest;
final colorFilter = ColorFilter.mode(Color(svgBackground), BlendMode.dstOver); final fitSize = applyBoxFit(fit, entry.displaySize, availableSize).destination;
final offset = fitSize / 2 - availableSize / 2;
final child = DecoratedBox(
decoration: CheckeredDecoration(checkSize: extent / 8, offset: offset),
child: SvgPicture(
UriPicture(
uri: entry.uri,
mimeType: entry.mimeType,
),
width: fitSize.width,
height: fitSize.height,
fit: fit,
),
);
// the thumbnail is centered for correct decoration sizing
// when constraints are tight during hero animation
return constraints.isTight ? Center(child: child) : child;
},
);
}
final colorFilter = background.isColor ? ColorFilter.mode(background.color, BlendMode.dstOver) : null;
return SvgPicture( return SvgPicture(
UriPicture( UriPicture(
uri: entry.uri, uri: entry.uri,
@ -36,9 +59,9 @@ class ThumbnailVectorImage extends StatelessWidget {
), ),
width: extent, width: extent,
height: extent, height: extent,
fit: fit,
); );
}, },
),
); );
return heroTag == null return heroTag == null
? child ? child

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

View file

@ -6,15 +6,15 @@ class HighlightDecoration extends Decoration {
const HighlightDecoration({@required this.color}); const HighlightDecoration({@required this.color});
@override @override
HighlightDecorationPainter createBoxPainter([VoidCallback onChanged]) { _HighlightDecorationPainter createBoxPainter([VoidCallback onChanged]) {
return HighlightDecorationPainter(this, onChanged); return _HighlightDecorationPainter(this, onChanged);
} }
} }
class HighlightDecorationPainter extends BoxPainter { class _HighlightDecorationPainter extends BoxPainter {
final HighlightDecoration decoration; final HighlightDecoration decoration;
const HighlightDecorationPainter(this.decoration, VoidCallback onChanged) : super(onChanged); const _HighlightDecorationPainter(this.decoration, VoidCallback onChanged) : super(onChanged);
@override @override
void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) { void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) {

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

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

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

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

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

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

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

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

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

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

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

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

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

View file

@ -20,7 +20,12 @@ class DebugTaskQueueOverlay extends StatelessWidget {
stream: servicePolicy.queueStream, stream: servicePolicy.queueStream,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasError) return SizedBox.shrink(); if (snapshot.hasError) return SizedBox.shrink();
final queuedEntries = (snapshot.hasData ? snapshot.data.queueByPriority.entries.toList() : []); final queuedEntries = <MapEntry<dynamic, int>>[];
if (snapshot.hasData) {
final state = snapshot.data;
queuedEntries.add(MapEntry('run', state.runningQueue));
queuedEntries.addAll(state.queueByPriority.entries.map((kv) => MapEntry(kv.key.toString(), kv.value)));
}
queuedEntries.sort((a, b) => a.key.compareTo(b.key)); queuedEntries.sort((a, b) => a.key.compareTo(b.key));
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,

View file

@ -177,9 +177,9 @@ class _AlbumFilterBarState extends State<AlbumFilterBar> {
), ),
ConstrainedBox( ConstrainedBox(
constraints: BoxConstraints(minWidth: 16), constraints: BoxConstraints(minWidth: 16),
child: AnimatedBuilder( child: ValueListenableBuilder<TextEditingValue>(
animation: _controller, valueListenable: _controller,
builder: (context, child) => AnimatedSwitcher( builder: (context, value, child) => AnimatedSwitcher(
duration: Durations.appBarActionChangeAnimation, duration: Durations.appBarActionChangeAnimation,
transitionBuilder: (child, animation) => FadeTransition( transitionBuilder: (child, animation) => FadeTransition(
opacity: animation, opacity: animation,
@ -189,7 +189,7 @@ class _AlbumFilterBarState extends State<AlbumFilterBar> {
child: child, child: child,
), ),
), ),
child: _controller.text.isNotEmpty ? clearButton : SizedBox.shrink(), child: value.text.isNotEmpty ? clearButton : SizedBox.shrink(),
), ),
), ),
) )

View file

@ -15,8 +15,6 @@ import 'package:aves/widgets/fullscreen/source_viewer_page.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pdf; import 'package:pdf/widgets.dart' as pdf;
import 'package:pedantic/pedantic.dart'; import 'package:pedantic/pedantic.dart';
import 'package:printing/printing.dart'; import 'package:printing/printing.dart';
@ -100,34 +98,32 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
final documentName = entry.bestTitle ?? 'Aves'; final documentName = entry.bestTitle ?? 'Aves';
final doc = pdf.Document(title: documentName); final doc = pdf.Document(title: documentName);
PdfImage pdfImage; pdf.Widget pdfChild;
if (entry.isSvg) { if (entry.isSvg) {
final bytes = await ImageFileService.getImage(uri, mimeType, entry.rotationDegrees, entry.isFlipped); final bytes = await ImageFileService.getImage(uri, mimeType, entry.rotationDegrees, entry.isFlipped);
if (bytes != null && bytes.isNotEmpty) { if (bytes != null && bytes.isNotEmpty) {
final svgRoot = await svg.fromSvgBytes(bytes, uri); pdfChild = pdf.SvgImage(svg: utf8.decode(bytes));
final viewBox = svgRoot.viewport.viewBox;
// 1000 is arbitrary, but large enough to look ok in the print preview
final targetSize = viewBox * 1000 / viewBox.longestSide;
final picture = svgRoot.toPicture(size: targetSize);
final uiImage = await picture.toImage(targetSize.width.ceil(), targetSize.height.ceil());
pdfImage = await pdfImageFromImage(
pdf: doc.document,
image: uiImage,
);
} }
} else { } else {
pdfImage = await pdfImageFromImageProvider( pdfChild = pdf.Image.provider(await flutterImageProvider(
pdf: doc.document, UriImage(
image: UriImage(
uri: uri, uri: uri,
mimeType: mimeType, mimeType: mimeType,
rotationDegrees: rotationDegrees, rotationDegrees: rotationDegrees,
isFlipped: isFlipped, isFlipped: isFlipped,
), ),
); ));
} }
if (pdfImage != null) { if (pdfChild != null) {
doc.addPage(pdf.Page(build: (context) => pdf.Center(child: pdf.Image(pdfImage)))); // Page doc.addPage(pdf.Page(
orientation: entry.isPortrait ? pdf.PageOrientation.portrait : pdf.PageOrientation.landscape,
build: (context) => pdf.FullPage(
ignoreMargins: true,
child: pdf.Center(
child: pdfChild,
),
),
)); // Page
unawaited(Printing.layoutPdf( unawaited(Printing.layoutPdf(
onLayout: (format) => doc.save(), onLayout: (format) => doc.save(),
name: documentName, name: documentName,

View file

@ -8,6 +8,7 @@ import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/change_notifier.dart';
import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart';
import 'package:aves/widgets/fullscreen/entry_action_delegate.dart'; import 'package:aves/widgets/fullscreen/entry_action_delegate.dart';
import 'package:aves/widgets/fullscreen/image_page.dart'; import 'package:aves/widgets/fullscreen/image_page.dart';
import 'package:aves/widgets/fullscreen/image_view.dart'; import 'package:aves/widgets/fullscreen/image_view.dart';
@ -22,7 +23,6 @@ import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart'; import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
import 'package:photo_view/photo_view.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:screen/screen.dart'; import 'package:screen/screen.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
@ -557,7 +557,7 @@ class _FullscreenVerticalPageViewState extends State<FullscreenVerticalPageView>
key: Key('vertical-pageview'), key: Key('vertical-pageview'),
scrollDirection: Axis.vertical, scrollDirection: Axis.vertical,
controller: widget.verticalPager, controller: widget.verticalPager,
physics: PhotoViewPageViewScrollPhysics(parent: PageScrollPhysics()), physics: MagnifierScrollerPhysics(parent: PageScrollPhysics()),
onPageChanged: (page) { onPageChanged: (page) {
widget.onVerticalPageChanged(page); widget.onVerticalPageChanged(page);
_infoPageVisibleNotifier.value = page == pages.length - 1; _infoPageVisibleNotifier.value = page == pages.length - 1;

View file

@ -1,9 +1,10 @@
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/common/magnifier/pan/gesture_detector_scope.dart';
import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart';
import 'package:aves/widgets/fullscreen/image_view.dart'; import 'package:aves/widgets/fullscreen/image_view.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart'; import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
import 'package:photo_view/photo_view.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
class MultiImagePage extends StatefulWidget { class MultiImagePage extends StatefulWidget {
@ -34,13 +35,13 @@ class MultiImagePageState extends State<MultiImagePage> with AutomaticKeepAliveC
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context); super.build(context);
return PhotoViewGestureDetectorScope( return MagnifierGestureDetectorScope(
axis: [Axis.horizontal, Axis.vertical], axis: [Axis.horizontal, Axis.vertical],
child: PageView.builder( child: PageView.builder(
key: Key('horizontal-pageview'), key: Key('horizontal-pageview'),
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
controller: widget.pageController, controller: widget.pageController,
physics: PhotoViewPageViewScrollPhysics(parent: BouncingScrollPhysics()), physics: MagnifierScrollerPhysics(parent: BouncingScrollPhysics()),
onPageChanged: widget.onPageChanged, onPageChanged: widget.onPageChanged,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final entry = entries[index]; final entry = entries[index];
@ -49,7 +50,7 @@ class MultiImagePageState extends State<MultiImagePage> with AutomaticKeepAliveC
key: Key('imageview'), key: Key('imageview'),
entry: entry, entry: entry,
heroTag: widget.collection.heroTag(entry), heroTag: widget.collection.heroTag(entry),
onTap: widget.onTap, onTap: (_) => widget.onTap?.call(),
videoControllers: widget.videoControllers, videoControllers: widget.videoControllers,
onDisposed: () => widget.onViewDisposed?.call(entry.uri), onDisposed: () => widget.onViewDisposed?.call(entry.uri),
), ),
@ -84,11 +85,11 @@ class SingleImagePageState extends State<SingleImagePage> with AutomaticKeepAliv
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context); super.build(context);
return PhotoViewGestureDetectorScope( return MagnifierGestureDetectorScope(
axis: [Axis.vertical], axis: [Axis.vertical],
child: ImageView( child: ImageView(
entry: widget.entry, entry: widget.entry,
onTap: widget.onTap, onTap: (_) => widget.onTap?.call(),
videoControllers: widget.videoControllers, videoControllers: widget.videoControllers,
), ),
); );

View file

@ -1,29 +1,35 @@
import 'dart:async'; import 'dart:async';
import 'package:aves/image_providers/thumbnail_provider.dart';
import 'package:aves/image_providers/uri_image_provider.dart';
import 'package:aves/image_providers/uri_picture_provider.dart'; import 'package:aves/image_providers/uri_picture_provider.dart';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings/entry_background.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/collection/empty.dart'; import 'package:aves/widgets/collection/empty.dart';
import 'package:aves/widgets/common/fx/checkered_decoration.dart';
import 'package:aves/widgets/common/magnifier/controller/controller.dart';
import 'package:aves/widgets/common/magnifier/controller/state.dart';
import 'package:aves/widgets/common/magnifier/magnifier.dart';
import 'package:aves/widgets/common/magnifier/scale/scale_boundaries.dart';
import 'package:aves/widgets/common/magnifier/scale/scale_level.dart';
import 'package:aves/widgets/common/magnifier/scale/state.dart';
import 'package:aves/widgets/fullscreen/tiled_view.dart'; import 'package:aves/widgets/fullscreen/tiled_view.dart';
import 'package:aves/widgets/fullscreen/video_view.dart'; import 'package:aves/widgets/fullscreen/video_view.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart'; import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:photo_view/photo_view.dart';
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
class ImageView extends StatefulWidget { class ImageView extends StatefulWidget {
final ImageEntry entry; final ImageEntry entry;
final Object heroTag; final Object heroTag;
final VoidCallback onTap; final MagnifierTapCallback onTap;
final List<Tuple2<String, IjkMediaController>> videoControllers; final List<Tuple2<String, IjkMediaController>> videoControllers;
final VoidCallback onDisposed; final VoidCallback onDisposed;
static const decorationCheckSize = 20.0;
const ImageView({ const ImageView({
Key key, Key key,
@required this.entry, @required this.entry,
@ -38,32 +44,30 @@ class ImageView extends StatefulWidget {
} }
class _ImageViewState extends State<ImageView> { class _ImageViewState extends State<ImageView> {
final PhotoViewController _photoViewController = PhotoViewController(); final MagnifierController _magnifierController = MagnifierController();
final PhotoViewScaleStateController _photoViewScaleStateController = PhotoViewScaleStateController();
final ValueNotifier<ViewState> _viewStateNotifier = ValueNotifier(ViewState.zero); final ValueNotifier<ViewState> _viewStateNotifier = ValueNotifier(ViewState.zero);
StreamSubscription<PhotoViewControllerValue> _subscription; final List<StreamSubscription> _subscriptions = [];
Size _photoViewChildSize;
static const backgroundDecoration = BoxDecoration(color: Colors.transparent); static const initialScale = ScaleLevel(ref: ScaleReference.contained);
static const maxScale = 2.0; static const minScale = ScaleLevel(ref: ScaleReference.contained);
static const maxScale = ScaleLevel(factor: 2.0);
ImageEntry get entry => widget.entry; ImageEntry get entry => widget.entry;
VoidCallback get onTap => widget.onTap; MagnifierTapCallback get onTap => widget.onTap;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_subscription = _photoViewController.outputStateStream.listen(_onViewChanged); _subscriptions.add(_magnifierController.stateStream.listen(_onViewStateChanged));
if (entry.isVideo || (!entry.isSvg && entry.canDecode && useTile)) { _subscriptions.add(_magnifierController.scaleBoundariesStream.listen(_onViewScaleBoundariesChanged));
_photoViewChildSize = entry.displaySize;
}
} }
@override @override
void dispose() { void dispose() {
_subscription.cancel(); _subscriptions
_subscription = null; ..forEach((sub) => sub.cancel())
..clear();
widget.onDisposed?.call(); widget.onDisposed?.call();
super.dispose(); super.dispose();
} }
@ -78,19 +82,9 @@ class _ImageViewState extends State<ImageView> {
} else if (entry.isSvg) { } else if (entry.isSvg) {
child = _buildSvgView(); child = _buildSvgView();
} else if (entry.canDecode) { } else if (entry.canDecode) {
if (useTile) { child = _buildRasterView();
child = _buildTiledImageView();
} else {
child = _buildImageView();
} }
} child ??= ErrorChild(onTap: () => onTap?.call(null));
child ??= _buildError();
// if the hero tag is defined in the `loadingBuilder` and also set by the `heroAttributes`,
// the route transition becomes visible if the final image is loaded before the hero animation is done.
// if the hero tag wraps the whole `PhotoView` and the `loadingBuilder` is not provided,
// there's a black frame between the hero animation and the final image, even when it's cached.
// no hero for videos, as a typical video first frame is different from its thumbnail // no hero for videos, as a typical video first frame is different from its thumbnail
return widget.heroTag != null && !entry.isVideo return widget.heroTag != null && !entry.isVideo
@ -102,103 +96,30 @@ class _ImageViewState extends State<ImageView> {
: child; : child;
} }
// the images loaded by `PhotoView` cannot have a width or height larger than 8192 Widget _buildRasterView() {
// so the reported offset and scale does not match expected values derived from the original dimensions return Magnifier(
// besides, large images should be tiled to be memory-friendly
bool get useTile => entry.canTile && (entry.width > 4096 || entry.height > 4096);
ImageProvider get fastThumbnailProvider => ThumbnailProvider(ThumbnailProviderKey.fromEntry(entry));
// this loading builder shows a transition image until the final image is ready
// if the image is already in the cache it will show the final image, otherwise the thumbnail
// in any case, we should use `Center` + `AspectRatio` + `BoxFit.fill` so that the transition image
// appears as the final image with `PhotoViewComputedScale.contained` for `initialScale`
Widget _loadingBuilder(BuildContext context, ImageProvider imageProvider) {
return Center(
child: AspectRatio(
// enforce original aspect ratio, as some thumbnails aspect ratios slightly differ
aspectRatio: entry.displayAspectRatio,
child: Image(
image: imageProvider,
fit: BoxFit.fill,
),
),
);
}
Widget _buildImageView() {
final uriImage = UriImage(
uri: entry.uri,
mimeType: entry.mimeType,
rotationDegrees: entry.rotationDegrees,
isFlipped: entry.isFlipped,
expectedContentLength: entry.sizeBytes,
);
return PhotoView(
// key includes size and orientation to refresh when the image is rotated // key includes size and orientation to refresh when the image is rotated
key: ValueKey('${entry.rotationDegrees}_${entry.isFlipped}_${entry.width}_${entry.height}_${entry.path}'), key: ValueKey('${entry.rotationDegrees}_${entry.isFlipped}_${entry.width}_${entry.height}_${entry.path}'),
imageProvider: uriImage, child: TiledImageView(
// when the full image is ready, we use it in the `loadingBuilder`
// we still provide a `loadingBuilder` in that case to avoid a black frame after hero animation
loadingBuilder: (context, event) => _loadingBuilder(
context,
imageCache.statusForKey(uriImage).keepAlive ? uriImage : fastThumbnailProvider,
),
loadFailedChild: _buildError(),
backgroundDecoration: backgroundDecoration,
imageSizedCallback: (size) {
// do not directly update the `ViewState` notifier as this callback is called during build
_photoViewChildSize = size;
},
controller: _photoViewController,
maxScale: maxScale,
minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained,
onTapUp: (tapContext, details, value) => onTap?.call(),
filterQuality: FilterQuality.low,
);
}
Widget _buildTiledImageView() {
return PhotoView.customChild(
// key includes size and orientation to refresh when the image is rotated
key: ValueKey('${entry.rotationDegrees}_${entry.isFlipped}_${entry.width}_${entry.height}_${entry.path}'),
child: Selector<MediaQueryData, Size>(
selector: (context, mq) => mq.size,
builder: (context, mqSize, child) {
// When the scale state is cycled to be in its `initial` state (i.e. `contained`), and the device is rotated,
// `PhotoView` keeps the scale state as `contained`, but the controller does not update or notify the new scale value.
// We cannot use `scaleStateChangedCallback` as a workaround, because the scale state is updated before animating the scale change,
// so we keep receiving scale updates after the scale state update.
// Instead we check the scale state here when the constraints change, so we can reset the obsolete scale value.
if (_photoViewScaleStateController.scaleState == PhotoViewScaleState.initial) {
final value = PhotoViewControllerValue(position: Offset.zero, scale: 0, rotation: 0, rotationFocusPoint: null);
WidgetsBinding.instance.addPostFrameCallback((_) => _onViewChanged(value));
}
return TiledImageView(
entry: entry, entry: entry,
viewportSize: mqSize,
viewStateNotifier: _viewStateNotifier, viewStateNotifier: _viewStateNotifier,
baseChild: _loadingBuilder(context, fastThumbnailProvider), errorBuilder: (context, error, stackTrace) => ErrorChild(onTap: () => onTap?.call(null)),
errorBuilder: (context, error, stackTrace) => _buildError(),
);
},
), ),
childSize: entry.displaySize, childSize: entry.displaySize,
backgroundDecoration: backgroundDecoration, controller: _magnifierController,
controller: _photoViewController,
scaleStateController: _photoViewScaleStateController,
maxScale: maxScale, maxScale: maxScale,
minScale: PhotoViewComputedScale.contained, minScale: minScale,
initialScale: PhotoViewComputedScale.contained, initialScale: initialScale,
onTapUp: (tapContext, details, value) => onTap?.call(), onTap: (c, d, s, childPosition) => onTap?.call(childPosition),
filterQuality: FilterQuality.low, applyScale: false,
); );
} }
Widget _buildSvgView() { Widget _buildSvgView() {
final colorFilter = ColorFilter.mode(Color(settings.svgBackground), BlendMode.dstOver); final background = settings.vectorBackground;
return PhotoView.customChild( final colorFilter = background.isColor ? ColorFilter.mode(background.color, BlendMode.dstOver) : null;
Widget child = Magnifier(
child: SvgPicture( child: SvgPicture(
UriPicture( UriPicture(
uri: entry.uri, uri: entry.uri,
@ -206,17 +127,54 @@ class _ImageViewState extends State<ImageView> {
colorFilter: colorFilter, colorFilter: colorFilter,
), ),
), ),
backgroundDecoration: backgroundDecoration, childSize: entry.displaySize,
controller: _photoViewController, controller: _magnifierController,
minScale: PhotoViewComputedScale.contained, minScale: minScale,
initialScale: PhotoViewComputedScale.contained, initialScale: initialScale,
onTapUp: (tapContext, details, value) => onTap?.call(), scaleStateCycle: _vectorScaleStateCycle,
onTap: (c, d, s, childPosition) => onTap?.call(childPosition),
); );
if (background == EntryBackground.checkered) {
child = ValueListenableBuilder<ViewState>(
valueListenable: _viewStateNotifier,
builder: (context, viewState, child) {
final viewportSize = viewState.viewportSize;
if (viewportSize == null) return child;
final side = viewportSize.shortestSide;
final checkSize = side / ((side / ImageView.decorationCheckSize).round());
final viewSize = entry.displaySize * viewState.scale;
final decorationSize = applyBoxFit(BoxFit.none, viewSize, viewportSize).source;
final offset = ((decorationSize - viewportSize) as Offset) / 2;
return Stack(
alignment: Alignment.center,
children: [
Positioned(
width: decorationSize.width,
height: decorationSize.height,
child: DecoratedBox(
decoration: CheckeredDecoration(
checkSize: checkSize,
offset: offset,
),
),
),
child,
],
);
},
child: child,
);
}
return child;
} }
Widget _buildVideoView() { Widget _buildVideoView() {
final videoController = widget.videoControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2; final videoController = widget.videoControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2;
return PhotoView.customChild( return Magnifier(
child: videoController != null child: videoController != null
? AvesVideo( ? AvesVideo(
entry: entry, entry: entry,
@ -224,16 +182,69 @@ class _ImageViewState extends State<ImageView> {
) )
: SizedBox(), : SizedBox(),
childSize: entry.displaySize, childSize: entry.displaySize,
backgroundDecoration: backgroundDecoration, controller: _magnifierController,
controller: _photoViewController,
maxScale: maxScale, maxScale: maxScale,
minScale: PhotoViewComputedScale.contained, minScale: minScale,
initialScale: PhotoViewComputedScale.contained, initialScale: initialScale,
onTapUp: (tapContext, details, value) => onTap?.call(), onTap: (c, d, s, childPosition) => onTap?.call(childPosition),
); );
} }
Widget _buildError() => GestureDetector( void _onViewStateChanged(MagnifierState v) {
final current = _viewStateNotifier.value;
final viewState = ViewState(v.position, v.scale, current.viewportSize);
_viewStateNotifier.value = viewState;
ViewStateNotification(entry.uri, viewState).dispatch(context);
}
void _onViewScaleBoundariesChanged(ScaleBoundaries v) {
final current = _viewStateNotifier.value;
final viewState = ViewState(current.position, current.scale, v.viewportSize);
_viewStateNotifier.value = viewState;
ViewStateNotification(entry.uri, viewState).dispatch(context);
}
static ScaleState _vectorScaleStateCycle(ScaleState actual) {
switch (actual) {
case ScaleState.initial:
return ScaleState.covering;
default:
return ScaleState.initial;
}
}
}
class ViewState {
final Offset position;
final double scale;
final Size viewportSize;
static const ViewState zero = ViewState(Offset.zero, 0, null);
const ViewState(this.position, this.scale, this.viewportSize);
@override
String toString() => '$runtimeType#${shortHash(this)}{position=$position, scale=$scale, viewportSize=$viewportSize}';
}
class ViewStateNotification extends Notification {
final String uri;
final ViewState viewState;
const ViewStateNotification(this.uri, this.viewState);
@override
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, viewState=$viewState}';
}
class ErrorChild extends StatelessWidget {
final VoidCallback onTap;
const ErrorChild({@required this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => onTap?.call(), onTap: () => onTap?.call(),
// use a `Container` with a dummy color to make it expand // use a `Container` with a dummy color to make it expand
// so that we can also detect taps around the title `Text` // so that we can also detect taps around the title `Text`
@ -246,37 +257,7 @@ class _ImageViewState extends State<ImageView> {
), ),
), ),
); );
void _onViewChanged(PhotoViewControllerValue v) {
final viewState = ViewState(v.position, v.scale, _photoViewChildSize);
_viewStateNotifier.value = viewState;
ViewStateNotification(entry.uri, viewState).dispatch(context);
} }
} }
class ViewState { typedef MagnifierTapCallback = void Function(Offset childPosition);
final Offset position;
final double scale;
final Size size;
static const ViewState zero = ViewState(Offset(0.0, 0.0), 0, null);
const ViewState(this.position, this.scale, this.size);
@override
String toString() {
return '$runtimeType#${shortHash(this)}{position=$position, scale=$scale, size=$size}';
}
}
class ViewStateNotification extends Notification {
final String uri;
final ViewState viewState;
const ViewStateNotification(this.uri, this.viewState);
@override
String toString() {
return '$runtimeType#${shortHash(this)}{uri=$uri, viewState=$viewState}';
}
}

View file

@ -3,6 +3,7 @@ import 'dart:collection';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/ref/brand_colors.dart'; import 'package:aves/ref/brand_colors.dart';
import 'package:aves/services/metadata_service.dart'; import 'package:aves/services/metadata_service.dart';
import 'package:aves/services/svg_metadata_service.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/utils/color_utils.dart'; import 'package:aves/utils/color_utils.dart';
@ -10,8 +11,8 @@ import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/fullscreen/info/common.dart'; import 'package:aves/widgets/fullscreen/info/common.dart';
import 'package:aves/widgets/fullscreen/info/metadata/metadata_thumbnail.dart'; import 'package:aves/widgets/fullscreen/info/metadata/metadata_thumbnail.dart';
import 'package:aves/widgets/fullscreen/info/metadata/svg_tile.dart';
import 'package:aves/widgets/fullscreen/info/metadata/xmp_tile.dart'; import 'package:aves/widgets/fullscreen/info/metadata/xmp_tile.dart';
import 'package:aves/widgets/fullscreen/source_viewer_page.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -91,9 +92,9 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
// cancel notification bubbling so that the info page // cancel notification bubbling so that the info page
// does not misinterpret content scrolling for page scrolling // does not misinterpret content scrolling for page scrolling
onNotification: (notification) => true, onNotification: (notification) => true,
child: AnimatedBuilder( child: ValueListenableBuilder<String>(
animation: _loadedMetadataUri, valueListenable: _loadedMetadataUri,
builder: (context, child) { builder: (context, uri, child) {
Widget content; Widget content;
if (_metadata.isEmpty) { if (_metadata.isEmpty) {
content = SizedBox.shrink(); content = SizedBox.shrink();
@ -118,7 +119,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
return AnimationLimiter( return AnimationLimiter(
// we update the limiter key after fetching the metadata of a new entry, // we update the limiter key after fetching the metadata of a new entry,
// in order to restart the staggered animation of the metadata section // in order to restart the staggered animation of the metadata section
key: Key(_loadedMetadataUri.value), key: Key(uri),
child: content, child: content,
); );
}, },
@ -175,7 +176,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
child: InfoRowGroup( child: InfoRowGroup(
dir.tags, dir.tags,
maxValueLength: Constants.infoGroupMaxValueLength, maxValueLength: Constants.infoGroupMaxValueLength,
linkHandlers: dirName == SvgMetadata.metadataDirectory ? SvgMetadata.getLinkHandlers(dir.tags) : null, linkHandlers: dirName == SvgMetadataService.metadataDirectory ? getSvgLinkHandlers(dir.tags) : null,
), ),
), ),
], ],
@ -192,7 +193,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
if (entry == null) return; if (entry == null) return;
if (_loadedMetadataUri.value == entry.uri) return; if (_loadedMetadataUri.value == entry.uri) return;
if (isVisible) { if (isVisible) {
final rawMetadata = await (entry.isSvg ? SvgMetadata.getAllMetadata(entry) : MetadataService.getAllMetadata(entry)) ?? {}; final rawMetadata = await (entry.isSvg ? SvgMetadataService.getAllMetadata(entry) : MetadataService.getAllMetadata(entry)) ?? {};
final directories = rawMetadata.entries.map((dirKV) { final directories = rawMetadata.entries.map((dirKV) {
var directoryName = dirKV.key as String ?? ''; var directoryName = dirKV.key as String ?? '';
@ -230,6 +231,25 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
_expandedDirectoryNotifier.value = null; _expandedDirectoryNotifier.value = null;
} }
static Map<String, InfoLinkHandler> getSvgLinkHandlers(SplayTreeMap<String, String> tags) {
return {
'Metadata': InfoLinkHandler(
linkText: 'View XML',
onTap: (context) {
Navigator.push(
context,
MaterialPageRoute(
settings: RouteSettings(name: SourceViewerPage.routeName),
builder: (context) => SourceViewerPage(
loader: () => SynchronousFuture(tags['Metadata']),
),
),
);
},
),
};
}
@override @override
bool get wantKeepAlive => true; bool get wantKeepAlive => true;
} }

View file

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

View file

@ -95,9 +95,7 @@ class XmpNamespace {
int get hashCode => namespace.hashCode; int get hashCode => namespace.hashCode;
@override @override
String toString() { String toString() => '$runtimeType#${shortHash(this)}{namespace=$namespace}';
return '$runtimeType#${shortHash(this)}{namespace=$namespace}';
}
} }
class XmpProp { class XmpProp {
@ -116,9 +114,7 @@ class XmpProp {
} }
@override @override
String toString() { String toString() => '$runtimeType#${shortHash(this)}{path=$path, value=$value}';
return '$runtimeType#${shortHash(this)}{path=$path, value=$value}';
}
} }
class OpenEmbeddedDataNotification extends Notification { class OpenEmbeddedDataNotification extends Notification {
@ -131,7 +127,5 @@ class OpenEmbeddedDataNotification extends Notification {
}); });
@override @override
String toString() { String toString() => '$runtimeType#${shortHash(this)}{propPath=$propPath, mimeType=$mimeType}';
return '$runtimeType#${shortHash(this)}{propPath=$propPath, mimeType=$mimeType}';
}
} }

View file

@ -278,7 +278,7 @@ class _DateRow extends StatelessWidget {
DecoratedIcon(AIcons.date, shadows: [Constants.embossShadow], size: _iconSize), DecoratedIcon(AIcons.date, shadows: [Constants.embossShadow], size: _iconSize),
SizedBox(width: _iconPadding), SizedBox(width: _iconPadding),
Expanded(flex: 3, child: Text(dateText, strutStyle: Constants.overflowStrutStyle)), Expanded(flex: 3, child: Text(dateText, strutStyle: Constants.overflowStrutStyle)),
if (!entry.isSvg) Expanded(flex: 2, child: Text(entry.resolutionText, strutStyle: Constants.overflowStrutStyle)), Expanded(flex: 2, child: Text(entry.isSvg ? entry.aspectRatioText : entry.resolutionText, strutStyle: Constants.overflowStrutStyle)),
], ],
); );
} }

View file

@ -4,7 +4,6 @@ import 'package:aves/model/image_entry.dart';
import 'package:aves/widgets/fullscreen/image_view.dart'; import 'package:aves/widgets/fullscreen/image_view.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class Minimap extends StatelessWidget { class Minimap extends StatelessWidget {
final ImageEntry entry; final ImageEntry entry;
@ -21,25 +20,24 @@ class Minimap extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Selector<MediaQueryData, Size>( return IgnorePointer(
selector: (context, mq) => mq.size, child: ValueListenableBuilder<ViewState>(
builder: (context, mqSize, child) { valueListenable: viewStateNotifier,
return AnimatedBuilder( builder: (context, viewState, child) {
animation: viewStateNotifier, final viewportSize = viewState.viewportSize;
builder: (context, child) { if (viewportSize == null) return SizedBox.shrink();
final viewState = viewStateNotifier.value;
return CustomPaint( return CustomPaint(
painter: MinimapPainter( painter: MinimapPainter(
viewportSize: mqSize, viewportSize: viewportSize,
entrySize: viewState.size ?? entry.displaySize, entrySize: entry.displaySize,
viewCenterOffset: viewState.position, viewCenterOffset: viewState.position,
viewScale: viewState.scale, viewScale: viewState.scale,
minimapBorderColor: Colors.white30, minimapBorderColor: Colors.white30,
), ),
size: size, size: size,
); );
}); }),
}); );
} }
} }

View file

@ -1,24 +1,26 @@
import 'dart:math'; import 'dart:math';
import 'package:aves/model/image_entry.dart';
import 'package:aves/utils/math_utils.dart';
import 'package:aves/image_providers/region_provider.dart'; import 'package:aves/image_providers/region_provider.dart';
import 'package:aves/image_providers/thumbnail_provider.dart';
import 'package:aves/image_providers/uri_image_provider.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings/entry_background.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/utils/math_utils.dart';
import 'package:aves/widgets/common/fx/checkered_decoration.dart';
import 'package:aves/widgets/fullscreen/image_view.dart'; import 'package:aves/widgets/fullscreen/image_view.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:tuple/tuple.dart';
class TiledImageView extends StatefulWidget { class TiledImageView extends StatefulWidget {
final ImageEntry entry; final ImageEntry entry;
final Size viewportSize;
final ValueNotifier<ViewState> viewStateNotifier; final ValueNotifier<ViewState> viewStateNotifier;
final Widget baseChild;
final ImageErrorWidgetBuilder errorBuilder; final ImageErrorWidgetBuilder errorBuilder;
const TiledImageView({ const TiledImageView({
@required this.entry, @required this.entry,
@required this.viewportSize,
@required this.viewStateNotifier, @required this.viewStateNotifier,
@required this.baseChild,
@required this.errorBuilder, @required this.errorBuilder,
}); });
@ -27,86 +29,274 @@ class TiledImageView extends StatefulWidget {
} }
class _TiledImageViewState extends State<TiledImageView> { class _TiledImageViewState extends State<TiledImageView> {
double _tileSide, _initialScale; bool _isTilingInitialized = false;
int _maxSampleSize; int _maxSampleSize;
Matrix4 _transform; double _tileSide;
Matrix4 _tileTransform;
ImageStream _fullImageStream;
ImageStreamListener _fullImageListener;
final ValueNotifier<bool> _fullImageLoaded = ValueNotifier(false);
ImageEntry get entry => widget.entry; ImageEntry get entry => widget.entry;
Size get viewportSize => widget.viewportSize;
ValueNotifier<ViewState> get viewStateNotifier => widget.viewStateNotifier; ValueNotifier<ViewState> get viewStateNotifier => widget.viewStateNotifier;
bool get useBackground => entry.canHaveAlpha && settings.rasterBackground != EntryBackground.transparent;
bool get useTiles => entry.canTile && (entry.width > 4096 || entry.height > 4096);
ImageProvider get thumbnailProvider => ThumbnailProvider(ThumbnailProviderKey.fromEntry(entry));
ImageProvider get fullImageProvider {
if (useTiles) {
assert(_isTilingInitialized);
final displayWidth = entry.displaySize.width.round();
final displayHeight = entry.displaySize.height.round();
final viewState = viewStateNotifier.value;
final regionRect = _getTileRects(
x: 0,
y: 0,
layerRegionWidth: displayWidth,
layerRegionHeight: displayHeight,
displayWidth: displayWidth,
displayHeight: displayHeight,
scale: viewState.scale,
viewRect: _getViewRect(viewState, displayWidth, displayHeight),
).item2;
return RegionProvider(RegionProviderKey.fromEntry(
entry,
sampleSize: _maxSampleSize,
rect: regionRect,
));
} else {
return UriImage(
uri: entry.uri,
mimeType: entry.mimeType,
rotationDegrees: entry.rotationDegrees,
isFlipped: entry.isFlipped,
expectedContentLength: entry.sizeBytes,
);
}
}
// magic number used to derive sample size from scale // magic number used to derive sample size from scale
static const scaleFactor = 2.0; static const scaleFactor = 2.0;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_init(); _fullImageListener = ImageStreamListener(_onFullImageCompleted);
if (!useTiles) _registerFullImage();
} }
@override @override
void didUpdateWidget(TiledImageView oldWidget) { void didUpdateWidget(TiledImageView oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.viewportSize != widget.viewportSize || oldWidget.entry.displaySize != widget.entry.displaySize) { final oldViewState = oldWidget.viewStateNotifier.value;
_init(); final viewState = widget.viewStateNotifier.value;
if (oldWidget.entry != widget.entry || oldViewState.viewportSize != viewState.viewportSize) {
_isTilingInitialized = false;
_fullImageLoaded.value = false;
_unregisterFullImage();
} }
} }
void _init() { @override
_tileSide = viewportSize.shortestSide * scaleFactor; void dispose() {
_initialScale = min(viewportSize.width / entry.displaySize.width, viewportSize.height / entry.displaySize.height); _unregisterFullImage();
_maxSampleSize = _sampleSizeForScale(_initialScale); super.dispose();
final rotationDegrees = entry.rotationDegrees;
final isFlipped = entry.isFlipped;
_transform = null;
if (rotationDegrees != 0 || isFlipped) {
_transform = Matrix4.identity()
..translate(entry.width / 2.0, entry.height / 2.0)
..scale(isFlipped ? -1.0 : 1.0, 1.0, 1.0)
..rotateZ(-toRadians(rotationDegrees.toDouble()))
..translate(-entry.displaySize.width / 2.0, -entry.displaySize.height / 2.0);
} }
void _registerFullImage() {
_fullImageStream = fullImageProvider.resolve(ImageConfiguration.empty);
_fullImageStream.addListener(_fullImageListener);
}
void _unregisterFullImage() {
_fullImageStream?.removeListener(_fullImageListener);
_fullImageStream = null;
}
void _onFullImageCompleted(ImageInfo image, bool synchronousCall) {
_unregisterFullImage();
_fullImageLoaded.value = true;
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (viewStateNotifier == null) return SizedBox.shrink(); if (viewStateNotifier == null) return SizedBox.shrink();
final displayWidth = entry.displaySize.width.round(); return ValueListenableBuilder<ViewState>(
final displayHeight = entry.displaySize.height.round(); valueListenable: viewStateNotifier,
builder: (context, viewState, child) {
final viewportSize = viewState.viewportSize;
final viewportSized = viewportSize != null;
if (viewportSized && useTiles && !_isTilingInitialized) _initTiling(viewportSize);
return AnimatedBuilder( return SizedBox.fromSize(
animation: viewStateNotifier, size: entry.displaySize * viewState.scale,
builder: (context, child) { child: Stack(
final viewState = viewStateNotifier.value; alignment: Alignment.center,
var scale = viewState.scale; children: [
if (scale == 0.0) { if (useBackground && viewportSized) _buildBackground(viewState),
// for initial scale as `PhotoViewComputedScale.contained` _buildLoading(viewState),
scale = _initialScale; if (useTiles) ..._getTiles(viewState),
if (!useTiles)
Image(
image: fullImageProvider,
gaplessPlayback: true,
errorBuilder: widget.errorBuilder,
width: (entry.displaySize * viewState.scale).width,
fit: BoxFit.contain,
filterQuality: FilterQuality.medium,
),
],
),
);
},
);
} }
final centerOffset = viewState.position; void _initTiling(Size viewportSize) {
final viewOrigin = Offset( final displaySize = entry.displaySize;
((displayWidth * scale - viewportSize.width) / 2 - centerOffset.dx), _tileSide = viewportSize.shortestSide * scaleFactor;
((displayHeight * scale - viewportSize.height) / 2 - centerOffset.dy), // scale for initial state `contained`
final containedScale = min(viewportSize.width / displaySize.width, viewportSize.height / displaySize.height);
_maxSampleSize = _sampleSizeForScale(containedScale);
final rotationDegrees = entry.rotationDegrees;
final isFlipped = entry.isFlipped;
_tileTransform = null;
if (rotationDegrees != 0 || isFlipped) {
_tileTransform = Matrix4.identity()
..translate(entry.width / 2.0, entry.height / 2.0)
..scale(isFlipped ? -1.0 : 1.0, 1.0, 1.0)
..rotateZ(-toRadians(rotationDegrees.toDouble()))
..translate(-displaySize.width / 2.0, -displaySize.height / 2.0);
}
_isTilingInitialized = true;
_registerFullImage();
}
Widget _buildLoading(ViewState viewState) {
return ValueListenableBuilder(
valueListenable: _fullImageLoaded,
builder: (context, fullImageLoaded, child) {
if (fullImageLoaded) return SizedBox.shrink();
return Center(
child: AspectRatio(
// enforce original aspect ratio, as some thumbnails aspect ratios slightly differ
aspectRatio: entry.displayAspectRatio,
child: Image(
image: thumbnailProvider,
fit: BoxFit.fill,
),
),
); );
final viewRect = viewOrigin & viewportSize; },
);
}
Widget _buildBackground(ViewState viewState) {
final viewportSize = viewState.viewportSize;
assert(viewportSize != null);
final viewSize = entry.displaySize * viewState.scale;
final decorationOffset = ((viewSize - viewportSize) as Offset) / 2 - viewState.position;
final decorationSize = applyBoxFit(BoxFit.none, viewSize, viewportSize).source;
Decoration decoration;
final background = settings.rasterBackground;
if (background == EntryBackground.checkered) {
final side = viewportSize.shortestSide;
final checkSize = side / ((side / ImageView.decorationCheckSize).round());
final offset = ((decorationSize - viewportSize) as Offset) / 2;
decoration = CheckeredDecoration(
checkSize: checkSize,
offset: offset,
);
} else {
decoration = BoxDecoration(
color: background.color,
);
}
return Positioned(
left: decorationOffset.dx >= 0 ? decorationOffset.dx : null,
top: decorationOffset.dy >= 0 ? decorationOffset.dy : null,
width: decorationSize.width,
height: decorationSize.height,
child: DecoratedBox(
decoration: decoration,
),
);
}
List<Widget> _getTiles(ViewState viewState) {
if (!_isTilingInitialized) return [];
final displayWidth = entry.displaySize.width.round();
final displayHeight = entry.displaySize.height.round();
final viewRect = _getViewRect(viewState, displayWidth, displayHeight);
final scale = viewState.scale;
final tiles = <RegionTile>[]; final tiles = <RegionTile>[];
var minSampleSize = min(_sampleSizeForScale(scale), _maxSampleSize); var minSampleSize = min(_sampleSizeForScale(scale), _maxSampleSize);
for (var sampleSize = _maxSampleSize; sampleSize >= minSampleSize; sampleSize = (sampleSize / 2).floor()) { for (var sampleSize = _maxSampleSize; sampleSize >= minSampleSize; sampleSize = (sampleSize / 2).floor()) {
// for the largest sample size (matching the initial scale), the whole image is in view // for the largest sample size (matching the initial scale), the whole image is in view
// so we subsample the whole image instead of splitting it in tiles // so we subsample the whole image without tiling
final useTiles = sampleSize != _maxSampleSize; final fullImageRegion = sampleSize == _maxSampleSize;
final regionSide = (_tileSide * sampleSize).round(); final regionSide = (_tileSide * sampleSize).round();
final layerRegionWidth = useTiles ? regionSide : displayWidth; final layerRegionWidth = fullImageRegion ? displayWidth : regionSide;
final layerRegionHeight = useTiles ? regionSide : displayHeight; final layerRegionHeight = fullImageRegion ? displayHeight : regionSide;
for (var x = 0; x < displayWidth; x += layerRegionWidth) { for (var x = 0; x < displayWidth; x += layerRegionWidth) {
for (var y = 0; y < displayHeight; y += layerRegionHeight) { for (var y = 0; y < displayHeight; y += layerRegionHeight) {
final rects = _getTileRects(
x: x,
y: y,
layerRegionWidth: layerRegionWidth,
layerRegionHeight: layerRegionHeight,
displayWidth: displayWidth,
displayHeight: displayHeight,
scale: scale,
viewRect: viewRect,
);
if (rects != null) {
tiles.add(RegionTile(
entry: entry,
tileRect: rects.item1,
regionRect: rects.item2,
sampleSize: sampleSize,
));
}
}
}
}
return tiles;
}
Rect _getViewRect(ViewState viewState, int displayWidth, int displayHeight) {
final scale = viewState.scale;
final centerOffset = viewState.position;
final viewportSize = viewState.viewportSize;
final viewOrigin = Offset(
((displayWidth * scale - viewportSize.width) / 2 - centerOffset.dx),
((displayHeight * scale - viewportSize.height) / 2 - centerOffset.dy),
);
return viewOrigin & viewportSize;
}
Tuple2<Rect, Rectangle<int>> _getTileRects({
@required int x,
@required int y,
@required int layerRegionWidth,
@required int layerRegionHeight,
@required int displayWidth,
@required int displayHeight,
@required double scale,
@required Rect viewRect,
}) {
final nextX = x + layerRegionWidth; final nextX = x + layerRegionWidth;
final nextY = y + layerRegionHeight; final nextY = y + layerRegionHeight;
final thisRegionWidth = layerRegionWidth - (nextX >= displayWidth ? nextX - displayWidth : 0); final thisRegionWidth = layerRegionWidth - (nextX >= displayWidth ? nextX - displayWidth : 0);
@ -114,14 +304,14 @@ class _TiledImageViewState extends State<TiledImageView> {
final tileRect = Rect.fromLTWH(x * scale, y * scale, thisRegionWidth * scale, thisRegionHeight * scale); final tileRect = Rect.fromLTWH(x * scale, y * scale, thisRegionWidth * scale, thisRegionHeight * scale);
// only build visible tiles // only build visible tiles
if (viewRect.overlaps(tileRect)) { if (!viewRect.overlaps(tileRect)) return null;
Rectangle<int> regionRect;
if (_transform != null) { Rectangle<int> regionRect;
if (_tileTransform != null) {
// apply EXIF orientation // apply EXIF orientation
final regionRectDouble = Rect.fromLTWH(x.toDouble(), y.toDouble(), thisRegionWidth.toDouble(), thisRegionHeight.toDouble()); final regionRectDouble = Rect.fromLTWH(x.toDouble(), y.toDouble(), thisRegionWidth.toDouble(), thisRegionHeight.toDouble());
final tl = MatrixUtils.transformPoint(_transform, regionRectDouble.topLeft); final tl = MatrixUtils.transformPoint(_tileTransform, regionRectDouble.topLeft);
final br = MatrixUtils.transformPoint(_transform, regionRectDouble.bottomRight); final br = MatrixUtils.transformPoint(_tileTransform, regionRectDouble.bottomRight);
regionRect = Rectangle<int>.fromPoints( regionRect = Rectangle<int>.fromPoints(
Point<int>(tl.dx.round(), tl.dy.round()), Point<int>(tl.dx.round(), tl.dy.round()),
Point<int>(br.dx.round(), br.dy.round()), Point<int>(br.dx.round(), br.dy.round()),
@ -129,30 +319,7 @@ class _TiledImageViewState extends State<TiledImageView> {
} else { } else {
regionRect = Rectangle<int>(x, y, thisRegionWidth, thisRegionHeight); regionRect = Rectangle<int>(x, y, thisRegionWidth, thisRegionHeight);
} }
return Tuple2<Rect, Rectangle<int>>(tileRect, regionRect);
tiles.add(RegionTile(
entry: entry,
tileRect: tileRect,
regionRect: regionRect,
sampleSize: sampleSize,
));
}
}
}
}
return Stack(
alignment: Alignment.center,
children: [
SizedBox(
width: displayWidth * scale,
height: displayHeight * scale,
child: widget.baseChild,
),
...tiles,
],
);
});
} }
int _sampleSizeForScale(double scale) { int _sampleSizeForScale(double scale) {

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

View file

@ -8,7 +8,7 @@ import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/settings/access_grants.dart'; import 'package:aves/widgets/settings/access_grants.dart';
import 'package:aves/widgets/settings/svg_background.dart'; import 'package:aves/widgets/settings/entry_background.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -115,8 +115,18 @@ class _SettingsPageState extends State<SettingsPage> {
}, },
), ),
ListTile( ListTile(
title: Text('SVG background'), title: Text('Raster image background'),
trailing: SvgBackgroundSelector(), trailing: EntryBackgroundSelector(
getter: () => settings.rasterBackground,
setter: (value) => settings.rasterBackground = value,
),
),
ListTile(
title: Text('Vector image background'),
trailing: EntryBackgroundSelector(
getter: () => settings.vectorBackground,
setter: (value) => settings.vectorBackground = value,
),
), ),
ListTile( ListTile(
title: Text('Coordinate format'), title: Text('Coordinate format'),

View file

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

View file

@ -295,7 +295,5 @@ class EntryByMimeDatum {
Color get color => stringToColor(displayText); Color get color => stringToColor(displayText);
@override @override
String toString() { String toString() => '$runtimeType#${shortHash(this)}{mimeType=$mimeType, displayText=$displayText, entryCount=$entryCount}';
return '[$runtimeType#${shortHash(this)}: mimeType=$mimeType, displayText=$displayText, entryCount=$entryCount]';
}
} }

View file

@ -63,7 +63,7 @@ packages:
name: cached_network_image name: cached_network_image
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.4.1" version: "2.5.0"
characters: characters:
dependency: transitive dependency: transitive
description: description:
@ -282,7 +282,7 @@ packages:
name: flutter_cache_manager name: flutter_cache_manager
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.0" version: "2.1.0"
flutter_cube: flutter_cube:
dependency: transitive dependency: transitive
description: description:
@ -605,7 +605,7 @@ packages:
name: panorama name: panorama
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.1.0" version: "0.1.2"
path: path:
dependency: transitive dependency: transitive
description: description:
@ -668,7 +668,7 @@ packages:
name: pdf name: pdf
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.12.0" version: "1.13.0"
pedantic: pedantic:
dependency: "direct main" dependency: "direct main"
description: description:
@ -682,7 +682,7 @@ packages:
name: percent_indicator name: percent_indicator
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.8" version: "2.1.9"
permission_handler: permission_handler:
dependency: "direct main" dependency: "direct main"
description: description:
@ -704,15 +704,6 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.1.0" version: "3.1.0"
photo_view:
dependency: "direct main"
description:
path: "."
ref: HEAD
resolved-ref: aa6400bbc85bf6ce953c4609d126796cdb4ca3c2
url: "git://github.com/deckerst/photo_view.git"
source: git
version: "0.9.2"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@ -747,7 +738,7 @@ packages:
name: printing name: printing
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.7.1" version: "3.7.2"
process: process:
dependency: transitive dependency: transitive
description: description:
@ -796,7 +787,7 @@ packages:
name: rxdart name: rxdart
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.24.1" version: "0.25.0"
screen: screen:
dependency: "direct main" dependency: "direct main"
description: description:
@ -866,7 +857,7 @@ packages:
name: shelf_static name: shelf_static
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.2.8" version: "0.2.9+1"
shelf_web_socket: shelf_web_socket:
dependency: transitive dependency: transitive
description: description:
@ -1061,13 +1052,6 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.0.1+3" version: "0.0.1+3"
utf:
dependency: transitive
description:
name: utf
url: "https://pub.dartlang.org"
source: hosted
version: "0.9.0+5"
uuid: uuid:
dependency: transitive dependency: transitive
description: description:

View file

@ -3,7 +3,7 @@ description: Aves is a gallery and metadata explorer app, built for Android.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev publish_to: 'none' # Remove this line if you wish to publish to pub.dev
version: 1.2.9+35 version: 1.3.0+36
# brendan-duncan/image (as of v2.1.19): # brendan-duncan/image (as of v2.1.19):
# - does not support TIFF with JPEG compression (issue #184) # - does not support TIFF with JPEG compression (issue #184)
@ -70,10 +70,6 @@ dependencies:
pedantic: pedantic:
percent_indicator: percent_indicator:
permission_handler: permission_handler:
photo_view:
# path: ../photo_view
git:
url: git://github.com/deckerst/photo_view.git
printing: printing:
provider: provider:
screen: screen:

View file

@ -1,7 +1,5 @@
Thanks for using Aves! Thanks for using Aves!
v1.2.9: v1.3.0:
- identify 360 photos/videos, GeoTIFF - added quick scale (aka one finger zoom) gesture to the viewer
- open panoramas (360 photos) - fixed zoom focus with double-tap or pinch-to-zoom gestures
- open GImage/GAudio/GDepth media and thumbnails embedded in XMP
- improved large TIFF handling
Full changelog available on Github Full changelog available on Github