feat(mobile): UI-03 album workspace pages (05-08)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-22 00:57:59 +08:00
parent 95e8417553
commit 5715c608d8
9 changed files with 1235 additions and 306 deletions
@@ -1,11 +1,11 @@
/// 相册详情页 widget 测试(UI-01 §4.2 重构后)。
/// 相册详情页 widget 测试(UI-03 §4 页面 06-08 重设计后)。
///
/// 覆盖:紧凑头部(封面占位 + 标题 + 时区时间场地行)→ 状态徽章 →
/// 照片入口(最近上传预览条 + 管理照片 / 空态「拍摄第一张照片」)→ 次级
/// 管理入口(ListTile)→ 底部固定上传栏(拍摄上传主 / 从相册选择次,上
/// 传关闭时禁用并说明);协作者(scope all 403 → own)隐藏管理入口;分类
/// 管理三流(新增载荷、空名校验、编辑 PATCH、删除确认);底部栏直达连拍
/// 页与多选入队。
/// 覆盖:封面 → 标题/时区时间场地 → 状态徽章 → 次级四入口(Photos/
/// Members/Categories/Share)→ Recent photos 三列网格 + See all → 底部
/// 固定上传栏(拍摄上传主 / 从相册选择次,上传关闭时禁用并说明);空相册
/// 安静占位;协作者(scope all 403 → own)隐藏管理入口;分类管理三流
/// (新增载荷、空名校验、编辑 PATCH、删除确认);底部栏直达连拍页与多选
/// 入队。
library;
import 'dart:io';
@@ -105,7 +105,7 @@ void main() {
await tester.pumpAndSettle();
}
testWidgets('布局结构:头部 → 状态 → 照片入口 → 次级操作 → 底部上传栏',
testWidgets('布局结构:封面 → 信息 → 四入口 → Recent photos → 底部上传栏',
(tester) async {
final adapter = detailAdapter(
photos: List<Map<String, dynamic>>.generate(
@@ -118,8 +118,8 @@ void main() {
);
await pumpDetail(tester, clientOf(adapter));
// 紧凑头部:封面占位(无 cover → 中性占位,非转圈)+ 标题 + 单行
// 时间/场地(相册时区墙钟 + 场地语言回退)。
// 封面占位(无 cover → 中性占位,非转圈)+ 标题 + 单行时间/场地
// (相册时区墙钟 + 场地语言回退)。
final body = find.byType(SingleChildScrollView);
expect(
find.descendant(of: body, matching: find.text('Opening Gala')),
@@ -130,7 +130,7 @@ void main() {
findsOneWidget,
);
expect(find.byIcon(Icons.image_outlined), findsOneWidget);
// 预览条缩略图(签名 URL 测试环境失败 → 替代图,不破图)。
// Recent photos 网格缩略图(签名 URL 测试环境失败 → 替代图,不破图)。
expect(find.byIcon(Icons.broken_image_outlined), findsNWidgets(3));
// 状态区:公开/上传徽章(关闭不等于不公开,分别呈现)。
@@ -140,14 +140,23 @@ void main() {
expect(find.text('Original'), findsNothing);
expect(find.text('sharetoken000000000001'), findsNothing);
// 照片入口:分区标题 + 「管理照片」入口 + 3 张预览缩略图。
expect(find.text('Manage photos'), findsOneWidget);
// Recent photos:分区标题 + 「See all」入口 + 三列网格。
expect(find.text('Recent photos'), findsOneWidget);
expect(find.text('See all'), findsOneWidget);
// 次级操作(ListTile,不再与主操作同级)。
expect(find.widgetWithText(ListTile, 'Edit'), findsOneWidget);
expect(find.widgetWithText(ListTile, 'Members'), findsOneWidget);
expect(find.widgetWithText(ListTile, 'Categories'), findsOneWidget);
expect(find.widgetWithText(ListTile, 'Share QR code'), findsOneWidget);
// 次级四入口(管理者全量)。入口文字限定在四入口卡内(页面下方
// 「分类管理」分区标题同为 Categories 文案,按 Card 作用域区分)。
final tilesCard = find.byType(Card).first;
expect(find.descendant(of: tilesCard, matching: find.text('Photos')),
findsOneWidget);
expect(find.descendant(of: tilesCard, matching: find.text('Members')),
findsOneWidget);
expect(find.descendant(of: tilesCard, matching: find.text('Categories')),
findsOneWidget);
expect(find.descendant(of: tilesCard, matching: find.text('Share')),
findsOneWidget);
// 顶栏「⋯」操作菜单入口。
expect(find.byIcon(Icons.more_vert), findsOneWidget);
// 底部固定上传栏:拍摄上传(主)+ 从相册选择(次)。
final capture = find.widgetWithText(FilledButton, 'Capture and upload');
@@ -155,34 +164,44 @@ void main() {
expect(capture, findsOneWidget);
expect(pick, findsOneWidget);
// 垂直顺序:标题 → 徽章 → 管理照片 → 底部栏(次级操作在 body 列内,
// 其在树中先于 bottomNavigationBar,几何位置随滚动变化不作断言)。
// 垂直顺序:标题 → 徽章 → 四入口 → Recent photos → 底部栏。全宽
// 16:9 封面较高,先滚动让 Recent photos 入屏再比较几何位置。
await reveal(tester, find.text('Recent photos'));
final titleTop = tester
.getRect(find.descendant(of: body, matching: find.text('Opening Gala')))
.top;
final badgeTop = tester.getRect(find.text('Public')).top;
final manageTop = tester.getRect(find.text('Manage photos')).top;
final tilesTop = tester
.getRect(find.descendant(of: tilesCard, matching: find.text('Photos')))
.top;
final recentTop = tester.getRect(find.text('Recent photos')).top;
final captureTop = tester.getRect(capture).top;
expect(titleTop, lessThan(badgeTop));
expect(badgeTop, lessThan(manageTop));
expect(manageTop, lessThan(captureTop));
expect(badgeTop, lessThan(tilesTop));
expect(tilesTop, lessThan(recentTop));
expect(recentTop, lessThan(captureTop));
});
testWidgets('无照片:空态引导「拍摄第一张照片」(上传开启时可用)',
testWidgets('无照片:空相册安静占位「Ready for your first photo」',
(tester) async {
await pumpDetail(tester, clientOf(detailAdapter()));
expect(find.text('No photos yet'), findsOneWidget);
final cta = find.widgetWithText(FilledButton, 'Take the first photo');
expect(cta, findsOneWidget);
expect(find.text('Ready for your first photo'), findsOneWidget);
expect(
tester.widget<FilledButton>(cta).onPressed,
find.text('Photos you take or pick will appear here'),
findsOneWidget,
);
// 空态无伪造计数/示例照片,上传入口收敛到底部栏(上传开启时可用)。
expect(find.text('No photos yet'), findsNothing);
final capture = find.widgetWithText(FilledButton, 'Capture and upload');
expect(
tester.widget<FilledButton>(capture).onPressed,
isNotNull,
reason: '上传开启时引导可用',
reason: '上传开启时底部栏可用',
);
});
testWidgets('上传关闭:底部栏说明原因并禁用双入口 + 引导禁用', (tester) async {
testWidgets('上传关闭:底部栏说明原因并禁用双入口', (tester) async {
final adapter = FakeAdapter((o) {
if (o.path == '/v1/albums/alb-1' && o.method == 'GET') {
return (200, albumJson(categories: categories, uploadEnabled: false));
@@ -216,15 +235,6 @@ void main() {
.onPressed,
isNull,
);
expect(
tester
.widget<FilledButton>(
find.widgetWithText(FilledButton, 'Take the first photo'),
)
.onPressed,
isNull,
reason: '空态引导与底部栏同一开关',
);
});
testWidgets('协作者(all 403 → own):隐藏管理入口,仅见自己上传预览',
@@ -249,14 +259,14 @@ void main() {
expect(photoRequests.first.uri.queryParameters['scope'], 'all');
expect(photoRequests.last.uri.queryParameters['scope'], 'own');
// 管理入口仅创建者/管理员可见;分享对全体成员开放。
expect(find.widgetWithText(ListTile, 'Edit'), findsNothing);
expect(find.widgetWithText(ListTile, 'Members'), findsNothing);
// 管理入口仅创建者/管理员可见;Photos/Share 对全体成员开放。
expect(find.text('Members'), findsNothing);
expect(find.text('Categories'), findsNothing, reason: '含分区一并隐藏');
expect(find.widgetWithText(ListTile, 'Share QR code'), findsOneWidget);
expect(find.text('Photos'), findsOneWidget);
expect(find.text('Share'), findsOneWidget);
expect(find.byIcon(Icons.add_circle_outline), findsNothing);
// 预览条退化为自己的上传(1 张;头部封面占位另 1 张)。
// 预览网格退化为自己的上传(1 张;封面占位另 1 张)。
expect(find.byIcon(Icons.broken_image_outlined), findsOneWidget);
});