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