diff --git a/apps/mobile/lib/l10n/app_en.arb b/apps/mobile/lib/l10n/app_en.arb index a8863e9..ef6f3f7 100644 --- a/apps/mobile/lib/l10n/app_en.arb +++ b/apps/mobile/lib/l10n/app_en.arb @@ -236,5 +236,20 @@ "photoUnhiddenToast": "Unhidden", "photoDeletedToast": "Deleted", "photoReload": "Reload", - "photoPreviewUnavailable": "Image unavailable" + "photoPreviewUnavailable": "Image unavailable", + "albumsSubtitle": "Organize and manage event photos", + "actionPhotos": "Photos", + "actionMembers": "Members", + "actionCategories": "Categories", + "actionShare": "Share", + "recentPhotos": "Recent photos", + "seeAllPhotos": "See all", + "editAlbum": "Edit album", + "manageMembers": "Manage members", + "shareAlbum": "Share album", + "emptyAlbumTitle": "Ready for your first photo", + "emptyAlbumSubtitle": "Photos you take or pick will appear here", + "processingPhotos": "Processing...", + "readonlyMember": "You can browse photos shared by others", + "moreActions": "More actions" } diff --git a/apps/mobile/lib/l10n/app_localizations.dart b/apps/mobile/lib/l10n/app_localizations.dart index 656213a..48cfc53 100644 --- a/apps/mobile/lib/l10n/app_localizations.dart +++ b/apps/mobile/lib/l10n/app_localizations.dart @@ -1471,6 +1471,96 @@ abstract class AppLocalizations { /// In zh, this message translates to: /// **'图片暂不可用'** String get photoPreviewUnavailable; + + /// No description provided for @albumsSubtitle. + /// + /// In zh, this message translates to: + /// **'管理和整理活动照片'** + String get albumsSubtitle; + + /// No description provided for @actionPhotos. + /// + /// In zh, this message translates to: + /// **'照片'** + String get actionPhotos; + + /// No description provided for @actionMembers. + /// + /// In zh, this message translates to: + /// **'成员'** + String get actionMembers; + + /// No description provided for @actionCategories. + /// + /// In zh, this message translates to: + /// **'分类'** + String get actionCategories; + + /// No description provided for @actionShare. + /// + /// In zh, this message translates to: + /// **'分享'** + String get actionShare; + + /// No description provided for @recentPhotos. + /// + /// In zh, this message translates to: + /// **'最近照片'** + String get recentPhotos; + + /// No description provided for @seeAllPhotos. + /// + /// In zh, this message translates to: + /// **'查看全部'** + String get seeAllPhotos; + + /// No description provided for @editAlbum. + /// + /// In zh, this message translates to: + /// **'编辑相册'** + String get editAlbum; + + /// No description provided for @manageMembers. + /// + /// In zh, this message translates to: + /// **'管理成员'** + String get manageMembers; + + /// No description provided for @shareAlbum. + /// + /// In zh, this message translates to: + /// **'分享相册'** + String get shareAlbum; + + /// No description provided for @emptyAlbumTitle. + /// + /// In zh, this message translates to: + /// **'准备好拍第一张照片了吗?'** + String get emptyAlbumTitle; + + /// No description provided for @emptyAlbumSubtitle. + /// + /// In zh, this message translates to: + /// **'拍摄或选择的照片将出现在这里'** + String get emptyAlbumSubtitle; + + /// No description provided for @processingPhotos. + /// + /// In zh, this message translates to: + /// **'处理中...'** + String get processingPhotos; + + /// No description provided for @readonlyMember. + /// + /// In zh, this message translates to: + /// **'您可以浏览其他人分享的照片'** + String get readonlyMember; + + /// No description provided for @moreActions. + /// + /// In zh, this message translates to: + /// **'更多操作'** + String get moreActions; } class _AppLocalizationsDelegate diff --git a/apps/mobile/lib/l10n/app_localizations_en.dart b/apps/mobile/lib/l10n/app_localizations_en.dart index e53e7f5..eb12d62 100644 --- a/apps/mobile/lib/l10n/app_localizations_en.dart +++ b/apps/mobile/lib/l10n/app_localizations_en.dart @@ -735,4 +735,49 @@ class AppLocalizationsEn extends AppLocalizations { @override String get photoPreviewUnavailable => 'Image unavailable'; + + @override + String get albumsSubtitle => 'Organize and manage event photos'; + + @override + String get actionPhotos => 'Photos'; + + @override + String get actionMembers => 'Members'; + + @override + String get actionCategories => 'Categories'; + + @override + String get actionShare => 'Share'; + + @override + String get recentPhotos => 'Recent photos'; + + @override + String get seeAllPhotos => 'See all'; + + @override + String get editAlbum => 'Edit album'; + + @override + String get manageMembers => 'Manage members'; + + @override + String get shareAlbum => 'Share album'; + + @override + String get emptyAlbumTitle => 'Ready for your first photo'; + + @override + String get emptyAlbumSubtitle => 'Photos you take or pick will appear here'; + + @override + String get processingPhotos => 'Processing...'; + + @override + String get readonlyMember => 'You can browse photos shared by others'; + + @override + String get moreActions => 'More actions'; } diff --git a/apps/mobile/lib/l10n/app_localizations_zh.dart b/apps/mobile/lib/l10n/app_localizations_zh.dart index afacf19..4bf6b1c 100644 --- a/apps/mobile/lib/l10n/app_localizations_zh.dart +++ b/apps/mobile/lib/l10n/app_localizations_zh.dart @@ -711,4 +711,49 @@ class AppLocalizationsZh extends AppLocalizations { @override String get photoPreviewUnavailable => '图片暂不可用'; + + @override + String get albumsSubtitle => '管理和整理活动照片'; + + @override + String get actionPhotos => '照片'; + + @override + String get actionMembers => '成员'; + + @override + String get actionCategories => '分类'; + + @override + String get actionShare => '分享'; + + @override + String get recentPhotos => '最近照片'; + + @override + String get seeAllPhotos => '查看全部'; + + @override + String get editAlbum => '编辑相册'; + + @override + String get manageMembers => '管理成员'; + + @override + String get shareAlbum => '分享相册'; + + @override + String get emptyAlbumTitle => '准备好拍第一张照片了吗?'; + + @override + String get emptyAlbumSubtitle => '拍摄或选择的照片将出现在这里'; + + @override + String get processingPhotos => '处理中...'; + + @override + String get readonlyMember => '您可以浏览其他人分享的照片'; + + @override + String get moreActions => '更多操作'; } diff --git a/apps/mobile/lib/l10n/app_zh.arb b/apps/mobile/lib/l10n/app_zh.arb index 326442f..e47109d 100644 --- a/apps/mobile/lib/l10n/app_zh.arb +++ b/apps/mobile/lib/l10n/app_zh.arb @@ -236,5 +236,20 @@ "photoUnhiddenToast": "已恢复显示", "photoDeletedToast": "已删除", "photoReload": "重新加载", - "photoPreviewUnavailable": "图片暂不可用" + "photoPreviewUnavailable": "图片暂不可用", + "albumsSubtitle": "管理和整理活动照片", + "actionPhotos": "照片", + "actionMembers": "成员", + "actionCategories": "分类", + "actionShare": "分享", + "recentPhotos": "最近照片", + "seeAllPhotos": "查看全部", + "editAlbum": "编辑相册", + "manageMembers": "管理成员", + "shareAlbum": "分享相册", + "emptyAlbumTitle": "准备好拍第一张照片了吗?", + "emptyAlbumSubtitle": "拍摄或选择的照片将出现在这里", + "processingPhotos": "处理中...", + "readonlyMember": "您可以浏览其他人分享的照片", + "moreActions": "更多操作" } diff --git a/apps/mobile/lib/pages/album_detail_page.dart b/apps/mobile/lib/pages/album_detail_page.dart index 2ac4ab0..ba85122 100644 --- a/apps/mobile/lib/pages/album_detail_page.dart +++ b/apps/mobile/lib/pages/album_detail_page.dart @@ -1,7 +1,13 @@ -/// 相册详情页(UI-01 §4.2 重构):紧凑封面与活动信息 → 公开/上传状态 → -/// 照片入口(最近上传预览条 / 拍摄第一张照片引导)→ 次级管理入口(编辑/ -/// 成员/分类/分享,ListTile 次强调)+ 分类管理区;底部固定「拍摄上传 -/// (主)/ 从相册选择(次)」,避让安全区,上传关闭时说明原因并禁用。 +/// 相册详情页(UI-03 §4 页面 06-08 重设计):顶栏返回 + 标题 +「⋯」 +/// 操作菜单(BottomSheet:编辑/成员/分类/分享,按角色过滤,先关面板再 +/// 导航)→ 全宽 16:9 封面 → 标题/时间地点/公开与上传状态 → 次级四入口 +/// (Photos/Members/Categories/Share,≥44dp)→ Recent photos 三列预览 +/// (≤9 张)+ See all →(管理者)分类管理区;底部固定「拍摄上传(主)/ +/// 从相册选择(次)」,避让安全区,上传关闭时说明原因并禁用。 +/// +/// 空相册(页面 08):保留真实封面与资料,中心安静占位「Ready for your +/// first photo」;协作者显示只读说明;有处理中照片时显示「处理中」,不 +/// 当绝对空白,无伪造计数与示例照片。 /// /// 数据范围:照片预览按 scope 自适应(all 403 → own)推导管理者身份—— /// 创建者/管理员见管理入口;协作者仅见被授权内容(照片入口为自己的 @@ -31,8 +37,11 @@ import 'album_photos_page.dart'; import 'capture_page.dart'; import 'share_qr_dialog.dart'; -/// 详情页照片预览条取数上限(方案 §4.2「3-5 张缩略图」)。 -const int _kPreviewStripLimit = 5; +/// 详情页最近照片三列网格取数上限(方案 §4.2「最多 6-9 张」)。 +const int _kRecentGridLimit = 9; + +/// 操作菜单(页面 07)的动作标识:面板 pop 返回,页面侧统一分发导航。 +enum AlbumAction { edit, members, categories, share } /// 相册详情([albumId] 路由参数;进入后 GET /albums/{id} + 照片预览)。 class AlbumDetailPage extends ConsumerStatefulWidget { @@ -52,7 +61,7 @@ class _AlbumDetailPageState extends ConsumerState { bool _loading = true; String? _errorCode; - /// 最近上传预览条(协作者为「自己上传」,服务端过滤)。 + /// 最近照片网格(协作者为「自己上传」,服务端过滤)。 List _recentPhotos = const []; bool _photosLoading = true; @@ -105,7 +114,7 @@ class _AlbumDetailPageState extends ConsumerState { } /// 照片入口预览:先按 all(owner/admin);403 回退 own(协作者仅自己 - /// 上传)。非 403 失败不阻塞详情页(入口退化为「管理照片」按钮)。 + /// 上传)。非 403 失败不阻塞详情页(入口退化为「查看全部」按钮)。 Future _loadPhotoPreview() async { final album = _album; if (album == null) return; @@ -114,7 +123,7 @@ class _AlbumDetailPageState extends ConsumerState { final page = await api.listAlbumPhotos( album.id, scope: AlbumPhotoScope.all, - limit: _kPreviewStripLimit, + limit: _kRecentGridLimit, ); if (!mounted) return; setState(() { @@ -135,7 +144,7 @@ class _AlbumDetailPageState extends ConsumerState { final ownPage = await api.listAlbumPhotos( album.id, scope: AlbumPhotoScope.own, - limit: _kPreviewStripLimit, + limit: _kRecentGridLimit, ); if (!mounted) return; setState(() { @@ -161,6 +170,8 @@ class _AlbumDetailPageState extends ConsumerState { } } + // ── 导航入口(四入口与操作菜单共享,不另起一套实现) ─────────────── + /// 编辑成功后:详情重载 + 全局列表同步。 Future _openEdit() async { final album = _album; @@ -195,7 +206,7 @@ class _AlbumDetailPageState extends ConsumerState { builder: (_) => AlbumPhotosPage(album: album), ), ); - // 管理页内可能发生隐藏/删除,回来后刷新预览条。 + // 管理页内可能发生隐藏/删除,回来后刷新预览网格。 await _loadPhotoPreview(); } @@ -211,6 +222,27 @@ class _AlbumDetailPageState extends ConsumerState { ); } + /// 「⋯」操作菜单(页面 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: + _scrollToCategories(); + case AlbumAction.share: + _shareQr(); + } + } + // ── 上传入口(底部固定栏:拍摄上传主按钮 / 从相册选择次按钮) ───────── /// 当前登录账号(TaskStore 隔离键;详情页处于登录壳内,null 视为异常)。 @@ -326,7 +358,16 @@ class _AlbumDetailPageState extends ConsumerState { final album = _album; final title = album == null ? l10n.albumDetail : albumDisplayTitle(album, l10n.localeName); return Scaffold( - appBar: AppBar(title: Text(title)), + 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, @@ -341,38 +382,78 @@ class _AlbumDetailPageState extends ConsumerState { ) : 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, + // 封面:全宽 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), - _SecondaryActions( - showManage: _isManager, - onEdit: _openEdit, - onMembers: _openMembers, - onCategories: _scrollToCategories, - onShareQr: _shareQr, + // 次级四入口(Photos/Members/Categories/Share)。 + Padding( + padding: const EdgeInsets.symmetric( + horizontal: kSpace16, + ), + child: _ActionTiles( + isManager: _isManager, + onPhotos: _openPhotos, + onMembers: _openMembers, + onCategories: _scrollToCategories, + 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, + ), ), if (_isManager) ...[ - const SizedBox(height: kSpace16), - _CategoriesSection( - key: _categoriesKey, - album: album, - onAdd: () => _openCategoryDialog(), - onEdit: (c) => _openCategoryDialog(c), - onDelete: _deleteCategory, + const SizedBox(height: kSpace24), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: kSpace16, + ), + child: _CategoriesSection( + key: _categoriesKey, + album: album, + onAdd: () => _openCategoryDialog(), + onEdit: (c) => _openCategoryDialog(c), + onDelete: _deleteCategory, + ), ), ], const SizedBox(height: kSpace32), @@ -381,65 +462,79 @@ class _AlbumDetailPageState extends ConsumerState { ), ); } -} -/// 紧凑头部:封面缩略图(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); + /// 单行时间/场地(相册时区墙钟 + 场地语言回退;结束未定显示「未定」)。 + 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); - final meta = venue == null + return 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, + } +} + +/// 操作菜单(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(), + ), + ], ), - 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, - ), - ), - ], + ), ); } } @@ -453,45 +548,162 @@ class _StatusSection extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); - return Row( + return Wrap( + spacing: kSpace8, + runSpacing: kSpace8, 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), + if (album.status == AlbumStatus.offline) StateBadge(variant: BadgeVariant.error, label: l10n.albumStatusOffline), - ], ], ); } } -/// 照片入口:有照片 → 预览条(≤5 张)+「管理照片」;无照片 →「拍摄第 -/// 一张照片」引导(上传关闭时禁用,由底部栏说明原因)。 -class _PhotoEntrySection extends StatelessWidget { - const _PhotoEntrySection({ - required this.album, +/// 次级四入口(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, - 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) { @@ -500,16 +712,16 @@ class _PhotoEntrySection extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SectionHeader( - title: l10n.albumPhotos, + title: l10n.recentPhotos, trailing: TextButton( onPressed: onOpenPhotos, - child: Text(l10n.albumManagePhotos), + child: Text(l10n.seeAllPhotos), ), ), const SizedBox(height: kSpace8), if (loading) const SizedBox( - height: 96, + height: 120, child: Center( child: SizedBox( height: 24, @@ -518,105 +730,76 @@ class _PhotoEntrySection extends StatelessWidget { ), ), ) - 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 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 - 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, + 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(width: kSpace8), - FilledButton.tonalIcon( - onPressed: album.uploadEnabled ? onTakeFirst : null, - icon: const Icon(Icons.photo_camera_outlined), - label: Text(l10n.albumTakeFirstPhoto), - ), - ], + 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, + ), + ), + ], + ), ), ], ); } -} -/// 次级管理入口(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, - ), - ], - ), - ); - } + /// 是否存在服务端仍在处理的照片(处理中提示;不当绝对空白)。 + bool get _hasProcessing => photos.any( + (p) => p.processingState == PhotoProcessingState.processing, + ); } /// 底部固定上传栏:拍摄上传(主)+ 从相册选择(次),避让安全区;上传 diff --git a/apps/mobile/lib/pages/albums_tab.dart b/apps/mobile/lib/pages/albums_tab.dart index 75cb6e7..3449114 100644 --- a/apps/mobile/lib/pages/albums_tab.dart +++ b/apps/mobile/lib/pages/albums_tab.dart @@ -1,9 +1,10 @@ -/// 相册列表页签:游标加载更多、下拉刷新、封面卡片(UI-01 §4.1)。 +/// 相册列表页签:游标加载更多、下拉刷新、整图封面卡(UI-03 §4 页面 05)。 /// -/// 卡片顺序:封面(16:9,无封面中性占位)→ 标题(≤2 行,语言回退链)→ -/// 时间/场地单行 → 公开/上传状态徽章(图标+文案,不只靠颜色)。手机单列; -/// 可用宽度 > 600 双列(行内卡片等高,不挤压英文标题)。publicToken 与 -/// 四开关堆叠不在主卡片(完整配置在详情设置)。 +/// 卡片为 16:9 全幅封面(无封面同尺寸中性占位),标题(白,≤2 行,语言 +/// 回退链)/时间场地(white70 单行)/公开与上传状态小徽章叠加在底部暗色 +/// 渐变上——文字区域高于基准时卡片增高,不截掉徽章。手机单列;可用宽度 +/// > 600 双列(行内卡片等高)。publicToken 与四开关堆叠不在主卡片(完整 +/// 配置在详情设置)。 library; import 'package:flutter/material.dart'; @@ -17,7 +18,6 @@ import '../l10n/app_localizations.dart'; import '../ui/components/empty_state.dart'; import '../ui/components/error_state.dart'; import '../ui/components/photo_thumbnail.dart'; -import '../ui/components/state_badge.dart'; import '../ui/tokens.dart'; import 'album_detail_page.dart'; import 'album_form_page.dart'; @@ -25,11 +25,12 @@ import 'album_form_page.dart'; /// 双列布局的可用宽度阈值(方案 §4.1「平板按可用宽度使用两列」)。 const double kAlbumTwoColumnMinWidth = 600; -/// 首页「相册」页签。 +/// 首页「相册」页签(UI-03 页面 05):标题 + 副标题,右上「新建」圆形 +/// 入口;空态 CTA 与之共用 [AlbumsTab.openCreate]。 class AlbumsTab extends ConsumerWidget { const AlbumsTab({super.key}); - /// 打开创建表单;成功后刷新列表(空态 CTA 与 FAB 共用)。 + /// 打开创建表单;成功后刷新列表(空态 CTA 与右上入口共用)。 static Future openCreate(BuildContext context, WidgetRef ref) async { final changed = await Navigator.of(context).push( MaterialPageRoute(builder: (_) => const AlbumFormPage()), @@ -42,13 +43,33 @@ class AlbumsTab extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); + final theme = Theme.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), + appBar: AppBar( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(l10n.albums), + Text( + l10n.albumsSubtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + actions: [ + Padding( + padding: const EdgeInsets.only(right: kSpace16), + child: IconButton.filled( + tooltip: l10n.albumCreate, + onPressed: () => openCreate(context, ref), + icon: const Icon(Icons.add), + ), + ), + ], ), body: AlbumListBody(state: state), ); @@ -136,17 +157,19 @@ class AlbumListBody extends ConsumerWidget { } final first = index * 2; final hasSecond = first + 1 < state.items.length; - return IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded(child: AlbumCard(album: state.items[first])), - if (hasSecond) ...[ - const SizedBox(width: kSpace12), - Expanded(child: AlbumCard(album: state.items[first + 1])), - ], + // 双列并排:卡片高度由 16:9 封面主导、各自自撑(不用 + // IntrinsicHeight 等高——整图卡内含 LayoutBuilder 不支持 + // intrinsic 计算;文字区域高于基准的极端大字体下允许行内 + // 微小高度差)。 + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: AlbumCard(album: state.items[first])), + if (hasSecond) ...[ + const SizedBox(width: kSpace12), + Expanded(child: AlbumCard(album: state.items[first + 1])), ], - ), + ], ); }, ), @@ -156,16 +179,26 @@ class AlbumListBody extends ConsumerWidget { } } -/// 相册卡片(UI-01 §4.1 样板):封面 → 标题 → 时间/场地 → 状态徽章。 +/// 相册卡片(UI-03 §4 页面 05):整卡 16:9 封面 + 底部暗色渐变叠层。 /// -/// 标题按「界面语言 → 相册默认语言 → 首个条目」回退;时间用相册时区; -/// 公开/上传分别显示(关闭不等于不公开)。publicToken 与四开关堆叠已从 -/// 主卡片移除(完整配置在详情设置)。 +/// 图片铺满整卡(无封面/加载失败为同尺寸中性占位),标题/时间场地/状态 +/// 徽章以白字叠加在渐变上;文字区域高于 16:9 基准时卡片增高(大字体下 +/// 不截断徽章)。标题按「界面语言 → 相册默认语言 → 首个条目」回退;时间 +/// 用相册时区;公开/上传分别显示(图标 + 文案,不只靠颜色)。publicToken +/// 与四开关堆叠已从主卡片移除(完整配置在详情设置)。 class AlbumCard extends StatelessWidget { const AlbumCard({required this.album, super.key}); final AlbumSummary album; + /// 底部渐变:透明 → black54,只压暗下半部(白字对比度)。 + static const LinearGradient _scrim = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: [0.42, 1.0], + colors: [Color(0x00000000), Color(0x8A000000)], + ); + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); @@ -183,57 +216,89 @@ class AlbumCard extends StatelessWidget { builder: (_) => AlbumDetailPage(albumId: album.id), ), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - PhotoThumbnail(url: album.cover?.url, aspectRatio: 16 / 9), - Padding( - padding: const EdgeInsets.all(kSpace16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - albumDisplayTitle(album, l10n.localeName), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleMedium, + child: LayoutBuilder( + builder: (context, constraints) { + // 16:9 基准高度;作为 Stack 首个子节点参与 IntrinsicHeight + // 双列等高(RenderStack 的 intrinsic 只看首子节点)。 + final baseHeight = constraints.maxWidth * 9 / 16; + return Stack( + alignment: Alignment.bottomLeft, + children: [ + SizedBox(width: double.infinity, height: baseHeight), + Positioned.fill( + child: PhotoThumbnail( + url: album.cover?.url, + aspectRatio: 16 / 9, ), - const SizedBox(height: kSpace4), - Text( - meta, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall, + ), + const Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration(gradient: _scrim), ), - const SizedBox(height: kSpace12), - Row( + ), + Padding( + padding: const EdgeInsets.fromLTRB( + kSpace16, + kSpace16, + kSpace16, + kSpace12, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - StateBadge( - variant: album.publicEnabled - ? BadgeVariant.info - : BadgeVariant.neutral, - label: album.publicEnabled - ? l10n.tagPublic - : l10n.albumPublicOff, - compact: true, + Text( + albumDisplayTitle(album, l10n.localeName), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + ), ), - const SizedBox(width: kSpace8), - StateBadge( - variant: album.uploadEnabled - ? BadgeVariant.success - : BadgeVariant.neutral, - label: album.uploadEnabled - ? l10n.albumUploadsOn - : l10n.albumUploadsOff, - compact: true, + const SizedBox(height: kSpace4), + Text( + meta, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.white70, + ), + ), + const SizedBox(height: kSpace8), + Row( + children: [ + _OverlayBadge( + icon: album.publicEnabled + ? Icons.info_outline + : Icons.remove_circle_outline, + color: album.publicEnabled + ? kColorInfoDark + : Colors.white70, + label: album.publicEnabled + ? l10n.tagPublic + : l10n.albumPublicOff, + ), + const SizedBox(width: kSpace8), + _OverlayBadge( + icon: album.uploadEnabled + ? Icons.check_circle + : Icons.remove_circle_outline, + color: album.uploadEnabled + ? kColorSuccessDark + : Colors.white70, + label: album.uploadEnabled + ? l10n.albumUploadsOn + : l10n.albumUploadsOff, + ), + ], ), ], ), - ], - ), - ), - ], + ), + ], + ); + }, ), ), ); @@ -249,3 +314,45 @@ class AlbumCard extends StatelessWidget { return '$start ~ $end'; } } + +/// 图片叠层上的状态小徽章:半透明深色底 + 白字(语义色仅用于图标, +/// 提亮变体保证在照片上的对比度)。 +class _OverlayBadge extends StatelessWidget { + const _OverlayBadge({ + required this.icon, + required this.color, + required this.label, + }); + + final IconData icon; + final Color color; + final String label; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.symmetric(horizontal: kSpace8, vertical: 4), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(kRadiusBadge), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: kSpace4), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelSmall?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/test/pages/album_detail_page_test.dart b/apps/mobile/test/pages/album_detail_page_test.dart index 0eff21b..732579f 100644 --- a/apps/mobile/test/pages/album_detail_page_test.dart +++ b/apps/mobile/test/pages/album_detail_page_test.dart @@ -1,11 +1,11 @@ -/// 相册详情页 widget 测试(UI-01 §4.2 重构后)。 +/// 相册详情页 widget 测试(UI-03 §4 页面 06-08 重设计后)。 /// -/// 覆盖:紧凑头部(封面占位 + 标题 + 时区时间场地行)→ 状态徽章 → -/// 照片入口(最近上传预览条 + 管理照片 / 空态「拍摄第一张照片」)→ 次级 -/// 管理入口(ListTile)→ 底部固定上传栏(拍摄上传主 / 从相册选择次,上 -/// 传关闭时禁用并说明);协作者(scope all 403 → own)隐藏管理入口;分类 -/// 管理三流(新增载荷、空名校验、编辑 PATCH、删除确认);底部栏直达连拍 -/// 页与多选入队。 +/// 覆盖:封面 → 标题/时区时间场地 → 状态徽章 → 次级四入口(Photos/ +/// Members/Categories/Share)→ Recent photos 三列网格 + See all → 底部 +/// 固定上传栏(拍摄上传主 / 从相册选择次,上传关闭时禁用并说明);空相册 +/// 安静占位;协作者(scope all 403 → own)隐藏管理入口;分类管理三流 +/// (新增载荷、空名校验、编辑 PATCH、删除确认);底部栏直达连拍页与多选 +/// 入队。 library; import 'dart:io'; @@ -105,7 +105,7 @@ void main() { await tester.pumpAndSettle(); } - testWidgets('布局结构:头部 → 状态 → 照片入口 → 次级操作 → 底部上传栏', + testWidgets('布局结构:封面 → 信息 → 四入口 → Recent photos → 底部上传栏', (tester) async { final adapter = detailAdapter( photos: List>.generate( @@ -118,8 +118,8 @@ void main() { ); await pumpDetail(tester, clientOf(adapter)); - // 紧凑头部:封面占位(无 cover → 中性占位,非转圈)+ 标题 + 单行 - // 时间/场地(相册时区墙钟 + 场地语言回退)。 + // 封面占位(无 cover → 中性占位,非转圈)+ 标题 + 单行时间/场地 + // (相册时区墙钟 + 场地语言回退)。 final body = find.byType(SingleChildScrollView); expect( find.descendant(of: body, matching: find.text('Opening Gala')), @@ -130,7 +130,7 @@ void main() { findsOneWidget, ); expect(find.byIcon(Icons.image_outlined), findsOneWidget); - // 预览条缩略图(签名 URL 测试环境失败 → 替代图,不破图)。 + // Recent photos 网格缩略图(签名 URL 测试环境失败 → 替代图,不破图)。 expect(find.byIcon(Icons.broken_image_outlined), findsNWidgets(3)); // 状态区:公开/上传徽章(关闭不等于不公开,分别呈现)。 @@ -140,14 +140,23 @@ void main() { expect(find.text('Original'), findsNothing); expect(find.text('sharetoken000000000001'), findsNothing); - // 照片入口:分区标题 + 「管理照片」入口 + 3 张预览缩略图。 - expect(find.text('Manage photos'), findsOneWidget); + // Recent photos:分区标题 + 「See all」入口 + 三列网格。 + expect(find.text('Recent photos'), findsOneWidget); + expect(find.text('See all'), findsOneWidget); - // 次级操作(ListTile,不再与主操作同级)。 - expect(find.widgetWithText(ListTile, 'Edit'), findsOneWidget); - expect(find.widgetWithText(ListTile, 'Members'), findsOneWidget); - expect(find.widgetWithText(ListTile, 'Categories'), findsOneWidget); - expect(find.widgetWithText(ListTile, 'Share QR code'), findsOneWidget); + // 次级四入口(管理者全量)。入口文字限定在四入口卡内(页面下方 + // 「分类管理」分区标题同为 Categories 文案,按 Card 作用域区分)。 + final tilesCard = find.byType(Card).first; + expect(find.descendant(of: tilesCard, matching: find.text('Photos')), + findsOneWidget); + expect(find.descendant(of: tilesCard, matching: find.text('Members')), + findsOneWidget); + expect(find.descendant(of: tilesCard, matching: find.text('Categories')), + findsOneWidget); + expect(find.descendant(of: tilesCard, matching: find.text('Share')), + findsOneWidget); + // 顶栏「⋯」操作菜单入口。 + expect(find.byIcon(Icons.more_vert), findsOneWidget); // 底部固定上传栏:拍摄上传(主)+ 从相册选择(次)。 final capture = find.widgetWithText(FilledButton, 'Capture and upload'); @@ -155,34 +164,44 @@ void main() { expect(capture, findsOneWidget); expect(pick, findsOneWidget); - // 垂直顺序:标题 → 徽章 → 管理照片 → 底部栏(次级操作在 body 列内, - // 其在树中先于 bottomNavigationBar,几何位置随滚动变化不作断言)。 + // 垂直顺序:标题 → 徽章 → 四入口 → Recent photos → 底部栏。全宽 + // 16:9 封面较高,先滚动让 Recent photos 入屏再比较几何位置。 + await reveal(tester, find.text('Recent photos')); final titleTop = tester .getRect(find.descendant(of: body, matching: find.text('Opening Gala'))) .top; final badgeTop = tester.getRect(find.text('Public')).top; - final manageTop = tester.getRect(find.text('Manage photos')).top; + final tilesTop = tester + .getRect(find.descendant(of: tilesCard, matching: find.text('Photos'))) + .top; + final recentTop = tester.getRect(find.text('Recent photos')).top; final captureTop = tester.getRect(capture).top; expect(titleTop, lessThan(badgeTop)); - expect(badgeTop, lessThan(manageTop)); - expect(manageTop, lessThan(captureTop)); + expect(badgeTop, lessThan(tilesTop)); + expect(tilesTop, lessThan(recentTop)); + expect(recentTop, lessThan(captureTop)); }); - testWidgets('无照片:空态引导「拍摄第一张照片」(上传开启时可用)', + testWidgets('无照片:空相册安静占位「Ready for your first photo」', (tester) async { await pumpDetail(tester, clientOf(detailAdapter())); - expect(find.text('No photos yet'), findsOneWidget); - final cta = find.widgetWithText(FilledButton, 'Take the first photo'); - expect(cta, findsOneWidget); + expect(find.text('Ready for your first photo'), findsOneWidget); expect( - tester.widget(cta).onPressed, + find.text('Photos you take or pick will appear here'), + findsOneWidget, + ); + // 空态无伪造计数/示例照片,上传入口收敛到底部栏(上传开启时可用)。 + expect(find.text('No photos yet'), findsNothing); + final capture = find.widgetWithText(FilledButton, 'Capture and upload'); + expect( + tester.widget(capture).onPressed, isNotNull, - reason: '上传开启时引导可用', + reason: '上传开启时底部栏可用', ); }); - testWidgets('上传关闭:底部栏说明原因并禁用双入口 + 引导禁用', (tester) async { + testWidgets('上传关闭:底部栏说明原因并禁用双入口', (tester) async { final adapter = FakeAdapter((o) { if (o.path == '/v1/albums/alb-1' && o.method == 'GET') { return (200, albumJson(categories: categories, uploadEnabled: false)); @@ -216,15 +235,6 @@ void main() { .onPressed, isNull, ); - expect( - tester - .widget( - find.widgetWithText(FilledButton, 'Take the first photo'), - ) - .onPressed, - isNull, - reason: '空态引导与底部栏同一开关', - ); }); testWidgets('协作者(all 403 → own):隐藏管理入口,仅见自己上传预览', @@ -249,14 +259,14 @@ void main() { expect(photoRequests.first.uri.queryParameters['scope'], 'all'); expect(photoRequests.last.uri.queryParameters['scope'], 'own'); - // 管理入口仅创建者/管理员可见;分享对全体成员开放。 - expect(find.widgetWithText(ListTile, 'Edit'), findsNothing); - expect(find.widgetWithText(ListTile, 'Members'), findsNothing); + // 管理入口仅创建者/管理员可见;Photos/Share 对全体成员开放。 + expect(find.text('Members'), findsNothing); expect(find.text('Categories'), findsNothing, reason: '含分区一并隐藏'); - expect(find.widgetWithText(ListTile, 'Share QR code'), findsOneWidget); + expect(find.text('Photos'), findsOneWidget); + expect(find.text('Share'), findsOneWidget); expect(find.byIcon(Icons.add_circle_outline), findsNothing); - // 预览条退化为自己的上传(1 张;头部封面占位另 1 张)。 + // 预览网格退化为自己的上传(1 张;封面占位另 1 张)。 expect(find.byIcon(Icons.broken_image_outlined), findsOneWidget); }); diff --git a/apps/mobile/test/pages/album_workspace_ui03_test.dart b/apps/mobile/test/pages/album_workspace_ui03_test.dart new file mode 100644 index 0000000..5b93676 --- /dev/null +++ b/apps/mobile/test/pages/album_workspace_ui03_test.dart @@ -0,0 +1,419 @@ +/// UI-03 页面 05-08(相册工作台)重设计回归。 +/// +/// 覆盖:相册列表整图卡片(白字标题/时间场地叠底部暗色渐变、叠层状态 +/// 小徽章、无封面同尺寸占位、标题副标题与右上新建入口);相册详情四入口 +/// (≥44dp 触控)、Recent photos 三列网格与 See all 跳转、底部栏避让 +/// 安全区;操作菜单按角色过滤(先关面板再导航、Cancel 仅关闭);空相册 +/// 安静占位(协作者只读说明);上传关闭禁用双入口;处理中照片指示。 +library; + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:photo_live/core/api_client.dart'; +import 'package:photo_live/core/error_codes.dart'; +import 'package:photo_live/l10n/app_localizations.dart'; +import 'package:photo_live/pages/album_detail_page.dart'; +import 'package:photo_live/pages/album_form_page.dart'; +import 'package:photo_live/pages/album_members_page.dart'; +import 'package:photo_live/pages/album_photos_page.dart'; +import 'package:photo_live/pages/albums_tab.dart'; +import 'package:photo_live/ui/components/photo_thumbnail.dart'; + +import '../helpers/fakes.dart'; + +void main() { + late FakeSessionStore store; + + setUp(() { + store = FakeSessionStore(); + }); + + ApiClient clientOf(FakeAdapter adapter) => ApiClient( + sessionStore: store, + dio: Dio(BaseOptions(baseUrl: 'http://test.local')) + ..httpClientAdapter = adapter, + ); + + /// 固定逻辑宽度(dp=1)。 + void setSurface(WidgetTester tester, Size size) { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1.0; + } + + /// 相册列表页签(默认 en)。 + Future pumpTab(WidgetTester tester, ApiClient client) async { + await tester.pumpWidget( + ProviderScope( + overrides: [...pageTestOverrides(client: client)], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const AlbumsTab(), + ), + ), + ); + await tester.pumpAndSettle(); + } + + /// 相册详情页(默认管理者 + 空照片;可注入照片/协作者/上传关闭)。 + Future pumpDetail( + WidgetTester tester, + ApiClient client, + ) async { + await tester.pumpWidget( + ProviderScope( + overrides: [...pageTestOverrides(session: store, client: client)], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const AlbumDetailPage(albumId: 'alb-1'), + ), + ), + ); + await tester.pumpAndSettle(); + } + + /// 详情页路由打桩:GET 详情 / GET 照片(scope 自适应)。 + FakeAdapter detailAdapter({ + List>? photos, + bool collaborator = false, + bool uploadEnabled = true, + }) => + FakeAdapter((o) { + if (o.path == '/v1/albums/alb-1' && o.method == 'GET') { + return (200, albumJson(uploadEnabled: uploadEnabled)); + } + if (o.path == '/v1/albums/alb-1/photos' && o.method == 'GET') { + final scope = o.uri.queryParameters['scope']; + if (collaborator && scope == 'all') { + return (403, errorBody(ErrorCodes.authForbidden)); + } + return (200, cursorPageBody(photos ?? >[])); + } + throw StateError('unexpected ${o.method} ${o.path}'); + }); + + List> photos(int count, {String processing = 'ready'}) => + List>.generate( + count, + (i) => adminPhotoJson( + photoId: 'ph-${(i + 1).toString().padLeft(2, '0')}', + processingState: processing, + preview: processing == 'ready' ? photoPreviewJson() : null, + ), + ); + + testWidgets('页面 05:整图卡片——白字标题/时间地点叠底部暗色渐变', (tester) async { + setSurface(tester, const Size(390, 844)); // 手机单列 + final adapter = FakeAdapter( + (o) => o.path == '/v1/albums' + ? (200, cursorPageBody([albumJson()])) + : throw StateError('unexpected ${o.path}'), + ); + await pumpTab(tester, clientOf(adapter)); + + // 标题白字、时间场地 white70,叠加在图片上(非卡片外独立信息区)。 + final title = tester.widget(find.text('Opening Gala')); + expect(title.style?.color, Colors.white); + final meta = tester.widget( + find.text('2026/9/18 14:30 ~ Not set · 大宴会厅'), + ); + expect(meta.style?.color, Colors.white70); + // 底部暗色渐变叠层存在(LinearGradient 的 DecoratedBox)。 + expect( + find.byWidgetPredicate( + (w) => + w is DecoratedBox && + w.decoration is BoxDecoration && + (w.decoration as BoxDecoration).gradient is LinearGradient, + ), + findsOneWidget, + ); + // 叠层状态小徽章:半透明深色底 + 白字(图标语义色提亮变体)。 + final badge = tester.widget(find.text('Public')); + expect(badge.style?.color, Colors.white); + expect(find.text('Uploads on'), findsOneWidget); + // 单卡片 = 单 Card(无信息白卡分包)。 + expect(find.byType(Card), findsOneWidget); + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + testWidgets('页面 05:无封面 → 同尺寸中性占位(整卡 16:9)', (tester) async { + setSurface(tester, const Size(390, 844)); // 手机单列 + final adapter = FakeAdapter( + (o) => o.path == '/v1/albums' + ? (200, cursorPageBody([albumJson()])) + : throw StateError('unexpected ${o.path}'), + ); + await pumpTab(tester, clientOf(adapter)); + + expect(find.byType(PhotoThumbnail), findsOneWidget); + expect(find.byIcon(Icons.image_outlined), findsOneWidget); + // 整卡 16:9:缩略图(Positioned.fill 铺满卡片)宽 = 卡片宽、高 = 9/16。 + final card = tester.getRect(find.byType(Card)); + final thumb = tester.getRect(find.byType(PhotoThumbnail)); + expect(card.width, closeTo(358, 0.5)); + expect(thumb.width, closeTo(358, 0.5)); + expect(thumb.height, closeTo(358 * 9 / 16, 0.5), reason: '16:9 基准高度'); + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + testWidgets('页面 05:标题 + 副标题 + 右上「新建」入口', (tester) async { + final adapter = FakeAdapter( + (o) => o.path == '/v1/albums' + ? (200, cursorPageBody([albumJson()])) + : throw StateError('unexpected ${o.path}'), + ); + await pumpTab(tester, clientOf(adapter)); + + expect(find.text('Albums'), findsOneWidget); + expect(find.text('Organize and manage event photos'), findsOneWidget); + // 右上「+」(圆形填充按钮)→ 创建表单。 + expect(find.byIcon(Icons.add), findsOneWidget); + expect(find.byType(AlbumFormPage), findsNothing); + await tester.tap(find.byIcon(Icons.add)); + await tester.pumpAndSettle(); + expect(find.byType(AlbumFormPage), findsOneWidget); + }); + + testWidgets('页面 06:四入口齐全且有效触控区 ≥44dp', (tester) async { + final adapter = detailAdapter(photos: photos(2)); + await pumpDetail(tester, clientOf(adapter)); + + // 入口文字限定在四入口卡内(页面下方「分类管理」分区标题同为 + // Categories 文案,需按 Card 作用域区分)。 + final tilesCard = find.byType(Card); + expect(tilesCard, findsOneWidget); + for (final label in ['Photos', 'Members', 'Categories', 'Share']) { + final labelInCard = find.descendant( + of: tilesCard, + matching: find.text(label), + ); + expect(labelInCard, findsOneWidget, reason: '$label 入口存在'); + final tile = find.ancestor( + of: labelInCard, + matching: find.byType(InkWell), + ); + expect(tile, findsOneWidget); + final rect = tester.getRect(tile); + expect(rect.width, greaterThanOrEqualTo(44), reason: '$label 宽 ≥44'); + expect(rect.height, greaterThanOrEqualTo(44), reason: '$label 高 ≥44'); + } + }); + + testWidgets('页面 06:Recent photos 三列网格(≤9)+ See all 跳转', + (tester) async { + final adapter = detailAdapter(photos: photos(9)); + await pumpDetail(tester, clientOf(adapter)); + + await tester.ensureVisible(find.text('Recent photos')); + await tester.pumpAndSettle(); + + final grid = find.byType(GridView); + expect(grid, findsOneWidget); + // 9 张缩略图全部入格(签名 URL 测试环境失败 → 替代图,不破图)。 + final thumbs = find + .descendant(of: grid, matching: find.byType(PhotoThumbnail)) + .evaluate(); + expect(thumbs, hasLength(9)); + // 第一行 3 张:第 4 张位于第 1 张下方(三列)。 + final first = tester.getRect( + find.descendant(of: grid, matching: find.byType(PhotoThumbnail)).first, + ); + final fourth = tester.getRect( + find.descendant(of: grid, matching: find.byType(PhotoThumbnail)).at(3), + ); + expect(fourth.top, greaterThan(first.top)); + expect( + (fourth.left - first.left).abs(), + lessThan(1), + reason: '三列网格第 4 张与第 1 张同列', + ); + + // See all → 照片管理页。 + await tester.tap(find.text('See all')); + await tester.pumpAndSettle(); + expect(find.byType(AlbumPhotosPage), findsOneWidget); + }); + + testWidgets('页面 07:操作菜单(管理者)——先关面板再导航', (tester) async { + final adapter = detailAdapter(photos: photos(1)); + await pumpDetail(tester, clientOf(adapter)); + + await tester.tap(find.byTooltip('More actions')); + await tester.pumpAndSettle(); + + // 管理者五项:Edit album / Manage members / Categories / Share album / + // Cancel。条目断言限定在面板内(页面下方「Categories」与四入口文案 + // 同名)。 + final sheet = find.byType(AlbumActionSheet); + expect(sheet, findsOneWidget); + expect(find.descendant(of: sheet, matching: find.text('Edit album')), + findsOneWidget); + expect(find.descendant(of: sheet, matching: find.text('Manage members')), + findsOneWidget); + expect(find.descendant(of: sheet, matching: find.text('Categories')), + findsOneWidget); + expect(find.descendant(of: sheet, matching: find.text('Share album')), + findsOneWidget); + expect( + find.descendant(of: sheet, matching: find.text('Cancel')), + findsOneWidget); + + // 点击动作:面板先关闭,再进入目标页(不叠层路由错乱)。表单页标题 + // 同为「Edit album」,以面板组件类型判定关闭。 + expect(find.byType(AlbumFormPage), findsNothing); + await tester.tap(find.descendant( + of: sheet, + matching: find.text('Edit album'), + )); + await tester.pumpAndSettle(); + expect(find.byType(AlbumActionSheet), findsNothing, reason: '面板已关闭'); + expect(find.byType(AlbumFormPage), findsOneWidget); + }); + + testWidgets('页面 07:操作菜单按角色过滤(协作者仅分享)', (tester) async { + final adapter = detailAdapter(photos: photos(1), collaborator: true); + await pumpDetail(tester, clientOf(adapter)); + + await tester.tap(find.byTooltip('More actions')); + await tester.pumpAndSettle(); + + final sheet = find.byType(AlbumActionSheet); + expect(sheet, findsOneWidget); + expect(find.descendant(of: sheet, matching: find.text('Edit album')), + findsNothing); + expect(find.descendant(of: sheet, matching: find.text('Manage members')), + findsNothing); + expect(find.descendant(of: sheet, matching: find.text('Categories')), + findsNothing); + expect(find.descendant(of: sheet, matching: find.text('Share album')), + findsOneWidget); + + // Cancel 仅关闭面板,不导航。 + await tester.tap( + find.descendant(of: sheet, matching: find.text('Cancel')), + ); + await tester.pumpAndSettle(); + expect(find.byType(AlbumActionSheet), findsNothing, reason: '面板已关闭'); + expect(find.byType(AlbumMembersPage), findsNothing); + }); + + testWidgets('页面 07:菜单「Manage members」共享详情导航方法', (tester) async { + final adapter = detailAdapter(photos: photos(1)); + await pumpDetail(tester, clientOf(adapter)); + + await tester.tap(find.byTooltip('More actions')); + await tester.pumpAndSettle(); + final sheet = find.byType(AlbumActionSheet); + await tester.tap(find.descendant( + of: sheet, + matching: find.text('Manage members'), + )); + await tester.pumpAndSettle(); + + expect(find.byType(AlbumMembersPage), findsOneWidget); + }); + + testWidgets('页面 08:空相册安静占位(管理者)+ 上传入口可用', (tester) async { + await pumpDetail(tester, clientOf(detailAdapter())); + + expect(find.text('Ready for your first photo'), findsOneWidget); + expect( + find.text('Photos you take or pick will appear here'), + findsOneWidget, + ); + // 无伪造计数与示例照片。 + expect(find.byType(GridView), findsNothing); + expect(find.textContaining('uploads'), findsNothing); + // 底部上传栏保留(上传开启)。 + expect( + tester + .widget( + find.widgetWithText(FilledButton, 'Capture and upload'), + ) + .onPressed, + isNotNull, + ); + }); + + testWidgets('页面 08:空相册(协作者)显示只读说明', (tester) async { + final adapter = detailAdapter(collaborator: true); + await pumpDetail(tester, clientOf(adapter)); + + expect(find.text('Ready for your first photo'), findsOneWidget); + expect( + find.text('You can browse photos shared by others'), + findsOneWidget, + ); + expect( + find.text('Photos you take or pick will appear here'), + findsNothing, + ); + }); + + testWidgets('页面 06/08:上传关闭 → 双入口禁用 + 原因说明', (tester) async { + final adapter = detailAdapter(uploadEnabled: false); + await pumpDetail(tester, clientOf(adapter)); + + expect( + find.text( + 'Uploads are off for this album. New photos cannot be added right now', + ), + findsOneWidget, + ); + expect( + tester + .widget( + find.widgetWithText(FilledButton, 'Capture and upload'), + ) + .onPressed, + isNull, + ); + expect( + tester + .widget( + find.widgetWithText(OutlinedButton, 'Pick from gallery'), + ) + .onPressed, + isNull, + ); + }); + + testWidgets('页面 06:底部固定栏避让安全区(SafeArea 包裹)', (tester) async { + final adapter = detailAdapter(photos: photos(1)); + await pumpDetail(tester, clientOf(adapter)); + + final capture = find.widgetWithText(FilledButton, 'Capture and upload'); + expect( + find.ancestor(of: capture, matching: find.byType(SafeArea)), + findsOneWidget, + reason: '底部栏按钮位于 SafeArea 内', + ); + }); + + testWidgets('页面 08:有处理中照片 → 「Processing...」指示,非空白', + (tester) async { + final adapter = detailAdapter( + photos: [...photos(2), ...photos(1, processing: 'processing')], + ); + await pumpDetail(tester, clientOf(adapter)); + + await tester.ensureVisible(find.text('Recent photos')); + await tester.pumpAndSettle(); + + // 处理中提示存在;网格照常展示(真实状态,不当绝对空白)。 + expect(find.text('Processing...'), findsOneWidget); + expect(find.text('Ready for your first photo'), findsNothing); + final grid = find.byType(GridView); + expect( + find.descendant(of: grid, matching: find.byType(PhotoThumbnail)) + .evaluate(), + hasLength(3), + ); + }); +}