2026-09-22 01:23:02 +08:00
|
|
|
|
/// 成员管理页(UI-03 §4 页面 11 重设计):Owner 单独分组(无移除)+
|
|
|
|
|
|
/// Collaborators 列表(首字母头像、显示名、账号·角色、行尾「⋯」移除);
|
|
|
|
|
|
/// 底部全宽「Invite colleague」打开账号前缀搜索 BottomSheet(300ms 防抖、
|
|
|
|
|
|
/// ≥2 字符、迟到响应守卫、已成员「Already added」态、MEMBER_CONFLICT
|
|
|
|
|
|
/// 本地化)。移除经确认对话框(含成员名)。本人/创建者不可移除;成员
|
|
|
|
|
|
/// 分页保留。
|
2026-09-19 01:56:38 +08:00
|
|
|
|
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';
|
2026-09-22 01:23:02 +08:00
|
|
|
|
import '../ui/components/section_header.dart';
|
|
|
|
|
|
import '../ui/tokens.dart';
|
2026-09-19 01:56:38 +08:00
|
|
|
|
|
|
|
|
|
|
/// 成员管理(从详情页携带完整 [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),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 01:23:02 +08:00
|
|
|
|
// ── 邀请(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);
|
2026-09-19 01:56:38 +08:00
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-09-22 01:23:02 +08:00
|
|
|
|
await ref.read(apiClientProvider).addMember(widget.albumId, user.id);
|
2026-09-19 01:56:38 +08:00
|
|
|
|
if (!mounted) return;
|
2026-09-22 01:23:02 +08:00
|
|
|
|
_addedIds.add(user.id);
|
|
|
|
|
|
setState(() {});
|
2026-09-19 01:56:38 +08:00
|
|
|
|
messenger.showSnackBar(
|
|
|
|
|
|
SnackBar(content: Text(l10n.memberInvited)),
|
|
|
|
|
|
);
|
2026-09-22 01:23:02 +08:00
|
|
|
|
widget.onInvited();
|
2026-09-19 01:56:38 +08:00
|
|
|
|
} 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);
|
2026-09-22 01:23:02 +08:00
|
|
|
|
return Padding(
|
|
|
|
|
|
// 键盘避让:输入框跟随视口内边距上移。
|
|
|
|
|
|
padding: EdgeInsets.fromLTRB(
|
|
|
|
|
|
kSpace16,
|
|
|
|
|
|
0,
|
|
|
|
|
|
kSpace16,
|
|
|
|
|
|
MediaQuery.of(context).viewInsets.bottom + kSpace16,
|
|
|
|
|
|
),
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
2026-09-19 01:56:38 +08:00
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
|
|
|
|
children: [
|
2026-09-22 01:23:02 +08:00
|
|
|
|
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('');
|
|
|
|
|
|
},
|
|
|
|
|
|
)),
|
2026-09-19 01:56:38 +08:00
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-09-22 01:23:02 +08:00
|
|
|
|
const SizedBox(height: kSpace8),
|
|
|
|
|
|
if (_results != null) Flexible(child: _resultsList(l10n)),
|
2026-09-19 01:56:38 +08:00
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 01:23:02 +08:00
|
|
|
|
/// 结果列表(面板高度内滚动;无结果显示占位文案)。
|
|
|
|
|
|
Widget _resultsList(AppLocalizations l10n) {
|
2026-09-19 01:56:38 +08:00
|
|
|
|
final results = _results!;
|
|
|
|
|
|
if (results.isEmpty) {
|
|
|
|
|
|
return Padding(
|
2026-09-22 01:23:02 +08:00
|
|
|
|
padding: const EdgeInsets.symmetric(vertical: kSpace12),
|
2026-09-19 01:56:38 +08:00
|
|
|
|
child: Text(
|
|
|
|
|
|
l10n.inviteNoResults,
|
|
|
|
|
|
textAlign: TextAlign.center,
|
|
|
|
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-09-22 01:23:02 +08:00
|
|
|
|
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(),
|
2026-09-19 01:56:38 +08:00
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-09-22 01:23:02 +08:00
|
|
|
|
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),
|
|
|
|
|
|
),
|
2026-09-19 01:56:38 +08:00
|
|
|
|
),
|
2026-09-22 01:23:02 +08:00
|
|
|
|
],
|
2026-09-19 01:56:38 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 01:23:02 +08:00
|
|
|
|
/// 首屏错误 + 重试。
|
|
|
|
|
|
class _ErrorRetryView extends StatelessWidget {
|
|
|
|
|
|
const _ErrorRetryView({required this.message, required this.onRetry});
|
2026-09-19 01:56:38 +08:00
|
|
|
|
|
2026-09-22 01:23:02 +08:00
|
|
|
|
final String message;
|
|
|
|
|
|
final Future<void> Function() onRetry;
|
2026-09-19 01:56:38 +08:00
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
|
final l10n = AppLocalizations.of(context);
|
2026-09-22 01:23:02 +08:00
|
|
|
|
return Center(
|
|
|
|
|
|
child: Column(
|
2026-09-19 01:56:38 +08:00
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
|
children: [
|
2026-09-22 01:23:02 +08:00
|
|
|
|
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),
|
2026-09-19 01:56:38 +08:00
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|