import 'dart:async'; import 'dart:math'; import 'package:aves/model/viewer/view_state.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/extensions/geometry.dart'; import 'package:aves/widgets/common/fx/dashed_path_painter.dart'; import 'package:aves/widgets/editor/transform/controller.dart'; import 'package:aves/widgets/editor/transform/crop_region.dart'; import 'package:aves/widgets/editor/transform/handles.dart'; import 'package:aves/widgets/editor/transform/painter.dart'; import 'package:aves/widgets/editor/transform/transformation.dart'; import 'package:aves_magnifier/aves_magnifier.dart'; import 'package:aves_model/aves_model.dart'; import 'package:aves_utils/aves_utils.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; class Cropper extends StatefulWidget { final AvesMagnifierController magnifierController; final TransformController transformController; final ValueNotifier marginNotifier; static const double handleDimension = kMinInteractiveDimension; static const EdgeInsets imageMargin = EdgeInsets.all(kMinInteractiveDimension); const Cropper({ super.key, required this.magnifierController, required this.transformController, required this.marginNotifier, }); @override State createState() => _CropperState(); } class _CropperState extends State with SingleTickerProviderStateMixin { final Set _subscriptions = {}; final ValueNotifier _outlineNotifier = ValueNotifier(Rect.zero); final ValueNotifier _gridDivisionNotifier = ValueNotifier(0); late AnimationController _gridAnimationController; late CurvedAnimation _gridOpacity; static const double minDimension = Cropper.handleDimension; static const int panResizeGridDivision = 3; static const int straightenGridDivision = 9; static const double overOutlineFactor = .25; AvesMagnifierController get magnifierController => widget.magnifierController; TransformController get transformController => widget.transformController; Transformation get transformation => transformController.transformation; CropAspectRatio get cropAspectRatio => transformController.aspectRatioNotifier.value; @override void initState() { super.initState(); _gridAnimationController = AnimationController( duration: context.read().viewerOverlayAnimation, vsync: this, ); _gridOpacity = CurvedAnimation( parent: _gridAnimationController, curve: Curves.easeOutQuad, ); _registerWidget(widget); } @override void didUpdateWidget(covariant Cropper oldWidget) { super.didUpdateWidget(oldWidget); _unregisterWidget(oldWidget); _registerWidget(widget); } @override void dispose() { _outlineNotifier.dispose(); _gridDivisionNotifier.dispose(); _gridOpacity.dispose(); _gridAnimationController.dispose(); _unregisterWidget(widget); super.dispose(); } void _registerWidget(Cropper widget) { _subscriptions.add(widget.magnifierController.stateStream.listen(_onViewStateChanged)); _subscriptions.add(widget.magnifierController.scaleBoundariesStream.map((v) => v.viewportSize).distinct().listen(_onViewportSizeChanged)); _subscriptions.add(widget.transformController.activityStream.listen(_onTransformActivity)); _subscriptions.add(widget.transformController.transformationStream.map((v) => v.orientation).distinct().listen(_onOrientationChanged)); _subscriptions.add(widget.transformController.transformationStream.map((v) => v.straightenDegrees).distinct().listen(_onStraightenDegreesChanged)); widget.transformController.aspectRatioNotifier.addListener(_onCropAspectRatioChanged); } void _unregisterWidget(Cropper widget) { _subscriptions ..forEach((sub) => sub.cancel()) ..clear(); widget.transformController.aspectRatioNotifier.removeListener(_onCropAspectRatioChanged); } @override Widget build(BuildContext context) { return Positioned.fill( child: ValueListenableBuilder( valueListenable: widget.marginNotifier, builder: (context, margin, child) { return ValueListenableBuilder( valueListenable: _outlineNotifier, builder: (context, outline, child) { if (outline.isEmpty) return const SizedBox(); final outlineVisualRect = outline.translate(margin.left, margin.top); return Stack( children: [ Positioned.fill( child: IgnorePointer( child: Stack( children: [ _buildDashLine([outlineVisualRect.topLeft, outlineVisualRect.topRight]), _buildDashLine([outlineVisualRect.bottomLeft, outlineVisualRect.bottomRight]), _buildDashLine([outlineVisualRect.topLeft, outlineVisualRect.bottomLeft]), _buildDashLine([outlineVisualRect.topRight, outlineVisualRect.bottomRight]), Positioned.fill( child: ValueListenableBuilder( valueListenable: _gridDivisionNotifier, builder: (context, gridDivision, child) { return ValueListenableBuilder( valueListenable: _gridOpacity, builder: (context, gridOpacity, child) { return CustomPaint( painter: CropperPainter( rect: outlineVisualRect, gridOpacity: gridOpacity, gridDivision: gridDivision, ), ); }, ); }, ), ), ], ), ), ), _buildVertexHandle( margin: margin, getPosition: () => outline.topLeft, setPosition: (v) => _handleOutline( left: min(outline.right - minDimension, v.dx), top: min(outline.bottom - minDimension, v.dy), ), ), _buildVertexHandle( margin: margin, getPosition: () => outline.topRight, setPosition: (v) => _handleOutline( right: max(outline.left + minDimension, v.dx), top: min(outline.bottom - minDimension, v.dy), ), ), _buildVertexHandle( margin: margin, getPosition: () => outline.bottomRight, setPosition: (v) => _handleOutline( right: max(outline.left + minDimension, v.dx), bottom: max(outline.top + minDimension, v.dy), ), ), _buildVertexHandle( margin: margin, getPosition: () => outline.bottomLeft, setPosition: (v) => _handleOutline( left: min(outline.right - minDimension, v.dx), bottom: max(outline.top + minDimension, v.dy), ), ), _buildEdgeHandle( margin: margin, getEdge: () => Rect.fromPoints(outline.bottomLeft, outline.topLeft), setEdge: (v) => _handleOutline( left: min(outline.right - minDimension, v.left), ), ), _buildEdgeHandle( margin: margin, getEdge: () => Rect.fromPoints(outline.topLeft, outline.topRight), setEdge: (v) => _handleOutline( top: min(outline.bottom - minDimension, v.top), ), ), _buildEdgeHandle( margin: margin, getEdge: () => Rect.fromPoints(outline.bottomRight, outline.topRight), setEdge: (v) => _handleOutline( right: max(outline.left + minDimension, v.right), ), ), _buildEdgeHandle( margin: margin, getEdge: () => Rect.fromPoints(outline.bottomLeft, outline.bottomRight), setEdge: (v) => _handleOutline( bottom: max(outline.top + minDimension, v.bottom), ), ), ], ); }, ); }, ), ); } // use 1 painter per line so that the dashes of one line // do not get offset depending on the previous line length Widget _buildDashLine(List points) => CustomPaint( painter: DashedPathPainter( originalPath: Path()..addPolygon(points, false), pathColor: CropperPainter.borderColor, strokeWidth: CropperPainter.borderWidth, ), ); void _handleOutline({ double? left, double? top, double? right, double? bottom, }) { final currentOutline = _outlineNotifier.value; var targetOutline = Rect.fromLTRB( left ?? currentOutline.left, top ?? currentOutline.top, right ?? currentOutline.right, bottom ?? currentOutline.bottom, ); _RatioStrategy? ratioStrategy; if (left != null && top != null && right != null) { ratioStrategy = _RatioStrategy.pinBottom; } else if (top != null && right != null && bottom != null) { ratioStrategy = _RatioStrategy.pinLeft; } else if (left != null && right != null && bottom != null) { ratioStrategy = _RatioStrategy.pinTop; } else if (left != null && top != null && bottom != null) { ratioStrategy = _RatioStrategy.pinRight; } else if (left != null && top != null) { ratioStrategy = _RatioStrategy.pinBottomRight; } else if (left != null) { ratioStrategy = _RatioStrategy.pinTopRight; } else if (top != null) { ratioStrategy = _RatioStrategy.pinBottomLeft; } else if (right != null) { ratioStrategy = _RatioStrategy.pinTopLeft; } else if (bottom != null) { ratioStrategy = _RatioStrategy.pinTopLeft; } if (ratioStrategy != null) { targetOutline = _applyCropRatioToOutline(targetOutline, ratioStrategy); } // do not try to coerce outline handled outside tilted image if (transformation.straightenDegrees != 0 && !_isOutlineContained(targetOutline)) return; // dismiss if we could not honour aspect ratio if (cropAspectRatio != CropAspectRatio.free && !_isOutlineContained(targetOutline)) return; final currentState = _getViewState(); final boundaries = magnifierController.scaleBoundaries; if (currentState == null || boundaries == null) return; final gestureRegion = _regionFromOutline(currentState, targetOutline); final viewportSize = boundaries.viewportSize; final gestureOutline = _containedOutlineFromRegion(currentState, gestureRegion); final clampedOutline = Rect.fromLTRB( max(gestureOutline.left, 0), max(gestureOutline.top, 0), min(gestureOutline.right, viewportSize.width), min(gestureOutline.bottom, viewportSize.height), ); var nextOutline = clampedOutline; if (max(gestureOutline.width - clampedOutline.width, gestureOutline.height - clampedOutline.height) > precisionErrorTolerance) { // zoom out when user gesture reaches outer edges final targetOutline = Rect.lerp(clampedOutline, gestureOutline, overOutlineFactor)!; final targetRegion = _regionFromOutline(currentState, targetOutline); final nextState = _viewStateForContainedRegion(boundaries, targetRegion); if (nextState != currentState) { magnifierController.update( position: nextState.position, scale: nextState.scale, source: ChangeSource.animation, ); nextOutline = _containedOutlineFromRegion(nextState, targetRegion); } } _setOutline(nextOutline); } bool _isOutlineContained(Rect outline) { final currentState = _getViewState(); final boundaries = magnifierController.scaleBoundaries; if (currentState == null || boundaries == null) return false; final regionToOutlineMatrix = _getRegionToOutlineMatrix(currentState); final outlineToRegionMatrix = Matrix4.inverted(regionToOutlineMatrix); final regionCorners = { outline.topLeft, outline.topRight, outline.bottomRight, outline.bottomLeft, }.map(outlineToRegionMatrix.transformOffset).toSet(); final contentRect = Offset.zero & boundaries.contentSize; return regionCorners.every((v) => contentRect.containsIncludingBottomRight(v, tolerance: precisionErrorTolerance)); } VertexHandle _buildVertexHandle({ required EdgeInsets margin, required ValueGetter getPosition, required ValueSetter setPosition, }) { return VertexHandle( margin: margin, getPosition: getPosition, setPosition: setPosition, onDragStart: _onResizeStart, onDragEnd: _onResizeEnd, ); } EdgeHandle _buildEdgeHandle({ required EdgeInsets margin, required ValueGetter getEdge, required ValueSetter setEdge, }) { return EdgeHandle( margin: margin, getEdge: getEdge, setEdge: setEdge, onDragStart: _onResizeStart, onDragEnd: _onResizeEnd, ); } void _onResizeStart() { transformController.activity = TransformActivity.resize; } void _onResizeEnd() { transformController.activity = TransformActivity.none; _showRegion(); } void _showRegion() { final boundaries = magnifierController.scaleBoundaries; if (boundaries == null) return; final region = transformation.region; final nextState = _viewStateForContainedRegion(boundaries, region); magnifierController.update( position: nextState.position, scale: nextState.scale, source: ChangeSource.animation, ); _setOutline(_containedOutlineFromRegion(nextState, region)); } ViewState _viewStateForContainedRegion(ScaleBoundaries boundaries, CropRegion imageRegion) { final matrix = transformation.matrix; final displayRegion = imageRegion.corners.map(matrix.transformOffset).toSet(); final xMin = displayRegion.map((v) => v.dx).min; final xMax = displayRegion.map((v) => v.dx).max; final yMin = displayRegion.map((v) => v.dy).min; final yMax = displayRegion.map((v) => v.dy).max; final displayRegionSize = Size(xMax - xMin, yMax - yMin); final nextScale = boundaries.clampScale(ScaleLevel.scaleForContained(boundaries.viewportSize, displayRegionSize)); final nextPosition = boundaries.clampPosition( position: boundaries.contentToStatePosition(nextScale, imageRegion.center), scale: nextScale, ); return ViewState( position: nextPosition, scale: nextScale, viewportSize: boundaries.viewportSize, contentSize: boundaries.contentSize, ); } void _onTransformActivity(TransformActivity activity) { switch (activity) { case TransformActivity.none: _showRegion(); case TransformActivity.pan: case TransformActivity.resize: _gridDivisionNotifier.value = panResizeGridDivision; case TransformActivity.straighten: _gridDivisionNotifier.value = straightenGridDivision; } if (activity == TransformActivity.none) { _gridAnimationController.reverse(); } else { _gridAnimationController.forward(); } } void _onOrientationChanged(TransformOrientation orientation) { _showRegion(); } void _onStraightenDegreesChanged(double degrees) { _updateCropRegion(); } void _onCropAspectRatioChanged() { final viewState = _getViewState(); if (viewState == null) return; var targetOutline = _applyCropRatioToOutline(_outlineNotifier.value, _RatioStrategy.keepArea); if (!_isOutlineContained(targetOutline)) { targetOutline = _applyCropRatioToOutline(_outlineNotifier.value, _RatioStrategy.contain); } transformController.cropRegion = _regionFromOutline(viewState, targetOutline); _showRegion(); } void _onViewStateChanged(MagnifierState state) { switch (transformController.activity) { case TransformActivity.none: break; case TransformActivity.straighten: case TransformActivity.pan: final currentOutline = _outlineNotifier.value; _setOutline(_applyCropRatioToOutline(currentOutline, _RatioStrategy.contain)); case TransformActivity.resize: break; } } void _onViewportSizeChanged(Size viewportSize) { final boundaries = magnifierController.scaleBoundaries; if (boundaries != null) { magnifierController.setScaleBoundaries( boundaries.copyWith( padding: _getBoundariesPadding, ), ); } _showRegion(); } EdgeInsets _getBoundariesPadding(double scale) { // TODO TLAD handle orientation if (transformation.orientation != TransformOrientation.normal) { return const EdgeInsets.all(double.infinity); } // TODO TLAD handle straightening if (transformation.straightenDegrees != 0) { return const EdgeInsets.all(double.infinity); } final viewState = _getViewState(); if (viewState != null) { final viewportSize = viewState.viewportSize; final contentSize = viewState.contentSize; if (viewportSize != null && contentSize != null) { final fullRegion = CropRegion.fromRect(Offset.zero & contentSize); final fullOutline = _containingOutlineFromRegion(viewState, fullRegion); final cropOutline = _outlineNotifier.value; final paddingWidth = max(0.0, (min(fullOutline.width, viewportSize.width) - cropOutline.width) / 2); final paddingHeight = max(0.0, (min(fullOutline.height, viewportSize.height) - cropOutline.height) / 2); return EdgeInsets.symmetric(vertical: paddingHeight, horizontal: paddingWidth); } } return EdgeInsets.zero; } ViewState? _getViewState() { final scaleBoundaries = magnifierController.scaleBoundaries; if (scaleBoundaries == null) return null; final state = magnifierController.currentState; return ViewState( position: state.position, scale: state.scale, viewportSize: scaleBoundaries.viewportSize, contentSize: scaleBoundaries.contentSize, ); } void _setOutline(Rect targetOutline) { final viewState = _getViewState(); final viewportSize = viewState?.viewportSize; if (targetOutline.isEmpty || viewState == null || viewportSize == null) return; // ensure outline is within content var targetRegion = _regionFromOutline(viewState, targetOutline); var newOutline = _containedOutlineFromRegion(viewState, targetRegion); final outlineWidthDelta = targetOutline.width - newOutline.width; final outlineHeightDelta = targetOutline.height - newOutline.height; if (outlineWidthDelta > precisionErrorTolerance || outlineHeightDelta > precisionErrorTolerance) { // keep outline area if possible, otherwise trim final regionToOutlineMatrix = _getRegionToOutlineMatrix(viewState); final rect = Offset.zero & viewState.contentSize!; final edgeRegionCorners = targetRegion.corners.where((v) => v.dx == rect.left || v.dx == rect.right || v.dy == rect.top || v.dy == rect.bottom).toSet(); final edgeOutlineCorners = edgeRegionCorners.map(regionToOutlineMatrix.transformOffset).toSet(); if (edgeOutlineCorners.isNotEmpty) { final direction = edgeOutlineCorners.map((v) => newOutline.center - v).reduce((prev, v) => prev + v); final movedOutline = targetOutline.shift(Offset( outlineWidthDelta * direction.dx.sign, outlineHeightDelta * direction.dy.sign, )); targetRegion = _regionFromOutline(viewState, movedOutline); newOutline = _containedOutlineFromRegion(viewState, targetRegion); } } // ensure outline is large enough to be handled newOutline = Rect.fromLTWH( newOutline.left, newOutline.top, max(newOutline.width, minDimension), max(newOutline.height, minDimension), ); _outlineNotifier.value = newOutline; switch (transformController.activity) { case TransformActivity.pan: case TransformActivity.resize: _updateCropRegion(); break; case TransformActivity.none: case TransformActivity.straighten: break; } } void _updateCropRegion() { final viewState = _getViewState(); final outline = _outlineNotifier.value; if (viewState != null && !outline.isEmpty) { transformController.cropRegion = _regionFromOutline(viewState, outline); } } Matrix4 _getRegionToOutlineMatrix(ViewState viewState) { final magnifierMatrix = viewState.matrix; final viewportCenter = viewState.viewportSize!.center(Offset.zero); final transformOrigin = Matrix4.inverted(magnifierMatrix).transformOffset(viewportCenter); final transformMatrix = Matrix4.identity() ..translate(transformOrigin.dx, transformOrigin.dy) ..multiply(transformation.matrix) ..translate(-transformOrigin.dx, -transformOrigin.dy); return magnifierMatrix..multiply(transformMatrix); } CropRegion _regionFromOutline(ViewState viewState, Rect outline) { final regionToOutlineMatrix = _getRegionToOutlineMatrix(viewState); final outlineToRegionMatrix = regionToOutlineMatrix..invert(); final rect = Offset.zero & viewState.contentSize!; double clampX(double dx) => dx.clamp(rect.left, rect.right); double clampY(double dy) => dy.clamp(rect.top, rect.bottom); Offset clampPoint(Offset v) => Offset(clampX(v.dx), clampY(v.dy)); Offset transform(Offset v) => clampPoint(outlineToRegionMatrix.transformOffset(v)); final clampedRegion = CropRegion( topLeft: transform(outline.topLeft), topRight: transform(outline.topRight), bottomRight: transform(outline.bottomRight), bottomLeft: transform(outline.bottomLeft), ); return clampedRegion; } Rect _containingOutlineFromRegion(ViewState viewState, CropRegion region) { final regionToOutlineMatrix = _getRegionToOutlineMatrix(viewState); final points = region.corners.map(regionToOutlineMatrix.transformOffset).toList(); final dxSet = points.map((v) => v.dx).toSet(); final dySet = points.map((v) => v.dy).toSet(); final topLeft = Offset(dxSet.reduce(min), dySet.reduce(min)); final bottomRight = Offset(dxSet.reduce(max), dySet.reduce(max)); return Rect.fromPoints(topLeft, bottomRight); } Rect _containedOutlineFromRegion(ViewState viewState, CropRegion region) { final regionToOutlineMatrix = _getRegionToOutlineMatrix(viewState); final points = region.corners.map(regionToOutlineMatrix.transformOffset).toList(); final sortedX = points.map((v) => v.dx).toList()..sort(); final sortedY = points.map((v) => v.dy).toList()..sort(); final topLeft = Offset(sortedX[1], sortedY[1]); final bottomRight = Offset(sortedX[2], sortedY[2]); return Rect.fromPoints(topLeft, bottomRight); } Rect _applyCropRatioToOutline(Rect outline, _RatioStrategy strategy) { final currentState = _getViewState(); final boundaries = magnifierController.scaleBoundaries; if (currentState == null || boundaries == null) return outline; final contentSize = boundaries.contentSize; late int longCoef; late int shortCoef; switch (cropAspectRatio) { case CropAspectRatio.free: return outline; case CropAspectRatio.original: longCoef = contentSize.longestSide.round(); shortCoef = contentSize.shortestSide.round(); case CropAspectRatio.square: longCoef = 1; shortCoef = 1; case CropAspectRatio.ar_16_9: longCoef = 16; shortCoef = 9; case CropAspectRatio.ar_4_3: longCoef = 4; shortCoef = 3; } final contentRect = Offset.zero & contentSize; final isLandscape = (outline.width - outline.height).abs() > precisionErrorTolerance ? outline.width > outline.height : contentSize.width > contentSize.height; final newRatio = isLandscape ? longCoef / shortCoef : shortCoef / longCoef; Size sizeToKeepArea() { final f = (outline.longestSide + outline.shortestSide) / (longCoef + shortCoef); final newLongest = f * longCoef; final newShortest = f * shortCoef; return isLandscape ? Size(newLongest, newShortest) : Size(newShortest, newLongest); } final regionToOutlineMatrix = _getRegionToOutlineMatrix(currentState); final outlineToRegionMatrix = Matrix4.inverted(regionToOutlineMatrix); Rect pinnedRect(Rect Function(Size targetSize) forSize) { final targetSize = sizeToKeepArea(); final rect = forSize(targetSize); // do not try to coerce outline handled outside tilted image if (transformation.straightenDegrees != 0) return rect; final regionCorners = { rect.topLeft, rect.topRight, rect.bottomRight, rect.bottomLeft, }.map(outlineToRegionMatrix.transformOffset).toSet(); if (regionCorners.every((v) => contentRect.containsIncludingBottomRight(v, tolerance: precisionErrorTolerance))) return rect; final clampedOutlineCorners = regionCorners.map((v) => regionToOutlineMatrix.transformOffset(Offset(v.dx.clamp(0, contentSize.width), v.dy.clamp(0, contentSize.height)))).toSet(); final minX = clampedOutlineCorners.map((v) => v.dx).min; final maxX = clampedOutlineCorners.map((v) => v.dx).max; final minY = clampedOutlineCorners.map((v) => v.dy).min; final maxY = clampedOutlineCorners.map((v) => v.dy).max; var width = rect.width; var height = rect.height; if (rect.left < minX - precisionErrorTolerance) { width = rect.right - minX; height = width / newRatio; } else if (rect.top < minY - precisionErrorTolerance) { height = rect.bottom - minY; width = height * newRatio; } else if (rect.right > maxX + precisionErrorTolerance) { width = maxX - rect.left; height = width / newRatio; } else if (rect.bottom > maxY + precisionErrorTolerance) { height = maxY - rect.top; width = height * newRatio; } final clampedSize = Size(width, height); return clampedSize < targetSize ? forSize(clampedSize) : rect; } switch (strategy) { case _RatioStrategy.keepArea: final targetSize = sizeToKeepArea(); return Rect.fromCenter( center: outline.center, width: targetSize.width, height: targetSize.height, ); case _RatioStrategy.contain: final currentRatio = outline.width / outline.height; if ((newRatio - currentRatio).abs() < precisionErrorTolerance) { return outline; } else { late final Size targetSize; if (newRatio > currentRatio) { targetSize = Size(outline.width, outline.width / newRatio); } else { targetSize = Size(outline.height * newRatio, outline.height); } return Rect.fromCenter( center: outline.center, width: targetSize.width, height: targetSize.height, ); } case _RatioStrategy.pinTopLeft: return pinnedRect((targetSize) => Rect.fromPoints( outline.topLeft, outline.topLeft.translate(targetSize.width, targetSize.height), )); case _RatioStrategy.pinTopRight: return pinnedRect((targetSize) => Rect.fromPoints( outline.topRight, outline.topRight.translate(-targetSize.width, targetSize.height), )); case _RatioStrategy.pinBottomRight: return pinnedRect((targetSize) => Rect.fromPoints( outline.bottomRight, outline.bottomRight.translate(-targetSize.width, -targetSize.height), )); case _RatioStrategy.pinBottomLeft: return pinnedRect((targetSize) => Rect.fromPoints( outline.bottomLeft, outline.bottomLeft.translate(targetSize.width, -targetSize.height), )); case _RatioStrategy.pinLeft: return pinnedRect((targetSize) => Rect.fromLTRB( outline.left, outline.center.dy - targetSize.height / 2, outline.left + targetSize.width, outline.center.dy + targetSize.height / 2, )); case _RatioStrategy.pinTop: return pinnedRect((targetSize) => Rect.fromLTRB( outline.center.dx - targetSize.width / 2, outline.top, outline.center.dx + targetSize.width / 2, outline.top + targetSize.height, )); case _RatioStrategy.pinRight: return pinnedRect((targetSize) => Rect.fromLTRB( outline.right - targetSize.width, outline.center.dy - targetSize.height / 2, outline.right, outline.center.dy + targetSize.height / 2, )); case _RatioStrategy.pinBottom: return pinnedRect((targetSize) => Rect.fromLTRB( outline.center.dx - targetSize.width / 2, outline.bottom - targetSize.height, outline.center.dx + targetSize.width / 2, outline.bottom, )); } } } enum _RatioStrategy { keepArea, contain, pinTopLeft, pinTopRight, pinBottomRight, pinBottomLeft, pinLeft, pinTop, pinRight, pinBottom }