change_password_screen.dart 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter/services.dart';
  3. import 'package:flutter_riverpod/flutter_riverpod.dart';
  4. import 'package:go_router/go_router.dart';
  5. import '../../../core/l10n/app_localizations.dart';
  6. import '../../../core/theme/app_colors.dart';
  7. import '../../../core/utils/top_toast.dart';
  8. import '../../../providers/change_password_provider.dart';
  9. class ChangePasswordScreen extends ConsumerStatefulWidget {
  10. const ChangePasswordScreen({super.key});
  11. @override
  12. ConsumerState<ChangePasswordScreen> createState() =>
  13. _ChangePasswordScreenState();
  14. }
  15. class _ChangePasswordScreenState extends ConsumerState<ChangePasswordScreen> {
  16. final _currentController = TextEditingController();
  17. final _newController = TextEditingController();
  18. final _confirmController = TextEditingController();
  19. final _codeController = TextEditingController();
  20. bool _obscureCurrent = true;
  21. bool _obscureNew = true;
  22. bool _obscureConfirm = true;
  23. // 新密码强度
  24. bool get _hasLength =>
  25. _newController.text.length >= 6 && _newController.text.length <= 16;
  26. bool get _hasLetter => _newController.text.contains(RegExp(r'[a-zA-Z]'));
  27. bool get _hasNumber => _newController.text.contains(RegExp(r'[0-9]'));
  28. bool get _newPasswordValid => _hasLength && _hasLetter && _hasNumber;
  29. bool get _canSubmit =>
  30. _currentController.text.isNotEmpty &&
  31. _newPasswordValid &&
  32. _confirmController.text.isNotEmpty &&
  33. _codeController.text.length == 6;
  34. @override
  35. void dispose() {
  36. _currentController.dispose();
  37. _newController.dispose();
  38. _confirmController.dispose();
  39. _codeController.dispose();
  40. super.dispose();
  41. }
  42. Future<void> _handleSubmit() async {
  43. final l10n = AppLocalizations.of(context)!;
  44. if (_newController.text != _confirmController.text) {
  45. showTopToast(context, message: l10n.passwordMismatch);
  46. return;
  47. }
  48. final success = await ref.read(changePasswordProvider.notifier).changePassword(
  49. oldPassword: _currentController.text,
  50. newPassword: _newController.text,
  51. vcode: _codeController.text,
  52. );
  53. if (success && mounted) {
  54. showTopToast(context, message: l10n.passwordChanged, backgroundColor: AppColors.success);
  55. context.pop();
  56. }
  57. }
  58. @override
  59. Widget build(BuildContext context) {
  60. final cs = Theme.of(context).colorScheme;
  61. final state = ref.watch(changePasswordProvider);
  62. final pwdTyped = _newController.text.isNotEmpty;
  63. final metCount = [_hasLength, _hasLetter, _hasNumber].where((m) => m).length;
  64. ref.listen<ChangePasswordState>(changePasswordProvider, (prev, next) {
  65. if (next.errorMessage != null && next.errorMessage != prev?.errorMessage) {
  66. final l10n = AppLocalizations.of(context)!;
  67. showTopToast(context, message: resolveProviderError(next.errorMessage!, l10n) ?? next.errorMessage!);
  68. ref.read(changePasswordProvider.notifier).clearError();
  69. }
  70. });
  71. return Scaffold(
  72. appBar: AppBar(
  73. elevation: 0,
  74. leading: IconButton(
  75. icon: const Icon(Icons.chevron_left, size: 28),
  76. onPressed: () => context.pop(),
  77. ),
  78. title: Text(
  79. AppLocalizations.of(context)!.changeLoginPassword,
  80. style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
  81. ),
  82. centerTitle: true,
  83. ),
  84. body: SingleChildScrollView(
  85. padding: const EdgeInsets.all(20),
  86. child: Column(
  87. crossAxisAlignment: CrossAxisAlignment.start,
  88. children: [
  89. // ── 当前密码 ──────────────────────────────────
  90. _FieldLabel(label: AppLocalizations.of(context)!.currentPassword),
  91. const SizedBox(height: 10),
  92. _InputField(
  93. controller: _currentController,
  94. hint: AppLocalizations.of(context)!.currentPasswordHint,
  95. obscure: _obscureCurrent,
  96. onChanged: (_) => setState(() {}),
  97. suffixIcon: _EyeButton(
  98. obscure: _obscureCurrent,
  99. onTap: () =>
  100. setState(() => _obscureCurrent = !_obscureCurrent),
  101. ),
  102. ),
  103. const SizedBox(height: 20),
  104. // ── 新密码 ────────────────────────────────────
  105. _FieldLabel(label: AppLocalizations.of(context)!.newPassword),
  106. const SizedBox(height: 10),
  107. _InputField(
  108. controller: _newController,
  109. hint: AppLocalizations.of(context)!.loginPasswordHint,
  110. obscure: _obscureNew,
  111. onChanged: (_) => setState(() {}),
  112. suffixIcon: _EyeButton(
  113. obscure: _obscureNew,
  114. onTap: () => setState(() => _obscureNew = !_obscureNew),
  115. ),
  116. ),
  117. const SizedBox(height: 10),
  118. // 密码强度 chips + 进度条
  119. Container(
  120. padding: const EdgeInsets.all(12),
  121. decoration: BoxDecoration(
  122. border: Border(
  123. bottom: BorderSide(color: cs.onSurface.withAlpha(100)),
  124. ),
  125. ),
  126. child: Column(
  127. children: [
  128. Row(
  129. children: [
  130. _StrengthChip(label: AppLocalizations.of(context)!.passwordLengthHint, met: _hasLength, typed: pwdTyped),
  131. const SizedBox(width: 8),
  132. _StrengthChip(label: AppLocalizations.of(context)!.containsLetter, met: _hasLetter, typed: pwdTyped),
  133. const SizedBox(width: 8),
  134. _StrengthChip(label: AppLocalizations.of(context)!.containsDigit, met: _hasNumber, typed: pwdTyped),
  135. ],
  136. ),
  137. const SizedBox(height: 8),
  138. Row(
  139. children: List.generate(3, (i) {
  140. final Color barColor;
  141. if (!pwdTyped) {
  142. barColor = cs.onSurface.withAlpha(40);
  143. } else if (i < metCount) {
  144. barColor = metCount == 3 ? AppColors.rise : AppColors.fall;
  145. } else {
  146. barColor = cs.onSurface.withAlpha(40);
  147. }
  148. return Expanded(
  149. child: Container(
  150. height: 3,
  151. margin: EdgeInsets.only(right: i < 2 ? 6 : 0),
  152. decoration: BoxDecoration(
  153. color: barColor,
  154. borderRadius: BorderRadius.circular(2),
  155. ),
  156. ),
  157. );
  158. }),
  159. ),
  160. ],
  161. ),
  162. ),
  163. const SizedBox(height: 20),
  164. // ── 确认新密码 ────────────────────────────────
  165. _FieldLabel(label: AppLocalizations.of(context)!.confirmNewPassword),
  166. const SizedBox(height: 10),
  167. _InputField(
  168. controller: _confirmController,
  169. hint: AppLocalizations.of(context)!.confirmNewPasswordHint,
  170. obscure: _obscureConfirm,
  171. onChanged: (_) => setState(() {}),
  172. suffixIcon: _EyeButton(
  173. obscure: _obscureConfirm,
  174. onTap: () =>
  175. setState(() => _obscureConfirm = !_obscureConfirm),
  176. ),
  177. ),
  178. const SizedBox(height: 20),
  179. // ── 邮箱验证码 ────────────────────────────────
  180. _FieldLabel(label: AppLocalizations.of(context)!.emailCode),
  181. const SizedBox(height: 10),
  182. Row(
  183. children: [
  184. Expanded(
  185. child: _InputField(
  186. controller: _codeController,
  187. hint: AppLocalizations.of(context)!.emailCodeHint,
  188. keyboardType: TextInputType.number,
  189. maxLength: 6,
  190. inputFormatters: [FilteringTextInputFormatter.digitsOnly],
  191. onChanged: (_) => setState(() {}),
  192. ),
  193. ),
  194. const SizedBox(width: 12),
  195. _SendCodeButton(
  196. cooldown: state.codeCooldown,
  197. isLoading: state.isSendingCode,
  198. onTap: state.codeCooldown == 0 && !state.isSendingCode
  199. ? () => ref.read(changePasswordProvider.notifier).sendEmailCode()
  200. : null,
  201. ),
  202. ],
  203. ),
  204. const SizedBox(height: 8),
  205. Text(
  206. AppLocalizations.of(context)!.checkSpamMessage,
  207. style: TextStyle(color: cs.onSurface.withAlpha(100), fontSize: 12),
  208. ),
  209. const SizedBox(height: 32),
  210. // ── 确认按钮 ──────────────────────────────────
  211. SizedBox(
  212. width: double.infinity,
  213. height: 50,
  214. child: ElevatedButton(
  215. onPressed: (_canSubmit && !state.isLoading) ? _handleSubmit : null,
  216. style: ElevatedButton.styleFrom(
  217. backgroundColor: AppColors.brand,
  218. disabledBackgroundColor: cs.outline.withAlpha(30),
  219. foregroundColor: Colors.black,
  220. shape: RoundedRectangleBorder(
  221. borderRadius: BorderRadius.circular(10),
  222. ),
  223. ),
  224. child: state.isLoading
  225. ? const SizedBox(
  226. width: 20,
  227. height: 20,
  228. child: CircularProgressIndicator(
  229. strokeWidth: 2,
  230. color: Colors.black,
  231. ),
  232. )
  233. : Text(
  234. AppLocalizations.of(context)!.confirmModify,
  235. style: TextStyle(
  236. color: (_canSubmit && !state.isLoading)
  237. ? Colors.black
  238. : cs.onSurface.withAlpha(153),
  239. fontSize: 15,
  240. fontWeight: FontWeight.w600,
  241. ),
  242. ),
  243. ),
  244. ),
  245. ],
  246. ),
  247. ),
  248. );
  249. }
  250. }
  251. // ── 密码强度 Chip ─────────────────────────────────────────────
  252. class _StrengthChip extends StatelessWidget {
  253. const _StrengthChip({required this.label, required this.met, this.typed = false});
  254. final String label;
  255. final bool met;
  256. final bool typed;
  257. @override
  258. Widget build(BuildContext context) {
  259. final Color color;
  260. final IconData icon;
  261. if (!typed) {
  262. color = Theme.of(context).colorScheme.onSurface.withAlpha(120);
  263. icon = Icons.circle;
  264. } else if (met) {
  265. color = AppColors.rise;
  266. icon = Icons.check;
  267. } else {
  268. color = AppColors.fall;
  269. icon = Icons.close;
  270. }
  271. return Container(
  272. padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
  273. decoration: BoxDecoration(
  274. borderRadius: BorderRadius.circular(4),
  275. border: Border.all(color: color.withAlpha(80), width: 1),
  276. ),
  277. child: Row(
  278. mainAxisSize: MainAxisSize.min,
  279. children: [
  280. Icon(icon, size: icon == Icons.circle ? 6 : 12, color: color),
  281. const SizedBox(width: 4),
  282. Text(label, style: TextStyle(color: color, fontSize: 11)),
  283. ],
  284. ),
  285. );
  286. }
  287. }
  288. // ── 字段标签 ─────────────────────────────────────────────────
  289. class _FieldLabel extends StatelessWidget {
  290. const _FieldLabel({required this.label});
  291. final String label;
  292. @override
  293. Widget build(BuildContext context) {
  294. final cs = Theme.of(context).colorScheme;
  295. return Text(
  296. label,
  297. style: TextStyle(
  298. color: cs.onSurface,
  299. fontSize: 14,
  300. fontWeight: FontWeight.w500,
  301. ),
  302. );
  303. }
  304. }
  305. // ── 输入框 ────────────────────────────────────────────────────
  306. class _InputField extends StatelessWidget {
  307. const _InputField({
  308. required this.controller,
  309. required this.hint,
  310. this.obscure = false,
  311. this.suffixIcon,
  312. this.onChanged,
  313. this.keyboardType,
  314. this.maxLength,
  315. this.inputFormatters,
  316. });
  317. final TextEditingController controller;
  318. final String hint;
  319. final bool obscure;
  320. final Widget? suffixIcon;
  321. final ValueChanged<String>? onChanged;
  322. final TextInputType? keyboardType;
  323. final int? maxLength;
  324. final List<TextInputFormatter>? inputFormatters;
  325. @override
  326. Widget build(BuildContext context) {
  327. final cs = Theme.of(context).colorScheme;
  328. return TextField(
  329. controller: controller,
  330. obscureText: obscure,
  331. keyboardType: keyboardType,
  332. maxLength: maxLength,
  333. inputFormatters: inputFormatters,
  334. onChanged: onChanged,
  335. style: TextStyle(color: cs.onSurface, fontSize: 14),
  336. decoration: InputDecoration(
  337. counterText: '',
  338. hintText: hint,
  339. hintStyle: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 13),
  340. contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
  341. suffixIcon: suffixIcon,
  342. ),
  343. );
  344. }
  345. }
  346. // ── 显示/隐藏密码按钮 ─────────────────────────────────────────
  347. class _EyeButton extends StatelessWidget {
  348. const _EyeButton({required this.obscure, required this.onTap});
  349. final bool obscure;
  350. final VoidCallback onTap;
  351. @override
  352. Widget build(BuildContext context) {
  353. final cs = Theme.of(context).colorScheme;
  354. return IconButton(
  355. onPressed: onTap,
  356. icon: Icon(
  357. obscure ? Icons.visibility_off_outlined : Icons.visibility_outlined,
  358. color: cs.onSurface.withAlpha(153),
  359. size: 20,
  360. ),
  361. );
  362. }
  363. }
  364. // ── 发送验证码按钮 ────────────────────────────────────────────
  365. class _SendCodeButton extends StatelessWidget {
  366. const _SendCodeButton({
  367. required this.cooldown,
  368. required this.isLoading,
  369. required this.onTap,
  370. });
  371. final int cooldown;
  372. final bool isLoading;
  373. final VoidCallback? onTap;
  374. @override
  375. Widget build(BuildContext context) {
  376. final cs = Theme.of(context).colorScheme;
  377. final enabled = onTap != null;
  378. return GestureDetector(
  379. onTap: onTap,
  380. child: Container(
  381. height: 50,
  382. padding: const EdgeInsets.symmetric(horizontal: 14),
  383. decoration: BoxDecoration(
  384. color: enabled ? AppColors.brand : cs.outline.withAlpha(30),
  385. borderRadius: BorderRadius.circular(10),
  386. ),
  387. child: Center(
  388. child: isLoading
  389. ? SizedBox(
  390. width: 16,
  391. height: 16,
  392. child: CircularProgressIndicator(
  393. strokeWidth: 2,
  394. color: cs.onSurface.withAlpha(153),
  395. ),
  396. )
  397. : Text(
  398. cooldown > 0 ? '${cooldown}s' : AppLocalizations.of(context)!.sendCode,
  399. style: TextStyle(
  400. color: enabled ? Colors.black : cs.onSurface.withAlpha(153),
  401. fontSize: 13,
  402. fontWeight: FontWeight.w600,
  403. ),
  404. ),
  405. ),
  406. ),
  407. );
  408. }
  409. }