From ab51616c708e4fe292fd1234a15d2530bbea0775 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Tue, 22 Sep 2026 02:06:23 +0800 Subject: [PATCH] feat(mobile): UI-03 capture and upload pages (13-16) Co-Authored-By: Claude Code --- apps/mobile/lib/l10n/app_en.arb | 31 ++ apps/mobile/lib/l10n/app_localizations.dart | 180 +++++++ .../mobile/lib/l10n/app_localizations_en.dart | 94 ++++ .../mobile/lib/l10n/app_localizations_zh.dart | 92 ++++ apps/mobile/lib/l10n/app_zh.arb | 31 ++ apps/mobile/lib/pages/capture_page.dart | 487 ++++++++++++----- apps/mobile/lib/pages/queue_tab.dart | 405 +++++++++------ .../mobile/lib/pages/server_history_page.dart | 491 ++++++++++++++---- apps/mobile/lib/ui/tokens.dart | 6 + .../test/pages/album_detail_page_test.dart | 12 +- apps/mobile/test/pages/capture_page_test.dart | 127 ++++- apps/mobile/test/pages/queue_tab_test.dart | 144 +++-- .../test/pages/server_history_page_test.dart | 282 +++++++++- 13 files changed, 1907 insertions(+), 475 deletions(-) diff --git a/apps/mobile/lib/l10n/app_en.arb b/apps/mobile/lib/l10n/app_en.arb index a122397..75ca9d1 100644 --- a/apps/mobile/lib/l10n/app_en.arb +++ b/apps/mobile/lib/l10n/app_en.arb @@ -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", diff --git a/apps/mobile/lib/l10n/app_localizations.dart b/apps/mobile/lib/l10n/app_localizations.dart index adfbc1a..83c7066 100644 --- a/apps/mobile/lib/l10n/app_localizations.dart +++ b/apps/mobile/lib/l10n/app_localizations.dart @@ -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: diff --git a/apps/mobile/lib/l10n/app_localizations_en.dart b/apps/mobile/lib/l10n/app_localizations_en.dart index 335e8ee..39cbe32 100644 --- a/apps/mobile/lib/l10n/app_localizations_en.dart +++ b/apps/mobile/lib/l10n/app_localizations_en.dart @@ -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'; diff --git a/apps/mobile/lib/l10n/app_localizations_zh.dart b/apps/mobile/lib/l10n/app_localizations_zh.dart index 2a3a59a..1e38c3e 100644 --- a/apps/mobile/lib/l10n/app_localizations_zh.dart +++ b/apps/mobile/lib/l10n/app_localizations_zh.dart @@ -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 => '准备中'; diff --git a/apps/mobile/lib/l10n/app_zh.arb b/apps/mobile/lib/l10n/app_zh.arb index f2d1c2c..3e53e49 100644 --- a/apps/mobile/lib/l10n/app_zh.arb +++ b/apps/mobile/lib/l10n/app_zh.arb @@ -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": "待上传", diff --git a/apps/mobile/lib/pages/capture_page.dart b/apps/mobile/lib/pages/capture_page.dart index bd21cb8..68aa292 100644 --- a/apps/mobile/lib/pages/capture_page.dart +++ b/apps/mobile/lib/pages/capture_page.dart @@ -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 capture(); - /// 取景预览。 + /// 取景预览(含纵横比校正——全屏 cover 铺满,不拉伸)。 Widget buildPreview(); /// 释放底层资源。 Future dispose(); + + /// 闪光灯能力(默认不支持——子类按硬件能力覆写;不支持则 UI 不显示 + /// 闪光开关,不提供假开关)。 + bool get supportsFlash => false; + + /// 设置闪光灯(仅 [supportsFlash] 为真时有意义;失败抛异常由调用方回滚)。 + Future setFlashEnabled(bool enabled) async {} } /// 生产拍摄源:camera 插件(后摄优先,最高分辨率,静音连拍)。 -class CameraPluginSource implements CaptureSource { +class CameraPluginSource extends CaptureSource { CameraPluginSource._(this._controller); - /// 创建并初始化(后摄优先)。 - static Future create() async { + /// 创建并初始化([preferFront] 为真选前摄,否则后摄优先)。 + static Future 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 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 setFlashEnabled(bool enabled) => _controller.setFlashMode( + enabled ? FlashMode.always : FlashMode.off, + ); @override Future dispose() => _controller.dispose(); @@ -77,7 +112,8 @@ class CameraPluginSource implements CaptureSource { /// 拍摄源工厂 Provider(测试 override 为替身)。 final captureSourceFactoryProvider = - Provider Function()>((ref) => CameraPluginSource.create); + Provider Function({bool preferFront})>( + (ref) => CameraPluginSource.create); // ── 连拍页 ──────────────────────────────────────────────────────────────── @@ -119,6 +155,15 @@ class _CapturePageState extends ConsumerState { 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 { super.dispose(); } - Future _initCamera() async { + Future _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 { } /// 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 _flipCamera() async { + if (_phase != _CameraPhase.ready) return; + final old = _source; + _source = null; + await old?.dispose(); + await _initCamera(preferFront: !_preferFront); + } + + /// 闪光灯开关:硬件设置失败则回滚(不留假状态)。 + Future _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 { _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 { }); } - /// 相册多选入队(目标 = 当前相册与分类)。 + /// 相册多选入队(目标 = 当前相册与分类;入口=底部缩略图位)。 Future _pickFromGallery() async { final paths = await ref.read(multiImagePickerProvider)(); if (paths.isEmpty || !mounted) return; @@ -272,33 +348,34 @@ class _CapturePageState extends ConsumerState { _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 { ), ), ), + // 底部操作层:缩略图(=相册入口)+ 快门(+闪光灯)+ 前后切换。 + 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 { ); } } +} - 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 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, diff --git a/apps/mobile/lib/pages/queue_tab.dart b/apps/mobile/lib/pages/queue_tab.dart index 272591c..3bb6741 100644 --- a/apps/mobile/lib/pages/queue_tab.dart +++ b/apps/mobile/lib/pages/queue_tab.dart @@ -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( + // 已完成经筛选菜单选择时三段不亮(属次级筛选,非主段)。 + selected: filter.state == QueueStateFilter.completed + ? const {} + : {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 _openFilterSheet(BuildContext context) { + return showModalBottomSheet( + 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( + 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( + value: '', + title: Text(l10n.queueAlbumFilterAll), ), + for (final album in albums) + RadioListTile( + 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( + 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( + 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 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( - value: value, - isExpanded: true, - underline: const SizedBox.shrink(), - hint: Text(l10n.queueAlbumFilterAll), - items: [ - DropdownMenuItem( - value: null, - child: Text( - l10n.queueAlbumFilterAll, - style: Theme.of(context).textTheme.bodyMedium, + 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( - 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); } diff --git a/apps/mobile/lib/pages/server_history_page.dart b/apps/mobile/lib/pages/server_history_page.dart index ff9685f..69de5e1 100644 --- a/apps/mobile/lib/pages/server_history_page.dart +++ b/apps/mobile/lib/pages/server_history_page.dart @@ -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 { 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 { setState(() { _loading = true; _errorCode = null; + _loadMoreFailed = false; }); try { final page = await ref @@ -95,9 +109,37 @@ class _ServerHistoryPageState extends ConsumerState { } } + /// 下拉刷新:保留当前列表(不切换全局加载态);失败以 snackbar 提示。 + Future _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 _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 { _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 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 { ), ), ), - 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( + initialValue: _albumId ?? '', + onSelected: (albumId) => setState(() { + _albumId = albumId == '' ? null : albumId; + }), + itemBuilder: (context) => [ + PopupMenuItem( + value: '', + child: Text(l10n.queueAlbumFilterAll), + ), + for (final album in albums) + PopupMenuItem( + 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( + initialValue: _time, + onSelected: (time) => setState(() => _time = time), + itemBuilder: (context) => [ + for (final time in QueueTimeFilter.values) + PopupMenuItem( + 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 { 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 { 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), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/lib/ui/tokens.dart b/apps/mobile/lib/ui/tokens.dart index 501ebc5..718eb7b 100644 --- a/apps/mobile/lib/ui/tokens.dart +++ b/apps/mobile/lib/ui/tokens.dart @@ -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; diff --git a/apps/mobile/test/pages/album_detail_page_test.dart b/apps/mobile/test/pages/album_detail_page_test.dart index 90f2a0d..a36463f 100644 --- a/apps/mobile/test/pages/album_detail_page_test.dart +++ b/apps/mobile/test/pages/album_detail_page_test.dart @@ -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 setFlashEnabled(bool enabled) async {} + @override Future dispose() async {} } diff --git a/apps/mobile/test/pages/capture_page_test.dart b/apps/mobile/test/pages/capture_page_test.dart index 0eff15a..7e8414e 100644 --- a/apps/mobile/test/pages/capture_page_test.dart +++ b/apps/mobile/test/pages/capture_page_test.dart @@ -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 kJpgBytes = [ 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 _files; @@ -42,6 +45,13 @@ class FakeCaptureSource implements CaptureSource { int failNextInits = 0; Object? initError; + /// 闪光灯能力(默认不支持——UI 不显示开关)。 + bool flashSupported = false; + final List flashCalls = []; + + /// dispose 计数(前后摄切换释放断言)。 + int disposeCalls = 0; + @override Future initialize() async { initializeCalls++; @@ -61,7 +71,14 @@ class FakeCaptureSource implements CaptureSource { ); @override - Future dispose() async {} + bool get supportsFlash => flashSupported; + + @override + Future setFlashEnabled(bool enabled) async => + flashCalls.add(enabled); + + @override + Future dispose() async => disposeCalls++; } void main() { @@ -71,6 +88,9 @@ void main() { late FakeCaptureSource source; late List galleryPaths; + /// 工厂调用记录(前后摄切换断言)。 + late List factoryPreferFront; + final categories = [ 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 = []; + factoryPreferFront = []; }); 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(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 归属不变)', diff --git a/apps/mobile/test/pages/queue_tab_test.dart b/apps/mobile/test/pages/queue_tab_test.dart index 6ce80c6..a7b57e9 100644 --- a/apps/mobile/test/pages/queue_tab_test.dart +++ b/apps/mobile/test/pages/queue_tab_test.dart @@ -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: { + '/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 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, '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, '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: '未收到进度事件的行不重建(行级进度,不整页刷新)', + ); }); } diff --git a/apps/mobile/test/pages/server_history_page_test.dart b/apps/mobile/test/pages/server_history_page_test.dart index 4dcadcd..8e0237b 100644 --- a/apps/mobile/test/pages/server_history_page_test.dart +++ b/apps/mobile/test/pages/server_history_page_test.dart @@ -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> albums = const >[], + }) => + FakeAdapter((o) { + if (o.method == 'GET' && o.path == '/v1/albums') { + return (200, cursorPageBody(albums)); + } + return uploads(o); + }); + Future pumpPage(WidgetTester tester, ApiClient client) async { await tester.pumpWidget( ProviderScope( @@ -91,21 +106,34 @@ void main() { ..httpClientAdapter = adapter, ); - testWidgets('A19 横幅 + 行内容(短码/KB/状态标签/设备短码/本设备标记)', + /// 仅 /v1/uploads 请求(剥离相册列表请求)。 + Iterable 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: [ + { + 'locale': 'zh-CN', + 'title': '婚礼当天', + 'description': null, + 'venue': null, + 'address': null, + }, + { + '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>.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));