import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../core/l10n/app_localizations.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/utils/top_toast.dart'; import '../../../providers/auth_provider.dart'; import 'auth_widgets.dart'; import 'verify_code_screen.dart'; class LoginScreen extends ConsumerStatefulWidget { const LoginScreen({super.key}); @override ConsumerState createState() => _LoginScreenState(); } class _LoginScreenState extends ConsumerState { final _emailController = TextEditingController(); final _passwordController = TextEditingController(); final _emailFocusNode = FocusNode(); final _passwordFocusNode = FocusNode(); bool _obscurePassword = true; bool _emailTouched = false; bool _passwordTouched = false; // ── 字符过滤正则(仅允许数字、字母及常用特殊符号)────────── static final _emailCharRegex = RegExp(r'[a-zA-Z0-9@._%+\-]'); static final _passwordCharRegex = RegExp(r'[a-zA-Z0-9!@#$%^&*()_\-+={};:",.<>/?~\[\]]'); // ── 邮箱格式校验 ───────────────────────────────────────── static final _emailRegex = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$'); bool get _emailValid => _emailRegex.hasMatch(_emailController.text.trim()); // ── 密码规则校验 (与 Web 端 validateLoginPasswordRule 一致) ── bool get _passwordValid { final pwd = _passwordController.text; if (pwd.isEmpty) return false; if (pwd.length < 6 || pwd.length > 16) return false; if (pwd.contains(RegExp(r'\s'))) return false; if (!RegExp(r'(?=.*[a-zA-Z])(?=.*\d)').hasMatch(pwd)) return false; return true; } bool get _canSubmit => _emailValid && _passwordValid; @override void initState() { super.initState(); _emailFocusNode.addListener(() { if (!_emailFocusNode.hasFocus && _emailController.text.isNotEmpty) { setState(() => _emailTouched = true); } }); _passwordFocusNode.addListener(() { if (!_passwordFocusNode.hasFocus && _passwordController.text.isNotEmpty) { setState(() => _passwordTouched = true); } }); } @override void dispose() { _emailController.dispose(); _passwordController.dispose(); _emailFocusNode.dispose(); _passwordFocusNode.dispose(); super.dispose(); } /// 邮箱脱敏显示 String get _maskedEmail { final email = _emailController.text.trim(); if (!email.contains('@')) return email; final parts = email.split('@'); final name = parts[0]; if (name.length <= 1) return '$name***@${parts[1]}'; return '${name[0]}***@${parts[1]}'; } /// 密码校验错误信息(与 Web 端 validateLoginPasswordRule 一致) String? _getPasswordError(AppLocalizations l10n) { final pwd = _passwordController.text; if (pwd.isEmpty) return null; if (pwd.length < 6 || pwd.length > 16) return l10n.passwordLengthError; if (pwd.contains(RegExp(r'\s'))) return l10n.passwordSpaceError; if (!RegExp(r'(?=.*[a-zA-Z])(?=.*\d)').hasMatch(pwd)) { return l10n.passwordLetterDigitError; } return null; } /// 点击登录 → 先发送验证码,成功后再跳转验证码页面 Future _handleLoginTap() async { final email = _emailController.text.trim(); if (email.isEmpty || _passwordController.text.isEmpty) return; if (!_canSubmit) return; final ok = await ref.read(authProvider.notifier).sendLoginCode( email: email, password: _passwordController.text, ); if (!ok || !mounted) return; context.push( '/verify-code', extra: VerifyCodeArgs( email: email, password: _passwordController.text, maskedEmail: _maskedEmail, codeSent: true, ), ); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final state = ref.watch(authProvider); final cs = Theme.of(context).colorScheme; ref.listen(authProvider, (prev, next) { if (next.errorMessage != null && next.errorMessage != prev?.errorMessage) { final code = next.errorMessage!; WidgetsBinding.instance.addPostFrameCallback((_) { if (!context.mounted) return; final l10n = AppLocalizations.of(context)!; showTopToast( context, message: resolveProviderError(code, l10n) ?? code, ); ref.read(authProvider.notifier).clearError(); }); } }); final isDark = Theme.of(context).brightness == Brightness.dark; return Scaffold( appBar: AppBar( elevation: 0, leading: IconButton( icon: const Icon(Icons.chevron_left, size: 28), onPressed: () => context.go('/'), ), ), body: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(24, 16, 24, 40), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 标题区 Text( l10n.welcomeBack, style: TextStyle( color: cs.onSurface, fontSize: 28, fontWeight: FontWeight.w700, letterSpacing: -0.5, ), ), const SizedBox(height: 6), Text( l10n.loginSubtitle, style: TextStyle( color: cs.onSurface.withAlpha(120), fontSize: 14, ), ), const SizedBox(height: 40), // 邮箱字段 Text( l10n.email, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 13, fontWeight: FontWeight.w500, ), ), const SizedBox(height: 8), AuthField( controller: _emailController, hint: l10n.emailHint, keyboardType: TextInputType.emailAddress, maxLength: 100, focusNode: _emailFocusNode, inputFormatters: [ FilteringTextInputFormatter.allow(_emailCharRegex), ], onChanged: (_) => setState(() { _emailTouched = false; }), errorText: _emailTouched && !_emailValid ? l10n.emailError : null, semanticsLabel: 'login_input_email', ), const SizedBox(height: 20), // 密码字段 Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( l10n.password, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 13, fontWeight: FontWeight.w500, ), ), Semantics( label: 'login_link_forgot_password', child: GestureDetector( onTap: () => context.push('/forgot-password'), child: Text( l10n.forgotPassword, style: TextStyle( color: AppColors.brand, fontSize: 13, fontWeight: FontWeight.w500, ), ), ), ), ], ), const SizedBox(height: 8), AuthField( controller: _passwordController, hint: l10n.loginPasswordHint, obscure: _obscurePassword, maxLength: 16, focusNode: _passwordFocusNode, inputFormatters: [ FilteringTextInputFormatter.allow(_passwordCharRegex), ], onChanged: (_) => setState(() {}), errorText: _passwordTouched ? _getPasswordError(l10n) : null, semanticsLabel: 'login_input_password', suffixIcon: IconButton( onPressed: () => setState(() => _obscurePassword = !_obscurePassword), icon: Icon( _obscurePassword ? Icons.visibility_off_outlined : Icons.visibility_outlined, color: cs.onSurface.withAlpha(120), size: 20, ), ), ), const SizedBox(height: 40), // 登录按钮 Semantics( label: 'login_btn_submit', button: true, enabled: _canSubmit && !state.isLoading, onTap: (_canSubmit && !state.isLoading) ? _handleLoginTap : null, child: SizedBox( width: double.infinity, height: 50, child: ElevatedButton( onPressed: (_canSubmit && !state.isLoading) ? _handleLoginTap : null, style: ElevatedButton.styleFrom( backgroundColor: AppColors.brand, foregroundColor: Colors.black, disabledBackgroundColor: isDark ? AppColors.darkBgTertiary : AppColors.lightBgTertiary, disabledForegroundColor: isDark ? AppColors.darkTextDisabled : AppColors.lightTextDisabled, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), ), child: state.isLoading ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.black, ), ) : Text( l10n.login, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, ), ), ), ), ), const SizedBox(height: 32), // 注册引导 Center( child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( l10n.noAccount, style: TextStyle( color: cs.onSurface.withAlpha(120), fontSize: 14, ), ), Semantics( label: 'auth_link_register', child: GestureDetector( onTap: () => context.push('/register'), child: Text( l10n.registerNow, style: const TextStyle( color: AppColors.brand, fontSize: 14, fontWeight: FontWeight.w600, ), ), ), ), ], ), ), ], ), ), ); } }