98 Commits
Author SHA1 Message Date
Vicky 010ce326ea merge: 将 develop 同步到 release/v1.0.0 2026-10-21 18:58:12 +08:00
Zhou Weidong a42d3a26ac merge: 合并 K97.1 剧场搜索框圆角调整 2026-10-21 18:55:00 +08:00
Zhou Weidong c278503d0e fix(theater): 减小剧场搜索框圆角 2026-10-21 18:54:49 +08:00
Zhou Weidong 76f347cc35 merge: 合并 K97 iOS 评论键盘避让 2026-10-21 18:47:04 +08:00
Zhou Weidong 349ea12404 fix(comment): 修复 iOS 评论输入弹窗键盘避让 2026-10-21 18:46:43 +08:00
Zhou Weidong dfc33027d8 merge: 合并 K96 剧场分类分页 2026-10-21 18:24:28 +08:00
Zhou Weidong 13a14f3e85 feat(theater): 剧场分类列表服务端分页 2026-10-21 18:24:14 +08:00
Zhou Weidong f8769f2e1d Revert "merge: 合并 K95.2 首切频道封面过渡"
This reverts commit a2a491d056, reversing
changes made to 838ca2ce2a.
2026-10-21 18:07:25 +08:00
Zhou Weidong a2a491d056 merge: 合并 K95.2 首切频道封面过渡 2026-10-21 18:06:17 +08:00
Zhou Weidong c9adf8b399 fix(home): 频道切换用封面过渡 2026-10-21 18:06:09 +08:00
Zhou Weidong 838ca2ce2a merge: 合并 K95.1 首次切换频道无感 2026-10-21 17:54:10 +08:00
Zhou Weidong 677e49fa6e fix(home): 首切频道预取并保留播放画面 2026-10-21 17:53:58 +08:00
Zhou Weidong dfa79bed88 merge: 合并 K95 频道切换加载修复 2026-10-21 17:46:49 +08:00
Zhou Weidong 3c4d65a9b2 fix(home): 修复频道快速切换卡 loading 2026-10-21 17:46:42 +08:00
Zhou Weidong c8392b2f2a merge: 合并 K94 首页类型 Feed 2026-10-21 17:34:02 +08:00
Zhou Weidong ae61d9ec4c feat(home): 首页按内容类型分频道 Feed 并支持分页 2026-10-21 17:33:52 +08:00
Zhou Weidong 4d0be47ee3 merge: 合并 K90 点赞与评论按集 2026-10-21 16:15:44 +08:00
Zhou Weidong 83822a0cbb feat(player): 点赞与评论改为按集 2026-10-21 16:15:29 +08:00
Zhou Weidong e6f34977f5 merge: 合并 K87.1 下拉刷新冻结位移修复 2026-10-21 15:23:51 +08:00
Zhou Weidong 35860e056b fix(ui): 下拉刷新刷新期间冻结下拉位移 2026-10-21 15:23:46 +08:00
Zhou Weidong 01bef7f758 merge: 合并 K87 下拉刷新完成后回弹 2026-10-21 15:12:46 +08:00
Zhou Weidong 45e8707f1e feat(ui): 下拉刷新完成后才回弹 2026-10-21 15:12:41 +08:00
Zhou Weidong d39d8d43c1 merge: 合并 K86.22 评论输入面板安全区修复 2026-10-21 15:00:26 +08:00
Zhou Weidong 3fbfa9ab81 fix(player): 去除评论输入面板安全区导致的收键盘位移 2026-10-21 15:00:20 +08:00
Zhou Weidong 036bd29855 merge: 合并 K86.21 评论输入浮层稳定定位修复 2026-10-21 14:54:12 +08:00
Zhou Weidong d2f2459a96 fix(player): 固定评论输入浮层位置避免收键盘跳动 2026-10-21 14:54:05 +08:00
Zhou Weidong e90217bdfd merge: 合并 K86.20 评论输入浮层打开状态修正 2026-10-21 14:48:10 +08:00
Zhou Weidong ea1a9f424b fix(player): 返回键仅收起评论输入键盘 2026-10-21 14:48:01 +08:00
Zhou Weidong 18a1d06821 merge: 合并 K86.20 评论输入初始键盘与聚焦修正 2026-10-21 14:43:56 +08:00
Zhou Weidong a66cdb2e1b fix(player): 修正评论输入浮层初始键盘高度与聚焦 2026-10-21 14:43:43 +08:00
Zhou Weidong 6c72f8bf22 merge: 合并 K86.19 评论输入实色填充保障 2026-10-21 14:35:54 +08:00
Zhou Weidong c892d9b995 fix(player): 评论输入面板下方改为实色填充 2026-10-21 14:35:40 +08:00
Zhou Weidong 2ae4aedac0 merge: 合并 K86.18 输入浮层实底填充修正 2026-10-21 14:30:04 +08:00
Zhou Weidong db6fbd58ea fix(player): 评论输入浮层补齐键盘区域实底 2026-10-21 14:29:52 +08:00
Zhou Weidong 8dc9d55106 merge: 合并 K86.18 评论输入透明浮层 2026-10-21 14:24:36 +08:00
Zhou Weidong 4908dc9d76 fix(player): 评论输入改为透明浮层并消除收起键盘抖动 2026-10-21 14:24:24 +08:00
Zhou Weidong 5b4526cb97 merge: 合并 K86.17 输入弹窗位置固定修正 2026-10-21 14:15:11 +08:00
Zhou Weidong bac42bc653 fix(player): 键盘收起后保持评论输入弹窗位置 2026-10-21 14:14:58 +08:00
Zhou Weidong bc971c17d5 merge: 合并 K86.17 输入弹窗收起规则修复 2026-10-21 14:10:20 +08:00
Zhou Weidong cb58f649c1 fix(player): 收起键盘不关闭评论输入弹窗 2026-10-21 14:10:09 +08:00
Zhou Weidong f9f77090d9 merge: 合并 K86.16 评论提示与输入弹层修复 2026-10-21 13:59:16 +08:00
Zhou Weidong 5d459dd08a fix(player): 评论成功提示改黑色浮层并去除输入弹窗遮罩 2026-10-21 13:59:04 +08:00
Zhou Weidong c3e6e02e47 merge: 合并 K86.15 评论发布成功提示 2026-10-21 13:47:46 +08:00
Zhou Weidong e86daf559a feat(player): 评论发布成功提示 2026-10-21 13:47:36 +08:00
Zhou Weidong 9c0c1b8982 merge: 合并 K86.14 评论输入紧凑弹窗修正 2026-10-21 13:37:04 +08:00
Zhou Weidong e0024ed123 fix(player): 评论输入弹窗改为紧凑高度 2026-10-21 13:36:52 +08:00
Zhou Weidong c8ac2c9823 merge: 合并 K86.14 评论输入独立弹窗 2026-10-21 13:31:54 +08:00
Zhou Weidong 282f51c24b fix(player): 评论输入改为独立弹窗 2026-10-21 13:31:41 +08:00
Zhou Weidong aa14d0a835 merge: 合并 K86.13 评论面板半屏高度 2026-10-21 13:20:33 +08:00
Zhou Weidong 50edbf40c4 fix(player): 评论面板固定半屏高度 2026-10-21 13:20:28 +08:00
Zhou Weidong fc877f25bf merge: 合并 K86.12 评论文字居中修复 2026-10-21 13:16:39 +08:00
Zhou Weidong f87b0d2689 fix(player): 评论入口文字垂直居中 2026-10-21 13:16:34 +08:00
Zhou Weidong 146cd2a29e merge: 合并 K86.11 评论假输入框入口 2026-10-21 13:12:39 +08:00
Zhou Weidong 425ea4c0b7 fix(player): 评论入口改为假输入框点击唤起 2026-10-21 13:12:30 +08:00
Zhou Weidong a142a6c318 merge: 合并 K86.10 评论面板高度与收起流程 2026-10-21 13:04:40 +08:00
Zhou Weidong d081cff3e4 fix(player): 评论面板保持高度并两段式收起 2026-10-21 13:04:29 +08:00
Zhou Weidong 9876772d06 merge: 合并 K86.9 评论面板键盘态布局修复 2026-10-21 13:00:20 +08:00
Zhou Weidong bc4c84a4ae fix(player): 修正评论面板键盘态布局重叠 2026-10-21 13:00:05 +08:00
Zhou Weidong a7c0576a03 merge: 合并 K86.8 评论列表与焦点保持 2026-10-21 12:53:56 +08:00
Zhou Weidong ad69ef7f54 fix(player): 聚焦时保留评论列表并稳定焦点 2026-10-21 12:53:46 +08:00
Zhou Weidong de99c09454 merge: 合并 K86.7 键盘弹出不压缩视频页 2026-10-21 12:48:01 +08:00
Zhou Weidong 1776c622ae fix(player): 键盘弹出不压缩视频页 2026-10-21 12:47:52 +08:00
Zhou Weidong 33b49c161a merge: 合并 K86.6 评论输入聚焦修复 2026-10-21 12:40:25 +08:00
Zhou Weidong c7b230263a fix(player): 修复评论输入聚焦时面板消失 2026-10-21 12:40:12 +08:00
Zhou Weidong 226ac5920c merge: 合并 K86.5 隐藏评论操作入口 2026-10-21 12:26:26 +08:00
Zhou Weidong 9bd46521aa fix(player): 评论暂时隐藏举报与删除入口 2026-10-21 12:26:14 +08:00
Zhou Weidong 838beaaa3e merge: 合并 K86.4 评论展开输入面板 2026-10-21 12:21:28 +08:00
Zhou Weidong 519cab20fe fix(player): 评论聚焦态改为展开输入面板 2026-10-21 12:21:17 +08:00
Zhou Weidong 8b51a7c18e merge: 合并 K86.3 Android 键盘窗口修正 2026-10-21 12:14:19 +08:00
Zhou Weidong dbeb9883a5 fix(android): 键盘弹出不再压缩整页 2026-10-21 12:14:09 +08:00
Zhou Weidong 5160ab437d merge: 合并 K86.2 评论面板间距与键盘修正 2026-10-21 12:07:39 +08:00
Zhou Weidong ab476cab7d fix(player): 评论面板自适应高度并只顶起输入框 2026-10-21 12:07:30 +08:00
Zhou Weidong 97735763a1 merge: 合并 K86.1 评论面板补修 2026-10-21 12:00:37 +08:00
Zhou Weidong 2659c29501 fix(player): 评论总数含回复并改用相对时间 2026-10-21 12:00:27 +08:00
Zhou Weidong 22cb74ded4 merge: 合并 K86 评论面板重做 2026-10-21 11:53:07 +08:00
Zhou Weidong 7862e0be09 fix(player): 评论面板按参考图重做 2026-10-21 11:52:59 +08:00
Zhou Weidong bdd3a53632 merge: 合并 K85 收藏与评论数实时刷新 2026-10-21 11:40:08 +08:00
Zhou Weidong 5e2b8d9e4d fix(player): 收藏与评论数实时刷新 2026-10-21 11:39:58 +08:00
Zhou Weidong 97bbced02c merge: 合并 K83-A 互动栏图标资源 2026-10-21 11:02:20 +08:00
Zhou Weidong cbb9f60f7f feat(player): 互动栏改用参考图同款图标资源 2026-10-21 11:02:05 +08:00
Zhou Weidong 35de4095b0 merge: 合并 K82-A 右侧互动栏图标升级 2026-10-21 10:52:31 +08:00
Zhou Weidong 4b1aea29c7 feat(player): 右侧互动栏图标按参考图替换并放大 2026-10-21 10:52:14 +08:00
Zhou Weidong e6a31a60df merge: 合并 K81-A 更多面板文字减重 2026-10-21 10:43:51 +08:00
Zhou Weidong 622dc2f9e8 fix(player): 更多面板文字减重并缩小标签 2026-10-21 10:43:36 +08:00
Zhou Weidong 62f87040d6 merge: 合并 K80-A 更多面板重做 2026-10-21 10:34:32 +08:00
Zhou Weidong 7fccd16750 feat(player): 更多面板按参考图重做 2026-10-21 10:34:18 +08:00
Zhou Weidong c06a67c4b0 merge: 合并 K79-A 进度拖动防闪 2026-10-21 10:24:47 +08:00
Zhou Weidong 1993ff1102 fix(player): 修复拖动进度条时加载态闪烁 2026-10-21 10:24:34 +08:00
Zhou Weidong 72708630ba merge: 合并 K78-A 底部导航放大 2026-10-21 10:16:28 +08:00
Zhou Weidong d6b2d308eb feat(nav): 底部导航图标与文字放大 2026-10-21 10:16:16 +08:00
Zhou Weidong 0ba6ea9c52 merge: 合并 K77-A 播放加载进度态 2026-10-21 10:07:47 +08:00
Zhou Weidong cf4b07aa69 feat(player): 未就绪改由进度条展示加载态 2026-10-21 10:07:34 +08:00
Zhou Weidong 76cc52ad66 merge: 合并 K76-A 开发默认地址 2026-10-21 09:58:09 +08:00
Zhou Weidong 99b45e4f12 chore(config): 开发默认地址改回 192.168.31.187 2026-10-21 09:57:58 +08:00
Zhou Weidong c0697c01e3 merge: 合并 K75-A 首页淡入转场 2026-10-21 09:23:48 +08:00
Zhou Weidong 8c04099d97 feat(player): 首页进详情与播放页改为淡入转场 2026-10-21 09:23:30 +08:00
Zhou Weidong 3a636ca894 merge: 合并 K74-A 开发本机地址 2026-10-21 09:17:06 +08:00
Zhou Weidong d13b601ae8 chore(config): 开发默认地址改回本机 10.39.237.1 2026-10-21 09:16:37 +08:00
94 changed files with 6151 additions and 1236 deletions

No files matched your search

+1 -1
View File
@@ -17,7 +17,7 @@
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
android:windowSoftInputMode="adjustNothing">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
@@ -8,6 +8,8 @@ import android.content.res.Configuration
import android.os.Build
import android.util.Rational
import android.content.pm.PackageManager
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
@@ -16,6 +18,8 @@ class MainActivity : FlutterActivity() {
private val shareRequestCode = 9041
private var pendingShare: MethodChannel.Result? = null
private var pipChannel: MethodChannel? = null
private var keyboardChannel: MethodChannel? = null
private var imeInset = 0
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
@@ -62,6 +66,37 @@ class MainActivity : FlutterActivity() {
result.error("share_failed", error.message, null)
}
}
keyboardChannel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
"com.stormg.lumadrama/keyboard"
)
keyboardChannel?.setMethodCallHandler { call, result ->
if (call.method != "get") {
result.notImplemented()
return@setMethodCallHandler
}
val inset = currentImeInset()
emitImeInset(inset)
result.success(inset)
}
val decorView = window.decorView
ViewCompat.setOnApplyWindowInsetsListener(decorView) { view, insets ->
emitImeInset(insets.getInsets(WindowInsetsCompat.Type.ime()).bottom)
ViewCompat.onApplyWindowInsets(view, insets)
}
ViewCompat.requestApplyInsets(decorView)
}
private fun currentImeInset(): Int =
ViewCompat.getRootWindowInsets(window.decorView)
?.getInsets(WindowInsetsCompat.Type.ime())
?.bottom
?: 0
private fun emitImeInset(value: Int) {
if (value == imeInset) return
imeInset = value
keyboardChannel?.invokeMethod("insetsChanged", value)
}
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
@@ -77,4 +112,11 @@ class MainActivity : FlutterActivity() {
pendingShare = null
}
}
override fun onDestroy() {
ViewCompat.setOnApplyWindowInsetsListener(window.decorView, null)
keyboardChannel?.setMethodCallHandler(null)
keyboardChannel = null
super.onDestroy()
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 928 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1004 B

+94
View File
@@ -0,0 +1,94 @@
# 开发进度
## 2026-10-21 K96 剧场页服务端分类分页
- 已完整读取 K96 `docs/plans/INBOX.md`。
- 已从最新 `origin/develop` 创建 ReelShort `feat/k96-theater-feed`。
- 计划见 FruitServer `docs/plans/2026-10-21-K96-theater-feed.md`。
- 客户端将新增 TheaterFeedController,并让剧场「全部 / 分类」改用
`/app/feed` 服务端游标分页;不改首页 K94/K95 已交付逻辑。
- 已完成 `CatalogApi.feed` / `CatalogRepository.fetchFeed` 可选 `categoryId`,
非空才拼请求参数。
- 已新增 `TheaterFeedController`:按分类独立缓存,缓存先渲染后静默刷新,
10 秒超时、游标追加去重、失败保留旧数据、失败分类切回自动重试。
- 剧场页「全部」和分类 chips 已改用 theater feed;滚动剩余不足 400px 触发
`loadMore`,下拉刷新同时执行 categories 与 theater 刷新,搜索模式保持独立。
- 已补分类参数、缓存切换、分页去重、hasMore 停止、失败保留、失败重试、
分类点击请求和滚动加载 widget 测试。
门禁原始输出:
```text
$ dart analyze lib test
Analyzing lib, test...
No issues found!
$ flutter test --no-pub
00:52 +553: All tests passed!
$ flutter build apk --debug
Running Gradle task 'assembleDebug'... 24.8s
✓ Built build/app/outputs/flutter-apk/app-debug.apk
```
解读:静态分析零告警,553 项全量测试通过,debug APK 构建成功;
`pubspec.lock` 已还原,无依赖变更,未安装或操作真机。
## 2026-10-21 K97 修复 iOS 评论输入弹窗键盘避让
- 已完整读取 K97 `docs/plans/INBOX.md`。
- 已从最新 `origin/develop` 创建 `fix/k97-ios-comment-keyboard`。
- 计划见 FruitServer `docs/plans/2026-10-21-K97-ios-comment-keyboard.md`。
- Android 保留既有 pinned/channel 策略;iOS 与其他平台改用实时
`MediaQuery.viewInsets.bottom`,并补 iOS 键盘避让测试。
- 已完成 `_isAndroid` 平台分流;Android 的 channel、80ms 防抖和 pinned 逻辑保持不变,
iOS/其他平台直接读取实时 viewInsets,超过屏幕 60% 的异常值按 0 处理。
- 面板已使用 160ms `easeOutCubic` AnimatedPadding;Android 不包裹 SafeArea,
iOS/其他平台使用 `SafeArea(top: false)` 处理 home indicator。
- 已补 iOS insets=300/0 测试,发表按钮在键盘上方;Android pinned 既有用例全部通过。
门禁原始输出:
```text
$ dart analyze lib test
Analyzing lib, test...
No issues found!
$ flutter test --no-pub
00:54 +555: All tests passed!
$ flutter build apk --debug
Running Gradle task 'assembleDebug'... 26.2s
✓ Built build/app/outputs/flutter-apk/app-debug.apk
$ flutter build ios --simulator --no-codesign
Xcode build done. 44.0s
✓ Built build/ios/iphonesimulator/Runner.app
```
解读:静态分析零告警,555 项全量测试通过,Android 与 iOS simulator 构建成功;
`pubspec.lock` 已还原,无依赖变更,未安装或操作真机。
## 2026-10-21 K97.1 剧场搜索框圆角改小
- 已从最新 `origin/develop` 创建 `fix/k97.1-theater-search-radius`。
- 仅将剧场搜索框圆角从 22 调整为 12,高度 44、颜色、聚焦态及
清空/识剧逻辑保持不变,并同步更新对应 widget 断言。
门禁原始输出:
```text
$ dart analyze lib test
Analyzing lib, test...
No issues found!
$ flutter test --no-pub
00:55 +555: All tests passed!
$ flutter build apk --debug
Running Gradle task 'assembleDebug'... 25.8s
✓ Built build/app/outputs/flutter-apk/app-debug.apk
```
解读:静态分析零告警,555 项全量测试通过,debug APK 构建成功;
`pubspec.lock` 已还原,无依赖变更,未安装或操作真机。
+3 -3
View File
@@ -11,10 +11,10 @@ import 'package:flutter/foundation.dart';
class AppConfig {
const AppConfig._();
/// 开发调试默认服务地址,当前直接指向公网。
/// 开发调试默认服务地址,当前指向本机局域网 IP。
///
/// 需要切换局域网联调时只改这一行,地址不要带结尾斜杠。
static const String devHostBaseUrl = 'http://39.162.50.99:8081';
/// 换网络时只改这一行,地址不要带结尾斜杠。
static const String devHostBaseUrl = 'http://192.168.31.187:8081';
/// 本机后端地址:通过 `adb reverse tcp:8081 tcp:8081` 映射到开发机,
/// 探测可达时 App 直连本机服务;不可达时回落到公网地址。
+18 -9
View File
@@ -15,6 +15,8 @@ class Episode {
this.videoUrl = '',
this.durationSec = 0,
this.locked = false,
this.likeCount,
this.commentCount,
});
final String id;
@@ -28,16 +30,21 @@ class Episode {
/// 后端判定需要解锁;解锁能力(金币/VIP)在 M3 接入。
final bool locked;
final int? likeCount;
final int? commentCount;
Episode withVideoUrl(String url) => Episode(
id: id,
number: number,
title: title,
isFree: isFree,
videoUrl: url,
durationSec: durationSec,
locked: locked,
);
Episode withVideoUrl(String url, {int? likeCount, int? commentCount}) =>
Episode(
id: id,
number: number,
title: title,
isFree: isFree,
videoUrl: url,
durationSec: durationSec,
locked: locked,
likeCount: likeCount ?? this.likeCount,
commentCount: commentCount ?? this.commentCount,
);
factory Episode.fromJson(Map<String, dynamic> json) {
return Episode(
@@ -48,6 +55,8 @@ class Episode {
durationSec: (json['durationSec'] as num?)?.toInt() ?? 0,
locked: json['locked'] == true,
videoUrl: json['playUrl'] as String? ?? '',
likeCount: _optionalInt(json['likeCount']),
commentCount: _optionalInt(json['commentCount']),
);
}
}
+31
View File
@@ -0,0 +1,31 @@
// Author: fruit.com.cn
import 'drama.dart';
/// `/app/feed` 的游标分页数据。
class FeedPage {
const FeedPage({
this.dramas = const [],
this.nextCursor,
this.hasMore = false,
});
final List<Drama> dramas;
final String? nextCursor;
final bool hasMore;
factory FeedPage.fromJson(Map<String, dynamic> json) {
final rawCursor = json['nextCursor'];
final cursor = rawCursor?.toString();
return FeedPage(
dramas: (json['list'] as List? ?? const [])
.whereType<Map<String, dynamic>>()
.map(Drama.fromCardJson)
.toList(),
nextCursor: cursor == null || cursor.isEmpty || cursor == 'null'
? null
: cursor,
hasMore: json['hasMore'] == true,
);
}
}
+2 -1
View File
@@ -2,9 +2,10 @@
import 'json_numbers.dart';
/// 互动目标类型,契约只允许短剧与评论。
/// 互动目标类型。
enum InteractionTargetType {
drama('DRAMA'),
episode('EPISODE'),
comment('COMMENT');
const InteractionTargetType(this.apiValue);
+10
View File
@@ -14,6 +14,8 @@ class PlayTicket {
this.unlockCoin = 0,
this.previewSeconds = 0,
this.qualities = const [],
this.likeCount,
this.commentCount,
});
static const String free = 'FREE';
@@ -38,6 +40,8 @@ class PlayTicket {
/// 后端下发的清晰度档位(如 AUTO / 720P / 1080P),HLS 由播放器自动切换。
final List<String> qualities;
final int? likeCount;
final int? commentCount;
bool get needsUnlock =>
accessType == coinRequired || accessType == vipRequired;
@@ -58,6 +62,12 @@ class PlayTicket {
.whereType<String>()
.where((quality) => quality.isNotEmpty)
.toList(),
likeCount: json['likeCount'] == null
? null
: jsonToInt(json['likeCount']),
commentCount: json['commentCount'] == null
? null
: jsonToInt(json['commentCount']),
);
}
}
+24
View File
@@ -1,4 +1,5 @@
import '../models/drama.dart';
import '../models/feed_page.dart';
import '../models/home_feed.dart';
import '../models/search_result.dart';
import 'api_client.dart';
@@ -17,6 +18,29 @@ class CatalogApi {
return HomeFeed.fromJson(_asMap(await _client.get('/app/discover/feed')));
}
/// 推荐流;types/categoryId 为空表示不过滤,cursor 为上一页末条。
Future<FeedPage> feed({
List<String> types = const [],
String? categoryId,
String? cursor,
int size = 10,
}) async {
return FeedPage.fromJson(
_asMap(
await _client.get(
'/app/feed',
query: {
if (types.isNotEmpty) 'types': types.join(','),
if (categoryId != null && categoryId.isNotEmpty)
'categoryId': categoryId,
if (cursor != null && cursor.isNotEmpty) 'cursor': cursor,
'size': '$size',
},
),
),
);
}
/// 搜索已上线短剧:按标题/简介模糊匹配,分页返回卡片列表。
Future<SearchResult> search(
String keyword, {
+12
View File
@@ -1,6 +1,7 @@
// Author: fruit.com.cn
import '../models/comment.dart';
import '../models/json_numbers.dart';
import 'api_client.dart';
/// 评论接口,对应契约 §1.7 的 `/app/comments*`。
@@ -12,6 +13,7 @@ class CommentApi {
/// 拉取游标分页;`rootId` 用于加载某一楼层的回复。
Future<CommentPage> page({
required String dramaId,
String? episodeId,
String? rootId,
String? cursor,
int size = commentPageSize,
@@ -20,6 +22,7 @@ class CommentApi {
'/app/comments',
query: {
'dramaId': dramaId,
'episodeId': ?episodeId,
'rootId': ?rootId,
'cursor': ?cursor,
'size': '$size',
@@ -29,6 +32,15 @@ class CommentApi {
return CommentPage.fromJson(data);
}
/// 查询单部剧的有效评论总数;后端 Long 兼容数字与字符串。
Future<int> count(String dramaId, {String? episodeId}) async {
final data = await _client.get(
'/app/comments/count',
query: {'dramaId': dramaId, 'episodeId': ?episodeId},
);
return jsonToInt(data);
}
/// 发表评论或回复;回复只提交 `parentId`,不自行拼层级。
Future<DramaComment> publish({
required String dramaId,
@@ -1,6 +1,7 @@
// Author: fruit.com.cn
import '../models/drama.dart';
import '../models/feed_page.dart';
import '../models/home_feed.dart';
import '../remote/catalog_api.dart';
@@ -13,6 +14,13 @@ abstract interface class CatalogRepository {
Future<HomeFeed> fetchDiscoverFeed();
Future<FeedPage> fetchFeed({
List<String> types = const [],
String? categoryId,
String? cursor,
int size = 10,
});
Future<Drama> fetchDrama(String dramaId);
}
@@ -28,6 +36,18 @@ class HttpCatalogRepository implements CatalogRepository {
Future<HomeFeed> fetchDiscoverFeed() => _api.discoverFeed();
@override
Future<Drama> fetchDrama(String dramaId) => _api.dramaDetail(dramaId);
Future<FeedPage> fetchFeed({
List<String> types = const [],
String? categoryId,
String? cursor,
int size = 10,
}) => _api.feed(
types: types,
categoryId: categoryId,
cursor: cursor,
size: size,
);
@override
Future<Drama> fetchDrama(String dramaId) => _api.dramaDetail(dramaId);
}
@@ -8,11 +8,15 @@ abstract interface class CommentRepository {
/// 拉取评论或回复的分页。
Future<CommentPage> page({
required String dramaId,
String? episodeId,
String? rootId,
String? cursor,
int size = commentPageSize,
});
/// 查询单部剧的有效评论总数。
Future<int> count(String dramaId, {String? episodeId});
/// 发表评论或回复。
Future<DramaComment> publish({
required String dramaId,
@@ -34,16 +38,22 @@ class HttpCommentRepository implements CommentRepository {
@override
Future<CommentPage> page({
required String dramaId,
String? episodeId,
String? rootId,
String? cursor,
int size = commentPageSize,
}) => _api.page(
dramaId: dramaId,
episodeId: episodeId,
rootId: rootId,
cursor: cursor,
size: size,
);
@override
Future<int> count(String dramaId, {String? episodeId}) =>
_api.count(dramaId, episodeId: episodeId);
@override
Future<DramaComment> publish({
required String dramaId,
@@ -75,6 +75,7 @@ class CommentController extends ChangeNotifier {
try {
final page = await _commentRepository.page(
dramaId: dramaId,
episodeId: episodeId,
size: commentPageSize,
);
_comments
@@ -101,6 +102,7 @@ class CommentController extends ChangeNotifier {
try {
final page = await _commentRepository.page(
dramaId: dramaId,
episodeId: episodeId,
cursor: cursor,
size: commentPageSize,
);
@@ -128,6 +130,7 @@ class CommentController extends ChangeNotifier {
try {
final page = await _commentRepository.page(
dramaId: dramaId,
episodeId: episodeId,
rootId: rootId,
size: commentPageSize,
);
@@ -157,6 +160,7 @@ class CommentController extends ChangeNotifier {
try {
final page = await _commentRepository.page(
dramaId: dramaId,
episodeId: episodeId,
rootId: rootId,
cursor: cursor,
size: commentPageSize,
@@ -0,0 +1,222 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../../data/models/drama.dart';
import '../../data/repositories/catalog_repository.dart';
import '../../../pages/home/home_feed_channel.dart';
/// 首页内容类型频道状态:每频道独立缓存、刷新和游标分页。
class HomeFeedController extends ChangeNotifier {
HomeFeedController(
this._repository, {
this.requestTimeout = defaultRequestTimeout,
}) : _states = {
for (final channel in HomeFeedChannel.values)
channel: _ChannelFeedState(),
};
static const int pageSize = 10;
static const Duration defaultRequestTimeout = Duration(seconds: 10);
final CatalogRepository _repository;
final Duration requestTimeout;
final Map<HomeFeedChannel, _ChannelFeedState> _states;
HomeFeedChannel _channel = HomeFeedChannel.recommend;
bool _disposed = false;
HomeFeedChannel get channel => _channel;
String? get lastError => _state(_channel).lastError;
List<Drama> get dramas => _state(_channel).dramas;
String? get cursor => _state(_channel).cursor;
bool get hasMore => _state(_channel).hasMore;
bool get loaded => _state(_channel).loaded;
bool get loadingMore => _state(_channel).loadingMore;
bool get loadingFirstPage => !loaded && _state(_channel).loading;
/// 用首页聚合接口打平的数据填充推荐首屏,不发起网络请求。
void seedRecommend(List<Drama> dramas) {
final state = _state(HomeFeedChannel.recommend);
state
..requestSeq = state.requestSeq + 1
..dramas = List.unmodifiable(dramas)
..cursor = null
..hasMore = false
..loaded = true
..loading = false
..loadingMore = false
..lastError = null
..inFlight = null;
_notifyIfCurrent(HomeFeedChannel.recommend);
}
/// 后台预取漫剧与真人剧首屏,不切换频道,也不影响当前列表。
Future<void> prefetchChannels() async {
await Future.wait([
_prefetchChannel(HomeFeedChannel.anime),
_prefetchChannel(HomeFeedChannel.live),
]);
}
/// 同步切到目标频道;有缓存先渲染,无缓存时复用或等待在飞请求。
Future<void> selectChannel(HomeFeedChannel next) async {
if (next == _channel) return;
_channel = next;
_notify();
final state = _state(next);
if (state.dramas.isNotEmpty) {
if (state.skipNextRefresh) {
state.skipNextRefresh = false;
} else if (!state.loading) {
unawaited(_refresh(next, keepData: true));
}
return;
}
if (state.loading) {
final inFlight = state.inFlight;
if (inFlight != null) await inFlight;
state.skipNextRefresh = false;
return;
}
await _refresh(next, keepData: false);
}
/// 刷新当前频道,失败时保留旧数据。
Future<bool> refreshCurrent() async {
final state = _state(_channel);
if (state.loading) {
return await state.inFlight ?? false;
}
return _refresh(_channel, keepData: true);
}
/// 加载当前频道下一页并去重追加。
Future<void> loadMore() async {
final channel = _channel;
final state = _state(channel);
if (!state.hasMore || state.loadingMore || state.cursor == null) return;
final seq = ++state.requestSeq;
state.loadingMore = true;
_notifyIfCurrent(channel);
try {
final page = await _repository
.fetchFeed(
types: channel.apiTypes,
cursor: state.cursor,
size: pageSize,
)
.timeout(requestTimeout);
if (!_acceptSequence(channel, seq)) return;
final seen = state.dramas.map((drama) => drama.id).toSet();
final appended = page.dramas
.where((drama) => seen.add(drama.id))
.toList();
state
..dramas = List.unmodifiable([...state.dramas, ...appended])
..cursor = page.nextCursor
..hasMore = page.hasMore
..loaded = true
..lastError = null;
} catch (error) {
if (_acceptSequence(channel, seq)) {
state.lastError = error.toString();
}
} finally {
state.loadingMore = false;
_notifyIfCurrent(channel);
}
}
Future<bool> _refresh(HomeFeedChannel channel, {required bool keepData}) {
final state = _state(channel);
final operation = _performRefresh(channel, keepData: keepData);
state.inFlight = operation;
unawaited(_clearInFlight(state, operation));
return operation;
}
Future<void> _prefetchChannel(HomeFeedChannel channel) async {
final state = _state(channel);
if (state.loaded || state.loading) return;
final success = await _refresh(channel, keepData: false);
if (success && !_disposed) state.skipNextRefresh = true;
}
Future<bool> _performRefresh(
HomeFeedChannel channel, {
required bool keepData,
}) async {
final state = _state(channel);
final seq = ++state.requestSeq;
state
..loading = true
..lastError = null;
_notifyIfCurrent(channel);
try {
final page = await _repository
.fetchFeed(types: channel.apiTypes, size: pageSize)
.timeout(requestTimeout);
if (!_acceptSequence(channel, seq)) return false;
state
..dramas = List.unmodifiable(page.dramas)
..cursor = page.nextCursor
..hasMore = page.hasMore
..loaded = true
..loading = false
..lastError = null;
_notifyIfCurrent(channel);
return true;
} catch (error) {
if (!_acceptSequence(channel, seq)) return false;
if (!keepData) state.dramas = const [];
state
..loaded = true
..loading = false
..lastError = error.toString();
_notifyIfCurrent(channel);
return false;
}
}
Future<void> _clearInFlight(
_ChannelFeedState state,
Future<bool> operation,
) async {
await operation;
if (identical(state.inFlight, operation)) state.inFlight = null;
}
_ChannelFeedState _state(HomeFeedChannel channel) => _states[channel]!;
bool _acceptSequence(HomeFeedChannel channel, int seq) =>
!_disposed && _state(channel).requestSeq == seq;
void _notifyIfCurrent(HomeFeedChannel channel) {
if (!_disposed && channel == _channel) _notify();
}
void _notify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
class _ChannelFeedState {
List<Drama> dramas = const [];
String? cursor;
bool hasMore = true;
bool loaded = false;
bool loading = false;
bool loadingMore = false;
String? lastError;
int requestSeq = 0;
Future<bool>? inFlight;
bool skipNextRefresh = false;
}
@@ -17,6 +17,9 @@ class InteractionController extends ChangeNotifier {
final Set<String> _likedDramaIds = {};
final Set<String> _busyDramaIds = {};
final Map<String, int> _likeCounts = {};
final Set<String> _likedEpisodeIds = {};
final Set<String> _busyEpisodeIds = {};
final Map<String, int> _episodeLikeCounts = {};
bool _disposed = false;
/// 当前会话内该剧是否已点赞。
@@ -28,42 +31,75 @@ class InteractionController extends ChangeNotifier {
/// 服务端最新点赞数,仅在点赞接口成功返回后可用。
int? likeCount(String dramaId) => _likeCounts[dramaId];
/// 当前会话内该集是否已点赞。
bool isEpisodeLiked(String episodeId) => _likedEpisodeIds.contains(episodeId);
/// 该集点赞请求是否进行中。
bool isEpisodeBusy(String episodeId) => _busyEpisodeIds.contains(episodeId);
/// 该集服务端最新点赞数。
int? episodeLikeCount(String episodeId) => _episodeLikeCounts[episodeId];
/// 乐观切换剧集点赞,失败时回滚并以服务端返回状态校准。
Future<OperationResult<LikeToggleResult>> toggleDramaLike(
String dramaId,
) async {
if (_busyDramaIds.contains(dramaId)) {
Future<OperationResult<LikeToggleResult>> toggleDramaLike(String dramaId) =>
_toggleLike(
id: dramaId,
targetType: InteractionTargetType.drama,
likedIds: _likedDramaIds,
busyIds: _busyDramaIds,
counts: _likeCounts,
);
/// 乐观切换分集点赞,失败时回滚并以服务端返回状态校准。
Future<OperationResult<LikeToggleResult>> toggleEpisodeLike(
String episodeId,
) => _toggleLike(
id: episodeId,
targetType: InteractionTargetType.episode,
likedIds: _likedEpisodeIds,
busyIds: _busyEpisodeIds,
counts: _episodeLikeCounts,
);
Future<OperationResult<LikeToggleResult>> _toggleLike({
required String id,
required InteractionTargetType targetType,
required Set<String> likedIds,
required Set<String> busyIds,
required Map<String, int> counts,
}) async {
if (busyIds.contains(id)) {
return const OperationResult.failure(null);
}
final wasLiked = _likedDramaIds.contains(dramaId);
_busyDramaIds.add(dramaId);
final wasLiked = likedIds.contains(id);
busyIds.add(id);
if (wasLiked) {
_likedDramaIds.remove(dramaId);
likedIds.remove(id);
} else {
_likedDramaIds.add(dramaId);
likedIds.add(id);
}
_notify();
try {
final result = await _repository.toggleLike(
targetType: InteractionTargetType.drama,
targetId: dramaId,
targetType: targetType,
targetId: id,
);
if (result.liked) {
_likedDramaIds.add(dramaId);
likedIds.add(id);
} else {
_likedDramaIds.remove(dramaId);
likedIds.remove(id);
}
_likeCounts[dramaId] = result.count;
counts[id] = result.count;
return OperationResult.success(result);
} catch (failure) {
if (wasLiked) {
_likedDramaIds.add(dramaId);
likedIds.add(id);
} else {
_likedDramaIds.remove(dramaId);
likedIds.remove(id);
}
return OperationResult.failure(_errorMessage(failure));
} finally {
_busyDramaIds.remove(dramaId);
busyIds.remove(id);
_notify();
}
}
@@ -93,12 +129,18 @@ class InteractionController extends ChangeNotifier {
void clear() {
if (_likedDramaIds.isEmpty &&
_busyDramaIds.isEmpty &&
_likeCounts.isEmpty) {
_likeCounts.isEmpty &&
_likedEpisodeIds.isEmpty &&
_busyEpisodeIds.isEmpty &&
_episodeLikeCounts.isEmpty) {
return;
}
_likedDramaIds.clear();
_busyDramaIds.clear();
_likeCounts.clear();
_likedEpisodeIds.clear();
_busyEpisodeIds.clear();
_episodeLikeCounts.clear();
_notify();
}
+22 -1
View File
@@ -105,6 +105,27 @@ class PlaybackSession extends ChangeNotifier {
return null;
}
/// 队列重排后把当前仍在播放的 slot 映射到新索引,不重新创建播放器。
void remapCurrentIndex(int index, {int? preloadIndex}) {
if (_closed || index < 0 || index >= episodes.length) return;
final activeSlot = _active;
if (activeSlot == null || activeSlot.index != _currentIndex) {
_currentIndex = index;
_nextIndex = preloadIndex;
_notify();
return;
}
_generation++;
activeSlot.index = index;
_currentIndex = index;
_nextIndex = preloadIndex;
final staleNext = _next;
_next = null;
_notify();
unawaited(_enqueue(() async => _retire(staleNext)));
if (_visible && !_overlayOpen) _schedulePreload();
}
Future<void> _enqueue(Future<void> Function() operation) {
final task = _operations.then((_) => operation());
// A plugin failure must not poison subsequent transitions or cleanup.
@@ -363,7 +384,7 @@ class PlaybackSession extends ChangeNotifier {
class _PlayerSlot {
_PlayerSlot(this.index, this.player);
final int index;
int index;
final PlayerPort player;
late final Future<void> ready;
bool prepared = false;
@@ -0,0 +1,187 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../../data/models/drama.dart';
import '../../data/repositories/catalog_repository.dart';
/// 剧场页服务端分类列表:按 categoryId 独立缓存、刷新和游标分页。
class TheaterFeedController extends ChangeNotifier {
TheaterFeedController(
this._repository, {
this.requestTimeout = defaultRequestTimeout,
});
static const int pageSize = 20;
static const Duration defaultRequestTimeout = Duration(seconds: 10);
final CatalogRepository _repository;
final Duration requestTimeout;
final Map<String?, _CategoryFeedState> _states = {};
String? _categoryId;
bool _initialized = false;
bool _disposed = false;
String? get categoryId => _categoryId;
String? get lastError => _state(_categoryId).lastError;
List<Drama> get dramas => _state(_categoryId).dramas;
String? get cursor => _state(_categoryId).cursor;
bool get hasMore => _state(_categoryId).hasMore;
bool get loaded => _state(_categoryId).loaded;
bool get loadingMore => _state(_categoryId).loadingMore;
bool get loadingFirstPage => !loaded && _state(_categoryId).loading;
/// 同步切换分类;有缓存先渲染,无缓存时等待或发起首屏请求。
Future<void> select(String? next) async {
if (_initialized && next == _categoryId) return;
_categoryId = next;
_initialized = true;
_notify();
final state = _state(next);
if (state.dramas.isNotEmpty) {
if (!state.loading) {
unawaited(_refresh(next, keepData: true));
}
return;
}
if (state.loading) {
final inFlight = state.inFlight;
if (inFlight != null) await inFlight;
return;
}
await _refresh(next, keepData: false);
}
/// 刷新当前分类;失败时保留已有列表。
Future<bool> refreshCurrent() async {
final state = _state(_categoryId);
if (state.loading) {
return await state.inFlight ?? false;
}
return _refresh(_categoryId, keepData: true);
}
/// 加载当前分类下一页并去重追加。
Future<void> loadMore() async {
final categoryId = _categoryId;
final state = _state(categoryId);
if (!state.hasMore || state.loadingMore || state.cursor == null) return;
final sequence = ++state.requestSeq;
state.loadingMore = true;
_notifyIfCurrent(categoryId);
try {
final page = await _repository
.fetchFeed(
categoryId: categoryId,
cursor: state.cursor,
size: pageSize,
)
.timeout(requestTimeout);
if (!_acceptSequence(categoryId, sequence)) return;
final seen = state.dramas.map((drama) => drama.id).toSet();
final appended = page.dramas
.where((drama) => seen.add(drama.id))
.toList();
state
..dramas = List.unmodifiable([...state.dramas, ...appended])
..cursor = page.nextCursor
..hasMore = page.hasMore
..loaded = true
..lastError = null;
} catch (error) {
if (_acceptSequence(categoryId, sequence)) {
state.lastError = error.toString();
}
} finally {
state.loadingMore = false;
_notifyIfCurrent(categoryId);
}
}
Future<bool> _refresh(String? categoryId, {required bool keepData}) {
final state = _state(categoryId);
final operation = _performRefresh(categoryId, keepData: keepData);
state.inFlight = operation;
unawaited(_clearInFlight(state, operation));
return operation;
}
Future<bool> _performRefresh(
String? categoryId, {
required bool keepData,
}) async {
final state = _state(categoryId);
final sequence = ++state.requestSeq;
state
..loading = true
..lastError = null;
_notifyIfCurrent(categoryId);
try {
final page = await _repository
.fetchFeed(categoryId: categoryId, size: pageSize)
.timeout(requestTimeout);
if (!_acceptSequence(categoryId, sequence)) return false;
state
..dramas = List.unmodifiable(page.dramas)
..cursor = page.nextCursor
..hasMore = page.hasMore
..loaded = true
..loading = false
..lastError = null;
_notifyIfCurrent(categoryId);
return true;
} catch (error) {
if (!_acceptSequence(categoryId, sequence)) return false;
if (!keepData) state.dramas = const [];
state
..loaded = true
..loading = false
..lastError = error.toString();
_notifyIfCurrent(categoryId);
return false;
}
}
Future<void> _clearInFlight(
_CategoryFeedState state,
Future<bool> operation,
) async {
await operation;
if (identical(state.inFlight, operation)) state.inFlight = null;
}
_CategoryFeedState _state(String? categoryId) =>
_states.putIfAbsent(categoryId, _CategoryFeedState.new);
bool _acceptSequence(String? categoryId, int sequence) =>
!_disposed && _state(categoryId).requestSeq == sequence;
void _notifyIfCurrent(String? categoryId) {
if (!_disposed && categoryId == _categoryId) _notify();
}
void _notify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
class _CategoryFeedState {
List<Drama> dramas = const [];
String? cursor;
bool hasMore = true;
bool loaded = false;
bool loading = false;
bool loadingMore = false;
String? lastError;
int requestSeq = 0;
Future<bool>? inFlight;
}
@@ -0,0 +1,24 @@
// Author: fruit.com.cn
import '../../l10n/app_localizations.dart';
/// Formats a timestamp as a relative label, falling back to a compact local date.
String formatRelativeDateTime(
DateTime value,
AppLocalizations l10n, {
DateTime? now,
}) {
final localValue = value.toLocal();
final localNow = (now ?? DateTime.now()).toLocal();
final elapsed = localNow.difference(localValue);
if (elapsed.isNegative || elapsed.inMinutes < 1) return l10n.justNow;
if (elapsed.inMinutes < 60) return l10n.minutesAgo(elapsed.inMinutes);
if (elapsed.inHours < 24) return l10n.hoursAgo(elapsed.inHours);
if (localValue.year == localNow.year) {
return '${_twoDigits(localValue.month)}-${_twoDigits(localValue.day)}';
}
return '${localValue.year}-${_twoDigits(localValue.month)}-'
'${_twoDigits(localValue.day)}';
}
String _twoDigits(int value) => value.toString().padLeft(2, '0');
+17
View File
@@ -0,0 +1,17 @@
// Author: fruit.com.cn
import 'package:flutter/widgets.dart';
/// 无位移的短促淡入转场。
class FadePageRoute<T> extends PageRouteBuilder<T> {
FadePageRoute({required WidgetBuilder builder, super.settings})
: super(
transitionDuration: const Duration(milliseconds: 220),
reverseTransitionDuration: const Duration(milliseconds: 180),
pageBuilder: (context, _, _) => builder(context),
transitionsBuilder: (context, animation, _, child) => FadeTransition(
opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut),
child: child,
),
);
}
+48
View File
@@ -0,0 +1,48 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
/// Provides the Android IME height even when `adjustNothing` keeps
/// `MediaQuery.viewInsets` at zero.
class KeyboardInsetNotifier extends ChangeNotifier {
KeyboardInsetNotifier._();
static final KeyboardInsetNotifier instance = KeyboardInsetNotifier._();
static const MethodChannel _channel = MethodChannel(
'com.stormg.lumadrama/keyboard',
);
/// Raw physical-pixel inset reported by Android.
int _inset = 0;
bool _started = false;
int get value => _inset;
Future<void> start() async {
if (_started || kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
return;
}
_started = true;
_channel.setMethodCallHandler((call) async {
if (call.method != 'insetsChanged') return;
_update(call.arguments);
});
try {
_update(await _channel.invokeMethod<Object?>('get'));
} catch (_) {
// Tests and non-embedded Android hosts may not provide the channel.
}
}
void _update(Object? value) {
final next = switch (value) {
int value => value,
num value => value.round(),
_ => 0,
};
if (next == _inset) return;
_inset = next;
notifyListeners();
}
}
+3 -3
View File
@@ -34,7 +34,7 @@ abstract final class AppDimens {
static const double previewCardTitle = 13;
static const double previewSectionTitle = 14;
static const double chipFont = 10;
static const double navLabel = 10;
static const double navLabel = 12;
static const double buttonHeight = 48;
static const double primaryButtonHeight = 52;
@@ -45,8 +45,8 @@ abstract final class AppDimens {
static const double tapTarget = 44;
static const double contentHeaderHeight = 46;
static const double navHeight = 64;
static const double navIcon = 20;
static const double navHeight = 68;
static const double navIcon = 24;
static const double progressHeight = 3;
static const double progressRadius = 2;
static const double progressLineHeight = 3;
+1 -1
View File
@@ -107,7 +107,7 @@ class _NavDestination extends StatelessWidget {
color: item.selected ? AppColors.blue : unselectedColor,
),
),
const SizedBox(height: 2),
const SizedBox(height: 3),
Text(
item.label,
maxLines: 1,
+92 -45
View File
@@ -9,10 +9,10 @@ import '../theme/app_colors.dart';
const double _maxPullDistance = 96;
const double _refreshThreshold = 56;
const double _indicatorHeight = 64;
const double _indicatorHeight = 56;
const double _indicatorMaxOffset = 10;
const Duration _arrowTurnDuration = Duration(milliseconds: 160);
const Duration _retractDuration = Duration(milliseconds: 200);
const Duration _settleDuration = Duration(milliseconds: 200);
enum _PullState { idle, pull, armed, refreshing }
@@ -26,7 +26,7 @@ class AppPullToRefresh extends StatefulWidget {
});
static const AlwaysScrollableScrollPhysics physics =
AlwaysScrollableScrollPhysics(parent: BouncingScrollPhysics());
AlwaysScrollableScrollPhysics(parent: ClampingScrollPhysics());
final Widget child;
final Future<void> Function() onRefresh;
@@ -36,13 +36,31 @@ class AppPullToRefresh extends StatefulWidget {
State<AppPullToRefresh> createState() => _AppPullToRefreshState();
}
class _AppPullToRefreshState extends State<AppPullToRefresh> {
class _AppPullToRefreshState extends State<AppPullToRefresh>
with SingleTickerProviderStateMixin {
late final AnimationController _offsetController;
_PullState _state = _PullState.idle;
double _pullDistance = 0;
bool _refreshing = false;
bool _retracting = false;
@override
void initState() {
super.initState();
_offsetController = AnimationController(
vsync: this,
duration: _settleDuration,
upperBound: _maxPullDistance,
);
}
@override
void dispose() {
_offsetController.dispose();
super.dispose();
}
bool _onScroll(ScrollNotification notification) {
if (notification.depth != 0 || _refreshing) return false;
if (notification.depth != 0 || _refreshing || _retracting) return false;
if (notification is ScrollStartNotification) {
if (notification.dragDetails != null) _setPullDistance(0);
} else if (notification is ScrollUpdateNotification) {
@@ -53,7 +71,7 @@ class _AppPullToRefreshState extends State<AppPullToRefresh> {
}
} else if (notification is OverscrollNotification) {
if (notification.dragDetails != null) {
_setPullDistance(_pullDistance - notification.overscroll);
_setPullDistance(_offsetController.value - notification.overscroll);
}
} else if (notification is ScrollEndNotification) {
_release();
@@ -68,75 +86,104 @@ class _AppPullToRefreshState extends State<AppPullToRefresh> {
: distance > 0
? _PullState.pull
: _PullState.idle;
if (distance == _pullDistance && state == _state) return;
setState(() {
_pullDistance = distance;
_state = state;
});
_offsetController.value = distance;
if (state != _state) setState(() => _state = state);
}
void _release() {
if (_refreshing || _retracting) return;
if (_state == _PullState.armed) {
unawaited(_refresh());
} else if (_state == _PullState.pull) {
setState(() {
_pullDistance = 0;
_state = _PullState.idle;
});
unawaited(_retract());
}
}
Future<void> _retract() async {
if (_retracting) return;
_retracting = true;
await _animateOffsetTo(0);
if (!mounted) return;
setState(() {
_state = _PullState.idle;
_retracting = false;
});
}
Future<void> _refresh() async {
if (_refreshing) return;
_refreshing = true;
setState(() => _state = _PullState.refreshing);
final refreshFuture = Future<void>.sync(widget.onRefresh);
try {
await widget.onRefresh();
await refreshFuture;
} finally {
await Future<void>.delayed(_retractDuration);
if (mounted) {
setState(() {
_pullDistance = 0;
_state = _PullState.idle;
_refreshing = false;
});
await _animateOffsetTo(0);
if (mounted) {
setState(() {
_state = _PullState.idle;
_refreshing = false;
});
}
}
}
}
Future<void> _animateOffsetTo(double target) async {
try {
await _offsetController
.animateTo(target, duration: _settleDuration, curve: Curves.easeOut)
.orCancel;
} on TickerCanceled {
// The overlay can be removed while the refresh future is still pending.
}
}
@override
Widget build(BuildContext context) {
return NotificationListener<ScrollNotification>(
onNotification: _onScroll,
child: Stack(
fit: StackFit.expand,
children: [
Listener(
onPointerUp: (_) => _release(),
onPointerCancel: (_) => _release(),
child: widget.child,
),
if (_state != _PullState.idle)
Positioned(
top: widget.edgeOffset,
left: 0,
right: 0,
child: IgnorePointer(child: _indicatorLayer(context)),
),
],
child: AnimatedBuilder(
animation: _offsetController,
child: Listener(
onPointerUp: (_) => _release(),
onPointerCancel: (_) => _release(),
child: widget.child,
),
builder: (context, child) {
final offset = _offsetController.value;
return Stack(
fit: StackFit.expand,
children: [
Transform.translate(
key: const Key('pull-refresh-content'),
offset: Offset(0, offset),
child: IgnorePointer(
ignoring: _refreshing || _retracting,
child: child,
),
),
if (_state != _PullState.idle && offset > 0)
Positioned(
top: widget.edgeOffset,
left: 0,
right: 0,
child: IgnorePointer(child: _indicatorLayer(context, offset)),
),
],
);
},
),
);
}
Widget _indicatorLayer(BuildContext context) {
final progress = (_pullDistance / _refreshThreshold).clamp(0.0, 1.0);
final opacity = _state == _PullState.refreshing
? 1.0
: progress.clamp(0.0, 1.0);
Widget _indicatorLayer(BuildContext context, double offset) {
final progress = (offset / _refreshThreshold).clamp(0.0, 1.0);
return SizedBox(
height: _indicatorHeight,
child: Opacity(
opacity: opacity,
opacity: progress,
child: Transform.translate(
offset: Offset(0, _indicatorMaxOffset * progress),
child: Align(
+8
View File
@@ -100,6 +100,13 @@
"count": {"type": "int"}
}
},
"justNow": "Just now",
"hoursAgo": "{count} hours ago",
"@hoursAgo": {
"placeholders": {
"count": {"type": "int"}
}
},
"artworkOnly": "Artwork",
"artworkOnlyNotice": "This is a visual catalog item. No video is available yet.",
"videoPending": "Video pending",
@@ -204,6 +211,7 @@
"writeComment": "Write a comment",
"commentLoginHint": "Sign in to comment",
"postComment": "Post",
"commentPostSuccess": "Comment posted",
"loadMore": "Load more",
"commentsLoadFailed": "Comments could not be loaded.",
"commentPostFailed": "Could not post your comment.",
+8
View File
@@ -79,6 +79,13 @@
"count": {"type": "int"}
}
},
"justNow": "Baru saja",
"hoursAgo": "{count} jam lalu",
"@hoursAgo": {
"placeholders": {
"count": {"type": "int"}
}
},
"artworkOnly": "Gambar",
"artworkOnlyNotice": "Ini hanya gambar katalog. Videonya belum tersedia.",
"videoPending": "Video belum siap",
@@ -178,6 +185,7 @@
"writeComment": "Tulis komentar",
"commentLoginHint": "Masuk untuk berkomentar",
"postComment": "Kirim",
"commentPostSuccess": "Komentar terkirim",
"loadMore": "Muat lebih banyak",
"commentsLoadFailed": "Komentar tidak dapat dimuat.",
"commentPostFailed": "Komentar tidak dapat dikirim.",
+18
View File
@@ -508,6 +508,18 @@ abstract class AppLocalizations {
/// **'{count} min ago'**
String minutesAgo(int count);
/// No description provided for @justNow.
///
/// In en, this message translates to:
/// **'Just now'**
String get justNow;
/// No description provided for @hoursAgo.
///
/// In en, this message translates to:
/// **'{count} hours ago'**
String hoursAgo(int count);
/// No description provided for @artworkOnly.
///
/// In en, this message translates to:
@@ -934,6 +946,12 @@ abstract class AppLocalizations {
/// **'Post'**
String get postComment;
/// No description provided for @commentPostSuccess.
///
/// In en, this message translates to:
/// **'Comment posted'**
String get commentPostSuccess;
/// No description provided for @loadMore.
///
/// In en, this message translates to:
+11
View File
@@ -227,6 +227,14 @@ class AppLocalizationsEn extends AppLocalizations {
return '$count min ago';
}
@override
String get justNow => 'Just now';
@override
String hoursAgo(int count) {
return '$count hours ago';
}
@override
String get artworkOnly => 'Artwork';
@@ -454,6 +462,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get postComment => 'Post';
@override
String get commentPostSuccess => 'Comment posted';
@override
String get loadMore => 'Load more';
+11
View File
@@ -227,6 +227,14 @@ class AppLocalizationsId extends AppLocalizations {
return '$count mnt lalu';
}
@override
String get justNow => 'Baru saja';
@override
String hoursAgo(int count) {
return '$count jam lalu';
}
@override
String get artworkOnly => 'Gambar';
@@ -455,6 +463,9 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get postComment => 'Kirim';
@override
String get commentPostSuccess => 'Komentar terkirim';
@override
String get loadMore => 'Muat lebih banyak';
+11
View File
@@ -224,6 +224,14 @@ class AppLocalizationsZh extends AppLocalizations {
return '$count 分钟前';
}
@override
String get justNow => '刚刚';
@override
String hoursAgo(int count) {
return '$count 小时前';
}
@override
String get artworkOnly => '海报展示';
@@ -449,6 +457,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get postComment => '发布';
@override
String get commentPostSuccess => '发表成功';
@override
String get loadMore => '加载更多';
+8
View File
@@ -79,6 +79,13 @@
"count": {"type": "int"}
}
},
"justNow": "刚刚",
"hoursAgo": "{count} 小时前",
"@hoursAgo": {
"placeholders": {
"count": {"type": "int"}
}
},
"artworkOnly": "海报展示",
"artworkOnlyNotice": "这张海报仅用于目录展示,暂时没有可播放视频。",
"videoPending": "视频待提供",
@@ -178,6 +185,7 @@
"writeComment": "写评论",
"commentLoginHint": "登录后参与评论",
"postComment": "发布",
"commentPostSuccess": "发表成功",
"loadMore": "加载更多",
"commentsLoadFailed": "评论加载失败,请重试。",
"commentPostFailed": "评论发布失败,请重试。",
+1 -1
View File
@@ -184,7 +184,7 @@ class _AuthPageState extends State<AuthPage> {
return Theme(
data: AppTheme.light(),
child: Scaffold(
resizeToAvoidBottomInset: true,
resizeToAvoidBottomInset: false,
backgroundColor: AppColors.ink,
body: Stack(
children: [
+77 -11
View File
@@ -8,9 +8,11 @@ import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/repositories/catalog_repository.dart';
import '../../core/data/repositories/search_repository.dart';
import '../../core/data/services/catalog_retry.dart';
import '../../core/data/services/category_controller.dart';
import '../../core/data/services/theater_feed_controller.dart';
import '../../core/formatting/error_text.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
@@ -70,6 +72,7 @@ class DiscoverPage extends StatefulWidget {
class _DiscoverPageState extends State<DiscoverPage> {
late final DiscoverLogic logic;
late final TheaterFeedController theater;
final ValueNotifier<bool> _headerPinned = ValueNotifier<bool>(false);
final ScrollController _categoryScroll = ScrollController();
final Map<String, GlobalKey> _categoryAnchors = {};
@@ -82,13 +85,25 @@ class _DiscoverPageState extends State<DiscoverPage> {
void initState() {
super.initState();
logic = DiscoverBinding().create(search: context.read<SearchRepository>());
theater = TheaterFeedController(context.read<CatalogRepository>());
logic.scroll.addListener(_onScroll);
theater.addListener(_onTheaterChanged);
unawaited(theater.select(null));
if (widget.focusSearch) _focusOnFirstFrame();
}
void _onScroll() {
final pinned = logic.scroll.hasClients && logic.scroll.offset > 0;
if (pinned != _headerPinned.value) _headerPinned.value = pinned;
if (logic.scroll.hasClients &&
!logic.hasSearchQuery &&
logic.scroll.position.extentAfter < 400) {
unawaited(theater.loadMore());
}
}
void _onTheaterChanged() {
if (mounted) setState(() {});
}
void _focusOnFirstFrame() {
@@ -111,6 +126,8 @@ class _DiscoverPageState extends State<DiscoverPage> {
@override
void dispose() {
logic.scroll.removeListener(_onScroll);
theater.removeListener(_onTheaterChanged);
theater.dispose();
_categoryScroll.dispose();
_headerPinned.dispose();
logic.dispose();
@@ -169,7 +186,8 @@ class _DiscoverPageState extends State<DiscoverPage> {
child: AppPullToRefresh(
key: const Key('discover-refresh'),
edgeOffset: _stickyHeaderHeight + 8,
onRefresh: categories.load,
onRefresh: () =>
Future.wait([categories.load(), theater.refreshCurrent()]),
child: ListView(
controller: logic.scroll,
physics: AppPullToRefresh.physics,
@@ -195,7 +213,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
bool _showStickyHeader(CategoryController categories) {
if (logic.hasSearchQuery || categories.error != null) return false;
return !(categories.loading && categories.feed.sections.isEmpty);
return !(categories.loading && categories.categories.isEmpty);
}
Widget _stickyHeader(AppLocalizations l10n, CategoryController categories) {
@@ -244,7 +262,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
),
];
}
if (categories.loading && categories.feed.sections.isEmpty) {
if (categories.loading && categories.categories.isEmpty) {
return [_block(const CircularProgressIndicator(strokeWidth: 2))];
}
final error = categories.error;
@@ -269,16 +287,35 @@ class _DiscoverPageState extends State<DiscoverPage> {
),
];
}
return _theaterBlocks(l10n, categories);
if (theater.loadingFirstPage) {
return [_block(const CircularProgressIndicator(strokeWidth: 2))];
}
final theaterError = theater.lastError;
if (theaterError != null && theater.dramas.isEmpty) {
return [
_block(
EmptyState(
key: const Key('home-categories-error'),
icon: Icons.cloud_off_rounded,
title: errorText(l10n, theaterError, l10n.catalogError),
subtitle: l10n.emptyHint,
actionLabel: l10n.retry,
onAction: () => unawaited(_retryTheater()),
footer: kDebugMode
? CatalogDebugInfo(
error: theaterError,
onEditEndpoint: () => unawaited(_editTheaterEndpoint()),
)
: null,
),
),
];
}
return _theaterBlocks(l10n);
}
List<Widget> _theaterBlocks(
AppLocalizations l10n,
CategoryController categories,
) {
final dramas = logic.selectedCategoryId == null
? categories.feed.dramas
: categories.dramasOf(logic.selectedCategoryId!);
List<Widget> _theaterBlocks(AppLocalizations l10n) {
final dramas = theater.dramas;
return [
_toolbar(l10n),
if (dramas.isEmpty)
@@ -293,6 +330,17 @@ class _DiscoverPageState extends State<DiscoverPage> {
)
else
DramaGrid(l10n: l10n, dramas: dramas, onOpen: _openDrama),
if (theater.loadingMore)
const Padding(
padding: EdgeInsets.symmetric(vertical: 20),
child: Center(
child: SizedBox.square(
key: Key('theater-load-more'),
dimension: 22,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
),
];
}
@@ -386,6 +434,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
void _selectCategory(String? categoryId) {
logic.selectCategory(categoryId);
unawaited(theater.select(categoryId));
final id = categoryId ?? 'all';
_ensuredCategoryId = id;
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -523,6 +572,23 @@ class _DiscoverPageState extends State<DiscoverPage> {
return retryCatalogLoad(reload: categories.load);
}
Future<void> _retryTheater() async {
await retryCatalogLoad(
reload: () async {
await theater.refreshCurrent();
},
);
}
Future<void> _editTheaterEndpoint() {
return showCatalogEndpointEditor(
context,
reload: () async {
await theater.refreshCurrent();
},
);
}
Future<void> _editCategoriesEndpoint(CategoryController categories) {
return showCatalogEndpointEditor(context, reload: categories.load);
}
@@ -6,7 +6,7 @@ import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
const double _searchHeight = 44;
const double _searchRadius = 22;
const double _searchRadius = 12;
const double _searchPadding = 14;
const double _iconSize = 18;
const double _iconGap = 8;
@@ -16,7 +16,7 @@ const double _clearSize = 18;
const double _clearIconSize = 12;
const Duration _focusTransition = Duration(milliseconds: 160);
/// 剧场顶部整行搜索框:浅灰胶囊,聚焦时变白并显示主题色描边。
/// 剧场顶部整行搜索框:浅灰圆角,聚焦时变白并显示主题色描边。
class DiscoverSearchField extends StatefulWidget {
const DiscoverSearchField({
super.key,
+21 -2
View File
@@ -35,6 +35,7 @@ class DramaDetailLogic extends ChangeNotifier {
int? continueIndex;
WatchProgress? progress;
String? progressError;
int _favoriteCountDelta = 0;
bool _disposed = false;
void notify() {
@@ -118,8 +119,26 @@ class DramaDetailLogic extends ChangeNotifier {
await loadContinue();
}
/// 收藏切换:乐观更新失败时会回滚,由调用方给出提示。
Future<bool> toggleFavorite() => favorites.toggle(drama.id, card: drama);
/// 详情页展示用的收藏数;没有服务端计数时继续保持缺省展示。
int? get adjustedFavoriteCount {
final base = drama.favoriteCount;
if (base == null) return null;
final adjusted = base + _favoriteCountDelta;
return adjusted < 0 ? 0 : adjusted;
}
/// 收藏切换:乐观状态由控制器负责回滚,这里只在成功后同步计数。
Future<bool> toggleFavorite() async {
final wasSaved = favorites.isSaved(drama.id);
final ok = await favorites.toggle(drama.id, card: drama);
if (!ok) return false;
final nowSaved = favorites.isSaved(drama.id);
if (nowSaved != wasSaved) {
_favoriteCountDelta += nowSaved ? 1 : -1;
notify();
}
return true;
}
@override
void dispose() {
+12 -1
View File
@@ -116,6 +116,12 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
final saved = context.watch<FavoriteController>().isSaved(drama.id);
final continueIndex = logic.continueIndex;
final episodesReady = drama.detailLoaded && drama.episodes.isNotEmpty;
final statsEpisode = episodesReady
? drama.episodes[continueIndex != null &&
continueIndex < drama.episodes.length
? continueIndex
: 0]
: null;
return Scaffold(
backgroundColor: AppColors.ink,
bottomNavigationBar: DramaDetailBottomBar(
@@ -142,7 +148,12 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
),
),
SliverToBoxAdapter(
child: DramaStatsCard(l10n: l10n, drama: drama),
child: DramaStatsCard(
l10n: l10n,
drama: drama,
favoriteCount: logic.adjustedFavoriteCount,
likeCount: statsEpisode?.likeCount,
),
),
SliverToBoxAdapter(
child: DramaSynopsisSection(
@@ -29,19 +29,28 @@ const TextStyle _synopsisStyle = TextStyle(
/// 详情页三项数据卡:热度、收藏与点赞,缺失值不做伪造。
class DramaStatsCard extends StatelessWidget {
const DramaStatsCard({super.key, required this.l10n, required this.drama});
const DramaStatsCard({
super.key,
required this.l10n,
required this.drama,
this.favoriteCount,
this.likeCount,
});
final AppLocalizations l10n;
final Drama drama;
final int? favoriteCount;
final int? likeCount;
@override
Widget build(BuildContext context) {
final favorite = drama.favoriteCount == null
final currentFavoriteCount = favoriteCount ?? drama.favoriteCount;
final favorite = currentFavoriteCount == null
? l10n.episodeCountLabel(drama.episodeCount)
: '${formatInteractionCount(drama.favoriteCount!)} ${l10n.favorite}';
final like = drama.likeCount == null
: '${formatInteractionCount(currentFavoriteCount)} ${l10n.favorite}';
final like = likeCount == null
? '—'
: '${formatInteractionCount(drama.likeCount!)} ${l10n.like}';
: '${formatInteractionCount(likeCount!)} ${l10n.like}';
return Container(
key: const Key('detail-stats'),
margin: const EdgeInsets.fromLTRB(
+14
View File
@@ -0,0 +1,14 @@
// Author: fruit.com.cn
/// 首页顶栏的三个内容频道。
enum HomeFeedChannel {
anime('ANIME'),
live('LIVE'),
recommend(null);
const HomeFeedChannel(this.apiType);
final String? apiType;
List<String> get apiTypes => apiType == null ? const [] : <String>[apiType!];
}
+162 -28
View File
@@ -9,6 +9,7 @@ import 'package:provider/provider.dart';
import '../../core/config/app_config.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/services/home_feed_controller.dart';
import '../../core/data/services/catalog_controller.dart';
import '../../core/data/services/catalog_retry.dart';
import '../../core/data/services/category_controller.dart';
@@ -24,8 +25,10 @@ import '../discover/discover_types.dart';
import '../discover/discover_view.dart';
import '../create/create_view.dart';
import '../mine/mine_types.dart';
import '../player/widgets/home_feed_header.dart';
import '../player/player_view.dart';
import '../mine/mine_view.dart';
import 'home_feed_channel.dart';
import 'home_binding.dart';
import 'home_logic.dart';
@@ -43,6 +46,8 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
static const _defaultSplashSize = 384.0;
bool _resumeRetryInFlight = false;
HomeFeedController? _homeFeed;
bool _recommendSeeded = false;
@override
void initState() {
@@ -52,6 +57,7 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
@override
void dispose() {
_homeFeed?.dispose();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@@ -131,11 +137,25 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
);
}
return _HomeTabs(
key: ValueKey(catalog.dramas.first.id),
dramas: catalog.dramas,
onRefreshFeed: catalog.refresh,
);
final homeFeed = _ensureHomeFeed(catalog);
if (!_recommendSeeded) {
_recommendSeeded = true;
homeFeed.seedRecommend(catalog.dramas);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) unawaited(_refreshRecommendAndPrefetch(homeFeed));
});
}
return _HomeTabs(key: const Key('home-tabs'), feedController: homeFeed);
}
HomeFeedController _ensureHomeFeed(CatalogController catalog) {
return _homeFeed ??= HomeFeedController(catalog.repository);
}
Future<void> _refreshRecommendAndPrefetch(HomeFeedController homeFeed) async {
if (await homeFeed.refreshCurrent()) {
await homeFeed.prefetchChannels();
}
}
Widget _startupSplash() {
@@ -199,14 +219,9 @@ class HomeFailureView extends StatelessWidget {
/// 四个根页面共享同一路由并保持各自状态。
class _HomeTabs extends StatefulWidget {
const _HomeTabs({
super.key,
required this.dramas,
required this.onRefreshFeed,
});
const _HomeTabs({super.key, required this.feedController});
final List<Drama> dramas;
final Future<bool> Function() onRefreshFeed;
final HomeFeedController feedController;
@override
State<_HomeTabs> createState() => _HomeTabsState();
@@ -214,19 +229,29 @@ class _HomeTabs extends StatefulWidget {
class _HomeTabsState extends State<_HomeTabs> {
late final HomeLogic logic;
List<Drama>? _visibleDramas;
HomeFeedChannel? _visibleChannel;
@override
void initState() {
super.initState();
logic = HomeBinding().create(widget.dramas);
logic = HomeBinding().create(widget.feedController.dramas);
_visibleDramas = widget.feedController.dramas;
_visibleChannel = widget.feedController.channel;
widget.feedController.addListener(_onFeedChanged);
}
@override
void dispose() {
widget.feedController.removeListener(_onFeedChanged);
logic.dispose();
super.dispose();
}
void _onFeedChanged() {
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
@@ -247,6 +272,7 @@ class _HomeTabsState extends State<_HomeTabs> {
child: Scaffold(
key: logic.scaffoldKey,
backgroundColor: Colors.transparent,
resizeToAvoidBottomInset: false,
body: _body(context),
bottomNavigationBar: logic.videoImmersive ? null : _nav(context, l10n),
),
@@ -276,24 +302,132 @@ class _HomeTabsState extends State<_HomeTabs> {
}
Widget _home(BuildContext context) {
return PlayerPage(
homeMode: true,
embedded: true,
active: logic.tab == 0,
drama: widget.dramas.first,
initialIndex: 0,
feedDramas: widget.dramas,
onOpenFullPlayer: (drama, episode) =>
logic.playDrama(context, drama, episodeId: episode.id),
onRefreshFeed: widget.onRefreshFeed,
onDramaChanged: logic.onDramaChanged,
onImmersiveChanged: logic.onImmersiveChanged,
onSearch: () => logic.select(1, search: true),
onDiscover: () => logic.select(1),
onMy: () => logic.select(3),
final currentDramas = widget.feedController.dramas;
final currentChannel = widget.feedController.channel;
if (currentDramas.isNotEmpty) {
_visibleDramas = currentDramas;
_visibleChannel = currentChannel;
}
if (currentDramas.isEmpty && !widget.feedController.loadingFirstPage) {
return _feedChannelPlaceholder(context);
}
final visibleDramas = _visibleDramas;
final visibleChannel = _visibleChannel;
final switching =
currentDramas.isEmpty && widget.feedController.loadingFirstPage;
if (visibleDramas == null ||
visibleDramas.isEmpty ||
visibleChannel == null) {
return _feedChannelPlaceholder(context);
}
final playerDramas = currentDramas.isEmpty ? visibleDramas : currentDramas;
final playerChannel = currentDramas.isEmpty
? visibleChannel
: currentChannel;
return Stack(
fit: StackFit.expand,
children: [
PlayerPage(
homeMode: true,
embedded: true,
active: logic.tab == 0,
drama: playerDramas.first,
initialIndex: 0,
feedDramas: playerDramas,
feedChannel: playerChannel,
headerChannel: currentChannel,
onOpenFullPlayer: (drama, episode) =>
logic.playDrama(context, drama, episodeId: episode.id),
onRefreshFeed: widget.feedController.refreshCurrent,
onLoadMore: widget.feedController.loadMore,
onChannelSelected: _selectFeedChannel,
onDramaChanged: logic.onDramaChanged,
onImmersiveChanged: logic.onImmersiveChanged,
onSearch: () => logic.select(1, search: true),
onDiscover: () => logic.select(1),
onMy: () => logic.select(3),
),
if (switching)
Positioned.fill(
child: IgnorePointer(
key: const Key('home-feed-channel-loading'),
child: ColoredBox(
color: Colors.black26,
child: Center(
child: SizedBox.square(
dimension: 24,
child: CircularProgressIndicator(
strokeWidth: 2.4,
color: Colors.white70,
),
),
),
),
),
),
],
);
}
Widget _feedChannelPlaceholder(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Stack(
fit: StackFit.expand,
children: [
const ColoredBox(color: Colors.black),
SafeArea(
bottom: false,
child: Column(
children: [
HomeFeedHeader(
l10n: l10n,
channel: widget.feedController.channel,
onChannelSelected: _selectFeedChannel,
onRefresh: () => _refreshPlaceholder(context),
onSearch: () => logic.select(1, search: true),
onMore: () {},
),
Expanded(
child: widget.feedController.loadingFirstPage
? const Center(
child: SizedBox.square(
dimension: 24,
child: CircularProgressIndicator(
key: Key('home-feed-channel-loading'),
strokeWidth: 2.4,
color: Colors.white70,
),
),
)
: EmptyState(
icon: Icons.movie_outlined,
title: l10n.noStories,
actionLabel: l10n.retry,
onAction: () =>
unawaited(widget.feedController.refreshCurrent()),
),
),
],
),
),
],
);
}
Future<bool> _refreshPlaceholder(BuildContext context) async {
final ok = await widget.feedController.refreshCurrent();
if (!ok && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context)!.catalogError)),
);
}
return ok;
}
void _selectFeedChannel(HomeFeedChannel channel) {
unawaited(widget.feedController.selectChannel(channel));
}
Widget _discover(BuildContext context) {
return DiscoverPage(
active: logic.tab == 1,
+2 -1
View File
@@ -7,6 +7,7 @@ import '../../core/data/models/drama.dart';
import '../../core/data/services/catalog_controller.dart';
import '../../core/data/services/download_controller.dart';
import '../../core/data/services/wallet_controller.dart';
import '../../core/navigation/fade_page_route.dart';
import 'player_view.dart';
/// 目录卡片统一入口:补齐详情后直接进入全屏播放页。
@@ -29,7 +30,7 @@ Future<void> playDramaFromCatalog(
? 0
: playable.episodes.indexWhere((episode) => episode.id == episodeId);
await Navigator.of(context).push(
MaterialPageRoute<void>(
FadePageRoute<void>(
builder: (_) => PlayerPage(
drama: playable,
initialIndex: index < 0 ? 0 : index,
+185 -13
View File
@@ -21,6 +21,7 @@ import '../../core/data/services/playback_session.dart';
import '../../core/data/services/video_player_port.dart';
import '../../core/data/services/wallet_controller.dart';
import '../../core/formatting/localized_content.dart';
import '../../core/navigation/fade_page_route.dart';
import '../../l10n/app_localizations.dart';
import '../discover/discover_types.dart';
import '../discover/discover_view.dart';
@@ -99,6 +100,15 @@ class PlayerLogic extends ChangeNotifier {
ValueChanged<Drama>? onDramaChanged;
ValueChanged<bool>? onImmersiveChanged;
bool _disposed = false;
PlayerFactory? _sessionFactory;
PlaybackCoordinator? _sessionCoordinator;
int feedRevision = 0;
List<Drama> _latestFeed = const [];
String? _latestKeepDramaId;
bool _latestResetToFirst = false;
bool _feedUpdateRunning = false;
bool _hasPendingFeed = false;
Future<void> _feedUpdateFuture = Future<void>.value();
/// [start] 是否已经跑过:没跑过时 session/pages 还是 late 未初始化,
/// dispose 不能直接碰它们(例如页面构建失败或纯逻辑测试)。
@@ -108,6 +118,17 @@ class PlayerLogic extends ChangeNotifier {
bool get chromeHidden => immersive;
/// 最近一次 feed 更新流程完成;测试和页面退出可等待它结束。
Future<void> get feedUpdateSettled => _feedUpdateFuture;
/// 当前 PageView 对应用户正在观看的短剧 id,尚未起播时为 null。
String? get currentDramaId {
if (!_started || visibleIndices.isEmpty) return null;
final index = session.currentIndex;
if (index < 0 || index >= queue.length) return null;
return queue.entryAt(index).drama.id;
}
Episode displayEpisode(Drama drama, Episode fallback) {
final number = pickedEpisode[drama.id];
if (number == null) return fallback;
@@ -144,6 +165,8 @@ class PlayerLogic extends ChangeNotifier {
required PlaybackCoordinator coordinator,
}) {
_started = true;
_sessionFactory = factory;
_sessionCoordinator = coordinator;
pictureInPicture.setStateListener(onPipChanged);
queue = feedDramas == null
? PlaybackQueue.series(pageDrama)
@@ -152,7 +175,41 @@ class PlayerLogic extends ChangeNotifier {
pages = PageController(initialPage: initialIndex);
// 票据解析后要把新地址写回这个列表(PlaybackSession 的 canPlay 读的就是它)
sessionEpisodes = [for (final entry in queue.entries) entry.episode];
session = PlaybackSession(
session = _createSession(factory, coordinator);
if (!active || visibleIndices.isEmpty) {
session.setRouteVisible(false);
}
unawaited(loadResumePoint());
unawaited(selectEpisode(initialIndex));
}
/// 串行替换首页 feed 队列;频道变化时重置到第 1 个,刷新时尽量保留当前剧。
void updateFeed(
List<Drama> dramas, {
String? keepDramaId,
bool resetToFirst = false,
}) {
feedRevision++;
pageRequest++;
_latestFeed = List<Drama>.unmodifiable(dramas);
_latestKeepDramaId = keepDramaId;
_latestResetToFirst = resetToFirst;
_hasPendingFeed = true;
if (!_started) {
feedDramas = _latestFeed;
return;
}
if (!_feedUpdateRunning) {
_feedUpdateFuture = _drainFeedUpdates();
unawaited(_feedUpdateFuture);
}
}
PlaybackSession _createSession(
PlayerFactory factory,
PlaybackCoordinator coordinator,
) {
return PlaybackSession(
episodes: sessionEpisodes,
factory: factory,
// 地址只由后端下发(票据或详情里的免费集签名地址),拿不到地址即视为锁定
@@ -161,11 +218,107 @@ class PlayerLogic extends ChangeNotifier {
activate: activatePlayer,
coordinator: coordinator,
);
if (!active || visibleIndices.isEmpty) {
session.setRouteVisible(false);
}
Future<void> _drainFeedUpdates() async {
_feedUpdateRunning = true;
while (true) {
if (!_hasPendingFeed) {
_feedUpdateRunning = false;
return;
}
_hasPendingFeed = false;
final revision = feedRevision;
final dramas = _latestFeed;
final keepDramaId = _latestKeepDramaId;
final resetToFirst = _latestResetToFirst;
if (revision != feedRevision) continue;
await _applyFeedUpdate(
dramas,
keepDramaId: keepDramaId,
resetToFirst: resetToFirst,
revision: revision,
);
}
unawaited(loadResumePoint());
unawaited(selectEpisode(initialIndex));
}
Future<void> _applyFeedUpdate(
List<Drama> dramas, {
required String? keepDramaId,
required bool resetToFirst,
required int revision,
}) async {
if (!_started || revision != feedRevision) return;
final currentId = currentDramaId;
final nextQueue = PlaybackQueue.home(dramas);
final nextVisible = List<int>.generate(nextQueue.length, (index) => index);
final nextEpisodes = [for (final entry in nextQueue.entries) entry.episode];
feedDramas = dramas;
queue = nextQueue;
visibleIndices = nextVisible;
if (dramas.isNotEmpty) pageDrama = dramas.first;
if (nextQueue.length == 0) {
session.setRouteVisible(false);
notify();
return;
}
final keepIndex =
!resetToFirst && keepDramaId != null && keepDramaId == currentId
? nextQueue.entries.indexWhere((entry) => entry.drama.id == keepDramaId)
: -1;
if (keepIndex >= 0 && session.active != null) {
sessionEpisodes
..clear()
..addAll(nextEpisodes);
session.remapCurrentIndex(
keepIndex,
preloadIndex: nextVisibleIndex(keepIndex),
);
_jumpToFeedPage(keepIndex);
notify();
return;
}
await _replaceSession(
nextEpisodes,
keepIndex >= 0 ? keepIndex : 0,
revision: revision,
);
}
Future<void> _replaceSession(
List<Episode> nextEpisodes,
int targetIndex, {
required int revision,
}) async {
if (revision != feedRevision) return;
final factory = _sessionFactory;
final coordinator = _sessionCoordinator;
if (factory == null || coordinator == null) return;
unawaited(saveCurrentProgress());
final previous = session;
sessionEpisodes = List<Episode>.of(nextEpisodes);
trackedPlayer = null;
trackedEpisode = null;
trackedDrama = null;
final nextSession = _createSession(factory, coordinator);
session = nextSession;
nextSession.setRouteVisible(active && visibleIndices.isNotEmpty);
notify();
await previous.close();
if (revision != feedRevision || !identical(session, nextSession)) return;
_jumpToFeedPage(targetIndex);
await selectEpisode(targetIndex, revision: revision);
}
void _jumpToFeedPage(int index) {
if (!_started) return;
if (pages.hasClients) {
pages.jumpToPage(index);
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (pages.hasClients) pages.jumpToPage(index);
});
}
/// 服务端续看位置:进播放页时取一次,用于"定位到上次那一集的那一秒"。
@@ -229,7 +382,11 @@ class PlayerLogic extends ChangeNotifier {
qualities = ticket.qualities;
if (ticket.accessible && ticket.playUrl.isNotEmpty) {
// 签名地址原样写回,不做任何拼接/改写(AppConfig.mediaUrl 只补 host)
sessionEpisodes[index] = episode.withVideoUrl(ticket.playUrl);
sessionEpisodes[index] = episode.withVideoUrl(
ticket.playUrl,
likeCount: ticket.likeCount,
commentCount: ticket.commentCount,
);
return true;
}
blockedTicket = ticket;
@@ -538,15 +695,24 @@ class PlayerLogic extends ChangeNotifier {
);
}
Future<void> selectEpisode(int index) async {
Future<void> selectEpisode(int index, {int? revision}) async {
final feedRevisionAtStart = revision ?? feedRevision;
final request = ++pageRequest;
cancelScrub();
// Capture the old position synchronously, persist without blocking a swipe.
unawaited(saveCurrentProgress());
if (request != pageRequest || !isMounted()) return;
if (request != pageRequest ||
feedRevisionAtStart != feedRevision ||
!isMounted()) {
return;
}
// 起播前先要票据:拿不到就不起播,交给三态 UI 提示(失败可重试)
final ready = await ensureTicket(index);
if (request != pageRequest || !isMounted()) return;
if (request != pageRequest ||
feedRevisionAtStart != feedRevision ||
!isMounted()) {
return;
}
if (!ready && ticketError != null) return;
await session.select(index, preloadIndex: nextVisibleIndex(index));
}
@@ -625,7 +791,7 @@ class PlayerLogic extends ChangeNotifier {
try {
if (!context.mounted) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
FadePageRoute<void>(
builder: (_) =>
DramaDetailPage(drama: queue.entryAt(session.currentIndex).drama),
),
@@ -646,7 +812,7 @@ class PlayerLogic extends ChangeNotifier {
await saveCurrentProgress();
if (!context.mounted) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
FadePageRoute<void>(
builder: (_) => PlayerPage(
drama: drama,
initialIndex: index,
@@ -796,7 +962,11 @@ class PlayerLogic extends ChangeNotifier {
notify();
}
Future<void> showComments(BuildContext context, Drama drama) async {
Future<void> showComments(
BuildContext context,
Drama drama, {
String? episodeId,
}) async {
session.setOverlayOpen(true);
await session.playbackSettled;
if (!context.mounted) {
@@ -809,7 +979,9 @@ class PlayerLogic extends ChangeNotifier {
isScrollControlled: true,
backgroundColor: Colors.transparent,
showDragHandle: false,
builder: (_) => DramaCommentsSheet(drama: drama),
isDismissible: false,
enableDrag: false,
builder: (_) => DramaCommentsSheet(drama: drama, episodeId: episodeId),
);
} finally {
session.setOverlayOpen(false);
+73 -27
View File
@@ -7,6 +7,7 @@ import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/repositories/comment_repository.dart';
import '../../core/data/services/favorite_controller.dart';
import '../../core/data/services/download_controller.dart';
import '../../core/data/services/interaction_controller.dart';
@@ -21,6 +22,7 @@ import '../../core/theme/app_theme.dart';
import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/download_action.dart';
import '../../l10n/app_localizations.dart';
import '../home/home_feed_channel.dart';
import 'comments_auth.dart';
import 'player_binding.dart';
import 'player_logic.dart';
@@ -56,10 +58,14 @@ class PlayerPage extends StatefulWidget {
this.onSearch,
this.onMenu,
this.feedDramas,
this.feedChannel,
this.headerChannel,
this.onDramaChanged,
this.onImmersiveChanged,
this.onOpenFullPlayer,
this.onRefreshFeed,
this.onLoadMore,
this.onChannelSelected,
this.wallet,
this.downloads,
});
@@ -76,10 +82,14 @@ class PlayerPage extends StatefulWidget {
final VoidCallback? onSearch;
final VoidCallback? onMenu;
final List<Drama>? feedDramas;
final HomeFeedChannel? feedChannel;
final HomeFeedChannel? headerChannel;
final ValueChanged<Drama>? onDramaChanged;
final ValueChanged<bool>? onImmersiveChanged;
final Future<void> Function(Drama drama, Episode episode)? onOpenFullPlayer;
final Future<bool> Function()? onRefreshFeed;
final Future<void> Function()? onLoadMore;
final ValueChanged<HomeFeedChannel>? onChannelSelected;
final WalletController? wallet;
final DownloadController? downloads;
@@ -99,6 +109,7 @@ class _PlayerPageState extends State<PlayerPage>
/// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。
final Map<String, int> _favoriteDelta = {};
final Map<String, int> _commentCounts = {};
@override
void initState() {
@@ -121,9 +132,25 @@ class _PlayerPageState extends State<PlayerPage>
void didUpdateWidget(covariant PlayerPage oldWidget) {
super.didUpdateWidget(oldWidget);
logic.attach(widget, isMounted: () => mounted);
final channelChanged = oldWidget.feedChannel != widget.feedChannel;
final feedChanged =
_feedSignature(oldWidget.feedDramas) !=
_feedSignature(widget.feedDramas);
if (widget.homeMode &&
widget.feedDramas != null &&
(feedChanged || channelChanged)) {
logic.updateFeed(
widget.feedDramas!,
keepDramaId: channelChanged ? null : logic.currentDramaId,
resetToFirst: channelChanged,
);
}
logic.onPageUpdated(oldWidget);
}
String _feedSignature(List<Drama>? dramas) =>
dramas == null ? '' : dramas.map((drama) => drama.id).join('|');
@override
void didChangeDependencies() {
super.didChangeDependencies();
@@ -203,6 +230,27 @@ class _PlayerPageState extends State<PlayerPage>
});
}
Future<void> _openComments(
BuildContext context,
Drama drama,
Episode episode,
) async {
final comments = context.read<CommentRepository>();
await openCommentsWithAuth(
context,
drama,
show: (context, drama) =>
logic.showComments(context, drama, episodeId: episode.id),
);
try {
final count = await comments.count(drama.id, episodeId: episode.id);
if (!mounted) return;
setState(() => _commentCounts[episode.id] = count);
} catch (_) {
// 评论面板已经关闭,计数刷新失败时保留旧值即可。
}
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
@@ -227,6 +275,10 @@ class _PlayerPageState extends State<PlayerPage>
final index = indices[position];
widget.onDramaChanged?.call(logic.queue.entryAt(index).drama);
unawaited(logic.selectEpisode(index));
if (position >= indices.length - 3) {
final loadMore = widget.onLoadMore;
if (loadMore != null) unawaited(loadMore());
}
},
itemBuilder: (context, position) =>
_buildEpisode(context, indices[position], interactions),
@@ -245,6 +297,11 @@ class _PlayerPageState extends State<PlayerPage>
bottom: false,
child: HomeFeedHeader(
l10n: l10n,
channel:
widget.headerChannel ??
widget.feedChannel ??
HomeFeedChannel.recommend,
onChannelSelected: widget.onChannelSelected ?? (_) {},
onRefresh: () async {
final ok = await widget.onRefreshFeed?.call() ?? true;
if (context.mounted && !ok) {
@@ -277,6 +334,7 @@ class _PlayerPageState extends State<PlayerPage>
: Scaffold(
key: const Key('player-scaffold'),
backgroundColor: Colors.transparent,
resizeToAvoidBottomInset: false,
body: body,
),
),
@@ -319,7 +377,6 @@ class _PlayerPageState extends State<PlayerPage>
),
PlayerVideoSurface(
controller: videoPort?.controller,
posterUrl: drama.posterUrl,
videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl,
theater: theater,
onTap: () =>
@@ -442,18 +499,19 @@ class _PlayerPageState extends State<PlayerPage>
onBoostEnd: _endBoost,
onTogglePlay: () =>
logic.session.setUserWantsPlay(!logic.session.userWantsPlay),
liked: interactions.isLiked(drama.id),
likeCount: interactions.likeCount(drama.id) ?? drama.likeCount,
liked: interactions.isEpisodeLiked(episode.id),
likeCount:
interactions.episodeLikeCount(episode.id) ?? episode.likeCount,
favoriteCount: drama.favoriteCount == null
? null
: drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0),
commentCount: drama.commentCount,
commentCount: _commentCounts[episode.id] ?? episode.commentCount,
saved: context.watch<FavoriteController>().isSaved(drama.id),
onLike: () async {
if (await ensureSignedIn(context)) {
if (!context.mounted) return;
unawaited(context.read<Haptics>().lightImpact());
final result = await interactions.toggleDramaLike(drama.id);
final result = await interactions.toggleEpisodeLike(episode.id);
if (!result.isSuccess && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -466,21 +524,8 @@ class _PlayerPageState extends State<PlayerPage>
}
}
},
onSave: () async {
final favorites = context.read<FavoriteController>();
if (await ensureSignedIn(context)) {
if (!context.mounted) return;
unawaited(context.read<Haptics>().lightImpact());
final ok = await favorites.toggle(drama.id, card: drama);
if (ok || !context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.favoriteToggleFailed)),
);
}
},
onComments: () => unawaited(
openCommentsWithAuth(context, drama, show: logic.showComments),
),
onSave: () => unawaited(_toggleSave(context, drama)),
onComments: () => unawaited(_openComments(context, drama, episode)),
onShare: () => logic.shareDrama(context, drama),
onDetail: () => unawaited(logic.openDetail(context)),
onOpenFullPlayer: () async {
@@ -501,9 +546,11 @@ class _PlayerPageState extends State<PlayerPage>
drama: drama,
episode: episode,
controller: ready ? videoPort!.controller : null,
liked: interactions.isLiked(drama.id),
likeCount: interactions.likeCount(drama.id) ?? drama.likeCount,
commentCount: drama.commentCount,
liked: interactions.isEpisodeLiked(episode.id),
likeCount:
interactions.episodeLikeCount(episode.id) ??
episode.likeCount,
commentCount: _commentCounts[episode.id] ?? episode.commentCount,
favoriteCount: drama.favoriteCount == null
? null
: drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0),
@@ -524,11 +571,10 @@ class _PlayerPageState extends State<PlayerPage>
onDetail: () => unawaited(logic.openDetail(context)),
onSpeed: () => unawaited(_openSpeedSheet(context)),
onEpisodes: () => logic.showEpisodes(context),
onComments: () => unawaited(
openCommentsWithAuth(context, drama, show: logic.showComments),
),
onComments: () =>
unawaited(_openComments(context, drama, episode)),
onLike: () =>
unawaited(_toggleLike(context, interactions, drama)),
unawaited(_toggleLike(context, interactions, episode)),
onSave: () => unawaited(_toggleSave(context, drama)),
onShare: () => logic.shareDrama(context, drama),
onMore: () => _openMore(context, episode),
+2 -2
View File
@@ -81,11 +81,11 @@ extension _PlayerViewActions on _PlayerPageState {
Future<void> _toggleLike(
BuildContext context,
InteractionController interactions,
Drama drama,
Episode episode,
) async {
if (!await ensureSignedIn(context) || !context.mounted) return;
unawaited(context.read<Haptics>().lightImpact());
final result = await interactions.toggleDramaLike(drama.id);
final result = await interactions.toggleEpisodeLike(episode.id);
if (result.isSuccess || !context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -0,0 +1,356 @@
// Author: fruit.com.cn
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../../core/data/models/comment.dart';
import '../../../core/data/services/auth_controller.dart';
import '../../../core/data/services/comment_controller.dart';
import '../../../core/platform/haptics.dart';
import '../../../core/platform/keyboard_inset.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/auth_gate.dart';
import '../../../l10n/app_localizations.dart';
const double _sheetRadius = 20;
const double _inputRadius = 12;
const double _postButtonHeight = 32;
const int _maxCommentLength = 300;
const Duration _keyboardSettleDelay = Duration(milliseconds: 80);
const double _maxKeyboardInsetRatio = 0.6;
/// 独立评论输入弹窗,复用原评论控制器的发布与列表更新能力。
class CommentComposerSheet extends StatefulWidget {
const CommentComposerSheet({
super.key,
required this.controller,
required this.onDismiss,
required this.onPosted,
this.replyTo,
});
final CommentController controller;
final VoidCallback onDismiss;
final VoidCallback onPosted;
final DramaComment? replyTo;
@override
State<CommentComposerSheet> createState() => _CommentComposerSheetState();
}
class _CommentComposerSheetState extends State<CommentComposerSheet> {
final _input = TextEditingController();
final _inputFocus = FocusNode();
final _keyboardInset = KeyboardInsetNotifier.instance;
Timer? _keyboardSettleTimer;
double _pinnedKeyboardInset = 0;
int _insetBaseline = 0;
bool _pinnedInitialized = false;
bool _insetReady = false;
bool _useChannelInset = false;
bool _keyboardVisible = false;
bool get _isAndroid =>
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
@override
void initState() {
super.initState();
_input.addListener(_onInputChanged);
_keyboardInset.addListener(_onKeyboardInsetChanged);
widget.controller.addListener(_onControllerChanged);
unawaited(_prepareInput());
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_isAndroid) return;
if (!_pinnedInitialized) {
_pinnedInitialized = true;
_pinnedKeyboardInset =
_normalizeKeyboardInset(MediaQuery.viewInsetsOf(context).bottom) ?? 0;
}
_trackKeyboardInset(_keyboardHeight(context));
}
Future<void> _prepareInput() async {
if (_isAndroid) {
await _keyboardInset.start();
if (!mounted) return;
_insetBaseline = _keyboardInset.value;
_insetReady = true;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_inputFocus.requestFocus();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _keyboardHeight(context) > 0) return;
unawaited(
SystemChannels.textInput.invokeMethod<void>('TextInput.show'),
);
});
});
}
@override
void dispose() {
_input.removeListener(_onInputChanged);
_keyboardInset.removeListener(_onKeyboardInsetChanged);
widget.controller.removeListener(_onControllerChanged);
_keyboardSettleTimer?.cancel();
_inputFocus.unfocus();
_input.dispose();
_inputFocus.dispose();
super.dispose();
}
void _onInputChanged() {
if (mounted) setState(() {});
}
void _onControllerChanged() {
if (mounted) setState(() {});
}
void _onKeyboardInsetChanged() {
if (!_isAndroid || !mounted || !_insetReady) return;
if (_keyboardInset.value != _insetBaseline) {
_useChannelInset = true;
}
_trackKeyboardInset(_keyboardHeight(context));
}
double _keyboardHeight(BuildContext context) {
final viewInset = MediaQuery.viewInsetsOf(context).bottom;
if (!_isAndroid) {
return _normalizeKeyboardInset(viewInset) ?? 0;
}
final channelInset = _useChannelInset
? _keyboardInset.value / MediaQuery.devicePixelRatioOf(context)
: 0.0;
return math.max(viewInset, channelInset);
}
double? _normalizeKeyboardInset(double inset) {
final maxInset = MediaQuery.sizeOf(context).height * _maxKeyboardInsetRatio;
if (!inset.isFinite || inset > maxInset) return null;
return math.max(0, inset).roundToDouble();
}
void _trackKeyboardInset(double inset) {
if (!_isAndroid) return;
final visible = inset > 0;
if (visible == _keyboardVisible) return;
_keyboardVisible = visible;
_keyboardSettleTimer?.cancel();
_keyboardSettleTimer = null;
if (!visible) return;
_keyboardSettleTimer = Timer(
_keyboardSettleDelay,
_captureSettledKeyboardInset,
);
}
void _captureSettledKeyboardInset() {
if (!_isAndroid) return;
_keyboardSettleTimer = null;
if (!mounted) return;
final inset = _keyboardHeight(context);
if (inset <= 0) {
_keyboardVisible = false;
return;
}
final next = _normalizeKeyboardInset(inset);
if (next == null || next == _pinnedKeyboardInset) return;
setState(() => _pinnedKeyboardInset = next);
}
Future<void> _submit() async {
if (widget.controller.submitting) return;
final text = _input.text.trim();
if (text.isEmpty) return;
unawaited(context.read<Haptics>().lightImpact());
final result = await widget.controller.publish(
text,
replyTo: widget.replyTo,
);
if (!mounted) return;
if (result.isSuccess) {
_input.clear();
widget.onPosted();
return;
}
final l10n = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(result.errorMessage ?? l10n.commentPostFailed)),
);
}
Future<void> _authenticate() async {
await ensureSignedIn(context);
if (!mounted || !context.read<AuthController>().isSignedIn) return;
_inputFocus.requestFocus();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final signedIn = context.watch<AuthController>().isSignedIn;
final hasContent = _input.text.trim().isNotEmpty;
final canPost = signedIn && hasContent && !widget.controller.submitting;
final multiline = _input.text.contains('\n');
final bottomInset = _isAndroid
? _pinnedKeyboardInset
: _normalizeKeyboardInset(MediaQuery.viewInsetsOf(context).bottom) ?? 0;
final content = Padding(
key: const Key('comment-composer-sheet'),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: TextFieldTapRegion(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (widget.replyTo != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
l10n.replyingTo(
widget.replyTo!.nickname.isEmpty
? l10n.anonymousUser
: widget.replyTo!.nickname,
),
style: const TextStyle(color: AppColors.muted, fontSize: 12),
),
),
Container(
decoration: BoxDecoration(
color: AppColors.commentInput,
borderRadius: BorderRadius.circular(_inputRadius),
),
child: TextField(
key: const Key('comment-composer-input'),
controller: _input,
focusNode: _inputFocus,
readOnly: !signedIn,
canRequestFocus: signedIn,
minLines: 1,
maxLines: 4,
maxLength: _maxCommentLength,
textAlignVertical: multiline
? TextAlignVertical.top
: TextAlignVertical.center,
style: TextStyle(
color: AppColors.cream,
fontSize: 15,
height: multiline ? 1.4 : null,
),
cursorColor: AppColors.blue,
decoration: InputDecoration(
hintText: signedIn
? l10n.writeComment
: l10n.commentLoginHint,
hintStyle: const TextStyle(
color: AppColors.muted,
fontSize: 14,
),
counterText: '',
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
),
onTap: signedIn ? null : _authenticate,
),
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: SizedBox(
height: _postButtonHeight,
child: TextButton(
key: const Key('comment-composer-post'),
onPressed: canPost ? _submit : null,
style: TextButton.styleFrom(
minimumSize: const Size(0, _postButtonHeight),
padding: const EdgeInsets.symmetric(horizontal: 16),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
backgroundColor: hasContent
? AppColors.blue.withValues(alpha: 0.12)
: AppColors.raised,
foregroundColor: AppColors.blue,
disabledForegroundColor: AppColors.muted,
disabledBackgroundColor: AppColors.raised,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Text(
l10n.postComment,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
),
),
],
),
),
);
return FocusScope(
key: const Key('comment-composer-focus-scope'),
autofocus: true,
child: Stack(
key: const Key('comment-composer-overlay'),
fit: StackFit.expand,
children: [
Positioned.fill(
child: GestureDetector(
key: const Key('comment-composer-dismiss-layer'),
behavior: HitTestBehavior.opaque,
onTap: widget.onDismiss,
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {},
child: Material(
key: const Key('comment-composer-panel'),
color: AppColors.card,
clipBehavior: Clip.antiAlias,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(_sheetRadius),
),
child: AnimatedPadding(
key: const Key('comment-composer-keyboard-padding'),
duration: const Duration(milliseconds: 160),
curve: Curves.easeOutCubic,
padding: EdgeInsets.only(bottom: bottomInset),
child: _isAndroid
? content
: SafeArea(top: false, child: content),
),
),
),
),
],
),
);
}
}
+56 -107
View File
@@ -3,29 +3,17 @@
import 'package:flutter/material.dart';
import '../../../core/data/models/comment.dart';
import '../../../core/data/services/comment_controller.dart';
import '../../../core/formatting/localized_date_time.dart';
import '../../../core/formatting/relative_date_time.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/remote_image.dart';
import '../../../l10n/app_localizations.dart';
const String _deleteAction = 'delete';
const String _reportAction = 'report';
/// 评论条目的交互回调集合。
class CommentInteractionHandlers {
const CommentInteractionHandlers({
required this.onReply,
required this.onLike,
required this.onDelete,
required this.onReport,
});
const CommentInteractionHandlers({required this.onReply});
final ValueChanged<DramaComment> onReply;
final Future<void> Function(DramaComment) onLike;
final Future<void> Function(DramaComment) onDelete;
final Future<void> Function(DramaComment) onReport;
}
/// 单条评论或回复的展示与操作行。
@@ -33,13 +21,11 @@ class CommentTile extends StatelessWidget {
const CommentTile({
super.key,
required this.comment,
required this.controller,
required this.handlers,
this.isReply = false,
});
final DramaComment comment;
final CommentController controller;
final CommentInteractionHandlers handlers;
final bool isReply;
@@ -51,66 +37,30 @@ class CommentTile extends StatelessWidget {
: comment.nickname;
return Padding(
key: Key('comment-item-${comment.id}'),
padding: const EdgeInsets.symmetric(vertical: 12),
padding: const EdgeInsets.only(top: 10, bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_avatar(),
const SizedBox(width: 10),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
name,
style: const TextStyle(
color: AppColors.cream,
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
),
if (comment.createdAt != null)
Text(
formatLocalizedDateTime(context, comment.createdAt!),
style: const TextStyle(
color: AppColors.subtle,
fontSize: 11,
),
),
const SizedBox(width: 4),
PopupMenuButton<String>(
key: Key('comment-menu-${comment.id}'),
tooltip: l10n.more,
onSelected: (value) {
if (value == _deleteAction) {
handlers.onDelete(comment);
} else if (value == _reportAction) {
handlers.onReport(comment);
}
},
itemBuilder: (_) => [
if (comment.mine)
PopupMenuItem(
value: _deleteAction,
child: Text(l10n.deleteComment),
),
PopupMenuItem(
value: _reportAction,
child: Text(l10n.reportComment),
),
],
),
],
Text(
name,
style: const TextStyle(
color: AppColors.muted,
fontSize: 12,
fontWeight: FontWeight.w400,
),
),
const SizedBox(height: 2),
Text(
comment.content,
style: const TextStyle(
color: AppColors.cream,
fontSize: 14,
fontSize: 16,
height: 1.4,
),
),
@@ -126,48 +76,11 @@ class CommentTile extends StatelessWidget {
),
),
),
Row(
children: [
TextButton.icon(
key: Key('comment-like-${comment.id}'),
style: TextButton.styleFrom(
foregroundColor: AppColors.muted,
padding: const EdgeInsets.symmetric(horizontal: 4),
minimumSize: const Size(0, 28),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: controller.likingComment(comment.id)
? null
: () => handlers.onLike(comment),
icon: Icon(
comment.liked
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
size: 16,
),
label: Text(
'${comment.likeCount}',
style: const TextStyle(fontSize: 11),
),
),
if (!isReply)
TextButton(
key: Key('comment-reply-${comment.id}'),
style: TextButton.styleFrom(
foregroundColor: AppColors.blue,
textStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
),
padding: const EdgeInsets.symmetric(horizontal: 4),
minimumSize: const Size(0, 28),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: () => handlers.onReply(comment),
child: Text(l10n.reply),
),
],
),
if (comment.createdAt != null || !isReply)
Padding(
padding: const EdgeInsets.only(top: 4),
child: _metadata(context, l10n),
),
],
),
),
@@ -177,14 +90,14 @@ class CommentTile extends StatelessWidget {
}
Widget _avatar() {
final size = isReply ? 24.0 : 32.0;
final size = isReply ? 28.0 : 40.0;
if (comment.avatarUrl.isEmpty) {
return CircleAvatar(
radius: size / 2,
backgroundColor: AppColors.raised,
child: Icon(
Icons.person_rounded,
size: isReply ? 14 : 18,
size: isReply ? 16 : 22,
color: AppColors.cream,
),
);
@@ -197,4 +110,40 @@ class CommentTile extends StatelessWidget {
),
);
}
Widget _metadata(BuildContext context, AppLocalizations l10n) {
final createdAt = comment.createdAt;
return Row(
children: [
if (createdAt != null)
Text(
formatRelativeDateTime(createdAt, l10n),
style: const TextStyle(color: AppColors.subtle, fontSize: 12),
),
if (createdAt != null && !isReply) ...[
const SizedBox(width: 8),
const Text(
'·',
style: TextStyle(color: AppColors.muted, fontSize: 12),
),
const SizedBox(width: 8),
],
if (!isReply)
TextButton(
key: Key('comment-reply-${comment.id}'),
style: TextButton.styleFrom(
foregroundColor: AppColors.muted,
padding: EdgeInsets.zero,
minimumSize: const Size(0, 24),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: () => handlers.onReply(comment),
child: Text(
l10n.reply,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w400),
),
),
],
);
}
}
+319 -289
View File
@@ -1,299 +1,324 @@
// Author: fruit.com.cn
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../../core/data/models/comment.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/data/repositories/comment_repository.dart';
import '../../../core/data/repositories/interaction_repository.dart';
import '../../../core/data/services/comment_controller.dart';
import '../../../core/data/services/auth_controller.dart';
import '../../../core/data/services/comment_controller.dart';
import '../../../core/formatting/error_text.dart';
import '../../../core/platform/haptics.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/auth_gate.dart';
import '../../../core/widgets/empty_state.dart';
import '../../../l10n/app_localizations.dart';
import '../../interaction/widgets/report_dialog.dart';
import 'comment_composer_sheet.dart';
import 'comment_tile.dart';
const double _minSheetHeight = 280;
const double _maxSheetHeight = 520;
const int _maxCommentLength = 300;
const double _composerRadius = 12;
const double _composerHeight = 52;
const double _sendButtonSize = 40;
const double _composerRadius = 22;
const double _composerHeight = 44;
const double _sendButtonSize = 38;
const double _sheetRadius = 20;
const double _headerHeight = 44;
const double _replyIndent = 52;
const double _composerTopGap = 8;
/// 服务端评论底部弹窗:匿名列表、游标翻页、回复、点赞和本人删除。
/// 服务端评论底部弹窗:匿名列表、游标翻页、回复与登录发布。
class DramaCommentsSheet extends StatefulWidget {
const DramaCommentsSheet({super.key, required this.drama});
const DramaCommentsSheet({super.key, required this.drama, this.episodeId});
final Drama drama;
final String? episodeId;
@override
State<DramaCommentsSheet> createState() => _DramaCommentsSheetState();
}
class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
final _input = TextEditingController();
final _inputFocus = FocusNode();
late final CommentRepository _comments;
late final CommentController _controller;
DramaComment? _replyTo;
OverlayEntry? _composerOverlay;
OverlayEntry? _successOverlay;
Timer? _successOverlayTimer;
int? _serverCount;
int _countRequest = 0;
@override
void initState() {
super.initState();
_comments = context.read<CommentRepository>();
_controller = CommentController(
comments: context.read<CommentRepository>(),
comments: _comments,
interactions: context.read<InteractionRepository>(),
dramaId: widget.drama.id,
episodeId: widget.episodeId,
);
unawaited(_controller.load());
unawaited(_refreshCount());
}
@override
void dispose() {
_removeComposerOverlay();
_successOverlayTimer?.cancel();
_successOverlay?.remove();
_controller.dispose();
_input.dispose();
_inputFocus.dispose();
super.dispose();
}
Future<void> _post() async {
if (_controller.submitting) return;
if (!await ensureSignedIn(context) || !mounted) return;
if (_input.text.trim().isEmpty) return;
unawaited(context.read<Haptics>().lightImpact());
final result = await _controller.publish(_input.text, replyTo: _replyTo);
if (!mounted) return;
if (result.isSuccess) {
_input.clear();
setState(() => _replyTo = null);
FocusScope.of(context).unfocus();
return;
}
_showMessage(
result.errorMessage ?? AppLocalizations.of(context)!.commentPostFailed,
);
}
void _beginReply(DramaComment comment) {
setState(() => _replyTo = comment);
_inputFocus.requestFocus();
unawaited(_openComposer(replyTo: comment));
}
void _cancelReply() {
if (_replyTo == null) return;
setState(() => _replyTo = null);
Future<void> _refreshCount() async {
final request = ++_countRequest;
try {
final count = await _comments.count(
widget.drama.id,
episodeId: widget.episodeId,
);
if (!mounted || request != _countRequest) return;
setState(() => _serverCount = count);
} catch (_) {
if (!mounted || request != _countRequest) return;
setState(() => _serverCount = null);
}
}
Future<void> _authenticateForComment() async {
_inputFocus.unfocus();
Future<void> _openComposer({DramaComment? replyTo}) async {
await ensureSignedIn(context);
if (!mounted || !context.read<AuthController>().isSignedIn) return;
_inputFocus.requestFocus();
}
Future<void> _like(DramaComment comment) async {
if (!await ensureSignedIn(context) || !mounted) return;
unawaited(context.read<Haptics>().lightImpact());
final result = await _controller.toggleLike(comment);
if (!result.isSuccess && mounted) {
_showMessage(
result.errorMessage ?? AppLocalizations.of(context)!.commentPostFailed,
);
}
}
Future<void> _confirmDelete(DramaComment comment) async {
final l10n = AppLocalizations.of(context)!;
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(l10n.deleteCommentTitle),
content: Text(l10n.deleteCommentMessage),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(l10n.cancel),
),
FilledButton(
key: const Key('comment-delete-confirm'),
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(l10n.delete),
),
],
_removeComposerOverlay();
final entry = OverlayEntry(
builder: (_) => CommentComposerSheet(
controller: _controller,
replyTo: replyTo,
onDismiss: _closeComposer,
onPosted: _handleComposerPosted,
),
);
if (confirmed != true || !mounted) return;
final result = await _controller.delete(comment);
if (!result.isSuccess && mounted) {
_showMessage(
result.errorMessage ??
AppLocalizations.of(context)!.commentDeleteFailed,
);
setState(() => _composerOverlay = entry);
Overlay.of(context, rootOverlay: true).insert(entry);
}
void _handleComposerPosted() {
_closeComposer();
_showPostSuccess();
unawaited(_refreshCount());
}
void _closeComposer() {
if (!mounted) {
_removeComposerOverlay();
return;
}
setState(_removeComposerOverlay);
}
Future<void> _report(DramaComment comment) async {
if (!await ensureSignedIn(context) || !mounted) return;
void _removeComposerOverlay() {
final entry = _composerOverlay;
_composerOverlay = null;
if (entry?.mounted ?? false) entry!.remove();
FocusManager.instance.primaryFocus?.unfocus();
}
void _handleDismissLayerTap() {
Navigator.pop(context);
}
void _showPostSuccess() {
_successOverlayTimer?.cancel();
_successOverlay?.remove();
final l10n = AppLocalizations.of(context)!;
final draft = await showReportDialog(context, title: l10n.reportComment);
if (draft == null || !mounted) return;
unawaited(context.read<Haptics>().lightImpact());
final result = await _controller.report(
comment,
reason: draft.reason,
description: draft.description,
);
if (!mounted) return;
_showMessage(
result.isSuccess
? l10n.reportSubmitted
: result.errorMessage ?? l10n.reportFailed,
final entry = OverlayEntry(
builder: (_) =>
_CommentPostSuccessOverlay(message: l10n.commentPostSuccess),
);
_successOverlay = entry;
Overlay.of(context, rootOverlay: true).insert(entry);
_successOverlayTimer = Timer(const Duration(milliseconds: 1200), () {
if (entry.mounted) entry.remove();
if (identical(_successOverlay, entry)) _successOverlay = null;
});
}
void _showMessage(String message) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
double _sheetHeight(BuildContext context) {
final screenHeight = MediaQuery.sizeOf(context).height;
final topSafeArea = MediaQuery.paddingOf(context).top;
final halfHeight = (screenHeight * 0.5).roundToDouble();
final maxHeight = math.max(0.0, screenHeight - topSafeArea);
return math.min(halfHeight, maxHeight);
}
@override
Widget build(BuildContext context) {
final available =
MediaQuery.sizeOf(context).height -
MediaQuery.viewInsetsOf(context).bottom -
24;
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
child: Container(
key: const Key('player-comments-sheet'),
decoration: const BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.vertical(
top: Radius.circular(_sheetRadius),
final sheetHeight = _sheetHeight(context);
final content = Stack(
fit: StackFit.expand,
children: [
Positioned.fill(
child: GestureDetector(
key: const Key('player-comments-dismiss-layer'),
behavior: HitTestBehavior.opaque,
onTap: _handleDismissLayerTap,
),
),
child: SizedBox(
height: available.clamp(_minSheetHeight, _maxSheetHeight),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 12),
child: ListenableBuilder(
listenable: _controller,
builder: (context, _) => _content(context),
Align(
alignment: Alignment.bottomCenter,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {},
child: KeyedSubtree(
key: const Key('player-comments-sheet'),
child: SizedBox(
key: const Key('comment-sheet-constraints'),
height: sheetHeight,
child: Container(
decoration: const BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.vertical(
top: Radius.circular(_sheetRadius),
),
),
child: SafeArea(
top: false,
child: ListenableBuilder(
listenable: _controller,
builder: (context, _) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: _content(context),
);
},
),
),
),
),
),
),
),
),
],
);
return PopScope(
canPop: _composerOverlay == null,
onPopInvokedWithResult: (didPop, _) {
if (!didPop && _composerOverlay != null) {
FocusManager.instance.primaryFocus?.unfocus();
unawaited(
SystemChannels.textInput.invokeMethod<void>('TextInput.hide'),
);
}
},
child: content,
);
}
Widget _content(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final count = _controller.exactCount;
final count = _serverCount ?? _controller.exactCount;
final signedIn = context.watch<AuthController>().isSignedIn;
final composerReserve = _composerHeight + _composerTopGap;
return Column(
key: const Key('comment-sheet-content'),
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
key: const Key('player-comments-drag-handle'),
width: 36,
height: 4,
decoration: BoxDecoration(
color: AppColors.border,
borderRadius: BorderRadius.circular(999),
),
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Text(
SizedBox(
height: _headerHeight,
width: double.infinity,
child: Stack(
alignment: Alignment.center,
children: [
Text(
count == null ? l10n.comments : l10n.commentsWithCount(count),
key: count == null ? null : const Key('comment-count-label'),
key: const Key('comment-count-label'),
textAlign: TextAlign.center,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 15,
fontWeight: FontWeight.w700,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
IconButton(
key: const Key('player-comments-close'),
tooltip: l10n.close,
onPressed: () => Navigator.pop(context),
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.close_rounded, size: 20),
),
],
),
Text(
widget.drama.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.muted,
fontSize: 12,
fontWeight: FontWeight.w500,
Positioned(
left: 0,
child: SizedBox.square(
dimension: 44,
child: IconButton(
key: const Key('player-comments-close'),
tooltip: l10n.close,
padding: EdgeInsets.zero,
onPressed: () => Navigator.pop(context),
icon: const Icon(
Icons.keyboard_arrow_down_rounded,
size: 24,
color: AppColors.cream,
),
),
),
),
],
),
),
const SizedBox(height: 8),
Expanded(child: _list(l10n)),
if (_replyTo != null) _replyBanner(l10n, _replyTo!),
_composer(l10n),
Expanded(child: _list(l10n, composerReserve: composerReserve)),
Padding(
key: const Key('comment-composer-area'),
padding: const EdgeInsets.only(top: _composerTopGap),
child: _fakeComposer(l10n, signedIn: signedIn),
),
],
);
}
Widget _list(AppLocalizations l10n) {
Widget _list(AppLocalizations l10n, {required double composerReserve}) {
if (_controller.loading && _controller.comments.isEmpty) {
return const Center(
child: CircularProgressIndicator(key: Key('comment-list-loading')),
return const Padding(
padding: EdgeInsets.only(top: 4),
child: Center(
child: CircularProgressIndicator(key: Key('comment-list-loading')),
),
);
}
if (_controller.error != null && _controller.comments.isEmpty) {
return EmptyState(
key: const Key('comment-list-error'),
icon: Icons.cloud_off_rounded,
title: errorText(l10n, _controller.error, l10n.commentsLoadFailed),
actionLabel: l10n.retry,
actionKey: const Key('comment-list-retry'),
onAction: _controller.load,
compact: true,
return Padding(
padding: const EdgeInsets.only(top: 4),
child: EmptyState(
key: const Key('comment-list-error'),
icon: Icons.cloud_off_rounded,
title: errorText(l10n, _controller.error, l10n.commentsLoadFailed),
actionLabel: l10n.retry,
actionKey: const Key('comment-list-retry'),
onAction: _controller.load,
compact: true,
),
);
}
if (_controller.comments.isEmpty) {
return EmptyState(
key: const Key('comment-list-empty'),
icon: Icons.chat_bubble_outline_rounded,
title: l10n.noComments,
compact: true,
return Padding(
padding: const EdgeInsets.only(top: 4),
child: EmptyState(
key: const Key('comment-list-empty'),
icon: Icons.chat_bubble_outline_rounded,
title: l10n.noComments,
compact: true,
),
);
}
return ListView.builder(
key: const Key('comment-list'),
padding: EdgeInsets.only(top: 4, bottom: composerReserve),
itemCount: _controller.comments.length + 1,
itemBuilder: (context, index) {
if (index == _controller.comments.length) return _footer(l10n);
return _CommentThread(
comment: _controller.comments[index],
controller: _controller,
handlers: CommentInteractionHandlers(
onReply: _beginReply,
onLike: _like,
onDelete: _confirmDelete,
onReport: _report,
),
handlers: CommentInteractionHandlers(onReply: _beginReply),
);
},
);
@@ -316,93 +341,91 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
return const SizedBox(height: 8);
}
Widget _replyBanner(AppLocalizations l10n, DramaComment replyTo) => Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
children: [
Expanded(
child: Text(
l10n.replyingTo(
replyTo.nickname.isEmpty ? l10n.anonymousUser : replyTo.nickname,
Widget _fakeComposer(AppLocalizations l10n, {required bool signedIn}) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _openComposer,
child: Container(
key: const Key('comment-input-placeholder'),
height: _composerHeight,
padding: const EdgeInsets.only(right: 6),
decoration: BoxDecoration(
color: AppColors.commentInput,
borderRadius: BorderRadius.circular(_composerRadius),
),
child: Row(
children: [
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(left: 16),
child: Text(
signedIn ? l10n.writeComment : l10n.commentLoginHint,
key: const Key('comment-input-placeholder-label'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.muted,
fontSize: 14,
height: 1,
),
),
),
),
),
style: const TextStyle(color: AppColors.muted, fontSize: 12),
),
SizedBox.square(
dimension: _sendButtonSize,
child: IconButton.filled(
key: const Key('comment-post'),
tooltip: l10n.postComment,
padding: EdgeInsets.zero,
iconSize: 18,
onPressed: _openComposer,
icon: const Icon(Icons.send_rounded),
),
),
],
),
IconButton(
key: const Key('comment-cancel-reply'),
tooltip: l10n.cancelReply,
onPressed: _cancelReply,
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.close_rounded, size: 18),
),
],
),
);
InputDecoration _composerDecoration(String hint) {
const radius = BorderRadius.all(Radius.circular(_composerRadius));
const clear = OutlineInputBorder(
borderRadius: radius,
borderSide: BorderSide.none,
);
return InputDecoration(
hintText: hint,
hintStyle: const TextStyle(color: AppColors.muted, fontSize: 14),
counterText: '',
filled: true,
fillColor: AppColors.commentInput,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
isDense: true,
border: clear,
enabledBorder: clear,
focusedBorder: clear,
),
);
}
}
Widget _composer(AppLocalizations l10n) {
final signedIn = context.watch<AuthController>().isSignedIn;
return Row(
children: [
Expanded(
child: SizedBox(
height: _composerHeight,
child: TextField(
key: const Key('comment-input'),
controller: _input,
focusNode: _inputFocus,
readOnly: !signedIn,
canRequestFocus: signedIn,
maxLength: _maxCommentLength,
maxLines: 1,
textAlignVertical: TextAlignVertical.center,
style: const TextStyle(color: AppColors.cream, fontSize: 14),
cursorColor: AppColors.blue,
decoration: _composerDecoration(
signedIn ? l10n.writeComment : l10n.commentLoginHint,
class _CommentPostSuccessOverlay extends StatelessWidget {
const _CommentPostSuccessOverlay({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Positioned.fill(
child: IgnorePointer(
child: SafeArea(
child: Align(
alignment: const Alignment(0, 0.55),
child: Semantics(
liveRegion: true,
child: Container(
key: const Key('comment-post-success-snackbar'),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.82),
borderRadius: BorderRadius.circular(8),
),
child: Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white, fontSize: 14),
),
),
onTap: signedIn ? null : _authenticateForComment,
onSubmitted: (_) => _post(),
),
),
),
const SizedBox(width: 8),
SizedBox.square(
dimension: _sendButtonSize,
child: IconButton.filled(
key: const Key('comment-post'),
tooltip: l10n.postComment,
padding: EdgeInsets.zero,
iconSize: 18,
onPressed: _controller.submitting ? null : _post,
icon: _controller.submitting
? const SizedBox.square(
dimension: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.send_rounded),
),
),
],
),
);
}
}
@@ -425,39 +448,51 @@ class _CommentThread extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CommentTile(
comment: comment,
controller: controller,
handlers: handlers,
),
CommentTile(comment: comment, handlers: handlers),
if (comment.replyCount > 0 || replies.isNotEmpty)
Padding(
padding: const EdgeInsets.only(left: 42, bottom: 4),
padding: const EdgeInsets.only(left: _replyIndent, bottom: 4),
child: TextButton(
key: Key('comment-replies-${comment.id}'),
onPressed: () => _toggleReplies(context),
style: TextButton.styleFrom(
foregroundColor: AppColors.blue,
textStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
),
padding: const EdgeInsets.symmetric(horizontal: 4),
minimumSize: const Size(0, 28),
foregroundColor: AppColors.muted,
padding: EdgeInsets.zero,
minimumSize: const Size(0, 24),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(
expanded
? AppLocalizations.of(context)!.hideReplies
: AppLocalizations.of(
context,
)!.viewReplies(comment.replyCount),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(width: 16, height: 1, color: AppColors.border),
const SizedBox(width: 8),
Text(
expanded
? AppLocalizations.of(context)!.hideReplies
: AppLocalizations.of(
context,
)!.viewReplies(comment.replyCount),
style: const TextStyle(
color: AppColors.muted,
fontSize: 12,
fontWeight: FontWeight.w400,
),
),
const SizedBox(width: 4),
Icon(
expanded
? Icons.keyboard_arrow_up_rounded
: Icons.keyboard_arrow_down_rounded,
size: 14,
color: AppColors.muted,
),
],
),
),
),
if (expanded)
Padding(
padding: const EdgeInsets.only(left: 28),
padding: const EdgeInsets.only(left: _replyIndent),
child: Column(
children: [
if (controller.repliesLoading(comment.id))
@@ -468,7 +503,6 @@ class _CommentThread extends StatelessWidget {
for (final reply in replies)
CommentTile(
comment: reply,
controller: controller,
handlers: handlers,
isReply: true,
),
@@ -480,10 +514,6 @@ class _CommentThread extends StatelessWidget {
],
),
),
const Padding(
padding: EdgeInsets.only(left: 42),
child: Divider(height: 1, color: AppColors.border),
),
],
);
}
+29 -44
View File
@@ -2,6 +2,7 @@
import 'package:flutter/material.dart';
import '../../home/home_feed_channel.dart';
import '../../../l10n/app_localizations.dart';
const double _topIconSize = 36;
@@ -16,12 +17,16 @@ class HomeFeedHeader extends StatefulWidget {
const HomeFeedHeader({
super.key,
required this.l10n,
required this.channel,
required this.onChannelSelected,
required this.onRefresh,
required this.onSearch,
required this.onMore,
});
final AppLocalizations l10n;
final HomeFeedChannel channel;
final ValueChanged<HomeFeedChannel> onChannelSelected;
final Future<bool> Function() onRefresh;
final VoidCallback onSearch;
final VoidCallback onMore;
@@ -31,7 +36,6 @@ class HomeFeedHeader extends StatefulWidget {
}
class _HomeFeedHeaderState extends State<HomeFeedHeader> {
_FeedTab _tab = _FeedTab.recommend;
bool _refreshing = false;
@override
@@ -54,8 +58,12 @@ class _HomeFeedHeaderState extends State<HomeFeedHeader> {
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (var index = 0; index < _FeedTab.values.length; index++)
_tabSlot(_FeedTab.values[index], index),
for (
var index = 0;
index < HomeFeedChannel.values.length;
index++
)
_tabSlot(HomeFeedChannel.values[index], index),
],
),
),
@@ -74,15 +82,15 @@ class _HomeFeedHeaderState extends State<HomeFeedHeader> {
);
}
Widget _tabSlot(_FeedTab tab, int index) {
Widget _tabSlot(HomeFeedChannel channel, int index) {
final item = _FeedTabItem(
tab: tab,
label: tab.label(widget.l10n),
selected: _tab == tab,
channel: channel,
label: _label(channel),
selected: widget.channel == channel,
fontSize: _tabFontSize,
underlineWidth: _underlineWidth,
underlineHeight: _underlineHeight,
onTap: () => _selectTab(tab),
onTap: () => widget.onChannelSelected(channel),
);
if (index == 0) return item;
return Padding(
@@ -91,14 +99,6 @@ class _HomeFeedHeaderState extends State<HomeFeedHeader> {
);
}
void _selectTab(_FeedTab tab) {
if (tab.comingSoon) {
_showComingSoon();
return;
}
setState(() => _tab = tab);
}
Future<void> _refresh() async {
if (_refreshing) return;
setState(() => _refreshing = true);
@@ -106,31 +106,10 @@ class _HomeFeedHeaderState extends State<HomeFeedHeader> {
if (mounted) setState(() => _refreshing = false);
}
void _showComingSoon() {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(widget.l10n.comingSoon)));
}
}
/// 顶部频道 tab:漫剧点击提示即将上线。
enum _FeedTab {
anime,
real,
recommend;
bool get comingSoon => this == _FeedTab.anime;
String get keyName => switch (this) {
_FeedTab.anime => 'anime',
_FeedTab.real => 'real',
_FeedTab.recommend => 'recommend',
};
String label(AppLocalizations l10n) => switch (this) {
_FeedTab.anime => l10n.feedTabAnime,
_FeedTab.real => l10n.feedTabReal,
_FeedTab.recommend => l10n.feedTabForYou,
String _label(HomeFeedChannel channel) => switch (channel) {
HomeFeedChannel.anime => widget.l10n.feedTabAnime,
HomeFeedChannel.live => widget.l10n.feedTabReal,
HomeFeedChannel.recommend => widget.l10n.feedTabForYou,
};
}
@@ -223,7 +202,7 @@ class _RefreshButton extends StatelessWidget {
class _FeedTabItem extends StatelessWidget {
const _FeedTabItem({
required this.tab,
required this.channel,
required this.label,
required this.selected,
required this.fontSize,
@@ -232,7 +211,7 @@ class _FeedTabItem extends StatelessWidget {
required this.onTap,
});
final _FeedTab tab;
final HomeFeedChannel channel;
final String label;
final bool selected;
final double fontSize;
@@ -243,7 +222,7 @@ class _FeedTabItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
return GestureDetector(
key: Key('home-feed-tab-${tab.keyName}'),
key: Key('home-feed-tab-${_keyName(channel)}'),
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Column(
@@ -272,4 +251,10 @@ class _FeedTabItem extends StatelessWidget {
),
);
}
String _keyName(HomeFeedChannel channel) => switch (channel) {
HomeFeedChannel.anime => 'anime',
HomeFeedChannel.live => 'real',
HomeFeedChannel.recommend => 'recommend',
};
}
@@ -1,5 +1,7 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
@@ -10,6 +12,7 @@ import '../../../core/formatting/localized_content.dart';
import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
import 'interaction_rail.dart';
import 'loading_progress_track.dart';
part 'home_feed_overlay_parts.dart';
@@ -170,8 +173,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
items: [
InteractionRailItem(
key: const Key('rail-save'),
icon: Icons.star_border_rounded,
activeIcon: Icons.star_rounded,
asset: 'assets/icon/rail/rail_save.png',
label: widget.l10n.favorite,
count: widget.favoriteCount,
countKey: const Key('rail-save-count'),
@@ -180,8 +182,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
),
InteractionRailItem(
key: const Key('rail-comments'),
icon: Icons.chat_bubble_outline_rounded,
activeIcon: Icons.chat_bubble_rounded,
asset: 'assets/icon/rail/rail_comment.png',
label: widget.l10n.comments,
count: widget.commentCount,
countKey: const Key('rail-comments-count'),
@@ -189,8 +190,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
),
InteractionRailItem(
key: const Key('rail-like'),
icon: Icons.favorite_border_rounded,
activeIcon: Icons.favorite_rounded,
asset: 'assets/icon/rail/rail_like.png',
label: widget.l10n.like,
count: widget.likeCount,
countKey: const Key('rail-like-count'),
@@ -199,8 +199,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
),
InteractionRailItem(
key: const Key('rail-share'),
icon: Icons.ios_share_rounded,
activeIcon: Icons.ios_share_rounded,
asset: 'assets/icon/rail/rail_share.png',
label: widget.l10n.share,
onTap: widget.onShare,
),
@@ -28,7 +28,36 @@ class _FeedProgressBar extends StatefulWidget {
}
class _FeedProgressBarState extends State<_FeedProgressBar> {
static const _bufferDebounce = Duration(milliseconds: 350);
double? _preview;
Timer? _bufferTimer;
bool _buffering = false;
@override
void initState() {
super.initState();
_attachSource();
}
@override
void didUpdateWidget(covariant _FeedProgressBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.controller != widget.controller) {
_detachSource(oldWidget.controller);
_bufferTimer?.cancel();
_bufferTimer = null;
_buffering = false;
_attachSource();
}
}
@override
void dispose() {
_detachSource(widget.controller);
_bufferTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
@@ -36,11 +65,18 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
builder: (context, constraints) {
final width = constraints.maxWidth;
final source = widget.controller;
final scrubbing = _preview != null;
final loading =
source == null ||
(!scrubbing && (!source.value.isInitialized || _buffering));
final track = source == null
? _track(0, width)
? _loadingTrack()
: ValueListenableBuilder<VideoPlayerValue>(
valueListenable: source,
builder: (context, value, _) {
if (!scrubbing && (!value.isInitialized || _buffering)) {
return _loadingTrack();
}
final total = value.duration.inMilliseconds;
final played = total <= 0
? 0.0
@@ -53,20 +89,97 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
return GestureDetector(
key: const Key('home-feed-progress'),
behavior: HitTestBehavior.opaque,
onHorizontalDragStart: (details) {
widget.onScrubStart?.call();
_update(details.localPosition.dx / width);
},
onHorizontalDragUpdate: (details) =>
_update(details.localPosition.dx / width),
onHorizontalDragEnd: (_) => _commit(_preview),
onHorizontalDragCancel: () => setState(() => _preview = null),
onHorizontalDragStart: loading
? null
: (details) {
widget.onScrubStart?.call();
_update(details.localPosition.dx / width);
},
onHorizontalDragUpdate: loading
? null
: (details) => _update(details.localPosition.dx / width),
onHorizontalDragEnd: loading ? null : (_) => _commit(_preview),
onHorizontalDragCancel: loading
? null
: () => setState(() => _preview = null),
child: track,
);
},
);
}
void _attachSource() {
widget.controller?.addListener(_handleSourceChanged);
_syncBufferState(notify: false);
}
void _detachSource(ValueListenable<VideoPlayerValue>? source) {
source?.removeListener(_handleSourceChanged);
}
void _handleSourceChanged() => _syncBufferState();
void _syncBufferState({bool notify = true}) {
final source = widget.controller;
if (source == null) {
_bufferTimer?.cancel();
_bufferTimer = null;
_setBuffering(false, notify: notify);
return;
}
final value = source.value;
if (!value.isInitialized) {
_bufferTimer?.cancel();
_bufferTimer = null;
_setBuffering(true, notify: notify);
return;
}
if (!value.isBuffering) {
_bufferTimer?.cancel();
_bufferTimer = null;
_setBuffering(false, notify: notify);
return;
}
if (_buffering || _bufferTimer != null) return;
_bufferTimer = Timer(_bufferDebounce, () {
_bufferTimer = null;
final current = widget.controller;
if (!mounted ||
current != source ||
!current!.value.isInitialized ||
!current.value.isBuffering) {
return;
}
_setBuffering(true);
});
}
void _setBuffering(bool buffering, {bool notify = true}) {
if (_buffering == buffering) return;
if (!notify || !mounted) {
_buffering = buffering;
return;
}
setState(() => _buffering = buffering);
}
Widget _loadingTrack() {
final bottom = _lineCenterFromBottom - widget.height / 2;
return SizedBox(
height: widget.hitHeight,
child: Stack(
children: [
Positioned(
left: 0,
right: 0,
bottom: bottom,
child: LoadingProgressTrack(height: widget.height),
),
],
),
);
}
void _update(double fraction) {
setState(() => _preview = fraction.clamp(0.0, 1.0).toDouble());
}
+13 -16
View File
@@ -5,17 +5,15 @@ import 'package:flutter/material.dart';
import '../../../core/formatting/count_format.dart';
import '../../../core/theme/app_colors.dart';
const double _buttonSize = 34;
const double _iconSize = 24;
const double _itemGap = 18;
const Color _buttonBackground = Color(0x80000000);
const double _buttonSize = 44;
const double _iconSize = 32;
const double _itemGap = 24;
/// 互动栏单项:图标、计数与点击。
class InteractionRailItem {
const InteractionRailItem({
required this.key,
required this.icon,
required this.activeIcon,
required this.asset,
required this.label,
required this.onTap,
this.count,
@@ -24,8 +22,7 @@ class InteractionRailItem {
});
final Key key;
final IconData icon;
final IconData activeIcon;
final String asset;
final String label;
final VoidCallback onTap;
final int? count;
@@ -69,13 +66,13 @@ class _RailButton extends StatelessWidget {
child: IconButton(
onPressed: item.onTap,
padding: EdgeInsets.zero,
style: IconButton.styleFrom(
backgroundColor: _buttonBackground,
foregroundColor: item.selected ? AppColors.blue : Colors.white,
),
icon: Icon(
item.selected ? item.activeIcon : item.icon,
size: _iconSize,
icon: Image.asset(
item.asset,
width: _iconSize,
height: _iconSize,
fit: BoxFit.contain,
color: item.selected ? AppColors.blue : Colors.white,
colorBlendMode: BlendMode.srcIn,
),
),
),
@@ -88,7 +85,7 @@ class _RailButton extends StatelessWidget {
style: const TextStyle(
color: Colors.white,
height: 1,
fontSize: 11,
fontSize: 12,
fontWeight: FontWeight.w600,
shadows: [Shadow(blurRadius: 6, color: Colors.black)],
),
@@ -0,0 +1,102 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
/// 播放未就绪时沿进度轨道循环移动的加载高光。
class LoadingProgressTrack extends StatefulWidget {
const LoadingProgressTrack({
super.key = const Key('progress-loading'),
required this.height,
this.trackColor = Colors.white24,
this.highlightColor = AppColors.blue,
});
final double height;
final Color trackColor;
final Color highlightColor;
@override
State<LoadingProgressTrack> createState() => _LoadingProgressTrackState();
}
class _LoadingProgressTrackState extends State<LoadingProgressTrack>
with SingleTickerProviderStateMixin, WidgetsBindingObserver {
late final AnimationController _controller;
late final Animation<double> _position;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
);
_position = CurvedAnimation(parent: _controller, curve: Curves.easeInOut);
WidgetsBinding.instance.addObserver(this);
_syncAnimation();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
_syncAnimation();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_controller.dispose();
super.dispose();
}
void _syncAnimation() {
if (WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed) {
if (!_controller.isAnimating) _controller.repeat();
} else {
_controller.stop();
}
}
@override
Widget build(BuildContext context) {
return SizedBox(
height: widget.height,
child: ClipRRect(
borderRadius: BorderRadius.circular(widget.height / 2),
child: LayoutBuilder(
builder: (context, constraints) {
final highlightWidth = constraints.maxWidth * 0.28;
final travel = constraints.maxWidth + highlightWidth;
return AnimatedBuilder(
animation: _position,
builder: (context, _) {
final left = -highlightWidth + travel * _position.value;
return Stack(
fit: StackFit.expand,
children: [
ColoredBox(
key: const Key('progress-loading-track'),
color: widget.trackColor,
),
Positioned(
left: left,
top: 0,
bottom: 0,
width: highlightWidth,
child: ColoredBox(
key: const Key('progress-loading-highlight'),
color: widget.highlightColor,
),
),
],
);
},
);
},
),
),
);
}
}
+141 -125
View File
@@ -6,12 +6,21 @@ import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
const double _sheetRadius = 20;
const double _rowHeight = 44;
const double _rowHeight = 56;
const double _groupGap = 12;
const double _segmentHeight = 28;
const double _groupVerticalPadding = 6;
const double _groupRadius = 16;
const double _rowHorizontalPadding = 14;
const double _rowIconSize = 22;
const double _rowIconGap = 14;
const double _rowLabelSize = 15;
const double _rowChevronGap = 10;
const double _segmentTrackHeight = 34;
const double _segmentHeight = 26;
const double _segmentRadius = 8;
const double _segmentTrackRadius = 10;
const double _segmentTrackPadding = 4;
const double _segmentPadding = 10;
const double _segmentGap = 6;
const List<double> _speedOptions = [0.75, 1.0, 1.25, 1.5, 2.0, 3.0];
const List<String> _qualityOptions = ['AUTO', '720P', '1080P'];
const String _autoQuality = 'AUTO';
@@ -82,7 +91,7 @@ class _PlayerMoreSheetState extends State<PlayerMoreSheet> {
final l10n = widget.l10n;
return Material(
key: const Key('player-more-sheet'),
color: AppColors.card,
color: AppColors.ink,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(_sheetRadius),
),
@@ -90,7 +99,7 @@ class _PlayerMoreSheetState extends State<PlayerMoreSheet> {
child: SafeArea(
top: false,
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
padding: const EdgeInsets.fromLTRB(12, 8, 12, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -105,31 +114,30 @@ class _PlayerMoreSheetState extends State<PlayerMoreSheet> {
),
),
),
const SizedBox(height: 8),
_header(l10n),
_speedPicker(),
const SizedBox(height: 4),
_qualityPicker(l10n),
const SizedBox(height: 12),
_group([_speedPicker(), _qualityPicker(l10n)]),
const SizedBox(height: _groupGap),
_actionRow(
key: const Key('player-more-clear'),
icon: Icons.fullscreen_rounded,
title: l10n.clearPlayback,
onTap: _clear,
),
if (widget.actions.onPip != null)
_group([
_actionRow(
key: const Key('player-more-pip'),
icon: Icons.picture_in_picture_alt_rounded,
title: l10n.pictureInPicture,
onTap: () => _closeThen(widget.actions.onPip!),
key: const Key('player-more-clear'),
icon: Icons.fullscreen,
title: l10n.clearPlayback,
onTap: _clear,
),
_actionRow(
key: const Key('player-more-offline'),
icon: Icons.download_rounded,
title: l10n.offlineCache,
onTap: () => _closeThen(widget.actions.onDownload),
),
if (widget.actions.onPip != null)
_actionRow(
key: const Key('player-more-pip'),
icon: Icons.picture_in_picture_alt_outlined,
title: l10n.pictureInPicture,
onTap: () => _closeThen(widget.actions.onPip!),
),
_actionRow(
key: const Key('player-more-offline'),
icon: Icons.download_outlined,
title: l10n.offlineCache,
onTap: () => _closeThen(widget.actions.onDownload),
),
]),
],
),
),
@@ -137,35 +145,21 @@ class _PlayerMoreSheetState extends State<PlayerMoreSheet> {
);
}
Widget _header(AppLocalizations l10n) => Row(
children: [
Expanded(
child: Text(
l10n.more,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
),
SizedBox.square(
dimension: 32,
child: IconButton(
key: const Key('player-more-close'),
tooltip: l10n.close,
padding: EdgeInsets.zero,
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close_rounded, size: 18),
),
),
],
Widget _group(List<Widget> rows) => Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: _groupVerticalPadding),
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(_groupRadius),
),
child: Column(mainAxisSize: MainAxisSize.min, children: rows),
);
Widget _speedPicker() => _optionRow(
rowKey: const Key('player-more-speed-row'),
optionsKey: const Key('player-more-speed'),
icon: Icons.speed_rounded,
trackKey: const Key('player-more-speed-track'),
icon: Icons.speed_outlined,
title: widget.l10n.playbackSpeed,
options: [
for (final value in _speedOptions)
@@ -184,7 +178,8 @@ class _PlayerMoreSheetState extends State<PlayerMoreSheet> {
Widget _qualityPicker(AppLocalizations l10n) => _optionRow(
rowKey: const Key('player-more-quality-row'),
optionsKey: const Key('player-more-quality'),
icon: Icons.high_quality_rounded,
trackKey: const Key('player-more-quality-track'),
icon: Icons.high_quality_outlined,
title: l10n.playbackQuality,
options: [
for (final quality in _qualityOptions) _qualitySegment(l10n, quality),
@@ -194,6 +189,7 @@ class _PlayerMoreSheetState extends State<PlayerMoreSheet> {
Widget _optionRow({
required Key rowKey,
required Key optionsKey,
required Key trackKey,
required IconData icon,
required String title,
required List<Widget> options,
@@ -201,45 +197,52 @@ class _PlayerMoreSheetState extends State<PlayerMoreSheet> {
return SizedBox(
key: rowKey,
height: _rowHeight,
child: Row(
children: [
SizedBox(
width: 88,
child: Row(
children: [
Icon(icon, color: AppColors.muted, size: 18),
const SizedBox(width: 6),
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 12,
fontWeight: FontWeight.w600,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: _rowHorizontalPadding),
child: Row(
children: [
Icon(icon, color: AppColors.onSurface, size: _rowIconSize),
const SizedBox(width: _rowIconGap),
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: _rowLabelSize,
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: _rowChevronGap),
Expanded(
flex: 2,
child: Align(
alignment: Alignment.centerRight,
child: SingleChildScrollView(
key: optionsKey,
scrollDirection: Axis.horizontal,
child: Container(
key: trackKey,
height: _segmentTrackHeight,
padding: const EdgeInsets.symmetric(
horizontal: _segmentTrackPadding,
),
decoration: BoxDecoration(
color: AppColors.raised,
borderRadius: BorderRadius.circular(_segmentTrackRadius),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: options,
),
),
),
],
),
),
const SizedBox(width: 6),
Expanded(
child: SingleChildScrollView(
key: optionsKey,
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (var index = 0; index < options.length; index++) ...[
if (index > 0) const SizedBox(width: _segmentGap),
options[index],
],
],
),
),
),
],
],
),
),
);
}
@@ -275,26 +278,34 @@ class _PlayerMoreSheetState extends State<PlayerMoreSheet> {
onTap: onTap,
child: SizedBox(
height: _rowHeight,
child: Row(
children: [
Icon(icon, color: AppColors.muted, size: 20),
const SizedBox(width: 12),
Expanded(
child: Text(
title,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 14,
fontWeight: FontWeight.w600,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: _rowHorizontalPadding,
),
child: Row(
children: [
Icon(icon, color: AppColors.onSurface, size: _rowIconSize),
const SizedBox(width: _rowIconGap),
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: _rowLabelSize,
fontWeight: FontWeight.w500,
),
),
),
),
const Icon(
Icons.chevron_right_rounded,
color: AppColors.subtle,
size: 18,
),
],
const SizedBox(width: _rowChevronGap),
const Icon(
Icons.chevron_right_rounded,
color: AppColors.subtle,
size: _rowIconSize,
),
],
),
),
),
),
@@ -330,36 +341,41 @@ class _SegmentButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final background = selected
? AppColors.blue
: enabled
? AppColors.raised
: AppColors.cardAlt;
final foreground = selected
? AppColors.onPrimary
: enabled
? AppColors.onSurface
: enabled
? AppColors.muted
: AppColors.subtle;
return Material(
color: background,
color: Colors.transparent,
borderRadius: BorderRadius.circular(_segmentRadius),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(_segmentRadius),
child: SizedBox(
child: Container(
height: _segmentHeight,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: _segmentPadding),
child: Center(
child: Text(
label,
maxLines: 1,
style: TextStyle(
color: foreground,
fontSize: 12,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
),
),
padding: const EdgeInsets.symmetric(horizontal: _segmentPadding),
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected ? AppColors.card : Colors.transparent,
borderRadius: BorderRadius.circular(_segmentRadius),
boxShadow: selected
? const [
BoxShadow(
color: Color(0x14000000),
blurRadius: 4,
offset: Offset(0, 1),
),
]
: null,
),
child: Text(
label,
maxLines: 1,
style: TextStyle(
color: foreground,
fontSize: 13,
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
),
),
),
+5 -9
View File
@@ -141,14 +141,13 @@ class PlayerPlaybackOverlay extends StatelessWidget {
final compact = MediaQuery.sizeOf(context).height < 700;
return Positioned(
right: AppDimens.spaceMd,
top: MediaQuery.sizeOf(context).height * (compact ? 0.42 : 0.55),
top: MediaQuery.sizeOf(context).height * (compact ? 0.30 : 0.43),
child: InteractionRail(
key: const Key('player-interaction-rail'),
items: [
InteractionRailItem(
key: const Key('player-rail-save'),
icon: Icons.star_border_rounded,
activeIcon: Icons.star_rounded,
asset: 'assets/icon/rail/rail_save.png',
label: data.l10n.favorite,
count: data.favoriteCount,
countKey: const Key('player-rail-save-count'),
@@ -157,8 +156,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
),
InteractionRailItem(
key: const Key('player-rail-comments'),
icon: Icons.chat_bubble_outline_rounded,
activeIcon: Icons.chat_bubble_rounded,
asset: 'assets/icon/rail/rail_comment.png',
label: data.l10n.comments,
count: data.commentCount,
countKey: const Key('player-rail-comments-count'),
@@ -166,8 +164,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
),
InteractionRailItem(
key: const Key('player-rail-like'),
icon: Icons.favorite_border_rounded,
activeIcon: Icons.favorite_rounded,
asset: 'assets/icon/rail/rail_like.png',
label: data.l10n.like,
count: data.likeCount,
countKey: const Key('player-rail-like-count'),
@@ -176,8 +173,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
),
InteractionRailItem(
key: const Key('player-rail-share'),
icon: Icons.ios_share_rounded,
activeIcon: Icons.ios_share_rounded,
asset: 'assets/icon/rail/rail_share.png',
label: data.l10n.share,
onTap: actions.onShare,
),
@@ -4,25 +4,21 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
import '../../../core/widgets/remote_image.dart';
const Duration _videoFadeDuration = Duration(milliseconds: 200);
const double _errorHorizontal = 32;
const int _errorMaxLines = 3;
/// 封面底层与视频层共用同一控制器,视频首帧就绪后再淡入。
/// 黑色底层与视频层共用同一控制器,视频首帧就绪后再淡入。
class PlayerVideoSurface extends StatefulWidget {
const PlayerVideoSurface({
super.key,
required this.controller,
required this.posterUrl,
required this.videoUrl,
required this.theater,
required this.onTap,
});
final VideoPlayerController? controller;
final String posterUrl;
final String videoUrl;
final bool theater;
final VoidCallback onTap;
@@ -57,7 +53,10 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface> {
return Stack(
fit: StackFit.expand,
children: [
_FallbackBackground(posterUrl: widget.posterUrl),
const ColoredBox(
key: Key('player-fallback-background'),
color: Colors.black,
),
AnimatedOpacity(
key: const Key('player-video-opacity'),
opacity: reveal ? 1 : 0,
@@ -164,35 +163,3 @@ class _VideoLayer extends StatelessWidget {
);
}
}
class _FallbackBackground extends StatelessWidget {
const _FallbackBackground({required this.posterUrl});
final String posterUrl;
@override
Widget build(BuildContext context) {
if (posterUrl.trim().isEmpty) {
return const ColoredBox(
key: Key('player-fallback-background'),
color: Colors.black,
);
}
return Stack(
key: const Key('player-fallback-background'),
fit: StackFit.expand,
children: [
RemoteImage(
posterUrl,
key: const Key('player-fallback-cover'),
fit: BoxFit.cover,
fallbackColor: Colors.black,
),
const ColoredBox(
key: Key('player-fallback-scrim'),
color: Color(0x4D000000),
),
],
);
}
}
+127 -42
View File
@@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
import '../../../core/theme/app_colors.dart';
import 'loading_progress_track.dart';
/// Keeps seeks within the playable range so dragging to the edge does not
/// trigger the player's completion callback before playback resumes.
@@ -43,22 +44,94 @@ class SeekProgressBar extends StatefulWidget {
}
class _SeekProgressBarState extends State<SeekProgressBar> {
static const _bufferDebounce = Duration(milliseconds: 350);
final _previewFraction = ValueNotifier<double?>(null);
Timer? _bufferTimer;
bool _buffering = false;
@override
void initState() {
super.initState();
_attachSource();
}
@override
void didUpdateWidget(covariant SeekProgressBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.valueListenable != widget.valueListenable) {
_detachSource(oldWidget.valueListenable);
_previewFraction.value = null;
_bufferTimer?.cancel();
_bufferTimer = null;
_buffering = false;
_attachSource();
}
}
@override
void dispose() {
_detachSource(widget.valueListenable);
_bufferTimer?.cancel();
_previewFraction.dispose();
super.dispose();
}
void _attachSource() {
widget.valueListenable?.addListener(_handleSourceChanged);
_syncBufferState(notify: false);
}
void _detachSource(ValueListenable<VideoPlayerValue>? source) {
source?.removeListener(_handleSourceChanged);
}
void _handleSourceChanged() => _syncBufferState();
void _syncBufferState({bool notify = true}) {
final source = widget.valueListenable;
if (source == null) {
_bufferTimer?.cancel();
_bufferTimer = null;
_setBuffering(false, notify: notify);
return;
}
final value = source.value;
if (!value.isInitialized) {
_bufferTimer?.cancel();
_bufferTimer = null;
_setBuffering(true, notify: notify);
return;
}
if (!value.isBuffering) {
_bufferTimer?.cancel();
_bufferTimer = null;
_setBuffering(false, notify: notify);
return;
}
if (_buffering || _bufferTimer != null) return;
_bufferTimer = Timer(_bufferDebounce, () {
_bufferTimer = null;
final current = widget.valueListenable;
if (!mounted ||
current != source ||
!current!.value.isInitialized ||
!current.value.isBuffering) {
return;
}
_setBuffering(true);
});
}
void _setBuffering(bool buffering, {bool notify = true}) {
if (_buffering == buffering) return;
if (!notify || !mounted) {
_buffering = buffering;
return;
}
setState(() => _buffering = buffering);
}
Future<void> _finish(double fraction, Duration duration) async {
final source = widget.valueListenable;
_previewFraction.value = fraction;
@@ -92,6 +165,8 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
Widget _buildTrack(BuildContext context, VideoPlayerValue video) {
final total = video.duration.inMilliseconds;
final scrubbing = _previewFraction.value != null;
final loading = !scrubbing && (!video.isInitialized || _buffering);
final enabled = video.isInitialized && total > 0 && widget.onSeek != null;
final played = total > 0
? (video.position.inMilliseconds / total).clamp(0.0, 1.0)
@@ -100,7 +175,14 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
final previewPosition = _previewFraction.value == null
? video.position
: seekPositionForFraction(fraction, video.duration);
final scrubbing = _previewFraction.value != null;
final trackHeight = widget.compact
? (scrubbing ? 3.0 : 1.5)
: (scrubbing ? 4.0 : 3.0);
final trackAreaHeight = loading
? (widget.compact ? 20.0 : 44.0)
: widget.compact
? (scrubbing ? 32.0 : 20.0)
: (scrubbing ? 48.0 : 44.0);
return Column(
mainAxisSize: MainAxisSize.min,
@@ -120,47 +202,50 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
),
),
SizedBox(
height: widget.compact
? (scrubbing ? 32 : 20)
: (scrubbing ? 48 : 44),
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: widget.compact
? (scrubbing ? 3 : 1.5)
: (scrubbing ? 4 : 3),
activeTrackColor: AppColors.blue,
inactiveTrackColor: widget.inactiveTrackColor,
thumbColor: AppColors.onPrimary,
thumbShape: RoundSliderThumbShape(
enabledThumbRadius: widget.compact
? (scrubbing ? 6 : 4)
: (scrubbing ? 8 : 6),
),
overlayShape: RoundSliderOverlayShape(
overlayRadius: widget.compact ? 12 : 18,
),
),
child: Semantics(
label: widget.semanticsLabel,
child: Slider(
value: fraction,
// 轨道与上方文案共用同一层水平内边距,避免 Slider 默认内缩。
padding: EdgeInsets.zero,
onChangeStart: enabled
? (value) {
_previewFraction.value = value;
widget.onScrubStart?.call();
}
: null,
onChanged: enabled
? (value) => _previewFraction.value = value
: null,
onChangeEnd: enabled
? (value) => unawaited(_finish(value, video.duration))
: null,
),
),
),
height: trackAreaHeight,
child: loading
? Center(
child: LoadingProgressTrack(
height: trackHeight,
trackColor: widget.inactiveTrackColor,
),
)
: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: trackHeight,
activeTrackColor: AppColors.blue,
inactiveTrackColor: widget.inactiveTrackColor,
thumbColor: AppColors.onPrimary,
thumbShape: RoundSliderThumbShape(
enabledThumbRadius: widget.compact
? (scrubbing ? 6 : 4)
: (scrubbing ? 8 : 6),
),
overlayShape: RoundSliderOverlayShape(
overlayRadius: widget.compact ? 12 : 18,
),
),
child: Semantics(
label: widget.semanticsLabel,
child: Slider(
value: fraction,
// 轨道与上方文案共用同一层水平内边距,避免 Slider 默认内缩。
padding: EdgeInsets.zero,
onChangeStart: enabled
? (value) {
_previewFraction.value = value;
widget.onScrubStart?.call();
}
: null,
onChanged: enabled
? (value) => _previewFraction.value = value
: null,
onChangeEnd: enabled
? (value) => unawaited(_finish(value, video.duration))
: null,
),
),
),
),
],
);
+1
View File
@@ -83,6 +83,7 @@ flutter:
assets:
- assets/icon/splash_logo.png
- assets/icon/splash_logo_android12.png
- assets/icon/rail/
# To add assets to your application, add an assets section, like this:
# assets:
@@ -4,6 +4,7 @@ import 'dart:async';
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/repositories/catalog_repository.dart';
import 'package:fruitshorts/core/data/services/catalog_controller.dart';
@@ -136,6 +137,14 @@ class ScriptedCatalogRepository implements CatalogRepository {
@override
Future<HomeFeed> fetchDiscoverFeed() async => feed;
@override
Future<FeedPage> fetchFeed({
List<String> types = const [],
String? categoryId,
String? cursor,
int size = 10,
}) async => const FeedPage();
@override
Future<Drama> fetchDrama(String dramaId) async {
detailCalls++;
+14 -1
View File
@@ -20,12 +20,16 @@ void main() {
'title': '第 1 集',
'isFree': true,
'playUrl': '/files/episode/9.mp4',
'likeCount': '18',
'commentCount': 4,
},
});
expect(drama.detailLoaded, isFalse);
expect(drama.episodes, hasLength(1));
expect(drama.episodes.single.videoUrl, '/files/episode/9.mp4');
expect(drama.episodes.single.likeCount, 18);
expect(drama.episodes.single.commentCount, 4);
expect(drama.episodeCount, 77);
expect(drama.isHot, isTrue);
expect(drama.language, 'zh-CN');
@@ -50,7 +54,14 @@ void main() {
'cover': '/files/cover.jpg',
'totalEpisodes': 77,
'episodes': [
{'id': 9, 'number': 1, 'title': '第 1 集', 'isFree': true},
{
'id': 9,
'number': 1,
'title': '第 1 集',
'isFree': true,
'likeCount': '21',
'commentCount': '6',
},
{'id': 10, 'number': 2, 'title': '第 2 集', 'isFree': false},
],
});
@@ -58,5 +69,7 @@ void main() {
expect(drama.detailLoaded, isTrue);
expect(drama.episodeCount, 77);
expect(drama.episodes.first.id, '9');
expect(drama.episodes.first.likeCount, 21);
expect(drama.episodes.first.commentCount, 6);
});
}
+80
View File
@@ -0,0 +1,80 @@
// 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/catalog_api.dart';
import '../support/fake_http_adapter.dart';
void main() {
String envelope(Object? data) =>
jsonEncode({'code': 0, 'message': 'ok', 'data': data});
test('should_send_types_and_cursor_when_feed_query_has_filters', () async {
final adapter = FakeHttpAdapter(
(_) async => FakeHttpResponse(
envelope({
'list': [
{
'id': '11',
'title': 'Anime',
'cover': '/files/anime.png',
'preview': {
'id': '12',
'number': 1,
'title': 'Episode 1',
'isFree': true,
},
},
],
'nextCursor': 10,
'hasMore': true,
}),
),
);
final api = CatalogApi(
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
);
final page = await api.feed(
types: const ['ANIME', 'LIVE'],
categoryId: '7',
cursor: '9',
size: 20,
);
expect(adapter.requests.single.uri.path, '/app/feed');
expect(adapter.requests.single.queryParameters, {
'types': 'ANIME,LIVE',
'categoryId': '7',
'cursor': '9',
'size': '20',
});
expect(page.dramas.single.id, '11');
expect(page.nextCursor, '10');
expect(page.hasMore, isTrue);
});
test(
'should_omit_empty_types_and_treat_null_cursor_string_as_empty',
() async {
final adapter = FakeHttpAdapter(
(_) async => FakeHttpResponse(
envelope({'list': [], 'nextCursor': 'null', 'hasMore': false}),
),
);
final api = CatalogApi(
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
);
final page = await api.feed();
expect(adapter.requests.single.queryParameters, {'size': '10'});
expect(page.dramas, isEmpty);
expect(page.nextCursor, isNull);
expect(page.hasMore, isFalse);
},
);
}
+39 -1
View File
@@ -55,12 +55,18 @@ void main() {
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
);
final page = await api.page(dramaId: '5', cursor: '99', size: 20);
final page = await api.page(
dramaId: '5',
episodeId: '7',
cursor: '99',
size: 20,
);
expect(adapter.requests.single.method, 'GET');
expect(adapter.requests.single.uri.path, '/app/comments');
expect(adapter.requests.single.queryParameters, {
'dramaId': '5',
'episodeId': '7',
'cursor': '99',
'size': '20',
});
@@ -93,6 +99,7 @@ void main() {
final reply = await api.publish(
dramaId: '5',
episodeId: '7',
content: 'great badword',
parentId: '2111012753262120962',
);
@@ -101,6 +108,7 @@ void main() {
expect(adapter.requests.single.uri.path, '/app/comments');
expect(adapter.requests.single.data, {
'dramaId': '5',
'episodeId': '7',
'content': 'great badword',
'parentId': '2111012753262120962',
});
@@ -126,4 +134,34 @@ void main() {
'/app/comments/2111012753262120962',
);
});
test('should_parse_numeric_comment_count', () async {
final adapter = FakeHttpAdapter(
(_) async => FakeHttpResponse(envelope(17)),
);
final api = CommentApi(
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
);
final count = await api.count('5', episodeId: '7');
expect(count, 17);
expect(adapter.requests.single.method, 'GET');
expect(adapter.requests.single.uri.path, '/app/comments/count');
expect(adapter.requests.single.queryParameters, {
'dramaId': '5',
'episodeId': '7',
});
});
test('should_parse_string_comment_count', () async {
final adapter = FakeHttpAdapter(
(_) async => FakeHttpResponse(envelope('2111012753262120962')),
);
final api = CommentApi(
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
);
expect(await api.count('5'), 2111012753262120962);
});
}
+38
View File
@@ -12,10 +12,12 @@ void main() {
CommentController controller(
FakeCommentRepository comments, {
FakeInteractionRepository? interactions,
String? episodeId,
}) => CommentController(
comments: comments,
interactions: interactions ?? FakeInteractionRepository(),
dramaId: '5',
episodeId: episodeId,
);
test('should_append_next_page_with_cursor_when_has_more_is_true', () async {
@@ -63,6 +65,42 @@ void main() {
model.dispose();
});
test('should_pass_episode_id_to_every_page_and_publish_request', () async {
final root = commentFixture(id: '10', replyCount: 1);
final repository = FakeCommentRepository(
pages: [
CommentPage(
comments: [root],
nextCursor: '10',
hasMore: true,
),
CommentPage(comments: [commentFixture(id: '9')]),
CommentPage(
comments: [commentFixture(id: '11', rootId: '10')],
nextCursor: '11',
hasMore: true,
),
CommentPage(comments: [commentFixture(id: '12', rootId: '10')]),
],
publishResult: commentFixture(id: '13', mine: true),
);
final model = controller(repository, episodeId: 'episode-7');
await model.load();
await model.loadMore();
await model.loadReplies('10');
await model.loadMoreReplies('10');
await model.publish('Nice episode');
expect(
repository.pageCalls.map((call) => call.episodeId),
everyElement('episode-7'),
);
expect(repository.pageCalls, hasLength(4));
expect(repository.publishCalls.single.episodeId, 'episode-7');
model.dispose();
});
test('should_use_server_content_and_pending_status_when_review_is_returned', () async {
final repository = FakeCommentRepository(
publishResult: commentFixture(
File diff suppressed because it is too large. Load diff
+30 -9
View File
@@ -62,8 +62,8 @@ void main() {
);
});
test('should_use_the_configured_public_address_for_device_default', () {
expect(AppConfig.devHostBaseUrl, 'http://39.162.50.99:8081');
test('should_use_the_configured_local_address_for_device_default', () {
expect(AppConfig.devHostBaseUrl, 'http://192.168.31.187:8081');
});
test('should_skip_probe_when_compiled_override_is_set', () async {
@@ -111,7 +111,11 @@ void main() {
},
);
expect(attempts, [AppConfig.lanBaseUrl, AppConfig.devHostBaseUrl]);
expect(attempts, [
AppConfig.lanBaseUrl,
AppConfig.devHostBaseUrl,
AppConfig.publicBaseUrl,
]);
expect(AppConfig.apiBaseUrl, AppConfig.publicBaseUrl);
});
@@ -129,7 +133,7 @@ void main() {
attempts.add(baseUrl);
active++;
if (active > maxActive) maxActive = active;
if (active == 2) allStarted.complete();
if (active == 3) allStarted.complete();
await allStarted.future;
active--;
return baseUrl == AppConfig.lanBaseUrl ||
@@ -139,8 +143,12 @@ void main() {
await probing.timeout(const Duration(seconds: 1));
expect(attempts, [AppConfig.lanBaseUrl, AppConfig.devHostBaseUrl]);
expect(maxActive, 2);
expect(attempts, [
AppConfig.lanBaseUrl,
AppConfig.devHostBaseUrl,
AppConfig.publicBaseUrl,
]);
expect(maxActive, 3);
expect(AppConfig.apiBaseUrl, AppConfig.lanBaseUrl);
},
);
@@ -156,7 +164,11 @@ void main() {
},
);
expect(attempts, [AppConfig.lanBaseUrl, AppConfig.devHostBaseUrl]);
expect(attempts, [
AppConfig.lanBaseUrl,
AppConfig.devHostBaseUrl,
AppConfig.publicBaseUrl,
]);
expect(AppConfig.apiBaseUrl, AppConfig.publicBaseUrl);
});
@@ -171,7 +183,11 @@ void main() {
},
);
expect(attempts, [AppConfig.lanBaseUrl, AppConfig.devHostBaseUrl]);
expect(attempts, [
AppConfig.lanBaseUrl,
AppConfig.devHostBaseUrl,
AppConfig.publicBaseUrl,
]);
expect(AppConfig.apiBaseUrl, AppConfig.devHostBaseUrl);
});
@@ -187,7 +203,12 @@ void main() {
},
);
expect(attempts, [custom, AppConfig.lanBaseUrl, AppConfig.devHostBaseUrl]);
expect(attempts, [
custom,
AppConfig.lanBaseUrl,
AppConfig.devHostBaseUrl,
AppConfig.publicBaseUrl,
]);
expect(AppConfig.apiBaseUrl, custom);
});
+127 -63
View File
@@ -9,76 +9,140 @@ import 'package:fruitshorts/core/widgets/app_pull_to_refresh.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
void main() {
testWidgets(
'should_show_pull_release_and_refreshing_states_and_refresh_once',
(tester) async {
final refresh = Completer<void>();
var calls = 0;
await tester.pumpWidget(
MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: AppPullToRefresh(
onRefresh: () {
calls++;
return refresh.future;
},
child: ListView(
key: const Key('pull-refresh-list'),
physics: AppPullToRefresh.physics,
children: const [SizedBox(height: 1200)],
),
testWidgets('should_freeze_pull_offset_until_refresh_completes', (
tester,
) async {
expect(AppPullToRefresh.physics.parent, isA<ClampingScrollPhysics>());
final refresh = Completer<void>();
var calls = 0;
await tester.pumpWidget(
MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: AppPullToRefresh(
onRefresh: () {
calls++;
return refresh.future;
},
child: ListView(
key: const Key('pull-refresh-list'),
physics: AppPullToRefresh.physics,
children: const [SizedBox(height: 1200)],
),
),
),
);
),
);
final list = find.byKey(const Key('pull-refresh-list'));
final gesture = await tester.startGesture(tester.getCenter(list));
await gesture.moveBy(const Offset(0, 30));
await tester.pump();
final scrollable = tester.state<ScrollableState>(
find.descendant(of: list, matching: find.byType(Scrollable)).first,
);
expect(scrollable.position.pixels, lessThan(0));
expect(find.text('Pull to refresh'), findsOneWidget);
expect(
tester.widget<Text>(find.text('Pull to refresh')).style?.color,
AppColors.blue,
);
final list = find.byKey(const Key('pull-refresh-list'));
final gesture = await tester.startGesture(tester.getCenter(list));
await gesture.moveBy(const Offset(0, 30));
await tester.pump();
final scrollable = tester.state<ScrollableState>(
find.descendant(of: list, matching: find.byType(Scrollable)).first,
);
expect(scrollable.position.pixels, 0);
expect(find.text('Pull to refresh'), findsOneWidget);
expect(_contentOffset(tester), greaterThan(0));
expect(
tester.widget<Text>(find.text('Pull to refresh')).style?.color,
AppColors.blue,
);
await gesture.moveBy(const Offset(0, 120));
await tester.pump();
expect(find.text('Release to refresh'), findsOneWidget);
expect(
tester.widget<Text>(find.text('Release to refresh')).style?.color,
AppColors.blue,
);
await gesture.moveBy(const Offset(0, 120));
await tester.pump();
expect(find.text('Release to refresh'), findsOneWidget);
expect(
tester.widget<Text>(find.text('Release to refresh')).style?.color,
AppColors.blue,
);
await gesture.up();
await tester.pump();
expect(find.text('Refreshing…'), findsOneWidget);
expect(
tester.widget<Text>(find.text('Refreshing…')).style?.color,
AppColors.blue,
);
expect(calls, 1);
await gesture.up();
await tester.pump();
expect(find.text('Refreshing…'), findsOneWidget);
expect(
tester.widget<Text>(find.text('Refreshing…')).style?.color,
AppColors.blue,
);
expect(calls, 1);
final releaseOffset = _contentOffset(tester);
expect(releaseOffset, greaterThan(56));
expect(find.byType(CircularProgressIndicator), findsOneWidget);
final duplicate = await tester.startGesture(
tester.getCenter(find.byKey(const Key('pull-refresh-list'))),
);
await duplicate.moveBy(const Offset(0, 70));
await duplicate.up();
await tester.pump();
expect(calls, 1);
await tester.pump(const Duration(milliseconds: 300));
expect(_contentOffset(tester), closeTo(releaseOffset, 0.1));
refresh.complete();
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
expect(find.text('Refreshing…'), findsNothing);
expect(find.text('Pull to refresh'), findsNothing);
},
final duplicate = await tester.startGesture(
tester.getCenter(find.byKey(const Key('pull-refresh-list'))),
);
await duplicate.moveBy(const Offset(0, 70));
await duplicate.up();
await tester.pump();
expect(calls, 1);
expect(_contentOffset(tester), closeTo(releaseOffset, 0.1));
refresh.complete();
await tester.pump();
await tester.pumpAndSettle();
expect(_contentOffset(tester), closeTo(0, 0.1));
expect(find.text('Refreshing…'), findsNothing);
expect(find.text('Pull to refresh'), findsNothing);
});
testWidgets('should_animate_back_when_released_below_threshold', (
tester,
) async {
var calls = 0;
await tester.pumpWidget(
MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: AppPullToRefresh(
onRefresh: () async {
calls++;
},
child: ListView(
key: const Key('pull-refresh-list'),
physics: AppPullToRefresh.physics,
children: const [SizedBox(height: 1200)],
),
),
),
),
);
final list = find.byKey(const Key('pull-refresh-list'));
final gesture = await tester.startGesture(tester.getCenter(list));
await gesture.moveBy(const Offset(0, 30));
await tester.pump();
final releaseOffset = _contentOffset(tester);
expect(releaseOffset, greaterThan(0));
expect(releaseOffset, lessThan(56));
expect(find.text('Pull to refresh'), findsOneWidget);
await gesture.up();
await tester.pump();
expect(calls, 0);
expect(find.text('Refreshing…'), findsNothing);
expect(_contentOffset(tester), greaterThan(0));
await tester.pump(const Duration(milliseconds: 100));
expect(_contentOffset(tester), greaterThan(0));
expect(_contentOffset(tester), lessThan(releaseOffset));
await tester.pumpAndSettle();
expect(_contentOffset(tester), closeTo(0, 0.1));
expect(find.text('Pull to refresh'), findsNothing);
});
}
double _contentOffset(WidgetTester tester) {
final transform = tester.widget<Transform>(
find.byKey(const Key('pull-refresh-content')),
);
return transform.transform.storage[13];
}
@@ -0,0 +1,320 @@
// Author: fruit.com.cn
import 'dart:async';
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/repositories/catalog_repository.dart';
import 'package:fruitshorts/core/data/services/home_feed_controller.dart';
import 'package:fruitshorts/pages/home/home_feed_channel.dart';
void main() {
test(
'should_seed_recommend_without_requesting_when_initial_feed_is_ready',
() async {
final repository = _ScriptedFeedRepository();
final controller = HomeFeedController(repository);
controller.seedRecommend([_drama('seed')]);
expect(controller.channel, HomeFeedChannel.recommend);
expect(controller.dramas.map((drama) => drama.id), ['seed']);
expect(controller.loaded, isTrue);
expect(repository.calls, isEmpty);
controller.dispose();
},
);
test(
'should_use_prefetched_channel_without_another_request_on_first_select',
() async {
final repository = _ScriptedFeedRepository()
..enqueue(() async => FeedPage(dramas: [_drama('anime')]))
..enqueue(() async => FeedPage(dramas: [_drama('live')]));
final controller = HomeFeedController(repository);
controller.seedRecommend([_drama('seed')]);
await controller.prefetchChannels();
await controller.selectChannel(HomeFeedChannel.anime);
expect(controller.dramas.single.id, 'anime');
expect(repository.calls.map((call) => call.types), [
['ANIME'],
['LIVE'],
]);
controller.dispose();
},
);
test('should_keep_recommend_when_prefetch_fails', () async {
final repository = _ScriptedFeedRepository()
..enqueue(() async => throw StateError('offline'))
..enqueue(() async => FeedPage(dramas: [_drama('live')]));
final controller = HomeFeedController(repository);
controller.seedRecommend([_drama('seed')]);
await controller.prefetchChannels();
expect(controller.channel, HomeFeedChannel.recommend);
expect(controller.dramas.single.id, 'seed');
expect(controller.lastError, isNull);
controller.dispose();
});
test(
'should_wait_prefetch_without_duplicate_request_when_channel_is_selected',
() async {
final anime = Completer<FeedPage>();
final repository = _ScriptedFeedRepository()
..enqueue(() => anime.future)
..enqueue(() async => FeedPage(dramas: [_drama('live')]));
final controller = HomeFeedController(repository);
controller.seedRecommend([_drama('seed')]);
final prefetch = controller.prefetchChannels();
final select = controller.selectChannel(HomeFeedChannel.anime);
anime.complete(FeedPage(dramas: [_drama('anime')]));
await prefetch;
await select;
expect(
repository.calls.where((call) => call.types.contains('ANIME')),
hasLength(1),
);
expect(controller.dramas.single.id, 'anime');
controller.dispose();
},
);
test('should_request_anime_live_and_recommend_with_expected_types', () async {
final repository = _ScriptedFeedRepository()
..enqueue(() async => FeedPage(dramas: [_drama('anime')]))
..enqueue(() async => FeedPage(dramas: [_drama('live')]))
..enqueue(() async => FeedPage(dramas: [_drama('recommend')]));
final controller = HomeFeedController(repository);
controller.seedRecommend([_drama('seed')]);
await controller.selectChannel(HomeFeedChannel.anime);
await controller.selectChannel(HomeFeedChannel.live);
await controller.selectChannel(HomeFeedChannel.recommend);
await pumpEventQueue();
expect(repository.calls.map((call) => call.types), [
['ANIME'],
['LIVE'],
[],
]);
controller.dispose();
});
test(
'should_keep_cached_data_while_silently_refreshing_cached_channel',
() async {
final refresh = Completer<FeedPage>();
final repository = _ScriptedFeedRepository()
..enqueue(() async => FeedPage(dramas: [_drama('anime-old')]))
..enqueue(() async => FeedPage(dramas: [_drama('live')]))
..enqueue(() => refresh.future);
final controller = HomeFeedController(repository);
await controller.selectChannel(HomeFeedChannel.anime);
await controller.selectChannel(HomeFeedChannel.live);
await controller.selectChannel(HomeFeedChannel.anime);
expect(controller.dramas.single.id, 'anime-old');
refresh.complete(FeedPage(dramas: [_drama('anime-new')]));
await pumpEventQueue();
expect(controller.dramas.single.id, 'anime-new');
controller.dispose();
},
);
test('should_append_unique_items_and_stop_when_has_more_is_false', () async {
final repository = _ScriptedFeedRepository()
..enqueue(
() async => FeedPage(
dramas: [_drama('a'), _drama('b')],
nextCursor: 'next',
hasMore: true,
),
)
..enqueue(
() async =>
FeedPage(dramas: [_drama('b'), _drama('c')], hasMore: false),
);
final controller = HomeFeedController(repository);
await controller.selectChannel(HomeFeedChannel.anime);
await controller.loadMore();
await controller.loadMore();
expect(controller.dramas.map((drama) => drama.id), ['a', 'b', 'c']);
expect(repository.calls, hasLength(2));
expect(repository.calls.last.cursor, 'next');
controller.dispose();
});
test(
'should_cache_both_channels_when_responses_complete_out_of_order',
() async {
final anime = Completer<FeedPage>();
final live = Completer<FeedPage>();
final animeRefresh = Completer<FeedPage>();
final repository = _ScriptedFeedRepository()
..enqueue(() => anime.future)
..enqueue(() => live.future)
..enqueue(() => animeRefresh.future);
final controller = HomeFeedController(repository);
final animeLoad = controller.selectChannel(HomeFeedChannel.anime);
final liveLoad = controller.selectChannel(HomeFeedChannel.live);
live.complete(FeedPage(dramas: [_drama('live')]));
await liveLoad;
anime.complete(FeedPage(dramas: [_drama('anime')]));
await animeLoad;
expect(controller.channel, HomeFeedChannel.live);
expect(controller.dramas.single.id, 'live');
await controller.selectChannel(HomeFeedChannel.anime);
expect(controller.dramas.single.id, 'anime');
animeRefresh.complete(FeedPage(dramas: [_drama('anime-refresh')]));
await pumpEventQueue();
expect(controller.dramas.single.id, 'anime-refresh');
controller.dispose();
},
);
test('should_retry_automatically_when_returning_to_failed_channel', () async {
final repository = _ScriptedFeedRepository()
..enqueue(() async => throw StateError('offline'))
..enqueue(() async => FeedPage(dramas: [_drama('live')]))
..enqueue(() async => FeedPage(dramas: [_drama('anime-retry')]));
final controller = HomeFeedController(repository);
await controller.selectChannel(HomeFeedChannel.anime);
expect(controller.lastError, contains('offline'));
expect(controller.loadingFirstPage, isFalse);
await controller.selectChannel(HomeFeedChannel.live);
await controller.selectChannel(HomeFeedChannel.anime);
expect(controller.dramas.single.id, 'anime-retry');
expect(repository.calls, hasLength(3));
controller.dispose();
});
test('should_not_report_first_page_loading_without_inflight_request', () {
final controller = HomeFeedController(_ScriptedFeedRepository());
expect(controller.loaded, isFalse);
expect(controller.loadingFirstPage, isFalse);
controller.dispose();
});
test('should_finish_loading_with_error_when_request_times_out', () async {
final pending = Completer<FeedPage>();
final repository = _ScriptedFeedRepository()..enqueue(() => pending.future);
final controller = HomeFeedController(
repository,
requestTimeout: const Duration(milliseconds: 10),
);
await controller.selectChannel(HomeFeedChannel.anime);
expect(controller.loadingFirstPage, isFalse);
expect(controller.loaded, isTrue);
expect(controller.lastError, isNotNull);
controller.dispose();
});
test(
'should_ignore_old_failure_when_same_channel_new_request_succeeds',
() async {
final oldRefresh = Completer<FeedPage>();
final repository = _ScriptedFeedRepository()
..enqueue(
() async => FeedPage(
dramas: [_drama('a')],
nextCursor: 'next',
hasMore: true,
),
)
..enqueue(() async => FeedPage(dramas: [_drama('live')]))
..enqueue(() => oldRefresh.future)
..enqueue(() async => FeedPage(dramas: [_drama('b')], hasMore: false));
final controller = HomeFeedController(repository);
await controller.selectChannel(HomeFeedChannel.anime);
await controller.selectChannel(HomeFeedChannel.live);
await controller.selectChannel(HomeFeedChannel.anime);
await controller.loadMore();
oldRefresh.completeError(StateError('old failure'));
await pumpEventQueue();
expect(controller.dramas.map((drama) => drama.id), ['a', 'b']);
expect(controller.lastError, isNull);
controller.dispose();
},
);
test('should_keep_old_data_when_refresh_fails', () async {
final repository = _ScriptedFeedRepository()
..enqueue(() async => FeedPage(dramas: [_drama('old')]))
..enqueue(() async => throw StateError('offline'));
final controller = HomeFeedController(repository);
await controller.selectChannel(HomeFeedChannel.anime);
final refreshed = await controller.refreshCurrent();
expect(refreshed, isFalse);
expect(controller.dramas.single.id, 'old');
expect(controller.lastError, contains('offline'));
controller.dispose();
});
}
class FeedCall {
const FeedCall({
required this.types,
required this.cursor,
required this.size,
});
final List<String> types;
final String? cursor;
final int size;
}
class _ScriptedFeedRepository implements CatalogRepository {
final List<FeedCall> calls = [];
final List<Future<FeedPage> Function()> _responses = [];
void enqueue(Future<FeedPage> Function() response) {
_responses.add(response);
}
@override
Future<FeedPage> fetchFeed({
List<String> types = const [],
String? categoryId,
String? cursor,
int size = 10,
}) {
calls.add(FeedCall(types: types, cursor: cursor, size: size));
return _responses.removeAt(0)();
}
@override
Future<HomeFeed> fetchHomeFeed() async => const HomeFeed();
@override
Future<HomeFeed> fetchDiscoverFeed() async => const HomeFeed();
@override
Future<Drama> fetchDrama(String dramaId) async => _drama(dramaId);
}
Drama _drama(String id) =>
Drama(id: id, title: id, synopsis: id, posterUrl: '');
@@ -0,0 +1,180 @@
// Author: fruit.com.cn
import 'dart:async';
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/repositories/catalog_repository.dart';
import 'package:fruitshorts/core/data/services/theater_feed_controller.dart';
void main() {
test('should_request_all_and_selected_category_with_expected_ids', () async {
final repository = _ScriptedTheaterRepository()
..enqueue(() async => FeedPage(dramas: [_drama('all')]))
..enqueue(() async => FeedPage(dramas: [_drama('category')]));
final controller = TheaterFeedController(repository);
await controller.select(null);
await controller.select('7');
expect(repository.calls.map((call) => call.categoryId), [null, '7']);
expect(controller.dramas.single.id, 'category');
controller.dispose();
});
test('should_keep_cached_category_visible_while_refreshing', () async {
final refresh = Completer<FeedPage>();
final repository = _ScriptedTheaterRepository()
..enqueue(() async => FeedPage(dramas: [_drama('all-old')]))
..enqueue(() async => FeedPage(dramas: [_drama('category')]))
..enqueue(() => refresh.future);
final controller = TheaterFeedController(repository);
await controller.select(null);
await controller.select('7');
await controller.select(null);
expect(controller.dramas.single.id, 'all-old');
refresh.complete(FeedPage(dramas: [_drama('all-new')]));
await pumpEventQueue();
expect(controller.dramas.single.id, 'all-new');
controller.dispose();
});
test('should_append_unique_items_and_stop_when_has_more_is_false', () async {
final repository = _ScriptedTheaterRepository()
..enqueue(
() async => FeedPage(
dramas: [_drama('a'), _drama('b')],
nextCursor: 'next',
hasMore: true,
),
)
..enqueue(
() async =>
FeedPage(dramas: [_drama('b'), _drama('c')], hasMore: false),
);
final controller = TheaterFeedController(repository);
await controller.select('7');
await controller.loadMore();
await controller.loadMore();
expect(controller.dramas.map((drama) => drama.id), ['a', 'b', 'c']);
expect(repository.calls, hasLength(2));
expect(repository.calls.last.categoryId, '7');
expect(repository.calls.last.cursor, 'next');
controller.dispose();
});
test('should_keep_old_data_when_refresh_fails', () async {
final repository = _ScriptedTheaterRepository()
..enqueue(() async => FeedPage(dramas: [_drama('old')]))
..enqueue(() async => throw StateError('offline'));
final controller = TheaterFeedController(repository);
await controller.select('7');
final refreshed = await controller.refreshCurrent();
expect(refreshed, isFalse);
expect(controller.dramas.single.id, 'old');
expect(controller.lastError, contains('offline'));
controller.dispose();
});
test(
'should_retry_automatically_when_returning_to_failed_category',
() async {
final repository = _ScriptedTheaterRepository()
..enqueue(() async => FeedPage(dramas: [_drama('all')]))
..enqueue(() async => throw StateError('offline'))
..enqueue(() async => FeedPage(dramas: [_drama('category-8')]))
..enqueue(() async => FeedPage(dramas: [_drama('category-7-retry')]));
final controller = TheaterFeedController(repository);
await controller.select(null);
await controller.select('7');
expect(controller.lastError, contains('offline'));
await controller.select('8');
await controller.select('7');
expect(controller.dramas.single.id, 'category-7-retry');
expect(repository.calls, hasLength(4));
controller.dispose();
},
);
test('should_finish_loading_with_error_when_request_times_out', () async {
final pending = Completer<FeedPage>();
final repository = _ScriptedTheaterRepository()
..enqueue(() => pending.future);
final controller = TheaterFeedController(
repository,
requestTimeout: const Duration(milliseconds: 10),
);
await controller.select(null);
expect(controller.loadingFirstPage, isFalse);
expect(controller.loaded, isTrue);
expect(controller.lastError, isNotNull);
controller.dispose();
});
test('should_not_report_first_page_loading_without_request', () {
final controller = TheaterFeedController(_ScriptedTheaterRepository());
expect(controller.loaded, isFalse);
expect(controller.loadingFirstPage, isFalse);
controller.dispose();
});
}
class TheaterFeedCall {
const TheaterFeedCall({
required this.categoryId,
required this.cursor,
required this.size,
});
final String? categoryId;
final String? cursor;
final int size;
}
class _ScriptedTheaterRepository implements CatalogRepository {
final List<TheaterFeedCall> calls = [];
final List<Future<FeedPage> Function()> _responses = [];
void enqueue(Future<FeedPage> Function() response) {
_responses.add(response);
}
@override
Future<FeedPage> fetchFeed({
List<String> types = const [],
String? categoryId,
String? cursor,
int size = 10,
}) {
calls.add(
TheaterFeedCall(categoryId: categoryId, cursor: cursor, size: size),
);
return _responses.removeAt(0)();
}
@override
Future<HomeFeed> fetchHomeFeed() async => const HomeFeed();
@override
Future<HomeFeed> fetchDiscoverFeed() async => const HomeFeed();
@override
Future<Drama> fetchDrama(String dramaId) async => _drama(dramaId);
}
Drama _drama(String id) =>
Drama(id: id, title: id, synopsis: id, posterUrl: '');
+249 -6
View File
@@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/app.dart';
import 'package:fruitshorts/core/data/models/content_category.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/repositories/category_repository.dart';
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
@@ -72,11 +73,20 @@ void main() {
testWidgets('should_filter_grid_when_category_chip_is_tapped', (
tester,
) async {
await _pumpTheater(tester, categoryRepository: _GridCategoryRepository());
final catalog = _CategoryTheaterCatalog();
await _pumpTheater(
tester,
catalogRepository: catalog,
categoryRepository: _GridCategoryRepository(),
);
await tester.tap(find.byKey(const Key('theater-category-7')));
await tester.pumpAndSettle();
expect(
catalog.requestedCategoryIds.where((categoryId) => categoryId == '7'),
hasLength(1),
);
expect(
find.byKey(const Key('home-drama-card-city-lights-demo')),
findsOneWidget,
@@ -92,6 +102,9 @@ void main() {
) async {
await _pumpTheater(
tester,
catalogRepository: _SinglePageTheaterCatalog(
dramas: _tallDiscoverFeed.dramas,
),
categoryRepository: _GridCategoryRepository(
discoverFeed: _tallDiscoverFeed,
),
@@ -121,6 +134,7 @@ void main() {
testWidgets('should_show_empty_and_retry_states', (tester) async {
await _pumpTheater(
tester,
catalogRepository: _SinglePageTheaterCatalog(dramas: const []),
categoryRepository: _GridCategoryRepository(
discoverFeed: const HomeFeed(),
),
@@ -144,6 +158,18 @@ void main() {
expect(find.textContaining('discover feed unavailable'), findsOneWidget);
});
testWidgets('should_show_retry_when_theater_feed_fails', (tester) async {
await _pumpTheater(
tester,
catalogRepository: _FailingTheaterCatalog(),
categoryRepository: _GridCategoryRepository(),
);
expect(find.byKey(const Key('home-categories-error')), findsOneWidget);
expect(find.text('Retry'), findsOneWidget);
expect(find.textContaining('theater feed unavailable'), findsWidgets);
});
testWidgets('should_show_search_results_when_text_changes', (tester) async {
await _pumpTheater(tester, categoryRepository: _GridCategoryRepository());
@@ -161,19 +187,63 @@ void main() {
);
});
testWidgets('should_load_next_theater_page_near_scroll_bottom', (
tester,
) async {
final catalog = _PagedTheaterCatalog([
FeedPage(dramas: _pageDramas(0, 20), nextCursor: '20', hasMore: true),
FeedPage(dramas: _pageDramas(20, 20)),
]);
await _pumpTheater(
tester,
catalogRepository: catalog,
categoryRepository: _GridCategoryRepository(),
);
final position = tester
.state<ScrollableState>(
find
.descendant(
of: find.byKey(const Key('discover-refresh')),
matching: find.byType(Scrollable),
)
.first,
)
.position;
position.jumpTo(position.maxScrollExtent);
await tester.pump();
await tester.pumpAndSettle();
expect(catalog.calls, 2);
expect(catalog.requestedCursors, [null, '20']);
expect(find.byKey(const Key('home-drama-card-page-39')), findsOneWidget);
});
testWidgets('should_refresh_feed_when_pulled_down', (tester) async {
final counter = _CountingCategoryRepository(
_GridCategoryRepository(discoverFeed: _tallDiscoverFeed),
);
await _pumpTheater(tester, categoryRepository: counter);
final theaterCounter = _CountingTheaterCatalog(
dramas: _tallDiscoverFeed.dramas,
);
await _pumpTheater(
tester,
catalogRepository: theaterCounter,
categoryRepository: counter,
);
final before = counter.categoryLoads + counter.feedLoads;
final beforeCategories = counter.categoryLoads + counter.feedLoads;
final beforeTheater = theaterCounter.feedCalls;
await tester.drag(_verticalFeed, const Offset(0, 320));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(counter.categoryLoads + counter.feedLoads, greaterThan(before));
expect(
counter.categoryLoads + counter.feedLoads,
greaterThan(beforeCategories),
);
expect(theaterCounter.feedCalls, beforeTheater + 1);
expect(find.byType(RefreshProgressIndicator), findsNothing);
});
@@ -187,7 +257,7 @@ void main() {
.decoration!
as BoxDecoration;
expect(shell().borderRadius, BorderRadius.circular(22));
expect(shell().borderRadius, BorderRadius.circular(12));
expect(shell().color, AppColors.card);
expect((shell().border! as Border).top.color, Colors.transparent);
expect(
@@ -205,6 +275,9 @@ void main() {
testWidgets('should_pin_theater_filters_when_scrolling', (tester) async {
await _pumpTheater(
tester,
catalogRepository: _SinglePageTheaterCatalog(
dramas: _tallDiscoverFeed.dramas,
),
categoryRepository: _GridCategoryRepository(
discoverFeed: _tallDiscoverFeed,
),
@@ -243,6 +316,9 @@ void main() {
testWidgets('should_keep_category_row_stable_across_scrolls', (tester) async {
await _pumpTheater(
tester,
catalogRepository: _SinglePageTheaterCatalog(
dramas: _tallDiscoverFeed.dramas,
),
categoryRepository: _GridCategoryRepository(
discoverFeed: _tallDiscoverFeed,
),
@@ -357,12 +433,13 @@ final Finder _verticalFeed = find.byWidgetPredicate(
Future<void> _pumpTheater(
WidgetTester tester, {
required CategoryRepository categoryRepository,
FakeCatalogRepository? catalogRepository,
}) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final catalog = FakeCatalogRepository();
final catalog = catalogRepository ?? FakeCatalogRepository();
await tester.pumpWidget(
LumaDramaApp(
playerFactory: createWidgetPlayer,
@@ -469,3 +546,169 @@ class _CountingCategoryRepository implements CategoryRepository {
return delegate.fetchDiscoverFeed();
}
}
class _TheaterOnlyCatalog extends FakeCatalogRepository {
_TheaterOnlyCatalog({super.dramas});
final FakeCatalogRepository _homeDelegate = FakeCatalogRepository();
@override
Future<HomeFeed> fetchHomeFeed() => _homeDelegate.fetchHomeFeed();
@override
Future<HomeFeed> fetchDiscoverFeed() => _homeDelegate.fetchDiscoverFeed();
@override
Future<FeedPage> fetchFeed({
List<String> types = const [],
String? categoryId,
String? cursor,
int size = 10,
}) {
return _homeDelegate.fetchFeed(
types: types,
categoryId: categoryId,
cursor: cursor,
size: size,
);
}
}
class _SinglePageTheaterCatalog extends _TheaterOnlyCatalog {
_SinglePageTheaterCatalog({required List<Drama> dramas})
: super(dramas: dramas);
@override
Future<FeedPage> fetchFeed({
List<String> types = const [],
String? categoryId,
String? cursor,
int size = 10,
}) async {
if (size != 20) {
return super.fetchFeed(
types: types,
categoryId: categoryId,
cursor: cursor,
size: size,
);
}
return FeedPage(dramas: dramas.take(size).toList());
}
}
class _CategoryTheaterCatalog extends _TheaterOnlyCatalog {
final List<String?> requestedCategoryIds = [];
@override
Future<FeedPage> fetchFeed({
List<String> types = const [],
String? categoryId,
String? cursor,
int size = 10,
}) async {
if (size != 20) {
return super.fetchFeed(
types: types,
categoryId: categoryId,
cursor: cursor,
size: size,
);
}
requestedCategoryIds.add(categoryId);
final rows = categoryId == '7'
? dramas.where((drama) => drama.id == 'city-lights-demo').toList()
: dramas;
return FeedPage(dramas: rows.take(size).toList());
}
}
class _CountingTheaterCatalog extends _TheaterOnlyCatalog {
_CountingTheaterCatalog({required List<Drama> dramas})
: super(dramas: dramas);
int feedCalls = 0;
@override
Future<FeedPage> fetchFeed({
List<String> types = const [],
String? categoryId,
String? cursor,
int size = 10,
}) {
if (size != 20) {
return super.fetchFeed(
types: types,
categoryId: categoryId,
cursor: cursor,
size: size,
);
}
feedCalls++;
return super.fetchFeed(
types: types,
categoryId: categoryId,
cursor: cursor,
size: size,
);
}
}
class _PagedTheaterCatalog extends _TheaterOnlyCatalog {
_PagedTheaterCatalog(this.pages);
final List<FeedPage> pages;
final List<String?> requestedCursors = [];
int get calls => requestedCursors.length;
@override
Future<FeedPage> fetchFeed({
List<String> types = const [],
String? categoryId,
String? cursor,
int size = 10,
}) async {
if (size != 20) {
return super.fetchFeed(
types: types,
categoryId: categoryId,
cursor: cursor,
size: size,
);
}
requestedCursors.add(cursor);
return pages.removeAt(0);
}
}
class _FailingTheaterCatalog extends _TheaterOnlyCatalog {
@override
Future<FeedPage> fetchFeed({
List<String> types = const [],
String? categoryId,
String? cursor,
int size = 10,
}) {
if (size == 20) {
return Future.error(StateError('theater feed unavailable'));
}
return super.fetchFeed(
types: types,
categoryId: categoryId,
cursor: cursor,
size: size,
);
}
}
List<Drama> _pageDramas(int start, int count) => [
for (var index = start; index < start + count; index++)
Drama(
id: 'page-$index',
title: 'Page $index',
synopsis: 'Page $index synopsis',
posterUrl: '',
genre: 'Drama',
),
];
@@ -158,6 +158,8 @@ Drama _detailDrama(Drama card) => Drama(
isFree: number == 1,
videoUrl: number == 1 ? '/files/${card.id}-1.mp4' : '',
locked: number > 1,
likeCount: card.likeCount,
commentCount: card.commentCount,
),
],
);
+57 -2
View File
@@ -9,12 +9,16 @@ import 'package:fruitshorts/pages/drama_detail/drama_detail_logic.dart';
import '../support/fake_watch_history.dart';
/// 详情页逻辑依赖:票据仓库 + 收藏控制器(测试里都是内存实现)。
DramaDetailLogic _logic(Drama drama, FakeWatchHistory history) =>
DramaDetailLogic _logic(
Drama drama,
FakeWatchHistory history, {
FavoriteController? favorites,
}) =>
DramaDetailLogic(
drama: drama,
history: history,
playback: FakePlaybackRepository(),
favorites: FavoriteController(InMemoryFavoriteRepository()),
favorites: favorites ?? FavoriteController(InMemoryFavoriteRepository()),
);
Drama _card(String id) => Drama(
@@ -98,4 +102,55 @@ void main() {
expect(logic.drama.episodes, hasLength(3));
logic.dispose();
});
test('should_adjust_favorite_count_only_after_successful_changes', () async {
final repository = InMemoryFavoriteRepository(cards: [_favoriteDrama]);
final favorites = FavoriteController(repository);
await favorites.load();
final logic = _logic(
_favoriteDrama,
FakeWatchHistory(),
favorites: favorites,
);
expect(logic.adjustedFavoriteCount, 2);
expect(await logic.toggleFavorite(), isTrue);
expect(logic.adjustedFavoriteCount, 3);
repository.failToggle = true;
expect(await logic.toggleFavorite(), isFalse);
expect(logic.adjustedFavoriteCount, 3);
repository.failToggle = false;
expect(await logic.toggleFavorite(), isTrue);
expect(logic.adjustedFavoriteCount, 2);
logic.dispose();
});
test('should_decrement_favorite_count_from_saved_state', () async {
final repository = InMemoryFavoriteRepository(
saved: {'favorite-detail'},
cards: [_favoriteDrama],
);
final favorites = FavoriteController(repository);
await favorites.load();
final logic = _logic(
_favoriteDrama,
FakeWatchHistory(),
favorites: favorites,
);
expect(await logic.toggleFavorite(), isTrue);
expect(logic.adjustedFavoriteCount, 1);
logic.dispose();
});
}
const _favoriteDrama = Drama(
id: 'favorite-detail',
title: 'Favorite detail',
synopsis: 'favorite count',
posterUrl: '/files/favorite-detail.jpg',
favoriteCount: 2,
detailLoaded: true,
);
@@ -140,11 +140,23 @@ void main() {
expect(decoration().color, AppColors.card);
expect((decoration().border! as Border).top.color, AppColors.blue);
expect(
tester
.widget<Text>(find.byKey(const Key('detail-stat-favorite-value')))
.data,
'3200 Favorite',
);
await tester.tap(find.byKey(const Key('detail-favorite')));
await tester.pumpAndSettle();
expect(decoration().color, AppColors.blue);
expect(
tester
.widget<Text>(find.byKey(const Key('detail-stat-favorite-value')))
.data,
'3201 Favorite',
);
expect(
find.descendant(
of: find.byKey(const Key('detail-favorite')),
@@ -152,6 +164,16 @@ void main() {
),
findsOneWidget,
);
await tester.tap(find.byKey(const Key('detail-favorite')));
await tester.pumpAndSettle();
expect(
tester
.widget<Text>(find.byKey(const Key('detail-stat-favorite-value')))
.data,
'3200 Favorite',
);
});
testWidgets('should_expand_synopsis_when_it_exceeds_three_lines', (
@@ -441,6 +463,8 @@ Drama _detailDrama({
durationSec: 135,
locked: number > 1,
videoUrl: number == 1 ? '/files/detail-k38-1.mp4' : '',
likeCount: withCounts ? 12400 : null,
commentCount: withCounts ? 96 : null,
),
],
);
@@ -0,0 +1,57 @@
// Author: fruit.com.cn
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/formatting/relative_date_time.dart';
import 'package:fruitshorts/l10n/app_localizations_en.dart';
void main() {
final l10n = AppLocalizationsEn();
final now = DateTime(2026, 10, 21, 12);
test('should_show_just_now_under_one_minute', () {
expect(
formatRelativeDateTime(
now.subtract(const Duration(seconds: 59)),
l10n,
now: now,
),
'Just now',
);
});
test('should_use_minutes_below_sixty_minutes', () {
expect(
formatRelativeDateTime(
now.subtract(const Duration(minutes: 59)),
l10n,
now: now,
),
'59 min ago',
);
});
test('should_use_hours_below_twenty_four_hours', () {
expect(
formatRelativeDateTime(
now.subtract(const Duration(hours: 23)),
l10n,
now: now,
),
'23 hours ago',
);
});
test('should_use_month_and_day_for_same_year', () {
expect(
formatRelativeDateTime(DateTime(2026, 9, 3), l10n, now: now),
'09-03',
);
});
test('should_use_full_date_for_previous_year', () {
expect(
formatRelativeDateTime(DateTime(2025, 12, 31), l10n, now: now),
'2025-12-31',
);
});
}
@@ -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/feed_page.dart';
import 'package:fruitshorts/core/data/models/home_feed.dart';
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
import 'package:fruitshorts/core/data/services/catalog_controller.dart';
@@ -59,6 +60,14 @@ class _FakeCatalog implements CatalogRepository {
@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<Drama> fetchDrama(String dramaId) async => _drama;
}
+54
View File
@@ -0,0 +1,54 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/theme/app_theme.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/home/home_feed_channel.dart';
import 'package:fruitshorts/pages/player/widgets/home_feed_header.dart';
void main() {
testWidgets('should_emit_channel_callbacks_and_render_selected_channel', (
tester,
) async {
final selected = <HomeFeedChannel>[];
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
locale: const Locale('en'),
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: AppLocalizations.localizationsDelegates,
home: Builder(
builder: (context) => Scaffold(
body: HomeFeedHeader(
l10n: AppLocalizations.of(context)!,
channel: HomeFeedChannel.anime,
onChannelSelected: selected.add,
onRefresh: () async => true,
onSearch: () {},
onMore: () {},
),
),
),
),
);
expect(find.byKey(const Key('home-feed-tab-anime')), findsOneWidget);
expect(find.byKey(const Key('home-feed-tab-real')), findsOneWidget);
expect(find.byKey(const Key('home-feed-tab-recommend')), findsOneWidget);
expect(
tester.widget<Text>(find.text('Anime')).style?.fontWeight,
FontWeight.w800,
);
await tester.tap(find.byKey(const Key('home-feed-tab-real')));
await tester.tap(find.byKey(const Key('home-feed-tab-recommend')));
await tester.tap(find.byKey(const Key('home-feed-tab-anime')));
expect(selected, [
HomeFeedChannel.live,
HomeFeedChannel.recommend,
HomeFeedChannel.anime,
]);
});
}
+138
View File
@@ -15,6 +15,7 @@ import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
import 'package:fruitshorts/core/theme/app_theme.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/home/home_feed_channel.dart';
import 'package:fruitshorts/pages/player/widgets/home_feed_header.dart';
import 'package:fruitshorts/pages/player/widgets/home_feed_overlay.dart';
import 'package:video_player/video_player.dart';
@@ -150,6 +151,36 @@ void main() {
tester.widget<Text>(find.byKey(const Key('rail-comments-count'))).data,
'12',
);
String railAsset(Key key) {
final image = tester.widget<Image>(
find.descendant(of: find.byKey(key), matching: find.byType(Image)),
);
return (image.image as AssetImage).assetName;
}
expect(railAsset(const Key('rail-save')), 'assets/icon/rail/rail_save.png');
expect(
railAsset(const Key('rail-comments')),
'assets/icon/rail/rail_comment.png',
);
expect(railAsset(const Key('rail-like')), 'assets/icon/rail/rail_like.png');
expect(
railAsset(const Key('rail-share')),
'assets/icon/rail/rail_share.png',
);
expect(
tester
.widget<Image>(
find.descendant(
of: find.byKey(const Key('rail-share')),
matching: find.byType(Image),
),
)
.colorBlendMode,
BlendMode.srcIn,
);
expect(find.byKey(const Key('progress-loading')), findsOneWidget);
expect(find.byKey(const Key('home-feed-progress-thumb')), findsNothing);
});
testWidgets('should_seek_and_open_player_from_feed_actions', (tester) async {
@@ -289,6 +320,111 @@ void main() {
expect(opened, 1);
});
testWidgets('should_debounce_feed_loading_and_keep_track_while_scrubbing', (
tester,
) async {
final controller = ValueNotifier<VideoPlayerValue>(
const VideoPlayerValue(
duration: Duration(seconds: 100),
position: Duration(seconds: 10),
isInitialized: true,
),
);
addTearDown(controller.dispose);
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
locale: const Locale('en'),
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: AppLocalizations.localizationsDelegates,
home: Builder(
builder: (context) => Scaffold(
body: HomeFeedOverlay(
l10n: AppLocalizations.of(context)!,
drama: const Drama(
id: 'debounce-drama',
title: 'Debounce Drama',
synopsis: 'synopsis',
posterUrl: '',
totalEpisodes: 8,
),
episode: const Episode(
id: 'debounce-episode',
number: 1,
title: 'Episode 1',
isFree: true,
),
controller: controller,
liked: false,
saved: false,
visible: true,
boosting: false,
onTogglePlay: () {},
onBoostStart: () {},
onBoostEnd: () {},
onLike: () {},
onSave: () {},
onComments: () {},
onShare: () {},
onDetail: () {},
onOpenFullPlayer: () {},
onMore: () {},
onSeek: (_) {},
),
),
),
),
);
await tester.pumpAndSettle();
controller.value = const VideoPlayerValue(
duration: Duration(seconds: 100),
position: Duration(seconds: 10),
isInitialized: true,
isBuffering: true,
);
await tester.pump(const Duration(milliseconds: 200));
expect(find.byKey(const Key('home-feed-progress-thumb')), findsOneWidget);
expect(find.byKey(const Key('progress-loading')), findsNothing);
await tester.pump(const Duration(milliseconds: 200));
expect(find.byKey(const Key('home-feed-progress-thumb')), findsNothing);
expect(find.byKey(const Key('progress-loading')), findsOneWidget);
controller.value = const VideoPlayerValue(
duration: Duration(seconds: 100),
position: Duration(seconds: 10),
isInitialized: true,
);
await tester.pump();
expect(find.byKey(const Key('home-feed-progress-thumb')), findsOneWidget);
final gesture = await tester.startGesture(
tester.getCenter(find.byKey(const Key('home-feed-progress'))),
);
await gesture.moveBy(const Offset(60, 0));
await tester.pump();
controller.value = const VideoPlayerValue(
duration: Duration(seconds: 100),
position: Duration(seconds: 10),
isInitialized: true,
isBuffering: true,
);
await tester.pump(const Duration(milliseconds: 400));
expect(find.byKey(const Key('home-feed-progress-thumb')), findsOneWidget);
expect(find.byKey(const Key('progress-loading')), findsNothing);
controller.value = const VideoPlayerValue(
duration: Duration(seconds: 100),
position: Duration(seconds: 10),
isInitialized: true,
);
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
});
testWidgets('should_show_refresh_spinner_until_refresh_finishes', (
tester,
) async {
@@ -304,6 +440,8 @@ void main() {
builder: (context) => Scaffold(
body: HomeFeedHeader(
l10n: AppLocalizations.of(context)!,
channel: HomeFeedChannel.recommend,
onChannelSelected: (_) {},
onRefresh: () {
calls++;
return completer.future;
+17
View File
@@ -12,6 +12,7 @@ import 'package:shared_preferences_platform_interface/shared_preferences_async_p
import 'package:fruitshorts/app.dart';
import 'package:fruitshorts/core/config/app_config.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/repositories/catalog_repository.dart';
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
@@ -158,6 +159,14 @@ class _EmptyCatalogRepository implements CatalogRepository {
@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<Drama> fetchDrama(String dramaId) async => _feed.dramas.first;
}
@@ -177,6 +186,14 @@ class _PendingCatalogRepository implements CatalogRepository {
@override
Future<HomeFeed> fetchDiscoverFeed() async => _feed;
@override
Future<FeedPage> fetchFeed({
List<String> types = const [],
String? categoryId,
String? cursor,
int size = 10,
}) async => FeedPage(dramas: _feed.dramas);
@override
Future<Drama> fetchDrama(String dramaId) async => _feed.dramas.first;
}
+119 -6
View File
@@ -1,12 +1,16 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/app.dart';
import 'package:fruitshorts/core/data/models/feed_page.dart';
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
import 'package:fruitshorts/pages/home/home_feed_channel.dart';
import 'package:fruitshorts/pages/player/player_view.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
@@ -86,6 +90,26 @@ void main() {
expect(tester.widget<PlayerPage>(find.byType(PlayerPage)).homeMode, isTrue);
});
testWidgets('should_keep_home_feed_size_when_keyboard_insets_change', (
tester,
) async {
addTearDown(tester.view.resetViewInsets);
await _pumpApp(tester);
final feed = find.byKey(const Key('home-feed-black-background'));
final before = tester.getRect(feed);
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
await tester.pump();
expect(
tester
.widget<Scaffold>(find.byType(Scaffold).first)
.resizeToAvoidBottomInset,
isFalse,
);
expect(tester.getRect(feed), before);
});
testWidgets('should_open_full_player_from_full_series_bar', (tester) async {
await _pumpApp(tester);
@@ -96,12 +120,56 @@ void main() {
expect(player.homeMode, isFalse);
});
testWidgets('should_show_coming_soon_for_anime_tab', (tester) async {
testWidgets('should_switch_to_anime_channel', (tester) async {
await _pumpApp(tester);
await tester.tap(find.byKey(const Key('home-feed-tab-anime')));
await tester.pumpAndSettle();
expect(find.text('Coming soon'), findsNothing);
expect(
tester.widget<Text>(find.text('Anime')).style?.fontWeight,
FontWeight.w800,
);
expect(find.byKey(const Key('home-feed-title')), findsOneWidget);
});
testWidgets('should_keep_player_when_first_anime_switch_is_loading', (
tester,
) async {
final catalog = _PendingAnimeCatalog();
await _pumpApp(tester, catalog: catalog);
for (var attempt = 0; attempt < 5 && catalog.animeCalls == 0; attempt++) {
await tester.pump();
}
expect(catalog.animeCalls, 1);
await tester.tap(find.byKey(const Key('home-feed-tab-anime')));
await tester.pump();
expect(find.text('Coming soon'), findsOneWidget);
expect(find.byType(PlayerPage), findsOneWidget);
expect(find.byKey(const Key('home-feed-channel-loading')), findsOneWidget);
catalog.anime.complete(FeedPage(dramas: [demoDramas[1]]));
await tester.pumpAndSettle();
expect(find.byKey(const Key('home-feed-channel-loading')), findsNothing);
expect(
tester.widget<PlayerPage>(find.byType(PlayerPage)).feedChannel,
HomeFeedChannel.anime,
);
});
testWidgets('should_show_empty_state_when_anime_has_no_dramas', (
tester,
) async {
await _pumpApp(tester, catalog: _EmptyAnimeCatalog());
await tester.tap(find.byKey(const Key('home-feed-tab-anime')));
await tester.pumpAndSettle();
expect(find.byType(PlayerPage), findsNothing);
expect(find.text('No stories available yet.'), findsOneWidget);
});
testWidgets('should_refresh_feed_without_blank_loading', (tester) async {
@@ -420,19 +488,20 @@ bool _hasLightStatusBar(WidgetTester tester) => tester
Future<void> _pumpApp(
WidgetTester tester, {
Size size = const Size(390, 844),
FakeCatalogRepository? catalog,
}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final catalog = FakeCatalogRepository();
final repository = catalog ?? FakeCatalogRepository();
await tester.pumpWidget(
LumaDramaApp(
playerFactory: createWidgetPlayer,
catalogRepository: catalog,
categoryRepository: FakeCategoryRepository(catalog: catalog),
catalogRepository: repository,
categoryRepository: FakeCategoryRepository(catalog: repository),
watchHistoryRepository: InMemoryWatchHistoryRepository(),
favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas),
favoriteRepository: InMemoryFavoriteRepository(cards: repository.dramas),
playbackRepository: FakePlaybackRepository(),
commentRepository: FakeCommentRepository(),
interactionRepository: FakeInteractionRepository(),
@@ -441,3 +510,47 @@ Future<void> _pumpApp(
);
await tester.pumpAndSettle();
}
class _PendingAnimeCatalog extends FakeCatalogRepository {
final anime = Completer<FeedPage>();
int animeCalls = 0;
@override
Future<FeedPage> fetchFeed({
List<String> types = const [],
String? categoryId,
String? cursor,
int size = 10,
}) {
if (types.contains('ANIME')) {
animeCalls++;
return anime.future;
}
return super.fetchFeed(
types: types,
categoryId: categoryId,
cursor: cursor,
size: size,
);
}
}
class _EmptyAnimeCatalog extends FakeCatalogRepository {
@override
Future<FeedPage> fetchFeed({
List<String> types = const [],
String? categoryId,
String? cursor,
int size = 10,
}) {
if (types.contains('ANIME')) {
return Future.value(const FeedPage(dramas: []));
}
return super.fetchFeed(
types: types,
categoryId: categoryId,
cursor: cursor,
size: size,
);
}
}
@@ -62,6 +62,56 @@ void main() {
controller.dispose();
});
test('should_toggle_episode_like_with_plus_one_and_minus_one', () async {
final repository = FakeInteractionRepository(
nextLikeResult: const LikeToggleResult(liked: true, count: 8),
);
final controller = InteractionController(repository);
await controller.toggleEpisodeLike('episode-5');
expect(repository.likeTargets, ['EPISODE:episode-5']);
expect(controller.isEpisodeLiked('episode-5'), isTrue);
expect(controller.episodeLikeCount('episode-5'), 8);
expect(controller.isLiked('episode-5'), isFalse);
repository.nextLikeResult = const LikeToggleResult(liked: false, count: 7);
await controller.toggleEpisodeLike('episode-5');
expect(controller.isEpisodeLiked('episode-5'), isFalse);
expect(controller.episodeLikeCount('episode-5'), 7);
controller.dispose();
});
test('should_isolate_episode_like_cache_by_episode_and_drama', () async {
final repository = FakeInteractionRepository(
nextLikeResult: const LikeToggleResult(liked: true, count: 8),
);
final controller = InteractionController(repository);
await controller.toggleEpisodeLike('episode-1');
repository.nextLikeResult = const LikeToggleResult(liked: true, count: 3);
await controller.toggleEpisodeLike('episode-2');
repository.nextLikeResult = const LikeToggleResult(liked: false, count: 7);
await controller.toggleEpisodeLike('episode-1');
repository.nextLikeResult = const LikeToggleResult(liked: true, count: 12);
await controller.toggleDramaLike('episode-1');
expect(controller.isEpisodeLiked('episode-1'), isFalse);
expect(controller.episodeLikeCount('episode-1'), 7);
expect(controller.isEpisodeLiked('episode-2'), isTrue);
expect(controller.episodeLikeCount('episode-2'), 3);
expect(controller.isLiked('episode-1'), isTrue);
expect(controller.likeCount('episode-1'), 12);
expect(repository.likeTargets, [
'EPISODE:episode-1',
'EPISODE:episode-2',
'EPISODE:episode-1',
'DRAMA:episode-1',
]);
controller.dispose();
});
test('should_clear_account_scoped_drama_likes_when_session_changes', () async {
final repository = FakeInteractionRepository(
nextLikeResult: const LikeToggleResult(liked: true, count: 1),
+50
View File
@@ -0,0 +1,50 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/navigation/fade_page_route.dart';
void main() {
testWidgets('should_fade_in_without_slide_and_use_short_transitions', (
tester,
) async {
final navigatorKey = GlobalKey<NavigatorState>();
var routePushed = false;
final route = FadePageRoute<void>(
builder: (_) =>
const Scaffold(key: Key('fade-route-page'), body: SizedBox.expand()),
);
expect(route.transitionDuration, const Duration(milliseconds: 220));
expect(route.reverseTransitionDuration, const Duration(milliseconds: 180));
expect(route.opaque, isTrue);
await tester.pumpWidget(
MaterialApp(
navigatorKey: navigatorKey,
home: Builder(
builder: (context) => Scaffold(
body: TextButton(
onPressed: () {
navigatorKey.currentState!.push(route);
routePushed = true;
},
child: const Text('Open'),
),
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(routePushed, isTrue);
expect(find.byKey(const Key('fade-route-page')), findsOneWidget);
final fade = tester.widget<FadeTransition>(
find.byType(FadeTransition).last,
);
expect(fade.opacity.value, inExclusiveRange(0, 1));
});
}
@@ -11,9 +11,13 @@ void main() {
'accessType': 'FREE',
'playUrl': '/files/episode/12.m3u8?e=1&s=abc',
'qualities': ['AUTO', '720P', '1080P'],
'likeCount': '17',
'commentCount': 5,
});
expect(ticket.qualities, ['AUTO', '720P', '1080P']);
expect(ticket.likeCount, 17);
expect(ticket.commentCount, 5);
});
test('should_default_to_empty_qualities_when_field_is_missing', () {
+136 -15
View File
@@ -16,7 +16,72 @@ void main() {
expect(seekPositionForFraction(1, duration), lessThan(duration));
});
testWidgets('should_preview_while_dragging_and_seek_on_release_when_slider_is_horizontal', (
testWidgets('should_show_loading_track_when_controller_is_missing', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SeekProgressBar(
valueListenable: null,
semanticsLabel: 'Seek video',
),
),
),
);
expect(find.byKey(const Key('progress-loading')), findsOneWidget);
expect(find.byType(Slider), findsNothing);
});
testWidgets('should_debounce_buffering_before_showing_loading_track', (
tester,
) async {
final video = ValueNotifier<VideoPlayerValue>(
const VideoPlayerValue(
duration: Duration(seconds: 12),
position: Duration(seconds: 3),
isInitialized: true,
isBuffering: true,
),
);
addTearDown(video.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SeekProgressBar(
valueListenable: video,
semanticsLabel: 'Seek video',
onSeek: (_) async {},
),
),
),
);
expect(find.byKey(const Key('progress-loading')), findsNothing);
expect(find.byType(Slider), findsOneWidget);
expect(find.textContaining('00:12'), findsNothing);
await tester.pump(const Duration(milliseconds: 200));
expect(find.byKey(const Key('progress-loading')), findsNothing);
expect(find.byType(Slider), findsOneWidget);
await tester.pump(const Duration(milliseconds: 200));
expect(find.byKey(const Key('progress-loading')), findsOneWidget);
expect(find.byType(Slider), findsNothing);
video.value = const VideoPlayerValue(
duration: Duration(seconds: 12),
position: Duration(seconds: 3),
isInitialized: true,
);
await tester.pump();
expect(find.byKey(const Key('progress-loading')), findsNothing);
expect(find.byType(Slider), findsOneWidget);
});
testWidgets('should_keep_slider_while_scrubbing_during_buffering', (
tester,
) async {
final video = ValueNotifier<VideoPlayerValue>(
@@ -27,8 +92,6 @@ void main() {
),
);
addTearDown(video.dispose);
var started = 0;
Duration? target;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
@@ -38,10 +101,7 @@ void main() {
child: SeekProgressBar(
valueListenable: video,
semanticsLabel: 'Seek video',
onScrubStart: () => started++,
onSeek: (position) async {
target = position;
},
onSeek: (_) async {},
),
),
),
@@ -52,19 +112,80 @@ void main() {
final gesture = await tester.startGesture(
tester.getCenter(find.byType(Slider)),
);
await gesture.moveBy(const Offset(120, 0));
await gesture.moveBy(const Offset(60, 0));
await tester.pump();
video.value = const VideoPlayerValue(
duration: Duration(seconds: 12),
position: Duration(seconds: 5),
isInitialized: true,
isBuffering: true,
);
await tester.pump(const Duration(milliseconds: 400));
expect(find.byKey(const Key('progress-loading')), findsNothing);
expect(find.byType(Slider), findsOneWidget);
video.value = const VideoPlayerValue(
duration: Duration(seconds: 12),
position: Duration(seconds: 5),
isInitialized: true,
);
await tester.pump();
expect(started, 1);
expect(target, isNull);
expect(find.textContaining('/ 00:12'), findsOneWidget);
await gesture.up();
await tester.pumpAndSettle();
expect(target, isNotNull);
expect(target!, greaterThan(const Duration(seconds: 3)));
expect(target!, lessThan(const Duration(seconds: 12)));
expect(find.textContaining('/ 00:12'), findsNothing);
});
testWidgets(
'should_preview_while_dragging_and_seek_on_release_when_slider_is_horizontal',
(tester) async {
final video = ValueNotifier<VideoPlayerValue>(
const VideoPlayerValue(
duration: Duration(seconds: 12),
position: Duration(seconds: 3),
isInitialized: true,
),
);
addTearDown(video.dispose);
var started = 0;
Duration? target;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: SizedBox(
width: 300,
child: SeekProgressBar(
valueListenable: video,
semanticsLabel: 'Seek video',
onScrubStart: () => started++,
onSeek: (position) async {
target = position;
},
),
),
),
),
),
);
final gesture = await tester.startGesture(
tester.getCenter(find.byType(Slider)),
);
await gesture.moveBy(const Offset(120, 0));
await tester.pump();
expect(started, 1);
expect(target, isNull);
expect(find.textContaining('/ 00:12'), findsOneWidget);
await gesture.up();
await tester.pumpAndSettle();
expect(target, isNotNull);
expect(target!, greaterThan(const Duration(seconds: 3)));
expect(target!, lessThan(const Duration(seconds: 12)));
expect(find.textContaining('/ 00:12'), findsNothing);
},
);
testWidgets('should_show_time_only_while_dragging_when_seek_bar_is_compact', (
tester,
) async {
@@ -0,0 +1,53 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/theme/app_colors.dart';
import 'package:fruitshorts/pages/player/widgets/loading_progress_track.dart';
void main() {
testWidgets('should_move_highlight_while_loading', (tester) async {
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
addTearDown(
() => tester.binding.handleAppLifecycleStateChanged(
AppLifecycleState.paused,
),
);
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: Center(
child: SizedBox(width: 240, child: LoadingProgressTrack(height: 4)),
),
),
),
);
expect(find.byKey(const Key('progress-loading')), findsOneWidget);
expect(
tester
.widget<ColoredBox>(find.byKey(const Key('progress-loading-track')))
.color,
Colors.white24,
);
expect(
tester
.widget<ColoredBox>(
find.byKey(const Key('progress-loading-highlight')),
)
.color,
AppColors.blue,
);
final initialLeft = tester
.getTopLeft(find.byKey(const Key('progress-loading-highlight')))
.dx;
await tester.pump(const Duration(milliseconds: 300));
final movedLeft = tester
.getTopLeft(find.byKey(const Key('progress-loading-highlight')))
.dx;
expect(movedLeft, greaterThan(initialLeft));
expect(tester.getSize(find.byKey(const Key('progress-loading'))).height, 4);
});
}
+217 -59
View File
@@ -18,7 +18,6 @@ import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
import 'package:fruitshorts/core/data/services/playback_session.dart';
import 'package:fruitshorts/core/theme/app_colors.dart';
import 'package:fruitshorts/core/widgets/remote_image.dart';
import 'package:fruitshorts/pages/auth/auth_view.dart';
import 'package:fruitshorts/pages/player/widgets/player_center_toggle.dart';
import 'package:fruitshorts/pages/player/widgets/seek_progress_bar.dart';
@@ -51,7 +50,7 @@ void main() {
await tester.pumpAndSettle();
expect(find.byKey(const Key('player-more-quality-AUTO')), findsOneWidget);
expect(find.byKey(const Key('player-more-offline')), findsOneWidget);
await tester.tap(find.byKey(const Key('player-more-close')));
await tester.tapAt(const Offset(10, 10));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
@@ -132,15 +131,9 @@ void main() {
expect(player.playing, isTrue);
});
testWidgets('should_fall_back_to_black_when_poster_is_empty', (tester) async {
await _pumpApp(
tester,
catalog: FakeCatalogRepository(dramas: const [_noCoverDrama]),
);
await _openSeriesPlayer(
tester,
cardKey: const Key('home-drama-card-no-cover-demo'),
);
testWidgets('should_keep_video_surface_background_black', (tester) async {
await _pumpApp(tester);
await _openSeriesPlayer(tester);
expect(
tester
@@ -150,6 +143,8 @@ void main() {
.color,
Colors.black,
);
expect(find.byKey(const Key('player-fallback-cover')), findsNothing);
expect(find.byKey(const Key('player-fallback-scrim')), findsNothing);
});
testWidgets('should_gate_comments_before_opening_sheet_when_signed_out', (
@@ -180,6 +175,69 @@ void main() {
expect(find.byKey(const Key('player-comments-sheet')), findsOneWidget);
});
testWidgets('should_refresh_comment_count_after_comment_sheet_closes', (
tester,
) async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.withData({
'auth.accessToken': 'access',
'auth.refreshToken': 'refresh',
'auth.user': '{"uid":"9","nickname":"Viewer","avatarUrl":""}',
});
final comments = FakeCommentRepository(countResult: 97);
await _pumpApp(tester, commentRepository: comments);
await _openSeriesPlayer(tester);
expect(
tester
.widget<Text>(find.byKey(const Key('player-rail-comments-count')))
.data,
'96',
);
await tester.tap(find.byKey(const Key('player-rail-comments')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('player-comments-sheet')), findsOneWidget);
expect(find.text('97 comments'), findsOneWidget);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(comments.countCalls, ['city-lights-demo', 'city-lights-demo']);
expect(
tester
.widget<Text>(find.byKey(const Key('player-rail-comments-count')))
.data,
'97',
);
});
testWidgets('should_update_home_feed_favorite_count_after_toggle', (
tester,
) async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.withData({
'auth.accessToken': 'access',
'auth.refreshToken': 'refresh',
'auth.user': '{"uid":"9","nickname":"Viewer","avatarUrl":""}',
});
final catalog = FakeCatalogRepository(dramas: [_countingDrama]);
await _pumpApp(tester, catalog: catalog);
expect(
tester.widget<Text>(find.byKey(const Key('rail-save-count'))).data,
'2',
);
await tester.tap(find.byKey(const Key('rail-save')));
await tester.pumpAndSettle();
expect(
tester.widget<Text>(find.byKey(const Key('rail-save-count'))).data,
'3',
);
});
testWidgets('should_show_center_toggle_only_when_paused', (tester) async {
await _pumpApp(tester);
await _openSeriesPlayer(tester);
@@ -295,7 +353,7 @@ void main() {
await tester.pumpAndSettle();
expect(find.byKey(const Key('player-more-sheet')), findsOneWidget);
await tester.tap(find.byKey(const Key('player-more-close')));
await tester.tapAt(const Offset(10, 10));
await tester.pumpAndSettle();
// 长按弹更多不改播放状态,仍处于播放中。
expect(find.byKey(const Key('player-play-toggle')), findsNothing);
@@ -415,19 +473,9 @@ void main() {
expect(find.byKey(const Key('player-background')), findsNothing);
final fallback = find.byKey(const Key('player-fallback-background'));
expect(fallback, findsOneWidget);
expect(find.byKey(const Key('player-fallback-cover')), findsOneWidget);
expect(
tester
.widget<RemoteImage>(find.byKey(const Key('player-fallback-cover')))
.fallbackColor,
Colors.black,
);
expect(
tester
.widget<ColoredBox>(find.byKey(const Key('player-fallback-scrim')))
.color,
const Color(0x4D000000),
);
expect(tester.widget<ColoredBox>(fallback).color, Colors.black);
expect(find.byKey(const Key('player-fallback-cover')), findsNothing);
expect(find.byKey(const Key('player-fallback-scrim')), findsNothing);
expect(
tester
.widget<Scaffold>(find.byKey(const Key('player-scaffold')))
@@ -476,11 +524,12 @@ void main() {
tester.getSize(find.byKey(const Key('player-bottom-clear-screen'))),
const Size(40, 40),
);
expect(tester.widget<Slider>(find.byType(Slider)).padding, EdgeInsets.zero);
expect(find.byKey(const Key('progress-loading')), findsOneWidget);
expect(find.byType(Slider), findsNothing);
expect(bottom.height, lessThanOrEqualTo(200));
expect(bottom.top, greaterThanOrEqualTo(screenHeight * 0.78));
expect(rail.top, greaterThanOrEqualTo(screenHeight * 0.55 - 2));
expect(rail.top, greaterThanOrEqualTo(screenHeight * 0.40));
expect(rail.bottom, lessThanOrEqualTo(bottom.top));
expect(find.byKey(const Key('player-author-note')), findsNothing);
expect(
@@ -488,11 +537,11 @@ void main() {
.getSize(
find.descendant(
of: find.byKey(const Key('player-rail-comments')),
matching: find.byIcon(Icons.chat_bubble_outline_rounded),
matching: find.byType(Image),
),
)
.width,
24,
32,
);
await tester.tap(find.byKey(const Key('player-synopsis-toggle')));
@@ -506,6 +555,28 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets(
'should_keep_fullscreen_player_position_when_keyboard_insets_change',
(tester) async {
addTearDown(tester.view.resetViewInsets);
await _pumpApp(tester);
await _openSeriesPlayer(tester);
final rail = find.byKey(const Key('player-interaction-rail'));
final before = tester.getRect(rail);
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
await tester.pump();
expect(
tester
.widget<Scaffold>(find.byKey(const Key('player-scaffold')))
.resizeToAvoidBottomInset,
isFalse,
);
expect(tester.getRect(rail), before);
},
);
testWidgets('should_keep_series_clear_state_when_swiping_next_episode', (
tester,
) async {
@@ -566,12 +637,10 @@ void main() {
'auth.refreshToken': 'test-refresh',
'auth.user': '{"uid":"9","nickname":"Me","avatarUrl":""}',
});
await _pumpApp(
tester,
interactions: FakeInteractionRepository(
nextLikeResult: const LikeToggleResult(liked: true, count: 23000),
),
final interactions = FakeInteractionRepository(
nextLikeResult: const LikeToggleResult(liked: true, count: 23000),
);
await _pumpApp(tester, interactions: interactions);
await _openSeriesPlayer(tester);
String railLabel(Key key) => tester
@@ -588,12 +657,75 @@ void main() {
await tester.tap(find.byKey(const Key('player-rail-like')));
await tester.pumpAndSettle();
expect(railLabel(const Key('player-rail-like')), '2.3万');
expect(interactions.likeTargets, contains('EPISODE:city-lights-demo-1'));
await tester.tap(find.byKey(const Key('player-rail-save')));
await tester.pumpAndSettle();
expect(railLabel(const Key('player-rail-save')), '3201');
});
testWidgets('should_render_reference_rail_icons_without_background', (
tester,
) async {
await _pumpApp(tester);
await _openSeriesPlayer(tester);
expect(
_railAssetName(tester, const Key('player-rail-save')),
'assets/icon/rail/rail_save.png',
);
expect(
_railAssetName(tester, const Key('player-rail-comments')),
'assets/icon/rail/rail_comment.png',
);
expect(
_railAssetName(tester, const Key('player-rail-like')),
'assets/icon/rail/rail_like.png',
);
expect(
_railAssetName(tester, const Key('player-rail-share')),
'assets/icon/rail/rail_share.png',
);
final shareImage = tester.widget<Image>(
find.descendant(
of: find.byKey(const Key('player-rail-share')),
matching: find.byType(Image),
),
);
expect(shareImage.width, 32);
expect(shareImage.height, 32);
expect(shareImage.colorBlendMode, BlendMode.srcIn);
final saveButton = tester.widget<IconButton>(
find.descendant(
of: find.byKey(const Key('player-rail-save')),
matching: find.byType(IconButton),
),
);
expect(saveButton.style?.backgroundColor, isNull);
expect(
tester.getSize(
find.descendant(
of: find.byKey(const Key('player-rail-save')),
matching: find.byType(IconButton),
),
),
const Size(44, 44),
);
expect(
tester
.widget<Text>(
find.descendant(
of: find.byKey(const Key('player-rail-like')),
matching: find.byType(Text),
),
)
.style
?.fontSize,
12,
);
});
testWidgets('should_toggle_synopsis_with_inline_text_button', (tester) async {
await _pumpApp(tester);
await _openSeriesPlayer(tester);
@@ -846,6 +978,7 @@ void main() {
expect(find.byKey(const Key('player-more-offline')), findsOneWidget);
expect(find.byKey(const Key('player-more-clear')), findsOneWidget);
expect(find.byKey(const Key('player-more-drag-handle')), findsOneWidget);
expect(find.byKey(const Key('player-more-close')), findsNothing);
for (final removed in const [
Key('player-more-not-interested'),
Key('player-more-danmaku'),
@@ -859,15 +992,27 @@ void main() {
}
expect(
tester.getSize(find.byKey(const Key('player-more-speed-1.0'))).height,
28,
26,
);
expect(
tester.getSize(find.byKey(const Key('player-more-speed-track'))).height,
34,
);
expect(
tester.getSize(find.byKey(const Key('player-more-speed-row'))).height,
44,
56,
);
expect(
tester.getSize(find.byKey(const Key('player-more-quality-row'))).height,
44,
56,
);
expect(
tester.getSize(find.byKey(const Key('player-more-clear'))).height,
56,
);
expect(
tester.getSize(find.byKey(const Key('player-more-offline'))).height,
56,
);
expect(
tester
@@ -895,7 +1040,7 @@ void main() {
)
.style
?.fontSize,
12,
13,
);
await tester.ensureVisible(find.byKey(const Key('player-more-speed-1.5')));
await tester.pumpAndSettle();
@@ -911,7 +1056,7 @@ void main() {
)
.style
?.fontWeight,
FontWeight.w700,
FontWeight.w600,
);
expect(
tester
@@ -923,7 +1068,7 @@ void main() {
)
.style
?.fontWeight,
FontWeight.w500,
FontWeight.w400,
);
});
@@ -1065,6 +1210,13 @@ void main() {
});
}
String _railAssetName(WidgetTester tester, Key itemKey) {
final image = tester.widget<Image>(
find.descendant(of: find.byKey(itemKey), matching: find.byType(Image)),
);
return (image.image as AssetImage).assetName;
}
void _expectPlayerNavigationBar(WidgetTester tester) {
final overlay = tester.widget<AnnotatedRegion<SystemUiOverlayStyle>>(
find.byKey(const Key('player-system-overlay')).last,
@@ -1092,7 +1244,8 @@ void _expectFullscreenOverlay(WidgetTester tester) {
expect(find.byKey(const Key('player-pip')), findsNothing);
expect(find.byKey(const Key('player-rail-comments')), findsOneWidget);
expect(find.byKey(const Key('player-fullscreen-toggle')), findsNothing);
expect(find.byType(Slider), findsOneWidget);
expect(find.byKey(const Key('progress-loading')), findsOneWidget);
expect(find.byType(Slider), findsNothing);
expect(find.byKey(const Key('player-title')), findsNothing);
expect(find.byKey(const Key('player-subtitle')), findsNothing);
expect(find.byKey(const Key('player-genre-chip')), findsNothing);
@@ -1142,6 +1295,7 @@ Future<void> _pumpApp(
FakeCatalogRepository? catalog,
FakePlaybackRepository? playback,
FakeInteractionRepository? interactions,
FakeCommentRepository? commentRepository,
PlayerFactory? playerFactory,
}) async {
tester.view.physicalSize = size;
@@ -1157,7 +1311,7 @@ Future<void> _pumpApp(
watchHistoryRepository: InMemoryWatchHistoryRepository(),
favoriteRepository: InMemoryFavoriteRepository(cards: repository.dramas),
playbackRepository: playback ?? FakePlaybackRepository(),
commentRepository: FakeCommentRepository(),
commentRepository: commentRepository ?? FakeCommentRepository(),
interactionRepository: interactions ?? FakeInteractionRepository(),
noticeRepository: FakeNoticeRepository(),
haptics: FakeHaptics(),
@@ -1166,6 +1320,27 @@ Future<void> _pumpApp(
await tester.pumpAndSettle();
}
const _countingDrama = Drama(
id: 'counting-demo',
title: 'Counting Demo',
synopsis: 'Counting interaction totals',
posterUrl: '/files/counting-demo.png',
genre: 'Romance',
detailLoaded: true,
favoriteCount: 2,
commentCount: 3,
likeCount: 4,
episodes: [
Episode(
id: 'counting-demo-1',
number: 1,
title: 'Episode 1',
isFree: true,
videoUrl: 'https://example.test/counting-1.mp4',
),
],
);
class _RecordingPlayer implements PlayerPort {
bool playing = false;
@@ -1211,20 +1386,3 @@ const _lockedDrama = Drama(
),
],
);
const _noCoverDrama = Drama(
id: 'no-cover-demo',
title: 'No Cover Demo',
synopsis: 'No cover synopsis',
posterUrl: '',
detailLoaded: true,
episodes: [
Episode(
id: 'no-cover-demo-1',
number: 1,
title: 'No Cover Episode',
isFree: true,
videoUrl: 'https://example.test/no-cover-demo-1.mp4',
),
],
);
+208
View File
@@ -412,6 +412,214 @@ void main() {
});
});
group('updateFeed', () {
test('should_append_feed_without_restarting_current_drama', () async {
final factory = _RecordingFactory();
final feed = [_drama('signal'), _drama('city')];
final logic = _logic(FakeWatchHistory());
logic.attach(
PlayerPage(
drama: feed.first,
initialIndex: 0,
homeMode: true,
feedDramas: feed,
),
isMounted: () => true,
);
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
await _drain(logic);
final activeBefore = logic.session.active;
logic.updateFeed([...feed, _drama('third')], keepDramaId: 'signal');
await logic.feedUpdateSettled;
expect(logic.currentDramaId, 'signal');
expect(logic.visibleIndices, [0, 1, 2]);
expect(logic.sessionEpisodes, hasLength(3));
expect(logic.session.active, same(activeBefore));
logic.dispose();
await _drain(logic);
});
test('should_remap_current_drama_without_restarting_when_order_changes',
() async {
final factory = _RecordingFactory();
final feed = [_drama('signal'), _drama('city')];
final logic = _logic(FakeWatchHistory());
logic.attach(
PlayerPage(
drama: feed.first,
initialIndex: 1,
homeMode: true,
feedDramas: feed,
),
isMounted: () => true,
);
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
await _drain(logic);
final activeBefore = logic.session.active;
logic.updateFeed(
[_drama('city'), _drama('signal'), _drama('third')],
keepDramaId: 'city',
);
await logic.feedUpdateSettled;
expect(logic.currentDramaId, 'city');
expect(logic.session.currentIndex, 0);
expect(logic.session.active, same(activeBefore));
logic.dispose();
await _drain(logic);
});
test('should_return_to_first_drama_when_feed_channel_changes', () async {
final factory = _RecordingFactory();
final feed = [_drama('signal'), _drama('city')];
final logic = _logic(FakeWatchHistory());
logic.attach(
PlayerPage(
drama: feed.first,
initialIndex: 0,
homeMode: true,
feedDramas: feed,
),
isMounted: () => true,
);
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
await _drain(logic);
logic.updateFeed(
[_drama('anime-a'), _drama('anime-b')],
resetToFirst: true,
);
await logic.feedUpdateSettled;
await _drain(logic);
expect(logic.queue.entryAt(0).drama.id, 'anime-a');
expect(logic.currentDramaId, 'anime-a');
expect(logic.session.currentIndex, 0);
logic.dispose();
await _drain(logic);
});
test('should_clear_queue_safely_when_feed_becomes_empty', () async {
final feed = [_drama('signal')];
final logic = _logic(FakeWatchHistory());
logic.attach(
PlayerPage(
drama: feed.first,
initialIndex: 0,
homeMode: true,
feedDramas: feed,
),
isMounted: () => true,
);
logic.start(
factory: _RecordingFactory().create,
coordinator: PlaybackCoordinator(),
);
await _drain(logic);
logic.updateFeed(const []);
await logic.feedUpdateSettled;
expect(logic.queue.length, 0);
expect(logic.visibleIndices, isEmpty);
expect(logic.currentDramaId, isNull);
logic.dispose();
await _drain(logic);
});
test('should_apply_only_latest_feed_when_update_is_called_five_times',
() async {
final factory = _RecordingFactory();
final logic = _logic(FakeWatchHistory());
final initial = [_drama('initial')];
logic.attach(
PlayerPage(
drama: initial.first,
initialIndex: 0,
homeMode: true,
feedDramas: initial,
),
isMounted: () => true,
);
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
await _drain(logic);
for (var index = 1; index <= 5; index++) {
logic.updateFeed([_drama('feed-$index')], resetToFirst: true);
}
await logic.feedUpdateSettled;
await _drain(logic);
expect(logic.feedRevision, 5);
expect(logic.queue.entryAt(0).drama.id, 'feed-5');
expect(logic.sessionEpisodes.single.id, 'feed-5-1');
expect(logic.currentDramaId, 'feed-5');
logic.dispose();
await _drain(logic);
});
test('should_select_first_item_when_feed_recovers_from_empty', () async {
final factory = _RecordingFactory();
final feed = [_drama('signal')];
final logic = _logic(FakeWatchHistory());
logic.attach(
PlayerPage(
drama: feed.first,
initialIndex: 0,
homeMode: true,
feedDramas: feed,
),
isMounted: () => true,
);
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
await _drain(logic);
logic.updateFeed(const []);
await logic.feedUpdateSettled;
logic.updateFeed([_drama('restored')], resetToFirst: true);
await logic.feedUpdateSettled;
await _drain(logic);
expect(logic.queue.entryAt(0).drama.id, 'restored');
expect(logic.session.currentIndex, 0);
expect(logic.currentDramaId, 'restored');
expect(factory.live, greaterThan(0));
logic.dispose();
await _drain(logic);
});
test('should_reset_to_first_when_channel_changes_with_same_ids',
() async {
final factory = _RecordingFactory();
final feed = [_drama('same-a'), _drama('same-b')];
final logic = _logic(FakeWatchHistory());
logic.attach(
PlayerPage(
drama: feed.first,
initialIndex: 1,
homeMode: true,
feedDramas: feed,
),
isMounted: () => true,
);
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
await _drain(logic);
expect(logic.session.currentIndex, 1);
logic.updateFeed(feed, resetToFirst: true);
await logic.feedUpdateSettled;
await _drain(logic);
expect(logic.session.currentIndex, 0);
expect(logic.currentDramaId, 'same-a');
logic.dispose();
await _drain(logic);
});
});
group('lifecycle', () {
test('should_pause_active_player_when_backgrounded_and_play_it_when_resumed', () async {
final factory = _RecordingFactory();
+102
View File
@@ -3,6 +3,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/theme/app_colors.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/player/widgets/player_more_sheet.dart';
@@ -14,6 +15,107 @@ void main() {
expect(find.byKey(const Key('player-more-clear')), findsOneWidget);
expect(find.byKey(const Key('player-more-offline')), findsOneWidget);
expect(find.byKey(const Key('player-more-pip')), findsNothing);
expect(find.byKey(const Key('player-more-close')), findsNothing);
});
testWidgets('should_render_reference_layout_metrics', (tester) async {
await _openSheet(tester);
expect(
tester.widget<Material>(find.byKey(const Key('player-more-sheet'))).color,
AppColors.ink,
);
expect(
tester.getSize(find.byKey(const Key('player-more-drag-handle'))),
const Size(36, 4),
);
for (final key in const [
Key('player-more-speed-row'),
Key('player-more-quality-row'),
Key('player-more-clear'),
Key('player-more-offline'),
]) {
expect(tester.getSize(find.byKey(key)).height, 56);
}
for (final key in const [
Key('player-more-speed-track'),
Key('player-more-quality-track'),
]) {
expect(tester.getSize(find.byKey(key)).height, 34);
final trackDecoration =
tester.widget<Container>(find.byKey(key)).decoration!
as BoxDecoration;
expect(trackDecoration.color, AppColors.raised);
}
for (final key in const [
Key('player-more-speed-0.75'),
Key('player-more-speed-1.0'),
Key('player-more-speed-1.25'),
]) {
expect(tester.getSize(find.byKey(key)).height, 26);
}
final speedLabel = tester.widget<Text>(
find
.descendant(
of: find.byKey(const Key('player-more-speed-row')),
matching: find.byType(Text),
)
.first,
);
expect(speedLabel.style?.fontSize, 15);
expect(speedLabel.style?.fontWeight, FontWeight.w500);
expect(tester.widget<Icon>(find.byIcon(Icons.speed_outlined)).size, 22);
expect(tester.widget<Icon>(find.byIcon(Icons.fullscreen)).size, 22);
final selected = tester.widget<Container>(
find
.descendant(
of: find.byKey(const Key('player-more-speed-1.0')),
matching: find.byType(Container),
)
.first,
);
final selectedDecoration = selected.decoration! as BoxDecoration;
expect(selectedDecoration.color, AppColors.card);
expect(selectedDecoration.boxShadow, isNotEmpty);
final selectedText = tester.widget<Text>(
find.descendant(
of: find.byKey(const Key('player-more-speed-1.0')),
matching: find.text('1.0x'),
),
);
expect(selectedText.style?.color, AppColors.onSurface);
expect(selectedText.style?.fontSize, 13);
expect(selectedText.style?.fontWeight, FontWeight.w600);
final unselected = tester.widget<Container>(
find
.descendant(
of: find.byKey(const Key('player-more-speed-1.25')),
matching: find.byType(Container),
)
.first,
);
final unselectedDecoration = unselected.decoration! as BoxDecoration;
expect(unselectedDecoration.color, Colors.transparent);
final unselectedText = tester.widget<Text>(
find.descendant(
of: find.byKey(const Key('player-more-speed-1.25')),
matching: find.text('1.25x'),
),
);
expect(unselectedText.style?.color, AppColors.muted);
expect(unselectedText.style?.fontSize, 13);
expect(unselectedText.style?.fontWeight, FontWeight.w400);
final disabledText = tester.widget<Text>(
find.descendant(
of: find.byKey(const Key('player-more-quality-720P')),
matching: find.byType(Text),
),
);
expect(disabledText.style?.color, AppColors.subtle);
});
testWidgets('should_trigger_pip_and_close_sheet_when_tapped', (tester) async {
+13 -5
View File
@@ -12,8 +12,9 @@ void main() {
) async {
await _pumpSurface(tester);
expect(find.byKey(const Key('player-fallback-background')), findsOneWidget);
expect(find.byKey(const Key('player-fallback-cover')), findsOneWidget);
expect(_fallbackColor(tester), Colors.black);
expect(find.byKey(const Key('player-fallback-cover')), findsNothing);
expect(find.byKey(const Key('player-fallback-scrim')), findsNothing);
expect(_opacity(tester), 0);
expect(find.byKey(const Key('player-debug-video')), findsNothing);
});
@@ -48,7 +49,8 @@ void main() {
expect(halfway, lessThan(1));
await tester.pump(const Duration(milliseconds: 100));
expect(_fadeValue(tester), 1);
expect(find.byKey(const Key('player-fallback-background')), findsOneWidget);
expect(_fallbackColor(tester), Colors.black);
expect(find.byKey(const Key('player-fallback-cover')), findsNothing);
expect(find.byKey(const Key('player-debug-video')), findsNothing);
});
@@ -62,7 +64,8 @@ void main() {
expect(find.byKey(const Key('player-video-error')), findsOneWidget);
expect(find.text('No route to host'), findsOneWidget);
expect(find.byKey(const Key('player-fallback-background')), findsOneWidget);
expect(_fallbackColor(tester), Colors.black);
expect(find.byKey(const Key('player-fallback-cover')), findsNothing);
expect(_opacity(tester), 0);
expect(find.byKey(const Key('player-debug-video')), findsNothing);
});
@@ -82,7 +85,6 @@ Future<VideoPlayerController> _pumpSurface(
home: Scaffold(
body: PlayerVideoSurface(
controller: controller,
posterUrl: '/files/poster.jpg',
videoUrl: controller.dataSource,
theater: false,
onTap: () {},
@@ -94,6 +96,12 @@ Future<VideoPlayerController> _pumpSurface(
return controller;
}
Color _fallbackColor(WidgetTester tester) {
return tester
.widget<ColoredBox>(find.byKey(const Key('player-fallback-background')))
.color;
}
double _opacity(WidgetTester tester) {
return tester
.widget<AnimatedOpacity>(find.byKey(const Key('player-video-opacity')))
+21
View File
@@ -1,4 +1,5 @@
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/search_result.dart';
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
@@ -18,6 +19,24 @@ class FakeCatalogRepository implements CatalogRepository, SearchRepository {
@override
Future<HomeFeed> fetchDiscoverFeed() async => _feed();
@override
Future<FeedPage> fetchFeed({
List<String> types = const [],
String? categoryId,
String? cursor,
int size = 10,
}) async {
final filtered = types.isEmpty
? dramas
: dramas.where((drama) => _matchesFeedTypes(drama, types)).toList();
return FeedPage(dramas: filtered.take(size).toList());
}
bool _matchesFeedTypes(Drama drama, List<String> types) {
final dramaType = drama.id == 'the-signal-demo' ? 'ANIME' : 'LIVE';
return types.contains(dramaType);
}
@override
Future<Drama> fetchDrama(String dramaId) async {
return dramas.firstWhere((drama) => drama.id == dramaId);
@@ -95,6 +114,8 @@ Drama _drama(
title: 'Episode ${index + 1}',
isFree: true,
videoUrl: 'https://example.test/$format-$index.mp4',
likeCount: likeCount,
commentCount: commentCount,
),
],
);
@@ -10,12 +10,14 @@ import 'package:fruitshorts/core/data/repositories/interaction_repository.dart';
class CommentPageCall {
const CommentPageCall({
required this.dramaId,
required this.episodeId,
required this.rootId,
required this.cursor,
required this.size,
});
final String dramaId;
final String? episodeId;
final String? rootId;
final String? cursor;
final int size;
@@ -42,6 +44,8 @@ class FakeCommentRepository implements CommentRepository {
this.publishError,
this.deleteError,
this.pageError,
this.countResult,
this.countError,
}) : pages = pages ?? const [];
final List<CommentPage> pages;
@@ -49,14 +53,19 @@ class FakeCommentRepository implements CommentRepository {
final Object? publishError;
final Object? deleteError;
final Object? pageError;
final int? countResult;
final Object? countError;
final List<CommentPageCall> pageCalls = [];
final List<PublishCall> publishCalls = [];
final List<String> deleteCalls = [];
final List<String> countCalls = [];
final List<String> countEpisodeCalls = [];
int _pageIndex = 0;
@override
Future<CommentPage> page({
required String dramaId,
String? episodeId,
String? rootId,
String? cursor,
int size = commentPageSize,
@@ -64,6 +73,7 @@ class FakeCommentRepository implements CommentRepository {
pageCalls.add(
CommentPageCall(
dramaId: dramaId,
episodeId: episodeId,
rootId: rootId,
cursor: cursor,
size: size,
@@ -75,6 +85,19 @@ class FakeCommentRepository implements CommentRepository {
return pages[_pageIndex++];
}
@override
Future<int> count(String dramaId, {String? episodeId}) async {
countCalls.add(dramaId);
if (episodeId != null && episodeId.isNotEmpty) {
countEpisodeCalls.add(episodeId);
}
final error = countError;
if (error != null) throw error;
final result = countResult;
if (result == null) throw StateError('comment count unavailable');
return result;
}
@override
Future<DramaComment> publish({
required String dramaId,
+3 -2
View File
@@ -16,11 +16,12 @@ void main() {
expect(AppDimens.listTitle, 15);
expect(AppDimens.sectionTitle, 16);
expect(AppDimens.pageTitle, 20);
expect(AppDimens.navLabel, 10);
expect(AppDimens.navLabel, 12);
expect(AppDimens.navIcon, 24);
expect(AppDimens.buttonHeight, 48);
expect(AppDimens.primaryButtonHeight, 52);
expect(AppDimens.tapTarget, 44);
expect(AppDimens.navHeight, 64);
expect(AppDimens.navHeight, 68);
expect(AppDimens.progressHeight, 3);
});
}
@@ -4,6 +4,7 @@ import 'dart:async';
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/watch_progress.dart';
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
@@ -265,6 +266,14 @@ class ScriptedWatchCatalog implements CatalogRepository {
@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<Drama> fetchDrama(String dramaId) async {
calls.add(dramaId);
+14
View File
@@ -3,6 +3,7 @@
import 'package:flutter/material.dart';
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/watch_progress.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
@@ -271,6 +272,19 @@ class _FailingWatchCatalog implements CatalogRepository {
@override
Future<HomeFeed> fetchDiscoverFeed() => _delegate.fetchDiscoverFeed();
@override
Future<FeedPage> fetchFeed({
List<String> types = const [],
String? categoryId,
String? cursor,
int size = 10,
}) => _delegate.fetchFeed(
types: types,
categoryId: categoryId,
cursor: cursor,
size: size,
);
@override
Future<Drama> fetchDrama(String dramaId) async {
if (dramaId == 'broken') throw StateError('missing detail');
+11 -4
View File
@@ -185,7 +185,8 @@ void main() {
await tester.pumpAndSettle();
await _openTheaterPlayer(tester);
expect(find.byType(Slider), findsOneWidget);
expect(find.byKey(const Key('progress-loading')), findsOneWidget);
expect(find.byType(Slider), findsNothing);
await tester.drag(find.byType(PageView), const Offset(0, -500));
await tester.pumpAndSettle();
expect(
@@ -247,8 +248,14 @@ void main() {
await tester.tap(find.byKey(const Key('player-rail-comments')));
await tester.pumpAndSettle();
expect(find.text('No comments yet.'), findsOneWidget);
await tester.enterText(find.byKey(const Key('comment-input')), 'Nice demo');
await tester.tap(find.byKey(const Key('comment-post')));
await tester.tap(find.byKey(const Key('comment-input-placeholder')));
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const Key('comment-composer-input')),
'Nice demo',
);
await tester.pump();
await tester.tap(find.byKey(const Key('comment-composer-post')));
await tester.pumpAndSettle();
expect(find.text('Nice demo'), findsOneWidget);
await tester.binding.handlePopRoute();
@@ -256,7 +263,7 @@ void main() {
await tester.tap(find.byKey(const Key('player-rail-like')));
await tester.pumpAndSettle();
expect(interactions.likeTargets, contains('DRAMA:city-lights-demo'));
expect(interactions.likeTargets, contains('EPISODE:city-lights-demo-1'));
await tester.tap(find.byKey(const Key('player-rail-save')));
await tester.pumpAndSettle();