From e609e3ad8d6b39311f975c17241aff30ddb3011d Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Sat, 17 Oct 2026 16:11:01 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(flutter):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E9=A1=B5=E4=B8=8E=E5=BA=95=E9=83=A8=E5=AF=BC?= =?UTF-8?q?=E8=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 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 | 372 ++++++++++++++++++----------- lib/pages/home/home_view.dart | 86 ++++--- test/auth/auth_page_test.dart | 244 +++++++++++++++++-- test/config/l10n_id_test.dart | 9 + test/home/home_nav_ui_test.dart | 126 ++++++++++ 12 files changed, 702 insertions(+), 189 deletions(-) create mode 100644 test/home/home_nav_ui_test.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index de22c26..6cd002e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -253,6 +253,7 @@ "authSignInTitle": "Welcome back", "authSignUpTitle": "Create your account", "authSubtitle": "Sign in with your email and password.", + "authBrandTagline": "Short dramas made for Indonesia", "authEmailLabel": "Email", "authPasswordLabel": "Password", "authPasswordHint": "At least 8 characters, letters and numbers", @@ -267,6 +268,8 @@ "authGateTitle": "Sign in to continue", "authGateMessage": "Watch history, favorites, comments and downloads need an account.", "authGateAction": "Sign In", + "showPassword": "Show password", + "hidePassword": "Hide password", "cancel": "Not now", "noEpisodesYet": "No playable episodes yet.", "networkError": "Network is unreachable. Please check your connection and retry.", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 7d02388..973c95c 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -253,6 +253,7 @@ "authSignInTitle": "Selamat datang kembali", "authSignUpTitle": "Buat akun kamu", "authSubtitle": "Masuk dengan email dan kata sandi.", + "authBrandTagline": "Drama pendek untuk Indonesia", "authEmailLabel": "Email", "authPasswordLabel": "Kata sandi", "authPasswordHint": "Minimal 8 karakter, berisi huruf dan angka", @@ -267,6 +268,8 @@ "authGateTitle": "Masuk untuk melanjutkan", "authGateMessage": "Riwayat tontonan, favorit, komentar, dan unduhan perlu akun.", "authGateAction": "Masuk", + "showPassword": "Tampilkan kata sandi", + "hidePassword": "Sembunyikan kata sandi", "cancel": "Nanti saja", "noEpisodesYet": "Belum ada episode yang bisa diputar.", "networkError": "Jaringan tidak tersedia. Periksa koneksi lalu coba lagi.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 1427bb2..1eeb935 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1234,6 +1234,12 @@ abstract class AppLocalizations { /// **'Sign in with your email and password.'** String get authSubtitle; + /// No description provided for @authBrandTagline. + /// + /// In en, this message translates to: + /// **'Short dramas made for Indonesia'** + String get authBrandTagline; + /// No description provided for @authEmailLabel. /// /// In en, this message translates to: @@ -1318,6 +1324,18 @@ abstract class AppLocalizations { /// **'Sign In'** String get authGateAction; + /// No description provided for @showPassword. + /// + /// In en, this message translates to: + /// **'Show password'** + String get showPassword; + + /// No description provided for @hidePassword. + /// + /// In en, this message translates to: + /// **'Hide password'** + String get hidePassword; + /// No description provided for @cancel. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 9243973..7e7d988 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -602,6 +602,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get authSubtitle => 'Sign in with your email and password.'; + @override + String get authBrandTagline => 'Short dramas made for Indonesia'; + @override String get authEmailLabel => 'Email'; @@ -645,6 +648,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get authGateAction => 'Sign In'; + @override + String get showPassword => 'Show password'; + + @override + String get hidePassword => 'Hide password'; + @override String get cancel => 'Not now'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 7d6b7e2..821b0ba 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -604,6 +604,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get authSubtitle => 'Masuk dengan email dan kata sandi.'; + @override + String get authBrandTagline => 'Drama pendek untuk Indonesia'; + @override String get authEmailLabel => 'Email'; @@ -647,6 +650,12 @@ class AppLocalizationsId extends AppLocalizations { @override String get authGateAction => 'Masuk'; + @override + String get showPassword => 'Tampilkan kata sandi'; + + @override + String get hidePassword => 'Sembunyikan kata sandi'; + @override String get cancel => 'Nanti saja'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 0ddd0ad..034f260 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -593,6 +593,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get authSubtitle => '用邮箱和密码登录。'; + @override + String get authBrandTagline => '为印尼市场打造的短剧体验'; + @override String get authEmailLabel => '邮箱'; @@ -635,6 +638,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get authGateAction => '去登录'; + @override + String get showPassword => '显示密码'; + + @override + String get hidePassword => '隐藏密码'; + @override String get cancel => '暂不'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index d664220..a2b0cba 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -215,6 +215,7 @@ "authSignInTitle": "欢迎回来", "authSignUpTitle": "创建账号", "authSubtitle": "用邮箱和密码登录。", + "authBrandTagline": "为印尼市场打造的短剧体验", "authEmailLabel": "邮箱", "authPasswordLabel": "密码", "authPasswordHint": "至少 8 位,需包含字母和数字", @@ -229,6 +230,8 @@ "authGateTitle": "登录后可继续", "authGateMessage": "观看记录、收藏、评论和下载需要登录。", "authGateAction": "去登录", + "showPassword": "显示密码", + "hidePassword": "隐藏密码", "cancel": "暂不", "noEpisodesYet": "还没有可播放的剧集。", "networkError": "网络异常,请检查网络后重试。", diff --git a/lib/pages/auth/auth_view.dart b/lib/pages/auth/auth_view.dart index 0b03e76..7e9c07f 100644 --- a/lib/pages/auth/auth_view.dart +++ b/lib/pages/auth/auth_view.dart @@ -1,3 +1,5 @@ +// Author: fruit.com.cn + import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -8,9 +10,9 @@ import '../../core/theme/app_theme.dart'; import '../../l10n/app_localizations.dart'; import 'auth_logic.dart'; +const double _minTapTarget = 44; + /// 登录 / 注册页:邮箱 + 密码。 -/// -/// 风格沿用 FruitShorts 现有页面(深色卡片 + 圆角 + 主色蓝),不另起一套视觉。 class AuthPage extends StatefulWidget { const AuthPage({super.key, this.signUpMode = false}); @@ -22,11 +24,29 @@ class AuthPage extends StatefulWidget { class _AuthPageState extends State { late AuthLogic _logic; + late final TextEditingController _emailController; + late final TextEditingController _passwordController; + late final FocusNode _emailFocus; + late final FocusNode _passwordFocus; + bool _obscurePassword = true; @override void initState() { super.initState(); _logic = AuthLogic(signUpMode: widget.signUpMode); + _emailController = TextEditingController(); + _passwordController = TextEditingController(); + _emailFocus = FocusNode(debugLabel: 'auth-email-focus'); + _passwordFocus = FocusNode(debugLabel: 'auth-password-focus'); + } + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + _emailFocus.dispose(); + _passwordFocus.dispose(); + super.dispose(); } String _errorText(AppLocalizations l10n, AuthFieldError error) { @@ -51,6 +71,7 @@ class _AuthPageState extends State { setState(() => _logic.errorMessage = _errorText(l10n, invalid)); return; } + FocusScope.of(context).unfocus(); setState(() { _logic.busy = true; _logic.errorMessage = null; @@ -71,7 +92,6 @@ class _AuthPageState extends State { if (!mounted) return; Navigator.of(context).maybePop(); } on ApiException catch (error) { - // 后端文案已经是可直接展示的中文/印尼语提示,错误码不外露 setState(() => _logic.errorMessage = error.message); } catch (_) { setState(() => _logic.errorMessage = l10n.authNetworkError); @@ -86,158 +106,244 @@ class _AuthPageState extends State { return Theme( data: AppTheme.dark(), child: Scaffold( + resizeToAvoidBottomInset: true, backgroundColor: AppColors.ink, appBar: AppBar( backgroundColor: AppColors.ink, elevation: 0, - foregroundColor: Colors.white, + foregroundColor: AppColors.onPrimary, title: Text(_logic.signUpMode ? l10n.signUp : l10n.signIn), ), body: SafeArea( - child: ListView( - padding: const EdgeInsets.fromLTRB(20, 12, 20, 28), - children: [ - Text( - _logic.signUpMode ? l10n.authSignUpTitle : l10n.authSignInTitle, - style: const TextStyle( - color: Colors.white, - fontSize: 22, - fontWeight: FontWeight.w800, - ), + child: AutofillGroup( + child: ListView( + key: const Key('auth-scroll'), + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + padding: EdgeInsets.fromLTRB( + 20, + 12, + 20, + 28 + MediaQuery.viewInsetsOf(context).bottom, ), - const SizedBox(height: 6), - Text( - l10n.authSubtitle, - style: const TextStyle(color: AppColors.muted, fontSize: 13), - ), - const SizedBox(height: 22), - _field( - label: l10n.authEmailLabel, - hint: 'name@example.com', - keyboardType: TextInputType.emailAddress, - onChanged: (value) => _logic.email = value, - autofocus: true, - ), - const SizedBox(height: 14), - _field( - label: l10n.authPasswordLabel, - hint: l10n.authPasswordHint, - obscure: true, - onChanged: (value) => _logic.password = value, - ), - if (_logic.errorMessage != null) ...[ - const SizedBox(height: 12), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Icon( - Icons.error_outline, - color: Color(0xFFFF6B6B), - size: 16, - ), - const SizedBox(width: 6), - Expanded( - child: Text( - _logic.errorMessage!, - style: const TextStyle( - color: Color(0xFFFF6B6B), - fontSize: 13, - ), - ), - ), - ], - ), - ], - const SizedBox(height: 22), - SizedBox( - height: 48, - child: FilledButton( - style: FilledButton.styleFrom( - backgroundColor: AppColors.blue, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), - ), - ), - onPressed: _logic.busy ? null : _submit, - child: _logic.busy - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : Text( - _logic.signUpMode ? l10n.signUp : l10n.signIn, - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w700, - ), - ), - ), - ), - const SizedBox(height: 14), - TextButton( - onPressed: _logic.busy - ? null - : () => setState(() => _logic.toggleMode()), - child: Text( + children: [ + _AuthBrand(tagline: l10n.authBrandTagline), + const SizedBox(height: 26), + Text( _logic.signUpMode - ? l10n.authSwitchToSignIn - : l10n.authSwitchToSignUp, + ? l10n.authSignUpTitle + : l10n.authSignInTitle, style: const TextStyle( - color: AppColors.blueSoft, - fontSize: 13, + color: AppColors.onPrimary, + fontSize: 24, + fontWeight: FontWeight.w800, ), ), - ), - ], + const SizedBox(height: 6), + Text( + l10n.authSubtitle, + style: const TextStyle(color: AppColors.muted, fontSize: 13), + ), + const SizedBox(height: 22), + _emailField(l10n), + const SizedBox(height: 14), + _passwordField(l10n), + if (_logic.errorMessage != null) ...[ + const SizedBox(height: 12), + _AuthError(message: _logic.errorMessage!), + ], + const SizedBox(height: 22), + _submitButton(l10n), + const SizedBox(height: 10), + _switchModeButton(l10n), + ], + ), ), ), ), ); } - Widget _field({ - required String label, - required String hint, - required ValueChanged onChanged, - bool obscure = false, - bool autofocus = false, - TextInputType? keyboardType, - }) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: const TextStyle( + Widget _emailField(AppLocalizations l10n) => _field( + child: TextField( + key: const Key('auth-email'), + controller: _emailController, + focusNode: _emailFocus, + enabled: !_logic.busy, + autofocus: true, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + autofillHints: const [AutofillHints.email], + onChanged: (value) => _logic.email = value, + onSubmitted: (_) => _passwordFocus.requestFocus(), + style: const TextStyle(color: AppColors.onPrimary, fontSize: 15), + decoration: _decoration(l10n.authEmailLabel, 'name@example.com'), + ), + ); + + Widget _passwordField(AppLocalizations l10n) => _field( + child: TextField( + key: const Key('auth-password'), + controller: _passwordController, + focusNode: _passwordFocus, + enabled: !_logic.busy, + obscureText: _obscurePassword, + keyboardType: TextInputType.visiblePassword, + textInputAction: TextInputAction.done, + autofillHints: const [AutofillHints.password], + onChanged: (value) => _logic.password = value, + onSubmitted: (_) => _submit(), + style: const TextStyle(color: AppColors.onPrimary, fontSize: 15), + decoration: _decoration( + l10n.authPasswordLabel, + l10n.authPasswordHint, + suffix: IconButton( + key: const Key('auth-password-visibility'), + constraints: const BoxConstraints.tightFor( + width: _minTapTarget, + height: _minTapTarget, + ), + tooltip: _obscurePassword ? l10n.showPassword : l10n.hidePassword, + onPressed: _logic.busy + ? null + : () => setState(() => _obscurePassword = !_obscurePassword), + icon: Icon( + _obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, color: AppColors.muted, - fontSize: 12, - fontWeight: FontWeight.w700, ), ), - const SizedBox(height: 6), - TextField( - autofocus: autofocus, - obscureText: obscure, - keyboardType: keyboardType, - onChanged: onChanged, - style: const TextStyle(color: Colors.white, fontSize: 15), - decoration: InputDecoration( - hintText: hint, - hintStyle: const TextStyle(color: Color(0xFF6C7280), fontSize: 14), - filled: true, - fillColor: AppColors.card, - contentPadding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 14, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, + ), + ), + ); + + Widget _field({required Widget child}) => + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [child]); + + InputDecoration _decoration(String label, String hint, {Widget? suffix}) { + final scheme = Theme.of(context).colorScheme; + return InputDecoration( + labelText: label, + hintText: hint, + hintStyle: TextStyle(color: scheme.onSurfaceVariant, fontSize: 14), + suffixIcon: suffix, + filled: true, + fillColor: AppColors.card, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 17), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: scheme.primary, width: 1.5), + ), + ); + } + + Widget _submitButton(AppLocalizations l10n) => SizedBox( + height: 50, + child: FilledButton( + key: const Key('auth-submit'), + onPressed: _logic.busy ? null : _submit, + style: FilledButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(25)), + ), + child: _logic.busy + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.onPrimary, + ), + ) + : Text( + _logic.signUpMode ? l10n.signUp : l10n.signIn, + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700), ), + ), + ); + + Widget _switchModeButton(AppLocalizations l10n) => TextButton( + key: const Key('auth-switch-mode'), + style: TextButton.styleFrom( + minimumSize: const Size(0, _minTapTarget), + foregroundColor: AppColors.blueSoft, + ), + onPressed: _logic.busy ? null : () => setState(() => _logic.toggleMode()), + child: Text( + _logic.signUpMode ? l10n.authSwitchToSignIn : l10n.authSwitchToSignUp, + style: const TextStyle(fontSize: 13), + ), + ); +} + +class _AuthBrand extends StatelessWidget { + const _AuthBrand({required this.tagline}); + + final String tagline; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Container( + width: 48, + height: 48, + decoration: const BoxDecoration( + color: AppColors.blue, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.play_arrow_rounded, + color: AppColors.onPrimary, + size: 30, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(context)!.appName, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 2), + Text( + tagline, + style: const TextStyle(color: AppColors.blueSoft, fontSize: 12), + ), + ], + ), + ), + ], + ); + } +} + +class _AuthError extends StatelessWidget { + const _AuthError({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + final color = Theme.of(context).colorScheme.error; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.error_outline, color: color, size: 18), + const SizedBox(width: 6), + Expanded( + child: Text( + message, + key: const Key('auth-error'), + style: TextStyle(color: color, fontSize: 13), ), ), ], diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 71e1b57..53d6147 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -118,7 +118,7 @@ class _HomeTabsState extends State<_HomeTabs> { }, child: Scaffold( key: logic.scaffoldKey, - backgroundColor: const Color(0xFF0B0C10), + backgroundColor: AppColors.ink, drawer: HomeQuickDrawer( drama: logic.currentDrama, onOpenSeries: () => logic.openCurrentSeries(context), @@ -183,12 +183,17 @@ class _HomeTabsState extends State<_HomeTabs> { ? null : SafeArea( top: false, + minimum: const EdgeInsets.only(bottom: 2), child: Container( key: const Key('home-tabs-bar'), - height: 63, - decoration: const BoxDecoration( - color: Color(0xFF0D0D11), - border: Border(top: BorderSide(color: Color(0xFF242329))), + height: 64, + decoration: BoxDecoration( + color: AppColors.bar, + border: Border( + top: BorderSide( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), ), child: Row( children: [ @@ -250,37 +255,48 @@ class _TabButton extends StatelessWidget { @override Widget build(BuildContext context) => Expanded( - child: InkWell( - onTap: onTap, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 3), - decoration: selected - ? BoxDecoration( - color: AppColors.bluePressed, - borderRadius: BorderRadius.circular(14), - ) - : null, - child: Icon( - selected ? selectedIcon : icon, - size: 20, - color: selected ? Colors.white : AppColors.muted, - ), + child: Semantics( + button: true, + selected: selected, + label: label, + child: InkWell( + onTap: onTap, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 48), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 160), + curve: Curves.easeOut, + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 3, + ), + decoration: BoxDecoration( + color: selected ? AppColors.bluePressed : null, + borderRadius: BorderRadius.circular(14), + ), + child: Icon( + selected ? selectedIcon : icon, + size: 22, + color: selected ? AppColors.onPrimary : AppColors.muted, + ), + ), + const SizedBox(height: 2), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 10, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + color: selected ? AppColors.blueSoft : AppColors.muted, + ), + ), + ], ), - const SizedBox(height: 2), - Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 10, - fontWeight: selected ? FontWeight.w700 : FontWeight.w500, - color: selected ? AppColors.blueSoft : AppColors.muted, - ), - ), - ], + ), ), ), ); diff --git a/test/auth/auth_page_test.dart b/test/auth/auth_page_test.dart index 61f3aa9..dc5fd08 100644 --- a/test/auth/auth_page_test.dart +++ b/test/auth/auth_page_test.dart @@ -1,9 +1,12 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.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 'package:fruitshorts/core/data/models/auth_session.dart'; import 'package:fruitshorts/core/data/remote/auth_api.dart'; import 'package:fruitshorts/core/data/remote/api_client.dart'; import 'package:fruitshorts/core/data/services/auth_controller.dart'; @@ -20,15 +23,22 @@ void main() { InMemorySharedPreferencesAsync.empty(); }); - Future pumpAuthPage(WidgetTester tester, {bool signUp = false}) async { + Future pumpAuthPage( + WidgetTester tester, { + bool signUp = false, + AuthApi? api, + }) async { final auth = AuthController( - AuthApi( - // 这个测试不联网:请求一律 500,正好用于验证"本地校验没过就不发请求" - ApiClient( - baseUrl: 'http://127.0.0.1:1', - adapter: FakeHttpAdapter((_) async => FakeHttpResponse('{}', 500)), - ), - ), + api ?? + AuthApi( + // 这个测试不联网:请求一律 500,正好用于验证"本地校验没过就不发请求" + ApiClient( + baseUrl: 'http://127.0.0.1:1', + adapter: FakeHttpAdapter( + (_) async => FakeHttpResponse('{}', 500), + ), + ), + ), AuthSessionStore(), ); await tester.pumpWidget( @@ -45,7 +55,9 @@ void main() { await tester.pumpAndSettle(); } - testWidgets('should_show_email_error_and_stay_on_page_when_form_is_empty', (tester) async { + testWidgets('should_show_email_error_and_stay_on_page_when_form_is_empty', ( + tester, + ) async { await pumpAuthPage(tester); expect(find.text('Welcome back'), findsOneWidget); @@ -57,25 +69,215 @@ void main() { expect(find.text('Welcome back'), findsOneWidget); }); - testWidgets('should_reject_weak_password_locally_when_only_letters_are_typed', (tester) async { + testWidgets( + 'should_reject_weak_password_locally_when_only_letters_are_typed', + (tester) async { + await pumpAuthPage(tester); + + await tester.enterText(find.byType(TextField).first, 'a@b.com'); + await tester.enterText(find.byType(TextField).last, 'abcdefgh'); + await tester.tap(find.byType(FilledButton)); + await tester.pumpAndSettle(); + + expect( + find.text('Password needs both letters and numbers'), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'should_flip_between_sign_in_and_sign_up_when_switch_link_is_tapped', + (tester) async { + await pumpAuthPage(tester); + expect(find.text('Welcome back'), findsOneWidget); + + await tester.tap(find.text('New here? Create an account')); + await tester.pumpAndSettle(); + + expect(find.text('Create your account'), findsOneWidget); + expect(find.text('Already have an account? Sign in'), findsOneWidget); + }, + ); + + testWidgets('should_show_brand_and_form_accessibility_when_page_opens', ( + tester, + ) async { await pumpAuthPage(tester); - await tester.enterText(find.byType(TextField).first, 'a@b.com'); - await tester.enterText(find.byType(TextField).last, 'abcdefgh'); - await tester.tap(find.byType(FilledButton)); - await tester.pumpAndSettle(); - - expect(find.text('Password needs both letters and numbers'), findsOneWidget); + expect(find.text('FruitShorts'), findsOneWidget); + expect(find.text('Short dramas made for Indonesia'), findsOneWidget); + final email = tester.widget(find.byKey(const Key('auth-email'))); + final password = tester.widget( + find.byKey(const Key('auth-password')), + ); + expect(email.autofillHints, contains(AutofillHints.email)); + expect(password.autofillHints, contains(AutofillHints.password)); + expect( + tester.getSize(find.byKey(const Key('auth-submit'))).height, + greaterThanOrEqualTo(44), + ); + expect( + tester.getSize(find.byKey(const Key('auth-password-visibility'))).height, + greaterThanOrEqualTo(44), + ); + expect( + tester.getSize(find.byKey(const Key('auth-switch-mode'))).height, + greaterThanOrEqualTo(44), + ); }); - testWidgets('should_flip_between_sign_in_and_sign_up_when_switch_link_is_tapped', (tester) async { + testWidgets('should_toggle_password_visibility_when_eye_button_is_tapped', ( + tester, + ) async { await pumpAuthPage(tester); - expect(find.text('Welcome back'), findsOneWidget); + expect( + tester + .widget(find.byKey(const Key('auth-password'))) + .obscureText, + isTrue, + ); - await tester.tap(find.text('New here? Create an account')); + await tester.tap(find.byKey(const Key('auth-password-visibility'))); + await tester.pump(); + + expect( + tester + .widget(find.byKey(const Key('auth-password'))) + .obscureText, + isFalse, + ); + expect(find.byTooltip('Hide password'), findsOneWidget); + }); + + testWidgets('should_move_focus_from_email_to_password_when_next_is_pressed', ( + tester, + ) async { + await pumpAuthPage(tester); + await tester.tap(find.byKey(const Key('auth-email'))); + await tester.testTextInput.receiveAction(TextInputAction.next); + await tester.pump(); + + expect( + FocusManager.instance.primaryFocus?.debugLabel, + 'auth-password-focus', + ); + }); + + testWidgets('should_disable_form_and_submit_while_done_action_is_pending', ( + tester, + ) async { + final api = _PendingAuthApi(); + await pumpAuthPage(tester, api: api); + await tester.enterText(find.byKey(const Key('auth-email')), 'a@b.com'); + await tester.enterText(find.byKey(const Key('auth-password')), 'abcd1234'); + await tester.tap(find.byKey(const Key('auth-password'))); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + + expect(api.loginCalls, 1); + expect( + tester.widget(find.byKey(const Key('auth-email'))).enabled, + isFalse, + ); + expect( + tester.widget(find.byKey(const Key('auth-password'))).enabled, + isFalse, + ); + expect( + tester + .widget(find.byKey(const Key('auth-password-visibility'))) + .onPressed, + isNull, + ); + expect( + tester + .widget(find.byKey(const Key('auth-submit'))) + .onPressed, + isNull, + ); + expect( + tester + .widget(find.byKey(const Key('auth-switch-mode'))) + .onPressed, + isNull, + ); + + api.release(); + await tester.pumpAndSettle(); + }); + + testWidgets('should_render_validation_error_with_theme_semantic_color', ( + tester, + ) async { + await pumpAuthPage(tester); + await tester.enterText(find.byKey(const Key('auth-email')), 'a@b.com'); + await tester.enterText(find.byKey(const Key('auth-password')), 'abcdefgh'); + await tester.tap(find.byKey(const Key('auth-submit'))); await tester.pumpAndSettle(); - expect(find.text('Create your account'), findsOneWidget); - expect(find.text('Already have an account? Sign in'), findsOneWidget); + final errorFinder = find.byKey(const Key('auth-error')); + final error = tester.widget(errorFinder); + final expected = Theme.of(tester.element(errorFinder)).colorScheme.error; + expect(error.style?.color, expected); }); + + testWidgets( + 'should_keep_submit_reachable_when_keyboard_opens_on_small_screen', + (tester) async { + tester.view.physicalSize = const Size(320, 568); + tester.view.devicePixelRatio = 1; + tester.view.viewInsets = const FakeViewPadding(bottom: 260); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetViewInsets); + + await pumpAuthPage(tester); + await tester.scrollUntilVisible( + find.byKey(const Key('auth-submit')), + 120, + scrollable: find + .descendant( + of: find.byKey(const Key('auth-scroll')), + matching: find.byType(Scrollable), + ) + .first, + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const Key('auth-submit')).hitTestable(), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }, + ); +} + +class _PendingAuthApi extends AuthApi { + _PendingAuthApi() + : super( + ApiClient( + baseUrl: 'http://127.0.0.1:1', + adapter: FakeHttpAdapter((_) async => FakeHttpResponse('{}', 500)), + ), + ); + + final _gate = Completer(); + int loginCalls = 0; + + void release() => _gate.complete(); + + @override + Future login({ + required String email, + required String password, + required String deviceId, + String platform = '', + String appVersion = '', + }) async { + loginCalls++; + await _gate.future; + throw const ApiException(500, 'pending failure'); + } } diff --git a/test/config/l10n_id_test.dart b/test/config/l10n_id_test.dart index c04c29c..4e09c28 100644 --- a/test/config/l10n_id_test.dart +++ b/test/config/l10n_id_test.dart @@ -45,4 +45,13 @@ void main() { expect(l10n.episodeProgress(2, 10), contains('2')); expect(l10n.episodeProgress(2, 10), contains('10')); }); + + test('should_keep_auth_ui_copy_in_all_supported_locales', () async { + for (final locale in const [Locale('en'), Locale('zh'), Locale('id')]) { + final l10n = await AppLocalizations.delegate.load(locale); + expect(l10n.authBrandTagline, isNotEmpty); + expect(l10n.showPassword, isNotEmpty); + expect(l10n.hidePassword, isNotEmpty); + } + }); } diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart new file mode 100644 index 0000000..6a9b9f7 --- /dev/null +++ b/test/home/home_nav_ui_test.dart @@ -0,0 +1,126 @@ +// 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/theme/app_colors.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'; + +const _keys = [ + Key('home-tabs-bar'), + Key('tab-discover'), + Key('tab-home'), + Key('tab-my-list'), + Key('tab-my'), +]; + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.withData({ + 'auth.accessToken': 'test-access', + 'auth.refreshToken': 'test-refresh', + 'auth.user': '{"uid":"9","nickname":"Viewer","avatarUrl":""}', + }); + }); + + testWidgets('should_theme_four_tabs_and_switch_selected_state_when_tapped', ( + tester, + ) async { + await _pumpApp(tester); + + for (final key in _keys) { + expect(find.byKey(key), findsOneWidget); + } + for (final key in _keys.skip(1)) { + expect(tester.getSize(find.byKey(key)).height, greaterThanOrEqualTo(48)); + } + + 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; + final context = tester.element(barFinder); + expect(decoration.color, AppColors.bar); + expect(border.top.color, Theme.of(context).colorScheme.outlineVariant); + expect( + tester + .widget( + find.descendant( + of: find.byKey(const Key('tab-home')), + matching: find.byType(AnimatedContainer), + ), + ) + .duration, + lessThanOrEqualTo(const Duration(milliseconds: 200)), + ); + expect( + find.descendant( + of: find.byKey(const Key('tab-home')), + matching: find.byIcon(Icons.movie_rounded), + ), + findsOneWidget, + ); + + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + expect( + find.descendant( + of: find.byKey(const Key('tab-discover')), + matching: find.byIcon(Icons.home_rounded), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byKey(const Key('tab-home')), + matching: find.byIcon(Icons.movie_outlined), + ), + findsOneWidget, + ); + }); + + testWidgets('should_hide_themed_navigation_when_immersive_mode_is_enabled', ( + tester, + ) async { + await _pumpApp(tester); + await tester.tap(find.byTooltip('Clear screen')); + await tester.pumpAndSettle(); + + for (final key in _keys) { + expect(find.byKey(key), findsNothing); + } + }); +} + +Future _pumpApp(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(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-home'))); + await tester.pumpAndSettle(); +} From 8a481d5c6278f32d94cc76a089941fba281c5fd9 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Sat, 17 Oct 2026 16:15:46 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(flutter):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=85=B3=E9=94=AE=E6=93=8D=E4=BD=9C=E9=9C=87=E5=8A=A8=E5=8F=8D?= =?UTF-8?q?=E9=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/app.dart | 4 + lib/core/platform/haptics.dart | 39 ++++++++++ lib/core/widgets/download_action.dart | 4 + lib/pages/auth/auth_view.dart | 4 + lib/pages/downloads/downloads_page.dart | 5 ++ lib/pages/drama_detail/drama_detail_view.dart | 4 + lib/pages/favorites/favorites_page.dart | 2 + lib/pages/home/home_view.dart | 25 ++++++- lib/pages/player/player_view.dart | 5 ++ .../player/widgets/drama_comments_sheet.dart | 7 +- .../watch_records/watch_records_page.dart | 5 +- test/auth/auth_page_test.dart | 14 +++- test/comment/drama_comments_sheet_test.dart | 75 ++++++++++--------- test/core/haptics_scan_test.dart | 23 ++++++ test/core/haptics_test.dart | 24 ++++++ test/download/downloads_page_test.dart | 62 ++++++++------- test/home/home_nav_ui_test.dart | 27 ++++++- test/library/favorites_page_test.dart | 8 +- test/support/fake_haptics.dart | 36 +++++++++ test/watch/watch_records_page_test.dart | 7 ++ 20 files changed, 308 insertions(+), 72 deletions(-) create mode 100644 lib/core/platform/haptics.dart create mode 100644 test/core/haptics_scan_test.dart create mode 100644 test/core/haptics_test.dart create mode 100644 test/support/fake_haptics.dart diff --git a/lib/app.dart b/lib/app.dart index f0d10d8..5c292c7 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -33,6 +33,7 @@ import 'core/data/services/favorite_controller.dart'; import 'core/data/services/interaction_controller.dart'; import 'core/data/services/playback_session.dart'; import 'core/data/services/video_player_port.dart'; +import 'core/platform/haptics.dart'; import 'core/theme/app_theme.dart'; import 'l10n/app_localizations.dart'; import 'pages/home/home_view.dart'; @@ -49,6 +50,7 @@ class LumaDramaApp extends StatefulWidget { this.playbackRepository, this.commentRepository, this.interactionRepository, + this.haptics = const FlutterHaptics(), }); final PlayerFactory playerFactory; @@ -62,6 +64,7 @@ class LumaDramaApp extends StatefulWidget { final PlaybackRepository? playbackRepository; final CommentRepository? commentRepository; final InteractionRepository? interactionRepository; + final Haptics haptics; @override State createState() => _LumaDramaAppState(); @@ -176,6 +179,7 @@ class _LumaDramaAppState extends State { Provider.value(value: _favorites), Provider.value(value: _commentRepository), Provider.value(value: _interactionRepository), + Provider.value(value: widget.haptics), ChangeNotifierProvider( create: (_) => CatalogController(_catalogRepository)..load(), ), diff --git a/lib/core/platform/haptics.dart b/lib/core/platform/haptics.dart new file mode 100644 index 0000000..cbdbd17 --- /dev/null +++ b/lib/core/platform/haptics.dart @@ -0,0 +1,39 @@ +// Author: fruit.com.cn + +import 'package:flutter/services.dart'; + +/// User-action haptic feedback boundary. +abstract interface class Haptics { + /// Feedback for a selection change. + Future selectionClick(); + + /// Feedback for a primary action. + Future lightImpact(); + + /// Feedback for a destructive action. + Future mediumImpact(); +} + +/// Default implementation backed by Flutter's platform haptics. +class FlutterHaptics implements Haptics { + const FlutterHaptics(); + + @override + Future selectionClick() => + _ignoreUnavailable(HapticFeedback.selectionClick); + + @override + Future lightImpact() => _ignoreUnavailable(HapticFeedback.lightImpact); + + @override + Future mediumImpact() => + _ignoreUnavailable(HapticFeedback.mediumImpact); + + Future _ignoreUnavailable(Future Function() feedback) async { + try { + await feedback(); + } catch (_) { + // Haptics are optional and may be unavailable on the target device. + } + } +} diff --git a/lib/core/widgets/download_action.dart b/lib/core/widgets/download_action.dart index 6c3a356..6da727b 100644 --- a/lib/core/widgets/download_action.dart +++ b/lib/core/widgets/download_action.dart @@ -1,10 +1,13 @@ // Author: fruit.com.cn +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../l10n/app_localizations.dart'; import '../data/services/download_controller.dart'; +import '../platform/haptics.dart'; import 'auth_gate.dart'; /// 详情页与播放页共用的下载授权入口。 @@ -16,6 +19,7 @@ Future requestEpisodeDownload( ) async { if (!await ensureSignedIn(context)) return; if (!context.mounted) return; + unawaited(context.read().lightImpact()); final result = await context.read().grant(episodeId); if (!context.mounted) return; final l10n = AppLocalizations.of(context)!; diff --git a/lib/pages/auth/auth_view.dart b/lib/pages/auth/auth_view.dart index 7e9c07f..af6bdc2 100644 --- a/lib/pages/auth/auth_view.dart +++ b/lib/pages/auth/auth_view.dart @@ -1,10 +1,13 @@ // Author: fruit.com.cn +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../core/data/remote/api_client.dart'; import '../../core/data/services/auth_controller.dart'; +import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; import '../../l10n/app_localizations.dart'; @@ -71,6 +74,7 @@ class _AuthPageState extends State { setState(() => _logic.errorMessage = _errorText(l10n, invalid)); return; } + unawaited(context.read().lightImpact()); FocusScope.of(context).unfocus(); setState(() { _logic.busy = true; diff --git a/lib/pages/downloads/downloads_page.dart b/lib/pages/downloads/downloads_page.dart index f1228d9..4247658 100644 --- a/lib/pages/downloads/downloads_page.dart +++ b/lib/pages/downloads/downloads_page.dart @@ -11,6 +11,7 @@ import '../../core/data/services/auth_controller.dart'; import '../../core/data/services/download_controller.dart'; import '../../core/formatting/error_text.dart'; import '../../core/formatting/localized_date_time.dart'; +import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; @@ -166,6 +167,7 @@ class _DownloadsBody extends StatelessWidget { } Future _remove(BuildContext context, String episodeId) async { + unawaited(context.read().mediumImpact()); final ok = await downloads.remove(episodeId); if (ok || !context.mounted) return; _showMessage(context, AppLocalizations.of(context)!.downloadDeleteFailed); @@ -191,6 +193,9 @@ class _DownloadsBody extends StatelessWidget { ), ); if (confirmed != true) return; + if (context.mounted) { + unawaited(context.read().mediumImpact()); + } final ok = await downloads.clear(); if (!ok && context.mounted) { _showMessage(context, l10n.downloadClearFailed); diff --git a/lib/pages/drama_detail/drama_detail_view.dart b/lib/pages/drama_detail/drama_detail_view.dart index 62d0ed8..f52b8de 100644 --- a/lib/pages/drama_detail/drama_detail_view.dart +++ b/lib/pages/drama_detail/drama_detail_view.dart @@ -11,6 +11,7 @@ import '../../core/data/services/favorite_controller.dart'; import '../../core/data/services/interaction_controller.dart'; import '../../core/formatting/error_text.dart'; import '../../core/formatting/localized_content.dart'; +import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/remote_image.dart'; @@ -315,6 +316,7 @@ class _DramaDetailPageState extends State { if (!await ensureSignedIn(context) || !mounted) return; final draft = await showReportDialog(context, title: l10n.reportDrama); if (draft == null || !mounted) return; + unawaited(context.read().lightImpact()); final result = await context.read().reportDrama( logic.drama.id, reason: draft.reason, @@ -341,6 +343,8 @@ class _DramaDetailPageState extends State { icon: Icon(saved ? Icons.bookmark : Icons.bookmark_border), onPressed: () async { if (!await ensureSignedIn(context)) return; + if (!context.mounted) return; + unawaited(context.read().lightImpact()); final ok = await logic.toggleFavorite(); if (ok || !context.mounted) return; // 成功靠图标变化反馈;失败必须说清楚(乐观更新已回滚) diff --git a/lib/pages/favorites/favorites_page.dart b/lib/pages/favorites/favorites_page.dart index 62b7c54..7033488 100644 --- a/lib/pages/favorites/favorites_page.dart +++ b/lib/pages/favorites/favorites_page.dart @@ -9,6 +9,7 @@ import '../../core/data/models/drama.dart'; import '../../core/data/services/auth_controller.dart'; import '../../core/data/services/favorite_controller.dart'; import '../../core/formatting/error_text.dart'; +import '../../core/platform/haptics.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/library_drama_tile.dart'; @@ -142,6 +143,7 @@ class _FavoritesBody extends StatelessWidget { } Future _remove(BuildContext context, String dramaId) async { + unawaited(context.read().lightImpact()); final ok = await favorites.remove(dramaId); if (ok || !context.mounted) return; final l10n = AppLocalizations.of(context)!; diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 53d6147..e19b838 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -1,5 +1,7 @@ // Author: fruit.com.cn +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -8,6 +10,7 @@ import '../../core/config/app_config.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/services/auth_controller.dart'; import '../../core/data/services/catalog_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'; @@ -203,7 +206,7 @@ class _HomeTabsState extends State<_HomeTabs> { selectedIcon: Icons.home_rounded, label: l10n.homeTab, selected: logic.tab == 1, - onTap: () => logic.select(1), + onTap: () => _selectTab(context, 1), ), _TabButton( key: const Key('tab-home'), @@ -211,7 +214,7 @@ class _HomeTabsState extends State<_HomeTabs> { selectedIcon: Icons.movie_rounded, label: l10n.shortsTab, selected: logic.tab == 0, - onTap: () => logic.select(0), + onTap: () => _selectTab(context, 0), ), _TabButton( key: const Key('tab-my-list'), @@ -219,7 +222,7 @@ class _HomeTabsState extends State<_HomeTabs> { selectedIcon: Icons.bookmark_rounded, label: l10n.myListTab, selected: logic.tab == 2 && !logic.profile, - onTap: logic.openLibrary, + onTap: () => _selectTab(context, 2), ), _TabButton( key: const Key('tab-my'), @@ -227,7 +230,7 @@ class _HomeTabsState extends State<_HomeTabs> { selectedIcon: Icons.person_rounded, label: l10n.myTab, selected: logic.tab == 2 && logic.profile, - onTap: logic.openProfile, + onTap: () => _selectTab(context, 2, myProfile: true), ), ], ), @@ -236,6 +239,20 @@ class _HomeTabsState extends State<_HomeTabs> { ), ); } + + void _selectTab(BuildContext context, int tab, {bool myProfile = false}) { + final same = tab == 2 + ? logic.tab == 2 && logic.profile == myProfile + : logic.tab == tab; + if (!same) { + unawaited(context.read().selectionClick()); + } + if (tab == 2) { + myProfile ? logic.openProfile() : logic.openLibrary(); + } else { + logic.select(tab); + } + } } class _TabButton extends StatelessWidget { diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 11e4a93..63fafb8 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -15,6 +15,7 @@ import '../../core/data/services/playback_session.dart'; import '../../core/data/services/video_player_port.dart'; import '../../core/formatting/error_text.dart'; import '../../core/formatting/localized_content.dart'; +import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; @@ -350,6 +351,8 @@ class _PlayerPageState extends State with WidgetsBindingObserver { // 收藏/点赞要对到具体账号:未登录先引导登录(首页/详情仍可匿名浏览) onLike: () async { if (await ensureSignedIn(context)) { + if (!context.mounted) return; + unawaited(context.read().lightImpact()); final result = await interactions.toggleDramaLike(drama.id); if (!result.isSuccess && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -366,6 +369,8 @@ class _PlayerPageState extends State with WidgetsBindingObserver { onSave: () async { final favorites = context.read(); if (await ensureSignedIn(context)) { + if (!context.mounted) return; + unawaited(context.read().lightImpact()); final ok = await favorites.toggle(drama.id, card: drama); if (ok || !context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/pages/player/widgets/drama_comments_sheet.dart b/lib/pages/player/widgets/drama_comments_sheet.dart index 708bb4a..363321b 100644 --- a/lib/pages/player/widgets/drama_comments_sheet.dart +++ b/lib/pages/player/widgets/drama_comments_sheet.dart @@ -11,6 +11,7 @@ import '../../../core/data/repositories/comment_repository.dart'; import '../../../core/data/repositories/interaction_repository.dart'; import '../../../core/data/services/comment_controller.dart'; import '../../../core/formatting/error_text.dart'; +import '../../../core/platform/haptics.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/widgets/auth_gate.dart'; import '../../../l10n/app_localizations.dart'; @@ -60,6 +61,7 @@ class _DramaCommentsSheetState extends State { if (_controller.submitting) return; if (_input.text.trim().isEmpty) return; if (!await ensureSignedIn(context) || !mounted) return; + unawaited(context.read().lightImpact()); final result = await _controller.publish(_input.text, replyTo: _replyTo); if (!mounted) return; if (result.isSuccess) { @@ -84,6 +86,7 @@ class _DramaCommentsSheetState extends State { Future _like(DramaComment comment) async { if (!await ensureSignedIn(context) || !mounted) return; + unawaited(context.read().lightImpact()); final result = await _controller.toggleLike(comment); if (!result.isSuccess && mounted) { _showMessage( @@ -116,7 +119,8 @@ class _DramaCommentsSheetState extends State { final result = await _controller.delete(comment); if (!result.isSuccess && mounted) { _showMessage( - result.errorMessage ?? AppLocalizations.of(context)!.commentDeleteFailed, + result.errorMessage ?? + AppLocalizations.of(context)!.commentDeleteFailed, ); } } @@ -126,6 +130,7 @@ class _DramaCommentsSheetState extends State { final l10n = AppLocalizations.of(context)!; final draft = await showReportDialog(context, title: l10n.reportComment); if (draft == null || !mounted) return; + unawaited(context.read().lightImpact()); final result = await _controller.report( comment, reason: draft.reason, diff --git a/lib/pages/watch_records/watch_records_page.dart b/lib/pages/watch_records/watch_records_page.dart index be98702..9401963 100644 --- a/lib/pages/watch_records/watch_records_page.dart +++ b/lib/pages/watch_records/watch_records_page.dart @@ -13,6 +13,7 @@ import '../../core/data/services/auth_controller.dart'; import '../../core/data/services/watch_records_controller.dart'; import '../../core/formatting/error_text.dart'; import '../../core/formatting/localized_date_time.dart'; +import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; @@ -141,6 +142,7 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> { } Future _remove(String dramaId) async { + unawaited(context.read().mediumImpact()); final ok = await controller.remove(dramaId); if (!ok && mounted) _showFailure('recordDeleteFailed'); } @@ -164,7 +166,8 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> { ], ), ); - if (confirmed != true) return; + if (confirmed != true || !mounted) return; + unawaited(context.read().mediumImpact()); final ok = await controller.clear(); if (!ok && mounted) _showFailure('recordClearFailed'); } diff --git a/test/auth/auth_page_test.dart b/test/auth/auth_page_test.dart index dc5fd08..ffa24e0 100644 --- a/test/auth/auth_page_test.dart +++ b/test/auth/auth_page_test.dart @@ -11,10 +11,12 @@ import 'package:fruitshorts/core/data/remote/auth_api.dart'; import 'package:fruitshorts/core/data/remote/api_client.dart'; 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/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/auth/auth_view.dart'; import '../support/fake_http_adapter.dart'; +import '../support/fake_haptics.dart'; /// 登录/注册页的可读证据:校验提示 + 登录/注册切换 + 未登录时不会去发请求。 void main() { @@ -27,6 +29,7 @@ void main() { WidgetTester tester, { bool signUp = false, AuthApi? api, + FakeHaptics? haptics, }) async { final auth = AuthController( api ?? @@ -42,8 +45,11 @@ void main() { AuthSessionStore(), ); await tester.pumpWidget( - ChangeNotifierProvider.value( - value: auth, + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: auth), + Provider.value(value: haptics ?? FakeHaptics()), + ], child: MaterialApp( locale: const Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, @@ -168,7 +174,8 @@ void main() { tester, ) async { final api = _PendingAuthApi(); - await pumpAuthPage(tester, api: api); + final haptics = FakeHaptics(); + await pumpAuthPage(tester, api: api, haptics: haptics); await tester.enterText(find.byKey(const Key('auth-email')), 'a@b.com'); await tester.enterText(find.byKey(const Key('auth-password')), 'abcd1234'); await tester.tap(find.byKey(const Key('auth-password'))); @@ -176,6 +183,7 @@ void main() { await tester.pump(); expect(api.loginCalls, 1); + expect(haptics.lightImpacts, 1); expect( tester.widget(find.byKey(const Key('auth-email'))).enabled, isFalse, diff --git a/test/comment/drama_comments_sheet_test.dart b/test/comment/drama_comments_sheet_test.dart index f876764..3cadf2d 100644 --- a/test/comment/drama_comments_sheet_test.dart +++ b/test/comment/drama_comments_sheet_test.dart @@ -11,6 +11,7 @@ import 'package:fruitshorts/core/data/repositories/comment_repository.dart'; import 'package:fruitshorts/core/data/repositories/interaction_repository.dart'; 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/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/player/widgets/drama_comments_sheet.dart'; import 'package:provider/provider.dart'; @@ -18,6 +19,7 @@ import 'package:shared_preferences_platform_interface/in_memory_shared_preferenc import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; import '../support/fake_http_adapter.dart'; +import '../support/fake_haptics.dart'; import '../support/fake_interaction_repositories.dart'; const _drama = Drama( @@ -66,28 +68,29 @@ void main() { expect(repository.pageCalls.last.cursor, '10'); }); - testWidgets('should_keep_input_and_show_backend_message_when_comment_is_blocked', ( - tester, - ) async { - final repository = FakeCommentRepository( - pages: [const CommentPage()], - publishError: const ApiException(400, '评论包含敏感词'), - ); + testWidgets( + 'should_keep_input_and_show_backend_message_when_comment_is_blocked', + (tester) async { + final repository = FakeCommentRepository( + pages: [const CommentPage()], + publishError: const ApiException(400, '评论包含敏感词'), + ); - await _pumpSheet(tester, repository: repository); - await tester.enterText( - find.byKey(const Key('comment-input')), - 'blocked text', - ); - await tester.tap(find.byKey(const Key('comment-post'))); - await tester.pumpAndSettle(); + await _pumpSheet(tester, repository: repository); + await tester.enterText( + find.byKey(const Key('comment-input')), + 'blocked text', + ); + await tester.tap(find.byKey(const Key('comment-post'))); + await tester.pumpAndSettle(); - expect(find.text('评论包含敏感词'), findsOneWidget); - final field = tester.widget( - find.byKey(const Key('comment-input')), - ); - expect(field.controller?.text, 'blocked text'); - }); + expect(find.text('评论包含敏感词'), findsOneWidget); + final field = tester.widget( + find.byKey(const Key('comment-input')), + ); + expect(field.controller?.text, 'blocked text'); + }, + ); testWidgets('should_show_review_badge_and_server_replaced_content', ( tester, @@ -101,8 +104,9 @@ void main() { mine: true, ), ); + final haptics = FakeHaptics(); - await _pumpSheet(tester, repository: repository); + await _pumpSheet(tester, repository: repository, haptics: haptics); await tester.enterText( find.byKey(const Key('comment-input')), 'hello badword', @@ -112,6 +116,7 @@ void main() { expect(find.text('hello ***'), findsOneWidget); expect(find.text('Under review'), findsOneWidget); + expect(haptics.lightImpacts, 1); final field = tester.widget( find.byKey(const Key('comment-input')), ); @@ -139,10 +144,7 @@ void main() { await tester.tap(find.byKey(const Key('comment-reply-10'))); await tester.pump(); expect(find.textContaining('Replying to'), findsOneWidget); - await tester.enterText( - find.byKey(const Key('comment-input')), - 'My reply', - ); + await tester.enterText(find.byKey(const Key('comment-input')), 'My reply'); await tester.tap(find.byKey(const Key('comment-post'))); await tester.pumpAndSettle(); @@ -153,9 +155,7 @@ void main() { testWidgets('should_show_error_and_retry_when_comment_page_fails', ( tester, ) async { - final repository = FakeCommentRepository( - pageError: StateError('offline'), - ); + final repository = FakeCommentRepository(pageError: StateError('offline')); await _pumpSheet(tester, repository: repository); @@ -175,16 +175,19 @@ void main() { ); final interactions = FakeInteractionRepository() ..nextLikeResult = const LikeToggleResult(liked: true, count: 7); + final haptics = FakeHaptics(); await _pumpSheet( tester, repository: repository, interactions: interactions, + haptics: haptics, ); await tester.tap(find.byKey(const Key('comment-like-10'))); await tester.pumpAndSettle(); expect(interactions.likeTargets, ['COMMENT:10']); + expect(haptics.lightImpacts, 1); expect(find.text('7'), findsOneWidget); }); @@ -199,11 +202,13 @@ void main() { ], ); final interactions = FakeInteractionRepository(); + final haptics = FakeHaptics(); await _pumpSheet( tester, repository: repository, interactions: interactions, + haptics: haptics, ); await tester.tap(find.byKey(const Key('comment-menu-10'))); await tester.pumpAndSettle(); @@ -212,9 +217,13 @@ void main() { await tester.tap(find.byKey(const Key('report-submit'))); await tester.pumpAndSettle(); - expect(interactions.reports.single.targetType, InteractionTargetType.comment); + expect( + interactions.reports.single.targetType, + InteractionTargetType.comment, + ); expect(interactions.reports.single.targetId, '10'); expect(interactions.reports.single.reasonCode, 'SPAM'); + expect(haptics.lightImpacts, 1); expect(find.text('Report submitted. Thank you.'), findsOneWidget); }); @@ -281,11 +290,7 @@ void main() { pages: [ CommentPage( comments: [ - commentFixture( - id: '10', - content: 'My comment', - mine: true, - ), + commentFixture(id: '10', content: 'My comment', mine: true), ], ), ], @@ -315,6 +320,7 @@ Future _pumpSheet( WidgetTester tester, { required FakeCommentRepository repository, FakeInteractionRepository? interactions, + FakeHaptics? haptics, }) async { final auth = await _auth(); await tester.pumpWidget( @@ -325,6 +331,7 @@ Future _pumpSheet( value: interactions ?? FakeInteractionRepository(), ), ChangeNotifierProvider.value(value: auth), + Provider.value(value: haptics ?? FakeHaptics()), ], child: MaterialApp( locale: const Locale('en'), diff --git a/test/core/haptics_scan_test.dart b/test/core/haptics_scan_test.dart new file mode 100644 index 0000000..f8a448c --- /dev/null +++ b/test/core/haptics_scan_test.dart @@ -0,0 +1,23 @@ +// Author: fruit.com.cn + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('should_keep_haptic_calls_inside_platform_wrapper', () { + final violations = []; + for (final entity in Directory('lib').listSync(recursive: true)) { + if (entity is! File || !entity.path.endsWith('.dart')) continue; + if (entity.path.endsWith('lib/core/platform/haptics.dart')) continue; + final lines = entity.readAsLinesSync(); + for (var index = 0; index < lines.length; index++) { + if (lines[index].contains('HapticFeedback.')) { + violations.add('${entity.path}:${index + 1}'); + } + } + } + + expect(violations, isEmpty, reason: violations.join('\n')); + }); +} diff --git a/test/core/haptics_test.dart b/test/core/haptics_test.dart new file mode 100644 index 0000000..f876eba --- /dev/null +++ b/test/core/haptics_test.dart @@ -0,0 +1,24 @@ +// Author: fruit.com.cn + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/platform/haptics.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('should_ignore_platform_errors_when_haptics_are_unavailable', () async { + const channel = SystemChannels.platform; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(channel, (_) async { + throw PlatformException(code: 'unavailable'); + }); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + + const haptics = FlutterHaptics(); + await expectLater(haptics.selectionClick(), completes); + await expectLater(haptics.lightImpact(), completes); + await expectLater(haptics.mediumImpact(), completes); + }); +} diff --git a/test/download/downloads_page_test.dart b/test/download/downloads_page_test.dart index fe64f1e..2866d09 100644 --- a/test/download/downloads_page_test.dart +++ b/test/download/downloads_page_test.dart @@ -10,6 +10,7 @@ import 'package:fruitshorts/core/data/repositories/download_repository.dart'; import 'package:fruitshorts/core/data/services/auth_controller.dart'; import 'package:fruitshorts/core/data/services/auth_session_store.dart'; import 'package:fruitshorts/core/data/services/download_controller.dart'; +import 'package:fruitshorts/core/platform/haptics.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/downloads/downloads_page.dart'; import 'package:provider/provider.dart'; @@ -17,20 +18,19 @@ import 'package:shared_preferences_platform_interface/in_memory_shared_preferenc import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; import '../support/fake_http_adapter.dart'; +import '../support/fake_haptics.dart'; -DownloadPolicy _policy({ - bool wifiOnly = true, - String maxQuality = 'AUTO', -}) => DownloadPolicy( - maxPerDevice: 20, - licenseDays: 30, - wifiOnly: wifiOnly, - maxQuality: maxQuality, - maxStorageBytes: 0, - used: 1, - remaining: 19, - expirePolicyText: 'Licenses last 30 days', -); +DownloadPolicy _policy({bool wifiOnly = true, String maxQuality = 'AUTO'}) => + DownloadPolicy( + maxPerDevice: 20, + licenseDays: 30, + wifiOnly: wifiOnly, + maxQuality: maxQuality, + maxStorageBytes: 0, + used: 1, + remaining: 19, + expirePolicyText: 'Licenses last 30 days', + ); DownloadLicense _license(String episodeId) => DownloadLicense( licenseId: 'license-$episodeId', @@ -145,8 +145,14 @@ void main() { licenses: [_license('1')], ); final controller = DownloadController(repository); + final haptics = FakeHaptics(); - await _pumpPage(tester, auth: auth, controller: controller); + await _pumpPage( + tester, + auth: auth, + controller: controller, + haptics: haptics, + ); await tester.tap(find.byKey(const Key('download-clear'))); await tester.pumpAndSettle(); expect(repository.clearCalls, 0); @@ -155,23 +161,25 @@ void main() { await tester.pumpAndSettle(); expect(repository.clearCalls, 1); + expect(haptics.mediumImpacts, 1); expect(find.byKey(const Key('downloads-empty')), findsOneWidget); }); - testWidgets('should_render_unique_quality_options_when_policy_uses_a_preset', ( - tester, - ) async { - final auth = await _auth(signedIn: true); - final repository = FakeDownloadRepository( - policy: _policy(maxQuality: '720P'), - ); - final controller = DownloadController(repository); + testWidgets( + 'should_render_unique_quality_options_when_policy_uses_a_preset', + (tester) async { + final auth = await _auth(signedIn: true); + final repository = FakeDownloadRepository( + policy: _policy(maxQuality: '720P'), + ); + final controller = DownloadController(repository); - await _pumpPage(tester, auth: auth, controller: controller); + await _pumpPage(tester, auth: auth, controller: controller); - expect(tester.takeException(), isNull); - expect(find.byKey(const Key('download-quality')), findsOneWidget); - }); + expect(tester.takeException(), isNull); + expect(find.byKey(const Key('download-quality')), findsOneWidget); + }, + ); } Future _auth({required bool signedIn}) async { @@ -198,12 +206,14 @@ Future _pumpPage( WidgetTester tester, { required AuthController auth, required DownloadController controller, + FakeHaptics? haptics, }) async { await tester.pumpWidget( MultiProvider( providers: [ ChangeNotifierProvider.value(value: auth), ChangeNotifierProvider.value(value: controller), + Provider.value(value: haptics ?? FakeHaptics()), ], child: MaterialApp( locale: const Locale('en'), diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart index 6a9b9f7..4843dc3 100644 --- a/test/home/home_nav_ui_test.dart +++ b/test/home/home_nav_ui_test.dart @@ -12,6 +12,7 @@ import 'package:shared_preferences_platform_interface/shared_preferences_async_p 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'; @@ -36,7 +37,9 @@ void main() { testWidgets('should_theme_four_tabs_and_switch_selected_state_when_tapped', ( tester, ) async { - await _pumpApp(tester); + final haptics = FakeHaptics(); + await _pumpApp(tester, haptics); + haptics.reset(); for (final key in _keys) { expect(find.byKey(key), findsOneWidget); @@ -73,6 +76,10 @@ void main() { await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); + expect(haptics.selectionClicks, 1); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + expect(haptics.selectionClicks, 1); expect( find.descendant( of: find.byKey(const Key('tab-discover')), @@ -100,9 +107,24 @@ void main() { expect(find.byKey(key), findsNothing); } }); + + testWidgets('should_not_haptic_when_ordinary_list_item_is_opened', ( + tester, + ) async { + final haptics = FakeHaptics(); + await _pumpApp(tester, haptics); + await tester.tap(find.byKey(const Key('tab-my'))); + await tester.pumpAndSettle(); + haptics.reset(); + + await tester.tap(find.text('App Language')); + await tester.pumpAndSettle(); + + expect(haptics.calls, isEmpty); + }); } -Future _pumpApp(WidgetTester tester) async { +Future _pumpApp(WidgetTester tester, [FakeHaptics? haptics]) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); @@ -118,6 +140,7 @@ Future _pumpApp(WidgetTester tester) async { playbackRepository: FakePlaybackRepository(), commentRepository: FakeCommentRepository(), interactionRepository: FakeInteractionRepository(), + haptics: haptics ?? FakeHaptics(), ), ); await tester.pumpAndSettle(); diff --git a/test/library/favorites_page_test.dart b/test/library/favorites_page_test.dart index b49548a..8e2b0ff 100644 --- a/test/library/favorites_page_test.dart +++ b/test/library/favorites_page_test.dart @@ -9,6 +9,7 @@ import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/services/auth_controller.dart'; import 'package:fruitshorts/core/data/services/auth_session_store.dart'; import 'package:fruitshorts/core/data/services/favorite_controller.dart'; +import 'package:fruitshorts/core/platform/haptics.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/favorites/favorites_page.dart'; import 'package:provider/provider.dart'; @@ -16,6 +17,7 @@ import 'package:shared_preferences_platform_interface/in_memory_shared_preferenc import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; import '../support/fake_http_adapter.dart'; +import '../support/fake_haptics.dart'; Drama _card(String id) => Drama( id: id, @@ -58,11 +60,13 @@ void main() { cards: [_card('signal')], )..failRemove = true; final favorites = FavoriteController(repository); + final haptics = FakeHaptics(); - await _pumpPage(tester, auth: auth, favorites: favorites); + await _pumpPage(tester, auth: auth, favorites: favorites, haptics: haptics); await tester.tap(find.byKey(const Key('favorite-remove-signal'))); await tester.pumpAndSettle(); + expect(haptics.lightImpacts, 1); expect(find.text('Drama signal'), findsOneWidget); expect( find.text('Could not update favorites. Please retry.'), @@ -134,12 +138,14 @@ Future _pumpPage( WidgetTester tester, { required AuthController auth, required FavoriteController favorites, + FakeHaptics? haptics, }) async { await tester.pumpWidget( MultiProvider( providers: [ ChangeNotifierProvider.value(value: auth), ChangeNotifierProvider.value(value: favorites), + Provider.value(value: haptics ?? FakeHaptics()), ], child: MaterialApp( locale: const Locale('en'), diff --git a/test/support/fake_haptics.dart b/test/support/fake_haptics.dart new file mode 100644 index 0000000..15314a9 --- /dev/null +++ b/test/support/fake_haptics.dart @@ -0,0 +1,36 @@ +// Author: fruit.com.cn + +import 'package:fruitshorts/core/platform/haptics.dart'; + +/// Deterministic haptics recorder for widget tests. +class FakeHaptics implements Haptics { + final List calls = []; + int selectionClicks = 0; + int lightImpacts = 0; + int mediumImpacts = 0; + + void reset() { + calls.clear(); + selectionClicks = 0; + lightImpacts = 0; + mediumImpacts = 0; + } + + @override + Future selectionClick() async { + calls.add('selection'); + selectionClicks++; + } + + @override + Future lightImpact() async { + calls.add('light'); + lightImpacts++; + } + + @override + Future mediumImpact() async { + calls.add('medium'); + mediumImpacts++; + } +} diff --git a/test/watch/watch_records_page_test.dart b/test/watch/watch_records_page_test.dart index d42ef0e..4510ff0 100644 --- a/test/watch/watch_records_page_test.dart +++ b/test/watch/watch_records_page_test.dart @@ -11,6 +11,7 @@ import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; import 'package:fruitshorts/core/data/services/auth_controller.dart'; import 'package:fruitshorts/core/data/services/auth_session_store.dart'; +import 'package:fruitshorts/core/platform/haptics.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/watch_records/watch_records_page.dart'; import 'package:provider/provider.dart'; @@ -19,6 +20,7 @@ import 'package:shared_preferences_platform_interface/shared_preferences_async_p import '../support/fake_catalog.dart'; import '../support/fake_http_adapter.dart'; +import '../support/fake_haptics.dart'; void main() { setUp(() { @@ -101,12 +103,14 @@ void main() { (tester) async { final auth = await _auth(signedIn: true); final history = CountingWatchHistory([_progress('the-signal-demo')]); + final haptics = FakeHaptics(); await _pumpPage( tester, auth: auth, history: history, catalog: FakeCatalogRepository(), + haptics: haptics, ); await tester.tap(find.byKey(const Key('watch-records-clear'))); await tester.pumpAndSettle(); @@ -118,6 +122,7 @@ void main() { await tester.pumpAndSettle(); expect(history.clearCalls, 1); + expect(haptics.mediumImpacts, 1); expect(find.text('No watch history yet.'), findsOneWidget); }, ); @@ -156,6 +161,7 @@ Future _pumpPage( required AuthController auth, required WatchHistoryRepository history, required CatalogRepository catalog, + FakeHaptics? haptics, }) async { await tester.pumpWidget( MultiProvider( @@ -163,6 +169,7 @@ Future _pumpPage( ChangeNotifierProvider.value(value: auth), Provider.value(value: history), Provider.value(value: catalog), + Provider.value(value: haptics ?? FakeHaptics()), ], child: MaterialApp( locale: const Locale('en'),