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);
});
@@ -0,0 +1,419 @@
/// UI-03 页面 05-08(相册工作台)重设计回归。
///
/// 覆盖:相册列表整图卡片(白字标题/时间场地叠底部暗色渐变、叠层状态
/// 小徽章、无封面同尺寸占位、标题副标题与右上新建入口);相册详情四入口
/// (≥44dp 触控)、Recent photos 三列网格与 See all 跳转、底部栏避让
/// 安全区;操作菜单按角色过滤(先关面板再导航、Cancel 仅关闭);空相册
/// 安静占位(协作者只读说明);上传关闭禁用双入口;处理中照片指示。
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/core/api_client.dart';
import 'package:photo_live/core/error_codes.dart';
import 'package:photo_live/l10n/app_localizations.dart';
import 'package:photo_live/pages/album_detail_page.dart';
import 'package:photo_live/pages/album_form_page.dart';
import 'package:photo_live/pages/album_members_page.dart';
import 'package:photo_live/pages/album_photos_page.dart';
import 'package:photo_live/pages/albums_tab.dart';
import 'package:photo_live/ui/components/photo_thumbnail.dart';
import '../helpers/fakes.dart';
void main() {
late FakeSessionStore store;
setUp(() {
store = FakeSessionStore();
});
ApiClient clientOf(FakeAdapter adapter) => ApiClient(
sessionStore: store,
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
..httpClientAdapter = adapter,
);
/// 固定逻辑宽度(dp=1)。
void setSurface(WidgetTester tester, Size size) {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1.0;
}
/// 相册列表页签(默认 en)。
Future<void> pumpTab(WidgetTester tester, ApiClient client) async {
await tester.pumpWidget(
ProviderScope(
overrides: [...pageTestOverrides(client: client)],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const AlbumsTab(),
),
),
);
await tester.pumpAndSettle();
}
/// 相册详情页(默认管理者 + 空照片;可注入照片/协作者/上传关闭)。
Future<void> pumpDetail(
WidgetTester tester,
ApiClient client,
) async {
await tester.pumpWidget(
ProviderScope(
overrides: [...pageTestOverrides(session: store, client: client)],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const AlbumDetailPage(albumId: 'alb-1'),
),
),
);
await tester.pumpAndSettle();
}
/// 详情页路由打桩:GET 详情 / GET 照片(scope 自适应)。
FakeAdapter detailAdapter({
List<Map<String, dynamic>>? photos,
bool collaborator = false,
bool uploadEnabled = true,
}) =>
FakeAdapter((o) {
if (o.path == '/v1/albums/alb-1' && o.method == 'GET') {
return (200, albumJson(uploadEnabled: uploadEnabled));
}
if (o.path == '/v1/albums/alb-1/photos' && o.method == 'GET') {
final scope = o.uri.queryParameters['scope'];
if (collaborator && scope == 'all') {
return (403, errorBody(ErrorCodes.authForbidden));
}
return (200, cursorPageBody(photos ?? <Map<String, dynamic>>[]));
}
throw StateError('unexpected ${o.method} ${o.path}');
});
List<Map<String, dynamic>> photos(int count, {String processing = 'ready'}) =>
List<Map<String, dynamic>>.generate(
count,
(i) => adminPhotoJson(
photoId: 'ph-${(i + 1).toString().padLeft(2, '0')}',
processingState: processing,
preview: processing == 'ready' ? photoPreviewJson() : null,
),
);
testWidgets('页面 05:整图卡片——白字标题/时间地点叠底部暗色渐变', (tester) async {
setSurface(tester, const Size(390, 844)); // 手机单列
final adapter = FakeAdapter(
(o) => o.path == '/v1/albums'
? (200, cursorPageBody([albumJson()]))
: throw StateError('unexpected ${o.path}'),
);
await pumpTab(tester, clientOf(adapter));
// 标题白字、时间场地 white70,叠加在图片上(非卡片外独立信息区)。
final title = tester.widget<Text>(find.text('Opening Gala'));
expect(title.style?.color, Colors.white);
final meta = tester.widget<Text>(
find.text('2026/9/18 14:30 ~ Not set · 大宴会厅'),
);
expect(meta.style?.color, Colors.white70);
// 底部暗色渐变叠层存在(LinearGradient 的 DecoratedBox)。
expect(
find.byWidgetPredicate(
(w) =>
w is DecoratedBox &&
w.decoration is BoxDecoration &&
(w.decoration as BoxDecoration).gradient is LinearGradient,
),
findsOneWidget,
);
// 叠层状态小徽章:半透明深色底 + 白字(图标语义色提亮变体)。
final badge = tester.widget<Text>(find.text('Public'));
expect(badge.style?.color, Colors.white);
expect(find.text('Uploads on'), findsOneWidget);
// 单卡片 = 单 Card(无信息白卡分包)。
expect(find.byType(Card), findsOneWidget);
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
testWidgets('页面 05:无封面 → 同尺寸中性占位(整卡 16:9)', (tester) async {
setSurface(tester, const Size(390, 844)); // 手机单列
final adapter = FakeAdapter(
(o) => o.path == '/v1/albums'
? (200, cursorPageBody([albumJson()]))
: throw StateError('unexpected ${o.path}'),
);
await pumpTab(tester, clientOf(adapter));
expect(find.byType(PhotoThumbnail), findsOneWidget);
expect(find.byIcon(Icons.image_outlined), findsOneWidget);
// 整卡 16:9:缩略图(Positioned.fill 铺满卡片)宽 = 卡片宽、高 = 9/16。
final card = tester.getRect(find.byType(Card));
final thumb = tester.getRect(find.byType(PhotoThumbnail));
expect(card.width, closeTo(358, 0.5));
expect(thumb.width, closeTo(358, 0.5));
expect(thumb.height, closeTo(358 * 9 / 16, 0.5), reason: '16:9 基准高度');
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
testWidgets('页面 05:标题 + 副标题 + 右上「新建」入口', (tester) async {
final adapter = FakeAdapter(
(o) => o.path == '/v1/albums'
? (200, cursorPageBody([albumJson()]))
: throw StateError('unexpected ${o.path}'),
);
await pumpTab(tester, clientOf(adapter));
expect(find.text('Albums'), findsOneWidget);
expect(find.text('Organize and manage event photos'), findsOneWidget);
// 右上「+」(圆形填充按钮)→ 创建表单。
expect(find.byIcon(Icons.add), findsOneWidget);
expect(find.byType(AlbumFormPage), findsNothing);
await tester.tap(find.byIcon(Icons.add));
await tester.pumpAndSettle();
expect(find.byType(AlbumFormPage), findsOneWidget);
});
testWidgets('页面 06:四入口齐全且有效触控区 ≥44dp', (tester) async {
final adapter = detailAdapter(photos: photos(2));
await pumpDetail(tester, clientOf(adapter));
// 入口文字限定在四入口卡内(页面下方「分类管理」分区标题同为
// Categories 文案,需按 Card 作用域区分)。
final tilesCard = find.byType(Card);
expect(tilesCard, findsOneWidget);
for (final label in ['Photos', 'Members', 'Categories', 'Share']) {
final labelInCard = find.descendant(
of: tilesCard,
matching: find.text(label),
);
expect(labelInCard, findsOneWidget, reason: '$label 入口存在');
final tile = find.ancestor(
of: labelInCard,
matching: find.byType(InkWell),
);
expect(tile, findsOneWidget);
final rect = tester.getRect(tile);
expect(rect.width, greaterThanOrEqualTo(44), reason: '$label 宽 ≥44');
expect(rect.height, greaterThanOrEqualTo(44), reason: '$label 高 ≥44');
}
});
testWidgets('页面 06:Recent photos 三列网格(≤9)+ See all 跳转',
(tester) async {
final adapter = detailAdapter(photos: photos(9));
await pumpDetail(tester, clientOf(adapter));
await tester.ensureVisible(find.text('Recent photos'));
await tester.pumpAndSettle();
final grid = find.byType(GridView);
expect(grid, findsOneWidget);
// 9 张缩略图全部入格(签名 URL 测试环境失败 → 替代图,不破图)。
final thumbs = find
.descendant(of: grid, matching: find.byType(PhotoThumbnail))
.evaluate();
expect(thumbs, hasLength(9));
// 第一行 3 张:第 4 张位于第 1 张下方(三列)。
final first = tester.getRect(
find.descendant(of: grid, matching: find.byType(PhotoThumbnail)).first,
);
final fourth = tester.getRect(
find.descendant(of: grid, matching: find.byType(PhotoThumbnail)).at(3),
);
expect(fourth.top, greaterThan(first.top));
expect(
(fourth.left - first.left).abs(),
lessThan(1),
reason: '三列网格第 4 张与第 1 张同列',
);
// See all → 照片管理页。
await tester.tap(find.text('See all'));
await tester.pumpAndSettle();
expect(find.byType(AlbumPhotosPage), findsOneWidget);
});
testWidgets('页面 07:操作菜单(管理者)——先关面板再导航', (tester) async {
final adapter = detailAdapter(photos: photos(1));
await pumpDetail(tester, clientOf(adapter));
await tester.tap(find.byTooltip('More actions'));
await tester.pumpAndSettle();
// 管理者五项:Edit album / Manage members / Categories / Share album /
// Cancel。条目断言限定在面板内(页面下方「Categories」与四入口文案
// 同名)。
final sheet = find.byType(AlbumActionSheet);
expect(sheet, findsOneWidget);
expect(find.descendant(of: sheet, matching: find.text('Edit album')),
findsOneWidget);
expect(find.descendant(of: sheet, matching: find.text('Manage members')),
findsOneWidget);
expect(find.descendant(of: sheet, matching: find.text('Categories')),
findsOneWidget);
expect(find.descendant(of: sheet, matching: find.text('Share album')),
findsOneWidget);
expect(
find.descendant(of: sheet, matching: find.text('Cancel')),
findsOneWidget);
// 点击动作:面板先关闭,再进入目标页(不叠层路由错乱)。表单页标题
// 同为「Edit album」,以面板组件类型判定关闭。
expect(find.byType(AlbumFormPage), findsNothing);
await tester.tap(find.descendant(
of: sheet,
matching: find.text('Edit album'),
));
await tester.pumpAndSettle();
expect(find.byType(AlbumActionSheet), findsNothing, reason: '面板已关闭');
expect(find.byType(AlbumFormPage), findsOneWidget);
});
testWidgets('页面 07:操作菜单按角色过滤(协作者仅分享)', (tester) async {
final adapter = detailAdapter(photos: photos(1), collaborator: true);
await pumpDetail(tester, clientOf(adapter));
await tester.tap(find.byTooltip('More actions'));
await tester.pumpAndSettle();
final sheet = find.byType(AlbumActionSheet);
expect(sheet, findsOneWidget);
expect(find.descendant(of: sheet, matching: find.text('Edit album')),
findsNothing);
expect(find.descendant(of: sheet, matching: find.text('Manage members')),
findsNothing);
expect(find.descendant(of: sheet, matching: find.text('Categories')),
findsNothing);
expect(find.descendant(of: sheet, matching: find.text('Share album')),
findsOneWidget);
// Cancel 仅关闭面板,不导航。
await tester.tap(
find.descendant(of: sheet, matching: find.text('Cancel')),
);
await tester.pumpAndSettle();
expect(find.byType(AlbumActionSheet), findsNothing, reason: '面板已关闭');
expect(find.byType(AlbumMembersPage), findsNothing);
});
testWidgets('页面 07:菜单「Manage members」共享详情导航方法', (tester) async {
final adapter = detailAdapter(photos: photos(1));
await pumpDetail(tester, clientOf(adapter));
await tester.tap(find.byTooltip('More actions'));
await tester.pumpAndSettle();
final sheet = find.byType(AlbumActionSheet);
await tester.tap(find.descendant(
of: sheet,
matching: find.text('Manage members'),
));
await tester.pumpAndSettle();
expect(find.byType(AlbumMembersPage), findsOneWidget);
});
testWidgets('页面 08:空相册安静占位(管理者)+ 上传入口可用', (tester) async {
await pumpDetail(tester, clientOf(detailAdapter()));
expect(find.text('Ready for your first photo'), findsOneWidget);
expect(
find.text('Photos you take or pick will appear here'),
findsOneWidget,
);
// 无伪造计数与示例照片。
expect(find.byType(GridView), findsNothing);
expect(find.textContaining('uploads'), findsNothing);
// 底部上传栏保留(上传开启)。
expect(
tester
.widget<FilledButton>(
find.widgetWithText(FilledButton, 'Capture and upload'),
)
.onPressed,
isNotNull,
);
});
testWidgets('页面 08:空相册(协作者)显示只读说明', (tester) async {
final adapter = detailAdapter(collaborator: true);
await pumpDetail(tester, clientOf(adapter));
expect(find.text('Ready for your first photo'), findsOneWidget);
expect(
find.text('You can browse photos shared by others'),
findsOneWidget,
);
expect(
find.text('Photos you take or pick will appear here'),
findsNothing,
);
});
testWidgets('页面 06/08:上传关闭 → 双入口禁用 + 原因说明', (tester) async {
final adapter = detailAdapter(uploadEnabled: false);
await pumpDetail(tester, clientOf(adapter));
expect(
find.text(
'Uploads are off for this album. New photos cannot be added right now',
),
findsOneWidget,
);
expect(
tester
.widget<FilledButton>(
find.widgetWithText(FilledButton, 'Capture and upload'),
)
.onPressed,
isNull,
);
expect(
tester
.widget<OutlinedButton>(
find.widgetWithText(OutlinedButton, 'Pick from gallery'),
)
.onPressed,
isNull,
);
});
testWidgets('页面 06:底部固定栏避让安全区(SafeArea 包裹)', (tester) async {
final adapter = detailAdapter(photos: photos(1));
await pumpDetail(tester, clientOf(adapter));
final capture = find.widgetWithText(FilledButton, 'Capture and upload');
expect(
find.ancestor(of: capture, matching: find.byType(SafeArea)),
findsOneWidget,
reason: '底部栏按钮位于 SafeArea 内',
);
});
testWidgets('页面 08:有处理中照片 → 「Processing...」指示,非空白',
(tester) async {
final adapter = detailAdapter(
photos: [...photos(2), ...photos(1, processing: 'processing')],
);
await pumpDetail(tester, clientOf(adapter));
await tester.ensureVisible(find.text('Recent photos'));
await tester.pumpAndSettle();
// 处理中提示存在;网格照常展示(真实状态,不当绝对空白)。
expect(find.text('Processing...'), findsOneWidget);
expect(find.text('Ready for your first photo'), findsNothing);
final grid = find.byType(GridView);
expect(
find.descendant(of: grid, matching: find.byType(PhotoThumbnail))
.evaluate(),
hasLength(3),
);
});
}