forgot_password_screen.dart 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter_riverpod/flutter_riverpod.dart';
  3. import 'package:go_router/go_router.dart';
  4. import '../../../core/l10n/app_localizations.dart';
  5. import '../../../core/theme/app_colors.dart';
  6. import '../../../core/utils/top_toast.dart';
  7. import '../../../providers/auth_provider.dart';
  8. import 'auth_widgets.dart';
  9. /// 忘记密码页面(单页:邮箱 + 验证码 + 新密码 + 确认密码)
  10. class ForgotPasswordScreen extends ConsumerStatefulWidget {
  11. const ForgotPasswordScreen({super.key});
  12. @override
  13. ConsumerState<ForgotPasswordScreen> createState() =>
  14. _ForgotPasswordScreenState();
  15. }
  16. class _ForgotPasswordScreenState extends ConsumerState<ForgotPasswordScreen> {
  17. final _emailController = TextEditingController();
  18. final _codeController = TextEditingController();
  19. final _passwordController = TextEditingController();
  20. final _confirmController = TextEditingController();
  21. final _emailFocusNode = FocusNode();
  22. bool _obscurePassword = true;
  23. bool _obscureConfirm = true;
  24. bool _emailTouched = false;
  25. static final _emailRegex = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
  26. bool get _emailValid => _emailRegex.hasMatch(_emailController.text.trim());
  27. bool get _has6to16 =>
  28. _passwordController.text.length >= 6 &&
  29. _passwordController.text.length <= 16;
  30. bool get _hasDigit => RegExp(r'[0-9]').hasMatch(_passwordController.text);
  31. bool get _hasLetter =>
  32. RegExp(r'[a-zA-Z]').hasMatch(_passwordController.text);
  33. bool get _passwordValid => _has6to16 && _hasDigit && _hasLetter;
  34. bool get _pwdTyped => _passwordController.text.isNotEmpty;
  35. bool get _passwordsMatch =>
  36. _passwordController.text == _confirmController.text &&
  37. _confirmController.text.isNotEmpty;
  38. bool get _codeFilled => _codeController.text.trim().length == 6;
  39. bool get _canSubmit =>
  40. _emailValid && _codeFilled && _passwordValid && _passwordsMatch;
  41. @override
  42. void initState() {
  43. super.initState();
  44. _emailFocusNode.addListener(() {
  45. if (!_emailFocusNode.hasFocus && _emailController.text.isNotEmpty) {
  46. setState(() => _emailTouched = true);
  47. }
  48. });
  49. }
  50. @override
  51. void dispose() {
  52. _emailController.dispose();
  53. _codeController.dispose();
  54. _passwordController.dispose();
  55. _confirmController.dispose();
  56. _emailFocusNode.dispose();
  57. super.dispose();
  58. }
  59. Future<void> _handleSendCode() async {
  60. final email = _emailController.text.trim();
  61. if (email.isEmpty) return;
  62. await ref.read(authProvider.notifier).sendResetCode(email: email);
  63. }
  64. Future<void> _handleResetPassword() async {
  65. if (!_canSubmit) return;
  66. final l10n = AppLocalizations.of(context)!;
  67. final notifier = ref.read(authProvider.notifier);
  68. notifier.setPendingReset(
  69. email: _emailController.text.trim(),
  70. code: _codeController.text.trim(),
  71. );
  72. final success =
  73. await notifier.resetPassword(password: _passwordController.text);
  74. if (success && mounted) {
  75. showTopToast(context,
  76. message: l10n.resetSuccess,
  77. backgroundColor: AppColors.rise);
  78. context.go('/login');
  79. }
  80. }
  81. @override
  82. Widget build(BuildContext context) {
  83. final l10n = AppLocalizations.of(context)!;
  84. final state = ref.watch(authProvider);
  85. final cs = Theme.of(context).colorScheme;
  86. ref.listen<AuthState>(authProvider, (prev, next) {
  87. if (next.errorMessage != null &&
  88. next.errorMessage != prev?.errorMessage) {
  89. final code = next.errorMessage!;
  90. WidgetsBinding.instance.addPostFrameCallback((_) {
  91. if (!context.mounted) return;
  92. final loc = AppLocalizations.of(context)!;
  93. showTopToast(
  94. context,
  95. message: resolveProviderError(code, loc) ?? code,
  96. );
  97. ref.read(authProvider.notifier).clearError();
  98. });
  99. }
  100. });
  101. return Scaffold(
  102. appBar: AppBar(
  103. elevation: 0,
  104. leading: IconButton(
  105. icon: const Icon(Icons.chevron_left, size: 28),
  106. onPressed: () => context.pop(),
  107. ),
  108. title: Text(
  109. l10n.forgotPasswordTitle,
  110. style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
  111. ),
  112. centerTitle: true,
  113. ),
  114. body: Column(
  115. children: [
  116. Expanded(
  117. child: SingleChildScrollView(
  118. padding: const EdgeInsets.fromLTRB(24, 24, 24, 24),
  119. child: Column(
  120. crossAxisAlignment: CrossAxisAlignment.start,
  121. children: [
  122. // 邮箱
  123. Text(l10n.email,
  124. style: TextStyle(
  125. color: cs.onSurface.withAlpha(153), fontSize: 13)),
  126. const SizedBox(height: 8),
  127. AuthField(
  128. controller: _emailController,
  129. hint: l10n.enterRegisteredEmail,
  130. keyboardType: TextInputType.emailAddress,
  131. maxLength: 100,
  132. focusNode: _emailFocusNode,
  133. onChanged: (_) => setState(() { _emailTouched = false; }),
  134. errorText: _emailTouched && !_emailValid ? l10n.emailError : null,
  135. ),
  136. const SizedBox(height: 20),
  137. // 验证码
  138. Text(l10n.verificationCode,
  139. style: TextStyle(
  140. color: cs.onSurface.withAlpha(153), fontSize: 13)),
  141. const SizedBox(height: 8),
  142. Row(
  143. children: [
  144. Expanded(
  145. child: AuthField(
  146. controller: _codeController,
  147. hint: l10n.verificationCodeHint,
  148. keyboardType: TextInputType.number,
  149. maxLength: 6,
  150. onChanged: (_) => setState(() {}),
  151. ),
  152. ),
  153. const SizedBox(width: 12),
  154. ElevatedButton(
  155. onPressed:
  156. (_emailValid && state.codeCooldown == 0 && !state.isLoading)
  157. ? _handleSendCode
  158. : null,
  159. style: ElevatedButton.styleFrom(
  160. backgroundColor: AppColors.brand,
  161. disabledBackgroundColor: AppColors.brand.withAlpha(80),
  162. minimumSize: const Size(0, 48),
  163. shape: RoundedRectangleBorder(
  164. borderRadius: BorderRadius.circular(10),
  165. ),
  166. padding: const EdgeInsets.symmetric(horizontal: 16),
  167. ),
  168. child: Text(
  169. state.codeCooldown > 0
  170. ? '${state.codeCooldown}s'
  171. : l10n.sendCode,
  172. style: const TextStyle(
  173. color: Colors.black,
  174. fontSize: 14,
  175. fontWeight: FontWeight.w600,
  176. ),
  177. ),
  178. ),
  179. ],
  180. ),
  181. const SizedBox(height: 20),
  182. // 新密码
  183. Text(l10n.newPassword,
  184. style: TextStyle(
  185. color: cs.onSurface.withAlpha(153), fontSize: 13)),
  186. const SizedBox(height: 8),
  187. AuthField(
  188. controller: _passwordController,
  189. hint: l10n.newPasswordHint,
  190. obscure: _obscurePassword,
  191. maxLength: 16,
  192. onChanged: (_) => setState(() {}),
  193. suffixIcon: IconButton(
  194. onPressed: () =>
  195. setState(() => _obscurePassword = !_obscurePassword),
  196. icon: Icon(
  197. _obscurePassword
  198. ? Icons.visibility_off_outlined
  199. : Icons.visibility_outlined,
  200. color: cs.onSurface.withAlpha(153),
  201. size: 20,
  202. ),
  203. ),
  204. ),
  205. const SizedBox(height: 8),
  206. Container(
  207. padding: const EdgeInsets.all(12),
  208. decoration: BoxDecoration(
  209. border: Border(
  210. bottom: BorderSide(color: cs.onSurface.withAlpha(100)),
  211. ),
  212. ),
  213. child: Row(
  214. children: [
  215. _StrengthChip(label: l10n.pwdCharsRule, met: _has6to16, typed: _pwdTyped),
  216. const SizedBox(width: 8),
  217. _StrengthChip(label: l10n.pwdDigitRule, met: _hasDigit, typed: _pwdTyped),
  218. const SizedBox(width: 8),
  219. _StrengthChip(label: l10n.pwdLetterRule, met: _hasLetter, typed: _pwdTyped),
  220. ],
  221. ),
  222. ),
  223. const SizedBox(height: 20),
  224. // 确认密码
  225. Text(l10n.confirmPassword,
  226. style: TextStyle(
  227. color: cs.onSurface.withAlpha(153), fontSize: 13)),
  228. const SizedBox(height: 8),
  229. AuthField(
  230. controller: _confirmController,
  231. hint: l10n.confirmPasswordHint,
  232. obscure: _obscureConfirm,
  233. maxLength: 16,
  234. onChanged: (_) => setState(() {}),
  235. suffixIcon: IconButton(
  236. onPressed: () =>
  237. setState(() => _obscureConfirm = !_obscureConfirm),
  238. icon: Icon(
  239. _obscureConfirm
  240. ? Icons.visibility_off_outlined
  241. : Icons.visibility_outlined,
  242. color: cs.onSurface.withAlpha(153),
  243. size: 20,
  244. ),
  245. ),
  246. ),
  247. if (_confirmController.text.isNotEmpty &&
  248. !_passwordsMatch) ...[
  249. const SizedBox(height: 6),
  250. Text(
  251. l10n.passwordMismatch,
  252. style: const TextStyle(color: AppColors.fall, fontSize: 12),
  253. ),
  254. ],
  255. ],
  256. ),
  257. ),
  258. ),
  259. // 确认重置按钮固定底部
  260. Padding(
  261. padding: const EdgeInsets.fromLTRB(24, 0, 24, 32),
  262. child: SizedBox(
  263. width: double.infinity,
  264. height: 50,
  265. child: ElevatedButton(
  266. onPressed:
  267. (_canSubmit && !state.isLoading) ? _handleResetPassword : null,
  268. style: ElevatedButton.styleFrom(
  269. backgroundColor: AppColors.brand,
  270. disabledBackgroundColor: AppColors.brand.withAlpha(80),
  271. shape: RoundedRectangleBorder(
  272. borderRadius: BorderRadius.circular(10),
  273. ),
  274. ),
  275. child: state.isLoading
  276. ? const SizedBox(
  277. width: 20,
  278. height: 20,
  279. child: CircularProgressIndicator(
  280. strokeWidth: 2, color: Colors.black),
  281. )
  282. : Text(
  283. l10n.confirmReset,
  284. style: const TextStyle(
  285. color: Colors.black,
  286. fontSize: 16,
  287. fontWeight: FontWeight.w600,
  288. ),
  289. ),
  290. ),
  291. ),
  292. ),
  293. ],
  294. ),
  295. );
  296. }
  297. }
  298. // ── 密码强度提示芯片 ──────────────────────────────────────────
  299. class _StrengthChip extends StatelessWidget {
  300. const _StrengthChip({required this.label, required this.met, this.typed = false});
  301. final String label;
  302. final bool met;
  303. final bool typed;
  304. @override
  305. Widget build(BuildContext context) {
  306. final Color color;
  307. final IconData icon;
  308. if (!typed) {
  309. color = Theme.of(context).colorScheme.onSurface.withAlpha(120);
  310. icon = Icons.circle;
  311. } else if (met) {
  312. color = AppColors.rise;
  313. icon = Icons.check;
  314. } else {
  315. color = AppColors.fall;
  316. icon = Icons.close;
  317. }
  318. return Container(
  319. padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
  320. decoration: BoxDecoration(
  321. borderRadius: BorderRadius.circular(4),
  322. border: Border.all(color: color.withAlpha(80), width: 1),
  323. ),
  324. child: Row(
  325. mainAxisSize: MainAxisSize.min,
  326. children: [
  327. Icon(icon, size: icon == Icons.circle ? 6 : 12, color: color),
  328. const SizedBox(width: 4),
  329. Text(
  330. label,
  331. style: TextStyle(color: color, fontSize: 11),
  332. ),
  333. ],
  334. ),
  335. );
  336. }
  337. }