feat(mobile): UI-01 upload center, capture and auxiliary pages
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user