From 45c3c645ffc59e980925e2e8f02f142367bd8f24 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Sat, 17 Oct 2026 17:14:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(flutter):=20=E9=87=8D=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E9=A1=B5=E8=A7=86=E8=A7=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/auth/auth_form_widgets.dart | 363 ++++++++++++++++++++++++++ lib/pages/auth/auth_view.dart | 361 ++++++++++--------------- lib/pages/auth/auth_widgets.dart | 183 +++++++++++++ test/auth/auth_visual_test.dart | 311 ++++++++++++++++++++++ 4 files changed, 989 insertions(+), 229 deletions(-) create mode 100644 lib/pages/auth/auth_form_widgets.dart create mode 100644 lib/pages/auth/auth_widgets.dart create mode 100644 test/auth/auth_visual_test.dart diff --git a/lib/pages/auth/auth_form_widgets.dart b/lib/pages/auth/auth_form_widgets.dart new file mode 100644 index 0000000..90cc3c7 --- /dev/null +++ b/lib/pages/auth/auth_form_widgets.dart @@ -0,0 +1,363 @@ +// Author: fruit.com.cn + +part of 'auth_view.dart'; + +class _AuthFields extends StatelessWidget { + const _AuthFields({required this.state, required this.callbacks}); + + final _AuthCardState state; + final _AuthCardCallbacks callbacks; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _fieldLabel(state.l10n.authEmailLabel), + const SizedBox(height: 8), + _emailField(), + const SizedBox(height: 16), + _fieldLabel(state.l10n.authPasswordLabel), + const SizedBox(height: 8), + _passwordField(), + ], + ); + } + + Widget _emailField() { + final enabled = !state.status.busy; + return ListenableBuilder( + listenable: state.fields.emailFocus, + builder: (context, _) => SizedBox( + height: _fieldHeight, + child: TextField( + key: const Key('auth-email'), + controller: state.fields.email, + focusNode: state.fields.emailFocus, + enabled: enabled, + autofocus: true, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + autofillHints: const [AutofillHints.email], + onChanged: callbacks.onEmailChanged, + onSubmitted: (_) => state.fields.passwordFocus.requestFocus(), + style: const TextStyle(color: AppColors.onPrimary, fontSize: 15), + decoration: _decoration( + hint: 'name@example.com', + icon: Icons.mail_outline_rounded, + focused: state.fields.emailFocus.hasFocus, + ), + ), + ), + ); + } + + Widget _passwordField() { + final enabled = !state.status.busy; + final l10n = state.l10n; + return ListenableBuilder( + listenable: state.fields.passwordFocus, + builder: (context, _) => SizedBox( + height: _fieldHeight, + child: TextField( + key: const Key('auth-password'), + controller: state.fields.password, + focusNode: state.fields.passwordFocus, + enabled: enabled, + obscureText: state.status.obscurePassword, + keyboardType: TextInputType.visiblePassword, + textInputAction: TextInputAction.done, + autofillHints: const [AutofillHints.password], + onChanged: callbacks.onPasswordChanged, + onSubmitted: (_) => callbacks.onSubmit(), + style: const TextStyle(color: AppColors.onPrimary, fontSize: 15), + decoration: _decoration( + hint: l10n.authPasswordHint, + icon: Icons.lock_outline_rounded, + focused: state.fields.passwordFocus.hasFocus, + suffix: IconButton( + key: const Key('auth-password-visibility'), + constraints: const BoxConstraints.tightFor( + width: _minTapTarget, + height: _minTapTarget, + ), + tooltip: state.status.obscurePassword + ? l10n.showPassword + : l10n.hidePassword, + onPressed: enabled ? callbacks.onTogglePasswordVisibility : null, + icon: Icon( + state.status.obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: AppColors.muted, + ), + ), + ), + ), + ), + ); + } + + Widget _fieldLabel(String label) => Text( + label, + style: const TextStyle( + color: AppColors.muted, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ); + + InputDecoration _decoration({ + required String hint, + required IconData icon, + required bool focused, + Widget? suffix, + }) { + final border = OutlineInputBorder( + borderRadius: BorderRadius.circular(_fieldRadius), + borderSide: BorderSide.none, + ); + return InputDecoration( + hintText: hint, + hintStyle: const TextStyle(color: AppColors.muted, fontSize: 14), + prefixIcon: Icon( + icon, + size: 21, + color: focused ? AppColors.blueSoft : AppColors.muted, + ), + prefixIconConstraints: const BoxConstraints( + minWidth: _minTapTarget, + minHeight: _minTapTarget, + ), + suffixIcon: suffix, + filled: true, + fillColor: AppColors.bar, + isDense: true, + contentPadding: const EdgeInsets.only(right: 16), + border: border, + enabledBorder: border, + disabledBorder: border, + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(_fieldRadius), + borderSide: const BorderSide( + color: AppColors.blue, + width: _fieldBorderWidth, + ), + ), + ); + } +} + +class _AuthSubmitButton extends StatelessWidget { + const _AuthSubmitButton({required this.state, required this.onSubmit}); + + final _AuthCardState state; + final VoidCallback onSubmit; + + @override + Widget build(BuildContext context) { + final status = state.status; + final l10n = state.l10n; + return SizedBox( + width: double.infinity, + height: _buttonHeight, + child: FilledButton( + key: const Key('auth-submit'), + onPressed: status.busy ? null : onSubmit, + style: ButtonStyle( + backgroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return AppColors.blue.withValues(alpha: 0.5); + } + if (states.contains(WidgetState.pressed)) { + return AppColors.bluePressed; + } + return AppColors.blue; + }), + foregroundColor: const WidgetStatePropertyAll(AppColors.onPrimary), + elevation: const WidgetStatePropertyAll(0), + padding: const WidgetStatePropertyAll(EdgeInsets.zero), + shape: WidgetStatePropertyAll( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(_buttonRadius), + ), + ), + ), + child: status.busy + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.onPrimary, + ), + ) + : Text( + status.signUpMode ? l10n.signUp : l10n.signIn, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + ), + ); + } +} + +class _ModeSegment extends StatelessWidget { + const _ModeSegment({ + required this.segmentKey, + required this.label, + required this.hiddenLabel, + required this.selected, + required this.onTap, + }); + + final Key segmentKey; + final String label; + final String hiddenLabel; + final bool selected; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + key: segmentKey, + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: AnimatedContainer( + duration: _segmentDuration, + alignment: Alignment.center, + decoration: BoxDecoration( + color: selected ? AppColors.blue : Colors.transparent, + borderRadius: BorderRadius.circular(18), + ), + child: Stack( + fit: StackFit.expand, + children: [ + Center( + child: Text( + label, + style: TextStyle( + color: selected ? AppColors.onPrimary : AppColors.muted, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ), + Positioned.fill( + child: ExcludeSemantics( + child: Opacity( + opacity: 0, + child: Center( + child: Text( + hiddenLabel, + maxLines: 1, + softWrap: false, + style: const TextStyle(fontSize: 14), + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _AuthBrand extends StatelessWidget { + const _AuthBrand({required this.tagline}); + + final String tagline; + + @override + Widget build(BuildContext context) { + return Row( + key: const Key('auth-brand'), + children: [ + Container( + key: const Key('auth-brand-mark'), + width: _brandMarkSize, + height: _brandMarkSize, + decoration: BoxDecoration( + color: AppColors.blue, + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow( + color: AppColors.blue.withValues(alpha: 0.32), + blurRadius: 24, + offset: const Offset(0, 10), + ), + ], + ), + child: const Icon( + Icons.movie_filter_rounded, + color: AppColors.onPrimary, + size: 26, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(context)!.appName, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 23, + fontWeight: FontWeight.w800, + height: 1.1, + ), + ), + const SizedBox(height: 4), + Text( + tagline, + style: const TextStyle( + color: AppColors.blueSoft, + fontSize: 13, + height: 1.35, + ), + ), + ], + ), + ), + ], + ); + } +} + +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 Container( + key: const Key('auth-error-capsule'), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: color.withValues(alpha: _errorOpacity), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withValues(alpha: _errorBorderOpacity)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.error_outline_rounded, color: color, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + message, + key: const Key('auth-error'), + style: TextStyle(color: color, fontSize: 13, height: 1.35), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/auth/auth_view.dart b/lib/pages/auth/auth_view.dart index af6bdc2..58b886d 100644 --- a/lib/pages/auth/auth_view.dart +++ b/lib/pages/auth/auth_view.dart @@ -13,7 +13,26 @@ import '../../core/theme/app_theme.dart'; import '../../l10n/app_localizations.dart'; import 'auth_logic.dart'; +part 'auth_widgets.dart'; +part 'auth_form_widgets.dart'; + const double _minTapTarget = 44; +const double _brandMarkSize = 48; +const double _contentMaxWidth = 440; +const double _cardRadius = 20; +const double _cardPadding = 20; +const double _cardBorderWidth = 1; +const double _fieldHeight = 52; +const double _fieldRadius = 14; +const double _fieldBorderWidth = 1.5; +const double _buttonHeight = 52; +const double _buttonRadius = 26; +const double _glowOpacity = 0.18; +const double _errorOpacity = 0.10; +const double _errorBorderOpacity = 0.28; +const double _enterSlideDistance = 16; +const Duration _enterDuration = Duration(milliseconds: 220); +const Duration _segmentDuration = Duration(milliseconds: 160); /// 登录 / 注册页:邮箱 + 密码。 class AuthPage extends StatefulWidget { @@ -112,245 +131,129 @@ class _AuthPageState extends State { child: Scaffold( resizeToAvoidBottomInset: true, backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.ink, - elevation: 0, - foregroundColor: AppColors.onPrimary, - title: Text(_logic.signUpMode ? l10n.signUp : l10n.signIn), - ), - body: SafeArea( - child: AutofillGroup( - child: ListView( - key: const Key('auth-scroll'), - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - padding: EdgeInsets.fromLTRB( - 20, - 12, - 20, - 28 + MediaQuery.viewInsetsOf(context).bottom, - ), - children: [ - _AuthBrand(tagline: l10n.authBrandTagline), - const SizedBox(height: 26), - Text( - _logic.signUpMode - ? l10n.authSignUpTitle - : l10n.authSignInTitle, - style: const TextStyle( - color: AppColors.onPrimary, - fontSize: 24, - fontWeight: FontWeight.w800, + body: Stack( + children: [ + Positioned( + top: 0, + left: 0, + right: 0, + height: 420, + child: IgnorePointer( + child: Container( + key: const Key('auth-background-glow'), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.blue.withValues(alpha: _glowOpacity), + AppColors.blue.withValues(alpha: 0.05), + AppColors.ink.withValues(alpha: 0), + ], + stops: const [0, 0.55, 1], + ), ), ), - 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 _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, - ), - ), - ), - ), - ); - - 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, + SafeArea( + child: AutofillGroup( + child: ListView( + key: const Key('auth-scroll'), + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + padding: EdgeInsets.fromLTRB( + 20, + 12, + 20, + 28 + MediaQuery.viewInsetsOf(context).bottom, + ), + children: [ + Center( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: _contentMaxWidth, + ), + child: TweenAnimationBuilder( + key: const Key('auth-enter-animation'), + tween: Tween(begin: 0, end: 1), + duration: _enterDuration, + curve: Curves.easeOutCubic, + builder: (context, value, child) => Opacity( + opacity: value, + child: Transform.translate( + offset: Offset( + 0, + _enterSlideDistance * (1 - value), + ), + child: child, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (Navigator.of(context).canPop()) + Align( + alignment: Alignment.centerLeft, + child: IconButton( + key: const Key('auth-back'), + tooltip: MaterialLocalizations.of( + context, + ).backButtonTooltip, + onPressed: () => + Navigator.of(context).pop(), + icon: const Icon( + Icons.arrow_back_rounded, + color: AppColors.onPrimary, + ), + ), + ), + _AuthBrand(tagline: l10n.authBrandTagline), + const SizedBox(height: 28), + _authCard(l10n), + ], + ), + ), + ), + ), + ], ), ), - const SizedBox(height: 2), - Text( - tagline, - style: const TextStyle(color: AppColors.blueSoft, fontSize: 12), - ), - ], - ), + ), + ], ), - ], + ), ); } -} -class _AuthError extends StatelessWidget { - const _AuthError({required this.message}); + Widget _authCard(AppLocalizations l10n) => _AuthCard( + state: _AuthCardState( + l10n: l10n, + status: _AuthFormStatus( + signUpMode: _logic.signUpMode, + busy: _logic.busy, + errorMessage: _logic.errorMessage, + obscurePassword: _obscurePassword, + ), + fields: _AuthFieldControllers( + email: _emailController, + password: _passwordController, + emailFocus: _emailFocus, + passwordFocus: _passwordFocus, + ), + ), + callbacks: _AuthCardCallbacks( + onModeSelected: _selectMode, + onEmailChanged: (value) => _logic.email = value, + onPasswordChanged: (value) => _logic.password = value, + onTogglePasswordVisibility: () => + setState(() => _obscurePassword = !_obscurePassword), + onSubmit: _submit, + ), + ); - 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), - ), - ), - ], - ); + void _selectMode(bool signUpMode) { + if (_logic.busy || _logic.signUpMode == signUpMode) return; + setState(() => _logic.toggleMode()); } } diff --git a/lib/pages/auth/auth_widgets.dart b/lib/pages/auth/auth_widgets.dart new file mode 100644 index 0000000..ea66e4f --- /dev/null +++ b/lib/pages/auth/auth_widgets.dart @@ -0,0 +1,183 @@ +// Author: fruit.com.cn + +part of 'auth_view.dart'; + +class _AuthCardState { + const _AuthCardState({ + required this.l10n, + required this.status, + required this.fields, + }); + + final AppLocalizations l10n; + final _AuthFormStatus status; + final _AuthFieldControllers fields; +} + +class _AuthFormStatus { + const _AuthFormStatus({ + required this.signUpMode, + required this.busy, + required this.errorMessage, + required this.obscurePassword, + }); + + final bool signUpMode; + final bool busy; + final String? errorMessage; + final bool obscurePassword; +} + +class _AuthFieldControllers { + const _AuthFieldControllers({ + required this.email, + required this.password, + required this.emailFocus, + required this.passwordFocus, + }); + + final TextEditingController email; + final TextEditingController password; + final FocusNode emailFocus; + final FocusNode passwordFocus; +} + +class _AuthCardCallbacks { + const _AuthCardCallbacks({ + required this.onModeSelected, + required this.onEmailChanged, + required this.onPasswordChanged, + required this.onTogglePasswordVisibility, + required this.onSubmit, + }); + + final ValueChanged onModeSelected; + final ValueChanged onEmailChanged; + final ValueChanged onPasswordChanged; + final VoidCallback onTogglePasswordVisibility; + final VoidCallback onSubmit; +} + +class _AuthCard extends StatelessWidget { + const _AuthCard({required this.state, required this.callbacks}); + + final _AuthCardState state; + final _AuthCardCallbacks callbacks; + + @override + Widget build(BuildContext context) { + final l10n = state.l10n; + final status = state.status; + return Container( + key: const Key('auth-card'), + padding: const EdgeInsets.all(_cardPadding), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(_cardRadius), + border: Border.all( + color: AppColors.outlineVariantDark, + width: _cardBorderWidth, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _AuthModeSwitch( + l10n: l10n, + signUpMode: status.signUpMode, + busy: status.busy, + onSelected: callbacks.onModeSelected, + ), + const SizedBox(height: 22), + Text( + status.signUpMode ? l10n.authSignUpTitle : l10n.authSignInTitle, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 25, + fontWeight: FontWeight.w800, + height: 1.2, + ), + ), + const SizedBox(height: 6), + Text( + l10n.authSubtitle, + style: const TextStyle( + color: AppColors.muted, + fontSize: 13, + height: 1.45, + ), + ), + const SizedBox(height: 22), + _AuthFields(state: state, callbacks: callbacks), + if (status.errorMessage != null) ...[ + const SizedBox(height: 14), + _AuthError(message: status.errorMessage!), + ], + const SizedBox(height: 22), + _AuthSubmitButton(state: state, onSubmit: callbacks.onSubmit), + ], + ), + ); + } +} + +class _AuthModeSwitch extends StatelessWidget { + const _AuthModeSwitch({ + required this.l10n, + required this.signUpMode, + required this.busy, + required this.onSelected, + }); + + final AppLocalizations l10n; + final bool signUpMode; + final bool busy; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + return TextButton( + key: const Key('auth-switch-mode'), + onPressed: busy ? null : () => onSelected(!signUpMode), + style: TextButton.styleFrom( + minimumSize: const Size(0, _minTapTarget), + padding: EdgeInsets.zero, + overlayColor: Colors.transparent, + splashFactory: NoSplash.splashFactory, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)), + ), + child: Container( + height: _minTapTarget, + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: AppColors.bar, + borderRadius: BorderRadius.circular(22), + border: Border.all(color: AppColors.outlineVariantDark), + ), + child: Row( + children: [ + Expanded( + child: _ModeSegment( + segmentKey: const Key('auth-mode-sign-in'), + label: l10n.signIn, + hiddenLabel: l10n.authSwitchToSignIn, + selected: !signUpMode, + onTap: busy ? null : () => onSelected(false), + ), + ), + const SizedBox(width: 4), + Expanded( + child: _ModeSegment( + segmentKey: const Key('auth-mode-sign-up'), + label: l10n.signUp, + hiddenLabel: l10n.authSwitchToSignUp, + selected: signUpMode, + onTap: busy ? null : () => onSelected(true), + ), + ), + ], + ), + ), + ); + } +} diff --git a/test/auth/auth_visual_test.dart b/test/auth/auth_visual_test.dart new file mode 100644 index 0000000..5f5e052 --- /dev/null +++ b/test/auth/auth_visual_test.dart @@ -0,0 +1,311 @@ +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/api_client.dart'; +import 'package:fruitshorts/core/data/remote/auth_api.dart'; +import 'package:fruitshorts/core/data/services/auth_controller.dart'; +import 'package:fruitshorts/core/data/services/auth_session_store.dart'; +import 'package:fruitshorts/core/platform/haptics.dart'; +import 'package:fruitshorts/core/theme/app_colors.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/auth/auth_view.dart'; + +import '../support/fake_haptics.dart'; +import '../support/fake_http_adapter.dart'; + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + Future pumpAuthPage( + WidgetTester tester, { + AuthApi? api, + FakeHaptics? haptics, + }) async { + final auth = AuthController( + api ?? + AuthApi( + ApiClient( + baseUrl: 'http://127.0.0.1:1', + adapter: FakeHttpAdapter( + (_) async => FakeHttpResponse('{}', 500), + ), + ), + ), + AuthSessionStore(), + ); + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: auth), + Provider.value(value: haptics ?? FakeHaptics()), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const AuthPage(), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 220)); + } + + testWidgets( + 'should_render_branded_dark_layout_with_approved_tokens_when_page_opens', + (tester) async { + await pumpAuthPage(tester); + + expect(find.byKey(const Key('auth-brand')), findsOneWidget); + expect(find.text('FruitShorts'), findsOneWidget); + expect(find.text('Short dramas made for Indonesia'), findsOneWidget); + expect( + tester.getSize(find.byKey(const Key('auth-brand-mark'))), + const Size(48, 48), + ); + + final glow = tester.widget( + find.byKey(const Key('auth-background-glow')), + ); + final glowDecoration = glow.decoration! as BoxDecoration; + final gradient = glowDecoration.gradient! as LinearGradient; + for (final color in gradient.colors) { + expect(color.a, lessThanOrEqualTo(0.18)); + } + + final card = tester.widget(find.byKey(const Key('auth-card'))); + final cardDecoration = card.decoration! as BoxDecoration; + expect(cardDecoration.color, AppColors.card); + expect(cardDecoration.borderRadius, BorderRadius.circular(20)); + expect( + (cardDecoration.border! as Border).top.color, + AppColors.outlineVariantDark, + ); + expect((cardDecoration.border! as Border).top.width, 1); + + final email = tester.widget( + find.byKey(const Key('auth-email')), + ); + expect(email.decoration?.fillColor, AppColors.bar); + expect(tester.getSize(find.byKey(const Key('auth-email'))).height, 52); + final emailBorder = email.decoration!.border! as OutlineInputBorder; + expect(emailBorder.borderRadius, BorderRadius.circular(14)); + + final submit = tester.widget( + find.byKey(const Key('auth-submit')), + ); + expect(submit.style!.backgroundColor!.resolve(const {}), AppColors.blue); + expect( + submit.style!.backgroundColor!.resolve(const {WidgetState.pressed}), + AppColors.bluePressed, + ); + expect(tester.getSize(find.byKey(const Key('auth-submit'))).height, 52); + expect( + (submit.style!.shape!.resolve(const {})! as RoundedRectangleBorder) + .borderRadius, + BorderRadius.circular(26), + ); + + expect(find.text('Sign In'), findsWidgets); + expect(find.text('Sign Up'), findsOneWidget); + expect( + tester.widget(find.text('Sign In').first).style?.fontSize, + isNot(0), + ); + }, + ); + + testWidgets('should_fade_and_slide_content_in_within_220ms', (tester) async { + await pumpAuthPage(tester); + + final animation = tester.widget>( + find.byKey(const Key('auth-enter-animation')), + ); + expect( + animation.duration, + lessThanOrEqualTo(const Duration(milliseconds: 220)), + ); + expect(animation.tween.begin, 0); + expect(animation.tween.end, 1); + }); + + testWidgets( + 'should_switch_between_sign_in_and_sign_up_when_segment_is_tapped', + (tester) async { + await pumpAuthPage(tester); + expect(find.text('Welcome back'), findsOneWidget); + + await tester.tap(find.byKey(const Key('auth-mode-sign-up'))); + await tester.pumpAndSettle(); + + expect(find.text('Create your account'), findsOneWidget); + expect(find.text('Welcome back'), findsNothing); + expect(_segmentColor(tester, 'auth-mode-sign-up'), AppColors.blue); + expect(_segmentColor(tester, 'auth-mode-sign-in'), Colors.transparent); + + await tester.tap(find.byKey(const Key('auth-mode-sign-in'))); + await tester.pumpAndSettle(); + + expect(find.text('Welcome back'), findsOneWidget); + expect(find.text('Create your account'), findsNothing); + expect(_segmentColor(tester, 'auth-mode-sign-in'), AppColors.blue); + expect(_segmentColor(tester, 'auth-mode-sign-up'), Colors.transparent); + }, + ); + + testWidgets( + 'should_move_focus_and_toggle_password_when_auth_controls_are_used', + (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', + ); + + await tester.tap(find.byKey(const Key('auth-email'))); + await tester.pump(); + final email = tester.widget( + find.byKey(const Key('auth-email')), + ); + final focusedBorder = + email.decoration!.focusedBorder! as OutlineInputBorder; + expect(focusedBorder.borderSide.color, AppColors.blue); + expect(focusedBorder.borderSide.width, 1.5); + expect( + tester.widget(find.byIcon(Icons.mail_outline_rounded)).color, + AppColors.blueSoft, + ); + + 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_disable_form_mode_switch_and_submit_when_sign_in_is_pending', + (tester) async { + final api = _PendingAuthApi(); + 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-submit'))); + await tester.pump(); + + expect(api.loginCalls, 1); + expect(haptics.lightImpacts, 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-switch-mode'))) + .onPressed, + isNull, + ); + expect( + tester + .widget(find.byKey(const Key('auth-submit'))) + .onPressed, + isNull, + ); + + api.release(); + await tester.pumpAndSettle(); + }, + ); + + testWidgets('should_show_validation_message_inside_semantic_error_capsule', ( + 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(); + + final capsuleFinder = find.byKey(const Key('auth-error-capsule')); + expect(capsuleFinder, findsOneWidget); + final capsule = tester.widget(capsuleFinder); + final decoration = capsule.decoration! as BoxDecoration; + expect(decoration.borderRadius, BorderRadius.circular(12)); + expect(decoration.color?.a, closeTo(0.1, 0.001)); + + final errorFinder = find.byKey(const Key('auth-error')); + final error = tester.widget(errorFinder); + final scheme = Theme.of(tester.element(errorFinder)).colorScheme; + expect(error.style?.color, scheme.error); + expect( + find.descendant( + of: capsuleFinder, + matching: find.byIcon(Icons.error_outline_rounded), + ), + findsOneWidget, + ); + }); +} + +Color? _segmentColor(WidgetTester tester, String key) { + final segment = find.byKey(Key(key)); + final animated = tester.widget( + find.descendant(of: segment, matching: find.byType(AnimatedContainer)), + ); + return (animated.decoration! as BoxDecoration).color; +} + +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'); + } +}