feat(mobile): album management pages with qr share
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,740 @@
|
||||
/// 相册详情页(P3 计划 Task 4):信息区(标题/时间/时区/令牌/开关标签)
|
||||
/// + 动作行(编辑、成员管理、分类管理=滚动定位、分享二维码、照片管理
|
||||
/// 占位)+ 分类管理区(列表 + 新增/编辑对话框 + 删除确认)。
|
||||
/// 编辑与分类变更后刷新本地详情并同步全局相册列表。
|
||||
library;
|
||||
|
||||
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/album_format.dart';
|
||||
import '../features/albums/models.dart';
|
||||
import '../features/albums/providers.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import 'album_form_page.dart';
|
||||
import 'album_members_page.dart';
|
||||
import 'album_photos_page.dart';
|
||||
import 'share_qr_dialog.dart';
|
||||
|
||||
/// 相册详情([albumId] 路由参数;进入后 GET /albums/{id})。
|
||||
class AlbumDetailPage extends ConsumerStatefulWidget {
|
||||
const AlbumDetailPage({required this.albumId, super.key});
|
||||
|
||||
final String albumId;
|
||||
|
||||
@override
|
||||
ConsumerState<AlbumDetailPage> createState() => _AlbumDetailPageState();
|
||||
}
|
||||
|
||||
class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
final GlobalKey _categoriesKey = GlobalKey();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
Album? _album;
|
||||
bool _loading = true;
|
||||
String? _errorCode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_errorCode = null;
|
||||
});
|
||||
try {
|
||||
final album = await ref.read(apiClientProvider).getAlbum(
|
||||
widget.albumId,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_album = album;
|
||||
_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> _openEdit() async {
|
||||
final album = _album;
|
||||
if (album == null) return;
|
||||
final changed = await Navigator.of(context).push<bool>(
|
||||
MaterialPageRoute<bool>(
|
||||
builder: (_) => AlbumFormPage(existing: album),
|
||||
),
|
||||
);
|
||||
if (changed == true) {
|
||||
await _load();
|
||||
ref.read(albumListProvider.notifier).refresh();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openMembers() async {
|
||||
final album = _album;
|
||||
if (album == null) return;
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => AlbumMembersPage(album: album),
|
||||
),
|
||||
);
|
||||
// 成员变更不影响详情数据,回来无需刷新。
|
||||
}
|
||||
|
||||
Future<void> _openPhotos() async {
|
||||
final album = _album;
|
||||
if (album == null) return;
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => AlbumPhotosPage(album: album),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _shareQr() {
|
||||
final album = _album;
|
||||
if (album == null) return;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => ShareQrDialog(publicToken: album.publicToken),
|
||||
);
|
||||
}
|
||||
|
||||
void _scrollToCategories() {
|
||||
final ctx = _categoriesKey.currentContext;
|
||||
if (ctx != null) {
|
||||
Scrollable.ensureVisible(
|
||||
ctx,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteCategory(Category category) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: Text(l10n.delete),
|
||||
content: Text(l10n.deleteConfirmCategory),
|
||||
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)
|
||||
.deleteCategory(category.id);
|
||||
await _load();
|
||||
} on AppException catch (e) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.messageForError(e))),
|
||||
);
|
||||
} on Object {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.errorGeneric)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openCategoryDialog([Category? existing]) async {
|
||||
final album = _album;
|
||||
if (album == null) return;
|
||||
final changed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => CategoryEditDialog(albumId: album.id, existing: existing),
|
||||
);
|
||||
if (changed == true) await _load();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final album = _album;
|
||||
final title = album == null ? l10n.albumDetail : _titleOf(album);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(title)),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: album == null
|
||||
? _ErrorRetryView(
|
||||
message: l10n.messageForErrorCode(_errorCode!),
|
||||
onRetry: _load,
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_InfoCard(album: album),
|
||||
const SizedBox(height: 16),
|
||||
_ActionsRow(
|
||||
onEdit: _openEdit,
|
||||
onMembers: _openMembers,
|
||||
onCategories: _scrollToCategories,
|
||||
onShareQr: _shareQr,
|
||||
onPhotos: _openPhotos,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_CategoriesSection(
|
||||
key: _categoriesKey,
|
||||
album: album,
|
||||
onAdd: () => _openCategoryDialog(),
|
||||
onEdit: (c) => _openCategoryDialog(c),
|
||||
onDelete: _deleteCategory,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _titleOf(Album album) {
|
||||
for (final t in album.translations) {
|
||||
if (t.locale == album.defaultLocale) return t.title;
|
||||
}
|
||||
if (album.translations.isNotEmpty) return album.translations.first.title;
|
||||
return album.id;
|
||||
}
|
||||
}
|
||||
|
||||
/// 首屏错误 + 重试。
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 信息卡:标题 + 状态、默认语言内容(描述/场地/地址)、时区时间区间、
|
||||
/// 公开令牌、四开关标签。
|
||||
class _InfoCard extends StatelessWidget {
|
||||
const _InfoCard({required this.album});
|
||||
|
||||
final Album album;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final translation = album.translations
|
||||
.where((t) => t.locale == album.defaultLocale)
|
||||
.firstOrNull;
|
||||
final start =
|
||||
formatAlbumDateTime(album.startAt, album.timezone, l10n.localeName);
|
||||
final end = album.endAt == null
|
||||
? l10n.notSet
|
||||
: formatAlbumDateTime(album.endAt!, album.timezone, l10n.localeName);
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
translation?.title ?? album.id,
|
||||
style: theme.textTheme.titleLarge
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_AlbumStatusBadge(status: album.status),
|
||||
],
|
||||
),
|
||||
if ((translation?.description ?? '').trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(translation!.description!),
|
||||
],
|
||||
if ((translation?.venue ?? '').trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.place_outlined, size: 16),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(child: Text(translation!.venue!)),
|
||||
],
|
||||
),
|
||||
],
|
||||
if ((translation?.address ?? '').trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.signpost_outlined, size: 16),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(child: Text(translation!.address!)),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.schedule, size: 16),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(child: Text('$start ~ $end (${album.timezone})')),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.link, size: 16),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
album.publicToken,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
_switchTag(context, l10n.tagUpload, album.uploadEnabled),
|
||||
_switchTag(context, l10n.tagPublic, album.publicEnabled),
|
||||
_switchTag(
|
||||
context,
|
||||
l10n.tagDownload,
|
||||
album.downloadEnabled,
|
||||
),
|
||||
_switchTag(
|
||||
context,
|
||||
l10n.tagOriginal,
|
||||
album.originalDownloadEnabled,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _switchTag(BuildContext context, String label, bool on) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: on
|
||||
? theme.colorScheme.primaryContainer
|
||||
: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: on
|
||||
? theme.colorScheme.onPrimaryContainer
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 动作行:编辑 / 成员管理 / 分类管理(滚动定位) / 分享二维码 / 照片管理。
|
||||
class _ActionsRow extends StatelessWidget {
|
||||
const _ActionsRow({
|
||||
required this.onEdit,
|
||||
required this.onMembers,
|
||||
required this.onCategories,
|
||||
required this.onShareQr,
|
||||
required this.onPhotos,
|
||||
});
|
||||
|
||||
final VoidCallback onEdit;
|
||||
final VoidCallback onMembers;
|
||||
final VoidCallback onCategories;
|
||||
final VoidCallback onShareQr;
|
||||
final VoidCallback onPhotos;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: onEdit,
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
label: Text(l10n.actionEdit),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: onMembers,
|
||||
icon: const Icon(Icons.group_outlined),
|
||||
label: Text(l10n.albumMembers),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: onCategories,
|
||||
icon: const Icon(Icons.category_outlined),
|
||||
label: Text(l10n.albumCategories),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: onShareQr,
|
||||
icon: const Icon(Icons.qr_code_2),
|
||||
label: Text(l10n.shareQrTitle),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: onPhotos,
|
||||
icon: const Icon(Icons.photo_outlined),
|
||||
label: Text(l10n.albumPhotos),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 分类管理区:列表(点击编辑、尾随删除)+ 新增入口。
|
||||
class _CategoriesSection extends StatelessWidget {
|
||||
const _CategoriesSection({
|
||||
super.key,
|
||||
required this.album,
|
||||
required this.onAdd,
|
||||
required this.onEdit,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
final Album album;
|
||||
final VoidCallback onAdd;
|
||||
final void Function(Category category) onEdit;
|
||||
final Future<void> Function(Category category) onDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.albumCategories,
|
||||
style: theme.textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.categoryAdd,
|
||||
onPressed: onAdd,
|
||||
icon: const Icon(Icons.add_circle_outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (album.categories.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
l10n.categoryEmpty,
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
)
|
||||
else
|
||||
Card(
|
||||
child: Column(
|
||||
children: [
|
||||
for (final (i, category) in album.categories.indexed) ...[
|
||||
if (i > 0) const Divider(height: 1, indent: 16, endIndent: 16),
|
||||
ListTile(
|
||||
dense: true,
|
||||
leading: SizedBox(
|
||||
width: 40,
|
||||
child: Text(
|
||||
'${category.sortOrder}',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(_namesOf(category)),
|
||||
onTap: () => onEdit(category),
|
||||
trailing: IconButton(
|
||||
tooltip: l10n.delete,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: () => onDelete(category),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 分类名展示:zh / en 名称以「 · 」连接。
|
||||
static String _namesOf(Category category) {
|
||||
final names = category.translations.map((t) => t.name).toList();
|
||||
return names.isEmpty ? category.id : names.join(' · ');
|
||||
}
|
||||
}
|
||||
|
||||
/// 状态徽标(详情信息卡用)。
|
||||
class _AlbumStatusBadge extends StatelessWidget {
|
||||
const _AlbumStatusBadge({required this.status});
|
||||
|
||||
final AlbumStatus status;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final active = status == AlbumStatus.active;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: active
|
||||
? theme.colorScheme.secondaryContainer
|
||||
: theme.colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
active ? l10n.albumStatusActive : l10n.albumStatusOffline,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: active
|
||||
? theme.colorScheme.onSecondaryContainer
|
||||
: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 分类新增/编辑对话框:排序序号 + 中文名 + 英文名(至少一个必填)。
|
||||
///
|
||||
/// 成功 pop(true);校验失败就地提示;API 失败 SnackBar(对话框保持)。
|
||||
class CategoryEditDialog extends ConsumerStatefulWidget {
|
||||
const CategoryEditDialog({
|
||||
required this.albumId,
|
||||
this.existing,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String albumId;
|
||||
|
||||
/// 编辑目标;null = 新增。
|
||||
final Category? existing;
|
||||
|
||||
@override
|
||||
ConsumerState<CategoryEditDialog> createState() => _CategoryEditDialogState();
|
||||
}
|
||||
|
||||
class _CategoryEditDialogState extends ConsumerState<CategoryEditDialog> {
|
||||
late final TextEditingController _sortOrder =
|
||||
TextEditingController(text: (widget.existing?.sortOrder ?? 0).toString());
|
||||
late final TextEditingController _zhName = TextEditingController(
|
||||
text: widget.existing?.translations
|
||||
.where((t) => t.locale == 'zh-CN')
|
||||
.firstOrNull
|
||||
?.name ??
|
||||
'',
|
||||
);
|
||||
late final TextEditingController _enName = TextEditingController(
|
||||
text: widget.existing?.translations
|
||||
.where((t) => t.locale == 'en')
|
||||
.firstOrNull
|
||||
?.name ??
|
||||
'',
|
||||
);
|
||||
|
||||
String? _error;
|
||||
bool _submitting = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sortOrder.dispose();
|
||||
_zhName.dispose();
|
||||
_enName.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final zh = _zhName.text.trim();
|
||||
final en = _enName.text.trim();
|
||||
if (zh.isEmpty && en.isEmpty) {
|
||||
setState(() => _error = l10n.categoryNameRequired);
|
||||
return;
|
||||
}
|
||||
final sortOrder = int.tryParse(_sortOrder.text.trim());
|
||||
if (sortOrder == null) {
|
||||
setState(() => _error = l10n.categorySortOrderInvalid);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_error = null;
|
||||
_submitting = true;
|
||||
});
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final navigator = Navigator.of(context);
|
||||
final translations = <CategoryTranslation>[
|
||||
if (zh.isNotEmpty) CategoryTranslation(locale: 'zh-CN', name: zh),
|
||||
if (en.isNotEmpty) CategoryTranslation(locale: 'en', name: en),
|
||||
];
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
if (widget.existing == null) {
|
||||
await api.createCategory(
|
||||
widget.albumId,
|
||||
CategoryCreateRequest(
|
||||
sortOrder: sortOrder,
|
||||
translations: translations,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await api.updateCategory(
|
||||
widget.existing!.id,
|
||||
CategoryUpdateRequest(
|
||||
sortOrder: sortOrder,
|
||||
translations: translations,
|
||||
),
|
||||
);
|
||||
}
|
||||
navigator.pop(true);
|
||||
} on AppException catch (e) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.messageForError(e))),
|
||||
);
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
} on Object {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.errorGeneric)),
|
||||
);
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return AlertDialog(
|
||||
title: Text(widget.existing == null ? l10n.categoryAdd : l10n.categoryEdit),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _sortOrder,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.categorySortOrder,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _zhName,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.categoryNameZh,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _enName,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.categoryNameEn,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_error!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _submitting ? null : () => Navigator.of(context).pop(),
|
||||
child: Text(l10n.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _submitting ? null : _submit,
|
||||
child: _submitting
|
||||
? const SizedBox(
|
||||
height: 18,
|
||||
width: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(l10n.save),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
/// 相册创建/编辑表单页(P3 计划 Task 4)。
|
||||
///
|
||||
/// zh-CN / en 双语 tab(标题/描述/场地/地址)、默认语言分段选择、
|
||||
/// 日期时间选择器(开始必填;结束可选、可显式清除)、时区文本(缺省
|
||||
/// Asia/Shanghai)、开关组:创建仅 download/original(契约缺省
|
||||
/// upload/public=true 不可创建时覆盖),编辑为四开关独立。
|
||||
/// 编辑提交经 [AlbumFormDraft.toUpdatePatch] 最小化 PATCH(仅变更字段);
|
||||
/// 无变更时保存禁用。成功 pop(true) 供调用方刷新列表/详情。
|
||||
library;
|
||||
|
||||
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_l10n.dart';
|
||||
import '../features/albums/album_format.dart';
|
||||
import '../features/albums/album_form.dart';
|
||||
import '../features/albums/models.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
/// 创建([existing] 为 null)/ 编辑相册表单。
|
||||
class AlbumFormPage extends ConsumerStatefulWidget {
|
||||
const AlbumFormPage({this.existing, this.initialDraft, super.key});
|
||||
|
||||
/// 编辑目标;null = 创建模式。
|
||||
final Album? existing;
|
||||
|
||||
/// 初始草稿(测试注入/预填);缺省从 [existing] 构建或全新(创建)。
|
||||
final AlbumFormDraft? initialDraft;
|
||||
|
||||
@override
|
||||
ConsumerState<AlbumFormPage> createState() => _AlbumFormPageState();
|
||||
}
|
||||
|
||||
class _AlbumFormPageState extends ConsumerState<AlbumFormPage> {
|
||||
static const String _zh = 'zh-CN';
|
||||
static const String _en = 'en';
|
||||
|
||||
late final List<TextEditingController> _controllers;
|
||||
|
||||
late final TextEditingController _zhTitle;
|
||||
late final TextEditingController _zhDescription;
|
||||
late final TextEditingController _zhVenue;
|
||||
late final TextEditingController _zhAddress;
|
||||
late final TextEditingController _enTitle;
|
||||
late final TextEditingController _enDescription;
|
||||
late final TextEditingController _enVenue;
|
||||
late final TextEditingController _enAddress;
|
||||
late final TextEditingController _timezone;
|
||||
|
||||
late String _defaultLocale;
|
||||
DateTime? _startAt;
|
||||
DateTime? _endAt;
|
||||
late bool _uploadEnabled;
|
||||
late bool _publicEnabled;
|
||||
late bool _downloadEnabled;
|
||||
late bool _originalDownloadEnabled;
|
||||
|
||||
int _tabIndex = 0;
|
||||
List<AlbumFormError> _errors = const <AlbumFormError>[];
|
||||
bool _submitting = false;
|
||||
|
||||
bool get _isCreate => widget.existing == null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final initial = widget.existing == null
|
||||
? (widget.initialDraft ??
|
||||
AlbumFormDraft(
|
||||
zh: const AlbumTranslationDraft(locale: _zh),
|
||||
en: const AlbumTranslationDraft(locale: _en),
|
||||
defaultLocale: _zh,
|
||||
timezone: 'Asia/Shanghai',
|
||||
))
|
||||
: (widget.initialDraft ?? AlbumFormDraft.fromAlbum(widget.existing!));
|
||||
_zhTitle = TextEditingController(text: initial.zh.title);
|
||||
_zhDescription = TextEditingController(text: initial.zh.description);
|
||||
_zhVenue = TextEditingController(text: initial.zh.venue);
|
||||
_zhAddress = TextEditingController(text: initial.zh.address);
|
||||
_enTitle = TextEditingController(text: initial.en.title);
|
||||
_enDescription = TextEditingController(text: initial.en.description);
|
||||
_enVenue = TextEditingController(text: initial.en.venue);
|
||||
_enAddress = TextEditingController(text: initial.en.address);
|
||||
_timezone = TextEditingController(text: initial.timezone);
|
||||
_controllers = [
|
||||
_zhTitle,
|
||||
_zhDescription,
|
||||
_zhVenue,
|
||||
_zhAddress,
|
||||
_enTitle,
|
||||
_enDescription,
|
||||
_enVenue,
|
||||
_enAddress,
|
||||
_timezone,
|
||||
];
|
||||
_defaultLocale = initial.defaultLocale;
|
||||
_startAt = initial.startAt;
|
||||
_endAt = initial.endAt;
|
||||
_uploadEnabled = initial.uploadEnabled;
|
||||
_publicEnabled = initial.publicEnabled;
|
||||
_downloadEnabled = initial.downloadEnabled;
|
||||
_originalDownloadEnabled = initial.originalDownloadEnabled;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final c in _controllers) {
|
||||
c.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
AlbumFormDraft _currentDraft() => AlbumFormDraft(
|
||||
zh: AlbumTranslationDraft(
|
||||
locale: _zh,
|
||||
title: _zhTitle.text,
|
||||
description: _zhDescription.text,
|
||||
venue: _zhVenue.text,
|
||||
address: _zhAddress.text,
|
||||
),
|
||||
en: AlbumTranslationDraft(
|
||||
locale: _en,
|
||||
title: _enTitle.text,
|
||||
description: _enDescription.text,
|
||||
venue: _enVenue.text,
|
||||
address: _enAddress.text,
|
||||
),
|
||||
defaultLocale: _defaultLocale,
|
||||
timezone: _timezone.text,
|
||||
startAt: _startAt,
|
||||
endAt: _endAt,
|
||||
uploadEnabled: _uploadEnabled,
|
||||
publicEnabled: _publicEnabled,
|
||||
downloadEnabled: _downloadEnabled,
|
||||
originalDownloadEnabled: _originalDownloadEnabled,
|
||||
);
|
||||
|
||||
/// 编辑模式:与原值无任何差异时禁用保存(PATCH minProperties 1)。
|
||||
bool get _canSubmit {
|
||||
if (_submitting) return false;
|
||||
if (_isCreate) return true;
|
||||
return _currentDraft().toUpdatePatch(widget.existing!) != null;
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final draft = _currentDraft();
|
||||
final errors = draft.validate();
|
||||
if (errors.isNotEmpty) {
|
||||
setState(() => _errors = errors);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_submitting = true;
|
||||
_errors = const <AlbumFormError>[];
|
||||
});
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final navigator = Navigator.of(context);
|
||||
try {
|
||||
if (_isCreate) {
|
||||
await ref.read(apiClientProvider).createAlbum(draft.toCreateRequest());
|
||||
} else {
|
||||
final patch = draft.toUpdatePatch(widget.existing!);
|
||||
if (patch == null) {
|
||||
navigator.pop(false);
|
||||
return;
|
||||
}
|
||||
await ref
|
||||
.read(apiClientProvider)
|
||||
.updateAlbum(widget.existing!.id, patch);
|
||||
}
|
||||
navigator.pop(true);
|
||||
} on AppException catch (error) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.messageForError(error))),
|
||||
);
|
||||
} on Object {
|
||||
messenger.showSnackBar(SnackBar(content: Text(l10n.errorGeneric)));
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 日期 + 时间选择器 → 相册时区墙钟 → UTC 瞬间。
|
||||
Future<void> _pickDateTime({required bool isStart}) async {
|
||||
final tzName = _timezone.text.trim();
|
||||
final initial = (isStart ? _startAt : _endAt) ?? _startAt ?? DateTime.now();
|
||||
final wall = tryToAlbumZone(initial, tzName) ?? initial.toUtc();
|
||||
final pickedDate = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: wall,
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime(2100),
|
||||
);
|
||||
if (pickedDate == null || !mounted) return;
|
||||
final pickedTime = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay(hour: wall.hour, minute: wall.minute),
|
||||
);
|
||||
if (pickedTime == null || !mounted) return;
|
||||
final naive = DateTime(
|
||||
pickedDate.year,
|
||||
pickedDate.month,
|
||||
pickedDate.day,
|
||||
pickedTime.hour,
|
||||
pickedTime.minute,
|
||||
);
|
||||
final utc = albumWallClockToUtc(naive, tzName);
|
||||
setState(() {
|
||||
if (isStart) {
|
||||
_startAt = utc;
|
||||
} else {
|
||||
_endAt = utc;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
// DefaultTabController 仅为 TabBar 提供 controller(内容区非
|
||||
// TabBarView,而是按 _tabIndex 手动切换,避免两 tab 表单失焦重建)。
|
||||
return DefaultTabController(
|
||||
length: 2,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_isCreate ? l10n.albumCreate : l10n.albumEdit),
|
||||
bottom: TabBar(
|
||||
onTap: (index) => setState(() => _tabIndex = index),
|
||||
tabs: [
|
||||
Tab(text: l10n.localeChinese),
|
||||
Tab(text: l10n.localeEnglish),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (_tabIndex == 0)
|
||||
_translationFields(
|
||||
title: _zhTitle,
|
||||
description: _zhDescription,
|
||||
venue: _zhVenue,
|
||||
address: _zhAddress,
|
||||
isDefault: _defaultLocale == _zh,
|
||||
)
|
||||
else
|
||||
_translationFields(
|
||||
title: _enTitle,
|
||||
description: _enDescription,
|
||||
venue: _enVenue,
|
||||
address: _enAddress,
|
||||
isDefault: _defaultLocale == _en,
|
||||
),
|
||||
const Divider(height: 32),
|
||||
Text(
|
||||
l10n.albumDefaultLocale,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SegmentedButton<String>(
|
||||
segments: [
|
||||
ButtonSegment(value: _zh, label: Text(l10n.localeChinese)),
|
||||
ButtonSegment(value: _en, label: Text(l10n.localeEnglish)),
|
||||
],
|
||||
selected: {_defaultLocale},
|
||||
onSelectionChanged: (selection) =>
|
||||
setState(() => _defaultLocale = selection.first),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_dateTimeRow(isStart: true),
|
||||
_dateTimeRow(isStart: false),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
controller: _timezone,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.albumTimezone,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (!_isCreate) ...[
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(l10n.switchUpload),
|
||||
value: _uploadEnabled,
|
||||
onChanged: (v) => setState(() => _uploadEnabled = v),
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(l10n.switchPublic),
|
||||
value: _publicEnabled,
|
||||
onChanged: (v) => setState(() => _publicEnabled = v),
|
||||
),
|
||||
],
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(l10n.switchDownload),
|
||||
value: _downloadEnabled,
|
||||
onChanged: (v) => setState(() => _downloadEnabled = v),
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(l10n.switchOriginalDownload),
|
||||
value: _originalDownloadEnabled,
|
||||
onChanged: (v) =>
|
||||
setState(() => _originalDownloadEnabled = v),
|
||||
),
|
||||
if (_errors.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
..._errors.map(
|
||||
(e) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
_errorText(l10n, e),
|
||||
style: TextStyle(color: theme.colorScheme.error),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: _canSubmit ? _submit : null,
|
||||
child: _submitting
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(_isCreate ? l10n.create : l10n.save),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _translationFields({
|
||||
required TextEditingController title,
|
||||
required TextEditingController description,
|
||||
required TextEditingController venue,
|
||||
required TextEditingController address,
|
||||
required bool isDefault,
|
||||
}) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: title,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: InputDecoration(
|
||||
labelText: isDefault ? '${l10n.albumTitle} *' : l10n.albumTitle,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: description,
|
||||
onChanged: (_) => setState(() {}),
|
||||
maxLines: 2,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.albumDescription,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: venue,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.albumVenue,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: address,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.albumAddress,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 开始(必填)/ 结束(可选、可清除)时间行;展示按当前时区输入换算。
|
||||
Widget _dateTimeRow({required bool isStart}) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final value = isStart ? _startAt : _endAt;
|
||||
final formatted = value == null
|
||||
? null
|
||||
: formatAlbumDateTime(value, _timezone.text, l10n.localeName);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: InkWell(
|
||||
onTap: () => _submitting ? null : _pickDateTime(isStart: isStart),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: isStart ? l10n.albumStartAt : l10n.albumEndAtOptional,
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: value == null
|
||||
? const Icon(Icons.edit_calendar_outlined)
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.edit_calendar_outlined),
|
||||
if (!isStart)
|
||||
IconButton(
|
||||
tooltip: l10n.clearEndAt,
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: _submitting
|
||||
? null
|
||||
: () => setState(() => _endAt = null),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
formatted ?? (isStart ? l10n.formStartAtRequired : l10n.notSet),
|
||||
style: formatted == null
|
||||
? theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _errorText(AppLocalizations l10n, AlbumFormError error) {
|
||||
switch (error) {
|
||||
case AlbumFormError.defaultLocaleMissing:
|
||||
return l10n.formDefaultLocaleMissing;
|
||||
case AlbumFormError.titleRequiredWithFields:
|
||||
return l10n.formTitleRequiredWithFields;
|
||||
case AlbumFormError.startAtRequired:
|
||||
return l10n.formStartAtRequired;
|
||||
case AlbumFormError.endAtNotAfterStartAt:
|
||||
return l10n.formEndAtNotAfterStartAt;
|
||||
case AlbumFormError.timezoneInvalid:
|
||||
return l10n.albumTimezoneInvalid;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
/// 照片管理占位页(P3 计划 Task 4):基础管理列表(可见性/处理状态
|
||||
/// 标签 + 游标加载更多);完整筛选与操作由 Task 9 队列 UI 替换。
|
||||
library;
|
||||
|
||||
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/album_format.dart';
|
||||
import '../features/albums/models.dart';
|
||||
import '../features/photos/models.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
/// 照片管理占位(从详情页携带 [album] 进入,时间按相册时区展示)。
|
||||
class AlbumPhotosPage extends ConsumerStatefulWidget {
|
||||
const AlbumPhotosPage({required this.album, super.key});
|
||||
|
||||
final Album album;
|
||||
|
||||
@override
|
||||
ConsumerState<AlbumPhotosPage> createState() => _AlbumPhotosPageState();
|
||||
}
|
||||
|
||||
class _AlbumPhotosPageState extends ConsumerState<AlbumPhotosPage> {
|
||||
static const int _pageSize = 20;
|
||||
|
||||
List<AdminPhoto> _items = const <AdminPhoto>[];
|
||||
String? _nextCursor;
|
||||
bool _hasMore = false;
|
||||
bool _loading = true;
|
||||
bool _loadingMore = false;
|
||||
String? _errorCode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_reload();
|
||||
}
|
||||
|
||||
Future<void> _reload() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_errorCode = null;
|
||||
});
|
||||
try {
|
||||
final page = await ref.read(apiClientProvider).listAlbumPhotos(
|
||||
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).listAlbumPhotos(
|
||||
widget.album.id,
|
||||
cursor: _nextCursor,
|
||||
limit: _pageSize,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_items = <AdminPhoto>[..._items, ...page.items];
|
||||
_nextCursor = page.nextCursor;
|
||||
_hasMore = page.hasMore;
|
||||
_loadingMore = false;
|
||||
});
|
||||
}
|
||||
} on AppException {
|
||||
if (mounted) {
|
||||
setState(() => _loadingMore = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
AppLocalizations.of(context).messageForErrorCode(
|
||||
ErrorCodes.networkError,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} on Object {
|
||||
if (mounted) {
|
||||
setState(() => _loadingMore = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context).errorGeneric),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(l10n.albumPhotos)),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Task 9 占位横幅:完整筛选/操作届时替换本页。
|
||||
Card(
|
||||
margin: const EdgeInsets.fromLTRB(12, 12, 12, 0),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.construction_outlined,
|
||||
size: 18,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.photosTask9Placeholder,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _items.isEmpty && _errorCode != null
|
||||
? 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: _items.length +
|
||||
(_items.isEmpty || _hasMore || _loadingMore ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (_items.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Text(
|
||||
l10n.albumPhotosEmpty,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
);
|
||||
}
|
||||
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(),
|
||||
);
|
||||
}
|
||||
return _photoTile(l10n, theme, _items[index]);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _photoTile(
|
||||
AppLocalizations l10n,
|
||||
ThemeData theme,
|
||||
AdminPhoto photo,
|
||||
) {
|
||||
return ListTile(
|
||||
leading: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(Icons.image_outlined),
|
||||
),
|
||||
title: Text(
|
||||
'${photo.photoId.substring(0, 8)} · ${(photo.bytes / 1024).round()} KB',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
formatAlbumDateTime(
|
||||
photo.uploadedAt,
|
||||
widget.album.timezone,
|
||||
l10n.localeName,
|
||||
),
|
||||
),
|
||||
trailing: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
_PhotoTag(
|
||||
label: _visibilityLabel(l10n, photo.visibility),
|
||||
highlight: photo.visibility == PhotoVisibility.published,
|
||||
),
|
||||
_PhotoTag(
|
||||
label: _processingLabel(l10n, photo.processingState),
|
||||
highlight: photo.processingState == PhotoProcessingState.ready,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _visibilityLabel(AppLocalizations l10n, PhotoVisibility v) {
|
||||
switch (v) {
|
||||
case PhotoVisibility.published:
|
||||
return l10n.visibilityPublished;
|
||||
case PhotoVisibility.unpublished:
|
||||
return l10n.visibilityUnpublished;
|
||||
case PhotoVisibility.hidden:
|
||||
return l10n.visibilityHidden;
|
||||
case PhotoVisibility.deleted:
|
||||
return l10n.visibilityDeleted;
|
||||
}
|
||||
}
|
||||
|
||||
static String _processingLabel(
|
||||
AppLocalizations l10n,
|
||||
PhotoProcessingState s,
|
||||
) {
|
||||
switch (s) {
|
||||
case PhotoProcessingState.pendingUpload:
|
||||
return l10n.procPendingUpload;
|
||||
case PhotoProcessingState.uploaded:
|
||||
return l10n.procUploaded;
|
||||
case PhotoProcessingState.processing:
|
||||
return l10n.procProcessing;
|
||||
case PhotoProcessingState.ready:
|
||||
return l10n.procReady;
|
||||
case PhotoProcessingState.processingFailed:
|
||||
return l10n.procProcessingFailed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 照片标签(可见性/处理状态)。
|
||||
class _PhotoTag extends StatelessWidget {
|
||||
const _PhotoTag({required this.label, required this.highlight});
|
||||
|
||||
final String label;
|
||||
final bool highlight;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: highlight
|
||||
? theme.colorScheme.secondaryContainer
|
||||
: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: highlight
|
||||
? theme.colorScheme.onSecondaryContainer
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
/// 相册列表页签(P3 计划 Task 4):游标加载更多、下拉刷新、卡片标签行
|
||||
/// (状态 + 四开关)、时区感知的时间展示、公开令牌短显、FAB 新建;
|
||||
/// 加载/空/错误态齐备。详情/编辑由卡片与列表级刷新联动。
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/error_l10n.dart';
|
||||
import '../features/albums/album_format.dart';
|
||||
import '../features/albums/models.dart';
|
||||
import '../features/albums/providers.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import 'album_detail_page.dart';
|
||||
import 'album_form_page.dart';
|
||||
|
||||
/// 首页「相册」页签。
|
||||
class AlbumsTab extends ConsumerWidget {
|
||||
const AlbumsTab({super.key});
|
||||
|
||||
Future<void> _openCreate(BuildContext context, WidgetRef ref) async {
|
||||
final changed = await Navigator.of(context).push<bool>(
|
||||
MaterialPageRoute<bool>(builder: (_) => const AlbumFormPage()),
|
||||
);
|
||||
if (changed == true) {
|
||||
ref.read(albumListProvider.notifier).refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final state = ref.watch(albumListProvider);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(l10n.albums)),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => _openCreate(context, ref),
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(l10n.albumCreate),
|
||||
),
|
||||
body: AlbumListBody(state: state),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 列表主体:按状态分发 加载/错误/空/列表(含加载更多页脚)。
|
||||
class AlbumListBody extends ConsumerWidget {
|
||||
const AlbumListBody({required this.state, super.key});
|
||||
|
||||
final AlbumListState state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final notifier = ref.read(albumListProvider.notifier);
|
||||
|
||||
if (state.loading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (state.items.isEmpty) {
|
||||
if (state.errorCode != null) {
|
||||
return _ErrorView(
|
||||
message: l10n.messageForErrorCode(state.errorCode!),
|
||||
onRetry: notifier.refresh,
|
||||
);
|
||||
}
|
||||
return _EmptyView(message: l10n.albumsEmpty);
|
||||
}
|
||||
|
||||
final hasFooter = state.hasMore || state.loadMoreFailed;
|
||||
return RefreshIndicator(
|
||||
onRefresh: notifier.refresh,
|
||||
child: ListView.builder(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
itemCount: state.items.length + (hasFooter ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index >= state.items.length) {
|
||||
if (state.loadMoreFailed) {
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
leading: const Icon(Icons.error_outline),
|
||||
title: Text(l10n.loadMoreFailed),
|
||||
onTap: notifier.loadMore,
|
||||
);
|
||||
}
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
height: 24,
|
||||
width: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
// 接近底部时预取下一页(loadMore 自带并发/末页守卫)。
|
||||
if (index >= state.items.length - 3 &&
|
||||
state.hasMore &&
|
||||
!state.loadingMore) {
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => notifier.loadMore(),
|
||||
);
|
||||
}
|
||||
return AlbumCard(album: state.items[index]);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 首屏错误视图(重试按钮触发整页刷新)。
|
||||
class _ErrorView extends StatelessWidget {
|
||||
const _ErrorView({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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 空列表视图。
|
||||
class _EmptyView extends StatelessWidget {
|
||||
const _EmptyView({required this.message});
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.photo_library_outlined, size: 48),
|
||||
const SizedBox(height: 12),
|
||||
Text(message, textAlign: TextAlign.center),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 相册卡片:默认语言标题、状态 + 四开关标签行、相册时区的时间区间、
|
||||
/// 公开令牌短显。
|
||||
class AlbumCard extends StatelessWidget {
|
||||
const AlbumCard({required this.album, super.key});
|
||||
|
||||
final AlbumSummary album;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final time = _timeRange(l10n);
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => AlbumDetailPage(albumId: album.id),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
_title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_StatusChip(status: album.status),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
_SwitchChip(label: l10n.tagUpload, on: album.uploadEnabled),
|
||||
_SwitchChip(label: l10n.tagPublic, on: album.publicEnabled),
|
||||
_SwitchChip(
|
||||
label: l10n.tagDownload,
|
||||
on: album.downloadEnabled,
|
||||
),
|
||||
_SwitchChip(
|
||||
label: l10n.tagOriginal,
|
||||
on: album.originalDownloadEnabled,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.schedule,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
time,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
album.publicToken.length > 10
|
||||
? '${album.publicToken.substring(0, 10)}…'
|
||||
: album.publicToken,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 默认语言标题(缺失时回退首个条目;无翻译时显示 ID 兜底)。
|
||||
String get _title {
|
||||
for (final t in album.translations) {
|
||||
if (t.locale == album.defaultLocale) return t.title;
|
||||
}
|
||||
if (album.translations.isNotEmpty) return album.translations.first.title;
|
||||
return album.id;
|
||||
}
|
||||
|
||||
/// 相册时区的时间区间(结束未定显示「未定」占位)。
|
||||
String _timeRange(AppLocalizations l10n) {
|
||||
final locale = l10n.localeName;
|
||||
final start = formatAlbumDateTime(album.startAt, album.timezone, locale);
|
||||
final end = album.endAt == null
|
||||
? l10n.notSet
|
||||
: formatAlbumDateTime(album.endAt!, album.timezone, locale);
|
||||
return '$start ~ $end';
|
||||
}
|
||||
}
|
||||
|
||||
/// 状态标签:active 正常 / offline 下线。
|
||||
class _StatusChip extends StatelessWidget {
|
||||
const _StatusChip({required this.status});
|
||||
|
||||
final AlbumStatus status;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final active = status == AlbumStatus.active;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: active
|
||||
? theme.colorScheme.secondaryContainer
|
||||
: theme.colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
active ? l10n.albumStatusActive : l10n.albumStatusOffline,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: active
|
||||
? theme.colorScheme.onSecondaryContainer
|
||||
: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 开关标签:色调区分开/关。
|
||||
class _SwitchChip extends StatelessWidget {
|
||||
const _SwitchChip({required this.label, required this.on});
|
||||
|
||||
final String label;
|
||||
final bool on;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: on
|
||||
? theme.colorScheme.primaryContainer
|
||||
: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: on
|
||||
? theme.colorScheme.onPrimaryContainer
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/api_client.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import 'albums_tab.dart';
|
||||
|
||||
/// 主壳:底部导航(相册 / 队列 / 设置)。
|
||||
class HomePage extends StatefulWidget {
|
||||
@@ -53,25 +54,6 @@ class _HomePageState extends State<HomePage> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 相册列表(桩)。Task 4:游标分页、创建/编辑、二维码分享。
|
||||
class AlbumsTab extends StatelessWidget {
|
||||
const AlbumsTab({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(l10n.albums)),
|
||||
body: Center(
|
||||
child: Text(
|
||||
l10n.albums,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 上传队列(桩)。Task 6/9:状态机展示、筛选与重试。
|
||||
class QueueTab extends StatelessWidget {
|
||||
const QueueTab({super.key});
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/// 分享二维码对话框(P3 计划 Task 4)。
|
||||
///
|
||||
/// viewer 链接构造:`{VIEWER_BASE}/a/{publicToken}`([ApiConfig.
|
||||
/// viewerAlbumUrl],VIEWER_BASE 经 --dart-define 覆盖,dev=模拟器宿主
|
||||
/// 回环 5175)。QrImageView 渲染链接 + 链接文本可长按选中 + 一键复制。
|
||||
///
|
||||
/// 用 [Dialog] 而非 [AlertDialog]:AlertDialog 内容经 IntrinsicWidth
|
||||
/// 测量,而 QrImageView 内部使用 LayoutBuilder(不支持 intrinsic 计算)
|
||||
/// 会抛布局断言,普通 Dialog 无此路径。
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
|
||||
import '../core/config.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
/// 观众分享二维码对话框([publicToken] 来自相册详情)。
|
||||
class ShareQrDialog extends StatelessWidget {
|
||||
const ShareQrDialog({required this.publicToken, super.key});
|
||||
|
||||
final String publicToken;
|
||||
|
||||
Future<void> _copy(BuildContext context, String url) async {
|
||||
await Clipboard.setData(ClipboardData(text: url));
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context).copiedToClipboard),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final url = ApiConfig.viewerAlbumUrl(publicToken);
|
||||
return Dialog(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
l10n.shareQrTitle,
|
||||
style: theme.textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
// 白底保证暗色主题下的扫码对比度。
|
||||
child: Container(
|
||||
color: Colors.white,
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: QrImageView(
|
||||
data: url,
|
||||
version: QrVersions.auto,
|
||||
size: 216,
|
||||
backgroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SelectableText(
|
||||
url,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.qrHint,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: () => _copy(context, url),
|
||||
icon: const Icon(Icons.copy_outlined),
|
||||
label: Text(l10n.copyLink),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(l10n.ok),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user