Files
photo-live/apps/mobile/lib/pages/capture_page.dart
T
浅念 0fa23e015e feat(app): server-side duplicate pre-check at enqueue moment
Task 2 of docs/plans/2026-09-28-上传查重前置到入队前-实施计划.md:
surface server-side duplicate history at the "click upload" moment,
merged with the local check into the EXISTING dialog.

- ApiClient.checkUploadDuplicates: POST /v1/uploads/duplicates/check
  (commit 805f77b endpoint); 500/chunk sequential requests; chunk-local
  index translated to global; empty list short-circuits (no request);
  errors normalized to AppException via _send.
- enqueue.checkDuplicatesUnified: local TaskStore history ∪ server
  history; server hits map back to filename via global index into the
  stat'd picks; merged, de-duplicated (filename+bytes) list; server
  failure (network/permission/any) silently degrades to local-only —
  init 409 remains the backstop.
- capture_page + album_detail_page _pickFromGallery now call the
  unified entry; dialog/skip/force logic untouched.

Tests: api_client chunking (1200 → 500/500/200) + index translation +
merge + 404 normalization + empty no-request; page-level three states
(hit/no-hit/network failure) incl. fresh-install server-only-hit dialog
on both pages; pre-existing Task 8 dialog tests keep passing via the
degrade path.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 19:17:35 +08:00

1122 lines
37 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 连拍页(UI-03 页面 13 深色摄影界面;P3 计划 Task 9 / R17 连拍入队)。
///
/// 进入时选定目标相册与分类(相册详情 FAB 传入);快门每张立即经准备器
/// 入队(preparing→queued,不逐张确认)。全屏取景(恒深色 #10161E,不随
/// 系统主题):顶部常驻层 = 返回 + 相册名 + 待传/失败徽章 + 分类 chips
/// (可切换,**仅影响之后拍摄**——引擎只读任务行归属);底部 = 最近一张
/// 缩略图(兼相册多选入口,iOS 相机惯例)+ 快门(≥72dp 白环)+ 前后摄
/// 切换 + 闪光灯(源支持才出现)。预览按传感器比例居中(所见即所得,
/// 不裁切不变形);源支持变焦(maxZoom > 1)时捏合缩放 + 双击复位 1×。
/// 入队成功以触觉 + 200ms 浮层轻反馈,
/// 不逐张弹确认;退出时若有待传任务弹确认(任务在后台继续——Android
/// 原生 WorkManager 承担,见 native_transport.dart)。
///
/// 相机/相册经注入点([captureSourceFactoryProvider]/[multiImagePickerProvider])
/// 隔离平台通道,widget 测试注入替身驱动。
library;
import 'dart:async';
import 'dart:io';
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show HapticFeedback;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../features/albums/models.dart';
import '../features/uploads/enqueue.dart';
import '../features/uploads/queue_controller.dart';
import '../l10n/app_localizations.dart';
import '../ui/tokens.dart';
// ── 相机源抽象(生产=camera 插件;测试替身) ─────────────────────────────
/// 拍摄源:初始化/拍摄/预览/释放。
abstract class CaptureSource {
/// 初始化(权限缺失/失败抛异常;权限拒绝抛 [CameraException])。
Future<void> initialize();
/// 拍摄一张(返回源文件)。
Future<XFile> capture();
/// 取景预览(按传感器比例居中——所见即所得,不裁切不变形)。
Widget buildPreview();
/// 释放底层资源。
Future<void> dispose();
/// 闪光灯能力(默认不支持——子类按硬件能力覆写;不支持则 UI 不显示
/// 闪光开关,不提供假开关)。
bool get supportsFlash => false;
/// 设置闪光灯(仅 [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 插件(后摄优先,最高分辨率,静音连拍)。
class CameraPluginSource extends CaptureSource {
CameraPluginSource._(this._controller);
/// 创建并初始化([preferFront] 为真选前摄,否则后摄优先)。
static Future<CameraPluginSource> create({bool preferFront = false}) async {
final cameras = await availableCameras();
if (cameras.isEmpty) {
throw CameraException('noCamera', 'no camera available');
}
CameraDescription pick() => cameras.firstWhere(
(c) =>
c.lensDirection ==
(preferFront ? CameraLensDirection.front : CameraLensDirection.back),
orElse: () => cameras.first,
);
final controller = CameraController(
pick(),
ResolutionPreset.max,
enableAudio: false,
);
await controller.initialize();
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 {}
@override
Future<XFile> capture() => _controller.takePicture();
@override
Widget buildPreview() {
// 所见即所得(§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 = 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;
}
@override
bool get supportsFlash => true;
@override
Future<void> setFlashEnabled(bool enabled) =>
_controller.setFlashMode(enabled ? FlashMode.always : FlashMode.off);
@override
Future<void> dispose() => _controller.dispose();
}
/// 拍摄源工厂 Provider(测试 override 为替身)。
final captureSourceFactoryProvider =
Provider<Future<CaptureSource> Function({bool preferFront})>(
(ref) => CameraPluginSource.create,
);
// ── 连拍页 ────────────────────────────────────────────────────────────────
/// 连拍页参数:目标相册(相册详情 FAB 进入时固定)与分类候选。
class CaptureTarget {
const CaptureTarget({
required this.accountId,
required this.albumId,
required this.albumTitle,
this.categories = const <Category>[],
});
/// 入队账号(TaskStore 隔离键)。
final String accountId;
/// 目标相册 ID。
final String albumId;
/// 目标相册展示名(信息层常驻显示)。
final String albumTitle;
/// 相册分类(空 = 无可切换项,仅默认分类)。
final List<Category> categories;
}
/// 连拍页。
class CapturePage extends ConsumerStatefulWidget {
const CapturePage({required this.target, super.key});
final CaptureTarget target;
@override
ConsumerState<CapturePage> createState() => _CapturePageState();
}
enum _CameraPhase { loading, ready, denied, failed }
class _CapturePageState extends ConsumerState<CapturePage>
with WidgetsBindingObserver {
CaptureSource? _source;
_CameraPhase _phase = _CameraPhase.loading;
/// 当前镜头方向(快门旁切换按钮翻转;重试沿用)。
bool _preferFront = false;
/// 闪光灯开关(源不支持则无 UI;重开相机后复位)。
bool _flashOn = false;
/// 最近一张拍摄文件(底部缩略图;拍摄前显示相册入口占位)。
String? _lastShotPath;
/// 当前分类(null = 相册默认分类;切换仅影响之后拍摄)。
String? _categoryId;
/// 连拍串行链:快门连点逐张落地(相机硬件本就串行)。
Future<void> _shotTail = Future<void>.value();
/// 入队成功短暂反馈(150-220ms 轻量动效档,§3.1)。
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();
// P0-3:监听 App 生命周期——后台释放相机(省电 + 隐私),回前台重开。
WidgetsBinding.instance.addObserver(this);
_initCamera();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_feedbackTimer?.cancel();
_zoomBadgeTimer?.cancel();
_source?.dispose().ignore();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
// P0-3:后台 → 释放相机硬件(不释放则摄像头指示灯常亮 + 耗电);
// 回前台 → 重新初始化(沿用当前前后摄/闪光灯偏好)。
if (state == AppLifecycleState.paused && _source != null) {
_source?.dispose().ignore();
_source = null;
setState(() => _phase = _CameraPhase.loading);
} else if (state == AppLifecycleState.resumed && _source == null) {
_initCamera();
}
}
Future<void> _initCamera({bool? preferFront}) async {
final front = preferFront ?? _preferFront;
setState(() => _phase = _CameraPhase.loading);
try {
final source = await ref.read(captureSourceFactoryProvider)(
preferFront: front,
);
await source.initialize();
if (!mounted) {
await source.dispose();
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(
() => _phase = _isPermissionDenied(error)
? _CameraPhase.denied
: _CameraPhase.failed,
);
}
}
/// camera 插件的权限拒绝错误码族(CameraAccessDenied /
/// CameraAccessDeniedWithoutRestriction 等)。
static bool _isPermissionDenied(Object error) =>
error is CameraException &&
(error.code.toLowerCase().contains('denied') ||
error.code.toLowerCase().contains('permission'));
/// 前后摄切换:释放当前源 → 反向重建(能力缺失自然落 failed 态重试)。
Future<void> _flipCamera() async {
if (_phase != _CameraPhase.ready) return;
final old = _source;
_source = null;
await old?.dispose();
await _initCamera(preferFront: !_preferFront);
}
/// 闪光灯开关:硬件设置失败则回滚(不留假状态)。
Future<void> _toggleFlash() async {
final source = _source;
if (source == null || !source.supportsFlash) return;
final next = !_flashOn;
try {
await source.setFlashEnabled(next);
if (mounted) setState(() => _flashOn = next);
} on Object {
// 设置失败:保持原状态(下次点击重试)。
}
}
/// 快门:立即入队(R17 不逐张确认);连点串行不丢张。
void _shoot() {
final source = _source;
if (source == null || _phase != _CameraPhase.ready) return;
final categoryId = _categoryId;
_shotTail = _shotTail
.then((_) async {
final photo = await source.capture();
if (!mounted) return;
// 最近一张缩略图(拍摄原文件;缺失走占位)。
setState(() => _lastShotPath = photo.path);
final enqueued = await enqueueForUpload(
ref,
accountId: widget.target.accountId,
albumId: widget.target.albumId,
categoryId: categoryId,
sourcePaths: [photo.path],
);
// 入队成功:短暂反馈(轻触 + 200ms 浮层),不逐张弹确认(§4.5)。
if (enqueued.enqueued > 0 && mounted) _flashEnqueued();
})
.catchError((Object _) {
// 单张失败吞掉(连拍不中断;失败任务/错误在徽章与队列页可见)。
});
}
/// 入队成功反馈:触觉轻点 + 顶部短暂浮层(200ms 后自动消退)。
void _flashEnqueued() {
HapticFeedback.selectionClick();
_feedbackTimer?.cancel();
setState(() => _showEnqueuedFeedback = true);
_feedbackTimer = Timer(const Duration(milliseconds: 200), () {
if (mounted) setState(() => _showEnqueuedFeedback = false);
});
}
// ── 捏合变焦手势 ─────────────────────────────────────────────────────────
void _onPinchStart(ScaleStartDetails details) {
final source = _source;
if (source == null || source.maxZoom <= 1) return;
// 基线取当前已下发倍数(受源能力夹取)——连续捏合从当前倍数续接。
_pinchBaseZoom = _lastSentZoom
.clamp(source.minZoom, source.maxZoom)
.toDouble();
// 单指轻点同样触发 onScaleStart——倍数徽标仅在真捏合(≥2 指)时显示,
// 避免轻点预览也弹 1.0×;基线重定保持无条件(单指转捏合、抬起再捏合
// 均从当前倍数续接)。
if (details.pointerCount >= 2) _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);
if (paths.isEmpty || !mounted) return;
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
// P0-1 层 1+2(Task 2 统一入口):入队前查重——本机 TaskStore 历史
// ∪ 服务器历史(bytes 命中;服务器失败静默降级本机-only)。
final duplicates = await checkDuplicatesUnified(
ref,
accountId: widget.target.accountId,
albumId: widget.target.albumId,
sourcePaths: paths,
);
Set<String> skipPaths;
Set<String> forcePaths;
if (duplicates != null && duplicates.isNotEmpty && mounted) {
// 批量确认弹窗(单次,非逐张)。
final choice = await _showDuplicateDialog(l10n, duplicates, paths);
if (choice == null) return; // 取消——不上传。
skipPaths = choice.skipPaths;
forcePaths = choice.forcePaths;
} else {
skipPaths = <String>{};
forcePaths = <String>{};
}
final result = await enqueueForUpload(
ref,
accountId: widget.target.accountId,
albumId: widget.target.albumId,
categoryId: _categoryId,
sourcePaths: paths,
skipPaths: skipPaths.toList(),
forcePaths: forcePaths.toList(),
);
if (!mounted) return;
final uploaded = result.enqueued;
final skipped = result.skipped;
messenger.showSnackBar(
SnackBar(
content: Text(
skipped > 0
? '$uploaded 张已入队,$skipped 张重复已跳过'
: l10n.uploadEnqueuedCount(uploaded),
),
),
);
}
/// 批量重复确认弹窗(Task 8):null=取消;「跳过重复」→ skipPaths=
/// 重复项;「全部上传」→ forcePaths=重复项(用户确认强传,服务端
/// force 查重跳过仅 owner/admin 生效——协作者仍会被 409 拒绝)。
Future<DuplicateDialogChoice?> _showDuplicateDialog(
AppLocalizations l10n,
List<DuplicateMatch> duplicates,
List<String> allPaths,
) async {
// 找出与 duplicates 匹配的路径(filename 一致即视为重复项)。
final dupFilenames = duplicates.map((d) => d.filename).toSet();
final dupPaths = allPaths
.where((p) => dupFilenames.contains(p.split('/').last))
.toSet();
final nonDupCount = allPaths.length - dupPaths.length;
final choice = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('可能已上传过'),
content: Text(
'${duplicates.length} 张照片可能已上传到该相册'
'${nonDupCount > 0 ? '(另 $nonDupCount 张不重复)' : ''}\n\n'
'${duplicates.take(3).map((d) => d.filename).join('、')}'
'${duplicates.length > 3 ? ' …' : ''}',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop('cancel'),
child: const Text('取消'),
),
if (nonDupCount > 0)
TextButton(
onPressed: () => Navigator.of(ctx).pop('skip'),
child: Text('跳过重复,上传 $nonDupCount 张'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop('all'),
child: Text('全部上传 ${allPaths.length} 张'),
),
],
),
);
if (choice == 'cancel' || choice == null) return null;
if (choice == 'skip') {
return (skipPaths: dupPaths, forcePaths: <String>{});
}
// 'all' = 用户确认强传:重复项 force 入队(不跳过任何)。
return (skipPaths: <String>{}, forcePaths: dupPaths);
}
void _switchCategory(String? categoryId) {
if (categoryId == _categoryId) return;
setState(() => _categoryId = categoryId);
// 归属不变提示(A15):仅影响之后拍摄。
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context).captureSwitchHint)),
);
}
Future<void> _confirmExit() async {
final l10n = AppLocalizations.of(context);
final leave = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(l10n.captureExitTitle),
content: Text(
l10n.captureExitBody(pendingCountOf(ref.read(taskCountsProvider))),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(l10n.captureKeepShooting),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(l10n.captureExitConfirm),
),
],
),
);
if (leave == true && mounted) {
Navigator.of(context).pop();
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final counts = ref.watch(taskCountsProvider);
final pending = pendingCountOf(counts);
final failed = failedCountOf(counts);
final categories = widget.target.categories;
return PopScope(
// 有待传任务 → 拦截退出弹确认(后台继续文案);无任务直接退出。
canPop: pending == 0,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
_confirmExit();
},
child: Scaffold(
// 深色摄影界面恒深色(§3):不读主题,固定 #10161E。
backgroundColor: kColorPhotoBackdrop,
body: Stack(
fit: StackFit.expand,
children: [
_buildCameraArea(l10n),
// 顶部常驻层:返回 + 相册 + 待传/失败徽章 + 分类 chips。
SafeArea(
child: Column(
children: [
_TopBar(
albumTitle: widget.target.albumTitle,
queued: pending,
failed: failed,
onBack: () => Navigator.of(context).maybePop(),
),
if (categories.isNotEmpty)
_CategoryChips(
categories: categories,
categoryId: _categoryId,
onCategorySelected: _switchCategory,
),
],
),
),
// 入队成功短暂反馈浮层(200ms 自动消退,不逐张弹确认)。
Positioned(
top: 132,
left: 0,
right: 0,
child: IgnorePointer(
child: AnimatedOpacity(
opacity: _showEnqueuedFeedback ? 1 : 0,
duration: const Duration(milliseconds: 180),
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.check_circle,
size: 16,
color: Colors.white,
),
const SizedBox(width: 6),
Text(
l10n.captureEnqueuedFeedback,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
),
),
],
),
),
),
),
),
),
// 底部操作层:缩略图(=相册入口)+ 快门(+闪光灯)+ 前后切换。
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _BottomControls(
enabled: _phase == _CameraPhase.ready,
lastShotPath: _lastShotPath,
flashSupported: _source?.supportsFlash ?? false,
flashOn: _flashOn,
onShutter: _shoot,
onGallery: _pickFromGallery,
onFlip: _flipCamera,
onFlash: _toggleFlash,
),
),
],
),
),
);
}
Widget _buildCameraArea(AppLocalizations l10n) {
final source = _source;
switch (_phase) {
case _CameraPhase.ready:
return _buildReadyArea(source!);
case _CameraPhase.loading:
return const Center(
child: CircularProgressIndicator(color: Colors.white),
);
case _CameraPhase.denied:
return _CameraMessageView(
icon: Icons.no_photography_outlined,
message: l10n.cameraPermissionDenied,
actionLabel: l10n.retry,
onAction: _initCamera,
);
case _CameraPhase.failed:
return _CameraMessageView(
icon: Icons.error_outline,
message: l10n.cameraInitFailed,
actionLabel: l10n.retry,
onAction: _initCamera,
);
}
}
/// 就绪态:预览 + 捏合变焦手势(源 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),
),
),
),
);
}
}
// ── 信息层组件 ────────────────────────────────────────────────────────────
/// 顶部常驻层:返回 + 相册名 + 待传/失败徽章(§4 页面 13)。
class _TopBar extends StatelessWidget {
const _TopBar({
required this.albumTitle,
required this.queued,
required this.failed,
required this.onBack,
});
final String albumTitle;
final int queued;
final int failed;
final VoidCallback onBack;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 12, 0),
child: Row(
children: [
IconButton(
tooltip: MaterialLocalizations.of(
context,
).backButtonTooltip, // 语义标签;深色界面固定白色前景。
color: Colors.white,
icon: const Icon(Icons.arrow_back),
onPressed: onBack,
),
const SizedBox(width: kSpace4),
const Icon(
Icons.photo_album_outlined,
size: 16,
color: Colors.white70,
),
const SizedBox(width: 6),
Expanded(
child: Text(
albumTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
),
if (failed > 0) ...[
_Badge(
label: l10n.captureFailedBadge(failed),
color: kColorErrorDark,
foreground: Colors.white,
),
const SizedBox(width: kSpace8),
],
_Badge(
label: l10n.captureQueued(queued),
color: Colors.white24,
foreground: Colors.white,
),
],
),
);
}
}
/// 分类 chips(切换仅影响之后拍摄;A15 归属不变)。
class _CategoryChips extends StatelessWidget {
const _CategoryChips({
required this.categories,
required this.categoryId,
required this.onCategorySelected,
});
final List<Category> categories;
final String? categoryId;
final void Function(String?) onCategorySelected;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return SizedBox(
height: 44,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
children: [
ChoiceChip(
label: Text(l10n.captureCategoryDefault),
selected: categoryId == null,
onSelected: (_) => onCategorySelected(null),
labelStyle: const TextStyle(color: Colors.white),
selectedColor: Colors.white24,
backgroundColor: Colors.black38,
),
for (final category in categories) ...[
const SizedBox(width: 8),
ChoiceChip(
label: Text(_categoryLabel(category, l10n)),
selected: categoryId == category.id,
onSelected: (_) => onCategorySelected(category.id),
labelStyle: const TextStyle(color: Colors.white),
selectedColor: Colors.white24,
backgroundColor: Colors.black38,
),
],
],
),
);
}
static String _categoryLabel(Category category, AppLocalizations l10n) {
final lang = l10n.localeName.split('-').first.toLowerCase();
CategoryTranslation? match;
for (final t in category.translations) {
if (t.locale.toLowerCase() == l10n.localeName.toLowerCase() ||
t.locale.toLowerCase().split('-').first == lang) {
match = t;
break;
}
}
if (match != null) return match.name;
return category.translations.isEmpty
? category.id
: category.translations.first.name;
}
}
/// 徽章(待传/失败计数)。
class _Badge extends StatelessWidget {
const _Badge({
required this.label,
required this.color,
required this.foreground,
});
final String label;
final Color color;
final Color foreground;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(999),
),
child: Text(label, style: TextStyle(color: foreground, fontSize: 12)),
);
}
}
/// 底部操作层:最近一张缩略图(兼相册多选入口)+ 快门(≥72dp 白环,
/// 源支持时上方附闪光灯)+ 前后摄切换。
class _BottomControls extends StatelessWidget {
const _BottomControls({
required this.enabled,
required this.lastShotPath,
required this.flashSupported,
required this.flashOn,
required this.onShutter,
required this.onGallery,
required this.onFlip,
required this.onFlash,
});
final bool enabled;
final String? lastShotPath;
final bool flashSupported;
final bool flashOn;
final VoidCallback onShutter;
final VoidCallback onGallery;
final VoidCallback onFlip;
final VoidCallback onFlash;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return IgnorePointer(
// 相机未就绪(加载/权限/失败)时收起操作层——那里是错误态的重试区。
ignoring: !enabled,
child: SafeArea(
minimum: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Align(
alignment: AlignmentDirectional.bottomStart,
child: _LastShotButton(
lastShotPath: lastShotPath,
onPressed: onGallery,
),
),
const Spacer(),
Column(
mainAxisSize: MainAxisSize.min,
children: [
if (flashSupported)
IconButton(
tooltip: l10n.captureFlash,
color: Colors.white,
icon: Icon(flashOn ? Icons.flash_on : Icons.flash_off),
onPressed: onFlash,
),
_ShutterButton(onPressed: onShutter, enabled: enabled),
],
),
const Spacer(),
Align(
alignment: AlignmentDirectional.bottomStart,
child: IconButton(
tooltip: l10n.captureFlipCamera,
color: Colors.white,
icon: const Icon(Icons.flip_camera_ios_outlined),
onPressed: onFlip,
),
),
],
),
),
);
}
}
/// 最近一张缩略图:拍摄前为相册入口占位(photo_library 图标),拍摄后
/// 显示最近一张(缺失走占位);点击均打开相册多选(iOS 相机惯例)。
class _LastShotButton extends StatelessWidget {
const _LastShotButton({required this.lastShotPath, required this.onPressed});
final String? lastShotPath;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
Widget content;
if (lastShotPath == null) {
content = const Icon(
Icons.photo_library_outlined,
size: 24,
color: Colors.white,
);
} else {
content = Image.file(
File(lastShotPath!),
fit: BoxFit.cover,
errorBuilder: (_, _, _) =>
const Icon(Icons.image_outlined, size: 24, color: Colors.white),
);
}
return IconButton(
tooltip: l10n.capturePickFromGallery,
onPressed: onPressed,
style: IconButton.styleFrom(
padding: EdgeInsets.zero,
fixedSize: const Size(56, 56),
),
icon: ClipRRect(
borderRadius: BorderRadius.circular(kRadiusBadge),
child: SizedBox(width: 48, height: 48, child: content),
),
);
}
}
/// 快门按钮(§4 页面 13:≥72dp 白环;连拍不进入模态确认,逐张自动入队)。
class _ShutterButton extends StatelessWidget {
const _ShutterButton({required this.onPressed, required this.enabled});
final VoidCallback onPressed;
final bool enabled;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: AppLocalizations.of(context).captureShutter,
child: IconButton(
key: const ValueKey('capture-shutter'),
onPressed: enabled ? onPressed : null,
style: IconButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size(76, 76),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
icon: Container(
width: 76,
height: 76,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 5),
),
alignment: Alignment.center,
child: Container(
width: 58,
height: 58,
decoration: BoxDecoration(
shape: BoxShape.circle,
// 就绪=白心(图标反色);未就绪=仅白环。
color: enabled ? Colors.white : Colors.transparent,
),
alignment: Alignment.center,
child: Icon(
Icons.camera,
size: 28,
color: enabled ? kColorPhotoBackdrop : Colors.white70,
),
),
),
),
);
}
}
/// 相机不可用视图(权限拒绝/初始化失败):深色说明 + 重试(§4 页面 13)。
class _CameraMessageView extends StatelessWidget {
const _CameraMessageView({
required this.icon,
required this.message,
required this.actionLabel,
required this.onAction,
});
final IconData icon;
final String message;
final String actionLabel;
final VoidCallback onAction;
@override
Widget build(BuildContext context) {
return Container(
color: kColorPhotoBackdrop,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 48, color: Colors.white70),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white),
),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: onAction,
icon: const Icon(Icons.refresh),
label: Text(actionLabel),
),
],
),
),
);
}
}