feat(mobile): UI-03 album workspace pages (05-08)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-22 00:57:59 +08:00
parent 95e8417553
commit 5715c608d8
9 changed files with 1235 additions and 306 deletions
+372 -189
View File
@@ -1,7 +1,13 @@
/// 相册详情页(UI-01 §4.2 重构):紧凑封面与活动信息 → 公开/上传状态 →
/// 照片入口(最近上传预览条 / 拍摄第一张照片引导)→ 次级管理入口(编辑/
/// 成员/分类/分享,ListTile 次强调)+ 分类管理区;底部固定「拍摄上传
/// (主)/ 从相册选择(次)」,避让安全区,上传关闭时说明原因并禁用。
/// 相册详情页(UI-03 §4 页面 06-08 重设计):顶栏返回 + 标题 +「⋯」
/// 操作菜单(BottomSheet:编辑/成员/分类/分享,按角色过滤,先关面板再
/// 导航)→ 全宽 16:9 封面 → 标题/时间地点/公开与上传状态 → 次级四入口
/// (Photos/Members/Categories/Share,≥44dp)→ Recent photos 三列预览
/// (≤9 张)+ See all →(管理者)分类管理区;底部固定「拍摄上传(主)/
/// 从相册选择(次)」,避让安全区,上传关闭时说明原因并禁用。
///
/// 空相册(页面 08):保留真实封面与资料,中心安静占位「Ready for your
/// first photo」;协作者显示只读说明;有处理中照片时显示「处理中」,不
/// 当绝对空白,无伪造计数与示例照片。
///
/// 数据范围:照片预览按 scope 自适应(all 403 → own)推导管理者身份——
/// 创建者/管理员见管理入口;协作者仅见被授权内容(照片入口为自己的
@@ -31,8 +37,11 @@ import 'album_photos_page.dart';
import 'capture_page.dart';
import 'share_qr_dialog.dart';
/// 详情页照片预览条取数上限(方案 §4.2「3-5 张缩略图」)。
const int _kPreviewStripLimit = 5;
/// 详情页最近照片三列网格取数上限(方案 §4.2「最多 6-9 张」)。
const int _kRecentGridLimit = 9;
/// 操作菜单(页面 07)的动作标识:面板 pop 返回,页面侧统一分发导航。
enum AlbumAction { edit, members, categories, share }
/// 相册详情([albumId] 路由参数;进入后 GET /albums/{id} + 照片预览)。
class AlbumDetailPage extends ConsumerStatefulWidget {
@@ -52,7 +61,7 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
bool _loading = true;
String? _errorCode;
/// 最近上传预览条(协作者为「自己上传」,服务端过滤)。
/// 最近照片网格(协作者为「自己上传」,服务端过滤)。
List<AdminPhoto> _recentPhotos = const <AdminPhoto>[];
bool _photosLoading = true;
@@ -105,7 +114,7 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
}
/// 照片入口预览:先按 all(owner/admin);403 回退 own(协作者仅自己
/// 上传)。非 403 失败不阻塞详情页(入口退化为「管理照片」按钮)。
/// 上传)。非 403 失败不阻塞详情页(入口退化为「查看全部」按钮)。
Future<void> _loadPhotoPreview() async {
final album = _album;
if (album == null) return;
@@ -114,7 +123,7 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
final page = await api.listAlbumPhotos(
album.id,
scope: AlbumPhotoScope.all,
limit: _kPreviewStripLimit,
limit: _kRecentGridLimit,
);
if (!mounted) return;
setState(() {
@@ -135,7 +144,7 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
final ownPage = await api.listAlbumPhotos(
album.id,
scope: AlbumPhotoScope.own,
limit: _kPreviewStripLimit,
limit: _kRecentGridLimit,
);
if (!mounted) return;
setState(() {
@@ -161,6 +170,8 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
}
}
// ── 导航入口(四入口与操作菜单共享,不另起一套实现) ───────────────
/// 编辑成功后:详情重载 + 全局列表同步。
Future<void> _openEdit() async {
final album = _album;
@@ -195,7 +206,7 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
builder: (_) => AlbumPhotosPage(album: album),
),
);
// 管理页内可能发生隐藏/删除,回来后刷新预览条。
// 管理页内可能发生隐藏/删除,回来后刷新预览网格。
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 视为异常)。
@@ -326,7 +358,16 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
final album = _album;
final title = album == null ? l10n.albumDetail : albumDisplayTitle(album, l10n.localeName);
return Scaffold(
appBar: AppBar(title: Text(title)),
appBar: AppBar(
title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis),
actions: [
IconButton(
tooltip: l10n.moreActions,
onPressed: _showActionMenu,
icon: const Icon(Icons.more_vert),
),
],
),
bottomNavigationBar: album == null ? null : _UploadBar(
uploadEnabled: album.uploadEnabled,
onCapture: _openCapture,
@@ -341,38 +382,78 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
)
: SingleChildScrollView(
controller: _scrollController,
padding: const EdgeInsets.all(kSpace16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_CompactHeader(album: album),
const SizedBox(height: kSpace16),
_StatusSection(album: album),
const SizedBox(height: kSpace16),
_PhotoEntrySection(
album: album,
photos: _recentPhotos,
loading: _photosLoading,
isManager: _isManager,
onOpenPhotos: _openPhotos,
onTakeFirst: _openCapture,
// 封面:全宽 16:9(无封面同尺寸中性占位)。
PhotoThumbnail(url: album.cover?.url, aspectRatio: 16 / 9),
Padding(
padding: const EdgeInsets.fromLTRB(
kSpace16, kSpace16, kSpace16, 0,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
albumDisplayTitle(album, l10n.localeName),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: kSpace4),
Text(
_metaLine(album, l10n),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
),
const SizedBox(height: kSpace12),
_StatusSection(album: album),
],
),
),
const SizedBox(height: kSpace16),
_SecondaryActions(
showManage: _isManager,
onEdit: _openEdit,
onMembers: _openMembers,
onCategories: _scrollToCategories,
onShareQr: _shareQr,
// 次级四入口(Photos/Members/Categories/Share)。
Padding(
padding: const EdgeInsets.symmetric(
horizontal: kSpace16,
),
child: _ActionTiles(
isManager: _isManager,
onPhotos: _openPhotos,
onMembers: _openMembers,
onCategories: _scrollToCategories,
onShare: _shareQr,
),
),
const SizedBox(height: kSpace24),
// Recent photos:三列网格(≤9)/ 空相册安静占位 /
// 处理中指示。
Padding(
padding: const EdgeInsets.symmetric(
horizontal: kSpace16,
),
child: _RecentPhotosSection(
photos: _recentPhotos,
loading: _photosLoading,
isManager: _isManager,
onOpenPhotos: _openPhotos,
),
),
if (_isManager) ...[
const SizedBox(height: kSpace16),
_CategoriesSection(
key: _categoriesKey,
album: album,
onAdd: () => _openCategoryDialog(),
onEdit: (c) => _openCategoryDialog(c),
onDelete: _deleteCategory,
const SizedBox(height: kSpace24),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: kSpace16,
),
child: _CategoriesSection(
key: _categoriesKey,
album: album,
onAdd: () => _openCategoryDialog(),
onEdit: (c) => _openCategoryDialog(c),
onDelete: _deleteCategory,
),
),
],
const SizedBox(height: kSpace32),
@@ -381,65 +462,79 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
),
);
}
}
/// 紧凑头部:封面缩略图(16:9,最高 200)+ 标题 + 时间/场地单行。
class _CompactHeader extends StatelessWidget {
const _CompactHeader({required this.album});
final Album album;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
/// 单行时间/场地(相册时区墙钟 + 场地语言回退;结束未定显示「未定」)。
static String _metaLine(Album album, AppLocalizations l10n) {
final venue = albumDisplayVenue(album, l10n.localeName);
final start =
formatAlbumDateTime(album.startAt, album.timezone, l10n.localeName);
final end = album.endAt == null
? l10n.notSet
: formatAlbumDateTime(album.endAt!, album.timezone, l10n.localeName);
final meta = venue == null
return venue == null
? '$start ~ $end (${album.timezone})'
: '$start ~ $end (${album.timezone}) · $venue';
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
LayoutBuilder(
builder: (context, constraints) {
// 16:9 封面,最高 200(超出按高度封顶,宽度同步收窄)。
final height = constraints.maxWidth * 9 / 16;
final capped = height > 200.0;
final h = capped ? 200.0 : height;
final w = capped ? h * 16 / 9 : constraints.maxWidth;
return Center(
child: ClipRRect(
borderRadius: BorderRadius.circular(kRadiusCard),
child: SizedBox(
width: w,
height: h,
child: PhotoThumbnail(
url: album.cover?.url,
aspectRatio: w / h,
}
}
/// 操作菜单(UI-03 §4 页面 07):Edit album / Manage members /
/// Categories / Share album + Cancel,按角色过滤(管理类动作仅
/// owner/admin;分享对全体成员开放)。点击 pop 对应 [AlbumAction],
/// 由详情页在面板关闭后统一导航。
class AlbumActionSheet extends StatelessWidget {
const AlbumActionSheet({required this.isManager, super.key});
/// 是否管理者(owner/admin):决定管理类动作是否显示。
final bool isManager;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
// 大字体/小屏下面板内容可滚动,不溢出。
return SafeArea(
top: false,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (isManager) ...[
ListTile(
leading: const Icon(Icons.edit_outlined),
title: Text(l10n.editAlbum),
onTap: () => Navigator.of(context).pop(AlbumAction.edit),
),
ListTile(
leading: const Icon(Icons.group_outlined),
title: Text(l10n.manageMembers),
onTap: () => Navigator.of(context).pop(AlbumAction.members),
),
ListTile(
leading: const Icon(Icons.category_outlined),
title: Text(l10n.actionCategories),
onTap: () => Navigator.of(context).pop(AlbumAction.categories),
),
],
ListTile(
leading: const Icon(Icons.share_outlined),
title: Text(l10n.shareAlbum),
onTap: () => Navigator.of(context).pop(AlbumAction.share),
),
const Divider(indent: kSpace16, endIndent: kSpace16),
ListTile(
title: Center(
child: Text(
l10n.cancel,
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
);
},
onTap: () => Navigator.of(context).pop(),
),
],
),
const SizedBox(height: kSpace12),
Text(
albumDisplayTitle(album, l10n.localeName),
style: theme.textTheme.titleLarge,
),
const SizedBox(height: kSpace4),
Text(
meta,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
);
}
}
@@ -453,45 +548,162 @@ class _StatusSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Row(
return Wrap(
spacing: kSpace8,
runSpacing: kSpace8,
children: [
StateBadge(
variant: album.publicEnabled ? BadgeVariant.info : BadgeVariant.neutral,
label: album.publicEnabled ? l10n.tagPublic : l10n.albumPublicOff,
),
const SizedBox(width: kSpace8),
StateBadge(
variant:
album.uploadEnabled ? BadgeVariant.success : BadgeVariant.neutral,
label: album.uploadEnabled ? l10n.albumUploadsOn : l10n.albumUploadsOff,
),
if (album.status == AlbumStatus.offline) ...[
const SizedBox(width: kSpace8),
if (album.status == AlbumStatus.offline)
StateBadge(variant: BadgeVariant.error, label: l10n.albumStatusOffline),
],
],
);
}
}
/// 照片入口:有照片 → 预览条(≤5 张)+「管理照片」;无照片 →「拍摄第
/// 一张照片」引导(上传关闭时禁用,由底部栏说明原因)。
class _PhotoEntrySection extends StatelessWidget {
const _PhotoEntrySection({
required this.album,
/// 次级四入口(UI-03 §4 页面 06):Photos / Members / Categories / Share
/// 等宽平铺,每格有效触控区 ≥44dp(实际高 72)。管理类入口(成员/分类)
/// 仅管理者可见;Photos 与 Share 对全体成员开放。
class _ActionTiles extends StatelessWidget {
const _ActionTiles({
required this.isManager,
required this.onPhotos,
required this.onMembers,
required this.onCategories,
required this.onShare,
});
final bool isManager;
final VoidCallback onPhotos;
final VoidCallback onMembers;
final VoidCallback onCategories;
final VoidCallback onShare;
@override
Widget build(BuildContext context) {
return Card(
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
child: Row(
children: [
Expanded(
child: _ActionTile(
icon: Icons.photo_library_outlined,
label: AppLocalizations.of(context).actionPhotos,
onTap: onPhotos,
),
),
if (isManager) ...[
const VerticalDivider(width: 1),
Expanded(
child: _ActionTile(
icon: Icons.group_outlined,
label: AppLocalizations.of(context).actionMembers,
onTap: onMembers,
),
),
const VerticalDivider(width: 1),
Expanded(
child: _ActionTile(
icon: Icons.category_outlined,
label: AppLocalizations.of(context).actionCategories,
onTap: onCategories,
),
),
],
const VerticalDivider(width: 1),
Expanded(
child: _ActionTile(
icon: Icons.share_outlined,
label: AppLocalizations.of(context).actionShare,
onTap: onShare,
),
),
],
),
);
}
}
/// 单个入口格:圆角容器图标 + 标签纵排(≥44dp 触控要求)。
class _ActionTile extends StatelessWidget {
const _ActionTile({
required this.icon,
required this.label,
required this.onTap,
});
final IconData icon;
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return InkWell(
onTap: onTap,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 72),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: kSpace12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(kRadiusInput),
),
child: Icon(
icon,
size: 22,
color: theme.colorScheme.primary,
),
),
const SizedBox(height: kSpace8),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
),
),
);
}
}
/// Recent photos(UI-03 §4 页面 06/08):
/// - 加载中 → 占位高度居中转圈;
/// - 有照片 → 三列网格(≤9 张,真实缩略图;URL 失败为中性占位),
/// 其中存在服务端处理中的照片时在网格上方提示「Processing...」;
/// - 无照片 → 安静空态(页面 08):图标 + 「Ready for your first photo」,
/// 协作者显示只读说明;不显示伪造计数与示例照片。
class _RecentPhotosSection extends StatelessWidget {
const _RecentPhotosSection({
required this.photos,
required this.loading,
required this.isManager,
required this.onOpenPhotos,
required this.onTakeFirst,
});
final Album album;
final List<AdminPhoto> photos;
final bool loading;
final bool isManager;
final VoidCallback onOpenPhotos;
final VoidCallback onTakeFirst;
@override
Widget build(BuildContext context) {
@@ -500,16 +712,16 @@ class _PhotoEntrySection extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SectionHeader(
title: l10n.albumPhotos,
title: l10n.recentPhotos,
trailing: TextButton(
onPressed: onOpenPhotos,
child: Text(l10n.albumManagePhotos),
child: Text(l10n.seeAllPhotos),
),
),
const SizedBox(height: kSpace8),
if (loading)
const SizedBox(
height: 96,
height: 120,
child: Center(
child: SizedBox(
height: 24,
@@ -518,105 +730,76 @@ class _PhotoEntrySection extends StatelessWidget {
),
),
)
else if (photos.isNotEmpty)
SizedBox(
height: 96,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: photos.length,
separatorBuilder: (_, _) => const SizedBox(width: kSpace8),
itemBuilder: (context, index) {
final photo = photos[index];
return SizedBox(
width: 96,
height: 96,
child: PhotoThumbnail(
url: photo.preview?.thumb.url,
aspectRatio: 1,
else if (photos.isNotEmpty) ...[
if (_hasProcessing)
Padding(
padding: const EdgeInsets.only(bottom: kSpace8),
child: Row(
children: [
Icon(
Icons.hourglass_top,
size: 16,
color: Theme.of(context).colorScheme.secondary,
),
);
},
const SizedBox(width: kSpace8),
Text(
l10n.processingPhotos,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
)
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: EdgeInsets.zero,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: kSpace8,
crossAxisSpacing: kSpace8,
),
itemCount: photos.length,
itemBuilder: (context, index) => PhotoThumbnail(
url: photos[index].preview?.thumb.url,
aspectRatio: 1,
),
),
]
else
Row(
children: [
Icon(
Icons.photo_camera_outlined,
size: 20,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: kSpace8),
Expanded(
child: Text(
l10n.albumPhotosEmpty,
style: Theme.of(context).textTheme.bodySmall,
Padding(
padding: const EdgeInsets.symmetric(vertical: kSpace24),
child: Column(
children: [
Icon(
Icons.photo_camera_outlined,
size: 48,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: kSpace8),
FilledButton.tonalIcon(
onPressed: album.uploadEnabled ? onTakeFirst : null,
icon: const Icon(Icons.photo_camera_outlined),
label: Text(l10n.albumTakeFirstPhoto),
),
],
const SizedBox(height: kSpace12),
Text(
l10n.emptyAlbumTitle,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: kSpace4),
Text(
isManager ? l10n.emptyAlbumSubtitle : l10n.readonlyMember,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
],
);
}
}
/// 次级管理入口(ListTile 次强调,不再与主操作同级):编辑 / 成员 / 分类
/// (滚动定位)/ 分享二维码。管理类入口仅创建者/管理员可见;分享对全体
/// 成员开放(publicToken 本身对成员可见)。
class _SecondaryActions extends StatelessWidget {
const _SecondaryActions({
required this.showManage,
required this.onEdit,
required this.onMembers,
required this.onCategories,
required this.onShareQr,
});
final bool showManage;
final VoidCallback onEdit;
final VoidCallback onMembers;
final VoidCallback onCategories;
final VoidCallback onShareQr;
@override
Widget build(BuildContext context) {
return Card(
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
child: Column(
children: [
if (showManage) ...[
ListTile(
leading: const Icon(Icons.edit_outlined),
title: Text(AppLocalizations.of(context).actionEdit),
onTap: onEdit,
),
ListTile(
leading: const Icon(Icons.group_outlined),
title: Text(AppLocalizations.of(context).albumMembers),
onTap: onMembers,
),
ListTile(
leading: const Icon(Icons.category_outlined),
title: Text(AppLocalizations.of(context).albumCategories),
onTap: onCategories,
),
],
ListTile(
leading: const Icon(Icons.qr_code_2),
title: Text(AppLocalizations.of(context).shareQrTitle),
onTap: onShareQr,
),
],
),
);
}
/// 是否存在服务端仍在处理的照片(处理中提示;不当绝对空白)。
bool get _hasProcessing => photos.any(
(p) => p.processingState == PhotoProcessingState.processing,
);
}
/// 底部固定上传栏:拍摄上传(主)+ 从相册选择(次),避让安全区;上传
+178 -71
View File
@@ -1,9 +1,10 @@
/// 相册列表页签:游标加载更多、下拉刷新、封面卡片(UI-01 §4.1)。
/// 相册列表页签:游标加载更多、下拉刷新、整图封面卡(UI-03 §4 页面 05)。
///
/// 卡片顺序:封面(16:9,无封面中性占位)→ 标题(≤2 行,语言回退链)→
/// 时间/场地单行 → 公开/上传状态徽章(图标+文案,不只靠颜色)。手机单列;
/// 可用宽度 > 600 双列(行内卡片等高,不挤压英文标题)。publicToken 与
/// 四开关堆叠不在主卡片(完整配置在详情设置)。
/// 卡片为 16:9 全幅封面(无封面同尺寸中性占位),标题(白,≤2 行,语言
/// 回退链)/时间场地(white70 单行)/公开与上传状态小徽章叠加在底部暗色
/// 渐变上——文字区域高于基准时卡片增高,不截掉徽章。手机单列;可用宽度
/// > 600 双列(行内卡片等高)。publicToken 与四开关堆叠不在主卡片(完整
/// 配置在详情设置)。
library;
import 'package:flutter/material.dart';
@@ -17,7 +18,6 @@ import '../l10n/app_localizations.dart';
import '../ui/components/empty_state.dart';
import '../ui/components/error_state.dart';
import '../ui/components/photo_thumbnail.dart';
import '../ui/components/state_badge.dart';
import '../ui/tokens.dart';
import 'album_detail_page.dart';
import 'album_form_page.dart';
@@ -25,11 +25,12 @@ import 'album_form_page.dart';
/// 双列布局的可用宽度阈值(方案 §4.1「平板按可用宽度使用两列」)。
const double kAlbumTwoColumnMinWidth = 600;
/// 首页「相册」页签。
/// 首页「相册」页签(UI-03 页面 05):标题 + 副标题,右上「新建」圆形
/// 入口;空态 CTA 与之共用 [AlbumsTab.openCreate]。
class AlbumsTab extends ConsumerWidget {
const AlbumsTab({super.key});
/// 打开创建表单;成功后刷新列表(空态 CTA 与 FAB 共用)。
/// 打开创建表单;成功后刷新列表(空态 CTA 与右上入口共用)。
static Future<void> openCreate(BuildContext context, WidgetRef ref) async {
final changed = await Navigator.of(context).push<bool>(
MaterialPageRoute<bool>(builder: (_) => const AlbumFormPage()),
@@ -42,13 +43,33 @@ class AlbumsTab extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final state = ref.watch(albumListProvider);
return Scaffold(
appBar: AppBar(title: Text(l10n.albums)),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => openCreate(context, ref),
icon: const Icon(Icons.add),
label: Text(l10n.albumCreate),
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.albums),
Text(
l10n.albumsSubtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
actions: [
Padding(
padding: const EdgeInsets.only(right: kSpace16),
child: IconButton.filled(
tooltip: l10n.albumCreate,
onPressed: () => openCreate(context, ref),
icon: const Icon(Icons.add),
),
),
],
),
body: AlbumListBody(state: state),
);
@@ -136,17 +157,19 @@ class AlbumListBody extends ConsumerWidget {
}
final first = index * 2;
final hasSecond = first + 1 < state.items.length;
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: AlbumCard(album: state.items[first])),
if (hasSecond) ...[
const SizedBox(width: kSpace12),
Expanded(child: AlbumCard(album: state.items[first + 1])),
],
// 双列并排:卡片高度由 16:9 封面主导、各自自撑(不用
// IntrinsicHeight 等高——整图卡内含 LayoutBuilder 不支持
// intrinsic 计算;文字区域高于基准的极端大字体下允许行内
// 微小高度差)。
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: AlbumCard(album: state.items[first])),
if (hasSecond) ...[
const SizedBox(width: kSpace12),
Expanded(child: AlbumCard(album: state.items[first + 1])),
],
),
],
);
},
),
@@ -156,16 +179,26 @@ class AlbumListBody extends ConsumerWidget {
}
}
/// 相册卡片(UI-01 §4.1 样板):封面 → 标题 → 时间/场地 → 状态徽章。
/// 相册卡片(UI-03 §4 页面 05):整卡 16:9 封面 + 底部暗色渐变叠层。
///
/// 标题按「界面语言 → 相册默认语言 → 首个条目」回退;时间用相册时区;
/// 公开/上传分别显示(关闭不等于不公开)。publicToken 与四开关堆叠已从
/// 主卡片移除(完整配置在详情设置)。
/// 图片铺满整卡(无封面/加载失败为同尺寸中性占位),标题/时间场地/状态
/// 徽章以白字叠加在渐变上;文字区域高于 16:9 基准时卡片增高(大字体下
/// 不截断徽章)。标题按「界面语言 → 相册默认语言 → 首个条目」回退;时间
/// 用相册时区;公开/上传分别显示(图标 + 文案,不只靠颜色)。publicToken
/// 与四开关堆叠已从主卡片移除(完整配置在详情设置)。
class AlbumCard extends StatelessWidget {
const AlbumCard({required this.album, super.key});
final AlbumSummary album;
/// 底部渐变:透明 → black54,只压暗下半部(白字对比度)。
static const LinearGradient _scrim = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: [0.42, 1.0],
colors: [Color(0x00000000), Color(0x8A000000)],
);
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
@@ -183,57 +216,89 @@ class AlbumCard extends StatelessWidget {
builder: (_) => AlbumDetailPage(albumId: album.id),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
PhotoThumbnail(url: album.cover?.url, aspectRatio: 16 / 9),
Padding(
padding: const EdgeInsets.all(kSpace16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
albumDisplayTitle(album, l10n.localeName),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium,
child: LayoutBuilder(
builder: (context, constraints) {
// 16:9 基准高度;作为 Stack 首个子节点参与 IntrinsicHeight
// 双列等高(RenderStack 的 intrinsic 只看首子节点)。
final baseHeight = constraints.maxWidth * 9 / 16;
return Stack(
alignment: Alignment.bottomLeft,
children: [
SizedBox(width: double.infinity, height: baseHeight),
Positioned.fill(
child: PhotoThumbnail(
url: album.cover?.url,
aspectRatio: 16 / 9,
),
const SizedBox(height: kSpace4),
Text(
meta,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall,
),
const Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(gradient: _scrim),
),
const SizedBox(height: kSpace12),
Row(
),
Padding(
padding: const EdgeInsets.fromLTRB(
kSpace16,
kSpace16,
kSpace16,
kSpace12,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
StateBadge(
variant: album.publicEnabled
? BadgeVariant.info
: BadgeVariant.neutral,
label: album.publicEnabled
? l10n.tagPublic
: l10n.albumPublicOff,
compact: true,
Text(
albumDisplayTitle(album, l10n.localeName),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: kSpace8),
StateBadge(
variant: album.uploadEnabled
? BadgeVariant.success
: BadgeVariant.neutral,
label: album.uploadEnabled
? l10n.albumUploadsOn
: l10n.albumUploadsOff,
compact: true,
const SizedBox(height: kSpace4),
Text(
meta,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.white70,
),
),
const SizedBox(height: kSpace8),
Row(
children: [
_OverlayBadge(
icon: album.publicEnabled
? Icons.info_outline
: Icons.remove_circle_outline,
color: album.publicEnabled
? kColorInfoDark
: Colors.white70,
label: album.publicEnabled
? l10n.tagPublic
: l10n.albumPublicOff,
),
const SizedBox(width: kSpace8),
_OverlayBadge(
icon: album.uploadEnabled
? Icons.check_circle
: Icons.remove_circle_outline,
color: album.uploadEnabled
? kColorSuccessDark
: Colors.white70,
label: album.uploadEnabled
? l10n.albumUploadsOn
: l10n.albumUploadsOff,
),
],
),
],
),
],
),
),
],
),
],
);
},
),
),
);
@@ -249,3 +314,45 @@ class AlbumCard extends StatelessWidget {
return '$start ~ $end';
}
}
/// 图片叠层上的状态小徽章:半透明深色底 + 白字(语义色仅用于图标,
/// 提亮变体保证在照片上的对比度)。
class _OverlayBadge extends StatelessWidget {
const _OverlayBadge({
required this.icon,
required this.color,
required this.label,
});
final IconData icon;
final Color color;
final String label;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: kSpace8, vertical: 4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(kRadiusBadge),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: color),
const SizedBox(width: kSpace4),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
}