import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/api/api_client_provider.dart'; import '../../../core/auth/auth_notifier.dart'; import '../../../core/constants.dart'; const int _kOtpLength = 6; const int _kFallbackResendCooldownSeconds = 60; const Color _kAccentPink = Color(0xFFBE7C8A); const Color _kBoxBorder = Color(0xFFE0E0E0); class OtpScreen extends ConsumerStatefulWidget { final String phone; const OtpScreen({super.key, required this.phone}); @override ConsumerState createState() => _OtpScreenState(); } class _OtpScreenState extends ConsumerState { final List _controllers = List.generate(_kOtpLength, (_) => TextEditingController()); final List _focusNodes = List.generate(_kOtpLength, (_) => FocusNode()); String? _otpRequestId; bool _autoSubmitted = false; String? _errorMessage; int _resendSeconds = _kFallbackResendCooldownSeconds; int _resendCooldown = _kFallbackResendCooldownSeconds; Timer? _resendTimer; ProviderSubscription>? _authSub; @override void initState() { super.initState(); final data = ref.read(authProvider).valueOrNull; if (data is AuthOtpSentData) _otpRequestId = data.otpRequestId; // Register the auth listener ONCE — must NOT live in build(), or the // resend countdown's setState will pile up duplicate listeners every // second and the error toast will fire many times per state change. _authSub = ref.listenManual>(authProvider, (prev, next) { if (next is AsyncError) { if (!mounted) return; final err = next.error; setState(() => _errorMessage = err.toString()); _clearBoxes(); // If the server says we're rate-limited, extend the resend countdown // to match — disables "Kirim ulang kode" until the lockout clears. if (err is AuthErrorInfo && err.retryAfterSeconds != null && (err.code == 'OTP_COOLDOWN' || err.code == 'OTP_RATE_LIMIT_PHONE' || err.code == 'OTP_RATE_LIMIT_IP')) { _resendCooldown = err.retryAfterSeconds!; _startResendCountdown(); } } else if (next is AsyncLoading || next is AsyncData) { if (_errorMessage != null && mounted) { setState(() => _errorMessage = null); } } }); _fetchResendCooldown(); _startResendCountdown(); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _focusNodes.first.requestFocus(); }); } @override void dispose() { _authSub?.close(); _resendTimer?.cancel(); for (final c in _controllers) { c.dispose(); } for (final f in _focusNodes) { f.dispose(); } super.dispose(); } Future _fetchResendCooldown() async { try { final response = await ref.read(apiClientProvider).get('/api/shared/config/otp'); final data = response['data'] as Map?; final value = data?['resend_cooldown_seconds'] as int?; if (value != null && value > 0 && mounted) { setState(() { _resendCooldown = value; _resendSeconds = value; }); } } catch (_) { // Stick with fallback. } } void _startResendCountdown() { _resendTimer?.cancel(); setState(() => _resendSeconds = _resendCooldown); _resendTimer = Timer.periodic(const Duration(seconds: 1), (timer) { if (!mounted) { timer.cancel(); return; } setState(() { if (_resendSeconds > 0) _resendSeconds--; if (_resendSeconds <= 0) timer.cancel(); }); }); } String _readCode() => _controllers.map((c) => c.text).join(); void _clearBoxes({bool refocusFirst = true}) { for (final c in _controllers) { c.clear(); } _autoSubmitted = false; if (refocusFirst && mounted) _focusNodes.first.requestFocus(); } void _onDigitChanged(int index, String value) { // Move forward when a digit is entered, back when cleared. if (value.isNotEmpty && index < _kOtpLength - 1) { _focusNodes[index + 1].requestFocus(); } if (value.isEmpty && index > 0) { _focusNodes[index - 1].requestFocus(); } final code = _readCode(); if (code.length == _kOtpLength && !_autoSubmitted && _otpRequestId != null) { _autoSubmitted = true; // Keep keyboard open during verify — dismissing it caused a Scaffold // layout shift mid-snackbar-animation, which made the error toast // visually duplicate. ref.read(authProvider.notifier).verifyOtp(_otpRequestId!, code); } } Future _resend() async { if (_resendSeconds > 0) return; _clearBoxes(); await ref.read(authProvider.notifier).requestOtp(widget.phone); if (!mounted) return; final next = ref.read(authProvider).valueOrNull; if (next is AuthOtpSentData) _otpRequestId = next.otpRequestId; _startResendCountdown(); } @override Widget build(BuildContext context) { final authState = ref.watch(authProvider); final isLoading = authState is AsyncLoading; final data = authState.valueOrNull; if (data is AuthOtpSentData) _otpRequestId = data.otpRequestId; return Scaffold( appBar: AppBar(title: const Text('Masukkan OTP')), body: Padding( padding: const EdgeInsets.all(24), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text('Kode OTP telah dikirim ke ${widget.phone}'), const SizedBox(height: 32), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: List.generate(_kOtpLength, _buildBox), ), const SizedBox(height: 12), if (_errorMessage != null) Text( _errorMessage!, textAlign: TextAlign.center, style: TextStyle(color: Colors.red.shade700, fontSize: 13), ), const SizedBox(height: 12), if (isLoading) const Center( child: Padding( padding: EdgeInsets.symmetric(vertical: 8), child: CircularProgressIndicator(), ), ), const SizedBox(height: 16), _buildResendRow(), ], ), ), ); } Widget _buildBox(int index) { return SizedBox( width: 48, height: 56, // Wrap with Focus to intercept hardware backspace BEFORE the TextField: // when the current box is empty, TextField.onChanged doesn't fire on // backspace, so we'd be stuck. We catch it here and rewind one box. child: Focus( canRequestFocus: false, onKeyEvent: (node, event) { if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.backspace && _controllers[index].text.isEmpty && index > 0) { _controllers[index - 1].clear(); _focusNodes[index - 1].requestFocus(); return KeyEventResult.handled; } return KeyEventResult.ignored; }, child: TextField( controller: _controllers[index], focusNode: _focusNodes[index], autofocus: index == 0, keyboardType: TextInputType.number, textAlign: TextAlign.center, maxLength: 1, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w600), inputFormatters: [FilteringTextInputFormatter.digitsOnly], decoration: InputDecoration( counterText: '', contentPadding: EdgeInsets.zero, enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: _kBoxBorder, width: 1.5), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: _kAccentPink, width: 2), ), ), onChanged: (v) => _onDigitChanged(index, v), ), ), ); } Widget _buildResendRow() { final canResend = _resendSeconds <= 0; return Center( child: canResend ? GestureDetector( onTap: _resend, child: const Text( 'Kirim ulang kode', style: TextStyle( color: _kAccentPink, fontWeight: FontWeight.w600, decoration: TextDecoration.underline, ), ), ) : Text( 'Kirim ulang dalam ${formatCountdown(_resendSeconds)}', style: TextStyle(color: Colors.grey.shade600), ), ); } }