diff --git a/mobile/lib/features/profile/profile_avatar_crop_page.dart b/mobile/lib/features/profile/profile_avatar_crop_page.dart index 199ec3fb1e..a6fff17055 100644 --- a/mobile/lib/features/profile/profile_avatar_crop_page.dart +++ b/mobile/lib/features/profile/profile_avatar_crop_page.dart @@ -40,7 +40,6 @@ class ProfileAvatarCropPage extends HookWidget { final dimensions = dimensionsData == null ? null : _ImageDimensions(dimensionsData[0], dimensionsData[1]); - final initializedForSize = useRef(null); final cropGeometry = useRef<_CropGeometry?>(null); final displaySize = useRef(null); final isClampingTransform = useRef(false); @@ -206,6 +205,7 @@ class ProfileAvatarCropPage extends HookWidget { final canvasWidth = constraints.maxWidth; final canvasHeight = math.max(1.0, constraints.maxHeight); final cropDiameter = math.min(canvasWidth, canvasHeight); + final previousGeometry = cropGeometry.value; cropGeometry.value = _CropGeometry( canvasWidth: canvasWidth, canvasHeight: canvasHeight, @@ -219,9 +219,7 @@ class ProfileAvatarCropPage extends HookWidget { ? cropDiameter : cropDiameter / aspect; displaySize.value = Size(displayWidth, displayHeight); - final canvasSize = Size(canvasWidth, canvasHeight); - if (initializedForSize.value != canvasSize) { - initializedForSize.value = canvasSize; + if (previousGeometry == null) { controller.value = Matrix4.identity() ..translateByDouble( (canvasWidth - displayWidth) / 2, @@ -229,6 +227,27 @@ class ProfileAvatarCropPage extends HookWidget { 0, 1, ); + } else if (previousGeometry.canvasWidth != canvasWidth || + previousGeometry.canvasHeight != canvasHeight || + previousGeometry.cropDiameter != cropDiameter) { + // Display dimensions scale with the crop diameter. Keep + // the same source-image point under the crop center and + // retain zoom, so the selected source rectangle survives. + final ratio = cropDiameter / previousGeometry.cropDiameter; + final matrix = controller.value.clone(); + matrix.storage[12] = + canvasWidth / 2 - + (previousGeometry.canvasWidth / 2 - + matrix.storage[12]) * + ratio; + matrix.storage[13] = + canvasHeight / 2 - + (previousGeometry.canvasHeight / 2 - + matrix.storage[13]) * + ratio; + // The listener clamps against the new geometry. Scale is + // unchanged, so cropScale remains synchronized as well. + controller.value = matrix; } void movePhoto(Offset delta) { unawaited(HapticFeedback.selectionClick()); diff --git a/mobile/test/features/profile/profile_avatar_crop_resize_test.dart b/mobile/test/features/profile/profile_avatar_crop_resize_test.dart new file mode 100644 index 0000000000..53fdc06dce --- /dev/null +++ b/mobile/test/features/profile/profile_avatar_crop_resize_test.dart @@ -0,0 +1,126 @@ +import 'dart:math' as math; + +import 'package:buzz/features/profile/profile_avatar_crop_page.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:image/image.dart' as image; + +void main() { + for (final sourceSize in [const Size(160, 100), const Size(100, 160)]) { + testWidgets('crop selection survives resizing for $sourceSize', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(400, 800); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final semantics = tester.ensureSemantics(); + final bytes = image.encodePng( + image.Image( + width: sourceSize.width.toInt(), + height: sourceSize.height.toInt(), + ), + ); + final viewerFinder = find.byKey(const ValueKey('avatar-crop-viewer')); + await tester.runAsync(() async { + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + home: ProfileAvatarCropPage( + imageBytes: Future.value(bytes), + ), + ), + ), + ); + // Observe image preparation completing across its compute boundary. + final deadline = DateTime.now().add(const Duration(seconds: 10)); + while (viewerFinder.evaluate().isEmpty && + DateTime.now().isBefore(deadline)) { + await Future(() {}); + await tester.pump(); + } + }); + expect(viewerFinder, findsOneWidget); + await tester.pumpAndSettle(); + + Semantics cropSemantics() => tester.widget( + find.byWidgetPredicate( + (widget) => + widget is Semantics && widget.properties.label == 'Photo crop', + ), + ); + // Exercise the editor's accessible zoom controls, then a real pan. + for (var i = 0; i < 8; i++) { + final actions = cropSemantics().properties.customSemanticsActions!; + actions.entries + .singleWhere((entry) => entry.key.label == 'Zoom in') + .value(); + await tester.pump(); + } + await tester.drag(viewerFinder, const Offset(48, -36)); + await tester.pumpAndSettle(); + + Rect selectedSourceRect() { + final viewer = tester.widget(viewerFinder); + final canvas = tester.getSize(viewerFinder); + final photo = viewer.child as Image; + final matrix = viewer.transformationController!.value; + final scale = matrix.getMaxScaleOnAxis(); + final diameter = math.min(canvas.width, canvas.height); + // Express the visible crop in original source-image coordinates. + final pixelsPerPoint = sourceSize.width / photo.width!; + return Rect.fromLTWH( + ((canvas.width - diameter) / 2 - matrix.storage[12]) / + scale * + pixelsPerPoint, + ((canvas.height - diameter) / 2 - matrix.storage[13]) / + scale * + pixelsPerPoint, + diameter / scale * pixelsPerPoint, + diameter / scale * pixelsPerPoint, + ); + } + + final selected = selectedSourceRect(); + final zoom = cropSemantics().properties.value; + expect(selected.width, lessThan(sourceSize.shortestSide)); + expect(selected.center.dx, isNot(closeTo(sourceSize.width / 2, 0.01))); + expect(selected.center.dy, isNot(closeTo(sourceSize.height / 2, 0.01))); + final scale = tester + .widget(viewerFinder) + .transformationController! + .value + .getMaxScaleOnAxis(); + for (final size in [ + const Size(800, 600), // Expanded posture: different crop diameter. + const Size(800, 400), // Rotation: short canvas height. + const Size(400, 800), // Fold back without accumulating drift. + ]) { + tester.view.physicalSize = size; + await tester.pumpAndSettle(); + final actual = selectedSourceRect(); + expect(actual.left, closeTo(selected.left, 0.001)); + expect(actual.top, closeTo(selected.top, 0.001)); + expect(actual.width, closeTo(selected.width, 0.001)); + expect(actual.height, closeTo(selected.height, 0.001)); + expect(actual.left, greaterThanOrEqualTo(0)); + expect(actual.top, greaterThanOrEqualTo(0)); + expect(actual.right, lessThanOrEqualTo(sourceSize.width)); + expect(actual.bottom, lessThanOrEqualTo(sourceSize.height)); + expect( + tester + .widget(viewerFinder) + .transformationController! + .value + .getMaxScaleOnAxis(), + closeTo(scale, 0.001), + ); + expect(cropSemantics().properties.value, zoom); + expect(tester.takeException(), isNull); + } + semantics.dispose(); + }); + } +}