Phase 3.4: customer OTP screen rewrite + lockout UX + bug fixes
OTP screen rewrite: 6 rounded boxes, auto-advance focus, auto-submit on the 6th digit, hardware-backspace on empty boxes (intercepted via Focus.onKeyEvent since TextField.onChanged doesn't fire on already-empty input), resend cooldown sourced from /api/shared/config/otp, and an inline error message under the boxes instead of a SnackBar. Several bugs fixed inline that surfaced during testing: - ref.listen inside build() accumulates listeners on every rebuild — the resend countdown's per-second setState was piling up duplicate listeners so one error triggered N callback fires. Moved to ref.listenManual in initState; subscription disposed in dispose(). - RouterNotifier was calling notifyListeners() on every auth state change including AsyncError, which rebuilt the Navigator/Scaffold mid-snackbar and visually duplicated the error toast. Now skips AsyncError and same-data-variant transitions. - ScaffoldMessenger.showSnackBar from a Riverpod listener callback could still render twice even with hideCurrentSnackBar — replaced with an inline error widget to sidestep the snackbar machinery entirely. - register_screen now uses context.go instead of context.push for the OTP route, so re-submitting the phone form doesn't stack multiple OtpScreen instances with active subscriptions. Lockout UX: AuthErrorInfo wraps the error message + code + retry_after_seconds parsed from the backend's structured error response. On rate-limit codes (OTP_COOLDOWN, OTP_RATE_LIMIT_PHONE, OTP_RATE_LIMIT_IP), the OTP screen extends "Kirim ulang kode" cooldown to match the server's wait, and the register screen disables "Kirim OTP" with a "Coba lagi dalam …" countdown. formatCountdown() in core/constants.dart renders Xd under 90 seconds and Xm Yd above (clearer than raw seconds for long lockouts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user