Files
photo-live/apps/mobile/lib/features/uploads/queue_controller.dart
T
2026-09-21 17:20:43 +08:00

333 lines
12 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 队列 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';
/// 状态筛选分组(UI-01 §4.4:chip → 状态集合;「全部」= 不过滤)。
enum QueueStateFilter {
all,
inProgress,
needsAttention,
completed;
/// 分组对应的状态集合(空 = 不过滤)。
///
/// - 进行中 = 引擎正在推进的活任务(准备/排队/传输/确认 + 等待网络
/// ——网络等待自动恢复,属系统仍在推进的范畴,归入进行中而非需关注);
/// - 需关注 = 需要用户动作的失败(准备失败需重新选图、上传失败需重试);
/// - 已完成 = 服务端已受理的终态(cancelled 仅在「全部」可见)。
Set<UploadTaskState> get states => switch (this) {
QueueStateFilter.all => const <UploadTaskState>{},
QueueStateFilter.inProgress => const <UploadTaskState>{
UploadTaskState.preparing,
UploadTaskState.queued,
UploadTaskState.uploading,
UploadTaskState.uploadedConfirming,
UploadTaskState.waitingNetwork,
},
QueueStateFilter.needsAttention => 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,
);
/// 摘要计数(UI-01 §4.4):**当前筛选范围内**(相册 × 时间)的任务数聚合
/// ——与列表同口径,范围标注由队列页摘要卡展示;仍走 SQL 聚合
/// ([TaskStore.countsByState]),不整表载入内存。写通知防抖刷新。
class QueueSummaryNotifier extends Notifier<Map<UploadTaskState, int>> {
StreamSubscription<void>? _changesSub;
Timer? _debounce;
@override
Map<UploadTaskState, int> build() {
final store = ref.watch(taskStoreProvider);
// 会话与筛选变化(相册/时间维度改变摘要口径)都触发重建。
ref.watch(sessionAccountProvider);
ref.watch(queueFilterProvider);
_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 filter = ref.read(queueFilterProvider);
final counts = await ref.read(taskStoreProvider).countsByState(
account,
albumId: filter.albumId,
since: filter.time.since(DateTime.now()),
);
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 queueSummaryProvider =
NotifierProvider<QueueSummaryNotifier, Map<UploadTaskState, int>>(
QueueSummaryNotifier.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]!;
// ── 摘要行计数(UI-01 §4.4:按任务数统计,不与字节进度混口径)──────────
/// 摘要「已完成」:服务端已受理的任务数(终态记录,不含已取消)。
int completedSummaryOf(Map<UploadTaskState, int> counts) =>
counts[UploadTaskState.completed]!;
/// 摘要「上传中」:正在传输/确认的任务数。
int uploadingSummaryOf(Map<UploadTaskState, int> counts) =>
counts[UploadTaskState.uploading]! +
counts[UploadTaskState.uploadedConfirming]!;
/// 摘要「等待中」:准备/排队/等待网络的任务数(等待调度,非用户错误)。
int waitingSummaryOf(Map<UploadTaskState, int> counts) =>
counts[UploadTaskState.preparing]! +
counts[UploadTaskState.queued]! +
counts[UploadTaskState.waitingNetwork]!;
/// 「未结束」计数(底部导航「上传」角标,UI-01 §4.1):待传(
/// [pendingCountOf])+ 可重试失败——均为引擎待推进或用户需关注的活任务;
/// 准备失败/取消/完成不计入。账号隔离由 TaskStore.countsByState 硬守护。
int unfinishedCountOf(Map<UploadTaskState, int> counts) =>
pendingCountOf(counts) + counts[UploadTaskState.retryableFailed]!;
/// 导航角标计数 Provider:主壳 watch(计数不变不触发重建;页签状态由
/// IndexedStack 保持,不随角标重建)。
final Provider<int> uploadBadgeCountProvider = Provider<int>(
(ref) => unfinishedCountOf(ref.watch(taskCountsProvider)),
);