// 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_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 { 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; @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 { 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) { setState(() => _logic.errorMessage = error.message); } catch (_) { 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], ), ), ), ), ), 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), ], ), ), ), ), ], ), ), ), ], ), ), ); } 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, ), ); void _selectMode(bool signUpMode) { if (_logic.busy || _logic.signUpMode == signUpMode) return; setState(() => _logic.toggleMode()); } }