feat(mobile): UI-01 theme system, shared components and navigation

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-21 14:55:29 +08:00
parent 8f4ad11bd5
commit c94b9e5e58
27 changed files with 2067 additions and 555 deletions
+76 -48
View File
@@ -1,7 +1,8 @@
/// 相册列表页签 widget 测试(P3 计划 Task 4)。
/// 相册列表页签 widget 测试(P3 Task 4;UI-01 Task 3 起覆盖封面卡片样板)。
///
/// 覆盖:首屏加载与卡片标签行(状态/开关/时区时间/令牌短显)、游标
/// 加载更多(第二页携带 cursor)、空态文案、首屏错误 + 重试恢复。
/// 覆盖:封面卡片样板(封面占位/标题回退/时间场地行/公开与上传徽章/
/// 令牌与四开关移除)、关闭状态徽章、封面加载失败替代图、游标加载更多
/// (第二页携带 cursor)、空态文案、首屏错误 + 重试恢复。
library;
import 'package:dio/dio.dart';
@@ -13,6 +14,7 @@ import 'package:photo_live/core/error_codes.dart';
import 'package:photo_live/features/albums/providers.dart';
import 'package:photo_live/l10n/app_localizations.dart';
import 'package:photo_live/pages/albums_tab.dart';
import 'package:photo_live/ui/components/photo_thumbnail.dart';
import '../helpers/fakes.dart';
@@ -24,10 +26,10 @@ void main() {
});
ApiClient clientOf(FakeAdapter adapter) => ApiClient(
sessionStore: store,
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
..httpClientAdapter = adapter,
);
sessionStore: store,
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
..httpClientAdapter = adapter,
);
Future<void> pumpTab(WidgetTester tester, ApiClient client) async {
await tester.pumpWidget(
@@ -43,35 +45,62 @@ void main() {
await tester.pumpAndSettle();
}
testWidgets('首屏加载 → 卡片渲染标题/状态/开关标签/时区时间/令牌短显',
(tester) async {
testWidgets('封面卡片样板:封面占位 + 标题回退 + 时间场地 + 公开/上传徽章', (tester) async {
final adapter = FakeAdapter(
(o) => o.path == '/v1/albums'
? (200, cursorPageBody([albumJson(
status: 'offline',
uploadEnabled: true,
publicEnabled: false,
downloadEnabled: true,
originalDownloadEnabled: false,
)]))
? (200, cursorPageBody([albumJson()]))
: throw StateError('unexpected ${o.path}'),
);
await pumpTab(tester, clientOf(adapter));
expect(find.text('开幕晚宴'), findsOneWidget);
expect(find.text('Offline'), findsOneWidget);
// 四个开关标签(色调区分开关,文案一致)。
expect(find.text('Upload'), findsOneWidget);
// 标题按界面语言回退(en → en 翻译),最多两行。
expect(find.text('Opening Gala'), findsOneWidget);
// 时间用相册时区(上海墙钟 14:30)+ 场地(en 未填场地回退 zh-CN)。
expect(find.text('2026/9/18 14:30 ~ Not set · 大宴会厅'), findsOneWidget);
// 封面未配置 → 固定 16:9 中性占位(非转圈)。
expect(find.byType(PhotoThumbnail), findsOneWidget);
expect(find.byIcon(Icons.image_outlined), findsOneWidget);
// 公开/上传状态徽章(图标 + 文案,不只靠颜色)。
expect(find.text('Public'), findsOneWidget);
expect(find.text('Download'), findsOneWidget);
expect(find.text('Original'), findsOneWidget);
// Asia/Shanghai 墙钟 14:30;结束未定。
expect(find.text('2026/9/18 14:30 ~ Not set'), findsOneWidget);
// 令牌短显(前 10 字符 + 省略号)。
expect(find.text('sharetoken…'), findsOneWidget);
expect(find.byIcon(Icons.info_outline), findsOneWidget);
expect(find.text('Uploads on'), findsOneWidget);
expect(find.byIcon(Icons.check_circle), findsOneWidget);
// publicToken 与四开关堆叠已从主卡片移除(完整配置在详情设置)。
expect(find.text('sharetoken…'), findsNothing);
expect(find.text('Download'), findsNothing);
expect(find.text('Original'), findsNothing);
expect(find.byIcon(Icons.add), findsOneWidget, reason: 'FAB 新建入口');
});
testWidgets('关闭状态徽章与封面加载失败替代图', (tester) async {
final adapter = FakeAdapter(
(o) => o.path == '/v1/albums'
? (
200,
cursorPageBody([
albumJson(
publicEnabled: false,
uploadEnabled: false,
cover: {
'url': 'http://test.local/cover.jpg',
'expiresAt': '2026-09-22T00:00:00Z',
},
),
]),
)
: throw StateError('unexpected ${o.path}'),
);
await pumpTab(tester, clientOf(adapter));
// 公开关闭 / 上传关闭 → 中性徽章。
expect(find.text('Not public'), findsOneWidget);
expect(find.text('Uploads off'), findsOneWidget);
// 封面 URL 加载失败(测试环境网络请求 400)→ 中性块 + 小图标,不显示破图。
expect(find.byType(PhotoThumbnail), findsOneWidget);
expect(find.byIcon(Icons.broken_image_outlined), findsOneWidget);
expect(find.byIcon(Icons.image_outlined), findsNothing);
});
testWidgets('滚动接近底部 → 游标加载第二页(携带 cursor)', (tester) async {
final page1 = List<Map<String, dynamic>>.generate(
20,
@@ -81,15 +110,13 @@ void main() {
10,
(i) => albumJson(id: 'alb-${i + 21}'),
);
final adapter = FakeAdapter(
(o) {
if (o.path != '/v1/albums') throw StateError('unexpected ${o.path}');
final cursor = o.uri.queryParameters['cursor'];
if (cursor == null) return (200, cursorPageBody(page1, nextCursor: 'c1'));
if (cursor == 'c1') return (200, cursorPageBody(page2));
throw StateError('unexpected cursor $cursor');
},
);
final adapter = FakeAdapter((o) {
if (o.path != '/v1/albums') throw StateError('unexpected ${o.path}');
final cursor = o.uri.queryParameters['cursor'];
if (cursor == null) return (200, cursorPageBody(page1, nextCursor: 'c1'));
if (cursor == 'c1') return (200, cursorPageBody(page2));
throw StateError('unexpected cursor $cursor');
});
await pumpTab(tester, clientOf(adapter));
// 首屏只拉第一页(20 条入 state;ListView 懒构建,不逐条断言渲染)。
@@ -99,8 +126,11 @@ void main() {
);
expect(container.read(albumListProvider).items, hasLength(20));
// 上滑触发加载更多。
await tester.drag(find.byType(ListView), const Offset(0, -2400));
// 上滑触发加载更多(封面卡片较高,分段滚动直至触发第二页请求)。
for (var i = 0; i < 10 && adapter.requests.length < 2; i++) {
await tester.drag(find.byType(ListView), const Offset(0, -1200));
await tester.pump();
}
await tester.pumpAndSettle();
expect(adapter.requests, hasLength(2));
@@ -132,16 +162,14 @@ void main() {
testWidgets('首屏错误 → 错误视图,重试后恢复', (tester) async {
var failFirst = true;
final adapter = FakeAdapter(
(o) {
if (o.path != '/v1/albums') throw StateError('unexpected ${o.path}');
if (failFirst) {
failFirst = false;
return (500, errorBody(ErrorCodes.internalError));
}
return (200, cursorPageBody([albumJson()]));
},
);
final adapter = FakeAdapter((o) {
if (o.path != '/v1/albums') throw StateError('unexpected ${o.path}');
if (failFirst) {
failFirst = false;
return (500, errorBody(ErrorCodes.internalError));
}
return (200, cursorPageBody([albumJson()]));
});
await pumpTab(tester, clientOf(adapter));
expect(
@@ -152,6 +180,6 @@ void main() {
await tester.tap(find.text('Retry'));
await tester.pumpAndSettle();
expect(find.text('开幕晚宴'), findsOneWidget);
expect(find.text('Opening Gala'), findsOneWidget);
});
}
+194
View File
@@ -0,0 +1,194 @@
/// 底部导航与「上传」角标测试(UI-01 Task 3,§4.1)。
///
/// 覆盖:三页签改名(Albums/Uploads/Settings)与 IndexedStack 状态保持、
/// 角标 = 当前账号未结束任务数(countsByState 聚合,写通知防抖刷新)、
/// 他账号任务不计入(账号隔离)、任务全部结束后角标隐藏、
/// [unfinishedCountOf] 纯函数口径(含 retryableFailed)。
library;
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:photo_live/app/theme.dart';
import 'package:photo_live/core/api_client.dart';
import 'package:photo_live/core/session_store.dart';
import 'package:photo_live/features/uploads/queue_controller.dart';
import 'package:photo_live/features/uploads/task_state.dart';
import 'package:photo_live/features/uploads/task_store.dart';
import 'package:photo_live/l10n/app_localizations.dart';
import 'package:photo_live/pages/albums_tab.dart';
import 'package:photo_live/pages/home_page.dart';
import 'package:photo_live/pages/queue_tab.dart';
import '../helpers/fakes.dart';
UploadTaskDraft _draft(String id, {String account = 'staff01'}) =>
UploadTaskDraft(
id: id,
accountId: account,
albumId: 'alb-1',
clientTaskId: 'ct-$id',
sourceFilePath: '/tmp/$id.jpg',
filename: '$id.jpg',
bytes: 10,
contentType: 'image/jpeg',
);
/// 沿合法状态机单步迁移(自迁移非法,须按序调用)。
Future<void> _transition(TaskStore store, String id, UploadTaskState to) =>
store.updateState(id, to);
void main() {
late FakeSessionStore session;
late TaskStore taskStore;
setUp(() {
session = FakeSessionStore()
..session = Session(
token: 'tok-live-1',
expiresAt: DateTime.utc(2099, 1, 1),
account: 'staff01',
);
taskStore = TaskStore.memory();
});
Future<void> pumpHome(WidgetTester tester) async {
// 相册列表页签进入即拉列表;空页兜底即可。
final adapter = FakeAdapter(
(o) => o.path == '/v1/albums'
? (200, cursorPageBody(<Map<String, dynamic>>[]))
: throw StateError('unexpected ${o.path}'),
);
final client = ApiClient(
sessionStore: session,
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
..httpClientAdapter = adapter,
);
await tester.pumpWidget(
ProviderScope(
overrides: [
...pageTestOverrides(
session: session,
taskStore: taskStore,
client: client,
),
],
child: MaterialApp(
theme: AppTheme.light,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const HomePage(),
),
),
);
await tester.pumpAndSettle();
}
/// 任务库写通知 → 200ms 防抖重查(kQueueRefreshDebounce)。
///
/// 不用 pumpAndSettle:uploading 任务的行级进度条为不确定态(常驻动画)。
Future<void> pumpCounts(WidgetTester tester) async {
await tester.pump(kQueueRefreshDebounce * 2);
await tester.pump(const Duration(milliseconds: 50));
}
testWidgets('三页签改名 + IndexedStack 保持页面状态', (tester) async {
await pumpHome(tester);
// IndexedStack 常驻三页签(非当前页签 offstage,find 默认跳过,故显式
// 关闭 skipOffstage)。Albums/Settings 各两次(页签 AppBar 标题 + 导航
// 标签);Uploads 仅导航标签(队列页标题为 Upload queue)。
expect(find.text('Albums', skipOffstage: false), findsNWidgets(2));
expect(find.text('Uploads', skipOffstage: false), findsOneWidget);
expect(find.text('Settings', skipOffstage: false), findsNWidgets(2));
expect(
find.text('Queue', skipOffstage: false),
findsNothing,
reason: '旧页签名已移除',
);
expect(find.byType(IndexedStack), findsOneWidget);
expect(find.byType(AlbumsTab, skipOffstage: false), findsOneWidget);
expect(find.byType(QueueTab, skipOffstage: false), findsOneWidget);
expect(find.byType(Badge), findsNothing, reason: '初始无任务无角标');
});
testWidgets('角标 = 当前账号未完成任务数;他账号任务不计入', (tester) async {
await pumpHome(tester);
// 其他账号任务:账号隔离,不显示角标。
final other = await taskStore.create(_draft('t-o1', account: 'staff02'));
expect(other.accountId, 'staff02');
await pumpCounts(tester);
expect(find.byType(Badge), findsNothing, reason: '他账号任务不计入');
// 本账号 3 个未完成(2 queued + 1 uploading)。
final t1 = await taskStore.create(_draft('t-1'));
final t2 = await taskStore.create(_draft('t-2'));
final t3 = await taskStore.create(_draft('t-3'));
await _transition(taskStore, t1.id, UploadTaskState.queued);
await _transition(taskStore, t2.id, UploadTaskState.queued);
await _transition(taskStore, t3.id, UploadTaskState.queued);
await _transition(taskStore, t3.id, UploadTaskState.uploading);
await pumpCounts(tester);
expect(find.byType(Badge), findsOneWidget);
expect(find.text('3'), findsOneWidget, reason: '角标计数 3');
});
testWidgets('任务全部结束 → 角标隐藏', (tester) async {
await pumpHome(tester);
final tasks = <String>[
for (var i = 1; i <= 2; i++) (await taskStore.create(_draft('t-$i'))).id,
];
for (final id in tasks) {
await _transition(taskStore, id, UploadTaskState.queued);
}
await pumpCounts(tester);
expect(find.text('2'), findsOneWidget);
for (final id in tasks) {
await _transition(taskStore, id, UploadTaskState.uploading);
await _transition(taskStore, id, UploadTaskState.uploadedConfirming);
await _transition(taskStore, id, UploadTaskState.completed);
}
await pumpCounts(tester);
expect(find.byType(Badge), findsNothing, reason: '0 隐藏角标');
});
test('unfinishedCountOf 口径:待传 + 等待网络 + 可重试失败', () {
Map<UploadTaskState, int> counts(Map<UploadTaskState, int> patch) =>
<UploadTaskState, int>{
for (final state in UploadTaskState.values) state: patch[state] ?? 0,
};
// 待传口径(pendingCountOf)之外,可重试失败计入;准备失败/取消/完成不计。
expect(
unfinishedCountOf(
counts({UploadTaskState.queued: 2, UploadTaskState.uploading: 1}),
),
3,
);
expect(
unfinishedCountOf(
counts({
UploadTaskState.retryableFailed: 1,
UploadTaskState.waitingNetwork: 2,
}),
),
3,
);
expect(
unfinishedCountOf(
counts({
UploadTaskState.completed: 5,
UploadTaskState.cancelled: 1,
UploadTaskState.preparationFailed: 1,
}),
),
0,
);
});
}