Files
photo-live/apps/mobile/lib/pages/album_members_page.dart
T
浅念 8bd09eefb9 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>
2026-09-22 01:23:02 +08:00

606 lines
19 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 成员管理页(UI-03 §4 页面 11 重设计):Owner 单独分组(无移除)+
/// Collaborators 列表(首字母头像、显示名、账号·角色、行尾「⋯」移除);
/// 底部全宽「Invite colleague」打开账号前缀搜索 BottomSheet(300ms 防抖、
/// ≥2 字符、迟到响应守卫、已成员「Already added」态、MEMBER_CONFLICT
/// 本地化)。移除经确认对话框(含成员名)。本人/创建者不可移除;成员
/// 分页保留。
library;
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/api_client.dart';
import '../core/app_exception.dart';
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 {
const AlbumMembersPage({required this.album, super.key});
final Album album;
@override
ConsumerState<AlbumMembersPage> createState() => _AlbumMembersPageState();
}
class _AlbumMembersPageState extends ConsumerState<AlbumMembersPage> {
static const int _pageSize = 50;
List<Member> _items = const <Member>[];
String? _nextCursor;
bool _hasMore = false;
bool _loading = true;
bool _loadingMore = false;
String? _errorCode;
/// 当前登录账号(本人移除 UI 拦截用);读取失败按未知处理。
String? _selfAccount;
@override
void initState() {
super.initState();
_loadSelfAccount();
_reload();
}
Future<void> _loadSelfAccount() async {
try {
final session = await ref.read(sessionStoreProvider).read();
if (mounted) {
setState(() => _selfAccount = session?.account);
}
} on Object {
// 存储读取失败不阻断页面(移除按钮保护回退服务端 403/409 裁决)。
}
}
Future<void> _reload() async {
setState(() {
_loading = true;
_errorCode = null;
});
try {
final page = await ref
.read(apiClientProvider)
.listMembers(widget.album.id, limit: _pageSize);
if (mounted) {
setState(() {
_items = page.items;
_nextCursor = page.nextCursor;
_hasMore = page.hasMore;
_loading = false;
});
}
} on AppException catch (e) {
if (mounted) {
setState(() {
_errorCode = e.code;
_loading = false;
});
}
} on Object {
if (mounted) {
setState(() {
_errorCode = ErrorCodes.internalError;
_loading = false;
});
}
}
}
Future<void> _loadMore() async {
if (_loading || _loadingMore || !_hasMore) return;
setState(() => _loadingMore = true);
try {
final page = await ref.read(apiClientProvider).listMembers(
widget.album.id,
cursor: _nextCursor,
limit: _pageSize,
);
if (mounted) {
setState(() {
_items = <Member>[..._items, ...page.items];
_nextCursor = page.nextCursor;
_hasMore = page.hasMore;
_loadingMore = false;
});
}
} on AppException catch (e) {
if (mounted) {
setState(() => _loadingMore = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context).messageForError(e))),
);
}
} on Object {
if (mounted) {
setState(() => _loadingMore = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).errorGeneric),
),
);
}
}
}
// ── 邀请(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();
final query = text.trim();
if (query.length < 2) {
setState(() {
_searching = false;
_results = null;
});
return;
}
_debounce = Timer(const Duration(milliseconds: 300), () {
_runSearch(query);
});
}
Future<void> _runSearch(String query) async {
setState(() => _searching = true);
try {
final items = await ref
.read(apiClientProvider)
.lookupUsers(query, limit: 10);
// 迟到响应守卫:仅当输入未再变化时应用结果。
if (!mounted || query != _searchController.text.trim()) return;
setState(() {
_results = items;
_searching = false;
});
} on AppException catch (e) {
if (!mounted || query != _searchController.text.trim()) return;
setState(() => _searching = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).messageForError(e)),
),
);
} on Object {
if (!mounted || query != _searchController.text.trim()) return;
setState(() => _searching = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context).errorGeneric)),
);
}
}
Future<void> _invite(AdminUserLookupItem user) async {
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
try {
await ref.read(apiClientProvider).addMember(widget.albumId, user.id);
if (!mounted) return;
_addedIds.add(user.id);
setState(() {});
messenger.showSnackBar(
SnackBar(content: Text(l10n.memberInvited)),
);
widget.onInvited();
} on AppException catch (e) {
messenger.showSnackBar(
SnackBar(
content: Text(
e.code == ErrorCodes.memberConflict
? l10n.errorMemberConflict
: l10n.messageForError(e),
),
),
);
} on Object {
messenger.showSnackBar(
SnackBar(content: Text(l10n.errorGeneric)),
);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Padding(
// 键盘避让:输入框跟随视口内边距上移。
padding: EdgeInsets.fromLTRB(
kSpace16,
0,
kSpace16,
MediaQuery.of(context).viewInsets.bottom + kSpace16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
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('');
},
)),
),
),
const SizedBox(height: kSpace8),
if (_results != null) Flexible(child: _resultsList(l10n)),
],
),
);
}
/// 结果列表(面板高度内滚动;无结果显示占位文案)。
Widget _resultsList(AppLocalizations l10n) {
final results = _results!;
if (results.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: kSpace12),
child: Text(
l10n.inviteNoResults,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
);
}
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(),
),
),
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),
),
),
],
);
}
}
/// 首屏错误 + 重试。
class _ErrorRetryView extends StatelessWidget {
const _ErrorRetryView({required this.message, required this.onRetry});
final String message;
final Future<void> Function() onRetry;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
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),
),
],
),
);
}
}