/// 成员管理页(P3 计划 Task 4):成员列表(角色标签、owner 徽标、 /// 本人/创建者不可移除)+ 账号前缀搜索邀请(300ms 防抖、≥2 字符、 /// lookupUsers 远程搜索)+ 移除确认。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'; /// 成员管理(从详情页携带完整 [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; final TextEditingController _searchController = TextEditingController(); Timer? _debounce; bool _searching = false; List? _results; @override void initState() { super.initState(); _loadSelfAccount(); _reload(); } @override void dispose() { _debounce?.cancel(); _searchController.dispose(); super.dispose(); } 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), ), ); } } } // ── 邀请搜索(防抖 300ms、≥2 字符) ── 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.album.id, user.id, ); if (!mounted) return; messenger.showSnackBar( SnackBar(content: Text(l10n.memberInvited)), ); _searchController.clear(); setState(() => _results = null); await _reload(); } 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)), ); } } Future _remove(Member member) async { final l10n = AppLocalizations.of(context); final confirmed = await showDialog( 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( 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(''); }, )), ), ), ), if (_results != null) _searchResults(l10n), const Divider(height: 1, indent: 16, endIndent: 16), Expanded(child: _memberList(l10n)), ], ), ); } /// 搜索结果面板(有限高度内滚动;无结果显示占位文案)。 Widget _searchResults(AppLocalizations l10n) { final results = _results!; if (results.isEmpty) { return Padding( padding: const EdgeInsets.symmetric(vertical: 12), child: Text( l10n.inviteNoResults, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall, ), ); } 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), ), ), ], ), ); } 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), ), ], ), ); } 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}); final MemberRole role; @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( 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, ), ), ], ), ); } }