Commit Graph

47 Commits

Author SHA1 Message Date
e6d991373e Client Profile: save-phone banner for anonymous users
Anonymous customers now see a brand-gradient "Simpan Nomor HP" panel
above the user card on the kamu tab, ported from the Figma SProfile
save-phone banner. Tapping it pushes /auth/register?from=profile, which
hides the "lanjut tanpa verifikasi (harga normal)" link — a user who
re-entered the verif funnel from Profile shouldn't be re-offered the
anon exit. Spec §1.3 added documenting the ?from= entry-point
convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:39:13 +08:00
1653482d54 Client: mirror branded notification sound to customer app
The customer app now uses the same halobestie_notif.ogg as the mitra
app (shipped in the previous commit). Channel ID unified across both
apps so backend FCM stops branching per recipient.

- client_app: same channel bump (chat_messages → halobestie_chat_v1)
  + RawResourceAndroidNotificationSound binding, both at channel-
  create time and per-notification details. .ogg copied to
  client_app/android/app/src/main/res/raw/halobestie_notif.ogg
  (same 32 KB asset, identical file).
- Backend: drop the per-recipientType channel ID branch; everyone
  targets halobestie_chat_v1 now.

Verified on emulator-5554 (customer): dumpsys shows the channel
bound to android.resource://com.mybestie/raw/halobestie_notif.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:05:05 +08:00
10699d1ad1 Mitra Profil: WA/TG contacts + Keluar-only (no Hapus Akun)
Replaces the placeholder "Hubungi Koordinator" row with two real
contacts pulled from backend config (support_handles_json), and drops
the "Hapus Akun" CTA. Mirrors the figma BestieProfile design but uses
the same WA/TG channel as the customer Tanya Admin sheet — business
decided the same ops team triages both audiences.

Backend:
- Promote support-handles route from /api/client to /api/shared
  (renamed file + export). Both apps now consume the same endpoint;
  hitting /api/client/* from mitra would violate the per-app
  convention in mitra_app/CLAUDE.md.
- client_app provider updated to /api/shared/support-handles.

Mitra app:
- New support_handles_provider mirroring the client_app one. Adds a
  `displayHandle` getter that strips the URL scheme for the subtitle
  ("https://wa.me/X" → "wa.me/X", "https://t.me/Y" → "t.me/Y") so the
  row looks like the figma without exposing raw URLs.
- Profil screen now lists: Chat WhatsApp Kami, Chat Telegram Kami,
  Syarat & Ketentuan, Kebijakan Privasi. Danger zone simplified to
  Keluar only — mitras request account deletion through the same
  WA/TG channels (no separate self-service path).
- url_launcher added as a runtime dep, launches deeplinks in
  externalApplication mode with graceful snackbar fallback when
  parsing or launching fails.

Updates [[feedback-mitra-internal-audience]] — pre-login rule still
holds (no admin CTAs on S3a/S3b/AccountInactive), but the post-login
Profil tab now does surface WA/TG. Overrides decided 2026-05-21.

Verified on emulator-5556: Profil tab renders both rows with handles
from `wa.me/6285173310010` + `t.me/halobestie`, Keluar present, no
Hapus Akun button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:32:21 +08:00
fcb8eaa505 App ID + launcher icon rename: halobestie.* → mybestie
- Customer: com.halobestie.client.client_app → com.mybestie
- Mitra:    com.halobestie.mitra.mitra_app  → com.mybestie.mitra
- iOS bundle IDs renamed to match (no .clientApp/.mitra camelCase legacy)

Mechanical rename touches Android build.gradle/Manifest/MainActivity
package, iOS pbxproj/Info.plist bundle IDs, Firebase configs
(google-services.json + GoogleService-Info.plist + firebase_options.dart),
new HaloBestie/Mitra launcher icons via flutter_launcher_icons (pubspec
config + adaptive-icon resources + AppIcon imageset), and the appId
references in every customer maestro flow + both .maestro/config.yaml
files. brandLogoBg (#FF699F) added to halo_tokens for the launcher pink.

Followup: re-register apps in Firebase consoles using the new package IDs;
strategy memo at project-firebase-env-strategy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:13:47 +08:00
ad02ee252d Phase 4 §1/§5: notif banner detection on API <33 + chat-delivery WS→FCM lifecycle
§1 notif banner: permission_handler v11 returns granted unconditionally
for Permission.notification on Android <13 because POST_NOTIFICATIONS
didn't exist as a runtime permission. Result: SHome1st amber "notifikasi
off" banner never showed on API 24-32 even when the user toggled
notifications off in Settings → Apps. Add a
NotificationManagerCompat.areNotificationsEnabled() pre-check via
flutter_local_notifications (works from API 19+) so the banner reflects
the real OS state on older Android.

§5 chat delivery: the contract is "WS when foreground, FCM when
background", but the previous build only honoured (1) — Android keeps
the TCP socket alive after the Dart isolate is paused, so backend's
`socket.readyState === 1` check returned true and FCM never fired.
Fix has five parts (all required together):

 1. Customer-side lifecycle observer in client_app/main.dart closes
    chatProvider's WS on paused/detached, reconnects on resumed.
 2. `_appPaused` gate in main.dart suppresses the activeSessionProvider
    listener's auto-reconnect (15s poll in active_session_notifier
    would otherwise re-open the WS the next tick after the observer
    closed it — defeating the fallback).
 3. Mitra-side lifecycle observer in mitra_app/main.dart stashes
    `_pausedChatSessionId`, calls mitraChatProvider.disconnect(), and
    re-issues connect(saved) on resumed.
 4. MitraChat gains a `_connectedSessionId` field + getter so the
    observer in step 3 can read it back across disconnect (disconnect
    clears it; the next connect overwrites it).
 5. SearchingScreen resets pairingProvider when entering with a new
    draft.paymentId — previously it retained PairingActiveData with
    the *old* sessionId after a session ended, and the next pairing
    flow navigated straight to that completed session showing
    "Sesi sudah berakhir".

Backend additions under /internal/_test/* for assertion harness:
inspectSessionWsState + GET /ws-connection-state,
POST /send-chat-message-as-mitra (with delivered_via),
POST /send-chat-message-as-customer (with delivered_via),
POST /send-fcm-chat-message (raw FCM dispatch).

Maestro coverage:
 - ts-customer-05-01: mitra → customer message when customer is
   backgrounded → delivered_via=fcm.
 - ts-customer-05-02: customer → mitra message when mitra is
   backgrounded → delivered_via=fcm.
 - ts-customer-01-01: §1 notif-denied banner on home. Documented
   precondition: mitra must be force-stopped or backgrounded on the
   chat screen before 05-02 runs (Maestro can only drive one --udid
   per run; mitra-side lifecycle observer end-to-end is deferred).

Helper scripts under client_app/.maestro/scripts/:
inspect_ws_state.js, assert_ws_state.js,
send_chat_message_as_mitra.js, assert_delivered_via.js (takes
SENDER=mitra|customer to route to the matching backend endpoint).

README_section_05.md documents the test plan, helper scripts, and the
deferred mitra-side maestro driving. Both apps tested manually on
API 28 AVDs where FCM delivery is sub-second; API 24 has 5-30 min
heartbeats that make it impractical for FCM-related testing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:50:46 +08:00
093256ff7d Phase 4 §2 + §1/§4: OnboardingIntent post-OTP routing + test naming + register-screen overflow
Spec §2 (flow_customer.mermaid) routes post-OTP based on user-lookup +
has_transacted, but the implementation previously dumped every OTP
success on /home. Introduce `OnboardingIntent` provider: set to
`onboarding` by routeForVerifChoice's verified branch (the "aku mau
curhat" transaction journey), set to `recover` by SHome1st's masuk →
banner. Router redirect on AuthAuthenticatedData+isAuthRoute consumes it:
`onboarding` → /payment/entry (dispatches S6 paywall vs PickMethod via
first_session_discount.eligible); `recover` → /home. Intent is reset in
/payment/entry's initState so subsequent masuk → flows don't inherit it.

auth_notifier.verifyOtp uses .copyWithPrevious on AsyncError so
valueOrNull retains AuthOtpSentData/AuthAnonymousData through OTP
failures — required for the OTP-blocked recovery path
(/onboarding/anon/method → /payment/method-pick) to clear the global
redirect without bouncing to /home. Router also extends the
isAuthRoute/isOnboardingFlow carve-out to AuthOtpSentData.

Maestro tests adopt `ts-<app>-<NN>-<MM>-<descriptor>.yaml` convention:
NN = mermaid section, MM = sub-flow index. New ts-customer-02-01..05
cover the §2 branches (verified brand-new → S6, existing-no-tx → S6,
existing-tx → method-pick, OTP-blocked → method-pick, anonymous first-
timer → method-pick); deferred 02-06/07/08/09 documented in
README_section_02.md. TS-07 → ts-customer-02-10 (masuk → recovery);
TS-01..06 → ts-customer-04-01..06 (§4 returning-user). Shared
onboarding_new_user_verified.yaml subflow extracted.

Register screen's body Column now uses LayoutBuilder + SingleChildScrollView
+ ConstrainedBox + IntrinsicHeight so the keyboard-open layout no
longer overflows by 1.3 px (verified visually).

Spec prose updated at flow_customer.mermaid §2 to describe the
intent-driven routing + login-vs-transaction divergence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:50:04 +08:00
93fa5f113a Test: TS-07 returning user with existing display_name skips set-name
Inverse coverage for the auth path: TS-01..TS-06 all wipe the customer
row (drop_customer=true) so every OTP path lands on the new-user
set-name branch. TS-07 instead seeds an existing identified customer
(phone + display_name + is_anonymous=false) and verifies the OTP
sign-in returns the existing row unchanged via
resolveCustomerForIdentity branch 1, so /auth/set-name is never shown.

Adds:
* /internal/_test/seed-customer endpoint — upserts a customer with
  phone + display_name + is_anonymous=false.
* client_app/.maestro/scripts/seed_customer.js helper.
* client_app/.maestro/flows/ts-07_returning_existing_name_skips_setname.yaml.
* TS-07 scenario doc + coverage-map row in
  requirement/phase4-customer-flow.md.

The flow asserts the "halo, <name>" greeting on the returning-user home
variant (identified users always land on _SHomeReturningView regardless
of chat history) plus an explicit notVisible on "Siapa namamu" as a
belt-and-braces check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:50:40 +08:00
e09f76ceb6 Phase 4 §4: payment-before-pair for returning users + Maestro suite
Stages 5.1, 5.3, 5.4 of the returning-user flow rework. All three §4
entry paths now require payment BEFORE pairing, matching the updated
mermaid spec.

* Spec (requirement/flow_customer.mermaid.md §4): payment block converges
  three call-sites (bestie-yang-udah-kenal-online, bestie-baru,
  offline-popup → cari bestie lain). PairRoute dispatches lama → targeted
  pair, baru/cari-lain → §3 blast. §3 retains its post-payment-shared
  contract.

* Stage 5.1 (client_app): PaymentDraft carries targetedMitraId +
  topicSensitivity. bestie_history_list seeds the draft + pushes
  /payment/entry (was legacy /payment). searching_screen branches on
  draft.targetedMitraId for blast-vs-targeted dispatch.
  payment_entry uses resetExceptTarget(); bestie_choice_sheet + home
  _onCurhatBestieBaruPressed call explicit reset() before push so
  the keepAlive draft can't leak stale targeting into a blast.

* Stage 5.3 (client_app): new BestieOfflineVariant.prePayReturning.
  Bestie-history-list _BestieRow splits tappable from dim so offline
  rows render dimmed but route taps into the popup. CTA "cari bestie
  lain" resets the draft + pushes /payment/entry.

* Stage 5.4 (client_app): deleted legacy /payment route,
  payment_screen.dart, payment_notifier.dart(+.g.dart). router cleaned.

* Tests (requirement/phase4-customer-flow.md + client_app/.maestro/):
  six Maestro flows TS-01..TS-06 covering every §4 branching point,
  all passing end-to-end. Shared onboarding prelude under
  .maestro/subflows/. New helper scripts: accept_latest_pending,
  force_mitra_offline, force_other_mitra_online,
  reset_all_mitras_online, mitra_accept_latest_internal. New backend
  _test endpoints to match. /reset-phone now cascade-deletes
  customer_transactions (FK was blocking). /force-pairing-timeout
  branches targeted (RETURNING_CHAT_TIMEOUT via
  expireTargetedPairingRequest, now exported) vs blast (PAIRING_FAILED).
  seed_history_session also outputs MITRA_NAME_RE (regex-escaped) for
  reliable selectors against display names containing regex specials.

* mitra_app: dispose-during-deactivate guardrail for back-press on the
  mitra chat screen after the customer's goodbye message. Pending real
  emulator repro verification (carried over from 2026-05-15).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:25:15 +08:00
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
a48f108fc0 Phase 4 §2.1: anonymous → existing-user merge breadcrumb
Adds `customers.account_belongs_to UUID NULL` and refactors customer
sign-in (phone/Google/Apple) so an anon row that re-verifies into an
existing customer no longer 409s. Instead the anon row stays intact
with a breadcrumb pointing at the real customer; tokens are issued
for the existing user. Actual data reconciliation onto the existing
row (chat_sessions, customer_transactions, payment_sessions,
pairing_failures) is deferred.

Backend
- migrate.js: ADD COLUMN account_belongs_to UUID REFERENCES customers(id)
  ON DELETE SET NULL.
- customer.service.js: stampAccountBelongsTo helper; account_belongs_to
  exposed in CUSTOMER_SELECT.
- auth.service.js: new shared resolveCustomerForIdentity (4-case logic);
  normalizeIdentityConflict + IDENTITY_ALREADY_LINKED 409 deleted;
  completeCustomerPhoneSignIn / signInWithGoogle / signInWithApple all
  route through the shared helper.
- client.auth.routes.js: new resolveAnonymousCustomerId picks the anon
  prefix ONLY from a verified Bearer JWT — closes the UUID-leak attack
  where a tamper-able body field could mis-route someone else's
  transactions. /otp/verify, /google, /apple all use it; the body field
  `anonymous_customer_id` is no longer accepted on any of them.
- test/services/auth.service.test.js: 9 Vitest cases covering phone +
  Google + Apple, all 4 logic cases + multi-merge accumulation.

Customer app
- auth_notifier.dart::verifyOtp: drop `skipAuth: true` and the dead
  body field so ApiClient auto-attaches the anon's Bearer from
  AuthBridge. Survives the AuthOtpSentData state transition (the
  earlier `_currentAnonymousCustomerId()` state-drop bug is bypassed by
  sourcing the id from the bridge instead of state).
- Google + Apple client paths remain unchanged (gated on provider
  creds; mirror this fix when wiring lands).

Docs
- flow_customer.mermaid.md: new §2.1 sub-section with the merge
  diagram, schema note, replaces-current-behaviour paragraph, and
  Bearer-only security callout.
- phase3.4-testing.md: §1.5 line 76 simplified (no more per-path
  split); new §1.5.1 with the 5-step operator scenario + DB invariants
  + curl recipe + Vitest pointer; new §1.5.2 covering Google/Apple
  parity (deferred client work flagged).

Verification (against live dev backend, before this commit):
- Vitest: 9/9 in auth.service.test.js; 49/51 overall (2 unrelated
  pre-existing failures in session-timer.service.test.js).
- Operator Node smoke: 14/14 in the §1.5.1 scenario; 11/11 in the
  Bearer-precedence cases.
- Real-device UI walkthrough on SM-A530F still pending — see resume
  memory `project_phase4_2_1_resume_test`.

Sister WIP bundled in migrate.js + customer.service.js: `usp_seen`
column + `markCustomerUspSeen` helper (Phase 4 USP one-time gate, was
already uncommitted in the working tree).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:57:53 +08:00
22b10c4bbf Phase 4 Stage 10 follow-up: restore BestieHistoryList picker for §4 curhat-lagi
The original Stage 10 plan retired chat_history_screen.dart on the
assumption that the new Chat tab Selesai sub-tab replaced it. That was
wrong: Figma has two distinct screens — `extras.jsx::SChatList` (the
Chat tab, browse-only) and `v4.jsx::BestieHistoryList` (the picker for
mermaid §4 returning-user curhat-lagi). They serve different purposes
on row tap: Selesai opens transcript, BestieHistoryList picks a past
bestie for targeted-pair.

Restoring BestieHistoryList at a new home:

- New screen `features/home/screens/bestie_history_list_screen.dart`
  matching Figma `v4.jsx::BestieHistoryList`:
    appBar title "bestie kamu sebelumnya"
    subtitle "{N} bestie yang pernah nemenin kamu"
    row: orb + "bestie {name}" + ONLINE pill + sessions count + last
         date + topic + → arrow
    row tap (online) → /payment with targetedMitraId (Stage-3 flow)
    row tap (closing-grace) → /chat/session/$id to finish goodbye
    row (offline) → dimmed, tap disabled

  Drops the per-row "curhat lagi" secondary button — the row tap IS the
  pick action now (cleaner, matches Figma).

- New route `/bestie/history` in router.dart; cleanly separated from the
  /chat/* family (which is now exclusively the Chat tab).

- BestieChoiceSheet "bestie yang udah kenal" re-pointed from /chat to
  /bestie/history.

- Stage 8 Maestro flow `08_returning_targeted.yaml` updated to assert
  the new screen title + tap the row by name (uses output.MITRA_NAME
  from the seed_history_session script).

- TECH_DEBT entry retired (curhat-lagi entry point restored). New
  TECH_DEBT entry tracks the still-pending wire-up of the Bestie
  Offline Popup variant for offline-row tap per mermaid §4.

flutter analyze clean (one pre-existing widget_test scaffolding error
unrelated to Stage 10).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:26:57 +08:00
1908e98012 Phase 4 Stage 10 Maestro: 09_chat_tab.yaml + seed-pending-payment endpoint
Closes the Stage 10 acceptance criterion §10.11 #13 (Maestro coverage).

- New dev-only `POST /internal/_test/seed-pending-payment` — inserts a
  payment_sessions row in `pending` status with expires_at 20m out, so
  the Pembayaran sub-tab has a deterministic row to render. Body
  accepts { phone, isExtension?, amount?, durationMinutes?, mode? }.
  Gated on NODE_ENV != 'production' like the other test routes.

- New Maestro helper script `seed_pending_payment.js` mirrors the
  existing seed_history_session pattern.

- New flow `09_chat_tab.yaml`:
    cold-start onboarding → home (returning view) →
    seed completed session + seed pending payment →
    tap "💬 chat" bottom-nav → lands on /chat/aktif via redirect →
    assert "aktif" / "pembayaran" / "selesai" pills + empty-state copy →
    tap pembayaran → assert "menunggu pembayaran sesi" + "bayar Rp..." →
    tap selesai → assert "X menit" duration row → tap row → assert
    "Transkrip Chat" appbar → back → still on /chat/selesai.

  Maestro parsed the YAML cleanly and started executing against the
  device; full run requires backend + online mitra in dev DB (same
  pre-reqs as flows 03/05/06/08).

- TECH_DEBT entry: Stage 10 retired the standalone bestie-history list
  screen, which means (a) the "curhat lagi" targeted-payment entry
  point has no UI affordance anywhere in the app — its plumbing in
  payment_notifier / payment_screen is now orphaned, and (b) the
  Stage 8 flow `08_returning_targeted.yaml` will fail at
  `assertVisible: "Riwayat Chat"` because it expects the deleted
  screen. Three fix paths listed in the entry for product to pick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:24:50 +08:00
e3ea1d793e Phase 4 Stage 10 client_app: Chat tab UI (3 sub-tabs + retire bestie_history)
Flutter half of Stage 10 — the new Chat tab landing in the bottom nav.
The CTA target swaps from /chat/history to /chat, which redirects into
/chat/aktif. Three sibling routes under a single ShellRoute share a
header + sub-tab pills + the existing HaloTabBar footer:

  /chat/aktif        — the current active session (0 or 1 row)
  /chat/pembayaran   — pending initial + extension payments
  /chat/selesai      — past sessions, cursor-paginated infinite scroll

URL is the source of truth for the active sub-tab so deep links, back
stack, and Maestro all agree on state.

New feature dir `lib/features/chat_tab/`:
- providers/pending_payments_provider.dart — FutureProvider against the
  Stage-10 backend endpoint, plus pendingPaymentsCountProvider for the
  red-dot derivative
- providers/selesai_history_provider.dart — AsyncNotifier over
  GET /api/client/chat/history; tracks accumulated items + next_cursor +
  hasMore; loadMore() and refresh()
- widgets/chat_row.dart — generic row used by all 3 sub-tabs, with
  optional PaymentAmountChip / DurationChip / 📞 Call indicator
- widgets/sub_tab_pill.dart — pill with active underline + optional
  numeric badge (null hides; matches Selesai's no-badge rule)
- screens/chat_tab_shell.dart — ShellRoute scaffold + ChatSubTab enum
- screens/{aktif,pembayaran,selesai}_view.dart — the three sub-tab bodies

Router (`router.dart`):
- /chat → redirect → /chat/aktif
- ShellRoute hosts /chat/aktif, /chat/pembayaran, /chat/selesai
- /chat/history retired; /chat/history/:sessionId → /chat/transcript/:sessionId
- ChatHistoryScreen import + file deleted

HaloTabBar (`features/home/widgets/halo_tab_bar.dart` — new in the
working tree from Stage 9 sweep): now a ConsumerWidget. Chat tab goes
to /chat. Red dot renders when pendingPaymentsCountProvider > 0.

Inbound call-site updates:
- bestie_choice_sheet.dart: /chat/history → /chat
- home_screen.dart history-row tap: /chat/history/:id → /chat/transcript/:id

This commit also carries the larger Stage 9 sweep + ESP-removal + USP
gate edits that were already staged in the working tree on
`home_screen.dart` and `router.dart` from the prior session.

flutter analyze: clean except for the pre-existing scaffold
test/widget_test.dart MyApp reference (unrelated, present on master).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:14:22 +08:00
770f61074c Phase 4 Stage 9: real-device sweep, 4 flows green + 2 shipping bugs fixed
Stage 9 sweep on Client_Phone AVD + physical mitra phone:
- 01_smoke 
- 02_onboarding_verified 
- 03_onboarding_anon 
- 04_payment_expired 
- 05_searching_timeout: in progress when wrap-up began
- 06–08: not yet attempted

## Real shipping bugs fixed (would have hit prod)

1. **Router carve-out too narrow** (router.dart). The AuthAnonymousData
   carve-out only protected /auth/display-name. On refreshListenable
   notify after loginAnonymous resolves, GoRouter re-evaluates the
   *bottom* of the navigation stack (/welcome — also an auth route),
   and the AuthAnonymousData fallback redirected to /home, tearing down
   the verif sheet before it could open. Loosened to allow any auth
   route under AuthAnonymousData.

2. **Phase 4 multi-screen payment never called startSearch**
   (searching_screen.dart). The legacy single-screen /payment did
   `pairing.startSearch()` on confirm. The Phase 4 flow is
   waiting → notif-gate → /chat/searching with no intermediate that
   owned the call — customers would land on the searching screen with
   no pairing in flight and never get matched. Added the kickoff to
   searching_screen::initState when state is PairingInitialData and
   paymentDraft.paymentId is set.

## Test infrastructure

- Self-contained Maestro flows 04 + 05 with inline verified-onboarding
  prelude, distinct test phones per flow, robust waits.
- 02 + 03 fixed: malformed `extendedWaitUntil` (visible: + notVisible:
  true → Maestro parsed as compound predicate); now use proper
  notVisible: block.
- New dev-only POST /internal/_test/force-confirm-payment so flows can
  advance past the waiting-payment screen without going through Xendit.
- /internal/_test/reset-phone now cascades through chat_messages →
  chat_sessions → payment_sessions → auth_sessions before deleting the
  customer row (FK 23503 was blocking re-runs).
- /internal/_test/force-pairing-timeout now accepts both
  `searching` and `pending_acceptance` states (mitra-online dev means
  the chat_session transitions through searching very quickly).
- mark_latest_payment_paid.js helper script for Stage 5+ flows.

## Maestro YAML quirks documented in flows

- text: matches anchored regex against the FULL content-desc — need .*
  wildcards for substring, e.g. "mulai.*Rp.*" not "mulai".
- The middot `·` and other special unicode break naive matching;
  always use .* anchors when the source string contains them.
- runFlow `when:` evaluates immediately; pair with waitForAnimationToEnd
  or a preceding extendedWaitUntil before branching.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:11:05 +08:00
862fc35a40 Phase 4 Stage 8: returning-user shell + Tanya Admin sheet
Bestie Choice Sheet on home Mulai Curhat CTA. When the user has at
least one prior session (bestieHistoryHasItemsProvider hits the chat-
sessions history endpoint), the CTA opens a HaloBottomSheet with two
cards: 'bestie yang udah kenal' -> /chat/history, 'bestie baru' ->
/payment/entry. Empty history -> direct to /payment/entry.

Bestie history list visual upgrade: HaloOrb (mitraId seed) + name +
last-session date + topic pills + sessions count + ONLINE pill.
Backend getCustomerHistory now returns topics, mitra_is_online,
sessions_count in a single payload (no per-row presence round-trip).

BestieOfflinePopup with two variants (returning | new_) replacing the
legacy BestieUnavailableDialog. tanya admin ghost CTA on both variants
opens the new TanyaAdminSheet. Stage 5's targeted-wait declined stub
+ Stage 7's chat-screen 409 stub + searching-screen call site all
migrated to the real component.

TanyaAdminSheet: HaloBottomSheet with WA + Telegram buttons, deeplinks
fetched via supportHandlesProvider (CC-config-driven). url_launcher
added to client_app; ios LSApplicationQueriesSchemes covers
https/http/whatsapp/tg.

Stage 2's OTP-blocked popup hubungi admin SnackBar stub also migrated
to TanyaAdminSheet.

Dev-only POST /internal/_test/seed-history-session lets Maestro 08
flow seed a history row before exercising the choice sheet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:47:02 +08:00
d454fd39db Phase 4 Stage 7: end-of-session 2-step confirm + thank-you screen
Customer-driven session end flow:
- AppBar 'akhiri' action on chat_screen (visible when connected and
  not already closing).
- Tap fires confirm_end_step1 HaloPopup. lanjut akhiri -> step2;
  gak jadi balik -> dismiss, stay in chat.
- confirm_end_step2 HaloPopup. tulis pesan penutup -> closing_message_sheet
  HaloBottomSheet (textarea + kirim & akhiri / lewat — langsung akhiri).
  lewati saja closes immediately.
- Both close paths POST /api/client/session/:sessionId/end via
  session_closure_notifier.closeSession() and route to /chat/thank-you.
- 409 from the close endpoint surfaces a ClosureRejectedByMitraData
  state and a stub HaloPopup with TODO(stage8) for the BestieOfflinePopup
  returning variant.

Removed the legacy _showSessionExpiredDialog modal — Stage 6's
ChatExpiredBanner is the replacement notification.

Inline _buildGoodbyeView retained with a TODO for the mitra-side early
end flow (still reaches it).

endSessionTwoStepConfirmProvider hardcoded to true with a TODO — the
Stage 1.5 app_config row exists but no client-readable config endpoint
exists yet. Flip the provider to a FutureProvider once the read endpoint
ships.

Maestro 07_end_session_2step.yaml chains after the chat-happy flow
and asserts the Indonesian copy at each step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:33:01 +08:00
14b5cc966b Phase 4 Stage 6: chat-room countdown UX + voice-call mode pill
Customer chat screen:
- Voice-call header pill (mode == 'call' renders accent-colored pill;
  chat mode renders no pill).
- HaloSnackbar fires once per session at 180s remaining ('sisa 3 menit
  lagi ya 🤍'), driven by the backend session_warning WS event.
- Last-2-min danger styling: timer pill flips to HaloTokens.danger +
  bold JetBrainsMono when remaining <= 120s.
- Floating ChatExpiredBanner widget injected above the input bar when
  remaining hits 0 in closing-grace state. perpanjang -> existing
  pricing bottom sheet.
- pricing_bottom_sheet.dart rewritten to the 5-option layout with
  chat|call mode toggle (mirrors duration-pick from Stage 3).

Mitra chat screen: voice-call header pill only (no countdown UX per PRD).

Backend:
- session.service.js getSessionById JOINs payment_sessions so mode +
  expires_at ship in /api/shared/chat/:id/info.
- session-timer.service.js onThreeMinuteWarning now emits expires_at +
  remaining_seconds for client resync.
- Dev-only POST /internal/_test/force-session-expires-at clears the
  3-min flag, reschedules the timer, and broadcasts WS resync. Lets
  the Maestro flow drive 175s -> 90s -> 0s without waiting live.

New chatRemainingSeconds StreamProvider derived from expiresAt, fed by
session_warning / session_timer / session_expired resync messages
(plan referenced a secondsLeftProvider that didn't actually exist).

Maestro 06_chat_countdown.yaml + force_session_expires_at.js helper.

Out of scope: meet.google.com URL launching - url_launcher isn't a
client_app dependency and message bubbles render plain Text. Defer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:25:11 +08:00
f170d54535 Phase 4 Stage 5: pairing UX upgrades (searching + match + targeted-wait)
Searching screen: soft-prompt card reskin, pulsing-dots panel replaces
the spinner, inline 5-min timeout panel with `coba cari lagi` (resets
pairing notifier + routes to /payment/entry for a fresh funnel — the
server-side payment is failed_pairing at that point so a stale retry
isn't valid) and `kembali ke home` ghost CTA.

Bestie-found screen: S9 Match-V4 reskin — HaloOrb + status dot +
'halo, aku bestie {name}' + `mulai sesi {N} menit →` with N pulled from
the active session's duration_minutes.

Targeted-wait overlay (new) at /chat/waiting-targeted/:mitraId. Three
sub-states from pairingProvider's PairingTargetedWaitingData:
waiting (20s countdown) / accepted (routes to chat) / declined (stubbed
BestieOfflinePopup with a TODO pointing to Stage 8). Reached via
payment_screen._routeToSearchOnConfirmed when the confirm carried a
targetedMitraId — keeps the mandatory payment-before-pairing invariant.

Dev-only POST /internal/_test/force-pairing-timeout drives the 5-min
timeout shortcut for the Maestro flow without waiting live.

Maestro 05_searching_timeout.yaml + force_pairing_timeout.js helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:49:07 +08:00
7ae8f33b2c Phase 4 Stage 4: notif gate + home permission-denied banner
Notif Gate full screen at /onboarding/notif-gate, reached from waiting
payment on confirmed/consumed status. Auto-advances to /chat/searching
when permission is already granted; otherwise shows izinkan/nanti aja
HaloButton CTAs. NotifPermission helper wraps firebase_messaging +
permission_handler with readStatus/request/openAppSettings; cached in
notifPermissionStatusProvider that re-reads on app foreground via an
internal WidgetsBindingObserver.

home_screen amber banner above-the-fold when notifPermissionStatusProvider
reports denied. Dismissable for the session via homeNotifBannerDismissedProvider
(in-memory StateProvider, no persistence - cold-restart re-shows).
nyalain CTA -> openAppSettings().

Manifest + Info.plist permission entries added.

Note: main.dart still pre-requests FirebaseMessaging permission at boot,
which can pre-resolve status so the gate auto-advances instead of acting
as the first prompt. Left intact for now; can be removed in a later
stage if the gate should be the first-ask UX.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:36:46 +08:00
706149c75e Phase 4 Stage 3: payment shell (multi-screen flow)
Six new screens under /payment/* + a paymentDraftProvider holding
mode/durationId/durationMinutes/priceIDR/paymentId/isFirstSessionDiscount
across the flow. PaymentEntryScreen handles the routing decision
(eligible+enabled -> /payment/discount-paywall, else /payment/method-pick)
and clears the draft on fresh entry.

Screens:
- discount_paywall_screen: S6 first-session discount with struck-through
  gimmick price + actual price + 'mulai · Rp{actual}' CTA -> /payment/method
- method_pick_screen: chat vs call cards
- duration_pick_screen: tier list with chat|call mode toggle that resets
  the selection on swap
- payment_method_screen: QRIS-first list, posts to existing
  /api/client/payment-sessions with mode/duration/price/discount/method
- waiting_payment_screen: qr_flutter QR (encodes paymentId in mock mode),
  20-min countdown header, 3s polling for status, pauses on background
  via WidgetsBindingObserver
- payment_expired_screen: retry CTA -> /payment/method with draft retained

Status mapping: real payment_sessions.status uses 'confirmed'/'consumed'
for paid (not 'paid' as in plan) and 'expired'/'abandoned' as terminal.

home_screen 'Mulai Curhat' CTA now pushes /payment/entry.

Dev-only /internal/_test/force-expire-payment endpoint to drive Maestro
flow 04_payment_expired.yaml without waiting 20 minutes. Gated behind
NODE_ENV !== 'production'.

chat_opening_provider PricingData extended to carry Phase 4 chat/call
groups + firstSessionDiscount, back-compat with the Phase 3 shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:28:59 +08:00
2645bcd0e5 Phase 4 Stage 2: onboarding redesign (client_app + mitra_app)
Verif Choice Sheet on display_name_screen drives the user into either
the verified or anonymous onboarding sub-flow. ESP screen (12 chips,
multi-select, info-only) + USP screen are shared between both branches;
selections persist through to chat_sessions.topics on session start.

OTP-blocked popup (HaloPopup) listens for the four real OTP-rate-limit
error codes (OTP_RATE_LIMIT_PHONE, OTP_RATE_LIMIT_IP, OTP_COOLDOWN,
OTP_ATTEMPTS_EXCEEDED) and drops the user onto the anonymous path with
ESP/USP state preserved.

Auth-providers gating replaces the --dart-define=ENABLE_SOCIAL_AUTH
build flag with server-driven discovery. authProvidersProvider preloads
GET /api/shared/auth-providers at cold start; welcome/register/
force-register screens render Google/Apple buttons only when the
backend reports enabled:true. Falls back to phone-OTP-only when both
providers are off. social_auth_enabled.dart deleted; client_app/CLAUDE.md
updated to reflect the new gating contract.

Mitra app: chat screen renders an ESP chip strip above the first message
bubble when chat_sessions.topics is non-empty.

Backend session.service.js getSessionById SELECTs cs.topics so the mitra
side can read the customer's selected topics.

Maestro flows 02_onboarding_verified.yaml + 03_onboarding_anon.yaml.

Deviation from plan: plan referenced OTP error code 'otp_retry_exhausted';
real codes are OTP_RATE_LIMIT_*/OTP_COOLDOWN/OTP_ATTEMPTS_EXCEEDED -
popup listens for all four. Plan said 'has_paid_first_session'; live
endpoint returns 'has_consulted_before' - used the live field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:23:57 +08:00
4680c36e34 OTP test infrastructure for Maestro flows
Dev-only /internal/_test/peek-otp + /internal/_test/reset-phone endpoints
gated by NODE_ENV !== 'production'. peek-otp reads the latest stub OTP
out of an in-memory map populated by otp.service.js fazpassSendStub;
reset-phone wipes otp_requests rows (and optionally the customers row)
so flows can re-run without tripping cooldowns.

JS + shell helpers under .maestro/scripts/ wrap the endpoints for use
inside Maestro runScript steps. 01_smoke.yaml expanded from a launch-only
sanity check to a full cold-start onboarding -> force-register -> OTP ->
home walk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:19:22 +08:00
4ada7c991a Phase 4 Stage 0: design system foundation (client_app)
- HaloTokens, HaloSpacing, HaloRadius, HaloMotion, HaloShadows (warm palette;
  calm/playful stubbed for phase 5).
- Bundled Bricolage Grotesque, Poppins, JetBrains Mono (~1.2 MB total, OFL).
- haloThemeData() wired into MaterialApp.router with Figma-aligned text
  scale, pill ElevatedButton, 64px input height, 24px-corner BottomSheet,
  dark pill SnackBar.
- Halo* widget primitives: Button, Orb, StepDots, BottomSheet, Popup,
  Snackbar, Chip.
- Dev-only /_theme_preview route gated by --dart-define=THEME_PREVIEW=true
  for visual reference during stages 2-8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:56:00 +08:00
d09e50af55 Phase 3.7: paid pairing flow + returning chat + extension flip
- Backend: payment_sessions + pairing_failures tables; payment.service.js
  and pairing-failure.service.js (new); rewritten pairing.service.js
  (payment-gated blast + targeted "Curhat lagi" + cancel + fallback);
  rewritten extension.service.js (data-driven auto-approve with offline
  safeguard, charge-at-approval); pricing.service.js (extension tiers
  without free trial); mitra-status.service.js (countAvailableMitras
  cached path); 60s sweeper for stale payment sessions
- Backend routes: client.payment.routes, client.mitra-availability.routes,
  internal/failed-pairings.routes; client.chat.routes rewritten for
  payment-gated start + /returning + /cancel + /fallback-to-blast;
  internal/config.routes adds 4 new keys with Valkey invalidate publish
- client_app: mitra-availability poll, payment screen + notifier, pairing
  notifier rewrite (PairingTargetedWaiting + PairingFailed states),
  targeted-waiting overlay + bestie-unavailable dialog, "Curhat lagi"
  CTA, failed-pairing terminal, extension via payment-session
- mitra_app: PairingRequestType enum, returning-chat 20s countdown
  auto-dismiss, extension card "otomatis disetujui" copy
- control_center: 4 new config rows in Settings, Failed Pairings page
  (filter + paginate + action menu), sidebar + route registered
- Test infrastructure: Vitest backend (7/7 pass), Playwright CC (4/4
  pass), Maestro mobile scaffold (CLI install pending)
- Bugs found via Playwright + fixed: LoginPage labels not associated
  with inputs (a11y); backend internal CORS missing PATCH/PUT/DELETE
  in allow-methods (silent settings breakage in browsers since Stage 4)
- Docs: phase3.7.md PRD, phase3.7-plan.md, phase3.7-questions.md (Q&A),
  phase3.7-testing.md (E2E checklist), phase3.7-test-run-2026-05-03.md
  (today's run results)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 23:02:49 +08:00
d9869bf6af Phase 3.4: customer OTP screen rewrite + lockout UX + bug fixes
OTP screen rewrite: 6 rounded boxes, auto-advance focus, auto-submit on the
6th digit, hardware-backspace on empty boxes (intercepted via Focus.onKeyEvent
since TextField.onChanged doesn't fire on already-empty input), resend
cooldown sourced from /api/shared/config/otp, and an inline error message
under the boxes instead of a SnackBar.

Several bugs fixed inline that surfaced during testing:

- ref.listen inside build() accumulates listeners on every rebuild — the
  resend countdown's per-second setState was piling up duplicate listeners
  so one error triggered N callback fires. Moved to ref.listenManual in
  initState; subscription disposed in dispose().

- RouterNotifier was calling notifyListeners() on every auth state change
  including AsyncError, which rebuilt the Navigator/Scaffold mid-snackbar
  and visually duplicated the error toast. Now skips AsyncError and
  same-data-variant transitions.

- ScaffoldMessenger.showSnackBar from a Riverpod listener callback could
  still render twice even with hideCurrentSnackBar — replaced with an
  inline error widget to sidestep the snackbar machinery entirely.

- register_screen now uses context.go instead of context.push for the
  OTP route, so re-submitting the phone form doesn't stack multiple
  OtpScreen instances with active subscriptions.

Lockout UX: AuthErrorInfo wraps the error message + code + retry_after_seconds
parsed from the backend's structured error response. On rate-limit codes
(OTP_COOLDOWN, OTP_RATE_LIMIT_PHONE, OTP_RATE_LIMIT_IP), the OTP screen
extends "Kirim ulang kode" cooldown to match the server's wait, and the
register screen disables "Kirim OTP" with a "Coba lagi dalam …" countdown.
formatCountdown() in core/constants.dart renders Xd under 90 seconds and
Xm Yd above (clearer than raw seconds for long lockouts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:54:49 +08:00
6801001b64 Phase 3: closing-overlay fix + goodbye-composer dedupe
Customer chat refreshSessionStatus now clears sessionExpired carryover so the
goodbye composer renders correctly when re-opening a closing session from
history. Backend /api/shared/chat/:id/info returns goodbye_submitted_by_me;
both apps suppress the composer for the side that has already submitted and
render an awaiting-banner view instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:43:19 +08:00
05ab1e10df Pin minSdk = 24 explicitly on both apps
Both apps were inheriting `flutter.minSdkVersion`, which currently resolves
to 24 (Android 7.0) in this Flutter SDK but could drift if Flutter bumps
its default. Per product requirement we support Android 7+; making the
floor explicit so it doesn't move silently.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:47:35 +08:00
f8380163bc Phase 3: session-end UX overhaul + closing-grace cleanup
Promotes the customer-side chat WebSocket to active-session-scoped (driven
by a new `activeSessionProvider`) so home reflects session state in real
time without a per-screen connection. Backend now auto-completes sessions
left in `closing` after a 5-minute grace window so abandoned goodbye flows
don't leave the customer's home permanently locked.

Customer:
- New `activeSessionProvider` (replaces `unread_notifier`) — single source
  of truth for the active session + unread count; polled every 15s.
- Chat WS lifecycle moved to `main.dart` listener on activeSessionProvider.
  Chat screen joins via `connectIfNotConnected`; the new
  `refreshSessionStatus` reconciles flags from the server when re-entering
  an already-connected session (covers missed `sessionClosing`/`sessionExpired`
  WS events).
- Home filters `closing` from the "Sesi Aktif" CTA so a session pending
  goodbye doesn't block "Mulai Curhat".
- Timer-expired UX is a non-dismissible modal (Tutup / Perpanjang) instead
  of an inline bar.
- Early-end goodbye composer gets an amber "Sesi telah ditutup oleh Bestie"
  banner. Goodbye TextEditingController lifted to state so focus changes
  no longer wipe the message.
- Closure provider reset on chat_screen mount to avoid stale
  `ClosureCompleteData` from a previous session leaking into a new view.
- Chat history now lists `closing` sessions with a "Belum ditutup" badge
  that routes to the live chat (goodbye composer) instead of the transcript.

Mitra:
- Same goodbye-controller fix as customer.
- Same chat-history badge + routing for `closing` items.

Backend:
- New `EndedBy.SYSTEM_AUTO_CLOSE` constant.
- `startClosureGraceTimer` extracted in `session-timer.service.js`; wired
  in from `closure.initiateEarlyEnd`, `extension.rejectExtension`, and
  `extension.handleExtensionTimeout`. Cancelled when customer submits
  goodbye.
- Restart recovery (`restoreActiveTimers`) re-arms grace timers and stamps
  any orphaned `closing` rows with `system_auto_close`.
- `getCustomerHistory` / `getMitraHistory` include `closing` alongside
  `completed`; ordering uses `COALESCE(ended_at, created_at)`.

Removed: dead `session_active_screen.dart` (no router entry).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:47:24 +08:00
98156d1e49 Phase 3.4: client_app self-managed auth cutover
Rips firebase_auth; auth talks directly to the new backend endpoints.
Anonymous-first + phone OTP work end-to-end; Google/Apple SDKs are kept
but buttons are hidden behind ENABLE_SOCIAL_AUTH until backend OAuth
credentials are provisioned.

Smoke-tested against the backend via curl:
- anonymous → PATCH display_name → /me
- OTP request (read stub code from backend log) → verify with
  anonymous_customer_id → same customer row preserved, display_name
  preserved, phone added → upgrade confirmed
- refresh rotation + logout → post-logout refresh correctly fails
  REFRESH_INVALID
- Debug APK builds clean

- pubspec: drop firebase_auth; add flutter_secure_storage
- core/auth/auth_bridge.dart: shared mutable state (access token +
  refresh callback + in-flight de-dup) — keepAlive provider
- core/auth/token_storage.dart: flutter_secure_storage wrapper
  (customer_refresh_token key)
- core/auth/social_auth_enabled.dart: const flag from
  --dart-define=ENABLE_SOCIAL_AUTH (default false)
- core/auth/auth_notifier.dart: bootstrap via stored refresh; anonymous
  via /api/shared/auth/anonymous + PATCH display_name; phone OTP via
  /api/client/auth/*; Google + Apple wired (passes anonymous_customer_id
  for upgrade); anonymity config check for ForceRegister state; granular
  error-code mapping
- core/api/api_client.dart: Bearer from bridge + postRaw(skipAuth) for
  auth endpoints + single-retry 401 refresh
- core/chat/chat_notifier.dart + core/pairing/pairing_notifier.dart: WS
  auth frame reads bridge.accessToken
- features/auth/screens/otp_screen.dart: verificationId → otpRequestId
- features/auth/screens/register_screen.dart + force_register_screen.dart:
  Google/Apple buttons gated behind kSocialAuthEnabled; force_register
  drops obsolete linkAccount() (upgrade happens server-side now via
  anonymous_customer_id)
- client_app/CLAUDE.md: Auth section rewritten (was stale on Firebase)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:08:20 +08:00
1a610363bb iOS navigation fixes: deep-link pop fallback + back-button PopScope
- notification_service: use GoRouter.go (not push) for terminal states
  (session_closing, session_expired) so the nav stack doesn't linger
  behind deep-linked screens
- chat_screen: PopScope + canPop fallback in client_app so iOS back
  gestures fall back to /home when there is nothing to pop
2026-04-24 11:58:05 +08:00
780cade3db Phase 3.3: topic sensitivity + Phase 3.4: auth foundation
Phase 3.3 — Session Topic Sensitivity (complete):
- Backend: topic_sensitivity column + session_sensitivity_log, sensitivity service
  (flip with one-way-latch + audit), PATCH /api/shared/chat/sessions/:id/topic,
  topic carried in pairing + extension WS payloads, CC filter + sensitive stats
  + per-mitra sensitive columns on activity page
- client_app: TopicSelectionBottomSheet before pricing, topic flows through
  pairing request, silent WS handler for session_topic_updated
- mitra_app: SensitivityBadge + SensitivityTheme + sensitivityConfigProvider,
  overlay badge + yellow accent, chat screen app-bar toggle with configurable
  confirmation + latch, extension card shows current flag, history + transcript
  yellow theme
- control_center: Sensitivitas Topik settings section, topic filter + column
  with inline audit log, sensitive stats dashboard card, mitra activity
  sensitive columns with QC flag

Phase 3.4 — Self-Managed Auth (foundation only):
- Migration: auth_sessions + otp_requests tables, social identity columns on
  customers, password_hash + lockout on control_center_users, OTP + CC lockout
  app_config keys
- New services: password (bcrypt + complexity), token (JWT HS256 + refresh
  rotation, session_id claim pre-wires future Valkey revocation),
  social-identity (Google + Apple JWKS), OTP (Fazpass stub — real API TBD)
- Constants: AuthProvider + OtpChannel
- Middleware, auth route rewrites, WS auth update, Firebase → FCM isolation
  still pending (next chunk); Fazpass docs + Apple Developer setup still
  required before E2E testing

Docs:
- requirement/phase3.3.md, phase3.3-plan.md, phase3.3-testing.md
- requirement/phase3.4.md, phase3.4-plan.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:15:12 +08:00
97d50a8e08 Chat UI redesign, splash screen, and onboarding carousel
- Redesign chat screens (both apps) to match Figma: pink theme with
  doodle pattern background, white app bar with centered name and
  chevron back, rose sender bubbles, white receiver bubbles, entry
  banners, and session-ended bottom bar
- Add splash_chat_hebat.png as native Android splash screen with
  Android 12+ support (values-v31)
- Add Flutter splash screen using splash_chat_hebat.png
- Add onboarding carousel (client_app only): 3 pages with 1s
  auto-advance, last page manual "Mulai" button, first-launch only
- Register image assets in both pubspec.yaml files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 22:05:15 +08:00
50d31260dc Fix auth errors, CORS, control center login, and stale session handling
- Mitra auth: parse DioException response for proper error messages
  (ACCOUNT_NOT_FOUND, ACCOUNT_INACTIVE) instead of generic "OTP invalid"
- Backend: add CORS to internal app (port 3001) for control center
- Control center: fix login race condition (wait for AuthContext verify
  before navigating), fix MitraActivityPage fetching paginated data
- Stale session goodbye: both apps detect SESSION_NOT_ACTIVE/409 and
  move to complete state instead of retrying endlessly

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:49:57 +08:00
b043b92b57 iOS push notification setup for client_app and mitra_app
- Add Runner.entitlements with aps-environment capability
- Add UIBackgroundModes (remote-notification, fetch) to Info.plist
- Add CODE_SIGN_ENTITLEMENTS to Debug/Release/Profile build configs
- Add GoogleService-Info.plist for both apps
- Upgrade Firebase packages and web_socket_channel to fix CocoaPods conflict
- Set client_app Podfile iOS platform to 15.0
- Fix mitra_app Xcode bundle ID to match Firebase (com.halobestie.mitra)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 21:20:03 +08:00
94ee00cc91 Enable back button on client_app chat screen for iOS
automaticallyImplyLeading was set to false, hiding the back arrow.
iOS has no physical back button so this is needed for navigation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:42:01 +08:00
e601e19aab Fix chat page stuck: defer provider state changes past build phase
connect() and disconnect() were modifying provider state inside
initState/dispose, which Riverpod disallows during widget tree building.
Wrapped both in Future.microtask() to defer past the build phase.
Applied to both mitra_app and client_app.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:15:01 +08:00
212e1e8ac6 Fix auth: auto-create customer, display name flow, OTP auto-verify
- Backend: getOrCreateCustomer with phone fallback for re-login
- Backend: PATCH /api/client/auth/profile for display name update
- Client app: AuthNeedsDisplayNameData state + SetDisplayNameScreen
- Client app: ApiClient.patch method
- Both apps: handle verificationCompleted for auto-verify (test numbers)
- Both apps: skip credential sign-in if already auto-verified
- Remove debug prints from mitra auth + OTP screens
- Fix ChatRequestNotifier.startListening skips when accepting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:22:28 +08:00
1b249e34b0 Fix router redirect breaking OTP flow on both apps
AsyncLoading during OTP request was redirecting from /login to /splash,
bouncing users back to login. Now auth routes stay put during loading —
only redirect to splash from non-auth routes (initial app startup).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:38:48 +08:00
229f889551 Phase 3.1 WS2: FCM fallback Flutter + CC, unread badges, dynamic ping
- Control center: add mitra ping config UI (require ping toggle + interval)
- Mitra app StatusNotifier: honor require_ping and ping_interval_seconds
  from API; skip heartbeat when ping not required
- Both apps: update notification services for FCM deep-linking
  - mitra_app: handle chat_request (open_accept), session_closing
  - client_app: handle session_closing, paired
- Unread badge providers:
  - mitra_app: UnreadSessions provider (polls active-with-unread, badge
    on active sessions button)
  - client_app: UnreadCount provider (polls active-with-unread, badge
    on _ActiveSessionCard)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:29:06 +08:00
fa8c963d92 Phase 3.1: Remove flutter_bloc + equatable, delete old bloc files
- Remove flutter_bloc and equatable dependencies from both apps
- Delete all 10 old bloc files (5 per app)
- Fix 6 remaining screens that used context.read<ApiClient>() from
  flutter_bloc → converted to ConsumerStatefulWidget/ConsumerWidget
  with ref.read(apiClientProvider)
- Both apps now use Riverpod exclusively for state management

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:12:28 +08:00
bc66bbf50a Phase 3.1: Complete client_app Riverpod migration (all blocs)
- Migrate SessionClosureBloc → SessionClosureNotifier (@riverpod)
- Migrate PairingBloc → PairingNotifier (@riverpod, WebSocket + timer)
- Migrate ChatBloc → ChatNotifier (@riverpod, WebSocket + message state)
- Remove all flutter_bloc usage from client_app screens and main.dart
- MultiBlocProvider fully removed from client_app
- All screens now use ConsumerWidget/ConsumerStatefulWidget + ref

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:01:48 +08:00
d15b2f05fc Phase 3.1 WIP: Riverpod migration (client_app Auth + ChatOpening)
- Add phase3.1 requirement and implementation plan docs
- Add Riverpod dependencies to both client_app and mitra_app
- Wrap both app roots with ProviderScope
- Migrate client_app AuthBloc → AuthNotifier (@riverpod annotation)
- Migrate client_app ChatOpeningBloc → chatPricingProvider (FutureProvider)
- Update router to use Riverpod-based auth state for redirects
- Update all auth screens (display name, register, OTP, force register)
- Update home screen and pricing bottom sheet
- Add android:usesCleartextTraffic for dev HTTP access on both apps
- mitra_app prepared with ProviderScope + ApiClient provider (blocs next)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:51:17 +08:00
b0502ac92b Phase 3 testing fixes: Fastify 5, SSE→WebSocket+FCM, enums, security, session lifecycle
- Upgrade Fastify 4→5 with all plugins (@fastify/websocket 11, cors 11, sensible 6)
- Migrate all SSE endpoints to WebSocket + FCM push (mitra chat requests, customer pairing status)
- Add flutter_local_notifications for foreground push notifications with sound
- Add splash screen to both apps (hide auth loading flash)
- Introduce constants/enums across entire codebase (no raw string literals)
- Move price tiers from hardcoded array to app_config DB (data-driven, includes 1-min test tier)
- Add session ownership validation on all shared chat routes
- Add ownership checks on endSession, respondToExtension, requestExtension
- Fix session timer: auto-complete expired/stale sessions on server restart
- Add 5-min grace period for abandoned closing sessions
- Fix extension flow: proper session_resumed handling, clearExtensionRequest, closure grace timer cleanup
- Fix chat screens: ConnectChat in initState, session status check on connect
- Fix customer expired view: 5-min countdown, closure state priority over expired state
- Fix mitra extension UI: loading spinner, disable buttons, handle EXTENSION_RESOLVED error
- Fix GoRouter navigation consistency (no more Navigator.pushNamed)
- Fix goodbye view keyboard overflow (SingleChildScrollView)
- Add active session card on customer home screen with refresh on navigate back
- Fix PricingBottomSheet extension mode (RequestExtension instead of new pairing)
- Send session_resumed to both parties on extension accept

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 00:17:25 +08:00
b4efcf14c2 Phase 3 scaffold: chat engine (WebSocket, FCM, pricing, timer, extension, history)
- Backend: WebSocket plugin, chat/pricing/timer/extension/closure/notification services
- Client app: ChatBloc, pricing dialog, chat screen with message status, extension/goodbye flow, history
- Mitra app: MitraChatBloc, ExtensionBloc, chat screen, extension accept/reject, history
- Control center: free trial, extension timeout, early end config toggles
- DB migration: chat_messages, session_closures, session_extensions, customer_transactions tables

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:58:11 +08:00
844d7234e6 Phase 2 refinements: Firebase config, dev environment fixes, phase 3 requirement draft
- Integrated Firebase SDK in both Flutter apps (google-services, firebase_options)
- Fixed auth flow, API client, and pairing/status blocs for dev environment
- Added full Flutter project scaffolds (android, ios, web, etc.)
- Added phase 3 chat engine requirement document
- Added bugreport zip pattern to gitignore

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 19:16:34 +08:00
d668112edd Phase 2 scaffold: mitra online status & pairing logic
Add mitra online/offline status with heartbeat-based auto-offline,
customer-mitra pairing via Valkey pub/sub blast, session management,
and control center dashboard with real-time stats.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:17:49 +08:00
a7a2a32d27 Phase 1 scaffold: auth for all apps
- Backend: Fastify with two listeners (public + internal), routes, services, DB migration + seed
- client_app: Flutter with BLoC, all auth screens (welcome, display name, register, OTP, force-register)
- mitra_app: Flutter with BLoC, OTP-only login
- control_center: React + Vite, email/password login, mitra/user management, anonymity settings
- Docs: phase1 plan, API contract, client app mockup
- CLAUDE.md and shared memory for all subprojects

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 10:08:42 +08:00