fund_password_screen.dart 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  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/fund_password_provider.dart';
  9. /// 资金密码页面
  10. /// [isResetMode] true=修改密码(需验证码),false=首次设置(无需验证码)
  11. class FundPasswordScreen extends ConsumerStatefulWidget {
  12. const FundPasswordScreen({super.key, this.isResetMode = false});
  13. final bool isResetMode;
  14. @override
  15. ConsumerState<FundPasswordScreen> createState() =>
  16. _FundPasswordScreenState();
  17. }
  18. class _FundPasswordScreenState extends ConsumerState<FundPasswordScreen> {
  19. final _passwordController = TextEditingController();
  20. final _confirmController = TextEditingController();
  21. final _codeController = TextEditingController();
  22. bool _obscurePassword = true;
  23. bool _obscureConfirm = true;
  24. @override
  25. void initState() {
  26. super.initState();
  27. Future.microtask(() => ref.invalidate(fundPasswordProvider));
  28. }
  29. @override
  30. void dispose() {
  31. _passwordController.dispose();
  32. _confirmController.dispose();
  33. _codeController.dispose();
  34. super.dispose();
  35. }
  36. bool get _canSubmit {
  37. final pwdFilled = _passwordController.text.isNotEmpty &&
  38. _confirmController.text.isNotEmpty;
  39. if (widget.isResetMode) {
  40. return pwdFilled && _codeController.text.length == 6;
  41. }
  42. return pwdFilled;
  43. }
  44. Future<void> _handleSubmit() async {
  45. final password = _passwordController.text;
  46. final confirm = _confirmController.text;
  47. final l10n = AppLocalizations.of(context)!;
  48. // 校验两次密码一致
  49. if (password != confirm) {
  50. showTopToast(context, message: l10n.passwordMismatch);
  51. return;
  52. }
  53. // 校验密码格式
  54. if (!FundPasswordNotifier.validatePassword(password)) {
  55. showTopToast(context, message: l10n.fundPasswordFormatError);
  56. return;
  57. }
  58. final notifier = ref.read(fundPasswordProvider.notifier);
  59. bool success;
  60. if (widget.isResetMode) {
  61. success = await notifier.resetPassword(
  62. newPassword: password,
  63. vcode: _codeController.text,
  64. );
  65. } else {
  66. success = await notifier.setPassword(password);
  67. }
  68. if (success && mounted) {
  69. context.pop();
  70. }
  71. }
  72. @override
  73. Widget build(BuildContext context) {
  74. final cs = Theme.of(context).colorScheme;
  75. final state = ref.watch(fundPasswordProvider);
  76. final isReset = widget.isResetMode;
  77. // 监听错误和成功
  78. ref.listen<FundPasswordState>(fundPasswordProvider, (prev, next) {
  79. if (next.errorMessage != null &&
  80. next.errorMessage != prev?.errorMessage) {
  81. final l10nE = AppLocalizations.of(context)!;
  82. showTopToast(context, message: resolveProviderError(next.errorMessage!, l10nE) ?? next.errorMessage!);
  83. ref.read(fundPasswordProvider.notifier).clearError();
  84. }
  85. if (next.successMessage != null &&
  86. next.successMessage != prev?.successMessage) {
  87. final l10nS = AppLocalizations.of(context)!;
  88. showTopToast(context, message: resolveProviderError(next.successMessage!, l10nS) ?? next.successMessage!, backgroundColor: AppColors.success);
  89. ref.read(fundPasswordProvider.notifier).clearSuccess();
  90. }
  91. });
  92. return Scaffold(
  93. appBar: AppBar(
  94. elevation: 0,
  95. leading: IconButton(
  96. icon: const Icon(Icons.chevron_left, size: 28),
  97. onPressed: () => context.pop(),
  98. ),
  99. title: Text(
  100. isReset ? AppLocalizations.of(context)!.changeFundPassword : AppLocalizations.of(context)!.setFundPassword,
  101. style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
  102. ),
  103. centerTitle: true,
  104. ),
  105. body: SingleChildScrollView(
  106. padding: const EdgeInsets.all(20),
  107. child: Column(
  108. crossAxisAlignment: CrossAxisAlignment.start,
  109. children: [
  110. // ── 提示横幅 ──────────────────────────────────
  111. _InfoBanner(
  112. text: AppLocalizations.of(context)!.fundPasswordBannerTip,
  113. ),
  114. const SizedBox(height: 24),
  115. // ── 资金密码 ──────────────────────────────────
  116. _FieldLabel(label: AppLocalizations.of(context)!.fundPassword),
  117. const SizedBox(height: 4),
  118. Text(
  119. AppLocalizations.of(context)!.fundPasswordHint,
  120. style: TextStyle(
  121. color: cs.onSurface.withAlpha(100), fontSize: 12),
  122. ),
  123. const SizedBox(height: 10),
  124. _InputField(
  125. controller: _passwordController,
  126. hint: AppLocalizations.of(context)!.setFundPasswordHint,
  127. obscure: _obscurePassword,
  128. onChanged: (_) => setState(() {}),
  129. suffixIcon: _EyeButton(
  130. obscure: _obscurePassword,
  131. onTap: () =>
  132. setState(() => _obscurePassword = !_obscurePassword),
  133. ),
  134. ),
  135. const SizedBox(height: 20),
  136. // ── 确认资金密码 ──────────────────────────────
  137. _FieldLabel(label: AppLocalizations.of(context)!.confirmFundPassword),
  138. const SizedBox(height: 10),
  139. _InputField(
  140. controller: _confirmController,
  141. hint: AppLocalizations.of(context)!.confirmFundPasswordHint,
  142. obscure: _obscureConfirm,
  143. onChanged: (_) => setState(() {}),
  144. suffixIcon: _EyeButton(
  145. obscure: _obscureConfirm,
  146. onTap: () =>
  147. setState(() => _obscureConfirm = !_obscureConfirm),
  148. ),
  149. ),
  150. // ── 邮箱验证码(仅修改模式)──────────────────
  151. if (isReset) ...[
  152. const SizedBox(height: 20),
  153. _FieldLabel(label: AppLocalizations.of(context)!.emailCode),
  154. const SizedBox(height: 10),
  155. Row(
  156. children: [
  157. Expanded(
  158. child: _InputField(
  159. controller: _codeController,
  160. hint: AppLocalizations.of(context)!.emailCodeHint,
  161. keyboardType: TextInputType.number,
  162. maxLength: 6,
  163. inputFormatters: [
  164. FilteringTextInputFormatter.digitsOnly,
  165. ],
  166. onChanged: (_) => setState(() {}),
  167. ),
  168. ),
  169. const SizedBox(width: 10),
  170. _SendCodeButton(
  171. countdown: state.codeCooldown,
  172. isLoading: state.isSendingCode,
  173. onTap: state.codeCooldown == 0 && !state.isSendingCode
  174. ? () {
  175. ref
  176. .read(fundPasswordProvider.notifier)
  177. .sendEmailCode();
  178. }
  179. : null,
  180. ),
  181. ],
  182. ),
  183. const SizedBox(height: 8),
  184. Text(
  185. AppLocalizations.of(context)!.checkSpamMessage,
  186. style: TextStyle(
  187. color: cs.onSurface.withAlpha(100),
  188. fontSize: 12,
  189. ),
  190. ),
  191. ],
  192. const SizedBox(height: 32),
  193. // ── 提交按钮 ──────────────────────────────────
  194. SizedBox(
  195. width: double.infinity,
  196. height: 50,
  197. child: ElevatedButton(
  198. onPressed:
  199. (_canSubmit && !state.isLoading) ? _handleSubmit : null,
  200. style: ElevatedButton.styleFrom(
  201. backgroundColor: AppColors.brand,
  202. disabledBackgroundColor: cs.outline.withAlpha(30),
  203. foregroundColor: Colors.black,
  204. shape: RoundedRectangleBorder(
  205. borderRadius: BorderRadius.circular(10),
  206. ),
  207. ),
  208. child: state.isLoading
  209. ? SizedBox(
  210. width: 20,
  211. height: 20,
  212. child: CircularProgressIndicator(
  213. strokeWidth: 2,
  214. color: Colors.black,
  215. ),
  216. )
  217. : Text(
  218. isReset ? AppLocalizations.of(context)!.confirmModify : AppLocalizations.of(context)!.confirmSet,
  219. style: TextStyle(
  220. color: _canSubmit
  221. ? Colors.black
  222. : cs.onSurface.withAlpha(153),
  223. fontSize: 15,
  224. fontWeight: FontWeight.w600,
  225. ),
  226. ),
  227. ),
  228. ),
  229. ],
  230. ),
  231. ),
  232. );
  233. }
  234. }
  235. // ── 共享组件 ─────────────────────────────────────────────────
  236. class _InfoBanner extends StatelessWidget {
  237. const _InfoBanner({required this.text});
  238. final String text;
  239. @override
  240. Widget build(BuildContext context) {
  241. return Container(
  242. padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
  243. decoration: BoxDecoration(
  244. color: const Color(0xFF2D2500),
  245. border: Border.all(color: const Color(0xFF4A3A00)),
  246. borderRadius: BorderRadius.circular(10),
  247. ),
  248. child: Row(
  249. crossAxisAlignment: CrossAxisAlignment.start,
  250. children: [
  251. const Icon(Icons.info_outline, color: AppColors.brand, size: 16),
  252. const SizedBox(width: 8),
  253. Expanded(
  254. child: Text(
  255. text,
  256. style: const TextStyle(
  257. color: AppColors.brand, fontSize: 13, height: 1.4),
  258. ),
  259. ),
  260. ],
  261. ),
  262. );
  263. }
  264. }
  265. class _FieldLabel extends StatelessWidget {
  266. const _FieldLabel({required this.label});
  267. final String label;
  268. @override
  269. Widget build(BuildContext context) {
  270. final cs = Theme.of(context).colorScheme;
  271. return Text(
  272. label,
  273. style: TextStyle(
  274. color: cs.onSurface,
  275. fontSize: 14,
  276. fontWeight: FontWeight.w500,
  277. ),
  278. );
  279. }
  280. }
  281. class _InputField extends StatelessWidget {
  282. const _InputField({
  283. required this.controller,
  284. required this.hint,
  285. this.obscure = false,
  286. this.suffixIcon,
  287. this.onChanged,
  288. this.keyboardType,
  289. this.maxLength,
  290. this.inputFormatters,
  291. });
  292. final TextEditingController controller;
  293. final String hint;
  294. final bool obscure;
  295. final Widget? suffixIcon;
  296. final ValueChanged<String>? onChanged;
  297. final TextInputType? keyboardType;
  298. final int? maxLength;
  299. final List<TextInputFormatter>? inputFormatters;
  300. @override
  301. Widget build(BuildContext context) {
  302. final cs = Theme.of(context).colorScheme;
  303. return TextField(
  304. controller: controller,
  305. obscureText: obscure,
  306. keyboardType: keyboardType,
  307. maxLength: maxLength,
  308. inputFormatters: inputFormatters,
  309. onChanged: onChanged,
  310. style: TextStyle(color: cs.onSurface, fontSize: 14),
  311. decoration: InputDecoration(
  312. counterText: '',
  313. hintText: hint,
  314. hintStyle:
  315. TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 13),
  316. contentPadding:
  317. const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
  318. suffixIcon: suffixIcon,
  319. ),
  320. );
  321. }
  322. }
  323. class _EyeButton extends StatelessWidget {
  324. const _EyeButton({required this.obscure, required this.onTap});
  325. final bool obscure;
  326. final VoidCallback onTap;
  327. @override
  328. Widget build(BuildContext context) {
  329. final cs = Theme.of(context).colorScheme;
  330. return IconButton(
  331. onPressed: onTap,
  332. icon: Icon(
  333. obscure ? Icons.visibility_off_outlined : Icons.visibility_outlined,
  334. color: cs.onSurface.withAlpha(153),
  335. size: 20,
  336. ),
  337. );
  338. }
  339. }
  340. class _SendCodeButton extends StatelessWidget {
  341. const _SendCodeButton({
  342. required this.countdown,
  343. required this.isLoading,
  344. required this.onTap,
  345. });
  346. final int countdown;
  347. final bool isLoading;
  348. final VoidCallback? onTap;
  349. @override
  350. Widget build(BuildContext context) {
  351. final cs = Theme.of(context).colorScheme;
  352. final enabled = onTap != null;
  353. return GestureDetector(
  354. onTap: onTap,
  355. child: Container(
  356. height: 50,
  357. padding: const EdgeInsets.symmetric(horizontal: 14),
  358. decoration: BoxDecoration(
  359. color: enabled ? AppColors.brand : cs.outline.withAlpha(30),
  360. borderRadius: BorderRadius.circular(10),
  361. ),
  362. child: Center(
  363. child: isLoading
  364. ? SizedBox(
  365. width: 16,
  366. height: 16,
  367. child: CircularProgressIndicator(
  368. strokeWidth: 2,
  369. color: cs.onSurface.withAlpha(153),
  370. ),
  371. )
  372. : Text(
  373. countdown > 0 ? '${countdown}s' : AppLocalizations.of(context)!.sendCode,
  374. style: TextStyle(
  375. color: enabled
  376. ? Colors.black
  377. : cs.onSurface.withAlpha(153),
  378. fontSize: 13,
  379. fontWeight: FontWeight.w600,
  380. ),
  381. ),
  382. ),
  383. ),
  384. );
  385. }
  386. }