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] =?UTF-8?q?feat(flutter):=20=E4=BC=98=E5=8C=96=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E9=A1=B5=E4=B8=8E=E5=BA=95=E9=83=A8=E5=AF=BC=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(); +}