feat(library): 收藏与观看历史支持分页
This commit is contained in:
1 parent
63d3b0f7ca
commit
235b50598a
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`,不启停服务、不操作真机。
|
||||
@@ -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` 改动未提交。
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)));
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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, '旧收藏');
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user