Compare commits

..

2 Commits

Author SHA1 Message Date
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
37 changed files with 1501 additions and 81 deletions

View File

@@ -23,6 +23,20 @@ export const getSessionConnections = (sessionId) => {
return sessionConnections.get(sessionId) || {}
}
// Test-only: expose the in-memory connection map state for a session so
// Maestro flows can assert that backgrounding the customer/mitra app closed
// its WebSocket (which is what gates the FCM fallback in chat.service.js).
// Returns booleans per role based on `socket.readyState === 1`.
export const inspectSessionWsState = (sessionId) => {
const conns = sessionConnections.get(sessionId) || {}
const customerSock = conns[UserType.CUSTOMER]
const mitraSock = conns[UserType.MITRA]
return {
customer_connected: !!(customerSock && customerSock.readyState === 1),
mitra_connected: !!(mitraSock && mitraSock.readyState === 1),
}
}
const sendToSocket = (socket, data) => {
if (socket && socket.readyState === 1) {
socket.send(JSON.stringify(data))

View File

@@ -8,8 +8,11 @@
import { peekStubOtp } from '../../services/otp.service.js'
import { acceptPairingRequest, expirePairingRequest, expireTargetedPairingRequest, getPendingRequestsForMitra } from '../../services/pairing.service.js'
import { startSessionTimer, _resetThreeMinFiredForTest, _broadcastTimerResyncForTest } from '../../services/session-timer.service.js'
import { inspectSessionWsState } from '../../plugins/websocket.js'
import { sendMessage } from '../../services/chat.service.js'
import { sendPushNotification } from '../../services/notification.service.js'
import { getDb } from '../../db/client.js'
import { PairingFailureCause, SessionStatus } from '../../constants.js'
import { PairingFailureCause, SessionStatus, UserType } from '../../constants.js'
const sql = getDb()
@@ -460,4 +463,127 @@ export const internalTestRoutes = async (fastify) => {
const session = await acceptPairingRequest(latest.session_id, mitraId)
return { ok: true, session_id: latest.session_id, session }
})
// Test-only: read the in-memory websocket connection state for a session.
// Used by Maestro flows asserting that backgrounding the customer/mitra
// app closed its WebSocket (which is what gates FCM fallback per
// chat.service.js:51). Returns booleans per role.
fastify.get('/ws-connection-state', async (request, reply) => {
const sessionId = request.query?.session_id
if (!sessionId) {
return reply.code(400).send({ error: 'session_id query param required' })
}
return inspectSessionWsState(sessionId)
})
// Test-only: emulate an FCM push to a specific customer without going
// through the chat-session/WS-fallback path. Useful for poking the
// device's local-notification handler in isolation (e.g. verifying the
// `chat_messages` channel renders, FCM token validity, etc).
//
// Body: { customer_id?, latest_customer?, content? }
// - customer_id: target a specific customers.id.
// - latest_customer: true → pick the most-recently-created customer
// that has an fcm_token (handy when the maestro flow just signed in
// anonymously and you don't have the UUID).
// - content: optional override for the notification body text.
fastify.post('/send-fcm-chat-message', async (request, reply) => {
const { customer_id: customerId, latest_customer: latest, content } =
request.body ?? {}
let targetId = customerId
if (!targetId && latest === true) {
const [row] = await sql`
SELECT id FROM customers
WHERE fcm_token IS NOT NULL
ORDER BY created_at DESC
LIMIT 1
`
if (!row) {
return reply.code(404).send({ error: 'no_customer_with_fcm_token' })
}
targetId = row.id
}
if (!targetId) {
return reply.code(400).send({
error: 'customer_id or latest_customer:true required in body',
})
}
const body = content || 'Pesan baru dari bestie · ketuk buat balas'
const ok = await sendPushNotification(UserType.CUSTOMER, targetId, {
title: 'Pesan baru dari Bestie',
body,
data: { type: 'chat_message', session_id: 'test-emulated' },
})
return { ok, customer_id: targetId, body }
})
// Test-only: send a chat message AS the customer of a paired session.
// Mirrors send-chat-message-as-mitra but with senderType=CUSTOMER —
// useful for asserting the mitra-side FCM fallback when the mitra app is
// backgrounded. Returns the dispatch transport so the caller can assert
// delivered_via=fcm.
//
// Body: { session_id, content }
fastify.post('/send-chat-message-as-customer', async (request, reply) => {
const { session_id: sessionId, content } = request.body ?? {}
if (!sessionId || !content) {
return reply.code(400).send({ error: 'session_id and content required in body' })
}
const [session] = await sql`
SELECT customer_id FROM chat_sessions WHERE id = ${sessionId} LIMIT 1
`
if (!session?.customer_id) {
return reply.code(404).send({ error: 'no_session_or_customer', session_id: sessionId })
}
// Recipient here is the mitra — inspect its WS state before dispatch so
// we can answer "websocket" vs "fcm" honestly.
const wsBefore = inspectSessionWsState(sessionId)
const message = await sendMessage({
sessionId,
senderType: UserType.CUSTOMER,
senderId: session.customer_id,
content,
})
return {
ok: true,
message_id: message.id,
delivered_via: wsBefore.mitra_connected ? 'websocket' : 'fcm',
}
})
// Test-only: send a chat message AS the mitra of a paired session, using
// the real chat.service.sendMessage code path. Returns which transport
// actually carried the message — useful for asserting the WS-vs-FCM
// fallback (e.g. Maestro backgrounds the customer app, calls this, and
// expects `delivered_via: "fcm"`).
//
// Body: { session_id, content }
fastify.post('/send-chat-message-as-mitra', async (request, reply) => {
const { session_id: sessionId, content } = request.body ?? {}
if (!sessionId || !content) {
return reply.code(400).send({ error: 'session_id and content required in body' })
}
const [session] = await sql`
SELECT mitra_id FROM chat_sessions WHERE id = ${sessionId} LIMIT 1
`
if (!session?.mitra_id) {
return reply.code(404).send({ error: 'no_session_or_mitra', session_id: sessionId })
}
// Snapshot WS state BEFORE the send so we can answer "which path?"
// honestly: sendMessage tries WS first, falls back to FCM only when WS
// returned false. We inspect customer_connected here because the mitra
// is the sender — recipient is the customer.
const wsBefore = inspectSessionWsState(sessionId)
const message = await sendMessage({
sessionId,
senderType: UserType.MITRA,
senderId: session.mitra_id,
content,
})
return {
ok: true,
message_id: message.id,
delivered_via: wsBefore.customer_connected ? 'websocket' : 'fcm',
}
})
}

View File

@@ -0,0 +1,89 @@
# §2 — New-User Onboarding test plan
Spec: [requirement/flow_customer.mermaid.md §2 + §2.1](../../../requirement/flow_customer.mermaid.md).
Tests use the naming convention `ts-customer-<section>-<sub>-<description>.yaml`:
- `<section>` — flow_customer.mermaid section number (`02` for §2 + §2.1).
- `<sub>` — sub-flow index within the section.
- `<description>` — snake_case summary of the branch under test.
## Implemented
| File | Branch (spec ref) | Expected destination |
|---|---|---|
| `ts-customer-02-01-verified_brand_new_to_s6_paywall.yaml` | §2 verified · UserLookup=no (brand-new) | `/payment/discount-paywall` (S6) |
| `ts-customer-02-02-verified_existing_no_tx_to_s6_paywall.yaml` | §2 verified · existing customer · has_transacted=false | `/payment/discount-paywall` (S6) |
| `ts-customer-02-03-verified_existing_transacted_to_method_pick.yaml` | §2 verified · existing customer · has_transacted=true | `/payment/method-pick` (PickMethod) |
| `ts-customer-02-04-otp_blocked_fallback_anonymous_to_method_pick.yaml` | §2 verified · OTPok="too many retries" → OTPBlock → fallback | `/payment/method-pick` (PickMethod) |
| `ts-customer-02-05-anonymous_first_timer_to_method_pick.yaml` | §2 anonymous · USPGateB=first-timer → USPb | `/payment/method-pick` (PickMethod) |
| `ts-customer-02-10-recover_via_masuk_existing_user_to_home.yaml` | SHome1st "masuk →" recover · existing identified user | `/home` (returning view) |
The shared verified-onboarding prelude lives at
[`../subflows/onboarding_new_user_verified.yaml`](../subflows/onboarding_new_user_verified.yaml).
## Deferred (not yet implemented — see reasons)
### `ts-customer-02-06` — anonymous · USP already seen → PickMethod directly
**Branch:** §2 anonymous · USPGateB=seen → skip USP → PickMethod.
**Why deferred:** verifying the seen=true skip path literally requires the
user to reach `VerifChoiceSheet` while local `flutter.usp_seen=true` AND
auth state is fresh (`AuthInitialData`, no anon customer). Maestro's
`launchApp clearState:true` wipes both `SharedPreferences` and
`FlutterSecureStorage` together — there's no built-in way to clear ONLY
secure storage between passes. A two-pass test that runs USP once then
relaunches preserves both prefs and the anonymous session, so Pass 2 lands
on `SHomeReturning` instead of `SHome1st`, bypassing VerifChoiceSheet.
**Possible future approach:** add a shell-level pre-test step that runs
`adb shell run-as <pkg> rm shared_prefs/FlutterSecureStorage*.xml` and
preserves `FlutterSharedPreferences.xml`, then invoke maestro. Requires a
wrapper script or a maestro extension that can call adb.
### `ts-customer-02-07` — §2.1 5.1 anon transact → OTP new phone → upgrade in place
### `ts-customer-02-08` — §2.1 5.2 anon transact → OTP existing phone → merge (existing has_transacted=true)
### `ts-customer-02-09` — §2.1 5.2 merge · existing has_transacted=false
**Branch:** §2.1 anonymous → existing-user merge sub-flow.
**Why deferred:** §2.1's prereq is "anonymous customer has completed at
least one transaction". The app derives `has_transacted` from
`chat_sessions` rows, and the auth flow ties customer identity to the
device's `FlutterSecureStorage` refresh token. Reproducing the prereq in
Maestro requires either:
1. Driving the app UI all the way through anonymous transaction → payment
confirm → pairing → chat → end-session (a full §3 + §5 + §6 traversal),
then triggering OTP from the SHomeReturning state. The SHomeReturning
"curhat sama bestie baru" CTA bypasses `VerifChoiceSheet` entirely, so
the only OTP entry point from this state is the SHome1st "masuk →"
banner — which is only shown on SHome1st (no identity), not on
SHomeReturning.
2. Pre-seeding a customer + chat_session in the DB *and* injecting the
corresponding refresh token into the device's FlutterSecureStorage so
the app boots as that anonymous-with-transactions customer. Maestro
doesn't expose adb shell exec, so this requires a wrapper script.
In practice, the §2.1 merge logic is more naturally covered by **backend
integration tests** against `resolveCustomerForIdentity` in
`backend/src/services/auth.service.js` — the app-side behavior after
re-login is the same `/home` landing (login intent) tested by 02-10. The
merge state is a backend invariant, not a UI invariant.
## Behavior notes
- §2 verified branches route post-OTP via `/payment/entry`, which then
dispatches based on the backend's `first_session_discount.eligible`
(see `backend/src/services/pricing.service.js::isCustomerEligibleForFirstSessionDiscount`).
This handles both "brand-new" and "existing-but-never-paid" with a
single check.
- The `OnboardingIntent` provider (added 2026-05-18 with the §2 fix)
distinguishes transaction-CTA entries (`onboarding`) from login-recover
entries (`recover`). Set at `verif_choice_sheet.dart::routeForVerifChoice`
(verified branch) and at `home_screen.dart::_LoginRecoverBanner`
(masuk →). Consumed by `router.dart`'s post-OTP redirect.
- `auth_notifier.dart::verifyOtp` uses `.copyWithPrevious(previous)` when
setting `AsyncError`, so `valueOrNull` retains the prior
`AuthOtpSentData` / `AuthAnonymousData` through the OTP-blocked popup
→ fallback path.

View File

@@ -0,0 +1,93 @@
# §5 — Chat Room test plan
Spec: [requirement/flow_customer.mermaid.md §5](../../../requirement/flow_customer.mermaid.md)
plus the user-stated message-delivery contract:
> 1. WebSocket when app is foreground / WS connected.
> 2. FCM when app is background / WS disconnected.
The lifecycle observers that enforce (2) live at:
- Customer side: `client_app/lib/main.dart::_AppState.didChangeAppLifecycleState`
- Mitra side: `mitra_app/lib/main.dart::_AppState.didChangeAppLifecycleState` +
`mitra_app/lib/core/chat/mitra_chat_notifier.dart` (`_connectedSessionId`
+ getter for the observer to read on `paused`).
A 15s `Timer.periodic` in `active_session_notifier.dart` would otherwise
re-open the customer WS right after the observer closed it; the
`_appPaused` gate in customer main.dart suppresses that race.
## Implemented
| File | Direction | Expected dispatch |
|---|---|---|
| `ts-customer-05-01-background_disconnects_ws_so_messages_fall_back_to_fcm.yaml` | mitra → customer, customer is backgrounded | `delivered_via=fcm` |
| `ts-customer-05-02-customer_message_to_backgrounded_mitra_falls_back_to_fcm.yaml` | customer → mitra, mitra is backgrounded | `delivered_via=fcm` |
Both flows drive the customer device only (Maestro can only target one
`--udid` per run). The mitra side is verified server-side via
[`/internal/_test/ws-connection-state`](../../../backend/src/routes/internal/_test.routes.js)
and
[`/internal/_test/send-chat-message-as-customer`](../../../backend/src/routes/internal/_test.routes.js)
(introduced 2026-05-18 with the lifecycle fix).
## Helper scripts (under `../scripts/`)
| Script | Purpose |
|---|---|
| `assert_ws_state.js` | Reads `/internal/_test/ws-connection-state?session_id=…`; throws on mismatch with `EXPECTED_CUSTOMER_CONNECTED` / `EXPECTED_MITRA_CONNECTED`. |
| `assert_delivered_via.js` | Sends a chat message via the test endpoint matching `SENDER` (`mitra` default, or `customer`); asserts the response's `delivered_via` equals `EXPECTED_DELIVERED_VIA`. |
| `inspect_ws_state.js` | Same data as `assert_ws_state.js` but exports the booleans into `output.CUSTOMER_CONNECTED` / `output.MITRA_CONNECTED` for downstream conditions without throwing. |
| `send_chat_message_as_mitra.js` | Mitra→customer one-shot send. Captures `output.MESSAGE_ID` + `output.DELIVERED_VIA`. |
## Pre-reqs for both flows
- Backend reachable; `NODE_ENV != 'production'`.
- ≥1 mitra online (the `seed_history_session.js` helper picks the
most-recently-online mitra to act as the eventual acceptor).
- For 05-01: customer device is the one Maestro drives; `pressKey: HOME`
inside the flow backgrounds it. No mitra-app interaction required.
- For 05-02: **the mitra app on `emulator-5556` must be backgrounded or
force-stopped before the flow runs**. The `assert_ws_state.js` guard
fails loudly if mitra is foreground (proving WS-over-FCM precedence —
also a correct test outcome, just the wrong side of the matrix). To
ensure FCM actually arrives on the device, the mitra account on
`emulator-5556` must have a `fcm_token` in `mitras.fcm_token` (i.e.,
the app has signed in and called `/api/shared/device-token` at some
point) — verified by `seed_history_session.js`'s output mitra ID and
the `MITRA_NAME_RE` it captures.
## Manual mitra-side verification
After 05-02 passes, optionally inspect the mitra device's notification
shade to confirm the FCM tray notification rendered:
```bash
export ADB_SERVER_SOCKET=tcp:172.22.240.1:5037
adb -s emulator-5556 shell cmd statusbar expand-notifications
adb -s emulator-5556 exec-out screencap -p > /tmp/mitra_shade.png
adb -s emulator-5556 shell cmd statusbar collapse
```
Expect to see a `Pesan baru dari Customer` notification within ~1s on an
API 28+ AVD (older AVDs queue FCM for 5-30 min — see the per-AVD
Play-Services notes in the project memory).
## Not yet automated
**Mitra-side lifecycle observer** end-to-end. To verify that the mitra
app's `didChangeAppLifecycleState(paused)` actually closes its chat WS,
the test would need to drive the mitra emulator (Maestro on
`--udid emulator-5556`) through login → chat-screen → `pressKey: HOME`
assert backend WS state. This requires:
1. Pre-seeding an active chat session for the currently-signed-in mitra
(no existing test endpoint for "seed customer + pair + active
session"; the existing scripts assume the customer app drives it).
2. Driving the mitra OTP login + chat-request acceptance UI from
Maestro.
Both are feasible but out of scope for the current iteration. The
mitra-side fix is covered indirectly: ts-customer-05-02 asserts the
backend correctly dispatches via FCM whenever the mitra WS is closed,
and the mitra app's lifecycle observer was verified manually
(2026-05-18) to be the mechanism that closes that WS.

View File

@@ -0,0 +1,61 @@
# ts-customer-01-01 — When OS-level notification permission is denied,
# SHome1st renders the amber "notifikasi off" banner above the
# 'aku mau curhat' CTA.
# Spec ref: requirement/flow_customer.mermaid.md §1 — `NotifCheck → no →
# HomeBanner` (line 24-26 of the mermaid).
#
# Banner widget: home_screen.dart::_NotifDeniedBanner. Gating provider:
# notifPermissionStatusProvider (core/notifications/notif_permission.dart).
# The provider reads `permission_handler::Permission.notification.status`
# which, on Android 13+, reflects the runtime POST_NOTIFICATIONS state and
# on older Android reflects the app's notification-enabled toggle.
#
# Pre-requisite (manual setup):
# The test only exercises the "denied" branch. Disable notifications for
# the customer app BEFORE invoking maestro:
# - Android 13+: maestro's `permissions: { notifications: deny }` below
# handles this (POST_NOTIFICATIONS is a runtime permission).
# - Android 7-12 (incl. the API-24 dev AVD): the runtime permission
# doesn't exist, so the maestro `permissions:` block is a no-op.
# Instead, toggle Settings → Apps → Halo Bestie → Notifications →
# Off manually. This sets the system `NotificationManagerCompat`
# state which the app's `notif_permission.dart::readStatus`
# pre-check picks up. `clearState: true` below is safe — `pm clear`
# wipes app data but not the system-level notification toggle.
# The backend FCM path still fires regardless of this branch (the OS
# simply suppresses the notification); the banner is the only
# user-visible signal that they're missing alerts, which is what we
# assert below.
appId: com.halobestie.client.client_app
env:
TEST_PHONE: "+6281234567890"
BACKEND_INTERNAL_URL: http://localhost:3001
---
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
permissions:
notifications: deny
- extendedWaitUntil:
visible:
text: "Mulai"
timeout: 15000
- tapOn:
text: "Mulai"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "(?s).*aku mau curhat.*"
timeout: 30000
# §1 NotifCheck=no → SHome1st banner. Text is the unique copy from
# _NotifDeniedBanner; the icon is decorative-only and not part of the
# a11y label.
- assertVisible: "(?s).*notifikasi off.*"
- assertVisible: "(?s).*kelewat chat dari bestie.*"
- assertVisible: "(?s).*nyalain.*"
- takeScreenshot: /tmp/notif_banner_design

View File

@@ -0,0 +1,33 @@
# ts-customer-02-01 — §2 verified path, BRAND-NEW user → S6 paywall.
# Spec ref: requirement/flow_customer.mermaid.md §2, branch
# OTPok="verified" → UserLookup="no · brand-new" → S6 Paywall.
#
# Verifies the post-OTP intent fix: the user who arrived via the §2
# transaction CTA ("aku mau curhat") is routed by the router redirect to
# /payment/entry → /payment/discount-paywall, NOT /home.
#
# Pre-reqs:
# - Backend reachable; NODE_ENV != 'production'.
# - ≥1 mitra online (mitraAvailable gates the "aku mau curhat" CTA).
# - first_session_discount is enabled in the pricing config (default).
appId: com.halobestie.client.client_app
env:
TEST_PHONE: "+6281234567890"
BACKEND_INTERNAL_URL: http://localhost:3001
---
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
- runFlow: ../subflows/onboarding_new_user_verified.yaml
# Post-OTP: router consumes intent=onboarding → /payment/discount-paywall.
# Brand-new user (no chat_sessions) is eligible for first_session_discount.
- extendedWaitUntil:
visible:
text: "(?s).*biar yakin yang mau cerita.*"
timeout: 20000
- assertVisible: "(?s).*mulai ·.*"

View File

@@ -0,0 +1,44 @@
# ts-customer-02-02 — §2 verified path, EXISTING customer with no prior
# transactions → S6 paywall.
# Spec ref: requirement/flow_customer.mermaid.md §2, branch
# OTPok="verified" → UserLookup="yes · existing" → LoadCallSign →
# TransactedCheck="no · never paid" → S6 Paywall.
#
# Pre-seeds an identified customer with display_name but no chat_sessions
# before the run, so:
# 1. The OTP-verify backend lookup finds the existing customer.
# 2. The stored display_name overwrites the typed S2 Nama (per spec L62).
# 3. has_transacted is implicitly false (no chat_sessions →
# isCustomerEligibleForFirstSessionDiscount returns true).
# 4. /payment/entry routes to /payment/discount-paywall (S6).
appId: com.halobestie.client.client_app
env:
TEST_PHONE: "+6281234567890"
BACKEND_INTERNAL_URL: http://localhost:3001
---
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# Seed the existing identified customer AFTER reset (which would otherwise
# drop them). No history session is seeded → has_transacted=false.
- runScript:
file: ../scripts/seed_customer.js
env:
TEST_PHONE: ${TEST_PHONE}
DISPLAY_NAME: "ExistingPriorName"
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
- runFlow: ../subflows/onboarding_new_user_verified.yaml
# Post-OTP: existing customer with no prior chat_sessions →
# first_session_discount.eligible=true → S6 paywall.
- extendedWaitUntil:
visible:
text: "(?s).*biar yakin yang mau cerita.*"
timeout: 20000
- assertVisible: "(?s).*mulai ·.*"

View File

@@ -0,0 +1,49 @@
# ts-customer-02-03 — §2 verified path, EXISTING customer with prior
# transactions → PickMethod (skip S6).
# Spec ref: requirement/flow_customer.mermaid.md §2, branch
# OTPok="verified" → UserLookup="yes · existing" → LoadCallSign →
# TransactedCheck="yes · returning verified" → PickMethod.
#
# Pre-seeds an identified customer with a completed chat_session so
# `isCustomerEligibleForFirstSessionDiscount` returns false → /payment/entry
# routes to /payment/method-pick instead of /payment/discount-paywall.
#
# Pre-reqs:
# - ≥1 mitra online (seed_history_session pairs with the most-recent
# online mitra to write the chat_sessions row).
appId: com.halobestie.client.client_app
env:
TEST_PHONE: "+6281234567890"
BACKEND_INTERNAL_URL: http://localhost:3001
---
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# Seed identified customer first — seed_history_session needs an existing
# customers row to attach the chat_session FK to.
- runScript:
file: ../scripts/seed_customer.js
env:
TEST_PHONE: ${TEST_PHONE}
DISPLAY_NAME: "ExistingTxName"
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- runScript:
file: ../scripts/seed_history_session.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
- runFlow: ../subflows/onboarding_new_user_verified.yaml
# Post-OTP: existing customer with prior chat_session →
# first_session_discount.eligible=false → /payment/method-pick (PickMethod).
- extendedWaitUntil:
visible:
text: "(?s).*pilih cara curhat.*"
timeout: 20000
- assertVisible: "(?s).*tulis dan baca dengan tenang.*"

View File

@@ -0,0 +1,95 @@
# ts-customer-02-04 — §2 verified path, OTP blocked → fallback to anonymous
# → PickMethod.
# Spec ref: requirement/flow_customer.mermaid.md §2, branch
# S3b OTP → OTPok="too many retries" → OTPBlock → fallback to Anon → USPGateB.
#
# The OtpBlockedPopup is gated by OTP_ATTEMPTS_EXCEEDED (default config:
# 5 verification attempts per OTP request; the 6th submission triggers the
# 429). Popup CTA "lanjut tanpa verif" calls context.go('/onboarding/anon/method')
# which redirects to /payment/method-pick — USP is presumed already evaluated
# upstream (verif_choice_sheet shown → USPb skipped), so the anonymous flow
# jumps straight into PickMethod.
#
# This flow inlines the pre-OTP onboarding steps (instead of using the
# onboarding_new_user_verified subflow) because we want to enter wrong OTPs
# rather than the peeked valid one.
appId: com.halobestie.client.client_app
env:
TEST_PHONE: "+6281234567890"
BACKEND_INTERNAL_URL: http://localhost:3001
---
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
- extendedWaitUntil:
visible:
text: "Mulai"
timeout: 15000
- tapOn:
text: "Mulai"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "(?s).*aku mau curhat.*"
timeout: 30000
- tapOn: "(?s).*aku mau curhat.*"
- extendedWaitUntil:
visible:
text: "(?s).*Siapa namamu.*"
timeout: 10000
- tapOn:
point: "50%, 28%"
- inputText: "MaestroBlock"
- tapOn: "lanjut"
- extendedWaitUntil:
visible:
text: "(?s).*Mau curhat sebagai siapa.*"
timeout: 10000
- tapOn: "(?s).*verifikasi nomor HP.*"
- runFlow:
when:
visible:
text: "Sebelum mulai"
commands:
- tapOn: "(?s).*aku ngerti.*"
- extendedWaitUntil:
visible:
text: "(?s).*nomor wa-mu.*"
timeout: 10000
- tapOn:
point: "60%, 47%"
- inputText: "81234567890"
- tapOn: "(?s).*kirim kode.*"
# OTP screen — type wrong "000000" six times. Default config is
# verify_max_attempts=5 (config.service.js L218); attempt 6 throws
# OTP_ATTEMPTS_EXCEEDED → OtpBlockedPopup fires.
- extendedWaitUntil:
visible:
text: "Masukkan OTP"
timeout: 15000
- inputText: "000000"
- inputText: "000000"
- inputText: "000000"
- inputText: "000000"
- inputText: "000000"
- inputText: "000000"
# Popup: "Verifikasi nomor lagi penuh" with primary CTA "lanjut tanpa verif".
- extendedWaitUntil:
visible:
text: "(?s).*Verifikasi nomor lagi penuh.*"
timeout: 15000
- tapOn: "(?s).*lanjut tanpa verif.*"
# Assert: redirected to /payment/method-pick (PickMethod).
- extendedWaitUntil:
visible:
text: "(?s).*pilih cara curhat.*"
timeout: 15000
- assertVisible: "(?s).*tulis dan baca dengan tenang.*"

View File

@@ -0,0 +1,71 @@
# ts-customer-02-05 — §2 anonymous path, first-timer (USP not yet seen) →
# PickMethod (method-pick screen).
# Spec ref: requirement/flow_customer.mermaid.md §2, branch
# VerifChoice="tanpa verif · Rp5k+" → USPGateB="no · first-timer" →
# USPb → PickMethod.
#
# Anonymous path skips OTP entirely — no router redirect needed (user
# stays AuthAnonymousData). USP screen pushes /payment/method-pick
# directly when verified=false. Verifies onboardingIntent is NOT set
# (it stays `recover` because we picked "curhat anonim", not "verifikasi").
appId: com.halobestie.client.client_app
env:
TEST_PHONE: "+6281234567890"
BACKEND_INTERNAL_URL: http://localhost:3001
---
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
# Welcome → SHome1st.
- extendedWaitUntil:
visible:
text: "Mulai"
timeout: 15000
- tapOn:
text: "Mulai"
retryTapIfNoChange: true
# "aku mau curhat" → S2 Nama.
- extendedWaitUntil:
visible:
text: "(?s).*aku mau curhat.*"
timeout: 30000
- tapOn: "(?s).*aku mau curhat.*"
# S2 Nama — submit display name.
- extendedWaitUntil:
visible:
text: "(?s).*Siapa namamu.*"
timeout: 10000
- tapOn:
point: "50%, 28%"
- inputText: "MaestroAnon"
- tapOn: "lanjut"
# VerifChoiceSheet — pick "curhat anonim" (anonymous branch). Per
# routeForVerifChoice (verif_choice_sheet.dart L94), USP-not-seen takes
# /onboarding/anon/usp; USP-seen would go directly to /payment/method-pick.
- extendedWaitUntil:
visible:
text: "(?s).*Mau curhat sebagai siapa.*"
timeout: 10000
- tapOn: "(?s).*curhat anonim.*"
# USPb (anonymous variant) — first-timer.
- extendedWaitUntil:
visible:
text: "Sebelum mulai"
timeout: 10000
- tapOn: "(?s).*aku ngerti.*"
# Assert: /payment/method-pick visible (PickMethod in spec).
- extendedWaitUntil:
visible:
text: "(?s).*pilih cara curhat.*"
timeout: 15000
- assertVisible: "(?s).*tulis dan baca dengan tenang.*"

View File

@@ -1,19 +1,28 @@
# TS-07 — Returning user with existing display_name skips set-name screen
# (requirement/phase4-customer-flow.md → Test Scenarios → TS-07).
# ts-customer-02-10 — SHome1st "masuk →" login-recover banner · existing
# identified user → /home directly (no set-name detour).
# Spec refs:
# - requirement/flow_customer.mermaid.md §2 (post-OTP path for existing
# identified customer with display_name stored)
# - Project directive: login-intent entries (masuk → banner) land on
# /home; transaction-CTA entries (aku mau curhat / curhat sama bestie
# baru) land on /payment/entry.
#
# Inverse of TS-01..TS-06: those flows wipe the customer (drop_customer=true)
# so every OTP path hits the new-user set-name branch. TS-07 instead seeds
# an EXISTING customer row with phone + display_name, then verifies the
# OTP sign-in returns the existing row unchanged (via
# resolveCustomerForIdentity branch 1) and the client routes directly to
# /home without showing /auth/set-name.
# Pre-seeds an existing identified customer with phone + display_name.
# After OTP succeeds, backend's resolveCustomerForIdentity returns the
# existing row unchanged. The router's onboardingIntentProvider stays at
# `recover` (the masuk → handler resets it defensively), so the post-OTP
# redirect lands on /home rather than /payment/entry.
#
# This was previously named TS-07 (under the §4-driven naming scheme).
# Renamed 2026-05-18 to the ts-customer-NN-MM-* convention; the assertion
# semantics are unchanged.
#
# Pre-reqs:
# - Backend reachable, NODE_ENV != 'production'.
# - (No mitra requirement — flow stops at /home.)
#
# Run:
# maestro test client_app/.maestro/flows/ts-07_returning_existing_name_skips_setname.yaml
# maestro test client_app/.maestro/flows/ts-customer-02-10-recover_via_masuk_existing_user_to_home.yaml
appId: com.halobestie.client.client_app
env:
TEST_PHONE: "+6281234567890"

View File

@@ -0,0 +1,151 @@
# ts-customer-05-01 — When the customer app is backgrounded, the chat
# WebSocket must close so backend `sendMessage` falls back to FCM.
#
# Spec ref: requirement/flow_customer.mermaid.md §5 (Chat Room) +
# user-stated path:
# 1. WebSocket when app is foreground / WS connected.
# 2. FCM when app is background / WS disconnected.
#
# Fix under test: client_app/lib/main.dart `_AppState.didChangeAppLifecycleState`
# closes the chat WS on AppLifecycleState.paused / detached. Before the
# fix, Android kept the TCP socket alive after the Dart isolate paused, so
# backend's `socket.readyState === 1` check returned true and FCM never
# fired — the customer received no alert in background.
#
# Verification approach:
# 1. Drive customer through pairing → active chat (same harness as
# ts-customer-04-04).
# 2. Assert customer-side WS is connected (backend state check).
# 3. Press HOME to background → wait briefly for the lifecycle observer
# to fire `disconnect()` and the socket-close to round-trip.
# 4. Assert customer-side WS is now closed.
# 5. Send a message AS mitra via the real `sendMessage` code path and
# assert it was delivered via FCM (recipient.customer WS was down at
# dispatch time).
#
# Pre-reqs:
# - Backend reachable; NODE_ENV != 'production'.
# - ≥1 mitra online (the seeded mitra acts as the blast acceptor and the
# subsequent message sender).
appId: com.halobestie.client.client_app
env:
TEST_PHONE: "+6281234567890"
BACKEND_INTERNAL_URL: http://localhost:3001
---
# ── Setup: pair customer with a mitra, land in active chat ──────────────
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
- runFlow: ../subflows/onboarding_returning_user.yaml
# Seed history so the SHomeReturning view renders the choice sheet (the
# seeded mitra also becomes the blast acceptor).
- runScript:
file: ../scripts/seed_history_session.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- swipe:
start: "50%, 30%"
end: "50%, 80%"
- extendedWaitUntil:
visible:
text: "(?s).*curhatan sebelumnya.*"
timeout: 10000
# "curhat sama bestie baru" → choice sheet → "bestie baru" → method-pick
- tapOn:
text: "(?s).*curhat sama bestie baru.*"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "(?s).*mau curhat sama siapa.*"
timeout: 5000
- tapOn: "(?s).*cari bestie baru yang siap dengerin.*"
# Payment chain → confirm → pairing accepted.
- extendedWaitUntil:
visible:
text: "(?s).*pilih cara curhat.*"
timeout: 10000
- tapOn:
text: "(?s).*tulis dan baca dengan tenang.*"
- extendedWaitUntil:
visible:
text: "(?s).*pilih durasi.*"
timeout: 10000
- tapOn: "(?s).*5 menit.*"
- tapOn: "(?s).*bayar Rp.*"
- extendedWaitUntil:
visible:
text: "(?s).*cara bayar.*"
timeout: 10000
- tapOn:
text: "(?s).*bayar Rp.*"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "scan QRIS untuk bayar"
timeout: 10000
- runScript:
file: ../scripts/mark_latest_payment_paid.js
env:
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- runFlow:
when:
visible:
text: "(?s).*biar nggak ketinggalan.*"
commands:
- tapOn:
text: "(?s).*nanti aja.*"
- extendedWaitUntil:
visible:
text: "(?s).*lagi nyari bestie.*"
timeout: 20000
- runScript:
file: ../scripts/mitra_accept_latest_internal.js
env:
MITRA_ID: ${output.MITRA_ID}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- extendedWaitUntil:
visible:
text: "(?s).*online.*"
timeout: 20000
# ── Assertion 1: WS is connected when chat screen is in foreground ──────
- runScript:
file: ../scripts/assert_ws_state.js
env:
SESSION_ID: ${output.ACCEPTED_SESSION_ID}
EXPECTED_CUSTOMER_CONNECTED: "true"
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# ── Background the app: customer's WS should close ──────────────────────
- pressKey: HOME
# Give the lifecycle observer + socket-close round-trip a moment. The
# disconnect() call posts a close frame; the backend reacts on
# `socket.on('close')`. ~1s is usually sufficient on the dev backend.
- waitForAnimationToEnd:
timeout: 3000
# ── Assertion 2: backend now reports customer_connected=false ───────────
- runScript:
file: ../scripts/assert_ws_state.js
env:
SESSION_ID: ${output.ACCEPTED_SESSION_ID}
EXPECTED_CUSTOMER_CONNECTED: "false"
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# ── Assertion 3: a message sent now is delivered via FCM, not WebSocket ─
- runScript:
file: ../scripts/assert_delivered_via.js
env:
SESSION_ID: ${output.ACCEPTED_SESSION_ID}
CONTENT: "halo, balasan dari mitra sementara kamu di background"
EXPECTED_DELIVERED_VIA: "fcm"
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}

View File

@@ -0,0 +1,144 @@
# ts-customer-05-02 — Symmetric to 05-01: customer sends a message and the
# mitra is in background → backend `sendMessage` falls back to FCM.
#
# Spec ref: requirement/flow_customer.mermaid.md §5 (Chat Room) +
# the user-stated path:
# 1. WebSocket when app is foreground / WS connected.
# 2. FCM when app is background / WS disconnected.
#
# Fix under test: mitra_app/lib/main.dart `_AppState.didChangeAppLifecycleState`
# closes the chat WS on paused/detached (and `mitra_chat_notifier.dart`
# exposes `connectedSessionId` + clears it on disconnect). Without this,
# Android keeps the TCP socket alive after the Dart isolate pauses, so
# the backend believes the mitra is online and never fires FCM — the
# mitra misses customer messages while the app is in background.
#
# Approach (Maestro can only drive one device per flow; here we drive the
# customer, and the mitra side is verified server-side):
# 1. Drive customer to active chat (same harness as ts-customer-04-04 /
# ts-customer-05-01). The acceptor is whichever mitra the
# seed_history_session.js helper picks (most-recently-online → on
# our setup that is the signed-in mitra on emulator-5556).
# 2. Assert backend says mitra_connected=false. This is true when:
# (a) the mitra app is force-stopped / not running, OR
# (b) the mitra app is on the chat screen AND backgrounded — the
# lifecycle observer should have closed the WS.
# Either condition fulfils the precondition for FCM fallback.
# 3. Send a message AS customer via /internal/_test/send-chat-message-as-customer
# and assert delivered_via="fcm".
#
# Pre-reqs (HARD):
# - Backend reachable; NODE_ENV != 'production'.
# - emulator-5556 mitra app: either force-stopped before the test, or
# signed in with TestMitra-1501 (the seed_history_session pick) and
# currently backgrounded. If the mitra is foreground in the chat
# screen, mitra_connected=true and this test asserts will fail —
# which is the correct failure mode (proves WS-over-FCM precedence).
# - The currently-signed-in mitra must have a `fcm_token` row in
# `mitras.fcm_token`; otherwise the FCM dispatch succeeds at the
# backend code path but never reaches a device.
appId: com.halobestie.client.client_app
env:
TEST_PHONE: "+6281234567890"
BACKEND_INTERNAL_URL: http://localhost:3001
---
# ── Setup: drive customer through onboarding → pair via blast ───────────
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
- runFlow: ../subflows/onboarding_returning_user.yaml
- runScript:
file: ../scripts/seed_history_session.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- swipe:
start: "50%, 30%"
end: "50%, 80%"
- extendedWaitUntil:
visible:
text: "(?s).*curhatan sebelumnya.*"
timeout: 10000
- tapOn:
text: "(?s).*curhat sama bestie baru.*"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "(?s).*mau curhat sama siapa.*"
timeout: 5000
- tapOn: "(?s).*cari bestie baru yang siap dengerin.*"
- extendedWaitUntil:
visible:
text: "(?s).*pilih cara curhat.*"
timeout: 10000
- tapOn:
text: "(?s).*tulis dan baca dengan tenang.*"
- extendedWaitUntil:
visible:
text: "(?s).*pilih durasi.*"
timeout: 10000
- tapOn: "(?s).*5 menit.*"
- tapOn: "(?s).*bayar Rp.*"
- extendedWaitUntil:
visible:
text: "(?s).*cara bayar.*"
timeout: 10000
- tapOn:
text: "(?s).*bayar Rp.*"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "scan QRIS untuk bayar"
timeout: 10000
- runScript:
file: ../scripts/mark_latest_payment_paid.js
env:
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- runFlow:
when:
visible:
text: "(?s).*biar nggak ketinggalan.*"
commands:
- tapOn:
text: "(?s).*nanti aja.*"
- extendedWaitUntil:
visible:
text: "(?s).*lagi nyari bestie.*"
timeout: 20000
- runScript:
file: ../scripts/mitra_accept_latest_internal.js
env:
MITRA_ID: ${output.MITRA_ID}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- extendedWaitUntil:
visible:
text: "(?s).*online.*"
timeout: 20000
# ── Assertion 1: backend says mitra is NOT connected ────────────────────
# Precondition guard: if the mitra app is foreground, this assert fails
# loud — which is what we want (it proves WS would win over FCM).
- runScript:
file: ../scripts/assert_ws_state.js
env:
SESSION_ID: ${output.ACCEPTED_SESSION_ID}
EXPECTED_CUSTOMER_CONNECTED: "true"
EXPECTED_MITRA_CONNECTED: "false"
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# ── Assertion 2: a customer-sent message is delivered via FCM ───────────
- runScript:
file: ../scripts/assert_delivered_via.js
env:
SESSION_ID: ${output.ACCEPTED_SESSION_ID}
CONTENT: "halo bestie, ini pesan saat kamu di background"
EXPECTED_DELIVERED_VIA: "fcm"
SENDER: "customer"
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}

View File

@@ -0,0 +1,40 @@
// Send a chat message AS the given side and assert the delivery transport
// matches EXPECTED_DELIVERED_VIA. Throws on mismatch.
//
// Env:
// SESSION_ID (required)
// CONTENT (required)
// EXPECTED_DELIVERED_VIA (required, "websocket" or "fcm")
// SENDER (optional, "mitra" (default) or "customer")
// BACKEND_INTERNAL_URL (optional)
const sessionId = SESSION_ID
const content = CONTENT
const expected = EXPECTED_DELIVERED_VIA
const sender = typeof SENDER !== 'undefined' && SENDER ? SENDER : 'mitra'
const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
if (!sessionId) throw new Error('SESSION_ID env not set')
if (!content) throw new Error('CONTENT env not set')
if (expected !== 'websocket' && expected !== 'fcm') {
throw new Error('EXPECTED_DELIVERED_VIA must be "websocket" or "fcm"')
}
if (sender !== 'mitra' && sender !== 'customer') {
throw new Error('SENDER must be "mitra" or "customer"')
}
const endpoint =
sender === 'mitra'
? '/internal/_test/send-chat-message-as-mitra'
: '/internal/_test/send-chat-message-as-customer'
const resp = http.post(`${url}${endpoint}`, {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId, content }),
})
if (resp.status !== 200) {
throw new Error(`send-chat-message-as-mitra failed (${resp.status}): ${resp.body}`)
}
const data = json(resp.body)
if (data.delivered_via !== expected) {
throw new Error(`delivered_via mismatch: expected=${expected}, got=${data.delivered_via}`)
}
output.MESSAGE_ID = data.message_id
output.DELIVERED_VIA = data.delivered_via

View File

@@ -0,0 +1,35 @@
// Assert backend's in-memory WS connection state for a session matches
// expectations. Throws (fails the maestro flow) on mismatch.
//
// Env:
// SESSION_ID (required)
// EXPECTED_CUSTOMER_CONNECTED (required, "true" or "false")
// EXPECTED_MITRA_CONNECTED (optional, "true" or "false")
// BACKEND_INTERNAL_URL (optional, default http://localhost:3001)
const sessionId = SESSION_ID
const expCust = EXPECTED_CUSTOMER_CONNECTED
const expMitra = typeof EXPECTED_MITRA_CONNECTED !== 'undefined' ? EXPECTED_MITRA_CONNECTED : null
const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
if (!sessionId) throw new Error('SESSION_ID env not set')
if (expCust !== 'true' && expCust !== 'false') {
throw new Error('EXPECTED_CUSTOMER_CONNECTED must be "true" or "false"')
}
const resp = http.get(`${url}/internal/_test/ws-connection-state?session_id=${sessionId}`)
if (resp.status !== 200) {
throw new Error(`ws-connection-state failed (${resp.status}): ${resp.body}`)
}
const data = json(resp.body)
const gotCust = String(data.customer_connected)
if (gotCust !== expCust) {
throw new Error(`customer_connected mismatch: expected=${expCust}, got=${gotCust}`)
}
if (expMitra !== null) {
const gotMitra = String(data.mitra_connected)
if (gotMitra !== expMitra) {
throw new Error(`mitra_connected mismatch: expected=${expMitra}, got=${gotMitra}`)
}
}
output.CUSTOMER_CONNECTED = gotCust
output.MITRA_CONNECTED = String(data.mitra_connected)

View File

@@ -0,0 +1,19 @@
// Reads the in-memory websocket connection state for a given session via
// the dev-only /internal/_test/ws-connection-state endpoint. Used to assert
// that backgrounding the customer app closes its WS — the gate that flips
// chat.service.sendMessage to the FCM fallback path.
//
// Env:
// SESSION_ID (required)
// BACKEND_INTERNAL_URL (optional, default http://localhost:3001)
const sessionId = SESSION_ID
const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
if (!sessionId) throw new Error('SESSION_ID env not set')
const resp = http.get(`${url}/internal/_test/ws-connection-state?session_id=${sessionId}`)
if (resp.status !== 200) {
throw new Error(`ws-connection-state failed (${resp.status}): ${resp.body}`)
}
const data = json(resp.body)
output.CUSTOMER_CONNECTED = String(data.customer_connected)
output.MITRA_CONNECTED = String(data.mitra_connected)

View File

@@ -0,0 +1,26 @@
// Send a chat message AS the mitra of the given session via the dev-only
// /internal/_test/send-chat-message-as-mitra endpoint. Goes through the
// real chat.service.sendMessage code path. Returns which transport
// carried the message — `websocket` if the customer's WS was alive at
// dispatch time, `fcm` otherwise.
//
// Env:
// SESSION_ID (required)
// CONTENT (required, message body)
// BACKEND_INTERNAL_URL (optional, default http://localhost:3001)
const sessionId = SESSION_ID
const content = CONTENT
const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
if (!sessionId) throw new Error('SESSION_ID env not set')
if (!content) throw new Error('CONTENT env not set')
const resp = http.post(`${url}/internal/_test/send-chat-message-as-mitra`, {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId, content }),
})
if (resp.status !== 200) {
throw new Error(`send-chat-message-as-mitra failed (${resp.status}): ${resp.body}`)
}
const data = json(resp.body)
output.MESSAGE_ID = data.message_id
output.DELIVERED_VIA = data.delivered_via

View File

@@ -0,0 +1,88 @@
# Shared §2 verified onboarding prelude — covers cold-start → SHome1st →
# "aku mau curhat" → S2 Nama → VerifChoiceSheet "verifikasi nomor HP" →
# S5b USP (if first-timer) → S3a Phone → S3b OTP → verifyOtp.
#
# After this subflow returns, the post-OTP redirect has fired:
# - intent=onboarding (set in routeForVerifChoice verified branch) →
# /payment/entry → /payment/discount-paywall (S6) for eligible users,
# OR /payment/method-pick for has_transacted=true users.
# Callers assert which destination is visible.
#
# Pre-reqs (parent flow's responsibility):
# - `env:` with TEST_PHONE and BACKEND_INTERNAL_URL.
# - reset_phone.js + launchApp clearState: true BEFORE this subflow.
# - ≥1 mitra online so the "aku mau curhat" CTA is enabled.
#
# Selector style: same regex-merged-node rules as
# onboarding_returning_user.yaml apply (see feedback-maestro-wsl-setup).
appId: ${APP_ID_ANDROID}
---
# Welcome carousel auto-advances 0→1→2 (1s each). Wait for Mulai on slide 3.
- extendedWaitUntil:
visible:
text: "Mulai"
timeout: 15000
- tapOn:
text: "Mulai"
retryTapIfNoChange: true
# SHome1st "aku mau curhat" CTA → /auth/display-name.
- extendedWaitUntil:
visible:
text: "(?s).*aku mau curhat.*"
timeout: 30000
- tapOn: "(?s).*aku mau curhat.*"
# S2 Nama — type name + Lanjut. No hideKeyboard (TextField.onSubmitted
# would auto-fire _submit and tear down the screen before Lanjut tap
# resolves). The "Nama panggilan" hint isn't visible in a11y; point-tap
# the field area (28% of viewport y on tested Pixel profile).
- extendedWaitUntil:
visible:
text: "(?s).*Siapa namamu.*"
timeout: 10000
- tapOn:
point: "50%, 28%"
- inputText: "MaestroNew"
- tapOn: "lanjut"
# VerifChoiceSheet — verified branch sets onboardingIntentProvider =
# onboarding (verif_choice_sheet.dart L87) so the router can route to
# /payment/entry post-OTP.
- extendedWaitUntil:
visible:
text: "(?s).*Mau curhat sebagai siapa.*"
timeout: 10000
- tapOn: "(?s).*verifikasi nomor HP.*"
# S5b USP — first-timer only (usp_seen=false after clearState). If
# parent flow pre-marked USP as seen, this step is skipped because the
# verified branch goes straight to /auth/register.
- runFlow:
when:
visible:
text: "Sebelum mulai"
commands:
- tapOn: "(?s).*aku ngerti.*"
# S3a phone input → kirim kode.
- extendedWaitUntil:
visible:
text: "(?s).*nomor wa-mu.*"
timeout: 10000
- tapOn:
point: "60%, 47%"
- inputText: "81234567890"
- tapOn: "(?s).*kirim kode.*"
# S3b OTP — peek stub, auto-submits on 6th digit.
- extendedWaitUntil:
visible:
text: "Masukkan OTP"
timeout: 15000
- runScript:
file: ../scripts/peek_otp.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- inputText: ${output.OTP}

View File

@@ -241,6 +241,12 @@ class Auth extends _$Auth {
}
Future<void> verifyOtp(String otpRequestId, String code) async {
// Preserve the prior auth data (typically AuthAnonymousData from the
// pre-OTP loginAnonymous) so AsyncError keeps `valueOrNull` non-null.
// The router uses valueOrNull to gate redirects — a wipe-to-null on
// OTP failure would bounce the OTP-blocked recovery path
// (/onboarding/anon/method → /payment/method-pick) to /home.
final previous = state;
state = const AsyncLoading();
try {
// Bearer is attached automatically by ApiClient from AuthBridge — when
@@ -259,12 +265,13 @@ class Auth extends _$Auth {
final profile = await _applyTokens(response);
state = AsyncData(await _stateForProfile(profile));
} on DioException catch (e) {
state = AsyncError(_otpVerifyErrorInfo(e), StackTrace.current);
state = AsyncError<AuthData>(_otpVerifyErrorInfo(e), StackTrace.current)
.copyWithPrevious(previous);
} catch (_) {
state = AsyncError(
state = AsyncError<AuthData>(
const AuthErrorInfo('Gagal verifikasi. Coba lagi.'),
StackTrace.current,
);
).copyWithPrevious(previous);
}
}

View File

@@ -0,0 +1,20 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// Tracks where the user came from when they entered an auth flow.
///
/// `onboarding` is set when the user taps a transaction CTA ("aku mau
/// curhat" / "curhat sama bestie baru") that drives them into the §2
/// New-User Onboarding journey. Post-OTP, the router consumes this and
/// pushes /payment/entry (which dispatches S6 paywall vs PickMethod via
/// `first_session_discount.eligible`).
///
/// `recover` (default) is the SHome1st "masuk →" login-recover banner
/// path — the spec doesn't route this through /payment/entry; the user
/// expects to land on /home with their chat history.
///
/// Spec ref: requirement/flow_customer.mermaid.md §2 (`UserLookup → S6
/// or PickMethod`).
enum OnboardingIntent { recover, onboarding }
final onboardingIntentProvider =
StateProvider<OnboardingIntent>((ref) => OnboardingIntent.recover);

View File

@@ -1,7 +1,9 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:permission_handler/permission_handler.dart' as ph;
import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -14,11 +16,29 @@ enum NotifPermStatus { notDetermined, granted, denied }
/// - iOS uses Firebase Messaging (which surfaces the system UNNotification
/// authorization status).
/// - Android 13+ uses `permission_handler` for `Permission.notification`
/// (POST_NOTIFICATIONS runtime). Older Android always reports granted.
/// (POST_NOTIFICATIONS runtime).
/// - Android <13: POST_NOTIFICATIONS doesn't exist as a runtime permission,
/// so `permission_handler` returns `granted` regardless of the user's
/// Settings → Apps → Notifications toggle. We add a
/// `NotificationManagerCompat.areNotificationsEnabled()` pre-check via
/// `flutter_local_notifications` to honour that user-facing toggle on
/// older Android.
class NotifPermission {
const NotifPermission();
Future<NotifPermStatus> readStatus() async {
// Older Android: permission_handler can't see the
// Settings → Apps → Notifications toggle, so we ask the local-notifs
// plugin (backed by NotificationManagerCompat.areNotificationsEnabled,
// which works from API 19). If disabled there, surface it as `denied`
// so the SHome1st banner reflects reality on API 24-32.
if (Platform.isAndroid) {
final androidImpl = FlutterLocalNotificationsPlugin()
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
final enabled = await androidImpl?.areNotificationsEnabled();
if (enabled == false) return NotifPermStatus.denied;
}
final phStatus = await ph.Permission.notification.status;
return _mapPh(phStatus);
}

View File

@@ -45,7 +45,8 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
void initState() {
super.initState();
_phoneController.addListener(() => setState(() {}));
_authSub = ref.listenManual<AsyncValue<AuthData>>(authProvider, (prev, next) {
_authSub =
ref.listenManual<AsyncValue<AuthData>>(authProvider, (prev, next) {
if (!mounted) return;
final data = next.valueOrNull;
if (data is AuthOtpSentData) {
@@ -106,7 +107,8 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
String _greetingName(AuthData? data) => switch (data) {
AuthAnonymousData d => d.displayName,
AuthAuthenticatedData d => (d.profile['display_name'] as String?) ?? '',
AuthNeedsDisplayNameData d => (d.profile['display_name'] as String?) ?? '',
AuthNeedsDisplayNameData d =>
(d.profile['display_name'] as String?) ?? '',
_ => '',
};
@@ -134,53 +136,63 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
child: HaloStepDots(total: 4, current: 3),
),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'nomor wa-mu, $shownName?',
style: const TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 28,
fontWeight: FontWeight.w700,
color: HaloTokens.brandDark,
height: 1.15,
letterSpacing: -0.56,
),
),
const SizedBox(height: 10),
const Text(
'supaya bisa lanjut kapan aja, dan dapat harga khusus pengguna baru.',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 14.5,
color: HaloTokens.inkSoft,
height: 1.5,
),
),
const SizedBox(height: 24),
_PhoneRow(
controller: _phoneController,
borderColor: hasMinDigits
? HaloTokens.brand
: HaloTokens.border,
),
const SizedBox(height: 16),
const _PrivacyCard(),
if (_errorMessage != null) ...[
const SizedBox(height: 12),
Text(
_errorMessage!,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
color: HaloTokens.danger,
fontSize: 13,
child: LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
child: ConstrainedBox(
constraints:
BoxConstraints(minHeight: constraints.maxHeight),
child: IntrinsicHeight(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'nomor wa-mu, $shownName?',
style: const TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 28,
fontWeight: FontWeight.w700,
color: HaloTokens.brandDark,
height: 1.15,
letterSpacing: -0.56,
),
),
const SizedBox(height: 10),
const Text(
'supaya bisa lanjut kapan aja, dan dapat harga khusus pengguna baru.',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 14.5,
color: HaloTokens.inkSoft,
height: 1.5,
),
),
const SizedBox(height: 24),
_PhoneRow(
controller: _phoneController,
borderColor: hasMinDigits
? HaloTokens.brand
: HaloTokens.border,
),
const SizedBox(height: 16),
const _PrivacyCard(),
if (_errorMessage != null) ...[
const SizedBox(height: 12),
Text(
_errorMessage!,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
color: HaloTokens.danger,
fontSize: 13,
),
),
],
],
),
),
],
],
),
),
),
),
HaloButton(
@@ -191,9 +203,8 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
: 'kirim kode',
fullWidth: true,
onPressed: canSubmit
? () => ref
.read(authProvider.notifier)
.requestOtp(_e164Phone())
? () =>
ref.read(authProvider.notifier).requestOtp(_e164Phone())
: null,
),
const SizedBox(height: 4),

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/auth/onboarding_intent_provider.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart';
import '../../onboarding/usp_seen_provider.dart';
@@ -81,6 +82,10 @@ Future<void> routeForVerifChoice(
if (!context.mounted) return;
switch (choice) {
case VerifChoice.verified:
// §2 transaction CTA path — router consumes this post-OTP and routes
// to /payment/entry (S6 paywall vs PickMethod via first_session_discount).
ref.read(onboardingIntentProvider.notifier).state =
OnboardingIntent.onboarding;
context.push(seen ? '/auth/register' : '/onboarding/verif/usp');
break;
case VerifChoice.anonymous:

View File

@@ -50,9 +50,22 @@ class _SearchingScreenState extends ConsumerState<SearchingScreen> {
// "Curhat lagi" flow stamped the targeted mitra onto the draft before
// payment, so we fire the targeted request and bounce to the dedicated
// wait overlay; everything else is a general blast.
final state = ref.read(pairingProvider);
//
// Carry-over guard: if a previous chat session ended, pairingProvider
// retains its terminal state (PairingActiveData with the *old*
// sessionId, PairingFailed, PairingCancelled, etc). Without resetting
// here, the `state is PairingInitialData` branch wouldn't fire and
// `_onPairingState` below would re-emit a stale PairingActiveData →
// /chat/session/<old_sessionId>, dropping the customer on a chat
// screen for a `completed` session ("Sesi sudah berakhir"). Reset to
// Initial whenever we have a fresh payment to consume.
final draft = ref.read(paymentDraftNotifierProvider);
var state = ref.read(pairingProvider);
if (state is! PairingInitialData && draft.paymentId != null) {
ref.read(pairingProvider.notifier).reset();
state = ref.read(pairingProvider);
}
if (state is PairingInitialData) {
final draft = ref.read(paymentDraftNotifierProvider);
if (draft.paymentId != null) {
if (draft.targetedMitraId != null) {
// ignore: discarded_futures

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/auth/auth_notifier.dart';
import '../../core/auth/onboarding_intent_provider.dart';
import '../../core/availability/mitra_availability_notifier.dart';
import '../../core/chat/active_session_notifier.dart';
import '../../core/notifications/notif_permission.dart';
@@ -198,11 +199,11 @@ class _SHome1stView extends ConsumerWidget {
}
}
class _LoginRecoverBanner extends StatelessWidget {
class _LoginRecoverBanner extends ConsumerWidget {
const _LoginRecoverBanner();
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Material(
@@ -210,7 +211,14 @@ class _LoginRecoverBanner extends StatelessWidget {
borderRadius: HaloRadius.md,
child: InkWell(
borderRadius: HaloRadius.md,
onTap: () => context.push('/auth/register'),
onTap: () {
// Recovery flow — post-OTP should land on /home (the user wants
// their history), NOT /payment/entry. Defensive reset in case a
// prior onboarding run left the intent dirty.
ref.read(onboardingIntentProvider.notifier).state =
OnboardingIntent.recover;
context.push('/auth/register');
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
@@ -786,11 +794,13 @@ class _NotifDeniedBanner extends ConsumerWidget {
),
),
),
TextButton(
style: TextButton.styleFrom(
OutlinedButton(
style: OutlinedButton.styleFrom(
foregroundColor: HaloTokens.brandDark,
side: const BorderSide(color: HaloTokens.brandDark, width: 1),
shape: const StadiumBorder(),
padding: const EdgeInsets.symmetric(
horizontal: HaloSpacing.s8,
horizontal: HaloSpacing.s12,
),
minimumSize: const Size(0, 32),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/auth/onboarding_intent_provider.dart';
import '../../../core/chat/chat_opening_provider.dart';
import '../../../core/theme/halo_tokens.dart';
import '../state/payment_draft_provider.dart';
@@ -31,6 +32,12 @@ class _PaymentEntryScreenState extends ConsumerState<PaymentEntryScreen> {
// reset() would wipe targetedMitraId and silently downgrade the
// returning-targeted flow to a blast.
ref.read(paymentDraftNotifierProvider.notifier).resetExceptTarget();
// Consume the onboarding intent — landing here means the router-level
// post-OTP redirect has fired (or the user navigated in via another
// CTA). Reset to default so a later masuk → recovery flow doesn't
// inherit a stale onboarding intent.
ref.read(onboardingIntentProvider.notifier).state =
OnboardingIntent.recover;
});
}

View File

@@ -40,13 +40,22 @@ class App extends ConsumerStatefulWidget {
ConsumerState<App> createState() => _AppState();
}
class _AppState extends ConsumerState<App> {
class _AppState extends ConsumerState<App> with WidgetsBindingObserver {
bool _fcmRegistered = false;
bool _authProvidersPreloaded = false;
// Tracks whether the OS has paused/detached this isolate. The
// activeSessionProvider runs a 15s poll (see active_session_notifier.dart);
// each tick fires the listener below, which would otherwise re-open the
// chat WebSocket immediately after didChangeAppLifecycleState closed it
// — defeating the WS→FCM fallback. We gate the reconnect on this flag so
// the WS stays closed while the app is backgrounded, even as polling
// continues.
bool _appPaused = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
// Phase 4: preload server-driven auth-provider gating once on cold start.
// Cached via @Riverpod(keepAlive: true) — subsequent reads are instant.
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -56,6 +65,45 @@ class _AppState extends ConsumerState<App> {
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// Background → close the chat WebSocket so backend `sendMessage` falls
// back to FCM (chat.service.js:51 — `if (!delivered) sendPushNotification`).
// Without this, Android keeps the TCP socket alive after the Dart
// isolate is paused, so the backend believes the customer is online and
// never fires the push — the user sees no alert until they reopen the
// app. See flow_customer.mermaid.md §5 (chat room) and the
// implementation note in main.dart's activeSession listener.
//
// Foreground → re-establish the WS for the current active session, if
// any. activeSessionProvider's last cached snapshot drives the target
// session id; the chat notifier's `connectIfNotConnected` is a no-op
// when the same session is already wired up.
final notifier = ref.read(chatProvider.notifier);
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.detached) {
_appPaused = true;
if (notifier.connectedSessionId != null) {
notifier.disconnect();
}
} else if (state == AppLifecycleState.resumed) {
_appPaused = false;
final snapshot = ref.read(activeSessionProvider).valueOrNull;
final sessionId = snapshot?.sessionId;
if (sessionId != null &&
(snapshot?.hasSession ?? false) &&
notifier.connectedSessionId != sessionId) {
notifier.connectIfNotConnected(sessionId);
}
}
}
void _registerFcmToken() {
if (_fcmRegistered) return;
_fcmRegistered = true;
@@ -88,6 +136,12 @@ class _AppState extends ConsumerState<App> {
// active session, regardless of which screen is mounted. The chat screen
// only joins this connection — it doesn't own it. FCM remains the
// background-only fallback.
//
// Gate on `_appPaused`: activeSessionProvider runs a 15s poll that fires
// this listener on every tick. If we reconnect while the app is
// backgrounded, we undo the disconnect that didChangeAppLifecycleState
// just performed and the FCM fallback never triggers for messages that
// arrive during background.
ref.listen(activeSessionProvider, (prev, next) {
final snapshot = next.valueOrNull;
final notifier = ref.read(chatProvider.notifier);
@@ -97,6 +151,7 @@ class _AppState extends ConsumerState<App> {
}
return;
}
if (_appPaused) return;
final sessionId = snapshot.sessionId;
if (sessionId != null && notifier.connectedSessionId != sessionId) {
notifier.connectIfNotConnected(sessionId);

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'core/auth/auth_notifier.dart';
import 'core/auth/onboarding_intent_provider.dart';
import 'features/auth/screens/display_name_screen.dart';
import 'features/auth/screens/register_screen.dart';
import 'features/auth/screens/otp_screen.dart';
@@ -96,23 +97,47 @@ GoRouter buildRouter(Ref ref) {
if (data == null) {
// Error state — drop onto Home; SHome1st variant handles the
// unauthenticated render (login banner overlay).
// EXCEPTION: /onboarding/* routes are the post-OTP-blocked popup
// fallback path (`/onboarding/anon/method` alias → method-pick).
// They must transit freely even when authProvider is in AsyncError
// (which is how OTP_ATTEMPTS_EXCEEDED leaves the state), otherwise
// the redirect to /home wins over the route-level alias.
if (isOnboardingFlow) return null;
if (!isAuthRoute && !isSplash) return '/home';
if (isSplash) return '/home';
return null;
}
if (data is AuthAuthenticatedData || data is AuthAnonymousData) {
if (data is AuthAuthenticatedData ||
data is AuthAnonymousData ||
data is AuthOtpSentData) {
// Allow the Phase 4 onboarding flow (ESP/USP) to stay put even when
// the user is already anonymous-authenticated — display_name_screen
// intentionally pushes into /onboarding/* after loginAnonymous.
if (isOnboardingFlow) return null;
// While AuthAnonymousData, the user may legitimately be mid-flow on
// /home → /auth/display-name (push) → about to open the Verif Choice
// Sheet. When refreshListenable fires after loginAnonymous resolves,
// GoRouter re-evaluates the bottom of the navigation stack — without
// this carve-out an /auth/* push would be torn down before the sheet
// can open. Allow any auth route to stay put under AuthAnonymousData.
if (data is AuthAnonymousData && isAuthRoute) return null;
// While AuthAnonymousData OR AuthOtpSentData, the user may
// legitimately be mid-flow on /home → /auth/display-name (push) →
// VerifChoice → /auth/register → /auth/otp. When refreshListenable
// fires after loginAnonymous resolves OR after requestOtp returns
// AuthOtpSentData, GoRouter re-evaluates the bottom of the
// navigation stack — without this carve-out an /auth/* push would
// be torn down before the next screen can open.
if ((data is AuthAnonymousData || data is AuthOtpSentData) &&
isAuthRoute) {
return null;
}
// §2 spec north star: when the user reached an auth route from a
// transaction CTA ("aku mau curhat" / "curhat sama bestie baru"),
// post-OTP must land at /payment/entry — which dispatches to S6
// paywall vs PickMethod via `first_session_discount.eligible`. The
// login-recover banner path keeps the default `recover` intent and
// lands on /home (preserves user expectation of seeing history).
if (data is AuthAuthenticatedData && isAuthRoute) {
final intent = ref.read(onboardingIntentProvider);
if (intent == OnboardingIntent.onboarding) {
return '/payment/entry';
}
}
return (isSplash || isAuthRoute) ? '/home' : null;
}
if (data is AuthNeedsDisplayNameData) return '/auth/set-name';

View File

@@ -143,6 +143,13 @@ class MitraChat extends _$MitraChat {
WebSocketChannel? _channel;
StreamSubscription? _wsSubscription;
Timer? _typingTimer;
// Survives `disconnect()` so a later `didChangeAppLifecycleState(resumed)`
// can re-issue `connect(sessionId)` with the right session — disconnect()
// resets `state` to MitraChatInitialData, which is otherwise the only
// record of which chat we were attached to.
String? _connectedSessionId;
String? get connectedSessionId => _connectedSessionId;
ApiClient get _apiClient => ref.read(apiClientProvider);
@@ -150,6 +157,7 @@ class MitraChat extends _$MitraChat {
MitraChatData build() => const MitraChatInitialData();
Future<void> connect(String sessionId) async {
_connectedSessionId = sessionId;
state = const MitraChatConnectingData();
try {
final sessionInfo = await _apiClient.get('/api/shared/chat/$sessionId/info');
@@ -222,6 +230,7 @@ class MitraChat extends _$MitraChat {
void disconnect() {
_cleanup();
_connectedSessionId = null;
// State reset is deferred to post-frame: disconnect() is called from
// mitra_chat_screen's deactivate() during back-nav, and a synchronous
// `state =` here notifies watchers while the chat screen's widget tree is

View File

@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/api/api_client_provider.dart';
import 'core/auth/auth_notifier.dart';
import 'core/chat/mitra_chat_notifier.dart';
import 'core/status/status_notifier.dart';
import 'core/chat/chat_request_notifier.dart';
import 'core/chat/widgets/chat_request_overlay.dart';
@@ -30,6 +31,13 @@ class App extends ConsumerStatefulWidget {
class _AppState extends ConsumerState<App> with WidgetsBindingObserver {
bool _fcmRegistered = false;
// Session the chat WS was on at the moment we backgrounded. Restored on
// resume so a backgrounded mitra reconnects to the same chat once they
// foreground the app. Mirrors the customer-app fix (main.dart on the
// client side) — backend's sendMessage checks recipient WS readyState
// before falling back to FCM, so leaving the WS open while paused makes
// FCM never fire and the mitra misses customer messages in background.
String? _pausedChatSessionId;
@override
void initState() {
@@ -47,8 +55,24 @@ class _AppState extends ConsumerState<App> with WidgetsBindingObserver {
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused || state == AppLifecycleState.detached) {
ref.read(onlineStatusProvider.notifier).onAppPaused();
// Close the chat WS so backend `sendMessage` falls back to FCM when
// the customer sends a message. Stash the active session_id so we
// can rejoin it on resume.
final chatNotifier = ref.read(mitraChatProvider.notifier);
final sid = chatNotifier.connectedSessionId;
if (sid != null) {
_pausedChatSessionId = sid;
chatNotifier.disconnect();
}
} else if (state == AppLifecycleState.resumed) {
ref.read(onlineStatusProvider.notifier).onAppResumed();
// Reconnect to the chat we backgrounded out of, if any.
final saved = _pausedChatSessionId;
_pausedChatSessionId = null;
if (saved != null) {
// ignore: discarded_futures
ref.read(mitraChatProvider.notifier).connect(saved);
}
}
}

View File

@@ -130,6 +130,33 @@ flowchart TD
> first-time pricing off forever. Backend phone-lookup behaviour already
> exists (see Phase 1 auto-link via phone); the app-side reconciliation +
> `has_transacted` plumbing is the new work.
>
> **Implementation (2026-05-18):** post-OTP routing is driven by an
> `onboardingIntentProvider` (`client_app/lib/core/auth/onboarding_intent_provider.dart`)
> that's set to `OnboardingIntent.onboarding` by `routeForVerifChoice`
> (verified branch in `verif_choice_sheet.dart`) and consumed by the
> router redirect for `AuthAuthenticatedData` on any auth route. When the
> intent is `onboarding`, the redirect returns `/payment/entry`; otherwise
> (default `recover`, set by the masuk → handler) it returns `/home`.
> `/payment/entry` then dispatches S6 vs PickMethod via the backend's
> `first_session_discount.eligible` flag — which is computed as
> "phone-verified AND no prior completed chat_sessions" in
> `pricing.service.js::isCustomerEligibleForFirstSessionDiscount`. That
> single check covers both "brand-new" and "existing-but-never-paid"
> (UserLookup=no and UserLookup=yes+has_transacted=false in the mermaid
> above).
>
> **Login-vs-transaction divergence:** the SHome1st "masuk →" login-recover
> banner pushes `/auth/register` with intent left at `recover`, so its
> post-OTP path lands on `/home` (the user expects to see their chat
> history, not be thrown into payment). This is a deliberate departure
> from a strict reading of the mermaid arrow, motivated by the user
> directive that login-intent and transaction-intent entries should not
> share the same landing zone. Maestro coverage:
> [client_app/.maestro/flows/ts-customer-02-10-recover_via_masuk_existing_user_to_home.yaml](../client_app/.maestro/flows/ts-customer-02-10-recover_via_masuk_existing_user_to_home.yaml).
> Tests for the §2 transaction-CTA branches live under
> `ts-customer-02-01..05`; see
> [client_app/.maestro/flows/README_section_02.md](../client_app/.maestro/flows/README_section_02.md).
### 2.1 Anonymous → existing-user merge (post-transaction OTP) 🔴