Files
photo-live/apps/mobile/lib/pages/capture_page.dart
T

829 lines
27 KiB
Dart
Raw Normal View History

/// 连拍页(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),
),
],
),
),
);
}
}