Files
photo-live/apps/mobile/lib/pages/capture_page.dart
T
2026-09-21 17:20:43 +08:00

614 lines
20 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.
/// 连拍页(P3 计划 Task 9,R17 连拍入队 / O01 现场反馈)。
///
/// 进入时选定目标相册与分类(相册详情 FAB 传入);快门每张立即经准备器
/// 入队(preparing→queued,不逐张确认);常驻信息层:相册名 + 分类(可
/// 切换,**仅影响之后拍摄**——引擎只读任务行归属,切换不改既有任务)+
/// 待传/失败徽章(TaskStore 写通知实时刷新)。相册多选按钮补充入队;
/// 退出时若有待传任务弹确认(任务在后台继续——Android 原生 WorkManager
/// 承担,见 native_transport.dart)。
///
/// 相机/相册经注入点([captureSourceFactoryProvider]/[multiImagePickerProvider])
/// 隔离平台通道,widget 测试注入替身驱动。
library;
import 'dart:async';
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';
// ── 相机源抽象(生产=camera 插件;测试替身) ─────────────────────────────
/// 拍摄源:初始化/拍摄/预览/释放。
abstract class CaptureSource {
/// 初始化(权限缺失/失败抛异常;权限拒绝抛 [CameraException])。
Future<void> initialize();
/// 拍摄一张(返回源文件)。
Future<XFile> capture();
/// 取景预览。
Widget buildPreview();
/// 释放底层资源。
Future<void> dispose();
}
/// 生产拍摄源:camera 插件(后摄优先,最高分辨率,静音连拍)。
class CameraPluginSource implements CaptureSource {
CameraPluginSource._(this._controller);
/// 创建并初始化(后摄优先)。
static Future<CameraPluginSource> create() async {
final cameras = await availableCameras();
if (cameras.isEmpty) {
throw CameraException('noCamera', 'no camera available');
}
final back = cameras.firstWhere(
(c) => c.lensDirection == CameraLensDirection.back,
orElse: () => cameras.first,
);
final controller = CameraController(back, 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() => CameraPreview(_controller);
@override
Future<void> dispose() => _controller.dispose();
}
/// 拍摄源工厂 Provider(测试 override 为替身)。
final captureSourceFactoryProvider =
Provider<Future<CaptureSource> Function()>((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;
/// 当前分类(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() async {
setState(() => _phase = _CameraPhase.loading);
try {
final source = await ref.read(captureSourceFactoryProvider)();
await source.initialize();
if (!mounted) {
await source.dispose();
return;
}
_source = source;
setState(() => _phase = _CameraPhase.ready);
} on Object catch (error) {
if (!mounted) return;
setState(() => _phase = _isPermissionDenied(error)
? _CameraPhase.denied
: _CameraPhase.failed);
}
}
/// camera 插件的权限拒绝错误码族(CameraAccessDenied /
/// CameraAccessDeniedWithoutD restriction 等)。
static bool _isPermissionDenied(Object error) =>
error is CameraException &&
(error.code.toLowerCase().contains('denied') ||
error.code.toLowerCase().contains('permission'));
/// 快门:立即入队(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;
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(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black87,
foregroundColor: Colors.white,
title: Text(l10n.captureTitle),
),
body: Stack(
fit: StackFit.expand,
children: [
_buildCameraArea(l10n),
// 常驻信息层:相册 + 分类 + 失败徽章(R17/O01)。
SafeArea(
child: Column(
children: [
_TargetOverlay(
albumTitle: widget.target.albumTitle,
categories: categories,
categoryId: _categoryId,
onCategorySelected: _switchCategory,
failed: failed,
),
],
),
),
// 入队成功短暂反馈浮层(200ms 自动消退,不逐张弹确认)。
Positioned(
top: 16,
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,
),
),
],
),
),
),
),
),
),
],
),
bottomNavigationBar: _buildBottomBar(l10n, pending),
),
);
}
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,
);
}
}
Widget _buildBottomBar(AppLocalizations l10n, int pending) {
return BottomAppBar(
color: Colors.black87,
// 默认高 56 放不下「快门 + 待传徽章」竖排(内容 ≈105,BottomAppBar
// 内部再占 24),显式加高避免溢出。
height: 132,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
IconButton(
tooltip: l10n.capturePickFromGallery,
icon: const Icon(Icons.photo_library_outlined,
color: Colors.white),
onPressed: _pickFromGallery,
),
// 快门 + 入队计数徽章(§4.5:底部清晰快门与入队计数)。
Column(
mainAxisSize: MainAxisSize.min,
children: [
_ShutterButton(
onPressed: _shoot,
enabled: _phase == _CameraPhase.ready,
),
const SizedBox(height: 4),
_Badge(
label: l10n.captureQueuedBadge(pending),
color: Colors.white24,
foreground: Colors.white,
),
],
),
// 占位对齐(快门居中)。
const SizedBox(width: 48),
],
),
);
}
}
// ── 信息层组件 ────────────────────────────────────────────────────────────
/// 顶部常驻信息层:相册名 + 分类 chips(切换仅影响之后拍摄)+ 失败徽章
/// (待传计数在快门旁常驻,见底部栏)。
class _TargetOverlay extends StatelessWidget {
const _TargetOverlay({
required this.albumTitle,
required this.categories,
required this.categoryId,
required this.onCategorySelected,
required this.failed,
});
final String albumTitle;
final List<Category> categories;
final String? categoryId;
final void Function(String?) onCategorySelected;
final int failed;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Row(
children: [
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),
),
),
const SizedBox(width: 8),
if (failed > 0)
_Badge(
label: l10n.captureFailedBadge(failed),
color: Colors.red,
foreground: Colors.white,
),
],
),
),
if (categories.isNotEmpty)
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),
),
);
}
}
/// 快门按钮(连拍:不进入模态确认,逐张自动入队)。
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(
onPressed: enabled ? onPressed : null,
iconSize: 64,
icon: Container(
width: 64,
height: 64,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 4),
),
child: const Icon(Icons.camera, color: Colors.white),
),
),
);
}
}
/// 相机不可用视图(权限拒绝/初始化失败):说明 + 重试。
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: Colors.black87,
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),
),
],
),
),
);
}
}