20321f1b93
- 换账号数据残留(用户报告):albumListProvider 非 autoDispose 且首页 IndexedStack 常驻——登出与登录成功两处显式 invalidate(任务库本就按账号 隔离键隔离,不受影响) - 空相册无法下拉刷新(用户报告):空态原本直接 return EmptyState 无 RefreshIndicator——被拉进相册后无法手动刷新;空态改入可滚动容器承载 下拉刷新(保留新建入口) - 397/397 全绿、analyze 0 Co-Authored-By: Claude Code <noreply@anthropic.com>
374 lines
14 KiB
Dart
374 lines
14 KiB
Dart
/// 相册列表页签:游标加载更多、下拉刷新、整图封面卡(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,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|