/// 连拍页(UI-03 页面 13 深色摄影界面;P3 计划 Task 9 / R17 连拍入队)。 /// /// 进入时选定目标相册与分类(相册详情 FAB 传入);快门每张立即经准备器 /// 入队(preparing→queued,不逐张确认)。全屏取景(恒深色 #10161E,不随 /// 系统主题):顶部常驻层 = 返回 + 相册名 + 待传/失败徽章 + 分类 chips /// (可切换,**仅影响之后拍摄**——引擎只读任务行归属);底部 = 最近一张 /// 缩略图(兼相册多选入口,iOS 相机惯例)+ 快门(≥72dp 白环)+ 前后摄 /// 切换 + 闪光灯(源支持才出现)。预览按传感器比例居中(所见即所得, /// 不裁切不变形);源支持变焦(maxZoom > 1)时捏合缩放 + 双击复位 1×。 /// 入队成功以触觉 + 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(); /// 取景预览(按传感器比例居中——所见即所得,不裁切不变形)。 Widget buildPreview(); /// 释放底层资源。 Future dispose(); /// 闪光灯能力(默认不支持——子类按硬件能力覆写;不支持则 UI 不显示 /// 闪光开关,不提供假开关)。 bool get supportsFlash => false; /// 设置闪光灯(仅 [supportsFlash] 为真时有意义;失败抛异常由调用方回滚)。 Future setFlashEnabled(bool enabled) async {} /// 变焦能力下限(默认 1=不支持变焦;子类按硬件覆写)。 double get minZoom => 1; /// 变焦能力上限(默认 1=不支持变焦;不支持则 UI 不启用手势)。 double get maxZoom => 1; /// 设置变焦倍数(默认 no-op——能力为 1 时 UI 不启用手势)。 Future setZoom(double level) 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(); final source = CameraPluginSource._(controller); try { source._minZoom = await controller.getMinZoomLevel(); source._maxZoom = await controller.getMaxZoomLevel(); } on Object { // 能力探测失败(部分设备/模拟器):保持 1/1,捏合不启用。 } return source; } final CameraController _controller; double _zoomLevel = 1.0; double _minZoom = 1; double _maxZoom = 1; @override Future initialize() async {} @override Future capture() => _controller.takePicture(); @override Widget buildPreview() { // 所见即所得(§4 页面 13 修订,决策 ④):按传感器纵向比例居中显示, // 上下露出深色背景——不再 FittedBox cover 全屏(那会横向裁 ~38% 画面, // 且预览所见 ≠ takePicture 全幅照片)。 final size = _controller.value.previewSize; Widget preview = CameraPreview(_controller); if (size != null && size.width > 0 && size.height > 0) { preview = Center( child: AspectRatio( // 交换:竖屏用 height 作宽、width 作高(传感器横向 → 纵向)。 aspectRatio: size.height / size.width, child: preview, ), ); } return preview; } @override double get minZoom => _minZoom; @override double get maxZoom => _maxZoom; @override Future setZoom(double level) async { final clamped = level.clamp(minZoom, maxZoom).toDouble(); if (clamped == _zoomLevel) return; await _controller.setZoomLevel(clamped); _zoomLevel = clamped; } @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 with WidgetsBindingObserver { 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; // ── 捏合变焦(Task 7:源 maxZoom > 1 时启用手势) ────────────────────── /// 捏合起始倍数基线(手势中目标 = 基线 × scale;手势结束置 null)。 double? _pinchBaseZoom; /// 最近一次已下发到源的变焦值(节流阈值基准;源重开时复位 1)。 double _lastSentZoom = 1; /// setZoom 在途标记(在途时缓存最新目标,完成后补发——防平台通道乱序 /// 导致徽标与硬件漂移)。 bool _zoomInFlight = false; double? _zoomQueued; /// 倍数徽标:显示当前目标倍数(1.0× 格式),停止更新 1s 后隐藏。 double _zoomDisplay = 1; bool _zoomBadgeVisible = false; Timer? _zoomBadgeTimer; @override void initState() { super.initState(); // P0-3:监听 App 生命周期——后台释放相机(省电 + 隐私),回前台重开。 WidgetsBinding.instance.addObserver(this); _initCamera(); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _feedbackTimer?.cancel(); _zoomBadgeTimer?.cancel(); _source?.dispose().ignore(); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { super.didChangeAppLifecycleState(state); // P0-3:后台 → 释放相机硬件(不释放则摄像头指示灯常亮 + 耗电); // 回前台 → 重新初始化(沿用当前前后摄/闪光灯偏好)。 if (state == AppLifecycleState.paused && _source != null) { _source?.dispose().ignore(); _source = null; setState(() => _phase = _CameraPhase.loading); } else if (state == AppLifecycleState.resumed && _source == null) { _initCamera(); } } 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; _zoomBadgeTimer?.cancel(); setState(() { _preferFront = front; _flashOn = false; // 新相机实例回默认关。 // 新源变焦天然回 1(新控制器默认倍数);徽标状态同步清理。 _zoomDisplay = 1; _zoomBadgeVisible = false; _phase = _CameraPhase.ready; }); _pinchBaseZoom = null; _lastSentZoom = 1; _zoomQueued = null; } 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.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); }); } // ── 捏合变焦手势 ───────────────────────────────────────────────────────── void _onPinchStart(ScaleStartDetails details) { final source = _source; if (source == null || source.maxZoom <= 1) return; // 基线取当前已下发倍数(受源能力夹取)——连续捏合从当前倍数续接。 _pinchBaseZoom = _lastSentZoom .clamp(source.minZoom, source.maxZoom) .toDouble(); // 单指轻点同样触发 onScaleStart——倍数徽标仅在真捏合(≥2 指)时显示, // 避免轻点预览也弹 1.0×;基线重定保持无条件(单指转捏合、抬起再捏合 // 均从当前倍数续接)。 if (details.pointerCount >= 2) _showZoomBadge(_pinchBaseZoom!); } void _onPinchUpdate(ScaleUpdateDetails details) { final source = _source; final base = _pinchBaseZoom; if (source == null || base == null) return; final target = (base * details.scale).clamp(source.minZoom, source.maxZoom).toDouble(); _showZoomBadge(target); _dispatchZoom(target); } void _onPinchEnd(ScaleEndDetails details) { // 手势结束(含单指抬起触发的重定基)——置空基线,下次手势从当前 // 倍数重新起步(onScaleStart 重新取 _lastSentZoom)。 _pinchBaseZoom = null; } /// 双击复位 1.0×。 void _resetZoom() { final source = _source; if (source == null || source.maxZoom <= 1) return; _showZoomBadge(1); _dispatchZoom(1); } /// 变焦下发:节流 + 串行化。onScaleUpdate 每帧触发(60-120/s),不能 /// 每次都打平台通道——与最近已发值差 ≥0.05 才发;在途时缓存最新目标, /// 完成后补发(防乱序导致徽标与硬件漂移)。 void _dispatchZoom(double target) { if (_zoomInFlight) { _zoomQueued = target; return; } if ((target - _lastSentZoom).abs() < 0.05) return; _sendZoom(target); } void _sendZoom(double target) { final source = _source; if (source == null) return; _zoomInFlight = true; _lastSentZoom = target; source .setZoom(target) .catchError((Object _) {}) // 硬件设置失败:保持当前值(下次手势重试)。 .whenComplete(() { _zoomInFlight = false; final queued = _zoomQueued; _zoomQueued = null; // 源已更换(翻转/重开相机):丢弃补发。 if (_source != source || queued == null) return; if ((queued - _lastSentZoom).abs() >= 0.05) _sendZoom(queued); }); } /// 倍数徽标:显示当前倍数(四舍五入 1 位小数),停止更新 1s 后隐藏。 void _showZoomBadge([double? level]) { _zoomBadgeTimer?.cancel(); setState(() { if (level != null) _zoomDisplay = level; _zoomBadgeVisible = true; }); _zoomBadgeTimer = Timer(const Duration(milliseconds: 1000), () { if (mounted) setState(() => _zoomBadgeVisible = false); }); } /// 相册多选入队(目标 = 当前相册与分类;入口=底部缩略图位)。 Future _pickFromGallery() async { final paths = await ref.read(multiImagePickerProvider)(context); if (paths.isEmpty || !mounted) return; final l10n = AppLocalizations.of(context); final messenger = ScaffoldMessenger.of(context); // P0-1 层 1:入队前查重(同相册已完成的 filename+bytes 匹配)。 final duplicates = await checkDuplicates( ref, accountId: widget.target.accountId, albumId: widget.target.albumId, sourcePaths: paths, ); Set skipPaths; Set forcePaths; if (duplicates != null && duplicates.isNotEmpty && mounted) { // 批量确认弹窗(单次,非逐张)。 final choice = await _showDuplicateDialog(l10n, duplicates, paths); if (choice == null) return; // 取消——不上传。 skipPaths = choice.skipPaths; forcePaths = choice.forcePaths; } else { skipPaths = {}; forcePaths = {}; } final result = await enqueueForUpload( ref, accountId: widget.target.accountId, albumId: widget.target.albumId, categoryId: _categoryId, sourcePaths: paths, skipPaths: skipPaths.toList(), forcePaths: forcePaths.toList(), ); if (!mounted) return; final uploaded = result.enqueued; final skipped = result.skipped; messenger.showSnackBar( SnackBar( content: Text( skipped > 0 ? '$uploaded 张已入队,$skipped 张重复已跳过' : l10n.uploadEnqueuedCount(uploaded), ), ), ); } /// 批量重复确认弹窗(Task 8):null=取消;「跳过重复」→ skipPaths= /// 重复项;「全部上传」→ forcePaths=重复项(用户确认强传,服务端 /// force 查重跳过仅 owner/admin 生效——协作者仍会被 409 拒绝)。 Future _showDuplicateDialog( AppLocalizations l10n, List duplicates, List allPaths, ) async { // 找出与 duplicates 匹配的路径(filename 一致即视为重复项)。 final dupFilenames = duplicates.map((d) => d.filename).toSet(); final dupPaths = allPaths .where((p) => dupFilenames.contains(p.split('/').last)) .toSet(); final nonDupCount = allPaths.length - dupPaths.length; final choice = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('可能已上传过'), content: Text( '${duplicates.length} 张照片可能已上传到该相册' '${nonDupCount > 0 ? '(另 $nonDupCount 张不重复)' : ''}\n\n' '${duplicates.take(3).map((d) => d.filename).join('、')}' '${duplicates.length > 3 ? ' …' : ''}', ), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop('cancel'), child: const Text('取消'), ), if (nonDupCount > 0) TextButton( onPressed: () => Navigator.of(ctx).pop('skip'), child: Text('跳过重复,上传 $nonDupCount 张'), ), FilledButton( onPressed: () => Navigator.of(ctx).pop('all'), child: Text('全部上传 ${allPaths.length} 张'), ), ], ), ); if (choice == 'cancel' || choice == null) return null; if (choice == 'skip') { return (skipPaths: dupPaths, forcePaths: {}); } // 'all' = 用户确认强传:重复项 force 入队(不跳过任何)。 return (skipPaths: {}, forcePaths: dupPaths); } 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 _buildReadyArea(source!); 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, ); } } /// 就绪态:预览 + 捏合变焦手势(源 maxZoom > 1 时挂)+ 倍数徽标。 Widget _buildReadyArea(CaptureSource source) { Widget area = source.buildPreview(); if (source.maxZoom > 1) { area = GestureDetector( // opaque:预览任意点均可起手势(含按比例居中后的上下留黑区)。 behavior: HitTestBehavior.opaque, onScaleStart: _onPinchStart, onScaleUpdate: _onPinchUpdate, onScaleEnd: _onPinchEnd, onDoubleTap: _resetZoom, child: area, ); } if (!_zoomBadgeVisible) return area; return Stack( fit: StackFit.expand, children: [area, _buildZoomBadge()], ); } /// 变焦倍数徽标(捏合/双击期间显示,不拦截手势)。 Widget _buildZoomBadge() { return IgnorePointer( child: Center( child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: Colors.black54, borderRadius: BorderRadius.circular(999), ), child: Text( '${_zoomDisplay.toStringAsFixed(1)}×', style: const TextStyle(color: Colors.white, fontSize: 13), ), ), ), ); } } // ── 信息层组件 ──────────────────────────────────────────────────────────── /// 顶部常驻层:返回 + 相册名 + 待传/失败徽章(§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), ), ], ), ), ); } }