// 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'; const double _minTapTarget = 44; /// 登录 / 注册页:邮箱 + 密码。 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, 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, ), ), 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, ), ), const SizedBox(height: 2), Text( tagline, style: const TextStyle(color: AppColors.blueSoft, fontSize: 12), ), ], ), ), ], ); } } 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 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), ), ), ], ); } }