Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a48433f01d | ||
|
|
f2ae0c70b6 | ||
|
|
57f3029018 | ||
|
|
9093700a04 | ||
|
|
78114fa46d | ||
|
|
abfca7a3be | ||
|
|
235b50598a | ||
|
|
63d3b0f7ca | ||
|
|
7a588898fa |
No files matched your search
@@ -0,0 +1,24 @@
|
||||
# Android Studio 调试 HTTP 日志
|
||||
|
||||
## 目标
|
||||
|
||||
- 仅在调试构建中输出 Dio 请求、响应与异常日志。
|
||||
- Android Studio Run 控制台通过 `debugPrint` 展示,logcat 标签保持 `flutter`。
|
||||
- Authorization 脱敏,X-Device-Id 保留,二进制响应只显示字节数。
|
||||
- 单条请求体或响应体最多 3000 字符。
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. 新增 `NetworkLogInterceptor`,支持注入日志回调并保留请求耗时。
|
||||
2. 在 Envelope 解包前暂存原始 bytes;日志拦截器追加到拦截链末尾后读取暂存值。
|
||||
3. 在 `ApiClient` 注册调试拦截器,`kDebugMode` 与常量开关同时满足时才输出。
|
||||
4. 以 fake adapter 补单测,覆盖行格式、脱敏、UTF-8、截断、非 JSON 与二进制。
|
||||
5. 运行 `dart analyze lib test` 和 `flutter test`,更新 PROGRESS 留痕。
|
||||
|
||||
## 风险与约束
|
||||
|
||||
- `ResponseType.bytes` 不能直接 `toString`,响应体统一 `utf8.decode`。
|
||||
- Dio 响应拦截器按 FIFO 执行;链尾日志通过 Envelope 暂存的原始 bytes 读取,
|
||||
避免拿到已解包后的 `data`。
|
||||
- release/profile 通过 `kDebugMode` 短路,不输出任何 HTTP 日志。
|
||||
- 不新增依赖,不提交现有 `pubspec.lock` 改动,不启停服务、不操作真机。
|
||||
@@ -0,0 +1,20 @@
|
||||
# 收藏卡片简介
|
||||
|
||||
## 目标
|
||||
|
||||
- 收藏卡片在标题/标签下方显示后端 `synopsis`,最多 2 行并省略溢出。
|
||||
- 空白简介不创建文本节点、不占高度,不显示 `null/undefined`。
|
||||
- 保持封面 80x106 和现有卡片高度,不引入布局溢出。
|
||||
|
||||
## 实现
|
||||
|
||||
1. `_FavoriteCard` 对 synopsis 做 trim 后条件渲染。
|
||||
2. 简介使用 `Flexible` 占据标题、标签与底部统计之间的剩余空间,
|
||||
`maxLines: 2`、`TextOverflow.ellipsis`、次要文案色和字号。
|
||||
3. 无简介时继续使用原 Spacer,保持原布局。
|
||||
4. widget 测试覆盖有简介、空简介和卡片高度稳定性。
|
||||
|
||||
## 约束
|
||||
|
||||
- 不改接口、后端、收藏分页。
|
||||
- 不新增依赖,不提交现有 `pubspec.lock`,不启停服务、不操作真机。
|
||||
@@ -0,0 +1,31 @@
|
||||
# 收藏与观看历史分页 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`,不启停服务、不操作真机。
|
||||
|
||||
## 2026-10-22 范围修订
|
||||
|
||||
- 分页协议和 Controller 分页状态保持不改。
|
||||
- 观看历史优先使用后端 `dramaTitle/dramaCover/episodeNumber`,只有标题为空时才预取详情。
|
||||
- 标题为空且详情也未返回标题时,用本地化“第 N 集”兜底。
|
||||
- 有标题的条目点击播放时再按需加载详情,不在列表加载阶段产生 N+1。
|
||||
- 使用当前真实后端校验第 1/2 页请求参数、total 与 list;若运行实例仍忽略分页,
|
||||
只在 PROGRESS 记录证据,不做前端全量兜底。
|
||||
@@ -531,3 +531,202 @@ $ flutter test --no-pub
|
||||
解读:VTT 时间/xywh/多 sprite、越界最近 cue、LRU2、403 重试、10 分钟 TTL、
|
||||
回退链、sprite 零本地取帧、无 URL 本地回退和最终封面兜底均通过;全量静态分析
|
||||
零告警,617 项测试全过。`pubspec.lock` 已还原,未启停服务,未操作真机。
|
||||
|
||||
## 2026-10-22 Android Studio 调试 HTTP 日志
|
||||
|
||||
- 分支:`feature/debug-http-logging`
|
||||
- 基线:`develop` / `origin/develop` = `dc4b602`
|
||||
- 计划:`docs/plans/2026-10-22-debug-http-logging.md`
|
||||
|
||||
```text
|
||||
$ git status --short --branch
|
||||
## develop...origin/develop
|
||||
M pubspec.lock
|
||||
|
||||
$ git rev-parse HEAD
|
||||
dc4b60270dd38d594510b45bd7a3d50b7dcd2602
|
||||
```
|
||||
|
||||
解读:基于最新 develop 创建功能分支;开工时 `pubspec.lock` 已有未提交改动,
|
||||
本次未修改、未暂存该文件。
|
||||
|
||||
### 实现结果
|
||||
|
||||
- 新增 `NetworkLogInterceptor`,默认 `debugPrint`,可注入
|
||||
`void Function(String)` 作为单测日志收集器。
|
||||
- `enabled` 常量与 `kDebugMode` 同时为真才输出,release/profile 零 HTTP 日志。
|
||||
- 请求行包含完整 URL 与 query,同时输出耗时起点、脱敏请求头和格式化请求体。
|
||||
- Authorization 仅保留前 8 字符后加 `...`;X-Device-Id 原样输出;
|
||||
其余请求头值统一 `***`。
|
||||
- ResponseType.bytes 响应统一 `utf8.decode(..., allowMalformed: true)`,
|
||||
JSON 自动缩进;图片/音视频/字体/PDF/ZIP 等只输出 MIME 与字节数。
|
||||
- 请求体和响应体超过 3000 字符时截断并追加 `…(truncated)`。
|
||||
- 拦截链为 `AuthHeaderInterceptor → EnvelopeInterceptor → NetworkLogInterceptor`。
|
||||
Envelope 在解包前暂存原始 bytes,末尾日志读取暂存值,因此仍能打印 Envelope
|
||||
处理前的原始响应。
|
||||
- 异常路径输出 `[HTTP] ✗ DioException类型 耗时ms URL message`,并继续
|
||||
`handler.next(err)`,不吞异常。
|
||||
|
||||
代表性原始日志:
|
||||
|
||||
```text
|
||||
[HTTP] → POST http://logging.test/app/logging?keyword=%E4%B8%AD%E6%96%87
|
||||
[HTTP] → 耗时起点:2026-10-22T12:12:25.377357
|
||||
[HTTP] → headers:{"Accept":"***","Authorization":"Bearer e...","X-Device-Id":"device-raw-001"}
|
||||
[HTTP] → body:{
|
||||
"filter": "中文"
|
||||
}
|
||||
[HTTP] ← 200 12ms POST http://logging.test/app/logging?keyword=%E4%B8%AD%E6%96%87
|
||||
[HTTP] ← body:{
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": {
|
||||
"title": "中文标题"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 门禁
|
||||
|
||||
```text
|
||||
$ flutter test --no-pub test/core/data/remote/network_log_interceptor_test.dart
|
||||
00:00 +0: should_log_request_and_response_lines_with_elapsed_when_requested
|
||||
00:00 +1: should_truncate_long_body_when_log_exceeds_limit
|
||||
00:00 +2: should_log_plain_text_without_throwing_when_response_is_not_json
|
||||
00:00 +3: should_log_byte_count_only_when_response_is_image
|
||||
00:00 +4: should_log_dio_exception_type_and_message_when_request_fails
|
||||
00:00 +5: All tests passed!
|
||||
|
||||
$ dart analyze lib test
|
||||
Analyzing lib, test...
|
||||
No issues found!
|
||||
|
||||
$ flutter test --no-pub
|
||||
00:58 +622: All tests passed!
|
||||
```
|
||||
|
||||
解读:请求/响应行与耗时、Authorization 脱敏、X-Device-Id 原样、bytes 中文
|
||||
UTF-8 解码、3000 字符截断、非 JSON、二进制字节数、DioException 类型与 message
|
||||
均通过;全量静态分析零告警,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 严格解析服务端 PageResult;旧 List 不做客户端全量包装,由后端部署新契约解决。
|
||||
- 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` 改动未提交。
|
||||
|
||||
### 范围修订:观看标题优先与按需详情
|
||||
|
||||
- 分页协议、Repository 与 Controller 分页状态保持不变。
|
||||
- `_hydrate` 只对 `dramaTitle` 为空的记录补拉详情;后端已有标题时不再请求详情。
|
||||
- 有标题的记录点击播放时才调用 `detailForOpen` 按需加载详情,避免列表阶段 N+1。
|
||||
- 标题仍为空时使用本地化“第 N 集”,封面缺失继续显示占位。
|
||||
- 移除了旧 List 自动包装为 PageResult 的前端全量兜底,避免掩盖后端契约问题。
|
||||
|
||||
真实后端分页取证(仅测试账号写入,不启停服务):
|
||||
|
||||
```text
|
||||
GET /app/watch/recent?pageIndex=1&pageSize=1
|
||||
{"shape":"list","length":2,"dramaIds":["1","5"]}
|
||||
GET /app/watch/recent?pageIndex=2&pageSize=1
|
||||
{"shape":"list","length":2,"dramaIds":["1","5"]}
|
||||
GET /app/favorites?pageIndex=1&pageSize=1
|
||||
{"shape":"list","length":2,"dramaIds":["1","5"]}
|
||||
GET /app/favorites?pageIndex=2&pageSize=1
|
||||
{"shape":"list","length":2,"dramaIds":["1","5"]}
|
||||
```
|
||||
|
||||
解读:当前 8081 运行实例仍是旧包,返回 List 且忽略 pageIndex/pageSize;第 1/2 页
|
||||
均返回相同两条数据。客户端不做全量兜底;live smoke 检测到旧 List 后明确标记 skipped,
|
||||
等待后端新包部署后再执行分页真实联调。
|
||||
|
||||
门禁:
|
||||
|
||||
```text
|
||||
$ flutter test --no-pub test/integration/live_watch_flow_test.dart test/library/library_api_test.dart test/watch/watch_api_test.dart test/watch/watch_records_controller_test.dart test/watch/watch_records_page_test.dart
|
||||
00:02 +22 ~1: All tests passed!
|
||||
|
||||
$ dart analyze lib test
|
||||
Analyzing lib, test...
|
||||
No issues found!
|
||||
|
||||
$ flutter test --no-pub
|
||||
00:52 +630 ~1: All tests passed!
|
||||
```
|
||||
|
||||
解读:有 dramaTitle 时不触发详情请求、点击时才加载详情、空标题兜底、
|
||||
第 2 页追加去重和 hasMore 行为均通过;全量分析零告警,630 项通过、1 项旧服务
|
||||
能力探测跳过。`pubspec.lock` 既有改动未提交。
|
||||
|
||||
## 2026-10-22 收藏卡片简介
|
||||
|
||||
- 分支:`feature/favorite-card-synopsis`
|
||||
- 基线:`develop` / `origin/develop` = `9093700`
|
||||
- 计划:`docs/plans/2026-10-22-favorite-card-synopsis.md`
|
||||
|
||||
### 实现
|
||||
|
||||
- `_FavoriteCard` 在标题/标签下方条件渲染 `drama.synopsis.trim()`。
|
||||
- 简介最多 2 行、`TextOverflow.ellipsis`,使用 `AppColors.muted`、12 号字和
|
||||
`1.15` 行高,由 `Flexible` 占据剩余空间,保持封面与卡片原高度。
|
||||
- 简介为空或纯空白时不创建文本节点,继续使用原 Spacer,不占高度。
|
||||
- 未改接口、后端和收藏分页逻辑。
|
||||
|
||||
### 门禁
|
||||
|
||||
```text
|
||||
$ flutter test --no-pub test/library/favorites_page_test.dart
|
||||
00:01 +7: All tests passed!
|
||||
|
||||
$ dart analyze lib test
|
||||
Analyzing lib, test...
|
||||
No issues found!
|
||||
|
||||
$ flutter test --no-pub
|
||||
00:51 +631 ~1: All tests passed!
|
||||
```
|
||||
|
||||
解读:有简介时文本最多 2 行并省略,空简介不渲染且卡片高度与正常卡片一致;
|
||||
全量静态分析零告警,631 项通过、1 项运行中旧后端能力探测跳过。
|
||||
`pubspec.lock` 既有改动未提交。
|
||||
@@ -0,0 +1,47 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'json_numbers.dart';
|
||||
|
||||
/// 服务端标准分页响应 `{total, list}`。
|
||||
///
|
||||
/// 兼容旧版「data 直接是数组」的响应:按数组长度作为 total,避免列表空白。
|
||||
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 = _parseItemMaps(json['list']).map(parseItem).toList(
|
||||
growable: false,
|
||||
);
|
||||
return PageResult(total: jsonToInt(json['total']), list: items);
|
||||
}
|
||||
|
||||
/// 兼容分页 Map 与旧版纯 List;其它类型视为空页。
|
||||
factory PageResult.parse(
|
||||
Object? data,
|
||||
T Function(Map<String, dynamic>) parseItem,
|
||||
) {
|
||||
if (data is Map) {
|
||||
return PageResult.fromJson(Map<String, dynamic>.from(data), parseItem);
|
||||
}
|
||||
if (data is List) {
|
||||
final items = _parseItemMaps(data).map(parseItem).toList(growable: false);
|
||||
return PageResult(total: items.length, list: items);
|
||||
}
|
||||
return PageResult<T>();
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _parseItemMaps(Object? raw) {
|
||||
if (raw is! List) return const [];
|
||||
return [
|
||||
for (final item in raw)
|
||||
if (item is Map) Map<String, dynamic>.from(item),
|
||||
];
|
||||
}
|
||||
@@ -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,8 +1,11 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../config/app_config.dart';
|
||||
import 'api_exception.dart';
|
||||
import 'api_interceptors.dart';
|
||||
import 'network_log_interceptor.dart';
|
||||
|
||||
/// `ApiException` 从这里也能拿到:历史调用方一律 `import 'api_client.dart'`,不必改 import。
|
||||
export 'api_exception.dart';
|
||||
@@ -39,6 +42,7 @@ class ApiClient {
|
||||
deviceIdReader: deviceIdReader,
|
||||
),
|
||||
const EnvelopeInterceptor(),
|
||||
NetworkLogInterceptor(),
|
||||
]);
|
||||
if (adapter != null) {
|
||||
_dio.httpClientAdapter = adapter;
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'dart:convert';
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'api_exception.dart';
|
||||
import 'network_log_interceptor.dart';
|
||||
|
||||
/// 请求级开关:放在 `RequestOptions.extra` 里,由 `ApiClient` 写入、拦截器读取。
|
||||
class ApiRequestFlags {
|
||||
@@ -72,6 +73,10 @@ class EnvelopeInterceptor extends Interceptor {
|
||||
ResponseInterceptorHandler handler,
|
||||
) {
|
||||
final options = response.requestOptions;
|
||||
if (NetworkLogInterceptor.isEnabled) {
|
||||
options.extra[NetworkLogInterceptor.rawResponseDataExtraKey] =
|
||||
response.data;
|
||||
}
|
||||
final shell = _decodeShell(response.data);
|
||||
final code = shell['code'] as int? ?? -1;
|
||||
final message = shell['message'] as String?;
|
||||
|
||||
@@ -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,16 @@ 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'},
|
||||
);
|
||||
// 线上可能仍返回 List;分页壳与纯数组都要能渲染。
|
||||
return PageResult.parse(data, Drama.fromCardJson);
|
||||
}
|
||||
|
||||
Future<void> removeFavorite(String dramaId) async {
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 调试构建下输出 Dio 请求、响应和异常详情。
|
||||
class NetworkLogInterceptor extends Interceptor {
|
||||
/// 在调试时关闭日志的本地常量开关。
|
||||
static const bool enabled = true;
|
||||
|
||||
/// Envelope 解包前暂存原始响应体,供链尾日志读取。
|
||||
static const String rawResponseDataExtraKey = 'app.http.rawResponseData';
|
||||
|
||||
/// 请求、响应体单条日志最大字符数。
|
||||
static const int maxBodyLength = 3000;
|
||||
|
||||
static const String _truncatedSuffix = '…(truncated)';
|
||||
static const String _stopwatchKey = 'app.http.stopwatch';
|
||||
static const String _startedAtKey = 'app.http.startedAt';
|
||||
static const String _emptyBody = '<empty>';
|
||||
static const String _redacted = '***';
|
||||
|
||||
/// 允许单测注入日志收集器;默认走 Android Studio Run 控制台。
|
||||
NetworkLogInterceptor({void Function(String)? logger})
|
||||
: _logger = logger ?? debugPrint;
|
||||
|
||||
final void Function(String) _logger;
|
||||
|
||||
/// release/profile 下恒为 false,保证零 HTTP 日志输出。
|
||||
static bool get isEnabled => kDebugMode && enabled;
|
||||
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
if (!isEnabled) {
|
||||
return handler.next(options);
|
||||
}
|
||||
final stopwatch = Stopwatch()..start();
|
||||
options.extra[_stopwatchKey] = stopwatch;
|
||||
options.extra[_startedAtKey] = DateTime.now().toIso8601String();
|
||||
final body = _formatRequestBody(options.data);
|
||||
final message = StringBuffer()
|
||||
..writeln('[HTTP] → ${options.method} ${options.uri}')
|
||||
..writeln('[HTTP] → 耗时起点:${options.extra[_startedAtKey]}')
|
||||
..writeln('[HTTP] → headers:${_formatHeaders(options.headers)}')
|
||||
..writeln('[HTTP] → body:$body');
|
||||
_logger(message.toString().trimRight());
|
||||
handler.next(options);
|
||||
}
|
||||
|
||||
@override
|
||||
void onResponse(
|
||||
Response<dynamic> response,
|
||||
ResponseInterceptorHandler handler,
|
||||
) {
|
||||
if (!isEnabled) {
|
||||
return handler.next(response);
|
||||
}
|
||||
final options = response.requestOptions;
|
||||
final elapsed = _elapsedMilliseconds(options);
|
||||
final rawBody = options.extra[rawResponseDataExtraKey] ?? response.data;
|
||||
final body = _formatResponseBody(
|
||||
rawBody,
|
||||
response.headers.value(Headers.contentTypeHeader),
|
||||
);
|
||||
final message = StringBuffer()
|
||||
..writeln(
|
||||
'[HTTP] ← ${response.statusCode ?? 0} ${elapsed}ms '
|
||||
'${options.method} ${options.uri}',
|
||||
)
|
||||
..writeln('[HTTP] ← body:$body');
|
||||
_logger(message.toString().trimRight());
|
||||
handler.next(response);
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
if (!isEnabled) {
|
||||
return handler.next(err);
|
||||
}
|
||||
final options = err.requestOptions;
|
||||
final message = StringBuffer()
|
||||
..writeln(
|
||||
'[HTTP] ✗ ${err.type.name} ${_elapsedMilliseconds(options)}ms '
|
||||
'${options.uri} ${err.message ?? ''}',
|
||||
)
|
||||
..write(_errorBodyLine(err));
|
||||
_logger(message.toString().trimRight());
|
||||
handler.next(err);
|
||||
}
|
||||
|
||||
int _elapsedMilliseconds(RequestOptions options) {
|
||||
final stopwatch = options.extra[_stopwatchKey];
|
||||
if (stopwatch is Stopwatch) {
|
||||
return stopwatch.elapsedMilliseconds;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
String _errorBodyLine(DioException error) {
|
||||
final rawBody =
|
||||
error.requestOptions.extra[rawResponseDataExtraKey] ??
|
||||
error.response?.data;
|
||||
if (rawBody == null) {
|
||||
return '';
|
||||
}
|
||||
final body = _formatResponseBody(
|
||||
rawBody,
|
||||
error.response?.headers.value(Headers.contentTypeHeader),
|
||||
);
|
||||
return '\n[HTTP] ✗ body:$body';
|
||||
}
|
||||
|
||||
String _formatHeaders(Map<String, dynamic> headers) {
|
||||
if (headers.isEmpty) {
|
||||
return '{}';
|
||||
}
|
||||
final entries = headers.entries.toList()
|
||||
..sort((left, right) => left.key.compareTo(right.key));
|
||||
final safe = <String, String>{
|
||||
for (final entry in entries)
|
||||
entry.key: _safeHeaderValue(entry.key, entry.value),
|
||||
};
|
||||
return _truncate(jsonEncode(safe));
|
||||
}
|
||||
|
||||
String _safeHeaderValue(String name, Object? value) {
|
||||
final text = value?.toString() ?? '';
|
||||
if (name.toLowerCase() == 'authorization') {
|
||||
return _maskAuthorization(text);
|
||||
}
|
||||
if (name.toLowerCase() == 'x-device-id') {
|
||||
return text;
|
||||
}
|
||||
return _redacted;
|
||||
}
|
||||
|
||||
String _maskAuthorization(String value) {
|
||||
if (value.length <= 8) {
|
||||
return _redacted;
|
||||
}
|
||||
return '${value.substring(0, math.min(8, value.length))}...';
|
||||
}
|
||||
|
||||
String _formatRequestBody(Object? body) {
|
||||
if (body == null) {
|
||||
return _emptyBody;
|
||||
}
|
||||
final json = _tryPrettyJson(body);
|
||||
if (json != null) {
|
||||
return _truncate(json);
|
||||
}
|
||||
final length = switch (body) {
|
||||
FormData data => data.length,
|
||||
List<int> bytes => bytes.length,
|
||||
_ => utf8.encode(body.toString()).length,
|
||||
};
|
||||
return '${body.runtimeType} ($length bytes)';
|
||||
}
|
||||
|
||||
String _formatResponseBody(Object? body, String? contentType) {
|
||||
if (body == null) {
|
||||
return _emptyBody;
|
||||
}
|
||||
if (body is List<int>) {
|
||||
final bytes = body is Uint8List ? body : Uint8List.fromList(body);
|
||||
if (_isBinary(contentType)) {
|
||||
return '<binary ${contentType ?? 'unknown'}, ${bytes.length} bytes>';
|
||||
}
|
||||
final text = utf8.decode(bytes, allowMalformed: true);
|
||||
return _formatText(text);
|
||||
}
|
||||
return _formatText(body.toString());
|
||||
}
|
||||
|
||||
String _formatText(String text) {
|
||||
if (text.isEmpty) {
|
||||
return _emptyBody;
|
||||
}
|
||||
return _truncate(_tryPrettyJson(text) ?? text);
|
||||
}
|
||||
|
||||
String? _tryPrettyJson(Object? body) {
|
||||
try {
|
||||
final value = body is String
|
||||
? jsonDecode(body)
|
||||
: body is List<int>
|
||||
? jsonDecode(utf8.decode(body, allowMalformed: true))
|
||||
: body;
|
||||
if (value is! Map && value is! List) {
|
||||
return null;
|
||||
}
|
||||
return const JsonEncoder.withIndent(' ').convert(value);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
bool _isBinary(String? contentType) {
|
||||
final type = contentType?.toLowerCase() ?? '';
|
||||
return type.startsWith('image/') ||
|
||||
type.startsWith('audio/') ||
|
||||
type.startsWith('video/') ||
|
||||
type.startsWith('font/') ||
|
||||
type.contains('application/octet-stream') ||
|
||||
type.contains('application/pdf') ||
|
||||
type.contains('application/zip');
|
||||
}
|
||||
|
||||
String _truncate(String value) {
|
||||
if (value.length <= maxBodyLength) {
|
||||
return value;
|
||||
}
|
||||
final end = maxBodyLength - _truncatedSuffix.length;
|
||||
return '${value.substring(0, end)}$_truncatedSuffix';
|
||||
}
|
||||
}
|
||||
@@ -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,17 @@ 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),
|
||||
];
|
||||
// 线上可能仍返回 List;分页壳与纯数组都要能渲染。
|
||||
return PageResult.parse(data, WatchProgress.fromJson);
|
||||
}
|
||||
|
||||
/// 删除某部剧的观看记录(记录页清单条用)。
|
||||
|
||||
@@ -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);
|
||||
@@ -81,12 +130,23 @@ class WatchRecordsController extends ChangeNotifier {
|
||||
_notify();
|
||||
}
|
||||
|
||||
/// 点击有标题的记录时再按需加载详情,避免列表阶段产生 N+1。
|
||||
Future<Drama?> detailForOpen(String dramaId) async {
|
||||
final cached = _detailCache[dramaId];
|
||||
if (cached != null) return cached;
|
||||
await _loadDetail(dramaId);
|
||||
_notify();
|
||||
return _detailCache[dramaId];
|
||||
}
|
||||
|
||||
/// 删除单条记录,先更新 UI,失败按原位置回滚。
|
||||
Future<bool> remove(String dramaId) async {
|
||||
final index = _recordIndex(dramaId);
|
||||
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 +156,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 +168,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 +185,9 @@ class WatchRecordsController extends ChangeNotifier {
|
||||
return true;
|
||||
} catch (_) {
|
||||
records = previous;
|
||||
total = previousTotal;
|
||||
_pageIndex = previousPageIndex;
|
||||
hasMore = records.length < total;
|
||||
actionError = 'recordClearFailed';
|
||||
return false;
|
||||
} finally {
|
||||
@@ -138,11 +210,12 @@ class WatchRecordsController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> _hydrate(List<String> dramaIds) async {
|
||||
for (final id in dramaIds) {
|
||||
final missingTitles = dramaIds.where(_hasMissingTitle).toSet().toList();
|
||||
for (final id in missingTitles) {
|
||||
if (_detailCache.containsKey(id)) await _loadDetail(id);
|
||||
}
|
||||
final queue = [
|
||||
for (final id in dramaIds)
|
||||
for (final id in missingTitles)
|
||||
if (!_detailCache.containsKey(id)) id,
|
||||
];
|
||||
final workerCount = queue.length < detailConcurrency
|
||||
@@ -159,9 +232,15 @@ class WatchRecordsController extends ChangeNotifier {
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> _loadDetail(String dramaId) async {
|
||||
bool _hasMissingTitle(String dramaId) {
|
||||
final index = _recordIndex(dramaId);
|
||||
if (index < 0) return;
|
||||
if (index < 0) return false;
|
||||
return (records[index].progress.dramaTitle?.trim() ?? '').isEmpty;
|
||||
}
|
||||
|
||||
Future<Drama?> _loadDetail(String dramaId) async {
|
||||
final index = _recordIndex(dramaId);
|
||||
if (index < 0) return null;
|
||||
final cached = _detailCache[dramaId];
|
||||
if (cached != null) {
|
||||
_replaceAt(
|
||||
@@ -172,14 +251,14 @@ class WatchRecordsController extends ChangeNotifier {
|
||||
detailFailed: false,
|
||||
),
|
||||
);
|
||||
return;
|
||||
return cached;
|
||||
}
|
||||
try {
|
||||
final detail = await _catalog.fetchDrama(dramaId);
|
||||
if (_disposed) return;
|
||||
if (_disposed) return null;
|
||||
_detailCache[dramaId] = detail;
|
||||
final current = _recordIndex(dramaId);
|
||||
if (current < 0) return;
|
||||
if (current < 0) return detail;
|
||||
_replaceAt(
|
||||
current,
|
||||
records[current].copyWith(
|
||||
@@ -188,6 +267,7 @@ class WatchRecordsController extends ChangeNotifier {
|
||||
detailFailed: false,
|
||||
),
|
||||
);
|
||||
return detail;
|
||||
} catch (failure, stackTrace) {
|
||||
debugPrint(
|
||||
'[WatchRecordsController] detail failed dramaId=$dramaId '
|
||||
@@ -197,13 +277,14 @@ class WatchRecordsController extends ChangeNotifier {
|
||||
stackTrace: stackTrace,
|
||||
label: '[WatchRecordsController] detail failed dramaId=$dramaId',
|
||||
);
|
||||
if (_disposed) return;
|
||||
if (_disposed) return null;
|
||||
final current = _recordIndex(dramaId);
|
||||
if (current < 0) return;
|
||||
if (current < 0) return null;
|
||||
_replaceAt(
|
||||
current,
|
||||
records[current].copyWith(detailLoading: false, detailFailed: true),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -33,8 +34,12 @@ const double _removeIconSize = 20;
|
||||
const double _statsFontSize = 12;
|
||||
const double _metaFontSize = 11;
|
||||
const double _titleFontSize = 15;
|
||||
const double _synopsisFontSize = 12;
|
||||
const double _synopsisLineHeight = 1.15;
|
||||
const double _synopsisGap = 4;
|
||||
const int _maxTags = 2;
|
||||
const double _emptyListHeight = 360;
|
||||
const double _loadMoreThreshold = 240;
|
||||
|
||||
final RegExp _tagSplitter = RegExp(r'[,/、·]');
|
||||
|
||||
@@ -156,6 +161,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 +178,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,
|
||||
@@ -225,6 +255,7 @@ class _FavoriteCard extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final tags = _tags();
|
||||
final synopsis = drama.synopsis.trim();
|
||||
return Container(
|
||||
key: ValueKey('favorite-card-${drama.id}'),
|
||||
decoration: BoxDecoration(
|
||||
@@ -318,7 +349,25 @@ class _FavoriteCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (synopsis.isNotEmpty) ...[
|
||||
const SizedBox(height: _synopsisGap),
|
||||
Flexible(
|
||||
child: Text(
|
||||
synopsis,
|
||||
key: ValueKey(
|
||||
'favorite-synopsis-${drama.id}',
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: _synopsisFontSize,
|
||||
height: _synopsisLineHeight,
|
||||
),
|
||||
),
|
||||
),
|
||||
] else
|
||||
const Spacer(),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
|
||||
@@ -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 {
|
||||
@@ -138,34 +140,59 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> {
|
||||
record: record,
|
||||
onRetry: () => controller.retryDetail(record.dramaId),
|
||||
onRemove: () => _remove(record.dramaId),
|
||||
onOpen: record.drama == null ? null : () => _open(record),
|
||||
onOpen: () => _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,
|
||||
@@ -192,10 +219,20 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> {
|
||||
}
|
||||
|
||||
Future<void> _open(WatchRecord record) async {
|
||||
final drama =
|
||||
record.drama ?? await controller.detailForOpen(record.dramaId);
|
||||
if (!mounted) return;
|
||||
if (drama == null) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l10n.detailLoadFailed)));
|
||||
return;
|
||||
}
|
||||
final episodeId = record.progress.episodeId;
|
||||
await playDramaFromCatalog(
|
||||
context,
|
||||
record.drama!,
|
||||
drama,
|
||||
episodeId: episodeId.isEmpty ? null : episodeId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/remote/api_interceptors.dart';
|
||||
import 'package:fruitshorts/core/data/remote/network_log_interceptor.dart';
|
||||
|
||||
void main() {
|
||||
const baseUrl = 'http://logging.test';
|
||||
const token = 'eyJhbGciOiJIUzM4NCJ9.secret';
|
||||
|
||||
Dio buildDio(
|
||||
List<String> logs,
|
||||
HttpClientAdapter adapter, {
|
||||
bool useEnvelope = true,
|
||||
}) {
|
||||
final dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: baseUrl,
|
||||
responseType: ResponseType.bytes,
|
||||
validateStatus: (_) => true,
|
||||
),
|
||||
);
|
||||
dio.httpClientAdapter = adapter;
|
||||
dio.interceptors.addAll([
|
||||
AuthHeaderInterceptor(
|
||||
accessTokenReader: () => token,
|
||||
deviceIdReader: () async => 'device-raw-001',
|
||||
),
|
||||
if (useEnvelope) const EnvelopeInterceptor(),
|
||||
NetworkLogInterceptor(logger: logs.add),
|
||||
]);
|
||||
return dio;
|
||||
}
|
||||
|
||||
test(
|
||||
'should_log_request_and_response_lines_with_elapsed_when_requested',
|
||||
() async {
|
||||
final logs = <String>[];
|
||||
final dio = buildDio(
|
||||
logs,
|
||||
_RawHttpAdapter.fromText(
|
||||
jsonEncode({
|
||||
'code': 0,
|
||||
'message': 'ok',
|
||||
'data': {'title': '中文标题'},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await dio.post<void>(
|
||||
'/app/logging',
|
||||
queryParameters: {'keyword': '中文'},
|
||||
data: {'filter': '中文'},
|
||||
);
|
||||
|
||||
expect(
|
||||
logs.first,
|
||||
contains('[HTTP] → POST $baseUrl/app/logging?keyword='),
|
||||
);
|
||||
expect(logs.join('\n'), contains('[HTTP] → 耗时起点:'));
|
||||
expect(logs.join('\n'), matches(RegExp(r'\[HTTP\] ← 200 \d+ms POST ')));
|
||||
expect(logs.join('\n'), contains('"filter": "中文"'));
|
||||
expect(logs.join('\n'), contains('"中文标题"'));
|
||||
expect(logs.join('\n'), contains('"Authorization":"Bearer e..."'));
|
||||
expect(logs.join('\n'), contains('"X-Device-Id":"device-raw-001"'));
|
||||
expect(logs.join('\n'), isNot(contains(token)));
|
||||
},
|
||||
);
|
||||
|
||||
test('should_truncate_long_body_when_log_exceeds_limit', () async {
|
||||
final logs = <String>[];
|
||||
final dio = buildDio(
|
||||
logs,
|
||||
_RawHttpAdapter.fromText(
|
||||
jsonEncode({
|
||||
'code': 0,
|
||||
'message': 'ok',
|
||||
'data': {'text': List.filled(3500, '长').join()},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await dio.get<void>('/app/long');
|
||||
|
||||
final responseLog = logs.singleWhere(
|
||||
(line) => line.startsWith('[HTTP] ← 200'),
|
||||
);
|
||||
const prefix = '[HTTP] ← body:';
|
||||
final body = responseLog.substring(
|
||||
responseLog.indexOf(prefix) + prefix.length,
|
||||
);
|
||||
expect(body.length, NetworkLogInterceptor.maxBodyLength);
|
||||
expect(body, endsWith('…(truncated)'));
|
||||
});
|
||||
|
||||
test(
|
||||
'should_log_plain_text_without_throwing_when_response_is_not_json',
|
||||
() async {
|
||||
final logs = <String>[];
|
||||
final dio = buildDio(
|
||||
logs,
|
||||
_RawHttpAdapter.fromText('plain text response'),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
dio.get<void>('/app/plain'),
|
||||
throwsA(isA<DioException>()),
|
||||
);
|
||||
|
||||
expect(logs.join('\n'), contains('[HTTP] ✗'));
|
||||
expect(logs.join('\n'), contains('plain text response'));
|
||||
},
|
||||
);
|
||||
|
||||
test('should_log_byte_count_only_when_response_is_image', () async {
|
||||
final logs = <String>[];
|
||||
final dio = buildDio(
|
||||
logs,
|
||||
_RawHttpAdapter(
|
||||
Uint8List.fromList([0xff, 0xd8, 0xff, 0x00]),
|
||||
contentType: 'image/jpeg',
|
||||
),
|
||||
useEnvelope: false,
|
||||
);
|
||||
|
||||
await dio.get<void>('/files/image.jpg');
|
||||
|
||||
expect(logs.join('\n'), contains('<binary image/jpeg, 4 bytes>'));
|
||||
expect(logs.join('\n'), isNot(contains('�')));
|
||||
});
|
||||
|
||||
test(
|
||||
'should_log_dio_exception_type_and_message_when_request_fails',
|
||||
() async {
|
||||
final logs = <String>[];
|
||||
final dio = buildDio(logs, _ThrowingHttpAdapter());
|
||||
|
||||
await expectLater(
|
||||
dio.get<void>('/app/offline'),
|
||||
throwsA(isA<DioException>()),
|
||||
);
|
||||
|
||||
expect(
|
||||
logs.join('\n'),
|
||||
matches(RegExp(r'\[HTTP\] ✗ connectionError \d+ms .* offline')),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _RawHttpAdapter implements HttpClientAdapter {
|
||||
_RawHttpAdapter(this.bytes, {required this.contentType});
|
||||
|
||||
factory _RawHttpAdapter.fromText(String body) {
|
||||
return _RawHttpAdapter(
|
||||
Uint8List.fromList(utf8.encode(body)),
|
||||
contentType: 'application/json; charset=utf-8',
|
||||
);
|
||||
}
|
||||
|
||||
final Uint8List bytes;
|
||||
final String contentType;
|
||||
|
||||
@override
|
||||
Future<ResponseBody> fetch(
|
||||
RequestOptions options,
|
||||
Stream<Uint8List>? requestStream,
|
||||
Future<void>? cancelFuture,
|
||||
) async {
|
||||
return ResponseBody.fromBytes(
|
||||
bytes,
|
||||
200,
|
||||
headers: {
|
||||
Headers.contentTypeHeader: [contentType],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void close({bool force = false}) {}
|
||||
}
|
||||
|
||||
class _ThrowingHttpAdapter implements HttpClientAdapter {
|
||||
@override
|
||||
Future<ResponseBody> fetch(
|
||||
RequestOptions options,
|
||||
Stream<Uint8List>? requestStream,
|
||||
Future<void>? cancelFuture,
|
||||
) {
|
||||
throw DioException(
|
||||
requestOptions: options,
|
||||
type: DioExceptionType.connectionError,
|
||||
message: 'offline',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void close({bool force = false}) {}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/page_result.dart';
|
||||
|
||||
void main() {
|
||||
group('PageResult.parse', () {
|
||||
test('should_parse_page_map_when_total_and_list_present', () {
|
||||
final page = PageResult.parse({
|
||||
'total': '3',
|
||||
'list': [
|
||||
{'id': '1', 'title': 'A'},
|
||||
{'id': '2', 'title': 'B'},
|
||||
],
|
||||
}, _item);
|
||||
|
||||
expect(page.total, 3);
|
||||
expect(page.list.map((e) => e['id']), ['1', '2']);
|
||||
});
|
||||
|
||||
test('should_parse_legacy_list_when_data_is_array', () {
|
||||
final page = PageResult.parse([
|
||||
{'id': '9', 'title': '收藏剧'},
|
||||
{'id': '8', 'title': '另一部'},
|
||||
], _item);
|
||||
|
||||
expect(page.total, 2);
|
||||
expect(page.list, hasLength(2));
|
||||
expect(page.list.first['title'], '收藏剧');
|
||||
});
|
||||
|
||||
test('should_return_empty_page_when_data_is_null_or_empty_list', () {
|
||||
expect(PageResult.parse(null, _item).total, 0);
|
||||
expect(PageResult.parse(null, _item).list, isEmpty);
|
||||
expect(PageResult.parse(const <Object>[], _item).total, 0);
|
||||
expect(PageResult.parse(const <Object>[], _item).list, isEmpty);
|
||||
});
|
||||
|
||||
test('should_return_empty_page_when_payload_is_invalid', () {
|
||||
final page = PageResult.parse('broken', _item);
|
||||
expect(page.total, 0);
|
||||
expect(page.list, isEmpty);
|
||||
});
|
||||
|
||||
test('should_skip_non_map_entries_in_legacy_list', () {
|
||||
final page = PageResult.parse([
|
||||
{'id': '1'},
|
||||
'noise',
|
||||
42,
|
||||
{'id': '2'},
|
||||
], _item);
|
||||
|
||||
expect(page.total, 2);
|
||||
expect(page.list.map((e) => e['id']), ['1', '2']);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Map<String, dynamic> _item(Map<String, dynamic> json) => json;
|
||||
@@ -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;
|
||||
@@ -57,6 +58,20 @@ void main() {
|
||||
}
|
||||
expect(session?.user.uid, isNotEmpty);
|
||||
|
||||
final recentProbe = await client.get(
|
||||
'/app/watch/recent',
|
||||
query: {'pageIndex': '1', 'pageSize': '1'},
|
||||
);
|
||||
final favoriteProbe = await client.get(
|
||||
'/app/favorites',
|
||||
query: {'pageIndex': '1', 'pageSize': '1'},
|
||||
);
|
||||
if (recentProbe is! Map<String, dynamic> ||
|
||||
favoriteProbe is! Map<String, dynamic>) {
|
||||
markTestSkipped('运行中的后端仍返回旧版 List,分页契约联调已跳过');
|
||||
return;
|
||||
}
|
||||
|
||||
// 1) 首页 → 取第一部剧
|
||||
final feed = await catalog.homeFeed();
|
||||
expect(feed.dramas, isNotEmpty);
|
||||
@@ -98,17 +113,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;
|
||||
}
|
||||
@@ -32,10 +32,10 @@ import '../support/fake_interaction_repositories.dart';
|
||||
import '../support/fake_notice_repository.dart';
|
||||
import '../support/widget_player.dart';
|
||||
|
||||
Drama _card(String id) => Drama(
|
||||
Drama _card(String id, {String synopsis = 'synopsis'}) => Drama(
|
||||
id: id,
|
||||
title: 'Drama $id',
|
||||
synopsis: 'synopsis',
|
||||
synopsis: synopsis,
|
||||
posterUrl: '/files/$id.png',
|
||||
totalEpisodes: 12,
|
||||
);
|
||||
@@ -73,6 +73,14 @@ void main() {
|
||||
);
|
||||
expect(title.style?.color, AppColors.onSurface);
|
||||
expect(title.style?.fontSize, 15);
|
||||
final synopsis = tester.widget<Text>(
|
||||
find.byKey(const Key('favorite-synopsis-signal')),
|
||||
);
|
||||
expect(synopsis.data, 'synopsis');
|
||||
expect(synopsis.maxLines, 2);
|
||||
expect(synopsis.overflow, TextOverflow.ellipsis);
|
||||
expect(synopsis.style?.color, AppColors.muted);
|
||||
expect(synopsis.style?.fontSize, 12);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('favorite-cover-signal'))),
|
||||
const Size(80, 106),
|
||||
@@ -100,6 +108,38 @@ void main() {
|
||||
expect(find.byKey(const Key('favorite-card-signal')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('should_hide_blank_synopsis_without_changing_card_height', (
|
||||
tester,
|
||||
) async {
|
||||
final auth = await _auth(signedIn: true);
|
||||
final longSynopsis = List.filled(12, '很长的剧情简介').join();
|
||||
final favorites = FavoriteController(
|
||||
InMemoryFavoriteRepository(
|
||||
saved: {'with-synopsis', 'without-synopsis'},
|
||||
cards: [
|
||||
_card('with-synopsis', synopsis: longSynopsis),
|
||||
_card('without-synopsis', synopsis: ' '),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
await _pumpPage(tester, auth: auth, favorites: favorites);
|
||||
|
||||
final withSynopsis = tester.widget<Text>(
|
||||
find.byKey(const Key('favorite-synopsis-with-synopsis')),
|
||||
);
|
||||
expect(withSynopsis.maxLines, 2);
|
||||
expect(withSynopsis.overflow, TextOverflow.ellipsis);
|
||||
expect(
|
||||
find.byKey(const Key('favorite-synopsis-without-synopsis')),
|
||||
findsNothing,
|
||||
);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('favorite-card-with-synopsis'))),
|
||||
tester.getSize(find.byKey(const Key('favorite-card-without-synopsis'))),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('should_keep_card_and_show_failure_when_remove_fails', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -137,7 +177,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 +199,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,104 @@
|
||||
// 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_parse_favorites_when_server_returns_legacy_list', () async {
|
||||
final adapter = FakeHttpAdapter(
|
||||
(_) async => FakeHttpResponse(
|
||||
jsonEncode({
|
||||
'code': 0,
|
||||
'message': 'ok',
|
||||
'data': [
|
||||
{'id': '5', 'title': '闪婚后', 'cover': '/files/5.png'},
|
||||
{'id': '4', 'title': '另一部', 'cover': '/files/4.png'},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
final api = LibraryApi(
|
||||
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
|
||||
);
|
||||
|
||||
final page = await api.favorites();
|
||||
|
||||
expect(page.total, 2);
|
||||
expect(page.list.map((drama) => drama.id), ['5', '4']);
|
||||
expect(page.list.first.title, '闪婚后');
|
||||
});
|
||||
|
||||
test('should_return_empty_favorites_when_data_is_null', () async {
|
||||
final adapter = FakeHttpAdapter(
|
||||
(_) async => FakeHttpResponse(
|
||||
jsonEncode({'code': 0, 'message': 'ok', 'data': null}),
|
||||
),
|
||||
);
|
||||
final api = LibraryApi(
|
||||
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
|
||||
);
|
||||
|
||||
final page = await api.favorites();
|
||||
|
||||
expect(page.total, 0);
|
||||
expect(page.list, isEmpty);
|
||||
});
|
||||
|
||||
test('should_throw_api_exception_when_favorites_business_code_failed', () async {
|
||||
final adapter = FakeHttpAdapter(
|
||||
(_) async => FakeHttpResponse(
|
||||
jsonEncode({'code': 401, 'message': '未登录或登录已过期', 'data': null}),
|
||||
),
|
||||
);
|
||||
final api = LibraryApi(
|
||||
ApiClient(
|
||||
baseUrl: 'http://127.0.0.1:8081',
|
||||
adapter: adapter,
|
||||
// 无刷新回调时,401 直接抛业务异常,避免重试逻辑。
|
||||
tokenRefresher: null,
|
||||
),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
() => api.favorites(),
|
||||
throwsA(
|
||||
isA<ApiException>().having((e) => e.code, 'code', 401),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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,101 @@
|
||||
// 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_parse_recent_when_server_returns_legacy_list', () async {
|
||||
final adapter = FakeHttpAdapter(
|
||||
(_) async => FakeHttpResponse(
|
||||
jsonEncode({
|
||||
'code': 0,
|
||||
'message': 'ok',
|
||||
'data': [
|
||||
{
|
||||
'dramaId': '5',
|
||||
'episodeId': '7',
|
||||
'positionMs': '203904',
|
||||
'durationMs': '227882',
|
||||
'finished': false,
|
||||
'playCount': 8,
|
||||
'updatedAt': '2026-10-21T15:17:05.419',
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
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');
|
||||
expect(page.list.single.episodeId, '7');
|
||||
expect(page.list.single.position.inMilliseconds, 203904);
|
||||
});
|
||||
|
||||
test('should_return_empty_recent_when_data_is_null', () async {
|
||||
final adapter = FakeHttpAdapter(
|
||||
(_) async => FakeHttpResponse(
|
||||
jsonEncode({'code': 0, 'message': 'ok', 'data': null}),
|
||||
),
|
||||
);
|
||||
final api = WatchApi(
|
||||
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
|
||||
);
|
||||
|
||||
final page = await api.recent();
|
||||
|
||||
expect(page.total, 0);
|
||||
expect(page.list, isEmpty);
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
@@ -15,11 +16,13 @@ import 'package:fruitshorts/core/data/services/watch_records_controller.dart';
|
||||
WatchProgress _progress(
|
||||
String dramaId, {
|
||||
String episodeId = 'episode-1',
|
||||
String? dramaTitle,
|
||||
int positionMs = 12000,
|
||||
int durationMs = 60000,
|
||||
}) => WatchProgress(
|
||||
dramaId: dramaId,
|
||||
episodeId: episodeId,
|
||||
dramaTitle: dramaTitle,
|
||||
position: Duration(milliseconds: positionMs),
|
||||
duration: Duration(milliseconds: durationMs),
|
||||
updatedAt: DateTime(2026, 10, 17, 12),
|
||||
@@ -73,6 +76,27 @@ void main() {
|
||||
expect(controller.error, isNull);
|
||||
});
|
||||
|
||||
test('should_use_backend_title_and_skip_detail_until_opened', () async {
|
||||
final history = ControlledWatchHistory([
|
||||
_progress('12', dramaTitle: '后端标题'),
|
||||
]);
|
||||
final catalog = ScriptedWatchCatalog();
|
||||
final controller = WatchRecordsController(
|
||||
history: history,
|
||||
catalog: catalog,
|
||||
);
|
||||
|
||||
await controller.load();
|
||||
|
||||
expect(controller.records.single.displayTitle, '后端标题');
|
||||
expect(catalog.calls, isEmpty);
|
||||
|
||||
final detail = await controller.detailForOpen('12');
|
||||
|
||||
expect(detail?.id, '12');
|
||||
expect(catalog.calls, ['12']);
|
||||
});
|
||||
|
||||
test('should_reuse_cached_details_when_records_are_loaded_again', () async {
|
||||
final history = ControlledWatchHistory([_progress('1'), _progress('2')]);
|
||||
final catalog = ScriptedWatchCatalog();
|
||||
@@ -93,6 +117,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 +286,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 +304,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 +370,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,53 @@ 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,
|
||||
),
|
||||
]);
|
||||
final catalog = _AlwaysFailingWatchCatalog();
|
||||
|
||||
await _pumpPage(tester, auth: auth, history: history, catalog: catalog);
|
||||
|
||||
expect(find.text('Backend Title'), findsOneWidget);
|
||||
expect(find.text('Episode 3'), findsOneWidget);
|
||||
expect(catalog.calls, isEmpty);
|
||||
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 +286,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 +356,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 +380,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 +416,27 @@ class _FailingWatchCatalog implements CatalogRepository {
|
||||
return _delegate.fetchDrama(dramaId);
|
||||
}
|
||||
}
|
||||
|
||||
class _AlwaysFailingWatchCatalog implements CatalogRepository {
|
||||
final List<String> calls = [];
|
||||
|
||||
@override
|
||||
Future<Drama> fetchDrama(String dramaId) async {
|
||||
calls.add(dramaId);
|
||||
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