/// 相册详情页(UI-03 §4 页面 06-08 重设计):顶栏返回 + 标题 +「⋯」 /// 操作菜单(BottomSheet:编辑/成员/分类/分享,按角色过滤,先关面板再 /// 导航)→ 全宽 16:9 封面 → 标题/时间地点/公开与上传状态 → 次级四入口 /// (Photos/Members/Categories/Share,≥44dp)→ Recent photos 三列预览 /// (≤9 张)+ See all;分类自页面 12 起提取为独立管理页(本页仅导航); /// 底部固定「拍摄上传(主)/从相册选择(次)」,避让安全区,上传关闭时 /// 说明原因并禁用。 /// /// 空相册(页面 08):保留真实封面与资料,中心安静占位「Ready for your /// first photo」;协作者显示只读说明;有处理中照片时显示「处理中」,不 /// 当绝对空白,无伪造计数与示例照片。 /// /// 数据范围:照片预览按 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_categories_page.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「最多 6-9 张」)。 const int _kRecentGridLimit = 9; /// 操作菜单(页面 07)的动作标识:面板 pop 返回,页面侧统一分发导航。 enum AlbumAction { edit, members, categories, share } /// 相册详情([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 { 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(); } 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: _kRecentGridLimit, ); 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: _kRecentGridLimit, ); 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(); } /// 分类管理(页面 12):进入独立分类页;返回后重载详情(分类随详情 /// 返回,供连拍目标等使用)。 Future _openCategories() async { final album = _album; if (album == null) return; await Navigator.of(context).push( MaterialPageRoute( builder: (_) => AlbumCategoriesPage(album: album, isManager: _isManager), ), ); await _load(); } void _shareQr() { final album = _album; if (album == null) return; // UI-03 页面 19:分享从对话框升级为全屏分享页(真实封面 + 二维码)。 Navigator.of(context).push( MaterialPageRoute(builder: (_) => ShareQrPage(album: album)), ); } /// 「⋯」操作菜单(页面 07):BottomSheet 按角色过滤动作;先关面板再 /// 导航(await pop 后统一分发),避免叠层路由错乱。 Future _showActionMenu() async { final action = await showModalBottomSheet( context: context, showDragHandle: true, builder: (_) => AlbumActionSheet(isManager: _isManager), ); if (!mounted || action == null) return; switch (action) { case AlbumAction.edit: await _openEdit(); case AlbumAction.members: await _openMembers(); case AlbumAction.categories: await _openCategories(); case AlbumAction.share: _shareQr(); } } // ── 上传入口(底部固定栏:拍摄上传主按钮 / 从相册选择次按钮) ───────── /// 当前登录账号(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))), ); } @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, maxLines: 1, overflow: TextOverflow.ellipsis), actions: [ IconButton( tooltip: l10n.moreActions, onPressed: _showActionMenu, icon: const Icon(Icons.more_vert), ), ], ), 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( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // 封面:全宽 16:9(无封面同尺寸中性占位)。 PhotoThumbnail(url: album.cover?.url, aspectRatio: 16 / 9), Padding( padding: const EdgeInsets.fromLTRB( kSpace16, kSpace16, kSpace16, 0, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( albumDisplayTitle(album, l10n.localeName), style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: kSpace4), Text( _metaLine(album, l10n), style: Theme.of(context).textTheme.bodySmall ?.copyWith( color: Theme.of(context) .colorScheme .onSurfaceVariant, ), ), const SizedBox(height: kSpace12), _StatusSection(album: album), ], ), ), const SizedBox(height: kSpace16), // 次级四入口(Photos/Members/Categories/Share)。 Padding( padding: const EdgeInsets.symmetric( horizontal: kSpace16, ), child: _ActionTiles( isManager: _isManager, onPhotos: _openPhotos, onMembers: _openMembers, onCategories: _openCategories, onShare: _shareQr, ), ), const SizedBox(height: kSpace24), // Recent photos:三列网格(≤9)/ 空相册安静占位 / // 处理中指示。 Padding( padding: const EdgeInsets.symmetric( horizontal: kSpace16, ), child: _RecentPhotosSection( photos: _recentPhotos, loading: _photosLoading, isManager: _isManager, onOpenPhotos: _openPhotos, ), ), const SizedBox(height: kSpace32), ], ), ), ); } /// 单行时间/场地(相册时区墙钟 + 场地语言回退;结束未定显示「未定」)。 static String _metaLine(Album album, AppLocalizations l10n) { 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); return venue == null ? '$start ~ $end (${album.timezone})' : '$start ~ $end (${album.timezone}) · $venue'; } } /// 操作菜单(UI-03 §4 页面 07):Edit album / Manage members / /// Categories / Share album + Cancel,按角色过滤(管理类动作仅 /// owner/admin;分享对全体成员开放)。点击 pop 对应 [AlbumAction], /// 由详情页在面板关闭后统一导航。 class AlbumActionSheet extends StatelessWidget { const AlbumActionSheet({required this.isManager, super.key}); /// 是否管理者(owner/admin):决定管理类动作是否显示。 final bool isManager; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); // 大字体/小屏下面板内容可滚动,不溢出。 return SafeArea( top: false, child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ if (isManager) ...[ ListTile( leading: const Icon(Icons.edit_outlined), title: Text(l10n.editAlbum), onTap: () => Navigator.of(context).pop(AlbumAction.edit), ), ListTile( leading: const Icon(Icons.group_outlined), title: Text(l10n.manageMembers), onTap: () => Navigator.of(context).pop(AlbumAction.members), ), ListTile( leading: const Icon(Icons.category_outlined), title: Text(l10n.actionCategories), onTap: () => Navigator.of(context).pop(AlbumAction.categories), ), ], ListTile( leading: const Icon(Icons.share_outlined), title: Text(l10n.shareAlbum), onTap: () => Navigator.of(context).pop(AlbumAction.share), ), const Divider(indent: kSpace16, endIndent: kSpace16), ListTile( title: Center( child: Text( l10n.cancel, style: theme.textTheme.bodyLarge?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), onTap: () => Navigator.of(context).pop(), ), ], ), ), ); } } /// 状态区:公开 / 上传分别显示(关闭不等于不公开)。 class _StatusSection extends StatelessWidget { const _StatusSection({required this.album}); final Album album; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); return Wrap( spacing: kSpace8, runSpacing: kSpace8, children: [ StateBadge( variant: album.publicEnabled ? BadgeVariant.info : BadgeVariant.neutral, label: album.publicEnabled ? l10n.tagPublic : l10n.albumPublicOff, ), StateBadge( variant: album.uploadEnabled ? BadgeVariant.success : BadgeVariant.neutral, label: album.uploadEnabled ? l10n.albumUploadsOn : l10n.albumUploadsOff, ), if (album.status == AlbumStatus.offline) StateBadge(variant: BadgeVariant.error, label: l10n.albumStatusOffline), ], ); } } /// 次级四入口(UI-03 §4 页面 06):Photos / Members / Categories / Share /// 等宽平铺,每格有效触控区 ≥44dp(实际高 72)。管理类入口(成员/分类) /// 仅管理者可见;Photos 与 Share 对全体成员开放。 class _ActionTiles extends StatelessWidget { const _ActionTiles({ required this.isManager, required this.onPhotos, required this.onMembers, required this.onCategories, required this.onShare, }); final bool isManager; final VoidCallback onPhotos; final VoidCallback onMembers; final VoidCallback onCategories; final VoidCallback onShare; @override Widget build(BuildContext context) { return Card( margin: EdgeInsets.zero, clipBehavior: Clip.antiAlias, child: Row( children: [ Expanded( child: _ActionTile( icon: Icons.photo_library_outlined, label: AppLocalizations.of(context).actionPhotos, onTap: onPhotos, ), ), if (isManager) ...[ const VerticalDivider(width: 1), Expanded( child: _ActionTile( icon: Icons.group_outlined, label: AppLocalizations.of(context).actionMembers, onTap: onMembers, ), ), const VerticalDivider(width: 1), Expanded( child: _ActionTile( icon: Icons.category_outlined, label: AppLocalizations.of(context).actionCategories, onTap: onCategories, ), ), ], const VerticalDivider(width: 1), Expanded( child: _ActionTile( icon: Icons.share_outlined, label: AppLocalizations.of(context).actionShare, onTap: onShare, ), ), ], ), ); } } /// 单个入口格:圆角容器图标 + 标签纵排(≥44dp 触控要求)。 class _ActionTile extends StatelessWidget { const _ActionTile({ required this.icon, required this.label, required this.onTap, }); final IconData icon; final String label; final VoidCallback onTap; @override Widget build(BuildContext context) { final theme = Theme.of(context); return InkWell( onTap: onTap, child: ConstrainedBox( constraints: const BoxConstraints(minHeight: 72), child: Padding( padding: const EdgeInsets.symmetric(vertical: kSpace12), child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( width: 44, height: 44, decoration: BoxDecoration( color: theme.colorScheme.primaryContainer, borderRadius: BorderRadius.circular(kRadiusInput), ), child: Icon( icon, size: 22, color: theme.colorScheme.primary, ), ), const SizedBox(height: kSpace8), Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall?.copyWith( fontWeight: FontWeight.w600, ), ), ], ), ), ), ); } } /// Recent photos(UI-03 §4 页面 06/08): /// - 加载中 → 占位高度居中转圈; /// - 有照片 → 三列网格(≤9 张,真实缩略图;URL 失败为中性占位), /// 其中存在服务端处理中的照片时在网格上方提示「Processing...」; /// - 无照片 → 安静空态(页面 08):图标 + 「Ready for your first photo」, /// 协作者显示只读说明;不显示伪造计数与示例照片。 class _RecentPhotosSection extends StatelessWidget { const _RecentPhotosSection({ required this.photos, required this.loading, required this.isManager, required this.onOpenPhotos, }); final List photos; final bool loading; final bool isManager; final VoidCallback onOpenPhotos; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SectionHeader( title: l10n.recentPhotos, trailing: TextButton( onPressed: onOpenPhotos, child: Text(l10n.seeAllPhotos), ), ), const SizedBox(height: kSpace8), if (loading) const SizedBox( height: 120, child: Center( child: SizedBox( height: 24, width: 24, child: CircularProgressIndicator(strokeWidth: 2), ), ), ) else if (photos.isNotEmpty) ...[ if (_hasProcessing) Padding( padding: const EdgeInsets.only(bottom: kSpace8), child: Row( children: [ Icon( Icons.hourglass_top, size: 16, color: Theme.of(context).colorScheme.secondary, ), const SizedBox(width: kSpace8), Text( l10n.processingPhotos, style: Theme.of(context).textTheme.bodySmall, ), ], ), ), GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), padding: EdgeInsets.zero, gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, mainAxisSpacing: kSpace8, crossAxisSpacing: kSpace8, ), itemCount: photos.length, itemBuilder: (context, index) => PhotoThumbnail( url: photos[index].preview?.thumb.url, aspectRatio: 1, ), ), ] else Padding( padding: const EdgeInsets.symmetric(vertical: kSpace24), child: Column( children: [ Icon( Icons.photo_camera_outlined, size: 48, color: Theme.of(context).colorScheme.onSurfaceVariant, ), const SizedBox(height: kSpace12), Text( l10n.emptyAlbumTitle, textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: kSpace4), Text( isManager ? l10n.emptyAlbumSubtitle : l10n.readonlyMember, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ], ), ), ], ); } /// 是否存在服务端仍在处理的照片(处理中提示;不当绝对空白)。 bool get _hasProcessing => photos.any( (p) => p.processingState == PhotoProcessingState.processing, ); } /// 底部固定上传栏:拍摄上传(主)+ 从相册选择(次),避让安全区;上传 /// 关闭时给出原因说明并禁用两个入口。 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), ), ], ), ); } }