diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..8c3f525 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,61 @@ +This document describes how crop_your_image is implemented. This is not only for the maintainers but also Cursor editor, which supports coding and refactoring using AI. + +# Architecture of crop_your_image + +## Overview + +At first, crop_your_image is a Flutter package that provides a widget named `Crop` and its controller `CropController`. + +Because this package requires a lot of calculations for detecting the cropping area, we have internally `Calculator` class. + +In addition, `Crop` also provides a mechanism to interchange backend logics for: + +- Determining the given image format +- Parsing the image from `Uint8List` to whatever data type +- Cropping the image with given rect + +The default backend uses `image` package for all the image processing, but this mechanism allows you to use other packages, or even delegate the processing to your server side. + +## Crop + +`Crop` is designed to be a handy widget that can be used at anywhere; this may be used in a `Dialog`, or a `BottomSheet`, etc. + +To achieve the design, `Crop` first checks the size of its viewport respecting the given constraints, then it overrides the `MediaQueryData.size` with the size so that its subtree can find the desired size calling `MediaQuery.sizeOf(context)`. + +This mechanism introduces one restriction that the user must supply the size of the viewport to `Crop` explicitly, typically using `SizedBox`, `AspectRatio`, or `Expanded`. + +`Crop`, or internal `_CropEditor` widget, doesn't use `InteractiveViewer` but does all the calculations for zooming and panning based on the given info by `GestureDetector`. This is because `InteractiveViewer` doesn't provide a handy way to get the current zoom level and pan position, which are required for the cropping area calculation. + +## CropController + +Users may want to imperatively control `Crop` widget, as in `TextField` has `TextEditingController`. Thus, `Crop` provides `CropController` to allow users to control the widget, such as calling `crop()`. + +Once the controller is given to `Crop`, this initializes the controller in `initState()` with attaching `CropControllerDelegate` and its methods declared with `late` keyword. + +TODO(chooyan-eng): Revise the implementation of `TextEditingController` or other controller patterns and if `CropController` follows the same pattern. + +## Calculator + +`Calculator` class is a utility to calculate various data for cropping. + +crop_your_image requires various kinds of data, such as: + +- Actual image size +- Displaying image size on viewport +- Current zoom level +- Current pan position +- The rect of the cropping area based on given viewport +- The rect of the cropping area based on given image size +- Given aspect ratio for cropping area +- Given initial size for cropping area + +meaning the calculation logic is always complex. + +To keep the code clean and testable, `Calculator` provides all the calculation logic in a pure Dart logic. + +The calculation logic changes depending on the given image fits vertically or horizontally to the viewport. So `Calculator` has two implementations, `VerticalCalculator` and `HorizontalCalculator`. + +TODO(chooyan-eng): Revise the calculation logic must be separated depending on the given image aspect ratio. There can be a nice calculation to be applied regardless of the image orientation. + +TODO(chooyan-eng): Currently, `Calculator` exposes the *methods* for calculation and `Crop` calls the methods whenever it needs, however, this can be refactored with the idea mentioned in https://github.com/chooyan-eng/complex_local_state_management/blob/main/docs/local_state.md + diff --git a/lib/src/widget/calculator.dart b/lib/src/widget/calculator.dart index b812341..609dfac 100644 --- a/lib/src/widget/calculator.dart +++ b/lib/src/widget/calculator.dart @@ -18,7 +18,7 @@ abstract class Calculator { double scaleToCover(Size screenSize, ViewportBasedRect imageRect); /// calculates ratio of [targetImage] and [screenSize] - double screenSizeRatio(ImageDetail targetImage, Size screenSize); + double screenSizeRatio(Size imageSize, Size screenSize); /// calculates [ViewportBasedRect] of the result of user moving the cropping area. ViewportBasedRect moveRect( @@ -307,8 +307,8 @@ class HorizontalCalculator extends Calculator { } @override - double screenSizeRatio(ImageDetail targetImage, Size screenSize) { - return targetImage.width / screenSize.width; + double screenSizeRatio(Size imageSize, Size screenSize) { + return imageSize.width / screenSize.width; } } @@ -352,7 +352,7 @@ class VerticalCalculator extends Calculator { } @override - double screenSizeRatio(ImageDetail targetImage, Size screenSize) { - return targetImage.height / screenSize.height; + double screenSizeRatio(Size imageSize, Size screenSize) { + return imageSize.height / screenSize.height; } } diff --git a/lib/src/widget/crop.dart b/lib/src/widget/crop.dart index c703597..e574469 100644 --- a/lib/src/widget/crop.dart +++ b/lib/src/widget/crop.dart @@ -1,11 +1,10 @@ import 'dart:async'; -import 'dart:math'; import 'package:crop_your_image/crop_your_image.dart'; import 'package:crop_your_image/src/logic/shape.dart'; -import 'package:crop_your_image/src/widget/calculator.dart'; import 'package:crop_your_image/src/widget/circle_crop_area_clipper.dart'; import 'package:crop_your_image/src/widget/constants.dart'; +import 'package:crop_your_image/src/widget/crop_editor_view_state.dart'; import 'package:crop_your_image/src/widget/rect_crop_area_clipper.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -73,7 +72,7 @@ class Crop extends StatelessWidget { /// As oval shape is not supported, [aspectRatio] is fixed to 1 if [withCircleUi] is true. final bool withCircleUi; - /// conroller for control crop actions + /// controller for control crop actions final CropController? controller; /// Callback called when cropping rect changes for any reasons. @@ -261,57 +260,24 @@ class _CropEditor extends StatefulWidget { } class _CropEditorState extends State<_CropEditor> { + /// controller for crop actions late CropController _cropController; + /// an object that preserve and expose all the state for _CropEditor + late CropEditorViewState _viewState; + ReadyCropEditorViewState get _readyState => + _viewState as ReadyCropEditorViewState; + /// image with detail info parsed with [widget.imageParser] ImageDetail? _parsedImageDetail; - /// [Size] of viewport - /// This is equivalent to [MediaQuery.of(context).size] - late Size _viewportSize; - - /// [ViewportBasedRect] of displaying image - /// Note that this is not the actual [Size] of the image. - late ViewportBasedRect _imageRect; - - /// for cropping editor - double? _aspectRatio; - bool _withCircleUi = false; - bool _isFitVertically = false; - - /// [ViewportBasedRect] of cropping area - /// The result of cropping is based on this [_cropRect]. - late ViewportBasedRect _cropRect; - set cropRect(ViewportBasedRect newRect) { - setState(() => _cropRect = newRect); - - final screenSizeRatio = calculator.screenSizeRatio( - _parsedImageDetail!, - _viewportSize, - ); - final imageBaseRect = Rect.fromLTWH( - (_cropRect.left - _imageRect.left) * screenSizeRatio / _scale, - (_cropRect.top - _imageRect.top) * screenSizeRatio / _scale, - _cropRect.width * screenSizeRatio / _scale, - _cropRect.height * screenSizeRatio / _scale, - ); - widget.onMoved?.call(_cropRect, imageBaseRect); - } - - bool get _isImageLoading => _lastComputed != null; - - Calculator get calculator => _isFitVertically - ? const VerticalCalculator() - : const HorizontalCalculator(); - + /// detected image format with [widget.formatDetector] ImageFormat? _detectedFormat; @override void initState() { super.initState(); - _withCircleUi = widget.withCircleUi; - // prepare for controller _cropController = widget.controller ?? CropController(); _cropController.delegate = CropControllerDelegate() @@ -320,38 +286,78 @@ class _CropEditorState extends State<_CropEditor> { _resizeWith(aspectRatio, null); } ..onChangeWithCircleUi = (withCircleUi) { - _withCircleUi = withCircleUi; + _viewState = _readyState.copyWith(withCircleUi: withCircleUi); _resizeWith(null, null); } ..onImageChanged = _resetImage ..onChangeCropRect = (newCropRect) { - cropRect = calculator.correct(newCropRect, _imageRect); + _updateCropRect(_readyState.correct(newCropRect)); } ..onChangeArea = (newArea) { - _resizeWith(_aspectRatio, newArea); + _resizeWith(widget.aspectRatio, newArea); }; } @override void didChangeDependencies() { - _viewportSize = MediaQuery.of(context).size; + _viewState = PreparingCropEditorViewState( + viewportSize: MediaQuery.of(context).size, + withCircleUi: widget.withCircleUi, + aspectRatio: widget.aspectRatio, + ); + /// parse image with given parser and format detector _parseImageWith( parser: widget.imageParser, formatDetector: widget.formatDetector, image: widget.image, - ); + ).then((parsed) { + if (parsed != null) { + setState(() { + _viewState = (_viewState as PreparingCropEditorViewState).prepared( + Size(parsed.width, parsed.height), + ); + }); + _resetCropRect(); + widget.onStatusChanged?.call(CropStatus.ready); + } + }); + super.didChangeDependencies(); } + /// apply crop rect changed to view state + void _updateCropRect(CropEditorViewState newState) { + setState(() => _viewState = newState); + widget.onMoved?.call(_readyState.cropRect, _readyState.imageBaseRect); + } + /// reset image to be cropped void _resetImage(Uint8List targetImage) { widget.onStatusChanged?.call(CropStatus.loading); + + /// reset view state back to preparing state + _viewState = PreparingCropEditorViewState( + viewportSize: MediaQuery.of(context).size, + withCircleUi: widget.withCircleUi, + aspectRatio: widget.aspectRatio, + ); + _parseImageWith( parser: widget.imageParser, formatDetector: widget.formatDetector, image: targetImage, - ); + ).then((parsed) { + if (parsed != null) { + setState(() { + _viewState = (_viewState as PreparingCropEditorViewState).prepared( + Size(parsed.width, parsed.height), + ); + }); + _resetCropRect(); + widget.onStatusChanged?.call(CropStatus.ready); + } + }); } /// temporary field to detect last computed. @@ -360,16 +366,16 @@ class _CropEditorState extends State<_CropEditor> { Uint8List? _lastImage; Future? _lastComputed; - void _parseImageWith({ + Future _parseImageWith({ required ImageParser parser, required FormatDetector? formatDetector, required Uint8List image, - }) { + }) async { if (_lastParser == parser && _lastImage == image && _lastFormatDetector == formatDetector) { // no change - return; + return null; } _lastParser = parser; @@ -382,79 +388,65 @@ class _CropEditorState extends State<_CropEditor> { [widget.imageParser, format, image], ); _lastComputed = future; - future.then((parsed) { - // check if Crop is still alive - if (!mounted) { - return; - } + final parsed = await future; + // check if Crop is still alive + if (!mounted) { + return null; + } - // if _parseImageWith() is called again before future completed, - // just skip and the last future is used. - if (_lastComputed == future) { - setState(() { - _parsedImageDetail = parsed; - _lastComputed = null; - _detectedFormat = format; - }); - _resetCropRect(); - widget.onStatusChanged?.call(CropStatus.ready); - } - }); + // if _parseImageWith() is called again before future completed, + // just skip and the last future is used. + if (_lastComputed == future) { + // cache parsed image for future use of _crop() + _parsedImageDetail = parsed; + _lastComputed = null; + _detectedFormat = format; + return parsed; + } + return null; } /// reset [ViewportBasedRect] of crop rect with current state void _resetCropRect() { - final screenSize = _viewportSize; - - final imageAspectRatio = - _parsedImageDetail!.width / _parsedImageDetail!.height; - _isFitVertically = imageAspectRatio < screenSize.aspectRatio; - - _imageRect = calculator.imageRect(screenSize, imageAspectRatio); + setState(() { + _viewState = _readyState.resetCropRect(); + }); if (widget.initialRectBuilder != null) { - cropRect = widget.initialRectBuilder!( - Rect.fromLTWH( - 0, - 0, - screenSize.width, - screenSize.height, + _updateCropRect( + _readyState.copyWith( + cropRect: widget.initialRectBuilder!( + Rect.fromLTWH( + 0, + 0, + _readyState.viewportSize.width, + _readyState.viewportSize.height, + ), + _readyState.imageRect, + ), ), - _imageRect, ); } else { _resizeWith(widget.aspectRatio, widget.initialArea); } if (widget.interactive) { - final initialScale = calculator.scaleToCover(screenSize, _imageRect); - _applyScale(initialScale); + _applyScale(_readyState.scaleToCover); } } /// resize crop rect with given aspect ratio and area. void _resizeWith(double? aspectRatio, ImageBasedRect? area) { - _aspectRatio = _withCircleUi ? 1 : aspectRatio; - if (area == null) { - cropRect = calculator.initialCropRect( - _viewportSize, - _imageRect, - _aspectRatio ?? 1, - widget.initialSize ?? 1, + _updateCropRect( + _readyState.cropRectInitialized( + initialSize: widget.initialSize, + aspectRatio: aspectRatio, + ), ); } else { // calculate how smaller the viewport is than the image - final screenSizeRatio = calculator.screenSizeRatio( - _parsedImageDetail!, - _viewportSize, - ); - cropRect = Rect.fromLTWH( - _imageRect.left + area.left / screenSizeRatio, - _imageRect.top + area.top / screenSizeRatio, - area.width / screenSizeRatio, - area.height / screenSizeRatio, - ); + _updateCropRect(_readyState.cropRectWith(area)); } } @@ -462,11 +454,6 @@ class _CropEditorState extends State<_CropEditor> { Future _crop(bool withCircleShape) async { assert(_parsedImageDetail != null); - final screenSizeRatio = calculator.screenSizeRatio( - _parsedImageDetail!, - _viewportSize, - ); - widget.onStatusChanged?.call(CropStatus.cropping); // use compute() not to block UI update @@ -475,12 +462,7 @@ class _CropEditorState extends State<_CropEditor> { [ widget.imageCropper, _parsedImageDetail!.image, - Rect.fromLTWH( - (_cropRect.left - _imageRect.left) * screenSizeRatio / _scale, - (_cropRect.top - _imageRect.top) * screenSizeRatio / _scale, - _cropRect.width * screenSizeRatio / _scale, - _cropRect.height * screenSizeRatio / _scale, - ), + _readyState.rectToCrop, withCircleShape, _detectedFormat, ], @@ -491,31 +473,16 @@ class _CropEditorState extends State<_CropEditor> { } // for zooming - double _scale = 1.0; double _baseScale = 1.0; - void _startScale(ScaleStartDetails detail) { - _baseScale = _scale; + /// handle scale events with pinching + void _handleScaleStart(ScaleStartDetails detail) { + _baseScale = _readyState.scale; } - void _updateScale(ScaleUpdateDetails detail) { - // move - var movedLeft = _imageRect.left + detail.focalPointDelta.dx; - if (movedLeft + _imageRect.width < _cropRect.right) { - movedLeft = _cropRect.right - _imageRect.width; - } - - var movedTop = _imageRect.top + detail.focalPointDelta.dy; - if (movedTop + _imageRect.height < _cropRect.bottom) { - movedTop = _cropRect.bottom - _imageRect.height; - } + void _handleScaleUpdate(ScaleUpdateDetails detail) { setState(() { - _imageRect = ViewportBasedRect.fromLTWH( - min(_cropRect.left, movedLeft), - min(_cropRect.top, movedTop), - _imageRect.width, - _imageRect.height, - ); + _viewState = _readyState.offsetUpdated(detail.focalPointDelta); }); _applyScale( @@ -524,6 +491,24 @@ class _CropEditorState extends State<_CropEditor> { ); } + /// handle mouse pointer signal event + void _handlePointerSignal(PointerSignalEvent signal) { + if (signal is PointerScrollEvent) { + if (signal.scrollDelta.dy > 0) { + _applyScale( + _readyState.scale - widget.scrollZoomSensitivity, + focalPoint: signal.localPosition, + ); + } else if (signal.scrollDelta.dy < 0) { + _applyScale( + _readyState.scale + widget.scrollZoomSensitivity, + focalPoint: signal.localPosition, + ); + } + } + } + + /// apply scale updated to view state void _applyScale( double nextScale, { Offset? focalPoint, @@ -533,107 +518,40 @@ class _CropEditorState extends State<_CropEditor> { return; } - late double baseHeight; - late double baseWidth; - final ratio = _parsedImageDetail!.height / _parsedImageDetail!.width; - - if (_isFitVertically) { - baseHeight = _viewportSize.height; - baseWidth = baseHeight / ratio; - } else { - baseWidth = _viewportSize.width; - baseHeight = baseWidth * ratio; - } - - // clamp the scale - nextScale = max( - nextScale, - max(_cropRect.width / baseWidth, _cropRect.height / baseHeight), - ); - - if (_scale == nextScale) { - return; - } - - // width - final newWidth = baseWidth * nextScale; - final horizontalFocalPointBias = focalPoint == null - ? 0.5 - : (focalPoint.dx - _imageRect.left) / _imageRect.width; - final leftPositionDelta = - (newWidth - _imageRect.width) * horizontalFocalPointBias; - - // height - final newHeight = baseHeight * nextScale; - final verticalFocalPointBias = focalPoint == null - ? 0.5 - : (focalPoint.dy - _imageRect.top) / _imageRect.height; - final topPositionDelta = - (newHeight - _imageRect.height) * verticalFocalPointBias; - - // position - final newLeft = max( - min(_cropRect.left, _imageRect.left - leftPositionDelta), - _cropRect.right - newWidth); - final newTop = max(min(_cropRect.top, _imageRect.top - topPositionDelta), - _cropRect.bottom - newHeight); - - // apply setState(() { - _imageRect = Rect.fromLTRB( - newLeft, - newTop, - newLeft + newWidth, - newTop + newHeight, + _viewState = _readyState.scaleUpdated( + nextScale, + focalPoint: focalPoint, ); - _scale = nextScale; }); } @override Widget build(BuildContext context) { - return _isImageLoading + return !_viewState.isReady ? Center(child: widget.progressIndicator) : Stack( clipBehavior: widget.clipBehavior, children: [ Listener( - onPointerSignal: (signal) { - if (signal is PointerScrollEvent) { - if (signal.scrollDelta.dy > 0) { - _applyScale( - _scale - widget.scrollZoomSensitivity, - focalPoint: signal.localPosition, - ); - } else if (signal.scrollDelta.dy < 0) { - _applyScale( - _scale + widget.scrollZoomSensitivity, - focalPoint: signal.localPosition, - ); - } - //print(_scale); - } - }, + onPointerSignal: _handlePointerSignal, child: GestureDetector( - onScaleStart: widget.interactive ? _startScale : null, - onScaleUpdate: widget.interactive ? _updateScale : null, + onScaleStart: widget.interactive ? _handleScaleStart : null, + onScaleUpdate: widget.interactive ? _handleScaleUpdate : null, child: Container( color: widget.baseColor, width: MediaQuery.of(context).size.width, height: MediaQuery.of(context).size.height, child: Stack( children: [ + SizedBox.expand(), Positioned( - left: _imageRect.left, - top: _imageRect.top, + left: _readyState.imageRect.left, + top: _readyState.imageRect.top, child: Image.memory( widget.image, - width: _isFitVertically - ? null - : MediaQuery.of(context).size.width * _scale, - height: _isFitVertically - ? MediaQuery.of(context).size.height * _scale - : null, + width: _readyState.imageRect.width, + height: _readyState.imageRect.height, fit: BoxFit.contain, ), ), @@ -644,9 +562,9 @@ class _CropEditorState extends State<_CropEditor> { ), IgnorePointer( child: ClipPath( - clipper: _withCircleUi - ? CircleCropAreaClipper(_cropRect) - : CropAreaClipper(_cropRect, widget.radius), + clipper: _readyState.withCircleUi + ? CircleCropAreaClipper(_readyState.cropRect) + : CropAreaClipper(_readyState.cropRect, widget.radius), child: Container( width: double.infinity, height: double.infinity, @@ -656,99 +574,70 @@ class _CropEditorState extends State<_CropEditor> { ), if (!widget.interactive && !widget.fixCropRect) Positioned( - left: _cropRect.left, - top: _cropRect.top, + left: _readyState.cropRect.left, + top: _readyState.cropRect.top, child: GestureDetector( - onPanUpdate: (details) { - cropRect = calculator.moveRect( - _cropRect, - details.delta.dx, - details.delta.dy, - _imageRect, - ); - }, + onPanUpdate: (details) => _updateCropRect( + _readyState.moveRect(details.delta), + ), child: Container( - width: _cropRect.width, - height: _cropRect.height, + width: _readyState.cropRect.width, + height: _readyState.cropRect.height, color: Colors.transparent, ), ), ), Positioned( - left: _cropRect.left - (dotTotalSize / 2), - top: _cropRect.top - (dotTotalSize / 2), + left: _readyState.cropRect.left - (dotTotalSize / 2), + top: _readyState.cropRect.top - (dotTotalSize / 2), child: GestureDetector( onPanUpdate: widget.fixCropRect ? null - : (details) { - cropRect = calculator.moveTopLeft( - _cropRect, - details.delta.dx, - details.delta.dy, - _imageRect, - _aspectRatio, - ); - }, + : (details) => _updateCropRect( + _readyState.moveTopLeft(details.delta), + ), child: widget.cornerDotBuilder ?.call(dotTotalSize, EdgeAlignment.topLeft) ?? const DotControl(), ), ), Positioned( - left: _cropRect.right - (dotTotalSize / 2), - top: _cropRect.top - (dotTotalSize / 2), + left: _readyState.cropRect.right - (dotTotalSize / 2), + top: _readyState.cropRect.top - (dotTotalSize / 2), child: GestureDetector( onPanUpdate: widget.fixCropRect ? null - : (details) { - cropRect = calculator.moveTopRight( - _cropRect, - details.delta.dx, - details.delta.dy, - _imageRect, - _aspectRatio, - ); - }, + : (details) => _updateCropRect( + _readyState.moveTopRight(details.delta), + ), child: widget.cornerDotBuilder ?.call(dotTotalSize, EdgeAlignment.topRight) ?? const DotControl(), ), ), Positioned( - left: _cropRect.left - (dotTotalSize / 2), - top: _cropRect.bottom - (dotTotalSize / 2), + left: _readyState.cropRect.left - (dotTotalSize / 2), + top: _readyState.cropRect.bottom - (dotTotalSize / 2), child: GestureDetector( onPanUpdate: widget.fixCropRect ? null - : (details) { - cropRect = calculator.moveBottomLeft( - _cropRect, - details.delta.dx, - details.delta.dy, - _imageRect, - _aspectRatio, - ); - }, + : (details) => _updateCropRect( + _readyState.moveBottomLeft(details.delta), + ), child: widget.cornerDotBuilder ?.call(dotTotalSize, EdgeAlignment.bottomLeft) ?? const DotControl(), ), ), Positioned( - left: _cropRect.right - (dotTotalSize / 2), - top: _cropRect.bottom - (dotTotalSize / 2), + left: _readyState.cropRect.right - (dotTotalSize / 2), + top: _readyState.cropRect.bottom - (dotTotalSize / 2), child: GestureDetector( onPanUpdate: widget.fixCropRect ? null - : (details) { - cropRect = calculator.moveBottomRight( - _cropRect, - details.delta.dx, - details.delta.dy, - _imageRect, - _aspectRatio, - ); - }, + : (details) => _updateCropRect( + _readyState.moveBottomRight(details.delta), + ), child: widget.cornerDotBuilder ?.call(dotTotalSize, EdgeAlignment.bottomRight) ?? const DotControl(), diff --git a/lib/src/widget/crop_editor_view_state.dart b/lib/src/widget/crop_editor_view_state.dart new file mode 100644 index 0000000..eb8691e --- /dev/null +++ b/lib/src/widget/crop_editor_view_state.dart @@ -0,0 +1,311 @@ +import 'dart:math'; + +import 'package:crop_your_image/src/widget/calculator.dart'; +import 'package:flutter/widgets.dart'; +import 'package:crop_your_image/crop_your_image.dart'; + +/// state management class for _CropEditor +/// see the link below for more details +/// https://github.com/chooyan-eng/complex_local_state_management/blob/main/docs/local_state.md +interface class CropEditorViewState { + final Size viewportSize; + final bool withCircleUi; + final double? aspectRatio; + + late final bool isReady; + + CropEditorViewState({ + required this.viewportSize, + required this.withCircleUi, + required this.aspectRatio, + }); +} + +/// implementation of [CropEditorViewState] for preparing state +class PreparingCropEditorViewState extends CropEditorViewState { + PreparingCropEditorViewState({ + required super.viewportSize, + required super.withCircleUi, + required super.aspectRatio, + }); + + @override + bool isReady = false; + + ReadyCropEditorViewState prepared(Size imageSize) { + return ReadyCropEditorViewState.prepared( + imageSize, + viewportSize: viewportSize, + withCircleUi: withCircleUi, + aspectRatio: aspectRatio, + scale: 1.0, + ); + } +} + +/// implementation of [CropEditorViewState] for ready state +class ReadyCropEditorViewState extends CropEditorViewState { + final Size imageSize; + final ViewportBasedRect cropRect; + final ViewportBasedRect imageRect; + final double scale; + final Offset offset; + + factory ReadyCropEditorViewState.prepared( + Size imageSize, { + required Size viewportSize, + required double scale, + required double? aspectRatio, + required bool withCircleUi, + }) { + final isFitVertically = imageSize.aspectRatio < viewportSize.aspectRatio; + final calculator = + isFitVertically ? VerticalCalculator() : HorizontalCalculator(); + + return ReadyCropEditorViewState( + viewportSize: viewportSize, + imageSize: imageSize, + imageRect: calculator.imageRect(viewportSize, imageSize.aspectRatio), + cropRect: ViewportBasedRect.zero, + scale: scale, + aspectRatio: aspectRatio, + withCircleUi: withCircleUi, + ); + } + + ReadyCropEditorViewState({ + required super.viewportSize, + required this.imageSize, + required this.imageRect, + required this.cropRect, + required this.scale, + this.offset = Offset.zero, + required super.aspectRatio, + required super.withCircleUi, + }); + + @override + bool isReady = true; + + late final isFitVertically = imageSize.aspectRatio < viewportSize.aspectRatio; + + late final calculator = + isFitVertically ? VerticalCalculator() : HorizontalCalculator(); + + late final screenSizeRatio = calculator.screenSizeRatio( + imageSize, + viewportSize, + ); + + late final rectToCrop = ImageBasedRect.fromLTWH( + (cropRect.left - imageRect.left) * screenSizeRatio / scale, + (cropRect.top - imageRect.top) * screenSizeRatio / scale, + cropRect.width * screenSizeRatio / scale, + cropRect.height * screenSizeRatio / scale, + ); + + late final imageBaseRect = Rect.fromLTWH( + (cropRect.left - imageRect.left) * screenSizeRatio / scale, + (cropRect.top - imageRect.top) * screenSizeRatio / scale, + cropRect.width * screenSizeRatio / scale, + cropRect.height * screenSizeRatio / scale, + ); + + late final scaleToCover = calculator.scaleToCover(viewportSize, imageRect); + + ReadyCropEditorViewState imageSizeDetected(Size size) { + return copyWith(imageSize: size); + } + + ReadyCropEditorViewState resetCropRect() { + return copyWith( + imageRect: calculator.imageRect(viewportSize, imageSize.aspectRatio), + ); + } + + ReadyCropEditorViewState correct(ViewportBasedRect newCropRect) { + return copyWith(cropRect: calculator.correct(newCropRect, imageRect)); + } + + ReadyCropEditorViewState cropRectInitialized({ + double? initialSize, + double? aspectRatio, + }) { + final effectiveAspectRatio = withCircleUi ? 1.0 : aspectRatio ?? 1.0; + return copyWith( + cropRect: calculator.initialCropRect( + viewportSize, + imageRect, + effectiveAspectRatio, + initialSize ?? 1, + ), + ); + } + + ReadyCropEditorViewState cropRectWith(ImageBasedRect area) { + return copyWith( + cropRect: Rect.fromLTWH( + imageRect.left + area.left / screenSizeRatio, + imageRect.top + area.top / screenSizeRatio, + area.width / screenSizeRatio, + area.height / screenSizeRatio, + ), + ); + } + + // Methods for state updates + ReadyCropEditorViewState moveRect(Offset delta) { + final newCropRect = calculator.moveRect( + cropRect, + delta.dx, + delta.dy, + imageRect, + ); + return copyWith(cropRect: newCropRect); + } + + ReadyCropEditorViewState moveTopLeft(Offset delta) { + final newCropRect = calculator.moveTopLeft( + cropRect, + delta.dx, + delta.dy, + imageRect, + aspectRatio, + ); + return copyWith(cropRect: newCropRect); + } + + ReadyCropEditorViewState moveTopRight(Offset delta) { + final newCropRect = calculator.moveTopRight( + cropRect, + delta.dx, + delta.dy, + imageRect, + aspectRatio, + ); + return copyWith(cropRect: newCropRect); + } + + ReadyCropEditorViewState moveBottomLeft(Offset delta) { + final newCropRect = calculator.moveBottomLeft( + cropRect, + delta.dx, + delta.dy, + imageRect, + aspectRatio, + ); + return copyWith(cropRect: newCropRect); + } + + ReadyCropEditorViewState moveBottomRight(Offset delta) { + final newCropRect = calculator.moveBottomRight( + cropRect, + delta.dx, + delta.dy, + imageRect, + aspectRatio, + ); + return copyWith(cropRect: newCropRect); + } + + ReadyCropEditorViewState offsetUpdated(Offset delta) { + var movedLeft = imageRect.left + delta.dx; + if (movedLeft + imageRect.width < cropRect.right) { + movedLeft = cropRect.right - imageRect.width; + } + + var movedTop = imageRect.top + delta.dy; + if (movedTop + imageRect.height < cropRect.bottom) { + movedTop = cropRect.bottom - imageRect.height; + } + + return copyWith( + imageRect: ViewportBasedRect.fromLTWH( + min(cropRect.left, movedLeft), + min(cropRect.top, movedTop), + imageRect.width, + imageRect.height, + ), + ); + } + + ReadyCropEditorViewState scaleUpdated( + double nextScale, { + Offset? focalPoint, + }) { + final baseSize = isFitVertically + ? Size( + viewportSize.height * imageSize.aspectRatio, + viewportSize.height, + ) + : Size( + viewportSize.width, + viewportSize.width / imageSize.aspectRatio, + ); + + // clamp the scale + nextScale = max( + nextScale, + max(cropRect.width / baseSize.width, cropRect.height / baseSize.height), + ); + + // no change + if (scale == nextScale) { + return this; + } + + // width + final newWidth = baseSize.width * nextScale; + final horizontalFocalPointBias = focalPoint == null + ? 0.5 + : (focalPoint.dx - imageRect.left) / imageRect.width; + final leftPositionDelta = + (newWidth - imageRect.width) * horizontalFocalPointBias; + + // height + final newHeight = baseSize.height * nextScale; + final verticalFocalPointBias = focalPoint == null + ? 0.5 + : (focalPoint.dy - imageRect.top) / imageRect.height; + final topPositionDelta = + (newHeight - imageRect.height) * verticalFocalPointBias; + + // position + final newLeft = max(min(cropRect.left, imageRect.left - leftPositionDelta), + cropRect.right - newWidth); + final newTop = max(min(cropRect.top, imageRect.top - topPositionDelta), + cropRect.bottom - newHeight); + + return copyWith( + scale: nextScale, + imageRect: ViewportBasedRect.fromLTWH( + newLeft, + newTop, + newWidth, + newHeight, + ), + ); + } + + ReadyCropEditorViewState copyWith({ + Size? viewportSize, + Size? imageSize, + ViewportBasedRect? imageRect, + ViewportBasedRect? cropRect, + double? scale, + Offset? offset, + double? aspectRatio, + bool? withCircleUi, + }) { + return ReadyCropEditorViewState( + viewportSize: viewportSize ?? this.viewportSize, + imageSize: imageSize ?? this.imageSize, + imageRect: imageRect ?? this.imageRect, + cropRect: cropRect ?? this.cropRect, + scale: scale ?? this.scale, + offset: offset ?? this.offset, + aspectRatio: aspectRatio ?? this.aspectRatio, + withCircleUi: withCircleUi ?? this.withCircleUi, + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 949b219..e9373aa 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: archive - sha256: "22600aa1e926be775fa5fe7e6894e7fb3df9efda8891c73f70fb3262399a432d" + sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d url: "https://pub.dev" source: hosted - version: "3.4.10" + version: "3.6.1" async: dependency: transitive description: @@ -49,22 +49,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.0" - convert: - dependency: transitive - description: - name: convert - sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" - url: "https://pub.dev" - source: hosted - version: "3.1.1" crypto: dependency: transitive description: name: crypto - sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.6" fake_async: dependency: transitive description: @@ -87,34 +79,26 @@ packages: dependency: "direct main" description: name: image - sha256: "2237616a36c0d69aef7549ab439b833fb7f9fb9fc861af2cc9ac3eedddd69ca8" - url: "https://pub.dev" - source: hosted - version: "4.2.0" - js: - dependency: transitive - description: - name: js - sha256: "4186c61b32f99e60f011f7160e32c89a758ae9b1d0c6d28e2c02ef0382300e2b" + sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "4.3.0" leak_tracker: dependency: transitive description: name: leak_tracker - sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" url: "https://pub.dev" source: hosted - version: "10.0.4" + version: "10.0.5" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.5" leak_tracker_testing: dependency: transitive description: @@ -135,18 +119,18 @@ packages: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.15.0" path: dependency: transitive description: @@ -163,14 +147,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.2" - pointycastle: - dependency: transitive - description: - name: pointycastle - sha256: "43ac87de6e10afabc85c445745a7b799e04de84cebaa4fd7bf55a5e1e9604d29" - url: "https://pub.dev" - source: hosted - version: "3.7.4" sky_engine: dependency: transitive description: flutter @@ -220,18 +196,18 @@ packages: dependency: transitive description: name: test_api - sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.7.2" typed_data: dependency: transitive description: name: typed_data - sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.4.0" vector_math: dependency: transitive description: @@ -244,10 +220,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" url: "https://pub.dev" source: hosted - version: "14.2.1" + version: "14.2.5" xml: dependency: transitive description: @@ -257,5 +233,5 @@ packages: source: hosted version: "6.5.0" sdks: - dart: ">=3.3.0 <4.0.0" + dart: ">=3.5.0 <4.0.0" flutter: ">=3.18.0-18.0.pre.54" diff --git a/test/widget/calculator_test.dart b/test/widget/calculator_test.dart index 49bea3f..685b8d1 100644 --- a/test/widget/calculator_test.dart +++ b/test/widget/calculator_test.dart @@ -1,6 +1,5 @@ import 'dart:ui'; -import 'package:crop_your_image/src/logic/parser/image_detail.dart'; import 'package:crop_your_image/src/widget/calculator.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -87,11 +86,7 @@ void main() { test('screenSizeRatio is 2.0', () { final actual = calculator.screenSizeRatio( - ImageDetail( - height: originalImageSize.height, - width: originalImageSize.width, - image: null, - ), + Size(originalImageSize.width, originalImageSize.height), viewportSize, ); @@ -188,11 +183,7 @@ void main() { test('screenSizeRatio is 2.0', () { final actual = calculator.screenSizeRatio( - ImageDetail( - height: originalImageSize.height, - width: originalImageSize.width, - image: null, - ), + Size(originalImageSize.width, originalImageSize.height), viewportSize, ); diff --git a/test/widget/crop_editor_view_state_test.dart b/test/widget/crop_editor_view_state_test.dart new file mode 100644 index 0000000..0299acd --- /dev/null +++ b/test/widget/crop_editor_view_state_test.dart @@ -0,0 +1,173 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:crop_your_image/src/widget/crop_editor_view_state.dart'; + +void main() { + group('PreparingCropEditorViewState', () { + final defaultViewportSize = Size(360, 200); + + late PreparingCropEditorViewState state; + + setUp(() { + state = PreparingCropEditorViewState( + viewportSize: defaultViewportSize, + withCircleUi: false, + aspectRatio: 1.5, + ); + }); + + test('preserves all the given values', () { + expect(state.viewportSize, defaultViewportSize); + expect(state.withCircleUi, false); + expect(state.aspectRatio, 1.5); + }); + + test('state is not ready', () { + expect(state.isReady, false); + }); + + test('prepared method creates ReadyCropEditorViewState', () { + final imageSize = Size(800, 600); + + final readyState = state.prepared(imageSize); + + expect(readyState, isA()); + expect(readyState.isReady, true); + expect(readyState.viewportSize, defaultViewportSize); + expect(readyState.imageSize, imageSize); + expect(readyState.scale, 1.0); + expect(readyState.withCircleUi, false); + expect(readyState.aspectRatio, 1.5); + }); + }); + + group('ReadyCropEditorViewState', () { + final defaultViewportSize = Size(360, 200); + final defaultImageSize = Size(800, 600); + + late ReadyCropEditorViewState state; + + setUp(() { + state = ReadyCropEditorViewState.prepared( + defaultImageSize, + viewportSize: defaultViewportSize, + scale: 1.0, + aspectRatio: null, + withCircleUi: false, + ); + }); + + test('prepared factory creates correct initial state', () { + expect(state.isReady, true); + expect(state.viewportSize, defaultViewportSize); + expect(state.imageSize, defaultImageSize); + expect(state.scale, 1.0); + expect(state.withCircleUi, false); + expect(state.aspectRatio, null); + }); + + test('isFitVertically calculates correctly', () { + final verticalState = ReadyCropEditorViewState.prepared( + Size(300, 800), // vertical image + viewportSize: defaultViewportSize, + scale: 1.0, + aspectRatio: null, + withCircleUi: false, + ); + + final horizontalState = ReadyCropEditorViewState.prepared( + Size(800, 300), // horizontal image + viewportSize: defaultViewportSize, + scale: 1.0, + aspectRatio: null, + withCircleUi: false, + ); + + expect(verticalState.isFitVertically, true); + expect(horizontalState.isFitVertically, false); + }); + + group('moveRect', () { + test('moves rect within bounds', () { + final initialRect = Rect.fromLTWH(30, 30, 50, 50); + final stateWithRect = state.copyWith(cropRect: initialRect); + + final movedState = stateWithRect.moveRect(const Offset(50, 30)); + + expect(movedState.cropRect.left, 80); + expect(movedState.cropRect.top, 60); + expect(movedState.cropRect.width, 50); + expect(movedState.cropRect.height, 50); + }); + + test('constrains movement within image bounds', () { + final initialRect = Rect.fromLTWH(50, 50, 100, 100); + final stateWithRect = state.copyWith(cropRect: initialRect); + + final movedState = stateWithRect.moveRect(const Offset(-100, -100)); + + // Check that the rect stays within bounds + expect(movedState.cropRect.left, closeTo(46, 1)); + expect(movedState.cropRect.top, 0); + expect(movedState.cropRect.width, 100); + expect(movedState.cropRect.height, 100); + }); + }); + + group('scaleUpdated', () { + test('updates scale with constraints', () { + final initialRect = Rect.fromLTWH(100, 100, 200, 200); + final stateWithRect = state.copyWith(cropRect: initialRect); + + final scaledState = stateWithRect.scaleUpdated(2.0); + + expect(scaledState.scale, 2.0); + expect(scaledState.imageRect.width, + greaterThan(stateWithRect.imageRect.width)); + expect(scaledState.imageRect.height, + greaterThan(stateWithRect.imageRect.height)); + }); + + test('maintains minimum scale to cover crop rect', () { + final initialRect = Rect.fromLTWH(100, 100, 200, 200); + final stateWithRect = state.copyWith(cropRect: initialRect); + + // Try to scale smaller than minimum allowed + final scaledState = stateWithRect.scaleUpdated(0.1); + + // Scale should be constrained to minimum required to cover crop rect + expect(scaledState.imageRect.width, + greaterThanOrEqualTo(initialRect.width)); + expect(scaledState.imageRect.height, + greaterThanOrEqualTo(initialRect.height)); + }); + }); + + test('cropRectInitialized respects aspect ratio', () { + final initializedState = state.cropRectInitialized( + aspectRatio: 1.5, + initialSize: 0.8, + ); + + final ratio = + initializedState.cropRect.width / initializedState.cropRect.height; + expect(ratio, closeTo(1.5, 0.01)); + }); + + test('withCircleUi forces aspect ratio to 1.0', () { + final state = ReadyCropEditorViewState.prepared( + defaultImageSize, + viewportSize: defaultViewportSize, + scale: 1.0, + aspectRatio: 1.5, // This should be ignored when withCircleUi is true + withCircleUi: true, + ); + + final initializedState = state.cropRectInitialized(); + + final ratio = + initializedState.cropRect.width / initializedState.cropRect.height; + expect(ratio, closeTo(1.0, 0.01)); + }); + }); +}