import 'package:flutter/material.dart'; import 'package:flutter/services.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'; /// 验证码场景 enum VerifyCodeMode { login, register } /// 验证码输入页面参数 class VerifyCodeArgs { final String email; final String password; final String maskedEmail; final VerifyCodeMode mode; final bool showAuthenticator; final bool codeSent; const VerifyCodeArgs({ required this.email, required this.password, required this.maskedEmail, this.mode = VerifyCodeMode.login, this.showAuthenticator = true, this.codeSent = false, }); } class VerifyCodeScreen extends ConsumerStatefulWidget { const VerifyCodeScreen({super.key, required this.args}); final VerifyCodeArgs args; @override ConsumerState createState() => _VerifyCodeScreenState(); } class _VerifyCodeScreenState extends ConsumerState { /// 0 = 邮箱验证码, 1 = 身份验证器 int _tabIndex = 0; final List _otpControllers = List.generate(6, (_) => TextEditingController()); final List _otpFocusNodes = List.generate(6, (_) => FocusNode()); @override void initState() { super.initState(); for (int i = 1; i < 6; i++) { final index = i; _otpFocusNodes[i].onKeyEvent = (node, event) { if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.backspace && _otpControllers[index].text.isEmpty) { _otpControllers[index - 1].clear(); _otpFocusNodes[index - 1].requestFocus(); setState(() {}); return KeyEventResult.handled; } return KeyEventResult.ignored; }; } Future.microtask(() { if (widget.args.codeSent) { ref.read(authProvider.notifier).startCountdown(); } else { _sendCode(); } _otpFocusNodes[0].requestFocus(); }); } @override void dispose() { for (final c in _otpControllers) { c.dispose(); } for (final f in _otpFocusNodes) { f.dispose(); } super.dispose(); } String get _otpCode => _otpControllers.map((c) => c.text).join(); bool get _otpFilled => _otpCode.length == 6; void _onDigitChanged(int index, String value) { // Handle paste: value contains multiple characters if (value.length > 1) { final digits = value.replaceAll(RegExp(r'[^0-9]'), ''); for (int i = 0; i < 6 && i < digits.length; i++) { _otpControllers[i].text = digits[i]; } for (int i = digits.length; i < 6; i++) { _otpControllers[i].clear(); } final focusIdx = digits.length < 6 ? digits.length : 5; _otpFocusNodes[focusIdx].requestFocus(); setState(() {}); return; } // Advance focus when digit entered if (value.isNotEmpty && index < 5) { _otpFocusNodes[index + 1].requestFocus(); } // iOS soft keyboard: when a non-empty field is cleared by backspace, // onChanged fires with "". Move focus to previous field so the next // backspace press lands on a non-empty field and triggers onChanged again. // (On iOS, backspace on an already-empty field fires no event at all.) if (value.isEmpty && index > 0) { _otpFocusNodes[index - 1].requestFocus(); } setState(() {}); } void _clearOtp() { for (final c in _otpControllers) { c.clear(); } } void _switchTab(int index) { if (index == _tabIndex) return; _clearOtp(); setState(() => _tabIndex = index); Future.microtask(() => _otpFocusNodes[0].requestFocus()); } Future _handlePaste() async { final data = await Clipboard.getData(Clipboard.kTextPlain); final text = data?.text ?? ''; final digits = text.replaceAll(RegExp(r'[^0-9]'), ''); if (digits.isEmpty) return; for (int i = 0; i < 6 && i < digits.length; i++) { _otpControllers[i].text = digits[i]; } final focusIdx = digits.length < 6 ? digits.length : 5; _otpFocusNodes[focusIdx].requestFocus(); if (!mounted) return; setState(() {}); } void _sendCode() { final notifier = ref.read(authProvider.notifier); if (widget.args.mode == VerifyCodeMode.register) { notifier.sendRegisterCode(email: widget.args.email); } else { notifier.sendLoginCode( email: widget.args.email, password: widget.args.password, ); } } Future _handleSubmit() async { final l10n = AppLocalizations.of(context)!; final notifier = ref.read(authProvider.notifier); if (widget.args.mode == VerifyCodeMode.register) { final success = await notifier.register(code: _otpCode); if (success && mounted) { showTopToast(context, message: l10n.registerSuccess, backgroundColor: AppColors.rise); context.go('/login'); } } else { final success = await notifier.login( email: widget.args.email, password: widget.args.password, code: _otpCode, vtype: _tabIndex == 0 ? '2' : '3', ); if (success && mounted) { context.go('/'); } } } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final cs = Theme.of(context).colorScheme; final state = ref.watch(authProvider); ref.listen(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( leading: IconButton( icon: const Icon(Icons.chevron_left, size: 28), onPressed: () => context.pop(), ), title: Text( l10n.enterVerifyCode, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), ), centerTitle: true, ), body: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(24, 16, 24, 24), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // ── Tab 切换 if (widget.args.showAuthenticator) ...[ _SegmentedTab( selectedIndex: _tabIndex, onChanged: _switchTab, emailLabel: l10n.emailCodeTab, authenticatorLabel: l10n.authenticatorTab, ), const SizedBox(height: 28), ] else const SizedBox(height: 8), // ── 图标 Container( width: 48, height: 48, decoration: BoxDecoration( color: _tabIndex == 0 ? const Color(0xFFE8F5E9) : const Color(0xFFFFF8E1), borderRadius: BorderRadius.circular(14), ), child: Icon( _tabIndex == 0 ? Icons.email_outlined : Icons.smartphone, color: _tabIndex == 0 ? const Color(0xFF4CAF50) : AppColors.brand, size: 24, ), ), const SizedBox(height: 20), // ── 标题 Text( l10n.enterVerifyCode, style: TextStyle( color: cs.onSurface, fontSize: 22, fontWeight: FontWeight.w700, ), ), const SizedBox(height: 16), // ── 提示文字 if (_tabIndex == 0) Text.rich( TextSpan( text: '${l10n.email}: ', children: [ TextSpan( text: widget.args.maskedEmail, style: TextStyle( fontWeight: FontWeight.w600, color: cs.onSurface, ), ), TextSpan(text: ' ${l10n.emailCodeHint}'), ], ), style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 14, height: 1.5, ), ) else Text( l10n.authenticatorHint, style: TextStyle( color: cs.onSurface.withAlpha(153), fontSize: 14, height: 1.5, ), ), const SizedBox(height: 28), // ── 6 位验证码输入 AutofillGroup( child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: List.generate(6, (i) { return SizedBox( width: 48, height: 52, child: Semantics( label: 'verify_input_code_$i', textField: true, child: TextField( controller: _otpControllers[i], focusNode: _otpFocusNodes[i], textAlign: TextAlign.center, keyboardType: TextInputType.number, maxLength: 1, autofillHints: const [AutofillHints.oneTimeCode], inputFormatters: [ FilteringTextInputFormatter.digitsOnly, ], onChanged: (v) => _onDigitChanged(i, v), style: TextStyle( color: cs.onSurface, fontSize: 20, fontWeight: FontWeight.w600, ), decoration: InputDecoration( counterText: '', contentPadding: const EdgeInsets.symmetric(vertical: 14), isDense: true, filled: true, fillColor: cs.surface, border: OutlineInputBorder( borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: cs.outline.withAlpha(60)), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: cs.outline.withAlpha(60)), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(10), borderSide: const BorderSide( color: AppColors.brand, width: 1.5), ), ), ), ), ); }), ), ), const SizedBox(height: 16), // ── 倒计时 / 粘贴 Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ if (_tabIndex == 0) Semantics( label: 'verify_link_resend', onTap: state.codeCooldown > 0 ? null : _sendCode, child: GestureDetector( onTap: state.codeCooldown > 0 ? null : _sendCode, child: Text( state.codeCooldown > 0 ? l10n.resendCountdown(state.codeCooldown) : l10n.resendCode, style: TextStyle( color: state.codeCooldown > 0 ? cs.onSurface.withAlpha(153) : AppColors.brand, fontSize: 13, ), ), ), ) else const SizedBox.shrink(), GestureDetector( onTap: _handlePaste, child: Text( l10n.paste, style: TextStyle( color: cs.onSurface, fontSize: 13, fontWeight: FontWeight.w600, ), ), ), ], ), const SizedBox(height: 32), // ── 提交按钮 Semantics( label: 'verify_btn_confirm', button: true, enabled: _otpFilled && !state.isLoading, onTap: (_otpFilled && !state.isLoading) ? _handleSubmit : null, child: SizedBox( width: double.infinity, height: 50, child: ElevatedButton( onPressed: (_otpFilled && !state.isLoading) ? _handleSubmit : null, style: ElevatedButton.styleFrom( backgroundColor: AppColors.brand, disabledBackgroundColor: AppColors.brand.withAlpha(80), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), ), child: state.isLoading ? SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.black), ) : Text( l10n.submit, style: TextStyle( color: _otpFilled ? Colors.black : Colors.black.withAlpha(153), fontSize: 16, fontWeight: FontWeight.w600, ), ), ), ), ), // ── 切换链接(仅身份验证器 Tab 显示) if (_tabIndex == 1) ...[ const SizedBox(height: 20), Center( child: Semantics( label: 'verify_link_switch', onTap: () => _switchTab(0), child: GestureDetector( onTap: () => _switchTab(0), child: Text( l10n.switchEmailVerify, style: const TextStyle( color: AppColors.brand, fontSize: 14, ), ), ), ), ), ], ], ), ), ); } } // ── 分段选择器 ─────────────────────────────────────────────── class _SegmentedTab extends StatelessWidget { const _SegmentedTab({ required this.selectedIndex, required this.onChanged, required this.emailLabel, required this.authenticatorLabel, }); final int selectedIndex; final ValueChanged onChanged; final String emailLabel; final String authenticatorLabel; @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; return Container( height: 40, decoration: BoxDecoration( color: isDark ? AppColors.darkBgTertiary : AppColors.lightBgTertiary, borderRadius: BorderRadius.circular(20), ), child: Row( children: [ _buildTab(context, 0, emailLabel), _buildTab(context, 1, authenticatorLabel), ], ), ); } Widget _buildTab(BuildContext context, int index, String label) { final cs = Theme.of(context).colorScheme; final selected = index == selectedIndex; return Expanded( child: GestureDetector( onTap: () => onChanged(index), child: Container( margin: const EdgeInsets.all(3), decoration: BoxDecoration( color: selected ? AppColors.brand : Colors.transparent, borderRadius: BorderRadius.circular(18), ), child: Center( child: Text( label, style: TextStyle( color: selected ? Colors.black : cs.onSurface.withAlpha(153), fontSize: 13, fontWeight: selected ? FontWeight.w600 : FontWeight.w400, ), ), ), ), ), ); } }