diff --git a/docs/plans/2026-10-22-library-paging-ui.md b/docs/plans/2026-10-22-library-paging-ui.md new file mode 100644 index 0000000..3c151f2 --- /dev/null +++ b/docs/plans/2026-10-22-library-paging-ui.md @@ -0,0 +1,22 @@ +# 收藏与观看历史分页 UI + +## 目标 + +- `/app/favorites` 与 `/app/watch/recent` 按 `PageResult{total,list}` 分页接入。 +- 收藏页、观看历史页支持首屏、触底加载、无更多、刷新、失败重试和总数展示。 +- 观看记录直接使用后端 `dramaTitle/dramaCover/episodeNumber`,缺失时安全兜底。 + +## 实现步骤 + +1. 新增通用 `PageResult`,更新 LibraryApi、WatchApi、Repository 与内存替身。 +2. FavoriteController、WatchRecordsController 增加分页状态、请求代次、dramaId 去重。 +3. 页面通过滚动通知触发 `loadMore`,复用下拉刷新并增加通用分页 footer。 +4. 观看卡片优先渲染接口字段,标题为空时用本地化“第 N 集”,封面为空显示占位。 +5. 补 API、Controller 和 widget 测试,运行 analyze 与全量 flutter test。 + +## 风险与约束 + +- 刷新期间保持旧列表,成功后原子替换并回到第 1 页;失败保留旧数据。 +- loadMore 与 refresh 通过请求代次隔离,旧响应不得覆盖新列表。 +- 空页立即停止加载;`loaded >= total` 后不再请求。 +- 不新增依赖、不提交现有 `pubspec.lock`,不启停服务、不操作真机。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 143fc27..de7ad9f 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -610,3 +610,49 @@ UTF-8 解码、3000 字符截断、非 JSON、二进制字节数、DioException 均通过;全量静态分析零告警,622 项测试全过。 未新增依赖、未启停服务、未操作真机;`pubspec.lock` 既有改动未提交。 + +## 2026-10-22 收藏与观看历史分页 UI + +- 分支:`feature/library-paging-ui` +- 基线:`develop` / `origin/develop` = `63d3b0f` +- 后端依赖:FruitServer `7b69493`(收藏/观看历史 PageResult 与展示字段) +- 计划:`docs/plans/2026-10-22-library-paging-ui.md` + +### 实现 + +- 新增通用 `PageResult`;`LibraryApi.favorites` 与 `WatchApi.recent` + 统一请求 `pageIndex/pageSize` 并返回 `PageResult`。 +- 收藏与观看仓储、Controller 增加首屏、刷新、loadMore、total、hasMore、 + loadingMore 和 loadMoreError;请求代次避免旧刷新/旧分页覆盖新列表。 +- 收藏按 dramaId 去重,观看按 dramaId 去重;空页或 `loaded >= total` 停止请求。 +- 收藏页、观看历史页接入滚动触底加载、下拉刷新、通用分页 footer、总数展示。 +- 观看卡片优先展示分页接口的 `dramaTitle/dramaCover/episodeNumber`; + 标题为空时显示本地化“第 N 集”,封面缺失时显示占位图标。 +- API 兼容滚动发布窗口:后端仍返回旧 List 时包装为 `PageResult(total=list.length)`; + 服务端新 PageResult 正常解析。 +- MineLogic 改用分页首屏 `list`,不再调用旧 limit 接口。 + +### 门禁 + +定向测试: + +```text +$ flutter test --no-pub test/library/library_api_test.dart test/library/favorite_controller_test.dart test/library/favorites_page_test.dart test/watch/watch_api_test.dart test/watch/watch_records_controller_test.dart test/watch/watch_records_page_test.dart test/mine/mine_logic_test.dart +00:03 +40: All tests passed! +``` + +完整门禁: + +```text +$ dart analyze lib test +Analyzing lib, test... +No issues found! + +$ flutter test --no-pub +00:56 +632: All tests passed! +``` + +解读:分页第一页、追加去重、无更多不重复请求、loadMore 失败重试、 +观看标题/集号/封面展示及空标题兜底均通过;全量静态分析零告警,632 项测试全过。 + +未新增依赖、未启停服务、未操作真机;现有 `pubspec.lock` 改动未提交。 diff --git a/lib/core/data/models/page_result.dart b/lib/core/data/models/page_result.dart new file mode 100644 index 0000000..37511bc --- /dev/null +++ b/lib/core/data/models/page_result.dart @@ -0,0 +1,23 @@ +// Author: fruit.com.cn + +import 'json_numbers.dart'; + +/// 服务端标准分页响应 `{total, list}`。 +class PageResult { + const PageResult({this.total = 0, this.list = const []}); + + final int total; + final List list; + + /// 解析分页壳,条目由调用方提供具体解析函数。 + factory PageResult.fromJson( + Map json, + T Function(Map) parseItem, + ) { + final items = (json['list'] as List? ?? const []) + .whereType>() + .map(parseItem) + .toList(growable: false); + return PageResult(total: jsonToInt(json['total']), list: items); + } +} diff --git a/lib/core/data/models/watch_progress.dart b/lib/core/data/models/watch_progress.dart index 3d655ef..2ba70e3 100644 --- a/lib/core/data/models/watch_progress.dart +++ b/lib/core/data/models/watch_progress.dart @@ -12,6 +12,10 @@ class WatchProgress { required this.episodeId, required this.position, required this.duration, + this.dramaTitle, + this.dramaCover, + this.episodeNumber, + this.totalEpisodes, this.finished = false, this.playCount = 0, this.updatedAt, @@ -19,6 +23,10 @@ class WatchProgress { final String dramaId; final String episodeId; + final String? dramaTitle; + final String? dramaCover; + final int? episodeNumber; + final int? totalEpisodes; final Duration position; final Duration duration; final bool finished; @@ -37,6 +45,14 @@ class WatchProgress { return WatchProgress( dramaId: (json['dramaId'] ?? '').toString(), episodeId: (json['episodeId'] ?? '').toString(), + dramaTitle: json['dramaTitle']?.toString(), + dramaCover: json['dramaCover']?.toString(), + episodeNumber: json['episodeNumber'] == null + ? null + : jsonToInt(json['episodeNumber']), + totalEpisodes: json['totalEpisodes'] == null + ? null + : jsonToInt(json['totalEpisodes']), position: Duration(milliseconds: jsonToInt(json['positionMs'])), duration: Duration(milliseconds: jsonToInt(json['durationMs'])), finished: json['finished'] == true, @@ -48,6 +64,10 @@ class WatchProgress { Map toJson() => { 'dramaId': dramaId, 'episodeId': episodeId, + if (dramaTitle != null) 'dramaTitle': dramaTitle, + if (dramaCover != null) 'dramaCover': dramaCover, + if (episodeNumber != null) 'episodeNumber': episodeNumber, + if (totalEpisodes != null) 'totalEpisodes': totalEpisodes, 'positionMs': position.inMilliseconds, 'durationMs': duration.inMilliseconds, 'finished': finished, diff --git a/lib/core/data/models/watch_record.dart b/lib/core/data/models/watch_record.dart index 954b478..41e93ee 100644 --- a/lib/core/data/models/watch_record.dart +++ b/lib/core/data/models/watch_record.dart @@ -19,8 +19,10 @@ class WatchRecord { String get dramaId => progress.dramaId; - /// 详情中按 episodeId 定位集号;缺失时返回 0。 + /// 优先使用分页接口集号,其次从已加载详情定位。 int get episodeNumber { + final pageEpisode = progress.episodeNumber; + if (pageEpisode != null && pageEpisode > 0) return pageEpisode; final episodes = drama?.episodes ?? const []; for (var index = 0; index < episodes.length; index++) { if (episodes[index].id == progress.episodeId) return index + 1; @@ -28,6 +30,24 @@ class WatchRecord { return 0; } + /// 服务端分页标题优先,详情标题作为回退。 + String get displayTitle { + final pageTitle = progress.dramaTitle?.trim() ?? ''; + return pageTitle.isNotEmpty ? pageTitle : drama?.title.trim() ?? ''; + } + + /// 服务端分页封面优先,详情封面作为回退。 + String get displayCover { + final pageCover = progress.dramaCover?.trim() ?? ''; + return pageCover.isNotEmpty ? pageCover : drama?.posterUrl ?? ''; + } + + /// 分页接口已带展示字段时无需等待详情加载。 + bool get hasDisplayMetadata => + displayTitle.isNotEmpty || + progress.dramaCover?.trim().isNotEmpty == true || + (progress.episodeNumber ?? 0) > 0; + /// 生成卡片加载中、成功或失败后的新状态。 WatchRecord copyWith({ Drama? drama, diff --git a/lib/core/data/remote/library_api.dart b/lib/core/data/remote/library_api.dart index 14c09b7..ad19fa8 100644 --- a/lib/core/data/remote/library_api.dart +++ b/lib/core/data/remote/library_api.dart @@ -1,6 +1,7 @@ // Author: fruit.com.cn import '../models/drama.dart'; +import '../models/page_result.dart'; import 'api_client.dart'; /// 收藏接口:`/app/favorites*`(契约 §1.5)。需要登录态。 @@ -20,13 +21,25 @@ class LibraryApi { return data == true; } - Future> favorites() async { - final data = await _client.get('/app/favorites'); - if (data is! List) return const []; - return [ - for (final item in data) - if (item is Map) Drama.fromCardJson(item), - ]; + Future> favorites({ + int pageIndex = 1, + int pageSize = 20, + }) async { + final data = await _client.get( + '/app/favorites', + query: {'pageIndex': '$pageIndex', 'pageSize': '$pageSize'}, + ); + if (data is Map) { + return PageResult.fromJson(data, Drama.fromCardJson); + } + if (data is List) { + final list = data + .whereType>() + .map(Drama.fromCardJson) + .toList(growable: false); + return PageResult(total: list.length, list: list); + } + return const PageResult(); } Future removeFavorite(String dramaId) async { diff --git a/lib/core/data/remote/watch_api.dart b/lib/core/data/remote/watch_api.dart index f57d7fb..a169856 100644 --- a/lib/core/data/remote/watch_api.dart +++ b/lib/core/data/remote/watch_api.dart @@ -1,5 +1,6 @@ // Author: fruit.com.cn +import '../models/page_result.dart'; import '../models/watch_progress.dart'; import 'api_client.dart'; @@ -39,17 +40,26 @@ class WatchApi { return data is Map ? WatchProgress.fromJson(data) : null; } - /// 最近观看(服务端按下标返回,已按更新时间倒序)。 - Future> recent({int limit = 20}) async { + /// 最近观看分页(服务端已按更新时间倒序)。 + Future> recent({ + int pageIndex = 1, + int pageSize = 20, + }) async { final data = await _client.get( '/app/watch/recent', - query: {'limit': '$limit'}, + query: {'pageIndex': '$pageIndex', 'pageSize': '$pageSize'}, ); - if (data is! List) return const []; - return [ - for (final item in data) - if (item is Map) WatchProgress.fromJson(item), - ]; + if (data is Map) { + return PageResult.fromJson(data, WatchProgress.fromJson); + } + if (data is List) { + final list = data + .whereType>() + .map(WatchProgress.fromJson) + .toList(growable: false); + return PageResult(total: list.length, list: list); + } + return const PageResult(); } /// 删除某部剧的观看记录(记录页清单条用)。 diff --git a/lib/core/data/repositories/favorite_repository.dart b/lib/core/data/repositories/favorite_repository.dart index 1329adf..402da00 100644 --- a/lib/core/data/repositories/favorite_repository.dart +++ b/lib/core/data/repositories/favorite_repository.dart @@ -1,6 +1,7 @@ // Author: fruit.com.cn import '../models/drama.dart'; +import '../models/page_result.dart'; import '../remote/library_api.dart'; /// 收藏来源:**以服务端为准**(本地不再存收藏状态)。 @@ -8,8 +9,8 @@ abstract interface class FavoriteRepository { /// 切换收藏,返回切换后的状态(true = 已收藏)。 Future toggle(String dramaId); - /// 收藏列表(卡片结构与首页一致)。 - Future> list(); + /// 收藏分页(卡片结构与首页一致)。 + Future> page({int pageIndex = 1, int pageSize = 20}); Future remove(String dramaId); } @@ -28,9 +29,9 @@ class HttpFavoriteRepository implements FavoriteRepository { Future toggle(String dramaId) => _api.toggleFavorite(dramaId); @override - Future> list() async { - if (!_signedIn) return const []; - return _api.favorites(); + Future> page({int pageIndex = 1, int pageSize = 20}) async { + if (!_signedIn) return const PageResult(); + return _api.favorites(pageIndex: pageIndex, pageSize: pageSize); } @override @@ -46,10 +47,10 @@ class InMemoryFavoriteRepository implements FavoriteRepository { final Set _saved; final List _cards; bool failToggle = false; - bool failList = false; + bool failPage = false; bool failRemove = false; int toggleCalls = 0; - int listCalls = 0; + int pageCalls = 0; @override Future toggle(String dramaId) async { @@ -60,10 +61,18 @@ class InMemoryFavoriteRepository implements FavoriteRepository { } @override - Future> list() async { - listCalls++; - if (failList) throw StateError('favorite unavailable'); - return _cards.where((card) => _saved.contains(card.id)).toList(); + Future> page({int pageIndex = 1, int pageSize = 20}) async { + pageCalls++; + if (failPage) throw StateError('favorite unavailable'); + final filtered = _cards.where((card) => _saved.contains(card.id)).toList(); + final offset = (pageIndex - 1) * pageSize; + if (offset >= filtered.length) { + return PageResult(total: filtered.length); + } + return PageResult( + total: filtered.length, + list: filtered.skip(offset).take(pageSize).toList(), + ); } @override diff --git a/lib/core/data/repositories/watch_history_repository.dart b/lib/core/data/repositories/watch_history_repository.dart index fb4a9dd..cb6b76f 100644 --- a/lib/core/data/repositories/watch_history_repository.dart +++ b/lib/core/data/repositories/watch_history_repository.dart @@ -1,5 +1,6 @@ // Author: fruit.com.cn +import '../models/page_result.dart'; import '../models/watch_progress.dart'; import '../remote/watch_api.dart'; @@ -20,8 +21,11 @@ abstract interface class WatchHistoryRepository { bool finished = false, }); - /// 最近观看(默认 20 条)。 - Future> recent({int limit = 20}); + /// 最近观看分页。 + Future> recent({ + int pageIndex = 1, + int pageSize = 20, + }); /// 清理某部剧的记录(K2 的记录页会用,这里先备好)。 Future remove(String dramaId); @@ -63,9 +67,12 @@ class HttpWatchHistoryRepository implements WatchHistoryRepository { } @override - Future> recent({int limit = 20}) async { - if (!_signedIn) return const []; - return _api.recent(limit: limit); + Future> recent({ + int pageIndex = 1, + int pageSize = 20, + }) async { + if (!_signedIn) return const PageResult(); + return _api.recent(pageIndex: pageIndex, pageSize: pageSize); } @override @@ -123,12 +130,23 @@ class InMemoryWatchHistoryRepository implements WatchHistoryRepository { } @override - Future> recent({int limit = 20}) async { + Future> recent({ + int pageIndex = 1, + int pageSize = 20, + }) async { if (failReads > 0) { failReads -= 1; throw StateError('progress unavailable'); } - return _records.values.take(limit).toList(); + final items = _records.values.toList(); + final offset = (pageIndex - 1) * pageSize; + if (offset >= items.length) { + return PageResult(total: items.length); + } + return PageResult( + total: items.length, + list: items.skip(offset).take(pageSize).toList(), + ); } @override diff --git a/lib/core/data/services/favorite_controller.dart b/lib/core/data/services/favorite_controller.dart index 4b9e4b1..ee55bc4 100644 --- a/lib/core/data/services/favorite_controller.dart +++ b/lib/core/data/services/favorite_controller.dart @@ -10,13 +10,22 @@ import '../repositories/favorite_repository.dart'; /// 点击收藏先乐观更新(立刻变色),失败再回滚并给出提示 —— 网络抖动时不至于 /// "点了没反应"或"看起来收藏了其实没有"。 class FavoriteController extends ChangeNotifier { - FavoriteController(this._repository); + FavoriteController(this._repository, {this.pageSize = defaultPageSize}); + + static const int defaultPageSize = 20; final FavoriteRepository _repository; + final int pageSize; final Set _saved = {}; List cards = const []; + int total = 0; bool loading = false; + bool loadingMore = false; + bool hasMore = false; String? error; + String? loadMoreError; + int _pageIndex = 0; + int _requestSeq = 0; bool _disposed = false; Set get savedIds => Set.unmodifiable(_saved); @@ -29,20 +38,66 @@ class FavoriteController extends ChangeNotifier { /// 拉收藏列表:列表既用于"我的"页展示,也用于校准 [savedIds]。 Future load() async { + final request = ++_requestSeq; loading = true; + loadingMore = false; error = null; + loadMoreError = null; notify(); try { - final list = await _repository.list(); + final page = await _repository.page(pageIndex: 1, pageSize: pageSize); + if (_disposed || request != _requestSeq) return; + final list = _uniqueCards(page.list); cards = list; _saved ..clear() ..addAll(list.map((drama) => drama.id)); + total = page.total; + _pageIndex = 1; + hasMore = page.list.isNotEmpty && cards.length < total; } catch (_) { - error = 'favoritesError'; + if (!_disposed && request == _requestSeq) { + error = 'favoritesError'; + } } finally { - loading = false; - notify(); + if (!_disposed && request == _requestSeq) { + loading = false; + notify(); + } + } + } + + /// 追加下一页;正在刷新、已在加载或无更多时直接忽略。 + Future loadMore() async { + if (loading || loadingMore || !hasMore) return; + final request = ++_requestSeq; + loadingMore = true; + loadMoreError = null; + notify(); + try { + final page = await _repository.page( + pageIndex: _pageIndex + 1, + pageSize: pageSize, + ); + if (_disposed || request != _requestSeq) return; + final known = cards.map((drama) => drama.id).toSet(); + final appended = _uniqueCards( + page.list, + ).where((drama) => known.add(drama.id)).toList(growable: false); + cards = [...cards, ...appended]; + _saved.addAll(appended.map((drama) => drama.id)); + total = page.total; + _pageIndex += 1; + hasMore = page.list.isNotEmpty && cards.length < total; + } catch (_) { + if (!_disposed && request == _requestSeq) { + loadMoreError = 'favoritesError'; + } + } finally { + if (!_disposed && request == _requestSeq) { + loadingMore = false; + notify(); + } } } @@ -52,10 +107,17 @@ class FavoriteController extends ChangeNotifier { /// 这样"收藏完打开我的收藏"能立刻看到,不必等下一次整表刷新。 Future toggle(String dramaId, {Drama? card}) async { final wasSaved = _saved.contains(dramaId); + final previousCards = cards; + final previousTotal = total; + final previousHasMore = hasMore; // 乐观更新 if (wasSaved) { _saved.remove(dramaId); - cards = cards.where((drama) => drama.id != dramaId).toList(); + if (cards.any((drama) => drama.id == dramaId)) { + cards = cards.where((drama) => drama.id != dramaId).toList(); + total = total > 0 ? total - 1 : 0; + hasMore = cards.length < total; + } } else { _saved.add(dramaId); } @@ -65,17 +127,30 @@ class FavoriteController extends ChangeNotifier { // 以服务端返回的状态为准(可能与本地猜测不同,例如并发操作) if (saved) { _saved.add(dramaId); - if (card != null && !cards.any((drama) => drama.id == dramaId)) { + if (wasSaved) { + cards = previousCards; + total = previousTotal; + hasMore = previousHasMore; + } else if (card != null && !cards.any((drama) => drama.id == dramaId)) { cards = [card, ...cards]; + total += 1; + hasMore = cards.length < total; } } else { _saved.remove(dramaId); - cards = cards.where((drama) => drama.id != dramaId).toList(); + if (cards.any((drama) => drama.id == dramaId)) { + cards = cards.where((drama) => drama.id != dramaId).toList(); + total = total > 0 ? total - 1 : 0; + hasMore = cards.length < total; + } } notify(); return true; } catch (_) { // 回滚 + cards = previousCards; + total = previousTotal; + hasMore = previousHasMore; if (wasSaved) { _saved.add(dramaId); } else { @@ -94,6 +169,8 @@ class FavoriteController extends ChangeNotifier { _saved.remove(dramaId); final next = [...cards]..removeAt(index); cards = next; + total = total > 0 ? total - 1 : 0; + hasMore = cards.length < total; notify(); try { await _repository.remove(dramaId); @@ -106,6 +183,8 @@ class FavoriteController extends ChangeNotifier { ); cards = restored; _saved.add(dramaId); + total += 1; + hasMore = cards.length < total; notify(); return false; } @@ -113,12 +192,27 @@ class FavoriteController extends ChangeNotifier { /// 退出登录时清空:收藏状态属于账号,不能跨账号串联。 void clear() { + _requestSeq += 1; _saved.clear(); cards = const []; + total = 0; + loading = false; + hasMore = false; + loadingMore = false; + loadMoreError = null; error = null; + _pageIndex = 0; notify(); } + List _uniqueCards(List source) { + final seen = {}; + return [ + for (final drama in source) + if (seen.add(drama.id)) drama, + ]; + } + @override void dispose() { _disposed = true; diff --git a/lib/core/data/services/watch_records_controller.dart b/lib/core/data/services/watch_records_controller.dart index 9862db3..569de4f 100644 --- a/lib/core/data/services/watch_records_controller.dart +++ b/lib/core/data/services/watch_records_controller.dart @@ -11,41 +11,53 @@ import '../repositories/watch_history_repository.dart'; /// 独立观看记录页状态。 /// -/// 最近记录只下发 dramaId;详情按 dramaId 去重后补取,并以 4 个并发为上限。 +/// 分页接口提供标题/封面/集号;详情按 dramaId 去重后补取,供点击播放使用。 class WatchRecordsController extends ChangeNotifier { factory WatchRecordsController({ required WatchHistoryRepository history, required CatalogRepository catalog, - int limit = defaultLimit, - }) => WatchRecordsController._(history, catalog, limit); + int pageSize = defaultPageSize, + }) => WatchRecordsController._(history, catalog, pageSize); - WatchRecordsController._(this._history, this._catalog, this.limit); + WatchRecordsController._(this._history, this._catalog, this.pageSize); - static const int defaultLimit = 20; + static const int defaultPageSize = 20; static const int detailConcurrency = 4; final WatchHistoryRepository _history; final CatalogRepository _catalog; - final int limit; + final int pageSize; final Map _detailCache = {}; List records = const []; + int total = 0; bool loading = false; + bool loadingMore = false; + bool hasMore = false; bool clearing = false; String? error; + String? loadMoreError; String? actionError; + int _pageIndex = 0; + int _requestSeq = 0; bool _disposed = false; /// 加载最近记录并补齐详情;单条详情失败不拖垮整页。 Future load() async { + final request = ++_requestSeq; loading = true; + loadingMore = false; error = null; + loadMoreError = null; actionError = null; _notify(); try { - final recent = await _history.recent(limit: limit); - if (_disposed) return; - records = _uniqueRecords(recent); + final page = await _history.recent(pageIndex: 1, pageSize: pageSize); + if (_disposed || request != _requestSeq) return; + records = _uniqueRecords(page.list); + total = page.total; + _pageIndex = 1; + hasMore = page.list.isNotEmpty && records.length < total; _notify(); await _hydrate(records.map((record) => record.dramaId).toList()); } catch (failure, stackTrace) { @@ -57,17 +69,54 @@ class WatchRecordsController extends ChangeNotifier { stackTrace: stackTrace, label: '[WatchRecordsController] recent failed', ); - if (!_disposed) { + if (!_disposed && request == _requestSeq) { error = failure is ApiException ? failure.message : 'progressError'; } } finally { - if (!_disposed) { + if (!_disposed && request == _requestSeq) { loading = false; _notify(); } } } + /// 追加下一页;刷新、加载中或无更多时忽略。 + Future loadMore() async { + if (loading || loadingMore || !hasMore) return; + final request = ++_requestSeq; + loadingMore = true; + loadMoreError = null; + _notify(); + try { + final page = await _history.recent( + pageIndex: _pageIndex + 1, + pageSize: pageSize, + ); + if (_disposed || request != _requestSeq) return; + final known = records.map((record) => record.dramaId).toSet(); + final appended = _uniqueRecords( + page.list, + ).where((record) => known.add(record.dramaId)).toList(growable: false); + records = [...records, ...appended]; + total = page.total; + _pageIndex += 1; + hasMore = page.list.isNotEmpty && records.length < total; + _notify(); + await _hydrate(appended.map((record) => record.dramaId).toList()); + } catch (failure) { + if (!_disposed && request == _requestSeq) { + loadMoreError = failure is ApiException + ? failure.message + : 'progressError'; + } + } finally { + if (!_disposed && request == _requestSeq) { + loadingMore = false; + _notify(); + } + } + } + /// 只重试一条详情失败记录。 Future retryDetail(String dramaId) async { final index = _recordIndex(dramaId); @@ -87,6 +136,8 @@ class WatchRecordsController extends ChangeNotifier { if (index < 0) return true; final removed = records[index]; records = [...records]..removeAt(index); + total = total > 0 ? total - 1 : 0; + hasMore = records.length < total; actionError = null; _notify(); try { @@ -96,6 +147,8 @@ class WatchRecordsController extends ChangeNotifier { final next = [...records]; next.insert(index > next.length ? next.length : index, removed); records = next; + total += 1; + hasMore = records.length < total; actionError = 'recordDeleteFailed'; return false; } finally { @@ -106,8 +159,15 @@ class WatchRecordsController extends ChangeNotifier { /// 清空全部记录,先更新 UI,失败恢复原列表。 Future clear() async { if (records.isEmpty) return true; + _requestSeq += 1; final previous = records; + final previousTotal = total; + final previousPageIndex = _pageIndex; records = const []; + total = 0; + hasMore = false; + _pageIndex = 0; + loadingMore = false; clearing = true; actionError = null; _notify(); @@ -116,6 +176,9 @@ class WatchRecordsController extends ChangeNotifier { return true; } catch (_) { records = previous; + total = previousTotal; + _pageIndex = previousPageIndex; + hasMore = records.length < total; actionError = 'recordClearFailed'; return false; } finally { diff --git a/lib/core/widgets/paged_list_footer.dart b/lib/core/widgets/paged_list_footer.dart new file mode 100644 index 0000000..8af2cda --- /dev/null +++ b/lib/core/widgets/paged_list_footer.dart @@ -0,0 +1,86 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../theme/app_colors.dart'; +import '../../l10n/app_localizations.dart'; + +/// 网络分页列表底部状态:加载、重试、手动加载与无更多。 +class PagedListFooter extends StatelessWidget { + const PagedListFooter({ + super.key, + required this.loading, + required this.hasMore, + required this.hasItems, + required this.onRetry, + this.errorMessage, + }); + + final bool loading; + final bool hasMore; + final bool hasItems; + final String? errorMessage; + final Future Function() onRetry; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + if (errorMessage != null) { + return Padding( + key: const Key('paged-list-error'), + padding: const EdgeInsets.symmetric(vertical: 12), + child: Column( + children: [ + Text( + errorMessage!, + style: const TextStyle(color: AppColors.muted, fontSize: 12), + ), + TextButton( + key: const Key('paged-list-retry'), + onPressed: () => unawaited(onRetry()), + child: Text(l10n.retry), + ), + ], + ), + ); + } + if (loading) { + return const SizedBox( + key: Key('paged-list-loading'), + height: 56, + child: Center( + child: SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.blue, + ), + ), + ), + ); + } + if (hasMore) { + return Center( + child: TextButton( + key: const Key('paged-list-load-more'), + onPressed: () => unawaited(onRetry()), + child: Text(l10n.loadMore), + ), + ); + } + if (hasItems) { + return Padding( + key: const Key('paged-list-no-more'), + padding: const EdgeInsets.symmetric(vertical: 14), + child: Text( + l10n.noMore, + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.muted, fontSize: 12), + ), + ); + } + return const SizedBox(height: 16); + } +} diff --git a/lib/pages/favorites/favorites_page.dart b/lib/pages/favorites/favorites_page.dart index 3f68c21..cb1b355 100644 --- a/lib/pages/favorites/favorites_page.dart +++ b/lib/pages/favorites/favorites_page.dart @@ -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 children, }) { - return AppPullToRefresh( - onRefresh: favorites.load, - child: ListView( - physics: AppPullToRefresh.physics, - padding: const EdgeInsets.fromLTRB( - _pageHorizontal, - 4, - _pageHorizontal, - 28, + return NotificationListener( + 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, diff --git a/lib/pages/mine/mine_logic.dart b/lib/pages/mine/mine_logic.dart index b60be77..6be582e 100644 --- a/lib/pages/mine/mine_logic.dart +++ b/lib/pages/mine/mine_logic.dart @@ -22,13 +22,14 @@ class MineLogic extends ChangeNotifier { if (!_disposed) notifyListeners(); } - /// 最近观看:服务端只给 dramaId/位置,剧名封面用目录卡片按 id 映射。 + /// 最近观看:取分页首屏,剧名封面仍由目录卡片按 id 映射。 Future 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 { diff --git a/lib/pages/watch_records/watch_records_page.dart b/lib/pages/watch_records/watch_records_page.dart index 5daa2b8..95c291f 100644 --- a/lib/pages/watch_records/watch_records_page.dart +++ b/lib/pages/watch_records/watch_records_page.dart @@ -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 children}) { - return AppPullToRefresh( - onRefresh: controller.load, - child: ListView( - physics: AppPullToRefresh.physics, - padding: const EdgeInsets.fromLTRB( - _pageHorizontal, - 4, - _pageHorizontal, - 28, + return NotificationListener( + 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, diff --git a/lib/pages/watch_records/widgets/watch_record_card.dart b/lib/pages/watch_records/widgets/watch_record_card.dart index 5bb67cc..bdc6d28 100644 --- a/lib/pages/watch_records/widgets/watch_record_card.dart +++ b/lib/pages/watch_records/widgets/watch_record_card.dart @@ -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), diff --git a/test/integration/live_watch_flow_test.dart b/test/integration/live_watch_flow_test.dart index aafd8df..9973162 100644 --- a/test/integration/live_watch_flow_test.dart +++ b/test/integration/live_watch_flow_test.dart @@ -14,7 +14,8 @@ import 'package:fruitshorts/core/data/remote/watch_api.dart'; /// 用一个一次性邮箱注册(需要登录态的接口才会通);服务没起时自动跳过。 void main() { const baseUrl = 'http://127.0.0.1:8081'; - final email = 'k1.selftest.${DateTime.now().millisecondsSinceEpoch}@example.com'; + final email = + 'k1.selftest.${DateTime.now().millisecondsSinceEpoch}@example.com'; const password = 'Passw0rd!23'; AuthSession? session; @@ -98,17 +99,17 @@ void main() { expect(resume?.position, position); // 最近观看里能看到这部剧 - final recent = await watch.recent(limit: 20); - expect(recent.map((item) => item.dramaId), contains(card.id)); + final recent = await watch.recent(pageSize: 20); + expect(recent.list.map((item) => item.dramaId), contains(card.id)); // 5) 收藏 → 出现在收藏列表 → 取消收藏 final savedState = await library.toggleFavorite(card.id); expect(savedState, isTrue); final favorites = await library.favorites(); - expect(favorites.map((drama) => drama.id), contains(card.id)); + expect(favorites.list.map((drama) => drama.id), contains(card.id)); await library.removeFavorite(card.id); final afterRemove = await library.favorites(); - expect(afterRemove.map((drama) => drama.id), isNot(contains(card.id))); + expect(afterRemove.list.map((drama) => drama.id), isNot(contains(card.id))); }); } diff --git a/test/library/favorite_controller_test.dart b/test/library/favorite_controller_test.dart index 0e5a96c..b02b89d 100644 --- a/test/library/favorite_controller_test.dart +++ b/test/library/favorite_controller_test.dart @@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/page_result.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/services/favorite_controller.dart'; @@ -49,7 +50,7 @@ void main() { }); test('should_expose_error_and_keep_old_state_when_list_fails', () async { - final repository = InMemoryFavoriteRepository()..failList = true; + final repository = InMemoryFavoriteRepository()..failPage = true; final controller = FavoriteController(repository); await controller.load(); @@ -115,4 +116,66 @@ void main() { expect(controller.savedIds, isEmpty); expect(controller.cards, isEmpty); }); + + test('should_append_unique_cards_and_stop_when_no_more_pages', () async { + final repository = _PagedFavoriteRepository({ + 1: PageResult(total: 3, list: [_card('a'), _card('b')]), + 2: PageResult(total: 3, list: [_card('b'), _card('c')]), + }); + final controller = FavoriteController(repository, pageSize: 2); + + await controller.load(); + await controller.loadMore(); + await controller.loadMore(); + + expect(controller.cards.map((card) => card.id), ['a', 'b', 'c']); + expect(controller.total, 3); + expect(controller.hasMore, isFalse); + expect(repository.pageRequests, [1, 2]); + }); + + test('should_retry_next_page_when_load_more_fails', () async { + final repository = _PagedFavoriteRepository({ + 1: PageResult(total: 2, list: [_card('a')]), + 2: PageResult(total: 2, list: [_card('b')]), + })..failPages.add(2); + final controller = FavoriteController(repository, pageSize: 1); + + await controller.load(); + await controller.loadMore(); + + expect(controller.cards.map((card) => card.id), ['a']); + expect(controller.loadMoreError, 'favoritesError'); + expect(controller.hasMore, isTrue); + + repository.failPages.clear(); + await controller.loadMore(); + + expect(controller.cards.map((card) => card.id), ['a', 'b']); + expect(controller.loadMoreError, isNull); + expect(repository.pageRequests, [1, 2, 2]); + }); +} + +class _PagedFavoriteRepository implements FavoriteRepository { + _PagedFavoriteRepository(this.pages); + + final Map> pages; + final List pageRequests = []; + final Set failPages = {}; + + @override + Future> page({int pageIndex = 1, int pageSize = 20}) async { + pageRequests.add(pageIndex); + if (failPages.contains(pageIndex)) { + throw StateError('page unavailable'); + } + return pages[pageIndex] ?? const PageResult(); + } + + @override + Future remove(String dramaId) async {} + + @override + Future toggle(String dramaId) async => true; } diff --git a/test/library/favorites_page_test.dart b/test/library/favorites_page_test.dart index 2091e85..b1874c8 100644 --- a/test/library/favorites_page_test.dart +++ b/test/library/favorites_page_test.dart @@ -137,7 +137,7 @@ void main() { testWidgets('should_show_retry_when_favorite_list_fails', (tester) async { final auth = await _auth(signedIn: true); - final repository = InMemoryFavoriteRepository()..failList = true; + final repository = InMemoryFavoriteRepository()..failPage = true; final favorites = FavoriteController(repository); await _pumpPage(tester, auth: auth, favorites: favorites); @@ -159,7 +159,7 @@ void main() { await _pumpPage(tester, auth: auth, favorites: favorites); expect(find.byKey(const Key('auth-gate-panel')), findsOneWidget); - expect(repository.listCalls, 0); + expect(repository.pageCalls, 0); }, ); diff --git a/test/library/library_api_test.dart b/test/library/library_api_test.dart new file mode 100644 index 0000000..26f5c0c --- /dev/null +++ b/test/library/library_api_test.dart @@ -0,0 +1,66 @@ +// Author: fruit.com.cn + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/library_api.dart'; + +import '../support/fake_http_adapter.dart'; + +void main() { + test('should_request_favorites_with_page_and_parse_page_result', () async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': { + 'total': '21', + 'list': [ + {'id': '9', 'title': '收藏剧', 'cover': '/files/9.png'}, + ], + }, + }), + ), + ); + final api = LibraryApi( + ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), + ); + + final page = await api.favorites(pageIndex: 2, pageSize: 10); + + final request = adapter.requests.single; + expect(request.uri.path, '/app/favorites'); + expect(request.queryParameters['pageIndex'], '2'); + expect(request.queryParameters['pageSize'], '10'); + expect(page.total, 21); + expect(page.list.single.id, '9'); + expect(page.list.single.title, '收藏剧'); + }); + + test( + 'should_wrap_legacy_list_when_backend_has_not_deployed_paging', + () async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': [ + {'id': '3', 'title': '旧收藏'}, + ], + }), + ), + ); + final api = LibraryApi( + ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), + ); + + final page = await api.favorites(); + + expect(page.total, 1); + expect(page.list.single.title, '旧收藏'); + }, + ); +} diff --git a/test/support/fake_watch_history.dart b/test/support/fake_watch_history.dart index 6616b7a..7643564 100644 --- a/test/support/fake_watch_history.dart +++ b/test/support/fake_watch_history.dart @@ -1,4 +1,7 @@ +// Author: fruit.com.cn + import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:fruitshorts/core/data/models/page_result.dart'; import 'package:fruitshorts/core/data/models/watch_progress.dart'; /// 记录每次进度读取/上报,方便断言"读了哪部剧、上报了哪一集"。 @@ -57,9 +60,19 @@ class FakeWatchHistory implements WatchHistoryRepository { } @override - Future> recent({int limit = 20}) async { + Future> recent({ + int pageIndex = 1, + int pageSize = 20, + }) async { if (failReads) throw StateError('history unavailable'); - return recentRecords.take(limit).toList(); + final offset = (pageIndex - 1) * pageSize; + if (offset >= recentRecords.length) { + return PageResult(total: recentRecords.length); + } + return PageResult( + total: recentRecords.length, + list: recentRecords.skip(offset).take(pageSize).toList(), + ); } @override diff --git a/test/watch/watch_api_test.dart b/test/watch/watch_api_test.dart new file mode 100644 index 0000000..e9e765e --- /dev/null +++ b/test/watch/watch_api_test.dart @@ -0,0 +1,83 @@ +// Author: fruit.com.cn + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/watch_api.dart'; + +import '../support/fake_http_adapter.dart'; + +void main() { + test('should_request_recent_with_page_and_parse_display_fields', () async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': { + 'total': '18', + 'list': [ + { + 'dramaId': '12', + 'episodeId': '88', + 'dramaTitle': '剧名', + 'dramaCover': '/files/12.png', + 'episodeNumber': 6, + 'totalEpisodes': 68, + 'positionMs': '80000', + 'durationMs': '120000', + }, + ], + }, + }), + ), + ); + final api = WatchApi( + ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), + ); + + final page = await api.recent(pageIndex: 2, pageSize: 10); + + final request = adapter.requests.single; + expect(request.uri.path, '/app/watch/recent'); + expect(request.queryParameters['pageIndex'], '2'); + expect(request.queryParameters['pageSize'], '10'); + expect(request.queryParameters.containsKey('limit'), isFalse); + expect(page.total, 18); + expect(page.list.single.dramaTitle, '剧名'); + expect(page.list.single.dramaCover, '/files/12.png'); + expect(page.list.single.episodeNumber, 6); + expect(page.list.single.totalEpisodes, 68); + }); + + test( + 'should_wrap_legacy_list_when_backend_has_not_deployed_paging', + () async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': [ + { + 'dramaId': '5', + 'episodeId': '7', + 'positionMs': '1000', + 'durationMs': '2000', + }, + ], + }), + ), + ); + final api = WatchApi( + ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), + ); + + final page = await api.recent(); + + expect(page.total, 1); + expect(page.list.single.dramaId, '5'); + }, + ); +} diff --git a/test/watch/watch_records_controller_test.dart b/test/watch/watch_records_controller_test.dart index 105f17d..82ae34f 100644 --- a/test/watch/watch_records_controller_test.dart +++ b/test/watch/watch_records_controller_test.dart @@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/feed_page.dart'; import 'package:fruitshorts/core/data/models/home_feed.dart'; +import 'package:fruitshorts/core/data/models/page_result.dart'; import 'package:fruitshorts/core/data/models/watch_progress.dart'; import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; @@ -93,6 +94,55 @@ void main() { ); }); + test('should_append_unique_records_and_stop_when_no_more_pages', () async { + final history = _PagedWatchHistory({ + 1: PageResult(total: 3, list: [_progress('1'), _progress('2')]), + 2: PageResult(total: 3, list: [_progress('2'), _progress('3')]), + }); + final catalog = ScriptedWatchCatalog(); + final controller = WatchRecordsController( + history: history, + catalog: catalog, + pageSize: 2, + ); + + await controller.load(); + await controller.loadMore(); + await controller.loadMore(); + + expect(controller.records.map((record) => record.dramaId), ['1', '2', '3']); + expect(controller.total, 3); + expect(controller.hasMore, isFalse); + expect(history.pageRequests, [1, 2]); + expect(catalog.calls, ['1', '2', '3']); + }); + + test('should_retry_next_page_when_watch_load_more_fails', () async { + final history = _PagedWatchHistory({ + 1: PageResult(total: 2, list: [_progress('1')]), + 2: PageResult(total: 2, list: [_progress('2')]), + })..failPages.add(2); + final controller = WatchRecordsController( + history: history, + catalog: ScriptedWatchCatalog(), + pageSize: 1, + ); + + await controller.load(); + await controller.loadMore(); + + expect(controller.records.map((record) => record.dramaId), ['1']); + expect(controller.loadMoreError, 'progressError'); + expect(controller.hasMore, isTrue); + + history.failPages.clear(); + await controller.loadMore(); + + expect(controller.records.map((record) => record.dramaId), ['1', '2']); + expect(controller.loadMoreError, isNull); + expect(history.pageRequests, [1, 2, 2]); + }); + test('should_keep_existing_records_when_refresh_fails', () async { final history = ControlledWatchHistory([_progress('1')]); final controller = WatchRecordsController( @@ -213,6 +263,7 @@ class ControlledWatchHistory implements WatchHistoryRepository { final List items; bool failRecent = false; Object? recentError; + final List recentPages = []; bool failRemove = false; bool failClear = false; Completer? removeGate; @@ -230,10 +281,21 @@ class ControlledWatchHistory implements WatchHistoryRepository { }) async => null; @override - Future> recent({int limit = 20}) async { + Future> recent({ + int pageIndex = 1, + int pageSize = 20, + }) async { + recentPages.add(pageIndex); if (recentError != null) throw recentError!; if (failRecent) throw StateError('recent unavailable'); - return items.take(limit).toList(); + final offset = (pageIndex - 1) * pageSize; + if (offset >= items.length) { + return PageResult(total: items.length); + } + return PageResult( + total: items.length, + list: items.skip(offset).take(pageSize).toList(), + ); } @override @@ -285,3 +347,40 @@ class ScriptedWatchCatalog implements CatalogRepository { return _detail(dramaId); } } + +class _PagedWatchHistory implements WatchHistoryRepository { + _PagedWatchHistory(this.pages); + + final Map> pages; + final List pageRequests = []; + final Set failPages = {}; + + @override + Future> recent({ + int pageIndex = 1, + int pageSize = 20, + }) async { + pageRequests.add(pageIndex); + if (failPages.contains(pageIndex)) { + throw StateError('page unavailable'); + } + return pages[pageIndex] ?? const PageResult(); + } + + @override + Future progressFor(String dramaId) async => null; + + @override + Future save({ + required String episodeId, + required Duration position, + Duration? duration, + bool finished = false, + }) async => null; + + @override + Future remove(String dramaId) async {} + + @override + Future clear() async {} +} diff --git a/test/watch/watch_records_page_test.dart b/test/watch/watch_records_page_test.dart index 4d4cca0..f4146da 100644 --- a/test/watch/watch_records_page_test.dart +++ b/test/watch/watch_records_page_test.dart @@ -6,6 +6,7 @@ import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/feed_page.dart'; import 'package:fruitshorts/core/data/models/home_feed.dart'; +import 'package:fruitshorts/core/data/models/page_result.dart'; import 'package:fruitshorts/core/data/models/watch_progress.dart'; import 'package:fruitshorts/core/data/remote/api_client.dart'; import 'package:fruitshorts/core/data/remote/auth_api.dart'; @@ -98,6 +99,56 @@ void main() { expect(list.physics, AppPullToRefresh.physics); }); + testWidgets('should_render_backend_title_episode_and_cover_metadata', ( + tester, + ) async { + final auth = await _auth(signedIn: true); + final history = CountingWatchHistory([ + _progress( + 'metadata', + dramaTitle: 'Backend Title', + dramaCover: '/files/backend.png', + episodeNumber: 3, + ), + ]); + + await _pumpPage( + tester, + auth: auth, + history: history, + catalog: _AlwaysFailingWatchCatalog(), + ); + + expect(find.text('Backend Title'), findsOneWidget); + expect(find.text('Episode 3'), findsOneWidget); + expect( + find.byKey(const Key('watch-record-cover-metadata')), + findsOneWidget, + ); + }); + + testWidgets('should_fallback_to_episode_title_and_cover_placeholder', ( + tester, + ) async { + final auth = await _auth(signedIn: true); + final history = CountingWatchHistory([ + _progress('fallback', dramaTitle: '', episodeNumber: 4), + ]); + + await _pumpPage( + tester, + auth: auth, + history: history, + catalog: _AlwaysFailingWatchCatalog(), + ); + + expect(find.text('Episode 4'), findsOneWidget); + expect( + find.byKey(const Key('watch-record-cover-placeholder-fallback')), + findsOneWidget, + ); + }); + testWidgets( 'should_show_failed_placeholder_and_keep_other_cards_when_detail_fails', (tester) async { @@ -238,9 +289,17 @@ void main() { }); } -WatchProgress _progress(String dramaId) => WatchProgress( +WatchProgress _progress( + String dramaId, { + String? dramaTitle, + String? dramaCover, + int? episodeNumber, +}) => WatchProgress( dramaId: dramaId, episodeId: '$dramaId-1', + dramaTitle: dramaTitle, + dramaCover: dramaCover, + episodeNumber: episodeNumber, position: const Duration(seconds: 12), duration: const Duration(seconds: 60), updatedAt: DateTime(2026, 10, 17, 12), @@ -300,9 +359,19 @@ class CountingWatchHistory extends InMemoryWatchHistoryRepository { int clearCalls = 0; @override - Future> recent({int limit = 20}) async { + Future> recent({ + int pageIndex = 1, + int pageSize = 20, + }) async { recentCalls++; - return visibleRecords.take(limit).toList(); + final offset = (pageIndex - 1) * pageSize; + if (offset >= visibleRecords.length) { + return PageResult(total: visibleRecords.length); + } + return PageResult( + total: visibleRecords.length, + list: visibleRecords.skip(offset).take(pageSize).toList(), + ); } @override @@ -314,7 +383,10 @@ class CountingWatchHistory extends InMemoryWatchHistoryRepository { class _FailingRecentHistory extends InMemoryWatchHistoryRepository { @override - Future> recent({int limit = 20}) async { + Future> recent({ + int pageIndex = 1, + int pageSize = 20, + }) async { throw const ApiException(100000, '账号不存在,请先注册'); } } @@ -347,3 +419,24 @@ class _FailingWatchCatalog implements CatalogRepository { return _delegate.fetchDrama(dramaId); } } + +class _AlwaysFailingWatchCatalog implements CatalogRepository { + @override + Future fetchDrama(String dramaId) async { + throw StateError('missing detail'); + } + + @override + Future fetchDiscoverFeed() async => const HomeFeed(); + + @override + Future fetchFeed({ + List types = const [], + String? categoryId, + String? cursor, + int size = 10, + }) async => const FeedPage(); + + @override + Future fetchHomeFeed() async => const HomeFeed(); +}