feat(app): camera preview WYSIWYG aspect + pinch zoom with double-tap reset

Preview now centers at the sensor aspect ratio (letterboxed on the dark
backdrop) instead of FittedBox cover, which cropped ~38% horizontally and
made the preview differ from the full-frame takePicture output.

Zoom capability hangs off CaptureSource as default implementations
(minZoom/maxZoom = 1, setZoom no-op) so existing fakes stay untouched;
CameraPluginSource probes getMin/MaxZoomLevel after initialize (failure
keeps 1/1 and disables the gesture). The page attaches a scale
GestureDetector only when maxZoom > 1: pinch drives setZoom (throttled at
0.05 delta, serialized to prevent out-of-order platform calls), a badge
shows the current level and hides after 1s, double-tap resets to 1.0x,
and camera flip/reopen resets zoom state. _StaticCaptureSource in
album_detail_page_test.dart switches implements -> extends to inherit the
new defaults.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-28 16:17:07 +08:00
parent 65cd6849cc
commit ac602acf47
3 changed files with 273 additions and 23 deletions
+74 -3
View File
@@ -5,7 +5,8 @@
/// 准备器 + 非 Android 权限门替身。覆盖:快门即拍即入队、连拍串行、分类
/// 切换仅影响之后拍摄、多选补充入队、待传/失败徽章实时刷新、有待传任务
/// 退出确认、权限拒绝/初始化失败视图与重试、深色界面(#10161E)、快门
/// ≥72dp、闪光灯按能力显示、前后摄切换重建、最近一张缩略图。
/// ≥72dp、闪光灯按能力显示、前后摄切换重建、最近一张缩略图、捏合变焦
/// 与双击复位。
library;
import 'dart:io';
@@ -81,6 +82,23 @@ class FakeCaptureSource extends CaptureSource {
Future<void> dispose() async => disposeCalls++;
}
/// 变焦能力替身(Task 7 捏合变焦):能力 1-8×,记录 setZoom 调用序列。
/// 继承 [FakeCaptureSource](预览标记/初始化/释放行为一致)。
class ZoomFakeCaptureSource extends FakeCaptureSource {
ZoomFakeCaptureSource(super.files);
final List<double> zoomCalls = <double>[];
@override
double get minZoom => 1;
@override
double get maxZoom => 8;
@override
Future<void> setZoom(double level) async => zoomCalls.add(level);
}
void main() {
late FakeSessionStore session;
late TaskStore store;
@@ -147,7 +165,8 @@ void main() {
categories: categories,
);
Future<void> pumpCapture(WidgetTester tester) async {
Future<void> pumpCapture(WidgetTester tester, {CaptureSource? cameraSource}) async {
final effectiveSource = cameraSource ?? source;
await tester.pumpWidget(
ProviderScope(
overrides: [
@@ -164,7 +183,7 @@ void main() {
captureSourceFactoryProvider.overrideWith(
(ref) => ({bool preferFront = false}) async {
factoryPreferFront.add(preferFront);
return source;
return effectiveSource;
},
),
uploadPreparerProvider.overrideWith(
@@ -462,4 +481,56 @@ void main() {
expect(find.text('Ceremony'), findsOneWidget);
expect(find.text('Gala'), findsOneWidget);
});
testWidgets('捏合变焦:双指张开驱动 setZoom 并显示倍数徽标', (tester) async {
final zoomSource = ZoomFakeCaptureSource(<File>[]);
await pumpCapture(tester, cameraSource: zoomSource);
expect(find.text('PREVIEW_MARK'), findsOneWidget, reason: '相机预览就绪');
final center = tester.getCenter(find.text('PREVIEW_MARK'));
// 必须两根指针:ScaleGestureRecognizer 的 scale = 当前指距/初始指距,
// 单指针 span=0 → scale 恒为 1.0,永远测不到变焦。
final p1 = await tester.startGesture(center.translate(-40, 0));
final p2 = await tester.startGesture(center.translate(40, 0));
await p1.moveBy(const Offset(-40, 0));
await p2.moveBy(const Offset(40, 0));
await tester.pump();
await p1.up();
await p2.up();
await tester.pump();
expect(zoomSource.zoomCalls.last, greaterThan(1.0));
expect(find.textContaining('×'), findsOneWidget, reason: '捏合期间显示倍数徽标');
// 停止 1s 后徽标隐藏;同时冲刷计时器避免悬挂。
await tester.pump(const Duration(seconds: 1));
expect(find.textContaining('×'), findsNothing, reason: '1s 后徽标隐藏');
});
testWidgets('双击复位 1.0×', (tester) async {
final zoomSource = ZoomFakeCaptureSource(<File>[]);
await pumpCapture(tester, cameraSource: zoomSource);
// 先捏合放大到 >1×,再双击复位。
final center = tester.getCenter(find.text('PREVIEW_MARK'));
final p1 = await tester.startGesture(center.translate(-40, 0));
final p2 = await tester.startGesture(center.translate(40, 0));
await p1.moveBy(const Offset(-40, 0));
await p2.moveBy(const Offset(40, 0));
await p1.up();
await p2.up();
await tester.pump();
expect(zoomSource.zoomCalls.last, greaterThan(1.0), reason: '前置:已放大');
await tester.tap(find.text('PREVIEW_MARK'));
await tester.pump(const Duration(milliseconds: 100));
await tester.tap(find.text('PREVIEW_MARK'));
await tester.pump();
expect(zoomSource.zoomCalls.last, 1.0);
expect(find.text('1.0×'), findsOneWidget, reason: '复位后徽标显示 1.0×');
// 冲刷徽标隐藏计时器避免悬挂。
await tester.pump(const Duration(seconds: 1));
});
}