Files
photo-live/apps/mobile/lib/pages/albums_tab.dart
T
浅念 20321f1b93 fix(app): account-switch data residue + empty-albums pull-to-refresh
- 换账号数据残留(用户报告):albumListProvider 非 autoDispose 且首页
  IndexedStack 常驻——登出与登录成功两处显式 invalidate(任务库本就按账号
  隔离键隔离,不受影响)
- 空相册无法下拉刷新(用户报告):空态原本直接 return EmptyState 无
  RefreshIndicator——被拉进相册后无法手动刷新;空态改入可滚动容器承载
  下拉刷新(保留新建入口)
- 397/397 全绿、analyze 0

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

374 lines
14 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,
);
}
// 空态也保留下拉刷新(被拉进相册后可手动刷新看到,2026-09-23 用户报告)。
return RefreshIndicator(
onRefresh: notifier.refresh,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
SizedBox(
height: MediaQuery.of(context).size.height * 0.5,
child: 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,
),
),
],
),
);
}
}