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:
@@ -0,0 +1,67 @@
|
||||
/// 引擎生命周期绑定(P3 计划 Task 6 关注点 1,Task 9 接线)。
|
||||
///
|
||||
/// 登录会话账号(SessionStore.account)→ [UploadEngine.start] 的 scope:
|
||||
/// - 账号在场(App 启动恢复会话 / 登录成功)→ start(accountId)——待传
|
||||
/// 队列尽快恢复出队(A05);
|
||||
/// - 账号离场(登出 / 401 驱逐)→ stop()——**任务行保留在库内**,账号
|
||||
/// 切换安全由 TaskStore 的 accountId 硬隔离守护保障;
|
||||
/// - 会话变更通知点:登录页成功、设置页登出、App 壳 401 回调,均调
|
||||
/// [SessionAccountNotifier.refresh]。
|
||||
///
|
||||
/// [uploadEngineScopeProvider] 由 App 壳(PhotoLiveApp.build)watch 保持
|
||||
/// 常驻——离开登录态即 stop,绝不遗留旧账号的周期出队。
|
||||
library;
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/api_client.dart';
|
||||
import 'providers.dart';
|
||||
import 'upload_engine.dart';
|
||||
|
||||
/// 会话账号 Notifier:读 SessionStore;登录/登出/401 后 refresh()。
|
||||
class SessionAccountNotifier extends AsyncNotifier<String?> {
|
||||
@override
|
||||
Future<String?> build() async {
|
||||
final session = await ref.watch(sessionStoreProvider).read();
|
||||
return _normalize(session?.account);
|
||||
}
|
||||
|
||||
/// 重读会话(登录成功/登出/401 驱逐后调用)。
|
||||
Future<void> refresh() async {
|
||||
final store = ref.read(sessionStoreProvider);
|
||||
state = const AsyncLoading<String?>();
|
||||
state = await AsyncValue.guard(() async {
|
||||
final session = await store.read();
|
||||
return _normalize(session?.account);
|
||||
});
|
||||
}
|
||||
|
||||
/// 空串归一为 null(未登录语义;引擎 scope 判空依据)。
|
||||
static String? _normalize(String? account) =>
|
||||
(account == null || account.isEmpty) ? null : account;
|
||||
}
|
||||
|
||||
/// 会话账号 Provider(引擎 scope 与队列/连拍 UI 的 accountId 来源)。
|
||||
final sessionAccountProvider =
|
||||
AsyncNotifierProvider<SessionAccountNotifier, String?>(
|
||||
SessionAccountNotifier.new,
|
||||
);
|
||||
|
||||
/// 引擎生命周期绑定:账号在场 → start;离场 → stop(任务保留库内)。
|
||||
///
|
||||
/// 返回当前 scope 账号(null=引擎停止);仅被 App 壳 watch 时生效
|
||||
/// (Provider 惰性求值)。
|
||||
final uploadEngineScopeProvider = Provider<String?>((ref) {
|
||||
final account = ref.watch(sessionAccountProvider).value;
|
||||
final engine = ref.watch(uploadEngineProvider);
|
||||
if (account != null) {
|
||||
engine.start(account);
|
||||
} else {
|
||||
engine.stop();
|
||||
}
|
||||
ref.onDispose(engine.stop);
|
||||
return account;
|
||||
});
|
||||
|
||||
/// 供文档引用的类型锚点([UploadEngine] 生命周期语义见 upload_engine.dart)。
|
||||
typedef EngineLifecycleDoc = UploadEngine;
|
||||
@@ -0,0 +1,64 @@
|
||||
/// 选图注入点 + 批量入队动作(P3 计划 Task 9)。
|
||||
///
|
||||
/// image_picker 的平台调用经 Provider 注入([singleImagePickerProvider]/
|
||||
/// [multiImagePickerProvider]),widget 测试 override 为替身——不经平台
|
||||
/// 通道即可驱动入队流;[enqueueForUpload] 为连拍页与相册详情 FAB 的共用
|
||||
/// 入队路径:首次动作触发 POST_NOTIFICATIONS 请求(静默降级)+ 逐张经
|
||||
/// 准备器入队(准备器内部串行复制,防连拍 IO 风暴)。
|
||||
library;
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import '../../core/notification_permission.dart';
|
||||
import 'providers.dart';
|
||||
import 'task_state.dart';
|
||||
|
||||
/// 单图选择(准备失败重试的重新选图路径);返回源文件路径或 null(取消)。
|
||||
typedef SingleImagePicker = Future<String?> Function();
|
||||
|
||||
/// 多图选择(相册多选入队);返回源文件路径列表(空=取消)。
|
||||
typedef MultiImagePicker = Future<List<String>> Function();
|
||||
|
||||
/// 单图选择 Provider(生产=image_picker;测试 override)。
|
||||
final singleImagePickerProvider = Provider<SingleImagePicker>(
|
||||
(ref) => () async {
|
||||
final file = await ImagePicker().pickImage(source: ImageSource.gallery);
|
||||
return file?.path;
|
||||
},
|
||||
);
|
||||
|
||||
/// 多图选择 Provider(生产=image_picker;测试 override)。
|
||||
final multiImagePickerProvider = Provider<MultiImagePicker>(
|
||||
(ref) => () async {
|
||||
final files = await ImagePicker().pickMultiImage();
|
||||
return files.map((file) => file.path).toList();
|
||||
},
|
||||
);
|
||||
|
||||
/// 批量入队:通知权限单次请求 + 逐张准备入队。
|
||||
///
|
||||
/// 返回成功入队(state=queued)的张数;准备失败的任务已落库为
|
||||
/// preparationFailed(连拍页失败徽章/队列页可见,不在此抛出)。
|
||||
Future<int> enqueueForUpload(
|
||||
WidgetRef ref, {
|
||||
required String accountId,
|
||||
required String albumId,
|
||||
String? categoryId,
|
||||
required List<String> sourcePaths,
|
||||
}) async {
|
||||
// 首次入队动作触发(幂等;拒绝/非 Android 静默降级,见实现注释)。
|
||||
await ref.read(notificationPermissionProvider).ensureRequested();
|
||||
final preparer = ref.read(uploadPreparerProvider);
|
||||
var enqueued = 0;
|
||||
for (final path in sourcePaths) {
|
||||
final task = await preparer.prepare(
|
||||
accountId: accountId,
|
||||
albumId: albumId,
|
||||
categoryId: categoryId,
|
||||
sourcePath: path,
|
||||
);
|
||||
if (task.state == UploadTaskState.queued) enqueued++;
|
||||
}
|
||||
return enqueued;
|
||||
}
|
||||
@@ -2,10 +2,12 @@
|
||||
///
|
||||
/// 生产装配:TaskStore.app()(documents/upload_tasks.sqlite)+ ApiClient
|
||||
/// (Bearer 自动注入)+ DeviceId(shared_preferences)。引擎生命周期
|
||||
/// (start(accountId)/stop,随登录态切换)由 App 壳/队列 UI 驱动——
|
||||
/// Task 9 队列 UI 落地时接线;本文件保证依赖图就绪。
|
||||
/// (start(accountId)/stop,随登录态切换)在 engine_scope.dart 绑定
|
||||
/// (Task 9 接线)。
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/api_client.dart';
|
||||
@@ -21,13 +23,55 @@ final taskStoreProvider = Provider<TaskStore>((ref) {
|
||||
return store;
|
||||
});
|
||||
|
||||
/// 单任务传输进度事件(队列 UI 行级进度条消费)。
|
||||
class UploadTaskProgress {
|
||||
const UploadTaskProgress({
|
||||
required this.taskId,
|
||||
required this.sent,
|
||||
required this.total,
|
||||
});
|
||||
|
||||
final String taskId;
|
||||
final int sent;
|
||||
final int total;
|
||||
|
||||
/// 0~1 进度分数;total 未知(0)返回 null(UI 转不确定态进度条)。
|
||||
double? get fraction =>
|
||||
total > 0 ? (sent / total).clamp(0.0, 1.0).toDouble() : null;
|
||||
}
|
||||
|
||||
/// 进度事件总线:引擎 onProgress 回调 → 广播流(行级 StreamBuilder 按
|
||||
/// taskId 过滤消费,避免整表重建风暴)。
|
||||
class UploadProgressHub {
|
||||
final StreamController<UploadTaskProgress> _controller =
|
||||
StreamController<UploadTaskProgress>.broadcast();
|
||||
|
||||
/// 进度事件流(广播;行级按 taskId 过滤)。
|
||||
Stream<UploadTaskProgress> get stream => _controller.stream;
|
||||
|
||||
/// 引擎回调挂接点(uploadEngineProvider 内接线)。
|
||||
void emit(String taskId, int sent, int total) =>
|
||||
_controller.add(UploadTaskProgress(taskId: taskId, sent: sent, total: total));
|
||||
|
||||
void dispose() => _controller.close();
|
||||
}
|
||||
|
||||
/// 进度总线 Provider。
|
||||
final uploadProgressHubProvider = Provider<UploadProgressHub>((ref) {
|
||||
final hub = UploadProgressHub();
|
||||
ref.onDispose(hub.dispose);
|
||||
return hub;
|
||||
});
|
||||
|
||||
/// 传输引擎(裸 Dio 直传 presign;并行/保留期配置见 upload_engine.dart
|
||||
/// 的 dart-define 常量)。
|
||||
/// 的 dart-define 常量;进度经 [UploadProgressHub] 广播)。
|
||||
final uploadEngineProvider = Provider<UploadEngine>((ref) {
|
||||
final hub = ref.watch(uploadProgressHubProvider);
|
||||
final engine = UploadEngine(
|
||||
ref.watch(taskStoreProvider),
|
||||
ref.watch(apiClientProvider),
|
||||
ref.watch(deviceIdProvider),
|
||||
onProgress: (taskId, sent, total) => hub.emit(taskId, sent, total),
|
||||
);
|
||||
ref.onDispose(engine.dispose);
|
||||
return engine;
|
||||
|
||||
@@ -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]!;
|
||||
@@ -0,0 +1,75 @@
|
||||
/// 任务状态/上传域错误码 → 本地化文案(P3 计划 Task 9)。
|
||||
///
|
||||
/// 队列/连拍页专用扩展:契约错误码复用 core/error_l10n 的映射,此处
|
||||
/// 补齐上传域客户端伪码(准备阶段 [PrepErrorCodes]、引擎源缺失伪码)
|
||||
/// 与队列高频契约码(ALBUM_UPLOAD_DISABLED 等)。
|
||||
library;
|
||||
|
||||
import '../../core/error_codes.dart';
|
||||
import '../../core/error_l10n.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../albums/models.dart';
|
||||
import 'preparer.dart';
|
||||
import 'task_state.dart';
|
||||
import 'upload_engine.dart';
|
||||
|
||||
extension AppLocalizationsUploadX on AppLocalizations {
|
||||
/// 任务状态标签(chip/文案)。
|
||||
String labelForTaskState(UploadTaskState state) => switch (state) {
|
||||
UploadTaskState.preparing => taskStatePreparing,
|
||||
UploadTaskState.preparationFailed => taskStatePreparationFailed,
|
||||
UploadTaskState.queued => taskStateQueued,
|
||||
UploadTaskState.uploading => taskStateUploading,
|
||||
UploadTaskState.uploadedConfirming => taskStateUploadedConfirming,
|
||||
UploadTaskState.completed => taskStateCompleted,
|
||||
UploadTaskState.waitingNetwork => taskStateWaitingNetwork,
|
||||
UploadTaskState.retryableFailed => taskStateRetryableFailed,
|
||||
UploadTaskState.cancelled => taskStateCancelled,
|
||||
};
|
||||
|
||||
/// 任务 lastError → 展示文案(上传域伪码 + 契约码;未映射回退
|
||||
/// error_l10n 的通用解析)。
|
||||
String messageForTaskError(String code) {
|
||||
switch (code) {
|
||||
case PrepErrorCodes.sourceUnreadable:
|
||||
return errorPrepSourceUnreadable;
|
||||
case PrepErrorCodes.unsupportedType:
|
||||
return errorPrepUnsupportedType;
|
||||
case PrepErrorCodes.tooLarge:
|
||||
return errorPrepTooLarge;
|
||||
case PrepErrorCodes.copyFailed:
|
||||
return errorPrepCopyFailed;
|
||||
case UploadEngine.sourceMissingCode:
|
||||
return errorUploadSourceMissing;
|
||||
case ErrorCodes.albumUploadDisabled:
|
||||
return errorAlbumUploadDisabled;
|
||||
case ErrorCodes.uploadSessionConflict:
|
||||
return errorUploadSessionConflict;
|
||||
case ErrorCodes.uploadVerifyFailed:
|
||||
return errorUploadVerifyFailed;
|
||||
default:
|
||||
return messageForErrorCode(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 相册展示标题解析:当前 locale → 相册默认语言 → 首条 → 相册 ID。
|
||||
///
|
||||
/// locale 匹配按语言前缀(App locale `zh`/`en` vs 契约 `zh-CN`/`en`)。
|
||||
String albumDisplayTitle(AlbumSummary album, String localeName) {
|
||||
final lang = localeName.split('-').first.toLowerCase();
|
||||
AlbumTranslation? match;
|
||||
for (final t in album.translations) {
|
||||
if (t.locale.toLowerCase() == localeName.toLowerCase() ||
|
||||
t.locale.toLowerCase().split('-').first == lang) {
|
||||
match = t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (match != null) return match.title;
|
||||
for (final t in album.translations) {
|
||||
if (t.locale == album.defaultLocale) return t.title;
|
||||
}
|
||||
if (album.translations.isNotEmpty) return album.translations.first.title;
|
||||
return album.id;
|
||||
}
|
||||
@@ -237,6 +237,11 @@ class TaskStore {
|
||||
/// 关闭底层数据库(App 退出/测试 tearDown)。
|
||||
Future<void> close() => _db.close();
|
||||
|
||||
/// 表写通知流(drift 对 insert/update/delete 自动发事件;队列 UI 的
|
||||
/// 节流刷新挂接点——引擎/准备器每次落库都会触发)。
|
||||
Stream<void> get changes =>
|
||||
_db.tableUpdates().map<void>((Set<TableUpdate> _) {});
|
||||
|
||||
/// 时间戳统一 UTC 落库(datetime 为 ISO-8601 文本存储:UTC 值以 `Z`
|
||||
/// 结尾、本地值带 `+hh:mm` 后缀,混存会让 SQL 文本比较错位一个时区
|
||||
/// 偏移——全部归一 UTC 后字典序即时间序)。
|
||||
|
||||
Reference in New Issue
Block a user