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>
This commit is contained in:
2026-05-14 19:12:34 +08:00
parent a48f108fc0
commit a09f37135c
56 changed files with 3417 additions and 1093 deletions

View File

@@ -11,6 +11,7 @@ import { mitraChatRoutes } from './routes/public/mitra.chat.routes.js'
import { clientChatRoutes } from './routes/public/client.chat.routes.js'
import { clientPaymentRoutes } from './routes/public/client.payment.routes.js'
import { clientMitraAvailabilityRoutes } from './routes/public/client.mitra-availability.routes.js'
import { publicBestieAvailabilityRoutes } from './routes/public/public.bestie-availability.routes.js'
import { clientOnboardingRoutes } from './routes/public/client.onboarding.routes.js'
import { clientSupportRoutes } from './routes/public/client.support.routes.js'
import { sharedChatRoutes } from './routes/public/shared.chat.routes.js'
@@ -36,6 +37,7 @@ export const buildPublicApp = async () => {
app.register(clientChatRoutes, { prefix: '/api/client/chat' })
app.register(clientPaymentRoutes, { prefix: '/api/client/payment-sessions' })
app.register(clientMitraAvailabilityRoutes, { prefix: '/api/client/mitra-availability' })
app.register(publicBestieAvailabilityRoutes, { prefix: '/api/public/bestie' })
// Phase 4: onboarding-state + support handles. Both are tiny so they live in their
// own files rather than bloating client.auth.routes / shared.config.routes.
app.register(clientOnboardingRoutes, { prefix: '/api/client' })

View File

@@ -3,15 +3,17 @@ import { countAvailableMitrasFromCache } from '../../services/mitra-status.servi
import { UserType } from '../../constants.js'
/**
* Customer-home availability poll.
* Customer-authed availability poll (kept for CC/debug callers that want the
* raw count).
*
* GET /api/client/mitra-availability → 200 { available: bool, count?: number }
* GET /api/client/mitra-availability → 200 { available: bool, count: number }
*
* Hot endpoint by design — polled every 5s per active customer while their home is
* foregrounded. Backed by a 10s in-memory cache (see mitra-status.service.js) so DB load
* The customer home polls `/api/public/bestie/available` instead — that route
* is unauthenticated and returns only the boolean, since SHome1st renders
* before the user has any JWT (see `requirement/flow_customer.mermaid.md` §1).
*
* Backed by a 10s in-memory cache (see mitra-status.service.js) so DB load
* stays bounded regardless of poller count. No rate limit by intent.
*
* `count` is included for CC/debug; the customer UI must read only `available`.
*/
export const clientMitraAvailabilityRoutes = async (app) => {
app.get('/', { preHandler: [authenticate] }, async (request, reply) => {

View File

@@ -0,0 +1,25 @@
import { countAvailableMitrasFromCache } from '../../services/mitra-status.service.js'
/**
* Public bestie-availability beacon.
*
* GET /api/public/bestie/available → 200 { available: bool }
*
* Unauthenticated by design: the SHome1st CTA must reflect global availability
* BEFORE the user has any JWT (see `requirement/flow_customer.mermaid.md` §1 +
* router.dart's "fresh / unauthenticated users land on Home directly" carve-out).
*
* Output is intentionally a single boolean — no `count`, no IDs, no metadata —
* so this endpoint leaks no operational signal beyond "at least one bestie is
* online right now". Backed by the same 10s in-memory cache that bounds DB
* load regardless of poller count.
*
* The auth'd `/api/client/mitra-availability` route is kept for CC/debug
* callers that need the raw count.
*/
export const publicBestieAvailabilityRoutes = async (app) => {
app.get('/available', async (_request, reply) => {
const { available } = await countAvailableMitrasFromCache()
return reply.send({ success: true, data: { available } })
})
}

View File

@@ -182,7 +182,7 @@ const finalizeExtension = async (extensionId, sessionId, accepted, viaTimeout) =
clearClosureGraceTimer(sessionId)
// Extend the session
await extendSessionTimer(extension.session_id, extension.requested_duration_minutes)
const extended = await extendSessionTimer(extension.session_id, extension.requested_duration_minutes)
// Resume session
await sql`UPDATE chat_sessions SET status = ${SessionStatus.ACTIVE} WHERE id = ${extension.session_id}`
@@ -194,11 +194,15 @@ const finalizeExtension = async (extensionId, sessionId, accepted, viaTimeout) =
FROM chat_sessions WHERE id = ${extension.session_id}
`
// Notify both parties
// Notify both parties. Include the freshly-extended `expires_at` so the
// customer's local seconds-left ticker can resume immediately — without it,
// the client has to wait until the next 60s SESSION_TIMER ping to pick up
// the new deadline, leaving the floating expired banner stuck on-screen.
sendToSessionParticipant(sessionId, UserType.CUSTOMER, {
type: WsMessage.EXTENSION_RESPONSE,
accepted: true,
duration_minutes: extension.requested_duration_minutes,
expires_at: extended?.expires_at ?? null,
via_timeout: viaTimeout,
})
sendToSessionParticipant(sessionId, UserType.CUSTOMER, {

View File

@@ -22,6 +22,11 @@ const OTP_TTL_MINUTES = 5
// -------------------------------------------------------------------
const generate6DigitCode = () => {
// Dev escape hatch: when OTP_STATIC_CODE is set (6 digits), every stub OTP
// returns this exact value. Lets manual testers skip the peek round-trip.
// Leave unset in production — real Fazpass owns the code there.
const staticCode = process.env.OTP_STATIC_CODE
if (staticCode && /^\d{6}$/.test(staticCode)) return staticCode
// Avoid Math.random for OTP generation — use crypto.randomInt
return String(crypto.randomInt(0, 1_000_000)).padStart(6, '0')
}

View File

@@ -527,16 +527,26 @@ export const declinePairingRequest = async (sessionId, mitraId) => {
pairingTimeouts.delete(sessionId)
}
// Intermediate failure: payment stays confirmed so the customer can re-blast
// from the S7 timeout CTA. Audit row is still written.
if (session.payment_session_id) {
await failPaymentSession(session.payment_session_id, PairingFailureCause.ALL_MITRAS_REJECTED)
const paySession = await getPaymentSession(session.payment_session_id)
if (paySession) {
await recordIntermediateFailure({
paymentSessionId: session.payment_session_id,
customerId: session.customer_id,
causeTag: PairingFailureCause.ALL_MITRAS_REJECTED,
amount: paySession.amount,
})
}
}
// Terminal: customer is in a searching state and the search just ended with no chat.
await notifyCustomer(session.customer_id, {
type: WsMessage.PAIRING_FAILED,
session_id: sessionId,
payment_session_id: session.payment_session_id,
cause_tag: PairingFailureCause.ALL_MITRAS_REJECTED,
is_terminal: false,
})
}
}
@@ -686,19 +696,27 @@ export const expirePairingRequest = async (sessionId, causeTag = PairingFailureC
WHERE session_id = ${sessionId} AND response IS NULL
`
// Fail the payment session (if any) — terminal.
// Intermediate failure: payment session stays `confirmed` so the customer can
// re-blast on the same payment from the S7 timeout CTA. Audit row is still
// written so the failed-pairing CC view captures every attempt.
if (session.payment_session_id) {
await failPaymentSession(session.payment_session_id, causeTag)
const paySession = await getPaymentSession(session.payment_session_id)
if (paySession) {
await recordIntermediateFailure({
paymentSessionId: session.payment_session_id,
customerId: session.customer_id,
causeTag,
amount: paySession.amount,
})
}
}
// Notify customer via WebSocket (FCM fallback). Terminal pairing failure → PAIRING_FAILED
// so the client can route to the failed-pairing screen consistently with the other
// terminal paths (cancel / all-rejected / payment-expired-mid-search).
await notifyCustomer(session.customer_id, {
type: WsMessage.PAIRING_FAILED,
session_id: sessionId,
payment_session_id: session.payment_session_id,
cause_tag: causeTag,
is_terminal: false,
})
// Notify mitras to dismiss (request expired) — independent fan-out, run in parallel.

View File

@@ -273,6 +273,7 @@ export const expireStalePaymentSessions = async () => {
type: WsMessage.PAIRING_FAILED,
payment_session_id: row.id,
cause_tag: PairingFailureCause.PAYMENT_SESSION_EXPIRED,
is_terminal: true,
})
if (!wsSent) {
await sendPushNotification(UserType.CUSTOMER, row.customer_id, {

View File

@@ -0,0 +1,135 @@
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest'
// Keep external sockets / FCM no-op so buildPublic doesn't try to open them.
vi.mock('../../src/plugins/websocket.js', () => ({
sendToUser: vi.fn(() => false),
sendToSessionParticipant: vi.fn(() => false),
registerWebSocketPlugin: vi.fn(async () => {}),
registerWebSocketRoute: vi.fn(),
isUserOnlineWs: vi.fn(() => false),
getSessionConnections: vi.fn(() => ({})),
}))
vi.mock('../../src/services/notification.service.js', () => ({
sendPushNotification: vi.fn(async () => true),
registerDeviceToken: vi.fn(async () => {}),
}))
const { buildPublic } = await import('../helpers/server.js')
const { createCustomer } = await import('../helpers/fixtures.js')
const { resetDbHard, db } = await import('../helpers/db.js')
const { customerJwt, authHeader } = await import('../helpers/jwt.js')
const {
getCustomerById,
markCustomerUspSeen,
} = await import('../../src/services/customer.service.js')
describe('Phase 4 — USP one-time gate', () => {
let app
beforeAll(async () => {
app = await buildPublic()
})
afterAll(async () => {
await app.close()
})
beforeEach(async () => {
await resetDbHard()
})
describe('migration default', () => {
it('new customer row has usp_seen = false', async () => {
const c = await createCustomer({ callName: 'New User' })
const row = await getCustomerById(c.id)
expect(row).toBeTruthy()
expect(row.usp_seen).toBe(false)
})
})
describe('markCustomerUspSeen() service', () => {
it('flips false → true and returns the updated row', async () => {
const c = await createCustomer({ callName: 'Marker' })
const updated = await markCustomerUspSeen(c.id)
expect(updated.usp_seen).toBe(true)
const reread = await getCustomerById(c.id)
expect(reread.usp_seen).toBe(true)
})
it('is idempotent — second call still returns usp_seen=true, no error', async () => {
const c = await createCustomer({ callName: 'Idem' })
await markCustomerUspSeen(c.id)
const second = await markCustomerUspSeen(c.id)
expect(second.usp_seen).toBe(true)
})
})
describe('POST /api/client/auth/usp-seen', () => {
it('returns 401 when no Authorization header is present', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/client/auth/usp-seen',
})
expect(res.statusCode).toBe(401)
})
it('returns 200 + flips flag for an authed customer', async () => {
const c = await createCustomer({ callName: 'Authed' })
const res = await app.inject({
method: 'POST',
url: '/api/client/auth/usp-seen',
headers: authHeader(customerJwt(c.id)),
})
expect(res.statusCode).toBe(200)
const body = res.json()
expect(body.success).toBe(true)
expect(body.data.id).toBe(c.id)
expect(body.data.usp_seen).toBe(true)
// DB persisted
const reread = await getCustomerById(c.id)
expect(reread.usp_seen).toBe(true)
})
it('rejects a non-customer JWT (mitra) with 403', async () => {
// Mint a JWT that says CUSTOMER but the route still asserts type — the
// route reads user_type from the JWT claim, so use mitraJwt for negative.
const { mitraJwt } = await import('../helpers/jwt.js')
const fakeId = '00000000-0000-0000-0000-000000000001'
const res = await app.inject({
method: 'POST',
url: '/api/client/auth/usp-seen',
headers: authHeader(mitraJwt(fakeId)),
})
expect(res.statusCode).toBe(403)
})
})
describe('GET /api/client/auth/me payload', () => {
it('includes usp_seen in the response (false for fresh customer)', async () => {
const c = await createCustomer({ callName: 'Reader' })
const res = await app.inject({
method: 'GET',
url: '/api/client/auth/me',
headers: authHeader(customerJwt(c.id)),
})
expect(res.statusCode).toBe(200)
const body = res.json()
expect(body.data).toHaveProperty('usp_seen')
expect(body.data.usp_seen).toBe(false)
})
it('reflects usp_seen=true after the flag has been set', async () => {
const c = await createCustomer({ callName: 'Reader2' })
await markCustomerUspSeen(c.id)
const res = await app.inject({
method: 'GET',
url: '/api/client/auth/me',
headers: authHeader(customerJwt(c.id)),
})
expect(res.statusCode).toBe(200)
expect(res.json().data.usp_seen).toBe(true)
})
})
})

View File

@@ -0,0 +1,139 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'
// Mock the WS plugin (we assert on what extension.service tried to broadcast)
// and the FCM notification service so tests don't try to reach external APIs.
vi.mock('../../src/plugins/websocket.js', () => ({
sendToUser: vi.fn(() => false),
sendToSessionParticipant: vi.fn(() => false),
registerWebSocketPlugin: vi.fn(),
registerWebSocketRoute: vi.fn(),
isUserOnlineWs: vi.fn(() => true),
getSessionConnections: vi.fn(() => ({})),
}))
vi.mock('../../src/services/notification.service.js', () => ({
sendPushNotification: vi.fn(async () => true),
registerDeviceToken: vi.fn(async () => {}),
}))
const { sendToSessionParticipant } = await import('../../src/plugins/websocket.js')
const { respondToExtension } = await import('../../src/services/extension.service.js')
const { createPaymentSession, confirmPaymentSession } = await import('../../src/services/payment.service.js')
const {
WsMessage,
SessionStatus,
ExtensionStatus,
} = await import('../../src/constants.js')
const { db, resetDb, resetAppConfig } = await import('../helpers/db.js')
const { createCustomer, createMitra } = await import('../helpers/fixtures.js')
describe('extension.service — EXTENSION_RESPONSE payload', () => {
let customer
let mitra
beforeAll(async () => {
await resetAppConfig()
})
beforeEach(async () => {
await resetDb()
customer = await createCustomer({ callName: 'ExtCust' })
mitra = await createMitra({ callName: 'ExtMitra', isOnline: true })
sendToSessionParticipant.mockClear()
})
afterEach(() => {
vi.clearAllMocks()
})
it('accepted extension broadcasts EXTENSION_RESPONSE with the new expires_at', async () => {
const sql = db()
// Seed an active chat_sessions row whose timer is about to run out so the
// extension push has a meaningful baseline to advance.
const baseExpiresAt = new Date(Date.now() + 30_000) // 30s left
const [session] = await sql`
INSERT INTO chat_sessions (customer_id, mitra_id, status, expires_at, duration_minutes)
VALUES (${customer.id}, ${mitra.id}, ${SessionStatus.ACTIVE}, ${baseExpiresAt}, 12)
RETURNING id
`
// A confirmed extension payment session (is_extension=true).
const extPay = await createPaymentSession({
customerId: customer.id,
durationMinutes: 10,
amount: 9000,
isExtension: true,
})
await confirmPaymentSession(extPay.id, customer.id)
// Pending extension row tied to that payment.
const [extension] = await sql`
INSERT INTO session_extensions (
session_id, requested_duration_minutes, requested_price, status, payment_session_id
)
VALUES (${session.id}, 10, 9000, ${ExtensionStatus.PENDING}, ${extPay.id})
RETURNING id
`
// Act
await respondToExtension(extension.id, session.id, mitra.id, true)
// Find the EXTENSION_RESPONSE call to the customer
const respCalls = sendToSessionParticipant.mock.calls.filter(
([, , payload]) => payload?.type === WsMessage.EXTENSION_RESPONSE,
)
expect(respCalls).toHaveLength(1)
const payload = respCalls[0][2]
expect(payload.accepted).toBe(true)
expect(payload.duration_minutes).toBe(10)
expect(payload.expires_at).toBeTruthy()
// The new expires_at must be ahead of the seeded baseExpiresAt by ~10 min.
const newExp = new Date(payload.expires_at).getTime()
const baseMs = baseExpiresAt.getTime()
const deltaMin = (newExp - baseMs) / 60_000
expect(deltaMin).toBeGreaterThan(9.5)
expect(deltaMin).toBeLessThan(10.5)
// DB should reflect the same shift.
const [refreshed] = await sql`SELECT expires_at FROM chat_sessions WHERE id = ${session.id}`
expect(new Date(refreshed.expires_at).getTime()).toBe(newExp)
})
it('rejected extension broadcasts EXTENSION_RESPONSE without expires_at', async () => {
const sql = db()
const baseExpiresAt = new Date(Date.now() + 30_000)
const [session] = await sql`
INSERT INTO chat_sessions (customer_id, mitra_id, status, expires_at, duration_minutes)
VALUES (${customer.id}, ${mitra.id}, ${SessionStatus.ACTIVE}, ${baseExpiresAt}, 12)
RETURNING id
`
const extPay = await createPaymentSession({
customerId: customer.id,
durationMinutes: 10,
amount: 9000,
isExtension: true,
})
await confirmPaymentSession(extPay.id, customer.id)
const [extension] = await sql`
INSERT INTO session_extensions (
session_id, requested_duration_minutes, requested_price, status, payment_session_id
)
VALUES (${session.id}, 10, 9000, ${ExtensionStatus.PENDING}, ${extPay.id})
RETURNING id
`
await respondToExtension(extension.id, session.id, mitra.id, false)
const respCalls = sendToSessionParticipant.mock.calls.filter(
([, , payload]) => payload?.type === WsMessage.EXTENSION_RESPONSE,
)
expect(respCalls).toHaveLength(1)
const payload = respCalls[0][2]
expect(payload.accepted).toBe(false)
// Rejected path does not extend the timer, so no expires_at is sent.
expect(payload.expires_at).toBeUndefined()
})
})

View File

@@ -61,7 +61,7 @@ describe('pairing.service', () => {
vi.clearAllMocks()
})
it('single-recipient general blast → mitra declines → terminates with ALL_MITRAS_REJECTED', async () => {
it('single-recipient general blast → mitra declines → retryable ALL_MITRAS_REJECTED, payment stays confirmed', async () => {
// Arrange: confirmed, non-targeted payment session.
const pay = await createPaymentSession({
customerId: customer.id,
@@ -80,7 +80,7 @@ describe('pairing.service', () => {
// classified as a general-blast all-rejected, NOT a targeted reject.
await declinePairingRequest(session.id, mitra.id)
// Assert: pairing_failures row carries ALL_MITRAS_REJECTED, not TARGETED_*.
// Assert: pairing_failures audit row carries ALL_MITRAS_REJECTED.
const sql = db()
const failures = await sql`
SELECT cause_tag FROM pairing_failures WHERE payment_session_id = ${pay.id}
@@ -88,16 +88,19 @@ describe('pairing.service', () => {
expect(failures).toHaveLength(1)
expect(failures[0].cause_tag).toBe(PairingFailureCause.ALL_MITRAS_REJECTED)
// Payment session is terminal (failed_pairing) — terminal failures consume the payment.
// Payment session stays CONFIRMED — the customer can re-blast on the same
// payment via the S7 Timeout "coba cari lagi" CTA.
const [paySession] = await sql`SELECT status FROM payment_sessions WHERE id = ${pay.id}`
expect(paySession.status).toBe(PaymentSessionStatus.FAILED_PAIRING)
expect(paySession.status).toBe(PaymentSessionStatus.CONFIRMED)
// Customer was notified with PAIRING_FAILED carrying the same cause tag.
// Customer was notified with PAIRING_FAILED carrying is_terminal=false so
// the client renders the retryable variant of the S7 timeout screen.
const pairingFailedCalls = sendToUser.mock.calls.filter(
([, , data]) => data?.type === WsMessage.PAIRING_FAILED,
)
expect(pairingFailedCalls).toHaveLength(1)
expect(pairingFailedCalls[0][2].cause_tag).toBe(PairingFailureCause.ALL_MITRAS_REJECTED)
expect(pairingFailedCalls[0][2].is_terminal).toBe(false)
})
it('cancelPairingRequest does NOT push PAIRING_FAILED to the customer', async () => {

View File

@@ -1,6 +1,6 @@
# Phase 4 Stage 2 — verified onboarding path:
# Splash → onboarding carousel → Welcome → Display Name → Verif Choice Sheet
# (verifikasi nomor HP)ESP (pick a chip) → USP → Register → OTP (6-digit)
# Phase 4 — verified onboarding path (post-ESP retirement, 2026-05-12):
# Splash → Display Name → Verif Choice Sheet (verifikasi nomor HP) →
# USP one-time gate (first-time userUSP screen) → Register → OTP (6-digit)
# → S6 paywall (when first-session-discount eligible) or duration picker.
#
# Run:
@@ -26,6 +26,7 @@ env:
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
# Onboarding carousel (still present per Phase 4 Stage 9 minimum-touch).
- extendedWaitUntil:
visible:
text: "Mulai"
@@ -33,12 +34,14 @@ env:
- tapOn:
text: "Mulai"
retryTapIfNoChange: true
# Phase 4 Stage 9: /welcome is retired. SHome1st CTA "aku mau curhat"
# leads into the onboarding flow.
- extendedWaitUntil:
visible:
text: "Lanjut sebagai Tamu"
text: "aku mau curhat"
timeout: 10000
- tapOn:
text: "Lanjut sebagai Tamu"
text: "aku mau curhat"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
@@ -59,21 +62,14 @@ env:
- tapOn:
text: "verifikasi nomor HP"
retryTapIfNoChange: true
# ESP screen — pick at least one chip then tap "lanjut"
- extendedWaitUntil:
visible:
text: "Lagi mikirin apa?"
timeout: 10000
- tapOn:
text: "Hubungan"
- tapOn:
text: "lanjut"
retryTapIfNoChange: true
# USP screen
# USP one-time gate — first run after clearState, so usp_seen=false → USP shown.
# Explicitly assert ESP "Lagi mikirin apa?" is NOT visible to catch regression.
- extendedWaitUntil:
visible:
text: "Sebelum mulai"
timeout: 10000
- assertNotVisible:
text: "Lagi mikirin apa?"
- tapOn:
text: "aku ngerti, lanjut"
retryTapIfNoChange: true

View File

@@ -1,7 +1,6 @@
# Phase 4 Stage 2 — anonymous onboarding path:
# Splash → onboarding carousel → Welcome → Display Name → Verif Choice Sheet
# (curhat anonim) → ESP → USP → arrival at /payment/method-pick (Stage 3
# owns the screen body; this flow stops at route arrival).
# Phase 4 — anonymous onboarding path (post-ESP retirement, 2026-05-12):
# Splash → Display Name → Verif Choice Sheet (curhat anonim) → USP one-time
# gate (first-time user → USP screen) → arrival at /payment/method-pick.
#
# Run:
# maestro test client_app/.maestro/flows/03_onboarding_anon.yaml
@@ -14,6 +13,7 @@ appId: com.halobestie.client.client_app
---
- launchApp:
clearState: true
# Onboarding carousel.
- extendedWaitUntil:
visible:
text: "Mulai"
@@ -21,12 +21,15 @@ appId: com.halobestie.client.client_app
- tapOn:
text: "Mulai"
retryTapIfNoChange: true
# Phase 4 Stage 9: SHome1st "aku mau curhat" CTA replaces the old
# /welcome "Lanjut sebagai Tamu" step. Bumped timeout: SHome1st has to
# wait on mitra-availability before the CTA renders.
- extendedWaitUntil:
visible:
text: "Lanjut sebagai Tamu"
timeout: 10000
text: "aku mau curhat"
timeout: 20000
- tapOn:
text: "Lanjut sebagai Tamu"
text: "aku mau curhat"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
@@ -47,19 +50,14 @@ appId: com.halobestie.client.client_app
- tapOn:
text: "curhat anonim"
retryTapIfNoChange: true
# ESP screen — leave empty + tap lewati to exercise the skip path
- extendedWaitUntil:
visible:
text: "Lagi mikirin apa?"
timeout: 10000
- tapOn:
text: "lewati"
retryTapIfNoChange: true
# USP screen
# USP one-time gate — first run after clearState, so usp_seen=false → USP shown.
# Assert ESP "Lagi mikirin apa?" is NOT visible to catch regression.
- extendedWaitUntil:
visible:
text: "Sebelum mulai"
timeout: 10000
- assertNotVisible:
text: "Lagi mikirin apa?"
- tapOn:
text: "aku ngerti, lanjut"
retryTapIfNoChange: true

View File

@@ -26,3 +26,27 @@ Flutter mobile application for end users (clients) seeking mental health support
- API calls go through `ApiClient`; it auto-attaches the JWT from `AuthBridge` and auto-refreshes on 401
- WebSocket handshake (`/api/shared/ws`) reads the access token from `AuthBridge` in the first frame's `{type:"auth", token, session_id?}` message
- Read `authProvidersProvider` (`core/auth/auth_providers_provider.dart`) to gate any Google/Apple UI — never call `loginGoogle` / `loginApple` from a path reachable when `providers.google` / `providers.apple` is `false`
## Pitfalls (HARD rules — silent failure modes)
### Never call `ref.read` / `ref.watch` / `ref.listen` from `State.dispose()`
In a `ConsumerStatefulWidget`, Riverpod invalidates `ref` the instant `dispose()` starts. Any `ref.*` call throws `Bad state: Cannot use "ref" after the widget was disposed.`. Flutter catches it inside `BuildOwner.finalizeTree`**so it does not surface as a red-screen crash**. Instead the widget tree is left half-finalized and the NEXT screen freezes (looks like a hang; the app process is alive). Two real cases (2026-05-14): [home_screen.dart] + [payment_screen.dart].
**Rule:** any cleanup that needs `ref` goes in `deactivate()`, which runs *before* `dispose()` while `ref` is still valid. Non-Riverpod cleanup (`TextEditingController.dispose()`, `WidgetsBinding.removeObserver`, `StreamSubscription.cancel`) stays in `dispose()`.
```dart
@override
void deactivate() {
ref.read(someProvider.notifier).cleanup();
super.deactivate();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
```
When debugging "screen frozen after navigation", grep the *previous* screen's State for `void dispose()` followed by `ref\.` — that's the first suspect.

View File

@@ -9,6 +9,14 @@
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
analyzer:
plugins:
# Activates custom_lint, which loads:
# - riverpod_lint (dev_dep) — upstream Riverpod rules
# - halo_lints (path: halo_lints/) — repo-specific rules, e.g.
# `no_ref_in_dispose`. See client_app/CLAUDE.md → Pitfalls.
- custom_lint
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`

View File

@@ -54,8 +54,16 @@ class ApiClient {
));
}
Future<Map<String, dynamic>> get(String path, {Map<String, dynamic>? queryParameters}) async {
final response = await _dio.get(path, queryParameters: queryParameters);
Future<Map<String, dynamic>> get(
String path, {
Map<String, dynamic>? queryParameters,
bool skipAuth = false,
}) async {
final response = await _dio.get(
path,
queryParameters: queryParameters,
options: skipAuth ? Options(extra: {_skipAuthKey: true}) : null,
);
return response.data as Map<String, dynamic>;
}

View File

@@ -6,16 +6,16 @@ part 'mitra_availability_notifier.g.dart';
/// Customer-home availability poll.
///
/// Polls `GET /api/client/mitra-availability` every 5 seconds while the home
/// screen is in the foreground. Polling is gated by the home screen calling
/// [setActive] from `WidgetsBindingObserver.didChangeAppLifecycleState`:
/// Polls `GET /api/public/bestie/available` every 5 seconds while the home
/// screen is in the foreground. The endpoint is unauthenticated by design —
/// SHome1st renders before any JWT exists, and the CTA's enabled state needs
/// to reflect global availability so users see whether bestie is online
/// before committing to onboarding. Polling is gated by the home screen
/// calling [setActive] from `WidgetsBindingObserver.didChangeAppLifecycleState`:
/// - resumed → setActive(true)
/// - paused/inactive → setActive(false)
///
/// On any HTTP error we emit `false` (never display stale state).
///
/// The endpoint also returns a `count`, but the customer UI must only read the
/// binary `available` field — the count is for CC/debug only.
@Riverpod(keepAlive: true)
class MitraAvailability extends _$MitraAvailability {
Timer? _pollTimer;
@@ -63,7 +63,10 @@ class MitraAvailability extends _$MitraAvailability {
bool available;
try {
final api = ref.read(apiClientProvider);
final response = await api.get('/api/client/mitra-availability');
final response = await api.get(
'/api/public/bestie/available',
skipAuth: true,
);
final data = response['data'] as Map<String, dynamic>?;
available = data?['available'] as bool? ?? false;
} catch (_) {

View File

@@ -6,21 +6,21 @@ part of 'mitra_availability_notifier.dart';
// RiverpodGenerator
// **************************************************************************
String _$mitraAvailabilityHash() => r'036de8fea66c6a0114abd4a422af12186c1447d7';
String _$mitraAvailabilityHash() => r'e385c671720973cd1cea4b15933cd59421f035f0';
/// Customer-home availability poll.
///
/// Polls `GET /api/client/mitra-availability` every 5 seconds while the home
/// screen is in the foreground. Polling is gated by the home screen calling
/// [setActive] from `WidgetsBindingObserver.didChangeAppLifecycleState`:
/// Polls `GET /api/public/bestie/available` every 5 seconds while the home
/// screen is in the foreground. The endpoint is unauthenticated by design —
/// SHome1st renders before any JWT exists, and the CTA's enabled state needs
/// to reflect global availability so users see whether bestie is online
/// before committing to onboarding. Polling is gated by the home screen
/// calling [setActive] from `WidgetsBindingObserver.didChangeAppLifecycleState`:
/// - resumed → setActive(true)
/// - paused/inactive → setActive(false)
///
/// On any HTTP error we emit `false` (never display stale state).
///
/// The endpoint also returns a `count`, but the customer UI must only read the
/// binary `available` field — the count is for CC/debug only.
///
/// Copied from [MitraAvailability].
@ProviderFor(MitraAvailability)
final mitraAvailabilityProvider =

View File

@@ -456,10 +456,18 @@ class Chat extends _$Chat {
case WsMessage.extensionResponse:
final accepted = data['accepted'] as bool? ?? false;
// On accept, the backend includes the freshly-extended `expires_at` so
// the local ticker can resume immediately (otherwise it would be stuck
// at 0 / the just-expired moment until the next SESSION_TIMER ping).
final extendedExpiresAtRaw = data['expires_at'] as String?;
final extendedExpiresAt = (accepted && extendedExpiresAtRaw != null)
? DateTime.tryParse(extendedExpiresAtRaw)?.toLocal()
: null;
state = current.copyWith(
extensionResponse: data,
sessionPaused: accepted ? false : current.sessionPaused,
sessionExpired: accepted ? false : current.sessionExpired,
expiresAt: extendedExpiresAt ?? current.expiresAt,
);
break;

View File

@@ -30,7 +30,7 @@ final chatRemainingSecondsProvider = AutoDisposeStreamProvider<int>.internal(
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef ChatRemainingSecondsRef = AutoDisposeStreamProviderRef<int>;
String _$chatHash() => r'f9d77e176acb682dc6477635e0e80a09e846988b';
String _$chatHash() => r'a42bf404f5b38034c6c9511259b64b1092af3caa';
/// See also [Chat].
@ProviderFor(Chat)

View File

@@ -6,7 +6,7 @@ part of 'session_closure_notifier.dart';
// RiverpodGenerator
// **************************************************************************
String _$sessionClosureHash() => r'521e57f74805faf01f11778872cb37ceae683a5b';
String _$sessionClosureHash() => r'a9aaf26da64d3489497d057e2b05741c08444e07';
/// See also [SessionClosure].
@ProviderFor(SessionClosure)

View File

@@ -30,9 +30,14 @@ class PairingSearchingData extends PairingData {
/// the payment-session-scoped cancel endpoint without re-prompting.
final String paymentSessionId;
/// Carried so a retryable PAIRING_FAILED can preserve the customer's original
/// topic choice when looping back into Blast via retryBlast().
final TopicSensitivity topicSensitivity;
const PairingSearchingData({
required this.sessionId,
required this.paymentSessionId,
required this.topicSensitivity,
});
}
@@ -105,13 +110,26 @@ class PairingTargetedUnavailableData extends PairingData {
});
}
/// Terminal pairing failure — payment session is in `failed_pairing`. Routes
/// to the failed-pairing screen (no_bestie_screen).
/// Pairing failure surfaced on the S7 Timeout screen.
///
/// `isRetryable=true` means the backend kept the payment session `confirmed`
/// (audit-only failure) so the customer can re-blast on the same payment via
/// `retryBlast()`. `isRetryable=false` means the payment is in `failed_pairing`
/// and any retry must start from a fresh payment session.
class PairingFailedData extends PairingData {
final PairingFailureCause cause;
final String? paymentSessionId;
final bool isRetryable;
// Carried so retryBlast() can re-issue the blast with the customer's original
// topic choice. Null when the failure originated before any topic was known.
final TopicSensitivity? topicSensitivity;
const PairingFailedData({required this.cause, this.paymentSessionId});
const PairingFailedData({
required this.cause,
this.paymentSessionId,
this.isRetryable = false,
this.topicSensitivity,
});
}
class PairingCancelledData extends PairingData {
@@ -156,6 +174,7 @@ class Pairing extends _$Pairing {
state = PairingSearchingData(
sessionId: sessionId,
paymentSessionId: paymentSessionId,
topicSensitivity: topicSensitivity,
);
} on DioException catch (e) {
_cleanup();
@@ -279,6 +298,7 @@ class Pairing extends _$Pairing {
state = PairingSearchingData(
sessionId: sessionId,
paymentSessionId: paymentSessionId,
topicSensitivity: topicSensitivity,
);
} on DioException catch (e) {
_cleanup();
@@ -302,6 +322,25 @@ class Pairing extends _$Pairing {
state = const PairingInitialData();
}
/// "Coba Cari Lagi" CTA on the S7 Timeout screen when the payment was kept
/// `confirmed` (retryable failure). Re-blasts on the same payment session.
///
/// Caller should only invoke this when `state is PairingFailedData &&
/// state.isRetryable && paymentSessionId != null && topicSensitivity != null`.
Future<void> retryBlast() async {
final current = state;
if (current is! PairingFailedData
|| !current.isRetryable
|| current.paymentSessionId == null
|| current.topicSensitivity == null) {
return;
}
await startSearch(
paymentSessionId: current.paymentSessionId!,
topicSensitivity: current.topicSensitivity!,
);
}
// ---- Internal ---------------------------------------------------------
Future<void> _connectWebSocket() async {
@@ -348,13 +387,20 @@ class Pairing extends _$Pairing {
}
if (type == WsMessage.pairingFailed) {
// Terminal — payment_session is in failed_pairing server-side.
final causeTag = data['cause_tag'] as String?;
final paymentSessionId = data['payment_session_id'] as String?;
// Missing flag = terminal (backward-compat with older emit sites). When
// false, the backend kept the payment confirmed and we can re-blast.
final isRetryable = data['is_terminal'] == false;
final carriedTopic = current is PairingSearchingData
? current.topicSensitivity
: null;
_cleanup();
state = PairingFailedData(
cause: PairingFailureCause.fromString(causeTag),
paymentSessionId: paymentSessionId,
isRetryable: isRetryable,
topicSensitivity: carriedTopic,
);
return;
}

View File

@@ -89,7 +89,7 @@ class _DisplayNameScreenState extends ConsumerState<DisplayNameScreen> {
return;
}
if (!mounted) return;
routeForVerifChoice(context, choice);
await routeForVerifChoice(context, ref, choice);
}
@override

View File

@@ -1,13 +1,28 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/auth/auth_notifier.dart';
import '../../../core/auth/auth_providers_provider.dart';
import '../../../core/constants.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart';
/// S3a — WhatsApp input screen.
///
/// Visual contract is `Figma/screens/onboarding.jsx::S3Phone`:
/// - HaloStepDots at the top (step 3 of 4: S2 Nama → S5 ESP → S5b USP → S3a)
/// - Personalised display-title `"nomor wa-mu, {name}?"`
/// - +62 prefix as static chip; user types only the trailing digits
/// - Privacy reassurance card
/// - Primary CTA `"kirim kode"` (disabled until ≥9 digits)
/// - Ghost link `"lanjut tanpa verifikasi (harga normal)"` → anonymous path
///
/// Two callers route here:
/// 1. New-user verified onboarding (USP → here) — auth state has the
/// anonymous display_name set by S2.
/// 2. SHome1st "masuk →" banner for returning-user recovery — auth state
/// is initial; the name greeting falls back to "kamu".
class RegisterScreen extends ConsumerStatefulWidget {
const RegisterScreen({super.key});
@@ -19,8 +34,9 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
final _phoneController = TextEditingController();
ProviderSubscription<AsyncValue<AuthData>>? _authSub;
// Server-imposed lockout: when /otp/request returns 429, the backend
// includes retry_after_seconds. We disable "Kirim OTP" for that window.
// Server-imposed lockout from /otp/request 429s. Backend embeds
// retry_after_seconds in the AuthErrorInfo so we can disable the CTA
// until the next slot opens.
int _lockoutSeconds = 0;
Timer? _lockoutTimer;
String? _errorMessage;
@@ -28,13 +44,14 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
@override
void initState() {
super.initState();
_phoneController.addListener(() => setState(() {}));
_authSub = ref.listenManual<AsyncValue<AuthData>>(authProvider, (prev, next) {
if (!mounted) return;
final data = next.valueOrNull;
if (data is AuthOtpSentData) {
// Use go (replace) so re-submitting the phone form doesn't stack
// multiple OtpScreen instances with active listeners.
context.go('/auth/otp', extra: _phoneController.text.trim());
// go (replace) so re-submitting the form doesn't stack OtpScreens
// with leftover listeners.
context.go('/auth/otp', extra: _e164Phone());
return;
}
if (next is AsyncError) {
@@ -76,97 +93,83 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
});
}
/// Subscriber digits with leading zeros stripped. Users commonly type
/// `0812…` (local format); the backend wants `+62812…`, so the 0 must go.
String _subscriberDigits() {
final digits = _phoneController.text.replaceAll(RegExp(r'\D'), '');
return digits.replaceFirst(RegExp(r'^0+'), '');
}
/// Local digits (no country code) → E.164 string the backend expects.
String _e164Phone() => '+62${_subscriberDigits()}';
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?) ?? '',
_ => '',
};
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
final isLoading = authState is AsyncLoading;
final isLockedOut = _lockoutSeconds > 0;
final canSubmit = !isLoading && !isLockedOut;
final providersAsync = ref.watch(authProvidersProvider);
final providers =
providersAsync.valueOrNull ?? AuthProvidersConfig.fallback;
final hasMinDigits = _subscriberDigits().length >= 9;
final canSubmit = hasMinDigits && !isLoading && !isLockedOut;
final name = _greetingName(authState.valueOrNull);
final shownName = name.isEmpty ? 'kamu' : name;
return Scaffold(
appBar: AppBar(title: const Text('Masuk / Daftar')),
backgroundColor: HaloTokens.bg,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(HaloSpacing.s24),
padding: const EdgeInsets.fromLTRB(28, 8, 28, 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (providers.hasAnySocial) ...[
if (providers.google) ...[
HaloButton(
label: 'lanjut dengan Google',
icon: const Icon(Icons.g_mobiledata),
variant: HaloButtonVariant.secondary,
fullWidth: true,
onPressed: isLoading
? null
: () => ref.read(authProvider.notifier).loginGoogle(),
),
const SizedBox(height: HaloSpacing.s12),
],
if (providers.apple) ...[
HaloButton(
label: 'lanjut dengan Apple',
icon: const Icon(Icons.apple),
variant: HaloButtonVariant.secondary,
fullWidth: true,
onPressed: isLoading
? null
: () => ref.read(authProvider.notifier).loginApple(),
),
const SizedBox(height: HaloSpacing.s12),
],
const Padding(
padding: EdgeInsets.symmetric(vertical: HaloSpacing.s16),
child: Row(
padding: EdgeInsets.only(top: 8, bottom: 8),
child: HaloStepDots(total: 4, current: 3),
),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: Divider(color: HaloTokens.border)),
Padding(
padding:
EdgeInsets.symmetric(horizontal: HaloSpacing.s12),
child: Text(
'atau',
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,
color: HaloTokens.inkMuted,
fontSize: 13,
fontSize: 14.5,
color: HaloTokens.inkSoft,
height: 1.5,
),
),
),
Expanded(child: Divider(color: HaloTokens.border)),
],
),
),
],
TextField(
const SizedBox(height: 24),
_PhoneRow(
controller: _phoneController,
decoration: const InputDecoration(
labelText: 'Nomor HP',
hintText: '+628xxxxxxxxxx',
),
keyboardType: TextInputType.phone,
),
const SizedBox(height: HaloSpacing.s16),
HaloButton(
label: isLoading
? 'memproses...'
: isLockedOut
? 'coba lagi dalam ${formatCountdown(_lockoutSeconds)}'
: 'kirim OTP',
fullWidth: true,
onPressed: canSubmit
? () {
final phone = _phoneController.text.trim();
if (phone.isEmpty) return;
ref.read(authProvider.notifier).requestOtp(phone);
}
: null,
borderColor: hasMinDigits
? HaloTokens.brand
: HaloTokens.border,
),
const SizedBox(height: 16),
const _PrivacyCard(),
if (_errorMessage != null) ...[
const SizedBox(height: HaloSpacing.s12),
const SizedBox(height: 12),
Text(
_errorMessage!,
textAlign: TextAlign.center,
@@ -180,6 +183,145 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
],
),
),
HaloButton(
label: isLoading
? 'memproses...'
: isLockedOut
? 'coba lagi dalam ${formatCountdown(_lockoutSeconds)}'
: 'kirim kode',
fullWidth: true,
onPressed: canSubmit
? () => ref
.read(authProvider.notifier)
.requestOtp(_e164Phone())
: null,
),
const SizedBox(height: 4),
TextButton(
onPressed: isLoading
? null
// Skip ESPb/USPb — the verified branch already ran ESPa+USPa,
// so the redirect alias drops the user straight at PickMethod.
: () => context.go('/onboarding/anon/method'),
style: TextButton.styleFrom(
foregroundColor: HaloTokens.inkSoft,
minimumSize: const Size(0, 40),
),
child: const Text(
'lanjut tanpa verifikasi (harga normal)',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 12.5,
decoration: TextDecoration.underline,
decorationColor: HaloTokens.inkSoft,
),
),
),
],
),
),
),
);
}
}
class _PhoneRow extends StatelessWidget {
final TextEditingController controller;
final Color borderColor;
const _PhoneRow({required this.controller, required this.borderColor});
@override
Widget build(BuildContext context) {
return Container(
height: 60,
padding: const EdgeInsets.symmetric(horizontal: 18),
decoration: BoxDecoration(
color: HaloTokens.surface,
borderRadius: HaloRadius.lg,
border: Border.all(color: borderColor, width: 1.5),
),
child: Row(
children: [
const Text(
'🇮🇩 +62',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 15,
fontWeight: FontWeight.w600,
color: HaloTokens.ink,
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: controller,
keyboardType: TextInputType.phone,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
maxLength: 13, // enough headroom for 12-digit ID mobiles
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 16,
fontWeight: FontWeight.w500,
color: HaloTokens.ink,
),
decoration: const InputDecoration(
hintText: '812 3456 7890',
hintStyle: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 16,
fontWeight: FontWeight.w500,
color: HaloTokens.inkMuted,
),
// Override the app-wide inputDecorationTheme so the input
// sits flush inside the outer pill — no fill, no border.
filled: false,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
errorBorder: InputBorder.none,
focusedErrorBorder: InputBorder.none,
disabledBorder: InputBorder.none,
isCollapsed: true,
counterText: '',
contentPadding: EdgeInsets.symmetric(vertical: 18),
),
),
),
],
),
);
}
}
class _PrivacyCard extends StatelessWidget {
const _PrivacyCard();
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: HaloTokens.brandSofter,
borderRadius: HaloRadius.md,
border: Border.all(color: HaloTokens.brandSoft),
),
child: const Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('🛡️', style: TextStyle(fontSize: 14)),
SizedBox(width: 10),
Expanded(
child: Text(
'anonim — bestie cuma tau nama panggilan kamu. nomor gak akan dishare.',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 12,
color: HaloTokens.brandDark,
height: 1.45,
),
),
),
],
),
);
}

View File

@@ -1,89 +0,0 @@
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/auth_providers_provider.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart';
class WelcomeScreen extends ConsumerWidget {
const WelcomeScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final providersAsync = ref.watch(authProvidersProvider);
final providers =
providersAsync.valueOrNull ?? AuthProvidersConfig.fallback;
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(HaloSpacing.s24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Center(child: HaloOrb(seed: 0, size: 96, label: 'H')),
const SizedBox(height: HaloSpacing.s24),
const Text(
'Halo Bestie',
style: TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 32,
fontWeight: FontWeight.w700,
color: HaloTokens.ink,
),
textAlign: TextAlign.center,
),
const SizedBox(height: HaloSpacing.s8),
const Text(
'Tempat curhat kamu',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 15,
color: HaloTokens.inkSoft,
),
textAlign: TextAlign.center,
),
const SizedBox(height: HaloSpacing.s48),
HaloButton(
label: 'Lanjut sebagai Tamu',
fullWidth: true,
onPressed: () => context.push('/auth/display-name'),
),
const SizedBox(height: HaloSpacing.s12),
if (providers.google) ...[
HaloButton(
label: 'lanjut dengan Google',
icon: const Icon(Icons.g_mobiledata),
variant: HaloButtonVariant.secondary,
fullWidth: true,
onPressed: () =>
ref.read(authProvider.notifier).loginGoogle(),
),
const SizedBox(height: HaloSpacing.s12),
],
if (providers.apple) ...[
HaloButton(
label: 'lanjut dengan Apple',
icon: const Icon(Icons.apple),
variant: HaloButtonVariant.secondary,
fullWidth: true,
onPressed: () =>
ref.read(authProvider.notifier).loginApple(),
),
const SizedBox(height: HaloSpacing.s12),
],
HaloButton(
label: 'Daftar / Masuk',
variant: HaloButtonVariant.ghost,
fullWidth: true,
onPressed: () => context.push('/auth/register'),
),
],
),
),
),
);
}
}

View File

@@ -7,8 +7,12 @@ import '../../support/widgets/tanya_admin_sheet.dart';
/// Modal shown when OTP delivery / verification is exhausted (rate-limited
/// 429 from `OTP_RATE_LIMIT_PHONE`, `OTP_RATE_LIMIT_IP`, `OTP_COOLDOWN`, or
/// `OTP_ATTEMPTS_EXCEEDED`). Offers a "lanjut tanpa verif" exit into the
/// anonymous flow (preserving any ESP/USP state) and a "hubungi admin" CTA
/// that opens the Tanya Admin sheet.
/// anonymous flow and a "hubungi admin" CTA that opens the Tanya Admin sheet.
///
/// By the time this popup can fire, the USP one-time gate has already been
/// evaluated upstream on `VerifChoiceSheet` (either shown + marked seen, or
/// skipped because already seen). The exit can therefore jump straight into
/// `/payment/method-pick` regardless.
class OtpBlockedPopup {
const OtpBlockedPopup._();
@@ -35,12 +39,7 @@ class OtpBlockedPopup {
),
primary: HaloPopupAction(
label: 'lanjut tanpa verif',
onPressed: () {
// ESP/USP picks live in Riverpod providers (espSelectionProvider,
// espSkippedProvider) and survive this navigation — no need to pass
// them as `extra`.
context.go('/onboarding/anon/method');
},
onPressed: () => context.go('/onboarding/anon/method'),
),
secondary: HaloPopupAction(
label: 'hubungi admin',

View File

@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart';
import '../../onboarding/usp_seen_provider.dart';
/// Result of the post-name Verif Choice Sheet. Caller routes to the matching
/// onboarding sub-flow.
@@ -65,13 +67,26 @@ class VerifChoiceSheet extends StatelessWidget {
}
/// Helper: route to the right onboarding sub-flow for a verif choice.
void routeForVerifChoice(BuildContext context, VerifChoice choice) {
///
/// Phase 4 (2026-05-12): the S5 ESP screen is retired and S5b USP is now a
/// one-time gate. If the user has already seen USP (local SharedPreferences
/// flag, OR-merged with `customers.usp_seen` on login), we skip USP entirely
/// and jump to the per-branch next step.
Future<void> routeForVerifChoice(
BuildContext context,
WidgetRef ref,
VerifChoice choice,
) async {
final seen = await ref.read(uspSeenProvider.future);
if (!context.mounted) return;
switch (choice) {
case VerifChoice.verified:
context.push('/onboarding/verif/esp');
context.push(seen ? '/auth/register' : '/onboarding/verif/usp');
break;
case VerifChoice.anonymous:
context.push('/onboarding/anon/esp');
// `/onboarding/anon/method` redirects to `/payment/method-pick`; use the
// canonical destination here.
context.push(seen ? '/payment/method-pick' : '/onboarding/anon/usp');
break;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -191,8 +191,14 @@ class _SearchingBody extends ConsumerWidget {
fullWidth: true,
size: HaloButtonSize.lg,
onPressed: () {
ref.read(pairingProvider.notifier).reset();
final notifier = ref.read(pairingProvider.notifier);
final s = state;
if (s is PairingFailedData && s.isRetryable) {
notifier.retryBlast();
} else {
notifier.reset();
context.go('/payment/entry');
}
},
),
const SizedBox(height: HaloSpacing.s8),

View File

@@ -1,56 +1,78 @@
import 'package:flutter/material.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/halo_button.dart';
/// Floating banner injected above the chat input bar when the session timer
/// has hit zero but the session is still in `closing` grace. Phase 4 Stage 6:
/// gives the customer a soft, in-place way to extend instead of the modal-only
/// flow from Phase 3.
/// Floating expired banner shown above the chat input when the session
/// timer has hit zero but the session is still in `closing` grace.
///
/// Mirrors Figma `v3.jsx::HBChatExpiredBanner` (line 423): brand-pink
/// background, white text, `⏰` icon, "habis nih... mau lanjutin curhat
/// sama {name}?" copy, white `perpanjang` button.
class ChatExpiredBanner extends StatelessWidget {
final String mitraName;
final VoidCallback onExtend;
const ChatExpiredBanner({super.key, required this.onExtend});
const ChatExpiredBanner({
super.key,
required this.mitraName,
required this.onExtend,
});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.fromLTRB(
HaloSpacing.s12,
HaloSpacing.s8,
HaloSpacing.s12,
HaloSpacing.s8,
margin: const EdgeInsets.fromLTRB(12, 0, 12, 8),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: HaloTokens.brand,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: HaloTokens.brand.withValues(alpha: 0.31),
blurRadius: 16,
offset: const Offset(0, 4),
),
padding: const EdgeInsets.fromLTRB(
HaloSpacing.s16,
HaloSpacing.s12,
HaloSpacing.s12,
HaloSpacing.s12,
),
decoration: const BoxDecoration(
color: HaloTokens.danger,
borderRadius: HaloRadius.lg,
boxShadow: HaloShadows.card,
],
),
child: Row(
children: [
const Text('', style: TextStyle(fontSize: 20)),
const SizedBox(width: HaloSpacing.s12),
const Expanded(
child: Text(
'waktu curhat habis',
style: TextStyle(
const Text('', style: TextStyle(fontSize: 16)),
const SizedBox(width: 10),
Expanded(
child: RichText(
text: TextSpan(
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 14,
fontWeight: FontWeight.w600,
fontSize: 12.5,
height: 1.4,
color: Colors.white,
),
children: [
const TextSpan(
text: 'habis nih...',
style: TextStyle(fontWeight: FontWeight.w600),
),
TextSpan(text: ' mau lanjutin curhat sama $mitraName?'),
],
),
),
HaloButton(
label: 'perpanjang',
size: HaloButtonSize.sm,
variant: HaloButtonVariant.secondary,
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: onExtend,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: HaloTokens.brand,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
minimumSize: const Size(0, 32),
elevation: 0,
textStyle: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 11.5,
fontWeight: FontWeight.w700,
),
),
child: const Text('perpanjang'),
),
],
),

View File

@@ -47,8 +47,19 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
}
@override
void dispose() {
void deactivate() {
// setActive(false) lives here, NOT in dispose(): modern Riverpod
// invalidates `ref` as soon as the State enters dispose(), so calling
// `ref.read` from there throws `Bad state: Cannot use "ref" after the
// widget was disposed.` That exception fires inside `finalizeTree` and
// leaves the widget tree in a half-finalized state — observed symptom
// is a frozen screen on the next push (e.g. Home → Chat).
ref.read(mitraAvailabilityProvider.notifier).setActive(false);
super.deactivate();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_client_provider.dart';
import '../../../core/auth/auth_notifier.dart';
class BestieHistoryItem {
final String sessionId;
@@ -28,13 +29,34 @@ class BestieHistoryItem {
mitraName: json['mitra_display_name'] as String? ?? 'Bestie',
endedAt: endedAtRaw is String ? DateTime.tryParse(endedAtRaw)?.toLocal() : null,
topics: (json['topics'] as List?)?.cast<String>() ?? const [],
sessionsCount: (json['sessions_count'] as num?)?.toInt() ?? 1,
// sessions_count comes from PostgreSQL COUNT(*) which postgres.js
// stringifies (bigint precision). Accept both shapes so the factory
// doesn't crash when the backend forgets to ::int.
sessionsCount: switch (json['sessions_count']) {
num n => n.toInt(),
String s => int.tryParse(s) ?? 1,
_ => 1,
},
mitraIsOnline: json['mitra_is_online'] as bool? ?? false,
);
}
}
final bestieHistoryProvider = FutureProvider<List<BestieHistoryItem>>((ref) async {
/// Scoped to the current customer so logging in as a different account (or
/// from anonymous → phone-verified) triggers a refetch instead of returning
/// the previous customer's cached list. Returns `[]` when no auth — keeps
/// SHome1st from issuing a doomed 401.
final bestieHistoryProvider =
FutureProvider<List<BestieHistoryItem>>((ref) async {
final customerId = ref.watch(authProvider.select((s) {
final data = s.valueOrNull;
return switch (data) {
AuthAuthenticatedData d => d.profile['id'] as String?,
AuthAnonymousData d => d.customerId,
_ => null,
};
}));
if (customerId == null) return const [];
final api = ref.read(apiClientProvider);
final response = await api.get('/api/client/chat/history');
final items = (response['data']['items'] as List<dynamic>? ?? [])

View File

@@ -1,12 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'esp_topic.dart';
/// Ephemeral selection from the ESP screen. Survives across the
/// onboarding flow (Verif Sheet → ESP → USP → OTP / Pilih cara). Cleared
/// when a chat session is created server-side.
final espSelectionProvider = StateProvider<Set<EspTopic>>((_) => <EspTopic>{});
/// Set to `true` when the user tapped "lewati" on the ESP screen. Distinct
/// from "user picked nothing then pressed Lanjut" — the backend wants to
/// know whether the empty set was intentional or a deliberate skip.
final espSkippedProvider = StateProvider<bool>((_) => false);

View File

@@ -1,34 +0,0 @@
/// Twelve emotional-state-pick (ESP) topic chips shown on the onboarding ESP
/// screen. Multi-select, info-only — the picks are persisted on the chat
/// session at session start and surfaced to the mitra as a chip row above
/// the first message bubble. They do NOT affect matching, pricing, or routing.
///
/// `value` is the wire-format string sent to the backend
/// (`chat_sessions.topics TEXT[]`). Lowercase snake_case to keep it stable
/// across UI label tweaks.
enum EspTopic {
relationship('relationship', 'Hubungan'),
family('family', 'Keluarga'),
work('work', 'Pekerjaan'),
study('study', 'Sekolah / Kuliah'),
finance('finance', 'Keuangan'),
health('health', 'Kesehatan'),
friendship('friendship', 'Pertemanan'),
selfWorth('self_worth', 'Self-worth'),
anxiety('anxiety', 'Kecemasan'),
loneliness('loneliness', 'Kesepian'),
grief('grief', 'Kehilangan'),
identity('identity', 'Identitas');
final String value;
final String label;
const EspTopic(this.value, this.label);
static EspTopic? fromValue(String? v) {
if (v == null) return null;
for (final t in values) {
if (t.value == v) return t;
}
return null;
}
}

View File

@@ -84,7 +84,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
await prefs.setBool(_kOnboardingDone, true);
ref.invalidate(onboardingDoneProvider);
if (mounted) {
context.go('/welcome');
context.go('/home');
}
}

View File

@@ -1,132 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart';
import '../esp_state.dart';
import '../esp_topic.dart';
/// Onboarding step 1 — multi-select chip grid for ESP topics. Picks are
/// persisted on the chat session at session-start time and surfaced read-only
/// to the mitra. They do NOT affect matching, pricing, or routing.
///
/// Routed under both `/onboarding/verif/esp` and `/onboarding/anon/esp` —
/// the parent flow path determines the next destination after Lanjut.
class EspScreen extends ConsumerWidget {
/// `verified` ➞ ESP → USP → OTP.
/// `anonymous` ➞ ESP → USP → /payment/method-pick (Stage 3).
final bool verified;
const EspScreen({super.key, required this.verified});
@override
Widget build(BuildContext context, WidgetRef ref) {
final selected = ref.watch(espSelectionProvider);
return Scaffold(
appBar: AppBar(
title: const Padding(
padding: EdgeInsets.only(top: HaloSpacing.s4),
child: HaloStepDots(total: 4, current: 1),
),
centerTitle: true,
actions: [
TextButton(
onPressed: () => _onSkip(context, ref),
child: const Text('lewati'),
),
],
),
body: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(
HaloSpacing.s24,
HaloSpacing.s8,
HaloSpacing.s24,
HaloSpacing.s24,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Lagi mikirin apa?',
style: TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 26,
height: 30 / 26,
fontWeight: FontWeight.w700,
color: HaloTokens.ink,
),
),
const SizedBox(height: HaloSpacing.s8),
const Text(
'Pilih topik yang nyangkut sama ceritamu. Nggak ada yang nyambung pun nggak apa-apa, bisa dilewati.',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 14,
height: 20 / 14,
color: HaloTokens.inkSoft,
),
),
const SizedBox(height: HaloSpacing.s24),
Expanded(
child: SingleChildScrollView(
child: Wrap(
spacing: HaloSpacing.s8,
runSpacing: HaloSpacing.s8,
children: EspTopic.values.map((topic) {
final isOn = selected.contains(topic);
return HaloChip(
label: topic.label,
selected: isOn,
onTap: () => _toggle(ref, topic),
);
}).toList(),
),
),
),
const SizedBox(height: HaloSpacing.s16),
HaloButton(
label: 'lanjut',
fullWidth: true,
onPressed: () => _onContinue(context, ref),
),
],
),
),
),
);
}
void _toggle(WidgetRef ref, EspTopic topic) {
final current = ref.read(espSelectionProvider);
final next = Set<EspTopic>.from(current);
if (!next.add(topic)) next.remove(topic);
ref.read(espSelectionProvider.notifier).state = next;
if (ref.read(espSkippedProvider)) {
ref.read(espSkippedProvider.notifier).state = false;
}
}
void _onSkip(BuildContext context, WidgetRef ref) {
ref.read(espSelectionProvider.notifier).state = <EspTopic>{};
ref.read(espSkippedProvider.notifier).state = true;
_goNext(context);
}
void _onContinue(BuildContext context, WidgetRef ref) {
if (ref.read(espSelectionProvider).isEmpty) {
ref.read(espSkippedProvider.notifier).state = true;
} else {
ref.read(espSkippedProvider.notifier).state = false;
}
_goNext(context);
}
void _goNext(BuildContext context) {
final next =
verified ? '/onboarding/verif/usp' : '/onboarding/anon/usp';
context.push(next);
}
}

View File

@@ -1,13 +1,17 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart';
import '../usp_seen_provider.dart';
/// Onboarding step 2 — static value-prop ("USP") cards. No state; just a
/// terminal CTA that routes onward to the auth/payment fork.
class UspScreen extends StatelessWidget {
/// `verified` ➞ USP → OTP (`/auth/register`).
/// `anonymous` ➞ USP → `/payment/method-pick` (Stage 3 owns this route).
/// Onboarding step 2 — static value-prop ("USP") cards. One-time gate
/// (Phase 4, 2026-05-12): on Continue we mark the local `usp_seen` flag and
/// best-effort persist to DB so this screen never shows again for this user.
///
/// `verified` ➞ USP → OTP (`/auth/register`).
/// `anonymous` ➞ USP → `/payment/method-pick`.
class UspScreen extends ConsumerWidget {
final bool verified;
const UspScreen({super.key, required this.verified});
@@ -36,7 +40,7 @@ class UspScreen extends StatelessWidget {
];
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(
title: const Padding(
@@ -90,7 +94,7 @@ class UspScreen extends StatelessWidget {
HaloButton(
label: 'aku ngerti, lanjut',
fullWidth: true,
onPressed: () => _onContinue(context),
onPressed: () => _onContinue(context, ref),
),
],
),
@@ -99,12 +103,14 @@ class UspScreen extends StatelessWidget {
);
}
void _onContinue(BuildContext context) {
Future<void> _onContinue(BuildContext context, WidgetRef ref) async {
// Persist the local + server flag before leaving — next time the user
// hits VerifChoice, this screen is skipped.
await ref.read(uspSeenProvider.notifier).markSeen();
if (!context.mounted) return;
if (verified) {
context.push('/auth/register');
} else {
// Stage 3 owns /payment/method-pick. Until then, route there as a
// placeholder; Maestro flow 03 stops at the route arrival.
context.push('/payment/method-pick');
}
}

View File

@@ -0,0 +1,81 @@
import 'dart:async';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../core/api/api_client_provider.dart';
import '../../core/auth/auth_notifier.dart';
part 'usp_seen_provider.g.dart';
const _kPrefsKey = 'usp_seen';
/// One-time gate for the S5b USP onboarding screen (Phase 4, 2026-05-12).
///
/// Local SharedPreferences flag is the runtime source of truth. When an
/// authenticated session is hydrated (bootstrap, OTP verify, social, name
/// patch), the server-side `customers.usp_seen` value is OR-merged into the
/// local flag — true wins. When the user dismisses the USP screen and an
/// account exists, the local true is best-effort propagated to the server via
/// `POST /api/client/auth/usp-seen`.
@Riverpod(keepAlive: true)
class UspSeen extends _$UspSeen {
@override
FutureOr<bool> build() async {
// Watch auth state; whenever an auth-bearing profile arrives, OR-merge the
// server flag into local. Disposed/recreated automatically with the
// notifier so no manual cleanup needed.
ref.listen<AsyncValue<AuthData>>(authProvider, (prev, next) {
final profile = _profileOf(next.valueOrNull);
if (profile != null) {
unawaited(_hydrateFromProfile(profile));
}
});
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_kPrefsKey) ?? false;
}
Map<String, dynamic>? _profileOf(AuthData? data) => switch (data) {
AuthAuthenticatedData d => d.profile,
AuthAnonymousData d => d.profile,
AuthForceRegisterData d => d.profile,
AuthNeedsDisplayNameData d => d.profile,
_ => null,
};
Future<void> _hydrateFromProfile(Map<String, dynamic> profile) async {
final serverSeen = profile['usp_seen'] as bool? ?? false;
if (!serverSeen) return;
if ((state.valueOrNull ?? false) == true) return;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kPrefsKey, true);
state = const AsyncData(true);
}
/// Mark seen locally; if an account exists, also persist to DB best-effort.
/// Safe to call when already seen — no-ops out of the network hit if local
/// is already true AND no account exists yet.
Future<void> markSeen() async {
final alreadySeen = (state.valueOrNull ?? false) == true;
if (!alreadySeen) {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kPrefsKey, true);
state = const AsyncData(true);
}
final authData = ref.read(authProvider).valueOrNull;
final hasAccount = authData is AuthAuthenticatedData ||
authData is AuthAnonymousData ||
authData is AuthForceRegisterData ||
authData is AuthNeedsDisplayNameData;
if (!hasAccount) return;
try {
await ref.read(apiClientProvider).post('/api/client/auth/usp-seen');
} catch (_) {
// Local stays true; next markSeen call (or a successful login on a
// different device) will re-attempt the DB write.
}
}
}

View File

@@ -0,0 +1,33 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'usp_seen_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$uspSeenHash() => r'e8c5b7def4f640fb3933929f099ce7f0b5cbe050';
/// One-time gate for the S5b USP onboarding screen (Phase 4, 2026-05-12).
///
/// Local SharedPreferences flag is the runtime source of truth. When an
/// authenticated session is hydrated (bootstrap, OTP verify, social, name
/// patch), the server-side `customers.usp_seen` value is OR-merged into the
/// local flag — true wins. When the user dismisses the USP screen and an
/// account exists, the local true is best-effort propagated to the server via
/// `POST /api/client/auth/usp-seen`.
///
/// Copied from [UspSeen].
@ProviderFor(UspSeen)
final uspSeenProvider = AsyncNotifierProvider<UspSeen, bool>.internal(
UspSeen.new,
name: r'uspSeenProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$uspSeenHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$UspSeen = AsyncNotifier<bool>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -60,13 +60,16 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
}
@override
void dispose() {
// Best-effort cancel on back/dispose if we still have a `pending` row.
void deactivate() {
// Best-effort cancel on back/leave if we still have a `pending` row.
// The notifier checks state before calling the API, so this is safe to
// call unconditionally.
// call unconditionally. Lives in deactivate(), not dispose(), because
// modern Riverpod invalidates `ref` once dispose() starts — the resulting
// `Bad state: Cannot use "ref" after the widget was disposed.` corrupts
// the widget-tree finalize and leaves the next screen frozen.
// ignore: discarded_futures
ref.read(paymentProvider.notifier).cancelIfPending();
super.dispose();
super.deactivate();
}

View File

@@ -125,19 +125,34 @@ class _WaitingPaymentScreenState extends ConsumerState<WaitingPaymentScreen>
if (status == PaymentSessionStatus.confirmed ||
status == PaymentSessionStatus.consumed) {
_markTerminal();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
context.go('/onboarding/notif-gate');
});
_navigateTerminal('/onboarding/notif-gate');
} else if (status == PaymentSessionStatus.expired ||
status == PaymentSessionStatus.abandoned) {
_markTerminal();
_navigateTerminal('/payment/expired/${widget.paymentId}');
}
}
/// Routes off the waiting screen once the payment session reached a
/// terminal status. Belt-and-braces:
/// - `Future.microtask` runs after the current event loop turn (after any
/// pending setState), so we don't fight an in-flight build.
/// - `addPostFrameCallback` is a fallback in case the microtask is
/// pre-empted (observed once on release builds where the screen stayed
/// visually stuck on "menunggu pembayaran" despite polling having
/// stopped — see 2026-05-14 thread).
void _navigateTerminal(String route) {
Future.microtask(() {
if (!mounted) return;
context.go(route);
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
context.go('/payment/expired/${widget.paymentId}');
// No-op if the microtask already navigated — `go` to the same location
// is idempotent in GoRouter.
context.go(route);
});
}
}
void _markTerminal() {
_terminal = true;

View File

@@ -0,0 +1,366 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/auth/auth_notifier.dart';
import '../../core/theme/halo_tokens.dart';
import '../home/widgets/halo_tab_bar.dart';
/// "Kamu" tab — profile screen.
///
/// Mirrors Figma `SProfile` (see `requirement/Figma/screens/extras.jsx::SProfile`):
/// user card → menu list (kontak / syarat / privasi) → action button → version.
///
/// The action button differs from Figma: we ship **logout** here instead of
/// the "hapus akun" CTA from the mockup. Account deletion is a deeper flow
/// (confirmation, server-side data removal, refund policy) and is not in
/// scope yet.
class ProfileScreen extends ConsumerWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final authData = ref.watch(authProvider).valueOrNull;
final (name, phone) = switch (authData) {
AuthAuthenticatedData d => (
(d.profile['display_name'] as String?) ?? 'kamu',
_maskPhone(d.profile['phone'] as String?),
),
AuthAnonymousData d => (
d.displayName.isEmpty ? 'kamu' : d.displayName,
'akun anonim',
),
_ => ('kamu', null),
};
return Scaffold(
backgroundColor: HaloTokens.bg,
body: SafeArea(
bottom: false,
child: Column(
children: [
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 16),
children: [
const Text(
'kamu',
style: TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 26,
fontWeight: FontWeight.w700,
color: HaloTokens.brandDark,
letterSpacing: -0.52,
),
),
const SizedBox(height: 18),
_UserCard(name: name, phone: phone),
const SizedBox(height: 20),
_MenuCard(items: [
_MenuItemData(
icon: Icons.mail_outline,
label: 'kontak kami',
sub: 'halo@halobestie.id',
onTap: () {},
),
_MenuItemData(
icon: Icons.description_outlined,
label: 'syarat & ketentuan',
onTap: () {},
),
_MenuItemData(
icon: Icons.lock_outline,
label: 'kebijakan privasi',
onTap: () {},
),
]),
const SizedBox(height: 16),
_LogoutButton(
onTap: () => _confirmLogout(context, ref),
),
const SizedBox(height: 20),
const Center(
child: Text(
'HaloBestie · v1.0.0',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 11,
color: HaloTokens.inkMuted,
),
),
),
],
),
),
const HaloTabBar(active: 'kamu'),
],
),
),
);
}
Future<void> _confirmLogout(BuildContext context, WidgetRef ref) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: HaloTokens.surface,
shape: const RoundedRectangleBorder(borderRadius: HaloRadius.lg),
title: const Text(
'keluar dari akun?',
style: TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontWeight: FontWeight.w700,
color: HaloTokens.brandDark,
),
),
content: const Text(
'kamu harus login lagi buat lanjutin curhatan.',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
color: HaloTokens.inkSoft,
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text(
'batal',
style: TextStyle(color: HaloTokens.inkMuted),
),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text(
'keluar',
style: TextStyle(
color: HaloTokens.danger,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
if (ok != true) return;
await ref.read(authProvider.notifier).logout();
// RouterNotifier observes the resulting AuthInitialData and sends the user
// to /home (SHome1st), so no manual navigation is needed here.
}
static String? _maskPhone(String? raw) {
if (raw == null || raw.length < 6) return raw;
final tail = raw.substring(raw.length - 4);
final head = raw.substring(0, raw.length - 8);
return '$head ••••$tail';
}
}
class _UserCard extends StatelessWidget {
final String name;
final String? phone;
const _UserCard({required this.name, this.phone});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: HaloTokens.surface,
borderRadius: HaloRadius.xl,
border: Border.all(color: HaloTokens.border),
),
child: Row(
children: [
Container(
width: 56,
height: 56,
decoration: const BoxDecoration(
gradient: RadialGradient(
colors: [HaloTokens.brand, HaloTokens.lilac],
),
shape: BoxShape.circle,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 18,
fontWeight: FontWeight.w700,
color: HaloTokens.ink,
letterSpacing: -0.18,
),
),
if (phone != null) ...[
const SizedBox(height: 2),
Text(
phone!,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 12,
color: HaloTokens.inkSoft,
),
),
],
],
),
),
],
),
);
}
}
class _MenuItemData {
final IconData icon;
final String label;
final String? sub;
final VoidCallback onTap;
const _MenuItemData({
required this.icon,
required this.label,
this.sub,
required this.onTap,
});
}
class _MenuCard extends StatelessWidget {
final List<_MenuItemData> items;
const _MenuCard({required this.items});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: HaloTokens.surface,
borderRadius: HaloRadius.lg,
border: Border.all(color: HaloTokens.border),
),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
for (var i = 0; i < items.length; i++) ...[
_MenuItemRow(item: items[i]),
if (i < items.length - 1)
const Divider(height: 1, thickness: 1, color: HaloTokens.border),
],
],
),
);
}
}
class _MenuItemRow extends StatelessWidget {
final _MenuItemData item;
const _MenuItemRow({required this.item});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: item.onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: const BoxDecoration(
color: HaloTokens.brandSofter,
borderRadius: BorderRadius.all(Radius.circular(10)),
),
alignment: Alignment.center,
child: Icon(item.icon, size: 18, color: HaloTokens.brandDark),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
item.label,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 14,
fontWeight: FontWeight.w500,
color: HaloTokens.ink,
),
),
if (item.sub != null) ...[
const SizedBox(height: 2),
Text(
item.sub!,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 11.5,
color: HaloTokens.inkMuted,
),
),
],
],
),
),
const Icon(
Icons.chevron_right,
size: 18,
color: HaloTokens.inkMuted,
),
],
),
),
),
);
}
}
class _LogoutButton extends StatelessWidget {
final VoidCallback onTap;
const _LogoutButton({required this.onTap});
@override
Widget build(BuildContext context) {
return Material(
color: HaloTokens.surface,
borderRadius: HaloRadius.lg,
child: InkWell(
onTap: onTap,
borderRadius: HaloRadius.lg,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16),
decoration: BoxDecoration(
borderRadius: HaloRadius.lg,
border: Border.all(
color: HaloTokens.danger.withValues(alpha: 0.25),
),
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.logout, size: 18, color: HaloTokens.danger),
SizedBox(width: 8),
Text(
'keluar',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 14,
fontWeight: FontWeight.w500,
color: HaloTokens.danger,
),
),
],
),
),
),
);
}
}

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
@@ -5,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/api/api_client_provider.dart';
import 'core/auth/auth_notifier.dart';
import 'core/auth/auth_providers_provider.dart';
import 'core/auth/token_storage.dart';
import 'core/chat/active_session_notifier.dart';
import 'core/chat/chat_notifier.dart';
import 'core/notifications/notification_service.dart';
@@ -14,6 +17,14 @@ import 'router.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Pre-warm flutter_secure_storage. The first call triggers AndroidX
// Security MasterKey generation (RSA in Keystore) — fast on hardware-backed
// keystores but multi-second on emulator's software-emulated TEE. Kicking
// it off here in parallel with Firebase init hides the latency behind the
// splash instead of paying it on the user's first interaction.
unawaited(TokenStorage().readRefreshToken());
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
final messaging = FirebaseMessaging.instance;

View File

@@ -552,6 +552,13 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.2"
halo_lints:
dependency: "direct dev"
description:
path: "../halo_lints"
relative: true
source: path
version: "0.0.1"
hooks:
dependency: transitive
description:

View File

@@ -58,6 +58,11 @@ dev_dependencies:
build_runner: ^2.4.13
custom_lint: ^0.7.0
riverpod_lint: ^2.6.2
# In-repo lint rules — lives at the repo root so client_app + mitra_app
# share the same set. Adds `no_ref_in_dispose` and any future repo-wide
# guardrails. See halo_lints/lib/halo_lints.dart.
halo_lints:
path: ../halo_lints
flutter:
uses-material-design: true

View File

@@ -0,0 +1,76 @@
import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/dart/ast/visitor.dart';
import 'package:analyzer/error/listener.dart';
import 'package:custom_lint_builder/custom_lint_builder.dart';
/// custom_lint entry point — discovered by the plugin loader.
PluginBase createPlugin() => _HaloLintsPlugin();
class _HaloLintsPlugin extends PluginBase {
@override
List<LintRule> getLintRules(CustomLintConfigs configs) => [
const NoRefInDisposeRule(),
];
}
/// Flags any use of `ref` (read/watch/listen/invalidate/etc.) inside the
/// `dispose()` method of a class extending `ConsumerState` /
/// `ConsumerStatefulWidgetState`.
///
/// Why: Riverpod invalidates `ref` the instant `State.dispose()` enters. The
/// resulting `Bad state: Cannot use "ref" after the widget was disposed.` is
/// caught silently inside `BuildOwner.finalizeTree`, leaves the widget tree
/// half-finalized, and the next-pushed screen appears frozen. Real instances
/// caught in this repo (2026-05-14): `home_screen.dart`, `payment_screen.dart`.
///
/// Fix: move the ref-using cleanup into `deactivate()`, which runs BEFORE
/// `dispose()` while `ref` is still valid. See client_app/CLAUDE.md → Pitfalls.
class NoRefInDisposeRule extends DartLintRule {
const NoRefInDisposeRule() : super(code: _code);
static const _code = LintCode(
name: 'no_ref_in_dispose',
problemMessage:
"Don't use 'ref' in dispose(). Riverpod invalidates ref the moment "
"dispose() runs; the resulting error is swallowed and silently "
"corrupts the widget tree (next screen freezes). Move this cleanup "
"to deactivate() instead. See client_app/CLAUDE.md → Pitfalls.",
);
@override
void run(
CustomLintResolver resolver,
ErrorReporter reporter,
CustomLintContext context,
) {
context.registry.addMethodDeclaration((method) {
if (method.name.lexeme != 'dispose') return;
final cls = method.parent;
if (cls is! ClassDeclaration) return;
// Walk up the supertype chain by name — element resolution is more
// robust but text-match is enough for the two real superclasses we
// care about, and avoids a full type resolve on every method.
final superName = cls.extendsClause?.superclass.name2.lexeme;
if (superName == null) return;
if (!superName.startsWith('Consumer')) return;
method.body.accept(_RefUsageVisitor(reporter));
});
}
}
class _RefUsageVisitor extends GeneralizingAstVisitor<void> {
final ErrorReporter reporter;
_RefUsageVisitor(this.reporter);
@override
void visitSimpleIdentifier(SimpleIdentifier node) {
// Match the bare identifier `ref` whenever it's USED (not declared).
// Declarations like `final ref = ...` are skipped via inDeclarationContext.
if (node.name == 'ref' && !node.inDeclarationContext()) {
reporter.atNode(node, NoRefInDisposeRule._code);
}
super.visitSimpleIdentifier(node);
}
}

349
halo_lints/pubspec.lock Normal file
View File

@@ -0,0 +1,349 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f
url: "https://pub.dev"
source: hosted
version: "85.0.0"
analyzer:
dependency: "direct main"
description:
name: analyzer
sha256: f4ad0fea5f102201015c9aae9d93bc02f75dd9491529a8c21f88d17a8523d44c
url: "https://pub.dev"
source: hosted
version: "7.6.0"
analyzer_plugin:
dependency: "direct main"
description:
name: analyzer_plugin
sha256: a5ab7590c27b779f3d4de67f31c4109dbe13dd7339f86461a6f2a8ab2594d8ce
url: "https://pub.dev"
source: hosted
version: "0.13.4"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.1"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
ci:
dependency: transitive
description:
name: ci
sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13"
url: "https://pub.dev"
source: hosted
version: "0.1.0"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
source: hosted
version: "0.4.2"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
custom_lint:
dependency: transitive
description:
name: custom_lint
sha256: "9656925637516c5cf0f5da018b33df94025af2088fe09c8ae2ca54c53f2d9a84"
url: "https://pub.dev"
source: hosted
version: "0.7.6"
custom_lint_builder:
dependency: "direct main"
description:
name: custom_lint_builder
sha256: "6cdc8e87e51baaaba9c43e283ed8d28e59a0c4732279df62f66f7b5984655414"
url: "https://pub.dev"
source: hosted
version: "0.7.6"
custom_lint_core:
dependency: transitive
description:
name: custom_lint_core
sha256: "31110af3dde9d29fb10828ca33f1dce24d2798477b167675543ce3d208dee8be"
url: "https://pub.dev"
source: hosted
version: "0.7.5"
custom_lint_visitor:
dependency: transitive
description:
name: custom_lint_visitor
sha256: "4a86a0d8415a91fbb8298d6ef03e9034dc8e323a599ddc4120a0e36c433983a2"
url: "https://pub.dev"
source: hosted
version: "1.0.0+7.7.0"
dart_style:
dependency: transitive
description:
name: dart_style
sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
freezed_annotation:
dependency: transitive
description:
name: freezed_annotation
sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
hotreloader:
dependency: transitive
description:
name: hotreloader
sha256: "66871df468fc24eee81f1a0a7cb98acc104716f9b7376d355437b48d633c4ebf"
url: "https://pub.dev"
source: hosted
version: "4.4.0"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
url: "https://pub.dev"
source: hosted
version: "4.11.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.dev"
source: hosted
version: "0.12.20"
meta:
dependency: transitive
description:
name: meta
sha256: df0c643f44ad098eb37988027a8e2b2b5a031fd3977f06bbfd3a76637e8df739
url: "https://pub.dev"
source: hosted
version: "1.18.2"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
pubspec_parse:
dependency: transitive
description:
name: pubspec_parse
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev"
source: hosted
version: "0.28.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
stream_transform:
dependency: transitive
description:
name: stream_transform
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
url: "https://pub.dev"
source: hosted
version: "2.1.1"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
url: "https://pub.dev"
source: hosted
version: "0.7.12"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
uuid:
dependency: transitive
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.dev"
source: hosted
version: "4.5.3"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.dev"
source: hosted
version: "15.2.0"
watcher:
dependency: transitive
description:
name: watcher
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.10.0 <4.0.0"

12
halo_lints/pubspec.yaml Normal file
View File

@@ -0,0 +1,12 @@
name: halo_lints
description: In-repo custom_lint rules for the Halo Bestie client app. Loaded as an analyzer plugin via client_app/analysis_options.yaml.
publish_to: 'none'
version: 0.0.1
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
analyzer: '>=6.0.0 <8.0.0'
analyzer_plugin: ^0.13.0
custom_lint_builder: ^0.7.0

View File

@@ -22,3 +22,27 @@ Flutter mobile application for mental health professionals (mitra/partners).
- API calls go through `ApiClient`; it auto-attaches the JWT from `AuthBridge` and auto-refreshes on 401
- WebSocket handshake (`/api/shared/ws`) sends the same access token in the first frame's `{type:"auth", token}` message
- Mitra role is encoded in the JWT claims (`user_type: "mitra"`) — the backend enforces the role per route; never trust client state alone
## Pitfalls (HARD rules — silent failure modes)
### Never call `ref.read` / `ref.watch` / `ref.listen` from `State.dispose()`
In a `ConsumerStatefulWidget`, Riverpod invalidates `ref` the instant `dispose()` starts. Any `ref.*` call throws `Bad state: Cannot use "ref" after the widget was disposed.`. Flutter catches it inside `BuildOwner.finalizeTree`**so it does not surface as a red-screen crash**. Instead the widget tree is left half-finalized and the NEXT screen freezes (looks like a hang; the app process is alive). Real case in this app: `mitra_chat_screen.dart` (2026-05-14).
**Rule:** any cleanup that needs `ref` goes in `deactivate()`, which runs *before* `dispose()` while `ref` is still valid. Non-Riverpod cleanup (`TextEditingController.dispose()`, `WidgetsBinding.removeObserver`, `StreamSubscription.cancel`) stays in `dispose()`.
```dart
@override
void deactivate() {
ref.read(someProvider.notifier).cleanup();
super.deactivate();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
```
A lint rule (`no_ref_in_dispose` in `halo_lints`) fails `dart run custom_lint` on this pattern. When debugging "screen frozen after navigation", grep the *previous* screen's State for `void dispose()` followed by `ref\.` — that's the first suspect.

View File

@@ -9,6 +9,14 @@
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
analyzer:
plugins:
# Activates custom_lint, which loads:
# - riverpod_lint (dev_dep) — upstream Riverpod rules
# - halo_lints (path: ../halo_lints) — repo-wide rules, e.g.
# `no_ref_in_dispose`. See mitra_app/CLAUDE.md → Pitfalls.
- custom_lint
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`

View File

@@ -10,6 +10,28 @@ import '../constants.dart';
part 'mitra_chat_notifier.g.dart';
/// Per-second session countdown, decoupled from `mitraChatProvider` so the
/// `session_tick` WS frame doesn't invalidate the entire chat state (which
/// would force `ref.watch(mitraChatProvider)` callers — including the chat
/// screen — to rebuild every second). Watched only by the small timer
/// indicator in the chat AppBar. See mitra_app/CLAUDE.md for the wider perf
/// rationale.
///
/// Manually declared (no @Riverpod annotation) to keep .g.dart codegen
/// minimal — one-line Notifier with no special config.
class MitraChatRemainingSecondsNotifier
extends AutoDisposeNotifier<int?> {
@override
int? build() => null;
void update(int? seconds) => state = seconds;
void clear() => state = null;
}
final mitraChatRemainingSecondsProvider =
NotifierProvider.autoDispose<MitraChatRemainingSecondsNotifier, int?>(
MitraChatRemainingSecondsNotifier.new,
);
// States
sealed class MitraChatData {
const MitraChatData();
@@ -304,7 +326,11 @@ class MitraChat extends _$MitraChat {
break;
case WsMessage.sessionTimer:
state = current.copyWith(remainingSeconds: data['remaining_seconds'] as int?);
// Route timer ticks to the dedicated provider so the chat state isn't
// invalidated every second. See [mitraChatRemainingSecondsProvider].
ref
.read(mitraChatRemainingSecondsProvider.notifier)
.update(data['remaining_seconds'] as int?);
break;
case WsMessage.sessionExpired:

View File

@@ -32,8 +32,6 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
final _goodbyeController = TextEditingController();
final _scrollController = ScrollController();
Timer? _typingThrottle;
bool _showBestieBanner = true;
bool _showUserBanner = true;
@override
void initState() {
@@ -43,15 +41,25 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
});
}
@override
void deactivate() {
// Disconnect runs here, NOT in dispose(): modern Riverpod invalidates
// `ref` the instant dispose() starts, and the resulting silent error
// corrupts the widget-tree finalize (next screen freezes). deactivate()
// runs BEFORE dispose() while `ref` is still valid. Same fix pattern
// applied in client_app/home_screen + payment_screen on 2026-05-14.
// ignore: discarded_futures
ref.read(mitraChatProvider.notifier).disconnect();
super.deactivate();
}
@override
void dispose() {
final notifier = ref.read(mitraChatProvider.notifier);
_messageController.dispose();
_goodbyeController.dispose();
_scrollController.dispose();
_typingThrottle?.cancel();
super.dispose();
Future.microtask(() => notifier.disconnect());
}
void _scrollToBottom() {
@@ -82,17 +90,20 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
@override
Widget build(BuildContext context) {
final chatState = ref.watch(mitraChatProvider);
final extState = ref.watch(mitraExtensionProvider);
// Listen for extension complete -> navigate home
// Parent build runs ONCE per lifecycle — there are no ref.watch calls here.
// State changes (messages, typing, status updates, mode flip, sensitivity
// flip) all rebuild only the leaf consumers that watch them:
// - _MitraChatVoicePill → mode flag (via .select)
// - _MitraChatTopicToggle → topicSensitivity (via .select) + config
// - _MitraChatTimerAction → mitraChatRemainingSecondsProvider
// - _MitraChatBodyContent → full chatProvider + extensionProvider
// Pattern mirrors client_app/chat_screen post-refactor (2026-05-14).
ref.listen(mitraExtensionProvider, (prev, next) {
if (next is ExtensionCompleteData) {
context.go('/home');
}
});
// Listen for chat state changes
ref.listen(mitraChatProvider, (prev, next) {
if (next is MitraChatConnectedData) {
_scrollToBottom();
@@ -106,10 +117,6 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
}
});
final currentSensitivity = chatState is MitraChatConnectedData
? chatState.topicSensitivity
: TopicSensitivity.regular;
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.white,
@@ -130,41 +137,53 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
overflow: TextOverflow.ellipsis,
),
),
if (chatState is MitraChatConnectedData &&
chatState.mode == SessionMode.call) ...[
const SizedBox(width: 8),
_buildVoiceCallPill(),
],
const _MitraChatVoicePill(),
],
),
actions: [
if (chatState is MitraChatConnectedData) _buildTopicToggle(chatState),
if (chatState is MitraChatConnectedData && chatState.remainingSeconds != null)
Padding(
padding: const EdgeInsets.only(right: 16),
child: Center(
child: Text(
'${chatState.remainingSeconds}s',
style: TextStyle(
color: chatState.remainingSeconds! < 30 ? Colors.red : Colors.black,
fontWeight: FontWeight.bold,
),
),
),
),
_MitraChatTopicToggle(sessionId: widget.sessionId),
const _MitraChatTimerAction(),
],
),
body: Column(
children: [
if (currentSensitivity == TopicSensitivity.sensitive)
_buildSensitivityHeader(),
Expanded(child: _buildBody(chatState, extState)),
],
body: _MitraChatBodyContent(
sessionId: widget.sessionId,
customerName: widget.customerName,
messageController: _messageController,
goodbyeController: _goodbyeController,
scrollController: _scrollController,
onSend: _sendMessage,
onTextChanged: _onTextChanged,
),
);
}
}
Widget _buildVoiceCallPill() {
/// AppBar voice-call mode badge. Watches only the `mode` field of the chat
/// state — the conditional collapses to a bool via `.select`, so this widget
/// rebuilds only when the mode actually flips (essentially never during a
/// session) and the surrounding AppBar stays still on every message/typing
/// state change.
class _MitraChatVoicePill extends ConsumerWidget {
const _MitraChatVoicePill();
@override
Widget build(BuildContext context, WidgetRef ref) {
final isCall = ref.watch(mitraChatProvider.select(
(s) => s is MitraChatConnectedData && s.mode == SessionMode.call,
));
if (!isCall) return const SizedBox.shrink();
return const Padding(
padding: EdgeInsets.only(left: 8),
child: _VoiceCallPillBody(),
);
}
}
class _VoiceCallPillBody extends StatelessWidget {
const _VoiceCallPillBody();
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: const BoxDecoration(
@@ -181,35 +200,31 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
),
);
}
}
Widget _buildSensitivityHeader() {
const theme = SensitivityTheme.sensitive;
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12),
color: theme.badgeBg,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.warning_amber_rounded, size: 16, color: theme.badgeFg),
const SizedBox(width: 6),
Text(
'Topik sensitif',
style: TextStyle(
color: theme.badgeFg,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
/// AppBar topic-sensitivity flag/lock action. Watches only `topicSensitivity`
/// (via `.select`) plus the `sensitivityConfigProvider`. Confirmation dialog
/// + snackbars + `flipTopic` call all live here so the parent screen doesn't
/// need to know about topic state.
class _MitraChatTopicToggle extends ConsumerStatefulWidget {
final String sessionId;
const _MitraChatTopicToggle({required this.sessionId});
Widget _buildTopicToggle(MitraChatConnectedData state) {
@override
ConsumerState<_MitraChatTopicToggle> createState() => _MitraChatTopicToggleState();
}
class _MitraChatTopicToggleState extends ConsumerState<_MitraChatTopicToggle> {
@override
Widget build(BuildContext context) {
final sensitivity = ref.watch(mitraChatProvider.select((s) {
if (s is MitraChatConnectedData) return s.topicSensitivity;
return null;
}));
if (sensitivity == null) return const SizedBox.shrink();
final configAsync = ref.watch(sensitivityConfigProvider);
final config = configAsync.value ?? SensitivityConfig.defaults;
final isSensitive = state.topicSensitivity == TopicSensitivity.sensitive;
final isSensitive = sensitivity == TopicSensitivity.sensitive;
final locked = config.oneWayLatch && isSensitive;
return Tooltip(
@@ -223,16 +238,16 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
isSensitive ? Icons.flag : Icons.outlined_flag,
color: isSensitive ? SensitivityTheme.sensitive.badgeBg : Colors.grey.shade600,
),
onPressed: locked ? null : () => _onTopicTogglePressed(state, config),
onPressed: locked ? null : () => _onTopicTogglePressed(sensitivity, config),
),
);
}
Future<void> _onTopicTogglePressed(
MitraChatConnectedData state,
TopicSensitivity current,
SensitivityConfig config,
) async {
final toValue = state.topicSensitivity == TopicSensitivity.sensitive
final toValue = current == TopicSensitivity.sensitive
? TopicSensitivity.regular
: TopicSensitivity.sensitive;
@@ -285,6 +300,95 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
);
}
}
}
/// Owns the chat-body subtree. Watches `mitraChatProvider` and
/// `mitraExtensionProvider` — so a WS message / typing / status / extension
/// update rebuilds *this* widget only, not the parent Scaffold or AppBar.
/// Entry-banner dismiss state moved here from the parent so its setState
/// doesn't propagate back up either.
class _MitraChatBodyContent extends ConsumerStatefulWidget {
final String sessionId;
final String customerName;
final TextEditingController messageController;
final TextEditingController goodbyeController;
final ScrollController scrollController;
final VoidCallback onSend;
final ValueChanged<String> onTextChanged;
const _MitraChatBodyContent({
required this.sessionId,
required this.customerName,
required this.messageController,
required this.goodbyeController,
required this.scrollController,
required this.onSend,
required this.onTextChanged,
});
@override
ConsumerState<_MitraChatBodyContent> createState() => _MitraChatBodyContentState();
}
class _MitraChatBodyContentState extends ConsumerState<_MitraChatBodyContent> {
bool _showBestieBanner = true;
bool _showUserBanner = true;
// Phase 4 ESP topic display labels. Mirrors the customer-side `EspTopic`
// enum's `label` property — we only need to read these here, not write.
static const Map<String, String> _espTopicLabels = {
'relationship': 'Hubungan',
'family': 'Keluarga',
'work': 'Pekerjaan',
'study': 'Sekolah / Kuliah',
'finance': 'Keuangan',
'health': 'Kesehatan',
'friendship': 'Pertemanan',
'self_worth': 'Self-worth',
'anxiety': 'Kecemasan',
'loneliness': 'Kesepian',
'grief': 'Kehilangan',
'identity': 'Identitas',
};
@override
Widget build(BuildContext context) {
final chatState = ref.watch(mitraChatProvider);
final extState = ref.watch(mitraExtensionProvider);
final isSensitive = chatState is MitraChatConnectedData &&
chatState.topicSensitivity == TopicSensitivity.sensitive;
return Column(
children: [
if (isSensitive) _buildSensitivityHeader(),
Expanded(child: _buildBody(chatState, extState)),
],
);
}
Widget _buildSensitivityHeader() {
const theme = SensitivityTheme.sensitive;
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12),
color: theme.badgeBg,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.warning_amber_rounded, size: 16, color: theme.badgeFg),
const SizedBox(width: 6),
Text(
'Topik sensitif',
style: TextStyle(
color: theme.badgeFg,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
Widget _buildBody(MitraChatData chatState, ExtensionData extState) {
if (chatState is MitraChatConnectingData) {
@@ -352,7 +456,7 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
// item (above the first message bubble). Info-only.
Expanded(
child: ListView.builder(
controller: _scrollController,
controller: widget.scrollController,
padding: const EdgeInsets.all(16),
itemCount: state.messages.length +
(state.topics.isNotEmpty ? 1 : 0),
@@ -385,23 +489,6 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
);
}
// Phase 4 ESP topic display labels. Mirrors the customer-side `EspTopic`
// enum's `label` property — we only need to read these here, not write.
static const Map<String, String> _espTopicLabels = {
'relationship': 'Hubungan',
'family': 'Keluarga',
'work': 'Pekerjaan',
'study': 'Sekolah / Kuliah',
'finance': 'Keuangan',
'health': 'Kesehatan',
'friendship': 'Pertemanan',
'self_worth': 'Self-worth',
'anxiety': 'Kecemasan',
'loneliness': 'Kesepian',
'grief': 'Kehilangan',
'identity': 'Identitas',
};
Widget _buildTopicChipsRow(List<String> topics) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
@@ -510,10 +597,10 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
children: [
Expanded(
child: TextField(
controller: _messageController,
onChanged: _onTextChanged,
controller: widget.messageController,
onChanged: widget.onTextChanged,
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(),
onSubmitted: (_) => widget.onSend(),
decoration: InputDecoration(
hintText: 'Ketik Pesan',
hintStyle: TextStyle(color: Colors.grey.shade400),
@@ -535,7 +622,7 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
),
child: IconButton(
icon: const Icon(Icons.send, color: Colors.white, size: 20),
onPressed: _sendMessage,
onPressed: widget.onSend,
),
),
],
@@ -632,7 +719,7 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
const Text('Tuliskan pesan terakhirmu untuk Customer', textAlign: TextAlign.center),
const SizedBox(height: 24),
TextField(
controller: _goodbyeController,
controller: widget.goodbyeController,
maxLines: 3,
decoration: InputDecoration(
hintText: 'Terima kasih sudah curhat...',
@@ -644,7 +731,7 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
onPressed: extState is ExtensionSubmittingData
? null
: () {
final text = _goodbyeController.text.trim();
final text = widget.goodbyeController.text.trim();
if (text.isNotEmpty) {
ref.read(mitraExtensionProvider.notifier).submitGoodbye(
widget.sessionId, text,
@@ -695,7 +782,7 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
),
Expanded(
child: ListView.builder(
controller: _scrollController,
controller: widget.scrollController,
padding: const EdgeInsets.all(16),
itemCount: state.messages.length,
itemBuilder: (context, index) {
@@ -711,3 +798,30 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
);
}
}
/// Tiny AppBar action that watches only [mitraChatRemainingSecondsProvider].
/// Decoupling the timer from the chat state means a WS `session_tick` frame
/// rebuilds *only* this widget (a single Text), not the surrounding AppBar,
/// Scaffold body, message ListView, or input bar. This is the per-second
/// hotspot the wider chat-screen perf work targets.
class _MitraChatTimerAction extends ConsumerWidget {
const _MitraChatTimerAction();
@override
Widget build(BuildContext context, WidgetRef ref) {
final seconds = ref.watch(mitraChatRemainingSecondsProvider);
if (seconds == null) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(right: 16),
child: Center(
child: Text(
'${seconds}s',
style: TextStyle(
color: seconds < 30 ? Colors.red : Colors.black,
fontWeight: FontWeight.bold,
),
),
),
);
}
}

View File

@@ -504,6 +504,13 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.2"
halo_lints:
dependency: "direct dev"
description:
path: "../halo_lints"
relative: true
source: path
version: "0.0.1"
hooks:
dependency: transitive
description:

View File

@@ -40,6 +40,11 @@ dev_dependencies:
build_runner: ^2.4.13
custom_lint: ^0.7.0
riverpod_lint: ^2.6.2
# In-repo lint rules — shared with client_app from the repo root. Adds
# the `no_ref_in_dispose` rule and any future repo-wide guardrails.
# See halo_lints/lib/halo_lints.dart and mitra_app/CLAUDE.md → Pitfalls.
halo_lints:
path: ../halo_lints
flutter:
uses-material-design: true

View File

@@ -0,0 +1,82 @@
# S10 Chat Screen — Figma Rewrite + Bug Fixes (Test Plan)
> Sub-plan of [phase4-customer-flow-plan.md](phase4-customer-flow-plan.md).
> Source-of-truth visuals: [requirement/Figma/screens/session.jsx](Figma/screens/session.jsx#L150) (S10Chat, lines 150284) +
> [requirement/Figma/screens/v3.jsx](Figma/screens/v3.jsx#L423) (HBChatExpiredBanner).
> Decided 2026-05-12: discard the pre-Figma S10 implementation and follow Figma strictly.
## Scope
1. Replace [client_app/lib/features/chat/screens/chat_screen.dart](../client_app/lib/features/chat/screens/chat_screen.dart) with a Figma-faithful S10 implementation.
2. Rewrite [client_app/lib/features/chat/widgets/chat_expired_banner.dart](../client_app/lib/features/chat/widgets/chat_expired_banner.dart) to match `HBChatExpiredBanner` (brand-pink, copy "habis nih... mau lanjutin curhat sama {name}?", white `perpanjang` chip).
3. Drop: the "[Bestie/User] Sudah Memasuki Ruangan" entry banners, the AppBar `akhiri` button, the doodle-pattern background, the voice-call mode pill.
4. Add: HBOrb avatar (placeholder gradient), "online · ngetik..." inline status, SISA WAKTU pill, 3px progress bar under header, animated 3-dot typing pill in messages list, 2-minute soft-warning inline banner, `+` attachment button on input bar (no-op for now), `terenkripsi · gak disimpan 🔒` footer.
## Bug fixes shipped alongside the rewrite
### Bug 1 — 3-min snackbar doesn't fire reliably
**Symptom:** when the chat timer drops below 3 minutes, no snackbar reminder ("sisa 3 menit lagi ya 🤍") appears.
**Cause:** [chat_screen.dart](../client_app/lib/features/chat/screens/chat_screen.dart) only listened to the backend `session_warning` WebSocket event. In dev/test scenarios where the backend doesn't emit that event (e.g. force-confirmed payments via `/internal/_test/force-confirm-payment`), the snackbar never fires.
**Fix:** fire the snackbar locally as soon as `chatRemainingSecondsProvider` crosses below 180s, using `_threeMinShown` to dedupe with the backend event. Re-arm when an extension pushes remaining back above 180s so the *next* crossing also fires.
### Bug 2 — Floating expired banner sticks after extension
**Symptom:** after extending a session via the time-up sheet, the floating "habis nih..." banner stays on-screen and the SISA WAKTU pill in the header is invisible (timer not ticking).
**Cause:** [extension.service.js#finalizeExtension](../backend/src/services/extension.service.js#L185) sends `EXTENSION_RESPONSE` to the customer **without** the freshly-extended `expires_at`. The client's local `chatRemainingSecondsProvider` is computed off `chatState.expiresAt`, which still points at the just-elapsed moment from the `SESSION_EXPIRED` snap. The provider yields 0 and returns. The new `expires_at` only reaches the client on the next periodic `SESSION_TIMER` ping — up to 60s later.
**Fix (backend):** include `expires_at: extended.expires_at` in the accept-side `EXTENSION_RESPONSE` payload (`extension.service.js`).
**Fix (client):** in [chat_notifier.dart](../client_app/lib/core/chat/chat_notifier.dart) `extensionResponse` case, parse `expires_at` from the payload when `accepted=true` and update `expiresAt` on the state. The provider re-runs, computes a positive remaining, and the banner/pill recover immediately.
---
## Test plan
### Backend Vitest — [test/services/extension.service.test.js](../backend/test/services/extension.service.test.js) (new, 2/2 passing)
| # | Test | Setup | Assert |
|---|------|-------|--------|
| 1 | Accepted extension broadcasts `expires_at` | Seed active session w/ `expires_at = now+30s`, confirmed extension payment, pending extension row. Call `respondToExtension(..., accepted=true)`. | `EXTENSION_RESPONSE` payload sent to customer has `accepted=true`, `duration_minutes=10`, `expires_at` set, ~10min after the seeded baseline. DB row matches. |
| 2 | Rejected extension omits `expires_at` | Same setup, `accepted=false` | `EXTENSION_RESPONSE` payload has `accepted=false` and **no** `expires_at` (timer wasn't extended). |
### Manual smoke (operator)
Both scenarios run on the emulator with the dev backend + a real-or-stubbed mitra. Use `.maestro/scripts/mark_latest_payment_paid.js` to force-confirm payments and skip Xendit.
**S10-A. 3-min snackbar fires from local tick.**
1. Pair into a session with a short duration (e.g. 5 min).
2. Wait until the SISA WAKTU pill shows ≤ 3:00.
3. **Expect:** snackbar "sisa 3 menit lagi ya 🤍" appears once.
4. Send a message — snackbar should NOT re-fire on rebuild.
**S10-B. 3-min snackbar re-arms after extension.**
1. Continue from S10-A (snackbar already fired, timer < 3 min).
2. Tap the soft-warning's `+30 menit` to open the time-up sheet.
3. Pick a tier, force-confirm payment.
4. **Expect:** SISA WAKTU pill resumes counting (e.g. ~14:00 if you picked +12min), progress bar refills, soft-warning + expired banner gone.
5. Let the timer drift down again to < 3 min.
6. **Expect:** snackbar fires again.
**S10-C. Floating expired banner clears after extension.**
1. Pair into a session and let the timer expire (or use `.maestro/scripts/force_session_expires_at.js` to short-circuit).
2. **Expect:** floating brand-pink "habis nih... mau lanjutin curhat sama {name}?" banner appears, SISA WAKTU pill disappears (remaining ≤ 0).
3. Tap `perpanjang`, pick a tier, force-confirm payment.
4. **Expect (within ~1s of backend ack):** banner disappears, SISA WAKTU pill returns with the new remaining, progress bar redraws. Input bar is reactivated.
**S10-D. Voice-call session** *(known gap, not a regression)*
- Voice-call mode badge was dropped per strict-Figma. If voice-call sessions need an indicator, raise as a follow-up.
**S10-E. Mid-session manual end** *(known gap)*
- Figma S10 has no `akhiri` button. PricingBottomSheet doesn't currently have a "cukup, akhiri sesi" option either, so manual end mid-session is unreachable until either (a) the time-up sheet grows that button or (b) we add an end-session affordance per business call.
### Maestro automation
Deferred — the Phase 4 Stage 9 Semantics regression on `SHome1st` still blocks the upstream onboarding flows from reaching S10 in Maestro (see [phase4-esp-removal-usp-gate.md "Known blocker"](phase4-esp-removal-usp-gate.md#known-blocker)). Once those flows unblock, add:
- `09_chat_three_min_snackbar.yaml` — covers S10-A + S10-B
- `10_chat_extension_recovers_timer.yaml` — covers S10-C
---
## Done criteria
- [x] Backend Vitest 2/2 green for `EXTENSION_RESPONSE.expires_at`.
- [x] `flutter analyze` clean on `chat_screen.dart` + `chat_expired_banner.dart` + `chat_notifier.dart`.
- [ ] Manual smoke S10-A, S10-B, S10-C all green on emulator-5554.
- [ ] Side-by-side visual diff vs `requirement/Figma/screens/session.jsx::S10Chat` — no obvious drift.

View File

@@ -0,0 +1,224 @@
# ESP Removal + USP One-Time Gate — Implementation & Test Plan
> Sub-plan of [phase4-customer-flow-plan.md](phase4-customer-flow-plan.md).
> Source-of-truth diagram: [flow_customer.mermaid.md §2](flow_customer.mermaid.md).
> Business decision: 2026-05-12 — retire S5 ESP entirely; show S5b USP at most once per user.
## Scope
1. Remove the S5 ESP screen + state from `client_app`.
2. Rewire `VerifChoiceSheet` to go straight to a `usp_seen?` gate, then USP, then the original next step (S3a for verified, PickMethod for anon).
3. Add a `customers.usp_seen` boolean to the backend; expose on `/api/client/me`; add `POST /api/client/usp-seen` to set it.
4. Add a Riverpod `uspSeenProvider` backed by `SharedPreferences` with DB sync on login and on dismissal.
5. Update Maestro flows + add new flows for the gate behaviour.
Out of scope: changing USP copy/visuals; reordering USP relative to OTP (business accepted the cross-device first-view edge case).
---
## Build order
The work splits into 5 ordered stages. Backend lands first so the client has a real endpoint to call.
### Stage 1 — Backend: schema + read + write
**Files:**
- `backend/src/db/migrate.js` — append a Phase-4 ALTER block:
```sql
ALTER TABLE customers ADD COLUMN IF NOT EXISTS usp_seen BOOLEAN NOT NULL DEFAULT FALSE;
```
- `backend/src/services/customer.service.js`
- Add `usp_seen` to `CUSTOMER_SELECT` so every read includes it.
- Add `markCustomerUspSeen(customerId)` — idempotent UPDATE that sets `usp_seen = TRUE` and returns the row via `CUSTOMER_SELECT`.
- `backend/src/routes/public/client.auth.routes.js`
- Already returns the customer from `getCustomerById` on `/api/client/me`. The new column rides along automatically once it's in `CUSTOMER_SELECT`.
- Add `POST /api/client/usp-seen` handler: requires JWT, calls `markCustomerUspSeen`, returns the updated customer. No request body needed.
**Acceptance:**
- New customer row has `usp_seen = false`.
- `POST /api/client/usp-seen` flips it; second POST is a no-op (still returns true).
- `/api/client/me` response includes `usp_seen` for both first-time and returning users.
### Stage 2 — client_app: `uspSeenProvider` + DB hydrate
**New file:** `client_app/lib/features/onboarding/usp_seen_provider.dart`
- Async-init `Notifier` (or `AsyncNotifier`) that:
- On build, reads SharedPreferences key `usp_seen` (default false).
- Exposes `bool get hasSeen` synchronously after init.
- `Future<void> markSeen()`:
1. Write `true` to SharedPreferences.
2. If JWT is present (authProvider state is `AuthAuthenticatedData`), call `POST /api/client/usp-seen` via `ApiClient` — fire-and-forget with logging; don't block UX on the network call.
- Add `hydrateFromCustomer(Customer c)` — call from auth bootstrap (e.g. wherever `/api/client/me` is fetched and stored in `AuthAuthenticatedData`). OR-merge: if `c.uspSeen == true`, write `true` to SharedPreferences.
**Edit:** the auth notifier that already calls `/api/client/me` on app boot — add a call to `uspSeenProvider.hydrateFromCustomer(...)` after the response lands. (Per Explore: `auth_notifier.dart` has the `AuthAuthenticatedData` carrying the profile.)
**Acceptance:**
- Fresh install: provider returns `false`.
- After `markSeen()`: provider returns `true`; SharedPreferences key set; backend hit (if auth'd).
- Login on a fresh device where DB has `usp_seen=true`: provider returns `true` after auth hydrate completes.
### Stage 3 — client_app: rewire VerifChoiceSheet → USP gate
**Edit:** `client_app/lib/features/auth/widgets/verif_choice_sheet.dart`
- Replace `routeForVerifChoice()` body. New logic (pseudo):
```dart
final seen = ref.read(uspSeenProvider).hasSeen;
if (choice == VerifChoice.verifWA) {
if (seen) context.push('/auth/register'); // straight to S3a
else context.push('/onboarding/verif/usp');
} else {
if (seen) context.push('/payment/method-pick'); // straight to PickMethod
else context.push('/onboarding/anon/usp');
}
```
(Exact target routes per existing `router.dart` registrations.)
**Edit:** `client_app/lib/features/onboarding/screens/usp_screen.dart`
- On the primary "Continue" / next CTA tap, `await ref.read(uspSeenProvider.notifier).markSeen()` BEFORE navigating to the next route.
- The existing post-USP routing (verified → S3a, anon → PickMethod) stays — the `markSeen()` call just precedes it.
**Edit:** `client_app/lib/features/auth/screens/otp_screen.dart` (or wherever the OTP-Blocked popup lives) — the fallback to anon path. Currently it pushes ESP; change to the same USP-gate logic above (`uspSeenProvider.hasSeen ? PickMethod : USP`).
**Acceptance:**
- First-time verified flow: VerifChoice "verif WA" → USP (with markSeen on continue) → S3a.
- Second-time verified flow: VerifChoice "verif WA" → S3a directly.
- First-time anon flow: VerifChoice "tanpa verif" → USP → PickMethod.
- Second-time anon flow: VerifChoice "tanpa verif" → PickMethod directly.
- OTP-Blocked fallback respects the gate.
### Stage 4 — client_app: delete ESP
This is the cleanup step; it intentionally runs *after* the new gate is wired so we never have a moment where the build is broken.
**Delete:**
- `client_app/lib/features/onboarding/screens/esp_screen.dart`
- `client_app/lib/features/onboarding/esp_state.dart` (the two `espSelectionProvider` / `espSkippedProvider`)
**Edit:** `client_app/lib/router.dart`
- Remove the two `/onboarding/verif/esp` and `/onboarding/anon/esp` `GoRoute` entries.
- Leave the `/onboarding/*` redirect carve-out intact (USP still uses it).
**Verify:** `flutter analyze` clean — no dangling imports, no orphan references to `EspTopic`, `espSelectionProvider`, `espSkippedProvider`.
### Stage 5 — Tests
Detailed below in the **Test plan** section. Updates:
- Existing Maestro flows `02_onboarding_verified.yaml` + `03_onboarding_anon.yaml` need to drop ESP steps and add gate-aware assertions.
- New Vitest cases for the migration + service + route.
- New Maestro flow for the USP-skip-on-second-run case.
---
## Data contract
### `customers.usp_seen` (new column)
- Type: `BOOLEAN NOT NULL DEFAULT FALSE`
- Set true only on `POST /api/client/usp-seen` or via direct backfill.
- Never read by anything except `/api/client/me` and the dedicated POST handler.
### `POST /api/client/usp-seen`
- Auth: JWT required (same middleware as `/me`).
- Request: empty body.
- Response: 200 with the updated customer object (same shape as `/me`).
- Errors: 401 on missing/invalid JWT; 404 if customer row doesn't exist (shouldn't happen in normal flow).
- Idempotent: calling twice is fine.
### `/api/client/me` (extended)
- Existing payload + `usp_seen: boolean`.
### SharedPreferences key (client)
- Key: `usp_seen`
- Value: `bool` (default `false`).
- Owned by `uspSeenProvider`; no other code reads/writes it directly.
---
## Test plan
### Unit tests (Vitest, in `backend/test/`)
**New file:** `backend/test/customer.usp-seen.test.js`
| # | Test | Setup | Assert |
|---|------|-------|--------|
| 1 | Migration default | Insert customer via `createCustomerWithIdentity` with no usp_seen | `getCustomerById(...).usp_seen === false` |
| 2 | `markCustomerUspSeen` flips flag | Customer with `usp_seen=false` | After call, row has `usp_seen=true`; return value's `usp_seen` is `true` |
| 3 | Idempotent | Customer with `usp_seen=true` | Calling again still returns `usp_seen=true`; no error |
| 4 | `POST /api/client/usp-seen` requires auth | No `Authorization` header | 401 |
| 5 | `POST /api/client/usp-seen` happy path | Authed customer, `usp_seen=false` | 200, response `usp_seen=true`, DB row `usp_seen=true` |
| 6 | `/api/client/me` includes flag | Authed customer | Response has `usp_seen` key (true or false) |
Target: 6/6 green via `npm test` in `backend/`.
### Flutter widget/integration tests
These are not currently a major surface in this repo (Maestro is the main client-side gate). Skip Flutter-level widget tests unless something breaks.
### Maestro flows (`client_app/.maestro/flows/`)
**Existing flow updates:**
- **`02_onboarding_verified.yaml`** — first-time-verified path
- Remove: any `assertVisible` / tap targets on ESP chips ("Hubungan", "Lewati").
- Update sequence: VerifChoiceSheet → tap "Verif WA Rp2k" → **assert USP visible** → tap continue → S3a WhatsApp input → ...
- Add at the start: `runScript: scripts/reset_phone_and_local.js` so the run always starts from a clean state (no `usp_seen` in DB, no SharedPreferences value).
- **`03_onboarding_anon.yaml`** — first-time-anonymous path
- Remove: ESP chip taps / "Lewati" tap.
- Update sequence: VerifChoiceSheet → tap "tanpa verif Rp5k" → **assert USP visible** → tap continue → `/payment/method-pick`.
- Add reset script at start.
**New flows / deferrals:**
The "second-run skip" and "DB hydrate" cases turned out hard to script in Maestro: once a customer logs in (anonymously or with phone), the app is in an authenticated session and the only path back to `VerifChoiceSheet` (where the gate is consulted) is via logout, which clears local SharedPreferences too. Returning-user CTAs go through `BestieChoiceSheet`, not `VerifChoiceSheet`, so the gate is never re-evaluated on the returning path.
What we *do* have:
- Flows `02` and `03` (first run, USP visible, ESP not visible) — covered above.
- Vitest 8/8 covers the backend: column default, `markCustomerUspSeen`, route 401/200/403, `/me` payload before + after the flag flips.
What stays in **manual smoke** (operator-driven, documented in the next section):
1. Local flag persists across `stopApp/launchApp` — `adb shell pm clear` should NOT happen between runs; verify USP is skipped on second walk through VerifChoice.
2. DB hydrate — pre-seed a customer row with `usp_seen=true` via control center or psql, sign in via phone OTP, verify USP is skipped on first ever appearance.
3. OTP-blocked popup — exit via "lanjut tanpa verif" still lands at `/payment/method-pick`. (Pre-USP-gate this was a direct redirect; the gate doesn't fire on this path because USP has already been shown/skipped upstream.)
**Known blocker (2026-05-12):** flows `02` and `03` had their ESP steps removed and USP gate assertions added, but the runtime can't currently execute them end-to-end. The new `SHome1st` view (Phase 4 Stage 9) wraps the home column in a single Semantics node, so the `"aku mau curhat"` CTA's `text` attribute reads as empty (its label only lives in the parent's merged `accessibilityText`). Maestro's `text:` matcher can't locate the button, blocking the entire flow before USP is even reached. This is a Stage-9 accessibility regression, not an ESP/USP issue — the flow YAML edits are correct and will pass once SHome1st's CTA is wrapped in its own `Semantics(label: '…', button: true)` or given a `Key`.
### Manual smoke (real device)
After Maestro is green on emulator, hand-run on the physical Samsung:
1. Fresh install → "aku mau curhat" → name → VerifChoice → verif WA → USP (visible) → S3a → OTP → S6 paywall.
2. Force-stop → relaunch → "aku mau curhat" → VerifChoice → verif WA → S3a (USP skipped) → ...
3. Same but anon path.
4. Uninstall + reinstall → login with same phone → "aku mau curhat" → verify USP skipped (DB hydrate proved end-to-end).
### Visual regression
`flutter analyze` clean. Spot-check VerifChoiceSheet, USP screen, OTP-Blocked popup in the emulator — no broken navigation, no leftover ESP icon/copy.
---
## Rollout & migration
- Migration is additive (NOT NULL DEFAULT FALSE), safe to run on existing DB. All existing customers come out with `usp_seen=false` — meaning every returning user will see USP one more time on next "aku mau curhat". Business accepted this.
- No backfill needed.
- Cloud Run rollout: backend first (migration runs on boot), then client_app build.
## Risks
1. **Provider init race** — if `uspSeenProvider` is read before SharedPreferences finishes loading, the gate could return false (default) and show USP unnecessarily. Mitigation: use `AsyncNotifier` and gate the VerifChoice navigation on the loaded state, or read SharedPreferences synchronously at app boot before the first VerifChoice render.
2. **Network failure on `markSeen()`** — if `POST /usp-seen` fails after local flag is set, DB stays false. Next session uses local (still true) so user UX is fine. On a new device, USP shows once more. Acceptable per the cross-device edge case decision.
3. **Two-tab race** — not applicable; mobile app.
---
## Done criteria
- [ ] `customers.usp_seen` column exists in dev DB.
- [ ] `POST /api/client/usp-seen` returns 200 for authed call.
- [ ] `/api/client/me` payload includes `usp_seen`.
- [ ] Vitest 6/6 green on the new test file.
- [ ] No `esp_screen.dart` / `esp_state.dart` / `/onboarding/*/esp` routes in client_app.
- [ ] `flutter analyze` clean on client_app.
- [ ] Maestro: `02`, `03`, `04`, `05`, `06` green on the Client_Phone emulator.
- [ ] Manual smoke 14 above pass on physical device.
- [ ] `TECH_DEBT.md` "S5 ESP screen retired" entry closed / removed once cleanup lands.

View File

@@ -0,0 +1,78 @@
# Resume — 2026-05-15
> Cross-device pickup note. Mirror of the local Claude memory `project_resume_next.md` so this is reachable on any machine that clones the repo. Delete this file when fully resumed.
Paused **2026-05-14 evening**. Chat-screen perf refactor done in code on both apps; release rebuild + install + retest on mitra is the gating step that didn't complete (S21 Ultra unplugged before the final build could finish).
## What needs doing tomorrow — in order
### 1. Rebuild + install mitra release on S21 Ultra
Code is on disk in `mitra_app/lib/features/chat/screens/mitra_chat_screen.dart` (full refactor) and `mitra_app/lib/core/chat/mitra_chat_notifier.dart` (timer-extraction provider). The APK currently on the S21 Ultra only has the timer-extraction fix — NOT the full body/AppBar split.
```bash
# Plug the S21 Ultra, authorize USB debugging if needed:
adb devices # confirm device shows as `device`, not `unauthorized`
# Build + install + run:
cd mitra_app
flutter run -d <S21_DEVICE_ID> --release --dart-define=API_BASE_URL=http://<DEV_MACHINE_IP>:3000
```
Yesterday's IDs (will differ on a new host):
- S21 Ultra: `RRCR100NN7Z`
- Customer SM-A530F: `52002a5db8e0c46b`
- Dev machine static IP: `192.168.88.247`
Backend dev server (`cd backend && npm run dev`) needs to be running first. The dev `API_BASE_URL` defaults to production if you forget the dart-define.
### 2. Test mitra chat under release
After install: open a chat session, send a few messages, watch the partner type. Expected:
- Timer ticks every 1s rebuild ONLY the timer pill in the AppBar.
- Sending/receiving messages rebuilds ONLY the body widget.
- Typing pulses don't cause whole-screen flicker.
Bar: it should feel as snappy as the customer app does now (which is the reference point).
### 3. Verify customer waiting_payment_screen navigation patch
Yesterday the customer app got stuck on "menunggu pembayaran" after a payment was confirmed (polling stopped but `addPostFrameCallback(context.go(...))` never fired). Patched with belt-and-suspenders in `waiting_payment_screen.dart::_navigateTerminal``Future.microtask` + `addPostFrameCallback` redundancy.
End-to-end test path:
1. Customer app: tap "aku mau curhat" → pick tier → create payment.
2. SQL-confirm the payment (or use the dev confirm endpoint).
3. Watch the waiting screen — should advance off "menunggu pembayaran" into notif-gate → searching within ~3s (one poll cycle).
If still stuck: I added `print` instrumentation would surface debug-mode only; consider running customer in debug to capture log output.
### 4. If mitra chat is still laggy after #1
Next suspect: message-list rebuilds on every state change re-iterate visible ListView.builder items. Try:
- Convert `_MessageBubble` to `const` constructor (immutable inputs).
- Wrap bubbles in `RepaintBoundary` to isolate paint.
Don't touch until #1 confirms whether the body-extraction refactor was sufficient.
## What landed today (already on disk / committed)
- **Dispose-in-ref fix** in `home_screen.dart`, `payment_screen.dart` (customer), `mitra_chat_screen.dart` (mitra). Pattern: ref-using cleanup goes in `deactivate()`, not `dispose()`. Symptom of regression: next screen looks frozen after navigation, even though app is alive.
- **`halo_lints`** package at repo root with `no_ref_in_dispose` rule. Wired into both apps' `analysis_options.yaml`. Also activates the already-installed `riverpod_lint` package (which ships `avoid_ref_inside_state_dispose` for the same case).
- **CLAUDE.md Pitfalls section** added to `client_app/CLAUDE.md` and `mitra_app/CLAUDE.md` documenting the dispose-ref landmine.
- **Customer chat refactor** — `chat_screen.dart` split into `_ChatHeader` + `_ChatBodySection` + `_TimerBanner`. Parent has zero `ref.watch`.
- **Mitra chat refactor** — `mitra_chat_screen.dart` mirrors customer pattern: `_MitraChatBodyContent`, `_MitraChatTopicToggle`, `_MitraChatVoicePill`, `_MitraChatTimerAction`. Plus the `mitraChatRemainingSecondsProvider` for per-second ticks.
- **Customer waiting screen nav** — `Future.microtask` + `addPostFrameCallback` redundancy at terminal status.
- **Phase 4 Option A retryable blast-failure** — backend `expirePairingRequest` + all-rejected use `recordIntermediateFailure` instead of `failPaymentSession`; WS payload has `is_terminal: false`; client carries `topicSensitivity` through `PairingFailedData`; "coba cari lagi" CTA re-blasts on the same payment via `retryBlast()`. Test updated to match new semantics.
## Hazards / gotchas to remember
- **Release mode is the bar.** Debug-mode JIT on both phones (SM-A530F + S21 Ultra) was unusably laggy. Always rebuild release to test real perf.
- **`node --watch` doesn't pick up newly-added module files.** When you add a brand-new route file or service, kill + restart the backend dev server. Don't trust the auto-reload for new files.
- **AVD on the dev host is unusable for interactive rendering** — use the physical devices.
- **`.claude/settings.local.json` + `.claude/agent-memory/` + `client_app/devtools_options.yaml`** stay modified — local-only, never commit.
## Decisions explicitly deferred
- **CI integration** — user raised the topic but we punted. Scope to gather when resuming: GitHub Actions vs other; per-PR triggers; which projects (backend vitest + control_center playwright + client/mitra flutter analyze + dart run custom_lint); APK build artifacts; Maestro Cloud or self-hosted device runner.
- **Phase 4 §2.1 real-device verification** — still pending from before today. See `requirement/phase3.4-testing.md` §1.5.1 for the runbook.
- **`backend/test/services/session-timer.service.test.js`** — 2 pre-existing failures (uuid-string fixture bug). Unrelated to anything we touched.