From 8c5e1491655125e97214a15954da8c47598b752d Mon Sep 17 00:00:00 2001 From: Ben Auer Date: Mon, 23 Feb 2026 11:56:39 +0100 Subject: [PATCH 1/6] fix(rotation): rewrite rotation handle math and remove dead code Replaced unreliable delta-accumulation drag math with direct center-to-cursor angle calculation. Added degree display during rotation. Removed dead rotationSignal and onRotate state mutation that leaked rotation state when switching tools. --- .../tools/canvas_helpers/image_editor.dart | 79 ++++--- .../feature_kits/tools/move_resize_tool.dart | 49 ++--- .../lib/feature_kits/tools/rotate_tool.dart | 7 - .../widgets/rotation/rotation_wrapper.dart | 203 +++++++----------- 4 files changed, 136 insertions(+), 202 deletions(-) diff --git a/apps/flites/lib/feature_kits/tools/canvas_helpers/image_editor.dart b/apps/flites/lib/feature_kits/tools/canvas_helpers/image_editor.dart index 45e9248..121c3fe 100644 --- a/apps/flites/lib/feature_kits/tools/canvas_helpers/image_editor.dart +++ b/apps/flites/lib/feature_kits/tools/canvas_helpers/image_editor.dart @@ -9,7 +9,6 @@ import '../../../main.dart'; import '../../../states/canvas_controller.dart'; import '../../../states/key_events.dart'; import '../../../states/open_project.dart'; -import '../../../states/selected_image_state.dart'; import '../../../types/flites_image.dart'; import '../../../utils/bounding_box_utils.dart'; import '../../../utils/get_flite_image.dart'; @@ -38,55 +37,49 @@ class ImageEditor extends StatelessWidget { @override Widget build(BuildContext context) => LayoutBuilder( - builder: (context, constraints) { - CanvasController.updateCanvasSize(constraints.biggest); + builder: (context, constraints) { + CanvasController.updateCanvasSize(constraints.biggest); - return Watch( - (context) { - final referenceImages = - getFliteImages(selectedReferenceImages.value); + return Watch((context) { + final referenceImages = getFliteImages(selectedReferenceImages.value); - // We're subscribing to the canvas scaling factor - // to ensure the reference images update correctly - // when the canvas is zoomed in or out. - appState.canvasScalingFactor.value; + // We're subscribing to the canvas scaling factor + // to ensure the reference images update correctly + // when the canvas is zoomed in or out. + appState.canvasScalingFactor.value; - return _CanvasGestureHandler( - child: Stack( - children: [ - // Canvas Background - background(context), + return _CanvasGestureHandler( + child: Stack( + children: [ + // Canvas Background + background(context), - // Bounding Box - const _CanvasBoundingBox(), + // Bounding Box + const _CanvasBoundingBox(), - // Reference Images - ...referenceImages.map( - CanvasReferenceImage.new, - ), + // Reference Images + ...referenceImages.map(CanvasReferenceImage.new), - // Tool-specific canvases - child, + // Tool-specific canvases + child, - // Zoom Controls - if (showZoomControls) - const Positioned( - right: Sizes.p32, - bottom: Sizes.p32, - child: ZoomControls(), - ), - - // Additional widgets to be displayed on top of the canvas - ...stackChildren, - ], + // Zoom Controls + if (showZoomControls) + const Positioned( + right: Sizes.p32, + bottom: Sizes.p32, + child: ZoomControls(), ), - ); - }, - ); - }, - ); - Widget background(BuildContext context) => Container( - decoration: BoxDecoration(color: context.colors.surface), - ); + // Additional widgets to be displayed on top of the canvas + ...stackChildren, + ], + ), + ); + }); + }, + ); + + Widget background(BuildContext context) => + Container(decoration: BoxDecoration(color: context.colors.surface)); } diff --git a/apps/flites/lib/feature_kits/tools/move_resize_tool.dart b/apps/flites/lib/feature_kits/tools/move_resize_tool.dart index c502aff..bbeebe7 100644 --- a/apps/flites/lib/feature_kits/tools/move_resize_tool.dart +++ b/apps/flites/lib/feature_kits/tools/move_resize_tool.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_box_transform/flutter_box_transform.dart'; -import 'package:signals/signals_flutter.dart'; import '../../main.dart'; import '../../states/canvas_controller.dart'; @@ -10,7 +9,6 @@ import '../../widgets/layout/app_shell.dart'; import '../../widgets/player/player.dart'; import 'canvas_helpers/image_editor.dart'; import 'canvas_helpers/selected_image_rect_wrapper.dart'; -import 'rotate_tool.dart'; /// Displays the current selection in move/resize mode class MoveResizeTool extends StatefulWidget { @@ -32,35 +30,28 @@ class MoveResizeToolState extends State { currentSelection, selectedImageRect, ) => - Watch( - (context) { - final rotationAngle = rotationSignal.value ?? 0; - return TransformableBox( - visibleHandles: - rotationAngle == 0 ? HandlePosition.corners.toSet() : {}, - enabledHandles: - rotationAngle == 0 ? HandlePosition.corners.toSet() : {}, - cornerHandleBuilder: (context, handle) => AngularHandle( - handle: handle, - length: 16, - color: context.colors.onSurface, - thickness: 3, - ), - resizeModeResolver: () => ResizeMode.symmetricScale, - rect: selectedImageRect, - onChanged: (result, event) { - currentSelection.updatePositionAndSize( - result.rect, - CanvasController.canvasScalingFactor, - CanvasController.canvasPosition, - ); - - SourceFilesState.saveImageChanges(currentSelection); - }, - contentBuilder: (context, rect, flip) => - FlitesImageRenderer(flitesImage: currentSelection), + TransformableBox( + visibleHandles: HandlePosition.corners.toSet(), + enabledHandles: HandlePosition.corners.toSet(), + cornerHandleBuilder: (context, handle) => AngularHandle( + handle: handle, + length: 16, + color: context.colors.onSurface, + thickness: 3, + ), + resizeModeResolver: () => ResizeMode.symmetricScale, + rect: selectedImageRect, + onChanged: (result, event) { + currentSelection.updatePositionAndSize( + result.rect, + CanvasController.canvasScalingFactor, + CanvasController.canvasPosition, ); + + SourceFilesState.saveImageChanges(currentSelection); }, + contentBuilder: (context, rect, flip) => + FlitesImageRenderer(flitesImage: currentSelection), ), ), ), diff --git a/apps/flites/lib/feature_kits/tools/rotate_tool.dart b/apps/flites/lib/feature_kits/tools/rotate_tool.dart index 443199e..b06dda8 100644 --- a/apps/flites/lib/feature_kits/tools/rotate_tool.dart +++ b/apps/flites/lib/feature_kits/tools/rotate_tool.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:signals/signals_flutter.dart'; import '../../states/canvas_controller.dart'; import '../../widgets/flites_image_renderer/flites_image_renderer.dart'; @@ -9,8 +8,6 @@ import '../../widgets/rotation/rotation_wrapper.dart'; import 'canvas_helpers/image_editor.dart'; import 'canvas_helpers/selected_image_rect_wrapper.dart'; -final rotationSignal = signal(null); - /// Displays the current selection in rotate mode class RotateTool extends StatelessWidget { const RotateTool({super.key}); @@ -45,10 +42,6 @@ class RotateTool extends StatelessWidget { CanvasController.canvasScalingFactor.toString(), ), rect: selectedImageRect, - onRotate: (newAngle) { - currentSelection.rotation = newAngle; - }, - initialRotation: currentSelection.rotation, child: SizedBox( width: selectedImageRect.width, height: selectedImageRect.height, diff --git a/apps/flites/lib/widgets/rotation/rotation_wrapper.dart b/apps/flites/lib/widgets/rotation/rotation_wrapper.dart index 601b122..e9f71d8 100644 --- a/apps/flites/lib/widgets/rotation/rotation_wrapper.dart +++ b/apps/flites/lib/widgets/rotation/rotation_wrapper.dart @@ -14,14 +14,10 @@ class RotationWrapper extends StatefulWidget { required this.child, required this.rect, super.key, - this.onRotate, - this.initialRotation, }); final Widget child; final Rect rect; - final void Function(double newAngle)? onRotate; - final double? initialRotation; @override State createState() => _RotationWrapperState(); @@ -29,7 +25,8 @@ class RotationWrapper extends StatefulWidget { class _RotationWrapperState extends State { double rotation = 0; - Offset dragStartPoint = Offset.zero; + double _dragStartAngle = 0; + double _rotationAtDragStart = 0; late final double circleRadius; @@ -37,61 +34,26 @@ class _RotationWrapperState extends State { void initState() { super.initState(); circleRadius = (longestSideSize(widget.rect.size) / 2) * 1.5; - - // Initialize rotation from the widget's initialRotation property - rotation = widget.initialRotation ?? 0; - - // Set the initial drag point at the top of the circle - // When rotation is 0, the handle should be at the top (0, -circleRadius) - dragStartPoint = Offset(0, -circleRadius); - } - - double calculateAngle(Offset start, Offset end) { - // Calculate angles from the origin (0,0) - final double startAngle = atan2(start.dy, start.dx); - final double endAngle = atan2(end.dy, end.dx); - - // Return the difference between the angles - return endAngle - startAngle; } - void _updateRotation(Offset currentPosition) { - final newAngle = calculateAngle( - const Offset(0, -1), // Top of circle as reference - dragStartPoint + flipY(currentPosition), - ); - - setState(() { - rotation = -newAngle; - }); - - // Always call the onRotate callback when rotation changes - widget.onRotate?.call(rotation); + /// Computes the angle from the center of the rotation circle to the given + /// local position. Returns angle in radians where 0 = top, positive = clockwise. + double _angleFromCenter(Offset localPosition) { + final center = Offset(circleRadius, circleRadius); + final offset = localPosition - center; + return atan2(offset.dx, -offset.dy); } - void updateStartPoint(Offset endPosition) { - // When a drag ends, we need to update the dragStartPoint - // to match the current rotation angle for the next drag - - // For a clean UX, keep the handle at the same visual position - // by calculating the offset based on the current rotation - final endAngle = calculateAngle( - const Offset(0, -1), // Top of circle - dragStartPoint + flipY(endPosition), - ); - - setState(() { - // Update the start point for the next drag - // This keeps the handle visually at the position where the user released it - dragStartPoint = rotateOffset(Offset(0, -circleRadius), endAngle); - }); + String get _rotationDegrees { + final degrees = (rotation * 180 / pi) % 360; + final normalized = degrees > 180 ? degrees - 360 : degrees; + return '${normalized.toStringAsFixed(1)}°'; } @override Widget build(BuildContext context) { final dotSize = widget.rect.height * 0.1; - // Get the current tool to check if we're in rotation mode final currentTool = toolController.selectedTool; final inRotationMode = currentTool == Tool.rotate; @@ -100,57 +62,39 @@ class _RotationWrapperState extends State { children: [ Padding( padding: const EdgeInsets.only(bottom: Sizes.p48), - child: Transform.rotate( - origin: Offset.zero, - angle: rotation, - child: SizedBox( - height: circleRadius * 2, - width: circleRadius * 2, - child: Stack( - alignment: Alignment.center, - children: [ - SizedBox( - width: widget.rect.width, - height: widget.rect.height, - child: Center( - child: widget.child, - ), - ), - // Only show the rotation circle when in rotation mode - if (inRotationMode) - Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: context.colors.outline, - width: Sizes.p2, + child: SizedBox( + height: circleRadius * 2, + width: circleRadius * 2, + child: Stack( + alignment: Alignment.center, + children: [ + // Rotated content (image + circle + handle) + Transform.rotate( + angle: rotation, + child: Stack( + alignment: Alignment.center, + children: [ + SizedBox( + width: widget.rect.width, + height: widget.rect.height, + child: Center( + child: widget.child, ), ), - width: circleRadius * 2 - (dotSize * 2 / 3), - ), - // Only show the rotation handle when in rotation mode - if (inRotationMode) - Positioned( - top: 0, - child: MouseRegion( - cursor: SystemMouseCursors.precise, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onPanStart: (_) {}, - onPanUpdate: (details) { - _updateRotation( - standardizeOffsetToRotation( - details.localPosition, - ), - ); - }, - onPanEnd: (details) { - updateStartPoint( - standardizeOffsetToRotation( - details.localPosition, - ), - ); - }, + if (inRotationMode) + Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: context.colors.outline, + width: Sizes.p2, + ), + ), + width: circleRadius * 2 - (dotSize * 2 / 3), + ), + if (inRotationMode) + Positioned( + top: 0, child: Container( decoration: BoxDecoration( shape: BoxShape.circle, @@ -160,14 +104,38 @@ class _RotationWrapperState extends State { width: dotSize, ), ), + ], + ), + ), + // Gesture detector overlay in non-rotated coordinate space + if (inRotationMode) + MouseRegion( + cursor: SystemMouseCursors.precise, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onPanStart: (details) { + _dragStartAngle = + _angleFromCenter(details.localPosition); + _rotationAtDragStart = rotation; + }, + onPanUpdate: (details) { + final currentAngle = + _angleFromCenter(details.localPosition); + setState(() { + rotation = _rotationAtDragStart + + (currentAngle - _dragStartAngle); + }); + }, + child: SizedBox( + width: circleRadius * 2, + height: circleRadius * 2, ), ), - ], - ), + ), + ], ), ), ), - // Only show the rotation controls when in rotation mode and rotation is not 0 if (rotation != 0 && inRotationMode) Positioned( bottom: 0, @@ -191,11 +159,18 @@ class _RotationWrapperState extends State { setState(() { rotation = 0; }); - widget.onRotate?.call(0); - dragStartPoint = Offset(0, -circleRadius); }, ), gapW8, + Text( + _rotationDegrees, + style: TextStyle( + color: context.colors.surfaceContainer, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + gapW8, IconButton( icon: Icon( Icons.check_circle_outlined, @@ -209,13 +184,10 @@ class _RotationWrapperState extends State { await currentImage.rotateImage(rotation); } - // Reset the rotation in the UI controls setState(() { rotation = 0; - dragStartPoint = Offset(0, -circleRadius); }); - // Switch back to canvas mode after rotation is applied toolController.selectedTool = Tool.canvas; }, ), @@ -226,23 +198,8 @@ class _RotationWrapperState extends State { ], ); } - - Offset standardizeOffsetToRotation(Offset offset) { - final angle = -(dragStartPoint.direction - pi / 2); - - final double x = offset.dx * cos(angle) - offset.dy * sin(angle); - final double y = offset.dx * sin(angle) + offset.dy * cos(angle); - return Offset(x, y); - } } -// Helper functions for offset and size calculations +// Helper function used by rotate_tool.dart double longestSideSize(Size size) => size.width > size.height ? size.width : size.height; - -Offset flipY(Offset offset) => Offset(offset.dx, -offset.dy); - -Offset rotateOffset(Offset offset, double angle) => Offset( - offset.dx * cos(angle) - offset.dy * sin(angle), - offset.dx * sin(angle) + offset.dy * cos(angle), - ); From 280c8194c128866cb3807de884eb2416d93351cb Mon Sep 17 00:00:00 2001 From: Ben Auer Date: Mon, 23 Feb 2026 12:07:03 +0100 Subject: [PATCH 2/6] fix(rotation): simplify bitmap rotation pipeline and preserve image position Refactored processRotation to return typed RotationResult instead of Map. rotateImage() now preserves canvas scale factor and centers image after rotation by compensating for trim center offset. --- apps/flites/lib/types/flites_image.dart | 34 +++++- .../lib/utils/image_processing_utils.dart | 107 +++++++++++++----- 2 files changed, 109 insertions(+), 32 deletions(-) diff --git a/apps/flites/lib/types/flites_image.dart b/apps/flites/lib/types/flites_image.dart index d23839c..31ac484 100644 --- a/apps/flites/lib/types/flites_image.dart +++ b/apps/flites/lib/types/flites_image.dart @@ -87,7 +87,7 @@ class FlitesImage { // Generate a unique ID for this image id = '${DateTime.now().millisecondsSinceEpoch}-${Random().nextInt(14000)}-${Random().nextInt(15000)}'; - } on Exception { + } on Exception catch (e) { // Rethrow with more context throw Exception('Failed to create FlitesImage: $e'); } @@ -106,23 +106,49 @@ class FlitesImage { } try { + // Remember the center and scale so the image stays in place + final oldRawSize = ImageUtils.sizeOfRawImage(image); + final scaleOnCanvas = widthOnCanvas / oldRawSize.width; + final oldCenter = Offset( + positionOnCanvas.dx + widthOnCanvas / 2, + positionOnCanvas.dy + heightOnCanvas / 2, + ); + rotation = rotationInRadians; // Apply rotation to the image data based on type + // Track the trim center offset to compensate for asymmetric cropping + double trimCenterOffsetX = 0; + double trimCenterOffsetY = 0; + if (SvgUtils.isSvg(image)) { image = await SvgUtils.rotateAndTrimSvg(image, rotation); } else { - image = await ImageProcessingUtils.rotateInIsolates(image, rotation); + final result = + await ImageProcessingUtils.rotateInIsolates(image, rotation); + image = result.imageBytes; + trimCenterOffsetX = result.trimCenterOffsetX; + trimCenterOffsetY = result.trimCenterOffsetY; } // Reset rotation after applying it to the image data rotation = 0; - widthOnCanvas = ImageUtils.sizeOfRawImage(image).width; + // Preserve the user's scale factor + widthOnCanvas = ImageUtils.sizeOfRawImage(image).width * scaleOnCanvas; + + // Recenter the image at the same point, compensating for the + // asymmetric trim offset so the image doesn't jump + positionOnCanvas = Offset( + oldCenter.dx - widthOnCanvas / 2 + + (trimCenterOffsetX * scaleOnCanvas), + oldCenter.dy - heightOnCanvas / 2 + + (trimCenterOffsetY * scaleOnCanvas), + ); // Save the changes SourceFilesState.saveImageChanges(this); - } on Exception { + } on Exception catch (e) { debugPrint('Error applying rotation: $e'); } } diff --git a/apps/flites/lib/utils/image_processing_utils.dart b/apps/flites/lib/utils/image_processing_utils.dart index 2d005ec..e525d79 100644 --- a/apps/flites/lib/utils/image_processing_utils.dart +++ b/apps/flites/lib/utils/image_processing_utils.dart @@ -5,59 +5,110 @@ import 'package:image/image.dart' as img; import '../widgets/overlays/loading_overlay.dart'; +/// Result of a rotation operation, including the trim offset so the caller +/// can compensate for the asymmetric crop and keep the image centered. +class RotationResult { + RotationResult({ + required this.imageBytes, + required this.trimCenterOffsetX, + required this.trimCenterOffsetY, + }); + + final Uint8List imageBytes; + + /// How much the center shifted (in raw pixels) due to trimming. + final double trimCenterOffsetX; + final double trimCenterOffsetY; +} + /// A utility class for working with images. /// Holds methods for rotating images. class ImageProcessingUtils { // Private constructor to prevent instantiation ImageProcessingUtils._(); + /// Rotates an image by the given angle in radians. /// The computation of each step is done in an isolate. - static Future rotateInIsolates( + static Future rotateInIsolates( Uint8List pngBytes, double angleRadians, ) async => withLoadingOverlay( - () async => compute(_processRotation, { - 'pngBytes': pngBytes, - 'angleRadians': angleRadians, - }), + () async { + final result = await compute(_processRotationIsolate, { + 'pngBytes': pngBytes, + 'angleRadians': angleRadians, + }); + return RotationResult( + imageBytes: result['imageBytes'] as Uint8List, + trimCenterOffsetX: result['trimCenterOffsetX'] as double, + trimCenterOffsetY: result['trimCenterOffsetY'] as double, + ); + }, ); - // Handles the actual image rotation processing - static Uint8List _processRotation(Map args) { - final pngBytes = args['pngBytes'] as Uint8List; - final angleRadians = args['angleRadians'] as double; + /// Isolate entry point — accepts and returns Maps because Dart isolates + /// can only pass primitive types across the boundary. + static Map _processRotationIsolate( + Map args, + ) { + final result = processRotation( + args['pngBytes'] as Uint8List, + args['angleRadians'] as double, + ); + return { + 'imageBytes': result.imageBytes, + 'trimCenterOffsetX': result.trimCenterOffsetX, + 'trimCenterOffsetY': result.trimCenterOffsetY, + }; + } - // 1. Decode image + /// Rotates a PNG image by [angleRadians], trims transparent borders, + /// and returns the result with trim center offsets. + static RotationResult processRotation( + Uint8List pngBytes, + double angleRadians, + ) { final originalImage = getImageFromBytes(pngBytes); if (originalImage == null) { throw Exception('Unable to decode PNG'); } - // 2. Calculate diagonal - final diagonal = sqrt( - pow(originalImage.width, 2) + pow(originalImage.height, 2), - ).ceil(); - - // 3. Create canvas - final canvas = img.Image( - width: diagonal, - height: diagonal, - numChannels: 4, - ); - - // 4. Rotate image + // copyRotate produces a symmetric output centered on the original center final rotatedImage = img.copyRotate( originalImage, angle: angleRadians * 180 / pi, + interpolation: img.Interpolation.cubic, ); - // 5. Composite images - final compositeImage = img.compositeImage(canvas, rotatedImage); + // Find the non-transparent bounding box + final trimRect = img.findTrim(rotatedImage); + final trimX = trimRect[0]; + final trimY = trimRect[1]; + final trimWidth = trimRect[2]; + final trimHeight = trimRect[3]; - // 6. Trim and encode - final trimmedImage = img.trim(compositeImage); - return Uint8List.fromList(img.encodePng(trimmedImage)); + // Calculate how much the center shifted due to asymmetric trimming. + // copyRotate center = (width/2, height/2) = the original image center. + // Trim center = (trimX + trimWidth/2, trimY + trimHeight/2). + final trimCenterOffsetX = + (trimX + trimWidth / 2.0) - rotatedImage.width / 2.0; + final trimCenterOffsetY = + (trimY + trimHeight / 2.0) - rotatedImage.height / 2.0; + + final trimmedImage = img.copyCrop( + rotatedImage, + x: trimX, + y: trimY, + width: trimWidth, + height: trimHeight, + ); + + return RotationResult( + imageBytes: Uint8List.fromList(img.encodePng(trimmedImage)), + trimCenterOffsetX: trimCenterOffsetX, + trimCenterOffsetY: trimCenterOffsetY, + ); } static img.Image? getImageFromBytes(Uint8List bytes) { From 08a681a6fd38cfb2adec73fa8a8d0f93a96a24c0 Mon Sep 17 00:00:00 2001 From: Ben Auer Date: Mon, 23 Feb 2026 12:07:45 +0100 Subject: [PATCH 3/6] fix(rotation): rewrite SVG rotation with composition and stable bounding box Rotations are now composed into a single transform using data-orig-* attributes to track original dimensions. Prevents bounding box from growing with repeated rotations. Fixed svgStringToPngBytes canvas drawing and exception catches. --- apps/flites/lib/utils/svg_utils.dart | 203 +++++++++++++++------------ 1 file changed, 115 insertions(+), 88 deletions(-) diff --git a/apps/flites/lib/utils/svg_utils.dart b/apps/flites/lib/utils/svg_utils.dart index 84f3162..badcd04 100644 --- a/apps/flites/lib/utils/svg_utils.dart +++ b/apps/flites/lib/utils/svg_utils.dart @@ -152,65 +152,74 @@ class SvgUtils { return svgData; } - final originalSvg = SvgData.fromSvgData(svgData); + final svg = SvgData.fromSvgData(svgData); + + // Check if this SVG was previously rotated by us (has stored originals). + // This prevents the bounding box from growing with each rotation — + // we always compute the box from the original content size. + final storedW = getDataAttribute(svg.attributes, 'data-orig-w'); + final storedH = getDataAttribute(svg.attributes, 'data-orig-h'); + final storedCx = getDataAttribute(svg.attributes, 'data-orig-cx'); + final storedCy = getDataAttribute(svg.attributes, 'data-orig-cy'); + final previouslyRotated = storedW != null && storedH != null; + + final Size originalContentSize; + final Offset rotationCenter; + final String innerContent; + final double totalAngleDegrees; + + if (previouslyRotated) { + // Compose rotations: extract the existing angle, strip the old + // rotation wrapper, and combine into a single transform. + originalContentSize = Size(storedW, storedH); + rotationCenter = Offset( + storedCx ?? svg.center.dx, + storedCy ?? svg.center.dy, + ); + final existingDegrees = + extractExistingRotationDegrees(svg.content); + totalAngleDegrees = existingDegrees + angleToDegrees(angleRadians); + innerContent = stripRotationWrapper(svg.content); + } else { + // First rotation — use current dimensions as the baseline. + originalContentSize = svg.sizeFromHeightAndWidth; + rotationCenter = svg.center; + totalAngleDegrees = angleToDegrees(angleRadians); + innerContent = svg.content; + } - // Calculate the size of the rotated svg + // Calculate bounding box from the ORIGINAL content size + final totalAngleRadians = totalAngleDegrees * pi / 180; final rotatedBoxSize = - rotateBox(originalSvg.sizeFromHeightAndWidth, angleRadians); - - // Rotate svg around new center and adjust size & viewBox to fit the - // rotated content - final rotatedSvgString = _rotateSvg( - originalSvg, - angleRadians, - ); - - // Get all data of the rotated svg - final rotatedSvg = SvgData.fromSvgString(rotatedSvgString); - - // Convert the rotated svg to a png, trim the blank space around the image - // and get the size of the trimmed image - final targetSize = await sizeFromTrimmedPng( - rotatedSvgString, - targetSize: Size( - rotatedBoxSize.width, - rotatedBoxSize.height, - ), - ); + rotateBox(originalContentSize, totalAngleRadians); - // Get the new rect with the new size and and coordinate origin - final newRect = _rectWithNewCoordinatesAndCenter( - originalCenter: originalSvg.center, - newSize: targetSize, - paddingFactor: 1.05, + // Preserve or add data-orig-* attributes + var attributes = ensureOriginalSizeAttributes( + svg.attributes, + originalContentSize, + rotationCenter, ); - // Update the viewBox with the new size and coordinates - final attributesLaterWithViewbox = updateSvgViewBox( - rotatedSvg.attributes, - width: newRect.width.ceil(), - height: newRect.height.ceil(), - x: newRect.left.ceil(), - y: newRect.top.ceil(), + // Set viewBox and size to the rotated bounding box, centered on + // the original rotation center + attributes = resizeAndCenterAttributes( + attributes, + targetSize: rotatedBoxSize, + centerX: rotationCenter.dx, + centerY: rotationCenter.dy, ); - // Update the size with the new size - final attributesLaterWithSize = updateSvgSize( - attributesLaterWithViewbox, - newRect.width.ceil(), - newRect.height.ceil(), - ); - - // Put together a svg string with the trimmed size + // Build the final SVG with a single rotation transform final finalSvg = svgStringFromParts( - attributes: attributesLaterWithSize, - content: rotatedSvg.content, + attributes: attributes, + contentWrapper: + '', + contentWrapperClosingTag: '', + content: innerContent, ); - // Convert back to Uint8List return Uint8List.fromList(utf8.encode(finalSvg)); - } on Exception { - // If anything goes wrong, return the original SVG data + } on Exception catch (e) { debugPrint('Error rotating SVG: $e'); return svgData; } @@ -219,40 +228,59 @@ class SvgUtils { static bool _validateMinAngleRadians(double angleRadians) => angleRadians.abs() > 0.001; - /// Returns a new Rect with new coordinates and a new size from a given center - /// and target size. Optionally adds a padding to the size. - static Rect _rectWithNewCoordinatesAndCenter({ - required Offset originalCenter, - required Size newSize, - double paddingFactor = 1, - }) { - final newX = originalCenter.dx - ((newSize.width / 2) * paddingFactor); - final newY = originalCenter.dy - ((newSize.height / 2) * paddingFactor); - - return Rect.fromLTWH(newX, newY, newSize.width, newSize.height); + /// Gets a numeric data-* attribute value from an SVG attributes string. + static double? getDataAttribute(String attributes, String name) { + final match = RegExp('$name="([^"]*)"').firstMatch(attributes); + return match != null ? double.tryParse(match.group(1)!) : null; } - static String _rotateSvg( - SvgData svg, - double angleRadians, + /// Adds data-orig-* attributes to preserve the original content + /// dimensions through multiple rotations. + static String ensureOriginalSizeAttributes( + String attributes, + Size contentSize, + Offset center, ) { - final attributesWithNewSize = resizeAndCenterAttributes( - svg.attributes, - targetSize: svg.sizeFromHeightAndWidth, - centerX: svg.center.dx, - centerY: svg.center.dy, - ); + if (attributes.contains('data-orig-w=')) { + return attributes; + } + return '$attributes' + ' data-orig-w="${contentSize.width}"' + ' data-orig-h="${contentSize.height}"' + ' data-orig-cx="${center.dx}"' + ' data-orig-cy="${center.dy}"'; + } - // Create a new SVG with the original attributes and a rotation transform - final rotatedSvg = svgStringFromParts( - attributes: attributesWithNewSize, - contentWrapper: - '', - contentWrapperClosingTag: '', - content: svg.content, - ); + /// Extracts the rotation angle in degrees from a `\s*', + ).firstMatch(trimmed); + if (openMatch == null) { + return content; + } + + var inner = trimmed.substring(openMatch.end); + // Remove the last which is the rotation wrapper's closing tag + final lastClose = inner.lastIndexOf(''); + if (lastClose >= 0) { + inner = inner.substring(0, lastClose) + inner.substring(lastClose + 4); + } + return inner.trim(); } static double angleToDegrees(double angleInRadians) => @@ -487,18 +515,17 @@ class SvgUtils { final SvgStringLoader svgStringLoader = SvgStringLoader(svgStringContent); final PictureInfo pictureInfo = await vg.loadPicture(svgStringLoader, null); - // final ui.Picture picture = pictureInfo.picture; + final ui.Picture picture = pictureInfo.picture; final ui.PictureRecorder recorder = ui.PictureRecorder(); - // final ui.Canvas canvas = Canvas( - // recorder, - // Rect.fromPoints(Offset.zero, Offset(targetWidth, targetHeight)), - // ); - // canvas - // ..scale( - // targetWidth / pictureInfo.size.width, - // targetHeight / pictureInfo.size.height, - // ) - // ..drawPicture(picture); + Canvas( + recorder, + Rect.fromPoints(Offset.zero, Offset(targetWidth, targetHeight)), + ) + ..scale( + targetWidth / pictureInfo.size.width, + targetHeight / pictureInfo.size.height, + ) + ..drawPicture(picture); final ui.Image imgByteData = await recorder .endRecording() .toImage(targetWidth.ceil(), targetHeight.ceil()); From ee19d662f9e574b61df32c0ea685ea461f171a78 Mon Sep 17 00:00:00 2001 From: Ben Auer Date: Mon, 23 Feb 2026 12:08:41 +0100 Subject: [PATCH 4/6] test(rotation): add bitmap and SVG rotation tests Covers processRotation, trimPngAsBytes, getImageFromBytes, SVG parsing utilities, rotation composition, bounding box stability, and rotation helper methods. 39 tests total. --- .../test/utils/image_rotation_test.dart | 150 +++++++ apps/flites/test/utils/svg_rotation_test.dart | 388 ++++++++++++++++++ 2 files changed, 538 insertions(+) create mode 100644 apps/flites/test/utils/image_rotation_test.dart create mode 100644 apps/flites/test/utils/svg_rotation_test.dart diff --git a/apps/flites/test/utils/image_rotation_test.dart b/apps/flites/test/utils/image_rotation_test.dart new file mode 100644 index 0000000..11a67a8 --- /dev/null +++ b/apps/flites/test/utils/image_rotation_test.dart @@ -0,0 +1,150 @@ +import 'dart:math'; +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:flites/utils/image_processing_utils.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; + +/// Creates a simple test PNG with a colored rectangle on a transparent +/// background. The rectangle is offset from center to make trim asymmetry +/// detectable. +Uint8List createTestPng({int width = 200, int height = 300}) { + final image = img.Image(width: width, height: height, numChannels: 4); + + // Draw a colored rectangle that is NOT centered, so trim will be asymmetric + // after rotation. This is realistic — game sprites are rarely perfectly + // centered in their bounding box. + final rectLeft = (width * 0.2).toInt(); + final rectTop = (height * 0.1).toInt(); + final rectRight = (width * 0.8).toInt(); + final rectBottom = (height * 0.7).toInt(); + + for (int y = rectTop; y < rectBottom; y++) { + for (int x = rectLeft; x < rectRight; x++) { + image.setPixelRgba(x, y, 255, 100, 100, 255); + } + } + + return Uint8List.fromList(img.encodePng(image)); +} + +void main() { + group('RotationResult', () { + test('holds image bytes and trim offsets', () { + final result = RotationResult( + imageBytes: Uint8List.fromList([1, 2, 3]), + trimCenterOffsetX: 1.5, + trimCenterOffsetY: -2.3, + ); + + expect(result.imageBytes, hasLength(3)); + expect(result.trimCenterOffsetX, 1.5); + expect(result.trimCenterOffsetY, -2.3); + }); + }); + + group('ImageProcessingUtils', () { + group('getImageFromBytes', () { + test('decodes a valid PNG', () { + final png = createTestPng(); + final decoded = ImageProcessingUtils.getImageFromBytes(png); + + expect(decoded, isNotNull); + expect(decoded!.width, 200); + expect(decoded.height, 300); + }); + + test('returns null for invalid data', () { + final invalid = Uint8List.fromList([0, 1, 2, 3]); + final decoded = ImageProcessingUtils.getImageFromBytes(invalid); + + expect(decoded, isNull); + }); + }); + + group('trimPngAsBytes', () { + test('trims transparent borders', () { + final png = createTestPng(); + final trimmed = ImageProcessingUtils.trimPngAsBytes(png); + + // The trimmed image should be smaller since the test image has + // transparent borders around the colored rectangle + final originalSize = img.decodePng(png)!; + final trimmedSize = img.decodePng(trimmed)!; + + expect(trimmedSize.width, lessThan(originalSize.width)); + expect(trimmedSize.height, lessThan(originalSize.height)); + }); + + test('throws on invalid data', () { + expect( + () => ImageProcessingUtils.trimPngAsBytes( + Uint8List.fromList([0, 1, 2]), + ), + throwsA(isA()), + ); + }); + }); + }); + + group('processRotation', () { + test('produces valid PNG output', () { + final png = createTestPng(); + final result = ImageProcessingUtils.processRotation(png, pi / 4); + + expect(result.imageBytes, isNotEmpty); + expect(img.decodePng(result.imageBytes), isNotNull); + }); + + test('returns non-zero trim offset for asymmetric image', () { + final png = createTestPng(); + final result = ImageProcessingUtils.processRotation(png, pi / 4); + + // Our test image has an off-center rectangle, so trimming after + // rotation should produce a non-zero center offset + expect( + result.trimCenterOffsetX != 0 || result.trimCenterOffsetY != 0, + isTrue, + ); + }); + + test('90 degree rotation swaps dimensions approximately', () { + final png = createTestPng(); + final result = ImageProcessingUtils.processRotation(png, pi / 2); + + final rotatedImage = img.decodePng(result.imageBytes)!; + + // The colored rect is roughly 120x180, so after 90° it should be ~180x120 + expect(rotatedImage.width, greaterThan(rotatedImage.height)); + }); + + test('throws on invalid PNG data', () { + expect( + () => ImageProcessingUtils.processRotation( + Uint8List.fromList([0, 1, 2]), + pi / 4, + ), + throwsA(isA()), + ); + }); + }); + + group('longestSideSize', () { + test('returns width when wider', () { + expect(longestSideSize(const Size(200, 100)), 200); + }); + + test('returns height when taller', () { + expect(longestSideSize(const Size(100, 200)), 200); + }); + + test('returns either when square', () { + expect(longestSideSize(const Size(100, 100)), 100); + }); + }); +} + +// Import the helper from rotation_wrapper since it's a top-level function +double longestSideSize(Size size) => + size.width > size.height ? size.width : size.height; diff --git a/apps/flites/test/utils/svg_rotation_test.dart b/apps/flites/test/utils/svg_rotation_test.dart new file mode 100644 index 0000000..25880e3 --- /dev/null +++ b/apps/flites/test/utils/svg_rotation_test.dart @@ -0,0 +1,388 @@ +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:flites/utils/svg_utils.dart'; +import 'package:flutter_test/flutter_test.dart'; + +String createTestSvg({double width = 200, double height = 100}) => + '' + ''; + +Uint8List svgToBytes(String svg) => Uint8List.fromList(utf8.encode(svg)); + +void main() { + group('SvgUtils', () { + group('isSvg', () { + test('returns true for valid SVG data', () { + final svgBytes = svgToBytes(createTestSvg()); + expect(SvgUtils.isSvg(svgBytes), isTrue); + }); + + test('returns false for non-SVG data', () { + final pngBytes = + Uint8List.fromList([137, 80, 78, 71, 13, 10, 26, 10]); + expect(SvgUtils.isSvg(pngBytes), isFalse); + }); + + test('returns false for data shorter than 5 bytes', () { + final shortData = Uint8List.fromList([1, 2, 3]); + expect(SvgUtils.isSvg(shortData), isFalse); + }); + }); + + group('getSvgSize', () { + test('extracts explicit width and height', () { + final svgBytes = svgToBytes(createTestSvg(width: 300, height: 150)); + final size = SvgUtils.getSvgSize(svgBytes); + expect(size, const Size(300, 150)); + }); + + test('falls back to viewBox when no explicit dimensions', () { + final svgBytes = svgToBytes( + '' + ' ', + ); + final size = SvgUtils.getSvgSize(svgBytes); + expect(size, const Size(400, 200)); + }); + + test('returns default 100x100 for SVG without dimensions', () { + final svgBytes = svgToBytes( + '', + ); + final size = SvgUtils.getSvgSize(svgBytes); + expect(size, const Size(100, 100)); + }); + }); + + group('getViewBox', () { + test('extracts viewBox as Rect', () { + final svgBytes = svgToBytes(createTestSvg()); + final viewBox = SvgUtils.getViewBox(svgBytes); + expect(viewBox, isNotNull); + expect(viewBox!.left, 0); + expect(viewBox.top, 0); + expect(viewBox.width, 200); + expect(viewBox.height, 100); + }); + + test('handles viewBox with offset', () { + final svgBytes = svgToBytes( + '', + ); + final viewBox = SvgUtils.getViewBox(svgBytes); + expect(viewBox, isNotNull); + expect(viewBox!.left, 10); + expect(viewBox.top, 20); + expect(viewBox.width, 300); + expect(viewBox.height, 150); + }); + }); + + group('getContent', () { + test('extracts inner content of SVG', () { + const svg = + ''; + final content = SvgUtils.getContent(svg); + expect(content, contains(''; + final attrs = SvgUtils.getAttributes(svg); + expect(attrs, contains('width="100"')); + expect(attrs, contains('height="200"')); + expect(attrs, contains('viewBox="0 0 100 200"')); + }); + }); + + group('rotateBox', () { + test('90 degree rotation swaps dimensions', () { + final rotated = SvgUtils.rotateBox(const Size(200, 100), pi / 2); + expect(rotated.width, closeTo(100, 1)); + expect(rotated.height, closeTo(200, 1)); + }); + + test('45 degree rotation produces larger bounding box', () { + const original = Size(200, 100); + final rotated = SvgUtils.rotateBox(original, pi / 4); + expect(rotated.width, greaterThan(original.width)); + expect(rotated.height, greaterThan(original.height)); + }); + }); + + group('updateSvgViewBox', () { + test('replaces existing viewBox', () { + const attrs = ' width="100" height="100" viewBox="0 0 100 100"'; + final updated = SvgUtils.updateSvgViewBox( + attrs, + width: 200, + height: 150, + x: 10, + y: 20, + ); + expect(updated, contains('viewBox="10 20 200 150"')); + expect(updated, isNot(contains('viewBox="0 0 100 100"'))); + }); + + test('adds viewBox when missing', () { + const attrs = ' width="100" height="100"'; + final updated = SvgUtils.updateSvgViewBox( + attrs, + width: 200, + height: 150, + x: 0, + y: 0, + ); + expect(updated, contains('viewBox="0 0 200 150"')); + }); + }); + + group('updateSvgSize', () { + test('replaces existing width and height', () { + const attrs = ' width="100" height="100"'; + final updated = SvgUtils.updateSvgSize(attrs, 200, 150); + expect(updated, contains('width="200"')); + expect(updated, contains('height="150"')); + }); + + test('adds width and height when missing', () { + const attrs = ' viewBox="0 0 100 100"'; + final updated = SvgUtils.updateSvgSize(attrs, 200, 150); + expect(updated, contains('width="200"')); + expect(updated, contains('height="150"')); + }); + }); + + group('svgStringFromParts', () { + test('builds valid SVG with wrapper', () { + final svg = SvgUtils.svgStringFromParts( + attributes: ' width="100" height="100"', + contentWrapper: '', + contentWrapperClosingTag: '', + content: '', + ); + expect(svg, contains('')); + expect(svg, contains('rotate(45, 50, 50)')); + expect(svg, contains('', + ); + expect(svg, contains(''; + final degrees = + SvgUtils.extractExistingRotationDegrees(content); + expect(degrees, 45); + }); + + test('returns 0 when no rotation transform', () { + const content = ''; + final degrees = + SvgUtils.extractExistingRotationDegrees(content); + expect(degrees, 0); + }); + + test('handles decimal angles', () { + const content = + ''; + final degrees = + SvgUtils.extractExistingRotationDegrees(content); + expect(degrees, 33.5); + }); + }); + + group('stripRotationWrapper', () { + test('removes outermost rotation g element', () { + const content = + ''; + final stripped = SvgUtils.stripRotationWrapper(content); + expect(stripped, contains(''; + final stripped = SvgUtils.stripRotationWrapper(content); + expect(stripped, contains('')); + expect(stripped, contains('')); + }); + }); + + group('rotateAndTrimSvg', () { + test('produces valid SVG with rotation transform', () async { + final svgBytes = svgToBytes(createTestSvg()); + final rotated = await SvgUtils.rotateAndTrimSvg(svgBytes, pi / 4); + final rotatedStr = String.fromCharCodes(rotated); + + expect(rotatedStr, contains('')); + expect(rotatedStr, contains('rotate(')); + }); + + test('adds data-orig attributes on first rotation', () async { + final svgBytes = svgToBytes(createTestSvg()); + final rotated = await SvgUtils.rotateAndTrimSvg(svgBytes, pi / 6); + final rotatedStr = String.fromCharCodes(rotated); + + expect(rotatedStr, contains('data-orig-w="200.0"')); + expect(rotatedStr, contains('data-orig-h="100.0"')); + expect(rotatedStr, contains('data-orig-cx=')); + expect(rotatedStr, contains('data-orig-cy=')); + }); + + test('returns unchanged SVG for near-zero angle', () async { + final svgBytes = svgToBytes(createTestSvg()); + final rotated = + await SvgUtils.rotateAndTrimSvg(svgBytes, 0.0001); + expect(rotated, equals(svgBytes)); + }); + + test('composes multiple rotations into single transform', + () async { + final svgBytes = svgToBytes(createTestSvg()); + + // First rotation: 30 degrees + final rotated1 = + await SvgUtils.rotateAndTrimSvg(svgBytes, pi / 6); + // Second rotation: another 30 degrees + final rotated2 = + await SvgUtils.rotateAndTrimSvg(rotated1, pi / 6); + + final resultStr = String.fromCharCodes(rotated2); + + // Should have a single rotation transform of ~60 degrees + final rotateMatches = + RegExp(r'rotate\(').allMatches(resultStr); + expect( + rotateMatches.length, + 1, + reason: 'Should have exactly one rotate transform', + ); + + // The angle should be ~60 degrees + final angleMatch = + RegExp(r'rotate\(\s*([0-9.]+)').firstMatch(resultStr); + expect(angleMatch, isNotNull); + final angle = double.parse(angleMatch!.group(1)!); + expect(angle, closeTo(60, 1)); + }); + + test( + 'bounding box does not grow with repeated same-angle rotations', + () async { + final svgBytes = svgToBytes(createTestSvg()); + + // Rotate 45 degrees + final rotated1 = + await SvgUtils.rotateAndTrimSvg(svgBytes, pi / 4); + + // Rotate back -45 degrees (should return to roughly original size) + final rotated2 = + await SvgUtils.rotateAndTrimSvg(rotated1, -pi / 4); + final size2 = SvgUtils.getSvgSize(rotated2); + + // Rotating back should produce dimensions close to original + // Allow some rounding because we use .ceil() + expect(size2.width, closeTo(200, 2)); + expect(size2.height, closeTo(100, 2)); + }, + ); + + test('preserves data-orig attributes through multiple rotations', + () async { + final svgBytes = svgToBytes(createTestSvg()); + final rotated1 = + await SvgUtils.rotateAndTrimSvg(svgBytes, pi / 6); + final rotated2 = + await SvgUtils.rotateAndTrimSvg(rotated1, pi / 6); + final rotated3 = + await SvgUtils.rotateAndTrimSvg(rotated2, pi / 6); + + final resultStr = String.fromCharCodes(rotated3); + + // Original dimensions should still be 200x100 + expect(resultStr, contains('data-orig-w="200.0"')); + expect(resultStr, contains('data-orig-h="100.0"')); + }); + + test('viewBox adjusts for rotated bounding box', () async { + final svgBytes = svgToBytes(createTestSvg()); + final rotated = + await SvgUtils.rotateAndTrimSvg(svgBytes, pi / 4); + final viewBox = SvgUtils.getViewBox(rotated)!; + + // 45° rotation of 200x100 → bounding box should be larger + expect(viewBox.width, greaterThan(200)); + expect(viewBox.height, greaterThan(100)); + }); + }); + }); +} From 7c6a8fb00ff2daab19d02e2a73a007859b52451d Mon Sep 17 00:00:00 2001 From: Ben Auer Date: Tue, 10 Mar 2026 16:48:37 +0100 Subject: [PATCH 5/6] fix(rotation): degree normalization, svg_utils type promotion, longestSideSize test import --- apps/flites/lib/utils/svg_utils.dart | 3 +-- apps/flites/lib/widgets/rotation/rotation_wrapper.dart | 4 ++-- apps/flites/test/utils/image_rotation_test.dart | 5 +---- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/apps/flites/lib/utils/svg_utils.dart b/apps/flites/lib/utils/svg_utils.dart index badcd04..6040c1d 100644 --- a/apps/flites/lib/utils/svg_utils.dart +++ b/apps/flites/lib/utils/svg_utils.dart @@ -161,14 +161,13 @@ class SvgUtils { final storedH = getDataAttribute(svg.attributes, 'data-orig-h'); final storedCx = getDataAttribute(svg.attributes, 'data-orig-cx'); final storedCy = getDataAttribute(svg.attributes, 'data-orig-cy'); - final previouslyRotated = storedW != null && storedH != null; final Size originalContentSize; final Offset rotationCenter; final String innerContent; final double totalAngleDegrees; - if (previouslyRotated) { + if (storedW != null && storedH != null) { // Compose rotations: extract the existing angle, strip the old // rotation wrapper, and combine into a single transform. originalContentSize = Size(storedW, storedH); diff --git a/apps/flites/lib/widgets/rotation/rotation_wrapper.dart b/apps/flites/lib/widgets/rotation/rotation_wrapper.dart index e9f71d8..dc92815 100644 --- a/apps/flites/lib/widgets/rotation/rotation_wrapper.dart +++ b/apps/flites/lib/widgets/rotation/rotation_wrapper.dart @@ -45,8 +45,8 @@ class _RotationWrapperState extends State { } String get _rotationDegrees { - final degrees = (rotation * 180 / pi) % 360; - final normalized = degrees > 180 ? degrees - 360 : degrees; + final degrees = rotation * 180 / pi; + final normalized = (((degrees + 180) % 360) + 360) % 360 - 180; return '${normalized.toStringAsFixed(1)}°'; } diff --git a/apps/flites/test/utils/image_rotation_test.dart b/apps/flites/test/utils/image_rotation_test.dart index 11a67a8..d6737ce 100644 --- a/apps/flites/test/utils/image_rotation_test.dart +++ b/apps/flites/test/utils/image_rotation_test.dart @@ -3,6 +3,7 @@ import 'dart:typed_data'; import 'dart:ui'; import 'package:flites/utils/image_processing_utils.dart'; +import 'package:flites/widgets/rotation/rotation_wrapper.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:image/image.dart' as img; @@ -144,7 +145,3 @@ void main() { }); }); } - -// Import the helper from rotation_wrapper since it's a top-level function -double longestSideSize(Size size) => - size.width > size.height ? size.width : size.height; From e3eb3c6461233966af5c94ef76b0950e21b6347a Mon Sep 17 00:00:00 2001 From: Ben Auer Date: Tue, 10 Mar 2026 17:02:26 +0100 Subject: [PATCH 6/6] =?UTF-8?q?fix(rotation):=20degree=20display,=20circle?= =?UTF-8?q?=20radius,=20and=20pan=20delta=20across=20=C2=B1=CF=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Normalize _rotationDegrees to (-180, 180] - Compute circle radius from current rect (getter) instead of initState - Normalize pan delta to (-π, π] to avoid snap when crossing seam --- .../widgets/rotation/rotation_wrapper.dart | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/apps/flites/lib/widgets/rotation/rotation_wrapper.dart b/apps/flites/lib/widgets/rotation/rotation_wrapper.dart index dc92815..c7a50b0 100644 --- a/apps/flites/lib/widgets/rotation/rotation_wrapper.dart +++ b/apps/flites/lib/widgets/rotation/rotation_wrapper.dart @@ -28,18 +28,13 @@ class _RotationWrapperState extends State { double _dragStartAngle = 0; double _rotationAtDragStart = 0; - late final double circleRadius; - - @override - void initState() { - super.initState(); - circleRadius = (longestSideSize(widget.rect.size) / 2) * 1.5; - } + double get _circleRadius => + (longestSideSize(widget.rect.size) / 2) * 1.5; /// Computes the angle from the center of the rotation circle to the given /// local position. Returns angle in radians where 0 = top, positive = clockwise. double _angleFromCenter(Offset localPosition) { - final center = Offset(circleRadius, circleRadius); + final center = Offset(_circleRadius, _circleRadius); final offset = localPosition - center; return atan2(offset.dx, -offset.dy); } @@ -63,8 +58,8 @@ class _RotationWrapperState extends State { Padding( padding: const EdgeInsets.only(bottom: Sizes.p48), child: SizedBox( - height: circleRadius * 2, - width: circleRadius * 2, + height: _circleRadius * 2, + width: _circleRadius * 2, child: Stack( alignment: Alignment.center, children: [ @@ -90,7 +85,7 @@ class _RotationWrapperState extends State { width: Sizes.p2, ), ), - width: circleRadius * 2 - (dotSize * 2 / 3), + width: _circleRadius * 2 - (dotSize * 2 / 3), ), if (inRotationMode) Positioned( @@ -121,14 +116,20 @@ class _RotationWrapperState extends State { onPanUpdate: (details) { final currentAngle = _angleFromCenter(details.localPosition); + double delta = currentAngle - _dragStartAngle; + while (delta > pi) { + delta -= 2 * pi; + } + while (delta <= -pi) { + delta += 2 * pi; + } setState(() { - rotation = _rotationAtDragStart + - (currentAngle - _dragStartAngle); + rotation = _rotationAtDragStart + delta; }); }, child: SizedBox( - width: circleRadius * 2, - height: circleRadius * 2, + width: _circleRadius * 2, + height: _circleRadius * 2, ), ), ),