merge: 将 develop 同步到 release/v1.0.0
This commit is contained in:
commit
b5bfc1d7b2
18 files changed
+875
-35
No files matched your search
@@ -92,3 +92,114 @@ Running Gradle task 'assembleDebug'... 25.8s
|
|||||||
|
|
||||||
解读:静态分析零告警,555 项全量测试通过,debug APK 构建成功;
|
解读:静态分析零告警,555 项全量测试通过,debug APK 构建成功;
|
||||||
`pubspec.lock` 已还原,无依赖变更,未安装或操作真机。
|
`pubspec.lock` 已还原,无依赖变更,未安装或操作真机。
|
||||||
|
|
||||||
|
## 2026-10-21 K98 修复登录与手机号登录键盘遮挡
|
||||||
|
|
||||||
|
- 已完整读取 K98 `docs/plans/INBOX.md`。
|
||||||
|
- 已从最新 `origin/develop` 创建 `fix/k98-auth-keyboard`。
|
||||||
|
- 计划见 FruitServer `docs/plans/2026-10-21-K98-auth-keyboard.md`。
|
||||||
|
- 将统一 viewInsets/channel 有效键盘高度,并给登录页与手机号弹窗增加
|
||||||
|
焦点/键盘变化后的字段滚动校正。
|
||||||
|
- 已新增 `effectiveKeyboardInset`,兼容 iOS 实时 viewInsets 与 Android
|
||||||
|
`adjustNothing` 下的 MethodChannel 键盘高度。
|
||||||
|
- 登录/注册页已增加 ScrollController、notifier 监听和邮箱/密码焦点校正;
|
||||||
|
手机号弹窗已同样接入手机号/验证码框滚动校正。
|
||||||
|
- 已补 iOS 登录/注册、Android channel、手机号验证码框键盘避让测试。
|
||||||
|
|
||||||
|
门禁原始输出:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ dart analyze lib test
|
||||||
|
Analyzing lib, test...
|
||||||
|
No issues found!
|
||||||
|
|
||||||
|
$ flutter test --no-pub
|
||||||
|
00:52 +559: All tests passed!
|
||||||
|
|
||||||
|
$ flutter build apk --debug
|
||||||
|
Running Gradle task 'assembleDebug'... 26.2s
|
||||||
|
✓ Built build/app/outputs/flutter-apk/app-debug.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:静态分析零告警,559 项全量测试通过,debug APK 构建成功;
|
||||||
|
`pubspec.lock` 已还原,无依赖变更,未安装或操作真机。
|
||||||
|
|
||||||
|
## 2026-10-21 K98.1 收藏与观看记录直接进入播放页
|
||||||
|
|
||||||
|
- 已从最新 `origin/develop` 创建 `fix/k98.1-play-from-lists`。
|
||||||
|
- 收藏卡片已改为调用 `playDramaFromCatalog`,不再进入详情页。
|
||||||
|
- 观看记录卡片已按 `progress.episodeId` 直达播放页对应集;episodeId 为空时从第 1 集起播。
|
||||||
|
- 详情未加载完成的观看记录仍保持原禁用、加载和重试行为。
|
||||||
|
- 已补收藏及观看记录直达 PlayerPage 的路由断言。
|
||||||
|
|
||||||
|
门禁原始输出:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ dart analyze lib test
|
||||||
|
Analyzing lib, test...
|
||||||
|
No issues found!
|
||||||
|
|
||||||
|
$ flutter test --no-pub
|
||||||
|
01:09 +561: All tests passed!
|
||||||
|
|
||||||
|
$ flutter build apk --debug
|
||||||
|
Running Gradle task 'assembleDebug'... 28.7s
|
||||||
|
✓ Built build/app/outputs/flutter-apk/app-debug.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:静态分析零告警,561 项全量测试通过,debug APK 构建成功;
|
||||||
|
`pubspec.lock` 已还原,无依赖变更,未安装或操作真机。
|
||||||
|
|
||||||
|
## 2026-10-21 K98.2 修复 feed 更新触发 build 期通知
|
||||||
|
|
||||||
|
- 已从最新 `origin/develop` 创建 `fix/k98.2-feed-build-notify`。
|
||||||
|
- PlayerPage 的 feed 更新改为帧后调度,同一帧多次变化继续由 PlayerLogic 合并。
|
||||||
|
- PlayerLogic 在非 idle 调度阶段延后 feed 通知与 PageController 跳页,并给延迟通知去重。
|
||||||
|
- HomeLogic.onDramaChanged 在 build/layout/paint 阶段改为帧后通知。
|
||||||
|
- 已补 build 阶段 updateFeed、最终 PageController 位置及 HomeLogic 延后通知回归测试。
|
||||||
|
|
||||||
|
门禁原始输出:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ dart analyze lib test
|
||||||
|
Analyzing lib, test...
|
||||||
|
No issues found!
|
||||||
|
|
||||||
|
$ flutter test --no-pub
|
||||||
|
00:55 +563: All tests passed!
|
||||||
|
|
||||||
|
$ flutter build apk --debug
|
||||||
|
Running Gradle task 'assembleDebug'... 26.4s
|
||||||
|
✓ Built build/app/outputs/flutter-apk/app-debug.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:静态分析零告警,563 项全量测试通过,debug APK 构建成功;
|
||||||
|
`pubspec.lock` 已还原,无依赖变更,未安装或操作真机。
|
||||||
|
|
||||||
|
## 2026-10-21 K98.3 修复频道切换复用已释放视频控制器
|
||||||
|
|
||||||
|
- 已从最新 `origin/develop` 创建 `fix/k98.3-disposed-video-controller`。
|
||||||
|
- `PlaybackSession.active` 与 `preparedPlayerAt` 不再暴露 closed/retiring slot;
|
||||||
|
`_retire` 先清空活动引用并通知,再等待界面帧边界后暂停和释放播放器。
|
||||||
|
- `PlayerLogic._replaceSession` 切换 session 后等待 `endOfFrame`,并再次校验
|
||||||
|
revision 后才关闭旧 session;其它 session 回收路径同样使用该帧边界。
|
||||||
|
- 已补 retiring player 脱钩单测,以及真实 `PlayerVideoSurface` 经 feed 切换、
|
||||||
|
连续 pump 后不出现 disposed controller 异常的回归测试。
|
||||||
|
|
||||||
|
门禁原始输出:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ dart analyze lib test
|
||||||
|
Analyzing lib, test...
|
||||||
|
No issues found!
|
||||||
|
|
||||||
|
$ flutter test --no-pub
|
||||||
|
00:58 +565: All tests passed!
|
||||||
|
|
||||||
|
$ flutter build apk --debug
|
||||||
|
Running Gradle task 'assembleDebug'... 35.7s
|
||||||
|
✓ Built build/app/outputs/flutter-apk/app-debug.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:静态分析零告警,565 项全量测试通过,debug APK 构建成功;
|
||||||
|
`pubspec.lock` 已还原,无依赖变更,未安装或操作真机。
|
||||||
@@ -49,6 +49,7 @@ class PlaybackSession extends ChangeNotifier {
|
|||||||
this.prepare,
|
this.prepare,
|
||||||
this.activate,
|
this.activate,
|
||||||
PlaybackCoordinator? coordinator,
|
PlaybackCoordinator? coordinator,
|
||||||
|
this.waitForFrame,
|
||||||
}) : _coordinator = coordinator ?? PlaybackCoordinator();
|
}) : _coordinator = coordinator ?? PlaybackCoordinator();
|
||||||
|
|
||||||
final List<Episode> episodes;
|
final List<Episode> episodes;
|
||||||
@@ -60,6 +61,7 @@ class PlaybackSession extends ChangeNotifier {
|
|||||||
|
|
||||||
/// Runs only when a slot becomes current (e.g. progress listeners).
|
/// Runs only when a slot becomes current (e.g. progress listeners).
|
||||||
final PlayerPreparation? activate;
|
final PlayerPreparation? activate;
|
||||||
|
final Future<void> Function()? waitForFrame;
|
||||||
final PlaybackCoordinator _coordinator;
|
final PlaybackCoordinator _coordinator;
|
||||||
final Set<_PlayerSlot> _live = {};
|
final Set<_PlayerSlot> _live = {};
|
||||||
_PlayerSlot? _active;
|
_PlayerSlot? _active;
|
||||||
@@ -80,8 +82,16 @@ class PlaybackSession extends ChangeNotifier {
|
|||||||
|
|
||||||
int get currentIndex => _currentIndex;
|
int get currentIndex => _currentIndex;
|
||||||
Episode get currentEpisode => episodes[_currentIndex];
|
Episode get currentEpisode => episodes[_currentIndex];
|
||||||
PlayerPort? get active =>
|
PlayerPort? get active {
|
||||||
_active?.index == _currentIndex ? _active?.player : null;
|
final slot = _active;
|
||||||
|
if (_closed ||
|
||||||
|
slot == null ||
|
||||||
|
slot.retiring ||
|
||||||
|
slot.index != _currentIndex) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return slot.player;
|
||||||
|
}
|
||||||
bool get loading => _loading;
|
bool get loading => _loading;
|
||||||
bool get userWantsPlay => _userWantsPlay;
|
bool get userWantsPlay => _userWantsPlay;
|
||||||
bool get canAutoAdvance => _mayPlay && !_loading && active != null;
|
bool get canAutoAdvance => _mayPlay && !_loading && active != null;
|
||||||
@@ -99,8 +109,15 @@ class PlaybackSession extends ChangeNotifier {
|
|||||||
/// Exposes only prepared slots so the adjacent page can mount its video texture
|
/// Exposes only prepared slots so the adjacent page can mount its video texture
|
||||||
/// during a swipe without starting audio or registering history listeners.
|
/// during a swipe without starting audio or registering history listeners.
|
||||||
PlayerPort? preparedPlayerAt(int index) {
|
PlayerPort? preparedPlayerAt(int index) {
|
||||||
|
if (_closed) return null;
|
||||||
for (final slot in [_active, _next]) {
|
for (final slot in [_active, _next]) {
|
||||||
if (slot?.index == index && slot!.prepared) return slot.player;
|
if (slot == null ||
|
||||||
|
slot.retiring ||
|
||||||
|
slot.index != index ||
|
||||||
|
!slot.prepared) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return slot.player;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -162,6 +179,11 @@ class PlaybackSession extends ChangeNotifier {
|
|||||||
Future<void> _retire(_PlayerSlot? slot) async {
|
Future<void> _retire(_PlayerSlot? slot) async {
|
||||||
if (slot == null || !_live.contains(slot)) return;
|
if (slot == null || !_live.contains(slot)) return;
|
||||||
slot.retiring = true;
|
slot.retiring = true;
|
||||||
|
final cleared = _active == slot || _next == slot;
|
||||||
|
if (_active == slot) _active = null;
|
||||||
|
if (_next == slot) _next = null;
|
||||||
|
if (cleared) _notify();
|
||||||
|
await waitForFrame?.call();
|
||||||
await slot.ready;
|
await slot.ready;
|
||||||
try {
|
try {
|
||||||
await slot.player.pause();
|
await slot.player.pause();
|
||||||
|
|||||||
@@ -1,8 +1,29 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
/// iOS 使用实时 viewInsets;Android adjustNothing 下取 MethodChannel 最大值。
|
||||||
|
double effectiveKeyboardInset(
|
||||||
|
BuildContext context,
|
||||||
|
KeyboardInsetNotifier notifier, {
|
||||||
|
double maxRatio = 0.6,
|
||||||
|
}) {
|
||||||
|
final media = MediaQuery.of(context);
|
||||||
|
final viewInset = media.viewInsets.bottom;
|
||||||
|
final channelInset =
|
||||||
|
!kIsWeb && defaultTargetPlatform == TargetPlatform.android
|
||||||
|
? notifier.value / media.devicePixelRatio
|
||||||
|
: 0.0;
|
||||||
|
final inset = math.max(viewInset, channelInset);
|
||||||
|
final maxInset = media.size.height * maxRatio;
|
||||||
|
if (!inset.isFinite || inset > maxInset) return 0;
|
||||||
|
return math.max(0, inset);
|
||||||
|
}
|
||||||
|
|
||||||
/// Provides the Android IME height even when `adjustNothing` keeps
|
/// Provides the Android IME height even when `adjustNothing` keeps
|
||||||
/// `MediaQuery.viewInsets` at zero.
|
/// `MediaQuery.viewInsets` at zero.
|
||||||
class KeyboardInsetNotifier extends ChangeNotifier {
|
class KeyboardInsetNotifier extends ChangeNotifier {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:provider/provider.dart';
|
|||||||
import '../../core/data/remote/api_client.dart';
|
import '../../core/data/remote/api_client.dart';
|
||||||
import '../../core/data/services/auth_controller.dart';
|
import '../../core/data/services/auth_controller.dart';
|
||||||
import '../../core/platform/haptics.dart';
|
import '../../core/platform/haptics.dart';
|
||||||
|
import '../../core/platform/keyboard_inset.dart';
|
||||||
import '../../core/theme/app_colors.dart';
|
import '../../core/theme/app_colors.dart';
|
||||||
import '../../core/theme/app_dimens.dart';
|
import '../../core/theme/app_dimens.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
@@ -101,6 +102,9 @@ class _AuthPageState extends State<AuthPage> {
|
|||||||
late final TextEditingController _passwordController;
|
late final TextEditingController _passwordController;
|
||||||
late final FocusNode _emailFocus;
|
late final FocusNode _emailFocus;
|
||||||
late final FocusNode _passwordFocus;
|
late final FocusNode _passwordFocus;
|
||||||
|
late final ScrollController _scrollController;
|
||||||
|
final _keyboardInset = KeyboardInsetNotifier.instance;
|
||||||
|
double _lastKeyboardInset = 0;
|
||||||
bool _obscurePassword = true;
|
bool _obscurePassword = true;
|
||||||
bool _emailEntryOpen = false;
|
bool _emailEntryOpen = false;
|
||||||
|
|
||||||
@@ -112,17 +116,87 @@ class _AuthPageState extends State<AuthPage> {
|
|||||||
_passwordController = TextEditingController();
|
_passwordController = TextEditingController();
|
||||||
_emailFocus = FocusNode(debugLabel: 'auth-email-focus');
|
_emailFocus = FocusNode(debugLabel: 'auth-email-focus');
|
||||||
_passwordFocus = FocusNode(debugLabel: 'auth-password-focus');
|
_passwordFocus = FocusNode(debugLabel: 'auth-password-focus');
|
||||||
|
_scrollController = ScrollController();
|
||||||
|
_emailFocus.addListener(_onFocusChanged);
|
||||||
|
_passwordFocus.addListener(_onFocusChanged);
|
||||||
|
_keyboardInset.addListener(_onKeyboardInsetChanged);
|
||||||
|
unawaited(_keyboardInset.start());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
final inset = effectiveKeyboardInset(context, _keyboardInset);
|
||||||
|
if ((inset - _lastKeyboardInset).abs() >= 1) {
|
||||||
|
_lastKeyboardInset = inset;
|
||||||
|
_scheduleRevealFocusedField();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_keyboardInset.removeListener(_onKeyboardInsetChanged);
|
||||||
|
_emailFocus.removeListener(_onFocusChanged);
|
||||||
|
_passwordFocus.removeListener(_onFocusChanged);
|
||||||
_emailController.dispose();
|
_emailController.dispose();
|
||||||
_passwordController.dispose();
|
_passwordController.dispose();
|
||||||
_emailFocus.dispose();
|
_emailFocus.dispose();
|
||||||
_passwordFocus.dispose();
|
_passwordFocus.dispose();
|
||||||
|
_scrollController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onKeyboardInsetChanged() {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {});
|
||||||
|
_scheduleRevealFocusedField();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onFocusChanged() {
|
||||||
|
if (_emailFocus.hasFocus || _passwordFocus.hasFocus) {
|
||||||
|
_scheduleRevealFocusedField();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleRevealFocusedField() {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) _revealFocusedField();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _revealFocusedField() {
|
||||||
|
if (!_scrollController.hasClients) return;
|
||||||
|
final focus = _passwordFocus.hasFocus
|
||||||
|
? _passwordFocus
|
||||||
|
: _emailFocus.hasFocus
|
||||||
|
? _emailFocus
|
||||||
|
: null;
|
||||||
|
final fieldContext = focus?.context;
|
||||||
|
final renderObject = fieldContext?.findRenderObject();
|
||||||
|
if (renderObject is! RenderBox || !renderObject.hasSize) return;
|
||||||
|
final keyboardInset = effectiveKeyboardInset(context, _keyboardInset);
|
||||||
|
if (keyboardInset <= 0) return;
|
||||||
|
final fieldBottom = renderObject
|
||||||
|
.localToGlobal(Offset(0, renderObject.size.height))
|
||||||
|
.dy;
|
||||||
|
final visibleBottom =
|
||||||
|
MediaQuery.sizeOf(context).height - keyboardInset - 24;
|
||||||
|
final overlap = fieldBottom - visibleBottom;
|
||||||
|
if (overlap <= 0) return;
|
||||||
|
final position = _scrollController.position;
|
||||||
|
final target = (position.pixels + overlap)
|
||||||
|
.clamp(position.minScrollExtent, position.maxScrollExtent)
|
||||||
|
.toDouble();
|
||||||
|
if (target <= position.pixels + 0.5) return;
|
||||||
|
unawaited(
|
||||||
|
_scrollController.animateTo(
|
||||||
|
target,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
String _errorText(AppLocalizations l10n, AuthFieldError error) {
|
String _errorText(AppLocalizations l10n, AuthFieldError error) {
|
||||||
switch (error) {
|
switch (error) {
|
||||||
case AuthFieldError.emailRequired:
|
case AuthFieldError.emailRequired:
|
||||||
@@ -246,12 +320,13 @@ class _AuthPageState extends State<AuthPage> {
|
|||||||
: _topContentPadding;
|
: _topContentPadding;
|
||||||
final bottom =
|
final bottom =
|
||||||
_bottomContentPadding +
|
_bottomContentPadding +
|
||||||
MediaQuery.viewInsetsOf(context).bottom;
|
effectiveKeyboardInset(context, _keyboardInset);
|
||||||
final available = constraints.maxHeight - top - bottom;
|
final available = constraints.maxHeight - top - bottom;
|
||||||
final minHeight = available > 0 ? available : 0.0;
|
final minHeight = available > 0 ? available : 0.0;
|
||||||
return AutofillGroup(
|
return AutofillGroup(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
key: const Key('auth-scroll'),
|
key: const Key('auth-scroll'),
|
||||||
|
controller: _scrollController,
|
||||||
keyboardDismissBehavior:
|
keyboardDismissBehavior:
|
||||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||||
padding: EdgeInsets.fromLTRB(
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import 'package:provider/provider.dart';
|
|||||||
import '../../core/data/remote/api_client.dart';
|
import '../../core/data/remote/api_client.dart';
|
||||||
import '../../core/data/services/auth_controller.dart';
|
import '../../core/data/services/auth_controller.dart';
|
||||||
import '../../core/platform/haptics.dart';
|
import '../../core/platform/haptics.dart';
|
||||||
|
import '../../core/platform/keyboard_inset.dart';
|
||||||
import '../../core/theme/app_colors.dart';
|
import '../../core/theme/app_colors.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
@@ -46,7 +47,10 @@ class _PhoneAuthSheetState extends State<PhoneAuthSheet> {
|
|||||||
late final TextEditingController _code;
|
late final TextEditingController _code;
|
||||||
late final FocusNode _phoneFocus;
|
late final FocusNode _phoneFocus;
|
||||||
late final FocusNode _codeFocus;
|
late final FocusNode _codeFocus;
|
||||||
|
late final ScrollController _scrollController;
|
||||||
|
final _keyboardInset = KeyboardInsetNotifier.instance;
|
||||||
Timer? _countdown;
|
Timer? _countdown;
|
||||||
|
double _lastKeyboardInset = 0;
|
||||||
bool _signUp = false;
|
bool _signUp = false;
|
||||||
bool _sending = false;
|
bool _sending = false;
|
||||||
bool _submitting = false;
|
bool _submitting = false;
|
||||||
@@ -61,18 +65,88 @@ class _PhoneAuthSheetState extends State<PhoneAuthSheet> {
|
|||||||
_code = TextEditingController();
|
_code = TextEditingController();
|
||||||
_phoneFocus = FocusNode(debugLabel: 'phone-auth-number');
|
_phoneFocus = FocusNode(debugLabel: 'phone-auth-number');
|
||||||
_codeFocus = FocusNode(debugLabel: 'phone-auth-code');
|
_codeFocus = FocusNode(debugLabel: 'phone-auth-code');
|
||||||
|
_scrollController = ScrollController();
|
||||||
|
_phoneFocus.addListener(_onFocusChanged);
|
||||||
|
_codeFocus.addListener(_onFocusChanged);
|
||||||
|
_keyboardInset.addListener(_onKeyboardInsetChanged);
|
||||||
|
unawaited(_keyboardInset.start());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
final inset = effectiveKeyboardInset(context, _keyboardInset);
|
||||||
|
if ((inset - _lastKeyboardInset).abs() >= 1) {
|
||||||
|
_lastKeyboardInset = inset;
|
||||||
|
_scheduleRevealFocusedField();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_keyboardInset.removeListener(_onKeyboardInsetChanged);
|
||||||
|
_phoneFocus.removeListener(_onFocusChanged);
|
||||||
|
_codeFocus.removeListener(_onFocusChanged);
|
||||||
_countdown?.cancel();
|
_countdown?.cancel();
|
||||||
_phone.dispose();
|
_phone.dispose();
|
||||||
_code.dispose();
|
_code.dispose();
|
||||||
_phoneFocus.dispose();
|
_phoneFocus.dispose();
|
||||||
_codeFocus.dispose();
|
_codeFocus.dispose();
|
||||||
|
_scrollController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onKeyboardInsetChanged() {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {});
|
||||||
|
_scheduleRevealFocusedField();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onFocusChanged() {
|
||||||
|
if (_phoneFocus.hasFocus || _codeFocus.hasFocus) {
|
||||||
|
_scheduleRevealFocusedField();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleRevealFocusedField() {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) _revealFocusedField();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _revealFocusedField() {
|
||||||
|
if (!_scrollController.hasClients) return;
|
||||||
|
final focus = _codeFocus.hasFocus
|
||||||
|
? _codeFocus
|
||||||
|
: _phoneFocus.hasFocus
|
||||||
|
? _phoneFocus
|
||||||
|
: null;
|
||||||
|
final fieldContext = focus?.context;
|
||||||
|
final renderObject = fieldContext?.findRenderObject();
|
||||||
|
if (renderObject is! RenderBox || !renderObject.hasSize) return;
|
||||||
|
final keyboardInset = effectiveKeyboardInset(context, _keyboardInset);
|
||||||
|
if (keyboardInset <= 0) return;
|
||||||
|
final fieldBottom = renderObject
|
||||||
|
.localToGlobal(Offset(0, renderObject.size.height))
|
||||||
|
.dy;
|
||||||
|
final visibleBottom =
|
||||||
|
MediaQuery.sizeOf(context).height - keyboardInset - 24;
|
||||||
|
final overlap = fieldBottom - visibleBottom;
|
||||||
|
if (overlap <= 0) return;
|
||||||
|
final position = _scrollController.position;
|
||||||
|
final target = (position.pixels + overlap)
|
||||||
|
.clamp(position.minScrollExtent, position.maxScrollExtent)
|
||||||
|
.toDouble();
|
||||||
|
if (target <= position.pixels + 0.5) return;
|
||||||
|
unawaited(
|
||||||
|
_scrollController.animateTo(
|
||||||
|
target,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
String get _target => _phone.text.replaceAll(RegExp(r'[\s-]'), '');
|
String get _target => _phone.text.replaceAll(RegExp(r'[\s-]'), '');
|
||||||
|
|
||||||
bool get _phoneValid => _target.length >= _phoneMinLength;
|
bool get _phoneValid => _target.length >= _phoneMinLength;
|
||||||
@@ -99,9 +173,8 @@ class _PhoneAuthSheetState extends State<PhoneAuthSheet> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_startCountdown();
|
_startCountdown();
|
||||||
setState(() => _error = null);
|
setState(() => _error = null);
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(context)
|
||||||
context,
|
.showSnackBar(SnackBar(content: Text(l10n.authCodeSent)));
|
||||||
).showSnackBar(SnackBar(content: Text(l10n.authCodeSent)));
|
|
||||||
_codeFocus.requestFocus();
|
_codeFocus.requestFocus();
|
||||||
} on ApiException catch (error) {
|
} on ApiException catch (error) {
|
||||||
if (mounted) setState(() => _error = error.message);
|
if (mounted) setState(() => _error = error.message);
|
||||||
@@ -166,16 +239,19 @@ class _PhoneAuthSheetState extends State<PhoneAuthSheet> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final bottom = MediaQuery.viewInsetsOf(context).bottom;
|
final bottom = effectiveKeyboardInset(context, _keyboardInset);
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: EdgeInsets.only(bottom: bottom),
|
padding: EdgeInsets.only(bottom: bottom),
|
||||||
child: Container(
|
child: Container(
|
||||||
key: const Key('phone-auth-sheet'),
|
key: const Key('phone-auth-sheet'),
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
color: AppColors.card,
|
color: AppColors.card,
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(_sheetRadius)),
|
borderRadius: BorderRadius.vertical(
|
||||||
|
top: Radius.circular(_sheetRadius),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
|
controller: _scrollController,
|
||||||
padding: const EdgeInsets.fromLTRB(22, 10, 22, 26),
|
padding: const EdgeInsets.fromLTRB(22, 10, 22, 26),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -278,9 +354,10 @@ class _PhoneAuthSheetState extends State<PhoneAuthSheet> {
|
|||||||
onSubmitted: (_) => _submit(),
|
onSubmitted: (_) => _submit(),
|
||||||
onChanged: (_) => setState(() => _error = null),
|
onChanged: (_) => setState(() => _error = null),
|
||||||
style: const TextStyle(color: AppColors.onSurface, fontSize: 15),
|
style: const TextStyle(color: AppColors.onSurface, fontSize: 15),
|
||||||
decoration: _decoration(l10n.authCodeLabel, l10n.authCodeHint).copyWith(
|
decoration: _decoration(
|
||||||
suffixIcon: _sendButton(l10n),
|
l10n.authCodeLabel,
|
||||||
),
|
l10n.authCodeHint,
|
||||||
|
).copyWith(suffixIcon: _sendButton(l10n)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import '../../core/widgets/empty_state.dart';
|
|||||||
import '../../core/widgets/remote_image.dart';
|
import '../../core/widgets/remote_image.dart';
|
||||||
import '../../core/widgets/subpage_header.dart';
|
import '../../core/widgets/subpage_header.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../drama_detail/drama_detail_view.dart';
|
import '../player/open_player.dart';
|
||||||
import 'favorites_binding.dart';
|
import 'favorites_binding.dart';
|
||||||
|
|
||||||
const double _pageHorizontal = 14;
|
const double _pageHorizontal = 14;
|
||||||
@@ -194,9 +194,7 @@ class _FavoritesBody extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _open(BuildContext context, Drama drama) async {
|
Future<void> _open(BuildContext context, Drama drama) async {
|
||||||
await Navigator.of(context).push<void>(
|
await playDramaFromCatalog(context, drama);
|
||||||
MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _remove(BuildContext context, String dramaId) async {
|
Future<void> _remove(BuildContext context, String dramaId) async {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/scheduler.dart';
|
||||||
|
|
||||||
import '../../core/data/models/drama.dart';
|
import '../../core/data/models/drama.dart';
|
||||||
import '../discover/discover_types.dart';
|
import '../discover/discover_types.dart';
|
||||||
@@ -22,6 +23,7 @@ class HomeLogic extends ChangeNotifier {
|
|||||||
bool drawerOpen = false;
|
bool drawerOpen = false;
|
||||||
bool detailOpen = false;
|
bool detailOpen = false;
|
||||||
bool videoImmersive = false;
|
bool videoImmersive = false;
|
||||||
|
bool _dramaNotifyScheduled = false;
|
||||||
bool _disposed = false;
|
bool _disposed = false;
|
||||||
|
|
||||||
void notify() {
|
void notify() {
|
||||||
@@ -120,7 +122,17 @@ class HomeLogic extends ChangeNotifier {
|
|||||||
|
|
||||||
void onDramaChanged(Drama drama) {
|
void onDramaChanged(Drama drama) {
|
||||||
currentDrama = drama;
|
currentDrama = drama;
|
||||||
notify();
|
if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.idle) {
|
||||||
|
notify();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_dramaNotifyScheduled || _disposed) return;
|
||||||
|
_dramaNotifyScheduled = true;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_dramaNotifyScheduled = false;
|
||||||
|
if (!_disposed) notify();
|
||||||
|
});
|
||||||
|
SchedulerBinding.instance.scheduleFrame();
|
||||||
}
|
}
|
||||||
|
|
||||||
void onImmersiveChanged(bool value) {
|
void onImmersiveChanged(bool value) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../../core/data/models/drama.dart';
|
import '../../core/data/models/drama.dart';
|
||||||
@@ -39,13 +40,16 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
required this.pictureInPicture,
|
required this.pictureInPicture,
|
||||||
this.wallet,
|
this.wallet,
|
||||||
this.downloads,
|
this.downloads,
|
||||||
});
|
Future<void> Function()? waitForFrame,
|
||||||
|
}) : _waitForFrame =
|
||||||
|
waitForFrame ?? (() => WidgetsBinding.instance.endOfFrame);
|
||||||
|
|
||||||
final WatchHistoryRepository history;
|
final WatchHistoryRepository history;
|
||||||
final PlaybackRepository playback;
|
final PlaybackRepository playback;
|
||||||
final AndroidPictureInPicture pictureInPicture;
|
final AndroidPictureInPicture pictureInPicture;
|
||||||
final WalletController? wallet;
|
final WalletController? wallet;
|
||||||
final DownloadController? downloads;
|
final DownloadController? downloads;
|
||||||
|
final Future<void> Function() _waitForFrame;
|
||||||
|
|
||||||
late PageController pages;
|
late PageController pages;
|
||||||
late PlaybackQueue queue;
|
late PlaybackQueue queue;
|
||||||
@@ -108,6 +112,8 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
bool _latestResetToFirst = false;
|
bool _latestResetToFirst = false;
|
||||||
bool _feedUpdateRunning = false;
|
bool _feedUpdateRunning = false;
|
||||||
bool _hasPendingFeed = false;
|
bool _hasPendingFeed = false;
|
||||||
|
bool _deferredFeedNotifyScheduled = false;
|
||||||
|
int _feedJumpSerial = 0;
|
||||||
Future<void> _feedUpdateFuture = Future<void>.value();
|
Future<void> _feedUpdateFuture = Future<void>.value();
|
||||||
|
|
||||||
/// [start] 是否已经跑过:没跑过时 session/pages 还是 late 未初始化,
|
/// [start] 是否已经跑过:没跑过时 session/pages 还是 late 未初始化,
|
||||||
@@ -217,6 +223,7 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
prepare: preparePlayer,
|
prepare: preparePlayer,
|
||||||
activate: activatePlayer,
|
activate: activatePlayer,
|
||||||
coordinator: coordinator,
|
coordinator: coordinator,
|
||||||
|
waitForFrame: _waitForFrame,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,7 +266,7 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
if (dramas.isNotEmpty) pageDrama = dramas.first;
|
if (dramas.isNotEmpty) pageDrama = dramas.first;
|
||||||
if (nextQueue.length == 0) {
|
if (nextQueue.length == 0) {
|
||||||
session.setRouteVisible(false);
|
session.setRouteVisible(false);
|
||||||
notify();
|
_notifyAfterFrameIfNeeded();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final keepIndex =
|
final keepIndex =
|
||||||
@@ -275,7 +282,7 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
preloadIndex: nextVisibleIndex(keepIndex),
|
preloadIndex: nextVisibleIndex(keepIndex),
|
||||||
);
|
);
|
||||||
_jumpToFeedPage(keepIndex);
|
_jumpToFeedPage(keepIndex);
|
||||||
notify();
|
_notifyAfterFrameIfNeeded();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await _replaceSession(
|
await _replaceSession(
|
||||||
@@ -303,7 +310,9 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
final nextSession = _createSession(factory, coordinator);
|
final nextSession = _createSession(factory, coordinator);
|
||||||
session = nextSession;
|
session = nextSession;
|
||||||
nextSession.setRouteVisible(active && visibleIndices.isNotEmpty);
|
nextSession.setRouteVisible(active && visibleIndices.isNotEmpty);
|
||||||
notify();
|
_notifyAfterFrameIfNeeded();
|
||||||
|
await _waitForFrame();
|
||||||
|
if (revision != feedRevision || !identical(session, nextSession)) return;
|
||||||
await previous.close();
|
await previous.close();
|
||||||
if (revision != feedRevision || !identical(session, nextSession)) return;
|
if (revision != feedRevision || !identical(session, nextSession)) return;
|
||||||
_jumpToFeedPage(targetIndex);
|
_jumpToFeedPage(targetIndex);
|
||||||
@@ -312,13 +321,32 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
|
|
||||||
void _jumpToFeedPage(int index) {
|
void _jumpToFeedPage(int index) {
|
||||||
if (!_started) return;
|
if (!_started) return;
|
||||||
if (pages.hasClients) {
|
final serial = ++_feedJumpSerial;
|
||||||
|
if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.idle &&
|
||||||
|
pages.hasClients) {
|
||||||
pages.jumpToPage(index);
|
pages.jumpToPage(index);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (_disposed || serial != _feedJumpSerial || !_started) return;
|
||||||
if (pages.hasClients) pages.jumpToPage(index);
|
if (pages.hasClients) pages.jumpToPage(index);
|
||||||
});
|
});
|
||||||
|
SchedulerBinding.instance.scheduleFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _notifyAfterFrameIfNeeded() {
|
||||||
|
if (_disposed) return;
|
||||||
|
if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.idle) {
|
||||||
|
notify();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_deferredFeedNotifyScheduled) return;
|
||||||
|
_deferredFeedNotifyScheduled = true;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_deferredFeedNotifyScheduled = false;
|
||||||
|
if (!_disposed) notify();
|
||||||
|
});
|
||||||
|
SchedulerBinding.instance.scheduleFrame();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 服务端续看位置:进播放页时取一次,用于"定位到上次那一集的那一秒"。
|
/// 服务端续看位置:进播放页时取一次,用于"定位到上次那一集的那一秒"。
|
||||||
|
|||||||
@@ -139,11 +139,17 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
if (widget.homeMode &&
|
if (widget.homeMode &&
|
||||||
widget.feedDramas != null &&
|
widget.feedDramas != null &&
|
||||||
(feedChanged || channelChanged)) {
|
(feedChanged || channelChanged)) {
|
||||||
logic.updateFeed(
|
final feedDramas = widget.feedDramas!;
|
||||||
widget.feedDramas!,
|
final keepDramaId = channelChanged ? null : logic.currentDramaId;
|
||||||
keepDramaId: channelChanged ? null : logic.currentDramaId,
|
final resetToFirst = channelChanged;
|
||||||
resetToFirst: channelChanged,
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
);
|
if (!mounted) return;
|
||||||
|
logic.updateFeed(
|
||||||
|
feedDramas,
|
||||||
|
keepDramaId: keepDramaId,
|
||||||
|
resetToFirst: resetToFirst,
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
logic.onPageUpdated(oldWidget);
|
logic.onPageUpdated(oldWidget);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../../core/data/models/drama.dart';
|
import '../../core/data/models/watch_record.dart';
|
||||||
import '../../core/data/repositories/catalog_repository.dart';
|
import '../../core/data/repositories/catalog_repository.dart';
|
||||||
import '../../core/data/repositories/watch_history_repository.dart';
|
import '../../core/data/repositories/watch_history_repository.dart';
|
||||||
import '../../core/data/services/auth_controller.dart';
|
import '../../core/data/services/auth_controller.dart';
|
||||||
@@ -19,7 +19,7 @@ import '../../core/widgets/auth_gate.dart';
|
|||||||
import '../../core/widgets/empty_state.dart';
|
import '../../core/widgets/empty_state.dart';
|
||||||
import '../../core/widgets/subpage_header.dart';
|
import '../../core/widgets/subpage_header.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../drama_detail/drama_detail_view.dart';
|
import '../player/open_player.dart';
|
||||||
import 'watch_records_binding.dart';
|
import 'watch_records_binding.dart';
|
||||||
import 'widgets/watch_record_card.dart';
|
import 'widgets/watch_record_card.dart';
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> {
|
|||||||
record: record,
|
record: record,
|
||||||
onRetry: () => controller.retryDetail(record.dramaId),
|
onRetry: () => controller.retryDetail(record.dramaId),
|
||||||
onRemove: () => _remove(record.dramaId),
|
onRemove: () => _remove(record.dramaId),
|
||||||
onOpen: record.drama == null ? null : () => _open(record.drama!),
|
onOpen: record.drama == null ? null : () => _open(record),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -191,9 +191,12 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _open(Drama drama) async {
|
Future<void> _open(WatchRecord record) async {
|
||||||
await Navigator.of(context).push<void>(
|
final episodeId = record.progress.episodeId;
|
||||||
MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)),
|
await playDramaFromCatalog(
|
||||||
|
context,
|
||||||
|
record.drama!,
|
||||||
|
episodeId: episodeId.isEmpty ? null : episodeId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
||||||
@@ -263,6 +265,90 @@ void main() {
|
|||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
testWidgets('should_keep_password_above_ios_keyboard', (tester) async {
|
||||||
|
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||||
|
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||||
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
addTearDown(tester.view.resetViewInsets);
|
||||||
|
|
||||||
|
await pumpAuthPage(tester);
|
||||||
|
await tester.tap(find.byKey(const Key('auth-password')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
_expectAboveKeyboard(tester, const Key('auth-password'), 300);
|
||||||
|
debugDefaultTargetPlatformOverride = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('should_keep_signup_password_above_ios_keyboard', (tester) async {
|
||||||
|
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||||
|
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||||
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
addTearDown(tester.view.resetViewInsets);
|
||||||
|
|
||||||
|
await pumpAuthPage(tester, signUp: true);
|
||||||
|
await tester.tap(find.byKey(const Key('auth-password')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
_expectAboveKeyboard(tester, const Key('auth-password'), 300);
|
||||||
|
debugDefaultTargetPlatformOverride = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('should_keep_password_above_android_channel_keyboard', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||||
|
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||||
|
const keyboardChannel = MethodChannel('com.stormg.lumadrama/keyboard');
|
||||||
|
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||||
|
keyboardChannel,
|
||||||
|
(call) async => call.method == 'get' ? 300 : null,
|
||||||
|
);
|
||||||
|
addTearDown(() {
|
||||||
|
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||||
|
keyboardChannel,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
tester.view.viewInsets = const FakeViewPadding();
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
addTearDown(tester.view.resetViewInsets);
|
||||||
|
|
||||||
|
await pumpAuthPage(tester);
|
||||||
|
await tester.binding.defaultBinaryMessenger.handlePlatformMessage(
|
||||||
|
keyboardChannel.name,
|
||||||
|
const StandardMethodCodec().encodeMethodCall(
|
||||||
|
const MethodCall('insetsChanged', 300),
|
||||||
|
),
|
||||||
|
(_) {},
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.byKey(const Key('auth-password')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
_expectAboveKeyboard(tester, const Key('auth-password'), 300);
|
||||||
|
debugDefaultTargetPlatformOverride = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _expectAboveKeyboard(WidgetTester tester, Key fieldKey, double inset) {
|
||||||
|
final screenHeight =
|
||||||
|
tester.view.physicalSize.height / tester.view.devicePixelRatio;
|
||||||
|
expect(
|
||||||
|
tester.getBottomLeft(find.byKey(fieldKey)).dy,
|
||||||
|
lessThanOrEqualTo(screenHeight - inset),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PendingAuthApi extends AuthApi {
|
class _PendingAuthApi extends AuthApi {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
||||||
@@ -97,6 +98,38 @@ void main() {
|
|||||||
expect(find.text('Enter a valid phone number'), findsOneWidget);
|
expect(find.text('Enter a valid phone number'), findsOneWidget);
|
||||||
expect(adapter.requests, isEmpty);
|
expect(adapter.requests, isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('should_keep_code_above_ios_keyboard', (tester) async {
|
||||||
|
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||||
|
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||||
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
addTearDown(tester.view.resetViewInsets);
|
||||||
|
final adapter = FakeHttpAdapter(
|
||||||
|
(_) async => FakeHttpResponse(
|
||||||
|
jsonEncode({'code': 0, 'message': 'ok', 'data': null}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final auth = AuthController(
|
||||||
|
AuthApi(ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter)),
|
||||||
|
AuthSessionStore(),
|
||||||
|
);
|
||||||
|
await _pumpHost(tester, auth);
|
||||||
|
await tester.tap(find.byKey(const Key('open-phone-sheet')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('auth-phone-code')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
tester.getBottomLeft(find.byKey(const Key('auth-phone-code'))).dy,
|
||||||
|
lessThanOrEqualTo(844 - 300),
|
||||||
|
);
|
||||||
|
debugDefaultTargetPlatformOverride = null;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pumpHost(WidgetTester tester, AuthController auth) {
|
Future<void> _pumpHost(WidgetTester tester, AuthController auth) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||||
import 'package:fruitshorts/pages/discover/discover_types.dart';
|
import 'package:fruitshorts/pages/discover/discover_types.dart';
|
||||||
@@ -121,6 +122,40 @@ void main() {
|
|||||||
logic.dispose();
|
logic.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('should_defer_drama_notification_when_changed_during_build', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final logic = HomeLogic(dramas);
|
||||||
|
var notifications = 0;
|
||||||
|
var immediateNotifications = -1;
|
||||||
|
var changed = false;
|
||||||
|
logic.addListener(() => notifications++);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: ListenableBuilder(
|
||||||
|
listenable: logic,
|
||||||
|
builder: (context, _) {
|
||||||
|
if (!changed) {
|
||||||
|
changed = true;
|
||||||
|
logic.onDramaChanged(dramas[1]);
|
||||||
|
immediateNotifications = notifications;
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(immediateNotifications, 0);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(logic.currentDrama.id, 'city');
|
||||||
|
await tester.pump();
|
||||||
|
expect(notifications, 1);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
logic.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
test('should_map_onto_root_tabs_when_my_page_actions_fire', () {
|
test('should_map_onto_root_tabs_when_my_page_actions_fire', () {
|
||||||
final logic = HomeLogic(dramas);
|
final logic = HomeLogic(dramas);
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:fruitshorts/app.dart';
|
||||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||||
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
||||||
import 'package:fruitshorts/core/data/remote/auth_api.dart';
|
import 'package:fruitshorts/core/data/remote/auth_api.dart';
|
||||||
import 'package:fruitshorts/core/data/repositories/favorite_repository.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/core/data/services/auth_controller.dart';
|
import 'package:fruitshorts/core/data/services/auth_controller.dart';
|
||||||
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
|
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
|
||||||
import 'package:fruitshorts/core/data/services/favorite_controller.dart';
|
import 'package:fruitshorts/core/data/services/favorite_controller.dart';
|
||||||
@@ -14,13 +17,20 @@ import 'package:fruitshorts/core/theme/app_colors.dart';
|
|||||||
import 'package:fruitshorts/core/widgets/app_pull_to_refresh.dart';
|
import 'package:fruitshorts/core/widgets/app_pull_to_refresh.dart';
|
||||||
import 'package:fruitshorts/core/widgets/empty_state.dart';
|
import 'package:fruitshorts/core/widgets/empty_state.dart';
|
||||||
import 'package:fruitshorts/l10n/app_localizations.dart';
|
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||||
|
import 'package:fruitshorts/pages/drama_detail/drama_detail_view.dart';
|
||||||
import 'package:fruitshorts/pages/favorites/favorites_page.dart';
|
import 'package:fruitshorts/pages/favorites/favorites_page.dart';
|
||||||
|
import 'package:fruitshorts/pages/player/player_view.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.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';
|
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
||||||
|
|
||||||
import '../support/fake_http_adapter.dart';
|
import '../support/fake_http_adapter.dart';
|
||||||
|
import '../support/fake_catalog.dart';
|
||||||
|
import '../support/fake_category.dart';
|
||||||
import '../support/fake_haptics.dart';
|
import '../support/fake_haptics.dart';
|
||||||
|
import '../support/fake_interaction_repositories.dart';
|
||||||
|
import '../support/fake_notice_repository.dart';
|
||||||
|
import '../support/widget_player.dart';
|
||||||
|
|
||||||
Drama _card(String id) => Drama(
|
Drama _card(String id) => Drama(
|
||||||
id: id,
|
id: id,
|
||||||
@@ -152,6 +162,43 @@ void main() {
|
|||||||
expect(repository.listCalls, 0);
|
expect(repository.listCalls, 0);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
testWidgets('should_open_player_when_favorite_card_is_tapped', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await _auth(signedIn: true);
|
||||||
|
final catalog = FakeCatalogRepository();
|
||||||
|
final drama = catalog.dramas.first;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
LumaDramaApp(
|
||||||
|
playerFactory: createWidgetPlayer,
|
||||||
|
catalogRepository: catalog,
|
||||||
|
categoryRepository: FakeCategoryRepository(catalog: catalog),
|
||||||
|
watchHistoryRepository: InMemoryWatchHistoryRepository(),
|
||||||
|
favoriteRepository: InMemoryFavoriteRepository(
|
||||||
|
saved: {drama.id},
|
||||||
|
cards: [drama],
|
||||||
|
),
|
||||||
|
playbackRepository: FakePlaybackRepository(),
|
||||||
|
commentRepository: FakeCommentRepository(),
|
||||||
|
interactionRepository: FakeInteractionRepository(),
|
||||||
|
noticeRepository: FakeNoticeRepository(),
|
||||||
|
haptics: FakeHaptics(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('tab-mine')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('mine-watchlist')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(Key('favorite-card-${drama.id}')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(PlayerPage), findsOneWidget);
|
||||||
|
expect(find.byType(DramaDetailPage), findsNothing);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<AuthController> _auth({required bool signedIn}) async {
|
Future<AuthController> _auth({required bool signedIn}) async {
|
||||||
|
|||||||
@@ -123,6 +123,38 @@ void main() {
|
|||||||
await session.close();
|
await session.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'should_detach_retiring_player_before_disposal_finishes',
|
||||||
|
() async {
|
||||||
|
final disposeStarted = Completer<void>();
|
||||||
|
final disposeGate = Completer<void>();
|
||||||
|
final factory = FakeFactory(
|
||||||
|
firstDisposeStarted: disposeStarted,
|
||||||
|
firstDisposeGate: disposeGate,
|
||||||
|
);
|
||||||
|
final session = PlaybackSession(
|
||||||
|
episodes: episodes,
|
||||||
|
factory: factory.create,
|
||||||
|
canPlay: (episode) => episode.isFree,
|
||||||
|
);
|
||||||
|
await session.select(0);
|
||||||
|
final retiring = factory.created.single;
|
||||||
|
|
||||||
|
final switching = session.select(1);
|
||||||
|
await disposeStarted.future;
|
||||||
|
|
||||||
|
expect(retiring.disposed, isFalse);
|
||||||
|
expect(session.active, isNull);
|
||||||
|
expect(session.preparedPlayerAt(0), isNull);
|
||||||
|
|
||||||
|
disposeGate.complete();
|
||||||
|
await switching;
|
||||||
|
expect(retiring.disposed, isTrue);
|
||||||
|
expect(session.active, isA<FakePlayer>());
|
||||||
|
await session.close();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('should_keep_user_pause_when_reselecting_the_same_episode', () async {
|
test('should_keep_user_pause_when_reselecting_the_same_episode', () async {
|
||||||
final factory = FakeFactory();
|
final factory = FakeFactory();
|
||||||
final session = PlaybackSession(
|
final session = PlaybackSession(
|
||||||
@@ -164,8 +196,14 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class FakeFactory {
|
class FakeFactory {
|
||||||
FakeFactory({this.holdFirstInitialize = false});
|
FakeFactory({
|
||||||
|
this.holdFirstInitialize = false,
|
||||||
|
this.firstDisposeStarted,
|
||||||
|
this.firstDisposeGate,
|
||||||
|
});
|
||||||
final bool holdFirstInitialize;
|
final bool holdFirstInitialize;
|
||||||
|
final Completer<void>? firstDisposeStarted;
|
||||||
|
final Completer<void>? firstDisposeGate;
|
||||||
final List<FakePlayer> created = [];
|
final List<FakePlayer> created = [];
|
||||||
final Completer<void> firstCreated = Completer<void>();
|
final Completer<void> firstCreated = Completer<void>();
|
||||||
int live = 0;
|
int live = 0;
|
||||||
@@ -176,6 +214,8 @@ class FakeFactory {
|
|||||||
if (live > peakLive) peakLive = live;
|
if (live > peakLive) peakLive = live;
|
||||||
final player = FakePlayer(
|
final player = FakePlayer(
|
||||||
holdInitialize: holdFirstInitialize && created.isEmpty,
|
holdInitialize: holdFirstInitialize && created.isEmpty,
|
||||||
|
disposeStarted: created.isEmpty ? firstDisposeStarted : null,
|
||||||
|
disposeGate: created.isEmpty ? firstDisposeGate : null,
|
||||||
onDispose: () => live--,
|
onDispose: () => live--,
|
||||||
);
|
);
|
||||||
created.add(player);
|
created.add(player);
|
||||||
@@ -185,9 +225,16 @@ class FakeFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class FakePlayer implements PlayerPort {
|
class FakePlayer implements PlayerPort {
|
||||||
FakePlayer({required this.holdInitialize, required this.onDispose});
|
FakePlayer({
|
||||||
|
required this.holdInitialize,
|
||||||
|
required this.onDispose,
|
||||||
|
this.disposeStarted,
|
||||||
|
this.disposeGate,
|
||||||
|
});
|
||||||
final bool holdInitialize;
|
final bool holdInitialize;
|
||||||
final void Function() onDispose;
|
final void Function() onDispose;
|
||||||
|
final Completer<void>? disposeStarted;
|
||||||
|
final Completer<void>? disposeGate;
|
||||||
final Completer<void> _initializeGate = Completer<void>();
|
final Completer<void> _initializeGate = Completer<void>();
|
||||||
bool initialized = false;
|
bool initialized = false;
|
||||||
bool disposed = false;
|
bool disposed = false;
|
||||||
@@ -211,6 +258,10 @@ class FakePlayer implements PlayerPort {
|
|||||||
@override
|
@override
|
||||||
Future<void> dispose() async {
|
Future<void> dispose() async {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
|
if (disposeStarted != null && !disposeStarted!.isCompleted) {
|
||||||
|
disposeStarted!.complete();
|
||||||
|
}
|
||||||
|
await disposeGate?.future;
|
||||||
disposed = true;
|
disposed = true;
|
||||||
onDispose();
|
onDispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ PlayerLogic _logic(FakeWatchHistory history, {Drama? drama}) {
|
|||||||
pictureInPicture: AndroidPictureInPicture(
|
pictureInPicture: AndroidPictureInPicture(
|
||||||
channel: const MethodChannel('test/progress/pip'),
|
channel: const MethodChannel('test/progress/pip'),
|
||||||
),
|
),
|
||||||
|
waitForFrame: () async {},
|
||||||
);
|
);
|
||||||
if (drama != null) {
|
if (drama != null) {
|
||||||
logic.attach(
|
logic.attach(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:video_player/video_player.dart';
|
||||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||||
import 'package:fruitshorts/core/data/models/play_ticket.dart';
|
import 'package:fruitshorts/core/data/models/play_ticket.dart';
|
||||||
import 'package:fruitshorts/core/data/models/player_port.dart';
|
import 'package:fruitshorts/core/data/models/player_port.dart';
|
||||||
@@ -11,8 +12,10 @@ import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart';
|
|||||||
import 'package:fruitshorts/core/data/services/download_controller.dart';
|
import 'package:fruitshorts/core/data/services/download_controller.dart';
|
||||||
import 'package:fruitshorts/core/data/services/download_storage.dart';
|
import 'package:fruitshorts/core/data/services/download_storage.dart';
|
||||||
import 'package:fruitshorts/core/data/services/playback_session.dart';
|
import 'package:fruitshorts/core/data/services/playback_session.dart';
|
||||||
|
import 'package:fruitshorts/core/data/services/video_player_port.dart';
|
||||||
import 'package:fruitshorts/pages/player/player_logic.dart';
|
import 'package:fruitshorts/pages/player/player_logic.dart';
|
||||||
import 'package:fruitshorts/pages/player/player_view.dart';
|
import 'package:fruitshorts/pages/player/player_view.dart';
|
||||||
|
import 'package:fruitshorts/pages/player/widgets/player_video_surface.dart';
|
||||||
|
|
||||||
import '../support/fake_watch_history.dart';
|
import '../support/fake_watch_history.dart';
|
||||||
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
||||||
@@ -45,10 +48,12 @@ AndroidPictureInPicture _pip() =>
|
|||||||
PlayerLogic _logic(
|
PlayerLogic _logic(
|
||||||
FakeWatchHistory history, {
|
FakeWatchHistory history, {
|
||||||
PlaybackRepository? playback,
|
PlaybackRepository? playback,
|
||||||
|
Future<void> Function()? waitForFrame,
|
||||||
}) => PlayerLogic(
|
}) => PlayerLogic(
|
||||||
history: history,
|
history: history,
|
||||||
playback: playback ?? FakePlaybackRepository(),
|
playback: playback ?? FakePlaybackRepository(),
|
||||||
pictureInPicture: _pip(),
|
pictureInPicture: _pip(),
|
||||||
|
waitForFrame: waitForFrame ?? () async {},
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Drains the session's serialized work so a test never ends mid-disposal.
|
/// Drains the session's serialized work so a test never ends mid-disposal.
|
||||||
@@ -236,6 +241,7 @@ void main() {
|
|||||||
logic.dispose();
|
logic.dispose();
|
||||||
await _drain(logic);
|
await _drain(logic);
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
group('history writes', () {
|
group('history writes', () {
|
||||||
@@ -618,6 +624,111 @@ void main() {
|
|||||||
logic.dispose();
|
logic.dispose();
|
||||||
await _drain(logic);
|
await _drain(logic);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'should_defer_feed_update_side_effects_when_called_during_build',
|
||||||
|
(tester) async {
|
||||||
|
final factory = _RecordingFactory();
|
||||||
|
final initial = [_drama('a'), _drama('b'), _drama('c')];
|
||||||
|
final latest = [_drama('d'), _drama('c'), _drama('a')];
|
||||||
|
final logic = _logic(FakeWatchHistory());
|
||||||
|
logic.attach(
|
||||||
|
PlayerPage(
|
||||||
|
drama: initial.first,
|
||||||
|
initialIndex: 0,
|
||||||
|
homeMode: true,
|
||||||
|
feedDramas: initial,
|
||||||
|
),
|
||||||
|
isMounted: () => true,
|
||||||
|
);
|
||||||
|
await tester.runAsync(() async {
|
||||||
|
logic.start(
|
||||||
|
factory: factory.create,
|
||||||
|
coordinator: PlaybackCoordinator(),
|
||||||
|
);
|
||||||
|
await _drain(logic);
|
||||||
|
});
|
||||||
|
expect(logic.currentDramaId, 'a');
|
||||||
|
expect(logic.session.active, isNotNull);
|
||||||
|
var updateScheduled = false;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: ListenableBuilder(
|
||||||
|
listenable: logic,
|
||||||
|
builder: (context, _) {
|
||||||
|
if (!updateScheduled) {
|
||||||
|
updateScheduled = true;
|
||||||
|
logic.updateFeed(latest, keepDramaId: 'a');
|
||||||
|
}
|
||||||
|
return Scaffold(
|
||||||
|
body: PageView.builder(
|
||||||
|
controller: logic.pages,
|
||||||
|
itemCount: logic.visibleIndices.length,
|
||||||
|
itemBuilder: (_, index) =>
|
||||||
|
ColoredBox(color: Colors.black, key: ValueKey(index)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(logic.pages.hasClients, isTrue);
|
||||||
|
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(logic.queue.entryAt(2).drama.id, 'a');
|
||||||
|
expect(logic.pages.page, closeTo(2, 0.01));
|
||||||
|
logic.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'should_unmount_old_controller_before_disposing_it_when_feed_changes',
|
||||||
|
(tester) async {
|
||||||
|
final factory = _VideoRecordingFactory();
|
||||||
|
final initial = [_drama('old')];
|
||||||
|
final logic = _logic(FakeWatchHistory());
|
||||||
|
logic.attach(
|
||||||
|
PlayerPage(
|
||||||
|
drama: initial.first,
|
||||||
|
initialIndex: 0,
|
||||||
|
homeMode: true,
|
||||||
|
feedDramas: initial,
|
||||||
|
),
|
||||||
|
isMounted: () => true,
|
||||||
|
);
|
||||||
|
await tester.runAsync(() async {
|
||||||
|
logic.start(
|
||||||
|
factory: factory.create,
|
||||||
|
coordinator: PlaybackCoordinator(),
|
||||||
|
);
|
||||||
|
await _drain(logic);
|
||||||
|
});
|
||||||
|
final oldPlayer = factory.created.single;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(home: _SessionVideoSurface(logic: logic)),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byType(VideoPlayer), findsOneWidget);
|
||||||
|
|
||||||
|
logic.updateFeed([_drama('new')], resetToFirst: true);
|
||||||
|
for (var frame = 0; frame < 5; frame++) {
|
||||||
|
await tester.pump();
|
||||||
|
await tester.runAsync(pumpEventQueue);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(oldPlayer.disposed, isTrue);
|
||||||
|
expect(factory.created.length, greaterThanOrEqualTo(2));
|
||||||
|
logic.dispose();
|
||||||
|
await tester.pump();
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
group('lifecycle', () {
|
group('lifecycle', () {
|
||||||
@@ -826,3 +937,70 @@ class _RecordingPlayer implements PlayerPort {
|
|||||||
onDispose();
|
onDispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _SessionVideoSurface extends StatelessWidget {
|
||||||
|
const _SessionVideoSurface({required this.logic});
|
||||||
|
|
||||||
|
final PlayerLogic logic;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: logic,
|
||||||
|
builder: (context, _) => AnimatedBuilder(
|
||||||
|
animation: logic.session,
|
||||||
|
builder: (context, _) {
|
||||||
|
final port = logic.session.active;
|
||||||
|
final video = port is VideoPlayerPort ? port : null;
|
||||||
|
return Scaffold(
|
||||||
|
body: PlayerVideoSurface(
|
||||||
|
controller: video?.controller,
|
||||||
|
videoUrl: video?.controller.dataSource ?? '',
|
||||||
|
theater: false,
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VideoRecordingFactory {
|
||||||
|
final List<_VideoRecordingPlayer> created = [];
|
||||||
|
|
||||||
|
PlayerPort create(Episode episode) {
|
||||||
|
final player = _VideoRecordingPlayer(episode);
|
||||||
|
created.add(player);
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VideoRecordingPlayer extends VideoPlayerPort {
|
||||||
|
_VideoRecordingPlayer(super.episode);
|
||||||
|
|
||||||
|
bool disposed = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> initialize() async {
|
||||||
|
controller.value = const VideoPlayerValue(
|
||||||
|
duration: Duration(minutes: 1),
|
||||||
|
size: Size(1280, 720),
|
||||||
|
position: Duration(seconds: 1),
|
||||||
|
isInitialized: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> play() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> pause() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> dispose() async {
|
||||||
|
if (disposed) return;
|
||||||
|
disposed = true;
|
||||||
|
await controller.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:fruitshorts/app.dart';
|
||||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||||
import 'package:fruitshorts/core/data/models/feed_page.dart';
|
import 'package:fruitshorts/core/data/models/feed_page.dart';
|
||||||
import 'package:fruitshorts/core/data/models/home_feed.dart';
|
import 'package:fruitshorts/core/data/models/home_feed.dart';
|
||||||
@@ -9,6 +10,8 @@ import 'package:fruitshorts/core/data/models/watch_progress.dart';
|
|||||||
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
||||||
import 'package:fruitshorts/core/data/remote/auth_api.dart';
|
import 'package:fruitshorts/core/data/remote/auth_api.dart';
|
||||||
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
|
import 'package:fruitshorts/core/data/repositories/catalog_repository.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/core/data/repositories/watch_history_repository.dart';
|
||||||
import 'package:fruitshorts/core/data/services/auth_controller.dart';
|
import 'package:fruitshorts/core/data/services/auth_controller.dart';
|
||||||
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
|
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
|
||||||
@@ -17,14 +20,20 @@ import 'package:fruitshorts/core/theme/app_colors.dart';
|
|||||||
import 'package:fruitshorts/core/widgets/app_pull_to_refresh.dart';
|
import 'package:fruitshorts/core/widgets/app_pull_to_refresh.dart';
|
||||||
import 'package:fruitshorts/core/widgets/empty_state.dart';
|
import 'package:fruitshorts/core/widgets/empty_state.dart';
|
||||||
import 'package:fruitshorts/l10n/app_localizations.dart';
|
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||||
|
import 'package:fruitshorts/pages/drama_detail/drama_detail_view.dart';
|
||||||
|
import 'package:fruitshorts/pages/player/player_view.dart';
|
||||||
import 'package:fruitshorts/pages/watch_records/watch_records_page.dart';
|
import 'package:fruitshorts/pages/watch_records/watch_records_page.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.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';
|
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
||||||
|
|
||||||
import '../support/fake_catalog.dart';
|
import '../support/fake_catalog.dart';
|
||||||
|
import '../support/fake_category.dart';
|
||||||
import '../support/fake_http_adapter.dart';
|
import '../support/fake_http_adapter.dart';
|
||||||
import '../support/fake_haptics.dart';
|
import '../support/fake_haptics.dart';
|
||||||
|
import '../support/fake_interaction_repositories.dart';
|
||||||
|
import '../support/fake_notice_repository.dart';
|
||||||
|
import '../support/widget_player.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
setUp(() {
|
setUp(() {
|
||||||
@@ -180,6 +189,53 @@ void main() {
|
|||||||
expect(find.text('账号不存在,请先注册'), findsOneWidget);
|
expect(find.text('账号不存在,请先注册'), findsOneWidget);
|
||||||
expect(find.byKey(const Key('watch-records-retry')), findsOneWidget);
|
expect(find.byKey(const Key('watch-records-retry')), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('should_open_player_at_recorded_episode_when_card_is_tapped', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await _auth(signedIn: true);
|
||||||
|
final catalog = FakeCatalogRepository();
|
||||||
|
final drama = catalog.dramas.first;
|
||||||
|
final episode = drama.episodes[2];
|
||||||
|
final history = InMemoryWatchHistoryRepository(
|
||||||
|
seed: [
|
||||||
|
WatchProgress(
|
||||||
|
dramaId: drama.id,
|
||||||
|
episodeId: episode.id,
|
||||||
|
position: const Duration(seconds: 12),
|
||||||
|
duration: const Duration(seconds: 60),
|
||||||
|
updatedAt: DateTime(2026, 10, 17, 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
LumaDramaApp(
|
||||||
|
playerFactory: createWidgetPlayer,
|
||||||
|
catalogRepository: catalog,
|
||||||
|
categoryRepository: FakeCategoryRepository(catalog: catalog),
|
||||||
|
watchHistoryRepository: history,
|
||||||
|
favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas),
|
||||||
|
playbackRepository: FakePlaybackRepository(),
|
||||||
|
commentRepository: FakeCommentRepository(),
|
||||||
|
interactionRepository: FakeInteractionRepository(),
|
||||||
|
noticeRepository: FakeNoticeRepository(),
|
||||||
|
haptics: FakeHaptics(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('tab-mine')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('mine-history')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(Key('watch-record-${drama.id}')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final player = tester.widget<PlayerPage>(find.byType(PlayerPage));
|
||||||
|
expect(player.initialIndex, 2);
|
||||||
|
expect(find.byType(DramaDetailPage), findsNothing);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
WatchProgress _progress(String dramaId) => WatchProgress(
|
WatchProgress _progress(String dramaId) => WatchProgress(
|
||||||
|
|||||||
Reference in new issue
Block a user