merge: 将 release/v1.0.0 同步到 main
This commit is contained in:
commit
cd3d84cf9a
94 files changed
+6151
-1236
No files matched your search
@@ -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 |
@@ -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` 已还原,无依赖变更,未安装或操作真机。
|
||||
@@ -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 直连本机服务;不可达时回落到公网地址。
|
||||
|
||||
@@ -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']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,9 +2,10 @@
|
||||
|
||||
import 'json_numbers.dart';
|
||||
|
||||
/// 互动目标类型,契约只允许短剧与评论。
|
||||
/// 互动目标类型。
|
||||
enum InteractionTargetType {
|
||||
drama('DRAMA'),
|
||||
episode('EPISODE'),
|
||||
comment('COMMENT');
|
||||
|
||||
const InteractionTargetType(this.apiValue);
|
||||
|
||||
@@ -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']),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => '加载更多';
|
||||
|
||||
|
||||
@@ -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": "评论发布失败,请重试。",
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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: '');
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user