feat(mobile): UI-01 upload center, capture and auxiliary pages

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-21 17:20:43 +08:00
parent f6929ae18f
commit 1f67b7c818
26 changed files with 2144 additions and 771 deletions
+86 -18
View File
@@ -15,6 +15,7 @@ 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';
@@ -124,6 +125,10 @@ class _CapturePageState extends ConsumerState<CapturePage> {
/// 连拍串行链:快门连点逐张落地(相机硬件本就串行)。
Future<void> _shotTail = Future<void>.value();
/// 入队成功短暂反馈(150-220ms 轻量动效档,§3.1)。
bool _showEnqueuedFeedback = false;
Timer? _feedbackTimer;
@override
void initState() {
super.initState();
@@ -132,6 +137,7 @@ class _CapturePageState extends ConsumerState<CapturePage> {
@override
void dispose() {
_feedbackTimer?.cancel();
_source?.dispose().ignore();
super.dispose();
}
@@ -170,18 +176,30 @@ class _CapturePageState extends ConsumerState<CapturePage> {
_shotTail = _shotTail.then((_) async {
final photo = await source.capture();
if (!mounted) return;
await enqueueForUpload(
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)();
@@ -264,7 +282,7 @@ class _CapturePageState extends ConsumerState<CapturePage> {
fit: StackFit.expand,
children: [
_buildCameraArea(l10n),
// 常驻信息层:相册 + 分类 + 待传/失败徽章(R17/O01)。
// 常驻信息层:相册 + 分类 + 失败徽章(R17/O01)。
SafeArea(
child: Column(
children: [
@@ -273,15 +291,56 @@ class _CapturePageState extends ConsumerState<CapturePage> {
categories: categories,
categoryId: _categoryId,
onCategorySelected: _switchCategory,
pending: pending,
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),
bottomNavigationBar: _buildBottomBar(l10n, pending),
),
);
}
@@ -312,9 +371,12 @@ class _CapturePageState extends ConsumerState<CapturePage> {
}
}
Widget _buildBottomBar(AppLocalizations l10n) {
Widget _buildBottomBar(AppLocalizations l10n, int pending) {
return BottomAppBar(
color: Colors.black87,
// 默认高 56 放不下「快门 + 待传徽章」竖排(内容 ≈105,BottomAppBar
// 内部再占 24),显式加高避免溢出。
height: 132,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
@@ -324,7 +386,22 @@ class _CapturePageState extends ConsumerState<CapturePage> {
color: Colors.white),
onPressed: _pickFromGallery,
),
_ShutterButton(onPressed: _shoot, enabled: _phase == _CameraPhase.ready),
// 快门 + 入队计数徽章(§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),
],
@@ -335,15 +412,14 @@ class _CapturePageState extends ConsumerState<CapturePage> {
// ── 信息层组件 ────────────────────────────────────────────────────────────
/// 顶部常驻信息层:相册名 + 分类 chips(切换仅影响之后拍摄)+ 待传/失败
/// 徽章。
/// 顶部常驻信息层:相册名 + 分类 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,
});
@@ -351,7 +427,6 @@ class _TargetOverlay extends StatelessWidget {
final List<Category> categories;
final String? categoryId;
final void Function(String?) onCategorySelected;
final int pending;
final int failed;
@override
@@ -376,19 +451,12 @@ class _TargetOverlay extends StatelessWidget {
),
),
const SizedBox(width: 8),
_Badge(
label: l10n.capturePendingBadge(pending),
color: Colors.white24,
foreground: Colors.white,
),
if (failed > 0) ...[
const SizedBox(width: 6),
if (failed > 0)
_Badge(
label: l10n.captureFailedBadge(failed),
color: Colors.red,
foreground: Colors.white,
),
],
],
),
),