Files
halobestie-clone/mitra_app/CLAUDE.md
Ramadhan Sjamsani bfb072ddfb Docs: textfield-centering pitfall + config-source / FCM channel conventions
- mitra_app/CLAUDE.md: pitfall entry for the InputDecorationTheme
  min-height collision that broke chat-input centering. Walks through
  the working recipe (constraints: BoxConstraints(), Material +
  StadiumBorder + Center wrapper). Points at chat_screen.dart::_InputBar
  in both apps as the source of truth.
- backend/CLAUDE.md: two new convention sections.
  - Config-source: when to use DB-stored (operator-tunable via CC) vs
    env-driven (deploy-fixed). Codifies the pattern shipped today for
    MITRA_HEARTBEAT_CADENCE_SECONDS so Xendit credentials / callback
    tokens follow the same shape tomorrow.
  - FCM channel: single shared `halobestie_chat_v1` channel for both
    apps, target via android.notification.channelId. Bump the channel
    ID when introducing a new sound (Android API 26+ binds sound at
    channel-create time).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:38:50 +08:00

5.3 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.

Never mutate notifier state synchronously from deactivate() cleanup

deactivate() is the safe place to call ref.read(...).cleanup(), but if that cleanup does state = SomeData(), Riverpod will notify watchers while the widget tree is mid-teardown → Tried to modify a provider while the widget tree was building. → red error screen + engine restart (in debug) or silent state loss (in release). Real case: mitra_chat_notifier.dart::disconnect() was firing this on back-press from the session-ended chat screen.

Rule: when a notifier's cleanup-from-deactivate() path needs to reset its own state, defer the assignment to the next frame:

void disconnect() {
  _cleanup();  // closing channels / cancelling subs is fine synchronously
  SchedulerBinding.instance.addPostFrameCallback((_) {
    state = const SomeInitialData();
  });
}

The synchronous side-effects (closing the WS, cancelling timers) still happen immediately. Only the state = assignment is deferred, which is a no-op for users — they're navigating away anyway. Regression coverage: .maestro/flows/ts-mitra-3-08-back_press_after_session_expired_no_red_screen.yaml.

Custom-styled TextField must override the theme's min-height constraint

The app-wide InputDecorationTheme in lib/core/theme/halo_theme.dart sets a 48dp min-height for form fields (auth, profile, etc.). Any pill-style chat-input or compact TextField that has a fixed-height parent (≤ 48dp) will silently lose vertical centering — the field refuses to collapse below 48dp, the line-box can't sit on the parent's midline, and textAlignVertical becomes a no-op. Text anchors top.

Rule: when building a custom-shaped TextField (pill, dense, fixed-height), explicitly null the theme constraint:

TextField(
  textAlignVertical: TextAlignVertical.center,
  decoration: const InputDecoration(
    isCollapsed: true,
    contentPadding: EdgeInsets.symmetric(horizontal: 16),
    constraints: BoxConstraints(), // ← REQUIRED — overrides theme min-height
    border: InputBorder.none,
    enabledBorder: InputBorder.none,
    focusedBorder: InputBorder.none,
    disabledBorder: InputBorder.none,
    errorBorder: InputBorder.none,
    focusedErrorBorder: InputBorder.none,
    filled: false,
  ),
)

Wrap in Material(shape: StadiumBorder(...), clipBehavior: antiAlias) + Center(child: TextField) for proper pill clipping. The chat input bar in mitra_chat_screen.dart and client_app/chat_screen.dart::_InputBar both use this pattern; copy from there rather than reinventing.