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
/// (可切换,**仅影响之后拍摄**——引擎只读任务行归属);底部 = 最近一张
/// 缩略图(兼相册多选入口,iOS 相机惯例)+ 快门(≥72dp 白环)+ 前后摄
/// 切换 + 闪光灯(源支持才出现)。入队成功以触觉 + 200ms 浮层轻反馈,
/// 切换 + 闪光灯(源支持才出现)。预览按传感器比例居中(所见即所得,
/// 不裁切不变形);源支持变焦(maxZoom > 1)时捏合缩放 + 双击复位 1×。
/// 入队成功以触觉 + 200ms 浮层轻反馈,
/// 不逐张弹确认;退出时若有待传任务弹确认(任务在后台继续——Android
/// 原生 WorkManager 承担,见 native_transport.dart)。
///
@@ -37,7 +39,7 @@ abstract class CaptureSource {
/// 拍摄一张(返回源文件)。
Future<XFile> capture();
/// 取景预览(含纵横比校正——全屏 cover 铺满,不拉伸)。
/// 取景预览(按传感器比例居中——所见即所得,不裁切不变形)。
Widget buildPreview();
/// 释放底层资源。
@@ -49,6 +51,15 @@ abstract class CaptureSource {
/// 设置闪光灯(仅 [supportsFlash] 为真时有意义;失败抛异常由调用方回滚)。
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 插件(后摄优先,最高分辨率,静音连拍)。
@@ -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<void> 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<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
bool get supportsFlash => true;
@@ -179,6 +219,24 @@ class _CapturePageState extends ConsumerState<CapturePage>
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<CapturePage>
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_feedbackTimer?.cancel();
_zoomBadgeTimer?.cancel();
_source?.dispose().ignore();
super.dispose();
}
@@ -222,11 +281,18 @@ class _CapturePageState extends ConsumerState<CapturePage>
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<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 {
final paths = await ref.read(multiImagePickerProvider)(context);
@@ -541,7 +685,7 @@ class _CapturePageState extends ConsumerState<CapturePage>
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<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),
),
),
),
);
}
}
// ── 信息层组件 ────────────────────────────────────────────────────────────