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:
@@ -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 {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user