merge: 收藏与观看历史支持分页

This commit is contained in:
Zhou Weidong committed 2026-10-22 12:34:39 +08:00
commit abfca7a3be
24 files changed
+1015 -105

No files matched your search

@@ -0,0 +1,22 @@
# 收藏与观看历史分页 UI
## 目标
- `/app/favorites` 与 `/app/watch/recent` 按 `PageResult{total,list}` 分页接入。
- 收藏页、观看历史页支持首屏、触底加载、无更多、刷新、失败重试和总数展示。
- 观看记录直接使用后端 `dramaTitle/dramaCover/episodeNumber`,缺失时安全兜底。
## 实现步骤
1. 新增通用 `PageResult<T>`,更新 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`,不启停服务、不操作真机。
+46
View File
@@ -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<T>`;`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` 改动未提交。
+23
View File
@@ -0,0 +1,23 @@
// Author: fruit.com.cn
import 'json_numbers.dart';
/// 服务端标准分页响应 `{total, list}`。
class PageResult<T> {
const PageResult({this.total = 0, this.list = const []});
final int total;
final List<T> list;
/// 解析分页壳,条目由调用方提供具体解析函数。
factory PageResult.fromJson(
Map<String, dynamic> json,
T Function(Map<String, dynamic>) parseItem,
) {
final items = (json['list'] as List? ?? const [])
.whereType<Map<String, dynamic>>()
.map(parseItem)
.toList(growable: false);
return PageResult(total: jsonToInt(json['total']), list: items);
}
}
+20
View File
@@ -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<String, dynamic> 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,
+21 -1
View File
@@ -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 <Episode>[];
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,
+20 -7
View File
@@ -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<List<Drama>> 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<String, dynamic>) Drama.fromCardJson(item),
];
Future<PageResult<Drama>> favorites({
int pageIndex = 1,
int pageSize = 20,
}) async {
final data = await _client.get(
'/app/favorites',
query: {'pageIndex': '$pageIndex', 'pageSize': '$pageSize'},
);
if (data is Map<String, dynamic>) {
return PageResult.fromJson(data, Drama.fromCardJson);
}
if (data is List) {
final list = data
.whereType<Map<String, dynamic>>()
.map(Drama.fromCardJson)
.toList(growable: false);
return PageResult(total: list.length, list: list);
}
return const PageResult();
}
Future<void> removeFavorite(String dramaId) async {
+18 -8
View File
@@ -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<String, dynamic> ? WatchProgress.fromJson(data) : null;
}
/// 最近观看(服务端按下标返回,已按更新时间倒序)。
Future<List<WatchProgress>> recent({int limit = 20}) async {
/// 最近观看分页(服务端已按更新时间倒序)。
Future<PageResult<WatchProgress>> 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<String, dynamic>) WatchProgress.fromJson(item),
];
if (data is Map<String, dynamic>) {
return PageResult.fromJson(data, WatchProgress.fromJson);
}
if (data is List) {
final list = data
.whereType<Map<String, dynamic>>()
.map(WatchProgress.fromJson)
.toList(growable: false);
return PageResult(total: list.length, list: list);
}
return const PageResult();
}
/// 删除某部剧的观看记录(记录页清单条用)。
@@ -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<bool> toggle(String dramaId);
/// 收藏列表(卡片结构与首页一致)。
Future<List<Drama>> list();
/// 收藏分页(卡片结构与首页一致)。
Future<PageResult<Drama>> page({int pageIndex = 1, int pageSize = 20});
Future<void> remove(String dramaId);
}
@@ -28,9 +29,9 @@ class HttpFavoriteRepository implements FavoriteRepository {
Future<bool> toggle(String dramaId) => _api.toggleFavorite(dramaId);
@override
Future<List<Drama>> list() async {
if (!_signedIn) return const [];
return _api.favorites();
Future<PageResult<Drama>> 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<String> _saved;
final List<Drama> _cards;
bool failToggle = false;
bool failList = false;
bool failPage = false;
bool failRemove = false;
int toggleCalls = 0;
int listCalls = 0;
int pageCalls = 0;
@override
Future<bool> toggle(String dramaId) async {
@@ -60,10 +61,18 @@ class InMemoryFavoriteRepository implements FavoriteRepository {
}
@override
Future<List<Drama>> list() async {
listCalls++;
if (failList) throw StateError('favorite unavailable');
return _cards.where((card) => _saved.contains(card.id)).toList();
Future<PageResult<Drama>> 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
@@ -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<List<WatchProgress>> recent({int limit = 20});
/// 最近观看分页。
Future<PageResult<WatchProgress>> recent({
int pageIndex = 1,
int pageSize = 20,
});
/// 清理某部剧的记录(K2 的记录页会用,这里先备好)。
Future<void> remove(String dramaId);
@@ -63,9 +67,12 @@ class HttpWatchHistoryRepository implements WatchHistoryRepository {
}
@override
Future<List<WatchProgress>> recent({int limit = 20}) async {
if (!_signedIn) return const [];
return _api.recent(limit: limit);
Future<PageResult<WatchProgress>> 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<List<WatchProgress>> recent({int limit = 20}) async {
Future<PageResult<WatchProgress>> 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
+102 -8
View File
@@ -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<String> _saved = {};
List<Drama> 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<String> get savedIds => Set.unmodifiable(_saved);
@@ -29,20 +38,66 @@ class FavoriteController extends ChangeNotifier {
/// 拉收藏列表:列表既用于"我的"页展示,也用于校准 [savedIds]。
Future<void> 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<void> 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<bool> 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<Drama> _uniqueCards(List<Drama> source) {
final seen = <String>{};
return [
for (final drama in source)
if (seen.add(drama.id)) drama,
];
}
@override
void dispose() {
_disposed = true;
@@ -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<String, Drama> _detailCache = {};
List<WatchRecord> 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<void> 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<void> 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<void> 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<bool> 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 {
+86
View File
@@ -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<void> 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);
}
}
+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),
+6 -5
View File
@@ -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)));
});
}
+64 -1
View File
@@ -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<int, PageResult<Drama>> pages;
final List<int> pageRequests = [];
final Set<int> failPages = {};
@override
Future<PageResult<Drama>> 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<void> remove(String dramaId) async {}
@override
Future<bool> toggle(String dramaId) async => true;
}
+2 -2
View File
@@ -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);
},
);
+66
View File
@@ -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, '旧收藏');
},
);
}
+15 -2
View File
@@ -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<List<WatchProgress>> recent({int limit = 20}) async {
Future<PageResult<WatchProgress>> 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
+83
View File
@@ -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');
},
);
}
+101 -2
View File
@@ -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<WatchProgress> items;
bool failRecent = false;
Object? recentError;
final List<int> recentPages = [];
bool failRemove = false;
bool failClear = false;
Completer<void>? removeGate;
@@ -230,10 +281,21 @@ class ControlledWatchHistory implements WatchHistoryRepository {
}) async => null;
@override
Future<List<WatchProgress>> recent({int limit = 20}) async {
Future<PageResult<WatchProgress>> 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<int, PageResult<WatchProgress>> pages;
final List<int> pageRequests = [];
final Set<int> failPages = {};
@override
Future<PageResult<WatchProgress>> 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<WatchProgress?> progressFor(String dramaId) async => null;
@override
Future<WatchProgress?> save({
required String episodeId,
required Duration position,
Duration? duration,
bool finished = false,
}) async => null;
@override
Future<void> remove(String dramaId) async {}
@override
Future<void> clear() async {}
}
+97 -4
View File
@@ -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<List<WatchProgress>> recent({int limit = 20}) async {
Future<PageResult<WatchProgress>> 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<List<WatchProgress>> recent({int limit = 20}) async {
Future<PageResult<WatchProgress>> 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<Drama> fetchDrama(String dramaId) async {
throw StateError('missing detail');
}
@override
Future<HomeFeed> fetchDiscoverFeed() async => const HomeFeed();
@override
Future<FeedPage> fetchFeed({
List<String> types = const [],
String? categoryId,
String? cursor,
int size = 10,
}) async => const FeedPage();
@override
Future<HomeFeed> fetchHomeFeed() async => const HomeFeed();
}