feat(mobile): UI-03 forms and collaboration pages (09-12)

- Page 09 create: grouped form (Event details / Description & languages),
  required default-language selector, zh-CN/en tabs with per-locale
  title/description/venue/address, explicit timezone field, bottom save
  button kept keyboard-reachable, create mode has no Access tab
- Page 10 edit: Details/Access tabs sharing one draft; Access has four
  switches with explanations; original downloads constrained by downloads
  (disabled + forced off in PATCH); unsaved-changes PopScope confirm;
  save commits all changes in one minimal PATCH
- Page 11 members: Owner group + Collaborators list with initial avatars
  and per-row remove; full-width Invite colleague opens account-search
  bottom sheet (debounced prefix search, race guard, already-added state);
  remove confirm names the member
- Page 12 categories: extracted to standalone page with per-row edit/
  delete menu and New category sheet (language tabs + name + sort order);
  delete confirm mentions photo impact; collaborator browse-only; old
  inline section removed from album detail
- l10n: new form/members/categories keys both locales; regenerated
- tests: form 14, members 6, categories 7, detail nav 1; full suite green

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-22 01:23:02 +08:00
parent 5715c608d8
commit 8bd09eefb9
13 changed files with 2322 additions and 1184 deletions
@@ -3,9 +3,8 @@
/// 覆盖:封面 → 标题/时区时间场地 → 状态徽章 → 次级四入口(Photos/
/// Members/Categories/Share)→ Recent photos 三列网格 + See all → 底部
/// 固定上传栏(拍摄上传主 / 从相册选择次,上传关闭时禁用并说明);空相册
/// 安静占位;协作者(scope all 403 → own)隐藏管理入口;分类管理三流
/// (新增载荷、空名校验、编辑 PATCH、删除确认);底部栏直达连拍页与多选
/// 入队。
/// 安静占位;协作者(scope all 403 → own)隐藏管理入口;分类入口跳转
/// 独立分类页(页面 12,旧内嵌区块已移除);底部栏直达连拍页与多选入队。
library;
import 'dart:io';
@@ -24,6 +23,7 @@ import 'package:photo_live/features/uploads/preparer.dart';
import 'package:photo_live/features/uploads/providers.dart';
import 'package:photo_live/features/uploads/task_store.dart';
import 'package:photo_live/l10n/app_localizations.dart';
import 'package:photo_live/pages/album_categories_page.dart';
import 'package:photo_live/pages/album_detail_page.dart';
import 'package:photo_live/pages/capture_page.dart';
@@ -270,186 +270,38 @@ void main() {
expect(find.byIcon(Icons.broken_image_outlined), findsOneWidget);
});
testWidgets('新增分类:填名称与排序 → POST 载荷 → 列表刷新', (tester) async {
var posted = false;
final adapter = detailAdapter(
onCategoryMutation: (method, path) {
if (method == 'POST') {
posted = true;
categories = <Map<String, dynamic>>[
...categories,
categoryJson(
id: 'cat-new',
sortOrder: 5,
translations: <Map<String, dynamic>>[
<String, dynamic>{'locale': 'zh-CN', 'name': '签到'},
<String, dynamic>{'locale': 'en', 'name': 'Check-in'},
],
),
];
return (201, categoryJson(id: 'cat-new', sortOrder: 5));
}
return null;
},
);
await pumpDetail(tester, clientOf(adapter));
await reveal(tester, find.byIcon(Icons.add_circle_outline));
await tester.tap(find.byIcon(Icons.add_circle_outline).hitTestable());
await tester.pumpAndSettle();
await tester.enterText(
find.widgetWithText(TextFormField, 'Chinese name'),
'签到',
);
await tester.enterText(
find.widgetWithText(TextFormField, 'English name'),
'Check-in',
);
await tester.enterText(
find.widgetWithText(TextFormField, 'Sort order'),
'5',
);
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(posted, isTrue);
// 成功后详情重载会追加 GET,故按方法+路径取 POST。
final request = adapter.requests.singleWhere(
(r) => r.method == 'POST' && r.path == '/v1/albums/alb-1/categories',
);
expect(request.path, '/v1/albums/alb-1/categories');
expect(request.body, <String, dynamic>{
'sortOrder': 5,
'translations': <Map<String, dynamic>>[
<String, dynamic>{'locale': 'zh-CN', 'name': '签到'},
<String, dynamic>{'locale': 'en', 'name': 'Check-in'},
],
});
// 详情重载后列表含新分类。
expect(find.text('签到 · Check-in'), findsOneWidget);
});
testWidgets('新增分类:名称全空 → 客户端拦截,不发请求', (tester) async {
testWidgets('分类入口:跳转独立分类页(旧内嵌区块已移除)', (tester) async {
final adapter = detailAdapter();
await pumpDetail(tester, clientOf(adapter));
final requestsBefore = adapter.requests.length;
await reveal(tester, find.byIcon(Icons.add_circle_outline));
await tester.tap(find.byIcon(Icons.add_circle_outline).hitTestable());
await tester.pumpAndSettle();
await tester.tap(find.text('Save'));
await tester.pump();
expect(
find.text('At least one language name is required'),
findsOneWidget,
);
expect(adapter.requests, hasLength(requestsBefore));
});
testWidgets('编辑分类:预填并改名 → PATCH 全量 translations + sortOrder',
(tester) async {
var patched = false;
final adapter = detailAdapter(
onCategoryMutation: (method, path) {
if (method == 'PATCH') {
patched = true;
categories = <Map<String, dynamic>>[
categoryJson(
id: 'cat-1',
sortOrder: 2,
translations: <Map<String, dynamic>>[
<String, dynamic>{'locale': 'zh-CN', 'name': '签到墙'},
<String, dynamic>{'locale': 'en', 'name': 'Ceremony'},
],
),
];
return (200, categoryJson(id: 'cat-1'));
}
return null;
},
);
await pumpDetail(tester, clientOf(adapter));
await reveal(tester, find.text('仪式 · Ceremony'));
await tester.tap(find.text('仪式 · Ceremony').hitTestable());
await tester.pumpAndSettle();
// 预填检查。
final zhField = tester.widget<TextFormField>(
find.widgetWithText(TextFormField, 'Chinese name'),
);
expect(zhField.controller!.text, '仪式');
await tester.enterText(
find.widgetWithText(TextFormField, 'Chinese name'),
'签到墙',
);
await tester.enterText(
find.widgetWithText(TextFormField, 'Sort order'),
'2',
);
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(patched, isTrue);
// 成功后详情重载会追加 GET,故按方法+路径取 PATCH。
final request = adapter.requests.singleWhere(
(r) => r.method == 'PATCH' && r.path == '/v1/categories/cat-1',
);
expect(request.body, <String, dynamic>{
'sortOrder': 2,
'translations': <Map<String, dynamic>>[
<String, dynamic>{'locale': 'zh-CN', 'name': '签到墙'},
<String, dynamic>{'locale': 'en', 'name': 'Ceremony'},
],
});
expect(find.text('签到墙 · Ceremony'), findsOneWidget);
});
testWidgets('删除分类:确认对话框 → DELETE → 列表移除', (tester) async {
final adapter = detailAdapter(
onCategoryMutation: (method, path) {
if (method == 'DELETE') {
categories = <Map<String, dynamic>>[];
return (204, null);
}
return null;
},
);
await pumpDetail(tester, clientOf(adapter));
await reveal(tester, find.byIcon(Icons.delete_outline));
await tester.tap(find.byIcon(Icons.delete_outline).hitTestable());
await tester.pumpAndSettle();
expect(
find.text('Delete this category? Its photos will become uncategorized.'),
findsOneWidget,
);
// 取消可用:先取消再重删,验证确认门。
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(find.text('仪式 · Ceremony'), findsOneWidget);
await reveal(tester, find.byIcon(Icons.delete_outline));
await tester.tap(find.byIcon(Icons.delete_outline).hitTestable());
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
await tester.pumpAndSettle();
expect(
adapter.requests.any(
(r) => r.method == 'DELETE' && r.path == '/v1/categories/cat-1',
),
isTrue,
);
// 旧内嵌分类管理区块不再存在于详情页。
expect(find.byIcon(Icons.add_circle_outline), findsNothing);
expect(find.text('仪式 · Ceremony'), findsNothing);
expect(find.text('No categories yet. Tap + to add one'), findsOneWidget);
// Categories 入口 → 独立分类页(页面 12)。入口在折叠线下,先滚动
// 可见再点击。
final tilesCard = find.byType(Card).first;
final categoriesTile =
find.descendant(of: tilesCard, matching: find.text('Categories'));
await reveal(tester, categoriesTile);
await tester.tap(categoriesTile);
await tester.pumpAndSettle();
expect(find.byType(AlbumCategoriesPage), findsOneWidget);
expect(find.text('仪式'), findsOneWidget, reason: '分类列表(默认语言名)');
// 返回后详情重载(分类随详情返回)。
await tester.pageBack();
await tester.pumpAndSettle();
expect(find.byType(AlbumCategoriesPage), findsNothing);
expect(
adapter.requests
.where((r) => r.method == 'GET' && r.path == '/v1/albums/alb-1')
.length,
greaterThanOrEqualTo(2),
);
});
group('底部固定上传栏(原 Task 9 FAB 流迁移)', () {
late Directory tmp;
late List<String> galleryPaths;