Files
photo-live/apps/mobile/lib/pages/albums_tab.dart
T
2026-09-19 01:56:38 +08:00

336 lines
10 KiB
Dart

/// 相册列表页签(P3 计划 Task 4):游标加载更多、下拉刷新、卡片标签行
/// (状态 + 四开关)、时区感知的时间展示、公开令牌短显、FAB 新建;
/// 加载/空/错误态齐备。详情/编辑由卡片与列表级刷新联动。
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 'album_detail_page.dart';
import 'album_form_page.dart';
/// 首页「相册」页签。
class AlbumsTab extends ConsumerWidget {
const AlbumsTab({super.key});
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),
);
}
}
/// 列表主体:按状态分发 加载/错误/空/列表(含加载更多页脚)。
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 _ErrorView(
message: l10n.messageForErrorCode(state.errorCode!),
onRetry: notifier.refresh,
);
}
return _EmptyView(message: l10n.albumsEmpty);
}
final hasFooter = state.hasMore || state.loadMoreFailed;
return RefreshIndicator(
onRefresh: notifier.refresh,
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: state.items.length + (hasFooter ? 1 : 0),
itemBuilder: (context, index) {
if (index >= state.items.length) {
if (state.loadMoreFailed) {
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
leading: const Icon(Icons.error_outline),
title: Text(l10n.loadMoreFailed),
onTap: notifier.loadMore,
);
}
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
// 接近底部时预取下一页(loadMore 自带并发/末页守卫)。
if (index >= state.items.length - 3 &&
state.hasMore &&
!state.loadingMore) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => notifier.loadMore(),
);
}
return AlbumCard(album: state.items[index]);
},
),
);
}
}
/// 首屏错误视图(重试按钮触发整页刷新)。
class _ErrorView extends StatelessWidget {
const _ErrorView({required this.message, required this.onRetry});
final String message;
final Future<void> Function() onRetry;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.cloud_off, size: 48),
const SizedBox(height: 12),
Text(message, textAlign: TextAlign.center),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: Text(l10n.retry),
),
],
),
);
}
}
/// 空列表视图。
class _EmptyView extends StatelessWidget {
const _EmptyView({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.photo_library_outlined, size: 48),
const SizedBox(height: 12),
Text(message, textAlign: TextAlign.center),
],
),
);
}
}
/// 相册卡片:默认语言标题、状态 + 四开关标签行、相册时区的时间区间、
/// 公开令牌短显。
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 time = _timeRange(l10n);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => AlbumDetailPage(albumId: album.id),
),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
_title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w600),
),
),
const SizedBox(width: 8),
_StatusChip(status: album.status),
],
),
const SizedBox(height: 8),
Wrap(
spacing: 6,
runSpacing: 4,
children: [
_SwitchChip(label: l10n.tagUpload, on: album.uploadEnabled),
_SwitchChip(label: l10n.tagPublic, on: album.publicEnabled),
_SwitchChip(
label: l10n.tagDownload,
on: album.downloadEnabled,
),
_SwitchChip(
label: l10n.tagOriginal,
on: album.originalDownloadEnabled,
),
],
),
const SizedBox(height: 8),
Row(
children: [
Icon(
Icons.schedule,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Expanded(
child: Text(
time,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
const SizedBox(height: 2),
Text(
album.publicToken.length > 10
? '${album.publicToken.substring(0, 10)}…'
: album.publicToken,
style: theme.textTheme.bodySmall?.copyWith(
fontFeatures: const [FontFeature.tabularFigures()],
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
),
);
}
/// 默认语言标题(缺失时回退首个条目;无翻译时显示 ID 兜底)。
String get _title {
for (final t in album.translations) {
if (t.locale == album.defaultLocale) return t.title;
}
if (album.translations.isNotEmpty) return album.translations.first.title;
return album.id;
}
/// 相册时区的时间区间(结束未定显示「未定」占位)。
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';
}
}
/// 状态标签:active 正常 / offline 下线。
class _StatusChip extends StatelessWidget {
const _StatusChip({required this.status});
final AlbumStatus status;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final active = status == AlbumStatus.active;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: active
? theme.colorScheme.secondaryContainer
: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(999),
),
child: Text(
active ? l10n.albumStatusActive : l10n.albumStatusOffline,
style: theme.textTheme.labelSmall?.copyWith(
color: active
? theme.colorScheme.onSecondaryContainer
: theme.colorScheme.onErrorContainer,
),
),
);
}
}
/// 开关标签:色调区分开/关。
class _SwitchChip extends StatelessWidget {
const _SwitchChip({required this.label, required this.on});
final String label;
final bool on;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: on
? theme.colorScheme.primaryContainer
: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(999),
),
child: Text(
label,
style: theme.textTheme.labelSmall?.copyWith(
color: on
? theme.colorScheme.onPrimaryContainer
: theme.colorScheme.onSurfaceVariant,
),
),
);
}
}