feat(mobile): upload queue ui and continuous capture page
Task 9 (P3 plan): queue tab replaces the stub, continuous capture page enqueues per shot, album detail gains an upload FAB, and the upload engine lifecycle is now bound to the login session. - Engine scope: session account drives UploadEngine start/stop; wired from app boot, login success, logout, and 401 eviction. Tasks stay in the store across logout (accountId hard isolation). - Queue tab: album x state x time filter bar, task rows with thumbnail, state chip, localized error copy, attempt count, per-row progress via a broadcast progress hub, state-dependent actions (re-pick retry, retryNow, cancel, delete), batch retry bar, 200ms-debounced live refresh from drift table updates. - Capture page: camera preview via injected CaptureSource, one enqueue per shutter press, persistent album/category/badges overlay with a switch-only-affects-new-shots hint, gallery multi-pick, exit confirm while uploads are pending, permission-denied and init-failure views. - POST_NOTIFICATIONS runtime permission via a small MethodChannel (photolive/permissions) + PermissionPlugin.kt: single runtime permission need, avoids permission_handler dependency; requested once on first enqueue, silent degradation when denied or unavailable. - Album detail FAB: capture-and-upload / pick-from-gallery sheet. - l10n: ~50 new keys in zh/en arbs; tests: +45 (filter matrix, engine scope lifecycle, permission gate, queue tab, capture page, FAB entry); existing page tests updated to inject session/task-store fakes. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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<out String>,
|
||||
grantResults: IntArray,
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
PermissionPlugin.onRequestPermissionsResult(requestCode, grantResults)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/api_client.dart';
|
||||
import '../features/uploads/engine_scope.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../pages/home_page.dart';
|
||||
import '../pages/login_page.dart';
|
||||
@@ -14,12 +15,15 @@ import 'theme.dart';
|
||||
final GlobalKey<NavigatorState> rootNavigatorKey =
|
||||
GlobalKey<NavigatorState>();
|
||||
|
||||
/// App 根组件:主题 + l10n 委托 + 命名路由 + 401 驱逐接线。
|
||||
/// App 根组件:主题 + l10n 委托 + 命名路由 + 401 驱逐接线 + 引擎生命周期。
|
||||
///
|
||||
/// 基线路由为命名路由(/login → /home → /password-change);随页面增长再
|
||||
/// 评估是否迁移 go_router,当前保持零额外依赖。
|
||||
/// initState 注册 [ApiClient.onUnauthorized]:任意业务端点 401(会话过期)
|
||||
/// 即清空栈回到 /login(登录/修改密码端点自身的 401 不触发,见 ApiClient)。
|
||||
/// 即清空栈回到 /login(登录/修改密码端点自身的 401 不触发,见 ApiClient),
|
||||
/// 并同步刷新会话账号(引擎 stop,任务保留库内)。
|
||||
/// build watch [uploadEngineScopeProvider]——App 存续期间引擎随登录态
|
||||
/// start/stop(Task 9 接线,见 engine_scope.dart)。
|
||||
class PhotoLiveApp extends ConsumerStatefulWidget {
|
||||
const PhotoLiveApp({super.key});
|
||||
|
||||
@@ -34,12 +38,16 @@ class _PhotoLiveAppState extends ConsumerState<PhotoLiveApp> {
|
||||
ref.read(apiClientProvider).onUnauthorized = () {
|
||||
rootNavigatorKey.currentState
|
||||
?.pushNamedAndRemoveUntil('/login', (route) => false);
|
||||
// 401 已清会话(ApiClient):刷新账号观察 → 引擎 stop。
|
||||
ref.read(sessionAccountProvider.notifier).refresh();
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final locale = ref.watch(localeSettingsProvider);
|
||||
// 引擎生命周期绑定(常驻 watch;账号在场 start / 离场 stop)。
|
||||
ref.watch(uploadEngineScopeProvider);
|
||||
return MaterialApp(
|
||||
navigatorKey: rootNavigatorKey,
|
||||
onGenerateTitle: (context) => AppLocalizations.of(context).appName,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/// POST_NOTIFICATIONS 运行时权限门(P3 计划 Task 9)。
|
||||
///
|
||||
/// Android 13+(API 33)起,上传前台服务通知需运行时授权;未授权时
|
||||
/// 传输继续、仅通知不显示——**静默降级,永不阻断入队**(技术方案 §6.3)。
|
||||
///
|
||||
/// 实现取舍(有意不引入 permission_handler):本 App 全部运行时权限需求
|
||||
/// 仅此一项(camera/image_picker 插件自管权限),为单个权限引入
|
||||
/// permission_handler 全家桶(AndroidX 传递依赖 + iOS Podfile 宏配置)
|
||||
/// 不成比例——复用既有原生桥接模式(Task 7 UploadPlugin 同款),自建
|
||||
/// MethodChannel `photolive/permissions`(MainActivity 内 PermissionPlugin
|
||||
/// 约 60 行 Kotlin,SDK<33 直接返回 granted)。后续再增权限需求时评估
|
||||
/// 插件化。
|
||||
///
|
||||
/// 进程内单次语义:首次入队动作(连拍/相册选择)时请求一次,之后不再
|
||||
/// 打扰;拒绝或通道失败(iOS/桌面无此通道)一律静默。
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// 通知权限门(注入 [channel]/[isAndroid] 供测试)。
|
||||
class NotificationPermission {
|
||||
NotificationPermission({
|
||||
MethodChannel? channel,
|
||||
bool? isAndroid,
|
||||
}) : _channel = channel ?? const MethodChannel(kChannelName),
|
||||
_isAndroid = isAndroid ?? (!kIsWeb && Platform.isAndroid);
|
||||
|
||||
/// 平台通道名(与 Kotlin `PermissionPlugin.CHANNEL` 一致)。
|
||||
static const String kChannelName = 'photolive/permissions';
|
||||
|
||||
final MethodChannel _channel;
|
||||
final bool _isAndroid;
|
||||
bool _requested = false;
|
||||
|
||||
/// 确保已请求一次(首次入队动作调用;幂等)。
|
||||
///
|
||||
/// 非 Android 平台直接跳过(iOS 后台传输不依赖通知权限);通道调用
|
||||
/// 失败/拒绝静默吞掉——上传链路不因通知权限降级而中断。
|
||||
Future<void> ensureRequested() async {
|
||||
if (_requested) return;
|
||||
_requested = true;
|
||||
if (!_isAndroid) return;
|
||||
try {
|
||||
await _channel.invokeMethod<String>('requestPostNotifications');
|
||||
} on Object {
|
||||
// 通道缺失(iOS/桌面)或请求异常:静默降级。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 生产 Provider(队列/连拍页经 ref.read 一次性调用)。
|
||||
final notificationPermissionProvider =
|
||||
Provider<NotificationPermission>((ref) => NotificationPermission());
|
||||
@@ -0,0 +1,67 @@
|
||||
/// 引擎生命周期绑定(P3 计划 Task 6 关注点 1,Task 9 接线)。
|
||||
///
|
||||
/// 登录会话账号(SessionStore.account)→ [UploadEngine.start] 的 scope:
|
||||
/// - 账号在场(App 启动恢复会话 / 登录成功)→ start(accountId)——待传
|
||||
/// 队列尽快恢复出队(A05);
|
||||
/// - 账号离场(登出 / 401 驱逐)→ stop()——**任务行保留在库内**,账号
|
||||
/// 切换安全由 TaskStore 的 accountId 硬隔离守护保障;
|
||||
/// - 会话变更通知点:登录页成功、设置页登出、App 壳 401 回调,均调
|
||||
/// [SessionAccountNotifier.refresh]。
|
||||
///
|
||||
/// [uploadEngineScopeProvider] 由 App 壳(PhotoLiveApp.build)watch 保持
|
||||
/// 常驻——离开登录态即 stop,绝不遗留旧账号的周期出队。
|
||||
library;
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/api_client.dart';
|
||||
import 'providers.dart';
|
||||
import 'upload_engine.dart';
|
||||
|
||||
/// 会话账号 Notifier:读 SessionStore;登录/登出/401 后 refresh()。
|
||||
class SessionAccountNotifier extends AsyncNotifier<String?> {
|
||||
@override
|
||||
Future<String?> build() async {
|
||||
final session = await ref.watch(sessionStoreProvider).read();
|
||||
return _normalize(session?.account);
|
||||
}
|
||||
|
||||
/// 重读会话(登录成功/登出/401 驱逐后调用)。
|
||||
Future<void> refresh() async {
|
||||
final store = ref.read(sessionStoreProvider);
|
||||
state = const AsyncLoading<String?>();
|
||||
state = await AsyncValue.guard(() async {
|
||||
final session = await store.read();
|
||||
return _normalize(session?.account);
|
||||
});
|
||||
}
|
||||
|
||||
/// 空串归一为 null(未登录语义;引擎 scope 判空依据)。
|
||||
static String? _normalize(String? account) =>
|
||||
(account == null || account.isEmpty) ? null : account;
|
||||
}
|
||||
|
||||
/// 会话账号 Provider(引擎 scope 与队列/连拍 UI 的 accountId 来源)。
|
||||
final sessionAccountProvider =
|
||||
AsyncNotifierProvider<SessionAccountNotifier, String?>(
|
||||
SessionAccountNotifier.new,
|
||||
);
|
||||
|
||||
/// 引擎生命周期绑定:账号在场 → start;离场 → stop(任务保留库内)。
|
||||
///
|
||||
/// 返回当前 scope 账号(null=引擎停止);仅被 App 壳 watch 时生效
|
||||
/// (Provider 惰性求值)。
|
||||
final uploadEngineScopeProvider = Provider<String?>((ref) {
|
||||
final account = ref.watch(sessionAccountProvider).value;
|
||||
final engine = ref.watch(uploadEngineProvider);
|
||||
if (account != null) {
|
||||
engine.start(account);
|
||||
} else {
|
||||
engine.stop();
|
||||
}
|
||||
ref.onDispose(engine.stop);
|
||||
return account;
|
||||
});
|
||||
|
||||
/// 供文档引用的类型锚点([UploadEngine] 生命周期语义见 upload_engine.dart)。
|
||||
typedef EngineLifecycleDoc = UploadEngine;
|
||||
@@ -0,0 +1,64 @@
|
||||
/// 选图注入点 + 批量入队动作(P3 计划 Task 9)。
|
||||
///
|
||||
/// image_picker 的平台调用经 Provider 注入([singleImagePickerProvider]/
|
||||
/// [multiImagePickerProvider]),widget 测试 override 为替身——不经平台
|
||||
/// 通道即可驱动入队流;[enqueueForUpload] 为连拍页与相册详情 FAB 的共用
|
||||
/// 入队路径:首次动作触发 POST_NOTIFICATIONS 请求(静默降级)+ 逐张经
|
||||
/// 准备器入队(准备器内部串行复制,防连拍 IO 风暴)。
|
||||
library;
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import '../../core/notification_permission.dart';
|
||||
import 'providers.dart';
|
||||
import 'task_state.dart';
|
||||
|
||||
/// 单图选择(准备失败重试的重新选图路径);返回源文件路径或 null(取消)。
|
||||
typedef SingleImagePicker = Future<String?> Function();
|
||||
|
||||
/// 多图选择(相册多选入队);返回源文件路径列表(空=取消)。
|
||||
typedef MultiImagePicker = Future<List<String>> Function();
|
||||
|
||||
/// 单图选择 Provider(生产=image_picker;测试 override)。
|
||||
final singleImagePickerProvider = Provider<SingleImagePicker>(
|
||||
(ref) => () async {
|
||||
final file = await ImagePicker().pickImage(source: ImageSource.gallery);
|
||||
return file?.path;
|
||||
},
|
||||
);
|
||||
|
||||
/// 多图选择 Provider(生产=image_picker;测试 override)。
|
||||
final multiImagePickerProvider = Provider<MultiImagePicker>(
|
||||
(ref) => () async {
|
||||
final files = await ImagePicker().pickMultiImage();
|
||||
return files.map((file) => file.path).toList();
|
||||
},
|
||||
);
|
||||
|
||||
/// 批量入队:通知权限单次请求 + 逐张准备入队。
|
||||
///
|
||||
/// 返回成功入队(state=queued)的张数;准备失败的任务已落库为
|
||||
/// preparationFailed(连拍页失败徽章/队列页可见,不在此抛出)。
|
||||
Future<int> enqueueForUpload(
|
||||
WidgetRef ref, {
|
||||
required String accountId,
|
||||
required String albumId,
|
||||
String? categoryId,
|
||||
required List<String> sourcePaths,
|
||||
}) async {
|
||||
// 首次入队动作触发(幂等;拒绝/非 Android 静默降级,见实现注释)。
|
||||
await ref.read(notificationPermissionProvider).ensureRequested();
|
||||
final preparer = ref.read(uploadPreparerProvider);
|
||||
var enqueued = 0;
|
||||
for (final path in sourcePaths) {
|
||||
final task = await preparer.prepare(
|
||||
accountId: accountId,
|
||||
albumId: albumId,
|
||||
categoryId: categoryId,
|
||||
sourcePath: path,
|
||||
);
|
||||
if (task.state == UploadTaskState.queued) enqueued++;
|
||||
}
|
||||
return enqueued;
|
||||
}
|
||||
@@ -2,10 +2,12 @@
|
||||
///
|
||||
/// 生产装配:TaskStore.app()(documents/upload_tasks.sqlite)+ ApiClient
|
||||
/// (Bearer 自动注入)+ DeviceId(shared_preferences)。引擎生命周期
|
||||
/// (start(accountId)/stop,随登录态切换)由 App 壳/队列 UI 驱动——
|
||||
/// Task 9 队列 UI 落地时接线;本文件保证依赖图就绪。
|
||||
/// (start(accountId)/stop,随登录态切换)在 engine_scope.dart 绑定
|
||||
/// (Task 9 接线)。
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/api_client.dart';
|
||||
@@ -21,13 +23,55 @@ final taskStoreProvider = Provider<TaskStore>((ref) {
|
||||
return store;
|
||||
});
|
||||
|
||||
/// 单任务传输进度事件(队列 UI 行级进度条消费)。
|
||||
class UploadTaskProgress {
|
||||
const UploadTaskProgress({
|
||||
required this.taskId,
|
||||
required this.sent,
|
||||
required this.total,
|
||||
});
|
||||
|
||||
final String taskId;
|
||||
final int sent;
|
||||
final int total;
|
||||
|
||||
/// 0~1 进度分数;total 未知(0)返回 null(UI 转不确定态进度条)。
|
||||
double? get fraction =>
|
||||
total > 0 ? (sent / total).clamp(0.0, 1.0).toDouble() : null;
|
||||
}
|
||||
|
||||
/// 进度事件总线:引擎 onProgress 回调 → 广播流(行级 StreamBuilder 按
|
||||
/// taskId 过滤消费,避免整表重建风暴)。
|
||||
class UploadProgressHub {
|
||||
final StreamController<UploadTaskProgress> _controller =
|
||||
StreamController<UploadTaskProgress>.broadcast();
|
||||
|
||||
/// 进度事件流(广播;行级按 taskId 过滤)。
|
||||
Stream<UploadTaskProgress> get stream => _controller.stream;
|
||||
|
||||
/// 引擎回调挂接点(uploadEngineProvider 内接线)。
|
||||
void emit(String taskId, int sent, int total) =>
|
||||
_controller.add(UploadTaskProgress(taskId: taskId, sent: sent, total: total));
|
||||
|
||||
void dispose() => _controller.close();
|
||||
}
|
||||
|
||||
/// 进度总线 Provider。
|
||||
final uploadProgressHubProvider = Provider<UploadProgressHub>((ref) {
|
||||
final hub = UploadProgressHub();
|
||||
ref.onDispose(hub.dispose);
|
||||
return hub;
|
||||
});
|
||||
|
||||
/// 传输引擎(裸 Dio 直传 presign;并行/保留期配置见 upload_engine.dart
|
||||
/// 的 dart-define 常量)。
|
||||
/// 的 dart-define 常量;进度经 [UploadProgressHub] 广播)。
|
||||
final uploadEngineProvider = Provider<UploadEngine>((ref) {
|
||||
final hub = ref.watch(uploadProgressHubProvider);
|
||||
final engine = UploadEngine(
|
||||
ref.watch(taskStoreProvider),
|
||||
ref.watch(apiClientProvider),
|
||||
ref.watch(deviceIdProvider),
|
||||
onProgress: (taskId, sent, total) => hub.emit(taskId, sent, total),
|
||||
);
|
||||
ref.onDispose(engine.dispose);
|
||||
return engine;
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
/// 队列 UI 状态层(P3 计划 Task 9)。
|
||||
///
|
||||
/// - [QueueFilter]:需求 4.3 三维筛选(相册 × 状态 × 时间)的**纯逻辑**
|
||||
/// (单测直接覆盖;与 [TaskStore.listByAccount] 的参数化查询语义一致:
|
||||
/// 状态分组 → states 集合、时间快捷 → since 下限);
|
||||
/// - [QueueFilterNotifier]:筛选状态(队列页 filter bar 消费);
|
||||
/// - [QueueTasksNotifier]:筛选后的任务列表——**实时刷新 = TaskStore 写
|
||||
/// 通知(drift tableUpdates)+ 200ms 防抖重查**(连拍入队/引擎迁移风暴
|
||||
/// 下不逐条重建 UI);
|
||||
/// - [TaskCountsNotifier]:账号内各状态计数(连拍页徽章 + 批量重试条,
|
||||
/// 同一防抖策略)。
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'engine_scope.dart';
|
||||
import 'providers.dart';
|
||||
import 'task_state.dart';
|
||||
import 'task_store.dart';
|
||||
|
||||
/// 状态筛选分组(chip → 状态集合;「全部」= 不过滤)。
|
||||
enum QueueStateFilter {
|
||||
all,
|
||||
preparing,
|
||||
queued,
|
||||
uploading,
|
||||
waitingNetwork,
|
||||
failed,
|
||||
completed;
|
||||
|
||||
/// 分组对应的状态集合(空 = 不过滤)。
|
||||
Set<UploadTaskState> get states => switch (this) {
|
||||
QueueStateFilter.all => const <UploadTaskState>{},
|
||||
QueueStateFilter.preparing => const <UploadTaskState>{
|
||||
UploadTaskState.preparing,
|
||||
},
|
||||
QueueStateFilter.queued => const <UploadTaskState>{
|
||||
UploadTaskState.queued,
|
||||
},
|
||||
QueueStateFilter.uploading => const <UploadTaskState>{
|
||||
UploadTaskState.uploading,
|
||||
UploadTaskState.uploadedConfirming,
|
||||
},
|
||||
QueueStateFilter.waitingNetwork => const <UploadTaskState>{
|
||||
UploadTaskState.waitingNetwork,
|
||||
},
|
||||
QueueStateFilter.failed => const <UploadTaskState>{
|
||||
UploadTaskState.preparationFailed,
|
||||
UploadTaskState.retryableFailed,
|
||||
},
|
||||
QueueStateFilter.completed => const <UploadTaskState>{
|
||||
UploadTaskState.completed,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/// 时间筛选快捷(今天/近7天/近30天/全部)。
|
||||
enum QueueTimeFilter {
|
||||
all,
|
||||
today,
|
||||
last7Days,
|
||||
last30Days;
|
||||
|
||||
/// 创建时间下限(null = 不过滤;本地时刻,matches 内与 UTC 落库值
|
||||
/// 比较前归一)。
|
||||
DateTime? since(DateTime now) => switch (this) {
|
||||
QueueTimeFilter.all => null,
|
||||
QueueTimeFilter.today => DateTime(now.year, now.month, now.day),
|
||||
QueueTimeFilter.last7Days =>
|
||||
now.subtract(const Duration(days: 7)),
|
||||
QueueTimeFilter.last30Days =>
|
||||
now.subtract(const Duration(days: 30)),
|
||||
};
|
||||
}
|
||||
|
||||
/// 三维筛选条件(不可变值对象;纯匹配逻辑单测覆盖)。
|
||||
class QueueFilter {
|
||||
const QueueFilter({
|
||||
this.albumId,
|
||||
this.state = QueueStateFilter.all,
|
||||
this.time = QueueTimeFilter.all,
|
||||
});
|
||||
|
||||
/// 相册过滤(null = 全部相册)。
|
||||
final String? albumId;
|
||||
|
||||
/// 状态分组过滤。
|
||||
final QueueStateFilter state;
|
||||
|
||||
/// 时间快捷过滤。
|
||||
final QueueTimeFilter time;
|
||||
|
||||
/// 纯匹配(与 TaskStore.listByAccount(albumId/states/since) 查询语义
|
||||
/// 一致;createdAt 为 UTC 落库值,比较前归一时区)。
|
||||
bool matches(UploadTask task, {required DateTime now}) {
|
||||
if (albumId != null && task.albumId != albumId) return false;
|
||||
final states = state.states;
|
||||
if (states.isNotEmpty && !states.contains(task.state)) return false;
|
||||
final cutoff = time.since(now);
|
||||
if (cutoff != null && task.createdAt.isBefore(cutoff.toUtc())) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
QueueFilter copyWith({
|
||||
String? albumId,
|
||||
bool clearAlbum = false,
|
||||
QueueStateFilter? state,
|
||||
QueueTimeFilter? time,
|
||||
}) =>
|
||||
QueueFilter(
|
||||
albumId: clearAlbum ? null : (albumId ?? this.albumId),
|
||||
state: state ?? this.state,
|
||||
time: time ?? this.time,
|
||||
);
|
||||
}
|
||||
|
||||
/// 筛选状态 Notifier。
|
||||
class QueueFilterNotifier extends Notifier<QueueFilter> {
|
||||
@override
|
||||
QueueFilter build() => const QueueFilter();
|
||||
|
||||
void setAlbum(String? albumId) =>
|
||||
state = albumId == null ? state.copyWith(clearAlbum: true) : state.copyWith(albumId: albumId);
|
||||
|
||||
void setStateGroup(QueueStateFilter group) => state = state.copyWith(state: group);
|
||||
|
||||
void setTime(QueueTimeFilter time) => state = state.copyWith(time: time);
|
||||
}
|
||||
|
||||
/// 筛选状态 Provider。
|
||||
final queueFilterProvider =
|
||||
NotifierProvider<QueueFilterNotifier, QueueFilter>(QueueFilterNotifier.new);
|
||||
|
||||
/// 防抖重查间隔(连拍/引擎迁移写风暴下限流)。
|
||||
const Duration kQueueRefreshDebounce = Duration(milliseconds: 200);
|
||||
|
||||
/// 队列任务列表:筛选参数化查询 + 写通知防抖刷新。
|
||||
class QueueTasksNotifier extends AsyncNotifier<List<UploadTask>> {
|
||||
StreamSubscription<void>? _changesSub;
|
||||
Timer? _debounce;
|
||||
|
||||
@override
|
||||
Future<List<UploadTask>> build() async {
|
||||
final filter = ref.watch(queueFilterProvider);
|
||||
final account = await ref.watch(sessionAccountProvider.future);
|
||||
final store = ref.watch(taskStoreProvider);
|
||||
_changesSub?.cancel();
|
||||
_debounce?.cancel();
|
||||
_changesSub = store.changes.listen((_) => _scheduleReload());
|
||||
ref.onDispose(_dispose);
|
||||
if (account == null) return const <UploadTask>[];
|
||||
return _query(store, account, filter);
|
||||
}
|
||||
|
||||
static Future<List<UploadTask>> _query(
|
||||
TaskStore store,
|
||||
String account,
|
||||
QueueFilter filter,
|
||||
) =>
|
||||
store.listByAccount(
|
||||
account,
|
||||
albumId: filter.albumId,
|
||||
states: filter.state.states.isEmpty ? null : filter.state.states,
|
||||
since: filter.time.since(DateTime.now()),
|
||||
);
|
||||
|
||||
/// 写通知 → 200ms 防抖后重查(同一账号/筛选的最新快照)。
|
||||
void _scheduleReload() {
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(kQueueRefreshDebounce, () {
|
||||
final account = ref.read(sessionAccountProvider).value;
|
||||
if (account == null) return;
|
||||
_query(ref.read(taskStoreProvider), account, ref.read(queueFilterProvider))
|
||||
.then((tasks) => state = AsyncData<List<UploadTask>>(tasks));
|
||||
});
|
||||
}
|
||||
|
||||
void _dispose() {
|
||||
_changesSub?.cancel();
|
||||
_changesSub = null;
|
||||
_debounce?.cancel();
|
||||
_debounce = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 队列任务列表 Provider。
|
||||
final queueTasksProvider =
|
||||
AsyncNotifierProvider<QueueTasksNotifier, List<UploadTask>>(
|
||||
QueueTasksNotifier.new,
|
||||
);
|
||||
|
||||
/// 账号内各状态计数(连拍页徽章/批量重试条;写通知防抖刷新)。
|
||||
class TaskCountsNotifier extends Notifier<Map<UploadTaskState, int>> {
|
||||
StreamSubscription<void>? _changesSub;
|
||||
Timer? _debounce;
|
||||
|
||||
@override
|
||||
Map<UploadTaskState, int> build() {
|
||||
final store = ref.watch(taskStoreProvider);
|
||||
// 会话变化(登录/登出)也触发重建(登出清零)。
|
||||
ref.watch(sessionAccountProvider);
|
||||
_changesSub?.cancel();
|
||||
_debounce?.cancel();
|
||||
_changesSub = store.changes.listen((_) => _scheduleReload());
|
||||
ref.onDispose(_dispose);
|
||||
Future<void>.microtask(() => _reload());
|
||||
return <UploadTaskState, int>{
|
||||
for (final state in UploadTaskState.values) state: 0,
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> _reload() async {
|
||||
final account = ref.read(sessionAccountProvider).value;
|
||||
if (account == null) return;
|
||||
final counts = await ref.read(taskStoreProvider).countsByState(account);
|
||||
if (ref.mounted) state = counts;
|
||||
}
|
||||
|
||||
void _scheduleReload() {
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(kQueueRefreshDebounce, _reload);
|
||||
}
|
||||
|
||||
void _dispose() {
|
||||
_changesSub?.cancel();
|
||||
_changesSub = null;
|
||||
_debounce?.cancel();
|
||||
_debounce = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 各状态计数 Provider(键覆盖全部状态,无任务计 0)。
|
||||
final taskCountsProvider =
|
||||
NotifierProvider<TaskCountsNotifier, Map<UploadTaskState, int>>(
|
||||
TaskCountsNotifier.new,
|
||||
);
|
||||
|
||||
/// 未完成(待传)计数:准备中/待传/上传中/确认中/等待网络之和。
|
||||
int pendingCountOf(Map<UploadTaskState, int> counts) =>
|
||||
counts[UploadTaskState.preparing]! +
|
||||
counts[UploadTaskState.queued]! +
|
||||
counts[UploadTaskState.uploading]! +
|
||||
counts[UploadTaskState.uploadedConfirming]! +
|
||||
counts[UploadTaskState.waitingNetwork]!;
|
||||
|
||||
/// 失败计数:准备失败 + 上传失败。
|
||||
int failedCountOf(Map<UploadTaskState, int> counts) =>
|
||||
counts[UploadTaskState.preparationFailed]! +
|
||||
counts[UploadTaskState.retryableFailed]!;
|
||||
@@ -0,0 +1,75 @@
|
||||
/// 任务状态/上传域错误码 → 本地化文案(P3 计划 Task 9)。
|
||||
///
|
||||
/// 队列/连拍页专用扩展:契约错误码复用 core/error_l10n 的映射,此处
|
||||
/// 补齐上传域客户端伪码(准备阶段 [PrepErrorCodes]、引擎源缺失伪码)
|
||||
/// 与队列高频契约码(ALBUM_UPLOAD_DISABLED 等)。
|
||||
library;
|
||||
|
||||
import '../../core/error_codes.dart';
|
||||
import '../../core/error_l10n.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../albums/models.dart';
|
||||
import 'preparer.dart';
|
||||
import 'task_state.dart';
|
||||
import 'upload_engine.dart';
|
||||
|
||||
extension AppLocalizationsUploadX on AppLocalizations {
|
||||
/// 任务状态标签(chip/文案)。
|
||||
String labelForTaskState(UploadTaskState state) => switch (state) {
|
||||
UploadTaskState.preparing => taskStatePreparing,
|
||||
UploadTaskState.preparationFailed => taskStatePreparationFailed,
|
||||
UploadTaskState.queued => taskStateQueued,
|
||||
UploadTaskState.uploading => taskStateUploading,
|
||||
UploadTaskState.uploadedConfirming => taskStateUploadedConfirming,
|
||||
UploadTaskState.completed => taskStateCompleted,
|
||||
UploadTaskState.waitingNetwork => taskStateWaitingNetwork,
|
||||
UploadTaskState.retryableFailed => taskStateRetryableFailed,
|
||||
UploadTaskState.cancelled => taskStateCancelled,
|
||||
};
|
||||
|
||||
/// 任务 lastError → 展示文案(上传域伪码 + 契约码;未映射回退
|
||||
/// error_l10n 的通用解析)。
|
||||
String messageForTaskError(String code) {
|
||||
switch (code) {
|
||||
case PrepErrorCodes.sourceUnreadable:
|
||||
return errorPrepSourceUnreadable;
|
||||
case PrepErrorCodes.unsupportedType:
|
||||
return errorPrepUnsupportedType;
|
||||
case PrepErrorCodes.tooLarge:
|
||||
return errorPrepTooLarge;
|
||||
case PrepErrorCodes.copyFailed:
|
||||
return errorPrepCopyFailed;
|
||||
case UploadEngine.sourceMissingCode:
|
||||
return errorUploadSourceMissing;
|
||||
case ErrorCodes.albumUploadDisabled:
|
||||
return errorAlbumUploadDisabled;
|
||||
case ErrorCodes.uploadSessionConflict:
|
||||
return errorUploadSessionConflict;
|
||||
case ErrorCodes.uploadVerifyFailed:
|
||||
return errorUploadVerifyFailed;
|
||||
default:
|
||||
return messageForErrorCode(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 相册展示标题解析:当前 locale → 相册默认语言 → 首条 → 相册 ID。
|
||||
///
|
||||
/// locale 匹配按语言前缀(App locale `zh`/`en` vs 契约 `zh-CN`/`en`)。
|
||||
String albumDisplayTitle(AlbumSummary album, String localeName) {
|
||||
final lang = localeName.split('-').first.toLowerCase();
|
||||
AlbumTranslation? match;
|
||||
for (final t in album.translations) {
|
||||
if (t.locale.toLowerCase() == localeName.toLowerCase() ||
|
||||
t.locale.toLowerCase().split('-').first == lang) {
|
||||
match = t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (match != null) return match.title;
|
||||
for (final t in album.translations) {
|
||||
if (t.locale == album.defaultLocale) return t.title;
|
||||
}
|
||||
if (album.translations.isNotEmpty) return album.translations.first.title;
|
||||
return album.id;
|
||||
}
|
||||
@@ -237,6 +237,11 @@ class TaskStore {
|
||||
/// 关闭底层数据库(App 退出/测试 tearDown)。
|
||||
Future<void> close() => _db.close();
|
||||
|
||||
/// 表写通知流(drift 对 insert/update/delete 自动发事件;队列 UI 的
|
||||
/// 节流刷新挂接点——引擎/准备器每次落库都会触发)。
|
||||
Stream<void> get changes =>
|
||||
_db.tableUpdates().map<void>((Set<TableUpdate> _) {});
|
||||
|
||||
/// 时间戳统一 UTC 落库(datetime 为 ISO-8601 文本存储:UTC 值以 `Z`
|
||||
/// 结尾、本地值带 `+hh:mm` 后缀,混存会让 SQL 文本比较错位一个时区
|
||||
/// 偏移——全部归一 UTC 后字典序即时间序)。
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -805,6 +805,306 @@ abstract class AppLocalizations {
|
||||
/// In zh, this message translates to:
|
||||
/// **'本设备'**
|
||||
String get thisDevice;
|
||||
|
||||
/// No description provided for @queueAlbumFilterAll.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'全部相册'**
|
||||
String get queueAlbumFilterAll;
|
||||
|
||||
/// No description provided for @queueStateAll.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'全部'**
|
||||
String get queueStateAll;
|
||||
|
||||
/// No description provided for @queueStatePreparing.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'准备中'**
|
||||
String get queueStatePreparing;
|
||||
|
||||
/// No description provided for @queueStateQueued.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'待传'**
|
||||
String get queueStateQueued;
|
||||
|
||||
/// No description provided for @queueStateUploading.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'上传中'**
|
||||
String get queueStateUploading;
|
||||
|
||||
/// No description provided for @queueStateWaitingNetwork.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'等待网络'**
|
||||
String get queueStateWaitingNetwork;
|
||||
|
||||
/// No description provided for @queueStateFailed.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'失败'**
|
||||
String get queueStateFailed;
|
||||
|
||||
/// No description provided for @queueStateCompleted.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'已完成'**
|
||||
String get queueStateCompleted;
|
||||
|
||||
/// No description provided for @queueTimeAll.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'全部时间'**
|
||||
String get queueTimeAll;
|
||||
|
||||
/// No description provided for @queueTimeToday.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'今天'**
|
||||
String get queueTimeToday;
|
||||
|
||||
/// No description provided for @queueTimeLast7Days.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'近7天'**
|
||||
String get queueTimeLast7Days;
|
||||
|
||||
/// No description provided for @queueTimeLast30Days.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'近30天'**
|
||||
String get queueTimeLast30Days;
|
||||
|
||||
/// No description provided for @queueEmpty.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'暂无上传任务'**
|
||||
String get queueEmpty;
|
||||
|
||||
/// No description provided for @queueRetryAllFailed.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'一键重试全部失败'**
|
||||
String get queueRetryAllFailed;
|
||||
|
||||
/// No description provided for @queueAttemptCount.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'已尝试 {count} 次'**
|
||||
String queueAttemptCount(int count);
|
||||
|
||||
/// No description provided for @taskStatePreparing.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'准备中'**
|
||||
String get taskStatePreparing;
|
||||
|
||||
/// No description provided for @taskStatePreparationFailed.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'准备失败'**
|
||||
String get taskStatePreparationFailed;
|
||||
|
||||
/// No description provided for @taskStateQueued.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'待上传'**
|
||||
String get taskStateQueued;
|
||||
|
||||
/// No description provided for @taskStateUploading.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'上传中'**
|
||||
String get taskStateUploading;
|
||||
|
||||
/// No description provided for @taskStateUploadedConfirming.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'确认中'**
|
||||
String get taskStateUploadedConfirming;
|
||||
|
||||
/// No description provided for @taskStateCompleted.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'已完成'**
|
||||
String get taskStateCompleted;
|
||||
|
||||
/// No description provided for @taskStateWaitingNetwork.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'等待网络'**
|
||||
String get taskStateWaitingNetwork;
|
||||
|
||||
/// No description provided for @taskStateRetryableFailed.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'上传失败'**
|
||||
String get taskStateRetryableFailed;
|
||||
|
||||
/// No description provided for @taskStateCancelled.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'已取消'**
|
||||
String get taskStateCancelled;
|
||||
|
||||
/// No description provided for @errorPrepSourceUnreadable.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'无法读取所选图片,请重新选择'**
|
||||
String get errorPrepSourceUnreadable;
|
||||
|
||||
/// No description provided for @errorPrepUnsupportedType.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'仅支持 JPG/PNG/HEIC 图片'**
|
||||
String get errorPrepUnsupportedType;
|
||||
|
||||
/// No description provided for @errorPrepTooLarge.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'图片超过 50MB 上限'**
|
||||
String get errorPrepTooLarge;
|
||||
|
||||
/// No description provided for @errorPrepCopyFailed.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'保存图片副本失败,请重试'**
|
||||
String get errorPrepCopyFailed;
|
||||
|
||||
/// No description provided for @errorUploadSourceMissing.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'源文件已缺失,请重新选择'**
|
||||
String get errorUploadSourceMissing;
|
||||
|
||||
/// No description provided for @errorAlbumUploadDisabled.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'相册已关闭上传'**
|
||||
String get errorAlbumUploadDisabled;
|
||||
|
||||
/// No description provided for @errorUploadSessionConflict.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'上传会话冲突,请重试'**
|
||||
String get errorUploadSessionConflict;
|
||||
|
||||
/// No description provided for @errorUploadVerifyFailed.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'上传核验失败,将自动重试'**
|
||||
String get errorUploadVerifyFailed;
|
||||
|
||||
/// No description provided for @captureTitle.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'连拍上传'**
|
||||
String get captureTitle;
|
||||
|
||||
/// No description provided for @captureCategoryDefault.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'默认分类'**
|
||||
String get captureCategoryDefault;
|
||||
|
||||
/// No description provided for @captureSwitchHint.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'切换分类仅影响之后拍摄的照片'**
|
||||
String get captureSwitchHint;
|
||||
|
||||
/// No description provided for @capturePendingBadge.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'待传 {count}'**
|
||||
String capturePendingBadge(int count);
|
||||
|
||||
/// No description provided for @captureFailedBadge.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'失败 {count}'**
|
||||
String captureFailedBadge(int count);
|
||||
|
||||
/// No description provided for @capturePickFromGallery.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'从相册选择'**
|
||||
String get capturePickFromGallery;
|
||||
|
||||
/// No description provided for @captureShutter.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'拍摄'**
|
||||
String get captureShutter;
|
||||
|
||||
/// No description provided for @captureExitTitle.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'退出拍摄?'**
|
||||
String get captureExitTitle;
|
||||
|
||||
/// No description provided for @captureExitBody.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'还有 {count} 张照片未上传,退出后任务将在后台继续。'**
|
||||
String captureExitBody(int count);
|
||||
|
||||
/// No description provided for @captureKeepShooting.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'继续拍摄'**
|
||||
String get captureKeepShooting;
|
||||
|
||||
/// No description provided for @captureExitConfirm.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'退出'**
|
||||
String get captureExitConfirm;
|
||||
|
||||
/// No description provided for @cameraPermissionDenied.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'无法访问相机,请在系统设置中允许相机权限'**
|
||||
String get cameraPermissionDenied;
|
||||
|
||||
/// No description provided for @cameraInitFailed.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'相机初始化失败'**
|
||||
String get cameraInitFailed;
|
||||
|
||||
/// No description provided for @uploadActionCapture.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'拍照上传'**
|
||||
String get uploadActionCapture;
|
||||
|
||||
/// No description provided for @uploadActionPick.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'从相册选择'**
|
||||
String get uploadActionPick;
|
||||
|
||||
/// No description provided for @uploadAction.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'上传'**
|
||||
String get uploadAction;
|
||||
|
||||
/// No description provided for @uploadEnqueuedCount.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'已加入 {count} 张上传队列'**
|
||||
String uploadEnqueuedCount(int count);
|
||||
|
||||
/// No description provided for @uploadPickNone.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'未选择照片'**
|
||||
String get uploadPickNone;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -370,4 +370,171 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get thisDevice => 'This device';
|
||||
|
||||
@override
|
||||
String get queueAlbumFilterAll => 'All albums';
|
||||
|
||||
@override
|
||||
String get queueStateAll => 'All';
|
||||
|
||||
@override
|
||||
String get queueStatePreparing => 'Preparing';
|
||||
|
||||
@override
|
||||
String get queueStateQueued => 'Queued';
|
||||
|
||||
@override
|
||||
String get queueStateUploading => 'Uploading';
|
||||
|
||||
@override
|
||||
String get queueStateWaitingNetwork => 'Waiting for network';
|
||||
|
||||
@override
|
||||
String get queueStateFailed => 'Failed';
|
||||
|
||||
@override
|
||||
String get queueStateCompleted => 'Done';
|
||||
|
||||
@override
|
||||
String get queueTimeAll => 'Any time';
|
||||
|
||||
@override
|
||||
String get queueTimeToday => 'Today';
|
||||
|
||||
@override
|
||||
String get queueTimeLast7Days => 'Last 7 days';
|
||||
|
||||
@override
|
||||
String get queueTimeLast30Days => 'Last 30 days';
|
||||
|
||||
@override
|
||||
String get queueEmpty => 'No upload tasks';
|
||||
|
||||
@override
|
||||
String get queueRetryAllFailed => 'Retry all failed';
|
||||
|
||||
@override
|
||||
String queueAttemptCount(int count) {
|
||||
return '$count attempt(s)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get taskStatePreparing => 'Preparing';
|
||||
|
||||
@override
|
||||
String get taskStatePreparationFailed => 'Preparation failed';
|
||||
|
||||
@override
|
||||
String get taskStateQueued => 'Queued';
|
||||
|
||||
@override
|
||||
String get taskStateUploading => 'Uploading';
|
||||
|
||||
@override
|
||||
String get taskStateUploadedConfirming => 'Confirming';
|
||||
|
||||
@override
|
||||
String get taskStateCompleted => 'Completed';
|
||||
|
||||
@override
|
||||
String get taskStateWaitingNetwork => 'Waiting for network';
|
||||
|
||||
@override
|
||||
String get taskStateRetryableFailed => 'Upload failed';
|
||||
|
||||
@override
|
||||
String get taskStateCancelled => 'Cancelled';
|
||||
|
||||
@override
|
||||
String get errorPrepSourceUnreadable =>
|
||||
'Could not read the selected photo. Please reselect';
|
||||
|
||||
@override
|
||||
String get errorPrepUnsupportedType =>
|
||||
'Only JPG, PNG and HEIC images are supported';
|
||||
|
||||
@override
|
||||
String get errorPrepTooLarge => 'Photo exceeds the 50MB limit';
|
||||
|
||||
@override
|
||||
String get errorPrepCopyFailed => 'Failed to copy the photo. Please retry';
|
||||
|
||||
@override
|
||||
String get errorUploadSourceMissing =>
|
||||
'Source file is missing. Please reselect';
|
||||
|
||||
@override
|
||||
String get errorAlbumUploadDisabled => 'Album uploads are disabled';
|
||||
|
||||
@override
|
||||
String get errorUploadSessionConflict =>
|
||||
'Upload session conflict. Please retry';
|
||||
|
||||
@override
|
||||
String get errorUploadVerifyFailed =>
|
||||
'Upload verification failed. Will retry';
|
||||
|
||||
@override
|
||||
String get captureTitle => 'Continuous capture';
|
||||
|
||||
@override
|
||||
String get captureCategoryDefault => 'Default category';
|
||||
|
||||
@override
|
||||
String get captureSwitchHint =>
|
||||
'Switching only affects photos captured afterwards';
|
||||
|
||||
@override
|
||||
String capturePendingBadge(int count) {
|
||||
return 'Pending $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String captureFailedBadge(int count) {
|
||||
return 'Failed $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get capturePickFromGallery => 'Pick from gallery';
|
||||
|
||||
@override
|
||||
String get captureShutter => 'Capture';
|
||||
|
||||
@override
|
||||
String get captureExitTitle => 'Leave shooting?';
|
||||
|
||||
@override
|
||||
String captureExitBody(int count) {
|
||||
return '$count photo(s) are still uploading. Tasks will continue in the background after you leave.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get captureKeepShooting => 'Keep shooting';
|
||||
|
||||
@override
|
||||
String get captureExitConfirm => 'Leave';
|
||||
|
||||
@override
|
||||
String get cameraPermissionDenied =>
|
||||
'Camera unavailable. Allow camera access in system settings';
|
||||
|
||||
@override
|
||||
String get cameraInitFailed => 'Camera failed to initialize';
|
||||
|
||||
@override
|
||||
String get uploadActionCapture => 'Capture and upload';
|
||||
|
||||
@override
|
||||
String get uploadActionPick => 'Pick from gallery';
|
||||
|
||||
@override
|
||||
String get uploadAction => 'Upload';
|
||||
|
||||
@override
|
||||
String uploadEnqueuedCount(int count) {
|
||||
return 'Added $count photo(s) to the upload queue';
|
||||
}
|
||||
|
||||
@override
|
||||
String get uploadPickNone => 'No photos selected';
|
||||
}
|
||||
|
||||
@@ -361,4 +361,164 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get thisDevice => '本设备';
|
||||
|
||||
@override
|
||||
String get queueAlbumFilterAll => '全部相册';
|
||||
|
||||
@override
|
||||
String get queueStateAll => '全部';
|
||||
|
||||
@override
|
||||
String get queueStatePreparing => '准备中';
|
||||
|
||||
@override
|
||||
String get queueStateQueued => '待传';
|
||||
|
||||
@override
|
||||
String get queueStateUploading => '上传中';
|
||||
|
||||
@override
|
||||
String get queueStateWaitingNetwork => '等待网络';
|
||||
|
||||
@override
|
||||
String get queueStateFailed => '失败';
|
||||
|
||||
@override
|
||||
String get queueStateCompleted => '已完成';
|
||||
|
||||
@override
|
||||
String get queueTimeAll => '全部时间';
|
||||
|
||||
@override
|
||||
String get queueTimeToday => '今天';
|
||||
|
||||
@override
|
||||
String get queueTimeLast7Days => '近7天';
|
||||
|
||||
@override
|
||||
String get queueTimeLast30Days => '近30天';
|
||||
|
||||
@override
|
||||
String get queueEmpty => '暂无上传任务';
|
||||
|
||||
@override
|
||||
String get queueRetryAllFailed => '一键重试全部失败';
|
||||
|
||||
@override
|
||||
String queueAttemptCount(int count) {
|
||||
return '已尝试 $count 次';
|
||||
}
|
||||
|
||||
@override
|
||||
String get taskStatePreparing => '准备中';
|
||||
|
||||
@override
|
||||
String get taskStatePreparationFailed => '准备失败';
|
||||
|
||||
@override
|
||||
String get taskStateQueued => '待上传';
|
||||
|
||||
@override
|
||||
String get taskStateUploading => '上传中';
|
||||
|
||||
@override
|
||||
String get taskStateUploadedConfirming => '确认中';
|
||||
|
||||
@override
|
||||
String get taskStateCompleted => '已完成';
|
||||
|
||||
@override
|
||||
String get taskStateWaitingNetwork => '等待网络';
|
||||
|
||||
@override
|
||||
String get taskStateRetryableFailed => '上传失败';
|
||||
|
||||
@override
|
||||
String get taskStateCancelled => '已取消';
|
||||
|
||||
@override
|
||||
String get errorPrepSourceUnreadable => '无法读取所选图片,请重新选择';
|
||||
|
||||
@override
|
||||
String get errorPrepUnsupportedType => '仅支持 JPG/PNG/HEIC 图片';
|
||||
|
||||
@override
|
||||
String get errorPrepTooLarge => '图片超过 50MB 上限';
|
||||
|
||||
@override
|
||||
String get errorPrepCopyFailed => '保存图片副本失败,请重试';
|
||||
|
||||
@override
|
||||
String get errorUploadSourceMissing => '源文件已缺失,请重新选择';
|
||||
|
||||
@override
|
||||
String get errorAlbumUploadDisabled => '相册已关闭上传';
|
||||
|
||||
@override
|
||||
String get errorUploadSessionConflict => '上传会话冲突,请重试';
|
||||
|
||||
@override
|
||||
String get errorUploadVerifyFailed => '上传核验失败,将自动重试';
|
||||
|
||||
@override
|
||||
String get captureTitle => '连拍上传';
|
||||
|
||||
@override
|
||||
String get captureCategoryDefault => '默认分类';
|
||||
|
||||
@override
|
||||
String get captureSwitchHint => '切换分类仅影响之后拍摄的照片';
|
||||
|
||||
@override
|
||||
String capturePendingBadge(int count) {
|
||||
return '待传 $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String captureFailedBadge(int count) {
|
||||
return '失败 $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get capturePickFromGallery => '从相册选择';
|
||||
|
||||
@override
|
||||
String get captureShutter => '拍摄';
|
||||
|
||||
@override
|
||||
String get captureExitTitle => '退出拍摄?';
|
||||
|
||||
@override
|
||||
String captureExitBody(int count) {
|
||||
return '还有 $count 张照片未上传,退出后任务将在后台继续。';
|
||||
}
|
||||
|
||||
@override
|
||||
String get captureKeepShooting => '继续拍摄';
|
||||
|
||||
@override
|
||||
String get captureExitConfirm => '退出';
|
||||
|
||||
@override
|
||||
String get cameraPermissionDenied => '无法访问相机,请在系统设置中允许相机权限';
|
||||
|
||||
@override
|
||||
String get cameraInitFailed => '相机初始化失败';
|
||||
|
||||
@override
|
||||
String get uploadActionCapture => '拍照上传';
|
||||
|
||||
@override
|
||||
String get uploadActionPick => '从相册选择';
|
||||
|
||||
@override
|
||||
String get uploadAction => '上传';
|
||||
|
||||
@override
|
||||
String uploadEnqueuedCount(int count) {
|
||||
return '已加入 $count 张上传队列';
|
||||
}
|
||||
|
||||
@override
|
||||
String get uploadPickNone => '未选择照片';
|
||||
}
|
||||
|
||||
@@ -117,5 +117,60 @@
|
||||
"uploadStateConfirmed": "已确认",
|
||||
"uploadStateCancelled": "已取消",
|
||||
"uploadStateExpired": "已过期",
|
||||
"thisDevice": "本设备"
|
||||
"thisDevice": "本设备",
|
||||
"queueAlbumFilterAll": "全部相册",
|
||||
"queueStateAll": "全部",
|
||||
"queueStatePreparing": "准备中",
|
||||
"queueStateQueued": "待传",
|
||||
"queueStateUploading": "上传中",
|
||||
"queueStateWaitingNetwork": "等待网络",
|
||||
"queueStateFailed": "失败",
|
||||
"queueStateCompleted": "已完成",
|
||||
"queueTimeAll": "全部时间",
|
||||
"queueTimeToday": "今天",
|
||||
"queueTimeLast7Days": "近7天",
|
||||
"queueTimeLast30Days": "近30天",
|
||||
"queueEmpty": "暂无上传任务",
|
||||
"queueRetryAllFailed": "一键重试全部失败",
|
||||
"queueAttemptCount": "已尝试 {count} 次",
|
||||
"@queueAttemptCount": { "placeholders": { "count": { "type": "int" } } },
|
||||
"taskStatePreparing": "准备中",
|
||||
"taskStatePreparationFailed": "准备失败",
|
||||
"taskStateQueued": "待上传",
|
||||
"taskStateUploading": "上传中",
|
||||
"taskStateUploadedConfirming": "确认中",
|
||||
"taskStateCompleted": "已完成",
|
||||
"taskStateWaitingNetwork": "等待网络",
|
||||
"taskStateRetryableFailed": "上传失败",
|
||||
"taskStateCancelled": "已取消",
|
||||
"errorPrepSourceUnreadable": "无法读取所选图片,请重新选择",
|
||||
"errorPrepUnsupportedType": "仅支持 JPG/PNG/HEIC 图片",
|
||||
"errorPrepTooLarge": "图片超过 50MB 上限",
|
||||
"errorPrepCopyFailed": "保存图片副本失败,请重试",
|
||||
"errorUploadSourceMissing": "源文件已缺失,请重新选择",
|
||||
"errorAlbumUploadDisabled": "相册已关闭上传",
|
||||
"errorUploadSessionConflict": "上传会话冲突,请重试",
|
||||
"errorUploadVerifyFailed": "上传核验失败,将自动重试",
|
||||
"captureTitle": "连拍上传",
|
||||
"captureCategoryDefault": "默认分类",
|
||||
"captureSwitchHint": "切换分类仅影响之后拍摄的照片",
|
||||
"capturePendingBadge": "待传 {count}",
|
||||
"@capturePendingBadge": { "placeholders": { "count": { "type": "int" } } },
|
||||
"captureFailedBadge": "失败 {count}",
|
||||
"@captureFailedBadge": { "placeholders": { "count": { "type": "int" } } },
|
||||
"capturePickFromGallery": "从相册选择",
|
||||
"captureShutter": "拍摄",
|
||||
"captureExitTitle": "退出拍摄?",
|
||||
"captureExitBody": "还有 {count} 张照片未上传,退出后任务将在后台继续。",
|
||||
"@captureExitBody": { "placeholders": { "count": { "type": "int" } } },
|
||||
"captureKeepShooting": "继续拍摄",
|
||||
"captureExitConfirm": "退出",
|
||||
"cameraPermissionDenied": "无法访问相机,请在系统设置中允许相机权限",
|
||||
"cameraInitFailed": "相机初始化失败",
|
||||
"uploadActionCapture": "拍照上传",
|
||||
"uploadActionPick": "从相册选择",
|
||||
"uploadAction": "上传",
|
||||
"uploadEnqueuedCount": "已加入 {count} 张上传队列",
|
||||
"@uploadEnqueuedCount": { "placeholders": { "count": { "type": "int" } } },
|
||||
"uploadPickNone": "未选择照片"
|
||||
}
|
||||
|
||||
@@ -14,10 +14,12 @@ import '../core/error_l10n.dart';
|
||||
import '../features/albums/album_format.dart';
|
||||
import '../features/albums/models.dart';
|
||||
import '../features/albums/providers.dart';
|
||||
import '../features/uploads/enqueue.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import 'album_form_page.dart';
|
||||
import 'album_members_page.dart';
|
||||
import 'album_photos_page.dart';
|
||||
import 'capture_page.dart';
|
||||
import 'share_qr_dialog.dart';
|
||||
|
||||
/// 相册详情([albumId] 路由参数;进入后 GET /albums/{id})。
|
||||
@@ -127,6 +129,87 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
);
|
||||
}
|
||||
|
||||
// ── 上传入口(Task 9 FAB:拍照上传 / 从相册选择) ──────────────────────
|
||||
|
||||
/// 当前登录账号(TaskStore 隔离键;详情页处于登录壳内,null 视为异常)。
|
||||
Future<String?> _currentAccountId() async {
|
||||
final session = await ref.read(sessionStoreProvider).read();
|
||||
return session?.account;
|
||||
}
|
||||
|
||||
Future<void> _openUploadSheet() {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_camera_outlined),
|
||||
title: Text(l10n.uploadActionCapture),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
_openCapture();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_library_outlined),
|
||||
title: Text(l10n.uploadActionPick),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
_pickFromGallery();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 拍照上传:进入连拍页(目标相册 + 分类选定一次)。
|
||||
Future<void> _openCapture() async {
|
||||
final album = _album;
|
||||
if (album == null) return;
|
||||
final account = await _currentAccountId();
|
||||
if (!mounted) return;
|
||||
if (account == null) return;
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => CapturePage(
|
||||
target: CaptureTarget(
|
||||
accountId: account,
|
||||
albumId: album.id,
|
||||
albumTitle: _titleOf(album),
|
||||
categories: album.categories,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 从相册选择:多选 → 逐张入队(默认分类)→ 入队数提示。
|
||||
Future<void> _pickFromGallery() async {
|
||||
final album = _album;
|
||||
if (album == null) return;
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final account = await _currentAccountId();
|
||||
if (account == null) return;
|
||||
final paths = await ref.read(multiImagePickerProvider)();
|
||||
if (paths.isEmpty || !mounted) return;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final count = await enqueueForUpload(
|
||||
ref,
|
||||
accountId: account,
|
||||
albumId: album.id,
|
||||
sourcePaths: paths,
|
||||
);
|
||||
if (!mounted) return;
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.uploadEnqueuedCount(count))),
|
||||
);
|
||||
}
|
||||
|
||||
void _scrollToCategories() {
|
||||
final ctx = _categoriesKey.currentContext;
|
||||
if (ctx != null) {
|
||||
@@ -192,6 +275,14 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
final title = album == null ? l10n.albumDetail : _titleOf(album);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(title)),
|
||||
// 上传入口(Task 9):拍照上传(连拍页)/ 从相册选择(多选入队)。
|
||||
floatingActionButton: album == null
|
||||
? null
|
||||
: FloatingActionButton.extended(
|
||||
onPressed: _openUploadSheet,
|
||||
icon: const Icon(Icons.photo_camera_outlined),
|
||||
label: Text(l10n.uploadAction),
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: album == null
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
/// 连拍页(P3 计划 Task 9,R17 连拍入队 / O01 现场反馈)。
|
||||
///
|
||||
/// 进入时选定目标相册与分类(相册详情 FAB 传入);快门每张立即经准备器
|
||||
/// 入队(preparing→queued,不逐张确认);常驻信息层:相册名 + 分类(可
|
||||
/// 切换,**仅影响之后拍摄**——引擎只读任务行归属,切换不改既有任务)+
|
||||
/// 待传/失败徽章(TaskStore 写通知实时刷新)。相册多选按钮补充入队;
|
||||
/// 退出时若有待传任务弹确认(任务在后台继续——Android 原生 WorkManager
|
||||
/// 承担,见 native_transport.dart)。
|
||||
///
|
||||
/// 相机/相册经注入点([captureSourceFactoryProvider]/[multiImagePickerProvider])
|
||||
/// 隔离平台通道,widget 测试注入替身驱动。
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:camera/camera.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../features/albums/models.dart';
|
||||
import '../features/uploads/enqueue.dart';
|
||||
import '../features/uploads/queue_controller.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
// ── 相机源抽象(生产=camera 插件;测试替身) ─────────────────────────────
|
||||
|
||||
/// 拍摄源:初始化/拍摄/预览/释放。
|
||||
abstract class CaptureSource {
|
||||
/// 初始化(权限缺失/失败抛异常;权限拒绝抛 [CameraException])。
|
||||
Future<void> initialize();
|
||||
|
||||
/// 拍摄一张(返回源文件)。
|
||||
Future<XFile> capture();
|
||||
|
||||
/// 取景预览。
|
||||
Widget buildPreview();
|
||||
|
||||
/// 释放底层资源。
|
||||
Future<void> dispose();
|
||||
}
|
||||
|
||||
/// 生产拍摄源:camera 插件(后摄优先,最高分辨率,静音连拍)。
|
||||
class CameraPluginSource implements CaptureSource {
|
||||
CameraPluginSource._(this._controller);
|
||||
|
||||
/// 创建并初始化(后摄优先)。
|
||||
static Future<CameraPluginSource> create() async {
|
||||
final cameras = await availableCameras();
|
||||
if (cameras.isEmpty) {
|
||||
throw CameraException('noCamera', 'no camera available');
|
||||
}
|
||||
final back = cameras.firstWhere(
|
||||
(c) => c.lensDirection == CameraLensDirection.back,
|
||||
orElse: () => cameras.first,
|
||||
);
|
||||
final controller = CameraController(back, ResolutionPreset.max,
|
||||
enableAudio: false);
|
||||
await controller.initialize();
|
||||
return CameraPluginSource._(controller);
|
||||
}
|
||||
|
||||
final CameraController _controller;
|
||||
|
||||
@override
|
||||
Future<void> initialize() async {}
|
||||
|
||||
@override
|
||||
Future<XFile> capture() => _controller.takePicture();
|
||||
|
||||
@override
|
||||
Widget buildPreview() => CameraPreview(_controller);
|
||||
|
||||
@override
|
||||
Future<void> dispose() => _controller.dispose();
|
||||
}
|
||||
|
||||
/// 拍摄源工厂 Provider(测试 override 为替身)。
|
||||
final captureSourceFactoryProvider =
|
||||
Provider<Future<CaptureSource> Function()>((ref) => CameraPluginSource.create);
|
||||
|
||||
// ── 连拍页 ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 连拍页参数:目标相册(相册详情 FAB 进入时固定)与分类候选。
|
||||
class CaptureTarget {
|
||||
const CaptureTarget({
|
||||
required this.accountId,
|
||||
required this.albumId,
|
||||
required this.albumTitle,
|
||||
this.categories = const <Category>[],
|
||||
});
|
||||
|
||||
/// 入队账号(TaskStore 隔离键)。
|
||||
final String accountId;
|
||||
|
||||
/// 目标相册 ID。
|
||||
final String albumId;
|
||||
|
||||
/// 目标相册展示名(信息层常驻显示)。
|
||||
final String albumTitle;
|
||||
|
||||
/// 相册分类(空 = 无可切换项,仅默认分类)。
|
||||
final List<Category> categories;
|
||||
}
|
||||
|
||||
/// 连拍页。
|
||||
class CapturePage extends ConsumerStatefulWidget {
|
||||
const CapturePage({required this.target, super.key});
|
||||
|
||||
final CaptureTarget target;
|
||||
|
||||
@override
|
||||
ConsumerState<CapturePage> createState() => _CapturePageState();
|
||||
}
|
||||
|
||||
enum _CameraPhase { loading, ready, denied, failed }
|
||||
|
||||
class _CapturePageState extends ConsumerState<CapturePage> {
|
||||
CaptureSource? _source;
|
||||
_CameraPhase _phase = _CameraPhase.loading;
|
||||
|
||||
/// 当前分类(null = 相册默认分类;切换仅影响之后拍摄)。
|
||||
String? _categoryId;
|
||||
|
||||
/// 连拍串行链:快门连点逐张落地(相机硬件本就串行)。
|
||||
Future<void> _shotTail = Future<void>.value();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initCamera();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_source?.dispose().ignore();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _initCamera() async {
|
||||
setState(() => _phase = _CameraPhase.loading);
|
||||
try {
|
||||
final source = await ref.read(captureSourceFactoryProvider)();
|
||||
await source.initialize();
|
||||
if (!mounted) {
|
||||
await source.dispose();
|
||||
return;
|
||||
}
|
||||
_source = source;
|
||||
setState(() => _phase = _CameraPhase.ready);
|
||||
} on Object catch (error) {
|
||||
if (!mounted) return;
|
||||
setState(() => _phase = _isPermissionDenied(error)
|
||||
? _CameraPhase.denied
|
||||
: _CameraPhase.failed);
|
||||
}
|
||||
}
|
||||
|
||||
/// camera 插件的权限拒绝错误码族(CameraAccessDenied /
|
||||
/// CameraAccessDeniedWithoutD restriction 等)。
|
||||
static bool _isPermissionDenied(Object error) =>
|
||||
error is CameraException &&
|
||||
(error.code.toLowerCase().contains('denied') ||
|
||||
error.code.toLowerCase().contains('permission'));
|
||||
|
||||
/// 快门:立即入队(R17 不逐张确认);连点串行不丢张。
|
||||
void _shoot() {
|
||||
final source = _source;
|
||||
if (source == null || _phase != _CameraPhase.ready) return;
|
||||
final categoryId = _categoryId;
|
||||
_shotTail = _shotTail.then((_) async {
|
||||
final photo = await source.capture();
|
||||
if (!mounted) return;
|
||||
await enqueueForUpload(
|
||||
ref,
|
||||
accountId: widget.target.accountId,
|
||||
albumId: widget.target.albumId,
|
||||
categoryId: categoryId,
|
||||
sourcePaths: [photo.path],
|
||||
);
|
||||
}).catchError((Object _) {
|
||||
// 单张失败吞掉(连拍不中断;失败任务/错误在徽章与队列页可见)。
|
||||
});
|
||||
}
|
||||
|
||||
/// 相册多选入队(目标 = 当前相册与分类)。
|
||||
Future<void> _pickFromGallery() async {
|
||||
final paths = await ref.read(multiImagePickerProvider)();
|
||||
if (paths.isEmpty || !mounted) return;
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final count = await enqueueForUpload(
|
||||
ref,
|
||||
accountId: widget.target.accountId,
|
||||
albumId: widget.target.albumId,
|
||||
categoryId: _categoryId,
|
||||
sourcePaths: paths,
|
||||
);
|
||||
if (!mounted) return;
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.uploadEnqueuedCount(count))),
|
||||
);
|
||||
}
|
||||
|
||||
void _switchCategory(String? categoryId) {
|
||||
if (categoryId == _categoryId) return;
|
||||
setState(() => _categoryId = categoryId);
|
||||
// 归属不变提示(A15):仅影响之后拍摄。
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(content: Text(AppLocalizations.of(context).captureSwitchHint)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmExit() async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final leave = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(l10n.captureExitTitle),
|
||||
content: Text(
|
||||
l10n.captureExitBody(pendingCountOf(ref.read(taskCountsProvider))),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: Text(l10n.captureKeepShooting),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: Text(l10n.captureExitConfirm),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (leave == true && mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final counts = ref.watch(taskCountsProvider);
|
||||
final pending = pendingCountOf(counts);
|
||||
final failed = failedCountOf(counts);
|
||||
final categories = widget.target.categories;
|
||||
|
||||
return PopScope(
|
||||
// 有待传任务 → 拦截退出弹确认(后台继续文案);无任务直接退出。
|
||||
canPop: pending == 0,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (didPop) return;
|
||||
_confirmExit();
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black87,
|
||||
foregroundColor: Colors.white,
|
||||
title: Text(l10n.captureTitle),
|
||||
),
|
||||
body: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
_buildCameraArea(l10n),
|
||||
// 常驻信息层:相册 + 分类 + 待传/失败徽章(R17/O01)。
|
||||
SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_TargetOverlay(
|
||||
albumTitle: widget.target.albumTitle,
|
||||
categories: categories,
|
||||
categoryId: _categoryId,
|
||||
onCategorySelected: _switchCategory,
|
||||
pending: pending,
|
||||
failed: failed,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: _buildBottomBar(l10n),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCameraArea(AppLocalizations l10n) {
|
||||
final source = _source;
|
||||
switch (_phase) {
|
||||
case _CameraPhase.ready:
|
||||
return source!.buildPreview();
|
||||
case _CameraPhase.loading:
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
);
|
||||
case _CameraPhase.denied:
|
||||
return _CameraMessageView(
|
||||
icon: Icons.no_photography_outlined,
|
||||
message: l10n.cameraPermissionDenied,
|
||||
actionLabel: l10n.retry,
|
||||
onAction: _initCamera,
|
||||
);
|
||||
case _CameraPhase.failed:
|
||||
return _CameraMessageView(
|
||||
icon: Icons.error_outline,
|
||||
message: l10n.cameraInitFailed,
|
||||
actionLabel: l10n.retry,
|
||||
onAction: _initCamera,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildBottomBar(AppLocalizations l10n) {
|
||||
return BottomAppBar(
|
||||
color: Colors.black87,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: l10n.capturePickFromGallery,
|
||||
icon: const Icon(Icons.photo_library_outlined,
|
||||
color: Colors.white),
|
||||
onPressed: _pickFromGallery,
|
||||
),
|
||||
_ShutterButton(onPressed: _shoot, enabled: _phase == _CameraPhase.ready),
|
||||
// 占位对齐(快门居中)。
|
||||
const SizedBox(width: 48),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 信息层组件 ────────────────────────────────────────────────────────────
|
||||
|
||||
/// 顶部常驻信息层:相册名 + 分类 chips(切换仅影响之后拍摄)+ 待传/失败
|
||||
/// 徽章。
|
||||
class _TargetOverlay extends StatelessWidget {
|
||||
const _TargetOverlay({
|
||||
required this.albumTitle,
|
||||
required this.categories,
|
||||
required this.categoryId,
|
||||
required this.onCategorySelected,
|
||||
required this.pending,
|
||||
required this.failed,
|
||||
});
|
||||
|
||||
final String albumTitle;
|
||||
final List<Category> categories;
|
||||
final String? categoryId;
|
||||
final void Function(String?) onCategorySelected;
|
||||
final int pending;
|
||||
final int failed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.photo_album_outlined,
|
||||
size: 16, color: Colors.white70),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
albumTitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_Badge(
|
||||
label: l10n.capturePendingBadge(pending),
|
||||
color: Colors.white24,
|
||||
foreground: Colors.white,
|
||||
),
|
||||
if (failed > 0) ...[
|
||||
const SizedBox(width: 6),
|
||||
_Badge(
|
||||
label: l10n.captureFailedBadge(failed),
|
||||
color: Colors.red,
|
||||
foreground: Colors.white,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (categories.isNotEmpty)
|
||||
SizedBox(
|
||||
height: 44,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
children: [
|
||||
ChoiceChip(
|
||||
label: Text(l10n.captureCategoryDefault),
|
||||
selected: categoryId == null,
|
||||
onSelected: (_) => onCategorySelected(null),
|
||||
labelStyle: const TextStyle(color: Colors.white),
|
||||
selectedColor: Colors.white24,
|
||||
backgroundColor: Colors.black38,
|
||||
),
|
||||
for (final category in categories) ...[
|
||||
const SizedBox(width: 8),
|
||||
ChoiceChip(
|
||||
label: Text(_categoryLabel(category, l10n)),
|
||||
selected: categoryId == category.id,
|
||||
onSelected: (_) => onCategorySelected(category.id),
|
||||
labelStyle: const TextStyle(color: Colors.white),
|
||||
selectedColor: Colors.white24,
|
||||
backgroundColor: Colors.black38,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
static String _categoryLabel(Category category, AppLocalizations l10n) {
|
||||
final lang = l10n.localeName.split('-').first.toLowerCase();
|
||||
CategoryTranslation? match;
|
||||
for (final t in category.translations) {
|
||||
if (t.locale.toLowerCase() == l10n.localeName.toLowerCase() ||
|
||||
t.locale.toLowerCase().split('-').first == lang) {
|
||||
match = t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (match != null) return match.name;
|
||||
return category.translations.isEmpty
|
||||
? category.id
|
||||
: category.translations.first.name;
|
||||
}
|
||||
}
|
||||
|
||||
/// 徽章(待传/失败计数)。
|
||||
class _Badge extends StatelessWidget {
|
||||
const _Badge({
|
||||
required this.label,
|
||||
required this.color,
|
||||
required this.foreground,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final Color color;
|
||||
final Color foreground;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(color: foreground, fontSize: 12),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 快门按钮(连拍:不进入模态确认,逐张自动入队)。
|
||||
class _ShutterButton extends StatelessWidget {
|
||||
const _ShutterButton({required this.onPressed, required this.enabled});
|
||||
|
||||
final VoidCallback onPressed;
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: AppLocalizations.of(context).captureShutter,
|
||||
child: IconButton(
|
||||
onPressed: enabled ? onPressed : null,
|
||||
iconSize: 64,
|
||||
icon: Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 4),
|
||||
),
|
||||
child: const Icon(Icons.camera, color: Colors.white),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 相机不可用视图(权限拒绝/初始化失败):说明 + 重试。
|
||||
class _CameraMessageView extends StatelessWidget {
|
||||
const _CameraMessageView({
|
||||
required this.icon,
|
||||
required this.message,
|
||||
required this.actionLabel,
|
||||
required this.onAction,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String message;
|
||||
final String actionLabel;
|
||||
final VoidCallback onAction;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: Colors.black87,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 48, color: Colors.white70),
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: onAction,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: Text(actionLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/api_client.dart';
|
||||
import '../features/uploads/engine_scope.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import 'albums_tab.dart';
|
||||
import 'queue_tab.dart';
|
||||
|
||||
/// 主壳:底部导航(相册 / 队列 / 设置)。
|
||||
class HomePage extends StatefulWidget {
|
||||
@@ -54,45 +56,6 @@ class _HomePageState extends State<HomePage> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 上传队列(桩)。Task 9:状态机展示、筛选与重试;当前仅提供
|
||||
/// 服务端上传历史入口(A19 跨设备记录)。
|
||||
class QueueTab extends StatelessWidget {
|
||||
const QueueTab({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(l10n.queue)),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Text(
|
||||
l10n.queueStubHint,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pushNamed('/server-history'),
|
||||
icon: const Icon(Icons.history_outlined),
|
||||
label: Text(l10n.serverHistoryEntry),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置页(P3 Task 3:修改密码入口 + 退出登录;Task 10 补语言切换持久化)。
|
||||
class SettingsTab extends ConsumerWidget {
|
||||
const SettingsTab({super.key});
|
||||
@@ -100,6 +63,8 @@ class SettingsTab extends ConsumerWidget {
|
||||
Future<void> _logout(BuildContext context, WidgetRef ref) async {
|
||||
// api.logout 尽力而为(服务端撤销失败也照常本地清会话——客户端内已处理)。
|
||||
await ref.read(apiClientProvider).logout();
|
||||
// 会话账号失效 → 引擎 stop(任务保留库内,见 engine_scope.dart)。
|
||||
await ref.read(sessionAccountProvider.notifier).refresh();
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).pushNamedAndRemoveUntil('/login', (route) => false);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../core/api_client.dart';
|
||||
import '../core/app_exception.dart';
|
||||
import '../core/error_l10n.dart';
|
||||
import '../features/uploads/engine_scope.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
/// 登录页(P3 计划 Task 3)。
|
||||
@@ -44,6 +45,8 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
_accountController.text.trim(),
|
||||
_passwordController.text,
|
||||
);
|
||||
// 登录成功:刷新会话账号 → 引擎以新账号 scope start(Task 9 接线)。
|
||||
await ref.read(sessionAccountProvider.notifier).refresh();
|
||||
navigator.pushReplacementNamed('/home');
|
||||
} on AppException catch (error) {
|
||||
// 429(AUTH_RATE_LIMITED)等文案经错误码映射;登录页的
|
||||
|
||||
@@ -0,0 +1,586 @@
|
||||
/// 上传队列页(P3 计划 Task 9,需求 4.3 / R17 / O01)。
|
||||
///
|
||||
/// 三维筛选(相册下拉 × 状态 chips × 时间快捷)→ [QueueFilter];任务行
|
||||
/// = 缩略图(受管副本,缺失/已清理显示占位)+ 文件名 + 相册名 + 状态
|
||||
/// 徽章 + 尝试/错误(本地化错误码)+ 传输进度(进度总线行级订阅)+ 按
|
||||
/// 状态动作(重试/取消/删除);失败任务存在时底部「一键重试全部失败」。
|
||||
/// 列表经 TaskStore 写通知 + 200ms 防抖实时刷新(连拍风暴不逐条重建)。
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../features/albums/models.dart';
|
||||
import '../features/albums/providers.dart';
|
||||
import '../features/uploads/engine_scope.dart';
|
||||
import '../features/uploads/enqueue.dart';
|
||||
import '../features/uploads/providers.dart';
|
||||
import '../features/uploads/queue_controller.dart';
|
||||
import '../features/uploads/queue_l10n.dart';
|
||||
import '../features/uploads/task_state.dart';
|
||||
import '../features/uploads/task_store.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
/// 队列 Tab(主壳 IndexedStack 内常驻)。
|
||||
class QueueTab extends ConsumerWidget {
|
||||
const QueueTab({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final counts = ref.watch(taskCountsProvider);
|
||||
final autoRetryable = (counts[UploadTaskState.retryableFailed] ?? 0) +
|
||||
(counts[UploadTaskState.waitingNetwork] ?? 0);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.queue),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: l10n.serverHistoryEntry,
|
||||
icon: const Icon(Icons.history_outlined),
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pushNamed('/server-history'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: const Column(
|
||||
children: [
|
||||
_FilterBar(),
|
||||
Expanded(child: _TaskListView()),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: autoRetryable > 0 ? const _BatchRetryBar() : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 筛选栏 ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 筛选栏:相册下拉 + 时间快捷 chips + 状态分组 chips(需求 4.3 三维)。
|
||||
class _FilterBar extends ConsumerWidget {
|
||||
const _FilterBar();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final filter = ref.watch(queueFilterProvider);
|
||||
final albums = ref.watch(albumListProvider).items;
|
||||
|
||||
return Material(
|
||||
color: theme.colorScheme.surfaceContainerLow,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.filter_list,
|
||||
size: 18, color: theme.colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: _AlbumDropdown(filter: filter, albums: albums)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
for (final (i, time) in QueueTimeFilter.values.indexed) ...[
|
||||
if (i > 0) const SizedBox(width: 8),
|
||||
ChoiceChip(
|
||||
label: Text(_timeLabel(l10n, time)),
|
||||
selected: filter.time == time,
|
||||
onSelected: (_) => ref
|
||||
.read(queueFilterProvider.notifier)
|
||||
.setTime(time),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
for (final (i, state) in QueueStateFilter.values.indexed) ...[
|
||||
if (i > 0) const SizedBox(width: 8),
|
||||
ChoiceChip(
|
||||
label: Text(_stateLabel(l10n, state)),
|
||||
selected: filter.state == state,
|
||||
onSelected: (_) => ref
|
||||
.read(queueFilterProvider.notifier)
|
||||
.setStateGroup(state),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _stateLabel(AppLocalizations l10n, QueueStateFilter state) =>
|
||||
switch (state) {
|
||||
QueueStateFilter.all => l10n.queueStateAll,
|
||||
QueueStateFilter.preparing => l10n.queueStatePreparing,
|
||||
QueueStateFilter.queued => l10n.queueStateQueued,
|
||||
QueueStateFilter.uploading => l10n.queueStateUploading,
|
||||
QueueStateFilter.waitingNetwork => l10n.queueStateWaitingNetwork,
|
||||
QueueStateFilter.failed => l10n.queueStateFailed,
|
||||
QueueStateFilter.completed => l10n.queueStateCompleted,
|
||||
};
|
||||
|
||||
static String _timeLabel(AppLocalizations l10n, QueueTimeFilter time) =>
|
||||
switch (time) {
|
||||
QueueTimeFilter.all => l10n.queueTimeAll,
|
||||
QueueTimeFilter.today => l10n.queueTimeToday,
|
||||
QueueTimeFilter.last7Days => l10n.queueTimeLast7Days,
|
||||
QueueTimeFilter.last30Days => l10n.queueTimeLast30Days,
|
||||
};
|
||||
}
|
||||
|
||||
/// 相册下拉(全部相册 + 相册列表缓存;选中相册不在列表时退回全部)。
|
||||
class _AlbumDropdown extends ConsumerWidget {
|
||||
const _AlbumDropdown({required this.filter, required this.albums});
|
||||
|
||||
final QueueFilter filter;
|
||||
final List<AlbumSummary> albums;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final knownIds = albums.map((a) => a.id).toSet();
|
||||
final value =
|
||||
filter.albumId != null && knownIds.contains(filter.albumId)
|
||||
? filter.albumId
|
||||
: null;
|
||||
return DropdownButton<String?>(
|
||||
value: value,
|
||||
isExpanded: true,
|
||||
underline: const SizedBox.shrink(),
|
||||
hint: Text(l10n.queueAlbumFilterAll),
|
||||
items: [
|
||||
DropdownMenuItem<String?>(
|
||||
value: null,
|
||||
child: Text(
|
||||
l10n.queueAlbumFilterAll,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
for (final album in albums)
|
||||
DropdownMenuItem<String?>(
|
||||
value: album.id,
|
||||
child: Text(
|
||||
albumDisplayTitle(album, l10n.localeName),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: (albumId) =>
|
||||
ref.read(queueFilterProvider.notifier).setAlbum(albumId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 任务列表 ──────────────────────────────────────────────────────────────
|
||||
|
||||
class _TaskListView extends ConsumerWidget {
|
||||
const _TaskListView();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final tasksAsync = ref.watch(queueTasksProvider);
|
||||
return tasksAsync.when(
|
||||
skipLoadingOnReload: true,
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(l10n.errorGeneric),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.tonal(
|
||||
onPressed: () => ref.invalidate(queueTasksProvider),
|
||||
child: Text(l10n.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (tasks) {
|
||||
if (tasks.isEmpty) {
|
||||
return Center(child: Text(l10n.queueEmpty));
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
itemCount: tasks.length,
|
||||
itemBuilder: (context, index) =>
|
||||
_TaskTile(task: tasks[index]),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 单条任务行。
|
||||
class _TaskTile extends ConsumerWidget {
|
||||
const _TaskTile({required this.task});
|
||||
|
||||
final UploadTask task;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final album = ref
|
||||
.watch(albumListProvider)
|
||||
.items
|
||||
.where((a) => a.id == task.albumId)
|
||||
.firstOrNull;
|
||||
final albumName = album != null
|
||||
? albumDisplayTitle(album, l10n.localeName)
|
||||
: task.albumId;
|
||||
final failedLike = task.state == UploadTaskState.preparationFailed ||
|
||||
task.state == UploadTaskState.retryableFailed ||
|
||||
task.state == UploadTaskState.waitingNetwork;
|
||||
|
||||
return ListTile(
|
||||
leading: _Thumb(
|
||||
path: task.cleanedAt == null ? task.sourceFilePath : null,
|
||||
),
|
||||
title: Text(
|
||||
task.filename,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
_StateChip(state: task.state),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
albumName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (failedLike && task.lastError != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
task.attempt > 0
|
||||
? '${l10n.messageForTaskError(task.lastError!)} · '
|
||||
'${l10n.queueAttemptCount(task.attempt)}'
|
||||
: l10n.messageForTaskError(task.lastError!),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(color: theme.colorScheme.error),
|
||||
),
|
||||
],
|
||||
if (task.state == UploadTaskState.uploading ||
|
||||
task.state == UploadTaskState.uploadedConfirming)
|
||||
_ProgressLine(
|
||||
taskId: task.id,
|
||||
confirming: task.state == UploadTaskState.uploadedConfirming,
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: _TaskActions(task: task),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 缩略图:受管副本存在则显示,缺失/已清理/解码失败显示占位图标。
|
||||
class _Thumb extends StatelessWidget {
|
||||
const _Thumb({required this.path});
|
||||
|
||||
final String? path;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
Widget placeholder() => Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.image_outlined,
|
||||
size: 24,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
if (path == null) return placeholder();
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: Image.file(
|
||||
File(path!),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => placeholder(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 状态徽章(分组配色:失败红/完成绿/进行中主色/等待橙/其余中性)。
|
||||
class _StateChip extends StatelessWidget {
|
||||
const _StateChip({required this.state});
|
||||
|
||||
final UploadTaskState state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final (color, onColor) = switch (state) {
|
||||
UploadTaskState.preparing => (
|
||||
theme.colorScheme.surfaceContainerHighest,
|
||||
theme.colorScheme.onSurfaceVariant
|
||||
),
|
||||
UploadTaskState.preparationFailed ||
|
||||
UploadTaskState.retryableFailed => (
|
||||
theme.colorScheme.errorContainer,
|
||||
theme.colorScheme.onErrorContainer
|
||||
),
|
||||
UploadTaskState.queued => (
|
||||
theme.colorScheme.secondaryContainer,
|
||||
theme.colorScheme.onSecondaryContainer
|
||||
),
|
||||
UploadTaskState.uploading ||
|
||||
UploadTaskState.uploadedConfirming => (
|
||||
theme.colorScheme.primaryContainer,
|
||||
theme.colorScheme.onPrimaryContainer
|
||||
),
|
||||
UploadTaskState.completed => (
|
||||
theme.colorScheme.tertiaryContainer,
|
||||
theme.colorScheme.onTertiaryContainer
|
||||
),
|
||||
UploadTaskState.waitingNetwork => (
|
||||
theme.colorScheme.tertiaryContainer,
|
||||
theme.colorScheme.onTertiaryContainer
|
||||
),
|
||||
UploadTaskState.cancelled => (
|
||||
theme.colorScheme.surfaceContainerHighest,
|
||||
theme.colorScheme.onSurfaceVariant
|
||||
),
|
||||
};
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
l10n.labelForTaskState(state),
|
||||
style: theme.textTheme.labelSmall?.copyWith(color: onColor),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 传输进度行:上传中 = 进度总线行级订阅(分数进度条);确认中 = 不确定
|
||||
/// 进度条(等待服务端核验)。
|
||||
class _ProgressLine extends ConsumerWidget {
|
||||
const _ProgressLine({required this.taskId, required this.confirming});
|
||||
|
||||
final String taskId;
|
||||
final bool confirming;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 6, right: 8),
|
||||
child: StreamBuilder<UploadTaskProgress>(
|
||||
stream: ref
|
||||
.watch(uploadProgressHubProvider)
|
||||
.stream
|
||||
.where((p) => p.taskId == taskId),
|
||||
builder: (context, snapshot) {
|
||||
final fraction = snapshot.data?.fraction;
|
||||
return LinearProgressIndicator(
|
||||
value: confirming ? null : fraction,
|
||||
minHeight: 4,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 行级动作(按状态;未登录账号缺席时禁用)。
|
||||
class _TaskActions extends ConsumerWidget {
|
||||
const _TaskActions({required this.task});
|
||||
|
||||
final UploadTask task;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final account = ref.watch(sessionAccountProvider).value;
|
||||
switch (task.state) {
|
||||
case UploadTaskState.preparationFailed:
|
||||
// 准备失败重试 = 重新选图(原始源路径不落库,见准备器注释)。
|
||||
return IconButton(
|
||||
tooltip: l10n.retry,
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: account == null
|
||||
? null
|
||||
: () => _retryPreparation(context, ref, account),
|
||||
);
|
||||
case UploadTaskState.retryableFailed:
|
||||
case UploadTaskState.waitingNetwork:
|
||||
return IconButton(
|
||||
tooltip: l10n.retry,
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: account == null
|
||||
? null
|
||||
: () => ref
|
||||
.read(uploadEngineProvider)
|
||||
.retryNow(account, task.id),
|
||||
);
|
||||
case UploadTaskState.queued:
|
||||
case UploadTaskState.preparing:
|
||||
return IconButton(
|
||||
tooltip: l10n.cancel,
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: account == null
|
||||
? null
|
||||
: () => ref.read(uploadEngineProvider).cancel(account, task.id),
|
||||
);
|
||||
case UploadTaskState.completed:
|
||||
case UploadTaskState.cancelled:
|
||||
return IconButton(
|
||||
tooltip: l10n.delete,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: account == null ? null : () => _delete(ref, account),
|
||||
);
|
||||
case UploadTaskState.uploading:
|
||||
case UploadTaskState.uploadedConfirming:
|
||||
return const SizedBox(width: 48);
|
||||
}
|
||||
}
|
||||
|
||||
/// 准备失败重试:重新选图 → preparer.retry(换源重新复制入队)。
|
||||
Future<void> _retryPreparation(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String account,
|
||||
) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final sourcePath = await ref.read(singleImagePickerProvider)();
|
||||
if (sourcePath == null || !context.mounted) return;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
await ref.read(uploadPreparerProvider).retry(
|
||||
accountId: account,
|
||||
taskId: task.id,
|
||||
sourcePath: sourcePath,
|
||||
);
|
||||
} on Object {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.errorGeneric)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 终态删除:删行 + 尽力删受管副本(行删除后清理执行器不再可见该文件)。
|
||||
Future<void> _delete(WidgetRef ref, String account) async {
|
||||
final store = ref.read(taskStoreProvider);
|
||||
await store.delete(task.id);
|
||||
if (task.cleanedAt == null) {
|
||||
try {
|
||||
final file = File(task.sourceFilePath);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
} on Object {
|
||||
// 文件删除失败接受(无行可追责,残留由用户重装/系统清理回收)。
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 底部批量重试条(存在可自动重试的失败任务时出现)。
|
||||
class _BatchRetryBar extends ConsumerWidget {
|
||||
const _BatchRetryBar();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final counts = ref.watch(taskCountsProvider);
|
||||
final failed = failedCountOf(counts);
|
||||
return Material(
|
||||
color: theme.colorScheme.errorContainer,
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: theme.colorScheme.onErrorContainer),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${l10n.queueStateFailed} · $failed',
|
||||
style: theme.textTheme.bodyMedium
|
||||
?.copyWith(color: theme.colorScheme.onErrorContainer),
|
||||
),
|
||||
),
|
||||
FilledButton.icon(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.error,
|
||||
foregroundColor: theme.colorScheme.onError,
|
||||
),
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
label: Text(l10n.queueRetryAllFailed),
|
||||
onPressed: () => _retryAll(ref),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 全部可自动重试任务(retryableFailed + waitingNetwork,跨当前筛选)
|
||||
/// 立即重新入队。
|
||||
Future<void> _retryAll(WidgetRef ref) async {
|
||||
final account = ref.read(sessionAccountProvider).value;
|
||||
if (account == null) return;
|
||||
final engine = ref.read(uploadEngineProvider);
|
||||
final tasks = await ref.read(taskStoreProvider).listByAccount(
|
||||
account,
|
||||
states: const {
|
||||
UploadTaskState.retryableFailed,
|
||||
UploadTaskState.waitingNetwork,
|
||||
},
|
||||
);
|
||||
for (final task in tasks) {
|
||||
await engine.retryNow(account, task.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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<String?> read() async => '0123456789abcdef0123456789abcdef';
|
||||
@override
|
||||
Future<void> 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<String?> settleScope(ProviderContainer container) async {
|
||||
final account = await container.read(sessionAccountProvider.future);
|
||||
// 触发 scope Provider 重建(引擎 start/stop 发生在 watch 传播中)。
|
||||
container.read(uploadEngineScopeProvider);
|
||||
await Future<void>.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<void>.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<void>.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);
|
||||
});
|
||||
}
|
||||
@@ -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<UploadTask> 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 = <UploadTaskState, int>{
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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<void> 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<dynamic> pageTestOverrides({
|
||||
FakeSessionStore? session,
|
||||
TaskStore? taskStore,
|
||||
ApiClient? client,
|
||||
}) {
|
||||
// 每个测试各自 new 一块内存库(独立 QueryExecutor,随容器销毁),
|
||||
// drift 的“多实例同执行器”告警在此是误报——测试输出降噪。
|
||||
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||
final store = taskStore ?? TaskStore.memory();
|
||||
return <dynamic>[
|
||||
if (session != null) sessionStoreProvider.overrideWithValue(session),
|
||||
taskStoreProvider.overrideWithValue(store),
|
||||
if (client != null) apiClientProvider.overrideWith((ref) => client),
|
||||
];
|
||||
}
|
||||
|
||||
/// 记录到适配器的请求快照。
|
||||
class RecordedRequest {
|
||||
const RecordedRequest({
|
||||
|
||||
@@ -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<int> kJpgBytes = <int>[
|
||||
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00,
|
||||
];
|
||||
|
||||
void main() {
|
||||
late FakeSessionStore store;
|
||||
late List<Map<String, dynamic>> categories;
|
||||
@@ -55,7 +70,11 @@ void main() {
|
||||
Future<void> 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<String> 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 = <String>[];
|
||||
taskStore = TaskStore.memory();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await taskStore.close();
|
||||
await tmp.delete(recursive: true);
|
||||
});
|
||||
|
||||
/// 详情页 + 上传链路替身(内存任务库/临时目录准备器/多选替身/
|
||||
/// 非平台相机源)。
|
||||
Future<void> 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<void>.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<void> initialize() async {}
|
||||
|
||||
@override
|
||||
Future<XFile> capture() => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Widget buildPreview() => const SizedBox();
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
}
|
||||
|
||||
@@ -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<int> kJpgBytes = <int>[
|
||||
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00,
|
||||
];
|
||||
|
||||
/// 拍摄源替身:capture 依次弹出预置文件;initialize 可注入失败次数。
|
||||
class FakeCaptureSource implements CaptureSource {
|
||||
FakeCaptureSource(this._files);
|
||||
|
||||
final List<File> _files;
|
||||
int initializeCalls = 0;
|
||||
int failNextInits = 0;
|
||||
Object? initError;
|
||||
|
||||
@override
|
||||
Future<void> initialize() async {
|
||||
initializeCalls++;
|
||||
if (failNextInits > 0) {
|
||||
failNextInits--;
|
||||
throw initError ?? StateError('init failed');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<XFile> capture() async => XFile(_files.removeAt(0).path);
|
||||
|
||||
@override
|
||||
Widget buildPreview() => const ColoredBox(
|
||||
color: Color(0xFF101010),
|
||||
child: Center(child: Text('PREVIEW_MARK')),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late FakeSessionStore session;
|
||||
late TaskStore store;
|
||||
late Directory tmp;
|
||||
late FakeCaptureSource source;
|
||||
late List<String> galleryPaths;
|
||||
|
||||
final categories = <Category>[
|
||||
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 = <String>[];
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await store.close();
|
||||
await tmp.delete(recursive: true);
|
||||
});
|
||||
|
||||
CaptureTarget target() => CaptureTarget(
|
||||
accountId: 'alice',
|
||||
albumId: 'alb-1',
|
||||
albumTitle: 'Opening Gala',
|
||||
categories: categories,
|
||||
);
|
||||
|
||||
Future<void> 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<void> 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<void>.delayed(const Duration(milliseconds: 300));
|
||||
});
|
||||
await tester.pump(kQueueRefreshDebounce);
|
||||
await tester.pump(const Duration(milliseconds: 60));
|
||||
}
|
||||
|
||||
/// 快门(即拍即入队链路经 [pressRealIo])。
|
||||
Future<void> 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(<String>[
|
||||
'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<void> requestBack() async {
|
||||
final navigator = tester.state<NavigatorState>(
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -37,7 +37,11 @@ void main() {
|
||||
Future<void> 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,
|
||||
|
||||
@@ -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<int> kJpgBytes = <int>[
|
||||
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00,
|
||||
];
|
||||
|
||||
class _FixedDeviceIdStore implements DeviceIdStore {
|
||||
@override
|
||||
Future<String?> read() async => '0123456789abcdef0123456789abcdef';
|
||||
@override
|
||||
Future<void> 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<void> retryNow(String accountId, String taskId) async =>
|
||||
retryCalls.add((accountId, taskId));
|
||||
|
||||
@override
|
||||
Future<bool> 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<String> 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(<Map<String, dynamic>>[
|
||||
albumJson(id: 'alb-1'),
|
||||
albumJson(
|
||||
id: 'alb-2',
|
||||
translations: <Map<String, dynamic>>[
|
||||
<String, dynamic>{
|
||||
'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 = <String>[];
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await store.close();
|
||||
await tmp.delete(recursive: true);
|
||||
});
|
||||
|
||||
Future<void> 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<UploadTask> 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<UploadTask> 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<void> 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<void>.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%');
|
||||
});
|
||||
}
|
||||
@@ -38,7 +38,11 @@ void main() {
|
||||
Future<void> 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,
|
||||
|
||||
Reference in New Issue
Block a user