/// 成员管理页(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 createState() => _AlbumMembersPageState(); } class _AlbumMembersPageState extends ConsumerState { static const int _pageSize = 50; List _items = const []; String? _nextCursor; bool _hasMore = false; bool _loading = true; bool _loadingMore = false; String? _errorCode; /// 当前登录账号(本人移除 UI 拦截用);读取失败按未知处理。 String? _selfAccount; @override void initState() { super.initState(); _loadSelfAccount(); _reload(); } Future _loadSelfAccount() async { try { final session = await ref.read(sessionStoreProvider).read(); if (mounted) { setState(() => _selfAccount = session?.account); } } on Object { // 存储读取失败不阻断页面(移除按钮保护回退服务端 403/409 裁决)。 } } Future _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 _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 = [..._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 _openInviteSheet() async { await showModalBottomSheet( context: context, showDragHandle: true, isScrollControlled: true, useSafeArea: true, builder: (_) => AlbumInviteSheet( albumId: widget.album.id, // 打开时的既有成员(已添加态判定;面板内邀请成功后本地累加)。 memberUserIds: { for (final m in _items) m.userId, }, memberAccounts: { for (final m in _items) m.account, }, onInvited: _reload, ), ); } // ── 移除(行尾「⋯」→ 确认对话框) ───────────────────────────── Future _remove(Member member) async { final l10n = AppLocalizations.of(context); final name = member.displayName.trim().isNotEmpty ? member.displayName.trim() : member.account; final confirmed = await showDialog( 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 owners, List 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 memberUserIds; /// 打开面板时的成员账号集合(按账号兜底判重)。 final Set memberAccounts; /// 邀请成功回调(上级刷新成员列表)。 final VoidCallback onInvited; @override ConsumerState createState() => _AlbumInviteSheetState(); } class _AlbumInviteSheetState extends ConsumerState { final TextEditingController _searchController = TextEditingController(); Timer? _debounce; bool _searching = false; List? _results; /// 面板生命周期内新增的成员(已添加态即时生效)。 final Set _addedIds = {}; @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 _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 _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 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), ), ], ), ); } }