| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363 |
- 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<ForgotPasswordScreen> createState() =>
- _ForgotPasswordScreenState();
- }
- class _ForgotPasswordScreenState extends ConsumerState<ForgotPasswordScreen> {
- 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<void> _handleSendCode() async {
- final email = _emailController.text.trim();
- if (email.isEmpty) return;
- await ref.read(authProvider.notifier).sendResetCode(email: email);
- }
- Future<void> _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<AuthState>(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),
- ),
- ],
- ),
- );
- }
- }
|