diff --git a/client_app/lib/core/auth/auth_notifier.dart b/client_app/lib/core/auth/auth_notifier.dart index d419379..fae74bb 100644 --- a/client_app/lib/core/auth/auth_notifier.dart +++ b/client_app/lib/core/auth/auth_notifier.dart @@ -10,6 +10,20 @@ import 'token_storage.dart'; part 'auth_notifier.g.dart'; +// Error envelope — carries the user-facing message plus structured details +// (error code, optional retry_after_seconds) so screens can gate CTAs after +// rate-limit responses without re-parsing the message string. +class AuthErrorInfo { + final String message; + final String? code; + final int? retryAfterSeconds; + + const AuthErrorInfo(this.message, {this.code, this.retryAfterSeconds}); + + @override + String toString() => message; +} + // States sealed class AuthData { @@ -217,9 +231,12 @@ class Auth extends _$Auth { channelUsed: data['channel_used'] as String?, )); } on DioException catch (e) { - state = AsyncError(_otpRequestMessage(e), StackTrace.current); + state = AsyncError(_otpRequestErrorInfo(e), StackTrace.current); } catch (_) { - state = AsyncError('Gagal mengirim OTP. Coba lagi.', StackTrace.current); + state = AsyncError( + const AuthErrorInfo('Gagal mengirim OTP. Coba lagi.'), + StackTrace.current, + ); } } @@ -239,9 +256,12 @@ class Auth extends _$Auth { final profile = await _applyTokens(response); state = AsyncData(await _stateForProfile(profile)); } on DioException catch (e) { - state = AsyncError(_otpVerifyMessage(e), StackTrace.current); + state = AsyncError(_otpVerifyErrorInfo(e), StackTrace.current); } catch (_) { - state = AsyncError('Gagal verifikasi. Coba lagi.', StackTrace.current); + state = AsyncError( + const AuthErrorInfo('Gagal verifikasi. Coba lagi.'), + StackTrace.current, + ); } } @@ -296,7 +316,7 @@ class Auth extends _$Auth { state = const AsyncLoading(); try { final credential = await SignInWithApple.getAppleIDCredential( - scopes: [AppleIDAuthorizationScopes.email, AppleIDAuthorizationScopes.fullName], + scopes: [AppleIDAuthorizationScopes.email], ); final idToken = credential.identityToken; if (idToken == null) { @@ -352,41 +372,40 @@ class Auth extends _$Auth { // ---------------- Error-code mapping ---------------- - String _otpRequestMessage(DioException e) { - final code = e.response?.data?['error']?['code'] as String?; - switch (code) { - case 'PHONE_INVALID': - return 'Nomor HP tidak valid.'; - case 'OTP_COOLDOWN': - return e.response?.data?['error']?['message'] as String? ?? - 'Tunggu sebentar sebelum minta OTP lagi.'; - case 'OTP_RATE_LIMIT_PHONE': - case 'OTP_RATE_LIMIT_IP': - return 'Terlalu banyak permintaan OTP. Coba lagi nanti.'; - default: - return 'Gagal mengirim OTP. Coba lagi.'; - } + int? _retryAfterSecondsFrom(DioException e) { + final raw = e.response?.data?['error']?['details']?['retry_after_seconds']; + if (raw is num) return raw.toInt(); + return null; } - String _otpVerifyMessage(DioException e) { + AuthErrorInfo _otpRequestErrorInfo(DioException e) { final code = e.response?.data?['error']?['code'] as String?; - switch (code) { - case 'WRONG_FLOW': - return 'OTP tidak valid untuk login pelanggan.'; - case 'CODE_MISMATCH': - case 'CODE_INVALID': - return 'Kode OTP salah.'; - case 'OTP_EXPIRED': - return 'Kode OTP kedaluwarsa. Minta kode baru.'; - case 'OTP_USED': - return 'Kode OTP sudah digunakan.'; - case 'OTP_ATTEMPTS_EXCEEDED': - return 'Terlalu banyak percobaan. Minta kode baru.'; - case 'IDENTITY_CONFLICT': - return 'Nomor ini sudah terdaftar di akun lain.'; - default: - return 'Gagal verifikasi. Coba lagi.'; - } + final retryAfter = _retryAfterSecondsFrom(e); + final message = switch (code) { + 'PHONE_INVALID' => 'Nomor HP tidak valid.', + 'OTP_COOLDOWN' => + e.response?.data?['error']?['message'] as String? ?? + 'Tunggu sebentar sebelum minta OTP lagi.', + 'OTP_RATE_LIMIT_PHONE' || 'OTP_RATE_LIMIT_IP' => + 'Terlalu banyak permintaan OTP. Coba lagi nanti.', + _ => 'Gagal mengirim OTP. Coba lagi.', + }; + return AuthErrorInfo(message, code: code, retryAfterSeconds: retryAfter); + } + + AuthErrorInfo _otpVerifyErrorInfo(DioException e) { + final code = e.response?.data?['error']?['code'] as String?; + final retryAfter = _retryAfterSecondsFrom(e); + final message = switch (code) { + 'WRONG_FLOW' => 'OTP tidak valid untuk login pelanggan.', + 'CODE_MISMATCH' || 'CODE_INVALID' => 'Kode OTP salah.', + 'OTP_EXPIRED' => 'Kode OTP kedaluwarsa. Minta kode baru.', + 'OTP_USED' => 'Kode OTP sudah digunakan.', + 'OTP_ATTEMPTS_EXCEEDED' => 'Terlalu banyak percobaan. Minta kode baru.', + 'IDENTITY_CONFLICT' => 'Nomor ini sudah terdaftar di akun lain.', + _ => 'Gagal verifikasi. Coba lagi.', + }; + return AuthErrorInfo(message, code: code, retryAfterSeconds: retryAfter); } String _socialSignInMessage(DioException e) { diff --git a/client_app/lib/core/constants.dart b/client_app/lib/core/constants.dart index 64dc5f9..e1a7f9f 100644 --- a/client_app/lib/core/constants.dart +++ b/client_app/lib/core/constants.dart @@ -1,3 +1,14 @@ +/// Format a remaining-seconds countdown for display in a button or label. +/// - Under 90 seconds: "Xd" (e.g. "60d") +/// - 90 seconds and up: "Xm Yd" (e.g. "11m 40d") +/// `d` and `m` are Indonesian short forms for detik (second) and menit (minute). +String formatCountdown(int totalSeconds) { + if (totalSeconds < 90) return '${totalSeconds}d'; + final minutes = totalSeconds ~/ 60; + final seconds = totalSeconds % 60; + return '${minutes}m ${seconds}d'; +} + /// User types class UserType { static const customer = 'customer'; diff --git a/client_app/lib/features/auth/screens/otp_screen.dart b/client_app/lib/features/auth/screens/otp_screen.dart index 3a37f0c..e834b80 100644 --- a/client_app/lib/features/auth/screens/otp_screen.dart +++ b/client_app/lib/features/auth/screens/otp_screen.dart @@ -1,6 +1,16 @@ +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; @@ -11,41 +21,151 @@ class OtpScreen extends ConsumerStatefulWidget { } class _OtpScreenState extends ConsumerState { - final _otpController = TextEditingController(); + 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(); - // Capture OTP request id from current state final data = ref.read(authProvider).valueOrNull; - if (data is AuthOtpSentData) { - _otpRequestId = data.otpRequestId; - } + 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() { - _otpController.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; - // Update OTP request id if state changes (e.g. resend) final data = authState.valueOrNull; - if (data is AuthOtpSentData) { - _otpRequestId = data.otpRequestId; - } - - ref.listen(authProvider, (prev, next) { - if (next is AsyncError) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString()))); - } - }); + if (data is AuthOtpSentData) _otpRequestId = data.otpRequestId; return Scaffold( appBar: AppBar(title: const Text('Masukkan OTP')), @@ -55,30 +175,100 @@ class _OtpScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text('Kode OTP telah dikirim ke ${widget.phone}'), - const SizedBox(height: 24), - TextField( - controller: _otpController, - decoration: const InputDecoration( - labelText: 'Kode OTP', - border: OutlineInputBorder(), - ), - keyboardType: TextInputType.number, - maxLength: 6, + const SizedBox(height: 32), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: List.generate(_kOtpLength, _buildBox), ), const SizedBox(height: 12), - ElevatedButton( - onPressed: isLoading ? null : () { - final otp = _otpController.text.trim(); - if (otp.length != 6 || _otpRequestId == null) return; - ref.read(authProvider.notifier).verifyOtp(_otpRequestId!, otp); - }, - child: isLoading - ? const CircularProgressIndicator() - : const Text('Verifikasi'), - ), + 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), + ), + ); + } } diff --git a/client_app/lib/features/auth/screens/register_screen.dart b/client_app/lib/features/auth/screens/register_screen.dart index acfe576..8fddc6e 100644 --- a/client_app/lib/features/auth/screens/register_screen.dart +++ b/client_app/lib/features/auth/screens/register_screen.dart @@ -1,8 +1,10 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../core/auth/auth_notifier.dart'; import '../../../core/auth/social_auth_enabled.dart'; +import '../../../core/constants.dart'; class RegisterScreen extends ConsumerStatefulWidget { const RegisterScreen({super.key}); @@ -13,27 +15,73 @@ class RegisterScreen extends ConsumerStatefulWidget { class _RegisterScreenState extends ConsumerState { final _phoneController = TextEditingController(); + ProviderSubscription>? _authSub; + + // Server-imposed lockout: when /otp/request returns 429, the backend + // includes retry_after_seconds. We disable "Kirim OTP" for that window. + int _lockoutSeconds = 0; + Timer? _lockoutTimer; + String? _errorMessage; + + @override + void initState() { + super.initState(); + // Listener registered once in initState — keeps it independent of the + // build cycle so it doesn't accumulate (see feedback_riverpod_listen_in_build). + _authSub = ref.listenManual>(authProvider, (prev, next) { + if (!mounted) return; + final data = next.valueOrNull; + if (data is AuthOtpSentData) { + // Use go (replace) so re-submitting the phone form doesn't stack + // multiple OtpScreen instances with active listeners. + context.go('/auth/otp', extra: _phoneController.text.trim()); + return; + } + if (next is AsyncError) { + final err = next.error; + setState(() => _errorMessage = err.toString()); + if (err is AuthErrorInfo && + err.retryAfterSeconds != null && + (err.code == 'OTP_COOLDOWN' || + err.code == 'OTP_RATE_LIMIT_PHONE' || + err.code == 'OTP_RATE_LIMIT_IP')) { + _startLockout(err.retryAfterSeconds!); + } + } else if (next is AsyncData) { + if (_errorMessage != null) setState(() => _errorMessage = null); + } + }); + } @override void dispose() { + _authSub?.close(); + _lockoutTimer?.cancel(); _phoneController.dispose(); super.dispose(); } + void _startLockout(int seconds) { + _lockoutTimer?.cancel(); + setState(() => _lockoutSeconds = seconds); + _lockoutTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (!mounted) { + timer.cancel(); + return; + } + setState(() { + if (_lockoutSeconds > 0) _lockoutSeconds--; + if (_lockoutSeconds <= 0) timer.cancel(); + }); + }); + } + @override Widget build(BuildContext context) { final authState = ref.watch(authProvider); final isLoading = authState is AsyncLoading; - - ref.listen(authProvider, (prev, next) { - final data = next.valueOrNull; - if (data is AuthOtpSentData) { - context.push('/auth/otp', extra: _phoneController.text.trim()); - } - if (next is AsyncError) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString()))); - } - }); + final isLockedOut = _lockoutSeconds > 0; + final canSubmit = !isLoading && !isLockedOut; return Scaffold( appBar: AppBar(title: const Text('Masuk / Daftar')), @@ -76,15 +124,25 @@ class _RegisterScreenState extends ConsumerState { ), const SizedBox(height: 12), ElevatedButton( - onPressed: isLoading ? null : () { + onPressed: canSubmit ? () { final phone = _phoneController.text.trim(); if (phone.isEmpty) return; ref.read(authProvider.notifier).requestOtp(phone); - }, + } : null, child: isLoading ? const CircularProgressIndicator() - : const Text('Kirim OTP'), + : Text(isLockedOut + ? 'Coba lagi dalam ${formatCountdown(_lockoutSeconds)}' + : 'Kirim OTP'), ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + Text( + _errorMessage!, + textAlign: TextAlign.center, + style: TextStyle(color: Colors.red.shade700, fontSize: 13), + ), + ], ], ), ), diff --git a/client_app/lib/router.dart b/client_app/lib/router.dart index ffd00ec..e9bc71d 100644 --- a/client_app/lib/router.dart +++ b/client_app/lib/router.dart @@ -22,7 +22,17 @@ class RouterNotifier extends ChangeNotifier { final Ref _ref; RouterNotifier(this._ref) { - _ref.listen(authProvider, (_, __) => notifyListeners()); + _ref.listen(authProvider, (prev, next) { + // Errors are handled locally by screens (toast) — they should never + // trigger router/Navigator rebuilds, otherwise the active SnackBar + // re-animates and looks like a duplicate toast. + if (next is AsyncError) return; + // Skip transient AsyncLoading where the data variant didn't change. + if (prev?.valueOrNull?.runtimeType == next.valueOrNull?.runtimeType) { + return; + } + notifyListeners(); + }); } }