import 'dart:async'; 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/auth/auth_notifier.dart'; import '../../../core/theme/halo_tokens.dart'; import '../../../core/theme/widgets/widgets.dart'; const int _kOtpLength = 6; const int _kMaxAttempts = 5; const int _kResendCooldownSeconds = 60; /// S3b · OTP verification (6-digit) for mitra. /// /// Visual contract mirrors `figma-bestie/project/screens/onboarding.jsx::S3Phone` /// (OTP-step view) with the customer's 4 boxes scaled up to 6 (mitra uses /// 6-digit OTPs from Fazpass). 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; int _attemptsUsed = 0; String? _inlineError; Timer? _cooldownTicker; int _cooldown = _kResendCooldownSeconds; bool _isResending = false; bool _dialogShown = false; ProviderSubscription>? _authSub; @override void initState() { super.initState(); final data = ref.read(mitraAuthProvider).valueOrNull; if (data is MitraAuthOtpSentData) { _otpRequestId = data.otpRequestId; } _startCooldown(); _authSub = ref.listenManual>(mitraAuthProvider, (prev, next) { if (!mounted) return; // Resend completed — the notifier emits a fresh MitraAuthOtpSentData // with a new otp_request_id. Reset local state without bouncing back // to S3a (which the router would otherwise do if we let the listener // fire on the same OtpSentData state). final data = next.valueOrNull; if (data is MitraAuthOtpSentData && data.otpRequestId != _otpRequestId) { _otpRequestId = data.otpRequestId; setState(() { _attemptsUsed = 0; _inlineError = null; }); _clearFieldsAndFocus(); _startCooldown(); return; } if (next is! AsyncError) return; final err = next.error; if (err is MitraAuthError) { _handleAuthError(err); } else { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(err.toString())), ); } }); } @override void dispose() { _authSub?.close(); _cooldownTicker?.cancel(); for (final c in _controllers) { c.dispose(); } for (final f in _focusNodes) { f.dispose(); } super.dispose(); } void _startCooldown() { _cooldownTicker?.cancel(); setState(() => _cooldown = _kResendCooldownSeconds); _cooldownTicker = Timer.periodic(const Duration(seconds: 1), (timer) { if (!mounted) { timer.cancel(); return; } if (_cooldown <= 1) { timer.cancel(); setState(() => _cooldown = 0); } else { setState(() => _cooldown -= 1); } }); } String get _otp => _controllers.map((c) => c.text).join(); void _clearFieldsAndFocus() { for (final c in _controllers) { c.clear(); } _focusNodes[0].requestFocus(); } void _onChanged(int index, String value) { if (value.isNotEmpty && index < _kOtpLength - 1) { _focusNodes[index + 1].requestFocus(); } if (value.isEmpty && index > 0) { _focusNodes[index - 1].requestFocus(); } if (_otp.length == _kOtpLength) { _submit(); } } KeyEventResult _onKeyEvent(int index, KeyEvent 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; } void _submit() { final otp = _otp; if (otp.length != _kOtpLength || _otpRequestId == null) return; setState(() => _inlineError = null); ref.read(mitraAuthProvider.notifier).verifyOtp(_otpRequestId!, otp); } Future _resend() async { if (_cooldown > 0 || _isResending) return; setState(() { _isResending = true; _inlineError = null; }); await ref.read(mitraAuthProvider.notifier).requestOtp(widget.phone); if (!mounted) return; setState(() => _isResending = false); } Future _showBlockedDialog() { return showDialog( context: context, barrierDismissible: false, builder: (ctx) => AlertDialog( title: const Text('Terlalu banyak percobaan'), content: const Text( 'Kode OTP sudah salah terlalu banyak kali. Minta kode baru untuk lanjut.', ), actions: [ TextButton( onPressed: () { Navigator.of(ctx).pop(); if (mounted) context.pop(); }, child: const Text('Minta kode baru'), ), ], ), ); } Future _showResetDialog(MitraAuthError err, {required String title}) { return showDialog( context: context, barrierDismissible: false, builder: (ctx) => AlertDialog( title: Text(title), content: Text(err.message), actions: [ TextButton( onPressed: () { Navigator.of(ctx).pop(); if (mounted) context.pop(); }, child: const Text('Minta kode baru'), ), ], ), ); } Future _showWrongFlowDialog(MitraAuthError err) { return showDialog( context: context, barrierDismissible: false, builder: (ctx) => AlertDialog( title: const Text('Bukan akun mitra'), content: Text( '${err.message}\n\nPastikan kamu pakai aplikasi yang benar.', ), actions: [ TextButton( onPressed: () { Navigator.of(ctx).pop(); if (mounted) context.pop(); }, child: const Text('Kembali'), ), ], ), ); } Future _handleAuthError(MitraAuthError err) async { if (_dialogShown) return; switch (err.code) { case 'CODE_INVALID': setState(() => _inlineError = err.message); break; case 'CODE_MISMATCH': setState(() { _attemptsUsed = (_attemptsUsed + 1).clamp(0, _kMaxAttempts); final remaining = _kMaxAttempts - _attemptsUsed; _inlineError = remaining > 0 ? 'Kode salah. Tersisa $remaining percobaan.' : 'Kode salah.'; }); _clearFieldsAndFocus(); break; case 'OTP_ATTEMPTS_EXCEEDED': _dialogShown = true; await _showBlockedDialog(); _dialogShown = false; break; case 'OTP_EXPIRED': _dialogShown = true; await _showResetDialog(err, title: 'Kode kedaluwarsa'); _dialogShown = false; break; case 'OTP_USED': _dialogShown = true; await _showResetDialog(err, title: 'Kode sudah dipakai'); _dialogShown = false; break; case 'WRONG_FLOW': _dialogShown = true; await _showWrongFlowDialog(err); _dialogShown = false; break; case 'ACCOUNT_INACTIVE': if (mounted) context.go('/auth/inactive'); break; case 'OTP_COOLDOWN': ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(err.message)), ); break; default: ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(err.message)), ); } } @override Widget build(BuildContext context) { final authState = ref.watch(mitraAuthProvider); final isLoading = authState is AsyncLoading; final resendLabel = _cooldown > 0 ? 'kirim ulang dalam ${_cooldown}s' : 'kirim ulang kode'; final resendEnabled = _cooldown == 0 && !_isResending && !isLoading; return Scaffold( backgroundColor: HaloTokens.bg, appBar: AppBar( backgroundColor: HaloTokens.bg, leading: IconButton( icon: const Icon(Icons.arrow_back, color: HaloTokens.ink), onPressed: () => context.pop(), ), ), body: SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(28, 0, 28, 28), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded( child: LayoutBuilder( builder: (context, constraints) => SingleChildScrollView( child: ConstrainedBox( constraints: BoxConstraints(minHeight: constraints.maxHeight), child: IntrinsicHeight( child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const Text( 'masukin 6 digit kode', style: TextStyle( fontFamily: HaloTokens.fontDisplay, fontSize: 28, fontWeight: FontWeight.w700, color: HaloTokens.brandDark, height: 1.15, letterSpacing: -0.56, ), ), const SizedBox(height: 10), RichText( text: TextSpan( style: const TextStyle( fontFamily: HaloTokens.fontBody, fontSize: 14.5, color: HaloTokens.inkSoft, height: 1.5, ), children: [ const TextSpan(text: 'kami baru kirim ke WA '), TextSpan( text: widget.phone, style: const TextStyle( fontWeight: FontWeight.w700, color: HaloTokens.ink, ), ), ], ), ), const SizedBox(height: 28), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: List.generate(_kOtpLength, (i) { return _OtpBox( controller: _controllers[i], focusNode: _focusNodes[i], autofocus: i == 0, onChanged: (v) => _onChanged(i, v), onKeyEvent: (e) => _onKeyEvent(i, e), hasError: _inlineError != null, ); }), ), if (_inlineError != null) ...[ const SizedBox(height: 12), Text( _inlineError!, textAlign: TextAlign.center, style: const TextStyle( fontFamily: HaloTokens.fontBody, color: HaloTokens.danger, fontSize: 13, ), ), ], const SizedBox(height: 20), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text( 'gak nyampe? ', style: TextStyle( fontFamily: HaloTokens.fontBody, fontSize: 13, color: HaloTokens.inkSoft, ), ), GestureDetector( onTap: resendEnabled ? _resend : null, child: Text( resendLabel, style: TextStyle( fontFamily: HaloTokens.fontBody, fontSize: 13, fontWeight: FontWeight.w600, color: resendEnabled ? HaloTokens.brandDark : HaloTokens.inkMuted, ), ), ), ], ), ], ), ), ), ), ), ), HaloButton( label: isLoading ? 'memproses...' : 'verifikasi', fullWidth: true, onPressed: (isLoading || _otp.length != _kOtpLength) ? null : _submit, ), ], ), ), ), ); } } class _OtpBox extends StatelessWidget { final TextEditingController controller; final FocusNode focusNode; final bool autofocus; final ValueChanged onChanged; // Use `Focus(canRequestFocus: false)` not `KeyboardListener` — the latter // spawns an extra FocusNode that swallows IME input on Android, which // breaks both maestro `inputText` and SMS auto-paste. Matches the customer // app pattern in client_app/.../otp_screen.dart::_buildBox. final KeyEventResult Function(KeyEvent) onKeyEvent; final bool hasError; const _OtpBox({ required this.controller, required this.focusNode, required this.autofocus, required this.onChanged, required this.onKeyEvent, required this.hasError, }); @override Widget build(BuildContext context) { final filled = controller.text.isNotEmpty; final borderColor = hasError ? HaloTokens.danger : filled ? HaloTokens.brand : HaloTokens.border; return SizedBox( width: 48, height: 60, child: Focus( canRequestFocus: false, onKeyEvent: (_, event) => onKeyEvent(event), child: TextField( controller: controller, focusNode: focusNode, autofocus: autofocus, textAlign: TextAlign.center, keyboardType: TextInputType.number, maxLength: 1, style: const TextStyle( fontFamily: HaloTokens.fontBody, fontSize: 24, fontWeight: FontWeight.w700, color: HaloTokens.ink, ), decoration: InputDecoration( counterText: '', filled: true, fillColor: HaloTokens.surface, contentPadding: const EdgeInsets.symmetric(vertical: 14), border: OutlineInputBorder( borderRadius: HaloRadius.md, borderSide: BorderSide(color: borderColor, width: 1.5), ), enabledBorder: OutlineInputBorder( borderRadius: HaloRadius.md, borderSide: BorderSide(color: borderColor, width: 1.5), ), focusedBorder: OutlineInputBorder( borderRadius: HaloRadius.md, borderSide: BorderSide( color: hasError ? HaloTokens.danger : HaloTokens.brand, width: 2, ), ), ), inputFormatters: [FilteringTextInputFormatter.digitsOnly], onChanged: onChanged, ), ), ); } }