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:
2026-04-27 13:54:49 +08:00
parent 3a7378d246
commit d9869bf6af
5 changed files with 374 additions and 86 deletions

View File

@@ -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) {

View File

@@ -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';