- 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>
5.3 KiB
Halo Bestie — Mitra App
Flutter mobile application for mental health professionals (mitra/partners).
See root
CLAUDE.mdfor 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 influtter_secure_storage.firebase_authis no longer used;firebase_messagingis kept for FCM push. - API: Calls public Fastify backend (
/api/mitra/and/api/shared/routes).shared.authcovers 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_INACTIVE403 on OTP verify whenis_active=false)
Conventions
- Never call
/api/client/or/internal/routes from this app - API calls go through
ApiClient; it auto-attaches the JWT fromAuthBridgeand 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.finalizeTree — so 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.