diff --git a/apps/mobile/android/app/src/main/kotlin/com/photolive/photo_live/MainActivity.kt b/apps/mobile/android/app/src/main/kotlin/com/photolive/photo_live/MainActivity.kt index 1786215..0e5e888 100644 --- a/apps/mobile/android/app/src/main/kotlin/com/photolive/photo_live/MainActivity.kt +++ b/apps/mobile/android/app/src/main/kotlin/com/photolive/photo_live/MainActivity.kt @@ -8,5 +8,16 @@ class MainActivity : FlutterActivity() { super.configureFlutterEngine(flutterEngine) // 上传桥接(P3 Task 7):MethodChannel 命令 + EventChannel 事件流。 UploadPlugin.register(applicationContext, flutterEngine) + // 运行时权限桥接(P3 Task 9):POST_NOTIFICATIONS 请求。 + PermissionPlugin.register(this, flutterEngine) + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray, + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + PermissionPlugin.onRequestPermissionsResult(requestCode, grantResults) } } diff --git a/apps/mobile/android/app/src/main/kotlin/com/photolive/photo_live/PermissionPlugin.kt b/apps/mobile/android/app/src/main/kotlin/com/photolive/photo_live/PermissionPlugin.kt new file mode 100644 index 0000000..b91854c --- /dev/null +++ b/apps/mobile/android/app/src/main/kotlin/com/photolive/photo_live/PermissionPlugin.kt @@ -0,0 +1,78 @@ +package com.photolive.photo_live + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel + +/** + * 运行时权限桥接(P3 Task 9):MethodChannel `photolive/permissions`。 + * + * 当前仅一个方法:requestPostNotifications——Android 13+(TIRAMISU)的 + * POST_NOTIFICATIONS 运时请求(上传前台服务通知可见性;未授权时传输 + * 继续,Dart 侧静默降级)。SDK < 33 无需运行时请求,直接回 granted。 + * + * 实现说明:App 全部运行时权限需求仅此一项,有意不引入 + * permission_handler 依赖(见 Dart 侧 notification_permission.dart 注释)。 + * 并发请求单飞:仅首个挂起,后续直接回 denied(Dart 侧本就单次语义)。 + */ +object PermissionPlugin { + + const val CHANNEL = "photolive/permissions" + private const val METHOD_REQUEST_POST_NOTIFICATIONS = "requestPostNotifications" + private const val REQUEST_CODE = 4101 + + private var pending: MethodChannel.Result? = null + + fun register(activity: MainActivity, engine: FlutterEngine) { + MethodChannel(engine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result -> + when (call.method) { + METHOD_REQUEST_POST_NOTIFICATIONS -> + handleRequestPostNotifications(activity, result) + else -> result.notImplemented() + } + } + } + + private fun handleRequestPostNotifications( + activity: MainActivity, + result: MethodChannel.Result, + ) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + result.success("granted") + return + } + val granted = ContextCompat.checkSelfPermission( + activity, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + if (granted) { + result.success("granted") + return + } + if (pending != null) { + // 已有请求在途:本 App 侧为单次语义,直接按未授权回包。 + result.success("denied") + return + } + pending = result + ActivityCompat.requestPermissions( + activity, + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + REQUEST_CODE, + ) + } + + /** MainActivity.onRequestPermissionsResult 转发(回包挂起的首个请求)。 */ + fun onRequestPermissionsResult(requestCode: Int, grantResults: IntArray) { + if (requestCode != REQUEST_CODE) return + val result = pending ?: return + pending = null + val granted = grantResults.isNotEmpty() && + grantResults[0] == PackageManager.PERMISSION_GRANTED + result.success(if (granted) "granted" else "denied") + } +} diff --git a/apps/mobile/lib/app/app.dart b/apps/mobile/lib/app/app.dart index c67100f..9c93910 100644 --- a/apps/mobile/lib/app/app.dart +++ b/apps/mobile/lib/app/app.dart @@ -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 rootNavigatorKey = GlobalKey(); -/// 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 { 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, diff --git a/apps/mobile/lib/core/notification_permission.dart b/apps/mobile/lib/core/notification_permission.dart new file mode 100644 index 0000000..b897ca9 --- /dev/null +++ b/apps/mobile/lib/core/notification_permission.dart @@ -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 ensureRequested() async { + if (_requested) return; + _requested = true; + if (!_isAndroid) return; + try { + await _channel.invokeMethod('requestPostNotifications'); + } on Object { + // 通道缺失(iOS/桌面)或请求异常:静默降级。 + } + } +} + +/// 生产 Provider(队列/连拍页经 ref.read 一次性调用)。 +final notificationPermissionProvider = + Provider((ref) => NotificationPermission()); diff --git a/apps/mobile/lib/features/uploads/engine_scope.dart b/apps/mobile/lib/features/uploads/engine_scope.dart new file mode 100644 index 0000000..cf23736 --- /dev/null +++ b/apps/mobile/lib/features/uploads/engine_scope.dart @@ -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 { + @override + Future build() async { + final session = await ref.watch(sessionStoreProvider).read(); + return _normalize(session?.account); + } + + /// 重读会话(登录成功/登出/401 驱逐后调用)。 + Future refresh() async { + final store = ref.read(sessionStoreProvider); + state = const AsyncLoading(); + 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.new, +); + +/// 引擎生命周期绑定:账号在场 → start;离场 → stop(任务保留库内)。 +/// +/// 返回当前 scope 账号(null=引擎停止);仅被 App 壳 watch 时生效 +/// (Provider 惰性求值)。 +final uploadEngineScopeProvider = Provider((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; diff --git a/apps/mobile/lib/features/uploads/enqueue.dart b/apps/mobile/lib/features/uploads/enqueue.dart new file mode 100644 index 0000000..1c98290 --- /dev/null +++ b/apps/mobile/lib/features/uploads/enqueue.dart @@ -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 Function(); + +/// 多图选择(相册多选入队);返回源文件路径列表(空=取消)。 +typedef MultiImagePicker = Future> Function(); + +/// 单图选择 Provider(生产=image_picker;测试 override)。 +final singleImagePickerProvider = Provider( + (ref) => () async { + final file = await ImagePicker().pickImage(source: ImageSource.gallery); + return file?.path; + }, +); + +/// 多图选择 Provider(生产=image_picker;测试 override)。 +final multiImagePickerProvider = Provider( + (ref) => () async { + final files = await ImagePicker().pickMultiImage(); + return files.map((file) => file.path).toList(); + }, +); + +/// 批量入队:通知权限单次请求 + 逐张准备入队。 +/// +/// 返回成功入队(state=queued)的张数;准备失败的任务已落库为 +/// preparationFailed(连拍页失败徽章/队列页可见,不在此抛出)。 +Future enqueueForUpload( + WidgetRef ref, { + required String accountId, + required String albumId, + String? categoryId, + required List 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; +} diff --git a/apps/mobile/lib/features/uploads/providers.dart b/apps/mobile/lib/features/uploads/providers.dart index 6630745..f561de6 100644 --- a/apps/mobile/lib/features/uploads/providers.dart +++ b/apps/mobile/lib/features/uploads/providers.dart @@ -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((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 _controller = + StreamController.broadcast(); + + /// 进度事件流(广播;行级按 taskId 过滤)。 + Stream 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((ref) { + final hub = UploadProgressHub(); + ref.onDispose(hub.dispose); + return hub; +}); + /// 传输引擎(裸 Dio 直传 presign;并行/保留期配置见 upload_engine.dart -/// 的 dart-define 常量)。 +/// 的 dart-define 常量;进度经 [UploadProgressHub] 广播)。 final uploadEngineProvider = Provider((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; diff --git a/apps/mobile/lib/features/uploads/queue_controller.dart b/apps/mobile/lib/features/uploads/queue_controller.dart new file mode 100644 index 0000000..7129aac --- /dev/null +++ b/apps/mobile/lib/features/uploads/queue_controller.dart @@ -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 get states => switch (this) { + QueueStateFilter.all => const {}, + QueueStateFilter.preparing => const { + UploadTaskState.preparing, + }, + QueueStateFilter.queued => const { + UploadTaskState.queued, + }, + QueueStateFilter.uploading => const { + UploadTaskState.uploading, + UploadTaskState.uploadedConfirming, + }, + QueueStateFilter.waitingNetwork => const { + UploadTaskState.waitingNetwork, + }, + QueueStateFilter.failed => const { + UploadTaskState.preparationFailed, + UploadTaskState.retryableFailed, + }, + QueueStateFilter.completed => const { + 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 { + @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.new); + +/// 防抖重查间隔(连拍/引擎迁移写风暴下限流)。 +const Duration kQueueRefreshDebounce = Duration(milliseconds: 200); + +/// 队列任务列表:筛选参数化查询 + 写通知防抖刷新。 +class QueueTasksNotifier extends AsyncNotifier> { + StreamSubscription? _changesSub; + Timer? _debounce; + + @override + Future> 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 []; + return _query(store, account, filter); + } + + static Future> _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>(tasks)); + }); + } + + void _dispose() { + _changesSub?.cancel(); + _changesSub = null; + _debounce?.cancel(); + _debounce = null; + } +} + +/// 队列任务列表 Provider。 +final queueTasksProvider = + AsyncNotifierProvider>( + QueueTasksNotifier.new, +); + +/// 账号内各状态计数(连拍页徽章/批量重试条;写通知防抖刷新)。 +class TaskCountsNotifier extends Notifier> { + StreamSubscription? _changesSub; + Timer? _debounce; + + @override + Map build() { + final store = ref.watch(taskStoreProvider); + // 会话变化(登录/登出)也触发重建(登出清零)。 + ref.watch(sessionAccountProvider); + _changesSub?.cancel(); + _debounce?.cancel(); + _changesSub = store.changes.listen((_) => _scheduleReload()); + ref.onDispose(_dispose); + Future.microtask(() => _reload()); + return { + for (final state in UploadTaskState.values) state: 0, + }; + } + + Future _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.new, +); + +/// 未完成(待传)计数:准备中/待传/上传中/确认中/等待网络之和。 +int pendingCountOf(Map counts) => + counts[UploadTaskState.preparing]! + + counts[UploadTaskState.queued]! + + counts[UploadTaskState.uploading]! + + counts[UploadTaskState.uploadedConfirming]! + + counts[UploadTaskState.waitingNetwork]!; + +/// 失败计数:准备失败 + 上传失败。 +int failedCountOf(Map counts) => + counts[UploadTaskState.preparationFailed]! + + counts[UploadTaskState.retryableFailed]!; diff --git a/apps/mobile/lib/features/uploads/queue_l10n.dart b/apps/mobile/lib/features/uploads/queue_l10n.dart new file mode 100644 index 0000000..03d36db --- /dev/null +++ b/apps/mobile/lib/features/uploads/queue_l10n.dart @@ -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; +} diff --git a/apps/mobile/lib/features/uploads/task_store.dart b/apps/mobile/lib/features/uploads/task_store.dart index d34c19b..afae72b 100644 --- a/apps/mobile/lib/features/uploads/task_store.dart +++ b/apps/mobile/lib/features/uploads/task_store.dart @@ -237,6 +237,11 @@ class TaskStore { /// 关闭底层数据库(App 退出/测试 tearDown)。 Future close() => _db.close(); + /// 表写通知流(drift 对 insert/update/delete 自动发事件;队列 UI 的 + /// 节流刷新挂接点——引擎/准备器每次落库都会触发)。 + Stream get changes => + _db.tableUpdates().map((Set _) {}); + /// 时间戳统一 UTC 落库(datetime 为 ISO-8601 文本存储:UTC 值以 `Z` /// 结尾、本地值带 `+hh:mm` 后缀,混存会让 SQL 文本比较错位一个时区 /// 偏移——全部归一 UTC 后字典序即时间序)。 diff --git a/apps/mobile/lib/l10n/app_en.arb b/apps/mobile/lib/l10n/app_en.arb index 589c403..7c95fb2 100644 --- a/apps/mobile/lib/l10n/app_en.arb +++ b/apps/mobile/lib/l10n/app_en.arb @@ -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" } diff --git a/apps/mobile/lib/l10n/app_localizations.dart b/apps/mobile/lib/l10n/app_localizations.dart index f2e77fc..c8dcf41 100644 --- a/apps/mobile/lib/l10n/app_localizations.dart +++ b/apps/mobile/lib/l10n/app_localizations.dart @@ -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 diff --git a/apps/mobile/lib/l10n/app_localizations_en.dart b/apps/mobile/lib/l10n/app_localizations_en.dart index 49547c7..4b7d1db 100644 --- a/apps/mobile/lib/l10n/app_localizations_en.dart +++ b/apps/mobile/lib/l10n/app_localizations_en.dart @@ -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'; } diff --git a/apps/mobile/lib/l10n/app_localizations_zh.dart b/apps/mobile/lib/l10n/app_localizations_zh.dart index 0d7f378..435ea37 100644 --- a/apps/mobile/lib/l10n/app_localizations_zh.dart +++ b/apps/mobile/lib/l10n/app_localizations_zh.dart @@ -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 => '未选择照片'; } diff --git a/apps/mobile/lib/l10n/app_zh.arb b/apps/mobile/lib/l10n/app_zh.arb index 804e83a..61df742 100644 --- a/apps/mobile/lib/l10n/app_zh.arb +++ b/apps/mobile/lib/l10n/app_zh.arb @@ -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": "未选择照片" } diff --git a/apps/mobile/lib/pages/album_detail_page.dart b/apps/mobile/lib/pages/album_detail_page.dart index 01a10df..e14b7d7 100644 --- a/apps/mobile/lib/pages/album_detail_page.dart +++ b/apps/mobile/lib/pages/album_detail_page.dart @@ -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 { ); } + // ── 上传入口(Task 9 FAB:拍照上传 / 从相册选择) ────────────────────── + + /// 当前登录账号(TaskStore 隔离键;详情页处于登录壳内,null 视为异常)。 + Future _currentAccountId() async { + final session = await ref.read(sessionStoreProvider).read(); + return session?.account; + } + + Future _openUploadSheet() { + final l10n = AppLocalizations.of(context); + return showModalBottomSheet( + 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 _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( + builder: (_) => CapturePage( + target: CaptureTarget( + accountId: account, + albumId: album.id, + albumTitle: _titleOf(album), + categories: album.categories, + ), + ), + ), + ); + } + + /// 从相册选择:多选 → 逐张入队(默认分类)→ 入队数提示。 + Future _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 { 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 diff --git a/apps/mobile/lib/pages/capture_page.dart b/apps/mobile/lib/pages/capture_page.dart new file mode 100644 index 0000000..2476667 --- /dev/null +++ b/apps/mobile/lib/pages/capture_page.dart @@ -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 initialize(); + + /// 拍摄一张(返回源文件)。 + Future capture(); + + /// 取景预览。 + Widget buildPreview(); + + /// 释放底层资源。 + Future dispose(); +} + +/// 生产拍摄源:camera 插件(后摄优先,最高分辨率,静音连拍)。 +class CameraPluginSource implements CaptureSource { + CameraPluginSource._(this._controller); + + /// 创建并初始化(后摄优先)。 + static Future 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 initialize() async {} + + @override + Future capture() => _controller.takePicture(); + + @override + Widget buildPreview() => CameraPreview(_controller); + + @override + Future dispose() => _controller.dispose(); +} + +/// 拍摄源工厂 Provider(测试 override 为替身)。 +final captureSourceFactoryProvider = + Provider Function()>((ref) => CameraPluginSource.create); + +// ── 连拍页 ──────────────────────────────────────────────────────────────── + +/// 连拍页参数:目标相册(相册详情 FAB 进入时固定)与分类候选。 +class CaptureTarget { + const CaptureTarget({ + required this.accountId, + required this.albumId, + required this.albumTitle, + this.categories = const [], + }); + + /// 入队账号(TaskStore 隔离键)。 + final String accountId; + + /// 目标相册 ID。 + final String albumId; + + /// 目标相册展示名(信息层常驻显示)。 + final String albumTitle; + + /// 相册分类(空 = 无可切换项,仅默认分类)。 + final List categories; +} + +/// 连拍页。 +class CapturePage extends ConsumerStatefulWidget { + const CapturePage({required this.target, super.key}); + + final CaptureTarget target; + + @override + ConsumerState createState() => _CapturePageState(); +} + +enum _CameraPhase { loading, ready, denied, failed } + +class _CapturePageState extends ConsumerState { + CaptureSource? _source; + _CameraPhase _phase = _CameraPhase.loading; + + /// 当前分类(null = 相册默认分类;切换仅影响之后拍摄)。 + String? _categoryId; + + /// 连拍串行链:快门连点逐张落地(相机硬件本就串行)。 + Future _shotTail = Future.value(); + + @override + void initState() { + super.initState(); + _initCamera(); + } + + @override + void dispose() { + _source?.dispose().ignore(); + super.dispose(); + } + + Future _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 _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 _confirmExit() async { + final l10n = AppLocalizations.of(context); + final leave = await showDialog( + 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 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), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/lib/pages/home_page.dart b/apps/mobile/lib/pages/home_page.dart index 5d0bb34..a05c40f 100644 --- a/apps/mobile/lib/pages/home_page.dart +++ b/apps/mobile/lib/pages/home_page.dart @@ -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 { } } -/// 上传队列(桩)。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 _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); } diff --git a/apps/mobile/lib/pages/login_page.dart b/apps/mobile/lib/pages/login_page.dart index 867bd75..ef86f16 100644 --- a/apps/mobile/lib/pages/login_page.dart +++ b/apps/mobile/lib/pages/login_page.dart @@ -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 { _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)等文案经错误码映射;登录页的 diff --git a/apps/mobile/lib/pages/queue_tab.dart b/apps/mobile/lib/pages/queue_tab.dart new file mode 100644 index 0000000..e20ec9a --- /dev/null +++ b/apps/mobile/lib/pages/queue_tab.dart @@ -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 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( + value: value, + isExpanded: true, + underline: const SizedBox.shrink(), + hint: Text(l10n.queueAlbumFilterAll), + items: [ + DropdownMenuItem( + value: null, + child: Text( + l10n.queueAlbumFilterAll, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + for (final album in albums) + DropdownMenuItem( + 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( + 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 _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 _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 _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); + } + } +} diff --git a/apps/mobile/test/app/app_shell_test.dart b/apps/mobile/test/app/app_shell_test.dart index 123bdef..1c053d8 100644 --- a/apps/mobile/test/app/app_shell_test.dart +++ b/apps/mobile/test/app/app_shell_test.dart @@ -33,9 +33,11 @@ void main() { ); // PhotoLiveApp.initState 读取被 override 的客户端并注册 onUnauthorized。 + // Task 9 起根组件 watch 引擎作用域(会话→引擎 start),真实 + // flutter_secure_storage/path_provider 平台通道在测试环境挂起,须替身。 await tester.pumpWidget( ProviderScope( - overrides: [apiClientProvider.overrideWith((ref) => client)], + overrides: [...pageTestOverrides(session: store, client: client)], child: const PhotoLiveApp(), ), ); diff --git a/apps/mobile/test/core/notification_permission_test.dart b/apps/mobile/test/core/notification_permission_test.dart new file mode 100644 index 0000000..4e5966b --- /dev/null +++ b/apps/mobile/test/core/notification_permission_test.dart @@ -0,0 +1,80 @@ +/// POST_NOTIFICATIONS 权限门测试(P3 计划 Task 9)。 +/// +/// 覆盖:Android 首次请求一次(幂等,二次调用不触通道)、非 Android 跳过、 +/// 通道缺失/异常静默降级不阻断入队。 +library; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:photo_live/core/notification_permission.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel(NotificationPermission.kChannelName); + // 测试绑定的 defaultBinaryMessenger 即 TestDefaultBinaryMessenger + // (setMockMethodCallHandler 所在)。 + final byteData = TestWidgetsFlutterBinding.instance.defaultBinaryMessenger; + + tearDown(() => byteData.setMockMethodCallHandler(channel, null)); + + test('Android:首次请求触发通道一次;再次调用不再打扰(幂等)', () async { + var calls = 0; + byteData.setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'requestPostNotifications'); + calls++; + return 'granted'; + }); + final permission = + NotificationPermission(isAndroid: true, channel: channel); + + await permission.ensureRequested(); + await permission.ensureRequested(); + await permission.ensureRequested(); + + expect(calls, 1, reason: '进程内只请求一次'); + }); + + test('非 Android:不触通道(iOS 后台传输不依赖通知权限)', () async { + var calls = 0; + byteData.setMockMethodCallHandler(channel, (call) async { + calls++; + return 'granted'; + }); + final permission = + NotificationPermission(isAndroid: false, channel: channel); + + await permission.ensureRequested(); + + expect(calls, 0); + }); + + test('通道缺失(iOS/桌面无此方法)→ 静默降级不抛', () async { + byteData.setMockMethodCallHandler( + channel, + (call) async => throw MissingPluginException('no handler'), + ); + final permission = + NotificationPermission(isAndroid: true, channel: channel); + + await permission.ensureRequested(); // 不抛即通过。 + }); + + test('通道异常后仍标记已请求(失败也不二次打扰)', () async { + var calls = 0; + byteData.setMockMethodCallHandler( + channel, + (call) async { + calls++; + throw PlatformException(code: 'ACTIVITY_MISSING'); + }, + ); + final permission = + NotificationPermission(isAndroid: true, channel: channel); + + await permission.ensureRequested(); + await permission.ensureRequested(); + + expect(calls, 1); + }); +} diff --git a/apps/mobile/test/features/uploads/engine_scope_test.dart b/apps/mobile/test/features/uploads/engine_scope_test.dart new file mode 100644 index 0000000..5720cf6 --- /dev/null +++ b/apps/mobile/test/features/uploads/engine_scope_test.dart @@ -0,0 +1,154 @@ +/// 引擎生命周期绑定测试(P3 计划 Task 9,Task 6 关注点 1 接线)。 +/// +/// ProviderContainer 级(非 widget):登录会话账号 → uploadEngineScopeProvider +/// 驱动 UploadEngine.start/stop。覆盖:登录启动、登出停止且任务保留库内、 +/// 账号切换重启。真实平台通道(secure storage 等)全部以替身注入。 +library; + +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:photo_live/core/api_client.dart'; +import 'package:photo_live/core/device_id.dart'; +import 'package:photo_live/core/session_store.dart'; +import 'package:photo_live/features/uploads/engine_scope.dart'; +import 'package:photo_live/features/uploads/providers.dart'; +import 'package:photo_live/features/uploads/task_state.dart'; +import 'package:photo_live/features/uploads/task_store.dart'; + +import '../../helpers/fakes.dart'; + +/// 固定设备 ID(引擎出队才会读;空库场景实际不触网络)。 +class _FixedDeviceIdStore implements DeviceIdStore { + @override + Future read() async => '0123456789abcdef0123456789abcdef'; + @override + Future write(String value) async {} +} + +void main() { + late FakeSessionStore session; + late TaskStore store; + + setUp(() { + session = FakeSessionStore(); + store = TaskStore.memory(); + }); + + tearDown(() => store.close()); + + ProviderContainer pumpContainer() { + final container = ProviderContainer( + overrides: [ + sessionStoreProvider.overrideWithValue(session), + taskStoreProvider.overrideWithValue(store), + deviceIdProvider + .overrideWith((ref) => DeviceId(store: _FixedDeviceIdStore())), + apiClientProvider.overrideWith( + (ref) => ApiClient( + sessionStore: session, + dio: Dio(BaseOptions(baseUrl: 'http://test.local')) + ..httpClientAdapter = FakeAdapter( + (_) => throw StateError('空库场景不应发起请求'), + ), + ), + ), + ], + ); + addTearDown(container.dispose); + return container; + } + + /// 读一次 scope Provider 使绑定生效,并等会话账号 future 落定。 + Future settleScope(ProviderContainer container) async { + final account = await container.read(sessionAccountProvider.future); + // 触发 scope Provider 重建(引擎 start/stop 发生在 watch 传播中)。 + container.read(uploadEngineScopeProvider); + await Future.delayed(Duration.zero); + return account; + } + + test('登录(会话在场)→ 引擎 start(isRunning,scope=账号)', () async { + session.session = Session( + token: 'tok-1', + expiresAt: DateTime.utc(2099, 1, 1), + account: 'alice', + ); + final container = pumpContainer(); + + final account = await settleScope(container); + final engine = container.read(uploadEngineProvider); + + expect(account, 'alice'); + expect(container.read(uploadEngineScopeProvider), 'alice'); + expect(engine.isRunning, isTrue, reason: '登录后引擎应周期出队'); + }); + + test('登出 → 引擎 stop,任务行保留库内', () async { + session.session = Session( + token: 'tok-1', + expiresAt: DateTime.utc(2099, 1, 1), + account: 'alice', + ); + final container = pumpContainer(); + await settleScope(container); + final engine = container.read(uploadEngineProvider); + expect(engine.isRunning, isTrue); + + // 种子一条任务(登出后必须保留——账号切换安全由 accountId 硬隔离保障)。 + final task = await store.create(UploadTaskDraft( + id: 'task-keep', + accountId: 'alice', + albumId: 'alb-1', + clientTaskId: 'ct-keep', + sourceFilePath: '/tmp/keep.jpg', + filename: 'keep.jpg', + bytes: 16, + contentType: 'image/jpeg', + )); + expect(task.state, UploadTaskState.preparing); + + session.session = null; + await container.read(sessionAccountProvider.notifier).refresh(); + await Future.delayed(Duration.zero); + + expect(engine.isRunning, isFalse, reason: '登出后引擎必须停止出队'); + expect(container.read(uploadEngineScopeProvider), isNull); + expect( + await store.get('alice', 'task-keep'), + isNotNull, + reason: '登出不清任务库', + ); + }); + + test('账号切换 alice → bob → 引擎随新账号重启', () async { + session.session = Session( + token: 'tok-1', + expiresAt: DateTime.utc(2099, 1, 1), + account: 'alice', + ); + final container = pumpContainer(); + await settleScope(container); + expect(container.read(uploadEngineScopeProvider), 'alice'); + + session.session = Session( + token: 'tok-2', + expiresAt: DateTime.utc(2099, 1, 1), + account: 'bob', + ); + await container.read(sessionAccountProvider.notifier).refresh(); + await Future.delayed(Duration.zero); + + final engine = container.read(uploadEngineProvider); + expect(container.read(uploadEngineScopeProvider), 'bob'); + expect(engine.isRunning, isTrue, reason: '切换账号 = 新 scope 继续出队'); + }); + + test('未登录启动 App → 引擎不启动', () async { + final container = pumpContainer(); + await settleScope(container); + + expect(container.read(uploadEngineScopeProvider), isNull); + expect(container.read(uploadEngineProvider).isRunning, isFalse); + }); +} diff --git a/apps/mobile/test/features/uploads/queue_filter_test.dart b/apps/mobile/test/features/uploads/queue_filter_test.dart new file mode 100644 index 0000000..ed561ee --- /dev/null +++ b/apps/mobile/test/features/uploads/queue_filter_test.dart @@ -0,0 +1,265 @@ +/// 队列筛选纯逻辑测试(P3 计划 Task 9,需求 4.3 三维筛选)。 +/// +/// 覆盖:状态分组映射、时间快捷 cutoff、[QueueFilter.matches] 全矩阵 +/// (相册 × 状态 × 时间 + 时区归一边界)、计数汇总与 copyWith。 +/// 任务行经内存 TaskStore 种子(真实迁移链,无需 widget。 +library; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:photo_live/features/uploads/queue_controller.dart'; +import 'package:photo_live/features/uploads/task_state.dart'; +import 'package:photo_live/features/uploads/task_store.dart'; + +void main() { + late TaskStore store; + var seq = 0; + + setUp(() { + store = TaskStore.memory(); + seq = 0; + }); + + tearDown(() => store.close()); + + /// 种子一条指定状态/相册/创建时间的任务(真实迁移链抵达目标状态)。 + Future seedTask({ + String accountId = 'alice', + String albumId = 'alb-1', + UploadTaskState state = UploadTaskState.queued, + DateTime? createdAt, + }) async { + final id = 'task-${seq++}'; + final draft = UploadTaskDraft( + id: id, + accountId: accountId, + albumId: albumId, + clientTaskId: 'ct-$id', + sourceFilePath: '/tmp/$id.jpg', + filename: '$id.jpg', + bytes: 16, + contentType: 'image/jpeg', + ); + var task = await store.create(draft, now: createdAt); + // 迁移链:preparing → 目标态(按状态机合法路径逐步推进)。 + switch (state) { + case UploadTaskState.preparing: + break; + case UploadTaskState.preparationFailed: + task = await store.updateState(task.id, state, now: createdAt); + case UploadTaskState.queued: + case UploadTaskState.cancelled: + task = await store.updateState(task.id, state, now: createdAt); + case UploadTaskState.waitingNetwork: + task = await store.updateState( + task.id, + UploadTaskState.queued, + now: createdAt, + ); + task = await store.updateState(task.id, state, now: createdAt); + case UploadTaskState.uploading: + case UploadTaskState.retryableFailed: + task = await store.updateState( + task.id, + UploadTaskState.queued, + now: createdAt, + ); + task = await store.updateState( + task.id, + UploadTaskState.uploading, + now: createdAt, + ); + if (state == UploadTaskState.retryableFailed) { + task = await store.updateState(task.id, state, now: createdAt); + } + case UploadTaskState.uploadedConfirming: + case UploadTaskState.completed: + task = await store.updateState( + task.id, + UploadTaskState.queued, + now: createdAt, + ); + task = await store.updateState( + task.id, + UploadTaskState.uploading, + now: createdAt, + ); + task = await store.updateState( + task.id, + UploadTaskState.uploadedConfirming, + now: createdAt, + ); + if (state == UploadTaskState.completed) { + task = await store.updateState(task.id, state, now: createdAt); + } + } + return task; + } + + group('QueueStateFilter.states 分组', () { + test('all → 空集(不过滤)', () { + expect(QueueStateFilter.all.states, isEmpty); + }); + + test('failed 组 = 准备失败 + 上传失败(不含等待网络)', () { + expect(QueueStateFilter.failed.states, { + UploadTaskState.preparationFailed, + UploadTaskState.retryableFailed, + }); + }); + + test('uploading 组 = 上传中 + 确认中;completed 组不含 cancelled', () { + expect(QueueStateFilter.uploading.states, { + UploadTaskState.uploading, + UploadTaskState.uploadedConfirming, + }); + expect(QueueStateFilter.completed.states, + {UploadTaskState.completed}); + }); + }); + + group('QueueTimeFilter.since', () { + final now = DateTime(2026, 9, 19, 15, 30); + + test('all → null(不过滤)', () { + expect(QueueTimeFilter.all.since(now), isNull); + }); + + test('today → 本地当日零点;7/30 天 → 对应回溯', () { + expect(QueueTimeFilter.today.since(now), DateTime(2026, 9, 19)); + expect( + QueueTimeFilter.last7Days.since(now), + DateTime(2026, 9, 12, 15, 30), + ); + expect( + QueueTimeFilter.last30Days.since(now), + DateTime(2026, 8, 20, 15, 30), + ); + }); + }); + + group('QueueFilter.matches 矩阵', () { + final now = DateTime(2026, 9, 19, 15, 30); + + test('相册维度:命中与不命中', () async { + final inAlbum = + await seedTask(albumId: 'alb-1', createdAt: now); + final other = + await seedTask(albumId: 'alb-2', createdAt: now); + final filter = + const QueueFilter(albumId: 'alb-1'); + + expect(filter.matches(inAlbum, now: now), isTrue); + expect(filter.matches(other, now: now), isFalse); + }); + + test('状态维度:failed 组命中组内两态、排除组外', () async { + final prep = await seedTask( + state: UploadTaskState.preparationFailed, + createdAt: now, + ); + final upload = await seedTask( + state: UploadTaskState.retryableFailed, + createdAt: now, + ); + final waiting = await seedTask( + state: UploadTaskState.waitingNetwork, + createdAt: now, + ); + final filter = const QueueFilter( + state: QueueStateFilter.failed, + ); + + expect(filter.matches(prep, now: now), isTrue); + expect(filter.matches(upload, now: now), isTrue); + expect(filter.matches(waiting, now: now), isFalse); + }); + + test('时间维度:cutoff 恰好含、早一毫秒不含(本地→UTC 归一)', + () async { + final edge = await seedTask( + createdAt: DateTime(2026, 9, 19, 0, 0), // = today cutoff + ); + final before = await seedTask( + createdAt: + DateTime(2026, 9, 18, 23, 59, 59, 999), + ); + final filter = const QueueFilter( + time: QueueTimeFilter.today, + ); + + expect(filter.matches(edge, now: now), isTrue); + expect(filter.matches(before, now: now), isFalse); + }); + + test('today 匹配 UTC 落库 createdAt(跨时区比较归一)', () async { + // UTC 落库时刻(本地 9/19 当日内)——create 落库即 UTC。 + final task = await seedTask( + createdAt: DateTime.utc(2026, 9, 19, 2, 30), + ); + const filter = + QueueFilter(time: QueueTimeFilter.today); + + expect(filter.matches(task, now: now), isTrue); + }); + + test('三维叠加:全部命中才匹配', () async { + final hit = await seedTask( + albumId: 'alb-1', + state: UploadTaskState.retryableFailed, + createdAt: DateTime(2026, 9, 19, 10, 0), + ); + final wrongAlbum = await seedTask( + albumId: 'alb-2', + state: UploadTaskState.retryableFailed, + createdAt: DateTime(2026, 9, 19, 10, 0), + ); + final wrongState = await seedTask( + albumId: 'alb-1', + state: UploadTaskState.queued, + createdAt: DateTime(2026, 9, 19, 10, 0), + ); + final tooOld = await seedTask( + albumId: 'alb-1', + state: UploadTaskState.retryableFailed, + createdAt: DateTime(2026, 8, 1), + ); + const filter = QueueFilter( + albumId: 'alb-1', + state: QueueStateFilter.failed, + time: QueueTimeFilter.last30Days, + ); + + expect(filter.matches(hit, now: now), isTrue); + expect(filter.matches(wrongAlbum, now: now), isFalse); + expect(filter.matches(wrongState, now: now), isFalse); + expect(filter.matches(tooOld, now: now), isFalse); + }); + }); + + group('QueueFilter.copyWith / 计数汇总', () { + test('copyWith 覆盖与 clearAlbum 显式清空', () { + const base = QueueFilter(albumId: 'alb-1'); + expect(base.copyWith(albumId: 'alb-2').albumId, 'alb-2'); + expect(base.copyWith(clearAlbum: true).albumId, isNull); + // 不传时保持。 + expect(base.copyWith().albumId, 'alb-1'); + }); + + test('pendingCountOf/failedCountOf 汇总', () { + final counts = { + for (final s in UploadTaskState.values) s: 0, + } + ..[UploadTaskState.preparing] = 1 + ..[UploadTaskState.queued] = 2 + ..[UploadTaskState.uploading] = 3 + ..[UploadTaskState.uploadedConfirming] = 4 + ..[UploadTaskState.waitingNetwork] = 5 + ..[UploadTaskState.preparationFailed] = 6 + ..[UploadTaskState.retryableFailed] = 7 + ..[UploadTaskState.completed] = 8 + ..[UploadTaskState.cancelled] = 9; + expect(pendingCountOf(counts), 15); + expect(failedCountOf(counts), 13); + }); + }); +} diff --git a/apps/mobile/test/helpers/fakes.dart b/apps/mobile/test/helpers/fakes.dart index 154e099..80ef64f 100644 --- a/apps/mobile/test/helpers/fakes.dart +++ b/apps/mobile/test/helpers/fakes.dart @@ -9,7 +9,11 @@ import 'dart:async'; import 'dart:convert'; import 'package:dio/dio.dart'; +import 'package:drift/drift.dart' show driftRuntimeOptions; +import 'package:photo_live/core/api_client.dart'; import 'package:photo_live/core/session_store.dart'; +import 'package:photo_live/features/uploads/providers.dart'; +import 'package:photo_live/features/uploads/task_store.dart'; /// 内存会话存储(测试替身)。 class FakeSessionStore implements SessionStore { @@ -25,6 +29,32 @@ class FakeSessionStore implements SessionStore { Future clear() async => session = null; } +/// Widget 测试通用 overrides(P3 Task 9 起,登录页/主壳会触达 +/// sessionAccountProvider 与任务库 Provider——真实 flutter_secure_storage +/// 与 path_provider 的平台通道在测试环境挂起,凡 pump 到相关页面均需 +/// 注入替身)。 +/// +/// 返回 [FakeSessionStore] + 内存 [TaskStore] + 可选 FakeAdapter ApiClient +/// 的 override 列表。Riverpod 3 已不公开 `Override` 类型(无法作签名注解), +/// 故元素类型为 dynamic——调用点以 spread 展开进 ProviderScope.overrides +/// (元素真实类型即 Override,展开本身类型安全): +/// `ProviderScope(overrides: [...pageTestOverrides(...), 其他], ...)`。 +List pageTestOverrides({ + FakeSessionStore? session, + TaskStore? taskStore, + ApiClient? client, +}) { + // 每个测试各自 new 一块内存库(独立 QueryExecutor,随容器销毁), + // drift 的“多实例同执行器”告警在此是误报——测试输出降噪。 + driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; + final store = taskStore ?? TaskStore.memory(); + return [ + if (session != null) sessionStoreProvider.overrideWithValue(session), + taskStoreProvider.overrideWithValue(store), + if (client != null) apiClientProvider.overrideWith((ref) => client), + ]; +} + /// 记录到适配器的请求快照。 class RecordedRequest { const RecordedRequest({ diff --git a/apps/mobile/test/pages/album_detail_page_test.dart b/apps/mobile/test/pages/album_detail_page_test.dart index cb0ee4b..f25d0d5 100644 --- a/apps/mobile/test/pages/album_detail_page_test.dart +++ b/apps/mobile/test/pages/album_detail_page_test.dart @@ -4,16 +4,31 @@ /// 分类管理三流(新增对话框载荷、名称空校验拦截、编辑 PATCH、删除确认)。 library; +import 'dart:io'; + +import 'package:camera/camera.dart' show XFile; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:photo_live/core/api_client.dart'; +import 'package:photo_live/core/notification_permission.dart'; +import 'package:photo_live/core/session_store.dart'; +import 'package:photo_live/features/uploads/enqueue.dart'; +import 'package:photo_live/features/uploads/preparer.dart'; +import 'package:photo_live/features/uploads/providers.dart'; +import 'package:photo_live/features/uploads/task_store.dart'; import 'package:photo_live/l10n/app_localizations.dart'; import 'package:photo_live/pages/album_detail_page.dart'; +import 'package:photo_live/pages/capture_page.dart'; import '../helpers/fakes.dart'; +/// JPEG 魔数最小字节(入队准备器真实文件驱动)。 +const List kJpgBytes = [ + 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, +]; + void main() { late FakeSessionStore store; late List> categories; @@ -55,7 +70,11 @@ void main() { Future pumpDetail(WidgetTester tester, ApiClient client) async { await tester.pumpWidget( ProviderScope( - overrides: [apiClientProvider.overrideWith((ref) => client)], + // Task 9 起详情页有上传 FAB(触达 sessionAccountProvider), + // 真实平台通道在测试环境挂起,必须注入替身。 + overrides: [ + ...pageTestOverrides(session: store, client: client), + ], child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, @@ -79,7 +98,8 @@ void main() { ); expect(find.text('sharetoken000000000001'), findsOneWidget); expect(find.text('大宴会厅'), findsOneWidget); - expect(find.text('Upload'), findsOneWidget); + // 「Upload」出现两次:开关标签 chip + Task 9 上传 FAB 文案。 + expect(find.text('Upload'), findsNWidgets(2)); expect(find.text('Original'), findsOneWidget); // 动作行(5 项)。 expect(find.text('Edit'), findsOneWidget); @@ -266,4 +286,131 @@ void main() { expect(find.text('仪式 · Ceremony'), findsNothing); expect(find.text('No categories yet. Tap + to add one'), findsOneWidget); }); + + group('上传入口 FAB(Task 9)', () { + late Directory tmp; + late List galleryPaths; + late TaskStore taskStore; + + setUp(() async { + store.session = Session( + token: 'tok-1', + expiresAt: DateTime.utc(2099, 1, 1), + account: 'alice', + ); + tmp = await Directory.systemTemp.createTemp('detail-upload-'); + galleryPaths = []; + taskStore = TaskStore.memory(); + }); + + tearDown(() async { + await taskStore.close(); + await tmp.delete(recursive: true); + }); + + /// 详情页 + 上传链路替身(内存任务库/临时目录准备器/多选替身/ + /// 非平台相机源)。 + Future pumpDetailForUpload(WidgetTester tester) async { + final adapter = detailAdapter(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + ...pageTestOverrides( + session: store, + taskStore: taskStore, + client: clientOf(adapter), + ), + multiImagePickerProvider + .overrideWith((ref) => () async => galleryPaths), + uploadPreparerProvider.overrideWith( + (ref) => UploadPreparer( + taskStore, + baseDirProvider: () async => tmp, + ), + ), + notificationPermissionProvider.overrideWith( + (ref) => NotificationPermission(isAndroid: false), + ), + captureSourceFactoryProvider + .overrideWith((ref) => () async => _StaticCaptureSource()), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const AlbumDetailPage(albumId: 'alb-1'), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('FAB → 底部弹层双入口(拍照上传 / 从相册选择)', (tester) async { + await pumpDetailForUpload(tester); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + + expect(find.text('Capture and upload'), findsOneWidget); + expect(find.text('Pick from gallery'), findsOneWidget); + }); + + testWidgets('拍照上传 → 进入连拍页(目标相册带出)', (tester) async { + await pumpDetailForUpload(tester); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Capture and upload')); + await tester.pumpAndSettle(); + + expect(find.byType(CapturePage), findsOneWidget); + // 连拍页常驻信息层显示目标相册名(与详情标题同源:默认 zh 主标题)。 + expect( + find.descendant( + of: find.byType(CapturePage), + matching: find.text('开幕晚宴'), + ), + findsOneWidget, + ); + }); + + testWidgets('从相册选择 → 多选入队 + 入队数提示', (tester) async { + final pick1 = File('${tmp.path}/pick1.jpg')..writeAsBytesSync(kJpgBytes); + final pick2 = File('${tmp.path}/pick2.jpg')..writeAsBytesSync(kJpgBytes); + galleryPaths.addAll([pick1.path, pick2.path]); + await pumpDetailForUpload(tester); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + // 入队链路含真实文件 IO:真实事件循环内触发行内动作。 + final tile = find.widgetWithText(ListTile, 'Pick from gallery'); + await tester.runAsync(() async { + (tile.evaluate().single.widget as ListTile).onTap!(); + await Future.delayed(const Duration(milliseconds: 300)); + }); + await tester.pumpAndSettle(); + + expect( + find.text('Added 2 photo(s) to the upload queue'), + findsOneWidget, + ); + final tasks = await taskStore.listByAccount('alice'); + expect(tasks, hasLength(2)); + expect(tasks.every((t) => t.albumId == 'alb-1'), isTrue); + }); + }); +} + +/// 相机源替身(详情页跳连拍页测试:仅就绪,不拍摄)。 +class _StaticCaptureSource implements CaptureSource { + @override + Future initialize() async {} + + @override + Future capture() => throw UnimplementedError(); + + @override + Widget buildPreview() => const SizedBox(); + + @override + Future dispose() async {} } diff --git a/apps/mobile/test/pages/capture_page_test.dart b/apps/mobile/test/pages/capture_page_test.dart new file mode 100644 index 0000000..13c2c44 --- /dev/null +++ b/apps/mobile/test/pages/capture_page_test.dart @@ -0,0 +1,336 @@ +/// 连拍页 widget 测试(P3 计划 Task 9,R17 连拍入队 / O01 现场反馈)。 +/// +/// 打桩面:FakeCaptureSource(capture 返回真实 JPEG 临时文件,无相机 +/// 平台通道)+ 内存 TaskStore + 临时目录准备器 + 非 Android 权限门替身 +/// (静默跳过通道)。覆盖:快门即拍即入队、连拍串行、分类切换仅影响 +/// 之后拍摄、多选补充入队、待传/失败徽章实时刷新、有待传任务退出确认、 +/// 权限拒绝/初始化失败视图与重试。 +library; + +import 'dart:io'; + +import 'package:camera/camera.dart' show CameraException, XFile; +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:photo_live/core/api_client.dart'; +import 'package:photo_live/core/notification_permission.dart'; +import 'package:photo_live/core/session_store.dart'; +import 'package:photo_live/features/albums/models.dart'; +import 'package:photo_live/features/uploads/enqueue.dart'; +import 'package:photo_live/features/uploads/preparer.dart'; +import 'package:photo_live/features/uploads/providers.dart'; +import 'package:photo_live/features/uploads/queue_controller.dart'; +import 'package:photo_live/features/uploads/task_state.dart'; +import 'package:photo_live/features/uploads/task_store.dart'; +import 'package:photo_live/l10n/app_localizations.dart'; +import 'package:photo_live/pages/capture_page.dart'; + +import '../helpers/fakes.dart'; + +const List kJpgBytes = [ + 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, +]; + +/// 拍摄源替身:capture 依次弹出预置文件;initialize 可注入失败次数。 +class FakeCaptureSource implements CaptureSource { + FakeCaptureSource(this._files); + + final List _files; + int initializeCalls = 0; + int failNextInits = 0; + Object? initError; + + @override + Future initialize() async { + initializeCalls++; + if (failNextInits > 0) { + failNextInits--; + throw initError ?? StateError('init failed'); + } + } + + @override + Future capture() async => XFile(_files.removeAt(0).path); + + @override + Widget buildPreview() => const ColoredBox( + color: Color(0xFF101010), + child: Center(child: Text('PREVIEW_MARK')), + ); + + @override + Future dispose() async {} +} + +void main() { + late FakeSessionStore session; + late TaskStore store; + late Directory tmp; + late FakeCaptureSource source; + late List galleryPaths; + + final categories = [ + const Category( + id: 'cat-1', + albumId: 'alb-1', + sortOrder: 0, + translations: [ + CategoryTranslation(locale: 'zh-CN', name: '仪式'), + CategoryTranslation(locale: 'en', name: 'Ceremony'), + ], + ), + const Category( + id: 'cat-2', + albumId: 'alb-1', + sortOrder: 1, + translations: [ + CategoryTranslation(locale: 'zh-CN', name: '晚宴'), + CategoryTranslation(locale: 'en', name: 'Gala'), + ], + ), + ]; + + setUp(() async { + session = FakeSessionStore() + ..session = Session( + token: 'tok-1', + expiresAt: DateTime.utc(2099, 1, 1), + account: 'alice', + ); + store = TaskStore.memory(); + tmp = await Directory.systemTemp.createTemp('capture-test-'); + final shot1 = File('${tmp.path}/IMG_0001.jpg')..writeAsBytesSync(kJpgBytes); + final shot2 = File('${tmp.path}/IMG_0002.jpg')..writeAsBytesSync(kJpgBytes); + final shot3 = File('${tmp.path}/IMG_0003.jpg')..writeAsBytesSync(kJpgBytes); + source = FakeCaptureSource([shot1, shot2, shot3]); + galleryPaths = []; + }); + + tearDown(() async { + await store.close(); + await tmp.delete(recursive: true); + }); + + CaptureTarget target() => CaptureTarget( + accountId: 'alice', + albumId: 'alb-1', + albumTitle: 'Opening Gala', + categories: categories, + ); + + Future pumpCapture(WidgetTester tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + ...pageTestOverrides( + session: session, + taskStore: store, + client: ApiClient( + sessionStore: session, + dio: Dio(BaseOptions(baseUrl: 'http://test.local')) + ..httpClientAdapter = FakeAdapter( + (_) => throw StateError('连拍页不应发起 API 请求')), + ), + ), + captureSourceFactoryProvider + .overrideWith((ref) => () async => source), + uploadPreparerProvider.overrideWith( + (ref) => UploadPreparer( + store, + baseDirProvider: () async => tmp, + ), + ), + notificationPermissionProvider.overrideWith( + (ref) => NotificationPermission(isAndroid: false), + ), + multiImagePickerProvider + .overrideWith((ref) => () async => galleryPaths), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: CapturePage(target: target()), + ), + ), + ); + // 相机初始化完成:帧级 pump 直至预览出现(首个测试字体/主题冷启动 + // 较慢,固定双 pump 不够稳)。 + await tester.pump(); + var guard = 0; + while (find.text('PREVIEW_MARK').evaluate().isEmpty && guard++ < 20) { + await tester.pump(const Duration(milliseconds: 50)); + } + } + + /// 触发一个会走真实文件 IO 的按钮:在 runAsync(真实事件循环)内直接 + /// 调用其回调——widget 测试的 fake-async 区里 dart:io 完成回调不会被 + /// pump 冲刷,入队链路必须整体跑在真实区;返回后 pump 刷新徽章防抖。 + Future pressRealIo(WidgetTester tester, IconData icon) async { + final button = find + .ancestor(of: find.byIcon(icon), matching: find.byType(IconButton)) + .evaluate() + .single + .widget as IconButton; + await tester.runAsync(() async { + button.onPressed!(); + await Future.delayed(const Duration(milliseconds: 300)); + }); + await tester.pump(kQueueRefreshDebounce); + await tester.pump(const Duration(milliseconds: 60)); + } + + /// 快门(即拍即入队链路经 [pressRealIo])。 + Future shoot(WidgetTester tester, {int times = 1}) async { + for (var i = 0; i < times; i++) { + await pressRealIo(tester, Icons.camera); + } + } + + testWidgets('快门即拍即入队:任务落库 queued + 待传徽章 1', (tester) async { + await pumpCapture(tester); + expect(find.text('PREVIEW_MARK'), findsOneWidget, reason: '相机预览就绪'); + expect(find.text('Pending 0'), findsOneWidget); + + await shoot(tester); + + final tasks = await store.listByAccount('alice'); + expect(tasks, hasLength(1)); + expect(tasks.single.state, UploadTaskState.queued); + expect(tasks.single.accountId, 'alice'); + expect(tasks.single.albumId, 'alb-1'); + expect(tasks.single.categoryId, isNull, reason: '默认分类'); + expect(tasks.single.filename, 'IMG_0001.jpg'); + expect(find.text('Pending 1'), findsOneWidget, reason: '徽章实时刷新'); + }); + + testWidgets('连拍两张串行入队(不逐张确认)', (tester) async { + await pumpCapture(tester); + + await shoot(tester, times: 2); + + final tasks = await store.listByAccount('alice'); + expect(tasks.map((t) => t.filename), unorderedEquals([ + 'IMG_0001.jpg', + 'IMG_0002.jpg', + ])); + expect(find.text('Pending 2'), findsOneWidget); + }); + + testWidgets('分类切换:hint 提示 + 仅影响之后拍摄(A15 归属不变)', + (tester) async { + await pumpCapture(tester); + await shoot(tester); // 第一张:默认分类。 + + await tester.tap(find.text('Gala')); + await tester.pump(); + expect( + find.text('Switching only affects photos captured afterwards'), + findsOneWidget, + reason: '切换归属不变提示', + ); + await tester.pumpAndSettle(); // snackbar 退场。 + + await shoot(tester); // 第二张:新分类。 + + final tasks = await store.listByAccount('alice'); + final first = + tasks.firstWhere((t) => t.filename == 'IMG_0001.jpg'); + final second = + tasks.firstWhere((t) => t.filename == 'IMG_0002.jpg'); + expect(first.categoryId, isNull, reason: '既有任务归属不变'); + expect(second.categoryId, 'cat-2', reason: '切换后拍摄走新分类'); + }); + + testWidgets('相册多选补充入队 → 入队数提示', (tester) async { + final pick1 = File('${tmp.path}/pick1.jpg')..writeAsBytesSync(kJpgBytes); + final pick2 = File('${tmp.path}/pick2.jpg')..writeAsBytesSync(kJpgBytes); + galleryPaths.addAll([pick1.path, pick2.path]); + await pumpCapture(tester); + + // 多选入队走真实文件 IO(同快门路径)。 + await pressRealIo(tester, Icons.photo_library_outlined); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text('Added 2 photo(s) to the upload queue'), findsOneWidget); + expect(await store.listByAccount('alice'), hasLength(2)); + }); + + testWidgets('退出确认:有待传任务 → 弹确认;保留继续拍 / 确认才退出', + (tester) async { + await pumpCapture(tester); + expect(find.byType(CapturePage), findsOneWidget); + + await shoot(tester); + + // home 路由无返回按钮——经 Navigator.maybePop 驱动系统返回路径 + //(PopScope.canPop=false → onPopInvokedWithResult 弹确认)。 + Future requestBack() async { + final navigator = tester.state( + find.byType(Navigator).first, + ); + await navigator.maybePop(); + await tester.pumpAndSettle(); + } + + // 有待传 → 弹确认。 + await requestBack(); + expect(find.text('Leave shooting?'), findsOneWidget); + expect( + find.textContaining('still uploading'), + findsOneWidget, + reason: '正文含待传数与后台继续说明', + ); + + // 保留继续拍 → 不退出。 + await tester.tap(find.text('Keep shooting')); + await tester.pumpAndSettle(); + expect(find.byType(CapturePage), findsOneWidget); + + // 再次退出 → 确认离开 → 出栈。 + await requestBack(); + await tester.tap(find.text('Leave')); + await tester.pumpAndSettle(); + expect(find.byType(CapturePage), findsNothing); + }); + + testWidgets('权限拒绝视图:文案 + 重试入口', (tester) async { + source.initError = CameraException('CameraAccessDenied', 'denied'); + source.failNextInits = 1; + await pumpCapture(tester); + + expect( + find.text( + 'Camera unavailable. Allow camera access in system settings'), + findsOneWidget, + ); + // 重试成功 → 预览就绪。 + await tester.tap(find.text('Retry')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + expect(find.text('PREVIEW_MARK'), findsOneWidget); + }); + + testWidgets('初始化失败视图:非权限异常文案 + 重试恢复', (tester) async { + source.failNextInits = 1; + source.initError = StateError('camera broken'); + await pumpCapture(tester); + + expect(find.text('Camera failed to initialize'), findsOneWidget); + + await tester.tap(find.text('Retry')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + expect(find.text('PREVIEW_MARK'), findsOneWidget); + }); + + testWidgets('常驻信息层:相册名 + 分类候选', (tester) async { + await pumpCapture(tester); + + expect(find.text('Opening Gala'), findsOneWidget); + expect(find.text('Default category'), findsOneWidget); + expect(find.text('Ceremony'), findsOneWidget); + expect(find.text('Gala'), findsOneWidget); + }); +} diff --git a/apps/mobile/test/pages/login_page_test.dart b/apps/mobile/test/pages/login_page_test.dart index d012dff..7cccd4e 100644 --- a/apps/mobile/test/pages/login_page_test.dart +++ b/apps/mobile/test/pages/login_page_test.dart @@ -37,7 +37,11 @@ void main() { Future pumpLoginPage(WidgetTester tester, ApiClient client) async { await tester.pumpWidget( ProviderScope( - overrides: [apiClientProvider.overrideWith((ref) => client)], + // Task 9 起登录成功会刷新 sessionAccountProvider——真实 + // flutter_secure_storage 平台通道在测试环境挂起,必须注入替身。 + overrides: [ + ...pageTestOverrides(session: store, client: client), + ], child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, diff --git a/apps/mobile/test/pages/queue_tab_test.dart b/apps/mobile/test/pages/queue_tab_test.dart new file mode 100644 index 0000000..f1335a9 --- /dev/null +++ b/apps/mobile/test/pages/queue_tab_test.dart @@ -0,0 +1,546 @@ +/// 上传队列页 widget 测试(P3 计划 Task 9,需求 4.3)。 +/// +/// 打桩面:RecordingEngine(记录 retryNow/cancel,不出队)+ 内存 TaskStore +/// 种子 + FakeAdapter 相册列表 + 临时目录准备器(准备失败重试换源)。 +/// 覆盖:任务行渲染(状态徽章/错误文案/相册名)、三维筛选交互、按状态 +/// 动作(重试/取消/删除)、批量重试条、写通知防抖实时刷新、行级进度条。 +library; + +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:photo_live/core/api_client.dart'; +import 'package:photo_live/core/device_id.dart'; +import 'package:photo_live/core/session_store.dart'; +import 'package:photo_live/features/uploads/enqueue.dart'; +import 'package:photo_live/features/uploads/preparer.dart'; +import 'package:photo_live/features/uploads/providers.dart'; +import 'package:photo_live/features/uploads/queue_controller.dart'; +import 'package:photo_live/features/uploads/task_state.dart'; +import 'package:photo_live/features/uploads/task_store.dart'; +import 'package:photo_live/features/uploads/upload_engine.dart'; +import 'package:photo_live/l10n/app_localizations.dart'; +import 'package:photo_live/pages/queue_tab.dart'; + +import '../helpers/fakes.dart'; + +/// JPEG 魔数(准备器换源重试用真实临时文件)。 +const List kJpgBytes = [ + 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, +]; + +class _FixedDeviceIdStore implements DeviceIdStore { + @override + Future read() async => '0123456789abcdef0123456789abcdef'; + @override + Future write(String value) async {} +} + +/// 引擎替身:记录 retryNow/cancel,不出队(动作流断言点;真实出队语义 +/// 由 upload_engine_test 覆盖)。 +class RecordingEngine extends UploadEngine { + RecordingEngine( + super.store, + super.api, + super.deviceId, { + required Dio objectDio, + }) : super(objectDio: objectDio); + + final List<(String, String)> retryCalls = <(String, String)>[]; + final List<(String, String)> cancelCalls = <(String, String)>[]; + + @override + Future retryNow(String accountId, String taskId) async => + retryCalls.add((accountId, taskId)); + + @override + Future cancel(String accountId, String taskId) async { + cancelCalls.add((accountId, taskId)); + return true; + } + + @override + void notifyWorkAvailable() {} +} + +void main() { + late FakeSessionStore session; + late TaskStore store; + late RecordingEngine engine; + late ApiClient api; + late Directory tmp; + late List pickedPaths; + + setUp(() async { + session = FakeSessionStore() + ..session = Session( + token: 'tok-1', + expiresAt: DateTime.utc(2099, 1, 1), + account: 'alice', + ); + store = TaskStore.memory(); + api = ApiClient( + sessionStore: session, + dio: Dio(BaseOptions(baseUrl: 'http://test.local')) + ..httpClientAdapter = FakeAdapter((o) { + if (o.method == 'GET' && o.path == '/v1/albums') { + return ( + 200, + cursorPageBody(>[ + albumJson(id: 'alb-1'), + albumJson( + id: 'alb-2', + translations: >[ + { + 'locale': 'en', + 'title': 'Wedding Day', + 'description': null, + 'venue': null, + 'address': null, + }, + ], + ), + ]), + ); + } + throw StateError('unexpected ${o.method} ${o.path}'); + }), + ); + engine = RecordingEngine( + store, + api, + DeviceId(store: _FixedDeviceIdStore()), + objectDio: Dio() + ..httpClientAdapter = + FakeAdapter((_) => throw StateError('替身引擎不应发起传输')), + ); + tmp = await Directory.systemTemp.createTemp('queue-tab-test-'); + pickedPaths = []; + }); + + tearDown(() async { + await store.close(); + await tmp.delete(recursive: true); + }); + + Future pumpQueue(WidgetTester tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + ...pageTestOverrides(session: session, taskStore: store, client: api), + uploadEngineProvider.overrideWith((ref) => engine), + uploadPreparerProvider.overrideWith( + (ref) => UploadPreparer( + store, + baseDirProvider: () async => tmp, + ), + ), + singleImagePickerProvider + .overrideWith((ref) => () async => pickedPaths.removeAt(0)), + ], + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: QueueTab(), + ), + ), + ); + // 不用 pumpAndSettle:uploading 行的不确定态进度条永远动画(帧不断)。 + await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(const Duration(milliseconds: 300)); + } + + /// 种子任务(真实迁移链到目标态;attempt/lastError 供失败文案断言)。 + Future seed( + String filename, { + String albumId = 'alb-1', + UploadTaskState state = UploadTaskState.queued, + DateTime? createdAt, + String? lastError, + int? attempt, + }) async { + final id = 'task-$filename'; + var task = await store.create( + UploadTaskDraft( + id: id, + accountId: 'alice', + albumId: albumId, + clientTaskId: 'ct-$filename', + sourceFilePath: '${tmp.path}/$filename', + filename: filename, + bytes: 16, + contentType: 'image/jpeg', + ), + now: createdAt, + ); + Future to(UploadTaskState s, + {String? error, int? tries}) => + store.updateState( + task.id, + s, + patch: UploadTaskPatch(lastError: error, attempt: tries), + now: createdAt, + ); + switch (state) { + case UploadTaskState.preparing: + break; + case UploadTaskState.queued: + case UploadTaskState.preparationFailed: + case UploadTaskState.cancelled: + task = await to(state, error: lastError); + case UploadTaskState.waitingNetwork: + task = await to(UploadTaskState.queued); + task = await to(state, error: lastError); + case UploadTaskState.uploading: + case UploadTaskState.retryableFailed: + task = await to(UploadTaskState.queued); + task = await to(UploadTaskState.uploading); + if (state == UploadTaskState.retryableFailed) { + task = await to(state, error: lastError, tries: attempt); + } + case UploadTaskState.uploadedConfirming: + case UploadTaskState.completed: + task = await to(UploadTaskState.queued); + task = await to(UploadTaskState.uploading); + task = await to(UploadTaskState.uploadedConfirming); + if (state == UploadTaskState.completed) { + task = await to(state); + } + } + return task; + } + + /// 等防抖重查落定(写通知 → 200ms Timer → 异步查询;不 pumpAndSettle—— + /// uploading 行的不确定态进度条动画不会停)。 + Future settleDebounce(WidgetTester tester) async { + await tester.pump(kQueueRefreshDebounce); + await tester.pump(const Duration(milliseconds: 120)); + } + + testWidgets('各状态任务行:徽章文案 + 相册名(缓存)+ 缩略图占位', + (tester) async { + await seed('prep.jpg', state: UploadTaskState.preparing); + await seed('ok.jpg', state: UploadTaskState.completed); + await seed('net.jpg', state: UploadTaskState.waitingNetwork); + await seed('alb2.jpg', + albumId: 'alb-2', state: UploadTaskState.uploading); + await pumpQueue(tester); + + // 状态徽章(en locale;断言锚定在行内——chip 栏文案与部分徽章同名)。 + expect( + find + .descendant( + of: find.widgetWithText(ListTile, 'prep.jpg'), + matching: find.text('Preparing'), + ) + .evaluate(), + isNotEmpty, + ); + expect( + find + .descendant( + of: find.widgetWithText(ListTile, 'ok.jpg'), + matching: find.text('Completed'), + ) + .evaluate(), + isNotEmpty, + ); + expect( + find + .descendant( + of: find.widgetWithText(ListTile, 'net.jpg'), + matching: find.text('Waiting for network'), + ) + .evaluate(), + isNotEmpty, + ); + expect( + find + .descendant( + of: find.widgetWithText(ListTile, 'alb2.jpg'), + matching: find.text('Uploading'), + ) + .evaluate(), + isNotEmpty, + ); + // 文件名。 + expect(find.text('ok.jpg'), findsOneWidget); + // 相册名经 albums 缓存展示(en 主标题;3 条 alb-1 + 1 条 alb-2)。 + expect(find.text('Opening Gala'), findsNWidgets(3)); + expect(find.text('Wedding Day'), findsOneWidget); + // 缩略图:每行一个 Image.file(源缺失走 errorBuilder 占位;fake-async + // 下真实 IO 不完成,仅断言 Image 挂载)。 + expect(find.byType(Image), findsNWidgets(4)); + }); + + testWidgets('失败任务:本地化错误文案 + 尝试次数', (tester) async { + await seed( + 'big.jpg', + state: UploadTaskState.preparationFailed, + lastError: 'PREP_TOO_LARGE', + ); + await seed( + 'flaky.jpg', + state: UploadTaskState.retryableFailed, + lastError: 'NETWORK_ERROR', + attempt: 2, + ); + await pumpQueue(tester); + + expect(find.text('Photo exceeds the 50MB limit'), findsOneWidget); + expect( + find.text('Network error. Check your connection and retry · ' + '2 attempt(s)'), + findsOneWidget, + ); + }); + + testWidgets('状态筛选:Failed chip → 仅失败行', (tester) async { + await seed('bad.jpg', state: UploadTaskState.preparationFailed); + await seed('good.jpg', state: UploadTaskState.completed); + await pumpQueue(tester); + + // 状态 chips 行横向滚动——Failed 在屏外,先滚入再点。 + await tester.ensureVisible(find.text('Failed')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Failed')); + await settleDebounce(tester); + + expect(find.text('bad.jpg'), findsOneWidget); + expect(find.text('good.jpg'), findsNothing); + }); + + testWidgets('时间筛选:Today → 昨日任务排除', (tester) async { + final now = DateTime.now(); + await seed('fresh.jpg', createdAt: now); + await seed( + 'old.jpg', + createdAt: now.subtract(const Duration(days: 40)), + ); + await pumpQueue(tester); + expect(find.text('old.jpg'), findsOneWidget, reason: '默认全部时间'); + + await tester.tap(find.text('Today')); + await settleDebounce(tester); + + expect(find.text('fresh.jpg'), findsOneWidget); + expect(find.text('old.jpg'), findsNothing); + }); + + testWidgets('相册筛选:下拉选中 alb-2 → 仅该相册行', (tester) async { + await seed('one.jpg', albumId: 'alb-1'); + await seed('two.jpg', albumId: 'alb-2'); + await pumpQueue(tester); + + await tester.tap(find.text('All albums')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Wedding Day').last); + await settleDebounce(tester); + + expect(find.text('two.jpg'), findsOneWidget); + expect(find.text('one.jpg'), findsNothing); + }); + + testWidgets('按状态动作可见性:queued 取消 / completed 删除 / uploading 无', + (tester) async { + await seed('q.jpg', state: UploadTaskState.queued); + await seed('done.jpg', state: UploadTaskState.completed); + await seed('up.jpg', state: UploadTaskState.uploading); + await pumpQueue(tester); + + // queued 行 → 取消图标;completed 行 → 删除图标;uploading 行无动作。 + final cancelTile = find.widgetWithText(ListTile, 'q.jpg'); + final doneTile = find.widgetWithText(ListTile, 'done.jpg'); + final upTile = find.widgetWithText(ListTile, 'up.jpg'); + expect( + find + .descendant(of: cancelTile, matching: find.byIcon(Icons.close)) + .evaluate() + .length, + 1, + reason: 'queued 行有取消按钮', + ); + expect( + find + .descendant( + of: doneTile, + matching: find.byIcon(Icons.delete_outline), + ) + .evaluate() + .length, + 1, + reason: 'completed 行有删除按钮', + ); + expect( + find.descendant(of: upTile, matching: find.byType(IconButton)).evaluate(), + isEmpty, + reason: 'uploading 行无动作(传输中不打断)', + ); + }); + + testWidgets('取消流:queued 行点取消 → engine.cancel(账号, 任务)', + (tester) async { + final task = await seed('q.jpg'); + await pumpQueue(tester); + + await tester.tap(find.descendant( + of: find.widgetWithText(ListTile, 'q.jpg'), + matching: find.byIcon(Icons.close), + )); + await tester.pumpAndSettle(); + + expect(engine.cancelCalls, [('alice', task.id)]); + }); + + testWidgets('重试流:retryableFailed 行 → engine.retryNow', + (tester) async { + final task = await seed( + 'flaky.jpg', + state: UploadTaskState.retryableFailed, + lastError: 'INTERNAL_ERROR', + ); + await pumpQueue(tester); + + await tester.tap(find.descendant( + of: find.widgetWithText(ListTile, 'flaky.jpg'), + matching: find.byIcon(Icons.refresh), + )); + await tester.pumpAndSettle(); + + expect(engine.retryCalls, [('alice', task.id)]); + }); + + testWidgets('准备失败重试:重新选图 → 换源重新入队为 queued', + (tester) async { + final task = await seed( + 'broken.jpg', + state: UploadTaskState.preparationFailed, + lastError: 'PREP_SOURCE_UNREADABLE', + ); + // 替身选图器返回真实 JPEG 临时文件。 + final picked = File('${tmp.path}/repicked.jpg') + ..writeAsBytesSync(kJpgBytes); + pickedPaths.add(picked.path); + await pumpQueue(tester); + + // 换源复制是真实文件 IO——runAsync 放行真实事件循环完成。 + await tester.runAsync(() async { + await tester.tap(find.descendant( + of: find.widgetWithText(ListTile, 'broken.jpg'), + matching: find.byIcon(Icons.refresh), + )); + await Future.delayed(const Duration(milliseconds: 400)); + }); + await settleDebounce(tester); + + final updated = await store.get('alice', task.id); + expect(updated?.state, UploadTaskState.queued); + expect(updated?.lastError, isNull); + // 重试换源 = 新源复制进同一受管路径(路径不变、内容替换)。 + expect(updated?.sourceFilePath, task.sourceFilePath); + expect(File(task.sourceFilePath).readAsBytesSync(), kJpgBytes, + reason: '受管副本内容已替换为重选文件'); + }); + + testWidgets('删除流:completed 行删除 → 行消失 + 库内清除', + (tester) async { + final task = await seed('done.jpg', state: UploadTaskState.completed); + await pumpQueue(tester); + + await tester.tap(find.descendant( + of: find.widgetWithText(ListTile, 'done.jpg'), + matching: find.byIcon(Icons.delete_outline), + )); + await settleDebounce(tester); + + expect(find.text('done.jpg'), findsNothing); + expect(await store.get('alice', task.id), isNull); + }); + + testWidgets('批量重试条:出现条件 + 一键重试全部可自动重试任务', + (tester) async { + final auto1 = await seed( + 'f1.jpg', + state: UploadTaskState.retryableFailed, + lastError: 'INTERNAL_ERROR', + ); + final auto2 = await seed( + 'f2.jpg', + state: UploadTaskState.waitingNetwork, + lastError: 'NETWORK_ERROR', + ); + await seed( + 'f3.jpg', + state: UploadTaskState.preparationFailed, + lastError: 'PREP_COPY_FAILED', + ); + await pumpQueue(tester); + + // 2 条可自动重试(retryableFailed + waitingNetwork)→ 批量条出现。 + expect(find.text('Retry all failed'), findsOneWidget); + expect(find.text('Failed · 2'), findsOneWidget, + reason: '失败计数 = 准备失败 + 上传失败(不含等待网络)'); + + await tester.tap(find.text('Retry all failed')); + await tester.pumpAndSettle(); + + expect(engine.retryCalls, containsAll([ + ('alice', auto1.id), + ('alice', auto2.id), + ])); + expect( + engine.retryCalls.map((c) => c.$2), + isNot(contains('task-f3.jpg')), + reason: '准备失败需换源重选,不进批量重试', + ); + }); + + testWidgets('无失败任务 → 无批量条', (tester) async { + await seed('q.jpg'); + await pumpQueue(tester); + + expect(find.text('Retry all failed'), findsNothing); + }); + + testWidgets('实时刷新:库内新增任务 → 防抖后列表出现(写通知驱动)', + (tester) async { + await seed('first.jpg'); + await pumpQueue(tester); + expect(find.text('second.jpg'), findsNothing); + + // 模拟连拍页入队(不经 UI 直接写库)。 + await seed('second.jpg'); + await settleDebounce(tester); + + expect(find.text('second.jpg'), findsOneWidget); + }); + + testWidgets('行级进度:uploading 行订阅进度总线,分数更新', + (tester) async { + final task = await seed('up.jpg', state: UploadTaskState.uploading); + await pumpQueue(tester); + + final context = tester.element(find.byType(QueueTab)); + final hub = ProviderScope.containerOf(context) + .read(uploadProgressHubProvider); + + LinearProgressIndicator indicatorOf() => find + .descendant( + of: find.widgetWithText(ListTile, 'up.jpg'), + matching: find.byType(LinearProgressIndicator), + ) + .evaluate() + .single + .widget as LinearProgressIndicator; + + expect(indicatorOf().value, isNull, reason: '无事件时不确定态进度条'); + + hub.emit(task.id, 50, 100); + await tester.pump(); + + expect(indicatorOf().value, 0.5, reason: '进度事件行级刷新为 50%'); + }); +} diff --git a/apps/mobile/test/pages/settings_tab_test.dart b/apps/mobile/test/pages/settings_tab_test.dart index f7a1c67..56b22b2 100644 --- a/apps/mobile/test/pages/settings_tab_test.dart +++ b/apps/mobile/test/pages/settings_tab_test.dart @@ -38,7 +38,11 @@ void main() { Future pumpHome(WidgetTester tester, ApiClient client) async { await tester.pumpWidget( ProviderScope( - overrides: [apiClientProvider.overrideWith((ref) => client)], + // Task 9 起主壳含队列页(触达 sessionAccountProvider/TaskStore), + // 真实平台通道在测试环境挂起,必须注入替身。 + overrides: [ + ...pageTestOverrides(session: store, client: client), + ], child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales,