Preserve avatar crop selection across canvas resizing

Rebase the crop translation around the new canvas center while retaining zoom and source-image selection. Cover portrait and landscape images through expansion, rotation, and fold-back.

Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-10-27 19:56:00 +00:00
parent 057abb7a87
commit ff33cbf488
2 changed files with 149 additions and 4 deletions

View File

@@ -40,7 +40,6 @@ class ProfileAvatarCropPage extends HookWidget {
final dimensions = dimensionsData == null
? null
: _ImageDimensions(dimensionsData[0], dimensionsData[1]);
final initializedForSize = useRef<Size?>(null);
final cropGeometry = useRef<_CropGeometry?>(null);
final displaySize = useRef<Size?>(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());

View File

@@ -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<Uint8List?>.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<void>(() {});
await tester.pump();
}
});
expect(viewerFinder, findsOneWidget);
await tester.pumpAndSettle();
Semantics cropSemantics() => tester.widget<Semantics>(
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<InteractiveViewer>(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<InteractiveViewer>(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<InteractiveViewer>(viewerFinder)
.transformationController!
.value
.getMaxScaleOnAxis(),
closeTo(scale, 0.001),
);
expect(cropSemantics().properties.value, zoom);
expect(tester.takeException(), isNull);
}
semantics.dispose();
});
}
}