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'; import '../../../core/theme/halo_tokens.dart'; import '../widgets/otp_blocked_popup.dart'; const int _kOtpLength = 6; const int _kFallbackResendCooldownSeconds = 60; // Codes that mean "the user cannot make progress without waiting" — these // trip the OTP-blocked popup. Mirrors backend `otp.service.js`. const _kOtpBlockedCodes = { 'OTP_RATE_LIMIT_PHONE', 'OTP_RATE_LIMIT_IP', 'OTP_COOLDOWN', 'OTP_ATTEMPTS_EXCEEDED', }; 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; bool _blockedPopupShown = false; 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; _authSub = ref.listenManual>(authProvider, (prev, next) { if (next is AsyncError) { if (!mounted) return; final err = next.error; setState(() => _errorMessage = err.toString()); _clearBoxes(); if (err is AuthErrorInfo) { if (_kOtpBlockedCodes.contains(err.code) && !_blockedPopupShown) { _blockedPopupShown = true; OtpBlockedPopup.show(context).then((_) { if (mounted) _blockedPopupShown = false; }); } if (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) { 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; 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: SafeArea( child: Padding( padding: const EdgeInsets.all(HaloSpacing.s24), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( 'Kode OTP telah dikirim ke ${widget.phone}', style: const TextStyle( fontFamily: HaloTokens.fontBody, fontSize: 15, color: HaloTokens.inkSoft, ), ), const SizedBox(height: HaloSpacing.s32), LayoutBuilder( builder: (ctx, constraints) { // 6 boxes laid out across the row. Tighter spacing than the // legacy 4-box layout (Figma reference) so the form still // fits a 320pt-wide screen. const gap = HaloSpacing.s8; final boxWidth = (constraints.maxWidth - gap * (_kOtpLength - 1)) / _kOtpLength; return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: List.generate(_kOtpLength, (i) { return Padding( padding: EdgeInsets.only( right: i == _kOtpLength - 1 ? 0 : gap, ), child: _buildBox(i, boxWidth), ); }), ); }, ), const SizedBox(height: HaloSpacing.s12), if (_errorMessage != null) Text( _errorMessage!, textAlign: TextAlign.center, style: const TextStyle( fontFamily: HaloTokens.fontBody, color: HaloTokens.danger, fontSize: 13, ), ), const SizedBox(height: HaloSpacing.s12), if (isLoading) const Center( child: Padding( padding: EdgeInsets.symmetric(vertical: HaloSpacing.s8), child: CircularProgressIndicator(), ), ), const SizedBox(height: HaloSpacing.s16), _buildResendRow(), ], ), ), ), ); } Widget _buildBox(int index, double width) { return SizedBox( width: width, height: 56, 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( fontFamily: HaloTokens.fontDisplay, fontSize: 22, fontWeight: FontWeight.w700, color: HaloTokens.ink, ), inputFormatters: [FilteringTextInputFormatter.digitsOnly], decoration: InputDecoration( counterText: '', contentPadding: EdgeInsets.zero, filled: true, fillColor: HaloTokens.surface, enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: HaloTokens.border, width: 1.5), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: HaloTokens.brand, 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( fontFamily: HaloTokens.fontBody, color: HaloTokens.brandDark, fontWeight: FontWeight.w600, decoration: TextDecoration.underline, ), ), ) : Text( 'Kirim ulang dalam ${formatCountdown(_resendSeconds)}', style: const TextStyle( fontFamily: HaloTokens.fontBody, color: HaloTokens.inkMuted, ), ), ); } }