# Mitra Pre-Home OTP — Implementation Plan

> **Status (2026-05-19):** Shipped. All five stages complete; 5 maestro
> flows green (`ts-mitra-A-01/03/04/05/06`). Plus a partial Bestie Home
> rebuild (greeting + status card + Ganti Status CTA + Pengingat). Remaining
> Bestie design work — BestieTabBar bottom nav, Undangan (both tabs), Profil,
> Incoming popups, Chat sesi aktif / durasi habis — is **not** in this plan;
> tracked in [[project-mitra-prehome-shipped]] memory.
>
> **Scope tweak during implementation:** mitras are internal-only — no public
> WhatsApp/Telegram admin CTAs surface on the pre-home screens. They reach the
> team through their existing internal channel (Slack, coordinator, etc).
> Stage 2 (admin contact helper + `url_launcher`) was removed; all "Hubungi
> admin" buttons stripped from S3a rate-limit popup, S3b blocked dialog, and
> AccountInactive. `support_handles_json` config remains the source of truth
> for customer-facing admin contact and will be fetched post-login by the
> mitra profile page in a separate change.
>
> Implements [flow_mitra.md §A](flow_mitra.md) (sub-flows A1–A4) and the
> "Implementation gaps (mitra_app)" table in
> [flow_mitra.mermaid.md §A](flow_mitra.mermaid.md).

> Spec sources:
> - PRD / flow: [flow_mitra.md §A](flow_mitra.md)
> - Diagrams: [flow_mitra.mermaid.md §A](flow_mitra.mermaid.md)
>
> Backend behavior is already correct — every error code, retry-after detail,
> and `ACCOUNT_INACTIVE` gate is in
> [`otp.service.js`](../backend/src/services/otp.service.js) and
> [`mitra.auth.routes.js`](../backend/src/routes/public/mitra.auth.routes.js).
> **No backend changes in this plan.** All work is mitra_app-side.

This document is the build sequence: **what** files change, **in what order**,
with **state-machine contracts** and **error-code routing** spelled out per
screen. The "why" is in the PRD — don't restate it here.

---

## Build Order (5 stages — Stage 2 dropped during impl)

Stage 1 is foundation (typed error) that everything else builds on. Stages 3–4
are the two screens. Stage 5 adds the terminal state. Stage 6 is verification.

1. **Typed auth-error model** — replace string `AsyncError` with structured `MitraAuthError`
2. ~~**Admin contact helper** — single source of truth for WA / Telegram URLs + `url_launcher` wrapper~~ — **dropped**: mitras are internal-only, no public admin CTA needed on pre-home screens.
3. **S3a · Input WhatsApp** — replace generic snackbar with code-routed handling
4. **S3b · OTP verification** — resend timer, attempts hint, six new dialog states
5. **AccountInactiveScreen + router wiring** — terminal full-screen state for `ACCOUNT_INACTIVE`
6. **Manual verification** — drive every error path with `OTP_STATIC_CODE` + CC toggles

Each stage is independently mergeable. Stage 4 is the largest single change
(~150 LoC across one file).

---

# Stage 1 — Typed auth-error model

The current
[`auth_notifier.dart`](../mitra_app/lib/core/auth/auth_notifier.dart)
flattens every error into `AsyncError(string)` via `_otpRequestMessage` /
`_otpVerifyMessage`. The screen can read the localized message but loses the
error **code** and the `retry_after_seconds` detail — both of which the UI
needs to pick between snackbar / dialog / inline / full-screen, and to render
countdowns.

## 1.1 New class

> **New:** in [`auth_notifier.dart`](../mitra_app/lib/core/auth/auth_notifier.dart),
> alongside the existing `MitraAuthData` sealed hierarchy.

```dart
class MitraAuthError implements Exception {
  final String code;          // e.g. 'OTP_COOLDOWN', 'ACCOUNT_INACTIVE'
  final String message;       // localized, ready to show as-is
  final int? retryAfterSeconds;

  const MitraAuthError(this.code, this.message, {this.retryAfterSeconds});

  @override
  String toString() => message;  // preserves existing snackbar fallback behavior
}
```

The `toString()` override means any screen that hasn't been updated yet will
keep working (just rendering the message string).

## 1.2 Parse helpers

Replace `_otpRequestMessage` / `_otpVerifyMessage` with a single
`_buildError(DioException e, Map<String, String> codeToMessage)` that:

1. Extracts `code`, `message`, and `details.retry_after_seconds` from
   `e.response?.data?['error']` (defaulting safely).
2. Returns a `MitraAuthError`. The `codeToMessage` arg supplies the localized
   fallback when the backend doesn't include a message string.

The two existing string maps (request vs verify) become `static const`
`Map<String, String>` lookups so the codeToMessage tables don't drift.

## 1.3 Update `requestOtp` / `verifyOtp`

Replace:
```dart
state = AsyncError(_otpRequestMessage(e), StackTrace.current);
```
with:
```dart
state = AsyncError(_buildError(e, _kRequestMessages), StackTrace.current);
```

The unknown-error fallback (the bare `catch(_)`) also returns a
`MitraAuthError('UNKNOWN', 'Gagal …')` so the screen never receives a raw
string.

## 1.4 Acceptance

- `auth_notifier.dart` exports `MitraAuthError`.
- Every `AsyncError` emitted by the notifier carries one.
- Existing call sites (login_screen / otp_screen) still compile because
  `error.toString()` still returns the message.

---

# Stage 2 — ~~Admin contact helper~~ (dropped)

> Dropped during implementation. Mitras are an internal cohort and have a
> separate, non-public support channel (coordinator / Slack / WhatsApp group
> that ops onboards them through). Surfacing the customer-facing
> `support_handles_json` numbers on the pre-home screens would mix audiences.
>
> Consequence in the screens that follow: any "Hubungi admin" CTA is omitted.
> The IP-rate-limit popup, the attempts-exceeded dialog, and the
> AccountInactive screen all rely on the mitra knowing how to reach their
> internal contact.
>
> The post-login mitra profile page will still surface
> `support_handles_json` (separate change) — that's a different audience
> (the customer-facing admin who handles billing/escalation questions).

---

# Stage 3 — S3a · Input WhatsApp

Update [`login_screen.dart`](../mitra_app/lib/features/auth/screens/login_screen.dart)
to route on `MitraAuthError.code` instead of dumping every error into a
snackbar.

## 3.1 State additions

```dart
String? _phoneErrorText;        // inline TextField error
int? _phoneRateLimitRetryAfter; // drives popup countdown
```

## 3.2 `ref.listen` rewrite

Replace the existing AsyncError branch:

```dart
if (next is AsyncError && next.error is MitraAuthError) {
  final err = next.error as MitraAuthError;
  switch (err.code) {
    case 'PHONE_INVALID':
      setState(() => _phoneErrorText = err.message);
      break;
    case 'OTP_COOLDOWN':
      // server message already includes seconds
      ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(err.message)));
      break;
    case 'OTP_RATE_LIMIT_PHONE':
      await _showRateLimitDialog(err, isIp: false);
      break;
    case 'OTP_RATE_LIMIT_IP':
      await _showRateLimitDialog(err, isIp: true);
      break;
    default:
      ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(err.message)));
  }
}
```

## 3.3 `_showRateLimitDialog`

`AlertDialog` with:
- Title: "Terlalu banyak permintaan untuk nomor ini" (or "…dari jaringan ini" for IP)
- Body: server message + retry-after subtext if present
- Single action: "Tutup" (Navigator.pop)

No live countdown inside the dialog (just static text "Coba lagi dalam N
menit"). Keeping it static avoids `Timer.periodic` inside a dialog — overkill
for an error popup. No admin CTA (see Stage 2 note).

## 3.4 `TextField` wiring

The phone field gets `errorText: _phoneErrorText`. Submitting clears
`_phoneErrorText` first so it doesn't linger on retry.

## 3.5 Acceptance

- Submitting `+99999` → field shows inline error, no snackbar.
- Submitting twice within 60s → snackbar with "Tunggu Ns…".
- Submitting 4× in an hour → rate-limit dialog with retry-after.
- Submitting from a heavily-trafficked IP (10/h) → IP rate-limit dialog with
  "Hubungi admin" CTA that opens WA.

---

# Stage 4 — S3b · OTP verification

The biggest single file change.
[`otp_screen.dart`](../mitra_app/lib/features/auth/screens/otp_screen.dart)
gains: resend button + 60s countdown, local attempts-remaining hint, and
six branched error paths.

## 4.1 New state

```dart
Timer? _cooldownTicker;
int _cooldown = 60;          // seconds until resend becomes enabled
int _attemptsUsed = 0;       // 0–5, drives "Tersisa N percobaan"
String? _inlineError;        // for CODE_INVALID
```

## 4.2 Cooldown timer

- Start in `initState` (cooldown begins the moment we land on this screen).
- `Timer.periodic(const Duration(seconds: 1), ...)` decrements `_cooldown`
  until 0, then cancels itself.
- Cancel in `dispose()`. **Do not** touch ref in dispose — see
  [`mitra_app/CLAUDE.md`](../mitra_app/CLAUDE.md) "no_ref_in_dispose" rule.
  This timer doesn't need ref, so plain `dispose()` is fine.

## 4.3 Resend button

Below the OTP fields, above the verify button:

```dart
TextButton(
  onPressed: _cooldown > 0 ? null : _resend,
  child: Text(_cooldown > 0 ? 'Kirim ulang dalam ${_cooldown}s' : 'Kirim ulang kode'),
)
```

`_resend` calls `ref.read(mitraAuthProvider.notifier).requestOtp(widget.phone)`
and on success listener fires (`MitraAuthOtpSentData` re-emitted with a new
`otp_request_id`), the screen:
- resets `_attemptsUsed = 0`
- resets `_cooldown = 60` and restarts the ticker
- clears the input fields

## 4.4 Attempts-remaining hint

A small text widget below the input row:

```dart
if (_attemptsUsed > 0)
  Text('Tersisa ${5 - _attemptsUsed} percobaan',
       style: Theme.of(context).textTheme.bodySmall?.copyWith(color: ...))
```

Incremented in the `CODE_MISMATCH` branch (Stage 4.5).

## 4.5 `ref.listen` rewrite

```dart
if (next is AsyncError && next.error is MitraAuthError) {
  final err = next.error as MitraAuthError;
  switch (err.code) {
    case 'CODE_INVALID':
      setState(() => _inlineError = err.message);
      break;
    case 'CODE_MISMATCH':
      setState(() {
        _attemptsUsed += 1;
        _inlineError = 'Kode salah. Tersisa ${5 - _attemptsUsed} percobaan';
      });
      _clearFieldsAndFocus();
      break;
    case 'OTP_ATTEMPTS_EXCEEDED':
      await _showBlockedDialog();
      break;
    case 'OTP_EXPIRED':
    case 'OTP_USED':
      await _showResetDialog(err);
      break;
    case 'WRONG_FLOW':
      await _showWrongFlowDialog(err);
      break;
    case 'ACCOUNT_INACTIVE':
      if (mounted) context.go('/auth/inactive');
      break;
    default:
      ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(err.message)));
  }
}
```

## 4.6 Dialog helpers

- **`_showBlockedDialog`** — "Terlalu banyak percobaan". Single CTA: "Minta
  kode baru" (`context.pop()` → returns to S3a). No admin CTA (Stage 2 note).
- **`_showResetDialog`** — used by `OTP_EXPIRED` and `OTP_USED`. Single CTA
  "Minta kode baru" → `context.pop()`.
- **`_showWrongFlowDialog`** — "Bukan akun mitra. Pastikan kamu pakai app yang
  benar." Single CTA → `context.pop()`.

All dialogs use `barrierDismissible: false` — the user must choose a recovery
path.

## 4.7 Acceptance

- 5× wrong code → hint decrements, blocked dialog on 5th, "Minta kode baru"
  pops to S3a.
- Wait 5 min after request, then submit → expired dialog.
- Resend within 60s of first request → cooldown blocks the button.
- After cooldown, tap resend → fields clear, hint clears, timer restarts.
- Submit code for a mitra with `is_active=false` → lands on AccountInactive.

---

# Stage 5 — AccountInactiveScreen + router

A terminal full-screen state. No back nav; the only way out is to contact
admin or sign in with a different phone.

## 5.1 New file

> **New:** `mitra_app/lib/features/auth/screens/account_inactive_screen.dart`

Wrapped in `PopScope(canPop: false)` so the system back button can't escape
the terminal state. Body: icon + title + body copy directing the mitra to
their internal coordinator, and a single "Pakai nomor lain" text button that
logs out and pops back to S3a. No public admin CTAs (Stage 2 note).

## 5.2 Router updates

In [`router.dart`](../mitra_app/lib/router.dart):

1. Add `GoRoute(path: '/auth/inactive', builder: (_, __) => const AccountInactiveScreen())`.
2. Update `redirect` to recognize `/auth/inactive` as an auth-route (so the
   guard doesn't bounce the unauthenticated user away):

   ```dart
   final isAuthRoute = state.matchedLocation.startsWith('/login') ||
       state.matchedLocation.startsWith('/otp') ||
       state.matchedLocation.startsWith('/auth');
   ```

## 5.3 Acceptance

- Verify with correct code against a mitra whose `is_active=false` →
  AccountInactive renders. No back arrow, system back blocked by `PopScope`.
- Tap "Pakai nomor lain" → logged out, S3a shown.

---

# Stage 6 — Manual verification

No automated tests this round — pre-home OTP isn't currently covered by
Maestro (existing `ts-customer-*` flows are client_app-side). One mitra-side
Maestro flow could be added later as a follow-up.

## 6.1 Pre-conditions

- Backend running with `OTP_STATIC_CODE=111111` in env so we can submit a
  predictable code without round-tripping `/internal/_test/peek-otp`.
- Seed two test mitras via control center:
  - **Mitra A** — `+628111111111`, `is_active=true`
  - **Mitra B** — `+628222222222`, `is_active=false`

## 6.2 Scenarios

| # | Trigger | Expected UI |
|---|---|---|
| 1 | S3a, submit `12345` (invalid format) | Inline TextField error "Nomor HP tidak valid." |
| 2 | S3a, submit valid phone twice within 60s | 2nd submit → snackbar "Tunggu Ns…" |
| 3 | S3a, submit valid phone 4× within 1h | 4th submit → phone rate-limit dialog with retry-after |
| 4 | S3a, submit Mitra A's phone | Pushes to S3b, 60s countdown visible |
| 5 | S3b, submit wrong code 4× | "Tersisa N percobaan" decrements 4→1 |
| 6 | S3b, submit wrong code 5th time | Blocked dialog with two CTAs |
| 7 | S3b, wait 5+ min then submit | Expired dialog → "Minta kode baru" pops to S3a |
| 8 | S3b, wait full 60s | Resend button enables; tapping resets attempts + timer |
| 9 | S3b, submit `111111` for Mitra A | Authenticated → /home |
| 10 | S3b, submit `111111` for Mitra B | AccountInactive screen |
| 11 | AccountInactive, tap "Pakai nomor lain" | Logged out, S3a shown |
| 12 | AccountInactive, press system back | Blocked by `PopScope` |

## 6.3 Sign-off

All 12 scenarios pass on a physical Android device (API 28+).

---

## Open questions

- **Resend countdown on backgrounding** — current plan uses elapsed-ticks not
  wall-clock. If a mitra backgrounds the app for 90s, comes back, the timer
  will still show "remaining" instead of immediately enabling. Acceptable for
  v1; revisit if it causes confusion.
- **Maestro coverage** — out of scope here, but mitra-side onboarding has
  zero E2E coverage today. Worth adding `ts-mitra-01-prehome-otp.yaml` once
  the screens stabilize.
