feat(mobile): album management pages with qr share
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,453 @@
|
||||
/// 成员管理页(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<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;
|
||||
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
Timer? _debounce;
|
||||
bool _searching = false;
|
||||
List<AdminUserLookupItem>? _results;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSelfAccount();
|
||||
_reload();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounce?.cancel();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 邀请搜索(防抖 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<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.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<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(
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user