reworked and integrated photo_view package, fixed double tap zoom focus

This commit is contained in:
Thibault Deckers 2020-12-17 14:02:26 +09:00
parent 38cbe7fc2e
commit 05496da344
38 changed files with 1599 additions and 287 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -134,9 +134,7 @@ class ImageEntry {
}
@override
String toString() {
return 'ImageEntry{uri=$uri, path=$path}';
}
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, path=$path}';
set path(String path) {
_path = path;

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,106 @@
import 'dart:async';
import 'dart:ui';
import 'package:aves/widgets/common/magnifier/controller/state.dart';
import 'package:flutter/widgets.dart';
class MagnifierController {
MagnifierController({
Offset initialPosition = Offset.zero,
}) : _valueNotifier = ValueNotifier(
MagnifierState(
position: initialPosition,
scale: null,
source: ChangeSource.internal,
),
),
super() {
initial = value;
prevValue = initial;
_valueNotifier.addListener(_changeListener);
_outputCtrl = StreamController<MagnifierState>.broadcast();
_outputCtrl.sink.add(initial);
}
final ValueNotifier<MagnifierState> _valueNotifier;
MagnifierState initial;
StreamController<MagnifierState> _outputCtrl;
/// The output for state/value updates. Usually a broadcast [Stream]
Stream<MagnifierState> get outputStateStream => _outputCtrl.stream;
/// The state value before the last change or the initial state if the state has not been changed.
MagnifierState prevValue;
/// Resets the state to the initial value;
void reset() {
_setValue(initial);
}
void _changeListener() {
_outputCtrl.sink.add(value);
}
/// Closes streams and removes eventual listeners.
void dispose() {
_outputCtrl.close();
_valueNotifier.dispose();
}
void setPosition(Offset position, ChangeSource source) {
// debugPrint('$runtimeType setPosition position=$position, source=$source');
if (value.position == position) return;
prevValue = value;
_setValue(MagnifierState(
position: position,
scale: scale,
source: source,
));
}
/// The position of the image in the screen given its offset after pan gestures.
Offset get position => value.position;
void setScale(double scale, ChangeSource source) {
// debugPrint('$runtimeType setScale scale=$scale source=$source');
if (value.scale == scale) return;
prevValue = value;
_setValue(MagnifierState(
position: position,
scale: scale,
source: source,
));
}
/// The scale factor to transform the child (image or a customChild).
double get scale => value.scale;
/// Update multiple fields of the state with only one update streamed.
void updateMultiple({
Offset position,
double scale,
@required ChangeSource source,
}) {
// debugPrint('$runtimeType updateMultiple position=$position scale=$scale, source=$source');
prevValue = value;
_setValue(MagnifierState(
position: position ?? value.position,
scale: scale ?? value.scale,
source: source,
));
}
/// The actual state value
MagnifierState get value => _valueNotifier.value;
void _setValue(MagnifierState newValue) {
// debugPrint('$runtimeType setValue value=$newValue');
if (_valueNotifier.value == newValue) return;
_valueNotifier.value = newValue;
}
}

View file

@ -0,0 +1,226 @@
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/scale_level.dart';
import 'package:aves/widgets/common/magnifier/scale/scalestate_controller.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;
MagnifierScaleStateController get scaleStateController => widget.scaleStateController;
ScaleBoundaries get scaleBoundaries => widget.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> _streamSubs = [];
void startListeners() {
_streamSubs.add(controller.outputStateStream.listen(_onMagnifierStateChange));
_streamSubs.add(scaleStateController.scaleStateChangeStream.listen(_onScaleStateChange));
}
void _onScaleStateChange(ScaleStateChange scaleStateChange) {
if (scaleStateChange.source == ChangeSource.internal) return;
if (!scaleStateController.hasChanged) return;
if (_animateScale == null || scaleStateController.isZooming) {
controller.setScale(scale, scaleStateChange.source);
return;
}
final nextScaleState = scaleStateChange.state;
final nextScale = getScaleForScaleState(nextScaleState, scaleBoundaries);
var nextPosition = Offset.zero;
if (nextScaleState == ScaleState.covering || nextScaleState == ScaleState.originalSize) {
final childFocalPoint = scaleStateChange.childFocalPoint;
if (childFocalPoint != null) {
final childCenter = scaleBoundaries.childSize.center(Offset.zero);
nextPosition = (childCenter - childFocalPoint) * nextScale;
}
}
final prevScale = controller.scale ?? getScaleForScaleState(scaleStateController.prevScaleState.state, scaleBoundaries);
_animateScale(prevScale, nextScale, nextPosition);
}
void addAnimateOnScaleStateUpdate(void Function(double prevScale, double nextScale, Offset nextPosition) animateScale) {
_animateScale = animateScale;
}
void _onMagnifierStateChange(MagnifierState state) {
controller.setPosition(clampPosition(), state.source);
if (controller.scale == controller.prevValue.scale) return;
if (state.source == ChangeSource.internal || state.source == ChangeSource.animation) return;
final newScaleState = (scale > scaleBoundaries.initialScale) ? ScaleState.zoomedIn : ScaleState.zoomedOut;
scaleStateController.setScaleState(newScaleState, state.source);
}
Offset get position => controller.position;
double get scale {
final scaleState = scaleStateController.scaleState.state;
final needsRecalc = markNeedsScaleRecalc && !(scaleState == ScaleState.zoomedIn || scaleState == ScaleState.zoomedOut);
final scaleExistsOnController = controller.scale != null;
if (needsRecalc || !scaleExistsOnController) {
final newScale = getScaleForScaleState(scaleState, scaleBoundaries);
markNeedsScaleRecalc = false;
setScale(newScale, ChangeSource.internal);
return newScale;
}
return controller.scale;
}
void setScale(double scale, ChangeSource source) => controller.setScale(scale, source);
void updateMultiple({
Offset position,
double scale,
@required ChangeSource source,
}) {
controller.updateMultiple(position: position, scale: scale, source: source);
}
void updateScaleStateFromNewScale(double newScale, ChangeSource source) {
// debugPrint('updateScaleStateFromNewScale scale=$newScale, source=$source');
var newScaleState = ScaleState.initial;
if (scale != scaleBoundaries.initialScale) {
newScaleState = (newScale > scaleBoundaries.initialScale) ? ScaleState.zoomedIn : ScaleState.zoomedOut;
}
scaleStateController.setScaleState(newScaleState, source);
}
void nextScaleState(ChangeSource source, {Offset childFocalPoint}) {
// debugPrint('$runtimeType nextScaleState source=$source');
final scaleState = scaleStateController.scaleState.state;
if (scaleState == ScaleState.zoomedIn || scaleState == ScaleState.zoomedOut) {
scaleStateController.setScaleState(scaleStateCycle(scaleState), source, childFocalPoint: childFocalPoint);
return;
}
final originalScale = getScaleForScaleState(
scaleState,
scaleBoundaries,
);
var prevScale = originalScale;
var prevScaleState = scaleState;
var nextScale = originalScale;
var nextScaleState = scaleState;
do {
prevScale = nextScale;
prevScaleState = nextScaleState;
nextScaleState = scaleStateCycle(prevScaleState);
nextScale = getScaleForScaleState(nextScaleState, scaleBoundaries);
} while (prevScale == nextScale && scaleState != nextScaleState);
if (originalScale == nextScale) return;
scaleStateController.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;
_streamSubs.forEach((sub) => sub.cancel());
_streamSubs.clear();
super.dispose();
}
double getScaleForScaleState(
ScaleState scaleState,
ScaleBoundaries scaleBoundaries,
) {
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;
}
}
}
/// 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,290 @@
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/scalestate_controller.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.scaleBoundaries,
@required this.scaleStateCycle,
@required this.scaleStateController,
@required this.applyScale,
}) : super(key: key);
final Widget child;
final MagnifierController controller;
final MagnifierScaleStateController scaleStateController;
final ScaleBoundaries scaleBoundaries;
final ScaleStateCycle scaleStateCycle;
final MagnifierTapCallback onTap;
final HitTestBehavior gestureDetectorBehavior;
final bool applyScale;
@override
State<StatefulWidget> createState() {
return MagnifierCoreState();
}
}
class MagnifierCoreState extends State<MagnifierCore> with TickerProviderStateMixin, MagnifierControllerDelegate, CornerHitDetector {
Offset _normalizedPosition;
double _scaleBefore;
AnimationController _scaleAnimationController;
Animation<double> _scaleAnimation;
AnimationController _positionAnimationController;
Animation<Offset> _positionAnimation;
ScaleBoundaries cachedScaleBoundaries;
void handleScaleAnimation() {
setScale(_scaleAnimation.value, ChangeSource.animation);
}
void handlePositionAnimate() {
controller.setPosition(_positionAnimation.value, ChangeSource.animation);
}
void onScaleStart(ScaleStartDetails details) {
_scaleBefore = scale;
_normalizedPosition = details.focalPoint - controller.position;
_scaleAnimationController.stop();
_positionAnimationController.stop();
}
void onScaleUpdate(ScaleUpdateDetails details) {
final newScale = _scaleBefore * details.scale;
final delta = details.focalPoint - _normalizedPosition;
updateScaleStateFromNewScale(newScale, ChangeSource.gesture);
//
updateMultiple(
scale: newScale,
position: clampPosition(position: delta * details.scale),
source: ChangeSource.gesture,
);
}
void onScaleEnd(ScaleEndDetails details) {
final _scale = scale;
final _position = controller.position;
final maxScale = scaleBoundaries.maxScale;
final minScale = scaleBoundaries.minScale;
//animate back to maxScale if gesture exceeded the maxScale specified
if (_scale > maxScale) {
final scaleComebackRatio = maxScale / _scale;
animateScale(_scale, maxScale);
final clampedPosition = clampPosition(
position: _position * scaleComebackRatio,
scale: maxScale,
);
animatePosition(_position, clampedPosition);
return;
}
//animate back to minScale if gesture fell smaller than the minScale specified
if (_scale < minScale) {
final scaleComebackRatio = minScale / _scale;
animateScale(_scale, minScale);
animatePosition(
_position,
clampPosition(
position: _position * scaleComebackRatio,
scale: minScale,
),
);
return;
}
// get magnitude from gesture velocity
final magnitude = details.velocity.pixelsPerSecond.distance;
// animate velocity only if there is no scale change and a significant magnitude
if (_scaleBefore / _scale == 1.0 && magnitude >= 400.0) {
final direction = details.velocity.pixelsPerSecond / magnitude;
animatePosition(
_position,
clampPosition(position: _position + direction * 100.0),
);
}
}
void onTap(TapUpDetails details) {
if (widget.onTap == null) return;
final viewportTapPosition = details.localPosition;
final childTapPosition = scaleBoundaries.toChildPosition(controller, viewportTapPosition);
widget.onTap.call(context, details, controller.value, childTapPosition);
}
void onDoubleTap(TapDownDetails details) {
final viewportTapPosition = details?.localPosition;
final childTapPosition = scaleBoundaries.toChildPosition(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 (scaleStateController.scaleState.state != ScaleState.initial && scale == scaleBoundaries.initialScale) {
scaleStateController.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();
addAnimateOnScaleStateUpdate(animateOnScaleStateUpdate);
cachedScaleBoundaries = widget.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.scaleBoundaries != cachedScaleBoundaries) {
markNeedsScaleRecalc = true;
cachedScaleBoundaries = widget.scaleBoundaries;
}
return StreamBuilder<MagnifierState>(
stream: controller.outputStateStream,
initialData: controller.prevValue,
builder: (context, snapshot) {
if (snapshot.hasData) {
final value = snapshot.data;
final applyScale = widget.applyScale;
final computedScale = applyScale ? scale : 1.0;
final matrix = Matrix4.identity()
..translate(value.position.dx, value.position.dy)
..scale(computedScale);
final Widget customChildLayout = CustomSingleChildLayout(
delegate: _CenterWithOriginalSizeDelegate(
scaleBoundaries.childSize,
basePosition,
applyScale,
),
child: widget.child,
);
return MagnifierGestureDetector(
child: Transform(
child: customChildLayout,
transform: matrix,
alignment: basePosition,
),
onDoubleTap: onDoubleTap,
onScaleStart: onScaleStart,
onScaleUpdate: onScaleUpdate,
onScaleEnd: onScaleEnd,
hitDetector: this,
onTapUp: widget.onTap == null ? null : onTap,
);
} else {
return Container();
}
});
}
}
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,231 @@
import 'dart:math';
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 GestureScaleStartCallback 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> {
TapDownDetails doubleTapDetails;
@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,
),
(instance) {
instance
..onStart = widget.onScaleStart
..onUpdate = widget.onScaleUpdate
..onEnd = widget.onScaleEnd;
},
);
gestures[DoubleTapGestureRecognizer] = GestureRecognizerFactoryWithHandlers<DoubleTapGestureRecognizer>(
() => DoubleTapGestureRecognizer(debugOwner: this),
(instance) {
instance.onDoubleTapCancel = () => doubleTapDetails = null;
instance.onDoubleTapDown = (details) => doubleTapDetails = details;
instance.onDoubleTap = () {
widget.onDoubleTap(doubleTapDetails);
doubleTapDetails = null;
};
},
);
return RawGestureDetector(
child: widget.child,
gestures: gestures,
behavior: widget.behavior ?? HitTestBehavior.translucent,
);
}
}
class MagnifierGestureRecognizer extends ScaleGestureRecognizer {
MagnifierGestureRecognizer({
this.hitDetector,
Object debugOwner,
this.validateAxis,
this.touchSlopFactor = 2,
PointerDeviceKind kind,
}) : super(debugOwner: debugOwner, kind: kind);
final CornerHitDetector hitDetector;
final List<Axis> validateAxis;
final double touchSlopFactor;
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);
}
if (shouldMove) {
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,169 @@
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/scalestate_controller.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 focusing on tap position when scaling by double tap
class Magnifier extends StatefulWidget {
const Magnifier({
Key key,
@required this.child,
this.childSize,
this.controller,
this.scaleStateController,
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 MagnifierScaleStateController scaleStateController;
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;
bool _controlledScaleStateController;
MagnifierScaleStateController _scaleStateController;
void _setChildSize(Size childSize) {
_childSize = childSize;
}
@override
void initState() {
super.initState();
_setChildSize(widget.childSize);
if (widget.controller == null) {
_controlledController = true;
_controller = MagnifierController();
} else {
_controlledController = false;
_controller = widget.controller;
}
if (widget.scaleStateController == null) {
_controlledScaleStateController = true;
_scaleStateController = MagnifierScaleStateController();
} else {
_controlledScaleStateController = false;
_scaleStateController = widget.scaleStateController;
}
}
@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;
}
if (widget.scaleStateController == null) {
if (!_controlledScaleStateController) {
_controlledScaleStateController = true;
_scaleStateController = MagnifierScaleStateController();
}
} else {
_controlledScaleStateController = false;
_scaleStateController = widget.scaleStateController;
}
super.didUpdateWidget(oldWidget);
}
@override
void dispose() {
if (_controlledController) {
_controller.dispose();
}
if (_controlledScaleStateController) {
_scaleStateController.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final scaleBoundaries = 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,
scaleStateController: _scaleStateController,
scaleStateCycle: widget.scaleStateCycle ?? defaultScaleStateCycle,
scaleBoundaries: scaleBoundaries,
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,60 @@
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 toChildPosition(MagnifierController controller, Offset viewportPosition) {
final position = controller.position;
final scale = controller.scale;
final viewportCenter = viewportSize.center(Offset.zero);
final childCenter = childSize.center(Offset.zero);
final childPosition = (viewportPosition - viewportCenter) / scale - position / scale + childCenter;
return childPosition;
}
@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,41 @@
import 'dart:async';
import 'package:aves/widgets/common/magnifier/controller/state.dart';
import 'package:aves/widgets/common/magnifier/scale/state.dart';
import 'package:flutter/rendering.dart';
typedef ScaleStateListener = void Function(double prevScale, double nextScale);
class MagnifierScaleStateController {
ScaleStateChange _scaleState;
StreamController<ScaleStateChange> _outputScaleStateCtrl;
ScaleStateChange prevScaleState;
Stream<ScaleStateChange> get scaleStateChangeStream => _outputScaleStateCtrl.stream;
ScaleStateChange get scaleState => _scaleState;
bool get hasChanged => prevScaleState != scaleState;
bool get isZooming => scaleState.state == ScaleState.zoomedIn || scaleState.state == ScaleState.zoomedOut;
MagnifierScaleStateController() {
_scaleState = ScaleStateChange(state: ScaleState.initial, source: ChangeSource.internal);
prevScaleState = _scaleState;
_outputScaleStateCtrl = StreamController<ScaleStateChange>.broadcast();
_outputScaleStateCtrl.sink.add(_scaleState);
}
void dispose() {
_outputScaleStateCtrl.close();
}
void setScaleState(ScaleState newValue, ChangeSource source, {Offset childFocalPoint}) {
if (_scaleState.state == newValue) return;
prevScaleState = _scaleState;
_scaleState = ScaleStateChange(state: newValue, source: source, childFocalPoint: childFocalPoint);
_outputScaleStateCtrl.sink.add(scaleState);
}
}

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

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

View file

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

View file

@ -1,26 +1,30 @@
import 'dart:async';
import 'package:aves/image_providers/thumbnail_provider.dart';
import 'package:aves/image_providers/uri_image_provider.dart';
import 'package:aves/image_providers/uri_picture_provider.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/collection/empty.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_level.dart';
import 'package:aves/widgets/common/magnifier/scale/scalestate_controller.dart';
import 'package:aves/widgets/common/magnifier/scale/state.dart';
import 'package:aves/widgets/fullscreen/tiled_view.dart';
import 'package:aves/widgets/fullscreen/video_view.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:photo_view/photo_view.dart';
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
class ImageView extends StatefulWidget {
final ImageEntry entry;
final Object heroTag;
final VoidCallback onTap;
final MagnifierTapCallback onTap;
final List<Tuple2<String, IjkMediaController>> videoControllers;
final VoidCallback onDisposed;
@ -38,26 +42,25 @@ class ImageView extends StatefulWidget {
}
class _ImageViewState extends State<ImageView> {
final PhotoViewController _photoViewController = PhotoViewController();
final PhotoViewScaleStateController _photoViewScaleStateController = PhotoViewScaleStateController();
final MagnifierController _magnifierController = MagnifierController();
final MagnifierScaleStateController _magnifierScaleStateController = MagnifierScaleStateController();
final ValueNotifier<ViewState> _viewStateNotifier = ValueNotifier(ViewState.zero);
StreamSubscription<PhotoViewControllerValue> _subscription;
Size _photoViewChildSize;
StreamSubscription<MagnifierState> _subscription;
Size _magnifierChildSize;
static const backgroundDecoration = BoxDecoration(color: Colors.transparent);
static const maxScale = 2.0;
static const initialScale = ScaleLevel(ref: ScaleReference.contained);
static const minScale = ScaleLevel(ref: ScaleReference.contained);
static const maxScale = ScaleLevel(factor: 2.0);
ImageEntry get entry => widget.entry;
VoidCallback get onTap => widget.onTap;
MagnifierTapCallback get onTap => widget.onTap;
@override
void initState() {
super.initState();
_subscription = _photoViewController.outputStateStream.listen(_onViewChanged);
if (entry.isVideo || (!entry.isSvg && entry.canDecode && useTile)) {
_photoViewChildSize = entry.displaySize;
}
_subscription = _magnifierController.outputStateStream.listen(_onViewChanged);
_magnifierChildSize = entry.displaySize;
}
@override
@ -78,19 +81,9 @@ class _ImageViewState extends State<ImageView> {
} else if (entry.isSvg) {
child = _buildSvgView();
} else if (entry.canDecode) {
if (useTile) {
child = _buildTiledImageView();
} else {
child = _buildImageView();
child = _buildRasterView();
}
}
child ??= _buildError();
// if the hero tag is defined in the `loadingBuilder` and also set by the `heroAttributes`,
// the route transition becomes visible if the final image is loaded before the hero animation is done.
// if the hero tag wraps the whole `PhotoView` and the `loadingBuilder` is not provided,
// there's a black frame between the hero animation and the final image, even when it's cached.
child ??= ErrorChild(onTap: () => onTap?.call(null));
// no hero for videos, as a typical video first frame is different from its thumbnail
return widget.heroTag != null && !entry.isVideo
@ -102,17 +95,12 @@ class _ImageViewState extends State<ImageView> {
: child;
}
// the images loaded by `PhotoView` cannot have a width or height larger than 8192
// so the reported offset and scale does not match expected values derived from the original dimensions
// besides, large images should be tiled to be memory-friendly
bool get useTile => entry.canTile && (entry.width > 4096 || entry.height > 4096);
ImageProvider get fastThumbnailProvider => ThumbnailProvider(ThumbnailProviderKey.fromEntry(entry));
// this loading builder shows a transition image until the final image is ready
// if the image is already in the cache it will show the final image, otherwise the thumbnail
// in any case, we should use `Center` + `AspectRatio` + `BoxFit.fill` so that the transition image
// appears as the final image with `PhotoViewComputedScale.contained` for `initialScale`
// is laid the same way as the final image when `contained`
Widget _loadingBuilder(BuildContext context, ImageProvider imageProvider) {
return Center(
child: AspectRatio(
@ -126,53 +114,20 @@ class _ImageViewState extends State<ImageView> {
);
}
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: ValueKey('${entry.rotationDegrees}_${entry.isFlipped}_${entry.width}_${entry.height}_${entry.path}'),
imageProvider: uriImage,
// when the full image is ready, we use it in the `loadingBuilder`
// we still provide a `loadingBuilder` in that case to avoid a black frame after hero animation
loadingBuilder: (context, event) => _loadingBuilder(
context,
imageCache.statusForKey(uriImage).keepAlive ? uriImage : fastThumbnailProvider,
),
loadFailedChild: _buildError(),
backgroundDecoration: backgroundDecoration,
imageSizedCallback: (size) {
// do not directly update the `ViewState` notifier as this callback is called during build
_photoViewChildSize = size;
},
controller: _photoViewController,
maxScale: maxScale,
minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained,
onTapUp: (tapContext, details, value) => onTap?.call(),
filterQuality: FilterQuality.low,
);
}
Widget _buildTiledImageView() {
return PhotoView.customChild(
Widget _buildRasterView() {
return Magnifier(
// key includes size and orientation to refresh when the image is rotated
key: ValueKey('${entry.rotationDegrees}_${entry.isFlipped}_${entry.width}_${entry.height}_${entry.path}'),
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,
// `Magnifier` keeps the scale state as `contained`, but the controller does not update or notify the new scale value.
// We cannot monitor scale state changes as a workaround, because the scale state is updated before animating the scale,
// 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);
if (_magnifierScaleStateController.scaleState.state == ScaleState.initial) {
final value = MagnifierState(position: Offset.zero, scale: 0, source: ChangeSource.internal);
WidgetsBinding.instance.addPostFrameCallback((_) => _onViewChanged(value));
}
return TiledImageView(
@ -180,25 +135,24 @@ class _ImageViewState extends State<ImageView> {
viewportSize: mqSize,
viewStateNotifier: _viewStateNotifier,
baseChild: _loadingBuilder(context, fastThumbnailProvider),
errorBuilder: (context, error, stackTrace) => _buildError(),
errorBuilder: (context, error, stackTrace) => ErrorChild(onTap: () => onTap?.call(null)),
);
},
),
childSize: entry.displaySize,
backgroundDecoration: backgroundDecoration,
controller: _photoViewController,
scaleStateController: _photoViewScaleStateController,
controller: _magnifierController,
scaleStateController: _magnifierScaleStateController,
maxScale: maxScale,
minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained,
onTapUp: (tapContext, details, value) => onTap?.call(),
filterQuality: FilterQuality.low,
minScale: minScale,
initialScale: initialScale,
onTap: (c, d, s, childPosition) => onTap?.call(childPosition),
applyScale: false,
);
}
Widget _buildSvgView() {
final colorFilter = ColorFilter.mode(Color(settings.svgBackground), BlendMode.dstOver);
return PhotoView.customChild(
return Magnifier(
child: SvgPicture(
UriPicture(
uri: entry.uri,
@ -206,17 +160,16 @@ class _ImageViewState extends State<ImageView> {
colorFilter: colorFilter,
),
),
backgroundDecoration: backgroundDecoration,
controller: _photoViewController,
minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained,
onTapUp: (tapContext, details, value) => onTap?.call(),
controller: _magnifierController,
minScale: minScale,
initialScale: initialScale,
onTap: (c, d, s, childPosition) => onTap?.call(childPosition),
);
}
Widget _buildVideoView() {
final videoController = widget.videoControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2;
return PhotoView.customChild(
return Magnifier(
child: videoController != null
? AvesVideo(
entry: entry,
@ -224,31 +177,16 @@ class _ImageViewState extends State<ImageView> {
)
: SizedBox(),
childSize: entry.displaySize,
backgroundDecoration: backgroundDecoration,
controller: _photoViewController,
controller: _magnifierController,
maxScale: maxScale,
minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained,
onTapUp: (tapContext, details, value) => onTap?.call(),
minScale: minScale,
initialScale: initialScale,
onTap: (c, d, s, childPosition) => onTap?.call(childPosition),
);
}
Widget _buildError() => GestureDetector(
onTap: () => onTap?.call(),
// use a `Container` with a dummy color to make it expand
// so that we can also detect taps around the title `Text`
child: Container(
color: Colors.transparent,
child: EmptyContent(
icon: AIcons.error,
text: 'Oops!',
alignment: Alignment.center,
),
),
);
void _onViewChanged(PhotoViewControllerValue v) {
final viewState = ViewState(v.position, v.scale, _photoViewChildSize);
void _onViewChanged(MagnifierState v) {
final viewState = ViewState(v.position, v.scale, _magnifierChildSize);
_viewStateNotifier.value = viewState;
ViewStateNotification(entry.uri, viewState).dispatch(context);
}
@ -264,9 +202,7 @@ class ViewState {
const ViewState(this.position, this.scale, this.size);
@override
String toString() {
return '$runtimeType#${shortHash(this)}{position=$position, scale=$scale, size=$size}';
}
String toString() => '$runtimeType#${shortHash(this)}{position=$position, scale=$scale, size=$size}';
}
class ViewStateNotification extends Notification {
@ -276,7 +212,30 @@ class ViewStateNotification extends Notification {
const ViewStateNotification(this.uri, this.viewState);
@override
String toString() {
return '$runtimeType#${shortHash(this)}{uri=$uri, viewState=$viewState}';
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, viewState=$viewState}';
}
class ErrorChild extends StatelessWidget {
final VoidCallback onTap;
const ErrorChild({@required this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => onTap?.call(),
// use a `Container` with a dummy color to make it expand
// so that we can also detect taps around the title `Text`
child: Container(
color: Colors.transparent,
child: EmptyContent(
icon: AIcons.error,
text: 'Oops!',
alignment: Alignment.center,
),
),
);
}
}
typedef MagnifierTapCallback = void Function(Offset childPosition);

View file

@ -6,7 +6,6 @@ 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';

View file

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

View file

@ -21,7 +21,8 @@ class Minimap extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Selector<MediaQueryData, Size>(
return IgnorePointer(
child: Selector<MediaQueryData, Size>(
selector: (context, mq) => mq.size,
builder: (context, mqSize, child) {
return AnimatedBuilder(
@ -39,7 +40,8 @@ class Minimap extends StatelessWidget {
size: size,
);
});
});
}),
);
}
}

View file

@ -1,8 +1,9 @@
import 'dart:math';
import 'package:aves/image_providers/region_provider.dart';
import 'package:aves/image_providers/uri_image_provider.dart';
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/widgets/fullscreen/image_view.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@ -37,6 +38,16 @@ class _TiledImageViewState extends State<TiledImageView> {
ValueNotifier<ViewState> get viewStateNotifier => widget.viewStateNotifier;
bool get useTiles => entry.canTile && (entry.width > 4096 || entry.height > 4096);
ImageProvider get fullImage => UriImage(
uri: entry.uri,
mimeType: entry.mimeType,
rotationDegrees: entry.rotationDegrees,
isFlipped: entry.isFlipped,
expectedContentLength: entry.sizeBytes,
);
// magic number used to derive sample size from scale
static const scaleFactor = 2.0;
@ -85,10 +96,45 @@ class _TiledImageViewState extends State<TiledImageView> {
final viewState = viewStateNotifier.value;
var scale = viewState.scale;
if (scale == 0.0) {
// for initial scale as `PhotoViewComputedScale.contained`
// for initial scale as `contained`
scale = _initialScale;
}
final scaledSize = entry.displaySize * scale;
final loading = SizedBox(
width: scaledSize.width,
height: scaledSize.height,
child: widget.baseChild,
);
List<Widget> children;
if (useTiles) {
children = [
loading,
..._getTiles(viewState, displayWidth, displayHeight, scale),
];
} else {
children = [
if (!imageCache.statusForKey(fullImage).keepAlive) loading,
Image(
image: fullImage,
gaplessPlayback: true,
errorBuilder: widget.errorBuilder,
width: scaledSize.width,
fit: BoxFit.contain,
filterQuality: FilterQuality.medium,
)
];
}
return Stack(
alignment: Alignment.center,
children: children,
);
},
);
}
List<RegionTile> _getTiles(ViewState viewState, int displayWidth, int displayHeight, double scale) {
final centerOffset = viewState.position;
final viewOrigin = Offset(
((displayWidth * scale - viewportSize.width) / 2 - centerOffset.dx),
@ -140,19 +186,7 @@ class _TiledImageViewState extends State<TiledImageView> {
}
}
}
return Stack(
alignment: Alignment.center,
children: [
SizedBox(
width: displayWidth * scale,
height: displayHeight * scale,
child: widget.baseChild,
),
...tiles,
],
);
});
return tiles;
}
int _sampleSizeForScale(double scale) {

View file

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

View file

@ -704,15 +704,6 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.0"
photo_view:
dependency: "direct main"
description:
path: "."
ref: HEAD
resolved-ref: aa6400bbc85bf6ce953c4609d126796cdb4ca3c2
url: "git://github.com/deckerst/photo_view.git"
source: git
version: "0.9.2"
platform:
dependency: transitive
description:

View file

@ -70,10 +70,6 @@ dependencies:
pedantic:
percent_indicator:
permission_handler:
photo_view:
# path: ../photo_view
git:
url: git://github.com/deckerst/photo_view.git
printing:
provider:
screen: