import 'package:flutter/material.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'; /// 忘记密码页面(单页:邮箱 + 验证码 + 新密码 + 确认密码) class ForgotPasswordScreen extends ConsumerStatefulWidget { const ForgotPasswordScreen({super.key}); @override ConsumerState createState() => _ForgotPasswordScreenState(); } class _ForgotPasswordScreenState extends ConsumerState { final _emailController = TextEditingController(); final _codeController = TextEditingController(); final _passwordController = TextEditingController(); final _confirmController = TextEditingController(); final _emailFocusNode = FocusNode(); bool _obscurePassword = true; bool _obscureConfirm = true; bool _emailTouched = false; static final _emailRegex = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$'); bool get _emailValid => _emailRegex.hasMatch(_emailController.text.trim()); bool get _has6to16 => _passwordController.text.length >= 6 && _passwordController.text.length <= 16; bool get _hasDigit => RegExp(r'[0-9]').hasMatch(_passwordController.text); bool get _hasLetter => RegExp(r'[a-zA-Z]').hasMatch(_passwordController.text); bool get _passwordValid => _has6to16 && _hasDigit && _hasLetter; bool get _pwdTyped => _passwordController.text.isNotEmpty; bool get _passwordsMatch => _passwordController.text == _confirmController.text && _confirmController.text.isNotEmpty; bool get _codeFilled => _codeController.text.trim().length == 6; bool get _canSubmit => _emailValid && _codeFilled && _passwordValid && _passwordsMatch; @override void initState() { super.initState(); _emailFocusNode.addListener(() { if (!_emailFocusNode.hasFocus && _emailController.text.isNotEmpty) { setState(() => _emailTouched = true); } }); } @override void dispose() { _emailController.dispose(); _codeController.dispose(); _passwordController.dispose(); _confirmController.dispose(); _emailFocusNode.dispose(); super.dispose(); } Future _handleSendCode() async { final email = _emailController.text.trim(); if (email.isEmpty) return; await ref.read(authProvider.notifier).sendResetCode(email: email); } Future _handleResetPassword() async { if (!_canSubmit) return; final l10n = AppLocalizations.of(context)!; final notifier = ref.read(authProvider.notifier); notifier.setPendingReset( email: _emailController.text.trim(), code: _codeController.text.trim(), ); final success = await notifier.resetPassword(password: _passwordController.text); if (success && mounted) { showTopToast(context, message: l10n.resetSuccess, backgroundColor: AppColors.rise); context.go('/login'); } } @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 loc = AppLocalizations.of(context)!; showTopToast( context, message: resolveProviderError(code, loc) ?? code, ); ref.read(authProvider.notifier).clearError(); }); } }); return Scaffold( appBar: AppBar( elevation: 0, leading: IconButton( icon: const Icon(Icons.chevron_left, size: 28), onPressed: () => context.pop(), ), title: Text( l10n.forgotPasswordTitle, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), ), centerTitle: true, ), body: Column( children: [ Expanded( child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(24, 24, 24, 24), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 邮箱 Text(l10n.email, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 13)), const SizedBox(height: 8), AuthField( controller: _emailController, hint: l10n.enterRegisteredEmail, keyboardType: TextInputType.emailAddress, maxLength: 100, focusNode: _emailFocusNode, onChanged: (_) => setState(() { _emailTouched = false; }), errorText: _emailTouched && !_emailValid ? l10n.emailError : null, ), const SizedBox(height: 20), // 验证码 Text(l10n.verificationCode, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 13)), const SizedBox(height: 8), Row( children: [ Expanded( child: AuthField( controller: _codeController, hint: l10n.verificationCodeHint, keyboardType: TextInputType.number, maxLength: 6, onChanged: (_) => setState(() {}), ), ), const SizedBox(width: 12), ElevatedButton( onPressed: (_emailValid && state.codeCooldown == 0 && !state.isLoading) ? _handleSendCode : null, style: ElevatedButton.styleFrom( backgroundColor: AppColors.brand, disabledBackgroundColor: AppColors.brand.withAlpha(80), minimumSize: const Size(0, 48), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), padding: const EdgeInsets.symmetric(horizontal: 16), ), child: Text( state.codeCooldown > 0 ? '${state.codeCooldown}s' : l10n.sendCode, style: const TextStyle( color: Colors.black, fontSize: 14, fontWeight: FontWeight.w600, ), ), ), ], ), const SizedBox(height: 20), // 新密码 Text(l10n.newPassword, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 13)), const SizedBox(height: 8), AuthField( controller: _passwordController, hint: l10n.newPasswordHint, obscure: _obscurePassword, maxLength: 16, onChanged: (_) => setState(() {}), suffixIcon: IconButton( onPressed: () => setState(() => _obscurePassword = !_obscurePassword), icon: Icon( _obscurePassword ? Icons.visibility_off_outlined : Icons.visibility_outlined, color: cs.onSurface.withAlpha(153), size: 20, ), ), ), const SizedBox(height: 8), Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( border: Border( bottom: BorderSide(color: cs.onSurface.withAlpha(100)), ), ), child: Row( children: [ _StrengthChip(label: l10n.pwdCharsRule, met: _has6to16, typed: _pwdTyped), const SizedBox(width: 8), _StrengthChip(label: l10n.pwdDigitRule, met: _hasDigit, typed: _pwdTyped), const SizedBox(width: 8), _StrengthChip(label: l10n.pwdLetterRule, met: _hasLetter, typed: _pwdTyped), ], ), ), const SizedBox(height: 20), // 确认密码 Text(l10n.confirmPassword, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 13)), const SizedBox(height: 8), AuthField( controller: _confirmController, hint: l10n.confirmPasswordHint, obscure: _obscureConfirm, maxLength: 16, onChanged: (_) => setState(() {}), suffixIcon: IconButton( onPressed: () => setState(() => _obscureConfirm = !_obscureConfirm), icon: Icon( _obscureConfirm ? Icons.visibility_off_outlined : Icons.visibility_outlined, color: cs.onSurface.withAlpha(153), size: 20, ), ), ), if (_confirmController.text.isNotEmpty && !_passwordsMatch) ...[ const SizedBox(height: 6), Text( l10n.passwordMismatch, style: const TextStyle(color: AppColors.fall, fontSize: 12), ), ], ], ), ), ), // 确认重置按钮固定底部 Padding( padding: const EdgeInsets.fromLTRB(24, 0, 24, 32), child: SizedBox( width: double.infinity, height: 50, child: ElevatedButton( onPressed: (_canSubmit && !state.isLoading) ? _handleResetPassword : null, style: ElevatedButton.styleFrom( backgroundColor: AppColors.brand, disabledBackgroundColor: AppColors.brand.withAlpha(80), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), ), child: state.isLoading ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.black), ) : Text( l10n.confirmReset, style: const TextStyle( color: Colors.black, fontSize: 16, fontWeight: FontWeight.w600, ), ), ), ), ), ], ), ); } } // ── 密码强度提示芯片 ────────────────────────────────────────── class _StrengthChip extends StatelessWidget { const _StrengthChip({required this.label, required this.met, this.typed = false}); final String label; final bool met; final bool typed; @override Widget build(BuildContext context) { final Color color; final IconData icon; if (!typed) { color = Theme.of(context).colorScheme.onSurface.withAlpha(120); icon = Icons.circle; } else if (met) { color = AppColors.rise; icon = Icons.check; } else { color = AppColors.fall; icon = Icons.close; } return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), decoration: BoxDecoration( borderRadius: BorderRadius.circular(4), border: Border.all(color: color.withAlpha(80), width: 1), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: icon == Icons.circle ? 6 : 12, color: color), const SizedBox(width: 4), Text( label, style: TextStyle(color: color, fontSize: 11), ), ], ), ); } }