/// 连拍页(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_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 initialize(); /// 拍摄一张(返回源文件)。 Future capture(); /// 取景预览。 Widget buildPreview(); /// 释放底层资源。 Future dispose(); } /// 生产拍摄源:camera 插件(后摄优先,最高分辨率,静音连拍)。 class CameraPluginSource implements CaptureSource { CameraPluginSource._(this._controller); /// 创建并初始化(后摄优先)。 static Future 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 initialize() async {} @override Future capture() => _controller.takePicture(); @override Widget buildPreview() => CameraPreview(_controller); @override Future dispose() => _controller.dispose(); } /// 拍摄源工厂 Provider(测试 override 为替身)。 final captureSourceFactoryProvider = Provider Function()>((ref) => CameraPluginSource.create); // ── 连拍页 ──────────────────────────────────────────────────────────────── /// 连拍页参数:目标相册(相册详情 FAB 进入时固定)与分类候选。 class CaptureTarget { const CaptureTarget({ required this.accountId, required this.albumId, required this.albumTitle, this.categories = const [], }); /// 入队账号(TaskStore 隔离键)。 final String accountId; /// 目标相册 ID。 final String albumId; /// 目标相册展示名(信息层常驻显示)。 final String albumTitle; /// 相册分类(空 = 无可切换项,仅默认分类)。 final List categories; } /// 连拍页。 class CapturePage extends ConsumerStatefulWidget { const CapturePage({required this.target, super.key}); final CaptureTarget target; @override ConsumerState createState() => _CapturePageState(); } enum _CameraPhase { loading, ready, denied, failed } class _CapturePageState extends ConsumerState { CaptureSource? _source; _CameraPhase _phase = _CameraPhase.loading; /// 当前分类(null = 相册默认分类;切换仅影响之后拍摄)。 String? _categoryId; /// 连拍串行链:快门连点逐张落地(相机硬件本就串行)。 Future _shotTail = Future.value(); @override void initState() { super.initState(); _initCamera(); } @override void dispose() { _source?.dispose().ignore(); super.dispose(); } Future _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; await enqueueForUpload( ref, accountId: widget.target.accountId, albumId: widget.target.albumId, categoryId: categoryId, sourcePaths: [photo.path], ); }).catchError((Object _) { // 单张失败吞掉(连拍不中断;失败任务/错误在徽章与队列页可见)。 }); } /// 相册多选入队(目标 = 当前相册与分类)。 Future _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 _confirmExit() async { final l10n = AppLocalizations.of(context); final leave = await showDialog( 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, pending: pending, failed: failed, ), ], ), ), ], ), bottomNavigationBar: _buildBottomBar(l10n), ), ); } 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) { return BottomAppBar( color: Colors.black87, child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ IconButton( tooltip: l10n.capturePickFromGallery, icon: const Icon(Icons.photo_library_outlined, color: Colors.white), onPressed: _pickFromGallery, ), _ShutterButton(onPressed: _shoot, enabled: _phase == _CameraPhase.ready), // 占位对齐(快门居中)。 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.pending, required this.failed, }); final String albumTitle; final List categories; final String? categoryId; final void Function(String?) onCategorySelected; final int pending; 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), _Badge( label: l10n.capturePendingBadge(pending), color: Colors.white24, foreground: Colors.white, ), if (failed > 0) ...[ const SizedBox(width: 6), _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), ), ], ), ), ); } }