- Port halo_tokens + halo_theme + HaloButton to mitra_app (rose palette, Bricolage display, Poppins body, JetBrainsMono). - Build S3a Input WhatsApp (figma-bestie BestieS3 first half) with +62 chip, leading-zero/62 normalization, allow '+' in input. - Build S3b OTP verification (6-digit, 60s resend timer, attempts hint, Focus(canRequestFocus:false) for maestro inputText compat) with full error branching (CODE_MISMATCH, OTP_EXPIRED, OTP_USED, ATTEMPTS_EXCEEDED, WRONG_FLOW, ACCOUNT_INACTIVE). - Add AccountInactive terminal screen for is_active=false mitras. - Typed MitraAuthError with Indonesian-first localized messages + retryAfterSeconds passthrough. - Rebuild home_screen.dart to match figma BestieHome (greeting + status card + Ganti Status CTA + Pengingat + 2-tile dark grid). - Backend: POST /internal/_test/seed-mitra (idempotent) and PATCH /internal/mitras/:id (display_name update). - Control center: inline Edit Nama on mitras row + expandable inline log table under clicked mitra (vs old below-table panel). - 5 maestro flows ts-mitra-A-01/03/04/05/06 covering invalid input, happy path, account inactive, phone-format normalization, and the back-to-S3a regression. All green. Plan + memory documented in: - requirement/phase4-mitra-prehome-plan.md - requirement/flow_mitra.md / flow_mitra.mermaid.md §A Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
15 KiB
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_jsonconfig 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 (sub-flows A1–A4) and the "Implementation gaps (mitra_app)" table in flow_mitra.mermaid.md §A.
Spec sources:
- PRD / flow: flow_mitra.md §A
- Diagrams: flow_mitra.mermaid.md §A
Backend behavior is already correct — every error code, retry-after detail, and
ACCOUNT_INACTIVEgate is inotp.service.jsandmitra.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.
- Typed auth-error model — replace string
AsyncErrorwith structuredMitraAuthError Admin contact helper — single source of truth for WA / Telegram URLs +— dropped: mitras are internal-only, no public admin CTA needed on pre-home screens.url_launcherwrapper- S3a · Input WhatsApp — replace generic snackbar with code-routed handling
- S3b · OTP verification — resend timer, attempts hint, six new dialog states
- AccountInactiveScreen + router wiring — terminal full-screen state for
ACCOUNT_INACTIVE - 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
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, alongside the existingMitraAuthDatasealed hierarchy.
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:
- Extracts
code,message, anddetails.retry_after_secondsfrome.response?.data?['error'](defaulting safely). - Returns a
MitraAuthError. ThecodeToMessagearg 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:
state = AsyncError(_otpRequestMessage(e), StackTrace.current);
with:
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.dartexportsMitraAuthError.- Every
AsyncErroremitted 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_jsonnumbers 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
to route on MitraAuthError.code instead of dumping every error into a
snackbar.
3.1 State additions
String? _phoneErrorText; // inline TextField error
int? _phoneRateLimitRetryAfter; // drives popup countdown
3.2 ref.listen rewrite
Replace the existing AsyncError branch:
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
gains: resend button + 60s countdown, local attempts-remaining hint, and
six branched error paths.
4.1 New state
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_cooldownuntil 0, then cancels itself.- Cancel in
dispose(). Do not touch ref in dispose — seemitra_app/CLAUDE.md"no_ref_in_dispose" rule. This timer doesn't need ref, so plaindispose()is fine.
4.3 Resend button
Below the OTP fields, above the verify button:
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 = 60and restarts the ticker - clears the input fields
4.4 Attempts-remaining hint
A small text widget below the input row:
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
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 byOTP_EXPIREDandOTP_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:
-
Add
GoRoute(path: '/auth/inactive', builder: (_, __) => const AccountInactiveScreen()). -
Update
redirectto recognize/auth/inactiveas an auth-route (so the guard doesn't bounce the unauthenticated user away):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 byPopScope. - 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=111111in 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
- Mitra A —
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.yamlonce the screens stabilize.