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
+368 -216
View File
@@ -1,6 +1,9 @@
/// 成员管理页(P3 计划 Task 4):成员列表(角色标签、owner 徽标、
/// 本人/创建者不可移除)+ 账号前缀搜索邀请(300ms 防抖、≥2 字符、
/// lookupUsers 远程搜索)+ 移除确认。MEMBER_CONFLICT 本地化覆盖。
/// 成员管理页(UI-03 §4 页面 11 重设计):Owner 单独分组(无移除)+
/// Collaborators 列表(首字母头像、显示名、账号·角色、行尾「⋯」移除);
/// 底部全宽「Invite colleague」打开账号前缀搜索 BottomSheet(300ms 防抖、
/// ≥2 字符、迟到响应守卫、已成员「Already added」态、MEMBER_CONFLICT
/// 本地化)。移除经确认对话框(含成员名)。本人/创建者不可移除;成员
/// 分页保留。
library;
import 'dart:async';
@@ -14,6 +17,8 @@ import '../core/error_codes.dart';
import '../core/error_l10n.dart';
import '../features/albums/models.dart';
import '../l10n/app_localizations.dart';
import '../ui/components/section_header.dart';
import '../ui/tokens.dart';
/// 成员管理(从详情页携带完整 [album] 进入)。
class AlbumMembersPage extends ConsumerStatefulWidget {
@@ -38,11 +43,6 @@ class _AlbumMembersPageState extends ConsumerState<AlbumMembersPage> {
/// 当前登录账号(本人移除 UI 拦截用);读取失败按未知处理。
String? _selfAccount;
final TextEditingController _searchController = TextEditingController();
Timer? _debounce;
bool _searching = false;
List<AdminUserLookupItem>? _results;
@override
void initState() {
super.initState();
@@ -50,13 +50,6 @@ class _AlbumMembersPageState extends ConsumerState<AlbumMembersPage> {
_reload();
}
@override
void dispose() {
_debounce?.cancel();
_searchController.dispose();
super.dispose();
}
Future<void> _loadSelfAccount() async {
try {
final session = await ref.read(sessionStoreProvider).read();
@@ -138,7 +131,281 @@ class _AlbumMembersPageState extends ConsumerState<AlbumMembersPage> {
}
}
// ── 邀请搜索(防抖 300ms、≥2 字符) ──
// ── 邀请(BottomSheet 搜索选择器) ─────────────────────────────
Future<void> _openInviteSheet() async {
await showModalBottomSheet<void>(
context: context,
showDragHandle: true,
isScrollControlled: true,
useSafeArea: true,
builder: (_) => AlbumInviteSheet(
albumId: widget.album.id,
// 打开时的既有成员(已添加态判定;面板内邀请成功后本地累加)。
memberUserIds: <String>{
for (final m in _items) m.userId,
},
memberAccounts: <String>{
for (final m in _items) m.account,
},
onInvited: _reload,
),
);
}
// ── 移除(行尾「⋯」→ 确认对话框) ─────────────────────────────
Future<void> _remove(Member member) async {
final l10n = AppLocalizations.of(context);
final name = member.displayName.trim().isNotEmpty
? member.displayName.trim()
: member.account;
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: Text(l10n.removeMember),
content: Text(l10n.removeMemberConfirm(name)),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(l10n.cancel),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(l10n.delete),
),
],
),
);
if (confirmed != true || !mounted) return;
final messenger = ScaffoldMessenger.of(context);
try {
await ref
.read(apiClientProvider)
.removeMember(widget.album.id, member.userId);
await _reload();
} on AppException catch (e) {
messenger.showSnackBar(
SnackBar(content: Text(l10n.messageForError(e))),
);
} on Object {
messenger.showSnackBar(
SnackBar(content: Text(l10n.errorGeneric)),
);
}
}
bool _canRemove(Member member) =>
member.role != MemberRole.owner && member.account != _selfAccount;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final owners = _items.where((m) => m.role == MemberRole.owner).toList();
final collaborators =
_items.where((m) => m.role != MemberRole.owner).toList();
return Scaffold(
appBar: AppBar(
title: Text(
_loading ? l10n.membersTitle : l10n.membersTitleCount(_items.length),
),
),
body: _loading
? const Center(child: CircularProgressIndicator())
: _errorCode != null
? _ErrorRetryView(
message: l10n.messageForErrorCode(_errorCode!),
onRetry: _reload,
)
: _memberGroups(l10n, owners, collaborators),
// 全宽「Invite colleague」:避让安全区,键盘/列表常驻可达。
bottomNavigationBar: SafeArea(
minimum: const EdgeInsets.fromLTRB(kSpace16, kSpace8, kSpace16, kSpace12),
child: FilledButton.icon(
onPressed: _loading ? null : _openInviteSheet,
icon: const Icon(Icons.person_add_outlined),
label: Text(l10n.inviteColleague),
),
),
);
}
/// Owner 单独组 + Collaborators 组;滚动加载更多挂在列表底部。
Widget _memberGroups(
AppLocalizations l10n,
List<Member> owners,
List<Member> collaborators,
) {
final theme = Theme.of(context);
return ListView(
padding: const EdgeInsets.fromLTRB(kSpace16, kSpace16, kSpace16, kSpace24),
children: [
SectionHeader(title: l10n.ownerGroup),
const SizedBox(height: kSpace8),
Card(
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
child: Column(
children: [
for (final (i, member) in owners.indexed) ...[
if (i > 0) const Divider(height: 1, indent: kSpace16, endIndent: kSpace16),
_MemberRow(
member: member,
isSelf: member.account == _selfAccount,
onRemove: null, // 创建者不可移除(无「⋯」)
),
],
],
),
),
const SizedBox(height: kSpace24),
SectionHeader(title: l10n.collaboratorsGroup),
const SizedBox(height: kSpace8),
if (collaborators.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: kSpace12),
child: Text(
l10n.noCollaborators,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
)
else
Card(
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
child: Column(
children: [
for (final (i, member) in collaborators.indexed) ...[
if (i > 0) const Divider(height: 1, indent: kSpace16, endIndent: kSpace16),
_MemberRow(
member: member,
isSelf: member.account == _selfAccount,
onRemove: _canRemove(member) ? () => _remove(member) : null,
),
],
],
),
),
if (_hasMore || _loadingMore)
Padding(
padding: const EdgeInsets.symmetric(vertical: kSpace16),
child: Center(
child: _loadingMore
? const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: // 滚到底部自动取下一页(沿用旧列表的触发时机)。
_LoadMoreTrigger(onTriggered: _loadMore),
),
),
],
);
}
}
/// 成员行:首字母头像 + 显示名(本人标记)+ 账号·角色 + 行尾「⋯」(仅可
/// 移除成员显示;点击直接进入移除确认)。
class _MemberRow extends StatelessWidget {
const _MemberRow({
required this.member,
required this.isSelf,
this.onRemove,
});
final Member member;
final bool isSelf;
/// 移除回调;null = 不显示「⋯」(创建者/本人)。
final VoidCallback? onRemove;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final name = member.displayName.trim().isNotEmpty
? member.displayName.trim()
: member.account;
final role = member.role == MemberRole.owner
? l10n.roleOwner
: l10n.roleCollaborator;
return ListTile(
leading: CircleAvatar(child: Text(name.substring(0, 1).toUpperCase())),
title: Text(isSelf ? '$name${l10n.selfMarker}' : name),
subtitle: Text('${member.account} · $role'),
trailing: onRemove == null
? null
: IconButton(
tooltip: l10n.removeMember,
icon: const Icon(Icons.more_horiz),
onPressed: onRemove,
),
);
}
}
/// 「加载更多」触发器:布局进列表尾部即触发一次下一页请求。
class _LoadMoreTrigger extends StatelessWidget {
const _LoadMoreTrigger({required this.onTriggered});
final VoidCallback onTriggered;
@override
Widget build(BuildContext context) {
WidgetsBinding.instance.addPostFrameCallback((_) => onTriggered());
return const SizedBox(height: 24, width: 24);
}
}
/// 邀请同事 BottomSheet:账号前缀搜索(防抖 300ms、≥2 字符)→ 结果行
/// (头像 + 显示名 + 账号 + Add);已成员显示「Already added」不可再加。
/// 邀请成功提示 + 回调上级刷新列表;本地累加已添加集合保持面板状态。
class AlbumInviteSheet extends ConsumerStatefulWidget {
const AlbumInviteSheet({
required this.albumId,
required this.memberUserIds,
required this.memberAccounts,
required this.onInvited,
super.key,
});
final String albumId;
/// 打开面板时的成员用户 ID 集合。
final Set<String> memberUserIds;
/// 打开面板时的成员账号集合(按账号兜底判重)。
final Set<String> memberAccounts;
/// 邀请成功回调(上级刷新成员列表)。
final VoidCallback onInvited;
@override
ConsumerState<AlbumInviteSheet> createState() => _AlbumInviteSheetState();
}
class _AlbumInviteSheetState extends ConsumerState<AlbumInviteSheet> {
final TextEditingController _searchController = TextEditingController();
Timer? _debounce;
bool _searching = false;
List<AdminUserLookupItem>? _results;
/// 面板生命周期内新增的成员(已添加态即时生效)。
final Set<String> _addedIds = <String>{};
@override
void dispose() {
_debounce?.cancel();
_searchController.dispose();
super.dispose();
}
bool _isMember(AdminUserLookupItem user) =>
widget.memberUserIds.contains(user.id) ||
_addedIds.contains(user.id) ||
widget.memberAccounts.contains(user.account);
void _onSearchChanged(String text) {
_debounce?.cancel();
@@ -188,17 +455,14 @@ class _AlbumMembersPageState extends ConsumerState<AlbumMembersPage> {
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
try {
await ref.read(apiClientProvider).addMember(
widget.album.id,
user.id,
);
await ref.read(apiClientProvider).addMember(widget.albumId, user.id);
if (!mounted) return;
_addedIds.add(user.id);
setState(() {});
messenger.showSnackBar(
SnackBar(content: Text(l10n.memberInvited)),
);
_searchController.clear();
setState(() => _results = null);
await _reload();
widget.onInvited();
} on AppException catch (e) {
messenger.showSnackBar(
SnackBar(
@@ -216,98 +480,62 @@ class _AlbumMembersPageState extends ConsumerState<AlbumMembersPage> {
}
}
Future<void> _remove(Member member) async {
final l10n = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: Text(l10n.removeMember),
content: Text(l10n.removeMemberConfirm),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(l10n.cancel),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(l10n.delete),
),
],
),
);
if (confirmed != true || !mounted) return;
final messenger = ScaffoldMessenger.of(context);
try {
await ref
.read(apiClientProvider)
.removeMember(widget.album.id, member.userId);
await _reload();
} on AppException catch (e) {
messenger.showSnackBar(
SnackBar(content: Text(l10n.messageForError(e))),
);
} on Object {
messenger.showSnackBar(
SnackBar(content: Text(l10n.errorGeneric)),
);
}
}
bool _canRemove(Member member) =>
member.role != MemberRole.owner && member.account != _selfAccount;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(title: Text(l10n.albumMembers)),
body: Column(
return Padding(
// 键盘避让:输入框跟随视口内边距上移。
padding: EdgeInsets.fromLTRB(
kSpace16,
0,
kSpace16,
MediaQuery.of(context).viewInsets.bottom + kSpace16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: TextField(
controller: _searchController,
onChanged: _onSearchChanged,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.person_search_outlined),
hintText: l10n.inviteSearchHint,
border: const OutlineInputBorder(),
suffixIcon: _searching
? const Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: (_searchController.text.isEmpty
? null
: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
_onSearchChanged('');
},
)),
),
TextField(
controller: _searchController,
autofocus: true,
onChanged: _onSearchChanged,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.person_search_outlined),
hintText: l10n.inviteSearchHint,
border: const OutlineInputBorder(),
suffixIcon: _searching
? const Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: (_searchController.text.isEmpty
? null
: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
_onSearchChanged('');
},
)),
),
),
if (_results != null) _searchResults(l10n),
const Divider(height: 1, indent: 16, endIndent: 16),
Expanded(child: _memberList(l10n)),
const SizedBox(height: kSpace8),
if (_results != null) Flexible(child: _resultsList(l10n)),
],
),
);
}
/// 搜索结果面板(有限高度内滚动;无结果显示占位文案)。
Widget _searchResults(AppLocalizations l10n) {
/// 结果列表(面板高度内滚动;无结果显示占位文案)。
Widget _resultsList(AppLocalizations l10n) {
final results = _results!;
if (results.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
padding: const EdgeInsets.symmetric(vertical: kSpace12),
child: Text(
l10n.inviteNoResults,
textAlign: TextAlign.center,
@@ -315,136 +543,60 @@ class _AlbumMembersPageState extends ConsumerState<AlbumMembersPage> {
),
);
}
return ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 220),
child: ListView(
shrinkWrap: true,
children: [
for (final user in results)
ListTile(
dense: true,
leading: CircleAvatar(child: Text(user.account.substring(0, 1).toUpperCase())),
title: Text(user.account),
subtitle: Text(user.displayName),
trailing: FilledButton.tonal(
onPressed: () => _invite(user),
child: Text(l10n.invite),
return ListView(
shrinkWrap: true,
children: [
for (final user in results)
ListTile(
dense: true,
leading: CircleAvatar(
child: Text(
user.displayName.trim().isNotEmpty
? user.displayName.trim().substring(0, 1).toUpperCase()
: user.account.substring(0, 1).toUpperCase(),
),
),
],
),
);
}
Widget _memberList(AppLocalizations l10n) {
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
if (_items.isEmpty) {
if (_errorCode != null) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.cloud_off, size: 48),
const SizedBox(height: 12),
Text(l10n.messageForErrorCode(_errorCode!)),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _reload,
icon: const Icon(Icons.refresh),
label: Text(l10n.retry),
),
],
title: Text(user.displayName),
subtitle: Text(user.account),
trailing: _isMember(user)
? Text(
l10n.alreadyAdded,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
)
: FilledButton.tonal(
onPressed: () => _invite(user),
child: Text(l10n.add),
),
),
);
}
return Center(child: Text(l10n.albumMembersEmpty));
}
return 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());
}
final member = _items[index];
final isSelf = member.account == _selfAccount;
return ListTile(
leading: CircleAvatar(
child: Text(
member.account.substring(0, 1).toUpperCase(),
),
),
title: Text(
isSelf ? '${member.account}${l10n.selfMarker}' : member.account,
),
subtitle: Text(member.displayName),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
_RoleChip(role: member.role),
if (_canRemove(member))
IconButton(
tooltip: l10n.removeMember,
icon: const Icon(Icons.person_remove_outlined),
onPressed: () => _remove(member),
),
],
),
);
},
],
);
}
}
/// 角色标签:owner 附星标(创建者徽标)。
class _RoleChip extends StatelessWidget {
const _RoleChip({required this.role});
/// 首屏错误 + 重试。
class _ErrorRetryView extends StatelessWidget {
const _ErrorRetryView({required this.message, required this.onRetry});
final MemberRole role;
final String message;
final Future<void> Function() onRetry;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final owner = role == MemberRole.owner;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: owner
? theme.colorScheme.tertiaryContainer
: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(999),
),
child: Row(
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (owner)
Icon(
Icons.star,
size: 12,
color: theme.colorScheme.onTertiaryContainer,
),
if (owner) const SizedBox(width: 4),
Text(
owner ? l10n.roleOwner : l10n.roleCollaborator,
style: theme.textTheme.labelSmall?.copyWith(
color: owner
? theme.colorScheme.onTertiaryContainer
: theme.colorScheme.onSurfaceVariant,
),
const Icon(Icons.cloud_off, size: 48),
const SizedBox(height: 12),
Text(message, textAlign: TextAlign.center),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: Text(l10n.retry),
),
],
),