1f67b7c818
Co-Authored-By: Claude Code <noreply@anthropic.com>
333 lines
12 KiB
Dart
333 lines
12 KiB
Dart
/// 队列 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)),
|
||
);
|