feat(mobile): UI-03 album workspace pages (05-08)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user