From f6929ae18ff173fee902b5e5ccbae482be4c2571 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Mon, 21 Sep 2026 15:55:44 +0800 Subject: [PATCH] feat(mobile): UI-01 album cards, photo grid and full viewer Co-Authored-By: Claude Code --- apps/mobile/lib/core/api_client.dart | 6 + apps/mobile/lib/features/albums/models.dart | 4 + apps/mobile/lib/l10n/app_en.arb | 19 +- apps/mobile/lib/l10n/app_localizations.dart | 102 ++ .../mobile/lib/l10n/app_localizations_en.dart | 54 ++ .../mobile/lib/l10n/app_localizations_zh.dart | 51 + apps/mobile/lib/l10n/app_zh.arb | 19 +- apps/mobile/lib/pages/album_detail_page.dart | 732 +++++++++------ .../lib/pages/album_photo_viewer_page.dart | 446 +++++++++ apps/mobile/lib/pages/album_photos_page.dart | 884 ++++++++++++------ apps/mobile/lib/pages/albums_tab.dart | 126 ++- apps/mobile/test/core/api_client_test.dart | 2 + apps/mobile/test/helpers/fakes.dart | 24 +- .../test/pages/album_detail_page_test.dart | 251 +++-- .../test/pages/album_photo_viewer_test.dart | 356 +++++++ .../test/pages/album_photos_page_test.dart | 285 +++++- apps/mobile/test/pages/albums_tab_test.dart | 91 +- 17 files changed, 2750 insertions(+), 702 deletions(-) create mode 100644 apps/mobile/lib/pages/album_photo_viewer_page.dart create mode 100644 apps/mobile/test/pages/album_photo_viewer_test.dart diff --git a/apps/mobile/lib/core/api_client.dart b/apps/mobile/lib/core/api_client.dart index d2649e3..cd40bc0 100644 --- a/apps/mobile/lib/core/api_client.dart +++ b/apps/mobile/lib/core/api_client.dart @@ -442,10 +442,15 @@ class ApiClient { /// operationId `listAlbumPhotos` /// (GET /albums/{albumId}/photos,管理列表,全状态可见)。 + /// + /// [scope](UI-01):缺省/null=服务端默认 all(仅 owner/admin,协作者 + /// 403 AUTH_FORBIDDEN);[AlbumPhotoScope.own]=仅自己上传(任何有效成员, + /// 服务端过滤)。页面层据此做「all 403 → own」自适应回退。 Future> listAlbumPhotos( String albumId, { PhotoVisibility? visibility, String? categoryId, + AlbumPhotoScope? scope, String? cursor, int? limit, }) => @@ -457,6 +462,7 @@ class ApiClient { extraQuery: { if (visibility != null) 'visibility': visibility.wire, 'categoryId': ?categoryId, + if (scope != null) 'scope': scope.wire, }, ); diff --git a/apps/mobile/lib/features/albums/models.dart b/apps/mobile/lib/features/albums/models.dart index a844d47..2f7259a 100644 --- a/apps/mobile/lib/features/albums/models.dart +++ b/apps/mobile/lib/features/albums/models.dart @@ -257,6 +257,7 @@ class Album extends AlbumSummary { required super.createdAt, required super.updatedAt, required super.statsSyncedAt, + super.cover, }); factory Album.fromJson(Map json) => Album( @@ -265,6 +266,9 @@ class Album extends AlbumSummary { (dynamic e) => Category.fromJson(e as Map), ) .toList(), + cover: json['cover'] == null + ? null + : SignedUrl.fromJson(json['cover'] as Map), id: json['id'] as String, publicToken: json['publicToken'] as String, ownerId: json['ownerId'] as String, diff --git a/apps/mobile/lib/l10n/app_en.arb b/apps/mobile/lib/l10n/app_en.arb index 687c4b0..35ecc3b 100644 --- a/apps/mobile/lib/l10n/app_en.arb +++ b/apps/mobile/lib/l10n/app_en.arb @@ -177,5 +177,22 @@ "uploadAction": "Upload", "uploadEnqueuedCount": "Added {count} photo(s) to the upload queue", "@uploadEnqueuedCount": { "placeholders": { "count": { "type": "int" } } }, - "uploadPickNone": "No photos selected" + "uploadPickNone": "No photos selected", + "albumManagePhotos": "Manage photos", + "albumTakeFirstPhoto": "Take the first photo", + "albumUploadDisabledHint": "Uploads are off for this album. New photos cannot be added right now", + "albumRecentPhotos": "Recent uploads", + "photoFilterAll": "All", + "photoFilterFailed": "Failed", + "photosScopeOwnHint": "Collaborator view: only photos you uploaded are shown", + "photosEmptyFiltered": "No photos match this filter", + "photoHide": "Hide", + "photoUnhide": "Unhide", + "photoDeleteConfirmTitle": "Delete this photo?", + "photoDeleteConfirmBody": "The cloud photo will be deleted. This cannot be undone.", + "photoHiddenToast": "Hidden", + "photoUnhiddenToast": "Unhidden", + "photoDeletedToast": "Deleted", + "photoReload": "Reload", + "photoPreviewUnavailable": "Image unavailable" } diff --git a/apps/mobile/lib/l10n/app_localizations.dart b/apps/mobile/lib/l10n/app_localizations.dart index ee30318..0e77280 100644 --- a/apps/mobile/lib/l10n/app_localizations.dart +++ b/apps/mobile/lib/l10n/app_localizations.dart @@ -1129,6 +1129,108 @@ abstract class AppLocalizations { /// In zh, this message translates to: /// **'未选择照片'** String get uploadPickNone; + + /// No description provided for @albumManagePhotos. + /// + /// In zh, this message translates to: + /// **'管理照片'** + String get albumManagePhotos; + + /// No description provided for @albumTakeFirstPhoto. + /// + /// In zh, this message translates to: + /// **'拍摄第一张照片'** + String get albumTakeFirstPhoto; + + /// No description provided for @albumUploadDisabledHint. + /// + /// In zh, this message translates to: + /// **'相册已关闭上传,暂不能新增照片'** + String get albumUploadDisabledHint; + + /// No description provided for @albumRecentPhotos. + /// + /// In zh, this message translates to: + /// **'最近上传'** + String get albumRecentPhotos; + + /// No description provided for @photoFilterAll. + /// + /// In zh, this message translates to: + /// **'全部'** + String get photoFilterAll; + + /// No description provided for @photoFilterFailed. + /// + /// In zh, this message translates to: + /// **'失败'** + String get photoFilterFailed; + + /// No description provided for @photosScopeOwnHint. + /// + /// In zh, this message translates to: + /// **'协作者视图:仅显示你上传的照片'** + String get photosScopeOwnHint; + + /// No description provided for @photosEmptyFiltered. + /// + /// In zh, this message translates to: + /// **'当前筛选下暂无照片'** + String get photosEmptyFiltered; + + /// No description provided for @photoHide. + /// + /// In zh, this message translates to: + /// **'隐藏'** + String get photoHide; + + /// No description provided for @photoUnhide. + /// + /// In zh, this message translates to: + /// **'恢复显示'** + String get photoUnhide; + + /// No description provided for @photoDeleteConfirmTitle. + /// + /// In zh, this message translates to: + /// **'删除这张照片?'** + String get photoDeleteConfirmTitle; + + /// No description provided for @photoDeleteConfirmBody. + /// + /// In zh, this message translates to: + /// **'云端照片将被删除,删除后不可恢复。'** + String get photoDeleteConfirmBody; + + /// No description provided for @photoHiddenToast. + /// + /// In zh, this message translates to: + /// **'已隐藏'** + String get photoHiddenToast; + + /// No description provided for @photoUnhiddenToast. + /// + /// In zh, this message translates to: + /// **'已恢复显示'** + String get photoUnhiddenToast; + + /// No description provided for @photoDeletedToast. + /// + /// In zh, this message translates to: + /// **'已删除'** + String get photoDeletedToast; + + /// No description provided for @photoReload. + /// + /// In zh, this message translates to: + /// **'重新加载'** + String get photoReload; + + /// No description provided for @photoPreviewUnavailable. + /// + /// In zh, this message translates to: + /// **'图片暂不可用'** + String get photoPreviewUnavailable; } class _AppLocalizationsDelegate diff --git a/apps/mobile/lib/l10n/app_localizations_en.dart b/apps/mobile/lib/l10n/app_localizations_en.dart index bea1a92..db1df6a 100644 --- a/apps/mobile/lib/l10n/app_localizations_en.dart +++ b/apps/mobile/lib/l10n/app_localizations_en.dart @@ -551,4 +551,58 @@ class AppLocalizationsEn extends AppLocalizations { @override String get uploadPickNone => 'No photos selected'; + + @override + String get albumManagePhotos => 'Manage photos'; + + @override + String get albumTakeFirstPhoto => 'Take the first photo'; + + @override + String get albumUploadDisabledHint => + 'Uploads are off for this album. New photos cannot be added right now'; + + @override + String get albumRecentPhotos => 'Recent uploads'; + + @override + String get photoFilterAll => 'All'; + + @override + String get photoFilterFailed => 'Failed'; + + @override + String get photosScopeOwnHint => + 'Collaborator view: only photos you uploaded are shown'; + + @override + String get photosEmptyFiltered => 'No photos match this filter'; + + @override + String get photoHide => 'Hide'; + + @override + String get photoUnhide => 'Unhide'; + + @override + String get photoDeleteConfirmTitle => 'Delete this photo?'; + + @override + String get photoDeleteConfirmBody => + 'The cloud photo will be deleted. This cannot be undone.'; + + @override + String get photoHiddenToast => 'Hidden'; + + @override + String get photoUnhiddenToast => 'Unhidden'; + + @override + String get photoDeletedToast => 'Deleted'; + + @override + String get photoReload => 'Reload'; + + @override + String get photoPreviewUnavailable => 'Image unavailable'; } diff --git a/apps/mobile/lib/l10n/app_localizations_zh.dart b/apps/mobile/lib/l10n/app_localizations_zh.dart index ad8b3e3..628db59 100644 --- a/apps/mobile/lib/l10n/app_localizations_zh.dart +++ b/apps/mobile/lib/l10n/app_localizations_zh.dart @@ -535,4 +535,55 @@ class AppLocalizationsZh extends AppLocalizations { @override String get uploadPickNone => '未选择照片'; + + @override + String get albumManagePhotos => '管理照片'; + + @override + String get albumTakeFirstPhoto => '拍摄第一张照片'; + + @override + String get albumUploadDisabledHint => '相册已关闭上传,暂不能新增照片'; + + @override + String get albumRecentPhotos => '最近上传'; + + @override + String get photoFilterAll => '全部'; + + @override + String get photoFilterFailed => '失败'; + + @override + String get photosScopeOwnHint => '协作者视图:仅显示你上传的照片'; + + @override + String get photosEmptyFiltered => '当前筛选下暂无照片'; + + @override + String get photoHide => '隐藏'; + + @override + String get photoUnhide => '恢复显示'; + + @override + String get photoDeleteConfirmTitle => '删除这张照片?'; + + @override + String get photoDeleteConfirmBody => '云端照片将被删除,删除后不可恢复。'; + + @override + String get photoHiddenToast => '已隐藏'; + + @override + String get photoUnhiddenToast => '已恢复显示'; + + @override + String get photoDeletedToast => '已删除'; + + @override + String get photoReload => '重新加载'; + + @override + String get photoPreviewUnavailable => '图片暂不可用'; } diff --git a/apps/mobile/lib/l10n/app_zh.arb b/apps/mobile/lib/l10n/app_zh.arb index bee67b8..6c9866e 100644 --- a/apps/mobile/lib/l10n/app_zh.arb +++ b/apps/mobile/lib/l10n/app_zh.arb @@ -177,5 +177,22 @@ "uploadAction": "上传", "uploadEnqueuedCount": "已加入 {count} 张上传队列", "@uploadEnqueuedCount": { "placeholders": { "count": { "type": "int" } } }, - "uploadPickNone": "未选择照片" + "uploadPickNone": "未选择照片", + "albumManagePhotos": "管理照片", + "albumTakeFirstPhoto": "拍摄第一张照片", + "albumUploadDisabledHint": "相册已关闭上传,暂不能新增照片", + "albumRecentPhotos": "最近上传", + "photoFilterAll": "全部", + "photoFilterFailed": "失败", + "photosScopeOwnHint": "协作者视图:仅显示你上传的照片", + "photosEmptyFiltered": "当前筛选下暂无照片", + "photoHide": "隐藏", + "photoUnhide": "恢复显示", + "photoDeleteConfirmTitle": "删除这张照片?", + "photoDeleteConfirmBody": "云端照片将被删除,删除后不可恢复。", + "photoHiddenToast": "已隐藏", + "photoUnhiddenToast": "已恢复显示", + "photoDeletedToast": "已删除", + "photoReload": "重新加载", + "photoPreviewUnavailable": "图片暂不可用" } diff --git a/apps/mobile/lib/pages/album_detail_page.dart b/apps/mobile/lib/pages/album_detail_page.dart index e14b7d7..f0a7fd3 100644 --- a/apps/mobile/lib/pages/album_detail_page.dart +++ b/apps/mobile/lib/pages/album_detail_page.dart @@ -1,7 +1,11 @@ -/// 相册详情页(P3 计划 Task 4):信息区(标题/时间/时区/令牌/开关标签) -/// + 动作行(编辑、成员管理、分类管理=滚动定位、分享二维码、照片管理 -/// 占位)+ 分类管理区(列表 + 新增/编辑对话框 + 删除确认)。 -/// 编辑与分类变更后刷新本地详情并同步全局相册列表。 +/// 相册详情页(UI-01 §4.2 重构):紧凑封面与活动信息 → 公开/上传状态 → +/// 照片入口(最近上传预览条 / 拍摄第一张照片引导)→ 次级管理入口(编辑/ +/// 成员/分类/分享,ListTile 次强调)+ 分类管理区;底部固定「拍摄上传 +/// (主)/ 从相册选择(次)」,避让安全区,上传关闭时说明原因并禁用。 +/// +/// 数据范围:照片预览按 scope 自适应(all 403 → own)推导管理者身份—— +/// 创建者/管理员见管理入口;协作者仅见被授权内容(照片入口为自己的 +/// 上传)。编辑与分类变更后刷新本地详情并同步全局相册列表。 library; import 'package:flutter/material.dart'; @@ -14,15 +18,23 @@ 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'; -/// 相册详情([albumId] 路由参数;进入后 GET /albums/{id})。 +/// 详情页照片预览条取数上限(方案 §4.2「3-5 张缩略图」)。 +const int _kPreviewStripLimit = 5; + +/// 相册详情([albumId] 路由参数;进入后 GET /albums/{id} + 照片预览)。 class AlbumDetailPage extends ConsumerStatefulWidget { const AlbumDetailPage({required this.albumId, super.key}); @@ -40,6 +52,14 @@ class _AlbumDetailPageState extends ConsumerState { 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(); @@ -61,12 +81,12 @@ class _AlbumDetailPageState extends ConsumerState { final album = await ref.read(apiClientProvider).getAlbum( widget.albumId, ); - if (mounted) { - setState(() { - _album = album; - _loading = false; - }); - } + if (!mounted) return; + setState(() { + _album = album; + _loading = false; + }); + await _loadPhotoPreview(); } on AppException catch (e) { if (mounted) { setState(() { @@ -84,6 +104,63 @@ class _AlbumDetailPageState extends ConsumerState { } } + /// 照片入口预览:先按 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; @@ -118,6 +195,8 @@ class _AlbumDetailPageState extends ConsumerState { builder: (_) => AlbumPhotosPage(album: album), ), ); + // 管理页内可能发生隐藏/删除,回来后刷新预览条。 + await _loadPhotoPreview(); } void _shareQr() { @@ -129,7 +208,7 @@ class _AlbumDetailPageState extends ConsumerState { ); } - // ── 上传入口(Task 9 FAB:拍照上传 / 从相册选择) ────────────────────── + // ── 上传入口(底部固定栏:拍摄上传主按钮 / 从相册选择次按钮) ───────── /// 当前登录账号(TaskStore 隔离键;详情页处于登录壳内,null 视为异常)。 Future _currentAccountId() async { @@ -137,36 +216,6 @@ class _AlbumDetailPageState extends ConsumerState { return session?.account; } - Future _openUploadSheet() { - final l10n = AppLocalizations.of(context); - return showModalBottomSheet( - context: context, - builder: (sheetContext) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.photo_camera_outlined), - title: Text(l10n.uploadActionCapture), - onTap: () { - Navigator.of(sheetContext).pop(); - _openCapture(); - }, - ), - ListTile( - leading: const Icon(Icons.photo_library_outlined), - title: Text(l10n.uploadActionPick), - onTap: () { - Navigator.of(sheetContext).pop(); - _pickFromGallery(); - }, - ), - ], - ), - ), - ); - } - /// 拍照上传:进入连拍页(目标相册 + 分类选定一次)。 Future _openCapture() async { final album = _album; @@ -180,7 +229,7 @@ class _AlbumDetailPageState extends ConsumerState { target: CaptureTarget( accountId: account, albumId: album.id, - albumTitle: _titleOf(album), + albumTitle: albumDisplayTitle(album, AppLocalizations.of(context).localeName), categories: album.categories, ), ), @@ -272,17 +321,14 @@ class _AlbumDetailPageState extends ConsumerState { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final album = _album; - final title = album == null ? l10n.albumDetail : _titleOf(album); + final title = album == null ? l10n.albumDetail : albumDisplayTitle(album, l10n.localeName); return Scaffold( appBar: AppBar(title: Text(title)), - // 上传入口(Task 9):拍照上传(连拍页)/ 从相册选择(多选入队)。 - floatingActionButton: album == null - ? null - : FloatingActionButton.extended( - onPressed: _openUploadSheet, - icon: const Icon(Icons.photo_camera_outlined), - label: Text(l10n.uploadAction), - ), + bottomNavigationBar: album == null ? null : _UploadBar( + uploadEnabled: album.uploadEnabled, + onCapture: _openCapture, + onPick: _pickFromGallery, + ), body: _loading ? const Center(child: CircularProgressIndicator()) : album == null @@ -292,21 +338,32 @@ class _AlbumDetailPageState extends ConsumerState { ) : SingleChildScrollView( controller: _scrollController, - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _InfoCard(album: album), - const SizedBox(height: 16), - _ActionsRow( - onEdit: _openEdit, - onMembers: _openMembers, - onCategories: _scrollToCategories, - onShareQr: _shareQr, - onPhotos: _openPhotos, - ), - const SizedBox(height: 16), + 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, @@ -314,20 +371,328 @@ class _AlbumDetailPageState extends ConsumerState { onEdit: (c) => _openCategoryDialog(c), onDelete: _deleteCategory, ), - const SizedBox(height: 32), ], - ), + const SizedBox(height: kSpace32), + ], ), ), ); } +} - static String _titleOf(Album album) { - for (final t in album.translations) { - if (t.locale == album.defaultLocale) return t.title; - } - if (album.translations.isNotEmpty) return album.translations.first.title; - return album.id; +/// 紧凑头部:封面缩略图(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), + ), + ), + ], + ), + ), + ], + ), + ); } } @@ -360,191 +725,6 @@ class _ErrorRetryView extends StatelessWidget { } } -/// 信息卡:标题 + 状态、默认语言内容(描述/场地/地址)、时区时间区间、 -/// 公开令牌、四开关标签。 -class _InfoCard extends StatelessWidget { - const _InfoCard({required this.album}); - - final Album album; - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); - final theme = Theme.of(context); - final translation = album.translations - .where((t) => t.locale == album.defaultLocale) - .firstOrNull; - final start = - formatAlbumDateTime(album.startAt, album.timezone, l10n.localeName); - final end = album.endAt == null - ? l10n.notSet - : formatAlbumDateTime(album.endAt!, album.timezone, l10n.localeName); - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - translation?.title ?? album.id, - style: theme.textTheme.titleLarge - ?.copyWith(fontWeight: FontWeight.w600), - ), - ), - const SizedBox(width: 8), - _AlbumStatusBadge(status: album.status), - ], - ), - if ((translation?.description ?? '').trim().isNotEmpty) ...[ - const SizedBox(height: 8), - Text(translation!.description!), - ], - if ((translation?.venue ?? '').trim().isNotEmpty) ...[ - const SizedBox(height: 8), - Row( - children: [ - const Icon(Icons.place_outlined, size: 16), - const SizedBox(width: 4), - Expanded(child: Text(translation!.venue!)), - ], - ), - ], - if ((translation?.address ?? '').trim().isNotEmpty) ...[ - const SizedBox(height: 4), - Row( - children: [ - const Icon(Icons.signpost_outlined, size: 16), - const SizedBox(width: 4), - Expanded(child: Text(translation!.address!)), - ], - ), - ], - const SizedBox(height: 12), - Row( - children: [ - const Icon(Icons.schedule, size: 16), - const SizedBox(width: 4), - Expanded(child: Text('$start ~ $end (${album.timezone})')), - ], - ), - const SizedBox(height: 4), - Row( - children: [ - const Icon(Icons.link, size: 16), - const SizedBox(width: 4), - Expanded( - child: Text( - album.publicToken, - style: theme.textTheme.bodySmall?.copyWith( - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - ), - ], - ), - const SizedBox(height: 12), - Wrap( - spacing: 6, - runSpacing: 4, - children: [ - _switchTag(context, l10n.tagUpload, album.uploadEnabled), - _switchTag(context, l10n.tagPublic, album.publicEnabled), - _switchTag( - context, - l10n.tagDownload, - album.downloadEnabled, - ), - _switchTag( - context, - l10n.tagOriginal, - album.originalDownloadEnabled, - ), - ], - ), - ], - ), - ), - ); - } - - Widget _switchTag(BuildContext context, String label, bool on) { - final theme = Theme.of(context); - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: on - ? theme.colorScheme.primaryContainer - : theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(999), - ), - child: Text( - label, - style: theme.textTheme.labelSmall?.copyWith( - color: on - ? theme.colorScheme.onPrimaryContainer - : theme.colorScheme.onSurfaceVariant, - ), - ), - ); - } -} - -/// 动作行:编辑 / 成员管理 / 分类管理(滚动定位) / 分享二维码 / 照片管理。 -class _ActionsRow extends StatelessWidget { - const _ActionsRow({ - required this.onEdit, - required this.onMembers, - required this.onCategories, - required this.onShareQr, - required this.onPhotos, - }); - - final VoidCallback onEdit; - final VoidCallback onMembers; - final VoidCallback onCategories; - final VoidCallback onShareQr; - final VoidCallback onPhotos; - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); - return Wrap( - spacing: 8, - runSpacing: 8, - children: [ - FilledButton.tonalIcon( - onPressed: onEdit, - icon: const Icon(Icons.edit_outlined), - label: Text(l10n.actionEdit), - ), - FilledButton.tonalIcon( - onPressed: onMembers, - icon: const Icon(Icons.group_outlined), - label: Text(l10n.albumMembers), - ), - FilledButton.tonalIcon( - onPressed: onCategories, - icon: const Icon(Icons.category_outlined), - label: Text(l10n.albumCategories), - ), - FilledButton.tonalIcon( - onPressed: onShareQr, - icon: const Icon(Icons.qr_code_2), - label: Text(l10n.shareQrTitle), - ), - FilledButton.tonalIcon( - onPressed: onPhotos, - icon: const Icon(Icons.photo_outlined), - label: Text(l10n.albumPhotos), - ), - ], - ); - } -} - /// 分类管理区:列表(点击编辑、尾随删除)+ 新增入口。 class _CategoriesSection extends StatelessWidget { const _CategoriesSection({ @@ -594,6 +774,7 @@ class _CategoriesSection extends StatelessWidget { ) else Card( + margin: EdgeInsets.zero, child: Column( children: [ for (final (i, category) in album.categories.indexed) ...[ @@ -633,37 +814,6 @@ class _CategoriesSection extends StatelessWidget { } } -/// 状态徽标(详情信息卡用)。 -class _AlbumStatusBadge extends StatelessWidget { - const _AlbumStatusBadge({required this.status}); - - final AlbumStatus status; - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); - final theme = Theme.of(context); - final active = status == AlbumStatus.active; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: active - ? theme.colorScheme.secondaryContainer - : theme.colorScheme.errorContainer, - borderRadius: BorderRadius.circular(999), - ), - child: Text( - active ? l10n.albumStatusActive : l10n.albumStatusOffline, - style: theme.textTheme.labelSmall?.copyWith( - color: active - ? theme.colorScheme.onSecondaryContainer - : theme.colorScheme.onErrorContainer, - ), - ), - ); - } -} - /// 分类新增/编辑对话框:排序序号 + 中文名 + 英文名(至少一个必填)。 /// /// 成功 pop(true);校验失败就地提示;API 失败 SnackBar(对话框保持)。 diff --git a/apps/mobile/lib/pages/album_photo_viewer_page.dart b/apps/mobile/lib/pages/album_photo_viewer_page.dart new file mode 100644 index 0000000..13ade26 --- /dev/null +++ b/apps/mobile/lib/pages/album_photo_viewer_page.dart @@ -0,0 +1,446 @@ +/// 照片大图查看器(UI-01 §4.3):已加载照片间左右切换、临近边缘加载 +/// 更多、contain 完整展示;底部信息栏(上传者 / 相册时区时间 / 尺寸大 +/// 小,photoId 放诊断区域);隐藏/恢复/删除单张操作(权限校验 + 删除 +/// 确认「云端照片将被删除」);签名 URL 过期受控刷新一次。 +/// +/// 关闭后回到原网格位置(网格页状态与 ScrollController 在路由下保留)。 +/// 恢复发布仅 owner/admin([PhotoGridController.isManager]);隐藏/删除 +/// 为「管理者或自己上传」(服务端最终裁决,403 以 SnackBar 呈现)。 +library; + +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../core/app_exception.dart'; +import '../core/error_l10n.dart'; +import '../features/albums/album_format.dart'; +import '../features/albums/models.dart'; +import '../features/photos/models.dart'; +import '../l10n/app_localizations.dart'; +import '../ui/components/state_badge.dart'; +import '../ui/tokens.dart'; +import 'album_photos_page.dart'; + +/// 大图查看器(网格页 push 进入,共享 [controller] 的已加载条目)。 +class AlbumPhotoViewerPage extends StatefulWidget { + const AlbumPhotoViewerPage({ + required this.controller, + required this.album, + required this.initialIndex, + super.key, + }); + + final PhotoGridController controller; + final Album album; + + /// 初始照片(controller.items 内的下标)。 + final int initialIndex; + + @override + State createState() => _AlbumPhotoViewerPageState(); +} + +class _AlbumPhotoViewerPageState extends State { + late final PageController _pageController; + late int _index; + + @override + void initState() { + super.initState(); + _index = widget.initialIndex.clamp(0, math.max(widget.controller.items.length - 1, 0)); + _pageController = PageController(initialPage: _index); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + void _onPageChanged(int page) { + setState(() => _index = page); + final controller = widget.controller; + // 临近已加载末尾 → 预取下一页(loadMore 自带并发/末页守卫)。 + if (page >= controller.items.length - 3 && + controller.hasMore && + !controller.loadingMore && + !controller.loading) { + controller.loadMore(); + } + } + + bool _isOwnUpload(AdminPhoto photo) => + widget.controller.selfAccount != null && + photo.uploader.account == widget.controller.selfAccount; + + /// 隐藏(published→hidden):管理者或自己上传。 + bool _canHide(AdminPhoto photo) => + photo.visibility == PhotoVisibility.published && + (widget.controller.isManager || _isOwnUpload(photo)); + + /// 恢复显示(hidden→published):仅管理者(服务端同口径)。 + bool _canUnhide(AdminPhoto photo) => + photo.visibility == PhotoVisibility.hidden && widget.controller.isManager; + + /// 删除:管理者或自己上传(未删除态)。 + bool _canDelete(AdminPhoto photo) => + photo.visibility != PhotoVisibility.deleted && + (widget.controller.isManager || _isOwnUpload(photo)); + + Future _hide(AdminPhoto photo) async { + final l10n = AppLocalizations.of(context); + final messenger = ScaffoldMessenger.of(context); + try { + await widget.controller.hidePhoto(photo); + messenger.showSnackBar(SnackBar(content: Text(l10n.photoHiddenToast))); + } on AppException catch (e) { + messenger.showSnackBar( + SnackBar(content: Text(l10n.messageForError(e))), + ); + } on Object { + messenger.showSnackBar(SnackBar(content: Text(l10n.errorGeneric))); + } + } + + Future _unhide(AdminPhoto photo) async { + final l10n = AppLocalizations.of(context); + final messenger = ScaffoldMessenger.of(context); + try { + await widget.controller.unhidePhoto(photo); + messenger.showSnackBar(SnackBar(content: Text(l10n.photoUnhiddenToast))); + } on AppException catch (e) { + messenger.showSnackBar( + SnackBar(content: Text(l10n.messageForError(e))), + ); + } on Object { + messenger.showSnackBar(SnackBar(content: Text(l10n.errorGeneric))); + } + } + + Future _delete(AdminPhoto photo) async { + final l10n = AppLocalizations.of(context); + // 删除确认:明确影响云端照片(与本机任务记录清理区分)。 + final confirmed = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: Text(l10n.photoDeleteConfirmTitle), + content: Text(l10n.photoDeleteConfirmBody), + 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 widget.controller.deletePhoto(photo); + widget.controller.removeById(photo.photoId); + if (!mounted) return; + messenger.showSnackBar(SnackBar(content: Text(l10n.photoDeletedToast))); + // 当前照片已删除且列表为空 → 回网格;否则夹取到有效页。 + if (widget.controller.items.isEmpty) { + Navigator.of(context).pop(); + } else { + setState(() { + _index = _index.clamp(0, widget.controller.items.length - 1); + }); + } + } on AppException catch (e) { + messenger.showSnackBar( + SnackBar(content: Text(l10n.messageForError(e))), + ); + } on Object { + messenger.showSnackBar(SnackBar(content: Text(l10n.errorGeneric))); + } + } + + @override + Widget build(BuildContext context) { + // 整个 Scaffold(含信息栏)随 controller 重建:隐藏/恢复/刷新合并 + // 后操作行与状态需同步更新(仅重建 body 会留下过期的操作入口)。 + return AnimatedBuilder( + animation: widget.controller, + builder: (context, _) { + final items = widget.controller.items; + return Scaffold( + backgroundColor: Colors.black, + body: items.isEmpty + ? const _ViewerShell(child: SizedBox.shrink()) + : _ViewerShell( + child: PageView.builder( + controller: _pageController, + itemCount: items.length, + onPageChanged: _onPageChanged, + itemBuilder: (context, page) => _ViewerPhotoCell( + photo: items[page], + album: widget.album, + controller: widget.controller, + ), + ), + ), + bottomNavigationBar: _infoBar(context), + ); + }, + ); + } + + /// 底部信息栏:上传者 / 时区时间 / 尺寸大小 + 操作行 + photoId 诊断区。 + Widget _infoBar(BuildContext context) { + final l10n = AppLocalizations.of(context); + final items = widget.controller.items; + if (items.isEmpty) return const SizedBox.shrink(); + final photo = items[_index.clamp(0, items.length - 1)]; + final dims = (photo.width != null && photo.height != null) + ? '${photo.width}×${photo.height}' + : null; + final size = _formatBytes(photo.bytes); + final meta = [ + formatAlbumDateTime( + photo.uploadedAt, + widget.album.timezone, + l10n.localeName, + ), + ?dims, + ?size, + ].join(' · '); + return SafeArea( + top: false, + child: Container( + color: Colors.black.withValues(alpha: 0.72), + padding: const EdgeInsets.symmetric( + horizontal: kSpace16, + vertical: kSpace8, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + photo.uploader.displayName.isEmpty + ? photo.uploader.account + : photo.uploader.displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Colors.white, + ), + ), + const SizedBox(height: kSpace4), + Text(meta, style: const TextStyle(color: Colors.white70)), + const SizedBox(height: kSpace4), + // 诊断区域:照片 ID(不与主信息争夺层级)。 + Text( + photo.photoId, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: Colors.white38), + ), + Wrap( + spacing: kSpace16, + children: [ + if (_canHide(photo)) + TextButton.icon( + onPressed: () => _hide(photo), + icon: const Icon(Icons.visibility_off_outlined), + label: Text(l10n.photoHide), + style: TextButton.styleFrom( + foregroundColor: Colors.white, + ), + ), + if (_canUnhide(photo)) + TextButton.icon( + onPressed: () => _unhide(photo), + icon: const Icon(Icons.visibility_outlined), + label: Text(l10n.photoUnhide), + style: TextButton.styleFrom( + foregroundColor: Colors.white, + ), + ), + if (_canDelete(photo)) + TextButton.icon( + onPressed: () => _delete(photo), + icon: const Icon(Icons.delete_outline), + label: Text(l10n.delete), + style: TextButton.styleFrom( + foregroundColor: Colors.white, + ), + ), + ], + ), + ], + ), + ), + ); + } + + static String? _formatBytes(int bytes) { + if (bytes <= 0) return null; + if (bytes < 1024 * 1024) return '${(bytes / 1024).round()} KB'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } +} + +/// 查看器壳层:关闭按钮(安全区避让)+ 内容。 +class _ViewerShell extends StatelessWidget { + const _ViewerShell({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Positioned.fill(child: child), + SafeArea( + child: Align( + alignment: Alignment.topLeft, + child: IconButton( + tooltip: MaterialLocalizations.of(context).closeButtonTooltip, + onPressed: () => Navigator.of(context).pop(), + // 44×44 有效触控区(图标本身 24)。 + icon: const Icon(Icons.close, color: Colors.white), + style: IconButton.styleFrom( + backgroundColor: Colors.black45, + foregroundColor: Colors.white, + ), + ), + ), + ), + ], + ); + } +} + +/// 单张大图:contain 展示;签名 URL 失败 → 一次受控刷新;无预览(处理 +/// 中/已删除/无权)显示状态说明。 +class _ViewerPhotoCell extends StatelessWidget { + const _ViewerPhotoCell({ + required this.photo, + required this.album, + required this.controller, + }); + + final AdminPhoto photo; + final Album album; + final PhotoGridController controller; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final url = photo.preview?.browse.url; + if (url == null) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.image_not_supported_outlined, size: 48, color: Colors.white38), + const SizedBox(height: kSpace12), + StateBadge( + variant: _stateBadgeVariant(photo), + label: _stateBadgeLabel(l10n, photo), + ), + const SizedBox(height: kSpace8), + Text( + l10n.photoPreviewUnavailable, + style: const TextStyle(color: Colors.white54), + ), + ], + ), + ); + } + return _ViewerImage( + imageUrl: url, + photoId: photo.photoId, + controller: controller, + ); + } + + static BadgeVariant _stateBadgeVariant(AdminPhoto photo) { + if (photo.visibility == PhotoVisibility.deleted || + photo.processingState == PhotoProcessingState.processingFailed) { + return BadgeVariant.error; + } + return BadgeVariant.warning; + } + + static String _stateBadgeLabel(AppLocalizations l10n, AdminPhoto photo) { + if (photo.visibility == PhotoVisibility.deleted) { + return l10n.visibilityDeleted; + } + if (photo.processingState == PhotoProcessingState.processingFailed) { + return l10n.procProcessingFailed; + } + return l10n.procProcessing; + } +} + +/// 大图 Image.network:失败时自动按原页参数刷新一次签名 URL;仍失败显示 +/// 占位(不再重试,见 [PhotoGridController.refreshPreview])。 +class _ViewerImage extends StatefulWidget { + const _ViewerImage({ + required this.imageUrl, + required this.photoId, + required this.controller, + }); + + final String imageUrl; + final String photoId; + final PhotoGridController controller; + + @override + State<_ViewerImage> createState() => _ViewerImageState(); +} + +class _ViewerImageState extends State<_ViewerImage> { + bool _autoRefreshScheduled = false; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return Image.network( + widget.imageUrl, + fit: BoxFit.contain, + gaplessPlayback: true, + loadingBuilder: (context, child, progress) => + progress == null ? child : const Center( + child: CircularProgressIndicator(strokeWidth: 2), + ), + errorBuilder: (_, _, _) { + // 失败 → 一次受控刷新(新签名 URL 触发重建重载)。 + if (!_autoRefreshScheduled && widget.controller.canRefresh(widget.photoId)) { + _autoRefreshScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.controller.refreshPreview(widget.photoId); + }); + } + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.broken_image_outlined, + size: 48, + color: Colors.white38, + ), + const SizedBox(height: kSpace8), + Text( + l10n.photoPreviewUnavailable, + style: const TextStyle(color: Colors.white54), + ), + ], + ), + ); + }, + ); + } +} diff --git a/apps/mobile/lib/pages/album_photos_page.dart b/apps/mobile/lib/pages/album_photos_page.dart index bd8f89f..30ea18c 100644 --- a/apps/mobile/lib/pages/album_photos_page.dart +++ b/apps/mobile/lib/pages/album_photos_page.dart @@ -1,7 +1,19 @@ -/// 照片管理占位页(P3 计划 Task 4):基础管理列表(可见性/处理状态 -/// 标签 + 游标加载更多);完整筛选与操作由 Task 9 队列 UI 替换。 +/// 照片管理页(UI-01 §4.3 重写):三列缩略图网格(极窄两列、平板按最小 +/// 格宽扩列)+ 状态角标(处理中/失败/隐藏/删除)+ 筛选 chips + 游标分页 +/// + 点开大图查看器。 +/// +/// 数据范围(scope):先按 all 请求(owner/admin);协作者 403 +/// AUTH_FORBIDDEN 时自适应回退 own(服务端过滤,仅自己上传)——isManager +/// 由此推导,供查看器操作权限(恢复发布仅管理者)使用。缩略图取条目 +/// preview.thumb;签名 URL 失效经 [PhotoGridController.refreshPreview] +/// 受控重取所在页一次(不无限重试)。 +/// +/// Processing/Failed 筛选为已加载页的本地过滤(服务端无对应查询参数), +/// 结果不足时自动补页(上限内),不声称全量。 library; +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -9,12 +21,397 @@ 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 '../core/models/cursor_page.dart'; +import '../core/session_store.dart'; import '../features/albums/models.dart'; import '../features/photos/models.dart'; 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_photo_viewer_page.dart'; -/// 照片管理占位(从详情页携带 [album] 进入,时间按相册时区展示)。 +/// 网格筛选(All / Published / Hidden 走服务端 visibility;Processing / +/// Failed 为已加载条目的本地过滤)。 +enum PhotoGridFilter { + all('all'), + published('published'), + hidden('hidden'), + processing('processing'), + failed('failed'); + + const PhotoGridFilter(this.wire); + + final String wire; + + /// 本地过滤(服务端无 processingState 查询参数)。 + bool get isLocal => this == PhotoGridFilter.processing || + this == PhotoGridFilter.failed; +} + +/// 一页数据的请求规格(刷新签名 URL 时按原参数重取该页)。 +@immutable +class _PageSpec { + const _PageSpec({ + required this.cursor, + required this.visibility, + required this.scope, + required this.startIndex, + required this.count, + }); + + final String? cursor; + final PhotoVisibility? visibility; + final AlbumPhotoScope scope; + final int startIndex; + final int count; +} + +/// 照片网格状态机(页面与查看器共享;ChangeNotifier 驱动两处重建)。 +/// +/// 职责:scope 自适应(all 403 → own)、游标分页、筛选切换、单张可见性 +/// 变更后的条目合并、签名 URL 一次性刷新。所有变更经 [notifyListeners] +/// 通知;dispose 后静默丢弃(页面退出后迟到的响应不触发断言)。 +class PhotoGridController extends ChangeNotifier { + PhotoGridController({ + required ApiClient api, + required Future Function() readSession, + required this.albumId, + this.pageSize = 20, + }) // 私有字段不能作 initializing formal(外部调用方无法传私有名), + // 参数名保持公开语义,忽略该风格提示。 + // ignore: prefer_initializing_formals + : _api = api, + // ignore: prefer_initializing_formals + _readSession = readSession; + + final ApiClient _api; + final Future Function() _readSession; + final String albumId; + final int pageSize; + + List _items = const []; + String? _nextCursor; + bool _hasMore = false; + bool _loading = false; + bool _loadingMore = false; + bool _loadMoreFailed = false; + String? _errorCode; + AlbumPhotoScope _scope = AlbumPhotoScope.all; + bool _scopeResolved = false; + PhotoGridFilter _filter = PhotoGridFilter.all; + final List<_PageSpec> _pages = <_PageSpec>[]; + final Set _refreshed = {}; + String? _selfAccount; + bool _disposed = false; + + /// 已加载条目(全量,含本地筛选被排除的)。 + List get items => _items; + + /// 当前筛选下应显示的条目。 + List get visibleItems => switch (_filter) { + PhotoGridFilter.processing => _items + .where((p) => p.processingState != PhotoProcessingState.ready) + .toList(growable: false), + PhotoGridFilter.failed => _items + .where((p) => p.processingState == PhotoProcessingState.processingFailed) + .toList(growable: false), + _ => _items, + }; + + bool get hasMore => _hasMore; + bool get loading => _loading; + bool get loadingMore => _loadingMore; + bool get loadMoreFailed => _loadMoreFailed; + String? get errorCode => _errorCode; + PhotoGridFilter get filter => _filter; + AlbumPhotoScope get scope => _scope; + + /// scope=all 且已完成自适应判定 → 当前用户是 owner/admin(恢复发布等 + /// 管理操作可用;判定依据为服务端 all 列表的 403 语义)。 + bool get isManager => _scopeResolved && _scope == AlbumPhotoScope.all; + + /// 当前登录账号(「自己上传」判定;读取失败为 null,按非本人处理)。 + String? get selfAccount => _selfAccount; + + /// 该照片是否仍可发起一次签名 URL 刷新(每张仅一次,不无限重试)。 + bool canRefresh(String photoId) => !_refreshed.contains(photoId); + + @override + void dispose() { + _disposed = true; + super.dispose(); + } + + void _notify() { + if (!_disposed) notifyListeners(); + } + + /// 读取当前登录账号(viewer 权限判定用;失败为 null,按非本人处理)。 + Future loadSelfAccount() async { + try { + _selfAccount = (await _readSession())?.account; + } on Object { + _selfAccount = null; + } + } + + /// 首次加载(scope 自适应:all 403 → own 重试一次);已判定后等同 + /// [reload](按已判定 scope 重取第一页)。 + Future resolveAndLoad() async { + if (_scopeResolved) return _reloadFirstPage(); + _loading = true; + _errorCode = null; + _loadMoreFailed = false; + _notify(); + final failure = await _tryFirstPage(AlbumPhotoScope.all); + if (failure == null) { + _scopeResolved = true; + _loading = false; + _notify(); + return; + } + if (failure.code != ErrorCodes.authForbidden) { + _errorCode = failure.code; + _loading = false; + _notify(); + return; + } + // 协作者:all → 403,回退 own(服务端过滤仅自己上传)。 + _scope = AlbumPhotoScope.own; + final ownFailure = await _tryFirstPage(AlbumPhotoScope.own); + _scopeResolved = true; + _errorCode = ownFailure?.code; + _loading = false; + _notify(); + } + + /// 按已判定 scope 重置到第一页(下拉刷新/筛选切换)。 + Future _reloadFirstPage() async { + _loading = true; + _errorCode = null; + _loadMoreFailed = false; + _notify(); + final failure = await _tryFirstPage(_scope); + _errorCode = failure?.code; + _loading = false; + _notify(); + } + + /// 取第一页;成功返回 null,失败返回异常。 + Future _tryFirstPage(AlbumPhotoScope scope) async { + try { + final page = await _fetch( + scope: scope, + visibility: _serverVisibilityFor(_filter), + cursor: null, + limit: pageSize, + ); + _items = page.items; + _nextCursor = page.nextCursor; + _hasMore = page.hasMore; + _loadMoreFailed = false; + _refreshed.clear(); + _pages + ..clear() + ..add( + _PageSpec( + cursor: null, + visibility: _serverVisibilityFor(_filter), + scope: scope, + startIndex: 0, + count: page.items.length, + ), + ); + return null; + } on AppException catch (e) { + return e; + } on Object { + return const AppException( + code: ErrorCodes.internalError, + message: '照片列表加载失败', + ); + } + } + + /// 重置到第一页(下拉刷新/筛选切换;scope 判定结果保持不变)。 + Future reload() => _reloadFirstPage(); + + /// 游标加载下一页(加载中/无更多时为空操作;失败保留已加载条目)。 + Future loadMore() async { + if (_loading || _loadingMore || !_hasMore) return; + _loadingMore = true; + _loadMoreFailed = false; + _notify(); + try { + final cursor = _nextCursor; + final page = await _fetch( + scope: _scope, + visibility: _serverVisibilityFor(_filter), + cursor: cursor, + limit: pageSize, + ); + _pages.add( + _PageSpec( + cursor: cursor, + visibility: _serverVisibilityFor(_filter), + scope: _scope, + startIndex: _items.length, + count: page.items.length, + ), + ); + _items = [..._items, ...page.items]; + _nextCursor = page.nextCursor; + _hasMore = page.hasMore; + _loadingMore = false; + _notify(); + } on AppException { + _loadingMore = false; + _loadMoreFailed = true; + _notify(); + } on Object { + _loadingMore = false; + _loadMoreFailed = true; + _notify(); + } + } + + /// 切换筛选:服务端筛选(all/published/hidden)重载第一页;本地筛选 + /// (processing/failed)仅重算可见集。 + Future setFilter(PhotoGridFilter filter) async { + if (filter == _filter) return; + _filter = filter; + if (!filter.isLocal) { + await reload(); + } else { + _notify(); + } + } + + /// 签名 URL 失效受控刷新:按原请求规格重取该照片所在页,按 photoId + /// 合并新 preview;每张照片仅一次(成功与否均不再重试)。 + Future refreshPreview(String photoId) async { + if (_refreshed.contains(photoId)) return false; + final index = _items.indexWhere((p) => p.photoId == photoId); + if (index < 0) return false; + final spec = _pageForIndex(index); + if (spec == null) return false; + _refreshed.add(photoId); + try { + final page = await _fetch( + scope: spec.scope, + visibility: spec.visibility, + cursor: spec.cursor, + limit: pageSize, + ); + var updated = false; + final byId = { + for (final item in page.items) item.photoId: item, + }; + _items = [ + for (final item in _items) byId[item.photoId] ?? item, + ]; + updated = byId.containsKey(photoId); + _notify(); + return updated; + } on AppException { + return false; + } on Object { + return false; + } + } + + /// 单张可见性变更后合并回列表(保留原 uploader/preview;隐藏不回收 + /// 已签发的预览——管理者仍可看)。 + void applyRecord(PhotoRecord record) { + _items = [ + for (final item in _items) + if (item.photoId == record.photoId) _withRecord(item, record) else item, + ]; + _notify(); + } + + /// 删除成功后移除条目(页边界不再重算:后续 refreshPreview 按近似页 + /// 重取,photoId 未命中时按失败处理,安全降级)。 + void removeById(String photoId) { + _items = _items.where((p) => p.photoId != photoId).toList(growable: false); + _notify(); + } + + /// 隐藏(published → hidden;协作者仅限自己上传,服务端裁决)。 + Future hidePhoto(AdminPhoto photo) async { + final record = await _api.updatePhotoVisibility( + photo.photoId, + PhotoVisibility.hidden, + ); + applyRecord(record); + } + + /// 恢复显示(hidden → published;仅 owner/admin,服务端裁决)。 + Future unhidePhoto(AdminPhoto photo) async { + final record = await _api.updatePhotoVisibility( + photo.photoId, + PhotoVisibility.published, + ); + applyRecord(record); + } + + /// 删除(软删除,云端照片将被移除;权限同隐藏)。 + Future deletePhoto(AdminPhoto photo) => + _api.deletePhoto(photo.photoId); + + AdminPhoto _withRecord(AdminPhoto src, PhotoRecord rec) => AdminPhoto( + uploader: src.uploader, + preview: src.preview, + photoId: rec.photoId, + albumId: rec.albumId, + categoryId: rec.categoryId, + uploaderId: rec.uploaderId, + visibility: rec.visibility, + processingState: rec.processingState, + bytes: rec.bytes, + width: rec.width, + height: rec.height, + shotAt: rec.shotAt, + uploadedAt: rec.uploadedAt, + publishedAt: rec.publishedAt, + likeCount: rec.likeCount, + ); + + /// 覆盖 [index] 的页规格(删除导致边界漂移时按近似页处理)。 + _PageSpec? _pageForIndex(int index) { + for (final spec in _pages) { + if (index >= spec.startIndex && index < spec.startIndex + spec.count) { + return spec; + } + } + return _pages.isEmpty ? null : _pages.first; + } + + static PhotoVisibility? _serverVisibilityFor(PhotoGridFilter filter) => + switch (filter) { + PhotoGridFilter.published => PhotoVisibility.published, + PhotoGridFilter.hidden => PhotoVisibility.hidden, + _ => null, + }; + + Future> _fetch({ + required AlbumPhotoScope scope, + String? cursor, + PhotoVisibility? visibility, + int? limit, + }) => _api.listAlbumPhotos( + albumId, + scope: scope, + visibility: visibility, + cursor: cursor, + limit: limit, + ); +} + +/// 照片管理网格页(从详情页携带 [album] 进入,时间按相册时区展示)。 class AlbumPhotosPage extends ConsumerStatefulWidget { const AlbumPhotosPage({required this.album, super.key}); @@ -25,297 +422,264 @@ class AlbumPhotosPage extends ConsumerStatefulWidget { } class _AlbumPhotosPageState extends ConsumerState { - static const int _pageSize = 20; - - List _items = const []; - String? _nextCursor; - bool _hasMore = false; - bool _loading = true; - bool _loadingMore = false; - String? _errorCode; + PhotoGridController? _controller; + final ScrollController _scrollController = ScrollController(); @override void initState() { super.initState(); - _reload(); + final controller = PhotoGridController( + api: ref.read(apiClientProvider), + readSession: ref.read(sessionStoreProvider).read, + albumId: widget.album.id, + ); + _controller = controller; + controller + ..loadSelfAccount() + ..resolveAndLoad(); } - Future _reload() async { - setState(() { - _loading = true; - _errorCode = null; - }); - try { - final page = await ref.read(apiClientProvider).listAlbumPhotos( - widget.album.id, - limit: _pageSize, - ); - if (mounted) { - setState(() { - _items = page.items; - _nextCursor = page.nextCursor; - _hasMore = page.hasMore; - _loading = false; - }); - } - } on AppException catch (e) { - if (mounted) { - setState(() { - _errorCode = e.code; - _loading = false; - }); - } - } on Object { - if (mounted) { - setState(() { - _errorCode = ErrorCodes.internalError; - _loading = false; - }); - } - } + @override + void dispose() { + _controller?.dispose(); + _scrollController.dispose(); + super.dispose(); } - Future _loadMore() async { - if (_loading || _loadingMore || !_hasMore) return; - setState(() => _loadingMore = true); - try { - final page = await ref.read(apiClientProvider).listAlbumPhotos( - widget.album.id, - cursor: _nextCursor, - limit: _pageSize, - ); - if (mounted) { - setState(() { - _items = [..._items, ...page.items]; - _nextCursor = page.nextCursor; - _hasMore = page.hasMore; - _loadingMore = false; - }); - } - } on AppException { - if (mounted) { - setState(() => _loadingMore = false); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - AppLocalizations.of(context).messageForErrorCode( - ErrorCodes.networkError, - ), - ), - ), - ); - } - } on Object { - if (mounted) { - setState(() => _loadingMore = false); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppLocalizations.of(context).errorGeneric), - ), - ); - } + Future _openViewer(int loadedIndex) async { + final controller = _controller; + if (controller == null) return; + await Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => AlbumPhotoViewerPage( + controller: controller, + album: widget.album, + initialIndex: loadedIndex, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final controller = _controller; + if (controller == null) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); } + return Scaffold( + appBar: AppBar(title: Text(AppLocalizations.of(context).albumPhotos)), + body: AnimatedBuilder( + animation: controller, + builder: (context, _) => _AlbumPhotosBody( + controller: controller, + album: widget.album, + scrollController: _scrollController, + onOpenViewer: _openViewer, + ), + ), + ); + } +} + +/// 网格主体:筛选 chips +(协作者 own 范围提示)+ 网格/空态/错误态。 +class _AlbumPhotosBody extends StatelessWidget { + const _AlbumPhotosBody({ + required this.controller, + required this.album, + required this.scrollController, + required this.onOpenViewer, + }); + + final PhotoGridController controller; + final Album album; + final ScrollController scrollController; + final void Function(int loadedIndex) onOpenViewer; + + /// 本地筛选自动补页上限(防 0 匹配时无限翻页;超过后显示空筛选态)。 + static const int _localFilterAutoLoadCap = 100; + + /// 网格列数(方案 §4.3):极窄(<360)两列;默认三列;平板按 + /// width/150 向下取整扩列(不小于 3)。 + static int columnCount(double width) { + if (width < 360) return 2; + return math.max(3, width ~/ 150); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); - final theme = Theme.of(context); - return Scaffold( - appBar: AppBar(title: Text(l10n.albumPhotos)), - body: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Task 9 占位横幅:完整筛选/操作届时替换本页。 - Card( - margin: const EdgeInsets.fromLTRB(12, 12, 12, 0), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - Icon( - Icons.construction_outlined, - size: 18, - color: theme.colorScheme.onSurfaceVariant, + if (controller.loading) { + return const Center(child: CircularProgressIndicator()); + } + if (controller.items.isEmpty && controller.errorCode != null) { + return ErrorState( + message: l10n.messageForErrorCode(controller.errorCode!), + onRetry: controller.reload, + ); + } + final visible = controller.visibleItems; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: kSpace16), + child: Row( + children: [ + for (final filter in PhotoGridFilter.values) + Padding( + padding: const EdgeInsets.only(right: kSpace8), + child: FilterChip( + label: Text(_filterLabel(l10n, filter)), + selected: controller.filter == filter, + onSelected: (_) => controller.setFilter(filter), ), - const SizedBox(width: 8), - Expanded( - child: Text( - l10n.photosTask9Placeholder, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ), + ), + ], + ), + ), + if (!controller.isManager) + Padding( + padding: const EdgeInsets.fromLTRB( + kSpace16, + kSpace8, + kSpace16, + kSpace4, + ), + child: Text( + l10n.photosScopeOwnHint, + style: Theme.of(context).textTheme.bodySmall, ), ), - Expanded( - child: _loading - ? const Center(child: CircularProgressIndicator()) - : _items.isEmpty && _errorCode != null - ? Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.cloud_off, size: 48), - const SizedBox(height: 12), - Text(l10n.messageForErrorCode(_errorCode!)), - const SizedBox(height: 12), - FilledButton.icon( - onPressed: _reload, - icon: const Icon(Icons.refresh), - label: Text(l10n.retry), - ), - ], - ), - ) - : ListView.builder( - itemCount: _items.length + - (_items.isEmpty || _hasMore || _loadingMore ? 1 : 0), - itemBuilder: (context, index) { - if (_items.isEmpty) { - return Padding( - padding: const EdgeInsets.all(32), - child: Text( - l10n.albumPhotosEmpty, - textAlign: TextAlign.center, - style: theme.textTheme.bodySmall, - ), - ); - } - if (index >= _items.length) { - return const Padding( - padding: EdgeInsets.symmetric(vertical: 16), - child: Center( - child: SizedBox( - height: 24, - width: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ), - ); - } - if (index >= _items.length - 3 && - _hasMore && - !_loadingMore) { - WidgetsBinding.instance.addPostFrameCallback( - (_) => _loadMore(), - ); - } - return _photoTile(l10n, theme, _items[index]); - }, - ), - ), - ], - ), + Expanded(child: _gridOrEmpty(context, l10n, visible)), + ], ); } - Widget _photoTile( + Widget _gridOrEmpty( + BuildContext context, AppLocalizations l10n, - ThemeData theme, - AdminPhoto photo, + List visible, ) { - return ListTile( - leading: Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: const Icon(Icons.image_outlined), - ), - title: Text( - '${photo.photoId.substring(0, 8)} · ${(photo.bytes / 1024).round()} KB', - style: theme.textTheme.bodyMedium?.copyWith( - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - subtitle: Text( - formatAlbumDateTime( - photo.uploadedAt, - widget.album.timezone, - l10n.localeName, - ), - ), - trailing: Wrap( - spacing: 6, - runSpacing: 4, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - _PhotoTag( - label: _visibilityLabel(l10n, photo.visibility), - highlight: photo.visibility == PhotoVisibility.published, + if (visible.isEmpty) { + final filtered = controller.filter != PhotoGridFilter.all; + return EmptyState( + icon: Icons.photo_outlined, + title: filtered ? l10n.photosEmptyFiltered : l10n.albumPhotosEmpty, + ); + } + return LayoutBuilder( + builder: (context, constraints) { + final columns = columnCount(constraints.maxWidth); + return RefreshIndicator( + onRefresh: controller.reload, + child: GridView.builder( + controller: scrollController, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(kSpace4), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columns, + mainAxisSpacing: kSpace4, + crossAxisSpacing: kSpace4, + ), + itemCount: visible.length + (controller.hasMore ? 1 : 0), + itemBuilder: (context, index) { + if (index >= visible.length) { + return const Center( + child: SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + } + _maybeLoadMore(index, visible.length); + final photo = visible[index]; + final loadedIndex = controller.items.indexOf(photo); + return _PhotoGridTile( + photo: photo, + onTap: () => onOpenViewer(loadedIndex), + ); + }, ), - _PhotoTag( - label: _processingLabel(l10n, photo.processingState), - highlight: photo.processingState == PhotoProcessingState.ready, - ), - ], - ), + ); + }, ); } - static String _visibilityLabel(AppLocalizations l10n, PhotoVisibility v) { - switch (v) { - case PhotoVisibility.published: - return l10n.visibilityPublished; - case PhotoVisibility.unpublished: - return l10n.visibilityUnpublished; - case PhotoVisibility.hidden: - return l10n.visibilityHidden; - case PhotoVisibility.deleted: - return l10n.visibilityDeleted; + /// 接近末尾预取下一页;本地筛选可见数不足时自动补页(有上限)。 + void _maybeLoadMore(int index, int visibleCount) { + final nearEnd = index >= visibleCount - 6; + final localStarved = controller.filter.isLocal && + visibleCount < 9 && + controller.items.length < _localFilterAutoLoadCap; + if ((nearEnd || localStarved) && + controller.hasMore && + !controller.loadingMore && + !controller.loading) { + WidgetsBinding.instance.addPostFrameCallback((_) => controller.loadMore()); } } - static String _processingLabel( - AppLocalizations l10n, - PhotoProcessingState s, - ) { - switch (s) { - case PhotoProcessingState.pendingUpload: - return l10n.procPendingUpload; - case PhotoProcessingState.uploaded: - return l10n.procUploaded; - case PhotoProcessingState.processing: - return l10n.procProcessing; - case PhotoProcessingState.ready: - return l10n.procReady; - case PhotoProcessingState.processingFailed: - return l10n.procProcessingFailed; - } - } + static String _filterLabel(AppLocalizations l10n, PhotoGridFilter filter) => + switch (filter) { + PhotoGridFilter.all => l10n.photoFilterAll, + PhotoGridFilter.published => l10n.visibilityPublished, + PhotoGridFilter.hidden => l10n.visibilityHidden, + PhotoGridFilter.processing => l10n.procProcessing, + PhotoGridFilter.failed => l10n.photoFilterFailed, + }; } -/// 照片标签(可见性/处理状态)。 -class _PhotoTag extends StatelessWidget { - const _PhotoTag({required this.label, required this.highlight}); +/// 网格瓦片:1:1 缩略图(cover 裁切)+ 左上角状态角标。 +class _PhotoGridTile extends StatelessWidget { + const _PhotoGridTile({required this.photo, required this.onTap}); - final String label; - final bool highlight; + final AdminPhoto photo; + final VoidCallback onTap; + + /// 角标裁决(优先级:删除 > 失败 > 隐藏 > 处理中;就绪且公开无角标)。 + static (String, BadgeVariant)? _badge(AppLocalizations l10n, AdminPhoto p) { + if (p.visibility == PhotoVisibility.deleted) { + return (l10n.visibilityDeleted, BadgeVariant.neutral); + } + if (p.processingState == PhotoProcessingState.processingFailed) { + return (l10n.procProcessingFailed, BadgeVariant.error); + } + if (p.visibility == PhotoVisibility.hidden) { + return (l10n.visibilityHidden, BadgeVariant.neutral); + } + if (p.processingState != PhotoProcessingState.ready) { + return (l10n.procProcessing, BadgeVariant.warning); + } + return null; + } @override Widget build(BuildContext context) { - final theme = Theme.of(context); - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: highlight - ? theme.colorScheme.secondaryContainer - : theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(999), - ), - child: Text( - label, - style: theme.textTheme.labelSmall?.copyWith( - color: highlight - ? theme.colorScheme.onSecondaryContainer - : theme.colorScheme.onSurfaceVariant, - ), + final l10n = AppLocalizations.of(context); + final badge = _badge(l10n, photo); + return InkWell( + onTap: onTap, + child: Stack( + fit: StackFit.expand, + children: [ + PhotoThumbnail( + url: photo.preview?.thumb.url, + aspectRatio: 1, + ), + if (badge != null) + Positioned( + top: kSpace4, + left: kSpace4, + child: StateBadge( + variant: badge.$2, + label: badge.$1, + compact: true, + ), + ), + ], ), ); } diff --git a/apps/mobile/lib/pages/albums_tab.dart b/apps/mobile/lib/pages/albums_tab.dart index 9707d25..75cb6e7 100644 --- a/apps/mobile/lib/pages/albums_tab.dart +++ b/apps/mobile/lib/pages/albums_tab.dart @@ -1,8 +1,9 @@ -/// 相册列表页签:游标加载更多、下拉刷新、封面卡片(UI-01 §4.1 样板)。 +/// 相册列表页签:游标加载更多、下拉刷新、封面卡片(UI-01 §4.1)。 /// /// 卡片顺序:封面(16:9,无封面中性占位)→ 标题(≤2 行,语言回退链)→ -/// 时间/场地单行 → 公开/上传状态徽章(图标+文案,不只靠颜色)。完整列表 -/// 交互(平板两列、错误重试等)随后续任务迭代。 +/// 时间/场地单行 → 公开/上传状态徽章(图标+文案,不只靠颜色)。手机单列; +/// 可用宽度 > 600 双列(行内卡片等高,不挤压英文标题)。publicToken 与 +/// 四开关堆叠不在主卡片(完整配置在详情设置)。 library; import 'package:flutter/material.dart'; @@ -21,11 +22,15 @@ import '../ui/tokens.dart'; import 'album_detail_page.dart'; import 'album_form_page.dart'; +/// 双列布局的可用宽度阈值(方案 §4.1「平板按可用宽度使用两列」)。 +const double kAlbumTwoColumnMinWidth = 600; + /// 首页「相册」页签。 class AlbumsTab extends ConsumerWidget { const AlbumsTab({super.key}); - Future _openCreate(BuildContext context, WidgetRef ref) async { + /// 打开创建表单;成功后刷新列表(空态 CTA 与 FAB 共用)。 + static Future openCreate(BuildContext context, WidgetRef ref) async { final changed = await Navigator.of(context).push( MaterialPageRoute(builder: (_) => const AlbumFormPage()), ); @@ -41,7 +46,7 @@ class AlbumsTab extends ConsumerWidget { return Scaffold( appBar: AppBar(title: Text(l10n.albums)), floatingActionButton: FloatingActionButton.extended( - onPressed: () => _openCreate(context, ref), + onPressed: () => openCreate(context, ref), icon: const Icon(Icons.add), label: Text(l10n.albumCreate), ), @@ -51,6 +56,10 @@ class AlbumsTab extends ConsumerWidget { } /// 列表主体:按状态分发 加载/错误/空/列表(含加载更多页脚)。 +/// +/// 空态带「新建相册」入口;错误态带重试。列表按可用宽度切换单/双列, +/// 行结构 [ListView.builder](行 = 1-2 张卡片),保证下拉刷新与游标 +/// 加载更多行为与旧版一致。 class AlbumListBody extends ConsumerWidget { const AlbumListBody({required this.state, super.key}); @@ -74,49 +83,75 @@ class AlbumListBody extends ConsumerWidget { return EmptyState( icon: Icons.photo_library_outlined, title: l10n.albumsEmpty, + actionLabel: l10n.albumCreate, + onAction: () => AlbumsTab.openCreate(context, ref), ); } - final hasFooter = state.hasMore || state.loadMoreFailed; - return RefreshIndicator( - onRefresh: notifier.refresh, - child: ListView.builder( - physics: const AlwaysScrollableScrollPhysics(), - itemCount: state.items.length + (hasFooter ? 1 : 0), - itemBuilder: (context, index) { - if (index >= state.items.length) { - if (state.loadMoreFailed) { - return ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: kSpace16, + return LayoutBuilder( + builder: (context, constraints) { + final twoColumns = constraints.maxWidth > kAlbumTwoColumnMinWidth; + final rowCount = (state.items.length / (twoColumns ? 2 : 1)).ceil(); + final hasFooter = state.hasMore || state.loadMoreFailed; + return RefreshIndicator( + onRefresh: notifier.refresh, + child: ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB( + kSpace16, + kSpace8, + kSpace16, + kSpace32, + ), + itemCount: rowCount + (hasFooter ? 1 : 0), + itemBuilder: (context, index) { + if (index >= rowCount) { + if (state.loadMoreFailed) { + return ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.error_outline), + title: Text(l10n.loadMoreFailed), + onTap: notifier.loadMore, + ); + } + return const Padding( + padding: EdgeInsets.symmetric(vertical: kSpace16), + child: Center( + child: SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ); + } + // 接近底部时预取下一页(loadMore 自带并发/末页守卫)。 + if (index >= rowCount - 2 && state.hasMore && !state.loadingMore) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => notifier.loadMore(), + ); + } + if (!twoColumns) { + return AlbumCard(album: state.items[index]); + } + 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])), + ], + ], ), - leading: const Icon(Icons.error_outline), - title: Text(l10n.loadMoreFailed), - onTap: notifier.loadMore, ); - } - return const Padding( - padding: EdgeInsets.symmetric(vertical: kSpace16), - child: Center( - child: SizedBox( - height: 24, - width: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ), - ); - } - // 接近底部时预取下一页(loadMore 自带并发/末页守卫)。 - if (index >= state.items.length - 3 && - state.hasMore && - !state.loadingMore) { - WidgetsBinding.instance.addPostFrameCallback( - (_) => notifier.loadMore(), - ); - } - return AlbumCard(album: state.items[index]); - }, - ), + }, + ), + ); + }, ); } } @@ -140,10 +175,7 @@ class AlbumCard extends StatelessWidget { ? _timeRange(l10n) : '${_timeRange(l10n)} · $venue'; return Card( - margin: const EdgeInsets.symmetric( - horizontal: kSpace16, - vertical: kSpace8, - ), + margin: const EdgeInsets.symmetric(vertical: kSpace8), clipBehavior: Clip.antiAlias, child: InkWell( onTap: () => Navigator.of(context).push( diff --git a/apps/mobile/test/core/api_client_test.dart b/apps/mobile/test/core/api_client_test.dart index dc04373..2c08020 100644 --- a/apps/mobile/test/core/api_client_test.dart +++ b/apps/mobile/test/core/api_client_test.dart @@ -389,11 +389,13 @@ void main() { 'alb-1', visibility: PhotoVisibility.hidden, categoryId: 'cat-9', + scope: AlbumPhotoScope.own, ); final query = adapter.requests.single.query; expect(query, containsPair('visibility', 'hidden')); expect(query, containsPair('categoryId', 'cat-9')); + expect(query, containsPair('scope', 'own')); expect(query.containsKey('cursor'), isFalse); expect(query.containsKey('limit'), isFalse); }); diff --git a/apps/mobile/test/helpers/fakes.dart b/apps/mobile/test/helpers/fakes.dart index 33d2123..d4fef2b 100644 --- a/apps/mobile/test/helpers/fakes.dart +++ b/apps/mobile/test/helpers/fakes.dart @@ -262,6 +262,9 @@ Map adminPhotoJson({ String visibility = 'published', String processingState = 'ready', int bytes = 123456, + String uploaderAccount = 'staff02', + String uploaderName = 'Staff Two', + Map? preview, }) => { 'photoId': photoId, 'albumId': albumId, @@ -278,10 +281,25 @@ Map adminPhotoJson({ 'likeCount': 0, 'uploader': { 'userId': 'u-2', - 'account': 'staff02', - 'displayName': 'Staff Two', + 'account': uploaderAccount, + 'displayName': uploaderName, + }, + 'preview': preview, +}; + +/// 契约 `PhotoPreview` 响应体(thumb/browse 签名 URL)。 +Map photoPreviewJson({ + String thumbUrl = 'http://test.local/thumb.jpg', + String browseUrl = 'http://test.local/browse.jpg', +}) => { + 'thumb': { + 'url': thumbUrl, + 'expiresAt': '2026-09-22T00:00:00Z', + }, + 'browse': { + 'url': browseUrl, + 'expiresAt': '2026-09-22T00:00:00Z', }, - 'preview': null, }; /// 契约游标分页响应体(hasMore 与 nextCursor 成对)。 diff --git a/apps/mobile/test/pages/album_detail_page_test.dart b/apps/mobile/test/pages/album_detail_page_test.dart index f25d0d5..0eff21b 100644 --- a/apps/mobile/test/pages/album_detail_page_test.dart +++ b/apps/mobile/test/pages/album_detail_page_test.dart @@ -1,7 +1,11 @@ -/// 相册详情页 widget 测试(P3 计划 Task 4)。 +/// 相册详情页 widget 测试(UI-01 §4.2 重构后)。 /// -/// 覆盖:详情信息区(标题/状态/时间时区/令牌/开关标签)与动作行; -/// 分类管理三流(新增对话框载荷、名称空校验拦截、编辑 PATCH、删除确认)。 +/// 覆盖:紧凑头部(封面占位 + 标题 + 时区时间场地行)→ 状态徽章 → +/// 照片入口(最近上传预览条 + 管理照片 / 空态「拍摄第一张照片」)→ 次级 +/// 管理入口(ListTile)→ 底部固定上传栏(拍摄上传主 / 从相册选择次,上 +/// 传关闭时禁用并说明);协作者(scope all 403 → own)隐藏管理入口;分类 +/// 管理三流(新增载荷、空名校验、编辑 PATCH、删除确认);底部栏直达连拍 +/// 页与多选入队。 library; import 'dart:io'; @@ -12,6 +16,7 @@ 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/core/notification_permission.dart'; import 'package:photo_live/core/session_store.dart'; import 'package:photo_live/features/uploads/enqueue.dart'; @@ -40,16 +45,27 @@ void main() { ]; }); - /// 路由:GET 详情(反映可变分类表)/ 分类 CRUD;其余路径直接抛错。 + /// 路由:GET 详情(反映可变分类表)/ GET 照片预览(scope 自适应;默认 + /// 管理者空列表)/ 分类 CRUD;其余路径直接抛错。 FakeAdapter detailAdapter({ + List>? photos, + bool collaborator = false, (int, Object?)? Function(String method, String path)? onCategoryMutation, }) => FakeAdapter((o) { if (o.path == '/v1/albums/alb-1' && o.method == 'GET') { return (200, albumJson(categories: categories)); } + 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 ?? >[])); + } if (o.path == '/v1/albums/alb-1/categories' && o.method == 'POST') { - return onCategoryMutation?.call('POST', o.path) ?? (201, categoryJson(id: 'cat-new')); + return onCategoryMutation?.call('POST', o.path) ?? + (201, categoryJson(id: 'cat-new')); } if (o.path.startsWith('/v1/categories/') && o.method == 'PATCH') { return onCategoryMutation?.call('PATCH', o.path) ?? @@ -70,11 +86,9 @@ void main() { Future pumpDetail(WidgetTester tester, ApiClient client) async { await tester.pumpWidget( ProviderScope( - // Task 9 起详情页有上传 FAB(触达 sessionAccountProvider), - // 真实平台通道在测试环境挂起,必须注入替身。 - overrides: [ - ...pageTestOverrides(session: store, client: client), - ], + // 详情页触达 sessionStoreProvider(上传入口账号),真实平台通道 + // 在测试环境挂起,必须注入替身。 + overrides: [...pageTestOverrides(session: store, client: client)], child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, @@ -85,31 +99,165 @@ void main() { await tester.pumpAndSettle(); } - testWidgets('加载详情 → 信息区 + 动作行 + 分类列表', (tester) async { - final adapter = detailAdapter(); + /// 新布局下分类区在折叠线以下:先滚动到位再交互。 + Future reveal(WidgetTester tester, Finder finder) async { + await tester.ensureVisible(finder); + await tester.pumpAndSettle(); + } + + testWidgets('布局结构:头部 → 状态 → 照片入口 → 次级操作 → 底部上传栏', + (tester) async { + final adapter = detailAdapter( + photos: List>.generate( + 3, + (i) => adminPhotoJson( + photoId: 'ph-${(i + 1).toString().padLeft(2, '0')}', + preview: photoPreviewJson(), + ), + ), + ); await pumpDetail(tester, clientOf(adapter)); - // 信息区(标题同时出现在 AppBar 与信息卡)。 - expect(find.text('开幕晚宴'), findsNWidgets(2)); - expect(find.text('Active'), findsOneWidget); + // 紧凑头部:封面占位(无 cover → 中性占位,非转圈)+ 标题 + 单行 + // 时间/场地(相册时区墙钟 + 场地语言回退)。 + final body = find.byType(SingleChildScrollView); expect( - find.text('2026/9/18 14:30 ~ Not set (Asia/Shanghai)'), + find.descendant(of: body, matching: find.text('Opening Gala')), findsOneWidget, ); - expect(find.text('sharetoken000000000001'), findsOneWidget); - expect(find.text('大宴会厅'), findsOneWidget); - // 「Upload」出现两次:开关标签 chip + Task 9 上传 FAB 文案。 - expect(find.text('Upload'), findsNWidgets(2)); - expect(find.text('Original'), findsOneWidget); - // 动作行(5 项)。 - expect(find.text('Edit'), findsOneWidget); - expect(find.text('Members'), findsOneWidget); - // 「Categories」同时出现在动作按钮与分区标题。 - expect(find.text('Categories'), findsNWidgets(2)); - expect(find.text('Share QR code'), findsOneWidget); - expect(find.text('Photos'), findsOneWidget); - // 分类条目(zh · en 名称连接)。 - expect(find.text('仪式 · Ceremony'), findsOneWidget); + expect( + find.text('2026/9/18 14:30 ~ Not set (Asia/Shanghai) · 大宴会厅'), + findsOneWidget, + ); + expect(find.byIcon(Icons.image_outlined), findsOneWidget); + // 预览条缩略图(签名 URL 测试环境失败 → 替代图,不破图)。 + expect(find.byIcon(Icons.broken_image_outlined), findsNWidgets(3)); + + // 状态区:公开/上传徽章(关闭不等于不公开,分别呈现)。 + expect(find.text('Public'), findsOneWidget); + expect(find.text('Uploads on'), findsOneWidget); + expect(find.text('Download'), findsNothing); + expect(find.text('Original'), findsNothing); + expect(find.text('sharetoken000000000001'), findsNothing); + + // 照片入口:分区标题 + 「管理照片」入口 + 3 张预览缩略图。 + expect(find.text('Manage photos'), 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); + + // 底部固定上传栏:拍摄上传(主)+ 从相册选择(次)。 + final capture = find.widgetWithText(FilledButton, 'Capture and upload'); + final pick = find.widgetWithText(OutlinedButton, 'Pick from gallery'); + expect(capture, findsOneWidget); + expect(pick, findsOneWidget); + + // 垂直顺序:标题 → 徽章 → 管理照片 → 底部栏(次级操作在 body 列内, + // 其在树中先于 bottomNavigationBar,几何位置随滚动变化不作断言)。 + 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 captureTop = tester.getRect(capture).top; + expect(titleTop, lessThan(badgeTop)); + expect(badgeTop, lessThan(manageTop)); + expect(manageTop, lessThan(captureTop)); + }); + + testWidgets('无照片:空态引导「拍摄第一张照片」(上传开启时可用)', + (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( + tester.widget(cta).onPressed, + isNotNull, + reason: '上传开启时引导可用', + ); + }); + + testWidgets('上传关闭:底部栏说明原因并禁用双入口 + 引导禁用', (tester) async { + final adapter = FakeAdapter((o) { + if (o.path == '/v1/albums/alb-1' && o.method == 'GET') { + return (200, albumJson(categories: categories, uploadEnabled: false)); + } + if (o.path == '/v1/albums/alb-1/photos' && o.method == 'GET') { + return (200, cursorPageBody(>[])); + } + throw StateError('unexpected ${o.method} ${o.path}'); + }); + 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, + ); + expect( + tester + .widget( + find.widgetWithText(FilledButton, 'Take the first photo'), + ) + .onPressed, + isNull, + reason: '空态引导与底部栏同一开关', + ); + }); + + testWidgets('协作者(all 403 → own):隐藏管理入口,仅见自己上传预览', + (tester) async { + final adapter = detailAdapter( + photos: >[ + adminPhotoJson( + photoId: 'ph-own-1', + uploaderAccount: 'staff02', + preview: photoPreviewJson(), + ), + ], + collaborator: true, + ); + await pumpDetail(tester, clientOf(adapter)); + + // scope 自适应:all(403)→ own(200)。 + final photoRequests = adapter.requests + .where((r) => r.path == '/v1/albums/alb-1/photos') + .toList(); + expect(photoRequests, hasLength(2)); + 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); + expect(find.text('Categories'), findsNothing, reason: '含分区一并隐藏'); + expect(find.widgetWithText(ListTile, 'Share QR code'), findsOneWidget); + expect(find.byIcon(Icons.add_circle_outline), findsNothing); + + // 预览条退化为自己的上传(1 张;头部封面占位另 1 张)。 + expect(find.byIcon(Icons.broken_image_outlined), findsOneWidget); }); testWidgets('新增分类:填名称与排序 → POST 载荷 → 列表刷新', (tester) async { @@ -136,7 +284,8 @@ void main() { ); await pumpDetail(tester, clientOf(adapter)); - await tester.tap(find.byIcon(Icons.add_circle_outline)); + await reveal(tester, find.byIcon(Icons.add_circle_outline)); + await tester.tap(find.byIcon(Icons.add_circle_outline).hitTestable()); await tester.pumpAndSettle(); await tester.enterText( @@ -176,7 +325,8 @@ void main() { await pumpDetail(tester, clientOf(adapter)); final requestsBefore = adapter.requests.length; - await tester.tap(find.byIcon(Icons.add_circle_outline)); + await reveal(tester, find.byIcon(Icons.add_circle_outline)); + await tester.tap(find.byIcon(Icons.add_circle_outline).hitTestable()); await tester.pumpAndSettle(); await tester.tap(find.text('Save')); @@ -213,7 +363,8 @@ void main() { ); await pumpDetail(tester, clientOf(adapter)); - await tester.tap(find.text('仪式 · Ceremony')); + await reveal(tester, find.text('仪式 · Ceremony')); + await tester.tap(find.text('仪式 · Ceremony').hitTestable()); await tester.pumpAndSettle(); // 预填检查。 @@ -260,7 +411,8 @@ void main() { ); await pumpDetail(tester, clientOf(adapter)); - await tester.tap(find.byIcon(Icons.delete_outline)); + await reveal(tester, find.byIcon(Icons.delete_outline)); + await tester.tap(find.byIcon(Icons.delete_outline).hitTestable()); await tester.pumpAndSettle(); expect( @@ -272,7 +424,8 @@ void main() { await tester.pumpAndSettle(); expect(find.text('仪式 · Ceremony'), findsOneWidget); - await tester.tap(find.byIcon(Icons.delete_outline)); + await reveal(tester, find.byIcon(Icons.delete_outline)); + await tester.tap(find.byIcon(Icons.delete_outline).hitTestable()); await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(FilledButton, 'Delete')); await tester.pumpAndSettle(); @@ -287,7 +440,7 @@ void main() { expect(find.text('No categories yet. Tap + to add one'), findsOneWidget); }); - group('上传入口 FAB(Task 9)', () { + group('底部固定上传栏(原 Task 9 FAB 流迁移)', () { late Directory tmp; late List galleryPaths; late TaskStore taskStore; @@ -344,47 +497,33 @@ void main() { await tester.pumpAndSettle(); } - testWidgets('FAB → 底部弹层双入口(拍照上传 / 从相册选择)', (tester) async { + testWidgets('「拍摄上传」→ 直达连拍页(目标相册带出)', (tester) async { await pumpDetailForUpload(tester); - await tester.tap(find.byType(FloatingActionButton)); - await tester.pumpAndSettle(); - - expect(find.text('Capture and upload'), findsOneWidget); - expect(find.text('Pick from gallery'), findsOneWidget); - }); - - testWidgets('拍照上传 → 进入连拍页(目标相册带出)', (tester) async { - await pumpDetailForUpload(tester); - - await tester.tap(find.byType(FloatingActionButton)); - await tester.pumpAndSettle(); await tester.tap(find.text('Capture and upload')); await tester.pumpAndSettle(); expect(find.byType(CapturePage), findsOneWidget); - // 连拍页常驻信息层显示目标相册名(与详情标题同源:默认 zh 主标题)。 + // 连拍页常驻信息层显示目标相册名(与详情标题同源:界面语言回退)。 expect( find.descendant( of: find.byType(CapturePage), - matching: find.text('开幕晚宴'), + matching: find.text('Opening Gala'), ), findsOneWidget, ); }); - testWidgets('从相册选择 → 多选入队 + 入队数提示', (tester) async { + testWidgets('「从相册选择」→ 多选入队 + 入队数提示', (tester) async { final pick1 = File('${tmp.path}/pick1.jpg')..writeAsBytesSync(kJpgBytes); final pick2 = File('${tmp.path}/pick2.jpg')..writeAsBytesSync(kJpgBytes); galleryPaths.addAll([pick1.path, pick2.path]); await pumpDetailForUpload(tester); - await tester.tap(find.byType(FloatingActionButton)); - await tester.pumpAndSettle(); // 入队链路含真实文件 IO:真实事件循环内触发行内动作。 - final tile = find.widgetWithText(ListTile, 'Pick from gallery'); + final button = find.widgetWithText(OutlinedButton, 'Pick from gallery'); await tester.runAsync(() async { - (tile.evaluate().single.widget as ListTile).onTap!(); + (button.evaluate().single.widget as OutlinedButton).onPressed!(); await Future.delayed(const Duration(milliseconds: 300)); }); await tester.pumpAndSettle(); diff --git a/apps/mobile/test/pages/album_photo_viewer_test.dart b/apps/mobile/test/pages/album_photo_viewer_test.dart new file mode 100644 index 0000000..3dd0181 --- /dev/null +++ b/apps/mobile/test/pages/album_photo_viewer_test.dart @@ -0,0 +1,356 @@ +/// 照片大图查看器 widget 测试(UI-01 §4.3)。 +/// +/// 覆盖:信息栏(上传者/相册时区时间/尺寸大小 + ID 诊断区)、左右切换、 +/// 权限(管理者隐藏/恢复/删除;协作者自己上传可隐藏删除、不可恢复)、 +/// 删除确认(云端照片将被删除)、关闭返回网格原位置、签名 URL 失败一次 +/// 受控刷新、临近末尾加载更多。 +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/core/session_store.dart'; +import 'package:photo_live/features/albums/models.dart'; +import 'package:photo_live/l10n/app_localizations.dart'; +import 'package:photo_live/pages/album_photos_page.dart'; +import 'package:photo_live/pages/album_photo_viewer_page.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, + ); + + /// 网格 + 可选打开查看器;返回打开前的网格 Scrollable 像素偏移。 + Future pumpGridAndOpen( + WidgetTester tester, + ApiClient client, { + int tile = 0, + }) async { + await tester.pumpWidget( + ProviderScope( + overrides: [...pageTestOverrides(session: store, client: client)], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: AlbumPhotosPage(album: Album.fromJson(albumJson())), + ), + ), + ); + await tester.pumpAndSettle(); + final gridScrollable = find.descendant( + of: find.byType(GridView), + matching: find.byType(Scrollable), + ); + final before = tester + .state(gridScrollable) + .position + .pixels; + await tester.tap(find.byType(PhotoThumbnail).at(tile)); + await tester.pumpAndSettle(); + return before; + } + + testWidgets('信息栏:上传者 / 相册时区时间 / 尺寸大小 + ID 诊断区', (tester) async { + final adapter = FakeAdapter((o) { + if (o.path != '/v1/albums/alb-1/photos') { + throw StateError('unexpected ${o.method} ${o.path}'); + } + return ( + 200, + cursorPageBody(>[ + adminPhotoJson(photoId: 'ph-info-1', preview: photoPreviewJson()), + ]), + ); + }); + await pumpGridAndOpen(tester, clientOf(adapter)); + + expect(find.byType(AlbumPhotoViewerPage), findsOneWidget); + expect(find.text('Staff Two'), findsOneWidget); + // uploadedAt 07:00Z → 相册时区上海 15:00;尺寸 + 大小。 + expect(find.text('2026/9/18 15:00 · 4000×3000 · 121 KB'), findsOneWidget); + // ID 放诊断区域(弱化呈现)。 + expect(find.text('ph-info-1'), findsOneWidget); + }); + + testWidgets('左右切换已加载照片 + 临近末尾加载更多', (tester) async { + final page1 = List>.generate( + 20, + (i) => adminPhotoJson( + photoId: 'ph-v${(i + 1).toString().padLeft(2, '0')}', + uploaderName: 'Uploader ${i + 1}', + preview: photoPreviewJson(), + ), + ); + final adapter = FakeAdapter((o) { + if (o.path != '/v1/albums/alb-1/photos') { + throw StateError('unexpected ${o.method} ${o.path}'); + } + final cursor = o.uri.queryParameters['cursor']; + if (cursor == null) { + return (200, cursorPageBody(page1, nextCursor: 'vc1')); + } + if (cursor == 'vc1') { + return ( + 200, + cursorPageBody(>[ + adminPhotoJson(photoId: 'ph-vpage2', preview: photoPreviewJson()), + ]), + ); + } + throw StateError('unexpected cursor $cursor'); + }); + await pumpGridAndOpen(tester, clientOf(adapter)); + + // 初始第一张。 + expect(find.text('Uploader 1'), findsOneWidget); + // 左右切换:到第二张。 + await tester.drag(find.byType(PageView), const Offset(-700, 0)); + await tester.pumpAndSettle(); + expect(find.text('Uploader 2'), findsOneWidget); + + // 连续滑到接近已加载末尾(page >= length-3 触发加载更多)。 + for (var i = 0; i < 20; i++) { + await tester.drag(find.byType(PageView), const Offset(-700, 0)); + await tester.pumpAndSettle(); + } + expect( + adapter.requests.any((r) => r.uri.queryParameters['cursor'] == 'vc1'), + isTrue, + reason: '临近末尾按游标加载第二页', + ); + }); + + testWidgets('管理者:已发布照片可隐藏/删除;隐藏照片可恢复', (tester) async { + final adapter = FakeAdapter((o) { + if (o.path == '/v1/albums/alb-1/photos') { + return ( + 200, + cursorPageBody(>[ + adminPhotoJson(photoId: 'ph-pub', preview: photoPreviewJson()), + adminPhotoJson( + photoId: 'ph-hid', + visibility: 'hidden', + preview: photoPreviewJson(), + ), + ]), + ); + } + if (o.method == 'PATCH' && o.path == '/v1/photos/ph-pub') { + return ( + 200, + adminPhotoJson(photoId: 'ph-pub', visibility: 'hidden'), + ); + } + throw StateError('unexpected ${o.method} ${o.path}'); + }); + final client = clientOf(adapter); + await pumpGridAndOpen(tester, client); + + // 已发布:隐藏 + 删除(管理者,scope=all 成功)。 + expect(find.text('Hide'), findsOneWidget); + expect(find.text('Delete'), findsOneWidget); + expect(find.text('Unhide'), findsNothing); + + // 切到第二张(隐藏照片):恢复显示(仅管理者)+ 删除。 + await tester.drag(find.byType(PageView), const Offset(-700, 0)); + await tester.pumpAndSettle(); + expect(find.text('Unhide'), findsOneWidget); + expect(find.text('Hide'), findsNothing); + + // 隐藏操作:PATCH visibility=hidden → 按钮翻转为恢复显示。 + await tester.drag(find.byType(PageView), const Offset(700, 0)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Hide')); + await tester.pumpAndSettle(); + final patch = adapter.requests.singleWhere( + (r) => r.method == 'PATCH' && r.path == '/v1/photos/ph-pub', + ); + expect(patch.body, {'visibility': 'hidden'}); + expect(find.text('Unhide'), findsOneWidget, reason: '隐藏后可恢复'); + expect(find.text('Hide'), findsNothing, reason: '已隐藏态不再显示隐藏入口'); + }); + + testWidgets('协作者(scope=own):自己上传可隐藏/删除,不可恢复', (tester) async { + store.session = Session( + token: 'tok-1', + expiresAt: DateTime.utc(2099, 1, 1), + account: 'staff02', + ); + final adapter = FakeAdapter((o) { + if (o.path != '/v1/albums/alb-1/photos') { + throw StateError('unexpected ${o.method} ${o.path}'); + } + final scope = o.uri.queryParameters['scope']; + if (scope == 'all') return (403, errorBody(ErrorCodes.authForbidden)); + if (scope == 'own') { + return ( + 200, + cursorPageBody(>[ + adminPhotoJson( + photoId: 'ph-own-hidden', + visibility: 'hidden', + uploaderAccount: 'staff02', + preview: null, + ), + ]), + ); + } + throw StateError('unexpected scope $scope'); + }); + await pumpGridAndOpen(tester, clientOf(adapter)); + + // 自己上传的隐藏照片:恢复发布仅创建者/管理员 → 不显示恢复入口。 + expect(find.text('Unhide'), findsNothing); + expect(find.text('Delete'), findsOneWidget, reason: '自己上传可删除'); + // 无预览(隐藏对协作者不签发)→ 状态说明 + 图片暂不可用。 + expect(find.text('Image unavailable'), findsOneWidget); + expect(find.text('ph-own-hidden'), findsOneWidget); + }); + + testWidgets('删除:确认「云端照片」→ DELETE → 条目移除', (tester) async { + final adapter = FakeAdapter((o) { + if (o.path == '/v1/albums/alb-1/photos') { + return ( + 200, + cursorPageBody(>[ + adminPhotoJson(photoId: 'ph-del', preview: photoPreviewJson()), + ]), + ); + } + if (o.method == 'DELETE' && o.path == '/v1/photos/ph-del') { + return (204, null); + } + throw StateError('unexpected ${o.method} ${o.path}'); + }); + await pumpGridAndOpen(tester, clientOf(adapter)); + + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + // 确认弹窗:明确影响云端照片。 + expect(find.text('Delete this photo?'), findsOneWidget); + expect( + find.text('The cloud photo will be deleted. This cannot be undone.'), + findsOneWidget, + ); + // 取消不删。 + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect( + adapter.requests.any((r) => r.method == 'DELETE'), + isFalse, + reason: '取消不触发删除', + ); + + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Delete')); + await tester.pumpAndSettle(); + + expect( + adapter.requests.any( + (r) => r.method == 'DELETE' && r.path == '/v1/photos/ph-del', + ), + isTrue, + ); + // 唯一照片删除后 → 查看器退回网格(空态)。 + expect(find.byType(AlbumPhotoViewerPage), findsNothing); + expect(find.text('No photos yet'), findsOneWidget); + }); + + testWidgets('关闭返回网格原滚动位置', (tester) async { + // 默认测试画布 800 宽 → 5 列;24 条 ≈ 5 行,超出视口可滚动。 + final items = List>.generate( + 24, + (i) => adminPhotoJson( + photoId: 'ph-s${(i + 1).toString().padLeft(2, '0')}', + preview: photoPreviewJson(), + ), + ); + final adapter = FakeAdapter((o) { + if (o.path != '/v1/albums/alb-1/photos') { + throw StateError('unexpected ${o.method} ${o.path}'); + } + return (200, cursorPageBody(items)); + }); + await tester.pumpWidget( + ProviderScope( + overrides: [...pageTestOverrides(session: store, client: clientOf(adapter))], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: AlbumPhotosPage(album: Album.fromJson(albumJson())), + ), + ), + ); + await tester.pumpAndSettle(); + + // 先滚动网格(离开顶部)。 + final gridScrollable = find.descendant( + of: find.byType(GridView), + matching: find.byType(Scrollable), + ); + await tester.drag(gridScrollable, const Offset(0, -600)); + await tester.pumpAndSettle(); + final scrolled = tester + .state(gridScrollable) + .position + .pixels; + expect(scrolled, greaterThan(0)); + + // 打开当前视口内可见的一张(滚动后树序首项可能在缓存区/被遮挡)→ + // 关闭 → 偏移保持。 + await tester.tap(find.byType(PhotoThumbnail).hitTestable().first); + await tester.pumpAndSettle(); + expect(find.byType(AlbumPhotoViewerPage), findsOneWidget); + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + + expect( + tester.state(gridScrollable).position.pixels, + scrolled, + reason: '关闭大图后网格回到原滚动位置', + ); + }); + + testWidgets('签名 URL 失败 → 一次受控刷新(不无限重试)', (tester) async { + var listCalls = 0; + final adapter = FakeAdapter((o) { + if (o.path != '/v1/albums/alb-1/photos') { + throw StateError('unexpected ${o.method} ${o.path}'); + } + listCalls++; + // 刷新后返回新签名 URL(测试环境网络仍失败 → 验证不再重试)。 + return ( + 200, + cursorPageBody(>[ + adminPhotoJson( + photoId: 'ph-url', + preview: photoPreviewJson( + browseUrl: 'http://test.local/browse-v$listCalls.jpg', + ), + ), + ]), + ); + }); + await pumpGridAndOpen(tester, clientOf(adapter)); + + // 初次列表 + 失败后自动刷新一次 = 2 次;再等待后不再增加。 + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(listCalls, 2, reason: '签名 URL 失败仅重取一次'); + expect(find.text('Image unavailable'), findsOneWidget); + }); +} diff --git a/apps/mobile/test/pages/album_photos_page_test.dart b/apps/mobile/test/pages/album_photos_page_test.dart index 6ef6bb8..6422f10 100644 --- a/apps/mobile/test/pages/album_photos_page_test.dart +++ b/apps/mobile/test/pages/album_photos_page_test.dart @@ -1,7 +1,8 @@ -/// 照片管理占位页 widget 测试(P3 计划 Task 4)。 +/// 照片管理网格 widget 测试(UI-01 §4.3)。 /// -/// 覆盖:Task 9 占位横幅、基础列表(可见性/处理状态标签、时区时间)、 -/// 游标加载更多。完整交互由 Task 9 替换。 +/// 覆盖:网格列数(极窄 2 / 默认 3 / 宽屏 5)、状态角标(处理中/失败/ +/// 隐藏/删除)、scope 自适应(all 403 → own + 协作者提示)、游标加载第二 +/// 页、筛选 chips(服务端 visibility 重载 / 本地 processing 过滤)、空态。 library; import 'package:dio/dio.dart'; @@ -9,9 +10,13 @@ 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/features/albums/models.dart'; import 'package:photo_live/l10n/app_localizations.dart'; import 'package:photo_live/pages/album_photos_page.dart'; +import 'package:photo_live/pages/album_photo_viewer_page.dart'; +import 'package:photo_live/ui/components/photo_thumbnail.dart'; +import 'package:photo_live/ui/components/state_badge.dart'; import '../helpers/fakes.dart'; @@ -31,7 +36,7 @@ void main() { Future pumpPhotos(WidgetTester tester, ApiClient client) async { await tester.pumpWidget( ProviderScope( - overrides: [apiClientProvider.overrideWith((ref) => client)], + overrides: [...pageTestOverrides(session: store, client: client)], child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, @@ -42,18 +47,134 @@ void main() { await tester.pumpAndSettle(); } - testWidgets('占位横幅 + 列表标签 + 游标加载第二页', (tester) async { + void setSurface(WidgetTester tester, Size size) { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1.0; + } + + testWidgets('网格列数:350→2 / 390→3 / 800→5(平板按格宽扩列)', (tester) async { + final adapter = FakeAdapter((o) { + if (o.path != '/v1/albums/alb-1/photos') { + throw StateError('unexpected ${o.method} ${o.path}'); + } + // 非空列表(空态不渲染 GridView,无法断言 delegate)。 + return ( + 200, + cursorPageBody(>[ + adminPhotoJson(photoId: 'ph-c1', preview: photoPreviewJson()), + adminPhotoJson(photoId: 'ph-c2', preview: photoPreviewJson()), + ]), + ); + }); + final client = clientOf(adapter); + + setSurface(tester, const Size(350, 800)); + await pumpPhotos(tester, client); + expect(_columnCount(tester), 2, reason: '极窄屏两列'); + + setSurface(tester, const Size(390, 844)); + await pumpPhotos(tester, client); + expect(_columnCount(tester), 3, reason: '默认三列'); + + setSurface(tester, const Size(800, 600)); + await pumpPhotos(tester, client); + expect(_columnCount(tester), 5, reason: '800/150 向下取整 5 列'); + + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + testWidgets('缩略图 + 状态角标(处理中/失败/隐藏;就绪公开无角标)', (tester) async { + setSurface(tester, const Size(390, 844)); + final adapter = FakeAdapter((o) { + if (o.path != '/v1/albums/alb-1/photos') { + throw StateError('unexpected ${o.method} ${o.path}'); + } + return ( + 200, + cursorPageBody(>[ + adminPhotoJson(photoId: 'ph-ready', preview: photoPreviewJson()), + adminPhotoJson( + photoId: 'ph-processing', + processingState: 'processing', + visibility: 'unpublished', + ), + adminPhotoJson( + photoId: 'ph-failed', + processingState: 'processing_failed', + visibility: 'unpublished', + ), + adminPhotoJson(photoId: 'ph-hidden', visibility: 'hidden'), + ]), + ); + }); + await pumpPhotos(tester, clientOf(adapter)); + + // 缩略图来自 preview.thumb(测试环境网络失败 → 替代图占位,不破图)。 + expect(find.byType(PhotoThumbnail), findsNWidgets(4)); + expect(find.byIcon(Icons.broken_image_outlined), findsOneWidget); + // 角标:处理中/失败/隐藏(图标 + 文案,不只靠颜色;文案限定在角标 + // 组件内——筛选 chips 有同名标签)。 + Finder badgeText(String label) => find.descendant( + of: find.byType(StateBadge), + matching: find.text(label), + ); + expect(badgeText('Processing'), findsOneWidget); + expect(badgeText('Processing failed'), findsOneWidget); + expect(badgeText('Hidden'), findsOneWidget); + expect(find.byIcon(Icons.hourglass_top), findsOneWidget); + expect(find.byIcon(Icons.error_outline), findsOneWidget); + expect(find.byIcon(Icons.remove_circle_outline), findsOneWidget); + + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + testWidgets('scope 自适应:all 403 → own 重试 + 协作者提示', (tester) async { + setSurface(tester, const Size(390, 844)); + final adapter = FakeAdapter((o) { + if (o.path != '/v1/albums/alb-1/photos') { + throw StateError('unexpected ${o.method} ${o.path}'); + } + final scope = o.uri.queryParameters['scope']; + if (scope == 'all') return (403, errorBody(ErrorCodes.authForbidden)); + if (scope == 'own') { + return ( + 200, + cursorPageBody(>[ + adminPhotoJson(photoId: 'ph-own-1'), + ]), + ); + } + throw StateError('unexpected scope $scope'); + }); + await pumpPhotos(tester, clientOf(adapter)); + + // 两次请求:all(403)→ own(200,服务端过滤自己上传)。 + expect(adapter.requests, hasLength(2)); + expect(adapter.requests.first.uri.queryParameters['scope'], 'all'); + expect(adapter.requests.last.uri.queryParameters['scope'], 'own'); + // 协作者范围提示可见。 + expect( + find.text('Collaborator view: only photos you uploaded are shown'), + findsOneWidget, + ); + + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + testWidgets('游标加载第二页(携带 cursor)', (tester) async { + setSurface(tester, const Size(390, 844)); final page1 = List>.generate( 20, (i) => adminPhotoJson( photoId: 'ph-${(i + 1).toString().padLeft(8, '0')}', - visibility: i % 2 == 0 ? 'published' : 'unpublished', - processingState: i % 2 == 0 ? 'ready' : 'processing', + preview: photoPreviewJson(), ), ); final page2 = >[ - // 区分度高的 ID:第二页条目渲染后可按标题前缀断言。 - adminPhotoJson(photoId: 'page2item'), + adminPhotoJson(photoId: 'ph-page2item', preview: photoPreviewJson()), ]; final adapter = FakeAdapter((o) { if (o.path != '/v1/albums/alb-1/photos') { @@ -66,29 +187,143 @@ void main() { if (cursor == 'pc1') return (200, cursorPageBody(page2)); throw StateError('unexpected cursor $cursor'); }); - await pumpPhotos(tester, clientOf(adapter)); - // Task 9 占位横幅。 - expect( - find.textContaining('the full experience ships in Task 9'), - findsOneWidget, - ); - // 标签与条目(ListView 懒构建,只断言在渲染的可见条目)。 - expect(find.text('Published'), findsWidgets); - expect(find.text('Unpublished'), findsWidgets); - expect(find.text('Ready'), findsWidgets); - expect(find.text('Processing'), findsWidgets); - expect(find.text('2026/9/18 15:00'), findsWidgets, reason: '相册时区展示'); - // 滚动加载第二页。 - await tester.drag(find.byType(ListView), const Offset(0, -4000)); + await tester.drag(find.byType(GridView), const Offset(0, -4000)); await tester.pumpAndSettle(); expect( adapter.requests.last.uri.queryParameters['cursor'], 'pc1', ); - // 第二页条目渲染(标题展示 photoId 前 8 位)。 - expect(find.textContaining('page2ite'), findsOneWidget); + + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + testWidgets('筛选 chips:Published 走服务端 visibility 重载', (tester) async { + setSurface(tester, const Size(390, 844)); + final adapter = FakeAdapter((o) { + if (o.path != '/v1/albums/alb-1/photos') { + throw StateError('unexpected ${o.method} ${o.path}'); + } + return ( + 200, + cursorPageBody(>[ + adminPhotoJson(photoId: 'ph-1', preview: photoPreviewJson()), + ]), + ); + }); + await pumpPhotos(tester, clientOf(adapter)); + + await tester.tap(find.widgetWithText(FilterChip, 'Published')); + await tester.pumpAndSettle(); + + expect( + adapter.requests.last.uri.queryParameters['visibility'], + 'published', + reason: 'Published 筛选下发服务端 visibility 参数', + ); + + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + testWidgets('本地筛选:Processing 只显示未就绪条目(不发新请求)', (tester) async { + setSurface(tester, const Size(390, 844)); + final adapter = FakeAdapter((o) { + if (o.path != '/v1/albums/alb-1/photos') { + throw StateError('unexpected ${o.method} ${o.path}'); + } + return ( + 200, + cursorPageBody(>[ + adminPhotoJson(photoId: 'ph-ready', preview: photoPreviewJson()), + adminPhotoJson( + photoId: 'ph-busy', + processingState: 'processing', + visibility: 'unpublished', + ), + ]), + ); + }); + await pumpPhotos(tester, clientOf(adapter)); + final requestsBefore = adapter.requests.length; + + // 瓦片角标文案与 chip 同名(「Processing」),按组件类型锁定 chip; + // 390 宽下 Processing/Failed 在横向滚动区外,先滚动到位。 + final processingChip = find.widgetWithText(FilterChip, 'Processing'); + await tester.ensureVisible(processingChip); + await tester.pumpAndSettle(); + await tester.tap(processingChip); + await tester.pumpAndSettle(); + + // 本地过滤:无新请求,仅剩处理中条目(含其角标)。 + expect(adapter.requests, hasLength(requestsBefore)); + expect( + find.descendant(of: find.byType(StateBadge), matching: find.text('Processing')), + findsOneWidget, + ); + expect(find.byType(PhotoThumbnail), findsOneWidget); + + // 本地筛选无匹配 → 空筛选态。 + final failedChip = find.widgetWithText(FilterChip, 'Failed'); + await tester.ensureVisible(failedChip); + await tester.pumpAndSettle(); + await tester.tap(failedChip); + await tester.pumpAndSettle(); + expect(find.text('No photos match this filter'), findsOneWidget); + + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + testWidgets('空列表 → 空态;点击瓦片进入大图查看器', (tester) async { + setSurface(tester, const Size(390, 844)); + final adapter = FakeAdapter((o) { + if (o.path != '/v1/albums/alb-1/photos') { + throw StateError('unexpected ${o.method} ${o.path}'); + } + return (200, cursorPageBody(>[])); + }); + await pumpPhotos(tester, clientOf(adapter)); + expect(find.text('No photos yet'), findsOneWidget); + + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + testWidgets('点击瓦片 → 大图查看器(关闭返回网格)', (tester) async { + setSurface(tester, const Size(390, 844)); + final adapter = FakeAdapter((o) { + if (o.path != '/v1/albums/alb-1/photos') { + throw StateError('unexpected ${o.method} ${o.path}'); + } + return ( + 200, + cursorPageBody(>[ + adminPhotoJson(photoId: 'ph-1', preview: photoPreviewJson()), + ]), + ); + }); + await pumpPhotos(tester, clientOf(adapter)); + + await tester.tap(find.byType(PhotoThumbnail).first); + await tester.pumpAndSettle(); + expect(find.byType(AlbumPhotoViewerPage), findsOneWidget); + + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + expect(find.byType(AlbumPhotoViewerPage), findsNothing); + expect(find.byType(AlbumPhotosPage), findsOneWidget); + + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); }); } + +/// 网格列数(GridView delegate 断言用)。 +int _columnCount(WidgetTester tester) => + (tester.widget(find.byType(GridView)).gridDelegate + as SliverGridDelegateWithFixedCrossAxisCount) + .crossAxisCount; diff --git a/apps/mobile/test/pages/albums_tab_test.dart b/apps/mobile/test/pages/albums_tab_test.dart index 175fe49..66d7028 100644 --- a/apps/mobile/test/pages/albums_tab_test.dart +++ b/apps/mobile/test/pages/albums_tab_test.dart @@ -1,8 +1,9 @@ -/// 相册列表页签 widget 测试(P3 Task 4;UI-01 Task 3 起覆盖封面卡片样板)。 +/// 相册列表页签 widget 测试(P3 Task 4;UI-01 Task 3/4 覆盖封面卡片与 +/// 响应式列表)。 /// -/// 覆盖:封面卡片样板(封面占位/标题回退/时间场地行/公开与上传徽章/ -/// 令牌与四开关移除)、关闭状态徽章、封面加载失败替代图、游标加载更多 -/// (第二页携带 cursor)、空态文案、首屏错误 + 重试恢复。 +/// 覆盖:封面卡片(封面占位/标题回退/时间场地行/公开与上传徽章/令牌与 +/// 四开关移除)、关闭状态徽章、封面加载失败替代图、响应式单/双列、空态 +/// CTA、游标加载更多(第二页携带 cursor)、首屏错误 + 重试恢复。 library; import 'package:dio/dio.dart'; @@ -14,6 +15,7 @@ import 'package:photo_live/core/error_codes.dart'; import 'package:photo_live/features/albums/providers.dart'; import 'package:photo_live/l10n/app_localizations.dart'; import 'package:photo_live/pages/albums_tab.dart'; +import 'package:photo_live/pages/album_form_page.dart'; import 'package:photo_live/ui/components/photo_thumbnail.dart'; import '../helpers/fakes.dart'; @@ -45,6 +47,12 @@ void main() { await tester.pumpAndSettle(); } + /// 固定逻辑宽度(dp=1)。 + void setSurface(WidgetTester tester, Size size) { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1.0; + } + testWidgets('封面卡片样板:封面占位 + 标题回退 + 时间场地 + 公开/上传徽章', (tester) async { final adapter = FakeAdapter( (o) => o.path == '/v1/albums' @@ -101,6 +109,67 @@ void main() { expect(find.byIcon(Icons.image_outlined), findsNothing); }); + testWidgets('响应式列数:390 宽单列 / 900 宽双列', (tester) async { + final adapter = FakeAdapter( + (o) => o.path == '/v1/albums' + ? ( + 200, + cursorPageBody([ + albumJson(id: 'alb-1'), + albumJson(id: 'alb-2'), + ]), + ) + : throw StateError('unexpected ${o.path}'), + ); + final client = clientOf(adapter); + + // 手机宽度(390):单列,两卡片同宽同行不可见。 + setSurface(tester, const Size(390, 844)); + await pumpTab(tester, client); + final card1 = tester.getRect(find.byType(PhotoThumbnail).first); + final card2 = tester.getRect(find.byType(PhotoThumbnail).last); + expect(card1.width, closeTo(390 - 32, 0.5), reason: '单列占满(左右 16)'); + expect( + card1.right == card2.right && card1.left == card2.left, + isTrue, + reason: '两卡片同列(垂直排布)', + ); + + // 平板宽度(900):双列并排。 + setSurface(tester, const Size(900, 1200)); + await pumpTab(tester, client); + final wide1 = tester.getRect(find.byType(PhotoThumbnail).first); + final wide2 = tester.getRect(find.byType(PhotoThumbnail).last); + expect(wide1.width, closeTo((900 - 32 - 12) / 2, 0.5), reason: '双列均分'); + expect( + wide2.left > wide1.right, + isTrue, + reason: '第二张在第一张右侧(双列)', + ); + + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + testWidgets('空列表 → 空态文案 + 「新建相册」CTA 进入表单', (tester) async { + final adapter = FakeAdapter( + (o) => o.path == '/v1/albums' + ? (200, cursorPageBody(>[])) + : throw StateError('unexpected ${o.path}'), + ); + await pumpTab(tester, clientOf(adapter)); + + expect( + find.text('No albums yet. Tap "Create album" to get started'), + findsOneWidget, + ); + // 空态 CTA(次强调按钮)→ 创建表单。 + expect(find.byType(AlbumFormPage), findsNothing); + await tester.tap(find.widgetWithText(FilledButton, 'Create album')); + await tester.pumpAndSettle(); + expect(find.byType(AlbumFormPage), findsOneWidget); + }); + testWidgets('滚动接近底部 → 游标加载第二页(携带 cursor)', (tester) async { final page1 = List>.generate( 20, @@ -146,20 +215,6 @@ void main() { ); }); - testWidgets('空列表 → 空态文案', (tester) async { - final adapter = FakeAdapter( - (o) => o.path == '/v1/albums' - ? (200, cursorPageBody(>[])) - : throw StateError('unexpected ${o.path}'), - ); - await pumpTab(tester, clientOf(adapter)); - - expect( - find.text('No albums yet. Tap "Create album" to get started'), - findsOneWidget, - ); - }); - testWidgets('首屏错误 → 错误视图,重试后恢复', (tester) async { var failFirst = true; final adapter = FakeAdapter((o) {