Files
halobestie-clone/mitra_app/CLAUDE.md
ramadhan sjamsani a09f37135c Phase 4 checkpoint: chat-screen perf refactor + retryable blast-failure + repo-wide dispose-ref guardrail
Chat-screen performance (customer + mitra):
- Parent screens have zero `ref.watch` — only `ref.listen` for side effects
- Body extracted into its own `ConsumerStatefulWidget`; AppBar parts split
  into narrow `.select` consumers (mode, sensitivity, timer)
- Per-second timer ticks routed to dedicated providers
  (`chatRemainingSecondsProvider` + new `mitraChatRemainingSecondsProvider`)
  so WS `session_tick` frames don't invalidate the rest of the chat state

Dispose-in-ref bug fix:
- `home_screen.dart`, `payment_screen.dart`, `mitra_chat_screen.dart` —
  ref-using cleanup moved from `dispose()` to `deactivate()`. Modern
  Riverpod invalidates `ref` the moment `dispose()` runs; the resulting
  silent error corrupts the widget-tree finalize and the next screen
  appears frozen
- `halo_lints` package added at repo root with `no_ref_in_dispose` rule
  to catch this pattern in CI / IDE analysis
- `custom_lint` activated in both apps' `analysis_options.yaml`
  (was installed but never wired in — also brings `riverpod_lint`'s
  `avoid_ref_inside_state_dispose` online)
- CLAUDE.md Pitfalls section added to client_app + mitra_app

Phase 4 §3 retryable blast-failure (Option A):
- Backend `expirePairingRequest` + all-rejected use
  `recordIntermediateFailure` instead of `failPaymentSession` so the
  payment session stays `confirmed` for re-blast
- WS `pairing_failed` payload carries `is_terminal: false` on the
  retryable paths; client parses the flag and exposes `retryBlast()`
- "Coba cari lagi" CTA on S7 Timeout now re-blasts on the same payment
- Pairing service test updated to reflect the new semantics

Customer waiting-payment screen navigation patch:
- `_navigateTerminal` uses `Future.microtask` + `addPostFrameCallback`
  redundancy after a release-mode bug where polling stopped but
  `context.go` never fired, leaving the screen visually stuck on
  "menunggu pembayaran"

See requirement/resume-2026-05-15.md for next-day pickup checklist
(mitra release rebuild + S21 Ultra install + retest is the gating item).

Bundles unrelated in-flight Phase 4 §2.x work that was already on disk
(ESP screen removal, USP one-time gate scaffolding, bestie-availability
public route, OTP service edits, Maestro flow tweaks) — kept together
to avoid a partial-rebase mess.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:12:34 +08:00

2.7 KiB

Halo Bestie — Mitra App

Flutter mobile application for mental health professionals (mitra/partners).

See root CLAUDE.md for full project context and architectural decisions.

Stack

  • Framework: Flutter (iOS + Android)
  • Auth: Self-managed (Phase 3.4). Phone OTP only — no Google / Apple. Access token lives in memory on an AuthBridge; refresh token persists in flutter_secure_storage. firebase_auth is no longer used; firebase_messaging is kept for FCM push.
  • API: Calls public Fastify backend (/api/mitra/ and /api/shared/ routes). shared.auth covers refresh + logout for both apps.

Key Concepts

  • Users are mitra — trained mental health professionals
  • Core flow: phone OTP login → set availability → accept sessions → chat with client → receive payment
  • Mitra accounts require approval from control center before going live (backend returns ACCOUNT_INACTIVE 403 on OTP verify when is_active=false)

Conventions

  • Never call /api/client/ or /internal/ routes from this app
  • API calls go through ApiClient; it auto-attaches the JWT from AuthBridge and auto-refreshes on 401
  • WebSocket handshake (/api/shared/ws) sends the same access token in the first frame's {type:"auth", token} message
  • Mitra role is encoded in the JWT claims (user_type: "mitra") — the backend enforces the role per route; never trust client state alone

Pitfalls (HARD rules — silent failure modes)

Never call ref.read / ref.watch / ref.listen from State.dispose()

In a ConsumerStatefulWidget, Riverpod invalidates ref the instant dispose() starts. Any ref.* call throws Bad state: Cannot use "ref" after the widget was disposed.. Flutter catches it inside BuildOwner.finalizeTreeso it does not surface as a red-screen crash. Instead the widget tree is left half-finalized and the NEXT screen freezes (looks like a hang; the app process is alive). Real case in this app: mitra_chat_screen.dart (2026-05-14).

Rule: any cleanup that needs ref goes in deactivate(), which runs before dispose() while ref is still valid. Non-Riverpod cleanup (TextEditingController.dispose(), WidgetsBinding.removeObserver, StreamSubscription.cancel) stays in dispose().

@override
void deactivate() {
  ref.read(someProvider.notifier).cleanup();
  super.deactivate();
}

@override
void dispose() {
  WidgetsBinding.instance.removeObserver(this);
  super.dispose();
}

A lint rule (no_ref_in_dispose in halo_lints) fails dart run custom_lint on this pattern. When debugging "screen frozen after navigation", grep the previous screen's State for void dispose() followed by ref\. — that's the first suspect.