Files
photo-live/apps/mobile/lib/pages/capture_page.dart
T
2026-09-22 02:06:23 +08:00

829 lines
27 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 白环)+ 前后摄
/// 切换 + 闪光灯(源支持才出现)。入队成功以触觉 + 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();
/// 取景预览(含纵横比校正——全屏 cover 铺满,不拉伸)。
Widget buildPreview();
/// 释放底层资源。
Future<void> dispose();
/// 闪光灯能力(默认不支持——子类按硬件能力覆写;不支持则 UI 不显示
/// 闪光开关,不提供假开关)。
bool get supportsFlash => false;
/// 设置闪光灯(仅 [supportsFlash] 为真时有意义;失败抛异常由调用方回滚)。
Future<void> setFlashEnabled(bool enabled) 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();
return CameraPluginSource._(controller);
}
final CameraController _controller;
@override
Future<void> initialize() async {}
@override
Future<XFile> capture() => _controller.takePicture();
@override
Widget buildPreview() {
// 纵横比校正(§4 页面 13):预览按传感器尺寸布局,再 cover 铺满——
// CameraPreview 直填父约束会把画面拉扁/拉高。
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,
child: SizedBox(width: size.width, height: size.height, child: preview),
);
}
return preview;
}
@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> {
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;
@override
void initState() {
super.initState();
_initCamera();
}
@override
void dispose() {
_feedbackTimer?.cancel();
_source?.dispose().ignore();
super.dispose();
}
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;
setState(() {
_preferFront = front;
_flashOn = false; // 新相机实例回默认关。
_phase = _CameraPhase.ready;
});
} 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 > 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);
});
}
/// 相册多选入队(目标 = 当前相册与分类;入口=底部缩略图位)。
Future<void> _pickFromGallery() async {
final paths = await ref.read(multiImagePickerProvider)();
if (paths.isEmpty || !mounted) return;
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final count = await enqueueForUpload(
ref,
accountId: widget.target.accountId,
albumId: widget.target.albumId,
categoryId: _categoryId,
sourcePaths: paths,
);
if (!mounted) return;
messenger.showSnackBar(
SnackBar(content: Text(l10n.uploadEnqueuedCount(count))),
);
}
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 source!.buildPreview();
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,
);
}
}
}
// ── 信息层组件 ────────────────────────────────────────────────────────────
/// 顶部常驻层:返回 + 相册名 + 待传/失败徽章(§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),
),
],
),
),
);
}
}