Files
halobestie-clone/requirement/phase4-mitra-prehome-plan.md
Ramadhan Sjamsani 9696eadeaf Mitra §A: pre-home (S3a/S3b/AccountInactive) + design system + Bestie Home
- 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>
2026-05-19 22:01:28 +08:00

15 KiB
Raw Permalink Blame History

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 (sub-flows A1A4) and the "Implementation gaps (mitra_app)" table in flow_mitra.mermaid.md §A.

Spec sources:

Backend behavior is already correct — every error code, retry-after detail, and ACCOUNT_INACTIVE gate is in otp.service.js and 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 34 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 wrapperdropped: 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 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 existing MitraAuthData sealed 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:

  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:

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.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 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;       // 05, 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 "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:

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:

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 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:

  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):

    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.