/// 相册详情页(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 createState() => _AlbumDetailPageState(); } class _AlbumDetailPageState extends ConsumerState { 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 _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 _openEdit() async { final album = _album; if (album == null) return; final changed = await Navigator.of(context).push( MaterialPageRoute( builder: (_) => AlbumFormPage(existing: album), ), ); if (changed == true) { await _load(); ref.read(albumListProvider.notifier).refresh(); } } Future _openMembers() async { final album = _album; if (album == null) return; await Navigator.of(context).push( MaterialPageRoute( builder: (_) => AlbumMembersPage(album: album), ), ); // 成员变更不影响详情数据,回来无需刷新。 } Future _openPhotos() async { final album = _album; if (album == null) return; await Navigator.of(context).push( MaterialPageRoute( builder: (_) => AlbumPhotosPage(album: album), ), ); } void _shareQr() { final album = _album; if (album == null) return; showDialog( context: context, builder: (_) => ShareQrDialog(publicToken: album.publicToken), ); } // ── 上传入口(Task 9 FAB:拍照上传 / 从相册选择) ────────────────────── /// 当前登录账号(TaskStore 隔离键;详情页处于登录壳内,null 视为异常)。 Future _currentAccountId() async { final session = await ref.read(sessionStoreProvider).read(); return session?.account; } Future _openUploadSheet() { final l10n = AppLocalizations.of(context); return showModalBottomSheet( 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 _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( builder: (_) => CapturePage( target: CaptureTarget( accountId: account, albumId: album.id, albumTitle: _titleOf(album), categories: album.categories, ), ), ), ); } /// 从相册选择:多选 → 逐张入队(默认分类)→ 入队数提示。 Future _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 _deleteCategory(Category category) async { final l10n = AppLocalizations.of(context); final confirmed = await showDialog( 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 _openCategoryDialog([Category? existing]) async { final album = _album; if (album == null) return; final changed = await showDialog( 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 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 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 createState() => _CategoryEditDialogState(); } class _CategoryEditDialogState extends ConsumerState { 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 _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 = [ 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), ), ], ); } }