feat(mobile): UI-03 capture and upload pages (13-16)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -199,6 +199,37 @@
|
||||
"queueRetryAllFailed": "Retry all failed",
|
||||
"queueAttemptCount": "{count} attempt(s)",
|
||||
"@queueAttemptCount": { "placeholders": { "count": { "type": "int" } } },
|
||||
"captureQueued": "{count} queued",
|
||||
"@captureQueued": { "placeholders": { "count": { "type": "int" } } },
|
||||
"captureFlipCamera": "Switch camera",
|
||||
"captureFlash": "Flash",
|
||||
"uploadsHistory": "History",
|
||||
"uploadsToday": "Today",
|
||||
"uploadsThisDevice": "This device",
|
||||
"uploadsCompleted": "completed",
|
||||
"uploadsUploading": "uploading",
|
||||
"uploadsWaiting": "waiting",
|
||||
"uploadsNeedsAttention": "Needs attention",
|
||||
"uploadsAll": "All",
|
||||
"uploadsActive": "Active",
|
||||
"uploadsConfirming": "Confirming…",
|
||||
"uploadsWaitingUpload": "Waiting to upload",
|
||||
"uploadsWaitingNetwork": "Waiting for network",
|
||||
"uploadsFileUnavailable": "File unavailable",
|
||||
"uploadsSelectAgain": "Select again",
|
||||
"uploadsRemoveRecord": "Remove record",
|
||||
"uploadsRemoveRecordConfirm": "Remove",
|
||||
"uploadsRetryEligible": "Retry eligible",
|
||||
"uploadsRetryEligibleScope": "Retries failed uploads that can recover on their own. Files marked unavailable need to be selected again.",
|
||||
"uploadsNoNeedsAttention": "No tasks need attention",
|
||||
"uploadsShowingNeedsAttention": "Showing tasks that need your attention",
|
||||
"uploadsFilters": "Filters",
|
||||
"serverRecords": "Server records",
|
||||
"serverSubtitle": "All devices · Your account",
|
||||
"serverConfirmed": "Confirmed",
|
||||
"serverReady": "Ready",
|
||||
"serverProcessing": "Processing",
|
||||
"serverDeviceUnknown": "Unknown device",
|
||||
"taskStatePreparing": "Preparing",
|
||||
"taskStatePreparationFailed": "Preparation failed",
|
||||
"taskStateQueued": "Queued",
|
||||
|
||||
@@ -1256,6 +1256,186 @@ abstract class AppLocalizations {
|
||||
/// **'已尝试 {count} 次'**
|
||||
String queueAttemptCount(int count);
|
||||
|
||||
/// No description provided for @captureQueued.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'{count} 张待传'**
|
||||
String captureQueued(int count);
|
||||
|
||||
/// No description provided for @captureFlipCamera.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'切换前后摄像头'**
|
||||
String get captureFlipCamera;
|
||||
|
||||
/// No description provided for @captureFlash.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'闪光灯'**
|
||||
String get captureFlash;
|
||||
|
||||
/// No description provided for @uploadsHistory.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'历史记录'**
|
||||
String get uploadsHistory;
|
||||
|
||||
/// No description provided for @uploadsToday.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'今天'**
|
||||
String get uploadsToday;
|
||||
|
||||
/// No description provided for @uploadsThisDevice.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'本设备'**
|
||||
String get uploadsThisDevice;
|
||||
|
||||
/// No description provided for @uploadsCompleted.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'已完成'**
|
||||
String get uploadsCompleted;
|
||||
|
||||
/// No description provided for @uploadsUploading.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'上传中'**
|
||||
String get uploadsUploading;
|
||||
|
||||
/// No description provided for @uploadsWaiting.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'等待中'**
|
||||
String get uploadsWaiting;
|
||||
|
||||
/// No description provided for @uploadsNeedsAttention.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'需关注'**
|
||||
String get uploadsNeedsAttention;
|
||||
|
||||
/// No description provided for @uploadsAll.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'全部'**
|
||||
String get uploadsAll;
|
||||
|
||||
/// No description provided for @uploadsActive.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'进行中'**
|
||||
String get uploadsActive;
|
||||
|
||||
/// No description provided for @uploadsConfirming.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'确认中…'**
|
||||
String get uploadsConfirming;
|
||||
|
||||
/// No description provided for @uploadsWaitingUpload.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'等待上传'**
|
||||
String get uploadsWaitingUpload;
|
||||
|
||||
/// No description provided for @uploadsWaitingNetwork.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'等待网络'**
|
||||
String get uploadsWaitingNetwork;
|
||||
|
||||
/// No description provided for @uploadsFileUnavailable.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'文件不可用'**
|
||||
String get uploadsFileUnavailable;
|
||||
|
||||
/// No description provided for @uploadsSelectAgain.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'重新选择'**
|
||||
String get uploadsSelectAgain;
|
||||
|
||||
/// No description provided for @uploadsRemoveRecord.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'移除记录'**
|
||||
String get uploadsRemoveRecord;
|
||||
|
||||
/// No description provided for @uploadsRemoveRecordConfirm.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'移除'**
|
||||
String get uploadsRemoveRecordConfirm;
|
||||
|
||||
/// No description provided for @uploadsRetryEligible.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'重试可恢复项'**
|
||||
String get uploadsRetryEligible;
|
||||
|
||||
/// No description provided for @uploadsRetryEligibleScope.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'仅重试可自动恢复的上传失败任务;文件不可用的任务需重新选择。'**
|
||||
String get uploadsRetryEligibleScope;
|
||||
|
||||
/// No description provided for @uploadsNoNeedsAttention.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'暂无需要关注的任务'**
|
||||
String get uploadsNoNeedsAttention;
|
||||
|
||||
/// No description provided for @uploadsShowingNeedsAttention.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'正在显示需要你处理的任务'**
|
||||
String get uploadsShowingNeedsAttention;
|
||||
|
||||
/// No description provided for @uploadsFilters.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'筛选'**
|
||||
String get uploadsFilters;
|
||||
|
||||
/// No description provided for @serverRecords.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'服务端记录'**
|
||||
String get serverRecords;
|
||||
|
||||
/// No description provided for @serverSubtitle.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'全部设备 · 当前账号'**
|
||||
String get serverSubtitle;
|
||||
|
||||
/// No description provided for @serverConfirmed.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'已确认'**
|
||||
String get serverConfirmed;
|
||||
|
||||
/// No description provided for @serverReady.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'已就绪'**
|
||||
String get serverReady;
|
||||
|
||||
/// No description provided for @serverProcessing.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'处理中'**
|
||||
String get serverProcessing;
|
||||
|
||||
/// No description provided for @serverDeviceUnknown.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'未知设备'**
|
||||
String get serverDeviceUnknown;
|
||||
|
||||
/// No description provided for @taskStatePreparing.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
|
||||
@@ -617,6 +617,100 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
return '$count attempt(s)';
|
||||
}
|
||||
|
||||
@override
|
||||
String captureQueued(int count) {
|
||||
return '$count queued';
|
||||
}
|
||||
|
||||
@override
|
||||
String get captureFlipCamera => 'Switch camera';
|
||||
|
||||
@override
|
||||
String get captureFlash => 'Flash';
|
||||
|
||||
@override
|
||||
String get uploadsHistory => 'History';
|
||||
|
||||
@override
|
||||
String get uploadsToday => 'Today';
|
||||
|
||||
@override
|
||||
String get uploadsThisDevice => 'This device';
|
||||
|
||||
@override
|
||||
String get uploadsCompleted => 'completed';
|
||||
|
||||
@override
|
||||
String get uploadsUploading => 'uploading';
|
||||
|
||||
@override
|
||||
String get uploadsWaiting => 'waiting';
|
||||
|
||||
@override
|
||||
String get uploadsNeedsAttention => 'Needs attention';
|
||||
|
||||
@override
|
||||
String get uploadsAll => 'All';
|
||||
|
||||
@override
|
||||
String get uploadsActive => 'Active';
|
||||
|
||||
@override
|
||||
String get uploadsConfirming => 'Confirming…';
|
||||
|
||||
@override
|
||||
String get uploadsWaitingUpload => 'Waiting to upload';
|
||||
|
||||
@override
|
||||
String get uploadsWaitingNetwork => 'Waiting for network';
|
||||
|
||||
@override
|
||||
String get uploadsFileUnavailable => 'File unavailable';
|
||||
|
||||
@override
|
||||
String get uploadsSelectAgain => 'Select again';
|
||||
|
||||
@override
|
||||
String get uploadsRemoveRecord => 'Remove record';
|
||||
|
||||
@override
|
||||
String get uploadsRemoveRecordConfirm => 'Remove';
|
||||
|
||||
@override
|
||||
String get uploadsRetryEligible => 'Retry eligible';
|
||||
|
||||
@override
|
||||
String get uploadsRetryEligibleScope =>
|
||||
'Retries failed uploads that can recover on their own. Files marked unavailable need to be selected again.';
|
||||
|
||||
@override
|
||||
String get uploadsNoNeedsAttention => 'No tasks need attention';
|
||||
|
||||
@override
|
||||
String get uploadsShowingNeedsAttention =>
|
||||
'Showing tasks that need your attention';
|
||||
|
||||
@override
|
||||
String get uploadsFilters => 'Filters';
|
||||
|
||||
@override
|
||||
String get serverRecords => 'Server records';
|
||||
|
||||
@override
|
||||
String get serverSubtitle => 'All devices · Your account';
|
||||
|
||||
@override
|
||||
String get serverConfirmed => 'Confirmed';
|
||||
|
||||
@override
|
||||
String get serverReady => 'Ready';
|
||||
|
||||
@override
|
||||
String get serverProcessing => 'Processing';
|
||||
|
||||
@override
|
||||
String get serverDeviceUnknown => 'Unknown device';
|
||||
|
||||
@override
|
||||
String get taskStatePreparing => 'Preparing';
|
||||
|
||||
|
||||
@@ -600,6 +600,98 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
return '已尝试 $count 次';
|
||||
}
|
||||
|
||||
@override
|
||||
String captureQueued(int count) {
|
||||
return '$count 张待传';
|
||||
}
|
||||
|
||||
@override
|
||||
String get captureFlipCamera => '切换前后摄像头';
|
||||
|
||||
@override
|
||||
String get captureFlash => '闪光灯';
|
||||
|
||||
@override
|
||||
String get uploadsHistory => '历史记录';
|
||||
|
||||
@override
|
||||
String get uploadsToday => '今天';
|
||||
|
||||
@override
|
||||
String get uploadsThisDevice => '本设备';
|
||||
|
||||
@override
|
||||
String get uploadsCompleted => '已完成';
|
||||
|
||||
@override
|
||||
String get uploadsUploading => '上传中';
|
||||
|
||||
@override
|
||||
String get uploadsWaiting => '等待中';
|
||||
|
||||
@override
|
||||
String get uploadsNeedsAttention => '需关注';
|
||||
|
||||
@override
|
||||
String get uploadsAll => '全部';
|
||||
|
||||
@override
|
||||
String get uploadsActive => '进行中';
|
||||
|
||||
@override
|
||||
String get uploadsConfirming => '确认中…';
|
||||
|
||||
@override
|
||||
String get uploadsWaitingUpload => '等待上传';
|
||||
|
||||
@override
|
||||
String get uploadsWaitingNetwork => '等待网络';
|
||||
|
||||
@override
|
||||
String get uploadsFileUnavailable => '文件不可用';
|
||||
|
||||
@override
|
||||
String get uploadsSelectAgain => '重新选择';
|
||||
|
||||
@override
|
||||
String get uploadsRemoveRecord => '移除记录';
|
||||
|
||||
@override
|
||||
String get uploadsRemoveRecordConfirm => '移除';
|
||||
|
||||
@override
|
||||
String get uploadsRetryEligible => '重试可恢复项';
|
||||
|
||||
@override
|
||||
String get uploadsRetryEligibleScope => '仅重试可自动恢复的上传失败任务;文件不可用的任务需重新选择。';
|
||||
|
||||
@override
|
||||
String get uploadsNoNeedsAttention => '暂无需要关注的任务';
|
||||
|
||||
@override
|
||||
String get uploadsShowingNeedsAttention => '正在显示需要你处理的任务';
|
||||
|
||||
@override
|
||||
String get uploadsFilters => '筛选';
|
||||
|
||||
@override
|
||||
String get serverRecords => '服务端记录';
|
||||
|
||||
@override
|
||||
String get serverSubtitle => '全部设备 · 当前账号';
|
||||
|
||||
@override
|
||||
String get serverConfirmed => '已确认';
|
||||
|
||||
@override
|
||||
String get serverReady => '已就绪';
|
||||
|
||||
@override
|
||||
String get serverProcessing => '处理中';
|
||||
|
||||
@override
|
||||
String get serverDeviceUnknown => '未知设备';
|
||||
|
||||
@override
|
||||
String get taskStatePreparing => '准备中';
|
||||
|
||||
|
||||
@@ -199,6 +199,37 @@
|
||||
"queueRetryAllFailed": "一键重试全部失败",
|
||||
"queueAttemptCount": "已尝试 {count} 次",
|
||||
"@queueAttemptCount": { "placeholders": { "count": { "type": "int" } } },
|
||||
"captureQueued": "{count} 张待传",
|
||||
"@captureQueued": { "placeholders": { "count": { "type": "int" } } },
|
||||
"captureFlipCamera": "切换前后摄像头",
|
||||
"captureFlash": "闪光灯",
|
||||
"uploadsHistory": "历史记录",
|
||||
"uploadsToday": "今天",
|
||||
"uploadsThisDevice": "本设备",
|
||||
"uploadsCompleted": "已完成",
|
||||
"uploadsUploading": "上传中",
|
||||
"uploadsWaiting": "等待中",
|
||||
"uploadsNeedsAttention": "需关注",
|
||||
"uploadsAll": "全部",
|
||||
"uploadsActive": "进行中",
|
||||
"uploadsConfirming": "确认中…",
|
||||
"uploadsWaitingUpload": "等待上传",
|
||||
"uploadsWaitingNetwork": "等待网络",
|
||||
"uploadsFileUnavailable": "文件不可用",
|
||||
"uploadsSelectAgain": "重新选择",
|
||||
"uploadsRemoveRecord": "移除记录",
|
||||
"uploadsRemoveRecordConfirm": "移除",
|
||||
"uploadsRetryEligible": "重试可恢复项",
|
||||
"uploadsRetryEligibleScope": "仅重试可自动恢复的上传失败任务;文件不可用的任务需重新选择。",
|
||||
"uploadsNoNeedsAttention": "暂无需要关注的任务",
|
||||
"uploadsShowingNeedsAttention": "正在显示需要你处理的任务",
|
||||
"uploadsFilters": "筛选",
|
||||
"serverRecords": "服务端记录",
|
||||
"serverSubtitle": "全部设备 · 当前账号",
|
||||
"serverConfirmed": "已确认",
|
||||
"serverReady": "已就绪",
|
||||
"serverProcessing": "处理中",
|
||||
"serverDeviceUnknown": "未知设备",
|
||||
"taskStatePreparing": "准备中",
|
||||
"taskStatePreparationFailed": "准备失败",
|
||||
"taskStateQueued": "待上传",
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
/// 连拍页(P3 计划 Task 9,R17 连拍入队 / O01 现场反馈)。
|
||||
/// 连拍页(UI-03 页面 13 深色摄影界面;P3 计划 Task 9 / R17 连拍入队)。
|
||||
///
|
||||
/// 进入时选定目标相册与分类(相册详情 FAB 传入);快门每张立即经准备器
|
||||
/// 入队(preparing→queued,不逐张确认);常驻信息层:相册名 + 分类(可
|
||||
/// 切换,**仅影响之后拍摄**——引擎只读任务行归属,切换不改既有任务)+
|
||||
/// 待传/失败徽章(TaskStore 写通知实时刷新)。相册多选按钮补充入队;
|
||||
/// 退出时若有待传任务弹确认(任务在后台继续——Android 原生 WorkManager
|
||||
/// 承担,见 native_transport.dart)。
|
||||
/// 入队(preparing→queued,不逐张确认)。全屏取景(恒深色 #10161E,不随
|
||||
/// 系统主题):顶部常驻层 = 返回 + 相册名 + 待传/失败徽章 + 分类 chips
|
||||
/// (可切换,**仅影响之后拍摄**——引擎只读任务行归属);底部 = 最近一张
|
||||
/// 缩略图(兼相册多选入口,iOS 相机惯例)+ 快门(≥72dp 白环)+ 前后摄
|
||||
/// 切换 + 闪光灯(源支持才出现)。入队成功以触觉 + 200ms 浮层轻反馈,
|
||||
/// 不逐张弹确认;退出时若有待传任务弹确认(任务在后台继续——Android
|
||||
/// 原生 WorkManager 承担,见 native_transport.dart)。
|
||||
///
|
||||
/// 相机/相册经注入点([captureSourceFactoryProvider]/[multiImagePickerProvider])
|
||||
/// 隔离平台通道,widget 测试注入替身驱动。
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:camera/camera.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -22,6 +25,7 @@ import '../features/albums/models.dart';
|
||||
import '../features/uploads/enqueue.dart';
|
||||
import '../features/uploads/queue_controller.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../ui/tokens.dart';
|
||||
|
||||
// ── 相机源抽象(生产=camera 插件;测试替身) ─────────────────────────────
|
||||
|
||||
@@ -33,28 +37,38 @@ abstract class CaptureSource {
|
||||
/// 拍摄一张(返回源文件)。
|
||||
Future<XFile> capture();
|
||||
|
||||
/// 取景预览。
|
||||
/// 取景预览(含纵横比校正——全屏 cover 铺满,不拉伸)。
|
||||
Widget buildPreview();
|
||||
|
||||
/// 释放底层资源。
|
||||
Future<void> dispose();
|
||||
|
||||
/// 闪光灯能力(默认不支持——子类按硬件能力覆写;不支持则 UI 不显示
|
||||
/// 闪光开关,不提供假开关)。
|
||||
bool get supportsFlash => false;
|
||||
|
||||
/// 设置闪光灯(仅 [supportsFlash] 为真时有意义;失败抛异常由调用方回滚)。
|
||||
Future<void> setFlashEnabled(bool enabled) async {}
|
||||
}
|
||||
|
||||
/// 生产拍摄源:camera 插件(后摄优先,最高分辨率,静音连拍)。
|
||||
class CameraPluginSource implements CaptureSource {
|
||||
class CameraPluginSource extends CaptureSource {
|
||||
CameraPluginSource._(this._controller);
|
||||
|
||||
/// 创建并初始化(后摄优先)。
|
||||
static Future<CameraPluginSource> create() async {
|
||||
/// 创建并初始化([preferFront] 为真选前摄,否则后摄优先)。
|
||||
static Future<CameraPluginSource> create({bool preferFront = false}) 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,
|
||||
CameraDescription pick() => cameras.firstWhere(
|
||||
(c) => c.lensDirection ==
|
||||
(preferFront
|
||||
? CameraLensDirection.front
|
||||
: CameraLensDirection.back),
|
||||
orElse: () => cameras.first,
|
||||
);
|
||||
final controller = CameraController(pick(), ResolutionPreset.max,
|
||||
enableAudio: false);
|
||||
await controller.initialize();
|
||||
return CameraPluginSource._(controller);
|
||||
@@ -69,7 +83,28 @@ class CameraPluginSource implements CaptureSource {
|
||||
Future<XFile> capture() => _controller.takePicture();
|
||||
|
||||
@override
|
||||
Widget buildPreview() => CameraPreview(_controller);
|
||||
Widget buildPreview() {
|
||||
// 纵横比校正(§4 页面 13):预览按传感器尺寸布局,再 cover 铺满——
|
||||
// CameraPreview 直填父约束会把画面拉扁/拉高。
|
||||
final size = _controller.value.previewSize;
|
||||
Widget preview = CameraPreview(_controller);
|
||||
if (size != null && size.width > 0 && size.height > 0) {
|
||||
preview = FittedBox(
|
||||
fit: BoxFit.cover,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: SizedBox(width: size.width, height: size.height, child: preview),
|
||||
);
|
||||
}
|
||||
return preview;
|
||||
}
|
||||
|
||||
@override
|
||||
bool get supportsFlash => true;
|
||||
|
||||
@override
|
||||
Future<void> setFlashEnabled(bool enabled) => _controller.setFlashMode(
|
||||
enabled ? FlashMode.always : FlashMode.off,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> dispose() => _controller.dispose();
|
||||
@@ -77,7 +112,8 @@ class CameraPluginSource implements CaptureSource {
|
||||
|
||||
/// 拍摄源工厂 Provider(测试 override 为替身)。
|
||||
final captureSourceFactoryProvider =
|
||||
Provider<Future<CaptureSource> Function()>((ref) => CameraPluginSource.create);
|
||||
Provider<Future<CaptureSource> Function({bool preferFront})>(
|
||||
(ref) => CameraPluginSource.create);
|
||||
|
||||
// ── 连拍页 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -119,6 +155,15 @@ class _CapturePageState extends ConsumerState<CapturePage> {
|
||||
CaptureSource? _source;
|
||||
_CameraPhase _phase = _CameraPhase.loading;
|
||||
|
||||
/// 当前镜头方向(快门旁切换按钮翻转;重试沿用)。
|
||||
bool _preferFront = false;
|
||||
|
||||
/// 闪光灯开关(源不支持则无 UI;重开相机后复位)。
|
||||
bool _flashOn = false;
|
||||
|
||||
/// 最近一张拍摄文件(底部缩略图;拍摄前显示相册入口占位)。
|
||||
String? _lastShotPath;
|
||||
|
||||
/// 当前分类(null = 相册默认分类;切换仅影响之后拍摄)。
|
||||
String? _categoryId;
|
||||
|
||||
@@ -142,17 +187,24 @@ class _CapturePageState extends ConsumerState<CapturePage> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _initCamera() async {
|
||||
Future<void> _initCamera({bool? preferFront}) async {
|
||||
final front = preferFront ?? _preferFront;
|
||||
setState(() => _phase = _CameraPhase.loading);
|
||||
try {
|
||||
final source = await ref.read(captureSourceFactoryProvider)();
|
||||
final source = await ref.read(captureSourceFactoryProvider)(
|
||||
preferFront: front,
|
||||
);
|
||||
await source.initialize();
|
||||
if (!mounted) {
|
||||
await source.dispose();
|
||||
return;
|
||||
}
|
||||
_source = source;
|
||||
setState(() => _phase = _CameraPhase.ready);
|
||||
setState(() {
|
||||
_preferFront = front;
|
||||
_flashOn = false; // 新相机实例回默认关。
|
||||
_phase = _CameraPhase.ready;
|
||||
});
|
||||
} on Object catch (error) {
|
||||
if (!mounted) return;
|
||||
setState(() => _phase = _isPermissionDenied(error)
|
||||
@@ -162,12 +214,34 @@ class _CapturePageState extends ConsumerState<CapturePage> {
|
||||
}
|
||||
|
||||
/// camera 插件的权限拒绝错误码族(CameraAccessDenied /
|
||||
/// CameraAccessDeniedWithoutD restriction 等)。
|
||||
/// CameraAccessDeniedWithoutRestriction 等)。
|
||||
static bool _isPermissionDenied(Object error) =>
|
||||
error is CameraException &&
|
||||
(error.code.toLowerCase().contains('denied') ||
|
||||
error.code.toLowerCase().contains('permission'));
|
||||
|
||||
/// 前后摄切换:释放当前源 → 反向重建(能力缺失自然落 failed 态重试)。
|
||||
Future<void> _flipCamera() async {
|
||||
if (_phase != _CameraPhase.ready) return;
|
||||
final old = _source;
|
||||
_source = null;
|
||||
await old?.dispose();
|
||||
await _initCamera(preferFront: !_preferFront);
|
||||
}
|
||||
|
||||
/// 闪光灯开关:硬件设置失败则回滚(不留假状态)。
|
||||
Future<void> _toggleFlash() async {
|
||||
final source = _source;
|
||||
if (source == null || !source.supportsFlash) return;
|
||||
final next = !_flashOn;
|
||||
try {
|
||||
await source.setFlashEnabled(next);
|
||||
if (mounted) setState(() => _flashOn = next);
|
||||
} on Object {
|
||||
// 设置失败:保持原状态(下次点击重试)。
|
||||
}
|
||||
}
|
||||
|
||||
/// 快门:立即入队(R17 不逐张确认);连点串行不丢张。
|
||||
void _shoot() {
|
||||
final source = _source;
|
||||
@@ -176,6 +250,8 @@ class _CapturePageState extends ConsumerState<CapturePage> {
|
||||
_shotTail = _shotTail.then((_) async {
|
||||
final photo = await source.capture();
|
||||
if (!mounted) return;
|
||||
// 最近一张缩略图(拍摄原文件;缺失走占位)。
|
||||
setState(() => _lastShotPath = photo.path);
|
||||
final enqueued = await enqueueForUpload(
|
||||
ref,
|
||||
accountId: widget.target.accountId,
|
||||
@@ -200,7 +276,7 @@ class _CapturePageState extends ConsumerState<CapturePage> {
|
||||
});
|
||||
}
|
||||
|
||||
/// 相册多选入队(目标 = 当前相册与分类)。
|
||||
/// 相册多选入队(目标 = 当前相册与分类;入口=底部缩略图位)。
|
||||
Future<void> _pickFromGallery() async {
|
||||
final paths = await ref.read(multiImagePickerProvider)();
|
||||
if (paths.isEmpty || !mounted) return;
|
||||
@@ -272,33 +348,34 @@ class _CapturePageState extends ConsumerState<CapturePage> {
|
||||
_confirmExit();
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black87,
|
||||
foregroundColor: Colors.white,
|
||||
title: Text(l10n.captureTitle),
|
||||
),
|
||||
// 深色摄影界面恒深色(§3):不读主题,固定 #10161E。
|
||||
backgroundColor: kColorPhotoBackdrop,
|
||||
body: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
_buildCameraArea(l10n),
|
||||
// 常驻信息层:相册 + 分类 + 失败徽章(R17/O01)。
|
||||
// 顶部常驻层:返回 + 相册 + 待传/失败徽章 + 分类 chips。
|
||||
SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_TargetOverlay(
|
||||
_TopBar(
|
||||
albumTitle: widget.target.albumTitle,
|
||||
categories: categories,
|
||||
categoryId: _categoryId,
|
||||
onCategorySelected: _switchCategory,
|
||||
queued: pending,
|
||||
failed: failed,
|
||||
onBack: () => Navigator.of(context).maybePop(),
|
||||
),
|
||||
if (categories.isNotEmpty)
|
||||
_CategoryChips(
|
||||
categories: categories,
|
||||
categoryId: _categoryId,
|
||||
onCategorySelected: _switchCategory,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 入队成功短暂反馈浮层(200ms 自动消退,不逐张弹确认)。
|
||||
Positioned(
|
||||
top: 16,
|
||||
top: 132,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: IgnorePointer(
|
||||
@@ -338,9 +415,24 @@ class _CapturePageState extends ConsumerState<CapturePage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// 底部操作层:缩略图(=相册入口)+ 快门(+闪光灯)+ 前后切换。
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: _BottomControls(
|
||||
enabled: _phase == _CameraPhase.ready,
|
||||
lastShotPath: _lastShotPath,
|
||||
flashSupported: _source?.supportsFlash ?? false,
|
||||
flashOn: _flashOn,
|
||||
onShutter: _shoot,
|
||||
onGallery: _pickFromGallery,
|
||||
onFlip: _flipCamera,
|
||||
onFlash: _toggleFlash,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: _buildBottomBar(l10n, pending),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -370,126 +462,113 @@ class _CapturePageState extends ConsumerState<CapturePage> {
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildBottomBar(AppLocalizations l10n, int pending) {
|
||||
return BottomAppBar(
|
||||
color: Colors.black87,
|
||||
// 默认高 56 放不下「快门 + 待传徽章」竖排(内容 ≈105,BottomAppBar
|
||||
// 内部再占 24),显式加高避免溢出。
|
||||
height: 132,
|
||||
// ── 信息层组件 ────────────────────────────────────────────────────────────
|
||||
|
||||
/// 顶部常驻层:返回 + 相册名 + 待传/失败徽章(§4 页面 13)。
|
||||
class _TopBar extends StatelessWidget {
|
||||
const _TopBar({
|
||||
required this.albumTitle,
|
||||
required this.queued,
|
||||
required this.failed,
|
||||
required this.onBack,
|
||||
});
|
||||
|
||||
final String albumTitle;
|
||||
final int queued;
|
||||
final int failed;
|
||||
final VoidCallback onBack;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 4, 12, 0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: l10n.capturePickFromGallery,
|
||||
icon: const Icon(Icons.photo_library_outlined,
|
||||
color: Colors.white),
|
||||
onPressed: _pickFromGallery,
|
||||
tooltip: MaterialLocalizations.of(context)
|
||||
.backButtonTooltip, // 语义标签;深色界面固定白色前景。
|
||||
color: Colors.white,
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: onBack,
|
||||
),
|
||||
// 快门 + 入队计数徽章(§4.5:底部清晰快门与入队计数)。
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_ShutterButton(
|
||||
onPressed: _shoot,
|
||||
enabled: _phase == _CameraPhase.ready,
|
||||
const SizedBox(width: kSpace4),
|
||||
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,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_Badge(
|
||||
label: l10n.captureQueuedBadge(pending),
|
||||
color: Colors.white24,
|
||||
foreground: Colors.white,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (failed > 0) ...[
|
||||
_Badge(
|
||||
label: l10n.captureFailedBadge(failed),
|
||||
color: kColorErrorDark,
|
||||
foreground: Colors.white,
|
||||
),
|
||||
const SizedBox(width: kSpace8),
|
||||
],
|
||||
_Badge(
|
||||
label: l10n.captureQueued(queued),
|
||||
color: Colors.white24,
|
||||
foreground: Colors.white,
|
||||
),
|
||||
// 占位对齐(快门居中)。
|
||||
const SizedBox(width: 48),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 信息层组件 ────────────────────────────────────────────────────────────
|
||||
|
||||
/// 顶部常驻信息层:相册名 + 分类 chips(切换仅影响之后拍摄)+ 失败徽章
|
||||
/// (待传计数在快门旁常驻,见底部栏)。
|
||||
class _TargetOverlay extends StatelessWidget {
|
||||
const _TargetOverlay({
|
||||
required this.albumTitle,
|
||||
/// 分类 chips(切换仅影响之后拍摄;A15 归属不变)。
|
||||
class _CategoryChips extends StatelessWidget {
|
||||
const _CategoryChips({
|
||||
required this.categories,
|
||||
required this.categoryId,
|
||||
required this.onCategorySelected,
|
||||
required this.failed,
|
||||
});
|
||||
|
||||
final String albumTitle;
|
||||
final List<Category> categories;
|
||||
final String? categoryId;
|
||||
final void Function(String?) onCategorySelected;
|
||||
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),
|
||||
if (failed > 0)
|
||||
_Badge(
|
||||
label: l10n.captureFailedBadge(failed),
|
||||
color: Colors.red,
|
||||
foreground: Colors.white,
|
||||
),
|
||||
],
|
||||
return 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,
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
],
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -538,7 +617,123 @@ class _Badge extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 快门按钮(连拍:不进入模态确认,逐张自动入队)。
|
||||
/// 底部操作层:最近一张缩略图(兼相册多选入口)+ 快门(≥72dp 白环,
|
||||
/// 源支持时上方附闪光灯)+ 前后摄切换。
|
||||
class _BottomControls extends StatelessWidget {
|
||||
const _BottomControls({
|
||||
required this.enabled,
|
||||
required this.lastShotPath,
|
||||
required this.flashSupported,
|
||||
required this.flashOn,
|
||||
required this.onShutter,
|
||||
required this.onGallery,
|
||||
required this.onFlip,
|
||||
required this.onFlash,
|
||||
});
|
||||
|
||||
final bool enabled;
|
||||
final String? lastShotPath;
|
||||
final bool flashSupported;
|
||||
final bool flashOn;
|
||||
final VoidCallback onShutter;
|
||||
final VoidCallback onGallery;
|
||||
final VoidCallback onFlip;
|
||||
final VoidCallback onFlash;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return IgnorePointer(
|
||||
// 相机未就绪(加载/权限/失败)时收起操作层——那里是错误态的重试区。
|
||||
ignoring: !enabled,
|
||||
child: SafeArea(
|
||||
minimum: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Align(
|
||||
alignment: AlignmentDirectional.bottomStart,
|
||||
child: _LastShotButton(
|
||||
lastShotPath: lastShotPath,
|
||||
onPressed: onGallery,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (flashSupported)
|
||||
IconButton(
|
||||
tooltip: l10n.captureFlash,
|
||||
color: Colors.white,
|
||||
icon: Icon(flashOn ? Icons.flash_on : Icons.flash_off),
|
||||
onPressed: onFlash,
|
||||
),
|
||||
_ShutterButton(onPressed: onShutter, enabled: enabled),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
Align(
|
||||
alignment: AlignmentDirectional.bottomStart,
|
||||
child: IconButton(
|
||||
tooltip: l10n.captureFlipCamera,
|
||||
color: Colors.white,
|
||||
icon: const Icon(Icons.flip_camera_ios_outlined),
|
||||
onPressed: onFlip,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 最近一张缩略图:拍摄前为相册入口占位(photo_library 图标),拍摄后
|
||||
/// 显示最近一张(缺失走占位);点击均打开相册多选(iOS 相机惯例)。
|
||||
class _LastShotButton extends StatelessWidget {
|
||||
const _LastShotButton({required this.lastShotPath, required this.onPressed});
|
||||
|
||||
final String? lastShotPath;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
Widget content;
|
||||
if (lastShotPath == null) {
|
||||
content = const Icon(
|
||||
Icons.photo_library_outlined,
|
||||
size: 24,
|
||||
color: Colors.white,
|
||||
);
|
||||
} else {
|
||||
content = Image.file(
|
||||
File(lastShotPath!),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => const Icon(
|
||||
Icons.image_outlined,
|
||||
size: 24,
|
||||
color: Colors.white,
|
||||
),
|
||||
);
|
||||
}
|
||||
return IconButton(
|
||||
tooltip: l10n.capturePickFromGallery,
|
||||
onPressed: onPressed,
|
||||
style: IconButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
fixedSize: const Size(56, 56),
|
||||
),
|
||||
icon: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(kRadiusBadge),
|
||||
child: SizedBox(width: 48, height: 48, child: content),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 快门按钮(§4 页面 13:≥72dp 白环;连拍不进入模态确认,逐张自动入队)。
|
||||
class _ShutterButton extends StatelessWidget {
|
||||
const _ShutterButton({required this.onPressed, required this.enabled});
|
||||
|
||||
@@ -551,23 +746,43 @@ class _ShutterButton extends StatelessWidget {
|
||||
button: true,
|
||||
label: AppLocalizations.of(context).captureShutter,
|
||||
child: IconButton(
|
||||
key: const ValueKey('capture-shutter'),
|
||||
onPressed: enabled ? onPressed : null,
|
||||
iconSize: 64,
|
||||
style: IconButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: const Size(76, 76),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
icon: Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
width: 76,
|
||||
height: 76,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 4),
|
||||
border: Border.all(color: Colors.white, width: 5),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Container(
|
||||
width: 58,
|
||||
height: 58,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
// 就绪=白心(图标反色);未就绪=仅白环。
|
||||
color: enabled ? Colors.white : Colors.transparent,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Icon(
|
||||
Icons.camera,
|
||||
size: 28,
|
||||
color: enabled ? kColorPhotoBackdrop : Colors.white70,
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.camera, color: Colors.white),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 相机不可用视图(权限拒绝/初始化失败):说明 + 重试。
|
||||
/// 相机不可用视图(权限拒绝/初始化失败):深色说明 + 重试(§4 页面 13)。
|
||||
class _CameraMessageView extends StatelessWidget {
|
||||
const _CameraMessageView({
|
||||
required this.icon,
|
||||
@@ -584,7 +799,7 @@ class _CameraMessageView extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: Colors.black87,
|
||||
color: kColorPhotoBackdrop,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
/// 上传中心(UI-01 §4.4,P3 计划 Task 9 基础上重构)。
|
||||
/// 上传中心(UI-03 页面 14/15:上传中心 + 异常任务视图)。
|
||||
///
|
||||
/// 顶部摘要卡(筛选范围标注「相册·时间·本设备」+ 四组任务数:已完成/
|
||||
/// 上传中/等待中/需关注——按任务数统计,单任务百分比才走字节进度)→
|
||||
/// 筛选栏(相册下拉 × 时间快捷 × 状态分组 chips:全部/进行中/需关注/
|
||||
/// 已完成)→ 任务行 = 缩略图 + 文件名 + 状态徽章(StateBadge 图标+文案+
|
||||
/// 页头「Uploads」(24sp)+ 右上「History」入口(→ 服务端记录页,与
|
||||
/// 本机队列数据源区分)。摘要卡(范围标注「相册·时间·本设备」+ 四组
|
||||
/// 任务数:已完成(放大突出)/上传中/等待中/需关注——按任务数统计,
|
||||
/// 单任务百分比才走字节进度;**不随状态筛选变口径**,异常视图下摘要
|
||||
/// 仍显示全量计数)。主筛选 = 大号三段(All / Active / Needs attention);
|
||||
/// 次级筛选(相册 × 时间 × 已完成)收进筛选图标菜单(BottomSheet)。
|
||||
/// 任务行 = 缩略图 + 文件名 + 相册 + 状态徽章(StateBadge 图标+文案+
|
||||
/// 颜色三重表达)+ 状态专属文案/操作(§4.4 表格:确认中不显示已公开、
|
||||
/// 准备失败走重新选择而非普通重试、终态走「移除记录」并明确不删云端
|
||||
/// 照片)+ 传输进度(进度总线行级订阅)。存在可重试失败时底部
|
||||
/// 「一键重试」条(仅作用于可重试任务,文案说明范围)。
|
||||
/// 照片)+ 传输进度(进度总线行级订阅,不整页刷新)。存在可重试失败时
|
||||
/// 底部「重试可恢复项」条(仅作用于可重试任务,文案说明范围)。
|
||||
///
|
||||
/// 列表经 TaskStore 写通知 + 200ms 防抖实时刷新(连拍风暴不逐条重建)。
|
||||
library;
|
||||
@@ -18,7 +21,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../features/albums/album_format.dart';
|
||||
import '../features/albums/models.dart';
|
||||
import '../features/albums/providers.dart';
|
||||
import '../features/uploads/engine_scope.dart';
|
||||
import '../features/uploads/enqueue.dart';
|
||||
@@ -46,19 +48,25 @@ class QueueTab extends ConsumerWidget {
|
||||
final retryable = counts[UploadTaskState.retryableFailed] ?? 0;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
// 24sp 主标题(theme titleLarge)+ 右上「History」链接(§4 页面14)。
|
||||
title: Text(l10n.queue),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: l10n.serverHistoryEntry,
|
||||
icon: const Icon(Icons.history_outlined),
|
||||
onPressed: () => Navigator.of(context).pushNamed('/server-history'),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: kSpace8),
|
||||
child: TextButton.icon(
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pushNamed('/server-history'),
|
||||
icon: const Icon(Icons.history_outlined, size: 18),
|
||||
label: Text(l10n.uploadsHistory),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: const Column(
|
||||
children: [
|
||||
_SummaryCard(),
|
||||
_FilterBar(),
|
||||
_PrimaryFilterBar(),
|
||||
_NeedsAttentionNote(),
|
||||
Expanded(child: _TaskListView()),
|
||||
],
|
||||
),
|
||||
@@ -69,7 +77,8 @@ class QueueTab extends ConsumerWidget {
|
||||
|
||||
// ── 摘要卡 ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 顶部摘要:筛选范围标注 + 四组任务数(SQL 聚合,与列表同口径)。
|
||||
/// 顶部摘要:筛选范围标注 + 四组任务数(SQL 聚合,与列表同口径;**不受
|
||||
/// 状态筛选影响**——页面 15 异常视图下仍显示全量计数,配说明行)。
|
||||
class _SummaryCard extends ConsumerWidget {
|
||||
const _SummaryCard();
|
||||
|
||||
@@ -88,8 +97,8 @@ class _SummaryCard extends ConsumerWidget {
|
||||
: albums.where((a) => a.id == filter.albumId).firstOrNull;
|
||||
final scope = [
|
||||
if (album != null) albumDisplayTitle(album, l10n.localeName),
|
||||
_FilterBar._timeLabel(l10n, filter.time),
|
||||
l10n.thisDevice,
|
||||
_queueTimeLabel(l10n, filter.time),
|
||||
l10n.uploadsThisDevice,
|
||||
].join(' · ');
|
||||
|
||||
final light = theme.brightness == Brightness.light;
|
||||
@@ -119,23 +128,26 @@ class _SummaryCard extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: kSpace8),
|
||||
// 已完成突出(§4 页面 14:摘要突出已完成数)。
|
||||
_SummaryCount(
|
||||
value: completedSummaryOf(counts),
|
||||
label: l10n.uploadsCompleted,
|
||||
color: light ? kColorSuccess : kColorSuccessDark,
|
||||
emphasized: true,
|
||||
),
|
||||
const SizedBox(height: kSpace4),
|
||||
Wrap(
|
||||
spacing: kSpace16,
|
||||
runSpacing: kSpace4,
|
||||
children: [
|
||||
_SummaryCount(
|
||||
value: completedSummaryOf(counts),
|
||||
label: l10n.queueSummaryCompleted,
|
||||
color: light ? kColorSuccess : kColorSuccessDark,
|
||||
),
|
||||
_SummaryCount(
|
||||
value: uploadingSummaryOf(counts),
|
||||
label: l10n.queueSummaryUploading,
|
||||
label: l10n.uploadsUploading,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
_SummaryCount(
|
||||
value: waitingSummaryOf(counts),
|
||||
label: l10n.queueSummaryWaiting,
|
||||
label: l10n.uploadsWaiting,
|
||||
color: light ? kColorWaiting : kColorWaitingDark,
|
||||
),
|
||||
_SummaryCount(
|
||||
@@ -152,17 +164,20 @@ class _SummaryCard extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 摘要单项:数字(主文字色、表格数字)+ 标签(语义色)。
|
||||
/// 摘要单项:数字(主文字色、表格数字)+ 标签(语义色);[emphasized]
|
||||
/// 档用于突出的「已完成」(titleLarge)。
|
||||
class _SummaryCount extends StatelessWidget {
|
||||
const _SummaryCount({
|
||||
required this.value,
|
||||
required this.label,
|
||||
required this.color,
|
||||
this.emphasized = false,
|
||||
});
|
||||
|
||||
final int value;
|
||||
final String label;
|
||||
final Color color;
|
||||
final bool emphasized;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -174,9 +189,12 @@ class _SummaryCount extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
'$value',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
style: (emphasized
|
||||
? theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
)
|
||||
: theme.textTheme.titleMedium)
|
||||
?.copyWith(fontFeatures: const [FontFeature.tabularFigures()]),
|
||||
),
|
||||
const SizedBox(width: kSpace4),
|
||||
Text(label, style: theme.textTheme.bodySmall?.copyWith(color: color)),
|
||||
@@ -187,9 +205,86 @@ class _SummaryCount extends StatelessWidget {
|
||||
|
||||
// ── 筛选栏 ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 筛选栏:相册下拉 + 时间快捷 chips + 状态分组 chips(§4.4 四组)。
|
||||
class _FilterBar extends ConsumerWidget {
|
||||
const _FilterBar();
|
||||
/// 时间快捷标签(「今天」与摘要口径共用一词,§4 页面 14 摘要「Today」)。
|
||||
String _queueTimeLabel(AppLocalizations l10n, QueueTimeFilter time) =>
|
||||
switch (time) {
|
||||
QueueTimeFilter.all => l10n.queueTimeAll,
|
||||
QueueTimeFilter.today => l10n.uploadsToday,
|
||||
QueueTimeFilter.last7Days => l10n.queueTimeLast7Days,
|
||||
QueueTimeFilter.last30Days => l10n.queueTimeLast30Days,
|
||||
};
|
||||
|
||||
/// 主筛选(§4 页面 14):大号三段 All / Active / Needs attention;次级
|
||||
/// 筛选(相册 × 时间 × 已完成)收进筛选图标的 BottomSheet。
|
||||
class _PrimaryFilterBar extends ConsumerWidget {
|
||||
const _PrimaryFilterBar();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final filter = ref.watch(queueFilterProvider);
|
||||
|
||||
return Material(
|
||||
color: theme.colorScheme.surfaceContainerLow,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(kSpace16, kSpace8, kSpace8, kSpace8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SegmentedButton<QueueStateFilter>(
|
||||
// 已完成经筛选菜单选择时三段不亮(属次级筛选,非主段)。
|
||||
selected: filter.state == QueueStateFilter.completed
|
||||
? const <QueueStateFilter>{}
|
||||
: <QueueStateFilter>{filter.state},
|
||||
emptySelectionAllowed: true,
|
||||
showSelectedIcon: false,
|
||||
style: SegmentedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(44),
|
||||
padding: const EdgeInsets.symmetric(horizontal: kSpace12),
|
||||
),
|
||||
segments: [
|
||||
ButtonSegment(
|
||||
value: QueueStateFilter.all,
|
||||
label: Text(l10n.uploadsAll),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: QueueStateFilter.inProgress,
|
||||
label: Text(l10n.uploadsActive),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: QueueStateFilter.needsAttention,
|
||||
label: Text(l10n.uploadsNeedsAttention),
|
||||
),
|
||||
],
|
||||
onSelectionChanged: (selection) => ref
|
||||
.read(queueFilterProvider.notifier)
|
||||
.setStateGroup(selection.first),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.uploadsFilters,
|
||||
icon: const Icon(Icons.tune),
|
||||
onPressed: () => _openFilterSheet(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openFilterSheet(BuildContext context) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (_) => const _FilterSheet(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 次级筛选面板(相册 × 时间快捷 × 已完成):单选即选即生效并收起。
|
||||
class _FilterSheet extends ConsumerWidget {
|
||||
const _FilterSheet();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -198,125 +293,131 @@ class _FilterBar extends ConsumerWidget {
|
||||
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(
|
||||
void close() => Navigator.of(context).pop();
|
||||
// 相册组值:'' = 全部相册(选中相册不在列表时回退全部)。
|
||||
final albumValue =
|
||||
albums.any((a) => a.id == filter.albumId) ? (filter.albumId ?? '') : '';
|
||||
return SafeArea(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.only(bottom: kSpace16),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: kSpace16),
|
||||
child: Text(
|
||||
l10n.uploadsFilters,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
// 相册(单选即选即生效并收起)。
|
||||
RadioGroup<String>(
|
||||
groupValue: albumValue,
|
||||
onChanged: (albumId) {
|
||||
ref
|
||||
.read(queueFilterProvider.notifier)
|
||||
.setAlbum(albumId == '' ? null : albumId);
|
||||
close();
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.filter_list,
|
||||
size: 18,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _AlbumDropdown(filter: filter, albums: albums),
|
||||
RadioListTile<String>(
|
||||
value: '',
|
||||
title: Text(l10n.queueAlbumFilterAll),
|
||||
),
|
||||
for (final album in albums)
|
||||
RadioListTile<String>(
|
||||
value: album.id,
|
||||
title: Text(
|
||||
albumDisplayTitle(album, l10n.localeName),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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 Divider(indent: kSpace16, endIndent: kSpace16),
|
||||
// 时间快捷(今天为摘要默认口径,§4 页面 14)。
|
||||
RadioGroup<QueueTimeFilter>(
|
||||
groupValue: filter.time,
|
||||
onChanged: (time) {
|
||||
if (time != null) {
|
||||
ref.read(queueFilterProvider.notifier).setTime(time);
|
||||
}
|
||||
close();
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
for (final time in QueueTimeFilter.values)
|
||||
RadioListTile<QueueTimeFilter>(
|
||||
value: time,
|
||||
title: Text(_queueTimeLabel(l10n, time)),
|
||||
),
|
||||
],
|
||||
),
|
||||
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 Divider(indent: kSpace16, endIndent: kSpace16),
|
||||
// 已完成(主三段之外的状态组,收进筛选菜单——§4 页面 14)。
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
filter.state == QueueStateFilter.completed
|
||||
? Icons.check_circle
|
||||
: Icons.check_circle_outline,
|
||||
color: filter.state == QueueStateFilter.completed
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
),
|
||||
title: Text(l10n.queueStateCompleted),
|
||||
onTap: () {
|
||||
ref.read(queueFilterProvider.notifier).setStateGroup(
|
||||
filter.state == QueueStateFilter.completed
|
||||
? QueueStateFilter.all
|
||||
: QueueStateFilter.completed,
|
||||
);
|
||||
close();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _stateLabel(AppLocalizations l10n, QueueStateFilter state) =>
|
||||
switch (state) {
|
||||
QueueStateFilter.all => l10n.queueStateAll,
|
||||
QueueStateFilter.inProgress => l10n.queueStateInProgress,
|
||||
QueueStateFilter.needsAttention => l10n.queueStateNeedsAttention,
|
||||
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;
|
||||
/// 异常视图说明行(页面 15):Needs attention 主段选中时常驻,说明列表
|
||||
/// 已过滤而摘要仍是全量口径。
|
||||
class _NeedsAttentionNote extends ConsumerWidget {
|
||||
const _NeedsAttentionNote();
|
||||
|
||||
@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,
|
||||
final theme = Theme.of(context);
|
||||
final state = ref.watch(
|
||||
queueFilterProvider.select((filter) => filter.state),
|
||||
);
|
||||
if (state != QueueStateFilter.needsAttention) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(kSpace16, kSpace8, kSpace16, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.filter_alt_outlined,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
for (final album in albums)
|
||||
DropdownMenuItem<String?>(
|
||||
value: album.id,
|
||||
const SizedBox(width: kSpace8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
albumDisplayTitle(album, l10n.localeName),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
l10n.uploadsShowingNeedsAttention,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: (albumId) =>
|
||||
ref.read(queueFilterProvider.notifier).setAlbum(albumId),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -329,6 +430,9 @@ class _TaskListView extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final stateFilter = ref.watch(
|
||||
queueFilterProvider.select((filter) => filter.state),
|
||||
);
|
||||
final tasksAsync = ref.watch(queueTasksProvider);
|
||||
return tasksAsync.when(
|
||||
skipLoadingOnReload: true,
|
||||
@@ -339,9 +443,14 @@ class _TaskListView extends ConsumerWidget {
|
||||
),
|
||||
data: (tasks) {
|
||||
if (tasks.isEmpty) {
|
||||
// 页面 15:异常视图空态专用文案(区别于整体队列空态)。
|
||||
return EmptyState(
|
||||
icon: Icons.cloud_upload_outlined,
|
||||
title: l10n.queueEmpty,
|
||||
icon: stateFilter == QueueStateFilter.needsAttention
|
||||
? Icons.task_alt_outlined
|
||||
: Icons.cloud_upload_outlined,
|
||||
title: stateFilter == QueueStateFilter.needsAttention
|
||||
? l10n.uploadsNoNeedsAttention
|
||||
: l10n.queueEmpty,
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
@@ -354,7 +463,7 @@ class _TaskListView extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 单条任务行(§4.4 状态表逐态落地)。
|
||||
/// 单条任务行(§4 页面 14 状态表逐态落地)。
|
||||
class _TaskTile extends ConsumerWidget {
|
||||
const _TaskTile({required this.task});
|
||||
|
||||
@@ -466,7 +575,8 @@ class _TaskTile extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// 徽章语义变体(§4.4:进行=主色、等待=琥珀、失败=红、完成=绿、取消=灰)。
|
||||
/// 徽章语义变体(§4 页面 14:进行=主色、等待=琥珀、失败=红、完成=绿、
|
||||
/// 取消=灰)。
|
||||
static BadgeVariant _badgeVariant(UploadTaskState state) =>
|
||||
switch (state) {
|
||||
UploadTaskState.preparing ||
|
||||
@@ -484,12 +594,12 @@ class _TaskTile extends ConsumerWidget {
|
||||
static String _badgeLabel(AppLocalizations l10n, UploadTaskState state) =>
|
||||
switch (state) {
|
||||
UploadTaskState.preparing => l10n.queueRowPreparing,
|
||||
UploadTaskState.preparationFailed => l10n.queueRowFileUnavailable,
|
||||
UploadTaskState.queued => l10n.queueRowWaitingUpload,
|
||||
UploadTaskState.preparationFailed => l10n.uploadsFileUnavailable,
|
||||
UploadTaskState.queued => l10n.uploadsWaitingUpload,
|
||||
UploadTaskState.uploading => l10n.taskStateUploading,
|
||||
UploadTaskState.uploadedConfirming => l10n.queueRowConfirming,
|
||||
UploadTaskState.uploadedConfirming => l10n.uploadsConfirming,
|
||||
UploadTaskState.completed => l10n.taskStateCompleted,
|
||||
UploadTaskState.waitingNetwork => l10n.taskStateWaitingNetwork,
|
||||
UploadTaskState.waitingNetwork => l10n.uploadsWaitingNetwork,
|
||||
UploadTaskState.retryableFailed => l10n.taskStateRetryableFailed,
|
||||
UploadTaskState.cancelled => l10n.taskStateCancelled,
|
||||
};
|
||||
@@ -592,7 +702,7 @@ class _TaskActions extends ConsumerWidget {
|
||||
case UploadTaskState.preparationFailed:
|
||||
return _RowAction(
|
||||
icon: Icons.image_search_outlined,
|
||||
label: l10n.queueActionSelectAgain,
|
||||
label: l10n.uploadsSelectAgain,
|
||||
onPressed: account == null
|
||||
? null
|
||||
: () => _retryPreparation(context, ref, account),
|
||||
@@ -628,7 +738,7 @@ class _TaskActions extends ConsumerWidget {
|
||||
case UploadTaskState.cancelled:
|
||||
return _RowAction(
|
||||
icon: Icons.delete_outline,
|
||||
label: l10n.queueActionRemoveRecord,
|
||||
label: l10n.uploadsRemoveRecord,
|
||||
foreground: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
onPressed: account == null
|
||||
? null
|
||||
@@ -678,7 +788,7 @@ class _TaskActions extends ConsumerWidget {
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: Text(l10n.queueRemoveRecordConfirm),
|
||||
child: Text(l10n.uploadsRemoveRecordConfirm),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -731,7 +841,8 @@ class _RowAction extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 底部批量重试条(存在可重试失败任务时出现;仅作用于 retryableFailed)。
|
||||
/// 底部批量重试条(存在可重试失败任务时出现;仅作用于 retryableFailed,
|
||||
/// 按钮文案「重试可恢复项」明确范围——§4 页面 15)。
|
||||
class _BatchRetryBar extends ConsumerWidget {
|
||||
const _BatchRetryBar();
|
||||
|
||||
@@ -770,7 +881,7 @@ class _BatchRetryBar extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
l10n.queueRetryAllScope,
|
||||
l10n.uploadsRetryEligibleScope,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
@@ -784,7 +895,7 @@ class _BatchRetryBar extends ConsumerWidget {
|
||||
foregroundColor: theme.colorScheme.onError,
|
||||
),
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
label: Text(l10n.queueRetryAllFailed),
|
||||
label: Text(l10n.uploadsRetryEligible),
|
||||
onPressed: () => _retryAll(ref),
|
||||
),
|
||||
],
|
||||
@@ -801,9 +912,9 @@ class _BatchRetryBar extends ConsumerWidget {
|
||||
if (account == null) return;
|
||||
final engine = ref.read(uploadEngineProvider);
|
||||
final tasks = await ref.read(taskStoreProvider).listByAccount(
|
||||
account,
|
||||
states: const {UploadTaskState.retryableFailed},
|
||||
);
|
||||
account,
|
||||
states: const {UploadTaskState.retryableFailed},
|
||||
);
|
||||
for (final task in tasks) {
|
||||
await engine.retryNow(account, task.id);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
/// 服务端上传历史页(UI-01 §4.4 主题统一,P3 计划 Task 6 / A19 语义)。
|
||||
/// 服务端上传历史页(UI-03 页面 16「Server records」)。
|
||||
///
|
||||
/// GET /uploads(operationId listUploads)游标分页展示**当前账号跨设备**
|
||||
/// 的上传会话记录:会话状态 / photo.processingState / sourceDeviceId /
|
||||
/// 创建时间。页面标题即「服务端历史(跨设备)」,顶部说明条明确「本机
|
||||
/// 待传队列在上传页」——本机队列是 drift 任务库(TaskStore),两者数据源
|
||||
/// 与生命周期不同,不合并统计(A19 标注要求)。
|
||||
/// 的上传会话记录:标题 +「全部设备 · 当前账号」副标题明确**跨设备服务端
|
||||
/// 口径**;顶部说明条强调本机待传队列在「上传」页——本机队列是 drift
|
||||
/// 任务库(TaskStore),两者数据源与生命周期不同,不合并统计(A19)。
|
||||
/// 相册/日期为客户端过滤(已加载记录内),分页按服务端原序继续;下拉
|
||||
/// 刷新 + 加载更多失败可重试。
|
||||
///
|
||||
/// 记录不含 filename(契约 UploadSessionRecord 无该字段),行首展示
|
||||
/// clientTaskId 前 8 位作识别;sourceDeviceId 与本机 [DeviceId] 一致时
|
||||
/// 标记「本设备」。
|
||||
/// 记录不含 filename 与设备名(契约 UploadSessionRecord 无该字段),行首
|
||||
/// 展示 clientTaskId 前 8 位作识别;设备来源做**安全回退**——sourceDeviceId
|
||||
/// 与本机 [DeviceId] 一致标「本设备」,否则显示通用占位(不展示裸设备
|
||||
/// ID,不编造设备名)。
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
@@ -21,15 +23,18 @@ import '../core/app_exception.dart';
|
||||
import '../core/device_id.dart';
|
||||
import '../core/error_codes.dart';
|
||||
import '../core/error_l10n.dart';
|
||||
import '../features/albums/album_format.dart';
|
||||
import '../features/albums/providers.dart';
|
||||
import '../features/photos/models.dart';
|
||||
import '../features/uploads/models.dart';
|
||||
import '../features/uploads/queue_controller.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../ui/components/empty_state.dart';
|
||||
import '../ui/components/error_state.dart';
|
||||
import '../ui/components/state_badge.dart';
|
||||
import '../ui/tokens.dart';
|
||||
|
||||
/// 服务端上传历史(从队列页占位入口进入)。
|
||||
/// 服务端上传历史(从上传中心「History」入口进入;无根底部导航)。
|
||||
class ServerHistoryPage extends ConsumerStatefulWidget {
|
||||
const ServerHistoryPage({super.key});
|
||||
|
||||
@@ -45,8 +50,16 @@ class _ServerHistoryPageState extends ConsumerState<ServerHistoryPage> {
|
||||
bool _hasMore = false;
|
||||
bool _loading = true;
|
||||
bool _loadingMore = false;
|
||||
|
||||
/// 加载更多失败(页脚重试行出现;重试成功后清除)。
|
||||
bool _loadMoreFailed = false;
|
||||
String? _loadMoreErrorCode;
|
||||
String? _errorCode;
|
||||
|
||||
/// 客户端次级筛选(相册 × 时间;作用于已加载记录)。
|
||||
String? _albumId;
|
||||
QueueTimeFilter _time = QueueTimeFilter.all;
|
||||
|
||||
/// 本机设备标识(「本设备」标记比对,A19)。
|
||||
String? _ownDeviceId;
|
||||
|
||||
@@ -65,6 +78,7 @@ class _ServerHistoryPageState extends ConsumerState<ServerHistoryPage> {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_errorCode = null;
|
||||
_loadMoreFailed = false;
|
||||
});
|
||||
try {
|
||||
final page = await ref
|
||||
@@ -95,9 +109,37 @@ class _ServerHistoryPageState extends ConsumerState<ServerHistoryPage> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 下拉刷新:保留当前列表(不切换全局加载态);失败以 snackbar 提示。
|
||||
Future<void> _refresh() async {
|
||||
try {
|
||||
final page = await ref
|
||||
.read(apiClientProvider)
|
||||
.listUploads(limit: _pageSize);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_items = page.items;
|
||||
_nextCursor = page.nextCursor;
|
||||
_hasMore = page.hasMore;
|
||||
_loadMoreFailed = false;
|
||||
});
|
||||
}
|
||||
} on Object {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context).errorGeneric),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMore() async {
|
||||
if (_loading || _loadingMore || !_hasMore) return;
|
||||
setState(() => _loadingMore = true);
|
||||
setState(() {
|
||||
_loadingMore = true;
|
||||
_loadMoreFailed = false;
|
||||
});
|
||||
try {
|
||||
final page = await ref.read(apiClientProvider).listUploads(
|
||||
cursor: _nextCursor,
|
||||
@@ -111,36 +153,63 @@ class _ServerHistoryPageState extends ConsumerState<ServerHistoryPage> {
|
||||
_loadingMore = false;
|
||||
});
|
||||
}
|
||||
} on AppException {
|
||||
} on AppException catch (e) {
|
||||
// 页脚重试行(§4 页面 16:加载失败可重试),不弹全局错误。
|
||||
if (mounted) {
|
||||
setState(() => _loadingMore = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
AppLocalizations.of(context)
|
||||
.messageForErrorCode(ErrorCodes.networkError),
|
||||
),
|
||||
),
|
||||
);
|
||||
setState(() {
|
||||
_loadingMore = false;
|
||||
_loadMoreFailed = true;
|
||||
_loadMoreErrorCode = e.code;
|
||||
});
|
||||
}
|
||||
} on Object {
|
||||
if (mounted) {
|
||||
setState(() => _loadingMore = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context).errorGeneric),
|
||||
),
|
||||
);
|
||||
setState(() {
|
||||
_loadingMore = false;
|
||||
_loadMoreFailed = true;
|
||||
_loadMoreErrorCode = ErrorCodes.internalError;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 已加载记录按相册 × 时间过滤(分页按服务端原序继续,不因过滤中断)。
|
||||
List<UploadSessionRecord> get _visibleItems {
|
||||
final cutoff = _time.since(DateTime.now());
|
||||
return _items.where((record) {
|
||||
if (_albumId != null && record.albumId != _albumId) return false;
|
||||
if (cutoff != null && record.createdAt.toLocal().isBefore(cutoff)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final albums = ref.watch(albumListProvider).items;
|
||||
final selectedAlbum = _albumId == null
|
||||
? null
|
||||
: albums.where((a) => a.id == _albumId).firstOrNull;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(l10n.serverHistoryTitle)),
|
||||
appBar: AppBar(
|
||||
// 标题 + 跨设备副标题(§4 页面 16:Server records · All devices)。
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(l10n.serverRecords),
|
||||
Text(
|
||||
l10n.serverSubtitle,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
@@ -169,105 +238,217 @@ class _ServerHistoryPageState extends ConsumerState<ServerHistoryPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _errorCode != null
|
||||
? ErrorState(
|
||||
message: l10n.messageForErrorCode(_errorCode!),
|
||||
onRetry: _reload,
|
||||
)
|
||||
: _items.isEmpty
|
||||
? EmptyState(
|
||||
icon: Icons.cloud_done_outlined,
|
||||
title: l10n.serverHistoryEmpty,
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: _items.length +
|
||||
(_hasMore || _loadingMore ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index >= _items.length) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
height: 24,
|
||||
width: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (index >= _items.length - 3 &&
|
||||
_hasMore &&
|
||||
!_loadingMore) {
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => _loadMore(),
|
||||
);
|
||||
}
|
||||
return _sessionTile(l10n, theme, _items[index]);
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
kSpace16, kSpace8, kSpace16, kSpace4),
|
||||
child: Row(
|
||||
children: [
|
||||
PopupMenuButton<String>(
|
||||
initialValue: _albumId ?? '',
|
||||
onSelected: (albumId) => setState(() {
|
||||
_albumId = albumId == '' ? null : albumId;
|
||||
}),
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem<String>(
|
||||
value: '',
|
||||
child: Text(l10n.queueAlbumFilterAll),
|
||||
),
|
||||
for (final album in albums)
|
||||
PopupMenuItem<String>(
|
||||
value: album.id,
|
||||
child: Text(
|
||||
albumDisplayTitle(album, l10n.localeName),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
child: _FilterPill(
|
||||
icon: Icons.photo_album_outlined,
|
||||
label: selectedAlbum == null
|
||||
? l10n.queueAlbumFilterAll
|
||||
: albumDisplayTitle(selectedAlbum, l10n.localeName),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: kSpace8),
|
||||
PopupMenuButton<QueueTimeFilter>(
|
||||
initialValue: _time,
|
||||
onSelected: (time) => setState(() => _time = time),
|
||||
itemBuilder: (context) => [
|
||||
for (final time in QueueTimeFilter.values)
|
||||
PopupMenuItem<QueueTimeFilter>(
|
||||
value: time,
|
||||
child: Text(switch (time) {
|
||||
QueueTimeFilter.all => l10n.queueTimeAll,
|
||||
QueueTimeFilter.today => l10n.uploadsToday,
|
||||
QueueTimeFilter.last7Days => l10n.queueTimeLast7Days,
|
||||
QueueTimeFilter.last30Days =>
|
||||
l10n.queueTimeLast30Days,
|
||||
}),
|
||||
),
|
||||
],
|
||||
child: _FilterPill(
|
||||
icon: Icons.schedule_outlined,
|
||||
label: switch (_time) {
|
||||
QueueTimeFilter.all => l10n.queueTimeAll,
|
||||
QueueTimeFilter.today => l10n.uploadsToday,
|
||||
QueueTimeFilter.last7Days => l10n.queueTimeLast7Days,
|
||||
QueueTimeFilter.last30Days => l10n.queueTimeLast30Days,
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(child: _buildBody(l10n)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sessionTile(
|
||||
AppLocalizations l10n,
|
||||
ThemeData theme,
|
||||
UploadSessionRecord record,
|
||||
) {
|
||||
Widget _buildBody(AppLocalizations l10n) {
|
||||
if (_loading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (_errorCode != null) {
|
||||
return ErrorState(
|
||||
message: l10n.messageForErrorCode(_errorCode!),
|
||||
onRetry: _reload,
|
||||
);
|
||||
}
|
||||
final visible = _visibleItems;
|
||||
if (visible.isEmpty) {
|
||||
return EmptyState(
|
||||
icon: Icons.cloud_done_outlined,
|
||||
title: l10n.serverHistoryEmpty,
|
||||
);
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: _refresh,
|
||||
child: ListView.builder(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
itemCount: visible.length + (_hasMore || _loadingMore ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index >= visible.length) {
|
||||
if (_loadMoreFailed) {
|
||||
return _LoadMoreFailedRow(
|
||||
message: l10n.messageForErrorCode(
|
||||
_loadMoreErrorCode ?? ErrorCodes.networkError,
|
||||
),
|
||||
onRetry: _loadMore,
|
||||
);
|
||||
}
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
height: 24,
|
||||
width: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
// 预取触发;加载更多失败后停住——页脚重试行接管(不自动循环
|
||||
// 重试,§4 页面 16)。
|
||||
if (index >= visible.length - 3 &&
|
||||
_hasMore &&
|
||||
!_loadingMore &&
|
||||
!_loadMoreFailed) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _loadMore());
|
||||
}
|
||||
return _sessionTile(context, visible[index]);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sessionTile(BuildContext context, UploadSessionRecord record) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
// createdAt 以本机时区展示(记录跨相册,无单一相册时区可依)。
|
||||
final local = record.createdAt.toLocal();
|
||||
final time = '${local.year}/${local.month}/${local.day} '
|
||||
'${local.hour.toString().padLeft(2, '0')}:'
|
||||
'${local.minute.toString().padLeft(2, '0')}';
|
||||
final deviceShort = record.sourceDeviceId?.substring(0, 8);
|
||||
// 设备来源安全回退:本机 → 「本设备」;其余 → 通用占位(契约无设备
|
||||
// 名,不展示裸 ID、不编造名称)。
|
||||
final ownDevice =
|
||||
_ownDeviceId != null && record.sourceDeviceId == _ownDeviceId;
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.cloud_done_outlined),
|
||||
title: Text(
|
||||
'${record.clientTaskId.substring(0, 8)} · '
|
||||
'${(record.expectedBytes / 1024).round()} KB',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
deviceShort == null
|
||||
? time
|
||||
: '$time · $deviceShort${ownDevice ? '(${l10n.thisDevice})' : ''}',
|
||||
),
|
||||
trailing: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
final deviceLabel = ownDevice ? l10n.thisDevice : l10n.serverDeviceUnknown;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(kSpace16, kSpace8, kSpace12, kSpace8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
StateBadge(
|
||||
compact: true,
|
||||
variant: switch (record.state) {
|
||||
UploadSessionState.confirmed => BadgeVariant.success,
|
||||
UploadSessionState.pending => BadgeVariant.info,
|
||||
UploadSessionState.cancelled ||
|
||||
UploadSessionState.expired => BadgeVariant.neutral,
|
||||
},
|
||||
label: _sessionStateLabel(l10n, record.state),
|
||||
// 契约无图片 URL——中性占位(不伪造缩略图)。
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(kRadiusBadge),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.cloud_done_outlined,
|
||||
size: 24,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
StateBadge(
|
||||
compact: true,
|
||||
variant: switch (record.photo.processingState) {
|
||||
PhotoProcessingState.ready => BadgeVariant.success,
|
||||
PhotoProcessingState.processing ||
|
||||
PhotoProcessingState.uploaded => BadgeVariant.info,
|
||||
PhotoProcessingState.pendingUpload => BadgeVariant.warning,
|
||||
PhotoProcessingState.processingFailed => BadgeVariant.error,
|
||||
},
|
||||
label: _processingLabel(l10n, record.photo.processingState),
|
||||
const SizedBox(width: kSpace12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${record.clientTaskId.substring(0, 8)} · '
|
||||
'${(record.expectedBytes / 1024).round()} KB',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'$time · $deviceLabel',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
StateBadge(
|
||||
compact: true,
|
||||
variant: switch (record.state) {
|
||||
UploadSessionState.confirmed => BadgeVariant.success,
|
||||
UploadSessionState.pending => BadgeVariant.info,
|
||||
UploadSessionState.cancelled ||
|
||||
UploadSessionState.expired => BadgeVariant.neutral,
|
||||
},
|
||||
label: _sessionStateLabel(l10n, record.state),
|
||||
),
|
||||
StateBadge(
|
||||
compact: true,
|
||||
variant: switch (record.photo.processingState) {
|
||||
PhotoProcessingState.ready => BadgeVariant.success,
|
||||
PhotoProcessingState.processing ||
|
||||
PhotoProcessingState.uploaded => BadgeVariant.info,
|
||||
PhotoProcessingState.pendingUpload => BadgeVariant.warning,
|
||||
PhotoProcessingState.processingFailed => BadgeVariant.error,
|
||||
},
|
||||
label: _processingLabel(l10n, record.photo.processingState),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -282,7 +463,7 @@ class _ServerHistoryPageState extends ConsumerState<ServerHistoryPage> {
|
||||
case UploadSessionState.pending:
|
||||
return l10n.uploadStatePending;
|
||||
case UploadSessionState.confirmed:
|
||||
return l10n.uploadStateConfirmed;
|
||||
return l10n.serverConfirmed;
|
||||
case UploadSessionState.cancelled:
|
||||
return l10n.uploadStateCancelled;
|
||||
case UploadSessionState.expired:
|
||||
@@ -300,11 +481,93 @@ class _ServerHistoryPageState extends ConsumerState<ServerHistoryPage> {
|
||||
case PhotoProcessingState.uploaded:
|
||||
return l10n.procUploaded;
|
||||
case PhotoProcessingState.processing:
|
||||
return l10n.procProcessing;
|
||||
return l10n.serverProcessing;
|
||||
case PhotoProcessingState.ready:
|
||||
return l10n.procReady;
|
||||
return l10n.serverReady;
|
||||
case PhotoProcessingState.processingFailed:
|
||||
return l10n.procProcessingFailed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 筛选药丸(相册/日期入口):边框胶囊 + 展开箭头(§4 页面 16)。
|
||||
///
|
||||
/// 作为 [PopupMenuButton.child] 使用——点击弹下拉菜单选择筛选值。
|
||||
class _FilterPill extends StatelessWidget {
|
||||
const _FilterPill({required this.icon, required this.label});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 16, color: theme.colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: 6),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 140),
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_drop_down,
|
||||
size: 18,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载更多失败页脚:错误说明 + 重试(§4 页面 16)。
|
||||
class _LoadMoreFailedRow extends StatelessWidget {
|
||||
const _LoadMoreFailedRow({required this.message, required this.onRetry});
|
||||
|
||||
final String message;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: kSpace16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.cloud_off, size: 16, color: theme.colorScheme.error),
|
||||
const SizedBox(width: kSpace8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh, size: 16),
|
||||
label: Text(l10n.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,12 @@ const Color kColorInfoDark = Color(0xFF8AB4F8);
|
||||
/// 次文字(深色表面用)。
|
||||
const Color kColorTextSecondaryDark = Color(0xFFA6B0C0);
|
||||
|
||||
/// 摄影深色背景(§3:相机取景/大图浏览 #10161E)。
|
||||
///
|
||||
/// 深色摄影界面**恒深色**——不随系统主题切换(spec §3),文字/图标直接
|
||||
/// 用固定白/白70,不读主题色。
|
||||
const Color kColorPhotoBackdrop = Color(0xFF10161E);
|
||||
|
||||
// ── 间距(4/8/12/16/24/32)───────────────────────────────────────────────
|
||||
|
||||
const double kSpace4 = 4.0;
|
||||
|
||||
@@ -346,8 +346,10 @@ void main() {
|
||||
notificationPermissionProvider.overrideWith(
|
||||
(ref) => NotificationPermission(isAndroid: false),
|
||||
),
|
||||
captureSourceFactoryProvider
|
||||
.overrideWith((ref) => () async => _StaticCaptureSource()),
|
||||
captureSourceFactoryProvider.overrideWith(
|
||||
(ref) => ({bool preferFront = false}) async =>
|
||||
_StaticCaptureSource(),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
@@ -412,6 +414,12 @@ class _StaticCaptureSource implements CaptureSource {
|
||||
@override
|
||||
Widget buildPreview() => const SizedBox();
|
||||
|
||||
@override
|
||||
bool get supportsFlash => false;
|
||||
|
||||
@override
|
||||
Future<void> setFlashEnabled(bool enabled) async {}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/// 连拍页 widget 测试(P3 计划 Task 9,R17 连拍入队 / O01 现场反馈)。
|
||||
/// 连拍页 widget 测试(UI-03 页面 13 深色摄影界面;R17 连拍入队 / O01)。
|
||||
///
|
||||
/// 打桩面:FakeCaptureSource(capture 返回真实 JPEG 临时文件,无相机
|
||||
/// 平台通道)+ 内存 TaskStore + 临时目录准备器 + 非 Android 权限门替身
|
||||
/// (静默跳过通道)。覆盖:快门即拍即入队、连拍串行、分类切换仅影响
|
||||
/// 之后拍摄、多选补充入队、待传/失败徽章实时刷新、有待传任务退出确认、
|
||||
/// 权限拒绝/初始化失败视图与重试。
|
||||
/// 平台通道;可注入闪光灯能力/记录翻转释放)+ 内存 TaskStore + 临时目录
|
||||
/// 准备器 + 非 Android 权限门替身。覆盖:快门即拍即入队、连拍串行、分类
|
||||
/// 切换仅影响之后拍摄、多选补充入队、待传/失败徽章实时刷新、有待传任务
|
||||
/// 退出确认、权限拒绝/初始化失败视图与重试、深色界面(#10161E)、快门
|
||||
/// ≥72dp、闪光灯按能力显示、前后摄切换重建、最近一张缩略图。
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
@@ -26,6 +27,7 @@ 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 'package:photo_live/ui/tokens.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
|
||||
@@ -33,8 +35,9 @@ const List<int> kJpgBytes = <int>[
|
||||
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00,
|
||||
];
|
||||
|
||||
/// 拍摄源替身:capture 依次弹出预置文件;initialize 可注入失败次数。
|
||||
class FakeCaptureSource implements CaptureSource {
|
||||
/// 拍摄源替身:capture 依次弹出预置文件;initialize 可注入失败次数;
|
||||
/// 闪光灯能力可注入(记录开关调用);释放计数供前后切换断言。
|
||||
class FakeCaptureSource extends CaptureSource {
|
||||
FakeCaptureSource(this._files);
|
||||
|
||||
final List<File> _files;
|
||||
@@ -42,6 +45,13 @@ class FakeCaptureSource implements CaptureSource {
|
||||
int failNextInits = 0;
|
||||
Object? initError;
|
||||
|
||||
/// 闪光灯能力(默认不支持——UI 不显示开关)。
|
||||
bool flashSupported = false;
|
||||
final List<bool> flashCalls = <bool>[];
|
||||
|
||||
/// dispose 计数(前后摄切换释放断言)。
|
||||
int disposeCalls = 0;
|
||||
|
||||
@override
|
||||
Future<void> initialize() async {
|
||||
initializeCalls++;
|
||||
@@ -61,7 +71,14 @@ class FakeCaptureSource implements CaptureSource {
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
bool get supportsFlash => flashSupported;
|
||||
|
||||
@override
|
||||
Future<void> setFlashEnabled(bool enabled) async =>
|
||||
flashCalls.add(enabled);
|
||||
|
||||
@override
|
||||
Future<void> dispose() async => disposeCalls++;
|
||||
}
|
||||
|
||||
void main() {
|
||||
@@ -71,6 +88,9 @@ void main() {
|
||||
late FakeCaptureSource source;
|
||||
late List<String> galleryPaths;
|
||||
|
||||
/// 工厂调用记录(前后摄切换断言)。
|
||||
late List<bool> factoryPreferFront;
|
||||
|
||||
final categories = <Category>[
|
||||
const Category(
|
||||
id: 'cat-1',
|
||||
@@ -106,11 +126,18 @@ void main() {
|
||||
final shot3 = File('${tmp.path}/IMG_0003.jpg')..writeAsBytesSync(kJpgBytes);
|
||||
source = FakeCaptureSource([shot1, shot2, shot3]);
|
||||
galleryPaths = <String>[];
|
||||
factoryPreferFront = <bool>[];
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await store.close();
|
||||
await tmp.delete(recursive: true);
|
||||
try {
|
||||
await tmp.delete(recursive: true);
|
||||
} on Object {
|
||||
// Windows:底部缩略图 FileImage 的异步读取可能仍持有临时文件句柄
|
||||
//(fake-async 区内真实 IO 完成回调不被冲刷)——删除失败不判定
|
||||
// 业务断言失败,残留目录交由系统临时目录清理。
|
||||
}
|
||||
});
|
||||
|
||||
CaptureTarget target() => CaptureTarget(
|
||||
@@ -134,8 +161,12 @@ void main() {
|
||||
(_) => throw StateError('连拍页不应发起 API 请求')),
|
||||
),
|
||||
),
|
||||
captureSourceFactoryProvider
|
||||
.overrideWith((ref) => () async => source),
|
||||
captureSourceFactoryProvider.overrideWith(
|
||||
(ref) => ({bool preferFront = false}) async {
|
||||
factoryPreferFront.add(preferFront);
|
||||
return source;
|
||||
},
|
||||
),
|
||||
uploadPreparerProvider.overrideWith(
|
||||
(ref) => UploadPreparer(
|
||||
store,
|
||||
@@ -188,10 +219,33 @@ void main() {
|
||||
}
|
||||
}
|
||||
|
||||
testWidgets('快门即拍即入队:任务落库 queued + 待传徽章 1', (tester) async {
|
||||
testWidgets('深色摄影界面:页面背景 #10161E(不随主题)', (tester) async {
|
||||
await pumpCapture(tester);
|
||||
expect(
|
||||
tester.widget<Scaffold>(find.byType(Scaffold)).backgroundColor,
|
||||
kColorPhotoBackdrop,
|
||||
reason: '§3 相机界面固定深色背景',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('快门 ≥72dp(§4 页面 13)+ 相机控制齐备', (tester) async {
|
||||
await pumpCapture(tester);
|
||||
|
||||
final shutterSize = tester.getSize(find.byKey(const ValueKey('capture-shutter')));
|
||||
expect(shutterSize.width, greaterThanOrEqualTo(72));
|
||||
expect(shutterSize.height, greaterThanOrEqualTo(72));
|
||||
|
||||
// 前后摄切换 + 相册入口(缩略图占位)常驻;闪光灯未注入能力不出现。
|
||||
expect(find.byIcon(Icons.flip_camera_ios_outlined), findsOneWidget);
|
||||
expect(find.byIcon(Icons.photo_library_outlined), findsOneWidget);
|
||||
expect(find.byIcon(Icons.flash_off), findsNothing,
|
||||
reason: '源不支持闪光灯时不显示开关');
|
||||
});
|
||||
|
||||
testWidgets('快门即拍即入队:任务落库 queued + 顶栏待传徽章 1', (tester) async {
|
||||
await pumpCapture(tester);
|
||||
expect(find.text('PREVIEW_MARK'), findsOneWidget, reason: '相机预览就绪');
|
||||
expect(find.text('Queued 0'), findsOneWidget);
|
||||
expect(find.text('0 queued'), findsOneWidget);
|
||||
|
||||
await shoot(tester);
|
||||
|
||||
@@ -202,7 +256,21 @@ void main() {
|
||||
expect(tasks.single.albumId, 'alb-1');
|
||||
expect(tasks.single.categoryId, isNull, reason: '默认分类');
|
||||
expect(tasks.single.filename, 'IMG_0001.jpg');
|
||||
expect(find.text('Queued 1'), findsOneWidget, reason: '徽章实时刷新');
|
||||
expect(find.text('1 queued'), findsOneWidget, reason: '徽章实时刷新');
|
||||
});
|
||||
|
||||
testWidgets('最近一张缩略图:拍摄前为相册占位,拍摄后显示照片',
|
||||
(tester) async {
|
||||
await pumpCapture(tester);
|
||||
expect(find.byType(Image), findsNothing, reason: '拍摄前无缩略图');
|
||||
|
||||
await shoot(tester);
|
||||
|
||||
expect(
|
||||
find.byType(Image),
|
||||
findsOneWidget,
|
||||
reason: '拍摄后底部显示最近一张(缩略图位)',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('入队成功反馈:短暂浮层出现(不逐张弹确认,§4.5)',
|
||||
@@ -247,7 +315,36 @@ void main() {
|
||||
'IMG_0001.jpg',
|
||||
'IMG_0002.jpg',
|
||||
]));
|
||||
expect(find.text('Queued 2'), findsOneWidget);
|
||||
expect(find.text('2 queued'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('闪光灯:源支持才显示;点击开→关记录调用', (tester) async {
|
||||
source.flashSupported = true;
|
||||
await pumpCapture(tester);
|
||||
|
||||
expect(find.byIcon(Icons.flash_off), findsOneWidget, reason: '默认关');
|
||||
await tester.tap(find.byIcon(Icons.flash_off));
|
||||
await tester.pump();
|
||||
|
||||
expect(source.flashCalls, [true]);
|
||||
expect(find.byIcon(Icons.flash_on), findsOneWidget, reason: '开启后图标翻转');
|
||||
});
|
||||
|
||||
testWidgets('前后摄切换:释放当前源 → 反向重建(工厂收 preferFront)',
|
||||
(tester) async {
|
||||
await pumpCapture(tester);
|
||||
expect(factoryPreferFront, [false]);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.flip_camera_ios_outlined));
|
||||
await tester.pump();
|
||||
var guard = 0;
|
||||
while (find.text('PREVIEW_MARK').evaluate().isEmpty && guard++ < 20) {
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
}
|
||||
|
||||
expect(source.disposeCalls, 1, reason: '旧源已释放(相机生命周期)');
|
||||
expect(factoryPreferFront, [false, true], reason: '反向重建');
|
||||
expect(find.text('PREVIEW_MARK'), findsOneWidget, reason: '预览恢复');
|
||||
});
|
||||
|
||||
testWidgets('分类切换:hint 提示 + 仅影响之后拍摄(A15 归属不变)',
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
/// 上传中心 widget 测试(UI-01 §4.4,P3 计划 Task 9 基础上重构)。
|
||||
/// 上传中心 widget 测试(UI-03 页面 14/15)。
|
||||
///
|
||||
/// 打桩面:RecordingEngine(记录 retryNow/cancel,不出队)+ 内存 TaskStore
|
||||
/// 种子 + FakeAdapter 相册列表 + 临时目录准备器(准备失败重试换源)。
|
||||
/// 覆盖:顶部摘要卡(范围标注 + 四组任务数)、四组状态筛选 chips、
|
||||
/// 任务行渲染(状态徽章/状态专属文案/错误文案/相册名)、按状态动作
|
||||
/// (重试/重新选择/取消/移除记录 + 确认弹窗)、批量重试条口径(仅
|
||||
/// 可重试失败)、写通知防抖实时刷新、行级进度条(分数 + 百分比文案)。
|
||||
/// 覆盖:页头(Uploads 标题 + History 入口)、摘要卡(范围标注 + 四组
|
||||
/// 任务数,突出已完成)、主段筛选(All/Active/Needs attention)、次级
|
||||
/// 筛选菜单(相册/时间/已完成)、任务行渲染(状态徽章/状态专属文案/
|
||||
/// 错误文案/相册名)、按状态动作(重试/重新选择/取消/移除记录 + 确认
|
||||
/// 弹窗)、异常视图(仅失败行 + 说明行 + 摘要全量口径 + 专用空态)、
|
||||
/// 批量重试条口径(仅可重试失败)、写通知防抖实时刷新、行级进度条
|
||||
/// (分数 + 百分比文案 + 未订阅行不重建)。
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
@@ -148,10 +151,15 @@ void main() {
|
||||
singleImagePickerProvider
|
||||
.overrideWith((ref) => () async => pickedPaths.removeAt(0)),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: QueueTab(),
|
||||
// History 入口跳转断言用占位路由。
|
||||
routes: <String, WidgetBuilder>{
|
||||
'/server-history': (context) =>
|
||||
const Scaffold(body: Center(child: Text('SERVER_HISTORY_MARK'))),
|
||||
},
|
||||
home: const QueueTab(),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -231,6 +239,12 @@ void main() {
|
||||
await tester.pump(const Duration(milliseconds: 120));
|
||||
}
|
||||
|
||||
/// 打开次级筛选菜单(相册 × 时间 × 已完成)。
|
||||
Future<void> openFilterSheet(WidgetTester tester) async {
|
||||
await tester.tap(find.byIcon(Icons.tune));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// 触发一个会走真实文件 IO 的按钮:在 runAsync(真实事件循环)内直接
|
||||
/// 调用其回调——widget 测试的 fake-async 区里 dart:io 完成回调不会被
|
||||
/// pump 冲刷,入队链路必须整体跑在真实区;返回后 pump 刷新徽章防抖。
|
||||
@@ -252,7 +266,20 @@ void main() {
|
||||
await tester.pump(const Duration(milliseconds: 60));
|
||||
}
|
||||
|
||||
testWidgets('摘要卡:范围标注(时间·本设备)+ 四组任务数(按任务数口径)',
|
||||
testWidgets('页头:Uploads 标题 + History 入口跳服务端历史页',
|
||||
(tester) async {
|
||||
await pumpQueue(tester);
|
||||
expect(find.text('Uploads'), findsOneWidget);
|
||||
expect(find.text('History'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('History'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('SERVER_HISTORY_MARK'), findsOneWidget,
|
||||
reason: 'History 入口 push /server-history(与本机队列数据源区分)');
|
||||
});
|
||||
|
||||
testWidgets('摘要卡:范围标注(时间·本设备)+ 四组任务数(突出已完成)',
|
||||
(tester) async {
|
||||
await seed('done1.jpg', state: UploadTaskState.completed);
|
||||
await seed('done2.jpg', state: UploadTaskState.completed);
|
||||
@@ -269,7 +296,7 @@ void main() {
|
||||
// 范围标注:未选相册 → 时间范围 · 本设备(跨设备记录在服务端历史页)。
|
||||
expect(find.text('Any time · This device'), findsOneWidget);
|
||||
|
||||
// 四组计数:摘要标签小写、chip 大写——find.text 区分大小写不冲突。
|
||||
// 四组计数:摘要标签小写、主段大写——find.text 区分大小写不冲突。
|
||||
void expectSummary(String label, String value) {
|
||||
final row = find.ancestor(
|
||||
of: find.text(label),
|
||||
@@ -297,7 +324,7 @@ void main() {
|
||||
albumId: 'alb-2', state: UploadTaskState.uploading);
|
||||
await pumpQueue(tester);
|
||||
|
||||
// 状态徽章(en locale;断言锚定在行内——chip 栏文案与部分徽章同名)。
|
||||
// 状态徽章(en locale;断言锚定在行内——主段文案与部分徽章同名)。
|
||||
for (final (filename, badge) in [
|
||||
('prep.jpg', 'Preparing photo'),
|
||||
('ok.jpg', 'Completed'),
|
||||
@@ -384,28 +411,56 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('状态筛选:Needs attention chip → 仅失败行', (tester) async {
|
||||
testWidgets('主段筛选 Needs attention → 仅失败行 + 说明行 + 摘要仍全量(页面 15)',
|
||||
(tester) async {
|
||||
await seed('bad.jpg', state: UploadTaskState.preparationFailed);
|
||||
await seed('good.jpg', state: UploadTaskState.completed);
|
||||
await pumpQueue(tester);
|
||||
|
||||
// 状态 chip 锚定 ChoiceChip(行内徽章可能与 chip 同文案)。
|
||||
await tester.tap(find.widgetWithText(ChoiceChip, 'Needs attention'));
|
||||
await tester.tap(find.text('Needs attention'));
|
||||
await settleDebounce(tester);
|
||||
|
||||
// 页面 15:异常视图只列失败任务,附「正在显示需关注任务」说明行。
|
||||
expect(find.text('bad.jpg'), findsOneWidget);
|
||||
expect(find.text('good.jpg'), findsNothing);
|
||||
expect(
|
||||
find.text('Showing tasks that need your attention'),
|
||||
findsOneWidget,
|
||||
);
|
||||
// 摘要不随状态筛选变口径:completed 1 / need attention 1 仍可见。
|
||||
expect(find.text('Any time · This device'), findsOneWidget);
|
||||
void expectSummary(String label, String value) {
|
||||
final row = find.ancestor(
|
||||
of: find.text(label),
|
||||
matching: find.byType(Row),
|
||||
);
|
||||
expect(find.descendant(of: row, matching: find.text(value)),
|
||||
findsOneWidget);
|
||||
}
|
||||
expectSummary('completed', '1');
|
||||
expectSummary('need attention', '1');
|
||||
});
|
||||
|
||||
testWidgets('状态筛选:In progress chip → 活任务(含等待网络)',
|
||||
testWidgets('异常视图空态:无失败任务 → 专用空文案(页面 15)',
|
||||
(tester) async {
|
||||
await seed('good.jpg', state: UploadTaskState.completed);
|
||||
await pumpQueue(tester);
|
||||
|
||||
await tester.tap(find.text('Needs attention'));
|
||||
await settleDebounce(tester);
|
||||
|
||||
expect(find.text('No tasks need attention'), findsOneWidget);
|
||||
expect(find.text('good.jpg'), findsNothing, reason: '非失败任务不显示');
|
||||
});
|
||||
|
||||
testWidgets('主段筛选 Active → 活任务(含等待网络)', (tester) async {
|
||||
await seed('live.jpg');
|
||||
await seed('net.jpg', state: UploadTaskState.waitingNetwork);
|
||||
await seed('done.jpg', state: UploadTaskState.completed);
|
||||
await seed('bad.jpg', state: UploadTaskState.preparationFailed);
|
||||
await pumpQueue(tester);
|
||||
|
||||
await tester.tap(find.widgetWithText(ChoiceChip, 'In progress'));
|
||||
await tester.tap(find.text('Active'));
|
||||
await settleDebounce(tester);
|
||||
|
||||
// 排队 + 等待网络属「进行中」(自动恢复范畴);终态与需关注排除。
|
||||
@@ -415,13 +470,17 @@ void main() {
|
||||
expect(find.text('bad.jpg'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('状态筛选:Completed chip → 仅已完成行', (tester) async {
|
||||
testWidgets('次级筛选菜单:Completed → 仅已完成行(主三段不亮)',
|
||||
(tester) async {
|
||||
await seed('done.jpg', state: UploadTaskState.completed);
|
||||
await seed('gone.jpg', state: UploadTaskState.cancelled);
|
||||
await seed('q.jpg');
|
||||
await pumpQueue(tester);
|
||||
|
||||
await tester.tap(find.widgetWithText(ChoiceChip, 'Completed'));
|
||||
await openFilterSheet(tester);
|
||||
// 行内徽章同名「Completed」——锚定筛选面板的 ListTile。
|
||||
await tester.tap(find.widgetWithText(ListTile, 'Completed'));
|
||||
await tester.pumpAndSettle();
|
||||
await settleDebounce(tester);
|
||||
|
||||
// §4.4:已完成组不含 cancelled(取消仅在「全部」可见)。
|
||||
@@ -430,7 +489,7 @@ void main() {
|
||||
expect(find.text('q.jpg'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('时间筛选:Today → 昨日任务排除', (tester) async {
|
||||
testWidgets('次级筛选菜单:Today → 昨日任务排除', (tester) async {
|
||||
final now = DateTime.now();
|
||||
await seed('fresh.jpg', createdAt: now);
|
||||
await seed(
|
||||
@@ -440,22 +499,26 @@ void main() {
|
||||
await pumpQueue(tester);
|
||||
expect(find.text('old.jpg'), findsOneWidget, reason: '默认全部时间');
|
||||
|
||||
await tester.tap(find.text('Today'));
|
||||
await openFilterSheet(tester);
|
||||
await tester.tap(find.widgetWithText(RadioListTile<QueueTimeFilter>, 'Today'));
|
||||
await tester.pumpAndSettle();
|
||||
await settleDebounce(tester);
|
||||
|
||||
expect(find.text('fresh.jpg'), findsOneWidget);
|
||||
expect(find.text('old.jpg'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('相册筛选:下拉选中 alb-2 → 仅该相册行 + 摘要范围带相册名',
|
||||
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 openFilterSheet(tester);
|
||||
// 任务行副标题同名相册名——锚定筛选面板的单选行。
|
||||
await tester.tap(
|
||||
find.widgetWithText(RadioListTile<String>, 'Wedding Day'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Wedding Day').last);
|
||||
await settleDebounce(tester);
|
||||
|
||||
expect(find.text('two.jpg'), findsOneWidget);
|
||||
@@ -626,13 +689,12 @@ void main() {
|
||||
await pumpQueue(tester);
|
||||
|
||||
// 批量条出现:标题计数与摘要「需关注」同口径(准备失败 + 上传失败),
|
||||
// 范围文案说明仅重试可自动重试的子集。
|
||||
expect(find.text('Retry all failed'), findsOneWidget);
|
||||
// 按钮文案「重试可恢复项」+ 范围说明(页面 15)。
|
||||
expect(find.text('Retry eligible'), findsOneWidget);
|
||||
expect(find.text('Needs attention · 2'), findsOneWidget);
|
||||
expect(find.textContaining('Only failed uploads on this device'),
|
||||
findsOneWidget);
|
||||
expect(find.textContaining('recover on their own'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Retry all failed'));
|
||||
await tester.tap(find.text('Retry eligible'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// §4.4:一键重试仅作用于 retryableFailed——等待网络自动恢复、准备
|
||||
@@ -654,7 +716,7 @@ void main() {
|
||||
await seed('q.jpg');
|
||||
await pumpQueue(tester);
|
||||
|
||||
expect(find.text('Retry all failed'), findsNothing);
|
||||
expect(find.text('Retry eligible'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('实时刷新:库内新增任务 → 防抖后列表出现(写通知驱动)',
|
||||
@@ -670,37 +732,45 @@ void main() {
|
||||
expect(find.text('second.jpg'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('行级进度:uploading 行订阅进度总线,分数 + 百分比文案更新',
|
||||
testWidgets('行级进度:进度事件只刷新订阅行,未订阅行不整行重建',
|
||||
(tester) async {
|
||||
final task = await seed('up.jpg', state: UploadTaskState.uploading);
|
||||
final task1 = await seed('up1.jpg', state: UploadTaskState.uploading);
|
||||
await seed('up2.jpg', state: UploadTaskState.uploading);
|
||||
await pumpQueue(tester);
|
||||
|
||||
final context = tester.element(find.byType(QueueTab));
|
||||
final hub = ProviderScope.containerOf(context)
|
||||
.read(uploadProgressHubProvider);
|
||||
|
||||
LinearProgressIndicator indicatorOf() => find
|
||||
LinearProgressIndicator indicatorOf(String filename) => find
|
||||
.descendant(
|
||||
of: rowOf('up.jpg'),
|
||||
of: rowOf(filename),
|
||||
matching: find.byType(LinearProgressIndicator),
|
||||
)
|
||||
.evaluate()
|
||||
.single
|
||||
.widget as LinearProgressIndicator;
|
||||
|
||||
expect(indicatorOf().value, isNull, reason: '无事件时不确定态进度条');
|
||||
expect(indicatorOf('up1.jpg').value, isNull, reason: '无事件时不确定态');
|
||||
|
||||
hub.emit(task.id, 50, 100);
|
||||
// 快照另一行的 widget 实例——行级订阅下它不应随 up1 的进度重建。
|
||||
final otherBefore = indicatorOf('up2.jpg');
|
||||
|
||||
hub.emit(task1.id, 50, 100);
|
||||
await tester.pump();
|
||||
|
||||
expect(indicatorOf().value, 0.5, reason: '进度事件行级刷新为 50%');
|
||||
// §4.4:上传中行展示「上传中 {percent}%」文案。
|
||||
expect(indicatorOf('up1.jpg').value, 0.5, reason: '进度事件行级刷新为 50%');
|
||||
expect(
|
||||
find.descendant(
|
||||
of: rowOf('up.jpg'),
|
||||
of: rowOf('up1.jpg'),
|
||||
matching: find.text('Uploading 50%'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
identical(indicatorOf('up2.jpg'), otherBefore),
|
||||
isTrue,
|
||||
reason: '未收到进度事件的行不重建(行级进度,不整页刷新)',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/// 服务端上传历史页 widget 测试(P3 计划 Task 6,A19 语义)。
|
||||
/// 服务端上传历史页 widget 测试(UI-03 页面 16;A19 语义)。
|
||||
///
|
||||
/// 覆盖:A19 数据源区分横幅、行内容(clientTaskId 短码/KB/状态标签/
|
||||
/// 设备短码 + 「本设备」标记)、游标加载更多、错误重试。
|
||||
/// 覆盖:标题/副标题(跨设备口径)与 A19 数据源区分横幅、行内容
|
||||
/// (clientTaskId 短码/KB/状态徽章/时间 · 设备来源安全回退)、相册与
|
||||
/// 日期客户端筛选、下拉刷新、游标加载更多、加载更多失败页脚重试、
|
||||
/// 首屏错误重试。
|
||||
library;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
@@ -66,6 +68,19 @@ void main() {
|
||||
..session = Session(token: 'tok-1', account: 'alice');
|
||||
});
|
||||
|
||||
/// 页面 watch albumListProvider(相册筛选候选)——适配器统一供给
|
||||
/// GET /v1/albums;/v1/uploads 路由由各用例注入。
|
||||
FakeAdapter adapterOf(
|
||||
(int, Object?) Function(RequestOptions options) uploads, {
|
||||
List<Map<String, dynamic>> albums = const <Map<String, dynamic>>[],
|
||||
}) =>
|
||||
FakeAdapter((o) {
|
||||
if (o.method == 'GET' && o.path == '/v1/albums') {
|
||||
return (200, cursorPageBody(albums));
|
||||
}
|
||||
return uploads(o);
|
||||
});
|
||||
|
||||
Future<void> pumpPage(WidgetTester tester, ApiClient client) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
@@ -91,21 +106,34 @@ void main() {
|
||||
..httpClientAdapter = adapter,
|
||||
);
|
||||
|
||||
testWidgets('A19 横幅 + 行内容(短码/KB/状态标签/设备短码/本设备标记)',
|
||||
/// 仅 /v1/uploads 请求(剥离相册列表请求)。
|
||||
Iterable<RecordedRequest> uploadsRequests(FakeAdapter adapter) =>
|
||||
adapter.requests.where((r) => r.path == '/v1/uploads');
|
||||
|
||||
testWidgets('标题/副标题(跨设备口径)+ A19 横幅 + 行内容与设备来源回退',
|
||||
(tester) async {
|
||||
final adapter = FakeAdapter((o) {
|
||||
final adapter = adapterOf((o) {
|
||||
if (o.method == 'GET' && o.path == '/v1/uploads') {
|
||||
return (
|
||||
200,
|
||||
cursorPageBody([
|
||||
sessionJson(clientTaskId: 'owndevice0000000000000000000001'),
|
||||
sessionJson(
|
||||
clientTaskId: 'otherdevice0000000000000000000002',
|
||||
uploadId: 'upl-0002',
|
||||
clientTaskId: 'otherdev000000000000000000000002',
|
||||
state: 'pending',
|
||||
processingState: 'processing',
|
||||
expectedBytes: 5 * 1024 * 1024,
|
||||
sourceDeviceId: _kOtherDevice,
|
||||
),
|
||||
sessionJson(
|
||||
uploadId: 'upl-0003',
|
||||
clientTaskId: 'nodevice0000000000000000000000003',
|
||||
state: 'expired',
|
||||
processingState: 'processing_failed',
|
||||
expectedBytes: 4096,
|
||||
sourceDeviceId: null,
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
@@ -114,29 +142,183 @@ void main() {
|
||||
|
||||
await pumpPage(tester, clientOf(adapter));
|
||||
|
||||
// A19 数据源区分横幅(UI-01:本机队列入口文案指向「上传」页)。
|
||||
expect(find.textContaining('cross-device'), findsOneWidget);
|
||||
// 页面 16:标题 + 跨设备副标题(与本地队列口径区分)。
|
||||
expect(find.text('Server records'), findsOneWidget);
|
||||
expect(find.text('All devices · Your account'), findsOneWidget);
|
||||
|
||||
// A19 数据源区分横幅(本机待传队列入口指向「上传」页)。
|
||||
expect(find.textContaining('all of your devices'), findsOneWidget);
|
||||
expect(find.textContaining('Uploads tab'), findsOneWidget);
|
||||
|
||||
// 行:clientTaskId 前 8 位 + KB(2048B → 2 KB;5MB → 5120 KB)。
|
||||
expect(find.textContaining('owndevic'), findsOneWidget);
|
||||
expect(find.textContaining('owndevic ·'), findsOneWidget);
|
||||
expect(find.textContaining('2 KB'), findsOneWidget);
|
||||
expect(find.textContaining('otherdev'), findsOneWidget);
|
||||
expect(find.textContaining('otherdev ·'), findsOneWidget);
|
||||
expect(find.textContaining('5120 KB'), findsOneWidget);
|
||||
expect(find.textContaining('nodevice · 4 KB'), findsOneWidget);
|
||||
|
||||
// 状态标签(会话状态 + 处理状态)。
|
||||
// 状态徽章(会话状态 + 处理状态;页面 16 专用文案)。
|
||||
expect(find.text('Confirmed'), findsOneWidget);
|
||||
expect(find.text('Pending'), findsOneWidget);
|
||||
expect(find.text('Ready'), findsOneWidget);
|
||||
expect(find.text('Processing'), findsOneWidget);
|
||||
expect(find.text('Expired'), findsOneWidget);
|
||||
expect(find.text('Processing failed'), findsOneWidget);
|
||||
|
||||
// 设备短码(本机设备aaaaaaaa + 「本设备」;他机无标记)。
|
||||
expect(find.textContaining(_kOwnDevice.substring(0, 8)), findsOneWidget);
|
||||
expect(find.textContaining(_kOtherDevice.substring(0, 8)), findsOneWidget);
|
||||
expect(find.textContaining('(This device)'), findsOneWidget);
|
||||
// 设备来源安全回退:本机 → This device;他机/缺失 → Unknown device
|
||||
//(不展示裸设备 ID)。
|
||||
expect(find.textContaining('This device'), findsOneWidget);
|
||||
expect(find.textContaining('Unknown device'), findsNWidgets(2));
|
||||
expect(
|
||||
find.textContaining(_kOtherDevice.substring(0, 8)),
|
||||
findsNothing,
|
||||
reason: '契约无设备名——不展示裸设备 ID',
|
||||
);
|
||||
|
||||
// listUploads 请求带分页 limit=20。
|
||||
expect(adapter.requests.single.uri.queryParameters['limit'], '20');
|
||||
expect(
|
||||
uploadsRequests(adapter).single.uri.queryParameters['limit'],
|
||||
'20',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('相册筛选(候选来自相册列表;客户端过滤已加载记录)',
|
||||
(tester) async {
|
||||
final adapter = adapterOf(
|
||||
(o) {
|
||||
if (o.method == 'GET' && o.path == '/v1/uploads') {
|
||||
return (
|
||||
200,
|
||||
cursorPageBody([
|
||||
sessionJson(
|
||||
albumId: 'alb-1',
|
||||
clientTaskId: 'galarow00000000000000000000000001',
|
||||
),
|
||||
sessionJson(
|
||||
albumId: 'alb-2',
|
||||
clientTaskId: 'wedrow000000000000000000000000002',
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
throw StateError('unexpected ${o.method} ${o.path}');
|
||||
},
|
||||
albums: [
|
||||
albumJson(),
|
||||
albumJson(
|
||||
id: 'alb-2',
|
||||
publicToken: 'sharetoken000000000002',
|
||||
translations: [
|
||||
<String, dynamic>{
|
||||
'locale': 'zh-CN',
|
||||
'title': '婚礼当天',
|
||||
'description': null,
|
||||
'venue': null,
|
||||
'address': null,
|
||||
},
|
||||
<String, dynamic>{
|
||||
'locale': 'en',
|
||||
'title': 'Wedding Day',
|
||||
'description': null,
|
||||
'venue': null,
|
||||
'address': null,
|
||||
},
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await pumpPage(tester, clientOf(adapter));
|
||||
expect(find.textContaining('galarow'), findsOneWidget);
|
||||
expect(find.textContaining('wedrow'), findsOneWidget);
|
||||
|
||||
// 打开相册筛选菜单(默认 All albums)→ 选择 Wedding Day。
|
||||
await tester.tap(find.text('All albums'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Wedding Day'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 仅保留 alb-2 记录;筛选入口标签随选择更新。
|
||||
expect(find.textContaining('wedrow'), findsOneWidget);
|
||||
expect(find.textContaining('galarow'), findsNothing);
|
||||
expect(find.text('Wedding Day'), findsOneWidget, reason: '药丸标签');
|
||||
});
|
||||
|
||||
testWidgets('日期筛选(Today:仅保留今天创建的记录)', (tester) async {
|
||||
final now = DateTime.now().toUtc().toIso8601String();
|
||||
final adapter = adapterOf((o) {
|
||||
if (o.method == 'GET' && o.path == '/v1/uploads') {
|
||||
return (
|
||||
200,
|
||||
cursorPageBody([
|
||||
sessionJson(
|
||||
clientTaskId: 'todayrow0000000000000000000000001',
|
||||
createdAt: now,
|
||||
),
|
||||
sessionJson(
|
||||
uploadId: 'upl-0002',
|
||||
clientTaskId: 'oldrow00000000000000000000000002',
|
||||
createdAt: '2026-09-01T00:00:00Z',
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
throw StateError('unexpected ${o.method} ${o.path}');
|
||||
});
|
||||
|
||||
await pumpPage(tester, clientOf(adapter));
|
||||
expect(find.textContaining('todayrow'), findsOneWidget);
|
||||
expect(find.textContaining('oldrow'), findsOneWidget);
|
||||
|
||||
// 打开日期筛选菜单(默认 Any time)→ 选择 Today。
|
||||
await tester.tap(find.text('Any time'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Today'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('todayrow'), findsOneWidget);
|
||||
expect(find.textContaining('oldrow'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('下拉刷新重取第一页', (tester) async {
|
||||
var first = true;
|
||||
final adapter = adapterOf((o) {
|
||||
if (o.method == 'GET' && o.path == '/v1/uploads') {
|
||||
return first
|
||||
? (
|
||||
200,
|
||||
cursorPageBody([
|
||||
sessionJson(
|
||||
clientTaskId: 'firstrow000000000000000000000001',
|
||||
),
|
||||
]),
|
||||
)
|
||||
: (
|
||||
200,
|
||||
cursorPageBody([
|
||||
sessionJson(
|
||||
clientTaskId: 'refreshr000000000000000000000002',
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
throw StateError('unexpected ${o.method} ${o.path}');
|
||||
});
|
||||
|
||||
await pumpPage(tester, clientOf(adapter));
|
||||
expect(find.textContaining('firstrow'), findsOneWidget);
|
||||
|
||||
first = false;
|
||||
await tester.fling(
|
||||
find.byType(ListView),
|
||||
const Offset(0, 300),
|
||||
600,
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('refreshr'), findsOneWidget);
|
||||
expect(find.textContaining('firstrow'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('游标加载更多(滚动触底 → 第二页带 cursor)', (tester) async {
|
||||
@@ -147,7 +329,7 @@ void main() {
|
||||
clientTaskId: 'page1row$i.padLeft-000000000000000000001',
|
||||
),
|
||||
);
|
||||
final adapter = FakeAdapter((o) {
|
||||
final adapter = adapterOf((o) {
|
||||
final cursor = o.uri.queryParameters['cursor'];
|
||||
if (cursor == null) {
|
||||
return (200, cursorPageBody(page1, nextCursor: 'hc1'));
|
||||
@@ -164,25 +346,77 @@ void main() {
|
||||
});
|
||||
|
||||
await pumpPage(tester, clientOf(adapter));
|
||||
expect(adapter.requests, hasLength(1), reason: '首页无 cursor');
|
||||
expect(uploadsRequests(adapter), hasLength(1), reason: '首页无 cursor');
|
||||
|
||||
await tester.drag(find.byType(ListView), const Offset(0, -6000));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(adapter.requests.last.uri.queryParameters['cursor'], 'hc1');
|
||||
expect(
|
||||
uploadsRequests(adapter).last.uri.queryParameters['cursor'],
|
||||
'hc1',
|
||||
);
|
||||
expect(find.textContaining('page2ite'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('加载失败展示错误与重试;恢复后重试成功', (tester) async {
|
||||
var failFirst = true;
|
||||
final adapter = FakeAdapter((o) {
|
||||
if (failFirst) {
|
||||
testWidgets('加载更多失败 → 页脚重试行;重试恢复追加第二页', (tester) async {
|
||||
final page1 = List<Map<String, dynamic>>.generate(
|
||||
20,
|
||||
(i) => sessionJson(
|
||||
uploadId: 'upl-p1-$i',
|
||||
clientTaskId: 'page1row$i.padLeft-000000000000000000001',
|
||||
),
|
||||
);
|
||||
var failMore = true;
|
||||
final adapter = adapterOf((o) {
|
||||
final cursor = o.uri.queryParameters['cursor'];
|
||||
if (cursor == null) {
|
||||
return (200, cursorPageBody(page1, nextCursor: 'hc1'));
|
||||
}
|
||||
if (failMore) {
|
||||
failMore = false;
|
||||
throw DioException(
|
||||
requestOptions: RequestOptions(path: o.path),
|
||||
type: DioExceptionType.connectionError,
|
||||
);
|
||||
}
|
||||
return (200, cursorPageBody([sessionJson()]));
|
||||
return (
|
||||
200,
|
||||
cursorPageBody(
|
||||
[sessionJson(clientTaskId: 'page2item000000000000000000000001')],
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
await pumpPage(tester, clientOf(adapter));
|
||||
|
||||
await tester.drag(find.byType(ListView), const Offset(0, -6000));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 首屏成功不整体报错:仅页脚错误说明 + 重试(页面 16)。
|
||||
expect(find.textContaining('Network error'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.cloud_off), findsOneWidget);
|
||||
expect(find.text('Retry'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Retry'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('Network error'), findsNothing);
|
||||
expect(find.textContaining('page2ite'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('首屏加载失败展示错误与重试;恢复后重试成功', (tester) async {
|
||||
var failFirst = true;
|
||||
final adapter = adapterOf((o) {
|
||||
if (o.method == 'GET' && o.path == '/v1/uploads') {
|
||||
if (failFirst) {
|
||||
throw DioException(
|
||||
requestOptions: RequestOptions(path: o.path),
|
||||
type: DioExceptionType.connectionError,
|
||||
);
|
||||
}
|
||||
return (200, cursorPageBody([sessionJson()]));
|
||||
}
|
||||
throw StateError('unexpected ${o.method} ${o.path}');
|
||||
});
|
||||
|
||||
await pumpPage(tester, clientOf(adapter));
|
||||
|
||||
Reference in New Issue
Block a user