// Author: fruit.com.cn import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../core/data/remote/api_client.dart'; import '../../core/data/services/auth_controller.dart'; import '../../core/platform/haptics.dart'; import '../../core/platform/keyboard_inset.dart'; import '../../core/theme/app_colors.dart'; import '../../core/widgets/centered_toast.dart'; import '../../l10n/app_localizations.dart'; const double _fieldHeight = 52; const double _fieldRadius = 14; const double _sheetRadius = 28; const double _buttonHeight = 52; const int _resendSeconds = 60; const int _phoneMinLength = 8; const int _codeMinLength = 4; /// 打开手机号 OTP 登录/注册表单;成功登录后返回 true。 Future showPhoneAuthSheet(BuildContext context, {bool signUp = false}) { return showModalBottomSheet( context: context, isScrollControlled: true, useSafeArea: true, backgroundColor: Colors.transparent, barrierColor: Colors.black.withValues(alpha: 0.62), builder: (_) => PhoneAuthSheet(initialSignUp: signUp), ); } class PhoneAuthSheet extends StatefulWidget { const PhoneAuthSheet({super.key, this.initialSignUp = false}); final bool initialSignUp; @override State createState() => _PhoneAuthSheetState(); } class _PhoneAuthSheetState extends State { late final TextEditingController _phone; late final TextEditingController _code; late final FocusNode _phoneFocus; late final FocusNode _codeFocus; late final ScrollController _scrollController; final _keyboardInset = KeyboardInsetNotifier.instance; Timer? _countdown; double _lastKeyboardInset = 0; bool _signUp = false; bool _sending = false; bool _submitting = false; int _remaining = 0; String? _error; @override void initState() { super.initState(); _signUp = widget.initialSignUp; _phone = TextEditingController(); _code = TextEditingController(); _phoneFocus = FocusNode(debugLabel: 'phone-auth-number'); _codeFocus = FocusNode(debugLabel: 'phone-auth-code'); _scrollController = ScrollController(); _phoneFocus.addListener(_onFocusChanged); _codeFocus.addListener(_onFocusChanged); _keyboardInset.addListener(_onKeyboardInsetChanged); unawaited(_keyboardInset.start()); } @override void didChangeDependencies() { super.didChangeDependencies(); final inset = effectiveKeyboardInset(context, _keyboardInset); if ((inset - _lastKeyboardInset).abs() >= 1) { _lastKeyboardInset = inset; _scheduleRevealFocusedField(); } } @override void dispose() { _keyboardInset.removeListener(_onKeyboardInsetChanged); _phoneFocus.removeListener(_onFocusChanged); _codeFocus.removeListener(_onFocusChanged); _countdown?.cancel(); _phone.dispose(); _code.dispose(); _phoneFocus.dispose(); _codeFocus.dispose(); _scrollController.dispose(); super.dispose(); } void _onKeyboardInsetChanged() { if (!mounted) return; setState(() {}); _scheduleRevealFocusedField(); } void _onFocusChanged() { if (_phoneFocus.hasFocus || _codeFocus.hasFocus) { _scheduleRevealFocusedField(); } } void _scheduleRevealFocusedField() { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _revealFocusedField(); }); } void _revealFocusedField() { if (!_scrollController.hasClients) return; final focus = _codeFocus.hasFocus ? _codeFocus : _phoneFocus.hasFocus ? _phoneFocus : null; final fieldContext = focus?.context; final renderObject = fieldContext?.findRenderObject(); if (renderObject is! RenderBox || !renderObject.hasSize) return; final keyboardInset = effectiveKeyboardInset(context, _keyboardInset); if (keyboardInset <= 0) return; final fieldBottom = renderObject .localToGlobal(Offset(0, renderObject.size.height)) .dy; final visibleBottom = MediaQuery.sizeOf(context).height - keyboardInset - 24; final overlap = fieldBottom - visibleBottom; if (overlap <= 0) return; final position = _scrollController.position; final target = (position.pixels + overlap) .clamp(position.minScrollExtent, position.maxScrollExtent) .toDouble(); if (target <= position.pixels + 0.5) return; unawaited( _scrollController.animateTo( target, duration: const Duration(milliseconds: 200), curve: Curves.easeOutCubic, ), ); } String get _target => _phone.text.replaceAll(RegExp(r'[\s-]'), ''); bool get _phoneValid => _target.length >= _phoneMinLength; Future _sendCode() async { if (_sending || _remaining > 0) return; final l10n = AppLocalizations.of(context)!; if (!_phoneValid) { setState(() => _error = l10n.authPhoneInvalid); return; } setState(() { _sending = true; _error = null; }); final haptics = context.read(); final auth = context.read(); String? failureMessage; await haptics.lightImpact(); try { await auth.sendOtp( target: _target, scene: _signUp ? 'REGISTER' : 'LOGIN', ); if (!mounted) return; _startCountdown(); setState(() => _error = null); ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(l10n.authCodeSent))); _codeFocus.requestFocus(); } on ApiException catch (error) { failureMessage = error.message; } catch (_) { failureMessage = l10n.authNetworkError; } finally { if (mounted) setState(() => _sending = false); } if (mounted && failureMessage != null) { showCenteredToast(context, failureMessage); } } void _startCountdown() { _countdown?.cancel(); _remaining = _resendSeconds; _countdown = Timer.periodic(const Duration(seconds: 1), (timer) { if (!mounted) return timer.cancel(); setState(() => _remaining--); if (_remaining <= 0) timer.cancel(); }); } Future _submit() async { if (_submitting) return; final l10n = AppLocalizations.of(context)!; if (!_phoneValid) { setState(() => _error = l10n.authPhoneInvalid); return; } if (_code.text.trim().length < _codeMinLength) { setState(() => _error = l10n.authCodeRequired); return; } setState(() { _submitting = true; _error = null; }); final haptics = context.read(); final auth = context.read(); String? failureMessage; await haptics.lightImpact(); try { if (_signUp) { await auth.registerWithPhone(target: _target, code: _code.text.trim()); } else { await auth.signInWithPhone(target: _target, code: _code.text.trim()); } if (mounted) Navigator.of(context).pop(true); } on ApiException catch (error) { failureMessage = error.message; } catch (_) { failureMessage = l10n.authNetworkError; } finally { if (mounted) setState(() => _submitting = false); } if (mounted && failureMessage != null) { showCenteredToast(context, failureMessage); } } void _switchMode() { setState(() { _signUp = !_signUp; _error = null; }); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final bottom = effectiveKeyboardInset(context, _keyboardInset); return Padding( padding: EdgeInsets.only(bottom: bottom), child: Container( key: const Key('phone-auth-sheet'), decoration: const BoxDecoration( color: AppColors.card, borderRadius: BorderRadius.vertical( top: Radius.circular(_sheetRadius), ), ), child: SingleChildScrollView( controller: _scrollController, padding: const EdgeInsets.fromLTRB(22, 10, 22, 26), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Center(child: _grabber()), const SizedBox(height: 16), Text( _signUp ? l10n.authPhoneSignUp : l10n.authPhoneTitle, style: const TextStyle( color: AppColors.onSurface, fontSize: 22, fontWeight: FontWeight.w800, ), ), const SizedBox(height: 6), Text( l10n.authSubtitle, style: const TextStyle(color: AppColors.subtle, fontSize: 12), ), const SizedBox(height: 20), _phoneField(l10n), const SizedBox(height: 12), _codeField(l10n), if (_error != null) ...[ const SizedBox(height: 10), Text( _error!, key: const Key('auth-phone-error'), style: const TextStyle(color: AppColors.accent, fontSize: 12), ), ], const SizedBox(height: 18), _submitButton(l10n), const SizedBox(height: 8), Center( child: TextButton( key: const Key('auth-phone-switch'), onPressed: _submitting ? null : _switchMode, style: TextButton.styleFrom( foregroundColor: AppColors.blueSoft, minimumSize: const Size(48, 48), ), child: Text( _signUp ? l10n.authSwitchToSignIn : l10n.authSwitchToSignUp, ), ), ), ], ), ), ), ); } Widget _grabber() => Container( width: 40, height: 4, decoration: BoxDecoration( color: AppColors.subtle.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2), ), ); Widget _phoneField(AppLocalizations l10n) => SizedBox( height: _fieldHeight, child: TextField( key: const Key('auth-phone-number'), controller: _phone, focusNode: _phoneFocus, enabled: !_submitting, keyboardType: TextInputType.phone, textInputAction: TextInputAction.next, autofillHints: const [AutofillHints.telephoneNumber], inputFormatters: [ FilteringTextInputFormatter.allow(RegExp(r'[0-9+\s-]')), LengthLimitingTextInputFormatter(20), ], onSubmitted: (_) => _codeFocus.requestFocus(), onChanged: (_) => setState(() => _error = null), style: const TextStyle(color: AppColors.onSurface, fontSize: 15), decoration: _decoration(l10n.authPhoneLabel, l10n.authPhoneHint), ), ); Widget _codeField(AppLocalizations l10n) => SizedBox( height: _fieldHeight, child: TextField( key: const Key('auth-phone-code'), controller: _code, focusNode: _codeFocus, enabled: !_submitting, keyboardType: TextInputType.number, textInputAction: TextInputAction.done, autofillHints: const [AutofillHints.oneTimeCode], inputFormatters: [ FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(6), ], onSubmitted: (_) => _submit(), onChanged: (_) => setState(() => _error = null), style: const TextStyle(color: AppColors.onSurface, fontSize: 15), decoration: _decoration( l10n.authCodeLabel, l10n.authCodeHint, ).copyWith(suffixIcon: _sendButton(l10n)), ), ); Widget _sendButton(AppLocalizations l10n) => Padding( padding: const EdgeInsets.only(right: 6), child: TextButton( key: const Key('auth-send-code'), onPressed: _sending || _remaining > 0 ? null : _sendCode, style: TextButton.styleFrom( foregroundColor: AppColors.blueSoft, minimumSize: const Size(48, 48), padding: const EdgeInsets.symmetric(horizontal: 10), ), child: _sending ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : Text( _remaining > 0 ? l10n.authResendCode(_remaining) : l10n.authSendCode, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700), ), ), ); Widget _submitButton(AppLocalizations l10n) => SizedBox( width: double.infinity, height: _buttonHeight, child: FilledButton( key: const Key('auth-phone-submit'), onPressed: _submitting ? null : _submit, style: FilledButton.styleFrom( backgroundColor: AppColors.blue, foregroundColor: AppColors.onPrimary, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(_buttonHeight / 2), ), ), child: _submitting ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, color: AppColors.onPrimary, ), ) : Text( _signUp ? l10n.authPhoneSignUpAction : l10n.authPhoneSignInAction, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w800), ), ), ); InputDecoration _decoration(String label, String hint) => InputDecoration( labelText: label, hintText: hint, labelStyle: const TextStyle(color: AppColors.subtle), hintStyle: const TextStyle(color: AppColors.subtle), filled: true, fillColor: AppColors.input, contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), border: OutlineInputBorder( borderRadius: BorderRadius.circular(_fieldRadius), borderSide: BorderSide.none, ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(_fieldRadius), borderSide: const BorderSide(color: AppColors.blue, width: 1.5), ), ); }