| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519 |
- 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<VerifyCodeScreen> createState() => _VerifyCodeScreenState();
- }
- class _VerifyCodeScreenState extends ConsumerState<VerifyCodeScreen> {
- /// 0 = 邮箱验证码, 1 = 身份验证器
- int _tabIndex = 0;
- final List<TextEditingController> _otpControllers =
- List.generate(6, (_) => TextEditingController());
- final List<FocusNode> _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<void> _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<void> _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<AuthState>(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<int> 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,
- ),
- ),
- ),
- ),
- ),
- );
- }
- }
|