diff --git a/lib/pages/auth/auth_form_widgets.dart b/lib/pages/auth/auth_form_widgets.dart index 576da35..81a58a7 100644 --- a/lib/pages/auth/auth_form_widgets.dart +++ b/lib/pages/auth/auth_form_widgets.dart @@ -22,11 +22,11 @@ class _AuthFields extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ _fieldLabel(state.l10n.authEmailLabel), - const SizedBox(height: 8), + const SizedBox(height: 6), _emailField(context, hasError: emailError), - const SizedBox(height: 16), + const SizedBox(height: 14), _fieldLabel(state.l10n.authPasswordLabel), - const SizedBox(height: 8), + const SizedBox(height: 6), _passwordField(context, hasError: passwordError), if (state.status.signUpMode) ...[ const SizedBox(height: 8), @@ -60,7 +60,8 @@ class _AuthFields extends StatelessWidget { autofillHints: const [AutofillHints.email], onChanged: callbacks.onEmailChanged, onSubmitted: (_) => state.fields.passwordFocus.requestFocus(), - style: const TextStyle(color: AppColors.onPrimary, fontSize: 15), + style: const TextStyle(color: AppColors.onSurface, fontSize: 15), + cursorColor: AppColors.blue, decoration: _decoration( context: context, hint: 'name@example.com', @@ -91,7 +92,8 @@ class _AuthFields extends StatelessWidget { autofillHints: const [AutofillHints.password], onChanged: callbacks.onPasswordChanged, onSubmitted: (_) => callbacks.onSubmit(), - style: const TextStyle(color: AppColors.onPrimary, fontSize: 15), + style: const TextStyle(color: AppColors.onSurface, fontSize: 15), + cursorColor: AppColors.blue, decoration: _decoration( context: context, hint: l10n.authPasswordHint, @@ -125,8 +127,8 @@ class _AuthFields extends StatelessWidget { label, style: const TextStyle( color: AppColors.muted, - fontSize: 13, - fontWeight: FontWeight.w600, + fontSize: 12, + fontWeight: FontWeight.w700, ), ); @@ -140,7 +142,10 @@ class _AuthFields extends StatelessWidget { }) { final border = OutlineInputBorder( borderRadius: BorderRadius.circular(_fieldRadius), - borderSide: BorderSide.none, + borderSide: const BorderSide( + color: AppColors.border, + width: _fieldBorderWidth, + ), ); final errorColor = Theme.of(context).colorScheme.error; final errorBorder = OutlineInputBorder( @@ -149,11 +154,11 @@ class _AuthFields extends StatelessWidget { ); return InputDecoration( hintText: hint, - hintStyle: const TextStyle(color: AppColors.muted, fontSize: 14), + hintStyle: const TextStyle(color: AppColors.subtle, fontSize: 14), prefixIcon: Icon( icon, size: 21, - color: focused ? AppColors.blueSoft : AppColors.muted, + color: focused ? AppColors.blue : AppColors.muted, ), prefixIconConstraints: const BoxConstraints( minWidth: _minTapTarget, @@ -161,7 +166,7 @@ class _AuthFields extends StatelessWidget { ), suffixIcon: suffix, filled: true, - fillColor: AppColors.bar, + fillColor: AppColors.card, isDense: true, contentPadding: const EdgeInsets.only(right: 16), border: border, @@ -229,8 +234,8 @@ class _AuthSubmitButton extends StatelessWidget { : Text( status.signUpMode ? l10n.signUp : l10n.signIn, style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, + fontSize: 15, + fontWeight: FontWeight.w800, ), ), ), @@ -273,7 +278,7 @@ class _ModeSegment extends StatelessWidget { child: Text( label, style: TextStyle( - color: selected ? AppColors.onPrimary : AppColors.muted, + color: selected ? AppColors.onPrimary : AppColors.onSurface, fontSize: 14, fontWeight: FontWeight.w700, ), @@ -327,29 +332,11 @@ class _AuthBrand extends StatelessWidget { ); } - Widget _brandMark() => Container( + Widget _brandMark() => _brandMarkWidget( key: const Key('auth-brand-mark'), - width: _brandMarkSize, - height: _brandMarkSize, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), - boxShadow: [ - BoxShadow( - color: AppColors.blue.withValues(alpha: 0.32), - blurRadius: 24, - offset: const Offset(0, 10), - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(14), - child: Image.asset( - 'assets/icon/splash_mark.png', - width: _brandMarkSize, - height: _brandMarkSize, - fit: BoxFit.cover, - ), - ), + size: compact ? _compactBrandMarkSize : _brandMarkSize, + radius: compact ? _compactBrandMarkRadius : _brandMarkRadius, + iconSize: compact ? _compactBrandIconSize : _brandIconSize, ); Widget _brandCopy(BuildContext context, {required bool centered}) => Column( @@ -361,9 +348,9 @@ class _AuthBrand extends StatelessWidget { AppLocalizations.of(context)!.appName, textAlign: centered ? TextAlign.center : TextAlign.start, style: const TextStyle( - color: AppColors.onPrimary, - fontSize: 26, - fontWeight: FontWeight.w800, + color: AppColors.onSurface, + fontSize: 24, + fontWeight: FontWeight.w900, height: 1.1, ), ), @@ -372,8 +359,8 @@ class _AuthBrand extends StatelessWidget { tagline, textAlign: centered ? TextAlign.center : TextAlign.start, style: const TextStyle( - color: AppColors.blueSoft, - fontSize: 14, + color: AppColors.muted, + fontSize: 13, height: 1.35, ), ), @@ -406,7 +393,7 @@ class _AuthError extends StatelessWidget { child: Text( message, key: const Key('auth-error'), - style: TextStyle(color: color, fontSize: 13, height: 1.35), + style: TextStyle(color: color, fontSize: 12, height: 1.35), ), ), ], diff --git a/lib/pages/auth/auth_view.dart b/lib/pages/auth/auth_view.dart index 18345a7..3fe70d9 100644 --- a/lib/pages/auth/auth_view.dart +++ b/lib/pages/auth/auth_view.dart @@ -11,6 +11,7 @@ import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; +import '../../core/widgets/subpage_header.dart'; import '../../l10n/app_localizations.dart'; import 'auth_logic.dart'; import 'phone_auth_sheet.dart'; @@ -19,18 +20,21 @@ part 'auth_widgets.dart'; part 'auth_form_widgets.dart'; const double _minTapTarget = 44; -const double _brandMarkSize = 48; +const double _brandMarkSize = 72; +const double _compactBrandMarkSize = 48; +const double _brandMarkRadius = 20; +const double _compactBrandMarkRadius = 14; +const double _brandIconSize = 36; +const double _compactBrandIconSize = 26; const double _contentMaxWidth = 440; // Auth keeps its approved 20px card silhouette. const double _cardRadius = AppDimens.authCardRadius; -const double _cardPadding = AppDimens.cardPadding; -const double _cardBorderWidth = 1; +const double _cardPadding = 20; 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 _fieldBorderWidth = 1; +const double _buttonHeight = 50; +const double _buttonRadius = 25; const double _errorOpacity = 0.10; const double _errorBorderOpacity = 0.28; const double _enterSlideDistance = 16; @@ -39,11 +43,48 @@ const double _bottomContentPadding = 28; const double _compactTopContentPadding = 48; const double _compactHeightBreakpoint = 720; const double _compactBrandGap = 16; -const double _warmGlowSize = 260; -const double _warmGlowOpacity = 0.06; +const double _glowSize = 280; +const double _warmGlowOpacity = 0.18; +const double _entryButtonHeight = 50; +const double _entryButtonRadius = 25; +const double _entryButtonFontSize = 15; +const double _entryIconSize = 20; const Duration _enterDuration = Duration(milliseconds: 220); const Duration _segmentDuration = Duration(milliseconds: 160); +Widget _brandMarkWidget({ + Key? key, + required double size, + required double radius, + required double iconSize, +}) { + return Container( + key: key, + width: size, + height: size, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(radius), + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.blue, AppColors.secondary], + ), + boxShadow: [ + BoxShadow( + color: AppColors.blue.withValues(alpha: 0.24), + blurRadius: 20, + offset: const Offset(0, 8), + ), + ], + ), + child: Icon( + Icons.play_arrow_rounded, + color: AppColors.onPrimary, + size: iconSize, + ), + ); +} + /// 登录 / 注册页:邮箱 + 密码。 class AuthPage extends StatefulWidget { const AuthPage({super.key, this.signUpMode = false}); @@ -147,34 +188,39 @@ class _AuthPageState extends State { backgroundColor: AppColors.ink, body: Stack( children: [ + const Positioned.fill( + child: IgnorePointer( + child: DecoratedBox( + key: Key('auth-page-background'), + decoration: BoxDecoration(gradient: AppTheme.pageGradient), + ), + ), + ), Positioned( - top: 0, - left: 0, - right: 0, - height: 420, + top: -90, + right: -70, + width: _glowSize, + height: _glowSize, child: IgnorePointer( child: Container( key: const Key('auth-background-glow'), decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, + shape: BoxShape.circle, + gradient: RadialGradient( colors: [ - AppColors.blue.withValues(alpha: _glowOpacity), - AppColors.blue.withValues(alpha: 0.05), - AppColors.ink.withValues(alpha: 0), + AppColors.surfaceTop, + AppColors.surfaceTop.withValues(alpha: 0), ], - stops: const [0, 0.55, 1], ), ), ), ), ), Positioned( - top: 72, - right: -80, - width: _warmGlowSize, - height: _warmGlowSize, + left: -110, + bottom: -90, + width: _glowSize, + height: _glowSize, child: IgnorePointer( child: Container( key: const Key('auth-background-warm-glow'), @@ -239,23 +285,16 @@ class _AuthPageState extends State { child: Align( alignment: Alignment.topLeft, child: Padding( - padding: const EdgeInsets.only(left: 4, top: 4), - child: IconButton( - key: const Key('auth-back'), - tooltip: MaterialLocalizations.of( - context, - ).backButtonTooltip, - onPressed: () { + padding: const EdgeInsets.only(left: 14, top: 8), + child: AppSubpageBackButton( + backKey: const Key('auth-back'), + onBack: () { if (_emailEntryOpen) { setState(() => _emailEntryOpen = false); } else { Navigator.of(context).pop(); } }, - icon: const Icon( - Icons.arrow_back_rounded, - color: AppColors.onPrimary, - ), ), ), ), @@ -298,58 +337,44 @@ class _AuthPageState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Center( - child: Container( + child: _brandMarkWidget( key: const Key('auth-brand-logo'), - width: 64, - height: 64, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(18), - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [AppColors.blue, AppColors.secondary], - ), - ), - child: const Icon( - Icons.play_arrow_rounded, - color: AppColors.onPrimary, - size: 32, - ), + size: _brandMarkSize, + radius: _brandMarkRadius, + iconSize: _brandIconSize, ), ), - const SizedBox(height: 14), + const SizedBox(height: 16), Text( l10n.fruitShortsBrand, textAlign: TextAlign.center, style: const TextStyle( fontFamily: 'Outfit', color: AppColors.onSurface, - fontSize: 20, - fontWeight: FontWeight.w800, + fontSize: 24, + fontWeight: FontWeight.w900, ), ), const SizedBox(height: 6), Text( l10n.authLandingSubtitle, textAlign: TextAlign.center, - style: const TextStyle(color: AppColors.subtle, fontSize: 11), + style: const TextStyle(color: AppColors.muted, fontSize: 13), ), - const SizedBox(height: 22), + const SizedBox(height: 28), _AuthEntryButton( key: const Key('auth-entry-phone'), label: l10n.authPhoneSignIn, icon: Icons.phone_android_rounded, - background: AppColors.input, - foreground: AppColors.onSurface, + primary: true, onTap: _openPhoneSheet, ), - const SizedBox(height: 11), + const SizedBox(height: 12), _AuthEntryButton( key: const Key('auth-entry-email'), label: l10n.authEmailSignIn, icon: Icons.mail_outline_rounded, - background: AppColors.input, - foreground: AppColors.onSurface, + primary: false, onTap: () => setState(() => _emailEntryOpen = true), ), const SizedBox(height: 18), @@ -417,33 +442,40 @@ class _AuthEntryButton extends StatelessWidget { const _AuthEntryButton({ super.key, required this.label, - required this.background, - required this.foreground, + required this.primary, required this.onTap, this.icon, }); final String label; - final Color background; - final Color foreground; + final bool primary; final VoidCallback onTap; final IconData? icon; @override Widget build(BuildContext context) { + final background = primary ? AppColors.blue : AppColors.card; + final foreground = primary ? AppColors.onPrimary : AppColors.onSurface; return Material( color: background, - borderRadius: BorderRadius.circular(9), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(_entryButtonRadius), + side: primary + ? BorderSide.none + : const BorderSide(color: AppColors.border), + ), clipBehavior: Clip.antiAlias, child: InkWell( onTap: onTap, + borderRadius: BorderRadius.circular(_entryButtonRadius), child: SizedBox( - height: 44, + height: _entryButtonHeight, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - if (icon != null) Icon(icon, size: 16, color: foreground), - if (icon != null) const SizedBox(width: 7), + if (icon != null) + Icon(icon, size: _entryIconSize, color: foreground), + if (icon != null) const SizedBox(width: 8), Flexible( child: Text( label, @@ -451,8 +483,8 @@ class _AuthEntryButton extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( color: foreground, - fontSize: 13, - fontWeight: FontWeight.w600, + fontSize: _entryButtonFontSize, + fontWeight: FontWeight.w800, ), ), ), diff --git a/lib/pages/auth/auth_widgets.dart b/lib/pages/auth/auth_widgets.dart index 3934899..9355158 100644 --- a/lib/pages/auth/auth_widgets.dart +++ b/lib/pages/auth/auth_widgets.dart @@ -76,10 +76,7 @@ class _AuthCard extends StatelessWidget { decoration: BoxDecoration( color: AppColors.card, borderRadius: BorderRadius.circular(_cardRadius), - border: Border.all( - color: AppColors.outlineVariantDark, - width: _cardBorderWidth, - ), + boxShadow: const [AppTheme.cardShadow], ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -94,7 +91,7 @@ class _AuthCard extends StatelessWidget { Text( status.signUpMode ? l10n.authSignUpTitle : l10n.authSignInTitle, style: const TextStyle( - color: AppColors.onPrimary, + color: AppColors.onSurface, fontSize: 25, fontWeight: FontWeight.w800, height: 1.2, @@ -149,12 +146,11 @@ class _AuthModeSwitch extends StatelessWidget { shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)), ), child: Container( - height: _minTapTarget, - padding: const EdgeInsets.all(3), + height: 36, + padding: const EdgeInsets.all(2), decoration: BoxDecoration( - color: AppColors.bar, - borderRadius: BorderRadius.circular(22), - border: Border.all(color: AppColors.outlineVariantDark), + color: AppColors.raised, + borderRadius: BorderRadius.circular(18), ), child: Row( children: [ diff --git a/test/auth/auth_k21_test.dart b/test/auth/auth_k21_test.dart index 6312819..52612e9 100644 --- a/test/auth/auth_k21_test.dart +++ b/test/auth/auth_k21_test.dart @@ -53,7 +53,7 @@ void main() { final errorBorder = email.decoration!.errorBorder! as OutlineInputBorder; final scheme = Theme.of(tester.element(emailFinder)).colorScheme; expect(errorBorder.borderSide.color, scheme.error); - expect(errorBorder.borderSide.width, 1.5); + expect(errorBorder.borderSide.width, 1); }); } diff --git a/test/auth/auth_visual_test.dart b/test/auth/auth_visual_test.dart index ae17115..192ed41 100644 --- a/test/auth/auth_visual_test.dart +++ b/test/auth/auth_visual_test.dart @@ -13,6 +13,7 @@ import 'package:fruitshorts/core/data/services/auth_controller.dart'; import 'package:fruitshorts/core/data/services/auth_session_store.dart'; import 'package:fruitshorts/core/platform/haptics.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; +import 'package:fruitshorts/core/theme/app_theme.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/auth/auth_view.dart'; @@ -30,6 +31,7 @@ void main() { AuthApi? api, FakeHaptics? haptics, bool openEmail = true, + bool asRoute = false, }) async { final auth = AuthController( api ?? @@ -53,11 +55,15 @@ void main() { locale: const Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, - home: const AuthPage(), + home: asRoute ? const _AuthRouteHost() : const AuthPage(), ), ), ); await tester.pump(const Duration(milliseconds: 220)); + if (asRoute) { + await tester.tap(find.byKey(const Key('auth-open'))); + await tester.pumpAndSettle(); + } if (openEmail) { await tester.ensureVisible(find.byKey(const Key('auth-entry-email'))); await tester.tap(find.byKey(const Key('auth-entry-email'))); @@ -78,21 +84,46 @@ void main() { ); expect( tester.getSize(find.byKey(const Key('auth-brand-logo'))), - const Size(64, 64), + const Size(72, 72), ); expect(find.byKey(const Key('auth-entry-google')), findsNothing); expect(find.byKey(const Key('auth-entry-phone')), findsOneWidget); expect(find.byKey(const Key('auth-entry-email')), findsOneWidget); expect(find.byKey(const Key('auth-legal')), findsOneWidget); + expect( + tester.getSize(find.byKey(const Key('auth-entry-phone'))).height, + 50, + ); + expect( + tester.getSize(find.byKey(const Key('auth-entry-email'))).height, + 50, + ); + final phoneButton = tester.widget( + find.descendant( + of: find.byKey(const Key('auth-entry-phone')), + matching: find.byType(Material), + ), + ); + expect(phoneButton.color, AppColors.blue); + final emailButton = tester.widget( + find.descendant( + of: find.byKey(const Key('auth-entry-email')), + matching: find.byType(Material), + ), + ); + expect(emailButton.color, AppColors.card); + expect( + (emailButton.shape! as RoundedRectangleBorder).side.color, + AppColors.border, + ); 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 gradient = glowDecoration.gradient! as RadialGradient; + expect(gradient.colors.first, AppColors.surfaceTop); + expect(gradient.colors.last.a, 0); await tester.tap(find.byKey(const Key('auth-entry-email'))); await tester.pumpAndSettle(); @@ -101,19 +132,28 @@ void main() { 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); + expect(cardDecoration.boxShadow, contains(AppTheme.cardShadow)); + expect(card.padding, const EdgeInsets.all(20)); final email = tester.widget( find.byKey(const Key('auth-email')), ); - expect(email.decoration?.fillColor, AppColors.bar); + expect(email.decoration?.fillColor, AppColors.card); + expect(email.style?.color, AppColors.onSurface); + expect(email.style?.fontSize, 15); + expect(email.cursorColor, AppColors.blue); expect(tester.getSize(find.byKey(const Key('auth-email'))).height, 52); - final emailBorder = email.decoration!.border! as OutlineInputBorder; + final emailBorder = + email.decoration!.enabledBorder! as OutlineInputBorder; expect(emailBorder.borderRadius, BorderRadius.circular(14)); + expect(emailBorder.borderSide.color, AppColors.border); + expect(emailBorder.borderSide.width, 1); + final password = tester.widget( + find.byKey(const Key('auth-password')), + ); + expect(password.style?.color, AppColors.onSurface); + expect(password.style?.fontSize, 15); + expect(password.cursorColor, AppColors.blue); final submit = tester.widget( find.byKey(const Key('auth-submit')), @@ -123,11 +163,11 @@ void main() { submit.style!.backgroundColor!.resolve(const {WidgetState.pressed}), AppColors.bluePressed, ); - expect(tester.getSize(find.byKey(const Key('auth-submit'))).height, 52); + expect(tester.getSize(find.byKey(const Key('auth-submit'))).height, 50); expect( (submit.style!.shape!.resolve(const {})! as RoundedRectangleBorder) .borderRadius, - BorderRadius.circular(26), + BorderRadius.circular(25), ); expect(find.text('Sign In'), findsWidgets); @@ -139,6 +179,23 @@ void main() { }, ); + testWidgets('should_render_40px_back_button_when_page_is_pushed', ( + tester, + ) async { + await pumpAuthPage(tester, openEmail: false, asRoute: true); + + final back = find.byKey(const Key('auth-back')); + expect(tester.getSize(back), const Size(40, 40)); + expect( + tester + .widget( + find.descendant(of: back, matching: find.byType(Material)), + ) + .color, + AppColors.card, + ); + }); + testWidgets('should_fade_and_slide_content_in_within_220ms', (tester) async { await pumpAuthPage(tester); @@ -198,10 +255,10 @@ void main() { final focusedBorder = email.decoration!.focusedBorder! as OutlineInputBorder; expect(focusedBorder.borderSide.color, AppColors.blue); - expect(focusedBorder.borderSide.width, 1.5); + expect(focusedBorder.borderSide.width, 1); expect( tester.widget(find.byIcon(Icons.mail_outline_rounded)).color, - AppColors.blueSoft, + AppColors.blue, ); await tester.tap(find.byKey(const Key('auth-password-visibility'))); @@ -280,6 +337,7 @@ void main() { final error = tester.widget(errorFinder); final scheme = Theme.of(tester.element(errorFinder)).colorScheme; expect(error.style?.color, scheme.error); + expect(error.style?.fontSize, 12); expect( find.descendant( of: capsuleFinder, @@ -325,3 +383,22 @@ class _PendingAuthApi extends AuthApi { throw const ApiException(500, 'pending failure'); } } + +class _AuthRouteHost extends StatelessWidget { + const _AuthRouteHost(); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: TextButton( + key: const Key('auth-open'), + onPressed: () => Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const AuthPage())), + child: const Text('Open auth'), + ), + ), + ); + } +}