From b0002abf828b5780c6987dae94b045983857dfdc Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 09:50:00 +0800 Subject: [PATCH 001/129] =?UTF-8?q?feat(auth):=20=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E5=BC=82=E5=B8=B8=E6=94=B9=E4=B8=BA=E5=B1=85=E4=B8=AD=E5=BC=B9?= =?UTF-8?q?=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-10-22-login-error-center-dialog.md | 28 ++++ docs/plans/PROGRESS.md | 30 +++++ lib/core/widgets/centered_error_dialog.dart | 67 ++++++++++ lib/l10n/app_en.arb | 3 + lib/l10n/app_id.arb | 3 + lib/l10n/app_localizations.dart | 18 +++ lib/l10n/app_localizations_en.dart | 9 ++ lib/l10n/app_localizations_id.dart | 9 ++ lib/l10n/app_localizations_zh.dart | 9 ++ lib/l10n/app_zh.arb | 3 + lib/pages/auth/auth_view.dart | 14 +- lib/pages/auth/phone_auth_sheet.dart | 31 ++++- test/auth/auth_page_test.dart | 124 ++++++++++++++++++ test/auth/phone_auth_sheet_test.dart | 98 ++++++++++++++ 14 files changed, 436 insertions(+), 10 deletions(-) create mode 100644 docs/plans/2026-10-22-login-error-center-dialog.md create mode 100644 lib/core/widgets/centered_error_dialog.dart diff --git a/docs/plans/2026-10-22-login-error-center-dialog.md b/docs/plans/2026-10-22-login-error-center-dialog.md new file mode 100644 index 0000000..fe5847f --- /dev/null +++ b/docs/plans/2026-10-22-login-error-center-dialog.md @@ -0,0 +1,28 @@ +# 登录异常居中弹窗 + +## 目标 + +- 邮箱登录、邮箱注册及手机号 OTP 登录的提交异常改为屏幕居中弹窗。 +- 本地表单校验继续使用现有内联字段错误,不触发弹窗。 + +## 实施步骤 + +1. 新增 `lib/core/widgets/centered_error_dialog.dart`,统一弹窗样式、遮罩关闭和确认按钮。 +2. 补充登录失败、注册失败、确认按钮的中英文印尼语文案并重新生成 l10n。 +3. 调整邮箱认证页:接口异常展示居中弹窗,提交 busy 在展示弹窗前复位。 +4. 调整手机号弹窗:OTP 发送和验证码登录/注册异常展示居中弹窗,成功 SnackBar 保持不变。 +5. 补接口异常、注册标题、本地校验不弹窗和手机号两条异常路径测试。 +6. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。 + +## 风险 / 待确认 + +- 同级 FruitServer 的 `INBOX.md` 当前顶部为已处理 K100,未找到本需求条目;以用户消息中的完整需求为准。 +- 现有主工作区处于 `feature/payment-iap` 且有未提交 `pubspec.lock`,本次使用独立 worktree,不触碰该文件。 + +## 执行结果 + +- 已新增居中错误弹窗组件及三语言文案,邮箱登录/注册和手机号 OTP/验证码提交异常均改为弹窗。 +- 本地字段校验仍走原内联错误;异常展示前提交 busy 已复位。 +- 接口异常、注册标题、未知异常网络文案、遮罩关闭、确认关闭、本地校验不弹窗均有测试覆盖。 +- `dart analyze lib test` 零告警,`flutter test --no-pub` 570 项全过。 +- 未启停服务,未操作真机。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 2122f93..c34a0c5 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -203,3 +203,33 @@ Running Gradle task 'assembleDebug'... 35.7s 解读:静态分析零告警,565 项全量测试通过,debug APK 构建成功; `pubspec.lock` 已还原,无依赖变更,未安装或操作真机。 + +## 2026-10-22 登录异常改为屏幕居中弹窗 + +- 已从 `origin/develop`(`9b78e43`)创建 `feature/login-error-center-dialog`。 +- 新增 `lib/core/widgets/centered_error_dialog.dart`,统一居中样式、遮罩关闭、确认按钮。 +- 邮箱登录/注册与手机号 OTP/验证码异常改为居中弹窗;字段校验保持内联,成功提示不变。 +- 新增 `authLoginFailed` / `authRegisterFailed` / `authErrorConfirm` 三语言文案并重新生成 l10n。 +- 异常弹窗前提交 busy 已复位,避免 loading 卡在弹窗打开期间。 + +执行命令与原始输出: + +```text +$ flutter gen-l10n +Because l10n.yaml exists, the options defined there will be used instead. +To use the command line arguments, delete the l10n.yaml file in the Flutter project. + +$ flutter test --no-pub test/auth/auth_page_test.dart test/auth/phone_auth_sheet_test.dart +00:02 +20: All tests passed! + +$ dart analyze lib test +Analyzing lib, test... +No issues found! + +$ flutter test --no-pub +00:49 +570: All tests passed! +``` + +解读:登录异常、注册标题、未知异常回退、本地校验不弹窗、遮罩/按钮关闭和手机号两条 +异常路径均通过;全量静态分析零告警,570 项测试全过。`pubspec.lock` 已还原, +未启停服务,未操作真机。 diff --git a/lib/core/widgets/centered_error_dialog.dart b/lib/core/widgets/centered_error_dialog.dart new file mode 100644 index 0000000..5a36621 --- /dev/null +++ b/lib/core/widgets/centered_error_dialog.dart @@ -0,0 +1,67 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../l10n/app_localizations.dart'; +import '../theme/app_colors.dart'; + +const Key _dialogKey = Key('centered-error-dialog'); +const Key _confirmKey = Key('centered-error-confirm'); +const double _dialogRadius = 16; +const double _titleHorizontalInset = 24; +const double _titleTopInset = 24; +const double _contentHorizontalInset = 24; +const double _contentTopInset = 12; +const double _contentBottomInset = 8; +const double _actionsHorizontalInset = 16; +const double _actionsBottomInset = 12; +const EdgeInsets _titlePadding = EdgeInsets.fromLTRB( + _titleHorizontalInset, + _titleTopInset, + _titleHorizontalInset, + 0, +); +const EdgeInsets _contentPadding = EdgeInsets.fromLTRB( + _contentHorizontalInset, + _contentTopInset, + _contentHorizontalInset, + _contentBottomInset, +); +const EdgeInsets _actionsPadding = EdgeInsets.fromLTRB( + _actionsHorizontalInset, + 0, + _actionsHorizontalInset, + _actionsBottomInset, +); + +/// Shows a reusable error dialog centered on the screen. +Future showCenteredErrorDialog( + BuildContext context, { + required String title, + required String message, +}) async { + final l10n = AppLocalizations.of(context)!; + await showDialog( + context: context, + barrierDismissible: true, + builder: (dialogContext) => AlertDialog( + key: _dialogKey, + backgroundColor: AppColors.card, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(_dialogRadius), + ), + titlePadding: _titlePadding, + contentPadding: _contentPadding, + title: Text(title), + content: Text(message), + actionsPadding: _actionsPadding, + actions: [ + TextButton( + key: _confirmKey, + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(l10n.authErrorConfirm), + ), + ], + ), + ); +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 526f378..528a3bf 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -435,6 +435,9 @@ "authPasswordLength": "Password must be 8-64 characters", "authPasswordWeak": "Password needs both letters and numbers", "authNetworkError": "Network error, please try again", + "authLoginFailed": "Login failed", + "authRegisterFailed": "Registration failed", + "authErrorConfirm": "OK", "authSwitchToSignUp": "New here? Create an account", "authSwitchToSignIn": "Already have an account? Sign in", "authGateTitle": "Sign in to continue", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index b707d25..f2995ce 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -409,6 +409,9 @@ "authPasswordLength": "Kata sandi harus 8-64 karakter", "authPasswordWeak": "Kata sandi perlu huruf dan angka", "authNetworkError": "Jaringan bermasalah, coba lagi", + "authLoginFailed": "Gagal masuk", + "authRegisterFailed": "Pendaftaran gagal", + "authErrorConfirm": "Mengerti", "authSwitchToSignUp": "Baru di sini? Buat akun", "authSwitchToSignIn": "Sudah punya akun? Masuk", "authGateTitle": "Masuk untuk melanjutkan", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 447651c..914bbf9 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1888,6 +1888,24 @@ abstract class AppLocalizations { /// **'Network error, please try again'** String get authNetworkError; + /// No description provided for @authLoginFailed. + /// + /// In en, this message translates to: + /// **'Login failed'** + String get authLoginFailed; + + /// No description provided for @authRegisterFailed. + /// + /// In en, this message translates to: + /// **'Registration failed'** + String get authRegisterFailed; + + /// No description provided for @authErrorConfirm. + /// + /// In en, this message translates to: + /// **'OK'** + String get authErrorConfirm; + /// No description provided for @authSwitchToSignUp. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index d36f78e..95459ce 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -958,6 +958,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get authNetworkError => 'Network error, please try again'; + @override + String get authLoginFailed => 'Login failed'; + + @override + String get authRegisterFailed => 'Registration failed'; + + @override + String get authErrorConfirm => 'OK'; + @override String get authSwitchToSignUp => 'New here? Create an account'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index b5c5ee0..f048f14 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -962,6 +962,15 @@ class AppLocalizationsId extends AppLocalizations { @override String get authNetworkError => 'Jaringan bermasalah, coba lagi'; + @override + String get authLoginFailed => 'Gagal masuk'; + + @override + String get authRegisterFailed => 'Pendaftaran gagal'; + + @override + String get authErrorConfirm => 'Mengerti'; + @override String get authSwitchToSignUp => 'Baru di sini? Buat akun'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 3bfa8c4..47411c3 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -946,6 +946,15 @@ class AppLocalizationsZh extends AppLocalizations { @override String get authNetworkError => '网络异常,请稍后重试'; + @override + String get authLoginFailed => '登录失败'; + + @override + String get authRegisterFailed => '注册失败'; + + @override + String get authErrorConfirm => '知道了'; + @override String get authSwitchToSignUp => '还没有账号?去注册'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 32ecfa9..805b5b8 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -371,6 +371,9 @@ "authPasswordLength": "密码长度需在 8-64 位之间", "authPasswordWeak": "密码需同时包含字母和数字", "authNetworkError": "网络异常,请稍后重试", + "authLoginFailed": "登录失败", + "authRegisterFailed": "注册失败", + "authErrorConfirm": "知道了", "authSwitchToSignUp": "还没有账号?去注册", "authSwitchToSignIn": "已有账号?去登录", "authGateTitle": "登录后可继续", diff --git a/lib/pages/auth/auth_view.dart b/lib/pages/auth/auth_view.dart index 59b38b2..dfd01a9 100644 --- a/lib/pages/auth/auth_view.dart +++ b/lib/pages/auth/auth_view.dart @@ -12,6 +12,7 @@ import '../../core/platform/keyboard_inset.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; +import '../../core/widgets/centered_error_dialog.dart'; import '../../core/widgets/subpage_header.dart'; import '../../l10n/app_localizations.dart'; import 'auth_logic.dart'; @@ -227,6 +228,7 @@ class _AuthPageState extends State { _logic.errorMessage = null; }); final auth = context.read(); + String? failureMessage; try { if (_logic.signUpMode) { await auth.register( @@ -242,14 +244,18 @@ class _AuthPageState extends State { if (!mounted) return; Navigator.of(context).maybePop(); } on ApiException catch (error) { - if (!mounted) return; - setState(() => _logic.errorMessage = error.message); + failureMessage = error.message; } catch (_) { - if (!mounted) return; - setState(() => _logic.errorMessage = l10n.authNetworkError); + failureMessage = l10n.authNetworkError; } finally { if (mounted) setState(() => _logic.busy = false); } + if (!mounted || failureMessage == null) return; + await showCenteredErrorDialog( + context, + title: _logic.signUpMode ? l10n.authRegisterFailed : l10n.authLoginFailed, + message: failureMessage, + ); } @override diff --git a/lib/pages/auth/phone_auth_sheet.dart b/lib/pages/auth/phone_auth_sheet.dart index c37f6c7..7702249 100644 --- a/lib/pages/auth/phone_auth_sheet.dart +++ b/lib/pages/auth/phone_auth_sheet.dart @@ -11,6 +11,7 @@ import '../../core/data/services/auth_controller.dart'; import '../../core/platform/haptics.dart'; import '../../core/platform/keyboard_inset.dart'; import '../../core/theme/app_colors.dart'; +import '../../core/widgets/centered_error_dialog.dart'; import '../../l10n/app_localizations.dart'; const double _fieldHeight = 52; @@ -164,6 +165,7 @@ class _PhoneAuthSheetState extends State { }); final haptics = context.read(); final auth = context.read(); + String? failureMessage; await haptics.lightImpact(); try { await auth.sendOtp( @@ -173,16 +175,20 @@ class _PhoneAuthSheetState extends State { if (!mounted) return; _startCountdown(); setState(() => _error = null); - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text(l10n.authCodeSent))); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.authCodeSent))); _codeFocus.requestFocus(); } on ApiException catch (error) { - if (mounted) setState(() => _error = error.message); + failureMessage = error.message; } catch (_) { - if (mounted) setState(() => _error = l10n.authNetworkError); + failureMessage = l10n.authNetworkError; } finally { if (mounted) setState(() => _sending = false); } + if (mounted && failureMessage != null) { + await _showFailure(failureMessage); + } } void _startCountdown() { @@ -212,6 +218,7 @@ class _PhoneAuthSheetState extends State { }); final haptics = context.read(); final auth = context.read(); + String? failureMessage; await haptics.lightImpact(); try { if (_signUp) { @@ -221,12 +228,24 @@ class _PhoneAuthSheetState extends State { } if (mounted) Navigator.of(context).pop(true); } on ApiException catch (error) { - if (mounted) setState(() => _error = error.message); + failureMessage = error.message; } catch (_) { - if (mounted) setState(() => _error = l10n.authNetworkError); + failureMessage = l10n.authNetworkError; } finally { if (mounted) setState(() => _submitting = false); } + if (mounted && failureMessage != null) { + await _showFailure(failureMessage); + } + } + + Future _showFailure(String message) { + final l10n = AppLocalizations.of(context)!; + return showCenteredErrorDialog( + context, + title: _signUp ? l10n.authRegisterFailed : l10n.authLoginFailed, + message: message, + ); } void _switchMode() { diff --git a/test/auth/auth_page_test.dart b/test/auth/auth_page_test.dart index 1e02e3c..923f450 100644 --- a/test/auth/auth_page_test.dart +++ b/test/auth/auth_page_test.dart @@ -78,6 +78,7 @@ void main() { expect(find.text('Enter your email'), findsOneWidget); expect(find.text('Welcome back'), findsOneWidget); + expect(find.byKey(const Key('centered-error-dialog')), findsNothing); }); testWidgets( @@ -94,6 +95,80 @@ void main() { find.text('Password needs both letters and numbers'), findsOneWidget, ); + expect(find.byKey(const Key('centered-error-dialog')), findsNothing); + }, + ); + + testWidgets('should_show_centered_dialog_when_login_api_returns_an_error', ( + tester, + ) async { + final api = _FailingAuthApi(loginMessage: 'Invalid email or password'); + await pumpAuthPage(tester, api: api); + await _enterValidCredentials(tester); + + await tester.ensureVisible(find.byKey(const Key('auth-submit'))); + await tester.tap(find.byKey(const Key('auth-submit'))); + await tester.pumpAndSettle(); + + final dialog = find.byKey(const Key('centered-error-dialog')); + expect(dialog, findsOneWidget); + expect(find.text('Login failed'), findsOneWidget); + expect(find.text('Invalid email or password'), findsOneWidget); + expect(api.loginCalls, 1); + expect( + tester.getCenter(dialog), + offsetMoreOrLessEquals( + tester.getCenter(find.byType(Scaffold)), + epsilon: 1, + ), + ); + expect( + tester + .widget(find.byKey(const Key('auth-submit'))) + .onPressed, + isNotNull, + ); + + await tester.tap(find.byKey(const Key('centered-error-confirm'))); + await tester.pumpAndSettle(); + expect(dialog, findsNothing); + }); + + testWidgets( + 'should_show_registration_failed_title_when_register_api_returns_an_error', + (tester) async { + final api = _FailingAuthApi(registerMessage: 'Email already exists'); + await pumpAuthPage(tester, signUp: true, api: api); + await _enterValidCredentials(tester); + + await tester.ensureVisible(find.byKey(const Key('auth-submit'))); + await tester.tap(find.byKey(const Key('auth-submit'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('centered-error-dialog')), findsOneWidget); + expect(find.text('Registration failed'), findsOneWidget); + expect(find.text('Email already exists'), findsOneWidget); + expect(api.registerCalls, 1); + + await tester.tapAt(const Offset(8, 8)); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('centered-error-dialog')), findsNothing); + }, + ); + + testWidgets( + 'should_show_network_error_when_login_throws_an_unknown_exception', + (tester) async { + final api = _FailingAuthApi(loginThrowsUnknown: true); + await pumpAuthPage(tester, api: api); + await _enterValidCredentials(tester); + + await tester.ensureVisible(find.byKey(const Key('auth-submit'))); + await tester.tap(find.byKey(const Key('auth-submit'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('centered-error-dialog')), findsOneWidget); + expect(find.text('Network error, please try again'), findsOneWidget); }, ); @@ -342,6 +417,11 @@ void main() { }); } +Future _enterValidCredentials(WidgetTester tester) async { + await tester.enterText(find.byKey(const Key('auth-email')), 'a@b.com'); + await tester.enterText(find.byKey(const Key('auth-password')), 'abcd1234'); +} + void _expectAboveKeyboard(WidgetTester tester, Key fieldKey, double inset) { final screenHeight = tester.view.physicalSize.height / tester.view.devicePixelRatio; @@ -351,6 +431,50 @@ void _expectAboveKeyboard(WidgetTester tester, Key fieldKey, double inset) { ); } +class _FailingAuthApi extends AuthApi { + _FailingAuthApi({ + this.loginMessage = 'login failed', + this.registerMessage = 'register failed', + this.loginThrowsUnknown = false, + }) : super( + ApiClient( + baseUrl: 'http://127.0.0.1:1', + adapter: FakeHttpAdapter((_) async => FakeHttpResponse('{}', 500)), + ), + ); + + final String loginMessage; + final String registerMessage; + final bool loginThrowsUnknown; + int loginCalls = 0; + int registerCalls = 0; + + @override + Future login({ + required String email, + required String password, + required String deviceId, + String platform = '', + String appVersion = '', + }) async { + loginCalls++; + if (loginThrowsUnknown) throw StateError('unexpected'); + throw ApiException(401, loginMessage); + } + + @override + Future register({ + required String email, + required String password, + required String deviceId, + String platform = '', + String appVersion = '', + }) async { + registerCalls++; + throw ApiException(409, registerMessage); + } +} + class _PendingAuthApi extends AuthApi { _PendingAuthApi() : super( diff --git a/test/auth/phone_auth_sheet_test.dart b/test/auth/phone_auth_sheet_test.dart index 036b286..3c3e2d8 100644 --- a/test/auth/phone_auth_sheet_test.dart +++ b/test/auth/phone_auth_sheet_test.dart @@ -5,6 +5,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/auth_session.dart'; import 'package:fruitshorts/core/data/remote/api_client.dart'; import 'package:fruitshorts/core/data/remote/auth_api.dart'; import 'package:fruitshorts/core/data/services/auth_controller.dart'; @@ -97,6 +98,64 @@ void main() { expect(find.text('Enter a valid phone number'), findsOneWidget); expect(adapter.requests, isEmpty); + expect(find.byKey(const Key('centered-error-dialog')), findsNothing); + }); + + testWidgets('should_show_centered_dialog_when_otp_request_fails', ( + tester, + ) async { + final api = _FailingPhoneAuthApi(sendOtpMessage: 'Too many OTP requests'); + final auth = AuthController(api, AuthSessionStore()); + await _pumpHost(tester, auth); + await tester.tap(find.byKey(const Key('open-phone-sheet'))); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const Key('auth-phone-number')), + '08123456789', + ); + + await tester.tap(find.byKey(const Key('auth-send-code'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('centered-error-dialog')), findsOneWidget); + expect(find.text('Login failed'), findsOneWidget); + expect(find.text('Too many OTP requests'), findsOneWidget); + expect(api.sendOtpCalls, 1); + expect( + tester + .widget(find.byKey(const Key('auth-send-code'))) + .onPressed, + isNotNull, + ); + }); + + testWidgets('should_show_centered_dialog_when_phone_login_fails', ( + tester, + ) async { + final api = _FailingPhoneAuthApi(loginMessage: 'Invalid verification code'); + final auth = AuthController(api, AuthSessionStore()); + await _pumpHost(tester, auth); + await tester.tap(find.byKey(const Key('open-phone-sheet'))); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const Key('auth-phone-number')), + '08123456789', + ); + await tester.enterText(find.byKey(const Key('auth-phone-code')), '123456'); + + await tester.tap(find.byKey(const Key('auth-phone-submit'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('centered-error-dialog')), findsOneWidget); + expect(find.text('Login failed'), findsOneWidget); + expect(find.text('Invalid verification code'), findsOneWidget); + expect(api.loginWithPhoneCalls, 1); + expect( + tester + .widget(find.byKey(const Key('auth-phone-submit'))) + .onPressed, + isNotNull, + ); }); testWidgets('should_keep_code_above_ios_keyboard', (tester) async { @@ -132,6 +191,45 @@ void main() { }); } +class _FailingPhoneAuthApi extends AuthApi { + _FailingPhoneAuthApi({ + this.sendOtpMessage = 'otp failed', + this.loginMessage = 'login failed', + }) : super( + ApiClient( + baseUrl: 'http://127.0.0.1:1', + adapter: FakeHttpAdapter((_) async => FakeHttpResponse('{}', 500)), + ), + ); + + final String sendOtpMessage; + final String loginMessage; + int sendOtpCalls = 0; + int loginWithPhoneCalls = 0; + + @override + Future sendOtp({ + required String target, + required String scene, + required String deviceId, + }) async { + sendOtpCalls++; + throw ApiException(429, sendOtpMessage); + } + + @override + Future loginWithPhone({ + required String target, + required String code, + required String deviceId, + String platform = '', + String appVersion = '', + }) async { + loginWithPhoneCalls++; + throw ApiException(401, loginMessage); + } +} + Future _pumpHost(WidgetTester tester, AuthController auth) { return tester.pumpWidget( MultiProvider( From d076c572838a2f4aef6e7b55a6d113afe42f55dc Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 09:57:57 +0800 Subject: [PATCH 002/129] =?UTF-8?q?fix(auth):=20=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E5=BC=82=E5=B8=B8=E6=94=B9=E4=B8=BA=E5=B1=85=E4=B8=AD=E5=90=90?= =?UTF-8?q?=E5=8F=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-10-22-login-error-center-toast.md | 29 ++++ docs/plans/PROGRESS.md | 33 +++++ lib/core/widgets/centered_error_dialog.dart | 67 --------- lib/core/widgets/centered_toast.dart | 128 ++++++++++++++++++ lib/l10n/app_en.arb | 3 - lib/l10n/app_id.arb | 3 - lib/l10n/app_localizations.dart | 18 --- lib/l10n/app_localizations_en.dart | 9 -- lib/l10n/app_localizations_id.dart | 9 -- lib/l10n/app_localizations_zh.dart | 9 -- lib/l10n/app_zh.arb | 3 - lib/pages/auth/auth_view.dart | 8 +- lib/pages/auth/phone_auth_sheet.dart | 15 +- test/auth/auth_page_test.dart | 64 +++++---- test/auth/phone_auth_sheet_test.dart | 25 +++- test/core/centered_toast_test.dart | 93 +++++++++++++ 16 files changed, 341 insertions(+), 175 deletions(-) create mode 100644 docs/plans/2026-10-22-login-error-center-toast.md delete mode 100644 lib/core/widgets/centered_error_dialog.dart create mode 100644 lib/core/widgets/centered_toast.dart create mode 100644 test/core/centered_toast_test.dart diff --git a/docs/plans/2026-10-22-login-error-center-toast.md b/docs/plans/2026-10-22-login-error-center-toast.md new file mode 100644 index 0000000..8897f34 --- /dev/null +++ b/docs/plans/2026-10-22-login-error-center-toast.md @@ -0,0 +1,29 @@ +# 登录异常居中吐司 + +## 目标 + +- 将上一版认证异常 AlertDialog 替换为无交互的屏幕居中吐司。 +- 吐司淡入展示约 2 秒后自动淡出,不遮罩、不拦截手势。 +- 同一时刻只存在一个吐司,新消息替换旧消息。 + +## 实施步骤 + +1. 删除 `centered_error_dialog.dart`,新增 `centered_toast.dart`。 +2. 认证页和手机号弹窗异常路径改用 `showCenteredToast`,保留 busy 复位和成功 SnackBar。 +3. 清理不再使用的认证错误标题与确认按钮 l10n 键,重新生成 AppLocalizations。 +4. 更新认证测试为吐司断言,并补单例替换、自动消失测试。 +5. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。 + +## 风险 / 待确认 + +- 吐司使用 root Overlay,认证路由退出时仍会按自身计时器自动清理。 +- 测试必须 pump 超过展示和淡出时长,确保不遗留 Timer 或 OverlayEntry。 + +## 执行结果 + +- 已删除 AlertDialog 组件,新增 OverlayEntry 居中吐司。 +- 邮箱和手机号异常改用吐司;busy 复位、成功 SnackBar 和本地内联校验保持不变。 +- 已清理三个不再使用的 l10n 键并重新生成 AppLocalizations。 +- 覆盖单例替换、点击穿透、自动消失、接口异常和本地校验不弹吐司。 +- `dart analyze lib test` 零告警,`flutter test --no-pub` 573 项全过。 +- 未启停服务,未操作真机。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index c34a0c5..c3bef1c 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -233,3 +233,36 @@ $ flutter test --no-pub 解读:登录异常、注册标题、未知异常回退、本地校验不弹窗、遮罩/按钮关闭和手机号两条 异常路径均通过;全量静态分析零告警,570 项测试全过。`pubspec.lock` 已还原, 未启停服务,未操作真机。 + +## 2026-10-22 登录异常调整为中心吐司 + +- 已从 `develop`(`11e37a9`)创建 `feature/login-error-center-toast`。 +- 删除 AlertDialog 组件,新增 OverlayEntry 居中吐司:淡入淡出、约 2 秒消失、不拦截手势。 +- 吐司保持全局单例,新消息替换旧消息;邮箱与手机号异常路径均已切换。 +- 本地表单校验继续内联,手机号验证码发送成功 SnackBar 保持不变。 +- 清理 `authLoginFailed` / `authRegisterFailed` / `authErrorConfirm` 并重新生成 l10n。 + +执行命令与原始输出: + +```text +$ flutter gen-l10n +Because l10n.yaml exists, the options defined there will be used instead. +To use the command line arguments, delete the l10n.yaml file in the Flutter project. + +$ flutter test --no-pub test/core/centered_toast_test.dart +00:00 +3: All tests passed! + +$ flutter test --no-pub test/auth/auth_page_test.dart test/auth/phone_auth_sheet_test.dart +00:02 +20: All tests passed! + +$ dart analyze lib test +Analyzing lib, test... +No issues found! + +$ flutter test --no-pub +01:00 +573: All tests passed! +``` + +解读:吐司单例替换、点击穿透和自动消失通过;认证异常与本地校验路径通过; +全量静态分析零告警,573 项测试全过。`pubspec.lock` 已还原,未启停服务, +未操作真机。 diff --git a/lib/core/widgets/centered_error_dialog.dart b/lib/core/widgets/centered_error_dialog.dart deleted file mode 100644 index 5a36621..0000000 --- a/lib/core/widgets/centered_error_dialog.dart +++ /dev/null @@ -1,67 +0,0 @@ -// Author: fruit.com.cn - -import 'package:flutter/material.dart'; - -import '../../l10n/app_localizations.dart'; -import '../theme/app_colors.dart'; - -const Key _dialogKey = Key('centered-error-dialog'); -const Key _confirmKey = Key('centered-error-confirm'); -const double _dialogRadius = 16; -const double _titleHorizontalInset = 24; -const double _titleTopInset = 24; -const double _contentHorizontalInset = 24; -const double _contentTopInset = 12; -const double _contentBottomInset = 8; -const double _actionsHorizontalInset = 16; -const double _actionsBottomInset = 12; -const EdgeInsets _titlePadding = EdgeInsets.fromLTRB( - _titleHorizontalInset, - _titleTopInset, - _titleHorizontalInset, - 0, -); -const EdgeInsets _contentPadding = EdgeInsets.fromLTRB( - _contentHorizontalInset, - _contentTopInset, - _contentHorizontalInset, - _contentBottomInset, -); -const EdgeInsets _actionsPadding = EdgeInsets.fromLTRB( - _actionsHorizontalInset, - 0, - _actionsHorizontalInset, - _actionsBottomInset, -); - -/// Shows a reusable error dialog centered on the screen. -Future showCenteredErrorDialog( - BuildContext context, { - required String title, - required String message, -}) async { - final l10n = AppLocalizations.of(context)!; - await showDialog( - context: context, - barrierDismissible: true, - builder: (dialogContext) => AlertDialog( - key: _dialogKey, - backgroundColor: AppColors.card, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(_dialogRadius), - ), - titlePadding: _titlePadding, - contentPadding: _contentPadding, - title: Text(title), - content: Text(message), - actionsPadding: _actionsPadding, - actions: [ - TextButton( - key: _confirmKey, - onPressed: () => Navigator.of(dialogContext).pop(), - child: Text(l10n.authErrorConfirm), - ), - ], - ), - ); -} diff --git a/lib/core/widgets/centered_toast.dart b/lib/core/widgets/centered_toast.dart new file mode 100644 index 0000000..709be8a --- /dev/null +++ b/lib/core/widgets/centered_toast.dart @@ -0,0 +1,128 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; + +const Key _toastKey = Key('centered-toast'); +const Duration _fadeDuration = Duration(milliseconds: 200); +const Duration _visibleDuration = Duration(seconds: 2); +const double _toastRadius = 12; +const double _toastMaxWidth = 320; +const double _toastHorizontalInset = 20; +const double _toastVerticalInset = 12; +const double _toastBackgroundOpacity = 0.78; +const TextStyle _toastTextStyle = TextStyle( + color: Colors.white, + fontSize: 14, + height: 1.4, + fontWeight: FontWeight.w500, +); +const EdgeInsets _toastPadding = EdgeInsets.symmetric( + horizontal: _toastHorizontalInset, + vertical: _toastVerticalInset, +); + +OverlayEntry? _activeToast; + +/// Shows a lightweight centered message and replaces any active toast. +void showCenteredToast(BuildContext context, String message) { + _removeActiveToast(); + final overlay = Overlay.of(context, rootOverlay: true); + late final OverlayEntry entry; + entry = OverlayEntry( + builder: (_) => _CenteredToast( + message: message, + onDismissed: () { + if (!identical(_activeToast, entry)) return; + _activeToast = null; + if (entry.mounted) entry.remove(); + }, + ), + ); + _activeToast = entry; + overlay.insert(entry); +} + +void _removeActiveToast() { + final entry = _activeToast; + _activeToast = null; + if (entry != null && entry.mounted) entry.remove(); +} + +class _CenteredToast extends StatefulWidget { + const _CenteredToast({required this.message, required this.onDismissed}); + + final String message; + final VoidCallback onDismissed; + + @override + State<_CenteredToast> createState() => _CenteredToastState(); +} + +class _CenteredToastState extends State<_CenteredToast> + with SingleTickerProviderStateMixin { + late final AnimationController _opacity = AnimationController( + vsync: this, + duration: _fadeDuration, + ); + Timer? _dismissTimer; + + @override + void initState() { + super.initState(); + _opacity.forward(); + _dismissTimer = Timer(_visibleDuration, _dismiss); + } + + Future _dismiss() async { + try { + await _opacity.reverse(); + } on TickerCanceled { + return; + } + if (mounted) widget.onDismissed(); + } + + @override + void dispose() { + _dismissTimer?.cancel(); + _opacity.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Positioned.fill( + child: IgnorePointer( + child: Center( + child: FadeTransition( + opacity: CurvedAnimation( + parent: _opacity, + curve: Curves.easeOutCubic, + ), + child: Material( + type: MaterialType.transparency, + child: Container( + key: _toastKey, + constraints: const BoxConstraints(maxWidth: _toastMaxWidth), + padding: _toastPadding, + decoration: BoxDecoration( + color: Colors.black.withValues( + alpha: _toastBackgroundOpacity, + ), + borderRadius: BorderRadius.circular(_toastRadius), + ), + child: Text( + widget.message, + textAlign: TextAlign.center, + style: _toastTextStyle, + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 528a3bf..526f378 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -435,9 +435,6 @@ "authPasswordLength": "Password must be 8-64 characters", "authPasswordWeak": "Password needs both letters and numbers", "authNetworkError": "Network error, please try again", - "authLoginFailed": "Login failed", - "authRegisterFailed": "Registration failed", - "authErrorConfirm": "OK", "authSwitchToSignUp": "New here? Create an account", "authSwitchToSignIn": "Already have an account? Sign in", "authGateTitle": "Sign in to continue", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index f2995ce..b707d25 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -409,9 +409,6 @@ "authPasswordLength": "Kata sandi harus 8-64 karakter", "authPasswordWeak": "Kata sandi perlu huruf dan angka", "authNetworkError": "Jaringan bermasalah, coba lagi", - "authLoginFailed": "Gagal masuk", - "authRegisterFailed": "Pendaftaran gagal", - "authErrorConfirm": "Mengerti", "authSwitchToSignUp": "Baru di sini? Buat akun", "authSwitchToSignIn": "Sudah punya akun? Masuk", "authGateTitle": "Masuk untuk melanjutkan", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 914bbf9..447651c 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1888,24 +1888,6 @@ abstract class AppLocalizations { /// **'Network error, please try again'** String get authNetworkError; - /// No description provided for @authLoginFailed. - /// - /// In en, this message translates to: - /// **'Login failed'** - String get authLoginFailed; - - /// No description provided for @authRegisterFailed. - /// - /// In en, this message translates to: - /// **'Registration failed'** - String get authRegisterFailed; - - /// No description provided for @authErrorConfirm. - /// - /// In en, this message translates to: - /// **'OK'** - String get authErrorConfirm; - /// No description provided for @authSwitchToSignUp. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 95459ce..d36f78e 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -958,15 +958,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get authNetworkError => 'Network error, please try again'; - @override - String get authLoginFailed => 'Login failed'; - - @override - String get authRegisterFailed => 'Registration failed'; - - @override - String get authErrorConfirm => 'OK'; - @override String get authSwitchToSignUp => 'New here? Create an account'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index f048f14..b5c5ee0 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -962,15 +962,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get authNetworkError => 'Jaringan bermasalah, coba lagi'; - @override - String get authLoginFailed => 'Gagal masuk'; - - @override - String get authRegisterFailed => 'Pendaftaran gagal'; - - @override - String get authErrorConfirm => 'Mengerti'; - @override String get authSwitchToSignUp => 'Baru di sini? Buat akun'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 47411c3..3bfa8c4 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -946,15 +946,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get authNetworkError => '网络异常,请稍后重试'; - @override - String get authLoginFailed => '登录失败'; - - @override - String get authRegisterFailed => '注册失败'; - - @override - String get authErrorConfirm => '知道了'; - @override String get authSwitchToSignUp => '还没有账号?去注册'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 805b5b8..32ecfa9 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -371,9 +371,6 @@ "authPasswordLength": "密码长度需在 8-64 位之间", "authPasswordWeak": "密码需同时包含字母和数字", "authNetworkError": "网络异常,请稍后重试", - "authLoginFailed": "登录失败", - "authRegisterFailed": "注册失败", - "authErrorConfirm": "知道了", "authSwitchToSignUp": "还没有账号?去注册", "authSwitchToSignIn": "已有账号?去登录", "authGateTitle": "登录后可继续", diff --git a/lib/pages/auth/auth_view.dart b/lib/pages/auth/auth_view.dart index dfd01a9..9d5106a 100644 --- a/lib/pages/auth/auth_view.dart +++ b/lib/pages/auth/auth_view.dart @@ -12,7 +12,7 @@ import '../../core/platform/keyboard_inset.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; -import '../../core/widgets/centered_error_dialog.dart'; +import '../../core/widgets/centered_toast.dart'; import '../../core/widgets/subpage_header.dart'; import '../../l10n/app_localizations.dart'; import 'auth_logic.dart'; @@ -251,11 +251,7 @@ class _AuthPageState extends State { if (mounted) setState(() => _logic.busy = false); } if (!mounted || failureMessage == null) return; - await showCenteredErrorDialog( - context, - title: _logic.signUpMode ? l10n.authRegisterFailed : l10n.authLoginFailed, - message: failureMessage, - ); + showCenteredToast(context, failureMessage); } @override diff --git a/lib/pages/auth/phone_auth_sheet.dart b/lib/pages/auth/phone_auth_sheet.dart index 7702249..0355821 100644 --- a/lib/pages/auth/phone_auth_sheet.dart +++ b/lib/pages/auth/phone_auth_sheet.dart @@ -11,7 +11,7 @@ import '../../core/data/services/auth_controller.dart'; import '../../core/platform/haptics.dart'; import '../../core/platform/keyboard_inset.dart'; import '../../core/theme/app_colors.dart'; -import '../../core/widgets/centered_error_dialog.dart'; +import '../../core/widgets/centered_toast.dart'; import '../../l10n/app_localizations.dart'; const double _fieldHeight = 52; @@ -187,7 +187,7 @@ class _PhoneAuthSheetState extends State { if (mounted) setState(() => _sending = false); } if (mounted && failureMessage != null) { - await _showFailure(failureMessage); + showCenteredToast(context, failureMessage); } } @@ -235,19 +235,10 @@ class _PhoneAuthSheetState extends State { if (mounted) setState(() => _submitting = false); } if (mounted && failureMessage != null) { - await _showFailure(failureMessage); + showCenteredToast(context, failureMessage); } } - Future _showFailure(String message) { - final l10n = AppLocalizations.of(context)!; - return showCenteredErrorDialog( - context, - title: _signUp ? l10n.authRegisterFailed : l10n.authLoginFailed, - message: message, - ); - } - void _switchMode() { setState(() { _signUp = !_signUp; diff --git a/test/auth/auth_page_test.dart b/test/auth/auth_page_test.dart index 923f450..85a005b 100644 --- a/test/auth/auth_page_test.dart +++ b/test/auth/auth_page_test.dart @@ -78,7 +78,7 @@ void main() { expect(find.text('Enter your email'), findsOneWidget); expect(find.text('Welcome back'), findsOneWidget); - expect(find.byKey(const Key('centered-error-dialog')), findsNothing); + expect(find.byKey(const Key('centered-toast')), findsNothing); }); testWidgets( @@ -95,11 +95,11 @@ void main() { find.text('Password needs both letters and numbers'), findsOneWidget, ); - expect(find.byKey(const Key('centered-error-dialog')), findsNothing); + expect(find.byKey(const Key('centered-toast')), findsNothing); }, ); - testWidgets('should_show_centered_dialog_when_login_api_returns_an_error', ( + testWidgets('should_show_centered_toast_when_login_api_returns_an_error', ( tester, ) async { final api = _FailingAuthApi(loginMessage: 'Invalid email or password'); @@ -110,13 +110,13 @@ void main() { await tester.tap(find.byKey(const Key('auth-submit'))); await tester.pumpAndSettle(); - final dialog = find.byKey(const Key('centered-error-dialog')); - expect(dialog, findsOneWidget); - expect(find.text('Login failed'), findsOneWidget); + final toast = find.byKey(const Key('centered-toast')); + expect(toast, findsOneWidget); expect(find.text('Invalid email or password'), findsOneWidget); + expect(find.byType(AlertDialog), findsNothing); expect(api.loginCalls, 1); expect( - tester.getCenter(dialog), + tester.getCenter(toast), offsetMoreOrLessEquals( tester.getCenter(find.byType(Scaffold)), epsilon: 1, @@ -129,32 +129,29 @@ void main() { isNotNull, ); - await tester.tap(find.byKey(const Key('centered-error-confirm'))); - await tester.pumpAndSettle(); - expect(dialog, findsNothing); + await _pumpToastAway(tester); + expect(toast, findsNothing); }); - testWidgets( - 'should_show_registration_failed_title_when_register_api_returns_an_error', - (tester) async { - final api = _FailingAuthApi(registerMessage: 'Email already exists'); - await pumpAuthPage(tester, signUp: true, api: api); - await _enterValidCredentials(tester); + testWidgets('should_show_centered_toast_when_register_api_returns_an_error', ( + tester, + ) async { + final api = _FailingAuthApi(registerMessage: 'Email already exists'); + await pumpAuthPage(tester, signUp: true, api: api); + await _enterValidCredentials(tester); - await tester.ensureVisible(find.byKey(const Key('auth-submit'))); - await tester.tap(find.byKey(const Key('auth-submit'))); - await tester.pumpAndSettle(); + await tester.ensureVisible(find.byKey(const Key('auth-submit'))); + await tester.tap(find.byKey(const Key('auth-submit'))); + await tester.pumpAndSettle(); - expect(find.byKey(const Key('centered-error-dialog')), findsOneWidget); - expect(find.text('Registration failed'), findsOneWidget); - expect(find.text('Email already exists'), findsOneWidget); - expect(api.registerCalls, 1); + expect(find.byKey(const Key('centered-toast')), findsOneWidget); + expect(find.text('Email already exists'), findsOneWidget); + expect(find.byType(AlertDialog), findsNothing); + expect(api.registerCalls, 1); - await tester.tapAt(const Offset(8, 8)); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('centered-error-dialog')), findsNothing); - }, - ); + await _pumpToastAway(tester); + expect(find.byKey(const Key('centered-toast')), findsNothing); + }); testWidgets( 'should_show_network_error_when_login_throws_an_unknown_exception', @@ -167,8 +164,12 @@ void main() { await tester.tap(find.byKey(const Key('auth-submit'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('centered-error-dialog')), findsOneWidget); + expect(find.byKey(const Key('centered-toast')), findsOneWidget); expect(find.text('Network error, please try again'), findsOneWidget); + expect(find.byType(AlertDialog), findsNothing); + + await _pumpToastAway(tester); + expect(find.byKey(const Key('centered-toast')), findsNothing); }, ); @@ -422,6 +423,11 @@ Future _enterValidCredentials(WidgetTester tester) async { await tester.enterText(find.byKey(const Key('auth-password')), 'abcd1234'); } +Future _pumpToastAway(WidgetTester tester) async { + await tester.pump(const Duration(seconds: 3)); + await tester.pumpAndSettle(); +} + void _expectAboveKeyboard(WidgetTester tester, Key fieldKey, double inset) { final screenHeight = tester.view.physicalSize.height / tester.view.devicePixelRatio; diff --git a/test/auth/phone_auth_sheet_test.dart b/test/auth/phone_auth_sheet_test.dart index 3c3e2d8..6b08bab 100644 --- a/test/auth/phone_auth_sheet_test.dart +++ b/test/auth/phone_auth_sheet_test.dart @@ -98,10 +98,10 @@ void main() { expect(find.text('Enter a valid phone number'), findsOneWidget); expect(adapter.requests, isEmpty); - expect(find.byKey(const Key('centered-error-dialog')), findsNothing); + expect(find.byKey(const Key('centered-toast')), findsNothing); }); - testWidgets('should_show_centered_dialog_when_otp_request_fails', ( + testWidgets('should_show_centered_toast_when_otp_request_fails', ( tester, ) async { final api = _FailingPhoneAuthApi(sendOtpMessage: 'Too many OTP requests'); @@ -117,9 +117,9 @@ void main() { await tester.tap(find.byKey(const Key('auth-send-code'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('centered-error-dialog')), findsOneWidget); - expect(find.text('Login failed'), findsOneWidget); + expect(find.byKey(const Key('centered-toast')), findsOneWidget); expect(find.text('Too many OTP requests'), findsOneWidget); + expect(find.byType(AlertDialog), findsNothing); expect(api.sendOtpCalls, 1); expect( tester @@ -127,9 +127,12 @@ void main() { .onPressed, isNotNull, ); + + await _pumpToastAway(tester); + expect(find.byKey(const Key('centered-toast')), findsNothing); }); - testWidgets('should_show_centered_dialog_when_phone_login_fails', ( + testWidgets('should_show_centered_toast_when_phone_login_fails', ( tester, ) async { final api = _FailingPhoneAuthApi(loginMessage: 'Invalid verification code'); @@ -146,9 +149,9 @@ void main() { await tester.tap(find.byKey(const Key('auth-phone-submit'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('centered-error-dialog')), findsOneWidget); - expect(find.text('Login failed'), findsOneWidget); + expect(find.byKey(const Key('centered-toast')), findsOneWidget); expect(find.text('Invalid verification code'), findsOneWidget); + expect(find.byType(AlertDialog), findsNothing); expect(api.loginWithPhoneCalls, 1); expect( tester @@ -156,6 +159,9 @@ void main() { .onPressed, isNotNull, ); + + await _pumpToastAway(tester); + expect(find.byKey(const Key('centered-toast')), findsNothing); }); testWidgets('should_keep_code_above_ios_keyboard', (tester) async { @@ -191,6 +197,11 @@ void main() { }); } +Future _pumpToastAway(WidgetTester tester) async { + await tester.pump(const Duration(seconds: 3)); + await tester.pumpAndSettle(); +} + class _FailingPhoneAuthApi extends AuthApi { _FailingPhoneAuthApi({ this.sendOtpMessage = 'otp failed', diff --git a/test/core/centered_toast_test.dart b/test/core/centered_toast_test.dart new file mode 100644 index 0000000..84e4a9a --- /dev/null +++ b/test/core/centered_toast_test.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/widgets/centered_toast.dart'; + +void main() { + testWidgets('should_replace_previous_toast_when_shown_again', (tester) async { + late BuildContext hostContext; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + hostContext = context; + return const Scaffold(body: SizedBox.expand()); + }, + ), + ), + ); + + showCenteredToast(hostContext, 'first message'); + await tester.pump(); + showCenteredToast(hostContext, 'second message'); + await tester.pump(); + + expect(find.byKey(const Key('centered-toast')), findsOneWidget); + expect(find.text('second message'), findsOneWidget); + expect(find.text('first message'), findsNothing); + + await tester.pump(const Duration(seconds: 2)); + await tester.pump(const Duration(milliseconds: 200)); + expect(find.byKey(const Key('centered-toast')), findsNothing); + }); + + testWidgets('should_allow_underlying_gesture_when_toast_is_visible', ( + tester, + ) async { + late BuildContext hostContext; + var taps = 0; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + hostContext = context; + return Scaffold( + body: GestureDetector( + key: const Key('toast-host'), + behavior: HitTestBehavior.opaque, + onTap: () => taps++, + child: const SizedBox.expand(), + ), + ); + }, + ), + ), + ); + showCenteredToast(hostContext, 'tap-through'); + await tester.pump(); + + await tester.tapAt( + tester.getCenter(find.byKey(const Key('centered-toast'))), + ); + + expect(taps, 1); + await tester.pump(const Duration(seconds: 2)); + await tester.pump(const Duration(milliseconds: 200)); + }); + + testWidgets('should_replace_toast_when_previous_toast_is_fading_out', ( + tester, + ) async { + late BuildContext hostContext; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + hostContext = context; + return const Scaffold(body: SizedBox.expand()); + }, + ), + ), + ); + + showCenteredToast(hostContext, 'first message'); + await tester.pump(); + await tester.pump(const Duration(seconds: 2)); + showCenteredToast(hostContext, 'replacement message'); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(find.text('replacement message'), findsOneWidget); + await tester.pump(const Duration(seconds: 3)); + await tester.pumpAndSettle(); + }); +} From 623c184243b9beb3c4570ec5d4af5b757e04f99e Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 10:09:33 +0800 Subject: [PATCH 003/129] =?UTF-8?q?fix(player):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E6=8C=87=E7=A4=BA=E5=99=A8=E4=B8=8E=E9=94=81?= =?UTF-8?q?=E5=AE=9A=E8=BF=9B=E5=BA=A6=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-10-22-player-loading-polish.md | 30 ++++++ docs/plans/PROGRESS.md | 33 +++++++ lib/pages/player/player_view.dart | 25 +++-- .../player/widgets/home_feed_overlay.dart | 3 + .../widgets/home_feed_overlay_parts.dart | 69 ++++++++++---- .../widgets/loading_progress_track.dart | 4 +- lib/pages/player/widgets/player_overlay.dart | 1 + .../player/widgets/player_overlay_models.dart | 4 + .../player/widgets/seek_progress_bar.dart | 43 ++++++++- test/home/home_preview_layout_test.dart | 78 ++++++++++++++++ test/playback/seek_progress_bar_test.dart | 42 +++++++++ test/player/loading_progress_track_test.dart | 3 +- test/player/player_fullscreen_test.dart | 91 ++++++++++++++++++- 13 files changed, 393 insertions(+), 33 deletions(-) create mode 100644 docs/plans/2026-10-22-player-loading-polish.md diff --git a/docs/plans/2026-10-22-player-loading-polish.md b/docs/plans/2026-10-22-player-loading-polish.md new file mode 100644 index 0000000..b98133a --- /dev/null +++ b/docs/plans/2026-10-22-player-loading-polish.md @@ -0,0 +1,30 @@ +# 播放器 loading 与锁定进度条调整 + +## 目标 + +- 播放器票据与 session loading 圈统一为 24×24、线宽 2。 +- 缓冲/加载进度高光改为白色。 +- 未解锁剧集显示静态 inactive 轨道,不进入 loading,不允许拖动 seek。 + +## 实施步骤 + +1. 更新 `LoadingProgressTrack` 默认高光颜色,并补颜色断言。 +2. 为 `SeekProgressBar`、`PlayerOverlayData`、`HomeFeedOverlay` 和 `_FeedProgressBar` 增加 locked 状态。 +3. `PlayerPage` 使用 `logic.session.isLocked || episode.locked` 传入两个浮层。 +4. 缩小 `player-ticket-loading` 与 `player-session-loading` 的尺寸和线宽。 +5. 补首页/全屏锁定态、缓冲高光和两个 loading 指示器尺寸测试。 +6. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。 + +## 风险 / 待确认 + +- 锁定态只影响进度条 UI 与手势,不改播放、解锁或接口逻辑。 +- 首页浮层锁定线沿用当前 `Colors.white24` inactive 色,避免改变非锁定轨道样式。 + +## 执行结果 + +- 两个播放器 loading 圈均为 24×24、线宽 2、主色不变。 +- loading 高光默认改为白色,普通轨道与首页 white24 轨道颜色未变。 +- 首页与全屏播放器均按剧集锁定态渲染静态空轨道并禁用 seek。 +- 已覆盖锁定轨道高度、无 loading、无拖动 seek、loading 指示器尺寸和高光颜色。 +- `dart analyze lib test` 零告警,`flutter test --no-pub` 576 项全过。 +- 未启停服务,未操作真机。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index c3bef1c..4c4cdbb 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -266,3 +266,36 @@ $ flutter test --no-pub 解读:吐司单例替换、点击穿透和自动消失通过;认证异常与本地校验路径通过; 全量静态分析零告警,573 项测试全过。`pubspec.lock` 已还原,未启停服务, 未操作真机。 + +## 2026-10-22 播放器 loading 与锁定进度条调整 + +- 已从 `develop`(`7b068e8`)创建 `feature/player-loading-polish`。 +- 播放器票据与 session loading 圈统一为 24×24、线宽 2。 +- loading 高光默认改为白色,普通轨道色与首页 white24 轨道保持不变。 +- 首页和全屏播放器进度条增加 locked 状态;锁定态显示静态 inactive 线,不进入 loading, + 禁用拖动与 seek。 +- 已补 loading 指示器尺寸、缓冲高光颜色、首页/全屏锁定态和锁定轨道高度测试。 + +执行命令与原始输出: + +```text +$ flutter test --no-pub test/player/loading_progress_track_test.dart test/playback/seek_progress_bar_test.dart +00:00 +8: All tests passed! + +$ flutter test --no-pub test/home/home_preview_layout_test.dart +00:01 +7: All tests passed! + +$ flutter test --no-pub test/player/player_fullscreen_test.dart +00:09 +37: All tests passed! + +$ dart analyze lib test +Analyzing lib, test... +No issues found! + +$ flutter test --no-pub +00:50 +576: All tests passed! +``` + +解读:加载高光、锁定静态轨道、拖动禁用和两个 loading 指示器尺寸均通过; +全量静态分析零告警,576 项测试全过。`pubspec.lock` 已还原,未启停服务, +未操作真机。 diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 9f23d17..4a7807e 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -42,6 +42,8 @@ import '../../core/data/repositories/playback_repository.dart'; part 'player_view_actions.dart'; const double _boostPlaybackSpeed = 2.0; +const double _playerLoadingSize = 24; +const double _playerLoadingStrokeWidth = 2; class PlayerPage extends StatefulWidget { const PlayerPage({ @@ -372,6 +374,7 @@ class _PlayerPageState extends State final videoPort = port is VideoPlayerPort ? port : null; final ready = videoPort?.controller.value.isInitialized ?? false; final theater = logic.pip || logic.pipPreparing; + final locked = logic.session.isLocked || episode.locked; return Stack( fit: StackFit.expand, @@ -400,9 +403,13 @@ class _PlayerPageState extends State // 票据三态之一:起播前正在向后端要签名地址 if (selected && logic.ticketLoading) const Center( - child: CircularProgressIndicator( - key: Key('player-ticket-loading'), - color: AppColors.blue, + child: SizedBox.square( + dimension: _playerLoadingSize, + child: CircularProgressIndicator( + key: Key('player-ticket-loading'), + color: AppColors.blue, + strokeWidth: _playerLoadingStrokeWidth, + ), ), ), // 票据三态之二:拿票失败(断网/服务异常)→ 可重试,不假装在加载 @@ -435,9 +442,13 @@ class _PlayerPageState extends State ), if (selected && logic.session.loading) const Center( - child: CircularProgressIndicator( - key: Key('player-session-loading'), - color: AppColors.blue, + child: SizedBox.square( + dimension: _playerLoadingSize, + child: CircularProgressIndicator( + key: Key('player-session-loading'), + color: AppColors.blue, + strokeWidth: _playerLoadingStrokeWidth, + ), ), ), if (selected && !ready && logic.session.error != null) @@ -499,6 +510,7 @@ class _PlayerPageState extends State drama: drama, episode: episode, controller: ready ? videoPort!.controller : null, + locked: locked, visible: _chromeVisible, boosting: _boosting, onBoostStart: _startBoost, @@ -552,6 +564,7 @@ class _PlayerPageState extends State drama: drama, episode: episode, controller: ready ? videoPort!.controller : null, + locked: locked, liked: interactions.isEpisodeLiked(episode.id), likeCount: interactions.episodeLikeCount(episode.id) ?? diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index d841bce..e6c99db 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -38,6 +38,7 @@ class HomeFeedOverlay extends StatefulWidget { required this.drama, required this.episode, required this.controller, + required this.locked, required this.liked, required this.saved, required this.visible, @@ -64,6 +65,7 @@ class HomeFeedOverlay extends StatefulWidget { final Drama drama; final Episode episode; final ValueListenable? controller; + final bool locked; final bool liked; final int? likeCount; final int? favoriteCount; @@ -387,6 +389,7 @@ class _HomeFeedOverlayState extends State { bottom: 0, child: _FeedProgressBar( controller: widget.controller, + locked: widget.locked, height: _progressHeight, hitHeight: _progressHitHeight, onScrubStart: widget.onScrubStart, diff --git a/lib/pages/player/widgets/home_feed_overlay_parts.dart b/lib/pages/player/widgets/home_feed_overlay_parts.dart index 690876b..37b3370 100644 --- a/lib/pages/player/widgets/home_feed_overlay_parts.dart +++ b/lib/pages/player/widgets/home_feed_overlay_parts.dart @@ -6,11 +6,13 @@ const double _thumbSize = 6; const double _thumbActiveSize = 14; const double _trackActiveHeight = 6; const double _lineCenterFromBottom = 8; +const Key _inactiveTrackKey = Key('home-feed-progress-inactive'); /// 整集条底部的进度线:20px 手势区内可点按/拖动 seek。 class _FeedProgressBar extends StatefulWidget { const _FeedProgressBar({ required this.controller, + required this.locked, required this.height, required this.hitHeight, this.onScrubStart, @@ -18,6 +20,7 @@ class _FeedProgressBar extends StatefulWidget { }); final ValueListenable? controller; + final bool locked; final double height; final double hitHeight; final VoidCallback? onScrubStart; @@ -50,6 +53,16 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { _buffering = false; _attachSource(); } + if (oldWidget.locked != widget.locked) { + _bufferTimer?.cancel(); + _bufferTimer = null; + _buffering = false; + if (widget.locked) { + _preview = null; + } else { + _syncBufferState(notify: false); + } + } } @override @@ -67,9 +80,12 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { final source = widget.controller; final scrubbing = _preview != null; final loading = - source == null || - (!scrubbing && (!source.value.isInitialized || _buffering)); - final track = source == null + !widget.locked && + (source == null || + (!scrubbing && (!source.value.isInitialized || _buffering))); + final track = widget.locked + ? _inactiveTrack() + : source == null ? _loadingTrack() : ValueListenableBuilder( valueListenable: source, @@ -86,22 +102,23 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { return _track(_preview ?? played, width); }, ); + final interactive = !widget.locked && !loading; return GestureDetector( key: const Key('home-feed-progress'), behavior: HitTestBehavior.opaque, - onHorizontalDragStart: loading - ? null - : (details) { + onHorizontalDragStart: interactive + ? (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), + } + : null, + onHorizontalDragUpdate: interactive + ? (details) => _update(details.localPosition.dx / width) + : null, + onHorizontalDragEnd: interactive ? (_) => _commit(_preview) : null, + onHorizontalDragCancel: interactive + ? () => setState(() => _preview = null) + : null, child: track, ); }, @@ -121,7 +138,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { void _syncBufferState({bool notify = true}) { final source = widget.controller; - if (source == null) { + if (widget.locked || source == null) { _bufferTimer?.cancel(); _bufferTimer = null; _setBuffering(false, notify: notify); @@ -180,12 +197,32 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { ); } + Widget _inactiveTrack() { + final bottom = _lineCenterFromBottom - widget.height / 2; + return SizedBox( + height: widget.hitHeight, + child: Stack( + children: [ + Positioned( + left: 0, + right: 0, + bottom: bottom, + child: SizedBox( + height: widget.height, + child: ColoredBox(key: _inactiveTrackKey, color: Colors.white24), + ), + ), + ], + ), + ); + } + void _update(double fraction) { setState(() => _preview = fraction.clamp(0.0, 1.0).toDouble()); } void _commit(double? fraction) { - if (fraction == null) return; + if (widget.locked || fraction == null) return; final total = widget.controller?.value.duration.inMilliseconds ?? 0; if (total > 0) { widget.onSeek?.call( diff --git a/lib/pages/player/widgets/loading_progress_track.dart b/lib/pages/player/widgets/loading_progress_track.dart index 5d48d51..d54cb91 100644 --- a/lib/pages/player/widgets/loading_progress_track.dart +++ b/lib/pages/player/widgets/loading_progress_track.dart @@ -2,15 +2,13 @@ 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, + this.highlightColor = Colors.white, }); final double height; diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index 9896595..5d3fdcb 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -263,6 +263,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { semanticsLabel: data.l10n.seekVideo, showTimes: true, compact: true, + locked: data.locked, inactiveTrackColor: Colors.white24, onScrubStart: actions.onScrubStart, onSeek: actions.onSeek, diff --git a/lib/pages/player/widgets/player_overlay_models.dart b/lib/pages/player/widgets/player_overlay_models.dart index 0bdc23e..1a2dd92 100644 --- a/lib/pages/player/widgets/player_overlay_models.dart +++ b/lib/pages/player/widgets/player_overlay_models.dart @@ -23,6 +23,7 @@ class PlayerOverlayData { required this.playbackSpeed, required this.totalEpisodes, required this.boosting, + required this.locked, this.authorNote = '', }); @@ -65,6 +66,9 @@ class PlayerOverlayData { /// 是否处于长按加速态。 final bool boosting; + /// 当前剧集是否锁定。 + final bool locked; + /// 作者声明;没有后端字段时为空。 final String authorNote; } diff --git a/lib/pages/player/widgets/seek_progress_bar.dart b/lib/pages/player/widgets/seek_progress_bar.dart index a4fcbb0..553462a 100644 --- a/lib/pages/player/widgets/seek_progress_bar.dart +++ b/lib/pages/player/widgets/seek_progress_bar.dart @@ -9,6 +9,8 @@ import 'package:video_player/video_player.dart'; import '../../../core/theme/app_colors.dart'; import 'loading_progress_track.dart'; +const Key _inactiveTrackKey = Key('progress-inactive'); + /// Keeps seeks within the playable range so dragging to the edge does not /// trigger the player's completion callback before playback resumes. Duration seekPositionForFraction(double fraction, Duration duration) { @@ -29,6 +31,7 @@ class SeekProgressBar extends StatefulWidget { this.showTimes = true, this.compact = false, this.inactiveTrackColor = AppColors.border, + this.locked = false, }); final ValueListenable? valueListenable; @@ -38,6 +41,7 @@ class SeekProgressBar extends StatefulWidget { final bool showTimes; final bool compact; final Color inactiveTrackColor; + final bool locked; @override State createState() => _SeekProgressBarState(); @@ -67,6 +71,16 @@ class _SeekProgressBarState extends State { _buffering = false; _attachSource(); } + if (oldWidget.locked != widget.locked) { + _bufferTimer?.cancel(); + _bufferTimer = null; + _buffering = false; + if (widget.locked) { + _previewFraction.value = null; + } else { + _syncBufferState(notify: false); + } + } } @override @@ -90,7 +104,7 @@ class _SeekProgressBarState extends State { void _syncBufferState({bool notify = true}) { final source = widget.valueListenable; - if (source == null) { + if (widget.locked || source == null) { _bufferTimer?.cancel(); _bufferTimer = null; _setBuffering(false, notify: notify); @@ -133,6 +147,7 @@ class _SeekProgressBarState extends State { } Future _finish(double fraction, Duration duration) async { + if (widget.locked) return; final source = widget.valueListenable; _previewFraction.value = fraction; try { @@ -166,8 +181,13 @@ class _SeekProgressBarState extends State { 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 loading = + !widget.locked && !scrubbing && (!video.isInitialized || _buffering); + final enabled = + !widget.locked && + video.isInitialized && + total > 0 && + widget.onSeek != null; final played = total > 0 ? (video.position.inMilliseconds / total).clamp(0.0, 1.0) : 0.0; @@ -203,7 +223,9 @@ class _SeekProgressBarState extends State { ), SizedBox( height: trackAreaHeight, - child: loading + child: widget.locked + ? _inactiveTrack(trackHeight) + : loading ? Center( child: LoadingProgressTrack( height: trackHeight, @@ -251,6 +273,19 @@ class _SeekProgressBarState extends State { ); } + Widget _inactiveTrack(double height) { + return Center( + child: SizedBox( + width: double.infinity, + height: height, + child: ColoredBox( + key: _inactiveTrackKey, + color: widget.inactiveTrackColor, + ), + ), + ); + } + String _format(Duration value) { final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0'); final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0'); diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 4064f01..bf2d5fb 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -106,6 +106,7 @@ void main() { isFree: true, ), controller: null, + locked: false, liked: true, saved: false, visible: true, @@ -183,6 +184,80 @@ void main() { expect(find.byKey(const Key('home-feed-progress-thumb')), findsNothing); }); + testWidgets('should_show_static_track_without_seek_when_feed_is_locked', ( + tester, + ) async { + var scrubStarts = 0; + var seeks = 0; + 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: 'locked-feed', + title: 'Locked Feed', + synopsis: 'synopsis', + posterUrl: '', + ), + episode: const Episode( + id: 'locked-feed-1', + number: 1, + title: 'Locked', + isFree: false, + locked: true, + ), + controller: null, + locked: true, + liked: false, + saved: false, + visible: true, + boosting: false, + onTogglePlay: () {}, + onBoostStart: () {}, + onBoostEnd: () {}, + onLike: () {}, + onSave: () {}, + onComments: () {}, + onShare: () {}, + onDetail: () {}, + onOpenFullPlayer: () {}, + onMore: () {}, + onScrubStart: () => scrubStarts++, + onSeek: (_) => seeks++, + ), + ), + ), + ), + ); + + expect(find.byKey(const Key('progress-loading')), findsNothing); + expect(find.byKey(const Key('progress-loading-highlight')), findsNothing); + expect( + find.byKey(const Key('home-feed-progress-inactive')), + findsOneWidget, + ); + expect( + tester + .getSize(find.byKey(const Key('home-feed-progress-inactive'))) + .height, + 2, + ); + + await tester.drag( + find.byKey(const Key('home-feed-progress')), + const Offset(120, 0), + ); + await tester.pumpAndSettle(); + expect(scrubStarts, 0); + expect(seeks, 0); + }); + testWidgets('should_seek_and_open_player_from_feed_actions', (tester) async { var opened = 0; Duration? seeked; @@ -219,6 +294,7 @@ void main() { isFree: true, ), controller: controller, + locked: false, liked: false, saved: false, visible: true, @@ -355,6 +431,7 @@ void main() { isFree: true, ), controller: controller, + locked: false, liked: false, saved: false, visible: true, @@ -505,6 +582,7 @@ void main() { isFree: true, ), controller: controller, + locked: false, liked: false, saved: false, visible: false, diff --git a/test/playback/seek_progress_bar_test.dart b/test/playback/seek_progress_bar_test.dart index 860423f..5682333 100644 --- a/test/playback/seek_progress_bar_test.dart +++ b/test/playback/seek_progress_bar_test.dart @@ -2,6 +2,7 @@ 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/seek_progress_bar.dart'; import 'package:video_player/video_player.dart'; @@ -34,6 +35,47 @@ void main() { expect(find.byType(Slider), findsNothing); }); + testWidgets('should_show_inactive_track_and_disable_seek_when_locked', ( + tester, + ) async { + final video = ValueNotifier( + const VideoPlayerValue( + duration: Duration(seconds: 12), + position: Duration(seconds: 3), + isInitialized: true, + ), + ); + addTearDown(video.dispose); + var seeks = 0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SeekProgressBar( + valueListenable: video, + semanticsLabel: 'Seek video', + locked: true, + onSeek: (_) async => seeks++, + ), + ), + ), + ); + + expect(find.byKey(const Key('progress-loading')), findsNothing); + expect(find.byKey(const Key('progress-loading-highlight')), findsNothing); + expect(find.byType(Slider), findsNothing); + final inactive = tester.widget( + find.byKey(const Key('progress-inactive')), + ); + expect(inactive.color, AppColors.border); + + await tester.drag( + find.byKey(const Key('progress-inactive')), + const Offset(120, 0), + ); + await tester.pumpAndSettle(); + expect(seeks, 0); + }); + testWidgets('should_debounce_buffering_before_showing_loading_track', ( tester, ) async { diff --git a/test/player/loading_progress_track_test.dart b/test/player/loading_progress_track_test.dart index cacf042..b461a8b 100644 --- a/test/player/loading_progress_track_test.dart +++ b/test/player/loading_progress_track_test.dart @@ -3,7 +3,6 @@ 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() { @@ -37,7 +36,7 @@ void main() { find.byKey(const Key('progress-loading-highlight')), ) .color, - AppColors.blue, + Colors.white, ); final initialLeft = tester diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 8b763c8..854ee69 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -65,6 +65,53 @@ void main() { _expectPlayerNavigationBar(tester); }); + testWidgets('should_render_player_loading_indicators_at_compact_size', ( + tester, + ) async { + final playback = _PendingPlaybackRepository(); + final initialization = Completer(); + await _pumpApp( + tester, + playback: playback, + playerFactory: (_) => _GatedPlayer(initialization.future), + settle: false, + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + final ticketLoading = find.byKey(const Key('player-ticket-loading')); + expect(ticketLoading, findsOneWidget); + expect(tester.getSize(ticketLoading), const Size(24, 24)); + expect( + tester.widget(ticketLoading).strokeWidth, + 2, + ); + + playback.ticket.complete( + const PlayTicket( + episodeId: 'city-lights-demo-1', + accessible: true, + accessType: PlayTicket.free, + playUrl: '/files/episode/fake-city.mp4?e=1&s=abc', + qualities: ['AUTO'], + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + final sessionLoading = find.byKey(const Key('player-session-loading')); + expect(sessionLoading, findsOneWidget); + expect(tester.getSize(sessionLoading), const Size(24, 24)); + expect( + tester.widget(sessionLoading).strokeWidth, + 2, + ); + + initialization.complete(); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + }); + testWidgets('should_pause_and_resume_player_when_page_route_covers_it', ( tester, ) async { @@ -956,6 +1003,16 @@ void main() { tester, cardKey: const Key('home-drama-card-locked-demo'), ); + expect(find.byKey(const Key('progress-loading')), findsNothing); + expect(find.byKey(const Key('progress-loading-highlight')), findsNothing); + expect(find.byKey(const Key('progress-inactive')), findsOneWidget); + expect(find.byType(Slider), findsNothing); + expect( + tester + .widget(find.byKey(const Key('player-progress'))) + .locked, + isTrue, + ); await tester.tap(find.byKey(const Key('player-episodes'))); await tester.pumpAndSettle(); @@ -1293,10 +1350,11 @@ Future _pumpApp( WidgetTester tester, { Size size = const Size(390, 844), FakeCatalogRepository? catalog, - FakePlaybackRepository? playback, + PlaybackRepository? playback, FakeInteractionRepository? interactions, FakeCommentRepository? commentRepository, PlayerFactory? playerFactory, + bool settle = true, }) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; @@ -1317,7 +1375,7 @@ Future _pumpApp( haptics: FakeHaptics(), ), ); - await tester.pumpAndSettle(); + if (settle) await tester.pumpAndSettle(); } const _countingDrama = Drama( @@ -1361,6 +1419,35 @@ class _RecordingPlayer implements PlayerPort { Future dispose() async {} } +class _PendingPlaybackRepository implements PlaybackRepository { + final Completer ticket = Completer(); + + @override + Future ticketFor(String episodeId, {bool forceRefresh = false}) => + ticket.future; + + @override + bool isFresh(String episodeId) => false; +} + +class _GatedPlayer implements PlayerPort { + _GatedPlayer(this.initialization); + + final Future initialization; + + @override + Future initialize() => initialization; + + @override + Future play() async {} + + @override + Future pause() async {} + + @override + Future dispose() async {} +} + const _lockedDrama = Drama( id: 'locked-demo', title: 'Locked Demo', From a25c5003712074f374f1b0e0848662a411a713d9 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 10:17:43 +0800 Subject: [PATCH 004/129] =?UTF-8?q?feat(player):=20=E4=BF=9D=E6=8C=81?= =?UTF-8?q?=E5=B1=8F=E5=B9=95=E5=B8=B8=E4=BA=AE=E5=B9=B6=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E6=92=AD=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/stormg/lumadrama/MainActivity.kt | 7 + .../2026-10-22-keep-awake-auto-resume.md | 31 ++++ docs/plans/PROGRESS.md | 27 +++ ios/Runner/AppDelegate.swift | 1 + lib/core/data/services/playback_session.dart | 5 + lib/pages/player/player_logic.dart | 37 +++- test/player/player_logic_test.dart | 166 ++++++++++++++++++ 7 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 docs/plans/2026-10-22-keep-awake-auto-resume.md diff --git a/android/app/src/main/kotlin/com/stormg/lumadrama/MainActivity.kt b/android/app/src/main/kotlin/com/stormg/lumadrama/MainActivity.kt index 3fc470e..a558249 100644 --- a/android/app/src/main/kotlin/com/stormg/lumadrama/MainActivity.kt +++ b/android/app/src/main/kotlin/com/stormg/lumadrama/MainActivity.kt @@ -6,8 +6,10 @@ import android.content.Intent import android.app.PictureInPictureParams import android.content.res.Configuration import android.os.Build +import android.os.Bundle import android.util.Rational import android.content.pm.PackageManager +import android.view.WindowManager import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import io.flutter.embedding.android.FlutterActivity @@ -21,6 +23,11 @@ class MainActivity : FlutterActivity() { private var keyboardChannel: MethodChannel? = null private var imeInset = 0 + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) pipChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.stormg.lumadrama/pip") diff --git a/docs/plans/2026-10-22-keep-awake-auto-resume.md b/docs/plans/2026-10-22-keep-awake-auto-resume.md new file mode 100644 index 0000000..b2781ed --- /dev/null +++ b/docs/plans/2026-10-22-keep-awake-auto-resume.md @@ -0,0 +1,31 @@ +# 屏幕常亮与前台自动续播 + +## 目标 + +- Android 与 iOS 前台保持屏幕常亮,不引入插件或依赖。 +- 用户主动暂停后切后台再回前台,在播放器与票据状态安全时自动续播。 +- 锁定、blocked、票据失败、错误态及浮层遮挡场景不得强制起播。 + +## 实施步骤 + +1. Android `MainActivity.onCreate` 添加 `FLAG_KEEP_SCREEN_ON`,保留现有渠道逻辑。 +2. iOS `AppDelegate` 设置 `UIApplication.shared.isIdleTimerDisabled = true`。 +3. 为 `PlaybackSession` 增加无浮层、路线可见且 active 播放器可用的恢复判断。 +4. 调整 `PlayerLogic.onLifecycle`,在现有 PiP 复位和过期票据刷新流程后安全设置播放意图。 +5. 补用户暂停恢复、锁定/票据失败不恢复、后台保存并暂停测试。 +6. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。 + +## 风险 / 待确认 + +- 真机常亮和生命周期行为由 PM 验收,本次不启停服务、不操作设备。 +- PiP 继续沿用现有可见性策略,不额外修改后台播放行为。 + +## 执行结果 + +- Android 主 Activity 已添加 `FLAG_KEEP_SCREEN_ON`,未改动 Flutter 渠道与 PiP 逻辑。 +- iOS AppDelegate 已设置 `UIApplication.shared.isIdleTimerDisabled = true`。 +- 用户主动暂停后回前台可安全恢复;过期票据会等待重取完成后再恢复。 +- locked、blocked、票据错误、会话错误、解锁/浮层遮挡和 PiP 场景均不会强制续播。 +- 后台仍先保存进度并暂停。 +- `dart analyze lib test` 零告警,`flutter test --no-pub` 580 项全过。 +- 未启停服务,未操作真机。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 4c4cdbb..f014d7e 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -299,3 +299,30 @@ $ flutter test --no-pub 解读:加载高光、锁定静态轨道、拖动禁用和两个 loading 指示器尺寸均通过; 全量静态分析零告警,576 项测试全过。`pubspec.lock` 已还原,未启停服务, 未操作真机。 + +## 2026-10-22 屏幕常亮与前台自动续播 + +- 已从 `develop`(`5bf1022`)创建 `feature/keep-awake-auto-resume`。 +- Android 主 Activity 添加 `FLAG_KEEP_SCREEN_ON`,保留已有渠道、PiP 和键盘逻辑。 +- iOS AppDelegate 开启 `isIdleTimerDisabled`,前台保持常亮。 +- 用户主动暂停后回前台安全恢复;过期票据先完成重取再设置播放意图。 +- locked、blocked、票据错误、会话错误、解锁/浮层遮挡和 PiP 场景不会强制续播。 +- 后台仍立即保存进度并暂停,不做后台播放。 + +执行命令与原始输出: + +```text +$ flutter test --no-pub test/player/player_logic_test.dart +00:00 +35: All tests passed! + +$ dart analyze lib test +Analyzing lib, test... +No issues found! + +$ flutter test --no-pub +00:50 +580: All tests passed! +``` + +解读:用户暂停恢复、过期票据重取、locked/ticketError/浮层阻断及后台保存暂停 +均通过;全量静态分析零告警,580 项测试全过。`pubspec.lock` 已还原, +未启停服务,未操作真机。 diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 946f662..f94360c 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -20,6 +20,7 @@ final class RunnerFlutterViewController: FlutterViewController { _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { + UIApplication.shared.isIdleTimerDisabled = true return super.application(application, didFinishLaunchingWithOptions: launchOptions) } diff --git a/lib/core/data/services/playback_session.dart b/lib/core/data/services/playback_session.dart index d0c58ea..ccb4526 100644 --- a/lib/core/data/services/playback_session.dart +++ b/lib/core/data/services/playback_session.dart @@ -92,6 +92,7 @@ class PlaybackSession extends ChangeNotifier { } return slot.player; } + bool get loading => _loading; bool get userWantsPlay => _userWantsPlay; bool get canAutoAdvance => _mayPlay && !_loading && active != null; @@ -103,6 +104,10 @@ class PlaybackSession extends ChangeNotifier { Future get playbackSettled => _operations; Future get preloadSettled => _preloadTask; + /// 当前页面可见、无浮层遮挡且存在可用播放器时可安全恢复。 + bool get canResumeAfterBackground => + !_closed && _routeVisible && !_overlayOpen && active != null; + bool get _visible => !_closed && _appResumed && _routeVisible; bool get _mayPlay => _visible && !_overlayOpen && _userWantsPlay; diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 288023f..0e3618d 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -512,6 +512,7 @@ class PlayerLogic extends ChangeNotifier { void onLifecycle(AppLifecycleState state) { if (state != AppLifecycleState.resumed) unawaited(saveCurrentProgress()); if (state == AppLifecycleState.resumed) { + final wasInPip = pip || pipPreparing; // 部分机型退出画中画不会回调 onPipChanged(false):回前台先兜底复位, // 否则 chromeHidden 恒为 true,播放浮层与首页底栏都会消失。 if (pip || pipPreparing) { @@ -523,8 +524,17 @@ class PlayerLogic extends ChangeNotifier { // 签名地址有有效期:回前台时若票据已过期,重新取一张再起播, // 否则会撞上 403(契约 §1.4) final episode = trackedEpisode; + final shouldAutoResume = !wasInPip && _canAutoResume(episode); if (episode != null && !playback.isFresh(episode.id)) { - unawaited(selectEpisode(session.currentIndex)); + unawaited( + _refreshTicketThenResume( + episode, + session.currentIndex, + shouldAutoResume, + ), + ); + } else if (shouldAutoResume) { + session.setUserWantsPlay(true); } } session.setAppResumed( @@ -533,6 +543,31 @@ class PlayerLogic extends ChangeNotifier { if (state != AppLifecycleState.resumed) cancelScrub(); } + Future _refreshTicketThenResume( + Episode episode, + int index, + bool shouldResume, + ) async { + await selectEpisode(index); + if (!shouldResume || + !isMounted() || + trackedEpisode?.id != episode.id || + !_canAutoResume(episode)) { + return; + } + session.setUserWantsPlay(true); + } + + bool _canAutoResume(Episode? episode) { + return episode != null && + ticketError == null && + blockedEpisodeId != episode.id && + unlockingEpisodeId == null && + !session.isLocked && + session.error == null && + session.canResumeAfterBackground; + } + @override void dispose() { if (_disposed) return; diff --git a/test/player/player_logic_test.dart b/test/player/player_logic_test.dart index c04bc59..f52251c 100644 --- a/test/player/player_logic_test.dart +++ b/test/player/player_logic_test.dart @@ -732,6 +732,172 @@ void main() { }); group('lifecycle', () { + test( + 'should_auto_resume_previously_paused_playback_when_app_returns', + () async { + final factory = _RecordingFactory(); + final playback = FakePlaybackRepository(fresh: false); + final drama = _drama('signal'); + final logic = _logic(FakeWatchHistory(), playback: playback); + logic.attach( + PlayerPage(drama: drama, initialIndex: 0), + isMounted: () => true, + ); + logic.start( + factory: factory.create, + coordinator: PlaybackCoordinator(), + ); + await _drain(logic); + final player = factory.created.first; + final requestsBeforeResume = playback.requests.length; + logic + ..trackedEpisode = drama.episodes.first + ..trackedDrama = drama; + + logic.session.setUserWantsPlay(false); + await _drain(logic); + expect(logic.session.userWantsPlay, isFalse); + expect(player.plays, 1); + + logic.onLifecycle(AppLifecycleState.paused); + await _drain(logic); + expect(logic.trackedEpisode, isNotNull); + expect(logic.ticketError, isNull); + expect(logic.blockedEpisodeId, isNull); + expect(logic.unlockingEpisodeId, isNull); + expect(logic.session.isLocked, isFalse); + expect(logic.session.error, isNull); + expect(logic.session.canResumeAfterBackground, isTrue); + logic.onLifecycle(AppLifecycleState.resumed); + await _drain(logic); + + expect(logic.session.userWantsPlay, isTrue); + expect(player.plays, 2); + expect(playback.requests.length, greaterThan(requestsBeforeResume)); + logic.dispose(); + await _drain(logic); + }, + ); + + test('should_not_auto_resume_locked_episode_when_app_returns', () async { + final factory = _RecordingFactory(); + final drama = _drama('locked', isFree: false); + final logic = _logic( + FakeWatchHistory(), + playback: FakePlaybackRepository( + ticket: const PlayTicket( + episodeId: 'locked-1', + accessible: false, + accessType: PlayTicket.coinRequired, + unlockCoin: 30, + ), + ), + ); + logic.attach( + PlayerPage(drama: drama, initialIndex: 0), + isMounted: () => true, + ); + logic.start( + factory: factory.create, + coordinator: PlaybackCoordinator(), + ); + await _drain(logic); + logic.trackedEpisode = drama.episodes.first; + logic.session.setUserWantsPlay(false); + + logic.onLifecycle(AppLifecycleState.paused); + await _drain(logic); + logic.onLifecycle(AppLifecycleState.resumed); + await _drain(logic); + + expect(logic.session.userWantsPlay, isFalse); + expect(factory.created, isEmpty); + logic.dispose(); + await _drain(logic); + }); + + test( + 'should_not_auto_resume_when_ticket_failure_or_overlay_is_active', + () async { + final factory = _RecordingFactory(); + final drama = _drama('signal'); + final logic = _logic(FakeWatchHistory()); + logic.attach( + PlayerPage(drama: drama, initialIndex: 0), + isMounted: () => true, + ); + logic.start( + factory: factory.create, + coordinator: PlaybackCoordinator(), + ); + await _drain(logic); + final player = factory.created.first; + logic.session.setUserWantsPlay(false); + await _drain(logic); + + logic.ticketError = 'ticket failed'; + logic.onLifecycle(AppLifecycleState.paused); + await _drain(logic); + logic.onLifecycle(AppLifecycleState.resumed); + await _drain(logic); + expect(logic.session.userWantsPlay, isFalse); + expect(player.plays, 1); + + logic.ticketError = null; + logic.session.setOverlayOpen(true); + logic.onLifecycle(AppLifecycleState.paused); + await _drain(logic); + logic.onLifecycle(AppLifecycleState.resumed); + await _drain(logic); + expect(logic.session.userWantsPlay, isFalse); + expect(player.plays, 1); + + logic.session.setOverlayOpen(false); + logic.dispose(); + await _drain(logic); + }, + ); + + test( + 'should_save_progress_and_pause_when_app_enters_background', + () async { + final factory = _RecordingFactory(); + final drama = _drama('signal'); + final history = FakeWatchHistory(); + final logic = _logic(history); + logic.attach( + PlayerPage(drama: drama, initialIndex: 0), + isMounted: () => true, + ); + logic.start( + factory: factory.create, + coordinator: PlaybackCoordinator(), + ); + await _drain(logic); + final player = factory.created.first; + final tracked = VideoPlayerPort(drama.episodes.first); + tracked.controller.value = const VideoPlayerValue( + duration: Duration(minutes: 3), + position: Duration(seconds: 5), + isInitialized: true, + ); + logic + ..trackedPlayer = tracked + ..trackedEpisode = drama.episodes.first + ..trackedDrama = drama; + + logic.onLifecycle(AppLifecycleState.paused); + await _drain(logic); + await logic.pendingWrite; + + expect(player.pauses, 1); + expect(history.writes, ['signal-1|5000|false']); + logic.dispose(); + await _drain(logic); + await tracked.dispose(); + }, + ); + test('should_pause_active_player_when_backgrounded_and_play_it_when_resumed', () async { final factory = _RecordingFactory(); final drama = _drama('signal'); From 759a02375ab2fc0209d0594df49278e0fa0d2687 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 10:27:58 +0800 Subject: [PATCH 005/129] =?UTF-8?q?fix(player):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E5=BE=80=E8=BF=94=E6=92=AD=E6=94=BE=E4=BD=8D?= =?UTF-8?q?=E7=BD=AE=E4=B8=A2=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...2026-10-22-playback-position-continuity.md | 32 +++++++ docs/plans/PROGRESS.md | 33 +++++++ lib/app.dart | 7 ++ .../services/playback_position_cache.dart | 79 ++++++++++++++++ .../drama_detail/drama_detail_binding.dart | 3 + .../drama_detail/drama_detail_logic.dart | 20 ++++ lib/pages/drama_detail/drama_detail_view.dart | 2 + lib/pages/player/player_binding.dart | 3 + lib/pages/player/player_logic.dart | 55 ++++++++++- lib/pages/player/player_view.dart | 2 + .../drama_detail_first_frame_test.dart | 2 + .../drama_detail/drama_detail_logic_test.dart | 29 ++++++ test/drama_detail/drama_detail_view_test.dart | 24 +++++ .../playback_position_cache_test.dart | 69 ++++++++++++++ test/player/player_logic_test.dart | 94 +++++++++++++++++++ test/player/player_open_detail_test.dart | 15 ++- 16 files changed, 464 insertions(+), 5 deletions(-) create mode 100644 docs/plans/2026-10-22-playback-position-continuity.md create mode 100644 lib/core/data/services/playback_position_cache.dart create mode 100644 test/playback/playback_position_cache_test.dart diff --git a/docs/plans/2026-10-22-playback-position-continuity.md b/docs/plans/2026-10-22-playback-position-continuity.md new file mode 100644 index 0000000..40bb925 --- /dev/null +++ b/docs/plans/2026-10-22-playback-position-continuity.md @@ -0,0 +1,32 @@ +# 播放位置连续性 + +## 目标 + +- 首页进入详情、详情返回首页、详情再进播放器时保持同一集同一秒。 +- 新增进程内最近播放位置缓存,优先于服务端旧记录。 +- 跨剧、跨集不串位;锁定/票据失败不因缓存强行起播;进程重启仍回服务端。 + +## 实施步骤 + +1. 新增 `PlaybackPositionCache`,按 episodeId 保存 dramaId/position/duration/updatedAt。 +2. App Provider 注入单一缓存实例,PlayerPage 与详情页共享。 +3. PlayerLogic 进度监听、saveCurrentProgress 写入缓存;loadResumePoint 优先命中缓存。 +4. preparePlayer 对每个待准备集按 dramaId 校验缓存,并继续使用现有位置钳位。 +5. 详情页续看状态优先使用该剧最新缓存,未命中再请求服务端。 +6. 补缓存、缓存命中不请求服务端、跨剧隔离、服务端回落和详情返回位置测试。 +7. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。 + +## 风险 / 待确认 + +- 缓存仅保存在进程内,App 重启后自动回落到服务端观看记录。 +- 片尾附近继续复用现有 `clampResumePosition` 完成保护。 + +## 执行结果 + +- 新增进程内按集缓存并通过 Provider 全局共享,详情页与播放器共用。 +- 进度监听、`saveCurrentProgress` 和进入详情前会写入缓存。 +- `loadResumePoint` 优先缓存,未命中或跨剧时才请求服务端。 +- 详情页继续观看与 CTA 优先进缓存集;返回首页保持原播放器与位置。 +- 已覆盖缓存读写/过期、跨剧隔离、缓存命中去网络、服务端回落和详情往返。 +- `dart analyze lib test` 零告警,`flutter test --no-pub` 589 项全过。 +- 未启停服务,未操作真机。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index f014d7e..680c60f 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -326,3 +326,36 @@ $ flutter test --no-pub 解读:用户暂停恢复、过期票据重取、locked/ticketError/浮层阻断及后台保存暂停 均通过;全量静态分析零告警,580 项测试全过。`pubspec.lock` 已还原, 未启停服务,未操作真机。 + +## 2026-10-22 播放位置连续性 + +- 已从 `develop`(`fe49c6d`)创建 `feature/playback-position-continuity`。 +- 新增 `PlaybackPositionCache`,按 episodeId 保存 dramaId、position、duration、updatedAt。 +- App Provider 注入单一缓存实例,PlayerLogic 与详情页共享。 +- 进度监听、保存进度与进入详情前更新缓存;loadResumePoint 优先缓存,未命中再回源。 +- 详情页继续观看与 CTA 优先使用最近缓存集,返回首页保持当前播放器位置。 +- 跨剧缓存不命中;片尾保护继续复用 `clampResumePosition`。 + +执行命令与原始输出: + +```text +$ flutter test --no-pub test/playback/playback_position_cache_test.dart +00:00 +4: All tests passed! + +$ flutter test --no-pub test/player/player_logic_test.dart +00:00 +38: All tests passed! + +$ flutter test --no-pub test/player/player_open_detail_test.dart +00:00 +1: All tests passed! + +$ dart analyze lib test +Analyzing lib, test... +No issues found! + +$ flutter test --no-pub +00:53 +589: All tests passed! +``` + +解读:缓存读写/过期、跨剧隔离、缓存命中去网络、服务端回落、详情 CTA 缓存集 +和详情往返同秒位置均通过;全量静态分析零告警,589 项测试全过。 +`pubspec.lock` 已还原,未启停服务,未操作真机。 diff --git a/lib/app.dart b/lib/app.dart index 3a6393f..1ae476e 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -42,6 +42,7 @@ import 'core/data/services/favorite_controller.dart'; import 'core/data/services/interaction_controller.dart'; import 'core/data/services/locale_controller.dart'; import 'core/data/services/event_tracker.dart'; +import 'core/data/services/playback_position_cache.dart'; import 'core/data/services/playback_session.dart'; import 'core/data/services/video_player_port.dart'; import 'core/data/services/wallet_controller.dart'; @@ -72,6 +73,7 @@ class LumaDramaApp extends StatefulWidget { this.watchHistoryRepository, this.favoriteRepository, this.playbackRepository, + this.playbackPositionCache, this.commentRepository, this.interactionRepository, this.noticeRepository, @@ -91,6 +93,7 @@ class LumaDramaApp extends StatefulWidget { final WatchHistoryRepository? watchHistoryRepository; final FavoriteRepository? favoriteRepository; final PlaybackRepository? playbackRepository; + final PlaybackPositionCache? playbackPositionCache; final CommentRepository? commentRepository; final InteractionRepository? interactionRepository; final NoticeRepository? noticeRepository; @@ -146,6 +149,9 @@ class _LumaDramaAppState extends State { widget.playbackRepository ?? HttpPlaybackRepository(PlaybackApi(_apiClient)); + late final PlaybackPositionCache _playbackPositionCache = + widget.playbackPositionCache ?? PlaybackPositionCache(); + late final FavoriteController _favoriteController = FavoriteController( _favorites, ); @@ -240,6 +246,7 @@ class _LumaDramaAppState extends State { Provider(create: (_) => widget.playerFactory), Provider.value(value: _catalogRepository), Provider.value(value: _playback), + Provider.value(value: _playbackPositionCache), Provider.value(value: _watchHistory), Provider.value(value: _favorites), Provider.value(value: _commentRepository), diff --git a/lib/core/data/services/playback_position_cache.dart b/lib/core/data/services/playback_position_cache.dart new file mode 100644 index 0000000..6a56f1a --- /dev/null +++ b/lib/core/data/services/playback_position_cache.dart @@ -0,0 +1,79 @@ +// Author: fruit.com.cn + +const Duration _defaultMaxAge = Duration(hours: 12); + +/// 进程内最近播放位置。 +class CachedPlaybackPosition { + const CachedPlaybackPosition({ + required this.episodeId, + required this.dramaId, + required this.position, + required this.duration, + required this.updatedAt, + }); + + final String episodeId; + final String dramaId; + final Duration position; + final Duration duration; + final DateTime updatedAt; +} + +/// 按集缓存最近播放位置,仅当前进程有效。 +class PlaybackPositionCache { + PlaybackPositionCache({ + DateTime Function()? now, + this.maxAge = _defaultMaxAge, + }) : _now = now ?? DateTime.now; + + final DateTime Function() _now; + final Duration maxAge; + final Map _positions = {}; + + /// 保存最近有效播放位置;零位置不会覆盖已有记录。 + void save({ + required String episodeId, + required String dramaId, + required Duration position, + required Duration duration, + }) { + if (episodeId.isEmpty || dramaId.isEmpty || position <= Duration.zero) { + return; + } + _positions[episodeId] = CachedPlaybackPosition( + episodeId: episodeId, + dramaId: dramaId, + position: position, + duration: duration, + updatedAt: _now(), + ); + } + + /// 读取同剧同集且未过期的最近位置。 + CachedPlaybackPosition? read(String episodeId, {required String dramaId}) { + final entry = _positions[episodeId]; + if (!_isValid(entry, dramaId)) return null; + return entry; + } + + /// 读取指定短剧最近更新的有效位置。 + CachedPlaybackPosition? latestForDrama(String dramaId) { + CachedPlaybackPosition? latest; + for (final entry in _positions.values) { + if (!_isValid(entry, dramaId)) continue; + if (latest == null || !entry.updatedAt.isBefore(latest.updatedAt)) { + latest = entry; + } + } + return latest; + } + + bool _isValid(CachedPlaybackPosition? entry, String dramaId) { + if (entry == null || + entry.dramaId != dramaId || + entry.position <= Duration.zero) { + return false; + } + return _now().difference(entry.updatedAt) <= maxAge; + } +} diff --git a/lib/pages/drama_detail/drama_detail_binding.dart b/lib/pages/drama_detail/drama_detail_binding.dart index 060dda3..4edaccd 100644 --- a/lib/pages/drama_detail/drama_detail_binding.dart +++ b/lib/pages/drama_detail/drama_detail_binding.dart @@ -4,6 +4,7 @@ import '../../core/data/models/drama.dart'; import '../../core/data/repositories/playback_repository.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/favorite_controller.dart'; +import '../../core/data/services/playback_position_cache.dart'; import 'drama_detail_logic.dart'; class DramaDetailBinding { @@ -12,10 +13,12 @@ class DramaDetailBinding { required WatchHistoryRepository history, required PlaybackRepository playback, required FavoriteController favorites, + required PlaybackPositionCache positionCache, }) => DramaDetailLogic( drama: drama, history: history, playback: playback, favorites: favorites, + positionCache: positionCache, ); } diff --git a/lib/pages/drama_detail/drama_detail_logic.dart b/lib/pages/drama_detail/drama_detail_logic.dart index 42c0a73..82d7536 100644 --- a/lib/pages/drama_detail/drama_detail_logic.dart +++ b/lib/pages/drama_detail/drama_detail_logic.dart @@ -11,6 +11,7 @@ import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/repositories/playback_repository.dart'; import '../../core/data/services/favorite_controller.dart'; import '../../core/data/services/download_controller.dart'; +import '../../core/data/services/playback_position_cache.dart'; import '../../core/data/services/wallet_controller.dart'; import '../player/player_view.dart'; @@ -20,6 +21,7 @@ class DramaDetailLogic extends ChangeNotifier { required this.history, required this.playback, required this.favorites, + required this.positionCache, }); /// 先用列表卡片渲染,取到详情后换成带完整剧集的版本。 @@ -27,6 +29,7 @@ class DramaDetailLogic extends ChangeNotifier { final WatchHistoryRepository history; final PlaybackRepository playback; final FavoriteController favorites; + final PlaybackPositionCache positionCache; bool loading = false; bool failed = false; @@ -71,6 +74,23 @@ class DramaDetailLogic extends ChangeNotifier { } Future loadContinue() async { + final cached = positionCache.latestForDrama(drama.id); + if (cached != null) { + final index = _indexOf(cached.episodeId); + if (index != null) { + progress = WatchProgress( + dramaId: cached.dramaId, + episodeId: cached.episodeId, + position: cached.position, + duration: cached.duration, + updatedAt: cached.updatedAt, + ); + continueIndex = index; + progressError = null; + notify(); + return; + } + } try { final record = await history.progressFor(drama.id); if (_disposed) return; diff --git a/lib/pages/drama_detail/drama_detail_view.dart b/lib/pages/drama_detail/drama_detail_view.dart index de2ae86..f3bcb48 100644 --- a/lib/pages/drama_detail/drama_detail_view.dart +++ b/lib/pages/drama_detail/drama_detail_view.dart @@ -12,6 +12,7 @@ import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/favorite_controller.dart'; import '../../core/data/services/interaction_controller.dart'; +import '../../core/data/services/playback_position_cache.dart'; import '../../core/data/services/platform_share_service.dart'; import '../../core/formatting/error_text.dart'; import '../../core/formatting/localized_content.dart'; @@ -64,6 +65,7 @@ class _DramaDetailPageState extends State { history: context.read(), playback: context.read(), favorites: context.read(), + positionCache: context.read(), ); _load(); } diff --git a/lib/pages/player/player_binding.dart b/lib/pages/player/player_binding.dart index 088245a..21ae3bc 100644 --- a/lib/pages/player/player_binding.dart +++ b/lib/pages/player/player_binding.dart @@ -4,6 +4,7 @@ import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/repositories/playback_repository.dart'; import '../../core/data/services/android_picture_in_picture.dart'; import '../../core/data/services/download_controller.dart'; +import '../../core/data/services/playback_position_cache.dart'; import '../../core/data/services/wallet_controller.dart'; import 'player_logic.dart'; @@ -11,11 +12,13 @@ class PlayerBinding { PlayerLogic create({ required WatchHistoryRepository history, required PlaybackRepository playback, + required PlaybackPositionCache positionCache, WalletController? wallet, DownloadController? downloads, }) => PlayerLogic( history: history, playback: playback, + positionCache: positionCache, pictureInPicture: AndroidPictureInPicture(), wallet: wallet, downloads: downloads, diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 0e3618d..7dd4b30 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -17,6 +17,7 @@ import '../../core/data/services/android_picture_in_picture.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/download_controller.dart'; import '../../core/data/services/platform_share_service.dart'; +import '../../core/data/services/playback_position_cache.dart'; import '../../core/data/services/playback_queue.dart'; import '../../core/data/services/playback_session.dart'; import '../../core/data/services/video_player_port.dart'; @@ -40,8 +41,10 @@ class PlayerLogic extends ChangeNotifier { required this.pictureInPicture, this.wallet, this.downloads, + PlaybackPositionCache? positionCache, Future Function()? waitForFrame, - }) : _waitForFrame = + }) : positionCache = positionCache ?? PlaybackPositionCache(), + _waitForFrame = waitForFrame ?? (() => WidgetsBinding.instance.endOfFrame); final WatchHistoryRepository history; @@ -49,6 +52,7 @@ class PlayerLogic extends ChangeNotifier { final AndroidPictureInPicture pictureInPicture; final WalletController? wallet; final DownloadController? downloads; + final PlaybackPositionCache positionCache; final Future Function() _waitForFrame; late PageController pages; @@ -351,9 +355,22 @@ class PlayerLogic extends ChangeNotifier { /// 服务端续看位置:进播放页时取一次,用于"定位到上次那一集的那一秒"。 Future loadResumePoint() async { - final dramaId = queue.entryAt(initialIndex).drama.id; + final entry = queue.entryAt(initialIndex); + final cached = positionCache.read( + entry.episode.id, + dramaId: entry.drama.id, + ); + if (cached != null) { + resumePoint = ResumePoint( + episodeId: cached.episodeId, + position: cached.position, + ); + resumeLoaded = true; + if (isMounted()) notify(); + return; + } try { - resumePoint = await history.resumePointFor(dramaId); + resumePoint = await history.resumePointFor(entry.drama.id); } catch (_) { // 拿不到续看位置不影响播放:从当前集开头看 resumePoint = null; @@ -639,6 +656,18 @@ class PlayerLogic extends ChangeNotifier { await pendingWrite; // 服务端每剧只记一条:只有"记录里的那一集"才定位,其它集从头播 if (resumePoint == null) await loadResumePoint(); + if (resumePositionFor(episode.id) == null) { + final dramaId = _dramaIdForEpisode(episode.id); + final cached = dramaId == null + ? null + : positionCache.read(episode.id, dramaId: dramaId); + if (cached != null) { + resumePoint = ResumePoint( + episodeId: cached.episodeId, + position: cached.position, + ); + } + } final saved = resumePositionFor(episode.id); if (saved != null) { await player.controller.seekTo( @@ -667,6 +696,12 @@ class PlayerLogic extends ChangeNotifier { if (session.active != player || !player.controller.value.isInitialized) { return; } + positionCache.save( + episodeId: episode.id, + dramaId: drama.id, + position: player.controller.value.position, + duration: player.controller.value.duration, + ); if (player.controller.value.isCompleted && !completionProgressSaved) { completionProgressSaved = true; unawaited( @@ -750,6 +785,12 @@ class PlayerLogic extends ChangeNotifier { !player.controller.value.isInitialized) { return pendingWrite; } + positionCache.save( + episodeId: episode.id, + dramaId: drama.id, + position: player.controller.value.position, + duration: player.controller.value.duration, + ); return queueProgress( drama, episode, @@ -851,6 +892,7 @@ class PlayerLogic extends ChangeNotifier { Future openDetail(BuildContext context) async { session.setOverlayOpen(true); + await saveCurrentProgress(); try { if (!context.mounted) return; await Navigator.of(context).push( @@ -865,6 +907,13 @@ class PlayerLogic extends ChangeNotifier { await saveCurrentProgress(); } + String? _dramaIdForEpisode(String episodeId) { + for (final entry in queue.entries) { + if (entry.episode.id == episodeId) return entry.drama.id; + } + return null; + } + Future openSeriesAt( BuildContext context, Drama drama, diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 4a7807e..27f036f 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -11,6 +11,7 @@ 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'; +import '../../core/data/services/playback_position_cache.dart'; import '../../core/data/services/playback_session.dart'; import '../../core/data/services/video_player_port.dart'; import '../../core/data/services/wallet_controller.dart'; @@ -119,6 +120,7 @@ class _PlayerPageState extends State logic = PlayerBinding().create( history: context.read(), playback: context.read(), + positionCache: context.read(), wallet: widget.wallet, downloads: widget.downloads, ); diff --git a/test/drama_detail/drama_detail_first_frame_test.dart b/test/drama_detail/drama_detail_first_frame_test.dart index 91b769e..68a5e78 100644 --- a/test/drama_detail/drama_detail_first_frame_test.dart +++ b/test/drama_detail/drama_detail_first_frame_test.dart @@ -14,6 +14,7 @@ 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/catalog_controller.dart'; import 'package:fruitshorts/core/data/services/favorite_controller.dart'; +import 'package:fruitshorts/core/data/services/playback_position_cache.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/drama_detail/drama_detail_view.dart'; @@ -102,6 +103,7 @@ Future _pumpDetail( value: InMemoryWatchHistoryRepository(), ), Provider.value(value: FakePlaybackRepository()), + Provider(create: (_) => PlaybackPositionCache()), ChangeNotifierProvider.value(value: favorites), ChangeNotifierProvider( create: (_) => CatalogController(catalog)..load(), diff --git a/test/drama_detail/drama_detail_logic_test.dart b/test/drama_detail/drama_detail_logic_test.dart index 97aa4f1..8249304 100644 --- a/test/drama_detail/drama_detail_logic_test.dart +++ b/test/drama_detail/drama_detail_logic_test.dart @@ -1,9 +1,11 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/watch_progress.dart'; import 'package:fruitshorts/core/data/remote/api_client.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/services/favorite_controller.dart'; +import 'package:fruitshorts/core/data/services/playback_position_cache.dart'; import 'package:fruitshorts/pages/drama_detail/drama_detail_logic.dart'; import '../support/fake_watch_history.dart'; @@ -13,12 +15,14 @@ DramaDetailLogic _logic( Drama drama, FakeWatchHistory history, { FavoriteController? favorites, + PlaybackPositionCache? positionCache, }) => DramaDetailLogic( drama: drama, history: history, playback: FakePlaybackRepository(), favorites: favorites ?? FavoriteController(InMemoryFavoriteRepository()), + positionCache: positionCache ?? PlaybackPositionCache(), ); Drama _card(String id) => Drama( @@ -103,6 +107,31 @@ void main() { logic.dispose(); }); + test('should_prefer_recent_local_position_when_loading_continue', () async { + final cache = PlaybackPositionCache(); + cache.save( + episodeId: 'signal-3', + dramaId: 'signal', + position: const Duration(minutes: 1, seconds: 20), + duration: const Duration(minutes: 3), + ); + final history = FakeWatchHistory() + ..records['signal'] = const WatchProgress( + dramaId: 'signal', + episodeId: 'signal-1', + position: Duration(seconds: 5), + duration: Duration(minutes: 3), + ); + final logic = _logic(_detail('signal'), history, positionCache: cache); + + await logic.load(() async => _detail('signal')); + + expect(logic.continueIndex, 2); + expect(logic.progress?.position, const Duration(seconds: 80)); + expect(history.reads, isEmpty); + logic.dispose(); + }); + test('should_adjust_favorite_count_only_after_successful_changes', () async { final repository = InMemoryFavoriteRepository(cards: [_favoriteDrama]); final favorites = FavoriteController(repository); diff --git a/test/drama_detail/drama_detail_view_test.dart b/test/drama_detail/drama_detail_view_test.dart index 0123304..0df97ea 100644 --- a/test/drama_detail/drama_detail_view_test.dart +++ b/test/drama_detail/drama_detail_view_test.dart @@ -27,6 +27,7 @@ import 'package:fruitshorts/core/data/services/auth_session_store.dart'; import 'package:fruitshorts/core/data/services/catalog_controller.dart'; import 'package:fruitshorts/core/data/services/download_controller.dart'; import 'package:fruitshorts/core/data/services/favorite_controller.dart'; +import 'package:fruitshorts/core/data/services/playback_position_cache.dart'; import 'package:fruitshorts/core/data/services/interaction_controller.dart'; import 'package:fruitshorts/core/data/services/playback_session.dart'; import 'package:fruitshorts/core/data/services/wallet_controller.dart'; @@ -259,6 +260,25 @@ void main() { expect(player.initialIndex, 79); }); + testWidgets('should_open_recent_cached_episode_when_detail_cta_is_tapped', ( + tester, + ) async { + final cache = PlaybackPositionCache(); + cache.save( + episodeId: 'detail-k38-3', + dramaId: 'detail-k38', + position: const Duration(minutes: 1, seconds: 20), + duration: const Duration(minutes: 3), + ); + await _pumpDetail(tester, positionCache: cache); + + await tester.tap(find.byKey(const Key('detail-cta'))); + await tester.pumpAndSettle(); + + final player = tester.widget(find.byType(PlayerPage)); + expect(player.initialIndex, 2); + }); + testWidgets('should_fit_compact_viewport_and_keep_cta_visible', ( tester, ) async { @@ -283,6 +303,7 @@ Future _pumpDetail( WidgetTester tester, { Drama? drama, Size size = const Size(390, 844), + PlaybackPositionCache? positionCache, }) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; @@ -303,6 +324,9 @@ Future _pumpDetail( value: InMemoryWatchHistoryRepository(), ), Provider.value(value: FakePlaybackRepository()), + Provider.value( + value: positionCache ?? PlaybackPositionCache(), + ), Provider.value(value: createWidgetPlayer), Provider(create: (_) => PlaybackCoordinator()), Provider.value(value: interactions), diff --git a/test/playback/playback_position_cache_test.dart b/test/playback/playback_position_cache_test.dart new file mode 100644 index 0000000..abc1281 --- /dev/null +++ b/test/playback/playback_position_cache_test.dart @@ -0,0 +1,69 @@ +// Author: fruit.com.cn + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/services/playback_position_cache.dart'; + +void main() { + test('should_store_and_read_recent_position_when_drama_matches', () { + final now = DateTime(2026, 10, 22, 12); + final cache = PlaybackPositionCache(now: () => now); + + cache.save( + episodeId: 'episode-3', + dramaId: 'drama-a', + position: const Duration(minutes: 1, seconds: 20), + duration: const Duration(minutes: 3), + ); + + final entry = cache.read('episode-3', dramaId: 'drama-a'); + expect(entry, isNotNull); + expect(entry!.position, const Duration(minutes: 1, seconds: 20)); + expect(entry.duration, const Duration(minutes: 3)); + expect(entry.updatedAt, now); + }); + + test('should_reject_cached_position_when_drama_does_not_match', () { + final cache = PlaybackPositionCache(); + cache.save( + episodeId: 'episode-3', + dramaId: 'drama-a', + position: const Duration(seconds: 80), + duration: const Duration(minutes: 3), + ); + + expect(cache.read('episode-3', dramaId: 'drama-b'), isNull); + }); + + test('should_use_latest_saved_episode_when_timestamps_match', () { + final now = DateTime(2026, 10, 22, 12); + final cache = PlaybackPositionCache(now: () => now); + cache.save( + episodeId: 'episode-1', + dramaId: 'drama-a', + position: const Duration(seconds: 30), + duration: const Duration(minutes: 3), + ); + cache.save( + episodeId: 'episode-2', + dramaId: 'drama-a', + position: const Duration(seconds: 45), + duration: const Duration(minutes: 3), + ); + + expect(cache.latestForDrama('drama-a')?.episodeId, 'episode-2'); + }); + + test('should_reject_expired_cached_position', () { + var now = DateTime(2026, 10, 22, 12); + final cache = PlaybackPositionCache(now: () => now); + cache.save( + episodeId: 'episode-3', + dramaId: 'drama-a', + position: const Duration(seconds: 80), + duration: const Duration(minutes: 3), + ); + now = now.add(const Duration(days: 1)); + + expect(cache.read('episode-3', dramaId: 'drama-a'), isNull); + }); +} diff --git a/test/player/player_logic_test.dart b/test/player/player_logic_test.dart index f52251c..4dc1927 100644 --- a/test/player/player_logic_test.dart +++ b/test/player/player_logic_test.dart @@ -5,6 +5,7 @@ import 'package:video_player/video_player.dart'; import 'package:fruitshorts/core/data/models/drama.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/watch_progress.dart'; import 'package:fruitshorts/core/data/models/download_license.dart'; import 'package:fruitshorts/core/data/models/download_policy.dart'; import 'package:fruitshorts/core/data/repositories/download_repository.dart'; @@ -12,6 +13,7 @@ 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_storage.dart'; import 'package:fruitshorts/core/data/services/playback_session.dart'; +import 'package:fruitshorts/core/data/services/playback_position_cache.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_view.dart'; @@ -48,11 +50,13 @@ AndroidPictureInPicture _pip() => PlayerLogic _logic( FakeWatchHistory history, { PlaybackRepository? playback, + PlaybackPositionCache? positionCache, Future Function()? waitForFrame, }) => PlayerLogic( history: history, playback: playback ?? FakePlaybackRepository(), pictureInPicture: _pip(), + positionCache: positionCache, waitForFrame: waitForFrame ?? () async {}, ); @@ -126,6 +130,96 @@ void main() { }); }); + group('playback position cache', () { + test('should_use_cached_resume_point_without_reading_history', () async { + final history = FakeWatchHistory()..saved = const Duration(seconds: 12); + final cache = PlaybackPositionCache(); + cache.save( + episodeId: 'signal-1', + dramaId: 'signal', + position: const Duration(minutes: 1, seconds: 20), + duration: const Duration(minutes: 3), + ); + final logic = _logic(history, positionCache: cache); + logic.attach( + PlayerPage(drama: _drama('signal'), initialIndex: 0, active: false), + isMounted: () => true, + ); + logic.start( + factory: _RecordingFactory().create, + coordinator: PlaybackCoordinator(), + ); + await _drain(logic); + await pumpEventQueue(); + + expect(logic.resumePoint?.position, const Duration(seconds: 80)); + expect(history.reads, isEmpty); + logic.dispose(); + await _drain(logic); + }); + + test('should_fall_back_to_server_when_cache_belongs_to_other_drama', + () async { + final history = FakeWatchHistory() + ..records['signal'] = const WatchProgress( + dramaId: 'signal', + episodeId: 'signal-2', + position: Duration(seconds: 42), + duration: Duration(minutes: 3), + ); + final cache = PlaybackPositionCache(); + cache.save( + episodeId: 'signal-1', + dramaId: 'other-drama', + position: const Duration(seconds: 80), + duration: const Duration(minutes: 3), + ); + final logic = _logic(history, positionCache: cache); + logic.attach( + PlayerPage(drama: _drama('signal'), initialIndex: 0, active: false), + isMounted: () => true, + ); + logic.start( + factory: _RecordingFactory().create, + coordinator: PlaybackCoordinator(), + ); + await _drain(logic); + await pumpEventQueue(); + + expect(logic.resumePoint?.episodeId, 'signal-2'); + expect(logic.resumePoint?.position, const Duration(seconds: 42)); + expect(history.reads, ['signal']); + logic.dispose(); + await _drain(logic); + }); + + test('should_fall_back_to_server_when_cache_is_missing', () async { + final history = FakeWatchHistory() + ..records['signal'] = const WatchProgress( + dramaId: 'signal', + episodeId: 'signal-1', + position: Duration(seconds: 36), + duration: Duration(minutes: 3), + ); + final logic = _logic(history); + logic.attach( + PlayerPage(drama: _drama('signal'), initialIndex: 0, active: false), + isMounted: () => true, + ); + logic.start( + factory: _RecordingFactory().create, + coordinator: PlaybackCoordinator(), + ); + await _drain(logic); + await pumpEventQueue(); + + expect(logic.resumePoint?.position, const Duration(seconds: 36)); + expect(history.reads, ['signal']); + logic.dispose(); + await _drain(logic); + }); + }); + test('should_play_local_file_without_ticket_when_episode_is_downloaded', () async { final playback = FakePlaybackRepository(); diff --git a/test/player/player_open_detail_test.dart b/test/player/player_open_detail_test.dart index ae36b5b..3349ace 100644 --- a/test/player/player_open_detail_test.dart +++ b/test/player/player_open_detail_test.dart @@ -13,6 +13,7 @@ import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart'; import 'package:fruitshorts/core/data/services/catalog_controller.dart'; import 'package:fruitshorts/core/data/services/favorite_controller.dart'; +import 'package:fruitshorts/core/data/services/playback_position_cache.dart'; import 'package:fruitshorts/core/data/services/playback_session.dart'; import 'package:fruitshorts/core/data/services/video_player_port.dart'; import 'package:fruitshorts/core/platform/haptics.dart'; @@ -35,12 +36,14 @@ void main() { final history = FakeWatchHistory(); final playback = FakePlaybackRepository(); final catalog = CatalogController(FakeCatalogRepository(dramas: [drama])); + final positionCache = PlaybackPositionCache(); final favorites = FavoriteController( InMemoryFavoriteRepository(saved: {drama.id}, cards: [drama]), ); final logic = PlayerLogic( history: history, playback: playback, + positionCache: positionCache, pictureInPicture: AndroidPictureInPicture( channel: const MethodChannel('test/player_open_detail/pip'), ), @@ -74,6 +77,7 @@ void main() { providers: [ Provider.value(value: history), Provider.value(value: playback), + Provider.value(value: positionCache), ChangeNotifierProvider.value(value: catalog), ChangeNotifierProvider.value(value: favorites), Provider.value(value: FakeHaptics()), @@ -104,7 +108,11 @@ void main() { expect(player.pauses, 1); expect(player.plays, playsBefore); expect(tracked.controller.value.position, const Duration(seconds: 37)); - expect(history.writes, isEmpty); + expect(history.writes, ['signal-1|37000|false']); + expect( + positionCache.read('signal-1', dramaId: 'signal')?.position, + const Duration(seconds: 37), + ); await tester.pageBack(); await tester.pump(); @@ -117,7 +125,10 @@ void main() { expect(logic.session.userWantsPlay, isTrue); expect(player.plays, playsBefore + 1); expect(tracked.controller.value.position, const Duration(seconds: 37)); - expect(history.writes, ['signal-1|37000|false']); + expect(history.writes, [ + 'signal-1|37000|false', + 'signal-1|37000|false', + ]); logic ..trackedPlayer = null From 5268adc999c782f2db405e35e8165c88e6f6bdd4 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 10:41:01 +0800 Subject: [PATCH 006/129] =?UTF-8?q?fix(player):=20=E8=AE=B0=E4=BD=8F?= =?UTF-8?q?=E6=8B=96=E5=8A=A8=E5=90=8E=E7=9A=84=E5=AE=9E=E9=99=85=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E4=BD=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-10-22-playback-seek-position.md | 21 +++ docs/plans/PROGRESS.md | 32 +++++ .../services/playback_position_cache.dart | 14 +- lib/core/data/services/video_player_port.dart | 3 + lib/pages/player/player_logic.dart | 135 +++++++++++++----- .../playback_position_cache_test.dart | 36 +++++ test/player/player_logic_test.dart | 81 +++++++++++ test/player/player_open_detail_test.dart | 97 +++++++++---- 8 files changed, 352 insertions(+), 67 deletions(-) create mode 100644 docs/plans/2026-10-22-playback-seek-position.md diff --git a/docs/plans/2026-10-22-playback-seek-position.md b/docs/plans/2026-10-22-playback-seek-position.md new file mode 100644 index 0000000..6add3d3 --- /dev/null +++ b/docs/plans/2026-10-22-playback-seek-position.md @@ -0,0 +1,21 @@ +# Seek 后播放位置保持 + +## 目标 + +- 拖动进度条后,缓存与观看记录立即使用 seek 完成后的真实位置。 +- 拖动后立即进详情/离开也不会回退到旧进度。 +- 越界位置 clamp,锁定/票据失败不写无效缓存。 + +## 实施步骤 + +1. 增加播放位置 clamp 纯函数,缓存写入统一限制在 `0..duration`。 +2. VideoPlayerPort 暴露 seek 方法,PlayerLogic 等待 seek 完成后再读取 controller。 +3. PlayerLogic 统一当前播放快照,监听、缓存和上报共用同一份 position/duration。 +4. 增加 pending seek 屏障,saveCurrentProgress 与离开路径等待 seek 和上报完成。 +5. 补 seek 缓存、越界 clamp、seek 后进详情并返回测试。 +6. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。 + +## 风险 / 待确认 + +- 片尾与完成逻辑继续沿用现有 controller/播放器行为。 +- 缓存仅在进程内,不改变服务端观看记录契约。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 680c60f..4ab0457 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -359,3 +359,35 @@ $ flutter test --no-pub 解读:缓存读写/过期、跨剧隔离、缓存命中去网络、服务端回落、详情 CTA 缓存集 和详情往返同秒位置均通过;全量静态分析零告警,589 项测试全过。 `pubspec.lock` 已还原,未启停服务,未操作真机。 + +## 2026-10-22 Seek 后播放位置保持 + +- 已从 `develop`(`47ca214`)创建 `feature/playback-seek-position`。 +- 新增播放位置 clamp;缓存、上报与详情返回统一使用 seek 完成后的 controller 位置。 +- PlayerLogic 增加 pending seek 屏障,saveCurrentProgress 与离开路径会等待 seek 和写入完成。 +- 监听器统一读取一次当前播放快照,避免旧位置覆盖新位置。 +- 已补 seek 目标缓存、越界 clamp、seek 后立即进详情并返回的位置测试。 + +执行命令与原始输出: + +```text +$ flutter test --no-pub test/playback/playback_position_cache_test.dart +00:00 +5: All tests passed! + +$ flutter test --no-pub test/player/player_logic_test.dart --plain-name should_cache_actual_position_after_seek_completes +00:00 +1: All tests passed! + +$ flutter test --no-pub test/player/player_open_detail_test.dart +00:00 +1: All tests passed! + +$ dart analyze lib test +Analyzing lib, test... +No issues found! + +$ flutter test --no-pub +00:49 +591: All tests passed! +``` + +解读:seek 完成后缓存与上报使用实际位置,越界目标被 clamp;seek 后立即 +进详情并返回仍保持目标位置。全量静态分析零告警,591 项测试全过。 +`pubspec.lock` 已还原,未启停服务,未操作真机。 diff --git a/lib/core/data/services/playback_position_cache.dart b/lib/core/data/services/playback_position_cache.dart index 6a56f1a..3825f81 100644 --- a/lib/core/data/services/playback_position_cache.dart +++ b/lib/core/data/services/playback_position_cache.dart @@ -2,6 +2,15 @@ const Duration _defaultMaxAge = Duration(hours: 12); +/// 将播放位置限制在当前媒体时长内。 +Duration clampPlaybackPosition(Duration position, Duration duration) { + if (position <= Duration.zero || duration <= Duration.zero) { + return Duration.zero; + } + if (position >= duration) return duration; + return position; +} + /// 进程内最近播放位置。 class CachedPlaybackPosition { const CachedPlaybackPosition({ @@ -37,13 +46,14 @@ class PlaybackPositionCache { required Duration position, required Duration duration, }) { - if (episodeId.isEmpty || dramaId.isEmpty || position <= Duration.zero) { + final safePosition = clampPlaybackPosition(position, duration); + if (episodeId.isEmpty || dramaId.isEmpty || safePosition <= Duration.zero) { return; } _positions[episodeId] = CachedPlaybackPosition( episodeId: episodeId, dramaId: dramaId, - position: position, + position: safePosition, duration: duration, updatedAt: _now(), ); diff --git a/lib/core/data/services/video_player_port.dart b/lib/core/data/services/video_player_port.dart index d5d4f40..2cb2b1f 100644 --- a/lib/core/data/services/video_player_port.dart +++ b/lib/core/data/services/video_player_port.dart @@ -61,6 +61,9 @@ class VideoPlayerPort implements PlayerPort { if (!_disposed && controller.value.isInitialized) await controller.pause(); } + /// 跳转到指定播放位置。 + Future seekTo(Duration position) => controller.seekTo(position); + @override Future dispose() async { if (_disposed) return; diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 7dd4b30..71ffa67 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -34,6 +34,12 @@ import 'player_view.dart'; import 'widgets/drama_comments_sheet.dart'; import 'widgets/episode_picker_sheet.dart'; +typedef _PlaybackSnapshot = ({ + Duration position, + Duration duration, + bool completed, +}); + class PlayerLogic extends ChangeNotifier { PlayerLogic({ required this.history, @@ -119,6 +125,7 @@ class PlayerLogic extends ChangeNotifier { bool _deferredFeedNotifyScheduled = false; int _feedJumpSerial = 0; Future _feedUpdateFuture = Future.value(); + Future? _pendingSeek; /// [start] 是否已经跑过:没跑过时 session/pages 还是 late 未初始化, /// dispose 不能直接碰它们(例如页面构建失败或纯逻辑测试)。 @@ -693,29 +700,24 @@ class PlayerLogic extends ChangeNotifier { var completionHandled = false; var completionProgressSaved = false; player.controller.addListener(() { - if (session.active != player || !player.controller.value.isInitialized) { - return; - } - positionCache.save( - episodeId: episode.id, - dramaId: drama.id, - position: player.controller.value.position, - duration: player.controller.value.duration, - ); - if (player.controller.value.isCompleted && !completionProgressSaved) { + if (session.active != player) return; + final snapshot = _readPlaybackSnapshot(player); + if (snapshot == null) return; + _cachePlaybackPosition(drama, episode, snapshot); + if (snapshot.completed && !completionProgressSaved) { completionProgressSaved = true; unawaited( queueProgress( drama, episode, - player.controller.value.duration, - duration: player.controller.value.duration, + snapshot.duration, + duration: snapshot.duration, finished: true, ), ); } if (!completionHandled && - player.controller.value.isCompleted && + snapshot.completed && session.canAutoAdvance && session.currentIndex == index) { completionHandled = true; @@ -723,7 +725,12 @@ class PlayerLogic extends ChangeNotifier { } if (!shouldReportProgress(DateTime.now())) return; unawaited( - queueProgress(drama, episode, player.controller.value.position), + queueProgress( + drama, + episode, + snapshot.position, + duration: snapshot.duration, + ), ); }); } @@ -775,27 +782,63 @@ class PlayerLogic extends ChangeNotifier { return pendingWrite; } - Future saveCurrentProgress() { + Future saveCurrentProgress() async { + final pendingSeek = _pendingSeek; + if (pendingSeek != null) await pendingSeek; + await _saveCurrentProgressNow(); + } + + Future _saveCurrentProgressNow() { final player = trackedPlayer; final episode = trackedEpisode; final drama = trackedDrama; - if (player == null || - episode == null || - drama == null || - !player.controller.value.isInitialized) { - return pendingWrite; + if (player == null || episode == null || drama == null) return pendingWrite; + final snapshot = _readPlaybackSnapshot(player); + if (snapshot == null) return pendingWrite; + return _persistPlaybackSnapshot(drama, episode, snapshot); + } + + _PlaybackSnapshot? _readPlaybackSnapshot(VideoPlayerPort player) { + final value = player.controller.value; + if (!value.isInitialized || value.duration <= Duration.zero) return null; + return ( + position: clampPlaybackPosition(value.position, value.duration), + duration: value.duration, + completed: value.isCompleted, + ); + } + + void _cachePlaybackPosition( + Drama drama, + Episode episode, + _PlaybackSnapshot snapshot, + ) { + if (session.isLocked || + ticketError != null || + blockedEpisodeId == episode.id) { + return; } positionCache.save( episodeId: episode.id, dramaId: drama.id, - position: player.controller.value.position, - duration: player.controller.value.duration, + position: snapshot.position, + duration: snapshot.duration, ); + } + + Future _persistPlaybackSnapshot( + Drama drama, + Episode episode, + _PlaybackSnapshot snapshot, { + bool finished = false, + }) { + _cachePlaybackPosition(drama, episode, snapshot); return queueProgress( drama, episode, - player.controller.value.position, - duration: player.controller.value.duration, + snapshot.position, + duration: snapshot.duration, + finished: finished, ); } @@ -856,23 +899,44 @@ class PlayerLogic extends ChangeNotifier { BuildContext context, VideoPlayerPort player, Duration position, + ) { + final operation = _performSeek(context, player, position); + _pendingSeek = operation; + return operation.whenComplete(() { + if (identical(_pendingSeek, operation)) _pendingSeek = null; + }); + } + + Future _performSeek( + BuildContext context, + VideoPlayerPort player, + Duration position, ) async { if (scrubbingPlayer != player || session.active != player) return; try { await session.playbackSettled; if (scrubbingPlayer != player || session.active != player) return; - await player.controller.seekTo(position); - if (session.active == player) { - // 暂停时拖动进度后直接恢复播放,避免画面停在旧位置。 - resumeAfterSeek(); - await saveCurrentProgress(); - } + final duration = player.controller.value.duration; + final target = clampPlaybackPosition(position, duration); + if (target <= Duration.zero) return; + await player.seekTo(target); + if (session.active != player) return; + final snapshot = _readPlaybackSnapshot(player); + if (snapshot == null) return; + final entry = queue.entryAt(session.currentIndex); + final save = _persistPlaybackSnapshot( + entry.drama, + entry.episode, + snapshot, + ); + // 暂停时拖动进度后直接恢复播放,避免画面停在旧位置。 + resumeAfterSeek(); + await save; } catch (_) { if (context.mounted && session.active == player) { final l10n = AppLocalizations.of(context)!; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l10n.seekFailed))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(l10n.seekFailed))); } } finally { if (scrubbingPlayer == player) cancelScrub(); @@ -1013,9 +1077,8 @@ class PlayerLogic extends ChangeNotifier { await session.playbackSettled; await saveCurrentProgress(); if (!context.mounted) return; - final action = await Navigator.of( - context, - ).push(MaterialPageRoute(builder: (_) => const MyPage())); + final action = await Navigator.of(context) + .push(MaterialPageRoute(builder: (_) => const MyPage())); if (!context.mounted) return; if (action == MyPageAction.discover) { await showDiscover(context); diff --git a/test/playback/playback_position_cache_test.dart b/test/playback/playback_position_cache_test.dart index abc1281..447a013 100644 --- a/test/playback/playback_position_cache_test.dart +++ b/test/playback/playback_position_cache_test.dart @@ -4,6 +4,42 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/services/playback_position_cache.dart'; void main() { + test('should_clamp_invalid_or_out_of_range_position_when_caching', () { + expect( + clampPlaybackPosition( + const Duration(seconds: -1), + const Duration(seconds: 10), + ), + Duration.zero, + ); + expect( + clampPlaybackPosition( + const Duration(seconds: 12), + const Duration(seconds: 10), + ), + const Duration(seconds: 10), + ); + expect( + clampPlaybackPosition( + const Duration(seconds: 5), + const Duration(seconds: 10), + ), + const Duration(seconds: 5), + ); + + final cache = PlaybackPositionCache(); + cache.save( + episodeId: 'episode-3', + dramaId: 'drama-a', + position: const Duration(minutes: 4), + duration: const Duration(minutes: 3), + ); + expect( + cache.read('episode-3', dramaId: 'drama-a')?.position, + const Duration(minutes: 3), + ); + }); + test('should_store_and_read_recent_position_when_drama_matches', () { final now = DateTime(2026, 10, 22, 12); final cache = PlaybackPositionCache(now: () => now); diff --git a/test/player/player_logic_test.dart b/test/player/player_logic_test.dart index 4dc1927..d16ee8d 100644 --- a/test/player/player_logic_test.dart +++ b/test/player/player_logic_test.dart @@ -218,6 +218,75 @@ void main() { logic.dispose(); await _drain(logic); }); + + testWidgets( + 'should_cache_actual_position_after_seek_completes', + (tester) async { + late BuildContext context; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (pageContext) { + context = pageContext; + return const Scaffold(body: SizedBox.expand()); + }, + ), + ), + ); + final cache = PlaybackPositionCache(); + final history = FakeWatchHistory(); + final factory = _VideoRecordingFactory(); + late PlayerLogic logic; + await tester.runAsync(() async { + logic = _logic(history, positionCache: cache); + logic.attach( + PlayerPage(drama: _drama('signal'), initialIndex: 0), + isMounted: () => true, + ); + logic.start( + factory: factory.create, + coordinator: PlaybackCoordinator(), + ); + await _drain(logic); + }); + final player = logic.session.active! as _VideoRecordingPlayer; + logic.beginScrub(player); + + await tester.runAsync( + () => logic.seekTo( + context, + player, + const Duration(seconds: 42), + ), + ); + await tester.runAsync(() => logic.pendingWrite); + + expect( + cache.read('signal-1', dramaId: 'signal')?.position, + const Duration(seconds: 42), + ); + expect(history.writes.last, 'signal-1|42000|false'); + + logic.beginScrub(player); + await tester.runAsync( + () => logic.seekTo( + context, + player, + const Duration(minutes: 2), + ), + ); + await tester.runAsync(() => logic.pendingWrite); + + expect(player.controller.value.position, const Duration(minutes: 1)); + expect( + cache.read('signal-1', dramaId: 'signal')?.position, + const Duration(minutes: 1), + ); + expect(history.writes.last, 'signal-1|60000|false'); + logic.dispose(); + await tester.pump(); + }, + ); }); test('should_play_local_file_without_ticket_when_episode_is_downloaded', @@ -1257,6 +1326,18 @@ class _VideoRecordingPlayer extends VideoPlayerPort { @override Future pause() async {} + @override + Future seekTo(Duration position) async { + final current = controller.value; + controller.value = VideoPlayerValue( + duration: current.duration, + size: current.size, + position: position, + isInitialized: current.isInitialized, + isPlaying: current.isPlaying, + ); + } + @override Future dispose() async { if (disposed) return; diff --git a/test/player/player_open_detail_test.dart b/test/player/player_open_detail_test.dart index 3349ace..3bbaa1e 100644 --- a/test/player/player_open_detail_test.dart +++ b/test/player/player_open_detail_test.dart @@ -48,7 +48,7 @@ void main() { channel: const MethodChannel('test/player_open_detail/pip'), ), ); - final factory = _RecordingFactory(); + final factory = _VideoRecordingFactory(); logic.attach( PlayerPage(drama: drama, initialIndex: 0), isMounted: () => true, @@ -59,15 +59,8 @@ void main() { final active = logic.session.active; final index = logic.session.currentIndex; final player = factory.created.first; - final playsBefore = player.plays; - final tracked = VideoPlayerPort(drama.episodes.first); - tracked.controller.value = const VideoPlayerValue( - duration: Duration(minutes: 5), - position: Duration(seconds: 37), - isInitialized: true, - ); logic - ..trackedPlayer = tracked + ..trackedPlayer = player ..trackedEpisode = drama.episodes.first ..trackedDrama = drama; @@ -96,22 +89,31 @@ void main() { ), ); + logic.beginScrub(player); + await logic.seekTo( + pageContext, + player, + const Duration(minutes: 1, seconds: 22), + ); + unawaited(logic.openDetail(pageContext)); await tester.pump(); await tester.pump(const Duration(milliseconds: 400)); await _pumpSession(tester); + await tester.runAsync(() => logic.session.playbackSettled); expect(find.byType(DramaDetailPage), findsOneWidget); expect(logic.session.active, same(active)); expect(logic.session.currentIndex, index); expect(logic.session.userWantsPlay, isTrue); - expect(player.pauses, 1); - expect(player.plays, playsBefore); - expect(tracked.controller.value.position, const Duration(seconds: 37)); - expect(history.writes, ['signal-1|37000|false']); + expect( + player.controller.value.position, + const Duration(minutes: 1, seconds: 22), + ); + expect(history.writes, ['signal-1|82000|false', 'signal-1|82000|false']); expect( positionCache.read('signal-1', dramaId: 'signal')?.position, - const Duration(seconds: 37), + const Duration(minutes: 1, seconds: 22), ); await tester.pageBack(); @@ -123,11 +125,14 @@ void main() { expect(logic.session.active, same(active)); expect(logic.session.currentIndex, index); expect(logic.session.userWantsPlay, isTrue); - expect(player.plays, playsBefore + 1); - expect(tracked.controller.value.position, const Duration(seconds: 37)); + expect( + player.controller.value.position, + const Duration(minutes: 1, seconds: 22), + ); expect(history.writes, [ - 'signal-1|37000|false', - 'signal-1|37000|false', + 'signal-1|82000|false', + 'signal-1|82000|false', + 'signal-1|82000|false', ]); logic @@ -136,7 +141,6 @@ void main() { ..trackedDrama = null ..dispose(); await _pumpSession(tester); - await tracked.dispose(); }, ); } @@ -164,29 +168,64 @@ Future _pumpSession(WidgetTester tester) async { } } -class _RecordingFactory { - final List<_RecordingPlayer> created = []; +class _VideoRecordingFactory { + final List<_VideoRecordingPlayer> created = []; PlayerPort create(Episode episode) { - final player = _RecordingPlayer(); + final player = _VideoRecordingPlayer(episode); created.add(player); return player; } } -class _RecordingPlayer implements PlayerPort { - int plays = 0; - int pauses = 0; +class _VideoRecordingPlayer extends VideoPlayerPort { + _VideoRecordingPlayer(super.episode); @override - Future initialize() async {} + Future initialize() async { + controller.value = const VideoPlayerValue( + duration: Duration(minutes: 5), + size: Size(1280, 720), + position: Duration(seconds: 1), + isInitialized: true, + ); + } @override - Future play() async => plays++; + Future play() async { + _setPlaying(true); + } @override - Future pause() async => pauses++; + Future pause() async { + _setPlaying(false); + } @override - Future dispose() async {} + Future seekTo(Duration position) async { + final current = controller.value; + controller.value = VideoPlayerValue( + duration: current.duration, + size: current.size, + position: position, + isInitialized: current.isInitialized, + isPlaying: current.isPlaying, + ); + } + + void _setPlaying(bool playing) { + final current = controller.value; + controller.value = VideoPlayerValue( + duration: current.duration, + size: current.size, + position: current.position, + isInitialized: current.isInitialized, + isPlaying: playing, + ); + } + + @override + Future dispose() async { + await controller.dispose(); + } } From 7ad4681049cdc294fdfc52dfe1ad6b9156e9523e Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 10:47:16 +0800 Subject: [PATCH 007/129] =?UTF-8?q?fix(player):=20=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E5=85=A5=E5=8F=A3=E7=BC=93=E5=86=B2=E6=8F=90?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-10-22-playback-entry-no-buffer.md | 21 ++++++ docs/plans/PROGRESS.md | 35 ++++++++++ lib/pages/player/player_logic.dart | 2 +- lib/pages/player/player_view.dart | 11 ---- .../widgets/home_feed_overlay_parts.dart | 17 ++--- .../player/widgets/seek_progress_bar.dart | 7 +- test/home/home_preview_layout_test.dart | 65 +++++++++++++++++- test/playback/seek_progress_bar_test.dart | 11 +++- test/player/player_fullscreen_test.dart | 19 +++--- test/player/player_logic_test.dart | 66 +++++++++++++++++-- test/player/player_open_detail_test.dart | 3 + test/widget_test.dart | 3 +- 12 files changed, 218 insertions(+), 42 deletions(-) create mode 100644 docs/plans/2026-10-22-playback-entry-no-buffer.md diff --git a/docs/plans/2026-10-22-playback-entry-no-buffer.md b/docs/plans/2026-10-22-playback-entry-no-buffer.md new file mode 100644 index 0000000..8d2c409 --- /dev/null +++ b/docs/plans/2026-10-22-playback-entry-no-buffer.md @@ -0,0 +1,21 @@ +# 播放入口无缓冲提示 + +## 目标 + +- 未初始化进度条显示静态 inactive 轨道,不出现流动加载高光。 +- 仅真实 buffering 持续超过 350ms 时显示白色高光。 +- 起播定位阶段不叠加 session 居中 loading;只有 ticket loading 居中显示。 +- 同集返回复用 controller;重建时 initialize→seek→play。 + +## 实施步骤 + +1. 调整 SeekProgressBar 与 _FeedProgressBar 的初始化/缓冲状态判断。 +2. 删除 PlayerView 的 session loading 居中圈,仅保留 ticket loading。 +3. preparePlayer 统一通过 VideoPlayerPort.seekTo 定位缓存位置。 +4. 更新进度条、全屏入口、详情返回和重建顺序测试。 +5. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。 + +## 风险 / 待确认 + +- 真实持续网络卡顿仍保留缓冲高光。 +- 锁定集继续沿用静态空轨道和禁用 seek 行为。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 4ab0457..2674012 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -391,3 +391,38 @@ $ flutter test --no-pub 解读:seek 完成后缓存与上报使用实际位置,越界目标被 clamp;seek 后立即 进详情并返回仍保持目标位置。全量静态分析零告警,591 项测试全过。 `pubspec.lock` 已还原,未启停服务,未操作真机。 + +## 2026-10-22 播放入口无缓冲提示 + +- 已从 Seek 修复合并后的 `develop`(`f302b39`)创建 `feature/playback-entry-no-buffer`。 +- 未初始化进度条改为静态 inactive 轨道,不显示流动高光且不可拖动。 +- 仅 initialized 且 isBuffering 持续超过 350ms 时显示白色缓冲高光。 +- PlayerView 删除 session 居中 loading,只保留 ticket loading。 +- preparePlayer 改为初始化完成后先 seek 缓存位置再 play;详情返回复用原 controller。 + +执行命令与原始输出: + +```text +$ flutter test --no-pub test/playback/seek_progress_bar_test.dart +00:00 +7: All tests passed! + +$ flutter test --no-pub test/home/home_preview_layout_test.dart +00:01 +8: All tests passed! + +$ flutter test --no-pub test/player/player_logic_test.dart +00:00 +40: All tests passed! + +$ flutter test --no-pub test/player/player_fullscreen_test.dart +00:09 +37: All tests passed! + +$ dart analyze lib test +Analyzing lib, test... +No issues found! + +$ flutter test --no-pub +00:49 +593: All tests passed! +``` + +解读:短缓冲不闪、持续缓冲仍显示白色高光;未初始化/入口/返回/重建路径 +均无多余 loading,重建顺序为 initialize→seek→play。全量静态分析零告警, +593 项测试全过。`pubspec.lock` 已还原,未启停服务,未操作真机。 diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 71ffa67..27046f7 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -677,7 +677,7 @@ class PlayerLogic extends ChangeNotifier { } final saved = resumePositionFor(episode.id); if (saved != null) { - await player.controller.seekTo( + await player.seekTo( clampResumePosition(saved, player.controller.value.duration), ); } diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 27f036f..20b0e49 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -442,17 +442,6 @@ class _PlayerPageState extends State ), ), ), - if (selected && logic.session.loading) - const Center( - child: SizedBox.square( - dimension: _playerLoadingSize, - child: CircularProgressIndicator( - key: Key('player-session-loading'), - color: AppColors.blue, - strokeWidth: _playerLoadingStrokeWidth, - ), - ), - ), if (selected && !ready && logic.session.error != null) Center( child: Padding( diff --git a/lib/pages/player/widgets/home_feed_overlay_parts.dart b/lib/pages/player/widgets/home_feed_overlay_parts.dart index 37b3370..ebf4578 100644 --- a/lib/pages/player/widgets/home_feed_overlay_parts.dart +++ b/lib/pages/player/widgets/home_feed_overlay_parts.dart @@ -79,18 +79,16 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { final width = constraints.maxWidth; final source = widget.controller; final scrubbing = _preview != null; - final loading = - !widget.locked && - (source == null || - (!scrubbing && (!source.value.isInitialized || _buffering))); + final loading = !widget.locked && !scrubbing && _buffering; final track = widget.locked ? _inactiveTrack() : source == null - ? _loadingTrack() + ? _inactiveTrack() : ValueListenableBuilder( valueListenable: source, builder: (context, value, _) { - if (!scrubbing && (!value.isInitialized || _buffering)) { + if (!value.isInitialized) return _inactiveTrack(); + if (!scrubbing && _buffering) { return _loadingTrack(); } final total = value.duration.inMilliseconds; @@ -102,7 +100,10 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { return _track(_preview ?? played, width); }, ); - final interactive = !widget.locked && !loading; + final interactive = + !widget.locked && + !loading && + (source?.value.isInitialized ?? false); return GestureDetector( key: const Key('home-feed-progress'), behavior: HitTestBehavior.opaque, @@ -148,7 +149,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { if (!value.isInitialized) { _bufferTimer?.cancel(); _bufferTimer = null; - _setBuffering(true, notify: notify); + _setBuffering(false, notify: notify); return; } if (!value.isBuffering) { diff --git a/lib/pages/player/widgets/seek_progress_bar.dart b/lib/pages/player/widgets/seek_progress_bar.dart index 553462a..bc58c53 100644 --- a/lib/pages/player/widgets/seek_progress_bar.dart +++ b/lib/pages/player/widgets/seek_progress_bar.dart @@ -114,7 +114,7 @@ class _SeekProgressBarState extends State { if (!value.isInitialized) { _bufferTimer?.cancel(); _bufferTimer = null; - _setBuffering(true, notify: notify); + _setBuffering(false, notify: notify); return; } if (!value.isBuffering) { @@ -181,8 +181,7 @@ class _SeekProgressBarState extends State { Widget _buildTrack(BuildContext context, VideoPlayerValue video) { final total = video.duration.inMilliseconds; final scrubbing = _previewFraction.value != null; - final loading = - !widget.locked && !scrubbing && (!video.isInitialized || _buffering); + final loading = !widget.locked && !scrubbing && _buffering; final enabled = !widget.locked && video.isInitialized && @@ -223,7 +222,7 @@ class _SeekProgressBarState extends State { ), SizedBox( height: trackAreaHeight, - child: widget.locked + child: widget.locked || !video.isInitialized ? _inactiveTrack(trackHeight) : loading ? Center( diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index bf2d5fb..275759f 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -180,7 +180,11 @@ void main() { .colorBlendMode, BlendMode.srcIn, ); - expect(find.byKey(const Key('progress-loading')), findsOneWidget); + expect(find.byKey(const Key('progress-loading')), findsNothing); + expect( + find.byKey(const Key('home-feed-progress-inactive')), + findsOneWidget, + ); expect(find.byKey(const Key('home-feed-progress-thumb')), findsNothing); }); @@ -258,6 +262,65 @@ void main() { expect(seeks, 0); }); + testWidgets('should_show_static_track_when_feed_video_is_uninitialized', ( + tester, + ) async { + final controller = ValueNotifier( + const VideoPlayerValue.uninitialized(), + ); + 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: 'loading-feed', + title: 'Loading Feed', + synopsis: 'synopsis', + posterUrl: '', + ), + episode: const Episode( + id: 'loading-feed-1', + number: 1, + title: 'Loading', + isFree: true, + ), + controller: controller, + locked: false, + liked: false, + saved: false, + visible: true, + boosting: false, + onTogglePlay: () {}, + onBoostStart: () {}, + onBoostEnd: () {}, + onLike: () {}, + onSave: () {}, + onComments: () {}, + onShare: () {}, + onDetail: () {}, + onOpenFullPlayer: () {}, + onMore: () {}, + onSeek: (_) {}, + ), + ), + ), + ), + ); + + expect(find.byKey(const Key('progress-loading')), findsNothing); + expect( + find.byKey(const Key('home-feed-progress-inactive')), + findsOneWidget, + ); + }); + testWidgets('should_seek_and_open_player_from_feed_actions', (tester) async { var opened = 0; Duration? seeked; diff --git a/test/playback/seek_progress_bar_test.dart b/test/playback/seek_progress_bar_test.dart index 5682333..04f0fae 100644 --- a/test/playback/seek_progress_bar_test.dart +++ b/test/playback/seek_progress_bar_test.dart @@ -17,21 +17,26 @@ void main() { expect(seekPositionForFraction(1, duration), lessThan(duration)); }); - testWidgets('should_show_loading_track_when_controller_is_missing', ( + testWidgets('should_show_inactive_track_when_video_is_not_initialized', ( tester, ) async { + final video = ValueNotifier( + const VideoPlayerValue.uninitialized(), + ); + addTearDown(video.dispose); await tester.pumpWidget( MaterialApp( home: Scaffold( body: SeekProgressBar( - valueListenable: null, + valueListenable: video, semanticsLabel: 'Seek video', ), ), ), ); - expect(find.byKey(const Key('progress-loading')), findsOneWidget); + expect(find.byKey(const Key('progress-loading')), findsNothing); + expect(find.byKey(const Key('progress-inactive')), findsOneWidget); expect(find.byType(Slider), findsNothing); }); diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 854ee69..9bfd278 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -99,13 +99,8 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); - final sessionLoading = find.byKey(const Key('player-session-loading')); - expect(sessionLoading, findsOneWidget); - expect(tester.getSize(sessionLoading), const Size(24, 24)); - expect( - tester.widget(sessionLoading).strokeWidth, - 2, - ); + expect(find.byKey(const Key('player-session-loading')), findsNothing); + expect(find.byKey(const Key('progress-loading')), findsNothing); initialization.complete(); await tester.pumpWidget(const SizedBox.shrink()); @@ -127,6 +122,8 @@ void main() { await _openSeriesPlayer(tester); final player = players.firstWhere((item) => item.playing); expect(player.playing, isTrue); + expect(find.byKey(const Key('progress-loading')), findsNothing); + expect(find.byKey(const Key('progress-inactive')), findsOneWidget); final navigator = tester.state( find.byType(Navigator).first, @@ -147,6 +144,8 @@ void main() { await tester.pumpAndSettle(); expect(player.playing, isTrue); + expect(find.byKey(const Key('progress-loading')), findsNothing); + expect(find.byKey(const Key('progress-inactive')), findsOneWidget); }); testWidgets('should_pause_feed_when_auth_page_is_pushed_and_resume_on_pop', ( @@ -571,7 +570,8 @@ void main() { tester.getSize(find.byKey(const Key('player-bottom-clear-screen'))), const Size(40, 40), ); - expect(find.byKey(const Key('progress-loading')), findsOneWidget); + expect(find.byKey(const Key('progress-loading')), findsNothing); + expect(find.byKey(const Key('progress-inactive')), findsOneWidget); expect(find.byType(Slider), findsNothing); expect(bottom.height, lessThanOrEqualTo(200)); @@ -1301,7 +1301,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.byKey(const Key('progress-loading')), findsOneWidget); + expect(find.byKey(const Key('progress-loading')), findsNothing); + expect(find.byKey(const Key('progress-inactive')), findsOneWidget); expect(find.byType(Slider), findsNothing); expect(find.byKey(const Key('player-title')), findsNothing); expect(find.byKey(const Key('player-subtitle')), findsNothing); diff --git a/test/player/player_logic_test.dart b/test/player/player_logic_test.dart index d16ee8d..a600ce7 100644 --- a/test/player/player_logic_test.dart +++ b/test/player/player_logic_test.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:video_player/video_player.dart'; +// ignore: depend_on_referenced_packages +import 'package:video_player_platform_interface/video_player_platform_interface.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/play_ticket.dart'; import 'package:fruitshorts/core/data/models/player_port.dart'; @@ -287,6 +289,44 @@ void main() { await tester.pump(); }, ); + + testWidgets('should_seek_cached_position_before_play_when_rebuilding', ( + tester, + ) async { + final previousPlatform = VideoPlayerPlatform.instance; + VideoPlayerPlatform.instance = _VideoPlayerPlatformStub(); + addTearDown(() => VideoPlayerPlatform.instance = previousPlatform); + final cache = PlaybackPositionCache(); + cache.save( + episodeId: 'signal-1', + dramaId: 'signal', + position: const Duration(seconds: 42), + duration: const Duration(minutes: 1), + ); + final factory = _VideoRecordingFactory(); + late PlayerLogic logic; + await tester.runAsync(() async { + logic = _logic(FakeWatchHistory(), positionCache: cache); + logic.attach( + PlayerPage(drama: _drama('signal'), initialIndex: 0), + isMounted: () => true, + ); + logic.start( + factory: factory.create, + coordinator: PlaybackCoordinator(), + ); + await _drain(logic); + await pumpEventQueue(); + await logic.session.playbackSettled; + await logic.session.preloadSettled; + }); + + final player = logic.session.active! as _VideoRecordingPlayer; + expect(player.events, containsAllInOrder(['initialize', 'seek', 'play'])); + expect(player.controller.value.position, const Duration(seconds: 42)); + logic.dispose(); + await tester.pump(); + }); }); test('should_play_local_file_without_ticket_when_episode_is_downloaded', @@ -1297,21 +1337,38 @@ class _SessionVideoSurface extends StatelessWidget { class _VideoRecordingFactory { final List<_VideoRecordingPlayer> created = []; + final List events = []; PlayerPort create(Episode episode) { - final player = _VideoRecordingPlayer(episode); + final player = _VideoRecordingPlayer(episode, events); created.add(player); return player; } } +class _VideoPlayerPlatformStub extends VideoPlayerPlatform { + @override + Future init() async {} + + @override + Future dispose(int playerId) async {} + + @override + Future setVolume(int playerId, double volume) async {} + + @override + Future setPlaybackSpeed(int playerId, double speed) async {} +} + class _VideoRecordingPlayer extends VideoPlayerPort { - _VideoRecordingPlayer(super.episode); + _VideoRecordingPlayer(super.episode, this.events); bool disposed = false; + final List events; @override Future initialize() async { + events.add('initialize'); controller.value = const VideoPlayerValue( duration: Duration(minutes: 1), size: Size(1280, 720), @@ -1321,13 +1378,14 @@ class _VideoRecordingPlayer extends VideoPlayerPort { } @override - Future play() async {} + Future play() async => events.add('play'); @override - Future pause() async {} + Future pause() async => events.add('pause'); @override Future seekTo(Duration position) async { + events.add('seek'); final current = controller.value; controller.value = VideoPlayerValue( duration: current.duration, diff --git a/test/player/player_open_detail_test.dart b/test/player/player_open_detail_test.dart index 3bbaa1e..a4e3176 100644 --- a/test/player/player_open_detail_test.dart +++ b/test/player/player_open_detail_test.dart @@ -59,6 +59,7 @@ void main() { final active = logic.session.active; final index = logic.session.currentIndex; final player = factory.created.first; + final createdBeforeDetail = factory.created.length; logic ..trackedPlayer = player ..trackedEpisode = drama.episodes.first @@ -106,6 +107,7 @@ void main() { expect(logic.session.active, same(active)); expect(logic.session.currentIndex, index); expect(logic.session.userWantsPlay, isTrue); + expect(factory.created, hasLength(createdBeforeDetail)); expect( player.controller.value.position, const Duration(minutes: 1, seconds: 22), @@ -125,6 +127,7 @@ void main() { expect(logic.session.active, same(active)); expect(logic.session.currentIndex, index); expect(logic.session.userWantsPlay, isTrue); + expect(factory.created, hasLength(createdBeforeDetail)); expect( player.controller.value.position, const Duration(minutes: 1, seconds: 22), diff --git a/test/widget_test.dart b/test/widget_test.dart index 43556a7..26fb796 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -185,7 +185,8 @@ void main() { await tester.pumpAndSettle(); await _openTheaterPlayer(tester); - expect(find.byKey(const Key('progress-loading')), findsOneWidget); + expect(find.byKey(const Key('progress-loading')), findsNothing); + expect(find.byKey(const Key('progress-inactive')), findsOneWidget); expect(find.byType(Slider), findsNothing); await tester.drag(find.byType(PageView), const Offset(0, -500)); await tester.pumpAndSettle(); From 51c56d884b39f5f13491a6dfffef05fd17abdb44 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 11:03:17 +0800 Subject: [PATCH 008/129] =?UTF-8?q?feat(player):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6=E6=9D=A1=E6=8B=96=E5=8A=A8=E7=94=BB=E9=9D=A2?= =?UTF-8?q?=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/stormg/lumadrama/MainActivity.kt | 116 ++++++ docs/plans/2026-10-22-scrub-frame-preview.md | 62 +++ docs/plans/PROGRESS.md | 53 +++ ios/Runner/AppDelegate.swift | 85 ++++ .../services/playback_thumbnail_service.dart | 365 ++++++++++++++++++ lib/pages/player/player_view.dart | 60 +++ .../player/widgets/home_feed_overlay.dart | 15 +- .../widgets/home_feed_overlay_parts.dart | 49 ++- lib/pages/player/widgets/player_overlay.dart | 4 + .../player/widgets/player_overlay_models.dart | 5 + .../player/widgets/scrub_preview_card.dart | 164 ++++++++ .../player/widgets/seek_progress_bar.dart | 183 +++++---- test/home/home_preview_layout_test.dart | 96 +++++ .../playback_thumbnail_service_test.dart | 227 +++++++++++ test/playback/seek_progress_bar_test.dart | 86 +++++ 15 files changed, 1493 insertions(+), 77 deletions(-) create mode 100644 docs/plans/2026-10-22-scrub-frame-preview.md create mode 100644 lib/core/data/services/playback_thumbnail_service.dart create mode 100644 lib/pages/player/widgets/scrub_preview_card.dart create mode 100644 test/playback/playback_thumbnail_service_test.dart diff --git a/android/app/src/main/kotlin/com/stormg/lumadrama/MainActivity.kt b/android/app/src/main/kotlin/com/stormg/lumadrama/MainActivity.kt index a558249..7db28d2 100644 --- a/android/app/src/main/kotlin/com/stormg/lumadrama/MainActivity.kt +++ b/android/app/src/main/kotlin/com/stormg/lumadrama/MainActivity.kt @@ -5,8 +5,13 @@ package com.stormg.lumadrama import android.content.Intent import android.app.PictureInPictureParams import android.content.res.Configuration +import android.graphics.Bitmap +import android.media.MediaMetadataRetriever +import android.net.Uri import android.os.Build import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.util.Rational import android.content.pm.PackageManager import android.view.WindowManager @@ -15,12 +20,19 @@ import androidx.core.view.WindowInsetsCompat import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel +import java.io.ByteArrayOutputStream +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean class MainActivity : FlutterActivity() { private val shareRequestCode = 9041 + private val thumbnailExecutor: ExecutorService = Executors.newSingleThreadExecutor() + private val thumbnailHandler = Handler(Looper.getMainLooper()) private var pendingShare: MethodChannel.Result? = null private var pipChannel: MethodChannel? = null private var keyboardChannel: MethodChannel? = null + private var thumbnailChannel: MethodChannel? = null private var imeInset = 0 override fun onCreate(savedInstanceState: Bundle?) { @@ -86,6 +98,47 @@ class MainActivity : FlutterActivity() { emitImeInset(inset) result.success(inset) } + thumbnailChannel = MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + "com.stormg.lumadrama/thumbnail" + ) + thumbnailChannel?.setMethodCallHandler { call, result -> + if (call.method != "frame") { + result.notImplemented() + return@setMethodCallHandler + } + val timeMs = (call.argument("timeMs"))?.toLong() + if (timeMs == null) { + result.success(null) + return@setMethodCallHandler + } + val localPath = call.argument("localPath").orEmpty() + val remoteUrl = call.argument("url").orEmpty() + val rawHeaders = call.argument>("headers") + val headers = rawHeaders?.entries + ?.associate { it.key.toString() to it.value.toString() } + .orEmpty() + val responded = AtomicBoolean(false) + val task = thumbnailExecutor.submit { + val bytes = renderThumbnail( + timeMs = timeMs, + localPath = localPath, + remoteUrl = remoteUrl, + headers = headers + ) + thumbnailHandler.post { + if (responded.compareAndSet(false, true)) { + result.success(bytes) + } + } + } + thumbnailHandler.postDelayed({ + if (responded.compareAndSet(false, true)) { + task.cancel(true) + result.success(null) + } + }, thumbnailTimeoutMs) + } val decorView = window.decorView ViewCompat.setOnApplyWindowInsetsListener(decorView) { view, insets -> emitImeInset(insets.getInsets(WindowInsetsCompat.Type.ime()).bottom) @@ -106,6 +159,59 @@ class MainActivity : FlutterActivity() { keyboardChannel?.invokeMethod("insetsChanged", value) } + private fun renderThumbnail( + timeMs: Long, + localPath: String, + remoteUrl: String, + headers: Map + ): ByteArray? { + val retriever = MediaMetadataRetriever() + var frame: Bitmap? = null + var scaled: Bitmap? = null + return try { + when { + localPath.isNotEmpty() -> retriever.setDataSource(normalizeLocalPath(localPath)) + remoteUrl.isNotEmpty() -> retriever.setDataSource(remoteUrl, headers) + else -> return null + } + frame = retriever.getFrameAtTime( + timeMs * 1000, + MediaMetadataRetriever.OPTION_CLOSEST_SYNC + ) + val source = frame ?: return null + scaled = if (source.width > thumbnailMaxWidth) { + val targetHeight = (source.height * thumbnailMaxWidth / source.width) + .coerceAtLeast(1) + Bitmap.createScaledBitmap( + source, + thumbnailMaxWidth, + targetHeight, + true + ) + } else { + source + } + val outputBitmap = scaled ?: source + val output = ByteArrayOutputStream() + val compressed = outputBitmap.compress( + Bitmap.CompressFormat.JPEG, + thumbnailJpegQuality, + output + ) + if (!compressed) return null + output.toByteArray() + } catch (_: Exception) { + null + } finally { + if (scaled != null && scaled !== frame) scaled.recycle() + frame?.recycle() + retriever.release() + } + } + + private fun normalizeLocalPath(path: String): String = + if (path.startsWith("file:")) Uri.parse(path).path ?: path else path + override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) { super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) pipChannel?.invokeMethod("onPipChanged", isInPictureInPictureMode) @@ -124,6 +230,16 @@ class MainActivity : FlutterActivity() { ViewCompat.setOnApplyWindowInsetsListener(window.decorView, null) keyboardChannel?.setMethodCallHandler(null) keyboardChannel = null + thumbnailChannel?.setMethodCallHandler(null) + thumbnailChannel = null + thumbnailHandler.removeCallbacksAndMessages(null) + thumbnailExecutor.shutdownNow() super.onDestroy() } + + companion object { + private const val thumbnailMaxWidth = 240 + private const val thumbnailJpegQuality = 70 + private const val thumbnailTimeoutMs = 1500L + } } diff --git a/docs/plans/2026-10-22-scrub-frame-preview.md b/docs/plans/2026-10-22-scrub-frame-preview.md new file mode 100644 index 0000000..4c4c5bf --- /dev/null +++ b/docs/plans/2026-10-22-scrub-frame-preview.md @@ -0,0 +1,62 @@ +# 播放器进度拖动画面预览 + +## 目标 + +首页 feed 与全屏播放器的进度条在拖动时显示当前时间点的视频画面缩略图、 +当前时间与总时长;松手立即隐藏并提交 seek。缩略图未返回或失败时显示短剧 +封面,不出现空白闪烁或错误文案。 + +## 性能红线 + +1. 拖动期间主线程无卡顿;图片解码和原生取帧不得阻塞 UI 线程。 +2. 原生取帧并发数不超过 1。 +3. 预览图最大宽度 240px。 +4. Dart 内存 LRU 缓存不超过 24 帧。 +5. 不新增 pub 依赖。 +6. 不改后端契约。 + +## 实现 + +### 缩略图服务 + +- 新增 `PlaybackThumbnailService`,公开入口按 `episodeId + position` 取 + `ByteData`,底层通过 + `MethodChannel('com.stormg.lumadrama/thumbnail')` 调用原生。 +- 时间按 1 秒分桶;同一 bucket 的在途请求合流。 +- 最多 24 帧 LRU;成功和失败结果都做过期控制,避免旧拖动位置覆盖当前预览。 +- 全局单飞;拖动期间只保留最新 bucket,尚未开始的旧请求直接丢弃。 +- 拖动停顿 120ms 后最多预取相邻一个 bucket,松手取消尚未开始的请求。 +- 原生异常、缺失实现和超过 1.5 秒无结果统一返回 `null`。 + +### 原生取帧 + +- Android 使用 `MediaMetadataRetriever`,优先本地下载路径,其次签名 URL; + `OPTION_CLOSEST_SYNC` 取帧,最大宽 240px,JPEG 质量 70,单线程 Executor, + 完成后立即 release。 +- iOS 使用 `AVAssetImageGenerator`,应用画面方向,最大尺寸约 240x426, + 合理时间容差,后台串行队列生成 JPEG。 +- 两端均保证只回调一次;失败或 1.5 秒超时返回 `null`,不向 UI 抛异常。 + +### UI + +- 新增 `ScrubPreviewCard`:宽 120dp、圆角 8、白色 1px 描边,显示 + `当前时间 / 总时长`;沿拖动点水平跟随并做安全边距 clamp。 +- 首页 `_FeedProgressBar` 与全屏 `SeekProgressBar` 共用该卡片。 +- 封面始终铺在卡片底层;`Image.memory` 仅在帧到达后覆盖,并限制 + `cacheWidth: 240`。 +- 开始拖动请求当前 bucket,拖动更新只保留最新位置;松手立即隐藏卡片并取消 + 未开始请求。 +- `PlayerPage` 持有页面级服务,dispose 时清空缓存,避免跨页面串集。 + +## 测试 + +- 单测:bucket、LRU 淘汰、同 bucket 合流、过期请求丢弃、松手取消、 + 相邻 bucket 防抖、异常/超时回落。 +- Widget:拖动显示预览卡片和时间、松手消失、无缩略图显示封面。 +- 门禁:`dart analyze lib test` 与 `flutter test --no-pub`。 + +## 非目标 + +- 不改服务端接口或持久化策略。 +- 不新增第三方依赖。 +- 不启停服务、不操作真机;原生取帧实际效果由 PM 真机验收。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 2674012..7b0e427 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -426,3 +426,56 @@ $ flutter test --no-pub 解读:短缓冲不闪、持续缓冲仍显示白色高光;未初始化/入口/返回/重建路径 均无多余 loading,重建顺序为 initialize→seek→play。全量静态分析零告警, 593 项测试全过。`pubspec.lock` 已还原,未启停服务,未操作真机。 + +## 2026-10-22 播放器进度拖动画面预览 + +- 已从最新 `develop`(`d06dce9`)创建 `feature/scrub-frame-preview`。 +- 计划文档:`docs/plans/2026-10-22-scrub-frame-preview.md`。 +- 性能红线:拖动期间主线程无卡顿;原生并发取帧不超过 1;预览图最大宽 + 240px;Dart LRU 缓存不超过 24 帧;不新增 pub 依赖;不改后端契约。 +- 实施顺序:缩略图服务与缓存 -> 预览卡片及两处进度条 -> Android/iOS + 原生渠道 -> 单测/widget 测试与全量门禁。 + +```text +$ git status --short --branch +## feature/scrub-frame-preview + +$ git log -5 --oneline --decorate +d06dce9 (HEAD -> feature/scrub-frame-preview, origin/develop, develop) merge: 合并播放入口无缓冲提示 +``` + +解读:工作树从最新 develop 创建且开工前无未提交改动。当前仅新增计划与 +PROGRESS 留痕,后续每完成一个阶段会补充命令原始输出与解读。 + +### 实现结果 + +- 新增 `PlaybackThumbnailService`:1 秒 bucket、24 帧 LRU、单飞合流、 + 最新 bucket 优先、停顿 120ms 后最多预取相邻 1 帧、1.5 秒统一回落。 +- 新增 `ScrubPreviewCard`:120dp 宽、圆角 8、白色 1px 描边,显示 + `当前时间 / 总时长`,安全边距内跟随拖动点;帧到达前始终显示封面。 +- 首页 feed 与全屏播放器进度条均接入;松手立即隐藏并取消尚未开始的请求。 +- Android 使用单线程 `MediaMetadataRetriever`,iOS 使用串行 + `AVAssetImageGenerator`;两端优先本地路径、最大宽 240px、JPEG 约 70、 + 失败或 1.5 秒超时回调 null。 +- 未新增 pub 依赖,未改后端契约;`pubspec.lock` 已还原,未启停服务, + 未操作真机。 + +执行命令与原始输出: + +```text +$ flutter test --no-pub test/playback/playback_thumbnail_service_test.dart test/playback/seek_progress_bar_test.dart test/home/home_preview_layout_test.dart +00:01 +28: All tests passed! + +$ dart analyze lib test +Analyzing lib, test... +No issues found! + +$ flutter test --no-pub +00:56 +606: All tests passed! +``` + +解读:bucket、24 帧 LRU、同 bucket 合流、过期请求丢弃、松手取消、 +120ms 预取防覆盖、异常/超时回落、首页与全屏预览卡片、封面占位和松手立即 +消失均通过;全量静态分析零告警,606 项测试全过。性能红线保持:主线程不做 +图片解码/取帧、原生并发 1、预览最大宽 240px、缓存最多 24 帧、不增依赖、 +不改后端。原生取帧真实画面与设备性能由 PM 真机验收。 diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index f94360c..5329c45 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,5 +1,24 @@ import Flutter import UIKit +import AVFoundation + +private final class ThumbnailResponseGate { + private let lock = NSLock() + private var completed = false + + func send(_ result: @escaping FlutterResult, data: Data?) { + lock.lock() + if completed { + lock.unlock() + return + } + completed = true + lock.unlock() + DispatchQueue.main.async { + result(data) + } + } +} /// Keeps the system LaunchScreen as the only splash. Flutter would otherwise /// instantiate that storyboard again and the icon appears a second time. @@ -16,6 +35,11 @@ final class RunnerFlutterViewController: FlutterViewController { @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + private let thumbnailQueue = DispatchQueue( + label: "com.stormg.lumadrama.thumbnail", + qos: .userInitiated + ) + override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? @@ -44,5 +68,66 @@ final class RunnerFlutterViewController: FlutterViewController { activity.completionWithItemsHandler = { _, _, _, _ in result(nil) } controller.present(activity, animated: true) } + + let thumbnailChannel = FlutterMethodChannel( + name: "com.stormg.lumadrama/thumbnail", + binaryMessenger: engineBridge.applicationRegistrar.messenger() + ) + thumbnailChannel.setMethodCallHandler { [weak self] call, result in + guard call.method == "frame" else { + result(FlutterMethodNotImplemented) + return + } + guard let self, + let arguments = call.arguments as? [String: Any], + let timeNumber = arguments["timeMs"] as? NSNumber else { + result(nil) + return + } + let localPath = arguments["localPath"] as? String ?? "" + let remoteUrl = arguments["url"] as? String ?? "" + let headers = arguments["headers"] as? [String: String] ?? [:] + let assetUrl: URL? + if !localPath.isEmpty { + assetUrl = localPath.hasPrefix("file:") + ? URL(string: localPath) + : URL(fileURLWithPath: localPath) + } else if !remoteUrl.isEmpty { + assetUrl = URL(string: remoteUrl) + } else { + assetUrl = nil + } + guard let assetUrl else { + result(nil) + return + } + + let options: [String: Any]? = headers.isEmpty + ? nil + : [AVURLAssetHTTPHeaderFieldsKey: headers] + let asset = AVURLAsset(url: assetUrl, options: options) + let generator = AVAssetImageGenerator(asset: asset) + generator.appliesPreferredTrackTransform = true + generator.maximumSize = CGSize(width: 240, height: 426) + generator.requestedTimeToleranceBefore = CMTime(seconds: 0.5, preferredTimescale: 600) + generator.requestedTimeToleranceAfter = CMTime(seconds: 0.5, preferredTimescale: 600) + let time = CMTime(value: timeNumber.int64Value, timescale: 1000) + let gate = ThumbnailResponseGate() + + self.thumbnailQueue.async { + do { + let image = try generator.copyCGImage(at: time, actualTime: nil) + let data = UIImage(cgImage: image).jpegData(compressionQuality: 0.7) + gate.send(result, data: data) + } catch { + gate.send(result, data: nil) + } + } + DispatchQueue.global(qos: .userInitiated).asyncAfter( + deadline: .now() + 1.5 + ) { + gate.send(result, data: nil) + } + } } } diff --git a/lib/core/data/services/playback_thumbnail_service.dart b/lib/core/data/services/playback_thumbnail_service.dart new file mode 100644 index 0000000..c817858 --- /dev/null +++ b/lib/core/data/services/playback_thumbnail_service.dart @@ -0,0 +1,365 @@ +// Author: fruit.com.cn + +import 'dart:async'; +import 'dart:collection'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +/// 原生取帧所需的播放源信息。 +class PlaybackThumbnailSource { + const PlaybackThumbnailSource({ + this.localPath = '', + this.remoteUrl = '', + this.headers = const {}, + }); + + final String localPath; + final String remoteUrl; + final Map headers; + + bool get isAvailable => localPath.isNotEmpty || remoteUrl.isNotEmpty; +} + +/// 按 episodeId 读取当前可用播放源,确保签名地址刷新后立即生效。 +typedef PlaybackThumbnailSourceResolver = + PlaybackThumbnailSource? Function(String episodeId); + +/// 传给原生取帧层的单次请求。 +class PlaybackThumbnailFrameRequest { + const PlaybackThumbnailFrameRequest({ + required this.episodeId, + required this.position, + required this.source, + }); + + final String episodeId; + final Duration position; + final PlaybackThumbnailSource source; + + Map toChannelArguments() => { + 'episodeId': episodeId, + 'timeMs': position.inMilliseconds, + 'localPath': source.localPath, + 'url': source.remoteUrl, + 'headers': source.headers, + }; +} + +/// 可注入的原生取帧接口。 +abstract interface class PlaybackThumbnailFrameLoader { + Future load(PlaybackThumbnailFrameRequest request); + + void dispose(); +} + +/// 默认通过平台通道从本地播放器原生层取帧。 +class MethodChannelPlaybackThumbnailFrameLoader + implements PlaybackThumbnailFrameLoader { + MethodChannelPlaybackThumbnailFrameLoader({MethodChannel? channel}) + : _channel = + channel ?? const MethodChannel('com.stormg.lumadrama/thumbnail'); + + final MethodChannel _channel; + + @override + Future load(PlaybackThumbnailFrameRequest request) async { + final bytes = await _channel.invokeMethod( + 'frame', + request.toChannelArguments(), + ); + return bytes == null ? null : ByteData.sublistView(bytes); + } + + @override + void dispose() {} +} + +/// 每秒一个 bucket,拖过同一秒时复用同一张缩略图。 +int playbackThumbnailBucket(Duration position) { + final milliseconds = position.inMilliseconds; + if (milliseconds <= 0) return 0; + return milliseconds ~/ Duration.millisecondsPerSecond; +} + +/// 播放器缩略图单飞、LRU 缓存与拖动预取调度。 +class PlaybackThumbnailService { + PlaybackThumbnailService({ + PlaybackThumbnailFrameLoader? frameLoader, + this.sourceResolver, + this.requestTimeout = const Duration(milliseconds: 1500), + this.prefetchDelay = const Duration(milliseconds: 120), + this.cacheCapacity = defaultCacheCapacity, + }) : _frameLoader = + frameLoader ?? MethodChannelPlaybackThumbnailFrameLoader(), + assert(cacheCapacity > 0); + + static const int defaultCacheCapacity = 24; + + final PlaybackThumbnailFrameLoader _frameLoader; + final PlaybackThumbnailSourceResolver? sourceResolver; + final Duration requestTimeout; + final Duration prefetchDelay; + final int cacheCapacity; + final LinkedHashMap _cache = LinkedHashMap(); + final LinkedHashMap _unavailable = LinkedHashMap(); + + _ThumbnailRequest? _inFlight; + Future? _inFlightFuture; + _ThumbnailRequest? _queued; + Timer? _prefetchTimer; + String? _wantedKey; + String? _latestEpisodeId; + int? _latestBucket; + int _prefetchDirection = 1; + bool _disposed = false; + + @visibleForTesting + int get cacheSize => _cache.length; + + /// 取指定剧集、时间点的缩略图。失败或超时统一返回 null。 + Future thumbnail(String episodeId, Duration position) { + if (_disposed) return Future.value(); + final bucket = playbackThumbnailBucket(position); + final key = _key(episodeId, bucket); + _wantedKey = key; + _noteRequestPosition(episodeId, bucket); + _prefetchTimer?.cancel(); + _prefetchTimer = null; + _dropQueued(); + + final cached = _readCache(key); + if (cached != null) { + _schedulePrefetch(key); + return Future.value(cached); + } + if (_inFlight?.key == key && _inFlightFuture != null) { + _schedulePrefetch(key); + return _inFlightFuture!; + } + if (_unavailable.containsKey(key)) { + _promoteUnavailable(key); + _schedulePrefetch(key); + return Future.value(); + } + + final request = _ThumbnailRequest( + key: key, + episodeId: episodeId, + bucket: bucket, + ); + _schedulePrefetch(key); + if (_inFlight == null) return _start(request); + return _queue(request); + } + + /// 松手或离开拖动后调用;只取消尚未开始的请求。 + void cancelPending() { + _wantedKey = null; + _prefetchTimer?.cancel(); + _prefetchTimer = null; + _dropQueued(); + } + + /// 页面销毁时清空帧缓存和所有待执行请求。 + void clear() { + cancelPending(); + _cache.clear(); + _unavailable.clear(); + _latestEpisodeId = null; + _latestBucket = null; + _prefetchDirection = 1; + } + + void dispose() { + if (_disposed) return; + _disposed = true; + clear(); + _frameLoader.dispose(); + } + + Future _start(_ThumbnailRequest request) { + final completer = Completer(); + _inFlight = request; + _inFlightFuture = completer.future; + unawaited(_run(request, completer)); + return completer.future; + } + + Future _queue(_ThumbnailRequest request) { + final completer = Completer(); + _queued = request.withCompleter(completer); + return completer.future; + } + + Future _run( + _ThumbnailRequest request, + Completer completer, + ) async { + ByteData? frame; + try { + frame = await _loadFrame(request); + } catch (_) { + frame = null; + } + if (frame == null) { + _putUnavailable(request.key); + } else { + _putCache(request.key, frame); + } + final deliver = _wantedKey == request.key; + _inFlight = null; + _inFlightFuture = null; + if (!completer.isCompleted) { + completer.complete(deliver ? frame : null); + } + _drainQueued(); + } + + Future _loadFrame(_ThumbnailRequest request) { + PlaybackThumbnailSource? source; + try { + source = sourceResolver?.call(request.episodeId); + } catch (_) { + return Future.value(); + } + if (source == null || !source.isAvailable) { + return Future.value(); + } + final frame = PlaybackThumbnailFrameRequest( + episodeId: request.episodeId, + position: Duration(seconds: request.bucket), + source: source, + ); + final result = Completer(); + late final Timer timeout; + void finish(ByteData? value) { + if (result.isCompleted) return; + timeout.cancel(); + result.complete(value); + } + + timeout = Timer(requestTimeout, () => finish(null)); + try { + _frameLoader + .load(frame) + .then(finish, onError: (Object _, StackTrace _) => finish(null)); + } catch (_) { + finish(null); + } + return result.future; + } + + void _drainQueued() { + if (_disposed || _inFlight != null) return; + final queued = _queued; + _queued = null; + if (queued == null || queued.key != _wantedKey) { + queued?.completer.complete(null); + return; + } + _inFlight = queued; + _inFlightFuture = queued.completer.future; + unawaited(_run(queued, queued.completer)); + } + + void _dropQueued() { + final queued = _queued; + _queued = null; + if (queued != null && !queued.completer.isCompleted) { + queued.completer.complete(null); + } + } + + void _noteRequestPosition(String episodeId, int bucket) { + if (_latestEpisodeId == episodeId && _latestBucket != null) { + if (bucket < _latestBucket!) _prefetchDirection = -1; + if (bucket > _latestBucket!) _prefetchDirection = 1; + } + _latestEpisodeId = episodeId; + _latestBucket = bucket; + } + + void _schedulePrefetch(String currentKey) { + _prefetchTimer?.cancel(); + _prefetchTimer = Timer(prefetchDelay, () { + _prefetchTimer = null; + if (_disposed || _wantedKey != currentKey) return; + final episodeId = _latestEpisodeId; + final bucket = _latestBucket; + if (episodeId == null || bucket == null) return; + final neighbor = bucket + _prefetchDirection; + if (neighbor < 0) return; + final key = _key(episodeId, neighbor); + if (_cache.containsKey(key) || + _unavailable.containsKey(key) || + _inFlight?.key == key || + _queued != null) { + return; + } + final request = _ThumbnailRequest( + key: key, + episodeId: episodeId, + bucket: neighbor, + ); + if (_inFlight == null) { + unawaited(_start(request)); + } else { + _queue(request); + } + }); + } + + ByteData? _readCache(String key) { + final value = _cache.remove(key); + if (value == null) return null; + _cache[key] = value; + return value; + } + + void _putCache(String key, ByteData value) { + _unavailable.remove(key); + _cache.remove(key); + _cache[key] = value; + while (_cache.length > cacheCapacity) { + _cache.remove(_cache.keys.first); + } + } + + void _putUnavailable(String key) { + _unavailable.remove(key); + _unavailable[key] = true; + while (_unavailable.length > cacheCapacity) { + _unavailable.remove(_unavailable.keys.first); + } + } + + void _promoteUnavailable(String key) { + if (_unavailable.remove(key) == null) return; + _unavailable[key] = true; + } + + String _key(String episodeId, int bucket) => '$episodeId:$bucket'; +} + +class _ThumbnailRequest { + _ThumbnailRequest({ + required this.key, + required this.episodeId, + required this.bucket, + Completer? completer, + }) : completer = completer ?? Completer(); + + final String key; + final String episodeId; + final int bucket; + final Completer completer; + + _ThumbnailRequest withCompleter(Completer value) => + _ThumbnailRequest( + key: key, + episodeId: episodeId, + bucket: bucket, + completer: value, + ); +} diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 20b0e49..1f6d8e7 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import '../../core/config/app_config.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/repositories/comment_repository.dart'; import '../../core/data/services/favorite_controller.dart'; @@ -13,6 +14,7 @@ import '../../core/data/services/download_controller.dart'; import '../../core/data/services/interaction_controller.dart'; import '../../core/data/services/playback_position_cache.dart'; import '../../core/data/services/playback_session.dart'; +import '../../core/data/services/playback_thumbnail_service.dart'; import '../../core/data/services/video_player_port.dart'; import '../../core/data/services/wallet_controller.dart'; import '../../core/formatting/error_text.dart'; @@ -103,6 +105,7 @@ class PlayerPage extends StatefulWidget { class _PlayerPageState extends State with WidgetsBindingObserver, RouteAware { late final PlayerLogic logic; + late final PlaybackThumbnailService _thumbnailService; PageRoute? _subscribedRoute; bool _chromeVisible = true; double _playbackSpeed = 1.0; @@ -125,6 +128,9 @@ class _PlayerPageState extends State downloads: widget.downloads, ); logic.attach(widget, isMounted: () => mounted); + _thumbnailService = PlaybackThumbnailService( + sourceResolver: _resolveThumbnailSource, + ); WidgetsBinding.instance.addObserver(this); logic.start( factory: context.read(), @@ -161,6 +167,57 @@ class _PlayerPageState extends State String _feedSignature(List? dramas) => dramas == null ? '' : dramas.map((drama) => drama.id).join('|'); + PlaybackThumbnailSource? _resolveThumbnailSource(String episodeId) { + final localPath = widget.downloads?.localPathFor(episodeId); + Episode? episode; + for (final item in logic.sessionEpisodes) { + if (item.id == episodeId) { + episode = item; + break; + } + } + if (localPath != null && localPath.isNotEmpty) { + return PlaybackThumbnailSource(localPath: localPath); + } + final rawUrl = episode?.videoUrl ?? ''; + final active = logic.session.active; + final activeSource = active is VideoPlayerPort + ? active.controller.dataSource + : ''; + final currentEpisodeId = logic.sessionEpisodes.isEmpty + ? null + : logic.session.currentEpisode.id; + final activeMatches = + currentEpisodeId == episodeId && activeSource.isNotEmpty; + if (activeMatches && activeSource.startsWith('file:')) { + try { + return PlaybackThumbnailSource( + localPath: Uri.parse(activeSource).toFilePath(), + ); + } catch (_) { + return null; + } + } + if (rawUrl.startsWith('file:')) { + try { + return PlaybackThumbnailSource( + localPath: Uri.parse(rawUrl).toFilePath(), + ); + } catch (_) { + return null; + } + } + final remoteUrl = AppConfig.mediaUrl( + rawUrl.isNotEmpty + ? rawUrl + : activeMatches + ? activeSource + : '', + ); + if (remoteUrl.isEmpty) return null; + return PlaybackThumbnailSource(remoteUrl: remoteUrl); + } + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -190,6 +247,7 @@ class _PlayerPageState extends State void dispose() { WidgetsBinding.instance.removeObserver(this); appRouteObserver.unsubscribe(this); + _thumbnailService.dispose(); logic.dispose(); super.dispose(); } @@ -547,6 +605,7 @@ class _PlayerPageState extends State onSeek: ready ? (position) => logic.seekTo(context, videoPort!, position) : null, + thumbnailService: _thumbnailService, ) else PlayerPlaybackOverlay( @@ -569,6 +628,7 @@ class _PlayerPageState extends State playbackSpeed: _playbackSpeed, totalEpisodes: logic.visibleIndices.length, boosting: _boosting, + thumbnailService: _thumbnailService, ), actions: PlayerOverlayActions( onBack: widget.embedded diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index e6c99db..e36a961 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -7,12 +7,15 @@ import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; import '../../../core/data/models/drama.dart'; +import '../../../core/data/services/playback_thumbnail_service.dart'; import '../../../core/formatting/count_format.dart'; 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'; +import 'scrub_preview_card.dart'; +import 'seek_progress_bar.dart'; part 'home_feed_overlay_parts.dart'; @@ -59,6 +62,7 @@ class HomeFeedOverlay extends StatefulWidget { this.authorNote = '', this.onScrubStart, this.onSeek, + this.thumbnailService, }); final AppLocalizations l10n; @@ -86,6 +90,7 @@ class HomeFeedOverlay extends StatefulWidget { final VoidCallback onMore; final VoidCallback? onScrubStart; final ValueChanged? onSeek; + final PlaybackThumbnailService? thumbnailService; @override State createState() => _HomeFeedOverlayState(); @@ -287,7 +292,11 @@ class _HomeFeedOverlayState extends State { ), ), ), - const Icon(Icons.chevron_right_rounded, color: Colors.white, size: 20), + const Icon( + Icons.chevron_right_rounded, + color: Colors.white, + size: 20, + ), ], ), ); @@ -342,6 +351,7 @@ class _HomeFeedOverlayState extends State { child: SizedBox( height: _fullBarHeight, child: Stack( + clipBehavior: Clip.none, children: [ Positioned.fill( child: Container( @@ -394,6 +404,9 @@ class _HomeFeedOverlayState extends State { hitHeight: _progressHitHeight, onScrubStart: widget.onScrubStart, onSeek: widget.onSeek, + thumbnailService: widget.thumbnailService, + episodeId: widget.episode.id, + posterUrl: widget.drama.posterUrl, ), ), ], diff --git a/lib/pages/player/widgets/home_feed_overlay_parts.dart b/lib/pages/player/widgets/home_feed_overlay_parts.dart index ebf4578..cd08b58 100644 --- a/lib/pages/player/widgets/home_feed_overlay_parts.dart +++ b/lib/pages/player/widgets/home_feed_overlay_parts.dart @@ -17,6 +17,9 @@ class _FeedProgressBar extends StatefulWidget { required this.hitHeight, this.onScrubStart, this.onSeek, + this.thumbnailService, + required this.episodeId, + required this.posterUrl, }); final ValueListenable? controller; @@ -25,6 +28,9 @@ class _FeedProgressBar extends StatefulWidget { final double hitHeight; final VoidCallback? onScrubStart; final ValueChanged? onSeek; + final PlaybackThumbnailService? thumbnailService; + final String episodeId; + final String posterUrl; @override State<_FeedProgressBar> createState() => _FeedProgressBarState(); @@ -51,6 +57,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { _bufferTimer?.cancel(); _bufferTimer = null; _buffering = false; + widget.thumbnailService?.cancelPending(); _attachSource(); } if (oldWidget.locked != widget.locked) { @@ -58,6 +65,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { _bufferTimer = null; _buffering = false; if (widget.locked) { + widget.thumbnailService?.cancelPending(); _preview = null; } else { _syncBufferState(notify: false); @@ -69,6 +77,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { void dispose() { _detachSource(widget.controller); _bufferTimer?.cancel(); + if (_preview != null) widget.thumbnailService?.cancelPending(); super.dispose(); } @@ -104,6 +113,14 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { !widget.locked && !loading && (source?.value.isInitialized ?? false); + final video = source?.value; + final previewPosition = _preview == null || video == null + ? null + : seekPositionForFraction(_preview!, video.duration); + final showPreview = + previewPosition != null && + widget.thumbnailService != null && + widget.episodeId.isNotEmpty; return GestureDetector( key: const Key('home-feed-progress'), behavior: HitTestBehavior.opaque, @@ -117,10 +134,28 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { ? (details) => _update(details.localPosition.dx / width) : null, onHorizontalDragEnd: interactive ? (_) => _commit(_preview) : null, - onHorizontalDragCancel: interactive - ? () => setState(() => _preview = null) - : null, - child: track, + onHorizontalDragCancel: interactive ? _cancelPreview : null, + child: Stack( + clipBehavior: Clip.none, + children: [ + track, + if (showPreview) + Positioned( + left: scrubPreviewLeft( + trackWidth: width, + fraction: _preview!, + ), + bottom: widget.hitHeight + 8, + child: ScrubPreviewCard( + service: widget.thumbnailService!, + episodeId: widget.episodeId, + posterUrl: widget.posterUrl, + position: previewPosition, + duration: video!.duration, + ), + ), + ], + ), ); }, ); @@ -230,6 +265,12 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { Duration(milliseconds: (total * fraction.clamp(0.0, 1.0)).round()), ); } + widget.thumbnailService?.cancelPending(); + setState(() => _preview = null); + } + + void _cancelPreview() { + widget.thumbnailService?.cancelPending(); setState(() => _preview = null); } diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index 5d3fdcb..79b7a1b 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -1,6 +1,7 @@ // Author: fruit.com.cn import 'package:flutter/material.dart'; + import '../../../core/formatting/localized_content.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; @@ -264,6 +265,9 @@ class PlayerPlaybackOverlay extends StatelessWidget { showTimes: true, compact: true, locked: data.locked, + thumbnailService: data.thumbnailService, + episodeId: data.episode.id, + posterUrl: data.drama.posterUrl, inactiveTrackColor: Colors.white24, onScrubStart: actions.onScrubStart, onSeek: actions.onSeek, diff --git a/lib/pages/player/widgets/player_overlay_models.dart b/lib/pages/player/widgets/player_overlay_models.dart index 1a2dd92..75d19df 100644 --- a/lib/pages/player/widgets/player_overlay_models.dart +++ b/lib/pages/player/widgets/player_overlay_models.dart @@ -4,6 +4,7 @@ import 'package:flutter/widgets.dart'; import 'package:video_player/video_player.dart'; import '../../../core/data/models/drama.dart'; +import '../../../core/data/services/playback_thumbnail_service.dart'; import '../../../l10n/app_localizations.dart'; /// 播放浮层展示数据。 @@ -24,6 +25,7 @@ class PlayerOverlayData { required this.totalEpisodes, required this.boosting, required this.locked, + this.thumbnailService, this.authorNote = '', }); @@ -69,6 +71,9 @@ class PlayerOverlayData { /// 当前剧集是否锁定。 final bool locked; + /// 拖动进度时按当前集和时间点取帧。 + final PlaybackThumbnailService? thumbnailService; + /// 作者声明;没有后端字段时为空。 final String authorNote; } diff --git a/lib/pages/player/widgets/scrub_preview_card.dart b/lib/pages/player/widgets/scrub_preview_card.dart new file mode 100644 index 0000000..e38f8f1 --- /dev/null +++ b/lib/pages/player/widgets/scrub_preview_card.dart @@ -0,0 +1,164 @@ +// Author: fruit.com.cn + +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; + +import '../../../core/data/services/playback_thumbnail_service.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/widgets/remote_image.dart'; + +const double scrubPreviewWidth = 120; +const double scrubPreviewSafeMargin = 12; + +/// 把预览卡片限制在进度条可用宽度内。 +double scrubPreviewLeft({ + required double trackWidth, + required double fraction, + double cardWidth = scrubPreviewWidth, + double safeMargin = scrubPreviewSafeMargin, +}) { + final maxLeft = trackWidth - cardWidth - safeMargin; + if (maxLeft < safeMargin) { + return ((trackWidth - cardWidth) / 2).clamp(0.0, trackWidth).toDouble(); + } + final requested = trackWidth * fraction.clamp(0.0, 1.0) - cardWidth / 2; + return requested.clamp(safeMargin, maxLeft).toDouble(); +} + +/// 拖动进度时覆盖在轨道上方的单帧预览。 +class ScrubPreviewCard extends StatefulWidget { + const ScrubPreviewCard({ + super.key, + required this.service, + required this.episodeId, + required this.posterUrl, + required this.position, + required this.duration, + }); + + final PlaybackThumbnailService service; + final String episodeId; + final String posterUrl; + final Duration position; + final Duration duration; + + @override + State createState() => _ScrubPreviewCardState(); +} + +class _ScrubPreviewCardState extends State { + ByteData? _frame; + int _requestSerial = 0; + int? _requestedBucket; + + @override + void initState() { + super.initState(); + _requestFrame(); + } + + @override + void didUpdateWidget(covariant ScrubPreviewCard oldWidget) { + super.didUpdateWidget(oldWidget); + final bucket = playbackThumbnailBucket(widget.position); + if (oldWidget.service != widget.service || + oldWidget.episodeId != widget.episodeId || + _requestedBucket != bucket) { + _frame = null; + _requestFrame(); + } else { + // 同 bucket 内继续拖动时不重复取帧,但会重置 120ms 预取停顿计时。 + unawaited(widget.service.thumbnail(widget.episodeId, widget.position)); + } + } + + @override + void dispose() { + _requestSerial++; + super.dispose(); + } + + void _requestFrame() { + final bucket = playbackThumbnailBucket(widget.position); + _requestedBucket = bucket; + final serial = ++_requestSerial; + final future = widget.service.thumbnail(widget.episodeId, widget.position); + future.then((frame) { + if (!mounted || serial != _requestSerial) return; + setState(() => _frame = frame); + }); + } + + @override + Widget build(BuildContext context) { + return Container( + key: const Key('scrub-preview-card'), + width: scrubPreviewWidth, + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.white, width: 1), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(7), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + color: Colors.black87, + child: Text( + '${_format(widget.position)} / ${_format(widget.duration)}', + key: const Key('scrub-preview-time'), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w600, + height: 1.2, + ), + ), + ), + AspectRatio( + aspectRatio: 16 / 9, + child: Stack( + fit: StackFit.expand, + children: [ + RemoteImage( + widget.posterUrl, + key: const Key('scrub-preview-poster'), + fit: BoxFit.cover, + fallbackColor: AppColors.bar, + ), + if (_frame case final frame?) + Image.memory( + frame.buffer.asUint8List( + frame.offsetInBytes, + frame.lengthInBytes, + ), + key: const Key('scrub-preview-frame'), + fit: BoxFit.cover, + cacheWidth: 240, + gaplessPlayback: true, + errorBuilder: (_, _, _) => const SizedBox.shrink(), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +String _format(Duration value) { + final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0'); + final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0'); + return '$minutes:$seconds'; +} diff --git a/lib/pages/player/widgets/seek_progress_bar.dart b/lib/pages/player/widgets/seek_progress_bar.dart index bc58c53..aa96945 100644 --- a/lib/pages/player/widgets/seek_progress_bar.dart +++ b/lib/pages/player/widgets/seek_progress_bar.dart @@ -6,8 +6,10 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; +import '../../../core/data/services/playback_thumbnail_service.dart'; import '../../../core/theme/app_colors.dart'; import 'loading_progress_track.dart'; +import 'scrub_preview_card.dart'; const Key _inactiveTrackKey = Key('progress-inactive'); @@ -32,6 +34,9 @@ class SeekProgressBar extends StatefulWidget { this.compact = false, this.inactiveTrackColor = AppColors.border, this.locked = false, + this.thumbnailService, + this.episodeId, + this.posterUrl = '', }); final ValueListenable? valueListenable; @@ -42,6 +47,9 @@ class SeekProgressBar extends StatefulWidget { final bool compact; final Color inactiveTrackColor; final bool locked; + final PlaybackThumbnailService? thumbnailService; + final String? episodeId; + final String posterUrl; @override State createState() => _SeekProgressBarState(); @@ -69,6 +77,7 @@ class _SeekProgressBarState extends State { _bufferTimer?.cancel(); _bufferTimer = null; _buffering = false; + widget.thumbnailService?.cancelPending(); _attachSource(); } if (oldWidget.locked != widget.locked) { @@ -76,6 +85,7 @@ class _SeekProgressBarState extends State { _bufferTimer = null; _buffering = false; if (widget.locked) { + widget.thumbnailService?.cancelPending(); _previewFraction.value = null; } else { _syncBufferState(notify: false); @@ -87,6 +97,9 @@ class _SeekProgressBarState extends State { void dispose() { _detachSource(widget.valueListenable); _bufferTimer?.cancel(); + if (_previewFraction.value != null) { + widget.thumbnailService?.cancelPending(); + } _previewFraction.dispose(); super.dispose(); } @@ -148,15 +161,10 @@ class _SeekProgressBarState extends State { Future _finish(double fraction, Duration duration) async { if (widget.locked) return; - final source = widget.valueListenable; _previewFraction.value = fraction; - try { - await widget.onSeek?.call(seekPositionForFraction(fraction, duration)); - } finally { - if (mounted && widget.valueListenable == source) { - _previewFraction.value = null; - } - } + widget.thumbnailService?.cancelPending(); + _previewFraction.value = null; + await widget.onSeek?.call(seekPositionForFraction(fraction, duration)); } @override @@ -194,6 +202,11 @@ class _SeekProgressBarState extends State { final previewPosition = _previewFraction.value == null ? video.position : seekPositionForFraction(fraction, video.duration); + final showPreview = + scrubbing && + widget.showTimes && + widget.thumbnailService != null && + (widget.episodeId?.isNotEmpty ?? false); final trackHeight = widget.compact ? (scrubbing ? 3.0 : 1.5) : (scrubbing ? 4.0 : 3.0); @@ -203,72 +216,98 @@ class _SeekProgressBarState extends State { ? (scrubbing ? 32.0 : 20.0) : (scrubbing ? 48.0 : 44.0); - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (widget.showTimes && scrubbing) - Padding( - padding: const EdgeInsets.only(bottom: 2), - child: Text( - '${_format(previewPosition)} / ${_format(video.duration)}', - textAlign: TextAlign.center, - style: const TextStyle( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.w600, - height: 1.2, - ), - ), - ), - SizedBox( - height: trackAreaHeight, - child: widget.locked || !video.isInitialized - ? _inactiveTrack(trackHeight) - : 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, - ), + return LayoutBuilder( + builder: (context, constraints) { + final track = Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.showTimes && scrubbing && !showPreview) + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text( + '${_format(previewPosition)} / ${_format(video.duration)}', + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w600, + height: 1.2, ), ), - ), - ], + ), + SizedBox( + height: trackAreaHeight, + child: widget.locked || !video.isInitialized + ? _inactiveTrack(trackHeight) + : 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, + ), + ), + ), + ), + ], + ); + return Stack( + clipBehavior: Clip.none, + children: [ + track, + if (showPreview) + Positioned( + left: scrubPreviewLeft( + trackWidth: constraints.maxWidth, + fraction: fraction, + ), + bottom: trackAreaHeight + 8, + child: ScrubPreviewCard( + service: widget.thumbnailService!, + episodeId: widget.episodeId!, + posterUrl: widget.posterUrl, + position: previewPosition, + duration: video.duration, + ), + ), + ], + ); + }, ); } diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 275759f..7a50e5a 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -1,6 +1,7 @@ // Author: fruit.com.cn import 'dart:async'; +import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -13,6 +14,7 @@ import 'package:fruitshorts/core/data/models/home_feed.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/playback_thumbnail_service.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'; @@ -609,6 +611,83 @@ void main() { expect(find.byIcon(Icons.refresh_rounded), findsOneWidget); }); + testWidgets('should_show_scrub_preview_card_from_home_progress', ( + tester, + ) async { + final controller = ValueNotifier( + const VideoPlayerValue( + duration: Duration(seconds: 100), + position: Duration(seconds: 10), + isInitialized: true, + ), + ); + final service = _nullThumbnailService(); + addTearDown(controller.dispose); + addTearDown(service.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: 'preview-drama', + title: 'Preview Drama', + synopsis: 'synopsis', + posterUrl: '', + totalEpisodes: 8, + ), + episode: const Episode( + id: 'preview-episode', + number: 1, + title: 'Episode 1', + isFree: true, + ), + controller: controller, + locked: false, + liked: false, + saved: false, + visible: true, + boosting: false, + thumbnailService: service, + onTogglePlay: () {}, + onBoostStart: () {}, + onBoostEnd: () {}, + onLike: () {}, + onSave: () {}, + onComments: () {}, + onShare: () {}, + onDetail: () {}, + onOpenFullPlayer: () {}, + onMore: () {}, + onScrubStart: () {}, + onSeek: (_) {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final gesture = await tester.startGesture( + tester.getCenter(find.byKey(const Key('home-feed-progress'))), + ); + await gesture.moveBy(const Offset(60, 0)); + await tester.pump(); + + expect(find.byKey(const Key('scrub-preview-card')), findsOneWidget); + expect(find.byKey(const Key('scrub-preview-poster')), findsOneWidget); + expect(find.textContaining('/ 01:40'), findsOneWidget); + + await gesture.up(); + await tester.pump(); + expect(find.byKey(const Key('scrub-preview-card')), findsNothing); + }); + testWidgets('should_seek_from_series_bar_when_overlay_is_cleared', ( tester, ) async { @@ -683,3 +762,20 @@ void main() { expect(seeked!.inSeconds, greaterThan(0)); }); } + +PlaybackThumbnailService _nullThumbnailService() { + return PlaybackThumbnailService( + frameLoader: _NullThumbnailLoader(), + sourceResolver: (_) => + const PlaybackThumbnailSource(remoteUrl: 'https://example/video.mp4'), + prefetchDelay: const Duration(hours: 1), + ); +} + +class _NullThumbnailLoader implements PlaybackThumbnailFrameLoader { + @override + Future load(PlaybackThumbnailFrameRequest request) async => null; + + @override + void dispose() {} +} diff --git a/test/playback/playback_thumbnail_service_test.dart b/test/playback/playback_thumbnail_service_test.dart new file mode 100644 index 0000000..c04fd86 --- /dev/null +++ b/test/playback/playback_thumbnail_service_test.dart @@ -0,0 +1,227 @@ +// Author: fruit.com.cn + +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/services/playback_thumbnail_service.dart'; + +void main() { + test('should_bucket_thumbnail_requests_by_whole_seconds', () { + expect(playbackThumbnailBucket(Duration.zero), 0); + expect(playbackThumbnailBucket(const Duration(milliseconds: 1999)), 1); + expect(playbackThumbnailBucket(const Duration(seconds: 12)), 12); + expect(playbackThumbnailBucket(const Duration(milliseconds: -300)), 0); + }); + + test('should_return_injected_frame_from_loader', () async { + final loader = _RecordingLoader((_) async => ByteData(1)); + final frame = await loader.load( + const PlaybackThumbnailFrameRequest( + episodeId: 'episode', + position: Duration(seconds: 1), + source: PlaybackThumbnailSource(remoteUrl: 'https://example/video.mp4'), + ), + ); + expect(frame, isNotNull); + }); + + test('should_evict_least_recently_used_frame_after_24_entries', () async { + final loader = _RecordingLoader((_) async => ByteData(1)); + final service = _service(loader); + addTearDown(service.dispose); + + for (var second = 0; second < 25; second++) { + final frame = await service.thumbnail( + 'episode', + Duration(seconds: second), + ); + expect( + frame, + isNotNull, + reason: 'bucket $second loader=${loader.buckets}', + ); + } + expect(service.cacheSize, 24); + expect(loader.buckets.first, 0); + + await service.thumbnail('episode', const Duration(seconds: 1)); + expect(loader.buckets.where((bucket) => bucket == 1), hasLength(1)); + + await service.thumbnail('episode', Duration.zero); + expect(loader.buckets.where((bucket) => bucket == 0), hasLength(2)); + }); + + test('should_coalesce_same_bucket_while_frame_is_in_flight', () async { + final frame = Completer(); + final loader = _RecordingLoader((_) => frame.future); + final service = _service(loader); + addTearDown(service.dispose); + + final first = service.thumbnail('episode', const Duration(seconds: 8)); + await Future.delayed(Duration.zero); + final second = service.thumbnail( + 'episode', + const Duration(milliseconds: 8999), + ); + + expect(identical(first, second), isTrue); + expect(loader.buckets, [8]); + frame.complete(ByteData(2)); + expect(await first, isNotNull); + expect(await second, isNotNull); + }); + + test('should_drop_superseded_request_and_start_latest_bucket', () async { + final frames = >{}; + final loader = _RecordingLoader((request) { + final completer = Completer(); + frames[request.position.inSeconds] = completer; + return completer.future; + }); + final service = _service(loader); + addTearDown(service.dispose); + + final stale = service.thumbnail('episode', const Duration(seconds: 4)); + await Future.delayed(Duration.zero); + final latest = service.thumbnail('episode', const Duration(seconds: 9)); + await Future.delayed(Duration.zero); + + expect(loader.buckets, [4]); + frames[4]!.complete(ByteData(4)); + expect(await stale, isNull); + await Future.delayed(Duration.zero); + expect(loader.buckets, [4, 9]); + + frames[9]!.complete(ByteData(9)); + expect(await latest, isNotNull); + }); + + test('should_cancel_unstarted_request_when_scrub_ends', () async { + final firstFrame = Completer(); + final loader = _RecordingLoader((request) { + if (request.position.inSeconds == 3) return firstFrame.future; + return Future.value(ByteData(7)); + }); + final service = _service(loader); + addTearDown(service.dispose); + + final active = service.thumbnail('episode', const Duration(seconds: 3)); + await Future.delayed(Duration.zero); + final pending = service.thumbnail('episode', const Duration(seconds: 7)); + service.cancelPending(); + + expect(await pending, isNull); + firstFrame.complete(ByteData(3)); + expect(await active, isNull); + await Future.delayed(Duration.zero); + expect(loader.buckets, [3]); + }); + + test('should_prefetch_one_neighbor_after_scrub_pause', () async { + final loader = _RecordingLoader((_) async => ByteData(1)); + final service = _service( + loader, + prefetchDelay: const Duration(milliseconds: 10), + ); + addTearDown(service.dispose); + + await service.thumbnail('episode', const Duration(seconds: 5)); + expect(loader.buckets, [5]); + + await Future.delayed(const Duration(milliseconds: 5)); + expect(loader.buckets, [5]); + + await Future.delayed(const Duration(milliseconds: 12)); + expect(loader.buckets, [5, 6]); + }); + + test('should_not_prefetch_over_newer_unstarted_bucket', () async { + final frames = >{}; + final loader = _RecordingLoader((request) { + final completer = Completer(); + frames[request.position.inSeconds] = completer; + return completer.future; + }); + final service = _service( + loader, + prefetchDelay: const Duration(milliseconds: 10), + ); + addTearDown(service.dispose); + + final first = service.thumbnail('episode', const Duration(seconds: 1)); + await Future.delayed(Duration.zero); + final latest = service.thumbnail('episode', const Duration(seconds: 3)); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(loader.buckets, [1]); + frames[1]!.complete(ByteData(1)); + expect(await first, isNull); + await Future.delayed(Duration.zero); + expect(loader.buckets, [1, 3]); + + frames[3]!.complete(ByteData(3)); + expect(await latest, isNotNull); + }); + + test('should_return_null_once_then_negative_cache_failed_bucket', () async { + final loader = _RecordingLoader((_) => Completer().future); + final service = _service( + loader, + requestTimeout: const Duration(milliseconds: 10), + ); + addTearDown(service.dispose); + + expect( + await service.thumbnail('episode', const Duration(seconds: 2)), + isNull, + ); + expect( + await service.thumbnail('episode', const Duration(seconds: 2)), + isNull, + ); + expect(loader.buckets, [2]); + }); + + test('should_convert_loader_exception_to_null', () async { + final loader = _RecordingLoader((_) async => throw StateError('decode')); + final service = _service(loader); + addTearDown(service.dispose); + + expect( + await service.thumbnail('episode', const Duration(seconds: 6)), + isNull, + ); + }); +} + +PlaybackThumbnailService _service( + PlaybackThumbnailFrameLoader loader, { + Duration requestTimeout = const Duration(hours: 1), + Duration prefetchDelay = const Duration(hours: 1), +}) { + return PlaybackThumbnailService( + frameLoader: loader, + sourceResolver: (_) => + const PlaybackThumbnailSource(remoteUrl: 'https://example/video.mp4'), + requestTimeout: requestTimeout, + prefetchDelay: prefetchDelay, + ); +} + +class _RecordingLoader implements PlaybackThumbnailFrameLoader { + _RecordingLoader(this._handler); + + final Future Function(PlaybackThumbnailFrameRequest request) + _handler; + final List buckets = []; + + @override + Future load(PlaybackThumbnailFrameRequest request) { + buckets.add(request.position.inSeconds); + return _handler(request); + } + + @override + void dispose() {} +} diff --git a/test/playback/seek_progress_bar_test.dart b/test/playback/seek_progress_bar_test.dart index 04f0fae..b7713c9 100644 --- a/test/playback/seek_progress_bar_test.dart +++ b/test/playback/seek_progress_bar_test.dart @@ -1,8 +1,12 @@ // Author: fruit.com.cn +import 'dart:typed_data'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/services/playback_thumbnail_service.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; +import 'package:fruitshorts/pages/player/widgets/scrub_preview_card.dart'; import 'package:fruitshorts/pages/player/widgets/seek_progress_bar.dart'; import 'package:video_player/video_player.dart'; @@ -17,6 +21,18 @@ void main() { expect(seekPositionForFraction(1, duration), lessThan(duration)); }); + test('should_clamp_preview_card_inside_track_safe_margins', () { + expect( + scrubPreviewLeft(trackWidth: 300, fraction: 0), + scrubPreviewSafeMargin, + ); + expect( + scrubPreviewLeft(trackWidth: 300, fraction: 1), + 300 - scrubPreviewWidth - scrubPreviewSafeMargin, + ); + expect(scrubPreviewLeft(trackWidth: 100, fraction: 0.5), 0); + }); + testWidgets('should_show_inactive_track_when_video_is_not_initialized', ( tester, ) async { @@ -233,6 +249,59 @@ void main() { }, ); + testWidgets('should_show_frame_card_with_cover_then_hide_on_release', ( + tester, + ) async { + final video = ValueNotifier( + const VideoPlayerValue( + duration: Duration(seconds: 12), + position: Duration(seconds: 3), + isInitialized: true, + ), + ); + final service = _thumbnailService(); + addTearDown(video.dispose); + addTearDown(service.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 300, + child: SeekProgressBar( + valueListenable: video, + semanticsLabel: 'Seek video', + episodeId: 'episode-1', + posterUrl: '', + thumbnailService: service, + onSeek: (_) async {}, + ), + ), + ), + ), + ), + ); + + final gesture = await tester.startGesture( + tester.getCenter(find.byType(Slider)), + ); + await gesture.moveBy(const Offset(80, 0)); + await tester.pump(); + + expect(find.byKey(const Key('scrub-preview-card')), findsOneWidget); + expect(find.byKey(const Key('scrub-preview-poster')), findsOneWidget); + expect(find.byKey(const Key('scrub-preview-frame')), findsNothing); + expect(find.textContaining('/ 00:12'), findsOneWidget); + expect( + tester.getSize(find.byKey(const Key('scrub-preview-card'))).width, + 120, + ); + + await gesture.up(); + await tester.pump(); + expect(find.byKey(const Key('scrub-preview-card')), findsNothing); + }); + testWidgets('should_show_time_only_while_dragging_when_seek_bar_is_compact', ( tester, ) async { @@ -267,3 +336,20 @@ void main() { expect(find.textContaining('00:12'), findsNothing); }); } + +PlaybackThumbnailService _thumbnailService() { + return PlaybackThumbnailService( + frameLoader: _NullThumbnailLoader(), + sourceResolver: (_) => + const PlaybackThumbnailSource(remoteUrl: 'https://example/video.mp4'), + prefetchDelay: const Duration(hours: 1), + ); +} + +class _NullThumbnailLoader implements PlaybackThumbnailFrameLoader { + @override + Future load(PlaybackThumbnailFrameRequest request) async => null; + + @override + void dispose() {} +} From 12b733d1114052a77d1393d8e14ddb09f8141542 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 11:40:23 +0800 Subject: [PATCH 009/129] =?UTF-8?q?feat(player):=20=E8=BF=9B=E5=BA=A6?= =?UTF-8?q?=E6=9D=A1=E6=8B=96=E5=8A=A8=E9=A2=84=E8=A7=88=E6=94=B9=E7=94=A8?= =?UTF-8?q?=E9=9B=AA=E7=A2=A7=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/2026-10-22-sprite-scrub-preview.md | 46 ++ docs/plans/PROGRESS.md | 52 ++ lib/core/data/models/drama.dart | 37 +- lib/core/data/models/play_ticket.dart | 8 + .../sprite_thumbnail_asset_loader.dart | 128 +++++ .../data/services/sprite_thumbnail_axis.dart | 461 ++++++++++++++++++ lib/pages/player/player_logic.dart | 2 + lib/pages/player/player_view.dart | 21 +- .../player/widgets/home_feed_overlay.dart | 5 + .../widgets/home_feed_overlay_parts.dart | 6 + lib/pages/player/widgets/player_overlay.dart | 2 + .../player/widgets/player_overlay_models.dart | 3 + .../player/widgets/scrub_preview_card.dart | 161 +++++- .../player/widgets/seek_progress_bar.dart | 7 + test/playback/sprite_thumbnail_axis_test.dart | 233 +++++++++ test/player/scrub_preview_card_test.dart | 171 +++++++ 16 files changed, 1312 insertions(+), 31 deletions(-) create mode 100644 docs/plans/2026-10-22-sprite-scrub-preview.md create mode 100644 lib/core/data/services/sprite_thumbnail_asset_loader.dart create mode 100644 lib/core/data/services/sprite_thumbnail_axis.dart create mode 100644 test/playback/sprite_thumbnail_axis_test.dart create mode 100644 test/player/scrub_preview_card_test.dart diff --git a/docs/plans/2026-10-22-sprite-scrub-preview.md b/docs/plans/2026-10-22-sprite-scrub-preview.md new file mode 100644 index 0000000..860a2a2 --- /dev/null +++ b/docs/plans/2026-10-22-sprite-scrub-preview.md @@ -0,0 +1,46 @@ +# 雪碧图 VTT 拖动预览 + +## 目标 + +将进度条拖动预览主链路改为服务端雪碧图 + WebVTT 局部裁剪,原生取帧只作为降级: + +1. `Episode` 与 `PlayTicket` 解析 `thumbnailsUrl`。 +2. VTT 只拉取/解析一次,内存缓存 10 分钟。 +3. cue 使用起止时间 + 相对 sprite 文件名 + `xywh`,按 position 二分选择。 +4. sprite 图最多缓存 2 张 `ui.Image`,拖动帧只绘制 sourceRect。 +5. 无 VTT、空轴或图片失败时回退现有 `PlaybackThumbnailService`,再回退封面。 + +## 性能红线 + +- 拖动帧禁止网络请求与图片解码,只允许 cue 二分查找和 `RawImage.sourceRect` 绘制。 +- VTT 每个 URL 只解析一次;sprite 解码不在 build/build paint 中执行。 +- sprite ui.Image LRU 上限 2 张,页面 dispose 清空。 +- 解码 sprite 最长边超过 2048px 时按比例缩小后常驻。 +- 签名 `thumbnailsUrl` 不写入任何持久化缓存。 + +## 实施 + +- 新增 `SpriteThumbnailAxisService`: + - Dio 拉取 VTT,403 清缓存后重试一次; + - 解析和选 cue 为纯函数,解析失败返回空轴; + - 播放器就绪时预取当前 cue 及相邻 sprite; + - LRU2 sprite 缓存和 10 分钟 VTT 缓存。 +- `PlayerPage` 持有页面级服务,按当前选中集预取,dispose 时清空。 +- `ScrubPreviewCard` 优先读取已缓存 sprite frame,显示 `RawImage` 裁剪; + VTT 未就绪时暂显封面,轴为空/不可用后启动本地取帧,全部失败保留封面。 +- 首页 feed 和全屏播放器都把 `thumbnailsUrl` 传到预览卡片。 +- 不新增 pub 依赖、不改后端契约。 + +## 测试 + +- VTT 解析:时间、xywh、多 sprite 文件。 +- cue 选择:命中、越界最近邻。 +- sprite LRU 最多 2 张。 +- 回退状态判定。 +- Widget:sprite 裁剪且不触发本地取帧;无 URL 回退本地;全部失败显示封面。 +- 门禁:`dart analyze lib test`、`flutter test --no-pub`。 + +## 风险 + +- 真实网络 VTT/sprite 可访问性和签名过期由 PM 真机验收。 +- 本次不启动服务、不操作真机、不修改 Web/FruitServer。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 7b0e427..6fa020d 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -479,3 +479,55 @@ $ flutter test --no-pub 消失均通过;全量静态分析零告警,606 项测试全过。性能红线保持:主线程不做 图片解码/取帧、原生并发 1、预览最大宽 240px、缓存最多 24 帧、不增依赖、 不改后端。原生取帧真实画面与设备性能由 PM 真机验收。 + +## 2026-10-22 雪碧图 VTT 拖动预览 + +- 已从 `develop`(`89a94f8`)创建 `feature/sprite-scrub-preview`。 +- 计划文档:`docs/plans/2026-10-22-sprite-scrub-preview.md`。 +- 性能红线:拖动帧不发起网络/解码,只做 cue 二分与 sourceRect 绘制; + VTT 缓存 10 分钟且只解析一次;sprite ui.Image 最多 2 张;超过 2048px + 按比例缩放;签名地址不进入持久化缓存。 +- 回退链:雪碧图 -> 现有本地原生取帧 -> 集封面。 + +```text +$ git status --short --branch +## feature/sprite-scrub-preview + +$ git rev-parse --short HEAD +89a94f8 +``` + +解读:工作树从指定最新 develop 创建,开工前无未提交改动;主工作区现有 +`pubspec.lock` 未复制、未修改。 + +### 实现结果 + +- `Episode` / `PlayTicket` 增加 nullable `thumbnailsUrl`;票据写回和详情来源都保留, + 本地下载只覆盖视频地址,不丢 VTT;签名地址未进入持久化缓存。 +- 新增 `SpriteThumbnailAxisService`:Dio VTT 拉取、10 分钟缓存、403 清缓存重试一次、 + cue 解析与二分、sprite `ui.Image` LRU2、2048px 解码上限。 +- `PlayerPage` 在选中集就绪时预取当前及相邻 sprite;dispose 释放 VTT 与图片。 +- `ScrubPreviewCard` 优先用 `CustomPaint.drawImageRect` 绘制 `sourceRect`; + VTT 未就绪显示封面,轴为空/加载失败回退本地原生取帧,再失败保留封面。 +- 拖动帧只做缓存查询、二分、矩形裁剪与 drawImageRect,不发起网络或图片解码。 + +执行命令与原始输出: + +```text +$ flutter test --no-pub test/playback/sprite_thumbnail_axis_test.dart test/player/scrub_preview_card_test.dart +00:00 +11: All tests passed! + +$ flutter test --no-pub test/playback/seek_progress_bar_test.dart test/home/home_preview_layout_test.dart test/player/player_fullscreen_test.dart +00:10 +55: All tests passed! + +$ dart analyze lib test +Analyzing lib, test... +No issues found! + +$ flutter test --no-pub +00:54 +617: All tests passed! +``` + +解读:VTT 时间/xywh/多 sprite、越界最近 cue、LRU2、403 重试、10 分钟 TTL、 +回退链、sprite 零本地取帧、无 URL 本地回退和最终封面兜底均通过;全量静态分析 +零告警,617 项测试全过。`pubspec.lock` 已还原,未启停服务,未操作真机。 diff --git a/lib/core/data/models/drama.dart b/lib/core/data/models/drama.dart index a2f024f..8b2f1b5 100644 --- a/lib/core/data/models/drama.dart +++ b/lib/core/data/models/drama.dart @@ -17,6 +17,7 @@ class Episode { this.locked = false, this.likeCount, this.commentCount, + this.thumbnailsUrl, }); final String id; @@ -32,19 +33,25 @@ class Episode { final bool locked; final int? likeCount; final int? commentCount; + final String? thumbnailsUrl; - 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, - ); + Episode withVideoUrl( + String url, { + int? likeCount, + int? commentCount, + String? thumbnailsUrl, + }) => Episode( + id: id, + number: number, + title: title, + isFree: isFree, + videoUrl: url, + durationSec: durationSec, + locked: locked, + likeCount: likeCount ?? this.likeCount, + commentCount: commentCount ?? this.commentCount, + thumbnailsUrl: thumbnailsUrl ?? this.thumbnailsUrl, + ); factory Episode.fromJson(Map json) { return Episode( @@ -57,6 +64,7 @@ class Episode { videoUrl: json['playUrl'] as String? ?? '', likeCount: _optionalInt(json['likeCount']), commentCount: _optionalInt(json['commentCount']), + thumbnailsUrl: _optionalString(json['thumbnailsUrl']), ); } } @@ -159,3 +167,8 @@ class Drama { } int? _optionalInt(dynamic value) => value == null ? null : jsonToInt(value); + +String? _optionalString(dynamic value) { + final text = value is String ? value.trim() : ''; + return text.isEmpty ? null : text; +} diff --git a/lib/core/data/models/play_ticket.dart b/lib/core/data/models/play_ticket.dart index b613e05..2a5ad1d 100644 --- a/lib/core/data/models/play_ticket.dart +++ b/lib/core/data/models/play_ticket.dart @@ -14,6 +14,7 @@ class PlayTicket { this.unlockCoin = 0, this.previewSeconds = 0, this.qualities = const [], + this.thumbnailsUrl, this.likeCount, this.commentCount, }); @@ -40,6 +41,7 @@ class PlayTicket { /// 后端下发的清晰度档位(如 AUTO / 720P / 1080P),HLS 由播放器自动切换。 final List qualities; + final String? thumbnailsUrl; final int? likeCount; final int? commentCount; @@ -62,6 +64,7 @@ class PlayTicket { .whereType() .where((quality) => quality.isNotEmpty) .toList(), + thumbnailsUrl: _optionalString(json['thumbnailsUrl']), likeCount: json['likeCount'] == null ? null : jsonToInt(json['likeCount']), @@ -71,3 +74,8 @@ class PlayTicket { ); } } + +String? _optionalString(dynamic value) { + final text = value is String ? value.trim() : ''; + return text.isEmpty ? null : text; +} diff --git a/lib/core/data/services/sprite_thumbnail_asset_loader.dart b/lib/core/data/services/sprite_thumbnail_asset_loader.dart new file mode 100644 index 0000000..c82648e --- /dev/null +++ b/lib/core/data/services/sprite_thumbnail_asset_loader.dart @@ -0,0 +1,128 @@ +// Author: fruit.com.cn + +import 'dart:convert'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:dio/dio.dart'; + +import '../../config/app_config.dart'; + +const int spriteThumbnailDecodeMaxDimension = 2048; + +/// VTT 资源拒绝访问,服务层据此清缓存并重试一次。 +class SpriteThumbnailForbiddenException implements Exception { + const SpriteThumbnailForbiddenException(); +} + +/// 已解码 sprite 与原图尺寸;cue 坐标会按解码缩放比例映射。 +class SpriteThumbnailDecodedImage { + const SpriteThumbnailDecodedImage({ + required this.image, + required this.originalWidth, + required this.originalHeight, + }); + + final ui.Image image; + final int originalWidth; + final int originalHeight; +} + +/// 可注入的资源加载接口,测试无需真实网络或图片。 +abstract interface class SpriteThumbnailAssetLoader { + Future loadVtt(Uri uri); + + Future loadSprite(Uri uri); +} + +/// 默认通过项目现有 Dio 拉取 VTT 和 sprite 图片。 +class DioSpriteThumbnailAssetLoader implements SpriteThumbnailAssetLoader { + DioSpriteThumbnailAssetLoader({Dio? dio}) + : _dio = + dio ?? + Dio( + BaseOptions( + connectTimeout: AppConfig.requestTimeout, + receiveTimeout: AppConfig.requestTimeout, + validateStatus: (_) => true, + ), + ); + + final Dio _dio; + + @override + Future loadVtt(Uri uri) async { + final response = await _request(uri); + if (response?.statusCode == 403) { + throw const SpriteThumbnailForbiddenException(); + } + if (response == null || !_isSuccess(response.statusCode)) return null; + final data = response.data; + if (data is! List) return null; + return utf8.decode(data, allowMalformed: true); + } + + @override + Future loadSprite(Uri uri) async { + final response = await _request(uri); + if (response == null || !_isSuccess(response.statusCode)) return null; + final data = response.data; + if (data is! List) return null; + return _decode(Uint8List.fromList(data)); + } + + Future>?> _request(Uri uri) async { + try { + return await _dio.get>( + uri.toString(), + options: Options(responseType: ResponseType.bytes), + ); + } catch (_) { + return null; + } + } + + bool _isSuccess(int? status) => + status != null && status >= 200 && status < 300; + + Future _decode(Uint8List bytes) async { + ui.ImmutableBuffer? buffer; + ui.ImageDescriptor? descriptor; + ui.Codec? codec; + try { + buffer = await ui.ImmutableBuffer.fromUint8List(bytes); + descriptor = await ui.ImageDescriptor.encoded(buffer); + final originalWidth = descriptor.width; + final originalHeight = descriptor.height; + final target = _targetSize(originalWidth, originalHeight); + codec = await descriptor.instantiateCodec( + targetWidth: target.$1, + targetHeight: target.$2, + ); + final frame = await codec.getNextFrame(); + return SpriteThumbnailDecodedImage( + image: frame.image, + originalWidth: originalWidth, + originalHeight: originalHeight, + ); + } catch (_) { + return null; + } finally { + codec?.dispose(); + descriptor?.dispose(); + buffer?.dispose(); + } + } + + (int?, int?) _targetSize(int width, int height) { + final longest = width > height ? width : height; + if (longest <= spriteThumbnailDecodeMaxDimension) { + return (null, null); + } + final scale = spriteThumbnailDecodeMaxDimension / longest; + return ( + (width * scale).round().clamp(1, spriteThumbnailDecodeMaxDimension), + (height * scale).round().clamp(1, spriteThumbnailDecodeMaxDimension), + ); + } +} diff --git a/lib/core/data/services/sprite_thumbnail_axis.dart b/lib/core/data/services/sprite_thumbnail_axis.dart new file mode 100644 index 0000000..3a63401 --- /dev/null +++ b/lib/core/data/services/sprite_thumbnail_axis.dart @@ -0,0 +1,461 @@ +// Author: fruit.com.cn + +import 'dart:collection'; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; + +import '../../config/app_config.dart'; +import 'sprite_thumbnail_asset_loader.dart'; + +const Duration spriteVttCacheTtl = Duration(minutes: 10); +const int spriteImageCacheCapacity = 2; + +/// 单个 VTT cue 对应的 sprite 裁剪区域。 +class SpriteThumbnailCue { + const SpriteThumbnailCue({ + required this.start, + required this.end, + required this.spriteFile, + required this.sourceRect, + }); + + final Duration start; + final Duration end; + final String spriteFile; + final ui.Rect sourceRect; +} + +/// 已解析的 VTT 时间轴。 +class SpriteThumbnailAxis { + const SpriteThumbnailAxis({required this.vttUrl, required this.cues}); + + final String vttUrl; + final List cues; + + factory SpriteThumbnailAxis.empty(String vttUrl) => + SpriteThumbnailAxis(vttUrl: vttUrl, cues: const []); + + Uri resolveSpriteUri(String spriteFile) => + Uri.parse(vttUrl).resolve(spriteFile); +} + +/// 可直接交给 RawImage.sourceRect 的裁剪帧。 +class SpriteThumbnailFrame { + const SpriteThumbnailFrame({ + required this.image, + required this.sourceRect, + required this.spriteUrl, + required this.cue, + }); + + final ui.Image image; + final ui.Rect sourceRect; + final String spriteUrl; + final SpriteThumbnailCue cue; +} + +/// 预览卡片当前应展示的回退阶段。 +enum ScrubPreviewStage { sprite, waitingForAxis, localFrame, cover } + +/// 预览回退链的纯判定函数。 +ScrubPreviewStage decideScrubPreviewStage({ + required bool hasThumbnailsUrl, + required bool hasSpriteFrame, + required bool hasAxis, + required bool hasLocalFrame, +}) { + if (hasSpriteFrame) return ScrubPreviewStage.sprite; + if (hasThumbnailsUrl && !hasAxis) { + return ScrubPreviewStage.waitingForAxis; + } + if (hasLocalFrame) return ScrubPreviewStage.localFrame; + return ScrubPreviewStage.cover; +} + +/// 解析 WebVTT;任意异常都收敛为空轴。 +SpriteThumbnailAxis parseSpriteThumbnailVtt( + String source, { + required String vttUrl, +}) { + final cues = []; + try { + for (final block + in source.replaceAll('\r\n', '\n').split(RegExp(r'\n\s*\n'))) { + final cue = _parseCueBlock(block); + if (cue != null) cues.add(cue); + } + } catch (_) { + return SpriteThumbnailAxis.empty(vttUrl); + } + cues.sort((left, right) => left.start.compareTo(right.start)); + return SpriteThumbnailAxis(vttUrl: vttUrl, cues: cues); +} + +/// 按 position 二分选择 cue;边界外选择最近 cue。 +SpriteThumbnailCue? selectSpriteThumbnailCue( + List cues, + Duration position, +) { + if (cues.isEmpty) return null; + var low = 0; + var high = cues.length; + while (low < high) { + final middle = (low + high) >> 1; + if (cues[middle].start <= position) { + low = middle + 1; + } else { + high = middle; + } + } + if (low == 0) return cues.first; + final previous = cues[low - 1]; + if (low == cues.length || position <= previous.end) return previous; + final next = cues[low]; + final previousDistance = position - previous.end; + final nextDistance = next.start - position; + return previousDistance <= nextDistance ? previous : next; +} + +/// 只读缓存接口,便于 widget 测试注入。 +abstract interface class SpriteThumbnailAxisSource implements Listenable { + SpriteThumbnailFrame? cachedFrameAt(String? thumbnailsUrl, Duration position); + + bool hasAxis(String? thumbnailsUrl); +} + +/// VTT 时间轴、cue 二分与 sprite ui.Image LRU 缓存。 +class SpriteThumbnailAxisService extends ChangeNotifier + implements SpriteThumbnailAxisSource { + SpriteThumbnailAxisService({ + SpriteThumbnailAssetLoader? loader, + this.cacheTtl = spriteVttCacheTtl, + DateTime Function()? now, + }) : _loader = loader ?? DioSpriteThumbnailAssetLoader(), + _now = now ?? DateTime.now; + + final SpriteThumbnailAssetLoader _loader; + final Duration cacheTtl; + final DateTime Function() _now; + final Map _axisCache = {}; + final Map> _axisRequests = {}; + final LinkedHashMap _spriteCache = LinkedHashMap(); + final Map> _spriteRequests = {}; + final Set _failedSprites = {}; + String? _activeAxisUrl; + int _axisGeneration = 0; + bool _disposed = false; + + @visibleForTesting + int get spriteCacheSize => _spriteCache.length; + + @visibleForTesting + List get spriteCacheKeys => _spriteCache.keys.toList(); + + /// 播放器就绪时预取当前 cue 与相邻 sprite,拖动帧不触发网络或解码。 + Future prepareAt(String? thumbnailsUrl, Duration position) async { + final url = _normalizeUrl(thumbnailsUrl); + if (_disposed || url == null) return; + _activateAxis(url); + final axis = await _loadAxis(url); + if (_disposed || _activeAxisUrl != url) return; + for (final cue in _prefetchCues(axis, position)) { + await _ensureSprite(axis, cue); + } + } + + @override + bool hasAxis(String? thumbnailsUrl) { + final url = _normalizeUrl(thumbnailsUrl); + if (url == null || url != _activeAxisUrl) return false; + return _readAxis(url) != null; + } + + @override + SpriteThumbnailFrame? cachedFrameAt( + String? thumbnailsUrl, + Duration position, + ) { + final url = _normalizeUrl(thumbnailsUrl); + if (url == null || url != _activeAxisUrl) return null; + final axis = _readAxis(url); + if (axis == null) return null; + final cue = selectSpriteThumbnailCue(axis.cues, position); + if (cue == null) return null; + final cached = _readSprite(cue.spriteFile); + if (cached == null) return null; + final sourceRect = _scaledSourceRect(cached, cue.sourceRect); + if (sourceRect == null) return null; + return SpriteThumbnailFrame( + image: cached.decoded.image, + sourceRect: sourceRect, + spriteUrl: axis.resolveSpriteUri(cue.spriteFile).toString(), + cue: cue, + ); + } + + /// 页面销毁时释放全部 VTT 与 ui.Image 内存。 + @override + void dispose() { + if (_disposed) return; + _disposed = true; + _clearAll(); + super.dispose(); + } + + void _activateAxis(String url) { + if (_activeAxisUrl == url) return; + _activeAxisUrl = url; + _axisGeneration++; + _clearSprites(); + } + + Future _loadAxis(String url) { + final cached = _readAxis(url); + if (cached != null) return Future.value(cached); + final existing = _axisRequests[url]; + if (existing != null) return existing; + final request = _fetchAxis(url); + _axisRequests[url] = request; + return request.whenComplete(() { + _axisRequests.remove(url); + }); + } + + Future _fetchAxis(String url) async { + for (var attempt = 0; attempt < 2; attempt++) { + try { + final text = await _loader.loadVtt(Uri.parse(url)); + final axis = text == null + ? SpriteThumbnailAxis.empty(url) + : parseSpriteThumbnailVtt(text, vttUrl: url); + if (_disposed) return SpriteThumbnailAxis.empty(url); + _cacheAxis(axis); + notifyListeners(); + return axis; + } on SpriteThumbnailForbiddenException { + if (_disposed) return SpriteThumbnailAxis.empty(url); + _axisCache.remove(url); + if (_activeAxisUrl == url) _clearSprites(); + } catch (_) { + break; + } + } + final empty = SpriteThumbnailAxis.empty(url); + if (_disposed) return empty; + _cacheAxis(empty); + notifyListeners(); + return empty; + } + + void _cacheAxis(SpriteThumbnailAxis axis) { + _axisCache[axis.vttUrl] = _CachedAxis( + axis: axis, + expiresAt: _now().add(cacheTtl), + ); + } + + SpriteThumbnailAxis? _readAxis(String url) { + final cached = _axisCache[url]; + if (cached == null) return null; + if (cached.expiresAt.isAfter(_now())) return cached.axis; + _axisCache.remove(url); + return null; + } + + List _prefetchCues( + SpriteThumbnailAxis axis, + Duration position, + ) { + final current = selectSpriteThumbnailCue(axis.cues, position); + if (current == null) return const []; + final index = axis.cues.indexOf(current); + final candidates = [current]; + for (final direction in const [1, -1]) { + for (var step = 1; step < axis.cues.length; step++) { + final neighborIndex = index + direction * step; + if (neighborIndex < 0 || neighborIndex >= axis.cues.length) break; + final neighbor = axis.cues[neighborIndex]; + if (neighbor.spriteFile != current.spriteFile) { + candidates.add(neighbor); + break; + } + } + if (candidates.length > 1) break; + } + return candidates; + } + + Future<_CachedSprite?> _ensureSprite( + SpriteThumbnailAxis axis, + SpriteThumbnailCue cue, + ) { + final cached = _readSprite(cue.spriteFile); + if (cached != null) return Future.value(cached); + if (_failedSprites.contains(cue.spriteFile)) return Future.value(); + final existing = _spriteRequests[cue.spriteFile]; + if (existing != null) return existing; + final generation = _axisGeneration; + final request = _loadSprite(axis, cue, generation); + _spriteRequests[cue.spriteFile] = request; + return request.whenComplete(() { + _spriteRequests.remove(cue.spriteFile); + }); + } + + Future<_CachedSprite?> _loadSprite( + SpriteThumbnailAxis axis, + SpriteThumbnailCue cue, + int generation, + ) async { + SpriteThumbnailDecodedImage? decoded; + try { + decoded = await _loader.loadSprite(axis.resolveSpriteUri(cue.spriteFile)); + } catch (_) { + decoded = null; + } + if (decoded == null) { + _failedSprites.add(cue.spriteFile); + if (!_disposed) notifyListeners(); + return null; + } + if (_disposed || generation != _axisGeneration) { + decoded.image.dispose(); + return null; + } + final cached = _CachedSprite(decoded); + _putSprite(cue.spriteFile, cached); + notifyListeners(); + return cached; + } + + _CachedSprite? _readSprite(String key) { + final value = _spriteCache.remove(key); + if (value == null) return null; + _spriteCache[key] = value; + return value; + } + + void _putSprite(String key, _CachedSprite value) { + _spriteCache.remove(key)?.decoded.image.dispose(); + _spriteCache[key] = value; + while (_spriteCache.length > spriteImageCacheCapacity) { + final oldest = _spriteCache.remove(_spriteCache.keys.first); + oldest?.decoded.image.dispose(); + } + } + + ui.Rect? _scaledSourceRect(_CachedSprite cached, ui.Rect source) { + final scaleX = cached.decoded.image.width / cached.decoded.originalWidth; + final scaleY = cached.decoded.image.height / cached.decoded.originalHeight; + final scaled = + ui.Rect.fromLTWH( + source.left * scaleX, + source.top * scaleY, + source.width * scaleX, + source.height * scaleY, + ).intersect( + ui.Rect.fromLTWH( + 0, + 0, + cached.decoded.image.width.toDouble(), + cached.decoded.image.height.toDouble(), + ), + ); + return scaled.isEmpty ? null : scaled; + } + + void _clearSprites() { + for (final cached in _spriteCache.values) { + cached.decoded.image.dispose(); + } + _spriteCache.clear(); + _spriteRequests.clear(); + _failedSprites.clear(); + } + + void _clearAll() { + _axisGeneration++; + _activeAxisUrl = null; + _axisCache.clear(); + _axisRequests.clear(); + _clearSprites(); + } + + String? _normalizeUrl(String? url) { + final trimmed = url?.trim() ?? ''; + if (trimmed.isEmpty) return null; + return AppConfig.mediaUrl(trimmed); + } +} + +final RegExp _timingPattern = RegExp( + r'^(\d{2,}:\d{2}:\d{2}\.\d{3})\s*-->\s*(\d{2,}:\d{2}:\d{2}\.\d{3})', +); +final RegExp _cueLinkPattern = RegExp(r'^(\S+?)#xywh=(.+)$'); +final RegExp _xywhPattern = RegExp(r'^(\d+),(\d+),(\d+),(\d+)$'); + +SpriteThumbnailCue? _parseCueBlock(String block) { + final lines = block + .split('\n') + .map((line) => line.trim()) + .where((line) => line.isNotEmpty) + .toList(); + final timingIndex = lines.indexWhere(_timingPattern.hasMatch); + if (timingIndex < 0 || timingIndex + 1 >= lines.length) return null; + final timing = _timingPattern.firstMatch(lines[timingIndex]); + if (timing == null) return null; + final link = _cueLinkPattern.firstMatch(lines[timingIndex + 1]); + if (link == null) return null; + final start = _parseTimestamp(timing.group(1)!); + final end = _parseTimestamp(timing.group(2)!); + if (start == null || end == null || end <= start) return null; + final segments = Uri.tryParse(link.group(1)!)?.pathSegments; + final file = segments == null || segments.isEmpty ? null : segments.last; + final xywh = _xywhPattern.firstMatch(link.group(2)!); + if (file == null || file.isEmpty || xywh == null) return null; + final rect = ui.Rect.fromLTWH( + double.parse(xywh.group(1)!), + double.parse(xywh.group(2)!), + double.parse(xywh.group(3)!), + double.parse(xywh.group(4)!), + ); + if (rect.isEmpty) return null; + return SpriteThumbnailCue( + start: start, + end: end, + spriteFile: file, + sourceRect: rect, + ); +} + +Duration? _parseTimestamp(String value) { + final parts = value.split(':'); + if (parts.length != 3) return null; + final secondsParts = parts[2].split('.'); + if (secondsParts.length != 2) return null; + final hours = int.tryParse(parts[0]); + final minutes = int.tryParse(parts[1]); + final seconds = int.tryParse(secondsParts[0]); + final milliseconds = int.tryParse(secondsParts[1]); + if ([hours, minutes, seconds, milliseconds].contains(null)) return null; + return Duration( + hours: hours!, + minutes: minutes!, + seconds: seconds!, + milliseconds: milliseconds!, + ); +} + +class _CachedAxis { + const _CachedAxis({required this.axis, required this.expiresAt}); + + final SpriteThumbnailAxis axis; + final DateTime expiresAt; +} + +class _CachedSprite { + const _CachedSprite(this.decoded); + + final SpriteThumbnailDecodedImage decoded; +} diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 27046f7..15c1260 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -149,6 +149,7 @@ class PlayerLogic extends ChangeNotifier { Episode displayEpisode(Drama drama, Episode fallback) { final number = pickedEpisode[drama.id]; if (number == null) return fallback; + if (fallback.number == number) return fallback; for (final episode in drama.episodes) { if (episode.number == number) return episode; } @@ -438,6 +439,7 @@ class PlayerLogic extends ChangeNotifier { ticket.playUrl, likeCount: ticket.likeCount, commentCount: ticket.commentCount, + thumbnailsUrl: ticket.thumbnailsUrl, ); return true; } diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 1f6d8e7..db69db6 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -14,6 +14,7 @@ import '../../core/data/services/download_controller.dart'; import '../../core/data/services/interaction_controller.dart'; import '../../core/data/services/playback_position_cache.dart'; import '../../core/data/services/playback_session.dart'; +import '../../core/data/services/sprite_thumbnail_axis.dart'; import '../../core/data/services/playback_thumbnail_service.dart'; import '../../core/data/services/video_player_port.dart'; import '../../core/data/services/wallet_controller.dart'; @@ -106,6 +107,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver, RouteAware { late final PlayerLogic logic; late final PlaybackThumbnailService _thumbnailService; + late final SpriteThumbnailAxisService _spriteAxisService; PageRoute? _subscribedRoute; bool _chromeVisible = true; double _playbackSpeed = 1.0; @@ -131,6 +133,7 @@ class _PlayerPageState extends State _thumbnailService = PlaybackThumbnailService( sourceResolver: _resolveThumbnailSource, ); + _spriteAxisService = SpriteThumbnailAxisService(); WidgetsBinding.instance.addObserver(this); logic.start( factory: context.read(), @@ -247,6 +250,7 @@ class _PlayerPageState extends State void dispose() { WidgetsBinding.instance.removeObserver(this); appRouteObserver.unsubscribe(this); + _spriteAxisService.dispose(); _thumbnailService.dispose(); logic.dispose(); super.dispose(); @@ -422,9 +426,12 @@ class _PlayerPageState extends State final l10n = AppLocalizations.of(context)!; final entry = logic.queue.entryAt(index); final drama = entry.drama; - final episode = widget.homeMode - ? logic.displayEpisode(drama, entry.episode) + final sessionEpisode = index >= 0 && index < logic.sessionEpisodes.length + ? logic.sessionEpisodes[index] : entry.episode; + final episode = widget.homeMode + ? logic.displayEpisode(drama, sessionEpisode) + : sessionEpisode; final selected = index == logic.session.currentIndex; // Selection changes before the prepared slot is promoted. Use its controller // throughout the handoff instead of briefly falling back to a placeholder. @@ -435,6 +442,14 @@ class _PlayerPageState extends State final ready = videoPort?.controller.value.isInitialized ?? false; final theater = logic.pip || logic.pipPreparing; final locked = logic.session.isLocked || episode.locked; + if (selected && ready) { + unawaited( + _spriteAxisService.prepareAt( + episode.thumbnailsUrl, + videoPort!.controller.value.position, + ), + ); + } return Stack( fit: StackFit.expand, @@ -606,6 +621,7 @@ class _PlayerPageState extends State ? (position) => logic.seekTo(context, videoPort!, position) : null, thumbnailService: _thumbnailService, + spriteAxisService: _spriteAxisService, ) else PlayerPlaybackOverlay( @@ -629,6 +645,7 @@ class _PlayerPageState extends State totalEpisodes: logic.visibleIndices.length, boosting: _boosting, thumbnailService: _thumbnailService, + spriteAxisService: _spriteAxisService, ), actions: PlayerOverlayActions( onBack: widget.embedded diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index e36a961..5dbf276 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -8,6 +8,7 @@ import 'package:video_player/video_player.dart'; import '../../../core/data/models/drama.dart'; import '../../../core/data/services/playback_thumbnail_service.dart'; +import '../../../core/data/services/sprite_thumbnail_axis.dart'; import '../../../core/formatting/count_format.dart'; import '../../../core/formatting/localized_content.dart'; import '../../../core/theme/app_colors.dart'; @@ -63,6 +64,7 @@ class HomeFeedOverlay extends StatefulWidget { this.onScrubStart, this.onSeek, this.thumbnailService, + this.spriteAxisService, }); final AppLocalizations l10n; @@ -91,6 +93,7 @@ class HomeFeedOverlay extends StatefulWidget { final VoidCallback? onScrubStart; final ValueChanged? onSeek; final PlaybackThumbnailService? thumbnailService; + final SpriteThumbnailAxisService? spriteAxisService; @override State createState() => _HomeFeedOverlayState(); @@ -405,6 +408,8 @@ class _HomeFeedOverlayState extends State { onScrubStart: widget.onScrubStart, onSeek: widget.onSeek, thumbnailService: widget.thumbnailService, + spriteAxisService: widget.spriteAxisService, + thumbnailsUrl: widget.episode.thumbnailsUrl, episodeId: widget.episode.id, posterUrl: widget.drama.posterUrl, ), diff --git a/lib/pages/player/widgets/home_feed_overlay_parts.dart b/lib/pages/player/widgets/home_feed_overlay_parts.dart index cd08b58..bbf57a2 100644 --- a/lib/pages/player/widgets/home_feed_overlay_parts.dart +++ b/lib/pages/player/widgets/home_feed_overlay_parts.dart @@ -18,6 +18,8 @@ class _FeedProgressBar extends StatefulWidget { this.onScrubStart, this.onSeek, this.thumbnailService, + this.spriteAxisService, + this.thumbnailsUrl, required this.episodeId, required this.posterUrl, }); @@ -29,6 +31,8 @@ class _FeedProgressBar extends StatefulWidget { final VoidCallback? onScrubStart; final ValueChanged? onSeek; final PlaybackThumbnailService? thumbnailService; + final SpriteThumbnailAxisService? spriteAxisService; + final String? thumbnailsUrl; final String episodeId; final String posterUrl; @@ -148,6 +152,8 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { bottom: widget.hitHeight + 8, child: ScrubPreviewCard( service: widget.thumbnailService!, + spriteAxisService: widget.spriteAxisService, + thumbnailsUrl: widget.thumbnailsUrl, episodeId: widget.episodeId, posterUrl: widget.posterUrl, position: previewPosition, diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index 79b7a1b..a401acf 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -266,6 +266,8 @@ class PlayerPlaybackOverlay extends StatelessWidget { compact: true, locked: data.locked, thumbnailService: data.thumbnailService, + spriteAxisService: data.spriteAxisService, + thumbnailsUrl: data.episode.thumbnailsUrl, episodeId: data.episode.id, posterUrl: data.drama.posterUrl, inactiveTrackColor: Colors.white24, diff --git a/lib/pages/player/widgets/player_overlay_models.dart b/lib/pages/player/widgets/player_overlay_models.dart index 75d19df..b811d4c 100644 --- a/lib/pages/player/widgets/player_overlay_models.dart +++ b/lib/pages/player/widgets/player_overlay_models.dart @@ -5,6 +5,7 @@ import 'package:video_player/video_player.dart'; import '../../../core/data/models/drama.dart'; import '../../../core/data/services/playback_thumbnail_service.dart'; +import '../../../core/data/services/sprite_thumbnail_axis.dart'; import '../../../l10n/app_localizations.dart'; /// 播放浮层展示数据。 @@ -26,6 +27,7 @@ class PlayerOverlayData { required this.boosting, required this.locked, this.thumbnailService, + this.spriteAxisService, this.authorNote = '', }); @@ -73,6 +75,7 @@ class PlayerOverlayData { /// 拖动进度时按当前集和时间点取帧。 final PlaybackThumbnailService? thumbnailService; + final SpriteThumbnailAxisService? spriteAxisService; /// 作者声明;没有后端字段时为空。 final String authorNote; diff --git a/lib/pages/player/widgets/scrub_preview_card.dart b/lib/pages/player/widgets/scrub_preview_card.dart index e38f8f1..89472a1 100644 --- a/lib/pages/player/widgets/scrub_preview_card.dart +++ b/lib/pages/player/widgets/scrub_preview_card.dart @@ -2,10 +2,12 @@ import 'dart:async'; import 'dart:typed_data'; +import 'dart:ui' as ui; import 'package:flutter/material.dart'; import '../../../core/data/services/playback_thumbnail_service.dart'; +import '../../../core/data/services/sprite_thumbnail_axis.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/widgets/remote_image.dart'; @@ -36,9 +38,13 @@ class ScrubPreviewCard extends StatefulWidget { required this.posterUrl, required this.position, required this.duration, + this.spriteAxisService, + this.thumbnailsUrl, }); final PlaybackThumbnailService service; + final SpriteThumbnailAxisSource? spriteAxisService; + final String? thumbnailsUrl; final String episodeId; final String posterUrl; final Duration position; @@ -49,50 +55,109 @@ class ScrubPreviewCard extends StatefulWidget { } class _ScrubPreviewCardState extends State { - ByteData? _frame; + ByteData? _fallbackFrame; int _requestSerial = 0; int? _requestedBucket; + String? _requestedEpisodeId; @override void initState() { super.initState(); - _requestFrame(); + widget.spriteAxisService?.addListener(_handleSpriteAxisChanged); + _syncFallbackSource(); } @override void didUpdateWidget(covariant ScrubPreviewCard oldWidget) { super.didUpdateWidget(oldWidget); - final bucket = playbackThumbnailBucket(widget.position); + if (oldWidget.spriteAxisService != widget.spriteAxisService) { + oldWidget.spriteAxisService?.removeListener(_handleSpriteAxisChanged); + widget.spriteAxisService?.addListener(_handleSpriteAxisChanged); + } if (oldWidget.service != widget.service || oldWidget.episodeId != widget.episodeId || - _requestedBucket != bucket) { - _frame = null; - _requestFrame(); - } else { - // 同 bucket 内继续拖动时不重复取帧,但会重置 120ms 预取停顿计时。 + oldWidget.thumbnailsUrl != widget.thumbnailsUrl) { + _resetFallback(); + _syncFallbackSource(); + return; + } + if (_requestedBucket != playbackThumbnailBucket(widget.position)) { + _fallbackFrame = null; + _syncFallbackSource(); + } else if (_fallbackFrame != null || _shouldUseLocalFallback()) { unawaited(widget.service.thumbnail(widget.episodeId, widget.position)); } } @override void dispose() { + widget.spriteAxisService?.removeListener(_handleSpriteAxisChanged); _requestSerial++; + widget.service.cancelPending(); super.dispose(); } - void _requestFrame() { + void _handleSpriteAxisChanged() { + if (!mounted) return; + if (_spriteFrame != null) { + widget.service.cancelPending(); + } + setState(() {}); + _syncFallbackSource(); + } + + SpriteThumbnailFrame? get _spriteFrame => widget.spriteAxisService + ?.cachedFrameAt(widget.thumbnailsUrl, widget.position); + + bool _shouldUseLocalFallback() { + if (_spriteFrame != null) return false; + final url = widget.thumbnailsUrl?.trim() ?? ''; + if (url.isEmpty || widget.spriteAxisService == null) return true; + return widget.spriteAxisService!.hasAxis(widget.thumbnailsUrl); + } + + void _syncFallbackSource() { + if (_spriteFrame != null) { + widget.service.cancelPending(); + return; + } + if (!_shouldUseLocalFallback()) return; + _requestFallbackFrame(); + } + + void _requestFallbackFrame() { final bucket = playbackThumbnailBucket(widget.position); + if (_requestedBucket == bucket && + _requestedEpisodeId == widget.episodeId && + (_fallbackFrame != null || _requestSerial > 0)) { + return; + } _requestedBucket = bucket; + _requestedEpisodeId = widget.episodeId; final serial = ++_requestSerial; - final future = widget.service.thumbnail(widget.episodeId, widget.position); - future.then((frame) { - if (!mounted || serial != _requestSerial) return; - setState(() => _frame = frame); + widget.service.thumbnail(widget.episodeId, widget.position).then((frame) { + if (!mounted || serial != _requestSerial || _spriteFrame != null) return; + setState(() => _fallbackFrame = frame); }); } + void _resetFallback() { + widget.service.cancelPending(); + _fallbackFrame = null; + _requestedBucket = null; + _requestedEpisodeId = null; + _requestSerial++; + } + @override Widget build(BuildContext context) { + final spriteFrame = _spriteFrame; + final stage = decideScrubPreviewStage( + hasThumbnailsUrl: (widget.thumbnailsUrl?.trim().isNotEmpty ?? false), + hasSpriteFrame: spriteFrame != null, + hasAxis: widget.spriteAxisService?.hasAxis(widget.thumbnailsUrl) ?? false, + hasLocalFrame: _fallbackFrame != null, + ); return Container( key: const Key('scrub-preview-card'), width: scrubPreviewWidth, @@ -135,11 +200,18 @@ class _ScrubPreviewCardState extends State { fit: BoxFit.cover, fallbackColor: AppColors.bar, ), - if (_frame case final frame?) + if (stage == ScrubPreviewStage.sprite && spriteFrame != null) + SpriteCropPreview( + key: const Key('scrub-preview-sprite'), + image: spriteFrame.image, + sourceRect: spriteFrame.sourceRect, + ), + if (stage == ScrubPreviewStage.localFrame && + _fallbackFrame != null) Image.memory( - frame.buffer.asUint8List( - frame.offsetInBytes, - frame.lengthInBytes, + _fallbackFrame!.buffer.asUint8List( + _fallbackFrame!.offsetInBytes, + _fallbackFrame!.lengthInBytes, ), key: const Key('scrub-preview-frame'), fit: BoxFit.cover, @@ -157,6 +229,61 @@ class _ScrubPreviewCardState extends State { } } +/// 对已解码 sprite 做 sourceRect 裁剪,不触发再次解码。 +class SpriteCropPreview extends StatelessWidget { + const SpriteCropPreview({ + super.key, + required this.image, + required this.sourceRect, + }); + + final ui.Image image; + final ui.Rect sourceRect; + + @override + Widget build(BuildContext context) => CustomPaint( + painter: _SpriteCropPainter(image: image, sourceRect: sourceRect), + ); +} + +class _SpriteCropPainter extends CustomPainter { + const _SpriteCropPainter({required this.image, required this.sourceRect}); + + final ui.Image image; + final ui.Rect sourceRect; + + @override + void paint(Canvas canvas, Size size) { + final fitted = applyBoxFit(BoxFit.cover, sourceRect.size, size); + final sourceOffset = Alignment.center.inscribe( + fitted.source, + Offset.zero & sourceRect.size, + ); + final source = Rect.fromLTWH( + sourceRect.left + sourceOffset.left, + sourceRect.top + sourceOffset.top, + fitted.source.width, + fitted.source.height, + ); + final destination = Alignment.center.inscribe( + fitted.destination, + Offset.zero & size, + ); + canvas.drawImageRect( + image, + source, + destination, + Paint() + ..filterQuality = FilterQuality.low + ..isAntiAlias = false, + ); + } + + @override + bool shouldRepaint(covariant _SpriteCropPainter oldDelegate) => + oldDelegate.image != image || oldDelegate.sourceRect != sourceRect; +} + String _format(Duration value) { final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0'); final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0'); diff --git a/lib/pages/player/widgets/seek_progress_bar.dart b/lib/pages/player/widgets/seek_progress_bar.dart index aa96945..ed0f53d 100644 --- a/lib/pages/player/widgets/seek_progress_bar.dart +++ b/lib/pages/player/widgets/seek_progress_bar.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; import '../../../core/data/services/playback_thumbnail_service.dart'; +import '../../../core/data/services/sprite_thumbnail_axis.dart'; import '../../../core/theme/app_colors.dart'; import 'loading_progress_track.dart'; import 'scrub_preview_card.dart'; @@ -35,8 +36,10 @@ class SeekProgressBar extends StatefulWidget { this.inactiveTrackColor = AppColors.border, this.locked = false, this.thumbnailService, + this.spriteAxisService, this.episodeId, this.posterUrl = '', + this.thumbnailsUrl, }); final ValueListenable? valueListenable; @@ -48,8 +51,10 @@ class SeekProgressBar extends StatefulWidget { final Color inactiveTrackColor; final bool locked; final PlaybackThumbnailService? thumbnailService; + final SpriteThumbnailAxisService? spriteAxisService; final String? episodeId; final String posterUrl; + final String? thumbnailsUrl; @override State createState() => _SeekProgressBarState(); @@ -299,6 +304,8 @@ class _SeekProgressBarState extends State { bottom: trackAreaHeight + 8, child: ScrubPreviewCard( service: widget.thumbnailService!, + spriteAxisService: widget.spriteAxisService, + thumbnailsUrl: widget.thumbnailsUrl, episodeId: widget.episodeId!, posterUrl: widget.posterUrl, position: previewPosition, diff --git a/test/playback/sprite_thumbnail_axis_test.dart b/test/playback/sprite_thumbnail_axis_test.dart new file mode 100644 index 0000000..663225b --- /dev/null +++ b/test/playback/sprite_thumbnail_axis_test.dart @@ -0,0 +1,233 @@ +// Author: fruit.com.cn + +import 'dart:ui' as ui; + +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/play_ticket.dart'; +import 'package:fruitshorts/core/data/services/sprite_thumbnail_asset_loader.dart'; +import 'package:fruitshorts/core/data/services/sprite_thumbnail_axis.dart'; + +const String _spriteVtt = ''' +WEBVTT + +00:00:00.000 --> 00:00:01.000 +sprite_001.jpg#xywh=0,0,160,90 + +00:00:01.000 --> 00:00:02.000 +sprite_001.jpg#xywh=160,0,160,90 + +00:00:02.000 --> 00:00:03.000 +sprite_002.jpg#xywh=0,0,160,90 + +00:00:03.000 --> 00:00:04.000 +sprite_003.jpg#xywh=0,0,160,90 +'''; + +void main() { + test('should_parse_cue_times_xywh_and_multiple_sprite_files', () { + final axis = parseSpriteThumbnailVtt( + _spriteVtt, + vttUrl: 'https://cdn.test/transcoded/thumbnails.vtt?e=1&s=x', + ); + + expect(axis.cues, hasLength(4)); + expect(axis.cues.first.start, Duration.zero); + expect(axis.cues.first.end, const Duration(seconds: 1)); + expect(axis.cues.first.spriteFile, 'sprite_001.jpg'); + expect(axis.cues.first.sourceRect, const Rect.fromLTWH(0, 0, 160, 90)); + expect(axis.cues[1].sourceRect, const Rect.fromLTWH(160, 0, 160, 90)); + expect(axis.cues[2].spriteFile, 'sprite_002.jpg'); + expect(axis.cues[3].spriteFile, 'sprite_003.jpg'); + expect( + axis.resolveSpriteUri(axis.cues[2].spriteFile).toString(), + 'https://cdn.test/transcoded/sprite_002.jpg', + ); + }); + + test('should_select_cue_and_use_nearest_when_position_is_out_of_range', () { + final cues = parseSpriteThumbnailVtt( + _spriteVtt, + vttUrl: 'https://cdn.test/thumbnails.vtt', + ).cues; + + expect( + selectSpriteThumbnailCue(cues, const Duration(milliseconds: 1500)), + same(cues[1]), + ); + expect( + selectSpriteThumbnailCue(cues, const Duration(seconds: -1)), + same(cues[0]), + ); + expect( + selectSpriteThumbnailCue(cues, const Duration(seconds: 9)), + same(cues[3]), + ); + }); + + test('should_return_empty_axis_when_vtt_cannot_be_parsed', () { + final axis = parseSpriteThumbnailVtt( + 'not-a-valid-vtt', + vttUrl: 'https://cdn.test/broken.vtt', + ); + + expect(axis.cues, isEmpty); + }); + + testWidgets('should_keep_only_two_decoded_sprites_in_lru', (tester) async { + final loader = _FakeSpriteLoader(vtt: _spriteVtt); + final service = SpriteThumbnailAxisService( + loader: loader, + cacheTtl: const Duration(minutes: 10), + ); + addTearDown(service.dispose); + + await service.prepareAt('https://cdn.test/thumbnails.vtt', Duration.zero); + expect(service.spriteCacheSize, 2); + expect( + service.spriteCacheKeys, + containsAll(['sprite_001.jpg', 'sprite_002.jpg']), + ); + + await service.prepareAt( + 'https://cdn.test/thumbnails.vtt', + const Duration(milliseconds: 2500), + ); + + expect(service.spriteCacheSize, 2); + expect(service.spriteCacheKeys, contains('sprite_002.jpg')); + expect(service.spriteCacheKeys, isNot(contains('sprite_001.jpg'))); + }); + + testWidgets('should_cache_vtt_and_retry_once_after_403', (tester) async { + final loader = _FakeSpriteLoader( + vttResponses: const [SpriteThumbnailForbiddenException(), _spriteVtt], + ); + final service = SpriteThumbnailAxisService(loader: loader); + addTearDown(service.dispose); + + await service.prepareAt('https://cdn.test/thumbnails.vtt', Duration.zero); + await service.prepareAt('https://cdn.test/thumbnails.vtt', Duration.zero); + + expect(loader.vttCalls, 2); + expect(service.hasAxis('https://cdn.test/thumbnails.vtt'), isTrue); + }); + + testWidgets('should_refresh_vtt_after_ten_minute_ttl', (tester) async { + var now = DateTime(2026, 10, 22); + final loader = _FakeSpriteLoader(vtt: _spriteVtt); + final service = SpriteThumbnailAxisService(loader: loader, now: () => now); + addTearDown(service.dispose); + + await service.prepareAt('https://cdn.test/thumbnails.vtt', Duration.zero); + now = now.add(const Duration(minutes: 11)); + await service.prepareAt('https://cdn.test/thumbnails.vtt', Duration.zero); + + expect(loader.vttCalls, 2); + }); + + test('should_resolve_fallback_chain_in_priority_order', () { + expect( + decideScrubPreviewStage( + hasThumbnailsUrl: true, + hasSpriteFrame: true, + hasAxis: true, + hasLocalFrame: true, + ), + ScrubPreviewStage.sprite, + ); + expect( + decideScrubPreviewStage( + hasThumbnailsUrl: true, + hasSpriteFrame: false, + hasAxis: false, + hasLocalFrame: true, + ), + ScrubPreviewStage.waitingForAxis, + ); + expect( + decideScrubPreviewStage( + hasThumbnailsUrl: false, + hasSpriteFrame: false, + hasAxis: false, + hasLocalFrame: true, + ), + ScrubPreviewStage.localFrame, + ); + expect( + decideScrubPreviewStage( + hasThumbnailsUrl: true, + hasSpriteFrame: false, + hasAxis: true, + hasLocalFrame: false, + ), + ScrubPreviewStage.cover, + ); + }); + + test('should_parse_thumbnails_url_from_episode_and_ticket', () { + final episode = Episode.fromJson(const { + 'id': '11', + 'number': 1, + 'title': 'episode', + 'isFree': true, + 'thumbnailsUrl': 'https://cdn.test/thumbnails.vtt?e=1&s=x', + }); + final ticket = PlayTicket.fromJson(const { + 'episodeId': '11', + 'accessible': true, + 'accessType': 'FREE', + 'thumbnailsUrl': 'https://cdn.test/thumbnails.vtt?e=1&s=x', + }); + + expect(episode.thumbnailsUrl, ticket.thumbnailsUrl); + expect( + episode.withVideoUrl('file:///video.mp4').thumbnailsUrl, + episode.thumbnailsUrl, + ); + }); +} + +class _FakeSpriteLoader implements SpriteThumbnailAssetLoader { + _FakeSpriteLoader({this.vtt, this.vttResponses}); + + final String? vtt; + final List? vttResponses; + int vttCalls = 0; + final List spriteCalls = []; + + @override + Future loadVtt(Uri uri) async { + final index = vttCalls++; + final responses = vttResponses; + if (responses == null) return vtt; + final response = responses[index.clamp(0, responses.length - 1)]; + if (response is SpriteThumbnailForbiddenException) throw response; + return response as String; + } + + @override + Future loadSprite(Uri uri) async { + spriteCalls.add(uri.pathSegments.last); + final image = await _createImage(320, 180); + return SpriteThumbnailDecodedImage( + image: image, + originalWidth: 320, + originalHeight: 180, + ); + } +} + +Future _createImage(int width, int height) async { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + canvas.drawRect( + Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()), + Paint()..color = Colors.blue, + ); + final picture = recorder.endRecording(); + final image = await picture.toImage(width, height); + picture.dispose(); + return image; +} diff --git a/test/player/scrub_preview_card_test.dart b/test/player/scrub_preview_card_test.dart new file mode 100644 index 0000000..2503b48 --- /dev/null +++ b/test/player/scrub_preview_card_test.dart @@ -0,0 +1,171 @@ +// Author: fruit.com.cn + +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/services/playback_thumbnail_service.dart'; +import 'package:fruitshorts/core/data/services/sprite_thumbnail_axis.dart'; +import 'package:fruitshorts/pages/player/widgets/scrub_preview_card.dart'; + +void main() { + testWidgets('should_render_sprite_crop_without_requesting_local_frame', ( + tester, + ) async { + final image = await _createImage(320, 180); + final cue = const SpriteThumbnailCue( + start: Duration.zero, + end: Duration(seconds: 2), + spriteFile: 'sprite_001.jpg', + sourceRect: Rect.fromLTWH(0, 0, 160, 90), + ); + final axis = _FakeAxisSource( + axisReady: true, + frame: SpriteThumbnailFrame( + image: image, + sourceRect: const Rect.fromLTWH(0, 0, 160, 90), + spriteUrl: 'https://cdn.test/sprite_001.jpg', + cue: cue, + ), + ); + final loader = _CountingThumbnailLoader(); + final localService = _localService(loader); + addTearDown(image.dispose); + addTearDown(axis.dispose); + addTearDown(localService.dispose); + + await tester.pumpWidget( + _card( + localService: localService, + axis: axis, + thumbnailsUrl: 'https://cdn.test/thumbnails.vtt', + ), + ); + await tester.pump(); + + final preview = tester.widget( + find.byKey(const Key('scrub-preview-sprite')), + ); + expect(preview.sourceRect, const Rect.fromLTWH(0, 0, 160, 90)); + expect(find.byKey(const Key('scrub-preview-frame')), findsNothing); + expect(loader.calls, 0); + }); + + testWidgets('should_fallback_to_local_frame_when_thumbnails_url_is_missing', ( + tester, + ) async { + final loader = _CountingThumbnailLoader(); + final localService = _localService(loader); + final axis = _FakeAxisSource(axisReady: false); + addTearDown(axis.dispose); + addTearDown(localService.dispose); + + await tester.pumpWidget( + _card(localService: localService, axis: axis, thumbnailsUrl: null), + ); + await tester.pump(); + + expect(loader.calls, 1); + expect(find.byKey(const Key('scrub-preview-poster')), findsOneWidget); + expect(find.byKey(const Key('scrub-preview-sprite')), findsNothing); + }); + + testWidgets('should_keep_poster_when_axis_and_local_frame_both_fail', ( + tester, + ) async { + final loader = _CountingThumbnailLoader(); + final localService = _localService(loader); + final axis = _FakeAxisSource(axisReady: true); + addTearDown(axis.dispose); + addTearDown(localService.dispose); + + await tester.pumpWidget( + _card( + localService: localService, + axis: axis, + thumbnailsUrl: 'https://cdn.test/empty.vtt', + ), + ); + await tester.pump(); + + expect(loader.calls, 1); + expect(find.byKey(const Key('scrub-preview-poster')), findsOneWidget); + expect(find.byKey(const Key('scrub-preview-sprite')), findsNothing); + expect(find.byKey(const Key('scrub-preview-frame')), findsNothing); + }); +} + +Widget _card({ + required PlaybackThumbnailService localService, + required SpriteThumbnailAxisSource axis, + required String? thumbnailsUrl, +}) { + return MaterialApp( + home: Scaffold( + body: Center( + child: ScrubPreviewCard( + service: localService, + spriteAxisService: axis, + thumbnailsUrl: thumbnailsUrl, + episodeId: 'episode-1', + posterUrl: '', + position: const Duration(seconds: 1), + duration: const Duration(seconds: 10), + ), + ), + ), + ); +} + +PlaybackThumbnailService _localService(PlaybackThumbnailFrameLoader loader) { + return PlaybackThumbnailService( + frameLoader: loader, + sourceResolver: (_) => + const PlaybackThumbnailSource(remoteUrl: 'https://example/video.mp4'), + prefetchDelay: const Duration(hours: 1), + ); +} + +class _FakeAxisSource extends ChangeNotifier + implements SpriteThumbnailAxisSource { + _FakeAxisSource({required this.axisReady, this.frame}); + + final bool axisReady; + final SpriteThumbnailFrame? frame; + + @override + SpriteThumbnailFrame? cachedFrameAt( + String? thumbnailsUrl, + Duration position, + ) => frame; + + @override + bool hasAxis(String? thumbnailsUrl) => axisReady; +} + +class _CountingThumbnailLoader implements PlaybackThumbnailFrameLoader { + int calls = 0; + + @override + Future load(PlaybackThumbnailFrameRequest request) async { + calls++; + return null; + } + + @override + void dispose() {} +} + +Future _createImage(int width, int height) async { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + canvas.drawRect( + Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()), + Paint()..color = Colors.orange, + ); + final picture = recorder.endRecording(); + final image = await picture.toImage(width, height); + picture.dispose(); + return image; +} From 7a588898fad7ae2e348612ff4dcad1c2ec9105f0 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 12:15:51 +0800 Subject: [PATCH 010/129] =?UTF-8?q?feat(debug):=20=E5=A2=9E=E5=8A=A0=20HTT?= =?UTF-8?q?P=20=E8=AF=B7=E6=B1=82=E5=93=8D=E5=BA=94=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/2026-10-22-debug-http-logging.md | 24 ++ docs/plans/PROGRESS.md | 79 +++++++ lib/core/data/remote/api_client.dart | 4 + lib/core/data/remote/api_interceptors.dart | 5 + .../data/remote/network_log_interceptor.dart | 219 ++++++++++++++++++ .../remote/network_log_interceptor_test.dart | 203 ++++++++++++++++ 6 files changed, 534 insertions(+) create mode 100644 docs/plans/2026-10-22-debug-http-logging.md create mode 100644 lib/core/data/remote/network_log_interceptor.dart create mode 100644 test/core/data/remote/network_log_interceptor_test.dart diff --git a/docs/plans/2026-10-22-debug-http-logging.md b/docs/plans/2026-10-22-debug-http-logging.md new file mode 100644 index 0000000..583255c --- /dev/null +++ b/docs/plans/2026-10-22-debug-http-logging.md @@ -0,0 +1,24 @@ +# Android Studio 调试 HTTP 日志 + +## 目标 + +- 仅在调试构建中输出 Dio 请求、响应与异常日志。 +- Android Studio Run 控制台通过 `debugPrint` 展示,logcat 标签保持 `flutter`。 +- Authorization 脱敏,X-Device-Id 保留,二进制响应只显示字节数。 +- 单条请求体或响应体最多 3000 字符。 + +## 实现步骤 + +1. 新增 `NetworkLogInterceptor`,支持注入日志回调并保留请求耗时。 +2. 在 Envelope 解包前暂存原始 bytes;日志拦截器追加到拦截链末尾后读取暂存值。 +3. 在 `ApiClient` 注册调试拦截器,`kDebugMode` 与常量开关同时满足时才输出。 +4. 以 fake adapter 补单测,覆盖行格式、脱敏、UTF-8、截断、非 JSON 与二进制。 +5. 运行 `dart analyze lib test` 和 `flutter test`,更新 PROGRESS 留痕。 + +## 风险与约束 + +- `ResponseType.bytes` 不能直接 `toString`,响应体统一 `utf8.decode`。 +- Dio 响应拦截器按 FIFO 执行;链尾日志通过 Envelope 暂存的原始 bytes 读取, + 避免拿到已解包后的 `data`。 +- release/profile 通过 `kDebugMode` 短路,不输出任何 HTTP 日志。 +- 不新增依赖,不提交现有 `pubspec.lock` 改动,不启停服务、不操作真机。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 6fa020d..143fc27 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -531,3 +531,82 @@ $ flutter test --no-pub 解读:VTT 时间/xywh/多 sprite、越界最近 cue、LRU2、403 重试、10 分钟 TTL、 回退链、sprite 零本地取帧、无 URL 本地回退和最终封面兜底均通过;全量静态分析 零告警,617 项测试全过。`pubspec.lock` 已还原,未启停服务,未操作真机。 + +## 2026-10-22 Android Studio 调试 HTTP 日志 + +- 分支:`feature/debug-http-logging` +- 基线:`develop` / `origin/develop` = `dc4b602` +- 计划:`docs/plans/2026-10-22-debug-http-logging.md` + +```text +$ git status --short --branch +## develop...origin/develop + M pubspec.lock + +$ git rev-parse HEAD +dc4b60270dd38d594510b45bd7a3d50b7dcd2602 +``` + +解读:基于最新 develop 创建功能分支;开工时 `pubspec.lock` 已有未提交改动, +本次未修改、未暂存该文件。 + +### 实现结果 + +- 新增 `NetworkLogInterceptor`,默认 `debugPrint`,可注入 + `void Function(String)` 作为单测日志收集器。 +- `enabled` 常量与 `kDebugMode` 同时为真才输出,release/profile 零 HTTP 日志。 +- 请求行包含完整 URL 与 query,同时输出耗时起点、脱敏请求头和格式化请求体。 +- Authorization 仅保留前 8 字符后加 `...`;X-Device-Id 原样输出; + 其余请求头值统一 `***`。 +- ResponseType.bytes 响应统一 `utf8.decode(..., allowMalformed: true)`, + JSON 自动缩进;图片/音视频/字体/PDF/ZIP 等只输出 MIME 与字节数。 +- 请求体和响应体超过 3000 字符时截断并追加 `…(truncated)`。 +- 拦截链为 `AuthHeaderInterceptor → EnvelopeInterceptor → NetworkLogInterceptor`。 + Envelope 在解包前暂存原始 bytes,末尾日志读取暂存值,因此仍能打印 Envelope + 处理前的原始响应。 +- 异常路径输出 `[HTTP] ✗ DioException类型 耗时ms URL message`,并继续 + `handler.next(err)`,不吞异常。 + +代表性原始日志: + +```text +[HTTP] → POST http://logging.test/app/logging?keyword=%E4%B8%AD%E6%96%87 +[HTTP] → 耗时起点:2026-10-22T12:12:25.377357 +[HTTP] → headers:{"Accept":"***","Authorization":"Bearer e...","X-Device-Id":"device-raw-001"} +[HTTP] → body:{ + "filter": "中文" +} +[HTTP] ← 200 12ms POST http://logging.test/app/logging?keyword=%E4%B8%AD%E6%96%87 +[HTTP] ← body:{ + "code": 0, + "message": "ok", + "data": { + "title": "中文标题" + } +} +``` + +### 门禁 + +```text +$ flutter test --no-pub test/core/data/remote/network_log_interceptor_test.dart +00:00 +0: should_log_request_and_response_lines_with_elapsed_when_requested +00:00 +1: should_truncate_long_body_when_log_exceeds_limit +00:00 +2: should_log_plain_text_without_throwing_when_response_is_not_json +00:00 +3: should_log_byte_count_only_when_response_is_image +00:00 +4: should_log_dio_exception_type_and_message_when_request_fails +00:00 +5: All tests passed! + +$ dart analyze lib test +Analyzing lib, test... +No issues found! + +$ flutter test --no-pub +00:58 +622: All tests passed! +``` + +解读:请求/响应行与耗时、Authorization 脱敏、X-Device-Id 原样、bytes 中文 +UTF-8 解码、3000 字符截断、非 JSON、二进制字节数、DioException 类型与 message +均通过;全量静态分析零告警,622 项测试全过。 + +未新增依赖、未启停服务、未操作真机;`pubspec.lock` 既有改动未提交。 diff --git a/lib/core/data/remote/api_client.dart b/lib/core/data/remote/api_client.dart index 6f934f1..6568177 100644 --- a/lib/core/data/remote/api_client.dart +++ b/lib/core/data/remote/api_client.dart @@ -1,8 +1,11 @@ +// Author: fruit.com.cn + import 'package:dio/dio.dart'; import '../../config/app_config.dart'; import 'api_exception.dart'; import 'api_interceptors.dart'; +import 'network_log_interceptor.dart'; /// `ApiException` 从这里也能拿到:历史调用方一律 `import 'api_client.dart'`,不必改 import。 export 'api_exception.dart'; @@ -39,6 +42,7 @@ class ApiClient { deviceIdReader: deviceIdReader, ), const EnvelopeInterceptor(), + NetworkLogInterceptor(), ]); if (adapter != null) { _dio.httpClientAdapter = adapter; diff --git a/lib/core/data/remote/api_interceptors.dart b/lib/core/data/remote/api_interceptors.dart index a48087d..cee6819 100644 --- a/lib/core/data/remote/api_interceptors.dart +++ b/lib/core/data/remote/api_interceptors.dart @@ -5,6 +5,7 @@ import 'dart:convert'; import 'package:dio/dio.dart'; import 'api_exception.dart'; +import 'network_log_interceptor.dart'; /// 请求级开关:放在 `RequestOptions.extra` 里,由 `ApiClient` 写入、拦截器读取。 class ApiRequestFlags { @@ -72,6 +73,10 @@ class EnvelopeInterceptor extends Interceptor { ResponseInterceptorHandler handler, ) { final options = response.requestOptions; + if (NetworkLogInterceptor.isEnabled) { + options.extra[NetworkLogInterceptor.rawResponseDataExtraKey] = + response.data; + } final shell = _decodeShell(response.data); final code = shell['code'] as int? ?? -1; final message = shell['message'] as String?; diff --git a/lib/core/data/remote/network_log_interceptor.dart b/lib/core/data/remote/network_log_interceptor.dart new file mode 100644 index 0000000..babd542 --- /dev/null +++ b/lib/core/data/remote/network_log_interceptor.dart @@ -0,0 +1,219 @@ +// Author: fruit.com.cn + +import 'dart:convert'; +import 'dart:math' as math; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; + +/// 调试构建下输出 Dio 请求、响应和异常详情。 +class NetworkLogInterceptor extends Interceptor { + /// 在调试时关闭日志的本地常量开关。 + static const bool enabled = true; + + /// Envelope 解包前暂存原始响应体,供链尾日志读取。 + static const String rawResponseDataExtraKey = 'app.http.rawResponseData'; + + /// 请求、响应体单条日志最大字符数。 + static const int maxBodyLength = 3000; + + static const String _truncatedSuffix = '…(truncated)'; + static const String _stopwatchKey = 'app.http.stopwatch'; + static const String _startedAtKey = 'app.http.startedAt'; + static const String _emptyBody = ''; + static const String _redacted = '***'; + + /// 允许单测注入日志收集器;默认走 Android Studio Run 控制台。 + NetworkLogInterceptor({void Function(String)? logger}) + : _logger = logger ?? debugPrint; + + final void Function(String) _logger; + + /// release/profile 下恒为 false,保证零 HTTP 日志输出。 + static bool get isEnabled => kDebugMode && enabled; + + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + if (!isEnabled) { + return handler.next(options); + } + final stopwatch = Stopwatch()..start(); + options.extra[_stopwatchKey] = stopwatch; + options.extra[_startedAtKey] = DateTime.now().toIso8601String(); + final body = _formatRequestBody(options.data); + final message = StringBuffer() + ..writeln('[HTTP] → ${options.method} ${options.uri}') + ..writeln('[HTTP] → 耗时起点:${options.extra[_startedAtKey]}') + ..writeln('[HTTP] → headers:${_formatHeaders(options.headers)}') + ..writeln('[HTTP] → body:$body'); + _logger(message.toString().trimRight()); + handler.next(options); + } + + @override + void onResponse( + Response response, + ResponseInterceptorHandler handler, + ) { + if (!isEnabled) { + return handler.next(response); + } + final options = response.requestOptions; + final elapsed = _elapsedMilliseconds(options); + final rawBody = options.extra[rawResponseDataExtraKey] ?? response.data; + final body = _formatResponseBody( + rawBody, + response.headers.value(Headers.contentTypeHeader), + ); + final message = StringBuffer() + ..writeln( + '[HTTP] ← ${response.statusCode ?? 0} ${elapsed}ms ' + '${options.method} ${options.uri}', + ) + ..writeln('[HTTP] ← body:$body'); + _logger(message.toString().trimRight()); + handler.next(response); + } + + @override + void onError(DioException err, ErrorInterceptorHandler handler) { + if (!isEnabled) { + return handler.next(err); + } + final options = err.requestOptions; + final message = StringBuffer() + ..writeln( + '[HTTP] ✗ ${err.type.name} ${_elapsedMilliseconds(options)}ms ' + '${options.uri} ${err.message ?? ''}', + ) + ..write(_errorBodyLine(err)); + _logger(message.toString().trimRight()); + handler.next(err); + } + + int _elapsedMilliseconds(RequestOptions options) { + final stopwatch = options.extra[_stopwatchKey]; + if (stopwatch is Stopwatch) { + return stopwatch.elapsedMilliseconds; + } + return 0; + } + + String _errorBodyLine(DioException error) { + final rawBody = + error.requestOptions.extra[rawResponseDataExtraKey] ?? + error.response?.data; + if (rawBody == null) { + return ''; + } + final body = _formatResponseBody( + rawBody, + error.response?.headers.value(Headers.contentTypeHeader), + ); + return '\n[HTTP] ✗ body:$body'; + } + + String _formatHeaders(Map headers) { + if (headers.isEmpty) { + return '{}'; + } + final entries = headers.entries.toList() + ..sort((left, right) => left.key.compareTo(right.key)); + final safe = { + for (final entry in entries) + entry.key: _safeHeaderValue(entry.key, entry.value), + }; + return _truncate(jsonEncode(safe)); + } + + String _safeHeaderValue(String name, Object? value) { + final text = value?.toString() ?? ''; + if (name.toLowerCase() == 'authorization') { + return _maskAuthorization(text); + } + if (name.toLowerCase() == 'x-device-id') { + return text; + } + return _redacted; + } + + String _maskAuthorization(String value) { + if (value.length <= 8) { + return _redacted; + } + return '${value.substring(0, math.min(8, value.length))}...'; + } + + String _formatRequestBody(Object? body) { + if (body == null) { + return _emptyBody; + } + final json = _tryPrettyJson(body); + if (json != null) { + return _truncate(json); + } + final length = switch (body) { + FormData data => data.length, + List bytes => bytes.length, + _ => utf8.encode(body.toString()).length, + }; + return '${body.runtimeType} ($length bytes)'; + } + + String _formatResponseBody(Object? body, String? contentType) { + if (body == null) { + return _emptyBody; + } + if (body is List) { + final bytes = body is Uint8List ? body : Uint8List.fromList(body); + if (_isBinary(contentType)) { + return ''; + } + final text = utf8.decode(bytes, allowMalformed: true); + return _formatText(text); + } + return _formatText(body.toString()); + } + + String _formatText(String text) { + if (text.isEmpty) { + return _emptyBody; + } + return _truncate(_tryPrettyJson(text) ?? text); + } + + String? _tryPrettyJson(Object? body) { + try { + final value = body is String + ? jsonDecode(body) + : body is List + ? jsonDecode(utf8.decode(body, allowMalformed: true)) + : body; + if (value is! Map && value is! List) { + return null; + } + return const JsonEncoder.withIndent(' ').convert(value); + } catch (_) { + return null; + } + } + + bool _isBinary(String? contentType) { + final type = contentType?.toLowerCase() ?? ''; + return type.startsWith('image/') || + type.startsWith('audio/') || + type.startsWith('video/') || + type.startsWith('font/') || + type.contains('application/octet-stream') || + type.contains('application/pdf') || + type.contains('application/zip'); + } + + String _truncate(String value) { + if (value.length <= maxBodyLength) { + return value; + } + final end = maxBodyLength - _truncatedSuffix.length; + return '${value.substring(0, end)}$_truncatedSuffix'; + } +} diff --git a/test/core/data/remote/network_log_interceptor_test.dart b/test/core/data/remote/network_log_interceptor_test.dart new file mode 100644 index 0000000..ef80004 --- /dev/null +++ b/test/core/data/remote/network_log_interceptor_test.dart @@ -0,0 +1,203 @@ +// Author: fruit.com.cn + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/remote/api_interceptors.dart'; +import 'package:fruitshorts/core/data/remote/network_log_interceptor.dart'; + +void main() { + const baseUrl = 'http://logging.test'; + const token = 'eyJhbGciOiJIUzM4NCJ9.secret'; + + Dio buildDio( + List logs, + HttpClientAdapter adapter, { + bool useEnvelope = true, + }) { + final dio = Dio( + BaseOptions( + baseUrl: baseUrl, + responseType: ResponseType.bytes, + validateStatus: (_) => true, + ), + ); + dio.httpClientAdapter = adapter; + dio.interceptors.addAll([ + AuthHeaderInterceptor( + accessTokenReader: () => token, + deviceIdReader: () async => 'device-raw-001', + ), + if (useEnvelope) const EnvelopeInterceptor(), + NetworkLogInterceptor(logger: logs.add), + ]); + return dio; + } + + test( + 'should_log_request_and_response_lines_with_elapsed_when_requested', + () async { + final logs = []; + final dio = buildDio( + logs, + _RawHttpAdapter.fromText( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': {'title': '中文标题'}, + }), + ), + ); + + await dio.post( + '/app/logging', + queryParameters: {'keyword': '中文'}, + data: {'filter': '中文'}, + ); + + expect( + logs.first, + contains('[HTTP] → POST $baseUrl/app/logging?keyword='), + ); + expect(logs.join('\n'), contains('[HTTP] → 耗时起点:')); + expect(logs.join('\n'), matches(RegExp(r'\[HTTP\] ← 200 \d+ms POST '))); + expect(logs.join('\n'), contains('"filter": "中文"')); + expect(logs.join('\n'), contains('"中文标题"')); + expect(logs.join('\n'), contains('"Authorization":"Bearer e..."')); + expect(logs.join('\n'), contains('"X-Device-Id":"device-raw-001"')); + expect(logs.join('\n'), isNot(contains(token))); + }, + ); + + test('should_truncate_long_body_when_log_exceeds_limit', () async { + final logs = []; + final dio = buildDio( + logs, + _RawHttpAdapter.fromText( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': {'text': List.filled(3500, '长').join()}, + }), + ), + ); + + await dio.get('/app/long'); + + final responseLog = logs.singleWhere( + (line) => line.startsWith('[HTTP] ← 200'), + ); + const prefix = '[HTTP] ← body:'; + final body = responseLog.substring( + responseLog.indexOf(prefix) + prefix.length, + ); + expect(body.length, NetworkLogInterceptor.maxBodyLength); + expect(body, endsWith('…(truncated)')); + }); + + test( + 'should_log_plain_text_without_throwing_when_response_is_not_json', + () async { + final logs = []; + final dio = buildDio( + logs, + _RawHttpAdapter.fromText('plain text response'), + ); + + await expectLater( + dio.get('/app/plain'), + throwsA(isA()), + ); + + expect(logs.join('\n'), contains('[HTTP] ✗')); + expect(logs.join('\n'), contains('plain text response')); + }, + ); + + test('should_log_byte_count_only_when_response_is_image', () async { + final logs = []; + final dio = buildDio( + logs, + _RawHttpAdapter( + Uint8List.fromList([0xff, 0xd8, 0xff, 0x00]), + contentType: 'image/jpeg', + ), + useEnvelope: false, + ); + + await dio.get('/files/image.jpg'); + + expect(logs.join('\n'), contains('')); + expect(logs.join('\n'), isNot(contains('�'))); + }); + + test( + 'should_log_dio_exception_type_and_message_when_request_fails', + () async { + final logs = []; + final dio = buildDio(logs, _ThrowingHttpAdapter()); + + await expectLater( + dio.get('/app/offline'), + throwsA(isA()), + ); + + expect( + logs.join('\n'), + matches(RegExp(r'\[HTTP\] ✗ connectionError \d+ms .* offline')), + ); + }, + ); +} + +class _RawHttpAdapter implements HttpClientAdapter { + _RawHttpAdapter(this.bytes, {required this.contentType}); + + factory _RawHttpAdapter.fromText(String body) { + return _RawHttpAdapter( + Uint8List.fromList(utf8.encode(body)), + contentType: 'application/json; charset=utf-8', + ); + } + + final Uint8List bytes; + final String contentType; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + return ResponseBody.fromBytes( + bytes, + 200, + headers: { + Headers.contentTypeHeader: [contentType], + }, + ); + } + + @override + void close({bool force = false}) {} +} + +class _ThrowingHttpAdapter implements HttpClientAdapter { + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) { + throw DioException( + requestOptions: options, + type: DioExceptionType.connectionError, + message: 'offline', + ); + } + + @override + void close({bool force = false}) {} +} From 235b50598a6a3fbcd962ffb47e331e0d120831e4 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 12:34:27 +0800 Subject: [PATCH 011/129] =?UTF-8?q?feat(library):=20=E6=94=B6=E8=97=8F?= =?UTF-8?q?=E4=B8=8E=E8=A7=82=E7=9C=8B=E5=8E=86=E5=8F=B2=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=88=86=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/2026-10-22-library-paging-ui.md | 22 ++++ docs/plans/PROGRESS.md | 46 ++++++++ lib/core/data/models/page_result.dart | 23 ++++ lib/core/data/models/watch_progress.dart | 20 ++++ lib/core/data/models/watch_record.dart | 22 +++- lib/core/data/remote/library_api.dart | 27 +++-- lib/core/data/remote/watch_api.dart | 26 +++-- .../repositories/favorite_repository.dart | 31 +++-- .../watch_history_repository.dart | 32 +++-- .../data/services/favorite_controller.dart | 110 ++++++++++++++++-- .../services/watch_records_controller.dart | 85 ++++++++++++-- lib/core/widgets/paged_list_footer.dart | 86 ++++++++++++++ lib/pages/favorites/favorites_page.dart | 49 ++++++-- lib/pages/mine/mine_logic.dart | 5 +- .../watch_records/watch_records_page.dart | 49 ++++++-- .../widgets/watch_record_card.dart | 37 ++++-- test/integration/live_watch_flow_test.dart | 11 +- test/library/favorite_controller_test.dart | 65 ++++++++++- test/library/favorites_page_test.dart | 4 +- test/library/library_api_test.dart | 66 +++++++++++ test/support/fake_watch_history.dart | 17 ++- test/watch/watch_api_test.dart | 83 +++++++++++++ test/watch/watch_records_controller_test.dart | 103 +++++++++++++++- test/watch/watch_records_page_test.dart | 101 +++++++++++++++- 24 files changed, 1015 insertions(+), 105 deletions(-) create mode 100644 docs/plans/2026-10-22-library-paging-ui.md create mode 100644 lib/core/data/models/page_result.dart create mode 100644 lib/core/widgets/paged_list_footer.dart create mode 100644 test/library/library_api_test.dart create mode 100644 test/watch/watch_api_test.dart diff --git a/docs/plans/2026-10-22-library-paging-ui.md b/docs/plans/2026-10-22-library-paging-ui.md new file mode 100644 index 0000000..3c151f2 --- /dev/null +++ b/docs/plans/2026-10-22-library-paging-ui.md @@ -0,0 +1,22 @@ +# 收藏与观看历史分页 UI + +## 目标 + +- `/app/favorites` 与 `/app/watch/recent` 按 `PageResult{total,list}` 分页接入。 +- 收藏页、观看历史页支持首屏、触底加载、无更多、刷新、失败重试和总数展示。 +- 观看记录直接使用后端 `dramaTitle/dramaCover/episodeNumber`,缺失时安全兜底。 + +## 实现步骤 + +1. 新增通用 `PageResult`,更新 LibraryApi、WatchApi、Repository 与内存替身。 +2. FavoriteController、WatchRecordsController 增加分页状态、请求代次、dramaId 去重。 +3. 页面通过滚动通知触发 `loadMore`,复用下拉刷新并增加通用分页 footer。 +4. 观看卡片优先渲染接口字段,标题为空时用本地化“第 N 集”,封面为空显示占位。 +5. 补 API、Controller 和 widget 测试,运行 analyze 与全量 flutter test。 + +## 风险与约束 + +- 刷新期间保持旧列表,成功后原子替换并回到第 1 页;失败保留旧数据。 +- loadMore 与 refresh 通过请求代次隔离,旧响应不得覆盖新列表。 +- 空页立即停止加载;`loaded >= total` 后不再请求。 +- 不新增依赖、不提交现有 `pubspec.lock`,不启停服务、不操作真机。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 143fc27..de7ad9f 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -610,3 +610,49 @@ UTF-8 解码、3000 字符截断、非 JSON、二进制字节数、DioException 均通过;全量静态分析零告警,622 项测试全过。 未新增依赖、未启停服务、未操作真机;`pubspec.lock` 既有改动未提交。 + +## 2026-10-22 收藏与观看历史分页 UI + +- 分支:`feature/library-paging-ui` +- 基线:`develop` / `origin/develop` = `63d3b0f` +- 后端依赖:FruitServer `7b69493`(收藏/观看历史 PageResult 与展示字段) +- 计划:`docs/plans/2026-10-22-library-paging-ui.md` + +### 实现 + +- 新增通用 `PageResult`;`LibraryApi.favorites` 与 `WatchApi.recent` + 统一请求 `pageIndex/pageSize` 并返回 `PageResult`。 +- 收藏与观看仓储、Controller 增加首屏、刷新、loadMore、total、hasMore、 + loadingMore 和 loadMoreError;请求代次避免旧刷新/旧分页覆盖新列表。 +- 收藏按 dramaId 去重,观看按 dramaId 去重;空页或 `loaded >= total` 停止请求。 +- 收藏页、观看历史页接入滚动触底加载、下拉刷新、通用分页 footer、总数展示。 +- 观看卡片优先展示分页接口的 `dramaTitle/dramaCover/episodeNumber`; + 标题为空时显示本地化“第 N 集”,封面缺失时显示占位图标。 +- API 兼容滚动发布窗口:后端仍返回旧 List 时包装为 `PageResult(total=list.length)`; + 服务端新 PageResult 正常解析。 +- MineLogic 改用分页首屏 `list`,不再调用旧 limit 接口。 + +### 门禁 + +定向测试: + +```text +$ flutter test --no-pub test/library/library_api_test.dart test/library/favorite_controller_test.dart test/library/favorites_page_test.dart test/watch/watch_api_test.dart test/watch/watch_records_controller_test.dart test/watch/watch_records_page_test.dart test/mine/mine_logic_test.dart +00:03 +40: All tests passed! +``` + +完整门禁: + +```text +$ dart analyze lib test +Analyzing lib, test... +No issues found! + +$ flutter test --no-pub +00:56 +632: All tests passed! +``` + +解读:分页第一页、追加去重、无更多不重复请求、loadMore 失败重试、 +观看标题/集号/封面展示及空标题兜底均通过;全量静态分析零告警,632 项测试全过。 + +未新增依赖、未启停服务、未操作真机;现有 `pubspec.lock` 改动未提交。 diff --git a/lib/core/data/models/page_result.dart b/lib/core/data/models/page_result.dart new file mode 100644 index 0000000..37511bc --- /dev/null +++ b/lib/core/data/models/page_result.dart @@ -0,0 +1,23 @@ +// Author: fruit.com.cn + +import 'json_numbers.dart'; + +/// 服务端标准分页响应 `{total, list}`。 +class PageResult { + const PageResult({this.total = 0, this.list = const []}); + + final int total; + final List list; + + /// 解析分页壳,条目由调用方提供具体解析函数。 + factory PageResult.fromJson( + Map json, + T Function(Map) parseItem, + ) { + final items = (json['list'] as List? ?? const []) + .whereType>() + .map(parseItem) + .toList(growable: false); + return PageResult(total: jsonToInt(json['total']), list: items); + } +} diff --git a/lib/core/data/models/watch_progress.dart b/lib/core/data/models/watch_progress.dart index 3d655ef..2ba70e3 100644 --- a/lib/core/data/models/watch_progress.dart +++ b/lib/core/data/models/watch_progress.dart @@ -12,6 +12,10 @@ class WatchProgress { required this.episodeId, required this.position, required this.duration, + this.dramaTitle, + this.dramaCover, + this.episodeNumber, + this.totalEpisodes, this.finished = false, this.playCount = 0, this.updatedAt, @@ -19,6 +23,10 @@ class WatchProgress { final String dramaId; final String episodeId; + final String? dramaTitle; + final String? dramaCover; + final int? episodeNumber; + final int? totalEpisodes; final Duration position; final Duration duration; final bool finished; @@ -37,6 +45,14 @@ class WatchProgress { return WatchProgress( dramaId: (json['dramaId'] ?? '').toString(), episodeId: (json['episodeId'] ?? '').toString(), + dramaTitle: json['dramaTitle']?.toString(), + dramaCover: json['dramaCover']?.toString(), + episodeNumber: json['episodeNumber'] == null + ? null + : jsonToInt(json['episodeNumber']), + totalEpisodes: json['totalEpisodes'] == null + ? null + : jsonToInt(json['totalEpisodes']), position: Duration(milliseconds: jsonToInt(json['positionMs'])), duration: Duration(milliseconds: jsonToInt(json['durationMs'])), finished: json['finished'] == true, @@ -48,6 +64,10 @@ class WatchProgress { Map toJson() => { 'dramaId': dramaId, 'episodeId': episodeId, + if (dramaTitle != null) 'dramaTitle': dramaTitle, + if (dramaCover != null) 'dramaCover': dramaCover, + if (episodeNumber != null) 'episodeNumber': episodeNumber, + if (totalEpisodes != null) 'totalEpisodes': totalEpisodes, 'positionMs': position.inMilliseconds, 'durationMs': duration.inMilliseconds, 'finished': finished, diff --git a/lib/core/data/models/watch_record.dart b/lib/core/data/models/watch_record.dart index 954b478..41e93ee 100644 --- a/lib/core/data/models/watch_record.dart +++ b/lib/core/data/models/watch_record.dart @@ -19,8 +19,10 @@ class WatchRecord { String get dramaId => progress.dramaId; - /// 详情中按 episodeId 定位集号;缺失时返回 0。 + /// 优先使用分页接口集号,其次从已加载详情定位。 int get episodeNumber { + final pageEpisode = progress.episodeNumber; + if (pageEpisode != null && pageEpisode > 0) return pageEpisode; final episodes = drama?.episodes ?? const []; for (var index = 0; index < episodes.length; index++) { if (episodes[index].id == progress.episodeId) return index + 1; @@ -28,6 +30,24 @@ class WatchRecord { return 0; } + /// 服务端分页标题优先,详情标题作为回退。 + String get displayTitle { + final pageTitle = progress.dramaTitle?.trim() ?? ''; + return pageTitle.isNotEmpty ? pageTitle : drama?.title.trim() ?? ''; + } + + /// 服务端分页封面优先,详情封面作为回退。 + String get displayCover { + final pageCover = progress.dramaCover?.trim() ?? ''; + return pageCover.isNotEmpty ? pageCover : drama?.posterUrl ?? ''; + } + + /// 分页接口已带展示字段时无需等待详情加载。 + bool get hasDisplayMetadata => + displayTitle.isNotEmpty || + progress.dramaCover?.trim().isNotEmpty == true || + (progress.episodeNumber ?? 0) > 0; + /// 生成卡片加载中、成功或失败后的新状态。 WatchRecord copyWith({ Drama? drama, diff --git a/lib/core/data/remote/library_api.dart b/lib/core/data/remote/library_api.dart index 14c09b7..ad19fa8 100644 --- a/lib/core/data/remote/library_api.dart +++ b/lib/core/data/remote/library_api.dart @@ -1,6 +1,7 @@ // Author: fruit.com.cn import '../models/drama.dart'; +import '../models/page_result.dart'; import 'api_client.dart'; /// 收藏接口:`/app/favorites*`(契约 §1.5)。需要登录态。 @@ -20,13 +21,25 @@ class LibraryApi { return data == true; } - Future> favorites() async { - final data = await _client.get('/app/favorites'); - if (data is! List) return const []; - return [ - for (final item in data) - if (item is Map) Drama.fromCardJson(item), - ]; + Future> favorites({ + int pageIndex = 1, + int pageSize = 20, + }) async { + final data = await _client.get( + '/app/favorites', + query: {'pageIndex': '$pageIndex', 'pageSize': '$pageSize'}, + ); + if (data is Map) { + return PageResult.fromJson(data, Drama.fromCardJson); + } + if (data is List) { + final list = data + .whereType>() + .map(Drama.fromCardJson) + .toList(growable: false); + return PageResult(total: list.length, list: list); + } + return const PageResult(); } Future removeFavorite(String dramaId) async { diff --git a/lib/core/data/remote/watch_api.dart b/lib/core/data/remote/watch_api.dart index f57d7fb..a169856 100644 --- a/lib/core/data/remote/watch_api.dart +++ b/lib/core/data/remote/watch_api.dart @@ -1,5 +1,6 @@ // Author: fruit.com.cn +import '../models/page_result.dart'; import '../models/watch_progress.dart'; import 'api_client.dart'; @@ -39,17 +40,26 @@ class WatchApi { return data is Map ? WatchProgress.fromJson(data) : null; } - /// 最近观看(服务端按下标返回,已按更新时间倒序)。 - Future> recent({int limit = 20}) async { + /// 最近观看分页(服务端已按更新时间倒序)。 + Future> recent({ + int pageIndex = 1, + int pageSize = 20, + }) async { final data = await _client.get( '/app/watch/recent', - query: {'limit': '$limit'}, + query: {'pageIndex': '$pageIndex', 'pageSize': '$pageSize'}, ); - if (data is! List) return const []; - return [ - for (final item in data) - if (item is Map) WatchProgress.fromJson(item), - ]; + if (data is Map) { + return PageResult.fromJson(data, WatchProgress.fromJson); + } + if (data is List) { + final list = data + .whereType>() + .map(WatchProgress.fromJson) + .toList(growable: false); + return PageResult(total: list.length, list: list); + } + return const PageResult(); } /// 删除某部剧的观看记录(记录页清单条用)。 diff --git a/lib/core/data/repositories/favorite_repository.dart b/lib/core/data/repositories/favorite_repository.dart index 1329adf..402da00 100644 --- a/lib/core/data/repositories/favorite_repository.dart +++ b/lib/core/data/repositories/favorite_repository.dart @@ -1,6 +1,7 @@ // Author: fruit.com.cn import '../models/drama.dart'; +import '../models/page_result.dart'; import '../remote/library_api.dart'; /// 收藏来源:**以服务端为准**(本地不再存收藏状态)。 @@ -8,8 +9,8 @@ abstract interface class FavoriteRepository { /// 切换收藏,返回切换后的状态(true = 已收藏)。 Future toggle(String dramaId); - /// 收藏列表(卡片结构与首页一致)。 - Future> list(); + /// 收藏分页(卡片结构与首页一致)。 + Future> page({int pageIndex = 1, int pageSize = 20}); Future remove(String dramaId); } @@ -28,9 +29,9 @@ class HttpFavoriteRepository implements FavoriteRepository { Future toggle(String dramaId) => _api.toggleFavorite(dramaId); @override - Future> list() async { - if (!_signedIn) return const []; - return _api.favorites(); + Future> page({int pageIndex = 1, int pageSize = 20}) async { + if (!_signedIn) return const PageResult(); + return _api.favorites(pageIndex: pageIndex, pageSize: pageSize); } @override @@ -46,10 +47,10 @@ class InMemoryFavoriteRepository implements FavoriteRepository { final Set _saved; final List _cards; bool failToggle = false; - bool failList = false; + bool failPage = false; bool failRemove = false; int toggleCalls = 0; - int listCalls = 0; + int pageCalls = 0; @override Future toggle(String dramaId) async { @@ -60,10 +61,18 @@ class InMemoryFavoriteRepository implements FavoriteRepository { } @override - Future> list() async { - listCalls++; - if (failList) throw StateError('favorite unavailable'); - return _cards.where((card) => _saved.contains(card.id)).toList(); + Future> page({int pageIndex = 1, int pageSize = 20}) async { + pageCalls++; + if (failPage) throw StateError('favorite unavailable'); + final filtered = _cards.where((card) => _saved.contains(card.id)).toList(); + final offset = (pageIndex - 1) * pageSize; + if (offset >= filtered.length) { + return PageResult(total: filtered.length); + } + return PageResult( + total: filtered.length, + list: filtered.skip(offset).take(pageSize).toList(), + ); } @override diff --git a/lib/core/data/repositories/watch_history_repository.dart b/lib/core/data/repositories/watch_history_repository.dart index fb4a9dd..cb6b76f 100644 --- a/lib/core/data/repositories/watch_history_repository.dart +++ b/lib/core/data/repositories/watch_history_repository.dart @@ -1,5 +1,6 @@ // Author: fruit.com.cn +import '../models/page_result.dart'; import '../models/watch_progress.dart'; import '../remote/watch_api.dart'; @@ -20,8 +21,11 @@ abstract interface class WatchHistoryRepository { bool finished = false, }); - /// 最近观看(默认 20 条)。 - Future> recent({int limit = 20}); + /// 最近观看分页。 + Future> recent({ + int pageIndex = 1, + int pageSize = 20, + }); /// 清理某部剧的记录(K2 的记录页会用,这里先备好)。 Future remove(String dramaId); @@ -63,9 +67,12 @@ class HttpWatchHistoryRepository implements WatchHistoryRepository { } @override - Future> recent({int limit = 20}) async { - if (!_signedIn) return const []; - return _api.recent(limit: limit); + Future> recent({ + int pageIndex = 1, + int pageSize = 20, + }) async { + if (!_signedIn) return const PageResult(); + return _api.recent(pageIndex: pageIndex, pageSize: pageSize); } @override @@ -123,12 +130,23 @@ class InMemoryWatchHistoryRepository implements WatchHistoryRepository { } @override - Future> recent({int limit = 20}) async { + Future> recent({ + int pageIndex = 1, + int pageSize = 20, + }) async { if (failReads > 0) { failReads -= 1; throw StateError('progress unavailable'); } - return _records.values.take(limit).toList(); + final items = _records.values.toList(); + final offset = (pageIndex - 1) * pageSize; + if (offset >= items.length) { + return PageResult(total: items.length); + } + return PageResult( + total: items.length, + list: items.skip(offset).take(pageSize).toList(), + ); } @override diff --git a/lib/core/data/services/favorite_controller.dart b/lib/core/data/services/favorite_controller.dart index 4b9e4b1..ee55bc4 100644 --- a/lib/core/data/services/favorite_controller.dart +++ b/lib/core/data/services/favorite_controller.dart @@ -10,13 +10,22 @@ import '../repositories/favorite_repository.dart'; /// 点击收藏先乐观更新(立刻变色),失败再回滚并给出提示 —— 网络抖动时不至于 /// "点了没反应"或"看起来收藏了其实没有"。 class FavoriteController extends ChangeNotifier { - FavoriteController(this._repository); + FavoriteController(this._repository, {this.pageSize = defaultPageSize}); + + static const int defaultPageSize = 20; final FavoriteRepository _repository; + final int pageSize; final Set _saved = {}; List cards = const []; + int total = 0; bool loading = false; + bool loadingMore = false; + bool hasMore = false; String? error; + String? loadMoreError; + int _pageIndex = 0; + int _requestSeq = 0; bool _disposed = false; Set get savedIds => Set.unmodifiable(_saved); @@ -29,20 +38,66 @@ class FavoriteController extends ChangeNotifier { /// 拉收藏列表:列表既用于"我的"页展示,也用于校准 [savedIds]。 Future load() async { + final request = ++_requestSeq; loading = true; + loadingMore = false; error = null; + loadMoreError = null; notify(); try { - final list = await _repository.list(); + final page = await _repository.page(pageIndex: 1, pageSize: pageSize); + if (_disposed || request != _requestSeq) return; + final list = _uniqueCards(page.list); cards = list; _saved ..clear() ..addAll(list.map((drama) => drama.id)); + total = page.total; + _pageIndex = 1; + hasMore = page.list.isNotEmpty && cards.length < total; } catch (_) { - error = 'favoritesError'; + if (!_disposed && request == _requestSeq) { + error = 'favoritesError'; + } } finally { - loading = false; - notify(); + if (!_disposed && request == _requestSeq) { + loading = false; + notify(); + } + } + } + + /// 追加下一页;正在刷新、已在加载或无更多时直接忽略。 + Future loadMore() async { + if (loading || loadingMore || !hasMore) return; + final request = ++_requestSeq; + loadingMore = true; + loadMoreError = null; + notify(); + try { + final page = await _repository.page( + pageIndex: _pageIndex + 1, + pageSize: pageSize, + ); + if (_disposed || request != _requestSeq) return; + final known = cards.map((drama) => drama.id).toSet(); + final appended = _uniqueCards( + page.list, + ).where((drama) => known.add(drama.id)).toList(growable: false); + cards = [...cards, ...appended]; + _saved.addAll(appended.map((drama) => drama.id)); + total = page.total; + _pageIndex += 1; + hasMore = page.list.isNotEmpty && cards.length < total; + } catch (_) { + if (!_disposed && request == _requestSeq) { + loadMoreError = 'favoritesError'; + } + } finally { + if (!_disposed && request == _requestSeq) { + loadingMore = false; + notify(); + } } } @@ -52,10 +107,17 @@ class FavoriteController extends ChangeNotifier { /// 这样"收藏完打开我的收藏"能立刻看到,不必等下一次整表刷新。 Future toggle(String dramaId, {Drama? card}) async { final wasSaved = _saved.contains(dramaId); + final previousCards = cards; + final previousTotal = total; + final previousHasMore = hasMore; // 乐观更新 if (wasSaved) { _saved.remove(dramaId); - cards = cards.where((drama) => drama.id != dramaId).toList(); + if (cards.any((drama) => drama.id == dramaId)) { + cards = cards.where((drama) => drama.id != dramaId).toList(); + total = total > 0 ? total - 1 : 0; + hasMore = cards.length < total; + } } else { _saved.add(dramaId); } @@ -65,17 +127,30 @@ class FavoriteController extends ChangeNotifier { // 以服务端返回的状态为准(可能与本地猜测不同,例如并发操作) if (saved) { _saved.add(dramaId); - if (card != null && !cards.any((drama) => drama.id == dramaId)) { + if (wasSaved) { + cards = previousCards; + total = previousTotal; + hasMore = previousHasMore; + } else if (card != null && !cards.any((drama) => drama.id == dramaId)) { cards = [card, ...cards]; + total += 1; + hasMore = cards.length < total; } } else { _saved.remove(dramaId); - cards = cards.where((drama) => drama.id != dramaId).toList(); + if (cards.any((drama) => drama.id == dramaId)) { + cards = cards.where((drama) => drama.id != dramaId).toList(); + total = total > 0 ? total - 1 : 0; + hasMore = cards.length < total; + } } notify(); return true; } catch (_) { // 回滚 + cards = previousCards; + total = previousTotal; + hasMore = previousHasMore; if (wasSaved) { _saved.add(dramaId); } else { @@ -94,6 +169,8 @@ class FavoriteController extends ChangeNotifier { _saved.remove(dramaId); final next = [...cards]..removeAt(index); cards = next; + total = total > 0 ? total - 1 : 0; + hasMore = cards.length < total; notify(); try { await _repository.remove(dramaId); @@ -106,6 +183,8 @@ class FavoriteController extends ChangeNotifier { ); cards = restored; _saved.add(dramaId); + total += 1; + hasMore = cards.length < total; notify(); return false; } @@ -113,12 +192,27 @@ class FavoriteController extends ChangeNotifier { /// 退出登录时清空:收藏状态属于账号,不能跨账号串联。 void clear() { + _requestSeq += 1; _saved.clear(); cards = const []; + total = 0; + loading = false; + hasMore = false; + loadingMore = false; + loadMoreError = null; error = null; + _pageIndex = 0; notify(); } + List _uniqueCards(List source) { + final seen = {}; + return [ + for (final drama in source) + if (seen.add(drama.id)) drama, + ]; + } + @override void dispose() { _disposed = true; diff --git a/lib/core/data/services/watch_records_controller.dart b/lib/core/data/services/watch_records_controller.dart index 9862db3..569de4f 100644 --- a/lib/core/data/services/watch_records_controller.dart +++ b/lib/core/data/services/watch_records_controller.dart @@ -11,41 +11,53 @@ import '../repositories/watch_history_repository.dart'; /// 独立观看记录页状态。 /// -/// 最近记录只下发 dramaId;详情按 dramaId 去重后补取,并以 4 个并发为上限。 +/// 分页接口提供标题/封面/集号;详情按 dramaId 去重后补取,供点击播放使用。 class WatchRecordsController extends ChangeNotifier { factory WatchRecordsController({ required WatchHistoryRepository history, required CatalogRepository catalog, - int limit = defaultLimit, - }) => WatchRecordsController._(history, catalog, limit); + int pageSize = defaultPageSize, + }) => WatchRecordsController._(history, catalog, pageSize); - WatchRecordsController._(this._history, this._catalog, this.limit); + WatchRecordsController._(this._history, this._catalog, this.pageSize); - static const int defaultLimit = 20; + static const int defaultPageSize = 20; static const int detailConcurrency = 4; final WatchHistoryRepository _history; final CatalogRepository _catalog; - final int limit; + final int pageSize; final Map _detailCache = {}; List records = const []; + int total = 0; bool loading = false; + bool loadingMore = false; + bool hasMore = false; bool clearing = false; String? error; + String? loadMoreError; String? actionError; + int _pageIndex = 0; + int _requestSeq = 0; bool _disposed = false; /// 加载最近记录并补齐详情;单条详情失败不拖垮整页。 Future load() async { + final request = ++_requestSeq; loading = true; + loadingMore = false; error = null; + loadMoreError = null; actionError = null; _notify(); try { - final recent = await _history.recent(limit: limit); - if (_disposed) return; - records = _uniqueRecords(recent); + final page = await _history.recent(pageIndex: 1, pageSize: pageSize); + if (_disposed || request != _requestSeq) return; + records = _uniqueRecords(page.list); + total = page.total; + _pageIndex = 1; + hasMore = page.list.isNotEmpty && records.length < total; _notify(); await _hydrate(records.map((record) => record.dramaId).toList()); } catch (failure, stackTrace) { @@ -57,17 +69,54 @@ class WatchRecordsController extends ChangeNotifier { stackTrace: stackTrace, label: '[WatchRecordsController] recent failed', ); - if (!_disposed) { + if (!_disposed && request == _requestSeq) { error = failure is ApiException ? failure.message : 'progressError'; } } finally { - if (!_disposed) { + if (!_disposed && request == _requestSeq) { loading = false; _notify(); } } } + /// 追加下一页;刷新、加载中或无更多时忽略。 + Future loadMore() async { + if (loading || loadingMore || !hasMore) return; + final request = ++_requestSeq; + loadingMore = true; + loadMoreError = null; + _notify(); + try { + final page = await _history.recent( + pageIndex: _pageIndex + 1, + pageSize: pageSize, + ); + if (_disposed || request != _requestSeq) return; + final known = records.map((record) => record.dramaId).toSet(); + final appended = _uniqueRecords( + page.list, + ).where((record) => known.add(record.dramaId)).toList(growable: false); + records = [...records, ...appended]; + total = page.total; + _pageIndex += 1; + hasMore = page.list.isNotEmpty && records.length < total; + _notify(); + await _hydrate(appended.map((record) => record.dramaId).toList()); + } catch (failure) { + if (!_disposed && request == _requestSeq) { + loadMoreError = failure is ApiException + ? failure.message + : 'progressError'; + } + } finally { + if (!_disposed && request == _requestSeq) { + loadingMore = false; + _notify(); + } + } + } + /// 只重试一条详情失败记录。 Future retryDetail(String dramaId) async { final index = _recordIndex(dramaId); @@ -87,6 +136,8 @@ class WatchRecordsController extends ChangeNotifier { if (index < 0) return true; final removed = records[index]; records = [...records]..removeAt(index); + total = total > 0 ? total - 1 : 0; + hasMore = records.length < total; actionError = null; _notify(); try { @@ -96,6 +147,8 @@ class WatchRecordsController extends ChangeNotifier { final next = [...records]; next.insert(index > next.length ? next.length : index, removed); records = next; + total += 1; + hasMore = records.length < total; actionError = 'recordDeleteFailed'; return false; } finally { @@ -106,8 +159,15 @@ class WatchRecordsController extends ChangeNotifier { /// 清空全部记录,先更新 UI,失败恢复原列表。 Future clear() async { if (records.isEmpty) return true; + _requestSeq += 1; final previous = records; + final previousTotal = total; + final previousPageIndex = _pageIndex; records = const []; + total = 0; + hasMore = false; + _pageIndex = 0; + loadingMore = false; clearing = true; actionError = null; _notify(); @@ -116,6 +176,9 @@ class WatchRecordsController extends ChangeNotifier { return true; } catch (_) { records = previous; + total = previousTotal; + _pageIndex = previousPageIndex; + hasMore = records.length < total; actionError = 'recordClearFailed'; return false; } finally { diff --git a/lib/core/widgets/paged_list_footer.dart b/lib/core/widgets/paged_list_footer.dart new file mode 100644 index 0000000..8af2cda --- /dev/null +++ b/lib/core/widgets/paged_list_footer.dart @@ -0,0 +1,86 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../theme/app_colors.dart'; +import '../../l10n/app_localizations.dart'; + +/// 网络分页列表底部状态:加载、重试、手动加载与无更多。 +class PagedListFooter extends StatelessWidget { + const PagedListFooter({ + super.key, + required this.loading, + required this.hasMore, + required this.hasItems, + required this.onRetry, + this.errorMessage, + }); + + final bool loading; + final bool hasMore; + final bool hasItems; + final String? errorMessage; + final Future Function() onRetry; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + if (errorMessage != null) { + return Padding( + key: const Key('paged-list-error'), + padding: const EdgeInsets.symmetric(vertical: 12), + child: Column( + children: [ + Text( + errorMessage!, + style: const TextStyle(color: AppColors.muted, fontSize: 12), + ), + TextButton( + key: const Key('paged-list-retry'), + onPressed: () => unawaited(onRetry()), + child: Text(l10n.retry), + ), + ], + ), + ); + } + if (loading) { + return const SizedBox( + key: Key('paged-list-loading'), + height: 56, + child: Center( + child: SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.blue, + ), + ), + ), + ); + } + if (hasMore) { + return Center( + child: TextButton( + key: const Key('paged-list-load-more'), + onPressed: () => unawaited(onRetry()), + child: Text(l10n.loadMore), + ), + ); + } + if (hasItems) { + return Padding( + key: const Key('paged-list-no-more'), + padding: const EdgeInsets.symmetric(vertical: 14), + child: Text( + l10n.noMore, + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.muted, fontSize: 12), + ), + ); + } + return const SizedBox(height: 16); + } +} diff --git a/lib/pages/favorites/favorites_page.dart b/lib/pages/favorites/favorites_page.dart index 3f68c21..cb1b355 100644 --- a/lib/pages/favorites/favorites_page.dart +++ b/lib/pages/favorites/favorites_page.dart @@ -16,6 +16,7 @@ import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/paged_list_footer.dart'; import '../../core/widgets/remote_image.dart'; import '../../core/widgets/subpage_header.dart'; import '../../l10n/app_localizations.dart'; @@ -35,6 +36,7 @@ const double _metaFontSize = 11; const double _titleFontSize = 15; const int _maxTags = 2; const double _emptyListHeight = 360; +const double _loadMoreThreshold = 240; final RegExp _tagSplitter = RegExp(r'[,/、·]'); @@ -156,6 +158,15 @@ class _FavoritesBody extends StatelessWidget { onRemove: () => _remove(context, drama.id), ), ], + PagedListFooter( + loading: favorites.loadingMore, + hasMore: favorites.hasMore, + hasItems: favorites.cards.isNotEmpty, + errorMessage: favorites.loadMoreError == null + ? null + : errorText(l10n, favorites.loadMoreError, l10n.favoritesError), + onRetry: favorites.loadMore, + ), ], ); } @@ -164,26 +175,42 @@ class _FavoritesBody extends StatelessWidget { FavoriteController favorites, { required List children, }) { - return AppPullToRefresh( - onRefresh: favorites.load, - child: ListView( - physics: AppPullToRefresh.physics, - padding: const EdgeInsets.fromLTRB( - _pageHorizontal, - 4, - _pageHorizontal, - 28, + return NotificationListener( + onNotification: _onScroll, + child: AppPullToRefresh( + onRefresh: favorites.load, + child: ListView( + physics: AppPullToRefresh.physics, + padding: const EdgeInsets.fromLTRB( + _pageHorizontal, + 4, + _pageHorizontal, + 28, + ), + children: children, ), - children: children, ), ); } + bool _onScroll(ScrollNotification notification) { + if (notification.depth != 0) return false; + final reachedBottom = + notification.metrics.extentAfter <= _loadMoreThreshold; + if (reachedBottom && + (notification is ScrollUpdateNotification || + notification is ScrollEndNotification || + notification is OverscrollNotification)) { + unawaited(favorites.loadMore()); + } + return false; + } + Widget _summary(AppLocalizations l10n) { return Align( alignment: Alignment.centerLeft, child: Text( - l10n.favoritesCount(favorites.cards.length), + l10n.favoritesCount(favorites.total), key: const Key('favorites-count'), style: const TextStyle( color: AppColors.muted, diff --git a/lib/pages/mine/mine_logic.dart b/lib/pages/mine/mine_logic.dart index b60be77..6be582e 100644 --- a/lib/pages/mine/mine_logic.dart +++ b/lib/pages/mine/mine_logic.dart @@ -22,13 +22,14 @@ class MineLogic extends ChangeNotifier { if (!_disposed) notifyListeners(); } - /// 最近观看:服务端只给 dramaId/位置,剧名封面用目录卡片按 id 映射。 + /// 最近观看:取分页首屏,剧名封面仍由目录卡片按 id 映射。 Future refreshRecent() async { recentLoading = true; recentError = null; notify(); try { - recent = await history.recent(limit: 20); + final page = await history.recent(pageIndex: 1, pageSize: 20); + recent = page.list; } catch (_) { recentError = 'progressError'; } finally { diff --git a/lib/pages/watch_records/watch_records_page.dart b/lib/pages/watch_records/watch_records_page.dart index 5daa2b8..95c291f 100644 --- a/lib/pages/watch_records/watch_records_page.dart +++ b/lib/pages/watch_records/watch_records_page.dart @@ -17,6 +17,7 @@ import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/paged_list_footer.dart'; import '../../core/widgets/subpage_header.dart'; import '../../l10n/app_localizations.dart'; import '../player/open_player.dart'; @@ -26,6 +27,7 @@ import 'widgets/watch_record_card.dart'; const double _pageHorizontal = 14; const double _statsFontSize = 12; const double _emptyListHeight = 360; +const double _loadMoreThreshold = 240; /// 独立观看记录页。 class WatchRecordsPage extends StatelessWidget { @@ -141,31 +143,56 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> { onOpen: record.drama == null ? null : () => _open(record), ), ], + PagedListFooter( + loading: controller.loadingMore, + hasMore: controller.hasMore, + hasItems: controller.records.isNotEmpty, + errorMessage: controller.loadMoreError == null + ? null + : errorText(l10n, controller.loadMoreError, l10n.progressError), + onRetry: controller.loadMore, + ), ], ); } Widget _refreshable({required List children}) { - return AppPullToRefresh( - onRefresh: controller.load, - child: ListView( - physics: AppPullToRefresh.physics, - padding: const EdgeInsets.fromLTRB( - _pageHorizontal, - 4, - _pageHorizontal, - 28, + return NotificationListener( + onNotification: _onScroll, + child: AppPullToRefresh( + onRefresh: controller.load, + child: ListView( + physics: AppPullToRefresh.physics, + padding: const EdgeInsets.fromLTRB( + _pageHorizontal, + 4, + _pageHorizontal, + 28, + ), + children: children, ), - children: children, ), ); } + bool _onScroll(ScrollNotification notification) { + if (notification.depth != 0) return false; + final reachedBottom = + notification.metrics.extentAfter <= _loadMoreThreshold; + if (reachedBottom && + (notification is ScrollUpdateNotification || + notification is ScrollEndNotification || + notification is OverscrollNotification)) { + unawaited(controller.loadMore()); + } + return false; + } + Widget _summary(AppLocalizations l10n) { return Row( children: [ Text( - l10n.watchRecordsCount(controller.records.length), + l10n.watchRecordsCount(controller.total), key: const Key('watch-records-count'), style: const TextStyle( color: AppColors.muted, diff --git a/lib/pages/watch_records/widgets/watch_record_card.dart b/lib/pages/watch_records/widgets/watch_record_card.dart index 5bb67cc..bdc6d28 100644 --- a/lib/pages/watch_records/widgets/watch_record_card.dart +++ b/lib/pages/watch_records/widgets/watch_record_card.dart @@ -39,17 +39,22 @@ class WatchRecordCard extends StatelessWidget { @override Widget build(BuildContext context) { - if (record.detailFailed) { + final hasMetadata = record.hasDisplayMetadata; + if (record.detailFailed && !hasMetadata) { return _FailedRecordTile(record: record, onRetry: onRetry); } final drama = record.drama; - if (drama == null || record.detailLoading) { + if (!hasMetadata && (drama == null || record.detailLoading)) { return _loadingCard(); } final l10n = AppLocalizations.of(context)!; - final episodeLabel = record.episodeNumber > 0 + final episodeNumber = record.episodeNumber; + final episodeLabel = episodeNumber > 0 ? l10n.episodeLabel(record.episodeNumber) : l10n.episodeUnavailable; + final title = record.displayTitle.isEmpty + ? episodeLabel + : record.displayTitle; return _card( onTap: onOpen, child: Stack( @@ -60,10 +65,14 @@ class WatchRecordCard extends StatelessWidget { ClipRRect( borderRadius: BorderRadius.circular(_coverRadius), child: RemoteImage( - drama.posterUrl, + record.displayCover, key: ValueKey('watch-record-cover-${record.dramaId}'), width: _coverWidth, height: _coverHeight, + showFallbackIcon: true, + fallbackIconKey: ValueKey( + 'watch-record-cover-placeholder-${record.dramaId}', + ), ), ), const SizedBox(width: 12), @@ -74,7 +83,7 @@ class WatchRecordCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - drama.title, + title, key: ValueKey('watch-record-title-${record.dramaId}'), maxLines: 2, overflow: TextOverflow.ellipsis, @@ -85,14 +94,18 @@ class WatchRecordCard extends StatelessWidget { ), ), const SizedBox(height: 4), - Text( - episodeLabel, - key: ValueKey('watch-record-episode-${record.dramaId}'), - style: const TextStyle( - color: AppColors.muted, - fontSize: _statsFontSize, + if (title != episodeLabel) ...[ + Text( + episodeLabel, + key: ValueKey( + 'watch-record-episode-${record.dramaId}', + ), + style: const TextStyle( + color: AppColors.muted, + fontSize: _statsFontSize, + ), ), - ), + ], const SizedBox(height: 8), ClipRRect( borderRadius: BorderRadius.circular(_progressRadius), diff --git a/test/integration/live_watch_flow_test.dart b/test/integration/live_watch_flow_test.dart index aafd8df..9973162 100644 --- a/test/integration/live_watch_flow_test.dart +++ b/test/integration/live_watch_flow_test.dart @@ -14,7 +14,8 @@ import 'package:fruitshorts/core/data/remote/watch_api.dart'; /// 用一个一次性邮箱注册(需要登录态的接口才会通);服务没起时自动跳过。 void main() { const baseUrl = 'http://127.0.0.1:8081'; - final email = 'k1.selftest.${DateTime.now().millisecondsSinceEpoch}@example.com'; + final email = + 'k1.selftest.${DateTime.now().millisecondsSinceEpoch}@example.com'; const password = 'Passw0rd!23'; AuthSession? session; @@ -98,17 +99,17 @@ void main() { expect(resume?.position, position); // 最近观看里能看到这部剧 - final recent = await watch.recent(limit: 20); - expect(recent.map((item) => item.dramaId), contains(card.id)); + final recent = await watch.recent(pageSize: 20); + expect(recent.list.map((item) => item.dramaId), contains(card.id)); // 5) 收藏 → 出现在收藏列表 → 取消收藏 final savedState = await library.toggleFavorite(card.id); expect(savedState, isTrue); final favorites = await library.favorites(); - expect(favorites.map((drama) => drama.id), contains(card.id)); + expect(favorites.list.map((drama) => drama.id), contains(card.id)); await library.removeFavorite(card.id); final afterRemove = await library.favorites(); - expect(afterRemove.map((drama) => drama.id), isNot(contains(card.id))); + expect(afterRemove.list.map((drama) => drama.id), isNot(contains(card.id))); }); } diff --git a/test/library/favorite_controller_test.dart b/test/library/favorite_controller_test.dart index 0e5a96c..b02b89d 100644 --- a/test/library/favorite_controller_test.dart +++ b/test/library/favorite_controller_test.dart @@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/page_result.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/services/favorite_controller.dart'; @@ -49,7 +50,7 @@ void main() { }); test('should_expose_error_and_keep_old_state_when_list_fails', () async { - final repository = InMemoryFavoriteRepository()..failList = true; + final repository = InMemoryFavoriteRepository()..failPage = true; final controller = FavoriteController(repository); await controller.load(); @@ -115,4 +116,66 @@ void main() { expect(controller.savedIds, isEmpty); expect(controller.cards, isEmpty); }); + + test('should_append_unique_cards_and_stop_when_no_more_pages', () async { + final repository = _PagedFavoriteRepository({ + 1: PageResult(total: 3, list: [_card('a'), _card('b')]), + 2: PageResult(total: 3, list: [_card('b'), _card('c')]), + }); + final controller = FavoriteController(repository, pageSize: 2); + + await controller.load(); + await controller.loadMore(); + await controller.loadMore(); + + expect(controller.cards.map((card) => card.id), ['a', 'b', 'c']); + expect(controller.total, 3); + expect(controller.hasMore, isFalse); + expect(repository.pageRequests, [1, 2]); + }); + + test('should_retry_next_page_when_load_more_fails', () async { + final repository = _PagedFavoriteRepository({ + 1: PageResult(total: 2, list: [_card('a')]), + 2: PageResult(total: 2, list: [_card('b')]), + })..failPages.add(2); + final controller = FavoriteController(repository, pageSize: 1); + + await controller.load(); + await controller.loadMore(); + + expect(controller.cards.map((card) => card.id), ['a']); + expect(controller.loadMoreError, 'favoritesError'); + expect(controller.hasMore, isTrue); + + repository.failPages.clear(); + await controller.loadMore(); + + expect(controller.cards.map((card) => card.id), ['a', 'b']); + expect(controller.loadMoreError, isNull); + expect(repository.pageRequests, [1, 2, 2]); + }); +} + +class _PagedFavoriteRepository implements FavoriteRepository { + _PagedFavoriteRepository(this.pages); + + final Map> pages; + final List pageRequests = []; + final Set failPages = {}; + + @override + Future> page({int pageIndex = 1, int pageSize = 20}) async { + pageRequests.add(pageIndex); + if (failPages.contains(pageIndex)) { + throw StateError('page unavailable'); + } + return pages[pageIndex] ?? const PageResult(); + } + + @override + Future remove(String dramaId) async {} + + @override + Future toggle(String dramaId) async => true; } diff --git a/test/library/favorites_page_test.dart b/test/library/favorites_page_test.dart index 2091e85..b1874c8 100644 --- a/test/library/favorites_page_test.dart +++ b/test/library/favorites_page_test.dart @@ -137,7 +137,7 @@ void main() { testWidgets('should_show_retry_when_favorite_list_fails', (tester) async { final auth = await _auth(signedIn: true); - final repository = InMemoryFavoriteRepository()..failList = true; + final repository = InMemoryFavoriteRepository()..failPage = true; final favorites = FavoriteController(repository); await _pumpPage(tester, auth: auth, favorites: favorites); @@ -159,7 +159,7 @@ void main() { await _pumpPage(tester, auth: auth, favorites: favorites); expect(find.byKey(const Key('auth-gate-panel')), findsOneWidget); - expect(repository.listCalls, 0); + expect(repository.pageCalls, 0); }, ); diff --git a/test/library/library_api_test.dart b/test/library/library_api_test.dart new file mode 100644 index 0000000..26f5c0c --- /dev/null +++ b/test/library/library_api_test.dart @@ -0,0 +1,66 @@ +// Author: fruit.com.cn + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/library_api.dart'; + +import '../support/fake_http_adapter.dart'; + +void main() { + test('should_request_favorites_with_page_and_parse_page_result', () async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': { + 'total': '21', + 'list': [ + {'id': '9', 'title': '收藏剧', 'cover': '/files/9.png'}, + ], + }, + }), + ), + ); + final api = LibraryApi( + ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), + ); + + final page = await api.favorites(pageIndex: 2, pageSize: 10); + + final request = adapter.requests.single; + expect(request.uri.path, '/app/favorites'); + expect(request.queryParameters['pageIndex'], '2'); + expect(request.queryParameters['pageSize'], '10'); + expect(page.total, 21); + expect(page.list.single.id, '9'); + expect(page.list.single.title, '收藏剧'); + }); + + test( + 'should_wrap_legacy_list_when_backend_has_not_deployed_paging', + () async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': [ + {'id': '3', 'title': '旧收藏'}, + ], + }), + ), + ); + final api = LibraryApi( + ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), + ); + + final page = await api.favorites(); + + expect(page.total, 1); + expect(page.list.single.title, '旧收藏'); + }, + ); +} diff --git a/test/support/fake_watch_history.dart b/test/support/fake_watch_history.dart index 6616b7a..7643564 100644 --- a/test/support/fake_watch_history.dart +++ b/test/support/fake_watch_history.dart @@ -1,4 +1,7 @@ +// Author: fruit.com.cn + import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:fruitshorts/core/data/models/page_result.dart'; import 'package:fruitshorts/core/data/models/watch_progress.dart'; /// 记录每次进度读取/上报,方便断言"读了哪部剧、上报了哪一集"。 @@ -57,9 +60,19 @@ class FakeWatchHistory implements WatchHistoryRepository { } @override - Future> recent({int limit = 20}) async { + Future> recent({ + int pageIndex = 1, + int pageSize = 20, + }) async { if (failReads) throw StateError('history unavailable'); - return recentRecords.take(limit).toList(); + final offset = (pageIndex - 1) * pageSize; + if (offset >= recentRecords.length) { + return PageResult(total: recentRecords.length); + } + return PageResult( + total: recentRecords.length, + list: recentRecords.skip(offset).take(pageSize).toList(), + ); } @override diff --git a/test/watch/watch_api_test.dart b/test/watch/watch_api_test.dart new file mode 100644 index 0000000..e9e765e --- /dev/null +++ b/test/watch/watch_api_test.dart @@ -0,0 +1,83 @@ +// Author: fruit.com.cn + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/watch_api.dart'; + +import '../support/fake_http_adapter.dart'; + +void main() { + test('should_request_recent_with_page_and_parse_display_fields', () async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': { + 'total': '18', + 'list': [ + { + 'dramaId': '12', + 'episodeId': '88', + 'dramaTitle': '剧名', + 'dramaCover': '/files/12.png', + 'episodeNumber': 6, + 'totalEpisodes': 68, + 'positionMs': '80000', + 'durationMs': '120000', + }, + ], + }, + }), + ), + ); + final api = WatchApi( + ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), + ); + + final page = await api.recent(pageIndex: 2, pageSize: 10); + + final request = adapter.requests.single; + expect(request.uri.path, '/app/watch/recent'); + expect(request.queryParameters['pageIndex'], '2'); + expect(request.queryParameters['pageSize'], '10'); + expect(request.queryParameters.containsKey('limit'), isFalse); + expect(page.total, 18); + expect(page.list.single.dramaTitle, '剧名'); + expect(page.list.single.dramaCover, '/files/12.png'); + expect(page.list.single.episodeNumber, 6); + expect(page.list.single.totalEpisodes, 68); + }); + + test( + 'should_wrap_legacy_list_when_backend_has_not_deployed_paging', + () async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': [ + { + 'dramaId': '5', + 'episodeId': '7', + 'positionMs': '1000', + 'durationMs': '2000', + }, + ], + }), + ), + ); + final api = WatchApi( + ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), + ); + + final page = await api.recent(); + + expect(page.total, 1); + expect(page.list.single.dramaId, '5'); + }, + ); +} diff --git a/test/watch/watch_records_controller_test.dart b/test/watch/watch_records_controller_test.dart index 105f17d..82ae34f 100644 --- a/test/watch/watch_records_controller_test.dart +++ b/test/watch/watch_records_controller_test.dart @@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/feed_page.dart'; import 'package:fruitshorts/core/data/models/home_feed.dart'; +import 'package:fruitshorts/core/data/models/page_result.dart'; import 'package:fruitshorts/core/data/models/watch_progress.dart'; import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; @@ -93,6 +94,55 @@ void main() { ); }); + test('should_append_unique_records_and_stop_when_no_more_pages', () async { + final history = _PagedWatchHistory({ + 1: PageResult(total: 3, list: [_progress('1'), _progress('2')]), + 2: PageResult(total: 3, list: [_progress('2'), _progress('3')]), + }); + final catalog = ScriptedWatchCatalog(); + final controller = WatchRecordsController( + history: history, + catalog: catalog, + pageSize: 2, + ); + + await controller.load(); + await controller.loadMore(); + await controller.loadMore(); + + expect(controller.records.map((record) => record.dramaId), ['1', '2', '3']); + expect(controller.total, 3); + expect(controller.hasMore, isFalse); + expect(history.pageRequests, [1, 2]); + expect(catalog.calls, ['1', '2', '3']); + }); + + test('should_retry_next_page_when_watch_load_more_fails', () async { + final history = _PagedWatchHistory({ + 1: PageResult(total: 2, list: [_progress('1')]), + 2: PageResult(total: 2, list: [_progress('2')]), + })..failPages.add(2); + final controller = WatchRecordsController( + history: history, + catalog: ScriptedWatchCatalog(), + pageSize: 1, + ); + + await controller.load(); + await controller.loadMore(); + + expect(controller.records.map((record) => record.dramaId), ['1']); + expect(controller.loadMoreError, 'progressError'); + expect(controller.hasMore, isTrue); + + history.failPages.clear(); + await controller.loadMore(); + + expect(controller.records.map((record) => record.dramaId), ['1', '2']); + expect(controller.loadMoreError, isNull); + expect(history.pageRequests, [1, 2, 2]); + }); + test('should_keep_existing_records_when_refresh_fails', () async { final history = ControlledWatchHistory([_progress('1')]); final controller = WatchRecordsController( @@ -213,6 +263,7 @@ class ControlledWatchHistory implements WatchHistoryRepository { final List items; bool failRecent = false; Object? recentError; + final List recentPages = []; bool failRemove = false; bool failClear = false; Completer? removeGate; @@ -230,10 +281,21 @@ class ControlledWatchHistory implements WatchHistoryRepository { }) async => null; @override - Future> recent({int limit = 20}) async { + Future> recent({ + int pageIndex = 1, + int pageSize = 20, + }) async { + recentPages.add(pageIndex); if (recentError != null) throw recentError!; if (failRecent) throw StateError('recent unavailable'); - return items.take(limit).toList(); + final offset = (pageIndex - 1) * pageSize; + if (offset >= items.length) { + return PageResult(total: items.length); + } + return PageResult( + total: items.length, + list: items.skip(offset).take(pageSize).toList(), + ); } @override @@ -285,3 +347,40 @@ class ScriptedWatchCatalog implements CatalogRepository { return _detail(dramaId); } } + +class _PagedWatchHistory implements WatchHistoryRepository { + _PagedWatchHistory(this.pages); + + final Map> pages; + final List pageRequests = []; + final Set failPages = {}; + + @override + Future> recent({ + int pageIndex = 1, + int pageSize = 20, + }) async { + pageRequests.add(pageIndex); + if (failPages.contains(pageIndex)) { + throw StateError('page unavailable'); + } + return pages[pageIndex] ?? const PageResult(); + } + + @override + Future progressFor(String dramaId) async => null; + + @override + Future save({ + required String episodeId, + required Duration position, + Duration? duration, + bool finished = false, + }) async => null; + + @override + Future remove(String dramaId) async {} + + @override + Future clear() async {} +} diff --git a/test/watch/watch_records_page_test.dart b/test/watch/watch_records_page_test.dart index 4d4cca0..f4146da 100644 --- a/test/watch/watch_records_page_test.dart +++ b/test/watch/watch_records_page_test.dart @@ -6,6 +6,7 @@ import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/feed_page.dart'; import 'package:fruitshorts/core/data/models/home_feed.dart'; +import 'package:fruitshorts/core/data/models/page_result.dart'; import 'package:fruitshorts/core/data/models/watch_progress.dart'; import 'package:fruitshorts/core/data/remote/api_client.dart'; import 'package:fruitshorts/core/data/remote/auth_api.dart'; @@ -98,6 +99,56 @@ void main() { expect(list.physics, AppPullToRefresh.physics); }); + testWidgets('should_render_backend_title_episode_and_cover_metadata', ( + tester, + ) async { + final auth = await _auth(signedIn: true); + final history = CountingWatchHistory([ + _progress( + 'metadata', + dramaTitle: 'Backend Title', + dramaCover: '/files/backend.png', + episodeNumber: 3, + ), + ]); + + await _pumpPage( + tester, + auth: auth, + history: history, + catalog: _AlwaysFailingWatchCatalog(), + ); + + expect(find.text('Backend Title'), findsOneWidget); + expect(find.text('Episode 3'), findsOneWidget); + expect( + find.byKey(const Key('watch-record-cover-metadata')), + findsOneWidget, + ); + }); + + testWidgets('should_fallback_to_episode_title_and_cover_placeholder', ( + tester, + ) async { + final auth = await _auth(signedIn: true); + final history = CountingWatchHistory([ + _progress('fallback', dramaTitle: '', episodeNumber: 4), + ]); + + await _pumpPage( + tester, + auth: auth, + history: history, + catalog: _AlwaysFailingWatchCatalog(), + ); + + expect(find.text('Episode 4'), findsOneWidget); + expect( + find.byKey(const Key('watch-record-cover-placeholder-fallback')), + findsOneWidget, + ); + }); + testWidgets( 'should_show_failed_placeholder_and_keep_other_cards_when_detail_fails', (tester) async { @@ -238,9 +289,17 @@ void main() { }); } -WatchProgress _progress(String dramaId) => WatchProgress( +WatchProgress _progress( + String dramaId, { + String? dramaTitle, + String? dramaCover, + int? episodeNumber, +}) => WatchProgress( dramaId: dramaId, episodeId: '$dramaId-1', + dramaTitle: dramaTitle, + dramaCover: dramaCover, + episodeNumber: episodeNumber, position: const Duration(seconds: 12), duration: const Duration(seconds: 60), updatedAt: DateTime(2026, 10, 17, 12), @@ -300,9 +359,19 @@ class CountingWatchHistory extends InMemoryWatchHistoryRepository { int clearCalls = 0; @override - Future> recent({int limit = 20}) async { + Future> recent({ + int pageIndex = 1, + int pageSize = 20, + }) async { recentCalls++; - return visibleRecords.take(limit).toList(); + final offset = (pageIndex - 1) * pageSize; + if (offset >= visibleRecords.length) { + return PageResult(total: visibleRecords.length); + } + return PageResult( + total: visibleRecords.length, + list: visibleRecords.skip(offset).take(pageSize).toList(), + ); } @override @@ -314,7 +383,10 @@ class CountingWatchHistory extends InMemoryWatchHistoryRepository { class _FailingRecentHistory extends InMemoryWatchHistoryRepository { @override - Future> recent({int limit = 20}) async { + Future> recent({ + int pageIndex = 1, + int pageSize = 20, + }) async { throw const ApiException(100000, '账号不存在,请先注册'); } } @@ -347,3 +419,24 @@ class _FailingWatchCatalog implements CatalogRepository { return _delegate.fetchDrama(dramaId); } } + +class _AlwaysFailingWatchCatalog implements CatalogRepository { + @override + Future fetchDrama(String dramaId) async { + throw StateError('missing detail'); + } + + @override + Future fetchDiscoverFeed() async => const HomeFeed(); + + @override + Future fetchFeed({ + List types = const [], + String? categoryId, + String? cursor, + int size = 10, + }) async => const FeedPage(); + + @override + Future fetchHomeFeed() async => const HomeFeed(); +} From 78114fa46d25a0d591390a6e2fcfb1280b112055 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 12:42:58 +0800 Subject: [PATCH 012/129] =?UTF-8?q?fix(library):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E8=A7=82=E7=9C=8B=E5=8E=86=E5=8F=B2=E6=A0=87=E9=A2=98=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/2026-10-22-library-paging-ui.md | 9 ++++ docs/plans/PROGRESS.md | 46 ++++++++++++++++++- lib/core/data/remote/library_api.dart | 7 --- lib/core/data/remote/watch_api.dart | 7 --- .../services/watch_records_controller.dart | 36 +++++++++++---- .../watch_records/watch_records_page.dart | 14 +++++- test/integration/live_watch_flow_test.dart | 14 ++++++ test/library/library_api_test.dart | 25 ---------- test/watch/watch_api_test.dart | 30 ------------ test/watch/watch_records_controller_test.dart | 23 ++++++++++ test/watch/watch_records_page_test.dart | 12 ++--- 11 files changed, 135 insertions(+), 88 deletions(-) diff --git a/docs/plans/2026-10-22-library-paging-ui.md b/docs/plans/2026-10-22-library-paging-ui.md index 3c151f2..f3c8202 100644 --- a/docs/plans/2026-10-22-library-paging-ui.md +++ b/docs/plans/2026-10-22-library-paging-ui.md @@ -20,3 +20,12 @@ - loadMore 与 refresh 通过请求代次隔离,旧响应不得覆盖新列表。 - 空页立即停止加载;`loaded >= total` 后不再请求。 - 不新增依赖、不提交现有 `pubspec.lock`,不启停服务、不操作真机。 + +## 2026-10-22 范围修订 + +- 分页协议和 Controller 分页状态保持不改。 +- 观看历史优先使用后端 `dramaTitle/dramaCover/episodeNumber`,只有标题为空时才预取详情。 +- 标题为空且详情也未返回标题时,用本地化“第 N 集”兜底。 +- 有标题的条目点击播放时再按需加载详情,不在列表加载阶段产生 N+1。 +- 使用当前真实后端校验第 1/2 页请求参数、total 与 list;若运行实例仍忽略分页, + 只在 PROGRESS 记录证据,不做前端全量兜底。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index de7ad9f..38e1036 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -628,8 +628,7 @@ UTF-8 解码、3000 字符截断、非 JSON、二进制字节数、DioException - 收藏页、观看历史页接入滚动触底加载、下拉刷新、通用分页 footer、总数展示。 - 观看卡片优先展示分页接口的 `dramaTitle/dramaCover/episodeNumber`; 标题为空时显示本地化“第 N 集”,封面缺失时显示占位图标。 -- API 兼容滚动发布窗口:后端仍返回旧 List 时包装为 `PageResult(total=list.length)`; - 服务端新 PageResult 正常解析。 +- API 严格解析服务端 PageResult;旧 List 不做客户端全量包装,由后端部署新契约解决。 - MineLogic 改用分页首屏 `list`,不再调用旧 limit 接口。 ### 门禁 @@ -656,3 +655,46 @@ $ flutter test --no-pub 观看标题/集号/封面展示及空标题兜底均通过;全量静态分析零告警,632 项测试全过。 未新增依赖、未启停服务、未操作真机;现有 `pubspec.lock` 改动未提交。 + +### 范围修订:观看标题优先与按需详情 + +- 分页协议、Repository 与 Controller 分页状态保持不变。 +- `_hydrate` 只对 `dramaTitle` 为空的记录补拉详情;后端已有标题时不再请求详情。 +- 有标题的记录点击播放时才调用 `detailForOpen` 按需加载详情,避免列表阶段 N+1。 +- 标题仍为空时使用本地化“第 N 集”,封面缺失继续显示占位。 +- 移除了旧 List 自动包装为 PageResult 的前端全量兜底,避免掩盖后端契约问题。 + +真实后端分页取证(仅测试账号写入,不启停服务): + +```text +GET /app/watch/recent?pageIndex=1&pageSize=1 +{"shape":"list","length":2,"dramaIds":["1","5"]} +GET /app/watch/recent?pageIndex=2&pageSize=1 +{"shape":"list","length":2,"dramaIds":["1","5"]} +GET /app/favorites?pageIndex=1&pageSize=1 +{"shape":"list","length":2,"dramaIds":["1","5"]} +GET /app/favorites?pageIndex=2&pageSize=1 +{"shape":"list","length":2,"dramaIds":["1","5"]} +``` + +解读:当前 8081 运行实例仍是旧包,返回 List 且忽略 pageIndex/pageSize;第 1/2 页 +均返回相同两条数据。客户端不做全量兜底;live smoke 检测到旧 List 后明确标记 skipped, +等待后端新包部署后再执行分页真实联调。 + +门禁: + +```text +$ flutter test --no-pub test/integration/live_watch_flow_test.dart test/library/library_api_test.dart test/watch/watch_api_test.dart test/watch/watch_records_controller_test.dart test/watch/watch_records_page_test.dart +00:02 +22 ~1: All tests passed! + +$ dart analyze lib test +Analyzing lib, test... +No issues found! + +$ flutter test --no-pub +00:52 +630 ~1: All tests passed! +``` + +解读:有 dramaTitle 时不触发详情请求、点击时才加载详情、空标题兜底、 +第 2 页追加去重和 hasMore 行为均通过;全量分析零告警,630 项通过、1 项旧服务 +能力探测跳过。`pubspec.lock` 既有改动未提交。 diff --git a/lib/core/data/remote/library_api.dart b/lib/core/data/remote/library_api.dart index ad19fa8..3eac576 100644 --- a/lib/core/data/remote/library_api.dart +++ b/lib/core/data/remote/library_api.dart @@ -32,13 +32,6 @@ class LibraryApi { if (data is Map) { return PageResult.fromJson(data, Drama.fromCardJson); } - if (data is List) { - final list = data - .whereType>() - .map(Drama.fromCardJson) - .toList(growable: false); - return PageResult(total: list.length, list: list); - } return const PageResult(); } diff --git a/lib/core/data/remote/watch_api.dart b/lib/core/data/remote/watch_api.dart index a169856..f63b5d9 100644 --- a/lib/core/data/remote/watch_api.dart +++ b/lib/core/data/remote/watch_api.dart @@ -52,13 +52,6 @@ class WatchApi { if (data is Map) { return PageResult.fromJson(data, WatchProgress.fromJson); } - if (data is List) { - final list = data - .whereType>() - .map(WatchProgress.fromJson) - .toList(growable: false); - return PageResult(total: list.length, list: list); - } return const PageResult(); } diff --git a/lib/core/data/services/watch_records_controller.dart b/lib/core/data/services/watch_records_controller.dart index 569de4f..ec70015 100644 --- a/lib/core/data/services/watch_records_controller.dart +++ b/lib/core/data/services/watch_records_controller.dart @@ -130,6 +130,15 @@ class WatchRecordsController extends ChangeNotifier { _notify(); } + /// 点击有标题的记录时再按需加载详情,避免列表阶段产生 N+1。 + Future detailForOpen(String dramaId) async { + final cached = _detailCache[dramaId]; + if (cached != null) return cached; + await _loadDetail(dramaId); + _notify(); + return _detailCache[dramaId]; + } + /// 删除单条记录,先更新 UI,失败按原位置回滚。 Future remove(String dramaId) async { final index = _recordIndex(dramaId); @@ -201,11 +210,12 @@ class WatchRecordsController extends ChangeNotifier { } Future _hydrate(List dramaIds) async { - for (final id in dramaIds) { + final missingTitles = dramaIds.where(_hasMissingTitle).toSet().toList(); + for (final id in missingTitles) { if (_detailCache.containsKey(id)) await _loadDetail(id); } final queue = [ - for (final id in dramaIds) + for (final id in missingTitles) if (!_detailCache.containsKey(id)) id, ]; final workerCount = queue.length < detailConcurrency @@ -222,9 +232,15 @@ class WatchRecordsController extends ChangeNotifier { ]); } - Future _loadDetail(String dramaId) async { + bool _hasMissingTitle(String dramaId) { final index = _recordIndex(dramaId); - if (index < 0) return; + if (index < 0) return false; + return (records[index].progress.dramaTitle?.trim() ?? '').isEmpty; + } + + Future _loadDetail(String dramaId) async { + final index = _recordIndex(dramaId); + if (index < 0) return null; final cached = _detailCache[dramaId]; if (cached != null) { _replaceAt( @@ -235,14 +251,14 @@ class WatchRecordsController extends ChangeNotifier { detailFailed: false, ), ); - return; + return cached; } try { final detail = await _catalog.fetchDrama(dramaId); - if (_disposed) return; + if (_disposed) return null; _detailCache[dramaId] = detail; final current = _recordIndex(dramaId); - if (current < 0) return; + if (current < 0) return detail; _replaceAt( current, records[current].copyWith( @@ -251,6 +267,7 @@ class WatchRecordsController extends ChangeNotifier { detailFailed: false, ), ); + return detail; } catch (failure, stackTrace) { debugPrint( '[WatchRecordsController] detail failed dramaId=$dramaId ' @@ -260,13 +277,14 @@ class WatchRecordsController extends ChangeNotifier { stackTrace: stackTrace, label: '[WatchRecordsController] detail failed dramaId=$dramaId', ); - if (_disposed) return; + if (_disposed) return null; final current = _recordIndex(dramaId); - if (current < 0) return; + if (current < 0) return null; _replaceAt( current, records[current].copyWith(detailLoading: false, detailFailed: true), ); + return null; } } diff --git a/lib/pages/watch_records/watch_records_page.dart b/lib/pages/watch_records/watch_records_page.dart index 95c291f..c8c326a 100644 --- a/lib/pages/watch_records/watch_records_page.dart +++ b/lib/pages/watch_records/watch_records_page.dart @@ -140,7 +140,7 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> { record: record, onRetry: () => controller.retryDetail(record.dramaId), onRemove: () => _remove(record.dramaId), - onOpen: record.drama == null ? null : () => _open(record), + onOpen: () => _open(record), ), ], PagedListFooter( @@ -219,10 +219,20 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> { } Future _open(WatchRecord record) async { + final drama = + record.drama ?? await controller.detailForOpen(record.dramaId); + if (!mounted) return; + if (drama == null) { + final l10n = AppLocalizations.of(context)!; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.detailLoadFailed))); + return; + } final episodeId = record.progress.episodeId; await playDramaFromCatalog( context, - record.drama!, + drama, episodeId: episodeId.isEmpty ? null : episodeId, ); } diff --git a/test/integration/live_watch_flow_test.dart b/test/integration/live_watch_flow_test.dart index 9973162..ffa5c08 100644 --- a/test/integration/live_watch_flow_test.dart +++ b/test/integration/live_watch_flow_test.dart @@ -58,6 +58,20 @@ void main() { } expect(session?.user.uid, isNotEmpty); + final recentProbe = await client.get( + '/app/watch/recent', + query: {'pageIndex': '1', 'pageSize': '1'}, + ); + final favoriteProbe = await client.get( + '/app/favorites', + query: {'pageIndex': '1', 'pageSize': '1'}, + ); + if (recentProbe is! Map || + favoriteProbe is! Map) { + markTestSkipped('运行中的后端仍返回旧版 List,分页契约联调已跳过'); + return; + } + // 1) 首页 → 取第一部剧 final feed = await catalog.homeFeed(); expect(feed.dramas, isNotEmpty); diff --git a/test/library/library_api_test.dart b/test/library/library_api_test.dart index 26f5c0c..127c61c 100644 --- a/test/library/library_api_test.dart +++ b/test/library/library_api_test.dart @@ -38,29 +38,4 @@ void main() { expect(page.list.single.id, '9'); expect(page.list.single.title, '收藏剧'); }); - - test( - 'should_wrap_legacy_list_when_backend_has_not_deployed_paging', - () async { - final adapter = FakeHttpAdapter( - (_) async => FakeHttpResponse( - jsonEncode({ - 'code': 0, - 'message': 'ok', - 'data': [ - {'id': '3', 'title': '旧收藏'}, - ], - }), - ), - ); - final api = LibraryApi( - ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), - ); - - final page = await api.favorites(); - - expect(page.total, 1); - expect(page.list.single.title, '旧收藏'); - }, - ); } diff --git a/test/watch/watch_api_test.dart b/test/watch/watch_api_test.dart index e9e765e..5369cc3 100644 --- a/test/watch/watch_api_test.dart +++ b/test/watch/watch_api_test.dart @@ -50,34 +50,4 @@ void main() { expect(page.list.single.episodeNumber, 6); expect(page.list.single.totalEpisodes, 68); }); - - test( - 'should_wrap_legacy_list_when_backend_has_not_deployed_paging', - () async { - final adapter = FakeHttpAdapter( - (_) async => FakeHttpResponse( - jsonEncode({ - 'code': 0, - 'message': 'ok', - 'data': [ - { - 'dramaId': '5', - 'episodeId': '7', - 'positionMs': '1000', - 'durationMs': '2000', - }, - ], - }), - ), - ); - final api = WatchApi( - ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), - ); - - final page = await api.recent(); - - expect(page.total, 1); - expect(page.list.single.dramaId, '5'); - }, - ); } diff --git a/test/watch/watch_records_controller_test.dart b/test/watch/watch_records_controller_test.dart index 82ae34f..5a415fa 100644 --- a/test/watch/watch_records_controller_test.dart +++ b/test/watch/watch_records_controller_test.dart @@ -16,11 +16,13 @@ import 'package:fruitshorts/core/data/services/watch_records_controller.dart'; WatchProgress _progress( String dramaId, { String episodeId = 'episode-1', + String? dramaTitle, int positionMs = 12000, int durationMs = 60000, }) => WatchProgress( dramaId: dramaId, episodeId: episodeId, + dramaTitle: dramaTitle, position: Duration(milliseconds: positionMs), duration: Duration(milliseconds: durationMs), updatedAt: DateTime(2026, 10, 17, 12), @@ -74,6 +76,27 @@ void main() { expect(controller.error, isNull); }); + test('should_use_backend_title_and_skip_detail_until_opened', () async { + final history = ControlledWatchHistory([ + _progress('12', dramaTitle: '后端标题'), + ]); + final catalog = ScriptedWatchCatalog(); + final controller = WatchRecordsController( + history: history, + catalog: catalog, + ); + + await controller.load(); + + expect(controller.records.single.displayTitle, '后端标题'); + expect(catalog.calls, isEmpty); + + final detail = await controller.detailForOpen('12'); + + expect(detail?.id, '12'); + expect(catalog.calls, ['12']); + }); + test('should_reuse_cached_details_when_records_are_loaded_again', () async { final history = ControlledWatchHistory([_progress('1'), _progress('2')]); final catalog = ScriptedWatchCatalog(); diff --git a/test/watch/watch_records_page_test.dart b/test/watch/watch_records_page_test.dart index f4146da..9b8fc26 100644 --- a/test/watch/watch_records_page_test.dart +++ b/test/watch/watch_records_page_test.dart @@ -111,16 +111,13 @@ void main() { episodeNumber: 3, ), ]); + final catalog = _AlwaysFailingWatchCatalog(); - await _pumpPage( - tester, - auth: auth, - history: history, - catalog: _AlwaysFailingWatchCatalog(), - ); + await _pumpPage(tester, auth: auth, history: history, catalog: catalog); expect(find.text('Backend Title'), findsOneWidget); expect(find.text('Episode 3'), findsOneWidget); + expect(catalog.calls, isEmpty); expect( find.byKey(const Key('watch-record-cover-metadata')), findsOneWidget, @@ -421,8 +418,11 @@ class _FailingWatchCatalog implements CatalogRepository { } class _AlwaysFailingWatchCatalog implements CatalogRepository { + final List calls = []; + @override Future fetchDrama(String dramaId) async { + calls.add(dramaId); throw StateError('missing detail'); } From 57f30290186aa86d86ee2ac3cfa4d20274a51240 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 12:45:27 +0800 Subject: [PATCH 013/129] =?UTF-8?q?feat(library):=20=E6=94=B6=E8=97=8F?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E5=B1=95=E7=A4=BA=E7=AE=80=E4=BB=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-10-22-favorite-card-synopsis.md | 20 +++++++++ docs/plans/PROGRESS.md | 32 ++++++++++++++ lib/pages/favorites/favorites_page.dart | 24 +++++++++- test/library/favorites_page_test.dart | 44 ++++++++++++++++++- 4 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 docs/plans/2026-10-22-favorite-card-synopsis.md diff --git a/docs/plans/2026-10-22-favorite-card-synopsis.md b/docs/plans/2026-10-22-favorite-card-synopsis.md new file mode 100644 index 0000000..8923018 --- /dev/null +++ b/docs/plans/2026-10-22-favorite-card-synopsis.md @@ -0,0 +1,20 @@ +# 收藏卡片简介 + +## 目标 + +- 收藏卡片在标题/标签下方显示后端 `synopsis`,最多 2 行并省略溢出。 +- 空白简介不创建文本节点、不占高度,不显示 `null/undefined`。 +- 保持封面 80x106 和现有卡片高度,不引入布局溢出。 + +## 实现 + +1. `_FavoriteCard` 对 synopsis 做 trim 后条件渲染。 +2. 简介使用 `Flexible` 占据标题、标签与底部统计之间的剩余空间, + `maxLines: 2`、`TextOverflow.ellipsis`、次要文案色和字号。 +3. 无简介时继续使用原 Spacer,保持原布局。 +4. widget 测试覆盖有简介、空简介和卡片高度稳定性。 + +## 约束 + +- 不改接口、后端、收藏分页。 +- 不新增依赖,不提交现有 `pubspec.lock`,不启停服务、不操作真机。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 38e1036..eee3f2f 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -698,3 +698,35 @@ $ flutter test --no-pub 解读:有 dramaTitle 时不触发详情请求、点击时才加载详情、空标题兜底、 第 2 页追加去重和 hasMore 行为均通过;全量分析零告警,630 项通过、1 项旧服务 能力探测跳过。`pubspec.lock` 既有改动未提交。 + +## 2026-10-22 收藏卡片简介 + +- 分支:`feature/favorite-card-synopsis` +- 基线:`develop` / `origin/develop` = `9093700` +- 计划:`docs/plans/2026-10-22-favorite-card-synopsis.md` + +### 实现 + +- `_FavoriteCard` 在标题/标签下方条件渲染 `drama.synopsis.trim()`。 +- 简介最多 2 行、`TextOverflow.ellipsis`,使用 `AppColors.muted`、12 号字和 + `1.15` 行高,由 `Flexible` 占据剩余空间,保持封面与卡片原高度。 +- 简介为空或纯空白时不创建文本节点,继续使用原 Spacer,不占高度。 +- 未改接口、后端和收藏分页逻辑。 + +### 门禁 + +```text +$ flutter test --no-pub test/library/favorites_page_test.dart +00:01 +7: All tests passed! + +$ dart analyze lib test +Analyzing lib, test... +No issues found! + +$ flutter test --no-pub +00:51 +631 ~1: All tests passed! +``` + +解读:有简介时文本最多 2 行并省略,空简介不渲染且卡片高度与正常卡片一致; +全量静态分析零告警,631 项通过、1 项运行中旧后端能力探测跳过。 +`pubspec.lock` 既有改动未提交。 diff --git a/lib/pages/favorites/favorites_page.dart b/lib/pages/favorites/favorites_page.dart index cb1b355..11512c3 100644 --- a/lib/pages/favorites/favorites_page.dart +++ b/lib/pages/favorites/favorites_page.dart @@ -34,6 +34,9 @@ const double _removeIconSize = 20; const double _statsFontSize = 12; const double _metaFontSize = 11; const double _titleFontSize = 15; +const double _synopsisFontSize = 12; +const double _synopsisLineHeight = 1.15; +const double _synopsisGap = 4; const int _maxTags = 2; const double _emptyListHeight = 360; const double _loadMoreThreshold = 240; @@ -252,6 +255,7 @@ class _FavoriteCard extends StatelessWidget { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final tags = _tags(); + final synopsis = drama.synopsis.trim(); return Container( key: ValueKey('favorite-card-${drama.id}'), decoration: BoxDecoration( @@ -345,7 +349,25 @@ class _FavoriteCard extends StatelessWidget { ), ), ), - const Spacer(), + if (synopsis.isNotEmpty) ...[ + const SizedBox(height: _synopsisGap), + Flexible( + child: Text( + synopsis, + key: ValueKey( + 'favorite-synopsis-${drama.id}', + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.muted, + fontSize: _synopsisFontSize, + height: _synopsisLineHeight, + ), + ), + ), + ] else + const Spacer(), Row( children: [ const Icon( diff --git a/test/library/favorites_page_test.dart b/test/library/favorites_page_test.dart index b1874c8..21579ef 100644 --- a/test/library/favorites_page_test.dart +++ b/test/library/favorites_page_test.dart @@ -32,10 +32,10 @@ import '../support/fake_interaction_repositories.dart'; import '../support/fake_notice_repository.dart'; import '../support/widget_player.dart'; -Drama _card(String id) => Drama( +Drama _card(String id, {String synopsis = 'synopsis'}) => Drama( id: id, title: 'Drama $id', - synopsis: 'synopsis', + synopsis: synopsis, posterUrl: '/files/$id.png', totalEpisodes: 12, ); @@ -73,6 +73,14 @@ void main() { ); expect(title.style?.color, AppColors.onSurface); expect(title.style?.fontSize, 15); + final synopsis = tester.widget( + find.byKey(const Key('favorite-synopsis-signal')), + ); + expect(synopsis.data, 'synopsis'); + expect(synopsis.maxLines, 2); + expect(synopsis.overflow, TextOverflow.ellipsis); + expect(synopsis.style?.color, AppColors.muted); + expect(synopsis.style?.fontSize, 12); expect( tester.getSize(find.byKey(const Key('favorite-cover-signal'))), const Size(80, 106), @@ -100,6 +108,38 @@ void main() { expect(find.byKey(const Key('favorite-card-signal')), findsNothing); }); + testWidgets('should_hide_blank_synopsis_without_changing_card_height', ( + tester, + ) async { + final auth = await _auth(signedIn: true); + final longSynopsis = List.filled(12, '很长的剧情简介').join(); + final favorites = FavoriteController( + InMemoryFavoriteRepository( + saved: {'with-synopsis', 'without-synopsis'}, + cards: [ + _card('with-synopsis', synopsis: longSynopsis), + _card('without-synopsis', synopsis: ' '), + ], + ), + ); + + await _pumpPage(tester, auth: auth, favorites: favorites); + + final withSynopsis = tester.widget( + find.byKey(const Key('favorite-synopsis-with-synopsis')), + ); + expect(withSynopsis.maxLines, 2); + expect(withSynopsis.overflow, TextOverflow.ellipsis); + expect( + find.byKey(const Key('favorite-synopsis-without-synopsis')), + findsNothing, + ); + expect( + tester.getSize(find.byKey(const Key('favorite-card-with-synopsis'))), + tester.getSize(find.byKey(const Key('favorite-card-without-synopsis'))), + ); + }); + testWidgets('should_keep_card_and_show_failure_when_remove_fails', ( tester, ) async { From a48433f01dd9526e347331ebc742ef91a3d6f797 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 13:09:55 +0800 Subject: [PATCH 014/129] =?UTF-8?q?fix(profile):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=88=91=E7=9A=84=E6=94=B6=E8=97=8F=E4=B8=8E=E8=A7=82=E7=9C=8B?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E5=88=97=E8=A1=A8=E4=B8=8D=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/data/models/page_result.dart | 32 ++++++++++++-- lib/core/data/remote/library_api.dart | 6 +-- lib/core/data/remote/watch_api.dart | 6 +-- test/core/page_result_parse_test.dart | 59 +++++++++++++++++++++++++ test/library/library_api_test.dart | 63 +++++++++++++++++++++++++++ test/watch/watch_api_test.dart | 48 ++++++++++++++++++++ 6 files changed, 202 insertions(+), 12 deletions(-) create mode 100644 test/core/page_result_parse_test.dart diff --git a/lib/core/data/models/page_result.dart b/lib/core/data/models/page_result.dart index 37511bc..16c446f 100644 --- a/lib/core/data/models/page_result.dart +++ b/lib/core/data/models/page_result.dart @@ -3,6 +3,8 @@ import 'json_numbers.dart'; /// 服务端标准分页响应 `{total, list}`。 +/// +/// 兼容旧版「data 直接是数组」的响应:按数组长度作为 total,避免列表空白。 class PageResult { const PageResult({this.total = 0, this.list = const []}); @@ -14,10 +16,32 @@ class PageResult { Map json, T Function(Map) parseItem, ) { - final items = (json['list'] as List? ?? const []) - .whereType>() - .map(parseItem) - .toList(growable: false); + final items = _parseItemMaps(json['list']).map(parseItem).toList( + growable: false, + ); return PageResult(total: jsonToInt(json['total']), list: items); } + + /// 兼容分页 Map 与旧版纯 List;其它类型视为空页。 + factory PageResult.parse( + Object? data, + T Function(Map) parseItem, + ) { + if (data is Map) { + return PageResult.fromJson(Map.from(data), parseItem); + } + if (data is List) { + final items = _parseItemMaps(data).map(parseItem).toList(growable: false); + return PageResult(total: items.length, list: items); + } + return PageResult(); + } +} + +List> _parseItemMaps(Object? raw) { + if (raw is! List) return const []; + return [ + for (final item in raw) + if (item is Map) Map.from(item), + ]; } diff --git a/lib/core/data/remote/library_api.dart b/lib/core/data/remote/library_api.dart index 3eac576..73588d8 100644 --- a/lib/core/data/remote/library_api.dart +++ b/lib/core/data/remote/library_api.dart @@ -29,10 +29,8 @@ class LibraryApi { '/app/favorites', query: {'pageIndex': '$pageIndex', 'pageSize': '$pageSize'}, ); - if (data is Map) { - return PageResult.fromJson(data, Drama.fromCardJson); - } - return const PageResult(); + // 线上可能仍返回 List;分页壳与纯数组都要能渲染。 + return PageResult.parse(data, Drama.fromCardJson); } Future removeFavorite(String dramaId) async { diff --git a/lib/core/data/remote/watch_api.dart b/lib/core/data/remote/watch_api.dart index f63b5d9..e46e240 100644 --- a/lib/core/data/remote/watch_api.dart +++ b/lib/core/data/remote/watch_api.dart @@ -49,10 +49,8 @@ class WatchApi { '/app/watch/recent', query: {'pageIndex': '$pageIndex', 'pageSize': '$pageSize'}, ); - if (data is Map) { - return PageResult.fromJson(data, WatchProgress.fromJson); - } - return const PageResult(); + // 线上可能仍返回 List;分页壳与纯数组都要能渲染。 + return PageResult.parse(data, WatchProgress.fromJson); } /// 删除某部剧的观看记录(记录页清单条用)。 diff --git a/test/core/page_result_parse_test.dart b/test/core/page_result_parse_test.dart new file mode 100644 index 0000000..43bed86 --- /dev/null +++ b/test/core/page_result_parse_test.dart @@ -0,0 +1,59 @@ +// Author: fruit.com.cn + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/page_result.dart'; + +void main() { + group('PageResult.parse', () { + test('should_parse_page_map_when_total_and_list_present', () { + final page = PageResult.parse({ + 'total': '3', + 'list': [ + {'id': '1', 'title': 'A'}, + {'id': '2', 'title': 'B'}, + ], + }, _item); + + expect(page.total, 3); + expect(page.list.map((e) => e['id']), ['1', '2']); + }); + + test('should_parse_legacy_list_when_data_is_array', () { + final page = PageResult.parse([ + {'id': '9', 'title': '收藏剧'}, + {'id': '8', 'title': '另一部'}, + ], _item); + + expect(page.total, 2); + expect(page.list, hasLength(2)); + expect(page.list.first['title'], '收藏剧'); + }); + + test('should_return_empty_page_when_data_is_null_or_empty_list', () { + expect(PageResult.parse(null, _item).total, 0); + expect(PageResult.parse(null, _item).list, isEmpty); + expect(PageResult.parse(const [], _item).total, 0); + expect(PageResult.parse(const [], _item).list, isEmpty); + }); + + test('should_return_empty_page_when_payload_is_invalid', () { + final page = PageResult.parse('broken', _item); + expect(page.total, 0); + expect(page.list, isEmpty); + }); + + test('should_skip_non_map_entries_in_legacy_list', () { + final page = PageResult.parse([ + {'id': '1'}, + 'noise', + 42, + {'id': '2'}, + ], _item); + + expect(page.total, 2); + expect(page.list.map((e) => e['id']), ['1', '2']); + }); + }); +} + +Map _item(Map json) => json; diff --git a/test/library/library_api_test.dart b/test/library/library_api_test.dart index 127c61c..2dbe0e9 100644 --- a/test/library/library_api_test.dart +++ b/test/library/library_api_test.dart @@ -38,4 +38,67 @@ void main() { expect(page.list.single.id, '9'); expect(page.list.single.title, '收藏剧'); }); + + test('should_parse_favorites_when_server_returns_legacy_list', () async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': [ + {'id': '5', 'title': '闪婚后', 'cover': '/files/5.png'}, + {'id': '4', 'title': '另一部', 'cover': '/files/4.png'}, + ], + }), + ), + ); + final api = LibraryApi( + ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), + ); + + final page = await api.favorites(); + + expect(page.total, 2); + expect(page.list.map((drama) => drama.id), ['5', '4']); + expect(page.list.first.title, '闪婚后'); + }); + + test('should_return_empty_favorites_when_data_is_null', () async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse( + jsonEncode({'code': 0, 'message': 'ok', 'data': null}), + ), + ); + final api = LibraryApi( + ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), + ); + + final page = await api.favorites(); + + expect(page.total, 0); + expect(page.list, isEmpty); + }); + + test('should_throw_api_exception_when_favorites_business_code_failed', () async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse( + jsonEncode({'code': 401, 'message': '未登录或登录已过期', 'data': null}), + ), + ); + final api = LibraryApi( + ApiClient( + baseUrl: 'http://127.0.0.1:8081', + adapter: adapter, + // 无刷新回调时,401 直接抛业务异常,避免重试逻辑。 + tokenRefresher: null, + ), + ); + + await expectLater( + () => api.favorites(), + throwsA( + isA().having((e) => e.code, 'code', 401), + ), + ); + }); } diff --git a/test/watch/watch_api_test.dart b/test/watch/watch_api_test.dart index 5369cc3..74a9c8a 100644 --- a/test/watch/watch_api_test.dart +++ b/test/watch/watch_api_test.dart @@ -50,4 +50,52 @@ void main() { expect(page.list.single.episodeNumber, 6); expect(page.list.single.totalEpisodes, 68); }); + + test('should_parse_recent_when_server_returns_legacy_list', () async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': [ + { + 'dramaId': '5', + 'episodeId': '7', + 'positionMs': '203904', + 'durationMs': '227882', + 'finished': false, + 'playCount': 8, + 'updatedAt': '2026-10-21T15:17:05.419', + }, + ], + }), + ), + ); + final api = WatchApi( + ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), + ); + + final page = await api.recent(); + + expect(page.total, 1); + expect(page.list.single.dramaId, '5'); + expect(page.list.single.episodeId, '7'); + expect(page.list.single.position.inMilliseconds, 203904); + }); + + test('should_return_empty_recent_when_data_is_null', () async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse( + jsonEncode({'code': 0, 'message': 'ok', 'data': null}), + ), + ); + final api = WatchApi( + ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), + ); + + final page = await api.recent(); + + expect(page.total, 0); + expect(page.list, isEmpty); + }); } From 5b2d8445dc009ef116fcb5b1b6f5c98cced6e4fe Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 13:21:27 +0800 Subject: [PATCH 015/129] =?UTF-8?q?refactor(watch):=20=E8=A7=82=E7=9C=8B?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E6=94=B9=E7=94=A8=E5=88=86=E9=A1=B5=E5=A5=91?= =?UTF-8?q?=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/data/remote/watch_api.dart | 11 ++++-- test/watch/watch_api_test.dart | 56 ++++++++++++++--------------- 2 files changed, 34 insertions(+), 33 deletions(-) diff --git a/lib/core/data/remote/watch_api.dart b/lib/core/data/remote/watch_api.dart index e46e240..f918e8d 100644 --- a/lib/core/data/remote/watch_api.dart +++ b/lib/core/data/remote/watch_api.dart @@ -40,7 +40,7 @@ class WatchApi { return data is Map ? WatchProgress.fromJson(data) : null; } - /// 最近观看分页(服务端已按更新时间倒序)。 + /// 最近观看分页:`pageIndex`/`pageSize`,解包 `PageResult`。 Future> recent({ int pageIndex = 1, int pageSize = 20, @@ -49,8 +49,13 @@ class WatchApi { '/app/watch/recent', query: {'pageIndex': '$pageIndex', 'pageSize': '$pageSize'}, ); - // 线上可能仍返回 List;分页壳与纯数组都要能渲染。 - return PageResult.parse(data, WatchProgress.fromJson); + if (data is! Map) { + return const PageResult(); + } + return PageResult.fromJson( + Map.from(data), + WatchProgress.fromJson, + ); } /// 删除某部剧的观看记录(记录页清单条用)。 diff --git a/test/watch/watch_api_test.dart b/test/watch/watch_api_test.dart index 74a9c8a..3214b41 100644 --- a/test/watch/watch_api_test.dart +++ b/test/watch/watch_api_test.dart @@ -51,23 +51,13 @@ void main() { expect(page.list.single.totalEpisodes, 68); }); - test('should_parse_recent_when_server_returns_legacy_list', () async { + test('should_return_empty_page_when_recent_list_is_empty', () async { final adapter = FakeHttpAdapter( (_) async => FakeHttpResponse( jsonEncode({ 'code': 0, 'message': 'ok', - 'data': [ - { - 'dramaId': '5', - 'episodeId': '7', - 'positionMs': '203904', - 'durationMs': '227882', - 'finished': false, - 'playCount': 8, - 'updatedAt': '2026-10-21T15:17:05.419', - }, - ], + 'data': {'total': '0', 'list': []}, }), ), ); @@ -77,25 +67,31 @@ void main() { final page = await api.recent(); - expect(page.total, 1); - expect(page.list.single.dramaId, '5'); - expect(page.list.single.episodeId, '7'); - expect(page.list.single.position.inMilliseconds, 203904); - }); - - test('should_return_empty_recent_when_data_is_null', () async { - final adapter = FakeHttpAdapter( - (_) async => FakeHttpResponse( - jsonEncode({'code': 0, 'message': 'ok', 'data': null}), - ), - ); - final api = WatchApi( - ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), - ); - - final page = await api.recent(); - expect(page.total, 0); expect(page.list, isEmpty); }); + + test('should_throw_api_exception_when_recent_response_is_error', () async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse( + jsonEncode({'code': 401, 'message': 'unauthorized', 'data': null}), + ), + ); + final api = WatchApi( + ApiClient( + baseUrl: 'http://127.0.0.1:8081', + adapter: adapter, + tokenRefresher: null, + ), + ); + + await expectLater( + () => api.recent(), + throwsA( + isA() + .having((e) => e.code, 'code', 401) + .having((e) => e.message, 'message', 'unauthorized'), + ), + ); + }); } From 4bc04fd8ae08aad764198158c4302b6cfd4d8c05 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 13:49:20 +0800 Subject: [PATCH 016/129] =?UTF-8?q?style(theme):=20=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E5=88=87=E6=8D=A2=E6=B7=B1=E8=89=B2=E8=A7=86=E8=A7=89?= =?UTF-8?q?=E9=A3=8E=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/2026-10-22-dark-theme-client.md | 28 +++ docs/plans/PROGRESS.md | 55 ++++++ lib/app.dart | 18 +- lib/core/theme/app_colors.dart | 120 ++++++++----- lib/core/theme/app_dimens.dart | 26 +-- lib/core/theme/app_theme.dart | 164 +++++++++++++++--- lib/core/widgets/app_bottom_nav.dart | 12 +- lib/core/widgets/auth_gate.dart | 4 +- lib/core/widgets/catalog_endpoint_editor.dart | 3 +- lib/core/widgets/centered_toast.dart | 10 +- lib/core/widgets/empty_state.dart | 3 +- lib/core/widgets/glass_panel.dart | 4 +- lib/core/widgets/remote_image.dart | 2 +- lib/core/widgets/subpage_header.dart | 13 +- .../account/account_deactivate_page.dart | 2 +- lib/pages/account/account_devices_page.dart | 2 +- lib/pages/account/account_export_page.dart | 2 +- lib/pages/account/account_profile_page.dart | 3 +- lib/pages/auth/auth_form_widgets.dart | 2 +- lib/pages/auth/auth_view.dart | 2 +- lib/pages/auth/auth_widgets.dart | 2 +- lib/pages/auth/phone_auth_sheet.dart | 4 +- lib/pages/create/create_view.dart | 10 +- lib/pages/discover/discover_view.dart | 14 +- .../widgets/discover_search_field.dart | 2 +- lib/pages/discover/widgets/drama_grid.dart | 8 +- .../widgets/feed_banner_carousel.dart | 6 +- lib/pages/downloads/downloads_page.dart | 18 +- lib/pages/drama_detail/drama_detail_view.dart | 10 +- .../widgets/drama_detail_sections.dart | 2 +- lib/pages/favorites/favorites_page.dart | 4 +- lib/pages/filter/filter_page.dart | 2 +- lib/pages/home/home_view.dart | 18 +- lib/pages/home/widgets/home_drama_grid.dart | 2 +- lib/pages/mine/mine_view.dart | 4 +- lib/pages/mine/widgets/mine_header.dart | 2 +- lib/pages/mine/widgets/mine_menu.dart | 6 +- lib/pages/new_releases/new_releases_page.dart | 17 +- lib/pages/notices/notices_page.dart | 2 +- lib/pages/player/player_logic.dart | 15 +- lib/pages/player/player_view.dart | 24 +-- lib/pages/player/player_view_actions.dart | 6 +- .../player/widgets/drama_comments_sheet.dart | 7 +- .../player/widgets/episode_picker_sheet.dart | 8 +- .../widgets/exit_clear_screen_button.dart | 6 +- .../player/widgets/home_feed_header.dart | 22 ++- .../player/widgets/home_feed_overlay.dart | 26 +-- .../widgets/home_feed_overlay_parts.dart | 9 +- .../player/widgets/interaction_rail.dart | 6 +- .../widgets/loading_progress_track.dart | 6 +- .../player/widgets/player_center_toggle.dart | 4 +- .../player/widgets/player_more_sheet.dart | 8 +- lib/pages/player/widgets/player_overlay.dart | 6 +- .../player/widgets/player_overlay_parts.dart | 4 +- .../player/widgets/player_video_surface.dart | 6 +- .../player/widgets/scrub_preview_card.dart | 6 +- .../player/widgets/seek_progress_bar.dart | 2 +- lib/pages/ranking/ranking_page.dart | 12 +- lib/pages/reserve/reserve_page.dart | 10 +- lib/pages/settings/settings_page.dart | 2 +- lib/pages/store/store_page.dart | 2 +- lib/pages/wallet/wallet_page.dart | 2 +- .../watch_records/watch_records_page.dart | 2 +- .../widgets/watch_record_card.dart | 2 +- test/auth/auth_visual_test.dart | 6 +- test/core/subpage_header_test.dart | 12 +- test/empty_state/empty_state_test.dart | 8 +- test/home/app_bottom_nav_test.dart | 2 +- test/home/home_nav_ui_test.dart | 16 +- test/home/k28_a_navigation_test.dart | 4 +- test/player/loading_progress_track_test.dart | 5 +- test/player/player_fullscreen_test.dart | 5 +- test/theme/app_dimens_test.dart | 12 +- test/theme/app_theme_test.dart | 64 ++++--- test/theme/dark_theme_smoke_test.dart | 117 +++++++++++++ 75 files changed, 732 insertions(+), 330 deletions(-) create mode 100644 docs/plans/2026-10-22-dark-theme-client.md create mode 100644 test/theme/dark_theme_smoke_test.dart diff --git a/docs/plans/2026-10-22-dark-theme-client.md b/docs/plans/2026-10-22-dark-theme-client.md new file mode 100644 index 0000000..6f2e2fd --- /dev/null +++ b/docs/plans/2026-10-22-dark-theme-client.md @@ -0,0 +1,28 @@ +# ReelShort 客户端深色视觉改造 + +## 目标 + +- 深色设为默认且唯一主题,页面、卡片、浮层、播放器和底部导航保持一致。 +- 色板收敛到 `AppColors`,主题组件配置收敛到 `AppTheme`。 +- 保持现有布局、信息结构、路由、业务逻辑和文案不变。 + +## 结果 + +- 背景 `#0B1020`、卡片 `#151B2E`、浮层 `#1C2436`、分割线白色 6%。 +- 主文字白色 92%、次文字 64%、禁用 38%。 +- 主色 `#FF6B2C`,辅助链接色 `#22D3EE`。 +- 卡片 12、按钮/输入框 10、标签 6,阴影关闭,组件使用分割线分层。 +- 页面级透明、遮罩、阴影、媒体底、玻璃面和渐变全部映射到 AppColors 语义 token。 +- 系统栏统一浅色图标,AppBar 与底部导航使用深色表面。 + +## 验证 + +- 主题 token、对比度、系统栏、圆角与关键页 smoke 测试。 +- `dart analyze lib test` +- `flutter test --no-pub` +- `flutter build apk --debug` + +## 约束 + +- 不提交 `pubspec.lock` 漂移。 +- 不启停服务、不操作真机。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index eee3f2f..0213a52 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -730,3 +730,58 @@ $ flutter test --no-pub 解读:有简介时文本最多 2 行并省略,空简介不渲染且卡片高度与正常卡片一致; 全量静态分析零告警,631 项通过、1 项运行中旧后端能力探测跳过。 `pubspec.lock` 既有改动未提交。 + +## 2026-10-22 ReelShort 客户端深色视觉改造 + +- 分支:`feature/dark-theme-client` +- 基线:开发前已有约 43 个未提交视觉文件,本次在原改动上继续收敛,未推倒重来。 +- 计划:`docs/plans/2026-10-22-dark-theme-client.md` + +### 颜色与主题 + +- `AppTheme.dark()` 成为默认且唯一主题,系统状态栏/导航栏图标统一浅色。 +- 色板统一为背景 `#0B1020`、卡片 `#151B2E`、浮层 `#1C2436`、 + divider `#0FFFFFFF`、主色 `#FF6B2C`、辅助链接色 `#22D3EE`。 +- 主/次/禁用文字分别使用白色 92% / 64% / 38%。 +- 卡片/按钮/输入框/标签圆角统一为 12/10/10/6,卡片阴影关闭。 +- 页面和组件内的 `Colors.*`、`Color(0x...)` 已全部映射到 `AppColors` + 语义 token;剩余原始色值只在 `app_colors.dart` 中定义。 +- 导航、玻璃面板、播放器遮罩、加载轨道、渐变、边框和阴影均改为深色语义。 +- 修正全局输入装饰 padding 导致账户性别下拉溢出,保持原布局宽度。 + +### 验证命令与原始结果 + +```text +$ git checkout -- pubspec.lock +$ git status --short --branch +## feature/dark-theme-client + +$ rg -n -P "Color\(0x|(? { localeResolutionCallback: (deviceLocale, _) => resolveAppLocale(deviceLocale), debugShowCheckedModeBanner: false, - themeMode: ThemeMode.light, - theme: AppTheme.light(), - darkTheme: AppTheme.light(), + themeMode: ThemeMode.dark, + theme: AppTheme.dark(), + darkTheme: AppTheme.dark(), builder: (context, child) => AnnotatedRegion( value: appSystemOverlayStyle, child: child ?? const SizedBox.shrink(), diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index e8961a9..585fc68 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -2,107 +2,143 @@ import 'package:flutter/material.dart'; -/// K28-S 浅色暖橙主题色板。 +/// 深色视觉色板。 /// /// 保留既有语义名,避免页面大范围改名;实际颜色统一由本文件控制。 abstract final class AppColors { - /// 主操作、选中态、播放进度。 - static const blue = Color(0xFFFF7A1A); + /// 透明与媒体黑底。 + static const transparent = Colors.transparent; + static const mediaBlack = Color(0xFF000000); + + /// 阴影与遮罩层级。 + static const shadowSubtle = Color(0x14000000); + static const shadowSoft = Color(0x33000000); + static const shadow = Color(0x8A000000); + static const shadowStrong = Color(0xE6000000); + static const scrim = Color(0xD1000000); + static const controlScrim = Color(0x80000000); + static const controlSoftScrim = Color(0x42000000); + static const scrubPreviewScrim = Color(0xDE000000); + static const overlaySoft = Color(0x66000000); + static const overlayBar = Color(0x4D000000); + static const overlayMedium = Color(0x99000000); + static const headerScrim = Color(0x59000000); + + /// 主操作、选中态、播放进度(品牌橙)。 + static const blue = Color(0xFFFF6B2C); static const primary = blue; /// 主操作按下态。 - static const bluePressed = Color(0xFFFF6A00); + static const bluePressed = Color(0xFFE55A1F); - /// 粉色辅助强调色。 - static const blueSoft = Color(0xFFFF6A8A); + /// 链接与次要高亮(禁止大面积铺色)。 + static const blueSoft = Color(0xFF22D3EE); static const secondary = blueSoft; static const lavender = blueSoft; /// 金币与奖励。 static const gold = Color(0xFFF3B34C); + static const goldDeep = Color(0xFFD9A23A); + static const bronze = Color(0xFFCD7F32); + static const bronzeDeep = Color(0xFFA9713A); /// 成功状态。 static const success = Color(0xFF2FA873); - /// 收藏和喜欢使用粉色强调。 - static const favorite = secondary; + /// 错误与危险操作。 + static const error = Color(0xFFFF6B6B); + + /// 收藏与喜欢(非主色铺色,独立强调)。 + static const favorite = Color(0xFFFF6A8A); /// 金币底色上的文字。 - static const onGold = Color(0xFF5A3511); + static const onGold = Color(0xFF3D2408); /// 主色上的文字与图标。 static const onPrimary = Color(0xFFFFFFFF); /// 页面背景。 - static const ink = Color(0xFFFFF8F0); + static const ink = Color(0xFF0B1020); /// 原生开屏与冷启动过渡底色。 - static const splashInk = Color(0xFF1F1B16); + static const splashInk = ink; - /// 页面顶部暖色渐变起点。 - static const surfaceTop = Color(0xFFFFE9D6); + /// 顶部与页面底同色,靠容器层次区分。 + static const surfaceTop = ink; /// 底部导航底色。 - static const bar = Color(0xFFFFFDFB); + static const bar = ink; - /// 卡片轻阴影。 - static const cardShadow = Color(0x14FF8A3D); + /// 卡片阴影(深色几乎不用阴影)。 + static const cardShadow = Color(0x00000000); - /// 卡片与弹层。 - static const card = Color(0xFFFFFFFF); + /// 卡片与列表容器。 + static const card = Color(0xFF151B2E); /// 次要卡片表面。 - static const cardAlt = Color(0xFFFFF4E9); + static const cardAlt = card; - /// 浮层、输入框和嵌套区域。 - static const raised = Color(0xFFFFF1E5); - static const quick = Color(0xFFFFF0E1); - static const input = Color(0xFFFFF4EA); + /// 浮层、弹窗、输入与嵌套区域。 + static const raised = Color(0xFF1C2436); + static const quick = raised; + static const input = raised; /// 评论输入框底色。 - static const commentInput = Color(0xFFF5EFE8); - static const goldCard = Color(0xFFFFF4DF); + static const commentInput = raised; + static const goldCard = Color(0xFF2A2418); - /// 正文颜色。 - static const cream = Color(0xFF1F1B16); + /// 正文颜色(与主文字同级)。 + static const cream = Color(0xEBFFFFFF); - /// 主文字。 - static const onSurface = Color(0xFF1F1B16); + /// 主文字 rgba(255,255,255,0.92)。 + static const onSurface = Color(0xEBFFFFFF); - /// 次要文字。 - static const muted = Color(0xFF8C837B); + /// 次要文字 rgba(255,255,255,0.64)。 + static const muted = Color(0xA3FFFFFF); - /// 弱提示与时间。 - static const subtle = Color(0xFFB3AAA1); + /// 弱提示与禁用 rgba(255,255,255,0.38)。 + static const subtle = Color(0x61FFFFFF); /// 橙色强调文案。 static const accent = blue; - /// 卡片描边。 - static const border = Color(0xFFF0E4D6); + /// 分割线 rgba(255,255,255,0.06)。 + static const border = Color(0x0FFFFFFF); /// 玻璃面板描边。 - static const glassBorder = Color(0x66FFFFFF); + static const glassBorder = Color(0x33FFFFFF); + static const glassSurfaceStart = Color(0xEB1C2436); + static const glassSurfaceEnd = Color(0xD1151B2E); - /// 粉色角标。 - static const badgePink = Color(0xFFFFE3EC); - static const badgePinkText = Color(0xFFFF6A8A); + /// 角标底与文字。 + static const badgePink = Color(0x33FF6A8A); + static const badgePinkText = favorite; /// 封面锁定数量与视频遮罩。 - static const overlayDark = Color(0x33000000); + static const overlayDark = Color(0x66000000); /// 封面集数胶囊。 - static const badgeBeige = Color(0xFFFFF0DF); + static const badgeBeige = raised; /// 导航未选中。 static const navUnselected = muted; /// 播放器次要文字。 - static const playerMuted = Color(0xFFBBBBBB); + static const playerMuted = Color(0xA3FFFFFF); /// 播放器控件胶囊与圆形底色。 static const playerOverlayPill = Color(0xB3000000); + /// 进度条未激活轨道。 + static const inactiveTrack = Color(0x24FFFFFF); + + /// 横幅/封面底部渐变。 + static const bannerScrimMid = Color(0x8A0B1020); + static const bannerScrimEnd = Color(0xE6000000); + + /// 票券虚线等主色描边。 + static const primaryBorder = Color(0x66FF6B2C); + static const outlineDark = border; static const outlineVariantDark = border; static const outlineLight = muted; diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 828b371..5de3484 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -9,18 +9,20 @@ abstract final class AppDimens { static const double spaceXl = 24; static const double pageHorizontal = 16; - static const double cardRadius = 16; - static const double authCardRadius = 20; + static const double cardRadius = 12; + static const double authCardRadius = 12; static const double cardPadding = 16; - static const double innerRadius = 12; + static const double innerRadius = 10; static const double innerPadding = 12; static const double pillRadius = 999; - static const double glassSmallRadius = 16; - static const double glassRadius = 20; - static const double glassCardRadius = 20; - static const double buttonRadius = 999; - static const double coverRadius = 16; - static const double sheetRadius = 20; + static const double glassSmallRadius = 12; + static const double glassRadius = 12; + static const double glassCardRadius = 12; + static const double buttonRadius = 10; + static const double inputRadius = 10; + static const double chipRadius = 6; + static const double coverRadius = 12; + static const double sheetRadius = 12; static const double caption = 12; static const double emptyStateSubtitle = 12.5; @@ -61,13 +63,13 @@ abstract final class AppDimens { static const double watchPlaceholderHeight = 114; static const double heroBannerHeight = 240; - static const double heroBannerRadius = 20; + static const double heroBannerRadius = 12; static const double heroTitle = 28; static const double heroSubtitle = 14; static const double cardMeta = 12; static const double cardViews = 11; static const double cardTitle = 15; - static const double cardShadowBlur = 18; - static const double cardShadowY = 6; + static const double cardShadowBlur = 0; + static const double cardShadowY = 0; } diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index 9683d2a..dc0003d 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -6,9 +6,9 @@ import 'package:flutter/services.dart'; import 'app_colors.dart'; import 'app_dimens.dart'; -/// K28-S 浅色暖橙主题。 +/// 深色视觉主题(默认且唯一)。 abstract final class AppTheme { - /// 页面顶部暖色到米白背景的渐变。 + /// 页面背景:深色扁平,靠容器层次区分。 static const LinearGradient pageGradient = LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, @@ -16,36 +16,50 @@ abstract final class AppTheme { stops: [0, 0.24], ); - /// 卡片轻阴影。 + /// 卡片阴影关闭,改用底色与分割线。 static const BoxShadow cardShadow = BoxShadow( color: AppColors.cardShadow, blurRadius: AppDimens.cardShadowBlur, offset: Offset(0, AppDimens.cardShadowY), ); - /// 全局浅色暖橙主题。 - static ThemeData light() { - const scheme = ColorScheme.light( + static final BorderRadius _buttonRadius = BorderRadius.circular( + AppDimens.buttonRadius, + ); + static final BorderRadius _cardRadius = BorderRadius.circular( + AppDimens.cardRadius, + ); + static final BorderRadius _inputRadius = BorderRadius.circular( + AppDimens.inputRadius, + ); + + /// 全局深色主题。 + static ThemeData dark() { + const scheme = ColorScheme.dark( primary: AppColors.blue, onPrimary: AppColors.onPrimary, secondary: AppColors.secondary, - onSecondary: AppColors.onPrimary, - primaryContainer: AppColors.surfaceTop, + onSecondary: AppColors.ink, + primaryContainer: AppColors.raised, onPrimaryContainer: AppColors.onSurface, secondaryContainer: AppColors.badgePink, onSecondaryContainer: AppColors.badgePinkText, surface: AppColors.ink, onSurface: AppColors.onSurface, + surfaceContainerHighest: AppColors.raised, outline: AppColors.border, outlineVariant: AppColors.border, - surfaceTint: AppColors.blue, + surfaceTint: AppColors.transparent, inversePrimary: AppColors.bluePressed, + error: AppColors.error, + onError: AppColors.onPrimary, ); final base = ThemeData( useMaterial3: true, - brightness: Brightness.light, + brightness: Brightness.dark, colorScheme: scheme, scaffoldBackgroundColor: AppColors.ink, + canvasColor: AppColors.ink, fontFamily: 'PlusJakartaSans', progressIndicatorTheme: const ProgressIndicatorThemeData( color: AppColors.blue, @@ -61,7 +75,23 @@ abstract final class AppTheme { unselectedLabelColor: AppColors.muted, ), textButtonTheme: TextButtonThemeData( - style: TextButton.styleFrom(foregroundColor: AppColors.blue), + style: TextButton.styleFrom( + foregroundColor: AppColors.secondary, + shape: RoundedRectangleBorder(borderRadius: _buttonRadius), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ButtonStyle( + backgroundColor: WidgetStateProperty.resolveWith(_buttonColor), + foregroundColor: const WidgetStatePropertyAll(AppColors.onPrimary), + elevation: const WidgetStatePropertyAll(0), + minimumSize: const WidgetStatePropertyAll( + Size(0, AppDimens.buttonHeight), + ), + shape: WidgetStatePropertyAll( + RoundedRectangleBorder(borderRadius: _buttonRadius), + ), + ), ), filledButtonTheme: FilledButtonThemeData( style: ButtonStyle( @@ -70,7 +100,9 @@ abstract final class AppTheme { minimumSize: const WidgetStatePropertyAll( Size(0, AppDimens.buttonHeight), ), - shape: const WidgetStatePropertyAll(StadiumBorder()), + shape: WidgetStatePropertyAll( + RoundedRectangleBorder(borderRadius: _buttonRadius), + ), ), ), outlinedButtonTheme: OutlinedButtonThemeData( @@ -82,14 +114,46 @@ abstract final class AppTheme { side: const WidgetStatePropertyAll( BorderSide(color: AppColors.border), ), - shape: const WidgetStatePropertyAll(StadiumBorder()), + shape: WidgetStatePropertyAll( + RoundedRectangleBorder(borderRadius: _buttonRadius), + ), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: AppColors.input, + hintStyle: const TextStyle(color: AppColors.subtle), + labelStyle: const TextStyle(color: AppColors.muted), + border: OutlineInputBorder( + borderRadius: _inputRadius, + borderSide: const BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: _inputRadius, + borderSide: const BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: _inputRadius, + borderSide: const BorderSide(color: AppColors.blue), + ), + ), + chipTheme: ChipThemeData( + backgroundColor: AppColors.raised, + selectedColor: AppColors.blue.withValues(alpha: 0.22), + labelStyle: const TextStyle(color: AppColors.onSurface), + secondaryLabelStyle: const TextStyle(color: AppColors.muted), + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppDimens.chipRadius), ), ), appBarTheme: const AppBarTheme( - backgroundColor: AppColors.ink, + backgroundColor: AppColors.card, foregroundColor: AppColors.onSurface, elevation: 0, - systemOverlayStyle: SystemUiOverlayStyle.dark, + scrolledUnderElevation: 0, + surfaceTintColor: AppColors.transparent, + systemOverlayStyle: SystemUiOverlayStyle.light, titleTextStyle: TextStyle( color: AppColors.onSurface, fontFamily: 'Outfit', @@ -98,19 +162,47 @@ abstract final class AppTheme { ), ), iconTheme: const IconThemeData(color: AppColors.onSurface), - dividerTheme: const DividerThemeData(color: AppColors.border), - cardTheme: const CardThemeData( + dividerTheme: const DividerThemeData( + color: AppColors.border, + thickness: 1, + space: 1, + ), + cardTheme: CardThemeData( color: AppColors.card, elevation: 0, + shadowColor: AppColors.transparent, + surfaceTintColor: AppColors.transparent, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(AppDimens.cardRadius)), - side: BorderSide(color: AppColors.border), + borderRadius: _cardRadius, + side: const BorderSide(color: AppColors.border), ), ), + dialogTheme: DialogThemeData( + backgroundColor: AppColors.raised, + surfaceTintColor: AppColors.transparent, + shape: RoundedRectangleBorder(borderRadius: _cardRadius), + titleTextStyle: const TextStyle( + color: AppColors.onSurface, + fontFamily: 'Outfit', + fontSize: AppDimens.sectionTitle, + fontWeight: FontWeight.w700, + ), + contentTextStyle: const TextStyle( + color: AppColors.muted, + fontSize: AppDimens.body, + ), + ), + bottomNavigationBarTheme: const BottomNavigationBarThemeData( + backgroundColor: AppColors.bar, + selectedItemColor: AppColors.blue, + unselectedItemColor: AppColors.navUnselected, + elevation: 0, + type: BottomNavigationBarType.fixed, + ), bottomSheetTheme: const BottomSheetThemeData( - backgroundColor: Colors.transparent, - modalBackgroundColor: Colors.transparent, - surfaceTintColor: Colors.transparent, + backgroundColor: AppColors.transparent, + modalBackgroundColor: AppColors.transparent, + surfaceTintColor: AppColors.transparent, ), switchTheme: SwitchThemeData( thumbColor: WidgetStateProperty.resolveWith( @@ -124,8 +216,13 @@ abstract final class AppTheme { ), ), snackBarTheme: const SnackBarThemeData( - backgroundColor: AppColors.onSurface, - contentTextStyle: TextStyle(color: AppColors.onPrimary), + backgroundColor: AppColors.raised, + contentTextStyle: TextStyle(color: AppColors.onSurface), + actionTextColor: AppColors.secondary, + ), + listTileTheme: const ListTileThemeData( + iconColor: AppColors.onSurface, + textColor: AppColors.onSurface, ), ); final textTheme = base.textTheme @@ -142,10 +239,29 @@ abstract final class AppTheme { headlineMedium: _display(base.textTheme.headlineMedium), headlineSmall: _display(base.textTheme.headlineSmall), titleLarge: _display(base.textTheme.titleLarge), + bodyLarge: base.textTheme.bodyLarge?.copyWith( + color: AppColors.onSurface, + ), + bodyMedium: base.textTheme.bodyMedium?.copyWith( + color: AppColors.onSurface, + ), + bodySmall: base.textTheme.bodySmall?.copyWith(color: AppColors.muted), + labelLarge: base.textTheme.labelLarge?.copyWith( + color: AppColors.onSurface, + ), + labelMedium: base.textTheme.labelMedium?.copyWith( + color: AppColors.muted, + ), + labelSmall: base.textTheme.labelSmall?.copyWith( + color: AppColors.subtle, + ), ); return base.copyWith(textTheme: textTheme); } + /// 兼容旧调用点,统一落到深色主题。 + static ThemeData light() => dark(); + static TextStyle? _display(TextStyle? style) => style?.copyWith(fontFamily: 'Outfit', color: AppColors.onSurface); diff --git a/lib/core/widgets/app_bottom_nav.dart b/lib/core/widgets/app_bottom_nav.dart index 102feb7..9b29d9d 100644 --- a/lib/core/widgets/app_bottom_nav.dart +++ b/lib/core/widgets/app_bottom_nav.dart @@ -26,7 +26,7 @@ class AppBottomNavItem { final bool selected; } -/// 四槽底部导航:首页 feed 用深色样式,其它页面保持浅色暖橙风格。 +/// 四槽底部导航:所有页面统一深色底与浅色图标。 class AppBottomNav extends StatelessWidget { const AppBottomNav({ super.key, @@ -47,24 +47,22 @@ class AppBottomNav extends StatelessWidget { key: barKey, height: AppDimens.navHeight, decoration: BoxDecoration( - color: dark ? Colors.black : backgroundColor.withValues(alpha: 0.95), - border: Border( - top: BorderSide(color: dark ? Colors.white12 : AppColors.border), - ), + color: dark ? AppColors.bar : backgroundColor.withValues(alpha: 0.96), + border: const Border(top: BorderSide(color: AppColors.border)), ), child: Row( children: [ for (final item in items) _NavDestination( item: item, - unselectedColor: dark ? Colors.white70 : AppColors.navUnselected, + unselectedColor: AppColors.navUnselected, ), ], ), ); if (dark) { return ColoredBox( - color: Colors.black, + color: AppColors.bar, child: SafeArea(top: false, child: bar), ); } diff --git a/lib/core/widgets/auth_gate.dart b/lib/core/widgets/auth_gate.dart index 6d5e079..9c78b84 100644 --- a/lib/core/widgets/auth_gate.dart +++ b/lib/core/widgets/auth_gate.dart @@ -37,7 +37,7 @@ Future ensureSignedIn(BuildContext context) async { Text( l10n.authGateTitle, style: const TextStyle( - color: Colors.white, + color: AppColors.onSurface, fontSize: 16, fontWeight: FontWeight.w800, ), @@ -98,7 +98,7 @@ class AuthGatePanel extends StatelessWidget { l10n.authGateTitle, textAlign: TextAlign.center, style: const TextStyle( - color: Colors.white, + color: AppColors.onSurface, fontSize: 18, fontWeight: FontWeight.w800, ), diff --git a/lib/core/widgets/catalog_endpoint_editor.dart b/lib/core/widgets/catalog_endpoint_editor.dart index e5922ba..1016672 100644 --- a/lib/core/widgets/catalog_endpoint_editor.dart +++ b/lib/core/widgets/catalog_endpoint_editor.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import '../config/app_config.dart'; import '../config/server_endpoint.dart'; +import '../theme/app_colors.dart'; const String _title = '设置服务地址'; const String _save = '保存'; @@ -94,7 +95,7 @@ class _CatalogEndpointDialogState extends State<_CatalogEndpointDialog> { const Text( _localNetworkHint, key: Key('catalog-debug-endpoint-hint'), - style: TextStyle(fontSize: 12, color: Colors.grey), + style: TextStyle(fontSize: 12, color: AppColors.muted), ), if (_error != null) ...[ const SizedBox(height: 8), diff --git a/lib/core/widgets/centered_toast.dart b/lib/core/widgets/centered_toast.dart index 709be8a..23f9e10 100644 --- a/lib/core/widgets/centered_toast.dart +++ b/lib/core/widgets/centered_toast.dart @@ -4,6 +4,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import '../theme/app_colors.dart'; + const Key _toastKey = Key('centered-toast'); const Duration _fadeDuration = Duration(milliseconds: 200); const Duration _visibleDuration = Duration(seconds: 2); @@ -11,9 +13,8 @@ const double _toastRadius = 12; const double _toastMaxWidth = 320; const double _toastHorizontalInset = 20; const double _toastVerticalInset = 12; -const double _toastBackgroundOpacity = 0.78; const TextStyle _toastTextStyle = TextStyle( - color: Colors.white, + color: AppColors.onSurface, fontSize: 14, height: 1.4, fontWeight: FontWeight.w500, @@ -108,10 +109,9 @@ class _CenteredToastState extends State<_CenteredToast> constraints: const BoxConstraints(maxWidth: _toastMaxWidth), padding: _toastPadding, decoration: BoxDecoration( - color: Colors.black.withValues( - alpha: _toastBackgroundOpacity, - ), + color: AppColors.raised, borderRadius: BorderRadius.circular(_toastRadius), + border: Border.all(color: AppColors.border), ), child: Text( widget.message, diff --git a/lib/core/widgets/empty_state.dart b/lib/core/widgets/empty_state.dart index 75b3ca3..08eac68 100644 --- a/lib/core/widgets/empty_state.dart +++ b/lib/core/widgets/empty_state.dart @@ -87,12 +87,11 @@ class _EmptyMark extends StatelessWidget { @override Widget build(BuildContext context) { - final dark = Theme.of(context).brightness == Brightness.dark; return Icon( icon, key: const Key('empty-state-icon'), size: compact ? _compactIconSize : _regularIconSize, - color: dark ? AppColors.gold : AppColors.blue, + color: AppColors.blueSoft, ); } } diff --git a/lib/core/widgets/glass_panel.dart b/lib/core/widgets/glass_panel.dart index 1edd1fb..c790938 100644 --- a/lib/core/widgets/glass_panel.dart +++ b/lib/core/widgets/glass_panel.dart @@ -7,7 +7,7 @@ import 'package:flutter/material.dart'; import '../theme/app_colors.dart'; import '../theme/app_dimens.dart'; -/// 首页信息卡与浮层控件使用的浅色玻璃面板。 +/// 首页信息卡与浮层控件使用的深色玻璃面板。 class GlassPanel extends StatelessWidget { const GlassPanel({ super.key, @@ -38,7 +38,7 @@ class GlassPanel extends StatelessWidget { const LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: [Color(0xE6FFFFFF), Color(0xB3FFFDFB)], + colors: [AppColors.glassSurfaceStart, AppColors.glassSurfaceEnd], ), ), child: Padding(padding: padding ?? EdgeInsets.zero, child: child), diff --git a/lib/core/widgets/remote_image.dart b/lib/core/widgets/remote_image.dart index 60d10be..9711f2b 100644 --- a/lib/core/widgets/remote_image.dart +++ b/lib/core/widgets/remote_image.dart @@ -16,7 +16,7 @@ class RemoteImage extends StatelessWidget { this.showFallbackIcon = false, this.fallbackIconKey, this.fallbackAspectRatio, - this.fallbackColor = AppColors.bar, + this.fallbackColor = AppColors.raised, }); final String path; diff --git a/lib/core/widgets/subpage_header.dart b/lib/core/widgets/subpage_header.dart index 6924433..ed8131f 100644 --- a/lib/core/widgets/subpage_header.dart +++ b/lib/core/widgets/subpage_header.dart @@ -16,9 +16,6 @@ const double _iconSize = 22; const double _iconWeight = 600; const double _borderWidth = 1; const double _shadowBlur = 8; -const Color _lightShadowColor = Color(0x14000000); -const Color _darkBackground = Color(0x59000000); -const Color _darkShadowColor = Color(0x33000000); const Offset _lightShadowOffset = Offset(0, 2); /// 二级页面统一圆形操作按钮,返回、分享和更多共用同一尺寸与表面样式。 @@ -52,14 +49,14 @@ class AppSubpageCircleButton extends StatelessWidget { shape: BoxShape.circle, boxShadow: [ BoxShadow( - color: dark ? _darkShadowColor : _lightShadowColor, + color: dark ? AppColors.shadowSoft : AppColors.shadowSubtle, blurRadius: _shadowBlur, offset: dark ? Offset.zero : _lightShadowOffset, ), ], ), child: Material( - color: dark ? _darkBackground : AppColors.card, + color: dark ? AppColors.headerScrim : AppColors.card, shape: CircleBorder( side: dark ? BorderSide.none @@ -75,7 +72,7 @@ class AppSubpageCircleButton extends StatelessWidget { child: Center( child: Icon( icon, - color: dark ? Colors.white : AppColors.onSurface, + color: AppColors.onSurface, size: _iconSize, weight: _iconWeight, ), @@ -134,8 +131,8 @@ class AppSubpageHeader extends StatelessWidget { @override Widget build(BuildContext context) { - final foreground = dark ? Colors.white : AppColors.onSurface; - final secondary = dark ? Colors.white70 : AppColors.muted; + final foreground = AppColors.onSurface; + final secondary = AppColors.muted; return SizedBox( key: const Key('subpage-header'), height: subtitle == null ? _height : _heightWithSubtitle, diff --git a/lib/pages/account/account_deactivate_page.dart b/lib/pages/account/account_deactivate_page.dart index f5a123b..73d4711 100644 --- a/lib/pages/account/account_deactivate_page.dart +++ b/lib/pages/account/account_deactivate_page.dart @@ -51,7 +51,7 @@ class _AccountDeactivatePageState extends State { final signedIn = context.watch().isSignedIn; final canShow = signedIn || _logic.scheduledAt != null; return Theme( - data: AppTheme.light(), + data: AppTheme.dark(), child: PlainPageScaffold( pageKey: const Key('account-deactivate-page'), backKey: const Key('account-deactivate-back'), diff --git a/lib/pages/account/account_devices_page.dart b/lib/pages/account/account_devices_page.dart index 99f3101..e35ccb6 100644 --- a/lib/pages/account/account_devices_page.dart +++ b/lib/pages/account/account_devices_page.dart @@ -51,7 +51,7 @@ class _AccountDevicesPageState extends State { final l10n = AppLocalizations.of(context)!; final signedIn = context.watch().isSignedIn; return Theme( - data: AppTheme.light(), + data: AppTheme.dark(), child: PlainPageScaffold( pageKey: const Key('account-devices-page'), backKey: const Key('account-devices-back'), diff --git a/lib/pages/account/account_export_page.dart b/lib/pages/account/account_export_page.dart index f451b27..4d134cd 100644 --- a/lib/pages/account/account_export_page.dart +++ b/lib/pages/account/account_export_page.dart @@ -52,7 +52,7 @@ class _AccountExportPageState extends State { final l10n = AppLocalizations.of(context)!; final signedIn = context.watch().isSignedIn; return Theme( - data: AppTheme.light(), + data: AppTheme.dark(), child: PlainPageScaffold( pageKey: const Key('account-export-page'), backKey: const Key('account-export-back'), diff --git a/lib/pages/account/account_profile_page.dart b/lib/pages/account/account_profile_page.dart index 46ebb99..87b15d4 100644 --- a/lib/pages/account/account_profile_page.dart +++ b/lib/pages/account/account_profile_page.dart @@ -70,7 +70,7 @@ class _AccountProfilePageState extends State { final l10n = AppLocalizations.of(context)!; final signedIn = context.watch().isSignedIn; return Theme( - data: AppTheme.light(), + data: AppTheme.dark(), child: PlainPageScaffold( pageKey: const Key('account-profile-page'), backKey: const Key('account-profile-back'), @@ -181,6 +181,7 @@ class _AccountProfilePageState extends State { Widget _genderField(AppLocalizations l10n) => DropdownButtonFormField( key: const Key('account-gender'), + isExpanded: true, initialValue: _gender, decoration: InputDecoration(labelText: l10n.accountGender), items: [ diff --git a/lib/pages/auth/auth_form_widgets.dart b/lib/pages/auth/auth_form_widgets.dart index 81a58a7..b46fd1a 100644 --- a/lib/pages/auth/auth_form_widgets.dart +++ b/lib/pages/auth/auth_form_widgets.dart @@ -268,7 +268,7 @@ class _ModeSegment extends StatelessWidget { duration: _segmentDuration, alignment: Alignment.center, decoration: BoxDecoration( - color: selected ? AppColors.blue : Colors.transparent, + color: selected ? AppColors.blue : AppColors.transparent, borderRadius: BorderRadius.circular(18), ), child: Stack( diff --git a/lib/pages/auth/auth_view.dart b/lib/pages/auth/auth_view.dart index 9d5106a..5cf37b5 100644 --- a/lib/pages/auth/auth_view.dart +++ b/lib/pages/auth/auth_view.dart @@ -258,7 +258,7 @@ class _AuthPageState extends State { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; return Theme( - data: AppTheme.light(), + data: AppTheme.dark(), child: Scaffold( resizeToAvoidBottomInset: false, backgroundColor: AppColors.ink, diff --git a/lib/pages/auth/auth_widgets.dart b/lib/pages/auth/auth_widgets.dart index 9355158..55b160e 100644 --- a/lib/pages/auth/auth_widgets.dart +++ b/lib/pages/auth/auth_widgets.dart @@ -141,7 +141,7 @@ class _AuthModeSwitch extends StatelessWidget { style: TextButton.styleFrom( minimumSize: const Size(0, _minTapTarget), padding: EdgeInsets.zero, - overlayColor: Colors.transparent, + overlayColor: AppColors.transparent, splashFactory: NoSplash.splashFactory, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)), ), diff --git a/lib/pages/auth/phone_auth_sheet.dart b/lib/pages/auth/phone_auth_sheet.dart index 0355821..9fe651b 100644 --- a/lib/pages/auth/phone_auth_sheet.dart +++ b/lib/pages/auth/phone_auth_sheet.dart @@ -28,8 +28,8 @@ Future showPhoneAuthSheet(BuildContext context, {bool signUp = false}) { context: context, isScrollControlled: true, useSafeArea: true, - backgroundColor: Colors.transparent, - barrierColor: Colors.black.withValues(alpha: 0.62), + backgroundColor: AppColors.transparent, + barrierColor: AppColors.mediaBlack.withValues(alpha: 0.62), builder: (_) => PhoneAuthSheet(initialSignUp: signUp), ); } diff --git a/lib/pages/create/create_view.dart b/lib/pages/create/create_view.dart index bf6e729..d354834 100644 --- a/lib/pages/create/create_view.dart +++ b/lib/pages/create/create_view.dart @@ -13,8 +13,8 @@ const double _arrowSize = 36; const double _illustrationWidth = 156; const double _illustrationHeight = 88; const double _mottoLineWidth = 96; -const List _aiGradientStart = [Color(0xFFFFE3D0), Color(0xFFFFC9AC)]; -const List _uploadGradient = [Color(0xFFFFEAD9), Color(0xFFFFD3B3)]; +const List _aiGradientStart = [AppColors.raised, AppColors.goldCard]; +const List _uploadGradient = [AppColors.card, AppColors.raised]; /// 创作页:AI 制作与上传发布两张主卡,底部品牌标语。 class CreatePage extends StatelessWidget { @@ -24,7 +24,7 @@ class CreatePage extends StatelessWidget { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; return Theme( - data: AppTheme.light(), + data: AppTheme.dark(), child: SafeArea( bottom: false, child: ListView( @@ -131,7 +131,7 @@ class _CreateCard extends StatelessWidget { boxShadow: const [AppTheme.cardShadow], ), child: Material( - color: Colors.transparent, + color: AppColors.transparent, borderRadius: BorderRadius.circular(_cardRadius), clipBehavior: Clip.antiAlias, child: InkWell( @@ -296,7 +296,7 @@ class _UploadIllustration extends StatelessWidget { width: 30, height: 20, decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.55), + color: AppColors.onSurface.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(4), ), child: const Icon( diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index ef3771a..961f09b 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -35,8 +35,8 @@ import 'widgets/discover_header.dart'; import 'widgets/discover_search_results.dart'; import 'widgets/drama_grid.dart'; -const Color _filterIconColor = Color(0xFF7B61FF); -const Color _newIconColor = Color(0xFF14B8A6); +const Color _filterIconColor = AppColors.blueSoft; +const Color _newIconColor = AppColors.blueSoft; const double _toolbarHeight = 36; const double _toolbarGap = 10; const double _toolbarRadius = 10; @@ -165,9 +165,9 @@ class _DiscoverPageState extends State { ), ); return AnnotatedRegion( - value: SystemUiOverlayStyle.dark, + value: SystemUiOverlayStyle.light, child: Theme( - data: AppTheme.light(), + data: AppTheme.dark(), child: embedded ? content : Scaffold(backgroundColor: AppColors.ink, body: content), @@ -228,13 +228,13 @@ class _DiscoverPageState extends State { color: AppColors.ink, border: Border( bottom: BorderSide( - color: pinned ? AppColors.border : Colors.transparent, + color: pinned ? AppColors.border : AppColors.transparent, ), ), boxShadow: pinned ? const [ BoxShadow( - color: Color(0x14000000), + color: AppColors.shadowSubtle, blurRadius: 8, offset: Offset(0, 2), ), @@ -419,7 +419,7 @@ class _DiscoverPageState extends State { _categoryUnderlineHeight, ), painter: CategoryUnderlinePainter( - color: selected ? AppColors.blue : Colors.transparent, + color: selected ? AppColors.blue : AppColors.transparent, ), ), ], diff --git a/lib/pages/discover/widgets/discover_search_field.dart b/lib/pages/discover/widgets/discover_search_field.dart index 345d408..0ddcc2f 100644 --- a/lib/pages/discover/widgets/discover_search_field.dart +++ b/lib/pages/discover/widgets/discover_search_field.dart @@ -97,7 +97,7 @@ class _DiscoverSearchFieldState extends State { color: AppColors.card, borderRadius: BorderRadius.circular(_searchRadius), border: Border.all( - color: _focused ? AppColors.blue : Colors.transparent, + color: _focused ? AppColors.blue : AppColors.transparent, ), ), child: Row( diff --git a/lib/pages/discover/widgets/drama_grid.dart b/lib/pages/discover/widgets/drama_grid.dart index b58d8cf..a76d7eb 100644 --- a/lib/pages/discover/widgets/drama_grid.dart +++ b/lib/pages/discover/widgets/drama_grid.dart @@ -23,8 +23,8 @@ const double _tagChipFontSize = 11; const double _heatFontSize = 9; const double _heatIconSize = 11; const int _maxTags = 3; -const Color _heatBackground = Color(0x99000000); -const Color _tagChipBackground = Color(0xFFF4F1EE); +const Color _heatBackground = AppColors.overlayMedium; +const Color _tagChipBackground = AppColors.raised; final RegExp _tagSplitter = RegExp(r'[,/、·]'); /// 剧场/分类页共用的双列剧目网格。 @@ -231,11 +231,11 @@ class _DiscoverCoverImage extends StatelessWidget { Widget _placeholder() { return const ColoredBox( - color: AppColors.card, + color: AppColors.raised, child: Center( child: Icon( Icons.image_not_supported_outlined, - color: AppColors.muted, + color: AppColors.subtle, size: 32, ), ), diff --git a/lib/pages/discover/widgets/feed_banner_carousel.dart b/lib/pages/discover/widgets/feed_banner_carousel.dart index b48cc83..839071c 100644 --- a/lib/pages/discover/widgets/feed_banner_carousel.dart +++ b/lib/pages/discover/widgets/feed_banner_carousel.dart @@ -183,8 +183,8 @@ class _FeedBannerCarouselState extends State { begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: drama == null - ? const [Color(0x00000000), Color(0x8A0B0C10)] - : const [Color(0x00000000), Color(0xE6000000)], + ? const [AppColors.transparent, AppColors.bannerScrimMid] + : const [AppColors.transparent, AppColors.bannerScrimEnd], ), ), ), @@ -232,7 +232,7 @@ class _FeedBannerCarouselState extends State { child: dramaId == null ? card : Material( - color: Colors.transparent, + color: AppColors.transparent, borderRadius: borderRadius, clipBehavior: Clip.antiAlias, child: InkWell( diff --git a/lib/pages/downloads/downloads_page.dart b/lib/pages/downloads/downloads_page.dart index 359c2a7..4be91fc 100644 --- a/lib/pages/downloads/downloads_page.dart +++ b/lib/pages/downloads/downloads_page.dart @@ -56,7 +56,7 @@ class _DownloadsPageState extends State { final l10n = AppLocalizations.of(context)!; final signedIn = context.watch().isSignedIn; return Theme( - data: AppTheme.light(), + data: AppTheme.dark(), child: PlainPageScaffold( pageKey: const Key('downloads-page'), backKey: const Key('downloads-back'), @@ -105,7 +105,7 @@ class _DownloadsBody extends StatelessWidget { child: Text( l10n.downloadedItems, style: const TextStyle( - color: Colors.white, + color: AppColors.onSurface, fontSize: 16, fontWeight: FontWeight.w700, ), @@ -236,7 +236,7 @@ class _PolicyPanel extends StatelessWidget { Text( l10n.downloadQuota(policy.remaining, policy.maxPerDevice), style: const TextStyle( - color: Colors.white, + color: AppColors.onSurface, fontSize: 17, fontWeight: FontWeight.w800, ), @@ -261,7 +261,7 @@ class _PolicyPanel extends StatelessWidget { child: Text( l10n.downloadWifiOnly, style: const TextStyle( - color: Colors.white, + color: AppColors.onSurface, fontSize: AppDimens.body, ), ), @@ -279,7 +279,7 @@ class _PolicyPanel extends StatelessWidget { child: Text( l10n.downloadQuality, style: const TextStyle( - color: Colors.white, + color: AppColors.onSurface, fontSize: AppDimens.body, ), ), @@ -375,7 +375,7 @@ class _LicenseTile extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - color: Colors.white, + color: AppColors.onSurface, fontSize: 15, fontWeight: FontWeight.w700, ), @@ -418,12 +418,12 @@ class _LicenseTile extends StatelessWidget { ), Text( l10n.downloadProgress(license.progressPercent), - style: const TextStyle(color: Colors.white70, fontSize: 12), + style: const TextStyle(color: AppColors.muted, fontSize: 12), ), if (license.quality.isNotEmpty) Text( license.quality, - style: const TextStyle(color: Colors.white70, fontSize: 12), + style: const TextStyle(color: AppColors.muted, fontSize: 12), ), ], ), @@ -433,7 +433,7 @@ class _LicenseTile extends StatelessWidget { child: LinearProgressIndicator( minHeight: AppDimens.progressHeight, value: progress.clamp(0.0, 1.0), - backgroundColor: Colors.white24, + backgroundColor: AppColors.border, valueColor: const AlwaysStoppedAnimation(AppColors.blueSoft), ), ), diff --git a/lib/pages/drama_detail/drama_detail_view.dart b/lib/pages/drama_detail/drama_detail_view.dart index f3bcb48..eaedc8e 100644 --- a/lib/pages/drama_detail/drama_detail_view.dart +++ b/lib/pages/drama_detail/drama_detail_view.dart @@ -88,15 +88,15 @@ class _DramaDetailPageState extends State { Widget build(BuildContext context) { return AnnotatedRegion( key: const Key('detail-system-overlay'), - value: SystemUiOverlayStyle.dark.copyWith( - statusBarColor: Colors.transparent, + value: SystemUiOverlayStyle.light.copyWith( + statusBarColor: AppColors.transparent, systemNavigationBarColor: AppColors.ink, - systemNavigationBarDividerColor: Colors.transparent, - systemNavigationBarIconBrightness: Brightness.dark, + systemNavigationBarDividerColor: AppColors.transparent, + systemNavigationBarIconBrightness: Brightness.light, systemNavigationBarContrastEnforced: false, ), child: Theme( - data: AppTheme.light(), + data: AppTheme.dark(), child: ListenableBuilder( listenable: logic, builder: (context, _) => _page(context), diff --git a/lib/pages/drama_detail/widgets/drama_detail_sections.dart b/lib/pages/drama_detail/widgets/drama_detail_sections.dart index 9b2e559..1db22b8 100644 --- a/lib/pages/drama_detail/widgets/drama_detail_sections.dart +++ b/lib/pages/drama_detail/widgets/drama_detail_sections.dart @@ -330,7 +330,7 @@ class DramaEpisodeGrid extends StatelessWidget { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(_chipRadius), side: BorderSide( - color: selected || locked ? Colors.transparent : AppColors.border, + color: selected || locked ? AppColors.transparent : AppColors.border, ), ), clipBehavior: Clip.antiAlias, diff --git a/lib/pages/favorites/favorites_page.dart b/lib/pages/favorites/favorites_page.dart index 11512c3..5333467 100644 --- a/lib/pages/favorites/favorites_page.dart +++ b/lib/pages/favorites/favorites_page.dart @@ -75,7 +75,7 @@ class _FavoritesPageState extends State { final l10n = AppLocalizations.of(context)!; final signedIn = context.watch().isSignedIn; return Theme( - data: AppTheme.light(), + data: AppTheme.dark(), child: Scaffold( key: const Key('favorites-page'), backgroundColor: AppColors.ink, @@ -264,7 +264,7 @@ class _FavoriteCard extends StatelessWidget { boxShadow: const [AppTheme.cardShadow], ), child: Material( - color: Colors.transparent, + color: AppColors.transparent, borderRadius: BorderRadius.circular(_cardRadius), clipBehavior: Clip.antiAlias, child: InkWell( diff --git a/lib/pages/filter/filter_page.dart b/lib/pages/filter/filter_page.dart index 2def7eb..43dc6dc 100644 --- a/lib/pages/filter/filter_page.dart +++ b/lib/pages/filter/filter_page.dart @@ -58,7 +58,7 @@ class _FilterPageState extends State { ); final results = _filtered(catalog.dramas); return AnnotatedRegion( - value: SystemUiOverlayStyle.dark, + value: SystemUiOverlayStyle.light, child: Scaffold( key: const Key('filter-page'), backgroundColor: AppColors.ink, diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index dcd4075..6a3ab4c 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -100,7 +100,7 @@ class _HomePageState extends State with WidgetsBindingObserver { final catalog = context.watch(); final l10n = AppLocalizations.of(context)!; return Theme( - data: AppTheme.light(), + data: AppTheme.dark(), child: _buildHome(context, catalog, l10n), ); } @@ -115,7 +115,7 @@ class _HomePageState extends State with WidgetsBindingObserver { } if (catalog.error != null) { return Scaffold( - backgroundColor: Colors.transparent, + backgroundColor: AppColors.transparent, body: HomeFailureView( error: catalog.debugError ?? catalog.error, onRetry: () => unawaited(_retryHome(catalog)), @@ -125,7 +125,7 @@ class _HomePageState extends State with WidgetsBindingObserver { } if (catalog.dramas.isEmpty) { return Scaffold( - backgroundColor: Colors.transparent, + backgroundColor: AppColors.transparent, body: EmptyState( key: const Key('home-empty'), icon: Icons.movie_outlined, @@ -271,14 +271,14 @@ class _HomeTabsState extends State<_HomeTabs> { }, child: Scaffold( key: logic.scaffoldKey, - backgroundColor: Colors.transparent, + backgroundColor: AppColors.transparent, resizeToAvoidBottomInset: false, body: _body(context), bottomNavigationBar: logic.videoImmersive ? null : _nav(context, l10n), ), ); return AnnotatedRegion( - value: feed ? SystemUiOverlayStyle.light : SystemUiOverlayStyle.dark, + value: SystemUiOverlayStyle.light, child: feed ? content : DecoratedBox( @@ -352,13 +352,13 @@ class _HomeTabsState extends State<_HomeTabs> { child: IgnorePointer( key: const Key('home-feed-channel-loading'), child: ColoredBox( - color: Colors.black26, + color: AppColors.controlSoftScrim, child: Center( child: SizedBox.square( dimension: 24, child: CircularProgressIndicator( strokeWidth: 2.4, - color: Colors.white70, + color: AppColors.muted, ), ), ), @@ -374,7 +374,7 @@ class _HomeTabsState extends State<_HomeTabs> { return Stack( fit: StackFit.expand, children: [ - const ColoredBox(color: Colors.black), + const ColoredBox(color: AppColors.mediaBlack), SafeArea( bottom: false, child: Column( @@ -395,7 +395,7 @@ class _HomeTabsState extends State<_HomeTabs> { child: CircularProgressIndicator( key: Key('home-feed-channel-loading'), strokeWidth: 2.4, - color: Colors.white70, + color: AppColors.muted, ), ), ) diff --git a/lib/pages/home/widgets/home_drama_grid.dart b/lib/pages/home/widgets/home_drama_grid.dart index a5b82ce..c923237 100644 --- a/lib/pages/home/widgets/home_drama_grid.dart +++ b/lib/pages/home/widgets/home_drama_grid.dart @@ -86,7 +86,7 @@ class HomeDramaCard extends StatelessWidget { boxShadow: const [AppTheme.cardShadow], ), child: Material( - color: Colors.transparent, + color: AppColors.transparent, borderRadius: BorderRadius.circular(AppDimens.cardRadius), clipBehavior: Clip.antiAlias, child: InkWell( diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index 3b777a5..cb3ac65 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -91,7 +91,7 @@ class _MyPageState extends State { ), ); return Theme( - data: AppTheme.light(), + data: AppTheme.dark(), child: embedded ? content : Scaffold(backgroundColor: AppColors.ink, body: content), @@ -223,7 +223,7 @@ class _MyPageState extends State { Future _openLanguage() async { final selected = await showModalBottomSheet( context: context, - backgroundColor: Colors.transparent, + backgroundColor: AppColors.transparent, showDragHandle: false, builder: (_) => _LanguageSheet(selected: context.read().locale), diff --git a/lib/pages/mine/widgets/mine_header.dart b/lib/pages/mine/widgets/mine_header.dart index cce6ebc..9b80dbf 100644 --- a/lib/pages/mine/widgets/mine_header.dart +++ b/lib/pages/mine/widgets/mine_header.dart @@ -36,7 +36,7 @@ class MineHeader extends StatelessWidget { boxShadow: const [AppTheme.cardShadow], ), child: Material( - color: Colors.transparent, + color: AppColors.transparent, borderRadius: BorderRadius.circular(AppDimens.cardRadius), clipBehavior: Clip.antiAlias, child: InkWell( diff --git a/lib/pages/mine/widgets/mine_menu.dart b/lib/pages/mine/widgets/mine_menu.dart index 8fc6270..7eab07a 100644 --- a/lib/pages/mine/widgets/mine_menu.dart +++ b/lib/pages/mine/widgets/mine_menu.dart @@ -71,7 +71,7 @@ class _ShortcutTile extends StatelessWidget { boxShadow: const [AppTheme.cardShadow], ), child: Material( - color: Colors.transparent, + color: AppColors.transparent, borderRadius: BorderRadius.circular(AppDimens.cardRadius), clipBehavior: Clip.antiAlias, child: InkWell( @@ -198,7 +198,7 @@ class MineVipCard extends StatelessWidget { boxShadow: const [AppTheme.cardShadow], ), child: Material( - color: Colors.transparent, + color: AppColors.transparent, borderRadius: BorderRadius.circular(AppDimens.cardRadius), clipBehavior: Clip.antiAlias, child: InkWell( @@ -287,7 +287,7 @@ class MineMenuList extends StatelessWidget { boxShadow: const [AppTheme.cardShadow], ), child: Material( - color: Colors.transparent, + color: AppColors.transparent, borderRadius: BorderRadius.circular(AppDimens.cardRadius), clipBehavior: Clip.antiAlias, child: Column( diff --git a/lib/pages/new_releases/new_releases_page.dart b/lib/pages/new_releases/new_releases_page.dart index 3076aab..12351e3 100644 --- a/lib/pages/new_releases/new_releases_page.dart +++ b/lib/pages/new_releases/new_releases_page.dart @@ -102,7 +102,7 @@ class _NewReleasesPageState extends State { final hero = visible.isEmpty ? null : visible.first; final more = pager.items.skip(1).take(_moreLimit).toList(); return AnnotatedRegion( - value: SystemUiOverlayStyle.dark, + value: SystemUiOverlayStyle.light, child: Scaffold( key: const Key('new-releases-page'), backgroundColor: AppColors.ink, @@ -259,7 +259,10 @@ class _NewReleasesPageState extends State { gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [Color(0x00000000), Color(0xB3000000)], + colors: [ + AppColors.transparent, + AppColors.playerOverlayPill, + ], ), ), ), @@ -282,7 +285,7 @@ class _NewReleasesPageState extends State { Text( l10n.premiereToday, style: const TextStyle( - color: Colors.white70, + color: AppColors.muted, fontSize: 10, fontWeight: FontWeight.w600, ), @@ -293,7 +296,7 @@ class _NewReleasesPageState extends State { maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( - color: Colors.white, + color: AppColors.onPrimary, fontSize: 20, fontWeight: FontWeight.w800, ), @@ -315,7 +318,7 @@ class _NewReleasesPageState extends State { maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - color: Colors.white70, + color: AppColors.muted, fontSize: 11, ), ), @@ -338,7 +341,7 @@ class _NewReleasesPageState extends State { ), child: const Icon( Icons.play_arrow_rounded, - color: Colors.white, + color: AppColors.onPrimary, size: 22, ), ), @@ -490,7 +493,7 @@ class _NewReleasesPageState extends State { child: Text( l10n.newBadge, style: TextStyle( - color: Colors.white, + color: AppColors.onPrimary, fontSize: compact ? 9 : 10, fontWeight: FontWeight.w800, ), diff --git a/lib/pages/notices/notices_page.dart b/lib/pages/notices/notices_page.dart index ef59a4d..4ed3619 100644 --- a/lib/pages/notices/notices_page.dart +++ b/lib/pages/notices/notices_page.dart @@ -46,7 +46,7 @@ class _NoticesPageState extends State { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; return Theme( - data: AppTheme.light(), + data: AppTheme.dark(), child: PlainPageScaffold( pageKey: const Key('notices-page'), backKey: const Key('notices-back'), diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 15c1260..97b90a3 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -23,6 +23,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/theme/app_colors.dart'; import '../../core/navigation/fade_page_route.dart'; import '../../l10n/app_localizations.dart'; import '../discover/discover_types.dart'; @@ -937,8 +938,9 @@ class PlayerLogic extends ChangeNotifier { } catch (_) { if (context.mounted && session.active == player) { final l10n = AppLocalizations.of(context)!; - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text(l10n.seekFailed))); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.seekFailed))); } } finally { if (scrubbingPlayer == player) cancelScrub(); @@ -1017,7 +1019,7 @@ class PlayerLogic extends ChangeNotifier { try { await showModalBottomSheet( context: context, - backgroundColor: Colors.transparent, + backgroundColor: AppColors.transparent, isScrollControlled: true, builder: (_) => EpisodePickerSheet( drama: drama, @@ -1079,8 +1081,9 @@ class PlayerLogic extends ChangeNotifier { await session.playbackSettled; await saveCurrentProgress(); if (!context.mounted) return; - final action = await Navigator.of(context) - .push(MaterialPageRoute(builder: (_) => const MyPage())); + final action = await Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const MyPage())); if (!context.mounted) return; if (action == MyPageAction.discover) { await showDiscover(context); @@ -1154,7 +1157,7 @@ class PlayerLogic extends ChangeNotifier { await showModalBottomSheet( context: context, isScrollControlled: true, - backgroundColor: Colors.transparent, + backgroundColor: AppColors.transparent, showDragHandle: false, isDismissible: false, enableDrag: false, diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index db69db6..42fe56a 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -393,19 +393,19 @@ class _PlayerPageState extends State return AnnotatedRegion( key: const Key('player-system-overlay'), value: SystemUiOverlayStyle.light.copyWith( - statusBarColor: Colors.transparent, - systemNavigationBarColor: Colors.black, - systemNavigationBarDividerColor: Colors.transparent, + statusBarColor: AppColors.transparent, + systemNavigationBarColor: AppColors.mediaBlack, + systemNavigationBarDividerColor: AppColors.transparent, systemNavigationBarIconBrightness: Brightness.light, systemNavigationBarContrastEnforced: false, ), child: Theme( - data: AppTheme.light(), + data: AppTheme.dark(), child: widget.embedded ? body : Scaffold( key: const Key('player-scaffold'), - backgroundColor: Colors.transparent, + backgroundColor: AppColors.transparent, resizeToAvoidBottomInset: false, body: body, ), @@ -457,7 +457,7 @@ class _PlayerPageState extends State if (widget.homeMode) const ColoredBox( key: Key('home-feed-black-background'), - color: Colors.black, + color: AppColors.mediaBlack, ), PlayerVideoSurface( controller: videoPort?.controller, @@ -497,14 +497,14 @@ class _PlayerPageState extends State children: [ const Icon( Icons.wifi_off_rounded, - color: Colors.white70, + color: AppColors.muted, size: 36, ), const SizedBox(height: 12), Text( errorText(l10n, logic.ticketError, l10n.playbackError), textAlign: TextAlign.center, - style: const TextStyle(color: Colors.white70), + style: const TextStyle(color: AppColors.muted), ), const SizedBox(height: 12), FilledButton( @@ -524,14 +524,14 @@ class _PlayerPageState extends State children: [ const Icon( Icons.error_outline_rounded, - color: Colors.white70, + color: AppColors.muted, size: 36, ), const SizedBox(height: 12), Text( l10n.playbackError, textAlign: TextAlign.center, - style: const TextStyle(color: Colors.white70), + style: const TextStyle(color: AppColors.muted), ), ], ), @@ -551,7 +551,7 @@ class _PlayerPageState extends State children: [ const Icon( Icons.movie_filter_outlined, - color: Colors.white70, + color: AppColors.muted, size: 36, ), const SizedBox(height: 12), @@ -560,7 +560,7 @@ class _PlayerPageState extends State ? l10n.lockedTitle(episode.number) : l10n.noMediaNotice, textAlign: TextAlign.center, - style: const TextStyle(color: Colors.white70), + style: const TextStyle(color: AppColors.muted), ), ], ), diff --git a/lib/pages/player/player_view_actions.dart b/lib/pages/player/player_view_actions.dart index ead3fe0..448b7c2 100644 --- a/lib/pages/player/player_view_actions.dart +++ b/lib/pages/player/player_view_actions.dart @@ -6,7 +6,7 @@ extension _PlayerViewActions on _PlayerPageState { Future _openSpeedSheet(BuildContext context) async { final selected = await showModalBottomSheet( context: context, - backgroundColor: Colors.transparent, + backgroundColor: AppColors.transparent, showDragHandle: false, builder: (_) => PlayerSpeedSheet(selected: _playbackSpeed), ); @@ -19,7 +19,7 @@ extension _PlayerViewActions on _PlayerPageState { final l10n = AppLocalizations.of(context)!; await showModalBottomSheet( context: context, - backgroundColor: Colors.transparent, + backgroundColor: AppColors.transparent, showDragHandle: false, isScrollControlled: true, builder: (_) => PlayerMoreSheet( @@ -52,7 +52,7 @@ extension _PlayerViewActions on _PlayerPageState { if (wallet == null) return; await showModalBottomSheet( context: context, - backgroundColor: Colors.transparent, + backgroundColor: AppColors.transparent, showDragHandle: false, isScrollControlled: true, builder: (_) => PlayerUnlockSheet( diff --git a/lib/pages/player/widgets/drama_comments_sheet.dart b/lib/pages/player/widgets/drama_comments_sheet.dart index 794a7fe..5f000f7 100644 --- a/lib/pages/player/widgets/drama_comments_sheet.dart +++ b/lib/pages/player/widgets/drama_comments_sheet.dart @@ -413,13 +413,16 @@ class _CommentPostSuccessOverlay extends StatelessWidget { vertical: 10, ), decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.82), + color: AppColors.mediaBlack.withValues(alpha: 0.82), borderRadius: BorderRadius.circular(8), ), child: Text( message, textAlign: TextAlign.center, - style: const TextStyle(color: Colors.white, fontSize: 14), + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 14, + ), ), ), ), diff --git a/lib/pages/player/widgets/episode_picker_sheet.dart b/lib/pages/player/widgets/episode_picker_sheet.dart index a59d148..df4b9b6 100644 --- a/lib/pages/player/widgets/episode_picker_sheet.dart +++ b/lib/pages/player/widgets/episode_picker_sheet.dart @@ -40,11 +40,11 @@ class EpisodePickerSheet extends StatelessWidget { final episodes = drama.episodes; return AnnotatedRegion( key: const Key('player-episodes-system-overlay'), - value: SystemUiOverlayStyle.dark.copyWith( - statusBarColor: Colors.transparent, + value: SystemUiOverlayStyle.light.copyWith( + statusBarColor: AppColors.transparent, systemNavigationBarColor: AppColors.card, - systemNavigationBarDividerColor: Colors.transparent, - systemNavigationBarIconBrightness: Brightness.dark, + systemNavigationBarDividerColor: AppColors.transparent, + systemNavigationBarIconBrightness: Brightness.light, systemNavigationBarContrastEnforced: false, ), child: Container( diff --git a/lib/pages/player/widgets/exit_clear_screen_button.dart b/lib/pages/player/widgets/exit_clear_screen_button.dart index a2b1c51..a514aaa 100644 --- a/lib/pages/player/widgets/exit_clear_screen_button.dart +++ b/lib/pages/player/widgets/exit_clear_screen_button.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; +import '../../../core/theme/app_colors.dart'; + /// Shared floating action used to leave clear-screen mode. class ExitClearScreenButton extends StatelessWidget { const ExitClearScreenButton({ @@ -23,8 +25,8 @@ class ExitClearScreenButton extends StatelessWidget { onPressed: onPressed, padding: EdgeInsets.zero, style: IconButton.styleFrom( - backgroundColor: const Color(0x80000000), - foregroundColor: Colors.white, + backgroundColor: AppColors.controlScrim, + foregroundColor: AppColors.onPrimary, shape: const CircleBorder(), ), icon: const Icon(Icons.fullscreen_exit_rounded, size: 18), diff --git a/lib/pages/player/widgets/home_feed_header.dart b/lib/pages/player/widgets/home_feed_header.dart index 1723d5d..fb192f5 100644 --- a/lib/pages/player/widgets/home_feed_header.dart +++ b/lib/pages/player/widgets/home_feed_header.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; +import '../../../core/theme/app_colors.dart'; + import '../../home/home_feed_channel.dart'; import '../../../l10n/app_localizations.dart'; @@ -140,8 +142,8 @@ class _TopIconButton extends StatelessWidget { icon: Icon( icon, size: glyphSize, - color: Colors.white, - shadows: const [Shadow(blurRadius: 6, color: Colors.black)], + color: AppColors.onPrimary, + shadows: const [Shadow(blurRadius: 6, color: AppColors.mediaBlack)], ), ), ); @@ -184,15 +186,17 @@ class _RefreshButton extends StatelessWidget { child: CircularProgressIndicator( key: Key('home-feed-refreshing'), strokeWidth: 2, - color: Colors.white, + color: AppColors.onPrimary, ), ), ) : Icon( Icons.refresh_rounded, size: glyphSize, - color: Colors.white, - shadows: const [Shadow(blurRadius: 6, color: Colors.black)], + color: AppColors.onPrimary, + shadows: const [ + Shadow(blurRadius: 6, color: AppColors.mediaBlack), + ], ), ), ), @@ -232,10 +236,12 @@ class _FeedTabItem extends StatelessWidget { label, maxLines: 1, style: TextStyle( - color: selected ? Colors.white : Colors.white70, + color: selected ? AppColors.onPrimary : AppColors.muted, fontSize: fontSize, fontWeight: selected ? FontWeight.w800 : FontWeight.w600, - shadows: const [Shadow(blurRadius: 6, color: Colors.black)], + shadows: const [ + Shadow(blurRadius: 6, color: AppColors.mediaBlack), + ], ), ), const SizedBox(height: 4), @@ -243,7 +249,7 @@ class _FeedTabItem extends StatelessWidget { width: underlineWidth, height: underlineHeight, decoration: BoxDecoration( - color: selected ? Colors.white : Colors.transparent, + color: selected ? AppColors.onPrimary : AppColors.transparent, borderRadius: BorderRadius.circular(1), ), ), diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index 5dbf276..26a7d53 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -20,8 +20,8 @@ import 'seek_progress_bar.dart'; part 'home_feed_overlay_parts.dart'; -const Color _overlayPill = Color(0x66000000); -const Color _fullBarBackground = Color(0x4D000000); +const Color _overlayPill = AppColors.overlaySoft; +const Color _fullBarBackground = AppColors.overlayBar; const double _titleFontSize = 17; const double _metaFontSize = 12; const double _tagFontSize = 11; @@ -29,7 +29,7 @@ const double _fullBarHeight = 44; const double _progressHeight = 2; const double _progressHitHeight = 24; const List _boostTextShadows = [ - Shadow(color: Colors.black54, blurRadius: 5, offset: Offset(0, 1)), + Shadow(color: AppColors.shadow, blurRadius: 5, offset: Offset(0, 1)), ]; const int _maxTags = 4; final RegExp _tagSplitter = RegExp(r'[,/、·]'); @@ -236,7 +236,7 @@ class _HomeFeedOverlayState extends State { key: const Key('home-feed-author-note'), maxLines: 2, overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Colors.white70, fontSize: 11), + style: const TextStyle(color: AppColors.muted, fontSize: 11), ), ], ], @@ -260,14 +260,14 @@ class _HomeFeedOverlayState extends State { maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - color: Colors.white70, + color: AppColors.muted, fontSize: _metaFontSize, ), ), ), const Icon( Icons.chevron_right_rounded, - color: Colors.white70, + color: AppColors.muted, size: 16, ), ], @@ -288,16 +288,16 @@ class _HomeFeedOverlayState extends State { overflow: TextOverflow.ellipsis, style: const TextStyle( fontFamily: 'Outfit', - color: Colors.white, + color: AppColors.onPrimary, fontSize: _titleFontSize, fontWeight: FontWeight.w800, - shadows: [Shadow(blurRadius: 8, color: Colors.black)], + shadows: [Shadow(blurRadius: 8, color: AppColors.mediaBlack)], ), ), ), const Icon( Icons.chevron_right_rounded, - color: Colors.white, + color: AppColors.onPrimary, size: 20, ), ], @@ -326,7 +326,7 @@ class _HomeFeedOverlayState extends State { tag, maxLines: 1, style: const TextStyle( - color: Colors.white, + color: AppColors.onPrimary, fontSize: _tagFontSize, ), ), @@ -369,7 +369,7 @@ class _HomeFeedOverlayState extends State { children: [ const Icon( Icons.play_arrow_rounded, - color: Colors.white, + color: AppColors.onPrimary, size: 14, ), const SizedBox(width: 6), @@ -381,7 +381,7 @@ class _HomeFeedOverlayState extends State { maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - color: Colors.white, + color: AppColors.onPrimary, fontSize: 13, fontWeight: FontWeight.w600, ), @@ -389,7 +389,7 @@ class _HomeFeedOverlayState extends State { ), const Icon( Icons.chevron_right_rounded, - color: Colors.white, + color: AppColors.onPrimary, size: 18, ), ], diff --git a/lib/pages/player/widgets/home_feed_overlay_parts.dart b/lib/pages/player/widgets/home_feed_overlay_parts.dart index bbf57a2..522b535 100644 --- a/lib/pages/player/widgets/home_feed_overlay_parts.dart +++ b/lib/pages/player/widgets/home_feed_overlay_parts.dart @@ -251,7 +251,10 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { bottom: bottom, child: SizedBox( height: widget.height, - child: ColoredBox(key: _inactiveTrackKey, color: Colors.white24), + child: ColoredBox( + key: _inactiveTrackKey, + color: AppColors.border, + ), ), ), ], @@ -301,7 +304,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { child: Container( key: const Key('home-feed-progress-track'), height: trackHeight, - color: Colors.white24, + color: AppColors.border, ), ), Positioned( @@ -321,7 +324,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { width: size, height: size, decoration: const BoxDecoration( - color: Colors.white, + color: AppColors.onPrimary, shape: BoxShape.circle, ), ), diff --git a/lib/pages/player/widgets/interaction_rail.dart b/lib/pages/player/widgets/interaction_rail.dart index f3ff832..3ee085d 100644 --- a/lib/pages/player/widgets/interaction_rail.dart +++ b/lib/pages/player/widgets/interaction_rail.dart @@ -71,7 +71,7 @@ class _RailButton extends StatelessWidget { width: _iconSize, height: _iconSize, fit: BoxFit.contain, - color: item.selected ? AppColors.blue : Colors.white, + color: item.selected ? AppColors.blue : AppColors.onPrimary, colorBlendMode: BlendMode.srcIn, ), ), @@ -83,11 +83,11 @@ class _RailButton extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - color: Colors.white, + color: AppColors.onPrimary, height: 1, fontSize: 12, fontWeight: FontWeight.w600, - shadows: [Shadow(blurRadius: 6, color: Colors.black)], + shadows: [Shadow(blurRadius: 6, color: AppColors.mediaBlack)], ), ), ], diff --git a/lib/pages/player/widgets/loading_progress_track.dart b/lib/pages/player/widgets/loading_progress_track.dart index d54cb91..8b78abb 100644 --- a/lib/pages/player/widgets/loading_progress_track.dart +++ b/lib/pages/player/widgets/loading_progress_track.dart @@ -2,13 +2,15 @@ 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 = Colors.white, + this.trackColor = AppColors.inactiveTrack, + this.highlightColor = AppColors.onPrimary, }); final double height; diff --git a/lib/pages/player/widgets/player_center_toggle.dart b/lib/pages/player/widgets/player_center_toggle.dart index cfb03c9..78ba374 100644 --- a/lib/pages/player/widgets/player_center_toggle.dart +++ b/lib/pages/player/widgets/player_center_toggle.dart @@ -6,9 +6,9 @@ import '../../../core/theme/app_colors.dart'; const double _toggleSize = 68; const double _iconSize = 36; -const Color _toggleBackground = Colors.black26; +const Color _toggleBackground = AppColors.controlSoftScrim; const List _iconShadows = [ - Shadow(color: Colors.black54, blurRadius: 12), + Shadow(color: AppColors.shadow, blurRadius: 12), ]; /// 首页沉浸流与全屏播放器共用的播放/暂停按钮。 diff --git a/lib/pages/player/widgets/player_more_sheet.dart b/lib/pages/player/widgets/player_more_sheet.dart index 18e9ad1..c59d828 100644 --- a/lib/pages/player/widgets/player_more_sheet.dart +++ b/lib/pages/player/widgets/player_more_sheet.dart @@ -271,7 +271,7 @@ class _PlayerMoreSheetState extends State { }) { return Material( key: key, - color: Colors.transparent, + color: AppColors.transparent, borderRadius: BorderRadius.circular(8), child: InkWell( borderRadius: BorderRadius.circular(8), @@ -347,7 +347,7 @@ class _SegmentButton extends StatelessWidget { ? AppColors.muted : AppColors.subtle; return Material( - color: Colors.transparent, + color: AppColors.transparent, borderRadius: BorderRadius.circular(_segmentRadius), child: InkWell( onTap: onTap, @@ -357,12 +357,12 @@ class _SegmentButton extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: _segmentPadding), alignment: Alignment.center, decoration: BoxDecoration( - color: selected ? AppColors.card : Colors.transparent, + color: selected ? AppColors.card : AppColors.transparent, borderRadius: BorderRadius.circular(_segmentRadius), boxShadow: selected ? const [ BoxShadow( - color: Color(0x14000000), + color: AppColors.shadowSubtle, blurRadius: 4, offset: Offset(0, 1), ), diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index a401acf..9781bf3 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -18,7 +18,7 @@ const double _topBarHeight = 58; const double _boostHintIconSize = 16; const double _boostHintFontSize = 13; const List _playerTextShadows = [ - Shadow(color: Colors.black54, blurRadius: 5, offset: Offset(0, 1)), + Shadow(color: AppColors.shadow, blurRadius: 5, offset: Offset(0, 1)), ]; /// K29 竖屏播放页浮层。 @@ -270,7 +270,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { thumbnailsUrl: data.episode.thumbnailsUrl, episodeId: data.episode.id, posterUrl: data.drama.posterUrl, - inactiveTrackColor: Colors.white24, + inactiveTrackColor: AppColors.inactiveTrack, onScrubStart: actions.onScrubStart, onSeek: actions.onSeek, ), @@ -279,7 +279,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { Widget _detailRow(String series) { return Material( - color: Colors.transparent, + color: AppColors.transparent, child: InkWell( key: const Key('player-open-detail'), onTap: actions.onDetail, diff --git a/lib/pages/player/widgets/player_overlay_parts.dart b/lib/pages/player/widgets/player_overlay_parts.dart index 383365d..39a1710 100644 --- a/lib/pages/player/widgets/player_overlay_parts.dart +++ b/lib/pages/player/widgets/player_overlay_parts.dart @@ -22,7 +22,7 @@ class _TopButton extends StatelessWidget { tooltip: tooltip, padding: EdgeInsets.zero, style: IconButton.styleFrom( - backgroundColor: const Color(0x80000000), + backgroundColor: AppColors.controlScrim, foregroundColor: AppColors.onPrimary, ), onPressed: onPressed, @@ -41,7 +41,7 @@ class _SpeedEntry extends StatelessWidget { @override Widget build(BuildContext context) { return Material( - color: const Color(0x80000000), + color: AppColors.controlScrim, borderRadius: BorderRadius.circular(999), child: InkWell( onTap: onTap, diff --git a/lib/pages/player/widgets/player_video_surface.dart b/lib/pages/player/widgets/player_video_surface.dart index 1226e3f..1636f31 100644 --- a/lib/pages/player/widgets/player_video_surface.dart +++ b/lib/pages/player/widgets/player_video_surface.dart @@ -4,6 +4,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; +import '../../../core/theme/app_colors.dart'; + const Duration _videoFadeDuration = Duration(milliseconds: 200); const double _errorHorizontal = 32; const int _errorMaxLines = 3; @@ -55,7 +57,7 @@ class _PlayerVideoSurfaceState extends State { children: [ const ColoredBox( key: Key('player-fallback-background'), - color: Colors.black, + color: AppColors.mediaBlack, ), AnimatedOpacity( key: const Key('player-video-opacity'), @@ -81,7 +83,7 @@ class _PlayerVideoSurfaceState extends State { maxLines: _errorMaxLines, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, - style: const TextStyle(color: Colors.white70, fontSize: 13), + style: const TextStyle(color: AppColors.muted, fontSize: 13), ), ), ), diff --git a/lib/pages/player/widgets/scrub_preview_card.dart b/lib/pages/player/widgets/scrub_preview_card.dart index 89472a1..6bb199d 100644 --- a/lib/pages/player/widgets/scrub_preview_card.dart +++ b/lib/pages/player/widgets/scrub_preview_card.dart @@ -164,7 +164,7 @@ class _ScrubPreviewCardState extends State { decoration: BoxDecoration( color: AppColors.card, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.white, width: 1), + border: Border.all(color: AppColors.onPrimary, width: 1), ), child: ClipRRect( borderRadius: BorderRadius.circular(7), @@ -174,7 +174,7 @@ class _ScrubPreviewCardState extends State { Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), - color: Colors.black87, + color: AppColors.scrubPreviewScrim, child: Text( '${_format(widget.position)} / ${_format(widget.duration)}', key: const Key('scrub-preview-time'), @@ -182,7 +182,7 @@ class _ScrubPreviewCardState extends State { maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - color: Colors.white, + color: AppColors.onPrimary, fontSize: 11, fontWeight: FontWeight.w600, height: 1.2, diff --git a/lib/pages/player/widgets/seek_progress_bar.dart b/lib/pages/player/widgets/seek_progress_bar.dart index ed0f53d..9723933 100644 --- a/lib/pages/player/widgets/seek_progress_bar.dart +++ b/lib/pages/player/widgets/seek_progress_bar.dart @@ -233,7 +233,7 @@ class _SeekProgressBarState extends State { '${_format(previewPosition)} / ${_format(video.duration)}', textAlign: TextAlign.center, style: const TextStyle( - color: Colors.white, + color: AppColors.onPrimary, fontSize: 14, fontWeight: FontWeight.w600, height: 1.2, diff --git a/lib/pages/ranking/ranking_page.dart b/lib/pages/ranking/ranking_page.dart index e28923d..24c34a4 100644 --- a/lib/pages/ranking/ranking_page.dart +++ b/lib/pages/ranking/ranking_page.dart @@ -27,10 +27,10 @@ const double _rankColumnWidth = 56; const double _listCoverWidth = 72; const double _listCoverHeight = 96; const double _loadMoreThreshold = 200; -const Color _goldStart = Color(0xFFF3B34C); -const Color _goldEnd = Color(0xFFD9A23A); -const Color _bronzeStart = Color(0xFFCD7F32); -const Color _bronzeEnd = Color(0xFFA9713A); +const Color _goldStart = AppColors.gold; +const Color _goldEnd = AppColors.goldDeep; +const Color _bronzeStart = AppColors.bronze; +const Color _bronzeEnd = AppColors.bronzeDeep; /// 剧场榜单页:前三名横排领奖台,第 4 名起纵向列表。 class RankingPage extends StatefulWidget { @@ -265,7 +265,7 @@ class _PodiumCard extends StatelessWidget { l10n.topLabel(rank), maxLines: 1, style: const TextStyle( - color: Colors.white, + color: AppColors.onPrimary, fontSize: 11, fontWeight: FontWeight.w800, ), @@ -335,7 +335,7 @@ class _RankingRow extends StatelessWidget { boxShadow: const [AppTheme.cardShadow], ), child: Material( - color: Colors.transparent, + color: AppColors.transparent, borderRadius: BorderRadius.circular(14), clipBehavior: Clip.antiAlias, child: InkWell( diff --git a/lib/pages/reserve/reserve_page.dart b/lib/pages/reserve/reserve_page.dart index 551449d..b6bd7c7 100644 --- a/lib/pages/reserve/reserve_page.dart +++ b/lib/pages/reserve/reserve_page.dart @@ -12,7 +12,7 @@ import '../new_releases/new_releases_page.dart'; const double _ticketRadius = 20; const double _notchSize = 24; -const Color _ticketDash = Color(0x66FF7A1A); +const Color _ticketDash = AppColors.primaryBorder; /// 预约页:档期周历 + 电影票式空态 + 预约权益说明。 class ReservePage extends StatefulWidget { @@ -131,7 +131,7 @@ class _ReservePageState extends State { Text( weekdays[date.weekday - 1], style: TextStyle( - color: selected ? Colors.white : AppColors.muted, + color: selected ? AppColors.onPrimary : AppColors.muted, fontSize: 11, fontWeight: FontWeight.w600, ), @@ -140,7 +140,7 @@ class _ReservePageState extends State { Text( '${date.day}', style: TextStyle( - color: selected ? Colors.white : AppColors.onSurface, + color: selected ? AppColors.onPrimary : AppColors.onSurface, fontSize: 15, fontWeight: FontWeight.w800, ), @@ -207,7 +207,7 @@ class _ReservePageState extends State { ), style: FilledButton.styleFrom( backgroundColor: AppColors.blue, - foregroundColor: Colors.white, + foregroundColor: AppColors.onPrimary, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), @@ -285,7 +285,7 @@ class _ReservePageState extends State { child: Text( '${index + 1}', style: const TextStyle( - color: Colors.white, + color: AppColors.onPrimary, fontSize: 12, fontWeight: FontWeight.w800, ), diff --git a/lib/pages/settings/settings_page.dart b/lib/pages/settings/settings_page.dart index bd8cc4e..180ca52 100644 --- a/lib/pages/settings/settings_page.dart +++ b/lib/pages/settings/settings_page.dart @@ -23,7 +23,7 @@ class SettingsPage extends StatelessWidget { final l10n = AppLocalizations.of(context)!; final auth = context.watch(); return Theme( - data: AppTheme.light(), + data: AppTheme.dark(), child: PlainPageScaffold( pageKey: const Key('settings-page'), backKey: const Key('settings-back'), diff --git a/lib/pages/store/store_page.dart b/lib/pages/store/store_page.dart index beed81a..63bb10a 100644 --- a/lib/pages/store/store_page.dart +++ b/lib/pages/store/store_page.dart @@ -57,7 +57,7 @@ class _StorePageState extends State { final l10n = AppLocalizations.of(context)!; final isVip = widget.type == StoreController.vipType; return Theme( - data: AppTheme.light(), + data: AppTheme.dark(), child: PlainPageScaffold( pageKey: const Key('store-page'), backKey: const Key('store-back'), diff --git a/lib/pages/wallet/wallet_page.dart b/lib/pages/wallet/wallet_page.dart index 72a5948..86b9827 100644 --- a/lib/pages/wallet/wallet_page.dart +++ b/lib/pages/wallet/wallet_page.dart @@ -46,7 +46,7 @@ class _WalletPageState extends State { final l10n = AppLocalizations.of(context)!; final wallet = context.watch(); return Theme( - data: AppTheme.light(), + data: AppTheme.dark(), child: PlainPageScaffold( pageKey: const Key('wallet-page'), backKey: const Key('wallet-back'), diff --git a/lib/pages/watch_records/watch_records_page.dart b/lib/pages/watch_records/watch_records_page.dart index c8c326a..b244c63 100644 --- a/lib/pages/watch_records/watch_records_page.dart +++ b/lib/pages/watch_records/watch_records_page.dart @@ -38,7 +38,7 @@ class WatchRecordsPage extends StatelessWidget { final signedIn = context.watch().isSignedIn; final l10n = AppLocalizations.of(context)!; return Theme( - data: AppTheme.light(), + data: AppTheme.dark(), child: Scaffold( key: const Key('watch-records-page'), backgroundColor: AppColors.ink, diff --git a/lib/pages/watch_records/widgets/watch_record_card.dart b/lib/pages/watch_records/widgets/watch_record_card.dart index bdc6d28..edb59ef 100644 --- a/lib/pages/watch_records/widgets/watch_record_card.dart +++ b/lib/pages/watch_records/widgets/watch_record_card.dart @@ -170,7 +170,7 @@ class WatchRecordCard extends StatelessWidget { key: ValueKey('watch-record-${record.dramaId}'), decoration: _cardDecoration(), child: Material( - color: Colors.transparent, + color: AppColors.transparent, borderRadius: BorderRadius.circular(_cardRadius), clipBehavior: Clip.antiAlias, child: InkWell( diff --git a/test/auth/auth_visual_test.dart b/test/auth/auth_visual_test.dart index 192ed41..92481fd 100644 --- a/test/auth/auth_visual_test.dart +++ b/test/auth/auth_visual_test.dart @@ -13,6 +13,7 @@ import 'package:fruitshorts/core/data/services/auth_controller.dart'; import 'package:fruitshorts/core/data/services/auth_session_store.dart'; import 'package:fruitshorts/core/platform/haptics.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; +import 'package:fruitshorts/core/theme/app_dimens.dart'; import 'package:fruitshorts/core/theme/app_theme.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/auth/auth_view.dart'; @@ -131,7 +132,10 @@ void main() { final card = tester.widget(find.byKey(const Key('auth-card'))); final cardDecoration = card.decoration! as BoxDecoration; expect(cardDecoration.color, AppColors.card); - expect(cardDecoration.borderRadius, BorderRadius.circular(20)); + expect( + cardDecoration.borderRadius, + BorderRadius.circular(AppDimens.authCardRadius), + ); expect(cardDecoration.boxShadow, contains(AppTheme.cardShadow)); expect(card.padding, const EdgeInsets.all(20)); diff --git a/test/core/subpage_header_test.dart b/test/core/subpage_header_test.dart index 353c4e6..cf8d3ea 100644 --- a/test/core/subpage_header_test.dart +++ b/test/core/subpage_header_test.dart @@ -7,7 +7,7 @@ import 'package:fruitshorts/core/theme/app_theme.dart'; import 'package:fruitshorts/core/widgets/subpage_header.dart'; void main() { - testWidgets('should_render_unified_light_header_dimensions', (tester) async { + testWidgets('should_render_unified_dark_header_dimensions', (tester) async { await tester.pumpWidget( MaterialApp( theme: AppTheme.light(), @@ -47,7 +47,7 @@ void main() { ) .decoration as BoxDecoration; - expect(shadow.boxShadow!.single.color, const Color(0x14000000)); + expect(shadow.boxShadow!.single.color, AppColors.shadowSubtle); expect(shadow.boxShadow!.single.blurRadius, 8); expect(shadow.boxShadow!.single.offset, const Offset(0, 2)); }); @@ -75,7 +75,7 @@ void main() { MaterialApp( theme: AppTheme.light(), home: const Scaffold( - backgroundColor: Colors.black, + backgroundColor: AppColors.mediaBlack, body: AppSubpageHeader( title: 'Title', backKey: Key('sample-back'), @@ -97,8 +97,8 @@ void main() { matching: find.byIcon(Icons.chevron_left_rounded), ), ); - expect(material.color, const Color(0x59000000)); - expect(icon.color, Colors.white); + expect(material.color, AppColors.headerScrim); + expect(icon.color, AppColors.onSurface); expect(icon.size, 22); expect(icon.weight, 600); final shadow = @@ -108,7 +108,7 @@ void main() { ) .decoration as BoxDecoration; - expect(shadow.boxShadow!.single.color, const Color(0x33000000)); + expect(shadow.boxShadow!.single.color, AppColors.shadowSoft); expect(shadow.boxShadow!.single.blurRadius, 8); }); } diff --git a/test/empty_state/empty_state_test.dart b/test/empty_state/empty_state_test.dart index cfac0bd..8c56f39 100644 --- a/test/empty_state/empty_state_test.dart +++ b/test/empty_state/empty_state_test.dart @@ -33,7 +33,7 @@ void main() { ); expect( tester.widget(find.byKey(const Key('empty-state-icon'))).color, - AppColors.blue, + AppColors.blueSoft, ); expect( tester @@ -103,7 +103,7 @@ void main() { expect(find.byKey(const Key('empty-state-action')), findsNothing); }); - testWidgets('should_tint_the_bare_icon_with_the_light_theme', (tester) async { + testWidgets('should_tint_the_bare_icon_with_the_dark_theme', (tester) async { await _pump( tester, theme: AppTheme.light(), @@ -111,7 +111,7 @@ void main() { ); expect( tester.widget(find.byKey(const Key('empty-state-icon'))).color, - AppColors.blue, + AppColors.blueSoft, ); await _pump( @@ -121,7 +121,7 @@ void main() { ); expect( tester.widget(find.byKey(const Key('empty-state-icon'))).color, - AppColors.blue, + AppColors.blueSoft, ); }); diff --git a/test/home/app_bottom_nav_test.dart b/test/home/app_bottom_nav_test.dart index f019a17..2ddf85a 100644 --- a/test/home/app_bottom_nav_test.dart +++ b/test/home/app_bottom_nav_test.dart @@ -144,6 +144,6 @@ void main() { matching: find.byType(ColoredBox), ), ); - expect(coloredBox.color, Colors.black); + expect(coloredBox.color, AppColors.bar); }); } diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart index 1b61ce0..03bd987 100644 --- a/test/home/home_nav_ui_test.dart +++ b/test/home/home_nav_ui_test.dart @@ -52,9 +52,9 @@ void main() { final bar = tester.widget(barFinder); final decoration = bar.decoration as BoxDecoration; final border = decoration.border as Border; - // 首页 feed 用深色导航。 - expect(decoration.color, Colors.black); - expect(border.top.color, Colors.white12); + // 所有页面统一深色导航。 + expect(decoration.color, AppColors.bar); + expect(border.top.color, AppColors.border); expect( tester .widget( @@ -65,7 +65,7 @@ void main() { ) .style ?.color, - Colors.white70, + AppColors.navUnselected, ); expect( tester @@ -100,10 +100,10 @@ void main() { await tester.tap(find.byKey(const Key('tab-theater'))); await tester.pumpAndSettle(); expect(haptics.selectionClicks, 1); - final lightBar = tester.widget(barFinder); - final lightDecoration = lightBar.decoration as BoxDecoration; - expect(lightDecoration.color, AppColors.bar.withValues(alpha: 0.95)); - expect((lightDecoration.border as Border).top.color, AppColors.border); + final theaterBar = tester.widget(barFinder); + final theaterDecoration = theaterBar.decoration as BoxDecoration; + expect(theaterDecoration.color, AppColors.bar.withValues(alpha: 0.96)); + expect((theaterDecoration.border as Border).top.color, AppColors.border); await tester.tap(find.byKey(const Key('tab-theater'))); await tester.pumpAndSettle(); expect(haptics.selectionClicks, 1); diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index 0998a3e..ab70046 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -328,7 +328,7 @@ void main() { expect(find.byKey(const Key('home-feed-pip')), findsNothing); }); - testWidgets('should_restore_light_theme_after_leaving_feed_tab', ( + testWidgets('should_keep_light_status_bar_after_leaving_feed_tab', ( tester, ) async { await _pumpApp(tester); @@ -338,7 +338,7 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('home-page-gradient')), findsOneWidget); - expect(_hasLightStatusBar(tester), isFalse); + expect(_hasLightStatusBar(tester), isTrue); }); testWidgets('should_switch_feed_drama_when_swiped_up', (tester) async { diff --git a/test/player/loading_progress_track_test.dart b/test/player/loading_progress_track_test.dart index b461a8b..090ead8 100644 --- a/test/player/loading_progress_track_test.dart +++ b/test/player/loading_progress_track_test.dart @@ -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/pages/player/widgets/loading_progress_track.dart'; void main() { @@ -28,7 +29,7 @@ void main() { tester .widget(find.byKey(const Key('progress-loading-track'))) .color, - Colors.white24, + AppColors.inactiveTrack, ); expect( tester @@ -36,7 +37,7 @@ void main() { find.byKey(const Key('progress-loading-highlight')), ) .color, - Colors.white, + AppColors.onPrimary, ); final initialLeft = tester diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 9bfd278..bc1bd8c 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -825,7 +825,10 @@ void main() { for (final style in [label, synopsis, separator]) { expect(style.fontSize, 12); expect(style.fontWeight, FontWeight.w500); - expect(style.color?.withValues(alpha: 1), AppColors.playerMuted); + expect( + style.color?.withValues(alpha: 1), + AppColors.playerMuted.withValues(alpha: 1), + ); } expect(separator.color, isNot(Colors.white)); }); diff --git a/test/theme/app_dimens_test.dart b/test/theme/app_dimens_test.dart index 4051eb1..d48b70d 100644 --- a/test/theme/app_dimens_test.dart +++ b/test/theme/app_dimens_test.dart @@ -4,13 +4,15 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/theme/app_dimens.dart'; void main() { - test('should_expose_the_k20_spacing_and_type_scale', () { + test('should_expose_dark_theme_radius_and_spacing_scale', () { expect(AppDimens.pageHorizontal, 16); - expect(AppDimens.cardRadius, 16); - expect(AppDimens.coverRadius, 16); - expect(AppDimens.authCardRadius, 20); + expect(AppDimens.cardRadius, 12); + expect(AppDimens.coverRadius, 12); + expect(AppDimens.authCardRadius, 12); + expect(AppDimens.buttonRadius, 10); + expect(AppDimens.inputRadius, 10); + expect(AppDimens.chipRadius, 6); expect(AppDimens.pillRadius, 999); - expect(AppDimens.buttonRadius, 999); expect(AppDimens.caption, 12); expect(AppDimens.body, 14); expect(AppDimens.listTitle, 15); diff --git a/test/theme/app_theme_test.dart b/test/theme/app_theme_test.dart index e7bc83c..1cd52c2 100644 --- a/test/theme/app_theme_test.dart +++ b/test/theme/app_theme_test.dart @@ -7,6 +7,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; +import 'package:fruitshorts/core/theme/app_dimens.dart'; import 'package:fruitshorts/core/theme/app_theme.dart'; double _contrast(Color foreground, Color background) { @@ -16,44 +17,59 @@ double _contrast(Color foreground, Color background) { } void main() { - test('should_expose_light_warm_orange_tokens', () { - expect(AppColors.blue, const Color(0xFFFF7A1A)); - expect(AppColors.bluePressed, const Color(0xFFFF6A00)); - expect(AppColors.secondary, const Color(0xFFFF6A8A)); - expect(AppColors.ink, const Color(0xFFFFF8F0)); - expect(AppColors.surfaceTop, const Color(0xFFFFE9D6)); - expect(AppColors.card, const Color(0xFFFFFFFF)); - expect(AppColors.onSurface, const Color(0xFF1F1B16)); - expect(AppColors.muted, const Color(0xFF8C837B)); - expect(AppColors.subtle, const Color(0xFFB3AAA1)); - expect(AppColors.border, const Color(0xFFF0E4D6)); - expect(AppColors.badgePink, const Color(0xFFFFE3EC)); - expect(AppColors.badgePinkText, const Color(0xFFFF6A8A)); + test('should_expose_dark_visual_tokens', () { + expect(AppColors.blue, const Color(0xFFFF6B2C)); + expect(AppColors.bluePressed, const Color(0xFFE55A1F)); + expect(AppColors.secondary, const Color(0xFF22D3EE)); + expect(AppColors.ink, const Color(0xFF0B1020)); + expect(AppColors.card, const Color(0xFF151B2E)); + expect(AppColors.raised, const Color(0xFF1C2436)); + expect(AppColors.border, const Color(0x0FFFFFFF)); + expect(AppColors.onSurface, const Color(0xEBFFFFFF)); + expect(AppColors.muted, const Color(0xA3FFFFFF)); + expect(AppColors.subtle, const Color(0x61FFFFFF)); + expect(AppDimens.cardRadius, 12); + expect(AppDimens.buttonRadius, 10); + expect(AppDimens.inputRadius, 10); + expect(AppDimens.chipRadius, 6); }); - test('should_keep_primary_text_readable_on_warm_background', () { + test('should_keep_primary_and_secondary_text_readable_on_dark', () { expect( _contrast(AppColors.onSurface, AppColors.ink), greaterThanOrEqualTo(4.5), ); + expect( + _contrast(AppColors.onSurface, AppColors.card), + greaterThanOrEqualTo(4.5), + ); expect(_contrast(AppColors.muted, AppColors.card), greaterThanOrEqualTo(3)); + expect(_contrast(AppColors.gold, AppColors.ink), greaterThanOrEqualTo(3)); + expect(_contrast(AppColors.blue, AppColors.ink), greaterThanOrEqualTo(3)); }); - test('should_use_light_color_scheme_and_dark_system_icons', () { - final theme = AppTheme.light(); + test('should_use_dark_color_scheme_and_light_system_icons', () { + final theme = AppTheme.dark(); - expect(theme.brightness, Brightness.light); + expect(theme.brightness, Brightness.dark); expect(theme.colorScheme.primary, AppColors.blue); expect(theme.colorScheme.onPrimary, AppColors.onPrimary); + expect(theme.colorScheme.secondary, AppColors.secondary); expect(theme.colorScheme.surface, AppColors.ink); expect(theme.colorScheme.onSurface, AppColors.onSurface); expect(theme.colorScheme.outline, AppColors.border); expect(theme.scaffoldBackgroundColor, AppColors.ink); - expect(theme.appBarTheme.systemOverlayStyle, SystemUiOverlayStyle.dark); + expect(theme.appBarTheme.backgroundColor, AppColors.card); + expect(theme.appBarTheme.systemOverlayStyle, SystemUiOverlayStyle.light); + expect(theme.cardTheme.elevation, 0); + expect(theme.dialogTheme.backgroundColor, AppColors.raised); + expect(theme.bottomNavigationBarTheme.backgroundColor, AppColors.bar); + expect(theme.inputDecorationTheme.fillColor, AppColors.input); }); - test('should_use_ink_navigation_bar_without_contrast_scrim', () { + test('should_use_ink_navigation_bar_with_light_icons', () { expect(appSystemOverlayStyle.statusBarColor, Colors.transparent); + expect(appSystemOverlayStyle.statusBarIconBrightness, Brightness.light); expect(appSystemOverlayStyle.systemNavigationBarColor, AppColors.ink); expect( appSystemOverlayStyle.systemNavigationBarDividerColor, @@ -61,20 +77,20 @@ void main() { ); expect( appSystemOverlayStyle.systemNavigationBarIconBrightness, - Brightness.dark, + Brightness.light, ); expect(appSystemOverlayStyle.systemNavigationBarContrastEnforced, isFalse); - final sheetTheme = AppTheme.light().bottomSheetTheme; + final sheetTheme = AppTheme.dark().bottomSheetTheme; expect(sheetTheme.backgroundColor, Colors.transparent); expect(sheetTheme.modalBackgroundColor, Colors.transparent); expect(sheetTheme.surfaceTintColor, Colors.transparent); }); - test('should_expose_page_gradient_and_soft_card_shadow', () { + test('should_expose_flat_page_gradient_without_card_shadow', () { expect(AppTheme.pageGradient.colors, [AppColors.surfaceTop, AppColors.ink]); expect(AppTheme.cardShadow.color, AppColors.cardShadow); - expect(AppTheme.cardShadow.blurRadius, 18); - expect(AppTheme.cardShadow.offset, const Offset(0, 6)); + expect(AppTheme.cardShadow.blurRadius, 0); + expect(AppTheme.cardShadow.offset, Offset.zero); }); } diff --git a/test/theme/dark_theme_smoke_test.dart b/test/theme/dark_theme_smoke_test.dart new file mode 100644 index 0000000..2fea453 --- /dev/null +++ b/test/theme/dark_theme_smoke_test.dart @@ -0,0 +1,117 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/auth_api.dart'; +import 'package:fruitshorts/core/data/services/auth_controller.dart'; +import 'package:fruitshorts/core/data/services/auth_session_store.dart'; +import 'package:fruitshorts/core/theme/app_colors.dart'; +import 'package:fruitshorts/core/theme/app_theme.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/watch_records/watch_records_page.dart'; +import 'package:provider/provider.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 '../support/fake_http_adapter.dart'; + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + testWidgets('should_render_material_app_with_dark_default_theme', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark(), + darkTheme: AppTheme.dark(), + themeMode: ThemeMode.dark, + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + key: const Key('dark-smoke-scaffold'), + appBar: AppBar(title: const Text('Dark')), + body: Card( + key: const Key('dark-smoke-card'), + child: ListTile( + title: Text( + 'Primary', + style: TextStyle(color: AppColors.onSurface), + ), + subtitle: Text('Muted', style: TextStyle(color: AppColors.muted)), + trailing: Text('128', style: TextStyle(color: AppColors.gold)), + ), + ), + bottomNavigationBar: NavigationBar( + selectedIndex: 0, + destinations: const [ + NavigationDestination(icon: Icon(Icons.home), label: 'Home'), + NavigationDestination(icon: Icon(Icons.person), label: 'Mine'), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final context = tester.element( + find.byKey(const Key('dark-smoke-scaffold')), + ); + final theme = Theme.of(context); + expect(theme.brightness, Brightness.dark); + expect(theme.scaffoldBackgroundColor, AppColors.ink); + expect(theme.appBarTheme.backgroundColor, AppColors.card); + expect(theme.cardTheme.color, AppColors.card); + + final card = tester.widget(find.byKey(const Key('dark-smoke-card'))); + expect(card.color ?? theme.cardTheme.color, AppColors.card); + expect(find.text('Primary'), findsOneWidget); + expect(find.text('Muted'), findsOneWidget); + expect(find.text('128'), findsOneWidget); + }); + + testWidgets('should_render_watch_records_page_on_dark_surface', ( + tester, + ) async { + final auth = AuthController( + AuthApi( + ApiClient( + baseUrl: 'http://127.0.0.1:1', + adapter: FakeHttpAdapter( + (_) async => + FakeHttpResponse('{"code":0,"message":"ok","data":null}'), + ), + ), + ), + AuthSessionStore(), + ); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: auth, + child: MaterialApp( + theme: AppTheme.dark(), + darkTheme: AppTheme.dark(), + themeMode: ThemeMode.dark, + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const WatchRecordsPage(), + ), + ), + ); + await tester.pumpAndSettle(); + + final scaffold = tester.widget( + find.byKey(const Key('watch-records-page')), + ); + expect(scaffold.backgroundColor, AppColors.ink); + expect(find.byKey(const Key('auth-gate-panel')), findsOneWidget); + expect(find.byType(Scaffold), findsOneWidget); + }); +} From 91e869d589e2f868e58f1b82d677a7d645518572 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 16:21:05 +0800 Subject: [PATCH 017/129] =?UTF-8?q?feat(nav):=20=E5=BA=95=E9=83=A8?= =?UTF-8?q?=E5=AF=BC=E8=88=AA=E6=94=B9=E4=B8=BA=E9=A6=96=E9=A1=B5=E6=8E=A8?= =?UTF-8?q?=E8=8D=90=E8=BF=BD=E5=89=A7=E6=88=91=E7=9A=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/theme/app_colors.dart | 7 +- lib/core/theme/app_dimens.dart | 2 +- lib/core/theme/app_theme.dart | 4 +- lib/core/widgets/app_bottom_nav.dart | 36 +++--- lib/core/widgets/app_root_tab.dart | 38 ++++++ lib/core/widgets/subpage_header.dart | 12 +- lib/l10n/app_en.arb | 2 +- lib/l10n/app_id.arb | 2 +- lib/l10n/app_localizations.dart | 2 +- lib/l10n/app_localizations_en.dart | 2 +- lib/l10n/app_localizations_id.dart | 2 +- lib/l10n/app_localizations_zh.dart | 2 +- lib/l10n/app_zh.arb | 2 +- lib/pages/discover/discover_types.dart | 4 +- lib/pages/discover/discover_view.dart | 50 +++----- lib/pages/favorites/favorites_page.dart | 60 +++++---- lib/pages/home/home_logic.dart | 12 +- lib/pages/home/home_view.dart | 58 +++------ lib/pages/mine/mine_types.dart | 2 +- lib/pages/mine/mine_view.dart | 48 +++---- lib/pages/player/player_logic.dart | 4 +- test/discover/discover_logic_test.dart | 4 +- test/discover/filter_page_test.dart | 2 +- test/discover/home_all_dramas_grid_test.dart | 2 +- test/discover/theater_pages_test.dart | 2 +- test/home/app_bottom_nav_test.dart | 54 ++++---- test/home/home_logic_test.dart | 8 +- test/home/home_nav_ui_test.dart | 128 +++++++++++++++---- test/home/k28_a_navigation_test.dart | 40 ++---- test/mine/mine_logic_test.dart | 4 +- test/player/player_fullscreen_test.dart | 4 +- test/theme/app_dimens_test.dart | 2 +- test/theme/app_theme_test.dart | 5 +- test/widget_test.dart | 16 +-- 34 files changed, 359 insertions(+), 263 deletions(-) create mode 100644 lib/core/widgets/app_root_tab.dart diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 585fc68..c9b764e 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -69,6 +69,11 @@ abstract final class AppColors { /// 底部导航底色。 static const bar = ink; + /// 底部导航专用:纯黑背景、选中白、未选中灰与轻分割线。 + static const navBackground = Color(0xFF000000); + static const navSelected = Color(0xFFFFFFFF); + static const navDivider = Color(0xFF1C1C1E); + /// 卡片阴影(深色几乎不用阴影)。 static const cardShadow = Color(0x00000000); @@ -121,7 +126,7 @@ abstract final class AppColors { static const badgeBeige = raised; /// 导航未选中。 - static const navUnselected = muted; + static const navUnselected = Color(0xFF8A8A8E); /// 播放器次要文字。 static const playerMuted = Color(0xA3FFFFFF); diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 5de3484..fba2ed4 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -36,7 +36,7 @@ abstract final class AppDimens { static const double previewCardTitle = 13; static const double previewSectionTitle = 14; static const double chipFont = 10; - static const double navLabel = 12; + static const double navLabel = 11; static const double buttonHeight = 48; static const double primaryButtonHeight = 52; diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index dc0003d..8cc1e5c 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -193,8 +193,8 @@ abstract final class AppTheme { ), ), bottomNavigationBarTheme: const BottomNavigationBarThemeData( - backgroundColor: AppColors.bar, - selectedItemColor: AppColors.blue, + backgroundColor: AppColors.navBackground, + selectedItemColor: AppColors.navSelected, unselectedItemColor: AppColors.navUnselected, elevation: 0, type: BottomNavigationBarType.fixed, diff --git a/lib/core/widgets/app_bottom_nav.dart b/lib/core/widgets/app_bottom_nav.dart index 9b29d9d..6578ecc 100644 --- a/lib/core/widgets/app_bottom_nav.dart +++ b/lib/core/widgets/app_bottom_nav.dart @@ -1,7 +1,5 @@ // Author: fruit.com.cn -import 'dart:ui'; - import 'package:flutter/material.dart'; import '../theme/app_colors.dart'; @@ -26,12 +24,12 @@ class AppBottomNavItem { final bool selected; } -/// 四槽底部导航:所有页面统一深色底与浅色图标。 +/// 四槽底部导航:纯黑背景,白色选中态,灰色未选中态。 class AppBottomNav extends StatelessWidget { const AppBottomNav({ super.key, required this.items, - this.backgroundColor = AppColors.bar, + this.backgroundColor = AppColors.navBackground, this.barKey, this.dark = false, }); @@ -47,8 +45,10 @@ class AppBottomNav extends StatelessWidget { key: barKey, height: AppDimens.navHeight, decoration: BoxDecoration( - color: dark ? AppColors.bar : backgroundColor.withValues(alpha: 0.96), - border: const Border(top: BorderSide(color: AppColors.border)), + color: dark ? AppColors.navBackground : backgroundColor, + border: const Border( + top: BorderSide(color: AppColors.navDivider, width: 0.5), + ), ), child: Row( children: [ @@ -60,17 +60,9 @@ class AppBottomNav extends StatelessWidget { ], ), ); - if (dark) { - return ColoredBox( - color: AppColors.bar, - child: SafeArea(top: false, child: bar), - ); - } - return ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), - child: SafeArea(top: false, child: bar), - ), + return ColoredBox( + color: AppColors.navBackground, + child: SafeArea(top: false, child: bar), ); } } @@ -102,7 +94,9 @@ class _NavDestination extends StatelessWidget { child: Icon( item.selected ? item.selectedIcon : item.icon, size: AppDimens.navIcon, - color: item.selected ? AppColors.blue : unselectedColor, + color: item.selected + ? AppColors.navSelected + : unselectedColor, ), ), const SizedBox(height: 3), @@ -112,8 +106,10 @@ class _NavDestination extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: AppDimens.navLabel, - fontWeight: item.selected ? FontWeight.w700 : FontWeight.w500, - color: item.selected ? AppColors.blue : unselectedColor, + fontWeight: item.selected ? FontWeight.w600 : FontWeight.w400, + color: item.selected + ? AppColors.navSelected + : unselectedColor, letterSpacing: 0, ), ), diff --git a/lib/core/widgets/app_root_tab.dart b/lib/core/widgets/app_root_tab.dart new file mode 100644 index 0000000..2a49059 --- /dev/null +++ b/lib/core/widgets/app_root_tab.dart @@ -0,0 +1,38 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../l10n/app_localizations.dart'; + +/// 客户端根导航的固定顺序。 +enum AppRootTab { home, recommend, following, mine } + +extension AppRootTabUi on AppRootTab { + Key get tabKey => switch (this) { + AppRootTab.home => const Key('tab-home'), + AppRootTab.recommend => const Key('tab-recommend'), + AppRootTab.following => const Key('tab-follow'), + AppRootTab.mine => const Key('tab-mine'), + }; + + IconData get icon => switch (this) { + AppRootTab.home => Icons.home_outlined, + AppRootTab.recommend => Icons.smart_display_outlined, + AppRootTab.following => Icons.bookmark_border_rounded, + AppRootTab.mine => Icons.person_outline_rounded, + }; + + IconData get selectedIcon => switch (this) { + AppRootTab.home => Icons.home_rounded, + AppRootTab.recommend => Icons.smart_display_rounded, + AppRootTab.following => Icons.bookmark_rounded, + AppRootTab.mine => Icons.person_rounded, + }; + + String label(AppLocalizations l10n) => switch (this) { + AppRootTab.home => l10n.homeTab, + AppRootTab.recommend => l10n.recommendedTab, + AppRootTab.following => l10n.followingTab, + AppRootTab.mine => l10n.myTab, + }; +} diff --git a/lib/core/widgets/subpage_header.dart b/lib/core/widgets/subpage_header.dart index ed8131f..0c57432 100644 --- a/lib/core/widgets/subpage_header.dart +++ b/lib/core/widgets/subpage_header.dart @@ -120,6 +120,7 @@ class AppSubpageHeader extends StatelessWidget { this.actions, this.onBack, this.dark = false, + this.showBack = true, }); final String title; @@ -128,6 +129,7 @@ class AppSubpageHeader extends StatelessWidget { final Widget? actions; final VoidCallback? onBack; final bool dark; + final bool showBack; @override Widget build(BuildContext context) { @@ -140,8 +142,14 @@ class AppSubpageHeader extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: _horizontal), child: Row( children: [ - AppSubpageBackButton(backKey: backKey, dark: dark, onBack: onBack), - const SizedBox(width: _backGap), + if (showBack) ...[ + AppSubpageBackButton( + backKey: backKey, + dark: dark, + onBack: onBack, + ), + const SizedBox(width: _backGap), + ], Expanded( child: Column( mainAxisAlignment: MainAxisAlignment.center, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 526f378..20336f1 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -162,7 +162,7 @@ "preferences": "Preferences", "followingTab": "Following", "animatedTab": "Animated", - "recommendedTab": "Recommended", + "recommendedTab": "Recommend", "noCategoryDramas": "No dramas in this category yet.", "allDramas": "All Dramas", "allLanguages": "All languages", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index b707d25..684a989 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -139,7 +139,7 @@ "completed": "Selesai", "noSavedShows": "Belum ada tontonan di sini.", "preferences": "Preferensi", - "followingTab": "Mengikuti", + "followingTab": "Daftar Tonton", "animatedTab": "Animasi", "recommendedTab": "Rekomendasi", "noCategoryDramas": "Belum ada drama di kategori ini.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 447651c..5ce83e2 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -685,7 +685,7 @@ abstract class AppLocalizations { /// No description provided for @recommendedTab. /// /// In en, this message translates to: - /// **'Recommended'** + /// **'Recommend'** String get recommendedTab; /// No description provided for @noCategoryDramas. diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index d36f78e..36952cf 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -329,7 +329,7 @@ class AppLocalizationsEn extends AppLocalizations { String get animatedTab => 'Animated'; @override - String get recommendedTab => 'Recommended'; + String get recommendedTab => 'Recommend'; @override String get noCategoryDramas => 'No dramas in this category yet.'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index b5c5ee0..a540d17 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -323,7 +323,7 @@ class AppLocalizationsId extends AppLocalizations { String get preferences => 'Preferensi'; @override - String get followingTab => 'Mengikuti'; + String get followingTab => 'Daftar Tonton'; @override String get animatedTab => 'Animasi'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 3bfa8c4..d57351f 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -318,7 +318,7 @@ class AppLocalizationsZh extends AppLocalizations { String get preferences => '偏好设置'; @override - String get followingTab => '关注'; + String get followingTab => '追剧'; @override String get animatedTab => '漫剧'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 32ecfa9..25647d4 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -139,7 +139,7 @@ "completed": "已看完", "noSavedShows": "这里还没有剧集。", "preferences": "偏好设置", - "followingTab": "关注", + "followingTab": "追剧", "animatedTab": "漫剧", "recommendedTab": "推荐", "noCategoryDramas": "这个分类暂时没有剧集。", diff --git a/lib/pages/discover/discover_types.dart b/lib/pages/discover/discover_types.dart index d369cc9..8443cb6 100644 --- a/lib/pages/discover/discover_types.dart +++ b/lib/pages/discover/discover_types.dart @@ -4,8 +4,8 @@ import 'package:flutter/foundation.dart'; import '../../core/data/models/drama.dart'; -/// 剧场页与根导航之间的动作。 -enum DiscoverPageAction { home, create, my } +/// 推荐页与根导航之间的动作。 +enum DiscoverPageAction { home, recommend, following, mine } /// 剧场页导航回调;没有 `onAction` 时按独立页面处理。 class DiscoverPageCallbacks { diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index 961f09b..8ec05bd 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -19,6 +19,7 @@ import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_bottom_nav.dart'; import '../../core/widgets/app_pull_to_refresh.dart'; +import '../../core/widgets/app_root_tab.dart'; import '../../core/widgets/catalog_debug_info.dart'; import '../../core/widgets/catalog_endpoint_editor.dart'; import '../../core/widgets/empty_state.dart'; @@ -149,7 +150,7 @@ class _DiscoverPageState extends State { key: const Key('theater-page-surface'), color: AppColors.ink, child: SafeArea( - bottom: !embedded, + bottom: false, child: Column( children: [ DiscoverHeader( @@ -613,37 +614,26 @@ class _DiscoverPageState extends State { Widget _bottomNav(AppLocalizations l10n) => AppBottomNav( items: [ - AppBottomNavItem( - tabKey: const Key('discover-nav-home'), - icon: Icons.home_outlined, - selectedIcon: Icons.home_rounded, - label: l10n.homeTab, - onTap: () => _act(DiscoverPageAction.home), - ), - AppBottomNavItem( - tabKey: const Key('discover-nav-current'), - icon: Icons.explore_outlined, - selectedIcon: Icons.explore_rounded, - label: l10n.theaterTab, - selected: true, - onTap: () {}, - ), - AppBottomNavItem( - tabKey: const Key('discover-nav-create'), - icon: Icons.add_box_outlined, - selectedIcon: Icons.add_box_rounded, - label: l10n.createTab, - onTap: () => _act(DiscoverPageAction.create), - ), - AppBottomNavItem( - tabKey: const Key('discover-nav-my'), - icon: Icons.person_outline_rounded, - selectedIcon: Icons.person_rounded, - label: l10n.myTab, - onTap: () => _act(DiscoverPageAction.my), - ), + for (final tab in AppRootTab.values) + AppBottomNavItem( + tabKey: tab.tabKey, + icon: tab.icon, + selectedIcon: tab.selectedIcon, + label: tab.label(l10n), + selected: tab == AppRootTab.recommend, + onTap: tab == AppRootTab.recommend + ? () {} + : () => _act(_actionOf(tab)), + ), ], ); + + DiscoverPageAction _actionOf(AppRootTab tab) => switch (tab) { + AppRootTab.home => DiscoverPageAction.home, + AppRootTab.recommend => DiscoverPageAction.recommend, + AppRootTab.following => DiscoverPageAction.following, + AppRootTab.mine => DiscoverPageAction.mine, + }; } /// 分类 tab 的弧形选中线。 diff --git a/lib/pages/favorites/favorites_page.dart b/lib/pages/favorites/favorites_page.dart index 5333467..5452ec1 100644 --- a/lib/pages/favorites/favorites_page.dart +++ b/lib/pages/favorites/favorites_page.dart @@ -44,14 +44,33 @@ const double _loadMoreThreshold = 240; final RegExp _tagSplitter = RegExp(r'[,/、·]'); /// 独立我的收藏页。 -class FavoritesPage extends StatefulWidget { +class FavoritesPage extends StatelessWidget { const FavoritesPage({super.key}); @override - State createState() => _FavoritesPageState(); + Widget build(BuildContext context) { + return Theme( + data: AppTheme.dark(), + child: Scaffold( + key: const Key('favorites-page'), + backgroundColor: AppColors.ink, + body: const SafeArea(child: FavoritesContent()), + ), + ); + } } -class _FavoritesPageState extends State { +/// 收藏列表主体;可独立使用,也可直接嵌入根导航。 +class FavoritesContent extends StatefulWidget { + const FavoritesContent({super.key, this.showBack = true}); + + final bool showBack; + + @override + State createState() => _FavoritesContentState(); +} + +class _FavoritesContentState extends State { bool _requested = false; @override @@ -74,28 +93,21 @@ class _FavoritesPageState extends State { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final signedIn = context.watch().isSignedIn; - return Theme( - data: AppTheme.dark(), - child: Scaffold( - key: const Key('favorites-page'), - backgroundColor: AppColors.ink, - body: SafeArea( - child: Column( - children: [ - AppSubpageHeader( - title: l10n.myFavorites, - backKey: const Key('favorites-back'), - ), - Expanded( - child: signedIn - ? _FavoritesBody( - favorites: context.watch(), - ) - : const AuthGatePanel(), - ), - ], + return SafeArea( + bottom: false, + child: Column( + children: [ + AppSubpageHeader( + title: l10n.myFavorites, + backKey: const Key('favorites-back'), + showBack: widget.showBack, ), - ), + Expanded( + child: signedIn + ? _FavoritesBody(favorites: context.watch()) + : const AuthGatePanel(), + ), + ], ), ); } diff --git a/lib/pages/home/home_logic.dart b/lib/pages/home/home_logic.dart index 826749e..dfd1f00 100644 --- a/lib/pages/home/home_logic.dart +++ b/lib/pages/home/home_logic.dart @@ -79,9 +79,11 @@ class HomeLogic extends ChangeNotifier { switch (action) { case DiscoverPageAction.home: select(0); - case DiscoverPageAction.create: + case DiscoverPageAction.recommend: + select(1, search: true); + case DiscoverPageAction.following: select(2); - case DiscoverPageAction.my: + case DiscoverPageAction.mine: select(3); } } @@ -102,10 +104,12 @@ class HomeLogic extends ChangeNotifier { switch (action) { case MyPageAction.home: select(0); - case MyPageAction.discover: + case MyPageAction.recommend: select(1, search: true); - case MyPageAction.create: + case MyPageAction.following: select(2); + case MyPageAction.mine: + select(3); case MyPageAction.resume: select(0, resume: true); } diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 6a3ab4c..61b52ec 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -17,13 +17,14 @@ import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_bottom_nav.dart'; +import '../../core/widgets/app_root_tab.dart'; import '../../core/widgets/catalog_debug_info.dart'; import '../../core/widgets/catalog_endpoint_editor.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; import '../discover/discover_types.dart'; import '../discover/discover_view.dart'; -import '../create/create_view.dart'; +import '../favorites/favorites_page.dart'; import '../mine/mine_types.dart'; import '../player/widgets/home_feed_header.dart'; import '../player/player_view.dart'; @@ -295,7 +296,7 @@ class _HomeTabsState extends State<_HomeTabs> { children: [ _home(context), _discover(context), - const CreatePage(), + const FavoritesContent(key: Key('favorites-page'), showBack: false), _my(context), ], ); @@ -455,10 +456,10 @@ class _HomeTabsState extends State<_HomeTabs> { barKey: const Key('home-tabs-bar'), dark: logic.tab == 0, items: [ - _navItem(context, l10n, 0), - _navItem(context, l10n, 1), - _navItem(context, l10n, 2), - _navItem(context, l10n, 3), + _navItem(context, l10n, AppRootTab.home), + _navItem(context, l10n, AppRootTab.recommend), + _navItem(context, l10n, AppRootTab.following), + _navItem(context, l10n, AppRootTab.mine), ], ); } @@ -466,43 +467,16 @@ class _HomeTabsState extends State<_HomeTabs> { AppBottomNavItem _navItem( BuildContext context, AppLocalizations l10n, - int index, + AppRootTab tab, ) { - final selected = logic.tab == index; - return switch (index) { - 0 => AppBottomNavItem( - tabKey: const Key('tab-home'), - icon: Icons.home_outlined, - selectedIcon: Icons.home_rounded, - label: l10n.homeTab, - selected: selected, - onTap: () => _selectTab(context, index), - ), - 1 => AppBottomNavItem( - tabKey: const Key('tab-theater'), - icon: Icons.explore_outlined, - selectedIcon: Icons.explore_rounded, - label: l10n.theaterTab, - selected: selected, - onTap: () => _selectTab(context, index), - ), - 2 => AppBottomNavItem( - tabKey: const Key('tab-create'), - icon: Icons.add_box_outlined, - selectedIcon: Icons.add_box_rounded, - label: l10n.createTab, - selected: selected, - onTap: () => _selectTab(context, index), - ), - _ => AppBottomNavItem( - tabKey: const Key('tab-mine'), - icon: Icons.person_outline_rounded, - selectedIcon: Icons.person_rounded, - label: l10n.myTab, - selected: selected, - onTap: () => _selectTab(context, index), - ), - }; + return AppBottomNavItem( + tabKey: tab.tabKey, + icon: tab.icon, + selectedIcon: tab.selectedIcon, + label: tab.label(l10n), + selected: logic.tab == tab.index, + onTap: () => _selectTab(context, tab.index), + ); } void _selectTab(BuildContext context, int tab) { diff --git a/lib/pages/mine/mine_types.dart b/lib/pages/mine/mine_types.dart index a823e8b..b7da322 100644 --- a/lib/pages/mine/mine_types.dart +++ b/lib/pages/mine/mine_types.dart @@ -5,7 +5,7 @@ import 'package:flutter/foundation.dart'; import '../../core/data/models/drama.dart'; /// 「我的」页与根导航之间的返回动作。 -enum MyPageAction { home, discover, create, resume } +enum MyPageAction { home, recommend, following, mine, resume } /// 「我的」页可进入的功能目的地。 enum MineDestination { diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index cb3ac65..bba996f 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -13,6 +13,7 @@ import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_bottom_nav.dart'; +import '../../core/widgets/app_root_tab.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/plain_page_scaffold.dart'; import '../../l10n/app_localizations.dart'; @@ -81,7 +82,7 @@ class _MyPageState extends State { Widget _page(BuildContext context) { final l10n = AppLocalizations.of(context)!; final content = SafeArea( - bottom: !embedded, + bottom: false, child: Column( children: [ _topBar(l10n), @@ -240,39 +241,26 @@ class _MyPageState extends State { Widget _bottomNav(AppLocalizations l10n) { return AppBottomNav( items: [ - AppBottomNavItem( - tabKey: const Key('mine-nav-home'), - icon: Icons.home_outlined, - selectedIcon: Icons.home_rounded, - label: l10n.homeTab, - onTap: () => _act(MyPageAction.home), - ), - AppBottomNavItem( - tabKey: const Key('mine-nav-discover'), - icon: Icons.explore_outlined, - selectedIcon: Icons.explore_rounded, - label: l10n.theaterTab, - onTap: () => _act(MyPageAction.discover), - ), - AppBottomNavItem( - tabKey: const Key('mine-nav-create'), - icon: Icons.add_box_outlined, - selectedIcon: Icons.add_box_rounded, - label: l10n.createTab, - onTap: () => _act(MyPageAction.create), - ), - AppBottomNavItem( - tabKey: const Key('mine-nav-current'), - icon: Icons.person_outline_rounded, - selectedIcon: Icons.person_rounded, - label: l10n.myTab, - selected: true, - onTap: () {}, - ), + for (final tab in AppRootTab.values) + AppBottomNavItem( + tabKey: tab.tabKey, + icon: tab.icon, + selectedIcon: tab.selectedIcon, + label: tab.label(l10n), + selected: tab == AppRootTab.mine, + onTap: tab == AppRootTab.mine ? () {} : () => _act(_actionOf(tab)), + ), ], ); } + MyPageAction _actionOf(AppRootTab tab) => switch (tab) { + AppRootTab.home => MyPageAction.home, + AppRootTab.recommend => MyPageAction.recommend, + AppRootTab.following => MyPageAction.following, + AppRootTab.mine => MyPageAction.mine, + }; + void _act(MyPageAction action) { logic.act( context, diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 97b90a3..a9c44e8 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -1085,7 +1085,7 @@ class PlayerLogic extends ChangeNotifier { context, ).push(MaterialPageRoute(builder: (_) => const MyPage())); if (!context.mounted) return; - if (action == MyPageAction.discover) { + if (action == MyPageAction.recommend) { await showDiscover(context); } else { session.setRouteVisible(true); @@ -1117,7 +1117,7 @@ class PlayerLogic extends ChangeNotifier { MaterialPageRoute(builder: (_) => DiscoverPage(focusSearch: focusSearch)), ); if (!context.mounted) return; - if (action == DiscoverPageAction.my) { + if (action == DiscoverPageAction.mine) { await showMy(context); } else { session.setRouteVisible(true); diff --git a/test/discover/discover_logic_test.dart b/test/discover/discover_logic_test.dart index bd84546..80bcfe8 100644 --- a/test/discover/discover_logic_test.dart +++ b/test/discover/discover_logic_test.dart @@ -99,9 +99,9 @@ void main() { ); expect(forwarded, [DiscoverPageAction.home]); - logic.act(pageContext, DiscoverPageAction.my, embedded: false); + logic.act(pageContext, DiscoverPageAction.mine, embedded: false); await tester.pumpAndSettle(); - await expectLater(popped.future, completion(DiscoverPageAction.my)); + await expectLater(popped.future, completion(DiscoverPageAction.mine)); logic.dispose(); }); diff --git a/test/discover/filter_page_test.dart b/test/discover/filter_page_test.dart index 10fe4c2..91d82dc 100644 --- a/test/discover/filter_page_test.dart +++ b/test/discover/filter_page_test.dart @@ -151,7 +151,7 @@ Future _pumpFilterPage(WidgetTester tester) async { ), ); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-theater'))); + await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('theater-toolbar-filter'))); await tester.pumpAndSettle(); diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index 960a0d2..5ed4eb0 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -455,7 +455,7 @@ Future _pumpTheater( ), ); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-theater'))); + await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); } diff --git a/test/discover/theater_pages_test.dart b/test/discover/theater_pages_test.dart index 4bb3437..1e1877c 100644 --- a/test/discover/theater_pages_test.dart +++ b/test/discover/theater_pages_test.dart @@ -303,7 +303,7 @@ Future _pumpTheater( ), ); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-theater'))); + await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); } diff --git a/test/home/app_bottom_nav_test.dart b/test/home/app_bottom_nav_test.dart index 2ddf85a..ecf53c2 100644 --- a/test/home/app_bottom_nav_test.dart +++ b/test/home/app_bottom_nav_test.dart @@ -7,7 +7,7 @@ import 'package:fruitshorts/core/theme/app_dimens.dart'; import 'package:fruitshorts/core/widgets/app_bottom_nav.dart'; void main() { - testWidgets('should_render_four_slots_with_orange_selected_state', ( + testWidgets('should_render_four_slots_with_white_selected_state', ( tester, ) async { var theaterTaps = 0; @@ -25,17 +25,17 @@ void main() { onTap: () {}, ), AppBottomNavItem( - tabKey: const Key('tab-theater'), - icon: Icons.explore_outlined, - selectedIcon: Icons.explore_rounded, - label: 'Theater', + tabKey: const Key('tab-recommend'), + icon: Icons.smart_display_outlined, + selectedIcon: Icons.smart_display_rounded, + label: 'Recommend', onTap: () => theaterTaps++, ), AppBottomNavItem( - tabKey: const Key('tab-create'), - icon: Icons.add_box_outlined, - selectedIcon: Icons.add_box_rounded, - label: 'Create', + tabKey: const Key('tab-follow'), + icon: Icons.bookmark_border_rounded, + selectedIcon: Icons.bookmark_rounded, + label: 'Following', onTap: () {}, ), AppBottomNavItem( @@ -52,15 +52,21 @@ void main() { ); expect(find.byKey(const Key('tab-home')), findsOneWidget); - expect(find.byKey(const Key('tab-theater')), findsOneWidget); - expect(find.byKey(const Key('tab-create')), findsOneWidget); + expect(find.byKey(const Key('tab-recommend')), findsOneWidget); + expect(find.byKey(const Key('tab-follow')), findsOneWidget); expect(find.byKey(const Key('tab-mine')), findsOneWidget); expect(find.byKey(const Key('nav-center-action')), findsNothing); expect(find.text('Home'), findsOneWidget); + final selectedLabel = tester.widget(find.text('Home')); + expect(selectedLabel.style?.color, AppColors.navSelected); + expect(selectedLabel.style?.fontWeight, FontWeight.w600); + final unselectedLabel = tester.widget(find.text('Recommend')); + expect(unselectedLabel.style?.color, AppColors.navUnselected); + expect(unselectedLabel.style?.fontWeight, FontWeight.w400); final centers = [ Key('tab-home'), - Key('tab-theater'), - Key('tab-create'), + Key('tab-recommend'), + Key('tab-follow'), Key('tab-mine'), ].map((key) => tester.getCenter(find.byKey(key)).dx).toList(); expect(centers, orderedEquals(centers.toList()..sort())); @@ -77,9 +83,9 @@ void main() { ), ) .color, - AppColors.blue, + AppColors.navSelected, ); - await tester.tap(find.byKey(const Key('tab-theater'))); + await tester.tap(find.byKey(const Key('tab-recommend'))); expect(theaterTaps, 1); }); @@ -105,17 +111,17 @@ void main() { onTap: () {}, ), AppBottomNavItem( - tabKey: const Key('tab-theater'), - icon: Icons.explore_outlined, - selectedIcon: Icons.explore_rounded, - label: 'Theater', + tabKey: const Key('tab-recommend'), + icon: Icons.smart_display_outlined, + selectedIcon: Icons.smart_display_rounded, + label: 'Recommend', onTap: () {}, ), AppBottomNavItem( - tabKey: const Key('tab-create'), - icon: Icons.add_box_outlined, - selectedIcon: Icons.add_box_rounded, - label: 'Create', + tabKey: const Key('tab-follow'), + icon: Icons.bookmark_border_rounded, + selectedIcon: Icons.bookmark_rounded, + label: 'Following', onTap: () {}, ), AppBottomNavItem( @@ -144,6 +150,6 @@ void main() { matching: find.byType(ColoredBox), ), ); - expect(coloredBox.color, AppColors.bar); + expect(coloredBox.color, AppColors.navBackground); }); } diff --git a/test/home/home_logic_test.dart b/test/home/home_logic_test.dart index c23a029..478bdad 100644 --- a/test/home/home_logic_test.dart +++ b/test/home/home_logic_test.dart @@ -159,11 +159,11 @@ void main() { test('should_map_onto_root_tabs_when_my_page_actions_fire', () { final logic = HomeLogic(dramas); - logic.myAction(MyPageAction.discover); + logic.myAction(MyPageAction.recommend); expect(logic.tab, 1); expect(logic.searchRequest, 1); - logic.myAction(MyPageAction.create); + logic.myAction(MyPageAction.following); expect(logic.tab, 2); logic.myAction(MyPageAction.resume); @@ -179,10 +179,10 @@ void main() { test('should_map_onto_root_tabs_when_discover_actions_fire', () { final logic = HomeLogic(dramas); - logic.discoverAction(DiscoverPageAction.my); + logic.discoverAction(DiscoverPageAction.mine); expect(logic.tab, 3); - logic.discoverAction(DiscoverPageAction.create); + logic.discoverAction(DiscoverPageAction.following); expect(logic.tab, 2); logic.discoverAction(DiscoverPageAction.home); diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart index 03bd987..1e080fe 100644 --- a/test/home/home_nav_ui_test.dart +++ b/test/home/home_nav_ui_test.dart @@ -7,6 +7,8 @@ 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/theme/app_colors.dart'; +import 'package:fruitshorts/core/widgets/app_bottom_nav.dart'; +import 'package:fruitshorts/pages/favorites/favorites_page.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'; @@ -19,8 +21,8 @@ import '../support/widget_player.dart'; const _keys = [ Key('home-tabs-bar'), Key('tab-home'), - Key('tab-theater'), - Key('tab-create'), + Key('tab-recommend'), + Key('tab-follow'), Key('tab-mine'), ]; @@ -47,20 +49,54 @@ void main() { for (final key in _keys.skip(1)) { expect(tester.getSize(find.byKey(key)).height, greaterThanOrEqualTo(48)); } + final tabCenters = _keys + .skip(1) + .map((key) => tester.getCenter(find.byKey(key)).dx) + .toList(); + expect(tabCenters, orderedEquals(tabCenters.toList()..sort())); + expect( + find.descendant( + of: find.byKey(const Key('tab-home')), + matching: find.text('Home'), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byKey(const Key('tab-recommend')), + matching: find.text('Recommend'), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byKey(const Key('tab-follow')), + matching: find.text('Following'), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byKey(const Key('tab-mine')), + matching: find.text('My'), + ), + findsOneWidget, + ); final barFinder = find.byKey(const Key('home-tabs-bar')); final bar = tester.widget(barFinder); final decoration = bar.decoration as BoxDecoration; final border = decoration.border as Border; // 所有页面统一深色导航。 - expect(decoration.color, AppColors.bar); - expect(border.top.color, AppColors.border); + expect(decoration.color, AppColors.navBackground); + expect(border.top.color, AppColors.navDivider); + expect(border.top.width, 0.5); expect( tester .widget( find.descendant( - of: find.byKey(const Key('tab-theater')), - matching: find.text('Theater'), + of: find.byKey(const Key('tab-recommend')), + matching: find.text('Recommend'), ), ) .style @@ -85,6 +121,27 @@ void main() { ), findsOneWidget, ); + expect( + find.descendant( + of: find.byKey(const Key('tab-recommend')), + matching: find.byIcon(Icons.smart_display_outlined), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byKey(const Key('tab-follow')), + matching: find.byIcon(Icons.bookmark_border_rounded), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byKey(const Key('tab-mine')), + matching: find.byIcon(Icons.person_outline_rounded), + ), + findsOneWidget, + ); expect( tester .widget( @@ -94,23 +151,26 @@ void main() { ), ) .color, - AppColors.blue, + AppColors.navSelected, ); - await tester.tap(find.byKey(const Key('tab-theater'))); + await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); expect(haptics.selectionClicks, 1); final theaterBar = tester.widget(barFinder); final theaterDecoration = theaterBar.decoration as BoxDecoration; - expect(theaterDecoration.color, AppColors.bar.withValues(alpha: 0.96)); - expect((theaterDecoration.border as Border).top.color, AppColors.border); - await tester.tap(find.byKey(const Key('tab-theater'))); + expect(theaterDecoration.color, AppColors.navBackground); + expect( + (theaterDecoration.border as Border).top.color, + AppColors.navDivider, + ); + await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); expect(haptics.selectionClicks, 1); expect( find.descendant( - of: find.byKey(const Key('tab-theater')), - matching: find.byIcon(Icons.explore_rounded), + of: find.byKey(const Key('tab-recommend')), + matching: find.byIcon(Icons.smart_display_rounded), ), findsOneWidget, ); @@ -118,13 +178,13 @@ void main() { tester .widget( find.descendant( - of: find.byKey(const Key('tab-theater')), - matching: find.text('Theater'), + of: find.byKey(const Key('tab-recommend')), + matching: find.text('Recommend'), ), ) .style ?.color, - AppColors.blue, + AppColors.navSelected, ); expect( tester @@ -147,32 +207,56 @@ void main() { ); }); - testWidgets('should_map_four_tabs_to_home_theater_create_and_me', ( + testWidgets('should_map_four_tabs_to_home_recommend_following_and_me', ( tester, ) async { await _pumpApp(tester); expect(find.byKey(const Key('tab-home')), findsOneWidget); - expect(find.byKey(const Key('tab-theater')), findsOneWidget); - expect(find.byKey(const Key('tab-create')), findsOneWidget); + expect(find.byKey(const Key('tab-recommend')), findsOneWidget); + expect(find.byKey(const Key('tab-follow')), findsOneWidget); expect(find.byKey(const Key('tab-mine')), findsOneWidget); + expect(find.byKey(const Key('tab-create')), findsNothing); expect(find.byKey(const Key('home-feed-title')), findsOneWidget); - await tester.tap(find.byKey(const Key('tab-theater'))); + await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); - await tester.tap(find.byKey(const Key('tab-create'))); + await tester.tap(find.byKey(const Key('tab-follow'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('create-page')), findsOneWidget); + final favorites = find.byType(FavoritesContent); + expect(favorites, findsOneWidget); + expect(find.byKey(const Key('favorites-page')), findsOneWidget); + expect(find.byKey(const Key('favorites-back')), findsNothing); + expect(find.byType(AppBottomNav), findsOneWidget); + expect( + find.descendant(of: favorites, matching: find.byType(Scaffold)), + findsNothing, + ); + expect(Navigator.canPop(tester.element(favorites)), isFalse); + expect( + find.descendant( + of: find.byKey(const Key('tab-follow')), + matching: find.byIcon(Icons.bookmark_rounded), + ), + findsOneWidget, + ); await tester.tap(find.byKey(const Key('tab-mine'))); await tester.pumpAndSettle(); expect(find.text('Viewer'), findsOneWidget); expect(find.byKey(const Key('mine-menu')), findsOneWidget); expect(find.byKey(const Key('mine-watchlist')), findsOneWidget); + expect(find.byKey(const Key('mine-creator-center')), findsOneWidget); expect(find.byKey(const Key('mine-library-tab-0')), findsNothing); + await tester.tap(find.byKey(const Key('tab-follow'))); + await tester.pumpAndSettle(); + expect(favorites, findsOneWidget); + expect(find.byType(AppBottomNav), findsOneWidget); + expect(Navigator.canPop(tester.element(favorites)), isFalse); + await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('home-feed-title')), findsOneWidget); diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index ab70046..4b3c6cf 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -10,6 +10,7 @@ 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/core/widgets/app_bottom_nav.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'; @@ -27,7 +28,7 @@ void main() { InMemorySharedPreferencesAsync.empty(); }); - testWidgets('should_render_four_tabs_and_open_create_placeholder', ( + testWidgets('should_render_four_tabs_and_open_following_page', ( tester, ) async { await _pumpApp(tester); @@ -35,27 +36,14 @@ void main() { for (final key in _tabKeys) { expect(find.byKey(key), findsOneWidget); } + expect(find.byKey(const Key('tab-create')), findsNothing); - await tester.tap(find.byKey(const Key('tab-create'))); + await tester.tap(find.byKey(const Key('tab-follow'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('create-page')), findsOneWidget); - expect(find.text('Create'), findsWidgets); - expect(find.text('Stories become short dramas'), findsOneWidget); - expect(find.byKey(const Key('create-ai')), findsOneWidget); - expect(find.byKey(const Key('create-upload')), findsOneWidget); - expect(tester.getSize(find.byKey(const Key('create-ai'))).height, 196); - expect(find.byKey(const Key('create-motto-primary')), findsOneWidget); - expect(find.byKey(const Key('create-motto-secondary')), findsOneWidget); - expect( - tester.getSize(find.byKey(const Key('create-motto-line'))), - const Size(96, 2), - ); - - await tester.tap(find.byKey(const Key('create-ai'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('create-placeholder-page')), findsOneWidget); - expect(find.text('Coming soon'), findsOneWidget); + expect(find.byKey(const Key('favorites-page')), findsOneWidget); + expect(find.byKey(const Key('favorites-back')), findsNothing); + expect(find.byType(AppBottomNav), findsOneWidget); }); testWidgets('should_start_home_on_immersive_feed', (tester) async { @@ -334,7 +322,7 @@ void main() { await _pumpApp(tester); expect(_hasLightStatusBar(tester), isTrue); - await tester.tap(find.byKey(const Key('tab-theater'))); + await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('home-page-gradient')), findsOneWidget); @@ -395,7 +383,7 @@ void main() { await _pumpApp(tester); expect(find.byKey(const Key('home-feed-title')), findsOneWidget); - await tester.tap(find.byKey(const Key('tab-theater'))); + await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('home-feed-title')), findsNothing); @@ -418,7 +406,7 @@ void main() { testWidgets('should_not_render_theater_language_filters', (tester) async { await _pumpApp(tester); - await tester.tap(find.byKey(const Key('tab-theater'))); + await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('theater-language-chips')), findsNothing); @@ -435,7 +423,7 @@ void main() { tester, ) async { await _pumpApp(tester); - await tester.tap(find.byKey(const Key('tab-theater'))); + await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); final card = find.byKey(const Key('home-drama-card-the-signal-demo')); @@ -464,7 +452,7 @@ void main() { expect(find.byKey(const Key('home-feed-title')), findsOneWidget); expect(tester.takeException(), isNull); - await tester.tap(find.byKey(const Key('tab-theater'))); + await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('theater-category-chips')), findsOneWidget); expect(find.byKey(const Key('theater-language-chips')), findsNothing); @@ -474,8 +462,8 @@ void main() { const _tabKeys = [ Key('tab-home'), - Key('tab-theater'), - Key('tab-create'), + Key('tab-recommend'), + Key('tab-follow'), Key('tab-mine'), ]; diff --git a/test/mine/mine_logic_test.dart b/test/mine/mine_logic_test.dart index 30cb8d5..b6105d8 100644 --- a/test/mine/mine_logic_test.dart +++ b/test/mine/mine_logic_test.dart @@ -91,11 +91,11 @@ void main() { logic.act( pageContext, - MyPageAction.discover, + MyPageAction.recommend, embedded: true, onAction: forwarded.add, ); - expect(forwarded, [MyPageAction.discover]); + expect(forwarded, [MyPageAction.recommend]); logic.act(pageContext, MyPageAction.resume, embedded: false); await tester.pumpAndSettle(); diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index bc1bd8c..53d0b23 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -39,7 +39,7 @@ void main() { tester, ) async { await _pumpApp(tester); - await tester.tap(find.byKey(const Key('tab-theater'))); + await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('home-drama-card-city-lights-demo'))); await tester.pumpAndSettle(); @@ -1331,7 +1331,7 @@ Future _openSeriesPlayer( WidgetTester tester, { Key cardKey = const Key('home-drama-card-city-lights-demo'), }) async { - await tester.tap(find.byKey(const Key('tab-theater'))); + await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); final card = find.byKey(cardKey); await tester.scrollUntilVisible( diff --git a/test/theme/app_dimens_test.dart b/test/theme/app_dimens_test.dart index d48b70d..c7187bc 100644 --- a/test/theme/app_dimens_test.dart +++ b/test/theme/app_dimens_test.dart @@ -18,7 +18,7 @@ void main() { expect(AppDimens.listTitle, 15); expect(AppDimens.sectionTitle, 16); expect(AppDimens.pageTitle, 20); - expect(AppDimens.navLabel, 12); + expect(AppDimens.navLabel, 11); expect(AppDimens.navIcon, 24); expect(AppDimens.buttonHeight, 48); expect(AppDimens.primaryButtonHeight, 52); diff --git a/test/theme/app_theme_test.dart b/test/theme/app_theme_test.dart index 1cd52c2..2f94760 100644 --- a/test/theme/app_theme_test.dart +++ b/test/theme/app_theme_test.dart @@ -63,7 +63,10 @@ void main() { expect(theme.appBarTheme.systemOverlayStyle, SystemUiOverlayStyle.light); expect(theme.cardTheme.elevation, 0); expect(theme.dialogTheme.backgroundColor, AppColors.raised); - expect(theme.bottomNavigationBarTheme.backgroundColor, AppColors.bar); + expect( + theme.bottomNavigationBarTheme.backgroundColor, + AppColors.navBackground, + ); expect(theme.inputDecorationTheme.fillColor, AppColors.input); }); diff --git a/test/widget_test.dart b/test/widget_test.dart index 26fb796..16f61ed 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -120,9 +120,9 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('home-feed-title')), findsOneWidget); - await tester.tap(find.byKey(const Key('tab-create'))); + await tester.tap(find.byKey(const Key('tab-follow'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('create-page')), findsOneWidget); + expect(find.byKey(const Key('favorites-page')), findsOneWidget); await tester.tap(find.byKey(const Key('tab-mine'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('mine-menu')), findsOneWidget); @@ -144,9 +144,9 @@ void main() { expect(find.byKey(const Key('mine-language')), findsOneWidget); expect(find.byKey(const Key('mine-wallet')), findsOneWidget); - await tester.tap(find.byKey(const Key('tab-theater'))); + await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); - expect(find.text('Theater'), findsWidgets); + expect(find.text('Recommend'), findsWidgets); }); testWidgets('should_switch_app_language_from_mine_menu', (tester) async { @@ -168,9 +168,9 @@ void main() { expect(find.text('我的'), findsWidgets); expect(find.text('语言'), findsOneWidget); - await tester.tap(find.byKey(const Key('tab-theater'))); + await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); - expect(find.text('剧场'), findsWidgets); + expect(find.text('推荐'), findsWidgets); }); testWidgets('should_fit_compact_viewport_when_player_is_opened', ( @@ -207,7 +207,7 @@ void main() { await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-theater'))); + await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('discover-search')), findsOneWidget); @@ -316,7 +316,7 @@ void main() { } Future _openTheaterPlayer(WidgetTester tester) async { - await tester.tap(find.byKey(const Key('tab-theater'))); + await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); final card = find.byKey(const Key('home-drama-card-city-lights-demo')); await tester.scrollUntilVisible( From 729c226c95966cea5229dbc1c22cf5dd4078cbd6 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 16:21:30 +0800 Subject: [PATCH 018/129] =?UTF-8?q?docs(nav):=20=E5=9B=9E=E5=A1=AB?= =?UTF-8?q?=E5=9B=9B=20Tab=20=E5=BA=95=E9=83=A8=E5=AF=BC=E8=88=AA=E8=AE=B0?= =?UTF-8?q?=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 46 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 0213a52..627a97a 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -785,3 +785,49 @@ Running Gradle task 'assembleDebug'... 解读:静态分析零告警,644 项测试全部通过,debug APK 构建成功;测试与构建后再次确认 `pubspec.lock` 无改动,未启停服务、未操作真机。 + +## 2026-10-22 客户端底部导航改为首页/推荐/追剧/我的 + +- 分支:`feature/client-bottom-nav-4tabs` +- 基线:`develop` / `origin/develop` = `ec3cb8f` +- 计划:`docs/plans/2026-10-22-client-bottom-nav-4tabs.md` +- 实现提交:`91e869d feat(nav): 底部导航改为首页推荐追剧我的` + +### 问题 + +根导航仍为首页/剧场/创作/我的,底栏选中态使用品牌橙。追剧若通过新路由打开, +会叠加第二层底栏并产生返回栈,不符合参考图和单根导航交互。 + +### 改动 + +- 新增 `AppRootTab` 统一四个 tab 的顺序、key、图标和 l10n 标签。 +- `AppBottomNav` 改为纯黑底、白色选中、`#8A8A8E` 未选中,图标 24、文字 11, + 保留 SafeArea 与轻震动。 +- 首页 `IndexedStack` 第三槽直接使用 `FavoritesContent`,不再 push 收藏路由。 + 追剧保留顶部标题区、隐藏返回按钮,只有一个根底栏,不创建嵌套 Scaffold。 +- `FavoritesPage` 保留独立 Scaffold 和返回行为,继续供“我的”入口使用。 +- 推荐、追剧、我的三个根页面统一调用 `HomeLogic.select` 切换,不进入返回栈。 +- 创作页保留,`mine-creator-center` 入口保留;创作不再出现在根底栏。 +- 中文使用“追剧”,英文使用 `Home / Recommend / Following / My`, + 印尼语使用 `Beranda / Rekomendasi / Daftar Tonton / Saya`。 +- `FavoritesContent` 内置顶部 SafeArea,避免嵌入首页后被状态栏遮挡。 + +### 门禁命令与原始输出 + +```text +$ dart analyze lib test +Analyzing lib, test... +No issues found! +``` + +```text +$ flutter test --no-pub +04:38 +644: All tests passed! +``` + +导航测试覆盖:四个 tab 数量与顺序、纯白/灰色状态、点击追剧进入 +`FavoritesContent`、仅一个 `AppBottomNav`、追剧内部无嵌套 Scaffold、 +`Navigator.canPop == false`、创作不再出现在底栏且我的入口保留。 + +解读:静态分析零告警,644 项测试全部通过;追剧与首页/推荐/我的均在同一根页面 +内切换,无叠加导航或返回栈。`pubspec.lock` 未提交,未启动或停止服务,未操作真机。 From 81787dc3aa149c083d305c37c71c59930d3dcedf Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 16:38:14 +0800 Subject: [PATCH 019/129] =?UTF-8?q?style(nav):=20=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E5=9B=9B=20Tab=20=E5=BA=95=E6=A0=8F=E5=B8=83=E5=B1=80=E8=A7=84?= =?UTF-8?q?=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/theme/app_colors.dart | 5 +-- lib/core/theme/app_dimens.dart | 5 ++- lib/core/widgets/app_bottom_nav.dart | 19 ++-------- lib/core/widgets/app_root_tab.dart | 34 +++++++++++++++++ lib/pages/discover/discover_view.dart | 20 ++-------- lib/pages/home/home_view.dart | 32 +++------------- lib/pages/mine/mine_view.dart | 20 +++------- test/home/app_bottom_nav_test.dart | 53 ++++++++++++++++++++------- test/home/home_nav_ui_test.dart | 16 ++------ test/theme/app_dimens_test.dart | 5 ++- 10 files changed, 103 insertions(+), 106 deletions(-) diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index c9b764e..c8dcab2 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -69,10 +69,9 @@ abstract final class AppColors { /// 底部导航底色。 static const bar = ink; - /// 底部导航专用:纯黑背景、选中白、未选中灰与轻分割线。 + /// 底部导航专用:纯黑背景、选中白与未选中灰。 static const navBackground = Color(0xFF000000); static const navSelected = Color(0xFFFFFFFF); - static const navDivider = Color(0xFF1C1C1E); /// 卡片阴影(深色几乎不用阴影)。 static const cardShadow = Color(0x00000000); @@ -126,7 +125,7 @@ abstract final class AppColors { static const badgeBeige = raised; /// 导航未选中。 - static const navUnselected = Color(0xFF8A8A8E); + static const navUnselected = Color(0xFF8E8E93); /// 播放器次要文字。 static const playerMuted = Color(0xA3FFFFFF); diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index fba2ed4..299cb6f 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -37,6 +37,7 @@ abstract final class AppDimens { static const double previewSectionTitle = 14; static const double chipFont = 10; static const double navLabel = 11; + static const double navLabelGap = 4; static const double buttonHeight = 48; static const double primaryButtonHeight = 52; @@ -47,8 +48,8 @@ abstract final class AppDimens { static const double tapTarget = 44; static const double contentHeaderHeight = 46; - static const double navHeight = 68; - static const double navIcon = 24; + static const double navHeight = 56; + static const double navIcon = 26; static const double progressHeight = 3; static const double progressRadius = 2; static const double progressLineHeight = 3; diff --git a/lib/core/widgets/app_bottom_nav.dart b/lib/core/widgets/app_bottom_nav.dart index 6578ecc..a4163df 100644 --- a/lib/core/widgets/app_bottom_nav.dart +++ b/lib/core/widgets/app_bottom_nav.dart @@ -26,30 +26,17 @@ class AppBottomNavItem { /// 四槽底部导航:纯黑背景,白色选中态,灰色未选中态。 class AppBottomNav extends StatelessWidget { - const AppBottomNav({ - super.key, - required this.items, - this.backgroundColor = AppColors.navBackground, - this.barKey, - this.dark = false, - }); + const AppBottomNav({super.key, required this.items, this.barKey}); final List items; - final Color backgroundColor; final Key? barKey; - final bool dark; @override Widget build(BuildContext context) { final bar = Container( key: barKey, height: AppDimens.navHeight, - decoration: BoxDecoration( - color: dark ? AppColors.navBackground : backgroundColor, - border: const Border( - top: BorderSide(color: AppColors.navDivider, width: 0.5), - ), - ), + color: AppColors.navBackground, child: Row( children: [ for (final item in items) @@ -99,7 +86,7 @@ class _NavDestination extends StatelessWidget { : unselectedColor, ), ), - const SizedBox(height: 3), + const SizedBox(height: AppDimens.navLabelGap), Text( item.label, maxLines: 1, diff --git a/lib/core/widgets/app_root_tab.dart b/lib/core/widgets/app_root_tab.dart index 2a49059..798b2f7 100644 --- a/lib/core/widgets/app_root_tab.dart +++ b/lib/core/widgets/app_root_tab.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../l10n/app_localizations.dart'; +import 'app_bottom_nav.dart'; /// 客户端根导航的固定顺序。 enum AppRootTab { home, recommend, following, mine } @@ -36,3 +37,36 @@ extension AppRootTabUi on AppRootTab { AppRootTab.mine => l10n.myTab, }; } + +/// 根导航唯一入口,确保四个主页面使用完全一致的底栏配置。 +class AppRootBottomNav extends StatelessWidget { + const AppRootBottomNav({ + super.key, + required this.selected, + required this.onSelected, + this.barKey, + }); + + final AppRootTab selected; + final ValueChanged onSelected; + final Key? barKey; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return AppBottomNav( + barKey: barKey, + items: [ + for (final tab in AppRootTab.values) + AppBottomNavItem( + tabKey: tab.tabKey, + icon: tab.icon, + selectedIcon: tab.selectedIcon, + label: tab.label(l10n), + selected: tab == selected, + onTap: () => onSelected(tab), + ), + ], + ); + } +} diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index 8ec05bd..f82d713 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -17,7 +17,6 @@ import '../../core/formatting/error_text.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; -import '../../core/widgets/app_bottom_nav.dart'; import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/app_root_tab.dart'; import '../../core/widgets/catalog_debug_info.dart'; @@ -160,7 +159,7 @@ class _DiscoverPageState extends State { loading: logic.searchLoading, ), Expanded(child: _feed(l10n, categories)), - if (!embedded) _bottomNav(l10n), + if (!embedded) _bottomNav(), ], ), ), @@ -612,20 +611,9 @@ class _DiscoverPageState extends State { unawaited(playDramaFromCatalog(context, drama)); } - Widget _bottomNav(AppLocalizations l10n) => AppBottomNav( - items: [ - for (final tab in AppRootTab.values) - AppBottomNavItem( - tabKey: tab.tabKey, - icon: tab.icon, - selectedIcon: tab.selectedIcon, - label: tab.label(l10n), - selected: tab == AppRootTab.recommend, - onTap: tab == AppRootTab.recommend - ? () {} - : () => _act(_actionOf(tab)), - ), - ], + Widget _bottomNav() => AppRootBottomNav( + selected: AppRootTab.recommend, + onSelected: (tab) => _act(_actionOf(tab)), ); DiscoverPageAction _actionOf(AppRootTab tab) => switch (tab) { diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 61b52ec..ec3eebe 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -16,7 +16,6 @@ import '../../core/data/services/category_controller.dart'; import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; -import '../../core/widgets/app_bottom_nav.dart'; import '../../core/widgets/app_root_tab.dart'; import '../../core/widgets/catalog_debug_info.dart'; import '../../core/widgets/catalog_endpoint_editor.dart'; @@ -262,7 +261,6 @@ class _HomeTabsState extends State<_HomeTabs> { } Widget _scaffold(BuildContext context) { - final l10n = AppLocalizations.of(context)!; final feed = logic.tab == 0; final content = PopScope( canPop: false, @@ -275,7 +273,7 @@ class _HomeTabsState extends State<_HomeTabs> { backgroundColor: AppColors.transparent, resizeToAvoidBottomInset: false, body: _body(context), - bottomNavigationBar: logic.videoImmersive ? null : _nav(context, l10n), + bottomNavigationBar: logic.videoImmersive ? null : _nav(context), ), ); return AnnotatedRegion( @@ -451,31 +449,11 @@ class _HomeTabsState extends State<_HomeTabs> { ); } - Widget _nav(BuildContext context, AppLocalizations l10n) { - return AppBottomNav( + Widget _nav(BuildContext context) { + return AppRootBottomNav( barKey: const Key('home-tabs-bar'), - dark: logic.tab == 0, - items: [ - _navItem(context, l10n, AppRootTab.home), - _navItem(context, l10n, AppRootTab.recommend), - _navItem(context, l10n, AppRootTab.following), - _navItem(context, l10n, AppRootTab.mine), - ], - ); - } - - AppBottomNavItem _navItem( - BuildContext context, - AppLocalizations l10n, - AppRootTab tab, - ) { - return AppBottomNavItem( - tabKey: tab.tabKey, - icon: tab.icon, - selectedIcon: tab.selectedIcon, - label: tab.label(l10n), - selected: logic.tab == tab.index, - onTap: () => _selectTab(context, tab.index), + selected: AppRootTab.values[logic.tab], + onSelected: (tab) => _selectTab(context, tab.index), ); } diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index bba996f..7c83f61 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -12,7 +12,6 @@ import '../../core/data/services/wallet_controller.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; -import '../../core/widgets/app_bottom_nav.dart'; import '../../core/widgets/app_root_tab.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/plain_page_scaffold.dart'; @@ -87,7 +86,7 @@ class _MyPageState extends State { children: [ _topBar(l10n), Expanded(child: _body(context, l10n)), - if (!embedded) _bottomNav(l10n), + if (!embedded) _bottomNav(), ], ), ); @@ -238,19 +237,10 @@ class _MyPageState extends State { _openProtected(RechargePage(onPurchased: () => unawaited(wallet.load()))); } - Widget _bottomNav(AppLocalizations l10n) { - return AppBottomNav( - items: [ - for (final tab in AppRootTab.values) - AppBottomNavItem( - tabKey: tab.tabKey, - icon: tab.icon, - selectedIcon: tab.selectedIcon, - label: tab.label(l10n), - selected: tab == AppRootTab.mine, - onTap: tab == AppRootTab.mine ? () {} : () => _act(_actionOf(tab)), - ), - ], + Widget _bottomNav() { + return AppRootBottomNav( + selected: AppRootTab.mine, + onSelected: (tab) => _act(_actionOf(tab)), ); } diff --git a/test/home/app_bottom_nav_test.dart b/test/home/app_bottom_nav_test.dart index ecf53c2..0b04e00 100644 --- a/test/home/app_bottom_nav_test.dart +++ b/test/home/app_bottom_nav_test.dart @@ -7,14 +7,22 @@ import 'package:fruitshorts/core/theme/app_dimens.dart'; import 'package:fruitshorts/core/widgets/app_bottom_nav.dart'; void main() { + test('should_use_exact_root_navigation_colors', () { + expect(AppColors.navBackground, const Color(0xFF000000)); + expect(AppColors.navSelected, const Color(0xFFFFFFFF)); + expect(AppColors.navUnselected, const Color(0xFF8E8E93)); + }); + testWidgets('should_render_four_slots_with_white_selected_state', ( tester, ) async { var theaterTaps = 0; + const barKey = Key('nav-bar'); await tester.pumpWidget( MaterialApp( home: Scaffold( bottomNavigationBar: AppBottomNav( + barKey: barKey, items: [ AppBottomNavItem( tabKey: const Key('tab-home'), @@ -60,6 +68,8 @@ void main() { final selectedLabel = tester.widget(find.text('Home')); expect(selectedLabel.style?.color, AppColors.navSelected); expect(selectedLabel.style?.fontWeight, FontWeight.w600); + expect(selectedLabel.style?.fontSize, AppDimens.navLabel); + expect(selectedLabel.style?.letterSpacing, 0); final unselectedLabel = tester.widget(find.text('Recommend')); expect(unselectedLabel.style?.color, AppColors.navUnselected); expect(unselectedLabel.style?.fontWeight, FontWeight.w400); @@ -70,20 +80,35 @@ void main() { Key('tab-mine'), ].map((key) => tester.getCenter(find.byKey(key)).dx).toList(); expect(centers, orderedEquals(centers.toList()..sort())); + final widths = [ + Key('tab-home'), + Key('tab-recommend'), + Key('tab-follow'), + Key('tab-mine'), + ].map((key) => tester.getSize(find.byKey(key)).width).toList(); + expect(widths.toSet(), hasLength(1)); expect( tester.getSize(find.byType(AppBottomNav)).height, AppDimens.navHeight, ); + final bar = tester.widget(find.byKey(barKey)); + expect(bar.color, AppColors.navBackground); + expect(bar.decoration, isNull); + final selectedIcon = tester.widget( + find.descendant( + of: find.byKey(const Key('tab-home')), + matching: find.byIcon(Icons.movie_rounded), + ), + ); + expect(selectedIcon.color, AppColors.navSelected); expect( - tester - .widget( - find.descendant( - of: find.byKey(const Key('tab-home')), - matching: find.byIcon(Icons.movie_rounded), - ), - ) - .color, - AppColors.navSelected, + tester.getSize(find.byIcon(Icons.movie_rounded)), + const Size(26, 26), + ); + expect( + tester.getTopLeft(find.text('Home')).dy - + tester.getBottomLeft(find.byIcon(Icons.movie_rounded)).dy, + AppDimens.navLabelGap, ); await tester.tap(find.byKey(const Key('tab-recommend'))); expect(theaterTaps, 1); @@ -99,7 +124,6 @@ void main() { data: const MediaQueryData(padding: EdgeInsets.only(bottom: 34)), child: Scaffold( bottomNavigationBar: AppBottomNav( - dark: true, barKey: barKey, items: [ AppBottomNavItem( @@ -144,12 +168,15 @@ void main() { ); expect(tester.getSize(find.byKey(barKey)).height, AppDimens.navHeight); - final coloredBox = tester.widget( + expect( find.descendant( of: find.byType(AppBottomNav), - matching: find.byType(ColoredBox), + matching: find.byWidgetPredicate( + (widget) => + widget is ColoredBox && widget.color == AppColors.navBackground, + ), ), + findsWidgets, ); - expect(coloredBox.color, AppColors.navBackground); }); } diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart index 1e080fe..0e044f0 100644 --- a/test/home/home_nav_ui_test.dart +++ b/test/home/home_nav_ui_test.dart @@ -85,12 +85,8 @@ void main() { final barFinder = find.byKey(const Key('home-tabs-bar')); final bar = tester.widget(barFinder); - final decoration = bar.decoration as BoxDecoration; - final border = decoration.border as Border; - // 所有页面统一深色导航。 - expect(decoration.color, AppColors.navBackground); - expect(border.top.color, AppColors.navDivider); - expect(border.top.width, 0.5); + expect(bar.color, AppColors.navBackground); + expect(bar.decoration, isNull); expect( tester .widget( @@ -158,12 +154,8 @@ void main() { await tester.pumpAndSettle(); expect(haptics.selectionClicks, 1); final theaterBar = tester.widget(barFinder); - final theaterDecoration = theaterBar.decoration as BoxDecoration; - expect(theaterDecoration.color, AppColors.navBackground); - expect( - (theaterDecoration.border as Border).top.color, - AppColors.navDivider, - ); + expect(theaterBar.color, AppColors.navBackground); + expect(theaterBar.decoration, isNull); await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); expect(haptics.selectionClicks, 1); diff --git a/test/theme/app_dimens_test.dart b/test/theme/app_dimens_test.dart index c7187bc..8a8cbfb 100644 --- a/test/theme/app_dimens_test.dart +++ b/test/theme/app_dimens_test.dart @@ -19,11 +19,12 @@ void main() { expect(AppDimens.sectionTitle, 16); expect(AppDimens.pageTitle, 20); expect(AppDimens.navLabel, 11); - expect(AppDimens.navIcon, 24); + expect(AppDimens.navLabelGap, 4); + expect(AppDimens.navIcon, 26); expect(AppDimens.buttonHeight, 48); expect(AppDimens.primaryButtonHeight, 52); expect(AppDimens.tapTarget, 44); - expect(AppDimens.navHeight, 68); + expect(AppDimens.navHeight, 56); expect(AppDimens.progressHeight, 3); }); } From 5362f977449561432088085c916bf3f80062171c Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 16:38:25 +0800 Subject: [PATCH 020/129] =?UTF-8?q?docs(nav):=20=E5=9B=9E=E5=A1=AB?= =?UTF-8?q?=E5=BA=95=E6=A0=8F=E5=B8=83=E5=B1=80=E8=A7=84=E6=A0=BC=E8=AE=B0?= =?UTF-8?q?=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 627a97a..4c7d5a7 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -831,3 +831,35 @@ $ flutter test --no-pub 解读:静态分析零告警,644 项测试全部通过;追剧与首页/推荐/我的均在同一根页面 内切换,无叠加导航或返回栈。`pubspec.lock` 未提交,未启动或停止服务,未操作真机。 + +## 2026-10-22 根导航布局规格对齐 + +- 分支:`feature/client-bottom-nav-layout` +- 基线:`develop` / `origin/develop` = `667fdc5` +- 实现提交:`81787dc style(nav): 对齐四 Tab 底栏布局规格` + +### 改动 + +- 新增 `AppRootBottomNav` 作为首页、推荐、追剧、我的唯一根导航入口, + 三个 Shell 页面不再各自拼装 item。 +- 四列使用 `Expanded` 等分,图标在上、文字在下,间距 4。 +- 内容高度 56,另由 `SafeArea` 增加底部安全区;图标 26、文字 11、 + `letterSpacing = 0`,选中 `w600`、未选中 `w400`。 +- 背景纯黑,取消顶部边框;选中 `#FFFFFF`,未选中 `#8E8E93`。 +- 首页、推荐、书签、人像继续使用各自的选中实心/未选中描边图标。 + +### 门禁命令与原始输出 + +```text +$ flutter analyze +Analyzing reelshort-bottom-nav... +No issues found! (ran in 5.2s) +``` + +```text +$ flutter test +04:43 +645: All tests passed! +``` + +解读:四等分、56 内容高度、安全区、26 图标、4 间距、精确色值和无分割线 +均有测试覆盖;645 项测试全部通过。`pubspec.lock` 未提交,未操作真机。 From dd96792e3308a028dbb657b27f025c759bf135d3 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 17:03:45 +0800 Subject: [PATCH 021/129] =?UTF-8?q?feat(home):=20=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E4=B8=89=E5=88=97=E6=8E=A2=E7=B4=A2=E7=BD=91?= =?UTF-8?q?=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/data/models/drama.dart | 12 + lib/core/theme/app_colors.dart | 19 +- lib/core/theme/app_dimens.dart | 14 + lib/l10n/app_en.arb | 19 +- lib/l10n/app_id.arb | 19 +- lib/l10n/app_localizations.dart | 102 +++++ lib/l10n/app_localizations_en.dart | 51 +++ lib/l10n/app_localizations_id.dart | 51 +++ lib/l10n/app_localizations_zh.dart | 51 +++ lib/l10n/app_zh.arb | 19 +- lib/pages/home/home_view.dart | 103 ++--- .../widgets/home_drama_classification.dart | 49 +++ lib/pages/home/widgets/home_failure_view.dart | 36 ++ lib/pages/home/widgets/home_feed_card.dart | 225 +++++++++++ lib/pages/home/widgets/home_feed_grid.dart | 294 ++++++++++++++ lib/pages/home/widgets/home_grid_header.dart | 365 ++++++++++++++++++ test/discover/filter_page_test.dart | 2 +- test/discover/home_all_dramas_grid_test.dart | 4 +- test/discover/theater_pages_test.dart | 2 +- test/home/catalog_diagnostics_test.dart | 2 +- test/home/home_card_style_test.dart | 190 ++++++--- test/home/home_grid_layout_test.dart | 252 ++++++++++++ test/home/home_nav_ui_test.dart | 6 +- test/home/home_preview_layout_test.dart | 67 +++- test/home/home_server_setting_test.dart | 2 +- test/home/home_startup_splash_test.dart | 2 +- test/home/k28_a_navigation_test.dart | 122 ++---- test/player/player_fullscreen_test.dart | 24 +- test/widget_test.dart | 64 ++- 29 files changed, 1912 insertions(+), 256 deletions(-) create mode 100644 lib/pages/home/widgets/home_drama_classification.dart create mode 100644 lib/pages/home/widgets/home_failure_view.dart create mode 100644 lib/pages/home/widgets/home_feed_card.dart create mode 100644 lib/pages/home/widgets/home_feed_grid.dart create mode 100644 lib/pages/home/widgets/home_grid_header.dart create mode 100644 test/home/home_grid_layout_test.dart diff --git a/lib/core/data/models/drama.dart b/lib/core/data/models/drama.dart index 8b2f1b5..4f889ae 100644 --- a/lib/core/data/models/drama.dart +++ b/lib/core/data/models/drama.dart @@ -86,6 +86,8 @@ class Drama { this.favoriteCount, this.commentCount, this.language = '', + this.dramaType = '', + this.createdAt, this.detailLoaded = false, }); @@ -109,6 +111,12 @@ class Drama { /// 卡片语种(如 zh-CN / id),用于剧场语言筛选。 final String language; + /// 内容类型:ANIME 漫剧,LIVE 真人剧。 + final String dramaType; + + /// 上架时间;卡片接口未直接下发时可由 Snowflake id 估算。 + final DateTime? createdAt; + /// 详情接口是否已经取过完整剧集列表。 /// 列表卡片只带一集可播预览,需要完整剧集时再进详情。 final bool detailLoaded; @@ -137,6 +145,8 @@ class Drama { favoriteCount: _optionalInt(json['favoriteCount']), commentCount: _optionalInt(json['commentCount']), language: json['language'] as String? ?? '', + dramaType: json['dramaType'] as String? ?? '', + createdAt: DateTime.tryParse(json['createdAt']?.toString() ?? ''), ); } @@ -157,6 +167,8 @@ class Drama { favoriteCount: _optionalInt(json['favoriteCount']), commentCount: _optionalInt(json['commentCount']), language: json['language'] as String? ?? '', + dramaType: json['dramaType'] as String? ?? '', + createdAt: DateTime.tryParse(json['createdAt']?.toString() ?? ''), detailLoaded: true, episodes: (json['episodes'] as List? ?? const []) .whereType>() diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index c8dcab2..83b62b3 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -60,6 +60,18 @@ abstract final class AppColors { /// 页面背景。 static const ink = Color(0xFF0B1020); + /// 首页/追剧/我的统一的纯黑内容背景。 + static const appBackground = Color(0xFF000000); + + /// 中性深灰卡片与控件表面。 + static const contentCard = Color(0xFF171717); + static const contentSurface = Color(0xFF1C1C1E); + static const contentChip = Color(0xFF2C2C2E); + static const contentBorder = Color(0x1AFFFFFF); + + /// 次级文字与列表 chevron。 + static const textSecondary = Color(0xFF8E8E93); + /// 原生开屏与冷启动过渡底色。 static const splashInk = ink; @@ -125,7 +137,12 @@ abstract final class AppColors { static const badgeBeige = raised; /// 导航未选中。 - static const navUnselected = Color(0xFF8E8E93); + static const navUnselected = textSecondary; + + /// 首页内容角标。 + static const badgeHot = Color(0xFFE5484D); + static const badgeNew = Color(0xFF3B82F6); + static const badgeAnime = Color(0xFF8B5CF6); /// 播放器次要文字。 static const playerMuted = Color(0xA3FFFFFF); diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 299cb6f..f783c56 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -73,4 +73,18 @@ abstract final class AppDimens { static const double cardTitle = 15; static const double cardShadowBlur = 0; static const double cardShadowY = 0; + + /// 首页探索网格。 + static const double homeGridGap = 8; + static const double homeCardRadius = 8; + static const double homeCardPadding = 8; + static const double homeCardTitle = 13; + static const double homeCardMeta = 10; + static const double homeCardBadge = 9; + static const double homeTagFont = 10; + static const double homeCardBodyExtent = 84; + static const double homeSectionFont = 14; + static const double homeSectionIndicator = 2; + static const double homeIconSlot = 44; + static const double homeHeaderIcon = 22; } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 20336f1..b4899d1 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -697,5 +697,22 @@ "danmakuSettings": "Danmaku settings", "offlineCache": "Offline cache", "screenCast": "Cast", - "sleepTimer": "Sleep timer" + "sleepTimer": "Sleep timer", + "homeSearchHint": "Search dramas, actors, genres...", + "homeSectionFeatured": "Featured", + "homeSectionNew": "New", + "homeSectionCategories": "Categories", + "homeSectionRanking": "Charts", + "homeSectionCostume": "Costume", + "homeSectionCommunity": "Community", + "homeTagHot": "Hot", + "homeTagUplifting": "Thrilling", + "homeTagSweet": "Sweet", + "homeTagFaceSlap": "Revenge", + "homeTagAngst": "Angst", + "homeBadgeHot": "Hot", + "homeBadgeNew": "New", + "homeBadgeAnime": "Anime", + "homeGridFilterEmpty": "No dramas match these filters", + "homeGridClearFilter": "Clear filters" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 684a989..38bc11f 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -654,5 +654,22 @@ "danmakuSettings": "Pengaturan komentar layar", "offlineCache": "Cache offline", "screenCast": "Cast", - "sleepTimer": "Timer tidur" + "sleepTimer": "Timer tidur", + "homeSearchHint": "Cari drama, aktor, genre...", + "homeSectionFeatured": "Unggulan", + "homeSectionNew": "Baru", + "homeSectionCategories": "Kategori", + "homeSectionRanking": "Peringkat", + "homeSectionCostume": "Kostum", + "homeSectionCommunity": "Komunitas", + "homeTagHot": "Populer", + "homeTagUplifting": "Membara", + "homeTagSweet": "Manis", + "homeTagFaceSlap": "Balas Dendam", + "homeTagAngst": "Cinta Penuh Luka", + "homeBadgeHot": "Populer", + "homeBadgeNew": "Baru", + "homeBadgeAnime": "Anime", + "homeGridFilterEmpty": "Tidak ada drama yang cocok dengan filter", + "homeGridClearFilter": "Hapus filter" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 5ce83e2..776d1c0 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2997,6 +2997,108 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Sleep timer'** String get sleepTimer; + + /// No description provided for @homeSearchHint. + /// + /// In en, this message translates to: + /// **'Search dramas, actors, genres...'** + String get homeSearchHint; + + /// No description provided for @homeSectionFeatured. + /// + /// In en, this message translates to: + /// **'Featured'** + String get homeSectionFeatured; + + /// No description provided for @homeSectionNew. + /// + /// In en, this message translates to: + /// **'New'** + String get homeSectionNew; + + /// No description provided for @homeSectionCategories. + /// + /// In en, this message translates to: + /// **'Categories'** + String get homeSectionCategories; + + /// No description provided for @homeSectionRanking. + /// + /// In en, this message translates to: + /// **'Charts'** + String get homeSectionRanking; + + /// No description provided for @homeSectionCostume. + /// + /// In en, this message translates to: + /// **'Costume'** + String get homeSectionCostume; + + /// No description provided for @homeSectionCommunity. + /// + /// In en, this message translates to: + /// **'Community'** + String get homeSectionCommunity; + + /// No description provided for @homeTagHot. + /// + /// In en, this message translates to: + /// **'Hot'** + String get homeTagHot; + + /// No description provided for @homeTagUplifting. + /// + /// In en, this message translates to: + /// **'Thrilling'** + String get homeTagUplifting; + + /// No description provided for @homeTagSweet. + /// + /// In en, this message translates to: + /// **'Sweet'** + String get homeTagSweet; + + /// No description provided for @homeTagFaceSlap. + /// + /// In en, this message translates to: + /// **'Revenge'** + String get homeTagFaceSlap; + + /// No description provided for @homeTagAngst. + /// + /// In en, this message translates to: + /// **'Angst'** + String get homeTagAngst; + + /// No description provided for @homeBadgeHot. + /// + /// In en, this message translates to: + /// **'Hot'** + String get homeBadgeHot; + + /// No description provided for @homeBadgeNew. + /// + /// In en, this message translates to: + /// **'New'** + String get homeBadgeNew; + + /// No description provided for @homeBadgeAnime. + /// + /// In en, this message translates to: + /// **'Anime'** + String get homeBadgeAnime; + + /// No description provided for @homeGridFilterEmpty. + /// + /// In en, this message translates to: + /// **'No dramas match these filters'** + String get homeGridFilterEmpty; + + /// No description provided for @homeGridClearFilter. + /// + /// In en, this message translates to: + /// **'Clear filters'** + String get homeGridClearFilter; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 36952cf..800a55f 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1565,4 +1565,55 @@ class AppLocalizationsEn extends AppLocalizations { @override String get sleepTimer => 'Sleep timer'; + + @override + String get homeSearchHint => 'Search dramas, actors, genres...'; + + @override + String get homeSectionFeatured => 'Featured'; + + @override + String get homeSectionNew => 'New'; + + @override + String get homeSectionCategories => 'Categories'; + + @override + String get homeSectionRanking => 'Charts'; + + @override + String get homeSectionCostume => 'Costume'; + + @override + String get homeSectionCommunity => 'Community'; + + @override + String get homeTagHot => 'Hot'; + + @override + String get homeTagUplifting => 'Thrilling'; + + @override + String get homeTagSweet => 'Sweet'; + + @override + String get homeTagFaceSlap => 'Revenge'; + + @override + String get homeTagAngst => 'Angst'; + + @override + String get homeBadgeHot => 'Hot'; + + @override + String get homeBadgeNew => 'New'; + + @override + String get homeBadgeAnime => 'Anime'; + + @override + String get homeGridFilterEmpty => 'No dramas match these filters'; + + @override + String get homeGridClearFilter => 'Clear filters'; } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index a540d17..22e2caa 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1565,4 +1565,55 @@ class AppLocalizationsId extends AppLocalizations { @override String get sleepTimer => 'Timer tidur'; + + @override + String get homeSearchHint => 'Cari drama, aktor, genre...'; + + @override + String get homeSectionFeatured => 'Unggulan'; + + @override + String get homeSectionNew => 'Baru'; + + @override + String get homeSectionCategories => 'Kategori'; + + @override + String get homeSectionRanking => 'Peringkat'; + + @override + String get homeSectionCostume => 'Kostum'; + + @override + String get homeSectionCommunity => 'Komunitas'; + + @override + String get homeTagHot => 'Populer'; + + @override + String get homeTagUplifting => 'Membara'; + + @override + String get homeTagSweet => 'Manis'; + + @override + String get homeTagFaceSlap => 'Balas Dendam'; + + @override + String get homeTagAngst => 'Cinta Penuh Luka'; + + @override + String get homeBadgeHot => 'Populer'; + + @override + String get homeBadgeNew => 'Baru'; + + @override + String get homeBadgeAnime => 'Anime'; + + @override + String get homeGridFilterEmpty => 'Tidak ada drama yang cocok dengan filter'; + + @override + String get homeGridClearFilter => 'Hapus filter'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index d57351f..299c1ff 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1541,4 +1541,55 @@ class AppLocalizationsZh extends AppLocalizations { @override String get sleepTimer => '定时关闭'; + + @override + String get homeSearchHint => '搜索短剧、演员、类型…'; + + @override + String get homeSectionFeatured => '精选'; + + @override + String get homeSectionNew => '新剧'; + + @override + String get homeSectionCategories => '分类'; + + @override + String get homeSectionRanking => '榜单'; + + @override + String get homeSectionCostume => '古装'; + + @override + String get homeSectionCommunity => '社群'; + + @override + String get homeTagHot => '热门'; + + @override + String get homeTagUplifting => '爽剧'; + + @override + String get homeTagSweet => '甜宠'; + + @override + String get homeTagFaceSlap => '打脸虐渣'; + + @override + String get homeTagAngst => '虐恋'; + + @override + String get homeBadgeHot => '热剧'; + + @override + String get homeBadgeNew => '新剧'; + + @override + String get homeBadgeAnime => '漫剧'; + + @override + String get homeGridFilterEmpty => '没有符合筛选条件的短剧'; + + @override + String get homeGridClearFilter => '清除筛选'; } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 25647d4..e8c2a0f 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -616,5 +616,22 @@ "danmakuSettings": "弹幕设置", "offlineCache": "离线缓存", "screenCast": "投屏", - "sleepTimer": "定时关闭" + "sleepTimer": "定时关闭", + "homeSearchHint": "搜索短剧、演员、类型…", + "homeSectionFeatured": "精选", + "homeSectionNew": "新剧", + "homeSectionCategories": "分类", + "homeSectionRanking": "榜单", + "homeSectionCostume": "古装", + "homeSectionCommunity": "社群", + "homeTagHot": "热门", + "homeTagUplifting": "爽剧", + "homeTagSweet": "甜宠", + "homeTagFaceSlap": "打脸虐渣", + "homeTagAngst": "虐恋", + "homeBadgeHot": "热剧", + "homeBadgeNew": "新剧", + "homeBadgeAnime": "漫剧", + "homeGridFilterEmpty": "没有符合筛选条件的短剧", + "homeGridClearFilter": "清除筛选" } diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index ec3eebe..d153f09 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -13,24 +13,32 @@ 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'; +import '../../core/data/services/wallet_controller.dart'; import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; +import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/app_root_tab.dart'; -import '../../core/widgets/catalog_debug_info.dart'; import '../../core/widgets/catalog_endpoint_editor.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; -import '../discover/discover_types.dart'; import '../discover/discover_view.dart'; import '../favorites/favorites_page.dart'; +import '../filter/filter_page.dart'; import '../mine/mine_types.dart'; +import '../new_releases/new_releases_page.dart'; import '../player/widgets/home_feed_header.dart'; import '../player/player_view.dart'; +import '../ranking/ranking_page.dart'; import '../mine/mine_view.dart'; +import '../store/vip_page.dart'; +import '../wallet/wallet_page.dart'; import 'home_feed_channel.dart'; import 'home_binding.dart'; import 'home_logic.dart'; +import 'widgets/home_feed_grid.dart'; +import 'widgets/home_failure_view.dart'; +import 'widgets/home_grid_header.dart'; class HomePage extends StatefulWidget { const HomePage({super.key}); @@ -189,34 +197,6 @@ class _HomePageState extends State with WidgetsBindingObserver { } } -/// 首页目录加载失败与重试入口。 -class HomeFailureView extends StatelessWidget { - const HomeFailureView({ - super.key, - this.error, - required this.onRetry, - this.onEditEndpoint, - }); - - final String? error; - final VoidCallback onRetry; - final VoidCallback? onEditEndpoint; - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - return EmptyState( - icon: Icons.cloud_off_rounded, - title: l10n.catalogError, - actionLabel: l10n.retry, - onAction: onRetry, - footer: kDebugMode - ? CatalogDebugInfo(error: error, onEditEndpoint: onEditEndpoint) - : null, - ); - } -} - /// 四个根页面共享同一路由并保持各自状态。 class _HomeTabs extends StatefulWidget { const _HomeTabs({super.key, required this.feedController}); @@ -261,7 +241,7 @@ class _HomeTabsState extends State<_HomeTabs> { } Widget _scaffold(BuildContext context) { - final feed = logic.tab == 0; + final mediaTab = logic.tab == 0 || logic.tab == 1; final content = PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { @@ -278,7 +258,7 @@ class _HomeTabsState extends State<_HomeTabs> { ); return AnnotatedRegion( value: SystemUiOverlayStyle.light, - child: feed + child: mediaTab ? content : DecoratedBox( key: const Key('home-page-gradient'), @@ -292,15 +272,31 @@ class _HomeTabsState extends State<_HomeTabs> { return IndexedStack( index: logic.tab, children: [ - _home(context), - _discover(context), + _homeGrid(context), + _recommend(context), const FavoritesContent(key: Key('favorites-page'), showBack: false), _my(context), ], ); } - Widget _home(BuildContext context) { + Widget _homeGrid(BuildContext context) { + return HomeFeedGrid( + feedController: widget.feedController, + callbacks: HomeGridCallbacks( + onSearch: () => _openSearch(context), + onOpenVip: () => unawaited(_openVip(context)), + onOpenWallet: () => unawaited(_openWallet(context)), + onOpenNewReleases: () => _pushPage(context, const NewReleasesPage()), + onOpenCategories: () => _pushPage(context, const FilterPage()), + onOpenRanking: () => _pushPage(context, const RankingPage()), + onOpenFilter: () => _pushPage(context, const FilterPage()), + ), + onOpenDrama: (drama) => unawaited(logic.playDrama(context, drama)), + ); + } + + Widget _recommend(BuildContext context) { final currentDramas = widget.feedController.dramas; final currentChannel = widget.feedController.channel; if (currentDramas.isNotEmpty) { @@ -329,7 +325,7 @@ class _HomeTabsState extends State<_HomeTabs> { PlayerPage( homeMode: true, embedded: true, - active: logic.tab == 0, + active: logic.tab == 1, drama: playerDramas.first, initialIndex: 0, feedDramas: playerDramas, @@ -342,7 +338,7 @@ class _HomeTabsState extends State<_HomeTabs> { onChannelSelected: _selectFeedChannel, onDramaChanged: logic.onDramaChanged, onImmersiveChanged: logic.onImmersiveChanged, - onSearch: () => logic.select(1, search: true), + onSearch: () => _openSearch(context), onDiscover: () => logic.select(1), onMy: () => logic.select(3), ), @@ -383,7 +379,7 @@ class _HomeTabsState extends State<_HomeTabs> { channel: widget.feedController.channel, onChannelSelected: _selectFeedChannel, onRefresh: () => _refreshPlaceholder(context), - onSearch: () => logic.select(1, search: true), + onSearch: () => _openSearch(context), onMore: () {}, ), Expanded( @@ -427,18 +423,33 @@ class _HomeTabsState extends State<_HomeTabs> { unawaited(widget.feedController.selectChannel(channel)); } - Widget _discover(BuildContext context) { - return DiscoverPage( - active: logic.tab == 1, - searchRequest: logic.searchRequest, - callbacks: DiscoverPageCallbacks( - onAction: logic.discoverAction, - onDramaSelected: (drama) => logic.playDrama(context, drama), - onProfile: logic.openProfile, + void _openSearch(BuildContext context) { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const DiscoverPage(focusSearch: true)), + ); + } + + void _pushPage(BuildContext context, Widget page) { + Navigator.of(context).push(MaterialPageRoute(builder: (_) => page)); + } + + Future _openVip(BuildContext context) async { + if (!await ensureSignedIn(context) || !context.mounted) return; + final wallet = context.read(); + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => VipPage(onPurchased: () => unawaited(wallet.load())), ), ); } + Future _openWallet(BuildContext context) async { + if (!await ensureSignedIn(context) || !context.mounted) return; + await Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const WalletPage())); + } + Widget _my(BuildContext context) { return MyPage( active: logic.tab == 3, diff --git a/lib/pages/home/widgets/home_drama_classification.dart b/lib/pages/home/widgets/home_drama_classification.dart new file mode 100644 index 0000000..88e87d6 --- /dev/null +++ b/lib/pages/home/widgets/home_drama_classification.dart @@ -0,0 +1,49 @@ +// Author: fruit.com.cn + +import '../../../core/data/models/drama.dart'; + +const int homeNewReleaseDays = 7; +const int homeHotViewThreshold = 100000; +const int _snowflakeEpochMillis = 1288834974657; +const int _snowflakeTimestampShift = 22; + +const Map> _tagAliases = { + 'uplifting': ['爽剧', '逆袭', '复仇', 'revenge', 'balas'], + 'sweet': ['甜宠', '甜', 'sweet', 'romantis'], + 'faceSlap': ['打脸', '虐渣', 'comeuppance', 'comeback'], + 'angst': ['虐恋', '虐心', 'angst', 'heartbreak'], +}; + +DateTime? publishedAtForDrama(Drama drama) { + if (drama.createdAt != null) return drama.createdAt; + final snowflake = BigInt.tryParse(drama.id); + if (snowflake == null) return null; + final timestamp = + (snowflake >> _snowflakeTimestampShift).toInt() + _snowflakeEpochMillis; + final publishedAt = DateTime.fromMillisecondsSinceEpoch(timestamp); + return publishedAt.year >= 2015 ? publishedAt : null; +} + +bool isNewDrama(Drama drama, {DateTime? now}) { + final publishedAt = publishedAtForDrama(drama); + if (publishedAt == null) return false; + final age = (now ?? DateTime.now()).difference(publishedAt); + return !age.isNegative && age <= const Duration(days: homeNewReleaseDays); +} + +bool isHotDrama(Drama drama) { + return drama.isHot || drama.viewCount >= homeHotViewThreshold; +} + +bool matchesHomeTag(Drama drama, String tagId) { + if (tagId == 'hot') return isHotDrama(drama); + return tagMatchesText(tagId, drama.genre) || + tagMatchesText(tagId, drama.category); +} + +bool tagMatchesText(String tagId, String text) { + if (tagId == 'hot') return false; + final normalized = text.toLowerCase(); + final aliases = _tagAliases[tagId] ?? const []; + return aliases.any(normalized.contains); +} diff --git a/lib/pages/home/widgets/home_failure_view.dart b/lib/pages/home/widgets/home_failure_view.dart new file mode 100644 index 0000000..8ec39d8 --- /dev/null +++ b/lib/pages/home/widgets/home_failure_view.dart @@ -0,0 +1,36 @@ +// Author: fruit.com.cn + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../../core/widgets/catalog_debug_info.dart'; +import '../../../core/widgets/empty_state.dart'; +import '../../../l10n/app_localizations.dart'; + +/// 首页目录加载失败与重试入口。 +class HomeFailureView extends StatelessWidget { + const HomeFailureView({ + super.key, + this.error, + required this.onRetry, + this.onEditEndpoint, + }); + + final String? error; + final VoidCallback onRetry; + final VoidCallback? onEditEndpoint; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return EmptyState( + icon: Icons.cloud_off_rounded, + title: l10n.catalogError, + actionLabel: l10n.retry, + onAction: onRetry, + footer: kDebugMode + ? CatalogDebugInfo(error: error, onEditEndpoint: onEditEndpoint) + : null, + ); + } +} diff --git a/lib/pages/home/widgets/home_feed_card.dart b/lib/pages/home/widgets/home_feed_card.dart new file mode 100644 index 0000000..9b3870d --- /dev/null +++ b/lib/pages/home/widgets/home_feed_card.dart @@ -0,0 +1,225 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/drama.dart'; +import '../../../core/formatting/count_format.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../core/widgets/remote_image.dart'; +import '../../../l10n/app_localizations.dart'; +import 'home_drama_classification.dart'; + +const double _coverAspectRatio = 5 / 6; +final RegExp _tagSplitter = RegExp(r'[,/、·]'); + +enum HomeDramaBadge { hot, newRelease, anime } + +class HomeFeedCard extends StatelessWidget { + const HomeFeedCard({ + super.key, + required this.drama, + required this.onTap, + this.now, + }); + + final Drama drama; + final VoidCallback onTap; + final DateTime? now; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Container( + key: Key('home-feed-card-${drama.id}'), + decoration: BoxDecoration( + color: AppColors.contentCard, + borderRadius: BorderRadius.circular(AppDimens.homeCardRadius), + border: Border.all(color: AppColors.contentBorder), + ), + child: Material( + color: AppColors.transparent, + borderRadius: BorderRadius.circular(AppDimens.homeCardRadius), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _cover(l10n), + Expanded( + child: Padding( + padding: const EdgeInsets.all(AppDimens.homeCardPadding), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + drama.title, + key: Key('home-feed-title-${drama.id}'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.navSelected, + fontSize: AppDimens.homeCardTitle, + fontWeight: FontWeight.w600, + height: 1.25, + ), + ), + const SizedBox(height: AppDimens.spaceXs), + _tags(), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _cover(AppLocalizations l10n) { + final badge = _badge(); + return AspectRatio( + aspectRatio: _coverAspectRatio, + child: Stack( + fit: StackFit.expand, + children: [ + RemoteImage( + drama.posterUrl, + key: Key('home-feed-cover-${drama.id}'), + fit: BoxFit.cover, + showFallbackIcon: true, + fallbackColor: AppColors.contentSurface, + ), + if (badge != null) + Positioned( + top: AppDimens.spaceXs, + left: AppDimens.spaceXs, + child: _badgeChip(l10n, badge), + ), + Positioned( + left: AppDimens.spaceXs, + bottom: AppDimens.spaceXs, + child: _views(), + ), + ], + ), + ); + } + + HomeDramaBadge? _badge() { + if (drama.dramaType.toUpperCase() == 'ANIME') { + return HomeDramaBadge.anime; + } + if (isNewDrama(drama, now: now)) return HomeDramaBadge.newRelease; + if (isHotDrama(drama)) { + return HomeDramaBadge.hot; + } + return null; + } + + Widget _badgeChip(AppLocalizations l10n, HomeDramaBadge badge) { + final (label, color) = switch (badge) { + HomeDramaBadge.hot => (l10n.homeBadgeHot, AppColors.badgeHot), + HomeDramaBadge.newRelease => (l10n.homeBadgeNew, AppColors.badgeNew), + HomeDramaBadge.anime => (l10n.homeBadgeAnime, AppColors.badgeAnime), + }; + return Container( + key: Key('home-feed-badge-${badge.name}-${drama.id}'), + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.spaceSm, + vertical: 2, + ), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(AppDimens.chipRadius), + ), + child: Text( + label, + style: const TextStyle( + color: AppColors.navSelected, + fontSize: AppDimens.homeCardBadge, + fontWeight: FontWeight.w700, + letterSpacing: 0, + ), + ), + ); + } + + Widget _views() { + return Container( + key: Key('home-feed-views-${drama.id}'), + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: AppColors.playerOverlayPill, + borderRadius: BorderRadius.circular(AppDimens.pillRadius), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.play_arrow_rounded, + size: AppDimens.homeCardMeta, + color: AppColors.navSelected, + ), + const SizedBox(width: 1), + Text( + formatInteractionCount(drama.viewCount), + style: const TextStyle( + color: AppColors.navSelected, + fontSize: AppDimens.homeCardMeta, + fontWeight: FontWeight.w600, + letterSpacing: 0, + ), + ), + ], + ), + ); + } + + Widget _tags() { + final tags = _cardTags(); + if (tags.isEmpty) return const SizedBox.shrink(); + return Row( + children: [ + for (final (index, tag) in tags.indexed) + Flexible( + child: Padding( + padding: EdgeInsets.only( + right: index == tags.length - 1 ? 0 : AppDimens.spaceXs, + ), + child: Container( + key: Key('home-feed-tag-${drama.id}-$index'), + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: AppColors.contentSurface, + borderRadius: BorderRadius.circular(AppDimens.chipRadius), + ), + child: Text( + tag, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: AppDimens.homeTagFont, + letterSpacing: 0, + ), + ), + ), + ), + ), + ], + ); + } + + List _cardTags() { + final seen = {}; + return [drama.genre, drama.category] + .expand((value) => value.split(_tagSplitter)) + .map((value) => value.trim()) + .where((value) => value.isNotEmpty && seen.add(value)) + .take(2) + .toList(growable: false); + } +} diff --git a/lib/pages/home/widgets/home_feed_grid.dart b/lib/pages/home/widgets/home_feed_grid.dart new file mode 100644 index 0000000..cd76a56 --- /dev/null +++ b/lib/pages/home/widgets/home_feed_grid.dart @@ -0,0 +1,294 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../core/data/models/content_category.dart'; +import '../../../core/data/models/drama.dart'; +import '../../../core/data/services/auth_controller.dart'; +import '../../../core/data/services/category_controller.dart'; +import '../../../core/data/services/home_feed_controller.dart'; +import '../../../core/data/services/wallet_controller.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../core/widgets/app_pull_to_refresh.dart'; +import '../../../core/widgets/empty_state.dart'; +import '../../../core/widgets/paged_list_footer.dart'; +import '../../../l10n/app_localizations.dart'; +import 'home_drama_classification.dart'; +import 'home_feed_card.dart'; +import 'home_grid_header.dart'; + +const double _loadMoreThreshold = 240; +const List _costumeAliases = ['古装', 'costume', 'period']; +const List _communityAliases = ['社群', 'community', 'komunitas']; + +class HomeFeedGrid extends StatefulWidget { + const HomeFeedGrid({ + super.key, + required this.feedController, + required this.callbacks, + required this.onOpenDrama, + }); + + final HomeFeedController feedController; + final HomeGridCallbacks callbacks; + final ValueChanged onOpenDrama; + + @override + State createState() => _HomeFeedGridState(); +} + +class _HomeFeedGridState extends State { + String? _selectedCategory; + String? _selectedTag; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final categories = context.watch().categories; + final auth = context.watch(); + final wallet = context.watch(); + final shortcuts = _categoryShortcuts(categories, l10n); + final tags = _availableTags(l10n); + final activeCategory = + shortcuts.any((shortcut) => shortcut.categoryName == _selectedCategory) + ? _selectedCategory + : null; + final activeTag = tags.any((tag) => tag.id == _selectedTag) + ? _selectedTag + : null; + final dramas = _filteredDramas(activeCategory, activeTag); + final walletBadge = auth.isSignedIn && wallet.snapshot != null + ? wallet.balance + : null; + return ColoredBox( + key: const Key('home-grid'), + color: AppColors.appBackground, + child: SafeArea( + bottom: false, + child: LayoutBuilder( + builder: (context, constraints) { + final itemWidth = + (constraints.maxWidth - + AppDimens.pageHorizontal * 2 - + AppDimens.homeGridGap * 2) / + 3; + final itemExtent = itemWidth * 6 / 5 + AppDimens.homeCardBodyExtent; + return NotificationListener( + onNotification: _onScroll, + child: AppPullToRefresh( + key: const Key('home-grid-refresh'), + onRefresh: _refresh, + child: CustomScrollView( + key: const Key('home-grid-scroll'), + physics: AppPullToRefresh.physics, + slivers: [ + SliverToBoxAdapter( + child: HomeGridHeader( + callbacks: widget.callbacks, + categories: shortcuts, + tags: tags, + selectedCategory: activeCategory, + selectedTag: activeTag, + onCategorySelected: _selectCategory, + onTagSelected: (tag) => + setState(() => _selectedTag = tag), + walletBadge: walletBadge, + ), + ), + if (widget.feedController.loadingFirstPage) + const SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: SizedBox.square( + dimension: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ) + else if (dramas.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: _emptyState(l10n, activeCategory, activeTag), + ) + else + SliverPadding( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.pageHorizontal, + ), + sliver: SliverGrid( + key: const Key('home-grid-cards'), + gridDelegate: + SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: AppDimens.homeGridGap, + mainAxisSpacing: AppDimens.homeGridGap, + mainAxisExtent: itemExtent, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => HomeFeedCard( + drama: dramas[index], + onTap: () => widget.onOpenDrama(dramas[index]), + ), + childCount: dramas.length, + ), + ), + ), + if (dramas.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only( + bottom: AppDimens.spaceXl, + ), + child: PagedListFooter( + loading: widget.feedController.loadingMore, + hasMore: widget.feedController.hasMore, + hasItems: dramas.isNotEmpty, + errorMessage: + widget.feedController.lastError == null + ? null + : l10n.catalogError, + onRetry: widget.feedController.loadMore, + ), + ), + ), + ], + ), + ), + ); + }, + ), + ), + ); + } + + Future _refresh() async { + await widget.feedController.refreshCurrent(); + } + + bool _onScroll(ScrollNotification notification) { + if (notification.depth != 0) return false; + final reachedBottom = + notification.metrics.extentAfter <= _loadMoreThreshold; + if (reachedBottom && + (notification is ScrollUpdateNotification || + notification is ScrollEndNotification || + notification is OverscrollNotification)) { + unawaited(widget.feedController.loadMore()); + } + return false; + } + + void _selectCategory(String? category) { + setState(() { + _selectedCategory = category; + _selectedTag = null; + }); + } + + List _categoryShortcuts( + List categories, + AppLocalizations l10n, + ) { + final shortcuts = []; + final costume = _findCategory(categories, _costumeAliases); + final community = _findCategory(categories, _communityAliases); + if (costume != null) { + shortcuts.add( + HomeGridCategoryShortcut( + label: l10n.homeSectionCostume, + categoryName: costume.name, + ), + ); + } + if (community != null) { + shortcuts.add( + HomeGridCategoryShortcut( + label: l10n.homeSectionCommunity, + categoryName: community.name, + ), + ); + } + return shortcuts; + } + + ContentCategory? _findCategory( + List categories, + List aliases, + ) { + for (final category in categories) { + final name = category.name.toLowerCase(); + if (aliases.any(name.contains)) return category; + } + return null; + } + + List _availableTags(AppLocalizations l10n) { + final definitions = [ + HomeGridTagOption(id: 'hot', label: l10n.homeTagHot), + HomeGridTagOption(id: 'uplifting', label: l10n.homeTagUplifting), + HomeGridTagOption(id: 'sweet', label: l10n.homeTagSweet), + HomeGridTagOption(id: 'faceSlap', label: l10n.homeTagFaceSlap), + HomeGridTagOption(id: 'angst', label: l10n.homeTagAngst), + ]; + final categoryText = context + .read() + .categories + .map((category) => category.name) + .toList(growable: false); + return definitions + .where((option) { + if (option.id == 'hot') { + return widget.feedController.dramas.any(isHotDrama); + } + return categoryText.any((text) => tagMatchesText(option.id, text)) || + widget.feedController.dramas.any( + (drama) => matchesHomeTag(drama, option.id), + ); + }) + .toList(growable: false); + } + + List _filteredDramas(String? category, String? tag) { + final normalizedCategory = category?.toLowerCase(); + return widget.feedController.dramas + .where((drama) { + final categoryMatches = + normalizedCategory == null || + drama.category.toLowerCase().contains(normalizedCategory) || + drama.genre.toLowerCase().contains(normalizedCategory); + final tagMatches = tag == null || matchesHomeTag(drama, tag); + return categoryMatches && tagMatches; + }) + .toList(growable: false); + } + + Widget _emptyState(AppLocalizations l10n, String? category, String? tag) { + if (widget.feedController.lastError != null) { + return EmptyState( + key: const Key('home-grid-error'), + icon: Icons.cloud_off_rounded, + title: l10n.catalogError, + actionLabel: l10n.retry, + onAction: () => unawaited(widget.feedController.refreshCurrent()), + ); + } + final filtered = category != null || tag != null; + return EmptyState( + key: const Key('home-grid-empty'), + icon: Icons.movie_filter_outlined, + title: filtered ? l10n.homeGridFilterEmpty : l10n.noStories, + subtitle: filtered ? null : l10n.emptyHint, + actionLabel: filtered ? l10n.homeGridClearFilter : null, + onAction: filtered + ? () => setState(() { + _selectedCategory = null; + _selectedTag = null; + }) + : null, + ); + } +} diff --git a/lib/pages/home/widgets/home_grid_header.dart b/lib/pages/home/widgets/home_grid_header.dart new file mode 100644 index 0000000..830330c --- /dev/null +++ b/lib/pages/home/widgets/home_grid_header.dart @@ -0,0 +1,365 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/formatting/count_format.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../l10n/app_localizations.dart'; + +class HomeGridCategoryShortcut { + const HomeGridCategoryShortcut({ + required this.label, + required this.categoryName, + }); + + final String label; + final String categoryName; +} + +class HomeGridTagOption { + const HomeGridTagOption({required this.id, required this.label}); + + final String id; + final String label; +} + +class HomeGridCallbacks { + const HomeGridCallbacks({ + required this.onSearch, + required this.onOpenVip, + required this.onOpenWallet, + required this.onOpenNewReleases, + required this.onOpenCategories, + required this.onOpenRanking, + required this.onOpenFilter, + }); + + final VoidCallback onSearch; + final VoidCallback onOpenVip; + final VoidCallback onOpenWallet; + final VoidCallback onOpenNewReleases; + final VoidCallback onOpenCategories; + final VoidCallback onOpenRanking; + final VoidCallback onOpenFilter; +} + +class HomeGridHeader extends StatelessWidget { + const HomeGridHeader({ + super.key, + required this.callbacks, + required this.categories, + required this.tags, + required this.selectedCategory, + required this.selectedTag, + required this.onCategorySelected, + required this.onTagSelected, + this.walletBadge, + }); + + final HomeGridCallbacks callbacks; + final List categories; + final List tags; + final String? selectedCategory; + final String? selectedTag; + final ValueChanged onCategorySelected; + final ValueChanged onTagSelected; + final int? walletBadge; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Column( + key: const Key('home-grid-header'), + children: [_searchRow(l10n), _primaryRow(l10n), _tagRow(l10n)], + ); + } + + Widget _searchRow(AppLocalizations l10n) { + return Padding( + padding: const EdgeInsets.fromLTRB( + AppDimens.pageHorizontal, + AppDimens.spaceSm, + AppDimens.pageHorizontal, + AppDimens.spaceSm, + ), + child: Row( + children: [ + Expanded( + child: InkWell( + key: const Key('home-search-entry'), + borderRadius: BorderRadius.circular(AppDimens.inputRadius), + onTap: callbacks.onSearch, + child: Container( + height: AppDimens.searchHeight, + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.spaceMd, + ), + decoration: BoxDecoration( + color: AppColors.contentSurface, + borderRadius: BorderRadius.circular(AppDimens.inputRadius), + ), + child: Row( + children: [ + const Icon( + Icons.search_rounded, + size: AppDimens.homeHeaderIcon, + color: AppColors.textSecondary, + ), + const SizedBox(width: AppDimens.spaceSm), + Expanded( + child: Text( + l10n.homeSearchHint, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: AppDimens.body, + ), + ), + ), + ], + ), + ), + ), + ), + const SizedBox(width: AppDimens.spaceSm), + _headerAction( + key: const Key('home-vip-entry'), + icon: Icons.workspace_premium_outlined, + tooltip: l10n.vipMembership, + onTap: callbacks.onOpenVip, + ), + const SizedBox(width: AppDimens.spaceXs), + _headerAction( + key: const Key('home-wallet-entry'), + icon: Icons.card_giftcard_rounded, + tooltip: l10n.wallet, + onTap: callbacks.onOpenWallet, + badge: walletBadge, + ), + ], + ), + ); + } + + Widget _headerAction({ + required Key key, + required IconData icon, + required String tooltip, + required VoidCallback onTap, + int? badge, + }) { + return SizedBox.square( + dimension: AppDimens.homeIconSlot, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: Tooltip( + message: tooltip, + child: Material( + color: AppColors.contentSurface, + borderRadius: BorderRadius.circular(AppDimens.inputRadius), + child: InkWell( + key: key, + borderRadius: BorderRadius.circular(AppDimens.inputRadius), + onTap: onTap, + child: Icon( + icon, + size: AppDimens.homeHeaderIcon, + color: AppColors.navSelected, + ), + ), + ), + ), + ), + if (badge != null && badge > 0) + Positioned( + key: const Key('home-wallet-badge'), + top: -5, + right: -6, + child: Container( + constraints: const BoxConstraints(minWidth: 20, minHeight: 16), + padding: const EdgeInsets.symmetric(horizontal: 5), + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.badgeHot, + borderRadius: BorderRadius.circular(AppDimens.pillRadius), + ), + child: Text( + formatInteractionCount(badge), + style: const TextStyle( + color: AppColors.navSelected, + fontSize: AppDimens.homeCardBadge, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ), + ); + } + + Widget _primaryRow(AppLocalizations l10n) { + return SingleChildScrollView( + key: const Key('home-primary-sections'), + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: AppDimens.pageHorizontal), + child: Row( + children: [ + _primaryItem( + key: const Key('home-section-featured'), + label: l10n.homeSectionFeatured, + selected: selectedCategory == null, + onTap: () => onCategorySelected(null), + ), + _primaryItem( + key: const Key('home-section-new'), + label: l10n.homeSectionNew, + selected: false, + onTap: callbacks.onOpenNewReleases, + ), + _primaryItem( + key: const Key('home-section-categories'), + label: l10n.homeSectionCategories, + selected: false, + onTap: callbacks.onOpenCategories, + ), + _primaryItem( + key: const Key('home-section-ranking'), + label: l10n.homeSectionRanking, + selected: false, + onTap: callbacks.onOpenRanking, + ), + for (final category in categories) + _primaryItem( + key: Key('home-section-category-${category.categoryName}'), + label: category.label, + selected: selectedCategory == category.categoryName, + onTap: () => onCategorySelected(category.categoryName), + ), + ], + ), + ); + } + + Widget _primaryItem({ + required Key key, + required String label, + required bool selected, + required VoidCallback onTap, + }) { + return InkWell( + key: key, + onTap: onTap, + child: Padding( + padding: const EdgeInsets.only(right: AppDimens.spaceXl), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: TextStyle( + color: selected + ? AppColors.navSelected + : AppColors.textSecondary, + fontSize: AppDimens.homeSectionFont, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + letterSpacing: 0, + ), + ), + const SizedBox(height: AppDimens.spaceXs), + Container( + height: AppDimens.homeSectionIndicator, + width: selected ? 20 : 0, + decoration: BoxDecoration( + color: AppColors.navSelected, + borderRadius: BorderRadius.circular(AppDimens.pillRadius), + ), + ), + ], + ), + ), + ); + } + + Widget _tagRow(AppLocalizations l10n) { + return Padding( + padding: const EdgeInsets.fromLTRB( + AppDimens.pageHorizontal, + AppDimens.spaceMd, + AppDimens.pageHorizontal, + AppDimens.spaceSm, + ), + child: Row( + children: [ + Expanded( + child: SingleChildScrollView( + key: const Key('home-secondary-tags'), + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (final tag in tags) ...[ + _tagChip(tag), + const SizedBox(width: AppDimens.spaceSm), + ], + ], + ), + ), + ), + const SizedBox(width: AppDimens.spaceXs), + Tooltip( + message: l10n.filter, + child: SizedBox.square( + dimension: AppDimens.tapTarget, + child: Material( + color: AppColors.contentSurface, + borderRadius: BorderRadius.circular(AppDimens.inputRadius), + child: InkWell( + key: const Key('home-grid-filter'), + borderRadius: BorderRadius.circular(AppDimens.inputRadius), + onTap: callbacks.onOpenFilter, + child: const Icon( + Icons.filter_list_rounded, + color: AppColors.navSelected, + size: AppDimens.homeHeaderIcon, + ), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _tagChip(HomeGridTagOption tag) { + final selected = selectedTag == tag.id; + return InkWell( + key: Key('home-tag-${tag.id}'), + borderRadius: BorderRadius.circular(AppDimens.pillRadius), + onTap: () => onTagSelected(selected ? null : tag.id), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.spaceMd, + vertical: AppDimens.spaceSm, + ), + decoration: BoxDecoration( + color: selected ? AppColors.contentChip : AppColors.contentSurface, + borderRadius: BorderRadius.circular(AppDimens.pillRadius), + ), + child: Text( + tag.label, + style: TextStyle( + color: selected ? AppColors.navSelected : AppColors.textSecondary, + fontSize: AppDimens.homeSectionFont - 2, + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + letterSpacing: 0, + ), + ), + ), + ); + } +} diff --git a/test/discover/filter_page_test.dart b/test/discover/filter_page_test.dart index 91d82dc..061df58 100644 --- a/test/discover/filter_page_test.dart +++ b/test/discover/filter_page_test.dart @@ -151,7 +151,7 @@ Future _pumpFilterPage(WidgetTester tester) async { ), ); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.tap(find.byKey(const Key('home-search-entry'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('theater-toolbar-filter'))); await tester.pumpAndSettle(); diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index 5ed4eb0..697b52e 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -455,7 +455,9 @@ Future _pumpTheater( ), ); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.tap(find.byKey(const Key('home-search-entry'))); + await tester.pumpAndSettle(); + FocusManager.instance.primaryFocus?.unfocus(); await tester.pumpAndSettle(); } diff --git a/test/discover/theater_pages_test.dart b/test/discover/theater_pages_test.dart index 1e1877c..30d89ee 100644 --- a/test/discover/theater_pages_test.dart +++ b/test/discover/theater_pages_test.dart @@ -303,7 +303,7 @@ Future _pumpTheater( ), ); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.tap(find.byKey(const Key('home-search-entry'))); await tester.pumpAndSettle(); } diff --git a/test/home/catalog_diagnostics_test.dart b/test/home/catalog_diagnostics_test.dart index 4c6aefc..80be4fa 100644 --- a/test/home/catalog_diagnostics_test.dart +++ b/test/home/catalog_diagnostics_test.dart @@ -10,7 +10,7 @@ import 'package:fruitshorts/core/data/services/catalog_retry.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/core/widgets/catalog_endpoint_editor.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; -import 'package:fruitshorts/pages/home/home_view.dart'; +import 'package:fruitshorts/pages/home/widgets/home_failure_view.dart'; void main() { tearDown(AppConfig.clearRuntimeBaseUrl); diff --git a/test/home/home_card_style_test.dart b/test/home/home_card_style_test.dart index c205ebd..3744549 100644 --- a/test/home/home_card_style_test.dart +++ b/test/home/home_card_style_test.dart @@ -7,91 +7,157 @@ import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/core/theme/app_dimens.dart'; import 'package:fruitshorts/core/theme/app_theme.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; -import 'package:fruitshorts/pages/home/widgets/home_drama_grid.dart'; +import 'package:fruitshorts/pages/home/widgets/home_feed_card.dart'; void main() { - testWidgets('should_render_compact_cover_badges_without_plus_action', ( + testWidgets('should_render_grid_card_cover_badge_views_title_and_tags', ( tester, ) async { + var taps = 0; + await _pumpCard( + tester, + drama: _drama( + id: 'anime-card', + title: 'A very long home grid drama title that must wrap', + dramaType: 'ANIME', + viewCount: 444500, + ), + onTap: () => taps++, + ); + + final card = find.byKey(const Key('home-feed-card-anime-card')); + final container = tester.widget(card); + final decoration = container.decoration! as BoxDecoration; + expect(decoration.color, AppColors.contentCard); + expect(decoration.boxShadow, isNull); + expect( + decoration.borderRadius, + BorderRadius.circular(AppDimens.homeCardRadius), + ); + expect((decoration.border! as Border).top.width, 1); + expect((decoration.border! as Border).top.color, AppColors.contentBorder); + + final cover = find.byKey(const Key('home-feed-cover-anime-card')); + final coverSize = tester.getSize(cover); + expect(coverSize.height / coverSize.width, closeTo(6 / 5, 0.02)); + expect( + find.byKey(const Key('home-feed-badge-anime-anime-card')), + findsOneWidget, + ); + expect(find.text('Anime'), findsOneWidget); + expect(find.byKey(const Key('home-feed-views-anime-card')), findsOneWidget); + final views = find.byKey(const Key('home-feed-views-anime-card')); + final viewsText = tester.widget( + find.descendant(of: views, matching: find.byType(Text)), + ); + expect(viewsText.data, startsWith('44.')); + expect( + tester + .widget(find.byKey(const Key('home-feed-title-anime-card'))) + .maxLines, + 2, + ); + expect(find.byKey(const Key('home-feed-tag-anime-card-0')), findsOneWidget); + expect(find.byKey(const Key('home-feed-tag-anime-card-1')), findsOneWidget); + expect(find.text('Romance'), findsOneWidget); + expect(find.text('Urban'), findsOneWidget); + + await tester.tap(card); + expect(taps, 1); + }); + + testWidgets('should_choose_anime_new_and_hot_badges_from_real_data', ( + tester, + ) async { + final now = DateTime(2026, 10, 22, 12); await tester.pumpWidget( MaterialApp( - theme: AppTheme.light(), + theme: AppTheme.dark(), locale: const Locale('en'), supportedLocales: AppLocalizations.supportedLocales, localizationsDelegates: AppLocalizations.localizationsDelegates, home: Scaffold( - body: SingleChildScrollView( - child: Builder( - builder: (context) => SizedBox( - width: 180, - child: HomeDramaCard( - l10n: AppLocalizations.of(context)!, - drama: _drama, - onTap: () {}, + body: Row( + children: [ + for (final drama in [ + _drama(id: 'anime', title: 'Anime', dramaType: 'ANIME'), + _drama( + id: 'new', + title: 'New', + createdAt: now.subtract(const Duration(days: 2)), ), - ), - ), + _drama(id: 'hot', title: 'Hot', isHot: true), + ]) + SizedBox( + width: 130, + height: 240, + child: HomeFeedCard(drama: drama, onTap: () {}, now: now), + ), + ], ), ), ), ); await tester.pumpAndSettle(); - final card = find.byKey(const Key('home-drama-card-style-drama')); - final cardContainer = tester.widget( - find.ancestor(of: card, matching: find.byType(Container)).last, - ); - final cardDecoration = cardContainer.decoration! as BoxDecoration; - expect(cardDecoration.color, AppColors.card); expect( - cardDecoration.borderRadius, - BorderRadius.circular(AppDimens.cardRadius), - ); - expect(cardDecoration.boxShadow, const [AppTheme.cardShadow]); - - final tag = tester.widget( - find.byKey(const Key('home-drama-tag-style-drama')), - ); - expect((tag.decoration! as BoxDecoration).color, AppColors.badgePink); - expect(find.text('Free'), findsOneWidget); - expect(find.text('All 24'), findsOneWidget); - expect( - find.byKey(const Key('home-drama-locked-style-drama')), - findsNothing, - ); - expect(find.byKey(const Key('home-drama-add-style-drama')), findsNothing); - - // 播放次数角标落在封面内,并按万位格式化。 - final views = find.byKey(const Key('home-drama-views-style-drama')); - expect( - find.descendant( - of: find.byKey(const Key('home-drama-cover-style-drama')), - matching: views, - ), + find.byKey(const Key('home-feed-badge-anime-anime')), findsOneWidget, ); expect( - find.descendant(of: views, matching: find.text('1.2万')), + find.byKey(const Key('home-feed-badge-newRelease-new')), findsOneWidget, ); - - // 压缩后卡片高度接近「卡宽 × 1.5」,明显矮于旧版 3:4 封面。 - final cardSize = tester.getSize(card); - expect(cardSize.height, lessThan(cardSize.width * 1.6)); - final coverSize = tester.getSize( - find.byKey(const Key('home-drama-cover-style-drama')), - ); - expect(coverSize.height / coverSize.width, closeTo(6 / 5, 0.02)); + expect(find.byKey(const Key('home-feed-badge-hot-hot')), findsOneWidget); }); } -const _drama = Drama( - id: 'style-drama', - title: 'Warm Orange Story', - synopsis: '暖橙短剧', - posterUrl: '/files/style.jpg', - genre: 'Romance', - totalEpisodes: 24, - freeEpisodes: 6, - viewCount: 12400, -); +Drama _drama({ + required String id, + required String title, + String dramaType = 'LIVE', + int viewCount = 0, + bool isHot = false, + DateTime? createdAt, +}) { + return Drama( + id: id, + title: title, + synopsis: 'synopsis', + posterUrl: '/files/$id.jpg', + genre: 'Romance', + category: 'Urban', + viewCount: viewCount, + isHot: isHot, + dramaType: dramaType, + createdAt: createdAt, + ); +} + +Future _pumpCard( + WidgetTester tester, { + required Drama drama, + required VoidCallback onTap, +}) { + return tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark(), + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Scaffold( + body: Center( + child: SizedBox( + width: 130, + height: 240, + child: HomeFeedCard( + drama: drama, + onTap: onTap, + now: DateTime(2026, 10, 22, 12), + ), + ), + ), + ), + ), + ); +} diff --git a/test/home/home_grid_layout_test.dart b/test/home/home_grid_layout_test.dart new file mode 100644 index 0000000..5ae8b09 --- /dev/null +++ b/test/home/home_grid_layout_test.dart @@ -0,0 +1,252 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +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/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/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'; + +import '../support/fake_category.dart'; +import '../support/fake_haptics.dart'; +import '../support/fake_interaction_repositories.dart'; +import '../support/widget_player.dart'; + +const _categories = [ + ContentCategory(id: 'costume', name: '古装'), + ContentCategory(id: 'community', name: '社群'), + ContentCategory(id: 'uplifting', name: '爽剧'), + ContentCategory(id: 'sweet', name: '甜宠'), + ContentCategory(id: 'face-slap', name: '打脸虐渣'), + ContentCategory(id: 'angst', name: '虐恋'), +]; + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + testWidgets('should_render_primary_sections_and_secondary_tags_in_order', ( + tester, + ) async { + final repository = _PagedCatalogRepository(); + await _pumpApp(tester, repository); + + final sectionKeys = [ + const Key('home-section-featured'), + const Key('home-section-new'), + const Key('home-section-categories'), + const Key('home-section-ranking'), + const Key('home-section-category-古装'), + const Key('home-section-category-社群'), + ]; + final sectionCenters = sectionKeys + .map((key) => tester.getCenter(find.byKey(key)).dx) + .toList(); + expect(sectionCenters, orderedEquals(sectionCenters.toList()..sort())); + + final tagKeys = [ + const Key('home-tag-hot'), + const Key('home-tag-uplifting'), + const Key('home-tag-sweet'), + const Key('home-tag-faceSlap'), + const Key('home-tag-angst'), + ]; + final tagCenters = tagKeys + .map((key) => tester.getCenter(find.byKey(key)).dx) + .toList(); + expect(tagCenters, orderedEquals(tagCenters.toList()..sort())); + expect(find.text('Featured'), findsOneWidget); + expect(find.text('New'), findsOneWidget); + expect(find.text('Categories'), findsOneWidget); + expect(find.text('Charts'), findsOneWidget); + expect(find.text('Costume'), findsOneWidget); + expect(find.text('Community'), findsOneWidget); + }); + + testWidgets('should_hide_unmatched_content_sections', (tester) async { + final repository = _PagedCatalogRepository(); + await _pumpApp( + tester, + repository, + categories: const [ContentCategory(id: 'romance', name: 'Romance')], + ); + + expect(find.byKey(const Key('home-section-category-古装')), findsNothing); + expect(find.byKey(const Key('home-section-category-社群')), findsNothing); + expect(find.byKey(const Key('home-tag-uplifting')), findsNothing); + }); + + testWidgets('should_open_existing_pages_from_home_sections', (tester) async { + final repository = _PagedCatalogRepository(); + await _pumpApp(tester, repository); + + await tester.tap(find.byKey(const Key('home-section-new'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('new-releases-page')), findsOneWidget); + await tester.pageBack(); + await tester.pumpAndSettle(); + + await tester.ensureVisible(find.byKey(const Key('home-section-ranking'))); + await tester.tap(find.byKey(const Key('home-section-ranking'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('ranking-page')), findsOneWidget); + await tester.pageBack(); + await tester.pumpAndSettle(); + + await tester.ensureVisible( + find.byKey(const Key('home-section-categories')), + ); + await tester.tap(find.byKey(const Key('home-section-categories'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('filter-page')), findsOneWidget); + }); + + testWidgets('should_load_next_page_without_expanding_page_size', ( + tester, + ) async { + final repository = _PagedCatalogRepository(); + await _pumpApp(tester, repository); + + expect(find.byKey(const Key('home-feed-card-page1-0')), findsOneWidget); + await tester.drag( + find.byKey(const Key('home-grid-scroll')), + const Offset(0, -1200), + ); + await tester.pumpAndSettle(); + expect(repository.cursors, contains('page1')); + expect(repository.requestedSizes.every((size) => size <= 10), isTrue); + + await tester.scrollUntilVisible( + find.byKey(const Key('home-feed-card-page2-1')), + 260, + scrollable: find + .descendant( + of: find.byKey(const Key('home-grid-refresh')), + matching: find.byType(Scrollable), + ) + .first, + ); + expect(find.byKey(const Key('home-feed-card-page2-1')), findsOneWidget); + }); + + testWidgets('should_open_player_when_grid_card_is_tapped', (tester) async { + final repository = _PagedCatalogRepository(); + await _pumpApp(tester, repository); + + await tester.tap(find.byKey(const Key('home-feed-card-page1-0'))); + await tester.pumpAndSettle(); + + final player = tester.widget(find.byType(PlayerPage).last); + expect(player.homeMode, isFalse); + }); +} + +Future _pumpApp( + WidgetTester tester, + _PagedCatalogRepository repository, { + List categories = _categories, +}) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: repository, + categoryRepository: FakeCategoryRepository( + catalog: repository, + categories: categories, + ), + watchHistoryRepository: InMemoryWatchHistoryRepository(), + favoriteRepository: InMemoryFavoriteRepository( + cards: repository.allDramas, + ), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + haptics: FakeHaptics(), + ), + ); + await tester.pumpAndSettle(); +} + +class _PagedCatalogRepository implements CatalogRepository { + _PagedCatalogRepository() + : pageOne = List.generate(10, (index) => _drama('page1-$index')), + pageTwo = List.generate(2, (index) => _drama('page2-$index')); + + final List pageOne; + final List pageTwo; + final List cursors = []; + final List requestedSizes = []; + + List get allDramas => [...pageOne, ...pageTwo]; + + @override + Future fetchHomeFeed() async => _feed(pageOne); + + @override + Future fetchDiscoverFeed() async => _feed(pageOne); + + @override + Future fetchFeed({ + List types = const [], + String? categoryId, + String? cursor, + int size = 10, + }) async { + requestedSizes.add(size); + if (types.isNotEmpty) { + return const FeedPage(dramas: []); + } + cursors.add(cursor); + if (cursor == 'page1') { + return FeedPage(dramas: pageTwo, hasMore: false); + } + return FeedPage(dramas: pageOne, nextCursor: 'page1', hasMore: true); + } + + @override + Future fetchDrama(String dramaId) async { + return allDramas.firstWhere((drama) => drama.id == dramaId); + } + + HomeFeed _feed(List dramas) { + return HomeFeed( + sections: [FeedSection(code: 'hot', title: 'Hot', dramas: dramas)], + ); + } +} + +Drama _drama(String id) { + return Drama( + id: id, + title: 'Grid Drama $id', + synopsis: 'synopsis', + posterUrl: '', + genre: 'Romance', + category: 'Urban', + viewCount: 200000, + detailLoaded: true, + episodes: [ + Episode( + id: '$id-1', + number: 1, + title: 'Episode 1', + isFree: true, + videoUrl: 'https://example.test/$id.mp4', + ), + ], + ); +} diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart index 0e044f0..3ba8edd 100644 --- a/test/home/home_nav_ui_test.dart +++ b/test/home/home_nav_ui_test.dart @@ -209,11 +209,11 @@ void main() { expect(find.byKey(const Key('tab-follow')), findsOneWidget); expect(find.byKey(const Key('tab-mine')), findsOneWidget); expect(find.byKey(const Key('tab-create')), findsNothing); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('home-grid')), findsOneWidget); await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); await tester.tap(find.byKey(const Key('tab-follow'))); await tester.pumpAndSettle(); @@ -251,7 +251,7 @@ void main() { await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('home-grid')), findsOneWidget); }); testWidgets('should_not_haptic_when_ordinary_list_item_is_opened', ( diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 7a50e5a..7f36cd1 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -9,6 +9,7 @@ import 'package:shared_preferences_platform_interface/in_memory_shared_preferenc import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.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/home_feed.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; @@ -20,6 +21,7 @@ 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:fruitshorts/pages/player/player_view.dart'; import 'package:video_player/video_player.dart'; import '../support/fake_catalog.dart'; @@ -30,7 +32,9 @@ import '../support/fake_notice_repository.dart'; import '../support/widget_player.dart'; void main() { - testWidgets('should_render_immersive_feed_on_home', (tester) async { + testWidgets('should_render_three_column_explorer_grid_on_home', ( + tester, + ) async { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); tester.view.physicalSize = const Size(390, 844); @@ -48,6 +52,58 @@ void main() { ), ], ); + await tester.pumpWidget( + LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: catalog, + categoryRepository: FakeCategoryRepository( + catalog: catalog, + categories: const [ + ContentCategory(id: 'costume', name: '古装'), + ContentCategory(id: 'community', name: '社群'), + ], + ), + watchHistoryRepository: InMemoryWatchHistoryRepository(), + favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + noticeRepository: FakeNoticeRepository(), + haptics: FakeHaptics(), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('home-grid')), findsOneWidget); + expect(find.byKey(const Key('home-grid-cards')), findsOneWidget); + final grid = tester.widget( + find.byKey(const Key('home-grid-cards')), + ); + final delegate = + grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; + expect(delegate.crossAxisCount, 3); + expect(delegate.crossAxisSpacing, 8); + expect(delegate.mainAxisSpacing, 8); + expect(find.byKey(const Key('home-section-featured')), findsOneWidget); + expect(find.byKey(const Key('home-section-new')), findsOneWidget); + expect(find.byKey(const Key('home-section-categories')), findsOneWidget); + expect(find.byKey(const Key('home-section-ranking')), findsOneWidget); + expect(find.byKey(const Key('home-section-category-古装')), findsOneWidget); + expect(find.byKey(const Key('home-section-category-社群')), findsOneWidget); + expect(find.byKey(const Key('home-secondary-tags')), findsOneWidget); + expect(find.byKey(const Key('home-grid-filter')), findsOneWidget); + expect(find.byType(PageView), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('should_keep_immersive_player_on_recommend_tab', (tester) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final catalog = FakeCatalogRepository(); await tester.pumpWidget( LumaDramaApp( playerFactory: createWidgetPlayer, @@ -63,14 +119,13 @@ void main() { ), ); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-home'))); + await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); - expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget); + expect(find.byKey(const Key('home-grid')), findsNothing); expect(find.byType(PageView), findsOneWidget); - expect(find.byKey(const Key('home-browse-page')), findsNothing); - expect(tester.takeException(), isNull); + expect(tester.widget(find.byType(PlayerPage)).homeMode, isTrue); + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); }); testWidgets('should_render_new_feed_structure_with_formatted_counts', ( diff --git a/test/home/home_server_setting_test.dart b/test/home/home_server_setting_test.dart index 3278287..d751e85 100644 --- a/test/home/home_server_setting_test.dart +++ b/test/home/home_server_setting_test.dart @@ -4,7 +4,7 @@ import 'package:shared_preferences_platform_interface/in_memory_shared_preferenc import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; -import 'package:fruitshorts/pages/home/home_view.dart'; +import 'package:fruitshorts/pages/home/widgets/home_failure_view.dart'; void main() { setUp(() { diff --git a/test/home/home_startup_splash_test.dart b/test/home/home_startup_splash_test.dart index d5f4666..1ec70f5 100644 --- a/test/home/home_startup_splash_test.dart +++ b/test/home/home_startup_splash_test.dart @@ -123,7 +123,7 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('startup-splash')), findsNothing); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('home-grid')), findsOneWidget); }); } diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index 4b3c6cf..461134f 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -46,36 +46,18 @@ void main() { expect(find.byType(AppBottomNav), findsOneWidget); }); - testWidgets('should_start_home_on_immersive_feed', (tester) async { + testWidgets('should_start_home_on_explorer_grid', (tester) async { await _pumpApp(tester); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); - expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget); - expect(find.byKey(const Key('home-feed-black-background')), findsOneWidget); - expect(find.byType(PageView), findsOneWidget); - expect(find.byKey(const Key('home-browse-page')), findsNothing); - expect( - tester.widget(find.byKey(const Key('rail-like-count'))).data, - '41.9万', - ); - expect( - tester.widget(find.byKey(const Key('rail-save-count'))).data, - '1006', - ); - expect( - tester.widget(find.byKey(const Key('rail-comments-count'))).data, - '3.2万', - ); - expect(find.byKey(const Key('home-feed-refresh')), findsOneWidget); - expect(find.byKey(const Key('home-feed-danmaku')), findsNothing); - expect(find.byKey(const Key('home-feed-fullscreen')), findsNothing); + expect(find.byKey(const Key('home-grid')), findsOneWidget); + expect(find.byKey(const Key('home-grid-cards')), findsOneWidget); + expect(find.byType(PageView), findsNothing); expect( tester.widget(find.byType(Scaffold).first).backgroundColor, Colors.transparent, ); expect(find.byKey(const Key('home-page-gradient')), findsNothing); expect(_hasLightStatusBar(tester), isTrue); - expect(tester.widget(find.byType(PlayerPage)).homeMode, isTrue); }); testWidgets('should_keep_home_feed_size_when_keyboard_insets_change', ( @@ -83,7 +65,7 @@ void main() { ) async { addTearDown(tester.view.resetViewInsets); await _pumpApp(tester); - final feed = find.byKey(const Key('home-feed-black-background')); + final feed = find.byKey(const Key('home-grid')); final before = tester.getRect(feed); tester.view.viewInsets = const FakeViewPadding(bottom: 300); @@ -99,7 +81,7 @@ void main() { }); testWidgets('should_open_full_player_from_full_series_bar', (tester) async { - await _pumpApp(tester); + await _pumpApp(tester, recommend: true); await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.pumpAndSettle(); @@ -109,7 +91,7 @@ void main() { }); testWidgets('should_switch_to_anime_channel', (tester) async { - await _pumpApp(tester); + await _pumpApp(tester, recommend: true); await tester.tap(find.byKey(const Key('home-feed-tab-anime'))); await tester.pumpAndSettle(); @@ -126,7 +108,7 @@ void main() { tester, ) async { final catalog = _PendingAnimeCatalog(); - await _pumpApp(tester, catalog: catalog); + await _pumpApp(tester, catalog: catalog, recommend: true); for (var attempt = 0; attempt < 5 && catalog.animeCalls == 0; attempt++) { await tester.pump(); } @@ -151,7 +133,7 @@ void main() { testWidgets('should_show_empty_state_when_anime_has_no_dramas', ( tester, ) async { - await _pumpApp(tester, catalog: _EmptyAnimeCatalog()); + await _pumpApp(tester, catalog: _EmptyAnimeCatalog(), recommend: true); await tester.tap(find.byKey(const Key('home-feed-tab-anime'))); await tester.pumpAndSettle(); @@ -161,7 +143,7 @@ void main() { }); testWidgets('should_refresh_feed_without_blank_loading', (tester) async { - await _pumpApp(tester); + await _pumpApp(tester, recommend: true); await tester.tap(find.byKey(const Key('home-feed-refresh'))); await tester.pump(); @@ -173,7 +155,7 @@ void main() { }); testWidgets('should_keep_feed_rail_above_bottom_panels', (tester) async { - await _pumpApp(tester); + await _pumpApp(tester, recommend: true); final railTop = tester.getCenter(find.byKey(const Key('rail-save'))).dy; final railBottom = tester .getRect(find.byKey(const Key('rail-share'))) @@ -194,7 +176,7 @@ void main() { testWidgets('should_keep_feed_header_and_progress_after_clear', ( tester, ) async { - await _pumpApp(tester); + await _pumpApp(tester, recommend: true); final gesture = await tester.startGesture(const Offset(195, 300)); await tester.pump(const Duration(milliseconds: 700)); await tester.pump(); @@ -223,7 +205,7 @@ void main() { }); testWidgets('should_boost_feed_on_side_long_press', (tester) async { - await _pumpApp(tester); + await _pumpApp(tester, recommend: true); expect(find.byKey(const Key('home-feed-title')), findsOneWidget); final gesture = await tester.startGesture(const Offset(60, 300)); @@ -245,7 +227,7 @@ void main() { }); testWidgets('should_hide_exit_clear_button_while_boosting', (tester) async { - await _pumpApp(tester); + await _pumpApp(tester, recommend: true); final moreGesture = await tester.startGesture(const Offset(195, 300)); await tester.pump(const Duration(milliseconds: 700)); await tester.pump(); @@ -282,7 +264,7 @@ void main() { testWidgets('should_resume_playback_when_boosting_while_paused', ( tester, ) async { - await _pumpApp(tester); + await _pumpApp(tester, recommend: true); await tester.tap(find.byKey(const Key('home-feed-video-toggle'))); await tester.pump(); expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); @@ -302,7 +284,7 @@ void main() { }); testWidgets('should_open_more_sheet_on_center_long_press', (tester) async { - await _pumpApp(tester); + await _pumpApp(tester, recommend: true); final gesture = await tester.startGesture(const Offset(195, 300)); await tester.pump(const Duration(milliseconds: 700)); @@ -316,7 +298,7 @@ void main() { expect(find.byKey(const Key('home-feed-pip')), findsNothing); }); - testWidgets('should_keep_light_status_bar_after_leaving_feed_tab', ( + testWidgets('should_keep_light_status_bar_in_grid_and_recommend', ( tester, ) async { await _pumpApp(tester); @@ -325,12 +307,13 @@ void main() { await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-page-gradient')), findsOneWidget); + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('home-page-gradient')), findsNothing); expect(_hasLightStatusBar(tester), isTrue); }); testWidgets('should_switch_feed_drama_when_swiped_up', (tester) async { - await _pumpApp(tester); + await _pumpApp(tester, recommend: true); final before = tester .widget(find.byKey(const Key('home-feed-title'))) .data; @@ -345,7 +328,7 @@ void main() { }); testWidgets('should_keep_feed_header_fixed_when_swiping', (tester) async { - await _pumpApp(tester); + await _pumpApp(tester, recommend: true); final before = tester.getRect(find.byKey(const Key('home-feed-search'))); await tester.drag(find.byType(PageView).first, const Offset(0, -600)); @@ -360,7 +343,7 @@ void main() { testWidgets('should_resume_feed_after_returning_from_full_player', ( tester, ) async { - await _pumpApp(tester); + await _pumpApp(tester, recommend: true); await tester.tap(find.byKey(const Key('home-feed-video-toggle'))); await tester.pump(); expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); @@ -379,23 +362,21 @@ void main() { expect(find.byKey(const Key('home-feed-center-play')), findsNothing); }); - testWidgets('should_hide_feed_when_switching_to_theater', (tester) async { + testWidgets('should_switch_from_grid_to_recommend_player', (tester) async { await _pumpApp(tester); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('home-grid')), findsOneWidget); await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-feed-title')), findsNothing); - expect(find.byKey(const Key('discover-search')), findsOneWidget); + expect(find.byKey(const Key('home-grid')), findsNothing); + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); }); - testWidgets('should_open_theater_search_when_feed_search_is_tapped', ( - tester, - ) async { + testWidgets('should_open_discover_search_from_home_search', (tester) async { await _pumpApp(tester); - await tester.tap(find.byKey(const Key('home-feed-search'))); + await tester.tap(find.byKey(const Key('home-search-entry'))); await tester.pumpAndSettle(); final field = tester.widget( @@ -404,41 +385,12 @@ void main() { expect(field.focusNode?.hasFocus, isTrue); }); - testWidgets('should_not_render_theater_language_filters', (tester) async { - await _pumpApp(tester); - await tester.tap(find.byKey(const Key('tab-recommend'))); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('theater-language-chips')), findsNothing); - expect(find.byKey(const Key('theater-category-chips')), findsOneWidget); - expect(find.byKey(const Key('discover-search')), findsOneWidget); - expect(find.byKey(const Key('discover-refresh')), findsOneWidget); - expect( - find.byKey(const Key('home-drama-card-the-signal-demo')), - findsOneWidget, - ); - }); - - testWidgets('should_open_fullscreen_player_when_theater_card_is_tapped', ( + testWidgets('should_open_player_when_home_grid_card_is_tapped', ( tester, ) async { await _pumpApp(tester); - await tester.tap(find.byKey(const Key('tab-recommend'))); - await tester.pumpAndSettle(); - - final card = find.byKey(const Key('home-drama-card-the-signal-demo')); - await tester.scrollUntilVisible( - card, - 220, - scrollable: find - .descendant( - of: find.byKey(const Key('discover-refresh')), - matching: find.byType(Scrollable), - ) - .first, - ); + final card = find.byKey(const Key('home-feed-card-the-signal-demo')); await tester.ensureVisible(card); - await tester.pumpAndSettle(); await tester.tap(card); await tester.pumpAndSettle(); @@ -446,16 +398,17 @@ void main() { expect(player.homeMode, isFalse); }); - testWidgets('should_fit_home_feed_and_theater_on_320_width', (tester) async { + testWidgets('should_fit_home_grid_and_recommend_on_320_width', ( + tester, + ) async { await _pumpApp(tester, size: const Size(320, 568)); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('home-grid-cards')), findsOneWidget); expect(tester.takeException(), isNull); await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('theater-category-chips')), findsOneWidget); - expect(find.byKey(const Key('theater-language-chips')), findsNothing); + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); expect(tester.takeException(), isNull); }); } @@ -477,6 +430,7 @@ Future _pumpApp( WidgetTester tester, { Size size = const Size(390, 844), FakeCatalogRepository? catalog, + bool recommend = false, }) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; @@ -497,6 +451,10 @@ Future _pumpApp( ), ); await tester.pumpAndSettle(); + if (recommend) { + await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.pumpAndSettle(); + } } class _PendingAnimeCatalog extends FakeCatalogRepository { diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 53d0b23..93399f9 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -35,13 +35,13 @@ void main() { InMemorySharedPreferencesAsync.empty(); }); - testWidgets('should_open_fullscreen_overlay_from_discover_card', ( + testWidgets('should_open_fullscreen_overlay_from_recommend_feed', ( tester, ) async { await _pumpApp(tester); await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('home-drama-card-city-lights-demo'))); + await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.pumpAndSettle(); _expectFullscreenOverlay(tester); @@ -59,6 +59,8 @@ void main() { tester, ) async { await _pumpApp(tester); + await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.pumpAndSettle(); _expectPlayerNavigationBar(tester); await _openSeriesPlayer(tester); @@ -78,6 +80,8 @@ void main() { ); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); + await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.pump(); final ticketLoading = find.byKey(const Key('player-ticket-loading')); expect(ticketLoading, findsOneWidget); @@ -160,6 +164,8 @@ void main() { return player; }, ); + await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.pumpAndSettle(); final player = players.firstWhere((item) => item.playing); expect(player.playing, isTrue); @@ -215,6 +221,8 @@ void main() { }); await _pumpApp(tester); + await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('rail-comments'))); await tester.pumpAndSettle(); @@ -269,6 +277,8 @@ void main() { }); final catalog = FakeCatalogRepository(dramas: [_countingDrama]); await _pumpApp(tester, catalog: catalog); + await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.pumpAndSettle(); expect( tester.widget(find.byKey(const Key('rail-save-count'))).data, @@ -865,7 +875,7 @@ void main() { ); await _openSeriesPlayer( tester, - cardKey: const Key('home-drama-card-long-copy-demo'), + cardKey: const Key('home-feed-card-long-copy-demo'), ); expect(tester.takeException(), isNull); @@ -1004,7 +1014,7 @@ void main() { ); await _openSeriesPlayer( tester, - cardKey: const Key('home-drama-card-locked-demo'), + cardKey: const Key('home-feed-card-locked-demo'), ); expect(find.byKey(const Key('progress-loading')), findsNothing); expect(find.byKey(const Key('progress-loading-highlight')), findsNothing); @@ -1329,9 +1339,9 @@ Future _clearFromMore(WidgetTester tester) async { Future _openSeriesPlayer( WidgetTester tester, { - Key cardKey = const Key('home-drama-card-city-lights-demo'), + Key cardKey = const Key('home-feed-card-city-lights-demo'), }) async { - await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); final card = find.byKey(cardKey); await tester.scrollUntilVisible( @@ -1339,7 +1349,7 @@ Future _openSeriesPlayer( 220, scrollable: find .descendant( - of: find.byKey(const Key('discover-refresh')), + of: find.byKey(const Key('home-grid-refresh')), matching: find.byType(Scrollable), ) .first, diff --git a/test/widget_test.dart b/test/widget_test.dart index 16f61ed..b5c7eca 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -66,9 +66,10 @@ void main() { await tester.pumpAndSettle(); // 首页 feed 启动会创建首个播放器,先清掉再统计剧场播放器。 created.clear(); - await _openTheaterPlayer(tester); + await _openRecommendFullPlayer(tester); - expect(created, hasLength(2)); + expect(created, isNotEmpty); + final createdBeforeSwipe = created.length; expect(find.byType(CircularProgressIndicator), findsNothing); await tester.drag(find.byType(PageView).last, const Offset(0, -550)); await tester.pumpAndSettle(); @@ -77,8 +78,8 @@ void main() { tester.widget(find.byKey(const Key('player-episode-heading'))).data, contains('Episode 2'), ); - expect(created, hasLength(3)); - expect(created.toSet(), hasLength(3)); + expect(created.length, greaterThan(createdBeforeSwipe)); + expect(created, contains('the-signal-demo-2')); }); testWidgets('should_open_fullscreen_player_when_theater_card_is_tapped', ( @@ -86,7 +87,7 @@ void main() { ) async { await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); - await _openTheaterPlayer(tester); + await _openRecommendFullPlayer(tester); final player = tester.widget(find.byType(PlayerPage).last); expect(player.homeMode, isFalse); @@ -103,22 +104,22 @@ void main() { await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); - await _openTheaterPlayer(tester); + await _openRecommendFullPlayer(tester); expect(find.byType(PlayerPage), findsOneWidget); await tester.tap(find.byKey(const Key('player-back'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); expect( Navigator.of( - tester.element(find.byKey(const Key('home-all-dramas-grid'))), + tester.element(find.byKey(const Key('home-feed-title'))), ).canPop(), isFalse, ); await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('home-grid')), findsOneWidget); await tester.tap(find.byKey(const Key('tab-follow'))); await tester.pumpAndSettle(); @@ -183,7 +184,7 @@ void main() { await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); - await _openTheaterPlayer(tester); + await _openRecommendFullPlayer(tester); expect(find.byKey(const Key('progress-loading')), findsNothing); expect(find.byKey(const Key('progress-inactive')), findsOneWidget); @@ -197,9 +198,7 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('should_render_theater_filters_and_grid_on_same_route_tab', ( - tester, - ) async { + testWidgets('should_render_home_explorer_grid_on_home_tab', (tester) async { tester.view.physicalSize = const Size(360, 720); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); @@ -207,13 +206,11 @@ void main() { await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-recommend'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('discover-search')), findsOneWidget); - expect(find.byKey(const Key('theater-category-chips')), findsOneWidget); - expect(find.byKey(const Key('theater-language-chips')), findsNothing); - expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); + expect(find.byKey(const Key('home-grid')), findsOneWidget); + expect(find.byKey(const Key('home-grid-cards')), findsOneWidget); + expect(find.byKey(const Key('home-search-entry')), findsOneWidget); + expect(find.byKey(const Key('home-secondary-tags')), findsOneWidget); expect(tester.takeException(), isNull); }); @@ -244,7 +241,7 @@ void main() { ), ); await tester.pumpAndSettle(); - await _openTheaterPlayer(tester); + await _openRecommendFullPlayer(tester); await tester.tap(find.byKey(const Key('player-rail-comments'))); await tester.pumpAndSettle(); @@ -264,13 +261,13 @@ void main() { await tester.tap(find.byKey(const Key('player-rail-like'))); await tester.pumpAndSettle(); - expect(interactions.likeTargets, contains('EPISODE:city-lights-demo-1')); + expect(interactions.likeTargets, contains('EPISODE:the-signal-demo-1')); await tester.tap(find.byKey(const Key('player-rail-save'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('player-rail-share'))); await tester.pumpAndSettle(); - expect(sharedText, contains('City Lights & Velvet Nights')); + expect(sharedText, contains('The Signal')); expect(sharedText, contains('FruitShorts')); expect(tester.takeException(), isNull); }); @@ -284,7 +281,7 @@ void main() { await tester.pumpWidget(appUnderTest(interactionRepository: interactions)); await tester.pumpAndSettle(); - await _openTheaterPlayer(tester); + await _openRecommendFullPlayer(tester); await tester.tap(find.byKey(const Key('player-rail-like'))); await tester.pumpAndSettle(); @@ -300,7 +297,7 @@ void main() { await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); - await _openTheaterPlayer(tester); + await _openRecommendFullPlayer(tester); await tester.tap(find.byKey(const Key('player-rail-save'))); await tester.pumpAndSettle(); @@ -311,26 +308,13 @@ void main() { await tester.tap(find.byKey(const Key('mine-watchlist'))); await tester.pumpAndSettle(); - expect(find.text('City Lights & Velvet Nights'), findsWidgets); + expect(find.text('The Signal'), findsWidgets); }); } -Future _openTheaterPlayer(WidgetTester tester) async { +Future _openRecommendFullPlayer(WidgetTester tester) async { await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); - final card = find.byKey(const Key('home-drama-card-city-lights-demo')); - await tester.scrollUntilVisible( - card, - 220, - scrollable: find - .descendant( - of: find.byKey(const Key('discover-refresh')), - matching: find.byType(Scrollable), - ) - .first, - ); - await tester.ensureVisible(card); - await tester.pumpAndSettle(); - await tester.tap(card); + await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.pumpAndSettle(); } From 1bb227e083d54425e6729a2782560621de6ebe40 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 17:04:03 +0800 Subject: [PATCH 022/129] =?UTF-8?q?docs(home):=20=E5=9B=9E=E5=A1=AB?= =?UTF-8?q?=E9=A6=96=E9=A1=B5=E7=BD=91=E6=A0=BC=E6=94=B9=E7=89=88=E8=AE=B0?= =?UTF-8?q?=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 4c7d5a7..3c997c0 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -863,3 +863,43 @@ $ flutter test 解读:四等分、56 内容高度、安全区、26 图标、4 间距、精确色值和无分割线 均有测试覆盖;645 项测试全部通过。`pubspec.lock` 未提交,未操作真机。 + +## 2026-10-22 首页改为三列探索网格 + +- 分支:`feature/home-grid-layout` +- 基线:`develop` / `origin/develop` = `6dea9d4` +- 实现提交:`dd96792 feat(home): 首页改为三列探索网格` + +### 布局与数据 + +- 首页按参考图改为纯黑探索页:搜索框、皇冠、礼物入口、一级分类、 + 二级标签与三列封面网格。 +- `HomeFeedController` 继续使用 10 条游标分页;支持下拉刷新、触底加载、 + 空态与失败重试,没有全量拉取。 +- 一级固定项为精选/新剧/分类/榜单;古装/社群仅在 `/app/categories` + 名称匹配时显示。新剧/分类/榜单复用现有页面。 +- 二级标签按分类与题材可用性显示,筛选按钮复用 `FilterPage`。 +- 卡片封面 5:6、圆角 8、间距 8;包含真实热剧/新剧/漫剧角标、 + 播放量、最多两行标题与最多两个题材标签。 +- `Drama` 增加 `dramaType` 和可选 `createdAt`;卡片未下发上架时间时 + 从 Snowflake id 解析,不使用假数据。 +- 原 `PlayerPage(homeMode: true)` 保留并迁至推荐 tab,播放器文件与测试未删除。 +- 首页顶部中性色、卡片与描边 token 已沉淀到 `AppColors/AppDimens`, + 后续 P14 复用。 + +### 门禁命令与原始输出 + +```text +$ flutter analyze +Analyzing reelshort-bottom-nav... +No issues found! (ran in 2.7s) +``` + +```text +$ flutter test +02:32 +651: All tests passed! +``` + +解读:三列网格、分类顺序、动态入口隐藏、角标、卡片信息、分页大小、 +下一页追加和卡片进入播放器均有测试覆盖。`home_view.dart` 为 478 行, +其余新增组件均低于 500 行。`pubspec.lock` 未提交,未操作真机。 From 83129acae40f26cdf2079242838c9b0116ffcc73 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 17:07:09 +0800 Subject: [PATCH 023/129] =?UTF-8?q?feat(mine):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E6=88=91=E7=9A=84=E9=A1=B5=E6=B7=B1=E8=89=B2=E8=A7=86=E8=A7=89?= =?UTF-8?q?=E9=A3=8E=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/theme/app_colors.dart | 1 + lib/core/theme/app_dimens.dart | 6 ++ lib/pages/mine/mine_view.dart | 45 ++++++---- lib/pages/mine/widgets/mine_header.dart | 21 +++-- lib/pages/mine/widgets/mine_menu.dart | 113 +++++++++++++----------- test/mine/mine_preview_layout_test.dart | 104 ++++++++++++++++++++++ 6 files changed, 208 insertions(+), 82 deletions(-) diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 83b62b3..6d2e57e 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -70,6 +70,7 @@ abstract final class AppColors { static const contentBorder = Color(0x1AFFFFFF); /// 次级文字与列表 chevron。 + static const textPrimary = Color(0xFFFFFFFF); static const textSecondary = Color(0xFF8E8E93); /// 原生开屏与冷启动过渡底色。 diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index f783c56..64f20ab 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -87,4 +87,10 @@ abstract final class AppDimens { static const double homeSectionIndicator = 2; static const double homeIconSlot = 44; static const double homeHeaderIcon = 22; + + /// 「我的」页中性视觉。 + static const double mineCardRadius = 16; + static const double mineRowHeight = 52; + static const double minePageTitle = 21; + static const double mineSectionTitle = 15; } diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index 7c83f61..a3e91da 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -80,21 +80,25 @@ class _MyPageState extends State { Widget _page(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final content = SafeArea( - bottom: false, - child: Column( - children: [ - _topBar(l10n), - Expanded(child: _body(context, l10n)), - if (!embedded) _bottomNav(), - ], + final content = ColoredBox( + key: const Key('mine-page-surface'), + color: AppColors.appBackground, + child: SafeArea( + bottom: false, + child: Column( + children: [ + _topBar(l10n), + Expanded(child: _body(context, l10n)), + if (!embedded) _bottomNav(), + ], + ), ), ); return Theme( data: AppTheme.dark(), child: embedded ? content - : Scaffold(backgroundColor: AppColors.ink, body: content), + : Scaffold(backgroundColor: AppColors.appBackground, body: content), ); } @@ -111,9 +115,9 @@ class _MyPageState extends State { l10n.myTab, style: const TextStyle( fontFamily: 'Outfit', - color: AppColors.onSurface, - fontSize: AppDimens.pageTitle, - fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + fontSize: AppDimens.minePageTitle, + fontWeight: FontWeight.w700, ), ), ), @@ -280,8 +284,10 @@ class _LanguageSheet extends StatelessWidget { ]; return Material( key: const Key('mine-language-sheet'), - color: AppColors.card, - borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + color: AppColors.contentCard, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(AppDimens.mineCardRadius), + ), clipBehavior: Clip.antiAlias, child: SafeArea( top: false, @@ -297,9 +303,9 @@ class _LanguageSheet extends StatelessWidget { child: Text( l10n.language, style: const TextStyle( - color: AppColors.onSurface, + color: AppColors.textPrimary, fontSize: 20, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, ), ), ), @@ -316,7 +322,10 @@ class _LanguageSheet extends StatelessWidget { contentPadding: const EdgeInsets.symmetric(horizontal: 4), title: Text(option.$3), trailing: selected?.languageCode == option.$1.languageCode - ? const Icon(Icons.check_rounded, color: AppColors.blue) + ? const Icon( + Icons.check_rounded, + color: AppColors.textPrimary, + ) : null, onTap: () => Navigator.pop(context, option.$1), ), @@ -351,7 +360,7 @@ class _MinePlaceholderPage extends StatelessWidget { child: Text( l10n.comingSoon, style: const TextStyle( - color: AppColors.muted, + color: AppColors.textSecondary, fontSize: AppDimens.body, ), ), diff --git a/lib/pages/mine/widgets/mine_header.dart b/lib/pages/mine/widgets/mine_header.dart index 9b80dbf..91b8bd2 100644 --- a/lib/pages/mine/widgets/mine_header.dart +++ b/lib/pages/mine/widgets/mine_header.dart @@ -6,7 +6,6 @@ import 'package:provider/provider.dart'; import '../../../core/data/services/auth_controller.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; -import '../../../core/theme/app_theme.dart'; import '../../../core/widgets/remote_image.dart'; import '../../../l10n/app_localizations.dart'; @@ -31,13 +30,13 @@ class MineHeader extends StatelessWidget { return Container( key: const Key('mine-user-row'), decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(AppDimens.cardRadius), - boxShadow: const [AppTheme.cardShadow], + color: AppColors.contentCard, + borderRadius: BorderRadius.circular(AppDimens.mineCardRadius), + border: Border.all(color: AppColors.contentBorder), ), child: Material( color: AppColors.transparent, - borderRadius: BorderRadius.circular(AppDimens.cardRadius), + borderRadius: BorderRadius.circular(AppDimens.mineCardRadius), clipBehavior: Clip.antiAlias, child: InkWell( onTap: onTap, @@ -50,7 +49,7 @@ class MineHeader extends StatelessWidget { Expanded(child: _labels(name, subtitle)), const Icon( Icons.chevron_right_rounded, - color: AppColors.subtle, + color: AppColors.textSecondary, size: 20, ), ], @@ -67,14 +66,14 @@ class MineHeader extends StatelessWidget { width: 64, height: 64, decoration: const BoxDecoration( - color: AppColors.badgePink, + color: AppColors.contentSurface, shape: BoxShape.circle, ), clipBehavior: Clip.antiAlias, child: path.isEmpty ? const Icon( Icons.person_rounded, - color: AppColors.badgePinkText, + color: AppColors.textPrimary, size: 30, ) : RemoteImage(path, fit: BoxFit.cover), @@ -91,9 +90,9 @@ class MineHeader extends StatelessWidget { overflow: TextOverflow.ellipsis, style: const TextStyle( fontFamily: 'Outfit', - color: AppColors.onSurface, + color: AppColors.textPrimary, fontSize: 18, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, ), ), const SizedBox(height: AppDimens.spaceXs), @@ -102,7 +101,7 @@ class MineHeader extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - color: AppColors.muted, + color: AppColors.textSecondary, fontSize: AppDimens.caption, ), ), diff --git a/lib/pages/mine/widgets/mine_menu.dart b/lib/pages/mine/widgets/mine_menu.dart index 7eab07a..b1dce67 100644 --- a/lib/pages/mine/widgets/mine_menu.dart +++ b/lib/pages/mine/widgets/mine_menu.dart @@ -4,7 +4,6 @@ import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; -import '../../../core/theme/app_theme.dart'; import '../../../l10n/app_localizations.dart'; import '../mine_types.dart'; @@ -22,12 +21,12 @@ class MineShortcutGrid extends StatelessWidget { Expanded( child: _ShortcutTile( key: const Key('mine-watchlist'), - icon: Icons.bookmark_rounded, + icon: Icons.bookmark_border_rounded, label: l10n.watchlist, onTap: () => onOpen(MineDestination.watchlist), ), ), - const SizedBox(width: 10), + const SizedBox(width: AppDimens.homeGridGap), Expanded( child: _ShortcutTile( key: const Key('mine-history'), @@ -36,11 +35,11 @@ class MineShortcutGrid extends StatelessWidget { onTap: () => onOpen(MineDestination.history), ), ), - const SizedBox(width: 10), + const SizedBox(width: AppDimens.homeGridGap), Expanded( child: _ShortcutTile( key: const Key('mine-downloads'), - icon: Icons.download_rounded, + icon: Icons.download_outlined, label: l10n.downloads, onTap: () => onOpen(MineDestination.downloads), ), @@ -66,13 +65,13 @@ class _ShortcutTile extends StatelessWidget { Widget build(BuildContext context) { return Container( decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(AppDimens.cardRadius), - boxShadow: const [AppTheme.cardShadow], + color: AppColors.contentCard, + borderRadius: BorderRadius.circular(AppDimens.mineCardRadius), + border: Border.all(color: AppColors.contentBorder), ), child: Material( color: AppColors.transparent, - borderRadius: BorderRadius.circular(AppDimens.cardRadius), + borderRadius: BorderRadius.circular(AppDimens.mineCardRadius), clipBehavior: Clip.antiAlias, child: InkWell( onTap: onTap, @@ -81,16 +80,16 @@ class _ShortcutTile extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(icon, color: AppColors.blue, size: 24), + Icon(icon, color: AppColors.textPrimary, size: 24), const SizedBox(height: AppDimens.spaceSm), Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - color: AppColors.onSurface, + color: AppColors.textPrimary, fontSize: AppDimens.caption, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, ), ), ], @@ -122,20 +121,16 @@ class MineWalletCard extends StatelessWidget { key: const Key('mine-wallet'), padding: const EdgeInsets.all(AppDimens.cardPadding), decoration: BoxDecoration( - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [AppColors.goldCard, AppColors.card], - ), - borderRadius: BorderRadius.circular(AppDimens.cardRadius), - boxShadow: const [AppTheme.cardShadow], + color: AppColors.contentCard, + borderRadius: BorderRadius.circular(AppDimens.mineCardRadius), + border: Border.all(color: AppColors.contentBorder), ), child: Row( children: [ const Icon( - Icons.monetization_on_rounded, - color: AppColors.gold, - size: 30, + Icons.toll_outlined, + color: AppColors.textSecondary, + size: 28, ), const SizedBox(width: AppDimens.spaceMd), Expanded( @@ -147,7 +142,7 @@ class MineWalletCard extends StatelessWidget { Text( l10n.coinBalance, style: const TextStyle( - color: AppColors.muted, + color: AppColors.textSecondary, fontSize: AppDimens.caption, ), ), @@ -155,9 +150,9 @@ class MineWalletCard extends StatelessWidget { Text( balance, style: const TextStyle( - color: AppColors.onSurface, + color: AppColors.textPrimary, fontSize: AppDimens.pageTitle, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, ), ), ], @@ -171,7 +166,9 @@ class MineWalletCard extends StatelessWidget { backgroundColor: AppColors.blue, foregroundColor: AppColors.onPrimary, minimumSize: const Size(64, AppDimens.buttonCompact), - shape: const StadiumBorder(), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppDimens.buttonRadius), + ), ), child: Text(l10n.topUpWallet), ), @@ -193,13 +190,13 @@ class MineVipCard extends StatelessWidget { return Container( key: const Key('mine-vip'), decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(AppDimens.cardRadius), - boxShadow: const [AppTheme.cardShadow], + color: AppColors.contentCard, + borderRadius: BorderRadius.circular(AppDimens.mineCardRadius), + border: Border.all(color: AppColors.contentBorder), ), child: Material( color: AppColors.transparent, - borderRadius: BorderRadius.circular(AppDimens.cardRadius), + borderRadius: BorderRadius.circular(AppDimens.mineCardRadius), clipBehavior: Clip.antiAlias, child: InkWell( onTap: onOpen, @@ -211,12 +208,12 @@ class MineVipCard extends StatelessWidget { width: AppDimens.buttonLarge, height: AppDimens.buttonLarge, decoration: const BoxDecoration( - color: AppColors.badgePink, + color: AppColors.contentSurface, shape: BoxShape.circle, ), child: const Icon( Icons.workspace_premium_rounded, - color: AppColors.badgePinkText, + color: AppColors.textPrimary, size: 22, ), ), @@ -228,9 +225,9 @@ class MineVipCard extends StatelessWidget { Text( l10n.vipMembership, style: const TextStyle( - color: AppColors.onSurface, - fontSize: AppDimens.listTitle, - fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + fontSize: AppDimens.mineSectionTitle, + fontWeight: FontWeight.w600, ), ), const SizedBox(height: 3), @@ -239,7 +236,7 @@ class MineVipCard extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - color: AppColors.muted, + color: AppColors.textSecondary, fontSize: AppDimens.caption, ), ), @@ -250,7 +247,7 @@ class MineVipCard extends StatelessWidget { l10n.joinVip, style: const TextStyle( color: AppColors.blue, - fontSize: AppDimens.caption, + fontSize: AppDimens.body, fontWeight: FontWeight.w700, ), ), @@ -282,13 +279,13 @@ class MineMenuList extends StatelessWidget { return Container( key: const Key('mine-menu'), decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(AppDimens.cardRadius), - boxShadow: const [AppTheme.cardShadow], + color: AppColors.contentCard, + borderRadius: BorderRadius.circular(AppDimens.mineCardRadius), + border: Border.all(color: AppColors.contentBorder), ), child: Material( color: AppColors.transparent, - borderRadius: BorderRadius.circular(AppDimens.cardRadius), + borderRadius: BorderRadius.circular(AppDimens.mineCardRadius), clipBehavior: Clip.antiAlias, child: Column( children: [ @@ -338,18 +335,19 @@ class MineMenuList extends StatelessWidget { final l10n = AppLocalizations.of(context)!; return ListTile( key: const Key('mine-language'), - minTileHeight: 48, + minTileHeight: AppDimens.mineRowHeight, contentPadding: const EdgeInsets.symmetric(horizontal: 14), leading: const Icon( Icons.language_rounded, - color: AppColors.blue, + color: AppColors.textSecondary, size: 20, ), title: Text( l10n.language, style: const TextStyle( - color: AppColors.onSurface, - fontSize: AppDimens.body, + color: AppColors.textPrimary, + fontSize: AppDimens.listTitle, + fontWeight: FontWeight.w500, ), ), trailing: Row( @@ -357,12 +355,15 @@ class MineMenuList extends StatelessWidget { children: [ Text( languageLabel, - style: const TextStyle(color: AppColors.muted, fontSize: 11), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: AppDimens.caption, + ), ), const SizedBox(width: 4), const Icon( Icons.chevron_right_rounded, - color: AppColors.subtle, + color: AppColors.textSecondary, size: 18, ), ], @@ -379,19 +380,20 @@ class MineMenuList extends StatelessWidget { }) { return ListTile( key: key, - minTileHeight: 48, + minTileHeight: AppDimens.mineRowHeight, contentPadding: const EdgeInsets.symmetric(horizontal: 14), - leading: Icon(icon, color: AppColors.blue, size: 20), + leading: Icon(icon, color: AppColors.textSecondary, size: 20), title: Text( title, style: const TextStyle( - color: AppColors.onSurface, - fontSize: AppDimens.body, + color: AppColors.textPrimary, + fontSize: AppDimens.listTitle, + fontWeight: FontWeight.w500, ), ), trailing: const Icon( Icons.chevron_right_rounded, - color: AppColors.subtle, + color: AppColors.textSecondary, size: 18, ), onTap: () => onOpen(destination), @@ -399,6 +401,11 @@ class MineMenuList extends StatelessWidget { } Widget _divider() { - return const Divider(height: 1, indent: 14, endIndent: 14); + return const Divider( + height: 1, + indent: 14, + endIndent: 14, + color: AppColors.contentSurface, + ); } } diff --git a/test/mine/mine_preview_layout_test.dart b/test/mine/mine_preview_layout_test.dart index 62eb05a..5def2ce 100644 --- a/test/mine/mine_preview_layout_test.dart +++ b/test/mine/mine_preview_layout_test.dart @@ -6,6 +6,8 @@ import 'package:fruitshorts/app.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/theme/app_colors.dart'; +import 'package:fruitshorts/core/theme/app_dimens.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'; @@ -69,6 +71,108 @@ void main() { expect(find.byKey(const Key('mine-library-tab-0')), findsNothing); expect(find.byKey(const Key('mine-library-tab-1')), findsNothing); expect(find.byKey(const Key('mine-library-tab-2')), findsNothing); + final surface = tester.widget( + find.byKey(const Key('mine-page-surface')), + ); + expect(surface.color, AppColors.appBackground); + final title = tester.widget( + find.descendant( + of: find.byKey(const Key('mine-page-surface')), + matching: find.text('My'), + ), + ); + expect(title.style?.color, AppColors.textPrimary); + expect(title.style?.fontSize, AppDimens.minePageTitle); + expect(title.style?.fontWeight, FontWeight.w700); + + for (final key in const [ + Key('mine-user-row'), + Key('mine-wallet'), + Key('mine-vip'), + Key('mine-menu'), + ]) { + final container = tester.widget(find.byKey(key)); + final decoration = container.decoration! as BoxDecoration; + expect(decoration.color, AppColors.contentCard); + expect( + decoration.borderRadius, + BorderRadius.circular(AppDimens.mineCardRadius), + ); + expect(decoration.gradient, isNull); + expect(decoration.boxShadow, isNull); + expect((decoration.border! as Border).top.color, AppColors.contentBorder); + } + + final shortcutIcon = tester.widget( + find.descendant( + of: find.byKey(const Key('mine-watchlist')), + matching: find.byType(Icon), + ), + ); + expect(shortcutIcon.size, 24); + expect(shortcutIcon.color, AppColors.textPrimary); + final shortcutKeys = [ + const Key('mine-watchlist'), + const Key('mine-history'), + const Key('mine-downloads'), + ]; + final shortcutWidths = shortcutKeys + .map((key) => tester.getSize(find.byKey(key)).width) + .toList(); + expect(shortcutWidths.toSet(), hasLength(1)); + final shortcutCard = tester.widget( + find + .descendant( + of: find.byKey(const Key('mine-watchlist')), + matching: find.byType(Container), + ) + .first, + ); + final shortcutDecoration = shortcutCard.decoration! as BoxDecoration; + expect(shortcutDecoration.color, AppColors.contentCard); + expect( + shortcutDecoration.borderRadius, + BorderRadius.circular(AppDimens.mineCardRadius), + ); + expect(shortcutDecoration.boxShadow, isNull); + final menuRows = find.descendant( + of: find.byKey(const Key('mine-menu')), + matching: find.byType(ListTile), + ); + expect(menuRows, findsNWidgets(6)); + for (final row in tester.widgetList(menuRows)) { + expect(row.minTileHeight, AppDimens.mineRowHeight); + } + final settingsRow = tester.widget( + find.byKey(const Key('mine-settings')), + ); + final settingsTitle = settingsRow.title! as Text; + expect(settingsTitle.style?.color, AppColors.textPrimary); + expect(settingsTitle.style?.fontSize, AppDimens.listTitle); + expect((settingsRow.trailing! as Icon).color, AppColors.textSecondary); + final recharge = tester.widget( + find.byKey(const Key('mine-wallet-recharge')), + ); + expect(recharge.style?.backgroundColor?.resolve(const {}), AppColors.blue); + expect( + tester.widget(find.text('Join VIP')).style?.color, + AppColors.blue, + ); + final dividers = tester.widgetList( + find.descendant( + of: find.byKey(const Key('mine-menu')), + matching: find.byType(Divider), + ), + ); + expect(dividers, hasLength(5)); + expect( + dividers.every((divider) => divider.color == AppColors.contentSurface), + isTrue, + ); expect(tester.takeException(), isNull); + + await tester.tap(find.byKey(const Key('mine-watchlist'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('favorites-page')), findsOneWidget); }); } From e40f4779c05d77318addc48eb030d320600891f0 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 17:07:27 +0800 Subject: [PATCH 024/129] =?UTF-8?q?docs(mine):=20=E5=9B=9E=E5=A1=AB?= =?UTF-8?q?=E6=88=91=E7=9A=84=E9=A1=B5=E8=A7=86=E8=A7=89=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 3c997c0..cd8f817 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -903,3 +903,37 @@ $ flutter test 解读:三列网格、分类顺序、动态入口隐藏、角标、卡片信息、分页大小、 下一页追加和卡片进入播放器均有测试覆盖。`home_view.dart` 为 478 行, 其余新增组件均低于 500 行。`pubspec.lock` 未提交,未操作真机。 + +## 2026-10-22 「我的」页视觉风格统一 + +- 分支:`feature/mine-visual-style` +- 基线:`develop` / `origin/develop` = `47bf645` +- 实现提交:`83129ac feat(mine): 统一我的页深色视觉风格` + +### Token 与改动 + +- 复用 P11 的 `appBackground/contentCard/contentSurface/contentBorder`, + 新增 `textPrimary/mineCardRadius/mineRowHeight/minePageTitle/mineSectionTitle`。 +- 页面底色改为纯黑,移除「我的」页的旧深蓝渐变;页头白色标题 21/w700。 +- 头像、三宫格、金币卡、会员卡、菜单统一为深灰 16 圆角、1px 极淡描边, + 无阴影、无渐变、无发光。 +- 主文字/昵称/金额使用 `#FFFFFF`,次级说明和 chevron 使用 `#8E8E93`。 +- 三宫格等宽,入口图标 24 线性;菜单行高 52、标题 15,分割线只存在行间。 +- 橙色仅保留在充值、加入 VIP 等主操作;头像与菜单图标不再使用粉色/橙色装饰。 +- 区块顺序、全部入口、跳转与交互逻辑保持不变。 + +### 门禁命令与原始输出 + +```text +$ flutter analyze +Analyzing reelshort-bottom-nav... +No issues found! +``` + +```text +$ flutter test +00:56 +651: All tests passed! +``` + +解读:背景、卡片 token、圆角/描边、无渐变阴影、三宫格等宽、菜单 52 行高、 +入口保留和收藏页跳转均有测试覆盖。`pubspec.lock` 未提交,未操作真机。 From f1b541895460cb5426292fa1df5e578f550cc5d0 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 17:23:22 +0800 Subject: [PATCH 025/129] =?UTF-8?q?feat(recommend):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=88=86=E5=8C=BA=E6=B5=81=E5=85=A8=E9=83=A8=E7=9F=AD=E5=89=A7?= =?UTF-8?q?=E4=B8=8E=E5=89=A7=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/services/home_feed_controller.dart | 2 +- lib/l10n/app_en.arb | 18 +- lib/l10n/app_id.arb | 18 +- lib/l10n/app_localizations.dart | 96 ++++ lib/l10n/app_localizations_en.dart | 48 ++ lib/l10n/app_localizations_id.dart | 50 ++ lib/l10n/app_localizations_zh.dart | 48 ++ lib/l10n/app_zh.arb | 18 +- lib/pages/home/home_view.dart | 136 +----- lib/pages/ranking/ranking_page.dart | 116 ++--- lib/pages/recommend/recommend_page.dart | 199 ++++++++ .../widgets/all_dramas_filter_grid.dart | 292 ++++++++++++ .../widgets/recommend_section_card.dart | 116 +++++ .../widgets/recommend_sections_view.dart | 209 +++++++++ test/discover/home_all_dramas_grid_test.dart | 72 --- test/home/home_grid_layout_test.dart | 2 +- test/home/home_nav_ui_test.dart | 2 +- test/home/home_preview_layout_test.dart | 19 +- test/home/k28_a_navigation_test.dart | 430 +++--------------- test/player/player_fullscreen_test.dart | 81 +--- test/recommend/recommend_sections_test.dart | 136 ++++++ test/widget_test.dart | 40 +- 22 files changed, 1426 insertions(+), 722 deletions(-) create mode 100644 lib/pages/recommend/recommend_page.dart create mode 100644 lib/pages/recommend/widgets/all_dramas_filter_grid.dart create mode 100644 lib/pages/recommend/widgets/recommend_section_card.dart create mode 100644 lib/pages/recommend/widgets/recommend_sections_view.dart create mode 100644 test/recommend/recommend_sections_test.dart diff --git a/lib/core/data/services/home_feed_controller.dart b/lib/core/data/services/home_feed_controller.dart index c85d681..7326983 100644 --- a/lib/core/data/services/home_feed_controller.dart +++ b/lib/core/data/services/home_feed_controller.dart @@ -18,7 +18,7 @@ class HomeFeedController extends ChangeNotifier { channel: _ChannelFeedState(), }; - static const int pageSize = 10; + static const int pageSize = 20; static const Duration defaultRequestTimeout = Duration(seconds: 10); final CatalogRepository _repository; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b4899d1..7d1c915 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -714,5 +714,21 @@ "homeBadgeNew": "New", "homeBadgeAnime": "Anime", "homeGridFilterEmpty": "No dramas match these filters", - "homeGridClearFilter": "Clear filters" + "homeGridClearFilter": "Clear filters", + "recommendTopTab": "Recommend", + "playlistTopTab": "Playlists", + "allDramasTopTab": "All Dramas", + "weeklyHitsTitle": "This Week's Hits", + "weeklyHitsSubtitle": "Binge the hottest stories of the week!", + "weeklyNewTitle": "New This Week", + "weeklyNewSubtitle": "Fresh releases to start your next binge", + "costumeAwardsTitle": "Costume Drama Awards", + "costumeAwardsSubtitle": "Top-rated picks every costume fan needs", + "localFilter": "Local", + "asiaFilter": "Asia", + "westernFilter": "Western", + "subtitlesOnlyFilter": "Subtitles", + "dubbedFilter": "Dubbed", + "reversalFilter": "Comeback", + "modernFilter": "Modern" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 38bc11f..d1fdecb 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -671,5 +671,21 @@ "homeBadgeNew": "Baru", "homeBadgeAnime": "Anime", "homeGridFilterEmpty": "Tidak ada drama yang cocok dengan filter", - "homeGridClearFilter": "Hapus filter" + "homeGridClearFilter": "Hapus filter", + "recommendTopTab": "Rekomendasi", + "playlistTopTab": "Daftar Putar", + "allDramasTopTab": "Semua Drama", + "weeklyHitsTitle": "Hits Minggu Ini", + "weeklyHitsSubtitle": "Drama terpopuler minggu ini, tonton sekaligus!", + "weeklyNewTitle": "Drama Baru Minggu Ini", + "weeklyNewSubtitle": "Rilisan baru untuk memulai maraton berikutnya", + "costumeAwardsTitle": "Drama Kostum Terbaik", + "costumeAwardsSubtitle": "Pilihan terbaik yang wajib ditonton", + "localFilter": "Lokal", + "asiaFilter": "Asia", + "westernFilter": "Barat", + "subtitlesOnlyFilter": "Subtitle", + "dubbedFilter": "Dubbing", + "reversalFilter": "Balas Dendam", + "modernFilter": "Modern" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 776d1c0..137e51f 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3099,6 +3099,102 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Clear filters'** String get homeGridClearFilter; + + /// No description provided for @recommendTopTab. + /// + /// In en, this message translates to: + /// **'Recommend'** + String get recommendTopTab; + + /// No description provided for @playlistTopTab. + /// + /// In en, this message translates to: + /// **'Playlists'** + String get playlistTopTab; + + /// No description provided for @allDramasTopTab. + /// + /// In en, this message translates to: + /// **'All Dramas'** + String get allDramasTopTab; + + /// No description provided for @weeklyHitsTitle. + /// + /// In en, this message translates to: + /// **'This Week\'s Hits'** + String get weeklyHitsTitle; + + /// No description provided for @weeklyHitsSubtitle. + /// + /// In en, this message translates to: + /// **'Binge the hottest stories of the week!'** + String get weeklyHitsSubtitle; + + /// No description provided for @weeklyNewTitle. + /// + /// In en, this message translates to: + /// **'New This Week'** + String get weeklyNewTitle; + + /// No description provided for @weeklyNewSubtitle. + /// + /// In en, this message translates to: + /// **'Fresh releases to start your next binge'** + String get weeklyNewSubtitle; + + /// No description provided for @costumeAwardsTitle. + /// + /// In en, this message translates to: + /// **'Costume Drama Awards'** + String get costumeAwardsTitle; + + /// No description provided for @costumeAwardsSubtitle. + /// + /// In en, this message translates to: + /// **'Top-rated picks every costume fan needs'** + String get costumeAwardsSubtitle; + + /// No description provided for @localFilter. + /// + /// In en, this message translates to: + /// **'Local'** + String get localFilter; + + /// No description provided for @asiaFilter. + /// + /// In en, this message translates to: + /// **'Asia'** + String get asiaFilter; + + /// No description provided for @westernFilter. + /// + /// In en, this message translates to: + /// **'Western'** + String get westernFilter; + + /// No description provided for @subtitlesOnlyFilter. + /// + /// In en, this message translates to: + /// **'Subtitles'** + String get subtitlesOnlyFilter; + + /// No description provided for @dubbedFilter. + /// + /// In en, this message translates to: + /// **'Dubbed'** + String get dubbedFilter; + + /// No description provided for @reversalFilter. + /// + /// In en, this message translates to: + /// **'Comeback'** + String get reversalFilter; + + /// No description provided for @modernFilter. + /// + /// In en, this message translates to: + /// **'Modern'** + String get modernFilter; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 800a55f..1e95bd6 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1616,4 +1616,52 @@ class AppLocalizationsEn extends AppLocalizations { @override String get homeGridClearFilter => 'Clear filters'; + + @override + String get recommendTopTab => 'Recommend'; + + @override + String get playlistTopTab => 'Playlists'; + + @override + String get allDramasTopTab => 'All Dramas'; + + @override + String get weeklyHitsTitle => 'This Week\'s Hits'; + + @override + String get weeklyHitsSubtitle => 'Binge the hottest stories of the week!'; + + @override + String get weeklyNewTitle => 'New This Week'; + + @override + String get weeklyNewSubtitle => 'Fresh releases to start your next binge'; + + @override + String get costumeAwardsTitle => 'Costume Drama Awards'; + + @override + String get costumeAwardsSubtitle => 'Top-rated picks every costume fan needs'; + + @override + String get localFilter => 'Local'; + + @override + String get asiaFilter => 'Asia'; + + @override + String get westernFilter => 'Western'; + + @override + String get subtitlesOnlyFilter => 'Subtitles'; + + @override + String get dubbedFilter => 'Dubbed'; + + @override + String get reversalFilter => 'Comeback'; + + @override + String get modernFilter => 'Modern'; } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 22e2caa..4d6038e 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1616,4 +1616,54 @@ class AppLocalizationsId extends AppLocalizations { @override String get homeGridClearFilter => 'Hapus filter'; + + @override + String get recommendTopTab => 'Rekomendasi'; + + @override + String get playlistTopTab => 'Daftar Putar'; + + @override + String get allDramasTopTab => 'Semua Drama'; + + @override + String get weeklyHitsTitle => 'Hits Minggu Ini'; + + @override + String get weeklyHitsSubtitle => + 'Drama terpopuler minggu ini, tonton sekaligus!'; + + @override + String get weeklyNewTitle => 'Drama Baru Minggu Ini'; + + @override + String get weeklyNewSubtitle => + 'Rilisan baru untuk memulai maraton berikutnya'; + + @override + String get costumeAwardsTitle => 'Drama Kostum Terbaik'; + + @override + String get costumeAwardsSubtitle => 'Pilihan terbaik yang wajib ditonton'; + + @override + String get localFilter => 'Lokal'; + + @override + String get asiaFilter => 'Asia'; + + @override + String get westernFilter => 'Barat'; + + @override + String get subtitlesOnlyFilter => 'Subtitle'; + + @override + String get dubbedFilter => 'Dubbing'; + + @override + String get reversalFilter => 'Balas Dendam'; + + @override + String get modernFilter => 'Modern'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 299c1ff..7d5ba02 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1592,4 +1592,52 @@ class AppLocalizationsZh extends AppLocalizations { @override String get homeGridClearFilter => '清除筛选'; + + @override + String get recommendTopTab => '推荐'; + + @override + String get playlistTopTab => '剧单'; + + @override + String get allDramasTopTab => '全部短剧'; + + @override + String get weeklyHitsTitle => '本週爆款推薦'; + + @override + String get weeklyHitsSubtitle => '本週話題熱劇,一次追完!'; + + @override + String get weeklyNewTitle => '本週精選新劇'; + + @override + String get weeklyNewSubtitle => '新劇來襲!追劇節奏啟動'; + + @override + String get costumeAwardsTitle => '古裝神劇大賞'; + + @override + String get costumeAwardsSubtitle => '口碑爆棚!古裝控必收片單'; + + @override + String get localFilter => '本地'; + + @override + String get asiaFilter => '亚洲'; + + @override + String get westernFilter => '欧美'; + + @override + String get subtitlesOnlyFilter => '仅字幕'; + + @override + String get dubbedFilter => '配音'; + + @override + String get reversalFilter => '逆袭'; + + @override + String get modernFilter => '现代'; } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index e8c2a0f..0385554 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -633,5 +633,21 @@ "homeBadgeNew": "新剧", "homeBadgeAnime": "漫剧", "homeGridFilterEmpty": "没有符合筛选条件的短剧", - "homeGridClearFilter": "清除筛选" + "homeGridClearFilter": "清除筛选", + "recommendTopTab": "推荐", + "playlistTopTab": "剧单", + "allDramasTopTab": "全部短剧", + "weeklyHitsTitle": "本週爆款推薦", + "weeklyHitsSubtitle": "本週話題熱劇,一次追完!", + "weeklyNewTitle": "本週精選新劇", + "weeklyNewSubtitle": "新劇來襲!追劇節奏啟動", + "costumeAwardsTitle": "古裝神劇大賞", + "costumeAwardsSubtitle": "口碑爆棚!古裝控必收片單", + "localFilter": "本地", + "asiaFilter": "亚洲", + "westernFilter": "欧美", + "subtitlesOnlyFilter": "仅字幕", + "dubbedFilter": "配音", + "reversalFilter": "逆袭", + "modernFilter": "现代" } diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index d153f09..02e860f 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -8,7 +8,6 @@ import 'package:flutter/services.dart'; 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'; @@ -27,13 +26,11 @@ import '../favorites/favorites_page.dart'; import '../filter/filter_page.dart'; import '../mine/mine_types.dart'; import '../new_releases/new_releases_page.dart'; -import '../player/widgets/home_feed_header.dart'; -import '../player/player_view.dart'; import '../ranking/ranking_page.dart'; +import '../recommend/recommend_page.dart'; import '../mine/mine_view.dart'; import '../store/vip_page.dart'; import '../wallet/wallet_page.dart'; -import 'home_feed_channel.dart'; import 'home_binding.dart'; import 'home_logic.dart'; import 'widgets/home_feed_grid.dart'; @@ -209,15 +206,11 @@ class _HomeTabs extends StatefulWidget { class _HomeTabsState extends State<_HomeTabs> { late final HomeLogic logic; - List? _visibleDramas; - HomeFeedChannel? _visibleChannel; @override void initState() { super.initState(); logic = HomeBinding().create(widget.feedController.dramas); - _visibleDramas = widget.feedController.dramas; - _visibleChannel = widget.feedController.channel; widget.feedController.addListener(_onFeedChanged); } @@ -297,132 +290,13 @@ class _HomeTabsState extends State<_HomeTabs> { } Widget _recommend(BuildContext context) { - 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 == 1, - 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: () => _openSearch(context), - onDiscover: () => logic.select(1), - onMy: () => logic.select(3), - ), - if (switching) - Positioned.fill( - child: IgnorePointer( - key: const Key('home-feed-channel-loading'), - child: ColoredBox( - color: AppColors.controlSoftScrim, - child: Center( - child: SizedBox.square( - dimension: 24, - child: CircularProgressIndicator( - strokeWidth: 2.4, - color: AppColors.muted, - ), - ), - ), - ), - ), - ), - ], + return RecommendPage( + feedController: widget.feedController, + onSearch: () => _openSearch(context), + onOpenFilter: () => _pushPage(context, const FilterPage()), ); } - Widget _feedChannelPlaceholder(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - return Stack( - fit: StackFit.expand, - children: [ - const ColoredBox(color: AppColors.mediaBlack), - SafeArea( - bottom: false, - child: Column( - children: [ - HomeFeedHeader( - l10n: l10n, - channel: widget.feedController.channel, - onChannelSelected: _selectFeedChannel, - onRefresh: () => _refreshPlaceholder(context), - onSearch: () => _openSearch(context), - 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: AppColors.muted, - ), - ), - ) - : EmptyState( - icon: Icons.movie_outlined, - title: l10n.noStories, - actionLabel: l10n.retry, - onAction: () => - unawaited(widget.feedController.refreshCurrent()), - ), - ), - ], - ), - ), - ], - ); - } - - Future _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)); - } - void _openSearch(BuildContext context) { Navigator.of(context).push( MaterialPageRoute(builder: (_) => const DiscoverPage(focusSearch: true)), diff --git a/lib/pages/ranking/ranking_page.dart b/lib/pages/ranking/ranking_page.dart index 24c34a4..615e9b3 100644 --- a/lib/pages/ranking/ranking_page.dart +++ b/lib/pages/ranking/ranking_page.dart @@ -34,7 +34,9 @@ const Color _bronzeEnd = AppColors.bronzeDeep; /// 剧场榜单页:前三名横排领奖台,第 4 名起纵向列表。 class RankingPage extends StatefulWidget { - const RankingPage({super.key}); + const RankingPage({super.key, this.embedded = false}); + + final bool embedded; @override State createState() => _RankingPageState(); @@ -109,63 +111,71 @@ class _RankingPageState extends State { final top = pager.visibleItems; final podium = top.take(3).toList(); final rest = top.skip(3).toList(); + final content = NotificationListener( + onNotification: _onScroll, + child: AppPullToRefresh( + key: const Key('ranking-refresh'), + onRefresh: _refresh, + child: ListView( + key: const Key('ranking-list'), + controller: _scroll, + physics: AppPullToRefresh.physics, + padding: const EdgeInsets.only(top: 4, bottom: 24), + children: top.isEmpty + ? [ + SizedBox( + height: 420, + child: EmptyState( + icon: Icons.leaderboard_outlined, + title: l10n.noStories, + ), + ), + ] + : [ + if (podium.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: _Podium(podium: podium, l10n: l10n), + ), + if (rest.isNotEmpty) ...[ + const SizedBox(height: 16), + for (var index = 0; index < rest.length; index++) ...[ + if (index > 0) const SizedBox(height: _rowGap), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: _RankingRow( + index: index + 3, + drama: rest[index], + l10n: l10n, + ), + ), + ], + ], + PagedLoadFooter( + loading: pager.loading, + hasMore: pager.hasMore, + hasItems: pager.items.isNotEmpty, + loadMoreKey: const Key('ranking-load-more'), + noMoreKey: const Key('ranking-no-more'), + noMoreLabel: l10n.noMore, + ), + ], + ), + ), + ); + if (widget.embedded) { + return ColoredBox( + key: const Key('ranking-page'), + color: AppColors.appBackground, + child: SafeArea(bottom: false, child: content), + ); + } return PlainPageScaffold( pageKey: const Key('ranking-page'), backKey: const Key('ranking-back'), title: l10n.topCharts, subtitle: l10n.rankingSubtitle, - child: NotificationListener( - onNotification: _onScroll, - child: AppPullToRefresh( - key: const Key('ranking-refresh'), - onRefresh: _refresh, - child: ListView( - key: const Key('ranking-list'), - controller: _scroll, - physics: AppPullToRefresh.physics, - padding: const EdgeInsets.only(top: 4, bottom: 24), - children: top.isEmpty - ? [ - SizedBox( - height: 420, - child: EmptyState( - icon: Icons.leaderboard_outlined, - title: l10n.noStories, - ), - ), - ] - : [ - if (podium.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 14), - child: _Podium(podium: podium, l10n: l10n), - ), - if (rest.isNotEmpty) ...[ - const SizedBox(height: 16), - for (var index = 0; index < rest.length; index++) ...[ - if (index > 0) const SizedBox(height: _rowGap), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 14), - child: _RankingRow( - index: index + 3, - drama: rest[index], - l10n: l10n, - ), - ), - ], - ], - PagedLoadFooter( - loading: pager.loading, - hasMore: pager.hasMore, - hasItems: pager.items.isNotEmpty, - loadMoreKey: const Key('ranking-load-more'), - noMoreKey: const Key('ranking-no-more'), - noMoreLabel: l10n.noMore, - ), - ], - ), - ), - ), + child: content, ); } } diff --git a/lib/pages/recommend/recommend_page.dart b/lib/pages/recommend/recommend_page.dart new file mode 100644 index 0000000..8f3aad9 --- /dev/null +++ b/lib/pages/recommend/recommend_page.dart @@ -0,0 +1,199 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../core/data/models/drama.dart'; +import '../../core/data/services/catalog_controller.dart'; +import '../../core/data/services/category_controller.dart'; +import '../../core/data/services/download_controller.dart'; +import '../../core/data/services/home_feed_controller.dart'; +import '../../core/data/services/wallet_controller.dart'; +import '../../core/navigation/fade_page_route.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_dimens.dart'; +import '../../l10n/app_localizations.dart'; +import '../player/player_view.dart'; +import '../ranking/ranking_page.dart'; +import 'widgets/all_dramas_filter_grid.dart'; +import 'widgets/recommend_sections_view.dart'; + +enum RecommendInnerTab { feed, playlists, allDramas } + +class RecommendPage extends StatefulWidget { + const RecommendPage({ + super.key, + required this.feedController, + required this.onSearch, + required this.onOpenFilter, + }); + + final HomeFeedController feedController; + final VoidCallback onSearch; + final VoidCallback onOpenFilter; + + @override + State createState() => _RecommendPageState(); +} + +class _RecommendPageState extends State { + RecommendInnerTab _tab = RecommendInnerTab.feed; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final categories = context.watch(); + return ColoredBox( + key: const Key('recommend-page'), + color: AppColors.appBackground, + child: SafeArea( + bottom: false, + child: Column( + children: [ + _header(l10n), + Expanded( + child: IndexedStack( + index: _tab.index, + children: [ + RecommendSectionsView( + feedController: widget.feedController, + categories: categories, + onOpenDrama: _openDrama, + onMore: _handleMore, + ), + const RankingPage(embedded: true), + AllDramasFilterGrid( + feedController: widget.feedController, + onOpenDrama: _openDrama, + onOpenFilter: widget.onOpenFilter, + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _header(AppLocalizations l10n) { + final items = [ + (RecommendInnerTab.feed, l10n.recommendTopTab, 'recommend'), + (RecommendInnerTab.playlists, l10n.playlistTopTab, 'playlist'), + (RecommendInnerTab.allDramas, l10n.allDramasTopTab, 'all'), + ]; + return SizedBox( + key: const Key('recommend-top-nav'), + height: 56, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.pageHorizontal, + ), + child: Row( + children: [ + Expanded( + child: Row( + children: [ + for (final item in items) + Expanded( + child: _tabButton( + key: Key('recommend-tab-${item.$3}'), + label: item.$2, + selected: _tab == item.$1, + onTap: () => setState(() => _tab = item.$1), + ), + ), + ], + ), + ), + const SizedBox(width: AppDimens.spaceSm), + IconButton( + key: const Key('recommend-search'), + tooltip: l10n.search, + onPressed: widget.onSearch, + iconSize: 24, + color: AppColors.textPrimary, + icon: const Icon(Icons.search_rounded), + ), + ], + ), + ), + ); + } + + Widget _tabButton({ + required Key key, + required String label, + required bool selected, + required VoidCallback onTap, + }) { + return InkWell( + key: key, + onTap: onTap, + child: Padding( + padding: const EdgeInsets.only(right: AppDimens.spaceSm), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: selected + ? AppColors.textPrimary + : AppColors.textSecondary, + fontSize: AppDimens.listTitle, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + ), + ), + const SizedBox(height: 3), + Container( + width: selected ? 20 : 0, + height: 2, + decoration: BoxDecoration( + color: AppColors.textPrimary, + borderRadius: BorderRadius.circular(AppDimens.pillRadius), + ), + ), + ], + ), + ), + ); + } + + void _handleMore(RecommendSectionKind section) { + setState(() { + _tab = section == RecommendSectionKind.hits + ? RecommendInnerTab.playlists + : RecommendInnerTab.allDramas; + }); + } + + Future _openDrama(Drama drama) async { + final catalog = context.read(); + final wallet = context.read(); + final downloads = context.read(); + var playable = drama; + try { + playable = await catalog.requireDetail(drama); + } catch (_) { + // 卡片预览仍可直接进入播放器。 + } + if (!mounted) return; + await Navigator.of(context).push( + FadePageRoute( + builder: (_) => PlayerPage( + drama: playable, + initialIndex: 0, + homeMode: true, + feedDramas: [playable], + wallet: wallet, + downloads: downloads, + ), + ), + ); + } +} diff --git a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart new file mode 100644 index 0000000..554dfdd --- /dev/null +++ b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart @@ -0,0 +1,292 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/drama.dart'; +import '../../../core/data/services/home_feed_controller.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../core/widgets/app_pull_to_refresh.dart'; +import '../../../core/widgets/empty_state.dart'; +import '../../../core/widgets/paged_list_footer.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../home/widgets/home_drama_classification.dart'; +import '../../home/widgets/home_feed_card.dart'; + +const double _loadMoreThreshold = 240; +const List _modernTerms = ['现代', 'modern', 'moderno']; +const List _reversalTerms = ['逆袭', 'comeback', 'balas']; + +class AllDramasFilterGrid extends StatefulWidget { + const AllDramasFilterGrid({ + super.key, + required this.feedController, + required this.onOpenDrama, + required this.onOpenFilter, + }); + + final HomeFeedController feedController; + final ValueChanged onOpenDrama; + final VoidCallback onOpenFilter; + + @override + State createState() => _AllDramasFilterGridState(); +} + +class _AllDramasFilterGridState extends State { + String? _genre; + bool _newest = false; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final dramas = _filtered(); + return LayoutBuilder( + builder: (context, constraints) { + final itemWidth = + (constraints.maxWidth - + AppDimens.pageHorizontal * 2 - + AppDimens.homeGridGap * 2) / + 3; + final itemExtent = itemWidth * 6 / 5 + AppDimens.homeCardBodyExtent; + return NotificationListener( + onNotification: _onScroll, + child: AppPullToRefresh( + key: const Key('all-dramas-refresh'), + onRefresh: () async { + await widget.feedController.refreshCurrent(); + }, + child: CustomScrollView( + key: const Key('all-dramas-scroll'), + physics: AppPullToRefresh.physics, + slivers: [ + SliverToBoxAdapter(child: _filters(l10n)), + if (dramas.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: EmptyState( + key: const Key('all-dramas-empty'), + icon: Icons.movie_filter_outlined, + title: l10n.homeGridFilterEmpty, + actionLabel: l10n.homeGridClearFilter, + onAction: () => setState(() => _genre = null), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.pageHorizontal, + ), + sliver: SliverGrid( + key: const Key('all-dramas-grid'), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: AppDimens.homeGridGap, + mainAxisSpacing: AppDimens.homeGridGap, + mainAxisExtent: itemExtent, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => HomeFeedCard( + drama: dramas[index], + onTap: () => widget.onOpenDrama(dramas[index]), + ), + childCount: dramas.length, + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), + child: PagedListFooter( + loading: widget.feedController.loadingMore, + hasMore: widget.feedController.hasMore, + hasItems: dramas.isNotEmpty, + errorMessage: widget.feedController.lastError == null + ? null + : l10n.catalogError, + onRetry: widget.feedController.loadMore, + ), + ), + ), + ], + ), + ), + ); + }, + ); + } + + Widget _filters(AppLocalizations l10n) { + final genres = _availableGenres(l10n); + return Padding( + padding: const EdgeInsets.fromLTRB( + AppDimens.pageHorizontal, + AppDimens.spaceSm, + AppDimens.pageHorizontal, + AppDimens.spaceMd, + ), + child: Column( + key: const Key('all-dramas-filters'), + children: [ + _chipRow(0, [l10n.all]), + _chipRow(1, [l10n.all]), + _chipRow(2, [ + (id: null, label: l10n.all), + for (final genre in genres) (id: genre.id, label: genre.label), + ]), + Row( + key: const Key('all-filter-row-3'), + children: [ + Expanded( + child: Row( + children: [ + _chip( + key: const Key('all-sort-hot'), + label: l10n.hot, + selected: !_newest, + onTap: () => setState(() => _newest = false), + ), + const SizedBox(width: AppDimens.spaceSm), + _chip( + key: const Key('all-sort-new'), + label: l10n.newest, + selected: _newest, + onTap: () => setState(() => _newest = true), + ), + ], + ), + ), + IconButton( + key: const Key('all-dramas-filter-page'), + tooltip: l10n.filter, + onPressed: widget.onOpenFilter, + color: AppColors.textPrimary, + iconSize: AppDimens.tapTarget / 2, + icon: const Icon(Icons.filter_list_rounded), + ), + ], + ), + ], + ), + ); + } + + Widget _chipRow(int row, List options) { + return Padding( + key: Key('all-filter-row-$row'), + padding: const EdgeInsets.only(bottom: AppDimens.spaceSm), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (final option in options) ...[ + if (option is String) + _chip(label: option, selected: true, onTap: () {}), + if (option is ({String? id, String label})) + _chip( + label: option.label, + selected: _genre == option.id, + onTap: () => setState(() => _genre = option.id), + ), + const SizedBox(width: AppDimens.spaceSm), + ], + ], + ), + ), + ); + } + + Widget _chip({ + Key? key, + required String label, + required bool selected, + required VoidCallback onTap, + }) { + return InkWell( + key: key, + onTap: onTap, + borderRadius: BorderRadius.circular(AppDimens.pillRadius), + child: Container( + height: 32, + padding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceMd), + alignment: Alignment.center, + decoration: BoxDecoration( + color: selected ? AppColors.contentChip : AppColors.contentSurface, + borderRadius: BorderRadius.circular(AppDimens.pillRadius), + ), + child: Text( + label, + style: TextStyle( + color: selected ? AppColors.textPrimary : AppColors.textSecondary, + fontSize: AppDimens.caption, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + ), + ), + ), + ); + } + + List<({String id, String label})> _availableGenres(AppLocalizations l10n) { + final source = widget.feedController.dramas; + final definitions = [ + (id: 'reversal', label: l10n.reversalFilter, terms: _reversalTerms), + (id: 'uplifting', label: l10n.homeTagUplifting, terms: []), + (id: 'modern', label: l10n.modernFilter, terms: _modernTerms), + (id: 'sweet', label: l10n.homeTagSweet, terms: []), + (id: 'angst', label: l10n.homeTagAngst, terms: []), + ]; + return definitions + .where( + (definition) => source.any( + (drama) => _matchesGenre(drama, definition.id, definition.terms), + ), + ) + .map((definition) => (id: definition.id, label: definition.label)) + .toList(growable: false); + } + + List _filtered() { + final dramas = widget.feedController.dramas + .where((drama) { + return _genre == null || _matchesGenre(drama, _genre!, const []); + }) + .toList(growable: false); + if (_newest) { + return [...dramas]..sort((a, b) { + final left = publishedAtForDrama(a); + final right = publishedAtForDrama(b); + if (left == null && right == null) return b.id.compareTo(a.id); + if (left == null) return 1; + if (right == null) return -1; + return right.compareTo(left); + }); + } + return [...dramas]..sort((a, b) => b.viewCount.compareTo(a.viewCount)); + } + + bool _matchesGenre(Drama drama, String id, List terms) { + if (id == 'reversal' || + id == 'uplifting' || + id == 'sweet' || + id == 'angst') { + return matchesHomeTag(drama, id == 'reversal' ? 'uplifting' : id); + } + final text = '${drama.genre} ${drama.category}'.toLowerCase(); + return terms.any(text.contains); + } + + bool _onScroll(ScrollNotification notification) { + if (notification.depth != 0) return false; + final reachedBottom = + notification.metrics.extentAfter <= _loadMoreThreshold; + if (reachedBottom && + (notification is ScrollUpdateNotification || + notification is ScrollEndNotification || + notification is OverscrollNotification)) { + unawaited(widget.feedController.loadMore()); + } + return false; + } +} diff --git a/lib/pages/recommend/widgets/recommend_section_card.dart b/lib/pages/recommend/widgets/recommend_section_card.dart new file mode 100644 index 0000000..d2974a8 --- /dev/null +++ b/lib/pages/recommend/widgets/recommend_section_card.dart @@ -0,0 +1,116 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/drama.dart'; +import '../../../core/formatting/count_format.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../core/widgets/remote_image.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../home/widgets/home_drama_classification.dart'; + +class RecommendSectionCard extends StatelessWidget { + const RecommendSectionCard({ + super.key, + required this.sectionKey, + required this.drama, + required this.onTap, + }); + + final String sectionKey; + final Drama drama; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return SizedBox( + width: 122, + child: InkWell( + key: Key('recommend-card-$sectionKey-${drama.id}'), + borderRadius: BorderRadius.circular(AppDimens.homeCardRadius), + onTap: onTap, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(AppDimens.homeCardRadius), + child: AspectRatio( + aspectRatio: 3 / 4, + child: Stack( + fit: StackFit.expand, + children: [ + RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, + fallbackColor: AppColors.contentSurface, + ), + if (isHotDrama(drama)) + Positioned( + top: AppDimens.spaceXs, + left: AppDimens.spaceXs, + child: Container( + key: Key('recommend-hot-$sectionKey-${drama.id}'), + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppColors.badgeHot, + borderRadius: BorderRadius.circular( + AppDimens.chipRadius, + ), + ), + child: Text( + l10n.homeBadgeHot, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.homeCardBadge, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: AppDimens.spaceXs), + Row( + children: [ + const Icon( + Icons.play_arrow_rounded, + color: AppColors.textSecondary, + size: AppDimens.homeCardMeta, + ), + Text( + formatInteractionCount(drama.viewCount), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: AppDimens.homeCardMeta, + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + drama.title, + key: Key('recommend-title-$sectionKey-${drama.id}'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.homeCardTitle, + fontWeight: FontWeight.w600, + height: 1.25, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/recommend/widgets/recommend_sections_view.dart b/lib/pages/recommend/widgets/recommend_sections_view.dart new file mode 100644 index 0000000..704c9a1 --- /dev/null +++ b/lib/pages/recommend/widgets/recommend_sections_view.dart @@ -0,0 +1,209 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/content_category.dart'; +import '../../../core/data/models/drama.dart'; +import '../../../core/data/services/category_controller.dart'; +import '../../../core/data/services/home_feed_controller.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../core/widgets/app_pull_to_refresh.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../home/widgets/home_drama_classification.dart'; +import 'recommend_section_card.dart'; + +const List _costumeNames = ['古装', 'costume', 'kostum']; + +enum RecommendSectionKind { hits, newReleases, costume } + +class RecommendSectionsView extends StatelessWidget { + const RecommendSectionsView({ + super.key, + required this.feedController, + required this.categories, + required this.onOpenDrama, + required this.onMore, + }); + + final HomeFeedController feedController; + final CategoryController categories; + final ValueChanged onOpenDrama; + final ValueChanged onMore; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final source = feedController.dramas; + final hits = [...source] + ..sort((a, b) => b.viewCount.compareTo(a.viewCount)); + final newest = [...source] + ..sort((a, b) { + final left = publishedAtForDrama(a); + final right = publishedAtForDrama(b); + if (left == null && right == null) return b.id.compareTo(a.id); + if (left == null) return 1; + if (right == null) return -1; + return right.compareTo(left); + }); + return AppPullToRefresh( + key: const Key('recommend-sections-refresh'), + onRefresh: () async { + await Future.wait([feedController.refreshCurrent(), categories.load()]); + }, + child: ListView( + key: const Key('recommend-sections-scroll'), + physics: AppPullToRefresh.physics, + padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), + children: [ + _section( + context, + l10n, + key: 'hits', + title: l10n.weeklyHitsTitle, + subtitle: l10n.weeklyHitsSubtitle, + dramas: hits.take(10).toList(), + onMore: () => onMore(RecommendSectionKind.hits), + ), + const SizedBox(height: AppDimens.spaceXl), + _section( + context, + l10n, + key: 'new', + title: l10n.weeklyNewTitle, + subtitle: l10n.weeklyNewSubtitle, + dramas: newest.take(10).toList(), + onMore: () => onMore(RecommendSectionKind.newReleases), + ), + const SizedBox(height: AppDimens.spaceXl), + _section( + context, + l10n, + key: 'costume', + title: l10n.costumeAwardsTitle, + subtitle: l10n.costumeAwardsSubtitle, + dramas: _costumeDramas(source), + onMore: () => onMore(RecommendSectionKind.costume), + ), + ], + ), + ); + } + + List _costumeDramas(List source) { + final category = _costumeCategory(); + if (category != null) { + final fromCategory = categories.dramasOf(category.id); + if (fromCategory.isNotEmpty) return fromCategory.take(10).toList(); + } + return source + .where( + (drama) => + _containsCostume(drama.category) || _containsCostume(drama.genre), + ) + .take(10) + .toList(); + } + + ContentCategory? _costumeCategory() { + for (final category in categories.categories) { + if (_containsCostume(category.name)) return category; + } + return null; + } + + bool _containsCostume(String value) { + final normalized = value.toLowerCase(); + return _costumeNames.any(normalized.contains); + } + + Widget _section( + BuildContext context, + AppLocalizations l10n, { + required String key, + required String title, + required String subtitle, + required List dramas, + required VoidCallback onMore, + }) { + return Column( + key: Key('recommend-section-$key'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.pageHorizontal, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.sectionTitle, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: AppDimens.cardViews, + ), + ), + ], + ), + ), + TextButton( + key: Key('recommend-more-$key'), + onPressed: onMore, + style: TextButton.styleFrom( + foregroundColor: AppColors.textSecondary, + padding: const EdgeInsets.symmetric(horizontal: 4), + minimumSize: const Size(0, 30), + ), + child: Row( + children: [ + Text( + l10n.more, + style: const TextStyle( + fontSize: AppDimens.caption, + fontWeight: FontWeight.w500, + ), + ), + const Icon(Icons.chevron_right_rounded, size: 16), + ], + ), + ), + ], + ), + ), + const SizedBox(height: AppDimens.spaceMd), + SizedBox( + height: 218, + child: ListView.separated( + key: Key('recommend-row-$key'), + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.pageHorizontal, + ), + itemCount: dramas.length, + separatorBuilder: (_, _) => + const SizedBox(width: AppDimens.spaceMd), + itemBuilder: (context, index) => RecommendSectionCard( + sectionKey: key, + drama: dramas[index], + onTap: () => onOpenDrama(dramas[index]), + ), + ), + ), + ], + ); + } +} diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index 697b52e..36bbb7b 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -79,7 +79,6 @@ void main() { catalogRepository: catalog, categoryRepository: _GridCategoryRepository(), ); - await tester.tap(find.byKey(const Key('theater-category-7'))); await tester.pumpAndSettle(); @@ -187,38 +186,6 @@ 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( - 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), @@ -656,34 +623,6 @@ class _CountingTheaterCatalog extends _TheaterOnlyCatalog { } } -class _PagedTheaterCatalog extends _TheaterOnlyCatalog { - _PagedTheaterCatalog(this.pages); - - final List pages; - final List requestedCursors = []; - - int get calls => requestedCursors.length; - - @override - Future fetchFeed({ - List 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 fetchFeed({ @@ -703,14 +642,3 @@ class _FailingTheaterCatalog extends _TheaterOnlyCatalog { ); } } - -List _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', - ), -]; diff --git a/test/home/home_grid_layout_test.dart b/test/home/home_grid_layout_test.dart index 5ae8b09..e07119b 100644 --- a/test/home/home_grid_layout_test.dart +++ b/test/home/home_grid_layout_test.dart @@ -124,7 +124,7 @@ void main() { ); await tester.pumpAndSettle(); expect(repository.cursors, contains('page1')); - expect(repository.requestedSizes.every((size) => size <= 10), isTrue); + expect(repository.requestedSizes.every((size) => size <= 20), isTrue); await tester.scrollUntilVisible( find.byKey(const Key('home-feed-card-page2-1')), diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart index 3ba8edd..af25c79 100644 --- a/test/home/home_nav_ui_test.dart +++ b/test/home/home_nav_ui_test.dart @@ -213,7 +213,7 @@ void main() { await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('recommend-page')), findsOneWidget); await tester.tap(find.byKey(const Key('tab-follow'))); await tester.pumpAndSettle(); diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 7f36cd1..c5552ee 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -96,7 +96,9 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('should_keep_immersive_player_on_recommend_tab', (tester) async { + testWidgets('should_show_section_feed_and_open_player_from_recommend', ( + tester, + ) async { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); tester.view.physicalSize = const Size(390, 844); @@ -123,9 +125,18 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('home-grid')), findsNothing); - expect(find.byType(PageView), findsOneWidget); - expect(tester.widget(find.byType(PlayerPage)).homeMode, isTrue); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('recommend-page')), findsOneWidget); + expect(find.byKey(const Key('recommend-section-hits')), findsOneWidget); + expect(find.byType(PlayerPage), findsNothing); + + await tester.tap( + find.byKey(const Key('recommend-card-hits-the-signal-demo')), + ); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byType(PlayerPage).last).homeMode, + isTrue, + ); }); testWidgets('should_render_new_feed_structure_with_formatted_counts', ( diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index 461134f..006bac5 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -1,17 +1,13 @@ // 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/core/widgets/app_bottom_nav.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'; @@ -37,7 +33,6 @@ void main() { expect(find.byKey(key), findsOneWidget); } expect(find.byKey(const Key('tab-create')), findsNothing); - await tester.tap(find.byKey(const Key('tab-follow'))); await tester.pumpAndSettle(); @@ -52,21 +47,17 @@ void main() { expect(find.byKey(const Key('home-grid')), findsOneWidget); expect(find.byKey(const Key('home-grid-cards')), findsOneWidget); expect(find.byType(PageView), findsNothing); - expect( - tester.widget(find.byType(Scaffold).first).backgroundColor, - Colors.transparent, - ); expect(find.byKey(const Key('home-page-gradient')), findsNothing); expect(_hasLightStatusBar(tester), isTrue); }); - testWidgets('should_keep_home_feed_size_when_keyboard_insets_change', ( + testWidgets('should_keep_home_grid_when_keyboard_insets_change', ( tester, ) async { addTearDown(tester.view.resetViewInsets); await _pumpApp(tester); - final feed = find.byKey(const Key('home-grid')); - final before = tester.getRect(feed); + final grid = find.byKey(const Key('home-grid')); + final before = tester.getRect(grid); tester.view.viewInsets = const FakeViewPadding(bottom: 300); await tester.pump(); @@ -77,338 +68,83 @@ void main() { .resizeToAvoidBottomInset, isFalse, ); - expect(tester.getRect(feed), before); + expect(tester.getRect(grid), before); }); - testWidgets('should_open_full_player_from_full_series_bar', (tester) async { - await _pumpApp(tester, recommend: true); - - await tester.tap(find.byKey(const Key('home-feed-full-series'))); - await tester.pumpAndSettle(); - - final player = tester.widget(find.byType(PlayerPage).last); - expect(player.homeMode, isFalse); - }); - - testWidgets('should_switch_to_anime_channel', (tester) async { - await _pumpApp(tester, recommend: true); - - await tester.tap(find.byKey(const Key('home-feed-tab-anime'))); - await tester.pumpAndSettle(); - - expect(find.text('Coming soon'), findsNothing); - expect( - tester.widget(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', ( + testWidgets('should_render_recommend_sections_and_inner_top_nav', ( tester, ) async { - final catalog = _PendingAnimeCatalog(); - await _pumpApp(tester, catalog: catalog, recommend: true); - for (var attempt = 0; attempt < 5 && catalog.animeCalls == 0; attempt++) { - await tester.pump(); + await _pumpApp(tester); + await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('recommend-page')), findsOneWidget); + expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget); + expect(find.byKey(const Key('recommend-tab-playlist')), findsOneWidget); + expect(find.byKey(const Key('recommend-tab-all')), findsOneWidget); + expect(find.byKey(const Key('recommend-section-hits')), findsOneWidget); + expect(find.byKey(const Key('recommend-section-new')), findsOneWidget); + expect(find.byKey(const Key('recommend-section-costume')), findsOneWidget); + }); + + testWidgets('should_switch_playlist_and_all_dramas_in_recommend', ( + tester, + ) async { + await _pumpApp(tester); + await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('recommend-tab-playlist'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('ranking-page')), findsOneWidget); + expect(find.byType(AppBottomNav), findsOneWidget); + + await tester.tap(find.byKey(const Key('recommend-tab-all'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('all-dramas-grid')), findsOneWidget); + for (var row = 0; row < 4; row++) { + expect(find.byKey(Key('all-filter-row-$row')), findsOneWidget); } - expect(catalog.animeCalls, 1); - - await tester.tap(find.byKey(const Key('home-feed-tab-anime'))); - await tester.pump(); - - 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(find.byType(PlayerPage)).feedChannel, - HomeFeedChannel.anime, - ); }); - testWidgets('should_show_empty_state_when_anime_has_no_dramas', ( - tester, - ) async { - await _pumpApp(tester, catalog: _EmptyAnimeCatalog(), recommend: true); - - 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 { - await _pumpApp(tester, recommend: true); - - await tester.tap(find.byKey(const Key('home-feed-refresh'))); - await tester.pump(); - - expect(find.byType(CircularProgressIndicator), findsNothing); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); - }); - - testWidgets('should_keep_feed_rail_above_bottom_panels', (tester) async { - await _pumpApp(tester, recommend: true); - final railTop = tester.getCenter(find.byKey(const Key('rail-save'))).dy; - final railBottom = tester - .getRect(find.byKey(const Key('rail-share'))) - .bottom; - final seriesTop = tester - .getRect(find.byKey(const Key('home-feed-full-series'))) - .top; - final titleRight = tester - .getRect(find.byKey(const Key('home-feed-title'))) - .right; - final railLeft = tester.getRect(find.byKey(const Key('rail-save'))).left; - - expect(railTop, greaterThan(844 * 0.45)); - expect(seriesTop - railBottom, closeTo(12, 1)); - expect(titleRight, lessThanOrEqualTo(railLeft + 2)); - }); - - testWidgets('should_keep_feed_header_and_progress_after_clear', ( - tester, - ) async { - await _pumpApp(tester, recommend: true); - final gesture = await tester.startGesture(const Offset(195, 300)); - await tester.pump(const Duration(milliseconds: 700)); - await tester.pump(); - await gesture.up(); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('player-more-clear'))); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('home-feed-refresh')), findsOneWidget); - expect(find.byKey(const Key('home-feed-tab-recommend')), findsOneWidget); - expect(find.byKey(const Key('home-feed-search')), findsOneWidget); - expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget); - expect(find.byKey(const Key('home-feed-progress')), findsOneWidget); - expect(find.byKey(const Key('rail-save')), findsNothing); - expect(find.byKey(const Key('home-feed-title')), findsNothing); - final exit = tester.getRect( - find.byKey(const Key('home-feed-exit-clear-screen')), - ); - expect(exit.right, greaterThan(340)); - expect(exit.center.dy, greaterThan(600)); - - await tester.tap(find.byKey(const Key('home-feed-exit-clear-screen'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('rail-save')), findsOneWidget); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); - }); - - testWidgets('should_boost_feed_on_side_long_press', (tester) async { - await _pumpApp(tester, recommend: true); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); - - final gesture = await tester.startGesture(const Offset(60, 300)); - await tester.pump(const Duration(milliseconds: 700)); - await tester.pump(); - - expect(find.byKey(const Key('home-feed-speed-boost-hint')), findsOneWidget); - expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget); - expect(find.byKey(const Key('home-feed-title')), findsNothing); - expect(find.byKey(const Key('rail-save')), findsNothing); - expect(find.byKey(const Key('home-feed-search')), findsNothing); - - await gesture.up(); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-feed-speed-boost-hint')), findsNothing); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); - expect(find.byKey(const Key('rail-save')), findsOneWidget); - expect(find.byKey(const Key('home-feed-search')), findsOneWidget); - }); - - testWidgets('should_hide_exit_clear_button_while_boosting', (tester) async { - await _pumpApp(tester, recommend: true); - final moreGesture = await tester.startGesture(const Offset(195, 300)); - await tester.pump(const Duration(milliseconds: 700)); - await tester.pump(); - await moreGesture.up(); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('player-more-clear'))); - await tester.pumpAndSettle(); - expect( - find.byKey(const Key('home-feed-exit-clear-screen')), - findsOneWidget, - ); - - final boostGesture = await tester.startGesture(const Offset(60, 300)); - await tester.pump(const Duration(milliseconds: 700)); - await tester.pump(); - - expect(find.byKey(const Key('home-feed-speed-boost-hint')), findsOneWidget); - expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget); - expect(find.byKey(const Key('home-feed-progress')), findsOneWidget); - expect(find.byKey(const Key('home-feed-exit-clear-screen')), findsNothing); - expect(find.byKey(const Key('home-feed-search')), findsNothing); - - await boostGesture.up(); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('home-feed-speed-boost-hint')), findsNothing); - expect( - find.byKey(const Key('home-feed-exit-clear-screen')), - findsOneWidget, - ); - expect(find.byKey(const Key('home-feed-search')), findsOneWidget); - }); - - testWidgets('should_resume_playback_when_boosting_while_paused', ( - tester, - ) async { - await _pumpApp(tester, recommend: true); - await tester.tap(find.byKey(const Key('home-feed-video-toggle'))); - await tester.pump(); - expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); - - final gesture = await tester.startGesture(const Offset(60, 300)); - await tester.pump(const Duration(milliseconds: 700)); - await tester.pump(); - - expect(find.byKey(const Key('home-feed-speed-boost-hint')), findsOneWidget); - expect(find.textContaining('2.0'), findsWidgets); - expect(find.byKey(const Key('home-feed-center-play')), findsNothing); - - await gesture.up(); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-feed-speed-boost-hint')), findsNothing); - expect(find.byKey(const Key('home-feed-center-play')), findsNothing); - }); - - testWidgets('should_open_more_sheet_on_center_long_press', (tester) async { - await _pumpApp(tester, recommend: true); - - final gesture = await tester.startGesture(const Offset(195, 300)); - await tester.pump(const Duration(milliseconds: 700)); - await tester.pump(); - await gesture.up(); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('player-more-sheet')), findsOneWidget); - expect(find.byKey(const Key('home-feed-clear')), findsNothing); - expect(find.byKey(const Key('home-feed-download')), findsNothing); - expect(find.byKey(const Key('home-feed-pip')), findsNothing); - }); - - testWidgets('should_keep_light_status_bar_in_grid_and_recommend', ( - tester, - ) async { + testWidgets('should_open_search_from_home_and_recommend', (tester) async { await _pumpApp(tester); - expect(_hasLightStatusBar(tester), isTrue); - - await tester.tap(find.byKey(const Key('tab-recommend'))); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); - expect(find.byKey(const Key('home-page-gradient')), findsNothing); - expect(_hasLightStatusBar(tester), isTrue); - }); - - testWidgets('should_switch_feed_drama_when_swiped_up', (tester) async { - await _pumpApp(tester, recommend: true); - final before = tester - .widget(find.byKey(const Key('home-feed-title'))) - .data; - - await tester.drag(find.byType(PageView).first, const Offset(0, -600)); - await tester.pumpAndSettle(); - - final after = tester - .widget(find.byKey(const Key('home-feed-title'))) - .data; - expect(after, isNot(before)); - }); - - testWidgets('should_keep_feed_header_fixed_when_swiping', (tester) async { - await _pumpApp(tester, recommend: true); - final before = tester.getRect(find.byKey(const Key('home-feed-search'))); - - await tester.drag(find.byType(PageView).first, const Offset(0, -600)); - await tester.pumpAndSettle(); - - final after = tester.getRect(find.byKey(const Key('home-feed-search'))); - expect(after.top, closeTo(before.top, 0.5)); - expect(find.byKey(const Key('home-feed-tab-recommend')), findsOneWidget); - expect(find.byKey(const Key('home-feed-refresh')), findsOneWidget); - }); - - testWidgets('should_resume_feed_after_returning_from_full_player', ( - tester, - ) async { - await _pumpApp(tester, recommend: true); - await tester.tap(find.byKey(const Key('home-feed-video-toggle'))); - await tester.pump(); - expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); - - await tester.tap(find.byKey(const Key('home-feed-full-series'))); - await tester.pumpAndSettle(); - expect( - tester.widget(find.byType(PlayerPage).last).homeMode, - isFalse, - ); - - await tester.tap(find.byKey(const Key('player-back'))); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); - expect(find.byKey(const Key('home-feed-center-play')), findsNothing); - }); - - testWidgets('should_switch_from_grid_to_recommend_player', (tester) async { - await _pumpApp(tester); - expect(find.byKey(const Key('home-grid')), findsOneWidget); - - await tester.tap(find.byKey(const Key('tab-recommend'))); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('home-grid')), findsNothing); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); - }); - - testWidgets('should_open_discover_search_from_home_search', (tester) async { - await _pumpApp(tester); - await tester.tap(find.byKey(const Key('home-search-entry'))); await tester.pumpAndSettle(); - - final field = tester.widget( - find.byKey(const Key('discover-search')), + expect( + tester + .widget(find.byKey(const Key('discover-search'))) + .focusNode + ?.hasFocus, + isTrue, ); - expect(field.focusNode?.hasFocus, isTrue); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('recommend-search'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('discover-search')), findsOneWidget); }); - testWidgets('should_open_player_when_home_grid_card_is_tapped', ( - tester, - ) async { + testWidgets('should_open_player_from_home_grid_card', (tester) async { await _pumpApp(tester); - final card = find.byKey(const Key('home-feed-card-the-signal-demo')); - await tester.ensureVisible(card); - await tester.tap(card); + await tester.tap(find.byKey(const Key('home-feed-card-the-signal-demo'))); await tester.pumpAndSettle(); final player = tester.widget(find.byType(PlayerPage).last); expect(player.homeMode, isFalse); }); - testWidgets('should_fit_home_grid_and_recommend_on_320_width', ( - tester, - ) async { + testWidgets('should_fit_home_and_recommend_on_320_width', (tester) async { await _pumpApp(tester, size: const Size(320, 568)); - expect(find.byKey(const Key('home-grid-cards')), findsOneWidget); expect(tester.takeException(), isNull); await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('recommend-section-hits')), findsOneWidget); expect(tester.takeException(), isNull); }); } @@ -429,21 +165,19 @@ bool _hasLightStatusBar(WidgetTester tester) => tester Future _pumpApp( WidgetTester tester, { Size size = const Size(390, 844), - FakeCatalogRepository? catalog, - bool recommend = false, }) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - final repository = catalog ?? FakeCatalogRepository(); + final catalog = FakeCatalogRepository(); await tester.pumpWidget( LumaDramaApp( playerFactory: createWidgetPlayer, - catalogRepository: repository, - categoryRepository: FakeCategoryRepository(catalog: repository), + catalogRepository: catalog, + categoryRepository: FakeCategoryRepository(catalog: catalog), watchHistoryRepository: InMemoryWatchHistoryRepository(), - favoriteRepository: InMemoryFavoriteRepository(cards: repository.dramas), + favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), playbackRepository: FakePlaybackRepository(), commentRepository: FakeCommentRepository(), interactionRepository: FakeInteractionRepository(), @@ -451,52 +185,4 @@ Future _pumpApp( ), ); await tester.pumpAndSettle(); - if (recommend) { - await tester.tap(find.byKey(const Key('tab-recommend'))); - await tester.pumpAndSettle(); - } -} - -class _PendingAnimeCatalog extends FakeCatalogRepository { - final anime = Completer(); - int animeCalls = 0; - - @override - Future fetchFeed({ - List 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 fetchFeed({ - List 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, - ); - } } diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 93399f9..433f3e6 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -19,6 +19,7 @@ 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/pages/auth/auth_view.dart'; +import 'package:fruitshorts/pages/player/player_view.dart'; import 'package:fruitshorts/pages/player/widgets/player_center_toggle.dart'; import 'package:fruitshorts/pages/player/widgets/seek_progress_bar.dart'; @@ -41,17 +42,16 @@ void main() { await _pumpApp(tester); await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('home-feed-full-series'))); + await tester.tap( + find.byKey(const Key('recommend-card-hits-the-signal-demo')), + ); await tester.pumpAndSettle(); - _expectFullscreenOverlay(tester); + expect( + tester.widget(find.byType(PlayerPage).last).homeMode, + isTrue, + ); expect(find.byType(PageView), findsOneWidget); - await tester.tap(find.byKey(const Key('player-more'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('player-more-quality-AUTO')), findsOneWidget); - expect(find.byKey(const Key('player-more-offline')), findsOneWidget); - await tester.tapAt(const Offset(10, 10)); - await tester.pumpAndSettle(); expect(tester.takeException(), isNull); }); @@ -61,7 +61,6 @@ void main() { await _pumpApp(tester); await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); - _expectPlayerNavigationBar(tester); await _openSeriesPlayer(tester); _expectPlayerNavigationBar(tester); @@ -81,7 +80,12 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const Key('recommend-card-hits-the-signal-demo')), + ); await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); final ticketLoading = find.byKey(const Key('player-ticket-loading')); expect(ticketLoading, findsOneWidget); @@ -166,6 +170,10 @@ void main() { ); await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const Key('recommend-card-hits-the-signal-demo')), + ); + await tester.pumpAndSettle(); final player = players.firstWhere((item) => item.playing); expect(player.playing, isTrue); @@ -220,10 +228,8 @@ void main() { 'auth.user': '{"uid":"9","nickname":"Viewer","avatarUrl":""}', }); await _pumpApp(tester); - - await tester.tap(find.byKey(const Key('tab-recommend'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('rail-comments'))); + await _openSeriesPlayer(tester); + await tester.tap(find.byKey(const Key('player-rail-comments'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-comments-sheet')), findsOneWidget); @@ -266,34 +272,6 @@ void main() { ); }); - 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); - await tester.tap(find.byKey(const Key('tab-recommend'))); - await tester.pumpAndSettle(); - - expect( - tester.widget(find.byKey(const Key('rail-save-count'))).data, - '2', - ); - - await tester.tap(find.byKey(const Key('rail-save'))); - await tester.pumpAndSettle(); - - expect( - tester.widget(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); @@ -1392,27 +1370,6 @@ Future _pumpApp( if (settle) 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; diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart new file mode 100644 index 0000000..fd2e8f4 --- /dev/null +++ b/test/recommend/recommend_sections_test.dart @@ -0,0 +1,136 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/app.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/widgets/app_bottom_nav.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'; + +import '../support/fake_catalog.dart'; +import '../support/fake_category.dart'; +import '../support/fake_haptics.dart'; +import '../support/fake_interaction_repositories.dart'; +import '../support/widget_player.dart'; + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + testWidgets('should_render_three_recommend_sections_and_horizontal_cards', ( + tester, + ) async { + await _pumpRecommend(tester); + + expect(find.byKey(const Key('recommend-page')), findsOneWidget); + expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget); + expect(find.byKey(const Key('recommend-tab-playlist')), findsOneWidget); + expect(find.byKey(const Key('recommend-tab-all')), findsOneWidget); + expect(find.byKey(const Key('recommend-section-hits')), findsOneWidget); + expect(find.byKey(const Key('recommend-section-new')), findsOneWidget); + expect(find.byKey(const Key('recommend-section-costume')), findsOneWidget); + expect(find.byKey(const Key('recommend-more-hits')), findsOneWidget); + expect(find.byKey(const Key('recommend-more-new')), findsOneWidget); + expect(find.byKey(const Key('recommend-more-costume')), findsOneWidget); + expect(find.byKey(const Key('recommend-row-hits')), findsOneWidget); + expect( + find.byKey(const Key('recommend-card-hits-the-signal-demo')), + findsOneWidget, + ); + expect(find.text("This Week's Hits"), findsOneWidget); + expect(find.text('New This Week'), findsOneWidget); + expect(find.text('Costume Drama Awards'), findsOneWidget); + expect(find.byType(AppBottomNav), findsOneWidget); + }); + + testWidgets('should_switch_inner_tabs_without_losing_grid_filter_state', ( + tester, + ) async { + await _pumpRecommend(tester); + + await tester.tap(find.byKey(const Key('recommend-tab-all'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('all-dramas-grid')), findsOneWidget); + for (var row = 0; row < 4; row++) { + expect(find.byKey(Key('all-filter-row-$row')), findsOneWidget); + } + expect(find.byKey(const Key('all-filter-local')), findsNothing); + expect(find.byKey(const Key('all-filter-subtitles')), findsNothing); + await tester.tap(find.byKey(const Key('all-sort-new'))); + await tester.pump(); + expect(_textStyle(tester, 'New').fontWeight, FontWeight.w700); + + await tester.tap(find.byKey(const Key('recommend-tab-playlist'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('ranking-page')), findsOneWidget); + expect(find.byType(AppBottomNav), findsOneWidget); + + await tester.tap(find.byKey(const Key('recommend-tab-all'))); + await tester.pumpAndSettle(); + expect(_textStyle(tester, 'New').fontWeight, FontWeight.w700); + expect(find.byKey(const Key('all-dramas-grid')), findsOneWidget); + }); + + testWidgets('should_open_player_and_search_from_recommend_page', ( + tester, + ) async { + await _pumpRecommend(tester); + + await tester.tap( + find.byKey(const Key('recommend-card-hits-the-signal-demo')), + ); + await tester.pumpAndSettle(); + final player = tester.widget(find.byType(PlayerPage).last); + expect(player.homeMode, isTrue); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('recommend-search'))); + await tester.pumpAndSettle(); + final search = tester.widget( + find.byKey(const Key('discover-search')), + ); + expect(search.focusNode?.hasFocus, isTrue); + }); +} + +TextStyle _textStyle(WidgetTester tester, String text) { + return tester + .widget( + find.descendant( + of: find.byKey(const Key('all-sort-new')), + matching: find.text(text), + ), + ) + .style!; +} + +Future _pumpRecommend(WidgetTester tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final catalog = FakeCatalogRepository(); + await tester.pumpWidget( + LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: catalog, + categoryRepository: FakeCategoryRepository(catalog: catalog), + watchHistoryRepository: InMemoryWatchHistoryRepository(), + favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + haptics: FakeHaptics(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.pumpAndSettle(); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index b5c7eca..b6b0eba 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -66,7 +66,7 @@ void main() { await tester.pumpAndSettle(); // 首页 feed 启动会创建首个播放器,先清掉再统计剧场播放器。 created.clear(); - await _openRecommendFullPlayer(tester); + await _openFullPlayerFromHomeGrid(tester); expect(created, isNotEmpty); final createdBeforeSwipe = created.length; @@ -79,7 +79,7 @@ void main() { contains('Episode 2'), ); expect(created.length, greaterThan(createdBeforeSwipe)); - expect(created, contains('the-signal-demo-2')); + expect(created, contains('city-lights-demo-2')); }); testWidgets('should_open_fullscreen_player_when_theater_card_is_tapped', ( @@ -87,7 +87,7 @@ void main() { ) async { await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); - await _openRecommendFullPlayer(tester); + await _openFullPlayerFromHomeGrid(tester); final player = tester.widget(find.byType(PlayerPage).last); expect(player.homeMode, isFalse); @@ -104,23 +104,17 @@ void main() { await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); - await _openRecommendFullPlayer(tester); + await _openFullPlayerFromHomeGrid(tester); expect(find.byType(PlayerPage), findsOneWidget); await tester.tap(find.byKey(const Key('player-back'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('home-grid')), findsOneWidget); expect( - Navigator.of( - tester.element(find.byKey(const Key('home-feed-title'))), - ).canPop(), + Navigator.of(tester.element(find.byKey(const Key('home-grid')))).canPop(), isFalse, ); - await tester.binding.handlePopRoute(); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-grid')), findsOneWidget); - await tester.tap(find.byKey(const Key('tab-follow'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('favorites-page')), findsOneWidget); @@ -184,7 +178,7 @@ void main() { await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); - await _openRecommendFullPlayer(tester); + await _openFullPlayerFromHomeGrid(tester); expect(find.byKey(const Key('progress-loading')), findsNothing); expect(find.byKey(const Key('progress-inactive')), findsOneWidget); @@ -241,7 +235,7 @@ void main() { ), ); await tester.pumpAndSettle(); - await _openRecommendFullPlayer(tester); + await _openFullPlayerFromHomeGrid(tester); await tester.tap(find.byKey(const Key('player-rail-comments'))); await tester.pumpAndSettle(); @@ -261,13 +255,13 @@ void main() { await tester.tap(find.byKey(const Key('player-rail-like'))); await tester.pumpAndSettle(); - expect(interactions.likeTargets, contains('EPISODE:the-signal-demo-1')); + expect(interactions.likeTargets, contains('EPISODE:city-lights-demo-1')); await tester.tap(find.byKey(const Key('player-rail-save'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('player-rail-share'))); await tester.pumpAndSettle(); - expect(sharedText, contains('The Signal')); + expect(sharedText, contains('City Lights & Velvet Nights')); expect(sharedText, contains('FruitShorts')); expect(tester.takeException(), isNull); }); @@ -281,7 +275,7 @@ void main() { await tester.pumpWidget(appUnderTest(interactionRepository: interactions)); await tester.pumpAndSettle(); - await _openRecommendFullPlayer(tester); + await _openFullPlayerFromHomeGrid(tester); await tester.tap(find.byKey(const Key('player-rail-like'))); await tester.pumpAndSettle(); @@ -297,7 +291,7 @@ void main() { await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); - await _openRecommendFullPlayer(tester); + await _openFullPlayerFromHomeGrid(tester); await tester.tap(find.byKey(const Key('player-rail-save'))); await tester.pumpAndSettle(); @@ -308,13 +302,15 @@ void main() { await tester.tap(find.byKey(const Key('mine-watchlist'))); await tester.pumpAndSettle(); - expect(find.text('The Signal'), findsWidgets); + expect(find.text('City Lights & Velvet Nights'), findsWidgets); }); } -Future _openRecommendFullPlayer(WidgetTester tester) async { - await tester.tap(find.byKey(const Key('tab-recommend'))); +Future _openFullPlayerFromHomeGrid(WidgetTester tester) async { + await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('home-feed-full-series'))); + final card = find.byKey(const Key('home-feed-card-city-lights-demo')); + await tester.ensureVisible(card); + await tester.tap(card); await tester.pumpAndSettle(); } From f9aec827cf825d9f56488e6bbfe86329761d2a9b Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 17:23:36 +0800 Subject: [PATCH 026/129] =?UTF-8?q?docs(recommend):=20=E5=9B=9E=E5=A1=AB?= =?UTF-8?q?=E6=8E=A8=E8=8D=90=E9=A1=B5=E6=94=B9=E7=89=88=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index cd8f817..15aa2db 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -937,3 +937,29 @@ $ flutter test 解读:背景、卡片 token、圆角/描边、无渐变阴影、三宫格等宽、菜单 52 行高、 入口保留和收藏页跳转均有测试覆盖。`pubspec.lock` 未提交,未操作真机。 + +## 2026-10-22 推荐页分区流 / 全部短剧 / 剧单 + +- 分支:`feature/recommend-section-feed` +- 基线:`develop` / `origin/develop` = `0ef2438` +- 实现提交:`f1b5418 feat(recommend): 新增分区流全部短剧与剧单` + +新增固定顶部三 tab:推荐 / 剧单 / 全部短剧。推荐区包含本週爆款推薦、 +本週精選新劇、古裝神劇大賞三条横滑卡片,数据分别本地按播放量、上架时间和 +古装分类/题材排序过滤;点击卡片进入 `PlayerPage(homeMode: true)`。 +全部短剧提供四行可用筛选 chips、三列首页同款卡片和 20 条触底分页; +无后端字段的本地/地区/字幕/配音筛选项不展示。剧单内嵌可复用的 +`RankingPage(embedded: true)`,根底栏保持可见。搜索复用现有发现搜索页。 + +```text +$ flutter analyze +Analyzing reelshort-bottom-nav... +No issues found! (ran in 2.0s) + +$ flutter test +00:52 +638: All tests passed! +``` + +解读:推荐三分区、横滑卡片、四行筛选、三列网格、tab 状态保留、搜索、 +卡片播放和内嵌榜单均有测试覆盖;所有生效文件低于 500 行。 +`pubspec.lock` 未提交,未操作真机。 From 97e3f4fc47ad1cf1247f6b8bf665a3041ed8b4dc Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 17:32:09 +0800 Subject: [PATCH 027/129] =?UTF-8?q?feat(following):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E8=BF=BD=E5=89=A7=E4=B8=89=E6=A0=8F=E4=B8=8E=E5=8F=8C=E5=88=97?= =?UTF-8?q?=E7=BD=91=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 37 ++ lib/l10n/app_en.arb | 14 +- lib/l10n/app_id.arb | 14 +- lib/l10n/app_localizations.dart | 30 ++ lib/l10n/app_localizations_en.dart | 19 + lib/l10n/app_localizations_id.dart | 19 + lib/l10n/app_localizations_zh.dart | 19 + lib/l10n/app_zh.arb | 14 +- lib/pages/following/following_grid_card.dart | 151 ++++++ lib/pages/following/following_page.dart | 466 +++++++++++++++++++ lib/pages/home/home_view.dart | 8 +- test/following/following_page_test.dart | 210 +++++++++ test/home/home_nav_ui_test.dart | 7 +- test/home/k28_a_navigation_test.dart | 3 +- test/widget_test.dart | 2 +- 15 files changed, 1001 insertions(+), 12 deletions(-) create mode 100644 lib/pages/following/following_grid_card.dart create mode 100644 lib/pages/following/following_page.dart create mode 100644 test/following/following_page_test.dart diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 15aa2db..5971618 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -963,3 +963,40 @@ $ flutter test 解读:推荐三分区、横滑卡片、四行筛选、三列网格、tab 状态保留、搜索、 卡片播放和内嵌榜单均有测试覆盖;所有生效文件低于 500 行。 `pubspec.lock` 未提交,未操作真机。 + +## 2026-10-22 追剧页三 tab 与双列网格 + +- 分支:`feature/following-tabs-grid` +- 基线:`develop` / `origin/develop` = `73380ee` + +### 页面与数据 + +- 根导航追剧槽位改为内嵌 `FollowingPage`,不再 push 收藏路由,也不叠加 + 第二层底部导航。 +- 页面提供追剧 / 播放历史 / 预约三个子 tab,使用 `IndexedStack` 保留各自 + 滚动位置;切换 tab 会退出编辑态。 +- 追剧复用 `FavoriteController`,显示“第 N 集 / 共 M 集”,支持下拉刷新、 + 触底分页、加载/错误/无更多状态。 +- 播放历史复用 `WatchRecordsController`,优先使用分页接口的 + `dramaTitle/dramaCover/episodeNumber`,仅在标题缺失时补取详情;卡片展示 + 已看集数与可用进度条,同样支持刷新和分页。 +- 双列网格使用共享卡片:3:4 封面、播放量胶囊、两行标题、副信息与编辑态 + 减号。删除前二次确认,最后一条移除后自动退出编辑态。 +- 预约没有独立分页数据源,本轮保留真实空态;未登录沿用 `AuthGatePanel`。 + +### 门禁命令与原始输出 + +```text +$ flutter analyze +Analyzing reelshort-bottom-nav... +No issues found! (ran in 1.9s) +``` + +```text +$ flutter test +01:12 +643: All tests passed! +``` + +解读:三 tab、双列网格、后端历史元数据直出、编辑删除确认、最后一条自动 +退出编辑、未登录与空态均有测试覆盖;`following_page.dart` 低于 500 行。 +`pubspec.lock` 未提交,未操作真机。 diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 7d1c915..31d95d2 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -730,5 +730,17 @@ "subtitlesOnlyFilter": "Subtitles", "dubbedFilter": "Dubbed", "reversalFilter": "Comeback", - "modernFilter": "Modern" + "modernFilter": "Modern", + "edit": "Edit", + "done": "Done", + "favoriteEpisodeProgress": "Episode {episode} / {total} total", + "@favoriteEpisodeProgress": { + "placeholders": { + "episode": { "type": "int" }, + "total": { "type": "int" } + } + }, + "watchedToEpisode": "Watched to Episode {episode}", + "@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } }, + "confirmRemove": "Remove this item?" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index d1fdecb..78edd86 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -687,5 +687,17 @@ "subtitlesOnlyFilter": "Subtitle", "dubbedFilter": "Dubbing", "reversalFilter": "Balas Dendam", - "modernFilter": "Modern" + "modernFilter": "Modern", + "edit": "Edit", + "done": "Selesai", + "favoriteEpisodeProgress": "Episode {episode} / total {total}", + "@favoriteEpisodeProgress": { + "placeholders": { + "episode": { "type": "int" }, + "total": { "type": "int" } + } + }, + "watchedToEpisode": "Ditonton hingga Episode {episode}", + "@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } }, + "confirmRemove": "Hapus item ini?" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 137e51f..8170424 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3195,6 +3195,36 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Modern'** String get modernFilter; + + /// No description provided for @edit. + /// + /// In en, this message translates to: + /// **'Edit'** + String get edit; + + /// No description provided for @done. + /// + /// In en, this message translates to: + /// **'Done'** + String get done; + + /// No description provided for @favoriteEpisodeProgress. + /// + /// In en, this message translates to: + /// **'Episode {episode} / {total} total'** + String favoriteEpisodeProgress(int episode, int total); + + /// No description provided for @watchedToEpisode. + /// + /// In en, this message translates to: + /// **'Watched to Episode {episode}'** + String watchedToEpisode(int episode); + + /// No description provided for @confirmRemove. + /// + /// In en, this message translates to: + /// **'Remove this item?'** + String get confirmRemove; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 1e95bd6..5c4772a 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1664,4 +1664,23 @@ class AppLocalizationsEn extends AppLocalizations { @override String get modernFilter => 'Modern'; + + @override + String get edit => 'Edit'; + + @override + String get done => 'Done'; + + @override + String favoriteEpisodeProgress(int episode, int total) { + return 'Episode $episode / $total total'; + } + + @override + String watchedToEpisode(int episode) { + return 'Watched to Episode $episode'; + } + + @override + String get confirmRemove => 'Remove this item?'; } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 4d6038e..6dc7af2 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1666,4 +1666,23 @@ class AppLocalizationsId extends AppLocalizations { @override String get modernFilter => 'Modern'; + + @override + String get edit => 'Edit'; + + @override + String get done => 'Selesai'; + + @override + String favoriteEpisodeProgress(int episode, int total) { + return 'Episode $episode / total $total'; + } + + @override + String watchedToEpisode(int episode) { + return 'Ditonton hingga Episode $episode'; + } + + @override + String get confirmRemove => 'Hapus item ini?'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 7d5ba02..0d5aeb2 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1640,4 +1640,23 @@ class AppLocalizationsZh extends AppLocalizations { @override String get modernFilter => '现代'; + + @override + String get edit => '编辑'; + + @override + String get done => '完成'; + + @override + String favoriteEpisodeProgress(int episode, int total) { + return '第 $episode 集 / 共 $total 集'; + } + + @override + String watchedToEpisode(int episode) { + return '已看至 第 $episode 集'; + } + + @override + String get confirmRemove => '确定移除这条内容吗?'; } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 0385554..9e01e24 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -649,5 +649,17 @@ "subtitlesOnlyFilter": "仅字幕", "dubbedFilter": "配音", "reversalFilter": "逆袭", - "modernFilter": "现代" + "modernFilter": "现代", + "edit": "编辑", + "done": "完成", + "favoriteEpisodeProgress": "第 {episode} 集 / 共 {total} 集", + "@favoriteEpisodeProgress": { + "placeholders": { + "episode": { "type": "int" }, + "total": { "type": "int" } + } + }, + "watchedToEpisode": "已看至 第 {episode} 集", + "@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } }, + "confirmRemove": "确定移除这条内容吗?" } diff --git a/lib/pages/following/following_grid_card.dart b/lib/pages/following/following_grid_card.dart new file mode 100644 index 0000000..3bcb88d --- /dev/null +++ b/lib/pages/following/following_grid_card.dart @@ -0,0 +1,151 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../core/formatting/count_format.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_dimens.dart'; +import '../../core/widgets/remote_image.dart'; + +class FollowingGridCard extends StatelessWidget { + const FollowingGridCard({ + super.key, + required this.dramaId, + required this.title, + required this.posterUrl, + required this.viewCount, + required this.subtitle, + required this.onTap, + this.progress, + this.editing = false, + this.onRemove, + }); + + final String dramaId; + final String title; + final String posterUrl; + final int viewCount; + final String subtitle; + final VoidCallback onTap; + final double? progress; + final bool editing; + final VoidCallback? onRemove; + + @override + Widget build(BuildContext context) { + return InkWell( + key: Key('following-card-$dramaId'), + borderRadius: BorderRadius.circular(AppDimens.homeCardRadius), + onTap: onTap, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: ClipRRect( + borderRadius: BorderRadius.circular( + AppDimens.homeCardRadius, + ), + child: RemoteImage( + posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, + fallbackColor: AppColors.contentSurface, + ), + ), + ), + Positioned( + left: AppDimens.spaceXs, + bottom: AppDimens.spaceXs, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppColors.playerOverlayPill, + borderRadius: BorderRadius.circular(AppDimens.pillRadius), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.play_arrow_rounded, + color: AppColors.textPrimary, + size: AppDimens.homeCardMeta, + ), + Text( + formatInteractionCount(viewCount), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.homeCardMeta, + ), + ), + ], + ), + ), + ), + if (editing) + Positioned( + top: -6, + right: -6, + child: GestureDetector( + key: Key('following-remove-$dramaId'), + onTap: onRemove, + child: const CircleAvatar( + radius: 13, + backgroundColor: AppColors.contentChip, + child: Icon( + Icons.remove_rounded, + color: AppColors.textPrimary, + size: 18, + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: AppDimens.spaceXs), + Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.homeCardTitle, + fontWeight: FontWeight.w600, + ), + ), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: AppDimens.homeCardMeta, + ), + ), + if (progress != null) ...[ + const SizedBox(height: AppDimens.spaceXs), + ClipRRect( + borderRadius: BorderRadius.circular(AppDimens.progressRadius), + child: LinearProgressIndicator( + key: Key('following-progress-$dramaId'), + value: progress!.clamp(0.0, 1.0).toDouble(), + minHeight: 2, + backgroundColor: AppColors.inactiveTrack, + valueColor: const AlwaysStoppedAnimation( + AppColors.primary, + ), + ), + ), + ], + ], + ), + ); + } +} diff --git a/lib/pages/following/following_page.dart b/lib/pages/following/following_page.dart new file mode 100644 index 0000000..c201220 --- /dev/null +++ b/lib/pages/following/following_page.dart @@ -0,0 +1,466 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../core/data/models/watch_record.dart'; +import '../../core/data/repositories/catalog_repository.dart'; +import '../../core/data/repositories/watch_history_repository.dart'; +import '../../core/data/services/auth_controller.dart'; +import '../../core/data/services/favorite_controller.dart'; +import '../../core/data/services/watch_records_controller.dart'; +import '../../core/formatting/error_text.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_dimens.dart'; +import '../../core/widgets/app_pull_to_refresh.dart'; +import '../../core/widgets/auth_gate.dart'; +import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/paged_list_footer.dart'; +import '../../l10n/app_localizations.dart'; +import '../player/open_player.dart'; +import 'following_grid_card.dart'; + +enum FollowingTab { favorites, history, reserve } + +class FollowingPage extends StatefulWidget { + const FollowingPage({ + super.key, + required this.onGoHome, + required this.favoriteController, + }); + + final VoidCallback onGoHome; + final FavoriteController favoriteController; + + @override + State createState() => _FollowingPageState(); +} + +class _FollowingPageState extends State { + late final WatchRecordsController _history; + late final ScrollController _favoriteScroll; + late final ScrollController _historyScroll; + FollowingTab _tab = FollowingTab.favorites; + bool _editing = false; + + @override + void initState() { + super.initState(); + _favoriteScroll = ScrollController(); + _historyScroll = ScrollController(); + _history = WatchRecordsController( + history: context.read(), + catalog: context.read(), + ); + unawaited(_history.load()); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) unawaited(widget.favoriteController.load()); + }); + } + + @override + void dispose() { + _favoriteScroll.dispose(); + _historyScroll.dispose(); + _history.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final signedIn = context.watch().isSignedIn; + return ColoredBox( + key: const Key('following-page'), + color: AppColors.appBackground, + child: SafeArea( + bottom: false, + child: Column( + children: [ + _header(l10n), + Expanded( + child: !signedIn + ? const AuthGatePanel() + : IndexedStack( + index: _tab.index, + children: [ + _FavoritesGrid( + controller: widget.favoriteController, + editing: _editing, + scrollController: _favoriteScroll, + onListEmptied: _exitEditingIfEmpty, + ), + _HistoryGrid( + controller: _history, + editing: _editing, + scrollController: _historyScroll, + onListEmptied: _exitEditingIfEmpty, + ), + _reserveEmpty(l10n), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _header(AppLocalizations l10n) { + final tabs = [ + (FollowingTab.favorites, l10n.followingTab, 'favorites'), + (FollowingTab.history, l10n.watchHistory, 'history'), + (FollowingTab.reserve, l10n.reserveTitle, 'reserve'), + ]; + return SizedBox( + key: const Key('following-header'), + height: 52, + child: Row( + children: [ + for (final tab in tabs) + Expanded( + child: InkWell( + key: Key('following-tab-${tab.$3}'), + onTap: () => setState(() { + _tab = tab.$1; + _editing = false; + }), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + tab.$2, + maxLines: 1, + style: TextStyle( + color: _tab == tab.$1 + ? AppColors.textPrimary + : AppColors.textSecondary, + fontSize: 16, + fontWeight: _tab == tab.$1 + ? FontWeight.w700 + : FontWeight.w500, + ), + ), + const SizedBox(height: 3), + Container( + width: _tab == tab.$1 ? 20 : 0, + height: 2, + color: AppColors.textPrimary, + ), + ], + ), + ), + ), + IconButton( + key: const Key('following-edit'), + tooltip: _editing ? l10n.done : l10n.edit, + onPressed: () => setState(() => _editing = !_editing), + color: AppColors.textPrimary, + iconSize: 24, + icon: Icon(_editing ? Icons.check_rounded : Icons.edit_outlined), + ), + ], + ), + ); + } + + Widget _reserveEmpty(AppLocalizations l10n) { + return EmptyState( + key: const Key('following-reserve-empty'), + icon: Icons.notifications_none_rounded, + title: l10n.reserveEmptyTitle, + subtitle: l10n.reserveEmptyHint, + actionLabel: l10n.homeTab, + onAction: widget.onGoHome, + ); + } + + void _exitEditingIfEmpty() { + if (_editing && mounted) setState(() => _editing = false); + } +} + +class _FavoritesGrid extends StatelessWidget { + const _FavoritesGrid({ + required this.controller, + required this.editing, + required this.scrollController, + required this.onListEmptied, + }); + + final FavoriteController controller; + final bool editing; + final ScrollController scrollController; + final VoidCallback onListEmptied; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: controller, + builder: (context, _) { + final l10n = AppLocalizations.of(context)!; + return _PagedGrid( + key: const Key('following-favorites-grid'), + scrollController: scrollController, + loading: controller.loading, + loadingMore: controller.loadingMore, + hasMore: controller.hasMore, + hasItems: controller.cards.isNotEmpty, + errorMessage: controller.error == null + ? null + : errorText(l10n, controller.error, l10n.favoritesError), + loadMoreError: controller.loadMoreError == null + ? null + : errorText(l10n, controller.loadMoreError, l10n.favoritesError), + onRefresh: controller.load, + onLoadMore: controller.loadMore, + itemCount: controller.cards.length, + empty: EmptyState( + key: const Key('following-favorites-empty'), + icon: Icons.bookmark_border_rounded, + title: l10n.noFavorites, + subtitle: l10n.emptyHint, + ), + itemBuilder: (context, index) { + final drama = controller.cards[index]; + final episode = drama.episodes.isEmpty + ? 1 + : drama.episodes.first.number; + return FollowingGridCard( + dramaId: drama.id, + title: drama.title, + posterUrl: drama.posterUrl, + viewCount: drama.viewCount, + editing: editing, + subtitle: l10n.favoriteEpisodeProgress( + episode, + drama.episodeCount, + ), + onTap: () => unawaited(playDramaFromCatalog(context, drama)), + onRemove: () async { + if (await _confirmRemove(context)) { + await controller.remove(drama.id); + if (controller.cards.isEmpty) onListEmptied(); + } + }, + ); + }, + ); + }, + ); + } +} + +class _HistoryGrid extends StatelessWidget { + const _HistoryGrid({ + required this.controller, + required this.editing, + required this.scrollController, + required this.onListEmptied, + }); + + final WatchRecordsController controller; + final bool editing; + final ScrollController scrollController; + final VoidCallback onListEmptied; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: controller, + builder: (context, _) { + final l10n = AppLocalizations.of(context)!; + return _PagedGrid( + key: const Key('following-history-grid'), + scrollController: scrollController, + loading: controller.loading, + loadingMore: controller.loadingMore, + hasMore: controller.hasMore, + hasItems: controller.records.isNotEmpty, + errorMessage: controller.error == null + ? null + : errorText(l10n, controller.error, l10n.progressError), + loadMoreError: controller.loadMoreError == null + ? null + : errorText(l10n, controller.loadMoreError, l10n.progressError), + onRefresh: controller.load, + onLoadMore: controller.loadMore, + itemCount: controller.records.length, + empty: EmptyState( + key: const Key('following-history-empty'), + icon: Icons.history_rounded, + title: l10n.noWatchRecords, + subtitle: l10n.emptyHint, + ), + itemBuilder: (context, index) { + final record = controller.records[index]; + final title = record.displayTitle.isEmpty + ? l10n.episodeLabel(record.episodeNumber) + : record.displayTitle; + return FollowingGridCard( + dramaId: record.dramaId, + title: title, + posterUrl: record.displayCover, + viewCount: record.drama?.viewCount ?? 0, + progress: record.progress.fraction, + editing: editing, + subtitle: l10n.watchedToEpisode(record.episodeNumber), + onTap: () => unawaited(_openHistory(context, controller, record)), + onRemove: () async { + if (await _confirmRemove(context)) { + await controller.remove(record.dramaId); + if (controller.records.isEmpty) onListEmptied(); + } + }, + ); + }, + ); + }, + ); + } +} + +Future _openHistory( + BuildContext context, + WatchRecordsController controller, + WatchRecord record, +) async { + final drama = record.drama ?? await controller.detailForOpen(record.dramaId); + if (!context.mounted || drama == null) return; + await playDramaFromCatalog( + context, + drama, + episodeId: record.progress.episodeId, + ); +} + +Future _confirmRemove(BuildContext context) async { + final l10n = AppLocalizations.of(context)!; + return await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + content: Text(l10n.confirmRemove), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(l10n.remove), + ), + ], + ), + ) ?? + false; +} + +class _PagedGrid extends StatelessWidget { + const _PagedGrid({ + super.key, + required this.scrollController, + required this.loading, + required this.loadingMore, + required this.hasMore, + required this.hasItems, + required this.itemCount, + required this.itemBuilder, + required this.empty, + required this.onRefresh, + required this.onLoadMore, + this.errorMessage, + this.loadMoreError, + }); + + static const double _loadMoreThreshold = 240; + + final ScrollController scrollController; + final bool loading; + final bool loadingMore; + final bool hasMore; + final bool hasItems; + final int itemCount; + final IndexedWidgetBuilder itemBuilder; + final Widget empty; + final String? errorMessage; + final String? loadMoreError; + final Future Function() onRefresh; + final Future Function() onLoadMore; + + @override + Widget build(BuildContext context) { + if (loading && !hasItems) { + return const Center(child: CircularProgressIndicator()); + } + final emptyContent = errorMessage == null + ? empty + : EmptyState( + icon: Icons.cloud_off_rounded, + title: errorMessage!, + actionLabel: AppLocalizations.of(context)!.retry, + onAction: () => unawaited(onRefresh()), + ); + return NotificationListener( + onNotification: _onScroll, + child: AppPullToRefresh( + onRefresh: onRefresh, + child: CustomScrollView( + key: ValueKey('following-scroll-$runtimeType'), + controller: scrollController, + physics: AppPullToRefresh.physics, + slivers: [ + if (!hasItems) + SliverFillRemaining(hasScrollBody: false, child: emptyContent) + else ...[ + SliverPadding( + padding: const EdgeInsets.fromLTRB( + AppDimens.pageHorizontal, + AppDimens.spaceMd, + AppDimens.pageHorizontal, + AppDimens.spaceSm, + ), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: AppDimens.spaceMd, + mainAxisSpacing: AppDimens.spaceMd, + childAspectRatio: 0.56, + ), + delegate: SliverChildBuilderDelegate( + itemBuilder, + childCount: itemCount, + ), + ), + ), + SliverToBoxAdapter( + child: PagedListFooter( + loading: loadingMore, + hasMore: hasMore, + hasItems: hasItems, + errorMessage: loadMoreError, + onRetry: onLoadMore, + ), + ), + const SliverToBoxAdapter( + child: SizedBox(height: AppDimens.spaceXl), + ), + ], + ], + ), + ), + ); + } + + bool _onScroll(ScrollNotification notification) { + if (notification.depth != 0) return false; + if (notification.metrics.extentAfter > _loadMoreThreshold) return false; + if (notification is ScrollUpdateNotification || + notification is ScrollEndNotification || + notification is OverscrollNotification) { + unawaited(onLoadMore()); + } + return false; + } +} diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 02e860f..15aef75 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -12,6 +12,7 @@ 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'; +import '../../core/data/services/favorite_controller.dart'; import '../../core/data/services/wallet_controller.dart'; import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; @@ -22,7 +23,7 @@ import '../../core/widgets/catalog_endpoint_editor.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; import '../discover/discover_view.dart'; -import '../favorites/favorites_page.dart'; +import '../following/following_page.dart'; import '../filter/filter_page.dart'; import '../mine/mine_types.dart'; import '../new_releases/new_releases_page.dart'; @@ -267,7 +268,10 @@ class _HomeTabsState extends State<_HomeTabs> { children: [ _homeGrid(context), _recommend(context), - const FavoritesContent(key: Key('favorites-page'), showBack: false), + FollowingPage( + onGoHome: () => logic.select(0), + favoriteController: context.read(), + ), _my(context), ], ); diff --git a/test/following/following_page_test.dart b/test/following/following_page_test.dart new file mode 100644 index 0000000..a7bf640 --- /dev/null +++ b/test/following/following_page_test.dart @@ -0,0 +1,210 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.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/watch_progress.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:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import '../support/fake_catalog.dart'; +import '../support/fake_category.dart'; +import '../support/fake_interaction_repositories.dart'; +import '../support/widget_player.dart'; + +void main() { + testWidgets('should_render_following_tabs_grid_and_edit_actions', ( + tester, + ) async { + _signIn(); + final catalog = FakeCatalogRepository(); + await tester.pumpWidget( + _app( + catalog: catalog, + favorites: InMemoryFavoriteRepository( + cards: catalog.dramas.take(1).toList(), + saved: {catalog.dramas.first.id}, + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-follow'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('following-page')), findsOneWidget); + expect(find.byKey(const Key('following-tab-favorites')), findsOneWidget); + expect(find.byKey(const Key('following-tab-history')), findsOneWidget); + expect(find.byKey(const Key('following-tab-reserve')), findsOneWidget); + expect(find.byKey(const Key('following-favorites-grid')), findsOneWidget); + expect(find.byType(CustomScrollView), findsWidgets); + + await tester.tap(find.byKey(const Key('following-edit'))); + await tester.pumpAndSettle(); + expect( + find.byKey(const Key('following-remove-the-signal-demo')), + findsOneWidget, + ); + + await tester.tap(find.byKey(const Key('following-remove-the-signal-demo'))); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsOneWidget); + await tester.tap( + find.descendant( + of: find.byType(AlertDialog), + matching: find.byType(FilledButton), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const Key('following-remove-the-signal-demo')), + findsNothing, + ); + expect(find.byIcon(Icons.edit_outlined), findsOneWidget); + expect( + find.byKey(const Key('following-card-the-signal-demo')), + findsNothing, + ); + + await tester.tap(find.byKey(const Key('following-tab-history'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('following-history-empty')), findsOneWidget); + await tester.tap(find.byKey(const Key('following-tab-reserve'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('following-reserve-empty')), findsOneWidget); + }); + + testWidgets('should_show_auth_gate_when_signed_out', (tester) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + await tester.pumpWidget(_app(catalog: FakeCatalogRepository())); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-follow'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('auth-gate-panel')), findsOneWidget); + expect(find.byKey(const Key('following-favorites-grid')), findsNothing); + }); + + testWidgets('should_show_empty_favorites_and_history', (tester) async { + _signIn(); + await tester.pumpWidget(_app(catalog: FakeCatalogRepository())); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-follow'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('following-favorites-empty')), findsOneWidget); + await tester.tap(find.byKey(const Key('following-tab-history'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('following-history-empty')), findsOneWidget); + }); + + testWidgets('should_render_history_metadata_without_detail_fetch', ( + tester, + ) async { + _signIn(); + final catalog = _CountingCatalogRepository(); + await tester.pumpWidget( + _app( + catalog: catalog, + history: InMemoryWatchHistoryRepository( + seed: [ + WatchProgress( + dramaId: 'the-signal-demo', + episodeId: 'the-signal-demo-3', + dramaTitle: 'Server title', + dramaCover: '/files/server-cover.jpg', + episodeNumber: 3, + totalEpisodes: 8, + position: const Duration(seconds: 25), + duration: const Duration(seconds: 100), + ), + ], + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-follow'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('following-tab-history'))); + await tester.pumpAndSettle(); + + expect(find.text('Server title'), findsOneWidget); + expect(find.text('Watched to Episode 3'), findsOneWidget); + expect( + find.byKey(const Key('following-progress-the-signal-demo')), + findsOneWidget, + ); + expect(catalog.detailFetches, 0); + }); + + testWidgets('should_reset_edit_state_when_switching_tabs', (tester) async { + _signIn(); + final catalog = FakeCatalogRepository(); + await tester.pumpWidget( + _app( + catalog: catalog, + favorites: InMemoryFavoriteRepository( + cards: catalog.dramas, + saved: catalog.dramas.map((drama) => drama.id).toSet(), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-follow'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('following-edit'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('following-tab-history'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('following-tab-favorites'))); + await tester.pumpAndSettle(); + + expect( + find.byKey(const Key('following-card-the-signal-demo')), + findsOneWidget, + ); + expect(find.byIcon(Icons.edit_outlined), findsOneWidget); + }); +} + +void _signIn() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.withData({ + 'auth.accessToken': 'access', + 'auth.refreshToken': 'refresh', + 'auth.user': '{"uid":"9","nickname":"Viewer","avatarUrl":""}', + }); +} + +Widget _app({ + required FakeCatalogRepository catalog, + FavoriteRepository? favorites, + WatchHistoryRepository? history, +}) { + return LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: catalog, + categoryRepository: FakeCategoryRepository(catalog: catalog), + watchHistoryRepository: history ?? InMemoryWatchHistoryRepository(), + favoriteRepository: favorites ?? InMemoryFavoriteRepository(), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + ); +} + +class _CountingCatalogRepository extends FakeCatalogRepository { + int detailFetches = 0; + + @override + Future fetchDrama(String dramaId) { + detailFetches += 1; + return super.fetchDrama(dramaId); + } +} diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart index af25c79..59675ae 100644 --- a/test/home/home_nav_ui_test.dart +++ b/test/home/home_nav_ui_test.dart @@ -8,7 +8,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_colors.dart'; import 'package:fruitshorts/core/widgets/app_bottom_nav.dart'; -import 'package:fruitshorts/pages/favorites/favorites_page.dart'; +import 'package:fruitshorts/pages/following/following_page.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'; @@ -217,10 +217,9 @@ void main() { await tester.tap(find.byKey(const Key('tab-follow'))); await tester.pumpAndSettle(); - final favorites = find.byType(FavoritesContent); + final favorites = find.byType(FollowingPage); expect(favorites, findsOneWidget); - expect(find.byKey(const Key('favorites-page')), findsOneWidget); - expect(find.byKey(const Key('favorites-back')), findsNothing); + expect(find.byKey(const Key('following-page')), findsOneWidget); expect(find.byType(AppBottomNav), findsOneWidget); expect( find.descendant(of: favorites, matching: find.byType(Scaffold)), diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index 006bac5..7473163 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -36,8 +36,7 @@ void main() { await tester.tap(find.byKey(const Key('tab-follow'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('favorites-page')), findsOneWidget); - expect(find.byKey(const Key('favorites-back')), findsNothing); + expect(find.byKey(const Key('following-page')), findsOneWidget); expect(find.byType(AppBottomNav), findsOneWidget); }); diff --git a/test/widget_test.dart b/test/widget_test.dart index b6b0eba..a6621d5 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -117,7 +117,7 @@ void main() { await tester.tap(find.byKey(const Key('tab-follow'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('favorites-page')), findsOneWidget); + expect(find.byKey(const Key('following-page')), findsOneWidget); await tester.tap(find.byKey(const Key('tab-mine'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('mine-menu')), findsOneWidget); From d06967c6ab76702d99140c030410062ef265837f Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 17:36:58 +0800 Subject: [PATCH 028/129] =?UTF-8?q?fix(recommend):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E6=8E=A8=E8=8D=90=E5=AD=90=E9=A1=B5=E5=86=85=E5=AE=B9=E6=98=A0?= =?UTF-8?q?=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 28 +++++----- lib/pages/home/home_view.dart | 6 +- lib/pages/player/player_view.dart | 8 ++- lib/pages/recommend/recommend_page.dart | 62 ++++++++++++++++++--- test/home/home_preview_layout_test.dart | 13 ++++- test/home/k28_a_navigation_test.dart | 10 +++- test/player/player_fullscreen_test.dart | 19 ++----- test/recommend/recommend_sections_test.dart | 30 +++++++++- 8 files changed, 128 insertions(+), 48 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 5971618..3ecc516 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -938,30 +938,30 @@ $ flutter test 解读:背景、卡片 token、圆角/描边、无渐变阴影、三宫格等宽、菜单 52 行高、 入口保留和收藏页跳转均有测试覆盖。`pubspec.lock` 未提交,未操作真机。 -## 2026-10-22 推荐页分区流 / 全部短剧 / 剧单 +## 2026-10-22 推荐页沉浸流 / 剧单 / 全部短剧 -- 分支:`feature/recommend-section-feed` -- 基线:`develop` / `origin/develop` = `0ef2438` -- 实现提交:`f1b5418 feat(recommend): 新增分区流全部短剧与剧单` +- 分支:`feature/recommend-section-feed`、`feature/recommend-tab-feed-correction` +- 修正基线:`develop` / `origin/develop` = `af6b9bb` -新增固定顶部三 tab:推荐 / 剧单 / 全部短剧。推荐区包含本週爆款推薦、 -本週精選新劇、古裝神劇大賞三条横滑卡片,数据分别本地按播放量、上架时间和 -古装分类/题材排序过滤;点击卡片进入 `PlayerPage(homeMode: true)`。 +固定顶部三 tab 为推荐 / 剧单 / 全部短剧,右侧保留搜索。推荐 tab 直接内嵌 +`PlayerPage(homeMode: true, embedded: true)`,支持一屏一剧、上下滑切换、 +收藏/点赞/选集/简介/进度条等现有播放器能力;播放器内部频道标题条关闭, +只保留视频与浮层。剧单 tab 包含本週爆款推薦、本週精選新劇、古裝神劇大賞 +三条横滑卡片,数据分别本地按播放量、上架时间和古装分类/题材排序过滤。 全部短剧提供四行可用筛选 chips、三列首页同款卡片和 20 条触底分页; -无后端字段的本地/地区/字幕/配音筛选项不展示。剧单内嵌可复用的 -`RankingPage(embedded: true)`,根底栏保持可见。搜索复用现有发现搜索页。 +无后端字段的本地/地区/字幕/配音筛选项不展示。根底栏始终保留四个入口。 ```text $ flutter analyze -Analyzing reelshort-bottom-nav... -No issues found! (ran in 2.0s) +Analyzing reelshort-recommend-correction... +No issues found! (ran in 3.2s) $ flutter test -00:52 +638: All tests passed! +01:03 +644: All tests passed! ``` -解读:推荐三分区、横滑卡片、四行筛选、三列网格、tab 状态保留、搜索、 -卡片播放和内嵌榜单均有测试覆盖;所有生效文件低于 500 行。 +解读:沉浸式推荐播放器、剧单三分区横滑、四行筛选、三列网格、tab 状态保留、 +搜索、卡片播放、播放器路由覆盖/恢复均有测试覆盖;所有生效文件低于 500 行。 `pubspec.lock` 未提交,未操作真机。 ## 2026-10-22 追剧页三 tab 与双列网格 diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 15aef75..ebe5245 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -296,6 +296,7 @@ class _HomeTabsState extends State<_HomeTabs> { Widget _recommend(BuildContext context) { return RecommendPage( feedController: widget.feedController, + active: logic.tab == 1, onSearch: () => _openSearch(context), onOpenFilter: () => _pushPage(context, const FilterPage()), ); @@ -323,9 +324,8 @@ class _HomeTabsState extends State<_HomeTabs> { Future _openWallet(BuildContext context) async { if (!await ensureSignedIn(context) || !context.mounted) return; - await Navigator.of( - context, - ).push(MaterialPageRoute(builder: (_) => const WalletPage())); + await Navigator.of(context) + .push(MaterialPageRoute(builder: (_) => const WalletPage())); } Widget _my(BuildContext context) { diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 42fe56a..cebd854 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -66,6 +66,7 @@ class PlayerPage extends StatefulWidget { this.feedDramas, this.feedChannel, this.headerChannel, + this.showHomeFeedHeader = true, this.onDramaChanged, this.onImmersiveChanged, this.onOpenFullPlayer, @@ -90,6 +91,7 @@ class PlayerPage extends StatefulWidget { final List? feedDramas; final HomeFeedChannel? feedChannel; final HomeFeedChannel? headerChannel; + final bool showHomeFeedHeader; final ValueChanged? onDramaChanged; final ValueChanged? onImmersiveChanged; final Future Function(Drama drama, Episode episode)? onOpenFullPlayer; @@ -360,7 +362,11 @@ class _PlayerPageState extends State fit: StackFit.expand, children: [ content, - if (widget.homeMode && !_boosting && !logic.pip && !logic.pipPreparing) + if (widget.homeMode && + widget.showHomeFeedHeader && + !_boosting && + !logic.pip && + !logic.pipPreparing) Positioned( top: 0, left: 0, diff --git a/lib/pages/recommend/recommend_page.dart b/lib/pages/recommend/recommend_page.dart index 8f3aad9..464ce9d 100644 --- a/lib/pages/recommend/recommend_page.dart +++ b/lib/pages/recommend/recommend_page.dart @@ -14,9 +14,9 @@ import '../../core/data/services/wallet_controller.dart'; import '../../core/navigation/fade_page_route.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; +import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; import '../player/player_view.dart'; -import '../ranking/ranking_page.dart'; import 'widgets/all_dramas_filter_grid.dart'; import 'widgets/recommend_sections_view.dart'; @@ -26,11 +26,13 @@ class RecommendPage extends StatefulWidget { const RecommendPage({ super.key, required this.feedController, + required this.active, required this.onSearch, required this.onOpenFilter, }); final HomeFeedController feedController; + final bool active; final VoidCallback onSearch; final VoidCallback onOpenFilter; @@ -57,13 +59,13 @@ class _RecommendPageState extends State { child: IndexedStack( index: _tab.index, children: [ + _recommendFeed(context), RecommendSectionsView( feedController: widget.feedController, categories: categories, onOpenDrama: _openDrama, onMore: _handleMore, ), - const RankingPage(embedded: true), AllDramasFilterGrid( feedController: widget.feedController, onOpenDrama: _openDrama, @@ -78,6 +80,49 @@ class _RecommendPageState extends State { ); } + Widget _recommendFeed(BuildContext context) { + final dramas = widget.feedController.dramas; + if (dramas.isEmpty) { + final l10n = AppLocalizations.of(context)!; + if (widget.feedController.loadingFirstPage) { + return const ColoredBox( + color: AppColors.mediaBlack, + child: Center( + child: CircularProgressIndicator( + key: Key('recommend-feed-loading'), + color: AppColors.muted, + ), + ), + ); + } + return ColoredBox( + color: AppColors.mediaBlack, + child: EmptyState( + key: const Key('recommend-feed-empty'), + icon: Icons.movie_outlined, + title: l10n.noStories, + actionLabel: l10n.retry, + onAction: () => unawaited(widget.feedController.refreshCurrent()), + ), + ); + } + return PlayerPage( + key: const Key('recommend-feed-player'), + homeMode: true, + embedded: true, + active: widget.active && _tab == RecommendInnerTab.feed, + showHomeFeedHeader: false, + drama: dramas.first, + initialIndex: 0, + feedDramas: dramas, + feedChannel: widget.feedController.channel, + onOpenFullPlayer: (drama, episode) => + _openDrama(drama, episodeId: episode.id), + onRefreshFeed: widget.feedController.refreshCurrent, + onLoadMore: widget.feedController.loadMore, + ); + } + Widget _header(AppLocalizations l10n) { final items = [ (RecommendInnerTab.feed, l10n.recommendTopTab, 'recommend'), @@ -165,14 +210,10 @@ class _RecommendPageState extends State { } void _handleMore(RecommendSectionKind section) { - setState(() { - _tab = section == RecommendSectionKind.hits - ? RecommendInnerTab.playlists - : RecommendInnerTab.allDramas; - }); + setState(() => _tab = RecommendInnerTab.allDramas); } - Future _openDrama(Drama drama) async { + Future _openDrama(Drama drama, {String? episodeId}) async { final catalog = context.read(); final wallet = context.read(); final downloads = context.read(); @@ -183,11 +224,14 @@ class _RecommendPageState extends State { // 卡片预览仍可直接进入播放器。 } if (!mounted) return; + final episodeIndex = episodeId == null + ? 0 + : playable.episodes.indexWhere((episode) => episode.id == episodeId); await Navigator.of(context).push( FadePageRoute( builder: (_) => PlayerPage( drama: playable, - initialIndex: 0, + initialIndex: episodeIndex < 0 ? 0 : episodeIndex, homeMode: true, feedDramas: [playable], wallet: wallet, diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index c5552ee..dd58684 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -96,7 +96,7 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('should_show_section_feed_and_open_player_from_recommend', ( + testWidgets('should_show_immersive_player_and_open_playlist_in_recommend', ( tester, ) async { SharedPreferencesAsyncPlatform.instance = @@ -126,8 +126,17 @@ void main() { expect(find.byKey(const Key('home-grid')), findsNothing); expect(find.byKey(const Key('recommend-page')), findsOneWidget); + expect(find.byKey(const Key('recommend-feed-player')), findsOneWidget); + expect(find.byType(PlayerPage), findsOneWidget); + expect( + tester.widget(find.byType(PlayerPage)).homeMode, + isTrue, + ); + expect(find.byKey(const Key('recommend-section-hits')), findsNothing); + + await tester.tap(find.byKey(const Key('recommend-tab-playlist'))); + await tester.pumpAndSettle(); expect(find.byKey(const Key('recommend-section-hits')), findsOneWidget); - expect(find.byType(PlayerPage), findsNothing); await tester.tap( find.byKey(const Key('recommend-card-hits-the-signal-demo')), diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index 7473163..b88287a 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -70,7 +70,7 @@ void main() { expect(tester.getRect(grid), before); }); - testWidgets('should_render_recommend_sections_and_inner_top_nav', ( + testWidgets('should_render_recommend_immersive_player_and_inner_top_nav', ( tester, ) async { await _pumpApp(tester); @@ -81,6 +81,10 @@ void main() { expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget); expect(find.byKey(const Key('recommend-tab-playlist')), findsOneWidget); expect(find.byKey(const Key('recommend-tab-all')), findsOneWidget); + expect(find.byKey(const Key('recommend-feed-player')), findsOneWidget); + + await tester.tap(find.byKey(const Key('recommend-tab-playlist'))); + await tester.pumpAndSettle(); expect(find.byKey(const Key('recommend-section-hits')), findsOneWidget); expect(find.byKey(const Key('recommend-section-new')), findsOneWidget); expect(find.byKey(const Key('recommend-section-costume')), findsOneWidget); @@ -95,7 +99,7 @@ void main() { await tester.tap(find.byKey(const Key('recommend-tab-playlist'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('ranking-page')), findsOneWidget); + expect(find.byKey(const Key('recommend-section-hits')), findsOneWidget); expect(find.byType(AppBottomNav), findsOneWidget); await tester.tap(find.byKey(const Key('recommend-tab-all'))); @@ -143,7 +147,7 @@ void main() { await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('recommend-section-hits')), findsOneWidget); + expect(find.byKey(const Key('recommend-feed-player')), findsOneWidget); expect(tester.takeException(), isNull); }); } diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 433f3e6..691023d 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -36,19 +36,18 @@ void main() { InMemorySharedPreferencesAsync.empty(); }); - testWidgets('should_open_fullscreen_overlay_from_recommend_feed', ( + testWidgets('should_render_immersive_overlay_in_recommend_feed', ( tester, ) async { await _pumpApp(tester); await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); - await tester.tap( - find.byKey(const Key('recommend-card-hits-the-signal-demo')), - ); - await tester.pumpAndSettle(); + expect(find.byKey(const Key('recommend-feed-player')), findsOneWidget); expect( - tester.widget(find.byType(PlayerPage).last).homeMode, + tester + .widget(find.byKey(const Key('recommend-feed-player'))) + .homeMode, isTrue, ); expect(find.byType(PageView), findsOneWidget); @@ -80,10 +79,6 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); await tester.tap(find.byKey(const Key('tab-recommend'))); - await tester.pumpAndSettle(); - await tester.tap( - find.byKey(const Key('recommend-card-hits-the-signal-demo')), - ); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); @@ -170,10 +165,6 @@ void main() { ); await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); - await tester.tap( - find.byKey(const Key('recommend-card-hits-the-signal-demo')), - ); - await tester.pumpAndSettle(); final player = players.firstWhere((item) => item.playing); expect(player.playing, isTrue); diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart index fd2e8f4..b5029ef 100644 --- a/test/recommend/recommend_sections_test.dart +++ b/test/recommend/recommend_sections_test.dart @@ -23,7 +23,7 @@ void main() { InMemorySharedPreferencesAsync.empty(); }); - testWidgets('should_render_three_recommend_sections_and_horizontal_cards', ( + testWidgets('should_render_immersive_player_as_recommend_tab', ( tester, ) async { await _pumpRecommend(tester); @@ -32,6 +32,25 @@ void main() { expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget); expect(find.byKey(const Key('recommend-tab-playlist')), findsOneWidget); expect(find.byKey(const Key('recommend-tab-all')), findsOneWidget); + expect(find.byKey(const Key('recommend-feed-player')), findsOneWidget); + final player = tester.widget( + find.byKey(const Key('recommend-feed-player')), + ); + expect(player.homeMode, isTrue); + expect(player.embedded, isTrue); + expect(player.showHomeFeedHeader, isFalse); + expect(find.byType(PageView), findsOneWidget); + expect(find.byKey(const Key('recommend-section-hits')), findsNothing); + expect(find.byType(AppBottomNav), findsOneWidget); + }); + + testWidgets('should_render_playlist_sections_and_horizontal_cards', ( + tester, + ) async { + await _pumpRecommend(tester); + await tester.tap(find.byKey(const Key('recommend-tab-playlist'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('recommend-section-hits')), findsOneWidget); expect(find.byKey(const Key('recommend-section-new')), findsOneWidget); expect(find.byKey(const Key('recommend-section-costume')), findsOneWidget); @@ -54,6 +73,10 @@ void main() { ) async { await _pumpRecommend(tester); + await tester.tap(find.byKey(const Key('recommend-tab-playlist'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('recommend-section-hits')), findsOneWidget); + await tester.tap(find.byKey(const Key('recommend-tab-all'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('all-dramas-grid')), findsOneWidget); @@ -68,7 +91,8 @@ void main() { await tester.tap(find.byKey(const Key('recommend-tab-playlist'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('ranking-page')), findsOneWidget); + expect(find.byKey(const Key('recommend-section-hits')), findsOneWidget); + expect(find.byKey(const Key('recommend-feed-player')), findsNothing); expect(find.byType(AppBottomNav), findsOneWidget); await tester.tap(find.byKey(const Key('recommend-tab-all'))); @@ -82,6 +106,8 @@ void main() { ) async { await _pumpRecommend(tester); + await tester.tap(find.byKey(const Key('recommend-tab-playlist'))); + await tester.pumpAndSettle(); await tester.tap( find.byKey(const Key('recommend-card-hits-the-signal-demo')), ); From cbc6d80cd412fd36974c913af5b5b4575caa1fd8 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 17:44:48 +0800 Subject: [PATCH 029/129] =?UTF-8?q?feat(mine):=20=E9=87=8D=E6=8E=92?= =?UTF-8?q?=E6=88=91=E7=9A=84=E9=A1=B5=E5=B9=B6=E6=96=B0=E5=A2=9E=E8=BF=B7?= =?UTF-8?q?=E4=BD=A0=E6=92=AD=E6=94=BE=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/theme/app_colors.dart | 7 + lib/core/theme/app_dimens.dart | 11 +- lib/l10n/app_en.arb | 10 +- lib/l10n/app_id.arb | 10 +- lib/l10n/app_localizations.dart | 60 +++- lib/l10n/app_localizations_en.dart | 30 +- lib/l10n/app_localizations_id.dart | 30 +- lib/l10n/app_localizations_zh.dart | 30 +- lib/l10n/app_zh.arb | 10 +- lib/pages/home/home_view.dart | 10 +- lib/pages/mine/mine_types.dart | 7 +- lib/pages/mine/mine_view.dart | 173 ++++++--- lib/pages/mine/widgets/mine_header.dart | 180 ++++++---- lib/pages/mine/widgets/mine_menu.dart | 322 +++++------------ lib/pages/mine/widgets/mine_mini_player.dart | 124 +++++++ test/config/l10n_id_test.dart | 1 - test/home/home_nav_ui_test.dart | 4 +- test/library/favorites_page_test.dart | 6 +- test/mine/mine_preview_layout_test.dart | 352 ++++++++++++------- test/notice/notices_entry_test.dart | 8 +- test/widget_test.dart | 6 +- 21 files changed, 860 insertions(+), 531 deletions(-) create mode 100644 lib/pages/mine/widgets/mine_mini_player.dart diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 6d2e57e..08aa77e 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -39,6 +39,9 @@ abstract final class AppColors { /// 金币与奖励。 static const gold = Color(0xFFF3B34C); static const goldDeep = Color(0xFFD9A23A); + static const vipGoldStart = Color(0xFFF5D9A0); + static const vipGoldEnd = Color(0xFFE8B65A); + static const vipBadgeSurface = Color(0x1F3D2408); static const bronze = Color(0xFFCD7F32); static const bronzeDeep = Color(0xFFA9713A); @@ -69,6 +72,10 @@ abstract final class AppColors { static const contentChip = Color(0xFF2C2C2E); static const contentBorder = Color(0x1AFFFFFF); + /// 「我的」页卡片与描边。 + static const mineCard = Color(0xFF16161A); + static const mineOutline = Color(0xFF4A4A4A); + /// 次级文字与列表 chevron。 static const textPrimary = Color(0xFFFFFFFF); static const textSecondary = Color(0xFF8E8E93); diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 64f20ab..7706f55 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -90,7 +90,16 @@ abstract final class AppDimens { /// 「我的」页中性视觉。 static const double mineCardRadius = 16; - static const double mineRowHeight = 52; + static const double mineRowHeight = 56; static const double minePageTitle = 21; static const double mineSectionTitle = 15; + static const double mineAvatar = 56; + static const double mineUserName = 20; + static const double mineUid = 13; + static const double mineMenuIcon = 22; + static const double mineMiniWidth = 96; + static const double mineMiniHeight = 132; + static const double mineMiniRadius = 8; + static const double mineMiniTitle = 10; + static const double mineMiniClose = 22; } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 31d95d2..a63715b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -115,7 +115,6 @@ "emptyHint": "New content will appear here.", "demoCatalogNotice": "Three demo dramas share the same local video.", "myTab": "My", - "localViewer": "Local Viewer", "localDemoProfile": "Local demo profile", "continueWatching": "Continue Watching", "episodeRemaining": "Episode {episode} · {minutes} min left", @@ -153,7 +152,6 @@ }, "resume": "Resume", "history": "History", - "watchlist": "Favorites", "likedTab": "Liked", "all": "All", "inProgress": "In Progress", @@ -733,6 +731,14 @@ "modernFilter": "Modern", "edit": "Edit", "done": "Done", + "visitor": "Visitor", + "copyUid": "Copy UID", + "uidCopied": "UID copied", + "offlineDownloads": "Offline downloads", + "watchPreferences": "Watch preferences", + "contactSupport": "Contact support", + "vipBrandName": "Netshort VIP", + "mineVipSubtitle": "Enjoy exclusive benefits", "favoriteEpisodeProgress": "Episode {episode} / {total} total", "@favoriteEpisodeProgress": { "placeholders": { diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 78edd86..4250d91 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -94,7 +94,6 @@ "emptyHint": "Konten baru akan muncul di sini.", "demoCatalogNotice": "Tiga drama demo memakai video lokal yang sama.", "myTab": "Saya", - "localViewer": "Penonton Lokal", "localDemoProfile": "Profil demo lokal", "continueWatching": "Lanjut Tonton", "episodeRemaining": "Episode {episode} · {minutes} menit tersisa", @@ -132,7 +131,6 @@ }, "resume": "Lanjutkan", "history": "Riwayat", - "watchlist": "Daftar Tonton", "likedTab": "Disukai", "all": "Semua", "inProgress": "Sedang Berjalan", @@ -690,6 +688,14 @@ "modernFilter": "Modern", "edit": "Edit", "done": "Selesai", + "visitor": "Pengunjung", + "copyUid": "Salin UID", + "uidCopied": "UID disalin", + "offlineDownloads": "Unduhan offline", + "watchPreferences": "Preferensi tontonan", + "contactSupport": "Hubungi dukungan", + "vipBrandName": "Netshort VIP", + "mineVipSubtitle": "Nikmati manfaat eksklusif", "favoriteEpisodeProgress": "Episode {episode} / total {total}", "@favoriteEpisodeProgress": { "placeholders": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 8170424..39502e6 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -568,12 +568,6 @@ abstract class AppLocalizations { /// **'My'** String get myTab; - /// No description provided for @localViewer. - /// - /// In en, this message translates to: - /// **'Local Viewer'** - String get localViewer; - /// No description provided for @localDemoProfile. /// /// In en, this message translates to: @@ -628,12 +622,6 @@ abstract class AppLocalizations { /// **'History'** String get history; - /// No description provided for @watchlist. - /// - /// In en, this message translates to: - /// **'Favorites'** - String get watchlist; - /// No description provided for @likedTab. /// /// In en, this message translates to: @@ -3208,6 +3196,54 @@ abstract class AppLocalizations { /// **'Done'** String get done; + /// No description provided for @visitor. + /// + /// In en, this message translates to: + /// **'Visitor'** + String get visitor; + + /// No description provided for @copyUid. + /// + /// In en, this message translates to: + /// **'Copy UID'** + String get copyUid; + + /// No description provided for @uidCopied. + /// + /// In en, this message translates to: + /// **'UID copied'** + String get uidCopied; + + /// No description provided for @offlineDownloads. + /// + /// In en, this message translates to: + /// **'Offline downloads'** + String get offlineDownloads; + + /// No description provided for @watchPreferences. + /// + /// In en, this message translates to: + /// **'Watch preferences'** + String get watchPreferences; + + /// No description provided for @contactSupport. + /// + /// In en, this message translates to: + /// **'Contact support'** + String get contactSupport; + + /// No description provided for @vipBrandName. + /// + /// In en, this message translates to: + /// **'Netshort VIP'** + String get vipBrandName; + + /// No description provided for @mineVipSubtitle. + /// + /// In en, this message translates to: + /// **'Enjoy exclusive benefits'** + String get mineVipSubtitle; + /// No description provided for @favoriteEpisodeProgress. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 5c4772a..458b70a 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -261,9 +261,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get myTab => 'My'; - @override - String get localViewer => 'Local Viewer'; - @override String get localDemoProfile => 'Local demo profile'; @@ -301,9 +298,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get history => 'History'; - @override - String get watchlist => 'Favorites'; - @override String get likedTab => 'Liked'; @@ -1671,6 +1665,30 @@ class AppLocalizationsEn extends AppLocalizations { @override String get done => 'Done'; + @override + String get visitor => 'Visitor'; + + @override + String get copyUid => 'Copy UID'; + + @override + String get uidCopied => 'UID copied'; + + @override + String get offlineDownloads => 'Offline downloads'; + + @override + String get watchPreferences => 'Watch preferences'; + + @override + String get contactSupport => 'Contact support'; + + @override + String get vipBrandName => 'Netshort VIP'; + + @override + String get mineVipSubtitle => 'Enjoy exclusive benefits'; + @override String favoriteEpisodeProgress(int episode, int total) { return 'Episode $episode / $total total'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 6dc7af2..1a7e93d 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -261,9 +261,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get myTab => 'Saya'; - @override - String get localViewer => 'Penonton Lokal'; - @override String get localDemoProfile => 'Profil demo lokal'; @@ -301,9 +298,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get history => 'Riwayat'; - @override - String get watchlist => 'Daftar Tonton'; - @override String get likedTab => 'Disukai'; @@ -1673,6 +1667,30 @@ class AppLocalizationsId extends AppLocalizations { @override String get done => 'Selesai'; + @override + String get visitor => 'Pengunjung'; + + @override + String get copyUid => 'Salin UID'; + + @override + String get uidCopied => 'UID disalin'; + + @override + String get offlineDownloads => 'Unduhan offline'; + + @override + String get watchPreferences => 'Preferensi tontonan'; + + @override + String get contactSupport => 'Hubungi dukungan'; + + @override + String get vipBrandName => 'Netshort VIP'; + + @override + String get mineVipSubtitle => 'Nikmati manfaat eksklusif'; + @override String favoriteEpisodeProgress(int episode, int total) { return 'Episode $episode / total $total'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 0d5aeb2..96d0ee5 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -256,9 +256,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get myTab => '我的'; - @override - String get localViewer => '本地观众'; - @override String get localDemoProfile => '本地演示档案'; @@ -296,9 +293,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get history => '历史'; - @override - String get watchlist => '收藏'; - @override String get likedTab => '喜欢'; @@ -1647,6 +1641,30 @@ class AppLocalizationsZh extends AppLocalizations { @override String get done => '完成'; + @override + String get visitor => '访客'; + + @override + String get copyUid => '复制 UID'; + + @override + String get uidCopied => 'UID 已复制'; + + @override + String get offlineDownloads => '离线缓存'; + + @override + String get watchPreferences => '观看偏好'; + + @override + String get contactSupport => '联系客服'; + + @override + String get vipBrandName => 'Netshort VIP'; + + @override + String get mineVipSubtitle => '享受专属权益'; + @override String favoriteEpisodeProgress(int episode, int total) { return '第 $episode 集 / 共 $total 集'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 9e01e24..7f5c954 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -94,7 +94,6 @@ "emptyHint": "有新内容时会显示在这里。", "demoCatalogNotice": "三部演示短剧共用同一条本地视频。", "myTab": "我的", - "localViewer": "本地观众", "localDemoProfile": "本地演示档案", "continueWatching": "继续观看", "episodeRemaining": "第 {episode} 集 · 还剩 {minutes} 分钟", @@ -132,7 +131,6 @@ }, "resume": "继续播放", "history": "历史", - "watchlist": "收藏", "likedTab": "喜欢", "all": "全部", "inProgress": "未看完", @@ -652,6 +650,14 @@ "modernFilter": "现代", "edit": "编辑", "done": "完成", + "visitor": "访客", + "copyUid": "复制 UID", + "uidCopied": "UID 已复制", + "offlineDownloads": "离线缓存", + "watchPreferences": "观看偏好", + "contactSupport": "联系客服", + "vipBrandName": "Netshort VIP", + "mineVipSubtitle": "享受专属权益", "favoriteEpisodeProgress": "第 {episode} 集 / 共 {total} 集", "@favoriteEpisodeProgress": { "placeholders": { diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index ebe5245..f719c42 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -324,17 +324,15 @@ class _HomeTabsState extends State<_HomeTabs> { Future _openWallet(BuildContext context) async { if (!await ensureSignedIn(context) || !context.mounted) return; - await Navigator.of(context) - .push(MaterialPageRoute(builder: (_) => const WalletPage())); + await Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const WalletPage())); } Widget _my(BuildContext context) { return MyPage( active: logic.tab == 3, - callbacks: MyPageCallbacks( - onAction: logic.myAction, - onDramaSelected: (drama) => logic.openDrama(context, drama), - ), + callbacks: MyPageCallbacks(onAction: logic.myAction), ); } diff --git a/lib/pages/mine/mine_types.dart b/lib/pages/mine/mine_types.dart index b7da322..0304e98 100644 --- a/lib/pages/mine/mine_types.dart +++ b/lib/pages/mine/mine_types.dart @@ -2,18 +2,16 @@ import 'package:flutter/foundation.dart'; -import '../../core/data/models/drama.dart'; - /// 「我的」页与根导航之间的返回动作。 enum MyPageAction { home, recommend, following, mine, resume } /// 「我的」页可进入的功能目的地。 enum MineDestination { - watchlist, history, downloads, wallet, vip, + preferences, works, creatorCenter, notices, @@ -23,10 +21,9 @@ enum MineDestination { /// 「我的」页导航回调;没有 `onAction` 时按独立页面处理。 class MyPageCallbacks { - const MyPageCallbacks({this.onAction, this.onDramaSelected}); + const MyPageCallbacks({this.onAction}); final ValueChanged? onAction; - final ValueChanged? onDramaSelected; bool get embedded => onAction != null; } diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index a3e91da..3f77213 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -5,8 +5,11 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../../core/data/models/drama.dart'; +import '../../core/data/models/watch_progress.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/auth_controller.dart'; +import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/locale_controller.dart'; import '../../core/data/services/wallet_controller.dart'; import '../../core/theme/app_colors.dart'; @@ -17,10 +20,11 @@ import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/plain_page_scaffold.dart'; import '../../l10n/app_localizations.dart'; import '../downloads/downloads_page.dart'; -import '../favorites/favorites_page.dart'; +import '../account/account_profile_page.dart'; +import '../filter/filter_page.dart'; import '../notices/notices_page.dart'; +import '../player/open_player.dart'; import '../settings/settings_page.dart'; -import '../store/recharge_page.dart'; import '../store/vip_page.dart'; import '../wallet/wallet_page.dart'; import '../watch_records/watch_records_page.dart'; @@ -29,6 +33,7 @@ import 'mine_logic.dart'; import 'mine_types.dart'; import 'widgets/mine_header.dart'; import 'widgets/mine_menu.dart'; +import 'widgets/mine_mini_player.dart'; /// K28-B 我的页。 class MyPage extends StatefulWidget { @@ -47,6 +52,7 @@ class MyPage extends StatefulWidget { class _MyPageState extends State { late final MineLogic logic; + bool _miniDismissed = false; bool get embedded => widget.callbacks.embedded; @@ -126,53 +132,51 @@ class _MyPageState extends State { } Widget _body(BuildContext context, AppLocalizations l10n) { - final auth = context.watch(); - final wallet = context.watch(); final locale = context.watch(); - return ListView( - key: const Key('mine-page-scroll'), - padding: const EdgeInsets.fromLTRB( - AppDimens.pageHorizontal, - 0, - AppDimens.pageHorizontal, - AppDimens.spaceXl, - ), + final recent = _miniRecord; + return Stack( children: [ - MineHeader(onTap: () => _openDestination(MineDestination.settings)), - const SizedBox(height: AppDimens.spaceMd), - MineShortcutGrid(onOpen: _openDestination), - const SizedBox(height: AppDimens.spaceMd), - MineWalletCard( - balance: _walletValue(l10n, auth, wallet), - onOpen: () => _openDestination(MineDestination.wallet), - onRecharge: _openRecharge, - ), - const SizedBox(height: AppDimens.spaceMd), - MineVipCard(onOpen: () => _openDestination(MineDestination.vip)), - const SizedBox(height: AppDimens.spaceMd), - MineMenuList( - onOpen: _openDestination, - onLanguage: _openLanguage, - languageLabel: _languageLabel(l10n, locale.locale), + Positioned.fill( + child: ListView( + key: const Key('mine-page-scroll'), + padding: EdgeInsets.fromLTRB( + AppDimens.pageHorizontal, + 0, + AppDimens.pageHorizontal, + recent == null ? AppDimens.spaceXl : 156, + ), + children: [ + MineHeader(onLogin: _openLogin, onEdit: _openEditActions), + MineVipBanner( + onOpen: () => _openDestination(MineDestination.vip), + ), + const SizedBox(height: AppDimens.spaceMd), + MineMenuList( + onOpen: _openDestination, + onLanguage: _openLanguage, + languageLabel: _languageLabel(l10n, locale.locale), + ), + ], + ), ), + if (recent != null) + Positioned( + left: AppDimens.pageHorizontal, + bottom: AppDimens.spaceMd, + child: MineMiniPlayer( + title: _miniTitle(l10n, recent), + coverUrl: recent.dramaCover?.trim() ?? '', + episodeLabel: l10n.episodeLabel(recent.episodeNumber ?? 0), + onTap: () => unawaited(_openRecent(recent)), + onClose: () => setState(() => _miniDismissed = true), + ), + ), ], ); } - String _walletValue( - AppLocalizations l10n, - AuthController auth, - WalletController wallet, - ) { - if (!auth.isSignedIn) return l10n.logInHint; - if (wallet.snapshot == null) return l10n.coins; - return l10n.walletBalanceShort(wallet.balance); - } - void _openDestination(MineDestination destination) { switch (destination) { - case MineDestination.watchlist: - _push(const FavoritesPage()); case MineDestination.history: _push(const WatchRecordsPage()); case MineDestination.downloads: @@ -181,6 +185,8 @@ class _MyPageState extends State { _openProtected(const WalletPage()); case MineDestination.vip: _openVip(); + case MineDestination.preferences: + _push(const FilterPage()); case MineDestination.works: _pushPlaceholder(MinePlaceholderKind.works); case MineDestination.creatorCenter: @@ -194,6 +200,64 @@ class _MyPageState extends State { } } + void _openLogin() { + unawaited(openAuthPage(context)); + } + + Future _openEditActions() async { + final selected = await showModalBottomSheet( + context: context, + backgroundColor: AppColors.transparent, + showDragHandle: false, + builder: (sheetContext) => Material( + color: AppColors.mineCard, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(AppDimens.mineCardRadius), + ), + clipBehavior: Clip.antiAlias, + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + key: const Key('mine-edit-profile'), + title: Text(AppLocalizations.of(sheetContext)!.accountProfile), + onTap: () => Navigator.pop(sheetContext, 'profile'), + ), + ListTile( + key: const Key('mine-edit-works'), + title: Text(AppLocalizations.of(sheetContext)!.myWorks), + onTap: () => Navigator.pop(sheetContext, 'works'), + ), + ListTile( + key: const Key('mine-edit-creator'), + title: Text(AppLocalizations.of(sheetContext)!.creatorCenter), + onTap: () => Navigator.pop(sheetContext, 'creator'), + ), + ListTile( + key: const Key('mine-edit-notices'), + title: Text(AppLocalizations.of(sheetContext)!.notices), + onTap: () => Navigator.pop(sheetContext, 'notices'), + ), + ], + ), + ), + ), + ); + if (!mounted || selected == null) return; + switch (selected) { + case 'profile': + _push(const AccountProfilePage()); + case 'works': + _pushPlaceholder(MinePlaceholderKind.works); + case 'creator': + _pushPlaceholder(MinePlaceholderKind.creator); + case 'notices': + _push(const NoticesPage()); + } + } + void _push(Widget page) { Navigator.of(context).push(MaterialPageRoute(builder: (_) => page)); } @@ -236,9 +300,32 @@ class _MyPageState extends State { await context.read().select(selected); } - void _openRecharge() { - final wallet = context.read(); - _openProtected(RechargePage(onPurchased: () => unawaited(wallet.load()))); + WatchProgress? get _miniRecord { + if (_miniDismissed || logic.recent.isEmpty) return null; + final recent = logic.recent.first; + return recent.episodeId.isEmpty ? null : recent; + } + + String _miniTitle(AppLocalizations l10n, WatchProgress recent) { + final title = recent.dramaTitle?.trim() ?? ''; + return title.isEmpty ? l10n.episodeLabel(recent.episodeNumber ?? 0) : title; + } + + Future _openRecent(WatchProgress recent) async { + final catalog = context.read(); + final seed = Drama( + id: recent.dramaId, + title: recent.dramaTitle?.trim() ?? '', + synopsis: '', + posterUrl: recent.dramaCover?.trim() ?? '', + ); + try { + final detail = await catalog.requireDetail(seed); + if (!mounted) return; + await playDramaFromCatalog(context, detail, episodeId: recent.episodeId); + } catch (_) { + // 最近记录失效时不投射错误页,等待下一次记录刷新。 + } } Widget _bottomNav() { diff --git a/lib/pages/mine/widgets/mine_header.dart b/lib/pages/mine/widgets/mine_header.dart index 91b8bd2..27c28bd 100644 --- a/lib/pages/mine/widgets/mine_header.dart +++ b/lib/pages/mine/widgets/mine_header.dart @@ -1,19 +1,28 @@ // Author: fruit.com.cn +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../../core/data/services/auth_controller.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; +import '../../../core/widgets/centered_toast.dart'; import '../../../core/widgets/remote_image.dart'; import '../../../l10n/app_localizations.dart'; -/// 「我的」页顶部身份卡。 -class MineHeader extends StatelessWidget { - const MineHeader({super.key, required this.onTap}); +const double _buttonHeight = 34; +const double _buttonRadius = 20; +const double _copyIconSize = 15; - final VoidCallback onTap; +/// 「我的」页用户区。 +class MineHeader extends StatelessWidget { + const MineHeader({super.key, required this.onLogin, required this.onEdit}); + + final VoidCallback onLogin; + final VoidCallback onEdit; @override Widget build(BuildContext context) { @@ -21,41 +30,80 @@ class MineHeader extends StatelessWidget { final auth = context.watch(); final user = auth.user; final signedIn = auth.isSignedIn; - final name = signedIn && (user?.nickname.isNotEmpty ?? false) - ? user!.nickname - : l10n.localViewer; - final subtitle = signedIn && (user?.uid.isNotEmpty ?? false) - ? 'ID: ${user!.uid}' - : l10n.logInHint; - return Container( + final nickname = signedIn && (user?.nickname.trim().isNotEmpty ?? false) + ? user!.nickname.trim() + : l10n.visitor; + final uid = signedIn ? user?.uid.trim() ?? '' : ''; + return Padding( key: const Key('mine-user-row'), - decoration: BoxDecoration( - color: AppColors.contentCard, - borderRadius: BorderRadius.circular(AppDimens.mineCardRadius), - border: Border.all(color: AppColors.contentBorder), - ), - child: Material( - color: AppColors.transparent, - borderRadius: BorderRadius.circular(AppDimens.mineCardRadius), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.all(AppDimens.cardPadding), - child: Row( + padding: const EdgeInsets.symmetric(vertical: AppDimens.spaceMd), + child: Row( + children: [ + _avatar(user?.avatarUrl ?? ''), + const SizedBox(width: AppDimens.spaceMd), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - _avatar(user?.avatarUrl ?? ''), - const SizedBox(width: AppDimens.spaceMd), - Expanded(child: _labels(name, subtitle)), - const Icon( - Icons.chevron_right_rounded, - color: AppColors.textSecondary, - size: 20, + Text( + nickname, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.textPrimary, + fontSize: AppDimens.mineUserName, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: AppDimens.spaceXs), + Row( + children: [ + Flexible( + child: Text( + '${l10n.accountUid}: ${uid.isEmpty ? '--' : uid}', + key: const Key('mine-uid'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: AppDimens.mineUid, + ), + ), + ), + const SizedBox(width: AppDimens.spaceXs), + IconButton( + key: const Key('mine-uid-copy'), + tooltip: l10n.copyUid, + onPressed: uid.isEmpty + ? null + : () => unawaited( + _copyUid(context, uid, l10n.uidCopied), + ), + visualDensity: VisualDensity.compact, + constraints: const BoxConstraints.tightFor( + width: AppDimens.tapTarget, + height: AppDimens.tapTarget, + ), + padding: EdgeInsets.zero, + icon: const Icon( + Icons.copy_rounded, + color: AppColors.textSecondary, + size: _copyIconSize, + ), + ), + ], ), ], ), ), - ), + const SizedBox(width: AppDimens.spaceSm), + _accountButton( + key: signedIn ? const Key('mine-edit') : const Key('mine-login'), + label: signedIn ? l10n.edit : l10n.logIn, + onTap: signedIn ? onEdit : onLogin, + ), + ], ), ); } @@ -63,8 +111,8 @@ class MineHeader extends StatelessWidget { Widget _avatar(String path) { return Container( key: const Key('mine-avatar'), - width: 64, - height: 64, + width: AppDimens.mineAvatar, + height: AppDimens.mineAvatar, decoration: const BoxDecoration( color: AppColors.contentSurface, shape: BoxShape.circle, @@ -74,38 +122,50 @@ class MineHeader extends StatelessWidget { ? const Icon( Icons.person_rounded, color: AppColors.textPrimary, - size: 30, + size: 28, ) : RemoteImage(path, fit: BoxFit.cover), ); } - Widget _labels(String name, String subtitle) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontFamily: 'Outfit', - color: AppColors.textPrimary, - fontSize: 18, - fontWeight: FontWeight.w700, - ), + Widget _accountButton({ + required Key key, + required String label, + required VoidCallback onTap, + }) { + return OutlinedButton( + key: key, + onPressed: onTap, + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, _buttonHeight), + padding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceMd), + foregroundColor: AppColors.textPrimary, + side: const BorderSide(color: AppColors.mineOutline), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(_buttonRadius), ), - const SizedBox(height: AppDimens.spaceXs), - Text( - subtitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.textSecondary, - fontSize: AppDimens.caption, - ), + textStyle: const TextStyle( + fontSize: AppDimens.body, + fontWeight: FontWeight.w500, ), - ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(label), + const SizedBox(width: 2), + const Icon(Icons.chevron_right_rounded, size: 17), + ], + ), ); } + + Future _copyUid( + BuildContext context, + String uid, + String message, + ) async { + await Clipboard.setData(ClipboardData(text: uid)); + if (context.mounted) showCenteredToast(context, message); + } } diff --git a/lib/pages/mine/widgets/mine_menu.dart b/lib/pages/mine/widgets/mine_menu.dart index b1dce67..ed5be25 100644 --- a/lib/pages/mine/widgets/mine_menu.dart +++ b/lib/pages/mine/widgets/mine_menu.dart @@ -7,180 +7,13 @@ import '../../../core/theme/app_dimens.dart'; import '../../../l10n/app_localizations.dart'; import '../mine_types.dart'; -/// 我的页三宫格快捷入口。 -class MineShortcutGrid extends StatelessWidget { - const MineShortcutGrid({super.key, required this.onOpen}); +const double _vipRadius = 12; +const double _vipBadgeSize = 36; +const double _vipButtonHeight = 34; - final ValueChanged onOpen; - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - return Row( - children: [ - Expanded( - child: _ShortcutTile( - key: const Key('mine-watchlist'), - icon: Icons.bookmark_border_rounded, - label: l10n.watchlist, - onTap: () => onOpen(MineDestination.watchlist), - ), - ), - const SizedBox(width: AppDimens.homeGridGap), - Expanded( - child: _ShortcutTile( - key: const Key('mine-history'), - icon: Icons.history_rounded, - label: l10n.watchHistory, - onTap: () => onOpen(MineDestination.history), - ), - ), - const SizedBox(width: AppDimens.homeGridGap), - Expanded( - child: _ShortcutTile( - key: const Key('mine-downloads'), - icon: Icons.download_outlined, - label: l10n.downloads, - onTap: () => onOpen(MineDestination.downloads), - ), - ), - ], - ); - } -} - -class _ShortcutTile extends StatelessWidget { - const _ShortcutTile({ - super.key, - required this.icon, - required this.label, - required this.onTap, - }); - - final IconData icon; - final String label; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: AppColors.contentCard, - borderRadius: BorderRadius.circular(AppDimens.mineCardRadius), - border: Border.all(color: AppColors.contentBorder), - ), - child: Material( - color: AppColors.transparent, - borderRadius: BorderRadius.circular(AppDimens.mineCardRadius), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: onTap, - child: SizedBox( - height: 88, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, color: AppColors.textPrimary, size: 24), - const SizedBox(height: AppDimens.spaceSm), - Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: AppDimens.caption, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ), - ), - ); - } -} - -/// 金币余额卡。 -class MineWalletCard extends StatelessWidget { - const MineWalletCard({ - super.key, - required this.balance, - required this.onOpen, - required this.onRecharge, - }); - - final String balance; - final VoidCallback onOpen; - final VoidCallback onRecharge; - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - return Container( - key: const Key('mine-wallet'), - padding: const EdgeInsets.all(AppDimens.cardPadding), - decoration: BoxDecoration( - color: AppColors.contentCard, - borderRadius: BorderRadius.circular(AppDimens.mineCardRadius), - border: Border.all(color: AppColors.contentBorder), - ), - child: Row( - children: [ - const Icon( - Icons.toll_outlined, - color: AppColors.textSecondary, - size: 28, - ), - const SizedBox(width: AppDimens.spaceMd), - Expanded( - child: InkWell( - onTap: onOpen, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.coinBalance, - style: const TextStyle( - color: AppColors.textSecondary, - fontSize: AppDimens.caption, - ), - ), - const SizedBox(height: 3), - Text( - balance, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: AppDimens.pageTitle, - fontWeight: FontWeight.w700, - ), - ), - ], - ), - ), - ), - TextButton( - key: const Key('mine-wallet-recharge'), - onPressed: onRecharge, - style: TextButton.styleFrom( - backgroundColor: AppColors.blue, - foregroundColor: AppColors.onPrimary, - minimumSize: const Size(64, AppDimens.buttonCompact), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppDimens.buttonRadius), - ), - ), - child: Text(l10n.topUpWallet), - ), - ], - ), - ); - } -} - -/// 会员权益卡。 -class MineVipCard extends StatelessWidget { - const MineVipCard({super.key, required this.onOpen}); +/// 金色会员横幅。 +class MineVipBanner extends StatelessWidget { + const MineVipBanner({super.key, required this.onOpen}); final VoidCallback onOpen; @@ -190,30 +23,34 @@ class MineVipCard extends StatelessWidget { return Container( key: const Key('mine-vip'), decoration: BoxDecoration( - color: AppColors.contentCard, - borderRadius: BorderRadius.circular(AppDimens.mineCardRadius), - border: Border.all(color: AppColors.contentBorder), + gradient: const LinearGradient( + colors: [AppColors.vipGoldStart, AppColors.vipGoldEnd], + ), + borderRadius: BorderRadius.circular(_vipRadius), ), child: Material( color: AppColors.transparent, - borderRadius: BorderRadius.circular(AppDimens.mineCardRadius), + borderRadius: BorderRadius.circular(_vipRadius), clipBehavior: Clip.antiAlias, child: InkWell( onTap: onOpen, child: Padding( - padding: const EdgeInsets.all(AppDimens.cardPadding), + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.cardPadding, + vertical: AppDimens.spaceMd, + ), child: Row( children: [ Container( - width: AppDimens.buttonLarge, - height: AppDimens.buttonLarge, + width: _vipBadgeSize, + height: _vipBadgeSize, decoration: const BoxDecoration( - color: AppColors.contentSurface, + color: AppColors.vipBadgeSurface, shape: BoxShape.circle, ), child: const Icon( Icons.workspace_premium_rounded, - color: AppColors.textPrimary, + color: AppColors.onGold, size: 22, ), ), @@ -223,33 +60,44 @@ class MineVipCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - l10n.vipMembership, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: AppDimens.mineSectionTitle, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 3), - Text( - l10n.vipUnlockMessage, + l10n.vipBrandName, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - color: AppColors.textSecondary, + color: AppColors.onGold, + fontSize: AppDimens.sectionTitle, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + l10n.mineVipSubtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onGold, fontSize: AppDimens.caption, ), ), ], ), ), - Text( - l10n.joinVip, - style: const TextStyle( - color: AppColors.blue, - fontSize: AppDimens.body, - fontWeight: FontWeight.w700, + const SizedBox(width: AppDimens.spaceSm), + TextButton( + key: const Key('mine-vip-open'), + onPressed: onOpen, + style: TextButton.styleFrom( + minimumSize: const Size(0, _vipButtonHeight), + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.spaceMd, + ), + backgroundColor: AppColors.onGold, + foregroundColor: AppColors.textPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(_vipButtonHeight), + ), ), + child: Text(l10n.joinVip), ), ], ), @@ -260,7 +108,7 @@ class MineVipCard extends StatelessWidget { } } -/// 我的页功能菜单。 +/// 「我的」页单列功能菜单。 class MineMenuList extends StatelessWidget { const MineMenuList({ super.key, @@ -279,7 +127,7 @@ class MineMenuList extends StatelessWidget { return Container( key: const Key('mine-menu'), decoration: BoxDecoration( - color: AppColors.contentCard, + color: AppColors.mineCard, borderRadius: BorderRadius.circular(AppDimens.mineCardRadius), border: Border.all(color: AppColors.contentBorder), ), @@ -290,57 +138,63 @@ class MineMenuList extends StatelessWidget { child: Column( children: [ _row( - key: const Key('mine-works'), - icon: Icons.video_collection_outlined, - title: l10n.myWorks, - destination: MineDestination.works, + key: const Key('mine-wallet'), + icon: Icons.account_balance_wallet_outlined, + title: l10n.wallet, + destination: MineDestination.wallet, ), _divider(), _row( - key: const Key('mine-creator-center'), - icon: Icons.auto_awesome_rounded, - title: l10n.creatorCenter, - destination: MineDestination.creatorCenter, + key: const Key('mine-downloads'), + icon: Icons.download_outlined, + title: l10n.offlineDownloads, + destination: MineDestination.downloads, ), _divider(), _row( - key: const Key('mine-notices'), - icon: Icons.notifications_none_rounded, - title: l10n.notices, - destination: MineDestination.notices, + key: const Key('mine-history'), + icon: Icons.history_rounded, + title: l10n.watchHistory, + destination: MineDestination.history, ), _divider(), + _row( + key: const Key('mine-watch-preferences'), + icon: Icons.tune_rounded, + title: l10n.watchPreferences, + destination: MineDestination.preferences, + ), + _divider(), + _row( + key: const Key('mine-help'), + icon: Icons.support_agent_rounded, + title: l10n.contactSupport, + destination: MineDestination.help, + ), + _divider(), + _languageRow(l10n), + _divider(), _row( key: const Key('mine-settings'), icon: Icons.settings_outlined, title: l10n.settings, destination: MineDestination.settings, ), - _divider(), - _languageRow(context), - _divider(), - _row( - key: const Key('mine-help'), - icon: Icons.help_outline_rounded, - title: l10n.helpFeedback, - destination: MineDestination.help, - ), ], ), ), ); } - Widget _languageRow(BuildContext context) { - final l10n = AppLocalizations.of(context)!; + Widget _languageRow(AppLocalizations l10n) { return ListTile( key: const Key('mine-language'), minTileHeight: AppDimens.mineRowHeight, - contentPadding: const EdgeInsets.symmetric(horizontal: 14), + contentPadding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceLg), leading: const Icon( Icons.language_rounded, - color: AppColors.textSecondary, - size: 20, + color: AppColors.textPrimary, + size: AppDimens.mineMenuIcon, ), title: Text( l10n.language, @@ -360,7 +214,7 @@ class MineMenuList extends StatelessWidget { fontSize: AppDimens.caption, ), ), - const SizedBox(width: 4), + const SizedBox(width: AppDimens.spaceXs), const Icon( Icons.chevron_right_rounded, color: AppColors.textSecondary, @@ -381,8 +235,12 @@ class MineMenuList extends StatelessWidget { return ListTile( key: key, minTileHeight: AppDimens.mineRowHeight, - contentPadding: const EdgeInsets.symmetric(horizontal: 14), - leading: Icon(icon, color: AppColors.textSecondary, size: 20), + contentPadding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceLg), + leading: Icon( + icon, + color: AppColors.textPrimary, + size: AppDimens.mineMenuIcon, + ), title: Text( title, style: const TextStyle( @@ -403,8 +261,8 @@ class MineMenuList extends StatelessWidget { Widget _divider() { return const Divider( height: 1, - indent: 14, - endIndent: 14, + indent: AppDimens.spaceLg, + endIndent: AppDimens.spaceLg, color: AppColors.contentSurface, ); } diff --git a/lib/pages/mine/widgets/mine_mini_player.dart b/lib/pages/mine/widgets/mine_mini_player.dart new file mode 100644 index 0000000..5886061 --- /dev/null +++ b/lib/pages/mine/widgets/mine_mini_player.dart @@ -0,0 +1,124 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../core/widgets/remote_image.dart'; + +const double _infoHeight = 42; + +/// 「我的」页左下角最近观看迷你播放器。 +class MineMiniPlayer extends StatelessWidget { + const MineMiniPlayer({ + super.key, + required this.title, + required this.coverUrl, + required this.episodeLabel, + required this.onTap, + required this.onClose, + }); + + final String title; + final String coverUrl; + final String episodeLabel; + final VoidCallback onTap; + final VoidCallback onClose; + + @override + Widget build(BuildContext context) { + return SizedBox( + key: const Key('mine-mini-player'), + width: AppDimens.mineMiniWidth, + height: AppDimens.mineMiniHeight, + child: DecoratedBox( + decoration: BoxDecoration( + color: AppColors.mineCard, + borderRadius: BorderRadius.circular(AppDimens.mineMiniRadius), + border: Border.all(color: AppColors.contentBorder), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(AppDimens.mineMiniRadius), + child: Material( + color: AppColors.transparent, + child: InkWell( + onTap: onTap, + child: Stack( + children: [ + Column( + children: [ + Expanded( + child: RemoteImage( + coverUrl, + fit: BoxFit.cover, + showFallbackIcon: true, + fallbackColor: AppColors.contentSurface, + ), + ), + SizedBox( + height: _infoHeight, + child: ColoredBox( + color: AppColors.mineCard, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 4, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.mineMiniTitle, + fontWeight: FontWeight.w600, + ), + ), + Text( + episodeLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: AppDimens.mineMiniTitle, + ), + ), + ], + ), + ), + ), + ), + ], + ), + Positioned( + right: 3, + top: 3, + child: IconButton( + key: const Key('mine-mini-close'), + tooltip: MaterialLocalizations.of( + context, + ).closeButtonTooltip, + onPressed: onClose, + style: IconButton.styleFrom( + minimumSize: const Size.square(AppDimens.mineMiniClose), + maximumSize: const Size.square(AppDimens.mineMiniClose), + padding: EdgeInsets.zero, + backgroundColor: AppColors.controlScrim, + foregroundColor: AppColors.textPrimary, + ), + iconSize: 14, + icon: const Icon(Icons.close_rounded), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/test/config/l10n_id_test.dart b/test/config/l10n_id_test.dart index 4e09c28..0686c0b 100644 --- a/test/config/l10n_id_test.dart +++ b/test/config/l10n_id_test.dart @@ -13,7 +13,6 @@ void main() { expect(l10n.homeTab, 'Beranda'); expect(l10n.watchNow, 'Tonton gratis'); - expect(l10n.watchlist, 'Daftar Tonton'); expect(l10n.coins, 'Koin'); expect(l10n.signIn, 'Masuk'); }); diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart index 59675ae..239a01d 100644 --- a/test/home/home_nav_ui_test.dart +++ b/test/home/home_nav_ui_test.dart @@ -238,8 +238,8 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Viewer'), findsOneWidget); expect(find.byKey(const Key('mine-menu')), findsOneWidget); - expect(find.byKey(const Key('mine-watchlist')), findsOneWidget); - expect(find.byKey(const Key('mine-creator-center')), findsOneWidget); + expect(find.byKey(const Key('mine-wallet')), findsOneWidget); + expect(find.byKey(const Key('mine-edit')), findsOneWidget); expect(find.byKey(const Key('mine-library-tab-0')), findsNothing); await tester.tap(find.byKey(const Key('tab-follow'))); diff --git a/test/library/favorites_page_test.dart b/test/library/favorites_page_test.dart index 21579ef..adfff12 100644 --- a/test/library/favorites_page_test.dart +++ b/test/library/favorites_page_test.dart @@ -228,12 +228,10 @@ void main() { ), ); 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.tap(find.byKey(const Key('tab-follow'))); await tester.pumpAndSettle(); - await tester.tap(find.byKey(Key('favorite-card-${drama.id}'))); + await tester.tap(find.byKey(Key('following-card-${drama.id}'))); await tester.pumpAndSettle(); expect(find.byType(PlayerPage), findsOneWidget); diff --git a/test/mine/mine_preview_layout_test.dart b/test/mine/mine_preview_layout_test.dart index 5def2ce..587a02d 100644 --- a/test/mine/mine_preview_layout_test.dart +++ b/test/mine/mine_preview_layout_test.dart @@ -3,160 +3,73 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/models/watch_progress.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/theme/app_colors.dart'; import 'package:fruitshorts/core/theme/app_dimens.dart'; +import 'package:fruitshorts/core/theme/app_theme.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/auth/auth_view.dart'; +import 'package:fruitshorts/pages/mine/mine_types.dart'; +import 'package:fruitshorts/pages/mine/widgets/mine_menu.dart'; +import 'package:fruitshorts/pages/player/player_view.dart'; +import 'package:fruitshorts/pages/store/vip_page.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 '../support/fake_catalog.dart'; import '../support/fake_category.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() { - testWidgets('should_render_minimal_profile_shortcuts_wallet_vip_and_menu', ( + testWidgets('should_render_visitor_user_vip_and_seven_menu_rows', ( tester, ) async { - SharedPreferencesAsyncPlatform.instance = - InMemorySharedPreferencesAsync.empty(); - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - final catalog = FakeCatalogRepository(); - await tester.pumpWidget( - LumaDramaApp( - playerFactory: createWidgetPlayer, - catalogRepository: catalog, - categoryRepository: FakeCategoryRepository(catalog: catalog), - watchHistoryRepository: InMemoryWatchHistoryRepository(), - 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 _pumpApp(tester, signedIn: false); + await _openMine(tester); expect(find.byKey(const Key('mine-user-row')), findsOneWidget); - expect(find.byKey(const Key('mine-continue-watching')), findsNothing); - for (final key in const [ + expect(find.byKey(const Key('mine-avatar')), findsOneWidget); + expect(find.text('Visitor'), findsOneWidget); + expect(find.text('UID: --'), findsOneWidget); + expect(find.byKey(const Key('mine-uid-copy')), findsOneWidget); + expect(find.byKey(const Key('mine-login')), findsOneWidget); + expect(find.byKey(const Key('mine-edit')), findsNothing); + expect(find.byKey(const Key('mine-vip')), findsOneWidget); + expect(find.byKey(const Key('mine-vip-open')), findsOneWidget); + expect(find.byKey(const Key('mine-menu')), findsOneWidget); + expect(find.byKey(const Key('mine-mini-player')), findsNothing); + for (final oldShortcut in const [ Key('mine-watchlist'), - Key('mine-history'), + Key('mine-history-shortcut'), + Key('mine-downloads-shortcut'), + ]) { + expect(find.byKey(oldShortcut), findsNothing); + } + + final menuRows = tester.widgetList( + find.descendant( + of: find.byKey(const Key('mine-menu')), + matching: find.byType(ListTile), + ), + ); + expect(menuRows.map((row) => row.key), const [ + Key('mine-wallet'), Key('mine-downloads'), - Key('mine-wallet'), - Key('mine-wallet-recharge'), - Key('mine-vip'), - Key('mine-menu'), - Key('mine-works'), - Key('mine-creator-center'), - Key('mine-notices'), - Key('mine-settings'), - Key('mine-language'), + Key('mine-history'), + Key('mine-watch-preferences'), Key('mine-help'), - ]) { - expect(find.byKey(key), findsOneWidget); - } - expect(find.byKey(const Key('mine-language')), findsOneWidget); - expect(find.byKey(const Key('mine-library-tab-0')), findsNothing); - expect(find.byKey(const Key('mine-library-tab-1')), findsNothing); - expect(find.byKey(const Key('mine-library-tab-2')), findsNothing); - final surface = tester.widget( - find.byKey(const Key('mine-page-surface')), - ); - expect(surface.color, AppColors.appBackground); - final title = tester.widget( - find.descendant( - of: find.byKey(const Key('mine-page-surface')), - matching: find.text('My'), - ), - ); - expect(title.style?.color, AppColors.textPrimary); - expect(title.style?.fontSize, AppDimens.minePageTitle); - expect(title.style?.fontWeight, FontWeight.w700); - - for (final key in const [ - Key('mine-user-row'), - Key('mine-wallet'), - Key('mine-vip'), - Key('mine-menu'), - ]) { - final container = tester.widget(find.byKey(key)); - final decoration = container.decoration! as BoxDecoration; - expect(decoration.color, AppColors.contentCard); - expect( - decoration.borderRadius, - BorderRadius.circular(AppDimens.mineCardRadius), - ); - expect(decoration.gradient, isNull); - expect(decoration.boxShadow, isNull); - expect((decoration.border! as Border).top.color, AppColors.contentBorder); - } - - final shortcutIcon = tester.widget( - find.descendant( - of: find.byKey(const Key('mine-watchlist')), - matching: find.byType(Icon), - ), - ); - expect(shortcutIcon.size, 24); - expect(shortcutIcon.color, AppColors.textPrimary); - final shortcutKeys = [ - const Key('mine-watchlist'), - const Key('mine-history'), - const Key('mine-downloads'), - ]; - final shortcutWidths = shortcutKeys - .map((key) => tester.getSize(find.byKey(key)).width) - .toList(); - expect(shortcutWidths.toSet(), hasLength(1)); - final shortcutCard = tester.widget( - find - .descendant( - of: find.byKey(const Key('mine-watchlist')), - matching: find.byType(Container), - ) - .first, - ); - final shortcutDecoration = shortcutCard.decoration! as BoxDecoration; - expect(shortcutDecoration.color, AppColors.contentCard); + Key('mine-language'), + Key('mine-settings'), + ]); + expect(menuRows, hasLength(7)); expect( - shortcutDecoration.borderRadius, - BorderRadius.circular(AppDimens.mineCardRadius), - ); - expect(shortcutDecoration.boxShadow, isNull); - final menuRows = find.descendant( - of: find.byKey(const Key('mine-menu')), - matching: find.byType(ListTile), - ); - expect(menuRows, findsNWidgets(6)); - for (final row in tester.widgetList(menuRows)) { - expect(row.minTileHeight, AppDimens.mineRowHeight); - } - final settingsRow = tester.widget( - find.byKey(const Key('mine-settings')), - ); - final settingsTitle = settingsRow.title! as Text; - expect(settingsTitle.style?.color, AppColors.textPrimary); - expect(settingsTitle.style?.fontSize, AppDimens.listTitle); - expect((settingsRow.trailing! as Icon).color, AppColors.textSecondary); - final recharge = tester.widget( - find.byKey(const Key('mine-wallet-recharge')), - ); - expect(recharge.style?.backgroundColor?.resolve(const {}), AppColors.blue); - expect( - tester.widget(find.text('Join VIP')).style?.color, - AppColors.blue, + menuRows.every((row) => row.minTileHeight == AppDimens.mineRowHeight), + isTrue, ); final dividers = tester.widgetList( find.descendant( @@ -164,15 +77,186 @@ void main() { matching: find.byType(Divider), ), ); - expect(dividers, hasLength(5)); + expect(dividers, hasLength(6)); expect( dividers.every((divider) => divider.color == AppColors.contentSurface), isTrue, ); + + final menu = tester.widget(find.byKey(const Key('mine-menu'))); + final menuDecoration = menu.decoration! as BoxDecoration; + expect(menuDecoration.color, AppColors.mineCard); + expect( + menuDecoration.borderRadius, + BorderRadius.circular(AppDimens.mineCardRadius), + ); + expect(menuDecoration.boxShadow, isNull); + final vip = tester.widget(find.byKey(const Key('mine-vip'))); + final vipDecoration = vip.decoration! as BoxDecoration; + expect((vipDecoration.gradient! as LinearGradient).colors, const [ + AppColors.vipGoldStart, + AppColors.vipGoldEnd, + ]); + expect(vipDecoration.boxShadow, isNull); expect(tester.takeException(), isNull); - await tester.tap(find.byKey(const Key('mine-watchlist'))); + await tester.tap(find.byKey(const Key('mine-login'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('favorites-page')), findsOneWidget); + expect(find.byType(AuthPage), findsOneWidget); + }); + + testWidgets('should_render_signed_in_user_and_dismiss_mini_player', ( + tester, + ) async { + await _pumpApp( + tester, + signedIn: true, + history: InMemoryWatchHistoryRepository(seed: [_recent]), + ); + await _openMine(tester); + + expect(find.text('Viewer'), findsOneWidget); + expect(find.text('UID: U-100'), findsOneWidget); + expect(find.byKey(const Key('mine-edit')), findsOneWidget); + expect(find.byKey(const Key('mine-login')), findsNothing); + expect(find.byKey(const Key('mine-mini-player')), findsOneWidget); + expect(find.text('Recent drama'), findsOneWidget); + expect(find.text('Episode 3'), findsOneWidget); + expect( + tester.getSize(find.byKey(const Key('mine-mini-player'))), + const Size(AppDimens.mineMiniWidth, AppDimens.mineMiniHeight), + ); + + await tester.tap(find.byKey(const Key('mine-mini-close'))); + await tester.pump(); + expect(find.byKey(const Key('mine-mini-player')), findsNothing); + + await tester.tap(find.byKey(const Key('mine-edit'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('mine-edit-profile')), findsOneWidget); + expect(find.byKey(const Key('mine-edit-works')), findsOneWidget); + expect(find.byKey(const Key('mine-edit-creator')), findsOneWidget); + expect(find.byKey(const Key('mine-edit-notices')), findsOneWidget); + await tester.tap(find.byKey(const Key('mine-edit-works'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('mine-placeholder-page')), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('should_open_vip_and_recent_player_from_mine_entries', ( + tester, + ) async { + await _pumpApp( + tester, + signedIn: true, + history: InMemoryWatchHistoryRepository(seed: [_recent]), + ); + await _openMine(tester); + + await tester.tap(find.byKey(const Key('mine-vip-open'))); + await tester.pumpAndSettle(); + expect(find.byType(VipPage), findsOneWidget); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('mine-mini-player'))); + await tester.pumpAndSettle(); + final player = tester.widget(find.byType(PlayerPage).last); + expect(player.embedded, isFalse); + }); + + testWidgets('should_expose_seven_menu_destinations_in_order', (tester) async { + tester.view.physicalSize = const Size(390, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final opened = []; + var languageOpened = 0; + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark(), + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Scaffold( + body: SingleChildScrollView( + padding: const EdgeInsets.all(AppDimens.pageHorizontal), + child: MineMenuList( + onOpen: opened.add, + onLanguage: () => languageOpened++, + languageLabel: 'English', + ), + ), + ), + ), + ); + + const rows = [ + (Key('mine-wallet'), MineDestination.wallet), + (Key('mine-downloads'), MineDestination.downloads), + (Key('mine-history'), MineDestination.history), + (Key('mine-watch-preferences'), MineDestination.preferences), + (Key('mine-help'), MineDestination.help), + ]; + for (final row in rows) { + await tester.tap(find.byKey(row.$1)); + await tester.pump(); + expect(opened.last, row.$2); + } + await tester.tap(find.byKey(const Key('mine-language'))); + await tester.pump(); + expect(languageOpened, 1); + await tester.tap(find.byKey(const Key('mine-settings'))); + await tester.pump(); + expect(opened.last, MineDestination.settings); }); } + +const _recent = WatchProgress( + dramaId: 'city-lights-demo', + episodeId: 'city-lights-demo-3', + dramaTitle: 'Recent drama', + dramaCover: '/files/recent.jpg', + episodeNumber: 3, + totalEpisodes: 8, + position: Duration(seconds: 30), + duration: Duration(minutes: 2), +); + +Future _openMine(WidgetTester tester) async { + await tester.tap(find.byKey(const Key('tab-mine'))); + await tester.pumpAndSettle(); +} + +Future _pumpApp( + WidgetTester tester, { + required bool signedIn, + WatchHistoryRepository? history, +}) async { + tester.view.physicalSize = const Size(390, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + SharedPreferencesAsyncPlatform.instance = signedIn + ? InMemorySharedPreferencesAsync.withData({ + 'auth.accessToken': 'access', + 'auth.refreshToken': 'refresh', + 'auth.user': '{"uid":"U-100","nickname":"Viewer","avatarUrl":""}', + }) + : InMemorySharedPreferencesAsync.empty(); + final catalog = FakeCatalogRepository(); + await tester.pumpWidget( + LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: catalog, + categoryRepository: FakeCategoryRepository(catalog: catalog), + watchHistoryRepository: history ?? InMemoryWatchHistoryRepository(), + favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + noticeRepository: FakeNoticeRepository(), + ), + ); + await tester.pumpAndSettle(); +} diff --git a/test/notice/notices_entry_test.dart b/test/notice/notices_entry_test.dart index 5515a63..f9de1bf 100644 --- a/test/notice/notices_entry_test.dart +++ b/test/notice/notices_entry_test.dart @@ -17,7 +17,7 @@ import '../support/fake_notice_repository.dart'; import '../support/widget_player.dart'; void main() { - testWidgets('should_open_notices_from_mine_menu_without_login_gate', ( + testWidgets('should_open_notices_from_settings_without_login_gate', ( tester, ) async { SharedPreferencesAsyncPlatform.instance = @@ -46,9 +46,11 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-mine'))); await tester.pumpAndSettle(); - await tester.ensureVisible(find.byKey(const Key('mine-notices'))); + await tester.ensureVisible(find.byKey(const Key('mine-settings'))); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('mine-notices'))); + await tester.tap(find.byKey(const Key('mine-settings'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('settings-notices'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('notices-page')), findsOneWidget); diff --git a/test/widget_test.dart b/test/widget_test.dart index a6621d5..28f8aa5 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -135,7 +135,7 @@ void main() { await tester.tap(find.byKey(const Key('tab-mine'))); await tester.pumpAndSettle(); expect(find.text('Vicky sheng'), findsOneWidget); - expect(find.text('Coins'), findsOneWidget); + expect(find.text('Netshort VIP'), findsOneWidget); expect(find.byKey(const Key('mine-language')), findsOneWidget); expect(find.byKey(const Key('mine-wallet')), findsOneWidget); @@ -297,9 +297,7 @@ void main() { await tester.tap(find.byKey(const Key('player-back'))); 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.tap(find.byKey(const Key('tab-follow'))); await tester.pumpAndSettle(); expect(find.text('City Lights & Velvet Nights'), findsWidgets); From 79bdef4b436cc51417d78d6a27a8931e015ee4d5 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 17:44:59 +0800 Subject: [PATCH 030/129] =?UTF-8?q?docs(mine):=20=E5=9B=9E=E5=A1=AB?= =?UTF-8?q?=E6=88=91=E7=9A=84=E9=A1=B5=E6=94=B9=E7=89=88=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 3ecc516..e62f42d 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1000,3 +1000,42 @@ $ flutter test 解读:三 tab、双列网格、后端历史元数据直出、编辑删除确认、最后一条自动 退出编辑、未登录与空态均有测试覆盖;`following_page.dart` 低于 500 行。 `pubspec.lock` 未提交,未操作真机。 + +## 2026-10-22 「我的」页布局重排与迷你播放器 + +- 分支:`feature/mine-layout-redesign` +- 基线:`develop` / `origin/develop` = `5aa8170` +- 实现提交:`cbc6d80 feat(mine): 重排我的页并新增迷你播放器` + +### 页面与入口 + +- 用户区改为 56 圆形头像、20/w700 昵称与 13 灰色 UID;UID 支持复制, + 未登录显示 Visitor 与登录按钮,已登录显示编辑按钮。 +- VIP 改为金色渐变通栏卡,品牌文案走 l10n,右侧开通按钮复用现有 VIP 页。 +- 删除收藏/历史/下载三宫格;单列菜单固定为钱包、离线缓存、播放历史、 + 观看偏好、联系客服、语言、设置,行高 56、图标 22、仅行间显示分割线。 +- 收藏由底部追剧 tab 承载;历史、下载、钱包、偏好、客服、语言、设置均 + 复用现有页面。我的作品、创作者中心与通知保留在登录后的编辑操作面板, + 设置页中的通知入口继续可用。 +- 我的页 token 收敛到 `AppColors/AppDimens`:背景纯黑、卡片 `#16161A`、 + 16 圆角、极淡描边、金色渐变仅用于 VIP。 +- 左下角迷你播放器为 96x132,直接使用观看历史首条数据,展示封面、剧名和 + 集号;无记录或关闭后不占位,点击按记录集号进入全屏播放器。 +- 删除三宫格对应的 `localViewer/watchlist` 废弃 l10n key 与旧组件代码。 + +### 门禁命令与原始输出 + +```text +$ flutter analyze +Analyzing reelshort-mine-redesign... +No issues found! (ran in 2.1s) +``` + +```text +$ flutter test +01:01 +647: All tests passed! +``` + +解读:用户区、UID、登录/编辑、VIP、7 项菜单顺序与跳转、旧入口可达性、 +迷你播放器显隐/关闭/开播及无数据不渲染均有测试覆盖。`mine_view.dart` +与新增子组件均低于 500 行;`pubspec.lock` 未提交,未操作真机。 From 9cf832b318f99f1e02bc2a7e5789fdbd685e04f7 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 17:58:43 +0800 Subject: [PATCH 031/129] =?UTF-8?q?refactor(navigation):=20=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=E5=85=A8=E5=B1=80=E4=B8=A4=E4=BE=A7=E6=82=AC=E6=B5=AE?= =?UTF-8?q?=E5=85=83=E7=B4=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 31 +++++ lib/core/theme/app_dimens.dart | 5 - lib/pages/mine/mine_view.dart | 85 +++---------- lib/pages/mine/widgets/mine_mini_player.dart | 124 ------------------- test/home/home_grid_layout_test.dart | 2 + test/home/home_nav_ui_test.dart | 41 ++++++ test/home/k28_a_navigation_test.dart | 9 ++ test/mine/mine_preview_layout_test.dart | 23 +--- 8 files changed, 103 insertions(+), 217 deletions(-) delete mode 100644 lib/pages/mine/widgets/mine_mini_player.dart diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index e62f42d..0a3327d 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1039,3 +1039,34 @@ $ flutter test 解读:用户区、UID、登录/编辑、VIP、7 项菜单顺序与跳转、旧入口可达性、 迷你播放器显隐/关闭/开播及无数据不渲染均有测试覆盖。`mine_view.dart` 与新增子组件均低于 500 行;`pubspec.lock` 未提交,未操作真机。 + +## 2026-10-22 全局移除两侧悬浮元素 + +- 分支:`feature/remove-floating-overlays` +- 基线:`develop` / `origin/develop` = `5cead7c` + +### 改动 + +- 保留首页顶部礼物入口的 `home-wallet-badge` 红色余额角标。 +- 删除「我的」页 `MineMiniPlayer` 文件、渲染分支及最近观看开播逻辑。 +- 删除仅为迷你播放器新增的 `mineMini*` 尺寸 token;没有新增专属 l10n key。 +- 首页、推荐、剧单、全部短剧和追剧页均不渲染两侧游戏/活动悬浮球、 + 悬浮关闭按钮或迷你播放器。 +- 卡片角标、播放量、标题、标签和顶部钱包红标保持不变。 + +### 门禁命令与原始输出 + +```text +$ flutter analyze +Analyzing reelshort-home-floating... +No issues found! (ran in 2.5s) +``` + +```text +$ flutter test +01:32 +647: All tests passed! +``` + +解读:登录且钱包快照存在时钱包红标仍显示;首页四个 tab、推荐三个子 tab、 +追剧页和我的页均断言不存在两侧悬浮组件。`pubspec.lock` 未提交, +未操作真机。 diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 7706f55..c752040 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -97,9 +97,4 @@ abstract final class AppDimens { static const double mineUserName = 20; static const double mineUid = 13; static const double mineMenuIcon = 22; - static const double mineMiniWidth = 96; - static const double mineMiniHeight = 132; - static const double mineMiniRadius = 8; - static const double mineMiniTitle = 10; - static const double mineMiniClose = 22; } diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index 3f77213..fea424f 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -5,11 +5,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../../core/data/models/drama.dart'; -import '../../core/data/models/watch_progress.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/auth_controller.dart'; -import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/locale_controller.dart'; import '../../core/data/services/wallet_controller.dart'; import '../../core/theme/app_colors.dart'; @@ -23,7 +20,6 @@ import '../downloads/downloads_page.dart'; import '../account/account_profile_page.dart'; import '../filter/filter_page.dart'; import '../notices/notices_page.dart'; -import '../player/open_player.dart'; import '../settings/settings_page.dart'; import '../store/vip_page.dart'; import '../wallet/wallet_page.dart'; @@ -33,7 +29,6 @@ import 'mine_logic.dart'; import 'mine_types.dart'; import 'widgets/mine_header.dart'; import 'widgets/mine_menu.dart'; -import 'widgets/mine_mini_player.dart'; /// K28-B 我的页。 class MyPage extends StatefulWidget { @@ -52,7 +47,6 @@ class MyPage extends StatefulWidget { class _MyPageState extends State { late final MineLogic logic; - bool _miniDismissed = false; bool get embedded => widget.callbacks.embedded; @@ -133,44 +127,23 @@ class _MyPageState extends State { Widget _body(BuildContext context, AppLocalizations l10n) { final locale = context.watch(); - final recent = _miniRecord; - return Stack( + return ListView( + key: const Key('mine-page-scroll'), + padding: const EdgeInsets.fromLTRB( + AppDimens.pageHorizontal, + 0, + AppDimens.pageHorizontal, + AppDimens.spaceXl, + ), children: [ - Positioned.fill( - child: ListView( - key: const Key('mine-page-scroll'), - padding: EdgeInsets.fromLTRB( - AppDimens.pageHorizontal, - 0, - AppDimens.pageHorizontal, - recent == null ? AppDimens.spaceXl : 156, - ), - children: [ - MineHeader(onLogin: _openLogin, onEdit: _openEditActions), - MineVipBanner( - onOpen: () => _openDestination(MineDestination.vip), - ), - const SizedBox(height: AppDimens.spaceMd), - MineMenuList( - onOpen: _openDestination, - onLanguage: _openLanguage, - languageLabel: _languageLabel(l10n, locale.locale), - ), - ], - ), + MineHeader(onLogin: _openLogin, onEdit: _openEditActions), + MineVipBanner(onOpen: () => _openDestination(MineDestination.vip)), + const SizedBox(height: AppDimens.spaceMd), + MineMenuList( + onOpen: _openDestination, + onLanguage: _openLanguage, + languageLabel: _languageLabel(l10n, locale.locale), ), - if (recent != null) - Positioned( - left: AppDimens.pageHorizontal, - bottom: AppDimens.spaceMd, - child: MineMiniPlayer( - title: _miniTitle(l10n, recent), - coverUrl: recent.dramaCover?.trim() ?? '', - episodeLabel: l10n.episodeLabel(recent.episodeNumber ?? 0), - onTap: () => unawaited(_openRecent(recent)), - onClose: () => setState(() => _miniDismissed = true), - ), - ), ], ); } @@ -300,34 +273,6 @@ class _MyPageState extends State { await context.read().select(selected); } - WatchProgress? get _miniRecord { - if (_miniDismissed || logic.recent.isEmpty) return null; - final recent = logic.recent.first; - return recent.episodeId.isEmpty ? null : recent; - } - - String _miniTitle(AppLocalizations l10n, WatchProgress recent) { - final title = recent.dramaTitle?.trim() ?? ''; - return title.isEmpty ? l10n.episodeLabel(recent.episodeNumber ?? 0) : title; - } - - Future _openRecent(WatchProgress recent) async { - final catalog = context.read(); - final seed = Drama( - id: recent.dramaId, - title: recent.dramaTitle?.trim() ?? '', - synopsis: '', - posterUrl: recent.dramaCover?.trim() ?? '', - ); - try { - final detail = await catalog.requireDetail(seed); - if (!mounted) return; - await playDramaFromCatalog(context, detail, episodeId: recent.episodeId); - } catch (_) { - // 最近记录失效时不投射错误页,等待下一次记录刷新。 - } - } - Widget _bottomNav() { return AppRootBottomNav( selected: AppRootTab.mine, diff --git a/lib/pages/mine/widgets/mine_mini_player.dart b/lib/pages/mine/widgets/mine_mini_player.dart deleted file mode 100644 index 5886061..0000000 --- a/lib/pages/mine/widgets/mine_mini_player.dart +++ /dev/null @@ -1,124 +0,0 @@ -// Author: fruit.com.cn - -import 'package:flutter/material.dart'; - -import '../../../core/theme/app_colors.dart'; -import '../../../core/theme/app_dimens.dart'; -import '../../../core/widgets/remote_image.dart'; - -const double _infoHeight = 42; - -/// 「我的」页左下角最近观看迷你播放器。 -class MineMiniPlayer extends StatelessWidget { - const MineMiniPlayer({ - super.key, - required this.title, - required this.coverUrl, - required this.episodeLabel, - required this.onTap, - required this.onClose, - }); - - final String title; - final String coverUrl; - final String episodeLabel; - final VoidCallback onTap; - final VoidCallback onClose; - - @override - Widget build(BuildContext context) { - return SizedBox( - key: const Key('mine-mini-player'), - width: AppDimens.mineMiniWidth, - height: AppDimens.mineMiniHeight, - child: DecoratedBox( - decoration: BoxDecoration( - color: AppColors.mineCard, - borderRadius: BorderRadius.circular(AppDimens.mineMiniRadius), - border: Border.all(color: AppColors.contentBorder), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(AppDimens.mineMiniRadius), - child: Material( - color: AppColors.transparent, - child: InkWell( - onTap: onTap, - child: Stack( - children: [ - Column( - children: [ - Expanded( - child: RemoteImage( - coverUrl, - fit: BoxFit.cover, - showFallbackIcon: true, - fallbackColor: AppColors.contentSurface, - ), - ), - SizedBox( - height: _infoHeight, - child: ColoredBox( - color: AppColors.mineCard, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 4, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: AppDimens.mineMiniTitle, - fontWeight: FontWeight.w600, - ), - ), - Text( - episodeLabel, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.textSecondary, - fontSize: AppDimens.mineMiniTitle, - ), - ), - ], - ), - ), - ), - ), - ], - ), - Positioned( - right: 3, - top: 3, - child: IconButton( - key: const Key('mine-mini-close'), - tooltip: MaterialLocalizations.of( - context, - ).closeButtonTooltip, - onPressed: onClose, - style: IconButton.styleFrom( - minimumSize: const Size.square(AppDimens.mineMiniClose), - maximumSize: const Size.square(AppDimens.mineMiniClose), - padding: EdgeInsets.zero, - backgroundColor: AppColors.controlScrim, - foregroundColor: AppColors.textPrimary, - ), - iconSize: 14, - icon: const Icon(Icons.close_rounded), - ), - ), - ], - ), - ), - ), - ), - ), - ); - } -} diff --git a/test/home/home_grid_layout_test.dart b/test/home/home_grid_layout_test.dart index e07119b..e0f4d35 100644 --- a/test/home/home_grid_layout_test.dart +++ b/test/home/home_grid_layout_test.dart @@ -71,6 +71,8 @@ void main() { expect(find.text('Charts'), findsOneWidget); expect(find.text('Costume'), findsOneWidget); expect(find.text('Community'), findsOneWidget); + expect(find.byKey(const Key('mine-mini-player')), findsNothing); + expect(find.byType(FloatingActionButton), findsNothing); }); testWidgets('should_hide_unmatched_content_sections', (tester) async { diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart index 239a01d..53e7ce4 100644 --- a/test/home/home_nav_ui_test.dart +++ b/test/home/home_nav_ui_test.dart @@ -3,6 +3,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/models/wallet.dart'; +import 'package:fruitshorts/core/data/repositories/commerce_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'; @@ -46,6 +48,10 @@ void main() { for (final key in _keys) { expect(find.byKey(key), findsOneWidget); } + expect(find.byKey(const Key('home-wallet-entry')), findsOneWidget); + expect(find.byKey(const Key('home-wallet-badge')), findsOneWidget); + expect(find.byKey(const Key('mine-mini-player')), findsNothing); + expect(find.byType(FloatingActionButton), findsNothing); for (final key in _keys.skip(1)) { expect(tester.getSize(find.byKey(key)).height, greaterThanOrEqualTo(48)); } @@ -214,6 +220,7 @@ void main() { await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('recommend-page')), findsOneWidget); + _expectNoFloatingOverlays(); await tester.tap(find.byKey(const Key('tab-follow'))); await tester.pumpAndSettle(); @@ -226,6 +233,7 @@ void main() { findsNothing, ); expect(Navigator.canPop(tester.element(favorites)), isFalse); + _expectNoFloatingOverlays(); expect( find.descendant( of: find.byKey(const Key('tab-follow')), @@ -241,6 +249,7 @@ void main() { expect(find.byKey(const Key('mine-wallet')), findsOneWidget); expect(find.byKey(const Key('mine-edit')), findsOneWidget); expect(find.byKey(const Key('mine-library-tab-0')), findsNothing); + _expectNoFloatingOverlays(); await tester.tap(find.byKey(const Key('tab-follow'))); await tester.pumpAndSettle(); @@ -287,6 +296,7 @@ Future _pumpApp(WidgetTester tester, [FakeHaptics? haptics]) async { playbackRepository: FakePlaybackRepository(), commentRepository: FakeCommentRepository(), interactionRepository: FakeInteractionRepository(), + commerceRepository: const _WalletCommerceRepository(), haptics: haptics ?? FakeHaptics(), ), ); @@ -294,3 +304,34 @@ Future _pumpApp(WidgetTester tester, [FakeHaptics? haptics]) async { await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); } + +class _WalletCommerceRepository implements CommerceRepository { + const _WalletCommerceRepository(); + + @override + Future fetchWallet() async { + return const WalletSnapshot( + wallet: CoinWallet( + coinBalance: 320, + totalRechargedCoin: 500, + totalConsumedCoin: 180, + totalRefundedCoin: 0, + ), + ); + } + + @override + Future unlock(String episodeId) async { + return const UnlockResult( + unlocked: true, + accessType: 'UNLOCKED', + coinCost: 0, + coinBalance: 320, + ); + } +} + +void _expectNoFloatingOverlays() { + expect(find.byKey(const Key('mine-mini-player')), findsNothing); + expect(find.byType(FloatingActionButton), findsNothing); +} diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index b88287a..cec7ade 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -82,12 +82,14 @@ void main() { expect(find.byKey(const Key('recommend-tab-playlist')), findsOneWidget); expect(find.byKey(const Key('recommend-tab-all')), findsOneWidget); expect(find.byKey(const Key('recommend-feed-player')), findsOneWidget); + _expectNoFloatingOverlays(); await tester.tap(find.byKey(const Key('recommend-tab-playlist'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('recommend-section-hits')), findsOneWidget); expect(find.byKey(const Key('recommend-section-new')), findsOneWidget); expect(find.byKey(const Key('recommend-section-costume')), findsOneWidget); + _expectNoFloatingOverlays(); }); testWidgets('should_switch_playlist_and_all_dramas_in_recommend', ( @@ -101,6 +103,7 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('recommend-section-hits')), findsOneWidget); expect(find.byType(AppBottomNav), findsOneWidget); + _expectNoFloatingOverlays(); await tester.tap(find.byKey(const Key('recommend-tab-all'))); await tester.pumpAndSettle(); @@ -108,6 +111,7 @@ void main() { for (var row = 0; row < 4; row++) { expect(find.byKey(Key('all-filter-row-$row')), findsOneWidget); } + _expectNoFloatingOverlays(); }); testWidgets('should_open_search_from_home_and_recommend', (tester) async { @@ -159,6 +163,11 @@ const _tabKeys = [ Key('tab-mine'), ]; +void _expectNoFloatingOverlays() { + expect(find.byKey(const Key('mine-mini-player')), findsNothing); + expect(find.byType(FloatingActionButton), findsNothing); +} + bool _hasLightStatusBar(WidgetTester tester) => tester .widgetList>( find.byType(AnnotatedRegion), diff --git a/test/mine/mine_preview_layout_test.dart b/test/mine/mine_preview_layout_test.dart index 587a02d..a8396cb 100644 --- a/test/mine/mine_preview_layout_test.dart +++ b/test/mine/mine_preview_layout_test.dart @@ -14,7 +14,6 @@ import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/auth/auth_view.dart'; import 'package:fruitshorts/pages/mine/mine_types.dart'; import 'package:fruitshorts/pages/mine/widgets/mine_menu.dart'; -import 'package:fruitshorts/pages/player/player_view.dart'; import 'package:fruitshorts/pages/store/vip_page.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'; @@ -105,7 +104,7 @@ void main() { expect(find.byType(AuthPage), findsOneWidget); }); - testWidgets('should_render_signed_in_user_and_dismiss_mini_player', ( + testWidgets('should_render_signed_in_user_without_floating_mini_player', ( tester, ) async { await _pumpApp( @@ -119,17 +118,8 @@ void main() { expect(find.text('UID: U-100'), findsOneWidget); expect(find.byKey(const Key('mine-edit')), findsOneWidget); expect(find.byKey(const Key('mine-login')), findsNothing); - expect(find.byKey(const Key('mine-mini-player')), findsOneWidget); - expect(find.text('Recent drama'), findsOneWidget); - expect(find.text('Episode 3'), findsOneWidget); - expect( - tester.getSize(find.byKey(const Key('mine-mini-player'))), - const Size(AppDimens.mineMiniWidth, AppDimens.mineMiniHeight), - ); - - await tester.tap(find.byKey(const Key('mine-mini-close'))); - await tester.pump(); expect(find.byKey(const Key('mine-mini-player')), findsNothing); + expect(find.byType(FloatingActionButton), findsNothing); await tester.tap(find.byKey(const Key('mine-edit'))); await tester.pumpAndSettle(); @@ -143,7 +133,7 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('should_open_vip_and_recent_player_from_mine_entries', ( + testWidgets('should_open_vip_from_mine_without_floating_player', ( tester, ) async { await _pumpApp( @@ -158,11 +148,8 @@ void main() { expect(find.byType(VipPage), findsOneWidget); await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); - - await tester.tap(find.byKey(const Key('mine-mini-player'))); - await tester.pumpAndSettle(); - final player = tester.widget(find.byType(PlayerPage).last); - expect(player.embedded, isFalse); + expect(find.byKey(const Key('mine-mini-player')), findsNothing); + expect(find.byType(FloatingActionButton), findsNothing); }); testWidgets('should_expose_seven_menu_destinations_in_order', (tester) async { From d7a59e5a27bd061642f71a83bb4d7d26f857f466 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 18:06:03 +0800 Subject: [PATCH 032/129] =?UTF-8?q?style(client):=20=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E7=9F=AD=E5=89=A7=E5=88=97=E8=A1=A8=E5=AD=97=E5=8F=B7=E4=B8=8E?= =?UTF-8?q?=E9=97=B4=E8=B7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 38 ++++++ lib/core/theme/app_colors.dart | 1 + lib/core/theme/app_dimens.dart | 48 ++++++-- lib/pages/following/following_grid_card.dart | 64 ++++++++-- lib/pages/following/following_page.dart | 113 +++++++++++------- lib/pages/home/widgets/home_feed_card.dart | 35 +++--- lib/pages/home/widgets/home_feed_grid.dart | 6 +- lib/pages/home/widgets/home_grid_header.dart | 34 +++--- lib/pages/recommend/recommend_page.dart | 8 +- .../widgets/all_dramas_filter_grid.dart | 24 ++-- .../widgets/recommend_section_card.dart | 82 ++++++++++--- .../widgets/recommend_sections_view.dart | 13 +- test/following/following_page_test.dart | 73 +++++++++++ test/home/home_card_style_test.dart | 41 +++++-- test/home/home_preview_layout_test.dart | 72 ++++++++++- test/mine/mine_preview_layout_test.dart | 8 ++ test/recommend/recommend_sections_test.dart | 64 ++++++++++ 17 files changed, 576 insertions(+), 148 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 0a3327d..be697c7 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1070,3 +1070,41 @@ $ flutter test 解读:登录且钱包快照存在时钱包红标仍显示;首页四个 tab、推荐三个子 tab、 追剧页和我的页均断言不存在两侧悬浮组件。`pubspec.lock` 未提交, 未操作真机。 + +## 2026-10-22 首页与短剧列表视觉字号对齐 + +- 分支:`feature/visual-type-scale` +- 基线:`develop` / `origin/develop` = `d03dcf1` + +### Token 与组件 + +- 新增 `textTertiary = #9E9E9E`,以及搜索、页签、筛选 chip、分区标题、 + 卡片标题/播放量/标签/角标、封面比例和网格间距的公共 token。 +- 搜索框统一为高 40、圆角 8、placeholder 13;一级分类 16,选中 + 白字 w700,未选中 `#9E9E9E` w400;二级标签 13 并采用参考图选中/未选中 + 底色。 +- 三列首页网格与全部短剧网格统一左右边距 14、列间距 6、行间距 18; + 封面 5:6、圆角 6。 +- 首页、剧单横滑、追剧双列卡片统一标题 15/w500/1.3、播放量 11 白字、 + 标签 11/高 18/左右 6,封面到标题 8、标题到标签 6。 +- 剧单分区标题 17/w700,副标题 11 `#8E8E93`,更多 12 `#8E8E93`; + 分区间距保持 24。 +- 全部短剧筛选 chip 统一高 30、圆角 15、字号 13;我的页标题 17, + 追剧顶部页签 16。 + +### 门禁命令与原始输出 + +```text +$ flutter analyze +Analyzing reelshort-visual-type... +No issues found! (ran in 1.8s) +``` + +```text +$ flutter test +00:57 +648: All tests passed! +``` + +解读:首页搜索/页签/二级标签、三类卡片字体与标签、5:6 封面、网格间距、 +筛选 chip、我的标题和追剧双列 320 宽度适配均有测试覆盖。目标组件均低于 +500 行;`pubspec.lock` 未提交,未操作真机。 diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 08aa77e..70f8447 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -79,6 +79,7 @@ abstract final class AppColors { /// 次级文字与列表 chevron。 static const textPrimary = Color(0xFFFFFFFF); static const textSecondary = Color(0xFF8E8E93); + static const textTertiary = Color(0xFF9E9E9E); /// 原生开屏与冷启动过渡底色。 static const splashInk = ink; diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index c752040..780df01 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -54,7 +54,9 @@ abstract final class AppDimens { static const double progressRadius = 2; static const double progressLineHeight = 3; - static const double searchHeight = 44; + static const double searchHeight = 40; + static const double searchRadius = 8; + static const double searchPlaceholderFont = 13; static const double navDot = 3; static const double glassBlur = 24; @@ -75,23 +77,51 @@ abstract final class AppDimens { static const double cardShadowY = 0; /// 首页探索网格。 - static const double homeGridGap = 8; - static const double homeCardRadius = 8; + static const double feedPageHorizontal = 14; + static const double homeGridGap = 6; + static const double homeGridRowGap = 18; + static const double homeCardRadius = 6; static const double homeCardPadding = 8; - static const double homeCardTitle = 13; - static const double homeCardMeta = 10; - static const double homeCardBadge = 9; - static const double homeTagFont = 10; + static const double cardTitleFont = 15; + static const double cardTitleLineHeight = 1.3; + static const double cardViewsFont = 11; + static const double cardViewsIcon = 11; + static const double cardTagFont = 11; + static const double cardTagHeight = 18; + static const double cardTagRadius = 9; + static const double cardTagHorizontalPadding = 6; + static const double cardBadgeFont = 10; + static const double cardBadgeRadius = 3; + static const double cardBadgeHorizontalPadding = 6; + static const double cardBadgeVerticalPadding = 2; + static const double cardCoverTitleGap = 8; + static const double cardTitleTagGap = 6; + static const double cardCoverAspectRatio = 5 / 6; static const double homeCardBodyExtent = 84; - static const double homeSectionFont = 14; + static const double homePrimaryTabFont = 16; + static const double homeSecondaryTagFont = 13; static const double homeSectionIndicator = 2; static const double homeIconSlot = 44; static const double homeHeaderIcon = 22; + /// 推荐分区。 + static const double recommendSectionTitle = 17; + static const double recommendSectionSubtitle = 11; + static const double recommendSectionMore = 12; + static const double recommendRowHeight = 252; + static const double recommendCardWidth = 122; + + /// 全部短剧筛选与追剧双列。 + static const double filterChipFont = 13; + static const double filterChipHeight = 30; + static const double filterChipRadius = 15; + static const double followingTabFont = 16; + static const double followingGridAspectRatio = 0.5; + /// 「我的」页中性视觉。 static const double mineCardRadius = 16; static const double mineRowHeight = 56; - static const double minePageTitle = 21; + static const double minePageTitle = 17; static const double mineSectionTitle = 15; static const double mineAvatar = 56; static const double mineUserName = 20; diff --git a/lib/pages/following/following_grid_card.dart b/lib/pages/following/following_grid_card.dart index 3bcb88d..d9175bb 100644 --- a/lib/pages/following/following_grid_card.dart +++ b/lib/pages/following/following_grid_card.dart @@ -14,6 +14,7 @@ class FollowingGridCard extends StatelessWidget { required this.title, required this.posterUrl, required this.viewCount, + required this.tags, required this.subtitle, required this.onTap, this.progress, @@ -25,6 +26,7 @@ class FollowingGridCard extends StatelessWidget { final String title; final String posterUrl; final int viewCount; + final List tags; final String subtitle; final VoidCallback onTap; final double? progress; @@ -40,7 +42,9 @@ class FollowingGridCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( + AspectRatio( + key: Key('following-cover-$dramaId'), + aspectRatio: AppDimens.cardCoverAspectRatio, child: Stack( clipBehavior: Clip.none, children: [ @@ -75,13 +79,14 @@ class FollowingGridCard extends StatelessWidget { const Icon( Icons.play_arrow_rounded, color: AppColors.textPrimary, - size: AppDimens.homeCardMeta, + size: AppDimens.cardViewsIcon, ), Text( formatInteractionCount(viewCount), + key: Key('following-views-$dramaId'), style: const TextStyle( color: AppColors.textPrimary, - fontSize: AppDimens.homeCardMeta, + fontSize: AppDimens.cardViewsFont, ), ), ], @@ -109,24 +114,30 @@ class FollowingGridCard extends StatelessWidget { ], ), ), - const SizedBox(height: AppDimens.spaceXs), + const SizedBox(height: AppDimens.cardCoverTitleGap), Text( title, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.textPrimary, - fontSize: AppDimens.homeCardTitle, - fontWeight: FontWeight.w600, + fontSize: AppDimens.cardTitleFont, + fontWeight: FontWeight.w500, + height: AppDimens.cardTitleLineHeight, ), ), + if (tags.isNotEmpty) ...[ + const SizedBox(height: AppDimens.cardTitleTagGap), + _tags(), + ], + const SizedBox(height: AppDimens.spaceXs), Text( subtitle, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - color: AppColors.textSecondary, - fontSize: AppDimens.homeCardMeta, + color: AppColors.textTertiary, + fontSize: AppDimens.cardViewsFont, ), ), if (progress != null) ...[ @@ -148,4 +159,41 @@ class FollowingGridCard extends StatelessWidget { ), ); } + + Widget _tags() { + final visible = tags.take(2).toList(growable: false); + return Row( + children: [ + for (final (index, tag) in visible.indexed) + Flexible( + child: Padding( + padding: EdgeInsets.only( + right: index == visible.length - 1 ? 0 : AppDimens.spaceXs, + ), + child: Container( + key: Key('following-tag-$dramaId-$index'), + height: AppDimens.cardTagHeight, + alignment: Alignment.center, + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.cardTagHorizontalPadding, + ), + decoration: BoxDecoration( + color: AppColors.contentSurface, + borderRadius: BorderRadius.circular(AppDimens.cardTagRadius), + ), + child: Text( + tag, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textTertiary, + fontSize: AppDimens.cardTagFont, + ), + ), + ), + ), + ), + ], + ); + } } diff --git a/lib/pages/following/following_page.dart b/lib/pages/following/following_page.dart index c201220..2f01832 100644 --- a/lib/pages/following/following_page.dart +++ b/lib/pages/following/following_page.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'package:flutter/material.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/watch_history_repository.dart'; @@ -22,6 +23,8 @@ import '../../l10n/app_localizations.dart'; import '../player/open_player.dart'; import 'following_grid_card.dart'; +final RegExp _tagSplitter = RegExp(r'[,/、·]'); + enum FollowingTab { favorites, history, reserve } class FollowingPage extends StatefulWidget { @@ -117,51 +120,56 @@ class _FollowingPageState extends State { return SizedBox( key: const Key('following-header'), height: 52, - child: Row( - children: [ - for (final tab in tabs) - Expanded( - child: InkWell( - key: Key('following-tab-${tab.$3}'), - onTap: () => setState(() { - _tab = tab.$1; - _editing = false; - }), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - tab.$2, - maxLines: 1, - style: TextStyle( - color: _tab == tab.$1 - ? AppColors.textPrimary - : AppColors.textSecondary, - fontSize: 16, - fontWeight: _tab == tab.$1 - ? FontWeight.w700 - : FontWeight.w500, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.feedPageHorizontal, + ), + child: Row( + children: [ + for (final tab in tabs) + Expanded( + child: InkWell( + key: Key('following-tab-${tab.$3}'), + onTap: () => setState(() { + _tab = tab.$1; + _editing = false; + }), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + tab.$2, + maxLines: 1, + style: TextStyle( + color: _tab == tab.$1 + ? AppColors.textPrimary + : AppColors.textTertiary, + fontSize: AppDimens.followingTabFont, + fontWeight: _tab == tab.$1 + ? FontWeight.w700 + : FontWeight.w400, + ), ), - ), - const SizedBox(height: 3), - Container( - width: _tab == tab.$1 ? 20 : 0, - height: 2, - color: AppColors.textPrimary, - ), - ], + const SizedBox(height: 3), + Container( + width: _tab == tab.$1 ? 20 : 0, + height: 2, + color: AppColors.textPrimary, + ), + ], + ), ), ), + IconButton( + key: const Key('following-edit'), + tooltip: _editing ? l10n.done : l10n.edit, + onPressed: () => setState(() => _editing = !_editing), + color: AppColors.textPrimary, + iconSize: 24, + icon: Icon(_editing ? Icons.check_rounded : Icons.edit_outlined), ), - IconButton( - key: const Key('following-edit'), - tooltip: _editing ? l10n.done : l10n.edit, - onPressed: () => setState(() => _editing = !_editing), - color: AppColors.textPrimary, - iconSize: 24, - icon: Icon(_editing ? Icons.check_rounded : Icons.edit_outlined), - ), - ], + ], + ), ), ); } @@ -233,6 +241,7 @@ class _FavoritesGrid extends StatelessWidget { title: drama.title, posterUrl: drama.posterUrl, viewCount: drama.viewCount, + tags: _dramaTags(drama), editing: editing, subtitle: l10n.favoriteEpisodeProgress( episode, @@ -304,6 +313,7 @@ class _HistoryGrid extends StatelessWidget { title: title, posterUrl: record.displayCover, viewCount: record.drama?.viewCount ?? 0, + tags: _dramaTags(record.drama), progress: record.progress.fraction, editing: editing, subtitle: l10n.watchedToEpisode(record.episodeNumber), @@ -322,6 +332,17 @@ class _HistoryGrid extends StatelessWidget { } } +List _dramaTags(Drama? drama) { + if (drama == null) return const []; + final seen = {}; + return [drama.genre, drama.category] + .expand((value) => value.split(_tagSplitter)) + .map((value) => value.trim()) + .where((value) => value.isNotEmpty && seen.add(value)) + .take(2) + .toList(growable: false); +} + Future _openHistory( BuildContext context, WatchRecordsController controller, @@ -416,17 +437,17 @@ class _PagedGrid extends StatelessWidget { else ...[ SliverPadding( padding: const EdgeInsets.fromLTRB( - AppDimens.pageHorizontal, + AppDimens.feedPageHorizontal, AppDimens.spaceMd, - AppDimens.pageHorizontal, + AppDimens.feedPageHorizontal, AppDimens.spaceSm, ), sliver: SliverGrid( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, - crossAxisSpacing: AppDimens.spaceMd, - mainAxisSpacing: AppDimens.spaceMd, - childAspectRatio: 0.56, + crossAxisSpacing: AppDimens.homeGridGap, + mainAxisSpacing: AppDimens.homeGridRowGap, + childAspectRatio: AppDimens.followingGridAspectRatio, ), delegate: SliverChildBuilderDelegate( itemBuilder, diff --git a/lib/pages/home/widgets/home_feed_card.dart b/lib/pages/home/widgets/home_feed_card.dart index 9b3870d..fd7c6be 100644 --- a/lib/pages/home/widgets/home_feed_card.dart +++ b/lib/pages/home/widgets/home_feed_card.dart @@ -10,7 +10,6 @@ import '../../../core/widgets/remote_image.dart'; import '../../../l10n/app_localizations.dart'; import 'home_drama_classification.dart'; -const double _coverAspectRatio = 5 / 6; final RegExp _tagSplitter = RegExp(r'[,/、·]'); enum HomeDramaBadge { hot, newRelease, anime } @@ -60,12 +59,12 @@ class HomeFeedCard extends StatelessWidget { overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.navSelected, - fontSize: AppDimens.homeCardTitle, - fontWeight: FontWeight.w600, - height: 1.25, + fontSize: AppDimens.cardTitleFont, + fontWeight: FontWeight.w500, + height: AppDimens.cardTitleLineHeight, ), ), - const SizedBox(height: AppDimens.spaceXs), + const SizedBox(height: AppDimens.cardTitleTagGap), _tags(), ], ), @@ -81,7 +80,7 @@ class HomeFeedCard extends StatelessWidget { Widget _cover(AppLocalizations l10n) { final badge = _badge(); return AspectRatio( - aspectRatio: _coverAspectRatio, + aspectRatio: AppDimens.cardCoverAspectRatio, child: Stack( fit: StackFit.expand, children: [ @@ -128,18 +127,18 @@ class HomeFeedCard extends StatelessWidget { return Container( key: Key('home-feed-badge-${badge.name}-${drama.id}'), padding: const EdgeInsets.symmetric( - horizontal: AppDimens.spaceSm, - vertical: 2, + horizontal: AppDimens.cardBadgeHorizontalPadding, + vertical: AppDimens.cardBadgeVerticalPadding, ), decoration: BoxDecoration( color: color, - borderRadius: BorderRadius.circular(AppDimens.chipRadius), + borderRadius: BorderRadius.circular(AppDimens.cardBadgeRadius), ), child: Text( label, style: const TextStyle( color: AppColors.navSelected, - fontSize: AppDimens.homeCardBadge, + fontSize: AppDimens.cardBadgeFont, fontWeight: FontWeight.w700, letterSpacing: 0, ), @@ -160,7 +159,7 @@ class HomeFeedCard extends StatelessWidget { children: [ const Icon( Icons.play_arrow_rounded, - size: AppDimens.homeCardMeta, + size: AppDimens.cardViewsIcon, color: AppColors.navSelected, ), const SizedBox(width: 1), @@ -168,7 +167,7 @@ class HomeFeedCard extends StatelessWidget { formatInteractionCount(drama.viewCount), style: const TextStyle( color: AppColors.navSelected, - fontSize: AppDimens.homeCardMeta, + fontSize: AppDimens.cardViewsFont, fontWeight: FontWeight.w600, letterSpacing: 0, ), @@ -191,18 +190,22 @@ class HomeFeedCard extends StatelessWidget { ), child: Container( key: Key('home-feed-tag-${drama.id}-$index'), - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + height: AppDimens.cardTagHeight, + alignment: Alignment.center, + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.cardTagHorizontalPadding, + ), decoration: BoxDecoration( color: AppColors.contentSurface, - borderRadius: BorderRadius.circular(AppDimens.chipRadius), + borderRadius: BorderRadius.circular(AppDimens.cardTagRadius), ), child: Text( tag, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - color: AppColors.textSecondary, - fontSize: AppDimens.homeTagFont, + color: AppColors.textTertiary, + fontSize: AppDimens.cardTagFont, letterSpacing: 0, ), ), diff --git a/lib/pages/home/widgets/home_feed_grid.dart b/lib/pages/home/widgets/home_feed_grid.dart index cd76a56..64043be 100644 --- a/lib/pages/home/widgets/home_feed_grid.dart +++ b/lib/pages/home/widgets/home_feed_grid.dart @@ -73,7 +73,7 @@ class _HomeFeedGridState extends State { builder: (context, constraints) { final itemWidth = (constraints.maxWidth - - AppDimens.pageHorizontal * 2 - + AppDimens.feedPageHorizontal * 2 - AppDimens.homeGridGap * 2) / 3; final itemExtent = itemWidth * 6 / 5 + AppDimens.homeCardBodyExtent; @@ -117,7 +117,7 @@ class _HomeFeedGridState extends State { else SliverPadding( padding: const EdgeInsets.symmetric( - horizontal: AppDimens.pageHorizontal, + horizontal: AppDimens.feedPageHorizontal, ), sliver: SliverGrid( key: const Key('home-grid-cards'), @@ -125,7 +125,7 @@ class _HomeFeedGridState extends State { SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, crossAxisSpacing: AppDimens.homeGridGap, - mainAxisSpacing: AppDimens.homeGridGap, + mainAxisSpacing: AppDimens.homeGridRowGap, mainAxisExtent: itemExtent, ), delegate: SliverChildBuilderDelegate( diff --git a/lib/pages/home/widgets/home_grid_header.dart b/lib/pages/home/widgets/home_grid_header.dart index 830330c..a12a7f1 100644 --- a/lib/pages/home/widgets/home_grid_header.dart +++ b/lib/pages/home/widgets/home_grid_header.dart @@ -78,9 +78,9 @@ class HomeGridHeader extends StatelessWidget { Widget _searchRow(AppLocalizations l10n) { return Padding( padding: const EdgeInsets.fromLTRB( - AppDimens.pageHorizontal, + AppDimens.feedPageHorizontal, AppDimens.spaceSm, - AppDimens.pageHorizontal, + AppDimens.feedPageHorizontal, AppDimens.spaceSm, ), child: Row( @@ -88,7 +88,7 @@ class HomeGridHeader extends StatelessWidget { Expanded( child: InkWell( key: const Key('home-search-entry'), - borderRadius: BorderRadius.circular(AppDimens.inputRadius), + borderRadius: BorderRadius.circular(AppDimens.searchRadius), onTap: callbacks.onSearch, child: Container( height: AppDimens.searchHeight, @@ -97,7 +97,7 @@ class HomeGridHeader extends StatelessWidget { ), decoration: BoxDecoration( color: AppColors.contentSurface, - borderRadius: BorderRadius.circular(AppDimens.inputRadius), + borderRadius: BorderRadius.circular(AppDimens.searchRadius), ), child: Row( children: [ @@ -114,7 +114,7 @@ class HomeGridHeader extends StatelessWidget { overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.textSecondary, - fontSize: AppDimens.body, + fontSize: AppDimens.searchPlaceholderFont, ), ), ), @@ -191,7 +191,7 @@ class HomeGridHeader extends StatelessWidget { formatInteractionCount(badge), style: const TextStyle( color: AppColors.navSelected, - fontSize: AppDimens.homeCardBadge, + fontSize: AppDimens.cardBadgeFont, fontWeight: FontWeight.w700, ), ), @@ -206,7 +206,9 @@ class HomeGridHeader extends StatelessWidget { return SingleChildScrollView( key: const Key('home-primary-sections'), scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: AppDimens.pageHorizontal), + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.feedPageHorizontal, + ), child: Row( children: [ _primaryItem( @@ -264,9 +266,9 @@ class HomeGridHeader extends StatelessWidget { style: TextStyle( color: selected ? AppColors.navSelected - : AppColors.textSecondary, - fontSize: AppDimens.homeSectionFont, - fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + : AppColors.textTertiary, + fontSize: AppDimens.homePrimaryTabFont, + fontWeight: selected ? FontWeight.w700 : FontWeight.w400, letterSpacing: 0, ), ), @@ -288,9 +290,9 @@ class HomeGridHeader extends StatelessWidget { Widget _tagRow(AppLocalizations l10n) { return Padding( padding: const EdgeInsets.fromLTRB( - AppDimens.pageHorizontal, + AppDimens.feedPageHorizontal, AppDimens.spaceMd, - AppDimens.pageHorizontal, + AppDimens.feedPageHorizontal, AppDimens.spaceSm, ), child: Row( @@ -347,15 +349,15 @@ class HomeGridHeader extends StatelessWidget { vertical: AppDimens.spaceSm, ), decoration: BoxDecoration( - color: selected ? AppColors.contentChip : AppColors.contentSurface, + color: selected ? AppColors.contentSurface : AppColors.transparent, borderRadius: BorderRadius.circular(AppDimens.pillRadius), ), child: Text( tag.label, style: TextStyle( - color: selected ? AppColors.navSelected : AppColors.textSecondary, - fontSize: AppDimens.homeSectionFont - 2, - fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + color: selected ? AppColors.navSelected : AppColors.textTertiary, + fontSize: AppDimens.homeSecondaryTagFont, + fontWeight: selected ? FontWeight.w700 : FontWeight.w400, letterSpacing: 0, ), ), diff --git a/lib/pages/recommend/recommend_page.dart b/lib/pages/recommend/recommend_page.dart index 464ce9d..341ea03 100644 --- a/lib/pages/recommend/recommend_page.dart +++ b/lib/pages/recommend/recommend_page.dart @@ -134,7 +134,7 @@ class _RecommendPageState extends State { height: 56, child: Padding( padding: const EdgeInsets.symmetric( - horizontal: AppDimens.pageHorizontal, + horizontal: AppDimens.feedPageHorizontal, ), child: Row( children: [ @@ -189,9 +189,9 @@ class _RecommendPageState extends State { style: TextStyle( color: selected ? AppColors.textPrimary - : AppColors.textSecondary, - fontSize: AppDimens.listTitle, - fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + : AppColors.textTertiary, + fontSize: AppDimens.homePrimaryTabFont, + fontWeight: selected ? FontWeight.w700 : FontWeight.w400, ), ), const SizedBox(height: 3), diff --git a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart index 554dfdd..0a09e9a 100644 --- a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart +++ b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart @@ -47,7 +47,7 @@ class _AllDramasFilterGridState extends State { builder: (context, constraints) { final itemWidth = (constraints.maxWidth - - AppDimens.pageHorizontal * 2 - + AppDimens.feedPageHorizontal * 2 - AppDimens.homeGridGap * 2) / 3; final itemExtent = itemWidth * 6 / 5 + AppDimens.homeCardBodyExtent; @@ -77,14 +77,14 @@ class _AllDramasFilterGridState extends State { else SliverPadding( padding: const EdgeInsets.symmetric( - horizontal: AppDimens.pageHorizontal, + horizontal: AppDimens.feedPageHorizontal, ), sliver: SliverGrid( key: const Key('all-dramas-grid'), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, crossAxisSpacing: AppDimens.homeGridGap, - mainAxisSpacing: AppDimens.homeGridGap, + mainAxisSpacing: AppDimens.homeGridRowGap, mainAxisExtent: itemExtent, ), delegate: SliverChildBuilderDelegate( @@ -122,9 +122,9 @@ class _AllDramasFilterGridState extends State { final genres = _availableGenres(l10n); return Padding( padding: const EdgeInsets.fromLTRB( - AppDimens.pageHorizontal, + AppDimens.feedPageHorizontal, AppDimens.spaceSm, - AppDimens.pageHorizontal, + AppDimens.feedPageHorizontal, AppDimens.spaceMd, ), child: Column( @@ -207,21 +207,21 @@ class _AllDramasFilterGridState extends State { return InkWell( key: key, onTap: onTap, - borderRadius: BorderRadius.circular(AppDimens.pillRadius), + borderRadius: BorderRadius.circular(AppDimens.filterChipRadius), child: Container( - height: 32, + height: AppDimens.filterChipHeight, padding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceMd), alignment: Alignment.center, decoration: BoxDecoration( - color: selected ? AppColors.contentChip : AppColors.contentSurface, - borderRadius: BorderRadius.circular(AppDimens.pillRadius), + color: selected ? AppColors.contentSurface : AppColors.transparent, + borderRadius: BorderRadius.circular(AppDimens.filterChipRadius), ), child: Text( label, style: TextStyle( - color: selected ? AppColors.textPrimary : AppColors.textSecondary, - fontSize: AppDimens.caption, - fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + color: selected ? AppColors.textPrimary : AppColors.textTertiary, + fontSize: AppDimens.filterChipFont, + fontWeight: selected ? FontWeight.w700 : FontWeight.w400, ), ), ), diff --git a/lib/pages/recommend/widgets/recommend_section_card.dart b/lib/pages/recommend/widgets/recommend_section_card.dart index d2974a8..be9b282 100644 --- a/lib/pages/recommend/widgets/recommend_section_card.dart +++ b/lib/pages/recommend/widgets/recommend_section_card.dart @@ -10,6 +10,8 @@ import '../../../core/widgets/remote_image.dart'; import '../../../l10n/app_localizations.dart'; import '../../home/widgets/home_drama_classification.dart'; +final RegExp _tagSplitter = RegExp(r'[,/、·]'); + class RecommendSectionCard extends StatelessWidget { const RecommendSectionCard({ super.key, @@ -26,7 +28,7 @@ class RecommendSectionCard extends StatelessWidget { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; return SizedBox( - width: 122, + width: AppDimens.recommendCardWidth, child: InkWell( key: Key('recommend-card-$sectionKey-${drama.id}'), borderRadius: BorderRadius.circular(AppDimens.homeCardRadius), @@ -38,7 +40,7 @@ class RecommendSectionCard extends StatelessWidget { ClipRRect( borderRadius: BorderRadius.circular(AppDimens.homeCardRadius), child: AspectRatio( - aspectRatio: 3 / 4, + aspectRatio: AppDimens.cardCoverAspectRatio, child: Stack( fit: StackFit.expand, children: [ @@ -55,20 +57,20 @@ class RecommendSectionCard extends StatelessWidget { child: Container( key: Key('recommend-hot-$sectionKey-${drama.id}'), padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, + horizontal: AppDimens.cardBadgeHorizontalPadding, + vertical: AppDimens.cardBadgeVerticalPadding, ), decoration: BoxDecoration( color: AppColors.badgeHot, borderRadius: BorderRadius.circular( - AppDimens.chipRadius, + AppDimens.cardBadgeRadius, ), ), child: Text( l10n.homeBadgeHot, style: const TextStyle( color: AppColors.textPrimary, - fontSize: AppDimens.homeCardBadge, + fontSize: AppDimens.cardBadgeFont, fontWeight: FontWeight.w700, ), ), @@ -78,19 +80,20 @@ class RecommendSectionCard extends StatelessWidget { ), ), ), - const SizedBox(height: AppDimens.spaceXs), + const SizedBox(height: AppDimens.cardCoverTitleGap), Row( + key: Key('recommend-views-$sectionKey-${drama.id}'), children: [ const Icon( Icons.play_arrow_rounded, - color: AppColors.textSecondary, - size: AppDimens.homeCardMeta, + color: AppColors.textPrimary, + size: AppDimens.cardViewsIcon, ), Text( formatInteractionCount(drama.viewCount), style: const TextStyle( - color: AppColors.textSecondary, - fontSize: AppDimens.homeCardMeta, + color: AppColors.textPrimary, + fontSize: AppDimens.cardViewsFont, ), ), ], @@ -103,14 +106,65 @@ class RecommendSectionCard extends StatelessWidget { overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.textPrimary, - fontSize: AppDimens.homeCardTitle, - fontWeight: FontWeight.w600, - height: 1.25, + fontSize: AppDimens.cardTitleFont, + fontWeight: FontWeight.w500, + height: AppDimens.cardTitleLineHeight, ), ), + if (_cardTags().isNotEmpty) ...[ + const SizedBox(height: AppDimens.cardTitleTagGap), + _tags(), + ], ], ), ), ); } + + Widget _tags() { + final tags = _cardTags(); + return Row( + children: [ + for (final (index, tag) in tags.indexed) + Flexible( + child: Padding( + padding: EdgeInsets.only( + right: index == tags.length - 1 ? 0 : AppDimens.spaceXs, + ), + child: Container( + key: Key('recommend-tag-$sectionKey-${drama.id}-$index'), + height: AppDimens.cardTagHeight, + alignment: Alignment.center, + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.cardTagHorizontalPadding, + ), + decoration: BoxDecoration( + color: AppColors.contentSurface, + borderRadius: BorderRadius.circular(AppDimens.cardTagRadius), + ), + child: Text( + tag, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textTertiary, + fontSize: AppDimens.cardTagFont, + ), + ), + ), + ), + ), + ], + ); + } + + List _cardTags() { + final seen = {}; + return [drama.genre, drama.category] + .expand((value) => value.split(_tagSplitter)) + .map((value) => value.trim()) + .where((value) => value.isNotEmpty && seen.add(value)) + .take(2) + .toList(growable: false); + } } diff --git a/lib/pages/recommend/widgets/recommend_sections_view.dart b/lib/pages/recommend/widgets/recommend_sections_view.dart index 704c9a1..ac1a0bb 100644 --- a/lib/pages/recommend/widgets/recommend_sections_view.dart +++ b/lib/pages/recommend/widgets/recommend_sections_view.dart @@ -132,7 +132,7 @@ class RecommendSectionsView extends StatelessWidget { children: [ Padding( padding: const EdgeInsets.symmetric( - horizontal: AppDimens.pageHorizontal, + horizontal: AppDimens.feedPageHorizontal, ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -145,7 +145,7 @@ class RecommendSectionsView extends StatelessWidget { title, style: const TextStyle( color: AppColors.textPrimary, - fontSize: AppDimens.sectionTitle, + fontSize: AppDimens.recommendSectionTitle, fontWeight: FontWeight.w700, ), ), @@ -154,7 +154,7 @@ class RecommendSectionsView extends StatelessWidget { subtitle, style: const TextStyle( color: AppColors.textSecondary, - fontSize: AppDimens.cardViews, + fontSize: AppDimens.recommendSectionSubtitle, ), ), ], @@ -173,7 +173,8 @@ class RecommendSectionsView extends StatelessWidget { Text( l10n.more, style: const TextStyle( - fontSize: AppDimens.caption, + color: AppColors.textSecondary, + fontSize: AppDimens.recommendSectionMore, fontWeight: FontWeight.w500, ), ), @@ -186,12 +187,12 @@ class RecommendSectionsView extends StatelessWidget { ), const SizedBox(height: AppDimens.spaceMd), SizedBox( - height: 218, + height: AppDimens.recommendRowHeight, child: ListView.separated( key: Key('recommend-row-$key'), scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric( - horizontal: AppDimens.pageHorizontal, + horizontal: AppDimens.feedPageHorizontal, ), itemCount: dramas.length, separatorBuilder: (_, _) => diff --git a/test/following/following_page_test.dart b/test/following/following_page_test.dart index a7bf640..0c9f16d 100644 --- a/test/following/following_page_test.dart +++ b/test/following/following_page_test.dart @@ -8,6 +8,8 @@ import 'package:fruitshorts/core/data/models/watch_progress.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/theme/app_colors.dart'; +import 'package:fruitshorts/core/theme/app_dimens.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'; @@ -41,6 +43,50 @@ void main() { expect(find.byKey(const Key('following-tab-reserve')), findsOneWidget); expect(find.byKey(const Key('following-favorites-grid')), findsOneWidget); expect(find.byType(CustomScrollView), findsWidgets); + final tab = tester.widget( + find.descendant( + of: find.byKey(const Key('following-tab-favorites')), + matching: find.text('Following'), + ), + ); + expect(tab.style?.fontSize, AppDimens.followingTabFont); + expect(tab.style?.fontWeight, FontWeight.w700); + expect(tab.style?.color, AppColors.textPrimary); + final grid = tester.widget( + find.descendant( + of: find.byKey(const Key('following-favorites-grid')), + matching: find.byType(SliverGrid), + ), + ); + final delegate = + grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; + expect(delegate.crossAxisSpacing, AppDimens.homeGridGap); + expect(delegate.mainAxisSpacing, AppDimens.homeGridRowGap); + expect(delegate.childAspectRatio, AppDimens.followingGridAspectRatio); + final card = find.byKey(const Key('following-card-the-signal-demo')); + final cover = tester.widget( + find.byKey(const Key('following-cover-the-signal-demo')), + ); + expect(cover.aspectRatio, AppDimens.cardCoverAspectRatio); + final title = tester.widget( + find.descendant(of: card, matching: find.text('The Signal')), + ); + expect(title.style?.fontSize, AppDimens.cardTitleFont); + expect(title.style?.fontWeight, FontWeight.w500); + expect(title.style?.height, AppDimens.cardTitleLineHeight); + final views = tester.widget( + find.byKey(const Key('following-views-the-signal-demo')), + ); + expect(views.style?.fontSize, AppDimens.cardViewsFont); + expect(views.style?.color, AppColors.textPrimary); + final tag = find.byKey(const Key('following-tag-the-signal-demo-0')); + expect(tag, findsOneWidget); + expect(tester.getSize(tag).height, AppDimens.cardTagHeight); + final tagText = tester.widget( + find.descendant(of: tag, matching: find.byType(Text)), + ); + expect(tagText.style?.fontSize, AppDimens.cardTagFont); + expect(tagText.style?.color, AppColors.textTertiary); await tester.tap(find.byKey(const Key('following-edit'))); await tester.pumpAndSettle(); @@ -171,6 +217,33 @@ void main() { ); expect(find.byIcon(Icons.edit_outlined), findsOneWidget); }); + + testWidgets('should_fit_following_cards_on_compact_viewport', (tester) async { + tester.view.physicalSize = const Size(320, 640); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + _signIn(); + final catalog = FakeCatalogRepository(); + await tester.pumpWidget( + _app( + catalog: catalog, + favorites: InMemoryFavoriteRepository( + cards: catalog.dramas.take(1).toList(), + saved: {catalog.dramas.first.id}, + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-follow'))); + await tester.pumpAndSettle(); + + expect( + find.byKey(const Key('following-card-the-signal-demo')), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }); } void _signIn() { diff --git a/test/home/home_card_style_test.dart b/test/home/home_card_style_test.dart index 3744549..749d48f 100644 --- a/test/home/home_card_style_test.dart +++ b/test/home/home_card_style_test.dart @@ -40,9 +40,19 @@ void main() { final cover = find.byKey(const Key('home-feed-cover-anime-card')); final coverSize = tester.getSize(cover); expect(coverSize.height / coverSize.width, closeTo(6 / 5, 0.02)); + final badge = find.byKey(const Key('home-feed-badge-anime-anime-card')); + expect(badge, findsOneWidget); + final badgeContainer = tester.widget(badge); expect( - find.byKey(const Key('home-feed-badge-anime-anime-card')), - findsOneWidget, + (badgeContainer.decoration! as BoxDecoration).borderRadius, + BorderRadius.circular(AppDimens.cardBadgeRadius), + ); + expect( + tester + .widget(find.descendant(of: badge, matching: find.byType(Text))) + .style + ?.fontSize, + AppDimens.cardBadgeFont, ); expect(find.text('Anime'), findsOneWidget); expect(find.byKey(const Key('home-feed-views-anime-card')), findsOneWidget); @@ -51,16 +61,31 @@ void main() { find.descendant(of: views, matching: find.byType(Text)), ); expect(viewsText.data, startsWith('44.')); - expect( - tester - .widget(find.byKey(const Key('home-feed-title-anime-card'))) - .maxLines, - 2, + expect(viewsText.style?.fontSize, AppDimens.cardViewsFont); + expect(viewsText.style?.color, AppColors.navSelected); + final title = tester.widget( + find.byKey(const Key('home-feed-title-anime-card')), ); - expect(find.byKey(const Key('home-feed-tag-anime-card-0')), findsOneWidget); + expect(title.maxLines, 2); + expect(title.style?.fontSize, AppDimens.cardTitleFont); + expect(title.style?.fontWeight, FontWeight.w500); + expect(title.style?.height, AppDimens.cardTitleLineHeight); + final firstTag = find.byKey(const Key('home-feed-tag-anime-card-0')); + expect(firstTag, findsOneWidget); expect(find.byKey(const Key('home-feed-tag-anime-card-1')), findsOneWidget); expect(find.text('Romance'), findsOneWidget); expect(find.text('Urban'), findsOneWidget); + final tagContainer = tester.widget(firstTag); + expect(tester.getSize(firstTag).height, AppDimens.cardTagHeight); + expect( + (tagContainer.decoration! as BoxDecoration).borderRadius, + BorderRadius.circular(AppDimens.cardTagRadius), + ); + final tagText = tester.widget( + find.descendant(of: firstTag, matching: find.byType(Text)), + ); + expect(tagText.style?.fontSize, AppDimens.cardTagFont); + expect(tagText.style?.color, AppColors.textTertiary); await tester.tap(card); expect(taps, 1); diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index dd58684..86eaee8 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -16,6 +16,8 @@ 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/playback_thumbnail_service.dart'; +import 'package:fruitshorts/core/theme/app_colors.dart'; +import 'package:fruitshorts/core/theme/app_dimens.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'; @@ -82,8 +84,8 @@ void main() { final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; expect(delegate.crossAxisCount, 3); - expect(delegate.crossAxisSpacing, 8); - expect(delegate.mainAxisSpacing, 8); + expect(delegate.crossAxisSpacing, AppDimens.homeGridGap); + expect(delegate.mainAxisSpacing, AppDimens.homeGridRowGap); expect(find.byKey(const Key('home-section-featured')), findsOneWidget); expect(find.byKey(const Key('home-section-new')), findsOneWidget); expect(find.byKey(const Key('home-section-categories')), findsOneWidget); @@ -92,6 +94,67 @@ void main() { expect(find.byKey(const Key('home-section-category-社群')), findsOneWidget); expect(find.byKey(const Key('home-secondary-tags')), findsOneWidget); expect(find.byKey(const Key('home-grid-filter')), findsOneWidget); + final searchBox = find.descendant( + of: find.byKey(const Key('home-search-entry')), + matching: find.byType(Container), + ); + expect(tester.getSize(searchBox).height, AppDimens.searchHeight); + expect( + (tester.widget(searchBox).decoration! as BoxDecoration) + .borderRadius, + BorderRadius.circular(AppDimens.searchRadius), + ); + final placeholder = tester.widget( + find.text('Search dramas, actors, genres...'), + ); + expect(placeholder.style?.fontSize, AppDimens.searchPlaceholderFont); + expect(placeholder.style?.color, AppColors.textSecondary); + final selectedPrimary = tester.widget( + find.descendant( + of: find.byKey(const Key('home-section-featured')), + matching: find.text('Featured'), + ), + ); + final unselectedPrimary = tester.widget( + find.descendant( + of: find.byKey(const Key('home-section-new')), + matching: find.text('New'), + ), + ); + expect(selectedPrimary.style?.fontSize, AppDimens.homePrimaryTabFont); + expect(selectedPrimary.style?.fontWeight, FontWeight.w700); + expect(selectedPrimary.style?.color, AppColors.navSelected); + expect(unselectedPrimary.style?.fontWeight, FontWeight.w400); + expect(unselectedPrimary.style?.color, AppColors.textTertiary); + final secondaryTag = tester.widget( + find.descendant( + of: find.byKey(const Key('home-tag-hot')), + matching: find.text('Hot'), + ), + ); + expect(secondaryTag.style?.fontSize, AppDimens.homeSecondaryTagFont); + expect(secondaryTag.style?.fontWeight, FontWeight.w400); + expect(secondaryTag.style?.color, AppColors.textTertiary); + final unselectedTagChip = tester.widget( + find.descendant( + of: find.byKey(const Key('home-tag-hot')), + matching: find.byType(Container), + ), + ); + expect( + (unselectedTagChip.decoration! as BoxDecoration).color, + AppColors.transparent, + ); + await tester.tap(find.byKey(const Key('home-tag-hot'))); + await tester.pump(); + final selectedTag = tester.widget( + find.descendant( + of: find.byKey(const Key('home-tag-hot')), + matching: find.text('Hot'), + ), + ); + expect(selectedTag.style?.color, AppColors.navSelected); + expect(selectedTag.style?.fontWeight, FontWeight.w700); expect(find.byType(PageView), findsNothing); expect(tester.takeException(), isNull); }); @@ -128,10 +191,7 @@ void main() { expect(find.byKey(const Key('recommend-page')), findsOneWidget); expect(find.byKey(const Key('recommend-feed-player')), findsOneWidget); expect(find.byType(PlayerPage), findsOneWidget); - expect( - tester.widget(find.byType(PlayerPage)).homeMode, - isTrue, - ); + expect(tester.widget(find.byType(PlayerPage)).homeMode, isTrue); expect(find.byKey(const Key('recommend-section-hits')), findsNothing); await tester.tap(find.byKey(const Key('recommend-tab-playlist'))); diff --git a/test/mine/mine_preview_layout_test.dart b/test/mine/mine_preview_layout_test.dart index a8396cb..5f63599 100644 --- a/test/mine/mine_preview_layout_test.dart +++ b/test/mine/mine_preview_layout_test.dart @@ -41,6 +41,14 @@ void main() { expect(find.byKey(const Key('mine-vip')), findsOneWidget); expect(find.byKey(const Key('mine-vip-open')), findsOneWidget); expect(find.byKey(const Key('mine-menu')), findsOneWidget); + final title = tester.widget( + find.descendant( + of: find.byKey(const Key('mine-page-surface')), + matching: find.text('My'), + ), + ); + expect(title.style?.fontSize, AppDimens.minePageTitle); + expect(title.style?.fontWeight, FontWeight.w700); expect(find.byKey(const Key('mine-mini-player')), findsNothing); for (final oldShortcut in const [ Key('mine-watchlist'), diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart index b5029ef..e488434 100644 --- a/test/recommend/recommend_sections_test.dart +++ b/test/recommend/recommend_sections_test.dart @@ -6,6 +6,8 @@ import 'package:fruitshorts/app.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/theme/app_colors.dart'; +import 'package:fruitshorts/core/theme/app_dimens.dart'; import 'package:fruitshorts/core/widgets/app_bottom_nav.dart'; import 'package:fruitshorts/pages/player/player_view.dart'; import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; @@ -66,6 +68,53 @@ void main() { expect(find.text('New This Week'), findsOneWidget); expect(find.text('Costume Drama Awards'), findsOneWidget); expect(find.byType(AppBottomNav), findsOneWidget); + final sectionTitle = tester.widget(find.text("This Week's Hits")); + expect(sectionTitle.style?.fontSize, AppDimens.recommendSectionTitle); + expect(sectionTitle.style?.fontWeight, FontWeight.w700); + final sectionSubtitle = tester.widget( + find.text('Binge the hottest stories of the week!'), + ); + expect(sectionSubtitle.style?.fontSize, AppDimens.recommendSectionSubtitle); + expect(sectionSubtitle.style?.color, AppColors.textSecondary); + final moreText = tester.widget( + find.descendant( + of: find.byKey(const Key('recommend-more-hits')), + matching: find.text('More'), + ), + ); + expect(moreText.style?.fontSize, AppDimens.recommendSectionMore); + expect(moreText.style?.color, AppColors.textSecondary); + final card = find.byKey(const Key('recommend-card-hits-the-signal-demo')); + final cover = tester.widget( + find.descendant(of: card, matching: find.byType(AspectRatio)), + ); + expect(cover.aspectRatio, AppDimens.cardCoverAspectRatio); + final cardTitle = tester.widget( + find.byKey(const Key('recommend-title-hits-the-signal-demo')), + ); + expect(cardTitle.style?.fontSize, AppDimens.cardTitleFont); + expect(cardTitle.style?.fontWeight, FontWeight.w500); + expect(cardTitle.style?.height, AppDimens.cardTitleLineHeight); + final views = tester.widget( + find.descendant( + of: find.byKey(const Key('recommend-views-hits-the-signal-demo')), + matching: find.byType(Text), + ), + ); + expect(views.style?.fontSize, AppDimens.cardViewsFont); + expect(views.style?.color, AppColors.textPrimary); + final tag = find.byKey(const Key('recommend-tag-hits-the-signal-demo-0')); + expect(tag, findsOneWidget); + expect(tester.getSize(tag).height, AppDimens.cardTagHeight); + final tagText = tester.widget( + find.descendant(of: tag, matching: find.byType(Text)), + ); + expect(tagText.style?.fontSize, AppDimens.cardTagFont); + expect(tagText.style?.color, AppColors.textTertiary); + expect( + (tester.widget(tag).decoration! as BoxDecoration).borderRadius, + BorderRadius.circular(AppDimens.cardTagRadius), + ); }); testWidgets('should_switch_inner_tabs_without_losing_grid_filter_state', ( @@ -88,6 +137,21 @@ void main() { await tester.tap(find.byKey(const Key('all-sort-new'))); await tester.pump(); expect(_textStyle(tester, 'New').fontWeight, FontWeight.w700); + expect(_textStyle(tester, 'New').fontSize, AppDimens.filterChipFont); + expect( + tester.getSize(find.byKey(const Key('all-sort-new'))).height, + AppDimens.filterChipHeight, + ); + final chipContainer = tester.widget( + find.descendant( + of: find.byKey(const Key('all-sort-new')), + matching: find.byType(Container), + ), + ); + expect( + (chipContainer.decoration! as BoxDecoration).borderRadius, + BorderRadius.circular(AppDimens.filterChipRadius), + ); await tester.tap(find.byKey(const Key('recommend-tab-playlist'))); await tester.pumpAndSettle(); From aa4870cce6bd5f4160e8bdaf87021eda658b679f Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 18:14:33 +0800 Subject: [PATCH 033/129] =?UTF-8?q?fix(home):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E9=A6=96=E9=A1=B5=E5=88=86=E7=B1=BB=E4=B8=8E=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 7 + lib/core/theme/app_dimens.dart | 10 +- lib/pages/following/following_grid_card.dart | 46 +-- lib/pages/home/home_view.dart | 10 +- lib/pages/home/widgets/home_feed_card.dart | 119 +++--- lib/pages/home/widgets/home_feed_grid.dart | 353 ++++++++++-------- lib/pages/home/widgets/home_grid_header.dart | 139 ++++--- .../widgets/all_dramas_filter_grid.dart | 75 +++- .../widgets/recommend_section_card.dart | 47 +-- test/following/following_page_test.dart | 14 +- test/home/home_card_style_test.dart | 39 +- test/home/home_grid_layout_test.dart | 84 ++++- test/home/home_preview_layout_test.dart | 62 ++- test/recommend/recommend_sections_test.dart | 27 +- 14 files changed, 573 insertions(+), 459 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index be697c7..fdcf24d 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1108,3 +1108,10 @@ $ flutter test 解读:首页搜索/页签/二级标签、三类卡片字体与标签、5:6 封面、网格间距、 筛选 chip、我的标题和追剧双列 320 宽度适配均有测试覆盖。目标组件均低于 500 行;`pubspec.lock` 未提交,未操作真机。 + +## 2026-10-22 首页布局对齐修正 + +- 一级分类改为页内切换 6 个列表,选中 18/w700 白字、未选中 15/w400 灰字,无下划线。 +- 二级 chips 与一级行左对齐可滑动;未选项纯文字,选中项深灰胶囊。 +- 首页、剧单、全部短剧、追剧卡片移除底板/描边,标题与题材从封面左边起始,题材改为 `|` 分隔纯文字。 +- 全部短剧的地区、字幕、配音筛选 UI 已占位;以下接口待补:地区、字幕/配音筛选数据。 diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 780df01..7f62d8f 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -98,9 +98,15 @@ abstract final class AppDimens { static const double cardTitleTagGap = 6; static const double cardCoverAspectRatio = 5 / 6; static const double homeCardBodyExtent = 84; - static const double homePrimaryTabFont = 16; + static const double homePrimaryTabFont = 15; + static const double homePrimaryTabSelectedFont = 18; + static const double homePrimaryRowHeight = 30; + static const Duration homePrimaryTabAnimationDuration = Duration( + milliseconds: 180, + ); static const double homeSecondaryTagFont = 13; - static const double homeSectionIndicator = 2; + static const double homePrimaryCategoryGap = 24; + static const double homeSecondaryTagGap = 18; static const double homeIconSlot = 44; static const double homeHeaderIcon = 22; diff --git a/lib/pages/following/following_grid_card.dart b/lib/pages/following/following_grid_card.dart index d9175bb..7c63162 100644 --- a/lib/pages/following/following_grid_card.dart +++ b/lib/pages/following/following_grid_card.dart @@ -41,6 +41,7 @@ class FollowingGridCard extends StatelessWidget { onTap: onTap, child: Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, children: [ AspectRatio( key: Key('following-cover-$dramaId'), @@ -119,6 +120,7 @@ class FollowingGridCard extends StatelessWidget { title, maxLines: 2, overflow: TextOverflow.ellipsis, + textAlign: TextAlign.left, style: const TextStyle( color: AppColors.textPrimary, fontSize: AppDimens.cardTitleFont, @@ -161,39 +163,17 @@ class FollowingGridCard extends StatelessWidget { } Widget _tags() { - final visible = tags.take(2).toList(growable: false); - return Row( - children: [ - for (final (index, tag) in visible.indexed) - Flexible( - child: Padding( - padding: EdgeInsets.only( - right: index == visible.length - 1 ? 0 : AppDimens.spaceXs, - ), - child: Container( - key: Key('following-tag-$dramaId-$index'), - height: AppDimens.cardTagHeight, - alignment: Alignment.center, - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.cardTagHorizontalPadding, - ), - decoration: BoxDecoration( - color: AppColors.contentSurface, - borderRadius: BorderRadius.circular(AppDimens.cardTagRadius), - ), - child: Text( - tag, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.textTertiary, - fontSize: AppDimens.cardTagFont, - ), - ), - ), - ), - ), - ], + final visible = tags.take(3).toList(growable: false); + return Text( + visible.join(' | '), + key: Key('following-tags-$dramaId'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.left, + style: const TextStyle( + color: AppColors.textTertiary, + fontSize: AppDimens.cardTagFont, + ), ); } } diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index f719c42..2630cf2 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -26,8 +26,6 @@ import '../discover/discover_view.dart'; import '../following/following_page.dart'; import '../filter/filter_page.dart'; import '../mine/mine_types.dart'; -import '../new_releases/new_releases_page.dart'; -import '../ranking/ranking_page.dart'; import '../recommend/recommend_page.dart'; import '../mine/mine_view.dart'; import '../store/vip_page.dart'; @@ -284,9 +282,6 @@ class _HomeTabsState extends State<_HomeTabs> { onSearch: () => _openSearch(context), onOpenVip: () => unawaited(_openVip(context)), onOpenWallet: () => unawaited(_openWallet(context)), - onOpenNewReleases: () => _pushPage(context, const NewReleasesPage()), - onOpenCategories: () => _pushPage(context, const FilterPage()), - onOpenRanking: () => _pushPage(context, const RankingPage()), onOpenFilter: () => _pushPage(context, const FilterPage()), ), onOpenDrama: (drama) => unawaited(logic.playDrama(context, drama)), @@ -324,9 +319,8 @@ class _HomeTabsState extends State<_HomeTabs> { Future _openWallet(BuildContext context) async { if (!await ensureSignedIn(context) || !context.mounted) return; - await Navigator.of( - context, - ).push(MaterialPageRoute(builder: (_) => const WalletPage())); + await Navigator.of(context) + .push(MaterialPageRoute(builder: (_) => const WalletPage())); } Widget _my(BuildContext context) { diff --git a/lib/pages/home/widgets/home_feed_card.dart b/lib/pages/home/widgets/home_feed_card.dart index fd7c6be..54fc807 100644 --- a/lib/pages/home/widgets/home_feed_card.dart +++ b/lib/pages/home/widgets/home_feed_card.dart @@ -29,49 +29,46 @@ class HomeFeedCard extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - return Container( + return Material( key: Key('home-feed-card-${drama.id}'), - decoration: BoxDecoration( - color: AppColors.contentCard, + color: AppColors.transparent, + child: InkWell( borderRadius: BorderRadius.circular(AppDimens.homeCardRadius), - border: Border.all(color: AppColors.contentBorder), - ), - child: Material( - color: AppColors.transparent, - borderRadius: BorderRadius.circular(AppDimens.homeCardRadius), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: onTap, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _cover(l10n), - Expanded( - child: Padding( - padding: const EdgeInsets.all(AppDimens.homeCardPadding), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - drama.title, - key: Key('home-feed-title-${drama.id}'), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.navSelected, - fontSize: AppDimens.cardTitleFont, - fontWeight: FontWeight.w500, - height: AppDimens.cardTitleLineHeight, - ), + onTap: onTap, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + _cover(l10n), + Expanded( + child: Padding( + padding: const EdgeInsets.only( + top: AppDimens.cardCoverTitleGap, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Text( + drama.title, + key: Key('home-feed-title-${drama.id}'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.left, + style: const TextStyle( + color: AppColors.navSelected, + fontSize: AppDimens.cardTitleFont, + fontWeight: FontWeight.w500, + height: AppDimens.cardTitleLineHeight, ), - const SizedBox(height: AppDimens.cardTitleTagGap), - _tags(), - ], - ), + ), + const SizedBox(height: AppDimens.cardTitleTagGap), + _tags(), + ], ), ), - ], - ), + ), + ], ), ), ); @@ -180,39 +177,17 @@ class HomeFeedCard extends StatelessWidget { Widget _tags() { final tags = _cardTags(); if (tags.isEmpty) return const SizedBox.shrink(); - return Row( - children: [ - for (final (index, tag) in tags.indexed) - Flexible( - child: Padding( - padding: EdgeInsets.only( - right: index == tags.length - 1 ? 0 : AppDimens.spaceXs, - ), - child: Container( - key: Key('home-feed-tag-${drama.id}-$index'), - height: AppDimens.cardTagHeight, - alignment: Alignment.center, - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.cardTagHorizontalPadding, - ), - decoration: BoxDecoration( - color: AppColors.contentSurface, - borderRadius: BorderRadius.circular(AppDimens.cardTagRadius), - ), - child: Text( - tag, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.textTertiary, - fontSize: AppDimens.cardTagFont, - letterSpacing: 0, - ), - ), - ), - ), - ), - ], + return Text( + tags.join(' | '), + key: Key('home-feed-tags-${drama.id}'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.left, + style: const TextStyle( + color: AppColors.textTertiary, + fontSize: AppDimens.cardTagFont, + letterSpacing: 0, + ), ); } @@ -222,7 +197,7 @@ class HomeFeedCard extends StatelessWidget { .expand((value) => value.split(_tagSplitter)) .map((value) => value.trim()) .where((value) => value.isNotEmpty && seen.add(value)) - .take(2) + .take(3) .toList(growable: false); } } diff --git a/lib/pages/home/widgets/home_feed_grid.dart b/lib/pages/home/widgets/home_feed_grid.dart index 64043be..238ad04 100644 --- a/lib/pages/home/widgets/home_feed_grid.dart +++ b/lib/pages/home/widgets/home_feed_grid.dart @@ -42,8 +42,27 @@ class HomeFeedGrid extends StatefulWidget { } class _HomeFeedGridState extends State { - String? _selectedCategory; - String? _selectedTag; + HomePrimaryTab _selectedPrimary = HomePrimaryTab.featured; + late final Map _scrollControllers; + final Map _selectedTags = { + for (final tab in HomePrimaryTab.values) tab: null, + }; + + @override + void initState() { + super.initState(); + _scrollControllers = { + for (final tab in HomePrimaryTab.values) tab: ScrollController(), + }; + } + + @override + void dispose() { + for (final controller in _scrollControllers.values) { + controller.dispose(); + } + super.dispose(); + } @override Widget build(BuildContext context) { @@ -53,14 +72,10 @@ class _HomeFeedGridState extends State { final wallet = context.watch(); final shortcuts = _categoryShortcuts(categories, l10n); final tags = _availableTags(l10n); - final activeCategory = - shortcuts.any((shortcut) => shortcut.categoryName == _selectedCategory) - ? _selectedCategory + final activeTag = + tags.any((tag) => tag.id == _selectedTags[_selectedPrimary]) + ? _selectedTags[_selectedPrimary] : null; - final activeTag = tags.any((tag) => tag.id == _selectedTag) - ? _selectedTag - : null; - final dramas = _filteredDramas(activeCategory, activeTag); final walletBadge = auth.isSignedIn && wallet.snapshot != null ? wallet.balance : null; @@ -69,102 +84,122 @@ class _HomeFeedGridState extends State { color: AppColors.appBackground, child: SafeArea( bottom: false, - child: LayoutBuilder( - builder: (context, constraints) { - final itemWidth = - (constraints.maxWidth - - AppDimens.feedPageHorizontal * 2 - - AppDimens.homeGridGap * 2) / - 3; - final itemExtent = itemWidth * 6 / 5 + AppDimens.homeCardBodyExtent; - return NotificationListener( - onNotification: _onScroll, - child: AppPullToRefresh( - key: const Key('home-grid-refresh'), - onRefresh: _refresh, - child: CustomScrollView( - key: const Key('home-grid-scroll'), - physics: AppPullToRefresh.physics, - slivers: [ - SliverToBoxAdapter( - child: HomeGridHeader( - callbacks: widget.callbacks, - categories: shortcuts, - tags: tags, - selectedCategory: activeCategory, - selectedTag: activeTag, - onCategorySelected: _selectCategory, - onTagSelected: (tag) => - setState(() => _selectedTag = tag), - walletBadge: walletBadge, - ), - ), - if (widget.feedController.loadingFirstPage) - const SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: SizedBox.square( - dimension: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ), - ) - else if (dramas.isEmpty) - SliverFillRemaining( - hasScrollBody: false, - child: _emptyState(l10n, activeCategory, activeTag), - ) - else - SliverPadding( - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.feedPageHorizontal, - ), - sliver: SliverGrid( - key: const Key('home-grid-cards'), - gridDelegate: - SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: AppDimens.homeGridGap, - mainAxisSpacing: AppDimens.homeGridRowGap, - mainAxisExtent: itemExtent, - ), - delegate: SliverChildBuilderDelegate( - (context, index) => HomeFeedCard( - drama: dramas[index], - onTap: () => widget.onOpenDrama(dramas[index]), - ), - childCount: dramas.length, - ), - ), - ), - if (dramas.isNotEmpty) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only( - bottom: AppDimens.spaceXl, - ), - child: PagedListFooter( - loading: widget.feedController.loadingMore, - hasMore: widget.feedController.hasMore, - hasItems: dramas.isNotEmpty, - errorMessage: - widget.feedController.lastError == null - ? null - : l10n.catalogError, - onRetry: widget.feedController.loadMore, - ), - ), - ), - ], - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + HomeGridHeader( + callbacks: widget.callbacks, + categories: shortcuts, + tags: tags, + selectedPrimary: _selectedPrimary, + selectedTag: activeTag, + onPrimarySelected: (tab) => + setState(() => _selectedPrimary = tab), + onTagSelected: (tag) => + setState(() => _selectedTags[_selectedPrimary] = tag), + walletBadge: walletBadge, + ), + Expanded( + child: IndexedStack( + index: _selectedPrimary.index, + children: [ + for (final tab in HomePrimaryTab.values) + _tabGrid(context, l10n, tab: tab, shortcuts: shortcuts), + ], ), - ); - }, + ), + ], ), ), ); } + Widget _tabGrid( + BuildContext context, + AppLocalizations l10n, { + required HomePrimaryTab tab, + required List shortcuts, + }) { + final tag = _selectedTags[tab]; + final dramas = _dramasFor(tab, tag, shortcuts); + return LayoutBuilder( + builder: (context, constraints) { + final itemWidth = + (constraints.maxWidth - + AppDimens.feedPageHorizontal * 2 - + AppDimens.homeGridGap * 2) / + 3; + final itemExtent = itemWidth * 6 / 5 + AppDimens.homeCardBodyExtent; + return NotificationListener( + onNotification: _onScroll, + child: AppPullToRefresh( + key: const Key('home-grid-refresh'), + onRefresh: _refresh, + child: CustomScrollView( + key: const Key('home-grid-scroll'), + controller: _scrollControllers[tab], + physics: AppPullToRefresh.physics, + slivers: [ + if (widget.feedController.loadingFirstPage) + const SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: SizedBox.square( + dimension: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ) + else if (dramas.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: _emptyState(l10n, tab, tag), + ) + else + SliverPadding( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.feedPageHorizontal, + ), + sliver: SliverGrid( + key: const Key('home-grid-cards'), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: AppDimens.homeGridGap, + mainAxisSpacing: AppDimens.homeGridRowGap, + mainAxisExtent: itemExtent, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => HomeFeedCard( + drama: dramas[index], + onTap: () => widget.onOpenDrama(dramas[index]), + ), + childCount: dramas.length, + ), + ), + ), + if (dramas.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), + child: PagedListFooter( + loading: widget.feedController.loadingMore, + hasMore: widget.feedController.hasMore, + hasItems: dramas.isNotEmpty, + errorMessage: widget.feedController.lastError == null + ? null + : l10n.catalogError, + onRetry: widget.feedController.loadMore, + ), + ), + ), + ], + ), + ), + ); + }, + ); + } + Future _refresh() async { await widget.feedController.refreshCurrent(); } @@ -182,37 +217,24 @@ class _HomeFeedGridState extends State { return false; } - void _selectCategory(String? category) { - setState(() { - _selectedCategory = category; - _selectedTag = null; - }); - } - List _categoryShortcuts( List categories, AppLocalizations l10n, ) { - final shortcuts = []; final costume = _findCategory(categories, _costumeAliases); final community = _findCategory(categories, _communityAliases); - if (costume != null) { - shortcuts.add( - HomeGridCategoryShortcut( - label: l10n.homeSectionCostume, - categoryName: costume.name, - ), - ); - } - if (community != null) { - shortcuts.add( - HomeGridCategoryShortcut( - label: l10n.homeSectionCommunity, - categoryName: community.name, - ), - ); - } - return shortcuts; + return [ + HomeGridCategoryShortcut( + label: l10n.homeSectionCostume, + categoryName: costume?.name ?? '', + tab: HomePrimaryTab.costume, + ), + HomeGridCategoryShortcut( + label: l10n.homeSectionCommunity, + categoryName: community?.name ?? '', + tab: HomePrimaryTab.community, + ), + ]; } ContentCategory? _findCategory( @@ -227,46 +249,59 @@ class _HomeFeedGridState extends State { } List _availableTags(AppLocalizations l10n) { - final definitions = [ + // UI 先完整展示筛选维度;后续接口补齐题材字段时只替换数据来源。 + return [ HomeGridTagOption(id: 'hot', label: l10n.homeTagHot), HomeGridTagOption(id: 'uplifting', label: l10n.homeTagUplifting), HomeGridTagOption(id: 'sweet', label: l10n.homeTagSweet), HomeGridTagOption(id: 'faceSlap', label: l10n.homeTagFaceSlap), HomeGridTagOption(id: 'angst', label: l10n.homeTagAngst), ]; - final categoryText = context - .read() - .categories - .map((category) => category.name) - .toList(growable: false); - return definitions - .where((option) { - if (option.id == 'hot') { - return widget.feedController.dramas.any(isHotDrama); - } - return categoryText.any((text) => tagMatchesText(option.id, text)) || - widget.feedController.dramas.any( - (drama) => matchesHomeTag(drama, option.id), - ); - }) - .toList(growable: false); } - List _filteredDramas(String? category, String? tag) { - final normalizedCategory = category?.toLowerCase(); - return widget.feedController.dramas - .where((drama) { - final categoryMatches = - normalizedCategory == null || - drama.category.toLowerCase().contains(normalizedCategory) || - drama.genre.toLowerCase().contains(normalizedCategory); - final tagMatches = tag == null || matchesHomeTag(drama, tag); - return categoryMatches && tagMatches; - }) - .toList(growable: false); + List _dramasFor( + HomePrimaryTab tab, + String? tag, + List shortcuts, + ) { + Iterable source = widget.feedController.dramas; + if (tab == HomePrimaryTab.costume || tab == HomePrimaryTab.community) { + final categoryName = shortcuts + .firstWhere((shortcut) => shortcut.tab == tab) + .categoryName + .toLowerCase(); + if (categoryName.isEmpty) return const []; + source = source.where( + (drama) => + drama.category.toLowerCase().contains(categoryName) || + drama.genre.toLowerCase().contains(categoryName), + ); + } + if (tag != null) { + source = source.where((drama) => matchesHomeTag(drama, tag)); + } + final dramas = source.toList(growable: false); + switch (tab) { + case HomePrimaryTab.newReleases: + return [...dramas]..sort((a, b) { + final left = publishedAtForDrama(a); + final right = publishedAtForDrama(b); + if (left == null && right == null) return b.id.compareTo(a.id); + if (left == null) return 1; + if (right == null) return -1; + return right.compareTo(left); + }); + case HomePrimaryTab.ranking: + return [...dramas]..sort((a, b) => b.viewCount.compareTo(a.viewCount)); + case HomePrimaryTab.featured: + case HomePrimaryTab.categories: + case HomePrimaryTab.costume: + case HomePrimaryTab.community: + return dramas; + } } - Widget _emptyState(AppLocalizations l10n, String? category, String? tag) { + Widget _emptyState(AppLocalizations l10n, HomePrimaryTab tab, String? tag) { if (widget.feedController.lastError != null) { return EmptyState( key: const Key('home-grid-error'), @@ -276,7 +311,10 @@ class _HomeFeedGridState extends State { onAction: () => unawaited(widget.feedController.refreshCurrent()), ); } - final filtered = category != null || tag != null; + final filtered = + tab == HomePrimaryTab.costume || + tab == HomePrimaryTab.community || + tag != null; return EmptyState( key: const Key('home-grid-empty'), icon: Icons.movie_filter_outlined, @@ -284,10 +322,7 @@ class _HomeFeedGridState extends State { subtitle: filtered ? null : l10n.emptyHint, actionLabel: filtered ? l10n.homeGridClearFilter : null, onAction: filtered - ? () => setState(() { - _selectedCategory = null; - _selectedTag = null; - }) + ? () => setState(() => _selectedTags[tab] = null) : null, ); } diff --git a/lib/pages/home/widgets/home_grid_header.dart b/lib/pages/home/widgets/home_grid_header.dart index a12a7f1..76b3988 100644 --- a/lib/pages/home/widgets/home_grid_header.dart +++ b/lib/pages/home/widgets/home_grid_header.dart @@ -7,14 +7,25 @@ import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../l10n/app_localizations.dart'; +enum HomePrimaryTab { + featured, + newReleases, + categories, + ranking, + costume, + community, +} + class HomeGridCategoryShortcut { const HomeGridCategoryShortcut({ required this.label, required this.categoryName, + required this.tab, }); final String label; final String categoryName; + final HomePrimaryTab tab; } class HomeGridTagOption { @@ -29,18 +40,12 @@ class HomeGridCallbacks { required this.onSearch, required this.onOpenVip, required this.onOpenWallet, - required this.onOpenNewReleases, - required this.onOpenCategories, - required this.onOpenRanking, required this.onOpenFilter, }); final VoidCallback onSearch; final VoidCallback onOpenVip; final VoidCallback onOpenWallet; - final VoidCallback onOpenNewReleases; - final VoidCallback onOpenCategories; - final VoidCallback onOpenRanking; final VoidCallback onOpenFilter; } @@ -50,9 +55,9 @@ class HomeGridHeader extends StatelessWidget { required this.callbacks, required this.categories, required this.tags, - required this.selectedCategory, + required this.selectedPrimary, required this.selectedTag, - required this.onCategorySelected, + required this.onPrimarySelected, required this.onTagSelected, this.walletBadge, }); @@ -60,9 +65,9 @@ class HomeGridHeader extends StatelessWidget { final HomeGridCallbacks callbacks; final List categories; final List tags; - final String? selectedCategory; + final HomePrimaryTab selectedPrimary; final String? selectedTag; - final ValueChanged onCategorySelected; + final ValueChanged onPrimarySelected; final ValueChanged onTagSelected; final int? walletBadge; @@ -71,6 +76,8 @@ class HomeGridHeader extends StatelessWidget { final l10n = AppLocalizations.of(context)!; return Column( key: const Key('home-grid-header'), + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, children: [_searchRow(l10n), _primaryRow(l10n), _tagRow(l10n)], ); } @@ -84,6 +91,8 @@ class HomeGridHeader extends StatelessWidget { AppDimens.spaceSm, ), child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, children: [ Expanded( child: InkWell( @@ -210,37 +219,38 @@ class HomeGridHeader extends StatelessWidget { horizontal: AppDimens.feedPageHorizontal, ), child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, children: [ _primaryItem( key: const Key('home-section-featured'), label: l10n.homeSectionFeatured, - selected: selectedCategory == null, - onTap: () => onCategorySelected(null), + tab: HomePrimaryTab.featured, ), _primaryItem( key: const Key('home-section-new'), label: l10n.homeSectionNew, - selected: false, - onTap: callbacks.onOpenNewReleases, + tab: HomePrimaryTab.newReleases, ), _primaryItem( key: const Key('home-section-categories'), label: l10n.homeSectionCategories, - selected: false, - onTap: callbacks.onOpenCategories, + tab: HomePrimaryTab.categories, ), _primaryItem( key: const Key('home-section-ranking'), label: l10n.homeSectionRanking, - selected: false, - onTap: callbacks.onOpenRanking, + tab: HomePrimaryTab.ranking, ), for (final category in categories) _primaryItem( - key: Key('home-section-category-${category.categoryName}'), + key: Key( + category.categoryName.isEmpty + ? 'home-section-${category.tab.name}' + : 'home-section-category-${category.categoryName}', + ), label: category.label, - selected: selectedCategory == category.categoryName, - onTap: () => onCategorySelected(category.categoryName), + tab: category.tab, ), ], ), @@ -250,38 +260,35 @@ class HomeGridHeader extends StatelessWidget { Widget _primaryItem({ required Key key, required String label, - required bool selected, - required VoidCallback onTap, + required HomePrimaryTab tab, }) { + final selected = selectedPrimary == tab; return InkWell( key: key, - onTap: onTap, + onTap: () => onPrimarySelected(tab), child: Padding( - padding: const EdgeInsets.only(right: AppDimens.spaceXl), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - label, + padding: const EdgeInsets.only(right: AppDimens.homePrimaryCategoryGap), + child: SizedBox( + height: AppDimens.homePrimaryRowHeight, + child: Align( + alignment: Alignment.bottomLeft, + child: AnimatedDefaultTextStyle( + key: Key('home-primary-label-${tab.name}'), + duration: AppDimens.homePrimaryTabAnimationDuration, + curve: Curves.easeOut, style: TextStyle( color: selected ? AppColors.navSelected : AppColors.textTertiary, - fontSize: AppDimens.homePrimaryTabFont, + fontSize: selected + ? AppDimens.homePrimaryTabSelectedFont + : AppDimens.homePrimaryTabFont, fontWeight: selected ? FontWeight.w700 : FontWeight.w400, letterSpacing: 0, ), + child: Text(label, textAlign: TextAlign.left), ), - const SizedBox(height: AppDimens.spaceXs), - Container( - height: AppDimens.homeSectionIndicator, - width: selected ? 20 : 0, - decoration: BoxDecoration( - color: AppColors.navSelected, - borderRadius: BorderRadius.circular(AppDimens.pillRadius), - ), - ), - ], + ), ), ), ); @@ -296,16 +303,20 @@ class HomeGridHeader extends StatelessWidget { AppDimens.spaceSm, ), child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, children: [ Expanded( child: SingleChildScrollView( key: const Key('home-secondary-tags'), scrollDirection: Axis.horizontal, child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, children: [ for (final tag in tags) ...[ _tagChip(tag), - const SizedBox(width: AppDimens.spaceSm), + const SizedBox(width: AppDimens.homeSecondaryTagGap), ], ], ), @@ -339,29 +350,35 @@ class HomeGridHeader extends StatelessWidget { Widget _tagChip(HomeGridTagOption tag) { final selected = selectedTag == tag.id; + final label = Text( + tag.label, + style: TextStyle( + color: selected ? AppColors.navSelected : AppColors.textTertiary, + fontSize: AppDimens.homeSecondaryTagFont, + fontWeight: selected ? FontWeight.w700 : FontWeight.w400, + letterSpacing: 0, + ), + ); return InkWell( key: Key('home-tag-${tag.id}'), borderRadius: BorderRadius.circular(AppDimens.pillRadius), onTap: () => onTagSelected(selected ? null : tag.id), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.spaceMd, - vertical: AppDimens.spaceSm, - ), - decoration: BoxDecoration( - color: selected ? AppColors.contentSurface : AppColors.transparent, - borderRadius: BorderRadius.circular(AppDimens.pillRadius), - ), - child: Text( - tag.label, - style: TextStyle( - color: selected ? AppColors.navSelected : AppColors.textTertiary, - fontSize: AppDimens.homeSecondaryTagFont, - fontWeight: selected ? FontWeight.w700 : FontWeight.w400, - letterSpacing: 0, - ), - ), - ), + child: selected + ? Container( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.spaceMd, + vertical: AppDimens.spaceSm, + ), + decoration: BoxDecoration( + color: AppColors.contentSurface, + borderRadius: BorderRadius.circular(AppDimens.pillRadius), + ), + child: label, + ) + : Padding( + padding: const EdgeInsets.symmetric(vertical: AppDimens.spaceSm), + child: label, + ), ); } } diff --git a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart index 0a09e9a..8ce56e4 100644 --- a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart +++ b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart @@ -36,6 +36,8 @@ class AllDramasFilterGrid extends StatefulWidget { } class _AllDramasFilterGridState extends State { + String _region = 'all'; + String _audio = 'all'; String? _genre; bool _newest = false; @@ -71,7 +73,11 @@ class _AllDramasFilterGridState extends State { icon: Icons.movie_filter_outlined, title: l10n.homeGridFilterEmpty, actionLabel: l10n.homeGridClearFilter, - onAction: () => setState(() => _genre = null), + onAction: () => setState(() { + _region = 'all'; + _audio = 'all'; + _genre = null; + }), ), ) else @@ -130,12 +136,28 @@ class _AllDramasFilterGridState extends State { child: Column( key: const Key('all-dramas-filters'), children: [ - _chipRow(0, [l10n.all]), - _chipRow(1, [l10n.all]), - _chipRow(2, [ - (id: null, label: l10n.all), - for (final genre in genres) (id: genre.id, label: genre.label), - ]), + _selectableRow( + 0, + [ + (id: 'all', label: l10n.all), + (id: 'local', label: l10n.localFilter), + (id: 'asia', label: l10n.asiaFilter), + (id: 'western', label: l10n.westernFilter), + ], + selected: _region, + onSelected: (value) => setState(() => _region = value), + ), + _selectableRow( + 1, + [ + (id: 'all', label: l10n.all), + (id: 'subtitles', label: l10n.subtitlesOnlyFilter), + (id: 'dubbed', label: l10n.dubbedFilter), + ], + selected: _audio, + onSelected: (value) => setState(() => _audio = value), + ), + _genreRow(2, genres, l10n), Row( key: const Key('all-filter-row-3'), children: [ @@ -173,7 +195,12 @@ class _AllDramasFilterGridState extends State { ); } - Widget _chipRow(int row, List options) { + Widget _selectableRow( + int row, + List<({String id, String label})> options, { + required String selected, + required ValueChanged onSelected, + }) { return Padding( key: Key('all-filter-row-$row'), padding: const EdgeInsets.only(bottom: AppDimens.spaceSm), @@ -182,14 +209,12 @@ class _AllDramasFilterGridState extends State { child: Row( children: [ for (final option in options) ...[ - if (option is String) - _chip(label: option, selected: true, onTap: () {}), - if (option is ({String? id, String label})) - _chip( - label: option.label, - selected: _genre == option.id, - onTap: () => setState(() => _genre = option.id), - ), + _chip( + key: Key('all-filter-${option.id}'), + label: option.label, + selected: selected == option.id, + onTap: () => onSelected(option.id), + ), const SizedBox(width: AppDimens.spaceSm), ], ], @@ -198,6 +223,23 @@ class _AllDramasFilterGridState extends State { ); } + Widget _genreRow( + int row, + List<({String id, String label})> options, + AppLocalizations l10n, + ) { + return _selectableRow( + row, + [ + (id: 'all', label: l10n.all), + for (final genre in options) (id: genre.id, label: genre.label), + ], + selected: _genre ?? 'all', + onSelected: (value) => + setState(() => _genre = value == 'all' ? null : value), + ); + } + Widget _chip({ Key? key, required String label, @@ -248,6 +290,7 @@ class _AllDramasFilterGridState extends State { } List _filtered() { + if (_region != 'all' || _audio != 'all') return const []; final dramas = widget.feedController.dramas .where((drama) { return _genre == null || _matchesGenre(drama, _genre!, const []); diff --git a/lib/pages/recommend/widgets/recommend_section_card.dart b/lib/pages/recommend/widgets/recommend_section_card.dart index be9b282..376bf4b 100644 --- a/lib/pages/recommend/widgets/recommend_section_card.dart +++ b/lib/pages/recommend/widgets/recommend_section_card.dart @@ -35,6 +35,7 @@ class RecommendSectionCard extends StatelessWidget { onTap: onTap, child: Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ ClipRRect( @@ -83,6 +84,8 @@ class RecommendSectionCard extends StatelessWidget { const SizedBox(height: AppDimens.cardCoverTitleGap), Row( key: Key('recommend-views-$sectionKey-${drama.id}'), + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, children: [ const Icon( Icons.play_arrow_rounded, @@ -123,38 +126,16 @@ class RecommendSectionCard extends StatelessWidget { Widget _tags() { final tags = _cardTags(); - return Row( - children: [ - for (final (index, tag) in tags.indexed) - Flexible( - child: Padding( - padding: EdgeInsets.only( - right: index == tags.length - 1 ? 0 : AppDimens.spaceXs, - ), - child: Container( - key: Key('recommend-tag-$sectionKey-${drama.id}-$index'), - height: AppDimens.cardTagHeight, - alignment: Alignment.center, - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.cardTagHorizontalPadding, - ), - decoration: BoxDecoration( - color: AppColors.contentSurface, - borderRadius: BorderRadius.circular(AppDimens.cardTagRadius), - ), - child: Text( - tag, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.textTertiary, - fontSize: AppDimens.cardTagFont, - ), - ), - ), - ), - ), - ], + return Text( + tags.join(' | '), + key: Key('recommend-tags-$sectionKey-${drama.id}'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.left, + style: const TextStyle( + color: AppColors.textTertiary, + fontSize: AppDimens.cardTagFont, + ), ); } @@ -164,7 +145,7 @@ class RecommendSectionCard extends StatelessWidget { .expand((value) => value.split(_tagSplitter)) .map((value) => value.trim()) .where((value) => value.isNotEmpty && seen.add(value)) - .take(2) + .take(3) .toList(growable: false); } } diff --git a/test/following/following_page_test.dart b/test/following/following_page_test.dart index 0c9f16d..b5e3a81 100644 --- a/test/following/following_page_test.dart +++ b/test/following/following_page_test.dart @@ -79,14 +79,14 @@ void main() { ); expect(views.style?.fontSize, AppDimens.cardViewsFont); expect(views.style?.color, AppColors.textPrimary); - final tag = find.byKey(const Key('following-tag-the-signal-demo-0')); - expect(tag, findsOneWidget); - expect(tester.getSize(tag).height, AppDimens.cardTagHeight); - final tagText = tester.widget( - find.descendant(of: tag, matching: find.byType(Text)), + final tags = tester.widget( + find.byKey(const Key('following-tags-the-signal-demo')), ); - expect(tagText.style?.fontSize, AppDimens.cardTagFont); - expect(tagText.style?.color, AppColors.textTertiary); + expect(tags.data, 'Suspense | Mystery'); + expect(tags.style?.fontSize, AppDimens.cardTagFont); + expect(tags.style?.color, AppColors.textTertiary); + expect(tester.getTopLeft(title).dx, tester.getTopLeft(cover).dx); + expect(tester.getTopLeft(tags).dx, tester.getTopLeft(cover).dx); await tester.tap(find.byKey(const Key('following-edit'))); await tester.pumpAndSettle(); diff --git a/test/home/home_card_style_test.dart b/test/home/home_card_style_test.dart index 749d48f..0c4c945 100644 --- a/test/home/home_card_style_test.dart +++ b/test/home/home_card_style_test.dart @@ -26,16 +26,17 @@ void main() { ); final card = find.byKey(const Key('home-feed-card-anime-card')); - final container = tester.widget(card); - final decoration = container.decoration! as BoxDecoration; - expect(decoration.color, AppColors.contentCard); - expect(decoration.boxShadow, isNull); + expect(tester.widget(card).color, AppColors.transparent); expect( - decoration.borderRadius, - BorderRadius.circular(AppDimens.homeCardRadius), + tester + .widgetList( + find.descendant(of: card, matching: find.byType(Container)), + ) + .map((container) => container.decoration) + .whereType() + .every((decoration) => decoration.border == null), + isTrue, ); - expect((decoration.border! as Border).top.width, 1); - expect((decoration.border! as Border).top.color, AppColors.contentBorder); final cover = find.byKey(const Key('home-feed-cover-anime-card')); final coverSize = tester.getSize(cover); @@ -70,22 +71,14 @@ void main() { expect(title.style?.fontSize, AppDimens.cardTitleFont); expect(title.style?.fontWeight, FontWeight.w500); expect(title.style?.height, AppDimens.cardTitleLineHeight); - final firstTag = find.byKey(const Key('home-feed-tag-anime-card-0')); - expect(firstTag, findsOneWidget); - expect(find.byKey(const Key('home-feed-tag-anime-card-1')), findsOneWidget); - expect(find.text('Romance'), findsOneWidget); - expect(find.text('Urban'), findsOneWidget); - final tagContainer = tester.widget(firstTag); - expect(tester.getSize(firstTag).height, AppDimens.cardTagHeight); - expect( - (tagContainer.decoration! as BoxDecoration).borderRadius, - BorderRadius.circular(AppDimens.cardTagRadius), + final tags = tester.widget( + find.byKey(const Key('home-feed-tags-anime-card')), ); - final tagText = tester.widget( - find.descendant(of: firstTag, matching: find.byType(Text)), - ); - expect(tagText.style?.fontSize, AppDimens.cardTagFont); - expect(tagText.style?.color, AppColors.textTertiary); + expect(tags.data, 'Romance | Urban'); + expect(tags.style?.fontSize, AppDimens.cardTagFont); + expect(tags.style?.color, AppColors.textTertiary); + expect(tester.getTopLeft(title).dx, tester.getTopLeft(cover).dx); + expect(tester.getTopLeft(tags).dx, tester.getTopLeft(cover).dx); await tester.tap(card); expect(taps, 1); diff --git a/test/home/home_grid_layout_test.dart b/test/home/home_grid_layout_test.dart index e0f4d35..179a10b 100644 --- a/test/home/home_grid_layout_test.dart +++ b/test/home/home_grid_layout_test.dart @@ -11,6 +11,7 @@ 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/pages/home/widgets/home_feed_card.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'; @@ -88,29 +89,82 @@ void main() { expect(find.byKey(const Key('home-tag-uplifting')), findsNothing); }); - testWidgets('should_open_existing_pages_from_home_sections', (tester) async { + testWidgets('should_switch_home_sections_without_pushing_pages', ( + tester, + ) async { final repository = _PagedCatalogRepository(); await _pumpApp(tester, repository); + String firstDramaId() { + return tester + .widget(find.byType(HomeFeedCard).first) + .drama + .id; + } + + expect(find.byKey(const Key('home-grid')), findsOneWidget); + expect(firstDramaId(), 'page1-0'); + await tester.tap(find.byKey(const Key('home-section-new'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('new-releases-page')), findsOneWidget); - await tester.pageBack(); - await tester.pumpAndSettle(); + expect(find.byKey(const Key('new-releases-page')), findsNothing); + expect(firstDramaId(), 'page1-9'); - await tester.ensureVisible(find.byKey(const Key('home-section-ranking'))); await tester.tap(find.byKey(const Key('home-section-ranking'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('ranking-page')), findsOneWidget); - await tester.pageBack(); - await tester.pumpAndSettle(); + expect(find.byKey(const Key('ranking-page')), findsNothing); + expect(firstDramaId(), 'page1-0'); - await tester.ensureVisible( - find.byKey(const Key('home-section-categories')), - ); await tester.tap(find.byKey(const Key('home-section-categories'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('filter-page')), findsOneWidget); + expect(find.byKey(const Key('filter-page')), findsNothing); + expect(find.byKey(const Key('home-grid-cards')), findsOneWidget); + + await tester.tap(find.byKey(const Key('home-section-category-古装'))); + await tester.pumpAndSettle(); + expect(firstDramaId(), 'page1-0'); + + await tester.tap(find.byKey(const Key('home-section-category-社群'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('home-grid-empty')), findsOneWidget); + expect(find.byKey(const Key('filter-page')), findsNothing); + }); + + testWidgets('should_keep_scroll_position_when_switching_sections', ( + tester, + ) async { + final repository = _PagedCatalogRepository(); + await _pumpApp(tester, repository); + + await tester.drag( + find.byKey(const Key('home-grid-scroll')), + const Offset(0, -320), + ); + await tester.pumpAndSettle(); + final scrollable = find + .descendant( + of: find.byKey(const Key('home-grid-scroll')), + matching: find.byType(Scrollable), + ) + .first; + final before = tester.state(scrollable).position.pixels; + expect(before, greaterThan(0)); + + await tester.tap(find.byKey(const Key('home-section-new'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('home-section-featured'))); + await tester.pumpAndSettle(); + + final afterScrollable = find + .descendant( + of: find.byKey(const Key('home-grid-scroll')), + matching: find.byType(Scrollable), + ) + .first; + expect( + tester.state(afterScrollable).position.pixels, + closeTo(before, 0.1), + ); }); testWidgets('should_load_next_page_without_expanding_page_size', ( @@ -232,14 +286,16 @@ class _PagedCatalogRepository implements CatalogRepository { } Drama _drama(String id) { + final index = int.tryParse(id.split('-').last) ?? 0; return Drama( id: id, title: 'Grid Drama $id', synopsis: 'synopsis', posterUrl: '', genre: 'Romance', - category: 'Urban', - viewCount: 200000, + category: index.isEven ? '古装' : 'Urban', + viewCount: (10 - index) * 10000, + createdAt: DateTime(2026, 10, 1).add(Duration(days: index)), detailLoaded: true, episodes: [ Episode( diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 86eaee8..152ffe7 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -109,23 +109,29 @@ void main() { ); expect(placeholder.style?.fontSize, AppDimens.searchPlaceholderFont); expect(placeholder.style?.color, AppColors.textSecondary); - final selectedPrimary = tester.widget( + final selectedPrimary = tester.widget( + find.byKey(const Key('home-primary-label-featured')), + ); + final unselectedPrimary = tester.widget( + find.byKey(const Key('home-primary-label-newReleases')), + ); + expect( + selectedPrimary.style.fontSize, + AppDimens.homePrimaryTabSelectedFont, + ); + expect(selectedPrimary.style.fontWeight, FontWeight.w700); + expect(selectedPrimary.style.color, AppColors.navSelected); + expect(selectedPrimary.duration, AppDimens.homePrimaryTabAnimationDuration); + expect(unselectedPrimary.style.fontSize, AppDimens.homePrimaryTabFont); + expect(unselectedPrimary.style.fontWeight, FontWeight.w400); + expect(unselectedPrimary.style.color, AppColors.textTertiary); + expect( find.descendant( of: find.byKey(const Key('home-section-featured')), - matching: find.text('Featured'), + matching: find.byType(Container), ), + findsNothing, ); - final unselectedPrimary = tester.widget( - find.descendant( - of: find.byKey(const Key('home-section-new')), - matching: find.text('New'), - ), - ); - expect(selectedPrimary.style?.fontSize, AppDimens.homePrimaryTabFont); - expect(selectedPrimary.style?.fontWeight, FontWeight.w700); - expect(selectedPrimary.style?.color, AppColors.navSelected); - expect(unselectedPrimary.style?.fontWeight, FontWeight.w400); - expect(unselectedPrimary.style?.color, AppColors.textTertiary); final secondaryTag = tester.widget( find.descendant( of: find.byKey(const Key('home-tag-hot')), @@ -135,18 +141,38 @@ void main() { expect(secondaryTag.style?.fontSize, AppDimens.homeSecondaryTagFont); expect(secondaryTag.style?.fontWeight, FontWeight.w400); expect(secondaryTag.style?.color, AppColors.textTertiary); - final unselectedTagChip = tester.widget( + expect( + find.descendant( + of: find.byKey(const Key('home-tag-hot')), + matching: find.byType(Container), + ), + findsNothing, + ); + final primaryStart = tester.getTopLeft(find.text('Featured')); + final pageStart = tester.getTopLeft(searchBox); + expect(primaryStart.dx, pageStart.dx); + final primaryGap = + tester.getTopLeft(find.text('New')).dx - + tester.getBottomRight(find.text('Featured')).dx; + expect(primaryGap, closeTo(AppDimens.homePrimaryCategoryGap, 0.1)); + final tagStart = tester.getTopLeft(find.text('Hot')); + expect(tagStart.dx, pageStart.dx); + final tagGap = + tester.getTopLeft(find.text('Uplifting')).dx - + tester.getBottomRight(find.text('Hot')).dx; + expect(tagGap, closeTo(AppDimens.homeSecondaryTagGap, 0.1)); + await tester.tap(find.byKey(const Key('home-tag-hot'))); + await tester.pump(); + final selectedTagChip = tester.widget( find.descendant( of: find.byKey(const Key('home-tag-hot')), matching: find.byType(Container), ), ); expect( - (unselectedTagChip.decoration! as BoxDecoration).color, - AppColors.transparent, + (selectedTagChip.decoration! as BoxDecoration).color, + AppColors.contentSurface, ); - await tester.tap(find.byKey(const Key('home-tag-hot'))); - await tester.pump(); final selectedTag = tester.widget( find.descendant( of: find.byKey(const Key('home-tag-hot')), diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart index e488434..1527c04 100644 --- a/test/recommend/recommend_sections_test.dart +++ b/test/recommend/recommend_sections_test.dart @@ -103,18 +103,14 @@ void main() { ); expect(views.style?.fontSize, AppDimens.cardViewsFont); expect(views.style?.color, AppColors.textPrimary); - final tag = find.byKey(const Key('recommend-tag-hits-the-signal-demo-0')); - expect(tag, findsOneWidget); - expect(tester.getSize(tag).height, AppDimens.cardTagHeight); - final tagText = tester.widget( - find.descendant(of: tag, matching: find.byType(Text)), - ); - expect(tagText.style?.fontSize, AppDimens.cardTagFont); - expect(tagText.style?.color, AppColors.textTertiary); - expect( - (tester.widget(tag).decoration! as BoxDecoration).borderRadius, - BorderRadius.circular(AppDimens.cardTagRadius), + final tags = tester.widget( + find.byKey(const Key('recommend-tags-hits-the-signal-demo')), ); + expect(tags.data, 'Suspense | Mystery'); + expect(tags.style?.fontSize, AppDimens.cardTagFont); + expect(tags.style?.color, AppColors.textTertiary); + expect(tester.getTopLeft(cardTitle).dx, tester.getTopLeft(cover).dx); + expect(tester.getTopLeft(tags).dx, tester.getTopLeft(cover).dx); }); testWidgets('should_switch_inner_tabs_without_losing_grid_filter_state', ( @@ -132,8 +128,13 @@ void main() { for (var row = 0; row < 4; row++) { expect(find.byKey(Key('all-filter-row-$row')), findsOneWidget); } - expect(find.byKey(const Key('all-filter-local')), findsNothing); - expect(find.byKey(const Key('all-filter-subtitles')), findsNothing); + expect(find.byKey(const Key('all-filter-local')), findsOneWidget); + expect(find.byKey(const Key('all-filter-subtitles')), findsOneWidget); + await tester.tap(find.byKey(const Key('all-filter-local'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('all-dramas-empty')), findsOneWidget); + await tester.tap(find.byKey(const Key('all-filter-all')).first); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('all-sort-new'))); await tester.pump(); expect(_textStyle(tester, 'New').fontWeight, FontWeight.w700); From 4f6116896fb65fb2288adc87ba988b0f093836f3 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 18:22:05 +0800 Subject: [PATCH 034/129] =?UTF-8?q?fix(home):=20=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E9=A6=96=E9=A1=B5=E5=88=86=E7=B1=BB=E4=B8=8E=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 6 +- lib/core/formatting/count_format.dart | 8 ++ lib/core/theme/app_dimens.dart | 9 +- lib/core/widgets/drama_genre_ribbon.dart | 88 +++++++++++++++++++ lib/pages/following/following_grid_card.dart | 34 +++---- lib/pages/following/following_page.dart | 2 +- lib/pages/home/widgets/home_feed_card.dart | 81 ++++++++--------- lib/pages/home/widgets/home_grid_header.dart | 6 +- .../widgets/recommend_section_card.dart | 36 +++----- test/core/count_format_test.dart | 7 ++ test/following/following_page_test.dart | 32 +++++-- test/home/home_card_style_test.dart | 29 ++++-- test/home/home_grid_layout_test.dart | 3 + test/recommend/recommend_sections_test.dart | 27 ++++-- 14 files changed, 251 insertions(+), 117 deletions(-) create mode 100644 lib/core/widgets/drama_genre_ribbon.dart diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index fdcf24d..292b1aa 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1111,7 +1111,7 @@ $ flutter test ## 2026-10-22 首页布局对齐修正 -- 一级分类改为页内切换 6 个列表,选中 18/w700 白字、未选中 15/w400 灰字,无下划线。 -- 二级 chips 与一级行左对齐可滑动;未选项纯文字,选中项深灰胶囊。 -- 首页、剧单、全部短剧、追剧卡片移除底板/描边,标题与题材从封面左边起始,题材改为 `|` 分隔纯文字。 +- 一级分类改为页内切换 6 个列表,未选 16/w400 灰字、选中 20/w700 白字,无下划线;二级 chips 仅精选显示。 +- 四类短剧卡片移除底板/描边,封面统一 8 圆角,标题左对齐,题材移至封面右上角异形彩色丝带。 +- 播放量统一 spark 图标与 `w` 格式,整数无小数;首页、剧单、全部短剧、追剧共用。 - 全部短剧的地区、字幕、配音筛选 UI 已占位;以下接口待补:地区、字幕/配音筛选数据。 diff --git a/lib/core/formatting/count_format.dart b/lib/core/formatting/count_format.dart index c360d7e..2af1b47 100644 --- a/lib/core/formatting/count_format.dart +++ b/lib/core/formatting/count_format.dart @@ -7,3 +7,11 @@ String formatInteractionCount(int count) { if (count < 10000) return '$count'; return '${(count / 10000).toStringAsFixed(1)}万'; } + +/// 播放量展示:以 w 为单位,整数不保留小数,非整数保留一位。 +String formatPlayCount(int count) { + if (count < 10000) return '$count'; + final value = count / 10000; + if (value == value.roundToDouble()) return '${value.toInt()}w'; + return '${value.toStringAsFixed(1)}w'; +} diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 7f62d8f..75db578 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -81,11 +81,16 @@ abstract final class AppDimens { static const double homeGridGap = 6; static const double homeGridRowGap = 18; static const double homeCardRadius = 6; + static const double cardCoverRadius = 8; static const double homeCardPadding = 8; static const double cardTitleFont = 15; static const double cardTitleLineHeight = 1.3; static const double cardViewsFont = 11; static const double cardViewsIcon = 11; + static const double cardGenreRibbonHeight = 20; + static const double cardGenreRibbonRadius = 10; + static const double cardGenreRibbonLeftInset = 8; + static const double cardGenreRibbonMaxWidth = 86; static const double cardTagFont = 11; static const double cardTagHeight = 18; static const double cardTagRadius = 9; @@ -98,8 +103,8 @@ abstract final class AppDimens { static const double cardTitleTagGap = 6; static const double cardCoverAspectRatio = 5 / 6; static const double homeCardBodyExtent = 84; - static const double homePrimaryTabFont = 15; - static const double homePrimaryTabSelectedFont = 18; + static const double homePrimaryTabFont = 16; + static const double homePrimaryTabSelectedFont = 20; static const double homePrimaryRowHeight = 30; static const Duration homePrimaryTabAnimationDuration = Duration( milliseconds: 180, diff --git a/lib/core/widgets/drama_genre_ribbon.dart b/lib/core/widgets/drama_genre_ribbon.dart new file mode 100644 index 0000000..93a42c8 --- /dev/null +++ b/lib/core/widgets/drama_genre_ribbon.dart @@ -0,0 +1,88 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../theme/app_colors.dart'; +import '../theme/app_dimens.dart'; + +const List _ribbonColors = [ + AppColors.badgeHot, + AppColors.badgeNew, + AppColors.badgeAnime, +]; + +/// 封面右上角的斜边题材丝带。 +class DramaGenreRibbon extends StatelessWidget { + const DramaGenreRibbon({ + super.key, + required this.tags, + required this.keyPrefix, + }); + + final List tags; + final String keyPrefix; + + @override + Widget build(BuildContext context) { + final visible = tags + .map((tag) => tag.trim()) + .where((tag) => tag.isNotEmpty) + .take(3) + .toList(growable: false); + if (visible.isEmpty) return const SizedBox.shrink(); + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + for (final (index, tag) in visible.indexed) ...[ + if (index > 0) const SizedBox(height: 2), + ClipPath( + key: Key('drama-genre-ribbon-$keyPrefix-$index'), + clipper: const _SlantedRibbonClipper(), + child: Container( + constraints: const BoxConstraints( + maxWidth: AppDimens.cardGenreRibbonMaxWidth, + ), + height: AppDimens.cardGenreRibbonHeight, + padding: const EdgeInsets.only( + left: + AppDimens.cardTagHorizontalPadding + + AppDimens.cardGenreRibbonLeftInset, + right: AppDimens.cardTagHorizontalPadding, + ), + alignment: Alignment.center, + color: _ribbonColors[index % _ribbonColors.length], + child: Text( + tag, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.cardBadgeFont, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ], + ); + } +} + +class _SlantedRibbonClipper extends CustomClipper { + const _SlantedRibbonClipper(); + + @override + Path getClip(Size size) { + return Path() + ..moveTo(AppDimens.cardGenreRibbonLeftInset, 0) + ..lineTo(size.width, 0) + ..lineTo(size.width, size.height) + ..lineTo(0, size.height) + ..close(); + } + + @override + bool shouldReclip(_SlantedRibbonClipper oldClipper) => false; +} diff --git a/lib/pages/following/following_grid_card.dart b/lib/pages/following/following_grid_card.dart index 7c63162..7a7317e 100644 --- a/lib/pages/following/following_grid_card.dart +++ b/lib/pages/following/following_grid_card.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import '../../core/formatting/count_format.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; +import '../../core/widgets/drama_genre_ribbon.dart'; import '../../core/widgets/remote_image.dart'; class FollowingGridCard extends StatelessWidget { @@ -37,7 +38,7 @@ class FollowingGridCard extends StatelessWidget { Widget build(BuildContext context) { return InkWell( key: Key('following-card-$dramaId'), - borderRadius: BorderRadius.circular(AppDimens.homeCardRadius), + borderRadius: BorderRadius.circular(AppDimens.cardCoverRadius), onTap: onTap, child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -52,7 +53,7 @@ class FollowingGridCard extends StatelessWidget { Positioned.fill( child: ClipRRect( borderRadius: BorderRadius.circular( - AppDimens.homeCardRadius, + AppDimens.cardCoverRadius, ), child: RemoteImage( posterUrl, @@ -78,12 +79,12 @@ class FollowingGridCard extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ const Icon( - Icons.play_arrow_rounded, + Icons.auto_awesome_rounded, color: AppColors.textPrimary, size: AppDimens.cardViewsIcon, ), Text( - formatInteractionCount(viewCount), + formatPlayCount(viewCount), key: Key('following-views-$dramaId'), style: const TextStyle( color: AppColors.textPrimary, @@ -94,6 +95,12 @@ class FollowingGridCard extends StatelessWidget { ), ), ), + if (!editing) + Positioned( + top: 0, + right: 0, + child: DramaGenreRibbon(tags: tags, keyPrefix: dramaId), + ), if (editing) Positioned( top: -6, @@ -128,10 +135,6 @@ class FollowingGridCard extends StatelessWidget { height: AppDimens.cardTitleLineHeight, ), ), - if (tags.isNotEmpty) ...[ - const SizedBox(height: AppDimens.cardTitleTagGap), - _tags(), - ], const SizedBox(height: AppDimens.spaceXs), Text( subtitle, @@ -161,19 +164,4 @@ class FollowingGridCard extends StatelessWidget { ), ); } - - Widget _tags() { - final visible = tags.take(3).toList(growable: false); - return Text( - visible.join(' | '), - key: Key('following-tags-$dramaId'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.left, - style: const TextStyle( - color: AppColors.textTertiary, - fontSize: AppDimens.cardTagFont, - ), - ); - } } diff --git a/lib/pages/following/following_page.dart b/lib/pages/following/following_page.dart index 2f01832..5be30a2 100644 --- a/lib/pages/following/following_page.dart +++ b/lib/pages/following/following_page.dart @@ -339,7 +339,7 @@ List _dramaTags(Drama? drama) { .expand((value) => value.split(_tagSplitter)) .map((value) => value.trim()) .where((value) => value.isNotEmpty && seen.add(value)) - .take(2) + .take(3) .toList(growable: false); } diff --git a/lib/pages/home/widgets/home_feed_card.dart b/lib/pages/home/widgets/home_feed_card.dart index 54fc807..b78f575 100644 --- a/lib/pages/home/widgets/home_feed_card.dart +++ b/lib/pages/home/widgets/home_feed_card.dart @@ -6,6 +6,7 @@ import '../../../core/data/models/drama.dart'; import '../../../core/formatting/count_format.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; +import '../../../core/widgets/drama_genre_ribbon.dart'; import '../../../core/widgets/remote_image.dart'; import '../../../l10n/app_localizations.dart'; import 'home_drama_classification.dart'; @@ -33,7 +34,7 @@ class HomeFeedCard extends StatelessWidget { key: Key('home-feed-card-${drama.id}'), color: AppColors.transparent, child: InkWell( - borderRadius: BorderRadius.circular(AppDimens.homeCardRadius), + borderRadius: BorderRadius.circular(AppDimens.cardCoverRadius), onTap: onTap, child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -62,8 +63,6 @@ class HomeFeedCard extends StatelessWidget { height: AppDimens.cardTitleLineHeight, ), ), - const SizedBox(height: AppDimens.cardTitleTagGap), - _tags(), ], ), ), @@ -76,30 +75,39 @@ class HomeFeedCard extends StatelessWidget { Widget _cover(AppLocalizations l10n) { final badge = _badge(); - return AspectRatio( - aspectRatio: AppDimens.cardCoverAspectRatio, - child: Stack( - fit: StackFit.expand, - children: [ - RemoteImage( - drama.posterUrl, - key: Key('home-feed-cover-${drama.id}'), - fit: BoxFit.cover, - showFallbackIcon: true, - fallbackColor: AppColors.contentSurface, - ), - if (badge != null) - Positioned( - top: AppDimens.spaceXs, - left: AppDimens.spaceXs, - child: _badgeChip(l10n, badge), + return ClipRRect( + key: Key('home-feed-cover-clip-${drama.id}'), + borderRadius: BorderRadius.circular(AppDimens.cardCoverRadius), + child: AspectRatio( + aspectRatio: AppDimens.cardCoverAspectRatio, + child: Stack( + fit: StackFit.expand, + children: [ + RemoteImage( + drama.posterUrl, + key: Key('home-feed-cover-${drama.id}'), + fit: BoxFit.cover, + showFallbackIcon: true, + fallbackColor: AppColors.contentSurface, ), - Positioned( - left: AppDimens.spaceXs, - bottom: AppDimens.spaceXs, - child: _views(), - ), - ], + if (badge != null) + Positioned( + top: AppDimens.spaceXs, + left: AppDimens.spaceXs, + child: _badgeChip(l10n, badge), + ), + Positioned( + top: 0, + right: 0, + child: DramaGenreRibbon(tags: _cardTags(), keyPrefix: drama.id), + ), + Positioned( + left: AppDimens.spaceXs, + bottom: AppDimens.spaceXs, + child: _views(), + ), + ], + ), ), ); } @@ -155,13 +163,13 @@ class HomeFeedCard extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ const Icon( - Icons.play_arrow_rounded, + Icons.auto_awesome_rounded, size: AppDimens.cardViewsIcon, color: AppColors.navSelected, ), const SizedBox(width: 1), Text( - formatInteractionCount(drama.viewCount), + formatPlayCount(drama.viewCount), style: const TextStyle( color: AppColors.navSelected, fontSize: AppDimens.cardViewsFont, @@ -174,23 +182,6 @@ class HomeFeedCard extends StatelessWidget { ); } - Widget _tags() { - final tags = _cardTags(); - if (tags.isEmpty) return const SizedBox.shrink(); - return Text( - tags.join(' | '), - key: Key('home-feed-tags-${drama.id}'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.left, - style: const TextStyle( - color: AppColors.textTertiary, - fontSize: AppDimens.cardTagFont, - letterSpacing: 0, - ), - ); - } - List _cardTags() { final seen = {}; return [drama.genre, drama.category] diff --git a/lib/pages/home/widgets/home_grid_header.dart b/lib/pages/home/widgets/home_grid_header.dart index 76b3988..713336e 100644 --- a/lib/pages/home/widgets/home_grid_header.dart +++ b/lib/pages/home/widgets/home_grid_header.dart @@ -78,7 +78,11 @@ class HomeGridHeader extends StatelessWidget { key: const Key('home-grid-header'), crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start, - children: [_searchRow(l10n), _primaryRow(l10n), _tagRow(l10n)], + children: [ + _searchRow(l10n), + _primaryRow(l10n), + if (selectedPrimary == HomePrimaryTab.featured) _tagRow(l10n), + ], ); } diff --git a/lib/pages/recommend/widgets/recommend_section_card.dart b/lib/pages/recommend/widgets/recommend_section_card.dart index 376bf4b..5178976 100644 --- a/lib/pages/recommend/widgets/recommend_section_card.dart +++ b/lib/pages/recommend/widgets/recommend_section_card.dart @@ -6,6 +6,7 @@ import '../../../core/data/models/drama.dart'; import '../../../core/formatting/count_format.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; +import '../../../core/widgets/drama_genre_ribbon.dart'; import '../../../core/widgets/remote_image.dart'; import '../../../l10n/app_localizations.dart'; import '../../home/widgets/home_drama_classification.dart'; @@ -31,7 +32,7 @@ class RecommendSectionCard extends StatelessWidget { width: AppDimens.recommendCardWidth, child: InkWell( key: Key('recommend-card-$sectionKey-${drama.id}'), - borderRadius: BorderRadius.circular(AppDimens.homeCardRadius), + borderRadius: BorderRadius.circular(AppDimens.cardCoverRadius), onTap: onTap, child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -39,7 +40,7 @@ class RecommendSectionCard extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ ClipRRect( - borderRadius: BorderRadius.circular(AppDimens.homeCardRadius), + borderRadius: BorderRadius.circular(AppDimens.cardCoverRadius), child: AspectRatio( aspectRatio: AppDimens.cardCoverAspectRatio, child: Stack( @@ -77,6 +78,14 @@ class RecommendSectionCard extends StatelessWidget { ), ), ), + Positioned( + top: 0, + right: 0, + child: DramaGenreRibbon( + tags: _cardTags(), + keyPrefix: '$sectionKey-${drama.id}', + ), + ), ], ), ), @@ -88,12 +97,12 @@ class RecommendSectionCard extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.start, children: [ const Icon( - Icons.play_arrow_rounded, + Icons.auto_awesome_rounded, color: AppColors.textPrimary, size: AppDimens.cardViewsIcon, ), Text( - formatInteractionCount(drama.viewCount), + formatPlayCount(drama.viewCount), style: const TextStyle( color: AppColors.textPrimary, fontSize: AppDimens.cardViewsFont, @@ -114,31 +123,12 @@ class RecommendSectionCard extends StatelessWidget { height: AppDimens.cardTitleLineHeight, ), ), - if (_cardTags().isNotEmpty) ...[ - const SizedBox(height: AppDimens.cardTitleTagGap), - _tags(), - ], ], ), ), ); } - Widget _tags() { - final tags = _cardTags(); - return Text( - tags.join(' | '), - key: Key('recommend-tags-$sectionKey-${drama.id}'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.left, - style: const TextStyle( - color: AppColors.textTertiary, - fontSize: AppDimens.cardTagFont, - ), - ); - } - List _cardTags() { final seen = {}; return [drama.genre, drama.category] diff --git a/test/core/count_format_test.dart b/test/core/count_format_test.dart index e80781b..46c3423 100644 --- a/test/core/count_format_test.dart +++ b/test/core/count_format_test.dart @@ -10,6 +10,13 @@ void main() { expect(formatInteractionCount(9999), '9999'); }); + test('should_format_play_count_with_w_and_trim_integer_decimals', () { + expect(formatPlayCount(8500), '8500'); + expect(formatPlayCount(10000), '1w'); + expect(formatPlayCount(500000), '50w'); + expect(formatPlayCount(444500), '44.5w'); + }); + test('should_use_one_decimal_wan_from_ten_thousand', () { expect(formatInteractionCount(10000), '1.0万'); expect(formatInteractionCount(12400), '1.2万'); diff --git a/test/following/following_page_test.dart b/test/following/following_page_test.dart index b5e3a81..a8a130f 100644 --- a/test/following/following_page_test.dart +++ b/test/following/following_page_test.dart @@ -68,6 +68,16 @@ void main() { find.byKey(const Key('following-cover-the-signal-demo')), ); expect(cover.aspectRatio, AppDimens.cardCoverAspectRatio); + final coverClip = tester.widget( + find.descendant( + of: find.byKey(const Key('following-cover-the-signal-demo')), + matching: find.byType(ClipRRect), + ), + ); + expect( + coverClip.borderRadius, + BorderRadius.circular(AppDimens.cardCoverRadius), + ); final title = tester.widget( find.descendant(of: card, matching: find.text('The Signal')), ); @@ -79,14 +89,24 @@ void main() { ); expect(views.style?.fontSize, AppDimens.cardViewsFont); expect(views.style?.color, AppColors.textPrimary); - final tags = tester.widget( - find.byKey(const Key('following-tags-the-signal-demo')), + expect( + find.descendant( + of: find.byKey(const Key('following-views-the-signal-demo')), + matching: find.byIcon(Icons.auto_awesome_rounded), + ), + findsOneWidget, ); - expect(tags.data, 'Suspense | Mystery'); - expect(tags.style?.fontSize, AppDimens.cardTagFont); - expect(tags.style?.color, AppColors.textTertiary); + final ribbon = find.byKey( + const Key('drama-genre-ribbon-the-signal-demo-0'), + ); + expect(ribbon, findsOneWidget); + final ribbonText = tester.widget( + find.descendant(of: ribbon, matching: find.byType(Text)), + ); + expect(ribbonText.data, 'Suspense'); + expect(ribbonText.style?.fontSize, AppDimens.cardBadgeFont); + expect(ribbonText.style?.color, AppColors.textPrimary); expect(tester.getTopLeft(title).dx, tester.getTopLeft(cover).dx); - expect(tester.getTopLeft(tags).dx, tester.getTopLeft(cover).dx); await tester.tap(find.byKey(const Key('following-edit'))); await tester.pumpAndSettle(); diff --git a/test/home/home_card_style_test.dart b/test/home/home_card_style_test.dart index 0c4c945..4a900dc 100644 --- a/test/home/home_card_style_test.dart +++ b/test/home/home_card_style_test.dart @@ -41,6 +41,13 @@ void main() { final cover = find.byKey(const Key('home-feed-cover-anime-card')); final coverSize = tester.getSize(cover); expect(coverSize.height / coverSize.width, closeTo(6 / 5, 0.02)); + final coverClip = tester.widget( + find.byKey(const Key('home-feed-cover-clip-anime-card')), + ); + expect( + coverClip.borderRadius, + BorderRadius.circular(AppDimens.cardCoverRadius), + ); final badge = find.byKey(const Key('home-feed-badge-anime-anime-card')); expect(badge, findsOneWidget); final badgeContainer = tester.widget(badge); @@ -61,9 +68,16 @@ void main() { final viewsText = tester.widget( find.descendant(of: views, matching: find.byType(Text)), ); - expect(viewsText.data, startsWith('44.')); + expect(viewsText.data, '44.5w'); expect(viewsText.style?.fontSize, AppDimens.cardViewsFont); expect(viewsText.style?.color, AppColors.navSelected); + expect( + find.descendant( + of: views, + matching: find.byIcon(Icons.auto_awesome_rounded), + ), + findsOneWidget, + ); final title = tester.widget( find.byKey(const Key('home-feed-title-anime-card')), ); @@ -71,14 +85,15 @@ void main() { expect(title.style?.fontSize, AppDimens.cardTitleFont); expect(title.style?.fontWeight, FontWeight.w500); expect(title.style?.height, AppDimens.cardTitleLineHeight); - final tags = tester.widget( - find.byKey(const Key('home-feed-tags-anime-card')), + final ribbon = find.byKey(const Key('drama-genre-ribbon-anime-card-0')); + expect(ribbon, findsOneWidget); + final ribbonText = tester.widget( + find.descendant(of: ribbon, matching: find.byType(Text)), ); - expect(tags.data, 'Romance | Urban'); - expect(tags.style?.fontSize, AppDimens.cardTagFont); - expect(tags.style?.color, AppColors.textTertiary); + expect(ribbonText.data, 'Romance'); + expect(ribbonText.style?.fontSize, AppDimens.cardBadgeFont); + expect(ribbonText.style?.color, AppColors.textPrimary); expect(tester.getTopLeft(title).dx, tester.getTopLeft(cover).dx); - expect(tester.getTopLeft(tags).dx, tester.getTopLeft(cover).dx); await tester.tap(card); expect(taps, 1); diff --git a/test/home/home_grid_layout_test.dart b/test/home/home_grid_layout_test.dart index 179a10b..38cb16b 100644 --- a/test/home/home_grid_layout_test.dart +++ b/test/home/home_grid_layout_test.dart @@ -108,6 +108,7 @@ void main() { await tester.tap(find.byKey(const Key('home-section-new'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('new-releases-page')), findsNothing); + expect(find.byKey(const Key('home-secondary-tags')), findsNothing); expect(firstDramaId(), 'page1-9'); await tester.tap(find.byKey(const Key('home-section-ranking'))); @@ -118,6 +119,7 @@ void main() { await tester.tap(find.byKey(const Key('home-section-categories'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('filter-page')), findsNothing); + expect(find.byKey(const Key('home-secondary-tags')), findsNothing); expect(find.byKey(const Key('home-grid-cards')), findsOneWidget); await tester.tap(find.byKey(const Key('home-section-category-古装'))); @@ -154,6 +156,7 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('home-section-featured'))); await tester.pumpAndSettle(); + expect(find.byKey(const Key('home-secondary-tags')), findsOneWidget); final afterScrollable = find .descendant( diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart index 1527c04..d20b534 100644 --- a/test/recommend/recommend_sections_test.dart +++ b/test/recommend/recommend_sections_test.dart @@ -85,6 +85,13 @@ void main() { expect(moreText.style?.fontSize, AppDimens.recommendSectionMore); expect(moreText.style?.color, AppColors.textSecondary); final card = find.byKey(const Key('recommend-card-hits-the-signal-demo')); + final coverClip = tester.widget( + find.descendant(of: card, matching: find.byType(ClipRRect)), + ); + expect( + coverClip.borderRadius, + BorderRadius.circular(AppDimens.cardCoverRadius), + ); final cover = tester.widget( find.descendant(of: card, matching: find.byType(AspectRatio)), ); @@ -103,14 +110,22 @@ void main() { ); expect(views.style?.fontSize, AppDimens.cardViewsFont); expect(views.style?.color, AppColors.textPrimary); - final tags = tester.widget( - find.byKey(const Key('recommend-tags-hits-the-signal-demo')), + final viewIcon = find.descendant( + of: find.byKey(const Key('recommend-views-hits-the-signal-demo')), + matching: find.byIcon(Icons.auto_awesome_rounded), ); - expect(tags.data, 'Suspense | Mystery'); - expect(tags.style?.fontSize, AppDimens.cardTagFont); - expect(tags.style?.color, AppColors.textTertiary); + expect(viewIcon, findsOneWidget); + final ribbon = find.byKey( + const Key('drama-genre-ribbon-hits-the-signal-demo-0'), + ); + expect(ribbon, findsOneWidget); + final ribbonText = tester.widget( + find.descendant(of: ribbon, matching: find.byType(Text)), + ); + expect(ribbonText.data, 'Suspense'); + expect(ribbonText.style?.fontSize, AppDimens.cardBadgeFont); + expect(ribbonText.style?.color, AppColors.textPrimary); expect(tester.getTopLeft(cardTitle).dx, tester.getTopLeft(cover).dx); - expect(tester.getTopLeft(tags).dx, tester.getTopLeft(cover).dx); }); testWidgets('should_switch_inner_tabs_without_losing_grid_filter_state', ( From 9543d850c8ecd18673e30cedd48f8b27357b2fbc Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 18:22:46 +0800 Subject: [PATCH 035/129] =?UTF-8?q?fix(home):=20=E6=94=B6=E7=B4=A7?= =?UTF-8?q?=E7=9F=AD=E5=89=A7=E5=8D=A1=E7=89=87=E9=97=B4=E8=B7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 2 +- lib/core/theme/app_dimens.dart | 4 ++-- lib/pages/recommend/widgets/recommend_sections_view.dart | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 292b1aa..23383af 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1112,6 +1112,6 @@ $ flutter test ## 2026-10-22 首页布局对齐修正 - 一级分类改为页内切换 6 个列表,未选 16/w400 灰字、选中 20/w700 白字,无下划线;二级 chips 仅精选显示。 -- 四类短剧卡片移除底板/描边,封面统一 8 圆角,标题左对齐,题材移至封面右上角异形彩色丝带。 +- 四类短剧卡片移除底板/描边,封面统一 8 圆角,网格行距 10、封底到标题 6,题材移至右上角异形丝带。 - 播放量统一 spark 图标与 `w` 格式,整数无小数;首页、剧单、全部短剧、追剧共用。 - 全部短剧的地区、字幕、配音筛选 UI 已占位;以下接口待补:地区、字幕/配音筛选数据。 diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 75db578..ae015eb 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -79,7 +79,7 @@ abstract final class AppDimens { /// 首页探索网格。 static const double feedPageHorizontal = 14; static const double homeGridGap = 6; - static const double homeGridRowGap = 18; + static const double homeGridRowGap = 10; static const double homeCardRadius = 6; static const double cardCoverRadius = 8; static const double homeCardPadding = 8; @@ -99,7 +99,7 @@ abstract final class AppDimens { static const double cardBadgeRadius = 3; static const double cardBadgeHorizontalPadding = 6; static const double cardBadgeVerticalPadding = 2; - static const double cardCoverTitleGap = 8; + static const double cardCoverTitleGap = 6; static const double cardTitleTagGap = 6; static const double cardCoverAspectRatio = 5 / 6; static const double homeCardBodyExtent = 84; diff --git a/lib/pages/recommend/widgets/recommend_sections_view.dart b/lib/pages/recommend/widgets/recommend_sections_view.dart index ac1a0bb..139c48d 100644 --- a/lib/pages/recommend/widgets/recommend_sections_view.dart +++ b/lib/pages/recommend/widgets/recommend_sections_view.dart @@ -196,7 +196,7 @@ class RecommendSectionsView extends StatelessWidget { ), itemCount: dramas.length, separatorBuilder: (_, _) => - const SizedBox(width: AppDimens.spaceMd), + const SizedBox(width: AppDimens.homeGridGap), itemBuilder: (context, index) => RecommendSectionCard( sectionKey: key, drama: dramas[index], From ce310afa8903d63ef908c35c63dff5f1a384fe1a Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 18:23:37 +0800 Subject: [PATCH 036/129] =?UTF-8?q?fix(home):=20=E6=94=B6=E7=B4=A7?= =?UTF-8?q?=E9=A6=96=E9=A1=B5=E9=A1=B6=E9=83=A8=E5=8C=BA=E9=97=B4=E8=B7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 1 + lib/core/theme/app_dimens.dart | 4 ++++ lib/pages/home/widgets/home_grid_header.dart | 13 ++++++++----- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 23383af..3e34e83 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1113,5 +1113,6 @@ $ flutter test - 一级分类改为页内切换 6 个列表,未选 16/w400 灰字、选中 20/w700 白字,无下划线;二级 chips 仅精选显示。 - 四类短剧卡片移除底板/描边,封面统一 8 圆角,网格行距 10、封底到标题 6,题材移至右上角异形丝带。 +- 首页搜索栏到一级、一级到二级、二级到网格间距为 8/8/10;非精选 tab 直接以 10 衔接网格。 - 播放量统一 spark 图标与 `w` 格式,整数无小数;首页、剧单、全部短剧、追剧共用。 - 全部短剧的地区、字幕、配音筛选 UI 已占位;以下接口待补:地区、字幕/配音筛选数据。 diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index ae015eb..9f114d6 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -112,6 +112,10 @@ abstract final class AppDimens { static const double homeSecondaryTagFont = 13; static const double homePrimaryCategoryGap = 24; static const double homeSecondaryTagGap = 18; + static const double homeSearchTopGap = 0; + static const double homeSearchPrimaryGap = 8; + static const double homePrimarySecondaryGap = 8; + static const double homeSecondaryGridGap = 10; static const double homeIconSlot = 44; static const double homeHeaderIcon = 22; diff --git a/lib/pages/home/widgets/home_grid_header.dart b/lib/pages/home/widgets/home_grid_header.dart index 713336e..df3445f 100644 --- a/lib/pages/home/widgets/home_grid_header.dart +++ b/lib/pages/home/widgets/home_grid_header.dart @@ -81,7 +81,10 @@ class HomeGridHeader extends StatelessWidget { children: [ _searchRow(l10n), _primaryRow(l10n), - if (selectedPrimary == HomePrimaryTab.featured) _tagRow(l10n), + if (selectedPrimary == HomePrimaryTab.featured) + _tagRow(l10n) + else + const SizedBox(height: AppDimens.homeSecondaryGridGap), ], ); } @@ -90,9 +93,9 @@ class HomeGridHeader extends StatelessWidget { return Padding( padding: const EdgeInsets.fromLTRB( AppDimens.feedPageHorizontal, - AppDimens.spaceSm, + AppDimens.homeSearchTopGap, AppDimens.feedPageHorizontal, - AppDimens.spaceSm, + AppDimens.homeSearchPrimaryGap, ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -302,9 +305,9 @@ class HomeGridHeader extends StatelessWidget { return Padding( padding: const EdgeInsets.fromLTRB( AppDimens.feedPageHorizontal, - AppDimens.spaceMd, + AppDimens.homePrimarySecondaryGap, AppDimens.feedPageHorizontal, - AppDimens.spaceSm, + AppDimens.homeSecondaryGridGap, ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, From e16faa258ad1415575b71a3aeabef93a931d3ec4 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 18:26:52 +0800 Subject: [PATCH 037/129] =?UTF-8?q?fix(home):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E5=B0=81=E9=9D=A2=E8=A7=92=E6=A0=87=E4=B8=8E=E9=A2=98=E6=9D=90?= =?UTF-8?q?=E6=A0=87=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 3 +- lib/core/theme/app_dimens.dart | 5 -- .../widgets/drama_cover_badge_ribbon.dart | 69 +++++++++++++++ lib/core/widgets/drama_genre_ribbon.dart | 88 ------------------- lib/pages/following/following_grid_card.dart | 26 ++++-- lib/pages/home/widgets/home_feed_card.dart | 74 +++++++++------- .../widgets/recommend_section_card.dart | 56 ++++++------ test/following/following_page_test.dart | 15 ++-- test/home/home_card_style_test.dart | 39 ++++---- test/recommend/recommend_sections_test.dart | 23 +++-- 10 files changed, 199 insertions(+), 199 deletions(-) create mode 100644 lib/core/widgets/drama_cover_badge_ribbon.dart delete mode 100644 lib/core/widgets/drama_genre_ribbon.dart diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 3e34e83..3fa7245 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1112,7 +1112,8 @@ $ flutter test ## 2026-10-22 首页布局对齐修正 - 一级分类改为页内切换 6 个列表,未选 16/w400 灰字、选中 20/w700 白字,无下划线;二级 chips 仅精选显示。 -- 四类短剧卡片移除底板/描边,封面统一 8 圆角,网格行距 10、封底到标题 6,题材移至右上角异形丝带。 +- 四类短剧卡片移除底板/描边,封面统一 8 圆角,网格行距 10、封底到标题 6;热剧/新剧/漫剧角标移至右上角异形丝带。 +- 标题下方恢复左对齐题材文字,使用 `|` 分隔、灰色小字,与封面状态角标保持独立。 - 首页搜索栏到一级、一级到二级、二级到网格间距为 8/8/10;非精选 tab 直接以 10 衔接网格。 - 播放量统一 spark 图标与 `w` 格式,整数无小数;首页、剧单、全部短剧、追剧共用。 - 全部短剧的地区、字幕、配音筛选 UI 已占位;以下接口待补:地区、字幕/配音筛选数据。 diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 9f114d6..98533a3 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -92,13 +92,8 @@ abstract final class AppDimens { static const double cardGenreRibbonLeftInset = 8; static const double cardGenreRibbonMaxWidth = 86; static const double cardTagFont = 11; - static const double cardTagHeight = 18; - static const double cardTagRadius = 9; static const double cardTagHorizontalPadding = 6; static const double cardBadgeFont = 10; - static const double cardBadgeRadius = 3; - static const double cardBadgeHorizontalPadding = 6; - static const double cardBadgeVerticalPadding = 2; static const double cardCoverTitleGap = 6; static const double cardTitleTagGap = 6; static const double cardCoverAspectRatio = 5 / 6; diff --git a/lib/core/widgets/drama_cover_badge_ribbon.dart b/lib/core/widgets/drama_cover_badge_ribbon.dart new file mode 100644 index 0000000..95a5048 --- /dev/null +++ b/lib/core/widgets/drama_cover_badge_ribbon.dart @@ -0,0 +1,69 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../theme/app_colors.dart'; +import '../theme/app_dimens.dart'; + +/// 封面右上角的斜边状态角标。 +class DramaCoverBadgeRibbon extends StatelessWidget { + const DramaCoverBadgeRibbon({ + super.key, + required this.label, + required this.color, + required this.keyPrefix, + }); + + final String label; + final Color color; + final String keyPrefix; + + @override + Widget build(BuildContext context) { + return ClipPath( + key: Key('drama-cover-badge-ribbon-$keyPrefix'), + clipper: const _SlantedRibbonClipper(), + child: Container( + constraints: const BoxConstraints( + maxWidth: AppDimens.cardGenreRibbonMaxWidth, + ), + height: AppDimens.cardGenreRibbonHeight, + padding: const EdgeInsets.only( + left: + AppDimens.cardTagHorizontalPadding + + AppDimens.cardGenreRibbonLeftInset, + right: AppDimens.cardTagHorizontalPadding, + ), + alignment: Alignment.center, + color: color, + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.cardBadgeFont, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + } +} + +class _SlantedRibbonClipper extends CustomClipper { + const _SlantedRibbonClipper(); + + @override + Path getClip(Size size) { + return Path() + ..moveTo(AppDimens.cardGenreRibbonLeftInset, 0) + ..lineTo(size.width, 0) + ..lineTo(size.width, size.height) + ..lineTo(0, size.height) + ..close(); + } + + @override + bool shouldReclip(_SlantedRibbonClipper oldClipper) => false; +} diff --git a/lib/core/widgets/drama_genre_ribbon.dart b/lib/core/widgets/drama_genre_ribbon.dart deleted file mode 100644 index 93a42c8..0000000 --- a/lib/core/widgets/drama_genre_ribbon.dart +++ /dev/null @@ -1,88 +0,0 @@ -// Author: fruit.com.cn - -import 'package:flutter/material.dart'; - -import '../theme/app_colors.dart'; -import '../theme/app_dimens.dart'; - -const List _ribbonColors = [ - AppColors.badgeHot, - AppColors.badgeNew, - AppColors.badgeAnime, -]; - -/// 封面右上角的斜边题材丝带。 -class DramaGenreRibbon extends StatelessWidget { - const DramaGenreRibbon({ - super.key, - required this.tags, - required this.keyPrefix, - }); - - final List tags; - final String keyPrefix; - - @override - Widget build(BuildContext context) { - final visible = tags - .map((tag) => tag.trim()) - .where((tag) => tag.isNotEmpty) - .take(3) - .toList(growable: false); - if (visible.isEmpty) return const SizedBox.shrink(); - return Column( - crossAxisAlignment: CrossAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - children: [ - for (final (index, tag) in visible.indexed) ...[ - if (index > 0) const SizedBox(height: 2), - ClipPath( - key: Key('drama-genre-ribbon-$keyPrefix-$index'), - clipper: const _SlantedRibbonClipper(), - child: Container( - constraints: const BoxConstraints( - maxWidth: AppDimens.cardGenreRibbonMaxWidth, - ), - height: AppDimens.cardGenreRibbonHeight, - padding: const EdgeInsets.only( - left: - AppDimens.cardTagHorizontalPadding + - AppDimens.cardGenreRibbonLeftInset, - right: AppDimens.cardTagHorizontalPadding, - ), - alignment: Alignment.center, - color: _ribbonColors[index % _ribbonColors.length], - child: Text( - tag, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: AppDimens.cardBadgeFont, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ], - ], - ); - } -} - -class _SlantedRibbonClipper extends CustomClipper { - const _SlantedRibbonClipper(); - - @override - Path getClip(Size size) { - return Path() - ..moveTo(AppDimens.cardGenreRibbonLeftInset, 0) - ..lineTo(size.width, 0) - ..lineTo(size.width, size.height) - ..lineTo(0, size.height) - ..close(); - } - - @override - bool shouldReclip(_SlantedRibbonClipper oldClipper) => false; -} diff --git a/lib/pages/following/following_grid_card.dart b/lib/pages/following/following_grid_card.dart index 7a7317e..0b78507 100644 --- a/lib/pages/following/following_grid_card.dart +++ b/lib/pages/following/following_grid_card.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import '../../core/formatting/count_format.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; -import '../../core/widgets/drama_genre_ribbon.dart'; import '../../core/widgets/remote_image.dart'; class FollowingGridCard extends StatelessWidget { @@ -95,12 +94,6 @@ class FollowingGridCard extends StatelessWidget { ), ), ), - if (!editing) - Positioned( - top: 0, - right: 0, - child: DramaGenreRibbon(tags: tags, keyPrefix: dramaId), - ), if (editing) Positioned( top: -6, @@ -135,6 +128,10 @@ class FollowingGridCard extends StatelessWidget { height: AppDimens.cardTitleLineHeight, ), ), + if (tags.isNotEmpty) ...[ + const SizedBox(height: AppDimens.cardTitleTagGap), + _tags(), + ], const SizedBox(height: AppDimens.spaceXs), Text( subtitle, @@ -164,4 +161,19 @@ class FollowingGridCard extends StatelessWidget { ), ); } + + Widget _tags() { + final visible = tags.take(3).toList(growable: false); + return Text( + visible.join(' | '), + key: Key('following-tags-$dramaId'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.left, + style: const TextStyle( + color: AppColors.textTertiary, + fontSize: AppDimens.cardTagFont, + ), + ); + } } diff --git a/lib/pages/home/widgets/home_feed_card.dart b/lib/pages/home/widgets/home_feed_card.dart index b78f575..1f78dbb 100644 --- a/lib/pages/home/widgets/home_feed_card.dart +++ b/lib/pages/home/widgets/home_feed_card.dart @@ -6,7 +6,7 @@ import '../../../core/data/models/drama.dart'; import '../../../core/formatting/count_format.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; -import '../../../core/widgets/drama_genre_ribbon.dart'; +import '../../../core/widgets/drama_cover_badge_ribbon.dart'; import '../../../core/widgets/remote_image.dart'; import '../../../l10n/app_localizations.dart'; import 'home_drama_classification.dart'; @@ -63,6 +63,8 @@ class HomeFeedCard extends StatelessWidget { height: AppDimens.cardTitleLineHeight, ), ), + const SizedBox(height: AppDimens.cardTitleTagGap), + _tags(), ], ), ), @@ -92,15 +94,14 @@ class HomeFeedCard extends StatelessWidget { ), if (badge != null) Positioned( - top: AppDimens.spaceXs, - left: AppDimens.spaceXs, - child: _badgeChip(l10n, badge), + top: 0, + right: 0, + child: DramaCoverBadgeRibbon( + label: _badgeLabel(l10n, badge), + color: _badgeColor(badge), + keyPrefix: '${drama.id}-${badge.name}', + ), ), - Positioned( - top: 0, - right: 0, - child: DramaGenreRibbon(tags: _cardTags(), keyPrefix: drama.id), - ), Positioned( left: AppDimens.spaceXs, bottom: AppDimens.spaceXs, @@ -123,32 +124,20 @@ class HomeFeedCard extends StatelessWidget { return null; } - Widget _badgeChip(AppLocalizations l10n, HomeDramaBadge badge) { - final (label, color) = switch (badge) { - HomeDramaBadge.hot => (l10n.homeBadgeHot, AppColors.badgeHot), - HomeDramaBadge.newRelease => (l10n.homeBadgeNew, AppColors.badgeNew), - HomeDramaBadge.anime => (l10n.homeBadgeAnime, AppColors.badgeAnime), + String _badgeLabel(AppLocalizations l10n, HomeDramaBadge badge) { + return switch (badge) { + HomeDramaBadge.hot => l10n.homeBadgeHot, + HomeDramaBadge.newRelease => l10n.homeBadgeNew, + HomeDramaBadge.anime => l10n.homeBadgeAnime, + }; + } + + Color _badgeColor(HomeDramaBadge badge) { + return switch (badge) { + HomeDramaBadge.hot => AppColors.badgeHot, + HomeDramaBadge.newRelease => AppColors.badgeNew, + HomeDramaBadge.anime => AppColors.badgeAnime, }; - return Container( - key: Key('home-feed-badge-${badge.name}-${drama.id}'), - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.cardBadgeHorizontalPadding, - vertical: AppDimens.cardBadgeVerticalPadding, - ), - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(AppDimens.cardBadgeRadius), - ), - child: Text( - label, - style: const TextStyle( - color: AppColors.navSelected, - fontSize: AppDimens.cardBadgeFont, - fontWeight: FontWeight.w700, - letterSpacing: 0, - ), - ), - ); } Widget _views() { @@ -182,6 +171,23 @@ class HomeFeedCard extends StatelessWidget { ); } + Widget _tags() { + final tags = _cardTags(); + if (tags.isEmpty) return const SizedBox.shrink(); + return Text( + tags.join(' | '), + key: Key('home-feed-tags-${drama.id}'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.left, + style: const TextStyle( + color: AppColors.textTertiary, + fontSize: AppDimens.cardTagFont, + letterSpacing: 0, + ), + ); + } + List _cardTags() { final seen = {}; return [drama.genre, drama.category] diff --git a/lib/pages/recommend/widgets/recommend_section_card.dart b/lib/pages/recommend/widgets/recommend_section_card.dart index 5178976..fdaab54 100644 --- a/lib/pages/recommend/widgets/recommend_section_card.dart +++ b/lib/pages/recommend/widgets/recommend_section_card.dart @@ -6,7 +6,7 @@ import '../../../core/data/models/drama.dart'; import '../../../core/formatting/count_format.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; -import '../../../core/widgets/drama_genre_ribbon.dart'; +import '../../../core/widgets/drama_cover_badge_ribbon.dart'; import '../../../core/widgets/remote_image.dart'; import '../../../l10n/app_localizations.dart'; import '../../home/widgets/home_drama_classification.dart'; @@ -54,38 +54,14 @@ class RecommendSectionCard extends StatelessWidget { ), if (isHotDrama(drama)) Positioned( - top: AppDimens.spaceXs, - left: AppDimens.spaceXs, - child: Container( - key: Key('recommend-hot-$sectionKey-${drama.id}'), - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.cardBadgeHorizontalPadding, - vertical: AppDimens.cardBadgeVerticalPadding, - ), - decoration: BoxDecoration( - color: AppColors.badgeHot, - borderRadius: BorderRadius.circular( - AppDimens.cardBadgeRadius, - ), - ), - child: Text( - l10n.homeBadgeHot, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: AppDimens.cardBadgeFont, - fontWeight: FontWeight.w700, - ), - ), + top: 0, + right: 0, + child: DramaCoverBadgeRibbon( + label: l10n.homeBadgeHot, + color: AppColors.badgeHot, + keyPrefix: '$sectionKey-${drama.id}-hot', ), ), - Positioned( - top: 0, - right: 0, - child: DramaGenreRibbon( - tags: _cardTags(), - keyPrefix: '$sectionKey-${drama.id}', - ), - ), ], ), ), @@ -123,6 +99,8 @@ class RecommendSectionCard extends StatelessWidget { height: AppDimens.cardTitleLineHeight, ), ), + const SizedBox(height: AppDimens.cardTitleTagGap), + _tags(), ], ), ), @@ -138,4 +116,20 @@ class RecommendSectionCard extends StatelessWidget { .take(3) .toList(growable: false); } + + Widget _tags() { + final tags = _cardTags(); + if (tags.isEmpty) return const SizedBox.shrink(); + return Text( + tags.join(' | '), + key: Key('recommend-tags-$sectionKey-${drama.id}'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.left, + style: const TextStyle( + color: AppColors.textTertiary, + fontSize: AppDimens.cardTagFont, + ), + ); + } } diff --git a/test/following/following_page_test.dart b/test/following/following_page_test.dart index a8a130f..d7273dc 100644 --- a/test/following/following_page_test.dart +++ b/test/following/following_page_test.dart @@ -96,17 +96,14 @@ void main() { ), findsOneWidget, ); - final ribbon = find.byKey( - const Key('drama-genre-ribbon-the-signal-demo-0'), + final tags = tester.widget( + find.byKey(const Key('following-tags-the-signal-demo')), ); - expect(ribbon, findsOneWidget); - final ribbonText = tester.widget( - find.descendant(of: ribbon, matching: find.byType(Text)), - ); - expect(ribbonText.data, 'Suspense'); - expect(ribbonText.style?.fontSize, AppDimens.cardBadgeFont); - expect(ribbonText.style?.color, AppColors.textPrimary); + expect(tags.data, 'Suspense | Mystery'); + expect(tags.style?.fontSize, AppDimens.cardTagFont); + expect(tags.style?.color, AppColors.textTertiary); expect(tester.getTopLeft(title).dx, tester.getTopLeft(cover).dx); + expect(tester.getTopLeft(tags).dx, tester.getTopLeft(cover).dx); await tester.tap(find.byKey(const Key('following-edit'))); await tester.pumpAndSettle(); diff --git a/test/home/home_card_style_test.dart b/test/home/home_card_style_test.dart index 4a900dc..212e915 100644 --- a/test/home/home_card_style_test.dart +++ b/test/home/home_card_style_test.dart @@ -48,13 +48,10 @@ void main() { coverClip.borderRadius, BorderRadius.circular(AppDimens.cardCoverRadius), ); - final badge = find.byKey(const Key('home-feed-badge-anime-anime-card')); - expect(badge, findsOneWidget); - final badgeContainer = tester.widget(badge); - expect( - (badgeContainer.decoration! as BoxDecoration).borderRadius, - BorderRadius.circular(AppDimens.cardBadgeRadius), + final badge = find.byKey( + const Key('drama-cover-badge-ribbon-anime-card-anime'), ); + expect(badge, findsOneWidget); expect( tester .widget(find.descendant(of: badge, matching: find.byType(Text))) @@ -62,6 +59,14 @@ void main() { ?.fontSize, AppDimens.cardBadgeFont, ); + expect( + tester.getTopRight(badge).dx, + closeTo(tester.getTopRight(cover).dx, 1), + ); + expect( + tester.getTopRight(badge).dy, + closeTo(tester.getTopRight(cover).dy, 1), + ); expect(find.text('Anime'), findsOneWidget); expect(find.byKey(const Key('home-feed-views-anime-card')), findsOneWidget); final views = find.byKey(const Key('home-feed-views-anime-card')); @@ -85,15 +90,14 @@ void main() { expect(title.style?.fontSize, AppDimens.cardTitleFont); expect(title.style?.fontWeight, FontWeight.w500); expect(title.style?.height, AppDimens.cardTitleLineHeight); - final ribbon = find.byKey(const Key('drama-genre-ribbon-anime-card-0')); - expect(ribbon, findsOneWidget); - final ribbonText = tester.widget( - find.descendant(of: ribbon, matching: find.byType(Text)), + final tags = tester.widget( + find.byKey(const Key('home-feed-tags-anime-card')), ); - expect(ribbonText.data, 'Romance'); - expect(ribbonText.style?.fontSize, AppDimens.cardBadgeFont); - expect(ribbonText.style?.color, AppColors.textPrimary); + expect(tags.data, 'Romance | Urban'); + expect(tags.style?.fontSize, AppDimens.cardTagFont); + expect(tags.style?.color, AppColors.textTertiary); expect(tester.getTopLeft(title).dx, tester.getTopLeft(cover).dx); + expect(tester.getTopLeft(tags).dx, tester.getTopLeft(cover).dx); await tester.tap(card); expect(taps, 1); @@ -134,14 +138,17 @@ void main() { await tester.pumpAndSettle(); expect( - find.byKey(const Key('home-feed-badge-anime-anime')), + find.byKey(const Key('drama-cover-badge-ribbon-anime-anime')), findsOneWidget, ); expect( - find.byKey(const Key('home-feed-badge-newRelease-new')), + find.byKey(const Key('drama-cover-badge-ribbon-new-newRelease')), + findsOneWidget, + ); + expect( + find.byKey(const Key('drama-cover-badge-ribbon-hot-hot')), findsOneWidget, ); - expect(find.byKey(const Key('home-feed-badge-hot-hot')), findsOneWidget); }); } diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart index d20b534..5aac69b 100644 --- a/test/recommend/recommend_sections_test.dart +++ b/test/recommend/recommend_sections_test.dart @@ -115,17 +115,24 @@ void main() { matching: find.byIcon(Icons.auto_awesome_rounded), ); expect(viewIcon, findsOneWidget); - final ribbon = find.byKey( - const Key('drama-genre-ribbon-hits-the-signal-demo-0'), + final badge = find.byKey( + const Key('drama-cover-badge-ribbon-hits-the-signal-demo-hot'), ); - expect(ribbon, findsOneWidget); - final ribbonText = tester.widget( - find.descendant(of: ribbon, matching: find.byType(Text)), + expect(badge, findsOneWidget); + final badgeText = tester.widget( + find.descendant(of: badge, matching: find.byType(Text)), ); - expect(ribbonText.data, 'Suspense'); - expect(ribbonText.style?.fontSize, AppDimens.cardBadgeFont); - expect(ribbonText.style?.color, AppColors.textPrimary); + expect(badgeText.data, 'Hit'); + expect(badgeText.style?.fontSize, AppDimens.cardBadgeFont); + expect(badgeText.style?.color, AppColors.textPrimary); + final tags = tester.widget( + find.byKey(const Key('recommend-tags-hits-the-signal-demo')), + ); + expect(tags.data, 'Suspense | Mystery'); + expect(tags.style?.fontSize, AppDimens.cardTagFont); + expect(tags.style?.color, AppColors.textTertiary); expect(tester.getTopLeft(cardTitle).dx, tester.getTopLeft(cover).dx); + expect(tester.getTopLeft(tags).dx, tester.getTopLeft(cover).dx); }); testWidgets('should_switch_inner_tabs_without_losing_grid_filter_state', ( From f7782aef1409c3de0c411bb462096eff31f37f70 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 18:27:55 +0800 Subject: [PATCH 038/129] =?UTF-8?q?fix(home):=20=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E9=87=8F=E8=A1=8C=E5=86=85=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 2 +- lib/core/theme/app_dimens.dart | 2 + lib/pages/following/following_grid_card.dart | 88 ++++++++++--------- lib/pages/home/widgets/home_feed_card.dart | 81 +++++++++-------- .../widgets/recommend_section_card.dart | 66 ++++++++------ test/following/following_page_test.dart | 3 +- test/home/home_card_style_test.dart | 7 +- test/recommend/recommend_sections_test.dart | 3 +- 8 files changed, 138 insertions(+), 114 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 3fa7245..b654c0c 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1115,5 +1115,5 @@ $ flutter test - 四类短剧卡片移除底板/描边,封面统一 8 圆角,网格行距 10、封底到标题 6;热剧/新剧/漫剧角标移至右上角异形丝带。 - 标题下方恢复左对齐题材文字,使用 `|` 分隔、灰色小字,与封面状态角标保持独立。 - 首页搜索栏到一级、一级到二级、二级到网格间距为 8/8/10;非精选 tab 直接以 10 衔接网格。 -- 播放量统一 spark 图标与 `w` 格式,整数无小数;首页、剧单、全部短剧、追剧共用。 +- 播放量移至标题行最右,使用 spark 图标与 12/w400 纯文字、`w` 格式,整数无小数;四类列表共用。 - 全部短剧的地区、字幕、配音筛选 UI 已占位;以下接口待补:地区、字幕/配音筛选数据。 diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 98533a3..1e1eb9c 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -87,6 +87,8 @@ abstract final class AppDimens { static const double cardTitleLineHeight = 1.3; static const double cardViewsFont = 11; static const double cardViewsIcon = 11; + static const double cardPlayCountFont = 12; + static const double cardPlayCountGap = 2; static const double cardGenreRibbonHeight = 20; static const double cardGenreRibbonRadius = 10; static const double cardGenreRibbonLeftInset = 8; diff --git a/lib/pages/following/following_grid_card.dart b/lib/pages/following/following_grid_card.dart index 0b78507..a525d69 100644 --- a/lib/pages/following/following_grid_card.dart +++ b/lib/pages/following/following_grid_card.dart @@ -62,38 +62,6 @@ class FollowingGridCard extends StatelessWidget { ), ), ), - Positioned( - left: AppDimens.spaceXs, - bottom: AppDimens.spaceXs, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: AppColors.playerOverlayPill, - borderRadius: BorderRadius.circular(AppDimens.pillRadius), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.auto_awesome_rounded, - color: AppColors.textPrimary, - size: AppDimens.cardViewsIcon, - ), - Text( - formatPlayCount(viewCount), - key: Key('following-views-$dramaId'), - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: AppDimens.cardViewsFont, - ), - ), - ], - ), - ), - ), if (editing) Positioned( top: -6, @@ -116,17 +84,27 @@ class FollowingGridCard extends StatelessWidget { ), ), const SizedBox(height: AppDimens.cardCoverTitleGap), - Text( - title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.left, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: AppDimens.cardTitleFont, - fontWeight: FontWeight.w500, - height: AppDimens.cardTitleLineHeight, - ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.left, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.cardTitleFont, + fontWeight: FontWeight.w500, + height: AppDimens.cardTitleLineHeight, + ), + ), + ), + const SizedBox(width: AppDimens.spaceXs), + _views(), + ], ), if (tags.isNotEmpty) ...[ const SizedBox(height: AppDimens.cardTitleTagGap), @@ -162,6 +140,30 @@ class FollowingGridCard extends StatelessWidget { ); } + Widget _views() { + return Row( + key: Key('following-views-$dramaId'), + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon( + Icons.auto_awesome_rounded, + color: AppColors.textPrimary, + size: AppDimens.cardViewsIcon, + ), + const SizedBox(width: AppDimens.cardPlayCountGap), + Text( + formatPlayCount(viewCount), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.cardPlayCountFont, + fontWeight: FontWeight.w400, + ), + ), + ], + ); + } + Widget _tags() { final visible = tags.take(3).toList(growable: false); return Text( diff --git a/lib/pages/home/widgets/home_feed_card.dart b/lib/pages/home/widgets/home_feed_card.dart index 1f78dbb..7b7d9b3 100644 --- a/lib/pages/home/widgets/home_feed_card.dart +++ b/lib/pages/home/widgets/home_feed_card.dart @@ -50,18 +50,28 @@ class HomeFeedCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start, children: [ - Text( - drama.title, - key: Key('home-feed-title-${drama.id}'), - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.left, - style: const TextStyle( - color: AppColors.navSelected, - fontSize: AppDimens.cardTitleFont, - fontWeight: FontWeight.w500, - height: AppDimens.cardTitleLineHeight, - ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Text( + drama.title, + key: Key('home-feed-title-${drama.id}'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.left, + style: const TextStyle( + color: AppColors.navSelected, + fontSize: AppDimens.cardTitleFont, + fontWeight: FontWeight.w500, + height: AppDimens.cardTitleLineHeight, + ), + ), + ), + const SizedBox(width: AppDimens.spaceXs), + _views(), + ], ), const SizedBox(height: AppDimens.cardTitleTagGap), _tags(), @@ -102,11 +112,6 @@ class HomeFeedCard extends StatelessWidget { keyPrefix: '${drama.id}-${badge.name}', ), ), - Positioned( - left: AppDimens.spaceXs, - bottom: AppDimens.spaceXs, - child: _views(), - ), ], ), ), @@ -141,33 +146,27 @@ class HomeFeedCard extends StatelessWidget { } Widget _views() { - return Container( + return Row( key: Key('home-feed-views-${drama.id}'), - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), - decoration: BoxDecoration( - color: AppColors.playerOverlayPill, - borderRadius: BorderRadius.circular(AppDimens.pillRadius), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.auto_awesome_rounded, - size: AppDimens.cardViewsIcon, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon( + Icons.auto_awesome_rounded, + size: AppDimens.cardViewsIcon, + color: AppColors.navSelected, + ), + const SizedBox(width: AppDimens.cardPlayCountGap), + Text( + formatPlayCount(drama.viewCount), + style: const TextStyle( color: AppColors.navSelected, + fontSize: AppDimens.cardPlayCountFont, + fontWeight: FontWeight.w400, + letterSpacing: 0, ), - const SizedBox(width: 1), - Text( - formatPlayCount(drama.viewCount), - style: const TextStyle( - color: AppColors.navSelected, - fontSize: AppDimens.cardViewsFont, - fontWeight: FontWeight.w600, - letterSpacing: 0, - ), - ), - ], - ), + ), + ], ); } diff --git a/lib/pages/recommend/widgets/recommend_section_card.dart b/lib/pages/recommend/widgets/recommend_section_card.dart index fdaab54..58d1187 100644 --- a/lib/pages/recommend/widgets/recommend_section_card.dart +++ b/lib/pages/recommend/widgets/recommend_section_card.dart @@ -68,37 +68,27 @@ class RecommendSectionCard extends StatelessWidget { ), const SizedBox(height: AppDimens.cardCoverTitleGap), Row( - key: Key('recommend-views-$sectionKey-${drama.id}'), - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, children: [ - const Icon( - Icons.auto_awesome_rounded, - color: AppColors.textPrimary, - size: AppDimens.cardViewsIcon, - ), - Text( - formatPlayCount(drama.viewCount), - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: AppDimens.cardViewsFont, + Expanded( + child: Text( + drama.title, + key: Key('recommend-title-$sectionKey-${drama.id}'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.cardTitleFont, + fontWeight: FontWeight.w500, + height: AppDimens.cardTitleLineHeight, + ), ), ), + const SizedBox(width: AppDimens.spaceXs), + _views(), ], ), - const SizedBox(height: 2), - Text( - drama.title, - key: Key('recommend-title-$sectionKey-${drama.id}'), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: AppDimens.cardTitleFont, - fontWeight: FontWeight.w500, - height: AppDimens.cardTitleLineHeight, - ), - ), const SizedBox(height: AppDimens.cardTitleTagGap), _tags(), ], @@ -107,6 +97,30 @@ class RecommendSectionCard extends StatelessWidget { ); } + Widget _views() { + return Row( + key: Key('recommend-views-$sectionKey-${drama.id}'), + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon( + Icons.auto_awesome_rounded, + color: AppColors.textPrimary, + size: AppDimens.cardViewsIcon, + ), + const SizedBox(width: AppDimens.cardPlayCountGap), + Text( + formatPlayCount(drama.viewCount), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.cardPlayCountFont, + fontWeight: FontWeight.w400, + ), + ), + ], + ); + } + List _cardTags() { final seen = {}; return [drama.genre, drama.category] diff --git a/test/following/following_page_test.dart b/test/following/following_page_test.dart index d7273dc..bf298b8 100644 --- a/test/following/following_page_test.dart +++ b/test/following/following_page_test.dart @@ -87,7 +87,8 @@ void main() { final views = tester.widget( find.byKey(const Key('following-views-the-signal-demo')), ); - expect(views.style?.fontSize, AppDimens.cardViewsFont); + expect(views.style?.fontSize, AppDimens.cardPlayCountFont); + expect(views.style?.fontWeight, FontWeight.w400); expect(views.style?.color, AppColors.textPrimary); expect( find.descendant( diff --git a/test/home/home_card_style_test.dart b/test/home/home_card_style_test.dart index 212e915..c09896d 100644 --- a/test/home/home_card_style_test.dart +++ b/test/home/home_card_style_test.dart @@ -74,8 +74,13 @@ void main() { find.descendant(of: views, matching: find.byType(Text)), ); expect(viewsText.data, '44.5w'); - expect(viewsText.style?.fontSize, AppDimens.cardViewsFont); + expect(viewsText.style?.fontSize, AppDimens.cardPlayCountFont); + expect(viewsText.style?.fontWeight, FontWeight.w400); expect(viewsText.style?.color, AppColors.navSelected); + expect( + find.descendant(of: cover, matching: find.byKey(views)), + findsNothing, + ); expect( find.descendant( of: views, diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart index 5aac69b..d80ef66 100644 --- a/test/recommend/recommend_sections_test.dart +++ b/test/recommend/recommend_sections_test.dart @@ -108,7 +108,8 @@ void main() { matching: find.byType(Text), ), ); - expect(views.style?.fontSize, AppDimens.cardViewsFont); + expect(views.style?.fontSize, AppDimens.cardPlayCountFont); + expect(views.style?.fontWeight, FontWeight.w400); expect(views.style?.color, AppColors.textPrimary); final viewIcon = find.descendant( of: find.byKey(const Key('recommend-views-hits-the-signal-demo')), From 1adcc167273632dfac5213ebafa472bf8f877ac4 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 18:28:44 +0800 Subject: [PATCH 039/129] =?UTF-8?q?fix(home):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E4=BA=8C=E7=BA=A7=E6=A0=87=E7=AD=BE=E9=80=89=E4=B8=AD=E6=A0=B7?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 2 +- lib/core/theme/app_dimens.dart | 7 +++++-- lib/pages/home/widgets/home_grid_header.dart | 17 ++++++++++------- test/home/home_preview_layout_test.dart | 14 +++++++++++++- 4 files changed, 29 insertions(+), 11 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index b654c0c..a4fd01e 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1111,7 +1111,7 @@ $ flutter test ## 2026-10-22 首页布局对齐修正 -- 一级分类改为页内切换 6 个列表,未选 16/w400 灰字、选中 20/w700 白字,无下划线;二级 chips 仅精选显示。 +- 一级分类页内切换 6 个列表,未选 16/w400、选中 20/w700,无下划线;二级 chips 高 26、字 15、间距 14,仅精选显示。 - 四类短剧卡片移除底板/描边,封面统一 8 圆角,网格行距 10、封底到标题 6;热剧/新剧/漫剧角标移至右上角异形丝带。 - 标题下方恢复左对齐题材文字,使用 `|` 分隔、灰色小字,与封面状态角标保持独立。 - 首页搜索栏到一级、一级到二级、二级到网格间距为 8/8/10;非精选 tab 直接以 10 衔接网格。 diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 1e1eb9c..f6e2fbc 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -106,9 +106,12 @@ abstract final class AppDimens { static const Duration homePrimaryTabAnimationDuration = Duration( milliseconds: 180, ); - static const double homeSecondaryTagFont = 13; + static const double homeSecondaryTagFont = 15; + static const double homeSecondaryTagHeight = 26; + static const double homeSecondaryTagHorizontalPadding = 10; + static const double homeSecondaryTagRadius = 13; static const double homePrimaryCategoryGap = 24; - static const double homeSecondaryTagGap = 18; + static const double homeSecondaryTagGap = 14; static const double homeSearchTopGap = 0; static const double homeSearchPrimaryGap = 8; static const double homePrimarySecondaryGap = 8; diff --git a/lib/pages/home/widgets/home_grid_header.dart b/lib/pages/home/widgets/home_grid_header.dart index df3445f..44dd11a 100644 --- a/lib/pages/home/widgets/home_grid_header.dart +++ b/lib/pages/home/widgets/home_grid_header.dart @@ -362,7 +362,7 @@ class HomeGridHeader extends StatelessWidget { style: TextStyle( color: selected ? AppColors.navSelected : AppColors.textTertiary, fontSize: AppDimens.homeSecondaryTagFont, - fontWeight: selected ? FontWeight.w700 : FontWeight.w400, + fontWeight: selected ? FontWeight.w600 : FontWeight.w400, letterSpacing: 0, ), ); @@ -372,19 +372,22 @@ class HomeGridHeader extends StatelessWidget { onTap: () => onTagSelected(selected ? null : tag.id), child: selected ? Container( + height: AppDimens.homeSecondaryTagHeight, padding: const EdgeInsets.symmetric( - horizontal: AppDimens.spaceMd, - vertical: AppDimens.spaceSm, + horizontal: AppDimens.homeSecondaryTagHorizontalPadding, ), + alignment: Alignment.center, decoration: BoxDecoration( color: AppColors.contentSurface, - borderRadius: BorderRadius.circular(AppDimens.pillRadius), + borderRadius: BorderRadius.circular( + AppDimens.homeSecondaryTagRadius, + ), ), child: label, ) - : Padding( - padding: const EdgeInsets.symmetric(vertical: AppDimens.spaceSm), - child: label, + : SizedBox( + height: AppDimens.homeSecondaryTagHeight, + child: Align(alignment: Alignment.centerLeft, child: label), ), ); } diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 152ffe7..36e8e79 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -157,6 +157,10 @@ void main() { expect(primaryGap, closeTo(AppDimens.homePrimaryCategoryGap, 0.1)); final tagStart = tester.getTopLeft(find.text('Hot')); expect(tagStart.dx, pageStart.dx); + expect( + tester.getSize(find.byKey(const Key('home-tag-hot'))).height, + AppDimens.homeSecondaryTagHeight, + ); final tagGap = tester.getTopLeft(find.text('Uplifting')).dx - tester.getBottomRight(find.text('Hot')).dx; @@ -173,6 +177,14 @@ void main() { (selectedTagChip.decoration! as BoxDecoration).color, AppColors.contentSurface, ); + expect( + (selectedTagChip.decoration! as BoxDecoration).borderRadius, + BorderRadius.circular(AppDimens.homeSecondaryTagRadius), + ); + expect( + tester.getSize(find.byKey(const Key('home-tag-hot'))).height, + AppDimens.homeSecondaryTagHeight, + ); final selectedTag = tester.widget( find.descendant( of: find.byKey(const Key('home-tag-hot')), @@ -180,7 +192,7 @@ void main() { ), ); expect(selectedTag.style?.color, AppColors.navSelected); - expect(selectedTag.style?.fontWeight, FontWeight.w700); + expect(selectedTag.style?.fontWeight, FontWeight.w600); expect(find.byType(PageView), findsNothing); expect(tester.takeException(), isNull); }); From 325f7c1f5dc5e663797ff776548aef59cd696f73 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 18:29:22 +0800 Subject: [PATCH 040/129] =?UTF-8?q?fix(home):=20=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E4=B8=80=E7=BA=A7=E5=88=86=E7=B1=BB=E6=9C=AA=E9=80=89=E5=AD=97?= =?UTF-8?q?=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 2 +- lib/core/theme/app_colors.dart | 1 + lib/core/theme/app_dimens.dart | 2 +- lib/pages/home/widgets/home_grid_header.dart | 2 +- test/home/home_preview_layout_test.dart | 2 +- 5 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index a4fd01e..61d99e4 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1111,7 +1111,7 @@ $ flutter test ## 2026-10-22 首页布局对齐修正 -- 一级分类页内切换 6 个列表,未选 16/w400、选中 20/w700,无下划线;二级 chips 高 26、字 15、间距 14,仅精选显示。 +- 一级分类页内切换 6 个列表,未选 18/w400 `#B0B0B0`、选中 20/w700 白字,无下划线;二级 chips 仅精选显示。 - 四类短剧卡片移除底板/描边,封面统一 8 圆角,网格行距 10、封底到标题 6;热剧/新剧/漫剧角标移至右上角异形丝带。 - 标题下方恢复左对齐题材文字,使用 `|` 分隔、灰色小字,与封面状态角标保持独立。 - 首页搜索栏到一级、一级到二级、二级到网格间距为 8/8/10;非精选 tab 直接以 10 衔接网格。 diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 70f8447..c43328a 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -80,6 +80,7 @@ abstract final class AppColors { static const textPrimary = Color(0xFFFFFFFF); static const textSecondary = Color(0xFF8E8E93); static const textTertiary = Color(0xFF9E9E9E); + static const textTabInactive = Color(0xFFB0B0B0); /// 原生开屏与冷启动过渡底色。 static const splashInk = ink; diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index f6e2fbc..1bddc13 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -100,7 +100,7 @@ abstract final class AppDimens { static const double cardTitleTagGap = 6; static const double cardCoverAspectRatio = 5 / 6; static const double homeCardBodyExtent = 84; - static const double homePrimaryTabFont = 16; + static const double homePrimaryTabFont = 18; static const double homePrimaryTabSelectedFont = 20; static const double homePrimaryRowHeight = 30; static const Duration homePrimaryTabAnimationDuration = Duration( diff --git a/lib/pages/home/widgets/home_grid_header.dart b/lib/pages/home/widgets/home_grid_header.dart index 44dd11a..482478a 100644 --- a/lib/pages/home/widgets/home_grid_header.dart +++ b/lib/pages/home/widgets/home_grid_header.dart @@ -286,7 +286,7 @@ class HomeGridHeader extends StatelessWidget { style: TextStyle( color: selected ? AppColors.navSelected - : AppColors.textTertiary, + : AppColors.textTabInactive, fontSize: selected ? AppDimens.homePrimaryTabSelectedFont : AppDimens.homePrimaryTabFont, diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 36e8e79..14a6cec 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -124,7 +124,7 @@ void main() { expect(selectedPrimary.duration, AppDimens.homePrimaryTabAnimationDuration); expect(unselectedPrimary.style.fontSize, AppDimens.homePrimaryTabFont); expect(unselectedPrimary.style.fontWeight, FontWeight.w400); - expect(unselectedPrimary.style.color, AppColors.textTertiary); + expect(unselectedPrimary.style.color, AppColors.textTabInactive); expect( find.descendant( of: find.byKey(const Key('home-section-featured')), From 972e542e5c661daf817a07113bc103e8953a30e5 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 18:30:33 +0800 Subject: [PATCH 041/129] =?UTF-8?q?fix(home):=20=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E5=B0=81=E9=9D=A2=E6=92=AD=E6=94=BE=E9=87=8F=E4=BD=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 2 +- lib/core/theme/app_colors.dart | 2 + lib/core/theme/app_dimens.dart | 2 + lib/core/widgets/cover_play_count.dart | 60 +++++++++++++++ lib/pages/following/following_grid_card.dart | 72 +++++++----------- lib/pages/home/widgets/home_feed_card.dart | 75 +++++++------------ .../widgets/recommend_section_card.dart | 72 +++++++----------- test/following/following_page_test.dart | 14 +++- test/home/home_card_style_test.dart | 12 +++ test/recommend/recommend_sections_test.dart | 15 +++- 10 files changed, 179 insertions(+), 147 deletions(-) create mode 100644 lib/core/widgets/cover_play_count.dart diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 61d99e4..3fefd21 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1115,5 +1115,5 @@ $ flutter test - 四类短剧卡片移除底板/描边,封面统一 8 圆角,网格行距 10、封底到标题 6;热剧/新剧/漫剧角标移至右上角异形丝带。 - 标题下方恢复左对齐题材文字,使用 `|` 分隔、灰色小字,与封面状态角标保持独立。 - 首页搜索栏到一级、一级到二级、二级到网格间距为 8/8/10;非精选 tab 直接以 10 衔接网格。 -- 播放量移至标题行最右,使用 spark 图标与 12/w400 纯文字、`w` 格式,整数无小数;四类列表共用。 +- 播放量回到封面右下角,使用 spark 图标与 12/w400 纯文字、`w` 格式,并加极淡底部渐变;四类列表共用。 - 全部短剧的地区、字幕、配音筛选 UI 已占位;以下接口待补:地区、字幕/配音筛选数据。 diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index c43328a..5a49010 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -159,6 +159,8 @@ abstract final class AppColors { /// 播放器控件胶囊与圆形底色。 static const playerOverlayPill = Color(0xB3000000); + static const cardBottomScrimStart = Color(0x00000000); + static const cardBottomScrimEnd = Color(0x40000000); /// 进度条未激活轨道。 static const inactiveTrack = Color(0x24FFFFFF); diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 1bddc13..1ed7c4e 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -89,6 +89,8 @@ abstract final class AppDimens { static const double cardViewsIcon = 11; static const double cardPlayCountFont = 12; static const double cardPlayCountGap = 2; + static const double cardPlayCountBottom = 6; + static const double cardPlayCountScrimHeight = 32; static const double cardGenreRibbonHeight = 20; static const double cardGenreRibbonRadius = 10; static const double cardGenreRibbonLeftInset = 8; diff --git a/lib/core/widgets/cover_play_count.dart b/lib/core/widgets/cover_play_count.dart new file mode 100644 index 0000000..73205b1 --- /dev/null +++ b/lib/core/widgets/cover_play_count.dart @@ -0,0 +1,60 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../formatting/count_format.dart'; +import '../theme/app_colors.dart'; +import '../theme/app_dimens.dart'; + +class CoverPlayCount extends StatelessWidget { + const CoverPlayCount({super.key, required this.count}); + + final int count; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon( + Icons.auto_awesome_rounded, + color: AppColors.textPrimary, + size: AppDimens.cardViewsIcon, + ), + const SizedBox(width: AppDimens.cardPlayCountGap), + Text( + formatPlayCount(count), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.cardPlayCountFont, + fontWeight: FontWeight.w400, + ), + ), + ], + ); + } +} + +class CoverBottomScrim extends StatelessWidget { + const CoverBottomScrim({super.key}); + + @override + Widget build(BuildContext context) { + return const SizedBox( + height: AppDimens.cardPlayCountScrimHeight, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.cardBottomScrimStart, + AppColors.cardBottomScrimEnd, + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/following/following_grid_card.dart b/lib/pages/following/following_grid_card.dart index a525d69..c5f43fd 100644 --- a/lib/pages/following/following_grid_card.dart +++ b/lib/pages/following/following_grid_card.dart @@ -2,9 +2,9 @@ import 'package:flutter/material.dart'; -import '../../core/formatting/count_format.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; +import '../../core/widgets/cover_play_count.dart'; import '../../core/widgets/remote_image.dart'; class FollowingGridCard extends StatelessWidget { @@ -80,31 +80,35 @@ class FollowingGridCard extends StatelessWidget { ), ), ), + const Positioned( + left: 0, + right: 0, + bottom: 0, + child: CoverBottomScrim(), + ), + Positioned( + right: 0, + bottom: AppDimens.cardPlayCountBottom, + child: CoverPlayCount( + key: Key('following-views-$dramaId'), + count: viewCount, + ), + ), ], ), ), const SizedBox(height: AppDimens.cardCoverTitleGap), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: Text( - title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.left, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: AppDimens.cardTitleFont, - fontWeight: FontWeight.w500, - height: AppDimens.cardTitleLineHeight, - ), - ), - ), - const SizedBox(width: AppDimens.spaceXs), - _views(), - ], + Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.left, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.cardTitleFont, + fontWeight: FontWeight.w500, + height: AppDimens.cardTitleLineHeight, + ), ), if (tags.isNotEmpty) ...[ const SizedBox(height: AppDimens.cardTitleTagGap), @@ -140,30 +144,6 @@ class FollowingGridCard extends StatelessWidget { ); } - Widget _views() { - return Row( - key: Key('following-views-$dramaId'), - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Icon( - Icons.auto_awesome_rounded, - color: AppColors.textPrimary, - size: AppDimens.cardViewsIcon, - ), - const SizedBox(width: AppDimens.cardPlayCountGap), - Text( - formatPlayCount(viewCount), - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: AppDimens.cardPlayCountFont, - fontWeight: FontWeight.w400, - ), - ), - ], - ); - } - Widget _tags() { final visible = tags.take(3).toList(growable: false); return Text( diff --git a/lib/pages/home/widgets/home_feed_card.dart b/lib/pages/home/widgets/home_feed_card.dart index 7b7d9b3..6a4c58f 100644 --- a/lib/pages/home/widgets/home_feed_card.dart +++ b/lib/pages/home/widgets/home_feed_card.dart @@ -3,9 +3,9 @@ import 'package:flutter/material.dart'; import '../../../core/data/models/drama.dart'; -import '../../../core/formatting/count_format.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; +import '../../../core/widgets/cover_play_count.dart'; import '../../../core/widgets/drama_cover_badge_ribbon.dart'; import '../../../core/widgets/remote_image.dart'; import '../../../l10n/app_localizations.dart'; @@ -50,28 +50,18 @@ class HomeFeedCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: Text( - drama.title, - key: Key('home-feed-title-${drama.id}'), - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.left, - style: const TextStyle( - color: AppColors.navSelected, - fontSize: AppDimens.cardTitleFont, - fontWeight: FontWeight.w500, - height: AppDimens.cardTitleLineHeight, - ), - ), - ), - const SizedBox(width: AppDimens.spaceXs), - _views(), - ], + Text( + drama.title, + key: Key('home-feed-title-${drama.id}'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.left, + style: const TextStyle( + color: AppColors.navSelected, + fontSize: AppDimens.cardTitleFont, + fontWeight: FontWeight.w500, + height: AppDimens.cardTitleLineHeight, + ), ), const SizedBox(height: AppDimens.cardTitleTagGap), _tags(), @@ -112,6 +102,20 @@ class HomeFeedCard extends StatelessWidget { keyPrefix: '${drama.id}-${badge.name}', ), ), + const Positioned( + left: 0, + right: 0, + bottom: 0, + child: CoverBottomScrim(), + ), + Positioned( + right: 0, + bottom: AppDimens.cardPlayCountBottom, + child: CoverPlayCount( + key: Key('home-feed-views-${drama.id}'), + count: drama.viewCount, + ), + ), ], ), ), @@ -145,31 +149,6 @@ class HomeFeedCard extends StatelessWidget { }; } - Widget _views() { - return Row( - key: Key('home-feed-views-${drama.id}'), - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Icon( - Icons.auto_awesome_rounded, - size: AppDimens.cardViewsIcon, - color: AppColors.navSelected, - ), - const SizedBox(width: AppDimens.cardPlayCountGap), - Text( - formatPlayCount(drama.viewCount), - style: const TextStyle( - color: AppColors.navSelected, - fontSize: AppDimens.cardPlayCountFont, - fontWeight: FontWeight.w400, - letterSpacing: 0, - ), - ), - ], - ); - } - Widget _tags() { final tags = _cardTags(); if (tags.isEmpty) return const SizedBox.shrink(); diff --git a/lib/pages/recommend/widgets/recommend_section_card.dart b/lib/pages/recommend/widgets/recommend_section_card.dart index 58d1187..c10894c 100644 --- a/lib/pages/recommend/widgets/recommend_section_card.dart +++ b/lib/pages/recommend/widgets/recommend_section_card.dart @@ -3,9 +3,9 @@ import 'package:flutter/material.dart'; import '../../../core/data/models/drama.dart'; -import '../../../core/formatting/count_format.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; +import '../../../core/widgets/cover_play_count.dart'; import '../../../core/widgets/drama_cover_badge_ribbon.dart'; import '../../../core/widgets/remote_image.dart'; import '../../../l10n/app_localizations.dart'; @@ -62,32 +62,36 @@ class RecommendSectionCard extends StatelessWidget { keyPrefix: '$sectionKey-${drama.id}-hot', ), ), + const Positioned( + left: 0, + right: 0, + bottom: 0, + child: CoverBottomScrim(), + ), + Positioned( + right: 0, + bottom: AppDimens.cardPlayCountBottom, + child: CoverPlayCount( + key: Key('recommend-views-$sectionKey-${drama.id}'), + count: drama.viewCount, + ), + ), ], ), ), ), const SizedBox(height: AppDimens.cardCoverTitleGap), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: Text( - drama.title, - key: Key('recommend-title-$sectionKey-${drama.id}'), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: AppDimens.cardTitleFont, - fontWeight: FontWeight.w500, - height: AppDimens.cardTitleLineHeight, - ), - ), - ), - const SizedBox(width: AppDimens.spaceXs), - _views(), - ], + Text( + drama.title, + key: Key('recommend-title-$sectionKey-${drama.id}'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.cardTitleFont, + fontWeight: FontWeight.w500, + height: AppDimens.cardTitleLineHeight, + ), ), const SizedBox(height: AppDimens.cardTitleTagGap), _tags(), @@ -97,30 +101,6 @@ class RecommendSectionCard extends StatelessWidget { ); } - Widget _views() { - return Row( - key: Key('recommend-views-$sectionKey-${drama.id}'), - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Icon( - Icons.auto_awesome_rounded, - color: AppColors.textPrimary, - size: AppDimens.cardViewsIcon, - ), - const SizedBox(width: AppDimens.cardPlayCountGap), - Text( - formatPlayCount(drama.viewCount), - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: AppDimens.cardPlayCountFont, - fontWeight: FontWeight.w400, - ), - ), - ], - ); - } - List _cardTags() { final seen = {}; return [drama.genre, drama.category] diff --git a/test/following/following_page_test.dart b/test/following/following_page_test.dart index bf298b8..1c0adc7 100644 --- a/test/following/following_page_test.dart +++ b/test/following/following_page_test.dart @@ -64,9 +64,10 @@ void main() { expect(delegate.mainAxisSpacing, AppDimens.homeGridRowGap); expect(delegate.childAspectRatio, AppDimens.followingGridAspectRatio); final card = find.byKey(const Key('following-card-the-signal-demo')); - final cover = tester.widget( - find.byKey(const Key('following-cover-the-signal-demo')), + final coverFinder = find.byKey( + const Key('following-cover-the-signal-demo'), ); + final cover = tester.widget(coverFinder); expect(cover.aspectRatio, AppDimens.cardCoverAspectRatio); final coverClip = tester.widget( find.descendant( @@ -89,6 +90,13 @@ void main() { ); expect(views.style?.fontSize, AppDimens.cardPlayCountFont); expect(views.style?.fontWeight, FontWeight.w400); + expect( + find.descendant( + of: coverFinder, + matching: find.byKey(const Key('following-views-the-signal-demo')), + ), + findsOneWidget, + ); expect(views.style?.color, AppColors.textPrimary); expect( find.descendant( @@ -103,7 +111,7 @@ void main() { expect(tags.data, 'Suspense | Mystery'); expect(tags.style?.fontSize, AppDimens.cardTagFont); expect(tags.style?.color, AppColors.textTertiary); - expect(tester.getTopLeft(title).dx, tester.getTopLeft(cover).dx); + expect(tester.getTopLeft(title).dx, tester.getTopLeft(coverFinder).dx); expect(tester.getTopLeft(tags).dx, tester.getTopLeft(cover).dx); await tester.tap(find.byKey(const Key('following-edit'))); diff --git a/test/home/home_card_style_test.dart b/test/home/home_card_style_test.dart index c09896d..4721472 100644 --- a/test/home/home_card_style_test.dart +++ b/test/home/home_card_style_test.dart @@ -79,6 +79,18 @@ void main() { expect(viewsText.style?.color, AppColors.navSelected); expect( find.descendant(of: cover, matching: find.byKey(views)), + findsOneWidget, + ); + expect( + tester.getTopRight(views).dx, + closeTo(tester.getTopRight(cover).dx, 1), + ); + expect( + tester.getBottomLeft(cover).dy - tester.getBottomLeft(views).dy, + AppDimens.cardPlayCountBottom, + ); + expect( + find.descendant(of: views, matching: find.byType(Container)), findsNothing, ); expect( diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart index d80ef66..0aaa7e6 100644 --- a/test/recommend/recommend_sections_test.dart +++ b/test/recommend/recommend_sections_test.dart @@ -92,9 +92,11 @@ void main() { coverClip.borderRadius, BorderRadius.circular(AppDimens.cardCoverRadius), ); - final cover = tester.widget( - find.descendant(of: card, matching: find.byType(AspectRatio)), + final coverFinder = find.descendant( + of: card, + matching: find.byType(AspectRatio), ); + final cover = tester.widget(coverFinder); expect(cover.aspectRatio, AppDimens.cardCoverAspectRatio); final cardTitle = tester.widget( find.byKey(const Key('recommend-title-hits-the-signal-demo')), @@ -110,6 +112,13 @@ void main() { ); expect(views.style?.fontSize, AppDimens.cardPlayCountFont); expect(views.style?.fontWeight, FontWeight.w400); + expect( + find.descendant( + of: coverFinder, + matching: find.byKey(const Key('recommend-views-hits-the-signal-demo')), + ), + findsOneWidget, + ); expect(views.style?.color, AppColors.textPrimary); final viewIcon = find.descendant( of: find.byKey(const Key('recommend-views-hits-the-signal-demo')), @@ -132,7 +141,7 @@ void main() { expect(tags.data, 'Suspense | Mystery'); expect(tags.style?.fontSize, AppDimens.cardTagFont); expect(tags.style?.color, AppColors.textTertiary); - expect(tester.getTopLeft(cardTitle).dx, tester.getTopLeft(cover).dx); + expect(tester.getTopLeft(cardTitle).dx, tester.getTopLeft(coverFinder).dx); expect(tester.getTopLeft(tags).dx, tester.getTopLeft(cover).dx); }); From 8375d8b25581086973adb995e2929f4e71002780 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 18:31:03 +0800 Subject: [PATCH 042/129] =?UTF-8?q?fix(home):=20=E6=94=B6=E7=B4=A7?= =?UTF-8?q?=E5=B0=81=E9=9D=A2=E7=8A=B6=E6=80=81=E8=A7=92=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 2 +- lib/core/theme/app_dimens.dart | 10 ++++------ lib/core/widgets/drama_cover_badge_ribbon.dart | 7 ++----- 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 3fefd21..6f90aee 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1112,7 +1112,7 @@ $ flutter test ## 2026-10-22 首页布局对齐修正 - 一级分类页内切换 6 个列表,未选 18/w400 `#B0B0B0`、选中 20/w700 白字,无下划线;二级 chips 仅精选显示。 -- 四类短剧卡片移除底板/描边,封面统一 8 圆角,网格行距 10、封底到标题 6;热剧/新剧/漫剧角标移至右上角异形丝带。 +- 四类卡片去底板/描边,封面 8 圆角、行距 10、封底标题间距 6;热剧/新剧/漫剧角标为右上角 16 高紧凑异形丝带。 - 标题下方恢复左对齐题材文字,使用 `|` 分隔、灰色小字,与封面状态角标保持独立。 - 首页搜索栏到一级、一级到二级、二级到网格间距为 8/8/10;非精选 tab 直接以 10 衔接网格。 - 播放量回到封面右下角,使用 spark 图标与 12/w400 纯文字、`w` 格式,并加极淡底部渐变;四类列表共用。 diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 1ed7c4e..cec1b67 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -91,13 +91,11 @@ abstract final class AppDimens { static const double cardPlayCountGap = 2; static const double cardPlayCountBottom = 6; static const double cardPlayCountScrimHeight = 32; - static const double cardGenreRibbonHeight = 20; - static const double cardGenreRibbonRadius = 10; - static const double cardGenreRibbonLeftInset = 8; - static const double cardGenreRibbonMaxWidth = 86; + static const double cardGenreRibbonHeight = 16; + static const double cardGenreRibbonLeftInset = 4; + static const double coverBadgeRibbonHorizontalPadding = 4; static const double cardTagFont = 11; - static const double cardTagHorizontalPadding = 6; - static const double cardBadgeFont = 10; + static const double cardBadgeFont = 9; static const double cardCoverTitleGap = 6; static const double cardTitleTagGap = 6; static const double cardCoverAspectRatio = 5 / 6; diff --git a/lib/core/widgets/drama_cover_badge_ribbon.dart b/lib/core/widgets/drama_cover_badge_ribbon.dart index 95a5048..f69d09f 100644 --- a/lib/core/widgets/drama_cover_badge_ribbon.dart +++ b/lib/core/widgets/drama_cover_badge_ribbon.dart @@ -24,15 +24,12 @@ class DramaCoverBadgeRibbon extends StatelessWidget { key: Key('drama-cover-badge-ribbon-$keyPrefix'), clipper: const _SlantedRibbonClipper(), child: Container( - constraints: const BoxConstraints( - maxWidth: AppDimens.cardGenreRibbonMaxWidth, - ), height: AppDimens.cardGenreRibbonHeight, padding: const EdgeInsets.only( left: - AppDimens.cardTagHorizontalPadding + + AppDimens.coverBadgeRibbonHorizontalPadding + AppDimens.cardGenreRibbonLeftInset, - right: AppDimens.cardTagHorizontalPadding, + right: AppDimens.coverBadgeRibbonHorizontalPadding, ), alignment: Alignment.center, color: color, From 0e7dcf4739a29ba7fc1919be1fe2897eadcd6cf1 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 18:33:31 +0800 Subject: [PATCH 043/129] =?UTF-8?q?fix(home):=20=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E5=B0=81=E9=9D=A2=E6=92=AD=E6=94=BE=E9=87=8F=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=E4=B8=8E=E4=BD=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 2 +- lib/core/theme/app_dimens.dart | 5 +++-- lib/core/widgets/cover_play_count.dart | 2 +- lib/pages/following/following_grid_card.dart | 2 +- lib/pages/home/widgets/home_feed_card.dart | 2 +- lib/pages/recommend/widgets/recommend_section_card.dart | 2 +- test/following/following_page_test.dart | 2 +- test/home/home_card_style_test.dart | 6 +++--- test/recommend/recommend_sections_test.dart | 2 +- 9 files changed, 13 insertions(+), 12 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 6f90aee..7147d33 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1115,5 +1115,5 @@ $ flutter test - 四类卡片去底板/描边,封面 8 圆角、行距 10、封底标题间距 6;热剧/新剧/漫剧角标为右上角 16 高紧凑异形丝带。 - 标题下方恢复左对齐题材文字,使用 `|` 分隔、灰色小字,与封面状态角标保持独立。 - 首页搜索栏到一级、一级到二级、二级到网格间距为 8/8/10;非精选 tab 直接以 10 衔接网格。 -- 播放量回到封面右下角,使用 spark 图标与 12/w400 纯文字、`w` 格式,并加极淡底部渐变;四类列表共用。 +- 播放量贴封面右下角(底 2、右 6),火焰图标 11 + 12/w400 纯文字,间距 4,并保留极淡底部渐变。 - 全部短剧的地区、字幕、配音筛选 UI 已占位;以下接口待补:地区、字幕/配音筛选数据。 diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index cec1b67..d60bb05 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -88,8 +88,9 @@ abstract final class AppDimens { static const double cardViewsFont = 11; static const double cardViewsIcon = 11; static const double cardPlayCountFont = 12; - static const double cardPlayCountGap = 2; - static const double cardPlayCountBottom = 6; + static const double cardPlayCountGap = 4; + static const double cardPlayCountBottom = 2; + static const double cardPlayCountRight = 6; static const double cardPlayCountScrimHeight = 32; static const double cardGenreRibbonHeight = 16; static const double cardGenreRibbonLeftInset = 4; diff --git a/lib/core/widgets/cover_play_count.dart b/lib/core/widgets/cover_play_count.dart index 73205b1..622fb37 100644 --- a/lib/core/widgets/cover_play_count.dart +++ b/lib/core/widgets/cover_play_count.dart @@ -18,7 +18,7 @@ class CoverPlayCount extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.center, children: [ const Icon( - Icons.auto_awesome_rounded, + Icons.local_fire_department_rounded, color: AppColors.textPrimary, size: AppDimens.cardViewsIcon, ), diff --git a/lib/pages/following/following_grid_card.dart b/lib/pages/following/following_grid_card.dart index c5f43fd..c1a433a 100644 --- a/lib/pages/following/following_grid_card.dart +++ b/lib/pages/following/following_grid_card.dart @@ -87,7 +87,7 @@ class FollowingGridCard extends StatelessWidget { child: CoverBottomScrim(), ), Positioned( - right: 0, + right: AppDimens.cardPlayCountRight, bottom: AppDimens.cardPlayCountBottom, child: CoverPlayCount( key: Key('following-views-$dramaId'), diff --git a/lib/pages/home/widgets/home_feed_card.dart b/lib/pages/home/widgets/home_feed_card.dart index 6a4c58f..a5ad5bd 100644 --- a/lib/pages/home/widgets/home_feed_card.dart +++ b/lib/pages/home/widgets/home_feed_card.dart @@ -109,7 +109,7 @@ class HomeFeedCard extends StatelessWidget { child: CoverBottomScrim(), ), Positioned( - right: 0, + right: AppDimens.cardPlayCountRight, bottom: AppDimens.cardPlayCountBottom, child: CoverPlayCount( key: Key('home-feed-views-${drama.id}'), diff --git a/lib/pages/recommend/widgets/recommend_section_card.dart b/lib/pages/recommend/widgets/recommend_section_card.dart index c10894c..8a0d477 100644 --- a/lib/pages/recommend/widgets/recommend_section_card.dart +++ b/lib/pages/recommend/widgets/recommend_section_card.dart @@ -69,7 +69,7 @@ class RecommendSectionCard extends StatelessWidget { child: CoverBottomScrim(), ), Positioned( - right: 0, + right: AppDimens.cardPlayCountRight, bottom: AppDimens.cardPlayCountBottom, child: CoverPlayCount( key: Key('recommend-views-$sectionKey-${drama.id}'), diff --git a/test/following/following_page_test.dart b/test/following/following_page_test.dart index 1c0adc7..b178d7b 100644 --- a/test/following/following_page_test.dart +++ b/test/following/following_page_test.dart @@ -101,7 +101,7 @@ void main() { expect( find.descendant( of: find.byKey(const Key('following-views-the-signal-demo')), - matching: find.byIcon(Icons.auto_awesome_rounded), + matching: find.byIcon(Icons.local_fire_department_rounded), ), findsOneWidget, ); diff --git a/test/home/home_card_style_test.dart b/test/home/home_card_style_test.dart index 4721472..9dd5902 100644 --- a/test/home/home_card_style_test.dart +++ b/test/home/home_card_style_test.dart @@ -82,8 +82,8 @@ void main() { findsOneWidget, ); expect( - tester.getTopRight(views).dx, - closeTo(tester.getTopRight(cover).dx, 1), + tester.getTopRight(cover).dx - tester.getTopRight(views).dx, + AppDimens.cardPlayCountRight, ); expect( tester.getBottomLeft(cover).dy - tester.getBottomLeft(views).dy, @@ -96,7 +96,7 @@ void main() { expect( find.descendant( of: views, - matching: find.byIcon(Icons.auto_awesome_rounded), + matching: find.byIcon(Icons.local_fire_department_rounded), ), findsOneWidget, ); diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart index 0aaa7e6..e2b372c 100644 --- a/test/recommend/recommend_sections_test.dart +++ b/test/recommend/recommend_sections_test.dart @@ -122,7 +122,7 @@ void main() { expect(views.style?.color, AppColors.textPrimary); final viewIcon = find.descendant( of: find.byKey(const Key('recommend-views-hits-the-signal-demo')), - matching: find.byIcon(Icons.auto_awesome_rounded), + matching: find.byIcon(Icons.local_fire_department_rounded), ); expect(viewIcon, findsOneWidget); final badge = find.byKey( From 91f4de01a18e000bb7c50d23b45fa7153827ee60 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 18:34:11 +0800 Subject: [PATCH 044/129] =?UTF-8?q?fix(home):=20=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E7=9F=AD=E5=89=A7=E5=B0=81=E9=9D=A2=E6=AF=94=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 2 +- lib/core/theme/app_dimens.dart | 2 +- lib/pages/home/widgets/home_feed_grid.dart | 4 +++- lib/pages/recommend/widgets/all_dramas_filter_grid.dart | 4 +++- test/home/home_card_style_test.dart | 5 ++++- 5 files changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 7147d33..65e44c5 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1112,7 +1112,7 @@ $ flutter test ## 2026-10-22 首页布局对齐修正 - 一级分类页内切换 6 个列表,未选 18/w400 `#B0B0B0`、选中 20/w700 白字,无下划线;二级 chips 仅精选显示。 -- 四类卡片去底板/描边,封面 8 圆角、行距 10、封底标题间距 6;热剧/新剧/漫剧角标为右上角 16 高紧凑异形丝带。 +- 四类卡片去底板/描边,封面统一 3:4、8 圆角、行距 10、封底标题间距 6;状态角标为右上角紧凑异形丝带。 - 标题下方恢复左对齐题材文字,使用 `|` 分隔、灰色小字,与封面状态角标保持独立。 - 首页搜索栏到一级、一级到二级、二级到网格间距为 8/8/10;非精选 tab 直接以 10 衔接网格。 - 播放量贴封面右下角(底 2、右 6),火焰图标 11 + 12/w400 纯文字,间距 4,并保留极淡底部渐变。 diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index d60bb05..e581022 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -99,7 +99,7 @@ abstract final class AppDimens { static const double cardBadgeFont = 9; static const double cardCoverTitleGap = 6; static const double cardTitleTagGap = 6; - static const double cardCoverAspectRatio = 5 / 6; + static const double cardCoverAspectRatio = 3 / 4; static const double homeCardBodyExtent = 84; static const double homePrimaryTabFont = 18; static const double homePrimaryTabSelectedFont = 20; diff --git a/lib/pages/home/widgets/home_feed_grid.dart b/lib/pages/home/widgets/home_feed_grid.dart index 238ad04..23b9b9f 100644 --- a/lib/pages/home/widgets/home_feed_grid.dart +++ b/lib/pages/home/widgets/home_feed_grid.dart @@ -129,7 +129,9 @@ class _HomeFeedGridState extends State { AppDimens.feedPageHorizontal * 2 - AppDimens.homeGridGap * 2) / 3; - final itemExtent = itemWidth * 6 / 5 + AppDimens.homeCardBodyExtent; + final itemExtent = + itemWidth / AppDimens.cardCoverAspectRatio + + AppDimens.homeCardBodyExtent; return NotificationListener( onNotification: _onScroll, child: AppPullToRefresh( diff --git a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart index 8ce56e4..059e47b 100644 --- a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart +++ b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart @@ -52,7 +52,9 @@ class _AllDramasFilterGridState extends State { AppDimens.feedPageHorizontal * 2 - AppDimens.homeGridGap * 2) / 3; - final itemExtent = itemWidth * 6 / 5 + AppDimens.homeCardBodyExtent; + final itemExtent = + itemWidth / AppDimens.cardCoverAspectRatio + + AppDimens.homeCardBodyExtent; return NotificationListener( onNotification: _onScroll, child: AppPullToRefresh( diff --git a/test/home/home_card_style_test.dart b/test/home/home_card_style_test.dart index 9dd5902..12e0dcb 100644 --- a/test/home/home_card_style_test.dart +++ b/test/home/home_card_style_test.dart @@ -40,7 +40,10 @@ void main() { final cover = find.byKey(const Key('home-feed-cover-anime-card')); final coverSize = tester.getSize(cover); - expect(coverSize.height / coverSize.width, closeTo(6 / 5, 0.02)); + expect( + coverSize.height / coverSize.width, + closeTo(1 / AppDimens.cardCoverAspectRatio, 0.02), + ); final coverClip = tester.widget( find.byKey(const Key('home-feed-cover-clip-anime-card')), ); From c6f3e3fa68ba24d58ee953bc2d2e3a031840e50e Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 18:36:30 +0800 Subject: [PATCH 045/129] =?UTF-8?q?fix(home):=20=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E4=BA=8C=E7=BA=A7=E7=AD=9B=E9=80=89=E6=8C=89=E9=92=AE=E5=B0=BA?= =?UTF-8?q?=E5=AF=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 2 +- lib/core/theme/app_dimens.dart | 3 +++ lib/pages/home/widgets/home_grid_header.dart | 19 +++++++++++-------- test/home/home_preview_layout_test.dart | 12 ++++++++++++ 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 65e44c5..ac93b2c 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1111,7 +1111,7 @@ $ flutter test ## 2026-10-22 首页布局对齐修正 -- 一级分类页内切换 6 个列表,未选 18/w400 `#B0B0B0`、选中 20/w700 白字,无下划线;二级 chips 仅精选显示。 +- 一级分类页内切换 6 个列表,未选 18/w400、选中 20/w700;二级 chips 与 26 高筛选按钮仅在精选显示。 - 四类卡片去底板/描边,封面统一 3:4、8 圆角、行距 10、封底标题间距 6;状态角标为右上角紧凑异形丝带。 - 标题下方恢复左对齐题材文字,使用 `|` 分隔、灰色小字,与封面状态角标保持独立。 - 首页搜索栏到一级、一级到二级、二级到网格间距为 8/8/10;非精选 tab 直接以 10 衔接网格。 diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index e581022..45fde37 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -111,6 +111,9 @@ abstract final class AppDimens { static const double homeSecondaryTagHeight = 26; static const double homeSecondaryTagHorizontalPadding = 10; static const double homeSecondaryTagRadius = 13; + static const double homeSecondaryFilterGap = 8; + static const double homeSecondaryFilterVisualSize = 26; + static const double homeSecondaryFilterIcon = 18; static const double homePrimaryCategoryGap = 24; static const double homeSecondaryTagGap = 14; static const double homeSearchTopGap = 0; diff --git a/lib/pages/home/widgets/home_grid_header.dart b/lib/pages/home/widgets/home_grid_header.dart index 482478a..d21a79a 100644 --- a/lib/pages/home/widgets/home_grid_header.dart +++ b/lib/pages/home/widgets/home_grid_header.dart @@ -329,22 +329,25 @@ class HomeGridHeader extends StatelessWidget { ), ), ), - const SizedBox(width: AppDimens.spaceXs), + const SizedBox(width: AppDimens.homeSecondaryFilterGap), Tooltip( message: l10n.filter, child: SizedBox.square( dimension: AppDimens.tapTarget, child: Material( - color: AppColors.contentSurface, - borderRadius: BorderRadius.circular(AppDimens.inputRadius), + color: AppColors.transparent, child: InkWell( key: const Key('home-grid-filter'), - borderRadius: BorderRadius.circular(AppDimens.inputRadius), onTap: callbacks.onOpenFilter, - child: const Icon( - Icons.filter_list_rounded, - color: AppColors.navSelected, - size: AppDimens.homeHeaderIcon, + child: Center( + child: SizedBox.square( + dimension: AppDimens.homeSecondaryFilterVisualSize, + child: const Icon( + Icons.filter_list_rounded, + color: AppColors.textTertiary, + size: AppDimens.homeSecondaryFilterIcon, + ), + ), ), ), ), diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 14a6cec..4397be8 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -94,6 +94,18 @@ void main() { expect(find.byKey(const Key('home-section-category-社群')), findsOneWidget); expect(find.byKey(const Key('home-secondary-tags')), findsOneWidget); expect(find.byKey(const Key('home-grid-filter')), findsOneWidget); + expect( + tester.getSize(find.byKey(const Key('home-grid-filter'))), + const Size.square(AppDimens.tapTarget), + ); + final filterIcon = tester.widget( + find.descendant( + of: find.byKey(const Key('home-grid-filter')), + matching: find.byIcon(Icons.filter_list_rounded), + ), + ); + expect(filterIcon.size, AppDimens.homeSecondaryFilterIcon); + expect(filterIcon.color, AppColors.textTertiary); final searchBox = find.descendant( of: find.byKey(const Key('home-search-entry')), matching: find.byType(Container), From 448010115f366b815818a84ea58660a8e002caeb Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 18:37:16 +0800 Subject: [PATCH 046/129] =?UTF-8?q?fix(home):=20=E9=87=8D=E7=BB=98?= =?UTF-8?q?=E4=BA=8C=E7=BA=A7=E7=AD=9B=E9=80=89=E4=B8=89=E7=BA=BF=E5=9B=BE?= =?UTF-8?q?=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 2 +- lib/core/theme/app_colors.dart | 1 + lib/core/theme/app_dimens.dart | 3 +- lib/pages/home/widgets/home_grid_header.dart | 33 +++++++++++++++++--- test/home/home_preview_layout_test.dart | 22 ++++++++----- 5 files changed, 48 insertions(+), 13 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index ac93b2c..d29bfce 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1111,7 +1111,7 @@ $ flutter test ## 2026-10-22 首页布局对齐修正 -- 一级分类页内切换 6 个列表,未选 18/w400、选中 20/w700;二级 chips 与 26 高筛选按钮仅在精选显示。 +- 一级分类未选 18/w400、选中 20/w700;二级 chips 与 26 高透明三线筛选图标仅在精选显示。 - 四类卡片去底板/描边,封面统一 3:4、8 圆角、行距 10、封底标题间距 6;状态角标为右上角紧凑异形丝带。 - 标题下方恢复左对齐题材文字,使用 `|` 分隔、灰色小字,与封面状态角标保持独立。 - 首页搜索栏到一级、一级到二级、二级到网格间距为 8/8/10;非精选 tab 直接以 10 衔接网格。 diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 5a49010..18cfc32 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -81,6 +81,7 @@ abstract final class AppColors { static const textSecondary = Color(0xFF8E8E93); static const textTertiary = Color(0xFF9E9E9E); static const textTabInactive = Color(0xFFB0B0B0); + static const textFilterIcon = Color(0xFF7A7A7E); /// 原生开屏与冷启动过渡底色。 static const splashInk = ink; diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 45fde37..95303f6 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -113,7 +113,8 @@ abstract final class AppDimens { static const double homeSecondaryTagRadius = 13; static const double homeSecondaryFilterGap = 8; static const double homeSecondaryFilterVisualSize = 26; - static const double homeSecondaryFilterIcon = 18; + static const double homeSecondaryFilterIcon = 16; + static const double homeSecondaryFilterStroke = 1.7; static const double homePrimaryCategoryGap = 24; static const double homeSecondaryTagGap = 14; static const double homeSearchTopGap = 0; diff --git a/lib/pages/home/widgets/home_grid_header.dart b/lib/pages/home/widgets/home_grid_header.dart index d21a79a..d61bf94 100644 --- a/lib/pages/home/widgets/home_grid_header.dart +++ b/lib/pages/home/widgets/home_grid_header.dart @@ -342,10 +342,12 @@ class HomeGridHeader extends StatelessWidget { child: Center( child: SizedBox.square( dimension: AppDimens.homeSecondaryFilterVisualSize, - child: const Icon( - Icons.filter_list_rounded, - color: AppColors.textTertiary, - size: AppDimens.homeSecondaryFilterIcon, + child: const Center( + child: CustomPaint( + key: Key('home-secondary-filter-icon'), + size: Size.square(AppDimens.homeSecondaryFilterIcon), + painter: _ThreeLineFilterPainter(), + ), ), ), ), @@ -395,3 +397,26 @@ class HomeGridHeader extends StatelessWidget { ); } } + +class _ThreeLineFilterPainter extends CustomPainter { + const _ThreeLineFilterPainter(); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = AppColors.textFilterIcon + ..strokeWidth = AppDimens.homeSecondaryFilterStroke + ..strokeCap = StrokeCap.butt; + final positions = [ + size.height * 0.25, + size.height * 0.5, + size.height * 0.75, + ]; + for (final y in positions) { + canvas.drawLine(Offset(0, y), Offset(size.width, y), paint); + } + } + + @override + bool shouldRepaint(_ThreeLineFilterPainter oldDelegate) => false; +} diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 4397be8..17bc0a6 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -98,14 +98,22 @@ void main() { tester.getSize(find.byKey(const Key('home-grid-filter'))), const Size.square(AppDimens.tapTarget), ); - final filterIcon = tester.widget( - find.descendant( - of: find.byKey(const Key('home-grid-filter')), - matching: find.byIcon(Icons.filter_list_rounded), - ), + final filterIcon = tester.widget( + find.byKey(const Key('home-secondary-filter-icon')), ); - expect(filterIcon.size, AppDimens.homeSecondaryFilterIcon); - expect(filterIcon.color, AppColors.textTertiary); + expect( + filterIcon.size, + const Size.square(AppDimens.homeSecondaryFilterIcon), + ); + final filterMaterial = tester.widget( + find + .ancestor( + of: find.byKey(const Key('home-grid-filter')), + matching: find.byType(Material), + ) + .first, + ); + expect(filterMaterial.color, AppColors.transparent); final searchBox = find.descendant( of: find.byKey(const Key('home-search-entry')), matching: find.byType(Container), From 4304727ffdc67b61c89155cc8c714ea8ae654181 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 18:38:41 +0800 Subject: [PATCH 047/129] =?UTF-8?q?fix(home):=20=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E4=BA=8C=E7=BA=A7=E7=AD=9B=E9=80=89=E5=9B=BE=E6=A0=87=E4=B8=AD?= =?UTF-8?q?=E5=BF=83=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/home/widgets/home_grid_header.dart | 10 ++++++---- test/home/home_preview_layout_test.dart | 10 +++++++++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/lib/pages/home/widgets/home_grid_header.dart b/lib/pages/home/widgets/home_grid_header.dart index d61bf94..8e52343 100644 --- a/lib/pages/home/widgets/home_grid_header.dart +++ b/lib/pages/home/widgets/home_grid_header.dart @@ -98,7 +98,7 @@ class HomeGridHeader extends StatelessWidget { AppDimens.homeSearchPrimaryGap, ), child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.start, children: [ Expanded( @@ -318,7 +318,7 @@ class HomeGridHeader extends StatelessWidget { key: const Key('home-secondary-tags'), scrollDirection: Axis.horizontal, child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.start, children: [ for (final tag in tags) ...[ @@ -332,8 +332,9 @@ class HomeGridHeader extends StatelessWidget { const SizedBox(width: AppDimens.homeSecondaryFilterGap), Tooltip( message: l10n.filter, - child: SizedBox.square( - dimension: AppDimens.tapTarget, + child: SizedBox( + width: AppDimens.tapTarget, + height: AppDimens.homeSecondaryFilterVisualSize, child: Material( color: AppColors.transparent, child: InkWell( @@ -341,6 +342,7 @@ class HomeGridHeader extends StatelessWidget { onTap: callbacks.onOpenFilter, child: Center( child: SizedBox.square( + key: const Key('home-secondary-filter-visual'), dimension: AppDimens.homeSecondaryFilterVisualSize, child: const Center( child: CustomPaint( diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 17bc0a6..347d66d 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -96,7 +96,15 @@ void main() { expect(find.byKey(const Key('home-grid-filter')), findsOneWidget); expect( tester.getSize(find.byKey(const Key('home-grid-filter'))), - const Size.square(AppDimens.tapTarget), + const Size(AppDimens.tapTarget, AppDimens.homeSecondaryFilterVisualSize), + ); + expect( + tester.getSize(find.byKey(const Key('home-secondary-filter-visual'))), + const Size.square(AppDimens.homeSecondaryFilterVisualSize), + ); + expect( + tester.getCenter(find.byKey(const Key('home-grid-filter'))).dy, + closeTo(tester.getCenter(find.byKey(const Key('home-tag-hot'))).dy, 0.1), ); final filterIcon = tester.widget( find.byKey(const Key('home-secondary-filter-icon')), From b3ea85df410dda5a58c1a79e41f7471c3765cc13 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 18:40:56 +0800 Subject: [PATCH 048/129] =?UTF-8?q?fix(home):=20=E7=A8=B3=E5=AE=9A?= =?UTF-8?q?=E4=BA=8C=E7=BA=A7=E6=A0=87=E7=AD=BE=E5=88=87=E6=8D=A2=E5=B8=83?= =?UTF-8?q?=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 2 +- lib/core/theme/app_dimens.dart | 5 ++- lib/pages/home/widgets/home_grid_header.dart | 38 +++++++++----------- test/home/home_preview_layout_test.dart | 25 ++++++------- 4 files changed, 33 insertions(+), 37 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index d29bfce..a6d43d8 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1111,7 +1111,7 @@ $ flutter test ## 2026-10-22 首页布局对齐修正 -- 一级分类未选 18/w400、选中 20/w700;二级 chips 与 26 高透明三线筛选图标仅在精选显示。 +- 一级分类未选 18/w400、选中 20/w700;二级 chips 间距 24、15/w600,并以稳定 AnimatedContainer 切换。 - 四类卡片去底板/描边,封面统一 3:4、8 圆角、行距 10、封底标题间距 6;状态角标为右上角紧凑异形丝带。 - 标题下方恢复左对齐题材文字,使用 `|` 分隔、灰色小字,与封面状态角标保持独立。 - 首页搜索栏到一级、一级到二级、二级到网格间距为 8/8/10;非精选 tab 直接以 10 衔接网格。 diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 95303f6..0cf7be7 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -111,12 +111,15 @@ abstract final class AppDimens { static const double homeSecondaryTagHeight = 26; static const double homeSecondaryTagHorizontalPadding = 10; static const double homeSecondaryTagRadius = 13; + static const Duration homeSecondaryTagAnimationDuration = Duration( + milliseconds: 150, + ); static const double homeSecondaryFilterGap = 8; static const double homeSecondaryFilterVisualSize = 26; static const double homeSecondaryFilterIcon = 16; static const double homeSecondaryFilterStroke = 1.7; static const double homePrimaryCategoryGap = 24; - static const double homeSecondaryTagGap = 14; + static const double homeSecondaryTagGap = 24; static const double homeSearchTopGap = 0; static const double homeSearchPrimaryGap = 8; static const double homePrimarySecondaryGap = 8; diff --git a/lib/pages/home/widgets/home_grid_header.dart b/lib/pages/home/widgets/home_grid_header.dart index 8e52343..ec0cb8b 100644 --- a/lib/pages/home/widgets/home_grid_header.dart +++ b/lib/pages/home/widgets/home_grid_header.dart @@ -366,10 +366,10 @@ class HomeGridHeader extends StatelessWidget { final selected = selectedTag == tag.id; final label = Text( tag.label, - style: TextStyle( + style: const TextStyle( color: selected ? AppColors.navSelected : AppColors.textTertiary, fontSize: AppDimens.homeSecondaryTagFont, - fontWeight: selected ? FontWeight.w600 : FontWeight.w400, + fontWeight: FontWeight.w600, letterSpacing: 0, ), ); @@ -377,25 +377,21 @@ class HomeGridHeader extends StatelessWidget { key: Key('home-tag-${tag.id}'), borderRadius: BorderRadius.circular(AppDimens.pillRadius), onTap: () => onTagSelected(selected ? null : tag.id), - child: selected - ? Container( - height: AppDimens.homeSecondaryTagHeight, - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.homeSecondaryTagHorizontalPadding, - ), - alignment: Alignment.center, - decoration: BoxDecoration( - color: AppColors.contentSurface, - borderRadius: BorderRadius.circular( - AppDimens.homeSecondaryTagRadius, - ), - ), - child: label, - ) - : SizedBox( - height: AppDimens.homeSecondaryTagHeight, - child: Align(alignment: Alignment.centerLeft, child: label), - ), + child: AnimatedContainer( + key: Key('home-tag-animated-${tag.id}'), + duration: AppDimens.homeSecondaryTagAnimationDuration, + curve: Curves.easeOut, + height: AppDimens.homeSecondaryTagHeight, + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.homeSecondaryTagHorizontalPadding, + ), + alignment: Alignment.center, + decoration: BoxDecoration( + color: selected ? AppColors.contentSurface : AppColors.transparent, + borderRadius: BorderRadius.circular(AppDimens.homeSecondaryTagRadius), + ), + child: label, + ), ); } } diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 347d66d..f68d3b0 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -167,14 +167,14 @@ void main() { ), ); expect(secondaryTag.style?.fontSize, AppDimens.homeSecondaryTagFont); - expect(secondaryTag.style?.fontWeight, FontWeight.w400); + expect(secondaryTag.style?.fontWeight, FontWeight.w600); expect(secondaryTag.style?.color, AppColors.textTertiary); + final unselectedTagChip = tester.widget( + find.byKey(const Key('home-tag-animated-hot')), + ); expect( - find.descendant( - of: find.byKey(const Key('home-tag-hot')), - matching: find.byType(Container), - ), - findsNothing, + (unselectedTagChip.decoration! as BoxDecoration).color, + AppColors.transparent, ); final primaryStart = tester.getTopLeft(find.text('Featured')); final pageStart = tester.getTopLeft(searchBox); @@ -183,23 +183,20 @@ void main() { tester.getTopLeft(find.text('New')).dx - tester.getBottomRight(find.text('Featured')).dx; expect(primaryGap, closeTo(AppDimens.homePrimaryCategoryGap, 0.1)); - final tagStart = tester.getTopLeft(find.text('Hot')); + final tagStart = tester.getTopLeft(find.byKey(const Key('home-tag-hot'))); expect(tagStart.dx, pageStart.dx); expect( tester.getSize(find.byKey(const Key('home-tag-hot'))).height, AppDimens.homeSecondaryTagHeight, ); final tagGap = - tester.getTopLeft(find.text('Uplifting')).dx - - tester.getBottomRight(find.text('Hot')).dx; + tester.getTopLeft(find.byKey(const Key('home-tag-uplifting'))).dx - + tester.getBottomRight(find.byKey(const Key('home-tag-hot'))).dx; expect(tagGap, closeTo(AppDimens.homeSecondaryTagGap, 0.1)); await tester.tap(find.byKey(const Key('home-tag-hot'))); await tester.pump(); - final selectedTagChip = tester.widget( - find.descendant( - of: find.byKey(const Key('home-tag-hot')), - matching: find.byType(Container), - ), + final selectedTagChip = tester.widget( + find.byKey(const Key('home-tag-animated-hot')), ); expect( (selectedTagChip.decoration! as BoxDecoration).color, From ba0ce2f8c0e5147094b2ea59cd6fe33c7e7f04e4 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 18:43:02 +0800 Subject: [PATCH 049/129] =?UTF-8?q?fix(home):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=A0=B7=E5=BC=8F=E5=B8=B8=E9=87=8F=E4=B8=8E=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 1 + lib/pages/home/widgets/home_grid_header.dart | 2 +- test/following/following_page_test.dart | 15 +++++++++------ test/home/home_card_style_test.dart | 19 ++++++++----------- test/recommend/recommend_sections_test.dart | 17 +++++++++++------ 5 files changed, 30 insertions(+), 24 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index a6d43d8..fef5305 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1116,4 +1116,5 @@ $ flutter test - 标题下方恢复左对齐题材文字,使用 `|` 分隔、灰色小字,与封面状态角标保持独立。 - 首页搜索栏到一级、一级到二级、二级到网格间距为 8/8/10;非精选 tab 直接以 10 衔接网格。 - 播放量贴封面右下角(底 2、右 6),火焰图标 11 + 12/w400 纯文字,间距 4,并保留极淡底部渐变。 +- 修复二级 chip 运行时颜色误用 `const TextStyle` 的编译错误,并校正相关测试 Finder 类型。 - 全部短剧的地区、字幕、配音筛选 UI 已占位;以下接口待补:地区、字幕/配音筛选数据。 diff --git a/lib/pages/home/widgets/home_grid_header.dart b/lib/pages/home/widgets/home_grid_header.dart index ec0cb8b..e7ca8e0 100644 --- a/lib/pages/home/widgets/home_grid_header.dart +++ b/lib/pages/home/widgets/home_grid_header.dart @@ -366,7 +366,7 @@ class HomeGridHeader extends StatelessWidget { final selected = selectedTag == tag.id; final label = Text( tag.label, - style: const TextStyle( + style: TextStyle( color: selected ? AppColors.navSelected : AppColors.textTertiary, fontSize: AppDimens.homeSecondaryTagFont, fontWeight: FontWeight.w600, diff --git a/test/following/following_page_test.dart b/test/following/following_page_test.dart index b178d7b..ede23ea 100644 --- a/test/following/following_page_test.dart +++ b/test/following/following_page_test.dart @@ -79,9 +79,11 @@ void main() { coverClip.borderRadius, BorderRadius.circular(AppDimens.cardCoverRadius), ); - final title = tester.widget( - find.descendant(of: card, matching: find.text('The Signal')), + final titleFinder = find.descendant( + of: card, + matching: find.text('The Signal'), ); + final title = tester.widget(titleFinder); expect(title.style?.fontSize, AppDimens.cardTitleFont); expect(title.style?.fontWeight, FontWeight.w500); expect(title.style?.height, AppDimens.cardTitleLineHeight); @@ -105,14 +107,15 @@ void main() { ), findsOneWidget, ); - final tags = tester.widget( - find.byKey(const Key('following-tags-the-signal-demo')), + final tagsFinder = find.byKey( + const Key('following-tags-the-signal-demo'), ); + final tags = tester.widget(tagsFinder); expect(tags.data, 'Suspense | Mystery'); expect(tags.style?.fontSize, AppDimens.cardTagFont); expect(tags.style?.color, AppColors.textTertiary); - expect(tester.getTopLeft(title).dx, tester.getTopLeft(coverFinder).dx); - expect(tester.getTopLeft(tags).dx, tester.getTopLeft(cover).dx); + expect(tester.getTopLeft(titleFinder).dx, tester.getTopLeft(coverFinder).dx); + expect(tester.getTopLeft(tagsFinder).dx, tester.getTopLeft(coverFinder).dx); await tester.tap(find.byKey(const Key('following-edit'))); await tester.pumpAndSettle(); diff --git a/test/home/home_card_style_test.dart b/test/home/home_card_style_test.dart index 12e0dcb..551cfd0 100644 --- a/test/home/home_card_style_test.dart +++ b/test/home/home_card_style_test.dart @@ -80,10 +80,7 @@ void main() { expect(viewsText.style?.fontSize, AppDimens.cardPlayCountFont); expect(viewsText.style?.fontWeight, FontWeight.w400); expect(viewsText.style?.color, AppColors.navSelected); - expect( - find.descendant(of: cover, matching: find.byKey(views)), - findsOneWidget, - ); + expect(find.descendant(of: cover, matching: views), findsOneWidget); expect( tester.getTopRight(cover).dx - tester.getTopRight(views).dx, AppDimens.cardPlayCountRight, @@ -103,21 +100,21 @@ void main() { ), findsOneWidget, ); - final title = tester.widget( - find.byKey(const Key('home-feed-title-anime-card')), + final titleFinder = find.byKey( + const Key('home-feed-title-anime-card'), ); + final title = tester.widget(titleFinder); expect(title.maxLines, 2); expect(title.style?.fontSize, AppDimens.cardTitleFont); expect(title.style?.fontWeight, FontWeight.w500); expect(title.style?.height, AppDimens.cardTitleLineHeight); - final tags = tester.widget( - find.byKey(const Key('home-feed-tags-anime-card')), - ); + final tagsFinder = find.byKey(const Key('home-feed-tags-anime-card')); + final tags = tester.widget(tagsFinder); expect(tags.data, 'Romance | Urban'); expect(tags.style?.fontSize, AppDimens.cardTagFont); expect(tags.style?.color, AppColors.textTertiary); - expect(tester.getTopLeft(title).dx, tester.getTopLeft(cover).dx); - expect(tester.getTopLeft(tags).dx, tester.getTopLeft(cover).dx); + expect(tester.getTopLeft(titleFinder).dx, tester.getTopLeft(cover).dx); + expect(tester.getTopLeft(tagsFinder).dx, tester.getTopLeft(cover).dx); await tester.tap(card); expect(taps, 1); diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart index e2b372c..4f7e973 100644 --- a/test/recommend/recommend_sections_test.dart +++ b/test/recommend/recommend_sections_test.dart @@ -98,9 +98,10 @@ void main() { ); final cover = tester.widget(coverFinder); expect(cover.aspectRatio, AppDimens.cardCoverAspectRatio); - final cardTitle = tester.widget( - find.byKey(const Key('recommend-title-hits-the-signal-demo')), + final cardTitleFinder = find.byKey( + const Key('recommend-title-hits-the-signal-demo'), ); + final cardTitle = tester.widget(cardTitleFinder); expect(cardTitle.style?.fontSize, AppDimens.cardTitleFont); expect(cardTitle.style?.fontWeight, FontWeight.w500); expect(cardTitle.style?.height, AppDimens.cardTitleLineHeight); @@ -135,14 +136,18 @@ void main() { expect(badgeText.data, 'Hit'); expect(badgeText.style?.fontSize, AppDimens.cardBadgeFont); expect(badgeText.style?.color, AppColors.textPrimary); - final tags = tester.widget( - find.byKey(const Key('recommend-tags-hits-the-signal-demo')), + final tagsFinder = find.byKey( + const Key('recommend-tags-hits-the-signal-demo'), ); + final tags = tester.widget(tagsFinder); expect(tags.data, 'Suspense | Mystery'); expect(tags.style?.fontSize, AppDimens.cardTagFont); expect(tags.style?.color, AppColors.textTertiary); - expect(tester.getTopLeft(cardTitle).dx, tester.getTopLeft(coverFinder).dx); - expect(tester.getTopLeft(tags).dx, tester.getTopLeft(cover).dx); + expect( + tester.getTopLeft(cardTitleFinder).dx, + tester.getTopLeft(coverFinder).dx, + ); + expect(tester.getTopLeft(tagsFinder).dx, tester.getTopLeft(coverFinder).dx); }); testWidgets('should_switch_inner_tabs_without_losing_grid_filter_state', ( From 909762ac3b0008a7880a2d7bd73966e175d9306e Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 18:46:19 +0800 Subject: [PATCH 050/129] =?UTF-8?q?fix(home):=20=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E6=A0=8F=E5=8F=B3=E4=BE=A7=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 1 + lib/core/theme/app_colors.dart | 2 + lib/core/theme/app_dimens.dart | 4 +- lib/pages/home/widgets/home_grid_header.dart | 15 +++--- test/home/home_nav_ui_test.dart | 52 ++++++++++++++++++++ 5 files changed, 66 insertions(+), 8 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index fef5305..23a7ef2 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1115,6 +1115,7 @@ $ flutter test - 四类卡片去底板/描边,封面统一 3:4、8 圆角、行距 10、封底标题间距 6;状态角标为右上角紧凑异形丝带。 - 标题下方恢复左对齐题材文字,使用 `|` 分隔、灰色小字,与封面状态角标保持独立。 - 首页搜索栏到一级、一级到二级、二级到网格间距为 8/8/10;非精选 tab 直接以 10 衔接网格。 +- 搜索框右侧皇冠与礼物改为 40 热区、24 彩色图标,透明背景、间距 12,保留礼物角标。 - 播放量贴封面右下角(底 2、右 6),火焰图标 11 + 12/w400 纯文字,间距 4,并保留极淡底部渐变。 - 修复二级 chip 运行时颜色误用 `const TextStyle` 的编译错误,并校正相关测试 Finder 类型。 - 全部短剧的地区、字幕、配音筛选 UI 已占位;以下接口待补:地区、字幕/配音筛选数据。 diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 18cfc32..861b272 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -39,6 +39,8 @@ abstract final class AppColors { /// 金币与奖励。 static const gold = Color(0xFFF3B34C); static const goldDeep = Color(0xFFD9A23A); + static const homeCrownGold = Color(0xFFF5C451); + static const homeGiftPink = Color(0xFFFF4D8D); static const vipGoldStart = Color(0xFFF5D9A0); static const vipGoldEnd = Color(0xFFE8B65A); static const vipBadgeSurface = Color(0x1F3D2408); diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 0cf7be7..729e973 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -124,8 +124,10 @@ abstract final class AppDimens { static const double homeSearchPrimaryGap = 8; static const double homePrimarySecondaryGap = 8; static const double homeSecondaryGridGap = 10; - static const double homeIconSlot = 44; static const double homeHeaderIcon = 22; + static const double homeActionSlot = 40; + static const double homeActionIcon = 24; + static const double homeActionGap = 12; /// 推荐分区。 static const double recommendSectionTitle = 17; diff --git a/lib/pages/home/widgets/home_grid_header.dart b/lib/pages/home/widgets/home_grid_header.dart index e7ca8e0..929862c 100644 --- a/lib/pages/home/widgets/home_grid_header.dart +++ b/lib/pages/home/widgets/home_grid_header.dart @@ -143,13 +143,15 @@ class HomeGridHeader extends StatelessWidget { _headerAction( key: const Key('home-vip-entry'), icon: Icons.workspace_premium_outlined, + color: AppColors.homeCrownGold, tooltip: l10n.vipMembership, onTap: callbacks.onOpenVip, ), - const SizedBox(width: AppDimens.spaceXs), + const SizedBox(width: AppDimens.homeActionGap), _headerAction( key: const Key('home-wallet-entry'), icon: Icons.card_giftcard_rounded, + color: AppColors.homeGiftPink, tooltip: l10n.wallet, onTap: callbacks.onOpenWallet, badge: walletBadge, @@ -162,12 +164,13 @@ class HomeGridHeader extends StatelessWidget { Widget _headerAction({ required Key key, required IconData icon, + required Color color, required String tooltip, required VoidCallback onTap, int? badge, }) { return SizedBox.square( - dimension: AppDimens.homeIconSlot, + dimension: AppDimens.homeActionSlot, child: Stack( clipBehavior: Clip.none, children: [ @@ -175,16 +178,14 @@ class HomeGridHeader extends StatelessWidget { child: Tooltip( message: tooltip, child: Material( - color: AppColors.contentSurface, - borderRadius: BorderRadius.circular(AppDimens.inputRadius), + color: AppColors.transparent, child: InkWell( key: key, - borderRadius: BorderRadius.circular(AppDimens.inputRadius), onTap: onTap, child: Icon( icon, - size: AppDimens.homeHeaderIcon, - color: AppColors.navSelected, + size: AppDimens.homeActionIcon, + color: color, ), ), ), diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart index 53e7ce4..dd783fd 100644 --- a/test/home/home_nav_ui_test.dart +++ b/test/home/home_nav_ui_test.dart @@ -9,6 +9,7 @@ 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/theme/app_colors.dart'; +import 'package:fruitshorts/core/theme/app_dimens.dart'; import 'package:fruitshorts/core/widgets/app_bottom_nav.dart'; import 'package:fruitshorts/pages/following/following_page.dart'; import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; @@ -50,6 +51,57 @@ void main() { } expect(find.byKey(const Key('home-wallet-entry')), findsOneWidget); expect(find.byKey(const Key('home-wallet-badge')), findsOneWidget); + final searchEntry = find.byKey(const Key('home-search-entry')); + final vipEntry = find.byKey(const Key('home-vip-entry')); + final walletEntry = find.byKey(const Key('home-wallet-entry')); + expect( + tester.getSize(vipEntry), + const Size.square(AppDimens.homeActionSlot), + ); + expect( + tester.getSize(walletEntry), + const Size.square(AppDimens.homeActionSlot), + ); + expect( + tester.getCenter(vipEntry).dy, + closeTo(tester.getCenter(searchEntry).dy, 0.1), + ); + expect( + tester.getCenter(walletEntry).dy, + closeTo(tester.getCenter(searchEntry).dy, 0.1), + ); + expect( + tester.getTopLeft(walletEntry).dx - tester.getTopRight(vipEntry).dx, + AppDimens.homeActionGap, + ); + expect( + tester.getTopRight(walletEntry).dx, + closeTo(390 - AppDimens.feedPageHorizontal, 0.1), + ); + final crown = tester.widget( + find.descendant( + of: vipEntry, + matching: find.byIcon(Icons.workspace_premium_outlined), + ), + ); + final gift = tester.widget( + find.descendant( + of: walletEntry, + matching: find.byIcon(Icons.card_giftcard_rounded), + ), + ); + expect(crown.size, AppDimens.homeActionIcon); + expect(crown.color, AppColors.homeCrownGold); + expect(gift.size, AppDimens.homeActionIcon); + expect(gift.color, AppColors.homeGiftPink); + expect( + tester + .widget( + find.ancestor(of: vipEntry, matching: find.byType(Material)).first, + ) + .color, + AppColors.transparent, + ); expect(find.byKey(const Key('mine-mini-player')), findsNothing); expect(find.byType(FloatingActionButton), findsNothing); for (final key in _keys.skip(1)) { From 8feede17634e87e42705833a2e697786a5339076 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 18:48:20 +0800 Subject: [PATCH 051/129] =?UTF-8?q?fix(home):=20=E6=94=BE=E5=A4=A7?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E6=A0=8F=E5=8F=B3=E4=BE=A7=E5=9B=BE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 2 +- lib/core/theme/app_dimens.dart | 2 +- lib/pages/home/widgets/home_grid_header.dart | 21 ++++++++++++++------ test/home/home_nav_ui_test.dart | 12 +++++++++++ 4 files changed, 29 insertions(+), 8 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 23a7ef2..c29f445 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1115,7 +1115,7 @@ $ flutter test - 四类卡片去底板/描边,封面统一 3:4、8 圆角、行距 10、封底标题间距 6;状态角标为右上角紧凑异形丝带。 - 标题下方恢复左对齐题材文字,使用 `|` 分隔、灰色小字,与封面状态角标保持独立。 - 首页搜索栏到一级、一级到二级、二级到网格间距为 8/8/10;非精选 tab 直接以 10 衔接网格。 -- 搜索框右侧皇冠与礼物改为 40 热区、24 彩色图标,透明背景、间距 12,保留礼物角标。 +- 搜索框右侧皇冠与礼物改为 40 热区、28 彩色图标,透明背景、间距 12,保留礼物角标。 - 播放量贴封面右下角(底 2、右 6),火焰图标 11 + 12/w400 纯文字,间距 4,并保留极淡底部渐变。 - 修复二级 chip 运行时颜色误用 `const TextStyle` 的编译错误,并校正相关测试 Finder 类型。 - 全部短剧的地区、字幕、配音筛选 UI 已占位;以下接口待补:地区、字幕/配音筛选数据。 diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 729e973..c7cfd1d 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -126,7 +126,7 @@ abstract final class AppDimens { static const double homeSecondaryGridGap = 10; static const double homeHeaderIcon = 22; static const double homeActionSlot = 40; - static const double homeActionIcon = 24; + static const double homeActionIcon = 28; static const double homeActionGap = 12; /// 推荐分区。 diff --git a/lib/pages/home/widgets/home_grid_header.dart b/lib/pages/home/widgets/home_grid_header.dart index 929862c..5891547 100644 --- a/lib/pages/home/widgets/home_grid_header.dart +++ b/lib/pages/home/widgets/home_grid_header.dart @@ -142,6 +142,7 @@ class HomeGridHeader extends StatelessWidget { const SizedBox(width: AppDimens.spaceSm), _headerAction( key: const Key('home-vip-entry'), + frameKey: const Key('home-vip-icon-frame'), icon: Icons.workspace_premium_outlined, color: AppColors.homeCrownGold, tooltip: l10n.vipMembership, @@ -150,6 +151,7 @@ class HomeGridHeader extends StatelessWidget { const SizedBox(width: AppDimens.homeActionGap), _headerAction( key: const Key('home-wallet-entry'), + frameKey: const Key('home-wallet-icon-frame'), icon: Icons.card_giftcard_rounded, color: AppColors.homeGiftPink, tooltip: l10n.wallet, @@ -163,6 +165,7 @@ class HomeGridHeader extends StatelessWidget { Widget _headerAction({ required Key key, + required Key frameKey, required IconData icon, required Color color, required String tooltip, @@ -182,10 +185,16 @@ class HomeGridHeader extends StatelessWidget { child: InkWell( key: key, onTap: onTap, - child: Icon( - icon, - size: AppDimens.homeActionIcon, - color: color, + child: Center( + child: SizedBox.square( + key: frameKey, + dimension: AppDimens.homeActionIcon, + child: Icon( + icon, + size: AppDimens.homeActionIcon, + color: color, + ), + ), ), ), ), @@ -194,8 +203,8 @@ class HomeGridHeader extends StatelessWidget { if (badge != null && badge > 0) Positioned( key: const Key('home-wallet-badge'), - top: -5, - right: -6, + top: 0, + right: 0, child: Container( constraints: const BoxConstraints(minWidth: 20, minHeight: 16), padding: const EdgeInsets.symmetric(horizontal: 5), diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart index dd783fd..94de1fc 100644 --- a/test/home/home_nav_ui_test.dart +++ b/test/home/home_nav_ui_test.dart @@ -62,6 +62,18 @@ void main() { tester.getSize(walletEntry), const Size.square(AppDimens.homeActionSlot), ); + expect( + tester.getSize(find.byKey(const Key('home-vip-icon-frame'))), + const Size.square(AppDimens.homeActionIcon), + ); + expect( + tester.getSize(find.byKey(const Key('home-wallet-icon-frame'))), + const Size.square(AppDimens.homeActionIcon), + ); + expect( + tester.getCenter(find.byKey(const Key('home-vip-icon-frame'))).dy, + tester.getCenter(find.byKey(const Key('home-wallet-icon-frame'))).dy, + ); expect( tester.getCenter(vipEntry).dy, closeTo(tester.getCenter(searchEntry).dy, 0.1), From 953ddec58746e7d4030afb128655fb5db198cd5a Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 18:52:43 +0800 Subject: [PATCH 052/129] =?UTF-8?q?fix(home):=20=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E6=B8=B2=E6=9F=93=E6=90=9C=E7=B4=A2=E6=A0=8F?= =?UTF-8?q?=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 2 +- lib/pages/home/widgets/home_grid_header.dart | 27 +++++++++++++++----- pubspec.yaml | 1 + test/home/home_nav_ui_test.dart | 8 ++++++ 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index c29f445..420ef2b 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1115,7 +1115,7 @@ $ flutter test - 四类卡片去底板/描边,封面统一 3:4、8 圆角、行距 10、封底标题间距 6;状态角标为右上角紧凑异形丝带。 - 标题下方恢复左对齐题材文字,使用 `|` 分隔、灰色小字,与封面状态角标保持独立。 - 首页搜索栏到一级、一级到二级、二级到网格间距为 8/8/10;非精选 tab 直接以 10 衔接网格。 -- 搜索框右侧皇冠与礼物改为 40 热区、28 彩色图标,透明背景、间距 12,保留礼物角标。 +- 搜索框右侧皇冠与礼物使用 28 图片图标,缺图时回退原彩色 Icon;透明热区、间距和角标不变。 - 播放量贴封面右下角(底 2、右 6),火焰图标 11 + 12/w400 纯文字,间距 4,并保留极淡底部渐变。 - 修复二级 chip 运行时颜色误用 `const TextStyle` 的编译错误,并校正相关测试 Finder 类型。 - 全部短剧的地区、字幕、配音筛选 UI 已占位;以下接口待补:地区、字幕/配音筛选数据。 diff --git a/lib/pages/home/widgets/home_grid_header.dart b/lib/pages/home/widgets/home_grid_header.dart index 5891547..04931ed 100644 --- a/lib/pages/home/widgets/home_grid_header.dart +++ b/lib/pages/home/widgets/home_grid_header.dart @@ -143,7 +143,9 @@ class HomeGridHeader extends StatelessWidget { _headerAction( key: const Key('home-vip-entry'), frameKey: const Key('home-vip-icon-frame'), - icon: Icons.workspace_premium_outlined, + imageKey: const Key('home-vip-image'), + assetPath: 'assets/icon/crown.png', + fallbackIcon: Icons.workspace_premium_outlined, color: AppColors.homeCrownGold, tooltip: l10n.vipMembership, onTap: callbacks.onOpenVip, @@ -152,7 +154,9 @@ class HomeGridHeader extends StatelessWidget { _headerAction( key: const Key('home-wallet-entry'), frameKey: const Key('home-wallet-icon-frame'), - icon: Icons.card_giftcard_rounded, + imageKey: const Key('home-wallet-image'), + assetPath: 'assets/icon/gift.png', + fallbackIcon: Icons.card_giftcard_rounded, color: AppColors.homeGiftPink, tooltip: l10n.wallet, onTap: callbacks.onOpenWallet, @@ -166,7 +170,9 @@ class HomeGridHeader extends StatelessWidget { Widget _headerAction({ required Key key, required Key frameKey, - required IconData icon, + required Key imageKey, + required String assetPath, + required IconData fallbackIcon, required Color color, required String tooltip, required VoidCallback onTap, @@ -189,10 +195,17 @@ class HomeGridHeader extends StatelessWidget { child: SizedBox.square( key: frameKey, dimension: AppDimens.homeActionIcon, - child: Icon( - icon, - size: AppDimens.homeActionIcon, - color: color, + child: Image.asset( + assetPath, + key: imageKey, + width: AppDimens.homeActionIcon, + height: AppDimens.homeActionIcon, + fit: BoxFit.contain, + errorBuilder: (_, _, _) => Icon( + fallbackIcon, + size: AppDimens.homeActionIcon, + color: color, + ), ), ), ), diff --git a/pubspec.yaml b/pubspec.yaml index 5951cc5..2133e43 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -81,6 +81,7 @@ flutter: # the material Icons class. uses-material-design: true assets: + - assets/icon/ - assets/icon/splash_logo.png - assets/icon/splash_logo_android12.png - assets/icon/rail/ diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart index 94de1fc..b1d78ad 100644 --- a/test/home/home_nav_ui_test.dart +++ b/test/home/home_nav_ui_test.dart @@ -74,6 +74,14 @@ void main() { tester.getCenter(find.byKey(const Key('home-vip-icon-frame'))).dy, tester.getCenter(find.byKey(const Key('home-wallet-icon-frame'))).dy, ); + final crownImage = tester.widget( + find.byKey(const Key('home-vip-image')), + ); + final giftImage = tester.widget( + find.byKey(const Key('home-wallet-image')), + ); + expect((crownImage.image as AssetImage).assetName, 'assets/icon/crown.png'); + expect((giftImage.image as AssetImage).assetName, 'assets/icon/gift.png'); expect( tester.getCenter(vipEntry).dy, closeTo(tester.getCenter(searchEntry).dy, 0.1), From 714e5caec3d414b1a9b2b2df4a2f5e8adc84080c Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 18:58:03 +0800 Subject: [PATCH 053/129] =?UTF-8?q?assets(home):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E6=A0=8F=E7=9A=87=E5=86=A0=E4=B8=8E=E7=A4=BC?= =?UTF-8?q?=E7=89=A9=E5=9B=BE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/icon/crown.png | Bin 0 -> 22782 bytes assets/icon/gift.png | Bin 0 -> 24538 bytes docs/plans/PROGRESS.md | 1 + 3 files changed, 1 insertion(+) create mode 100644 assets/icon/crown.png create mode 100644 assets/icon/gift.png diff --git a/assets/icon/crown.png b/assets/icon/crown.png new file mode 100644 index 0000000000000000000000000000000000000000..53d07d49e6e9b99f649ff32c4320038ac736828f GIT binary patch literal 22782 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 JPDHLkV1m;kGv)vQ literal 0 HcmV?d00001 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 420ef2b..9f2e796 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1116,6 +1116,7 @@ $ flutter test - 标题下方恢复左对齐题材文字,使用 `|` 分隔、灰色小字,与封面状态角标保持独立。 - 首页搜索栏到一级、一级到二级、二级到网格间距为 8/8/10;非精选 tab 直接以 10 衔接网格。 - 搜索框右侧皇冠与礼物使用 28 图片图标,缺图时回退原彩色 Icon;透明热区、间距和角标不变。 +- 皇冠/礼物 144×144 透明 PNG 已归档到 `assets/icon/`,组件路径与资源注册一致。 - 播放量贴封面右下角(底 2、右 6),火焰图标 11 + 12/w400 纯文字,间距 4,并保留极淡底部渐变。 - 修复二级 chip 运行时颜色误用 `const TextStyle` 的编译错误,并校正相关测试 Finder 类型。 - 全部短剧的地区、字幕、配音筛选 UI 已占位;以下接口待补:地区、字幕/配音筛选数据。 From 5a4e910554140402201304456c526229cac45586 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 18:59:11 +0800 Subject: [PATCH 054/129] =?UTF-8?q?fix(home):=20=E6=94=B6=E7=B4=A7?= =?UTF-8?q?=E4=BA=8C=E7=BA=A7=E6=A0=87=E7=AD=BE=E9=97=B4=E8=B7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 2 +- lib/core/theme/app_dimens.dart | 4 ++-- test/home/home_preview_layout_test.dart | 6 ++++++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 9f2e796..c3cd377 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1111,7 +1111,7 @@ $ flutter test ## 2026-10-22 首页布局对齐修正 -- 一级分类未选 18/w400、选中 20/w700;二级 chips 间距 24、15/w600,并以稳定 AnimatedContainer 切换。 +- 一级分类未选 18/w400、选中 20/w700;二级 chips 间距 10、左右 8、15/w600,并以稳定 AnimatedContainer 切换。 - 四类卡片去底板/描边,封面统一 3:4、8 圆角、行距 10、封底标题间距 6;状态角标为右上角紧凑异形丝带。 - 标题下方恢复左对齐题材文字,使用 `|` 分隔、灰色小字,与封面状态角标保持独立。 - 首页搜索栏到一级、一级到二级、二级到网格间距为 8/8/10;非精选 tab 直接以 10 衔接网格。 diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index c7cfd1d..c7ce9c2 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -109,7 +109,7 @@ abstract final class AppDimens { ); static const double homeSecondaryTagFont = 15; static const double homeSecondaryTagHeight = 26; - static const double homeSecondaryTagHorizontalPadding = 10; + static const double homeSecondaryTagHorizontalPadding = 8; static const double homeSecondaryTagRadius = 13; static const Duration homeSecondaryTagAnimationDuration = Duration( milliseconds: 150, @@ -119,7 +119,7 @@ abstract final class AppDimens { static const double homeSecondaryFilterIcon = 16; static const double homeSecondaryFilterStroke = 1.7; static const double homePrimaryCategoryGap = 24; - static const double homeSecondaryTagGap = 24; + static const double homeSecondaryTagGap = 10; static const double homeSearchTopGap = 0; static const double homeSearchPrimaryGap = 8; static const double homePrimarySecondaryGap = 8; diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index f68d3b0..7b5d1cd 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -176,6 +176,12 @@ void main() { (unselectedTagChip.decoration! as BoxDecoration).color, AppColors.transparent, ); + expect( + unselectedTagChip.padding, + const EdgeInsets.symmetric( + horizontal: AppDimens.homeSecondaryTagHorizontalPadding, + ), + ); final primaryStart = tester.getTopLeft(find.text('Featured')); final pageStart = tester.getTopLeft(searchBox); expect(primaryStart.dx, pageStart.dx); From 403cce0e3b5567b17f6e18babb04c48f977049b8 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 18:59:58 +0800 Subject: [PATCH 055/129] =?UTF-8?q?assets(home):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E6=A0=8F=E5=9B=BE=E6=A0=87=E8=A7=86=E8=A7=89?= =?UTF-8?q?=E6=AF=94=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/icon/crown.png | Bin 22782 -> 19987 bytes assets/icon/gift.png | Bin 24538 -> 18834 bytes docs/plans/PROGRESS.md | 1 + 3 files changed, 1 insertion(+) diff --git a/assets/icon/crown.png b/assets/icon/crown.png index 53d07d49e6e9b99f649ff32c4320038ac736828f..831cc7e90e413d0b608acab33a455aad40b54f8d 100644 GIT binary patch literal 19987 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 JPDHLkV1m;kGv)vQ diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index c3cd377..9f49402 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1117,6 +1117,7 @@ $ flutter test - 首页搜索栏到一级、一级到二级、二级到网格间距为 8/8/10;非精选 tab 直接以 10 衔接网格。 - 搜索框右侧皇冠与礼物使用 28 图片图标,缺图时回退原彩色 Icon;透明热区、间距和角标不变。 - 皇冠/礼物 144×144 透明 PNG 已归档到 `assets/icon/`,组件路径与资源注册一致。 +- 两张图标已按统一 104/144 内容高重新归一化,28×28 渲染时视觉高度一致。 - 播放量贴封面右下角(底 2、右 6),火焰图标 11 + 12/w400 纯文字,间距 4,并保留极淡底部渐变。 - 修复二级 chip 运行时颜色误用 `const TextStyle` 的编译错误,并校正相关测试 Finder 类型。 - 全部短剧的地区、字幕、配音筛选 UI 已占位;以下接口待补:地区、字幕/配音筛选数据。 From 4fa9d36b459f2ecd7a704204eb30416f94c27f19 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 19:03:42 +0800 Subject: [PATCH 056/129] =?UTF-8?q?assets(home):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E6=A0=8F=E5=9B=BE=E6=A0=87=E6=AF=94=E4=BE=8B?= =?UTF-8?q?=E4=B8=8E=E9=97=B4=E8=B7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/icon/crown.png | Bin 19987 -> 15104 bytes assets/icon/gift.png | Bin 18834 -> 7941 bytes docs/plans/PROGRESS.md | 1 + lib/core/theme/app_dimens.dart | 4 ++-- 4 files changed, 3 insertions(+), 2 deletions(-) diff --git a/assets/icon/crown.png b/assets/icon/crown.png index 831cc7e90e413d0b608acab33a455aad40b54f8d..e2a823b25f5091ab7fc670b2e592ca3be23e8825 100644 GIT binary patch literal 15104 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 spcgR{n(}x5dt>;2T;$+D*yXWDzG*4lfY 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 Date: Thu, 22 Oct 2026 19:05:55 +0800 Subject: [PATCH 057/129] =?UTF-8?q?feat(home):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=BB=91=E5=8A=A8=E5=88=87=E6=8D=A2=E9=A6=96=E9=A1=B5=E5=88=86?= =?UTF-8?q?=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 1 + lib/pages/home/widgets/home_feed_grid.dart | 66 +++++++++++++++++++--- test/home/home_grid_layout_test.dart | 27 +++++++++ 3 files changed, 86 insertions(+), 8 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 664f32b..814e5e0 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1112,6 +1112,7 @@ $ flutter test ## 2026-10-22 首页布局对齐修正 - 一级分类未选 18/w400、选中 20/w700;二级 chips 间距 10、左右 8、15/w600,并以稳定 AnimatedContainer 切换。 +- 首页内容区改为横向 PageView:点击与滑动共用 PageController,各 tab 保留纵向滚动位置。 - 四类卡片去底板/描边,封面统一 3:4、8 圆角、行距 10、封底标题间距 6;状态角标为右上角紧凑异形丝带。 - 标题下方恢复左对齐题材文字,使用 `|` 分隔、灰色小字,与封面状态角标保持独立。 - 首页搜索栏到一级、一级到二级、二级到网格间距为 8/8/10;非精选 tab 直接以 10 衔接网格。 diff --git a/lib/pages/home/widgets/home_feed_grid.dart b/lib/pages/home/widgets/home_feed_grid.dart index 23b9b9f..caa1574 100644 --- a/lib/pages/home/widgets/home_feed_grid.dart +++ b/lib/pages/home/widgets/home_feed_grid.dart @@ -43,6 +43,7 @@ class HomeFeedGrid extends StatefulWidget { class _HomeFeedGridState extends State { HomePrimaryTab _selectedPrimary = HomePrimaryTab.featured; + late final PageController _pageController; late final Map _scrollControllers; final Map _selectedTags = { for (final tab in HomePrimaryTab.values) tab: null, @@ -51,6 +52,7 @@ class _HomeFeedGridState extends State { @override void initState() { super.initState(); + _pageController = PageController(initialPage: _selectedPrimary.index); _scrollControllers = { for (final tab in HomePrimaryTab.values) tab: ScrollController(), }; @@ -58,6 +60,7 @@ class _HomeFeedGridState extends State { @override void dispose() { + _pageController.dispose(); for (final controller in _scrollControllers.values) { controller.dispose(); } @@ -93,19 +96,30 @@ class _HomeFeedGridState extends State { tags: tags, selectedPrimary: _selectedPrimary, selectedTag: activeTag, - onPrimarySelected: (tab) => - setState(() => _selectedPrimary = tab), + onPrimarySelected: _selectPrimary, onTagSelected: (tag) => setState(() => _selectedTags[_selectedPrimary] = tag), walletBadge: walletBadge, ), Expanded( - child: IndexedStack( - index: _selectedPrimary.index, - children: [ - for (final tab in HomePrimaryTab.values) - _tabGrid(context, l10n, tab: tab, shortcuts: shortcuts), - ], + child: PageView.builder( + key: const Key('home-primary-page-view'), + controller: _pageController, + itemCount: HomePrimaryTab.values.length, + onPageChanged: (index) => setState( + () => _selectedPrimary = HomePrimaryTab.values[index], + ), + itemBuilder: (context, index) => _KeepAlivePage( + key: PageStorageKey( + 'home-primary-page-${HomePrimaryTab.values[index].name}', + ), + child: _tabGrid( + context, + l10n, + tab: HomePrimaryTab.values[index], + shortcuts: shortcuts, + ), + ), ), ), ], @@ -114,6 +128,21 @@ class _HomeFeedGridState extends State { ); } + void _selectPrimary(HomePrimaryTab tab) { + if (tab == _selectedPrimary) return; + if (!_pageController.hasClients) { + setState(() => _selectedPrimary = tab); + return; + } + unawaited( + _pageController.animateToPage( + tab.index, + duration: AppDimens.homePrimaryTabAnimationDuration, + curve: Curves.easeOut, + ), + ); + } + Widget _tabGrid( BuildContext context, AppLocalizations l10n, { @@ -329,3 +358,24 @@ class _HomeFeedGridState extends State { ); } } + +class _KeepAlivePage extends StatefulWidget { + const _KeepAlivePage({super.key, required this.child}); + + final Widget child; + + @override + State<_KeepAlivePage> createState() => _KeepAlivePageState(); +} + +class _KeepAlivePageState extends State<_KeepAlivePage> + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + return widget.child; + } +} diff --git a/test/home/home_grid_layout_test.dart b/test/home/home_grid_layout_test.dart index 38cb16b..56d64d5 100644 --- a/test/home/home_grid_layout_test.dart +++ b/test/home/home_grid_layout_test.dart @@ -11,6 +11,7 @@ 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/theme/app_dimens.dart'; import 'package:fruitshorts/pages/home/widgets/home_feed_card.dart'; import 'package:fruitshorts/pages/player/player_view.dart'; import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; @@ -170,6 +171,32 @@ void main() { ); }); + testWidgets('should_swipe_between_primary_sections', (tester) async { + final repository = _PagedCatalogRepository(); + await _pumpApp(tester, repository); + + expect(find.byKey(const Key('home-secondary-tags')), findsOneWidget); + await tester.drag( + find.byKey(const Key('home-primary-page-view')), + const Offset(-320, 0), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('home-secondary-tags')), findsNothing); + final selected = tester.widget( + find.byKey(const Key('home-primary-label-newReleases')), + ); + expect(selected.style.fontSize, AppDimens.homePrimaryTabSelectedFont); + expect(selected.style.fontWeight, FontWeight.w700); + + await tester.drag( + find.byKey(const Key('home-primary-page-view')), + const Offset(320, 0), + ); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('home-secondary-tags')), findsOneWidget); + }); + testWidgets('should_load_next_page_without_expanding_page_size', ( tester, ) async { From d6f1cf2a2c28ab3da86e96aa335347e6108fdb35 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 19:07:32 +0800 Subject: [PATCH 058/129] =?UTF-8?q?fix(home):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=88=86=E7=B1=BB=E5=88=86=E9=A1=B5=E6=8E=A7=E5=88=B6=E5=99=A8?= =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 1 + lib/pages/home/widgets/home_feed_grid.dart | 15 +++++++++------ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 814e5e0..d5589ab 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1113,6 +1113,7 @@ $ flutter test - 一级分类未选 18/w400、选中 20/w700;二级 chips 间距 10、左右 8、15/w600,并以稳定 AnimatedContainer 切换。 - 首页内容区改为横向 PageView:点击与滑动共用 PageController,各 tab 保留纵向滚动位置。 +- PageController 与各 tab ScrollController 改为字段直接初始化,修复热重载后的 LateInitializationError。 - 四类卡片去底板/描边,封面统一 3:4、8 圆角、行距 10、封底标题间距 6;状态角标为右上角紧凑异形丝带。 - 标题下方恢复左对齐题材文字,使用 `|` 分隔、灰色小字,与封面状态角标保持独立。 - 首页搜索栏到一级、一级到二级、二级到网格间距为 8/8/10;非精选 tab 直接以 10 衔接网格。 diff --git a/lib/pages/home/widgets/home_feed_grid.dart b/lib/pages/home/widgets/home_feed_grid.dart index caa1574..70ec7d5 100644 --- a/lib/pages/home/widgets/home_feed_grid.dart +++ b/lib/pages/home/widgets/home_feed_grid.dart @@ -43,8 +43,10 @@ class HomeFeedGrid extends StatefulWidget { class _HomeFeedGridState extends State { HomePrimaryTab _selectedPrimary = HomePrimaryTab.featured; - late final PageController _pageController; - late final Map _scrollControllers; + final PageController _pageController = PageController(); + final Map _scrollControllers = { + for (final tab in HomePrimaryTab.values) tab: ScrollController(), + }; final Map _selectedTags = { for (final tab in HomePrimaryTab.values) tab: null, }; @@ -52,10 +54,11 @@ class _HomeFeedGridState extends State { @override void initState() { super.initState(); - _pageController = PageController(initialPage: _selectedPrimary.index); - _scrollControllers = { - for (final tab in HomePrimaryTab.values) tab: ScrollController(), - }; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _pageController.hasClients) { + _pageController.jumpToPage(_selectedPrimary.index); + } + }); } @override From 7945f3ffedd380c249d61368bd8ea6c050a01f74 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 19:11:30 +0800 Subject: [PATCH 059/129] =?UTF-8?q?fix(home):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E7=9F=AD=E5=89=A7=E5=88=97=E8=A1=A8=E4=B8=8B=E6=8B=89=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 7 + lib/core/widgets/app_pull_to_refresh.dart | 36 ++-- lib/pages/home/home_view.dart | 6 - lib/pages/home/widgets/home_feed_grid.dart | 159 +++++++++--------- lib/pages/recommend/recommend_page.dart | 7 +- .../widgets/all_dramas_filter_grid.dart | 111 ++++++------ .../widgets/recommend_sections_view.dart | 114 +++++++------ test/core/app_pull_to_refresh_test.dart | 8 +- 8 files changed, 247 insertions(+), 201 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index d5589ab..cd8c8a2 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1124,3 +1124,10 @@ $ flutter test - 播放量贴封面右下角(底 2、右 6),火焰图标 11 + 12/w400 纯文字,间距 4,并保留极淡底部渐变。 - 修复二级 chip 运行时颜色误用 `const TextStyle` 的编译错误,并校正相关测试 Finder 类型。 - 全部短剧的地区、字幕、配音筛选 UI 已占位;以下接口待补:地区、字幕/配音筛选数据。 + +## 2026-10-22 短剧列表刷新体验优化 + +- 公共下拉刷新统一为白指示器、40 displacement、Bouncing + AlwaysScrollable,并在下拉期间屏蔽内容点击与滚动条。 +- 首页移除父级 feed 监听,网格、剧单和全部短剧改为局部监听,刷新时保留旧数据及原滚动位置。 +- 剧单、全部短剧、追剧沿用相同刷新策略;首帧加载与刷新指示器分离。 +- 按当前流程未执行 analyze/test。 diff --git a/lib/core/widgets/app_pull_to_refresh.dart b/lib/core/widgets/app_pull_to_refresh.dart index 1961981..73f807b 100644 --- a/lib/core/widgets/app_pull_to_refresh.dart +++ b/lib/core/widgets/app_pull_to_refresh.dart @@ -10,7 +10,7 @@ import '../theme/app_colors.dart'; const double _maxPullDistance = 96; const double _refreshThreshold = 56; const double _indicatorHeight = 56; -const double _indicatorMaxOffset = 10; +const double _indicatorDisplacement = 40; const Duration _arrowTurnDuration = Duration(milliseconds: 160); const Duration _settleDuration = Duration(milliseconds: 200); @@ -26,7 +26,7 @@ class AppPullToRefresh extends StatefulWidget { }); static const AlwaysScrollableScrollPhysics physics = - AlwaysScrollableScrollPhysics(parent: ClampingScrollPhysics()); + AlwaysScrollableScrollPhysics(parent: BouncingScrollPhysics()); final Widget child; final Future Function() onRefresh; @@ -146,10 +146,13 @@ class _AppPullToRefreshState extends State onNotification: _onScroll, child: AnimatedBuilder( animation: _offsetController, - child: Listener( - onPointerUp: (_) => _release(), - onPointerCancel: (_) => _release(), - child: widget.child, + child: ScrollConfiguration( + behavior: const _NoScrollbarScrollBehavior(), + child: Listener( + onPointerUp: (_) => _release(), + onPointerCancel: (_) => _release(), + child: widget.child, + ), ), builder: (context, child) { final offset = _offsetController.value; @@ -160,7 +163,7 @@ class _AppPullToRefreshState extends State key: const Key('pull-refresh-content'), offset: Offset(0, offset), child: IgnorePointer( - ignoring: _refreshing || _retracting, + ignoring: offset > 0 || _refreshing || _retracting, child: child, ), ), @@ -185,7 +188,7 @@ class _AppPullToRefreshState extends State child: Opacity( opacity: progress, child: Transform.translate( - offset: Offset(0, _indicatorMaxOffset * progress), + offset: Offset(0, _indicatorDisplacement * progress), child: Align( alignment: Alignment.topCenter, child: Padding( @@ -206,7 +209,7 @@ class _AppPullToRefreshState extends State dimension: 18, child: CircularProgressIndicator( strokeWidth: 2, - color: AppColors.blue, + color: AppColors.navSelected, ), ), l10n.refreshingLabel, @@ -220,7 +223,7 @@ class _AppPullToRefreshState extends State child: Icon( Icons.arrow_downward_rounded, size: 18, - color: AppColors.blue, + color: AppColors.navSelected, ), ), armed ? l10n.releaseToRefresh : l10n.pullToRefresh, @@ -236,7 +239,7 @@ class _AppPullToRefreshState extends State Text( text, style: const TextStyle( - color: AppColors.blue, + color: AppColors.navSelected, fontSize: 13, fontWeight: FontWeight.w600, ), @@ -245,3 +248,14 @@ class _AppPullToRefreshState extends State ); } } + +class _NoScrollbarScrollBehavior extends MaterialScrollBehavior { + const _NoScrollbarScrollBehavior(); + + @override + Widget buildScrollbar( + BuildContext context, + Widget child, + ScrollableDetails details, + ) => child; +} diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 2630cf2..d8e309e 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -210,20 +210,14 @@ class _HomeTabsState extends State<_HomeTabs> { void initState() { super.initState(); logic = HomeBinding().create(widget.feedController.dramas); - 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( diff --git a/lib/pages/home/widgets/home_feed_grid.dart b/lib/pages/home/widgets/home_feed_grid.dart index 70ec7d5..50d3f60 100644 --- a/lib/pages/home/widgets/home_feed_grid.dart +++ b/lib/pages/home/widgets/home_feed_grid.dart @@ -152,83 +152,90 @@ class _HomeFeedGridState extends State { required HomePrimaryTab tab, required List shortcuts, }) { - final tag = _selectedTags[tab]; - final dramas = _dramasFor(tab, tag, shortcuts); - return LayoutBuilder( - builder: (context, constraints) { - final itemWidth = - (constraints.maxWidth - - AppDimens.feedPageHorizontal * 2 - - AppDimens.homeGridGap * 2) / - 3; - final itemExtent = - itemWidth / AppDimens.cardCoverAspectRatio + - AppDimens.homeCardBodyExtent; - return NotificationListener( - onNotification: _onScroll, - child: AppPullToRefresh( - key: const Key('home-grid-refresh'), - onRefresh: _refresh, - child: CustomScrollView( - key: const Key('home-grid-scroll'), - controller: _scrollControllers[tab], - physics: AppPullToRefresh.physics, - slivers: [ - if (widget.feedController.loadingFirstPage) - const SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: SizedBox.square( - dimension: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ), - ) - else if (dramas.isEmpty) - SliverFillRemaining( - hasScrollBody: false, - child: _emptyState(l10n, tab, tag), - ) - else - SliverPadding( - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.feedPageHorizontal, - ), - sliver: SliverGrid( - key: const Key('home-grid-cards'), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: AppDimens.homeGridGap, - mainAxisSpacing: AppDimens.homeGridRowGap, - mainAxisExtent: itemExtent, - ), - delegate: SliverChildBuilderDelegate( - (context, index) => HomeFeedCard( - drama: dramas[index], - onTap: () => widget.onOpenDrama(dramas[index]), - ), - childCount: dramas.length, - ), - ), - ), - if (dramas.isNotEmpty) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), - child: PagedListFooter( - loading: widget.feedController.loadingMore, - hasMore: widget.feedController.hasMore, - hasItems: dramas.isNotEmpty, - errorMessage: widget.feedController.lastError == null - ? null - : l10n.catalogError, - onRetry: widget.feedController.loadMore, - ), - ), - ), - ], + return ListenableBuilder( + listenable: widget.feedController, + builder: (context, _) { + if (widget.feedController.loadingFirstPage) { + return const Center( + child: SizedBox.square( + dimension: 24, + child: CircularProgressIndicator(strokeWidth: 2), ), - ), + ); + } + final tag = _selectedTags[tab]; + final dramas = _dramasFor(tab, tag, shortcuts); + return LayoutBuilder( + builder: (context, constraints) { + final itemWidth = + (constraints.maxWidth - + AppDimens.feedPageHorizontal * 2 - + AppDimens.homeGridGap * 2) / + 3; + final itemExtent = + itemWidth / AppDimens.cardCoverAspectRatio + + AppDimens.homeCardBodyExtent; + return NotificationListener( + onNotification: _onScroll, + child: AppPullToRefresh( + key: const Key('home-grid-refresh'), + onRefresh: _refresh, + child: CustomScrollView( + key: const Key('home-grid-scroll'), + controller: _scrollControllers[tab], + physics: AppPullToRefresh.physics, + slivers: [ + if (dramas.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: _emptyState(l10n, tab, tag), + ) + else + SliverPadding( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.feedPageHorizontal, + ), + sliver: SliverGrid( + key: const Key('home-grid-cards'), + gridDelegate: + SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: AppDimens.homeGridGap, + mainAxisSpacing: AppDimens.homeGridRowGap, + mainAxisExtent: itemExtent, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => HomeFeedCard( + drama: dramas[index], + onTap: () => widget.onOpenDrama(dramas[index]), + ), + childCount: dramas.length, + ), + ), + ), + if (dramas.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only( + bottom: AppDimens.spaceXl, + ), + child: PagedListFooter( + loading: widget.feedController.loadingMore, + hasMore: widget.feedController.hasMore, + hasItems: dramas.isNotEmpty, + errorMessage: + widget.feedController.lastError == null + ? null + : l10n.catalogError, + onRetry: widget.feedController.loadMore, + ), + ), + ), + ], + ), + ), + ); + }, ); }, ); diff --git a/lib/pages/recommend/recommend_page.dart b/lib/pages/recommend/recommend_page.dart index 341ea03..fb723a5 100644 --- a/lib/pages/recommend/recommend_page.dart +++ b/lib/pages/recommend/recommend_page.dart @@ -46,7 +46,7 @@ class _RecommendPageState extends State { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final categories = context.watch(); + final categories = context.read(); return ColoredBox( key: const Key('recommend-page'), color: AppColors.appBackground, @@ -59,7 +59,10 @@ class _RecommendPageState extends State { child: IndexedStack( index: _tab.index, children: [ - _recommendFeed(context), + ListenableBuilder( + listenable: widget.feedController, + builder: (context, _) => _recommendFeed(context), + ), RecommendSectionsView( feedController: widget.feedController, categories: categories, diff --git a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart index 059e47b..10d2d33 100644 --- a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart +++ b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart @@ -44,7 +44,6 @@ class _AllDramasFilterGridState extends State { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final dramas = _filtered(); return LayoutBuilder( builder: (context, constraints) { final itemWidth = @@ -66,55 +65,69 @@ class _AllDramasFilterGridState extends State { key: const Key('all-dramas-scroll'), physics: AppPullToRefresh.physics, slivers: [ - SliverToBoxAdapter(child: _filters(l10n)), - if (dramas.isEmpty) - SliverFillRemaining( - hasScrollBody: false, - child: EmptyState( - key: const Key('all-dramas-empty'), - icon: Icons.movie_filter_outlined, - title: l10n.homeGridFilterEmpty, - actionLabel: l10n.homeGridClearFilter, - onAction: () => setState(() { - _region = 'all'; - _audio = 'all'; - _genre = null; - }), - ), - ) - else - SliverPadding( - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.feedPageHorizontal, - ), - sliver: SliverGrid( - key: const Key('all-dramas-grid'), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: AppDimens.homeGridGap, - mainAxisSpacing: AppDimens.homeGridRowGap, - mainAxisExtent: itemExtent, - ), - delegate: SliverChildBuilderDelegate( - (context, index) => HomeFeedCard( - drama: dramas[index], - onTap: () => widget.onOpenDrama(dramas[index]), - ), - childCount: dramas.length, - ), - ), - ), SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), - child: PagedListFooter( - loading: widget.feedController.loadingMore, - hasMore: widget.feedController.hasMore, - hasItems: dramas.isNotEmpty, - errorMessage: widget.feedController.lastError == null - ? null - : l10n.catalogError, - onRetry: widget.feedController.loadMore, + child: ListenableBuilder( + listenable: widget.feedController, + builder: (context, _) => _filters(l10n), + ), + ), + ListenableBuilder( + listenable: widget.feedController, + builder: (context, _) { + final dramas = _filtered(); + if (dramas.isEmpty) { + return SliverFillRemaining( + hasScrollBody: false, + child: EmptyState( + key: const Key('all-dramas-empty'), + icon: Icons.movie_filter_outlined, + title: l10n.homeGridFilterEmpty, + actionLabel: l10n.homeGridClearFilter, + onAction: () => setState(() { + _region = 'all'; + _audio = 'all'; + _genre = null; + }), + ), + ); + } + return SliverPadding( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.feedPageHorizontal, + ), + sliver: SliverGrid( + key: const Key('all-dramas-grid'), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: AppDimens.homeGridGap, + mainAxisSpacing: AppDimens.homeGridRowGap, + mainAxisExtent: itemExtent, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => HomeFeedCard( + drama: dramas[index], + onTap: () => widget.onOpenDrama(dramas[index]), + ), + childCount: dramas.length, + ), + ), + ); + }, + ), + ListenableBuilder( + listenable: widget.feedController, + builder: (context, _) => SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), + child: PagedListFooter( + loading: widget.feedController.loadingMore, + hasMore: widget.feedController.hasMore, + hasItems: _filtered().isNotEmpty, + errorMessage: widget.feedController.lastError == null + ? null + : l10n.catalogError, + onRetry: widget.feedController.loadMore, + ), ), ), ), diff --git a/lib/pages/recommend/widgets/recommend_sections_view.dart b/lib/pages/recommend/widgets/recommend_sections_view.dart index 139c48d..4560103 100644 --- a/lib/pages/recommend/widgets/recommend_sections_view.dart +++ b/lib/pages/recommend/widgets/recommend_sections_view.dart @@ -33,60 +33,68 @@ class RecommendSectionsView extends StatelessWidget { @override Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - final source = feedController.dramas; - final hits = [...source] - ..sort((a, b) => b.viewCount.compareTo(a.viewCount)); - final newest = [...source] - ..sort((a, b) { - final left = publishedAtForDrama(a); - final right = publishedAtForDrama(b); - if (left == null && right == null) return b.id.compareTo(a.id); - if (left == null) return 1; - if (right == null) return -1; - return right.compareTo(left); - }); - return AppPullToRefresh( - key: const Key('recommend-sections-refresh'), - onRefresh: () async { - await Future.wait([feedController.refreshCurrent(), categories.load()]); + return ListenableBuilder( + listenable: Listenable.merge([feedController, categories]), + builder: (context, _) { + final l10n = AppLocalizations.of(context)!; + final source = feedController.dramas; + final hits = [...source] + ..sort((a, b) => b.viewCount.compareTo(a.viewCount)); + final newest = [...source] + ..sort((a, b) { + final left = publishedAtForDrama(a); + final right = publishedAtForDrama(b); + if (left == null && right == null) return b.id.compareTo(a.id); + if (left == null) return 1; + if (right == null) return -1; + return right.compareTo(left); + }); + return AppPullToRefresh( + key: const Key('recommend-sections-refresh'), + onRefresh: () async { + await Future.wait([ + feedController.refreshCurrent(), + categories.load(), + ]); + }, + child: ListView( + key: const Key('recommend-sections-scroll'), + physics: AppPullToRefresh.physics, + padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), + children: [ + _section( + context, + l10n, + key: 'hits', + title: l10n.weeklyHitsTitle, + subtitle: l10n.weeklyHitsSubtitle, + dramas: hits.take(10).toList(), + onMore: () => onMore(RecommendSectionKind.hits), + ), + const SizedBox(height: AppDimens.spaceXl), + _section( + context, + l10n, + key: 'new', + title: l10n.weeklyNewTitle, + subtitle: l10n.weeklyNewSubtitle, + dramas: newest.take(10).toList(), + onMore: () => onMore(RecommendSectionKind.newReleases), + ), + const SizedBox(height: AppDimens.spaceXl), + _section( + context, + l10n, + key: 'costume', + title: l10n.costumeAwardsTitle, + subtitle: l10n.costumeAwardsSubtitle, + dramas: _costumeDramas(source), + onMore: () => onMore(RecommendSectionKind.costume), + ), + ], + ), + ); }, - child: ListView( - key: const Key('recommend-sections-scroll'), - physics: AppPullToRefresh.physics, - padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), - children: [ - _section( - context, - l10n, - key: 'hits', - title: l10n.weeklyHitsTitle, - subtitle: l10n.weeklyHitsSubtitle, - dramas: hits.take(10).toList(), - onMore: () => onMore(RecommendSectionKind.hits), - ), - const SizedBox(height: AppDimens.spaceXl), - _section( - context, - l10n, - key: 'new', - title: l10n.weeklyNewTitle, - subtitle: l10n.weeklyNewSubtitle, - dramas: newest.take(10).toList(), - onMore: () => onMore(RecommendSectionKind.newReleases), - ), - const SizedBox(height: AppDimens.spaceXl), - _section( - context, - l10n, - key: 'costume', - title: l10n.costumeAwardsTitle, - subtitle: l10n.costumeAwardsSubtitle, - dramas: _costumeDramas(source), - onMore: () => onMore(RecommendSectionKind.costume), - ), - ], - ), ); } diff --git a/test/core/app_pull_to_refresh_test.dart b/test/core/app_pull_to_refresh_test.dart index 8cf5e49..e9598a2 100644 --- a/test/core/app_pull_to_refresh_test.dart +++ b/test/core/app_pull_to_refresh_test.dart @@ -12,7 +12,7 @@ void main() { testWidgets('should_freeze_pull_offset_until_refresh_completes', ( tester, ) async { - expect(AppPullToRefresh.physics.parent, isA()); + expect(AppPullToRefresh.physics.parent, isA()); final refresh = Completer(); var calls = 0; await tester.pumpWidget( @@ -48,7 +48,7 @@ void main() { expect(_contentOffset(tester), greaterThan(0)); expect( tester.widget(find.text('Pull to refresh')).style?.color, - AppColors.blue, + AppColors.navSelected, ); await gesture.moveBy(const Offset(0, 120)); @@ -56,7 +56,7 @@ void main() { expect(find.text('Release to refresh'), findsOneWidget); expect( tester.widget(find.text('Release to refresh')).style?.color, - AppColors.blue, + AppColors.navSelected, ); await gesture.up(); @@ -64,7 +64,7 @@ void main() { expect(find.text('Refreshing…'), findsOneWidget); expect( tester.widget(find.text('Refreshing…')).style?.color, - AppColors.blue, + AppColors.navSelected, ); expect(calls, 1); final releaseOffset = _contentOffset(tester); From 8936c3d6eb969ba20a737d98588542cf1a7d397f Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 19:14:35 +0800 Subject: [PATCH 060/129] =?UTF-8?q?fix(client):=20=E4=BF=AE=E6=AD=A3=20Cha?= =?UTF-8?q?ngeNotifier=20=E7=9B=91=E5=90=AC=E7=94=9F=E5=91=BD=E5=91=A8?= =?UTF-8?q?=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 7 + lib/pages/home/widgets/home_feed_grid.dart | 190 +++++++++--------- lib/pages/player/player_logic.dart | 83 +++++--- .../widgets/comment_composer_sheet.dart | 9 + lib/pages/recommend/recommend_page.dart | 41 ++-- .../widgets/all_dramas_filter_grid.dart | 111 +++++----- .../widgets/recommend_sections_view.dart | 114 +++++------ 7 files changed, 286 insertions(+), 269 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index cd8c8a2..ceec043 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1131,3 +1131,10 @@ $ flutter test - 首页移除父级 feed 监听,网格、剧单和全部短剧改为局部监听,刷新时保留旧数据及原滚动位置。 - 剧单、全部短剧、追剧沿用相同刷新策略;首帧加载与刷新指示器分离。 - 按当前流程未执行 analyze/test。 + +## 2026-10-22 ChangeNotifier 监听生命周期排查 + +- 全仓核对手动 add/remove;首页、推荐和追剧改为内容区单实例 ListenableBuilder,避免重复订阅。 +- 播放器进度监听改为具名回调,在切换、重建会话和 dispose 时解绑;评论输入弹窗补 controller 更换迁移。 +- `_onFeedChanged` 已无源码调用链,报错来源为热重载前注册的旧 tear-off;需完全关闭 App 后冷启动。 +- 按当前流程未执行 analyze/test。 diff --git a/lib/pages/home/widgets/home_feed_grid.dart b/lib/pages/home/widgets/home_feed_grid.dart index 50d3f60..97f07a1 100644 --- a/lib/pages/home/widgets/home_feed_grid.dart +++ b/lib/pages/home/widgets/home_feed_grid.dart @@ -105,22 +105,25 @@ class _HomeFeedGridState extends State { walletBadge: walletBadge, ), Expanded( - child: PageView.builder( - key: const Key('home-primary-page-view'), - controller: _pageController, - itemCount: HomePrimaryTab.values.length, - onPageChanged: (index) => setState( - () => _selectedPrimary = HomePrimaryTab.values[index], - ), - itemBuilder: (context, index) => _KeepAlivePage( - key: PageStorageKey( - 'home-primary-page-${HomePrimaryTab.values[index].name}', + child: ListenableBuilder( + listenable: widget.feedController, + builder: (context, _) => PageView.builder( + key: const Key('home-primary-page-view'), + controller: _pageController, + itemCount: HomePrimaryTab.values.length, + onPageChanged: (index) => setState( + () => _selectedPrimary = HomePrimaryTab.values[index], ), - child: _tabGrid( - context, - l10n, - tab: HomePrimaryTab.values[index], - shortcuts: shortcuts, + itemBuilder: (context, index) => _KeepAlivePage( + key: PageStorageKey( + 'home-primary-page-${HomePrimaryTab.values[index].name}', + ), + child: _tabGrid( + context, + l10n, + tab: HomePrimaryTab.values[index], + shortcuts: shortcuts, + ), ), ), ), @@ -152,90 +155,81 @@ class _HomeFeedGridState extends State { required HomePrimaryTab tab, required List shortcuts, }) { - return ListenableBuilder( - listenable: widget.feedController, - builder: (context, _) { - if (widget.feedController.loadingFirstPage) { - return const Center( - child: SizedBox.square( - dimension: 24, - child: CircularProgressIndicator(strokeWidth: 2), + if (widget.feedController.loadingFirstPage) { + return const Center( + child: SizedBox.square( + dimension: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + } + final tag = _selectedTags[tab]; + final dramas = _dramasFor(tab, tag, shortcuts); + return LayoutBuilder( + builder: (context, constraints) { + final itemWidth = + (constraints.maxWidth - + AppDimens.feedPageHorizontal * 2 - + AppDimens.homeGridGap * 2) / + 3; + final itemExtent = + itemWidth / AppDimens.cardCoverAspectRatio + + AppDimens.homeCardBodyExtent; + return NotificationListener( + onNotification: _onScroll, + child: AppPullToRefresh( + key: const Key('home-grid-refresh'), + onRefresh: _refresh, + child: CustomScrollView( + key: const Key('home-grid-scroll'), + controller: _scrollControllers[tab], + physics: AppPullToRefresh.physics, + slivers: [ + if (dramas.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: _emptyState(l10n, tab, tag), + ) + else + SliverPadding( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.feedPageHorizontal, + ), + sliver: SliverGrid( + key: const Key('home-grid-cards'), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: AppDimens.homeGridGap, + mainAxisSpacing: AppDimens.homeGridRowGap, + mainAxisExtent: itemExtent, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => HomeFeedCard( + drama: dramas[index], + onTap: () => widget.onOpenDrama(dramas[index]), + ), + childCount: dramas.length, + ), + ), + ), + if (dramas.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), + child: PagedListFooter( + loading: widget.feedController.loadingMore, + hasMore: widget.feedController.hasMore, + hasItems: dramas.isNotEmpty, + errorMessage: widget.feedController.lastError == null + ? null + : l10n.catalogError, + onRetry: widget.feedController.loadMore, + ), + ), + ), + ], ), - ); - } - final tag = _selectedTags[tab]; - final dramas = _dramasFor(tab, tag, shortcuts); - return LayoutBuilder( - builder: (context, constraints) { - final itemWidth = - (constraints.maxWidth - - AppDimens.feedPageHorizontal * 2 - - AppDimens.homeGridGap * 2) / - 3; - final itemExtent = - itemWidth / AppDimens.cardCoverAspectRatio + - AppDimens.homeCardBodyExtent; - return NotificationListener( - onNotification: _onScroll, - child: AppPullToRefresh( - key: const Key('home-grid-refresh'), - onRefresh: _refresh, - child: CustomScrollView( - key: const Key('home-grid-scroll'), - controller: _scrollControllers[tab], - physics: AppPullToRefresh.physics, - slivers: [ - if (dramas.isEmpty) - SliverFillRemaining( - hasScrollBody: false, - child: _emptyState(l10n, tab, tag), - ) - else - SliverPadding( - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.feedPageHorizontal, - ), - sliver: SliverGrid( - key: const Key('home-grid-cards'), - gridDelegate: - SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: AppDimens.homeGridGap, - mainAxisSpacing: AppDimens.homeGridRowGap, - mainAxisExtent: itemExtent, - ), - delegate: SliverChildBuilderDelegate( - (context, index) => HomeFeedCard( - drama: dramas[index], - onTap: () => widget.onOpenDrama(dramas[index]), - ), - childCount: dramas.length, - ), - ), - ), - if (dramas.isNotEmpty) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only( - bottom: AppDimens.spaceXl, - ), - child: PagedListFooter( - loading: widget.feedController.loadingMore, - hasMore: widget.feedController.hasMore, - hasItems: dramas.isNotEmpty, - errorMessage: - widget.feedController.lastError == null - ? null - : l10n.catalogError, - onRetry: widget.feedController.loadMore, - ), - ), - ), - ], - ), - ), - ); - }, + ), ); }, ); diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index a9c44e8..3ad74ec 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -69,6 +69,9 @@ class PlayerLogic extends ChangeNotifier { VideoPlayerPort? trackedPlayer; Episode? trackedEpisode; Drama? trackedDrama; + int? _trackedPlayerIndex; + bool _completionHandled = false; + bool _completionProgressSaved = false; DateTime lastProgressWrite = DateTime.fromMillisecondsSinceEpoch(0); /// 进度上报节流:契约建议每 10 秒一次 @@ -317,6 +320,7 @@ class PlayerLogic extends ChangeNotifier { unawaited(saveCurrentProgress()); final previous = session; sessionEpisodes = List.of(nextEpisodes); + _detachTrackedPlayerListener(); trackedPlayer = null; trackedEpisode = null; trackedDrama = null; @@ -601,6 +605,7 @@ class PlayerLogic extends ChangeNotifier { _disposed = true; pictureInPicture.setStateListener(null); unawaited(saveCurrentProgress()); + _detachTrackedPlayerListener(); if (_started) { session.setRouteVisible(false); cancelScrub(); @@ -696,46 +701,66 @@ class PlayerLogic extends ChangeNotifier { await player.controller.setVolume(muted ? 0 : 1); await player.controller.setPlaybackSpeed(playbackSpeed); if (session.active != player) return; + _detachTrackedPlayerListener(); trackedPlayer = player; trackedEpisode = episode; trackedDrama = drama; + _trackedPlayerIndex = index; + _completionHandled = false; + _completionProgressSaved = false; lastProgressWrite = DateTime.now(); - var completionHandled = false; - var completionProgressSaved = false; - player.controller.addListener(() { - if (session.active != player) return; - final snapshot = _readPlaybackSnapshot(player); - if (snapshot == null) return; - _cachePlaybackPosition(drama, episode, snapshot); - if (snapshot.completed && !completionProgressSaved) { - completionProgressSaved = true; - unawaited( - queueProgress( - drama, - episode, - snapshot.duration, - duration: snapshot.duration, - finished: true, - ), - ); - } - if (!completionHandled && - snapshot.completed && - session.canAutoAdvance && - session.currentIndex == index) { - completionHandled = true; - unawaited(advanceAfterEnd(player, index)); - } - if (!shouldReportProgress(DateTime.now())) return; + player.controller.addListener(_handleTrackedPlayerChanged); + } + + void _detachTrackedPlayerListener() { + trackedPlayer?.controller.removeListener(_handleTrackedPlayerChanged); + _trackedPlayerIndex = null; + } + + void _handleTrackedPlayerChanged() { + if (_disposed) return; + final player = trackedPlayer; + final episode = trackedEpisode; + final drama = trackedDrama; + final index = _trackedPlayerIndex; + if (player == null || + episode == null || + drama == null || + index == null || + session.active != player) { + return; + } + final snapshot = _readPlaybackSnapshot(player); + if (snapshot == null) return; + _cachePlaybackPosition(drama, episode, snapshot); + if (snapshot.completed && !_completionProgressSaved) { + _completionProgressSaved = true; unawaited( queueProgress( drama, episode, - snapshot.position, + snapshot.duration, duration: snapshot.duration, + finished: true, ), ); - }); + } + if (!_completionHandled && + snapshot.completed && + session.canAutoAdvance && + session.currentIndex == index) { + _completionHandled = true; + unawaited(advanceAfterEnd(player, index)); + } + if (!shouldReportProgress(DateTime.now())) return; + unawaited( + queueProgress( + drama, + episode, + snapshot.position, + duration: snapshot.duration, + ), + ); } Future advanceAfterEnd(VideoPlayerPort player, int index) async { diff --git a/lib/pages/player/widgets/comment_composer_sheet.dart b/lib/pages/player/widgets/comment_composer_sheet.dart index 8e7ea04..9f9ce82 100644 --- a/lib/pages/player/widgets/comment_composer_sheet.dart +++ b/lib/pages/player/widgets/comment_composer_sheet.dart @@ -79,6 +79,15 @@ class _CommentComposerSheetState extends State { _trackKeyboardInset(_keyboardHeight(context)); } + @override + void didUpdateWidget(covariant CommentComposerSheet oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_onControllerChanged); + widget.controller.addListener(_onControllerChanged); + } + } + Future _prepareInput() async { if (_isAndroid) { await _keyboardInset.start(); diff --git a/lib/pages/recommend/recommend_page.dart b/lib/pages/recommend/recommend_page.dart index fb723a5..803e7f7 100644 --- a/lib/pages/recommend/recommend_page.dart +++ b/lib/pages/recommend/recommend_page.dart @@ -56,25 +56,28 @@ class _RecommendPageState extends State { children: [ _header(l10n), Expanded( - child: IndexedStack( - index: _tab.index, - children: [ - ListenableBuilder( - listenable: widget.feedController, - builder: (context, _) => _recommendFeed(context), - ), - RecommendSectionsView( - feedController: widget.feedController, - categories: categories, - onOpenDrama: _openDrama, - onMore: _handleMore, - ), - AllDramasFilterGrid( - feedController: widget.feedController, - onOpenDrama: _openDrama, - onOpenFilter: widget.onOpenFilter, - ), - ], + child: ListenableBuilder( + listenable: Listenable.merge([ + widget.feedController, + categories, + ]), + builder: (context, _) => IndexedStack( + index: _tab.index, + children: [ + _recommendFeed(context), + RecommendSectionsView( + feedController: widget.feedController, + categories: categories, + onOpenDrama: _openDrama, + onMore: _handleMore, + ), + AllDramasFilterGrid( + feedController: widget.feedController, + onOpenDrama: _openDrama, + onOpenFilter: widget.onOpenFilter, + ), + ], + ), ), ), ], diff --git a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart index 10d2d33..059e47b 100644 --- a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart +++ b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart @@ -44,6 +44,7 @@ class _AllDramasFilterGridState extends State { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; + final dramas = _filtered(); return LayoutBuilder( builder: (context, constraints) { final itemWidth = @@ -65,69 +66,55 @@ class _AllDramasFilterGridState extends State { key: const Key('all-dramas-scroll'), physics: AppPullToRefresh.physics, slivers: [ - SliverToBoxAdapter( - child: ListenableBuilder( - listenable: widget.feedController, - builder: (context, _) => _filters(l10n), + SliverToBoxAdapter(child: _filters(l10n)), + if (dramas.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: EmptyState( + key: const Key('all-dramas-empty'), + icon: Icons.movie_filter_outlined, + title: l10n.homeGridFilterEmpty, + actionLabel: l10n.homeGridClearFilter, + onAction: () => setState(() { + _region = 'all'; + _audio = 'all'; + _genre = null; + }), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.feedPageHorizontal, + ), + sliver: SliverGrid( + key: const Key('all-dramas-grid'), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: AppDimens.homeGridGap, + mainAxisSpacing: AppDimens.homeGridRowGap, + mainAxisExtent: itemExtent, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => HomeFeedCard( + drama: dramas[index], + onTap: () => widget.onOpenDrama(dramas[index]), + ), + childCount: dramas.length, + ), + ), ), - ), - ListenableBuilder( - listenable: widget.feedController, - builder: (context, _) { - final dramas = _filtered(); - if (dramas.isEmpty) { - return SliverFillRemaining( - hasScrollBody: false, - child: EmptyState( - key: const Key('all-dramas-empty'), - icon: Icons.movie_filter_outlined, - title: l10n.homeGridFilterEmpty, - actionLabel: l10n.homeGridClearFilter, - onAction: () => setState(() { - _region = 'all'; - _audio = 'all'; - _genre = null; - }), - ), - ); - } - return SliverPadding( - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.feedPageHorizontal, - ), - sliver: SliverGrid( - key: const Key('all-dramas-grid'), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: AppDimens.homeGridGap, - mainAxisSpacing: AppDimens.homeGridRowGap, - mainAxisExtent: itemExtent, - ), - delegate: SliverChildBuilderDelegate( - (context, index) => HomeFeedCard( - drama: dramas[index], - onTap: () => widget.onOpenDrama(dramas[index]), - ), - childCount: dramas.length, - ), - ), - ); - }, - ), - ListenableBuilder( - listenable: widget.feedController, - builder: (context, _) => SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), - child: PagedListFooter( - loading: widget.feedController.loadingMore, - hasMore: widget.feedController.hasMore, - hasItems: _filtered().isNotEmpty, - errorMessage: widget.feedController.lastError == null - ? null - : l10n.catalogError, - onRetry: widget.feedController.loadMore, - ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), + child: PagedListFooter( + loading: widget.feedController.loadingMore, + hasMore: widget.feedController.hasMore, + hasItems: dramas.isNotEmpty, + errorMessage: widget.feedController.lastError == null + ? null + : l10n.catalogError, + onRetry: widget.feedController.loadMore, ), ), ), diff --git a/lib/pages/recommend/widgets/recommend_sections_view.dart b/lib/pages/recommend/widgets/recommend_sections_view.dart index 4560103..139c48d 100644 --- a/lib/pages/recommend/widgets/recommend_sections_view.dart +++ b/lib/pages/recommend/widgets/recommend_sections_view.dart @@ -33,68 +33,60 @@ class RecommendSectionsView extends StatelessWidget { @override Widget build(BuildContext context) { - return ListenableBuilder( - listenable: Listenable.merge([feedController, categories]), - builder: (context, _) { - final l10n = AppLocalizations.of(context)!; - final source = feedController.dramas; - final hits = [...source] - ..sort((a, b) => b.viewCount.compareTo(a.viewCount)); - final newest = [...source] - ..sort((a, b) { - final left = publishedAtForDrama(a); - final right = publishedAtForDrama(b); - if (left == null && right == null) return b.id.compareTo(a.id); - if (left == null) return 1; - if (right == null) return -1; - return right.compareTo(left); - }); - return AppPullToRefresh( - key: const Key('recommend-sections-refresh'), - onRefresh: () async { - await Future.wait([ - feedController.refreshCurrent(), - categories.load(), - ]); - }, - child: ListView( - key: const Key('recommend-sections-scroll'), - physics: AppPullToRefresh.physics, - padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), - children: [ - _section( - context, - l10n, - key: 'hits', - title: l10n.weeklyHitsTitle, - subtitle: l10n.weeklyHitsSubtitle, - dramas: hits.take(10).toList(), - onMore: () => onMore(RecommendSectionKind.hits), - ), - const SizedBox(height: AppDimens.spaceXl), - _section( - context, - l10n, - key: 'new', - title: l10n.weeklyNewTitle, - subtitle: l10n.weeklyNewSubtitle, - dramas: newest.take(10).toList(), - onMore: () => onMore(RecommendSectionKind.newReleases), - ), - const SizedBox(height: AppDimens.spaceXl), - _section( - context, - l10n, - key: 'costume', - title: l10n.costumeAwardsTitle, - subtitle: l10n.costumeAwardsSubtitle, - dramas: _costumeDramas(source), - onMore: () => onMore(RecommendSectionKind.costume), - ), - ], - ), - ); + final l10n = AppLocalizations.of(context)!; + final source = feedController.dramas; + final hits = [...source] + ..sort((a, b) => b.viewCount.compareTo(a.viewCount)); + final newest = [...source] + ..sort((a, b) { + final left = publishedAtForDrama(a); + final right = publishedAtForDrama(b); + if (left == null && right == null) return b.id.compareTo(a.id); + if (left == null) return 1; + if (right == null) return -1; + return right.compareTo(left); + }); + return AppPullToRefresh( + key: const Key('recommend-sections-refresh'), + onRefresh: () async { + await Future.wait([feedController.refreshCurrent(), categories.load()]); }, + child: ListView( + key: const Key('recommend-sections-scroll'), + physics: AppPullToRefresh.physics, + padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), + children: [ + _section( + context, + l10n, + key: 'hits', + title: l10n.weeklyHitsTitle, + subtitle: l10n.weeklyHitsSubtitle, + dramas: hits.take(10).toList(), + onMore: () => onMore(RecommendSectionKind.hits), + ), + const SizedBox(height: AppDimens.spaceXl), + _section( + context, + l10n, + key: 'new', + title: l10n.weeklyNewTitle, + subtitle: l10n.weeklyNewSubtitle, + dramas: newest.take(10).toList(), + onMore: () => onMore(RecommendSectionKind.newReleases), + ), + const SizedBox(height: AppDimens.spaceXl), + _section( + context, + l10n, + key: 'costume', + title: l10n.costumeAwardsTitle, + subtitle: l10n.costumeAwardsSubtitle, + dramas: _costumeDramas(source), + onMore: () => onMore(RecommendSectionKind.costume), + ), + ], + ), ); } From 38643e7fdea9f07817f3cbac2dc981620aa13a13 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 19:19:50 +0800 Subject: [PATCH 061/129] =?UTF-8?q?fix(home):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E4=BA=8C=E7=BA=A7=E7=AD=9B=E9=80=89=E6=8C=89=E9=92=AE=E5=8F=B3?= =?UTF-8?q?=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 6 ++++++ lib/core/theme/app_dimens.dart | 2 +- lib/pages/home/widgets/home_grid_header.dart | 7 +++---- test/home/home_preview_layout_test.dart | 10 +++++++++- 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index ceec043..62c1c3e 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1138,3 +1138,9 @@ $ flutter test - 播放器进度监听改为具名回调,在切换、重建会话和 dispose 时解绑;评论输入弹窗补 controller 更换迁移。 - `_onFeedChanged` 已无源码调用链,报错来源为热重载前注册的旧 tear-off;需完全关闭 App 后冷启动。 - 按当前流程未执行 analyze/test。 + +## 2026-10-22 二级标签筛选按钮右对齐 + +- 三横线热区由 44 宽改为 26×26,消除 44 宽居中盒造成的视觉右边距偏移。 +- 标签滚动区与按钮间距改为 6,按钮右边缘固定对齐页面 14 边距。 +- 补充按钮尺寸与屏幕右边距断言;按当前流程未执行 analyze/test。 diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index e27b295..27c13c6 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -114,7 +114,7 @@ abstract final class AppDimens { static const Duration homeSecondaryTagAnimationDuration = Duration( milliseconds: 150, ); - static const double homeSecondaryFilterGap = 8; + static const double homeSecondaryFilterGap = 6; static const double homeSecondaryFilterVisualSize = 26; static const double homeSecondaryFilterIcon = 16; static const double homeSecondaryFilterStroke = 1.7; diff --git a/lib/pages/home/widgets/home_grid_header.dart b/lib/pages/home/widgets/home_grid_header.dart index 04931ed..3720c46 100644 --- a/lib/pages/home/widgets/home_grid_header.dart +++ b/lib/pages/home/widgets/home_grid_header.dart @@ -333,7 +333,7 @@ class HomeGridHeader extends StatelessWidget { AppDimens.homeSecondaryGridGap, ), child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.start, children: [ Expanded( @@ -355,9 +355,8 @@ class HomeGridHeader extends StatelessWidget { const SizedBox(width: AppDimens.homeSecondaryFilterGap), Tooltip( message: l10n.filter, - child: SizedBox( - width: AppDimens.tapTarget, - height: AppDimens.homeSecondaryFilterVisualSize, + child: SizedBox.square( + dimension: AppDimens.homeSecondaryFilterVisualSize, child: Material( color: AppColors.transparent, child: InkWell( diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 7b5d1cd..5382579 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -96,7 +96,7 @@ void main() { expect(find.byKey(const Key('home-grid-filter')), findsOneWidget); expect( tester.getSize(find.byKey(const Key('home-grid-filter'))), - const Size(AppDimens.tapTarget, AppDimens.homeSecondaryFilterVisualSize), + const Size.square(AppDimens.homeSecondaryFilterVisualSize), ); expect( tester.getSize(find.byKey(const Key('home-secondary-filter-visual'))), @@ -106,6 +106,14 @@ void main() { tester.getCenter(find.byKey(const Key('home-grid-filter'))).dy, closeTo(tester.getCenter(find.byKey(const Key('home-tag-hot'))).dy, 0.1), ); + expect( + tester.getRect(find.byKey(const Key('home-grid-filter'))).right, + closeTo( + tester.getRect(find.byKey(const Key('home-grid'))).right - + AppDimens.feedPageHorizontal, + 0.1, + ), + ); final filterIcon = tester.widget( find.byKey(const Key('home-secondary-filter-icon')), ); From 97e2db1b7bbb5ee841820c8e04f24a9a14bc95dc Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 19:22:01 +0800 Subject: [PATCH 062/129] =?UTF-8?q?feat(home):=20=E6=9B=B4=E6=8D=A2?= =?UTF-8?q?=E5=BA=95=E9=83=A8=E9=A6=96=E9=A1=B5=E5=AF=BC=E8=88=AA=E5=9B=BE?= =?UTF-8?q?=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 6 ++++++ lib/core/widgets/app_root_tab.dart | 4 ++-- test/home/home_nav_ui_test.dart | 6 +++--- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 62c1c3e..12f49a4 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1144,3 +1144,9 @@ $ flutter test - 三横线热区由 44 宽改为 26×26,消除 44 宽居中盒造成的视觉右边距偏移。 - 标签滚动区与按钮间距改为 6,按钮右边缘固定对齐页面 14 边距。 - 补充按钮尺寸与屏幕右边距断言;按当前流程未执行 analyze/test。 + +## 2026-10-22 首页导航图标调整 + +- `AppRootTab.home` 未选中改为 `theaters_outlined`,选中改为 `theaters`。 +- 其余三个根导航图标、26 尺寸及白/灰颜色规则保持不变;测试断言同步。 +- 按当前流程未执行 analyze/test。 diff --git a/lib/core/widgets/app_root_tab.dart b/lib/core/widgets/app_root_tab.dart index 798b2f7..dec8033 100644 --- a/lib/core/widgets/app_root_tab.dart +++ b/lib/core/widgets/app_root_tab.dart @@ -17,14 +17,14 @@ extension AppRootTabUi on AppRootTab { }; IconData get icon => switch (this) { - AppRootTab.home => Icons.home_outlined, + AppRootTab.home => Icons.theaters_outlined, AppRootTab.recommend => Icons.smart_display_outlined, AppRootTab.following => Icons.bookmark_border_rounded, AppRootTab.mine => Icons.person_outline_rounded, }; IconData get selectedIcon => switch (this) { - AppRootTab.home => Icons.home_rounded, + AppRootTab.home => Icons.theaters, AppRootTab.recommend => Icons.smart_display_rounded, AppRootTab.following => Icons.bookmark_rounded, AppRootTab.mine => Icons.person_rounded, diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart index b1d78ad..e831fc3 100644 --- a/test/home/home_nav_ui_test.dart +++ b/test/home/home_nav_ui_test.dart @@ -191,7 +191,7 @@ void main() { expect( find.descendant( of: find.byKey(const Key('tab-home')), - matching: find.byIcon(Icons.home_rounded), + matching: find.byIcon(Icons.theaters), ), findsOneWidget, ); @@ -221,7 +221,7 @@ void main() { .widget( find.descendant( of: find.byKey(const Key('tab-home')), - matching: find.byIcon(Icons.home_rounded), + matching: find.byIcon(Icons.theaters), ), ) .color, @@ -271,7 +271,7 @@ void main() { expect( find.descendant( of: find.byKey(const Key('tab-home')), - matching: find.byIcon(Icons.home_outlined), + matching: find.byIcon(Icons.theaters_outlined), ), findsOneWidget, ); From f47e4dd2980df48e42c890dd1eb846ee9044643a Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 19:24:34 +0800 Subject: [PATCH 063/129] =?UTF-8?q?feat(home):=20=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E8=83=B6=E7=89=87=E7=9B=98=E5=9B=BE=E7=89=87=E5=AF=BC=E8=88=AA?= =?UTF-8?q?=E5=9B=BE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/icon/film_reel.png | Bin 0 -> 7666 bytes docs/plans/PROGRESS.md | 6 ++ lib/core/widgets/app_bottom_nav.dart | 99 ++++++++++++++++----------- lib/core/widgets/app_root_tab.dart | 12 +++- test/home/app_bottom_nav_test.dart | 25 +++++++ test/home/home_nav_ui_test.dart | 37 ++++++++-- 6 files changed, 132 insertions(+), 47 deletions(-) create mode 100644 assets/icon/film_reel.png diff --git a/assets/icon/film_reel.png b/assets/icon/film_reel.png new file mode 100644 index 0000000000000000000000000000000000000000..1047c23c9d95071f12a5e4e5154fc249a63be195 GIT binary patch literal 7666 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 literal 0 HcmV?d00001 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 12f49a4..cc142c9 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1150,3 +1150,9 @@ $ flutter test - `AppRootTab.home` 未选中改为 `theaters_outlined`,选中改为 `theaters`。 - 其余三个根导航图标、26 尺寸及白/灰颜色规则保持不变;测试断言同步。 - 按当前流程未执行 analyze/test。 + +## 2026-10-22 首页圆形胶片盘图标 + +- `AppRootTab` 新增可选 `assetPath`,首页使用 `assets/icon/film_reel.png`。 +- 共享导航通过 `ColorFiltered + Image.asset` 按选中态着色,尺寸保持 26,缺图回退胶片 Material 图标。 +- 推荐、追剧、我的图标不变;素材与测试断言同步,按当前流程未执行 analyze/test。 diff --git a/lib/core/widgets/app_bottom_nav.dart b/lib/core/widgets/app_bottom_nav.dart index a4163df..f9a4ec0 100644 --- a/lib/core/widgets/app_bottom_nav.dart +++ b/lib/core/widgets/app_bottom_nav.dart @@ -14,11 +14,13 @@ class AppBottomNavItem { required this.label, required this.onTap, this.selected = false, + this.assetPath, }); final Key tabKey; final IconData icon; final IconData selectedIcon; + final String? assetPath; final String label; final VoidCallback onTap; final bool selected; @@ -61,49 +63,66 @@ class _NavDestination extends StatelessWidget { final Color unselectedColor; @override - Widget build(BuildContext context) => Expanded( - child: Semantics( - button: true, - selected: item.selected, - label: item.label, - child: InkWell( - key: item.tabKey, - onTap: item.onTap, - child: ConstrainedBox( - constraints: const BoxConstraints(minHeight: AppDimens.navHeight), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - AnimatedScale( - duration: const Duration(milliseconds: 160), - curve: Curves.easeOut, - scale: item.selected ? 1 : 0.96, - child: Icon( - item.selected ? item.selectedIcon : item.icon, - size: AppDimens.navIcon, - color: item.selected - ? AppColors.navSelected - : unselectedColor, + Widget build(BuildContext context) { + final color = item.selected ? AppColors.navSelected : unselectedColor; + final fallbackIcon = item.selected ? item.selectedIcon : item.icon; + return Expanded( + child: Semantics( + button: true, + selected: item.selected, + label: item.label, + child: InkWell( + key: item.tabKey, + onTap: item.onTap, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: AppDimens.navHeight), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AnimatedScale( + duration: const Duration(milliseconds: 160), + curve: Curves.easeOut, + scale: item.selected ? 1 : 0.96, + child: item.assetPath == null + ? Icon( + fallbackIcon, + size: AppDimens.navIcon, + color: color, + ) + : ColorFiltered( + colorFilter: ColorFilter.mode(color, BlendMode.srcIn), + child: Image.asset( + item.assetPath!, + width: AppDimens.navIcon, + height: AppDimens.navIcon, + fit: BoxFit.contain, + errorBuilder: (_, _, _) => Icon( + item.icon, + size: AppDimens.navIcon, + color: color, + ), + ), + ), ), - ), - const SizedBox(height: AppDimens.navLabelGap), - Text( - item.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: AppDimens.navLabel, - fontWeight: item.selected ? FontWeight.w600 : FontWeight.w400, - color: item.selected - ? AppColors.navSelected - : unselectedColor, - letterSpacing: 0, + const SizedBox(height: AppDimens.navLabelGap), + Text( + item.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: AppDimens.navLabel, + fontWeight: item.selected + ? FontWeight.w600 + : FontWeight.w400, + color: color, + letterSpacing: 0, + ), ), - ), - ], + ], + ), ), ), ), - ), - ); + ); + } } diff --git a/lib/core/widgets/app_root_tab.dart b/lib/core/widgets/app_root_tab.dart index dec8033..c0e9d2d 100644 --- a/lib/core/widgets/app_root_tab.dart +++ b/lib/core/widgets/app_root_tab.dart @@ -6,7 +6,16 @@ import '../../l10n/app_localizations.dart'; import 'app_bottom_nav.dart'; /// 客户端根导航的固定顺序。 -enum AppRootTab { home, recommend, following, mine } +enum AppRootTab { + home(assetPath: 'assets/icon/film_reel.png'), + recommend(), + following(), + mine(); + + const AppRootTab({this.assetPath}); + + final String? assetPath; +} extension AppRootTabUi on AppRootTab { Key get tabKey => switch (this) { @@ -62,6 +71,7 @@ class AppRootBottomNav extends StatelessWidget { tabKey: tab.tabKey, icon: tab.icon, selectedIcon: tab.selectedIcon, + assetPath: tab.assetPath, label: tab.label(l10n), selected: tab == selected, onTap: () => onSelected(tab), diff --git a/test/home/app_bottom_nav_test.dart b/test/home/app_bottom_nav_test.dart index 0b04e00..37a44be 100644 --- a/test/home/app_bottom_nav_test.dart +++ b/test/home/app_bottom_nav_test.dart @@ -179,4 +179,29 @@ void main() { findsWidgets, ); }); + + testWidgets('should_fallback_when_asset_icon_is_missing', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + bottomNavigationBar: AppBottomNav( + items: [ + AppBottomNavItem( + tabKey: const Key('tab-home'), + icon: Icons.theaters_outlined, + selectedIcon: Icons.theaters, + assetPath: 'assets/icon/missing_film_reel.png', + label: 'Home', + selected: true, + onTap: () {}, + ), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.theaters_outlined), findsOneWidget); + }); } diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart index e831fc3..1214bb8 100644 --- a/test/home/home_nav_ui_test.dart +++ b/test/home/home_nav_ui_test.dart @@ -191,7 +191,7 @@ void main() { expect( find.descendant( of: find.byKey(const Key('tab-home')), - matching: find.byIcon(Icons.theaters), + matching: find.byType(Image), ), findsOneWidget, ); @@ -218,14 +218,28 @@ void main() { ); expect( tester - .widget( + .widget( find.descendant( of: find.byKey(const Key('tab-home')), - matching: find.byIcon(Icons.theaters), + matching: find.byType(ColorFiltered), ), ) - .color, - AppColors.navSelected, + .colorFilter, + const ColorFilter.mode(AppColors.navSelected, BlendMode.srcIn), + ); + final selectedHomeImage = tester.widget( + find.descendant( + of: find.byKey(const Key('tab-home')), + matching: find.byType(Image), + ), + ); + expect( + (selectedHomeImage.image as AssetImage).assetName, + 'assets/icon/film_reel.png', + ); + expect( + Size(selectedHomeImage.width!, selectedHomeImage.height!), + const Size.square(AppDimens.navIcon), ); await tester.tap(find.byKey(const Key('tab-recommend'))); @@ -271,10 +285,21 @@ void main() { expect( find.descendant( of: find.byKey(const Key('tab-home')), - matching: find.byIcon(Icons.theaters_outlined), + matching: find.byType(Image), ), findsOneWidget, ); + expect( + tester + .widget( + find.descendant( + of: find.byKey(const Key('tab-home')), + matching: find.byType(ColorFiltered), + ), + ) + .colorFilter, + const ColorFilter.mode(AppColors.navUnselected, BlendMode.srcIn), + ); }); testWidgets('should_map_four_tabs_to_home_recommend_following_and_me', ( From 8ae2ed4d93a7a567deb2ef2c3ba98a94b5b67a31 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 19:26:31 +0800 Subject: [PATCH 064/129] =?UTF-8?q?chore(home):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E9=A6=96=E9=A1=B5=E5=AF=BC=E8=88=AA=E6=91=84=E5=BD=B1=E6=9C=BA?= =?UTF-8?q?=E7=B4=A0=E6=9D=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/icon/film_reel.png | Bin 7666 -> 3502 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/assets/icon/film_reel.png b/assets/icon/film_reel.png index 1047c23c9d95071f12a5e4e5154fc249a63be195..9d607fcf0d18177c586f2a898a2ed03ce3036d78 100644 GIT binary patch literal 3502 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 From 4592b5dfbad9a8c15ddd533c5c0e61d568b4c564 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 19:29:12 +0800 Subject: [PATCH 065/129] =?UTF-8?q?feat(home):=20=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=E5=9B=BE=E6=A0=87=E5=8C=BA=E5=88=86=E5=AE=9E=E5=BF=83=E9=80=89?= =?UTF-8?q?=E4=B8=AD=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/icon/film_reel_filled.png | Bin 0 -> 2624 bytes docs/plans/PROGRESS.md | 6 ++++++ lib/core/widgets/app_bottom_nav.dart | 11 ++++++++--- lib/core/widgets/app_root_tab.dart | 9 +++++++-- test/home/app_bottom_nav_test.dart | 18 ++++++++++++------ test/home/home_nav_ui_test.dart | 12 +++++++++++- 6 files changed, 44 insertions(+), 12 deletions(-) create mode 100644 assets/icon/film_reel_filled.png diff --git a/assets/icon/film_reel_filled.png b/assets/icon/film_reel_filled.png new file mode 100644 index 0000000000000000000000000000000000000000..719167143c6be7bf7fb64d3398fe8c8067d0ae14 GIT binary patch literal 2624 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 o#O^*R!dcT9_#bQ6U!6}l_zf2F Icon( - item.icon, + fallbackIcon, size: AppDimens.navIcon, color: color, ), diff --git a/lib/core/widgets/app_root_tab.dart b/lib/core/widgets/app_root_tab.dart index c0e9d2d..c9f0c96 100644 --- a/lib/core/widgets/app_root_tab.dart +++ b/lib/core/widgets/app_root_tab.dart @@ -7,14 +7,18 @@ import 'app_bottom_nav.dart'; /// 客户端根导航的固定顺序。 enum AppRootTab { - home(assetPath: 'assets/icon/film_reel.png'), + home( + assetPath: 'assets/icon/film_reel.png', + selectedAssetPath: 'assets/icon/film_reel_filled.png', + ), recommend(), following(), mine(); - const AppRootTab({this.assetPath}); + const AppRootTab({this.assetPath, this.selectedAssetPath}); final String? assetPath; + final String? selectedAssetPath; } extension AppRootTabUi on AppRootTab { @@ -72,6 +76,7 @@ class AppRootBottomNav extends StatelessWidget { icon: tab.icon, selectedIcon: tab.selectedIcon, assetPath: tab.assetPath, + selectedAssetPath: tab.selectedAssetPath, label: tab.label(l10n), selected: tab == selected, onTap: () => onSelected(tab), diff --git a/test/home/app_bottom_nav_test.dart b/test/home/app_bottom_nav_test.dart index 37a44be..289d568 100644 --- a/test/home/app_bottom_nav_test.dart +++ b/test/home/app_bottom_nav_test.dart @@ -181,8 +181,8 @@ void main() { }); testWidgets('should_fallback_when_asset_icon_is_missing', (tester) async { - await tester.pumpWidget( - MaterialApp( + Widget navigation({required bool selected}) { + return MaterialApp( home: Scaffold( bottomNavigationBar: AppBottomNav( items: [ @@ -191,17 +191,23 @@ void main() { icon: Icons.theaters_outlined, selectedIcon: Icons.theaters, assetPath: 'assets/icon/missing_film_reel.png', + selectedAssetPath: 'assets/icon/missing_film_reel_filled.png', label: 'Home', - selected: true, + selected: selected, onTap: () {}, ), ], ), ), - ), - ); - await tester.pumpAndSettle(); + ); + } + await tester.pumpWidget(navigation(selected: true)); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.theaters), findsOneWidget); + + await tester.pumpWidget(navigation(selected: false)); + await tester.pumpAndSettle(); expect(find.byIcon(Icons.theaters_outlined), findsOneWidget); }); } diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart index 1214bb8..354fc46 100644 --- a/test/home/home_nav_ui_test.dart +++ b/test/home/home_nav_ui_test.dart @@ -235,7 +235,7 @@ void main() { ); expect( (selectedHomeImage.image as AssetImage).assetName, - 'assets/icon/film_reel.png', + 'assets/icon/film_reel_filled.png', ); expect( Size(selectedHomeImage.width!, selectedHomeImage.height!), @@ -300,6 +300,16 @@ void main() { .colorFilter, const ColorFilter.mode(AppColors.navUnselected, BlendMode.srcIn), ); + final unselectedHomeImage = tester.widget( + find.descendant( + of: find.byKey(const Key('tab-home')), + matching: find.byType(Image), + ), + ); + expect( + (unselectedHomeImage.image as AssetImage).assetName, + 'assets/icon/film_reel.png', + ); }); testWidgets('should_map_four_tabs_to_home_recommend_following_and_me', ( From fd2b0dbf2341f79d1a3ce00aa33ea892a95b4a17 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 19:30:03 +0800 Subject: [PATCH 066/129] =?UTF-8?q?style(home):=20=E6=94=BE=E5=A4=A7?= =?UTF-8?q?=E5=BA=95=E9=83=A8=E5=AF=BC=E8=88=AA=E5=9B=BE=E6=A0=87=E4=B8=8E?= =?UTF-8?q?=E6=96=87=E5=AD=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 6 ++++++ lib/core/theme/app_dimens.dart | 8 ++++---- test/home/app_bottom_nav_test.dart | 2 +- test/theme/app_dimens_test.dart | 8 ++++---- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 14b684c..a7d3961 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1162,3 +1162,9 @@ $ flutter test - `AppRootTab` 新增 `selectedAssetPath`,首页选中使用 `film_reel_filled.png`,未选中使用描边版。 - 共享导航保持 `ColorFiltered(srcIn)` 与 26×26,缺图按选中态回退实心/描边胶片图标。 - 推荐、追剧、我的不变;测试断言同步,按当前流程未执行 analyze/test。 + +## 2026-10-22 底部导航图标与文字加大 + +- 公共 token 调整为图标 30、文字 13、图标文字间距 5、内容高度 62。 +- 图片首页图标同步使用 30×30;SafeArea 仍在内容区外独立追加,四等分布局不变。 +- 尺寸测试断言同步,按当前流程未执行 analyze/test。 diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 27c13c6..f0ba1fe 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -36,8 +36,8 @@ abstract final class AppDimens { static const double previewCardTitle = 13; static const double previewSectionTitle = 14; static const double chipFont = 10; - static const double navLabel = 11; - static const double navLabelGap = 4; + static const double navLabel = 13; + static const double navLabelGap = 5; static const double buttonHeight = 48; static const double primaryButtonHeight = 52; @@ -48,8 +48,8 @@ abstract final class AppDimens { static const double tapTarget = 44; static const double contentHeaderHeight = 46; - static const double navHeight = 56; - static const double navIcon = 26; + static const double navHeight = 62; + static const double navIcon = 30; static const double progressHeight = 3; static const double progressRadius = 2; static const double progressLineHeight = 3; diff --git a/test/home/app_bottom_nav_test.dart b/test/home/app_bottom_nav_test.dart index 289d568..c457535 100644 --- a/test/home/app_bottom_nav_test.dart +++ b/test/home/app_bottom_nav_test.dart @@ -103,7 +103,7 @@ void main() { expect(selectedIcon.color, AppColors.navSelected); expect( tester.getSize(find.byIcon(Icons.movie_rounded)), - const Size(26, 26), + const Size.square(AppDimens.navIcon), ); expect( tester.getTopLeft(find.text('Home')).dy - diff --git a/test/theme/app_dimens_test.dart b/test/theme/app_dimens_test.dart index 8a8cbfb..df8c7a6 100644 --- a/test/theme/app_dimens_test.dart +++ b/test/theme/app_dimens_test.dart @@ -18,13 +18,13 @@ void main() { expect(AppDimens.listTitle, 15); expect(AppDimens.sectionTitle, 16); expect(AppDimens.pageTitle, 20); - expect(AppDimens.navLabel, 11); - expect(AppDimens.navLabelGap, 4); - expect(AppDimens.navIcon, 26); + expect(AppDimens.navLabel, 13); + expect(AppDimens.navLabelGap, 5); + expect(AppDimens.navIcon, 30); expect(AppDimens.buttonHeight, 48); expect(AppDimens.primaryButtonHeight, 52); expect(AppDimens.tapTarget, 44); - expect(AppDimens.navHeight, 56); + expect(AppDimens.navHeight, 62); expect(AppDimens.progressHeight, 3); }); } From df960f84ea921bdd4a0f5e962b13640791b38ce4 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 19:30:52 +0800 Subject: [PATCH 067/129] =?UTF-8?q?chore(home):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E9=A6=96=E9=A1=B5=E8=83=B6=E7=89=87=E5=B8=A6=E5=AF=BC=E8=88=AA?= =?UTF-8?q?=E7=B4=A0=E6=9D=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/icon/film_reel.png | Bin 3502 -> 1948 bytes assets/icon/film_reel_filled.png | Bin 2624 -> 1766 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/assets/icon/film_reel.png b/assets/icon/film_reel.png index 9d607fcf0d18177c586f2a898a2ed03ce3036d78..ac62ebb835e0a8d59a8e87e92d478dc1398be231 100644 GIT binary patch delta 1935 zcmYLKdo+}57k_6KnsGU228YTeW;8j=<&excGK?Cxl7pHUA|eUX>BNvUW{8ZaOc#AJ z$^G)W9t^p~L7AppMn3g-l^?lDD&$ITkf4}|P`&oNEyGo1uV+NMF zBglnpf8sLt)1&^|!N=8m+DpJ)cLOl{_g6&78R;h`J4djQ;mG~ue2g9YcJVnm$Rj0+R7Z%>qf{;Mr>lS}(z{X~>9N!--N+sOW{ z&kPmRA%uR?e>c7^A~7Efe93%!ogKQMRT%fKR5CWuR}S8y(31j^!k$GZIs{Aldw{Am z@7w9>bi6}w0ksE_^y}vj-Jrw-a09Fqr$dyl(SdV7>o1ropTAKYBwX*W?#Ml?-xLhE zxN*Q=gLBsd!;w3ZdF+54{0Xu{16b(1-aWJsyviVxpQfWyF$M+9Xw^o_DjAnkZf4`C zoAB{rYZX{hr(Nc4;JnhL(D55^L3;P0qfzE%+25;%vS30+g=tFdn@_xBz#5xBI@nUn zLrAgU6;uc3y1Vh`t3bz+y}aBnjmUWmVC@PaL>maWmRwgvSbi@rE=^g}UJ%5qaoW+^ z6yoEYki_%DjZEi!1j58b7;l#gY9(L?_yum`bVKW9vbwvL#P1Zp2;dp~-njWrkb@x5 z-2{nn%u#z7rzn@@EOM!*C5&Xr`c;MJUm$slnFRCY7lSBlRUVR0zVwt<85(o(crjbN zyu4`RMah1x=n@PYy)xoK@mtfdS?yVJ2yQb#pJ$%bpfC)55pGga%GO#9r*xiYMrpIB zd>hF!^Wf2R>T0$Qq|j8#?$N57eKRd7Tcov+_qTuCIG6U8jv}V!;D5-LH4+cUe1(fk zGv+PiFA`aEt=zkgO=;|VX#*R`#oWzaEVAstkS1$faEe@5;X$I^yK3*abLX}4jK&pD zW=qn~3XWoroj%QA>QYhD?iqdHhbvi9nijvI_Il6(mO{oJT#g-V^F6i(gW)W$x0$Y2 z2`nEc@?YwB+6b`&dx53(0*<3!30XYn*^nq;jvq4xC2D}bpl^fkATwt99z|~s%q0xk zyl85()s&}fbuQHF&~yzi$<-D!Mwl%Xtt}5TQL!P2jar`BZSLOLh-;b|e}2k*d?wQ* z*qSpw|$nQ-qGShkePQTOfzGb+Oz(y z){W90T^?wgLh-U?wjB1ho(&wTt*fyPYs8<9Dw)T0eM;SO7b!`vvWI5_y|a%GJ?SD` zFu6m%y=4(Ycp8bT$M-bg!?DY{(gLOKJtdaZY;1Jk2ZQl%FRVn%vKVmviOIhDiSaajN5405z^$dvM2E%Vipeo9`4VoD`?`Qhy5wvem(K77iSdifqhU38 zr*u-BCF>hCo1ycn;ScUf(LPvfhDmX>kmkCz;hJ0B6pHhz!B1Hg3VowsJYln7aop-- zXS{O9E!&-e0m>dEuUFv{1@~1)C{bFqJ$6pW`%^=DxlNV5MA@2F9d|=r{|O1QnVW;9BbVT zqEMB+j1`j(FF1<5k3c()~Mni~GO|0*N@ceBNCFn`v zY(vPagjtV=4rUzgjGO>U;9R~^sfthb8b@Cd!Y~Y)ztiSj4nRq%{R$V3xrQ7Lw+N;A z>(}EoNn+Dix!at5|L;mj-if}g;tQ%l`AvG>gd+H>P!Lc!kw@~jp~FKM+J{H>4B;T< zgT+v9AZNKM;$V2?;Q;X}_X%~Hh8|_ERQ38BWxmI{$9tJm80e0aQQyPp zjNmu#3)$MIXC4iK274cZ>!o0PCFpBy+y_dG{GGHOC5KMvD~`TO*=(9YIGHJ%0{{Li zlK3-m33)Ts@W=x9fsyiDnxn$S{;oW;v*P~^J!t#|us4+m)ZwRZ>`i?EXdzr2+{jgj H0uuiN?8m3n literal 3502 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 ay$jfgp7_^k5&RxR(~jeb!0jMv-hTj$8Y_PQ literal 2624 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 o#O^*R!dcT9_#bQ6U!6}l_zf2F Date: Thu, 22 Oct 2026 19:32:20 +0800 Subject: [PATCH 068/129] =?UTF-8?q?chore(home):=20=E5=8A=A0=E5=AE=BD?= =?UTF-8?q?=E9=A6=96=E9=A1=B5=E8=83=B6=E7=89=87=E5=B8=A6=E7=B4=A0=E6=9D=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/icon/film_reel.png | Bin 1948 -> 2004 bytes assets/icon/film_reel_filled.png | Bin 1766 -> 1707 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/assets/icon/film_reel.png b/assets/icon/film_reel.png index ac62ebb835e0a8d59a8e87e92d478dc1398be231..d9528697dbf4240e7fc74a036ae80c2a1bcb50a4 100644 GIT binary patch literal 2004 zcmb_d{Xf%tAO3DLMjkfi6j5s&X(frRu;gKL%pHkI4by#yWrnC&#XS#qhKecWSY>01 zF+@xUDKE;AHST4eI!HC;>3FKcD(>$&ulrB9fB0OV*ZcGOys!6lUBA4u{d|AY(=pWn z06>r8Me+yl{J#!D6T~Uc$z}l1;!{X&)Z{yJcjdWeWb}*nxX6N{&Z44;t{d7K#$?oQ zvRn+;G1<=#>9fzXRc(;0$Z$hGtJe*|mtl_&ml`V9!h z`U$r!CiTk2WsC#Ii#kL(l%!&DZ0$|kBaZfhmjl?S+E$?xl4l3S9P11~z1jiDA<4i~ z>!jgy0pJT;ML!+I)+5QJVK`O$H)qIY#KdARIp>uDKu7X!ph##p*UI)p-KXKeI&9xn zzb4Ke$Ph4*l-JjE`wq6?jd>*Mr2oKXNb4Vy!$l28`oFpZdt10JNM1&z{#TqJEvWY_ zkO$N`pPBej{|1Mq+jhO4Bs`X{V>&=D(OM|v7Ud%r9*EbesQhL| zeOUV}JbwD^AdXQbrcbktB1Ms@wDEnH+DNEAhv&+gs_0t9>aTBR4#3Kepe zzm_=KW6N7E4zqP;xK=!cV`D~bx!t?a+?ohg60D9DLfJC1XujT`&$AwN&<|`24YW~2 zA6YUVeEZ`@+t2DavD2CR)bl0c^|P?^r(k{0%htatUq|g&B5xTaY^^*%_hjo5PpZ+J zP++f*<3@gVXNAv@!&!qi!wt8`Aq$CSn(dEk`?B4Sd!?lNo1By6DY|lxy*D}$B)3U0 zYC3W0?zT_T(vYTPBf=c-(0hmRZm4PKyU*I=SyjE7BK;+4D*eotec$Ui@h_E zcK+L}qc!S9#i!|VWt3%(S6duax1N|*Gd4*1}eRj2t z#(FN1{R%jSXVSzDdFs-mucH;=w(C;DD$aEBw08q8S4e`Ca}$T3}`#5_sSl7c@zDXO0To%PL-<7@%rAK3ZwYEfNS1qIE)@WUR znZ24CQrzfBGrEO~+R7X4x@=@NEUIqCX(OyC zZL*+RMzIWQjwi4K_Aha7F}JGEQAhuE;hMEOGclIgke4z1@70jN5_o*`{-R?0*}OJ0 ztJwpsl{8$}h?zv28NvGEk{K$<1BR4%&3^U4a&5#Qb7S|bD~ya=2{3xoz7#$^nC~3p z^v0dJ{7umK7)HA0`iPLBRN;kL}ue(%3-i#d4_OmmQr+&TCmMT*l6;IaHB)m_zQaN z|En1!2V;TL_)#2XBsg)?->I?PnD(Gos6=LtSqd0(0Ht~kYDDynm~p#7+iTGb))NZo z8#rcxl*m8=A5f^CM79hjZp>iaL;YpnewP`IPqDDZ{0E`Kbx4O|uL(F_51~3z5lf{} zhQaWrNVer&hSUN2pM^qh$ngR7L;?bfs7Ot!lxL4wVKh|OB({_qm+ogN!OHLC(2{zUrZ zl|7IOt9yu@wcHn5^!iyhdBs~}QMBpiT`Ed^aVKXGLexGLG@k%}7Y6JETCK3-BIDtN z&A3d?)#=|wVrR&kFzrE&gErXq#c|Edb`5YE6yk6|aZ9_$ h|5hWoANzvWwdyFZ4wOo+ekr821I+)~s z`CJc%T#8L)nhK*Ccd7QcG>V-|PHvq~&w0*y|9IbLz3cZ|zqQ_HJ?|3j;qJ-AFfTZg zY))V0etyuO5qL_qx3e7Fm*tN>e7HJH)<7pA$svr52t^zw{WFntejBcg?mnI{>P^|F z@ig$0EUG%_Li|0k$zXACWVD#`hH-f!=I!Zn&~LSUc5+cK##co<7}7dsC4 zsd2L0(HvQ)Y?mX)WF8^9)PVW!n@>gafvXG>=}8(g1&t|=Vk)jngJA>@2+kdU{L%+GNk#IS=DZ+Ih0}@BBoiLy z2PIq@YH?r|!QnBS&Yx3&<$Cxt(u)lj3aoGN2WmX;Q* zJjr=)6dVI#qt`~<$-Zl9R;#^>wtnUEjbkiU=Q zn7H$3T8;HvN0Mnum3L`%Z9duN=}1CKKJKTy?H0m`=x;KTip)7P zxhn+LYzH^Xt~Hf?H+5hGu~4x2S{G4yM5N9d7n~)PRJ#*tcdy&n?cIB$Dzjz9qn(-f zv%H+_~}~CGEIZu+;H>45te-BL2Ujp*yab_dNi7|u>RP1y;flU zFoFM4%fm{jJ8%eCTrcL>`IeI;vmVU}0_M1jF(_35`~-a)eA{h9md{Dl=D=+Hpw)}k z$JXj{XOz2Q<+E zxA9;h4pz!;oTj%t8^LdO_m5AeS=}MP-@SRR^?b)?KI#=EDFK;<=R?#p_o+PV@9Eg6 z=smy#t&_=~*6qv_UY0WfqK3wL%a9h_xybT4bkFCMEjO{^@^$vmOn_J3Dbb@I{AHs% zq>L@IX#A51Y!j}x85gR%bYQty@#(>Gb4s3WRKQ2f`1cnU;-&3qaQ%^yUem<-0`bCN zoq^(jeyabjalt3w;aDJj@e9Frp{#mxj7ti~PIBKA6Pe)-hOuc3ACM5Yz&sgJkF!rB z#+tLfQ}P&EubThho)PQ8Ix>yQqJ%W(tqtdbx>hRIvmQ5PQ6lt-gz_}(;h1yKiBK~t%}=KZ zr%sd@zbe?_=>5MdA$fcHj*1Vc0_8X9aT|)@r$mNB;l%DqJBAhyu5S|>(JR72%16tf z-YDcTV;n=&A^b^?b_h~?7Aj-SIdCU&fFlQ$4kM{TUO5!w7<3R+Ol77A#wN^!aw7Sf z*Bg8!1X;FL_Wi+H!Q>9jjtc&HSgID zLSIUvBFFXJQ6PV*Htc9<%?W?WD)$lP9St?Q!mNGmXOR6t*DcP|l*~YNC6D@?KxGCF ze<)#Vo|}Fk0x^ej!SxC-t_Jk6H0%SV27dM$50a=8Itrt&k~dqY;r1rGje&pv8A14& zu!y*wqVF`%y>GC4Hq}o4N`FtG$wkTkh8{Hj{Mnm|c*@X|VH;x~09uZ_Y+XsU$Ndxj E1A@q>=Kufz diff --git a/assets/icon/film_reel_filled.png b/assets/icon/film_reel_filled.png index 21b61c4ac0b74027a6724efe6063784638f47383..44737284f9cabe1e8bcfdf469380e7ad7c6091e3 100644 GIT binary patch literal 1707 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 Zt^Y>U#E++h@VgLoJBB9$w*q7H{{p9DD_sBp From b22ac6ab045190fdfd1fbd22f96b30ddf56ff0a8 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 19:34:00 +0800 Subject: [PATCH 069/129] =?UTF-8?q?chore(home):=20=E5=86=8D=E6=AC=A1?= =?UTF-8?q?=E5=8A=A0=E5=AE=BD=E9=A6=96=E9=A1=B5=E8=83=B6=E7=89=87=E5=B8=A6?= =?UTF-8?q?=E7=B4=A0=E6=9D=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/icon/film_reel.png | Bin 2004 -> 1970 bytes assets/icon/film_reel_filled.png | Bin 1707 -> 1666 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/assets/icon/film_reel.png b/assets/icon/film_reel.png index d9528697dbf4240e7fc74a036ae80c2a1bcb50a4..771e41f44baa81d999149e694b13f5a814023ee8 100644 GIT binary patch literal 1970 zcmai#dpy(oAIHC&`+Y9Cgksf<)5WD^Icl=lCTn9O)1+{8!4YHCjHM}37`eq=yMl2l{=3j>$omsJ^8)%X~X;O@ZF7r`z zV~TOvJLHfw$lx2n4qo-!y>6K>f_Q}vTkeL3!}FeabYq|V@S8_V9Qp4EK#Gh&uPMHK zgrrmfG(bOIN|;kwe~5?Fy&ZJ!b_INKt1leJ9e{4YZoUDx4US{Ibf=i*WrcjQ(^q(W zU)}FnGrJp@i~9Xq0<%#aA~#WI*d#^db6E|B=7tNfoZL$eMx}0;UDrC!p2+h~XRmmV zPRe0)+lAj8VxeKi>t9=dSE_{50sCU6V_;1?5S#XDul5aw zz!xL=-lxjHOx@;(QU(s>rI90P6tB7Ein@_u3VE3@+fviEL^!atOQdFgWJ_r4b(OhM zh$u>+5LEOctU1i4Gc&WmEH~1<-PHd`4!nxbH|$Ov>BG@&b`EEUgx|Z%l0DMl?Fiu_ zktSMla+qSIX^ym0)7k%I^D^0dwYkrvrER1o^E1=DFuitdqX_ECimclmur!>@s$O{1 zSy9vuT`cWZ_!H3)K0)+#p*Xra#V{ZX z5VR1yL)%WP7tfvRDXUE5z+(5!Mnq8|dC;(!BzBd@n)R!K59?ikoCkdpyLxk{Nh5sL z=31P&sr=%7vEZ|dHePp)eRT7mZi>YUEIp!+!k2-^yJ-4}No~({V`aXmHSIy(%!4j& zd1t~dxcnwNR$wrrrimI4hqHu#nC2>B7+t00WlewyIkx*Z((BCC5m#E#4tws5EA@Ex z)L8nD623hZPi1Cce;u+lvUR0?^OX2*)flO4Sg5XUH>1#EkiTqG?6^&nTz`=I%LiKa z{ea}zi<4_xJIFx?{>m~bj8E-6HXdANey8Ff`t`s?y|WuOlKC}nInOpx$okhUESLh- zCLbV5XS+$eDTVda_IqRu;$1yEj~HCDpw3<=?)QO2|H0o2wR^stbBauQ(el35dZ8|@ z)|zXCP1TPKsjttp&LF5Dn{JH98U|{#Xq~yzZdq1-__i*@G~vRao858fm*NxqQcK*< z+%9FtMUg%3xTmvwU_CLK7mVc!`FnHO`wVA4;n4Xi%9bJe)aXff6^eT`doUu`cE&)P zBbI+c+lAZ`@AUH}9eQS!I+Fd6t8&Y)^+E8JmEHmM{#$aNQURnBRB?!Dv4^ewfwVy>9i@*}x1dV%ABKiOlMnyoBBAsExeqo}VdSsxkE zC%%uC#9YFLg@`xbdpWyfk09cv--qxlhqi@0erN%C1r{8OI%%)xxlxZ4;k&R3Ag1Y0xNYYfWj);&4GP!Nf5cZh6%7FuW(PF9hqpy$k5UKd#_30zXn@Sb zYW9qn%YI(*8T*=u-m0vefFVVwf@QM(+f9TKpD>_NHt>@`sfSl$47O#@8}$|0w+%fo4qCqiwrng%NLx=#X9>c!O+t3F;+SpxVRpR|TcI1Vsh0 zLDx(L3Cb4C$bt;Ryb{}hsucRlcZ+?vLr!1!T`d^Yz}PRDR4^p7oID^d_4J-(U7EE(KgB zX@$C-YaZ6{%X$>WL+f7Me7;d;7y*yH!U&&RQ{4^YQYb9Lrf&d20lLVXOm`DySXD|X zfDaHIJYhgRRY^|!*1Me;Rj)FBLc6z&gRY!JvNr%yz)o@86KD@^CRR0@{NdsK@v7#nm-|a^v`I2PL(l6p)tHYEO&g(KWSf@Y;a#oi!R`Dv z^^GFlU}%*=VpmGixdv_Sjj8A}3jx~_5<8%4&o#RG18A$mjy4I&EjjL%4CjA`-p`Y6 aXYA>vUI_xLdl;O>0PYY0bN8TM+01 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 literal 1707 zcmaJ?dpOg382@2&*(`>XlUU2CQI^8!LQ~69Xw#-PxzxmJMp-er&Em9DM3J)ATq=q# z$2Q@(ltOAv7h{dNlqtDX!pS-1jLvh;bN)GheBbwZ-{<+f&*%L-@B4mF5`6J6$WIUe z0AOBQaef+qcJ}G(YV4qA|2+WEjrPKMkl6f@%k}Ad$Y#y;gVJRtgiups(M#X+PM&6> z)sTI(rMvZ!2bbq!JR%;pHf=;4FtcCDr7z_oFkCo%0%caBw2^6>w6bhah-?rTmo4OMHBnqoVZ9rC>OkGSVrYA7;!?U!jKSCi z?_lcab5OAgyK$PfvGV5c)B8CLjjj^RY>#L_DKO>EQZjiC}p_rby&>yx@MaK^yy1G8z* zjbn6fuq^5Hxbge#PuvPQ)!MX3%oH!M(|J_~o)lGWAswa3b`e)~YRDMNO3WW#NuNati0$J*-4!ZywJ*SK5AHbf2b zcvxF7n2ou2OV0g5RdvKDWpdA|Ciay(k21 zaVt7ve(J>``G(acWGp;(NbI?!-40?!(9kFxfl3g3&0Q%VR zm_{oZl^ z7WM8gRX1-K`PdKch}xf!AuJinXGzvb=H>kXi`-wPQZba^$*;}iiOrfl)p^0sDH(V5 z)nO-E@7i)$Q-LN7j2|mgHQyfFKeo8ZTQ*(fG@aU2afet?8+!<2c)LglwamLNUdyYk zM1?o%Q}ay=#1uNycfN7CR49&1zS{PB1C*K{Aexf!t@NRVX?$$W%;5Xh11)wGEx=Zs;@ZST1E0OVCd>CC_NodF7h8 z>gWF#r^tXO5Bs<)f2UAvA;PTkKS*a}A>9~QjPP!}9k9%QdHKpx!SK+MKjbPFb|Ieh9HH|lOA*Ez&-!mykt}fJY^Lgc; zLT5&Tsrl!G;;#-)?`0y)?X1_;4#(wcXA|Z%zc*iM&v)h)dTM1k|JY8y0Yo(_KI%yo z-i&Z~QkBcfT&C&VgTaD`FdERKZLo4FO{2(?xzLQx^q=Dw=UxyN0uSG-JE!u$v=1GfljY&`yR8a`=fQ|l8-(`JGtKtzWkQ~cea zcX@D~ONSOEqJDTCXL}LD$BvCeDgpON&jQY5c(Zayn-feDn`T9f Date: Thu, 22 Oct 2026 19:42:22 +0800 Subject: [PATCH 070/129] =?UTF-8?q?feat(recommend):=20=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E6=B2=89=E6=B5=B8=E5=BC=8F=E6=8E=A8=E8=8D=90=E9=A1=B5=E8=A7=86?= =?UTF-8?q?=E8=A7=89=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 7 + lib/core/theme/app_colors.dart | 2 + lib/l10n/app_en.arb | 1 + lib/l10n/app_id.arb | 1 + lib/l10n/app_localizations.dart | 6 + lib/l10n/app_localizations_en.dart | 3 + lib/l10n/app_localizations_id.dart | 3 + lib/l10n/app_localizations_zh.dart | 3 + lib/l10n/app_zh.arb | 1 + lib/pages/player/player_view.dart | 1 + .../player/widgets/home_feed_overlay.dart | 311 ++---------- .../widgets/home_feed_recommend_panel.dart | 476 ++++++++++++++++++ lib/pages/recommend/recommend_page.dart | 140 ++---- lib/pages/recommend/recommend_types.dart | 3 + .../recommend/widgets/recommend_top_nav.dart | 124 +++++ test/home/home_preview_layout_test.dart | 98 ++-- test/recommend/recommend_sections_test.dart | 49 ++ 17 files changed, 831 insertions(+), 398 deletions(-) create mode 100644 lib/pages/player/widgets/home_feed_recommend_panel.dart create mode 100644 lib/pages/recommend/recommend_types.dart create mode 100644 lib/pages/recommend/widgets/recommend_top_nav.dart diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index a7d3961..5d7d531 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1168,3 +1168,10 @@ $ flutter test - 公共 token 调整为图标 30、文字 13、图标文字间距 5、内容高度 62。 - 图片首页图标同步使用 30×30;SafeArea 仍在内容区外独立追加,四等分布局不变。 - 尺寸测试断言同步,按当前流程未执行 analyze/test。 + +## 2026-10-22 推荐页沉浸式布局对齐 + +- 推荐/剧单/全部短剧顶部改为视频内居中浮层,17 字号、无下划线,搜索固定右侧 14。 +- 右侧保留收藏/点赞/选集,白色 32 图标、28 间距;评论、分享入口已从该浮层移除。 +- 底部信息重构为标题、真实热度、剧名、题材/集数、可展开简介与渐变观看全集按钮。 +- 新增三语“观看全集”文案;按当前流程未执行 analyze/test。 diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 861b272..26e9aac 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -23,10 +23,12 @@ abstract final class AppColors { static const overlayBar = Color(0x4D000000); static const overlayMedium = Color(0x99000000); static const headerScrim = Color(0x59000000); + static const overlaySecondary = Color(0xFFC7C7CC); /// 主操作、选中态、播放进度(品牌橙)。 static const blue = Color(0xFFFF6B2C); static const primary = blue; + static const actionRed = Color(0xFFFF3B30); /// 主操作按下态。 static const bluePressed = Color(0xFFE55A1F); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a63715b..f5b44c3 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -748,5 +748,6 @@ }, "watchedToEpisode": "Watched to Episode {episode}", "@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } }, + "homeWatchAll": "Watch all", "confirmRemove": "Remove this item?" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 4250d91..025c1e8 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -705,5 +705,6 @@ }, "watchedToEpisode": "Ditonton hingga Episode {episode}", "@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } }, + "homeWatchAll": "Tonton semua", "confirmRemove": "Hapus item ini?" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 39502e6..2b3ae8a 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3256,6 +3256,12 @@ abstract class AppLocalizations { /// **'Watched to Episode {episode}'** String watchedToEpisode(int episode); + /// No description provided for @homeWatchAll. + /// + /// In en, this message translates to: + /// **'Watch all'** + String get homeWatchAll; + /// No description provided for @confirmRemove. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 458b70a..ef3ebad 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1699,6 +1699,9 @@ class AppLocalizationsEn extends AppLocalizations { return 'Watched to Episode $episode'; } + @override + String get homeWatchAll => 'Watch all'; + @override String get confirmRemove => 'Remove this item?'; } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 1a7e93d..f76ec32 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1701,6 +1701,9 @@ class AppLocalizationsId extends AppLocalizations { return 'Ditonton hingga Episode $episode'; } + @override + String get homeWatchAll => 'Tonton semua'; + @override String get confirmRemove => 'Hapus item ini?'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 96d0ee5..b99bb6e 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1675,6 +1675,9 @@ class AppLocalizationsZh extends AppLocalizations { return '已看至 第 $episode 集'; } + @override + String get homeWatchAll => '观看全集'; + @override String get confirmRemove => '确定移除这条内容吗?'; } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 7f5c954..e98372e 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -667,5 +667,6 @@ }, "watchedToEpisode": "已看至 第 {episode} 集", "@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } }, + "homeWatchAll": "观看全集", "confirmRemove": "确定移除这条内容吗?" } diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index cebd854..2197109 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -621,6 +621,7 @@ class _PlayerPageState extends State if (!mounted || !widget.active) return; logic.session.setUserWantsPlay(true); }, + onEpisodes: () => logic.showEpisodes(context), onMore: _openCurrentMore, onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null, onSeek: ready diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index 26a7d53..9ebfa79 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -9,30 +9,23 @@ import 'package:video_player/video_player.dart'; import '../../../core/data/models/drama.dart'; import '../../../core/data/services/playback_thumbnail_service.dart'; import '../../../core/data/services/sprite_thumbnail_axis.dart'; -import '../../../core/formatting/count_format.dart'; -import '../../../core/formatting/localized_content.dart'; import '../../../core/theme/app_colors.dart'; import '../../../l10n/app_localizations.dart'; -import 'interaction_rail.dart'; +import 'home_feed_recommend_panel.dart'; import 'loading_progress_track.dart'; import 'scrub_preview_card.dart'; import 'seek_progress_bar.dart'; part 'home_feed_overlay_parts.dart'; -const Color _overlayPill = AppColors.overlaySoft; -const Color _fullBarBackground = AppColors.overlayBar; -const double _titleFontSize = 17; -const double _metaFontSize = 12; -const double _tagFontSize = 11; -const double _fullBarHeight = 44; const double _progressHeight = 2; -const double _progressHitHeight = 24; +const double _progressHitHeight = 12; +const double _pageHorizontal = 14; +const double _railGap = 12; +const double _railBottomOffset = 60; const List _boostTextShadows = [ Shadow(color: AppColors.shadow, blurRadius: 5, offset: Offset(0, 1)), ]; -const int _maxTags = 4; -final RegExp _tagSplitter = RegExp(r'[,/、·]'); /// 首页沉浸式 feed 浮层:手势层、右侧互动栏、底部信息与整集进度条。 class HomeFeedOverlay extends StatefulWidget { @@ -53,6 +46,7 @@ class HomeFeedOverlay extends StatefulWidget { required this.onShare, required this.onDetail, required this.onOpenFullPlayer, + this.onEpisodes, required this.onTogglePlay, required this.onBoostStart, required this.onBoostEnd, @@ -86,6 +80,7 @@ class HomeFeedOverlay extends StatefulWidget { final VoidCallback onShare; final VoidCallback onDetail; final VoidCallback onOpenFullPlayer; + final VoidCallback? onEpisodes; final VoidCallback onTogglePlay; final VoidCallback onBoostStart; final VoidCallback onBoostEnd; @@ -124,17 +119,42 @@ class _HomeFeedOverlayState extends State { ), ), if (widget.boosting) - Column(children: [const Spacer(), _boostHint(), _fullSeriesBar()]) - else - Column( - children: [ - const Spacer(), - if (widget.visible) _bottomInfo(), - _fullSeriesBar(), - ], + Positioned(left: 0, right: 0, bottom: 20, child: _boostHint()) + else if (widget.visible) + Positioned( + left: _pageHorizontal, + right: _pageHorizontal, + bottom: 12, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded(child: _bottomInfo()), + const SizedBox(width: _railGap), + Padding( + padding: const EdgeInsets.only(bottom: _railBottomOffset), + child: _rail(), + ), + ], + ), ), - if (widget.visible && !widget.boosting) - Positioned(right: 10, bottom: 56, child: _rail()), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: _FeedProgressBar( + controller: widget.controller, + locked: widget.locked, + height: _progressHeight, + hitHeight: _progressHitHeight, + onScrubStart: widget.onScrubStart, + onSeek: widget.onSeek, + thumbnailService: widget.thumbnailService, + spriteAxisService: widget.spriteAxisService, + thumbnailsUrl: widget.episode.thumbnailsUrl, + episodeId: widget.episode.id, + posterUrl: widget.drama.posterUrl, + ), + ), ], ), ); @@ -179,244 +199,23 @@ class _HomeFeedOverlayState extends State { } Widget _rail() { - return InteractionRail( - items: [ - InteractionRailItem( - key: const Key('rail-save'), - asset: 'assets/icon/rail/rail_save.png', - label: widget.l10n.favorite, - count: widget.favoriteCount, - countKey: const Key('rail-save-count'), - selected: widget.saved, - onTap: widget.onSave, - ), - InteractionRailItem( - key: const Key('rail-comments'), - asset: 'assets/icon/rail/rail_comment.png', - label: widget.l10n.comments, - count: widget.commentCount, - countKey: const Key('rail-comments-count'), - onTap: widget.onComments, - ), - InteractionRailItem( - key: const Key('rail-like'), - asset: 'assets/icon/rail/rail_like.png', - label: widget.l10n.like, - count: widget.likeCount, - countKey: const Key('rail-like-count'), - selected: widget.liked, - onTap: widget.onLike, - ), - InteractionRailItem( - key: const Key('rail-share'), - asset: 'assets/icon/rail/rail_share.png', - label: widget.l10n.share, - onTap: widget.onShare, - ), - ], + return HomeFeedActionRail( + saved: widget.saved, + liked: widget.liked, + favoriteCount: widget.favoriteCount, + likeCount: widget.likeCount, + onSave: widget.onSave, + onLike: widget.onLike, + onEpisodes: widget.onEpisodes ?? widget.onOpenFullPlayer, ); } Widget _bottomInfo() { - return Padding( - padding: const EdgeInsets.fromLTRB(14, 0, 56, 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - _seriesMetaRow(), - const SizedBox(height: 2), - _titleRow(), - const SizedBox(height: 6), - _tagsRow(), - if (widget.authorNote.isNotEmpty) ...[ - const SizedBox(height: 6), - Text( - '${widget.l10n.authorNoteLabel}: ${widget.authorNote}', - key: const Key('home-feed-author-note'), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: AppColors.muted, fontSize: 11), - ), - ], - ], - ), - ); - } - - Widget _seriesMetaRow() { - return GestureDetector( - key: const Key('home-feed-series-meta'), - onTap: widget.onDetail, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - widget.l10n.seriesMeta( - '${widget.drama.episodeCount}', - formatInteractionCount(widget.drama.viewCount), - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.muted, - fontSize: _metaFontSize, - ), - ), - ), - const Icon( - Icons.chevron_right_rounded, - color: AppColors.muted, - size: 16, - ), - ], - ), - ); - } - - Widget _titleRow() { - return GestureDetector( - onTap: widget.onDetail, - child: Row( - children: [ - Flexible( - child: Text( - localizedDramaTitle(widget.l10n, widget.drama), - key: const Key('home-feed-title'), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontFamily: 'Outfit', - color: AppColors.onPrimary, - fontSize: _titleFontSize, - fontWeight: FontWeight.w800, - shadows: [Shadow(blurRadius: 8, color: AppColors.mediaBlack)], - ), - ), - ), - const Icon( - Icons.chevron_right_rounded, - color: AppColors.onPrimary, - size: 20, - ), - ], - ), - ); - } - - Widget _tagsRow() { - final tags = [ - widget.l10n.episodeLabel(widget.episode.number), - ..._tagValues(), - ].take(_maxTags).toList(); - return Wrap( - key: const Key('home-feed-tags'), - spacing: 6, - runSpacing: 4, - children: [ - for (final tag in tags) - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: _overlayPill, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - tag, - maxLines: 1, - style: const TextStyle( - color: AppColors.onPrimary, - fontSize: _tagFontSize, - ), - ), - ), - ], - ); - } - - List _tagValues() { - final result = []; - for (final value in [widget.drama.genre, widget.drama.category]) { - for (final part in value.split(_tagSplitter)) { - final tag = part.trim(); - if (tag.isEmpty || result.contains(tag)) continue; - result.add(tag); - } - } - return result; - } - - Widget _fullSeriesBar() { - return GestureDetector( - key: const Key('home-feed-full-series'), - onTap: widget.onOpenFullPlayer, - child: SizedBox( - height: _fullBarHeight, - child: Stack( - clipBehavior: Clip.none, - children: [ - Positioned.fill( - child: Container( - key: const Key('home-feed-full-series-bg'), - color: _fullBarBackground, - ), - ), - Positioned.fill( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 14), - child: Row( - children: [ - const Icon( - Icons.play_arrow_rounded, - color: AppColors.onPrimary, - size: 14, - ), - const SizedBox(width: 6), - Expanded( - child: Text( - widget.l10n.watchFullSeries( - '${widget.drama.episodeCount}', - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onPrimary, - fontSize: 13, - fontWeight: FontWeight.w600, - ), - ), - ), - const Icon( - Icons.chevron_right_rounded, - color: AppColors.onPrimary, - size: 18, - ), - ], - ), - ), - ), - Positioned( - left: 0, - right: 0, - bottom: 0, - child: _FeedProgressBar( - controller: widget.controller, - locked: widget.locked, - height: _progressHeight, - hitHeight: _progressHitHeight, - onScrubStart: widget.onScrubStart, - onSeek: widget.onSeek, - thumbnailService: widget.thumbnailService, - spriteAxisService: widget.spriteAxisService, - thumbnailsUrl: widget.episode.thumbnailsUrl, - episodeId: widget.episode.id, - posterUrl: widget.drama.posterUrl, - ), - ), - ], - ), - ), + return HomeFeedInfoPanel( + drama: widget.drama, + episode: widget.episode, + onDetail: widget.onDetail, + onOpenFullPlayer: widget.onOpenFullPlayer, ); } } diff --git a/lib/pages/player/widgets/home_feed_recommend_panel.dart b/lib/pages/player/widgets/home_feed_recommend_panel.dart new file mode 100644 index 0000000..5d681e6 --- /dev/null +++ b/lib/pages/player/widgets/home_feed_recommend_panel.dart @@ -0,0 +1,476 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/drama.dart'; +import '../../../core/formatting/count_format.dart'; +import '../../../core/formatting/localized_content.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../home/widgets/home_drama_classification.dart'; + +const double _headlineFont = 26; +const double _seriesFont = 17; +const double _metaFont = 12; +const double _tagFont = 12; +const double _synopsisFont = 13; +const double _watchAllHeight = 44; +const double _actionGap = 28; +const double _actionIcon = 32; +const double _actionWidth = 64; +const List _textShadows = [ + Shadow(color: AppColors.shadow, blurRadius: 6, offset: Offset(0, 1)), +]; +final RegExp _tagSplitter = RegExp(r'[,/、·]'); + +class HomeFeedInfoPanel extends StatefulWidget { + const HomeFeedInfoPanel({ + super.key, + required this.drama, + required this.episode, + required this.onDetail, + required this.onOpenFullPlayer, + }); + + final Drama drama; + final Episode episode; + final VoidCallback onDetail; + final VoidCallback onOpenFullPlayer; + + @override + State createState() => _HomeFeedInfoPanelState(); +} + +class _HomeFeedInfoPanelState extends State { + bool _synopsisExpanded = false; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final dramaTitle = localizedDramaTitle(l10n, widget.drama); + final episodeTitle = widget.episode.title.trim(); + final headline = episodeTitle.isEmpty ? dramaTitle : episodeTitle; + final synopsis = localizedDramaSynopsis(l10n, widget.drama).trim(); + final genres = _genreValues(); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + headline, + key: const Key('home-feed-title'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: _headlineFont, + fontWeight: FontWeight.w700, + height: 1.2, + letterSpacing: 0, + shadows: _textShadows, + ), + ), + const SizedBox(height: 8), + _metaPills(l10n), + if (_hasMeta()) const SizedBox(height: 8), + GestureDetector( + key: const Key('home-feed-series-name'), + behavior: HitTestBehavior.opaque, + onTap: widget.onDetail, + child: Row( + children: [ + Expanded( + child: Text( + dramaTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: _seriesFont, + fontWeight: FontWeight.w600, + shadows: _textShadows, + ), + ), + ), + const Icon( + Icons.chevron_right_rounded, + size: 16, + color: AppColors.overlaySecondary, + ), + ], + ), + ), + const SizedBox(height: 8), + _tagMetaRow(l10n, genres), + if (synopsis.isNotEmpty) ...[ + const SizedBox(height: 8), + _synopsis(l10n, synopsis), + ], + const SizedBox(height: 12), + _watchAll(l10n), + ], + ); + } + + bool _hasMeta() { + return isHotDrama(widget.drama) || widget.drama.viewCount > 0; + } + + Widget _metaPills(AppLocalizations l10n) { + final hot = isHotDrama(widget.drama); + final views = widget.drama.viewCount; + if (!hot && views <= 0) return const SizedBox.shrink(); + return Wrap( + key: const Key('home-feed-meta-pills'), + spacing: 6, + runSpacing: 4, + children: [ + if (hot) + _pill( + key: const Key('home-feed-ranking-pill'), + background: AppColors.error, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.local_fire_department_rounded, + size: 12, + color: AppColors.textPrimary, + ), + const SizedBox(width: 2), + Text( + l10n.rankingHot, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: _metaFont, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + if (views > 0) + _pill( + key: const Key('home-feed-view-pill'), + background: AppColors.overlaySoft, + child: Text( + formatInteractionCount(views), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: _metaFont, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ); + } + + Widget _pill({ + required Key key, + required Color background, + required Widget child, + }) { + return Container( + key: key, + height: 22, + padding: const EdgeInsets.symmetric(horizontal: 8), + alignment: Alignment.center, + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(11), + ), + child: child, + ); + } + + Widget _tagMetaRow(AppLocalizations l10n, List genres) { + return Row( + key: const Key('home-feed-tags'), + children: [ + if (isHotDrama(widget.drama)) ...[ + Container( + key: const Key('home-feed-hot-badge'), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AppColors.error, + borderRadius: BorderRadius.circular(3), + ), + child: Text( + l10n.homeBadgeHot, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 6), + ], + Expanded( + child: Text( + genres.join(' | '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.overlaySecondary, + fontSize: _tagFont, + ), + ), + ), + const SizedBox(width: 6), + Text( + l10n.favoriteEpisodeProgress( + widget.episode.number, + widget.drama.episodeCount, + ), + key: const Key('home-feed-episode-progress'), + style: const TextStyle( + color: AppColors.overlaySecondary, + fontSize: _metaFont, + ), + ), + ], + ); + } + + Widget _synopsis(AppLocalizations l10n, String synopsis) { + const style = TextStyle( + color: AppColors.overlaySecondary, + fontSize: _synopsisFont, + height: 1.35, + shadows: _textShadows, + ); + return LayoutBuilder( + builder: (context, constraints) { + final painter = TextPainter( + text: TextSpan(text: synopsis, style: style), + maxLines: 2, + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + )..layout(maxWidth: constraints.maxWidth); + final overflows = painter.didExceedMaxLines; + if (!overflows) { + return Text( + synopsis, + key: const Key('home-feed-synopsis'), + style: style, + ); + } + if (_synopsisExpanded) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + synopsis, + key: const Key('home-feed-synopsis'), + style: style, + ), + const SizedBox(height: 2), + _synopsisToggle( + key: const Key('home-feed-synopsis-more'), + label: l10n.showLess, + ), + ], + ); + } + return Stack( + children: [ + Padding( + padding: const EdgeInsets.only(right: 40), + child: Text( + synopsis, + key: const Key('home-feed-synopsis'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: style, + ), + ), + Positioned( + right: 0, + bottom: 0, + child: _synopsisToggle( + key: const Key('home-feed-synopsis-more'), + label: l10n.more, + ), + ), + ], + ); + }, + ); + } + + Widget _synopsisToggle({required Key key, required String label}) { + return GestureDetector( + key: key, + onTap: () => setState(() => _synopsisExpanded = !_synopsisExpanded), + child: Text( + label, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: _synopsisFont, + fontWeight: FontWeight.w600, + ), + ), + ); + } + + Widget _watchAll(AppLocalizations l10n) { + return SizedBox( + width: double.infinity, + height: _watchAllHeight, + child: DecoratedBox( + key: const Key('home-feed-full-series-bg'), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [AppColors.actionRed, AppColors.blue], + ), + borderRadius: BorderRadius.circular(8), + ), + child: Material( + color: AppColors.transparent, + child: InkWell( + key: const Key('home-feed-full-series'), + onTap: widget.onOpenFullPlayer, + borderRadius: BorderRadius.circular(8), + child: Center( + child: Text( + l10n.homeWatchAll, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ), + ); + } + + List _genreValues() { + final result = []; + for (final value in [widget.drama.genre, widget.drama.category]) { + for (final part in value.split(_tagSplitter)) { + final tag = part.trim(); + if (tag.isEmpty || result.contains(tag)) continue; + result.add(tag); + } + } + return result.take(3).toList(growable: false); + } +} + +class HomeFeedActionRail extends StatelessWidget { + const HomeFeedActionRail({ + super.key, + required this.saved, + required this.liked, + required this.onSave, + required this.onLike, + required this.onEpisodes, + this.favoriteCount, + this.likeCount, + }); + + final bool saved; + final bool liked; + final int? favoriteCount; + final int? likeCount; + final VoidCallback onSave; + final VoidCallback onLike; + final VoidCallback onEpisodes; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return SizedBox( + width: _actionWidth, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _action( + key: const Key('rail-save'), + icon: saved + ? Icons.bookmark_rounded + : Icons.bookmark_border_rounded, + label: favoriteCount == null + ? l10n.favorite + : formatInteractionCount(favoriteCount!), + labelKey: const Key('rail-save-count'), + onTap: onSave, + ), + const SizedBox(height: _actionGap), + _action( + key: const Key('rail-like'), + icon: liked + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, + label: likeCount == null + ? l10n.like + : formatInteractionCount(likeCount!), + labelKey: const Key('rail-like-count'), + onTap: onLike, + ), + const SizedBox(height: _actionGap), + _action( + key: const Key('rail-episodes'), + icon: Icons.layers_rounded, + label: l10n.episodes, + labelKey: const Key('rail-episodes-label'), + labelFontSize: 11, + onTap: onEpisodes, + ), + ], + ), + ); + } + + Widget _action({ + required Key key, + required IconData icon, + required String label, + required Key labelKey, + required VoidCallback onTap, + double labelFontSize = 12, + }) { + return GestureDetector( + key: key, + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: _actionIcon, + color: AppColors.textPrimary, + shadows: const [ + Shadow( + color: AppColors.shadowSoft, + blurRadius: 4, + offset: Offset(0, 1), + ), + ], + ), + const SizedBox(height: 2), + Text( + label, + key: labelKey, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + color: AppColors.textPrimary, + fontSize: labelFontSize, + fontWeight: FontWeight.w500, + height: 1, + shadows: _textShadows, + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/recommend/recommend_page.dart b/lib/pages/recommend/recommend_page.dart index 803e7f7..75d2d15 100644 --- a/lib/pages/recommend/recommend_page.dart +++ b/lib/pages/recommend/recommend_page.dart @@ -13,14 +13,13 @@ import '../../core/data/services/home_feed_controller.dart'; import '../../core/data/services/wallet_controller.dart'; import '../../core/navigation/fade_page_route.dart'; import '../../core/theme/app_colors.dart'; -import '../../core/theme/app_dimens.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; import '../player/player_view.dart'; +import 'recommend_types.dart'; import 'widgets/all_dramas_filter_grid.dart'; import 'widgets/recommend_sections_view.dart'; - -enum RecommendInnerTab { feed, playlists, allDramas } +import 'widgets/recommend_top_nav.dart'; class RecommendPage extends StatefulWidget { const RecommendPage({ @@ -50,38 +49,49 @@ class _RecommendPageState extends State { return ColoredBox( key: const Key('recommend-page'), color: AppColors.appBackground, - child: SafeArea( - bottom: false, - child: Column( - children: [ - _header(l10n), - Expanded( - child: ListenableBuilder( - listenable: Listenable.merge([ - widget.feedController, - categories, - ]), - builder: (context, _) => IndexedStack( - index: _tab.index, - children: [ - _recommendFeed(context), + child: Stack( + children: [ + Positioned.fill( + child: ListenableBuilder( + listenable: Listenable.merge([widget.feedController, categories]), + builder: (context, _) => IndexedStack( + index: _tab.index, + children: [ + _recommendFeed(context), + _withTopNavSpace( RecommendSectionsView( feedController: widget.feedController, categories: categories, onOpenDrama: _openDrama, onMore: _handleMore, ), + ), + _withTopNavSpace( AllDramasFilterGrid( feedController: widget.feedController, onOpenDrama: _openDrama, onOpenFilter: widget.onOpenFilter, ), - ], - ), + ), + ], ), ), - ], - ), + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: SafeArea( + bottom: false, + child: RecommendTopNav( + selected: _tab, + onVideo: _tab == RecommendInnerTab.feed, + onSelected: (tab) => setState(() => _tab = tab), + onSearch: widget.onSearch, + ), + ), + ), + ], ), ); } @@ -129,88 +139,12 @@ class _RecommendPageState extends State { ); } - Widget _header(AppLocalizations l10n) { - final items = [ - (RecommendInnerTab.feed, l10n.recommendTopTab, 'recommend'), - (RecommendInnerTab.playlists, l10n.playlistTopTab, 'playlist'), - (RecommendInnerTab.allDramas, l10n.allDramasTopTab, 'all'), - ]; - return SizedBox( - key: const Key('recommend-top-nav'), - height: 56, + Widget _withTopNavSpace(Widget child) { + return SafeArea( + bottom: false, child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.feedPageHorizontal, - ), - child: Row( - children: [ - Expanded( - child: Row( - children: [ - for (final item in items) - Expanded( - child: _tabButton( - key: Key('recommend-tab-${item.$3}'), - label: item.$2, - selected: _tab == item.$1, - onTap: () => setState(() => _tab = item.$1), - ), - ), - ], - ), - ), - const SizedBox(width: AppDimens.spaceSm), - IconButton( - key: const Key('recommend-search'), - tooltip: l10n.search, - onPressed: widget.onSearch, - iconSize: 24, - color: AppColors.textPrimary, - icon: const Icon(Icons.search_rounded), - ), - ], - ), - ), - ); - } - - Widget _tabButton({ - required Key key, - required String label, - required bool selected, - required VoidCallback onTap, - }) { - return InkWell( - key: key, - onTap: onTap, - child: Padding( - padding: const EdgeInsets.only(right: AppDimens.spaceSm), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: selected - ? AppColors.textPrimary - : AppColors.textTertiary, - fontSize: AppDimens.homePrimaryTabFont, - fontWeight: selected ? FontWeight.w700 : FontWeight.w400, - ), - ), - const SizedBox(height: 3), - Container( - width: selected ? 20 : 0, - height: 2, - decoration: BoxDecoration( - color: AppColors.textPrimary, - borderRadius: BorderRadius.circular(AppDimens.pillRadius), - ), - ), - ], - ), + padding: const EdgeInsets.only(top: RecommendTopNav.height), + child: child, ), ); } diff --git a/lib/pages/recommend/recommend_types.dart b/lib/pages/recommend/recommend_types.dart new file mode 100644 index 0000000..8c75b4a --- /dev/null +++ b/lib/pages/recommend/recommend_types.dart @@ -0,0 +1,3 @@ +// Author: fruit.com.cn + +enum RecommendInnerTab { feed, playlists, allDramas } diff --git a/lib/pages/recommend/widgets/recommend_top_nav.dart b/lib/pages/recommend/widgets/recommend_top_nav.dart new file mode 100644 index 0000000..328b554 --- /dev/null +++ b/lib/pages/recommend/widgets/recommend_top_nav.dart @@ -0,0 +1,124 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../l10n/app_localizations.dart'; +import '../recommend_types.dart'; + +const double _navHeight = 56; +const double _tabGap = 22; +const double _tabFont = 17; +const double _searchHitSize = 40; +const double _searchIconSize = 26; + +class RecommendTopNav extends StatelessWidget { + const RecommendTopNav({ + super.key, + required this.selected, + required this.onSelected, + required this.onSearch, + this.onVideo = false, + }); + + static const double height = _navHeight; + + final RecommendInnerTab selected; + final ValueChanged onSelected; + final VoidCallback onSearch; + final bool onVideo; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final items = [ + (RecommendInnerTab.feed, l10n.recommendTopTab, 'recommend'), + (RecommendInnerTab.playlists, l10n.playlistTopTab, 'playlist'), + (RecommendInnerTab.allDramas, l10n.allDramasTopTab, 'all'), + ]; + final shadows = onVideo + ? const [Shadow(color: AppColors.mediaBlack, blurRadius: 6)] + : null; + return SizedBox( + key: const Key('recommend-top-nav'), + height: height, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.feedPageHorizontal, + ), + child: Stack( + alignment: Alignment.center, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var index = 0; index < items.length; index++) ...[ + if (index > 0) const SizedBox(width: _tabGap), + _tab( + key: Key('recommend-tab-${items[index].$3}'), + label: items[index].$2, + selected: selected == items[index].$1, + shadows: shadows, + onTap: () => onSelected(items[index].$1), + ), + ], + ], + ), + Align( + alignment: Alignment.centerRight, + child: SizedBox.square( + dimension: _searchHitSize, + child: IconButton( + key: const Key('recommend-search'), + tooltip: l10n.search, + onPressed: onSearch, + padding: EdgeInsets.zero, + constraints: const BoxConstraints.tightFor( + width: _searchHitSize, + height: _searchHitSize, + ), + iconSize: _searchIconSize, + color: AppColors.textPrimary, + icon: const Icon(Icons.search_rounded), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _tab({ + required Key key, + required String label, + required bool selected, + required List? shadows, + required VoidCallback onTap, + }) { + return GestureDetector( + key: key, + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: SizedBox( + height: height, + child: Center( + child: Text( + label, + maxLines: 1, + style: TextStyle( + color: selected + ? AppColors.textPrimary + : AppColors.textTabInactive, + fontSize: _tabFont, + fontWeight: selected ? FontWeight.w700 : FontWeight.w400, + letterSpacing: 0, + shadows: shadows, + ), + ), + ), + ), + ); + } +} diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 5382579..f198ff7 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -291,7 +291,10 @@ void main() { const drama = Drama( id: 'feed-drama', title: 'Feed Drama', - synopsis: 'synopsis', + synopsis: + 'A long synopsis follows the heroine through a dangerous city, ' + 'a hidden identity, and a final choice that changes everyone around ' + 'her forever.', posterUrl: '', genre: 'Romance', category: 'Urban', @@ -302,6 +305,7 @@ void main() { favoriteCount: 32000, commentCount: 12, ); + var episodesOpened = 0; await tester.pumpWidget( MaterialApp( theme: AppTheme.light(), @@ -337,6 +341,7 @@ void main() { onShare: () {}, onDetail: () {}, onOpenFullPlayer: () {}, + onEpisodes: () => episodesOpened++, onMore: () {}, ), ), @@ -347,13 +352,28 @@ void main() { expect( tester.widget(find.byKey(const Key('home-feed-title'))).data, - 'Feed Drama', + 'Ignored Episode Title', ); + expect(find.text('Feed Drama'), findsOneWidget); expect(find.byKey(const Key('home-feed-info-card')), findsNothing); expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget); expect(find.byKey(const Key('home-feed-tags')), findsOneWidget); - expect(find.text('Episode 2'), findsOneWidget); + expect(find.byKey(const Key('home-feed-synopsis-more')), findsOneWidget); + await tester.tap(find.byKey(const Key('home-feed-synopsis-more'))); + await tester.pump(); + expect( + tester.widget(find.byKey(const Key('home-feed-synopsis'))).maxLines, + isNull, + ); + expect( + tester + .widget(find.byKey(const Key('home-feed-episode-progress'))) + .data, + 'Episode 2 / 8 total', + ); expect(find.textContaining('41.9万'), findsOneWidget); + expect(find.byKey(const Key('home-feed-ranking-pill')), findsOneWidget); + expect(find.byKey(const Key('home-feed-view-pill')), findsOneWidget); expect( tester.widget(find.byKey(const Key('rail-like-count'))).data, '1006', @@ -363,43 +383,38 @@ void main() { '3.2万', ); expect( - tester.widget(find.byKey(const Key('rail-comments-count'))).data, - '12', - ); - String railAsset(Key key) { - final image = tester.widget( - 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', + find.descendant( + of: find.byKey(const Key('rail-like')), + matching: find.byIcon(Icons.favorite_rounded), + ), + findsOneWidget, ); expect( - tester - .widget( - find.descendant( - of: find.byKey(const Key('rail-share')), - matching: find.byType(Image), - ), - ) - .colorBlendMode, - BlendMode.srcIn, + find.descendant( + of: find.byKey(const Key('rail-save')), + matching: find.byIcon(Icons.bookmark_border_rounded), + ), + findsOneWidget, ); + expect(find.byKey(const Key('rail-episodes')), findsOneWidget); + expect( + find.descendant( + of: find.byKey(const Key('rail-episodes')), + matching: find.byIcon(Icons.layers_rounded), + ), + findsOneWidget, + ); + expect(find.byKey(const Key('rail-comments')), findsNothing); + expect(find.byKey(const Key('rail-share')), findsNothing); + expect(find.text('Comments'), findsNothing); expect(find.byKey(const Key('progress-loading')), findsNothing); expect( find.byKey(const Key('home-feed-progress-inactive')), findsOneWidget, ); expect(find.byKey(const Key('home-feed-progress-thumb')), findsNothing); + await tester.tap(find.byKey(const Key('rail-episodes'))); + expect(episodesOpened, 1); }); testWidgets('should_show_static_track_without_seek_when_feed_is_locked', ( @@ -597,14 +612,19 @@ void main() { final bar = tester.getRect(find.byKey(const Key('home-feed-full-series'))); expect(bar.height, 44); + final buttonBackground = + tester + .widget( + find.byKey(const Key('home-feed-full-series-bg')), + ) + .decoration + as BoxDecoration; + expect((buttonBackground.gradient! as LinearGradient).colors, [ + AppColors.actionRed, + AppColors.blue, + ]); expect( - tester - .widget(find.byKey(const Key('home-feed-full-series-bg'))) - .color, - const Color(0x4D000000), - ); - expect( - tester.getRect(find.textContaining('Watch full series')).center.dy, + tester.getRect(find.text('Watch all')).center.dy, closeTo(bar.center.dy, 2), ); expect( @@ -613,7 +633,7 @@ void main() { ); expect( tester.getSize(find.byKey(const Key('home-feed-progress'))).height, - greaterThanOrEqualTo(24), + 12, ); expect( tester diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart index 4f7e973..d7aff3d 100644 --- a/test/recommend/recommend_sections_test.dart +++ b/test/recommend/recommend_sections_test.dart @@ -35,6 +35,55 @@ void main() { expect(find.byKey(const Key('recommend-tab-playlist')), findsOneWidget); expect(find.byKey(const Key('recommend-tab-all')), findsOneWidget); expect(find.byKey(const Key('recommend-feed-player')), findsOneWidget); + final recommendText = tester.widget( + find.descendant( + of: find.byKey(const Key('recommend-tab-recommend')), + matching: find.text('Recommend'), + ), + ); + expect(recommendText.style?.fontSize, 17); + expect(recommendText.style?.fontWeight, FontWeight.w700); + expect(recommendText.style?.color, AppColors.textPrimary); + final playlistText = tester.widget( + find.descendant( + of: find.byKey(const Key('recommend-tab-playlist')), + matching: find.text('Playlists'), + ), + ); + expect(playlistText.style?.fontSize, 17); + expect(playlistText.style?.fontWeight, FontWeight.w400); + expect(playlistText.style?.color, AppColors.textTabInactive); + expect( + tester + .widget(find.byKey(const Key('recommend-search'))) + .iconSize, + 26, + ); + final topNavRect = tester.getRect( + find.byKey(const Key('recommend-top-nav')), + ); + expect( + tester.getRect(find.byKey(const Key('recommend-search'))).right, + closeTo(topNavRect.right - AppDimens.feedPageHorizontal, 0.1), + ); + final tabGroupLeft = tester + .getRect(find.byKey(const Key('recommend-tab-recommend'))) + .left; + final tabGroupRight = tester + .getRect(find.byKey(const Key('recommend-tab-all'))) + .right; + expect( + (tabGroupLeft + tabGroupRight) / 2, + closeTo(topNavRect.center.dx, 0.1), + ); + expect( + tester + .getRect(find.byKey(const Key('recommend-top-nav'))) + .overlaps( + tester.getRect(find.byKey(const Key('recommend-feed-player'))), + ), + isTrue, + ); final player = tester.widget( find.byKey(const Key('recommend-feed-player')), ); From 0c06a6dccdb3e3b7c9e4dced8b972f0a6bce258a Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 19:46:44 +0800 Subject: [PATCH 071/129] =?UTF-8?q?fix(recommend):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E7=83=AD=E6=92=AD=E4=BF=A1=E6=81=AF=E8=A1=8C=E4=B8=8E=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E6=A0=8F=E4=BD=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 2 ++ lib/l10n/app_en.arb | 3 +++ lib/l10n/app_id.arb | 3 +++ lib/l10n/app_localizations.dart | 12 ++++++++++++ lib/l10n/app_localizations_en.dart | 8 ++++++++ lib/l10n/app_localizations_id.dart | 8 ++++++++ lib/l10n/app_localizations_zh.dart | 8 ++++++++ lib/l10n/app_zh.arb | 3 +++ lib/pages/player/widgets/home_feed_overlay.dart | 7 ++++++- .../widgets/home_feed_recommend_panel.dart | 13 ++++++++++--- test/home/home_preview_layout_test.dart | 16 +++++++++++++++- 11 files changed, 78 insertions(+), 5 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 5d7d531..b98bd9f 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1175,3 +1175,5 @@ $ flutter test - 右侧保留收藏/点赞/选集,白色 32 图标、28 间距;评论、分享入口已从该浮层移除。 - 底部信息重构为标题、真实热度、剧名、题材/集数、可展开简介与渐变观看全集按钮。 - 新增三语“观看全集”文案;按当前流程未执行 analyze/test。 +- 榜单行支持可选排名:有名次显示 `TOPn·热播榜`,无名次回退“热播榜”;热度值改用 `w` 格式。 +- 题材改为空格分隔,集数固定右侧单行;右侧操作栏改为距屏幕边缘 8,底部位置不变。 diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f5b44c3..a19e4ac 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -749,5 +749,8 @@ "watchedToEpisode": "Watched to Episode {episode}", "@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } }, "homeWatchAll": "Watch all", + "hotChartLabel": "Trending", + "hotChartRanked": "TOP{rank} · Trending", + "@hotChartRanked": { "placeholders": { "rank": { "type": "int" } } }, "confirmRemove": "Remove this item?" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 025c1e8..185ecbe 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -706,5 +706,8 @@ "watchedToEpisode": "Ditonton hingga Episode {episode}", "@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } }, "homeWatchAll": "Tonton semua", + "hotChartLabel": "Peringkat populer", + "hotChartRanked": "TOP{rank} · Populer", + "@hotChartRanked": { "placeholders": { "rank": { "type": "int" } } }, "confirmRemove": "Hapus item ini?" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 2b3ae8a..e610f00 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3262,6 +3262,18 @@ abstract class AppLocalizations { /// **'Watch all'** String get homeWatchAll; + /// No description provided for @hotChartLabel. + /// + /// In en, this message translates to: + /// **'Trending'** + String get hotChartLabel; + + /// No description provided for @hotChartRanked. + /// + /// In en, this message translates to: + /// **'TOP{rank} · Trending'** + String hotChartRanked(int rank); + /// No description provided for @confirmRemove. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index ef3ebad..1d25160 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1702,6 +1702,14 @@ class AppLocalizationsEn extends AppLocalizations { @override String get homeWatchAll => 'Watch all'; + @override + String get hotChartLabel => 'Trending'; + + @override + String hotChartRanked(int rank) { + return 'TOP$rank · Trending'; + } + @override String get confirmRemove => 'Remove this item?'; } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index f76ec32..e807c46 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1704,6 +1704,14 @@ class AppLocalizationsId extends AppLocalizations { @override String get homeWatchAll => 'Tonton semua'; + @override + String get hotChartLabel => 'Peringkat populer'; + + @override + String hotChartRanked(int rank) { + return 'TOP$rank · Populer'; + } + @override String get confirmRemove => 'Hapus item ini?'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index b99bb6e..ffba839 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1678,6 +1678,14 @@ class AppLocalizationsZh extends AppLocalizations { @override String get homeWatchAll => '观看全集'; + @override + String get hotChartLabel => '热播榜'; + + @override + String hotChartRanked(int rank) { + return 'TOP$rank·热播榜'; + } + @override String get confirmRemove => '确定移除这条内容吗?'; } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index e98372e..795c67b 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -668,5 +668,8 @@ "watchedToEpisode": "已看至 第 {episode} 集", "@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } }, "homeWatchAll": "观看全集", + "hotChartLabel": "热播榜", + "hotChartRanked": "TOP{rank}·热播榜", + "@hotChartRanked": { "placeholders": { "rank": { "type": "int" } } }, "confirmRemove": "确定移除这条内容吗?" } diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index 9ebfa79..92b000e 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -21,6 +21,7 @@ part 'home_feed_overlay_parts.dart'; const double _progressHeight = 2; const double _progressHitHeight = 12; const double _pageHorizontal = 14; +const double _railRight = 8; const double _railGap = 12; const double _railBottomOffset = 60; const List _boostTextShadows = [ @@ -53,6 +54,7 @@ class HomeFeedOverlay extends StatefulWidget { required this.onMore, this.likeCount, this.favoriteCount, + this.rankingRank, this.commentCount, this.authorNote = '', this.onScrubStart, @@ -69,6 +71,7 @@ class HomeFeedOverlay extends StatefulWidget { final bool liked; final int? likeCount; final int? favoriteCount; + final int? rankingRank; final int? commentCount; final bool saved; final String authorNote; @@ -123,7 +126,7 @@ class _HomeFeedOverlayState extends State { else if (widget.visible) Positioned( left: _pageHorizontal, - right: _pageHorizontal, + right: _railRight, bottom: 12, child: Row( crossAxisAlignment: CrossAxisAlignment.end, @@ -200,6 +203,7 @@ class _HomeFeedOverlayState extends State { Widget _rail() { return HomeFeedActionRail( + key: const Key('home-feed-action-rail'), saved: widget.saved, liked: widget.liked, favoriteCount: widget.favoriteCount, @@ -216,6 +220,7 @@ class _HomeFeedOverlayState extends State { episode: widget.episode, onDetail: widget.onDetail, onOpenFullPlayer: widget.onOpenFullPlayer, + rankingRank: widget.rankingRank, ); } } diff --git a/lib/pages/player/widgets/home_feed_recommend_panel.dart b/lib/pages/player/widgets/home_feed_recommend_panel.dart index 5d681e6..1fd69b3 100644 --- a/lib/pages/player/widgets/home_feed_recommend_panel.dart +++ b/lib/pages/player/widgets/home_feed_recommend_panel.dart @@ -30,12 +30,14 @@ class HomeFeedInfoPanel extends StatefulWidget { required this.episode, required this.onDetail, required this.onOpenFullPlayer, + this.rankingRank, }); final Drama drama; final Episode episode; final VoidCallback onDetail; final VoidCallback onOpenFullPlayer; + final int? rankingRank; @override State createState() => _HomeFeedInfoPanelState(); @@ -119,6 +121,7 @@ class _HomeFeedInfoPanelState extends State { Widget _metaPills(AppLocalizations l10n) { final hot = isHotDrama(widget.drama); final views = widget.drama.viewCount; + final rank = widget.rankingRank; if (!hot && views <= 0) return const SizedBox.shrink(); return Wrap( key: const Key('home-feed-meta-pills'), @@ -139,7 +142,9 @@ class _HomeFeedInfoPanelState extends State { ), const SizedBox(width: 2), Text( - l10n.rankingHot, + rank != null && rank > 0 + ? l10n.hotChartRanked(rank) + : l10n.hotChartLabel, style: const TextStyle( color: AppColors.textPrimary, fontSize: _metaFont, @@ -154,7 +159,7 @@ class _HomeFeedInfoPanelState extends State { key: const Key('home-feed-view-pill'), background: AppColors.overlaySoft, child: Text( - formatInteractionCount(views), + formatPlayCount(views), style: const TextStyle( color: AppColors.textPrimary, fontSize: _metaFont, @@ -209,7 +214,7 @@ class _HomeFeedInfoPanelState extends State { ], Expanded( child: Text( - genres.join(' | '), + genres.join(' '), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( @@ -225,6 +230,8 @@ class _HomeFeedInfoPanelState extends State { widget.drama.episodeCount, ), key: const Key('home-feed-episode-progress'), + maxLines: 1, + softWrap: false, style: const TextStyle( color: AppColors.overlaySecondary, fontSize: _metaFont, diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index f198ff7..a34c27d 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -371,9 +371,16 @@ void main() { .data, 'Episode 2 / 8 total', ); - expect(find.textContaining('41.9万'), findsOneWidget); + expect(find.textContaining('41.9w'), findsOneWidget); expect(find.byKey(const Key('home-feed-ranking-pill')), findsOneWidget); expect(find.byKey(const Key('home-feed-view-pill')), findsOneWidget); + expect(find.text('Trending'), findsOneWidget); + expect(find.text('Romance Urban'), findsOneWidget); + final episodeProgress = tester.widget( + find.byKey(const Key('home-feed-episode-progress')), + ); + expect(episodeProgress.maxLines, 1); + expect(episodeProgress.softWrap, isFalse); expect( tester.widget(find.byKey(const Key('rail-like-count'))).data, '1006', @@ -407,6 +414,13 @@ void main() { expect(find.byKey(const Key('rail-comments')), findsNothing); expect(find.byKey(const Key('rail-share')), findsNothing); expect(find.text('Comments'), findsNothing); + expect( + tester.getRect(find.byKey(const Key('home-feed-action-rail'))).right, + closeTo( + tester.getRect(find.byKey(const Key('home-feed-gesture'))).right - 8, + 0.1, + ), + ); expect(find.byKey(const Key('progress-loading')), findsNothing); expect( find.byKey(const Key('home-feed-progress-inactive')), From d66ac8327ef712687df8d432af0b2e8437e22951 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 19:55:07 +0800 Subject: [PATCH 072/129] =?UTF-8?q?fix(recommend):=20=E7=B2=BE=E7=AE=80?= =?UTF-8?q?=E5=BA=95=E9=83=A8=E4=BF=A1=E6=81=AF=E5=B9=B6=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6=E6=8B=96=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 7 ++ lib/core/theme/app_colors.dart | 1 + lib/l10n/app_en.arb | 3 - lib/l10n/app_id.arb | 3 - lib/l10n/app_localizations.dart | 12 -- lib/l10n/app_localizations_en.dart | 8 -- lib/l10n/app_localizations_id.dart | 8 -- lib/l10n/app_localizations_zh.dart | 8 -- lib/l10n/app_zh.arb | 3 - lib/pages/home/home_view.dart | 1 + lib/pages/player/player_view.dart | 14 ++- .../player/widgets/home_feed_overlay.dart | 28 ++++- .../widgets/home_feed_overlay_parts.dart | 16 ++- .../widgets/home_feed_recommend_panel.dart | 107 +++--------------- lib/pages/recommend/recommend_page.dart | 35 ++++-- test/home/home_preview_layout_test.dart | 50 +++++++- 16 files changed, 141 insertions(+), 163 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index b98bd9f..8a02993 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1177,3 +1177,10 @@ $ flutter test - 新增三语“观看全集”文案;按当前流程未执行 analyze/test。 - 榜单行支持可选排名:有名次显示 `TOPn·热播榜`,无名次回退“热播榜”;热度值改用 `w` 格式。 - 题材改为空格分隔,集数固定右侧单行;右侧操作栏改为距屏幕边缘 8,底部位置不变。 + +## 2026-10-22 推荐页底部信息二次修正 + +- 按最新反馈移除整行榜单与热度胶囊,信息顺序收敛为标题、剧名、标签、简介、观看全集。 +- 剧名 19/w600,箭头 20 白色并紧邻剧名;标签行改为 13/w600 亮灰内联布局。 +- 简介「展开/收起」沿用现有三语文案;进度条热区恢复 24 并接入拖动自动清屏与预览卡片。 +- 拖动期间隐藏顶部导航、操作栏、文字及根底栏,松手恢复;按当前流程未执行 analyze/test。 diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 26e9aac..7d55946 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -24,6 +24,7 @@ abstract final class AppColors { static const overlayMedium = Color(0x99000000); static const headerScrim = Color(0x59000000); static const overlaySecondary = Color(0xFFC7C7CC); + static const overlayLabel = Color(0xFFE6E6EB); /// 主操作、选中态、播放进度(品牌橙)。 static const blue = Color(0xFFFF6B2C); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a19e4ac..f5b44c3 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -749,8 +749,5 @@ "watchedToEpisode": "Watched to Episode {episode}", "@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } }, "homeWatchAll": "Watch all", - "hotChartLabel": "Trending", - "hotChartRanked": "TOP{rank} · Trending", - "@hotChartRanked": { "placeholders": { "rank": { "type": "int" } } }, "confirmRemove": "Remove this item?" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 185ecbe..025c1e8 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -706,8 +706,5 @@ "watchedToEpisode": "Ditonton hingga Episode {episode}", "@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } }, "homeWatchAll": "Tonton semua", - "hotChartLabel": "Peringkat populer", - "hotChartRanked": "TOP{rank} · Populer", - "@hotChartRanked": { "placeholders": { "rank": { "type": "int" } } }, "confirmRemove": "Hapus item ini?" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index e610f00..2b3ae8a 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3262,18 +3262,6 @@ abstract class AppLocalizations { /// **'Watch all'** String get homeWatchAll; - /// No description provided for @hotChartLabel. - /// - /// In en, this message translates to: - /// **'Trending'** - String get hotChartLabel; - - /// No description provided for @hotChartRanked. - /// - /// In en, this message translates to: - /// **'TOP{rank} · Trending'** - String hotChartRanked(int rank); - /// No description provided for @confirmRemove. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 1d25160..ef3ebad 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1702,14 +1702,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get homeWatchAll => 'Watch all'; - @override - String get hotChartLabel => 'Trending'; - - @override - String hotChartRanked(int rank) { - return 'TOP$rank · Trending'; - } - @override String get confirmRemove => 'Remove this item?'; } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index e807c46..f76ec32 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1704,14 +1704,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get homeWatchAll => 'Tonton semua'; - @override - String get hotChartLabel => 'Peringkat populer'; - - @override - String hotChartRanked(int rank) { - return 'TOP$rank · Populer'; - } - @override String get confirmRemove => 'Hapus item ini?'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index ffba839..b99bb6e 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1678,14 +1678,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get homeWatchAll => '观看全集'; - @override - String get hotChartLabel => '热播榜'; - - @override - String hotChartRanked(int rank) { - return 'TOP$rank·热播榜'; - } - @override String get confirmRemove => '确定移除这条内容吗?'; } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 795c67b..e98372e 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -668,8 +668,5 @@ "watchedToEpisode": "已看至 第 {episode} 集", "@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } }, "homeWatchAll": "观看全集", - "hotChartLabel": "热播榜", - "hotChartRanked": "TOP{rank}·热播榜", - "@hotChartRanked": { "placeholders": { "rank": { "type": "int" } } }, "confirmRemove": "确定移除这条内容吗?" } diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index d8e309e..4586dbb 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -288,6 +288,7 @@ class _HomeTabsState extends State<_HomeTabs> { active: logic.tab == 1, onSearch: () => _openSearch(context), onOpenFilter: () => _pushPage(context, const FilterPage()), + onScrubVisibilityChanged: logic.onImmersiveChanged, ); } diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 2197109..ea3026a 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -69,6 +69,7 @@ class PlayerPage extends StatefulWidget { this.showHomeFeedHeader = true, this.onDramaChanged, this.onImmersiveChanged, + this.onScrubVisibilityChanged, this.onOpenFullPlayer, this.onRefreshFeed, this.onLoadMore, @@ -94,6 +95,7 @@ class PlayerPage extends StatefulWidget { final bool showHomeFeedHeader; final ValueChanged? onDramaChanged; final ValueChanged? onImmersiveChanged; + final ValueChanged? onScrubVisibilityChanged; final Future Function(Drama drama, Episode episode)? onOpenFullPlayer; final Future Function()? onRefreshFeed; final Future Function()? onLoadMore; @@ -114,6 +116,7 @@ class _PlayerPageState extends State bool _chromeVisible = true; double _playbackSpeed = 1.0; bool _boosting = false; + bool _scrubbing = false; double? _speedBeforeBoost; bool? _chromeBeforeBoost; @@ -296,6 +299,12 @@ class _PlayerPageState extends State unawaited(logic.setPlaybackSpeed(speed)); } + void _setScrubbing(bool value) { + if (_scrubbing == value) return; + setState(() => _scrubbing = value); + widget.onScrubVisibilityChanged?.call(value); + } + /// 收藏状态确认后同步右侧计数(总数没有独立计数接口,按 ±1 跟随)。 void _applyFavoriteDelta(String dramaId, {required bool nowSaved}) { setState(() { @@ -364,6 +373,7 @@ class _PlayerPageState extends State content, if (widget.homeMode && widget.showHomeFeedHeader && + !_scrubbing && !_boosting && !logic.pip && !logic.pipPreparing) @@ -624,6 +634,7 @@ class _PlayerPageState extends State onEpisodes: () => logic.showEpisodes(context), onMore: _openCurrentMore, onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null, + onScrubVisibilityChanged: _setScrubbing, onSeek: ready ? (position) => logic.seekTo(context, videoPort!, position) : null, @@ -695,7 +706,7 @@ class _PlayerPageState extends State ? null : () => _openUnlockSheet(context, index, episode), ), - if (widget.homeMode && !_chromeVisible && !_boosting) + if (widget.homeMode && !_chromeVisible && !_boosting && !_scrubbing) Positioned( right: 12, bottom: 56, @@ -706,6 +717,7 @@ class _PlayerPageState extends State ), ), if (widget.homeMode && + !_scrubbing && !logic.chromeHidden && selected && !logic.session.userWantsPlay) diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index 92b000e..e2906b7 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -19,7 +19,7 @@ import 'seek_progress_bar.dart'; part 'home_feed_overlay_parts.dart'; const double _progressHeight = 2; -const double _progressHitHeight = 12; +const double _progressHitHeight = 24; const double _pageHorizontal = 14; const double _railRight = 8; const double _railGap = 12; @@ -54,10 +54,11 @@ class HomeFeedOverlay extends StatefulWidget { required this.onMore, this.likeCount, this.favoriteCount, - this.rankingRank, this.commentCount, this.authorNote = '', this.onScrubStart, + this.onScrubEnd, + this.onScrubVisibilityChanged, this.onSeek, this.thumbnailService, this.spriteAxisService, @@ -71,7 +72,6 @@ class HomeFeedOverlay extends StatefulWidget { final bool liked; final int? likeCount; final int? favoriteCount; - final int? rankingRank; final int? commentCount; final bool saved; final String authorNote; @@ -89,6 +89,8 @@ class HomeFeedOverlay extends StatefulWidget { final VoidCallback onBoostEnd; final VoidCallback onMore; final VoidCallback? onScrubStart; + final VoidCallback? onScrubEnd; + final ValueChanged? onScrubVisibilityChanged; final ValueChanged? onSeek; final PlaybackThumbnailService? thumbnailService; final SpriteThumbnailAxisService? spriteAxisService; @@ -98,6 +100,8 @@ class HomeFeedOverlay extends StatefulWidget { } class _HomeFeedOverlayState extends State { + bool _scrubbing = false; + @override Widget build(BuildContext context) { return SafeArea( @@ -123,7 +127,7 @@ class _HomeFeedOverlayState extends State { ), if (widget.boosting) Positioned(left: 0, right: 0, bottom: 20, child: _boostHint()) - else if (widget.visible) + else if (widget.visible && !_scrubbing) Positioned( left: _pageHorizontal, right: _railRight, @@ -149,7 +153,8 @@ class _HomeFeedOverlayState extends State { locked: widget.locked, height: _progressHeight, hitHeight: _progressHitHeight, - onScrubStart: widget.onScrubStart, + onScrubStart: _startScrub, + onScrubEnd: _endScrub, onSeek: widget.onSeek, thumbnailService: widget.thumbnailService, spriteAxisService: widget.spriteAxisService, @@ -163,6 +168,18 @@ class _HomeFeedOverlayState extends State { ); } + void _startScrub() { + if (!_scrubbing) setState(() => _scrubbing = true); + widget.onScrubVisibilityChanged?.call(true); + widget.onScrubStart?.call(); + } + + void _endScrub() { + if (_scrubbing) setState(() => _scrubbing = false); + widget.onScrubVisibilityChanged?.call(false); + widget.onScrubEnd?.call(); + } + void _handleLongPressStart(LongPressStartDetails details, double width) { final third = width / 3; final dx = details.localPosition.dx; @@ -220,7 +237,6 @@ class _HomeFeedOverlayState extends State { episode: widget.episode, onDetail: widget.onDetail, onOpenFullPlayer: widget.onOpenFullPlayer, - rankingRank: widget.rankingRank, ); } } diff --git a/lib/pages/player/widgets/home_feed_overlay_parts.dart b/lib/pages/player/widgets/home_feed_overlay_parts.dart index 522b535..4a7c6dc 100644 --- a/lib/pages/player/widgets/home_feed_overlay_parts.dart +++ b/lib/pages/player/widgets/home_feed_overlay_parts.dart @@ -16,6 +16,7 @@ class _FeedProgressBar extends StatefulWidget { required this.height, required this.hitHeight, this.onScrubStart, + this.onScrubEnd, this.onSeek, this.thumbnailService, this.spriteAxisService, @@ -29,6 +30,7 @@ class _FeedProgressBar extends StatefulWidget { final double height; final double hitHeight; final VoidCallback? onScrubStart; + final VoidCallback? onScrubEnd; final ValueChanged? onSeek; final PlaybackThumbnailService? thumbnailService; final SpriteThumbnailAxisService? spriteAxisService; @@ -137,8 +139,18 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { onHorizontalDragUpdate: interactive ? (details) => _update(details.localPosition.dx / width) : null, - onHorizontalDragEnd: interactive ? (_) => _commit(_preview) : null, - onHorizontalDragCancel: interactive ? _cancelPreview : null, + onHorizontalDragEnd: interactive + ? (_) { + widget.onScrubEnd?.call(); + _commit(_preview); + } + : null, + onHorizontalDragCancel: interactive + ? () { + widget.onScrubEnd?.call(); + _cancelPreview(); + } + : null, child: Stack( clipBehavior: Clip.none, children: [ diff --git a/lib/pages/player/widgets/home_feed_recommend_panel.dart b/lib/pages/player/widgets/home_feed_recommend_panel.dart index 1fd69b3..5d92347 100644 --- a/lib/pages/player/widgets/home_feed_recommend_panel.dart +++ b/lib/pages/player/widgets/home_feed_recommend_panel.dart @@ -10,9 +10,8 @@ import '../../../l10n/app_localizations.dart'; import '../../home/widgets/home_drama_classification.dart'; const double _headlineFont = 26; -const double _seriesFont = 17; -const double _metaFont = 12; -const double _tagFont = 12; +const double _seriesFont = 19; +const double _tagFont = 13; const double _synopsisFont = 13; const double _watchAllHeight = 44; const double _actionGap = 28; @@ -30,14 +29,12 @@ class HomeFeedInfoPanel extends StatefulWidget { required this.episode, required this.onDetail, required this.onOpenFullPlayer, - this.rankingRank, }); final Drama drama; final Episode episode; final VoidCallback onDetail; final VoidCallback onOpenFullPlayer; - final int? rankingRank; @override State createState() => _HomeFeedInfoPanelState(); @@ -73,15 +70,14 @@ class _HomeFeedInfoPanelState extends State { ), ), const SizedBox(height: 8), - _metaPills(l10n), - if (_hasMeta()) const SizedBox(height: 8), GestureDetector( key: const Key('home-feed-series-name'), behavior: HitTestBehavior.opaque, onTap: widget.onDetail, child: Row( + mainAxisSize: MainAxisSize.min, children: [ - Expanded( + Flexible( child: Text( dramaTitle, maxLines: 1, @@ -94,10 +90,11 @@ class _HomeFeedInfoPanelState extends State { ), ), ), + const SizedBox(width: 4), const Icon( Icons.chevron_right_rounded, - size: 16, - color: AppColors.overlaySecondary, + size: 20, + color: AppColors.textPrimary, ), ], ), @@ -114,81 +111,6 @@ class _HomeFeedInfoPanelState extends State { ); } - bool _hasMeta() { - return isHotDrama(widget.drama) || widget.drama.viewCount > 0; - } - - Widget _metaPills(AppLocalizations l10n) { - final hot = isHotDrama(widget.drama); - final views = widget.drama.viewCount; - final rank = widget.rankingRank; - if (!hot && views <= 0) return const SizedBox.shrink(); - return Wrap( - key: const Key('home-feed-meta-pills'), - spacing: 6, - runSpacing: 4, - children: [ - if (hot) - _pill( - key: const Key('home-feed-ranking-pill'), - background: AppColors.error, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.local_fire_department_rounded, - size: 12, - color: AppColors.textPrimary, - ), - const SizedBox(width: 2), - Text( - rank != null && rank > 0 - ? l10n.hotChartRanked(rank) - : l10n.hotChartLabel, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: _metaFont, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - if (views > 0) - _pill( - key: const Key('home-feed-view-pill'), - background: AppColors.overlaySoft, - child: Text( - formatPlayCount(views), - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: _metaFont, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ); - } - - Widget _pill({ - required Key key, - required Color background, - required Widget child, - }) { - return Container( - key: key, - height: 22, - padding: const EdgeInsets.symmetric(horizontal: 8), - alignment: Alignment.center, - decoration: BoxDecoration( - color: background, - borderRadius: BorderRadius.circular(11), - ), - child: child, - ); - } - Widget _tagMetaRow(AppLocalizations l10n, List genres) { return Row( key: const Key('home-feed-tags'), @@ -210,20 +132,21 @@ class _HomeFeedInfoPanelState extends State { ), ), ), - const SizedBox(width: 6), + const SizedBox(width: 8), ], - Expanded( + Flexible( + fit: FlexFit.loose, child: Text( genres.join(' '), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - color: AppColors.overlaySecondary, + color: AppColors.overlayLabel, fontSize: _tagFont, ), ), ), - const SizedBox(width: 6), + const SizedBox(width: 8), Text( l10n.favoriteEpisodeProgress( widget.episode.number, @@ -233,8 +156,8 @@ class _HomeFeedInfoPanelState extends State { maxLines: 1, softWrap: false, style: const TextStyle( - color: AppColors.overlaySecondary, - fontSize: _metaFont, + color: AppColors.overlayLabel, + fontSize: _tagFont, ), ), ], @@ -298,7 +221,7 @@ class _HomeFeedInfoPanelState extends State { bottom: 0, child: _synopsisToggle( key: const Key('home-feed-synopsis-more'), - label: l10n.more, + label: l10n.expandSynopsis, ), ), ], diff --git a/lib/pages/recommend/recommend_page.dart b/lib/pages/recommend/recommend_page.dart index 75d2d15..098bfa6 100644 --- a/lib/pages/recommend/recommend_page.dart +++ b/lib/pages/recommend/recommend_page.dart @@ -28,12 +28,14 @@ class RecommendPage extends StatefulWidget { required this.active, required this.onSearch, required this.onOpenFilter, + required this.onScrubVisibilityChanged, }); final HomeFeedController feedController; final bool active; final VoidCallback onSearch; final VoidCallback onOpenFilter; + final ValueChanged onScrubVisibilityChanged; @override State createState() => _RecommendPageState(); @@ -41,6 +43,7 @@ class RecommendPage extends StatefulWidget { class _RecommendPageState extends State { RecommendInnerTab _tab = RecommendInnerTab.feed; + bool _scrubbing = false; @override Widget build(BuildContext context) { @@ -77,20 +80,21 @@ class _RecommendPageState extends State { ), ), ), - Positioned( - top: 0, - left: 0, - right: 0, - child: SafeArea( - bottom: false, - child: RecommendTopNav( - selected: _tab, - onVideo: _tab == RecommendInnerTab.feed, - onSelected: (tab) => setState(() => _tab = tab), - onSearch: widget.onSearch, + if (!_scrubbing) + Positioned( + top: 0, + left: 0, + right: 0, + child: SafeArea( + bottom: false, + child: RecommendTopNav( + selected: _tab, + onVideo: _tab == RecommendInnerTab.feed, + onSelected: (tab) => setState(() => _tab = tab), + onSearch: widget.onSearch, + ), ), ), - ), ], ), ); @@ -136,9 +140,16 @@ class _RecommendPageState extends State { _openDrama(drama, episodeId: episode.id), onRefreshFeed: widget.feedController.refreshCurrent, onLoadMore: widget.feedController.loadMore, + onScrubVisibilityChanged: _setScrubbing, ); } + void _setScrubbing(bool value) { + if (_scrubbing == value) return; + setState(() => _scrubbing = value); + widget.onScrubVisibilityChanged(value); + } + Widget _withTopNavSpace(Widget child) { return SafeArea( bottom: false, diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index a34c27d..f6a9c88 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -23,6 +23,7 @@ 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:fruitshorts/pages/player/widgets/scrub_preview_card.dart'; import 'package:fruitshorts/pages/player/player_view.dart'; import 'package:video_player/video_player.dart'; @@ -355,6 +356,23 @@ void main() { 'Ignored Episode Title', ); expect(find.text('Feed Drama'), findsOneWidget); + final seriesTitle = tester.widget(find.text('Feed Drama')); + expect(seriesTitle.style?.fontSize, 19); + expect(seriesTitle.style?.fontWeight, FontWeight.w600); + expect(seriesTitle.style?.color, AppColors.textPrimary); + final seriesChevron = tester.widget( + find.descendant( + of: find.byKey(const Key('home-feed-series-name')), + matching: find.byIcon(Icons.chevron_right_rounded), + ), + ); + expect(seriesChevron.size, 20); + expect(seriesChevron.color, AppColors.textPrimary); + expect( + tester.getTopLeft(find.byIcon(Icons.chevron_right_rounded)).dx - + tester.getTopRight(find.text('Feed Drama')).dx, + 4, + ); expect(find.byKey(const Key('home-feed-info-card')), findsNothing); expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget); expect(find.byKey(const Key('home-feed-tags')), findsOneWidget); @@ -371,16 +389,27 @@ void main() { .data, 'Episode 2 / 8 total', ); - expect(find.textContaining('41.9w'), findsOneWidget); - expect(find.byKey(const Key('home-feed-ranking-pill')), findsOneWidget); - expect(find.byKey(const Key('home-feed-view-pill')), findsOneWidget); - expect(find.text('Trending'), findsOneWidget); + expect(find.byKey(const Key('home-feed-meta-pills')), findsNothing); + expect(find.byKey(const Key('home-feed-ranking-pill')), findsNothing); + expect(find.byKey(const Key('home-feed-view-pill')), findsNothing); expect(find.text('Romance Urban'), findsOneWidget); + final genreText = tester.widget(find.text('Romance Urban')); + expect(genreText.style?.fontSize, 13); + expect(genreText.style?.color, AppColors.overlayLabel); final episodeProgress = tester.widget( find.byKey(const Key('home-feed-episode-progress')), ); expect(episodeProgress.maxLines, 1); expect(episodeProgress.softWrap, isFalse); + expect(episodeProgress.style?.fontSize, 13); + expect(episodeProgress.style?.color, AppColors.overlayLabel); + expect( + tester + .getTopLeft(find.byKey(const Key('home-feed-episode-progress'))) + .dx - + tester.getTopRight(find.text('Romance Urban')).dx, + 8, + ); expect( tester.widget(find.byKey(const Key('rail-like-count'))).data, '1006', @@ -567,6 +596,9 @@ void main() { testWidgets('should_seek_and_open_player_from_feed_actions', (tester) async { var opened = 0; Duration? seeked; + final scrubVisibility = []; + final thumbnailService = _nullThumbnailService(); + addTearDown(thumbnailService.dispose); final controller = ValueNotifier( VideoPlayerValue( duration: const Duration(seconds: 100), @@ -616,7 +648,9 @@ void main() { onOpenFullPlayer: () => opened++, onMore: () {}, onScrubStart: () {}, + onScrubVisibilityChanged: scrubVisibility.add, onSeek: (value) => seeked = value, + thumbnailService: thumbnailService, ), ), ), @@ -647,7 +681,7 @@ void main() { ); expect( tester.getSize(find.byKey(const Key('home-feed-progress'))).height, - 12, + greaterThanOrEqualTo(24), ); expect( tester @@ -668,6 +702,10 @@ void main() { ); await gesture.moveBy(const Offset(120, 0)); await tester.pump(); + expect(scrubVisibility.last, isTrue); + expect(find.byKey(const Key('home-feed-title')), findsNothing); + expect(find.byKey(const Key('rail-save')), findsNothing); + expect(find.byType(ScrubPreviewCard), findsOneWidget); expect( tester.getSize(find.byKey(const Key('home-feed-progress-thumb'))), const Size(14, 14), @@ -691,6 +729,8 @@ void main() { ); await gesture.up(); await tester.pumpAndSettle(); + expect(scrubVisibility.last, isFalse); + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); expect(seeked, isNotNull); expect(seeked!.inSeconds, greaterThan(0)); expect( From 30b55afdd4fda7267ba0e58f72b27e45592c8d5d Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 20:01:54 +0800 Subject: [PATCH 073/129] =?UTF-8?q?fix(recommend):=20=E7=A8=B3=E5=AE=9A?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6=E6=8B=96=E5=8A=A8=E4=B8=8E=E6=B8=85=E5=B1=8F?= =?UTF-8?q?=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 3 +- lib/pages/home/home_logic.dart | 7 + lib/pages/home/home_view.dart | 12 +- lib/pages/player/player_view.dart | 64 ++++++- .../player/widgets/home_feed_overlay.dart | 48 +++-- .../widgets/home_feed_overlay_parts.dart | 168 ++++++++++++------ lib/pages/recommend/recommend_page.dart | 66 +++++-- test/home/home_preview_layout_test.dart | 51 +++++- test/recommend/recommend_sections_test.dart | 16 ++ 9 files changed, 339 insertions(+), 96 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 8a02993..f5d2cc1 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1183,4 +1183,5 @@ $ flutter test - 按最新反馈移除整行榜单与热度胶囊,信息顺序收敛为标题、剧名、标签、简介、观看全集。 - 剧名 19/w600,箭头 20 白色并紧邻剧名;标签行改为 13/w600 亮灰内联布局。 - 简介「展开/收起」沿用现有三语文案;进度条热区恢复 24 并接入拖动自动清屏与预览卡片。 -- 拖动期间隐藏顶部导航、操作栏、文字及根底栏,松手恢复;按当前流程未执行 analyze/test。 +- 拖动期间隐藏操作栏、文字及根底栏,顶部导航常驻;松手后持续清屏直到点击画面。 +- 进度条固定中心并动画到 6 高,时间气泡限制在左右 12;按当前流程未执行 analyze/test。 diff --git a/lib/pages/home/home_logic.dart b/lib/pages/home/home_logic.dart index dfd1f00..dab2d46 100644 --- a/lib/pages/home/home_logic.dart +++ b/lib/pages/home/home_logic.dart @@ -23,6 +23,7 @@ class HomeLogic extends ChangeNotifier { bool drawerOpen = false; bool detailOpen = false; bool videoImmersive = false; + bool scrubHidden = false; bool _dramaNotifyScheduled = false; bool _disposed = false; @@ -144,6 +145,12 @@ class HomeLogic extends ChangeNotifier { notify(); } + void onScrubVisibilityChanged(bool value) { + if (scrubHidden == value) return; + scrubHidden = value; + notify(); + } + @override void dispose() { _disposed = true; diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 4586dbb..615bf61 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -239,7 +239,15 @@ class _HomeTabsState extends State<_HomeTabs> { backgroundColor: AppColors.transparent, resizeToAvoidBottomInset: false, body: _body(context), - bottomNavigationBar: logic.videoImmersive ? null : _nav(context), + bottomNavigationBar: logic.videoImmersive + ? null + : Visibility( + visible: !logic.scrubHidden, + maintainSize: true, + maintainAnimation: true, + maintainState: true, + child: _nav(context), + ), ), ); return AnnotatedRegion( @@ -288,7 +296,7 @@ class _HomeTabsState extends State<_HomeTabs> { active: logic.tab == 1, onSearch: () => _openSearch(context), onOpenFilter: () => _pushPage(context, const FilterPage()), - onScrubVisibilityChanged: logic.onImmersiveChanged, + onScrubVisibilityChanged: logic.onScrubVisibilityChanged, ); } diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index ea3026a..062c93f 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -59,6 +59,7 @@ class PlayerPage extends StatefulWidget { this.active = true, this.resumeRequest = 0, this.backRequest = 0, + this.scrubCancelRequest = 0, this.onDiscover, this.onMy, this.onSearch, @@ -85,6 +86,7 @@ class PlayerPage extends StatefulWidget { final bool active; final int resumeRequest; final int backRequest; + final int scrubCancelRequest; final VoidCallback? onDiscover; final VoidCallback? onMy; final VoidCallback? onSearch; @@ -117,6 +119,7 @@ class _PlayerPageState extends State double _playbackSpeed = 1.0; bool _boosting = false; bool _scrubbing = false; + bool _clearScreenFromScrub = false; double? _speedBeforeBoost; bool? _chromeBeforeBoost; @@ -135,6 +138,9 @@ class _PlayerPageState extends State downloads: widget.downloads, ); logic.attach(widget, isMounted: () => mounted); + if (oldWidget.scrubCancelRequest != widget.scrubCancelRequest) { + _cancelFeedScrub(); + } _thumbnailService = PlaybackThumbnailService( sourceResolver: _resolveThumbnailSource, ); @@ -266,7 +272,9 @@ class _PlayerPageState extends State } void _hideChrome() { + if (!_chromeVisible) return; setState(() => _chromeVisible = false); + widget.onScrubVisibilityChanged?.call(true); } /// 长按两侧:切 2 倍速并清屏,记下进入前的状态用于松手恢复。 @@ -281,6 +289,7 @@ class _PlayerPageState extends State _playbackSpeed = _boostPlaybackSpeed; _chromeVisible = false; }); + widget.onScrubVisibilityChanged?.call(true); unawaited(logic.setPlaybackSpeed(_boostPlaybackSpeed)); } @@ -296,6 +305,7 @@ class _PlayerPageState extends State _playbackSpeed = speed; _chromeVisible = chrome; }); + widget.onScrubVisibilityChanged?.call(!chrome); unawaited(logic.setPlaybackSpeed(speed)); } @@ -305,6 +315,39 @@ class _PlayerPageState extends State widget.onScrubVisibilityChanged?.call(value); } + void _finishFeedScrub() { + setState(() { + _scrubbing = false; + _chromeVisible = false; + _clearScreenFromScrub = true; + }); + } + + void _cancelFeedScrub() { + logic.cancelScrub(); + _scrubbing = false; + _chromeVisible = true; + _clearScreenFromScrub = false; + } + + void _restoreFeedChrome() { + setState(() { + _scrubbing = false; + _chromeVisible = true; + _clearScreenFromScrub = false; + }); + widget.onScrubVisibilityChanged?.call(false); + } + + void _handleFeedSurfaceTap() { + if (_scrubbing) return; + if (!_chromeVisible) { + _restoreFeedChrome(); + return; + } + logic.session.setUserWantsPlay(!logic.session.userWantsPlay); + } + /// 收藏状态确认后同步右侧计数(总数没有独立计数接口,按 ±1 跟随)。 void _applyFavoriteDelta(String dramaId, {required bool nowSaved}) { setState(() { @@ -374,6 +417,7 @@ class _PlayerPageState extends State if (widget.homeMode && widget.showHomeFeedHeader && !_scrubbing && + _chromeVisible && !_boosting && !logic.pip && !logic.pipPreparing) @@ -479,16 +523,14 @@ class _PlayerPageState extends State controller: videoPort?.controller, videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl, theater: theater, - onTap: () => - logic.session.setUserWantsPlay(!logic.session.userWantsPlay), + onTap: () => _handleFeedSurfaceTap(), ), if (widget.homeMode && _chromeVisible && !logic.chromeHidden) Positioned.fill( child: GestureDetector( key: const Key('home-feed-video-toggle'), behavior: HitTestBehavior.opaque, - onTap: () => - logic.session.setUserWantsPlay(!logic.session.userWantsPlay), + onTap: _handleFeedSurfaceTap, ), ), // 票据三态之一:起播前正在向后端要签名地址 @@ -595,8 +637,7 @@ class _PlayerPageState extends State boosting: _boosting, onBoostStart: _startBoost, onBoostEnd: _endBoost, - onTogglePlay: () => - logic.session.setUserWantsPlay(!logic.session.userWantsPlay), + onTogglePlay: _handleFeedSurfaceTap, liked: interactions.isEpisodeLiked(episode.id), likeCount: interactions.episodeLikeCount(episode.id) ?? episode.likeCount, @@ -634,7 +675,9 @@ class _PlayerPageState extends State onEpisodes: () => logic.showEpisodes(context), onMore: _openCurrentMore, onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null, + onScrubEnd: _finishFeedScrub, onScrubVisibilityChanged: _setScrubbing, + cancelScrubRequest: widget.scrubCancelRequest, onSeek: ready ? (position) => logic.seekTo(context, videoPort!, position) : null, @@ -706,18 +749,23 @@ class _PlayerPageState extends State ? null : () => _openUnlockSheet(context, index, episode), ), - if (widget.homeMode && !_chromeVisible && !_boosting && !_scrubbing) + if (widget.homeMode && + !_chromeVisible && + !_boosting && + !_scrubbing && + !_clearScreenFromScrub) Positioned( right: 12, bottom: 56, child: ExitClearScreenButton( key: const Key('home-feed-exit-clear-screen'), tooltip: l10n.exitClearScreen, - onPressed: () => setState(() => _chromeVisible = true), + onPressed: _restoreFeedChrome, ), ), if (widget.homeMode && !_scrubbing && + _chromeVisible && !logic.chromeHidden && selected && !logic.session.userWantsPlay) diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index e2906b7..13891d0 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -13,13 +13,12 @@ import '../../../core/theme/app_colors.dart'; import '../../../l10n/app_localizations.dart'; import 'home_feed_recommend_panel.dart'; import 'loading_progress_track.dart'; -import 'scrub_preview_card.dart'; import 'seek_progress_bar.dart'; part 'home_feed_overlay_parts.dart'; const double _progressHeight = 2; -const double _progressHitHeight = 24; +const double _progressHitHeight = 28; const double _pageHorizontal = 14; const double _railRight = 8; const double _railGap = 12; @@ -60,6 +59,7 @@ class HomeFeedOverlay extends StatefulWidget { this.onScrubEnd, this.onScrubVisibilityChanged, this.onSeek, + this.cancelScrubRequest = 0, this.thumbnailService, this.spriteAxisService, }); @@ -92,6 +92,7 @@ class HomeFeedOverlay extends StatefulWidget { final VoidCallback? onScrubEnd; final ValueChanged? onScrubVisibilityChanged; final ValueChanged? onSeek; + final int cancelScrubRequest; final PlaybackThumbnailService? thumbnailService; final SpriteThumbnailAxisService? spriteAxisService; @@ -101,6 +102,16 @@ class HomeFeedOverlay extends StatefulWidget { class _HomeFeedOverlayState extends State { bool _scrubbing = false; + bool _clearAfterScrub = false; + + @override + void didUpdateWidget(covariant HomeFeedOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.cancelScrubRequest != widget.cancelScrubRequest) { + _scrubbing = false; + _clearAfterScrub = false; + } + } @override Widget build(BuildContext context) { @@ -116,7 +127,7 @@ class _HomeFeedOverlayState extends State { child: GestureDetector( key: const Key('home-feed-gesture'), behavior: HitTestBehavior.opaque, - onTap: widget.onTogglePlay, + onTap: _handleSurfaceTap, onLongPressStart: (details) => _handleLongPressStart(details, constraints.maxWidth), onLongPressEnd: (_) => widget.onBoostEnd(), @@ -127,7 +138,7 @@ class _HomeFeedOverlayState extends State { ), if (widget.boosting) Positioned(left: 0, right: 0, bottom: 20, child: _boostHint()) - else if (widget.visible && !_scrubbing) + else if (widget.visible && !_scrubbing && !_clearAfterScrub) Positioned( left: _pageHorizontal, right: _railRight, @@ -153,14 +164,10 @@ class _HomeFeedOverlayState extends State { locked: widget.locked, height: _progressHeight, hitHeight: _progressHitHeight, + cancelScrubRequest: widget.cancelScrubRequest, onScrubStart: _startScrub, onScrubEnd: _endScrub, onSeek: widget.onSeek, - thumbnailService: widget.thumbnailService, - spriteAxisService: widget.spriteAxisService, - thumbnailsUrl: widget.episode.thumbnailsUrl, - episodeId: widget.episode.id, - posterUrl: widget.drama.posterUrl, ), ), ], @@ -169,17 +176,34 @@ class _HomeFeedOverlayState extends State { } void _startScrub() { - if (!_scrubbing) setState(() => _scrubbing = true); + if (!_scrubbing || _clearAfterScrub) { + setState(() { + _scrubbing = true; + _clearAfterScrub = false; + }); + } widget.onScrubVisibilityChanged?.call(true); widget.onScrubStart?.call(); } void _endScrub() { - if (_scrubbing) setState(() => _scrubbing = false); - widget.onScrubVisibilityChanged?.call(false); + if (_scrubbing || !_clearAfterScrub) { + setState(() { + _scrubbing = false; + _clearAfterScrub = true; + }); + } widget.onScrubEnd?.call(); } + void _handleSurfaceTap() { + if (_clearAfterScrub) { + setState(() => _clearAfterScrub = false); + widget.onScrubVisibilityChanged?.call(false); + } + widget.onTogglePlay(); + } + void _handleLongPressStart(LongPressStartDetails details, double width) { final third = width / 3; final dx = details.localPosition.dx; diff --git a/lib/pages/player/widgets/home_feed_overlay_parts.dart b/lib/pages/player/widgets/home_feed_overlay_parts.dart index 4a7c6dc..2e054ac 100644 --- a/lib/pages/player/widgets/home_feed_overlay_parts.dart +++ b/lib/pages/player/widgets/home_feed_overlay_parts.dart @@ -6,6 +6,9 @@ const double _thumbSize = 6; const double _thumbActiveSize = 14; const double _trackActiveHeight = 6; const double _lineCenterFromBottom = 8; +const Duration _trackResizeDuration = Duration(milliseconds: 120); +const double _timeBubbleWidth = 96; +const double _timeBubbleMargin = 12; const Key _inactiveTrackKey = Key('home-feed-progress-inactive'); /// 整集条底部的进度线:20px 手势区内可点按/拖动 seek。 @@ -15,28 +18,20 @@ class _FeedProgressBar extends StatefulWidget { required this.locked, required this.height, required this.hitHeight, + this.cancelScrubRequest = 0, this.onScrubStart, this.onScrubEnd, this.onSeek, - this.thumbnailService, - this.spriteAxisService, - this.thumbnailsUrl, - required this.episodeId, - required this.posterUrl, }); final ValueListenable? controller; final bool locked; final double height; final double hitHeight; + final int cancelScrubRequest; final VoidCallback? onScrubStart; final VoidCallback? onScrubEnd; final ValueChanged? onSeek; - final PlaybackThumbnailService? thumbnailService; - final SpriteThumbnailAxisService? spriteAxisService; - final String? thumbnailsUrl; - final String episodeId; - final String posterUrl; @override State<_FeedProgressBar> createState() => _FeedProgressBarState(); @@ -63,15 +58,16 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { _bufferTimer?.cancel(); _bufferTimer = null; _buffering = false; - widget.thumbnailService?.cancelPending(); _attachSource(); } + if (oldWidget.cancelScrubRequest != widget.cancelScrubRequest) { + _preview = null; + } if (oldWidget.locked != widget.locked) { _bufferTimer?.cancel(); _bufferTimer = null; _buffering = false; if (widget.locked) { - widget.thumbnailService?.cancelPending(); _preview = null; } else { _syncBufferState(notify: false); @@ -83,7 +79,6 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { void dispose() { _detachSource(widget.controller); _bufferTimer?.cancel(); - if (_preview != null) widget.thumbnailService?.cancelPending(); super.dispose(); } @@ -123,10 +118,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { final previewPosition = _preview == null || video == null ? null : seekPositionForFraction(_preview!, video.duration); - final showPreview = - previewPosition != null && - widget.thumbnailService != null && - widget.episodeId.isNotEmpty; + final showPreview = previewPosition != null; return GestureDetector( key: const Key('home-feed-progress'), behavior: HitTestBehavior.opaque, @@ -157,17 +149,10 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { track, if (showPreview) Positioned( - left: scrubPreviewLeft( - trackWidth: width, - fraction: _preview!, - ), + left: _timeBubbleLeft(width, _preview!), bottom: widget.hitHeight + 8, - child: ScrubPreviewCard( - service: widget.thumbnailService!, - spriteAxisService: widget.spriteAxisService, - thumbnailsUrl: widget.thumbnailsUrl, - episodeId: widget.episodeId, - posterUrl: widget.posterUrl, + child: _ScrubTimeBubble( + key: const Key('scrub-preview-time-bubble'), position: previewPosition, duration: video!.duration, ), @@ -286,12 +271,10 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { Duration(milliseconds: (total * fraction.clamp(0.0, 1.0)).round()), ); } - widget.thumbnailService?.cancelPending(); setState(() => _preview = null); } void _cancelPreview() { - widget.thumbnailService?.cancelPending(); setState(() => _preview = null); } @@ -299,11 +282,11 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { final dragging = _preview != null; final trackHeight = dragging ? _trackActiveHeight : widget.height; final size = dragging ? _thumbActiveSize : _thumbSize; - final left = (width * fraction - size / 2) - .clamp(0.0, width - size) + final thumbSlotLeft = (width * fraction - _thumbActiveSize / 2) + .clamp(0.0, width - _thumbActiveSize) .toDouble(); - final lineBottom = _lineCenterFromBottom - trackHeight / 2; - final thumbBottom = _lineCenterFromBottom - size / 2; + final trackSlotBottom = _lineCenterFromBottom - _trackActiveHeight / 2; + final thumbSlotBottom = _lineCenterFromBottom - _thumbActiveSize / 2; return SizedBox( height: widget.hitHeight, child: Stack( @@ -312,32 +295,53 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { Positioned( left: 0, right: 0, - bottom: lineBottom, - child: Container( - key: const Key('home-feed-progress-track'), - height: trackHeight, - color: AppColors.border, + bottom: trackSlotBottom, + child: SizedBox( + height: _trackActiveHeight, + child: Stack( + children: [ + Align( + alignment: Alignment.centerLeft, + child: AnimatedContainer( + key: const Key('home-feed-progress-track'), + duration: _trackResizeDuration, + curve: Curves.easeOut, + width: width, + height: trackHeight, + color: AppColors.border, + ), + ), + Align( + alignment: Alignment.centerLeft, + child: AnimatedContainer( + duration: _trackResizeDuration, + curve: Curves.easeOut, + width: width * fraction, + height: trackHeight, + color: AppColors.blue, + ), + ), + ], + ), ), ), Positioned( - left: 0, - bottom: lineBottom, - child: Container( - width: width * fraction, - height: trackHeight, - color: AppColors.blue, - ), - ), - Positioned( - left: left, - bottom: thumbBottom, - child: Container( - key: const Key('home-feed-progress-thumb'), - width: size, - height: size, - decoration: const BoxDecoration( - color: AppColors.onPrimary, - shape: BoxShape.circle, + left: thumbSlotLeft, + bottom: thumbSlotBottom, + child: SizedBox.square( + dimension: _thumbActiveSize, + child: Center( + child: AnimatedContainer( + key: const Key('home-feed-progress-thumb'), + duration: _trackResizeDuration, + curve: Curves.easeOut, + width: size, + height: size, + decoration: const BoxDecoration( + color: AppColors.onPrimary, + shape: BoxShape.circle, + ), + ), ), ), ), @@ -345,4 +349,56 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { ), ); } + + double _timeBubbleLeft(double width, double fraction) { + final maxLeft = width - _timeBubbleWidth - _timeBubbleMargin; + if (maxLeft < _timeBubbleMargin) { + return ((width - _timeBubbleWidth) / 2) + .clamp(0.0, width - _timeBubbleWidth) + .toDouble(); + } + return (width * fraction - _timeBubbleWidth / 2) + .clamp(_timeBubbleMargin, maxLeft) + .toDouble(); + } +} + +class _ScrubTimeBubble extends StatelessWidget { + const _ScrubTimeBubble({ + super.key, + required this.position, + required this.duration, + }); + + final Duration position; + final Duration duration; + + @override + Widget build(BuildContext context) { + return Container( + width: _timeBubbleWidth, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + decoration: BoxDecoration( + color: AppColors.overlayMedium, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '${_formatTime(position)} / ${_formatTime(duration)}', + key: const Key('scrub-preview-time'), + textAlign: TextAlign.center, + maxLines: 1, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ); + } + + String _formatTime(Duration value) { + final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0'); + final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0'); + return '$minutes:$seconds'; + } } diff --git a/lib/pages/recommend/recommend_page.dart b/lib/pages/recommend/recommend_page.dart index 098bfa6..3b524f9 100644 --- a/lib/pages/recommend/recommend_page.dart +++ b/lib/pages/recommend/recommend_page.dart @@ -44,6 +44,7 @@ class RecommendPage extends StatefulWidget { class _RecommendPageState extends State { RecommendInnerTab _tab = RecommendInnerTab.feed; bool _scrubbing = false; + int _scrubCancelRequest = 0; @override Widget build(BuildContext context) { @@ -80,21 +81,40 @@ class _RecommendPageState extends State { ), ), ), - if (!_scrubbing) - Positioned( - top: 0, - left: 0, - right: 0, - child: SafeArea( - bottom: false, - child: RecommendTopNav( - selected: _tab, - onVideo: _tab == RecommendInnerTab.feed, - onSelected: (tab) => setState(() => _tab = tab), - onSearch: widget.onSearch, + Positioned( + top: 0, + left: 0, + right: 0, + child: IgnorePointer( + child: SizedBox( + height: 72, + child: DecoratedBox( + key: const Key('recommend-top-gradient'), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [AppColors.mediaBlack, AppColors.transparent], + ), + ), ), ), ), + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: SafeArea( + bottom: false, + child: RecommendTopNav( + selected: _tab, + onVideo: _tab == RecommendInnerTab.feed, + onSelected: _selectTab, + onSearch: _handleSearch, + ), + ), + ), ], ), ); @@ -141,9 +161,31 @@ class _RecommendPageState extends State { onRefreshFeed: widget.feedController.refreshCurrent, onLoadMore: widget.feedController.loadMore, onScrubVisibilityChanged: _setScrubbing, + scrubCancelRequest: _scrubCancelRequest, ); } + void _selectTab(RecommendInnerTab tab) { + final wasScrubbing = _scrubbing; + setState(() { + _tab = tab; + if (wasScrubbing) _scrubCancelRequest++; + _scrubbing = false; + }); + if (wasScrubbing) widget.onScrubVisibilityChanged(false); + } + + void _handleSearch() { + if (_scrubbing) { + setState(() { + _scrubbing = false; + _scrubCancelRequest++; + }); + widget.onScrubVisibilityChanged(false); + } + widget.onSearch(); + } + void _setScrubbing(bool value) { if (_scrubbing == value) return; setState(() => _scrubbing = value); diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index f6a9c88..cfd815d 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -23,7 +23,6 @@ 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:fruitshorts/pages/player/widgets/scrub_preview_card.dart'; import 'package:fruitshorts/pages/player/player_view.dart'; import 'package:video_player/video_player.dart'; @@ -681,8 +680,12 @@ void main() { ); expect( tester.getSize(find.byKey(const Key('home-feed-progress'))).height, - greaterThanOrEqualTo(24), + greaterThanOrEqualTo(28), ); + final idleTrackCenter = tester + .getRect(find.byKey(const Key('home-feed-progress-track'))) + .center + .dy; expect( tester .getRect(find.byKey(const Key('home-feed-progress-thumb'))) @@ -705,7 +708,33 @@ void main() { expect(scrubVisibility.last, isTrue); expect(find.byKey(const Key('home-feed-title')), findsNothing); expect(find.byKey(const Key('rail-save')), findsNothing); - expect(find.byType(ScrubPreviewCard), findsOneWidget); + expect(find.byKey(const Key('scrub-preview-time-bubble')), findsOneWidget); + final bubbleRect = tester.getRect( + find.byKey(const Key('scrub-preview-time-bubble')), + ); + expect(bubbleRect.left, greaterThanOrEqualTo(12)); + expect( + bubbleRect.right, + lessThanOrEqualTo( + tester.getRect(find.byKey(const Key('home-feed-gesture'))).right - 12, + ), + ); + await gesture.moveBy(const Offset(-600, 0)); + await tester.pump(); + expect( + tester.getRect(find.byKey(const Key('scrub-preview-time-bubble'))).left, + closeTo(12, 0.1), + ); + await gesture.moveBy(const Offset(1200, 0)); + await tester.pump(); + expect( + tester.getRect(find.byKey(const Key('scrub-preview-time-bubble'))).right, + closeTo( + tester.getRect(find.byKey(const Key('home-feed-gesture'))).right - 12, + 0.1, + ), + ); + await tester.pump(const Duration(milliseconds: 130)); expect( tester.getSize(find.byKey(const Key('home-feed-progress-thumb'))), const Size(14, 14), @@ -714,6 +743,13 @@ void main() { tester.getSize(find.byKey(const Key('home-feed-progress-track'))).height, 6, ); + expect( + tester + .getRect(find.byKey(const Key('home-feed-progress-track'))) + .center + .dy, + closeTo(idleTrackCenter, 0.1), + ); expect( tester .getRect(find.byKey(const Key('home-feed-progress-thumb'))) @@ -729,8 +765,8 @@ void main() { ); await gesture.up(); await tester.pumpAndSettle(); - expect(scrubVisibility.last, isFalse); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(scrubVisibility.last, isTrue); + expect(find.byKey(const Key('home-feed-title')), findsNothing); expect(seeked, isNotNull); expect(seeked!.inSeconds, greaterThan(0)); expect( @@ -742,6 +778,11 @@ void main() { 2, ); + await tester.tap(find.byKey(const Key('home-feed-gesture'))); + await tester.pump(); + expect(scrubVisibility.last, isFalse); + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.pump(); expect(opened, 1); diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart index d7aff3d..1cbfb35 100644 --- a/test/recommend/recommend_sections_test.dart +++ b/test/recommend/recommend_sections_test.dart @@ -35,6 +35,7 @@ void main() { expect(find.byKey(const Key('recommend-tab-playlist')), findsOneWidget); expect(find.byKey(const Key('recommend-tab-all')), findsOneWidget); expect(find.byKey(const Key('recommend-feed-player')), findsOneWidget); + expect(find.byKey(const Key('recommend-top-gradient')), findsOneWidget); final recommendText = tester.widget( find.descendant( of: find.byKey(const Key('recommend-tab-recommend')), @@ -93,6 +94,21 @@ void main() { expect(find.byType(PageView), findsOneWidget); expect(find.byKey(const Key('recommend-section-hits')), findsNothing); expect(find.byType(AppBottomNav), findsOneWidget); + + final progress = find.byKey(const Key('home-feed-progress')); + final progressCenterY = tester.getCenter(progress).dy; + final scrub = await tester.startGesture(tester.getCenter(progress)); + await scrub.moveBy(const Offset(40, 0)); + await tester.pump(const Duration(milliseconds: 130)); + expect(find.byKey(const Key('recommend-top-nav')), findsOneWidget); + expect(tester.getCenter(progress).dy, closeTo(progressCenterY, 0.1)); + await scrub.up(); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('recommend-top-nav')), findsOneWidget); + + await tester.tap(find.byKey(const Key('recommend-tab-playlist'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('recommend-section-hits')), findsOneWidget); }); testWidgets('should_render_playlist_sections_and_horizontal_cards', ( From d731b36c1f0d14cf84ee7a0ef01938c07af918a9 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 20:04:43 +0800 Subject: [PATCH 074/129] =?UTF-8?q?fix(recommend):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6=E5=8F=96=E6=B6=88=E7=8A=B6=E6=80=81=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E7=BC=96=E8=AF=91=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 1 + lib/pages/player/player_view.dart | 6 +++--- lib/pages/recommend/recommend_page.dart | 1 - 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index f5d2cc1..040637c 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1185,3 +1185,4 @@ $ flutter test - 简介「展开/收起」沿用现有三语文案;进度条热区恢复 24 并接入拖动自动清屏与预览卡片。 - 拖动期间隐藏操作栏、文字及根底栏,顶部导航常驻;松手后持续清屏直到点击画面。 - 进度条固定中心并动画到 6 高,时间气泡限制在左右 12;按当前流程未执行 analyze/test。 +- 修复 `scrubCancelRequest` 误置于 `initState` 的编译错误,并清理推荐页未使用变量;`flutter analyze` 无问题。 diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 062c93f..2fbaa88 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -138,9 +138,6 @@ class _PlayerPageState extends State downloads: widget.downloads, ); logic.attach(widget, isMounted: () => mounted); - if (oldWidget.scrubCancelRequest != widget.scrubCancelRequest) { - _cancelFeedScrub(); - } _thumbnailService = PlaybackThumbnailService( sourceResolver: _resolveThumbnailSource, ); @@ -156,6 +153,9 @@ class _PlayerPageState extends State void didUpdateWidget(covariant PlayerPage oldWidget) { super.didUpdateWidget(oldWidget); logic.attach(widget, isMounted: () => mounted); + if (oldWidget.scrubCancelRequest != widget.scrubCancelRequest) { + _cancelFeedScrub(); + } final channelChanged = oldWidget.feedChannel != widget.feedChannel; final feedChanged = _feedSignature(oldWidget.feedDramas) != diff --git a/lib/pages/recommend/recommend_page.dart b/lib/pages/recommend/recommend_page.dart index 3b524f9..2ab4a14 100644 --- a/lib/pages/recommend/recommend_page.dart +++ b/lib/pages/recommend/recommend_page.dart @@ -48,7 +48,6 @@ class _RecommendPageState extends State { @override Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; final categories = context.read(); return ColoredBox( key: const Key('recommend-page'), From b6666290ba9e128dbde4ee174b844eab5c1fc71e Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 20:08:45 +0800 Subject: [PATCH 075/129] =?UTF-8?q?fix(recommend):=20=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6=E6=8B=96=E5=8A=A8=E5=89=8D=E6=B5=AE=E5=B1=82?= =?UTF-8?q?=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 2 +- lib/core/theme/app_colors.dart | 1 + lib/pages/player/player_view.dart | 15 +++------ .../player/widgets/home_feed_overlay.dart | 29 +++++------------ .../widgets/home_feed_overlay_parts.dart | 6 ++-- test/home/home_preview_layout_test.dart | 31 ++++++++++++++----- test/recommend/recommend_sections_test.dart | 6 ++++ 7 files changed, 47 insertions(+), 43 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 040637c..c2fc4c1 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1184,5 +1184,5 @@ $ flutter test - 剧名 19/w600,箭头 20 白色并紧邻剧名;标签行改为 13/w600 亮灰内联布局。 - 简介「展开/收起」沿用现有三语文案;进度条热区恢复 24 并接入拖动自动清屏与预览卡片。 - 拖动期间隐藏操作栏、文字及根底栏,顶部导航常驻;松手后持续清屏直到点击画面。 -- 进度条固定中心并动画到 6 高,时间气泡限制在左右 12;按当前流程未执行 analyze/test。 +- 进度条固定中心并动画到 6 高,时间气泡限制在左右 12;松手恢复拖动前浮层状态。 - 修复 `scrubCancelRequest` 误置于 `initState` 的编译错误,并清理推荐页未使用变量;`flutter analyze` 无问题。 diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 7d55946..c7aca6f 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -18,6 +18,7 @@ abstract final class AppColors { static const scrim = Color(0xD1000000); static const controlScrim = Color(0x80000000); static const controlSoftScrim = Color(0x42000000); + static const timeBubbleScrim = Color(0xB3000000); static const scrubPreviewScrim = Color(0xDE000000); static const overlaySoft = Color(0x66000000); static const overlayBar = Color(0x4D000000); diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 2fbaa88..dc9d0d4 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -119,7 +119,6 @@ class _PlayerPageState extends State double _playbackSpeed = 1.0; bool _boosting = false; bool _scrubbing = false; - bool _clearScreenFromScrub = false; double? _speedBeforeBoost; bool? _chromeBeforeBoost; @@ -315,26 +314,24 @@ class _PlayerPageState extends State widget.onScrubVisibilityChanged?.call(value); } - void _finishFeedScrub() { + void _finishFeedScrub(bool wasVisible) { setState(() { _scrubbing = false; - _chromeVisible = false; - _clearScreenFromScrub = true; + _chromeVisible = wasVisible; }); + widget.onScrubVisibilityChanged?.call(!wasVisible); } void _cancelFeedScrub() { logic.cancelScrub(); _scrubbing = false; _chromeVisible = true; - _clearScreenFromScrub = false; } void _restoreFeedChrome() { setState(() { _scrubbing = false; _chromeVisible = true; - _clearScreenFromScrub = false; }); widget.onScrubVisibilityChanged?.call(false); } @@ -749,11 +746,7 @@ class _PlayerPageState extends State ? null : () => _openUnlockSheet(context, index, episode), ), - if (widget.homeMode && - !_chromeVisible && - !_boosting && - !_scrubbing && - !_clearScreenFromScrub) + if (widget.homeMode && !_chromeVisible && !_boosting && !_scrubbing) Positioned( right: 12, bottom: 56, diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index 13891d0..f254f84 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -89,7 +89,7 @@ class HomeFeedOverlay extends StatefulWidget { final VoidCallback onBoostEnd; final VoidCallback onMore; final VoidCallback? onScrubStart; - final VoidCallback? onScrubEnd; + final ValueChanged? onScrubEnd; final ValueChanged? onScrubVisibilityChanged; final ValueChanged? onSeek; final int cancelScrubRequest; @@ -102,14 +102,13 @@ class HomeFeedOverlay extends StatefulWidget { class _HomeFeedOverlayState extends State { bool _scrubbing = false; - bool _clearAfterScrub = false; + bool _wasVisibleBeforeScrub = true; @override void didUpdateWidget(covariant HomeFeedOverlay oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.cancelScrubRequest != widget.cancelScrubRequest) { _scrubbing = false; - _clearAfterScrub = false; } } @@ -138,7 +137,7 @@ class _HomeFeedOverlayState extends State { ), if (widget.boosting) Positioned(left: 0, right: 0, bottom: 20, child: _boostHint()) - else if (widget.visible && !_scrubbing && !_clearAfterScrub) + else if (widget.visible && !_scrubbing) Positioned( left: _pageHorizontal, right: _railRight, @@ -176,31 +175,19 @@ class _HomeFeedOverlayState extends State { } void _startScrub() { - if (!_scrubbing || _clearAfterScrub) { - setState(() { - _scrubbing = true; - _clearAfterScrub = false; - }); - } + _wasVisibleBeforeScrub = widget.visible; + if (!_scrubbing) setState(() => _scrubbing = true); widget.onScrubVisibilityChanged?.call(true); widget.onScrubStart?.call(); } void _endScrub() { - if (_scrubbing || !_clearAfterScrub) { - setState(() { - _scrubbing = false; - _clearAfterScrub = true; - }); - } - widget.onScrubEnd?.call(); + final wasVisible = _wasVisibleBeforeScrub; + if (_scrubbing) setState(() => _scrubbing = false); + widget.onScrubEnd?.call(wasVisible); } void _handleSurfaceTap() { - if (_clearAfterScrub) { - setState(() => _clearAfterScrub = false); - widget.onScrubVisibilityChanged?.call(false); - } widget.onTogglePlay(); } diff --git a/lib/pages/player/widgets/home_feed_overlay_parts.dart b/lib/pages/player/widgets/home_feed_overlay_parts.dart index 2e054ac..034fbab 100644 --- a/lib/pages/player/widgets/home_feed_overlay_parts.dart +++ b/lib/pages/player/widgets/home_feed_overlay_parts.dart @@ -150,7 +150,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { if (showPreview) Positioned( left: _timeBubbleLeft(width, _preview!), - bottom: widget.hitHeight + 8, + bottom: _lineCenterFromBottom + 16, child: _ScrubTimeBubble( key: const Key('scrub-preview-time-bubble'), position: previewPosition, @@ -377,9 +377,9 @@ class _ScrubTimeBubble extends StatelessWidget { Widget build(BuildContext context) { return Container( width: _timeBubbleWidth, - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), decoration: BoxDecoration( - color: AppColors.overlayMedium, + color: AppColors.timeBubbleScrim, borderRadius: BorderRadius.circular(6), ), child: Text( diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index cfd815d..bdb5012 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -719,6 +719,28 @@ void main() { tester.getRect(find.byKey(const Key('home-feed-gesture'))).right - 12, ), ); + expect( + tester + .getRect(find.byKey(const Key('home-feed-progress-track'))) + .center + .dy - + bubbleRect.bottom, + closeTo(16, 0.1), + ); + final bubbleContainer = tester.widget( + find.descendant( + of: find.byKey(const Key('scrub-preview-time-bubble')), + matching: find.byType(Container), + ), + ); + expect( + (bubbleContainer.decoration! as BoxDecoration).color, + AppColors.timeBubbleScrim, + ); + expect( + bubbleContainer.padding, + const EdgeInsets.symmetric(horizontal: 6, vertical: 3), + ); await gesture.moveBy(const Offset(-600, 0)); await tester.pump(); expect( @@ -765,8 +787,8 @@ void main() { ); await gesture.up(); await tester.pumpAndSettle(); - expect(scrubVisibility.last, isTrue); - expect(find.byKey(const Key('home-feed-title')), findsNothing); + expect(scrubVisibility.last, isFalse); + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); expect(seeked, isNotNull); expect(seeked!.inSeconds, greaterThan(0)); expect( @@ -778,11 +800,6 @@ void main() { 2, ); - await tester.tap(find.byKey(const Key('home-feed-gesture'))); - await tester.pump(); - expect(scrubVisibility.last, isFalse); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); - await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.pump(); expect(opened, 1); diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart index 1cbfb35..7646fbe 100644 --- a/test/recommend/recommend_sections_test.dart +++ b/test/recommend/recommend_sections_test.dart @@ -102,9 +102,15 @@ void main() { await tester.pump(const Duration(milliseconds: 130)); expect(find.byKey(const Key('recommend-top-nav')), findsOneWidget); expect(tester.getCenter(progress).dy, closeTo(progressCenterY, 0.1)); + final navVisibility = find.ancestor( + of: find.byKey(const Key('home-tabs-bar')), + matching: find.byType(Visibility), + ); + expect(tester.widget(navVisibility.first).visible, isFalse); await scrub.up(); await tester.pumpAndSettle(); expect(find.byKey(const Key('recommend-top-nav')), findsOneWidget); + expect(tester.widget(navVisibility.first).visible, isTrue); await tester.tap(find.byKey(const Key('recommend-tab-playlist'))); await tester.pumpAndSettle(); From 276534334093a25f5aff836f36cdfb7011fb8cdb Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 20:15:07 +0800 Subject: [PATCH 076/129] =?UTF-8?q?fix(recommend):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6=E6=8B=96=E5=8A=A8=E4=B8=8E=E5=BA=95=E6=A0=8F?= =?UTF-8?q?=E8=81=94=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 5 +- lib/pages/home/home_logic.dart | 7 -- lib/pages/home/home_view.dart | 11 +- lib/pages/player/player_logic.dart | 8 +- lib/pages/player/player_view.dart | 10 +- .../player/widgets/home_feed_overlay.dart | 4 +- .../widgets/home_feed_overlay_parts.dart | 104 +++++++++--------- lib/pages/recommend/recommend_page.dart | 23 +--- test/recommend/recommend_sections_test.dart | 14 ++- 9 files changed, 79 insertions(+), 107 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index c2fc4c1..48c4c2b 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1183,6 +1183,7 @@ $ flutter test - 按最新反馈移除整行榜单与热度胶囊,信息顺序收敛为标题、剧名、标签、简介、观看全集。 - 剧名 19/w600,箭头 20 白色并紧邻剧名;标签行改为 13/w600 亮灰内联布局。 - 简介「展开/收起」沿用现有三语文案;进度条热区恢复 24 并接入拖动自动清屏与预览卡片。 -- 拖动期间隐藏操作栏、文字及根底栏,顶部导航常驻;松手后持续清屏直到点击画面。 -- 进度条固定中心并动画到 6 高,时间气泡限制在左右 12;松手恢复拖动前浮层状态。 +- 拖动仅控制视频浮层内容,顶部导航与根底栏保持常驻。 +- 进度条固定中心并动画到 6 高,时间气泡限制在左右 12;end/cancel/pointer 兜底均恢复拖动前状态。 - 修复 `scrubCancelRequest` 误置于 `initState` 的编译错误,并清理推荐页未使用变量;`flutter analyze` 无问题。 +- 观看全集按钮相对进度条上移 8,间距调整为 20;`flutter analyze` 无问题。 diff --git a/lib/pages/home/home_logic.dart b/lib/pages/home/home_logic.dart index dab2d46..dfd1f00 100644 --- a/lib/pages/home/home_logic.dart +++ b/lib/pages/home/home_logic.dart @@ -23,7 +23,6 @@ class HomeLogic extends ChangeNotifier { bool drawerOpen = false; bool detailOpen = false; bool videoImmersive = false; - bool scrubHidden = false; bool _dramaNotifyScheduled = false; bool _disposed = false; @@ -145,12 +144,6 @@ class HomeLogic extends ChangeNotifier { notify(); } - void onScrubVisibilityChanged(bool value) { - if (scrubHidden == value) return; - scrubHidden = value; - notify(); - } - @override void dispose() { _disposed = true; diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 615bf61..d8e309e 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -239,15 +239,7 @@ class _HomeTabsState extends State<_HomeTabs> { backgroundColor: AppColors.transparent, resizeToAvoidBottomInset: false, body: _body(context), - bottomNavigationBar: logic.videoImmersive - ? null - : Visibility( - visible: !logic.scrubHidden, - maintainSize: true, - maintainAnimation: true, - maintainState: true, - child: _nav(context), - ), + bottomNavigationBar: logic.videoImmersive ? null : _nav(context), ), ); return AnnotatedRegion( @@ -296,7 +288,6 @@ class _HomeTabsState extends State<_HomeTabs> { active: logic.tab == 1, onSearch: () => _openSearch(context), onOpenFilter: () => _pushPage(context, const FilterPage()), - onScrubVisibilityChanged: logic.onScrubVisibilityChanged, ); } diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 3ad74ec..8060bd8 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -914,15 +914,19 @@ class PlayerLogic extends ChangeNotifier { void beginScrub(VideoPlayerPort player) { if (session.active != player) return; scrubbingPlayer = player; - session.setOverlayOpen(true); } void cancelScrub() { if (scrubbingPlayer == null) return; scrubbingPlayer = null; - session.setOverlayOpen(false); } + Future endScrub( + BuildContext context, + VideoPlayerPort player, + Duration position, + ) => seekTo(context, player, position); + Future seekTo( BuildContext context, VideoPlayerPort player, diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index dc9d0d4..1628224 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -70,7 +70,6 @@ class PlayerPage extends StatefulWidget { this.showHomeFeedHeader = true, this.onDramaChanged, this.onImmersiveChanged, - this.onScrubVisibilityChanged, this.onOpenFullPlayer, this.onRefreshFeed, this.onLoadMore, @@ -97,7 +96,6 @@ class PlayerPage extends StatefulWidget { final bool showHomeFeedHeader; final ValueChanged? onDramaChanged; final ValueChanged? onImmersiveChanged; - final ValueChanged? onScrubVisibilityChanged; final Future Function(Drama drama, Episode episode)? onOpenFullPlayer; final Future Function()? onRefreshFeed; final Future Function()? onLoadMore; @@ -273,7 +271,6 @@ class _PlayerPageState extends State void _hideChrome() { if (!_chromeVisible) return; setState(() => _chromeVisible = false); - widget.onScrubVisibilityChanged?.call(true); } /// 长按两侧:切 2 倍速并清屏,记下进入前的状态用于松手恢复。 @@ -288,7 +285,6 @@ class _PlayerPageState extends State _playbackSpeed = _boostPlaybackSpeed; _chromeVisible = false; }); - widget.onScrubVisibilityChanged?.call(true); unawaited(logic.setPlaybackSpeed(_boostPlaybackSpeed)); } @@ -304,14 +300,12 @@ class _PlayerPageState extends State _playbackSpeed = speed; _chromeVisible = chrome; }); - widget.onScrubVisibilityChanged?.call(!chrome); unawaited(logic.setPlaybackSpeed(speed)); } void _setScrubbing(bool value) { if (_scrubbing == value) return; setState(() => _scrubbing = value); - widget.onScrubVisibilityChanged?.call(value); } void _finishFeedScrub(bool wasVisible) { @@ -319,7 +313,6 @@ class _PlayerPageState extends State _scrubbing = false; _chromeVisible = wasVisible; }); - widget.onScrubVisibilityChanged?.call(!wasVisible); } void _cancelFeedScrub() { @@ -333,7 +326,6 @@ class _PlayerPageState extends State _scrubbing = false; _chromeVisible = true; }); - widget.onScrubVisibilityChanged?.call(false); } void _handleFeedSurfaceTap() { @@ -676,7 +668,7 @@ class _PlayerPageState extends State onScrubVisibilityChanged: _setScrubbing, cancelScrubRequest: widget.scrubCancelRequest, onSeek: ready - ? (position) => logic.seekTo(context, videoPort!, position) + ? (position) => logic.endScrub(context, videoPort!, position) : null, thumbnailService: _thumbnailService, spriteAxisService: _spriteAxisService, diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index f254f84..6b7fa8f 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -22,6 +22,7 @@ const double _progressHitHeight = 28; const double _pageHorizontal = 14; const double _railRight = 8; const double _railGap = 12; +const double _bottomInfoInset = 20; const double _railBottomOffset = 60; const List _boostTextShadows = [ Shadow(color: AppColors.shadow, blurRadius: 5, offset: Offset(0, 1)), @@ -141,7 +142,7 @@ class _HomeFeedOverlayState extends State { Positioned( left: _pageHorizontal, right: _railRight, - bottom: 12, + bottom: _bottomInfoInset, child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ @@ -184,6 +185,7 @@ class _HomeFeedOverlayState extends State { void _endScrub() { final wasVisible = _wasVisibleBeforeScrub; if (_scrubbing) setState(() => _scrubbing = false); + widget.onScrubVisibilityChanged?.call(false); widget.onScrubEnd?.call(wasVisible); } diff --git a/lib/pages/player/widgets/home_feed_overlay_parts.dart b/lib/pages/player/widgets/home_feed_overlay_parts.dart index 034fbab..2f16eab 100644 --- a/lib/pages/player/widgets/home_feed_overlay_parts.dart +++ b/lib/pages/player/widgets/home_feed_overlay_parts.dart @@ -43,6 +43,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { double? _preview; Timer? _bufferTimer; bool _buffering = false; + bool _dragging = false; @override void initState() { @@ -61,6 +62,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { _attachSource(); } if (oldWidget.cancelScrubRequest != widget.cancelScrubRequest) { + _dragging = false; _preview = null; } if (oldWidget.locked != widget.locked) { @@ -68,6 +70,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { _bufferTimer = null; _buffering = false; if (widget.locked) { + _dragging = false; _preview = null; } else { _syncBufferState(notify: false); @@ -88,7 +91,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { builder: (context, constraints) { final width = constraints.maxWidth; final source = widget.controller; - final scrubbing = _preview != null; + final scrubbing = _dragging; final loading = !widget.locked && !scrubbing && _buffering; final track = widget.locked ? _inactiveTrack() @@ -115,49 +118,41 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { !loading && (source?.value.isInitialized ?? false); final video = source?.value; - final previewPosition = _preview == null || video == null + final previewPosition = !_dragging || _preview == null || video == null ? null : seekPositionForFraction(_preview!, video.duration); final showPreview = previewPosition != null; - return GestureDetector( - key: const Key('home-feed-progress'), + return Listener( behavior: HitTestBehavior.opaque, - onHorizontalDragStart: interactive - ? (details) { - widget.onScrubStart?.call(); - _update(details.localPosition.dx / width); - } - : null, - onHorizontalDragUpdate: interactive - ? (details) => _update(details.localPosition.dx / width) - : null, - onHorizontalDragEnd: interactive - ? (_) { - widget.onScrubEnd?.call(); - _commit(_preview); - } - : null, - onHorizontalDragCancel: interactive - ? () { - widget.onScrubEnd?.call(); - _cancelPreview(); - } - : null, - child: Stack( - clipBehavior: Clip.none, - children: [ - track, - if (showPreview) - Positioned( - left: _timeBubbleLeft(width, _preview!), - bottom: _lineCenterFromBottom + 16, - child: _ScrubTimeBubble( - key: const Key('scrub-preview-time-bubble'), - position: previewPosition, - duration: video!.duration, + onPointerUp: (_) => _finishScrub(), + onPointerCancel: (_) => _finishScrub(), + child: GestureDetector( + key: const Key('home-feed-progress'), + behavior: HitTestBehavior.opaque, + onHorizontalDragStart: interactive + ? (details) => _startScrub(details.localPosition.dx / width) + : null, + onHorizontalDragUpdate: interactive + ? (details) => _update(details.localPosition.dx / width) + : null, + onHorizontalDragEnd: interactive ? (_) => _finishScrub() : null, + onHorizontalDragCancel: interactive ? _finishScrub : null, + child: Stack( + clipBehavior: Clip.none, + children: [ + track, + if (showPreview) + Positioned( + left: _timeBubbleLeft(width, _preview!), + bottom: _lineCenterFromBottom + 16, + child: _ScrubTimeBubble( + key: const Key('scrub-preview-time-bubble'), + position: previewPosition, + duration: video!.duration, + ), ), - ), - ], + ], + ), ), ); }, @@ -259,23 +254,32 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { ); } + void _startScrub(double fraction) { + if (_dragging) return; + setState(() { + _dragging = true; + _preview = fraction.clamp(0.0, 1.0).toDouble(); + }); + widget.onScrubStart?.call(); + } + void _update(double fraction) { + if (!_dragging) return; setState(() => _preview = fraction.clamp(0.0, 1.0).toDouble()); } - void _commit(double? fraction) { + void _finishScrub() { + if (!_dragging) return; + final fraction = _preview; + setState(() { + _dragging = false; + _preview = null; + }); + widget.onScrubEnd?.call(); if (widget.locked || fraction == null) return; final total = widget.controller?.value.duration.inMilliseconds ?? 0; - if (total > 0) { - widget.onSeek?.call( - Duration(milliseconds: (total * fraction.clamp(0.0, 1.0)).round()), - ); - } - setState(() => _preview = null); - } - - void _cancelPreview() { - setState(() => _preview = null); + if (total <= 0) return; + widget.onSeek?.call(Duration(milliseconds: (total * fraction).round())); } Widget _track(double fraction, double width) { diff --git a/lib/pages/recommend/recommend_page.dart b/lib/pages/recommend/recommend_page.dart index 2ab4a14..c409d8f 100644 --- a/lib/pages/recommend/recommend_page.dart +++ b/lib/pages/recommend/recommend_page.dart @@ -28,14 +28,12 @@ class RecommendPage extends StatefulWidget { required this.active, required this.onSearch, required this.onOpenFilter, - required this.onScrubVisibilityChanged, }); final HomeFeedController feedController; final bool active; final VoidCallback onSearch; final VoidCallback onOpenFilter; - final ValueChanged onScrubVisibilityChanged; @override State createState() => _RecommendPageState(); @@ -43,7 +41,6 @@ class RecommendPage extends StatefulWidget { class _RecommendPageState extends State { RecommendInnerTab _tab = RecommendInnerTab.feed; - bool _scrubbing = false; int _scrubCancelRequest = 0; @override @@ -159,38 +156,22 @@ class _RecommendPageState extends State { _openDrama(drama, episodeId: episode.id), onRefreshFeed: widget.feedController.refreshCurrent, onLoadMore: widget.feedController.loadMore, - onScrubVisibilityChanged: _setScrubbing, scrubCancelRequest: _scrubCancelRequest, ); } void _selectTab(RecommendInnerTab tab) { - final wasScrubbing = _scrubbing; setState(() { _tab = tab; - if (wasScrubbing) _scrubCancelRequest++; - _scrubbing = false; + _scrubCancelRequest++; }); - if (wasScrubbing) widget.onScrubVisibilityChanged(false); } void _handleSearch() { - if (_scrubbing) { - setState(() { - _scrubbing = false; - _scrubCancelRequest++; - }); - widget.onScrubVisibilityChanged(false); - } + setState(() => _scrubCancelRequest++); widget.onSearch(); } - void _setScrubbing(bool value) { - if (_scrubbing == value) return; - setState(() => _scrubbing = value); - widget.onScrubVisibilityChanged(value); - } - Widget _withTopNavSpace(Widget child) { return SafeArea( bottom: false, diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart index 7646fbe..b96f63b 100644 --- a/test/recommend/recommend_sections_test.dart +++ b/test/recommend/recommend_sections_test.dart @@ -102,15 +102,19 @@ void main() { await tester.pump(const Duration(milliseconds: 130)); expect(find.byKey(const Key('recommend-top-nav')), findsOneWidget); expect(tester.getCenter(progress).dy, closeTo(progressCenterY, 0.1)); - final navVisibility = find.ancestor( - of: find.byKey(const Key('home-tabs-bar')), - matching: find.byType(Visibility), + expect(find.byType(AppBottomNav), findsOneWidget); + expect( + tester.getSize(find.byKey(const Key('home-tabs-bar'))).height, + AppDimens.navHeight, ); - expect(tester.widget(navVisibility.first).visible, isFalse); await scrub.up(); await tester.pumpAndSettle(); expect(find.byKey(const Key('recommend-top-nav')), findsOneWidget); - expect(tester.widget(navVisibility.first).visible, isTrue); + expect(find.byType(AppBottomNav), findsOneWidget); + expect( + tester.getSize(find.byKey(const Key('home-tabs-bar'))).height, + AppDimens.navHeight, + ); await tester.tap(find.byKey(const Key('recommend-tab-playlist'))); await tester.pumpAndSettle(); From 5abee5f69a5256e3ceb5c14623acd80c0ddf0951 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 20:16:13 +0800 Subject: [PATCH 077/129] =?UTF-8?q?style(recommend):=20=E6=94=B6=E7=AA=84?= =?UTF-8?q?=E8=A7=82=E7=9C=8B=E5=85=A8=E9=9B=86=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 2 +- .../widgets/home_feed_recommend_panel.dart | 67 +++++++++++-------- test/home/home_preview_layout_test.dart | 5 ++ 3 files changed, 45 insertions(+), 29 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 48c4c2b..4b0b372 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1186,4 +1186,4 @@ $ flutter test - 拖动仅控制视频浮层内容,顶部导航与根底栏保持常驻。 - 进度条固定中心并动画到 6 高,时间气泡限制在左右 12;end/cancel/pointer 兜底均恢复拖动前状态。 - 修复 `scrubCancelRequest` 误置于 `initState` 的编译错误,并清理推荐页未使用变量;`flutter analyze` 无问题。 -- 观看全集按钮相对进度条上移 8,间距调整为 20;`flutter analyze` 无问题。 +- 观看全集按钮相对进度条上移 8,间距调整为 20;按钮改为屏幕宽度 70% 左对齐,`flutter analyze` 无问题。 diff --git a/lib/pages/player/widgets/home_feed_recommend_panel.dart b/lib/pages/player/widgets/home_feed_recommend_panel.dart index 5d92347..6ca74a0 100644 --- a/lib/pages/player/widgets/home_feed_recommend_panel.dart +++ b/lib/pages/player/widgets/home_feed_recommend_panel.dart @@ -1,5 +1,7 @@ // Author: fruit.com.cn +import 'dart:math' as math; + import 'package:flutter/material.dart'; import '../../../core/data/models/drama.dart'; @@ -106,7 +108,7 @@ class _HomeFeedInfoPanelState extends State { _synopsis(l10n, synopsis), ], const SizedBox(height: 12), - _watchAll(l10n), + _watchAll(context, l10n), ], ); } @@ -245,37 +247,46 @@ class _HomeFeedInfoPanelState extends State { ); } - Widget _watchAll(AppLocalizations l10n) { - return SizedBox( - width: double.infinity, - height: _watchAllHeight, - child: DecoratedBox( - key: const Key('home-feed-full-series-bg'), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [AppColors.actionRed, AppColors.blue], - ), - borderRadius: BorderRadius.circular(8), - ), - child: Material( - color: AppColors.transparent, - child: InkWell( - key: const Key('home-feed-full-series'), - onTap: widget.onOpenFullPlayer, - borderRadius: BorderRadius.circular(8), - child: Center( - child: Text( - l10n.homeWatchAll, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 15, - fontWeight: FontWeight.w600, + Widget _watchAll(BuildContext context, AppLocalizations l10n) { + final screenWidth = MediaQuery.sizeOf(context).width; + return LayoutBuilder( + builder: (context, constraints) { + final buttonWidth = math.min(screenWidth * 0.7, constraints.maxWidth); + return Align( + alignment: Alignment.centerLeft, + child: SizedBox( + width: buttonWidth, + height: _watchAllHeight, + child: DecoratedBox( + key: const Key('home-feed-full-series-bg'), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [AppColors.actionRed, AppColors.blue], + ), + borderRadius: BorderRadius.circular(8), + ), + child: Material( + color: AppColors.transparent, + child: InkWell( + key: const Key('home-feed-full-series'), + onTap: widget.onOpenFullPlayer, + borderRadius: BorderRadius.circular(8), + child: Center( + child: Text( + l10n.homeWatchAll, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), ), ), ), ), - ), - ), + ); + }, ); } diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index bdb5012..0fa0662 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -659,6 +659,11 @@ void main() { final bar = tester.getRect(find.byKey(const Key('home-feed-full-series'))); expect(bar.height, 44); + final overlayRect = tester.getRect( + find.byKey(const Key('home-feed-gesture')), + ); + expect(bar.left, closeTo(overlayRect.left + 14, 0.1)); + expect(bar.width, closeTo(overlayRect.width * 0.7, 1)); final buttonBackground = tester .widget( From 04f1f5a73f2f7b5732fad26d168da70e97578e87 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 20:19:21 +0800 Subject: [PATCH 078/129] =?UTF-8?q?fix(recommend):=20=E6=94=B6=E7=AA=84?= =?UTF-8?q?=E6=8B=96=E5=8A=A8=E6=B8=85=E5=B1=8F=E8=8C=83=E5=9B=B4=E5=B9=B6?= =?UTF-8?q?=E6=8F=90=E5=8D=87=E7=81=B5=E6=95=8F=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 2 +- lib/pages/player/widgets/home_feed_overlay.dart | 5 +++-- .../player/widgets/home_feed_overlay_parts.dart | 1 + test/home/home_preview_layout_test.dart | 14 +++++++++++++- 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 4b0b372..8682c22 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1184,6 +1184,6 @@ $ flutter test - 剧名 19/w600,箭头 20 白色并紧邻剧名;标签行改为 13/w600 亮灰内联布局。 - 简介「展开/收起」沿用现有三语文案;进度条热区恢复 24 并接入拖动自动清屏与预览卡片。 - 拖动仅控制视频浮层内容,顶部导航与根底栏保持常驻。 -- 进度条固定中心并动画到 6 高,时间气泡限制在左右 12;end/cancel/pointer 兜底均恢复拖动前状态。 +- 进度条固定中心并动画到 6 高,命中高度 32,时间气泡限制在左右 12;end/cancel/pointer 兜底均恢复拖动前状态。 - 修复 `scrubCancelRequest` 误置于 `initState` 的编译错误,并清理推荐页未使用变量;`flutter analyze` 无问题。 - 观看全集按钮相对进度条上移 8,间距调整为 20;按钮改为屏幕宽度 70% 左对齐,`flutter analyze` 无问题。 diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index 6b7fa8f..bd0433b 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; @@ -18,11 +19,11 @@ import 'seek_progress_bar.dart'; part 'home_feed_overlay_parts.dart'; const double _progressHeight = 2; -const double _progressHitHeight = 28; +const double _progressHitHeight = 32; const double _pageHorizontal = 14; const double _railRight = 8; const double _railGap = 12; -const double _bottomInfoInset = 20; +const double _bottomInfoInset = 32; const double _railBottomOffset = 60; const List _boostTextShadows = [ Shadow(color: AppColors.shadow, blurRadius: 5, offset: Offset(0, 1)), diff --git a/lib/pages/player/widgets/home_feed_overlay_parts.dart b/lib/pages/player/widgets/home_feed_overlay_parts.dart index 2f16eab..6bf6483 100644 --- a/lib/pages/player/widgets/home_feed_overlay_parts.dart +++ b/lib/pages/player/widgets/home_feed_overlay_parts.dart @@ -129,6 +129,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { child: GestureDetector( key: const Key('home-feed-progress'), behavior: HitTestBehavior.opaque, + dragStartBehavior: DragStartBehavior.down, onHorizontalDragStart: interactive ? (details) => _startScrub(details.localPosition.dx / width) : null, diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 0fa0662..600bec6 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'dart:typed_data'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; @@ -664,6 +665,11 @@ void main() { ); expect(bar.left, closeTo(overlayRect.left + 14, 0.1)); expect(bar.width, closeTo(overlayRect.width * 0.7, 1)); + expect( + tester.getCenter(find.byKey(const Key('home-feed-progress-track'))).dy - + bar.bottom, + greaterThanOrEqualTo(20), + ); final buttonBackground = tester .widget( @@ -685,7 +691,13 @@ void main() { ); expect( tester.getSize(find.byKey(const Key('home-feed-progress'))).height, - greaterThanOrEqualTo(28), + greaterThanOrEqualTo(32), + ); + expect( + tester + .widget(find.byKey(const Key('home-feed-progress'))) + .dragStartBehavior, + DragStartBehavior.down, ); final idleTrackCenter = tester .getRect(find.byKey(const Key('home-feed-progress-track'))) From d66ff13d0f4d63c5c39c54c25d499ff89ad9811f Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 20:23:24 +0800 Subject: [PATCH 079/129] =?UTF-8?q?fix(player):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6=E6=9D=A1=E6=8C=89=E4=B8=8B=E4=B8=8E=E6=8B=96?= =?UTF-8?q?=E5=8A=A8=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 6 + .../player/widgets/seek_progress_bar.dart | 92 ++++++++--- test/player/seek_scrub_visibility_test.dart | 149 ++++++++++++++++++ 3 files changed, 221 insertions(+), 26 deletions(-) create mode 100644 test/player/seek_scrub_visibility_test.dart diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 8682c22..3de180c 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1187,3 +1187,9 @@ $ flutter test - 进度条固定中心并动画到 6 高,命中高度 32,时间气泡限制在左右 12;end/cancel/pointer 兜底均恢复拖动前状态。 - 修复 `scrubCancelRequest` 误置于 `initState` 的编译错误,并清理推荐页未使用变量;`flutter analyze` 无问题。 - 观看全集按钮相对进度条上移 8,间距调整为 20;按钮改为屏幕宽度 70% 左对齐,`flutter analyze` 无问题。 + +## 2026-10-22 进度条按下与拖动彻底修复 + +- Slider 不再在 `onChangeStart` 清屏;首次真实移动后才进入 scrub,轨道 2/6、thumb 6/8。 +- 推荐流拖动使用独立 opaque 手势与 pointer 兜底,end/cancel 同时恢复浮层和轨道。 +- `flutter test test/player/seek_scrub_visibility_test.dart`:tap、首次移动、松手恢复 3 项测试全部通过。 diff --git a/lib/pages/player/widgets/seek_progress_bar.dart b/lib/pages/player/widgets/seek_progress_bar.dart index 9723933..00a9023 100644 --- a/lib/pages/player/widgets/seek_progress_bar.dart +++ b/lib/pages/player/widgets/seek_progress_bar.dart @@ -30,6 +30,7 @@ class SeekProgressBar extends StatefulWidget { required this.valueListenable, required this.semanticsLabel, this.onScrubStart, + this.onScrubEnd, this.onSeek, this.showTimes = true, this.compact = false, @@ -45,6 +46,7 @@ class SeekProgressBar extends StatefulWidget { final ValueListenable? valueListenable; final String semanticsLabel; final VoidCallback? onScrubStart; + final VoidCallback? onScrubEnd; final Future Function(Duration)? onSeek; final bool showTimes; final bool compact; @@ -66,6 +68,10 @@ class _SeekProgressBarState extends State { final _previewFraction = ValueNotifier(null); Timer? _bufferTimer; bool _buffering = false; + bool _dragging = false; + bool _pointerMoved = false; + Offset? _pointerStart; + double? _dragStartFraction; @override void initState() { @@ -165,10 +171,14 @@ class _SeekProgressBarState extends State { } Future _finish(double fraction, Duration duration) async { - if (widget.locked) return; - _previewFraction.value = fraction; + if (widget.locked || !_dragging) return; + setState(() => _dragging = false); + _pointerMoved = false; + _pointerStart = null; + _dragStartFraction = null; widget.thumbnailService?.cancelPending(); _previewFraction.value = null; + widget.onScrubEnd?.call(); await widget.onSeek?.call(seekPositionForFraction(fraction, duration)); } @@ -193,7 +203,7 @@ class _SeekProgressBarState extends State { Widget _buildTrack(BuildContext context, VideoPlayerValue video) { final total = video.duration.inMilliseconds; - final scrubbing = _previewFraction.value != null; + final scrubbing = _dragging; final loading = !widget.locked && !scrubbing && _buffering; final enabled = !widget.locked && @@ -212,9 +222,7 @@ class _SeekProgressBarState extends State { widget.showTimes && widget.thumbnailService != null && (widget.episodeId?.isNotEmpty ?? false); - final trackHeight = widget.compact - ? (scrubbing ? 3.0 : 1.5) - : (scrubbing ? 4.0 : 3.0); + final trackHeight = scrubbing ? 6.0 : 2.0; final trackAreaHeight = loading ? (widget.compact ? 20.0 : 44.0) : widget.compact @@ -258,9 +266,7 @@ class _SeekProgressBarState extends State { inactiveTrackColor: widget.inactiveTrackColor, thumbColor: AppColors.onPrimary, thumbShape: RoundSliderThumbShape( - enabledThumbRadius: widget.compact - ? (scrubbing ? 6 : 4) - : (scrubbing ? 8 : 6), + enabledThumbRadius: scrubbing ? 8 : 6, ), overlayShape: RoundSliderOverlayShape( overlayRadius: widget.compact ? 12 : 18, @@ -268,23 +274,57 @@ class _SeekProgressBarState extends State { ), 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, + child: Listener( + onPointerDown: (event) { + _pointerStart = event.position; + _pointerMoved = false; + }, + onPointerMove: (event) { + final start = _pointerStart; + if (start != null && + (event.position - start).distance > 0) { + _pointerMoved = true; + } + }, + onPointerUp: (_) { + if (!_dragging) { + _pointerMoved = false; + _pointerStart = null; + _dragStartFraction = null; + } + }, + onPointerCancel: (_) { + if (!_dragging) { + _pointerMoved = false; + _pointerStart = null; + _dragStartFraction = null; + } + }, + child: Slider( + value: fraction, + // 轨道与上方文案共用同一层水平内边距,避免 Slider 默认内缩。 + padding: EdgeInsets.zero, + onChangeStart: enabled + ? (value) { + _dragStartFraction = value; + _pointerMoved = false; + } + : null, + onChanged: enabled + ? (value) { + if (!_pointerMoved) return; + if (!_dragging) { + setState(() => _dragging = true); + widget.onScrubStart?.call(); + } + _previewFraction.value = value; + } + : null, + onChangeEnd: enabled + ? (value) => + unawaited(_finish(value, video.duration)) + : null, + ), ), ), ), diff --git a/test/player/seek_scrub_visibility_test.dart b/test/player/seek_scrub_visibility_test.dart new file mode 100644 index 0000000..8117f07 --- /dev/null +++ b/test/player/seek_scrub_visibility_test.dart @@ -0,0 +1,149 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/pages/player/widgets/seek_progress_bar.dart'; +import 'package:video_player/video_player.dart'; + +void main() { + testWidgets('tapping the progress bar does not hide the overlay', ( + tester, + ) async { + final harness = await _pumpHarness(tester); + + await tester.tap(find.byType(Slider)); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('scrub-overlay')), findsOneWidget); + expect(harness.scrubStarts, 0); + expect(harness.seeks, 0); + expect(_sliderTheme(tester).trackHeight, 2); + }); + + testWidgets('horizontal drag hides the overlay and thickens the track', ( + tester, + ) async { + final harness = await _pumpHarness(tester); + final slider = find.byType(Slider); + final gesture = await tester.startGesture(tester.getCenter(slider)); + + await gesture.moveBy(const Offset(24, 0)); + await tester.pump(); + expect(find.byKey(const Key('scrub-overlay')), findsNothing); + expect(harness.scrubStarts, 1); + expect(_sliderTheme(tester).trackHeight, 6); + expect( + (_sliderTheme(tester).thumbShape! as RoundSliderThumbShape) + .enabledThumbRadius, + 8, + ); + + await gesture.moveBy(const Offset(72, 0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('releasing restores the overlay and idle track height', ( + tester, + ) async { + final harness = await _pumpHarness(tester); + final gesture = await tester.startGesture( + tester.getCenter(find.byType(Slider)), + ); + await gesture.moveBy(const Offset(32, 0)); + await tester.pump(); + await gesture.moveBy(const Offset(64, 0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('scrub-overlay')), findsOneWidget); + expect(harness.scrubEnds, 1); + expect(harness.seeks, 1); + expect(_sliderTheme(tester).trackHeight, 2); + expect( + (_sliderTheme(tester).thumbShape! as RoundSliderThumbShape) + .enabledThumbRadius, + 6, + ); + }); +} + +Future<_HarnessState> _pumpHarness(WidgetTester tester) async { + final controller = ValueNotifier( + const VideoPlayerValue( + duration: Duration(minutes: 2), + position: Duration(seconds: 20), + isInitialized: true, + ), + ); + addTearDown(controller.dispose); + final key = GlobalKey<_HarnessState>(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: _Harness(key: key, controller: controller), + ), + ), + ); + await tester.pumpAndSettle(); + return key.currentState!; +} + +SliderThemeData _sliderTheme(WidgetTester tester) { + return tester + .widget( + find + .ancestor( + of: find.byType(Slider), + matching: find.byType(SliderTheme), + ) + .first, + ) + .data; +} + +class _Harness extends StatefulWidget { + const _Harness({super.key, required this.controller}); + + final ValueNotifier controller; + + @override + State<_Harness> createState() => _HarnessState(); +} + +class _HarnessState extends State<_Harness> { + bool visible = true; + int scrubStarts = 0; + int scrubEnds = 0; + int seeks = 0; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + if (visible) const Text('Overlay', key: Key('scrub-overlay')), + SizedBox( + width: 320, + height: 80, + child: SeekProgressBar( + valueListenable: widget.controller, + semanticsLabel: 'Progress', + onScrubStart: () => setState(() { + visible = false; + scrubStarts++; + }), + onScrubEnd: () => setState(() { + visible = true; + scrubEnds++; + }), + onSeek: (_) async { + seeks++; + }, + ), + ), + ], + ); + } +} From cf6d12b7c8d71afdb870dc86b7b8e363fa16b618 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 20:26:58 +0800 Subject: [PATCH 080/129] =?UTF-8?q?fix(player):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6=E6=9D=A1=E6=8B=96=E5=8A=A8=E6=A0=91=E7=A8=B3?= =?UTF-8?q?=E5=AE=9A=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 3 +- lib/pages/player/player_view.dart | 3 + .../player/widgets/home_feed_overlay.dart | 58 ++++--- .../widgets/home_feed_overlay_parts.dart | 6 +- test/home/home_preview_layout_test.dart | 28 +++- test/player/seek_scrub_visibility_test.dart | 141 ++++++++++++++++++ 6 files changed, 216 insertions(+), 23 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 3de180c..049c0a1 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1192,4 +1192,5 @@ $ flutter test - Slider 不再在 `onChangeStart` 清屏;首次真实移动后才进入 scrub,轨道 2/6、thumb 6/8。 - 推荐流拖动使用独立 opaque 手势与 pointer 兜底,end/cancel 同时恢复浮层和轨道。 -- `flutter test test/player/seek_scrub_visibility_test.dart`:tap、首次移动、松手恢复 3 项测试全部通过。 +- 推荐浮层改为 AnimatedOpacity + IgnorePointer,进度条节点始终留在树中;scrub setState 均加 mounted 守卫。 +- `flutter test test/player/seek_scrub_visibility_test.dart`:6 项测试全部通过。 diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 1628224..3c2b3ef 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -304,11 +304,13 @@ class _PlayerPageState extends State } void _setScrubbing(bool value) { + if (!mounted) return; if (_scrubbing == value) return; setState(() => _scrubbing = value); } void _finishFeedScrub(bool wasVisible) { + if (!mounted) return; setState(() { _scrubbing = false; _chromeVisible = wasVisible; @@ -322,6 +324,7 @@ class _PlayerPageState extends State } void _restoreFeedChrome() { + if (!mounted) return; setState(() { _scrubbing = false; _chromeVisible = true; diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index bd0433b..f3ed2e1 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -25,6 +25,7 @@ const double _railRight = 8; const double _railGap = 12; const double _bottomInfoInset = 32; const double _railBottomOffset = 60; +const Duration _overlayFadeDuration = Duration(milliseconds: 120); const List _boostTextShadows = [ Shadow(color: AppColors.shadow, blurRadius: 5, offset: Offset(0, 1)), ]; @@ -137,25 +138,46 @@ class _HomeFeedOverlayState extends State { ), ), ), - if (widget.boosting) - Positioned(left: 0, right: 0, bottom: 20, child: _boostHint()) - else if (widget.visible && !_scrubbing) - Positioned( - left: _pageHorizontal, - right: _railRight, - bottom: _bottomInfoInset, - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded(child: _bottomInfo()), - const SizedBox(width: _railGap), - Padding( - padding: const EdgeInsets.only(bottom: _railBottomOffset), - child: _rail(), - ), - ], + Positioned( + left: _pageHorizontal, + right: _railRight, + bottom: _bottomInfoInset, + child: AnimatedOpacity( + key: const Key('home-feed-content-opacity'), + duration: _overlayFadeDuration, + opacity: widget.visible && !_scrubbing && !widget.boosting + ? 1 + : 0, + child: IgnorePointer( + key: const Key('home-feed-content-ignore'), + ignoring: !widget.visible || _scrubbing || widget.boosting, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded(child: _bottomInfo()), + const SizedBox(width: _railGap), + Padding( + padding: const EdgeInsets.only(bottom: _railBottomOffset), + child: _rail(), + ), + ], + ), ), ), + ), + Positioned( + left: 0, + right: 0, + bottom: 20, + child: AnimatedOpacity( + duration: _overlayFadeDuration, + opacity: widget.boosting ? 1 : 0, + child: IgnorePointer( + ignoring: !widget.boosting, + child: _boostHint(), + ), + ), + ), Positioned( left: 0, right: 0, @@ -177,6 +199,7 @@ class _HomeFeedOverlayState extends State { } void _startScrub() { + if (!mounted) return; _wasVisibleBeforeScrub = widget.visible; if (!_scrubbing) setState(() => _scrubbing = true); widget.onScrubVisibilityChanged?.call(true); @@ -184,6 +207,7 @@ class _HomeFeedOverlayState extends State { } void _endScrub() { + if (!mounted) return; final wasVisible = _wasVisibleBeforeScrub; if (_scrubbing) setState(() => _scrubbing = false); widget.onScrubVisibilityChanged?.call(false); diff --git a/lib/pages/player/widgets/home_feed_overlay_parts.dart b/lib/pages/player/widgets/home_feed_overlay_parts.dart index 6bf6483..bd377df 100644 --- a/lib/pages/player/widgets/home_feed_overlay_parts.dart +++ b/lib/pages/player/widgets/home_feed_overlay_parts.dart @@ -256,7 +256,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { } void _startScrub(double fraction) { - if (_dragging) return; + if (!mounted || _dragging) return; setState(() { _dragging = true; _preview = fraction.clamp(0.0, 1.0).toDouble(); @@ -265,12 +265,12 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { } void _update(double fraction) { - if (!_dragging) return; + if (!mounted || !_dragging) return; setState(() => _preview = fraction.clamp(0.0, 1.0).toDouble()); } void _finishScrub() { - if (!_dragging) return; + if (!mounted || !_dragging) return; final fraction = _preview; setState(() { _dragging = false; diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 600bec6..b0af80e 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -723,8 +723,24 @@ void main() { await gesture.moveBy(const Offset(120, 0)); await tester.pump(); expect(scrubVisibility.last, isTrue); - expect(find.byKey(const Key('home-feed-title')), findsNothing); - expect(find.byKey(const Key('rail-save')), findsNothing); + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('rail-save')), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('home-feed-content-opacity')), + ) + .opacity, + 0, + ); + expect( + tester + .widget( + find.byKey(const Key('home-feed-content-ignore')), + ) + .ignoring, + isTrue, + ); expect(find.byKey(const Key('scrub-preview-time-bubble')), findsOneWidget); final bubbleRect = tester.getRect( find.byKey(const Key('scrub-preview-time-bubble')), @@ -806,6 +822,14 @@ void main() { await tester.pumpAndSettle(); expect(scrubVisibility.last, isFalse); expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('home-feed-content-opacity')), + ) + .opacity, + 1, + ); expect(seeked, isNotNull); expect(seeked!.inSeconds, greaterThan(0)); expect( diff --git a/test/player/seek_scrub_visibility_test.dart b/test/player/seek_scrub_visibility_test.dart index 8117f07..0497128 100644 --- a/test/player/seek_scrub_visibility_test.dart +++ b/test/player/seek_scrub_visibility_test.dart @@ -2,6 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/player/widgets/home_feed_overlay.dart'; import 'package:fruitshorts/pages/player/widgets/seek_progress_bar.dart'; import 'package:video_player/video_player.dart'; @@ -68,6 +71,86 @@ void main() { 6, ); }); + + testWidgets('feed progress tap does not clear overlay content', ( + tester, + ) async { + await _pumpFeedOverlay(tester); + + await tester.tap(find.byKey(const Key('home-feed-progress'))); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.byKey(const Key('home-feed-progress')), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('home-feed-content-opacity')), + ) + .opacity, + 1, + ); + }); + + testWidgets('feed progress drag then immediate release restores content', ( + tester, + ) async { + await _pumpFeedOverlay(tester); + final gesture = await tester.startGesture( + tester.getCenter(find.byKey(const Key('home-feed-progress'))), + ); + + await gesture.moveBy(const Offset(80, 0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.byKey(const Key('home-feed-progress')), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('home-feed-content-opacity')), + ) + .opacity, + 1, + ); + expect( + tester.getSize(find.byKey(const Key('home-feed-progress-track'))).height, + 2, + ); + }); + + testWidgets('feed progress remains mounted and thick while dragging', ( + tester, + ) async { + await _pumpFeedOverlay(tester); + final progress = find.byKey(const Key('home-feed-progress')); + final gesture = await tester.startGesture(tester.getCenter(progress)); + + await gesture.moveBy(const Offset(80, 0)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 130)); + + expect(tester.takeException(), isNull); + expect(progress, findsOneWidget); + expect(find.byKey(const Key('scrub-preview-time-bubble')), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('home-feed-content-opacity')), + ) + .opacity, + 0, + ); + expect( + tester.getSize(find.byKey(const Key('home-feed-progress-track'))).height, + 6, + ); + + await gesture.up(); + await tester.pumpAndSettle(); + }); } Future<_HarnessState> _pumpHarness(WidgetTester tester) async { @@ -104,6 +187,64 @@ SliderThemeData _sliderTheme(WidgetTester tester) { .data; } +Future> _pumpFeedOverlay( + WidgetTester tester, +) async { + final controller = ValueNotifier( + const VideoPlayerValue( + duration: Duration(minutes: 2), + position: Duration(seconds: 20), + isInitialized: true, + ), + ); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + 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: 'scrub-drama', + title: 'Scrub Drama', + synopsis: 'A short synopsis.', + posterUrl: '', + totalEpisodes: 8, + ), + episode: const Episode( + id: 'scrub-episode', + number: 1, + title: 'Scrub Episode', + isFree: true, + ), + controller: controller, + locked: false, + liked: false, + saved: false, + visible: true, + boosting: false, + onLike: () {}, + onSave: () {}, + onComments: () {}, + onShare: () {}, + onDetail: () {}, + onOpenFullPlayer: () {}, + onTogglePlay: () {}, + onBoostStart: () {}, + onBoostEnd: () {}, + onMore: () {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + return controller; +} + class _Harness extends StatefulWidget { const _Harness({super.key, required this.controller}); From 357492acb2943fde52abdcac80079cd844852896 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 20:31:41 +0800 Subject: [PATCH 081/129] =?UTF-8?q?fix(recommend):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E6=A0=87=E9=A2=98=E4=B8=8E=E8=A7=82=E7=9C=8B=E5=85=A8=E9=9B=86?= =?UTF-8?q?=E8=B7=B3=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 6 +++ .../widgets/home_feed_recommend_panel.dart | 51 ++++++++++++++----- .../player/widgets/seek_progress_bar.dart | 5 +- lib/pages/recommend/recommend_page.dart | 31 +---------- test/home/home_preview_layout_test.dart | 15 +++--- test/recommend/recommend_sections_test.dart | 26 +++++++++- 6 files changed, 82 insertions(+), 52 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 049c0a1..cf32104 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1194,3 +1194,9 @@ $ flutter test - 推荐流拖动使用独立 opaque 手势与 pointer 兜底,end/cancel 同时恢复浮层和轨道。 - 推荐浮层改为 AnimatedOpacity + IgnorePointer,进度条节点始终留在树中;scrub setState 均加 mounted 守卫。 - `flutter test test/player/seek_scrub_visibility_test.dart`:6 项测试全部通过。 + +## 2026-10-22 推荐页播放跳转修正 + +- 标题、剧名行和观看全集统一通过 `playDramaFromCatalog` 打开非 `homeMode` 逐集播放页。 +- 仅含集数的剧集标题不再显示在标题上方,集数只保留下方进度文案。 +- `flutter analyze`:No issues found。 diff --git a/lib/pages/player/widgets/home_feed_recommend_panel.dart b/lib/pages/player/widgets/home_feed_recommend_panel.dart index 6ca74a0..3299d01 100644 --- a/lib/pages/player/widgets/home_feed_recommend_panel.dart +++ b/lib/pages/player/widgets/home_feed_recommend_panel.dart @@ -50,38 +50,44 @@ class _HomeFeedInfoPanelState extends State { final l10n = AppLocalizations.of(context)!; final dramaTitle = localizedDramaTitle(l10n, widget.drama); final episodeTitle = widget.episode.title.trim(); - final headline = episodeTitle.isEmpty ? dramaTitle : episodeTitle; + final headline = _headline(l10n, dramaTitle, episodeTitle); final synopsis = localizedDramaSynopsis(l10n, widget.drama).trim(); final genres = _genreValues(); return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - headline, - key: const Key('home-feed-title'), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: _headlineFont, - fontWeight: FontWeight.w700, - height: 1.2, - letterSpacing: 0, - shadows: _textShadows, + GestureDetector( + key: const Key('home-feed-title-open'), + behavior: HitTestBehavior.opaque, + onTap: widget.onOpenFullPlayer, + child: Text( + headline, + key: const Key('home-feed-title'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: _headlineFont, + fontWeight: FontWeight.w700, + height: 1.2, + letterSpacing: 0, + shadows: _textShadows, + ), ), ), const SizedBox(height: 8), GestureDetector( key: const Key('home-feed-series-name'), behavior: HitTestBehavior.opaque, - onTap: widget.onDetail, + onTap: widget.onOpenFullPlayer, child: Row( mainAxisSize: MainAxisSize.min, children: [ Flexible( child: Text( dramaTitle, + key: const Key('home-feed-series-title'), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( @@ -113,6 +119,23 @@ class _HomeFeedInfoPanelState extends State { ); } + String _headline( + AppLocalizations l10n, + String dramaTitle, + String episodeTitle, + ) { + if (episodeTitle.isEmpty) return dramaTitle; + final normalized = episodeTitle.toLowerCase().replaceAll(' ', ''); + final localizedEpisode = l10n + .episodeLabel(widget.episode.number) + .toLowerCase() + .replaceAll(' ', ''); + final episodeOnly = RegExp(r'^(第?\d+集|episode\d+)$').hasMatch(normalized); + return normalized == localizedEpisode || episodeOnly + ? dramaTitle + : episodeTitle; + } + Widget _tagMetaRow(AppLocalizations l10n, List genres) { return Row( key: const Key('home-feed-tags'), diff --git a/lib/pages/player/widgets/seek_progress_bar.dart b/lib/pages/player/widgets/seek_progress_bar.dart index 00a9023..9090b6a 100644 --- a/lib/pages/player/widgets/seek_progress_bar.dart +++ b/lib/pages/player/widgets/seek_progress_bar.dart @@ -171,11 +171,12 @@ class _SeekProgressBarState extends State { } Future _finish(double fraction, Duration duration) async { - if (widget.locked || !_dragging) return; + final startedAt = _dragStartFraction; + _dragStartFraction = null; + if (widget.locked || !_dragging || startedAt == null) return; setState(() => _dragging = false); _pointerMoved = false; _pointerStart = null; - _dragStartFraction = null; widget.thumbnailService?.cancelPending(); _previewFraction.value = null; widget.onScrubEnd?.call(); diff --git a/lib/pages/recommend/recommend_page.dart b/lib/pages/recommend/recommend_page.dart index c409d8f..e060687 100644 --- a/lib/pages/recommend/recommend_page.dart +++ b/lib/pages/recommend/recommend_page.dart @@ -6,15 +6,12 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; -import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/category_controller.dart'; -import '../../core/data/services/download_controller.dart'; import '../../core/data/services/home_feed_controller.dart'; -import '../../core/data/services/wallet_controller.dart'; -import '../../core/navigation/fade_page_route.dart'; import '../../core/theme/app_colors.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; +import '../player/open_player.dart'; import '../player/player_view.dart'; import 'recommend_types.dart'; import 'widgets/all_dramas_filter_grid.dart'; @@ -187,30 +184,6 @@ class _RecommendPageState extends State { } Future _openDrama(Drama drama, {String? episodeId}) async { - final catalog = context.read(); - final wallet = context.read(); - final downloads = context.read(); - var playable = drama; - try { - playable = await catalog.requireDetail(drama); - } catch (_) { - // 卡片预览仍可直接进入播放器。 - } - if (!mounted) return; - final episodeIndex = episodeId == null - ? 0 - : playable.episodes.indexWhere((episode) => episode.id == episodeId); - await Navigator.of(context).push( - FadePageRoute( - builder: (_) => PlayerPage( - drama: playable, - initialIndex: episodeIndex < 0 ? 0 : episodeIndex, - homeMode: true, - feedDramas: [playable], - wallet: wallet, - downloads: downloads, - ), - ), - ); + await playDramaFromCatalog(context, drama, episodeId: episodeId); } } diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index b0af80e..5bd0f70 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -282,7 +282,7 @@ void main() { await tester.pumpAndSettle(); expect( tester.widget(find.byType(PlayerPage).last).homeMode, - isTrue, + isFalse, ); }); @@ -321,7 +321,7 @@ void main() { episode: const Episode( id: 'feed-episode', number: 2, - title: 'Ignored Episode Title', + title: 'Episode 2', isFree: true, ), controller: null, @@ -353,10 +353,11 @@ void main() { expect( tester.widget(find.byKey(const Key('home-feed-title'))).data, - 'Ignored Episode Title', + 'Feed Drama', + ); + final seriesTitle = tester.widget( + find.byKey(const Key('home-feed-series-title')), ); - expect(find.text('Feed Drama'), findsOneWidget); - final seriesTitle = tester.widget(find.text('Feed Drama')); expect(seriesTitle.style?.fontSize, 19); expect(seriesTitle.style?.fontWeight, FontWeight.w600); expect(seriesTitle.style?.color, AppColors.textPrimary); @@ -370,7 +371,9 @@ void main() { expect(seriesChevron.color, AppColors.textPrimary); expect( tester.getTopLeft(find.byIcon(Icons.chevron_right_rounded)).dx - - tester.getTopRight(find.text('Feed Drama')).dx, + tester + .getTopRight(find.byKey(const Key('home-feed-series-title'))) + .dx, 4, ); expect(find.byKey(const Key('home-feed-info-card')), findsNothing); diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart index b96f63b..d56c2a3 100644 --- a/test/recommend/recommend_sections_test.dart +++ b/test/recommend/recommend_sections_test.dart @@ -278,6 +278,30 @@ void main() { expect(find.byKey(const Key('all-dramas-grid')), findsOneWidget); }); + testWidgets('should_open_standalone_player_from_title_and_watch_all', ( + tester, + ) async { + await _pumpRecommend(tester); + + await tester.tap(find.byKey(const Key('home-feed-title-open'))); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byType(PlayerPage).last).homeMode, + isFalse, + ); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('recommend-page')), findsOneWidget); + expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget); + + await tester.tap(find.byKey(const Key('home-feed-full-series'))); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byType(PlayerPage).last).homeMode, + isFalse, + ); + }); + testWidgets('should_open_player_and_search_from_recommend_page', ( tester, ) async { @@ -290,7 +314,7 @@ void main() { ); await tester.pumpAndSettle(); final player = tester.widget(find.byType(PlayerPage).last); - expect(player.homeMode, isTrue); + expect(player.homeMode, isFalse); await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); From 17faa749b255f07db812b53954f8815eb4a479a5 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 20:34:58 +0800 Subject: [PATCH 082/129] =?UTF-8?q?feat(recommend):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=A8=AA=E5=90=91=E5=88=87=E6=8D=A2=E4=B8=89=E4=B8=AA=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 6 ++ lib/pages/recommend/recommend_page.dart | 88 +++++++++++++++++---- test/recommend/recommend_sections_test.dart | 44 ++++++++++- 3 files changed, 121 insertions(+), 17 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index cf32104..a351bdd 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1200,3 +1200,9 @@ $ flutter test - 标题、剧名行和观看全集统一通过 `playDramaFromCatalog` 打开非 `homeMode` 逐集播放页。 - 仅含集数的剧集标题不再显示在标题上方,集数只保留下方进度文案。 - `flutter analyze`:No issues found。 + +## 2026-10-22 推荐页横向切换 + +- 推荐、剧单、全部短剧改为共享 PageController 的横向 PageView,点击与滑动均驱动同一选中态。 +- 三页使用 AutomaticKeepAliveClientMixin 保留视频、滚动位置和筛选状态;顶部导航固定在外层。 +- `flutter analyze`:No issues found。 diff --git a/lib/pages/recommend/recommend_page.dart b/lib/pages/recommend/recommend_page.dart index e060687..2d054c6 100644 --- a/lib/pages/recommend/recommend_page.dart +++ b/lib/pages/recommend/recommend_page.dart @@ -38,8 +38,15 @@ class RecommendPage extends StatefulWidget { class _RecommendPageState extends State { RecommendInnerTab _tab = RecommendInnerTab.feed; + final PageController _pageController = PageController(); int _scrubCancelRequest = 0; + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final categories = context.read(); @@ -51,23 +58,35 @@ class _RecommendPageState extends State { Positioned.fill( child: ListenableBuilder( listenable: Listenable.merge([widget.feedController, categories]), - builder: (context, _) => IndexedStack( - index: _tab.index, + builder: (context, _) => PageView( + key: const Key('recommend-tab-page-view'), + controller: _pageController, + scrollDirection: Axis.horizontal, + onPageChanged: _onPageChanged, children: [ - _recommendFeed(context), - _withTopNavSpace( - RecommendSectionsView( - feedController: widget.feedController, - categories: categories, - onOpenDrama: _openDrama, - onMore: _handleMore, + _KeepAliveTab( + key: const PageStorageKey('recommend-tab-feed'), + child: _recommendFeed(context), + ), + _KeepAliveTab( + key: const PageStorageKey('recommend-tab-playlists'), + child: _withTopNavSpace( + RecommendSectionsView( + feedController: widget.feedController, + categories: categories, + onOpenDrama: _openDrama, + onMore: _handleMore, + ), ), ), - _withTopNavSpace( - AllDramasFilterGrid( - feedController: widget.feedController, - onOpenDrama: _openDrama, - onOpenFilter: widget.onOpenFilter, + _KeepAliveTab( + key: const PageStorageKey('recommend-tab-all'), + child: _withTopNavSpace( + AllDramasFilterGrid( + feedController: widget.feedController, + onOpenDrama: _openDrama, + onOpenFilter: widget.onOpenFilter, + ), ), ), ], @@ -162,6 +181,24 @@ class _RecommendPageState extends State { _tab = tab; _scrubCancelRequest++; }); + if (_pageController.hasClients) { + unawaited( + _pageController.animateToPage( + tab.index, + duration: const Duration(milliseconds: 220), + curve: Curves.easeOut, + ), + ); + } + } + + void _onPageChanged(int index) { + final next = RecommendInnerTab.values[index]; + if (next == _tab) return; + setState(() { + _tab = next; + _scrubCancelRequest++; + }); } void _handleSearch() { @@ -180,10 +217,31 @@ class _RecommendPageState extends State { } void _handleMore(RecommendSectionKind section) { - setState(() => _tab = RecommendInnerTab.allDramas); + _selectTab(RecommendInnerTab.allDramas); } Future _openDrama(Drama drama, {String? episodeId}) async { await playDramaFromCatalog(context, drama, episodeId: episodeId); } } + +class _KeepAliveTab extends StatefulWidget { + const _KeepAliveTab({super.key, required this.child}); + + final Widget child; + + @override + State<_KeepAliveTab> createState() => _KeepAliveTabState(); +} + +class _KeepAliveTabState extends State<_KeepAliveTab> + with AutomaticKeepAliveClientMixin<_KeepAliveTab> { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + return widget.child; + } +} diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart index d56c2a3..fa50057 100644 --- a/test/recommend/recommend_sections_test.dart +++ b/test/recommend/recommend_sections_test.dart @@ -91,7 +91,7 @@ void main() { expect(player.homeMode, isTrue); expect(player.embedded, isTrue); expect(player.showHomeFeedHeader, isFalse); - expect(find.byType(PageView), findsOneWidget); + expect(find.byType(PageView), findsNWidgets(2)); expect(find.byKey(const Key('recommend-section-hits')), findsNothing); expect(find.byType(AppBottomNav), findsOneWidget); @@ -269,7 +269,7 @@ void main() { await tester.tap(find.byKey(const Key('recommend-tab-playlist'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('recommend-section-hits')), findsOneWidget); - expect(find.byKey(const Key('recommend-feed-player')), findsNothing); + expect(find.byKey(const Key('recommend-feed-player')), findsOneWidget); expect(find.byType(AppBottomNav), findsOneWidget); await tester.tap(find.byKey(const Key('recommend-tab-all'))); @@ -278,6 +278,46 @@ void main() { expect(find.byKey(const Key('all-dramas-grid')), findsOneWidget); }); + testWidgets('should_switch_inner_tabs_with_horizontal_page_swipes', ( + tester, + ) async { + await _pumpRecommend(tester); + final pages = find.byKey(const Key('recommend-tab-page-view')); + final topNavBefore = tester.getRect( + find.byKey(const Key('recommend-top-nav')), + ); + + await tester.drag(pages, const Offset(-320, 0)); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('recommend-section-hits')), findsOneWidget); + expect(find.byKey(const Key('recommend-feed-player')), findsOneWidget); + expect( + tester + .widget( + find.descendant( + of: find.byKey(const Key('recommend-tab-playlist')), + matching: find.text('Playlists'), + ), + ) + .style + ?.color, + AppColors.textPrimary, + ); + expect( + tester.getRect(find.byKey(const Key('recommend-top-nav'))), + topNavBefore, + ); + + await tester.drag(pages, const Offset(-320, 0)); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('all-dramas-grid')), findsOneWidget); + + await tester.drag(pages, const Offset(640, 0)); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('recommend-feed-player')), findsOneWidget); + expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget); + }); + testWidgets('should_open_standalone_player_from_title_and_watch_all', ( tester, ) async { From 4eb411ef289f378754980678f13c6924e7d41001 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 20:39:00 +0800 Subject: [PATCH 083/129] =?UTF-8?q?style(recommend):=20=E9=87=8D=E6=8E=92?= =?UTF-8?q?=E5=85=A8=E9=83=A8=E7=9F=AD=E5=89=A7=E7=AD=9B=E9=80=89=E4=B8=8E?= =?UTF-8?q?=E7=BD=91=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 6 + lib/core/theme/app_colors.dart | 1 + lib/core/theme/app_dimens.dart | 12 +- .../widgets/all_dramas_filter_grid.dart | 169 +++++++++++------- test/recommend/recommend_sections_test.dart | 47 +++++ 5 files changed, 170 insertions(+), 65 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index a351bdd..7146751 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1206,3 +1206,9 @@ $ flutter test - 推荐、剧单、全部短剧改为共享 PageController 的横向 PageView,点击与滑动均驱动同一选中态。 - 三页使用 AutomaticKeepAliveClientMixin 保留视频、滚动位置和筛选状态;顶部导航固定在外层。 - `flutter analyze`:No issues found。 + +## 2026-10-22 全部短剧布局重排 + +- 四行筛选统一 28 高、8 行距、24 项目间距;选中 chip 改为 `#4A1E1E`、白字 13/w600、圆角 14。 +- 最热/最新改为纯文字选中态,三横线筛选固定在右端;网格改为左右 12、列距 6、行距 12。 +- 第三行筛选完整展示,卡片继续复用首页组件;`flutter analyze`:No issues found。 diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index c7aca6f..5dbe249 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -77,6 +77,7 @@ abstract final class AppColors { static const contentSurface = Color(0xFF1C1C1E); static const contentChip = Color(0xFF2C2C2E); static const contentBorder = Color(0x1AFFFFFF); + static const filterChipSelected = Color(0xFF4A1E1E); /// 「我的」页卡片与描边。 static const mineCard = Color(0xFF16161A); diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index f0ba1fe..2e6cb4a 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -138,8 +138,16 @@ abstract final class AppDimens { /// 全部短剧筛选与追剧双列。 static const double filterChipFont = 13; - static const double filterChipHeight = 30; - static const double filterChipRadius = 15; + static const double filterChipHeight = 28; + static const double filterChipRadius = 14; + static const double filterChipHorizontalPadding = 12; + static const double filterRowGap = 8; + static const double filterItemGap = 24; + static const double allDramasHorizontal = 12; + static const double allDramasGridGap = 6; + static const double allDramasGridRowGap = 12; + static const double allDramasFilterToGridGap = 12; + static const double allDramasFilterIcon = 26; static const double followingTabFont = 16; static const double followingGridAspectRatio = 0.5; diff --git a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart index 059e47b..102f05a 100644 --- a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart +++ b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart @@ -49,8 +49,8 @@ class _AllDramasFilterGridState extends State { builder: (context, constraints) { final itemWidth = (constraints.maxWidth - - AppDimens.feedPageHorizontal * 2 - - AppDimens.homeGridGap * 2) / + AppDimens.allDramasHorizontal * 2 - + AppDimens.allDramasGridGap * 2) / 3; final itemExtent = itemWidth / AppDimens.cardCoverAspectRatio + @@ -85,14 +85,14 @@ class _AllDramasFilterGridState extends State { else SliverPadding( padding: const EdgeInsets.symmetric( - horizontal: AppDimens.feedPageHorizontal, + horizontal: AppDimens.allDramasHorizontal, ), sliver: SliverGrid( key: const Key('all-dramas-grid'), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, - crossAxisSpacing: AppDimens.homeGridGap, - mainAxisSpacing: AppDimens.homeGridRowGap, + crossAxisSpacing: AppDimens.allDramasGridGap, + mainAxisSpacing: AppDimens.allDramasGridRowGap, mainAxisExtent: itemExtent, ), delegate: SliverChildBuilderDelegate( @@ -131,9 +131,9 @@ class _AllDramasFilterGridState extends State { return Padding( padding: const EdgeInsets.fromLTRB( AppDimens.feedPageHorizontal, - AppDimens.spaceSm, + 0, AppDimens.feedPageHorizontal, - AppDimens.spaceMd, + AppDimens.allDramasFilterToGridGap, ), child: Column( key: const Key('all-dramas-filters'), @@ -160,38 +160,7 @@ class _AllDramasFilterGridState extends State { onSelected: (value) => setState(() => _audio = value), ), _genreRow(2, genres, l10n), - Row( - key: const Key('all-filter-row-3'), - children: [ - Expanded( - child: Row( - children: [ - _chip( - key: const Key('all-sort-hot'), - label: l10n.hot, - selected: !_newest, - onTap: () => setState(() => _newest = false), - ), - const SizedBox(width: AppDimens.spaceSm), - _chip( - key: const Key('all-sort-new'), - label: l10n.newest, - selected: _newest, - onTap: () => setState(() => _newest = true), - ), - ], - ), - ), - IconButton( - key: const Key('all-dramas-filter-page'), - tooltip: l10n.filter, - onPressed: widget.onOpenFilter, - color: AppColors.textPrimary, - iconSize: AppDimens.tapTarget / 2, - icon: const Icon(Icons.filter_list_rounded), - ), - ], - ), + _sortRow(l10n), ], ), ); @@ -205,21 +174,26 @@ class _AllDramasFilterGridState extends State { }) { return Padding( key: Key('all-filter-row-$row'), - padding: const EdgeInsets.only(bottom: AppDimens.spaceSm), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - for (final option in options) ...[ - _chip( - key: Key('all-filter-${option.id}'), - label: option.label, - selected: selected == option.id, - onTap: () => onSelected(option.id), - ), - const SizedBox(width: AppDimens.spaceSm), + padding: const EdgeInsets.only(bottom: AppDimens.filterRowGap), + child: SizedBox( + height: AppDimens.filterChipHeight, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + for (var index = 0; index < options.length; index++) ...[ + _chip( + key: Key('all-filter-${options[index].id}'), + label: options[index].label, + selected: selected == options[index].id, + onTap: () => onSelected(options[index].id), + ), + if (index < options.length - 1) + const SizedBox(width: AppDimens.filterItemGap), + ], ], - ], + ), ), ), ); @@ -254,18 +228,93 @@ class _AllDramasFilterGridState extends State { borderRadius: BorderRadius.circular(AppDimens.filterChipRadius), child: Container( height: AppDimens.filterChipHeight, - padding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceMd), + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.filterChipHorizontalPadding, + ), alignment: Alignment.center, decoration: BoxDecoration( - color: selected ? AppColors.contentSurface : AppColors.transparent, + color: selected + ? AppColors.filterChipSelected + : AppColors.transparent, borderRadius: BorderRadius.circular(AppDimens.filterChipRadius), ), child: Text( label, style: TextStyle( - color: selected ? AppColors.textPrimary : AppColors.textTertiary, + color: selected ? AppColors.textPrimary : AppColors.textTabInactive, fontSize: AppDimens.filterChipFont, - fontWeight: selected ? FontWeight.w700 : FontWeight.w400, + fontWeight: selected ? FontWeight.w600 : FontWeight.w400, + height: 1, + ), + ), + ), + ); + } + + Widget _sortRow(AppLocalizations l10n) { + return SizedBox( + key: const Key('all-filter-row-3'), + height: AppDimens.filterChipHeight, + child: Row( + children: [ + _sortOption( + key: const Key('all-sort-hot'), + label: l10n.hot, + selected: !_newest, + onTap: () => setState(() => _newest = false), + ), + const SizedBox(width: AppDimens.filterItemGap), + _sortOption( + key: const Key('all-sort-new'), + label: l10n.newest, + selected: _newest, + onTap: () => setState(() => _newest = true), + ), + const Spacer(), + SizedBox.square( + dimension: AppDimens.allDramasFilterIcon, + child: IconButton( + key: const Key('all-dramas-filter-page'), + tooltip: l10n.filter, + padding: EdgeInsets.zero, + constraints: const BoxConstraints.tightFor( + width: AppDimens.allDramasFilterIcon, + height: AppDimens.allDramasFilterIcon, + ), + onPressed: widget.onOpenFilter, + color: AppColors.textPrimary, + iconSize: AppDimens.allDramasFilterIcon, + icon: const Icon(Icons.filter_list_rounded), + ), + ), + ], + ), + ); + } + + Widget _sortOption({ + required Key key, + required String label, + required bool selected, + required VoidCallback onTap, + }) { + return GestureDetector( + key: key, + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: SizedBox( + height: AppDimens.filterChipHeight, + child: Center( + child: Text( + label, + style: TextStyle( + color: selected + ? AppColors.textPrimary + : AppColors.textTabInactive, + fontSize: AppDimens.filterChipFont, + fontWeight: selected ? FontWeight.w700 : FontWeight.w400, + height: 1, + ), ), ), ), @@ -273,7 +322,6 @@ class _AllDramasFilterGridState extends State { } List<({String id, String label})> _availableGenres(AppLocalizations l10n) { - final source = widget.feedController.dramas; final definitions = [ (id: 'reversal', label: l10n.reversalFilter, terms: _reversalTerms), (id: 'uplifting', label: l10n.homeTagUplifting, terms: []), @@ -282,11 +330,6 @@ class _AllDramasFilterGridState extends State { (id: 'angst', label: l10n.homeTagAngst, terms: []), ]; return definitions - .where( - (definition) => source.any( - (drama) => _matchesGenre(drama, definition.id, definition.terms), - ), - ) .map((definition) => (id: definition.id, label: definition.label)) .toList(growable: false); } diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart index fa50057..132301a 100644 --- a/test/recommend/recommend_sections_test.dart +++ b/test/recommend/recommend_sections_test.dart @@ -237,14 +237,49 @@ void main() { await tester.tap(find.byKey(const Key('recommend-tab-all'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('all-dramas-grid')), findsOneWidget); + final grid = tester.widget( + find.byKey(const Key('all-dramas-grid')), + ); + final delegate = + grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; + expect(delegate.crossAxisCount, 3); + expect(delegate.crossAxisSpacing, AppDimens.allDramasGridGap); + expect(delegate.mainAxisSpacing, AppDimens.allDramasGridRowGap); for (var row = 0; row < 4; row++) { expect(find.byKey(Key('all-filter-row-$row')), findsOneWidget); } expect(find.byKey(const Key('all-filter-local')), findsOneWidget); expect(find.byKey(const Key('all-filter-subtitles')), findsOneWidget); + for (final key in [ + 'all-filter-reversal', + 'all-filter-uplifting', + 'all-filter-modern', + 'all-filter-sweet', + 'all-filter-angst', + ]) { + expect(find.byKey(Key(key)), findsOneWidget); + } await tester.tap(find.byKey(const Key('all-filter-local'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('all-dramas-empty')), findsOneWidget); + expect(_textStyle(tester, 'Local').fontWeight, FontWeight.w600); + expect(_textStyle(tester, 'Local').color, AppColors.textPrimary); + final selectedChip = tester.widget( + find + .descendant( + of: find.byKey(const Key('all-filter-local')), + matching: find.byType(Container), + ) + .first, + ); + expect( + (selectedChip.decoration! as BoxDecoration).color, + AppColors.filterChipSelected, + ); + expect( + tester.getSize(find.byKey(const Key('all-filter-local'))).height, + AppDimens.filterChipHeight, + ); await tester.tap(find.byKey(const Key('all-filter-all')).first); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('all-sort-new'))); @@ -265,6 +300,18 @@ void main() { (chipContainer.decoration! as BoxDecoration).borderRadius, BorderRadius.circular(AppDimens.filterChipRadius), ); + final filterButton = find.byKey(const Key('all-dramas-filter-page')); + expect( + tester.getSize(filterButton), + const Size.square(AppDimens.allDramasFilterIcon), + ); + expect( + tester.getRect(filterButton).right, + closeTo( + tester.getRect(find.byKey(const Key('all-filter-row-3'))).right, + 0.1, + ), + ); await tester.tap(find.byKey(const Key('recommend-tab-playlist'))); await tester.pumpAndSettle(); From e147002e000b3058769121dd9514256c7e0e8ed2 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 20:43:15 +0800 Subject: [PATCH 084/129] =?UTF-8?q?fix(recommend):=20=E5=9B=BA=E5=AE=9A?= =?UTF-8?q?=E5=85=A8=E9=83=A8=E7=9F=AD=E5=89=A7=E7=AD=9B=E9=80=89=E5=8C=BA?= =?UTF-8?q?=E5=B9=B6=E7=BB=9F=E4=B8=80=E9=97=B4=E8=B7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 1 + lib/core/theme/app_dimens.dart | 4 +- .../widgets/all_dramas_filter_grid.dart | 140 ++++++++++-------- test/recommend/recommend_sections_test.dart | 63 ++++++++ 4 files changed, 144 insertions(+), 64 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 7146751..f109384 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1212,3 +1212,4 @@ $ flutter test - 四行筛选统一 28 高、8 行距、24 项目间距;选中 chip 改为 `#4A1E1E`、白字 13/w600、圆角 14。 - 最热/最新改为纯文字选中态,三横线筛选固定在右端;网格改为左右 12、列距 6、行距 12。 - 第三行筛选完整展示,卡片继续复用首页组件;`flutter analyze`:No issues found。 +- 筛选项字号提升到 15、圆角 15;筛选区移出滚动容器并固定吸顶,下拉刷新仅作用于网格。 diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 2e6cb4a..f767148 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -137,9 +137,9 @@ abstract final class AppDimens { static const double recommendCardWidth = 122; /// 全部短剧筛选与追剧双列。 - static const double filterChipFont = 13; + static const double filterChipFont = 15; static const double filterChipHeight = 28; - static const double filterChipRadius = 14; + static const double filterChipRadius = 15; static const double filterChipHorizontalPadding = 12; static const double filterRowGap = 8; static const double filterItemGap = 24; diff --git a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart index 102f05a..e1317f6 100644 --- a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart +++ b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart @@ -55,72 +55,84 @@ class _AllDramasFilterGridState extends State { final itemExtent = itemWidth / AppDimens.cardCoverAspectRatio + AppDimens.homeCardBodyExtent; - return NotificationListener( - onNotification: _onScroll, - child: AppPullToRefresh( - key: const Key('all-dramas-refresh'), - onRefresh: () async { - await widget.feedController.refreshCurrent(); - }, - child: CustomScrollView( - key: const Key('all-dramas-scroll'), - physics: AppPullToRefresh.physics, - slivers: [ - SliverToBoxAdapter(child: _filters(l10n)), - if (dramas.isEmpty) - SliverFillRemaining( - hasScrollBody: false, - child: EmptyState( - key: const Key('all-dramas-empty'), - icon: Icons.movie_filter_outlined, - title: l10n.homeGridFilterEmpty, - actionLabel: l10n.homeGridClearFilter, - onAction: () => setState(() { - _region = 'all'; - _audio = 'all'; - _genre = null; - }), - ), - ) - else - SliverPadding( - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.allDramasHorizontal, - ), - sliver: SliverGrid( - key: const Key('all-dramas-grid'), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: AppDimens.allDramasGridGap, - mainAxisSpacing: AppDimens.allDramasGridRowGap, - mainAxisExtent: itemExtent, - ), - delegate: SliverChildBuilderDelegate( - (context, index) => HomeFeedCard( - drama: dramas[index], - onTap: () => widget.onOpenDrama(dramas[index]), + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _filters(l10n), + Expanded( + child: NotificationListener( + onNotification: _onScroll, + child: AppPullToRefresh( + key: const Key('all-dramas-refresh'), + onRefresh: () async { + await widget.feedController.refreshCurrent(); + }, + child: CustomScrollView( + key: const Key('all-dramas-scroll'), + physics: AppPullToRefresh.physics, + slivers: [ + if (dramas.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: EmptyState( + key: const Key('all-dramas-empty'), + icon: Icons.movie_filter_outlined, + title: l10n.homeGridFilterEmpty, + actionLabel: l10n.homeGridClearFilter, + onAction: () => setState(() { + _region = 'all'; + _audio = 'all'; + _genre = null; + }), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.allDramasHorizontal, + ), + sliver: SliverGrid( + key: const Key('all-dramas-grid'), + gridDelegate: + SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: AppDimens.allDramasGridGap, + mainAxisSpacing: + AppDimens.allDramasGridRowGap, + mainAxisExtent: itemExtent, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => HomeFeedCard( + drama: dramas[index], + onTap: () => widget.onOpenDrama(dramas[index]), + ), + childCount: dramas.length, + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only( + bottom: AppDimens.spaceXl, + ), + child: PagedListFooter( + loading: widget.feedController.loadingMore, + hasMore: widget.feedController.hasMore, + hasItems: dramas.isNotEmpty, + errorMessage: + widget.feedController.lastError == null + ? null + : l10n.catalogError, + onRetry: widget.feedController.loadMore, + ), ), - childCount: dramas.length, ), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), - child: PagedListFooter( - loading: widget.feedController.loadingMore, - hasMore: widget.feedController.hasMore, - hasItems: dramas.isNotEmpty, - errorMessage: widget.feedController.lastError == null - ? null - : l10n.catalogError, - onRetry: widget.feedController.loadMore, - ), + ], ), ), - ], + ), ), - ), + ], ); }, ); @@ -304,7 +316,11 @@ class _AllDramasFilterGridState extends State { onTap: onTap, child: SizedBox( height: AppDimens.filterChipHeight, - child: Center( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.filterChipHorizontalPadding, + ), + alignment: Alignment.center, child: Text( label, style: TextStyle( diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart index 132301a..5851add 100644 --- a/test/recommend/recommend_sections_test.dart +++ b/test/recommend/recommend_sections_test.dart @@ -248,6 +248,55 @@ void main() { for (var row = 0; row < 4; row++) { expect(find.byKey(Key('all-filter-row-$row')), findsOneWidget); } + final filterStarts = [ + for (var row = 0; row < 4; row++) + tester.getRect(find.byKey(Key('all-filter-row-$row'))).left, + ]; + expect( + filterStarts, + everyElement(closeTo(AppDimens.feedPageHorizontal, 0.1)), + ); + for (var row = 0; row < 3; row++) { + expect( + tester.getSize(find.byKey(Key('all-filter-row-$row'))).height, + AppDimens.filterChipHeight + AppDimens.filterRowGap, + ); + } + expect( + tester.getSize(find.byKey(const Key('all-filter-row-3'))).height, + AppDimens.filterChipHeight, + ); + expect( + tester.getRect(find.byKey(const Key('all-dramas-scroll'))).top - + tester.getRect(find.byKey(const Key('all-dramas-filters'))).bottom, + closeTo(AppDimens.allDramasFilterToGridGap, 0.1), + ); + expect( + find.descendant( + of: find.byKey(const Key('all-dramas-refresh')), + matching: find.byKey(const Key('all-dramas-filters')), + ), + findsNothing, + ); + expect( + find.descendant( + of: find.byKey(const Key('all-dramas-scroll')), + matching: find.byKey(const Key('all-dramas-grid')), + ), + findsOneWidget, + ); + final fixedFilterTop = tester + .getRect(find.byKey(const Key('all-dramas-filters'))) + .top; + await tester.drag( + find.byKey(const Key('all-dramas-scroll')), + const Offset(0, -120), + ); + await tester.pump(); + expect( + tester.getRect(find.byKey(const Key('all-dramas-filters'))).top, + closeTo(fixedFilterTop, 0.1), + ); expect(find.byKey(const Key('all-filter-local')), findsOneWidget); expect(find.byKey(const Key('all-filter-subtitles')), findsOneWidget); for (final key in [ @@ -259,10 +308,24 @@ void main() { ]) { expect(find.byKey(Key(key)), findsOneWidget); } + final rowZero = find.byKey(const Key('all-filter-row-0')); + final rowZeroAll = find.descendant( + of: rowZero, + matching: find.byKey(const Key('all-filter-all')), + ); + final rowZeroLocal = find.descendant( + of: rowZero, + matching: find.byKey(const Key('all-filter-local')), + ); + expect( + tester.getTopLeft(rowZeroLocal).dx - tester.getTopRight(rowZeroAll).dx, + AppDimens.filterItemGap, + ); await tester.tap(find.byKey(const Key('all-filter-local'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('all-dramas-empty')), findsOneWidget); expect(_textStyle(tester, 'Local').fontWeight, FontWeight.w600); + expect(_textStyle(tester, 'Local').fontSize, AppDimens.filterChipFont); expect(_textStyle(tester, 'Local').color, AppColors.textPrimary); final selectedChip = tester.widget( find From af4d9189382d67c63da903f8e2cb88802b7e2913 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 20:46:13 +0800 Subject: [PATCH 085/129] =?UTF-8?q?fix(recommend):=20=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E5=85=A8=E9=83=A8=E7=9F=AD=E5=89=A7=E7=AD=9B=E9=80=89=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 1 + lib/core/theme/app_dimens.dart | 6 +- .../widgets/all_dramas_filter_grid.dart | 3 + test/recommend/recommend_sections_test.dart | 55 +++++++++++++++++++ 4 files changed, 62 insertions(+), 3 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index f109384..0fd1f8e 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1213,3 +1213,4 @@ $ flutter test - 最热/最新改为纯文字选中态,三横线筛选固定在右端;网格改为左右 12、列距 6、行距 12。 - 第三行筛选完整展示,卡片继续复用首页组件;`flutter analyze`:No issues found。 - 筛选项字号提升到 15、圆角 15;筛选区移出滚动容器并固定吸顶,下拉刷新仅作用于网格。 +- 四行首项文字统一到同一 x;行高 30、行距 6、项目距 12,第 4 行保持弹性空白后固定筛选图标。 diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index f767148..cf402ff 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -138,11 +138,11 @@ abstract final class AppDimens { /// 全部短剧筛选与追剧双列。 static const double filterChipFont = 15; - static const double filterChipHeight = 28; + static const double filterChipHeight = 30; static const double filterChipRadius = 15; static const double filterChipHorizontalPadding = 12; - static const double filterRowGap = 8; - static const double filterItemGap = 24; + static const double filterRowGap = 6; + static const double filterItemGap = 12; static const double allDramasHorizontal = 12; static const double allDramasGridGap = 6; static const double allDramasGridRowGap = 12; diff --git a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart index e1317f6..1aa04d6 100644 --- a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart +++ b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart @@ -193,6 +193,7 @@ class _AllDramasFilterGridState extends State { scrollDirection: Axis.horizontal, child: Row( mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ for (var index = 0; index < options.length; index++) ...[ _chip( @@ -268,6 +269,8 @@ class _AllDramasFilterGridState extends State { key: const Key('all-filter-row-3'), height: AppDimens.filterChipHeight, child: Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ _sortOption( key: const Key('all-sort-hot'), diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart index 5851add..25775ad 100644 --- a/test/recommend/recommend_sections_test.dart +++ b/test/recommend/recommend_sections_test.dart @@ -256,6 +256,43 @@ void main() { filterStarts, everyElement(closeTo(AppDimens.feedPageHorizontal, 0.1)), ); + final pageRect = tester.getRect(find.byKey(const Key('recommend-page'))); + for (var row = 0; row < 4; row++) { + final rowRect = tester.getRect(find.byKey(Key('all-filter-row-$row'))); + expect(rowRect.left, closeTo(pageRect.left + 14, 0.1)); + expect(rowRect.right, closeTo(pageRect.right - 14, 0.1)); + } + final firstTexts = [ + find.descendant( + of: find.byKey(const Key('all-filter-row-0')), + matching: find.byType(Text), + ), + find.descendant( + of: find.byKey(const Key('all-filter-row-1')), + matching: find.byType(Text), + ), + find.descendant( + of: find.byKey(const Key('all-filter-row-2')), + matching: find.byType(Text), + ), + find.descendant( + of: find.byKey(const Key('all-filter-row-3')), + matching: find.byType(Text), + ), + ]; + final firstTextX = firstTexts + .map((finder) => tester.getTopLeft(finder.first).dx) + .toList(); + expect(firstTextX, everyElement(closeTo(firstTextX.first, 0.1))); + expect( + firstTextX.first, + closeTo( + pageRect.left + + AppDimens.feedPageHorizontal + + AppDimens.filterChipHorizontalPadding, + 0.1, + ), + ); for (var row = 0; row < 3; row++) { expect( tester.getSize(find.byKey(Key('all-filter-row-$row'))).height, @@ -321,6 +358,24 @@ void main() { tester.getTopLeft(rowZeroLocal).dx - tester.getTopRight(rowZeroAll).dx, AppDimens.filterItemGap, ); + final rowOne = find.byKey(const Key('all-filter-row-1')); + final rowTwo = find.byKey(const Key('all-filter-row-2')); + for (final row in [rowOne, rowTwo]) { + final all = find.descendant( + of: row, + matching: find.byKey(const Key('all-filter-all')), + ); + final next = find.descendant( + of: row, + matching: row == rowOne + ? find.byKey(const Key('all-filter-subtitles')) + : find.byKey(const Key('all-filter-reversal')), + ); + expect( + tester.getTopLeft(next).dx - tester.getTopRight(all).dx, + AppDimens.filterItemGap, + ); + } await tester.tap(find.byKey(const Key('all-filter-local'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('all-dramas-empty')), findsOneWidget); From d79e2006501cd1b7a405bc2b93e5d41dcbe14031 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 20:49:54 +0800 Subject: [PATCH 086/129] =?UTF-8?q?style(recommend):=20=E6=8C=89=E6=88=AA?= =?UTF-8?q?=E5=9B=BE=E9=87=8D=E5=81=9A=E5=85=A8=E9=83=A8=E7=9F=AD=E5=89=A7?= =?UTF-8?q?=E7=AD=9B=E9=80=89=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 2 + lib/core/theme/app_colors.dart | 6 +- lib/core/theme/app_dimens.dart | 16 +++-- .../widgets/all_dramas_filter_grid.dart | 71 ++++++------------- test/recommend/recommend_sections_test.dart | 30 ++++++-- 5 files changed, 62 insertions(+), 63 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 0fd1f8e..68ccc5f 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1214,3 +1214,5 @@ $ flutter test - 第三行筛选完整展示,卡片继续复用首页组件;`flutter analyze`:No issues found。 - 筛选项字号提升到 15、圆角 15;筛选区移出滚动容器并固定吸顶,下拉刷新仅作用于网格。 - 四行首项文字统一到同一 x;行高 30、行距 6、项目距 12,第 4 行保持弹性空白后固定筛选图标。 +- 按最新截图改为 16sp、32 高、10 行距、16 项距;选中态为 `#21090E` 底配 `#FF4D6A` 粉字。 +- 第 4 行复用选中胶囊样式,右侧改为 44×44 圆形深灰按钮和 22 三横线图标;`flutter analyze` 无问题。 diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 5dbe249..f1d5aec 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -77,7 +77,11 @@ abstract final class AppColors { static const contentSurface = Color(0xFF1C1C1E); static const contentChip = Color(0xFF2C2C2E); static const contentBorder = Color(0x1AFFFFFF); - static const filterChipSelected = Color(0xFF4A1E1E); + static const filterChipSelected = Color(0xFF21090E); + static const filterChipTextSelected = Color(0xFFFF4D6A); + static const filterChipTextInactive = Color(0xFF8A8A8A); + static const filterMenuButton = Color(0xFF2A2A2A); + static const filterMenuIcon = Color(0xFFBDBDBD); /// 「我的」页卡片与描边。 static const mineCard = Color(0xFF16161A); diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index cf402ff..0fc48db 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -137,17 +137,19 @@ abstract final class AppDimens { static const double recommendCardWidth = 122; /// 全部短剧筛选与追剧双列。 - static const double filterChipFont = 15; - static const double filterChipHeight = 30; - static const double filterChipRadius = 15; - static const double filterChipHorizontalPadding = 12; - static const double filterRowGap = 6; - static const double filterItemGap = 12; + static const double filterChipFont = 16; + static const double filterChipHeight = 32; + static const double filterChipRadius = 16; + static const double filterChipHorizontalPadding = 14; + static const double filterRowGap = 10; + static const double filterItemGap = 16; static const double allDramasHorizontal = 12; static const double allDramasGridGap = 6; static const double allDramasGridRowGap = 12; static const double allDramasFilterToGridGap = 12; - static const double allDramasFilterIcon = 26; + static const double allDramasFilterIcon = 22; + static const double allDramasFilterButton = 44; + static const double allDramasFilterRight = 12; static const double followingTabFont = 16; static const double followingGridAspectRatio = 0.5; diff --git a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart index 1aa04d6..ff5d0dc 100644 --- a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart +++ b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart @@ -144,7 +144,7 @@ class _AllDramasFilterGridState extends State { padding: const EdgeInsets.fromLTRB( AppDimens.feedPageHorizontal, 0, - AppDimens.feedPageHorizontal, + AppDimens.allDramasFilterRight, AppDimens.allDramasFilterToGridGap, ), child: Column( @@ -254,7 +254,9 @@ class _AllDramasFilterGridState extends State { child: Text( label, style: TextStyle( - color: selected ? AppColors.textPrimary : AppColors.textTabInactive, + color: selected + ? AppColors.filterChipTextSelected + : AppColors.filterChipTextInactive, fontSize: AppDimens.filterChipFont, fontWeight: selected ? FontWeight.w600 : FontWeight.w400, height: 1, @@ -267,39 +269,41 @@ class _AllDramasFilterGridState extends State { Widget _sortRow(AppLocalizations l10n) { return SizedBox( key: const Key('all-filter-row-3'), - height: AppDimens.filterChipHeight, + height: AppDimens.allDramasFilterButton, child: Row( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, children: [ - _sortOption( + _chip( key: const Key('all-sort-hot'), label: l10n.hot, selected: !_newest, onTap: () => setState(() => _newest = false), ), const SizedBox(width: AppDimens.filterItemGap), - _sortOption( + _chip( key: const Key('all-sort-new'), label: l10n.newest, selected: _newest, onTap: () => setState(() => _newest = true), ), const Spacer(), - SizedBox.square( - dimension: AppDimens.allDramasFilterIcon, - child: IconButton( + Material( + key: const Key('all-dramas-filter-page-button'), + color: AppColors.filterMenuButton, + shape: const CircleBorder(), + child: InkWell( key: const Key('all-dramas-filter-page'), - tooltip: l10n.filter, - padding: EdgeInsets.zero, - constraints: const BoxConstraints.tightFor( - width: AppDimens.allDramasFilterIcon, - height: AppDimens.allDramasFilterIcon, + customBorder: const CircleBorder(), + onTap: widget.onOpenFilter, + child: const SizedBox.square( + dimension: AppDimens.allDramasFilterButton, + child: Icon( + Icons.filter_list_rounded, + size: AppDimens.allDramasFilterIcon, + color: AppColors.filterMenuIcon, + ), ), - onPressed: widget.onOpenFilter, - color: AppColors.textPrimary, - iconSize: AppDimens.allDramasFilterIcon, - icon: const Icon(Icons.filter_list_rounded), ), ), ], @@ -307,39 +311,6 @@ class _AllDramasFilterGridState extends State { ); } - Widget _sortOption({ - required Key key, - required String label, - required bool selected, - required VoidCallback onTap, - }) { - return GestureDetector( - key: key, - behavior: HitTestBehavior.opaque, - onTap: onTap, - child: SizedBox( - height: AppDimens.filterChipHeight, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.filterChipHorizontalPadding, - ), - alignment: Alignment.center, - child: Text( - label, - style: TextStyle( - color: selected - ? AppColors.textPrimary - : AppColors.textTabInactive, - fontSize: AppDimens.filterChipFont, - fontWeight: selected ? FontWeight.w700 : FontWeight.w400, - height: 1, - ), - ), - ), - ), - ); - } - List<({String id, String label})> _availableGenres(AppLocalizations l10n) { final definitions = [ (id: 'reversal', label: l10n.reversalFilter, terms: _reversalTerms), diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart index 25775ad..b1299c1 100644 --- a/test/recommend/recommend_sections_test.dart +++ b/test/recommend/recommend_sections_test.dart @@ -260,7 +260,7 @@ void main() { for (var row = 0; row < 4; row++) { final rowRect = tester.getRect(find.byKey(Key('all-filter-row-$row'))); expect(rowRect.left, closeTo(pageRect.left + 14, 0.1)); - expect(rowRect.right, closeTo(pageRect.right - 14, 0.1)); + expect(rowRect.right, closeTo(pageRect.right - 12, 0.1)); } final firstTexts = [ find.descendant( @@ -301,7 +301,7 @@ void main() { } expect( tester.getSize(find.byKey(const Key('all-filter-row-3'))).height, - AppDimens.filterChipHeight, + AppDimens.allDramasFilterButton, ); expect( tester.getRect(find.byKey(const Key('all-dramas-scroll'))).top - @@ -335,6 +335,7 @@ void main() { closeTo(fixedFilterTop, 0.1), ); expect(find.byKey(const Key('all-filter-local')), findsOneWidget); + expect(_textStyle(tester, 'Local').color, AppColors.filterChipTextInactive); expect(find.byKey(const Key('all-filter-subtitles')), findsOneWidget); for (final key in [ 'all-filter-reversal', @@ -376,12 +377,17 @@ void main() { AppDimens.filterItemGap, ); } + expect( + tester.getTopLeft(find.byKey(const Key('all-sort-new'))).dx - + tester.getTopRight(find.byKey(const Key('all-sort-hot'))).dx, + AppDimens.filterItemGap, + ); await tester.tap(find.byKey(const Key('all-filter-local'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('all-dramas-empty')), findsOneWidget); expect(_textStyle(tester, 'Local').fontWeight, FontWeight.w600); expect(_textStyle(tester, 'Local').fontSize, AppDimens.filterChipFont); - expect(_textStyle(tester, 'Local').color, AppColors.textPrimary); + expect(_textStyle(tester, 'Local').color, AppColors.filterChipTextSelected); final selectedChip = tester.widget( find .descendant( @@ -402,8 +408,9 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('all-sort-new'))); await tester.pump(); - expect(_textStyle(tester, 'New').fontWeight, FontWeight.w700); + expect(_textStyle(tester, 'New').fontWeight, FontWeight.w600); expect(_textStyle(tester, 'New').fontSize, AppDimens.filterChipFont); + expect(_textStyle(tester, 'New').color, AppColors.filterChipTextSelected); expect( tester.getSize(find.byKey(const Key('all-sort-new'))).height, AppDimens.filterChipHeight, @@ -421,7 +428,7 @@ void main() { final filterButton = find.byKey(const Key('all-dramas-filter-page')); expect( tester.getSize(filterButton), - const Size.square(AppDimens.allDramasFilterIcon), + const Size.square(AppDimens.allDramasFilterButton), ); expect( tester.getRect(filterButton).right, @@ -430,6 +437,19 @@ void main() { 0.1, ), ); + final filterMaterial = tester.widget( + find.byKey(const Key('all-dramas-filter-page-button')), + ); + expect(filterMaterial.color, AppColors.filterMenuButton); + expect(filterMaterial.shape, const CircleBorder()); + final filterIcon = tester.widget( + find.descendant( + of: filterButton, + matching: find.byIcon(Icons.filter_list_rounded), + ), + ); + expect(filterIcon.size, AppDimens.allDramasFilterIcon); + expect(filterIcon.color, AppColors.filterMenuIcon); await tester.tap(find.byKey(const Key('recommend-tab-playlist'))); await tester.pumpAndSettle(); From 21bb716af492e2dc6e21ee79b60d57b0249370dc Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 20:53:07 +0800 Subject: [PATCH 087/129] =?UTF-8?q?fix(recommend):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E7=AD=9B=E9=80=89=E5=B7=A6=E8=B5=B7=E5=A7=8B=E4=B8=8E=E6=A8=AA?= =?UTF-8?q?=E5=90=91=E6=BB=91=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 2 + lib/core/theme/app_dimens.dart | 6 +-- .../widgets/all_dramas_filter_grid.dart | 41 +++++++++++++------ test/recommend/recommend_sections_test.dart | 16 ++++++++ 4 files changed, 50 insertions(+), 15 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 68ccc5f..8ae87b4 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1216,3 +1216,5 @@ $ flutter test - 四行首项文字统一到同一 x;行高 30、行距 6、项目距 12,第 4 行保持弹性空白后固定筛选图标。 - 按最新截图改为 16sp、32 高、10 行距、16 项距;选中态为 `#21090E` 底配 `#FF4D6A` 粉字。 - 第 4 行复用选中胶囊样式,右侧改为 44×44 圆形深灰按钮和 22 三横线图标;`flutter analyze` 无问题。 +- 筛选行距收紧到 6、项距 10,chip 左右 padding 12;四行均可横向回弹滑动。 +- 第 4 行仅最热/最新在滚动区内,44 圆形筛选按钮固定在滚动区外;`flutter analyze` 无问题。 diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 0fc48db..904fcee 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -140,9 +140,9 @@ abstract final class AppDimens { static const double filterChipFont = 16; static const double filterChipHeight = 32; static const double filterChipRadius = 16; - static const double filterChipHorizontalPadding = 14; - static const double filterRowGap = 10; - static const double filterItemGap = 16; + static const double filterChipHorizontalPadding = 12; + static const double filterRowGap = 6; + static const double filterItemGap = 10; static const double allDramasHorizontal = 12; static const double allDramasGridGap = 6; static const double allDramasGridRowGap = 12; diff --git a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart index ff5d0dc..75086a8 100644 --- a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart +++ b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart @@ -190,8 +190,11 @@ class _AllDramasFilterGridState extends State { child: SizedBox( height: AppDimens.filterChipHeight, child: SingleChildScrollView( + key: Key('all-filter-scroll-$row'), scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics(), child: Row( + mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, children: [ @@ -274,20 +277,34 @@ class _AllDramasFilterGridState extends State { mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, children: [ - _chip( - key: const Key('all-sort-hot'), - label: l10n.hot, - selected: !_newest, - onTap: () => setState(() => _newest = false), + Expanded( + child: SingleChildScrollView( + key: const Key('all-filter-scroll-3'), + scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics(), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _chip( + key: const Key('all-sort-hot'), + label: l10n.hot, + selected: !_newest, + onTap: () => setState(() => _newest = false), + ), + const SizedBox(width: AppDimens.filterItemGap), + _chip( + key: const Key('all-sort-new'), + label: l10n.newest, + selected: _newest, + onTap: () => setState(() => _newest = true), + ), + ], + ), + ), ), const SizedBox(width: AppDimens.filterItemGap), - _chip( - key: const Key('all-sort-new'), - label: l10n.newest, - selected: _newest, - onTap: () => setState(() => _newest = true), - ), - const Spacer(), Material( key: const Key('all-dramas-filter-page-button'), color: AppColors.filterMenuButton, diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart index b1299c1..6d981ce 100644 --- a/test/recommend/recommend_sections_test.dart +++ b/test/recommend/recommend_sections_test.dart @@ -247,6 +247,11 @@ void main() { expect(delegate.mainAxisSpacing, AppDimens.allDramasGridRowGap); for (var row = 0; row < 4; row++) { expect(find.byKey(Key('all-filter-row-$row')), findsOneWidget); + final scroll = tester.widget( + find.byKey(Key('all-filter-scroll-$row')), + ); + expect(scroll.scrollDirection, Axis.horizontal); + expect(scroll.physics, isA()); } final filterStarts = [ for (var row = 0; row < 4; row++) @@ -355,6 +360,10 @@ void main() { of: rowZero, matching: find.byKey(const Key('all-filter-local')), ); + expect( + tester.getTopLeft(rowZeroAll).dx, + closeTo(pageRect.left + AppDimens.feedPageHorizontal, 0.1), + ); expect( tester.getTopLeft(rowZeroLocal).dx - tester.getTopRight(rowZeroAll).dx, AppDimens.filterItemGap, @@ -426,6 +435,13 @@ void main() { BorderRadius.circular(AppDimens.filterChipRadius), ); final filterButton = find.byKey(const Key('all-dramas-filter-page')); + expect( + find.descendant( + of: find.byKey(const Key('all-filter-scroll-3')), + matching: filterButton, + ), + findsNothing, + ); expect( tester.getSize(filterButton), const Size.square(AppDimens.allDramasFilterButton), From 57c9e6b47710c43c0849d75817c54eb3d02abedb Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 20:56:39 +0800 Subject: [PATCH 088/129] =?UTF-8?q?fix(recommend):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E5=85=A8=E9=83=A8=E7=9F=AD=E5=89=A7=E7=AD=9B=E9=80=89=E5=B7=A6?= =?UTF-8?q?=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 2 + .../widgets/all_dramas_filter_grid.dart | 66 ++++++----- .../all_dramas_filter_alignment_test.dart | 110 ++++++++++++++++++ test/recommend/recommend_sections_test.dart | 11 +- 4 files changed, 156 insertions(+), 33 deletions(-) create mode 100644 test/recommend/all_dramas_filter_alignment_test.dart diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 8ae87b4..8bbcd43 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1218,3 +1218,5 @@ $ flutter test - 第 4 行复用选中胶囊样式,右侧改为 44×44 圆形深灰按钮和 22 三横线图标;`flutter analyze` 无问题。 - 筛选行距收紧到 6、项距 10,chip 左右 padding 12;四行均可横向回弹滑动。 - 第 4 行仅最热/最新在滚动区内,44 圆形筛选按钮固定在滚动区外;`flutter analyze` 无问题。 +- 四行统一复用 `_filterRow` 构建,筛选区仅保留一层左右 12 padding。 +- 新增量化测试断言四行首项 x 均为 12、相邻 chip 间距均为 10;`flutter analyze` 无问题。 diff --git a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart index 75086a8..5ddcda2 100644 --- a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart +++ b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart @@ -142,9 +142,9 @@ class _AllDramasFilterGridState extends State { final genres = _availableGenres(l10n); return Padding( padding: const EdgeInsets.fromLTRB( - AppDimens.feedPageHorizontal, + AppDimens.allDramasHorizontal, 0, - AppDimens.allDramasFilterRight, + AppDimens.allDramasHorizontal, AppDimens.allDramasFilterToGridGap, ), child: Column( @@ -184,37 +184,45 @@ class _AllDramasFilterGridState extends State { required String selected, required ValueChanged onSelected, }) { - return Padding( - key: Key('all-filter-row-$row'), - padding: const EdgeInsets.only(bottom: AppDimens.filterRowGap), - child: SizedBox( - height: AppDimens.filterChipHeight, - child: SingleChildScrollView( - key: Key('all-filter-scroll-$row'), - scrollDirection: Axis.horizontal, - physics: const BouncingScrollPhysics(), - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - for (var index = 0; index < options.length; index++) ...[ - _chip( - key: Key('all-filter-${options[index].id}'), - label: options[index].label, - selected: selected == options[index].id, - onTap: () => onSelected(options[index].id), - ), - if (index < options.length - 1) - const SizedBox(width: AppDimens.filterItemGap), - ], + return _filterRow( + row: row, + child: SingleChildScrollView( + key: Key('all-filter-scroll-$row'), + scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics(), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + for (var index = 0; index < options.length; index++) ...[ + _chip( + key: Key('all-filter-${options[index].id}'), + label: options[index].label, + selected: selected == options[index].id, + onTap: () => onSelected(options[index].id), + ), + if (index < options.length - 1) + const SizedBox(width: AppDimens.filterItemGap), ], - ), + ], ), ), ); } + Widget _filterRow({ + required int row, + required Widget child, + double height = AppDimens.filterChipHeight, + }) { + return Padding( + key: Key('all-filter-row-$row'), + padding: EdgeInsets.only(bottom: row < 3 ? AppDimens.filterRowGap : 0), + child: SizedBox(height: height, child: child), + ); + } + Widget _genreRow( int row, List<({String id, String label})> options, @@ -270,8 +278,8 @@ class _AllDramasFilterGridState extends State { } Widget _sortRow(AppLocalizations l10n) { - return SizedBox( - key: const Key('all-filter-row-3'), + return _filterRow( + row: 3, height: AppDimens.allDramasFilterButton, child: Row( mainAxisAlignment: MainAxisAlignment.start, diff --git a/test/recommend/all_dramas_filter_alignment_test.dart b/test/recommend/all_dramas_filter_alignment_test.dart new file mode 100644 index 0000000..4f35d15 --- /dev/null +++ b/test/recommend/all_dramas_filter_alignment_test.dart @@ -0,0 +1,110 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/services/home_feed_controller.dart'; +import 'package:fruitshorts/core/theme/app_dimens.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/recommend/widgets/all_dramas_filter_grid.dart'; + +import '../support/fake_catalog.dart'; + +void main() { + testWidgets('all filter rows start at the same left edge', (tester) async { + final catalog = FakeCatalogRepository(); + final controller = HomeFeedController(catalog) + ..seedRecommend(catalog.dramas); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Scaffold( + body: AllDramasFilterGrid( + feedController: controller, + onOpenDrama: (_) {}, + onOpenFilter: () {}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(AppDimens.allDramasHorizontal, 12); + final firstChips = [ + find.descendant( + of: find.byKey(const Key('all-filter-row-0')), + matching: find.byKey(const Key('all-filter-all')), + ), + find.descendant( + of: find.byKey(const Key('all-filter-row-1')), + matching: find.byKey(const Key('all-filter-all')), + ), + find.descendant( + of: find.byKey(const Key('all-filter-row-2')), + matching: find.byKey(const Key('all-filter-all')), + ), + find.byKey(const Key('all-sort-hot')), + ]; + final starts = firstChips + .map((finder) => tester.getTopLeft(finder).dx) + .toList(growable: false); + + for (final start in starts) { + expect(start, closeTo(12, 0.5)); + expect(start, closeTo(starts.first, 0.5)); + } + + final rowZeroChips = [ + find.byKey(const Key('all-filter-all')).first, + find.byKey(const Key('all-filter-local')), + find.byKey(const Key('all-filter-asia')), + ]; + final rowZeroRects = rowZeroChips.map(tester.getRect).toList(); + final rowZeroGaps = [ + for (var index = 1; index < rowZeroRects.length; index++) + rowZeroRects[index].left - rowZeroRects[index - 1].right, + ]; + expect(rowZeroGaps[0], closeTo(10, 0.5)); + expect(rowZeroGaps[1], closeTo(rowZeroGaps[0], 0.5)); + + final rowOneAll = find.descendant( + of: find.byKey(const Key('all-filter-row-1')), + matching: find.byKey(const Key('all-filter-all')), + ); + final rowOneSubtitles = find.byKey(const Key('all-filter-subtitles')); + final rowOneDubbed = find.byKey(const Key('all-filter-dubbed')); + final rowOneGap = + tester.getTopLeft(rowOneSubtitles).dx - + tester.getTopRight(rowOneAll).dx; + expect(rowOneGap, closeTo(10, 0.5)); + expect( + tester.getTopLeft(rowOneDubbed).dx - + tester.getTopRight(rowOneSubtitles).dx, + closeTo(rowOneGap, 0.5), + ); + + final rowTwoAll = find.descendant( + of: find.byKey(const Key('all-filter-row-2')), + matching: find.byKey(const Key('all-filter-all')), + ); + final rowTwoReversal = find.byKey(const Key('all-filter-reversal')); + final rowTwoUplifting = find.byKey(const Key('all-filter-uplifting')); + final rowTwoGap = + tester.getTopLeft(rowTwoReversal).dx - tester.getTopRight(rowTwoAll).dx; + expect(rowTwoGap, closeTo(10, 0.5)); + expect( + tester.getTopLeft(rowTwoUplifting).dx - + tester.getTopRight(rowTwoReversal).dx, + closeTo(rowTwoGap, 0.5), + ); + + expect( + tester.getTopLeft(find.byKey(const Key('all-sort-new'))).dx - + tester.getTopRight(find.byKey(const Key('all-sort-hot'))).dx, + closeTo(10, 0.5), + ); + }); +} diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart index 6d981ce..2503f52 100644 --- a/test/recommend/recommend_sections_test.dart +++ b/test/recommend/recommend_sections_test.dart @@ -259,12 +259,15 @@ void main() { ]; expect( filterStarts, - everyElement(closeTo(AppDimens.feedPageHorizontal, 0.1)), + everyElement(closeTo(AppDimens.allDramasHorizontal, 0.1)), ); final pageRect = tester.getRect(find.byKey(const Key('recommend-page'))); for (var row = 0; row < 4; row++) { final rowRect = tester.getRect(find.byKey(Key('all-filter-row-$row'))); - expect(rowRect.left, closeTo(pageRect.left + 14, 0.1)); + expect( + rowRect.left, + closeTo(pageRect.left + AppDimens.allDramasHorizontal, 0.1), + ); expect(rowRect.right, closeTo(pageRect.right - 12, 0.1)); } final firstTexts = [ @@ -293,7 +296,7 @@ void main() { firstTextX.first, closeTo( pageRect.left + - AppDimens.feedPageHorizontal + + AppDimens.allDramasHorizontal + AppDimens.filterChipHorizontalPadding, 0.1, ), @@ -362,7 +365,7 @@ void main() { ); expect( tester.getTopLeft(rowZeroAll).dx, - closeTo(pageRect.left + AppDimens.feedPageHorizontal, 0.1), + closeTo(pageRect.left + AppDimens.allDramasHorizontal, 0.1), ); expect( tester.getTopLeft(rowZeroLocal).dx - tester.getTopRight(rowZeroAll).dx, From 196ce69a12166e506d844b88d0a79a6510f61307 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 20:59:02 +0800 Subject: [PATCH 089/129] =?UTF-8?q?fix(recommend):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E7=AA=84=E7=AD=9B=E9=80=89=E8=A1=8C=E6=A8=AA=E5=90=91=E5=B1=85?= =?UTF-8?q?=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 2 + .../widgets/all_dramas_filter_grid.dart | 92 ++++++++++--------- 2 files changed, 52 insertions(+), 42 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 8bbcd43..96d5cf7 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1220,3 +1220,5 @@ $ flutter test - 第 4 行仅最热/最新在滚动区内,44 圆形筛选按钮固定在滚动区外;`flutter analyze` 无问题。 - 四行统一复用 `_filterRow` 构建,筛选区仅保留一层左右 12 padding。 - 新增量化测试断言四行首项 x 均为 12、相邻 chip 间距均为 10;`flutter analyze` 无问题。 +- 修复横向 viewport 对短内容居中:滚动内容最小宽度撑满视口后由 Row.start 排列。 +- `flutter test test/recommend/all_dramas_filter_alignment_test.dart`:1 项测试通过。 diff --git a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart index 5ddcda2..245f8af 100644 --- a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart +++ b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart @@ -186,26 +186,41 @@ class _AllDramasFilterGridState extends State { }) { return _filterRow( row: row, - child: SingleChildScrollView( - key: Key('all-filter-scroll-$row'), + child: _horizontalChipScroll( + scrollKey: Key('all-filter-scroll-$row'), + chips: [ + for (var index = 0; index < options.length; index++) ...[ + _chip( + key: Key('all-filter-${options[index].id}'), + label: options[index].label, + selected: selected == options[index].id, + onTap: () => onSelected(options[index].id), + ), + if (index < options.length - 1) + const SizedBox(width: AppDimens.filterItemGap), + ], + ], + ), + ); + } + + Widget _horizontalChipScroll({ + required Key scrollKey, + required List chips, + }) { + return LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + key: scrollKey, scrollDirection: Axis.horizontal, physics: const BouncingScrollPhysics(), - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - for (var index = 0; index < options.length; index++) ...[ - _chip( - key: Key('all-filter-${options[index].id}'), - label: options[index].label, - selected: selected == options[index].id, - onTap: () => onSelected(options[index].id), - ), - if (index < options.length - 1) - const SizedBox(width: AppDimens.filterItemGap), - ], - ], + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: constraints.maxWidth), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: chips, + ), ), ), ); @@ -286,30 +301,23 @@ class _AllDramasFilterGridState extends State { crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( - child: SingleChildScrollView( - key: const Key('all-filter-scroll-3'), - scrollDirection: Axis.horizontal, - physics: const BouncingScrollPhysics(), - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - _chip( - key: const Key('all-sort-hot'), - label: l10n.hot, - selected: !_newest, - onTap: () => setState(() => _newest = false), - ), - const SizedBox(width: AppDimens.filterItemGap), - _chip( - key: const Key('all-sort-new'), - label: l10n.newest, - selected: _newest, - onTap: () => setState(() => _newest = true), - ), - ], - ), + child: _horizontalChipScroll( + scrollKey: const Key('all-filter-scroll-3'), + chips: [ + _chip( + key: const Key('all-sort-hot'), + label: l10n.hot, + selected: !_newest, + onTap: () => setState(() => _newest = false), + ), + const SizedBox(width: AppDimens.filterItemGap), + _chip( + key: const Key('all-sort-new'), + label: l10n.newest, + selected: _newest, + onTap: () => setState(() => _newest = true), + ), + ], ), ), const SizedBox(width: AppDimens.filterItemGap), From 9cf1efc31681f5e9b4b4d09f367a89eac1310bde Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 21:02:29 +0800 Subject: [PATCH 090/129] =?UTF-8?q?fix(home):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E4=BA=8C=E7=BA=A7=E6=A0=87=E7=AD=BE=E7=AD=9B=E9=80=89=E6=8C=89?= =?UTF-8?q?=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 6 ++ lib/core/theme/app_colors.dart | 1 - lib/core/theme/app_dimens.dart | 4 - lib/pages/home/home_view.dart | 1 - lib/pages/home/widgets/home_grid_header.dart | 90 ++++---------------- test/home/home_preview_layout_test.dart | 34 ++------ 6 files changed, 27 insertions(+), 109 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 96d5cf7..1fc6ae2 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1222,3 +1222,9 @@ $ flutter test - 新增量化测试断言四行首项 x 均为 12、相邻 chip 间距均为 10;`flutter analyze` 无问题。 - 修复横向 viewport 对短内容居中:滚动内容最小宽度撑满视口后由 Row.start 排列。 - `flutter test test/recommend/all_dramas_filter_alignment_test.dart`:1 项测试通过。 + +## 2026-10-22 首页二级标签移除筛选按钮 + +- 首页二级标签行改为纯横向滑动,右侧三横线按钮不渲染也不占位。 +- 删除不再使用的筛选回调、尺寸 token、颜色和 CustomPainter;全部短剧页筛选按钮保持不变。 +- `flutter analyze`:No issues found。 diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index f1d5aec..c421a1c 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -92,7 +92,6 @@ abstract final class AppColors { static const textSecondary = Color(0xFF8E8E93); static const textTertiary = Color(0xFF9E9E9E); static const textTabInactive = Color(0xFFB0B0B0); - static const textFilterIcon = Color(0xFF7A7A7E); /// 原生开屏与冷启动过渡底色。 static const splashInk = ink; diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 904fcee..e233bdb 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -114,10 +114,6 @@ abstract final class AppDimens { static const Duration homeSecondaryTagAnimationDuration = Duration( milliseconds: 150, ); - static const double homeSecondaryFilterGap = 6; - static const double homeSecondaryFilterVisualSize = 26; - static const double homeSecondaryFilterIcon = 16; - static const double homeSecondaryFilterStroke = 1.7; static const double homePrimaryCategoryGap = 24; static const double homeSecondaryTagGap = 10; static const double homeSearchTopGap = 0; diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index d8e309e..ee2445e 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -276,7 +276,6 @@ class _HomeTabsState extends State<_HomeTabs> { onSearch: () => _openSearch(context), onOpenVip: () => unawaited(_openVip(context)), onOpenWallet: () => unawaited(_openWallet(context)), - onOpenFilter: () => _pushPage(context, const FilterPage()), ), onOpenDrama: (drama) => unawaited(logic.playDrama(context, drama)), ); diff --git a/lib/pages/home/widgets/home_grid_header.dart b/lib/pages/home/widgets/home_grid_header.dart index 3720c46..78710d3 100644 --- a/lib/pages/home/widgets/home_grid_header.dart +++ b/lib/pages/home/widgets/home_grid_header.dart @@ -40,13 +40,11 @@ class HomeGridCallbacks { required this.onSearch, required this.onOpenVip, required this.onOpenWallet, - required this.onOpenFilter, }); final VoidCallback onSearch; final VoidCallback onOpenVip; final VoidCallback onOpenWallet; - final VoidCallback onOpenFilter; } class HomeGridHeader extends StatelessWidget { @@ -82,7 +80,7 @@ class HomeGridHeader extends StatelessWidget { _searchRow(l10n), _primaryRow(l10n), if (selectedPrimary == HomePrimaryTab.featured) - _tagRow(l10n) + _tagRow() else const SizedBox(height: AppDimens.homeSecondaryGridGap), ], @@ -324,7 +322,7 @@ class HomeGridHeader extends StatelessWidget { ); } - Widget _tagRow(AppLocalizations l10n) { + Widget _tagRow() { return Padding( padding: const EdgeInsets.fromLTRB( AppDimens.feedPageHorizontal, @@ -332,54 +330,19 @@ class HomeGridHeader extends StatelessWidget { AppDimens.feedPageHorizontal, AppDimens.homeSecondaryGridGap, ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Expanded( - child: SingleChildScrollView( - key: const Key('home-secondary-tags'), - scrollDirection: Axis.horizontal, - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - for (final tag in tags) ...[ - _tagChip(tag), - const SizedBox(width: AppDimens.homeSecondaryTagGap), - ], - ], - ), - ), - ), - const SizedBox(width: AppDimens.homeSecondaryFilterGap), - Tooltip( - message: l10n.filter, - child: SizedBox.square( - dimension: AppDimens.homeSecondaryFilterVisualSize, - child: Material( - color: AppColors.transparent, - child: InkWell( - key: const Key('home-grid-filter'), - onTap: callbacks.onOpenFilter, - child: Center( - child: SizedBox.square( - key: const Key('home-secondary-filter-visual'), - dimension: AppDimens.homeSecondaryFilterVisualSize, - child: const Center( - child: CustomPaint( - key: Key('home-secondary-filter-icon'), - size: Size.square(AppDimens.homeSecondaryFilterIcon), - painter: _ThreeLineFilterPainter(), - ), - ), - ), - ), - ), - ), - ), - ), - ], + child: SingleChildScrollView( + key: const Key('home-secondary-tags'), + scrollDirection: Axis.horizontal, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + for (final tag in tags) ...[ + _tagChip(tag), + const SizedBox(width: AppDimens.homeSecondaryTagGap), + ], + ], + ), ), ); } @@ -417,26 +380,3 @@ class HomeGridHeader extends StatelessWidget { ); } } - -class _ThreeLineFilterPainter extends CustomPainter { - const _ThreeLineFilterPainter(); - - @override - void paint(Canvas canvas, Size size) { - final paint = Paint() - ..color = AppColors.textFilterIcon - ..strokeWidth = AppDimens.homeSecondaryFilterStroke - ..strokeCap = StrokeCap.butt; - final positions = [ - size.height * 0.25, - size.height * 0.5, - size.height * 0.75, - ]; - for (final y in positions) { - canvas.drawLine(Offset(0, y), Offset(size.width, y), paint); - } - } - - @override - bool shouldRepaint(_ThreeLineFilterPainter oldDelegate) => false; -} diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 5bd0f70..f9566de 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -94,43 +94,21 @@ void main() { expect(find.byKey(const Key('home-section-category-古装')), findsOneWidget); expect(find.byKey(const Key('home-section-category-社群')), findsOneWidget); expect(find.byKey(const Key('home-secondary-tags')), findsOneWidget); - expect(find.byKey(const Key('home-grid-filter')), findsOneWidget); expect( - tester.getSize(find.byKey(const Key('home-grid-filter'))), - const Size.square(AppDimens.homeSecondaryFilterVisualSize), + tester.getRect(find.byKey(const Key('home-secondary-tags'))).left, + closeTo(AppDimens.feedPageHorizontal, 0.1), ); expect( - tester.getSize(find.byKey(const Key('home-secondary-filter-visual'))), - const Size.square(AppDimens.homeSecondaryFilterVisualSize), - ); - expect( - tester.getCenter(find.byKey(const Key('home-grid-filter'))).dy, - closeTo(tester.getCenter(find.byKey(const Key('home-tag-hot'))).dy, 0.1), - ); - expect( - tester.getRect(find.byKey(const Key('home-grid-filter'))).right, + tester.getRect(find.byKey(const Key('home-secondary-tags'))).right, closeTo( tester.getRect(find.byKey(const Key('home-grid'))).right - AppDimens.feedPageHorizontal, 0.1, ), ); - final filterIcon = tester.widget( - find.byKey(const Key('home-secondary-filter-icon')), - ); - expect( - filterIcon.size, - const Size.square(AppDimens.homeSecondaryFilterIcon), - ); - final filterMaterial = tester.widget( - find - .ancestor( - of: find.byKey(const Key('home-grid-filter')), - matching: find.byType(Material), - ) - .first, - ); - expect(filterMaterial.color, AppColors.transparent); + expect(find.byKey(const Key('home-grid-filter')), findsNothing); + expect(find.byKey(const Key('home-secondary-filter-visual')), findsNothing); + expect(find.byKey(const Key('home-secondary-filter-icon')), findsNothing); final searchBox = find.descendant( of: find.byKey(const Key('home-search-entry')), matching: find.byType(Container), From 13d70fcef264e4e8798892d58472d3724b50f9fb Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 21:05:51 +0800 Subject: [PATCH 091/129] =?UTF-8?q?fix(recommend):=20=E6=9B=B4=E6=AD=A3?= =?UTF-8?q?=E7=AD=9B=E9=80=89=E6=8C=89=E9=92=AE=E6=89=80=E5=B1=9E=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 1 + lib/core/theme/app_colors.dart | 3 +- lib/core/theme/app_dimens.dart | 7 +- lib/pages/home/home_view.dart | 2 +- lib/pages/home/widgets/home_grid_header.dart | 89 +++++++++++++++---- lib/pages/recommend/recommend_page.dart | 3 - .../widgets/all_dramas_filter_grid.dart | 56 +++--------- test/home/home_preview_layout_test.dart | 34 +++++-- .../all_dramas_filter_alignment_test.dart | 1 - test/recommend/recommend_sections_test.dart | 35 +------- 10 files changed, 124 insertions(+), 107 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 1fc6ae2..57a957d 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1228,3 +1228,4 @@ $ flutter test - 首页二级标签行改为纯横向滑动,右侧三横线按钮不渲染也不占位。 - 删除不再使用的筛选回调、尺寸 token、颜色和 CustomPainter;全部短剧页筛选按钮保持不变。 - `flutter analyze`:No issues found。 +- 按后续更正恢复首页透明三横线按钮(26、无背景、`#7A7A7E`),删除全部短剧第 4 行圆形按钮及回调链路。 diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index c421a1c..4536f27 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -80,8 +80,6 @@ abstract final class AppColors { static const filterChipSelected = Color(0xFF21090E); static const filterChipTextSelected = Color(0xFFFF4D6A); static const filterChipTextInactive = Color(0xFF8A8A8A); - static const filterMenuButton = Color(0xFF2A2A2A); - static const filterMenuIcon = Color(0xFFBDBDBD); /// 「我的」页卡片与描边。 static const mineCard = Color(0xFF16161A); @@ -92,6 +90,7 @@ abstract final class AppColors { static const textSecondary = Color(0xFF8E8E93); static const textTertiary = Color(0xFF9E9E9E); static const textTabInactive = Color(0xFFB0B0B0); + static const textFilterIcon = Color(0xFF7A7A7E); /// 原生开屏与冷启动过渡底色。 static const splashInk = ink; diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index e233bdb..e8ce8a1 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -114,6 +114,10 @@ abstract final class AppDimens { static const Duration homeSecondaryTagAnimationDuration = Duration( milliseconds: 150, ); + static const double homeSecondaryFilterGap = 6; + static const double homeSecondaryFilterVisualSize = 26; + static const double homeSecondaryFilterIcon = 16; + static const double homeSecondaryFilterStroke = 1.7; static const double homePrimaryCategoryGap = 24; static const double homeSecondaryTagGap = 10; static const double homeSearchTopGap = 0; @@ -143,9 +147,6 @@ abstract final class AppDimens { static const double allDramasGridGap = 6; static const double allDramasGridRowGap = 12; static const double allDramasFilterToGridGap = 12; - static const double allDramasFilterIcon = 22; - static const double allDramasFilterButton = 44; - static const double allDramasFilterRight = 12; static const double followingTabFont = 16; static const double followingGridAspectRatio = 0.5; diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index ee2445e..46db787 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -276,6 +276,7 @@ class _HomeTabsState extends State<_HomeTabs> { onSearch: () => _openSearch(context), onOpenVip: () => unawaited(_openVip(context)), onOpenWallet: () => unawaited(_openWallet(context)), + onOpenFilter: () => _pushPage(context, const FilterPage()), ), onOpenDrama: (drama) => unawaited(logic.playDrama(context, drama)), ); @@ -286,7 +287,6 @@ class _HomeTabsState extends State<_HomeTabs> { feedController: widget.feedController, active: logic.tab == 1, onSearch: () => _openSearch(context), - onOpenFilter: () => _pushPage(context, const FilterPage()), ); } diff --git a/lib/pages/home/widgets/home_grid_header.dart b/lib/pages/home/widgets/home_grid_header.dart index 78710d3..4c6ef5e 100644 --- a/lib/pages/home/widgets/home_grid_header.dart +++ b/lib/pages/home/widgets/home_grid_header.dart @@ -40,11 +40,13 @@ class HomeGridCallbacks { required this.onSearch, required this.onOpenVip, required this.onOpenWallet, + required this.onOpenFilter, }); final VoidCallback onSearch; final VoidCallback onOpenVip; final VoidCallback onOpenWallet; + final VoidCallback onOpenFilter; } class HomeGridHeader extends StatelessWidget { @@ -80,7 +82,7 @@ class HomeGridHeader extends StatelessWidget { _searchRow(l10n), _primaryRow(l10n), if (selectedPrimary == HomePrimaryTab.featured) - _tagRow() + _tagRow(l10n) else const SizedBox(height: AppDimens.homeSecondaryGridGap), ], @@ -322,7 +324,7 @@ class HomeGridHeader extends StatelessWidget { ); } - Widget _tagRow() { + Widget _tagRow(AppLocalizations l10n) { return Padding( padding: const EdgeInsets.fromLTRB( AppDimens.feedPageHorizontal, @@ -330,19 +332,54 @@ class HomeGridHeader extends StatelessWidget { AppDimens.feedPageHorizontal, AppDimens.homeSecondaryGridGap, ), - child: SingleChildScrollView( - key: const Key('home-secondary-tags'), - scrollDirection: Axis.horizontal, - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - for (final tag in tags) ...[ - _tagChip(tag), - const SizedBox(width: AppDimens.homeSecondaryTagGap), - ], - ], - ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Expanded( + child: SingleChildScrollView( + key: const Key('home-secondary-tags'), + scrollDirection: Axis.horizontal, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + for (final tag in tags) ...[ + _tagChip(tag), + const SizedBox(width: AppDimens.homeSecondaryTagGap), + ], + ], + ), + ), + ), + const SizedBox(width: AppDimens.homeSecondaryFilterGap), + Tooltip( + message: l10n.filter, + child: SizedBox.square( + dimension: AppDimens.homeSecondaryFilterVisualSize, + child: Material( + color: AppColors.transparent, + child: InkWell( + key: const Key('home-grid-filter'), + onTap: callbacks.onOpenFilter, + child: Center( + child: SizedBox.square( + key: const Key('home-secondary-filter-visual'), + dimension: AppDimens.homeSecondaryFilterVisualSize, + child: const Center( + child: CustomPaint( + key: Key('home-secondary-filter-icon'), + size: Size.square(AppDimens.homeSecondaryFilterIcon), + painter: _ThreeLineFilterPainter(), + ), + ), + ), + ), + ), + ), + ), + ), + ], ), ); } @@ -380,3 +417,25 @@ class HomeGridHeader extends StatelessWidget { ); } } + +class _ThreeLineFilterPainter extends CustomPainter { + const _ThreeLineFilterPainter(); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = AppColors.textFilterIcon + ..strokeWidth = AppDimens.homeSecondaryFilterStroke + ..strokeCap = StrokeCap.butt; + for (final y in [ + size.height * 0.25, + size.height * 0.5, + size.height * 0.75, + ]) { + canvas.drawLine(Offset(0, y), Offset(size.width, y), paint); + } + } + + @override + bool shouldRepaint(_ThreeLineFilterPainter oldDelegate) => false; +} diff --git a/lib/pages/recommend/recommend_page.dart b/lib/pages/recommend/recommend_page.dart index 2d054c6..3734263 100644 --- a/lib/pages/recommend/recommend_page.dart +++ b/lib/pages/recommend/recommend_page.dart @@ -24,13 +24,11 @@ class RecommendPage extends StatefulWidget { required this.feedController, required this.active, required this.onSearch, - required this.onOpenFilter, }); final HomeFeedController feedController; final bool active; final VoidCallback onSearch; - final VoidCallback onOpenFilter; @override State createState() => _RecommendPageState(); @@ -85,7 +83,6 @@ class _RecommendPageState extends State { AllDramasFilterGrid( feedController: widget.feedController, onOpenDrama: _openDrama, - onOpenFilter: widget.onOpenFilter, ), ), ), diff --git a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart index 245f8af..0440a1f 100644 --- a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart +++ b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart @@ -24,12 +24,10 @@ class AllDramasFilterGrid extends StatefulWidget { super.key, required this.feedController, required this.onOpenDrama, - required this.onOpenFilter, }); final HomeFeedController feedController; final ValueChanged onOpenDrama; - final VoidCallback onOpenFilter; @override State createState() => _AllDramasFilterGridState(); @@ -295,49 +293,21 @@ class _AllDramasFilterGridState extends State { Widget _sortRow(AppLocalizations l10n) { return _filterRow( row: 3, - height: AppDimens.allDramasFilterButton, - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: _horizontalChipScroll( - scrollKey: const Key('all-filter-scroll-3'), - chips: [ - _chip( - key: const Key('all-sort-hot'), - label: l10n.hot, - selected: !_newest, - onTap: () => setState(() => _newest = false), - ), - const SizedBox(width: AppDimens.filterItemGap), - _chip( - key: const Key('all-sort-new'), - label: l10n.newest, - selected: _newest, - onTap: () => setState(() => _newest = true), - ), - ], - ), + child: _horizontalChipScroll( + scrollKey: const Key('all-filter-scroll-3'), + chips: [ + _chip( + key: const Key('all-sort-hot'), + label: l10n.hot, + selected: !_newest, + onTap: () => setState(() => _newest = false), ), const SizedBox(width: AppDimens.filterItemGap), - Material( - key: const Key('all-dramas-filter-page-button'), - color: AppColors.filterMenuButton, - shape: const CircleBorder(), - child: InkWell( - key: const Key('all-dramas-filter-page'), - customBorder: const CircleBorder(), - onTap: widget.onOpenFilter, - child: const SizedBox.square( - dimension: AppDimens.allDramasFilterButton, - child: Icon( - Icons.filter_list_rounded, - size: AppDimens.allDramasFilterIcon, - color: AppColors.filterMenuIcon, - ), - ), - ), + _chip( + key: const Key('all-sort-new'), + label: l10n.newest, + selected: _newest, + onTap: () => setState(() => _newest = true), ), ], ), diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index f9566de..5bd0f70 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -94,21 +94,43 @@ void main() { expect(find.byKey(const Key('home-section-category-古装')), findsOneWidget); expect(find.byKey(const Key('home-section-category-社群')), findsOneWidget); expect(find.byKey(const Key('home-secondary-tags')), findsOneWidget); + expect(find.byKey(const Key('home-grid-filter')), findsOneWidget); expect( - tester.getRect(find.byKey(const Key('home-secondary-tags'))).left, - closeTo(AppDimens.feedPageHorizontal, 0.1), + tester.getSize(find.byKey(const Key('home-grid-filter'))), + const Size.square(AppDimens.homeSecondaryFilterVisualSize), ); expect( - tester.getRect(find.byKey(const Key('home-secondary-tags'))).right, + tester.getSize(find.byKey(const Key('home-secondary-filter-visual'))), + const Size.square(AppDimens.homeSecondaryFilterVisualSize), + ); + expect( + tester.getCenter(find.byKey(const Key('home-grid-filter'))).dy, + closeTo(tester.getCenter(find.byKey(const Key('home-tag-hot'))).dy, 0.1), + ); + expect( + tester.getRect(find.byKey(const Key('home-grid-filter'))).right, closeTo( tester.getRect(find.byKey(const Key('home-grid'))).right - AppDimens.feedPageHorizontal, 0.1, ), ); - expect(find.byKey(const Key('home-grid-filter')), findsNothing); - expect(find.byKey(const Key('home-secondary-filter-visual')), findsNothing); - expect(find.byKey(const Key('home-secondary-filter-icon')), findsNothing); + final filterIcon = tester.widget( + find.byKey(const Key('home-secondary-filter-icon')), + ); + expect( + filterIcon.size, + const Size.square(AppDimens.homeSecondaryFilterIcon), + ); + final filterMaterial = tester.widget( + find + .ancestor( + of: find.byKey(const Key('home-grid-filter')), + matching: find.byType(Material), + ) + .first, + ); + expect(filterMaterial.color, AppColors.transparent); final searchBox = find.descendant( of: find.byKey(const Key('home-search-entry')), matching: find.byType(Container), diff --git a/test/recommend/all_dramas_filter_alignment_test.dart b/test/recommend/all_dramas_filter_alignment_test.dart index 4f35d15..000aa1b 100644 --- a/test/recommend/all_dramas_filter_alignment_test.dart +++ b/test/recommend/all_dramas_filter_alignment_test.dart @@ -25,7 +25,6 @@ void main() { body: AllDramasFilterGrid( feedController: controller, onOpenDrama: (_) {}, - onOpenFilter: () {}, ), ), ), diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart index 2503f52..8acd660 100644 --- a/test/recommend/recommend_sections_test.dart +++ b/test/recommend/recommend_sections_test.dart @@ -309,7 +309,7 @@ void main() { } expect( tester.getSize(find.byKey(const Key('all-filter-row-3'))).height, - AppDimens.allDramasFilterButton, + AppDimens.filterChipHeight, ); expect( tester.getRect(find.byKey(const Key('all-dramas-scroll'))).top - @@ -437,38 +437,7 @@ void main() { (chipContainer.decoration! as BoxDecoration).borderRadius, BorderRadius.circular(AppDimens.filterChipRadius), ); - final filterButton = find.byKey(const Key('all-dramas-filter-page')); - expect( - find.descendant( - of: find.byKey(const Key('all-filter-scroll-3')), - matching: filterButton, - ), - findsNothing, - ); - expect( - tester.getSize(filterButton), - const Size.square(AppDimens.allDramasFilterButton), - ); - expect( - tester.getRect(filterButton).right, - closeTo( - tester.getRect(find.byKey(const Key('all-filter-row-3'))).right, - 0.1, - ), - ); - final filterMaterial = tester.widget( - find.byKey(const Key('all-dramas-filter-page-button')), - ); - expect(filterMaterial.color, AppColors.filterMenuButton); - expect(filterMaterial.shape, const CircleBorder()); - final filterIcon = tester.widget( - find.descendant( - of: filterButton, - matching: find.byIcon(Icons.filter_list_rounded), - ), - ); - expect(filterIcon.size, AppDimens.allDramasFilterIcon); - expect(filterIcon.color, AppColors.filterMenuIcon); + expect(find.byKey(const Key('all-dramas-filter-page')), findsNothing); await tester.tap(find.byKey(const Key('recommend-tab-playlist'))); await tester.pumpAndSettle(); From 2fac46f2488650a1704258ff69edc401f96e422b Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 21:14:23 +0800 Subject: [PATCH 092/129] =?UTF-8?q?feat:=20=E6=94=B9=E9=80=A0=E8=BF=BD?= =?UTF-8?q?=E5=89=A7=E9=A1=B5=E5=8F=8C=E5=88=97=E8=A1=A8=E4=B8=8E=E4=B8=89?= =?UTF-8?q?=E5=88=97=E5=8D=A1=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 6 + lib/core/theme/app_dimens.dart | 19 +- lib/l10n/app_localizations_zh.dart | 2 +- lib/l10n/app_zh.arb | 2 +- lib/pages/following/following_grid_card.dart | 104 +++--- lib/pages/following/following_page.dart | 343 +++++++----------- lib/pages/following/following_paged_grid.dart | 152 ++++++++ test/following/following_page_test.dart | 83 +++-- 8 files changed, 435 insertions(+), 276 deletions(-) create mode 100644 lib/pages/following/following_paged_grid.dart diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 57a957d..8819183 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1229,3 +1229,9 @@ $ flutter test - 删除不再使用的筛选回调、尺寸 token、颜色和 CustomPainter;全部短剧页筛选按钮保持不变。 - `flutter analyze`:No issues found。 - 按后续更正恢复首页透明三横线按钮(26、无背景、`#7A7A7E`),删除全部短剧第 4 行圆形按钮及回调链路。 + +## 2026-10-22 追剧页三列改造 + +- 追剧页移除预约 tab,改为追剧/播放历史双页横向切换,并保留各自滚动位置。 +- 追剧与历史统一三列卡片,新增固定加号卡片,历史进度与编辑删除能力保留。 +- 按最新口径调整列距 6、行距 12、封面圆角 6;`flutter analyze`:No issues found。 diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index e8ce8a1..9ceb1aa 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -147,8 +147,23 @@ abstract final class AppDimens { static const double allDramasGridGap = 6; static const double allDramasGridRowGap = 12; static const double allDramasFilterToGridGap = 12; - static const double followingTabFont = 16; - static const double followingGridAspectRatio = 0.5; + static const double followingHeaderHeight = 52; + static const double followingTabFont = 18; + static const double followingTabGap = 24; + static const double followingEditIconSize = 24; + static const double followingAddIconSize = 28; + static const double followingGridGap = 6; + static const double followingGridRowGap = 12; + static const double followingCardTextExtent = 66; + static const double followingSubtitleFont = 12; + static const double followingSubtitleGap = 4; + static const double followingProgressHeight = 2; + static const Duration followingTabAnimationDuration = Duration( + milliseconds: 180, + ); + static const Duration followingPageAnimationDuration = Duration( + milliseconds: 220, + ); /// 「我的」页中性视觉。 static const double mineCardRadius = 16; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index b99bb6e..1feadad 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -851,7 +851,7 @@ class AppLocalizationsZh extends AppLocalizations { String get freeBonus => '领免费奖励'; @override - String get watchHistory => '观看历史'; + String get watchHistory => '播放历史'; @override String get settings => '设置'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index e98372e..8d4623e 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -339,7 +339,7 @@ "joinVip": "开通会员", "earnRewards": "赚奖励", "freeBonus": "领免费奖励", - "watchHistory": "观看历史", + "watchHistory": "播放历史", "settings": "设置", "serverAddress": "服务器地址", "serverSettings": "服务器设置", diff --git a/lib/pages/following/following_grid_card.dart b/lib/pages/following/following_grid_card.dart index c1a433a..a3506e0 100644 --- a/lib/pages/following/following_grid_card.dart +++ b/lib/pages/following/following_grid_card.dart @@ -14,7 +14,6 @@ class FollowingGridCard extends StatelessWidget { required this.title, required this.posterUrl, required this.viewCount, - required this.tags, required this.subtitle, required this.onTap, this.progress, @@ -26,7 +25,6 @@ class FollowingGridCard extends StatelessWidget { final String title; final String posterUrl; final int viewCount; - final List tags; final String subtitle; final VoidCallback onTap; final double? progress; @@ -37,22 +35,21 @@ class FollowingGridCard extends StatelessWidget { Widget build(BuildContext context) { return InkWell( key: Key('following-card-$dramaId'), - borderRadius: BorderRadius.circular(AppDimens.cardCoverRadius), + borderRadius: BorderRadius.circular(AppDimens.homeCardRadius), onTap: onTap, child: Column( crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, children: [ AspectRatio( key: Key('following-cover-$dramaId'), aspectRatio: AppDimens.cardCoverAspectRatio, child: Stack( - clipBehavior: Clip.none, + clipBehavior: Clip.hardEdge, children: [ Positioned.fill( child: ClipRRect( borderRadius: BorderRadius.circular( - AppDimens.cardCoverRadius, + AppDimens.homeCardRadius, ), child: RemoteImage( posterUrl, @@ -64,18 +61,18 @@ class FollowingGridCard extends StatelessWidget { ), if (editing) Positioned( - top: -6, - right: -6, + top: -2, + right: -2, child: GestureDetector( key: Key('following-remove-$dramaId'), onTap: onRemove, child: const CircleAvatar( - radius: 13, + radius: 12, backgroundColor: AppColors.contentChip, child: Icon( Icons.remove_rounded, color: AppColors.textPrimary, - size: 18, + size: 17, ), ), ), @@ -88,12 +85,29 @@ class FollowingGridCard extends StatelessWidget { ), Positioned( right: AppDimens.cardPlayCountRight, - bottom: AppDimens.cardPlayCountBottom, + bottom: progress == null + ? AppDimens.cardPlayCountBottom + : AppDimens.followingProgressHeight + 4, child: CoverPlayCount( key: Key('following-views-$dramaId'), count: viewCount, ), ), + if (progress != null) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: LinearProgressIndicator( + key: Key('following-progress-$dramaId'), + value: progress!.clamp(0.0, 1.0).toDouble(), + minHeight: AppDimens.followingProgressHeight, + backgroundColor: AppColors.inactiveTrack, + valueColor: const AlwaysStoppedAnimation( + AppColors.primary, + ), + ), + ), ], ), ), @@ -110,51 +124,55 @@ class FollowingGridCard extends StatelessWidget { height: AppDimens.cardTitleLineHeight, ), ), - if (tags.isNotEmpty) ...[ - const SizedBox(height: AppDimens.cardTitleTagGap), - _tags(), - ], - const SizedBox(height: AppDimens.spaceXs), + const SizedBox(height: AppDimens.followingSubtitleGap), Text( subtitle, maxLines: 1, overflow: TextOverflow.ellipsis, + textAlign: TextAlign.left, style: const TextStyle( color: AppColors.textTertiary, - fontSize: AppDimens.cardViewsFont, + fontSize: AppDimens.followingSubtitleFont, ), ), - if (progress != null) ...[ - const SizedBox(height: AppDimens.spaceXs), - ClipRRect( - borderRadius: BorderRadius.circular(AppDimens.progressRadius), - child: LinearProgressIndicator( - key: Key('following-progress-$dramaId'), - value: progress!.clamp(0.0, 1.0).toDouble(), - minHeight: 2, - backgroundColor: AppColors.inactiveTrack, - valueColor: const AlwaysStoppedAnimation( - AppColors.primary, - ), - ), - ), - ], ], ), ); } +} - Widget _tags() { - final visible = tags.take(3).toList(growable: false); - return Text( - visible.join(' | '), - key: Key('following-tags-$dramaId'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.left, - style: const TextStyle( - color: AppColors.textTertiary, - fontSize: AppDimens.cardTagFont, +class FollowingAddCard extends StatelessWidget { + const FollowingAddCard({super.key, required this.onTap}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + key: const Key('following-add-card'), + borderRadius: BorderRadius.circular(AppDimens.homeCardRadius), + onTap: onTap, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: AppDimens.cardCoverAspectRatio, + child: DecoratedBox( + decoration: BoxDecoration( + color: AppColors.contentSurface, + borderRadius: BorderRadius.circular(AppDimens.homeCardRadius), + ), + child: const Center( + child: Icon( + Icons.add, + key: Key('following-add-icon'), + size: AppDimens.followingAddIconSize, + color: AppColors.textPrimary, + ), + ), + ), + ), + ], ), ); } diff --git a/lib/pages/following/following_page.dart b/lib/pages/following/following_page.dart index 5be30a2..e148972 100644 --- a/lib/pages/following/following_page.dart +++ b/lib/pages/following/following_page.dart @@ -5,7 +5,6 @@ import 'dart:async'; import 'package:flutter/material.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/watch_history_repository.dart'; @@ -15,17 +14,14 @@ import '../../core/data/services/watch_records_controller.dart'; import '../../core/formatting/error_text.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; -import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/empty_state.dart'; -import '../../core/widgets/paged_list_footer.dart'; import '../../l10n/app_localizations.dart'; import '../player/open_player.dart'; import 'following_grid_card.dart'; +import 'following_paged_grid.dart'; -final RegExp _tagSplitter = RegExp(r'[,/、·]'); - -enum FollowingTab { favorites, history, reserve } +enum FollowingTab { favorites, history } class FollowingPage extends StatefulWidget { const FollowingPage({ @@ -42,17 +38,16 @@ class FollowingPage extends StatefulWidget { } class _FollowingPageState extends State { + final PageController _pageController = PageController(); + final ScrollController _favoriteScroll = ScrollController(); + final ScrollController _historyScroll = ScrollController(); late final WatchRecordsController _history; - late final ScrollController _favoriteScroll; - late final ScrollController _historyScroll; FollowingTab _tab = FollowingTab.favorites; bool _editing = false; @override void initState() { super.initState(); - _favoriteScroll = ScrollController(); - _historyScroll = ScrollController(); _history = WatchRecordsController( history: context.read(), catalog: context.read(), @@ -65,6 +60,7 @@ class _FollowingPageState extends State { @override void dispose() { + _pageController.dispose(); _favoriteScroll.dispose(); _historyScroll.dispose(); _history.dispose(); @@ -86,13 +82,16 @@ class _FollowingPageState extends State { Expanded( child: !signedIn ? const AuthGatePanel() - : IndexedStack( - index: _tab.index, + : PageView( + key: const Key('following-tab-pages'), + controller: _pageController, + onPageChanged: _onPageChanged, children: [ _FavoritesGrid( controller: widget.favoriteController, editing: _editing, scrollController: _favoriteScroll, + onGoHome: widget.onGoHome, onListEmptied: _exitEditingIfEmpty, ), _HistoryGrid( @@ -101,7 +100,6 @@ class _FollowingPageState extends State { scrollController: _historyScroll, onListEmptied: _exitEditingIfEmpty, ), - _reserveEmpty(l10n), ], ), ), @@ -112,79 +110,119 @@ class _FollowingPageState extends State { } Widget _header(AppLocalizations l10n) { - final tabs = [ - (FollowingTab.favorites, l10n.followingTab, 'favorites'), - (FollowingTab.history, l10n.watchHistory, 'history'), - (FollowingTab.reserve, l10n.reserveTitle, 'reserve'), - ]; return SizedBox( key: const Key('following-header'), - height: 52, + height: AppDimens.followingHeaderHeight, child: Padding( padding: const EdgeInsets.symmetric( horizontal: AppDimens.feedPageHorizontal, ), child: Row( children: [ - for (final tab in tabs) - Expanded( + _tabButton(FollowingTab.favorites, l10n.followingTab, 'favorites'), + const SizedBox(width: AppDimens.followingTabGap), + _tabButton(FollowingTab.history, l10n.watchHistory, 'history'), + const Spacer(), + if (_editing) + SizedBox( + width: 44, + height: 44, child: InkWell( - key: Key('following-tab-${tab.$3}'), - onTap: () => setState(() { - _tab = tab.$1; - _editing = false; - }), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - tab.$2, - maxLines: 1, - style: TextStyle( - color: _tab == tab.$1 - ? AppColors.textPrimary - : AppColors.textTertiary, - fontSize: AppDimens.followingTabFont, - fontWeight: _tab == tab.$1 - ? FontWeight.w700 - : FontWeight.w400, + key: const Key('following-edit'), + onTap: _toggleEditing, + child: Align( + alignment: Alignment.centerRight, + child: Text( + l10n.done, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.followingTabFont, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ) + else + Semantics( + button: true, + label: l10n.edit, + child: SizedBox( + width: 44, + height: 44, + child: InkWell( + key: const Key('following-edit'), + onTap: _toggleEditing, + child: const Align( + alignment: Alignment.centerRight, + child: SizedBox.square( + dimension: AppDimens.followingEditIconSize, + child: Icon( + Icons.edit_outlined, + color: AppColors.textPrimary, ), ), - const SizedBox(height: 3), - Container( - width: _tab == tab.$1 ? 20 : 0, - height: 2, - color: AppColors.textPrimary, - ), - ], + ), ), ), ), - IconButton( - key: const Key('following-edit'), - tooltip: _editing ? l10n.done : l10n.edit, - onPressed: () => setState(() => _editing = !_editing), - color: AppColors.textPrimary, - iconSize: 24, - icon: Icon(_editing ? Icons.check_rounded : Icons.edit_outlined), - ), ], ), ), ); } - Widget _reserveEmpty(AppLocalizations l10n) { - return EmptyState( - key: const Key('following-reserve-empty'), - icon: Icons.notifications_none_rounded, - title: l10n.reserveEmptyTitle, - subtitle: l10n.reserveEmptyHint, - actionLabel: l10n.homeTab, - onAction: widget.onGoHome, + Widget _tabButton(FollowingTab tab, String label, String keyName) { + final selected = _tab == tab; + return InkWell( + key: Key('following-tab-$keyName'), + onTap: () => _selectTab(tab), + child: SizedBox( + height: AppDimens.followingHeaderHeight, + child: Center( + child: AnimatedDefaultTextStyle( + duration: AppDimens.followingTabAnimationDuration, + style: TextStyle( + color: selected + ? AppColors.textPrimary + : AppColors.textTabInactive, + fontSize: AppDimens.followingTabFont, + fontWeight: selected ? FontWeight.w600 : FontWeight.w400, + ), + child: Text(label, maxLines: 1), + ), + ), + ), ); } + void _selectTab(FollowingTab tab) { + if (_tab != tab || _editing) { + setState(() { + _tab = tab; + _editing = false; + }); + } + _pageController.animateToPage( + tab.index, + duration: AppDimens.followingPageAnimationDuration, + curve: Curves.easeOutCubic, + ); + } + + void _onPageChanged(int index) { + final tab = FollowingTab.values[index]; + if (_tab == tab && !_editing) return; + setState(() { + _tab = tab; + _editing = false; + }); + } + + void _toggleEditing() { + setState(() => _editing = !_editing); + } + void _exitEditingIfEmpty() { if (_editing && mounted) setState(() => _editing = false); } @@ -195,12 +233,14 @@ class _FavoritesGrid extends StatelessWidget { required this.controller, required this.editing, required this.scrollController, + required this.onGoHome, required this.onListEmptied, }); final FavoriteController controller; final bool editing; final ScrollController scrollController; + final VoidCallback onGoHome; final VoidCallback onListEmptied; @override @@ -209,13 +249,14 @@ class _FavoritesGrid extends StatelessWidget { listenable: controller, builder: (context, _) { final l10n = AppLocalizations.of(context)!; - return _PagedGrid( + return FollowingPagedGrid( key: const Key('following-favorites-grid'), scrollController: scrollController, loading: controller.loading, loadingMore: controller.loadingMore, hasMore: controller.hasMore, hasItems: controller.cards.isNotEmpty, + showInitialLoading: false, errorMessage: controller.error == null ? null : errorText(l10n, controller.error, l10n.favoritesError), @@ -224,15 +265,13 @@ class _FavoritesGrid extends StatelessWidget { : errorText(l10n, controller.loadMoreError, l10n.favoritesError), onRefresh: controller.load, onLoadMore: controller.loadMore, - itemCount: controller.cards.length, - empty: EmptyState( - key: const Key('following-favorites-empty'), - icon: Icons.bookmark_border_rounded, - title: l10n.noFavorites, - subtitle: l10n.emptyHint, - ), + itemCount: controller.cards.length + 1, + empty: const SizedBox.shrink(), itemBuilder: (context, index) { - final drama = controller.cards[index]; + if (index == 0) { + return FollowingAddCard(onTap: onGoHome); + } + final drama = controller.cards[index - 1]; final episode = drama.episodes.isEmpty ? 1 : drama.episodes.first.number; @@ -241,7 +280,6 @@ class _FavoritesGrid extends StatelessWidget { title: drama.title, posterUrl: drama.posterUrl, viewCount: drama.viewCount, - tags: _dramaTags(drama), editing: editing, subtitle: l10n.favoriteEpisodeProgress( episode, @@ -249,9 +287,10 @@ class _FavoritesGrid extends StatelessWidget { ), onTap: () => unawaited(playDramaFromCatalog(context, drama)), onRemove: () async { - if (await _confirmRemove(context)) { - await controller.remove(drama.id); - if (controller.cards.isEmpty) onListEmptied(); + if (await _confirmRemove(context) && + await controller.remove(drama.id) && + controller.cards.isEmpty) { + onListEmptied(); } }, ); @@ -281,7 +320,7 @@ class _HistoryGrid extends StatelessWidget { listenable: controller, builder: (context, _) { final l10n = AppLocalizations.of(context)!; - return _PagedGrid( + return FollowingPagedGrid( key: const Key('following-history-grid'), scrollController: scrollController, loading: controller.loading, @@ -305,23 +344,21 @@ class _HistoryGrid extends StatelessWidget { ), itemBuilder: (context, index) { final record = controller.records[index]; - final title = record.displayTitle.isEmpty - ? l10n.episodeLabel(record.episodeNumber) - : record.displayTitle; + final progress = record.progress.fraction; return FollowingGridCard( dramaId: record.dramaId, - title: title, + title: _historyTitle(l10n, record), posterUrl: record.displayCover, - viewCount: record.drama?.viewCount ?? 0, - tags: _dramaTags(record.drama), - progress: record.progress.fraction, + viewCount: record.drama?.viewCount ?? record.progress.playCount, + progress: progress > 0 ? progress : null, editing: editing, - subtitle: l10n.watchedToEpisode(record.episodeNumber), + subtitle: _historySubtitle(l10n, record), onTap: () => unawaited(_openHistory(context, controller, record)), onRemove: () async { - if (await _confirmRemove(context)) { - await controller.remove(record.dramaId); - if (controller.records.isEmpty) onListEmptied(); + if (await _confirmRemove(context) && + await controller.remove(record.dramaId) && + controller.records.isEmpty) { + onListEmptied(); } }, ); @@ -332,15 +369,19 @@ class _HistoryGrid extends StatelessWidget { } } -List _dramaTags(Drama? drama) { - if (drama == null) return const []; - final seen = {}; - return [drama.genre, drama.category] - .expand((value) => value.split(_tagSplitter)) - .map((value) => value.trim()) - .where((value) => value.isNotEmpty && seen.add(value)) - .take(3) - .toList(growable: false); +String _historyTitle(AppLocalizations l10n, WatchRecord record) { + return record.displayTitle.isEmpty + ? l10n.episodeLabel(record.episodeNumber) + : record.displayTitle; +} + +String _historySubtitle(AppLocalizations l10n, WatchRecord record) { + final total = + record.progress.totalEpisodes ?? record.drama?.episodeCount ?? 0; + if (record.episodeNumber <= 0 || total <= 0) { + return l10n.watchedToEpisode(record.episodeNumber); + } + return l10n.favoriteEpisodeProgress(record.episodeNumber, total); } Future _openHistory( @@ -377,111 +418,3 @@ Future _confirmRemove(BuildContext context) async { ) ?? false; } - -class _PagedGrid extends StatelessWidget { - const _PagedGrid({ - super.key, - required this.scrollController, - required this.loading, - required this.loadingMore, - required this.hasMore, - required this.hasItems, - required this.itemCount, - required this.itemBuilder, - required this.empty, - required this.onRefresh, - required this.onLoadMore, - this.errorMessage, - this.loadMoreError, - }); - - static const double _loadMoreThreshold = 240; - - final ScrollController scrollController; - final bool loading; - final bool loadingMore; - final bool hasMore; - final bool hasItems; - final int itemCount; - final IndexedWidgetBuilder itemBuilder; - final Widget empty; - final String? errorMessage; - final String? loadMoreError; - final Future Function() onRefresh; - final Future Function() onLoadMore; - - @override - Widget build(BuildContext context) { - if (loading && !hasItems) { - return const Center(child: CircularProgressIndicator()); - } - final emptyContent = errorMessage == null - ? empty - : EmptyState( - icon: Icons.cloud_off_rounded, - title: errorMessage!, - actionLabel: AppLocalizations.of(context)!.retry, - onAction: () => unawaited(onRefresh()), - ); - return NotificationListener( - onNotification: _onScroll, - child: AppPullToRefresh( - onRefresh: onRefresh, - child: CustomScrollView( - key: ValueKey('following-scroll-$runtimeType'), - controller: scrollController, - physics: AppPullToRefresh.physics, - slivers: [ - if (!hasItems) - SliverFillRemaining(hasScrollBody: false, child: emptyContent) - else ...[ - SliverPadding( - padding: const EdgeInsets.fromLTRB( - AppDimens.feedPageHorizontal, - AppDimens.spaceMd, - AppDimens.feedPageHorizontal, - AppDimens.spaceSm, - ), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - crossAxisSpacing: AppDimens.homeGridGap, - mainAxisSpacing: AppDimens.homeGridRowGap, - childAspectRatio: AppDimens.followingGridAspectRatio, - ), - delegate: SliverChildBuilderDelegate( - itemBuilder, - childCount: itemCount, - ), - ), - ), - SliverToBoxAdapter( - child: PagedListFooter( - loading: loadingMore, - hasMore: hasMore, - hasItems: hasItems, - errorMessage: loadMoreError, - onRetry: onLoadMore, - ), - ), - const SliverToBoxAdapter( - child: SizedBox(height: AppDimens.spaceXl), - ), - ], - ], - ), - ), - ); - } - - bool _onScroll(ScrollNotification notification) { - if (notification.depth != 0) return false; - if (notification.metrics.extentAfter > _loadMoreThreshold) return false; - if (notification is ScrollUpdateNotification || - notification is ScrollEndNotification || - notification is OverscrollNotification) { - unawaited(onLoadMore()); - } - return false; - } -} diff --git a/lib/pages/following/following_paged_grid.dart b/lib/pages/following/following_paged_grid.dart new file mode 100644 index 0000000..269e9e6 --- /dev/null +++ b/lib/pages/following/following_paged_grid.dart @@ -0,0 +1,152 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_dimens.dart'; +import '../../core/widgets/app_pull_to_refresh.dart'; +import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/paged_list_footer.dart'; +import '../../l10n/app_localizations.dart'; + +class FollowingPagedGrid extends StatelessWidget { + const FollowingPagedGrid({ + super.key, + required this.scrollController, + required this.loading, + required this.loadingMore, + required this.hasMore, + required this.hasItems, + required this.itemCount, + required this.itemBuilder, + required this.empty, + required this.onRefresh, + required this.onLoadMore, + this.showInitialLoading = true, + this.errorMessage, + this.loadMoreError, + }); + + static const double _loadMoreThreshold = 240; + + final ScrollController scrollController; + final bool loading; + final bool loadingMore; + final bool hasMore; + final bool hasItems; + final int itemCount; + final IndexedWidgetBuilder itemBuilder; + final Widget empty; + final String? errorMessage; + final String? loadMoreError; + final bool showInitialLoading; + final Future Function() onRefresh; + final Future Function() onLoadMore; + + @override + Widget build(BuildContext context) { + if (showInitialLoading && loading && !hasItems) { + return const Center(child: CircularProgressIndicator()); + } + return LayoutBuilder( + builder: (context, constraints) { + final gridWidth = + constraints.maxWidth - AppDimens.feedPageHorizontal * 2; + final itemWidth = (gridWidth - AppDimens.followingGridGap * 2) / 3; + final itemExtent = + itemWidth / AppDimens.cardCoverAspectRatio + + AppDimens.followingCardTextExtent; + return NotificationListener( + onNotification: _onScroll, + child: AppPullToRefresh( + onRefresh: onRefresh, + child: CustomScrollView( + key: ValueKey('following-scroll-$runtimeType'), + controller: scrollController, + physics: AppPullToRefresh.physics, + slivers: [ + if (itemCount == 0) + SliverFillRemaining( + hasScrollBody: false, + child: errorMessage == null + ? empty + : EmptyState( + icon: Icons.cloud_off_rounded, + title: errorMessage!, + actionLabel: AppLocalizations.of(context)!.retry, + onAction: () => unawaited(onRefresh()), + ), + ) + else ...[ + if (errorMessage != null) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppDimens.feedPageHorizontal, + AppDimens.spaceSm, + AppDimens.feedPageHorizontal, + 0, + ), + child: Text( + errorMessage!, + style: const TextStyle( + color: AppColors.textTertiary, + fontSize: AppDimens.followingSubtitleFont, + ), + ), + ), + ), + SliverPadding( + padding: const EdgeInsets.fromLTRB( + AppDimens.feedPageHorizontal, + 0, + AppDimens.feedPageHorizontal, + AppDimens.spaceSm, + ), + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: AppDimens.followingGridGap, + mainAxisSpacing: AppDimens.followingGridRowGap, + mainAxisExtent: itemExtent, + ), + delegate: SliverChildBuilderDelegate( + itemBuilder, + childCount: itemCount, + ), + ), + ), + SliverToBoxAdapter( + child: PagedListFooter( + loading: loadingMore, + hasMore: hasMore, + hasItems: hasItems, + errorMessage: loadMoreError, + onRetry: onLoadMore, + ), + ), + const SliverToBoxAdapter( + child: SizedBox(height: AppDimens.spaceXl), + ), + ], + ], + ), + ), + ); + }, + ); + } + + bool _onScroll(ScrollNotification notification) { + if (notification.depth != 0) return false; + if (notification.metrics.extentAfter > _loadMoreThreshold) return false; + if (notification is ScrollUpdateNotification || + notification is ScrollEndNotification || + notification is OverscrollNotification) { + unawaited(onLoadMore()); + } + return false; + } +} diff --git a/test/following/following_page_test.dart b/test/following/following_page_test.dart index ede23ea..c32429d 100644 --- a/test/following/following_page_test.dart +++ b/test/following/following_page_test.dart @@ -40,18 +40,19 @@ void main() { expect(find.byKey(const Key('following-page')), findsOneWidget); expect(find.byKey(const Key('following-tab-favorites')), findsOneWidget); expect(find.byKey(const Key('following-tab-history')), findsOneWidget); - expect(find.byKey(const Key('following-tab-reserve')), findsOneWidget); + expect(find.byKey(const Key('following-tab-reserve')), findsNothing); expect(find.byKey(const Key('following-favorites-grid')), findsOneWidget); + expect(find.byKey(const Key('following-add-card')), findsOneWidget); expect(find.byType(CustomScrollView), findsWidgets); - final tab = tester.widget( + final tab = tester.widget( find.descendant( of: find.byKey(const Key('following-tab-favorites')), - matching: find.text('Following'), + matching: find.byType(AnimatedDefaultTextStyle), ), ); - expect(tab.style?.fontSize, AppDimens.followingTabFont); - expect(tab.style?.fontWeight, FontWeight.w700); - expect(tab.style?.color, AppColors.textPrimary); + expect(tab.style.fontSize, AppDimens.followingTabFont); + expect(tab.style.fontWeight, FontWeight.w600); + expect(tab.style.color, AppColors.textPrimary); final grid = tester.widget( find.descendant( of: find.byKey(const Key('following-favorites-grid')), @@ -60,9 +61,10 @@ void main() { ); final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; - expect(delegate.crossAxisSpacing, AppDimens.homeGridGap); - expect(delegate.mainAxisSpacing, AppDimens.homeGridRowGap); - expect(delegate.childAspectRatio, AppDimens.followingGridAspectRatio); + expect(delegate.crossAxisCount, 3); + expect(delegate.crossAxisSpacing, AppDimens.followingGridGap); + expect(delegate.mainAxisSpacing, AppDimens.followingGridRowGap); + expect(delegate.mainAxisExtent, greaterThan(0)); final card = find.byKey(const Key('following-card-the-signal-demo')); final coverFinder = find.byKey( const Key('following-cover-the-signal-demo'), @@ -77,7 +79,7 @@ void main() { ); expect( coverClip.borderRadius, - BorderRadius.circular(AppDimens.cardCoverRadius), + BorderRadius.circular(AppDimens.homeCardRadius), ); final titleFinder = find.descendant( of: card, @@ -88,7 +90,10 @@ void main() { expect(title.style?.fontWeight, FontWeight.w500); expect(title.style?.height, AppDimens.cardTitleLineHeight); final views = tester.widget( - find.byKey(const Key('following-views-the-signal-demo')), + find.descendant( + of: find.byKey(const Key('following-views-the-signal-demo')), + matching: find.byType(Text), + ), ); expect(views.style?.fontSize, AppDimens.cardPlayCountFont); expect(views.style?.fontWeight, FontWeight.w400); @@ -107,15 +112,10 @@ void main() { ), findsOneWidget, ); - final tagsFinder = find.byKey( - const Key('following-tags-the-signal-demo'), + expect( + tester.getTopLeft(titleFinder).dx, + tester.getTopLeft(coverFinder).dx, ); - final tags = tester.widget(tagsFinder); - expect(tags.data, 'Suspense | Mystery'); - expect(tags.style?.fontSize, AppDimens.cardTagFont); - expect(tags.style?.color, AppColors.textTertiary); - expect(tester.getTopLeft(titleFinder).dx, tester.getTopLeft(coverFinder).dx); - expect(tester.getTopLeft(tagsFinder).dx, tester.getTopLeft(coverFinder).dx); await tester.tap(find.byKey(const Key('following-edit'))); await tester.pumpAndSettle(); @@ -148,9 +148,6 @@ void main() { await tester.tap(find.byKey(const Key('following-tab-history'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('following-history-empty')), findsOneWidget); - await tester.tap(find.byKey(const Key('following-tab-reserve'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('following-reserve-empty')), findsOneWidget); }); testWidgets('should_show_auth_gate_when_signed_out', (tester) async { @@ -172,7 +169,8 @@ void main() { await tester.tap(find.byKey(const Key('tab-follow'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('following-favorites-empty')), findsOneWidget); + expect(find.byKey(const Key('following-add-card')), findsOneWidget); + expect(find.byKey(const Key('following-favorites-empty')), findsNothing); await tester.tap(find.byKey(const Key('following-tab-history'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('following-history-empty')), findsOneWidget); @@ -209,7 +207,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Server title'), findsOneWidget); - expect(find.text('Watched to Episode 3'), findsOneWidget); + expect(find.text('Episode 3 / 8 total'), findsOneWidget); expect( find.byKey(const Key('following-progress-the-signal-demo')), findsOneWidget, @@ -217,6 +215,43 @@ void main() { expect(catalog.detailFetches, 0); }); + testWidgets('should_switch_following_pages_horizontally', (tester) async { + _signIn(); + await tester.pumpWidget(_app(catalog: FakeCatalogRepository())); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-follow'))); + await tester.pumpAndSettle(); + + await tester.drag( + find.byKey(const Key('following-tab-pages')), + const Offset(-500, 0), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('following-history-empty')), findsOneWidget); + final historyTab = tester.widget( + find.descendant( + of: find.byKey(const Key('following-tab-history')), + matching: find.byType(AnimatedDefaultTextStyle), + ), + ); + expect(historyTab.style.fontWeight, FontWeight.w600); + expect(historyTab.style.color, AppColors.textPrimary); + }); + + testWidgets('should_open_home_from_add_card', (tester) async { + _signIn(); + await tester.pumpWidget(_app(catalog: FakeCatalogRepository())); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-follow'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('following-add-card'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('home-grid')), findsOneWidget); + }); + testWidgets('should_reset_edit_state_when_switching_tabs', (tester) async { _signIn(); final catalog = FakeCatalogRepository(); From c14d89ffce20c770ce0b20002ca74e52c5945715 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 21:18:02 +0800 Subject: [PATCH 093/129] =?UTF-8?q?feat:=20=E8=B0=83=E6=95=B4=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E5=8E=86=E5=8F=B2=E4=B8=BA=E5=8D=95=E5=88=97=E5=88=97?= =?UTF-8?q?=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 6 + lib/core/theme/app_dimens.dart | 10 + lib/l10n/app_localizations_zh.dart | 2 +- lib/l10n/app_zh.arb | 2 +- .../following/following_history_card.dart | 228 ++++++++++++++++++ lib/pages/following/following_page.dart | 27 ++- lib/pages/following/following_paged_grid.dart | 21 +- test/following/following_page_test.dart | 35 +++ 8 files changed, 317 insertions(+), 14 deletions(-) create mode 100644 lib/pages/following/following_history_card.dart diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 8819183..1d91fd3 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1235,3 +1235,9 @@ $ flutter test - 追剧页移除预约 tab,改为追剧/播放历史双页横向切换,并保留各自滚动位置。 - 追剧与历史统一三列卡片,新增固定加号卡片,历史进度与编辑删除能力保留。 - 按最新口径调整列距 6、行距 12、封面圆角 6;`flutter analyze`:No issues found。 + +## 2026-10-22 播放历史单列列表 + +- 追剧保持三列网格,播放历史改为左封面右信息的单列列表,行距 16。 +- 历史卡片补充 96×128 封面、观看进度、已看百分比、继续观看和编辑删除。 +- 刷新、分页与各页独立滚动位置保持;`flutter analyze`:No issues found。 diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 9ceb1aa..d18a354 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -158,6 +158,16 @@ abstract final class AppDimens { static const double followingSubtitleFont = 12; static const double followingSubtitleGap = 4; static const double followingProgressHeight = 2; + static const double followingHistoryCoverWidth = 96; + static const double followingHistoryCoverHeight = 128; + static const double followingHistoryCoverRadius = 6; + static const double followingHistoryContentGap = 12; + static const double followingHistoryRowGap = 16; + static const double followingHistoryProgressHeight = 3; + static const double followingHistoryProgressRadius = 2; + static const double followingHistoryProgressGap = 8; + static const double followingHistoryLabelGap = 4; + static const double followingHistoryActionHeight = 28; static const Duration followingTabAnimationDuration = Duration( milliseconds: 180, ); diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 1feadad..1c49d79 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -284,7 +284,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String watchedPercent(int percent) { - return '已观看 $percent%'; + return '已看 $percent%'; } @override diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 8d4623e..bae9656 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -121,7 +121,7 @@ "count": {"type": "int"} } }, - "watchedPercent": "已观看 {percent}%", + "watchedPercent": "已看 {percent}%", "@watchedPercent": { "placeholders": { "percent": { diff --git a/lib/pages/following/following_history_card.dart b/lib/pages/following/following_history_card.dart new file mode 100644 index 0000000..e38f974 --- /dev/null +++ b/lib/pages/following/following_history_card.dart @@ -0,0 +1,228 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_dimens.dart'; +import '../../core/widgets/cover_play_count.dart'; +import '../../core/widgets/remote_image.dart'; +import '../../l10n/app_localizations.dart'; + +class FollowingHistoryCard extends StatelessWidget { + const FollowingHistoryCard({ + super.key, + required this.dramaId, + required this.title, + required this.posterUrl, + required this.viewCount, + required this.subtitle, + required this.onTap, + required this.onContinue, + this.progress, + this.progressLabel, + this.editing = false, + this.onRemove, + }); + + final String dramaId; + final String title; + final String posterUrl; + final int viewCount; + final String subtitle; + final VoidCallback onTap; + final VoidCallback onContinue; + final double? progress; + final String? progressLabel; + final bool editing; + final VoidCallback? onRemove; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return InkWell( + key: Key('following-history-card-$dramaId'), + borderRadius: BorderRadius.circular( + AppDimens.followingHistoryCoverRadius, + ), + onTap: onTap, + child: SizedBox( + height: AppDimens.followingHistoryCoverHeight, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _cover(), + const SizedBox(width: AppDimens.followingHistoryContentGap), + Expanded(child: _details(l10n)), + ], + ), + ), + ); + } + + Widget _cover() { + return SizedBox( + key: Key('following-history-cover-$dramaId'), + width: AppDimens.followingHistoryCoverWidth, + height: AppDimens.followingHistoryCoverHeight, + child: Stack( + clipBehavior: Clip.hardEdge, + children: [ + Positioned.fill( + child: ClipRRect( + borderRadius: BorderRadius.circular( + AppDimens.followingHistoryCoverRadius, + ), + child: RemoteImage( + posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, + fallbackColor: AppColors.contentSurface, + ), + ), + ), + const Positioned( + left: 0, + right: 0, + bottom: 0, + child: CoverBottomScrim(), + ), + Positioned( + right: AppDimens.cardPlayCountRight, + bottom: AppDimens.cardPlayCountBottom, + child: CoverPlayCount( + key: Key('following-history-views-$dramaId'), + count: viewCount, + ), + ), + ], + ), + ); + } + + Widget _details(AppLocalizations l10n) { + final hasProgress = progress != null; + return Stack( + children: [ + Align( + alignment: Alignment.center, + child: Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.only(right: editing ? 28 : 0), + child: Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.left, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.cardTitleFont, + fontWeight: FontWeight.w500, + height: AppDimens.cardTitleLineHeight, + ), + ), + ), + const SizedBox(height: AppDimens.followingSubtitleGap), + Row( + children: [ + Expanded( + child: Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textTertiary, + fontSize: AppDimens.followingSubtitleFont, + ), + ), + ), + if (!editing && hasProgress) ...[ + const SizedBox(width: AppDimens.spaceSm), + _continueButton(l10n), + ], + ], + ), + if (hasProgress) ...[ + const SizedBox(height: AppDimens.followingHistoryProgressGap), + ClipRRect( + borderRadius: BorderRadius.circular( + AppDimens.followingHistoryProgressRadius, + ), + child: LinearProgressIndicator( + key: Key('following-progress-$dramaId'), + value: progress!.clamp(0.0, 1.0).toDouble(), + minHeight: AppDimens.followingHistoryProgressHeight, + backgroundColor: AppColors.inactiveTrack, + valueColor: const AlwaysStoppedAnimation( + AppColors.primary, + ), + ), + ), + if (progressLabel != null) ...[ + const SizedBox(height: AppDimens.followingHistoryLabelGap), + Text( + progressLabel!, + key: Key('following-history-percent-$dramaId'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textTertiary, + fontSize: AppDimens.followingSubtitleFont, + ), + ), + ], + ], + ], + ), + ), + ), + if (editing) + Positioned( + top: 0, + right: 0, + child: GestureDetector( + key: Key('following-history-remove-$dramaId'), + onTap: onRemove, + child: const CircleAvatar( + radius: 12, + backgroundColor: AppColors.contentChip, + child: Icon( + Icons.remove_rounded, + color: AppColors.textPrimary, + size: 17, + ), + ), + ), + ), + ], + ); + } + + Widget _continueButton(AppLocalizations l10n) { + return SizedBox( + height: AppDimens.followingHistoryActionHeight, + child: OutlinedButton( + key: Key('following-history-continue-$dramaId'), + onPressed: onContinue, + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.textPrimary, + side: const BorderSide(color: AppColors.textPrimary), + padding: const EdgeInsets.symmetric(horizontal: 10), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + shape: const StadiumBorder(), + textStyle: const TextStyle( + fontSize: AppDimens.followingSubtitleFont, + fontWeight: FontWeight.w400, + ), + ), + child: Text(l10n.continueWatching), + ), + ); + } +} diff --git a/lib/pages/following/following_page.dart b/lib/pages/following/following_page.dart index e148972..e1d99ec 100644 --- a/lib/pages/following/following_page.dart +++ b/lib/pages/following/following_page.dart @@ -19,6 +19,7 @@ import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; import '../player/open_player.dart'; import 'following_grid_card.dart'; +import 'following_history_card.dart'; import 'following_paged_grid.dart'; enum FollowingTab { favorites, history } @@ -94,7 +95,7 @@ class _FollowingPageState extends State { onGoHome: widget.onGoHome, onListEmptied: _exitEditingIfEmpty, ), - _HistoryGrid( + _HistoryList( controller: _history, editing: _editing, scrollController: _historyScroll, @@ -301,8 +302,8 @@ class _FavoritesGrid extends StatelessWidget { } } -class _HistoryGrid extends StatelessWidget { - const _HistoryGrid({ +class _HistoryList extends StatelessWidget { + const _HistoryList({ required this.controller, required this.editing, required this.scrollController, @@ -327,6 +328,7 @@ class _HistoryGrid extends StatelessWidget { loadingMore: controller.loadingMore, hasMore: controller.hasMore, hasItems: controller.records.isNotEmpty, + singleColumn: true, errorMessage: controller.error == null ? null : errorText(l10n, controller.error, l10n.progressError), @@ -345,15 +347,28 @@ class _HistoryGrid extends StatelessWidget { itemBuilder: (context, index) { final record = controller.records[index]; final progress = record.progress.fraction; - return FollowingGridCard( + final hasProgress = record.progress.duration > Duration.zero; + final watchedPercent = (progress * 100) + .round() + .clamp(0, 100) + .toInt(); + void openRecord() { + unawaited(_openHistory(context, controller, record)); + } + + return FollowingHistoryCard( dramaId: record.dramaId, title: _historyTitle(l10n, record), posterUrl: record.displayCover, viewCount: record.drama?.viewCount ?? record.progress.playCount, - progress: progress > 0 ? progress : null, + progress: hasProgress ? progress : null, + progressLabel: hasProgress + ? l10n.watchedPercent(watchedPercent) + : null, editing: editing, subtitle: _historySubtitle(l10n, record), - onTap: () => unawaited(_openHistory(context, controller, record)), + onTap: openRecord, + onContinue: openRecord, onRemove: () async { if (await _confirmRemove(context) && await controller.remove(record.dramaId) && diff --git a/lib/pages/following/following_paged_grid.dart b/lib/pages/following/following_paged_grid.dart index 269e9e6..5ea8167 100644 --- a/lib/pages/following/following_paged_grid.dart +++ b/lib/pages/following/following_paged_grid.dart @@ -25,6 +25,7 @@ class FollowingPagedGrid extends StatelessWidget { required this.onRefresh, required this.onLoadMore, this.showInitialLoading = true, + this.singleColumn = false, this.errorMessage, this.loadMoreError, }); @@ -42,6 +43,7 @@ class FollowingPagedGrid extends StatelessWidget { final String? errorMessage; final String? loadMoreError; final bool showInitialLoading; + final bool singleColumn; final Future Function() onRefresh; final Future Function() onLoadMore; @@ -58,6 +60,18 @@ class FollowingPagedGrid extends StatelessWidget { final itemExtent = itemWidth / AppDimens.cardCoverAspectRatio + AppDimens.followingCardTextExtent; + final gridDelegate = singleColumn + ? const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 1, + mainAxisSpacing: AppDimens.followingHistoryRowGap, + mainAxisExtent: AppDimens.followingHistoryCoverHeight, + ) + : SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: AppDimens.followingGridGap, + mainAxisSpacing: AppDimens.followingGridRowGap, + mainAxisExtent: itemExtent, + ); return NotificationListener( onNotification: _onScroll, child: AppPullToRefresh( @@ -106,12 +120,7 @@ class FollowingPagedGrid extends StatelessWidget { AppDimens.spaceSm, ), sliver: SliverGrid( - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: AppDimens.followingGridGap, - mainAxisSpacing: AppDimens.followingGridRowGap, - mainAxisExtent: itemExtent, - ), + gridDelegate: gridDelegate, delegate: SliverChildBuilderDelegate( itemBuilder, childCount: itemCount, diff --git a/test/following/following_page_test.dart b/test/following/following_page_test.dart index c32429d..d534ea4 100644 --- a/test/following/following_page_test.dart +++ b/test/following/following_page_test.dart @@ -208,11 +208,46 @@ void main() { expect(find.text('Server title'), findsOneWidget); expect(find.text('Episode 3 / 8 total'), findsOneWidget); + expect(find.text('Watched 25%'), findsOneWidget); + expect( + find.byKey(const Key('following-history-card-the-signal-demo')), + findsOneWidget, + ); + expect( + find.byKey(const Key('following-history-continue-the-signal-demo')), + findsOneWidget, + ); expect( find.byKey(const Key('following-progress-the-signal-demo')), findsOneWidget, ); + final cover = tester.widget( + find.byKey(const Key('following-history-cover-the-signal-demo')), + ); + expect(cover.width, AppDimens.followingHistoryCoverWidth); + expect(cover.height, AppDimens.followingHistoryCoverHeight); + final historyGrid = tester.widget( + find.descendant( + of: find.byKey(const Key('following-history-grid')), + matching: find.byType(SliverGrid), + ), + ); + final historyDelegate = + historyGrid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; + expect(historyDelegate.crossAxisCount, 1); + expect(historyDelegate.mainAxisSpacing, AppDimens.followingHistoryRowGap); expect(catalog.detailFetches, 0); + + await tester.tap(find.byKey(const Key('following-edit'))); + await tester.pumpAndSettle(); + expect( + find.byKey(const Key('following-history-remove-the-signal-demo')), + findsOneWidget, + ); + expect( + find.byKey(const Key('following-history-continue-the-signal-demo')), + findsNothing, + ); }); testWidgets('should_switch_following_pages_horizontally', (tester) async { From 091ea358bd44e8de1fc759e6dc0468b504def1e1 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 21:20:06 +0800 Subject: [PATCH 094/129] =?UTF-8?q?fix:=20=E8=B0=83=E6=95=B4=E8=BF=BD?= =?UTF-8?q?=E5=89=A7=E5=8A=A0=E5=8F=B7=E5=8D=A1=E7=89=87=E9=A1=BA=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 1 + lib/pages/following/following_page.dart | 4 ++-- test/following/following_page_test.dart | 6 ++++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 1d91fd3..aaa039f 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1240,4 +1240,5 @@ $ flutter test - 追剧保持三列网格,播放历史改为左封面右信息的单列列表,行距 16。 - 历史卡片补充 96×128 封面、观看进度、已看百分比、继续观看和编辑删除。 +- 追剧加号卡片移到全部追剧卡片之后,空列表时仍在左上角。 - 刷新、分页与各页独立滚动位置保持;`flutter analyze`:No issues found。 diff --git a/lib/pages/following/following_page.dart b/lib/pages/following/following_page.dart index e1d99ec..250db53 100644 --- a/lib/pages/following/following_page.dart +++ b/lib/pages/following/following_page.dart @@ -269,10 +269,10 @@ class _FavoritesGrid extends StatelessWidget { itemCount: controller.cards.length + 1, empty: const SizedBox.shrink(), itemBuilder: (context, index) { - if (index == 0) { + if (index == controller.cards.length) { return FollowingAddCard(onTap: onGoHome); } - final drama = controller.cards[index - 1]; + final drama = controller.cards[index]; final episode = drama.episodes.isEmpty ? 1 : drama.episodes.first.number; diff --git a/test/following/following_page_test.dart b/test/following/following_page_test.dart index d534ea4..ff0f371 100644 --- a/test/following/following_page_test.dart +++ b/test/following/following_page_test.dart @@ -116,6 +116,12 @@ void main() { tester.getTopLeft(titleFinder).dx, tester.getTopLeft(coverFinder).dx, ); + expect( + tester.getTopLeft(card).dx, + lessThan( + tester.getTopLeft(find.byKey(const Key('following-add-card'))).dx, + ), + ); await tester.tap(find.byKey(const Key('following-edit'))); await tester.pumpAndSettle(); From 4d304cb549b852192c10434d010e949062735f2c Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 21:21:52 +0800 Subject: [PATCH 095/129] =?UTF-8?q?style:=20=E8=B0=83=E6=95=B4=E8=BF=BD?= =?UTF-8?q?=E5=89=A7=E5=8A=A0=E5=8F=B7=E9=A2=9C=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 1 + lib/core/theme/app_dimens.dart | 1 + lib/pages/following/following_grid_card.dart | 26 +++++++++++++++++--- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index aaa039f..33be1cc 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1241,4 +1241,5 @@ $ flutter test - 追剧保持三列网格,播放历史改为左封面右信息的单列列表,行距 16。 - 历史卡片补充 96×128 封面、观看进度、已看百分比、继续观看和编辑删除。 - 追剧加号卡片移到全部追剧卡片之后,空列表时仍在左上角。 +- 加号改为 `#8E8E93`、28 尺寸、1.5 描边,卡片底色与圆角不变。 - 刷新、分页与各页独立滚动位置保持;`flutter analyze`:No issues found。 diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index d18a354..807e451 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -152,6 +152,7 @@ abstract final class AppDimens { static const double followingTabGap = 24; static const double followingEditIconSize = 24; static const double followingAddIconSize = 28; + static const double followingAddIconStroke = 1.5; static const double followingGridGap = 6; static const double followingGridRowGap = 12; static const double followingCardTextExtent = 66; diff --git a/lib/pages/following/following_grid_card.dart b/lib/pages/following/following_grid_card.dart index a3506e0..e20f320 100644 --- a/lib/pages/following/following_grid_card.dart +++ b/lib/pages/following/following_grid_card.dart @@ -163,11 +163,10 @@ class FollowingAddCard extends StatelessWidget { borderRadius: BorderRadius.circular(AppDimens.homeCardRadius), ), child: const Center( - child: Icon( - Icons.add, + child: CustomPaint( key: Key('following-add-icon'), - size: AppDimens.followingAddIconSize, - color: AppColors.textPrimary, + size: Size.square(AppDimens.followingAddIconSize), + painter: _FollowingPlusPainter(), ), ), ), @@ -177,3 +176,22 @@ class FollowingAddCard extends StatelessWidget { ); } } + +class _FollowingPlusPainter extends CustomPainter { + const _FollowingPlusPainter(); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = AppColors.textSecondary + ..strokeWidth = AppDimens.followingAddIconStroke + ..strokeCap = StrokeCap.round; + final center = size.center(Offset.zero); + canvas + ..drawLine(Offset(center.dx, 0), Offset(center.dx, size.height), paint) + ..drawLine(Offset(0, center.dy), Offset(size.width, center.dy), paint); + } + + @override + bool shouldRepaint(covariant _FollowingPlusPainter oldDelegate) => false; +} From 3f3dc9263cabae94b6ae661525a36b346b646fe3 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 21:25:55 +0800 Subject: [PATCH 096/129] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=E8=BF=BD?= =?UTF-8?q?=E5=89=A7=E9=A1=B5=E5=88=87=E6=8D=A2=E4=B8=8E=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 6 + lib/core/theme/app_dimens.dart | 3 +- .../following/following_keep_alive_tab.dart | 24 + lib/pages/following/following_page.dart | 418 ++++++++++-------- test/following/following_page_test.dart | 3 + 5 files changed, 267 insertions(+), 187 deletions(-) create mode 100644 lib/pages/following/following_keep_alive_tab.dart diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 33be1cc..8aab73b 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1243,3 +1243,9 @@ $ flutter test - 追剧加号卡片移到全部追剧卡片之后,空列表时仍在左上角。 - 加号改为 `#8E8E93`、28 尺寸、1.5 描边,卡片底色与圆角不变。 - 刷新、分页与各页独立滚动位置保持;`flutter analyze`:No issues found。 + +## 2026-10-22 追剧页切换与编辑按钮修正 + +- 追剧与历史页增加 keep-alive,tab 与编辑态改用局部 ValueNotifier 更新,切换不再重建整个页面。 +- 编辑图标调整为 20、`#B0B0B0`,热区 40×40,保持右侧 14 边距和垂直居中。 +- 列表刷新期间保留已有数据与各页滚动位置;`flutter analyze`:No issues found。 diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 807e451..5ba9597 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -150,7 +150,8 @@ abstract final class AppDimens { static const double followingHeaderHeight = 52; static const double followingTabFont = 18; static const double followingTabGap = 24; - static const double followingEditIconSize = 24; + static const double followingEditIconSize = 20; + static const double followingEditHitSize = 40; static const double followingAddIconSize = 28; static const double followingAddIconStroke = 1.5; static const double followingGridGap = 6; diff --git a/lib/pages/following/following_keep_alive_tab.dart b/lib/pages/following/following_keep_alive_tab.dart new file mode 100644 index 0000000..d0c9c14 --- /dev/null +++ b/lib/pages/following/following_keep_alive_tab.dart @@ -0,0 +1,24 @@ +// Author: fruit.com.cn + +import 'package:flutter/widgets.dart'; + +class FollowingKeepAliveTab extends StatefulWidget { + const FollowingKeepAliveTab({super.key, required this.child}); + + final Widget child; + + @override + State createState() => _FollowingKeepAliveTabState(); +} + +class _FollowingKeepAliveTabState extends State + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + return widget.child; + } +} diff --git a/lib/pages/following/following_page.dart b/lib/pages/following/following_page.dart index 250db53..4780f36 100644 --- a/lib/pages/following/following_page.dart +++ b/lib/pages/following/following_page.dart @@ -2,6 +2,7 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -20,6 +21,7 @@ import '../../l10n/app_localizations.dart'; import '../player/open_player.dart'; import 'following_grid_card.dart'; import 'following_history_card.dart'; +import 'following_keep_alive_tab.dart'; import 'following_paged_grid.dart'; enum FollowingTab { favorites, history } @@ -42,9 +44,11 @@ class _FollowingPageState extends State { final PageController _pageController = PageController(); final ScrollController _favoriteScroll = ScrollController(); final ScrollController _historyScroll = ScrollController(); + final ValueNotifier _tab = ValueNotifier( + FollowingTab.favorites, + ); + final ValueNotifier _editing = ValueNotifier(false); late final WatchRecordsController _history; - FollowingTab _tab = FollowingTab.favorites; - bool _editing = false; @override void initState() { @@ -64,6 +68,8 @@ class _FollowingPageState extends State { _pageController.dispose(); _favoriteScroll.dispose(); _historyScroll.dispose(); + _tab.dispose(); + _editing.dispose(); _history.dispose(); super.dispose(); } @@ -88,18 +94,22 @@ class _FollowingPageState extends State { controller: _pageController, onPageChanged: _onPageChanged, children: [ - _FavoritesGrid( - controller: widget.favoriteController, - editing: _editing, - scrollController: _favoriteScroll, - onGoHome: widget.onGoHome, - onListEmptied: _exitEditingIfEmpty, + FollowingKeepAliveTab( + child: _FavoritesGrid( + controller: widget.favoriteController, + editing: _editing, + scrollController: _favoriteScroll, + onGoHome: widget.onGoHome, + onListEmptied: _exitEditingIfEmpty, + ), ), - _HistoryList( - controller: _history, - editing: _editing, - scrollController: _historyScroll, - onListEmptied: _exitEditingIfEmpty, + FollowingKeepAliveTab( + child: _HistoryList( + controller: _history, + editing: _editing, + scrollController: _historyScroll, + onListEmptied: _exitEditingIfEmpty, + ), ), ], ), @@ -118,63 +128,85 @@ class _FollowingPageState extends State { padding: const EdgeInsets.symmetric( horizontal: AppDimens.feedPageHorizontal, ), - child: Row( - children: [ - _tabButton(FollowingTab.favorites, l10n.followingTab, 'favorites'), - const SizedBox(width: AppDimens.followingTabGap), - _tabButton(FollowingTab.history, l10n.watchHistory, 'history'), - const Spacer(), - if (_editing) - SizedBox( - width: 44, - height: 44, - child: InkWell( - key: const Key('following-edit'), - onTap: _toggleEditing, - child: Align( - alignment: Alignment.centerRight, - child: Text( - l10n.done, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: AppDimens.followingTabFont, - fontWeight: FontWeight.w600, - ), + child: ValueListenableBuilder( + valueListenable: _tab, + builder: (context, selectedTab, _) { + return ValueListenableBuilder( + valueListenable: _editing, + builder: (context, editing, _) { + return Row( + children: [ + _tabButton( + FollowingTab.favorites, + l10n.followingTab, + 'favorites', + selectedTab == FollowingTab.favorites, ), - ), - ), - ) - else - Semantics( - button: true, - label: l10n.edit, - child: SizedBox( - width: 44, - height: 44, - child: InkWell( - key: const Key('following-edit'), - onTap: _toggleEditing, - child: const Align( - alignment: Alignment.centerRight, - child: SizedBox.square( - dimension: AppDimens.followingEditIconSize, - child: Icon( - Icons.edit_outlined, - color: AppColors.textPrimary, + const SizedBox(width: AppDimens.followingTabGap), + _tabButton( + FollowingTab.history, + l10n.watchHistory, + 'history', + selectedTab == FollowingTab.history, + ), + const Spacer(), + if (editing) + SizedBox( + width: AppDimens.followingEditHitSize, + height: AppDimens.followingEditHitSize, + child: InkWell( + key: const Key('following-edit'), + onTap: _toggleEditing, + child: Align( + alignment: Alignment.centerRight, + child: Text( + l10n.done, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.followingTabFont, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ) + else + Semantics( + button: true, + label: l10n.edit, + child: SizedBox( + width: AppDimens.followingEditHitSize, + height: AppDimens.followingEditHitSize, + child: InkWell( + key: const Key('following-edit'), + onTap: _toggleEditing, + child: const Align( + alignment: Alignment.centerRight, + child: Icon( + Icons.edit_outlined, + size: AppDimens.followingEditIconSize, + color: AppColors.textTabInactive, + ), + ), + ), ), ), - ), - ), - ), - ), - ], + ], + ); + }, + ); + }, ), ), ); } - Widget _tabButton(FollowingTab tab, String label, String keyName) { - final selected = _tab == tab; + Widget _tabButton( + FollowingTab tab, + String label, + String keyName, + bool selected, + ) { return InkWell( key: Key('following-tab-$keyName'), onTap: () => _selectTab(tab), @@ -198,35 +230,30 @@ class _FollowingPageState extends State { } void _selectTab(FollowingTab tab) { - if (_tab != tab || _editing) { - setState(() { - _tab = tab; - _editing = false; - }); + _editing.value = false; + _tab.value = tab; + if (_pageController.hasClients) { + unawaited( + _pageController.animateToPage( + tab.index, + duration: AppDimens.followingPageAnimationDuration, + curve: Curves.easeOutCubic, + ), + ); } - _pageController.animateToPage( - tab.index, - duration: AppDimens.followingPageAnimationDuration, - curve: Curves.easeOutCubic, - ); } void _onPageChanged(int index) { final tab = FollowingTab.values[index]; - if (_tab == tab && !_editing) return; - setState(() { - _tab = tab; - _editing = false; - }); + _editing.value = false; + _tab.value = tab; } void _toggleEditing() { - setState(() => _editing = !_editing); + _editing.value = !_editing.value; } - void _exitEditingIfEmpty() { - if (_editing && mounted) setState(() => _editing = false); - } + void _exitEditingIfEmpty() => _editing.value = false; } class _FavoritesGrid extends StatelessWidget { @@ -239,60 +266,69 @@ class _FavoritesGrid extends StatelessWidget { }); final FavoriteController controller; - final bool editing; + final ValueListenable editing; final ScrollController scrollController; final VoidCallback onGoHome; final VoidCallback onListEmptied; @override Widget build(BuildContext context) { - return ListenableBuilder( - listenable: controller, - builder: (context, _) { - final l10n = AppLocalizations.of(context)!; - return FollowingPagedGrid( - key: const Key('following-favorites-grid'), - scrollController: scrollController, - loading: controller.loading, - loadingMore: controller.loadingMore, - hasMore: controller.hasMore, - hasItems: controller.cards.isNotEmpty, - showInitialLoading: false, - errorMessage: controller.error == null - ? null - : errorText(l10n, controller.error, l10n.favoritesError), - loadMoreError: controller.loadMoreError == null - ? null - : errorText(l10n, controller.loadMoreError, l10n.favoritesError), - onRefresh: controller.load, - onLoadMore: controller.loadMore, - itemCount: controller.cards.length + 1, - empty: const SizedBox.shrink(), - itemBuilder: (context, index) { - if (index == controller.cards.length) { - return FollowingAddCard(onTap: onGoHome); - } - final drama = controller.cards[index]; - final episode = drama.episodes.isEmpty - ? 1 - : drama.episodes.first.number; - return FollowingGridCard( - dramaId: drama.id, - title: drama.title, - posterUrl: drama.posterUrl, - viewCount: drama.viewCount, - editing: editing, - subtitle: l10n.favoriteEpisodeProgress( - episode, - drama.episodeCount, - ), - onTap: () => unawaited(playDramaFromCatalog(context, drama)), - onRemove: () async { - if (await _confirmRemove(context) && - await controller.remove(drama.id) && - controller.cards.isEmpty) { - onListEmptied(); + return ValueListenableBuilder( + valueListenable: editing, + builder: (context, isEditing, _) { + return ListenableBuilder( + listenable: controller, + builder: (context, _) { + final l10n = AppLocalizations.of(context)!; + return FollowingPagedGrid( + key: const Key('following-favorites-grid'), + scrollController: scrollController, + loading: controller.loading, + loadingMore: controller.loadingMore, + hasMore: controller.hasMore, + hasItems: controller.cards.isNotEmpty, + showInitialLoading: false, + errorMessage: controller.error == null + ? null + : errorText(l10n, controller.error, l10n.favoritesError), + loadMoreError: controller.loadMoreError == null + ? null + : errorText( + l10n, + controller.loadMoreError, + l10n.favoritesError, + ), + onRefresh: controller.load, + onLoadMore: controller.loadMore, + itemCount: controller.cards.length + 1, + empty: const SizedBox.shrink(), + itemBuilder: (context, index) { + if (index == controller.cards.length) { + return FollowingAddCard(onTap: onGoHome); } + final drama = controller.cards[index]; + final episode = drama.episodes.isEmpty + ? 1 + : drama.episodes.first.number; + return FollowingGridCard( + dramaId: drama.id, + title: drama.title, + posterUrl: drama.posterUrl, + viewCount: drama.viewCount, + editing: isEditing, + subtitle: l10n.favoriteEpisodeProgress( + episode, + drama.episodeCount, + ), + onTap: () => unawaited(playDramaFromCatalog(context, drama)), + onRemove: () async { + if (await _confirmRemove(context) && + await controller.remove(drama.id) && + controller.cards.isEmpty) { + onListEmptied(); + } + }, + ); }, ); }, @@ -311,70 +347,80 @@ class _HistoryList extends StatelessWidget { }); final WatchRecordsController controller; - final bool editing; + final ValueListenable editing; final ScrollController scrollController; final VoidCallback onListEmptied; @override Widget build(BuildContext context) { - return ListenableBuilder( - listenable: controller, - builder: (context, _) { - final l10n = AppLocalizations.of(context)!; - return FollowingPagedGrid( - key: const Key('following-history-grid'), - scrollController: scrollController, - loading: controller.loading, - loadingMore: controller.loadingMore, - hasMore: controller.hasMore, - hasItems: controller.records.isNotEmpty, - singleColumn: true, - errorMessage: controller.error == null - ? null - : errorText(l10n, controller.error, l10n.progressError), - loadMoreError: controller.loadMoreError == null - ? null - : errorText(l10n, controller.loadMoreError, l10n.progressError), - onRefresh: controller.load, - onLoadMore: controller.loadMore, - itemCount: controller.records.length, - empty: EmptyState( - key: const Key('following-history-empty'), - icon: Icons.history_rounded, - title: l10n.noWatchRecords, - subtitle: l10n.emptyHint, - ), - itemBuilder: (context, index) { - final record = controller.records[index]; - final progress = record.progress.fraction; - final hasProgress = record.progress.duration > Duration.zero; - final watchedPercent = (progress * 100) - .round() - .clamp(0, 100) - .toInt(); - void openRecord() { - unawaited(_openHistory(context, controller, record)); - } - - return FollowingHistoryCard( - dramaId: record.dramaId, - title: _historyTitle(l10n, record), - posterUrl: record.displayCover, - viewCount: record.drama?.viewCount ?? record.progress.playCount, - progress: hasProgress ? progress : null, - progressLabel: hasProgress - ? l10n.watchedPercent(watchedPercent) - : null, - editing: editing, - subtitle: _historySubtitle(l10n, record), - onTap: openRecord, - onContinue: openRecord, - onRemove: () async { - if (await _confirmRemove(context) && - await controller.remove(record.dramaId) && - controller.records.isEmpty) { - onListEmptied(); + return ValueListenableBuilder( + valueListenable: editing, + builder: (context, isEditing, _) { + return ListenableBuilder( + listenable: controller, + builder: (context, _) { + final l10n = AppLocalizations.of(context)!; + return FollowingPagedGrid( + key: const Key('following-history-grid'), + scrollController: scrollController, + loading: controller.loading, + loadingMore: controller.loadingMore, + hasMore: controller.hasMore, + hasItems: controller.records.isNotEmpty, + singleColumn: true, + errorMessage: controller.error == null + ? null + : errorText(l10n, controller.error, l10n.progressError), + loadMoreError: controller.loadMoreError == null + ? null + : errorText( + l10n, + controller.loadMoreError, + l10n.progressError, + ), + onRefresh: controller.load, + onLoadMore: controller.loadMore, + itemCount: controller.records.length, + empty: EmptyState( + key: const Key('following-history-empty'), + icon: Icons.history_rounded, + title: l10n.noWatchRecords, + subtitle: l10n.emptyHint, + ), + itemBuilder: (context, index) { + final record = controller.records[index]; + final progress = record.progress.fraction; + final hasProgress = record.progress.duration > Duration.zero; + final watchedPercent = (progress * 100) + .round() + .clamp(0, 100) + .toInt(); + void openRecord() { + unawaited(_openHistory(context, controller, record)); } + + return FollowingHistoryCard( + dramaId: record.dramaId, + title: _historyTitle(l10n, record), + posterUrl: record.displayCover, + viewCount: + record.drama?.viewCount ?? record.progress.playCount, + progress: hasProgress ? progress : null, + progressLabel: hasProgress + ? l10n.watchedPercent(watchedPercent) + : null, + editing: isEditing, + subtitle: _historySubtitle(l10n, record), + onTap: openRecord, + onContinue: openRecord, + onRemove: () async { + if (await _confirmRemove(context) && + await controller.remove(record.dramaId) && + controller.records.isEmpty) { + onListEmptied(); + } + }, + ); }, ); }, diff --git a/test/following/following_page_test.dart b/test/following/following_page_test.dart index ff0f371..0132852 100644 --- a/test/following/following_page_test.dart +++ b/test/following/following_page_test.dart @@ -53,6 +53,9 @@ void main() { expect(tab.style.fontSize, AppDimens.followingTabFont); expect(tab.style.fontWeight, FontWeight.w600); expect(tab.style.color, AppColors.textPrimary); + final editIcon = tester.widget(find.byIcon(Icons.edit_outlined)); + expect(editIcon.size, AppDimens.followingEditIconSize); + expect(editIcon.color, AppColors.textTabInactive); final grid = tester.widget( find.descendant( of: find.byKey(const Key('following-favorites-grid')), From 95fb2ff882e73680af052f38614221f3a86ddb62 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 21:28:36 +0800 Subject: [PATCH 097/129] =?UTF-8?q?style:=20=E8=B0=83=E6=95=B4=E7=BB=A7?= =?UTF-8?q?=E7=BB=AD=E8=A7=82=E7=9C=8B=E6=8C=89=E9=92=AE=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 5 ++++ .../following/following_history_card.dart | 26 ++++++++++++------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 8aab73b..e2a00ef 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1249,3 +1249,8 @@ $ flutter test - 追剧与历史页增加 keep-alive,tab 与编辑态改用局部 ValueNotifier 更新,切换不再重建整个页面。 - 编辑图标调整为 20、`#B0B0B0`,热区 40×40,保持右侧 14 边距和垂直居中。 - 列表刷新期间保留已有数据与各页滚动位置;`flutter analyze`:No issues found。 + +## 2026-10-22 播放历史继续观看按钮微调 + +- 继续观看改为无边框、无底色的纯文字按钮,默认 `#B0B0B0`、12/w500,按压态使用品牌色。 +- 保持 28 高点击热区和右侧零间距;未执行测试或 analyze。 diff --git a/lib/pages/following/following_history_card.dart b/lib/pages/following/following_history_card.dart index e38f974..e2a2af3 100644 --- a/lib/pages/following/following_history_card.dart +++ b/lib/pages/following/following_history_card.dart @@ -205,20 +205,26 @@ class FollowingHistoryCard extends StatelessWidget { Widget _continueButton(AppLocalizations l10n) { return SizedBox( height: AppDimens.followingHistoryActionHeight, - child: OutlinedButton( + child: TextButton( key: Key('following-history-continue-$dramaId'), onPressed: onContinue, - style: OutlinedButton.styleFrom( - foregroundColor: AppColors.textPrimary, - side: const BorderSide(color: AppColors.textPrimary), - padding: const EdgeInsets.symmetric(horizontal: 10), - minimumSize: Size.zero, + style: ButtonStyle( + foregroundColor: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.pressed) + ? AppColors.primary + : AppColors.textTabInactive, + ), + backgroundColor: const WidgetStatePropertyAll(AppColors.transparent), + overlayColor: const WidgetStatePropertyAll(AppColors.transparent), + padding: const WidgetStatePropertyAll(EdgeInsets.zero), + minimumSize: const WidgetStatePropertyAll(Size.zero), tapTargetSize: MaterialTapTargetSize.shrinkWrap, visualDensity: VisualDensity.compact, - shape: const StadiumBorder(), - textStyle: const TextStyle( - fontSize: AppDimens.followingSubtitleFont, - fontWeight: FontWeight.w400, + textStyle: const WidgetStatePropertyAll( + TextStyle( + fontSize: AppDimens.followingSubtitleFont, + fontWeight: FontWeight.w500, + ), ), ), child: Text(l10n.continueWatching), From 9785a1baf3ad6951552498374afbf7514560b8ed Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 21:35:14 +0800 Subject: [PATCH 098/129] =?UTF-8?q?feat:=20=E6=94=B9=E9=80=A0=E6=88=91?= =?UTF-8?q?=E7=9A=84=E9=A1=B5=E6=B8=90=E5=8F=98=E4=B8=8E=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=E7=BD=91=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 6 + lib/core/theme/app_colors.dart | 7 + lib/core/theme/app_dimens.dart | 9 +- lib/pages/mine/mine_view.dart | 78 +++++--- lib/pages/mine/widgets/mine_menu.dart | 251 ++++++++++++------------ test/mine/mine_preview_layout_test.dart | 72 +++++-- 6 files changed, 244 insertions(+), 179 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index e2a00ef..1b2c8d8 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1254,3 +1254,9 @@ $ flutter test - 继续观看改为无边框、无底色的纯文字按钮,默认 `#B0B0B0`、12/w500,按压态使用品牌色。 - 保持 28 高点击热区和右侧零间距;未执行测试或 analyze。 + +## 2026-10-22 我的页渐变与图标网格 + +- 页面背景改为暖黑三段纵向渐变,用户区与 VIP 卡布局保持不变。 +- 行式入口替换为 4 列图标网格,7 个原入口按顺序保留,第 8 格为空占位。 +- 网格使用 14 边距、半透明深灰底、圆角 12 和仅内部 1px 分隔线;`flutter analyze`:No issues found。 diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 4536f27..db57bdd 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -84,6 +84,13 @@ abstract final class AppColors { /// 「我的」页卡片与描边。 static const mineCard = Color(0xFF16161A); static const mineOutline = Color(0xFF4A4A4A); + static const mineGradientTop = Color(0xFF1A0F0A); + static const mineGradientMiddle = Color(0xFF0D0908); + static const mineGradientBottom = Color(0xFF000000); + static const mineGridSurface = Color(0x99101010); + static const mineGridDivider = Color(0xFF2A2A2A); + static const mineGridIconSurface = Color(0xFF1C1C1E); + static const mineGridLabel = Color(0xFFE6E6EB); /// 次级文字与列表 chevron。 static const textPrimary = Color(0xFFFFFFFF); diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 5ba9597..99641fb 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -179,11 +179,16 @@ abstract final class AppDimens { /// 「我的」页中性视觉。 static const double mineCardRadius = 16; - static const double mineRowHeight = 56; static const double minePageTitle = 17; static const double mineSectionTitle = 15; static const double mineAvatar = 56; static const double mineUserName = 20; static const double mineUid = 13; - static const double mineMenuIcon = 22; + static const double mineGridHorizontal = 14; + static const double mineGridRadius = 12; + static const double mineGridCellHeight = 96; + static const double mineGridIconSurface = 44; + static const double mineGridIcon = 22; + static const double mineGridLabel = 12; + static const double mineGridDividerWidth = 1; } diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index fea424f..43ce56b 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../core/data/repositories/watch_history_repository.dart'; @@ -80,25 +81,39 @@ class _MyPageState extends State { Widget _page(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final content = ColoredBox( + final content = DecoratedBox( key: const Key('mine-page-surface'), - color: AppColors.appBackground, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.mineGradientTop, + AppColors.mineGradientMiddle, + AppColors.mineGradientBottom, + ], + stops: [0, 0.48, 1], + ), + ), child: SafeArea( bottom: false, child: Column( children: [ _topBar(l10n), - Expanded(child: _body(context, l10n)), + Expanded(child: _body(context)), if (!embedded) _bottomNav(), ], ), ), ); - return Theme( - data: AppTheme.dark(), - child: embedded - ? content - : Scaffold(backgroundColor: AppColors.appBackground, body: content), + return AnnotatedRegion( + value: SystemUiOverlayStyle.light, + child: Theme( + data: AppTheme.dark(), + child: embedded + ? content + : Scaffold(backgroundColor: AppColors.appBackground, body: content), + ), ); } @@ -125,24 +140,34 @@ class _MyPageState extends State { ); } - Widget _body(BuildContext context, AppLocalizations l10n) { - final locale = context.watch(); + Widget _body(BuildContext context) { return ListView( key: const Key('mine-page-scroll'), - padding: const EdgeInsets.fromLTRB( - AppDimens.pageHorizontal, - 0, - AppDimens.pageHorizontal, - AppDimens.spaceXl, - ), + padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), children: [ - MineHeader(onLogin: _openLogin, onEdit: _openEditActions), - MineVipBanner(onOpen: () => _openDestination(MineDestination.vip)), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.pageHorizontal, + ), + child: MineHeader(onLogin: _openLogin, onEdit: _openEditActions), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.pageHorizontal, + ), + child: MineVipBanner( + onOpen: () => _openDestination(MineDestination.vip), + ), + ), const SizedBox(height: AppDimens.spaceMd), - MineMenuList( - onOpen: _openDestination, - onLanguage: _openLanguage, - languageLabel: _languageLabel(l10n, locale.locale), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.mineGridHorizontal, + ), + child: MineMenuList( + onOpen: _openDestination, + onLanguage: _openLanguage, + ), ), ], ); @@ -252,15 +277,6 @@ class _MyPageState extends State { _openProtected(VipPage(onPurchased: () => unawaited(wallet.load()))); } - String _languageLabel(AppLocalizations l10n, Locale? locale) { - return switch (locale?.languageCode) { - 'zh' => l10n.simplifiedChinese, - 'en' => l10n.english, - 'id' => l10n.bahasaIndonesia, - _ => l10n.language, - }; - } - Future _openLanguage() async { final selected = await showModalBottomSheet( context: context, diff --git a/lib/pages/mine/widgets/mine_menu.dart b/lib/pages/mine/widgets/mine_menu.dart index ed5be25..6146127 100644 --- a/lib/pages/mine/widgets/mine_menu.dart +++ b/lib/pages/mine/widgets/mine_menu.dart @@ -108,18 +108,16 @@ class MineVipBanner extends StatelessWidget { } } -/// 「我的」页单列功能菜单。 +/// 「我的」页 4 列功能入口。 class MineMenuList extends StatelessWidget { const MineMenuList({ super.key, required this.onOpen, required this.onLanguage, - required this.languageLabel, }); final ValueChanged onOpen; final VoidCallback onLanguage; - final String languageLabel; @override Widget build(BuildContext context) { @@ -127,58 +125,85 @@ class MineMenuList extends StatelessWidget { return Container( key: const Key('mine-menu'), decoration: BoxDecoration( - color: AppColors.mineCard, - borderRadius: BorderRadius.circular(AppDimens.mineCardRadius), - border: Border.all(color: AppColors.contentBorder), + color: AppColors.mineGridSurface, + borderRadius: BorderRadius.circular(AppDimens.mineGridRadius), ), child: Material( color: AppColors.transparent, - borderRadius: BorderRadius.circular(AppDimens.mineCardRadius), + borderRadius: BorderRadius.circular(AppDimens.mineGridRadius), clipBehavior: Clip.antiAlias, - child: Column( + child: Table( + key: const Key('mine-menu-grid'), + defaultColumnWidth: const FlexColumnWidth(), + defaultVerticalAlignment: TableCellVerticalAlignment.middle, + border: const TableBorder( + horizontalInside: BorderSide( + color: AppColors.mineGridDivider, + width: AppDimens.mineGridDividerWidth, + ), + verticalInside: BorderSide( + color: AppColors.mineGridDivider, + width: AppDimens.mineGridDividerWidth, + ), + ), children: [ - _row( - key: const Key('mine-wallet'), - icon: Icons.account_balance_wallet_outlined, - title: l10n.wallet, - destination: MineDestination.wallet, + TableRow( + children: [ + _cell( + index: 0, + actionKey: const Key('mine-wallet'), + icon: Icons.account_balance_wallet_outlined, + label: l10n.wallet, + onTap: () => onOpen(MineDestination.wallet), + ), + _cell( + index: 1, + actionKey: const Key('mine-downloads'), + icon: Icons.download_outlined, + label: l10n.offlineDownloads, + onTap: () => onOpen(MineDestination.downloads), + ), + _cell( + index: 2, + actionKey: const Key('mine-history'), + icon: Icons.history_rounded, + label: l10n.watchHistory, + onTap: () => onOpen(MineDestination.history), + ), + _cell( + index: 3, + actionKey: const Key('mine-watch-preferences'), + icon: Icons.tune_rounded, + label: l10n.watchPreferences, + onTap: () => onOpen(MineDestination.preferences), + ), + ], ), - _divider(), - _row( - key: const Key('mine-downloads'), - icon: Icons.download_outlined, - title: l10n.offlineDownloads, - destination: MineDestination.downloads, - ), - _divider(), - _row( - key: const Key('mine-history'), - icon: Icons.history_rounded, - title: l10n.watchHistory, - destination: MineDestination.history, - ), - _divider(), - _row( - key: const Key('mine-watch-preferences'), - icon: Icons.tune_rounded, - title: l10n.watchPreferences, - destination: MineDestination.preferences, - ), - _divider(), - _row( - key: const Key('mine-help'), - icon: Icons.support_agent_rounded, - title: l10n.contactSupport, - destination: MineDestination.help, - ), - _divider(), - _languageRow(l10n), - _divider(), - _row( - key: const Key('mine-settings'), - icon: Icons.settings_outlined, - title: l10n.settings, - destination: MineDestination.settings, + TableRow( + children: [ + _cell( + index: 4, + actionKey: const Key('mine-help'), + icon: Icons.support_agent_rounded, + label: l10n.contactSupport, + onTap: () => onOpen(MineDestination.help), + ), + _cell( + index: 5, + actionKey: const Key('mine-language'), + icon: Icons.language_rounded, + label: l10n.language, + onTap: onLanguage, + ), + _cell( + index: 6, + actionKey: const Key('mine-settings'), + icon: Icons.settings_outlined, + label: l10n.settings, + onTap: () => onOpen(MineDestination.settings), + ), + _cell(index: 7), + ], ), ], ), @@ -186,84 +211,58 @@ class MineMenuList extends StatelessWidget { ); } - Widget _languageRow(AppLocalizations l10n) { - return ListTile( - key: const Key('mine-language'), - minTileHeight: AppDimens.mineRowHeight, - contentPadding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceLg), - leading: const Icon( - Icons.language_rounded, - color: AppColors.textPrimary, - size: AppDimens.mineMenuIcon, - ), - title: Text( - l10n.language, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: AppDimens.listTitle, - fontWeight: FontWeight.w500, - ), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - languageLabel, - style: const TextStyle( - color: AppColors.textSecondary, - fontSize: AppDimens.caption, - ), - ), - const SizedBox(width: AppDimens.spaceXs), - const Icon( - Icons.chevron_right_rounded, - color: AppColors.textSecondary, - size: 18, - ), - ], - ), - onTap: onLanguage, - ); - } - - Widget _row({ - required Key key, - required IconData icon, - required String title, - required MineDestination destination, + Widget _cell({ + required int index, + Key? actionKey, + IconData? icon, + String? label, + VoidCallback? onTap, }) { - return ListTile( - key: key, - minTileHeight: AppDimens.mineRowHeight, - contentPadding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceLg), - leading: Icon( - icon, - color: AppColors.textPrimary, - size: AppDimens.mineMenuIcon, - ), - title: Text( - title, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: AppDimens.listTitle, - fontWeight: FontWeight.w500, - ), - ), - trailing: const Icon( - Icons.chevron_right_rounded, - color: AppColors.textSecondary, - size: 18, - ), - onTap: () => onOpen(destination), - ); - } - - Widget _divider() { - return const Divider( - height: 1, - indent: AppDimens.spaceLg, - endIndent: AppDimens.spaceLg, - color: AppColors.contentSurface, + return SizedBox( + key: Key('mine-menu-cell-$index'), + height: AppDimens.mineGridCellHeight, + child: actionKey == null + ? const SizedBox.shrink() + : InkWell( + key: actionKey, + onTap: onTap, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: AppDimens.mineGridIconSurface, + height: AppDimens.mineGridIconSurface, + decoration: const BoxDecoration( + color: AppColors.mineGridIconSurface, + shape: BoxShape.circle, + ), + child: Icon( + icon, + color: AppColors.primary, + size: AppDimens.mineGridIcon, + ), + ), + const SizedBox(height: AppDimens.spaceXs + 2), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Text( + label ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.mineGridLabel, + fontSize: AppDimens.mineGridLabel, + fontWeight: FontWeight.w500, + height: 1.2, + ), + ), + ), + ], + ), + ), + ), ); } } diff --git a/test/mine/mine_preview_layout_test.dart b/test/mine/mine_preview_layout_test.dart index 5f63599..b017ed0 100644 --- a/test/mine/mine_preview_layout_test.dart +++ b/test/mine/mine_preview_layout_test.dart @@ -25,7 +25,7 @@ import '../support/fake_notice_repository.dart'; import '../support/widget_player.dart'; void main() { - testWidgets('should_render_visitor_user_vip_and_seven_menu_rows', ( + testWidgets('should_render_visitor_user_vip_and_seven_grid_entries', ( tester, ) async { await _pumpApp(tester, signedIn: false); @@ -50,6 +50,18 @@ void main() { expect(title.style?.fontSize, AppDimens.minePageTitle); expect(title.style?.fontWeight, FontWeight.w700); expect(find.byKey(const Key('mine-mini-player')), findsNothing); + final surface = tester.widget( + find.byKey(const Key('mine-page-surface')), + ); + final surfaceDecoration = surface.decoration as BoxDecoration; + final background = surfaceDecoration.gradient! as LinearGradient; + expect(background.begin, Alignment.topCenter); + expect(background.end, Alignment.bottomCenter); + expect(background.colors, const [ + AppColors.mineGradientTop, + AppColors.mineGradientMiddle, + AppColors.mineGradientBottom, + ]); for (final oldShortcut in const [ Key('mine-watchlist'), Key('mine-history-shortcut'), @@ -58,13 +70,7 @@ void main() { expect(find.byKey(oldShortcut), findsNothing); } - final menuRows = tester.widgetList( - find.descendant( - of: find.byKey(const Key('mine-menu')), - matching: find.byType(ListTile), - ), - ); - expect(menuRows.map((row) => row.key), const [ + const entryKeys = [ Key('mine-wallet'), Key('mine-downloads'), Key('mine-history'), @@ -72,32 +78,59 @@ void main() { Key('mine-help'), Key('mine-language'), Key('mine-settings'), - ]); - expect(menuRows, hasLength(7)); + ]; + expect(entryKeys, hasLength(7)); + for (final entryKey in entryKeys) { + expect(find.byKey(entryKey), findsOneWidget); + } + for (var index = 0; index < 8; index++) { + expect(find.byKey(Key('mine-menu-cell-$index')), findsOneWidget); + } expect( - menuRows.every((row) => row.minTileHeight == AppDimens.mineRowHeight), - isTrue, + find.descendant( + of: find.byKey(const Key('mine-menu-cell-7')), + matching: find.byType(Icon), + ), + findsNothing, ); - final dividers = tester.widgetList( + expect( find.descendant( of: find.byKey(const Key('mine-menu')), - matching: find.byType(Divider), + matching: find.byType(ListTile), ), + findsNothing, ); - expect(dividers, hasLength(6)); + final positions = [ + for (final entryKey in entryKeys) tester.getTopLeft(find.byKey(entryKey)), + ]; expect( - dividers.every((divider) => divider.color == AppColors.contentSurface), - isTrue, + positions.take(4).map((position) => position.dy).toSet(), + hasLength(1), ); + expect( + positions.skip(4).map((position) => position.dy).toSet(), + hasLength(1), + ); + expect(positions[1].dx, greaterThan(positions[0].dx)); + expect(positions[2].dx, greaterThan(positions[1].dx)); + expect(positions[3].dx, greaterThan(positions[2].dx)); + expect(positions[5].dx, greaterThan(positions[4].dx)); + expect(positions[6].dx, greaterThan(positions[5].dx)); + expect(tester.getTopLeft(find.byKey(const Key('mine-menu'))).dx, 14); final menu = tester.widget(find.byKey(const Key('mine-menu'))); final menuDecoration = menu.decoration! as BoxDecoration; - expect(menuDecoration.color, AppColors.mineCard); + expect(menuDecoration.color, AppColors.mineGridSurface); expect( menuDecoration.borderRadius, - BorderRadius.circular(AppDimens.mineCardRadius), + BorderRadius.circular(AppDimens.mineGridRadius), ); + expect(menuDecoration.border, isNull); expect(menuDecoration.boxShadow, isNull); + final table = tester.widget(find.byKey(const Key('mine-menu-grid'))); + final border = table.border!; + expect(border.horizontalInside.color, AppColors.mineGridDivider); + expect(border.verticalInside.color, AppColors.mineGridDivider); final vip = tester.widget(find.byKey(const Key('mine-vip'))); final vipDecoration = vip.decoration! as BoxDecoration; expect((vipDecoration.gradient! as LinearGradient).colors, const [ @@ -179,7 +212,6 @@ void main() { child: MineMenuList( onOpen: opened.add, onLanguage: () => languageOpened++, - languageLabel: 'English', ), ), ), From a81bec4d473e680044ee299800d07171bd002e70 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 21:38:49 +0800 Subject: [PATCH 099/129] =?UTF-8?q?style:=20=E5=A2=9E=E5=BC=BA=E6=88=91?= =?UTF-8?q?=E7=9A=84=E9=A1=B5=E6=B8=90=E5=8F=98=E4=B8=8E=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=E9=85=8D=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 2 + lib/core/theme/app_colors.dart | 12 ++++- lib/core/theme/app_dimens.dart | 1 - lib/pages/mine/mine_view.dart | 58 +++++++++----------- lib/pages/mine/widgets/mine_menu.dart | 22 ++++++-- test/mine/mine_preview_layout_test.dart | 70 ++++++++++++++++++++++--- 6 files changed, 116 insertions(+), 49 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 1b2c8d8..d6f438c 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1260,3 +1260,5 @@ $ flutter test - 页面背景改为暖黑三段纵向渐变,用户区与 VIP 卡布局保持不变。 - 行式入口替换为 4 列图标网格,7 个原入口按顺序保留,第 8 格为空占位。 - 网格使用 14 边距、半透明深灰底、圆角 12 和仅内部 1px 分隔线;`flutter analyze`:No issues found。 +- 渐变增强为 `#3A1B0E`/`#1A0C07`/黑色并叠加顶部品牌橙径向光晕,同时移除页面级「我的」标题栏。 +- 7 个宫格图标分别使用金币金、天蓝、薄荷绿、粉红、紫色、青色和冷灰;`flutter analyze`:No issues found。 diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index db57bdd..84ec3fe 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -84,13 +84,21 @@ abstract final class AppColors { /// 「我的」页卡片与描边。 static const mineCard = Color(0xFF16161A); static const mineOutline = Color(0xFF4A4A4A); - static const mineGradientTop = Color(0xFF1A0F0A); - static const mineGradientMiddle = Color(0xFF0D0908); + static const mineGradientTop = Color(0xFF3A1B0E); + static const mineGradientMiddle = Color(0xFF1A0C07); static const mineGradientBottom = Color(0xFF000000); + static const mineGradientGlow = Color(0x33FF6B2C); static const mineGridSurface = Color(0x99101010); static const mineGridDivider = Color(0xFF2A2A2A); static const mineGridIconSurface = Color(0xFF1C1C1E); static const mineGridLabel = Color(0xFFE6E6EB); + static const mineIconWallet = Color(0xFFF5C451); + static const mineIconDownloads = Color(0xFF4DA3FF); + static const mineIconHistory = Color(0xFF4ADE80); + static const mineIconPreferences = Color(0xFFFF6B9A); + static const mineIconSupport = Color(0xFFA78BFA); + static const mineIconLanguage = Color(0xFF22D3EE); + static const mineIconSettings = Color(0xFFA1A1AA); /// 次级文字与列表 chevron。 static const textPrimary = Color(0xFFFFFFFF); diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 99641fb..3e7e345 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -179,7 +179,6 @@ abstract final class AppDimens { /// 「我的」页中性视觉。 static const double mineCardRadius = 16; - static const double minePageTitle = 17; static const double mineSectionTitle = 15; static const double mineAvatar = 56; static const double mineUserName = 20; diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index 43ce56b..bb866e7 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -80,7 +80,6 @@ class _MyPageState extends State { } Widget _page(BuildContext context) { - final l10n = AppLocalizations.of(context)!; final content = DecoratedBox( key: const Key('mine-page-surface'), decoration: const BoxDecoration( @@ -92,18 +91,32 @@ class _MyPageState extends State { AppColors.mineGradientMiddle, AppColors.mineGradientBottom, ], - stops: [0, 0.48, 1], + stops: [0, 0.4, 0.75], ), ), - child: SafeArea( - bottom: false, - child: Column( - children: [ - _topBar(l10n), - Expanded(child: _body(context)), - if (!embedded) _bottomNav(), - ], - ), + child: Stack( + fit: StackFit.expand, + children: [ + const DecoratedBox( + key: Key('mine-page-glow'), + decoration: BoxDecoration( + gradient: RadialGradient( + center: Alignment(0, -0.95), + radius: 0.9, + colors: [AppColors.mineGradientGlow, AppColors.transparent], + ), + ), + ), + SafeArea( + bottom: false, + child: Column( + children: [ + Expanded(child: _body(context)), + if (!embedded) _bottomNav(), + ], + ), + ), + ], ), ); return AnnotatedRegion( @@ -117,29 +130,6 @@ class _MyPageState extends State { ); } - Widget _topBar(AppLocalizations l10n) { - return SizedBox( - height: AppDimens.contentHeaderHeight, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.pageHorizontal, - ), - child: Align( - alignment: Alignment.centerLeft, - child: Text( - l10n.myTab, - style: const TextStyle( - fontFamily: 'Outfit', - color: AppColors.textPrimary, - fontSize: AppDimens.minePageTitle, - fontWeight: FontWeight.w700, - ), - ), - ), - ), - ); - } - Widget _body(BuildContext context) { return ListView( key: const Key('mine-page-scroll'), diff --git a/lib/pages/mine/widgets/mine_menu.dart b/lib/pages/mine/widgets/mine_menu.dart index 6146127..a146200 100644 --- a/lib/pages/mine/widgets/mine_menu.dart +++ b/lib/pages/mine/widgets/mine_menu.dart @@ -153,6 +153,7 @@ class MineMenuList extends StatelessWidget { index: 0, actionKey: const Key('mine-wallet'), icon: Icons.account_balance_wallet_outlined, + iconColor: AppColors.mineIconWallet, label: l10n.wallet, onTap: () => onOpen(MineDestination.wallet), ), @@ -160,6 +161,7 @@ class MineMenuList extends StatelessWidget { index: 1, actionKey: const Key('mine-downloads'), icon: Icons.download_outlined, + iconColor: AppColors.mineIconDownloads, label: l10n.offlineDownloads, onTap: () => onOpen(MineDestination.downloads), ), @@ -167,6 +169,7 @@ class MineMenuList extends StatelessWidget { index: 2, actionKey: const Key('mine-history'), icon: Icons.history_rounded, + iconColor: AppColors.mineIconHistory, label: l10n.watchHistory, onTap: () => onOpen(MineDestination.history), ), @@ -174,6 +177,7 @@ class MineMenuList extends StatelessWidget { index: 3, actionKey: const Key('mine-watch-preferences'), icon: Icons.tune_rounded, + iconColor: AppColors.mineIconPreferences, label: l10n.watchPreferences, onTap: () => onOpen(MineDestination.preferences), ), @@ -185,6 +189,7 @@ class MineMenuList extends StatelessWidget { index: 4, actionKey: const Key('mine-help'), icon: Icons.support_agent_rounded, + iconColor: AppColors.mineIconSupport, label: l10n.contactSupport, onTap: () => onOpen(MineDestination.help), ), @@ -192,6 +197,7 @@ class MineMenuList extends StatelessWidget { index: 5, actionKey: const Key('mine-language'), icon: Icons.language_rounded, + iconColor: AppColors.mineIconLanguage, label: l10n.language, onTap: onLanguage, ), @@ -199,6 +205,7 @@ class MineMenuList extends StatelessWidget { index: 6, actionKey: const Key('mine-settings'), icon: Icons.settings_outlined, + iconColor: AppColors.mineIconSettings, label: l10n.settings, onTap: () => onOpen(MineDestination.settings), ), @@ -215,6 +222,7 @@ class MineMenuList extends StatelessWidget { required int index, Key? actionKey, IconData? icon, + Color? iconColor, String? label, VoidCallback? onTap, }) { @@ -237,10 +245,16 @@ class MineMenuList extends StatelessWidget { color: AppColors.mineGridIconSurface, shape: BoxShape.circle, ), - child: Icon( - icon, - color: AppColors.primary, - size: AppDimens.mineGridIcon, + child: DecoratedBox( + decoration: BoxDecoration( + color: iconColor?.withValues(alpha: 0.08), + shape: BoxShape.circle, + ), + child: Icon( + icon, + color: iconColor, + size: AppDimens.mineGridIcon, + ), ), ), const SizedBox(height: AppDimens.spaceXs + 2), diff --git a/test/mine/mine_preview_layout_test.dart b/test/mine/mine_preview_layout_test.dart index b017ed0..2d03568 100644 --- a/test/mine/mine_preview_layout_test.dart +++ b/test/mine/mine_preview_layout_test.dart @@ -41,14 +41,6 @@ void main() { expect(find.byKey(const Key('mine-vip')), findsOneWidget); expect(find.byKey(const Key('mine-vip-open')), findsOneWidget); expect(find.byKey(const Key('mine-menu')), findsOneWidget); - final title = tester.widget( - find.descendant( - of: find.byKey(const Key('mine-page-surface')), - matching: find.text('My'), - ), - ); - expect(title.style?.fontSize, AppDimens.minePageTitle); - expect(title.style?.fontWeight, FontWeight.w700); expect(find.byKey(const Key('mine-mini-player')), findsNothing); final surface = tester.widget( find.byKey(const Key('mine-page-surface')), @@ -62,6 +54,25 @@ void main() { AppColors.mineGradientMiddle, AppColors.mineGradientBottom, ]); + expect(background.stops, const [0, 0.4, 0.75]); + expect( + find.descendant( + of: find.byKey(const Key('mine-page-surface')), + matching: find.text('My'), + ), + findsNothing, + ); + final glow = tester.widget( + find.byKey(const Key('mine-page-glow')), + ); + final glowDecoration = glow.decoration as BoxDecoration; + final radialGlow = glowDecoration.gradient! as RadialGradient; + expect(radialGlow.center, const Alignment(0, -0.95)); + expect(radialGlow.radius, 0.9); + expect(radialGlow.colors, const [ + AppColors.mineGradientGlow, + AppColors.transparent, + ]); for (final oldShortcut in const [ Key('mine-watchlist'), Key('mine-history-shortcut'), @@ -83,6 +94,49 @@ void main() { for (final entryKey in entryKeys) { expect(find.byKey(entryKey), findsOneWidget); } + const coloredIcons = [ + ( + Key('mine-wallet'), + Icons.account_balance_wallet_outlined, + AppColors.mineIconWallet, + ), + ( + Key('mine-downloads'), + Icons.download_outlined, + AppColors.mineIconDownloads, + ), + (Key('mine-history'), Icons.history_rounded, AppColors.mineIconHistory), + ( + Key('mine-watch-preferences'), + Icons.tune_rounded, + AppColors.mineIconPreferences, + ), + ( + Key('mine-help'), + Icons.support_agent_rounded, + AppColors.mineIconSupport, + ), + ( + Key('mine-language'), + Icons.language_rounded, + AppColors.mineIconLanguage, + ), + ( + Key('mine-settings'), + Icons.settings_outlined, + AppColors.mineIconSettings, + ), + ]; + for (final entry in coloredIcons) { + final icon = tester.widget( + find.descendant( + of: find.byKey(entry.$1), + matching: find.byIcon(entry.$2), + ), + ); + expect(icon.color, entry.$3); + expect(icon.size, AppDimens.mineGridIcon); + } for (var index = 0; index < 8; index++) { expect(find.byKey(Key('mine-menu-cell-$index')), findsOneWidget); } From 9953c955e33e8ec281246ec3db8cd9bc782ed45f Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 21:44:03 +0800 Subject: [PATCH 100/129] =?UTF-8?q?feat:=20=E8=A1=A5=E5=85=85=E6=88=91?= =?UTF-8?q?=E7=9A=84=E9=A1=B5=E5=86=85=E5=AE=B9=E5=8C=BA=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 6 + lib/core/theme/app_colors.dart | 2 + lib/core/theme/app_dimens.dart | 12 ++ lib/l10n/app_en.arb | 14 +- lib/l10n/app_id.arb | 14 +- lib/l10n/app_localizations.dart | 42 +++++ lib/l10n/app_localizations_en.dart | 24 +++ lib/l10n/app_localizations_id.dart | 24 +++ lib/l10n/app_localizations_zh.dart | 23 +++ lib/l10n/app_zh.arb | 14 +- lib/pages/following/following_page.dart | 11 ++ lib/pages/home/home_logic.dart | 7 + lib/pages/home/home_view.dart | 11 +- lib/pages/mine/about_page.dart | 70 ++++++++ lib/pages/mine/mine_logic.dart | 4 +- lib/pages/mine/mine_types.dart | 3 +- lib/pages/mine/mine_view.dart | 58 +++++- .../mine/widgets/mine_continue_watching.dart | 169 ++++++++++++++++++ .../mine/widgets/mine_more_services.dart | 93 ++++++++++ lib/pages/mine/widgets/mine_stats_panel.dart | 91 ++++++++++ test/home/home_logic_test.dart | 4 + test/mine/mine_logic_test.dart | 1 + test/mine/mine_preview_layout_test.dart | 112 ++++++++++++ 23 files changed, 800 insertions(+), 9 deletions(-) create mode 100644 lib/pages/mine/about_page.dart create mode 100644 lib/pages/mine/widgets/mine_continue_watching.dart create mode 100644 lib/pages/mine/widgets/mine_more_services.dart create mode 100644 lib/pages/mine/widgets/mine_stats_panel.dart diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index d6f438c..dd2061b 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1262,3 +1262,9 @@ $ flutter test - 网格使用 14 边距、半透明深灰底、圆角 12 和仅内部 1px 分隔线;`flutter analyze`:No issues found。 - 渐变增强为 `#3A1B0E`/`#1A0C07`/黑色并叠加顶部品牌橙径向光晕,同时移除页面级「我的」标题栏。 - 7 个宫格图标分别使用金币金、天蓝、薄荷绿、粉红、紫色、青色和冷灰;`flutter analyze`:No issues found。 + +## 2026-10-22 我的页补充内容 + +- VIP 与宫格之间新增继续观看横向列表、追剧/观看记录/金币统计面板,继续观看最多 10 条。 +- 宫格后新增常见问题与关于我们入口;关于页版本暂用与 pubspec 同步的常量占位。 +- 继续观看全部入口可切到追剧页播放历史 tab;`flutter analyze`:No issues found。 diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 84ec3fe..ae58709 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -89,6 +89,8 @@ abstract final class AppColors { static const mineGradientBottom = Color(0xFF000000); static const mineGradientGlow = Color(0x33FF6B2C); static const mineGridSurface = Color(0x99101010); + static const minePanelSurface = Color(0xFF101010); + static const mineMoreDivider = Color(0xFF1C1C1E); static const mineGridDivider = Color(0xFF2A2A2A); static const mineGridIconSurface = Color(0xFF1C1C1E); static const mineGridLabel = Color(0xFFE6E6EB); diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 3e7e345..557c3e9 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -190,4 +190,16 @@ abstract final class AppDimens { static const double mineGridIcon = 22; static const double mineGridLabel = 12; static const double mineGridDividerWidth = 1; + static const double mineSectionTitleFont = 16; + static const double mineSectionLinkFont = 12; + static const double mineSectionGap = 20; + static const double mineContinueCardWidth = 96; + static const double mineContinueRowHeight = 168; + static const double mineContinueTitleFont = 13; + static const double mineContinueMetaFont = 11; + static const double mineStatsValueFont = 18; + static const double mineStatsLabelFont = 11; + static const double minePanelPadding = 12; + static const double mineMoreRowHeight = 52; + static const double mineMoreFont = 15; } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f5b44c3..08e4c07 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -749,5 +749,17 @@ "watchedToEpisode": "Watched to Episode {episode}", "@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } }, "homeWatchAll": "Watch all", - "confirmRemove": "Remove this item?" + "confirmRemove": "Remove this item?", + "followingCountLabel": "Following", + "watchRecordsCountLabel": "Watch records", + "dataUnavailable": "—", + "commonQuestions": "Common questions", + "aboutUs": "About us", + "aboutDescription": "A focused, immersive place to watch short dramas.", + "versionLabel": "Version {version}", + "@versionLabel": { + "placeholders": { + "version": { "type": "String" } + } + } } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 025c1e8..8659caa 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -706,5 +706,17 @@ "watchedToEpisode": "Ditonton hingga Episode {episode}", "@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } }, "homeWatchAll": "Tonton semua", - "confirmRemove": "Hapus item ini?" + "confirmRemove": "Hapus item ini?", + "followingCountLabel": "Mengikuti", + "watchRecordsCountLabel": "Riwayat tontonan", + "dataUnavailable": "—", + "commonQuestions": "Pertanyaan umum", + "aboutUs": "Tentang kami", + "aboutDescription": "Tempat fokus dan imersif untuk menonton drama pendek.", + "versionLabel": "Versi {version}", + "@versionLabel": { + "placeholders": { + "version": { "type": "String" } + } + } } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 2b3ae8a..aa8b922 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3267,6 +3267,48 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Remove this item?'** String get confirmRemove; + + /// No description provided for @followingCountLabel. + /// + /// In en, this message translates to: + /// **'Following'** + String get followingCountLabel; + + /// No description provided for @watchRecordsCountLabel. + /// + /// In en, this message translates to: + /// **'Watch records'** + String get watchRecordsCountLabel; + + /// No description provided for @dataUnavailable. + /// + /// In en, this message translates to: + /// **'—'** + String get dataUnavailable; + + /// No description provided for @commonQuestions. + /// + /// In en, this message translates to: + /// **'Common questions'** + String get commonQuestions; + + /// No description provided for @aboutUs. + /// + /// In en, this message translates to: + /// **'About us'** + String get aboutUs; + + /// No description provided for @aboutDescription. + /// + /// In en, this message translates to: + /// **'A focused, immersive place to watch short dramas.'** + String get aboutDescription; + + /// No description provided for @versionLabel. + /// + /// In en, this message translates to: + /// **'Version {version}'** + String versionLabel(String version); } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index ef3ebad..0c0fdb3 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1704,4 +1704,28 @@ class AppLocalizationsEn extends AppLocalizations { @override String get confirmRemove => 'Remove this item?'; + + @override + String get followingCountLabel => 'Following'; + + @override + String get watchRecordsCountLabel => 'Watch records'; + + @override + String get dataUnavailable => '—'; + + @override + String get commonQuestions => 'Common questions'; + + @override + String get aboutUs => 'About us'; + + @override + String get aboutDescription => + 'A focused, immersive place to watch short dramas.'; + + @override + String versionLabel(String version) { + return 'Version $version'; + } } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index f76ec32..a190f6a 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1706,4 +1706,28 @@ class AppLocalizationsId extends AppLocalizations { @override String get confirmRemove => 'Hapus item ini?'; + + @override + String get followingCountLabel => 'Mengikuti'; + + @override + String get watchRecordsCountLabel => 'Riwayat tontonan'; + + @override + String get dataUnavailable => '—'; + + @override + String get commonQuestions => 'Pertanyaan umum'; + + @override + String get aboutUs => 'Tentang kami'; + + @override + String get aboutDescription => + 'Tempat fokus dan imersif untuk menonton drama pendek.'; + + @override + String versionLabel(String version) { + return 'Versi $version'; + } } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 1c49d79..a5e27d1 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1680,4 +1680,27 @@ class AppLocalizationsZh extends AppLocalizations { @override String get confirmRemove => '确定移除这条内容吗?'; + + @override + String get followingCountLabel => '追剧数'; + + @override + String get watchRecordsCountLabel => '观看记录'; + + @override + String get dataUnavailable => '—'; + + @override + String get commonQuestions => '常见问题'; + + @override + String get aboutUs => '关于我们'; + + @override + String get aboutDescription => '专注于短剧观看与沉浸式追剧体验。'; + + @override + String versionLabel(String version) { + return '版本 $version'; + } } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index bae9656..bb0b6fd 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -668,5 +668,17 @@ "watchedToEpisode": "已看至 第 {episode} 集", "@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } }, "homeWatchAll": "观看全集", - "confirmRemove": "确定移除这条内容吗?" + "confirmRemove": "确定移除这条内容吗?", + "followingCountLabel": "追剧数", + "watchRecordsCountLabel": "观看记录", + "dataUnavailable": "—", + "commonQuestions": "常见问题", + "aboutUs": "关于我们", + "aboutDescription": "专注于短剧观看与沉浸式追剧体验。", + "versionLabel": "版本 {version}", + "@versionLabel": { + "placeholders": { + "version": { "type": "String" } + } + } } diff --git a/lib/pages/following/following_page.dart b/lib/pages/following/following_page.dart index 4780f36..4a8e05d 100644 --- a/lib/pages/following/following_page.dart +++ b/lib/pages/following/following_page.dart @@ -31,10 +31,12 @@ class FollowingPage extends StatefulWidget { super.key, required this.onGoHome, required this.favoriteController, + this.historyRequest = 0, }); final VoidCallback onGoHome; final FavoriteController favoriteController; + final int historyRequest; @override State createState() => _FollowingPageState(); @@ -74,6 +76,15 @@ class _FollowingPageState extends State { super.dispose(); } + @override + void didUpdateWidget(covariant FollowingPage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.historyRequest == widget.historyRequest) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _selectTab(FollowingTab.history); + }); + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; diff --git a/lib/pages/home/home_logic.dart b/lib/pages/home/home_logic.dart index dfd1f00..d82c60c 100644 --- a/lib/pages/home/home_logic.dart +++ b/lib/pages/home/home_logic.dart @@ -20,6 +20,7 @@ class HomeLogic extends ChangeNotifier { int searchRequest = 0; int resumeRequest = 0; int backRequest = 0; + int followingHistoryRequest = 0; bool drawerOpen = false; bool detailOpen = false; bool videoImmersive = false; @@ -100,6 +101,12 @@ class HomeLogic extends ChangeNotifier { ).push(MaterialPageRoute(builder: (_) => const WatchRecordsPage())); } + void openFollowingHistory() { + tab = 2; + followingHistoryRequest += 1; + notify(); + } + void myAction(MyPageAction action) { switch (action) { case MyPageAction.home: diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 46db787..cb1676c 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -263,6 +263,7 @@ class _HomeTabsState extends State<_HomeTabs> { FollowingPage( onGoHome: () => logic.select(0), favoriteController: context.read(), + historyRequest: logic.followingHistoryRequest, ), _my(context), ], @@ -312,14 +313,18 @@ class _HomeTabsState extends State<_HomeTabs> { Future _openWallet(BuildContext context) async { if (!await ensureSignedIn(context) || !context.mounted) return; - await Navigator.of(context) - .push(MaterialPageRoute(builder: (_) => const WalletPage())); + await Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const WalletPage())); } Widget _my(BuildContext context) { return MyPage( active: logic.tab == 3, - callbacks: MyPageCallbacks(onAction: logic.myAction), + callbacks: MyPageCallbacks( + onAction: logic.myAction, + onOpenHistoryTab: logic.openFollowingHistory, + ), ); } diff --git a/lib/pages/mine/about_page.dart b/lib/pages/mine/about_page.dart new file mode 100644 index 0000000..2da3cbe --- /dev/null +++ b/lib/pages/mine/about_page.dart @@ -0,0 +1,70 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_dimens.dart'; +import '../../core/theme/app_theme.dart'; +import '../../core/widgets/plain_page_scaffold.dart'; +import '../../l10n/app_localizations.dart'; + +// package_info is not configured in this client; keep the fallback in sync +// with the version in pubspec.yaml. +const String _fallbackAppVersion = '1.0.0'; + +class MineAboutPage extends StatelessWidget { + const MineAboutPage({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Theme( + data: AppTheme.dark(), + child: PlainPageScaffold( + pageKey: const Key('mine-about-page'), + backKey: const Key('mine-about-back'), + title: l10n.aboutUs, + child: ListView( + padding: const EdgeInsets.fromLTRB( + AppDimens.pageHorizontal, + AppDimens.spaceXl, + AppDimens.pageHorizontal, + AppDimens.spaceXl, + ), + children: [ + Text( + l10n.appName, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.textPrimary, + fontSize: AppDimens.detailHeadline, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: AppDimens.spaceSm), + Text( + l10n.versionLabel(_fallbackAppVersion), + key: const Key('mine-about-version'), + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.textTertiary, + fontSize: AppDimens.caption, + ), + ), + const SizedBox(height: AppDimens.spaceXl), + Text( + l10n.aboutDescription, + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.textTabInactive, + fontSize: AppDimens.body, + height: 1.5, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/mine/mine_logic.dart b/lib/pages/mine/mine_logic.dart index 6be582e..cf7f0e6 100644 --- a/lib/pages/mine/mine_logic.dart +++ b/lib/pages/mine/mine_logic.dart @@ -14,6 +14,7 @@ class MineLogic extends ChangeNotifier { final WatchHistoryRepository history; List recent = const []; + int? watchRecordTotal; bool recentLoading = false; String? recentError; bool _disposed = false; @@ -28,8 +29,9 @@ class MineLogic extends ChangeNotifier { recentError = null; notify(); try { - final page = await history.recent(pageIndex: 1, pageSize: 20); + final page = await history.recent(pageIndex: 1, pageSize: 10); recent = page.list; + watchRecordTotal = page.total; } catch (_) { recentError = 'progressError'; } finally { diff --git a/lib/pages/mine/mine_types.dart b/lib/pages/mine/mine_types.dart index 0304e98..3d69297 100644 --- a/lib/pages/mine/mine_types.dart +++ b/lib/pages/mine/mine_types.dart @@ -21,9 +21,10 @@ enum MineDestination { /// 「我的」页导航回调;没有 `onAction` 时按独立页面处理。 class MyPageCallbacks { - const MyPageCallbacks({this.onAction}); + const MyPageCallbacks({this.onAction, this.onOpenHistoryTab}); final ValueChanged? onAction; + final VoidCallback? onOpenHistoryTab; bool get embedded => onAction != null; } diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index bb866e7..9105a09 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -8,6 +8,7 @@ import 'package:provider/provider.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/auth_controller.dart'; +import '../../core/data/services/favorite_controller.dart'; import '../../core/data/services/locale_controller.dart'; import '../../core/data/services/wallet_controller.dart'; import '../../core/theme/app_colors.dart'; @@ -25,11 +26,15 @@ import '../settings/settings_page.dart'; import '../store/vip_page.dart'; import '../wallet/wallet_page.dart'; import '../watch_records/watch_records_page.dart'; +import 'about_page.dart'; import 'mine_binding.dart'; import 'mine_logic.dart'; import 'mine_types.dart'; +import 'widgets/mine_continue_watching.dart'; import 'widgets/mine_header.dart'; import 'widgets/mine_menu.dart'; +import 'widgets/mine_more_services.dart'; +import 'widgets/mine_stats_panel.dart'; /// K28-B 我的页。 class MyPage extends StatefulWidget { @@ -131,6 +136,20 @@ class _MyPageState extends State { } Widget _body(BuildContext context) { + final auth = context.watch(); + final favorites = context.watch(); + final wallet = context.watch(); + final continueWatching = logic.recent.take(10).toList(growable: false); + final followingCount = + auth.isSignedIn && + favorites.error == null && + !(favorites.loading && favorites.total == 0) + ? favorites.total + : null; + final watchRecordCount = auth.isSignedIn ? logic.watchRecordTotal : null; + final coinBalance = auth.isSignedIn && wallet.snapshot != null + ? wallet.balance + : null; return ListView( key: const Key('mine-page-scroll'), padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), @@ -149,7 +168,25 @@ class _MyPageState extends State { onOpen: () => _openDestination(MineDestination.vip), ), ), - const SizedBox(height: AppDimens.spaceMd), + if (continueWatching.isNotEmpty) ...[ + const SizedBox(height: AppDimens.mineSectionGap), + MineContinueWatching( + records: continueWatching, + onOpenAll: _openHistoryTab, + ), + ], + const SizedBox(height: AppDimens.mineSectionGap), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.mineGridHorizontal, + ), + child: MineStatsPanel( + followingCount: followingCount, + watchRecordCount: watchRecordCount, + coinBalance: coinBalance, + ), + ), + const SizedBox(height: AppDimens.mineSectionGap), Padding( padding: const EdgeInsets.symmetric( horizontal: AppDimens.mineGridHorizontal, @@ -159,6 +196,16 @@ class _MyPageState extends State { onLanguage: _openLanguage, ), ), + const SizedBox(height: AppDimens.mineSectionGap), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.mineGridHorizontal, + ), + child: MineMoreServices( + onCommonQuestions: () => _pushPlaceholder(MinePlaceholderKind.help), + onAbout: () => _push(const MineAboutPage()), + ), + ), ], ); } @@ -192,6 +239,15 @@ class _MyPageState extends State { unawaited(openAuthPage(context)); } + void _openHistoryTab() { + final callback = widget.callbacks.onOpenHistoryTab; + if (callback != null) { + callback(); + return; + } + _push(const WatchRecordsPage()); + } + Future _openEditActions() async { final selected = await showModalBottomSheet( context: context, diff --git a/lib/pages/mine/widgets/mine_continue_watching.dart b/lib/pages/mine/widgets/mine_continue_watching.dart new file mode 100644 index 0000000..1930979 --- /dev/null +++ b/lib/pages/mine/widgets/mine_continue_watching.dart @@ -0,0 +1,169 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/watch_progress.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../core/widgets/remote_image.dart'; +import '../../../l10n/app_localizations.dart'; + +class MineContinueWatching extends StatelessWidget { + const MineContinueWatching({ + super.key, + required this.records, + required this.onOpenAll, + }); + + final List records; + final VoidCallback onOpenAll; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final visible = records.take(10).toList(growable: false); + if (visible.isEmpty) return const SizedBox.shrink(); + return Column( + key: const Key('mine-continue-watching'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.mineGridHorizontal, + ), + child: Row( + children: [ + Expanded( + child: Text( + l10n.continueWatching, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.mineSectionTitleFont, + fontWeight: FontWeight.w600, + ), + ), + ), + InkWell( + key: const Key('mine-continue-all'), + onTap: onOpenAll, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l10n.all, + style: const TextStyle( + color: AppColors.textTertiary, + fontSize: AppDimens.mineSectionLinkFont, + ), + ), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.textTertiary, + size: 16, + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: AppDimens.spaceSm), + SizedBox( + height: AppDimens.mineContinueRowHeight, + child: ListView.separated( + key: const Key('mine-continue-list'), + scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.mineGridHorizontal, + ), + itemCount: visible.length, + separatorBuilder: (_, _) => + const SizedBox(width: AppDimens.spaceMd), + itemBuilder: (_, index) { + final record = visible[index]; + return _card(l10n, record); + }, + ), + ), + ], + ); + } + + Widget _card(AppLocalizations l10n, WatchProgress record) { + final title = record.dramaTitle?.trim() ?? ''; + final episode = record.episodeNumber; + final hasProgress = record.duration > Duration.zero; + return SizedBox( + width: AppDimens.mineContinueCardWidth, + child: InkWell( + key: Key('mine-continue-${record.dramaId}'), + borderRadius: BorderRadius.circular(AppDimens.homeCardRadius), + onTap: onOpenAll, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: AppDimens.cardCoverAspectRatio, + child: Stack( + clipBehavior: Clip.hardEdge, + children: [ + Positioned.fill( + child: ClipRRect( + borderRadius: BorderRadius.circular( + AppDimens.homeCardRadius, + ), + child: RemoteImage( + record.dramaCover ?? '', + fit: BoxFit.cover, + showFallbackIcon: true, + fallbackColor: AppColors.contentSurface, + ), + ), + ), + if (hasProgress) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: LinearProgressIndicator( + value: record.fraction, + minHeight: AppDimens.followingProgressHeight, + backgroundColor: AppColors.inactiveTrack, + valueColor: const AlwaysStoppedAnimation( + AppColors.primary, + ), + ), + ), + ], + ), + ), + const SizedBox(height: AppDimens.cardCoverTitleGap), + Text( + title.isEmpty ? l10n.dataUnavailable : title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.mineContinueTitleFont, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + episode == null || episode <= 0 + ? l10n.dataUnavailable + : l10n.episodeLabel(episode), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textTertiary, + fontSize: AppDimens.mineContinueMetaFont, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/mine/widgets/mine_more_services.dart b/lib/pages/mine/widgets/mine_more_services.dart new file mode 100644 index 0000000..2cd0130 --- /dev/null +++ b/lib/pages/mine/widgets/mine_more_services.dart @@ -0,0 +1,93 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../l10n/app_localizations.dart'; + +class MineMoreServices extends StatelessWidget { + const MineMoreServices({ + super.key, + required this.onCommonQuestions, + required this.onAbout, + }); + + final VoidCallback onCommonQuestions; + final VoidCallback onAbout; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Container( + key: const Key('mine-more-services'), + decoration: BoxDecoration( + color: AppColors.minePanelSurface, + borderRadius: BorderRadius.circular(AppDimens.mineGridRadius), + ), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + _row( + key: const Key('mine-common-questions'), + label: l10n.commonQuestions, + onTap: onCommonQuestions, + ), + const Divider( + height: AppDimens.mineGridDividerWidth, + thickness: AppDimens.mineGridDividerWidth, + color: AppColors.mineMoreDivider, + ), + _row( + key: const Key('mine-about'), + label: l10n.aboutUs, + onTap: onAbout, + ), + ], + ), + ); + } + + Widget _row({ + required Key key, + required String label, + required VoidCallback onTap, + }) { + return Material( + color: AppColors.transparent, + child: InkWell( + key: key, + onTap: onTap, + child: SizedBox( + height: AppDimens.mineMoreRowHeight, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.minePanelPadding, + ), + child: Row( + children: [ + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.mineMoreFont, + fontWeight: FontWeight.w400, + ), + ), + ), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.textSecondary, + size: 18, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/mine/widgets/mine_stats_panel.dart b/lib/pages/mine/widgets/mine_stats_panel.dart new file mode 100644 index 0000000..7c2b344 --- /dev/null +++ b/lib/pages/mine/widgets/mine_stats_panel.dart @@ -0,0 +1,91 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../l10n/app_localizations.dart'; + +class MineStatsPanel extends StatelessWidget { + const MineStatsPanel({ + super.key, + required this.followingCount, + required this.watchRecordCount, + required this.coinBalance, + }); + + final int? followingCount; + final int? watchRecordCount; + final int? coinBalance; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Container( + key: const Key('mine-stats'), + padding: const EdgeInsets.all(AppDimens.minePanelPadding), + decoration: BoxDecoration( + color: AppColors.minePanelSurface, + borderRadius: BorderRadius.circular(AppDimens.mineGridRadius), + ), + child: Row( + children: [ + _stat( + key: const Key('mine-stat-following'), + value: followingCount, + label: l10n.followingCountLabel, + l10n: l10n, + ), + _stat( + key: const Key('mine-stat-watch-records'), + value: watchRecordCount, + label: l10n.watchRecordsCountLabel, + l10n: l10n, + ), + _stat( + key: const Key('mine-stat-coins'), + value: coinBalance, + label: l10n.coinBalance, + l10n: l10n, + ), + ], + ), + ); + } + + Widget _stat({ + required Key key, + required int? value, + required String label, + required AppLocalizations l10n, + }) { + return Expanded( + child: Column( + key: key, + children: [ + Text( + value?.toString() ?? l10n.dataUnavailable, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.mineStatsValueFont, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: AppDimens.spaceXs), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.textTertiary, + fontSize: AppDimens.mineStatsLabelFont, + ), + ), + ], + ), + ); + } +} diff --git a/test/home/home_logic_test.dart b/test/home/home_logic_test.dart index 478bdad..0b67f63 100644 --- a/test/home/home_logic_test.dart +++ b/test/home/home_logic_test.dart @@ -166,6 +166,10 @@ void main() { logic.myAction(MyPageAction.following); expect(logic.tab, 2); + logic.openFollowingHistory(); + expect(logic.tab, 2); + expect(logic.followingHistoryRequest, 1); + logic.myAction(MyPageAction.resume); expect(logic.tab, 0); expect(logic.resumeRequest, 1); diff --git a/test/mine/mine_logic_test.dart b/test/mine/mine_logic_test.dart index b6105d8..536fc1a 100644 --- a/test/mine/mine_logic_test.dart +++ b/test/mine/mine_logic_test.dart @@ -27,6 +27,7 @@ void main() { await pumpEventQueue(); expect(logic.recent.single.dramaId, 'signal'); + expect(logic.watchRecordTotal, 1); expect(logic.recentLoading, isFalse); expect(logic.recentError, isNull); logic.dispose(); diff --git a/test/mine/mine_preview_layout_test.dart b/test/mine/mine_preview_layout_test.dart index 2d03568..c09efb5 100644 --- a/test/mine/mine_preview_layout_test.dart +++ b/test/mine/mine_preview_layout_test.dart @@ -3,7 +3,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/models/wallet.dart'; import 'package:fruitshorts/core/data/models/watch_progress.dart'; +import 'package:fruitshorts/core/data/repositories/commerce_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'; @@ -12,6 +14,7 @@ import 'package:fruitshorts/core/theme/app_dimens.dart'; import 'package:fruitshorts/core/theme/app_theme.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/auth/auth_view.dart'; +import 'package:fruitshorts/pages/mine/about_page.dart'; import 'package:fruitshorts/pages/mine/mine_types.dart'; import 'package:fruitshorts/pages/mine/widgets/mine_menu.dart'; import 'package:fruitshorts/pages/store/vip_page.dart'; @@ -40,7 +43,11 @@ void main() { expect(find.byKey(const Key('mine-edit')), findsNothing); expect(find.byKey(const Key('mine-vip')), findsOneWidget); expect(find.byKey(const Key('mine-vip-open')), findsOneWidget); + expect(find.byKey(const Key('mine-continue-watching')), findsNothing); + expect(find.byKey(const Key('mine-stats')), findsOneWidget); + expect(find.text('—'), findsNWidgets(3)); expect(find.byKey(const Key('mine-menu')), findsOneWidget); + expect(find.byKey(const Key('mine-more-services')), findsOneWidget); expect(find.byKey(const Key('mine-mini-player')), findsNothing); final surface = tester.widget( find.byKey(const Key('mine-page-surface')), @@ -192,6 +199,14 @@ void main() { AppColors.vipGoldEnd, ]); expect(vipDecoration.boxShadow, isNull); + final order = [ + tester.getTopLeft(find.byKey(const Key('mine-user-row'))).dy, + tester.getTopLeft(find.byKey(const Key('mine-vip'))).dy, + tester.getTopLeft(find.byKey(const Key('mine-stats'))).dy, + tester.getTopLeft(find.byKey(const Key('mine-menu'))).dy, + tester.getTopLeft(find.byKey(const Key('mine-more-services'))).dy, + ]; + expect(order, [...order]..sort()); expect(tester.takeException(), isNull); await tester.tap(find.byKey(const Key('mine-login'))); @@ -215,6 +230,33 @@ void main() { expect(find.byKey(const Key('mine-login')), findsNothing); expect(find.byKey(const Key('mine-mini-player')), findsNothing); expect(find.byType(FloatingActionButton), findsNothing); + expect(find.byKey(const Key('mine-continue-watching')), findsOneWidget); + expect(find.byKey(const Key('mine-continue-all')), findsOneWidget); + expect(find.text('Recent drama'), findsOneWidget); + expect(find.text('Episode 3'), findsWidgets); + + String statValue(Key key) { + return tester + .widget( + find + .descendant(of: find.byKey(key), matching: find.byType(Text)) + .first, + ) + .data!; + } + + expect(statValue(const Key('mine-stat-following')), '3'); + expect(statValue(const Key('mine-stat-watch-records')), '1'); + expect(statValue(const Key('mine-stat-coins')), '320'); + final signedInOrder = [ + tester.getTopLeft(find.byKey(const Key('mine-user-row'))).dy, + tester.getTopLeft(find.byKey(const Key('mine-vip'))).dy, + tester.getTopLeft(find.byKey(const Key('mine-continue-watching'))).dy, + tester.getTopLeft(find.byKey(const Key('mine-stats'))).dy, + tester.getTopLeft(find.byKey(const Key('mine-menu'))).dy, + tester.getTopLeft(find.byKey(const Key('mine-more-services'))).dy, + ]; + expect(signedInOrder, [...signedInOrder]..sort()); await tester.tap(find.byKey(const Key('mine-edit'))); await tester.pumpAndSettle(); @@ -228,6 +270,48 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('should_open_more_services_and_about_page', (tester) async { + await _pumpApp(tester, signedIn: false); + await _openMine(tester); + + await tester.tap(find.byKey(const Key('mine-common-questions'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('mine-placeholder-page')), findsOneWidget); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('mine-about'))); + await tester.pumpAndSettle(); + expect(find.byType(MineAboutPage), findsOneWidget); + expect(find.byKey(const Key('mine-about-page')), findsOneWidget); + expect(find.text('Version 1.0.0'), findsOneWidget); + expect( + find.text( + AppLocalizations.of( + tester.element(find.byType(MineAboutPage)), + )!.aboutDescription, + ), + findsOneWidget, + ); + }); + + testWidgets('should_open_history_tab_from_continue_watching_all', ( + tester, + ) async { + await _pumpApp( + tester, + signedIn: true, + history: InMemoryWatchHistoryRepository(seed: [_recent]), + ); + await _openMine(tester); + + await tester.tap(find.byKey(const Key('mine-continue-all'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('following-page')), findsOneWidget); + expect(find.byKey(const Key('following-history-grid')), findsOneWidget); + }); + testWidgets('should_open_vip_from_mine_without_floating_player', ( tester, ) async { @@ -313,6 +397,7 @@ Future _pumpApp( WidgetTester tester, { required bool signedIn, WatchHistoryRepository? history, + CommerceRepository? commerce, }) async { tester.view.physicalSize = const Size(390, 1000); tester.view.devicePixelRatio = 1; @@ -337,7 +422,34 @@ Future _pumpApp( commentRepository: FakeCommentRepository(), interactionRepository: FakeInteractionRepository(), noticeRepository: FakeNoticeRepository(), + commerceRepository: commerce ?? const _MineWalletCommerceRepository(), ), ); await tester.pumpAndSettle(); } + +class _MineWalletCommerceRepository implements CommerceRepository { + const _MineWalletCommerceRepository(); + + @override + Future fetchWallet() async { + return const WalletSnapshot( + wallet: CoinWallet( + coinBalance: 320, + totalRechargedCoin: 500, + totalConsumedCoin: 180, + totalRefundedCoin: 0, + ), + ); + } + + @override + Future unlock(String episodeId) async { + return const UnlockResult( + unlocked: true, + accessType: 'UNLOCKED', + coinCost: 0, + coinBalance: 320, + ); + } +} From c604cc133366897e9339fc7f829827d8aa27213a Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 21:46:49 +0800 Subject: [PATCH 101/129] =?UTF-8?q?style:=20=E6=94=B6=E7=B4=A7=E6=88=91?= =?UTF-8?q?=E7=9A=84=E7=94=A8=E6=88=B7=E5=8C=BA=E4=BF=A1=E6=81=AF=E9=97=B4?= =?UTF-8?q?=E8=B7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 1 + lib/core/theme/app_dimens.dart | 4 +++- lib/pages/mine/widgets/mine_header.dart | 14 ++++++++------ 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index dd2061b..25b94cb 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1268,3 +1268,4 @@ $ flutter test - VIP 与宫格之间新增继续观看横向列表、追剧/观看记录/金币统计面板,继续观看最多 10 条。 - 宫格后新增常见问题与关于我们入口;关于页版本暂用与 pubspec 同步的常量占位。 - 继续观看全部入口可切到追剧页播放历史 tab;`flutter analyze`:No issues found。 +- 用户区昵称与 UID 收紧为 4 间距,昵称列相对头像垂直居中;UID 12、复制图标 14。 diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 557c3e9..fad5c53 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -182,7 +182,9 @@ abstract final class AppDimens { static const double mineSectionTitle = 15; static const double mineAvatar = 56; static const double mineUserName = 20; - static const double mineUid = 13; + static const double mineUserNameLineHeight = 1.2; + static const double mineUid = 12; + static const double mineCopyIcon = 14; static const double mineGridHorizontal = 14; static const double mineGridRadius = 12; static const double mineGridCellHeight = 96; diff --git a/lib/pages/mine/widgets/mine_header.dart b/lib/pages/mine/widgets/mine_header.dart index 27c28bd..b302cfd 100644 --- a/lib/pages/mine/widgets/mine_header.dart +++ b/lib/pages/mine/widgets/mine_header.dart @@ -15,7 +15,6 @@ import '../../../l10n/app_localizations.dart'; const double _buttonHeight = 34; const double _buttonRadius = 20; -const double _copyIconSize = 15; /// 「我的」页用户区。 class MineHeader extends StatelessWidget { @@ -38,11 +37,13 @@ class MineHeader extends StatelessWidget { key: const Key('mine-user-row'), padding: const EdgeInsets.symmetric(vertical: AppDimens.spaceMd), child: Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ _avatar(user?.avatarUrl ?? ''), const SizedBox(width: AppDimens.spaceMd), Expanded( child: Column( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( @@ -54,6 +55,7 @@ class MineHeader extends StatelessWidget { color: AppColors.textPrimary, fontSize: AppDimens.mineUserName, fontWeight: FontWeight.w700, + height: AppDimens.mineUserNameLineHeight, ), ), const SizedBox(height: AppDimens.spaceXs), @@ -66,7 +68,7 @@ class MineHeader extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - color: AppColors.textSecondary, + color: AppColors.textTertiary, fontSize: AppDimens.mineUid, ), ), @@ -82,14 +84,14 @@ class MineHeader extends StatelessWidget { ), visualDensity: VisualDensity.compact, constraints: const BoxConstraints.tightFor( - width: AppDimens.tapTarget, - height: AppDimens.tapTarget, + width: AppDimens.mineCopyIcon, + height: AppDimens.mineCopyIcon, ), padding: EdgeInsets.zero, icon: const Icon( Icons.copy_rounded, - color: AppColors.textSecondary, - size: _copyIconSize, + color: AppColors.textTertiary, + size: AppDimens.mineCopyIcon, ), ), ], From 19acbb2278ea85c3b2a4d23268cc57e10a134b0f Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 21:49:47 +0800 Subject: [PATCH 102/129] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=E6=88=91?= =?UTF-8?q?=E7=9A=84=20UID=20=E8=A1=8C=E9=AB=98=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 1 + lib/core/theme/app_dimens.dart | 4 +- lib/pages/mine/widgets/mine_header.dart | 72 +++++++++++++------------ test/mine/mine_preview_layout_test.dart | 22 ++++++++ 4 files changed, 65 insertions(+), 34 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 25b94cb..4b7e0c4 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1269,3 +1269,4 @@ $ flutter test - 宫格后新增常见问题与关于我们入口;关于页版本暂用与 pubspec 同步的常量占位。 - 继续观看全部入口可切到追剧页播放历史 tab;`flutter analyze`:No issues found。 - 用户区昵称与 UID 收紧为 4 间距,昵称列相对头像垂直居中;UID 12、复制图标 14。 +- UID 行改用 GestureDetector 并锁定 16 高,昵称与 UID 间距 2、昵称行高 1.15,避免 Material 热区撑高;`flutter analyze`:No issues found。 diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index fad5c53..5ab2367 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -182,8 +182,10 @@ abstract final class AppDimens { static const double mineSectionTitle = 15; static const double mineAvatar = 56; static const double mineUserName = 20; - static const double mineUserNameLineHeight = 1.2; + static const double mineUserNameLineHeight = 1.15; + static const double mineUserNameUidGap = 2; static const double mineUid = 12; + static const double mineUidRowHeight = 16; static const double mineCopyIcon = 14; static const double mineGridHorizontal = 14; static const double mineGridRadius = 12; diff --git a/lib/pages/mine/widgets/mine_header.dart b/lib/pages/mine/widgets/mine_header.dart index b302cfd..a73dde6 100644 --- a/lib/pages/mine/widgets/mine_header.dart +++ b/lib/pages/mine/widgets/mine_header.dart @@ -48,6 +48,7 @@ class MineHeader extends StatelessWidget { children: [ Text( nickname, + key: const Key('mine-nickname'), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( @@ -58,43 +59,48 @@ class MineHeader extends StatelessWidget { height: AppDimens.mineUserNameLineHeight, ), ), - const SizedBox(height: AppDimens.spaceXs), - Row( - children: [ - Flexible( - child: Text( - '${l10n.accountUid}: ${uid.isEmpty ? '--' : uid}', - key: const Key('mine-uid'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.textTertiary, - fontSize: AppDimens.mineUid, + const SizedBox(height: AppDimens.mineUserNameUidGap), + SizedBox( + key: const Key('mine-uid'), + height: AppDimens.mineUidRowHeight, + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Flexible( + child: Text( + '${l10n.accountUid}: ${uid.isEmpty ? '--' : uid}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textTertiary, + fontSize: AppDimens.mineUid, + ), ), ), - ), - const SizedBox(width: AppDimens.spaceXs), - IconButton( - key: const Key('mine-uid-copy'), - tooltip: l10n.copyUid, - onPressed: uid.isEmpty - ? null - : () => unawaited( - _copyUid(context, uid, l10n.uidCopied), + Semantics( + button: true, + label: l10n.copyUid, + child: GestureDetector( + key: const Key('mine-uid-copy'), + behavior: HitTestBehavior.opaque, + onTap: uid.isEmpty + ? null + : () => unawaited( + _copyUid(context, uid, l10n.uidCopied), + ), + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 4), + child: Icon( + Icons.copy_rounded, + color: AppColors.textTertiary, + size: AppDimens.mineCopyIcon, ), - visualDensity: VisualDensity.compact, - constraints: const BoxConstraints.tightFor( - width: AppDimens.mineCopyIcon, - height: AppDimens.mineCopyIcon, + ), + ), ), - padding: EdgeInsets.zero, - icon: const Icon( - Icons.copy_rounded, - color: AppColors.textTertiary, - size: AppDimens.mineCopyIcon, - ), - ), - ], + ], + ), ), ], ), diff --git a/test/mine/mine_preview_layout_test.dart b/test/mine/mine_preview_layout_test.dart index c09efb5..53cebb9 100644 --- a/test/mine/mine_preview_layout_test.dart +++ b/test/mine/mine_preview_layout_test.dart @@ -39,6 +39,28 @@ void main() { expect(find.text('Visitor'), findsOneWidget); expect(find.text('UID: --'), findsOneWidget); expect(find.byKey(const Key('mine-uid-copy')), findsOneWidget); + final uidRowSize = tester.getSize(find.byKey(const Key('mine-uid'))); + expect(uidRowSize.height, closeTo(AppDimens.mineUidRowHeight, 0.1)); + final nicknameRect = tester.getRect(find.byKey(const Key('mine-nickname'))); + final uidRect = tester.getRect(find.byKey(const Key('mine-uid'))); + expect(uidRect.top - nicknameRect.bottom, lessThanOrEqualTo(6)); + final nickname = tester.widget( + find.byKey(const Key('mine-nickname')), + ); + expect(nickname.style?.fontSize, AppDimens.mineUserName); + expect(nickname.style?.fontWeight, FontWeight.w700); + expect(nickname.style?.height, AppDimens.mineUserNameLineHeight); + final uid = tester.widget(find.text('UID: --')); + expect(uid.style?.fontSize, AppDimens.mineUid); + expect(uid.style?.color, AppColors.textTertiary); + final copyIcon = tester.widget( + find.descendant( + of: find.byKey(const Key('mine-uid-copy')), + matching: find.byType(Icon), + ), + ); + expect(copyIcon.size, AppDimens.mineCopyIcon); + expect(copyIcon.color, AppColors.textTertiary); expect(find.byKey(const Key('mine-login')), findsOneWidget); expect(find.byKey(const Key('mine-edit')), findsNothing); expect(find.byKey(const Key('mine-vip')), findsOneWidget); From 604ab60b633b600a9b5b9c8bd1dd3a30cfff4139 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 21:52:13 +0800 Subject: [PATCH 103/129] =?UTF-8?q?style:=20=E5=A2=9E=E5=8A=A0=E6=8E=A8?= =?UTF-8?q?=E8=8D=90=E5=89=A7=E5=8D=95=E6=B8=90=E5=8F=98=E8=83=8C=E6=99=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 5 ++ lib/core/theme/app_colors.dart | 1 + lib/pages/recommend/recommend_page.dart | 53 +++++++++++++++++---- test/recommend/recommend_sections_test.dart | 25 ++++++++++ 4 files changed, 76 insertions(+), 8 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 4b7e0c4..e95abe9 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1270,3 +1270,8 @@ $ flutter test - 继续观看全部入口可切到追剧页播放历史 tab;`flutter analyze`:No issues found。 - 用户区昵称与 UID 收紧为 4 间距,昵称列相对头像垂直居中;UID 12、复制图标 14。 - UID 行改用 GestureDetector 并锁定 16 高,昵称与 UID 间距 2、昵称行高 1.15,避免 Material 热区撑高;`flutter analyze`:No issues found。 + +## 2026-10-22 推荐剧单渐变背景 + +- 仅为推荐页「剧单」子页增加暖黑三段渐变和品牌橙径向光晕,推荐视频与全部短剧背景不变。 +- 渐变复用「我的」页色值,光晕使用 `#26FF6B2C`;分区与卡片样式保持原样;`flutter analyze`:No issues found。 diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index ae58709..87d9b92 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -101,6 +101,7 @@ abstract final class AppColors { static const mineIconSupport = Color(0xFFA78BFA); static const mineIconLanguage = Color(0xFF22D3EE); static const mineIconSettings = Color(0xFFA1A1AA); + static const recommendPlaylistGlow = Color(0x26FF6B2C); /// 次级文字与列表 chevron。 static const textPrimary = Color(0xFFFFFFFF); diff --git a/lib/pages/recommend/recommend_page.dart b/lib/pages/recommend/recommend_page.dart index 3734263..81527fc 100644 --- a/lib/pages/recommend/recommend_page.dart +++ b/lib/pages/recommend/recommend_page.dart @@ -68,14 +68,7 @@ class _RecommendPageState extends State { ), _KeepAliveTab( key: const PageStorageKey('recommend-tab-playlists'), - child: _withTopNavSpace( - RecommendSectionsView( - feedController: widget.feedController, - categories: categories, - onOpenDrama: _openDrama, - onMore: _handleMore, - ), - ), + child: _recommendPlaylists(categories), ), _KeepAliveTab( key: const PageStorageKey('recommend-tab-all'), @@ -173,6 +166,50 @@ class _RecommendPageState extends State { ); } + Widget _recommendPlaylists(CategoryController categories) { + return DecoratedBox( + key: const Key('recommend-playlist-surface'), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.mineGradientTop, + AppColors.mineGradientMiddle, + AppColors.mineGradientBottom, + ], + stops: [0, 0.4, 0.75], + ), + ), + child: Stack( + fit: StackFit.expand, + children: [ + const DecoratedBox( + key: Key('recommend-playlist-glow'), + decoration: BoxDecoration( + gradient: RadialGradient( + center: Alignment(0, -0.95), + radius: 0.9, + colors: [ + AppColors.recommendPlaylistGlow, + AppColors.transparent, + ], + ), + ), + ), + _withTopNavSpace( + RecommendSectionsView( + feedController: widget.feedController, + categories: categories, + onOpenDrama: _openDrama, + onMore: _handleMore, + ), + ), + ], + ), + ); + } + void _selectTab(RecommendInnerTab tab) { setState(() { _tab = tab; diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart index 8acd660..54b4997 100644 --- a/test/recommend/recommend_sections_test.dart +++ b/test/recommend/recommend_sections_test.dart @@ -143,6 +143,31 @@ void main() { expect(find.text('New This Week'), findsOneWidget); expect(find.text('Costume Drama Awards'), findsOneWidget); expect(find.byType(AppBottomNav), findsOneWidget); + final playlistSurface = tester.widget( + find.byKey(const Key('recommend-playlist-surface')), + ); + final playlistGradient = + (playlistSurface.decoration as BoxDecoration).gradient! + as LinearGradient; + expect(playlistGradient.begin, Alignment.topCenter); + expect(playlistGradient.end, Alignment.bottomCenter); + expect(playlistGradient.colors, const [ + AppColors.mineGradientTop, + AppColors.mineGradientMiddle, + AppColors.mineGradientBottom, + ]); + expect(playlistGradient.stops, const [0, 0.4, 0.75]); + final playlistGlow = tester.widget( + find.byKey(const Key('recommend-playlist-glow')), + ); + final glowGradient = + (playlistGlow.decoration as BoxDecoration).gradient! as RadialGradient; + expect(glowGradient.center, const Alignment(0, -0.95)); + expect(glowGradient.radius, 0.9); + expect(glowGradient.colors, const [ + AppColors.recommendPlaylistGlow, + AppColors.transparent, + ]); final sectionTitle = tester.widget(find.text("This Week's Hits")); expect(sectionTitle.style?.fontSize, AppDimens.recommendSectionTitle); expect(sectionTitle.style?.fontWeight, FontWeight.w700); From 37e35f4b4767839b430d70c3eb04d3d77565fb7a Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 22:01:36 +0800 Subject: [PATCH 104/129] =?UTF-8?q?style:=20=E7=BB=9F=E4=B8=80=E5=85=A8?= =?UTF-8?q?=E7=AB=99=E6=B7=B1=E8=89=B2=E8=A7=86=E8=A7=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 7 ++++ lib/core/theme/app_colors.dart | 32 ++++++++--------- lib/core/theme/app_theme.dart | 24 +++++++------ lib/core/widgets/plain_page_scaffold.dart | 26 ++++++++------ lib/core/widgets/subpage_header.dart | 36 ++++++++----------- lib/pages/auth/auth_widgets.dart | 2 +- lib/pages/downloads/downloads_page.dart | 7 ++-- .../widgets/drama_detail_header.dart | 9 ++--- .../widgets/drama_detail_sections.dart | 4 ++- .../widgets/drama_episode_states.dart | 4 ++- lib/pages/favorites/favorites_page.dart | 5 +-- lib/pages/filter/filter_page.dart | 1 + lib/pages/new_releases/new_releases_page.dart | 17 ++------- lib/pages/notices/notices_page.dart | 5 ++- lib/pages/ranking/ranking_page.dart | 8 ++--- lib/pages/reserve/reserve_page.dart | 4 ++- lib/pages/settings/settings_page.dart | 6 +++- lib/pages/store/store_page.dart | 4 +-- lib/pages/wallet/wallet_page.dart | 17 ++------- .../widgets/watch_record_card.dart | 8 ++--- test/core/subpage_header_test.dart | 10 +++--- test/drama_detail/drama_detail_view_test.dart | 3 +- test/theme/app_theme_test.dart | 23 ++++++++---- test/theme/dark_theme_smoke_test.dart | 2 +- 24 files changed, 134 insertions(+), 130 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index e95abe9..4d8d39f 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1275,3 +1275,10 @@ $ flutter test - 仅为推荐页「剧单」子页增加暖黑三段渐变和品牌橙径向光晕,推荐视频与全部短剧背景不变。 - 渐变复用「我的」页色值,光晕使用 `#26FF6B2C`;分区与卡片样式保持原样;`flutter analyze`:No issues found。 + +## 2026-10-22 全站深色视觉统一 + +- 全局 token 改为纯黑页面、`#16161A` 卡片、`#1C1C1E` 描边/分割线与 B0/8E 文本层级,旧深蓝页面渐变替换为暖黑渐变。 +- 普通二级页标题栏改为透明、白色标题与返回图标;统一卡片 12 圆角、1px 描边和无阴影策略。 +- 覆盖新剧、筛选、榜单、预约、收藏、观看历史、下载、设置、钱包、VIP、通知、详情、登录、关于;播放器沉浸区保持纯黑。 +- `flutter analyze`:No issues found。 diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 87d9b92..41966ee 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -35,8 +35,8 @@ abstract final class AppColors { /// 主操作按下态。 static const bluePressed = Color(0xFFE55A1F); - /// 链接与次要高亮(禁止大面积铺色)。 - static const blueSoft = Color(0xFF22D3EE); + /// 链接与次要高亮:统一改用中性浅灰,避免旧青色残留。 + static const blueSoft = Color(0xFFB0B0B0); static const secondary = blueSoft; static const lavender = blueSoft; @@ -67,13 +67,13 @@ abstract final class AppColors { static const onPrimary = Color(0xFFFFFFFF); /// 页面背景。 - static const ink = Color(0xFF0B1020); + static const ink = Color(0xFF000000); /// 首页/追剧/我的统一的纯黑内容背景。 static const appBackground = Color(0xFF000000); /// 中性深灰卡片与控件表面。 - static const contentCard = Color(0xFF171717); + static const contentCard = Color(0xFF16161A); static const contentSurface = Color(0xFF1C1C1E); static const contentChip = Color(0xFF2C2C2E); static const contentBorder = Color(0x1AFFFFFF); @@ -105,16 +105,16 @@ abstract final class AppColors { /// 次级文字与列表 chevron。 static const textPrimary = Color(0xFFFFFFFF); - static const textSecondary = Color(0xFF8E8E93); - static const textTertiary = Color(0xFF9E9E9E); + static const textSecondary = Color(0xFFB0B0B0); + static const textTertiary = Color(0xFF8E8E93); static const textTabInactive = Color(0xFFB0B0B0); static const textFilterIcon = Color(0xFF7A7A7E); /// 原生开屏与冷启动过渡底色。 static const splashInk = ink; - /// 顶部与页面底同色,靠容器层次区分。 - static const surfaceTop = ink; + /// 页面顶部暖黑色,渐变底部仍收口到纯黑。 + static const surfaceTop = mineGradientTop; /// 底部导航底色。 static const bar = ink; @@ -127,13 +127,13 @@ abstract final class AppColors { static const cardShadow = Color(0x00000000); /// 卡片与列表容器。 - static const card = Color(0xFF151B2E); + static const card = Color(0xFF16161A); /// 次要卡片表面。 - static const cardAlt = card; + static const cardAlt = Color(0xFF101010); /// 浮层、弹窗、输入与嵌套区域。 - static const raised = Color(0xFF1C2436); + static const raised = Color(0xFF1C1C1E); static const quick = raised; static const input = raised; @@ -156,13 +156,13 @@ abstract final class AppColors { /// 橙色强调文案。 static const accent = blue; - /// 分割线 rgba(255,255,255,0.06)。 - static const border = Color(0x0FFFFFFF); + /// 统一卡片描边、分割线与输入框边框。 + static const border = Color(0xFF1C1C1E); /// 玻璃面板描边。 static const glassBorder = Color(0x33FFFFFF); - static const glassSurfaceStart = Color(0xEB1C2436); - static const glassSurfaceEnd = Color(0xD1151B2E); + static const glassSurfaceStart = Color(0xEB16161A); + static const glassSurfaceEnd = Color(0xD1101010); /// 角标底与文字。 static const badgePink = Color(0x33FF6A8A); @@ -194,7 +194,7 @@ abstract final class AppColors { static const inactiveTrack = Color(0x24FFFFFF); /// 横幅/封面底部渐变。 - static const bannerScrimMid = Color(0x8A0B1020); + static const bannerScrimMid = Color(0x8A1A0C07); static const bannerScrimEnd = Color(0xE6000000); /// 票券虚线等主色描边。 diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index 8cc1e5c..0f1789a 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -8,12 +8,16 @@ import 'app_dimens.dart'; /// 深色视觉主题(默认且唯一)。 abstract final class AppTheme { - /// 页面背景:深色扁平,靠容器层次区分。 + /// 页面背景:顶部暖黑、底部纯黑,避免旧深蓝渐变。 static const LinearGradient pageGradient = LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [AppColors.surfaceTop, AppColors.ink], - stops: [0, 0.24], + colors: [ + AppColors.mineGradientTop, + AppColors.mineGradientMiddle, + AppColors.mineGradientBottom, + ], + stops: [0, 0.4, 0.75], ); /// 卡片阴影关闭,改用底色与分割线。 @@ -76,7 +80,7 @@ abstract final class AppTheme { ), textButtonTheme: TextButtonThemeData( style: TextButton.styleFrom( - foregroundColor: AppColors.secondary, + foregroundColor: AppColors.primary, shape: RoundedRectangleBorder(borderRadius: _buttonRadius), ), ), @@ -107,12 +111,12 @@ abstract final class AppTheme { ), outlinedButtonTheme: OutlinedButtonThemeData( style: ButtonStyle( - foregroundColor: const WidgetStatePropertyAll(AppColors.blue), + foregroundColor: const WidgetStatePropertyAll(AppColors.textPrimary), minimumSize: const WidgetStatePropertyAll( Size(0, AppDimens.buttonHeight), ), side: const WidgetStatePropertyAll( - BorderSide(color: AppColors.border), + BorderSide(color: AppColors.mineOutline), ), shape: WidgetStatePropertyAll( RoundedRectangleBorder(borderRadius: _buttonRadius), @@ -148,20 +152,20 @@ abstract final class AppTheme { ), ), appBarTheme: const AppBarTheme( - backgroundColor: AppColors.card, - foregroundColor: AppColors.onSurface, + backgroundColor: AppColors.transparent, + foregroundColor: AppColors.textPrimary, elevation: 0, scrolledUnderElevation: 0, surfaceTintColor: AppColors.transparent, systemOverlayStyle: SystemUiOverlayStyle.light, titleTextStyle: TextStyle( - color: AppColors.onSurface, + color: AppColors.textPrimary, fontFamily: 'Outfit', fontSize: AppDimens.pageTitle, fontWeight: FontWeight.w800, ), ), - iconTheme: const IconThemeData(color: AppColors.onSurface), + iconTheme: const IconThemeData(color: AppColors.textPrimary), dividerTheme: const DividerThemeData( color: AppColors.border, thickness: 1, diff --git a/lib/core/widgets/plain_page_scaffold.dart b/lib/core/widgets/plain_page_scaffold.dart index 8e5c07f..29c83e4 100644 --- a/lib/core/widgets/plain_page_scaffold.dart +++ b/lib/core/widgets/plain_page_scaffold.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../theme/app_colors.dart'; +import '../theme/app_theme.dart'; import 'subpage_header.dart'; /// 二级页面通用骨架:返回栏 + 标题/副标题 + 可滚动内容,无底部导航。 @@ -27,17 +28,20 @@ class PlainPageScaffold extends StatelessWidget { return Scaffold( key: pageKey, backgroundColor: AppColors.ink, - body: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AppSubpageHeader( - title: title, - subtitle: subtitle, - backKey: backKey, - ), - Expanded(child: child), - ], + body: DecoratedBox( + decoration: const BoxDecoration(gradient: AppTheme.pageGradient), + child: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppSubpageHeader( + title: title, + subtitle: subtitle, + backKey: backKey, + ), + Expanded(child: child), + ], + ), ), ), ); diff --git a/lib/core/widgets/subpage_header.dart b/lib/core/widgets/subpage_header.dart index 0c57432..1f19d0d 100644 --- a/lib/core/widgets/subpage_header.dart +++ b/lib/core/widgets/subpage_header.dart @@ -14,9 +14,7 @@ const double _subtitleFontSize = 12; const double _actionGap = 8; const double _iconSize = 22; const double _iconWeight = 600; -const double _borderWidth = 1; const double _shadowBlur = 8; -const Offset _lightShadowOffset = Offset(0, 2); /// 二级页面统一圆形操作按钮,返回、分享和更多共用同一尺寸与表面样式。 class AppSubpageCircleButton extends StatelessWidget { @@ -47,24 +45,18 @@ class AppSubpageCircleButton extends StatelessWidget { key: const Key('subpage-back-shadow'), decoration: BoxDecoration( shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: dark ? AppColors.shadowSoft : AppColors.shadowSubtle, - blurRadius: _shadowBlur, - offset: dark ? Offset.zero : _lightShadowOffset, - ), - ], + boxShadow: dark + ? const [ + BoxShadow( + color: AppColors.shadowSoft, + blurRadius: _shadowBlur, + ), + ] + : const [], ), child: Material( - color: dark ? AppColors.headerScrim : AppColors.card, - shape: CircleBorder( - side: dark - ? BorderSide.none - : const BorderSide( - color: AppColors.border, - width: _borderWidth, - ), - ), + color: dark ? AppColors.headerScrim : AppColors.transparent, + shape: const CircleBorder(), clipBehavior: Clip.antiAlias, child: InkWell( customBorder: const CircleBorder(), @@ -72,7 +64,7 @@ class AppSubpageCircleButton extends StatelessWidget { child: Center( child: Icon( icon, - color: AppColors.onSurface, + color: dark ? AppColors.onSurface : AppColors.textPrimary, size: _iconSize, weight: _iconWeight, ), @@ -85,7 +77,7 @@ class AppSubpageCircleButton extends StatelessWidget { } } -/// 二级页面统一返回按钮,浅色页使用白底描边,深色页使用半透明黑底。 +/// 二级页面统一返回按钮,普通页透明,沉浸页使用半透明黑底。 class AppSubpageBackButton extends StatelessWidget { const AppSubpageBackButton({ super.key, @@ -133,8 +125,8 @@ class AppSubpageHeader extends StatelessWidget { @override Widget build(BuildContext context) { - final foreground = AppColors.onSurface; - final secondary = AppColors.muted; + final foreground = AppColors.textPrimary; + final secondary = AppColors.textSecondary; return SizedBox( key: const Key('subpage-header'), height: subtitle == null ? _height : _heightWithSubtitle, diff --git a/lib/pages/auth/auth_widgets.dart b/lib/pages/auth/auth_widgets.dart index 55b160e..aaf377a 100644 --- a/lib/pages/auth/auth_widgets.dart +++ b/lib/pages/auth/auth_widgets.dart @@ -76,7 +76,7 @@ class _AuthCard extends StatelessWidget { decoration: BoxDecoration( color: AppColors.card, borderRadius: BorderRadius.circular(_cardRadius), - boxShadow: const [AppTheme.cardShadow], + border: Border.all(color: AppColors.border), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, diff --git a/lib/pages/downloads/downloads_page.dart b/lib/pages/downloads/downloads_page.dart index 4be91fc..e6ada06 100644 --- a/lib/pages/downloads/downloads_page.dart +++ b/lib/pages/downloads/downloads_page.dart @@ -227,8 +227,8 @@ class _PolicyPanel extends StatelessWidget { padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), decoration: BoxDecoration( color: AppColors.card, - borderRadius: BorderRadius.circular(AppDimens.innerRadius), - border: Border.all(color: AppColors.outlineVariantDark), + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + border: Border.all(color: AppColors.border), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -362,7 +362,8 @@ class _LicenseTile extends StatelessWidget { padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: AppColors.card, - borderRadius: BorderRadius.circular(AppDimens.innerRadius), + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + border: Border.all(color: AppColors.border), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/pages/drama_detail/widgets/drama_detail_header.dart b/lib/pages/drama_detail/widgets/drama_detail_header.dart index d1da929..5c27728 100644 --- a/lib/pages/drama_detail/widgets/drama_detail_header.dart +++ b/lib/pages/drama_detail/widgets/drama_detail_header.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import '../../../core/data/models/drama.dart'; import '../../../core/formatting/localized_content.dart'; import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_theme.dart'; import '../../../core/widgets/remote_image.dart'; import '../../../core/widgets/subpage_header.dart'; import '../../../l10n/app_localizations.dart'; @@ -53,13 +54,7 @@ class DramaDetailHeader extends StatelessWidget { Widget build(BuildContext context) { return Container( key: const Key('detail-hero'), - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [AppColors.surfaceTop, AppColors.ink], - ), - ), + decoration: const BoxDecoration(gradient: AppTheme.pageGradient), child: SafeArea( bottom: false, child: Padding( diff --git a/lib/pages/drama_detail/widgets/drama_detail_sections.dart b/lib/pages/drama_detail/widgets/drama_detail_sections.dart index 1db22b8..cd79043 100644 --- a/lib/pages/drama_detail/widgets/drama_detail_sections.dart +++ b/lib/pages/drama_detail/widgets/drama_detail_sections.dart @@ -5,12 +5,13 @@ import 'package:flutter/material.dart'; import '../../../core/data/models/drama.dart'; import '../../../core/formatting/count_format.dart'; import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; import '../../../l10n/app_localizations.dart'; const double _horizontal = 14; const double _sectionTop = 16; const double _statsVertical = 12; -const double _statsRadius = 14; +const double _statsRadius = AppDimens.cardRadius; const double _dividerHeight = 28; const int _synopsisLines = 3; const int _gridColumns = 6; @@ -63,6 +64,7 @@ class DramaStatsCard extends StatelessWidget { decoration: BoxDecoration( color: AppColors.card, borderRadius: BorderRadius.circular(_statsRadius), + border: Border.all(color: AppColors.border), ), child: Row( children: [ diff --git a/lib/pages/drama_detail/widgets/drama_episode_states.dart b/lib/pages/drama_detail/widgets/drama_episode_states.dart index de7787d..39db405 100644 --- a/lib/pages/drama_detail/widgets/drama_episode_states.dart +++ b/lib/pages/drama_detail/widgets/drama_episode_states.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import '../../../core/formatting/error_text.dart'; import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; import '../../../l10n/app_localizations.dart'; const int _skeletonCount = 6; @@ -138,7 +139,8 @@ class DramaEpisodesLoadError extends StatelessWidget { padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: AppColors.card, - borderRadius: BorderRadius.circular(14), + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + border: Border.all(color: AppColors.border), ), child: Column( children: [ diff --git a/lib/pages/favorites/favorites_page.dart b/lib/pages/favorites/favorites_page.dart index 5452ec1..e751b5b 100644 --- a/lib/pages/favorites/favorites_page.dart +++ b/lib/pages/favorites/favorites_page.dart @@ -12,6 +12,7 @@ import '../../core/formatting/count_format.dart'; import '../../core/formatting/error_text.dart'; import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/auth_gate.dart'; @@ -24,7 +25,7 @@ import '../player/open_player.dart'; import 'favorites_binding.dart'; const double _pageHorizontal = 14; -const double _cardRadius = 14; +const double _cardRadius = AppDimens.cardRadius; const double _cardPadding = 10; const double _coverWidth = 80; const double _coverHeight = 106; @@ -273,7 +274,7 @@ class _FavoriteCard extends StatelessWidget { decoration: BoxDecoration( color: AppColors.card, borderRadius: BorderRadius.circular(_cardRadius), - boxShadow: const [AppTheme.cardShadow], + border: Border.all(color: AppColors.border), ), child: Material( color: AppColors.transparent, diff --git a/lib/pages/filter/filter_page.dart b/lib/pages/filter/filter_page.dart index 43dc6dc..d1f62d0 100644 --- a/lib/pages/filter/filter_page.dart +++ b/lib/pages/filter/filter_page.dart @@ -255,6 +255,7 @@ class _FilterPageState extends State { decoration: BoxDecoration( color: AppColors.card, borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.border), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/pages/new_releases/new_releases_page.dart b/lib/pages/new_releases/new_releases_page.dart index 12351e3..3de4c34 100644 --- a/lib/pages/new_releases/new_releases_page.dart +++ b/lib/pages/new_releases/new_releases_page.dart @@ -10,6 +10,7 @@ import '../../core/data/models/drama.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../core/formatting/count_format.dart'; import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/paged_local_list.dart'; import '../../core/widgets/remote_image.dart'; @@ -107,13 +108,7 @@ class _NewReleasesPageState extends State { key: const Key('new-releases-page'), backgroundColor: AppColors.ink, body: DecoratedBox( - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [AppColors.surfaceTop, AppColors.ink], - ), - ), + decoration: const BoxDecoration(gradient: AppTheme.pageGradient), child: SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -407,13 +402,7 @@ class _NewReleasesPageState extends State { decoration: BoxDecoration( color: AppColors.card, borderRadius: BorderRadius.circular(_cardRadius), - boxShadow: const [ - BoxShadow( - color: AppColors.cardShadow, - blurRadius: 12, - offset: Offset(0, 4), - ), - ], + border: Border.all(color: AppColors.border), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/pages/notices/notices_page.dart b/lib/pages/notices/notices_page.dart index 4ed3619..f9df1fd 100644 --- a/lib/pages/notices/notices_page.dart +++ b/lib/pages/notices/notices_page.dart @@ -213,7 +213,10 @@ class _NoticeTile extends StatelessWidget { return Material( key: ValueKey('notice-item-${notice.id}'), color: AppColors.card, - borderRadius: BorderRadius.circular(AppDimens.innerRadius), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + side: const BorderSide(color: AppColors.border), + ), clipBehavior: Clip.antiAlias, child: InkWell( onTap: onTap, diff --git a/lib/pages/ranking/ranking_page.dart b/lib/pages/ranking/ranking_page.dart index 615e9b3..749001a 100644 --- a/lib/pages/ranking/ranking_page.dart +++ b/lib/pages/ranking/ranking_page.dart @@ -9,7 +9,7 @@ import '../../core/data/models/drama.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../core/formatting/count_format.dart'; import '../../core/theme/app_colors.dart'; -import '../../core/theme/app_theme.dart'; +import '../../core/theme/app_dimens.dart'; import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/empty_state.dart'; import '../../core/widgets/paged_local_list.dart'; @@ -341,12 +341,12 @@ class _RankingRow extends StatelessWidget { return Container( decoration: BoxDecoration( color: AppColors.card, - borderRadius: BorderRadius.circular(14), - boxShadow: const [AppTheme.cardShadow], + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + border: Border.all(color: AppColors.border), ), child: Material( color: AppColors.transparent, - borderRadius: BorderRadius.circular(14), + borderRadius: BorderRadius.circular(AppDimens.cardRadius), clipBehavior: Clip.antiAlias, child: InkWell( key: Key('ranking-row-$index'), diff --git a/lib/pages/reserve/reserve_page.dart b/lib/pages/reserve/reserve_page.dart index b6bd7c7..d5e1385 100644 --- a/lib/pages/reserve/reserve_page.dart +++ b/lib/pages/reserve/reserve_page.dart @@ -5,12 +5,13 @@ import 'package:provider/provider.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_dimens.dart'; import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/subpage_header.dart'; import '../../l10n/app_localizations.dart'; import '../new_releases/new_releases_page.dart'; -const double _ticketRadius = 20; +const double _ticketRadius = AppDimens.cardRadius; const double _notchSize = 24; const Color _ticketDash = AppColors.primaryBorder; @@ -163,6 +164,7 @@ class _ReservePageState extends State { decoration: BoxDecoration( color: AppColors.card, borderRadius: BorderRadius.circular(_ticketRadius), + border: Border.all(color: AppColors.border), ), child: Column( children: [ diff --git a/lib/pages/settings/settings_page.dart b/lib/pages/settings/settings_page.dart index 180ca52..ed5b20b 100644 --- a/lib/pages/settings/settings_page.dart +++ b/lib/pages/settings/settings_page.dart @@ -7,6 +7,7 @@ import 'package:provider/provider.dart'; import '../../core/data/services/auth_controller.dart'; import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/plain_page_scaffold.dart'; @@ -47,7 +48,10 @@ class SettingsPage extends StatelessWidget { Widget _noticesTile(BuildContext context, AppLocalizations l10n) { return Material( color: AppColors.card, - borderRadius: BorderRadius.circular(14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + side: const BorderSide(color: AppColors.border), + ), clipBehavior: Clip.antiAlias, child: ListTile( key: const Key('settings-notices'), diff --git a/lib/pages/store/store_page.dart b/lib/pages/store/store_page.dart index 63bb10a..3c00467 100644 --- a/lib/pages/store/store_page.dart +++ b/lib/pages/store/store_page.dart @@ -151,8 +151,8 @@ class _ProductTile extends StatelessWidget { padding: const EdgeInsets.fromLTRB(14, 14, 12, 14), decoration: BoxDecoration( color: AppColors.card, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.gold.withValues(alpha: 0.26)), + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + border: Border.all(color: AppColors.border), ), child: Row( children: [ diff --git a/lib/pages/wallet/wallet_page.dart b/lib/pages/wallet/wallet_page.dart index 86b9827..359d7d1 100644 --- a/lib/pages/wallet/wallet_page.dart +++ b/lib/pages/wallet/wallet_page.dart @@ -183,20 +183,9 @@ class _BalanceHero extends StatelessWidget { key: const Key('wallet-balance-card'), padding: const EdgeInsets.fromLTRB(20, 22, 20, 20), decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [AppColors.goldCard, AppColors.card], - ), - border: Border.all(color: AppColors.gold.withValues(alpha: 0.35)), - boxShadow: [ - BoxShadow( - color: AppColors.gold.withValues(alpha: 0.08), - blurRadius: 28, - offset: const Offset(0, 14), - ), - ], + color: AppColors.card, + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + border: Border.all(color: AppColors.border), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/pages/watch_records/widgets/watch_record_card.dart b/lib/pages/watch_records/widgets/watch_record_card.dart index edb59ef..4fa1f0b 100644 --- a/lib/pages/watch_records/widgets/watch_record_card.dart +++ b/lib/pages/watch_records/widgets/watch_record_card.dart @@ -5,11 +5,11 @@ import 'package:flutter/material.dart'; import '../../../core/data/models/watch_record.dart'; import '../../../core/formatting/localized_date_time.dart'; import '../../../core/theme/app_colors.dart'; -import '../../../core/theme/app_theme.dart'; +import '../../../core/theme/app_dimens.dart'; import '../../../core/widgets/remote_image.dart'; import '../../../l10n/app_localizations.dart'; -const double _cardRadius = 14; +const double _cardRadius = AppDimens.cardRadius; const double _cardPadding = 10; const double _coverWidth = 72; const double _coverHeight = 96; @@ -187,7 +187,7 @@ class WatchRecordCard extends StatelessWidget { BoxDecoration _cardDecoration() => BoxDecoration( color: AppColors.card, borderRadius: BorderRadius.circular(_cardRadius), - boxShadow: const [AppTheme.cardShadow], + border: Border.all(color: AppColors.border), ); Widget _removeButton(String tooltip) { @@ -227,7 +227,7 @@ class _FailedRecordTile extends StatelessWidget { decoration: BoxDecoration( color: AppColors.card, borderRadius: BorderRadius.circular(_cardRadius), - boxShadow: const [AppTheme.cardShadow], + border: Border.all(color: AppColors.border), ), child: Container( key: ValueKey('watch-record-failed-${record.dramaId}'), diff --git a/test/core/subpage_header_test.dart b/test/core/subpage_header_test.dart index cf8d3ea..16b0024 100644 --- a/test/core/subpage_header_test.dart +++ b/test/core/subpage_header_test.dart @@ -28,7 +28,7 @@ void main() { final title = tester.widget(find.byKey(const Key('subpage-title'))); expect(title.style?.fontSize, 18); expect(title.style?.fontWeight, FontWeight.w800); - expect(title.style?.color, AppColors.onSurface); + expect(title.style?.color, AppColors.textPrimary); final back = tester.getRect(find.byKey(const Key('sample-back'))); final titleRect = tester.getRect(find.byKey(const Key('subpage-title'))); expect(titleRect.left - back.right, closeTo(12, 0.5)); @@ -38,8 +38,8 @@ void main() { matching: find.byType(Material), ), ); - expect(surface.color, AppColors.card); - expect((surface.shape! as CircleBorder).side.color, AppColors.border); + expect(surface.color, AppColors.transparent); + expect((surface.shape! as CircleBorder).side, BorderSide.none); final shadow = tester .widget( @@ -47,9 +47,7 @@ void main() { ) .decoration as BoxDecoration; - expect(shadow.boxShadow!.single.color, AppColors.shadowSubtle); - expect(shadow.boxShadow!.single.blurRadius, 8); - expect(shadow.boxShadow!.single.offset, const Offset(0, 2)); + expect(shadow.boxShadow, isEmpty); }); testWidgets('should_use_64_height_when_subtitle_exists', (tester) async { diff --git a/test/drama_detail/drama_detail_view_test.dart b/test/drama_detail/drama_detail_view_test.dart index 0df97ea..30fb3a1 100644 --- a/test/drama_detail/drama_detail_view_test.dart +++ b/test/drama_detail/drama_detail_view_test.dart @@ -94,7 +94,8 @@ void main() { ); final statsDecoration = stats.decoration! as BoxDecoration; expect(statsDecoration.color, AppColors.card); - expect(statsDecoration.borderRadius, BorderRadius.circular(14)); + expect(statsDecoration.borderRadius, BorderRadius.circular(12)); + expect((statsDecoration.border! as Border).top.color, AppColors.border); expect(find.byKey(const Key('detail-synopsis-section')), findsOneWidget); expect(find.byKey(const Key('detail-tags')), findsOneWidget); expect(find.byKey(const Key('detail-episodes-section')), findsOneWidget); diff --git a/test/theme/app_theme_test.dart b/test/theme/app_theme_test.dart index 2f94760..442b452 100644 --- a/test/theme/app_theme_test.dart +++ b/test/theme/app_theme_test.dart @@ -20,11 +20,15 @@ void main() { test('should_expose_dark_visual_tokens', () { expect(AppColors.blue, const Color(0xFFFF6B2C)); expect(AppColors.bluePressed, const Color(0xFFE55A1F)); - expect(AppColors.secondary, const Color(0xFF22D3EE)); - expect(AppColors.ink, const Color(0xFF0B1020)); - expect(AppColors.card, const Color(0xFF151B2E)); - expect(AppColors.raised, const Color(0xFF1C2436)); - expect(AppColors.border, const Color(0x0FFFFFFF)); + expect(AppColors.secondary, const Color(0xFFB0B0B0)); + expect(AppColors.ink, const Color(0xFF000000)); + expect(AppColors.card, const Color(0xFF16161A)); + expect(AppColors.cardAlt, const Color(0xFF101010)); + expect(AppColors.raised, const Color(0xFF1C1C1E)); + expect(AppColors.border, const Color(0xFF1C1C1E)); + expect(AppColors.textPrimary, const Color(0xFFFFFFFF)); + expect(AppColors.textSecondary, const Color(0xFFB0B0B0)); + expect(AppColors.textTertiary, const Color(0xFF8E8E93)); expect(AppColors.onSurface, const Color(0xEBFFFFFF)); expect(AppColors.muted, const Color(0xA3FFFFFF)); expect(AppColors.subtle, const Color(0x61FFFFFF)); @@ -59,7 +63,7 @@ void main() { expect(theme.colorScheme.onSurface, AppColors.onSurface); expect(theme.colorScheme.outline, AppColors.border); expect(theme.scaffoldBackgroundColor, AppColors.ink); - expect(theme.appBarTheme.backgroundColor, AppColors.card); + expect(theme.appBarTheme.backgroundColor, AppColors.transparent); expect(theme.appBarTheme.systemOverlayStyle, SystemUiOverlayStyle.light); expect(theme.cardTheme.elevation, 0); expect(theme.dialogTheme.backgroundColor, AppColors.raised); @@ -91,7 +95,12 @@ void main() { }); test('should_expose_flat_page_gradient_without_card_shadow', () { - expect(AppTheme.pageGradient.colors, [AppColors.surfaceTop, AppColors.ink]); + expect(AppTheme.pageGradient.colors, [ + AppColors.mineGradientTop, + AppColors.mineGradientMiddle, + AppColors.mineGradientBottom, + ]); + expect(AppTheme.pageGradient.stops, [0, 0.4, 0.75]); expect(AppTheme.cardShadow.color, AppColors.cardShadow); expect(AppTheme.cardShadow.blurRadius, 0); expect(AppTheme.cardShadow.offset, Offset.zero); diff --git a/test/theme/dark_theme_smoke_test.dart b/test/theme/dark_theme_smoke_test.dart index 2fea453..7ece6f6 100644 --- a/test/theme/dark_theme_smoke_test.dart +++ b/test/theme/dark_theme_smoke_test.dart @@ -65,7 +65,7 @@ void main() { final theme = Theme.of(context); expect(theme.brightness, Brightness.dark); expect(theme.scaffoldBackgroundColor, AppColors.ink); - expect(theme.appBarTheme.backgroundColor, AppColors.card); + expect(theme.appBarTheme.backgroundColor, AppColors.transparent); expect(theme.cardTheme.color, AppColors.card); final card = tester.widget(find.byKey(const Key('dark-smoke-card'))); From 6f1da5aa3937138f5861901b396f246beafac051 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 22:07:13 +0800 Subject: [PATCH 105/129] =?UTF-8?q?style:=20=E7=BB=9F=E4=B8=80=E4=B8=8B?= =?UTF-8?q?=E6=8B=89=E4=B8=8E=E5=8A=A0=E8=BD=BD=E6=8C=87=E7=A4=BA=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 6 ++ lib/core/theme/app_colors.dart | 1 + lib/core/theme/app_dimens.dart | 5 ++ lib/core/widgets/app_pull_to_refresh.dart | 70 ++++++------------ lib/core/widgets/paged_list_footer.dart | 37 ++-------- lib/core/widgets/paged_local_list.dart | 32 ++------- lib/l10n/app_en.arb | 4 -- lib/l10n/app_id.arb | 4 -- lib/l10n/app_localizations.dart | 24 ------- lib/l10n/app_localizations_en.dart | 12 ---- lib/l10n/app_localizations_id.dart | 12 ---- lib/l10n/app_localizations_zh.dart | 12 ---- lib/l10n/app_zh.arb | 4 -- lib/pages/favorites/favorites_page.dart | 2 - lib/pages/following/following_paged_grid.dart | 2 - lib/pages/home/widgets/home_feed_grid.dart | 2 - lib/pages/new_releases/new_releases_page.dart | 4 -- lib/pages/notices/notices_page.dart | 71 ++++++++++++------- lib/pages/ranking/ranking_page.dart | 4 -- .../widgets/all_dramas_filter_grid.dart | 2 - .../watch_records/watch_records_page.dart | 2 - test/core/app_pull_to_refresh_test.dart | 37 ++++++---- test/discover/home_all_dramas_grid_test.dart | 3 +- test/discover/theater_pages_test.dart | 6 +- test/library/favorites_page_test.dart | 3 +- test/notice/notices_page_test.dart | 7 +- 26 files changed, 127 insertions(+), 241 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 4d8d39f..0f2db2e 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1282,3 +1282,9 @@ $ flutter test - 普通二级页标题栏改为透明、白色标题与返回图标;统一卡片 12 圆角、1px 描边和无阴影策略。 - 覆盖新剧、筛选、榜单、预约、收藏、观看历史、下载、设置、钱包、VIP、通知、详情、登录、关于;播放器沉浸区保持纯黑。 - `flutter analyze`:No issues found。 + +## 2026-10-22 统一下拉与加载指示器 + +- 下拉刷新统一为 40×40、60% 黑底圆角指示器内嵌 20 白色加载圈,不再显示任何状态文字。 +- 网络分页与本地分页 footer 统一仅显示白色加载圈,无更多状态完全隐藏;通知页补触底自动加载。 +- 移除不再使用的刷新提示与无更多文案;`flutter analyze`:No issues found。 diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 41966ee..7ce34c0 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -187,6 +187,7 @@ abstract final class AppColors { /// 播放器控件胶囊与圆形底色。 static const playerOverlayPill = Color(0xB3000000); + static const refreshIndicatorSurface = Color(0x99000000); static const cardBottomScrimStart = Color(0x00000000); static const cardBottomScrimEnd = Color(0x40000000); diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 5ab2367..cf5d312 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -53,6 +53,11 @@ abstract final class AppDimens { static const double progressHeight = 3; static const double progressRadius = 2; static const double progressLineHeight = 3; + static const double refreshIndicatorSize = 40; + static const double refreshIndicatorRadius = 20; + static const double refreshSpinnerSize = 20; + static const double refreshSpinnerStroke = 2; + static const double loadMoreIndicatorHeight = 40; static const double searchHeight = 40; static const double searchRadius = 8; diff --git a/lib/core/widgets/app_pull_to_refresh.dart b/lib/core/widgets/app_pull_to_refresh.dart index 73f807b..86aa8cc 100644 --- a/lib/core/widgets/app_pull_to_refresh.dart +++ b/lib/core/widgets/app_pull_to_refresh.dart @@ -4,19 +4,18 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import '../../l10n/app_localizations.dart'; import '../theme/app_colors.dart'; +import '../theme/app_dimens.dart'; const double _maxPullDistance = 96; const double _refreshThreshold = 56; const double _indicatorHeight = 56; const double _indicatorDisplacement = 40; -const Duration _arrowTurnDuration = Duration(milliseconds: 160); const Duration _settleDuration = Duration(milliseconds: 200); enum _PullState { idle, pull, armed, refreshing } -/// 带文字状态提示的下拉刷新容器,指示区悬浮在内容上方。 +/// 下拉刷新容器,指示区只显示统一的白色加载圈。 class AppPullToRefresh extends StatefulWidget { const AppPullToRefresh({ super.key, @@ -172,7 +171,7 @@ class _AppPullToRefreshState extends State top: widget.edgeOffset, left: 0, right: 0, - child: IgnorePointer(child: _indicatorLayer(context, offset)), + child: IgnorePointer(child: _indicatorLayer(offset)), ), ], ); @@ -181,7 +180,7 @@ class _AppPullToRefreshState extends State ); } - Widget _indicatorLayer(BuildContext context, double offset) { + Widget _indicatorLayer(double offset) { final progress = (offset / _refreshThreshold).clamp(0.0, 1.0); return SizedBox( height: _indicatorHeight, @@ -193,7 +192,7 @@ class _AppPullToRefreshState extends State alignment: Alignment.topCenter, child: Padding( padding: const EdgeInsets.only(top: 8), - child: _indicator(context), + child: _indicator(), ), ), ), @@ -201,50 +200,25 @@ class _AppPullToRefreshState extends State ); } - Widget _indicator(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - if (_state == _PullState.refreshing) { - return _label( - const SizedBox.square( - dimension: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - color: AppColors.navSelected, - ), - ), - l10n.refreshingLabel, - ); - } - final armed = _state == _PullState.armed; - return _label( - AnimatedRotation( - turns: armed ? 0.5 : 0, - duration: _arrowTurnDuration, - child: Icon( - Icons.arrow_downward_rounded, - size: 18, - color: AppColors.navSelected, + Widget _indicator() { + return Container( + key: const Key('pull-refresh-indicator'), + width: AppDimens.refreshIndicatorSize, + height: AppDimens.refreshIndicatorSize, + padding: const EdgeInsets.all( + (AppDimens.refreshIndicatorSize - AppDimens.refreshSpinnerSize) / 2, + ), + decoration: BoxDecoration( + color: AppColors.refreshIndicatorSurface, + borderRadius: BorderRadius.circular(AppDimens.refreshIndicatorRadius), + ), + child: const SizedBox.square( + dimension: AppDimens.refreshSpinnerSize, + child: CircularProgressIndicator( + strokeWidth: AppDimens.refreshSpinnerStroke, + color: AppColors.textPrimary, ), ), - armed ? l10n.releaseToRefresh : l10n.pullToRefresh, - ); - } - - Widget _label(Widget icon, String text) { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - icon, - const SizedBox(width: 6), - Text( - text, - style: const TextStyle( - color: AppColors.navSelected, - fontSize: 13, - fontWeight: FontWeight.w600, - ), - ), - ], ); } } diff --git a/lib/core/widgets/paged_list_footer.dart b/lib/core/widgets/paged_list_footer.dart index 8af2cda..ab47026 100644 --- a/lib/core/widgets/paged_list_footer.dart +++ b/lib/core/widgets/paged_list_footer.dart @@ -5,22 +5,19 @@ import 'dart:async'; import 'package:flutter/material.dart'; import '../theme/app_colors.dart'; +import '../theme/app_dimens.dart'; import '../../l10n/app_localizations.dart'; -/// 网络分页列表底部状态:加载、重试、手动加载与无更多。 +/// 网络分页列表底部状态:仅保留加载圈与错误重试。 class PagedListFooter extends StatelessWidget { const PagedListFooter({ super.key, required this.loading, - required this.hasMore, - required this.hasItems, required this.onRetry, this.errorMessage, }); final bool loading; - final bool hasMore; - final bool hasItems; final String? errorMessage; final Future Function() onRetry; @@ -49,38 +46,18 @@ class PagedListFooter extends StatelessWidget { if (loading) { return const SizedBox( key: Key('paged-list-loading'), - height: 56, + height: AppDimens.loadMoreIndicatorHeight, child: Center( child: SizedBox.square( - dimension: 20, + dimension: AppDimens.refreshSpinnerSize, child: CircularProgressIndicator( - strokeWidth: 2, - color: AppColors.blue, + strokeWidth: AppDimens.refreshSpinnerStroke, + color: AppColors.textPrimary, ), ), ), ); } - if (hasMore) { - return Center( - child: TextButton( - key: const Key('paged-list-load-more'), - onPressed: () => unawaited(onRetry()), - child: Text(l10n.loadMore), - ), - ); - } - if (hasItems) { - return Padding( - key: const Key('paged-list-no-more'), - padding: const EdgeInsets.symmetric(vertical: 14), - child: Text( - l10n.noMore, - textAlign: TextAlign.center, - style: const TextStyle(color: AppColors.muted, fontSize: 12), - ), - ); - } - return const SizedBox(height: 16); + return const SizedBox.shrink(); } } diff --git a/lib/core/widgets/paged_local_list.dart b/lib/core/widgets/paged_local_list.dart index ce5a80d..4565c7c 100644 --- a/lib/core/widgets/paged_local_list.dart +++ b/lib/core/widgets/paged_local_list.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../theme/app_colors.dart'; +import '../theme/app_dimens.dart'; const int _defaultPageSize = 20; const Duration _defaultLoadDelay = Duration(milliseconds: 300); @@ -64,53 +65,34 @@ class PagedLocalListController extends ChangeNotifier { } } -/// 分页列表底部加载态与到底提示。 +/// 分页列表底部加载态。 class PagedLoadFooter extends StatelessWidget { const PagedLoadFooter({ super.key, required this.loading, - required this.hasMore, - required this.hasItems, required this.loadMoreKey, - required this.noMoreKey, - required this.noMoreLabel, }); final bool loading; - final bool hasMore; - final bool hasItems; final Key loadMoreKey; - final Key noMoreKey; - final String noMoreLabel; @override Widget build(BuildContext context) { if (loading) { return SizedBox( key: loadMoreKey, - height: 56, + height: AppDimens.loadMoreIndicatorHeight, child: const Center( child: SizedBox.square( - dimension: 20, + dimension: AppDimens.refreshSpinnerSize, child: CircularProgressIndicator( - strokeWidth: 2, - color: AppColors.blue, + strokeWidth: AppDimens.refreshSpinnerStroke, + color: AppColors.textPrimary, ), ), ), ); } - if (hasItems && !hasMore) { - return Padding( - key: noMoreKey, - padding: const EdgeInsets.symmetric(vertical: 14), - child: Text( - noMoreLabel, - textAlign: TextAlign.center, - style: const TextStyle(color: AppColors.muted, fontSize: 12), - ), - ); - } - return const SizedBox(height: 16); + return const SizedBox.shrink(); } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 08e4c07..96606d1 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -344,10 +344,6 @@ } }, "retry": "Retry", - "pullToRefresh": "Pull to refresh", - "releaseToRefresh": "Release to refresh", - "refreshingLabel": "Refreshing…", - "noMore": "No more", "backToSeries": "Back to series", "nextEpisode": "Next episode", "pause": "Pause", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 8659caa..7154718 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -318,10 +318,6 @@ } }, "retry": "Coba lagi", - "pullToRefresh": "Tarik untuk menyegarkan", - "releaseToRefresh": "Lepaskan untuk menyegarkan", - "refreshingLabel": "Menyegarkan…", - "noMore": "Tidak ada lagi", "backToSeries": "Kembali ke serial", "nextEpisode": "Episode berikutnya", "pause": "Jeda", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index aa8b922..c1477e5 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1342,30 +1342,6 @@ abstract class AppLocalizations { /// **'Retry'** String get retry; - /// No description provided for @pullToRefresh. - /// - /// In en, this message translates to: - /// **'Pull to refresh'** - String get pullToRefresh; - - /// No description provided for @releaseToRefresh. - /// - /// In en, this message translates to: - /// **'Release to refresh'** - String get releaseToRefresh; - - /// No description provided for @refreshingLabel. - /// - /// In en, this message translates to: - /// **'Refreshing…'** - String get refreshingLabel; - - /// No description provided for @noMore. - /// - /// In en, this message translates to: - /// **'No more'** - String get noMore; - /// No description provided for @backToSeries. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 0c0fdb3..669128c 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -681,18 +681,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get retry => 'Retry'; - @override - String get pullToRefresh => 'Pull to refresh'; - - @override - String get releaseToRefresh => 'Release to refresh'; - - @override - String get refreshingLabel => 'Refreshing…'; - - @override - String get noMore => 'No more'; - @override String get backToSeries => 'Back to series'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index a190f6a..53f9a02 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -684,18 +684,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get retry => 'Coba lagi'; - @override - String get pullToRefresh => 'Tarik untuk menyegarkan'; - - @override - String get releaseToRefresh => 'Lepaskan untuk menyegarkan'; - - @override - String get refreshingLabel => 'Menyegarkan…'; - - @override - String get noMore => 'Tidak ada lagi'; - @override String get backToSeries => 'Kembali ke serial'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index a5e27d1..8d700cc 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -673,18 +673,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get retry => '重试'; - @override - String get pullToRefresh => '下拉刷新'; - - @override - String get releaseToRefresh => '松手刷新'; - - @override - String get refreshingLabel => '正在刷新…'; - - @override - String get noMore => '没有更多了'; - @override String get backToSeries => '返回剧集详情'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index bb0b6fd..bb792b8 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -280,10 +280,6 @@ } }, "retry": "重试", - "pullToRefresh": "下拉刷新", - "releaseToRefresh": "松手刷新", - "refreshingLabel": "正在刷新…", - "noMore": "没有更多了", "backToSeries": "返回剧集详情", "nextEpisode": "下一集", "pause": "暂停", diff --git a/lib/pages/favorites/favorites_page.dart b/lib/pages/favorites/favorites_page.dart index e751b5b..bfadfe5 100644 --- a/lib/pages/favorites/favorites_page.dart +++ b/lib/pages/favorites/favorites_page.dart @@ -176,8 +176,6 @@ class _FavoritesBody extends StatelessWidget { ], PagedListFooter( loading: favorites.loadingMore, - hasMore: favorites.hasMore, - hasItems: favorites.cards.isNotEmpty, errorMessage: favorites.loadMoreError == null ? null : errorText(l10n, favorites.loadMoreError, l10n.favoritesError), diff --git a/lib/pages/following/following_paged_grid.dart b/lib/pages/following/following_paged_grid.dart index 5ea8167..119b900 100644 --- a/lib/pages/following/following_paged_grid.dart +++ b/lib/pages/following/following_paged_grid.dart @@ -130,8 +130,6 @@ class FollowingPagedGrid extends StatelessWidget { SliverToBoxAdapter( child: PagedListFooter( loading: loadingMore, - hasMore: hasMore, - hasItems: hasItems, errorMessage: loadMoreError, onRetry: onLoadMore, ), diff --git a/lib/pages/home/widgets/home_feed_grid.dart b/lib/pages/home/widgets/home_feed_grid.dart index 97f07a1..23cd742 100644 --- a/lib/pages/home/widgets/home_feed_grid.dart +++ b/lib/pages/home/widgets/home_feed_grid.dart @@ -218,8 +218,6 @@ class _HomeFeedGridState extends State { padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), child: PagedListFooter( loading: widget.feedController.loadingMore, - hasMore: widget.feedController.hasMore, - hasItems: dramas.isNotEmpty, errorMessage: widget.feedController.lastError == null ? null : l10n.catalogError, diff --git a/lib/pages/new_releases/new_releases_page.dart b/lib/pages/new_releases/new_releases_page.dart index 3de4c34..86eb30c 100644 --- a/lib/pages/new_releases/new_releases_page.dart +++ b/lib/pages/new_releases/new_releases_page.dart @@ -192,13 +192,9 @@ class _NewReleasesPageState extends State { ), PagedLoadFooter( loading: pager.loading, - hasMore: pager.hasMore, - hasItems: pager.items.isNotEmpty, loadMoreKey: const Key( 'new-releases-load-more', ), - noMoreKey: const Key('new-releases-no-more'), - noMoreLabel: l10n.noMore, ), ], ), diff --git a/lib/pages/notices/notices_page.dart b/lib/pages/notices/notices_page.dart index f9df1fd..5ac2715 100644 --- a/lib/pages/notices/notices_page.dart +++ b/lib/pages/notices/notices_page.dart @@ -18,6 +18,8 @@ import '../../core/widgets/empty_state.dart'; import '../../core/widgets/plain_page_scaffold.dart'; import '../../l10n/app_localizations.dart'; +const double _loadMoreThreshold = 240; + /// Anonymous system notice list with pagination and detail dialog. class NoticesPage extends StatefulWidget { const NoticesPage({super.key}); @@ -111,29 +113,55 @@ class _NoticesPageState extends State { } return AppPullToRefresh( onRefresh: _controller.refresh, - child: ListView.separated( - key: const Key('notices-list'), - physics: AppPullToRefresh.physics, - padding: const EdgeInsets.fromLTRB(16, 12, 16, 28), - itemCount: _controller.notices.length + 1, - separatorBuilder: (_, _) => const SizedBox(height: 10), - itemBuilder: (context, index) { - if (index == _controller.notices.length) return _footer(l10n); - final notice = _controller.notices[index]; - return _NoticeTile( - notice: notice, - onTap: () => _showDetails(notice, l10n), - ); - }, + child: NotificationListener( + onNotification: _onScroll, + child: ListView.separated( + key: const Key('notices-list'), + physics: AppPullToRefresh.physics, + padding: const EdgeInsets.fromLTRB(16, 12, 16, 28), + itemCount: _controller.notices.length + 1, + separatorBuilder: (_, _) => const SizedBox(height: 10), + itemBuilder: (context, index) { + if (index == _controller.notices.length) return _footer(l10n); + final notice = _controller.notices[index]; + return _NoticeTile( + notice: notice, + onTap: () => _showDetails(notice, l10n), + ); + }, + ), ), ); } + bool _onScroll(ScrollNotification notification) { + if (notification.depth != 0 || + !_controller.hasMore || + _controller.loadingMore) { + return false; + } + if (notification.metrics.extentAfter > _loadMoreThreshold) return false; + if (notification is ScrollUpdateNotification || + notification is ScrollEndNotification || + notification is OverscrollNotification) { + unawaited(_controller.loadMore()); + } + return false; + } + Widget _footer(AppLocalizations l10n) { if (_controller.loadingMore) { - return const Padding( - padding: EdgeInsets.symmetric(vertical: 12), - child: Center(child: CircularProgressIndicator()), + return const SizedBox( + height: AppDimens.loadMoreIndicatorHeight, + child: Center( + child: SizedBox.square( + dimension: AppDimens.refreshSpinnerSize, + child: CircularProgressIndicator( + strokeWidth: AppDimens.refreshSpinnerStroke, + color: AppColors.textPrimary, + ), + ), + ), ); } if (_controller.loadMoreError != null) { @@ -143,14 +171,7 @@ class _NoticesPageState extends State { label: Text(l10n.retry), ); } - if (_controller.hasMore) { - return TextButton( - key: const Key('notices-load-more'), - onPressed: _controller.loadMore, - child: Text(l10n.loadMore), - ); - } - return const SizedBox(height: 8); + return const SizedBox.shrink(); } Future _showDetails(AppNotice notice, AppLocalizations l10n) { diff --git a/lib/pages/ranking/ranking_page.dart b/lib/pages/ranking/ranking_page.dart index 749001a..ae5b478 100644 --- a/lib/pages/ranking/ranking_page.dart +++ b/lib/pages/ranking/ranking_page.dart @@ -153,11 +153,7 @@ class _RankingPageState extends State { ], PagedLoadFooter( loading: pager.loading, - hasMore: pager.hasMore, - hasItems: pager.items.isNotEmpty, loadMoreKey: const Key('ranking-load-more'), - noMoreKey: const Key('ranking-no-more'), - noMoreLabel: l10n.noMore, ), ], ), diff --git a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart index 0440a1f..9e436bd 100644 --- a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart +++ b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart @@ -115,8 +115,6 @@ class _AllDramasFilterGridState extends State { ), child: PagedListFooter( loading: widget.feedController.loadingMore, - hasMore: widget.feedController.hasMore, - hasItems: dramas.isNotEmpty, errorMessage: widget.feedController.lastError == null ? null diff --git a/lib/pages/watch_records/watch_records_page.dart b/lib/pages/watch_records/watch_records_page.dart index b244c63..b996c22 100644 --- a/lib/pages/watch_records/watch_records_page.dart +++ b/lib/pages/watch_records/watch_records_page.dart @@ -145,8 +145,6 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> { ], PagedListFooter( loading: controller.loadingMore, - hasMore: controller.hasMore, - hasItems: controller.records.isNotEmpty, errorMessage: controller.loadMoreError == null ? null : errorText(l10n, controller.loadMoreError, l10n.progressError), diff --git a/test/core/app_pull_to_refresh_test.dart b/test/core/app_pull_to_refresh_test.dart index e9598a2..71054d1 100644 --- a/test/core/app_pull_to_refresh_test.dart +++ b/test/core/app_pull_to_refresh_test.dart @@ -44,28 +44,32 @@ void main() { 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)); + final indicator = find.byKey(const Key('pull-refresh-indicator')); + expect(indicator, findsOneWidget); expect( - tester.widget(find.text('Pull to refresh')).style?.color, - AppColors.navSelected, + find.descendant( + of: indicator, + matching: find.byType(CircularProgressIndicator), + ), + findsOneWidget, ); + expect(tester.getSize(indicator), const Size(40, 40)); + final background = tester.widget(indicator).decoration; + final backgroundDecoration = background! as BoxDecoration; + expect(backgroundDecoration.color, AppColors.refreshIndicatorSurface); + expect(backgroundDecoration.borderRadius, BorderRadius.circular(20)); + expect(find.text('Pull to refresh'), findsNothing); + expect(_contentOffset(tester), greaterThan(0)); await gesture.moveBy(const Offset(0, 120)); await tester.pump(); - expect(find.text('Release to refresh'), findsOneWidget); - expect( - tester.widget(find.text('Release to refresh')).style?.color, - AppColors.navSelected, - ); + expect(indicator, findsOneWidget); + expect(find.text('Release to refresh'), findsNothing); await gesture.up(); await tester.pump(); - expect(find.text('Refreshing…'), findsOneWidget); - expect( - tester.widget(find.text('Refreshing…')).style?.color, - AppColors.navSelected, - ); + expect(indicator, findsOneWidget); + expect(find.text('Refreshing…'), findsNothing); expect(calls, 1); final releaseOffset = _contentOffset(tester); expect(releaseOffset, greaterThan(56)); @@ -87,6 +91,7 @@ void main() { await tester.pump(); await tester.pumpAndSettle(); expect(_contentOffset(tester), closeTo(0, 0.1)); + expect(indicator, findsNothing); expect(find.text('Refreshing…'), findsNothing); expect(find.text('Pull to refresh'), findsNothing); }); @@ -122,7 +127,8 @@ void main() { final releaseOffset = _contentOffset(tester); expect(releaseOffset, greaterThan(0)); expect(releaseOffset, lessThan(56)); - expect(find.text('Pull to refresh'), findsOneWidget); + expect(find.byKey(const Key('pull-refresh-indicator')), findsOneWidget); + expect(find.text('Pull to refresh'), findsNothing); await gesture.up(); await tester.pump(); @@ -136,6 +142,7 @@ void main() { await tester.pumpAndSettle(); expect(_contentOffset(tester), closeTo(0, 0.1)); + expect(find.byKey(const Key('pull-refresh-indicator')), findsNothing); expect(find.text('Pull to refresh'), findsNothing); }); } diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index 36bbb7b..b55d0f6 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -388,7 +388,8 @@ void main() { ); await tester.drag(_verticalFeed, const Offset(0, 240)); await tester.pump(); - expect(find.text('Refreshing…'), findsOneWidget); + expect(find.byKey(const Key('pull-refresh-indicator')), findsOneWidget); + expect(find.text('Refreshing…'), findsNothing); await tester.pumpAndSettle(); }); } diff --git a/test/discover/theater_pages_test.dart b/test/discover/theater_pages_test.dart index 30d89ee..95757e0 100644 --- a/test/discover/theater_pages_test.dart +++ b/test/discover/theater_pages_test.dart @@ -198,7 +198,7 @@ void main() { await tester.pump(); expect(find.byKey(const Key('ranking-row-39')), findsOneWidget); expect(find.byKey(const Key('ranking-row-40')), findsNothing); - expect(find.byKey(const Key('ranking-no-more')), findsOneWidget); + expect(find.byKey(const Key('ranking-no-more')), findsNothing); position.jumpTo(0); await tester.pump(); @@ -211,7 +211,7 @@ void main() { expect(position.pixels, 0); position.jumpTo(position.maxScrollExtent); await tester.pump(); - expect(find.byKey(const Key('ranking-load-more')), findsOneWidget); + expect(find.byKey(const Key('ranking-load-more')), findsNothing); expect(find.byKey(const Key('ranking-no-more')), findsNothing); await tester.pumpAndSettle(); }); @@ -243,7 +243,7 @@ void main() { await tester.pumpAndSettle(); position.jumpTo(position.maxScrollExtent); await tester.pumpAndSettle(); - expect(find.byKey(const Key('new-releases-no-more')), findsOneWidget); + expect(find.byKey(const Key('new-releases-no-more')), findsNothing); position.jumpTo(0); await tester.pump(); diff --git a/test/library/favorites_page_test.dart b/test/library/favorites_page_test.dart index adfff12..4599f42 100644 --- a/test/library/favorites_page_test.dart +++ b/test/library/favorites_page_test.dart @@ -99,7 +99,8 @@ void main() { ); await tester.drag(find.byType(ListView), const Offset(0, 240)); await tester.pump(); - expect(find.text('Refreshing…'), findsOneWidget); + expect(find.byKey(const Key('pull-refresh-indicator')), findsOneWidget); + expect(find.text('Refreshing…'), findsNothing); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('favorite-remove-signal'))); diff --git a/test/notice/notices_page_test.dart b/test/notice/notices_page_test.dart index 8ec1a6b..d257b4f 100644 --- a/test/notice/notices_page_test.dart +++ b/test/notice/notices_page_test.dart @@ -24,9 +24,10 @@ void main() { await _pump(tester, repository); expect(find.text('Notice 1'), findsOneWidget); - expect(find.byKey(const Key('notices-load-more')), findsOneWidget); - - await tester.tap(find.byKey(const Key('notices-load-more'))); + await tester.drag( + find.byKey(const Key('notices-list')), + const Offset(0, -300), + ); await tester.pumpAndSettle(); expect(find.text('Notice 3'), findsOneWidget); From 9b832836893593531cbc77ad5b7015528d341a9f Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 22:19:55 +0800 Subject: [PATCH 106/129] =?UTF-8?q?feat:=20=E9=87=8D=E5=81=9A=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=E7=83=AD=E6=92=AD=E6=A6=9C=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 6 + lib/core/theme/app_colors.dart | 1 + lib/core/theme/app_dimens.dart | 15 + lib/l10n/app_en.arb | 4 +- lib/l10n/app_id.arb | 4 +- lib/l10n/app_localizations.dart | 4 +- lib/l10n/app_localizations_en.dart | 4 +- lib/l10n/app_localizations_id.dart | 5 +- lib/l10n/app_localizations_zh.dart | 4 +- lib/l10n/app_zh.arb | 4 +- lib/pages/home/widgets/home_feed_grid.dart | 4 + lib/pages/ranking/ranking_page.dart | 436 +++++++++++---------- test/discover/theater_pages_test.dart | 22 +- test/home/home_grid_layout_test.dart | 5 +- 14 files changed, 285 insertions(+), 233 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 0f2db2e..d7c8081 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1288,3 +1288,9 @@ $ flutter test - 下拉刷新统一为 40×40、60% 黑底圆角指示器内嵌 20 白色加载圈,不再显示任何状态文字。 - 网络分页与本地分页 footer 统一仅显示白色加载圈,无更多状态完全隐藏;通知页补触底自动加载。 - 移除不再使用的刷新提示与无更多文案;`flutter analyze`:No issues found。 + +## 2026-10-22 首页热播榜重做 + +- 首页榜单 tab 改为独立领奖台 + 单列排行布局,不再复用三列网格。 +- 前三名按 2/1/3 排列,冠军放大并上移;第 4 名起使用大号描边名次与横向信息行。 +- 榜单沿用现有播放热度排序、分页和播放逻辑;`flutter analyze`:No issues found。 diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 7ce34c0..cbc1920 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -50,6 +50,7 @@ abstract final class AppColors { static const vipBadgeSurface = Color(0x1F3D2408); static const bronze = Color(0xFFCD7F32); static const bronzeDeep = Color(0xFFA9713A); + static const rankSilver = Color(0xFFC0C0C0); /// 成功状态。 static const success = Color(0xFF2FA873); diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index cf5d312..67ddd3e 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -141,6 +141,21 @@ abstract final class AppDimens { static const double recommendRowHeight = 252; static const double recommendCardWidth = 122; + /// 首页热播榜。 + static const double rankingHeroTitle = 22; + static const double rankingHeroSubtitle = 11; + static const double rankingPodiumChampionWidth = 110; + static const double rankingPodiumChampionHeight = 146; + static const double rankingPodiumWidth = 92; + static const double rankingPodiumHeight = 122; + static const double rankingPodiumRadius = 10; + static const double rankingRowHeight = 96; + static const double rankingNumberWidth = 56; + static const double rankingNumberFont = 48; + static const double rankingCoverWidth = 60; + static const double rankingCoverHeight = 80; + static const double rankingCoverRadius = 6; + /// 全部短剧筛选与追剧双列。 static const double filterChipFont = 16; static const double filterChipHeight = 32; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 96606d1..58f9e34 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -64,7 +64,7 @@ "count": {"type": "String"} } }, - "topCharts": "Top Charts", + "topCharts": "Hot Ranking", "newReleases": "New Releases", "newDramas": "New", "trendingMicroDramas": "Trending Micro-Dramas", @@ -633,7 +633,7 @@ "ledgerAdjustment": "Adjustment", "rankingTab": "Charts", "rankingTitle": "Top Charts", - "rankingSubtitle": "Ranked from today's live catalog feed.", + "rankingSubtitle": "Ranked live by play heat", "rankingHot": "Trending", "rankingNew": "New releases", "newReleasesAll": "All new releases", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 7154718..a05c235 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -48,7 +48,7 @@ "authorNoteLabel": "Catatan penulis", "reserve": "Reservasi", "heatLabel": "{count} panas", - "topCharts": "Peringkat Teratas", + "topCharts": "Peringkat Populer", "newReleases": "Rilisan Baru", "newDramas": "Baru", "trendingMicroDramas": "Drama Mini Populer", @@ -595,7 +595,7 @@ "ledgerAdjustment": "Penyesuaian", "rankingTab": "Peringkat", "rankingTitle": "Peringkat Teratas", - "rankingSubtitle": "Diurutkan dari feed katalog terkini.", + "rankingSubtitle": "Diurutkan langsung berdasarkan jumlah tayangan", "rankingHot": "Populer", "rankingNew": "Rilis baru", "newReleasesAll": "Semua rilis baru", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index c1477e5..e6d0fa0 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -391,7 +391,7 @@ abstract class AppLocalizations { /// No description provided for @topCharts. /// /// In en, this message translates to: - /// **'Top Charts'** + /// **'Hot Ranking'** String get topCharts; /// No description provided for @newReleases. @@ -2677,7 +2677,7 @@ abstract class AppLocalizations { /// No description provided for @rankingSubtitle. /// /// In en, this message translates to: - /// **'Ranked from today\'s live catalog feed.'** + /// **'Ranked live by play heat'** String get rankingSubtitle; /// No description provided for @rankingHot. diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 669128c..a8d5b9e 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -162,7 +162,7 @@ class AppLocalizationsEn extends AppLocalizations { } @override - String get topCharts => 'Top Charts'; + String get topCharts => 'Hot Ranking'; @override String get newReleases => 'New Releases'; @@ -1389,7 +1389,7 @@ class AppLocalizationsEn extends AppLocalizations { String get rankingTitle => 'Top Charts'; @override - String get rankingSubtitle => 'Ranked from today\'s live catalog feed.'; + String get rankingSubtitle => 'Ranked live by play heat'; @override String get rankingHot => 'Trending'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 53f9a02..8d05415 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -162,7 +162,7 @@ class AppLocalizationsId extends AppLocalizations { } @override - String get topCharts => 'Peringkat Teratas'; + String get topCharts => 'Peringkat Populer'; @override String get newReleases => 'Rilisan Baru'; @@ -1389,7 +1389,8 @@ class AppLocalizationsId extends AppLocalizations { String get rankingTitle => 'Peringkat Teratas'; @override - String get rankingSubtitle => 'Diurutkan dari feed katalog terkini.'; + String get rankingSubtitle => + 'Diurutkan langsung berdasarkan jumlah tayangan'; @override String get rankingHot => 'Populer'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 8d700cc..c4e1ab3 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -159,7 +159,7 @@ class AppLocalizationsZh extends AppLocalizations { } @override - String get topCharts => '排行榜'; + String get topCharts => '热播榜'; @override String get newReleases => '新剧'; @@ -1367,7 +1367,7 @@ class AppLocalizationsZh extends AppLocalizations { String get rankingTitle => '热门榜单'; @override - String get rankingSubtitle => '依据目录实时分区排序。'; + String get rankingSubtitle => '按播放热度实时排序'; @override String get rankingHot => '热播'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index bb792b8..4954462 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -48,7 +48,7 @@ "authorNoteLabel": "作者声明", "reserve": "预约", "heatLabel": "{count}热度", - "topCharts": "排行榜", + "topCharts": "热播榜", "newReleases": "新剧", "newDramas": "新剧", "trendingMicroDramas": "热门短剧", @@ -557,7 +557,7 @@ "ledgerAdjustment": "余额调整", "rankingTab": "榜单", "rankingTitle": "热门榜单", - "rankingSubtitle": "依据目录实时分区排序。", + "rankingSubtitle": "按播放热度实时排序", "rankingHot": "热播", "rankingNew": "新剧", "newReleasesAll": "全部新剧", diff --git a/lib/pages/home/widgets/home_feed_grid.dart b/lib/pages/home/widgets/home_feed_grid.dart index 23cd742..5c7cb54 100644 --- a/lib/pages/home/widgets/home_feed_grid.dart +++ b/lib/pages/home/widgets/home_feed_grid.dart @@ -17,6 +17,7 @@ import '../../../core/widgets/app_pull_to_refresh.dart'; import '../../../core/widgets/empty_state.dart'; import '../../../core/widgets/paged_list_footer.dart'; import '../../../l10n/app_localizations.dart'; +import '../../ranking/ranking_page.dart'; import 'home_drama_classification.dart'; import 'home_feed_card.dart'; import 'home_grid_header.dart'; @@ -155,6 +156,9 @@ class _HomeFeedGridState extends State { required HomePrimaryTab tab, required List shortcuts, }) { + if (tab == HomePrimaryTab.ranking) { + return const RankingPage(embedded: true); + } if (widget.feedController.loadingFirstPage) { return const Center( child: SizedBox.square( diff --git a/lib/pages/ranking/ranking_page.dart b/lib/pages/ranking/ranking_page.dart index ae5b478..3083418 100644 --- a/lib/pages/ranking/ranking_page.dart +++ b/lib/pages/ranking/ranking_page.dart @@ -10,6 +10,7 @@ import '../../core/data/services/catalog_controller.dart'; import '../../core/formatting/count_format.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; +import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/empty_state.dart'; import '../../core/widgets/paged_local_list.dart'; @@ -19,20 +20,9 @@ import '../../l10n/app_localizations.dart'; import '../player/open_player.dart'; const int _topCount = 50; -const double _podiumGap = 8; -const double _coverRadius = 12; -const double _titleSlotHeight = 24; -const double _rowGap = 12; -const double _rankColumnWidth = 56; -const double _listCoverWidth = 72; -const double _listCoverHeight = 96; -const double _loadMoreThreshold = 200; -const Color _goldStart = AppColors.gold; -const Color _goldEnd = AppColors.goldDeep; -const Color _bronzeStart = AppColors.bronze; -const Color _bronzeEnd = AppColors.bronzeDeep; +const double _loadMoreThreshold = 200, _championLift = 12; -/// 剧场榜单页:前三名横排领奖台,第 4 名起纵向列表。 +/// 剧场榜单页:前三名领奖台,第 4 名起为单列排行行。 class RankingPage extends StatefulWidget { const RankingPage({super.key, this.embedded = false}); @@ -93,12 +83,10 @@ class _RankingPageState extends State { bool _onScroll(ScrollNotification notification) { if (notification.depth != 0 || _refreshing) return false; - final reachedBottom = - notification.metrics.extentAfter <= _loadMoreThreshold; - if (reachedBottom && - (notification is ScrollUpdateNotification || - notification is ScrollEndNotification || - notification is OverscrollNotification)) { + if (notification.metrics.extentAfter > _loadMoreThreshold) return false; + if (notification is ScrollUpdateNotification || + notification is ScrollEndNotification || + notification is OverscrollNotification) { unawaited(_pager!.loadMore()); } return false; @@ -107,10 +95,10 @@ class _RankingPageState extends State { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final pager = _pager!; - final top = pager.visibleItems; - final podium = top.take(3).toList(); - final rest = top.skip(3).toList(); + final catalog = context.watch(); + final top = _pager!.visibleItems; + final podium = top.take(3).toList(growable: false); + final rest = top.skip(3).toList(growable: false); final content = NotificationListener( onNotification: _onScroll, child: AppPullToRefresh( @@ -120,39 +108,47 @@ class _RankingPageState extends State { key: const Key('ranking-list'), controller: _scroll, physics: AppPullToRefresh.physics, - padding: const EdgeInsets.only(top: 4, bottom: 24), + padding: const EdgeInsets.only(top: 8, bottom: 24), children: top.isEmpty - ? [ - SizedBox( - height: 420, - child: EmptyState( - icon: Icons.leaderboard_outlined, - title: l10n.noStories, - ), - ), - ] + ? [_emptyState(l10n, catalog)] : [ + if (widget.embedded) _intro(l10n), + const SizedBox(height: AppDimens.spaceMd), if (podium.isNotEmpty) Padding( - padding: const EdgeInsets.symmetric(horizontal: 14), + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.feedPageHorizontal, + ), child: _Podium(podium: podium, l10n: l10n), ), if (rest.isNotEmpty) ...[ - const SizedBox(height: 16), + const SizedBox(height: AppDimens.spaceXl), for (var index = 0; index < rest.length; index++) ...[ - if (index > 0) const SizedBox(height: _rowGap), Padding( - padding: const EdgeInsets.symmetric(horizontal: 14), + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.feedPageHorizontal, + ), child: _RankingRow( index: index + 3, drama: rest[index], l10n: l10n, ), ), + if (index != rest.length - 1) + const Padding( + padding: EdgeInsets.symmetric( + horizontal: AppDimens.feedPageHorizontal, + ), + child: Divider( + height: AppDimens.mineGridDividerWidth, + thickness: AppDimens.mineGridDividerWidth, + color: AppColors.border, + ), + ), ], ], PagedLoadFooter( - loading: pager.loading, + loading: _pager!.loading, loadMoreKey: const Key('ranking-load-more'), ), ], @@ -160,9 +156,9 @@ class _RankingPageState extends State { ), ); if (widget.embedded) { - return ColoredBox( + return DecoratedBox( key: const Key('ranking-page'), - color: AppColors.appBackground, + decoration: const BoxDecoration(gradient: AppTheme.pageGradient), child: SafeArea(bottom: false, child: content), ); } @@ -174,6 +170,59 @@ class _RankingPageState extends State { child: content, ); } + + Widget _intro(AppLocalizations l10n) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.feedPageHorizontal, + ), + child: Column( + key: const Key('ranking-hero'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.topCharts, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.rankingHeroTitle, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + l10n.rankingSubtitle, + style: const TextStyle( + color: AppColors.textTertiary, + fontSize: AppDimens.rankingHeroSubtitle, + ), + ), + ], + ), + ); + } + + Widget _emptyState(AppLocalizations l10n, CatalogController catalog) { + final theme = Theme.of(context); + return SizedBox( + height: 420, + child: Theme( + data: theme.copyWith( + colorScheme: theme.colorScheme.copyWith( + onSurface: AppColors.textTertiary, + ), + ), + child: EmptyState( + key: Key(catalog.error == null ? 'ranking-empty' : 'ranking-error'), + icon: catalog.error == null + ? Icons.leaderboard_outlined + : Icons.cloud_off_rounded, + title: catalog.error == null ? l10n.noStories : l10n.catalogError, + actionLabel: catalog.error == null ? null : l10n.retry, + onAction: catalog.error == null ? null : () => unawaited(_refresh()), + ), + ), + ); + } } class _Podium extends StatelessWidget { @@ -187,19 +236,32 @@ class _Podium extends StatelessWidget { return Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ - Expanded(flex: 100, child: _slot(2)), - const SizedBox(width: _podiumGap), - Expanded(flex: 115, child: _slot(1)), - const SizedBox(width: _podiumGap), - Expanded(flex: 100, child: _slot(3)), + SizedBox( + width: AppDimens.rankingPodiumWidth, + child: podium.length > 1 + ? _PodiumCard(rank: 2, drama: podium[1], l10n: l10n) + : const SizedBox.shrink(), + ), + const Spacer(), + SizedBox( + width: AppDimens.rankingPodiumChampionWidth, + child: podium.isNotEmpty + ? Transform.translate( + offset: const Offset(0, -_championLift), + child: _PodiumCard(rank: 1, drama: podium.first, l10n: l10n), + ) + : const SizedBox.shrink(), + ), + const Spacer(), + SizedBox( + width: AppDimens.rankingPodiumWidth, + child: podium.length > 2 + ? _PodiumCard(rank: 3, drama: podium[2], l10n: l10n) + : const SizedBox.shrink(), + ), ], ); } - - Widget _slot(int rank) { - if (podium.length < rank) return const SizedBox.shrink(); - return _PodiumCard(rank: rank, drama: podium[rank - 1], l10n: l10n); - } } class _PodiumCard extends StatelessWidget { @@ -213,26 +275,23 @@ class _PodiumCard extends StatelessWidget { final Drama drama; final AppLocalizations l10n; - bool get _center => rank == 1; + double get _coverWidth => rank == 1 + ? AppDimens.rankingPodiumChampionWidth + : AppDimens.rankingPodiumWidth; - LinearGradient get _badgeGradient => switch (rank) { - 1 => const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [AppColors.blue, AppColors.bluePressed], - ), - 2 => const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [_goldStart, _goldEnd], - ), - _ => const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [_bronzeStart, _bronzeEnd], - ), + double get _coverHeight => rank == 1 + ? AppDimens.rankingPodiumChampionHeight + : AppDimens.rankingPodiumHeight; + + Color get _badgeColor => switch (rank) { + 1 => AppColors.homeCrownGold, + 2 => AppColors.rankSilver, + _ => AppColors.bronze, }; + Color get _badgeTextColor => + rank == 3 ? AppColors.textPrimary : AppColors.onGold; + @override Widget build(BuildContext context) { return GestureDetector( @@ -241,10 +300,11 @@ class _PodiumCard extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ ClipRRect( - borderRadius: BorderRadius.circular(_coverRadius), - child: AspectRatio( + borderRadius: BorderRadius.circular(AppDimens.rankingPodiumRadius), + child: SizedBox( key: Key('ranking-cover-$rank'), - aspectRatio: 3 / 4, + width: _coverWidth, + height: _coverHeight, child: Stack( fit: StackFit.expand, children: [ @@ -255,23 +315,23 @@ class _PodiumCard extends StatelessWidget { fallbackAspectRatio: 3 / 4, ), Positioned( - top: 8, - left: 8, + top: 4, + left: 4, child: Container( key: Key('ranking-top-$rank'), padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 3, + horizontal: 6, + vertical: 2, ), decoration: BoxDecoration( - gradient: _badgeGradient, - borderRadius: BorderRadius.circular(999), + color: _badgeColor, + borderRadius: BorderRadius.circular(6), ), child: Text( l10n.topLabel(rank), maxLines: 1, - style: const TextStyle( - color: AppColors.onPrimary, + style: TextStyle( + color: _badgeTextColor, fontSize: 11, fontWeight: FontWeight.w800, ), @@ -284,22 +344,25 @@ class _PodiumCard extends StatelessWidget { ), const SizedBox(height: 6), SizedBox( - height: _titleSlotHeight, - child: Center( - child: Text( - drama.title, - key: Key('ranking-title-$rank'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: TextStyle( - color: AppColors.onSurface, - fontSize: _center ? 15 : 13, - fontWeight: _center ? FontWeight.w800 : FontWeight.w700, - ), + height: 34, + child: Text( + drama.title, + key: Key('ranking-title-$rank'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w600, + height: 1.25, ), ), ), + if (drama.viewCount > 0) ...[ + const SizedBox(height: 2), + _heat(drama.viewCount, l10n), + ], ], ), ); @@ -319,164 +382,119 @@ class _RankingRow extends StatelessWidget { int get _rank => index + 1; - List _tagValues() { - final result = []; + String get _category { + final values = {}; for (final value in [drama.genre, drama.category]) { for (final part in value.split(RegExp(r'[,/、·]'))) { final tag = part.trim(); - if (tag.isEmpty || result.contains(tag)) continue; - result.add(tag); - if (result.length == 3) return result; + if (tag.isNotEmpty) values.add(tag); } } - return result; + return values.join(' · '); } @override Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(AppDimens.cardRadius), - border: Border.all(color: AppColors.border), - ), + return SizedBox( + height: AppDimens.rankingRowHeight, child: Material( color: AppColors.transparent, - borderRadius: BorderRadius.circular(AppDimens.cardRadius), - clipBehavior: Clip.antiAlias, child: InkWell( key: Key('ranking-row-$index'), onTap: () => unawaited(playDramaFromCatalog(context, drama)), - child: Padding( - padding: const EdgeInsets.all(10), - child: Row( - children: [ - SizedBox( - width: _rankColumnWidth, - child: Text( - l10n.topLabel(_rank), - key: Key('ranking-top-$_rank'), - textAlign: TextAlign.center, - maxLines: 1, - style: const TextStyle( - color: AppColors.subtle, - fontSize: 18, - fontWeight: FontWeight.w800, - ), + child: Row( + children: [ + SizedBox( + width: AppDimens.rankingNumberWidth, + child: Text( + '$_rank', + key: Key('ranking-top-$_rank'), + textAlign: TextAlign.center, + maxLines: 1, + style: TextStyle( + color: AppColors.textPrimary.withValues(alpha: 0.12), + fontSize: AppDimens.rankingNumberFont, + fontWeight: FontWeight.w800, + height: 1, ), ), - ClipRRect( - borderRadius: BorderRadius.circular(10), - child: SizedBox( - width: _listCoverWidth, - height: _listCoverHeight, - child: RemoteImage( - drama.posterUrl, - fit: BoxFit.cover, - showFallbackIcon: true, - fallbackAspectRatio: 3 / 4, - ), + ), + ClipRRect( + borderRadius: BorderRadius.circular( + AppDimens.rankingCoverRadius, + ), + child: SizedBox( + key: Key('ranking-row-cover-$_rank'), + width: AppDimens.rankingCoverWidth, + height: AppDimens.rankingCoverHeight, + child: RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, + fallbackAspectRatio: 3 / 4, ), ), - const SizedBox(width: 12), - Expanded( + ), + const SizedBox(width: AppDimens.spaceMd), + Expanded( + child: SizedBox( + height: AppDimens.rankingCoverHeight, child: Column( crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, children: [ Text( drama.title, - maxLines: 2, + maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - color: AppColors.onSurface, + color: AppColors.textPrimary, fontSize: 15, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w500, ), ), - if (drama.synopsis.trim().isNotEmpty) ...[ - const SizedBox(height: 4), + if (_category.isNotEmpty) ...[ + const SizedBox(height: AppDimens.spaceXs), Text( - drama.synopsis, - maxLines: 2, + _category, + maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - color: AppColors.muted, - fontSize: 12, - height: 1.3, + color: AppColors.textTertiary, + fontSize: 11, ), ), ], - if (_tagValues().isNotEmpty) ...[ - const SizedBox(height: 6), - Wrap( - spacing: 6, - runSpacing: 4, - children: [ - for (final tag in _tagValues()) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: AppColors.raised, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - tag, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.muted, - fontSize: 11, - ), - ), - ), - ], - ), - ], - const SizedBox(height: 8), - Row( - children: [ - const Icon( - Icons.local_fire_department_rounded, - color: AppColors.blue, - size: 12, - ), - const SizedBox(width: 4), - Flexible( - child: Text( - l10n.heatLabel( - formatInteractionCount(drama.viewCount), - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.muted, - fontSize: 11, - ), - ), - ), - const SizedBox(width: 8), - Text( - l10n.episodeCount(drama.episodeCount), - maxLines: 1, - style: const TextStyle( - color: AppColors.muted, - fontSize: 11, - ), - ), - ], - ), + const Spacer(), + if (drama.viewCount > 0) _heat(drama.viewCount, l10n), ], ), ), - ], - ), + ), + ], ), ), ), ); } } + +Widget _heat(int count, AppLocalizations l10n) { + return Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.local_fire_department_rounded, + color: AppColors.textTertiary, + size: 11, + ), + const SizedBox(width: 4), + Text( + l10n.heatLabel(formatPlayCount(count)), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: AppColors.textTertiary, fontSize: 11), + ), + ], + ); +} diff --git a/test/discover/theater_pages_test.dart b/test/discover/theater_pages_test.dart index 95757e0..23d4ccb 100644 --- a/test/discover/theater_pages_test.dart +++ b/test/discover/theater_pages_test.dart @@ -117,8 +117,8 @@ void main() { expect(top3.right, closeTo(376, 0.5)); expect(top2.left, lessThan(top1.left)); expect(top3.left, greaterThan(top1.right)); - expect(top1.bottom, closeTo(top2.bottom, 0.5)); - expect(top1.bottom, closeTo(top3.bottom, 0.5)); + expect(top1.bottom, closeTo(top2.bottom - 12, 0.5)); + expect(top2.bottom, closeTo(top3.bottom, 0.5)); expect(find.byKey(const Key('ranking-row-3')), findsNothing); }); @@ -129,13 +129,19 @@ void main() { expect(find.byKey(const Key('ranking-row-3')), findsOneWidget); expect(find.byKey(const Key('ranking-row-2')), findsNothing); - expect(find.byKey(const Key('ranking-top-4')), findsOneWidget); + final rankNumber = tester.widget( + find.byKey(const Key('ranking-top-4')), + ); + expect(rankNumber.data, '4'); + expect(rankNumber.style?.fontSize, 48); + expect(rankNumber.style?.fontWeight, FontWeight.w800); expect( - find.descendant( - of: find.byKey(const Key('ranking-row-3')), - matching: find.text('synopsis'), - ), - findsOneWidget, + tester.getSize(find.byKey(const Key('ranking-row-3'))), + const Size(362, 96), + ); + expect( + tester.getSize(find.byKey(const Key('ranking-row-cover-4'))), + const Size(60, 80), ); }); diff --git a/test/home/home_grid_layout_test.dart b/test/home/home_grid_layout_test.dart index 56d64d5..4c46e99 100644 --- a/test/home/home_grid_layout_test.dart +++ b/test/home/home_grid_layout_test.dart @@ -114,8 +114,9 @@ void main() { await tester.tap(find.byKey(const Key('home-section-ranking'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('ranking-page')), findsNothing); - expect(firstDramaId(), 'page1-0'); + expect(find.byKey(const Key('ranking-page')), findsOneWidget); + expect(find.byKey(const Key('ranking-hero')), findsOneWidget); + expect(find.byKey(const Key('home-grid-cards')), findsNothing); await tester.tap(find.byKey(const Key('home-section-categories'))); await tester.pumpAndSettle(); From 4140c4eb5099db49abbfcdb1716d9bd76677da0a Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 22:25:14 +0800 Subject: [PATCH 107/129] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E5=99=A8=E5=8F=8C=E5=87=BB=E7=82=B9=E8=B5=9E=E4=B8=8E?= =?UTF-8?q?=E9=A3=98=E5=BF=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 6 + lib/core/theme/app_colors.dart | 2 + lib/pages/player/player_view.dart | 3 + lib/pages/player/player_view_actions.dart | 23 +++ .../player/widgets/home_feed_overlay.dart | 41 ++++++ .../widgets/player_double_tap_hearts.dart | 132 ++++++++++++++++++ 6 files changed, 207 insertions(+) create mode 100644 lib/pages/player/widgets/player_double_tap_hearts.dart diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index d7c8081..88688bf 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1294,3 +1294,9 @@ $ flutter test - 首页榜单 tab 改为独立领奖台 + 单列排行布局,不再复用三列网格。 - 前三名按 2/1/3 排列,冠军放大并上移;第 4 名起使用大号描边名次与横向信息行。 - 榜单沿用现有播放热度排序、分页和播放逻辑;`flutter analyze`:No issues found。 + +## 2026-10-22 播放器双击点赞与飘心 + +- 沉浸式播放手势增加原生双击识别,双击优先于单击暂停,长按左右倍速与中间更多保持不变。 +- 双击仅对未点赞剧集调用现有点赞接口,已点赞或请求中只播动画;触发轻震动并在失败时回滚提示。 +- 飘心层使用独立 IgnorePointer Stack,动画完成后自动移除;`flutter analyze`:No issues found。 diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index cbc1920..56ab3f8 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -60,6 +60,8 @@ abstract final class AppColors { /// 收藏与喜欢(非主色铺色,独立强调)。 static const favorite = Color(0xFFFF6A8A); + static const doubleTapHeartRed = Color(0xFFFF4D6D); + static const doubleTapHeartPink = Color(0xFFFF7A9A); /// 金币底色上的文字。 static const onGold = Color(0xFF3D2408); diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 3c2b3ef..7379f51 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -24,6 +24,7 @@ import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; +import '../../core/widgets/centered_toast.dart'; import '../../core/widgets/download_action.dart'; import '../../l10n/app_localizations.dart'; import '../home/home_feed_channel.dart'; @@ -655,6 +656,8 @@ class _PlayerPageState extends State } } }, + onDoubleTapLike: (_) => + unawaited(_likeFromDoubleTap(context, interactions, episode)), onSave: () => unawaited(_toggleSave(context, drama)), onComments: () => unawaited(_openComments(context, drama, episode)), onShare: () => logic.shareDrama(context, drama), diff --git a/lib/pages/player/player_view_actions.dart b/lib/pages/player/player_view_actions.dart index 448b7c2..6f58164 100644 --- a/lib/pages/player/player_view_actions.dart +++ b/lib/pages/player/player_view_actions.dart @@ -96,6 +96,29 @@ extension _PlayerViewActions on _PlayerPageState { ); } + Future _likeFromDoubleTap( + BuildContext context, + InteractionController interactions, + Episode episode, + ) async { + unawaited(context.read().lightImpact()); + if (interactions.isEpisodeLiked(episode.id) || + interactions.isEpisodeBusy(episode.id)) { + return; + } + if (!await ensureSignedIn(context) || !context.mounted) return; + if (interactions.isEpisodeLiked(episode.id) || + interactions.isEpisodeBusy(episode.id)) { + return; + } + final result = await interactions.toggleEpisodeLike(episode.id); + if (result.isSuccess || !context.mounted) return; + showCenteredToast( + context, + result.errorMessage ?? AppLocalizations.of(context)!.likeFailed, + ); + } + Future _toggleSave(BuildContext context, Drama drama) async { final favorites = context.read(); if (!await ensureSignedIn(context) || !context.mounted) return; diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index f3ed2e1..88295de 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -14,6 +14,7 @@ import '../../../core/theme/app_colors.dart'; import '../../../l10n/app_localizations.dart'; import 'home_feed_recommend_panel.dart'; import 'loading_progress_track.dart'; +import 'player_double_tap_hearts.dart'; import 'seek_progress_bar.dart'; part 'home_feed_overlay_parts.dart'; @@ -54,6 +55,7 @@ class HomeFeedOverlay extends StatefulWidget { required this.onBoostStart, required this.onBoostEnd, required this.onMore, + this.onDoubleTapLike, this.likeCount, this.favoriteCount, this.commentCount, @@ -91,6 +93,7 @@ class HomeFeedOverlay extends StatefulWidget { final VoidCallback onBoostStart; final VoidCallback onBoostEnd; final VoidCallback onMore; + final ValueChanged? onDoubleTapLike; final VoidCallback? onScrubStart; final ValueChanged? onScrubEnd; final ValueChanged? onScrubVisibilityChanged; @@ -106,6 +109,9 @@ class HomeFeedOverlay extends StatefulWidget { class _HomeFeedOverlayState extends State { bool _scrubbing = false; bool _wasVisibleBeforeScrub = true; + Offset? _doubleTapPosition; + int _nextHeartBurstId = 0; + final List<({int id, Offset position})> _heartBursts = []; @override void didUpdateWidget(covariant HomeFeedOverlay oldWidget) { @@ -130,6 +136,9 @@ class _HomeFeedOverlayState extends State { key: const Key('home-feed-gesture'), behavior: HitTestBehavior.opaque, onTap: _handleSurfaceTap, + onDoubleTapDown: (details) => + _doubleTapPosition = details.localPosition, + onDoubleTap: _handleDoubleTap, onLongPressStart: (details) => _handleLongPressStart(details, constraints.maxWidth), onLongPressEnd: (_) => widget.onBoostEnd(), @@ -193,6 +202,25 @@ class _HomeFeedOverlayState extends State { onSeek: widget.onSeek, ), ), + if (_heartBursts.isNotEmpty) + Positioned.fill( + child: IgnorePointer( + child: Stack( + clipBehavior: Clip.none, + children: [ + for (final burst in _heartBursts) + Positioned( + left: burst.position.dx, + top: burst.position.dy - 10, + child: PlayerDoubleTapHeartBurst( + key: ValueKey(burst.id), + onCompleted: () => _removeHeartBurst(burst.id), + ), + ), + ], + ), + ), + ), ], ), ); @@ -218,6 +246,19 @@ class _HomeFeedOverlayState extends State { widget.onTogglePlay(); } + void _handleDoubleTap() { + final position = _doubleTapPosition; + if (position == null) return; + final id = _nextHeartBurstId++; + setState(() => _heartBursts.add((id: id, position: position))); + widget.onDoubleTapLike?.call(position); + } + + void _removeHeartBurst(int id) { + if (!mounted) return; + setState(() => _heartBursts.removeWhere((burst) => burst.id == id)); + } + void _handleLongPressStart(LongPressStartDetails details, double width) { final third = width / 3; final dx = details.localPosition.dx; diff --git a/lib/pages/player/widgets/player_double_tap_hearts.dart b/lib/pages/player/widgets/player_double_tap_hearts.dart new file mode 100644 index 0000000..6fc0e9c --- /dev/null +++ b/lib/pages/player/widgets/player_double_tap_hearts.dart @@ -0,0 +1,132 @@ +// Author: fruit.com.cn + +import 'dart:math'; + +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_colors.dart'; + +class PlayerDoubleTapHeartBurst extends StatefulWidget { + const PlayerDoubleTapHeartBurst({super.key, required this.onCompleted}); + + final VoidCallback onCompleted; + + @override + State createState() => + _PlayerDoubleTapHeartBurstState(); +} + +class _PlayerDoubleTapHeartBurstState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final List<_HeartParticle> _particles; + bool _completed = false; + + @override + void initState() { + super.initState(); + final random = Random(); + var delay = 0; + _particles = List.generate(6 + random.nextInt(3), (index) { + if (index > 0) delay += 40 + random.nextInt(21); + return _HeartParticle( + delay: delay, + duration: 700 + random.nextInt(201), + travel: 120 + random.nextInt(41), + drift: -30 + random.nextInt(61), + size: (22 + random.nextInt(13)).toDouble(), + color: random.nextBool() + ? AppColors.doubleTapHeartRed + : AppColors.doubleTapHeartPink, + ); + }); + final total = _particles + .map((particle) => particle.delay + particle.duration) + .reduce(max); + _controller = AnimationController( + vsync: this, + duration: Duration(milliseconds: total), + )..addStatusListener(_onStatus); + _controller.forward(); + } + + void _onStatus(AnimationStatus status) { + if (status != AnimationStatus.completed || _completed) return; + _completed = true; + widget.onCompleted(); + } + + @override + void dispose() { + _controller + ..removeStatusListener(_onStatus) + ..dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: SizedBox.shrink( + child: AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final elapsed = + _controller.value * _controller.duration!.inMilliseconds; + return Stack( + clipBehavior: Clip.none, + children: [ + for (final particle in _particles) _particle(particle, elapsed), + ], + ); + }, + ), + ), + ); + } + + Widget _particle(_HeartParticle particle, double elapsed) { + final progress = ((elapsed - particle.delay) / particle.duration).clamp( + 0.0, + 1.0, + ); + if (progress <= 0) return const SizedBox.shrink(); + final move = Curves.easeOutCubic.transform(progress); + final scale = progress < 0.3 + ? 0.55 + (1.15 - 0.55) * (progress / 0.3) + : 1.15 - (1.15 - 0.2) * ((progress - 0.3) / 0.7); + return Positioned( + left: particle.drift * move - particle.size / 2, + top: -particle.travel * move, + child: Opacity( + opacity: 1 - Curves.easeIn.transform(progress), + child: Transform.scale( + scale: scale, + child: Icon( + Icons.favorite_rounded, + color: particle.color, + size: particle.size, + ), + ), + ), + ); + } +} + +class _HeartParticle { + const _HeartParticle({ + required this.delay, + required this.duration, + required this.travel, + required this.drift, + required this.size, + required this.color, + }); + + final int delay; + final int duration; + final int travel; + final int drift; + final double size; + final Color color; +} From 68ccfa21c0e7ff71b062bb63d110c63871a23db0 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 22:30:01 +0800 Subject: [PATCH 108/129] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=E5=8F=8C?= =?UTF-8?q?=E5=87=BB=E7=82=B9=E8=B5=9E=E4=B8=8E=E5=BF=83=E5=BD=A2=E8=81=94?= =?UTF-8?q?=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 5 +- lib/pages/player/player_view.dart | 31 +--- lib/pages/player/player_view_actions.dart | 31 +--- .../player/widgets/home_feed_overlay.dart | 44 ++++-- .../widgets/home_feed_recommend_panel.dart | 6 +- .../player/widgets/interaction_rail.dart | 4 +- lib/pages/player/widgets/player_overlay.dart | 1 + test/player/double_tap_like_test.dart | 142 ++++++++++++++++++ 8 files changed, 200 insertions(+), 64 deletions(-) create mode 100644 test/player/double_tap_like_test.dart diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 88688bf..70a35d1 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1297,6 +1297,7 @@ $ flutter test ## 2026-10-22 播放器双击点赞与飘心 -- 沉浸式播放手势增加原生双击识别,双击优先于单击暂停,长按左右倍速与中间更多保持不变。 -- 双击仅对未点赞剧集调用现有点赞接口,已点赞或请求中只播动画;触发轻震动并在失败时回滚提示。 +- 沉浸式播放器改用 onTapUp + 280ms Timer 判定双击,双击取消单击,长按左右倍速与中间更多保持不变。 +- 双击和右侧心形共用点赞方法,成功后控制器通知并点亮红色实心心形,失败由 controller 回滚并使用 Toast。 - 飘心层使用独立 IgnorePointer Stack,动画完成后自动移除;`flutter analyze`:No issues found。 +- `flutter test test/player/double_tap_like_test.dart`:2 项测试全部通过。 diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 7379f51..f1f6d95 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -516,16 +516,8 @@ class _PlayerPageState extends State controller: videoPort?.controller, videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl, theater: theater, - onTap: () => _handleFeedSurfaceTap(), + onTap: widget.homeMode ? () {} : _handleFeedSurfaceTap, ), - if (widget.homeMode && _chromeVisible && !logic.chromeHidden) - Positioned.fill( - child: GestureDetector( - key: const Key('home-feed-video-toggle'), - behavior: HitTestBehavior.opaque, - onTap: _handleFeedSurfaceTap, - ), - ), // 票据三态之一:起播前正在向后端要签名地址 if (selected && logic.ticketLoading) const Center( @@ -640,24 +632,11 @@ class _PlayerPageState extends State commentCount: _commentCounts[episode.id] ?? episode.commentCount, saved: context.watch().isSaved(drama.id), onLike: () async { - if (await ensureSignedIn(context)) { - if (!context.mounted) return; - unawaited(context.read().lightImpact()); - final result = await interactions.toggleEpisodeLike(episode.id); - if (!result.isSuccess && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - result.errorMessage ?? - AppLocalizations.of(context)!.likeFailed, - ), - ), - ); - } - } + await _toggleLike(context, interactions, episode); }, - onDoubleTapLike: (_) => - unawaited(_likeFromDoubleTap(context, interactions, episode)), + onDoubleTapLike: (_) => unawaited( + _toggleLike(context, interactions, episode, likeOnly: true), + ), onSave: () => unawaited(_toggleSave(context, drama)), onComments: () => unawaited(_openComments(context, drama, episode)), onShare: () => logic.shareDrama(context, drama), diff --git a/lib/pages/player/player_view_actions.dart b/lib/pages/player/player_view_actions.dart index 6f58164..171047a 100644 --- a/lib/pages/player/player_view_actions.dart +++ b/lib/pages/player/player_view_actions.dart @@ -81,34 +81,17 @@ extension _PlayerViewActions on _PlayerPageState { Future _toggleLike( BuildContext context, InteractionController interactions, - Episode episode, - ) async { - if (!await ensureSignedIn(context) || !context.mounted) return; + Episode episode, { + bool likeOnly = false, + }) async { unawaited(context.read().lightImpact()); - final result = await interactions.toggleEpisodeLike(episode.id); - if (result.isSuccess || !context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - result.errorMessage ?? AppLocalizations.of(context)!.likeFailed, - ), - ), - ); - } - - Future _likeFromDoubleTap( - BuildContext context, - InteractionController interactions, - Episode episode, - ) async { - unawaited(context.read().lightImpact()); - if (interactions.isEpisodeLiked(episode.id) || - interactions.isEpisodeBusy(episode.id)) { + if (interactions.isEpisodeBusy(episode.id) || + (likeOnly && interactions.isEpisodeLiked(episode.id))) { return; } if (!await ensureSignedIn(context) || !context.mounted) return; - if (interactions.isEpisodeLiked(episode.id) || - interactions.isEpisodeBusy(episode.id)) { + if (interactions.isEpisodeBusy(episode.id) || + (likeOnly && interactions.isEpisodeLiked(episode.id))) { return; } final result = await interactions.toggleEpisodeLike(episode.id); diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index 88295de..cf182a2 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -107,9 +107,12 @@ class HomeFeedOverlay extends StatefulWidget { } class _HomeFeedOverlayState extends State { + static const Duration _singleTapDelay = Duration(milliseconds: 280); + bool _scrubbing = false; bool _wasVisibleBeforeScrub = true; - Offset? _doubleTapPosition; + Timer? _singleTapTimer; + DateTime? _lastTapAt; int _nextHeartBurstId = 0; final List<({int id, Offset position})> _heartBursts = []; @@ -121,6 +124,12 @@ class _HomeFeedOverlayState extends State { } } + @override + void dispose() { + _singleTapTimer?.cancel(); + super.dispose(); + } + @override Widget build(BuildContext context) { return SafeArea( @@ -135,10 +144,7 @@ class _HomeFeedOverlayState extends State { child: GestureDetector( key: const Key('home-feed-gesture'), behavior: HitTestBehavior.opaque, - onTap: _handleSurfaceTap, - onDoubleTapDown: (details) => - _doubleTapPosition = details.localPosition, - onDoubleTap: _handleDoubleTap, + onTapUp: _handleTapUp, onLongPressStart: (details) => _handleLongPressStart(details, constraints.maxWidth), onLongPressEnd: (_) => widget.onBoostEnd(), @@ -242,13 +248,28 @@ class _HomeFeedOverlayState extends State { widget.onScrubEnd?.call(wasVisible); } - void _handleSurfaceTap() { - widget.onTogglePlay(); + void _handleTapUp(TapUpDetails details) { + final now = DateTime.now(); + final lastTap = _lastTapAt; + if (_singleTapTimer != null && + lastTap != null && + now.difference(lastTap) <= _singleTapDelay) { + _singleTapTimer?.cancel(); + _singleTapTimer = null; + _lastTapAt = null; + _showHearts(details.localPosition); + return; + } + _lastTapAt = now; + _singleTapTimer?.cancel(); + _singleTapTimer = Timer(_singleTapDelay, () { + _singleTapTimer = null; + _lastTapAt = null; + if (mounted) widget.onTogglePlay(); + }); } - void _handleDoubleTap() { - final position = _doubleTapPosition; - if (position == null) return; + void _showHearts(Offset position) { final id = _nextHeartBurstId++; setState(() => _heartBursts.add((id: id, position: position))); widget.onDoubleTapLike?.call(position); @@ -260,6 +281,9 @@ class _HomeFeedOverlayState extends State { } void _handleLongPressStart(LongPressStartDetails details, double width) { + _singleTapTimer?.cancel(); + _singleTapTimer = null; + _lastTapAt = null; final third = width / 3; final dx = details.localPosition.dx; if (dx < third || dx > width - third) { diff --git a/lib/pages/player/widgets/home_feed_recommend_panel.dart b/lib/pages/player/widgets/home_feed_recommend_panel.dart index 3299d01..4e509f5 100644 --- a/lib/pages/player/widgets/home_feed_recommend_panel.dart +++ b/lib/pages/player/widgets/home_feed_recommend_panel.dart @@ -371,6 +371,9 @@ class HomeFeedActionRail extends StatelessWidget { icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded, + iconColor: liked + ? AppColors.doubleTapHeartRed + : AppColors.textPrimary, label: likeCount == null ? l10n.like : formatInteractionCount(likeCount!), @@ -397,6 +400,7 @@ class HomeFeedActionRail extends StatelessWidget { required String label, required Key labelKey, required VoidCallback onTap, + Color iconColor = AppColors.textPrimary, double labelFontSize = 12, }) { return GestureDetector( @@ -409,7 +413,7 @@ class HomeFeedActionRail extends StatelessWidget { Icon( icon, size: _actionIcon, - color: AppColors.textPrimary, + color: iconColor, shadows: const [ Shadow( color: AppColors.shadowSoft, diff --git a/lib/pages/player/widgets/interaction_rail.dart b/lib/pages/player/widgets/interaction_rail.dart index 3ee085d..48f0c98 100644 --- a/lib/pages/player/widgets/interaction_rail.dart +++ b/lib/pages/player/widgets/interaction_rail.dart @@ -19,6 +19,7 @@ class InteractionRailItem { this.count, this.countKey, this.selected = false, + this.selectedColor = AppColors.blue, }); final Key key; @@ -28,6 +29,7 @@ class InteractionRailItem { final int? count; final Key? countKey; final bool selected; + final Color selectedColor; } /// 播放页与首页 feed 共用的右侧互动栏:收藏/评论/喜欢/分享。 @@ -71,7 +73,7 @@ class _RailButton extends StatelessWidget { width: _iconSize, height: _iconSize, fit: BoxFit.contain, - color: item.selected ? AppColors.blue : AppColors.onPrimary, + color: item.selected ? item.selectedColor : AppColors.onPrimary, colorBlendMode: BlendMode.srcIn, ), ), diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index 9781bf3..792c1fd 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -170,6 +170,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { count: data.likeCount, countKey: const Key('player-rail-like-count'), selected: data.liked, + selectedColor: AppColors.doubleTapHeartRed, onTap: actions.onLike, ), InteractionRailItem( diff --git a/test/player/double_tap_like_test.dart b/test/player/double_tap_like_test.dart new file mode 100644 index 0000000..cd5ecb3 --- /dev/null +++ b/test/player/double_tap_like_test.dart @@ -0,0 +1,142 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/services/interaction_controller.dart'; +import 'package:fruitshorts/core/theme/app_theme.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/player/widgets/home_feed_overlay.dart'; + +import '../support/fake_catalog.dart'; +import '../support/fake_haptics.dart'; +import '../support/fake_interaction_repositories.dart'; + +void main() { + testWidgets('quick double tap likes without pausing', (tester) async { + final repository = FakeInteractionRepository(); + final haptics = FakeHaptics(); + final key = GlobalKey<_HarnessState>(); + await _pumpHarness(tester, repository, haptics, key); + + final gesture = find.byKey(const Key('home-feed-gesture')); + final center = tester.getCenter(gesture); + + await tester.tapAt(center); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tapAt(center); + await tester.pumpAndSettle(); + + expect(key.currentState!.pauseToggles, 0); + expect(repository.likeTargets, hasLength(1)); + expect(haptics.lightImpacts, 1); + expect( + find.descendant( + of: find.byKey(const Key('rail-like')), + matching: find.byIcon(Icons.favorite_rounded), + ), + findsOneWidget, + ); + }); + + testWidgets('single tap toggles playback after 280ms', (tester) async { + final key = GlobalKey<_HarnessState>(); + await _pumpHarness(tester, FakeInteractionRepository(), FakeHaptics(), key); + + await tester.tapAt( + tester.getCenter(find.byKey(const Key('home-feed-gesture'))), + ); + await tester.pump(const Duration(milliseconds: 279)); + expect(key.currentState!.pauseToggles, 0); + + await tester.pump(const Duration(milliseconds: 2)); + expect(key.currentState!.pauseToggles, 1); + }); +} + +Future _pumpHarness( + WidgetTester tester, + FakeInteractionRepository repository, + FakeHaptics haptics, + GlobalKey<_HarnessState> key, +) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark(), + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: _Harness(key: key, repository: repository, haptics: haptics), + ), + ); + await tester.pumpAndSettle(); +} + +class _Harness extends StatefulWidget { + const _Harness({super.key, required this.repository, required this.haptics}); + + final FakeInteractionRepository repository; + final FakeHaptics haptics; + + @override + State<_Harness> createState() => _HarnessState(); +} + +class _HarnessState extends State<_Harness> { + late final InteractionController interactions = InteractionController( + widget.repository, + ); + int pauseToggles = 0; + + Future _like({bool likeOnly = false}) async { + await widget.haptics.lightImpact(); + final episodeId = demoDramas.first.episodes.first.id; + if (interactions.isEpisodeBusy(episodeId) || + (likeOnly && interactions.isEpisodeLiked(episodeId))) { + return; + } + await interactions.toggleEpisodeLike(episodeId); + } + + @override + void dispose() { + interactions.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Scaffold( + backgroundColor: Colors.black, + body: ListenableBuilder( + listenable: interactions, + builder: (context, _) { + final episode = demoDramas.first.episodes.first; + return HomeFeedOverlay( + l10n: l10n, + drama: demoDramas.first, + episode: episode, + controller: null, + locked: false, + liked: interactions.isEpisodeLiked(episode.id), + likeCount: interactions.episodeLikeCount(episode.id), + saved: false, + visible: true, + boosting: false, + onLike: () => _like(), + onDoubleTapLike: (_) => _like(likeOnly: true), + onSave: () {}, + onComments: () {}, + onShare: () {}, + onDetail: () {}, + onOpenFullPlayer: () {}, + onTogglePlay: () => setState(() => pauseToggles++), + onBoostStart: () {}, + onBoostEnd: () {}, + onMore: () {}, + ); + }, + ), + ); + } +} From 5794dd4e4d0c482792a969ebf170eafec505c573 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 22:34:18 +0800 Subject: [PATCH 109/129] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E5=99=A8=E9=87=8D=E5=A4=8D=E6=9A=82=E5=81=9C=E6=89=8B?= =?UTF-8?q?=E5=8A=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 1 + lib/pages/player/player_view.dart | 35 ++++++++++++++----- .../widgets/player_double_tap_hearts.dart | 10 +++--- .../player/widgets/player_video_surface.dart | 27 +++++++------- test/player/double_tap_like_test.dart | 11 +++--- 5 files changed, 52 insertions(+), 32 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 70a35d1..2739e2c 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1301,3 +1301,4 @@ $ flutter test - 双击和右侧心形共用点赞方法,成功后控制器通知并点亮红色实心心形,失败由 controller 回滚并使用 Toast。 - 飘心层使用独立 IgnorePointer Stack,动画完成后自动移除;`flutter analyze`:No issues found。 - `flutter test test/player/double_tap_like_test.dart`:2 项测试全部通过。 +- 移除视频表面点击识别器,双击后增加 300ms 暂停防护;飘心收敛为 3~4 颗、20~26、±18、90~120dp、600~700ms。 diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index f1f6d95..894cd70 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -120,6 +120,8 @@ class _PlayerPageState extends State bool _scrubbing = false; double? _speedBeforeBoost; bool? _chromeBeforeBoost; + bool _doubleTapHandling = false; + Timer? _doubleTapGuardTimer; /// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。 final Map _favoriteDelta = {}; @@ -261,6 +263,7 @@ class _PlayerPageState extends State appRouteObserver.unsubscribe(this); _spriteAxisService.dispose(); _thumbnailService.dispose(); + _doubleTapGuardTimer?.cancel(); logic.dispose(); super.dispose(); } @@ -333,7 +336,7 @@ class _PlayerPageState extends State } void _handleFeedSurfaceTap() { - if (_scrubbing) return; + if (_scrubbing || _doubleTapHandling) return; if (!_chromeVisible) { _restoreFeedChrome(); return; @@ -341,6 +344,24 @@ class _PlayerPageState extends State logic.session.setUserWantsPlay(!logic.session.userWantsPlay); } + void _togglePlayback() { + if (_doubleTapHandling) return; + logic.session.setUserWantsPlay(!logic.session.userWantsPlay); + } + + void _handleDoubleTapLike( + BuildContext context, + InteractionController interactions, + Episode episode, + ) { + _doubleTapHandling = true; + _doubleTapGuardTimer?.cancel(); + _doubleTapGuardTimer = Timer(const Duration(milliseconds: 300), () { + _doubleTapHandling = false; + }); + unawaited(_toggleLike(context, interactions, episode, likeOnly: true)); + } + /// 收藏状态确认后同步右侧计数(总数没有独立计数接口,按 ±1 跟随)。 void _applyFavoriteDelta(String dramaId, {required bool nowSaved}) { setState(() { @@ -516,7 +537,6 @@ class _PlayerPageState extends State controller: videoPort?.controller, videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl, theater: theater, - onTap: widget.homeMode ? () {} : _handleFeedSurfaceTap, ), // 票据三态之一:起播前正在向后端要签名地址 if (selected && logic.ticketLoading) @@ -634,9 +654,8 @@ class _PlayerPageState extends State onLike: () async { await _toggleLike(context, interactions, episode); }, - onDoubleTapLike: (_) => unawaited( - _toggleLike(context, interactions, episode, likeOnly: true), - ), + onDoubleTapLike: (_) => + _handleDoubleTapLike(context, interactions, episode), onSave: () => unawaited(_toggleSave(context, drama)), onComments: () => unawaited(_openComments(context, drama, episode)), onShare: () => logic.shareDrama(context, drama), @@ -686,8 +705,7 @@ class _PlayerPageState extends State onBack: widget.embedded ? null : () => unawaited(logic.leave(context)), - onTogglePlay: () => - logic.session.setUserWantsPlay(!logic.session.userWantsPlay), + onTogglePlay: _togglePlayback, onClear: () => setState(() => _chromeVisible = false), onExitClear: () => setState(() => _chromeVisible = true), onDetail: () => unawaited(logic.openDetail(context)), @@ -743,8 +761,7 @@ class _PlayerPageState extends State child: PlayerCenterToggle( key: const Key('home-feed-center-play'), playing: false, - onTap: () => - logic.session.setUserWantsPlay(!logic.session.userWantsPlay), + onTap: _togglePlayback, ), ), ], diff --git a/lib/pages/player/widgets/player_double_tap_hearts.dart b/lib/pages/player/widgets/player_double_tap_hearts.dart index 6fc0e9c..68de8b9 100644 --- a/lib/pages/player/widgets/player_double_tap_hearts.dart +++ b/lib/pages/player/widgets/player_double_tap_hearts.dart @@ -27,14 +27,14 @@ class _PlayerDoubleTapHeartBurstState extends State super.initState(); final random = Random(); var delay = 0; - _particles = List.generate(6 + random.nextInt(3), (index) { + _particles = List.generate(3 + random.nextInt(2), (index) { if (index > 0) delay += 40 + random.nextInt(21); return _HeartParticle( delay: delay, - duration: 700 + random.nextInt(201), - travel: 120 + random.nextInt(41), - drift: -30 + random.nextInt(61), - size: (22 + random.nextInt(13)).toDouble(), + duration: 600 + random.nextInt(101), + travel: 90 + random.nextInt(31), + drift: -18 + random.nextInt(37), + size: (20 + random.nextInt(7)).toDouble(), color: random.nextBool() ? AppColors.doubleTapHeartRed : AppColors.doubleTapHeartPink, diff --git a/lib/pages/player/widgets/player_video_surface.dart b/lib/pages/player/widgets/player_video_surface.dart index 1636f31..670ccce 100644 --- a/lib/pages/player/widgets/player_video_surface.dart +++ b/lib/pages/player/widgets/player_video_surface.dart @@ -17,13 +17,13 @@ class PlayerVideoSurface extends StatefulWidget { required this.controller, required this.videoUrl, required this.theater, - required this.onTap, + this.onTap, }); final VideoPlayerController? controller; final String videoUrl; final bool theater; - final VoidCallback onTap; + final VoidCallback? onTap; @override State createState() => _PlayerVideoSurfaceState(); @@ -147,21 +147,24 @@ class _VideoLayer extends StatelessWidget { final VideoPlayerController controller; final VideoPlayerValue value; final bool theater; - final VoidCallback onTap; + final VoidCallback? onTap; @override Widget build(BuildContext context) { + final child = FittedBox( + fit: theater ? BoxFit.contain : BoxFit.cover, + child: SizedBox( + width: value.size.width, + height: value.size.height, + child: VideoPlayer(controller, key: ValueKey(controller)), + ), + ); + final tap = onTap; + if (tap == null) return child; return GestureDetector( behavior: HitTestBehavior.opaque, - onTap: onTap, - child: FittedBox( - fit: theater ? BoxFit.contain : BoxFit.cover, - child: SizedBox( - width: value.size.width, - height: value.size.height, - child: VideoPlayer(controller, key: ValueKey(controller)), - ), - ), + onTap: tap, + child: child, ); } } diff --git a/test/player/double_tap_like_test.dart b/test/player/double_tap_like_test.dart index cd5ecb3..3a44f04 100644 --- a/test/player/double_tap_like_test.dart +++ b/test/player/double_tap_like_test.dart @@ -24,7 +24,8 @@ void main() { await tester.tapAt(center); await tester.pump(const Duration(milliseconds: 100)); await tester.tapAt(center); - await tester.pumpAndSettle(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); expect(key.currentState!.pauseToggles, 0); expect(repository.likeTargets, hasLength(1)); @@ -36,19 +37,17 @@ void main() { ), findsOneWidget, ); + await tester.pumpAndSettle(); }); - testWidgets('single tap toggles playback after 280ms', (tester) async { + testWidgets('single tap toggles playback after 300ms', (tester) async { final key = GlobalKey<_HarnessState>(); await _pumpHarness(tester, FakeInteractionRepository(), FakeHaptics(), key); await tester.tapAt( tester.getCenter(find.byKey(const Key('home-feed-gesture'))), ); - await tester.pump(const Duration(milliseconds: 279)); - expect(key.currentState!.pauseToggles, 0); - - await tester.pump(const Duration(milliseconds: 2)); + await tester.pump(const Duration(milliseconds: 400)); expect(key.currentState!.pauseToggles, 1); }); } From bbc759473cdb166e62f5e1517de1c09bdd503cc8 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 22:39:21 +0800 Subject: [PATCH 110/129] =?UTF-8?q?fix:=20=E4=BD=BF=E7=94=A8=E7=82=B9?= =?UTF-8?q?=E5=87=BB=E5=BA=8F=E5=8F=B7=E9=98=B2=E6=AD=A2=E5=8F=8C=E5=87=BB?= =?UTF-8?q?=E8=AF=AF=E6=9A=82=E5=81=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 2 + lib/pages/player/player_view.dart | 21 ++-- .../player/widgets/home_feed_overlay.dart | 30 +++-- .../player/widgets/player_video_surface.dart | 14 +-- test/player/double_tap_like_test.dart | 105 +++++++++++++----- test/player/player_logic_test.dart | 1 - test/player/player_video_surface_test.dart | 1 - 7 files changed, 113 insertions(+), 61 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 2739e2c..39d0c4d 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1302,3 +1302,5 @@ $ flutter test - 飘心层使用独立 IgnorePointer Stack,动画完成后自动移除;`flutter analyze`:No issues found。 - `flutter test test/player/double_tap_like_test.dart`:2 项测试全部通过。 - 移除视频表面点击识别器,双击后增加 300ms 暂停防护;飘心收敛为 3~4 颗、20~26、±18、90~120dp、600~700ms。 +- 双击窗口延长至 320ms,单击 Timer 使用点击序号校验,双击后保留 350ms 播放切换保护。 +- `double_tap_like_test.dart` 覆盖 120/250/300ms 双击与 500ms 单击恢复:2 项测试全部通过。 diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 894cd70..12df1b8 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -120,8 +120,7 @@ class _PlayerPageState extends State bool _scrubbing = false; double? _speedBeforeBoost; bool? _chromeBeforeBoost; - bool _doubleTapHandling = false; - Timer? _doubleTapGuardTimer; + DateTime? _lastDoubleTapAt; /// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。 final Map _favoriteDelta = {}; @@ -263,7 +262,6 @@ class _PlayerPageState extends State appRouteObserver.unsubscribe(this); _spriteAxisService.dispose(); _thumbnailService.dispose(); - _doubleTapGuardTimer?.cancel(); logic.dispose(); super.dispose(); } @@ -336,7 +334,7 @@ class _PlayerPageState extends State } void _handleFeedSurfaceTap() { - if (_scrubbing || _doubleTapHandling) return; + if (_scrubbing || _doubleTapGuardActive) return; if (!_chromeVisible) { _restoreFeedChrome(); return; @@ -345,20 +343,23 @@ class _PlayerPageState extends State } void _togglePlayback() { - if (_doubleTapHandling) return; + if (_doubleTapGuardActive) return; logic.session.setUserWantsPlay(!logic.session.userWantsPlay); } + bool get _doubleTapGuardActive { + final lastDoubleTapAt = _lastDoubleTapAt; + return lastDoubleTapAt != null && + DateTime.now().difference(lastDoubleTapAt) < + const Duration(milliseconds: 350); + } + void _handleDoubleTapLike( BuildContext context, InteractionController interactions, Episode episode, ) { - _doubleTapHandling = true; - _doubleTapGuardTimer?.cancel(); - _doubleTapGuardTimer = Timer(const Duration(milliseconds: 300), () { - _doubleTapHandling = false; - }); + _lastDoubleTapAt = DateTime.now(); unawaited(_toggleLike(context, interactions, episode, likeOnly: true)); } diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index cf182a2..2c028cb 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -56,6 +56,7 @@ class HomeFeedOverlay extends StatefulWidget { required this.onBoostEnd, required this.onMore, this.onDoubleTapLike, + this.now, this.likeCount, this.favoriteCount, this.commentCount, @@ -94,6 +95,7 @@ class HomeFeedOverlay extends StatefulWidget { final VoidCallback onBoostEnd; final VoidCallback onMore; final ValueChanged? onDoubleTapLike; + final DateTime Function()? now; final VoidCallback? onScrubStart; final ValueChanged? onScrubEnd; final ValueChanged? onScrubVisibilityChanged; @@ -107,12 +109,13 @@ class HomeFeedOverlay extends StatefulWidget { } class _HomeFeedOverlayState extends State { - static const Duration _singleTapDelay = Duration(milliseconds: 280); + static const Duration _singleTapDelay = Duration(milliseconds: 320); bool _scrubbing = false; bool _wasVisibleBeforeScrub = true; Timer? _singleTapTimer; DateTime? _lastTapAt; + int _tapSeq = 0; int _nextHeartBurstId = 0; final List<({int id, Offset position})> _heartBursts = []; @@ -126,6 +129,7 @@ class _HomeFeedOverlayState extends State { @override void dispose() { + _tapSeq++; _singleTapTimer?.cancel(); super.dispose(); } @@ -249,11 +253,11 @@ class _HomeFeedOverlayState extends State { } void _handleTapUp(TapUpDetails details) { - final now = DateTime.now(); + final tapSeq = ++_tapSeq; + final now = widget.now?.call() ?? DateTime.now(); final lastTap = _lastTapAt; - if (_singleTapTimer != null && - lastTap != null && - now.difference(lastTap) <= _singleTapDelay) { + if (lastTap != null && now.difference(lastTap) <= _singleTapDelay) { + _tapSeq++; _singleTapTimer?.cancel(); _singleTapTimer = null; _lastTapAt = null; @@ -262,11 +266,22 @@ class _HomeFeedOverlayState extends State { } _lastTapAt = now; _singleTapTimer?.cancel(); - _singleTapTimer = Timer(_singleTapDelay, () { - _singleTapTimer = null; + late final Timer pending; + pending = Timer(_singleTapDelay, () { + if (identical(_singleTapTimer, pending)) { + _singleTapTimer = null; + } + if (tapSeq != _tapSeq) return; + final latestTap = _lastTapAt; + if (latestTap != null && + (widget.now?.call() ?? DateTime.now()).difference(latestTap) < + _singleTapDelay) { + return; + } _lastTapAt = null; if (mounted) widget.onTogglePlay(); }); + _singleTapTimer = pending; } void _showHearts(Offset position) { @@ -281,6 +296,7 @@ class _HomeFeedOverlayState extends State { } void _handleLongPressStart(LongPressStartDetails details, double width) { + _tapSeq++; _singleTapTimer?.cancel(); _singleTapTimer = null; _lastTapAt = null; diff --git a/lib/pages/player/widgets/player_video_surface.dart b/lib/pages/player/widgets/player_video_surface.dart index 670ccce..513dda7 100644 --- a/lib/pages/player/widgets/player_video_surface.dart +++ b/lib/pages/player/widgets/player_video_surface.dart @@ -17,13 +17,11 @@ class PlayerVideoSurface extends StatefulWidget { required this.controller, required this.videoUrl, required this.theater, - this.onTap, }); final VideoPlayerController? controller; final String videoUrl; final bool theater; - final VoidCallback? onTap; @override State createState() => _PlayerVideoSurfaceState(); @@ -69,7 +67,6 @@ class _PlayerVideoSurfaceState extends State { controller: controller, value: value!, theater: widget.theater, - onTap: widget.onTap, ) : const SizedBox.expand(), ), @@ -141,17 +138,15 @@ class _VideoLayer extends StatelessWidget { required this.controller, required this.value, required this.theater, - required this.onTap, }); final VideoPlayerController controller; final VideoPlayerValue value; final bool theater; - final VoidCallback? onTap; @override Widget build(BuildContext context) { - final child = FittedBox( + return FittedBox( fit: theater ? BoxFit.contain : BoxFit.cover, child: SizedBox( width: value.size.width, @@ -159,12 +154,5 @@ class _VideoLayer extends StatelessWidget { child: VideoPlayer(controller, key: ValueKey(controller)), ), ); - final tap = onTap; - if (tap == null) return child; - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: tap, - child: child, - ); } } diff --git a/test/player/double_tap_like_test.dart b/test/player/double_tap_like_test.dart index 3a44f04..b1f5ad6 100644 --- a/test/player/double_tap_like_test.dart +++ b/test/player/double_tap_like_test.dart @@ -12,43 +12,67 @@ import '../support/fake_haptics.dart'; import '../support/fake_interaction_repositories.dart'; void main() { - testWidgets('quick double tap likes without pausing', (tester) async { - final repository = FakeInteractionRepository(); - final haptics = FakeHaptics(); - final key = GlobalKey<_HarnessState>(); - await _pumpHarness(tester, repository, haptics, key); + testWidgets('double tap within 320ms never pauses', (tester) async { + for (final delay in [120, 250, 300]) { + final repository = FakeInteractionRepository(); + final haptics = FakeHaptics(); + final key = GlobalKey<_HarnessState>(); + final clock = _TestClock(); + await _pumpHarness(tester, repository, haptics, key, clock); - final gesture = find.byKey(const Key('home-feed-gesture')); - final center = tester.getCenter(gesture); + final center = tester.getCenter( + find.byKey(const Key('home-feed-gesture')), + ); + await tester.tapAt(center); + clock.advance(Duration(milliseconds: delay)); + await tester.pump(Duration(milliseconds: delay)); + await tester.tapAt(center); + await tester.pump(); + clock.advance(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 500)); - await tester.tapAt(center); - await tester.pump(const Duration(milliseconds: 100)); - await tester.tapAt(center); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 400)); - - expect(key.currentState!.pauseToggles, 0); - expect(repository.likeTargets, hasLength(1)); - expect(haptics.lightImpacts, 1); - expect( - find.descendant( - of: find.byKey(const Key('rail-like')), - matching: find.byIcon(Icons.favorite_rounded), - ), - findsOneWidget, - ); - await tester.pumpAndSettle(); + expect( + key.currentState!.pauseToggles, + 0, + reason: 'double tap with $delay ms gap paused playback', + ); + expect(repository.likeTargets, hasLength(1)); + expect(haptics.lightImpacts, 1); + expect( + find.descendant( + of: find.byKey(const Key('rail-like')), + matching: find.byIcon(Icons.favorite_rounded), + ), + findsOneWidget, + ); + await tester.pumpAndSettle(); + } }); - testWidgets('single tap toggles playback after 300ms', (tester) async { + testWidgets('taps 500ms apart toggle playback twice', (tester) async { final key = GlobalKey<_HarnessState>(); - await _pumpHarness(tester, FakeInteractionRepository(), FakeHaptics(), key); + final clock = _TestClock(); + await _pumpHarness( + tester, + FakeInteractionRepository(), + FakeHaptics(), + key, + clock, + ); await tester.tapAt( tester.getCenter(find.byKey(const Key('home-feed-gesture'))), ); - await tester.pump(const Duration(milliseconds: 400)); + clock.advance(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 500)); expect(key.currentState!.pauseToggles, 1); + + await tester.tapAt( + tester.getCenter(find.byKey(const Key('home-feed-gesture'))), + ); + clock.advance(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 500)); + expect(key.currentState!.pauseToggles, 2); }); } @@ -57,6 +81,7 @@ Future _pumpHarness( FakeInteractionRepository repository, FakeHaptics haptics, GlobalKey<_HarnessState> key, + _TestClock clock, ) async { await tester.pumpWidget( MaterialApp( @@ -64,17 +89,28 @@ Future _pumpHarness( locale: const Locale('en'), supportedLocales: AppLocalizations.supportedLocales, localizationsDelegates: AppLocalizations.localizationsDelegates, - home: _Harness(key: key, repository: repository, haptics: haptics), + home: _Harness( + key: key, + repository: repository, + haptics: haptics, + clock: clock, + ), ), ); await tester.pumpAndSettle(); } class _Harness extends StatefulWidget { - const _Harness({super.key, required this.repository, required this.haptics}); + const _Harness({ + super.key, + required this.repository, + required this.haptics, + required this.clock, + }); final FakeInteractionRepository repository; final FakeHaptics haptics; + final _TestClock clock; @override State<_Harness> createState() => _HarnessState(); @@ -124,6 +160,7 @@ class _HarnessState extends State<_Harness> { boosting: false, onLike: () => _like(), onDoubleTapLike: (_) => _like(likeOnly: true), + now: widget.clock.now, onSave: () {}, onComments: () {}, onShare: () {}, @@ -139,3 +176,13 @@ class _HarnessState extends State<_Harness> { ); } } + +class _TestClock { + DateTime _now = DateTime(2026, 10, 22, 12); + + DateTime now() => _now; + + void advance(Duration duration) { + _now = _now.add(duration); + } +} diff --git a/test/player/player_logic_test.dart b/test/player/player_logic_test.dart index a600ce7..4aadab2 100644 --- a/test/player/player_logic_test.dart +++ b/test/player/player_logic_test.dart @@ -1326,7 +1326,6 @@ class _SessionVideoSurface extends StatelessWidget { controller: video?.controller, videoUrl: video?.controller.dataSource ?? '', theater: false, - onTap: () {}, ), ); }, diff --git a/test/player/player_video_surface_test.dart b/test/player/player_video_surface_test.dart index 7ab4f3c..d9357bf 100644 --- a/test/player/player_video_surface_test.dart +++ b/test/player/player_video_surface_test.dart @@ -87,7 +87,6 @@ Future _pumpSurface( controller: controller, videoUrl: controller.dataSource, theater: false, - onTap: () {}, ), ), ), From 9aa797504b1b7f0e8666dedfbe5cd479c8c9e1f1 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 22:48:21 +0800 Subject: [PATCH 111/129] =?UTF-8?q?feat:=20=E7=BB=9F=E4=B8=80=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=E4=B8=8E=E6=8E=A8=E8=8D=90=E6=90=9C=E7=B4=A2=E5=85=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 6 + lib/l10n/app_en.arb | 1 + lib/l10n/app_id.arb | 1 + lib/l10n/app_localizations.dart | 6 + lib/l10n/app_localizations_en.dart | 3 + lib/l10n/app_localizations_id.dart | 3 + lib/l10n/app_localizations_zh.dart | 3 + lib/l10n/app_zh.arb | 1 + lib/pages/discover/discover_logic.dart | 14 - lib/pages/discover/discover_view.dart | 22 -- lib/pages/discover/search_page.dart | 256 ++++++++++++++++++ .../widgets/discover_search_results.dart | 11 +- lib/pages/home/home_logic.dart | 8 +- lib/pages/home/home_view.dart | 8 +- lib/pages/player/player_logic.dart | 21 +- test/discover/discover_logic_test.dart | 38 --- test/home/home_logic_test.dart | 9 +- test/home/k28_a_navigation_test.dart | 21 +- 18 files changed, 325 insertions(+), 107 deletions(-) create mode 100644 lib/pages/discover/search_page.dart diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 39d0c4d..4ca1922 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1304,3 +1304,9 @@ $ flutter test - 移除视频表面点击识别器,双击后增加 300ms 暂停防护;飘心收敛为 3~4 颗、20~26、±18、90~120dp、600~700ms。 - 双击窗口延长至 320ms,单击 Timer 使用点击序号校验,双击后保留 350ms 播放切换保护。 - `double_tap_like_test.dart` 覆盖 120/250/300ms 双击与 500ms 单击恢复:2 项测试全部通过。 + +## 2026-10-22 统一二级搜索页 + +- 首页与推荐/剧单/全部短剧搜索入口统一 push SearchPage,不再切换推荐 tab 聚焦。 +- 搜索页提供返回、自动聚焦输入框与取消,复用 `/app/search` 逻辑和首页三列卡片。 +- 清理旧的 searchRequest/focusSearch/syncSearch 参数;`flutter analyze`:No issues found。 diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 58f9e34..4549c2d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -16,6 +16,7 @@ "currentSeries": "Current series", "discoverTab": "Theater", "searchDiscover": "Search dramas, actors, genres...", + "searchCancel": "Cancel", "featured": "Featured", "recommended": "Recommended", "fruitShortsBrand": "FruitShorts", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index a05c235..f0ae1ba 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -16,6 +16,7 @@ "currentSeries": "Serial saat ini", "discoverTab": "Teater", "searchDiscover": "Cari drama, aktor, genre...", + "searchCancel": "Batal", "featured": "Unggulan", "recommended": "Rekomendasi", "fruitShortsBrand": "FruitShorts", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index e6d0fa0..42223d4 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -196,6 +196,12 @@ abstract class AppLocalizations { /// **'Search dramas, actors, genres...'** String get searchDiscover; + /// No description provided for @searchCancel. + /// + /// In en, this message translates to: + /// **'Cancel'** + String get searchCancel; + /// No description provided for @featured. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index a8d5b9e..96f45c6 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -57,6 +57,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get searchDiscover => 'Search dramas, actors, genres...'; + @override + String get searchCancel => 'Cancel'; + @override String get featured => 'Featured'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 8d05415..9a3aeb7 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -57,6 +57,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get searchDiscover => 'Cari drama, aktor, genre...'; + @override + String get searchCancel => 'Batal'; + @override String get featured => 'Unggulan'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index c4e1ab3..62f7489 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -56,6 +56,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get searchDiscover => '搜索短剧、演员、类型…'; + @override + String get searchCancel => '取消'; + @override String get featured => '精选'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 4954462..9af6280 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -16,6 +16,7 @@ "currentSeries": "当前短剧", "discoverTab": "剧场", "searchDiscover": "搜索短剧、演员、类型…", + "searchCancel": "取消", "featured": "精选", "recommended": "推荐", "fruitShortsBrand": "水果短剧", diff --git a/lib/pages/discover/discover_logic.dart b/lib/pages/discover/discover_logic.dart index d500ed7..7a96911 100644 --- a/lib/pages/discover/discover_logic.dart +++ b/lib/pages/discover/discover_logic.dart @@ -118,20 +118,6 @@ class DiscoverLogic extends ChangeNotifier { searchResults = const []; } - /// 从播放器发起的搜索请求只在剧场页可见时抢焦点。 - void syncSearch({ - required bool active, - required int request, - required int previousRequest, - required bool Function() isActive, - }) { - if (!active || request == previousRequest) return; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (isActive()) searchFocus.requestFocus(); - }); - WidgetsBinding.instance.scheduleFrame(); - } - void act( BuildContext context, DiscoverPageAction action, { diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index f82d713..6fe6d64 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -56,14 +56,10 @@ class DiscoverPage extends StatefulWidget { const DiscoverPage({ super.key, this.active = true, - this.searchRequest = 0, - this.focusSearch = false, this.callbacks = const DiscoverPageCallbacks(), }); final bool active; - final int searchRequest; - final bool focusSearch; final DiscoverPageCallbacks callbacks; @override @@ -89,7 +85,6 @@ class _DiscoverPageState extends State { logic.scroll.addListener(_onScroll); theater.addListener(_onTheaterChanged); unawaited(theater.select(null)); - if (widget.focusSearch) _focusOnFirstFrame(); } void _onScroll() { @@ -106,23 +101,6 @@ class _DiscoverPageState extends State { if (mounted) setState(() {}); } - void _focusOnFirstFrame() { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && widget.active) logic.openSearch(); - }); - } - - @override - void didUpdateWidget(covariant DiscoverPage oldWidget) { - super.didUpdateWidget(oldWidget); - logic.syncSearch( - active: widget.active, - request: widget.searchRequest, - previousRequest: oldWidget.searchRequest, - isActive: () => mounted && widget.active, - ); - } - @override void dispose() { logic.scroll.removeListener(_onScroll); diff --git a/lib/pages/discover/search_page.dart b/lib/pages/discover/search_page.dart new file mode 100644 index 0000000..bc4ac1c --- /dev/null +++ b/lib/pages/discover/search_page.dart @@ -0,0 +1,256 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import '../../core/data/repositories/search_repository.dart'; +import '../../core/formatting/error_text.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_dimens.dart'; +import '../../core/theme/app_theme.dart'; +import '../../core/widgets/empty_state.dart'; +import '../../l10n/app_localizations.dart'; +import '../home/widgets/home_feed_card.dart'; +import '../player/open_player.dart'; +import 'discover_binding.dart'; +import 'discover_logic.dart'; + +class SearchPage extends StatefulWidget { + const SearchPage({super.key}); + + @override + State createState() => _SearchPageState(); +} + +class _SearchPageState extends State { + late final DiscoverLogic _logic; + + @override + void initState() { + super.initState(); + _logic = DiscoverBinding().create(search: context.read()); + _logic.addListener(_rebuild); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _logic.searchFocus.requestFocus(); + }); + } + + void _rebuild() { + if (mounted) setState(() {}); + } + + @override + void dispose() { + _logic + ..removeListener(_rebuild) + ..dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return AnnotatedRegion( + value: SystemUiOverlayStyle.light, + child: Theme( + data: AppTheme.dark(), + child: Scaffold( + key: const Key('search-page'), + backgroundColor: AppColors.appBackground, + body: SafeArea( + child: Column( + children: [ + _header(l10n), + Expanded(child: _body(l10n)), + ], + ), + ), + ), + ), + ); + } + + Widget _header(AppLocalizations l10n) { + return Padding( + padding: const EdgeInsets.fromLTRB(8, 6, 14, 8), + child: SizedBox( + height: AppDimens.searchHeight, + child: Row( + children: [ + SizedBox.square( + dimension: AppDimens.buttonMedium, + child: IconButton( + key: const Key('search-back'), + padding: EdgeInsets.zero, + onPressed: () => Navigator.of(context).maybePop(), + icon: const Icon( + Icons.arrow_back_ios_new_rounded, + color: AppColors.textPrimary, + size: 22, + ), + ), + ), + const SizedBox(width: AppDimens.spaceSm), + Expanded(child: _field(l10n)), + const SizedBox(width: AppDimens.spaceSm), + TextButton( + key: const Key('search-cancel'), + onPressed: () => Navigator.of(context).maybePop(), + child: Text(l10n.searchCancel), + ), + ], + ), + ), + ); + } + + Widget _field(AppLocalizations l10n) { + final hasText = _logic.search.text.isNotEmpty; + return Container( + key: const Key('search-field'), + height: AppDimens.searchHeight, + padding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceMd), + decoration: BoxDecoration( + color: AppColors.contentSurface, + borderRadius: BorderRadius.circular(AppDimens.searchRadius), + ), + child: Row( + children: [ + const Icon( + Icons.search_rounded, + color: AppColors.textSecondary, + size: 18, + ), + const SizedBox(width: AppDimens.spaceSm), + Expanded( + child: TextField( + key: const Key('discover-search'), + controller: _logic.search, + focusNode: _logic.searchFocus, + autofocus: true, + onChanged: (_) => _logic.onSearchChanged(), + textInputAction: TextInputAction.search, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + ), + decoration: InputDecoration( + isDense: true, + hintText: l10n.searchDiscover, + hintStyle: const TextStyle( + color: AppColors.textSecondary, + fontSize: 13, + ), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: EdgeInsets.zero, + ), + ), + ), + if (_logic.searchLoading) + const SizedBox.square( + dimension: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.textPrimary, + ), + ) + else if (hasText) + IconButton( + key: const Key('search-clear'), + padding: EdgeInsets.zero, + constraints: const BoxConstraints.tightFor(width: 24, height: 24), + onPressed: () { + _logic.search.clear(); + _logic.onSearchChanged(); + _logic.searchFocus.requestFocus(); + }, + icon: const Icon( + Icons.close_rounded, + color: AppColors.textSecondary, + size: 16, + ), + ), + ], + ), + ); + } + + Widget _body(AppLocalizations l10n) { + if (!_logic.hasSearchQuery) { + return EmptyState( + key: const Key('search-idle'), + icon: Icons.search_rounded, + title: l10n.searchDiscover, + ); + } + if (_logic.searchLoading) { + return const Center( + child: SizedBox.square( + key: Key('search-loading'), + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.textPrimary, + ), + ), + ); + } + if (_logic.searchError != null) { + return EmptyState( + key: const Key('search-error'), + icon: Icons.cloud_off_rounded, + title: errorText(l10n, _logic.searchError, l10n.searchFailed), + actionLabel: l10n.retry, + onAction: _logic.retrySearch, + ); + } + if (_logic.searchResults.isEmpty) { + return EmptyState( + key: const Key('search-empty'), + icon: Icons.search_off_rounded, + title: l10n.noSearchResults, + subtitle: l10n.emptyHint, + ); + } + return LayoutBuilder( + builder: (context, constraints) { + final width = + (constraints.maxWidth - + AppDimens.feedPageHorizontal * 2 - + AppDimens.homeGridGap * 2) / + 3; + final itemExtent = + width / AppDimens.cardCoverAspectRatio + + AppDimens.homeCardBodyExtent; + return GridView.builder( + key: const Key('search-results-grid'), + padding: const EdgeInsets.fromLTRB( + AppDimens.feedPageHorizontal, + AppDimens.spaceMd, + AppDimens.feedPageHorizontal, + AppDimens.spaceXl, + ), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: AppDimens.homeGridGap, + mainAxisSpacing: AppDimens.homeGridRowGap, + mainAxisExtent: itemExtent, + ), + itemCount: _logic.searchResults.length, + itemBuilder: (context, index) { + final drama = _logic.searchResults[index]; + return HomeFeedCard( + drama: drama, + onTap: () => unawaited(playDramaFromCatalog(context, drama)), + ); + }, + ); + }, + ); + } +} diff --git a/lib/pages/discover/widgets/discover_search_results.dart b/lib/pages/discover/widgets/discover_search_results.dart index dfaa0d5..51b9281 100644 --- a/lib/pages/discover/widgets/discover_search_results.dart +++ b/lib/pages/discover/widgets/discover_search_results.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import '../../../core/data/models/drama.dart'; import '../../../core/formatting/error_text.dart'; +import '../../../core/theme/app_colors.dart'; import '../../../core/widgets/empty_state.dart'; import '../../../l10n/app_localizations.dart'; import 'drama_grid.dart'; @@ -38,7 +39,15 @@ class DiscoverSearchResults extends StatelessWidget { if (loading) { return const Padding( padding: EdgeInsets.symmetric(vertical: 80), - child: Center(child: CircularProgressIndicator(strokeWidth: 2)), + child: Center( + child: SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.textPrimary, + ), + ), + ), ); } final message = error; diff --git a/lib/pages/home/home_logic.dart b/lib/pages/home/home_logic.dart index d82c60c..7e5dc6d 100644 --- a/lib/pages/home/home_logic.dart +++ b/lib/pages/home/home_logic.dart @@ -17,7 +17,6 @@ class HomeLogic extends ChangeNotifier { final scaffoldKey = GlobalKey(); Drama currentDrama; int tab = 0; - int searchRequest = 0; int resumeRequest = 0; int backRequest = 0; int followingHistoryRequest = 0; @@ -31,10 +30,9 @@ class HomeLogic extends ChangeNotifier { if (!_disposed) notifyListeners(); } - void select(int nextTab, {bool search = false, bool resume = false}) { + void select(int nextTab, {bool resume = false}) { FocusManager.instance.primaryFocus?.unfocus(); tab = nextTab; - if (search) searchRequest++; if (resume) resumeRequest++; notify(); } @@ -81,7 +79,7 @@ class HomeLogic extends ChangeNotifier { case DiscoverPageAction.home: select(0); case DiscoverPageAction.recommend: - select(1, search: true); + select(1); case DiscoverPageAction.following: select(2); case DiscoverPageAction.mine: @@ -112,7 +110,7 @@ class HomeLogic extends ChangeNotifier { case MyPageAction.home: select(0); case MyPageAction.recommend: - select(1, search: true); + select(1); case MyPageAction.following: select(2); case MyPageAction.mine: diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index cb1676c..bdf3401 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -22,7 +22,7 @@ import '../../core/widgets/app_root_tab.dart'; import '../../core/widgets/catalog_endpoint_editor.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; -import '../discover/discover_view.dart'; +import '../discover/search_page.dart'; import '../following/following_page.dart'; import '../filter/filter_page.dart'; import '../mine/mine_types.dart'; @@ -292,9 +292,9 @@ class _HomeTabsState extends State<_HomeTabs> { } void _openSearch(BuildContext context) { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const DiscoverPage(focusSearch: true)), - ); + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const SearchPage())); } void _pushPage(BuildContext context, Widget page) { diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 8060bd8..8cc6877 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -26,8 +26,7 @@ import '../../core/formatting/localized_content.dart'; import '../../core/theme/app_colors.dart'; import '../../core/navigation/fade_page_route.dart'; import '../../l10n/app_localizations.dart'; -import '../discover/discover_types.dart'; -import '../discover/discover_view.dart'; +import '../discover/search_page.dart'; import '../drama_detail/drama_detail_view.dart'; import '../mine/mine_types.dart'; import '../mine/mine_view.dart'; @@ -1127,13 +1126,10 @@ class PlayerLogic extends ChangeNotifier { onSearch?.call(); return; } - await showDiscover(context, focusSearch: true); + await showDiscover(context); } - Future showDiscover( - BuildContext context, { - bool focusSearch = false, - }) async { + Future showDiscover(BuildContext context) async { if (embedded) { onDiscover?.call(); return; @@ -1142,13 +1138,10 @@ class PlayerLogic extends ChangeNotifier { await session.playbackSettled; await saveCurrentProgress(); if (!context.mounted) return; - final action = await Navigator.of(context).push( - MaterialPageRoute(builder: (_) => DiscoverPage(focusSearch: focusSearch)), - ); - if (!context.mounted) return; - if (action == DiscoverPageAction.mine) { - await showMy(context); - } else { + await Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const SearchPage())); + if (context.mounted) { session.setRouteVisible(true); } } diff --git a/test/discover/discover_logic_test.dart b/test/discover/discover_logic_test.dart index 80bcfe8..f73a5e5 100644 --- a/test/discover/discover_logic_test.dart +++ b/test/discover/discover_logic_test.dart @@ -23,44 +23,6 @@ void main() { logic.dispose(); }); - testWidgets('should_focus_search_only_for_new_request_on_active_page', ( - tester, - ) async { - final logic = DiscoverLogic(); - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: TextField( - controller: logic.search, - focusNode: logic.searchFocus, - ), - ), - ), - ); - - logic.syncSearch( - active: false, - request: 1, - previousRequest: 0, - isActive: () => false, - ); - await tester.pump(); - expect(logic.searchFocus.hasFocus, isFalse); - - logic.syncSearch( - active: true, - request: 2, - previousRequest: 1, - isActive: () => true, - ); - await tester.pump(); - await tester.pump(); - expect(logic.searchFocus.hasFocus, isTrue); - - await tester.pumpWidget(const SizedBox.shrink()); - logic.dispose(); - }); - testWidgets('should_forward_action_when_embedded_or_pop_when_standalone', ( tester, ) async { diff --git a/test/home/home_logic_test.dart b/test/home/home_logic_test.dart index 0b67f63..44b4dce 100644 --- a/test/home/home_logic_test.dart +++ b/test/home/home_logic_test.dart @@ -42,23 +42,17 @@ void main() { logic.select(1); expect(logic.tab, 1); - expect(logic.searchRequest, 0); expect(logic.resumeRequest, 0); expect(logic.backRequest, 0); expect(notifications, 1); logic.dispose(); }); - test('should_raise_only_its_own_counter_when_each_select_flag_is_used', () { + test('should_raise_only_resume_counter_when_select_flag_is_used', () { final logic = HomeLogic(dramas); - logic.select(1, search: true); - expect(logic.searchRequest, 1); - expect(logic.resumeRequest, 0); - logic.select(0, resume: true); expect(logic.resumeRequest, 1); - expect(logic.searchRequest, 1); logic.dispose(); }); @@ -161,7 +155,6 @@ void main() { logic.myAction(MyPageAction.recommend); expect(logic.tab, 1); - expect(logic.searchRequest, 1); logic.myAction(MyPageAction.following); expect(logic.tab, 2); diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index cec7ade..5e5eda7 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -118,6 +118,10 @@ void main() { await _pumpApp(tester); await tester.tap(find.byKey(const Key('home-search-entry'))); await tester.pumpAndSettle(); + expect(find.byKey(const Key('search-page')), findsOneWidget); + expect(find.byKey(const Key('search-back')), findsOneWidget); + expect(find.byKey(const Key('search-cancel')), findsOneWidget); + expect(find.byType(AppBottomNav), findsNothing); expect( tester .widget(find.byKey(const Key('discover-search'))) @@ -125,7 +129,7 @@ void main() { ?.hasFocus, isTrue, ); - await tester.binding.handlePopRoute(); + await tester.tap(find.byKey(const Key('search-cancel'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-recommend'))); @@ -133,6 +137,20 @@ void main() { await tester.tap(find.byKey(const Key('recommend-search'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('discover-search')), findsOneWidget); + await tester.enterText(find.byKey(const Key('discover-search')), 'signal'); + await tester.pump(const Duration(milliseconds: 400)); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('search-results-grid')), findsOneWidget); + final results = tester.widget( + find.byKey(const Key('search-results-grid')), + ); + final delegate = + results.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; + expect(delegate.crossAxisCount, 3); + await tester.tap(find.byKey(const Key('search-back'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('recommend-page')), findsOneWidget); + expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget); }); testWidgets('should_open_player_from_home_grid_card', (tester) async { @@ -188,6 +206,7 @@ Future _pumpApp( playerFactory: createWidgetPlayer, catalogRepository: catalog, categoryRepository: FakeCategoryRepository(catalog: catalog), + searchRepository: catalog, watchHistoryRepository: InMemoryWatchHistoryRepository(), favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), playbackRepository: FakePlaybackRepository(), From 8637f5da21ae203213fbb6db4dad0ff9a488a74d Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 22:50:47 +0800 Subject: [PATCH 112/129] =?UTF-8?q?style:=20=E8=B0=83=E6=95=B4=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E7=BB=93=E6=9E=9C=E4=B8=A4=E5=88=97=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 1 + lib/core/theme/app_colors.dart | 1 + lib/core/theme/app_dimens.dart | 6 + lib/pages/discover/search_page.dart | 16 +-- .../discover/widgets/search_result_card.dart | 104 ++++++++++++++++++ test/home/k28_a_navigation_test.dart | 5 +- 6 files changed, 124 insertions(+), 9 deletions(-) create mode 100644 lib/pages/discover/widgets/search_result_card.dart diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 4ca1922..0bd6326 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1310,3 +1310,4 @@ $ flutter test - 首页与推荐/剧单/全部短剧搜索入口统一 push SearchPage,不再切换推荐 tab 聚焦。 - 搜索页提供返回、自动聚焦输入框与取消,复用 `/app/search` 逻辑和首页三列卡片。 - 清理旧的 searchRequest/focusSearch/syncSearch 参数;`flutter analyze`:No issues found。 +- 搜索页结果改为独立两列卡片,左右 14、列距 12、行距 16,并移除 HomeFeedCard 复用。 diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 56ab3f8..a3fb544 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -112,6 +112,7 @@ abstract final class AppColors { static const textTertiary = Color(0xFF8E8E93); static const textTabInactive = Color(0xFFB0B0B0); static const textFilterIcon = Color(0xFF7A7A7E); + static const searchResultMeta = Color(0xFF9E9E9E); /// 原生开屏与冷启动过渡底色。 static const splashInk = ink; diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 67ddd3e..c127122 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -62,6 +62,12 @@ abstract final class AppDimens { static const double searchHeight = 40; static const double searchRadius = 8; static const double searchPlaceholderFont = 13; + static const double searchResultGap = 12; + static const double searchResultRowGap = 16; + static const double searchResultTitleGap = 8; + static const double searchResultMetaGap = 6; + static const double searchResultMetaFont = 12; + static const double searchResultTextExtent = 68; static const double navDot = 3; static const double glassBlur = 24; diff --git a/lib/pages/discover/search_page.dart b/lib/pages/discover/search_page.dart index bc4ac1c..c78741a 100644 --- a/lib/pages/discover/search_page.dart +++ b/lib/pages/discover/search_page.dart @@ -13,10 +13,10 @@ import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; -import '../home/widgets/home_feed_card.dart'; import '../player/open_player.dart'; import 'discover_binding.dart'; import 'discover_logic.dart'; +import 'widgets/search_result_card.dart'; class SearchPage extends StatefulWidget { const SearchPage({super.key}); @@ -222,11 +222,11 @@ class _SearchPageState extends State { final width = (constraints.maxWidth - AppDimens.feedPageHorizontal * 2 - - AppDimens.homeGridGap * 2) / - 3; + AppDimens.searchResultGap) / + 2; final itemExtent = width / AppDimens.cardCoverAspectRatio + - AppDimens.homeCardBodyExtent; + AppDimens.searchResultTextExtent; return GridView.builder( key: const Key('search-results-grid'), padding: const EdgeInsets.fromLTRB( @@ -236,15 +236,15 @@ class _SearchPageState extends State { AppDimens.spaceXl, ), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: AppDimens.homeGridGap, - mainAxisSpacing: AppDimens.homeGridRowGap, + crossAxisCount: 2, + crossAxisSpacing: AppDimens.searchResultGap, + mainAxisSpacing: AppDimens.searchResultRowGap, mainAxisExtent: itemExtent, ), itemCount: _logic.searchResults.length, itemBuilder: (context, index) { final drama = _logic.searchResults[index]; - return HomeFeedCard( + return SearchResultCard( drama: drama, onTap: () => unawaited(playDramaFromCatalog(context, drama)), ); diff --git a/lib/pages/discover/widgets/search_result_card.dart b/lib/pages/discover/widgets/search_result_card.dart new file mode 100644 index 0000000..ad6043f --- /dev/null +++ b/lib/pages/discover/widgets/search_result_card.dart @@ -0,0 +1,104 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/data/models/drama.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../core/widgets/cover_play_count.dart'; +import '../../../core/widgets/remote_image.dart'; + +class SearchResultCard extends StatelessWidget { + const SearchResultCard({super.key, required this.drama, required this.onTap}); + + final Drama drama; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final tags = _tags(); + return Material( + key: Key('search-result-card-${drama.id}'), + color: AppColors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(AppDimens.cardCoverRadius), + onTap: onTap, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(AppDimens.cardCoverRadius), + child: AspectRatio( + key: Key('search-result-cover-${drama.id}'), + aspectRatio: AppDimens.cardCoverAspectRatio, + child: Stack( + fit: StackFit.expand, + children: [ + RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, + fallbackColor: AppColors.contentSurface, + ), + const Positioned( + left: 0, + right: 0, + bottom: 0, + child: CoverBottomScrim(), + ), + Positioned( + right: AppDimens.cardPlayCountRight, + bottom: AppDimens.cardPlayCountBottom, + child: CoverPlayCount( + key: Key('search-result-views-${drama.id}'), + count: drama.viewCount, + ), + ), + ], + ), + ), + ), + const SizedBox(height: AppDimens.searchResultTitleGap), + Text( + drama.title, + key: Key('search-result-title-${drama.id}'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.left, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.cardTitleFont, + fontWeight: FontWeight.w500, + height: AppDimens.cardTitleLineHeight, + ), + ), + if (tags.isNotEmpty) ...[ + const SizedBox(height: AppDimens.searchResultMetaGap), + Text( + tags.join(' | '), + key: Key('search-result-tags-${drama.id}'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.left, + style: const TextStyle( + color: AppColors.searchResultMeta, + fontSize: AppDimens.searchResultMetaFont, + ), + ), + ], + ], + ), + ), + ); + } + + List _tags() { + final seen = {}; + return [drama.genre, drama.category] + .expand((value) => value.split(RegExp(r'[,/、·]'))) + .map((value) => value.trim()) + .where((value) => value.isNotEmpty && seen.add(value)) + .take(3) + .toList(growable: false); + } +} diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index 5e5eda7..ae8b830 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -146,7 +146,10 @@ void main() { ); final delegate = results.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; - expect(delegate.crossAxisCount, 3); + expect(delegate.crossAxisCount, 2); + expect(delegate.crossAxisSpacing, 12); + expect(delegate.mainAxisSpacing, 16); + expect(results.padding, const EdgeInsets.fromLTRB(14, 12, 14, 24)); await tester.tap(find.byKey(const Key('search-back'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('recommend-page')), findsOneWidget); From 6b3d870c93a35a46c07c460af9212ee9cec1ec7d Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 22:52:50 +0800 Subject: [PATCH 113/129] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E7=BB=93=E6=9E=9C=E4=B8=8B=E6=8B=89=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 1 + lib/pages/discover/discover_logic.dart | 29 +++++++++-- lib/pages/discover/search_page.dart | 66 +++++++++++++++++++------- 3 files changed, 75 insertions(+), 21 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 0bd6326..7641b72 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1311,3 +1311,4 @@ $ flutter test - 搜索页提供返回、自动聚焦输入框与取消,复用 `/app/search` 逻辑和首页三列卡片。 - 清理旧的 searchRequest/focusSearch/syncSearch 参数;`flutter analyze`:No issues found。 - 搜索页结果改为独立两列卡片,左右 14、列距 12、行距 16,并移除 HomeFeedCard 复用。 +- 搜索结果支持下拉刷新:保留旧结果按当前关键词重取第一页,空关键词直接回弹且不改变焦点。 diff --git a/lib/pages/discover/discover_logic.dart b/lib/pages/discover/discover_logic.dart index 7a96911..c2d9f53 100644 --- a/lib/pages/discover/discover_logic.dart +++ b/lib/pages/discover/discover_logic.dart @@ -61,6 +61,14 @@ class DiscoverLogic extends ChangeNotifier { if (query.isNotEmpty) unawaited(_runSearch(query)); } + /// 用当前关键词重新请求第一页;刷新期间保留旧结果。 + Future refreshSearch() async { + final query = search.text.trim(); + if (query.isEmpty) return; + _searchTimer?.cancel(); + await _runSearch(query, preserveResults: true); + } + void _resetSearch() { _searchTimer?.cancel(); _searchRequest++; @@ -85,7 +93,7 @@ class DiscoverLogic extends ChangeNotifier { _searchTimer = Timer(searchDebounce, () => unawaited(_runSearch(query))); } - Future _runSearch(String query) async { + Future _runSearch(String query, {bool preserveResults = false}) async { final repository = searchRepository; if (repository == null) return; final request = ++_searchRequest; @@ -101,9 +109,17 @@ class DiscoverLogic extends ChangeNotifier { if (request != _searchRequest) return; searchResults = result.list; } on ApiException catch (exception) { - _applySearchError(request, exception.message); + _applySearchError( + request, + exception.message, + preserveResults: preserveResults, + ); } catch (_) { - _applySearchError(request, 'searchFailed'); + _applySearchError( + request, + 'searchFailed', + preserveResults: preserveResults, + ); } finally { if (request == _searchRequest) { searchLoading = false; @@ -112,8 +128,13 @@ class DiscoverLogic extends ChangeNotifier { } } - void _applySearchError(int request, String message) { + void _applySearchError( + int request, + String message, { + bool preserveResults = false, + }) { if (request != _searchRequest) return; + if (preserveResults && searchResults.isNotEmpty) return; searchError = message; searchResults = const []; } diff --git a/lib/pages/discover/search_page.dart b/lib/pages/discover/search_page.dart index c78741a..cf086ef 100644 --- a/lib/pages/discover/search_page.dart +++ b/lib/pages/discover/search_page.dart @@ -11,6 +11,7 @@ import '../../core/formatting/error_text.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; +import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; import '../player/open_player.dart'; @@ -64,7 +65,13 @@ class _SearchPageState extends State { child: Column( children: [ _header(l10n), - Expanded(child: _body(l10n)), + Expanded( + child: AppPullToRefresh( + key: const Key('search-refresh'), + onRefresh: _logic.refreshSearch, + child: _body(l10n), + ), + ), ], ), ), @@ -182,15 +189,20 @@ class _SearchPageState extends State { Widget _body(AppLocalizations l10n) { if (!_logic.hasSearchQuery) { - return EmptyState( - key: const Key('search-idle'), - icon: Icons.search_rounded, - title: l10n.searchDiscover, + return _stateScroll( + EmptyState( + key: const Key('search-idle'), + icon: Icons.search_rounded, + title: l10n.searchDiscover, + ), ); } + if (_logic.searchResults.isNotEmpty) { + return _resultsGrid(); + } if (_logic.searchLoading) { - return const Center( - child: SizedBox.square( + return _stateScroll( + const SizedBox.square( key: Key('search-loading'), dimension: 20, child: CircularProgressIndicator( @@ -201,22 +213,41 @@ class _SearchPageState extends State { ); } if (_logic.searchError != null) { - return EmptyState( - key: const Key('search-error'), - icon: Icons.cloud_off_rounded, - title: errorText(l10n, _logic.searchError, l10n.searchFailed), - actionLabel: l10n.retry, - onAction: _logic.retrySearch, + return _stateScroll( + EmptyState( + key: const Key('search-error'), + icon: Icons.cloud_off_rounded, + title: errorText(l10n, _logic.searchError, l10n.searchFailed), + actionLabel: l10n.retry, + onAction: _logic.retrySearch, + ), ); } - if (_logic.searchResults.isEmpty) { - return EmptyState( + return _stateScroll( + EmptyState( key: const Key('search-empty'), icon: Icons.search_off_rounded, title: l10n.noSearchResults, subtitle: l10n.emptyHint, - ); - } + ), + ); + } + + Widget _stateScroll(Widget child) { + return LayoutBuilder( + builder: (context, constraints) => ListView( + physics: AppPullToRefresh.physics, + children: [ + SizedBox( + height: constraints.maxHeight, + child: Center(child: child), + ), + ], + ), + ); + } + + Widget _resultsGrid() { return LayoutBuilder( builder: (context, constraints) { final width = @@ -229,6 +260,7 @@ class _SearchPageState extends State { AppDimens.searchResultTextExtent; return GridView.builder( key: const Key('search-results-grid'), + physics: AppPullToRefresh.physics, padding: const EdgeInsets.fromLTRB( AppDimens.feedPageHorizontal, AppDimens.spaceMd, From d868ce9336fa6b7770016f93d0b496283fffad24 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 22:59:07 +0800 Subject: [PATCH 114/129] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=20App=20?= =?UTF-8?q?=E5=9B=BE=E6=A0=87=E4=B8=8E=E5=BC=80=E5=B1=8F=E7=B4=A0=E6=9D=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/src/main/AndroidManifest.xml | 17 +- .../res/drawable-hdpi/android12splash.png | Bin 14784 -> 14413 bytes .../drawable-hdpi/ic_launcher_foreground.png | Bin 10658 -> 6985 bytes .../app/src/main/res/drawable-hdpi/splash.png | Bin 17934 -> 18329 bytes .../res/drawable-mdpi/android12splash.png | Bin 9345 -> 9204 bytes .../drawable-mdpi/ic_launcher_foreground.png | Bin 6917 -> 3288 bytes .../app/src/main/res/drawable-mdpi/splash.png | Bin 11512 -> 11662 bytes .../drawable-night-hdpi/android12splash.png | Bin 14784 -> 14413 bytes .../drawable-night-mdpi/android12splash.png | Bin 9345 -> 9204 bytes .../drawable-night-xhdpi/android12splash.png | Bin 19598 -> 20072 bytes .../drawable-night-xxhdpi/android12splash.png | Bin 31471 -> 34262 bytes .../android12splash.png | Bin 43623 -> 49885 bytes .../src/main/res/drawable-v21/background.png | Bin 69 -> 69 bytes .../res/drawable-xhdpi/android12splash.png | Bin 19598 -> 20072 bytes .../drawable-xhdpi/ic_launcher_foreground.png | Bin 14855 -> 6839 bytes .../src/main/res/drawable-xhdpi/splash.png | Bin 23353 -> 23962 bytes .../res/drawable-xxhdpi/android12splash.png | Bin 31471 -> 34262 bytes .../ic_launcher_foreground.png | Bin 22927 -> 10199 bytes .../src/main/res/drawable-xxhdpi/splash.png | Bin 37915 -> 39806 bytes .../res/drawable-xxxhdpi/android12splash.png | Bin 43623 -> 49885 bytes .../ic_launcher_foreground.png | Bin 31455 -> 13411 bytes .../src/main/res/drawable-xxxhdpi/splash.png | Bin 52528 -> 54588 bytes .../app/src/main/res/drawable/background.png | Bin 69 -> 69 bytes .../res/mipmap-anydpi-v26/ic_launcher.xml | 8 +- .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 4399 -> 2550 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 2499 -> 1249 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 6288 -> 2903 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 10698 -> 5586 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 15401 -> 6088 bytes .../src/main/res/values-night-v31/styles.xml | 7 +- .../app/src/main/res/values-v31/styles.xml | 7 +- android/app/src/main/res/values/colors.xml | 3 +- assets/icon/fruitshorts.png | Bin 49168 -> 29542 bytes assets/icon/splash_logo.png | Bin 48043 -> 49941 bytes assets/icon/splash_logo_android12.png | Bin 39499 -> 47200 bytes docs/plans/PROGRESS.md | 6 + .../Icon-App-1024x1024@1x.png | Bin 49168 -> 30996 bytes .../AppIcon.appiconset/Icon-App-20x20@1x.png | Bin 866 -> 454 bytes .../AppIcon.appiconset/Icon-App-20x20@2x.png | Bin 2188 -> 999 bytes .../AppIcon.appiconset/Icon-App-20x20@3x.png | Bin 3951 -> 1631 bytes .../AppIcon.appiconset/Icon-App-29x29@1x.png | Bin 1414 -> 718 bytes .../AppIcon.appiconset/Icon-App-29x29@2x.png | Bin 3781 -> 1612 bytes .../AppIcon.appiconset/Icon-App-29x29@3x.png | Bin 6273 -> 2586 bytes .../AppIcon.appiconset/Icon-App-40x40@1x.png | Bin 2188 -> 999 bytes .../AppIcon.appiconset/Icon-App-40x40@2x.png | Bin 5554 -> 2346 bytes .../AppIcon.appiconset/Icon-App-40x40@3x.png | Bin 9103 -> 3688 bytes .../AppIcon.appiconset/Icon-App-50x50@1x.png | Bin 3099 -> 1355 bytes .../AppIcon.appiconset/Icon-App-50x50@2x.png | Bin 7491 -> 3010 bytes .../AppIcon.appiconset/Icon-App-57x57@1x.png | Bin 3705 -> 1553 bytes .../AppIcon.appiconset/Icon-App-57x57@2x.png | Bin 8938 -> 3491 bytes .../AppIcon.appiconset/Icon-App-60x60@2x.png | Bin 9103 -> 3688 bytes .../AppIcon.appiconset/Icon-App-60x60@3x.png | Bin 15271 -> 5695 bytes .../AppIcon.appiconset/Icon-App-72x72@1x.png | Bin 4830 -> 2550 bytes .../AppIcon.appiconset/Icon-App-72x72@2x.png | Bin 11534 -> 5586 bytes .../AppIcon.appiconset/Icon-App-76x76@1x.png | Bin 5298 -> 2721 bytes .../AppIcon.appiconset/Icon-App-76x76@2x.png | Bin 12421 -> 6765 bytes .../Icon-App-83.5x83.5@2x.png | Bin 14046 -> 6333 bytes .../LaunchBackground.imageset/background.png | Bin 69 -> 69 bytes .../LaunchImage.imageset/LaunchImage.png | Bin 11512 -> 11662 bytes .../LaunchImage.imageset/LaunchImage@2x.png | Bin 23353 -> 23962 bytes .../LaunchImage.imageset/LaunchImage@3x.png | Bin 37915 -> 39806 bytes ios/Runner/Base.lproj/LaunchScreen.storyboard | 2 +- ios/Runner/Info.plist | 194 +++++++++--------- lib/pages/home/home_view.dart | 15 +- pubspec.yaml | 6 +- 65 files changed, 139 insertions(+), 126 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 6c9f35c..e7df0ed 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,7 +1,7 @@ - - + + android:name="io.flutter.embedding.android.NormalTheme" + android:resource="@style/NormalTheme"/> @@ -35,7 +34,7 @@ This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> + android:value="2"/> - - + + diff --git a/android/app/src/main/res/drawable-hdpi/android12splash.png b/android/app/src/main/res/drawable-hdpi/android12splash.png index b83ff0446c6dc6b13df4903842dd39cde7f456c3..096bc138640d6c5abd9e8da957b6adb9b341f195 100644 GIT binary patch literal 14413 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 bZ%KlsrY&E{t;phk6C_Yk(2%csXA$~8Y^mC| literal 14784 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 VqWp(9crz0sC#@t^E@AZLe*x*oxMKhS diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png index 958fa59c5684cc3a360ef0c25f1128c39e83a576..11cedcfb1c7e271334e6fd585946ee2f80306ab1 100644 GIT binary patch literal 6985 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 literal 10658 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 diff --git a/android/app/src/main/res/drawable-hdpi/splash.png b/android/app/src/main/res/drawable-hdpi/splash.png index f7ad471b5237ebbf8d6fc62113d8009e130b5c5a..791f6e499294231fdbcd93ca4ec94d2261217546 100644 GIT binary patch literal 18329 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 literal 17934 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 u1>6|oLEO^cfoUD|hWcfS8um1=C$Cmv7 diff --git a/android/app/src/main/res/drawable-mdpi/android12splash.png b/android/app/src/main/res/drawable-mdpi/android12splash.png index fdcfb536b3982fa1b9c4f4e659a5f2e464ae4850..6747a876ec7e6ffddac8e478b8b5621329deca61 100644 GIT binary patch literal 9204 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 literal 9345 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 WE&+(@x(;Rl000069 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�J20ead!H9WYmXE*I_c!e4tMLAu#W!3 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 v{o{>%PBxk(&j0SC6qiR)$nc8i)KJ7wrTl diff --git a/android/app/src/main/res/drawable-night-hdpi/android12splash.png b/android/app/src/main/res/drawable-night-hdpi/android12splash.png index b83ff0446c6dc6b13df4903842dd39cde7f456c3..096bc138640d6c5abd9e8da957b6adb9b341f195 100644 GIT binary patch literal 14413 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 bZ%KlsrY&E{t;phk6C_Yk(2%csXA$~8Y^mC| literal 14784 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 VqWp(9crz0sC#@t^E@AZLe*x*oxMKhS diff --git a/android/app/src/main/res/drawable-night-mdpi/android12splash.png b/android/app/src/main/res/drawable-night-mdpi/android12splash.png index fdcfb536b3982fa1b9c4f4e659a5f2e464ae4850..6747a876ec7e6ffddac8e478b8b5621329deca61 100644 GIT binary patch literal 9204 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 literal 9345 zcmeHtRX|kXw=W4W6=fbwPFd5UIX+LN@b=$Jo4j`>g7xTH>@GRnwlM~`=0^rjC8zcG?A zNTfZ)sF@a8P~M8U$8fmEEJ^qfFjXw*Dry=#_*e!NiMr3!fo{$Gf6fJ@!Rjp^f150FMHnY!{{>#%WtF(1jx}nvq(QFChO1xftvd8ts#ajxnvpIPgIP~ z)UTyx>PO94QpK=0WjuUgbS4vl>L4TLl)BOho_Xd_K9T^7vHQLIZ?AN(;jH3S4wzm{z@^||+pn2k=OQs#ya(UrtJ zhddxtX8czqL;d=}$`ZbZtqJRo#t7CDNv2SncE!S}fo~5U?zR0Km%+LF1Ka(4gojC@ zel82R&2?1P_Ln6LQJ+Aa_aV&}Ze0`YIFI{uzyI5Knvthe9q=c9;TlntkWIx+1rL9rmG2?*hz1 zy&9l1L7nQ6ttH11YMNt&zc(ElpS@mhIi*B!nY-+r9#Azs=l>U|A5XhaYBp6tpL)NS z`imtEF|&kTzqo2JTnWKL%RVYU!FNYsSOz1IteLm0X^or@e>e zeimEmhu!rCwV6kp%08)&JAtJ?X@gTO(SrQ%+t3;#HD(aA%xUY%Q-Og{Dusu*Wy*W z`QHWbZ#$K6UbxC}Y~SeZ@4W=lz^5`}>?dlK`kA>2giI5i&dHCBY@lmMXkGQ=o-QL7 z&<|OhyQ1Fp0--b{CL=G6-XsX@CIRadg(I`UVPqv_fP;uaStzk{1!~~j(q-! ziq7`wO&nWjL6SWTtF!{uu_HyzuBRM9cP8fJ6r9ugte`t!bmm|~;+u=H-N7X{Mn{L8 zN7*f0?%93XOdZs75s@L0I7FU*hhV=9|2(aFv+;R3-7|NKsb{dK@cRkvaZUP7QucD3 z1s65th}j*uQnVdfo}6@sj;)9`YEueQS#vzUCx1?r1tC8F>rty%|0mdL>X17Nx_DYF z>1i5zR}zSM--GEQXk_ZKVwYL`EBDu8Ui$OW_Pi0KF>tALp`mtRDSulLnbkm1R{qnN zeXyGgs~?M&Nz9&HKUy%2e)R!VAm8g=d}-499AkT4r7>CC9TMqZA%e1JWw`ZFc0FO< zyule)*81uXqR6BTH50b!9a2Z>Uqb0O-Z!`nVViZ$bw6V*Fd$9$-W+Vveh9AFv{|F%2g=dH59$0+rn4F!=PDidZbjCQ( zAmhT}&H8m)Y`4`yrU_tLSLF+bK2I~O^{2lBj%DcX+3Ze_oBHini5)(BB zThaI&m&;ZSwlkNI;!D=VO$8{^k^9 zOvD$(1PRnshYgvydBu;f_zR;wC{_f}W00*n(?}heluw3;{FjPjrc(Q^(}x1vQ2TZT zb938S>KfcEBJ%ZrRlj}y5CPiXx_&xj)5Gky&}qNH zOO=sp=l@h0pDJ9%d2F0QCh~?xoithrtnELD$nf>9YZE|z!?JSg+P3cU-iJW!Uq4Ww^zR2sjWR zjTlu3ySnc~_|0-?&1NS$r3ap}Yy4?Qqsoq2nZjIsQFwsKVVxvZ_3MvqBVihlvL-6tr=MUDE_mV_FM z!j&t85}hkQ;%{KDs$yKJyo?Y^n-xi8^CGcA^kX4-XzUeB+dgQilsYM_42?O(LTpIC+5F71biHYoY7k^wpE-8m=Q2P9}ojiy<4(MPx+ z-r0=G&;SeAll3$((0k4rD`!VN^%<@0gJ`LpifMXINnBFOUe|G+m>7`Cjuh4W9}8Y$EG_|lC@@w#idfxTyxe%VnJ zLnnPR69Gl}2Zh~+kc!Pp2vRzHxovbr&t_~-*OJhv^Plj;yUFK)!Nmxo{2fjX6Ni%5 zg}fO&i{b%|fPSA%fxm%6@Q^Pn!A8@6Yc(CaJLiqTq{=2VJu9A|x5_N=>XmF*P%ymg z5mV}iIqv?THJID1^2ojedDV*#kV^3GPn0DhpT+OWmNd93CG6Kl_TxiVYQ~VzUxX30 zmV7zYFGg|;Q4j@%q#O;#w2KMcRM;10NX(fOCU6>X&ySzzEY04_zFuN~F)SPqaUlCG z>J2Hw4RiD?J>or{2 z{>M;Z7UCZnHW%DBlL2wd3{8^Qf&Y~mn$28oof6;C(UI|NgAC&0%9v{Mv;x%a@kCIX z8@ley`fCLObBe*7k9e>kyaziaXDOowbRX~deOW)fRjuB>geHz&xAZFh*ur&hIpYXd z$_o_U;l}L8Kj~!s7n?6A$}1`}XqQ~XHB-|JZU|?d6SAaD$kF(V#4tRdA0Ad7VHdb6 zpPf+{`GSADV*TD@n>(A#jSPM5k}Zz`!^6*rvJil*e8b0utx1-Bi6i|?aAOI z?wt9gH5qho&q${J5i(4WF<(wHdd39NO_!B@>Tz<1AmR6n+RoZl?7e=x?3}pnMaJRX z)(BEN+FwWFT;EMU1!euU8+;k}7t8n@7|Q0m&HLx%T=$p?Pe{EKIuf4SBW}Z|@?G@d z(929-^A!Kx(DO{*gsqWTv|!dRN12k^s)bo-AqGsOciezaW^@T(kv$X4!Ki=x--epY zLyB(yQHM?+TVW~elNoqKd*5pD#(+}ZJ*-=al5!VDyIZEAiLZ3sPlfN^(9rF*NUm&6 zc16X+hkWCZa;xx0NcCP{ z{19no<~5hWD) z_zImaCa_LmIR}XDWm3Oy%G~Qa(JumRoIZ|$iCGPl2&22=;Z75CH#H<$kgRrvQ(V`o z2PJV`8;pFvZ=Jr}mB)|XyGoHAr3X92!gA$@2tanuHHk4#w*Im(cE!kwbx?gEEU6r*Jr1~X6|%$UAwKmPx0>J zvHjFDW8znS+G-F3kCfmgpauIHP-z*k_Vk{SozQY?ZQq0jTnytl1rMjAGf4(3Xu&pO zuu$XR8yxkUUAIOj**l<*EFYJ}{oymU5-M=jlNZs=#q$`@^Y>8iKigB_3ZQvQ_h@xx z3R)xv=EAl9zNvfczTG`V+1q8qt~Gm3W^;W&N@RA(7S0nCmMdUq(E}c&!w!DZN9s`KEayg%M8hmEZfY1}kdm*v0{V#_PJ)16VdNRCsW(m6k%+WP`0!Y0nP9YiNI*6Z zjLM6CP6i1~Mas*%aJ72WrGBuEME$I>631gDFa?_~zjPz9uandoos>;iKy&<^PI};G zhK*5`j$bc{^x9l&WGlVJTQou`*wofra(2n@y3ZiGU7Qa8SmQD$-G@*#Sa81R_K?di z>~Bt|E2a=$_<)DUru5ki!h!vLVAgIr&0R#Ym~lWYyUb1AXwR}`BKodd@jf(0=SoaW z!MlXyDT%aTVgok_LYf;F5<;MLRYu-_(`VR~z`} zSiXuJZJzJkkiO3}JUTU}5zLoC0lM3@eqHovUOvi*@rmS6CJ$U$*~QiEPj z{^dBoaf$vb(rEG}myb_0pF&>c^e70j+)LI&tjEFiMUA{OV5kLSV7wRc#!E#OBzeLQ zqs66gmI(geK6eP6`&&Kc!-UW`($jIMF-A^WXC559%ewdzOVN_KvbwU@N`D#gGZd#m zg}q0)u2{FXu-(*@i?f`qwNt*~Z=Au|l~SE3`lq?Z8KS4_L6)i30Q@dB5otI^%r!uhZK|X8|;(8_% zWnjJ*tE*mLJg2IAU{y5aWD1G@yqx<6y?Jck?54n+tdjQmMEgb*2VN zI`;SX-703 zCyp;>{KCk{SAP1VWy}ea7z(IQ9d@9+q)5cJ#@I|^H1l38B5GOkNUF-X zlulw4$jvL$ULMUBBQOigwh4B~aj)x7fQASInI zIoZIJdPC@&6g~;--c#{?xDfZruZ)e*Qn88eOG~R;`USUQkHeqzan@2F9tK;A`z++{ z`OQ#px&d^1IVs>G?h|6BF!xu$VwNoAb-~IV49Rer;kh2u=5u=Sx4$(Lcge-LGiYK_ zO@9~Q6r6j^Op$IsHuUqPObvAdehP;b6*=(`ulX3|`1CmWdlxP(wM=+SD?l@!93Bd< z0z^Nm8<_bM+Xxbw76Bk#X<4z{5$rttXaMp(mtyBE$NqsK?L8__0I0bQf}~a`OyX83 z_MW&`!br8a9@mg*VV}omy6{RkEnQ@O*X{L{T(Q$22WZFw(C`+h`cC^fopdlEV-U+@ zEt|nGNr5GQj^xAn42PHw(GoI$LLw%_wg5L>PcMMjca=P~t+a_;h=ir6QDBjc#m1h> zY3dEEr$zhJdAOq1R@~>CZl3l$dTl8Uh-Z?Ua;uVi)|)!ezNyLMQUDNdKrn26YEL~q z<>JS|!uxwCTSDU1jo-1~2F26%_VkjzR=)!N6{{Hny5vFkiPcG!4gSHZXD)2;DQ2b} zWX@ENHIllj59|vtMBw7S=&W`q#88QGW6p#J6H;%Hb4^#so7M9OVjUy1QSZ2qFP-ns zdN0$uS5lpb;JOW!Y7E**rL2|IFkhceLPP$pP}(>;>047iF>X z*}}7FIhO|n5jZr8T$G%}6Kf_V69NEE7fz`7%EOO2vR_mTzoz)vXR7j@`j$6J*ZZCa z>vwgzjM>|f`#4cH1K2=;jmtfYM;J!S4C@<;8tf{(6C(f`-k@LC2-&!pW|nHR>jc(% zz*OAf*+mVVoimP=8HTs27&6RjR0anZ$4y#?W2r$`SD;a5TH=mQl@7Uh>p7L%F0YwH zcMG?3ARx^?n8VZWxR--Fi!l%IaBy z3!?FGUj%&}2jZzHYRJ!9`_SP~c=*ep{*>OV&dR^1Wu&eOh@cu!OnM;ChPW=ridpA} zd-Ja_h^yrHG&ulAxBMEcb@j)ea{9UqI%H03C##$@Gl>?OB)5DXuMAif^bO{Xo8M^F z*B{pfqMe>1(*{^xUMZB1n{nuEFVVcw0x+maxJ_>F5c#UB%fSLb=ox?@aiw=bm`PHr zMBC1L2EuQAcKnmACR2^-e~GMpJBHs+a84UEbO{G^CJCRVW(0TyZ!}=BKUlqrNA~*s zUSAt~1#1DEnVmdAoRq~q9{sH%mG)sb+x1vv& z{PRDeAHP^$aT8A8YK)ktd58}%S8es8b!~NJ?UQG7?1jV1eC06S^dq8mM zE5v#!3G9W7iEvR@89_pqg+l<-61@yquIpE#V<^~13Z-ogUYe9uHmsHiaeMtd2}q$G z-%B1}KHd@vD1EzjD)n7gC2o*QCQqBC>J*GFI)2z6DzDnVdV%_3^&AWi>8uM%72wF@ zM?{I&EgF>{Nv3jI^Dv7zG;}!DRPG;KiwpDKa?5E5IboXtIX_0*9u-tJy5&mV}zh|DFc1?sA1zuF2|kM%fKu0q#@>@l}P z&!nDi1!|HLetTC^(iA#QYJ53YHuw*kTYFaU<)Ob@O}qkzCaQ7|59sNre>}1xzTKer zvvD!b?Z+%=@jTTN(InenWkx@ZEXWmcc84mITJ`(!dprAljsz zN3(TUBh4|^Ptu} zJvookyG;trH){79Q>ru3Hy>V@?e48DHbLG9c%NHymHQ-~+&N0?>K;3I$2lF}x|6)k zdHqh$t3NL>rJdor0d#A8IFk{K9aFIZ7_4~3?F0-+fDd1J=3Y{7V#mem1}F3LDho!8 z*4HW+f@cd$%6>^Z831+*gn&Ny=&ER3H*Vs^-M_I=_N7kn`@xY3s+{$ACE{MS-m0rR;+8NdsT#L%Fee(pzqSI z`USs8F$kCwaN#Q7Z=~Pa{APyI&IE?A>-oM62dJ(Eu(}rj0|v|{;@kfI7@IQTlPgR@ zYU{X|vqG!ySYW-!)N54!x_|(^Ju!u!TX-hm8W${Edo{qs6KwJ|R^&kuAep0KX z*TaL}K~h70Kzo^um^n?qRF$%do4M0(PmBiUoU84lq$B%wc1|x$S*!S14q-hS_r;j^ z3^24>ZRJ2sA{+OBUPU}L_%@Yc8{6D9(B?e!JbYO0|8~5Hu*@(^`f6vG^p$tr{7LnL zUk~EZ=Ra7$eyB_%c!`-}i-F$c$nxdlOkNe`lL<(E58+#voQS(F zZJ6Zic0(crdpI*@ypBs-`W#5JjqEieQMFc?R_cc-ZS4iMUuawkKu+)ndPL`=fvJ<+ zU((97yQx}|>b`n)`CtrZho+AxNn-lJhR&zv9|nub-Rp(;yc31iUa_Q4@FHszl1Kv6 zfluPB+)fmNmmbON%iyTvaQoByHCL7~t0a*yvx^55eFo`f!bt?{7f|O?V-RR5&s1>R z8`tU+I-U<>d>`d!ej83KdVXX9Z(4b&LDMq}B8*&EjO#4(TPn-W6GRojm9%o_IH zn#piS(e;nXfFG>5S|l%0B_pc?n$`76C9lJ+r>FmVXci_KZz=o6?aDXQ{{9 zqKP4huEC;3+?i z=$s}FJD1Sq!*+xXsDi5dtNit%*kU z#rpQ$X4GtT*LgEYUIz_|s=7bAMnnZ&w^_r2j*e)CJfNQQudAvMBh!F*g#P8?|77Y+ zzyJKB3OHV?3(7Uh+wAbKjwLK$6>HSA2@oQsM^5SFR1Z*O-_0*kZ?8v;F!n1WyvUQ6 zk9UM8ggzTOdpE7Sg-Qdz;FDzsXyRDycSI|VTaWX7H>tx5KwXX=Q9v=AfY2{3-{0 zM9F~W_Wq@en%V|k5N&pdM1l`^R{XVo*e?BSEK|HZ!Pk29HPZE$8uDx;szgX}vZ>(a z^E8Z3Ux05y=qcZJX;4C`1$J3>@XTgjCwQj9d!yuUiJpfiKq|C7!#^9CSlcb-*)g}^ zLq@p*r~6hpkMyh7LUf6Y%J&d}vf}|Ws`7eZ$HSQYa_<$D>_m}>=0@uLv6`)!$AK62 z1jnB6+x_L6e%o!A94Q9J`nl$DH?rYbiP6yAWD$v5z`8u|UT!oF=rn#xxws16dv!Sl zeAMrnjVSP@3v6#3 zj*W(<7lwv`hE^eq20TB@`2QdO|CafG%?b2ax56!qh!x~$6bh6b7WY$X=Lifh zj^5To<`%P%$I}6j-}UwzDwGR5OPO{VCTc^oj(gohsD@*FWycSvT^!3ZR^A+gY)$3+ zV`R2HBA>9rldM=DC4&K_Qd zBR)OSHZhhxVJ1e${%}HdlY|Um;`33ATSYp~?Tl+YZH-$rSLvh;b1Ppm`<&f!Xgud! zcTJ*OU^8|2%Q>SyVMIC9xqC$_MyE1cIhU_^z?r3C?1Mz?Smt1l!sS(bz>w$s^D{f@ z^kg;Dwuf7hsM)b>LC?#K-&4tJg68WjJhS%JjyUodp!qINLg~3iE1tMT z_961Ham~a3ntrnQd|uo26<3vTYV^{kaJ)+z%T~R^saD?^fzZyRFt|vuFvA|1h2*eu z3D|hwm^c-%gKiwRX!&2`^bawIq`RrP#%AlSw0>_V=+964b@Y{9+g**xtBRcpSQxD=MOHtcDo_O z?Xk$7by*(6pFdBhkVXEd$G)q2F2_-IAE0_`Db8QB7`2I?5Rvd)KRx=jnz)kzbVmX}|gx&BP9*-Y5w!1PJ}w*(hWlNOsji4nzQbYokE z%%8|#;H;l>Ibbfm8QzU`&(0=fB>Oq{?SEY@-!T}qp8}I`3PH>|50SCL?%m_`v;xp^ z&z8c1?4^TG8PVxyExxs~`elauDZx)fFw+_yD*oJ?4*Gul?{j0?q1uZ1`C3-XSk9VC zYV+UNRuey5p56}{_^`l{em_ve_0 ztLe{R@s4rhXG_`k*fKOx;=pf|CQp|OxesYN=f3qk9G4IWth7LOoL7mGEecpJdyPBw zPMQc3Io8^D?*rE@jP~@C>HJ2&$a3HBL4zNUO?h57T{y*!wY&XJ>whN71|!8_pd7PP zN7!neWw!sk*+1^+IL!XJKV@05l;KP}r$iQDz**eDUiBYNcCF>+42Olt;6CYFWa*tz zk=Ch*DtiwNmn))I$sN-C6#Da#dB|y*dw7Rx_Plwm|A}6f$Fk@4B=V8MN7@nF@W4mU;czjvCC`&-e#dO@eb>4VYH_WOQjCBZ z3f)T$ZciC|I-I*2e|G+KeeWYOf*Ay9_(XC>MN(xclZ7a1M9f)Mvs=abU(f22o7|nZ zT4mR{_-pZ-DyM{?VVHZpwCW9XOgHu&RMR~WY8!Lrg19$JvI?c2y>J&GnA6^{($onZ ztW{xWvd~}Uzfh}7e>l29x1tqoS<{6yU-oR!`v)wD4dVx~1NI7{aE1Eq3MxFVr)VT! zDTx`1f=ZboM3~>UR1@ju*(J`pruy5Qhp3fD#WsGv>@;0VVSL8ufx*yQrR*?xJ4fhw zyVTf^FTUiwqWujs9X*8;x!VT!9JmBq%~kpwxDN{)Lw&dZUPdWOhrd9_{&A`~3Lk03 z_q);cU2RmYR%vGiUhI7 zQlAVtHtcm+t=>=Y>{QOzq|t=15eE{Lx(&jpA=IAhjh@slA2>D~sm|j0z&2@DTF&EV zDtEuz3`T7Pl8CQQ-4k_kq7yj<6pqKQH{XEig|7Z6Inlm#>I)SJVUYWZ76SJIpRKqp zHa(A9b3aLCpL-4_JJRFg%Ciw-PWc^wl8`{cDv@Wk!mS0BbR7JWuqyibArjy2)`tlf zgFy~-DD1Max4|t8Mg8HQs+#_ZGGjr9>niJ2j(lo>ax)C2KW$QtYjHNg7WJAm#0u#D zObL=g3-Vaapm&-_gcvV{(7*XFT*^{JdZ_lG_&(Az8rf?0{w2o{A1b4WrudUrT#IoQ zYN3ghume>Y{E{Hp5re^c3*UFeYmu7*>N_X>xDj*B$zK0oIb}|%wvL*5{n37@R}k+} zjaeG@SlHZ)w|Q2@r&>@!VCl)7?tf6q|*;FvW>Zg%}_b~S}#jAu6PLq z*ZuU$e6$P(iJgS)ci6$yVp};wYo9*8tp^^DTC8Uh%J{45MOSY#&v~HrE5Zizh1LXz z#ZdZxs!%(}8$PX4d&syJm>N0VmE3taf@6?hU@Jw9!N9h2iOO)j`N>M(x%4f&nBUQ% zwG*#?6q5Q(M<>;6j$v_Lw>mHSr(crp>9`5zR*QUqR$FxBQGn$ju0S08_aoDqr5lQ= zb582?PSm>(^TtNT@Fgwgy;}WRmv7j7_)(@u^9EMF)1uR4`kM9N4cAk)7&MH+wv+Sr z_D1o|I0Z-FhU-_m-G_Edvo+dJX(q~=Ie0bD)w#dB*m}@UZosM4me+$49Mc~$pfu*nNL5T^;3Hm1o#!(I=;Vgz<$&7uS=V zuD?IM{~U2@w|Ou1-F9$V{PVtA!a#_FOr9;ax%x~yyij;Vga@c0(VOYafM|*o8f`cXbi9A?yYNWnFx=&> z$ip{RX$O~4?<wq8FM9Fa+#rhbTJ`BC-m{j}wj_0D_Bo~O$C>qnqG7#<1J)sg zw=>5RCNxIg>OJHHx*>hzb&ceJ7sS>gnM~uyva6IOvbSfpQt!4bsR{UY9wiMi9L80@ zoPMN9+C@=z1}yI0WpGplm)Yry48XLr(=I;f2H3R@b-Ii!vGS9K=3q-<_u8F>ULhv$ zQ5)|}Q(aclV!9L-MnVhV~ zSLsGVvTVZgKyJ7j=`wu0?7B{tY`0c5m$0IR5(oZCzn-3x>_IyEE>{HyqzpE(bjY^& z27_?H@7C43QtAk3eC|XD$I^PiS~v1&K~C-h>^q?e11o>ue$wQ!DPg8< zmu56?kr=`&a=AFbhx%pME@MKHaSdYet zb$LgYB)b_io2q^PR(bF7O^!GoKGO0X%qgr1A4W3^D2G#^W0N7Y6XMsPmeZyMN*QOa z0eB=gKdeU1lb%#gDhYv4Q~;`l4T)+uV=dz|sA@jk&+$-&yK{@Y?0nI8?q{(1xl zwWd<<=|&?K<*cFiQDX#5T(%orsilslhSH<@y;s4gJlOJ_NJrxx5>Vo;DL`J@|cQ9MEzC6+z55`fQ zns==HJ|j(fMfC7yWa4Ru%lXoCdL0b`ZJsAYgQAYf(x%7p@shEKdV+vvoafRk2D%Pl zrnKn<5@!*mj4x@wIP$}bqLG=W=>g|gHc@niFR%-V0@XE*W0=;~tPl~C2Q7#6{ty|b zZ~Z3p-z`&|i8Q6+d;LrC2%1-!POy?98_!@I!w-c4l$(2!s)&mwM&Fmp+%9@%%`k%J&6ro0 zx3>v5o7o*3t!sCazsyGijuO)GMs34iqGQW@Pi4kYgEy_6KAA1MM?}Y;m=jeB2~J7c zmn)WUk)teAK9VpV-6e@Z>ku>&KEH&`Je; z4>;&X1`y%V`NpfWE--9oF2gvns;k`ty#wBtIg)!8}6<6|0vmStK z;hu^5b#zSS1l!t8tz<;oNnb;#oyQ!;ee8A6xkEAOf^l80qr_@%zi+M!8Sk|GSh(F* zd)nbUB=$V}xjLsFP{anc`S#QGAdj+bM+;cG?C9^vE$%7h(>agt-$>_VMcdwnA*F_DxPp?8ysf3;8#2*mWHKfYn+xKXspPCoQYf_m%Dwww@G8SUyN3tKX8m z?6|Z79`_w!Q#roHFeaq?A-}@})hd3dI0nP!X=vw9>iPVg?RL?_Z?+y&&T|T_Y}EZZ zjGR^dhBLfrXQJ8@r`qAIqwi4EgULYVgL=!YId!&w9{Da>9%2F1ZE+P<$5K8H+Ypj*XhyrlJ|Uk=RENkt#@P-rlrKf+7zn zuOTDO5)nmt84NvN-Vp@Y;B6HVD3vZZ{^g^2In{|sDLwx{5;zVZm$%1C?X}v24>m_i z@5??K(?@D{@vJ!7hGkyR?!5*%l=5NH7uCf1U&^aF+D@J0iMjTMKV41bPwI#QzgekQ zkIJGBH9&Y|iv?VT2{{69QDr@GXiN0mAzyGjAz1a#@D4+sYAqKx?L6_eCYp;m6 zw@$k`ct7>a>8}am)wVyEdi!&}r3bRyqm-Nd7Plz@)}!%kj&X29{%%1AN)bs1J|@hd zb1yZ)jSb9 z2>;j|X>CxT{!+&hk@mUxyslh(VaId%1U#C12W*)&0T$R}s-v=HMD*P?{Vz1F+&T0eJL)O!xx&k_LjtpHNrfoc2CHY zWk7k7l9z}ESe0mTrb%o<_=D3u2{AN7wf$jhzZ}tFerNPuI4*S{{Qa&*A3gp|81T+m z;n>+wl^HRShO$3_&Uh4V)5~m=4ULM$RSRo8rfl)=Z^eFd$e4_qu$cQQl3um-N%X$3 ztkx+Q(7GUfSs{-ma}n)W55kqS9Aft*QYcUW!+M|~J}+Nb%jg2wq*-sX0V!t^pvX)+ z!S?C(`8N_?6q#FgDTwEl5*Jf(r8Zv-@m^hh8_9KskJE<_^!<|*38mnn>>{x zkTfn(=7CW-k|}-1kzU-MVD>(9{eu6UHm+502d#(!4V?>=A@-u|wL*cbC+r0e`QECN z*2%)nTqOi~bjbxPdIl@?Bqx-9g}0tt(=^H{!B+!(vfD%f3ytCY4>}7Z?m8LMy&Dt9sWI`2X8n;)Qnt%KBf{?)?F)1 zJp8~5C>2lY_jcFFR@P>DLZn_Q6;slE!c-|CI+acFlDA^m%ov7`klXGsT!>Lsx_R@d z$ZHq~%}A4XY%*w1IC5*}Sk=8N+kTHYiG%n)1u1rNDMLcCge1hQN~Q(P_9v^N1HaBs zB7?7lvw6_~tLE8e;AKBG)?N@TrF*}d^Fw-b+cZ+8O|M{${BRC^@DvL6YlD|Q5_xaG zgT3U5DzLk}WMDJK)41NW%0!Yb@b2e!v4wTw(v9V;SJw>4&H#uesbrVvHa9n>I-9=a zI#expmCeTILI`v8#|>lPVemhHlv~XNoo#~Usb<+L6n-7{92MIJaAlw(<=ewQPVW@{ zImOKgYogS|PXRpriJoab?HZW2+~8?(G(JL}u2pmMx1D@H-7A+}!YH2TrVR{OWq-Iz z$2*HF#8z0JpRVSN=ezSeZt2;$Q+EgL8St+=x!l;?`~#3BiQ<3QPl(PYq#Q{4LWX{Y zeU{f00KL>LC@sAxG9lU*P~U7?v+@moWt!=qR$)%WVUUddN|V)=4mN5YO7o5%+Qc>d zBc&4)I31E=)R3?td4;<9{#?w&X@kw zp~-zz05=$uD3=l)o5juka~UJiOVJ3;okf|gO&|ZCFF9TMXFyuVWZn-~*5vBZA>EOo zhxZW%o0kH+MwHrQDG`8clS$wt7Vwfzp-3zTB1%9ee@YRrYu6gv8eXmAN1mYSNtrGHdZ7LQM8I$eOX0t?*@i5nby#4!$+ma5mxtDCgz}oB0zp{(* zSmN?+p&BoqiGYGbqcVI_;Y*MRaSzoi%<``#P3zC-(k9zuHQ>1|1>%mTT#$6k6|4`y z+0Pc6gW?Mk=;B$BddoYDogTTLv^;0OEJ1av!w>puMseLymJpauS=y7QP2>B$Qomju;wPcrjx7bZ^6e==ohuOe z<+Xe=!9JVCUfdc2_4G%IqX=PfYl9sL6%H(@Ricf9`!}DUY9U(#{^DSyww4olG=Kyy z^>5Q~jxX8azn|rH1k1g8kl)OolRs*ev$#(SbV`kby=mviWyu{Zl5}`h4&<}3B388q zfh020jEhiF3Xe8#X)w~tBj{oY(wI%HjkC1<&?Fy_UxYft(lK(A84A6`mSX#*^f00? zu!jF54kPu#^$;_6bVaTMngrkVm#1rzu{ezU%-&+82&ZcrhC*Xs^oh!Ujn!WlnQVvW zmqG$C7lig6vxr{)v&vDnlq`jlqNj0{ko9I2$V!DI)vp~cHfG@)EHi?2_M`gjB>xX( z*)^edhgnYzUO*QS21Cv_wwBfJA@n^!RQb}woKP|rV@&c#ZR!>aPadnM#z%f>=_dYm zpSe5k-6V{)l|qw|v|Gvy&oaVobsET|?WxiSCOg4~Wt*^^U2p+Tmf){hv5e1eeGCa4 zsYA+njj)886u+0G?=h|U-s^tF3M)L2tNUTCJqf&8`49k2DfOpC?Jj)H_vaIM*&}W>mMT*8u@eq4odBBqC z;1Ax*$1>-xek%}YZZHO>KQlQ1%V$(+GF*^&84c2?tv&9gnurRh_m^TV3Ls50)FO#N zo+iwEo<0cXJ%XioKf^!%x(_;_SEe;?e-7%O$k(wAn0mzjC5*vN(w@a^5N&C{W*Pcs*u8p|QR--_B1`?hPiY8f9UgiQ@h%6_$8|m)= zk=SCpwGj{6NnME!FNnXEnOt7Z8C{WAL&d<6JQ2(MN)|mn0oi>`)VYQy$?y1zpyKym zcL8UQduH{57IDP}j^(U{X2OF+K<&sVx5LynB4&r}XECeF7W1(nLX_-g=8n9rEg9Et z@qfYtC9I=vgb?-W5rKe4HMY~_h;yEnQjEMP@d!{Ns9FM|pM zu-y+~_cUThOlN-tKz1JzAH@5=(tm9SYmCvJDeK=%G;@d17P2R|Y=N-d15oH=4WRi_dm;Ubk zHPfK9+)HR#Dn*FxzFdb#8C(q+x$NEykH5tXy&cTJK9u;}62-TvRLb@whooN{BlKVT zGu#x~+D>53&3xm`MW>^~;jvPtbR^a^;{oct34^;{M~Xw01^s;IKK!2gNH$4}7Bo5% z;WGKQ$5fFQXyfSaZht&uHhj^erXU)s6o1m(Gw6SO1%797aRh2KX6AG9@Zj@eHwY^v zmAoa>-aUCSkbfNMQ~&yAQI_ioQ8&ALT?~kZ<@8f^B^Kz*4(Ro<*v|zb04Ww1sok_x z%zs4o#2vBLsT|#E45mc>lEPrvP3ZlcN4>5f!Gq!bNc%Y&9_t&iETUvFio9)?)_}Z^ zy`#)UeEKT~lA`}dKwgy=pJ$L8Pz?0j?MhH}E-=9OM=87!A~4?v?#cOY5-$yQ?kV|r ziYM>$yk4(`H-iNqm;&iB7^I5Xf(dJTOjohzo9^Y`#ga8|Xu=3hh9vpuKUAzMTqig@ z!>>js1~QZAZU;`{>$N`-dm5*qy)Bm{B71QlCkalMxi$GTs-R@6GgXNn5dSx~FHiqD zPx4AadEa0p7}E^|*ZO&at>u^w}mn zz8!3~8vMc0kbW&$3cYWjSxXev*NL78kZH-HuP==2{YHtvw7xYf+L>ICmZ0(^-Hw3q+;=Q%&%er=7CrhFTw@Uf5*%q8JHO{DwpDVin>|H2{RAGFC>(wtk;rgzkC^xL^HkeQ22i0PtPsTB2u>OkkF zY1L5w-8_E-U=c^(DQWaTtM?8$hMZ-a>(*rkt!oRME-Z4{qZA$lF&s04Jtx*bjyoDy zWg;5l!v`?W1?ZFHcb$w~i$uA8`=E@H-c&VOhpK7q0%+uG~?A3 z^x^;pP5F-fy;5Vx*ty}=HzOX)Hioy>CGhGhBJX`}82fd^+i5$>I}U-~qFbLkeO8W> zx%O^zw!?e&&niKb?-|e->0{oZqpL+8>3HhUJx{M6E_@$Bv`o))fd|LepKtU&i`dgXt0Xk+0Zc* zl`iuVgCTL^2bq!!gXT1-%wEr?AR*tuX*^{G@F(fb2CFmKZxQRPkuL7lNC3e;Vldoj zmsq~xBDJx;k0y|)zk=C%H{0^Sjx&Iy)B$?TVy^Yjsj1Dw1?y0&-5fCq1IuF9pXM1< zFDNCBu5-Bb51!H{BuzqGo^E5_KN!Ho9vIK|6AzYig3Op0Wlc3`f>}HN6(hL3l?uf) z@|OaI>lGz*KZmFIGAKes-N{qElHfN_<&IjVExt4ceq2f}C)a<$I+-Q4vPK+(cnFbr zr4CdUw=OZ-4UJqy7+D#S2y=rzuErli)>g1}v2bx{7(5Z{J)zjw=8*o&2W`X&)xWk? z-@;;w+-Hd_jWrB`FKU|ypKq6Q>`<{dMRhN65Wd0v zZ8cG;`uLSWl%2jY@wpko)=U`B#w$UPgZ;nc!vPUbav^R*8wb;`iBit0<5zMh7qnUpW!`JC58$|ODN@rx>*J-AA3bU#mm z#*=#Fp?6lW9Xc}a6LMI8=+#n)fjI9MNb>DM_APik91Y_UCHPj5)%xD`3E1DUtY*%&eMA%iD*bPwT8Coj5^V^5xrU`vh1^_RBQ2$3EiZaEB{xVnCY zHX|kiYT)`i5#o>G@Rdgr8%OTP7cv45NcQVg)Uan+|9P-Frl^@bxO z-fb(#Z>@8*zS#TV;TpaSa zO!CO}AF;b2XFztkjcY}_*S!(fRrGz!*}{WCK>1l>6fXn&8Tv-9t7U)F;U z#yu$sz<0l9`cW#dO)g4?z82sAO4d$oecysj7^&Wdm?@8gvs*Hh!H4$T&WC1CGRfBAgOQNWE}rrz%73 z{!0a-Tdg%J0||kJIZ~vG8EzJLQj}KOt#NMN`^+oC9@`Z+gX-i3?b(cqBHF=Ub{;U; zp8u=Rp6KCR!yz~4P-7qjESCwc0O}rciGOC9~Bb|5U#N8jtg@50s`37 z&igOqMFvk`j9gZvvN(!G?^&ySmFevz_^3n3p{tSw+9Tr>?ci~sw}7iN`8S0q9?$V* zKCZ;G>?ds7UkeWhB3@gcPwMyt1V#cMED%Tf728vUniHX$i$8%rk^r1GhhlHe|MHln zAplG^F!BLkXXnRXmu1z*F@gGl?%CDs_pF`if)`uY!hVgpK;ufCYM3|Uk8MzbTr3n1y3l{kLu!Jub0<-QsAGu_-s1(`|~ofKF* zi5;I_m-E0oAg@bZdvbGExo>3!nImF!SJC*4!W zy-Q~R4^=Jh-ohlV#yW6&`0r_9kCH64ANB2c%Qh)HI0|y`vQR+)X_ZoFp*Ez=Wo4by zs#$_U34?^a=4=;8U`r3|XQOt9zCHz)K0ZBWCcD2aY6Ael+Py2+x&rLidhst)B4`fI zzvAF?kcK2ZT;a>=$d6{F0>ik2)_lRWc%iQJ;q+ezmnUBBl8wfkwT=!~*Qua6aUG^f z56Swa^+#p8A>F>|#GY{q5}=9vq{;bCv-S(YxR29v-+()1XEj{6gK@IFT5;|ugxSpFi8UKb}*Q@g3?zOUt>`L9r(C+#k?~T z5@jBq29IGU=0!#)`n-_MP)_;n;o;-M@YiTI1PmOkl2{9LCtuJo+(`R2c7JfCg)25! z#?^vvbKp-Oe?PQ1+~Bwz##2F2e|_-*$b!%PTrb@w_*z0iTOi%2;82-xQCp$Ro3v7-L6MSR(6zC&S2wWJ;^SS_&-H9u< zuw*xg_@}@8B$0rbWyFhY{Wc0qgZcv;t+!S-=c->cAA7)x>AC7E*J@>Fm07p%8M=TI zVa7R8eg0?U-Y~@!qhDSdWH3ZEk$$5Z^H!x^3VW@8LBl!Z62Y$W*j7UYIKeXWvkVu! zTNMLGY_bj#bX)Irt3xVSdKJ0%77NUcqm@)znN71M=f83P#aYN@cOe)68NURk176QS{L%k0v$9mb4H@B8RsUNG zP~#fg*`hKndDdHN6947(;D|9rz<6g9l(!U@A{$iloY!ZQ#dI&45AP0!td`ni+Iul0 zd?tpPvhU6eU#(g80-7=mGKHRz5s;5bj$Vu0=KheJWLlQ-RPkgMJ!cQ0sV4JNbnFtj z3K;!XQukPqI zf0ftJ`|8fqR=K3WmIw+8fg$rc<5I@OcQf8Cah+8(v-L|pXM*)GzVJ}mQzcwJX z0fnD1iQ_7GeWP4ei;=~O0W59tZ0!rfoHC%XG24wg2@U~z8Z{qdbmD_GmhnH6%3`jL z_zhaX4n0X19GrZF{@Nb8g>0E_mGwj}fVF6;7qW(u7to&d_eK9r_*={aH zB@p77Z_=!}SvFMLV9U_lRJ1^X2?LMuSdA8cd1qp8&=?M!hbEtvt}iqZsn&_RA*8`!c&rRimN*|)tpWbgn$7w6Xs4<=Vo}Pn;k_MBfpe+&Wj^WiJ7Flj)Dp3Mv=aUQB)D zoq%A3JwJ$=D*l1H#^{^xqXeuIGFY{5q_xpGR~*ziTU$MP8K$y#i(^2+@Ck!Kn;S>@ zDN6$*f#LD8YI&*B=1FzrL9OA|aVy{V>(n+gZYS03o$MjW_3zK!Lb9!|BI=B!_<<$w zW$X~WV%9O2E#K#NkWjHW7zG(RG1Z=X$$#xIS0PQEI)v%K@Z`XosnL)fnnk~jLL2 z;;6>wq_`U6xh?}6VvQKSV?$c|E*%?pQP&kU_;(+e@}fvfi*43f!FYo0sFkKq^S`Z{a-ND*QTo!>;R?B3imQ_sy*O{dN8|RuVvyL>8&8 zzhNdXN#F<-8lBvp$ik={iEb|vRI7hjx5ga1^vo*HKyj$9NV*9bfRpl?YGE1nNsja>$7P$;Ws&;wp1{mHzAE9N)`<%{qok+ZAW%XAY! zn4eF8ED?4LL5AHrYAfF;-CdJ1pylQ;S984x52daRveh0=EdJGYJaV=L5?W^CyW9d! zb$`a~QIuNbJy8#2o%W+=>V;x9>Y&`xaMlE*(Emk~WjBPxv{C)6_=-|8ZZKU@S^tem z!g90!78%U0icG)mL$8u&iEfyB<^H+C>iLTk@qyWM^IwvIxd36{877iSIZ52SCSz&Z zm*1)yXmJNUHi?QI=;+R^Q`zksN8Pd4$PxH{zftZc1lst$@iZS%C0B8K#tO$XWN{MQ zE<47c%gcp`QR-@^FE9QxyovZIBe;%*9sTdn)uXG|do@q|CxsvRYtrU@Ti71`Pt&Pl zbxRc-N$J6yovg&LPfkEMSICLz3I8+$hRr#ESyFZbJ1LfbEJDtq6jEMAFv^OV*0A6!Li$aHZ)!rQ^hdXFt#2 zvaYXH4!u~BOE>q>uNsPPdO&~@6s=gdG1S<8hvN%HiC?eTpD1bG;JDQZ`PI5E zIN*S#D1nD?i&l+X82`-7$yYV2S!@j+gZ>F|cU-N1Acqv%6K$oz7-otmt6o~0gXQucDuxNci*)62KWMt+ z(tsxbtpSSfut>vRHLLB=fK`M?KMh}UY83!=O8FZlmlGVHWdrDZy=_*qHOn)y$&646M)glv0SJQ*tF-i zrbF)w-+-_Vm}(Eh|F(J`9~;!`ZI%ILw0EO_9AxGhJ?3`?AiMyj-IFqhy`2;`>Z{*m z)1SH|d8RX*BjLGN4h99|drg6scGY7ET5?sL`USbpS~t!D#v6KcFrKSkgYYqHxf}L6 zDJEcMK&tNdHvWNK!fFfGRp@KB48&2%?j{wP3YSlile~Z3S`;^mu+=`9@Hdi2Vr3q0 zTnn3Bp_c*N_e6OprdJ^sd7vYT+Vnd2l}yjhL?nddRz(hR*1)*d!-$@;0oN1h*nyb#NMT#V;sHrzot`E>AO9~%u*xhbVFKSrPF7{LE!z`JBx3z|X6d!Q z)027_))`OZ=XY5C_a<@>Fz`E|Cv6&^MIM=%N2qP{W{(P{kVPu;dCi`ZW431l|9ksY z5uAc^5RybtKG}j4)zKM8XiZ#&&@*rYKA9M3Y+&Z-SobN)$gdE-0CuR92^00Q!x+h6 zae1&@VEyH$LFc#VxO=m#t3wM2p)aYxW>-&N%b`8V%iG`HERcH;6QK5ky2UFtIy%Iz zN4A}Jz_YoBpE1%V5M!8$ zI}4V|PAi?BoleCxVaH*VpA#Y~!<5{cadt0pG}TfVs~HBKx6%iaFaP;wW$ZeYmSoz5 zV|X&o^TLV2d0GikiqnEVbeKN{)BBuvVO_T@ueb z74FV*zTPrZ-EU0zPXZ#Y`d6^uI82W3@D-W7e>}NgP!bUK^`DXj@&BEkozMRr%bxxj z`B{MP@EMts|MKp37bPw9t>hM&hpB~#%}#D#wgj!Gz<+eW*50;ORs4R-bF^L{=$Y}+ zX{kPXPbjZszxZQjZW*>DnLW{toQ?h8$O>CqBf9@PQ`|? zTY9ZBV+f?u!rHVC;$qrZek7eu1@^`N4jX04)b%8ubJ-UUDZ4XN|j1mz$QO! z7t*mVU-HK9{36wwB?)6=t70skmxVZXVzas<7U@}8b|WaXIN zhv&X_A6^^JsCRy@OKVy04hlcCY5nqYQkGag6*?4KH^(}Bs&hzan$~CIWw{lseEadWZAtchcNt5z z^S%Z^mF2zh2kxIsdf$WvD`b?rNia;=+?P<{LJZ>Kvd=c?j&OCb6J+pY5<@)cjH>W6_snt?nb(yo=)Wz_^uhtXP2TNV0*2%ncq?i&{Q3CyM}JE+8$#(AZ*tmHI9A@; zJY1Z&QHZu0(IiTv$;hWIM;krf9t@4r)UJHpn!~8AUyneTEBG%nW6?9Bv^ky5&ZF@; zO`)Iq^7Py8X#v?lF6bq3^Uc(C_1*d(hkz*^&2$mLYCH#-rL>S$7M63{-AidpBi8RC z*L9u&kTF6IfI0P%3rssT**;V}A6@)7eG8~Lkt=V0+^c8;`uIy%JcBU>U48X_#C>YB z+2xQYS0>W$)zKiso3iUy&$zf#kq_UC3Fy2(KokUdz!dQRiq_zNt4t?-eFc+j^hCyc zl$rxlk)!l&xjs3XK!G^iiuKxFuXDZSmY-X~F6xkn%}XoX?!O8uM|J0bP+=cnnE9?a z{fwowNcHtj@ZHSV3bM~?%%$`X7CLr^F=_!zH%rp1)zrh)LTc5B!60Wa)upP8{RX>T za#pEu_2F^)feZ)c)UgFqlSs|578<_aMuqH*Y4F^S}KO{v%9E z97%OJ*_<&??O#IIwYwLt0YpF><;&Yod@lXNrL2&bzVodgT{q0{@cEBc>68>tCN${k z2g9e_F7pV-0_+aY#+ux)0o9~b+q0do-=n~7-dz<+FR%#2_*r!BVrvQ-Dqd9*%AI_9 zwK_gm9CPV}a#(Fm(7Pa#2Gp2(bxmhcai-v%@eV_p7H`!Rf(Tk%Wgn!C=I+9c=QD`o ztPUM$-Dg5nXCq4zSx8@DZEGLdDyhi*gh)nzVtU-|ZkU2~6}+9J+vh%lD>dU6%OHQx%_oG~;~Vv^fzwDxFN`y#G(Y#e{Oea$7&r%UvzUfJ>e z{V0=Q;^Iiyg?0K{xU`>sB-Lm7;*yiXRX-O{*S+_`pHx8Ta*4WLm9rX)eH_EGHQFQf z%#5})K+q0^Y`>3l(o7AH0WLAo4(*sYZ^SRe8Mf^{14Ka5FRRU3Y)&hFV_ng??M0+J z+$(QCZQwioL}LYku(oHdXbC)x z-RHOXIOu$E7n|z$lv}qE2+GVYkl<>R8ik+K4x;{4kuRzFJ-wRO&nB?bmF?)XJGQC5@`Cop{Q3Zv547492He>T0#1O7klT>D$n_a3)d z(=9J~%8X;n%G)e2nNxA5WRGpxCa#%gqR=cWQ&f%?q5_+yXu52(^x(Cr^MVu13y6Va z2u@AOJ0e~&ykMa)1O?%I&%bbfJkLIV!Ta+(@8|t~eLk;?JA*12nh4Af?e2jCNHH*F z`%K29JlJK3bn{M-*PH#2bJBe?9cF&sQmx`TTQGib79aQ8tr|vPlN9eaNdW&(nRt6} z-1Wx8_2%MMs=J19S><=g@O(}WccEEnRlYH1LV)-%r~@5bd|LZjp*J!!X7|VRn0?5+hwm5Q5WL`-Av#e4>MAu;?!K zu=iIuSTb!Tp~`&xH@fKhY0+i*I#-c!^b668OtE{*kZBbl=5SgKRc~GOHxyUKZ{=T)Y6m1 zY>O~Ltm8GlGg7cU5 zN&_goQcGX3rn-;7xWH>cKKv3s?EOq|cI4}K z`ZVtE+090Ppsj~d$6(<5brD;`R=g5rKV9M$SgXs?`{uxpH;gD(G?R-U(;&EOLjQgC z7JYI4SZ3F92XTw(hiWSeK$1nI2N!}Y#$@814)W+Z3SG-AgkTmP)=fI9o=`si_JUr< zmGEni;mUWQ!XV$@b@eatK6mR_CQYj{+umQ=WcHk2{~7lvS1!6b)!+}Fji^+d9H78= z7Pgn2)6+&CiL>pxPM$H|W614ue>&+z+~l)Qi+$CjKi!1-roA$;sN*!5qskWj>ND)9 z;jkZGn;Jc_{E(VDxJ=t!lTYGzhjyan+o`Xv0-pYXSg^$xWQ1-SLy5#lTfCH7+*>{b z*S`Fu&@JBXrm*x~^&#bFwjV@nFub5ZskfY;?ar%wIsZN>0kOF`>WnZy@>p-L2=nub zrKAXl8(Xh~PBUS;B;Hi|`V>OqwUiuFZq;-0k=dCB=QG(B>19{@fvr3~z=Em7UZ&(S zbyRiff+WKq=nk`g8Lyp=El%H2Oa*Cg6_a3T)Fe8NQWLFDlJ2O(b_7S4lscQRU{;0vK)Q|;FW*hOx|3Wk|Si_@j;EOIAd|! z%&uvu23m=Pg2G0l)kO}5DDsIVLPPiKvD6cxPD~%@srFN&jQOngyo!#vzO?1Q>#ZBs z#T=twzT11;=6iBC7QZWtzQe;SS|E-oNh8Bd0u)S_{+Vm=z)DqKgcRn-jvXL2pg#$g zWbx@vx*QwPrBm*-n3=Z5A}DKZC%QF$r;6U?r&>D>K)Fk85&8`0(WO~x+}7wU-r8HC zS$)BzX}MRJgwI-omuC$oz)<_%!I}s&&k>Hf?H;t@sDy9{!-mBV&|a%woUoObOmrpj zNOam9Eovx}LV@iJr%D44rz5`uSdaQIt&m~uYYBO^GxNKiEy29OtrY=&{RK9=vN?7V zm!@c3Ju6JxBuQ?E+d1yVl{n_2f3%qZg#&dT5Q?7`l2Du&__G)|xbX*tCHfDQs-i)1 z7@}SjT&zS2scfBo05B28W2VpZgw6L>-xxPmbYiyG>%R1I08P=+mDZ_7zNia4r(R^p z^!*-cdFr~Tvk9_!xwQ>?C(+~M>KfWs00r{&E~sBtw7S`~(u6?vqRKgg#;sxxa_N?e zM0bI(9xPN#CtP&5P{CO2z?@v+_Y9zoNbSg1l9_x44L2o=krYQ_x)+r;?`vv;I)eu{ zIzPbPzojczP?72aQ1~=9Z+2BMn}(iQPx;ygRM;@R_&DpM+n!{sh@Cx7@t_mMgixGp zNhaoUT$*5ii3Q&V-;S)ElI#l$K~v+pMZ>i=8t(BDB@e`kvQ4{2-VI@29W49wftU#th5fQwLn@`W22{{ca8 B-Dv;- literal 19598 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 literal 31471 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 diff --git a/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png b/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png index 54c03f6e96baa3ccbaedf08b717114eeb2697d8e..082bece90504a1914018ea93c70f837d284ccf98 100644 GIT binary patch literal 49885 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 IYy0;90N$>F-~a#s literal 43623 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 delta 30 kcmZ>DouDH*ak>~Y1H)d1P~C~9LJUCQ>FVdQ&MBb@0CZjlssI20 diff --git a/android/app/src/main/res/drawable-xhdpi/android12splash.png b/android/app/src/main/res/drawable-xhdpi/android12splash.png index 902356d71cb2e1e3be2adc8b4ed8740a29698aae..567045725260d82b10470084b9861c296525c1ce 100644 GIT binary patch literal 20072 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 B-Dv;- literal 19598 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 literal 14855 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 HPO~7g$z7P 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 diff --git a/android/app/src/main/res/drawable-xhdpi/splash.png b/android/app/src/main/res/drawable-xhdpi/splash.png index b01e86c8b8fb0d78ce33486ccbddf78e642f19be..bcc66bdc43aefa69994445fd1023fd4646a368ad 100644 GIT binary patch literal 23962 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 literal 23353 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 diff --git a/android/app/src/main/res/drawable-xxhdpi/android12splash.png b/android/app/src/main/res/drawable-xxhdpi/android12splash.png index b111cdb9e2c342a05c733d227ec451daba32ce0f..ad4e8e30c7da1bd78cf1faf6ce19a00f8d5ff8f6 100644 GIT binary patch literal 34262 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 literal 31471 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 diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png index 5b61cb8d974c136f8da00afc01e38790d23540e9..0675775c9addcd8c3b40e4801c9fb65349e05a9b 100644 GIT binary patch literal 10199 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 R2TuS1002ovPDHLkV1f@BXp8^= literal 22927 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 Z3LA9se0}Y*^CYMY_jL7hS?83{1OO^tPf!2= literal 37915 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�cp81+znuRKfWYb$~x{UM&Tf(_Bc%=@_|R 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 td`!bg7%h<^_MM(B- z>?FIfn_w|lD&E8~g&!5W_x3HDpH}&&A!EJv|>;Lbw(=}52N6ugR zBL8{XKjvikkN@7ss$%B%<%j!A+K)7^zAG1A^wGX_?*m>`6hvr{KP2d4~&-H)*{68c7pC|lZIQ(Bu_`k&Xf9UZ4J4CX6^gN~=sb^5R zW_C7d*4_=fN2G*1t`on+T|DnJC{KH?2i~IG>@ujE$@SY2OMEl-II6O(Sb4&AC9r*H zFUj8}!jIt8H~Hf&(ZnEyV{{_LraRWJZKs*=t}m#heLhD+edQwbk)Q_nxr?7tMuoN0 z#IFt7>B@jHZTlnuc5O&|!95~PWw)F_^2feFQb$yHgR(yA`=3Bmtan;CAfXu0GY~)p z-k0&*HRjREpKfpYyy#h9MC*5rTIR6RXntL8;-);i!_`K>uqS7wWdiKdkNcT7CyHfXCJS3st^$Ce~V{50{9 z@#}ZFT>s=SMCqYY=MxSv?uEsZ+O)}LQrm5o+zIxfrk7aggg`~F|F0P<@jkCvE7Fp| z0-{sc2=PFfLuF1CT<=kH$D2Dzokgc^(JNxuvu7?EBohT<77?ut^U-RsxIgAPKb8hn zHa|C@tERpNw_y+db=hXn+ej%rNv2HwNOYEZ&Bk;eLJ9dGVB2UfUzxa}h3 zAZ2wceN}Ytv{&3|@od6_{(82UTcY$`_mKxd6VS}pvok^s#xWsl^@?wV2 z=C{0y?Z*Qx)FmjoWqGeq&pWalUz$k%GY^@5;?0wPSHgXu1zp4(RtP=b9l5(B!bN{S z4A?E5PE;E+VmJGDy@d;tJyj@ZmP<^j!+!1HtlaA zTjxK?KSrBN-z6+!>BOWXPt1<0yDZjzqd&~{QKlq@PJWJkz&M-7**O;62ZPx$(@mA)zrmypg!X^FE`Sl5nZx?9MjLwHoYALju>$V&!0p8ZPiWy?`*J1(6;VGkvEC)j#0*m+a>)E zC*8M84S7V3v+K)zu((U64Z2dNrQj!MToj=aLSwm<_vhxY^lk;^cWrsPMd!RneSO9b zX3jNdZP&VDp0z?*T93W?&sT=*CuB;BvFaa{&KUKOt+Ga1FE65Qec79|G9Y|9_~LW@ z-;<%ay_~&TH}ehy$@G!xKE~6#2U>IGI+DTovI*qw!^%#&)ZL?pc{|cylG_P1>CBD{~R$3 zX@ps~^LCbc&5S>tOgk$=4{Yw&C9x2|<=eOn4&J+$O`k8bFzfgsV39!X4r|mPj_0dn zA`Z?8Acf5n!mC`Id!vrc$|h1%Q)N1`Hy?xTQ8`5^u!B_Mslr4JD%BlZoE)9KCMGGC zuDZt6ryO4p?@*G@^>M;FBp_nK9RL8!L^vrg$O$nL=KT?YS_h zZy%c)nmiy_Z```M3?VP07V6d|7Ed?t!Mr3lH*LF)r^7*^KY&W*o=64-=^(o9pn>e( z*3T&NwrM;(WZ`Jp&l6Kor?!sWf;a}TAsuEWvrBY6YNoII|B=cTDJ=3`rKFG68!r)0 zK)q5Jp3?9e)^Y4o)V8d8n}LJ(Gonlh^Lrn(%@_CnI!;2+1daVQ<9YHI;ROYJm<3`x z3hCCctD)0xO>%XAbp^b)(skX)by7?WD5F%BhRj z1ajNTFjA@uJMV|3R96C>b~RdAB_9gA$9=oJRw29gj&8StkR#d;H~*NpGGU%S?>gyT zAv1JTE_hfb+_2*vQ@1|NaR=8YPy2p?e7(H$xe&2q}^Wng-?{yLO${^+`~L62_I?wWD&bWV5wYU z0BIz<zSVakjkirmIAw_ggFKp8$}YdSD3 z&lU)-jZKwd&Orlc0aI#gH_o1K3)5e!8Ef=B#YeqB5FLlq2`5Dby6}Hma z!z;#8hm&#G0`O4qlo~;jdL>|ZYXti34M|%La`J)K8>;x)uHCKf<9zAzAh8^Iyio8x;O z3Ew%giHT6+rfe{Z`}q&o&;`K3aKkt=l*~g!hZQ3v%G2_3;HG0D?CKwF zQ_*s5&^oFtu`WnYT+rz9ctH6ha}V95C9_)MXkR^En6UeP>54IuuheAfp;xfG6$@3l4$ zJ?~g&VZep5m5H@CZOKknaN=>jaY!F3bN}bM25va9bw3R9W`?PqJSMFCkd0yE937`- zN4LJ;qYIW}{oS5x=qcOgh&M#cDQYMw8pYjeFsM+8nP_YoICX>4kYk3U>=Tg2cMpTpl0p#A80P)=sTObJ( zEgq(M&z~kYh8(Uzf=+l5hw}$P*j-yi8|_P9=x{MW;H1S2x*dFUK9F3s7wC~f`}cn2 zZQmaiTWoZg-)MG_K3TI>G;NF7FKW?iNS6n#n#V=6CCSveR{%LBmA`>{d0nu6>GC8P zw~uJu{jAcqzk@s-LGVIVZmLzxcx!|f0FESMdQkFQL5renth%XS0e&+p&7t~oLehTH z%yl8t;$c z!PuJ@lPWQztIs8p+uKxC$Hs`Nt&I+D`IcL$#xnIK%L@)Aj$nr37hdaC7_-_D&s!c6 z!tKYegyCx?i{!GQd1C7#Wtd~ZkCYwMLJ-Ux*sZ$kb42%37sOb_>=On`-|^=Y=8tF$jOagbKWuTFy&ryR{dMgmsOZ z#+UHyW_X-oEm0pT-O+8CD4vk8sQjhUj+g9#Etl3dww}%)h}1nY>BN6qs=aUuR_kWX z_x&9ZPAu2oA^)DXSgxElRe0z^d8s-~Y+mN{b9IRX8Xz~W~6zS2ns)_fY zuf!t3py(;JEJf%P!!24a?SK4RN_5Y4oFi7^4c#+g9fIk0%cE@`o=`% zyNhsg1`WH7MYlNWHB7qNg5b%3hYC&PF-)_MEe&-^c&Yy3Ky0o+?s&{bN^9qHTUR(c zTB!w#3wZq(>;SrVwQu)yzu?Yv@e6B>AONdiGXY?7{45liUwwV~5enFFyOD?ynXlUFCpmZ>DXaNA8S<+d5;VbM4dU*90`!IAVhe8h%{9_to-p3sDRK6ABCfW7j$@9f$K=hYMiit4^_LdzYyl5%>6Td)E0b@YVFv7e zIWRfGyhTHhs=l79mw6oNl8v8m__$82SYGaG*u@a(LT?)yvy`pn4*(3FaAKjpw1I%F zF>nwMPf@hr4DE=X=bg=(AVo&a@52HmE4d4v5stS|{DBM03BetT@-xSSn899M1+S2= z=_r0*80dS)8o0bJ2H#eG$-6I}DdC)!aXXzu;K+JslI!sj?RPL7W*5d~fkmExZ_TcGUrnsypq~7fL z{QM;4uu2Y)W%K_Fi{=-32e?>vQtao(3N;!x4cC?eU&#nqFvrowEYciAo=q`_hUS{a z5z|Pxy$Xx0V*_Kv+$+Qdf1?P@y|!1#CF6dLE=zaQ9$2jVNz+e{DdfKo9seBQL^ip3 zt;Er*kI;P&cG#Mafh-Fn{kA^Klg3bjrw88ifj!%cOo|<$_E!NL{GJPfq-6U3>84TQ z6lvZHTSbASH?n=&+3Tm&MJ_xiUCx)%Krm*?G~-HzIVNP~PSGN{7g%GSYvGt?R~KA5 zojuD)|BUb7c0UXlk)%41dYP@K-%sp<2OE2&P9wXYJJxLQMV5cI;0rfWD`=}U7G%MG zZMZwHS1mP*k1I%keM9}ue|Ylb{?#=6d z@B09^>&+1OJMUWG*(D+L<~yk}@DH0O@Kdt9i1hqVVcn-ZA z!-Y0EH*JLop%_BbXK&DpDAS9G+!-5dpV_Gjs=Ej#keTcPmfV;$THuo_`{9t@(O)26 zi|hz@%=ti=#S5~ZMdp5&I*UsN``NGhAjba+K1FP1sdtwK5~h^b+wpS%w{L5AENxuk zcr^lLdUl~keML;vW@1WN`82)g(%6rV%ZS&&R|{4=&vjPi`@jj9Kh9ysqBiwd*}50( zJ8;MQ_Q|-cF*6@U68}f~gA3?Y|-I zHYZ@Av%o^(`rz8Nq0*1~gg?rpwBw0b&SIA#3jG<-Mv=pRkLr`+Cju}%(33A*?Ws{| zo6mS6&zgvW==J51S;zG?=}`!3md~Q9#-R&htbL8>-N*J0$3O?zM)Z+doN~Z~yW-dn zXMZL|LDu_kLCn|d+X6^ZJRx!&$Zp%upIItT75mkC``=e&x3P^G0TiFsPwrQ!l{heA*$?3@HH)QhIE;Vl7eIOB{z_LU| zk;V<7N`kD{73nx1=?4mFl>vtR6qN{7Q`{-5cGM?~`BhJhiNDY0!QGRA$XAd57*1t> ztHtNBTRCAzT3JR9r)IF54`@{^MKb=@12KLv0@CArde$+1cHJ&uS5k4Ty;RJJ0|C09 z0%dkeiYm8Wd?JkAm6Vv%5G=Ru)#|)2lJgumLrK3OiN9=ZNqb0bosBoUVRafW2%Fk! zllT3v&+YQ$fH)IzP+-V`TkNgwjQtbG$?+9zb_kib)}KU0wNRbMeuR!*0MQkEbXwZ2 zSb)HH?s%)MkB7lu1T~`c6B~n|rIw`dYK*eW0?|R9KXf5_g#uy8O<#2!ur;lp1g{e0 z0gWr0{Td_v?|Z-@w5lh9m4`Z}T$O`q-Wq@KI)t$)g*$<$eHWwPTeAFL#O3E^Q0cs; ziy||x+KFfVG}PS6ZeB}htNb=*?eGnK%nek^Ix>@r$*LO(ukD7^%}ViE@tgBMN<~{} z*h=(fEFg3ZBy%!HBLT;VaM-e_m}VDlCH7m``l7=SweX&w1eg~d9>6@dm#}DAH&*qIfuGj2`w35UkP_jumyn$<0b0i=7u*cl{AS)yDftDPMy)3^CO zuc`X2+^?9Anmg_uH4AT+v>V$F^J%ai)01)QvHT=9MtTPT2lLE(JADa0z3-X4$Q$+R zbLsUlV2fLAB97~*QS68&58c^Ndy|rt{DJq*JKM|bOo zm(Lxb6TVL7lkK*(5q395{0u{1AKB3}D4PtJ8_wVPcJxm176dyJ%TDV_30a9utSEN2 zFI|e_icSPEM~}`mL%iD$$E9z1KCP}$m`*KU3$7rI;R|5IsmHNx#(cprO<^k)R-T*I z73FSo3d*OM07@SrP6xA!5Zh`2fC{Kpbxny=0N7a8h`x~ffH|SF2l-}NQ7D)Ut>5MI z?pNFKuv9g-y)z`FWIhRfoy{9Z;qNhncC_ruxTa$H(tQ%uqxsXZ!?~7QQx=I^QvPSl z>Spq>=4Ko)n&RF`787}7y#Y>QuG{)faib3_X>dKrvB()qR$!~~4g>3djW19` z{ss)9ssFTufRkoOXR*iSdzW=-RWdw7yP>!8^yaMlOU)0fyLV?+Ooe?3vl7^ygGD$k zU}4&`v8bqy;pem4!ll5q9i{mp><)_^;cm0X`1Nskjmyb~tJ6RZR!SwyC^VO|Zx$xw z$NI~cxz5M^aLb^9HZThb?jyCo>DxVUuox@M5sD54E~Dcq;b=QH;Wj=+xQ;TE-k3NU zsY<@w40m`aam7^jLPld+o$ZOM!SynLG~ws*2WZ%g z0}vzY)A~;ly0wCGs$OG}kTzmD&2u|7z5My%VO<%N1{qabL9(AJTZX zCtlbPU6RacuR+?}*M)=ajH(BpH|gho3V|h9$6U+Doa3zg`7Q zol1cos4RwT4wmS)%=@;`hXJs7_G$)usB@$intJ$|7P3rC(o+dAoTzaZ7xsNIVtL5m zhgsEfmMzWf3VD^dW&6oL0BdE=f$sFqrqHVtz%`trj(RI8AtXDduf z;xI+l%N&S5qVn)V#5}bULOHRa`h1-tbeP@v*WdWAxe|CwklK3ipy(-lZV4+dyVlEV zQ)Cn+lxFn$+vYg^_sq?TC2wRpnbLFR=q-(WKq#RFb=s;)Suq=;<=%pUk{)IeM|NwN zv7e=LoN%!X!Ywv<3}D){ghfAWDlv?#-ZBmvS^6}OglFHk?P+X6M%=w#_$~sVYtY8O z`zcRrcFu4hW^Lsw&1#0->ufLNpn$Xn<;GGkO%E1Mh)wrEU;fbJufRMPnwi*?H9he^ zVVJEtbJt+|eD`Qr+`--8N8(-Mm#WAATC)}=iwoC==ekPh`>{34q-Xs)J1)Y|%08hk z;IW;a88RhlV~3m36QoS9BQ{$0P?tPJy9vQu_kp@{qwJd>BK!5RXpx`~ff#jb?+R*l zBIYE|P4#X6D|yh@YeR?b)grhj>z=Ry0dVk9lvlGy>0_A?*E=FPP<3BAn4(5mnXM-! zJIX`&RliO8a=o>AFOMZIkWZIhwsyT}`9Ad9QgXip#o+7w#ye)Oe1|%pwgsWZx>u{! zLvOxgqS`maR6=}}hO`EP8HOe3MRL4jHlbY`NE)e}sdcMrz+=$MaGXC-d~Y9ieM01V zuI#Ym_IGx7RV|1=jeofB@y$v^qT4ebm#?2H+<}UADN<*GfNB}1BR*8fkdtEPza4Ac#bjGC{7c#Z#vw&=&5^_H`(ETN zj0Z(O5+Blwb=!9ltUdT0n`ljYcLJvz+Z5{2JqQgI0=!1UA{~IA}dd+HSxL3e~N*i0Sxpu^!1; z+W#6tq++AD=Z;!UD)!3b(Uh3aDB2GJbjVDK*R(SD#Qw4-3drQyQvMY8dabF3S5Ejz zq1P({)_CV-mxhqNnAD=kf-BSy;mp}M3bygA!(6nx3 z*Gw$kC=jJV?rUS^oY}CE3mj_CmP^I30Y(XlX0=b+?pQw*dv|s7&v9$q{Xw6SykJg% zn!>v5gjx8H4Q_9*8=I9Td6om}ck^GW%_5G3B>Yfo51f7Tf*7WFA~`Gu+__848Matk z*;^Gt14z;bvBimnI}=;Bgj&?y3a(3!QL=d^gHbV9y{siGJWwW& z#znh;SH01%Y@Jj=F-^~M#(@|by2Qed$=KQdq$fm~8o^6rl>8^Jm08`wEJ_{k-1Af9 zjA;tPq>SpfA9H8}bSAG}I0#UTsqh_sA3MQC@sy>XbaOh4DGlvel%RlMFko4Wycd zX|zaIl=sEvZ;ZcFS)(?<)K@LGnK53Gn9>O`COe-WOU7+m^NV$2e8XHqzd|deF;q!# z9kC+n3O?FTz+8A{NK>VhmA2yHzbKLu3*4Orw+}|3YY@tzcKk{VYf+xQa4L@X>ywnt^dQ<=yieDLE51A5NP`ejMC*(9K1DR{rd`*4 zqEdc_(f2)&K-2$zE_K*Jim6nTf;T&MXtj5&P^;76Jw+cF-`(NEFAC(%l8Z>w_Xb6= zb4<8Tq_Rsf^`Tm|f|&BkE4VzS7bLty8x}h`eSvl38jz-(tlk{!=&2v(#5)!#?WLEl zPJ~cTE@zGWxZcMc%J(}iH?obbCfj}{OVZccAcaM4{#3$u4nE1*8Mtd)x-M*x5%5Sa zd-b0Tf7~?25p>D`@flO2E=Q>a@QQx8tEs61$ReTm&WwB3c{pObZ>f`~;$5gc907Jvpr*zQ`+qyw^Y*ajT|h0kP|E1;dJ6rWuYJEUu;!&vWy*K*9Qg^*d zNzG7am~PkN+=a}oHdfE5Vo{d-wa;7ZhDQ(lBx)=HrMJ@;F(6Unfs87jDQ|Sw6Rcay za{>SP5QYAezev(V=`%zEvk{ibTezafqM2{@)7kXA)&7X$XM1||bPt~>BBU9&(DuxT ztB)FjQl4KodVRksSy0keF{$zyPs!B9c8vvB%GPBY+R98~hy{pI2AB-6Om?{b8fZ$% zXIJB^cY2BHrsy@*;&(X_1@9`RimX6iao@VAx zbcJ89L^6N_J8c67cbd;vT>`GGHwMof3;yL6RVmPN*xI=`T&ZHyHOPI0s2|a6GyS{o zGNNyB!#B!CIMOINq7`V!`+4Ycz?O7oD=DM0Ezp{x1y7fSi=pDcl&gXHKKz^NY7*UgWvA&BMAV4pe0~2el(nsg&)u^8CZ@mRsYTF>sl94Tz3F?E*1g>` z!CMtkS#_&AWxD9oQ9;xA2DTre1^-qx*(Of_qTrfx{%J&ySJ2Z`*M|Wnsw6KU-hFc? zd9`~K0J1ImV-u>-h|dw9xy7JXac`EZx!BgTVaC0_@;2p9tsQAo0D?T@7#dmt$PWf4 zz8V1U$)C{o|M8wlqpYn6^IJNwcfmCIqGs3dlC|?oo=(Y!HlCsv7$ZrPU&`-hR7c%1 zhJ3&p-@KC_bC49E8Oi08JdS*QOm@^RWoYhEQQ&+t~b*@#@N36IY=!$$giwZ%7G!F)w~UOMzC& z+gNh!3vFx+!;0lsD>@ucF9_cgl!Q(i-GfuRyq?9sC$1<&mACD8>@k=E>L32^j0?pO#fmZrY_Q#Qj$&1-p?zXkhwLVsb22>J3mM-R3!ckOqC zct1jz)e~ntz1#a~!lwCnr$#Olt}M?J=2Ztj`7ukwd87TYjsgwun36RCkDb0CVBuxe z_WHR2?DcE)74Zz9gMLCy<;**c~#HCHh_Xu8mNaJUp5e_Q*j}D>0R&M8=fU-rqWB0%- z(LG?A_F^Cus2GE4uizQ}O$->^obWXCW=M7t1qNgIeKmX^v%-4$+*7%hJAdTq$5c^o z#vFi~Qtt7D3WcXuzPMMwSycXMb-5lR)cC(4@>nX6C%wYIX^SpOxQ!b6Bud$1z*>Ar z*UviY`=`oR)57{EM}NcG7Xkioit|vZ0k^GaCRAT3sk__1lX-aBGrU`p9n1?w12z3| zb`X}=X#S9_g4kU%Ua0XXO>?qx?YZPY{}{-3u{YaxTwS9`-m@>)VkX3}?g2{v#!Q-D z4HplEX0FOr>OJ`Oa^$6?z8FyAmW_B`K?5Ue=J9Ju=4whu{o;(O5+HijWS6d|N9MQv zkG4KSL%!$Ad+(%s#+e;H39F11vUxppb7TY(#^co6(60_cF-m~*g6zg$oWst za~iRMvw;PI%WFQ9CsoMcRHgzC6h{d)_;;P_GTKk_9G(C<0rf1;&IO4VcPi&SZ3uV# z6i%jt?QHrb7E3M-(uKBhx4f<28t7ibJL(BGx~*;m?537;kA1gZIDE?vKxo1Mn_@eW zEM{lEtu^8vpG6H!oaEMh~DT0D`KDY$dgD3bQ z$NTscyDC`cg~euy-eT#Rm1`i1UR4CxPQk|W?R3|x1W?lIq1W{ zLq-Lya6`#SnpHPTVY8`OPY>TChI>BLd9d?k1l$?gfbp!i)^%iv6$1=z!U%~sS5sdt z*G{~>w<2qd+AE2-0j1-7bBpYfA2EiS0JB(@wbLX9-R>RWPRVO3K;v3T{4t3w6G>(#}< z5)BQqK0C06V&Qxvbkp+}>F&2jN&<%#hlWL*tbSz-n7{Cx*dR(|1r0Z#DKix+Yk#-7?*}}TvKkdE&Dw<&2*fJ5xV-Qylbj- zLf}O0Z>WT~ts2n-GNYjk-bfE600b^!C$)I-!g0&@XT5q1;vUiOkoJHoG?^mnqn7f` z|7?fsSny1f_K}iVcEnZ2(#9EG8b}a;EWy4b87QHQ@ov~;*MhH=>YAi+{6--hBVap= z%gNaoe5Lt`1^R8aCIBSh0CO)?ua#sPzT*w_-uVa2Zy)3+OIJ2OBO&Vm@wGH5#B*uy zA|qSmz64u!wrRN^9V^mbtu97m2@2~~kcW>$5vxvNl}!IcZ_7sqzf^9S^iGyv(I~EW zDgEfDbkTKhkNsUvwm`=p3D(_koyG=Ud%dy2@0@;q_w6rxS z9^aJEBaO#vglvRQvYx~T`dBlDe-TiK~+x*22i`y!kuK4ngaH| zc0PA$XE&6}9j;OJxNwXk+L?sExPkoiDauL}uOUI2KImMERB}i5LPU zw^&P2dgICNM9OUq&&8BfE(5;tdT`4it<8-?zoSAw?Hi&mm+-Q30Z?CTOvB{P{IBh^ zopMcjb%TPxz=G5J?!Qvqtdo=YZ_A*liBV(kW*ph-Z=-KlUczkW)hHOw*d$-#s#?m| z8J^$LecIqpF4@@7GlBUao^kg^?TV4~jlEy#a%Z)&TFKvaEQYCU<>O|ZNFjb=1kzEa zw9f>h!=hQ_F%KMB5gG#kfRF-O`K1QBAZqR%iY4uqKR=Bo^2)xTA2yh^*7ndN{){lna}n>%Oy=P34R-lYyC zAD%R}VOdzHa3!xfB93a6ovXHFa-HWLd%bfLxnCPf`-&jQ({a8;{@d;GcskdPbqjY* z`8|7sIQO?V zUfAIA@27d4c?LehDfiU!NDa#ZjbTBxoav_cnb*?vqX5AEO9|#OH*RmJ@`b*Qg4kYq zEIH;-RwNvGkV!|(`LvoJR!R5o`a+QtNlf_Y?Bkue#7x`q|9OZJu9Co zfEag!3;DUmXu}vcM>++6cSoBOm-mY`G8gQUN#ltZlxzvdP6mNR1#m$%eAZW!dAf4b zk_ByrdW9Qi|LkMuzj?67%oYEr_vTUGGg(a+v3$zP{U*MRfjmcs%v;S3ZRE@9X#x$jJTGaML>+|1);Q z;y%G2uin=N{oeLP1jr3XgH`2Rj4ZDZg=!R*XQArDbSxLCH&A?f3SVBY0Q2tPp+C*$ z23JR=J71S}zRqTeDG_xjbrSlR!u0*{O)R}QRjt#h4785Ys9@Zr*^8|_#Z8r;*6 zS?74hORi#|uVE*6dds#HP9ft~ZeVD+hy4cN-wX}9b-3~ZM+TPkLI z`$y`Gt4MwS!WVoAr~9&K`ShiZl9Yb+OP6woah-R0Is&L-;kJztH=0$$KaMPbm43c7 zBz`+K-cobKAKea9_t)#$ysMxopV_U(-gBxObzp%$Li7_pB+T<)k?WXn}m6%g|KCK|OfwjruE zzx%#?N&j?zVFx+C+dfC`iQaBzsFI-<0qttP`(e!Yh@dFA@LcraHuF-Aht_SZ!U_3F z*=a2lzF)0~NKjJ=r@b*|7H*ddpJ3`wzTlWIr|nQwi_rGT``+5h1PTMf>=`5+(nl@~ zeqKA)-W?FWzu}>M`)H)rgVay8wM$$7P%-UhFB;ASwqL`7al|yv>%NGK2bqd`SQslayl<-vB#W)Eg_prLz#a`>3Cp3pmCnB zV|_uOW5zJJLbxv4CzCqL3?Jmtph&+<-*Zxs$T_aG93Ogp&oAY%S~v1rn_rOp&k%MH zI8Q!-`qytT(1Dpo zN#k_qQWT5h2~JReqE2x~{OrvyOl%~IzqI)2_{QB8nyoijZvxcddaVR@g~=lZ*Ae# zr`G6iM}H)~{7FyJ=ioObjJ5F^QtXUR$tKy^xKr zynS;^!1Co}6hqJ90^2>HkD$5N#Ye@>Z8z+xgh+;KCnBwwiUkTMx!ea@y`26V^^wrm zeM0yB5m0{(t0^!++A1qF1VAvnqv+E&Iwy7kUeW{(8b z^-w=^j#q?uwR#!*?DeE^e_CF^(~j3SDJ;>^0A%dO+#zo^wyJW0V82 z@lpE+0pV5agPa)QOInWZrzopWYul>%h-P`bvp$vV0Hra7t^$Sbu%Ht&oJjdF;vQMG z70THThcVM|Gn_(46(D#^_L_^P2vQbvn)l*M^$qX6PFPGZKMdu0NQz{bAkaU6j4!zPOGR=-fcIpV4#z8$HL; zaE?W#;P#a^C?XWyYO>HK3tYNBFqiUmvt3nt{(O{g6R~(De%?L2YWF*5`@XSauCc-~}1U1;7|9r-Q1wIV9uU`*@w`Z>{%{btT~+|s`@s!1nIvWTNuACDvp4&M(Ly9Cf@ zUtp?-53Kr*Vv6|)X}j%t;kbPMQpY;=mGk+juu4|@`L%*J^Xd=22X3(H>$p<9s5(_g zH1qI7nuu$Uv6f}6+uB~_3;icWXElgL$doP|YWMQW2J%b6+*6*=vseFBfH!`fntd^v zRBMH`(}Jecvf1CVXI`#2ibnuhOfRx(bDwkY4dG#nth2u5LA{mV8cAw`)2Q{O^WzN% z{V84uU&n?eGcY-n&eIU*|I{}_=C8mm)nrext-9X>dKQ-EP@5X&`>m}74krcw___D_ zE#EsNBnLoq(6Gw%Ly=ksKjwiTX4)ab8MAweYtbq;jNCUsu`hk^bipwHR-KcB%PC3m zq<~kNIE4ZXnFSgn_mdJMUXlz&<0{eunHBh5yL0qtxU48+zAF%f=2Ku{8R+>XEojlI zk_#2FvzCTfc;&%d|32-4mcf&9F{QR9U$t-+6!TKKUex|zO2mG?_fYtQuIsr4+m|qX zBap|^ryWDN+hgdfHsm{gJon{_z6`Pbn?kR;A;`KLyYI5OMvwgM${gt9*79!WMNshR zv`$`Z{aZM`nUjh&6>VMTa87u#Epm(ZqMtkG3$9MtiOHP8#2)^YgQz9-7FgUk$`FEi z!oLEWJj$94A`>-V|J$?Sc>pQzF$HBaG_xXP+}n#b9Yb)`dZ@nXk1gtH?( zUev5COVFD$AK}nc@u{iN^mde-wYiV0L9)pElMDZ*=0H3W1x7DemyA(csRD*(aRf=8 zY&e-k`}P5u6O5k}CF8o&&g8{j&?aT@+$#SEXT(fE`3cmqGP}CQlEd^ks*m*sFdhq1 zl^{!w(YZXY&3_xC{LToz-;c!7RKpY7V9s<~Biwy9!O_e*1Rqsao8puvUr;3QVp=FE z*|PDlc?EdS*4q|Da)iF8OwpapYxUfOW>`Pe1yi~Ra#h@-^`I{L+5Oj_q72>0P%E(R}u*!*!u zo}^K?LKM?D_*RdI`M#bh5ni?QffG!la<-rD0q%58bkDbsAeRM|8BltthQQZG-Zi3a zP5vE{+*6t;bM(cfi(jFPp&Wbmh3LN|0Oz%;)(FwLTEFiyI5(rIGwjpxO%bs`>k$p1 zi~($i6~Ov5Nyj!G1H~lx*5p;_?dJWcg_v!vd*-6B$k`puh>nJ73 zsQ`H9eTrpu@&eYLNja(AduwTEA!bA~!r|x8+6z(j6GYU0T0qo(E+k`G4*ndMeL;Kv zl7T7PEQ8ILA_1qzxOWC^Av2toSAlgHNyqApTC2m$LXP@^qxRps;_Mj5mq7_w*E|@-rliW#v7F7@;l>-azc3lO|9LM8$l$^L7qLl}=_)h(5*=A+9`WvZ zCb3MT#uZ1sz!5q=U{-Ya?U#-WuWW;df>G0Uwe90JxYi2w96bF&z?7I*X_r*u_1iI z&Zz?Mk>@6cH-qkf#`lD%Rp^Ol1U5+OG6=qT!<^Wv8qUxVx!=VR_424u%krE5+FpzD znXCUMfp|7}Pc;4~v5?E{HwZm-_<*$J!QV&0g|MunCeFbSOXj^09cBo@nt5QYzu};3 z5nm4rep3!#yw>L480~eEU2{BS(tbmZCv_1$KE$zc2YA0-F|xq%vgtmTzUuN{{(DNk!1er7Lui{}YM{ zA_m;^ZOflmE7NTRid{1t2hT5^0#!xPqF{#wSg0cllAXqbvFn&?ux#KocESzixo?cA zocuM5r%EoqI_tMxvw*nJssmaDB##_Y9`>tTEubJtTmyoGd#v5Of@Nv6DB>keFJfN> z%?s2j9JwZJqA1^X)6{*JmshXT=}Sf;p`m~-3);xOPWrA(!FA2sbgO9J{_}@3o8lr$ z?jdOric|)}HM?#d4s^rNk;g)rHS*KXiIZS`V!K7RNC8wirOGXoz#K}21fS3eO+Q(L zkyR8wnfs9{+v>tUg@^OIGb?K{i~qM|NH6Mr-_ArNLgk#O=znI80*(R<@}fT()h<~) z{W0G?-`y6lY=>#_!s3sUloa=Te4Wj$E55oll83%64jpY+1&hxgp#yf~hA5|(K$?5~ z{tpLucu*xH;CL)`W-$qViXVNzWYGBZs;t#&Sl+#cI2T-^tU_kktEUbi%|zggpm{}- z<|j>i=OI~8L3b$zfR!%k0toe;Z(rBw=HjOYb@6z)l&y*RFpkOqxRx+wO!2c0US#;l z{=WgTR{7=72LLvO?*5z+jmNhh4$s|O*tv8Zvv_FQS$SKh`9R(6<-IL|{rtD8?Q0j} zsdx9@41hqcEOeZqn2o3Tz2r8|eTN}i&t&fKk`YZm2>-@6`6H+tbMq#>+w$`0a5VH@ zGiLYd$b|@qc_EzJiIQnI%h3jxez7v$(2FmMtE0=|{C5Zzmb~T5FZWHE3+4mpgV&)C zP$Z2_67r)^%Ob69_GbI))sfam@B5_43epYxKnGWm+kGB%J{H6bx`7rUHr%~*ZeUKb ze2q7pO7b>P$(FYbO!qyhAN_TarnuvE8Ts|Ab(ynm(L#| zcik)5Rtn*5+ZCg+i~nW4tn!_l7)_8J&0Eqa3hz2b%;eujvJZ;xjSaNGRuY3j04RX! za(7)`ikSTHi{X;G-E@@D-=EbX#l6WuyQcB7%(uUN6ccYkyGn!ce)(Cqn-~`kiC8=} zSb9ivx499~EgE~GATJpj4(!2|L-bMRJM2^4*0z?3=27!(1=;O&yZ>N^*EhF23yu=M z=<*mOQ86A2bS!B{ZQQ>E_?a3J2 z(hi+yt}*i}r!4ct!YB2LRBM&vnE`KKfd9d9apvwJwE6Sya_vcpN zr3mcDxo)kU2O<)RHP1cj3;}oSo_0*RLw?|dEfir9acmT*Kb{;oi@e`ANyzHh{bt8+w>`<4e*WE6o}ir2moJUKM(s0&D*UfoauQcG z4w;UmmHp5A+#N=>8wO3=d=-XUH6OXV9hr_!y@rU(Oyad2E-Q2O1@d{IEnZ9t6WJxlhs(J6Y$wa={uE%-^9j1jac)sa zJC!6yi2V4HF#vOs*YCVTdF@$p4?nILmgRRDra8y!wQS{er-qMlKTO}91M$I6c`LG6 z)^0k2YsIj-btB}1*SVdLJva|Tk|2obg%X3`f~0&#>=_=Pza_$x9*kS`&w}H|u+LyQ z)mO|NKO4Nqn~weXToEt)nTJy*D@PkkvtpDz4ze=e+~23=79ChKgm!2NYQxXeuV4K< zB{{sQ^5Z3oh)AaHp+6SWc2g6#hW*|h#k*j{G^X`d&^~Dxn!ZWSLQCfRWZZq@cpP8= z)tI58-=Z3fMKvD3cDRE#5-xMdW-mP`G`kityaw7OTHY$F`m*IJOj=RI8v(M2PGr5q zWgbp74~YdFtmEH11~v!?Ibvh%u}{(-cdLyC3-MOl&Ik6qWC_qppirTvy%Q!A1yWU? zZizUl>o}IJ+!c-GBaUw+F%ejRE^tGn?^Mrl7umE0ti&!& z^@U&VFVJ;)J>mFM)UN@GijRt8%}zomJ;Q48EwfqKn`d3%c|m*MuswI%O@g$XzLy?- zas1j)d-qqk0>S6hqojyPBT9-vxzWd&=kD2xE$3$AQh+>w|;7)QaIqC|B=wDh~>JvRya zy^xnI?~f4h*;#W8Nfei)Y;syGi%3M51uLu5YIElz)fiyW1;~pwsm=tq50Q&X(yiL@NJruIdG8E(LEy zX^rdRrTyM7Hg%&Vhy*J1q-OI4!NKxrqT)C=@XY}1DwgZ?qA2x+MchRj12;=c&WfY! zP=87xK10I{s_oFS=IHg~Jk(6KLR?l}aPz4CYm{HzYwa=hWDZct;O73h0s$DnRR_Xq zL-iE3WkqBecIQ&(OA@Lgvhj1#=+gt4R!|F(**T$<5Q*y|1dHoiv8M*K1|Lb7`{sDm z9DF?q5)%P1xC0#)O_eaDb6IkXG%iwSa#=+&RiPR2RbWY1s(3qHJ!hE2-$J zG`1S4#Q*XDeW0!|MABNYJ4&#T|OQWAIhW zY_#`zyw@?Pon3|*mGk$cnquWD6B0Umt7ZOtyZX?;2KLK4Ba>I#6rqt7NTywm!kMPH zIFWF<(C(hxua8~=9xwibHk%7?UPnrH7zDuT%B))sw7q_qc?CE-elUob^C@k&W_!gW z)T0(xAk=0=T)&9@L6dWd%1~{zSY5LY@hvRa>f+|%z47R7S!?B_@=rFAK&=PDoXxxN zXy7Usm;yt2%>$kW%(t&73n|?oqE)!}is4gbnMHiKEA5ie+nHGG72IZxT<^`cO8*#c&_?k!k~5#D8y+NJabMfs|sKs-2Z;DSaVB`VI&m%pk= z>7}Zmu-{YmP?)kPQ5kE@jZfxN;g^N=UwfA?^x2u%!+<^MX_AA~b@wgwPGNTZSRVLG zO@3=+vy3uSia>vv3Kx-2%Kr^WgIXF;qm>!m^Q#N_^!Hrs>D#-b#n@2Yvy`zM_Snh? zDRfk8B}if4AYhuS0IpFU{jC>m+gv9OV(WDBqW1k{HwsE&GG%|CHRcuQoE}vD?)djw z#i~SWh>r!jTFBkh*teZqzp0Dc9YKXsnrVytb{zp4px62)PnH~Ray=(XQ1e>8?-k_b zZrt=lkw0U91zC7XJF(tK`J}#JPMkTnxEttzCpA7v8Hfv<+Y|jMz)>GCFg<$lio{FB zoN8^Ch+YWM>+TE24@J#(xsf`8T&9??H!uut*A6W|xI5=E`=zrumcLo!rG@lOLBz0`*q&uUsRC1+y*0`(HvWH zf+)<`%r#WeMH=NdC{k$e%Rft(2}hbA_n|E`w^{UzwD)nX_mcVnaRK^ZhyE1>R>N5b z%a6<|m2-njFLj)jDWN{n`LOrQw!HRURDZku))6OeVpcwyz=>^PMVZ}xk3UK?nf_eB zNikr6J_?88l@@$X1#AFmQs5YI;sQUd&Wq0JMNY(K>tKEjl(J1R4xzt4nd!ARr{NWG zrKP4a(IbgaIuTMs3Qhc^2<-Nx*89{v@{7E1`v%7gOWOIL_gK^9=*U_olR4A3-p9+b ze{MKMqSYcZ>~^PpwT1Oj1jXN6IXpk8?-uH`^3K}j{r%7yy`ZZah7v-eDT~_tU(Yhc zIMX-Tu2Wu5jE0!CBd7IM_`%72pi@V4xoW^=G~J+_FekY#Z4_p9y})i*t?5PR zNW1I)8|vDEzo}4xtfELZkrlP-F>|vdHS!OOD=W!?EUafn=fI73Xm?Zm&o%3)MZei> z&}lt(f3q$jWNLo@_#2XQ5T?jUda~*SzO6mWb@Y9XF27YtoVZ+uH+0o7G)?L0%x}ncio_h zZ&!K{d)Bu?(41N7F?ttYUvrE66#qfvD8JM1lkH^NHIXwj(Pj=ZPg;YI3l%KlF;mKh zo6PG!KoI`-1Xwu(k}s!8cAdNI&)C8~xNq31tg>46d$}pCJhuBnU4WrUewl6UnEIn~ za0AhsafP41UD}s_y0NyWB)@gA{1vbKpZgTo4rG=YHVwf!J?CcmUvJ|m#xz0&3yWtRa_o8!RPljAV#!>S@_KUn=WQht*0N;;XyqUgU>K0 z=G=X~LC$F}5So;UbzmgW1ION6Hj#mwi_GGTk6Bu>zX`T;XfeL~OGzi$-E+)-hG5jg zkk*WYg}3Jd2H-e=B;zE?T_cQ}>sjbz_p~G?JP}+=6XP?oE|F~@&cDBb@>biDoEPl+ zqq4r(`KpJG#0B=c}R$n}HWpuQ@rsW1M(-rTC^XJJ>g1WS9 zOGAz~d%1PIWVxifUmm43goHzEBGlmrhWDYPg8h-1&xtc zg1!lhV^=PcAEt?DK8d`9u$<(AM?CB#2kVuDRB_$N0%Jr3q9$k~0edmgJQ!=WJ zfkr905&HAz9E%#?+f!hCF!-X6@zpDlOG3aRG7733I3zJc*hoa>S_ zUsQ*)CD>J~ip8v9Q^pDm4c>s81dO&-TLd4l5((nJ?XhASlm_laa0I<27SGH1*=X7J~d zE|Z|qz}FaaCg47ufPFYQ)usQmV{o*mKsfMZ#R=@86>bnJqqFtx1y!FEqR8)(X?!6^Zao&Y(w3qF9XA0G{Fv+2`=5<(f#GMaR_2f&pwE!PFdK#57N|=y! z``x|{Hdejl(+!3`@3Q=Jw7rbVA-6!-7fRw7@^dFIwQYuSM0Q1@??syC6r6L3Ojm9B zh2eSit?UCTq^nLmJJ7WFsls3d??}6*Y)_{S^A~NlWLq3 zQh4S?Lkt86ptC_XJ25G^tyj}djP4bsRvH9#ui}2gEqBf^{J^xkpUefJ-H+vNTeMvK z)7fn9*;^5KH(ID+v9A)p%V;?){TPo|zTryFxdozOwWwYRof5k}YbIOIO2O;B-3 zAgUY#rG4PeEpH0Uy@Mw$(bm2DiiXI82M)yKK{E{(B~cO$Rb_2XiKAN9ti^un(zvRS zd{#cA9Cnv@YtsN0c3N+nzJ-E@%kM_N58@@C`goYWpBPzTW7(8gKwlB*u_Q&{)=)z2 z_L{pDSZnLdr$VIZmG>v|MODg4zkiY>voFZw0ZBX{k6+^v4+9bIc`SEEba}gxQ5@vU zlh?^tKb0pzi1B!0s2OeVEN-XaWkb2Z1!~vQx#$A1+$ZSn)KB? z`hnUr+h(e-(sth{Uv76GCdQ$9L80cd?J6YgsgdKBfMD-~*C>a}IYY(+WhM;v*9T7M ziP2{vv2kQ4i|W(d6P|5%$+v$-5PtYr>dDjt$PPK7V*>-cB zo5PmXA6pJtY!z1gAP6uUg2vHf)kkC1_-ALR-#o0>-w->qErwI`&uoi~0c!)W$LiyXNhXAu z^DY4`K|AHxU;I$D%DkZm;_`FW3eUHNAG(P6&;Y$0`uL3y$6S7_eE+dWwfNvW<5|MoQffH%f*9iPaA>Fk|-rBDucJkJt85sy)gcZJ4G(I0Gi z^_XYWAaTNgOK%1&{R2HoMS%pRcLL36m2IWzoOdcmP;oWNEEi3%S1m^TI+724Na6l+ ziauo_HbhKkAm|s8uhPDgP`6sIX_5ill052LZ?w&;(MdfWx4Gr7U@+YLuamI{Y%>8A zCcM=w5hQjAdIJi78cc#LMV!u2`gO6Ew@i%T!JGM=OP!;bg#_IIqx2Vd7y);>UDPf7 z(rO&N*mQ&Qj!R(g;Wa~S1;Ws=@#mI62u>K=lRLUql3=-c6q!BaS^I)2arrg!N7bx~ zSP{#AEU4Znu~?i9Ub1jA8SMiEb-iTq-V``^B1FP0Mw;G2Dpy6CIDUReBu$$Ins1y6 zY2?`Xc)QW_67cgV7vM+$gTB*CtB+p=lrjCdfQ?hN{jM~=%J4+hr?h640>!?Wm3Ij* z2qG8RBHC3|Cml}9_NVh)<3dOtiE~CTV@zCGfE~JUgg%PUIA+**YAr!TzS~(xPvQQi zKx0q>-f1hxzA9Zw^JdFVfS0Lz`iiR&Q-4L_oOPbT>{0K!nkP{x<{=0cCcpZ`INWS> zk00=JWqW)$C$9b}grmY76IP7>FkxyOZP>DN$*Xq+yZvB|H;Lfg_K}+Jwkq_WXoPxD zB*r;s5>7EGx63~(2n0KN zUi+LPTe2iM>^C|{qB#=xLrrL{?fAbg@K*8eXlQ=|axbi%?|#GOa}xIVyN}s8(4!>Y zz{9)xcP5&8@`u01LDXDnwqXQ@p=9ta`+4}vWm5>$re!w@hw6&DQ2)ouZR4}XB&GQ+ zO6>lu-Q|Y=Vi(I@dLpiIwvSoIAA!W@X_(UjlY%Dt;g*H5*FaVx)3-&w7Zzmlpcny~ zqr*`7mYw6_WobHR15R$D)g|KF8oG-+qgoRt;&lGz`5#*X z*>dx1rxMQL*G@!G01I%BhD4I}x-llAR!<<}+A2cNTver`@gvwbGiOENh4qi)R9nI# zG-_Y>rIIfOJ>=ZjKVKigAns1L6_s11&uwHauC0rgh74T$oU4}t!qqJzD(ABi@oNXX zv<6u)AIcUu()mP$>&DO^Zwcb;Ba8ncpP0>B6^F~ur^-FP^6(;U-9fz^YE7I^HQhR7 z!a>``r$ZC#!(P*3wVxD%wO1(gBTi`dZYKksnZ|f1jJ8Xh`N1OusKm$W9odb>1&R4oW>u%YME8gh(1F~hJ_8**D)Z+DV0 zpDw@86ic~-lU1zxW!|!V1NR{ieYTepRGZNF!g{qeXQ?dBVVqja`yKte6t|ejX?QD$jdhFyVrT>KIE=Ri*n)6eZPNKxTW{H$ey{-SmTW9N->3uS)F40NYBmV4T| zW&T{ds}1eTW^wE#x4(fUWez0P|FT(ed~7|2AN*L{d>C<6MZD{DjFC zIR-WawwPJ53QPwrwHr(h?tnQUrSt(!*NCXr`UgWa=O%_n-9>t=NEOh9)QT?!GR1Jy zk66|3$C9kiQ_HO$8-2^wTrO!r*tD)?3rq3)Whm)55x3h-FyYC_0o(HW%iJ+nEPaJ% zf61-)Z)MMb1iJ>LF9SI&tmrm4O>8Yb3#Vs#Msv*TA&YL1Fog-QqkDe;`^E_uWS!4x zy}ibbxPMCI0AV$Ddv}dNlzLAiN9DrIteyo|Uk)vQXZ3U3--G z+aQ;i#U*b?0mK=={{t(>Kg=APXzDBaJ5+tWB4WBH_F5MxdYEg49x7J4hu1Lf+Iv@W zEws&SbAtg|S{q3Xx8#G$|3gVCd@U83=dU9~{&>f8&mM=yWM@~sqD~;Kl*e5I=X_0D z{xRYj*U*t$X^jsvz6*x7jq|p2PeOs3(;&bdc{MTJcvUF-oZHPxhVNg)8tT3VN5=;D z_cDlhxZMZ{*M3~w=7>En+*5fJgq&E9lY_KaMYk`-y=&9;S`+l@uS2zyl0f%7fLIT0 zpG7nSD>Be*C0#qm#=CQJ_cv;&uRSGD>Nbpk14us$8$N4Yzi27fOL8nZ*L@;+KX2TV zqQW`Chzqc*2l1+%JP}p12L(ODiAox2(hF_5;*AmJkH*_k2a^ZzBi~y&MS;Xxy>Up%AZY$U#VDJx9Kv~fryvDJ|EMSc9bRm z-Uxd+e9h0du$;GS!LShBtL1b;wtVo}s8Qq9U45fNCUIME=VPv%!l6avq=G>aNc?KQ z;?(ROoQ4+G!Ux~=R<&_A`(;cUvsNSF!hb6t?_cHu1i(aLjQS{(^F!7Rl>^l~m2YpH z#5u>>sKtp1O z!W2o>+421IkT$*Jc}3QkPdm-HI}N6|Wmj<>S^~&b&5M)kZm;MZ3>N$vmu;>HOSpwe0kJh#uol_}4z=cITFTFp>8dyjRXsi~#QFgVW<5eV=Hk z_li3roAbk?aD`FeOG^3lcQcSu%28H&5}3eC83!JYEOR{n<+FQiv|7at-c1h&u0PM} zy^rPw)P4WtS;lmFUiWd90+_*jpoLuVmyYHpL%-Ux&e$pf4+GX1ETabo_ip=B0#SY* znQUtl?7$1U1CH;v?S^JKI)EL7>@l2XO&LqZ#uj$%Ae8Fq(<6dT_bR)5$89EY@5~c{ zG#!81`lBia0XA@X$5+mDa3mA%{!{CJtjEH@@VI;eWqVC?hs|sG503aaIK71EG%wV(Zr`;7bP zqrMN?`<(wJEP(~7c&e&hYs|kqk#f78*#6>FlSG6|3-K<_9hp9J@MmP!KZg@Bg|BVk zF@X_#Z_RhU^-<1Ap*G@&qOsVMxy++a&0ngYd@W^i$vW~#t-T)5aE$N{ zQ{k1CZwXLq=vh%(G_LOig|WZa9!#cBBj76>r&8R|Wks$Dgl&qlk$ zAVZate^{Y)UC8p9GKKvtbOqx4^rHqGsLPUSNjB}x1H=5cX9whpse zl8ibWs=0tmpn(G`&OP}nHU`KNDjc`7kA;w%RWD5*b=Cn(>J44D`rDC>zFBQ;sX>Gd z-LJxD($2{U67SJMAfkKS!n1Os`?HbM+&8O>f)ODSes!(46LA!6{>w(TmDUuq3Vm}7 z1E2u*)BTD%|CvwA>@0BpuF??mS6x|kXU^oY}$7hteQq|jF zKEd(RhyiBpKudH1XI=?6Uj>G^>14U!E^g?4fEc^_KQWWiT~Q6k z!gD(N@%XpP=CpbmUb5bTm8oUo_`|8ljUp0y&njy`XDqR*IN5Xk=h!z=OJ=!Cts+su zy`gXXv2V6D3(x``L5*nkDCKv{|vo z3|LsU2aL#^c9~tnylNvruq_SnB2NNEHDocbNldESZ0p&pCWJqyP4}KvT`3t>$HeXjBQ&|!aeF`#*e4edhmFS}E%M}!5=U=QTk506 z@UTyOoPPR_CD9BnkBZ?UbGVY1*zi*GQc-2!*>j4wH)mmWp@%;UrR};I009pAE1V>b z=?ZH!^o}dSC4QIlah{|x7Y6oUhx!fUBR5~A94g~Vd{9}DYU9M$Qitqrlecblo9=vl zcbYP?`FP}t0)To6?s8heRm~s5g!;YpU)DM`_T(fazg=5QwQ3h~=P~ZZ3G%_Z68?6p zQQlk6uRL?s%aw`ZUI|cum!9?0+hv0T9{tQvCGFt3!8n2`E5c~<@P>Q3=b{j`F!1v^ zAdMa~R}J$S%1f~5UQxTEY~BD!yq=t^ICt|r6-isW)80FgvVk2+yHV9}NU2}zUAr8c zLao)_H;=MsVgozl0fo`eLZSj!uZ@JW{;Ty##5@mL{D!r*%JKsXjl{B8SmxOgonDvy zob7?w|Ke9c!|lw#5rAJca5VMJQtLT_Zsx=S9CGKOHh%N>Bjv zy?Q=@!4YOxMq?LH_xBA+9_QbkP8-9BH zk^R{M9Y1+A#c-9brG(xOdCC^ondZ$pZnE{uMA(JBIYb-`T;a|=fxi>5wKTBoxeT&D z-!|cQn8`fTn_s6(WCbMkL3i4(fV&AZPi zxjf3QznfJb)O=&(!XZ1Z)&5m50svmq^%qBsXnP18nfAbKU3y+vUgqKyC{*MX!{3?@ z=ClRZAgK-BFL~gaqWdSXqsrzRPiyQP9YGm{;?Et^2@-=lOl!x(&uP9n_xxAp@CQJ; zZ)0XrY^B#P5yOSmN9KsNz#LUqm5E$Ery=^@XeiW{we#~ z^qqCiy(EDf5Y?EwOtO4>oh2nvSrkvgV`v&Yq5yyBj@|CTk8cTnBZi+`3un;N2R-77 zfnG9Ipk+_w*AG^ErI=&Qce<;<<$;I12^uKJo?QmZP?X5OPm+TU^*eeXpDI(S?Brd> zFV0|Wfr?S=+&yt8Rqaa#gEZ>O`)HC`$P4M;2f6w67?NF?n=}(QK4(+Pc;WeX&e^<73_$ez$ z0jP?B!wt=TpD&0~;40X89<2Y0=WXHLoQn_-W^4`M+3deo072Z)ToV9a68%@@(h}`$ zn9`RWuW?U3DB7 z5~QE>u)7gyNjT@2*Hb238e_c)P_P_SgFDTydDwbSMC2e@Z;;k2EI1jPYfMAQ4JRP& z__r6CV?yXmW<9yqU2OgegC&Se^IB+g<+K4E!xoa2m&c9OEtohNxBIdKY^#`x+?+e1 ze+YiC;y;Z;RBGYuc%*;z-t&Fu>k3MT*WIg-#uKKjQC5!a_UE(EwP)dYYb?*M{j8S6 zi-_N2MnE@rkr8m=zg&QEU;cLPAlHMRGg2#*yOJ>J1G=0(4V`x*ok~oonxhSy=6zlYdB5y@~^v9$a9^@6<#Vx#OXg? zKUp;55+8UhZ!Y!E*xHjoaHX@=eP7Y7$j6G^iPt~-N`fv?hFwBAiVI+&F9G%@iW-?d zaPpgaU?L`QGbiORCFu8A?E5iYve%VqpNZI(2cPy}KP=daT|e*|97&9_{a=lfE=>|$ zl`K$s-phPqvzHm_7c_XK)HE@A#F{V}`BL@D?Y?c9A1$Yj<}ZRAj{iSjsA={9T}Ci@ z#}myi#%W?0*phSlXk==xmvRL(H~8HTadE;ko2{oMq6OmvqCzN)F<+hZpAkM11pqf$->1_4}o!TrJ$%KncFkTxw zvp+xe=p{ZY8vTR49C1gJjANS{BzH@ace*9W3^Da|cWNlN#hAvd`8{y$u;q~i9|M7R zvoqi9+GOKR&BJ8upkEG=h>Gp_UWSaQrIFIY4(ume5E=tO&IZ2^0W>%`Gj9~T!X zB?ZYj!N(yTsU*z}58RXrYCqM)uxn&?%gh7p*OKuq1t#%=${u}805}Fx7(Iexa0y$| zuD(oDpbJbzNnH{PE(J4`M@Kv#{vl|%8P4%*I|;I%dGE6K{>Djcy)rXD6{Q$2!XVBB z?zH1t;Vm|0Z0>fVi6 z_(RWAdw{J!^+GgpF(-8t-qm?q3(`u(dh$5od0J6A4f>h~Y|ss#Zya9V&X-UAnw%!e zHj8&5zZj<4=<*wupZJgAeJMd{KD^Cp8l*a(5xg?;_KC`vpZl7xI9ETZ^2+M+WCaQX z2T=*#dT@X^5TJS-&Pb5iZ>>n20*=uuPp4MMMNZ+`h5H23Q0a{w+KlrHArsr_mqa;I zK;Buq`V-u zl&~;f6k%h3@D%FjLkP6WX(Rc1d0btEY!mMIe!qLeg7mX79~Ez_Og~eB;Nk%bqSTLx z0UEpiDoDh{?Pu&>M`k#US*g}FLWz%u*{PPX;~`V8Fr=+a?!&jBY*`=m4NvfFe_J~_7g@qoU(wv~2_$nLN4GYEyXnjX=70X{wv2f^6nxJ%D-b}JD56!g zZpQ1+op)TmJj!Z!tLY6#<6U&nfgF|-Cf&N9uWHq}J2tBdD?x_q@D(8OG_mhnf?lt^S!up>>j9UF;L|0` zr&m;&3_=m)b#7cFO>sIbzuvn+HE;WsG9R^G*Z!CkBIBV{$>4o}4ZPSA6IB1FdtSIv z1)skxIB)o^&n`gf%2b}7DfF|H*r$@nn<&vgV5(l4^7n$*!`k|Z8F)Vu1-|F9y)@Fi zC?1;b><5^f0N)eUKjtPS@J{MawS!pA{k*kTQ{gRYPN(+cnpL|Xgym*W)6xQIotA;E z?Zr)*PKeWn^fNlc5CvoEA;)n_qx7F%V)$!azm~i3B!`vJDS|g2QmbX;EyqeHGEpqe z>ar2>hcI?G3%pWrqY6E8Kl|5?PpmrDU$PmHEt>a;eh4Iiy@q4dk7=*Qq_}a3f^C=Jc^}J zd{nR-#z@4w0{(+<}kHF5`^vb2UdQSM%_-5%_X_xTVuv1ZB6$G2( zw(i>8xfg0=(|eObm%0{NVygd(J@yFm-3On`Wmm(cgzbtW-rK^Esuv|(B8r0gcu?J9 zS=ow&4(s0S(wqZ3&FZWJgD$YD_P-+0diz_+t`c);(R_ZtAKnyMhAp=UT7f=*6Crk6 zUUWYiM-D{YtU#fA(LdhPnzISaBWp%RlrEYL091fvNxCq9=9I+smVgz$5q9MF z20h!Ut~wGJNT0Aak%7p#gte(&RN83x4sLyCQT-F77-B3cvZ(4vsuLVT0QdqAk7_H4 zBAexaP3MuOSgU~-ggS=@mWDyst3CIO+=Cv3|Q8q>I44FURf&6qR6 ze=?KLUcNMyspjms`BB{@?(~(pPDoS5M7a#eYULE2&R5Nt=e<2iM%Dfe6e9$e%~@v` zJ7fLMqt-e-l>z*Jg{gH~F3rozc*e-@XV;HUaC$ukD@T(cgkVw?mVspQr^&S97HO;h zIO6W@EtxfS!zkKQdzCpJ;22m9the09Z18|n6l`$QpC(B7vd$>hG@klrZY_-o^~P@- zSt?Zw&qq$f+ZGw@6x7`$5}_BX9(T1s0h`O9fkbqR3>_8I^3PPC#g2n+COSW~G3a|a zmWg26B{edOlzS-$)1iVlOrMHS<-@E%gAs#^;ko%B$gB zfj&q0eN*)Fp!t1lC{HFlaAi3a zcdfwXFmUfBcBh*NWfkgg?aHxr-Addp?V7(P2o7|U2MK=Q0&H&q=zUnJk91DF`zCL5 z?AK)UvQ_)Qpqz}UwQpLs(w-Dt6Kr(yxttB5sVxRlQ`Vmi?0~E!{V~D{YAa=pLG4N0 z()ecRG-w46aM)yW5az7;GB^C|-9h~Zp*(4f_2fj}1V3O)2UxC$b2r0+dX{s>8sxH6 zyxDXf+qF%V(Ie6)foK}}sj8~<2IV+}e8VRaUec(xSkXH;*9o>q5medt5hgb&qpl&x z&YaBp7d+Rl2Be**L;tXZ{5e-b!o7%SGhLe=Z!-?fczn_$i?at1 z+vUGH|59lzlsikQ)nT&HQbcs$htfyLtNj)Dd}II8t_;)XmxQls3^Aj0w71X_RW*=2 zy<$Na=u`{p=me8!5Q>&p*jHALEVG%L`YI)n%@Zq6*U%8;0`1HhJZ#T*L(FMA)A&<7 z^mjv74+Ka8%Lif>`Zs&{*wCp1fxEkHC4LL3oDl_4&crTghAYhoa+#O9FsQ>j@gDTv zQqcoC-Tqr`UYYe^iCcc2UFX5!bT+GX(chkU?G1VP+I*h>vtTTkKYvy8Kt%NL;q8wz zDuzr#m$Y|2;z^J+U2-}uADxC-Vp9;Jz9qWj>LKQ-mEbHtZhN*}hyD~NgW>{5w}nm8 z21U?7Lb$DYdwCeVp(kS^gOsgukgIZ745c}46P(nC z41K3*-h&ni&=+LW&!R?DK(8KzI$(!={|Md8lIC>hmnE|l`G+;AIs|U0VRHzC*eO>Z z@>-qE;fb|Z812b(}2E+nrpt!5KUM1uKKfR|fLEbZz2n)#Bb=}=72q#TxcnIztm01an_vWrUQhOi?( z_=A_T{<{eQ2CI{4rIJ$!^pm%xYMVt9Rcj*?E8Ni(AGi-;;}7brIVz6?2Yd2j)JmeF ze0)z(7e=i52p54nLf`;n6t6|ul+_&=E#)O^f7)Jt-iw|`$WBLMW)pmW*g}pgqN;L| za^Hr2mBQk6`}B%dQ;-?5A1DE6Q?FKcX!=G+!Zpr z>fw)y4b|$>a^{McjhQzO?G^^ZfeUn!dphj$wI_W##}{mdZe-TY-ugUfs}Y#KeA@q2 zijHH;l8D#7w^vZ*#p#M~YeVCPDwul#s|T3U=0R1%kLtjk{}eQxb6T`;X5F5ffj=j* z6|t{9%@PsasfWKK{k=YX8{a(tMY6f}1sOLt!7*mPPEY~vSka-=B7n86zg?5{y>8on>T1L2ii2_+C4XaM-?rY;+)in?e`oF0}vE+=)%lH8BosnK=9y z^z9_ptJExXN`i~altQTz3b$u?g8V#bEp>?7xy3nR&$sCXe0f5*%uk%vOvu2F4i`*P z|J0mM7mHVWH2Tw4`cE`Yv~4n+O{p{PL-Tg`-^abHtW@4GNXsz6>^QYc}}ue zebHmjMQh64Bt{(u;)lUaAu=PojiL*?%8*fiJQu9nd{55JumJV+luzXP(}&hUqK~9x zJW$8`I$j>Wo}6s8Zdp^p{c=aIRfAWRP#yYO9jV8sjN_r-d@5uK>Oi3)xRls$D@8$9 zXMd{)JTdzDMLQ2fl(gmbI`oN@oN-JhNONX=Hi5>pciQ4$BY|Q2q>s7)Fa0XpQDIk7 zi9@6G=Dw7jgXn?D<+#J zq&#?usrjm}6xp;~6wrG>-%H&NAOM%s|^osr~H%6R&e4Q}A2 zb+8Y|K2RCj?i~kkdHARTC+1QW zpPDnpy!#Jat7_-D|J|sM@l+C2icJtvc+`>VH2OP1XSqN=KP;m@qzbY#Uk{QKJ#ZsO zJFQ@ilWT{$E`l9kb*}6ga8rC`8z!DfNq;C`G<0BqX$10|MGM22$3QBS08ND|iH3d- zqB3K@#;a;ya{oGLFi|!`^i*g)2%pFx)?;35Erak?xtdx9uGAlJrKCNp3uA3$on9>6 zK9$p5@>(m(1)dPpSBv#+GL^q&MxpFj5;9^P9rk1@50>tMn!O&q`TelZ1_&r2X*<<&l9|gK(3-9 z0{Wpox^@>ImzZ0lHbMN|XxXwYi90>}iiHK$aZQ)kOp;t1lIl<<)0hqoceXR4{_FnH}Zh(#3;(S6)+19Y)PmfnZQF{Y9piBPd?)&1{x%wf$>2fweXOl*AZ@^ZPP~g1oZ~5m_l?`U`c^RV0K9w+#Jr_H@ZlR% z*XGoI&*PDnuXOC`=El9Uqa18n`{F?lcHxH8$~iVwvc7ZQ6zpjiIIKtXVd4Htc))92 zPiwsI-dI``3~HlrLsysonS&b^eRyKN3?f|qEFIeTXMHtS(65sDlrj%c1AbBhoKzUw z1H}WM1*83sLm-_4Yv-Dm&1VmJyuutD>%L={RQ(SPw@2!Jg&a+eg&gy}e`TYh^x#D! z6JR0$(x~y36*u+GX9o>|RR%K*m0<86NPG=qtT~UitfI>j&F^kNEbJT^cY9R|VXNDQ z4(WeEzBrSe6$t@`E`opmWol(1v{ZJY|K)Ko&;Z@@Uqg5m@`P1(wqll%ar6aMf^uUx zSvuJ=Evw~j{x14V^<&44+7%Vxzp~M){>|-g^>=))4?nZE;=|kn*#l{B)d#ICDr;$B z61lZl8qqII4SH0h;A&^C_DzH;03YQUX#-b_o}&kA3$}+ITN@8oS8%etS1ZRz`c9vZ zevH6nJE4mtydI^));u?15BCmD1y7edbU}g=b2xz^2Kq^TkiH#+WjL!~#w|*VLIq5l zAIzQsamD#Ca5ohV$(Qu~CI0e->XRanIGJs$Nc-4(_bMFznA3f6Z^3$^dt-M}WfwF= zx}yJkwNc88{QIL!Q1!;YhIH?jC*Qjq&W{rxJAI!@VQTr}}+!R|HSsuK`n0UBG z3W^nNa}?FU8Aur&p1AeN^!eNw0BEKk?yt#-B?n+OtyJ9%dgYcT{(@J~)yYU@2subZ z#G!pta7~KLUOMeyO!b%+{J(M*6SlpLeZ9o(y7(c8(28BAS2&?yYuyRV`6)#9IigcV z`d^t!ki7u_1NaX^BKZamf?br?`$~5{ZET;#{RIn|T$gC!4yyE+C>#Do}AwkLxyvJ)xWrA;tta6~Bj<;gT2Ff^Npod$cV->46eU?7_xH>d)7yr+?3WQpv!Z&J|&!2!t zWRD)16u!1=E0_|G4uRncB2nplv#u3)mxQ#_FI6r|?C+cUhZp19!FzJrU>^B1X)A;uXnxc!I+y^Rg_>zn9(t|WgL2@F zD%1VRsVz78iSiTnK31q|b?y&q#sOrD6yKk>B)d2M?Tdq>UZBfpyh`Rg>QzSfK5lng zb_DorM=in76Q2W!k^SYjH$5l05P2i5AeM9wqNGx@sy}tmNPquMjkGGbU_x$JU#Xqc zWJd(>`;cxcA56_-^87e~J7S>Wh-+tyc~8DhvGcR&3V@N|MiGk`A|j+X1cihCSu~Jt zdQ`9V^z_br$dMyq1DHlzh1ufoa8Na`cyaUFuk+JwVck=jVu~gvJ_Px!?h93db*F^R+L%F;f zMI#S0jluRHdvxEVo(p%tp+C0pMCuaca5pizu63pJU8;?tp;}L)X{T^9%cO*2mu>Kv zD344$W*A>na@>~cL>@z=gFX>BL4U>3MOq=&J+|ManYud|1&)GopAZ@fM$;eDx;NGr z4N(1{EGAFBctTd~*0_(d0c=p*ej{dai~FtM`X zfd~t%8LfX*oXJrzT?*?L?F6Oi2|MdV0&JIeGhHDLdG*hlexI6v+h}US1U*asVA=?X zQR~nN^SDuSx(cjSKg~?HfAJS;cz)ev-*LUPUPpcB*jBq z^E5EY1RSH64U?8uAM$e~oc+nlVko0_ORm9D(|13>xf6m(@}Y=}(s>Qt^{iOR|MK^T(v6JM==nxp>#m+hp2ab2(IqE?N9i=T2RXAy5n%+LfS;`v*J=%NbmvK3 z85-T~w+;!Q)oang?p zOF&N*w1oJg5i9?hSiXF`l5pFj{OqMCug$Kt5L*^HA^pENsCjGXwzeZC@lK>yJoG3T ztQI%aLo)&o>Hgj^DSv?1N9nDeE2_G;lEkN(sw3wDZ@{>}fX`!8di$8}IQL&ry2`TgsR1-+kLB-87Z#r5c6?Arw$3!iKhq$Y-XaPZx8hQyOv`~dWAPFIm+?R9i{c!(>U%u?~@UZjF z%+Ait&d$yxbsEd%Xy%s3wQ0D%%EYpkrc{P**RNeyWkrReq>sV>Gee-Wra15uneY4p ziEcI8d)f; zU)nkLx-cnn=uGP$Wbwqwe1OSAhuh8L=g#KBuVnkK{a-!Ey{rm6MmK_&hVdJ$Sv-Go z;r=nXJeaH3Wdti2@s<-FS*;9_B!30Gly)}YlK0;v3(%*CVU9c?EA#s4m&kYDp$xW_ z-xw7p*7SU4pI)`L*1lmagV!k_WY&NuLfP-Nx-&H?F|c^(I&c{R0v)D+a+<#c>zs+= zSxy%CzUCHZP1jjjN|$aRst&HP$}v@D&#p9A4kV85;;pJ$nT=(Ywt7BF65y}p1j8BJiNPKj*zTlbiIgu>`YMj%0oynuv-1bL>6(9PODX#Y=%X zgKxQmYV`fGdV{%FK6VoXT0h(n0|5rbP$o8=u3LgN&z=vzI?3_7=IF(AyR=QQ>(@h} z4{x%A&K@%G3on%%vtY$`qfMhTJ=|08t_s#y1H!Kc)%CGA;yOg~+rLOwT9@kkSUUPz z>vHY0y3AkvuBut9pPBA_qB$mVn0GrLc@GCJwgQ114sC53?RGivl4-NAADv}YZirR* zxY=%ObtiJ~Sj9m-Rsa-o6i{K(i6#=X7kNXsfpo5H-Iqhc!k6Mqhn&xo0*J@q#=BCT z;gz_zoCsX-)FWq%?ufNB5E%p3;HA?~>(Y@mviYl%B2#y&PK)<%Rpx+iQ%f$1bv40l>q7sZ3f&}z(MF$R(8q`dLAk~<2DnjnvCOhbsiLb@S zcF5p1uuV=Yf@ZH zG=z2bj@z>`OQil?$dT96ylZxyrh8J28{(01I)t<{XHw)JM!R>`@q#G-0?*s1*=oXD zZ*S8ROk^j<9k`5UW(z9EN{n2xVNxRxHsjSIN|lf2m>0#E?r|I~!Ts2vYeDy#4gyBm zn$&>haV<#D$uX~#R~nwp8H})g&8(2Eavom!gfw||4PGyD_R7BYGS8h#uI{1b_~BQD zi&n=&t{om*bZrVuWT;f?u&9{mcFB=9Gb{zlWmXDYnK7L3zlTBk3s-gNM@ImW`|tk? zEG1TA(@Yau0C0X^pPwqla?T@)fY;rt!)L>FtU@|a&Mp<~=tf&ApG|h}> zGjstoAx_?~4a$;jy zeGw2jKis-{DCQ#xXDw7A79Z4A2M+8nwK5XIU!OLWIJsywk-A)eSsIPE;OG5e@#v`W z*nSfOJPEIGcsR=OZ%XjX2ng}fXtr`S6ew4c z=uF0a*Ga7JVjZxXdv2BSx$NQu$8D3MQ8CFx zQj?N_imzdgiE)GVlKjzs)4E(eEFx?5+Xw>4vEZda1M3>$MXE2I{6Ie z+`#T5;NWCV_>Grce}4Zk{P^K~2shejEYTC+^lWW|M%*&jgXP4DqUuXo4$D(yTqYQ6 z%Ow^%2ww=%|61$nCEhkf!x|3tX&pHUe=$?pQ3uP=s(j_+`F)^ToOm1b{_t2fu^JVt zj;xu-8Q7G&lrk+OhPy<0Lyz!2C*6tJbmaSmoa><1Z@WR3?^Hg45Qz}MM(>tdcOmNT zJF4kF2W%OKxjMJ2%AcSBj_5$2Xoc$Rte z0!wxxDLgFD^;Mu6sNyha2YlZrWF&_850u!?fgz1N#_s2>wz98*E(R?^<~U&#_2qbt zl>0J~A8r7z6GSphN|Kx_R~MeG5vOb>aiH6WA#m4{cq1ci0a4!(_p$ofxoT9v2Gmrn ztzGgGP%h&k)!R#^msELizaz7UN9IIG@9^W29dcULv-R}=Q`QSAhXl-h6sT)%3snuK zjzg@s-v)h=KJV|=(B`}O;3*Hbgk6p6r#0R(t^~c@9P`2W5RVtYABsbb;ivlN&+_1@9=W5@(|)r*Z2~fFnN|!==uBR4;P^LIB;<8DWRmGq>oPyj+)gP%9+Z*; z8-NVFe^}m+yj{yXe-%hxkiPqNLsy90Jw}`d2_xcWuG=2+6*jry5QcSwFD!SXjg#W37 zUC{I?cyiVB=q;s^mQg(YCe;rpqS1F)e@DnmDB82&dtH_5_-kseSZ~mDR;&WuNWGZN zgtLD~;bhvUOk?xo1ta5}@PTKUf?RT-tA)dB@dO<-aZF5j|2rMxQ5k5}jp6tYT9KTZ zire^4kM=#+l#Sr#<$>zQej9?N31cUvboy1F@+)7oBS?vVH+KX=`Jo>?0q%bfHJZ6S*=r&w9k#SSU`?W5EwvEmG#u4JwyLAOiud|9#~ryKLFHRi?a>g%W8 zn*g0gI1#H9YYr~*A^XkrDmxgRO`A~yBSMJvk&`i|I#;SMKb^K``8TPjqz0~~=VMj2 z6{e*tR}JzG*ogMO8afvq$(V_Zuw7W>T9-S9nnvF{wcG7d-Fu*tl6X7h#E>(=99=p< zZS55TtJHf<3{i;nM?l_(ewL}_?x3l}T1Q9PFi|DA9myERVs<&==O_Gj5*@xb>^KDNc|y54=KPyhLF;(NF1dvof;>mSr^oZo+X zwL=FeAEAT0eVsAJrd;iI^Oi7=ddMLQU9)1Tf~1*sQ^vLzoG|_P-DTP0QBmhBiKMBM zksR+i*-4j<8ZV~aEor!l^1879&7nXL$SmjomTy{321lNN)KaQ2mI{pxI2#G#DuP8l z4*I3s{g93w*DH|||8eVPnw<)+a)>g_otJ;6$$%gRVQcT#4n7|XApT-=Y zuUAh#x5;te>{w6PxVX(=*j$5v39>n>TYXq!82&RG-&57ha5=NW8!n|?+n?=-Q|x+ zwML3ErfhE5-mLJ*A8kOHmhQ-&uW@NC%h1?(POE0%HT^Jf^*i^omzr+TUOz7=&#^mSVJgWV94MB}Y>(`rs5x^j8qO zV3{Yy04@2OGV+oErpmwwNThGCf<<=A<;t2HLn~z-*I=)e_~$Nu^nVz;dvm_l`>Fcb z$MOExBz`W>RdO*ezQR^*?0gF0;Z$!*co0z!ll7qCevdY;wm#w&wn37=t6&F2^y{(R z`D%@KPtf{=ET{*#_&uUuAPAf_@p0I7{tdV8cD)e_dwze39AVv%b$wC_S@{*WC-jxo ztvlrEb&rF_0yGpq^~yyNZo8~)hY$L0O4$;DKYTEGOiu@zn)^A%XlEtTO~AZ5ilg@4 zDd1-JUXMMUDs{ERK&k7zt;8W_1<}xqO;t(0Mn-a;8g*LARW2sjVz&jYvHa!b!iIwm zxohPFO)-x?b{n=)tHiix^%~ASq-Xa=CQd8BJgkW9O81kcLj{t0WHF`o6!0}o_6xS1 z30q=e@yinv3MCj=d#%w@wX-&U-eC(U*$;6b#z(CKfAjrzzIH*V;Ze1)3; z*!!DG%)qKTY?Doruig`pURe#Vrt@8Ud6F1|7xTxzzHgoM(^WwF_p*3F9Q+Ae}Kw#a*Ua+Z8=2TDvbr6(vFrRB*oiJ(~pb+YiA%?!lmTO7-@ryLF?3H4Qf@3#qYFD;^ z=Iiwosf<>sjC8{JoteFL!JeTl--Tk;a$^GFGCCW%%@H7&lO}AsX(_8816M0Os2Gwwjo`L81fg!d;1sk}8Y`p#e>SmN zmWH{lDt_T9E6+98PJ`0U99x^oS_krPTk`M0k%wRw&aY1rU%H&%#-r-!1`KkyqmALT)X@A( z%6Irr0j}H>yq%o`NyU3miTGjpjL*1|vSD>JSKx!x)(&9tzQDZa2PnNZbKq-fhjb+> zzfI)3Wi$3OkbEU(oDx5{DCqTU8iy41_)Q0=&E}FF!=O_rfza_NySKtaQl#0vnbdKJ zrPqYy{q4zKo2CjcwxbU*HT9zsSqf6jQap1mUZ#e2DiP<;*S4S%*(qA!()D5}UL zh>2BfpPh?D=117%xe4*@{J4cZU)h}fv9Z>pqb^CF5;-#e-I9A`TeH|j{2)ug!!E*B+0PG=J;HlDYT47Wd??JPN7-C~T+aUN+x`We|u(dQ{0ii`4 z_0M~M#@%Q&qa=*Ol#2Yahtny!IT39cGruK)Cz0IMomWWG<%kgeOCXk;C6VexYuM6n zs0yXby<;HOA+KE{w5CJFGc#RdE6=Moz@=~gXZ_2OwRh!7C*A42N!9qe`}-^o$sqRb ze9uBJnpB1b)eY}O=v0Oi4TOCbhn+@VnZ&>NF=%HOj*d+=&qNyZ=atvOg8Syf#U&uY zUpLNAVHORZNbkNI*)2cC;t+QT>A4E4T)0#qy_3dd;N}g+mFm>|gVU=AM0D!K$fp$| z2TLKnWt)R$Q)+6Q_lo}9UuDzkVRR>3Fz5EB1gcXG*u3ASNZYoZo9}AsWH!9qigJV# zikBv@C$z?hLyoS$>9gvNn(JN(^@1t3i!&ag+#nG4EX%#eMJG?d5Nn;1GG@%dQq%eG z4ayQt56=KyAO8uu;C&Y6KAI~uN_^@^(A5wAmE&ah-3aycxcvMiyRS8-{-~B9Ebs;T zUNt8D5UmI39m$AnhbslcOy8C%M7gr_IeQSPooAVyg>(6<=hdgG`dOng^esY+kjm;` zuSH>Ic=50c;B9cC3;)6ocFvPq9wLuTsr@~~O&q&jVU3iduT%GD3h)!~L7N@bH%gBN8$;expHZP~v-2f=Bw3tZV@MbrQi*s#72=baUUbbIsHFmUyuZ{CfXM_k!bZh1&v5~^BJm?U6&%wB-!?$#Jc)9|ND$?AB9BkHegi!UT~TPHe|?jg zM=)}5_OG>p+F+@J+psL*094SkNS+!gY|5}XY!f*m8%#?X-CtNO+-37Vrtn1FRNRqe zcwVOh@F{9N52oDf<|4V=+~p{dB4yO8wV&3gj0iQOnEH)|YxkGgG))!~bt=;x%;piL zb@R!dLlr!u(qVED!=!+h)fSt zn<_GGI|jQH6@LG+t{>**_(?}sgOmV4Ee9sZN(01Ns}m4wQWB47QLPSMOmc*5@8|WZ zG6-hn{ly|-Y0Y2xO9qLfF|%T;nRvE_8~V#-6u_~}U{zN~m@fkHY4LXUQEnE`Z9`mb6KJ+XlSg$LU)I)I3LXjc*F0w*8 zvQg*ubWRbpV@Kll`ta^GYg6|7R{=}k&0wiLlrWTM&@SoHs$Wtt6U)T&^Nq;(?`BWm zrl@3$ZHLq#1?D@>zyjB(x`%KKPiY2N-*ZCAJ1?gd!C!AA`IeZO_S*6yLfozZXi2)` z{doJ+iyzn9_LJS~7~M-fdx_|F=Cdz3mkcppcEK;B7lOl}Y5lr(AJh_;G|dY}R^4rA z*?HFJfDR!C251T}KDFkj&aHFYC3clBkNY9$CoSM)dyG<@v1()R*z?m@rd#eViSBN4 zs!`V}TUIK!?jm3Dj=zSzru;=%S8op6@AH3>?0*^S6ZjMa?&vwwM<#8B-i_f6*lCoj z#w-sEVpH(1)*)fp;#ZlgJG)=+y1`>6pQW{g_-@@cEEO^+jpExgd9u(G=wnOSo3*Nj z`)|(`Nz+*aKY-YG-iL+WwW;#hdV4E8J@EI+5zp56E#cYlRlu(x&(sEwFRr8#jMM|tUP@;2LRM;&>mYtiQ;>N zE`4Rh9h}RpLhW1sw4QVkw`4Pr?Wz;b=K~kstj&Okr5o#)m9%Ml)7pMl^BM*B67rA;=OU#W#!=bLis8~6DQ+bmj?<^Glw&)Z zYr`$8!yX#QluyvL`=Luv=MJAG6b~XqSOmnPDke?J7Z&T+l9#EKjtI}Q48YO+B3;1{ zGj91|%TeFx&Fz`Mtv-M{^@)QmNBsVh3_zFUeQK4&9JuctuzSShBI+9i|7HvD;muTS zm7dZbUI2eo;~XuTa+I)+?{mKwNZt(enN**+w?95P={H*b#WKj6AJM(f5|{|ExOl{$ zz1Cf6%xP)Le1rQ*RD5&eC-oB5piz?s-%NbS4r!K58hca4mIPC+Yxt`R%t`@fRgApp zY7XBb*rM*=44Z#tpmkyN%d;DUj-){HwIH9#ChS*$RxK?z*hbu1FGg$sFyDwpmDotI zP2t6xMa+B2s*>wz>)+IkklpNA%6bIVyO_z>JEUh6;A6)h3;&>kgO$c|e^&#olM*BiRGHDF=DuMO;lDd$oL@J+b^~djg983G z$=)ja>#X6mONsabr+@1L`Jq<4?SzP!`iLMNp=DE;&3&(D&}XMSA_eSM;d zZ4&d>`NizbFV^%T$sT}EU{RZaESY28)7l+AsoX}KW78&Z7FSwe}wp?8z|qp?o;L49T)(;{s6sCQAitZ-_@Io zT4s`+0}kbJB7j0ji53WI7CVtC$g%l(p#J3$hXZ!H1~DqMBu$I)GL;K`)YTaB8L+B- z+?L!`O0KD-wt1}o63xpX(5p!9hH-?XMw&3>rfqG>d<4h72IBb2rp9gFnLFo<68MZ_ zO^b$`B^6}$8)Dq8cTi=!cH?|J@&G;7N@wy07Jf`@ssb?v1+n(OY))!E#vM78FOg8zGV@ z1m67IRuxb>EUArR^bSK+sw1I<$~ys3pMx|&dm(^OM|p}G(vIN)#V>2QmsX8gi#1@h zDH|xxOR{Oh^pkURm|^KnYw8hG?tn|b*W!HGr^14 z#aER+y-sQVYz9?l@`Qvm8pD5?_TtBq@_zrOPE)2J8{^)qqv@OV=h!NnmCVX#XZxn^ zF0a({7#MtNXIJ@7GJ_Iq*vM!vc#RjiJv|6p&Gh#UcB5|FdOg78K_~Lf^pb`}VEz7#~bjcHz-EQW*=laFQS?8SYSNE-;C-SB3`>{0x{I(^1M&9Y}sn`ytnn%FQCRpie2D^@Mdq#~dMGX*uzmm5lEMdQUHWmlfX_ZJAbW-e{M z560b9lb^}xBv=y`xrcFmGf>ysZ2|tat2AQym>SRe&9KAfvN!$1FcE!>5E$b>+yU0t_olo;M)WQ}d1 z^|+WjJ=AGWAKF18g_k%j4&-)(c-V7F_#Ly5yA3pGO|yZ`3PcC}GJ`0SzxipJ@m7)+ zAN+eBiySOWDT%VmH;C#puv>2Dc&G!MfUR4dD%G4{4>QsfRGH~(vgWw4snoBtDFNl6 zmuEsi+=d&cpWM$NJ>NoBNj1=O8&Wt*gLg_9>mS-vDZN=rsYyr~O1JOU6ud!_^f0E+ z!1)lcIjtCZcrsgUGK@1Rbhq%@epE7DHkW5HaNnBWhIFLq06-cUAA^7P1dp_(t2AxS7~W z#?2UM;3pba2PCVfVJixh;t9b@$c_{Pw10_}r|I2;_N4bw3r;&*JrR7;0^yS_3QaXbbMkv7-@`e~oL~UQX*@1U0edXz!cZY8KyaC|6ytHwC(Z!s4O}CgX zgEz|&def=U<#7RyCRxC_QhDzwZ-};c0nWby6gONz%d{S3|4%l;zN0Hz>7YSu$PudK zyy5-@DzhAVFG4wO)Lr%tMo9MBRg{WA!Q)j8ZRvt(rJo172w!3h-oIrtH;oV~XTQId z;TRGs3Stqw7oapQL87>1h(u>Y8>pB@-`|Ux_~4#014Uw7d@lozJ}u=MB`=|EEL)wv zzT+?UThr5Ttvx9Z62t#Bfpih8d1|ThP4>qx#0l>M4*4l^I}+!CCB58dOC~vcZ>X}* zWB_ZRE#b&UAbi>M5JWj~=K(>JwJ2wSAYo?hJaRWW58^W*kX>;qCt!Lx86ZNXArE5n z6RzT5Ys?+|#|yo)S8l_ z7!zYmI}5{C9}nyJ{P>d`K+EV92`f@9zZJQ1(O;3H2~hm~j9N3)$kFVY)2@)Ix_zR2 zH)6cKN*e82@h5w4*Hd{2;wPj}{66X=?8CPkIFnZCk)D+Q}YIK!f396eVe?UZd5 z1gIjj*lJ8K>+2W5LSK!G!IMSju^bYP><84xOj@j0Xf&;LEn0cr&xX4B2>RH1*8Ds# zhD}!h?KkFFTL{!8Sys9BGi#o&Wm(*Bccdju)F$<}d&-6s06xaUvaqRN zxSMb1#FL>vUGe+F(A~fGDe%A^x`84cC9AgQ0Vx?;C5t=X2gLrWBl{Jjxq}+MPlEq_ zL}pupX{{z!Y7P;!Y(%t^k<*QTVGw-d!lLWS%etKeNJc_D%=ji8l$$ER9pW^Qe#+z? zht$c5;oy!{0oj8}R76SOgoon+makWtHU%!is+F9$qeh%V^39Rxjaq_+1#^vTgA2fBq2_1vr! z6D``49^nx6?rYPK(mh7v61UKH#}ZbVmWoe@&8=aTR$EPcjx>+sk_q#F0_@RkqQc`-eqk{A%@qC9whc9uu_W5`W0~S zn@(J1vBs!d8eT%q2t)E*SwAt<|Ut};m-fj0vIUdm@h-C&$ z7mH{&4BCOCGp+~po_o#NnbT9LkmM5tyggi37gvfeP~W+= z^c-tY+C>nH_n+35tobd%Gi^aFz|*_MeAV)H4PU-hwT3vzKNAC+FLP|z@LN<}JTOYv zGD4l!Q&Fr<7I|j})jbgiqMi-yRbu15gKm|S4ZkHzs(69|YbNTPJ!dx5ZQhdPUQfP~ zLxF%)%f^IKaioT(uvMMo!H6@pnAzm?&Px{7 zpQ@!wplu7&2lBwZLF+S3sR2f}aSSt5v79kuw{cRtTeUUTgAt-(sD{l;U4Nt}@y;Vv zgg{!*$+5Okso$~?7;KB9y40>WtfkN?rB3U*V(@6xBulL>VoD0+jouHbBX#b6*jA02 zLg@DVJKJ3!I&GinVGSjsF;+wLh=H;F{8;~v99J}=qthGmNsJXx~? zP&y^1o#pH+#T@i;vLj@*QLZqnyng>=b0jC+gpszWf^`8b-m)y584-F?;FR6_N|O2e zHUYC%@VPxlfki~azA0pqH0Un;DD83GLX8+^<@=lQJZP2t+N9P=p&&Q@w^_k1gNdYH zYCYxm`9E}q`uM*1_*wk=L8_hYX~6qGyUx*_81uD=I}s5RA84G5B(=Oxp_bH17`z(i z^OLGu4Wi4-T1}2A12C7nk(B2?GVb2eJ2QwE61s*Y!oWLyFBLAXR8N*T_ci z^(fN>Jlxwg-?JHzi(KP{4h{qVRZ|YhMD(^qxqiv8uPOlJT*0OzC#VLdWEEv(ZA+C zzBD_8U$31$d=bE|Au*~-CU`8YRBb!|4BXkp*AT5Jr8Z;Qn|;ewj(gwziie5b95UkW zFAYZ}W)n*%zbZ%L^@N+Ml8JBju#EgI1VpTVcmk-E6(nD)6eiJucemIj=s8_>U06AQ zV)koK#~dVtjpu*yW7z@DKo>y254e8gOjkR0t4IU|N>W!-miXSUlE==Sm`+5xMFE1a z@Fx3cxQe^Y3}Um@Yx-bwwdwM9{hw$Qcd93@Z;S7X#a^^n4u#T4{e9`S#wLsK-Aegu ziWFh13O0j;$}9^?3G)RnpVJlod`A1mFp{yCUb!vlA?T=SBIWY;<^C_5Jk|1we=J4_ zFpdl2z^)D7qbe7HP8tlMSwB;pU&!Vqe;#xXX5J5G)Z0p>$< z-;*^pDaGSrmOBI42AE5_DLrAKIyZYJ{aNA@76az31l-|mjVeOlIMwTZ_Rkjiq0H&F z&^%8($B{F2K4v3Fcy0qcczs4FGuS~xKCIAC&2n#kf>-+XCS9!&wvv^ELT`1hoWBgZ zDJg*X0tOub5k?RQ#F+|Y(1JibiY%PKaN#v84=||DuwMX%kS0I@1HU(b_X7M_#Xu~; zaCnsh4F8+>Kj!>Ta)6ESzuoaa)d7gX|J39EY{&m+A19d{f}#mnCCVAxp?GL(>fbB5 IYy0;90N$>F-~a#s literal 43623 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 FV1ff|pHKh* literal 31455 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 J;$uEZ{|{p$hu#1H literal 52528 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�`h$3cG73oe7wJI)}J{P@_ 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 delta 30 kcmZ>DouDH*ak>~Y1H)d1P~C~9LJUCQ>FVdQ&MBb@0CZjlssI20 diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 18d2d58..c79c58a 100644 --- a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,5 +1,9 @@ - - + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index 045ed695dc56c060ba12bf9084fd516b429fa09f..78efd6d3733f46629a33998cedb18200cff5f0e6 100644 GIT binary patch delta 2541 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 Dym9wz literal 4399 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 Eg4A)=>i_@% diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index dacd5fff2594e873d9330163dada0463abb1f1e0..a53613d720ee776a1b44ab271c0e0d3014873275 100644 GIT binary patch delta 2897 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 literal 6288 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

    |)+SI5C~TNhuYj&z)N7!fHb40#YG z4yh4kc*U3(8HS|>5QDiL-~rkW_1*D>Q_WB3W0%Ra$n3T)EN_IoSSmo8{JBN<3 z={b(zOwXH6S)C{=-keggs@(u2=Vy_;g_iOP%W&+#g+&ZHQ&=_&6@218vC5<}Ppf-q zX_{EoaDV~^ZS?BAwT!LhVDn1Q+}$*24H%62}Q4 zfnPcO5}TOz$Hu8;o5$_~;gHFx34|bH=(%((#QnVnI)$SKFz{$;qF+PEdA7cXETE;z zK!v@I$Qe21xUe`VLdeXer9H;n9meGApfm+UYNK@ZBC&{vNKOQ9uzr^Cuh>uhFX4MR zW0G58vyEQDNatT1?Og%~Jf4lWrUUp0V?te2#>jjB5m<8cH6k&sVB>20m1oC~|2#6e z{7hjDkXW|Ac+tCfs`}ug=()Xoa71F4yz?i5)uqaPkD%M>Q`jkcC!a;PpoQ! z>QMKiS(aiHP6?WIa``N|^^M?l#BEKBh$&x`is3sl_gz>IE(#kaAw(1w3xoD8Y)~ri zEap=5D@+VAQcD!}ftuWJ6KBFY{PT3;pqS_^swJ(hm--mFfMpkqz!IrxF!v&QEk&E;@IUnDn0U7t532w}m|uY017M zJFnM(5B{6r?2paOE6Oal5fJT)LgjC{QLEgaVoiQWNdG}0x!8$m;o1^hy)4>M%;XG_ z2_IyWL%U$m!qFi3feOp~qlas4^VlQ8ZmHZ7UNeWM|59+VA|s-Mhzp?*xO8GSf9G$o zNW^he8_h-t3rdo_t*B&XC*hy~TfR@vy-ZBmuS3vJ=pfq=h3PPb#XR^jJM)sc?|w2q zBWCETBt`}~@Yo;0XWj`<{kfQdQJ#z@4fhcQ6FN2PzF%ZgA$>F$f>m-BUfq4iyUM@(pP;qUWu*|P1^@X2aOpV_!f4k7_wRTQ zy#1fS!TZ2(h}ofn$9Zf+4t6=Xe4_my-ZyR+lo?@YY7k}&QU%o~Sphd@Be;_4%ZQZ-mQ zZ@ljQqF_LLTe(hxGbZ+*|0=xt7(DRH@W`*i%=8 zX_Y5ucn?@FrV>%`T;6ON`)>#4_IJEO(^Z)g%=^yg;P1WyZ~hf{=pRFMMhIURgj9($ zsEpISw|EzyEtkhf68RYVr|qwik-~5q7BuU4?gZa|R4nrm1brC>V##=cDrE>*UAZHt zFgF6ZstEGlg%QDa*wqDOgig+<)2RDWs1K@h(B6cvej0xCMfkZ-z`Y+5Le{BwJWKf|p8L-?RkKWoG=Ptfys~yLtM-|B=jEkh-;zhc= z0G3UuT)DN6<7^O-I3&W?7VdUWm1r1*QV}UO3@+U50!7COF)VCuhD(rb`E4TO7rS)1 zMZ^mD(N8)41XW@$OF!66MEsBi>yxu9le;Jj=;d>zR%7*t-|BNc3gBmVns&2(^@;yW z!UZ0C6*in^&Gb4;kN=H8iA9AuqV1F~o*553bep6+Awa>PLMBqrBHq|phXZ%RBfnOl z5~cgz+z2Z+k}f}*A>h5jYK)RI{;g94aQ=khCm#xb*Rk1J3a|-`Y22t zf=*2w$@Rvl0bV0mIB5vm7X2XZHV!1+9+E4Y%b_gt_gy&)JelI^#RDXS2firmxqU`` zBXUXwK4dKL+;~JnK}oj_W0Qd3pLoK$?}NSCAWV$4H^j0$4}BQk`s=X!Ht?F#*B1HD zBFC9uKFO|}wC!>fTOFZF9F^lW2t|u<8zxy>Ce1n*PG#@tR9YGAdvm>fFJb|in5N?s z-gAFb`QX1QT&#r{A`m5V%I}7^|0W!MKv=KVsyN;welOyXW$*cK^7@)NJ72J7**1WV zQ8QAG^gs?QFzk}Cxn^HD;otjK(vj)i{40<+C6sv5bV^dK_D*q?xM}gQF!ml#fG|66 z9=_Fk;c5THzqRgvf2`bea_eyTL1C_LdwZl33F)6|5&E$1e*b?^%eIQvnEOfEu&ss0 z!qHN#%GlMZ-4+Whud{J=iU0U4_kNzq&mxXSO&kOe4`Y3oUVZa6)2rCu_vax=(^`(CYrsc?C zk1!${Iy7~SBqJ%b)Nbx;)WC8!$EN0%F5t~~`gb2EZJDs>Cgj8^`4><5o!0d0ACOu9 zo&%vuW$(mu!ScfFT@PTp*$eoIJBuL#?Xd~MSn~^?Hjf=Q4!&Ltr-}05l0+tj4$LhX zl5i$l|K$_Gr4#1(j3bxO0ocJ3CasuZiGW8Cpk2Zf&+yytFAioSut1FQ9GeVUjXug# zCWQr@sf>xjVn|(&D27;qgmdm3e>;2b$>7vW_0N5D;!|H1p~X%;m9xR)AkiS4x%QWS zxAV9EjZ~*Al*?aG8d)O~{3kQ;8j_GV0-oo6;G^uO*F&-Ngc)iaEJHC|VE}MS?9y5H zfBq?MESX2{8UNIm>CAp;u1Pr?SU5NnQdpX9f9dyHU-%uUPL(KBtt|(=3TlfVe@z({ zLcYM`dk%@kP(qvEiAF6f993ul`G)2jJ?OkKK>`>t#1K$Lj+dDbcfzD9j9H~ zR`V}@tMiS|L3zR=FkvgJw~vSjeq5}y@LE#zV>W)y64^h|Y9QE7hm#g3*l7~WDgVMJ zOYizMGPVnXrpPQuBkz+1?n0kXjED^roBj*mZhzrl`zL+~W0MwvaVuS4afC$hSEk^4 zAT=%WS`imP7%#x4Sh1Tyt6?0vt@O5!*!TZEy89-ew)`o;XqJ)0AYw@>Y^($)f6#gC z^ZwIc6-m4DxI=i=N>=O~F%kUO^}(>~hX^o^tpLSH1h)mAPp4+hqxYFNKWOZ`g--7y zc1eV_Hdoln1$O4g{;NM^mrn|C zVxclBrpAV|iBQawkZ|2HiCw0II0Q=u-7d*`t^BYZLMVQu&LB1=0Y^{#X2k@TzkMN* zryP~;*rXh)MQ&5D#N5Pi@Zt8(3I2(uS9TboXc?2rAETA&GqK5$x1lpa<$7=FWao6S z-ZnPrYvefZAj0Uc_!Z8aB{xSmbYd{Np%Ql(-H@;yMmKEO4x<|?afi_jmAJ#`hDzLF zbVDWXFuI`By5M#4bZW}=!Qz%VRSj>4VAdV=!S&tFuK989Y!})BJ3>s|DHR+cnz!7*8l(j07*qoM6N<$f+RqCg8%>k diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index ad7574a9aeda24b0a9771b75e8007013001f7140..1faba1a000603c625e8b132d9f81bc94de363f91 100644 GIT binary patch literal 6088 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 X`EJWN;`HAOFo?9cf>@o1QPBSY -