/// 连拍页(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 initialize(); /// 拍摄一张(返回源文件)。 Future capture(); /// 取景预览(含纵横比校正——全屏 cover 铺满,不拉伸)。 Widget buildPreview(); /// 释放底层资源。 Future dispose(); /// 闪光灯能力(默认不支持——子类按硬件能力覆写;不支持则 UI 不显示 /// 闪光开关,不提供假开关)。 bool get supportsFlash => false; /// 设置闪光灯(仅 [supportsFlash] 为真时有意义;失败抛异常由调用方回滚)。 Future setFlashEnabled(bool enabled) async {} } /// 生产拍摄源:camera 插件(后摄优先,最高分辨率,静音连拍)。 class CameraPluginSource extends CaptureSource { CameraPluginSource._(this._controller); /// 创建并初始化([preferFront] 为真选前摄,否则后摄优先)。 static Future 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 initialize() async {} @override Future 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 setFlashEnabled(bool enabled) => _controller.setFlashMode( enabled ? FlashMode.always : FlashMode.off, ); @override Future dispose() => _controller.dispose(); } /// 拍摄源工厂 Provider(测试 override 为替身)。 final captureSourceFactoryProvider = Provider Function({bool preferFront})>( (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; /// 当前镜头方向(快门旁切换按钮翻转;重试沿用)。 bool _preferFront = false; /// 闪光灯开关(源不支持则无 UI;重开相机后复位)。 bool _flashOn = false; /// 最近一张拍摄文件(底部缩略图;拍摄前显示相册入口占位)。 String? _lastShotPath; /// 当前分类(null = 相册默认分类;切换仅影响之后拍摄)。 String? _categoryId; /// 连拍串行链:快门连点逐张落地(相机硬件本就串行)。 Future _shotTail = Future.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 _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 _flipCamera() async { if (_phase != _CameraPhase.ready) return; final old = _source; _source = null; await old?.dispose(); await _initCamera(preferFront: !_preferFront); } /// 闪光灯开关:硬件设置失败则回滚(不留假状态)。 Future _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 _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( // 深色摄影界面恒深色(§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 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), ), ], ), ), ); } }