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
+10 -2
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/api_client.dart';
import '../features/uploads/engine_scope.dart';
import '../l10n/app_localizations.dart';
import '../pages/home_page.dart';
import '../pages/login_page.dart';
@@ -14,12 +15,15 @@ import 'theme.dart';
final GlobalKey<NavigatorState> rootNavigatorKey =
GlobalKey<NavigatorState>();
/// App 根组件:主题 + l10n 委托 + 命名路由 + 401 驱逐接线。
/// App 根组件:主题 + l10n 委托 + 命名路由 + 401 驱逐接线 + 引擎生命周期。
///
/// 基线路由为命名路由(/login → /home → /password-change);随页面增长再
/// 评估是否迁移 go_router,当前保持零额外依赖。
/// initState 注册 [ApiClient.onUnauthorized]:任意业务端点 401(会话过期)
/// 即清空栈回到 /login(登录/修改密码端点自身的 401 不触发,见 ApiClient)。
/// 即清空栈回到 /login(登录/修改密码端点自身的 401 不触发,见 ApiClient),
/// 并同步刷新会话账号(引擎 stop,任务保留库内)。
/// build watch [uploadEngineScopeProvider]——App 存续期间引擎随登录态
/// start/stop(Task 9 接线,见 engine_scope.dart)。
class PhotoLiveApp extends ConsumerStatefulWidget {
const PhotoLiveApp({super.key});
@@ -34,12 +38,16 @@ class _PhotoLiveAppState extends ConsumerState<PhotoLiveApp> {
ref.read(apiClientProvider).onUnauthorized = () {
rootNavigatorKey.currentState
?.pushNamedAndRemoveUntil('/login', (route) => false);
// 401 已清会话(ApiClient):刷新账号观察 → 引擎 stop。
ref.read(sessionAccountProvider.notifier).refresh();
};
}
@override
Widget build(BuildContext context) {
final locale = ref.watch(localeSettingsProvider);
// 引擎生命周期绑定(常驻 watch;账号在场 start / 离场 stop)。
ref.watch(uploadEngineScopeProvider);
return MaterialApp(
navigatorKey: rootNavigatorKey,
onGenerateTitle: (context) => AppLocalizations.of(context).appName,
@@ -0,0 +1,57 @@
/// POST_NOTIFICATIONS 运行时权限门(P3 计划 Task 9)。
///
/// Android 13+(API 33)起,上传前台服务通知需运行时授权;未授权时
/// 传输继续、仅通知不显示——**静默降级,永不阻断入队**(技术方案 §6.3)。
///
/// 实现取舍(有意不引入 permission_handler):本 App 全部运行时权限需求
/// 仅此一项(camera/image_picker 插件自管权限),为单个权限引入
/// permission_handler 全家桶(AndroidX 传递依赖 + iOS Podfile 宏配置)
/// 不成比例——复用既有原生桥接模式(Task 7 UploadPlugin 同款),自建
/// MethodChannel `photolive/permissions`(MainActivity 内 PermissionPlugin
/// 约 60 行 Kotlin,SDK<33 直接返回 granted)。后续再增权限需求时评估
/// 插件化。
///
/// 进程内单次语义:首次入队动作(连拍/相册选择)时请求一次,之后不再
/// 打扰;拒绝或通道失败(iOS/桌面无此通道)一律静默。
library;
import 'dart:io';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 通知权限门(注入 [channel]/[isAndroid] 供测试)。
class NotificationPermission {
NotificationPermission({
MethodChannel? channel,
bool? isAndroid,
}) : _channel = channel ?? const MethodChannel(kChannelName),
_isAndroid = isAndroid ?? (!kIsWeb && Platform.isAndroid);
/// 平台通道名(与 Kotlin `PermissionPlugin.CHANNEL` 一致)。
static const String kChannelName = 'photolive/permissions';
final MethodChannel _channel;
final bool _isAndroid;
bool _requested = false;
/// 确保已请求一次(首次入队动作调用;幂等)。
///
/// 非 Android 平台直接跳过(iOS 后台传输不依赖通知权限);通道调用
/// 失败/拒绝静默吞掉——上传链路不因通知权限降级而中断。
Future<void> ensureRequested() async {
if (_requested) return;
_requested = true;
if (!_isAndroid) return;
try {
await _channel.invokeMethod<String>('requestPostNotifications');
} on Object {
// 通道缺失(iOS/桌面)或请求异常:静默降级。
}
}
}
/// 生产 Provider(队列/连拍页经 ref.read 一次性调用)。
final notificationPermissionProvider =
Provider<NotificationPermission>((ref) => NotificationPermission());
@@ -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 后字典序即时间序)。
+56 -1
View File
@@ -117,5 +117,60 @@
"uploadStateConfirmed": "Confirmed",
"uploadStateCancelled": "Cancelled",
"uploadStateExpired": "Expired",
"thisDevice": "This device"
"thisDevice": "This device",
"queueAlbumFilterAll": "All albums",
"queueStateAll": "All",
"queueStatePreparing": "Preparing",
"queueStateQueued": "Queued",
"queueStateUploading": "Uploading",
"queueStateWaitingNetwork": "Waiting for network",
"queueStateFailed": "Failed",
"queueStateCompleted": "Done",
"queueTimeAll": "Any time",
"queueTimeToday": "Today",
"queueTimeLast7Days": "Last 7 days",
"queueTimeLast30Days": "Last 30 days",
"queueEmpty": "No upload tasks",
"queueRetryAllFailed": "Retry all failed",
"queueAttemptCount": "{count} attempt(s)",
"@queueAttemptCount": { "placeholders": { "count": { "type": "int" } } },
"taskStatePreparing": "Preparing",
"taskStatePreparationFailed": "Preparation failed",
"taskStateQueued": "Queued",
"taskStateUploading": "Uploading",
"taskStateUploadedConfirming": "Confirming",
"taskStateCompleted": "Completed",
"taskStateWaitingNetwork": "Waiting for network",
"taskStateRetryableFailed": "Upload failed",
"taskStateCancelled": "Cancelled",
"errorPrepSourceUnreadable": "Could not read the selected photo. Please reselect",
"errorPrepUnsupportedType": "Only JPG, PNG and HEIC images are supported",
"errorPrepTooLarge": "Photo exceeds the 50MB limit",
"errorPrepCopyFailed": "Failed to copy the photo. Please retry",
"errorUploadSourceMissing": "Source file is missing. Please reselect",
"errorAlbumUploadDisabled": "Album uploads are disabled",
"errorUploadSessionConflict": "Upload session conflict. Please retry",
"errorUploadVerifyFailed": "Upload verification failed. Will retry",
"captureTitle": "Continuous capture",
"captureCategoryDefault": "Default category",
"captureSwitchHint": "Switching only affects photos captured afterwards",
"capturePendingBadge": "Pending {count}",
"@capturePendingBadge": { "placeholders": { "count": { "type": "int" } } },
"captureFailedBadge": "Failed {count}",
"@captureFailedBadge": { "placeholders": { "count": { "type": "int" } } },
"capturePickFromGallery": "Pick from gallery",
"captureShutter": "Capture",
"captureExitTitle": "Leave shooting?",
"captureExitBody": "{count} photo(s) are still uploading. Tasks will continue in the background after you leave.",
"@captureExitBody": { "placeholders": { "count": { "type": "int" } } },
"captureKeepShooting": "Keep shooting",
"captureExitConfirm": "Leave",
"cameraPermissionDenied": "Camera unavailable. Allow camera access in system settings",
"cameraInitFailed": "Camera failed to initialize",
"uploadActionCapture": "Capture and upload",
"uploadActionPick": "Pick from gallery",
"uploadAction": "Upload",
"uploadEnqueuedCount": "Added {count} photo(s) to the upload queue",
"@uploadEnqueuedCount": { "placeholders": { "count": { "type": "int" } } },
"uploadPickNone": "No photos selected"
}
+300
View File
@@ -805,6 +805,306 @@ abstract class AppLocalizations {
/// In zh, this message translates to:
/// **'本设备'**
String get thisDevice;
/// No description provided for @queueAlbumFilterAll.
///
/// In zh, this message translates to:
/// **'全部相册'**
String get queueAlbumFilterAll;
/// No description provided for @queueStateAll.
///
/// In zh, this message translates to:
/// **'全部'**
String get queueStateAll;
/// No description provided for @queueStatePreparing.
///
/// In zh, this message translates to:
/// **'准备中'**
String get queueStatePreparing;
/// No description provided for @queueStateQueued.
///
/// In zh, this message translates to:
/// **'待传'**
String get queueStateQueued;
/// No description provided for @queueStateUploading.
///
/// In zh, this message translates to:
/// **'上传中'**
String get queueStateUploading;
/// No description provided for @queueStateWaitingNetwork.
///
/// In zh, this message translates to:
/// **'等待网络'**
String get queueStateWaitingNetwork;
/// No description provided for @queueStateFailed.
///
/// In zh, this message translates to:
/// **'失败'**
String get queueStateFailed;
/// No description provided for @queueStateCompleted.
///
/// In zh, this message translates to:
/// **'已完成'**
String get queueStateCompleted;
/// No description provided for @queueTimeAll.
///
/// In zh, this message translates to:
/// **'全部时间'**
String get queueTimeAll;
/// No description provided for @queueTimeToday.
///
/// In zh, this message translates to:
/// **'今天'**
String get queueTimeToday;
/// No description provided for @queueTimeLast7Days.
///
/// In zh, this message translates to:
/// **'近7天'**
String get queueTimeLast7Days;
/// No description provided for @queueTimeLast30Days.
///
/// In zh, this message translates to:
/// **'近30天'**
String get queueTimeLast30Days;
/// No description provided for @queueEmpty.
///
/// In zh, this message translates to:
/// **'暂无上传任务'**
String get queueEmpty;
/// No description provided for @queueRetryAllFailed.
///
/// In zh, this message translates to:
/// **'一键重试全部失败'**
String get queueRetryAllFailed;
/// No description provided for @queueAttemptCount.
///
/// In zh, this message translates to:
/// **'已尝试 {count} 次'**
String queueAttemptCount(int count);
/// No description provided for @taskStatePreparing.
///
/// In zh, this message translates to:
/// **'准备中'**
String get taskStatePreparing;
/// No description provided for @taskStatePreparationFailed.
///
/// In zh, this message translates to:
/// **'准备失败'**
String get taskStatePreparationFailed;
/// No description provided for @taskStateQueued.
///
/// In zh, this message translates to:
/// **'待上传'**
String get taskStateQueued;
/// No description provided for @taskStateUploading.
///
/// In zh, this message translates to:
/// **'上传中'**
String get taskStateUploading;
/// No description provided for @taskStateUploadedConfirming.
///
/// In zh, this message translates to:
/// **'确认中'**
String get taskStateUploadedConfirming;
/// No description provided for @taskStateCompleted.
///
/// In zh, this message translates to:
/// **'已完成'**
String get taskStateCompleted;
/// No description provided for @taskStateWaitingNetwork.
///
/// In zh, this message translates to:
/// **'等待网络'**
String get taskStateWaitingNetwork;
/// No description provided for @taskStateRetryableFailed.
///
/// In zh, this message translates to:
/// **'上传失败'**
String get taskStateRetryableFailed;
/// No description provided for @taskStateCancelled.
///
/// In zh, this message translates to:
/// **'已取消'**
String get taskStateCancelled;
/// No description provided for @errorPrepSourceUnreadable.
///
/// In zh, this message translates to:
/// **'无法读取所选图片,请重新选择'**
String get errorPrepSourceUnreadable;
/// No description provided for @errorPrepUnsupportedType.
///
/// In zh, this message translates to:
/// **'仅支持 JPG/PNG/HEIC 图片'**
String get errorPrepUnsupportedType;
/// No description provided for @errorPrepTooLarge.
///
/// In zh, this message translates to:
/// **'图片超过 50MB 上限'**
String get errorPrepTooLarge;
/// No description provided for @errorPrepCopyFailed.
///
/// In zh, this message translates to:
/// **'保存图片副本失败,请重试'**
String get errorPrepCopyFailed;
/// No description provided for @errorUploadSourceMissing.
///
/// In zh, this message translates to:
/// **'源文件已缺失,请重新选择'**
String get errorUploadSourceMissing;
/// No description provided for @errorAlbumUploadDisabled.
///
/// In zh, this message translates to:
/// **'相册已关闭上传'**
String get errorAlbumUploadDisabled;
/// No description provided for @errorUploadSessionConflict.
///
/// In zh, this message translates to:
/// **'上传会话冲突,请重试'**
String get errorUploadSessionConflict;
/// No description provided for @errorUploadVerifyFailed.
///
/// In zh, this message translates to:
/// **'上传核验失败,将自动重试'**
String get errorUploadVerifyFailed;
/// No description provided for @captureTitle.
///
/// In zh, this message translates to:
/// **'连拍上传'**
String get captureTitle;
/// No description provided for @captureCategoryDefault.
///
/// In zh, this message translates to:
/// **'默认分类'**
String get captureCategoryDefault;
/// No description provided for @captureSwitchHint.
///
/// In zh, this message translates to:
/// **'切换分类仅影响之后拍摄的照片'**
String get captureSwitchHint;
/// No description provided for @capturePendingBadge.
///
/// In zh, this message translates to:
/// **'待传 {count}'**
String capturePendingBadge(int count);
/// No description provided for @captureFailedBadge.
///
/// In zh, this message translates to:
/// **'失败 {count}'**
String captureFailedBadge(int count);
/// No description provided for @capturePickFromGallery.
///
/// In zh, this message translates to:
/// **'从相册选择'**
String get capturePickFromGallery;
/// No description provided for @captureShutter.
///
/// In zh, this message translates to:
/// **'拍摄'**
String get captureShutter;
/// No description provided for @captureExitTitle.
///
/// In zh, this message translates to:
/// **'退出拍摄?'**
String get captureExitTitle;
/// No description provided for @captureExitBody.
///
/// In zh, this message translates to:
/// **'还有 {count} 张照片未上传,退出后任务将在后台继续。'**
String captureExitBody(int count);
/// No description provided for @captureKeepShooting.
///
/// In zh, this message translates to:
/// **'继续拍摄'**
String get captureKeepShooting;
/// No description provided for @captureExitConfirm.
///
/// In zh, this message translates to:
/// **'退出'**
String get captureExitConfirm;
/// No description provided for @cameraPermissionDenied.
///
/// In zh, this message translates to:
/// **'无法访问相机,请在系统设置中允许相机权限'**
String get cameraPermissionDenied;
/// No description provided for @cameraInitFailed.
///
/// In zh, this message translates to:
/// **'相机初始化失败'**
String get cameraInitFailed;
/// No description provided for @uploadActionCapture.
///
/// In zh, this message translates to:
/// **'拍照上传'**
String get uploadActionCapture;
/// No description provided for @uploadActionPick.
///
/// In zh, this message translates to:
/// **'从相册选择'**
String get uploadActionPick;
/// No description provided for @uploadAction.
///
/// In zh, this message translates to:
/// **'上传'**
String get uploadAction;
/// No description provided for @uploadEnqueuedCount.
///
/// In zh, this message translates to:
/// **'已加入 {count} 张上传队列'**
String uploadEnqueuedCount(int count);
/// No description provided for @uploadPickNone.
///
/// In zh, this message translates to:
/// **'未选择照片'**
String get uploadPickNone;
}
class _AppLocalizationsDelegate
@@ -370,4 +370,171 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get thisDevice => 'This device';
@override
String get queueAlbumFilterAll => 'All albums';
@override
String get queueStateAll => 'All';
@override
String get queueStatePreparing => 'Preparing';
@override
String get queueStateQueued => 'Queued';
@override
String get queueStateUploading => 'Uploading';
@override
String get queueStateWaitingNetwork => 'Waiting for network';
@override
String get queueStateFailed => 'Failed';
@override
String get queueStateCompleted => 'Done';
@override
String get queueTimeAll => 'Any time';
@override
String get queueTimeToday => 'Today';
@override
String get queueTimeLast7Days => 'Last 7 days';
@override
String get queueTimeLast30Days => 'Last 30 days';
@override
String get queueEmpty => 'No upload tasks';
@override
String get queueRetryAllFailed => 'Retry all failed';
@override
String queueAttemptCount(int count) {
return '$count attempt(s)';
}
@override
String get taskStatePreparing => 'Preparing';
@override
String get taskStatePreparationFailed => 'Preparation failed';
@override
String get taskStateQueued => 'Queued';
@override
String get taskStateUploading => 'Uploading';
@override
String get taskStateUploadedConfirming => 'Confirming';
@override
String get taskStateCompleted => 'Completed';
@override
String get taskStateWaitingNetwork => 'Waiting for network';
@override
String get taskStateRetryableFailed => 'Upload failed';
@override
String get taskStateCancelled => 'Cancelled';
@override
String get errorPrepSourceUnreadable =>
'Could not read the selected photo. Please reselect';
@override
String get errorPrepUnsupportedType =>
'Only JPG, PNG and HEIC images are supported';
@override
String get errorPrepTooLarge => 'Photo exceeds the 50MB limit';
@override
String get errorPrepCopyFailed => 'Failed to copy the photo. Please retry';
@override
String get errorUploadSourceMissing =>
'Source file is missing. Please reselect';
@override
String get errorAlbumUploadDisabled => 'Album uploads are disabled';
@override
String get errorUploadSessionConflict =>
'Upload session conflict. Please retry';
@override
String get errorUploadVerifyFailed =>
'Upload verification failed. Will retry';
@override
String get captureTitle => 'Continuous capture';
@override
String get captureCategoryDefault => 'Default category';
@override
String get captureSwitchHint =>
'Switching only affects photos captured afterwards';
@override
String capturePendingBadge(int count) {
return 'Pending $count';
}
@override
String captureFailedBadge(int count) {
return 'Failed $count';
}
@override
String get capturePickFromGallery => 'Pick from gallery';
@override
String get captureShutter => 'Capture';
@override
String get captureExitTitle => 'Leave shooting?';
@override
String captureExitBody(int count) {
return '$count photo(s) are still uploading. Tasks will continue in the background after you leave.';
}
@override
String get captureKeepShooting => 'Keep shooting';
@override
String get captureExitConfirm => 'Leave';
@override
String get cameraPermissionDenied =>
'Camera unavailable. Allow camera access in system settings';
@override
String get cameraInitFailed => 'Camera failed to initialize';
@override
String get uploadActionCapture => 'Capture and upload';
@override
String get uploadActionPick => 'Pick from gallery';
@override
String get uploadAction => 'Upload';
@override
String uploadEnqueuedCount(int count) {
return 'Added $count photo(s) to the upload queue';
}
@override
String get uploadPickNone => 'No photos selected';
}
@@ -361,4 +361,164 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get thisDevice => '本设备';
@override
String get queueAlbumFilterAll => '全部相册';
@override
String get queueStateAll => '全部';
@override
String get queueStatePreparing => '准备中';
@override
String get queueStateQueued => '待传';
@override
String get queueStateUploading => '上传中';
@override
String get queueStateWaitingNetwork => '等待网络';
@override
String get queueStateFailed => '失败';
@override
String get queueStateCompleted => '已完成';
@override
String get queueTimeAll => '全部时间';
@override
String get queueTimeToday => '今天';
@override
String get queueTimeLast7Days => '近7天';
@override
String get queueTimeLast30Days => '近30天';
@override
String get queueEmpty => '暂无上传任务';
@override
String get queueRetryAllFailed => '一键重试全部失败';
@override
String queueAttemptCount(int count) {
return '已尝试 $count 次';
}
@override
String get taskStatePreparing => '准备中';
@override
String get taskStatePreparationFailed => '准备失败';
@override
String get taskStateQueued => '待上传';
@override
String get taskStateUploading => '上传中';
@override
String get taskStateUploadedConfirming => '确认中';
@override
String get taskStateCompleted => '已完成';
@override
String get taskStateWaitingNetwork => '等待网络';
@override
String get taskStateRetryableFailed => '上传失败';
@override
String get taskStateCancelled => '已取消';
@override
String get errorPrepSourceUnreadable => '无法读取所选图片,请重新选择';
@override
String get errorPrepUnsupportedType => '仅支持 JPG/PNG/HEIC 图片';
@override
String get errorPrepTooLarge => '图片超过 50MB 上限';
@override
String get errorPrepCopyFailed => '保存图片副本失败,请重试';
@override
String get errorUploadSourceMissing => '源文件已缺失,请重新选择';
@override
String get errorAlbumUploadDisabled => '相册已关闭上传';
@override
String get errorUploadSessionConflict => '上传会话冲突,请重试';
@override
String get errorUploadVerifyFailed => '上传核验失败,将自动重试';
@override
String get captureTitle => '连拍上传';
@override
String get captureCategoryDefault => '默认分类';
@override
String get captureSwitchHint => '切换分类仅影响之后拍摄的照片';
@override
String capturePendingBadge(int count) {
return '待传 $count';
}
@override
String captureFailedBadge(int count) {
return '失败 $count';
}
@override
String get capturePickFromGallery => '从相册选择';
@override
String get captureShutter => '拍摄';
@override
String get captureExitTitle => '退出拍摄?';
@override
String captureExitBody(int count) {
return '还有 $count 张照片未上传,退出后任务将在后台继续。';
}
@override
String get captureKeepShooting => '继续拍摄';
@override
String get captureExitConfirm => '退出';
@override
String get cameraPermissionDenied => '无法访问相机,请在系统设置中允许相机权限';
@override
String get cameraInitFailed => '相机初始化失败';
@override
String get uploadActionCapture => '拍照上传';
@override
String get uploadActionPick => '从相册选择';
@override
String get uploadAction => '上传';
@override
String uploadEnqueuedCount(int count) {
return '已加入 $count 张上传队列';
}
@override
String get uploadPickNone => '未选择照片';
}
+56 -1
View File
@@ -117,5 +117,60 @@
"uploadStateConfirmed": "已确认",
"uploadStateCancelled": "已取消",
"uploadStateExpired": "已过期",
"thisDevice": "本设备"
"thisDevice": "本设备",
"queueAlbumFilterAll": "全部相册",
"queueStateAll": "全部",
"queueStatePreparing": "准备中",
"queueStateQueued": "待传",
"queueStateUploading": "上传中",
"queueStateWaitingNetwork": "等待网络",
"queueStateFailed": "失败",
"queueStateCompleted": "已完成",
"queueTimeAll": "全部时间",
"queueTimeToday": "今天",
"queueTimeLast7Days": "近7天",
"queueTimeLast30Days": "近30天",
"queueEmpty": "暂无上传任务",
"queueRetryAllFailed": "一键重试全部失败",
"queueAttemptCount": "已尝试 {count} 次",
"@queueAttemptCount": { "placeholders": { "count": { "type": "int" } } },
"taskStatePreparing": "准备中",
"taskStatePreparationFailed": "准备失败",
"taskStateQueued": "待上传",
"taskStateUploading": "上传中",
"taskStateUploadedConfirming": "确认中",
"taskStateCompleted": "已完成",
"taskStateWaitingNetwork": "等待网络",
"taskStateRetryableFailed": "上传失败",
"taskStateCancelled": "已取消",
"errorPrepSourceUnreadable": "无法读取所选图片,请重新选择",
"errorPrepUnsupportedType": "仅支持 JPG/PNG/HEIC 图片",
"errorPrepTooLarge": "图片超过 50MB 上限",
"errorPrepCopyFailed": "保存图片副本失败,请重试",
"errorUploadSourceMissing": "源文件已缺失,请重新选择",
"errorAlbumUploadDisabled": "相册已关闭上传",
"errorUploadSessionConflict": "上传会话冲突,请重试",
"errorUploadVerifyFailed": "上传核验失败,将自动重试",
"captureTitle": "连拍上传",
"captureCategoryDefault": "默认分类",
"captureSwitchHint": "切换分类仅影响之后拍摄的照片",
"capturePendingBadge": "待传 {count}",
"@capturePendingBadge": { "placeholders": { "count": { "type": "int" } } },
"captureFailedBadge": "失败 {count}",
"@captureFailedBadge": { "placeholders": { "count": { "type": "int" } } },
"capturePickFromGallery": "从相册选择",
"captureShutter": "拍摄",
"captureExitTitle": "退出拍摄?",
"captureExitBody": "还有 {count} 张照片未上传,退出后任务将在后台继续。",
"@captureExitBody": { "placeholders": { "count": { "type": "int" } } },
"captureKeepShooting": "继续拍摄",
"captureExitConfirm": "退出",
"cameraPermissionDenied": "无法访问相机,请在系统设置中允许相机权限",
"cameraInitFailed": "相机初始化失败",
"uploadActionCapture": "拍照上传",
"uploadActionPick": "从相册选择",
"uploadAction": "上传",
"uploadEnqueuedCount": "已加入 {count} 张上传队列",
"@uploadEnqueuedCount": { "placeholders": { "count": { "type": "int" } } },
"uploadPickNone": "未选择照片"
}
@@ -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
+545
View File
@@ -0,0 +1,545 @@
/// 连拍页(P3 计划 Task 9,R17 连拍入队 / O01 现场反馈)。
///
/// 进入时选定目标相册与分类(相册详情 FAB 传入);快门每张立即经准备器
/// 入队(preparing→queued,不逐张确认);常驻信息层:相册名 + 分类(可
/// 切换,**仅影响之后拍摄**——引擎只读任务行归属,切换不改既有任务)+
/// 待传/失败徽章(TaskStore 写通知实时刷新)。相册多选按钮补充入队;
/// 退出时若有待传任务弹确认(任务在后台继续——Android 原生 WorkManager
/// 承担,见 native_transport.dart)。
///
/// 相机/相册经注入点([captureSourceFactoryProvider]/[multiImagePickerProvider])
/// 隔离平台通道,widget 测试注入替身驱动。
library;
import 'dart:async';
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../features/albums/models.dart';
import '../features/uploads/enqueue.dart';
import '../features/uploads/queue_controller.dart';
import '../l10n/app_localizations.dart';
// ── 相机源抽象(生产=camera 插件;测试替身) ─────────────────────────────
/// 拍摄源:初始化/拍摄/预览/释放。
abstract class CaptureSource {
/// 初始化(权限缺失/失败抛异常;权限拒绝抛 [CameraException])。
Future<void> initialize();
/// 拍摄一张(返回源文件)。
Future<XFile> capture();
/// 取景预览。
Widget buildPreview();
/// 释放底层资源。
Future<void> dispose();
}
/// 生产拍摄源:camera 插件(后摄优先,最高分辨率,静音连拍)。
class CameraPluginSource implements CaptureSource {
CameraPluginSource._(this._controller);
/// 创建并初始化(后摄优先)。
static Future<CameraPluginSource> create() async {
final cameras = await availableCameras();
if (cameras.isEmpty) {
throw CameraException('noCamera', 'no camera available');
}
final back = cameras.firstWhere(
(c) => c.lensDirection == CameraLensDirection.back,
orElse: () => cameras.first,
);
final controller = CameraController(back, ResolutionPreset.max,
enableAudio: false);
await controller.initialize();
return CameraPluginSource._(controller);
}
final CameraController _controller;
@override
Future<void> initialize() async {}
@override
Future<XFile> capture() => _controller.takePicture();
@override
Widget buildPreview() => CameraPreview(_controller);
@override
Future<void> dispose() => _controller.dispose();
}
/// 拍摄源工厂 Provider(测试 override 为替身)。
final captureSourceFactoryProvider =
Provider<Future<CaptureSource> Function()>((ref) => CameraPluginSource.create);
// ── 连拍页 ────────────────────────────────────────────────────────────────
/// 连拍页参数:目标相册(相册详情 FAB 进入时固定)与分类候选。
class CaptureTarget {
const CaptureTarget({
required this.accountId,
required this.albumId,
required this.albumTitle,
this.categories = const <Category>[],
});
/// 入队账号(TaskStore 隔离键)。
final String accountId;
/// 目标相册 ID。
final String albumId;
/// 目标相册展示名(信息层常驻显示)。
final String albumTitle;
/// 相册分类(空 = 无可切换项,仅默认分类)。
final List<Category> categories;
}
/// 连拍页。
class CapturePage extends ConsumerStatefulWidget {
const CapturePage({required this.target, super.key});
final CaptureTarget target;
@override
ConsumerState<CapturePage> createState() => _CapturePageState();
}
enum _CameraPhase { loading, ready, denied, failed }
class _CapturePageState extends ConsumerState<CapturePage> {
CaptureSource? _source;
_CameraPhase _phase = _CameraPhase.loading;
/// 当前分类(null = 相册默认分类;切换仅影响之后拍摄)。
String? _categoryId;
/// 连拍串行链:快门连点逐张落地(相机硬件本就串行)。
Future<void> _shotTail = Future<void>.value();
@override
void initState() {
super.initState();
_initCamera();
}
@override
void dispose() {
_source?.dispose().ignore();
super.dispose();
}
Future<void> _initCamera() async {
setState(() => _phase = _CameraPhase.loading);
try {
final source = await ref.read(captureSourceFactoryProvider)();
await source.initialize();
if (!mounted) {
await source.dispose();
return;
}
_source = source;
setState(() => _phase = _CameraPhase.ready);
} on Object catch (error) {
if (!mounted) return;
setState(() => _phase = _isPermissionDenied(error)
? _CameraPhase.denied
: _CameraPhase.failed);
}
}
/// camera 插件的权限拒绝错误码族(CameraAccessDenied /
/// CameraAccessDeniedWithoutD restriction 等)。
static bool _isPermissionDenied(Object error) =>
error is CameraException &&
(error.code.toLowerCase().contains('denied') ||
error.code.toLowerCase().contains('permission'));
/// 快门:立即入队(R17 不逐张确认);连点串行不丢张。
void _shoot() {
final source = _source;
if (source == null || _phase != _CameraPhase.ready) return;
final categoryId = _categoryId;
_shotTail = _shotTail.then((_) async {
final photo = await source.capture();
if (!mounted) return;
await enqueueForUpload(
ref,
accountId: widget.target.accountId,
albumId: widget.target.albumId,
categoryId: categoryId,
sourcePaths: [photo.path],
);
}).catchError((Object _) {
// 单张失败吞掉(连拍不中断;失败任务/错误在徽章与队列页可见)。
});
}
/// 相册多选入队(目标 = 当前相册与分类)。
Future<void> _pickFromGallery() async {
final paths = await ref.read(multiImagePickerProvider)();
if (paths.isEmpty || !mounted) return;
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final count = await enqueueForUpload(
ref,
accountId: widget.target.accountId,
albumId: widget.target.albumId,
categoryId: _categoryId,
sourcePaths: paths,
);
if (!mounted) return;
messenger.showSnackBar(
SnackBar(content: Text(l10n.uploadEnqueuedCount(count))),
);
}
void _switchCategory(String? categoryId) {
if (categoryId == _categoryId) return;
setState(() => _categoryId = categoryId);
// 归属不变提示(A15):仅影响之后拍摄。
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context).captureSwitchHint)),
);
}
Future<void> _confirmExit() async {
final l10n = AppLocalizations.of(context);
final leave = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(l10n.captureExitTitle),
content: Text(
l10n.captureExitBody(pendingCountOf(ref.read(taskCountsProvider))),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(l10n.captureKeepShooting),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(l10n.captureExitConfirm),
),
],
),
);
if (leave == true && mounted) {
Navigator.of(context).pop();
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final counts = ref.watch(taskCountsProvider);
final pending = pendingCountOf(counts);
final failed = failedCountOf(counts);
final categories = widget.target.categories;
return PopScope(
// 有待传任务 → 拦截退出弹确认(后台继续文案);无任务直接退出。
canPop: pending == 0,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
_confirmExit();
},
child: Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black87,
foregroundColor: Colors.white,
title: Text(l10n.captureTitle),
),
body: Stack(
fit: StackFit.expand,
children: [
_buildCameraArea(l10n),
// 常驻信息层:相册 + 分类 + 待传/失败徽章(R17/O01)。
SafeArea(
child: Column(
children: [
_TargetOverlay(
albumTitle: widget.target.albumTitle,
categories: categories,
categoryId: _categoryId,
onCategorySelected: _switchCategory,
pending: pending,
failed: failed,
),
],
),
),
],
),
bottomNavigationBar: _buildBottomBar(l10n),
),
);
}
Widget _buildCameraArea(AppLocalizations l10n) {
final source = _source;
switch (_phase) {
case _CameraPhase.ready:
return source!.buildPreview();
case _CameraPhase.loading:
return const Center(
child: CircularProgressIndicator(color: Colors.white),
);
case _CameraPhase.denied:
return _CameraMessageView(
icon: Icons.no_photography_outlined,
message: l10n.cameraPermissionDenied,
actionLabel: l10n.retry,
onAction: _initCamera,
);
case _CameraPhase.failed:
return _CameraMessageView(
icon: Icons.error_outline,
message: l10n.cameraInitFailed,
actionLabel: l10n.retry,
onAction: _initCamera,
);
}
}
Widget _buildBottomBar(AppLocalizations l10n) {
return BottomAppBar(
color: Colors.black87,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
IconButton(
tooltip: l10n.capturePickFromGallery,
icon: const Icon(Icons.photo_library_outlined,
color: Colors.white),
onPressed: _pickFromGallery,
),
_ShutterButton(onPressed: _shoot, enabled: _phase == _CameraPhase.ready),
// 占位对齐(快门居中)。
const SizedBox(width: 48),
],
),
);
}
}
// ── 信息层组件 ────────────────────────────────────────────────────────────
/// 顶部常驻信息层:相册名 + 分类 chips(切换仅影响之后拍摄)+ 待传/失败
/// 徽章。
class _TargetOverlay extends StatelessWidget {
const _TargetOverlay({
required this.albumTitle,
required this.categories,
required this.categoryId,
required this.onCategorySelected,
required this.pending,
required this.failed,
});
final String albumTitle;
final List<Category> categories;
final String? categoryId;
final void Function(String?) onCategorySelected;
final int pending;
final int failed;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Row(
children: [
const Icon(Icons.photo_album_outlined,
size: 16, color: Colors.white70),
const SizedBox(width: 6),
Expanded(
child: Text(
albumTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white),
),
),
const SizedBox(width: 8),
_Badge(
label: l10n.capturePendingBadge(pending),
color: Colors.white24,
foreground: Colors.white,
),
if (failed > 0) ...[
const SizedBox(width: 6),
_Badge(
label: l10n.captureFailedBadge(failed),
color: Colors.red,
foreground: Colors.white,
),
],
],
),
),
if (categories.isNotEmpty)
SizedBox(
height: 44,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
children: [
ChoiceChip(
label: Text(l10n.captureCategoryDefault),
selected: categoryId == null,
onSelected: (_) => onCategorySelected(null),
labelStyle: const TextStyle(color: Colors.white),
selectedColor: Colors.white24,
backgroundColor: Colors.black38,
),
for (final category in categories) ...[
const SizedBox(width: 8),
ChoiceChip(
label: Text(_categoryLabel(category, l10n)),
selected: categoryId == category.id,
onSelected: (_) => onCategorySelected(category.id),
labelStyle: const TextStyle(color: Colors.white),
selectedColor: Colors.white24,
backgroundColor: Colors.black38,
),
],
],
),
),
],
);
}
static String _categoryLabel(Category category, AppLocalizations l10n) {
final lang = l10n.localeName.split('-').first.toLowerCase();
CategoryTranslation? match;
for (final t in category.translations) {
if (t.locale.toLowerCase() == l10n.localeName.toLowerCase() ||
t.locale.toLowerCase().split('-').first == lang) {
match = t;
break;
}
}
if (match != null) return match.name;
return category.translations.isEmpty
? category.id
: category.translations.first.name;
}
}
/// 徽章(待传/失败计数)。
class _Badge extends StatelessWidget {
const _Badge({
required this.label,
required this.color,
required this.foreground,
});
final String label;
final Color color;
final Color foreground;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(999),
),
child: Text(
label,
style: TextStyle(color: foreground, fontSize: 12),
),
);
}
}
/// 快门按钮(连拍:不进入模态确认,逐张自动入队)。
class _ShutterButton extends StatelessWidget {
const _ShutterButton({required this.onPressed, required this.enabled});
final VoidCallback onPressed;
final bool enabled;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: AppLocalizations.of(context).captureShutter,
child: IconButton(
onPressed: enabled ? onPressed : null,
iconSize: 64,
icon: Container(
width: 64,
height: 64,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 4),
),
child: const Icon(Icons.camera, color: Colors.white),
),
),
);
}
}
/// 相机不可用视图(权限拒绝/初始化失败):说明 + 重试。
class _CameraMessageView extends StatelessWidget {
const _CameraMessageView({
required this.icon,
required this.message,
required this.actionLabel,
required this.onAction,
});
final IconData icon;
final String message;
final String actionLabel;
final VoidCallback onAction;
@override
Widget build(BuildContext context) {
return Container(
color: Colors.black87,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 48, color: Colors.white70),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white),
),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: onAction,
icon: const Icon(Icons.refresh),
label: Text(actionLabel),
),
],
),
),
);
}
}
+4 -39
View File
@@ -2,8 +2,10 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/api_client.dart';
import '../features/uploads/engine_scope.dart';
import '../l10n/app_localizations.dart';
import 'albums_tab.dart';
import 'queue_tab.dart';
/// 主壳:底部导航(相册 / 队列 / 设置)。
class HomePage extends StatefulWidget {
@@ -54,45 +56,6 @@ class _HomePageState extends State<HomePage> {
}
}
/// 上传队列(桩)。Task 9:状态机展示、筛选与重试;当前仅提供
/// 服务端上传历史入口(A19 跨设备记录)。
class QueueTab extends StatelessWidget {
const QueueTab({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: Text(l10n.queue)),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
l10n.queueStubHint,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(height: 16),
FilledButton.tonalIcon(
onPressed: () =>
Navigator.of(context).pushNamed('/server-history'),
icon: const Icon(Icons.history_outlined),
label: Text(l10n.serverHistoryEntry),
),
],
),
),
);
}
}
/// 设置页(P3 Task 3:修改密码入口 + 退出登录;Task 10 补语言切换持久化)。
class SettingsTab extends ConsumerWidget {
const SettingsTab({super.key});
@@ -100,6 +63,8 @@ class SettingsTab extends ConsumerWidget {
Future<void> _logout(BuildContext context, WidgetRef ref) async {
// api.logout 尽力而为(服务端撤销失败也照常本地清会话——客户端内已处理)。
await ref.read(apiClientProvider).logout();
// 会话账号失效 → 引擎 stop(任务保留库内,见 engine_scope.dart)。
await ref.read(sessionAccountProvider.notifier).refresh();
if (!context.mounted) return;
Navigator.of(context).pushNamedAndRemoveUntil('/login', (route) => false);
}
+3
View File
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/api_client.dart';
import '../core/app_exception.dart';
import '../core/error_l10n.dart';
import '../features/uploads/engine_scope.dart';
import '../l10n/app_localizations.dart';
/// 登录页(P3 计划 Task 3)。
@@ -44,6 +45,8 @@ class _LoginPageState extends ConsumerState<LoginPage> {
_accountController.text.trim(),
_passwordController.text,
);
// 登录成功:刷新会话账号 → 引擎以新账号 scope start(Task 9 接线)。
await ref.read(sessionAccountProvider.notifier).refresh();
navigator.pushReplacementNamed('/home');
} on AppException catch (error) {
// 429(AUTH_RATE_LIMITED)等文案经错误码映射;登录页的
+586
View File
@@ -0,0 +1,586 @@
/// 上传队列页(P3 计划 Task 9,需求 4.3 / R17 / O01)。
///
/// 三维筛选(相册下拉 × 状态 chips × 时间快捷)→ [QueueFilter];任务行
/// = 缩略图(受管副本,缺失/已清理显示占位)+ 文件名 + 相册名 + 状态
/// 徽章 + 尝试/错误(本地化错误码)+ 传输进度(进度总线行级订阅)+ 按
/// 状态动作(重试/取消/删除);失败任务存在时底部「一键重试全部失败」。
/// 列表经 TaskStore 写通知 + 200ms 防抖实时刷新(连拍风暴不逐条重建)。
library;
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../features/albums/models.dart';
import '../features/albums/providers.dart';
import '../features/uploads/engine_scope.dart';
import '../features/uploads/enqueue.dart';
import '../features/uploads/providers.dart';
import '../features/uploads/queue_controller.dart';
import '../features/uploads/queue_l10n.dart';
import '../features/uploads/task_state.dart';
import '../features/uploads/task_store.dart';
import '../l10n/app_localizations.dart';
/// 队列 Tab(主壳 IndexedStack 内常驻)。
class QueueTab extends ConsumerWidget {
const QueueTab({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final counts = ref.watch(taskCountsProvider);
final autoRetryable = (counts[UploadTaskState.retryableFailed] ?? 0) +
(counts[UploadTaskState.waitingNetwork] ?? 0);
return Scaffold(
appBar: AppBar(
title: Text(l10n.queue),
actions: [
IconButton(
tooltip: l10n.serverHistoryEntry,
icon: const Icon(Icons.history_outlined),
onPressed: () =>
Navigator.of(context).pushNamed('/server-history'),
),
],
),
body: const Column(
children: [
_FilterBar(),
Expanded(child: _TaskListView()),
],
),
bottomNavigationBar: autoRetryable > 0 ? const _BatchRetryBar() : null,
);
}
}
// ── 筛选栏 ────────────────────────────────────────────────────────────────
/// 筛选栏:相册下拉 + 时间快捷 chips + 状态分组 chips(需求 4.3 三维)。
class _FilterBar extends ConsumerWidget {
const _FilterBar();
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final filter = ref.watch(queueFilterProvider);
final albums = ref.watch(albumListProvider).items;
return Material(
color: theme.colorScheme.surfaceContainerLow,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Column(
children: [
Row(
children: [
Icon(Icons.filter_list,
size: 18, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(width: 8),
Expanded(child: _AlbumDropdown(filter: filter, albums: albums)),
],
),
const SizedBox(height: 4),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final (i, time) in QueueTimeFilter.values.indexed) ...[
if (i > 0) const SizedBox(width: 8),
ChoiceChip(
label: Text(_timeLabel(l10n, time)),
selected: filter.time == time,
onSelected: (_) => ref
.read(queueFilterProvider.notifier)
.setTime(time),
visualDensity: VisualDensity.compact,
),
],
],
),
),
const SizedBox(height: 4),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final (i, state) in QueueStateFilter.values.indexed) ...[
if (i > 0) const SizedBox(width: 8),
ChoiceChip(
label: Text(_stateLabel(l10n, state)),
selected: filter.state == state,
onSelected: (_) => ref
.read(queueFilterProvider.notifier)
.setStateGroup(state),
visualDensity: VisualDensity.compact,
),
],
],
),
),
const SizedBox(height: 4),
],
),
),
);
}
static String _stateLabel(AppLocalizations l10n, QueueStateFilter state) =>
switch (state) {
QueueStateFilter.all => l10n.queueStateAll,
QueueStateFilter.preparing => l10n.queueStatePreparing,
QueueStateFilter.queued => l10n.queueStateQueued,
QueueStateFilter.uploading => l10n.queueStateUploading,
QueueStateFilter.waitingNetwork => l10n.queueStateWaitingNetwork,
QueueStateFilter.failed => l10n.queueStateFailed,
QueueStateFilter.completed => l10n.queueStateCompleted,
};
static String _timeLabel(AppLocalizations l10n, QueueTimeFilter time) =>
switch (time) {
QueueTimeFilter.all => l10n.queueTimeAll,
QueueTimeFilter.today => l10n.queueTimeToday,
QueueTimeFilter.last7Days => l10n.queueTimeLast7Days,
QueueTimeFilter.last30Days => l10n.queueTimeLast30Days,
};
}
/// 相册下拉(全部相册 + 相册列表缓存;选中相册不在列表时退回全部)。
class _AlbumDropdown extends ConsumerWidget {
const _AlbumDropdown({required this.filter, required this.albums});
final QueueFilter filter;
final List<AlbumSummary> albums;
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final knownIds = albums.map((a) => a.id).toSet();
final value =
filter.albumId != null && knownIds.contains(filter.albumId)
? filter.albumId
: null;
return DropdownButton<String?>(
value: value,
isExpanded: true,
underline: const SizedBox.shrink(),
hint: Text(l10n.queueAlbumFilterAll),
items: [
DropdownMenuItem<String?>(
value: null,
child: Text(
l10n.queueAlbumFilterAll,
style: Theme.of(context).textTheme.bodyMedium,
),
),
for (final album in albums)
DropdownMenuItem<String?>(
value: album.id,
child: Text(
albumDisplayTitle(album, l10n.localeName),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
onChanged: (albumId) =>
ref.read(queueFilterProvider.notifier).setAlbum(albumId),
);
}
}
// ── 任务列表 ──────────────────────────────────────────────────────────────
class _TaskListView extends ConsumerWidget {
const _TaskListView();
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final tasksAsync = ref.watch(queueTasksProvider);
return tasksAsync.when(
skipLoadingOnReload: true,
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.errorGeneric),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: () => ref.invalidate(queueTasksProvider),
child: Text(l10n.retry),
),
],
),
),
data: (tasks) {
if (tasks.isEmpty) {
return Center(child: Text(l10n.queueEmpty));
}
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: tasks.length,
itemBuilder: (context, index) =>
_TaskTile(task: tasks[index]),
);
},
);
}
}
/// 单条任务行。
class _TaskTile extends ConsumerWidget {
const _TaskTile({required this.task});
final UploadTask task;
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final album = ref
.watch(albumListProvider)
.items
.where((a) => a.id == task.albumId)
.firstOrNull;
final albumName = album != null
? albumDisplayTitle(album, l10n.localeName)
: task.albumId;
final failedLike = task.state == UploadTaskState.preparationFailed ||
task.state == UploadTaskState.retryableFailed ||
task.state == UploadTaskState.waitingNetwork;
return ListTile(
leading: _Thumb(
path: task.cleanedAt == null ? task.sourceFilePath : null,
),
title: Text(
task.filename,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 2),
Row(
children: [
_StateChip(state: task.state),
const SizedBox(width: 8),
Expanded(
child: Text(
albumName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
),
],
),
if (failedLike && task.lastError != null) ...[
const SizedBox(height: 2),
Text(
task.attempt > 0
? '${l10n.messageForTaskError(task.lastError!)} · '
'${l10n.queueAttemptCount(task.attempt)}'
: l10n.messageForTaskError(task.lastError!),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.error),
),
],
if (task.state == UploadTaskState.uploading ||
task.state == UploadTaskState.uploadedConfirming)
_ProgressLine(
taskId: task.id,
confirming: task.state == UploadTaskState.uploadedConfirming,
),
],
),
trailing: _TaskActions(task: task),
);
}
}
/// 缩略图:受管副本存在则显示,缺失/已清理/解码失败显示占位图标。
class _Thumb extends StatelessWidget {
const _Thumb({required this.path});
final String? path;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
Widget placeholder() => Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: Icon(
Icons.image_outlined,
size: 24,
color: theme.colorScheme.onSurfaceVariant,
),
);
if (path == null) return placeholder();
return ClipRRect(
borderRadius: BorderRadius.circular(6),
child: SizedBox(
width: 48,
height: 48,
child: Image.file(
File(path!),
fit: BoxFit.cover,
errorBuilder: (_, _, _) => placeholder(),
),
),
);
}
}
/// 状态徽章(分组配色:失败红/完成绿/进行中主色/等待橙/其余中性)。
class _StateChip extends StatelessWidget {
const _StateChip({required this.state});
final UploadTaskState state;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final (color, onColor) = switch (state) {
UploadTaskState.preparing => (
theme.colorScheme.surfaceContainerHighest,
theme.colorScheme.onSurfaceVariant
),
UploadTaskState.preparationFailed ||
UploadTaskState.retryableFailed => (
theme.colorScheme.errorContainer,
theme.colorScheme.onErrorContainer
),
UploadTaskState.queued => (
theme.colorScheme.secondaryContainer,
theme.colorScheme.onSecondaryContainer
),
UploadTaskState.uploading ||
UploadTaskState.uploadedConfirming => (
theme.colorScheme.primaryContainer,
theme.colorScheme.onPrimaryContainer
),
UploadTaskState.completed => (
theme.colorScheme.tertiaryContainer,
theme.colorScheme.onTertiaryContainer
),
UploadTaskState.waitingNetwork => (
theme.colorScheme.tertiaryContainer,
theme.colorScheme.onTertiaryContainer
),
UploadTaskState.cancelled => (
theme.colorScheme.surfaceContainerHighest,
theme.colorScheme.onSurfaceVariant
),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(999),
),
child: Text(
l10n.labelForTaskState(state),
style: theme.textTheme.labelSmall?.copyWith(color: onColor),
),
);
}
}
/// 传输进度行:上传中 = 进度总线行级订阅(分数进度条);确认中 = 不确定
/// 进度条(等待服务端核验)。
class _ProgressLine extends ConsumerWidget {
const _ProgressLine({required this.taskId, required this.confirming});
final String taskId;
final bool confirming;
@override
Widget build(BuildContext context, WidgetRef ref) {
return Padding(
padding: const EdgeInsets.only(top: 6, right: 8),
child: StreamBuilder<UploadTaskProgress>(
stream: ref
.watch(uploadProgressHubProvider)
.stream
.where((p) => p.taskId == taskId),
builder: (context, snapshot) {
final fraction = snapshot.data?.fraction;
return LinearProgressIndicator(
value: confirming ? null : fraction,
minHeight: 4,
);
},
),
);
}
}
/// 行级动作(按状态;未登录账号缺席时禁用)。
class _TaskActions extends ConsumerWidget {
const _TaskActions({required this.task});
final UploadTask task;
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final account = ref.watch(sessionAccountProvider).value;
switch (task.state) {
case UploadTaskState.preparationFailed:
// 准备失败重试 = 重新选图(原始源路径不落库,见准备器注释)。
return IconButton(
tooltip: l10n.retry,
icon: const Icon(Icons.refresh),
onPressed: account == null
? null
: () => _retryPreparation(context, ref, account),
);
case UploadTaskState.retryableFailed:
case UploadTaskState.waitingNetwork:
return IconButton(
tooltip: l10n.retry,
icon: const Icon(Icons.refresh),
onPressed: account == null
? null
: () => ref
.read(uploadEngineProvider)
.retryNow(account, task.id),
);
case UploadTaskState.queued:
case UploadTaskState.preparing:
return IconButton(
tooltip: l10n.cancel,
icon: const Icon(Icons.close),
onPressed: account == null
? null
: () => ref.read(uploadEngineProvider).cancel(account, task.id),
);
case UploadTaskState.completed:
case UploadTaskState.cancelled:
return IconButton(
tooltip: l10n.delete,
icon: const Icon(Icons.delete_outline),
onPressed: account == null ? null : () => _delete(ref, account),
);
case UploadTaskState.uploading:
case UploadTaskState.uploadedConfirming:
return const SizedBox(width: 48);
}
}
/// 准备失败重试:重新选图 → preparer.retry(换源重新复制入队)。
Future<void> _retryPreparation(
BuildContext context,
WidgetRef ref,
String account,
) async {
final l10n = AppLocalizations.of(context);
final sourcePath = await ref.read(singleImagePickerProvider)();
if (sourcePath == null || !context.mounted) return;
final messenger = ScaffoldMessenger.of(context);
try {
await ref.read(uploadPreparerProvider).retry(
accountId: account,
taskId: task.id,
sourcePath: sourcePath,
);
} on Object {
messenger.showSnackBar(
SnackBar(content: Text(l10n.errorGeneric)),
);
}
}
/// 终态删除:删行 + 尽力删受管副本(行删除后清理执行器不再可见该文件)。
Future<void> _delete(WidgetRef ref, String account) async {
final store = ref.read(taskStoreProvider);
await store.delete(task.id);
if (task.cleanedAt == null) {
try {
final file = File(task.sourceFilePath);
if (await file.exists()) {
await file.delete();
}
} on Object {
// 文件删除失败接受(无行可追责,残留由用户重装/系统清理回收)。
}
}
}
}
/// 底部批量重试条(存在可自动重试的失败任务时出现)。
class _BatchRetryBar extends ConsumerWidget {
const _BatchRetryBar();
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final counts = ref.watch(taskCountsProvider);
final failed = failedCountOf(counts);
return Material(
color: theme.colorScheme.errorContainer,
child: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Row(
children: [
Icon(Icons.error_outline, color: theme.colorScheme.onErrorContainer),
const SizedBox(width: 8),
Expanded(
child: Text(
'${l10n.queueStateFailed} · $failed',
style: theme.textTheme.bodyMedium
?.copyWith(color: theme.colorScheme.onErrorContainer),
),
),
FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: theme.colorScheme.error,
foregroundColor: theme.colorScheme.onError,
),
icon: const Icon(Icons.refresh, size: 18),
label: Text(l10n.queueRetryAllFailed),
onPressed: () => _retryAll(ref),
),
],
),
),
),
);
}
/// 全部可自动重试任务(retryableFailed + waitingNetwork,跨当前筛选)
/// 立即重新入队。
Future<void> _retryAll(WidgetRef ref) async {
final account = ref.read(sessionAccountProvider).value;
if (account == null) return;
final engine = ref.read(uploadEngineProvider);
final tasks = await ref.read(taskStoreProvider).listByAccount(
account,
states: const {
UploadTaskState.retryableFailed,
UploadTaskState.waitingNetwork,
},
);
for (final task in tasks) {
await engine.retryNow(account, task.id);
}
}
}