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:
@@ -11,6 +11,7 @@ import { mitraChatRoutes } from './routes/public/mitra.chat.routes.js'
|
|||||||
import { clientChatRoutes } from './routes/public/client.chat.routes.js'
|
import { clientChatRoutes } from './routes/public/client.chat.routes.js'
|
||||||
import { clientPaymentRoutes } from './routes/public/client.payment.routes.js'
|
import { clientPaymentRoutes } from './routes/public/client.payment.routes.js'
|
||||||
import { clientMitraAvailabilityRoutes } from './routes/public/client.mitra-availability.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 { clientOnboardingRoutes } from './routes/public/client.onboarding.routes.js'
|
||||||
import { clientSupportRoutes } from './routes/public/client.support.routes.js'
|
import { clientSupportRoutes } from './routes/public/client.support.routes.js'
|
||||||
import { sharedChatRoutes } from './routes/public/shared.chat.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(clientChatRoutes, { prefix: '/api/client/chat' })
|
||||||
app.register(clientPaymentRoutes, { prefix: '/api/client/payment-sessions' })
|
app.register(clientPaymentRoutes, { prefix: '/api/client/payment-sessions' })
|
||||||
app.register(clientMitraAvailabilityRoutes, { prefix: '/api/client/mitra-availability' })
|
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
|
// 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.
|
// own files rather than bloating client.auth.routes / shared.config.routes.
|
||||||
app.register(clientOnboardingRoutes, { prefix: '/api/client' })
|
app.register(clientOnboardingRoutes, { prefix: '/api/client' })
|
||||||
|
|||||||
@@ -3,15 +3,17 @@ import { countAvailableMitrasFromCache } from '../../services/mitra-status.servi
|
|||||||
import { UserType } from '../../constants.js'
|
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
|
* The customer home polls `/api/public/bestie/available` instead — that route
|
||||||
* foregrounded. Backed by a 10s in-memory cache (see mitra-status.service.js) so DB load
|
* 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.
|
* 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) => {
|
export const clientMitraAvailabilityRoutes = async (app) => {
|
||||||
app.get('/', { preHandler: [authenticate] }, async (request, reply) => {
|
app.get('/', { preHandler: [authenticate] }, async (request, reply) => {
|
||||||
|
|||||||
@@ -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 } })
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -182,7 +182,7 @@ const finalizeExtension = async (extensionId, sessionId, accepted, viaTimeout) =
|
|||||||
clearClosureGraceTimer(sessionId)
|
clearClosureGraceTimer(sessionId)
|
||||||
|
|
||||||
// Extend the session
|
// 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
|
// Resume session
|
||||||
await sql`UPDATE chat_sessions SET status = ${SessionStatus.ACTIVE} WHERE id = ${extension.session_id}`
|
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}
|
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, {
|
sendToSessionParticipant(sessionId, UserType.CUSTOMER, {
|
||||||
type: WsMessage.EXTENSION_RESPONSE,
|
type: WsMessage.EXTENSION_RESPONSE,
|
||||||
accepted: true,
|
accepted: true,
|
||||||
duration_minutes: extension.requested_duration_minutes,
|
duration_minutes: extension.requested_duration_minutes,
|
||||||
|
expires_at: extended?.expires_at ?? null,
|
||||||
via_timeout: viaTimeout,
|
via_timeout: viaTimeout,
|
||||||
})
|
})
|
||||||
sendToSessionParticipant(sessionId, UserType.CUSTOMER, {
|
sendToSessionParticipant(sessionId, UserType.CUSTOMER, {
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ const OTP_TTL_MINUTES = 5
|
|||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
const generate6DigitCode = () => {
|
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
|
// Avoid Math.random for OTP generation — use crypto.randomInt
|
||||||
return String(crypto.randomInt(0, 1_000_000)).padStart(6, '0')
|
return String(crypto.randomInt(0, 1_000_000)).padStart(6, '0')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -527,16 +527,26 @@ export const declinePairingRequest = async (sessionId, mitraId) => {
|
|||||||
pairingTimeouts.delete(sessionId)
|
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) {
|
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, {
|
await notifyCustomer(session.customer_id, {
|
||||||
type: WsMessage.PAIRING_FAILED,
|
type: WsMessage.PAIRING_FAILED,
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
payment_session_id: session.payment_session_id,
|
payment_session_id: session.payment_session_id,
|
||||||
cause_tag: PairingFailureCause.ALL_MITRAS_REJECTED,
|
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
|
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) {
|
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, {
|
await notifyCustomer(session.customer_id, {
|
||||||
type: WsMessage.PAIRING_FAILED,
|
type: WsMessage.PAIRING_FAILED,
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
payment_session_id: session.payment_session_id,
|
payment_session_id: session.payment_session_id,
|
||||||
cause_tag: causeTag,
|
cause_tag: causeTag,
|
||||||
|
is_terminal: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Notify mitras to dismiss (request expired) — independent fan-out, run in parallel.
|
// Notify mitras to dismiss (request expired) — independent fan-out, run in parallel.
|
||||||
|
|||||||
@@ -273,6 +273,7 @@ export const expireStalePaymentSessions = async () => {
|
|||||||
type: WsMessage.PAIRING_FAILED,
|
type: WsMessage.PAIRING_FAILED,
|
||||||
payment_session_id: row.id,
|
payment_session_id: row.id,
|
||||||
cause_tag: PairingFailureCause.PAYMENT_SESSION_EXPIRED,
|
cause_tag: PairingFailureCause.PAYMENT_SESSION_EXPIRED,
|
||||||
|
is_terminal: true,
|
||||||
})
|
})
|
||||||
if (!wsSent) {
|
if (!wsSent) {
|
||||||
await sendPushNotification(UserType.CUSTOMER, row.customer_id, {
|
await sendPushNotification(UserType.CUSTOMER, row.customer_id, {
|
||||||
|
|||||||
135
backend/test/routes/client.usp-seen.routes.test.js
Normal file
135
backend/test/routes/client.usp-seen.routes.test.js
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
139
backend/test/services/extension.service.test.js
Normal file
139
backend/test/services/extension.service.test.js
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -61,7 +61,7 @@ describe('pairing.service', () => {
|
|||||||
vi.clearAllMocks()
|
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.
|
// Arrange: confirmed, non-targeted payment session.
|
||||||
const pay = await createPaymentSession({
|
const pay = await createPaymentSession({
|
||||||
customerId: customer.id,
|
customerId: customer.id,
|
||||||
@@ -80,7 +80,7 @@ describe('pairing.service', () => {
|
|||||||
// classified as a general-blast all-rejected, NOT a targeted reject.
|
// classified as a general-blast all-rejected, NOT a targeted reject.
|
||||||
await declinePairingRequest(session.id, mitra.id)
|
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 sql = db()
|
||||||
const failures = await sql`
|
const failures = await sql`
|
||||||
SELECT cause_tag FROM pairing_failures WHERE payment_session_id = ${pay.id}
|
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).toHaveLength(1)
|
||||||
expect(failures[0].cause_tag).toBe(PairingFailureCause.ALL_MITRAS_REJECTED)
|
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}`
|
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(
|
const pairingFailedCalls = sendToUser.mock.calls.filter(
|
||||||
([, , data]) => data?.type === WsMessage.PAIRING_FAILED,
|
([, , data]) => data?.type === WsMessage.PAIRING_FAILED,
|
||||||
)
|
)
|
||||||
expect(pairingFailedCalls).toHaveLength(1)
|
expect(pairingFailedCalls).toHaveLength(1)
|
||||||
expect(pairingFailedCalls[0][2].cause_tag).toBe(PairingFailureCause.ALL_MITRAS_REJECTED)
|
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 () => {
|
it('cancelPairingRequest does NOT push PAIRING_FAILED to the customer', async () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Phase 4 Stage 2 — verified onboarding path:
|
# Phase 4 — verified onboarding path (post-ESP retirement, 2026-05-12):
|
||||||
# Splash → onboarding carousel → Welcome → Display Name → Verif Choice Sheet
|
# Splash → Display Name → Verif Choice Sheet (verifikasi nomor HP) →
|
||||||
# (verifikasi nomor HP) → ESP (pick a chip) → USP → Register → OTP (6-digit)
|
# USP one-time gate (first-time user → USP screen) → Register → OTP (6-digit)
|
||||||
# → S6 paywall (when first-session-discount eligible) or duration picker.
|
# → S6 paywall (when first-session-discount eligible) or duration picker.
|
||||||
#
|
#
|
||||||
# Run:
|
# Run:
|
||||||
@@ -26,6 +26,7 @@ env:
|
|||||||
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
|
||||||
- launchApp:
|
- launchApp:
|
||||||
clearState: true
|
clearState: true
|
||||||
|
# Onboarding carousel (still present per Phase 4 Stage 9 minimum-touch).
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible:
|
visible:
|
||||||
text: "Mulai"
|
text: "Mulai"
|
||||||
@@ -33,12 +34,14 @@ env:
|
|||||||
- tapOn:
|
- tapOn:
|
||||||
text: "Mulai"
|
text: "Mulai"
|
||||||
retryTapIfNoChange: true
|
retryTapIfNoChange: true
|
||||||
|
# Phase 4 Stage 9: /welcome is retired. SHome1st CTA "aku mau curhat"
|
||||||
|
# leads into the onboarding flow.
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible:
|
visible:
|
||||||
text: "Lanjut sebagai Tamu"
|
text: "aku mau curhat"
|
||||||
timeout: 10000
|
timeout: 10000
|
||||||
- tapOn:
|
- tapOn:
|
||||||
text: "Lanjut sebagai Tamu"
|
text: "aku mau curhat"
|
||||||
retryTapIfNoChange: true
|
retryTapIfNoChange: true
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible:
|
visible:
|
||||||
@@ -59,21 +62,14 @@ env:
|
|||||||
- tapOn:
|
- tapOn:
|
||||||
text: "verifikasi nomor HP"
|
text: "verifikasi nomor HP"
|
||||||
retryTapIfNoChange: true
|
retryTapIfNoChange: true
|
||||||
# ESP screen — pick at least one chip then tap "lanjut"
|
# USP one-time gate — first run after clearState, so usp_seen=false → USP shown.
|
||||||
- extendedWaitUntil:
|
# Explicitly assert ESP "Lagi mikirin apa?" is NOT visible to catch regression.
|
||||||
visible:
|
|
||||||
text: "Lagi mikirin apa?"
|
|
||||||
timeout: 10000
|
|
||||||
- tapOn:
|
|
||||||
text: "Hubungan"
|
|
||||||
- tapOn:
|
|
||||||
text: "lanjut"
|
|
||||||
retryTapIfNoChange: true
|
|
||||||
# USP screen
|
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible:
|
visible:
|
||||||
text: "Sebelum mulai"
|
text: "Sebelum mulai"
|
||||||
timeout: 10000
|
timeout: 10000
|
||||||
|
- assertNotVisible:
|
||||||
|
text: "Lagi mikirin apa?"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
text: "aku ngerti, lanjut"
|
text: "aku ngerti, lanjut"
|
||||||
retryTapIfNoChange: true
|
retryTapIfNoChange: true
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
# Phase 4 Stage 2 — anonymous onboarding path:
|
# Phase 4 — anonymous onboarding path (post-ESP retirement, 2026-05-12):
|
||||||
# Splash → onboarding carousel → Welcome → Display Name → Verif Choice Sheet
|
# Splash → Display Name → Verif Choice Sheet (curhat anonim) → USP one-time
|
||||||
# (curhat anonim) → ESP → USP → arrival at /payment/method-pick (Stage 3
|
# gate (first-time user → USP screen) → arrival at /payment/method-pick.
|
||||||
# owns the screen body; this flow stops at route arrival).
|
|
||||||
#
|
#
|
||||||
# Run:
|
# Run:
|
||||||
# maestro test client_app/.maestro/flows/03_onboarding_anon.yaml
|
# maestro test client_app/.maestro/flows/03_onboarding_anon.yaml
|
||||||
@@ -14,6 +13,7 @@ appId: com.halobestie.client.client_app
|
|||||||
---
|
---
|
||||||
- launchApp:
|
- launchApp:
|
||||||
clearState: true
|
clearState: true
|
||||||
|
# Onboarding carousel.
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible:
|
visible:
|
||||||
text: "Mulai"
|
text: "Mulai"
|
||||||
@@ -21,12 +21,15 @@ appId: com.halobestie.client.client_app
|
|||||||
- tapOn:
|
- tapOn:
|
||||||
text: "Mulai"
|
text: "Mulai"
|
||||||
retryTapIfNoChange: true
|
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:
|
- extendedWaitUntil:
|
||||||
visible:
|
visible:
|
||||||
text: "Lanjut sebagai Tamu"
|
text: "aku mau curhat"
|
||||||
timeout: 10000
|
timeout: 20000
|
||||||
- tapOn:
|
- tapOn:
|
||||||
text: "Lanjut sebagai Tamu"
|
text: "aku mau curhat"
|
||||||
retryTapIfNoChange: true
|
retryTapIfNoChange: true
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible:
|
visible:
|
||||||
@@ -47,19 +50,14 @@ appId: com.halobestie.client.client_app
|
|||||||
- tapOn:
|
- tapOn:
|
||||||
text: "curhat anonim"
|
text: "curhat anonim"
|
||||||
retryTapIfNoChange: true
|
retryTapIfNoChange: true
|
||||||
# ESP screen — leave empty + tap lewati to exercise the skip path
|
# USP one-time gate — first run after clearState, so usp_seen=false → USP shown.
|
||||||
- extendedWaitUntil:
|
# Assert ESP "Lagi mikirin apa?" is NOT visible to catch regression.
|
||||||
visible:
|
|
||||||
text: "Lagi mikirin apa?"
|
|
||||||
timeout: 10000
|
|
||||||
- tapOn:
|
|
||||||
text: "lewati"
|
|
||||||
retryTapIfNoChange: true
|
|
||||||
# USP screen
|
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible:
|
visible:
|
||||||
text: "Sebelum mulai"
|
text: "Sebelum mulai"
|
||||||
timeout: 10000
|
timeout: 10000
|
||||||
|
- assertNotVisible:
|
||||||
|
text: "Lagi mikirin apa?"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
text: "aku ngerti, lanjut"
|
text: "aku ngerti, lanjut"
|
||||||
retryTapIfNoChange: true
|
retryTapIfNoChange: true
|
||||||
|
|||||||
@@ -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
|
- 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
|
- 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`
|
- 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.
|
||||||
|
|||||||
@@ -9,6 +9,14 @@
|
|||||||
# packages, and plugins designed to encourage good coding practices.
|
# packages, and plugins designed to encourage good coding practices.
|
||||||
include: package:flutter_lints/flutter.yaml
|
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:
|
linter:
|
||||||
# The lint rules applied to this project can be customized in the
|
# The lint rules applied to this project can be customized in the
|
||||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||||
|
|||||||
@@ -54,8 +54,16 @@ class ApiClient {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>> get(String path, {Map<String, dynamic>? queryParameters}) async {
|
Future<Map<String, dynamic>> get(
|
||||||
final response = await _dio.get(path, queryParameters: queryParameters);
|
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>;
|
return response.data as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,16 +6,16 @@ part 'mitra_availability_notifier.g.dart';
|
|||||||
|
|
||||||
/// Customer-home availability poll.
|
/// Customer-home availability poll.
|
||||||
///
|
///
|
||||||
/// Polls `GET /api/client/mitra-availability` every 5 seconds while the home
|
/// Polls `GET /api/public/bestie/available` every 5 seconds while the home
|
||||||
/// screen is in the foreground. Polling is gated by the home screen calling
|
/// screen is in the foreground. The endpoint is unauthenticated by design —
|
||||||
/// [setActive] from `WidgetsBindingObserver.didChangeAppLifecycleState`:
|
/// 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)
|
/// - resumed → setActive(true)
|
||||||
/// - paused/inactive → setActive(false)
|
/// - paused/inactive → setActive(false)
|
||||||
///
|
///
|
||||||
/// On any HTTP error we emit `false` (never display stale state).
|
/// 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)
|
@Riverpod(keepAlive: true)
|
||||||
class MitraAvailability extends _$MitraAvailability {
|
class MitraAvailability extends _$MitraAvailability {
|
||||||
Timer? _pollTimer;
|
Timer? _pollTimer;
|
||||||
@@ -63,7 +63,10 @@ class MitraAvailability extends _$MitraAvailability {
|
|||||||
bool available;
|
bool available;
|
||||||
try {
|
try {
|
||||||
final api = ref.read(apiClientProvider);
|
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>?;
|
final data = response['data'] as Map<String, dynamic>?;
|
||||||
available = data?['available'] as bool? ?? false;
|
available = data?['available'] as bool? ?? false;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
|
|||||||
@@ -6,21 +6,21 @@ part of 'mitra_availability_notifier.dart';
|
|||||||
// RiverpodGenerator
|
// RiverpodGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$mitraAvailabilityHash() => r'036de8fea66c6a0114abd4a422af12186c1447d7';
|
String _$mitraAvailabilityHash() => r'e385c671720973cd1cea4b15933cd59421f035f0';
|
||||||
|
|
||||||
/// Customer-home availability poll.
|
/// Customer-home availability poll.
|
||||||
///
|
///
|
||||||
/// Polls `GET /api/client/mitra-availability` every 5 seconds while the home
|
/// Polls `GET /api/public/bestie/available` every 5 seconds while the home
|
||||||
/// screen is in the foreground. Polling is gated by the home screen calling
|
/// screen is in the foreground. The endpoint is unauthenticated by design —
|
||||||
/// [setActive] from `WidgetsBindingObserver.didChangeAppLifecycleState`:
|
/// 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)
|
/// - resumed → setActive(true)
|
||||||
/// - paused/inactive → setActive(false)
|
/// - paused/inactive → setActive(false)
|
||||||
///
|
///
|
||||||
/// On any HTTP error we emit `false` (never display stale state).
|
/// 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].
|
/// Copied from [MitraAvailability].
|
||||||
@ProviderFor(MitraAvailability)
|
@ProviderFor(MitraAvailability)
|
||||||
final mitraAvailabilityProvider =
|
final mitraAvailabilityProvider =
|
||||||
|
|||||||
@@ -456,10 +456,18 @@ class Chat extends _$Chat {
|
|||||||
|
|
||||||
case WsMessage.extensionResponse:
|
case WsMessage.extensionResponse:
|
||||||
final accepted = data['accepted'] as bool? ?? false;
|
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(
|
state = current.copyWith(
|
||||||
extensionResponse: data,
|
extensionResponse: data,
|
||||||
sessionPaused: accepted ? false : current.sessionPaused,
|
sessionPaused: accepted ? false : current.sessionPaused,
|
||||||
sessionExpired: accepted ? false : current.sessionExpired,
|
sessionExpired: accepted ? false : current.sessionExpired,
|
||||||
|
expiresAt: extendedExpiresAt ?? current.expiresAt,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ final chatRemainingSecondsProvider = AutoDisposeStreamProvider<int>.internal(
|
|||||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||||
// ignore: unused_element
|
// ignore: unused_element
|
||||||
typedef ChatRemainingSecondsRef = AutoDisposeStreamProviderRef<int>;
|
typedef ChatRemainingSecondsRef = AutoDisposeStreamProviderRef<int>;
|
||||||
String _$chatHash() => r'f9d77e176acb682dc6477635e0e80a09e846988b';
|
String _$chatHash() => r'a42bf404f5b38034c6c9511259b64b1092af3caa';
|
||||||
|
|
||||||
/// See also [Chat].
|
/// See also [Chat].
|
||||||
@ProviderFor(Chat)
|
@ProviderFor(Chat)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ part of 'session_closure_notifier.dart';
|
|||||||
// RiverpodGenerator
|
// RiverpodGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$sessionClosureHash() => r'521e57f74805faf01f11778872cb37ceae683a5b';
|
String _$sessionClosureHash() => r'a9aaf26da64d3489497d057e2b05741c08444e07';
|
||||||
|
|
||||||
/// See also [SessionClosure].
|
/// See also [SessionClosure].
|
||||||
@ProviderFor(SessionClosure)
|
@ProviderFor(SessionClosure)
|
||||||
|
|||||||
@@ -30,9 +30,14 @@ class PairingSearchingData extends PairingData {
|
|||||||
/// the payment-session-scoped cancel endpoint without re-prompting.
|
/// the payment-session-scoped cancel endpoint without re-prompting.
|
||||||
final String paymentSessionId;
|
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({
|
const PairingSearchingData({
|
||||||
required this.sessionId,
|
required this.sessionId,
|
||||||
required this.paymentSessionId,
|
required this.paymentSessionId,
|
||||||
|
required this.topicSensitivity,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,13 +110,26 @@ class PairingTargetedUnavailableData extends PairingData {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Terminal pairing failure — payment session is in `failed_pairing`. Routes
|
/// Pairing failure surfaced on the S7 Timeout screen.
|
||||||
/// to the failed-pairing screen (no_bestie_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 {
|
class PairingFailedData extends PairingData {
|
||||||
final PairingFailureCause cause;
|
final PairingFailureCause cause;
|
||||||
final String? paymentSessionId;
|
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 {
|
class PairingCancelledData extends PairingData {
|
||||||
@@ -156,6 +174,7 @@ class Pairing extends _$Pairing {
|
|||||||
state = PairingSearchingData(
|
state = PairingSearchingData(
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
paymentSessionId: paymentSessionId,
|
paymentSessionId: paymentSessionId,
|
||||||
|
topicSensitivity: topicSensitivity,
|
||||||
);
|
);
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
_cleanup();
|
_cleanup();
|
||||||
@@ -279,6 +298,7 @@ class Pairing extends _$Pairing {
|
|||||||
state = PairingSearchingData(
|
state = PairingSearchingData(
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
paymentSessionId: paymentSessionId,
|
paymentSessionId: paymentSessionId,
|
||||||
|
topicSensitivity: topicSensitivity,
|
||||||
);
|
);
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
_cleanup();
|
_cleanup();
|
||||||
@@ -302,6 +322,25 @@ class Pairing extends _$Pairing {
|
|||||||
state = const PairingInitialData();
|
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 ---------------------------------------------------------
|
// ---- Internal ---------------------------------------------------------
|
||||||
|
|
||||||
Future<void> _connectWebSocket() async {
|
Future<void> _connectWebSocket() async {
|
||||||
@@ -348,13 +387,20 @@ class Pairing extends _$Pairing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (type == WsMessage.pairingFailed) {
|
if (type == WsMessage.pairingFailed) {
|
||||||
// Terminal — payment_session is in failed_pairing server-side.
|
|
||||||
final causeTag = data['cause_tag'] as String?;
|
final causeTag = data['cause_tag'] as String?;
|
||||||
final paymentSessionId = data['payment_session_id'] 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();
|
_cleanup();
|
||||||
state = PairingFailedData(
|
state = PairingFailedData(
|
||||||
cause: PairingFailureCause.fromString(causeTag),
|
cause: PairingFailureCause.fromString(causeTag),
|
||||||
paymentSessionId: paymentSessionId,
|
paymentSessionId: paymentSessionId,
|
||||||
|
isRetryable: isRetryable,
|
||||||
|
topicSensitivity: carriedTopic,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ class _DisplayNameScreenState extends ConsumerState<DisplayNameScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
routeForVerifChoice(context, choice);
|
await routeForVerifChoice(context, ref, choice);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -1,13 +1,28 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../../core/auth/auth_notifier.dart';
|
import '../../../core/auth/auth_notifier.dart';
|
||||||
import '../../../core/auth/auth_providers_provider.dart';
|
|
||||||
import '../../../core/constants.dart';
|
import '../../../core/constants.dart';
|
||||||
import '../../../core/theme/halo_tokens.dart';
|
import '../../../core/theme/halo_tokens.dart';
|
||||||
import '../../../core/theme/widgets/widgets.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 {
|
class RegisterScreen extends ConsumerStatefulWidget {
|
||||||
const RegisterScreen({super.key});
|
const RegisterScreen({super.key});
|
||||||
|
|
||||||
@@ -19,8 +34,9 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
|||||||
final _phoneController = TextEditingController();
|
final _phoneController = TextEditingController();
|
||||||
ProviderSubscription<AsyncValue<AuthData>>? _authSub;
|
ProviderSubscription<AsyncValue<AuthData>>? _authSub;
|
||||||
|
|
||||||
// Server-imposed lockout: when /otp/request returns 429, the backend
|
// Server-imposed lockout from /otp/request 429s. Backend embeds
|
||||||
// includes retry_after_seconds. We disable "Kirim OTP" for that window.
|
// retry_after_seconds in the AuthErrorInfo so we can disable the CTA
|
||||||
|
// until the next slot opens.
|
||||||
int _lockoutSeconds = 0;
|
int _lockoutSeconds = 0;
|
||||||
Timer? _lockoutTimer;
|
Timer? _lockoutTimer;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
@@ -28,13 +44,14 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_phoneController.addListener(() => setState(() {}));
|
||||||
_authSub = ref.listenManual<AsyncValue<AuthData>>(authProvider, (prev, next) {
|
_authSub = ref.listenManual<AsyncValue<AuthData>>(authProvider, (prev, next) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final data = next.valueOrNull;
|
final data = next.valueOrNull;
|
||||||
if (data is AuthOtpSentData) {
|
if (data is AuthOtpSentData) {
|
||||||
// Use go (replace) so re-submitting the phone form doesn't stack
|
// go (replace) so re-submitting the form doesn't stack OtpScreens
|
||||||
// multiple OtpScreen instances with active listeners.
|
// with leftover listeners.
|
||||||
context.go('/auth/otp', extra: _phoneController.text.trim());
|
context.go('/auth/otp', extra: _e164Phone());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (next is AsyncError) {
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final authState = ref.watch(authProvider);
|
final authState = ref.watch(authProvider);
|
||||||
final isLoading = authState is AsyncLoading;
|
final isLoading = authState is AsyncLoading;
|
||||||
final isLockedOut = _lockoutSeconds > 0;
|
final isLockedOut = _lockoutSeconds > 0;
|
||||||
final canSubmit = !isLoading && !isLockedOut;
|
final hasMinDigits = _subscriberDigits().length >= 9;
|
||||||
final providersAsync = ref.watch(authProvidersProvider);
|
final canSubmit = hasMinDigits && !isLoading && !isLockedOut;
|
||||||
final providers =
|
|
||||||
providersAsync.valueOrNull ?? AuthProvidersConfig.fallback;
|
final name = _greetingName(authState.valueOrNull);
|
||||||
|
final shownName = name.isEmpty ? 'kamu' : name;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Masuk / Daftar')),
|
backgroundColor: HaloTokens.bg,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(HaloSpacing.s24),
|
padding: const EdgeInsets.fromLTRB(28, 8, 28, 28),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
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(
|
const Padding(
|
||||||
padding: EdgeInsets.symmetric(vertical: HaloSpacing.s16),
|
padding: EdgeInsets.only(top: 8, bottom: 8),
|
||||||
child: Row(
|
child: HaloStepDots(total: 4, current: 3),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: Divider(color: HaloTokens.border)),
|
Text(
|
||||||
Padding(
|
'nomor wa-mu, $shownName?',
|
||||||
padding:
|
style: const TextStyle(
|
||||||
EdgeInsets.symmetric(horizontal: HaloSpacing.s12),
|
fontFamily: HaloTokens.fontDisplay,
|
||||||
child: Text(
|
fontSize: 28,
|
||||||
'atau',
|
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(
|
style: TextStyle(
|
||||||
fontFamily: HaloTokens.fontBody,
|
fontFamily: HaloTokens.fontBody,
|
||||||
color: HaloTokens.inkMuted,
|
fontSize: 14.5,
|
||||||
fontSize: 13,
|
color: HaloTokens.inkSoft,
|
||||||
|
height: 1.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 24),
|
||||||
Expanded(child: Divider(color: HaloTokens.border)),
|
_PhoneRow(
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
TextField(
|
|
||||||
controller: _phoneController,
|
controller: _phoneController,
|
||||||
decoration: const InputDecoration(
|
borderColor: hasMinDigits
|
||||||
labelText: 'Nomor HP',
|
? HaloTokens.brand
|
||||||
hintText: '+628xxxxxxxxxx',
|
: HaloTokens.border,
|
||||||
),
|
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const _PrivacyCard(),
|
||||||
if (_errorMessage != null) ...[
|
if (_errorMessage != null) ...[
|
||||||
const SizedBox(height: HaloSpacing.s12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
_errorMessage!,
|
_errorMessage!,
|
||||||
textAlign: TextAlign.center,
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,8 +7,12 @@ import '../../support/widgets/tanya_admin_sheet.dart';
|
|||||||
/// Modal shown when OTP delivery / verification is exhausted (rate-limited
|
/// Modal shown when OTP delivery / verification is exhausted (rate-limited
|
||||||
/// 429 from `OTP_RATE_LIMIT_PHONE`, `OTP_RATE_LIMIT_IP`, `OTP_COOLDOWN`, or
|
/// 429 from `OTP_RATE_LIMIT_PHONE`, `OTP_RATE_LIMIT_IP`, `OTP_COOLDOWN`, or
|
||||||
/// `OTP_ATTEMPTS_EXCEEDED`). Offers a "lanjut tanpa verif" exit into the
|
/// `OTP_ATTEMPTS_EXCEEDED`). Offers a "lanjut tanpa verif" exit into the
|
||||||
/// anonymous flow (preserving any ESP/USP state) and a "hubungi admin" CTA
|
/// anonymous flow and a "hubungi admin" CTA that opens the Tanya Admin sheet.
|
||||||
/// 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 {
|
class OtpBlockedPopup {
|
||||||
const OtpBlockedPopup._();
|
const OtpBlockedPopup._();
|
||||||
|
|
||||||
@@ -35,12 +39,7 @@ class OtpBlockedPopup {
|
|||||||
),
|
),
|
||||||
primary: HaloPopupAction(
|
primary: HaloPopupAction(
|
||||||
label: 'lanjut tanpa verif',
|
label: 'lanjut tanpa verif',
|
||||||
onPressed: () {
|
onPressed: () => context.go('/onboarding/anon/method'),
|
||||||
// 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');
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
secondary: HaloPopupAction(
|
secondary: HaloPopupAction(
|
||||||
label: 'hubungi admin',
|
label: 'hubungi admin',
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../../core/theme/halo_tokens.dart';
|
import '../../../core/theme/halo_tokens.dart';
|
||||||
import '../../../core/theme/widgets/widgets.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
|
/// Result of the post-name Verif Choice Sheet. Caller routes to the matching
|
||||||
/// onboarding sub-flow.
|
/// onboarding sub-flow.
|
||||||
@@ -65,13 +67,26 @@ class VerifChoiceSheet extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Helper: route to the right onboarding sub-flow for a verif choice.
|
/// 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) {
|
switch (choice) {
|
||||||
case VerifChoice.verified:
|
case VerifChoice.verified:
|
||||||
context.push('/onboarding/verif/esp');
|
context.push(seen ? '/auth/register' : '/onboarding/verif/usp');
|
||||||
break;
|
break;
|
||||||
case VerifChoice.anonymous:
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -191,8 +191,14 @@ class _SearchingBody extends ConsumerWidget {
|
|||||||
fullWidth: true,
|
fullWidth: true,
|
||||||
size: HaloButtonSize.lg,
|
size: HaloButtonSize.lg,
|
||||||
onPressed: () {
|
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');
|
context.go('/payment/entry');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: HaloSpacing.s8),
|
const SizedBox(height: HaloSpacing.s8),
|
||||||
|
|||||||
@@ -1,56 +1,78 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../core/theme/halo_tokens.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
|
/// Floating expired banner shown above the chat input when the session
|
||||||
/// has hit zero but the session is still in `closing` grace. Phase 4 Stage 6:
|
/// timer has hit zero but the session is still in `closing` grace.
|
||||||
/// gives the customer a soft, in-place way to extend instead of the modal-only
|
///
|
||||||
/// flow from Phase 3.
|
/// 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 {
|
class ChatExpiredBanner extends StatelessWidget {
|
||||||
|
final String mitraName;
|
||||||
final VoidCallback onExtend;
|
final VoidCallback onExtend;
|
||||||
|
|
||||||
const ChatExpiredBanner({super.key, required this.onExtend});
|
const ChatExpiredBanner({
|
||||||
|
super.key,
|
||||||
|
required this.mitraName,
|
||||||
|
required this.onExtend,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.fromLTRB(
|
margin: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||||||
HaloSpacing.s12,
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
HaloSpacing.s8,
|
decoration: BoxDecoration(
|
||||||
HaloSpacing.s12,
|
color: HaloTokens.brand,
|
||||||
HaloSpacing.s8,
|
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(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Text('⏰', style: TextStyle(fontSize: 20)),
|
const Text('⏰', style: TextStyle(fontSize: 16)),
|
||||||
const SizedBox(width: HaloSpacing.s12),
|
const SizedBox(width: 10),
|
||||||
const Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: RichText(
|
||||||
'waktu curhat habis',
|
text: TextSpan(
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontFamily: HaloTokens.fontBody,
|
fontFamily: HaloTokens.fontBody,
|
||||||
fontSize: 14,
|
fontSize: 12.5,
|
||||||
fontWeight: FontWeight.w600,
|
height: 1.4,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
|
children: [
|
||||||
|
const TextSpan(
|
||||||
|
text: 'habis nih...',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
TextSpan(text: ' mau lanjutin curhat sama $mitraName?'),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
HaloButton(
|
),
|
||||||
label: 'perpanjang',
|
const SizedBox(width: 8),
|
||||||
size: HaloButtonSize.sm,
|
ElevatedButton(
|
||||||
variant: HaloButtonVariant.secondary,
|
|
||||||
onPressed: onExtend,
|
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'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -47,8 +47,19 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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);
|
ref.read(mitraAvailabilityProvider.notifier).setActive(false);
|
||||||
|
super.deactivate();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/api/api_client_provider.dart';
|
import '../../../core/api/api_client_provider.dart';
|
||||||
|
import '../../../core/auth/auth_notifier.dart';
|
||||||
|
|
||||||
class BestieHistoryItem {
|
class BestieHistoryItem {
|
||||||
final String sessionId;
|
final String sessionId;
|
||||||
@@ -28,13 +29,34 @@ class BestieHistoryItem {
|
|||||||
mitraName: json['mitra_display_name'] as String? ?? 'Bestie',
|
mitraName: json['mitra_display_name'] as String? ?? 'Bestie',
|
||||||
endedAt: endedAtRaw is String ? DateTime.tryParse(endedAtRaw)?.toLocal() : null,
|
endedAt: endedAtRaw is String ? DateTime.tryParse(endedAtRaw)?.toLocal() : null,
|
||||||
topics: (json['topics'] as List?)?.cast<String>() ?? const [],
|
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,
|
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 api = ref.read(apiClientProvider);
|
||||||
final response = await api.get('/api/client/chat/history');
|
final response = await api.get('/api/client/chat/history');
|
||||||
final items = (response['data']['items'] as List<dynamic>? ?? [])
|
final items = (response['data']['items'] as List<dynamic>? ?? [])
|
||||||
|
|||||||
@@ -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);
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -84,7 +84,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
|||||||
await prefs.setBool(_kOnboardingDone, true);
|
await prefs.setBool(_kOnboardingDone, true);
|
||||||
ref.invalidate(onboardingDoneProvider);
|
ref.invalidate(onboardingDoneProvider);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
context.go('/welcome');
|
context.go('/home');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../../core/theme/halo_tokens.dart';
|
import '../../../core/theme/halo_tokens.dart';
|
||||||
import '../../../core/theme/widgets/widgets.dart';
|
import '../../../core/theme/widgets/widgets.dart';
|
||||||
|
import '../usp_seen_provider.dart';
|
||||||
|
|
||||||
/// Onboarding step 2 — static value-prop ("USP") cards. No state; just a
|
/// Onboarding step 2 — static value-prop ("USP") cards. One-time gate
|
||||||
/// terminal CTA that routes onward to the auth/payment fork.
|
/// (Phase 4, 2026-05-12): on Continue we mark the local `usp_seen` flag and
|
||||||
class UspScreen extends StatelessWidget {
|
/// best-effort persist to DB so this screen never shows again for this user.
|
||||||
/// `verified` ➞ USP → OTP (`/auth/register`).
|
///
|
||||||
/// `anonymous` ➞ USP → `/payment/method-pick` (Stage 3 owns this route).
|
/// `verified` ➞ USP → OTP (`/auth/register`).
|
||||||
|
/// `anonymous` ➞ USP → `/payment/method-pick`.
|
||||||
|
class UspScreen extends ConsumerWidget {
|
||||||
final bool verified;
|
final bool verified;
|
||||||
|
|
||||||
const UspScreen({super.key, required this.verified});
|
const UspScreen({super.key, required this.verified});
|
||||||
@@ -36,7 +40,7 @@ class UspScreen extends StatelessWidget {
|
|||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Padding(
|
title: const Padding(
|
||||||
@@ -90,7 +94,7 @@ class UspScreen extends StatelessWidget {
|
|||||||
HaloButton(
|
HaloButton(
|
||||||
label: 'aku ngerti, lanjut',
|
label: 'aku ngerti, lanjut',
|
||||||
fullWidth: true,
|
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) {
|
if (verified) {
|
||||||
context.push('/auth/register');
|
context.push('/auth/register');
|
||||||
} else {
|
} 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');
|
context.push('/payment/method-pick');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
81
client_app/lib/features/onboarding/usp_seen_provider.dart
Normal file
81
client_app/lib/features/onboarding/usp_seen_provider.dart
Normal 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
client_app/lib/features/onboarding/usp_seen_provider.g.dart
Normal file
33
client_app/lib/features/onboarding/usp_seen_provider.g.dart
Normal 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
|
||||||
@@ -60,13 +60,16 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void deactivate() {
|
||||||
// Best-effort cancel on back/dispose if we still have a `pending` row.
|
// 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
|
// 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
|
// ignore: discarded_futures
|
||||||
ref.read(paymentProvider.notifier).cancelIfPending();
|
ref.read(paymentProvider.notifier).cancelIfPending();
|
||||||
super.dispose();
|
super.deactivate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -125,19 +125,34 @@ class _WaitingPaymentScreenState extends ConsumerState<WaitingPaymentScreen>
|
|||||||
if (status == PaymentSessionStatus.confirmed ||
|
if (status == PaymentSessionStatus.confirmed ||
|
||||||
status == PaymentSessionStatus.consumed) {
|
status == PaymentSessionStatus.consumed) {
|
||||||
_markTerminal();
|
_markTerminal();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
_navigateTerminal('/onboarding/notif-gate');
|
||||||
if (!mounted) return;
|
|
||||||
context.go('/onboarding/notif-gate');
|
|
||||||
});
|
|
||||||
} else if (status == PaymentSessionStatus.expired ||
|
} else if (status == PaymentSessionStatus.expired ||
|
||||||
status == PaymentSessionStatus.abandoned) {
|
status == PaymentSessionStatus.abandoned) {
|
||||||
_markTerminal();
|
_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((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
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() {
|
void _markTerminal() {
|
||||||
_terminal = true;
|
_terminal = true;
|
||||||
|
|||||||
366
client_app/lib/features/profile/profile_screen.dart
Normal file
366
client_app/lib/features/profile/profile_screen.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:flutter/material.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/api/api_client_provider.dart';
|
||||||
import 'core/auth/auth_notifier.dart';
|
import 'core/auth/auth_notifier.dart';
|
||||||
import 'core/auth/auth_providers_provider.dart';
|
import 'core/auth/auth_providers_provider.dart';
|
||||||
|
import 'core/auth/token_storage.dart';
|
||||||
import 'core/chat/active_session_notifier.dart';
|
import 'core/chat/active_session_notifier.dart';
|
||||||
import 'core/chat/chat_notifier.dart';
|
import 'core/chat/chat_notifier.dart';
|
||||||
import 'core/notifications/notification_service.dart';
|
import 'core/notifications/notification_service.dart';
|
||||||
@@ -14,6 +17,14 @@ import 'router.dart';
|
|||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
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);
|
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||||||
|
|
||||||
final messaging = FirebaseMessaging.instance;
|
final messaging = FirebaseMessaging.instance;
|
||||||
|
|||||||
@@ -552,6 +552,13 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.2"
|
version: "2.3.2"
|
||||||
|
halo_lints:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
path: "../halo_lints"
|
||||||
|
relative: true
|
||||||
|
source: path
|
||||||
|
version: "0.0.1"
|
||||||
hooks:
|
hooks:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -58,6 +58,11 @@ dev_dependencies:
|
|||||||
build_runner: ^2.4.13
|
build_runner: ^2.4.13
|
||||||
custom_lint: ^0.7.0
|
custom_lint: ^0.7.0
|
||||||
riverpod_lint: ^2.6.2
|
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:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|||||||
76
halo_lints/lib/halo_lints.dart
Normal file
76
halo_lints/lib/halo_lints.dart
Normal 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
349
halo_lints/pubspec.lock
Normal 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
12
halo_lints/pubspec.yaml
Normal 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
|
||||||
@@ -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
|
- 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
|
- 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
|
- 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.
|
||||||
|
|||||||
@@ -9,6 +9,14 @@
|
|||||||
# packages, and plugins designed to encourage good coding practices.
|
# packages, and plugins designed to encourage good coding practices.
|
||||||
include: package:flutter_lints/flutter.yaml
|
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:
|
linter:
|
||||||
# The lint rules applied to this project can be customized in the
|
# The lint rules applied to this project can be customized in the
|
||||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||||
|
|||||||
@@ -10,6 +10,28 @@ import '../constants.dart';
|
|||||||
|
|
||||||
part 'mitra_chat_notifier.g.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
|
// States
|
||||||
sealed class MitraChatData {
|
sealed class MitraChatData {
|
||||||
const MitraChatData();
|
const MitraChatData();
|
||||||
@@ -304,7 +326,11 @@ class MitraChat extends _$MitraChat {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case WsMessage.sessionTimer:
|
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;
|
break;
|
||||||
|
|
||||||
case WsMessage.sessionExpired:
|
case WsMessage.sessionExpired:
|
||||||
|
|||||||
@@ -32,8 +32,6 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
|
|||||||
final _goodbyeController = TextEditingController();
|
final _goodbyeController = TextEditingController();
|
||||||
final _scrollController = ScrollController();
|
final _scrollController = ScrollController();
|
||||||
Timer? _typingThrottle;
|
Timer? _typingThrottle;
|
||||||
bool _showBestieBanner = true;
|
|
||||||
bool _showUserBanner = true;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
final notifier = ref.read(mitraChatProvider.notifier);
|
|
||||||
_messageController.dispose();
|
_messageController.dispose();
|
||||||
_goodbyeController.dispose();
|
_goodbyeController.dispose();
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
_typingThrottle?.cancel();
|
_typingThrottle?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
Future.microtask(() => notifier.disconnect());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _scrollToBottom() {
|
void _scrollToBottom() {
|
||||||
@@ -82,17 +90,20 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final chatState = ref.watch(mitraChatProvider);
|
// Parent build runs ONCE per lifecycle — there are no ref.watch calls here.
|
||||||
final extState = ref.watch(mitraExtensionProvider);
|
// State changes (messages, typing, status updates, mode flip, sensitivity
|
||||||
|
// flip) all rebuild only the leaf consumers that watch them:
|
||||||
// Listen for extension complete -> navigate home
|
// - _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) {
|
ref.listen(mitraExtensionProvider, (prev, next) {
|
||||||
if (next is ExtensionCompleteData) {
|
if (next is ExtensionCompleteData) {
|
||||||
context.go('/home');
|
context.go('/home');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for chat state changes
|
|
||||||
ref.listen(mitraChatProvider, (prev, next) {
|
ref.listen(mitraChatProvider, (prev, next) {
|
||||||
if (next is MitraChatConnectedData) {
|
if (next is MitraChatConnectedData) {
|
||||||
_scrollToBottom();
|
_scrollToBottom();
|
||||||
@@ -106,10 +117,6 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
final currentSensitivity = chatState is MitraChatConnectedData
|
|
||||||
? chatState.topicSensitivity
|
|
||||||
: TopicSensitivity.regular;
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
@@ -130,41 +137,53 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (chatState is MitraChatConnectedData &&
|
const _MitraChatVoicePill(),
|
||||||
chatState.mode == SessionMode.call) ...[
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
_buildVoiceCallPill(),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
if (chatState is MitraChatConnectedData) _buildTopicToggle(chatState),
|
_MitraChatTopicToggle(sessionId: widget.sessionId),
|
||||||
if (chatState is MitraChatConnectedData && chatState.remainingSeconds != null)
|
const _MitraChatTimerAction(),
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Column(
|
body: _MitraChatBodyContent(
|
||||||
children: [
|
sessionId: widget.sessionId,
|
||||||
if (currentSensitivity == TopicSensitivity.sensitive)
|
customerName: widget.customerName,
|
||||||
_buildSensitivityHeader(),
|
messageController: _messageController,
|
||||||
Expanded(child: _buildBody(chatState, extState)),
|
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(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
@@ -181,35 +200,31 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildSensitivityHeader() {
|
/// AppBar topic-sensitivity flag/lock action. Watches only `topicSensitivity`
|
||||||
const theme = SensitivityTheme.sensitive;
|
/// (via `.select`) plus the `sensitivityConfigProvider`. Confirmation dialog
|
||||||
return Container(
|
/// + snackbars + `flipTopic` call all live here so the parent screen doesn't
|
||||||
width: double.infinity,
|
/// need to know about topic state.
|
||||||
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12),
|
class _MitraChatTopicToggle extends ConsumerStatefulWidget {
|
||||||
color: theme.badgeBg,
|
final String sessionId;
|
||||||
child: Row(
|
const _MitraChatTopicToggle({required this.sessionId});
|
||||||
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 _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 configAsync = ref.watch(sensitivityConfigProvider);
|
||||||
final config = configAsync.value ?? SensitivityConfig.defaults;
|
final config = configAsync.value ?? SensitivityConfig.defaults;
|
||||||
final isSensitive = state.topicSensitivity == TopicSensitivity.sensitive;
|
final isSensitive = sensitivity == TopicSensitivity.sensitive;
|
||||||
final locked = config.oneWayLatch && isSensitive;
|
final locked = config.oneWayLatch && isSensitive;
|
||||||
|
|
||||||
return Tooltip(
|
return Tooltip(
|
||||||
@@ -223,16 +238,16 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
|
|||||||
isSensitive ? Icons.flag : Icons.outlined_flag,
|
isSensitive ? Icons.flag : Icons.outlined_flag,
|
||||||
color: isSensitive ? SensitivityTheme.sensitive.badgeBg : Colors.grey.shade600,
|
color: isSensitive ? SensitivityTheme.sensitive.badgeBg : Colors.grey.shade600,
|
||||||
),
|
),
|
||||||
onPressed: locked ? null : () => _onTopicTogglePressed(state, config),
|
onPressed: locked ? null : () => _onTopicTogglePressed(sensitivity, config),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onTopicTogglePressed(
|
Future<void> _onTopicTogglePressed(
|
||||||
MitraChatConnectedData state,
|
TopicSensitivity current,
|
||||||
SensitivityConfig config,
|
SensitivityConfig config,
|
||||||
) async {
|
) async {
|
||||||
final toValue = state.topicSensitivity == TopicSensitivity.sensitive
|
final toValue = current == TopicSensitivity.sensitive
|
||||||
? TopicSensitivity.regular
|
? TopicSensitivity.regular
|
||||||
: TopicSensitivity.sensitive;
|
: 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) {
|
Widget _buildBody(MitraChatData chatState, ExtensionData extState) {
|
||||||
if (chatState is MitraChatConnectingData) {
|
if (chatState is MitraChatConnectingData) {
|
||||||
@@ -352,7 +456,7 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
|
|||||||
// item (above the first message bubble). Info-only.
|
// item (above the first message bubble). Info-only.
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
controller: _scrollController,
|
controller: widget.scrollController,
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
itemCount: state.messages.length +
|
itemCount: state.messages.length +
|
||||||
(state.topics.isNotEmpty ? 1 : 0),
|
(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) {
|
Widget _buildTopicChipsRow(List<String> topics) {
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
@@ -510,10 +597,10 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _messageController,
|
controller: widget.messageController,
|
||||||
onChanged: _onTextChanged,
|
onChanged: widget.onTextChanged,
|
||||||
textInputAction: TextInputAction.send,
|
textInputAction: TextInputAction.send,
|
||||||
onSubmitted: (_) => _sendMessage(),
|
onSubmitted: (_) => widget.onSend(),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Ketik Pesan',
|
hintText: 'Ketik Pesan',
|
||||||
hintStyle: TextStyle(color: Colors.grey.shade400),
|
hintStyle: TextStyle(color: Colors.grey.shade400),
|
||||||
@@ -535,7 +622,7 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
|
|||||||
),
|
),
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
icon: const Icon(Icons.send, color: Colors.white, size: 20),
|
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 Text('Tuliskan pesan terakhirmu untuk Customer', textAlign: TextAlign.center),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _goodbyeController,
|
controller: widget.goodbyeController,
|
||||||
maxLines: 3,
|
maxLines: 3,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Terima kasih sudah curhat...',
|
hintText: 'Terima kasih sudah curhat...',
|
||||||
@@ -644,7 +731,7 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
|
|||||||
onPressed: extState is ExtensionSubmittingData
|
onPressed: extState is ExtensionSubmittingData
|
||||||
? null
|
? null
|
||||||
: () {
|
: () {
|
||||||
final text = _goodbyeController.text.trim();
|
final text = widget.goodbyeController.text.trim();
|
||||||
if (text.isNotEmpty) {
|
if (text.isNotEmpty) {
|
||||||
ref.read(mitraExtensionProvider.notifier).submitGoodbye(
|
ref.read(mitraExtensionProvider.notifier).submitGoodbye(
|
||||||
widget.sessionId, text,
|
widget.sessionId, text,
|
||||||
@@ -695,7 +782,7 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
|
|||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
controller: _scrollController,
|
controller: widget.scrollController,
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
itemCount: state.messages.length,
|
itemCount: state.messages.length,
|
||||||
itemBuilder: (context, index) {
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -504,6 +504,13 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.2"
|
version: "2.3.2"
|
||||||
|
halo_lints:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
path: "../halo_lints"
|
||||||
|
relative: true
|
||||||
|
source: path
|
||||||
|
version: "0.0.1"
|
||||||
hooks:
|
hooks:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ dev_dependencies:
|
|||||||
build_runner: ^2.4.13
|
build_runner: ^2.4.13
|
||||||
custom_lint: ^0.7.0
|
custom_lint: ^0.7.0
|
||||||
riverpod_lint: ^2.6.2
|
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:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|||||||
82
requirement/phase4-chat-screen-figma.md
Normal file
82
requirement/phase4-chat-screen-figma.md
Normal 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 150–284) +
|
||||||
|
> [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.
|
||||||
224
requirement/phase4-esp-removal-usp-gate.md
Normal file
224
requirement/phase4-esp-removal-usp-gate.md
Normal 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 1–4 above pass on physical device.
|
||||||
|
- [ ] `TECH_DEBT.md` "S5 ESP screen retired" entry closed / removed once cleanup lands.
|
||||||
78
requirement/resume-2026-05-15.md
Normal file
78
requirement/resume-2026-05-15.md
Normal 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.
|
||||||
Reference in New Issue
Block a user