0fa23e015e
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>
1122 lines
37 KiB
Dart
1122 lines
37 KiB
Dart
/// 连拍页(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),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|