/// 相册详情页(UI-01 §4.2 重构):紧凑封面与活动信息 → 公开/上传状态 → /// 照片入口(最近上传预览条 / 拍摄第一张照片引导)→ 次级管理入口(编辑/ /// 成员/分类/分享,ListTile 次强调)+ 分类管理区;底部固定「拍摄上传 /// (主)/ 从相册选择(次)」,避让安全区,上传关闭时说明原因并禁用。 /// /// 数据范围:照片预览按 scope 自适应(all 403 → own)推导管理者身份—— /// 创建者/管理员见管理入口;协作者仅见被授权内容(照片入口为自己的 /// 上传)。编辑与分类变更后刷新本地详情并同步全局相册列表。 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/photos/models.dart'; import '../features/uploads/enqueue.dart'; import '../l10n/app_localizations.dart'; import '../ui/components/photo_thumbnail.dart'; import '../ui/components/section_header.dart'; import '../ui/components/state_badge.dart'; import '../ui/tokens.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'; /// 详情页照片预览条取数上限(方案 §4.2「3-5 张缩略图」)。 const int _kPreviewStripLimit = 5; /// 相册详情([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; /// 最近上传预览条(协作者为「自己上传」,服务端过滤)。 List _recentPhotos = const []; bool _photosLoading = true; /// 管理者(owner/admin):scope=all 照片请求成功推导;403 → 协作者。 /// 预览请求非 403 失败时保守按管理者展示(服务端仍逐请求裁决)。 bool _isManager = true; @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) return; setState(() { _album = album; _loading = false; }); await _loadPhotoPreview(); } on AppException catch (e) { if (mounted) { setState(() { _errorCode = e.code; _loading = false; }); } } on Object { if (mounted) { setState(() { _errorCode = ErrorCodes.internalError; _loading = false; }); } } } /// 照片入口预览:先按 all(owner/admin);403 回退 own(协作者仅自己 /// 上传)。非 403 失败不阻塞详情页(入口退化为「管理照片」按钮)。 Future _loadPhotoPreview() async { final album = _album; if (album == null) return; final api = ref.read(apiClientProvider); try { final page = await api.listAlbumPhotos( album.id, scope: AlbumPhotoScope.all, limit: _kPreviewStripLimit, ); if (!mounted) return; setState(() { _recentPhotos = page.items; _photosLoading = false; _isManager = true; }); } on AppException catch (e) { if (!mounted) return; if (e.code != ErrorCodes.authForbidden) { setState(() { _recentPhotos = const []; _photosLoading = false; }); return; } try { final ownPage = await api.listAlbumPhotos( album.id, scope: AlbumPhotoScope.own, limit: _kPreviewStripLimit, ); if (!mounted) return; setState(() { _recentPhotos = ownPage.items; _photosLoading = false; _isManager = false; }); } on AppException { if (!mounted) return; setState(() { _recentPhotos = const []; _photosLoading = false; _isManager = false; }); } } on Object { if (mounted) { setState(() { _recentPhotos = const []; _photosLoading = 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), ), ); // 管理页内可能发生隐藏/删除,回来后刷新预览条。 await _loadPhotoPreview(); } void _shareQr() { final album = _album; if (album == null) return; showDialog( context: context, builder: (_) => ShareQrDialog( publicToken: album.publicToken, albumTitle: albumDisplayTitle(album, AppLocalizations.of(context).localeName), ), ); } // ── 上传入口(底部固定栏:拍摄上传主按钮 / 从相册选择次按钮) ───────── /// 当前登录账号(TaskStore 隔离键;详情页处于登录壳内,null 视为异常)。 Future _currentAccountId() async { final session = await ref.read(sessionStoreProvider).read(); return session?.account; } /// 拍照上传:进入连拍页(目标相册 + 分类选定一次)。 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: albumDisplayTitle(album, AppLocalizations.of(context).localeName), 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 : albumDisplayTitle(album, l10n.localeName); return Scaffold( appBar: AppBar(title: Text(title)), bottomNavigationBar: album == null ? null : _UploadBar( uploadEnabled: album.uploadEnabled, onCapture: _openCapture, onPick: _pickFromGallery, ), body: _loading ? const Center(child: CircularProgressIndicator()) : album == null ? _ErrorRetryView( message: l10n.messageForErrorCode(_errorCode!), onRetry: _load, ) : SingleChildScrollView( controller: _scrollController, padding: const EdgeInsets.all(kSpace16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _CompactHeader(album: album), const SizedBox(height: kSpace16), _StatusSection(album: album), const SizedBox(height: kSpace16), _PhotoEntrySection( album: album, photos: _recentPhotos, loading: _photosLoading, isManager: _isManager, onOpenPhotos: _openPhotos, onTakeFirst: _openCapture, ), const SizedBox(height: kSpace16), _SecondaryActions( showManage: _isManager, onEdit: _openEdit, onMembers: _openMembers, onCategories: _scrollToCategories, onShareQr: _shareQr, ), if (_isManager) ...[ const SizedBox(height: kSpace16), _CategoriesSection( key: _categoriesKey, album: album, onAdd: () => _openCategoryDialog(), onEdit: (c) => _openCategoryDialog(c), onDelete: _deleteCategory, ), ], const SizedBox(height: kSpace32), ], ), ), ); } } /// 紧凑头部:封面缩略图(16:9,最高 200)+ 标题 + 时间/场地单行。 class _CompactHeader extends StatelessWidget { const _CompactHeader({required this.album}); final Album album; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); final venue = albumDisplayVenue(album, l10n.localeName); final start = formatAlbumDateTime(album.startAt, album.timezone, l10n.localeName); final end = album.endAt == null ? l10n.notSet : formatAlbumDateTime(album.endAt!, album.timezone, l10n.localeName); final meta = venue == null ? '$start ~ $end (${album.timezone})' : '$start ~ $end (${album.timezone}) · $venue'; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ LayoutBuilder( builder: (context, constraints) { // 16:9 封面,最高 200(超出按高度封顶,宽度同步收窄)。 final height = constraints.maxWidth * 9 / 16; final capped = height > 200.0; final h = capped ? 200.0 : height; final w = capped ? h * 16 / 9 : constraints.maxWidth; return Center( child: ClipRRect( borderRadius: BorderRadius.circular(kRadiusCard), child: SizedBox( width: w, height: h, child: PhotoThumbnail( url: album.cover?.url, aspectRatio: w / h, ), ), ), ); }, ), const SizedBox(height: kSpace12), Text( albumDisplayTitle(album, l10n.localeName), style: theme.textTheme.titleLarge, ), const SizedBox(height: kSpace4), Text( meta, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ); } } /// 状态区:公开 / 上传分别显示(关闭不等于不公开)。 class _StatusSection extends StatelessWidget { const _StatusSection({required this.album}); final Album album; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); return Row( children: [ StateBadge( variant: album.publicEnabled ? BadgeVariant.info : BadgeVariant.neutral, label: album.publicEnabled ? l10n.tagPublic : l10n.albumPublicOff, ), const SizedBox(width: kSpace8), StateBadge( variant: album.uploadEnabled ? BadgeVariant.success : BadgeVariant.neutral, label: album.uploadEnabled ? l10n.albumUploadsOn : l10n.albumUploadsOff, ), if (album.status == AlbumStatus.offline) ...[ const SizedBox(width: kSpace8), StateBadge(variant: BadgeVariant.error, label: l10n.albumStatusOffline), ], ], ); } } /// 照片入口:有照片 → 预览条(≤5 张)+「管理照片」;无照片 →「拍摄第 /// 一张照片」引导(上传关闭时禁用,由底部栏说明原因)。 class _PhotoEntrySection extends StatelessWidget { const _PhotoEntrySection({ required this.album, required this.photos, required this.loading, required this.isManager, required this.onOpenPhotos, required this.onTakeFirst, }); final Album album; final List photos; final bool loading; final bool isManager; final VoidCallback onOpenPhotos; final VoidCallback onTakeFirst; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SectionHeader( title: l10n.albumPhotos, trailing: TextButton( onPressed: onOpenPhotos, child: Text(l10n.albumManagePhotos), ), ), const SizedBox(height: kSpace8), if (loading) const SizedBox( height: 96, child: Center( child: SizedBox( height: 24, width: 24, child: CircularProgressIndicator(strokeWidth: 2), ), ), ) else if (photos.isNotEmpty) SizedBox( height: 96, child: ListView.separated( scrollDirection: Axis.horizontal, itemCount: photos.length, separatorBuilder: (_, _) => const SizedBox(width: kSpace8), itemBuilder: (context, index) { final photo = photos[index]; return SizedBox( width: 96, height: 96, child: PhotoThumbnail( url: photo.preview?.thumb.url, aspectRatio: 1, ), ); }, ), ) else Row( children: [ Icon( Icons.photo_camera_outlined, size: 20, color: Theme.of(context).colorScheme.onSurfaceVariant, ), const SizedBox(width: kSpace8), Expanded( child: Text( l10n.albumPhotosEmpty, style: Theme.of(context).textTheme.bodySmall, ), ), const SizedBox(width: kSpace8), FilledButton.tonalIcon( onPressed: album.uploadEnabled ? onTakeFirst : null, icon: const Icon(Icons.photo_camera_outlined), label: Text(l10n.albumTakeFirstPhoto), ), ], ), ], ); } } /// 次级管理入口(ListTile 次强调,不再与主操作同级):编辑 / 成员 / 分类 /// (滚动定位)/ 分享二维码。管理类入口仅创建者/管理员可见;分享对全体 /// 成员开放(publicToken 本身对成员可见)。 class _SecondaryActions extends StatelessWidget { const _SecondaryActions({ required this.showManage, required this.onEdit, required this.onMembers, required this.onCategories, required this.onShareQr, }); final bool showManage; final VoidCallback onEdit; final VoidCallback onMembers; final VoidCallback onCategories; final VoidCallback onShareQr; @override Widget build(BuildContext context) { return Card( margin: EdgeInsets.zero, clipBehavior: Clip.antiAlias, child: Column( children: [ if (showManage) ...[ ListTile( leading: const Icon(Icons.edit_outlined), title: Text(AppLocalizations.of(context).actionEdit), onTap: onEdit, ), ListTile( leading: const Icon(Icons.group_outlined), title: Text(AppLocalizations.of(context).albumMembers), onTap: onMembers, ), ListTile( leading: const Icon(Icons.category_outlined), title: Text(AppLocalizations.of(context).albumCategories), onTap: onCategories, ), ], ListTile( leading: const Icon(Icons.qr_code_2), title: Text(AppLocalizations.of(context).shareQrTitle), onTap: onShareQr, ), ], ), ); } } /// 底部固定上传栏:拍摄上传(主)+ 从相册选择(次),避让安全区;上传 /// 关闭时给出原因说明并禁用两个入口。 class _UploadBar extends StatelessWidget { const _UploadBar({ required this.uploadEnabled, required this.onCapture, required this.onPick, }); final bool uploadEnabled; final VoidCallback onCapture; final VoidCallback onPick; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); return SafeArea( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (!uploadEnabled) Padding( padding: const EdgeInsets.fromLTRB( kSpace16, kSpace8, kSpace16, 0, ), child: Row( children: [ Icon( Icons.info_outline, size: 16, color: theme.colorScheme.onSurfaceVariant, ), const SizedBox(width: kSpace4), Expanded( child: Text( l10n.albumUploadDisabledHint, style: theme.textTheme.bodySmall, ), ), ], ), ), Padding( padding: const EdgeInsets.fromLTRB( kSpace16, kSpace8, kSpace16, kSpace12, ), child: Row( children: [ Expanded( child: FilledButton.icon( onPressed: uploadEnabled ? onCapture : null, icon: const Icon(Icons.photo_camera_outlined), label: Text(l10n.uploadActionCapture), ), ), const SizedBox(width: kSpace12), Expanded( child: OutlinedButton.icon( onPressed: uploadEnabled ? onPick : null, icon: const Icon(Icons.photo_library_outlined), label: Text(l10n.uploadActionPick), ), ), ], ), ), ], ), ); } } /// 首屏错误 + 重试。 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 _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( margin: EdgeInsets.zero, 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(' · '); } } /// 分类新增/编辑对话框:排序序号 + 中文名 + 英文名(至少一个必填)。 /// /// 成功 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), ), ], ); } }