bbea1dd364
Co-Authored-By: Claude Code <noreply@anthropic.com>
336 lines
10 KiB
Dart
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|