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
@@ -0,0 +1,251 @@
/// 队列 UI 状态层(P3 计划 Task 9)。
///
/// - [QueueFilter]:需求 4.3 三维筛选(相册 × 状态 × 时间)的**纯逻辑**
/// (单测直接覆盖;与 [TaskStore.listByAccount] 的参数化查询语义一致:
/// 状态分组 → states 集合、时间快捷 → since 下限);
/// - [QueueFilterNotifier]:筛选状态(队列页 filter bar 消费);
/// - [QueueTasksNotifier]:筛选后的任务列表——**实时刷新 = TaskStore 写
/// 通知(drift tableUpdates)+ 200ms 防抖重查**(连拍入队/引擎迁移风暴
/// 下不逐条重建 UI);
/// - [TaskCountsNotifier]:账号内各状态计数(连拍页徽章 + 批量重试条,
/// 同一防抖策略)。
library;
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'engine_scope.dart';
import 'providers.dart';
import 'task_state.dart';
import 'task_store.dart';
/// 状态筛选分组(chip → 状态集合;「全部」= 不过滤)。
enum QueueStateFilter {
all,
preparing,
queued,
uploading,
waitingNetwork,
failed,
completed;
/// 分组对应的状态集合(空 = 不过滤)。
Set<UploadTaskState> get states => switch (this) {
QueueStateFilter.all => const <UploadTaskState>{},
QueueStateFilter.preparing => const <UploadTaskState>{
UploadTaskState.preparing,
},
QueueStateFilter.queued => const <UploadTaskState>{
UploadTaskState.queued,
},
QueueStateFilter.uploading => const <UploadTaskState>{
UploadTaskState.uploading,
UploadTaskState.uploadedConfirming,
},
QueueStateFilter.waitingNetwork => const <UploadTaskState>{
UploadTaskState.waitingNetwork,
},
QueueStateFilter.failed => const <UploadTaskState>{
UploadTaskState.preparationFailed,
UploadTaskState.retryableFailed,
},
QueueStateFilter.completed => const <UploadTaskState>{
UploadTaskState.completed,
},
};
}
/// 时间筛选快捷(今天/近7天/近30天/全部)。
enum QueueTimeFilter {
all,
today,
last7Days,
last30Days;
/// 创建时间下限(null = 不过滤;本地时刻,matches 内与 UTC 落库值
/// 比较前归一)。
DateTime? since(DateTime now) => switch (this) {
QueueTimeFilter.all => null,
QueueTimeFilter.today => DateTime(now.year, now.month, now.day),
QueueTimeFilter.last7Days =>
now.subtract(const Duration(days: 7)),
QueueTimeFilter.last30Days =>
now.subtract(const Duration(days: 30)),
};
}
/// 三维筛选条件(不可变值对象;纯匹配逻辑单测覆盖)。
class QueueFilter {
const QueueFilter({
this.albumId,
this.state = QueueStateFilter.all,
this.time = QueueTimeFilter.all,
});
/// 相册过滤(null = 全部相册)。
final String? albumId;
/// 状态分组过滤。
final QueueStateFilter state;
/// 时间快捷过滤。
final QueueTimeFilter time;
/// 纯匹配(与 TaskStore.listByAccount(albumId/states/since) 查询语义
/// 一致;createdAt 为 UTC 落库值,比较前归一时区)。
bool matches(UploadTask task, {required DateTime now}) {
if (albumId != null && task.albumId != albumId) return false;
final states = state.states;
if (states.isNotEmpty && !states.contains(task.state)) return false;
final cutoff = time.since(now);
if (cutoff != null && task.createdAt.isBefore(cutoff.toUtc())) return false;
return true;
}
QueueFilter copyWith({
String? albumId,
bool clearAlbum = false,
QueueStateFilter? state,
QueueTimeFilter? time,
}) =>
QueueFilter(
albumId: clearAlbum ? null : (albumId ?? this.albumId),
state: state ?? this.state,
time: time ?? this.time,
);
}
/// 筛选状态 Notifier。
class QueueFilterNotifier extends Notifier<QueueFilter> {
@override
QueueFilter build() => const QueueFilter();
void setAlbum(String? albumId) =>
state = albumId == null ? state.copyWith(clearAlbum: true) : state.copyWith(albumId: albumId);
void setStateGroup(QueueStateFilter group) => state = state.copyWith(state: group);
void setTime(QueueTimeFilter time) => state = state.copyWith(time: time);
}
/// 筛选状态 Provider。
final queueFilterProvider =
NotifierProvider<QueueFilterNotifier, QueueFilter>(QueueFilterNotifier.new);
/// 防抖重查间隔(连拍/引擎迁移写风暴下限流)。
const Duration kQueueRefreshDebounce = Duration(milliseconds: 200);
/// 队列任务列表:筛选参数化查询 + 写通知防抖刷新。
class QueueTasksNotifier extends AsyncNotifier<List<UploadTask>> {
StreamSubscription<void>? _changesSub;
Timer? _debounce;
@override
Future<List<UploadTask>> build() async {
final filter = ref.watch(queueFilterProvider);
final account = await ref.watch(sessionAccountProvider.future);
final store = ref.watch(taskStoreProvider);
_changesSub?.cancel();
_debounce?.cancel();
_changesSub = store.changes.listen((_) => _scheduleReload());
ref.onDispose(_dispose);
if (account == null) return const <UploadTask>[];
return _query(store, account, filter);
}
static Future<List<UploadTask>> _query(
TaskStore store,
String account,
QueueFilter filter,
) =>
store.listByAccount(
account,
albumId: filter.albumId,
states: filter.state.states.isEmpty ? null : filter.state.states,
since: filter.time.since(DateTime.now()),
);
/// 写通知 → 200ms 防抖后重查(同一账号/筛选的最新快照)。
void _scheduleReload() {
_debounce?.cancel();
_debounce = Timer(kQueueRefreshDebounce, () {
final account = ref.read(sessionAccountProvider).value;
if (account == null) return;
_query(ref.read(taskStoreProvider), account, ref.read(queueFilterProvider))
.then((tasks) => state = AsyncData<List<UploadTask>>(tasks));
});
}
void _dispose() {
_changesSub?.cancel();
_changesSub = null;
_debounce?.cancel();
_debounce = null;
}
}
/// 队列任务列表 Provider。
final queueTasksProvider =
AsyncNotifierProvider<QueueTasksNotifier, List<UploadTask>>(
QueueTasksNotifier.new,
);
/// 账号内各状态计数(连拍页徽章/批量重试条;写通知防抖刷新)。
class TaskCountsNotifier extends Notifier<Map<UploadTaskState, int>> {
StreamSubscription<void>? _changesSub;
Timer? _debounce;
@override
Map<UploadTaskState, int> build() {
final store = ref.watch(taskStoreProvider);
// 会话变化(登录/登出)也触发重建(登出清零)。
ref.watch(sessionAccountProvider);
_changesSub?.cancel();
_debounce?.cancel();
_changesSub = store.changes.listen((_) => _scheduleReload());
ref.onDispose(_dispose);
Future<void>.microtask(() => _reload());
return <UploadTaskState, int>{
for (final state in UploadTaskState.values) state: 0,
};
}
Future<void> _reload() async {
final account = ref.read(sessionAccountProvider).value;
if (account == null) return;
final counts = await ref.read(taskStoreProvider).countsByState(account);
if (ref.mounted) state = counts;
}
void _scheduleReload() {
_debounce?.cancel();
_debounce = Timer(kQueueRefreshDebounce, _reload);
}
void _dispose() {
_changesSub?.cancel();
_changesSub = null;
_debounce?.cancel();
_debounce = null;
}
}
/// 各状态计数 Provider(键覆盖全部状态,无任务计 0)。
final taskCountsProvider =
NotifierProvider<TaskCountsNotifier, Map<UploadTaskState, int>>(
TaskCountsNotifier.new,
);
/// 未完成(待传)计数:准备中/待传/上传中/确认中/等待网络之和。
int pendingCountOf(Map<UploadTaskState, int> counts) =>
counts[UploadTaskState.preparing]! +
counts[UploadTaskState.queued]! +
counts[UploadTaskState.uploading]! +
counts[UploadTaskState.uploadedConfirming]! +
counts[UploadTaskState.waitingNetwork]!;
/// 失败计数:准备失败 + 上传失败。
int failedCountOf(Map<UploadTaskState, int> counts) =>
counts[UploadTaskState.preparationFailed]! +
counts[UploadTaskState.retryableFailed]!;