Files
photo-live/apps/mobile/lib/pages/albums_tab.dart
T
2026-09-21 15:55:44 +08:00

252 lines
9.2 KiB
Dart

/// 相册列表页签:游标加载更多、下拉刷新、封面卡片(UI-01 §4.1)。
///
/// 卡片顺序:封面(16:9,无封面中性占位)→ 标题(≤2 行,语言回退链)→
/// 时间/场地单行 → 公开/上传状态徽章(图标+文案,不只靠颜色)。手机单列;
/// 可用宽度 > 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/components/state_badge.dart';
import '../ui/tokens.dart';
import 'album_detail_page.dart';
import 'album_form_page.dart';
/// 双列布局的可用宽度阈值(方案 §4.1「平板按可用宽度使用两列」)。
const double kAlbumTwoColumnMinWidth = 600;
/// 首页「相册」页签。
class AlbumsTab extends ConsumerWidget {
const AlbumsTab({super.key});
/// 打开创建表单;成功后刷新列表(空态 CTA 与 FAB 共用)。
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 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),
),
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;
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])),
],
],
),
);
},
),
);
},
);
}
}
/// 相册卡片(UI-01 §4.1 样板):封面 → 标题 → 时间/场地 → 状态徽章。
///
/// 标题按「界面语言 → 相册默认语言 → 首个条目」回退;时间用相册时区;
/// 公开/上传分别显示(关闭不等于不公开)。publicToken 与四开关堆叠已从
/// 主卡片移除(完整配置在详情设置)。
class AlbumCard extends StatelessWidget {
const AlbumCard({required this.album, super.key});
final AlbumSummary album;
@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: 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,
),
const SizedBox(height: kSpace4),
Text(
meta,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall,
),
const SizedBox(height: kSpace12),
Row(
children: [
StateBadge(
variant: album.publicEnabled
? BadgeVariant.info
: BadgeVariant.neutral,
label: album.publicEnabled
? l10n.tagPublic
: l10n.albumPublicOff,
compact: true,
),
const SizedBox(width: kSpace8),
StateBadge(
variant: album.uploadEnabled
? BadgeVariant.success
: BadgeVariant.neutral,
label: album.uploadEnabled
? l10n.albumUploadsOn
: l10n.albumUploadsOff,
compact: true,
),
],
),
],
),
),
],
),
),
);
}
/// 相册时区的时间区间(结束未定显示「未定」占位)。
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';
}
}