Files
photo-live/apps/mobile/lib/pages/albums_tab.dart
T
浅念 6074fc8ed3 feat(ui06): App line A0-A7 — nav pill removal, light segments, bare summary, token alignment to app-preview
- tokens:次文字 #69778E、soft/card-border/seg 色组、输入圆角 10(kRadiusField)
- theme:导航 indicatorColor 透明+显式选中蓝;卡片去阴影改 1px #EDF0F5 边框;输入边框同步
- 新组件 SegmentTabs(#EDF1F6 轨道/选中实蓝/light 浅蓝变体/null 无高亮)替换 M3 SegmentedButton
- 上传中心:摘要去白卡直排+主数字 32/40;completed 次级筛选无高亮语义保留
- 相册卡 scrim 30% 起 #0C1728E8;导航角标红底白字白边;登录主标题 27/700
- theme_test 三处旧规格断言随 UI06 值更新;397/397 全绿、analyze 0 issues
- 真机证据:MuMu + 测试服务器 API,登录/相册/详情/上传 4 页截图确认达标
- 登记偏差:语义色保持对比度合规深色版;视口矩阵随 G2;详见验收记录 §6

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 08:09:20 +08:00

360 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 相册列表页签:游标加载更多、下拉刷新、整图封面卡(UI-03 §4 页面 05)。
///
/// 卡片为 16:9 全幅封面(无封面同尺寸中性占位),标题(白,≤2 行,语言
/// 回退链)/时间场地(white70 单行)/公开与上传状态小徽章叠加在底部暗色
/// 渐变上——文字区域高于基准时卡片增高,不截掉徽章。手机单列;可用宽度
/// > 600 双列(行内卡片等高)。publicToken 与四开关堆叠不在主卡片(完整
/// 配置在详情设置)。
library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/error_l10n.dart';
import '../features/albums/album_format.dart';
import '../features/albums/models.dart';
import '../features/albums/providers.dart';
import '../l10n/app_localizations.dart';
import '../ui/components/empty_state.dart';
import '../ui/components/error_state.dart';
import '../ui/components/photo_thumbnail.dart';
import '../ui/tokens.dart';
import 'album_detail_page.dart';
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 与右上入口共用)。
static Future<void> openCreate(BuildContext context, WidgetRef ref) async {
final changed = await Navigator.of(context).push<bool>(
MaterialPageRoute<bool>(builder: (_) => const AlbumFormPage()),
);
if (changed == true) {
ref.read(albumListProvider.notifier).refresh();
}
}
@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: 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),
);
}
}
/// 列表主体:按状态分发 加载/错误/空/列表(含加载更多页脚)。
///
/// 空态带「新建相册」入口;错误态带重试。列表按可用宽度切换单/双列,
/// 行结构 [ListView.builder](行 = 1-2 张卡片),保证下拉刷新与游标
/// 加载更多行为与旧版一致。
class AlbumListBody extends ConsumerWidget {
const AlbumListBody({required this.state, super.key});
final AlbumListState state;
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final notifier = ref.read(albumListProvider.notifier);
if (state.loading) {
return const Center(child: CircularProgressIndicator());
}
if (state.items.isEmpty) {
if (state.errorCode != null) {
return ErrorState(
message: l10n.messageForErrorCode(state.errorCode!),
onRetry: notifier.refresh,
);
}
return EmptyState(
icon: Icons.photo_library_outlined,
title: l10n.albumsEmpty,
actionLabel: l10n.albumCreate,
onAction: () => AlbumsTab.openCreate(context, ref),
);
}
return LayoutBuilder(
builder: (context, constraints) {
final twoColumns = constraints.maxWidth > kAlbumTwoColumnMinWidth;
final rowCount = (state.items.length / (twoColumns ? 2 : 1)).ceil();
final hasFooter = state.hasMore || state.loadMoreFailed;
return RefreshIndicator(
onRefresh: notifier.refresh,
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(
kSpace16,
kSpace8,
kSpace16,
kSpace32,
),
itemCount: rowCount + (hasFooter ? 1 : 0),
itemBuilder: (context, index) {
if (index >= rowCount) {
if (state.loadMoreFailed) {
return ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.error_outline),
title: Text(l10n.loadMoreFailed),
onTap: notifier.loadMore,
);
}
return const Padding(
padding: EdgeInsets.symmetric(vertical: kSpace16),
child: Center(
child: SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
// 接近底部时预取下一页(loadMore 自带并发/末页守卫)。
if (index >= rowCount - 2 && state.hasMore && !state.loadingMore) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => notifier.loadMore(),
);
}
if (!twoColumns) {
return AlbumCard(album: state.items[index]);
}
final first = index * 2;
final hasSecond = first + 1 < state.items.length;
// 双列并排:卡片高度由 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])),
],
],
);
},
),
);
},
);
}
}
/// 相册卡片(UI-03 §4 页面 05):整卡 16:9 封面 + 底部暗色渐变叠层。
///
/// 图片铺满整卡(无封面/加载失败为同尺寸中性占位),标题/时间场地/状态
/// 徽章以白字叠加在渐变上;文字区域高于 16:9 基准时卡片增高(大字体下
/// 不截断徽章)。标题按「界面语言 → 相册默认语言 → 首个条目」回退;时间
/// 用相册时区;公开/上传分别显示(图标 + 文案,不只靠颜色)。publicToken
/// 与四开关堆叠已从主卡片移除(完整配置在详情设置)。
class AlbumCard extends StatelessWidget {
const AlbumCard({required this.album, super.key});
final AlbumSummary album;
/// 底部渐变:透明 → black54,只压暗下半部(白字对比度)。
/// UI06:app-preview .scrim——30% 起渐变到 #0C1728E8(只压暗下部)。
static const LinearGradient _scrim = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: [0.30, 1.0],
colors: [Color(0x00000000), Color(0xE80C1728)],
);
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final venue = albumDisplayVenue(album, l10n.localeName);
final meta = venue == null
? _timeRange(l10n)
: '${_timeRange(l10n)} · $venue';
return Card(
margin: const EdgeInsets.symmetric(vertical: kSpace8),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => AlbumDetailPage(albumId: album.id),
),
),
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 Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(gradient: _scrim),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(
kSpace16,
kSpace16,
kSpace16,
kSpace12,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
albumDisplayTitle(album, l10n.localeName),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
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,
),
],
),
],
),
),
],
);
},
),
),
);
}
/// 相册时区的时间区间(结束未定显示「未定」占位)。
String _timeRange(AppLocalizations l10n) {
final locale = l10n.localeName;
final start = formatAlbumDateTime(album.startAt, album.timezone, locale);
final end = album.endAt == null
? l10n.notSet
: formatAlbumDateTime(album.endAt!, album.timezone, locale);
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,
),
),
],
),
);
}
}