feat(mobile): UI-03 capture and upload pages (13-16)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1,17 +1,20 @@
|
||||
/// 连拍页(P3 计划 Task 9,R17 连拍入队 / O01 现场反馈)。
|
||||
/// 连拍页(UI-03 页面 13 深色摄影界面;P3 计划 Task 9 / R17 连拍入队)。
|
||||
///
|
||||
/// 进入时选定目标相册与分类(相册详情 FAB 传入);快门每张立即经准备器
|
||||
/// 入队(preparing→queued,不逐张确认);常驻信息层:相册名 + 分类(可
|
||||
/// 切换,**仅影响之后拍摄**——引擎只读任务行归属,切换不改既有任务)+
|
||||
/// 待传/失败徽章(TaskStore 写通知实时刷新)。相册多选按钮补充入队;
|
||||
/// 退出时若有待传任务弹确认(任务在后台继续——Android 原生 WorkManager
|
||||
/// 承担,见 native_transport.dart)。
|
||||
/// 入队(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';
|
||||
@@ -22,6 +25,7 @@ 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 插件;测试替身) ─────────────────────────────
|
||||
|
||||
@@ -33,28 +37,38 @@ abstract class CaptureSource {
|
||||
/// 拍摄一张(返回源文件)。
|
||||
Future<XFile> capture();
|
||||
|
||||
/// 取景预览。
|
||||
/// 取景预览(含纵横比校正——全屏 cover 铺满,不拉伸)。
|
||||
Widget buildPreview();
|
||||
|
||||
/// 释放底层资源。
|
||||
Future<void> dispose();
|
||||
|
||||
/// 闪光灯能力(默认不支持——子类按硬件能力覆写;不支持则 UI 不显示
|
||||
/// 闪光开关,不提供假开关)。
|
||||
bool get supportsFlash => false;
|
||||
|
||||
/// 设置闪光灯(仅 [supportsFlash] 为真时有意义;失败抛异常由调用方回滚)。
|
||||
Future<void> setFlashEnabled(bool enabled) async {}
|
||||
}
|
||||
|
||||
/// 生产拍摄源:camera 插件(后摄优先,最高分辨率,静音连拍)。
|
||||
class CameraPluginSource implements CaptureSource {
|
||||
class CameraPluginSource extends CaptureSource {
|
||||
CameraPluginSource._(this._controller);
|
||||
|
||||
/// 创建并初始化(后摄优先)。
|
||||
static Future<CameraPluginSource> create() async {
|
||||
/// 创建并初始化([preferFront] 为真选前摄,否则后摄优先)。
|
||||
static Future<CameraPluginSource> create({bool preferFront = false}) 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,
|
||||
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);
|
||||
@@ -69,7 +83,28 @@ class CameraPluginSource implements CaptureSource {
|
||||
Future<XFile> capture() => _controller.takePicture();
|
||||
|
||||
@override
|
||||
Widget buildPreview() => CameraPreview(_controller);
|
||||
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();
|
||||
@@ -77,7 +112,8 @@ class CameraPluginSource implements CaptureSource {
|
||||
|
||||
/// 拍摄源工厂 Provider(测试 override 为替身)。
|
||||
final captureSourceFactoryProvider =
|
||||
Provider<Future<CaptureSource> Function()>((ref) => CameraPluginSource.create);
|
||||
Provider<Future<CaptureSource> Function({bool preferFront})>(
|
||||
(ref) => CameraPluginSource.create);
|
||||
|
||||
// ── 连拍页 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -119,6 +155,15 @@ class _CapturePageState extends ConsumerState<CapturePage> {
|
||||
CaptureSource? _source;
|
||||
_CameraPhase _phase = _CameraPhase.loading;
|
||||
|
||||
/// 当前镜头方向(快门旁切换按钮翻转;重试沿用)。
|
||||
bool _preferFront = false;
|
||||
|
||||
/// 闪光灯开关(源不支持则无 UI;重开相机后复位)。
|
||||
bool _flashOn = false;
|
||||
|
||||
/// 最近一张拍摄文件(底部缩略图;拍摄前显示相册入口占位)。
|
||||
String? _lastShotPath;
|
||||
|
||||
/// 当前分类(null = 相册默认分类;切换仅影响之后拍摄)。
|
||||
String? _categoryId;
|
||||
|
||||
@@ -142,17 +187,24 @@ class _CapturePageState extends ConsumerState<CapturePage> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _initCamera() async {
|
||||
Future<void> _initCamera({bool? preferFront}) async {
|
||||
final front = preferFront ?? _preferFront;
|
||||
setState(() => _phase = _CameraPhase.loading);
|
||||
try {
|
||||
final source = await ref.read(captureSourceFactoryProvider)();
|
||||
final source = await ref.read(captureSourceFactoryProvider)(
|
||||
preferFront: front,
|
||||
);
|
||||
await source.initialize();
|
||||
if (!mounted) {
|
||||
await source.dispose();
|
||||
return;
|
||||
}
|
||||
_source = source;
|
||||
setState(() => _phase = _CameraPhase.ready);
|
||||
setState(() {
|
||||
_preferFront = front;
|
||||
_flashOn = false; // 新相机实例回默认关。
|
||||
_phase = _CameraPhase.ready;
|
||||
});
|
||||
} on Object catch (error) {
|
||||
if (!mounted) return;
|
||||
setState(() => _phase = _isPermissionDenied(error)
|
||||
@@ -162,12 +214,34 @@ class _CapturePageState extends ConsumerState<CapturePage> {
|
||||
}
|
||||
|
||||
/// camera 插件的权限拒绝错误码族(CameraAccessDenied /
|
||||
/// CameraAccessDeniedWithoutD restriction 等)。
|
||||
/// 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;
|
||||
@@ -176,6 +250,8 @@ class _CapturePageState extends ConsumerState<CapturePage> {
|
||||
_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,
|
||||
@@ -200,7 +276,7 @@ class _CapturePageState extends ConsumerState<CapturePage> {
|
||||
});
|
||||
}
|
||||
|
||||
/// 相册多选入队(目标 = 当前相册与分类)。
|
||||
/// 相册多选入队(目标 = 当前相册与分类;入口=底部缩略图位)。
|
||||
Future<void> _pickFromGallery() async {
|
||||
final paths = await ref.read(multiImagePickerProvider)();
|
||||
if (paths.isEmpty || !mounted) return;
|
||||
@@ -272,33 +348,34 @@ class _CapturePageState extends ConsumerState<CapturePage> {
|
||||
_confirmExit();
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black87,
|
||||
foregroundColor: Colors.white,
|
||||
title: Text(l10n.captureTitle),
|
||||
),
|
||||
// 深色摄影界面恒深色(§3):不读主题,固定 #10161E。
|
||||
backgroundColor: kColorPhotoBackdrop,
|
||||
body: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
_buildCameraArea(l10n),
|
||||
// 常驻信息层:相册 + 分类 + 失败徽章(R17/O01)。
|
||||
// 顶部常驻层:返回 + 相册 + 待传/失败徽章 + 分类 chips。
|
||||
SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_TargetOverlay(
|
||||
_TopBar(
|
||||
albumTitle: widget.target.albumTitle,
|
||||
categories: categories,
|
||||
categoryId: _categoryId,
|
||||
onCategorySelected: _switchCategory,
|
||||
queued: pending,
|
||||
failed: failed,
|
||||
onBack: () => Navigator.of(context).maybePop(),
|
||||
),
|
||||
if (categories.isNotEmpty)
|
||||
_CategoryChips(
|
||||
categories: categories,
|
||||
categoryId: _categoryId,
|
||||
onCategorySelected: _switchCategory,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 入队成功短暂反馈浮层(200ms 自动消退,不逐张弹确认)。
|
||||
Positioned(
|
||||
top: 16,
|
||||
top: 132,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: IgnorePointer(
|
||||
@@ -338,9 +415,24 @@ class _CapturePageState extends ConsumerState<CapturePage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// 底部操作层:缩略图(=相册入口)+ 快门(+闪光灯)+ 前后切换。
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: _buildBottomBar(l10n, pending),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -370,126 +462,113 @@ class _CapturePageState extends ConsumerState<CapturePage> {
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildBottomBar(AppLocalizations l10n, int pending) {
|
||||
return BottomAppBar(
|
||||
color: Colors.black87,
|
||||
// 默认高 56 放不下「快门 + 待传徽章」竖排(内容 ≈105,BottomAppBar
|
||||
// 内部再占 24),显式加高避免溢出。
|
||||
height: 132,
|
||||
// ── 信息层组件 ────────────────────────────────────────────────────────────
|
||||
|
||||
/// 顶部常驻层:返回 + 相册名 + 待传/失败徽章(§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(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: l10n.capturePickFromGallery,
|
||||
icon: const Icon(Icons.photo_library_outlined,
|
||||
color: Colors.white),
|
||||
onPressed: _pickFromGallery,
|
||||
tooltip: MaterialLocalizations.of(context)
|
||||
.backButtonTooltip, // 语义标签;深色界面固定白色前景。
|
||||
color: Colors.white,
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: onBack,
|
||||
),
|
||||
// 快门 + 入队计数徽章(§4.5:底部清晰快门与入队计数)。
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_ShutterButton(
|
||||
onPressed: _shoot,
|
||||
enabled: _phase == _CameraPhase.ready,
|
||||
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,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_Badge(
|
||||
label: l10n.captureQueuedBadge(pending),
|
||||
color: Colors.white24,
|
||||
foreground: Colors.white,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
// 占位对齐(快门居中)。
|
||||
const SizedBox(width: 48),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 信息层组件 ────────────────────────────────────────────────────────────
|
||||
|
||||
/// 顶部常驻信息层:相册名 + 分类 chips(切换仅影响之后拍摄)+ 失败徽章
|
||||
/// (待传计数在快门旁常驻,见底部栏)。
|
||||
class _TargetOverlay extends StatelessWidget {
|
||||
const _TargetOverlay({
|
||||
required this.albumTitle,
|
||||
/// 分类 chips(切换仅影响之后拍摄;A15 归属不变)。
|
||||
class _CategoryChips extends StatelessWidget {
|
||||
const _CategoryChips({
|
||||
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,
|
||||
),
|
||||
],
|
||||
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,
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
],
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -538,7 +617,123 @@ class _Badge extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 快门按钮(连拍:不进入模态确认,逐张自动入队)。
|
||||
/// 底部操作层:最近一张缩略图(兼相册多选入口)+ 快门(≥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});
|
||||
|
||||
@@ -551,23 +746,43 @@ class _ShutterButton extends StatelessWidget {
|
||||
button: true,
|
||||
label: AppLocalizations.of(context).captureShutter,
|
||||
child: IconButton(
|
||||
key: const ValueKey('capture-shutter'),
|
||||
onPressed: enabled ? onPressed : null,
|
||||
iconSize: 64,
|
||||
style: IconButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: const Size(76, 76),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
icon: Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
width: 76,
|
||||
height: 76,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 4),
|
||||
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,
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.camera, color: Colors.white),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 相机不可用视图(权限拒绝/初始化失败):说明 + 重试。
|
||||
/// 相机不可用视图(权限拒绝/初始化失败):深色说明 + 重试(§4 页面 13)。
|
||||
class _CameraMessageView extends StatelessWidget {
|
||||
const _CameraMessageView({
|
||||
required this.icon,
|
||||
@@ -584,7 +799,7 @@ class _CameraMessageView extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: Colors.black87,
|
||||
color: kColorPhotoBackdrop,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
||||
Reference in New Issue
Block a user