feat(mobile): upload queue ui and continuous capture page

Task 9 (P3 plan): queue tab replaces the stub, continuous capture page
enqueues per shot, album detail gains an upload FAB, and the upload
engine lifecycle is now bound to the login session.

- Engine scope: session account drives UploadEngine start/stop; wired
  from app boot, login success, logout, and 401 eviction. Tasks stay in
  the store across logout (accountId hard isolation).
- Queue tab: album x state x time filter bar, task rows with thumbnail,
  state chip, localized error copy, attempt count, per-row progress via
  a broadcast progress hub, state-dependent actions (re-pick retry,
  retryNow, cancel, delete), batch retry bar, 200ms-debounced live
  refresh from drift table updates.
- Capture page: camera preview via injected CaptureSource, one enqueue
  per shutter press, persistent album/category/badges overlay with a
  switch-only-affects-new-shots hint, gallery multi-pick, exit confirm
  while uploads are pending, permission-denied and init-failure views.
- POST_NOTIFICATIONS runtime permission via a small MethodChannel
  (photolive/permissions) + PermissionPlugin.kt: single runtime
  permission need, avoids permission_handler dependency; requested once
  on first enqueue, silent degradation when denied or unavailable.
- Album detail FAB: capture-and-upload / pick-from-gallery sheet.
- l10n: ~50 new keys in zh/en arbs; tests: +45 (filter matrix, engine
  scope lifecycle, permission gate, queue tab, capture page, FAB entry);
  existing page tests updated to inject session/task-store fakes.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-19 04:47:31 +08:00
parent 42e1c2e34b
commit 955f9b13c1
30 changed files with 4206 additions and 51 deletions
@@ -14,10 +14,12 @@ import '../core/error_l10n.dart';
import '../features/albums/album_format.dart';
import '../features/albums/models.dart';
import '../features/albums/providers.dart';
import '../features/uploads/enqueue.dart';
import '../l10n/app_localizations.dart';
import 'album_form_page.dart';
import 'album_members_page.dart';
import 'album_photos_page.dart';
import 'capture_page.dart';
import 'share_qr_dialog.dart';
/// 相册详情([albumId] 路由参数;进入后 GET /albums/{id})。
@@ -127,6 +129,87 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
);
}
// ── 上传入口(Task 9 FAB:拍照上传 / 从相册选择) ──────────────────────
/// 当前登录账号(TaskStore 隔离键;详情页处于登录壳内,null 视为异常)。
Future<String?> _currentAccountId() async {
final session = await ref.read(sessionStoreProvider).read();
return session?.account;
}
Future<void> _openUploadSheet() {
final l10n = AppLocalizations.of(context);
return showModalBottomSheet<void>(
context: context,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.photo_camera_outlined),
title: Text(l10n.uploadActionCapture),
onTap: () {
Navigator.of(sheetContext).pop();
_openCapture();
},
),
ListTile(
leading: const Icon(Icons.photo_library_outlined),
title: Text(l10n.uploadActionPick),
onTap: () {
Navigator.of(sheetContext).pop();
_pickFromGallery();
},
),
],
),
),
);
}
/// 拍照上传:进入连拍页(目标相册 + 分类选定一次)。
Future<void> _openCapture() async {
final album = _album;
if (album == null) return;
final account = await _currentAccountId();
if (!mounted) return;
if (account == null) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => CapturePage(
target: CaptureTarget(
accountId: account,
albumId: album.id,
albumTitle: _titleOf(album),
categories: album.categories,
),
),
),
);
}
/// 从相册选择:多选 → 逐张入队(默认分类)→ 入队数提示。
Future<void> _pickFromGallery() async {
final album = _album;
if (album == null) return;
final l10n = AppLocalizations.of(context);
final account = await _currentAccountId();
if (account == null) return;
final paths = await ref.read(multiImagePickerProvider)();
if (paths.isEmpty || !mounted) return;
final messenger = ScaffoldMessenger.of(context);
final count = await enqueueForUpload(
ref,
accountId: account,
albumId: album.id,
sourcePaths: paths,
);
if (!mounted) return;
messenger.showSnackBar(
SnackBar(content: Text(l10n.uploadEnqueuedCount(count))),
);
}
void _scrollToCategories() {
final ctx = _categoriesKey.currentContext;
if (ctx != null) {
@@ -192,6 +275,14 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
final title = album == null ? l10n.albumDetail : _titleOf(album);
return Scaffold(
appBar: AppBar(title: Text(title)),
// 上传入口(Task 9):拍照上传(连拍页)/ 从相册选择(多选入队)。
floatingActionButton: album == null
? null
: FloatingActionButton.extended(
onPressed: _openUploadSheet,
icon: const Icon(Icons.photo_camera_outlined),
label: Text(l10n.uploadAction),
),
body: _loading
? const Center(child: CircularProgressIndicator())
: album == null
+545
View File
@@ -0,0 +1,545 @@
/// 连拍页(P3 计划 Task 9,R17 连拍入队 / O01 现场反馈)。
///
/// 进入时选定目标相册与分类(相册详情 FAB 传入);快门每张立即经准备器
/// 入队(preparing→queued,不逐张确认);常驻信息层:相册名 + 分类(可
/// 切换,**仅影响之后拍摄**——引擎只读任务行归属,切换不改既有任务)+
/// 待传/失败徽章(TaskStore 写通知实时刷新)。相册多选按钮补充入队;
/// 退出时若有待传任务弹确认(任务在后台继续——Android 原生 WorkManager
/// 承担,见 native_transport.dart)。
///
/// 相机/相册经注入点([captureSourceFactoryProvider]/[multiImagePickerProvider])
/// 隔离平台通道,widget 测试注入替身驱动。
library;
import 'dart:async';
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
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';
// ── 相机源抽象(生产=camera 插件;测试替身) ─────────────────────────────
/// 拍摄源:初始化/拍摄/预览/释放。
abstract class CaptureSource {
/// 初始化(权限缺失/失败抛异常;权限拒绝抛 [CameraException])。
Future<void> initialize();
/// 拍摄一张(返回源文件)。
Future<XFile> capture();
/// 取景预览。
Widget buildPreview();
/// 释放底层资源。
Future<void> dispose();
}
/// 生产拍摄源:camera 插件(后摄优先,最高分辨率,静音连拍)。
class CameraPluginSource implements CaptureSource {
CameraPluginSource._(this._controller);
/// 创建并初始化(后摄优先)。
static Future<CameraPluginSource> create() 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,
enableAudio: false);
await controller.initialize();
return CameraPluginSource._(controller);
}
final CameraController _controller;
@override
Future<void> initialize() async {}
@override
Future<XFile> capture() => _controller.takePicture();
@override
Widget buildPreview() => CameraPreview(_controller);
@override
Future<void> dispose() => _controller.dispose();
}
/// 拍摄源工厂 Provider(测试 override 为替身)。
final captureSourceFactoryProvider =
Provider<Future<CaptureSource> Function()>((ref) => CameraPluginSource.create);
// ── 连拍页 ────────────────────────────────────────────────────────────────
/// 连拍页参数:目标相册(相册详情 FAB 进入时固定)与分类候选。
class CaptureTarget {
const CaptureTarget({
required this.accountId,
required this.albumId,
required this.albumTitle,
this.categories = const <Category>[],
});
/// 入队账号(TaskStore 隔离键)。
final String accountId;
/// 目标相册 ID。
final String albumId;
/// 目标相册展示名(信息层常驻显示)。
final String albumTitle;
/// 相册分类(空 = 无可切换项,仅默认分类)。
final List<Category> categories;
}
/// 连拍页。
class CapturePage extends ConsumerStatefulWidget {
const CapturePage({required this.target, super.key});
final CaptureTarget target;
@override
ConsumerState<CapturePage> createState() => _CapturePageState();
}
enum _CameraPhase { loading, ready, denied, failed }
class _CapturePageState extends ConsumerState<CapturePage> {
CaptureSource? _source;
_CameraPhase _phase = _CameraPhase.loading;
/// 当前分类(null = 相册默认分类;切换仅影响之后拍摄)。
String? _categoryId;
/// 连拍串行链:快门连点逐张落地(相机硬件本就串行)。
Future<void> _shotTail = Future<void>.value();
@override
void initState() {
super.initState();
_initCamera();
}
@override
void dispose() {
_source?.dispose().ignore();
super.dispose();
}
Future<void> _initCamera() async {
setState(() => _phase = _CameraPhase.loading);
try {
final source = await ref.read(captureSourceFactoryProvider)();
await source.initialize();
if (!mounted) {
await source.dispose();
return;
}
_source = source;
setState(() => _phase = _CameraPhase.ready);
} on Object catch (error) {
if (!mounted) return;
setState(() => _phase = _isPermissionDenied(error)
? _CameraPhase.denied
: _CameraPhase.failed);
}
}
/// camera 插件的权限拒绝错误码族(CameraAccessDenied /
/// CameraAccessDeniedWithoutD restriction 等)。
static bool _isPermissionDenied(Object error) =>
error is CameraException &&
(error.code.toLowerCase().contains('denied') ||
error.code.toLowerCase().contains('permission'));
/// 快门:立即入队(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;
await enqueueForUpload(
ref,
accountId: widget.target.accountId,
albumId: widget.target.albumId,
categoryId: categoryId,
sourcePaths: [photo.path],
);
}).catchError((Object _) {
// 单张失败吞掉(连拍不中断;失败任务/错误在徽章与队列页可见)。
});
}
/// 相册多选入队(目标 = 当前相册与分类)。
Future<void> _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<void> _confirmExit() async {
final l10n = AppLocalizations.of(context);
final leave = await showDialog<bool>(
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(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black87,
foregroundColor: Colors.white,
title: Text(l10n.captureTitle),
),
body: Stack(
fit: StackFit.expand,
children: [
_buildCameraArea(l10n),
// 常驻信息层:相册 + 分类 + 待传/失败徽章(R17/O01)。
SafeArea(
child: Column(
children: [
_TargetOverlay(
albumTitle: widget.target.albumTitle,
categories: categories,
categoryId: _categoryId,
onCategorySelected: _switchCategory,
pending: pending,
failed: failed,
),
],
),
),
],
),
bottomNavigationBar: _buildBottomBar(l10n),
),
);
}
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,
);
}
}
Widget _buildBottomBar(AppLocalizations l10n) {
return BottomAppBar(
color: Colors.black87,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
IconButton(
tooltip: l10n.capturePickFromGallery,
icon: const Icon(Icons.photo_library_outlined,
color: Colors.white),
onPressed: _pickFromGallery,
),
_ShutterButton(onPressed: _shoot, enabled: _phase == _CameraPhase.ready),
// 占位对齐(快门居中)。
const SizedBox(width: 48),
],
),
);
}
}
// ── 信息层组件 ────────────────────────────────────────────────────────────
/// 顶部常驻信息层:相册名 + 分类 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,
});
final String albumTitle;
final List<Category> categories;
final String? categoryId;
final void Function(String?) onCategorySelected;
final int pending;
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),
_Badge(
label: l10n.capturePendingBadge(pending),
color: Colors.white24,
foreground: Colors.white,
),
if (failed > 0) ...[
const SizedBox(width: 6),
_Badge(
label: l10n.captureFailedBadge(failed),
color: Colors.red,
foreground: Colors.white,
),
],
],
),
),
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,
),
],
],
),
),
],
);
}
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),
),
);
}
}
/// 快门按钮(连拍:不进入模态确认,逐张自动入队)。
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(
onPressed: enabled ? onPressed : null,
iconSize: 64,
icon: Container(
width: 64,
height: 64,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 4),
),
child: const Icon(Icons.camera, color: Colors.white),
),
),
);
}
}
/// 相机不可用视图(权限拒绝/初始化失败):说明 + 重试。
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: Colors.black87,
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),
),
],
),
),
);
}
}
+4 -39
View File
@@ -2,8 +2,10 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/api_client.dart';
import '../features/uploads/engine_scope.dart';
import '../l10n/app_localizations.dart';
import 'albums_tab.dart';
import 'queue_tab.dart';
/// 主壳:底部导航(相册 / 队列 / 设置)。
class HomePage extends StatefulWidget {
@@ -54,45 +56,6 @@ class _HomePageState extends State<HomePage> {
}
}
/// 上传队列(桩)。Task 9:状态机展示、筛选与重试;当前仅提供
/// 服务端上传历史入口(A19 跨设备记录)。
class QueueTab extends StatelessWidget {
const QueueTab({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: Text(l10n.queue)),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
l10n.queueStubHint,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(height: 16),
FilledButton.tonalIcon(
onPressed: () =>
Navigator.of(context).pushNamed('/server-history'),
icon: const Icon(Icons.history_outlined),
label: Text(l10n.serverHistoryEntry),
),
],
),
),
);
}
}
/// 设置页(P3 Task 3:修改密码入口 + 退出登录;Task 10 补语言切换持久化)。
class SettingsTab extends ConsumerWidget {
const SettingsTab({super.key});
@@ -100,6 +63,8 @@ class SettingsTab extends ConsumerWidget {
Future<void> _logout(BuildContext context, WidgetRef ref) async {
// api.logout 尽力而为(服务端撤销失败也照常本地清会话——客户端内已处理)。
await ref.read(apiClientProvider).logout();
// 会话账号失效 → 引擎 stop(任务保留库内,见 engine_scope.dart)。
await ref.read(sessionAccountProvider.notifier).refresh();
if (!context.mounted) return;
Navigator.of(context).pushNamedAndRemoveUntil('/login', (route) => false);
}
+3
View File
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/api_client.dart';
import '../core/app_exception.dart';
import '../core/error_l10n.dart';
import '../features/uploads/engine_scope.dart';
import '../l10n/app_localizations.dart';
/// 登录页(P3 计划 Task 3)。
@@ -44,6 +45,8 @@ class _LoginPageState extends ConsumerState<LoginPage> {
_accountController.text.trim(),
_passwordController.text,
);
// 登录成功:刷新会话账号 → 引擎以新账号 scope start(Task 9 接线)。
await ref.read(sessionAccountProvider.notifier).refresh();
navigator.pushReplacementNamed('/home');
} on AppException catch (error) {
// 429(AUTH_RATE_LIMITED)等文案经错误码映射;登录页的
+586
View File
@@ -0,0 +1,586 @@
/// 上传队列页(P3 计划 Task 9,需求 4.3 / R17 / O01)。
///
/// 三维筛选(相册下拉 × 状态 chips × 时间快捷)→ [QueueFilter];任务行
/// = 缩略图(受管副本,缺失/已清理显示占位)+ 文件名 + 相册名 + 状态
/// 徽章 + 尝试/错误(本地化错误码)+ 传输进度(进度总线行级订阅)+ 按
/// 状态动作(重试/取消/删除);失败任务存在时底部「一键重试全部失败」。
/// 列表经 TaskStore 写通知 + 200ms 防抖实时刷新(连拍风暴不逐条重建)。
library;
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../features/albums/models.dart';
import '../features/albums/providers.dart';
import '../features/uploads/engine_scope.dart';
import '../features/uploads/enqueue.dart';
import '../features/uploads/providers.dart';
import '../features/uploads/queue_controller.dart';
import '../features/uploads/queue_l10n.dart';
import '../features/uploads/task_state.dart';
import '../features/uploads/task_store.dart';
import '../l10n/app_localizations.dart';
/// 队列 Tab(主壳 IndexedStack 内常驻)。
class QueueTab extends ConsumerWidget {
const QueueTab({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final counts = ref.watch(taskCountsProvider);
final autoRetryable = (counts[UploadTaskState.retryableFailed] ?? 0) +
(counts[UploadTaskState.waitingNetwork] ?? 0);
return Scaffold(
appBar: AppBar(
title: Text(l10n.queue),
actions: [
IconButton(
tooltip: l10n.serverHistoryEntry,
icon: const Icon(Icons.history_outlined),
onPressed: () =>
Navigator.of(context).pushNamed('/server-history'),
),
],
),
body: const Column(
children: [
_FilterBar(),
Expanded(child: _TaskListView()),
],
),
bottomNavigationBar: autoRetryable > 0 ? const _BatchRetryBar() : null,
);
}
}
// ── 筛选栏 ────────────────────────────────────────────────────────────────
/// 筛选栏:相册下拉 + 时间快捷 chips + 状态分组 chips(需求 4.3 三维)。
class _FilterBar extends ConsumerWidget {
const _FilterBar();
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final filter = ref.watch(queueFilterProvider);
final albums = ref.watch(albumListProvider).items;
return Material(
color: theme.colorScheme.surfaceContainerLow,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Column(
children: [
Row(
children: [
Icon(Icons.filter_list,
size: 18, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(width: 8),
Expanded(child: _AlbumDropdown(filter: filter, albums: albums)),
],
),
const SizedBox(height: 4),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final (i, time) in QueueTimeFilter.values.indexed) ...[
if (i > 0) const SizedBox(width: 8),
ChoiceChip(
label: Text(_timeLabel(l10n, time)),
selected: filter.time == time,
onSelected: (_) => ref
.read(queueFilterProvider.notifier)
.setTime(time),
visualDensity: VisualDensity.compact,
),
],
],
),
),
const SizedBox(height: 4),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final (i, state) in QueueStateFilter.values.indexed) ...[
if (i > 0) const SizedBox(width: 8),
ChoiceChip(
label: Text(_stateLabel(l10n, state)),
selected: filter.state == state,
onSelected: (_) => ref
.read(queueFilterProvider.notifier)
.setStateGroup(state),
visualDensity: VisualDensity.compact,
),
],
],
),
),
const SizedBox(height: 4),
],
),
),
);
}
static String _stateLabel(AppLocalizations l10n, QueueStateFilter state) =>
switch (state) {
QueueStateFilter.all => l10n.queueStateAll,
QueueStateFilter.preparing => l10n.queueStatePreparing,
QueueStateFilter.queued => l10n.queueStateQueued,
QueueStateFilter.uploading => l10n.queueStateUploading,
QueueStateFilter.waitingNetwork => l10n.queueStateWaitingNetwork,
QueueStateFilter.failed => l10n.queueStateFailed,
QueueStateFilter.completed => l10n.queueStateCompleted,
};
static String _timeLabel(AppLocalizations l10n, QueueTimeFilter time) =>
switch (time) {
QueueTimeFilter.all => l10n.queueTimeAll,
QueueTimeFilter.today => l10n.queueTimeToday,
QueueTimeFilter.last7Days => l10n.queueTimeLast7Days,
QueueTimeFilter.last30Days => l10n.queueTimeLast30Days,
};
}
/// 相册下拉(全部相册 + 相册列表缓存;选中相册不在列表时退回全部)。
class _AlbumDropdown extends ConsumerWidget {
const _AlbumDropdown({required this.filter, required this.albums});
final QueueFilter filter;
final List<AlbumSummary> albums;
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final knownIds = albums.map((a) => a.id).toSet();
final value =
filter.albumId != null && knownIds.contains(filter.albumId)
? filter.albumId
: null;
return DropdownButton<String?>(
value: value,
isExpanded: true,
underline: const SizedBox.shrink(),
hint: Text(l10n.queueAlbumFilterAll),
items: [
DropdownMenuItem<String?>(
value: null,
child: Text(
l10n.queueAlbumFilterAll,
style: Theme.of(context).textTheme.bodyMedium,
),
),
for (final album in albums)
DropdownMenuItem<String?>(
value: album.id,
child: Text(
albumDisplayTitle(album, l10n.localeName),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
onChanged: (albumId) =>
ref.read(queueFilterProvider.notifier).setAlbum(albumId),
);
}
}
// ── 任务列表 ──────────────────────────────────────────────────────────────
class _TaskListView extends ConsumerWidget {
const _TaskListView();
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final tasksAsync = ref.watch(queueTasksProvider);
return tasksAsync.when(
skipLoadingOnReload: true,
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.errorGeneric),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: () => ref.invalidate(queueTasksProvider),
child: Text(l10n.retry),
),
],
),
),
data: (tasks) {
if (tasks.isEmpty) {
return Center(child: Text(l10n.queueEmpty));
}
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: tasks.length,
itemBuilder: (context, index) =>
_TaskTile(task: tasks[index]),
);
},
);
}
}
/// 单条任务行。
class _TaskTile extends ConsumerWidget {
const _TaskTile({required this.task});
final UploadTask task;
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final album = ref
.watch(albumListProvider)
.items
.where((a) => a.id == task.albumId)
.firstOrNull;
final albumName = album != null
? albumDisplayTitle(album, l10n.localeName)
: task.albumId;
final failedLike = task.state == UploadTaskState.preparationFailed ||
task.state == UploadTaskState.retryableFailed ||
task.state == UploadTaskState.waitingNetwork;
return ListTile(
leading: _Thumb(
path: task.cleanedAt == null ? task.sourceFilePath : null,
),
title: Text(
task.filename,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 2),
Row(
children: [
_StateChip(state: task.state),
const SizedBox(width: 8),
Expanded(
child: Text(
albumName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
),
],
),
if (failedLike && task.lastError != null) ...[
const SizedBox(height: 2),
Text(
task.attempt > 0
? '${l10n.messageForTaskError(task.lastError!)} · '
'${l10n.queueAttemptCount(task.attempt)}'
: l10n.messageForTaskError(task.lastError!),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.error),
),
],
if (task.state == UploadTaskState.uploading ||
task.state == UploadTaskState.uploadedConfirming)
_ProgressLine(
taskId: task.id,
confirming: task.state == UploadTaskState.uploadedConfirming,
),
],
),
trailing: _TaskActions(task: task),
);
}
}
/// 缩略图:受管副本存在则显示,缺失/已清理/解码失败显示占位图标。
class _Thumb extends StatelessWidget {
const _Thumb({required this.path});
final String? path;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
Widget placeholder() => Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: Icon(
Icons.image_outlined,
size: 24,
color: theme.colorScheme.onSurfaceVariant,
),
);
if (path == null) return placeholder();
return ClipRRect(
borderRadius: BorderRadius.circular(6),
child: SizedBox(
width: 48,
height: 48,
child: Image.file(
File(path!),
fit: BoxFit.cover,
errorBuilder: (_, _, _) => placeholder(),
),
),
);
}
}
/// 状态徽章(分组配色:失败红/完成绿/进行中主色/等待橙/其余中性)。
class _StateChip extends StatelessWidget {
const _StateChip({required this.state});
final UploadTaskState state;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final (color, onColor) = switch (state) {
UploadTaskState.preparing => (
theme.colorScheme.surfaceContainerHighest,
theme.colorScheme.onSurfaceVariant
),
UploadTaskState.preparationFailed ||
UploadTaskState.retryableFailed => (
theme.colorScheme.errorContainer,
theme.colorScheme.onErrorContainer
),
UploadTaskState.queued => (
theme.colorScheme.secondaryContainer,
theme.colorScheme.onSecondaryContainer
),
UploadTaskState.uploading ||
UploadTaskState.uploadedConfirming => (
theme.colorScheme.primaryContainer,
theme.colorScheme.onPrimaryContainer
),
UploadTaskState.completed => (
theme.colorScheme.tertiaryContainer,
theme.colorScheme.onTertiaryContainer
),
UploadTaskState.waitingNetwork => (
theme.colorScheme.tertiaryContainer,
theme.colorScheme.onTertiaryContainer
),
UploadTaskState.cancelled => (
theme.colorScheme.surfaceContainerHighest,
theme.colorScheme.onSurfaceVariant
),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(999),
),
child: Text(
l10n.labelForTaskState(state),
style: theme.textTheme.labelSmall?.copyWith(color: onColor),
),
);
}
}
/// 传输进度行:上传中 = 进度总线行级订阅(分数进度条);确认中 = 不确定
/// 进度条(等待服务端核验)。
class _ProgressLine extends ConsumerWidget {
const _ProgressLine({required this.taskId, required this.confirming});
final String taskId;
final bool confirming;
@override
Widget build(BuildContext context, WidgetRef ref) {
return Padding(
padding: const EdgeInsets.only(top: 6, right: 8),
child: StreamBuilder<UploadTaskProgress>(
stream: ref
.watch(uploadProgressHubProvider)
.stream
.where((p) => p.taskId == taskId),
builder: (context, snapshot) {
final fraction = snapshot.data?.fraction;
return LinearProgressIndicator(
value: confirming ? null : fraction,
minHeight: 4,
);
},
),
);
}
}
/// 行级动作(按状态;未登录账号缺席时禁用)。
class _TaskActions extends ConsumerWidget {
const _TaskActions({required this.task});
final UploadTask task;
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final account = ref.watch(sessionAccountProvider).value;
switch (task.state) {
case UploadTaskState.preparationFailed:
// 准备失败重试 = 重新选图(原始源路径不落库,见准备器注释)。
return IconButton(
tooltip: l10n.retry,
icon: const Icon(Icons.refresh),
onPressed: account == null
? null
: () => _retryPreparation(context, ref, account),
);
case UploadTaskState.retryableFailed:
case UploadTaskState.waitingNetwork:
return IconButton(
tooltip: l10n.retry,
icon: const Icon(Icons.refresh),
onPressed: account == null
? null
: () => ref
.read(uploadEngineProvider)
.retryNow(account, task.id),
);
case UploadTaskState.queued:
case UploadTaskState.preparing:
return IconButton(
tooltip: l10n.cancel,
icon: const Icon(Icons.close),
onPressed: account == null
? null
: () => ref.read(uploadEngineProvider).cancel(account, task.id),
);
case UploadTaskState.completed:
case UploadTaskState.cancelled:
return IconButton(
tooltip: l10n.delete,
icon: const Icon(Icons.delete_outline),
onPressed: account == null ? null : () => _delete(ref, account),
);
case UploadTaskState.uploading:
case UploadTaskState.uploadedConfirming:
return const SizedBox(width: 48);
}
}
/// 准备失败重试:重新选图 → preparer.retry(换源重新复制入队)。
Future<void> _retryPreparation(
BuildContext context,
WidgetRef ref,
String account,
) async {
final l10n = AppLocalizations.of(context);
final sourcePath = await ref.read(singleImagePickerProvider)();
if (sourcePath == null || !context.mounted) return;
final messenger = ScaffoldMessenger.of(context);
try {
await ref.read(uploadPreparerProvider).retry(
accountId: account,
taskId: task.id,
sourcePath: sourcePath,
);
} on Object {
messenger.showSnackBar(
SnackBar(content: Text(l10n.errorGeneric)),
);
}
}
/// 终态删除:删行 + 尽力删受管副本(行删除后清理执行器不再可见该文件)。
Future<void> _delete(WidgetRef ref, String account) async {
final store = ref.read(taskStoreProvider);
await store.delete(task.id);
if (task.cleanedAt == null) {
try {
final file = File(task.sourceFilePath);
if (await file.exists()) {
await file.delete();
}
} on Object {
// 文件删除失败接受(无行可追责,残留由用户重装/系统清理回收)。
}
}
}
}
/// 底部批量重试条(存在可自动重试的失败任务时出现)。
class _BatchRetryBar extends ConsumerWidget {
const _BatchRetryBar();
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final counts = ref.watch(taskCountsProvider);
final failed = failedCountOf(counts);
return Material(
color: theme.colorScheme.errorContainer,
child: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Row(
children: [
Icon(Icons.error_outline, color: theme.colorScheme.onErrorContainer),
const SizedBox(width: 8),
Expanded(
child: Text(
'${l10n.queueStateFailed} · $failed',
style: theme.textTheme.bodyMedium
?.copyWith(color: theme.colorScheme.onErrorContainer),
),
),
FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: theme.colorScheme.error,
foregroundColor: theme.colorScheme.onError,
),
icon: const Icon(Icons.refresh, size: 18),
label: Text(l10n.queueRetryAllFailed),
onPressed: () => _retryAll(ref),
),
],
),
),
),
);
}
/// 全部可自动重试任务(retryableFailed + waitingNetwork,跨当前筛选)
/// 立即重新入队。
Future<void> _retryAll(WidgetRef ref) async {
final account = ref.read(sessionAccountProvider).value;
if (account == null) return;
final engine = ref.read(uploadEngineProvider);
final tasks = await ref.read(taskStoreProvider).listByAccount(
account,
states: const {
UploadTaskState.retryableFailed,
UploadTaskState.waitingNetwork,
},
);
for (final task in tasks) {
await engine.retryNow(account, task.id);
}
}
}