feat(library): 收藏与观看历史支持分页

This commit is contained in:
Zhou Weidong committed 2026-10-22 12:34:27 +08:00
1 parent 63d3b0f7ca
commit 235b50598a
24 files changed
+1015 -105

No files matched your search

+38 -11
View File
@@ -16,6 +16,7 @@ import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_pull_to_refresh.dart';
import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/paged_list_footer.dart';
import '../../core/widgets/remote_image.dart';
import '../../core/widgets/subpage_header.dart';
import '../../l10n/app_localizations.dart';
@@ -35,6 +36,7 @@ const double _metaFontSize = 11;
const double _titleFontSize = 15;
const int _maxTags = 2;
const double _emptyListHeight = 360;
const double _loadMoreThreshold = 240;
final RegExp _tagSplitter = RegExp(r'[,/、·]');
@@ -156,6 +158,15 @@ class _FavoritesBody extends StatelessWidget {
onRemove: () => _remove(context, drama.id),
),
],
PagedListFooter(
loading: favorites.loadingMore,
hasMore: favorites.hasMore,
hasItems: favorites.cards.isNotEmpty,
errorMessage: favorites.loadMoreError == null
? null
: errorText(l10n, favorites.loadMoreError, l10n.favoritesError),
onRetry: favorites.loadMore,
),
],
);
}
@@ -164,26 +175,42 @@ class _FavoritesBody extends StatelessWidget {
FavoriteController favorites, {
required List<Widget> children,
}) {
return AppPullToRefresh(
onRefresh: favorites.load,
child: ListView(
physics: AppPullToRefresh.physics,
padding: const EdgeInsets.fromLTRB(
_pageHorizontal,
4,
_pageHorizontal,
28,
return NotificationListener<ScrollNotification>(
onNotification: _onScroll,
child: AppPullToRefresh(
onRefresh: favorites.load,
child: ListView(
physics: AppPullToRefresh.physics,
padding: const EdgeInsets.fromLTRB(
_pageHorizontal,
4,
_pageHorizontal,
28,
),
children: children,
),
children: children,
),
);
}
bool _onScroll(ScrollNotification notification) {
if (notification.depth != 0) return false;
final reachedBottom =
notification.metrics.extentAfter <= _loadMoreThreshold;
if (reachedBottom &&
(notification is ScrollUpdateNotification ||
notification is ScrollEndNotification ||
notification is OverscrollNotification)) {
unawaited(favorites.loadMore());
}
return false;
}
Widget _summary(AppLocalizations l10n) {
return Align(
alignment: Alignment.centerLeft,
child: Text(
l10n.favoritesCount(favorites.cards.length),
l10n.favoritesCount(favorites.total),
key: const Key('favorites-count'),
style: const TextStyle(
color: AppColors.muted,
+3 -2
View File
@@ -22,13 +22,14 @@ class MineLogic extends ChangeNotifier {
if (!_disposed) notifyListeners();
}
/// 最近观看:服务端只给 dramaId/位置,剧名封面用目录卡片按 id 映射。
/// 最近观看:取分页首屏,剧名封面仍由目录卡片按 id 映射。
Future<void> refreshRecent() async {
recentLoading = true;
recentError = null;
notify();
try {
recent = await history.recent(limit: 20);
final page = await history.recent(pageIndex: 1, pageSize: 20);
recent = page.list;
} catch (_) {
recentError = 'progressError';
} finally {
+38 -11
View File
@@ -17,6 +17,7 @@ import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_pull_to_refresh.dart';
import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/paged_list_footer.dart';
import '../../core/widgets/subpage_header.dart';
import '../../l10n/app_localizations.dart';
import '../player/open_player.dart';
@@ -26,6 +27,7 @@ import 'widgets/watch_record_card.dart';
const double _pageHorizontal = 14;
const double _statsFontSize = 12;
const double _emptyListHeight = 360;
const double _loadMoreThreshold = 240;
/// 独立观看记录页。
class WatchRecordsPage extends StatelessWidget {
@@ -141,31 +143,56 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> {
onOpen: record.drama == null ? null : () => _open(record),
),
],
PagedListFooter(
loading: controller.loadingMore,
hasMore: controller.hasMore,
hasItems: controller.records.isNotEmpty,
errorMessage: controller.loadMoreError == null
? null
: errorText(l10n, controller.loadMoreError, l10n.progressError),
onRetry: controller.loadMore,
),
],
);
}
Widget _refreshable({required List<Widget> children}) {
return AppPullToRefresh(
onRefresh: controller.load,
child: ListView(
physics: AppPullToRefresh.physics,
padding: const EdgeInsets.fromLTRB(
_pageHorizontal,
4,
_pageHorizontal,
28,
return NotificationListener<ScrollNotification>(
onNotification: _onScroll,
child: AppPullToRefresh(
onRefresh: controller.load,
child: ListView(
physics: AppPullToRefresh.physics,
padding: const EdgeInsets.fromLTRB(
_pageHorizontal,
4,
_pageHorizontal,
28,
),
children: children,
),
children: children,
),
);
}
bool _onScroll(ScrollNotification notification) {
if (notification.depth != 0) return false;
final reachedBottom =
notification.metrics.extentAfter <= _loadMoreThreshold;
if (reachedBottom &&
(notification is ScrollUpdateNotification ||
notification is ScrollEndNotification ||
notification is OverscrollNotification)) {
unawaited(controller.loadMore());
}
return false;
}
Widget _summary(AppLocalizations l10n) {
return Row(
children: [
Text(
l10n.watchRecordsCount(controller.records.length),
l10n.watchRecordsCount(controller.total),
key: const Key('watch-records-count'),
style: const TextStyle(
color: AppColors.muted,
@@ -39,17 +39,22 @@ class WatchRecordCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (record.detailFailed) {
final hasMetadata = record.hasDisplayMetadata;
if (record.detailFailed && !hasMetadata) {
return _FailedRecordTile(record: record, onRetry: onRetry);
}
final drama = record.drama;
if (drama == null || record.detailLoading) {
if (!hasMetadata && (drama == null || record.detailLoading)) {
return _loadingCard();
}
final l10n = AppLocalizations.of(context)!;
final episodeLabel = record.episodeNumber > 0
final episodeNumber = record.episodeNumber;
final episodeLabel = episodeNumber > 0
? l10n.episodeLabel(record.episodeNumber)
: l10n.episodeUnavailable;
final title = record.displayTitle.isEmpty
? episodeLabel
: record.displayTitle;
return _card(
onTap: onOpen,
child: Stack(
@@ -60,10 +65,14 @@ class WatchRecordCard extends StatelessWidget {
ClipRRect(
borderRadius: BorderRadius.circular(_coverRadius),
child: RemoteImage(
drama.posterUrl,
record.displayCover,
key: ValueKey('watch-record-cover-${record.dramaId}'),
width: _coverWidth,
height: _coverHeight,
showFallbackIcon: true,
fallbackIconKey: ValueKey(
'watch-record-cover-placeholder-${record.dramaId}',
),
),
),
const SizedBox(width: 12),
@@ -74,7 +83,7 @@ class WatchRecordCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
drama.title,
title,
key: ValueKey('watch-record-title-${record.dramaId}'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
@@ -85,14 +94,18 @@ class WatchRecordCard extends StatelessWidget {
),
),
const SizedBox(height: 4),
Text(
episodeLabel,
key: ValueKey('watch-record-episode-${record.dramaId}'),
style: const TextStyle(
color: AppColors.muted,
fontSize: _statsFontSize,
if (title != episodeLabel) ...[
Text(
episodeLabel,
key: ValueKey(
'watch-record-episode-${record.dramaId}',
),
style: const TextStyle(
color: AppColors.muted,
fontSize: _statsFontSize,
),
),
),
],
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(_progressRadius),