merge: 收藏与观看历史支持分页
This commit is contained in:
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`,不启停服务、不操作真机。
|
||||||
@@ -610,3 +610,49 @@ UTF-8 解码、3000 字符截断、非 JSON、二进制字节数、DioException
|
|||||||
均通过;全量静态分析零告警,622 项测试全过。
|
均通过;全量静态分析零告警,622 项测试全过。
|
||||||
|
|
||||||
未新增依赖、未启停服务、未操作真机;`pubspec.lock` 既有改动未提交。
|
未新增依赖、未启停服务、未操作真机;`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.episodeId,
|
||||||
required this.position,
|
required this.position,
|
||||||
required this.duration,
|
required this.duration,
|
||||||
|
this.dramaTitle,
|
||||||
|
this.dramaCover,
|
||||||
|
this.episodeNumber,
|
||||||
|
this.totalEpisodes,
|
||||||
this.finished = false,
|
this.finished = false,
|
||||||
this.playCount = 0,
|
this.playCount = 0,
|
||||||
this.updatedAt,
|
this.updatedAt,
|
||||||
@@ -19,6 +23,10 @@ class WatchProgress {
|
|||||||
|
|
||||||
final String dramaId;
|
final String dramaId;
|
||||||
final String episodeId;
|
final String episodeId;
|
||||||
|
final String? dramaTitle;
|
||||||
|
final String? dramaCover;
|
||||||
|
final int? episodeNumber;
|
||||||
|
final int? totalEpisodes;
|
||||||
final Duration position;
|
final Duration position;
|
||||||
final Duration duration;
|
final Duration duration;
|
||||||
final bool finished;
|
final bool finished;
|
||||||
@@ -37,6 +45,14 @@ class WatchProgress {
|
|||||||
return WatchProgress(
|
return WatchProgress(
|
||||||
dramaId: (json['dramaId'] ?? '').toString(),
|
dramaId: (json['dramaId'] ?? '').toString(),
|
||||||
episodeId: (json['episodeId'] ?? '').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'])),
|
position: Duration(milliseconds: jsonToInt(json['positionMs'])),
|
||||||
duration: Duration(milliseconds: jsonToInt(json['durationMs'])),
|
duration: Duration(milliseconds: jsonToInt(json['durationMs'])),
|
||||||
finished: json['finished'] == true,
|
finished: json['finished'] == true,
|
||||||
@@ -48,6 +64,10 @@ class WatchProgress {
|
|||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'dramaId': dramaId,
|
'dramaId': dramaId,
|
||||||
'episodeId': episodeId,
|
'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,
|
'positionMs': position.inMilliseconds,
|
||||||
'durationMs': duration.inMilliseconds,
|
'durationMs': duration.inMilliseconds,
|
||||||
'finished': finished,
|
'finished': finished,
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ class WatchRecord {
|
|||||||
|
|
||||||
String get dramaId => progress.dramaId;
|
String get dramaId => progress.dramaId;
|
||||||
|
|
||||||
/// 详情中按 episodeId 定位集号;缺失时返回 0。
|
/// 优先使用分页接口集号,其次从已加载详情定位。
|
||||||
int get episodeNumber {
|
int get episodeNumber {
|
||||||
|
final pageEpisode = progress.episodeNumber;
|
||||||
|
if (pageEpisode != null && pageEpisode > 0) return pageEpisode;
|
||||||
final episodes = drama?.episodes ?? const <Episode>[];
|
final episodes = drama?.episodes ?? const <Episode>[];
|
||||||
for (var index = 0; index < episodes.length; index++) {
|
for (var index = 0; index < episodes.length; index++) {
|
||||||
if (episodes[index].id == progress.episodeId) return index + 1;
|
if (episodes[index].id == progress.episodeId) return index + 1;
|
||||||
@@ -28,6 +30,24 @@ class WatchRecord {
|
|||||||
return 0;
|
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({
|
WatchRecord copyWith({
|
||||||
Drama? drama,
|
Drama? drama,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
import '../models/drama.dart';
|
import '../models/drama.dart';
|
||||||
|
import '../models/page_result.dart';
|
||||||
import 'api_client.dart';
|
import 'api_client.dart';
|
||||||
|
|
||||||
/// 收藏接口:`/app/favorites*`(契约 §1.5)。需要登录态。
|
/// 收藏接口:`/app/favorites*`(契约 §1.5)。需要登录态。
|
||||||
@@ -20,13 +21,25 @@ class LibraryApi {
|
|||||||
return data == true;
|
return data == true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Drama>> favorites() async {
|
Future<PageResult<Drama>> favorites({
|
||||||
final data = await _client.get('/app/favorites');
|
int pageIndex = 1,
|
||||||
if (data is! List) return const [];
|
int pageSize = 20,
|
||||||
return [
|
}) async {
|
||||||
for (final item in data)
|
final data = await _client.get(
|
||||||
if (item is Map<String, dynamic>) Drama.fromCardJson(item),
|
'/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 {
|
Future<void> removeFavorite(String dramaId) async {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import '../models/page_result.dart';
|
||||||
import '../models/watch_progress.dart';
|
import '../models/watch_progress.dart';
|
||||||
import 'api_client.dart';
|
import 'api_client.dart';
|
||||||
|
|
||||||
@@ -39,17 +40,26 @@ class WatchApi {
|
|||||||
return data is Map<String, dynamic> ? WatchProgress.fromJson(data) : null;
|
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(
|
final data = await _client.get(
|
||||||
'/app/watch/recent',
|
'/app/watch/recent',
|
||||||
query: {'limit': '$limit'},
|
query: {'pageIndex': '$pageIndex', 'pageSize': '$pageSize'},
|
||||||
);
|
);
|
||||||
if (data is! List) return const [];
|
if (data is Map<String, dynamic>) {
|
||||||
return [
|
return PageResult.fromJson(data, WatchProgress.fromJson);
|
||||||
for (final item in data)
|
}
|
||||||
if (item is Map<String, dynamic>) WatchProgress.fromJson(item),
|
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
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
import '../models/drama.dart';
|
import '../models/drama.dart';
|
||||||
|
import '../models/page_result.dart';
|
||||||
import '../remote/library_api.dart';
|
import '../remote/library_api.dart';
|
||||||
|
|
||||||
/// 收藏来源:**以服务端为准**(本地不再存收藏状态)。
|
/// 收藏来源:**以服务端为准**(本地不再存收藏状态)。
|
||||||
@@ -8,8 +9,8 @@ abstract interface class FavoriteRepository {
|
|||||||
/// 切换收藏,返回切换后的状态(true = 已收藏)。
|
/// 切换收藏,返回切换后的状态(true = 已收藏)。
|
||||||
Future<bool> toggle(String dramaId);
|
Future<bool> toggle(String dramaId);
|
||||||
|
|
||||||
/// 收藏列表(卡片结构与首页一致)。
|
/// 收藏分页(卡片结构与首页一致)。
|
||||||
Future<List<Drama>> list();
|
Future<PageResult<Drama>> page({int pageIndex = 1, int pageSize = 20});
|
||||||
|
|
||||||
Future<void> remove(String dramaId);
|
Future<void> remove(String dramaId);
|
||||||
}
|
}
|
||||||
@@ -28,9 +29,9 @@ class HttpFavoriteRepository implements FavoriteRepository {
|
|||||||
Future<bool> toggle(String dramaId) => _api.toggleFavorite(dramaId);
|
Future<bool> toggle(String dramaId) => _api.toggleFavorite(dramaId);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Drama>> list() async {
|
Future<PageResult<Drama>> page({int pageIndex = 1, int pageSize = 20}) async {
|
||||||
if (!_signedIn) return const [];
|
if (!_signedIn) return const PageResult();
|
||||||
return _api.favorites();
|
return _api.favorites(pageIndex: pageIndex, pageSize: pageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -46,10 +47,10 @@ class InMemoryFavoriteRepository implements FavoriteRepository {
|
|||||||
final Set<String> _saved;
|
final Set<String> _saved;
|
||||||
final List<Drama> _cards;
|
final List<Drama> _cards;
|
||||||
bool failToggle = false;
|
bool failToggle = false;
|
||||||
bool failList = false;
|
bool failPage = false;
|
||||||
bool failRemove = false;
|
bool failRemove = false;
|
||||||
int toggleCalls = 0;
|
int toggleCalls = 0;
|
||||||
int listCalls = 0;
|
int pageCalls = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> toggle(String dramaId) async {
|
Future<bool> toggle(String dramaId) async {
|
||||||
@@ -60,10 +61,18 @@ class InMemoryFavoriteRepository implements FavoriteRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Drama>> list() async {
|
Future<PageResult<Drama>> page({int pageIndex = 1, int pageSize = 20}) async {
|
||||||
listCalls++;
|
pageCalls++;
|
||||||
if (failList) throw StateError('favorite unavailable');
|
if (failPage) throw StateError('favorite unavailable');
|
||||||
return _cards.where((card) => _saved.contains(card.id)).toList();
|
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
|
@override
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import '../models/page_result.dart';
|
||||||
import '../models/watch_progress.dart';
|
import '../models/watch_progress.dart';
|
||||||
import '../remote/watch_api.dart';
|
import '../remote/watch_api.dart';
|
||||||
|
|
||||||
@@ -20,8 +21,11 @@ abstract interface class WatchHistoryRepository {
|
|||||||
bool finished = false,
|
bool finished = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// 最近观看(默认 20 条)。
|
/// 最近观看分页。
|
||||||
Future<List<WatchProgress>> recent({int limit = 20});
|
Future<PageResult<WatchProgress>> recent({
|
||||||
|
int pageIndex = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
});
|
||||||
|
|
||||||
/// 清理某部剧的记录(K2 的记录页会用,这里先备好)。
|
/// 清理某部剧的记录(K2 的记录页会用,这里先备好)。
|
||||||
Future<void> remove(String dramaId);
|
Future<void> remove(String dramaId);
|
||||||
@@ -63,9 +67,12 @@ class HttpWatchHistoryRepository implements WatchHistoryRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<WatchProgress>> recent({int limit = 20}) async {
|
Future<PageResult<WatchProgress>> recent({
|
||||||
if (!_signedIn) return const [];
|
int pageIndex = 1,
|
||||||
return _api.recent(limit: limit);
|
int pageSize = 20,
|
||||||
|
}) async {
|
||||||
|
if (!_signedIn) return const PageResult();
|
||||||
|
return _api.recent(pageIndex: pageIndex, pageSize: pageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -123,12 +130,23 @@ class InMemoryWatchHistoryRepository implements WatchHistoryRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<WatchProgress>> recent({int limit = 20}) async {
|
Future<PageResult<WatchProgress>> recent({
|
||||||
|
int pageIndex = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
}) async {
|
||||||
if (failReads > 0) {
|
if (failReads > 0) {
|
||||||
failReads -= 1;
|
failReads -= 1;
|
||||||
throw StateError('progress unavailable');
|
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
|
@override
|
||||||
|
|||||||
@@ -10,13 +10,22 @@ import '../repositories/favorite_repository.dart';
|
|||||||
/// 点击收藏先乐观更新(立刻变色),失败再回滚并给出提示 —— 网络抖动时不至于
|
/// 点击收藏先乐观更新(立刻变色),失败再回滚并给出提示 —— 网络抖动时不至于
|
||||||
/// "点了没反应"或"看起来收藏了其实没有"。
|
/// "点了没反应"或"看起来收藏了其实没有"。
|
||||||
class FavoriteController extends ChangeNotifier {
|
class FavoriteController extends ChangeNotifier {
|
||||||
FavoriteController(this._repository);
|
FavoriteController(this._repository, {this.pageSize = defaultPageSize});
|
||||||
|
|
||||||
|
static const int defaultPageSize = 20;
|
||||||
|
|
||||||
final FavoriteRepository _repository;
|
final FavoriteRepository _repository;
|
||||||
|
final int pageSize;
|
||||||
final Set<String> _saved = {};
|
final Set<String> _saved = {};
|
||||||
List<Drama> cards = const [];
|
List<Drama> cards = const [];
|
||||||
|
int total = 0;
|
||||||
bool loading = false;
|
bool loading = false;
|
||||||
|
bool loadingMore = false;
|
||||||
|
bool hasMore = false;
|
||||||
String? error;
|
String? error;
|
||||||
|
String? loadMoreError;
|
||||||
|
int _pageIndex = 0;
|
||||||
|
int _requestSeq = 0;
|
||||||
bool _disposed = false;
|
bool _disposed = false;
|
||||||
|
|
||||||
Set<String> get savedIds => Set.unmodifiable(_saved);
|
Set<String> get savedIds => Set.unmodifiable(_saved);
|
||||||
@@ -29,20 +38,66 @@ class FavoriteController extends ChangeNotifier {
|
|||||||
|
|
||||||
/// 拉收藏列表:列表既用于"我的"页展示,也用于校准 [savedIds]。
|
/// 拉收藏列表:列表既用于"我的"页展示,也用于校准 [savedIds]。
|
||||||
Future<void> load() async {
|
Future<void> load() async {
|
||||||
|
final request = ++_requestSeq;
|
||||||
loading = true;
|
loading = true;
|
||||||
|
loadingMore = false;
|
||||||
error = null;
|
error = null;
|
||||||
|
loadMoreError = null;
|
||||||
notify();
|
notify();
|
||||||
try {
|
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;
|
cards = list;
|
||||||
_saved
|
_saved
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(list.map((drama) => drama.id));
|
..addAll(list.map((drama) => drama.id));
|
||||||
|
total = page.total;
|
||||||
|
_pageIndex = 1;
|
||||||
|
hasMore = page.list.isNotEmpty && cards.length < total;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
error = 'favoritesError';
|
if (!_disposed && request == _requestSeq) {
|
||||||
|
error = 'favoritesError';
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
if (!_disposed && request == _requestSeq) {
|
||||||
notify();
|
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 {
|
Future<bool> toggle(String dramaId, {Drama? card}) async {
|
||||||
final wasSaved = _saved.contains(dramaId);
|
final wasSaved = _saved.contains(dramaId);
|
||||||
|
final previousCards = cards;
|
||||||
|
final previousTotal = total;
|
||||||
|
final previousHasMore = hasMore;
|
||||||
// 乐观更新
|
// 乐观更新
|
||||||
if (wasSaved) {
|
if (wasSaved) {
|
||||||
_saved.remove(dramaId);
|
_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 {
|
} else {
|
||||||
_saved.add(dramaId);
|
_saved.add(dramaId);
|
||||||
}
|
}
|
||||||
@@ -65,17 +127,30 @@ class FavoriteController extends ChangeNotifier {
|
|||||||
// 以服务端返回的状态为准(可能与本地猜测不同,例如并发操作)
|
// 以服务端返回的状态为准(可能与本地猜测不同,例如并发操作)
|
||||||
if (saved) {
|
if (saved) {
|
||||||
_saved.add(dramaId);
|
_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];
|
cards = [card, ...cards];
|
||||||
|
total += 1;
|
||||||
|
hasMore = cards.length < total;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
_saved.remove(dramaId);
|
_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();
|
notify();
|
||||||
return true;
|
return true;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// 回滚
|
// 回滚
|
||||||
|
cards = previousCards;
|
||||||
|
total = previousTotal;
|
||||||
|
hasMore = previousHasMore;
|
||||||
if (wasSaved) {
|
if (wasSaved) {
|
||||||
_saved.add(dramaId);
|
_saved.add(dramaId);
|
||||||
} else {
|
} else {
|
||||||
@@ -94,6 +169,8 @@ class FavoriteController extends ChangeNotifier {
|
|||||||
_saved.remove(dramaId);
|
_saved.remove(dramaId);
|
||||||
final next = [...cards]..removeAt(index);
|
final next = [...cards]..removeAt(index);
|
||||||
cards = next;
|
cards = next;
|
||||||
|
total = total > 0 ? total - 1 : 0;
|
||||||
|
hasMore = cards.length < total;
|
||||||
notify();
|
notify();
|
||||||
try {
|
try {
|
||||||
await _repository.remove(dramaId);
|
await _repository.remove(dramaId);
|
||||||
@@ -106,6 +183,8 @@ class FavoriteController extends ChangeNotifier {
|
|||||||
);
|
);
|
||||||
cards = restored;
|
cards = restored;
|
||||||
_saved.add(dramaId);
|
_saved.add(dramaId);
|
||||||
|
total += 1;
|
||||||
|
hasMore = cards.length < total;
|
||||||
notify();
|
notify();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -113,12 +192,27 @@ class FavoriteController extends ChangeNotifier {
|
|||||||
|
|
||||||
/// 退出登录时清空:收藏状态属于账号,不能跨账号串联。
|
/// 退出登录时清空:收藏状态属于账号,不能跨账号串联。
|
||||||
void clear() {
|
void clear() {
|
||||||
|
_requestSeq += 1;
|
||||||
_saved.clear();
|
_saved.clear();
|
||||||
cards = const [];
|
cards = const [];
|
||||||
|
total = 0;
|
||||||
|
loading = false;
|
||||||
|
hasMore = false;
|
||||||
|
loadingMore = false;
|
||||||
|
loadMoreError = null;
|
||||||
error = null;
|
error = null;
|
||||||
|
_pageIndex = 0;
|
||||||
notify();
|
notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<Drama> _uniqueCards(List<Drama> source) {
|
||||||
|
final seen = <String>{};
|
||||||
|
return [
|
||||||
|
for (final drama in source)
|
||||||
|
if (seen.add(drama.id)) drama,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
|
|||||||
@@ -11,41 +11,53 @@ import '../repositories/watch_history_repository.dart';
|
|||||||
|
|
||||||
/// 独立观看记录页状态。
|
/// 独立观看记录页状态。
|
||||||
///
|
///
|
||||||
/// 最近记录只下发 dramaId;详情按 dramaId 去重后补取,并以 4 个并发为上限。
|
/// 分页接口提供标题/封面/集号;详情按 dramaId 去重后补取,供点击播放使用。
|
||||||
class WatchRecordsController extends ChangeNotifier {
|
class WatchRecordsController extends ChangeNotifier {
|
||||||
factory WatchRecordsController({
|
factory WatchRecordsController({
|
||||||
required WatchHistoryRepository history,
|
required WatchHistoryRepository history,
|
||||||
required CatalogRepository catalog,
|
required CatalogRepository catalog,
|
||||||
int limit = defaultLimit,
|
int pageSize = defaultPageSize,
|
||||||
}) => WatchRecordsController._(history, catalog, limit);
|
}) => 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;
|
static const int detailConcurrency = 4;
|
||||||
|
|
||||||
final WatchHistoryRepository _history;
|
final WatchHistoryRepository _history;
|
||||||
final CatalogRepository _catalog;
|
final CatalogRepository _catalog;
|
||||||
final int limit;
|
final int pageSize;
|
||||||
final Map<String, Drama> _detailCache = {};
|
final Map<String, Drama> _detailCache = {};
|
||||||
|
|
||||||
List<WatchRecord> records = const [];
|
List<WatchRecord> records = const [];
|
||||||
|
int total = 0;
|
||||||
bool loading = false;
|
bool loading = false;
|
||||||
|
bool loadingMore = false;
|
||||||
|
bool hasMore = false;
|
||||||
bool clearing = false;
|
bool clearing = false;
|
||||||
String? error;
|
String? error;
|
||||||
|
String? loadMoreError;
|
||||||
String? actionError;
|
String? actionError;
|
||||||
|
int _pageIndex = 0;
|
||||||
|
int _requestSeq = 0;
|
||||||
bool _disposed = false;
|
bool _disposed = false;
|
||||||
|
|
||||||
/// 加载最近记录并补齐详情;单条详情失败不拖垮整页。
|
/// 加载最近记录并补齐详情;单条详情失败不拖垮整页。
|
||||||
Future<void> load() async {
|
Future<void> load() async {
|
||||||
|
final request = ++_requestSeq;
|
||||||
loading = true;
|
loading = true;
|
||||||
|
loadingMore = false;
|
||||||
error = null;
|
error = null;
|
||||||
|
loadMoreError = null;
|
||||||
actionError = null;
|
actionError = null;
|
||||||
_notify();
|
_notify();
|
||||||
try {
|
try {
|
||||||
final recent = await _history.recent(limit: limit);
|
final page = await _history.recent(pageIndex: 1, pageSize: pageSize);
|
||||||
if (_disposed) return;
|
if (_disposed || request != _requestSeq) return;
|
||||||
records = _uniqueRecords(recent);
|
records = _uniqueRecords(page.list);
|
||||||
|
total = page.total;
|
||||||
|
_pageIndex = 1;
|
||||||
|
hasMore = page.list.isNotEmpty && records.length < total;
|
||||||
_notify();
|
_notify();
|
||||||
await _hydrate(records.map((record) => record.dramaId).toList());
|
await _hydrate(records.map((record) => record.dramaId).toList());
|
||||||
} catch (failure, stackTrace) {
|
} catch (failure, stackTrace) {
|
||||||
@@ -57,17 +69,54 @@ class WatchRecordsController extends ChangeNotifier {
|
|||||||
stackTrace: stackTrace,
|
stackTrace: stackTrace,
|
||||||
label: '[WatchRecordsController] recent failed',
|
label: '[WatchRecordsController] recent failed',
|
||||||
);
|
);
|
||||||
if (!_disposed) {
|
if (!_disposed && request == _requestSeq) {
|
||||||
error = failure is ApiException ? failure.message : 'progressError';
|
error = failure is ApiException ? failure.message : 'progressError';
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!_disposed) {
|
if (!_disposed && request == _requestSeq) {
|
||||||
loading = false;
|
loading = false;
|
||||||
_notify();
|
_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 {
|
Future<void> retryDetail(String dramaId) async {
|
||||||
final index = _recordIndex(dramaId);
|
final index = _recordIndex(dramaId);
|
||||||
@@ -87,6 +136,8 @@ class WatchRecordsController extends ChangeNotifier {
|
|||||||
if (index < 0) return true;
|
if (index < 0) return true;
|
||||||
final removed = records[index];
|
final removed = records[index];
|
||||||
records = [...records]..removeAt(index);
|
records = [...records]..removeAt(index);
|
||||||
|
total = total > 0 ? total - 1 : 0;
|
||||||
|
hasMore = records.length < total;
|
||||||
actionError = null;
|
actionError = null;
|
||||||
_notify();
|
_notify();
|
||||||
try {
|
try {
|
||||||
@@ -96,6 +147,8 @@ class WatchRecordsController extends ChangeNotifier {
|
|||||||
final next = [...records];
|
final next = [...records];
|
||||||
next.insert(index > next.length ? next.length : index, removed);
|
next.insert(index > next.length ? next.length : index, removed);
|
||||||
records = next;
|
records = next;
|
||||||
|
total += 1;
|
||||||
|
hasMore = records.length < total;
|
||||||
actionError = 'recordDeleteFailed';
|
actionError = 'recordDeleteFailed';
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -106,8 +159,15 @@ class WatchRecordsController extends ChangeNotifier {
|
|||||||
/// 清空全部记录,先更新 UI,失败恢复原列表。
|
/// 清空全部记录,先更新 UI,失败恢复原列表。
|
||||||
Future<bool> clear() async {
|
Future<bool> clear() async {
|
||||||
if (records.isEmpty) return true;
|
if (records.isEmpty) return true;
|
||||||
|
_requestSeq += 1;
|
||||||
final previous = records;
|
final previous = records;
|
||||||
|
final previousTotal = total;
|
||||||
|
final previousPageIndex = _pageIndex;
|
||||||
records = const [];
|
records = const [];
|
||||||
|
total = 0;
|
||||||
|
hasMore = false;
|
||||||
|
_pageIndex = 0;
|
||||||
|
loadingMore = false;
|
||||||
clearing = true;
|
clearing = true;
|
||||||
actionError = null;
|
actionError = null;
|
||||||
_notify();
|
_notify();
|
||||||
@@ -116,6 +176,9 @@ class WatchRecordsController extends ChangeNotifier {
|
|||||||
return true;
|
return true;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
records = previous;
|
records = previous;
|
||||||
|
total = previousTotal;
|
||||||
|
_pageIndex = previousPageIndex;
|
||||||
|
hasMore = records.length < total;
|
||||||
actionError = 'recordClearFailed';
|
actionError = 'recordClearFailed';
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} 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/app_pull_to_refresh.dart';
|
||||||
import '../../core/widgets/auth_gate.dart';
|
import '../../core/widgets/auth_gate.dart';
|
||||||
import '../../core/widgets/empty_state.dart';
|
import '../../core/widgets/empty_state.dart';
|
||||||
|
import '../../core/widgets/paged_list_footer.dart';
|
||||||
import '../../core/widgets/remote_image.dart';
|
import '../../core/widgets/remote_image.dart';
|
||||||
import '../../core/widgets/subpage_header.dart';
|
import '../../core/widgets/subpage_header.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
@@ -35,6 +36,7 @@ const double _metaFontSize = 11;
|
|||||||
const double _titleFontSize = 15;
|
const double _titleFontSize = 15;
|
||||||
const int _maxTags = 2;
|
const int _maxTags = 2;
|
||||||
const double _emptyListHeight = 360;
|
const double _emptyListHeight = 360;
|
||||||
|
const double _loadMoreThreshold = 240;
|
||||||
|
|
||||||
final RegExp _tagSplitter = RegExp(r'[,/、·]');
|
final RegExp _tagSplitter = RegExp(r'[,/、·]');
|
||||||
|
|
||||||
@@ -156,6 +158,15 @@ class _FavoritesBody extends StatelessWidget {
|
|||||||
onRemove: () => _remove(context, drama.id),
|
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, {
|
FavoriteController favorites, {
|
||||||
required List<Widget> children,
|
required List<Widget> children,
|
||||||
}) {
|
}) {
|
||||||
return AppPullToRefresh(
|
return NotificationListener<ScrollNotification>(
|
||||||
onRefresh: favorites.load,
|
onNotification: _onScroll,
|
||||||
child: ListView(
|
child: AppPullToRefresh(
|
||||||
physics: AppPullToRefresh.physics,
|
onRefresh: favorites.load,
|
||||||
padding: const EdgeInsets.fromLTRB(
|
child: ListView(
|
||||||
_pageHorizontal,
|
physics: AppPullToRefresh.physics,
|
||||||
4,
|
padding: const EdgeInsets.fromLTRB(
|
||||||
_pageHorizontal,
|
_pageHorizontal,
|
||||||
28,
|
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) {
|
Widget _summary(AppLocalizations l10n) {
|
||||||
return Align(
|
return Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: Text(
|
child: Text(
|
||||||
l10n.favoritesCount(favorites.cards.length),
|
l10n.favoritesCount(favorites.total),
|
||||||
key: const Key('favorites-count'),
|
key: const Key('favorites-count'),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: AppColors.muted,
|
color: AppColors.muted,
|
||||||
|
|||||||
@@ -22,13 +22,14 @@ class MineLogic extends ChangeNotifier {
|
|||||||
if (!_disposed) notifyListeners();
|
if (!_disposed) notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 最近观看:服务端只给 dramaId/位置,剧名封面用目录卡片按 id 映射。
|
/// 最近观看:取分页首屏,剧名封面仍由目录卡片按 id 映射。
|
||||||
Future<void> refreshRecent() async {
|
Future<void> refreshRecent() async {
|
||||||
recentLoading = true;
|
recentLoading = true;
|
||||||
recentError = null;
|
recentError = null;
|
||||||
notify();
|
notify();
|
||||||
try {
|
try {
|
||||||
recent = await history.recent(limit: 20);
|
final page = await history.recent(pageIndex: 1, pageSize: 20);
|
||||||
|
recent = page.list;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
recentError = 'progressError';
|
recentError = 'progressError';
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import '../../core/theme/app_theme.dart';
|
|||||||
import '../../core/widgets/app_pull_to_refresh.dart';
|
import '../../core/widgets/app_pull_to_refresh.dart';
|
||||||
import '../../core/widgets/auth_gate.dart';
|
import '../../core/widgets/auth_gate.dart';
|
||||||
import '../../core/widgets/empty_state.dart';
|
import '../../core/widgets/empty_state.dart';
|
||||||
|
import '../../core/widgets/paged_list_footer.dart';
|
||||||
import '../../core/widgets/subpage_header.dart';
|
import '../../core/widgets/subpage_header.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../player/open_player.dart';
|
import '../player/open_player.dart';
|
||||||
@@ -26,6 +27,7 @@ import 'widgets/watch_record_card.dart';
|
|||||||
const double _pageHorizontal = 14;
|
const double _pageHorizontal = 14;
|
||||||
const double _statsFontSize = 12;
|
const double _statsFontSize = 12;
|
||||||
const double _emptyListHeight = 360;
|
const double _emptyListHeight = 360;
|
||||||
|
const double _loadMoreThreshold = 240;
|
||||||
|
|
||||||
/// 独立观看记录页。
|
/// 独立观看记录页。
|
||||||
class WatchRecordsPage extends StatelessWidget {
|
class WatchRecordsPage extends StatelessWidget {
|
||||||
@@ -141,31 +143,56 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> {
|
|||||||
onOpen: record.drama == null ? null : () => _open(record),
|
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}) {
|
Widget _refreshable({required List<Widget> children}) {
|
||||||
return AppPullToRefresh(
|
return NotificationListener<ScrollNotification>(
|
||||||
onRefresh: controller.load,
|
onNotification: _onScroll,
|
||||||
child: ListView(
|
child: AppPullToRefresh(
|
||||||
physics: AppPullToRefresh.physics,
|
onRefresh: controller.load,
|
||||||
padding: const EdgeInsets.fromLTRB(
|
child: ListView(
|
||||||
_pageHorizontal,
|
physics: AppPullToRefresh.physics,
|
||||||
4,
|
padding: const EdgeInsets.fromLTRB(
|
||||||
_pageHorizontal,
|
_pageHorizontal,
|
||||||
28,
|
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) {
|
Widget _summary(AppLocalizations l10n) {
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
l10n.watchRecordsCount(controller.records.length),
|
l10n.watchRecordsCount(controller.total),
|
||||||
key: const Key('watch-records-count'),
|
key: const Key('watch-records-count'),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: AppColors.muted,
|
color: AppColors.muted,
|
||||||
|
|||||||
@@ -39,17 +39,22 @@ class WatchRecordCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (record.detailFailed) {
|
final hasMetadata = record.hasDisplayMetadata;
|
||||||
|
if (record.detailFailed && !hasMetadata) {
|
||||||
return _FailedRecordTile(record: record, onRetry: onRetry);
|
return _FailedRecordTile(record: record, onRetry: onRetry);
|
||||||
}
|
}
|
||||||
final drama = record.drama;
|
final drama = record.drama;
|
||||||
if (drama == null || record.detailLoading) {
|
if (!hasMetadata && (drama == null || record.detailLoading)) {
|
||||||
return _loadingCard();
|
return _loadingCard();
|
||||||
}
|
}
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final episodeLabel = record.episodeNumber > 0
|
final episodeNumber = record.episodeNumber;
|
||||||
|
final episodeLabel = episodeNumber > 0
|
||||||
? l10n.episodeLabel(record.episodeNumber)
|
? l10n.episodeLabel(record.episodeNumber)
|
||||||
: l10n.episodeUnavailable;
|
: l10n.episodeUnavailable;
|
||||||
|
final title = record.displayTitle.isEmpty
|
||||||
|
? episodeLabel
|
||||||
|
: record.displayTitle;
|
||||||
return _card(
|
return _card(
|
||||||
onTap: onOpen,
|
onTap: onOpen,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
@@ -60,10 +65,14 @@ class WatchRecordCard extends StatelessWidget {
|
|||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(_coverRadius),
|
borderRadius: BorderRadius.circular(_coverRadius),
|
||||||
child: RemoteImage(
|
child: RemoteImage(
|
||||||
drama.posterUrl,
|
record.displayCover,
|
||||||
key: ValueKey('watch-record-cover-${record.dramaId}'),
|
key: ValueKey('watch-record-cover-${record.dramaId}'),
|
||||||
width: _coverWidth,
|
width: _coverWidth,
|
||||||
height: _coverHeight,
|
height: _coverHeight,
|
||||||
|
showFallbackIcon: true,
|
||||||
|
fallbackIconKey: ValueKey(
|
||||||
|
'watch-record-cover-placeholder-${record.dramaId}',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
@@ -74,7 +83,7 @@ class WatchRecordCard extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
drama.title,
|
title,
|
||||||
key: ValueKey('watch-record-title-${record.dramaId}'),
|
key: ValueKey('watch-record-title-${record.dramaId}'),
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
@@ -85,14 +94,18 @@ class WatchRecordCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
if (title != episodeLabel) ...[
|
||||||
episodeLabel,
|
Text(
|
||||||
key: ValueKey('watch-record-episode-${record.dramaId}'),
|
episodeLabel,
|
||||||
style: const TextStyle(
|
key: ValueKey(
|
||||||
color: AppColors.muted,
|
'watch-record-episode-${record.dramaId}',
|
||||||
fontSize: _statsFontSize,
|
),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.muted,
|
||||||
|
fontSize: _statsFontSize,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(_progressRadius),
|
borderRadius: BorderRadius.circular(_progressRadius),
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import 'package:fruitshorts/core/data/remote/watch_api.dart';
|
|||||||
/// 用一个一次性邮箱注册(需要登录态的接口才会通);服务没起时自动跳过。
|
/// 用一个一次性邮箱注册(需要登录态的接口才会通);服务没起时自动跳过。
|
||||||
void main() {
|
void main() {
|
||||||
const baseUrl = 'http://127.0.0.1:8081';
|
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';
|
const password = 'Passw0rd!23';
|
||||||
|
|
||||||
AuthSession? session;
|
AuthSession? session;
|
||||||
@@ -98,17 +99,17 @@ void main() {
|
|||||||
expect(resume?.position, position);
|
expect(resume?.position, position);
|
||||||
|
|
||||||
// 最近观看里能看到这部剧
|
// 最近观看里能看到这部剧
|
||||||
final recent = await watch.recent(limit: 20);
|
final recent = await watch.recent(pageSize: 20);
|
||||||
expect(recent.map((item) => item.dramaId), contains(card.id));
|
expect(recent.list.map((item) => item.dramaId), contains(card.id));
|
||||||
|
|
||||||
// 5) 收藏 → 出现在收藏列表 → 取消收藏
|
// 5) 收藏 → 出现在收藏列表 → 取消收藏
|
||||||
final savedState = await library.toggleFavorite(card.id);
|
final savedState = await library.toggleFavorite(card.id);
|
||||||
expect(savedState, isTrue);
|
expect(savedState, isTrue);
|
||||||
final favorites = await library.favorites();
|
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);
|
await library.removeFavorite(card.id);
|
||||||
final afterRemove = await library.favorites();
|
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:flutter_test/flutter_test.dart';
|
||||||
import 'package:fruitshorts/core/data/models/drama.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/repositories/favorite_repository.dart';
|
||||||
import 'package:fruitshorts/core/data/services/favorite_controller.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 {
|
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);
|
final controller = FavoriteController(repository);
|
||||||
|
|
||||||
await controller.load();
|
await controller.load();
|
||||||
@@ -115,4 +116,66 @@ void main() {
|
|||||||
expect(controller.savedIds, isEmpty);
|
expect(controller.savedIds, isEmpty);
|
||||||
expect(controller.cards, 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 {
|
testWidgets('should_show_retry_when_favorite_list_fails', (tester) async {
|
||||||
final auth = await _auth(signedIn: true);
|
final auth = await _auth(signedIn: true);
|
||||||
final repository = InMemoryFavoriteRepository()..failList = true;
|
final repository = InMemoryFavoriteRepository()..failPage = true;
|
||||||
final favorites = FavoriteController(repository);
|
final favorites = FavoriteController(repository);
|
||||||
|
|
||||||
await _pumpPage(tester, auth: auth, favorites: favorites);
|
await _pumpPage(tester, auth: auth, favorites: favorites);
|
||||||
@@ -159,7 +159,7 @@ void main() {
|
|||||||
await _pumpPage(tester, auth: auth, favorites: favorites);
|
await _pumpPage(tester, auth: auth, favorites: favorites);
|
||||||
|
|
||||||
expect(find.byKey(const Key('auth-gate-panel')), findsOneWidget);
|
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/repositories/watch_history_repository.dart';
|
||||||
|
import 'package:fruitshorts/core/data/models/page_result.dart';
|
||||||
import 'package:fruitshorts/core/data/models/watch_progress.dart';
|
import 'package:fruitshorts/core/data/models/watch_progress.dart';
|
||||||
|
|
||||||
/// 记录每次进度读取/上报,方便断言"读了哪部剧、上报了哪一集"。
|
/// 记录每次进度读取/上报,方便断言"读了哪部剧、上报了哪一集"。
|
||||||
@@ -57,9 +60,19 @@ class FakeWatchHistory implements WatchHistoryRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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');
|
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
|
@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/drama.dart';
|
||||||
import 'package:fruitshorts/core/data/models/feed_page.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/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/models/watch_progress.dart';
|
||||||
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
|
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
|
||||||
import 'package:fruitshorts/core/data/repositories/watch_history_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 {
|
test('should_keep_existing_records_when_refresh_fails', () async {
|
||||||
final history = ControlledWatchHistory([_progress('1')]);
|
final history = ControlledWatchHistory([_progress('1')]);
|
||||||
final controller = WatchRecordsController(
|
final controller = WatchRecordsController(
|
||||||
@@ -213,6 +263,7 @@ class ControlledWatchHistory implements WatchHistoryRepository {
|
|||||||
final List<WatchProgress> items;
|
final List<WatchProgress> items;
|
||||||
bool failRecent = false;
|
bool failRecent = false;
|
||||||
Object? recentError;
|
Object? recentError;
|
||||||
|
final List<int> recentPages = [];
|
||||||
bool failRemove = false;
|
bool failRemove = false;
|
||||||
bool failClear = false;
|
bool failClear = false;
|
||||||
Completer<void>? removeGate;
|
Completer<void>? removeGate;
|
||||||
@@ -230,10 +281,21 @@ class ControlledWatchHistory implements WatchHistoryRepository {
|
|||||||
}) async => null;
|
}) async => null;
|
||||||
|
|
||||||
@override
|
@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 (recentError != null) throw recentError!;
|
||||||
if (failRecent) throw StateError('recent unavailable');
|
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
|
@override
|
||||||
@@ -285,3 +347,40 @@ class ScriptedWatchCatalog implements CatalogRepository {
|
|||||||
return _detail(dramaId);
|
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/drama.dart';
|
||||||
import 'package:fruitshorts/core/data/models/feed_page.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/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/models/watch_progress.dart';
|
||||||
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
||||||
import 'package:fruitshorts/core/data/remote/auth_api.dart';
|
import 'package:fruitshorts/core/data/remote/auth_api.dart';
|
||||||
@@ -98,6 +99,56 @@ void main() {
|
|||||||
expect(list.physics, AppPullToRefresh.physics);
|
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(
|
testWidgets(
|
||||||
'should_show_failed_placeholder_and_keep_other_cards_when_detail_fails',
|
'should_show_failed_placeholder_and_keep_other_cards_when_detail_fails',
|
||||||
(tester) async {
|
(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,
|
dramaId: dramaId,
|
||||||
episodeId: '$dramaId-1',
|
episodeId: '$dramaId-1',
|
||||||
|
dramaTitle: dramaTitle,
|
||||||
|
dramaCover: dramaCover,
|
||||||
|
episodeNumber: episodeNumber,
|
||||||
position: const Duration(seconds: 12),
|
position: const Duration(seconds: 12),
|
||||||
duration: const Duration(seconds: 60),
|
duration: const Duration(seconds: 60),
|
||||||
updatedAt: DateTime(2026, 10, 17, 12),
|
updatedAt: DateTime(2026, 10, 17, 12),
|
||||||
@@ -300,9 +359,19 @@ class CountingWatchHistory extends InMemoryWatchHistoryRepository {
|
|||||||
int clearCalls = 0;
|
int clearCalls = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<WatchProgress>> recent({int limit = 20}) async {
|
Future<PageResult<WatchProgress>> recent({
|
||||||
|
int pageIndex = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
}) async {
|
||||||
recentCalls++;
|
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
|
@override
|
||||||
@@ -314,7 +383,10 @@ class CountingWatchHistory extends InMemoryWatchHistoryRepository {
|
|||||||
|
|
||||||
class _FailingRecentHistory extends InMemoryWatchHistoryRepository {
|
class _FailingRecentHistory extends InMemoryWatchHistoryRepository {
|
||||||
@override
|
@override
|
||||||
Future<List<WatchProgress>> recent({int limit = 20}) async {
|
Future<PageResult<WatchProgress>> recent({
|
||||||
|
int pageIndex = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
}) async {
|
||||||
throw const ApiException(100000, '账号不存在,请先注册');
|
throw const ApiException(100000, '账号不存在,请先注册');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -347,3 +419,24 @@ class _FailingWatchCatalog implements CatalogRepository {
|
|||||||
return _delegate.fetchDrama(dramaId);
|
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