diff --git a/apps/mobile/lib/pages/capture_page.dart b/apps/mobile/lib/pages/capture_page.dart index a5f22e1..8ca93b5 100644 --- a/apps/mobile/lib/pages/capture_page.dart +++ b/apps/mobile/lib/pages/capture_page.dart @@ -5,7 +5,9 @@ /// 系统主题):顶部常驻层 = 返回 + 相册名 + 待传/失败徽章 + 分类 chips /// (可切换,**仅影响之后拍摄**——引擎只读任务行归属);底部 = 最近一张 /// 缩略图(兼相册多选入口,iOS 相机惯例)+ 快门(≥72dp 白环)+ 前后摄 -/// 切换 + 闪光灯(源支持才出现)。入队成功以触觉 + 200ms 浮层轻反馈, +/// 切换 + 闪光灯(源支持才出现)。预览按传感器比例居中(所见即所得, +/// 不裁切不变形);源支持变焦(maxZoom > 1)时捏合缩放 + 双击复位 1×。 +/// 入队成功以触觉 + 200ms 浮层轻反馈, /// 不逐张弹确认;退出时若有待传任务弹确认(任务在后台继续——Android /// 原生 WorkManager 承担,见 native_transport.dart)。 /// @@ -37,7 +39,7 @@ abstract class CaptureSource { /// 拍摄一张(返回源文件)。 Future capture(); - /// 取景预览(含纵横比校正——全屏 cover 铺满,不拉伸)。 + /// 取景预览(按传感器比例居中——所见即所得,不裁切不变形)。 Widget buildPreview(); /// 释放底层资源。 @@ -49,6 +51,15 @@ abstract class CaptureSource { /// 设置闪光灯(仅 [supportsFlash] 为真时有意义;失败抛异常由调用方回滚)。 Future setFlashEnabled(bool enabled) async {} + + /// 变焦能力下限(默认 1=不支持变焦;子类按硬件覆写)。 + double get minZoom => 1; + + /// 变焦能力上限(默认 1=不支持变焦;不支持则 UI 不启用手势)。 + double get maxZoom => 1; + + /// 设置变焦倍数(默认 no-op——能力为 1 时 UI 不启用手势)。 + Future setZoom(double level) async {} } /// 生产拍摄源:camera 插件(后摄优先,最高分辨率,静音连拍)。 @@ -73,11 +84,22 @@ class CameraPluginSource extends CaptureSource { enableAudio: false, ); 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; + double _zoomLevel = 1.0; + double _minZoom = 1; + double _maxZoom = 1; + @override Future initialize() async {} @@ -86,22 +108,40 @@ class CameraPluginSource extends CaptureSource { @override Widget buildPreview() { - // 纵横比校正(§4 页面 13):传感器 previewSize 报的是**横向**(width > - // height),竖屏 App 直接用它会把画面拉扁——需交换宽高转为纵向比例, - // 再 FittedBox cover 铺满(裁切不变形)。 + // 所见即所得(§4 页面 13 修订,决策 ④):按传感器纵向比例居中显示, + // 上下露出深色背景——不再 FittedBox cover 全屏(那会横向裁 ~38% 画面, + // 且预览所见 ≠ takePicture 全幅照片)。 final size = _controller.value.previewSize; Widget preview = CameraPreview(_controller); if (size != null && size.width > 0 && size.height > 0) { - preview = FittedBox( - fit: BoxFit.cover, - clipBehavior: Clip.hardEdge, - // 交换:竖屏用 height 作宽、width 作高(传感器横向 → 纵向)。 - child: SizedBox(width: size.height, height: size.width, child: preview), + preview = Center( + child: AspectRatio( + // 交换:竖屏用 height 作宽、width 作高(传感器横向 → 纵向)。 + aspectRatio: size.height / size.width, + child: preview, + ), ); } return preview; } + @override + double get minZoom => _minZoom; + + @override + double get maxZoom => _maxZoom; + + @override + Future 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 bool get supportsFlash => true; @@ -179,6 +219,24 @@ class _CapturePageState extends ConsumerState bool _showEnqueuedFeedback = false; 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 void initState() { super.initState(); @@ -191,6 +249,7 @@ class _CapturePageState extends ConsumerState void dispose() { WidgetsBinding.instance.removeObserver(this); _feedbackTimer?.cancel(); + _zoomBadgeTimer?.cancel(); _source?.dispose().ignore(); super.dispose(); } @@ -222,11 +281,18 @@ class _CapturePageState extends ConsumerState return; } _source = source; + _zoomBadgeTimer?.cancel(); setState(() { _preferFront = front; _flashOn = false; // 新相机实例回默认关。 + // 新源变焦天然回 1(新控制器默认倍数);徽标状态同步清理。 + _zoomDisplay = 1; + _zoomBadgeVisible = false; _phase = _CameraPhase.ready; }); + _pinchBaseZoom = null; + _lastSentZoom = 1; + _zoomQueued = null; } on Object catch (error) { if (!mounted) return; setState( @@ -302,6 +368,84 @@ class _CapturePageState extends ConsumerState }); } + // ── 捏合变焦手势 ───────────────────────────────────────────────────────── + + 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 _pickFromGallery() async { final paths = await ref.read(multiImagePickerProvider)(context); @@ -541,7 +685,7 @@ class _CapturePageState extends ConsumerState final source = _source; switch (_phase) { case _CameraPhase.ready: - return source!.buildPreview(); + return _buildReadyArea(source!); case _CameraPhase.loading: return const Center( child: CircularProgressIndicator(color: Colors.white), @@ -562,6 +706,46 @@ class _CapturePageState extends ConsumerState ); } } + + /// 就绪态:预览 + 捏合变焦手势(源 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), + ), + ), + ), + ); + } } // ── 信息层组件 ──────────────────────────────────────────────────────────── diff --git a/apps/mobile/test/pages/album_detail_page_test.dart b/apps/mobile/test/pages/album_detail_page_test.dart index 4f6695e..4c8ad09 100644 --- a/apps/mobile/test/pages/album_detail_page_test.dart +++ b/apps/mobile/test/pages/album_detail_page_test.dart @@ -403,8 +403,9 @@ void main() { }); } -/// 相机源替身(详情页跳连拍页测试:仅就绪,不拍摄)。 -class _StaticCaptureSource implements CaptureSource { +/// 相机源替身(详情页跳连拍页测试:仅就绪,不拍摄)。extends 继承 +/// 接口默认实现(闪光灯/变焦能力默认不支持——UI 不显示开关不启用手势)。 +class _StaticCaptureSource extends CaptureSource { @override Future initialize() async {} @@ -414,12 +415,6 @@ class _StaticCaptureSource implements CaptureSource { @override Widget buildPreview() => const SizedBox(); - @override - bool get supportsFlash => false; - - @override - Future setFlashEnabled(bool enabled) async {} - @override Future dispose() async {} } diff --git a/apps/mobile/test/pages/capture_page_test.dart b/apps/mobile/test/pages/capture_page_test.dart index ef199f3..a1b988d 100644 --- a/apps/mobile/test/pages/capture_page_test.dart +++ b/apps/mobile/test/pages/capture_page_test.dart @@ -5,7 +5,8 @@ /// 准备器 + 非 Android 权限门替身。覆盖:快门即拍即入队、连拍串行、分类 /// 切换仅影响之后拍摄、多选补充入队、待传/失败徽章实时刷新、有待传任务 /// 退出确认、权限拒绝/初始化失败视图与重试、深色界面(#10161E)、快门 -/// ≥72dp、闪光灯按能力显示、前后摄切换重建、最近一张缩略图。 +/// ≥72dp、闪光灯按能力显示、前后摄切换重建、最近一张缩略图、捏合变焦 +/// 与双击复位。 library; import 'dart:io'; @@ -81,6 +82,23 @@ class FakeCaptureSource extends CaptureSource { Future dispose() async => disposeCalls++; } +/// 变焦能力替身(Task 7 捏合变焦):能力 1-8×,记录 setZoom 调用序列。 +/// 继承 [FakeCaptureSource](预览标记/初始化/释放行为一致)。 +class ZoomFakeCaptureSource extends FakeCaptureSource { + ZoomFakeCaptureSource(super.files); + + final List zoomCalls = []; + + @override + double get minZoom => 1; + + @override + double get maxZoom => 8; + + @override + Future setZoom(double level) async => zoomCalls.add(level); +} + void main() { late FakeSessionStore session; late TaskStore store; @@ -147,7 +165,8 @@ void main() { categories: categories, ); - Future pumpCapture(WidgetTester tester) async { + Future 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([]); + 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([]); + 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)); + }); }