// 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 '../../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; // Auth keeps its approved 20px card silhouette. const double _cardRadius = AppDimens.authCardRadius; const double _cardPadding = AppDimens.cardPadding; 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 double _topContentPadding = 64; 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 Duration _enterDuration = Duration(milliseconds: 220); const Duration _segmentDuration = Duration(milliseconds: 160); /// 登录 / 注册页:邮箱 + 密码。 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.dark(), child: Scaffold( resizeToAvoidBottomInset: true, backgroundColor: AppColors.ink, 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], ), ), ), ), ), Positioned( top: 72, right: -80, width: _warmGlowSize, height: _warmGlowSize, 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: 4, top: 4), child: IconButton( key: const Key('auth-back'), tooltip: MaterialLocalizations.of( context, ).backButtonTooltip, onPressed: () { if (_emailEntryOpen) { setState(() => _emailEntryOpen = false); } else { Navigator.of(context).pop(); } }, icon: const Icon( Icons.arrow_back_rounded, color: AppColors.onPrimary, ), ), ), ), ), ), ], ), ), ); } 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: Container( 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, ), ), ), const SizedBox(height: 14), Text( l10n.fruitShortsBrand, textAlign: TextAlign.center, style: const TextStyle( fontFamily: 'Outfit', color: AppColors.onSurface, fontSize: 20, fontWeight: FontWeight.w800, ), ), const SizedBox(height: 6), Text( l10n.authLandingSubtitle, textAlign: TextAlign.center, style: const TextStyle(color: Color(0xFF9A96A3), fontSize: 11), ), const SizedBox(height: 22), _AuthEntryButton( key: const Key('auth-entry-google'), label: 'G ${l10n.authContinueGoogle}', background: Colors.white, foreground: const Color(0xFF1A1A1A), leadingColor: const Color(0xFF4285F4), onTap: () => _comingSoon(l10n), ), const SizedBox(height: 11), _AuthEntryButton( key: const Key('auth-entry-phone'), label: l10n.authPhoneSignIn, icon: Icons.phone_android_rounded, background: AppColors.input, foreground: AppColors.onSurface, onTap: () => _comingSoon(l10n), ), const SizedBox(height: 11), _AuthEntryButton( key: const Key('auth-entry-email'), label: l10n.authEmailSignIn, icon: Icons.mail_outline_rounded, background: AppColors.input, foreground: AppColors.onSurface, onTap: () => setState(() => _emailEntryOpen = true), ), const SizedBox(height: 18), Text( l10n.authLegal, key: const Key('auth-legal'), textAlign: TextAlign.center, style: const TextStyle( color: Color(0xFF6B6772), fontSize: 9, height: 1.6, ), ), ], ); void _comingSoon(AppLocalizations l10n) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(l10n.comingSoon))); } 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.background, required this.foreground, required this.onTap, this.icon, this.leadingColor, }); final String label; final Color background; final Color foreground; final VoidCallback onTap; final IconData? icon; final Color? leadingColor; @override Widget build(BuildContext context) { return Material( color: background, borderRadius: BorderRadius.circular(9), clipBehavior: Clip.antiAlias, child: InkWell( onTap: onTap, child: SizedBox( height: 44, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ if (icon != null) Icon(icon, size: 16, color: leadingColor ?? foreground), if (icon != null) const SizedBox(width: 7), Flexible( child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: foreground, fontSize: 13, fontWeight: FontWeight.w600, ), ), ), ], ), ), ), ); } }