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
+196 -12
View File
@@ -5,7 +5,9 @@
/// 系统主题):顶部常驻层 = 返回 + 相册名 + 待传/失败徽章 + 分类 chips /// 系统主题):顶部常驻层 = 返回 + 相册名 + 待传/失败徽章 + 分类 chips
/// (可切换,**仅影响之后拍摄**——引擎只读任务行归属);底部 = 最近一张 /// (可切换,**仅影响之后拍摄**——引擎只读任务行归属);底部 = 最近一张
/// 缩略图(兼相册多选入口,iOS 相机惯例)+ 快门(≥72dp 白环)+ 前后摄 /// 缩略图(兼相册多选入口,iOS 相机惯例)+ 快门(≥72dp 白环)+ 前后摄
/// 切换 + 闪光灯(源支持才出现)。入队成功以触觉 + 200ms 浮层轻反馈, /// 切换 + 闪光灯(源支持才出现)。预览按传感器比例居中(所见即所得,
/// 不裁切不变形);源支持变焦(maxZoom > 1)时捏合缩放 + 双击复位 1×。
/// 入队成功以触觉 + 200ms 浮层轻反馈,
/// 不逐张弹确认;退出时若有待传任务弹确认(任务在后台继续——Android /// 不逐张弹确认;退出时若有待传任务弹确认(任务在后台继续——Android
/// 原生 WorkManager 承担,见 native_transport.dart)。 /// 原生 WorkManager 承担,见 native_transport.dart)。
/// ///
@@ -37,7 +39,7 @@ abstract class CaptureSource {
/// 拍摄一张(返回源文件)。 /// 拍摄一张(返回源文件)。
Future<XFile> capture(); Future<XFile> capture();
/// 取景预览(含纵横比校正——全屏 cover 铺满,不拉伸)。 /// 取景预览(按传感器比例居中——所见即所得,不裁切不变形)。
Widget buildPreview(); Widget buildPreview();
/// 释放底层资源。 /// 释放底层资源。
@@ -49,6 +51,15 @@ abstract class CaptureSource {
/// 设置闪光灯(仅 [supportsFlash] 为真时有意义;失败抛异常由调用方回滚)。 /// 设置闪光灯(仅 [supportsFlash] 为真时有意义;失败抛异常由调用方回滚)。
Future<void> setFlashEnabled(bool enabled) async {} Future<void> setFlashEnabled(bool enabled) async {}
/// 变焦能力下限(默认 1=不支持变焦;子类按硬件覆写)。
double get minZoom => 1;
/// 变焦能力上限(默认 1=不支持变焦;不支持则 UI 不启用手势)。
double get maxZoom => 1;
/// 设置变焦倍数(默认 no-op——能力为 1 时 UI 不启用手势)。
Future<void> setZoom(double level) async {}
} }
/// 生产拍摄源:camera 插件(后摄优先,最高分辨率,静音连拍)。 /// 生产拍摄源:camera 插件(后摄优先,最高分辨率,静音连拍)。
@@ -73,11 +84,22 @@ class CameraPluginSource extends CaptureSource {
enableAudio: false, enableAudio: false,
); );
await controller.initialize(); await controller.initialize();
return CameraPluginSource._(controller); final source = CameraPluginSource._(controller);
try {
source._minZoom = await controller.getMinZoomLevel();
source._maxZoom = await controller.getMaxZoomLevel();
} on Object {
// 能力探测失败(部分设备/模拟器):保持 1/1,捏合不启用。
}
return source;
} }
final CameraController _controller; final CameraController _controller;
double _zoomLevel = 1.0;
double _minZoom = 1;
double _maxZoom = 1;
@override @override
Future<void> initialize() async {} Future<void> initialize() async {}
@@ -86,22 +108,40 @@ class CameraPluginSource extends CaptureSource {
@override @override
Widget buildPreview() { Widget buildPreview() {
// 纵横比校正(§4 页面 13):传感器 previewSize 报的是**横向**(width > // 所见即所得(§4 页面 13 修订,决策 ④):按传感器纵向比例居中显示,
// height),竖屏 App 直接用它会把画面拉扁——需交换宽高转为纵向比例, // 上下露出深色背景——不再 FittedBox cover 全屏(那会横向裁 ~38% 画面,
// 再 FittedBox cover 铺满(裁切不变形)。 // 且预览所见 ≠ takePicture 全幅照片)。
final size = _controller.value.previewSize; final size = _controller.value.previewSize;
Widget preview = CameraPreview(_controller); Widget preview = CameraPreview(_controller);
if (size != null && size.width > 0 && size.height > 0) { if (size != null && size.width > 0 && size.height > 0) {
preview = FittedBox( preview = Center(
fit: BoxFit.cover, child: AspectRatio(
clipBehavior: Clip.hardEdge, // 交换:竖屏用 height 作宽、width 作高(传感器横向 → 纵向)。
// 交换:竖屏用 height 作宽、width 作高(传感器横向 → 纵向)。 aspectRatio: size.height / size.width,
child: SizedBox(width: size.height, height: size.width, child: preview), child: preview,
),
); );
} }
return preview; return preview;
} }
@override
double get minZoom => _minZoom;
@override
double get maxZoom => _maxZoom;
@override
Future<void> setZoom(double level) async {
final clamped = level.clamp(minZoom, maxZoom).toDouble();
if (clamped == _zoomLevel) return;
await _controller.setZoomLevel(clamped);
_zoomLevel = clamped;
}
/// 当前变焦倍数(最近一次硬件同步值)。
double get currentZoom => _zoomLevel;
@override @override
bool get supportsFlash => true; bool get supportsFlash => true;
@@ -179,6 +219,24 @@ class _CapturePageState extends ConsumerState<CapturePage>
bool _showEnqueuedFeedback = false; bool _showEnqueuedFeedback = false;
Timer? _feedbackTimer; Timer? _feedbackTimer;
// ── 捏合变焦(Task 7:源 maxZoom > 1 时启用手势) ──────────────────────
/// 捏合起始倍数基线(手势中目标 = 基线 × scale;手势结束置 null)。
double? _pinchBaseZoom;
/// 最近一次已下发到源的变焦值(节流阈值基准;源重开时复位 1)。
double _lastSentZoom = 1;
/// setZoom 在途标记(在途时缓存最新目标,完成后补发——防平台通道乱序
/// 导致徽标与硬件漂移)。
bool _zoomInFlight = false;
double? _zoomQueued;
/// 倍数徽标:显示当前目标倍数(1.0× 格式),停止更新 1s 后隐藏。
double _zoomDisplay = 1;
bool _zoomBadgeVisible = false;
Timer? _zoomBadgeTimer;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -191,6 +249,7 @@ class _CapturePageState extends ConsumerState<CapturePage>
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
_feedbackTimer?.cancel(); _feedbackTimer?.cancel();
_zoomBadgeTimer?.cancel();
_source?.dispose().ignore(); _source?.dispose().ignore();
super.dispose(); super.dispose();
} }
@@ -222,11 +281,18 @@ class _CapturePageState extends ConsumerState<CapturePage>
return; return;
} }
_source = source; _source = source;
_zoomBadgeTimer?.cancel();
setState(() { setState(() {
_preferFront = front; _preferFront = front;
_flashOn = false; // 新相机实例回默认关。 _flashOn = false; // 新相机实例回默认关。
// 新源变焦天然回 1(新控制器默认倍数);徽标状态同步清理。
_zoomDisplay = 1;
_zoomBadgeVisible = false;
_phase = _CameraPhase.ready; _phase = _CameraPhase.ready;
}); });
_pinchBaseZoom = null;
_lastSentZoom = 1;
_zoomQueued = null;
} on Object catch (error) { } on Object catch (error) {
if (!mounted) return; if (!mounted) return;
setState( setState(
@@ -302,6 +368,84 @@ class _CapturePageState extends ConsumerState<CapturePage>
}); });
} }
// ── 捏合变焦手势 ─────────────────────────────────────────────────────────
void _onPinchStart(ScaleStartDetails details) {
final source = _source;
if (source == null || source.maxZoom <= 1) return;
// 基线取当前已下发倍数(受源能力夹取)——连续捏合从当前倍数续接。
_pinchBaseZoom = _lastSentZoom
.clamp(source.minZoom, source.maxZoom)
.toDouble();
_showZoomBadge(_pinchBaseZoom!);
}
void _onPinchUpdate(ScaleUpdateDetails details) {
final source = _source;
final base = _pinchBaseZoom;
if (source == null || base == null) return;
final target =
(base * details.scale).clamp(source.minZoom, source.maxZoom).toDouble();
_showZoomBadge(target);
_dispatchZoom(target);
}
void _onPinchEnd(ScaleEndDetails details) {
// 手势结束(含单指抬起触发的重定基)——置空基线,下次手势从当前
// 倍数重新起步(onScaleStart 重新取 _lastSentZoom)。
_pinchBaseZoom = null;
}
/// 双击复位 1.0×。
void _resetZoom() {
final source = _source;
if (source == null || source.maxZoom <= 1) return;
_showZoomBadge(1);
_dispatchZoom(1);
}
/// 变焦下发:节流 + 串行化。onScaleUpdate 每帧触发(60-120/s),不能
/// 每次都打平台通道——与最近已发值差 ≥0.05 才发;在途时缓存最新目标,
/// 完成后补发(防乱序导致徽标与硬件漂移)。
void _dispatchZoom(double target) {
if (_zoomInFlight) {
_zoomQueued = target;
return;
}
if ((target - _lastSentZoom).abs() < 0.05) return;
_sendZoom(target);
}
void _sendZoom(double target) {
final source = _source;
if (source == null) return;
_zoomInFlight = true;
_lastSentZoom = target;
source
.setZoom(target)
.catchError((Object _) {}) // 硬件设置失败:保持当前值(下次手势重试)。
.whenComplete(() {
_zoomInFlight = false;
final queued = _zoomQueued;
_zoomQueued = null;
// 源已更换(翻转/重开相机):丢弃补发。
if (_source != source || queued == null) return;
if ((queued - _lastSentZoom).abs() >= 0.05) _sendZoom(queued);
});
}
/// 倍数徽标:显示当前倍数(四舍五入 1 位小数),停止更新 1s 后隐藏。
void _showZoomBadge([double? level]) {
_zoomBadgeTimer?.cancel();
setState(() {
if (level != null) _zoomDisplay = level;
_zoomBadgeVisible = true;
});
_zoomBadgeTimer = Timer(const Duration(milliseconds: 1000), () {
if (mounted) setState(() => _zoomBadgeVisible = false);
});
}
/// 相册多选入队(目标 = 当前相册与分类;入口=底部缩略图位)。 /// 相册多选入队(目标 = 当前相册与分类;入口=底部缩略图位)。
Future<void> _pickFromGallery() async { Future<void> _pickFromGallery() async {
final paths = await ref.read(multiImagePickerProvider)(context); final paths = await ref.read(multiImagePickerProvider)(context);
@@ -541,7 +685,7 @@ class _CapturePageState extends ConsumerState<CapturePage>
final source = _source; final source = _source;
switch (_phase) { switch (_phase) {
case _CameraPhase.ready: case _CameraPhase.ready:
return source!.buildPreview(); return _buildReadyArea(source!);
case _CameraPhase.loading: case _CameraPhase.loading:
return const Center( return const Center(
child: CircularProgressIndicator(color: Colors.white), child: CircularProgressIndicator(color: Colors.white),
@@ -562,6 +706,46 @@ class _CapturePageState extends ConsumerState<CapturePage>
); );
} }
} }
/// 就绪态:预览 + 捏合变焦手势(源 maxZoom > 1 时挂)+ 倍数徽标。
Widget _buildReadyArea(CaptureSource source) {
Widget area = source.buildPreview();
if (source.maxZoom > 1) {
area = GestureDetector(
// opaque:预览任意点均可起手势(含按比例居中后的上下留黑区)。
behavior: HitTestBehavior.opaque,
onScaleStart: _onPinchStart,
onScaleUpdate: _onPinchUpdate,
onScaleEnd: _onPinchEnd,
onDoubleTap: _resetZoom,
child: area,
);
}
if (!_zoomBadgeVisible) return area;
return Stack(
fit: StackFit.expand,
children: [area, _buildZoomBadge()],
);
}
/// 变焦倍数徽标(捏合/双击期间显示,不拦截手势)。
Widget _buildZoomBadge() {
return IgnorePointer(
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(999),
),
child: Text(
'${_zoomDisplay.toStringAsFixed(1)}×',
style: const TextStyle(color: Colors.white, fontSize: 13),
),
),
),
);
}
} }
// ── 信息层组件 ──────────────────────────────────────────────────────────── // ── 信息层组件 ────────────────────────────────────────────────────────────
@@ -403,8 +403,9 @@ void main() {
}); });
} }
/// 相机源替身(详情页跳连拍页测试:仅就绪,不拍摄)。 /// 相机源替身(详情页跳连拍页测试:仅就绪,不拍摄)。extends 继承
class _StaticCaptureSource implements CaptureSource { /// 接口默认实现(闪光灯/变焦能力默认不支持——UI 不显示开关不启用手势)。
class _StaticCaptureSource extends CaptureSource {
@override @override
Future<void> initialize() async {} Future<void> initialize() async {}
@@ -414,12 +415,6 @@ class _StaticCaptureSource implements CaptureSource {
@override @override
Widget buildPreview() => const SizedBox(); Widget buildPreview() => const SizedBox();
@override
bool get supportsFlash => false;
@override
Future<void> setFlashEnabled(bool enabled) async {}
@override @override
Future<void> dispose() async {} Future<void> dispose() async {}
} }
+74 -3
View File
@@ -5,7 +5,8 @@
/// 准备器 + 非 Android 权限门替身。覆盖:快门即拍即入队、连拍串行、分类 /// 准备器 + 非 Android 权限门替身。覆盖:快门即拍即入队、连拍串行、分类
/// 切换仅影响之后拍摄、多选补充入队、待传/失败徽章实时刷新、有待传任务 /// 切换仅影响之后拍摄、多选补充入队、待传/失败徽章实时刷新、有待传任务
/// 退出确认、权限拒绝/初始化失败视图与重试、深色界面(#10161E)、快门 /// 退出确认、权限拒绝/初始化失败视图与重试、深色界面(#10161E)、快门
/// ≥72dp、闪光灯按能力显示、前后摄切换重建、最近一张缩略图。 /// ≥72dp、闪光灯按能力显示、前后摄切换重建、最近一张缩略图、捏合变焦
/// 与双击复位。
library; library;
import 'dart:io'; import 'dart:io';
@@ -81,6 +82,23 @@ class FakeCaptureSource extends CaptureSource {
Future<void> dispose() async => disposeCalls++; 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() { void main() {
late FakeSessionStore session; late FakeSessionStore session;
late TaskStore store; late TaskStore store;
@@ -147,7 +165,8 @@ void main() {
categories: categories, categories: categories,
); );
Future<void> pumpCapture(WidgetTester tester) async { Future<void> pumpCapture(WidgetTester tester, {CaptureSource? cameraSource}) async {
final effectiveSource = cameraSource ?? source;
await tester.pumpWidget( await tester.pumpWidget(
ProviderScope( ProviderScope(
overrides: [ overrides: [
@@ -164,7 +183,7 @@ void main() {
captureSourceFactoryProvider.overrideWith( captureSourceFactoryProvider.overrideWith(
(ref) => ({bool preferFront = false}) async { (ref) => ({bool preferFront = false}) async {
factoryPreferFront.add(preferFront); factoryPreferFront.add(preferFront);
return source; return effectiveSource;
}, },
), ),
uploadPreparerProvider.overrideWith( uploadPreparerProvider.overrideWith(
@@ -462,4 +481,56 @@ void main() {
expect(find.text('Ceremony'), findsOneWidget); expect(find.text('Ceremony'), findsOneWidget);
expect(find.text('Gala'), 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));
});
} }