mirror of
https://github.com/block/buzz.git
synced 2026-10-28 04:51:40 +00:00
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:
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user