verify_code_screen.dart 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519
  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/auth_provider.dart';
  9. /// 验证码场景
  10. enum VerifyCodeMode { login, register }
  11. /// 验证码输入页面参数
  12. class VerifyCodeArgs {
  13. final String email;
  14. final String password;
  15. final String maskedEmail;
  16. final VerifyCodeMode mode;
  17. final bool showAuthenticator;
  18. final bool codeSent;
  19. const VerifyCodeArgs({
  20. required this.email,
  21. required this.password,
  22. required this.maskedEmail,
  23. this.mode = VerifyCodeMode.login,
  24. this.showAuthenticator = true,
  25. this.codeSent = false,
  26. });
  27. }
  28. class VerifyCodeScreen extends ConsumerStatefulWidget {
  29. const VerifyCodeScreen({super.key, required this.args});
  30. final VerifyCodeArgs args;
  31. @override
  32. ConsumerState<VerifyCodeScreen> createState() => _VerifyCodeScreenState();
  33. }
  34. class _VerifyCodeScreenState extends ConsumerState<VerifyCodeScreen> {
  35. /// 0 = 邮箱验证码, 1 = 身份验证器
  36. int _tabIndex = 0;
  37. final List<TextEditingController> _otpControllers =
  38. List.generate(6, (_) => TextEditingController());
  39. final List<FocusNode> _otpFocusNodes = List.generate(6, (_) => FocusNode());
  40. @override
  41. void initState() {
  42. super.initState();
  43. for (int i = 1; i < 6; i++) {
  44. final index = i;
  45. _otpFocusNodes[i].onKeyEvent = (node, event) {
  46. if (event is KeyDownEvent &&
  47. event.logicalKey == LogicalKeyboardKey.backspace &&
  48. _otpControllers[index].text.isEmpty) {
  49. _otpControllers[index - 1].clear();
  50. _otpFocusNodes[index - 1].requestFocus();
  51. setState(() {});
  52. return KeyEventResult.handled;
  53. }
  54. return KeyEventResult.ignored;
  55. };
  56. }
  57. Future.microtask(() {
  58. if (widget.args.codeSent) {
  59. ref.read(authProvider.notifier).startCountdown();
  60. } else {
  61. _sendCode();
  62. }
  63. _otpFocusNodes[0].requestFocus();
  64. });
  65. }
  66. @override
  67. void dispose() {
  68. for (final c in _otpControllers) {
  69. c.dispose();
  70. }
  71. for (final f in _otpFocusNodes) {
  72. f.dispose();
  73. }
  74. super.dispose();
  75. }
  76. String get _otpCode => _otpControllers.map((c) => c.text).join();
  77. bool get _otpFilled => _otpCode.length == 6;
  78. void _onDigitChanged(int index, String value) {
  79. // Handle paste: value contains multiple characters
  80. if (value.length > 1) {
  81. final digits = value.replaceAll(RegExp(r'[^0-9]'), '');
  82. for (int i = 0; i < 6 && i < digits.length; i++) {
  83. _otpControllers[i].text = digits[i];
  84. }
  85. for (int i = digits.length; i < 6; i++) {
  86. _otpControllers[i].clear();
  87. }
  88. final focusIdx = digits.length < 6 ? digits.length : 5;
  89. _otpFocusNodes[focusIdx].requestFocus();
  90. setState(() {});
  91. return;
  92. }
  93. // Advance focus when digit entered
  94. if (value.isNotEmpty && index < 5) {
  95. _otpFocusNodes[index + 1].requestFocus();
  96. }
  97. // iOS soft keyboard: when a non-empty field is cleared by backspace,
  98. // onChanged fires with "". Move focus to previous field so the next
  99. // backspace press lands on a non-empty field and triggers onChanged again.
  100. // (On iOS, backspace on an already-empty field fires no event at all.)
  101. if (value.isEmpty && index > 0) {
  102. _otpFocusNodes[index - 1].requestFocus();
  103. }
  104. setState(() {});
  105. }
  106. void _clearOtp() {
  107. for (final c in _otpControllers) {
  108. c.clear();
  109. }
  110. }
  111. void _switchTab(int index) {
  112. if (index == _tabIndex) return;
  113. _clearOtp();
  114. setState(() => _tabIndex = index);
  115. Future.microtask(() => _otpFocusNodes[0].requestFocus());
  116. }
  117. Future<void> _handlePaste() async {
  118. final data = await Clipboard.getData(Clipboard.kTextPlain);
  119. final text = data?.text ?? '';
  120. final digits = text.replaceAll(RegExp(r'[^0-9]'), '');
  121. if (digits.isEmpty) return;
  122. for (int i = 0; i < 6 && i < digits.length; i++) {
  123. _otpControllers[i].text = digits[i];
  124. }
  125. final focusIdx = digits.length < 6 ? digits.length : 5;
  126. _otpFocusNodes[focusIdx].requestFocus();
  127. if (!mounted) return;
  128. setState(() {});
  129. }
  130. void _sendCode() {
  131. final notifier = ref.read(authProvider.notifier);
  132. if (widget.args.mode == VerifyCodeMode.register) {
  133. notifier.sendRegisterCode(email: widget.args.email);
  134. } else {
  135. notifier.sendLoginCode(
  136. email: widget.args.email,
  137. password: widget.args.password,
  138. );
  139. }
  140. }
  141. Future<void> _handleSubmit() async {
  142. final l10n = AppLocalizations.of(context)!;
  143. final notifier = ref.read(authProvider.notifier);
  144. if (widget.args.mode == VerifyCodeMode.register) {
  145. final success = await notifier.register(code: _otpCode);
  146. if (success && mounted) {
  147. showTopToast(context, message: l10n.registerSuccess, backgroundColor: AppColors.rise);
  148. context.go('/login');
  149. }
  150. } else {
  151. final success = await notifier.login(
  152. email: widget.args.email,
  153. password: widget.args.password,
  154. code: _otpCode,
  155. vtype: _tabIndex == 0 ? '2' : '3',
  156. );
  157. if (success && mounted) {
  158. context.go('/');
  159. }
  160. }
  161. }
  162. @override
  163. Widget build(BuildContext context) {
  164. final l10n = AppLocalizations.of(context)!;
  165. final cs = Theme.of(context).colorScheme;
  166. final state = ref.watch(authProvider);
  167. ref.listen<AuthState>(authProvider, (prev, next) {
  168. if (next.errorMessage != null && next.errorMessage != prev?.errorMessage) {
  169. final code = next.errorMessage!;
  170. WidgetsBinding.instance.addPostFrameCallback((_) {
  171. if (!context.mounted) return;
  172. final loc = AppLocalizations.of(context)!;
  173. showTopToast(
  174. context,
  175. message: resolveProviderError(code, loc) ?? code,
  176. );
  177. ref.read(authProvider.notifier).clearError();
  178. });
  179. }
  180. });
  181. return Scaffold(
  182. appBar: AppBar(
  183. leading: IconButton(
  184. icon: const Icon(Icons.chevron_left, size: 28),
  185. onPressed: () => context.pop(),
  186. ),
  187. title: Text(
  188. l10n.enterVerifyCode,
  189. style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
  190. ),
  191. centerTitle: true,
  192. ),
  193. body: SingleChildScrollView(
  194. padding: const EdgeInsets.fromLTRB(24, 16, 24, 24),
  195. child: Column(
  196. crossAxisAlignment: CrossAxisAlignment.start,
  197. children: [
  198. // ── Tab 切换
  199. if (widget.args.showAuthenticator) ...[
  200. _SegmentedTab(
  201. selectedIndex: _tabIndex,
  202. onChanged: _switchTab,
  203. emailLabel: l10n.emailCodeTab,
  204. authenticatorLabel: l10n.authenticatorTab,
  205. ),
  206. const SizedBox(height: 28),
  207. ] else
  208. const SizedBox(height: 8),
  209. // ── 图标
  210. Container(
  211. width: 48,
  212. height: 48,
  213. decoration: BoxDecoration(
  214. color: _tabIndex == 0
  215. ? const Color(0xFFE8F5E9)
  216. : const Color(0xFFFFF8E1),
  217. borderRadius: BorderRadius.circular(14),
  218. ),
  219. child: Icon(
  220. _tabIndex == 0 ? Icons.email_outlined : Icons.smartphone,
  221. color: _tabIndex == 0
  222. ? const Color(0xFF4CAF50)
  223. : AppColors.brand,
  224. size: 24,
  225. ),
  226. ),
  227. const SizedBox(height: 20),
  228. // ── 标题
  229. Text(
  230. l10n.enterVerifyCode,
  231. style: TextStyle(
  232. color: cs.onSurface,
  233. fontSize: 22,
  234. fontWeight: FontWeight.w700,
  235. ),
  236. ),
  237. const SizedBox(height: 16),
  238. // ── 提示文字
  239. if (_tabIndex == 0)
  240. Text.rich(
  241. TextSpan(
  242. text: '${l10n.email}: ',
  243. children: [
  244. TextSpan(
  245. text: widget.args.maskedEmail,
  246. style: TextStyle(
  247. fontWeight: FontWeight.w600,
  248. color: cs.onSurface,
  249. ),
  250. ),
  251. TextSpan(text: ' ${l10n.emailCodeHint}'),
  252. ],
  253. ),
  254. style: TextStyle(
  255. color: cs.onSurface.withAlpha(153),
  256. fontSize: 14,
  257. height: 1.5,
  258. ),
  259. )
  260. else
  261. Text(
  262. l10n.authenticatorHint,
  263. style: TextStyle(
  264. color: cs.onSurface.withAlpha(153),
  265. fontSize: 14,
  266. height: 1.5,
  267. ),
  268. ),
  269. const SizedBox(height: 28),
  270. // ── 6 位验证码输入
  271. AutofillGroup(
  272. child: Row(
  273. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  274. children: List.generate(6, (i) {
  275. return SizedBox(
  276. width: 48,
  277. height: 52,
  278. child: Semantics(
  279. label: 'verify_input_code_$i',
  280. textField: true,
  281. child: TextField(
  282. controller: _otpControllers[i],
  283. focusNode: _otpFocusNodes[i],
  284. textAlign: TextAlign.center,
  285. keyboardType: TextInputType.number,
  286. maxLength: 1,
  287. autofillHints: const [AutofillHints.oneTimeCode],
  288. inputFormatters: [
  289. FilteringTextInputFormatter.digitsOnly,
  290. ],
  291. onChanged: (v) => _onDigitChanged(i, v),
  292. style: TextStyle(
  293. color: cs.onSurface,
  294. fontSize: 20,
  295. fontWeight: FontWeight.w600,
  296. ),
  297. decoration: InputDecoration(
  298. counterText: '',
  299. contentPadding: const EdgeInsets.symmetric(vertical: 14),
  300. isDense: true,
  301. filled: true,
  302. fillColor: cs.surface,
  303. border: OutlineInputBorder(
  304. borderRadius: BorderRadius.circular(10),
  305. borderSide: BorderSide(color: cs.outline.withAlpha(60)),
  306. ),
  307. enabledBorder: OutlineInputBorder(
  308. borderRadius: BorderRadius.circular(10),
  309. borderSide: BorderSide(color: cs.outline.withAlpha(60)),
  310. ),
  311. focusedBorder: OutlineInputBorder(
  312. borderRadius: BorderRadius.circular(10),
  313. borderSide: const BorderSide(
  314. color: AppColors.brand, width: 1.5),
  315. ),
  316. ),
  317. ),
  318. ),
  319. );
  320. }),
  321. ),
  322. ),
  323. const SizedBox(height: 16),
  324. // ── 倒计时 / 粘贴
  325. Row(
  326. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  327. children: [
  328. if (_tabIndex == 0)
  329. Semantics(
  330. label: 'verify_link_resend',
  331. onTap: state.codeCooldown > 0 ? null : _sendCode,
  332. child: GestureDetector(
  333. onTap: state.codeCooldown > 0 ? null : _sendCode,
  334. child: Text(
  335. state.codeCooldown > 0
  336. ? l10n.resendCountdown(state.codeCooldown)
  337. : l10n.resendCode,
  338. style: TextStyle(
  339. color: state.codeCooldown > 0
  340. ? cs.onSurface.withAlpha(153)
  341. : AppColors.brand,
  342. fontSize: 13,
  343. ),
  344. ),
  345. ),
  346. )
  347. else
  348. const SizedBox.shrink(),
  349. GestureDetector(
  350. onTap: _handlePaste,
  351. child: Text(
  352. l10n.paste,
  353. style: TextStyle(
  354. color: cs.onSurface,
  355. fontSize: 13,
  356. fontWeight: FontWeight.w600,
  357. ),
  358. ),
  359. ),
  360. ],
  361. ),
  362. const SizedBox(height: 32),
  363. // ── 提交按钮
  364. Semantics(
  365. label: 'verify_btn_confirm',
  366. button: true,
  367. enabled: _otpFilled && !state.isLoading,
  368. onTap: (_otpFilled && !state.isLoading) ? _handleSubmit : null,
  369. child: SizedBox(
  370. width: double.infinity,
  371. height: 50,
  372. child: ElevatedButton(
  373. onPressed:
  374. (_otpFilled && !state.isLoading) ? _handleSubmit : null,
  375. style: ElevatedButton.styleFrom(
  376. backgroundColor: AppColors.brand,
  377. disabledBackgroundColor: AppColors.brand.withAlpha(80),
  378. shape: RoundedRectangleBorder(
  379. borderRadius: BorderRadius.circular(10),
  380. ),
  381. ),
  382. child: state.isLoading
  383. ? SizedBox(
  384. width: 20,
  385. height: 20,
  386. child: CircularProgressIndicator(
  387. strokeWidth: 2, color: Colors.black),
  388. )
  389. : Text(
  390. l10n.submit,
  391. style: TextStyle(
  392. color: _otpFilled
  393. ? Colors.black
  394. : Colors.black.withAlpha(153),
  395. fontSize: 16,
  396. fontWeight: FontWeight.w600,
  397. ),
  398. ),
  399. ),
  400. ),
  401. ),
  402. // ── 切换链接(仅身份验证器 Tab 显示)
  403. if (_tabIndex == 1) ...[
  404. const SizedBox(height: 20),
  405. Center(
  406. child: Semantics(
  407. label: 'verify_link_switch',
  408. onTap: () => _switchTab(0),
  409. child: GestureDetector(
  410. onTap: () => _switchTab(0),
  411. child: Text(
  412. l10n.switchEmailVerify,
  413. style: const TextStyle(
  414. color: AppColors.brand,
  415. fontSize: 14,
  416. ),
  417. ),
  418. ),
  419. ),
  420. ),
  421. ],
  422. ],
  423. ),
  424. ),
  425. );
  426. }
  427. }
  428. // ── 分段选择器 ───────────────────────────────────────────────
  429. class _SegmentedTab extends StatelessWidget {
  430. const _SegmentedTab({
  431. required this.selectedIndex,
  432. required this.onChanged,
  433. required this.emailLabel,
  434. required this.authenticatorLabel,
  435. });
  436. final int selectedIndex;
  437. final ValueChanged<int> onChanged;
  438. final String emailLabel;
  439. final String authenticatorLabel;
  440. @override
  441. Widget build(BuildContext context) {
  442. final isDark = Theme.of(context).brightness == Brightness.dark;
  443. return Container(
  444. height: 40,
  445. decoration: BoxDecoration(
  446. color: isDark ? AppColors.darkBgTertiary : AppColors.lightBgTertiary,
  447. borderRadius: BorderRadius.circular(20),
  448. ),
  449. child: Row(
  450. children: [
  451. _buildTab(context, 0, emailLabel),
  452. _buildTab(context, 1, authenticatorLabel),
  453. ],
  454. ),
  455. );
  456. }
  457. Widget _buildTab(BuildContext context, int index, String label) {
  458. final cs = Theme.of(context).colorScheme;
  459. final selected = index == selectedIndex;
  460. return Expanded(
  461. child: GestureDetector(
  462. onTap: () => onChanged(index),
  463. child: Container(
  464. margin: const EdgeInsets.all(3),
  465. decoration: BoxDecoration(
  466. color: selected ? AppColors.brand : Colors.transparent,
  467. borderRadius: BorderRadius.circular(18),
  468. ),
  469. child: Center(
  470. child: Text(
  471. label,
  472. style: TextStyle(
  473. color: selected ? Colors.black : cs.onSurface.withAlpha(153),
  474. fontSize: 13,
  475. fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
  476. ),
  477. ),
  478. ),
  479. ),
  480. ),
  481. );
  482. }
  483. }