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

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