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