Files
photo-live/apps/mobile/lib/pages/album_detail_page.dart
T

832 lines
25 KiB
Dart
Raw Normal View History

/// 相册详情页(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 '../features/uploads/enqueue.dart';
import '../l10n/app_localizations.dart';
import 'album_form_page.dart';
import 'album_members_page.dart';
import 'album_photos_page.dart';
import 'capture_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),
);
}
// ── 上传入口(Task 9 FAB:拍照上传 / 从相册选择) ──────────────────────
/// 当前登录账号(TaskStore 隔离键;详情页处于登录壳内,null 视为异常)。
Future<String?> _currentAccountId() async {
final session = await ref.read(sessionStoreProvider).read();
return session?.account;
}
Future<void> _openUploadSheet() {
final l10n = AppLocalizations.of(context);
return showModalBottomSheet<void>(
context: context,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.photo_camera_outlined),
title: Text(l10n.uploadActionCapture),
onTap: () {
Navigator.of(sheetContext).pop();
_openCapture();
},
),
ListTile(
leading: const Icon(Icons.photo_library_outlined),
title: Text(l10n.uploadActionPick),
onTap: () {
Navigator.of(sheetContext).pop();
_pickFromGallery();
},
),
],
),
),
);
}
/// 拍照上传:进入连拍页(目标相册 + 分类选定一次)。
Future<void> _openCapture() async {
final album = _album;
if (album == null) return;
final account = await _currentAccountId();
if (!mounted) return;
if (account == null) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => CapturePage(
target: CaptureTarget(
accountId: account,
albumId: album.id,
albumTitle: _titleOf(album),
categories: album.categories,
),
),
),
);
}
/// 从相册选择:多选 → 逐张入队(默认分类)→ 入队数提示。
Future<void> _pickFromGallery() async {
final album = _album;
if (album == null) return;
final l10n = AppLocalizations.of(context);
final account = await _currentAccountId();
if (account == null) return;
final paths = await ref.read(multiImagePickerProvider)();
if (paths.isEmpty || !mounted) return;
final messenger = ScaffoldMessenger.of(context);
final count = await enqueueForUpload(
ref,
accountId: account,
albumId: album.id,
sourcePaths: paths,
);
if (!mounted) return;
messenger.showSnackBar(
SnackBar(content: Text(l10n.uploadEnqueuedCount(count))),
);
}
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)),
// 上传入口(Task 9):拍照上传(连拍页)/ 从相册选择(多选入队)。
floatingActionButton: album == null
? null
: FloatingActionButton.extended(
onPressed: _openUploadSheet,
icon: const Icon(Icons.photo_camera_outlined),
label: Text(l10n.uploadAction),
),
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),
),
],
);
}
}