// 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_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'; part 'auth_widgets.dart'; part 'auth_form_widgets.dart'; const double _minTapTarget = 44; 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 = 20; const double _fieldHeight = 52; const double _fieldRadius = 14; 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; const double _topContentPadding = 64; const double _bottomContentPadding = 28; const double _compactTopContentPadding = 48; const double _compactHeightBreakpoint = 720; const double _compactBrandGap = 16; 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}); final bool signUpMode; @override State createState() => _AuthPageState(); } 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; bool _emailEntryOpen = false; @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) { switch (error) { case AuthFieldError.emailRequired: return l10n.authEmailRequired; case AuthFieldError.emailInvalid: return l10n.authEmailInvalid; case AuthFieldError.passwordRequired: return l10n.authPasswordRequired; case AuthFieldError.passwordLength: return l10n.authPasswordLength; case AuthFieldError.passwordWeak: return l10n.authPasswordWeak; } } Future _submit() async { if (_logic.busy) return; final l10n = AppLocalizations.of(context)!; final invalid = _logic.validate(); if (invalid != null) { setState(() => _logic.errorMessage = _errorText(l10n, invalid)); return; } unawaited(context.read().lightImpact()); FocusScope.of(context).unfocus(); setState(() { _logic.busy = true; _logic.errorMessage = null; }); final auth = context.read(); try { if (_logic.signUpMode) { await auth.register( email: _logic.normalizedEmail, password: _logic.password, ); } else { await auth.signIn( email: _logic.normalizedEmail, password: _logic.password, ); } if (!mounted) return; Navigator.of(context).maybePop(); } on ApiException catch (error) { if (!mounted) return; setState(() => _logic.errorMessage = error.message); } catch (_) { if (!mounted) return; setState(() => _logic.errorMessage = l10n.authNetworkError); } finally { if (mounted) setState(() => _logic.busy = false); } } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; return Theme( data: AppTheme.light(), child: Scaffold( resizeToAvoidBottomInset: false, 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: -90, right: -70, width: _glowSize, height: _glowSize, child: IgnorePointer( child: Container( key: const Key('auth-background-glow'), decoration: BoxDecoration( shape: BoxShape.circle, gradient: RadialGradient( colors: [ AppColors.surfaceTop, AppColors.surfaceTop.withValues(alpha: 0), ], ), ), ), ), ), Positioned( left: -110, bottom: -90, width: _glowSize, height: _glowSize, child: IgnorePointer( child: Container( key: const Key('auth-background-warm-glow'), decoration: BoxDecoration( shape: BoxShape.circle, gradient: RadialGradient( colors: [ AppColors.gold.withValues(alpha: _warmGlowOpacity), AppColors.gold.withValues(alpha: 0), ], ), ), ), ), ), SafeArea( child: LayoutBuilder( builder: (context, constraints) { final compact = constraints.maxHeight < _compactHeightBreakpoint; final top = compact ? _compactTopContentPadding : _topContentPadding; final bottom = _bottomContentPadding + MediaQuery.viewInsetsOf(context).bottom; final available = constraints.maxHeight - top - bottom; final minHeight = available > 0 ? available : 0.0; return AutofillGroup( child: SingleChildScrollView( key: const Key('auth-scroll'), keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, padding: EdgeInsets.fromLTRB( AppDimens.pageHorizontal, top, AppDimens.pageHorizontal, bottom, ), child: ConstrainedBox( constraints: BoxConstraints(minHeight: minHeight), child: Center( child: ConstrainedBox( constraints: const BoxConstraints( maxWidth: _contentMaxWidth, ), child: _content(l10n, compact: compact), ), ), ), ), ); }, ), ), if (Navigator.of(context).canPop()) Positioned( top: 0, left: 0, right: 0, child: SafeArea( child: Align( alignment: Alignment.topLeft, child: Padding( 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(); } }, ), ), ), ), ), ], ), ), ); } Widget _content(AppLocalizations l10n, {required bool compact}) => 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: _emailEntryOpen ? Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _AuthBrand(tagline: l10n.authBrandTagline, compact: compact), SizedBox(height: compact ? _compactBrandGap : 28), _authCard(l10n), ], ) : _landing(l10n), ); Widget _landing(AppLocalizations l10n) => Column( key: const Key('auth-landing'), mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Center( child: _brandMarkWidget( key: const Key('auth-brand-logo'), size: _brandMarkSize, radius: _brandMarkRadius, iconSize: _brandIconSize, ), ), const SizedBox(height: 16), Text( l10n.fruitShortsBrand, textAlign: TextAlign.center, style: const TextStyle( fontFamily: 'Outfit', color: AppColors.onSurface, fontSize: 24, fontWeight: FontWeight.w900, ), ), const SizedBox(height: 6), Text( l10n.authLandingSubtitle, textAlign: TextAlign.center, style: const TextStyle(color: AppColors.muted, fontSize: 13), ), const SizedBox(height: 28), _AuthEntryButton( key: const Key('auth-entry-phone'), label: l10n.authPhoneSignIn, icon: Icons.phone_android_rounded, primary: true, onTap: _openPhoneSheet, ), const SizedBox(height: 12), _AuthEntryButton( key: const Key('auth-entry-email'), label: l10n.authEmailSignIn, icon: Icons.mail_outline_rounded, primary: false, onTap: () => setState(() => _emailEntryOpen = true), ), const SizedBox(height: 18), Text( l10n.authLegal, key: const Key('auth-legal'), textAlign: TextAlign.center, style: const TextStyle( color: AppColors.muted, fontSize: 9, height: 1.6, ), ), ], ); Future _openPhoneSheet() async { final signedIn = await showPhoneAuthSheet( context, signUp: _logic.signUpMode, ); if (signedIn == true && mounted) Navigator.of(context).maybePop(); } Widget _authCard(AppLocalizations l10n) => _AuthCard( state: _AuthCardState( l10n: l10n, status: _AuthFormStatus( signUpMode: _logic.signUpMode, busy: _logic.busy, errorMessage: _logic.errorMessage, fieldError: _logic.fieldError, obscurePassword: _obscurePassword, ), fields: _AuthFieldControllers( email: _emailController, password: _passwordController, emailFocus: _emailFocus, passwordFocus: _passwordFocus, ), ), callbacks: _AuthCardCallbacks( onModeSelected: _selectMode, onEmailChanged: (value) => setState(() { _logic.email = value; _logic.clearFieldError(); }), onPasswordChanged: (value) => setState(() { _logic.password = value; _logic.clearFieldError(); }), onTogglePasswordVisibility: () => setState(() => _obscurePassword = !_obscurePassword), onSubmit: _submit, ), ); void _selectMode(bool signUpMode) { if (_logic.busy || _logic.signUpMode == signUpMode) return; setState(() => _logic.toggleMode()); } } class _AuthEntryButton extends StatelessWidget { const _AuthEntryButton({ super.key, required this.label, required this.primary, required this.onTap, this.icon, }); final String label; 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, 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: _entryButtonHeight, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ if (icon != null) Icon(icon, size: _entryIconSize, color: foreground), if (icon != null) const SizedBox(width: 8), Flexible( child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: foreground, fontSize: _entryButtonFontSize, fontWeight: FontWeight.w800, ), ), ), ], ), ), ), ); } }