From a09f37135c868b24ecab3ddb8d07392fe90556a5 Mon Sep 17 00:00:00 2001 From: ramadhan sjamsani Date: Thu, 14 May 2026 19:12:34 +0800 Subject: [PATCH] Phase 4 checkpoint: chat-screen perf refactor + retryable blast-failure + repo-wide dispose-ref guardrail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/src/app.public.js | 2 + .../client.mitra-availability.routes.js | 14 +- .../public.bestie-availability.routes.js | 25 + backend/src/services/extension.service.js | 8 +- backend/src/services/otp.service.js | 5 + backend/src/services/pairing.service.js | 32 +- backend/src/services/payment.service.js | 1 + .../routes/client.usp-seen.routes.test.js | 135 ++ .../test/services/extension.service.test.js | 139 ++ backend/test/services/pairing.service.test.js | 13 +- .../flows/02_onboarding_verified.yaml | 28 +- .../.maestro/flows/03_onboarding_anon.yaml | 30 +- client_app/CLAUDE.md | 24 + client_app/analysis_options.yaml | 8 + client_app/lib/core/api/api_client.dart | 12 +- .../mitra_availability_notifier.dart | 17 +- .../mitra_availability_notifier.g.dart | 14 +- client_app/lib/core/chat/chat_notifier.dart | 8 + client_app/lib/core/chat/chat_notifier.g.dart | 2 +- .../core/chat/session_closure_notifier.g.dart | 2 +- .../lib/core/pairing/pairing_notifier.dart | 54 +- .../auth/screens/display_name_screen.dart | 2 +- .../auth/screens/register_screen.dart | 298 +++- .../features/auth/screens/welcome_screen.dart | 89 -- .../auth/widgets/otp_blocked_popup.dart | 15 +- .../auth/widgets/verif_choice_sheet.dart | 21 +- .../features/chat/screens/chat_screen.dart | 1263 +++++++++++------ .../chat/screens/searching_screen.dart | 10 +- .../chat/widgets/chat_expired_banner.dart | 94 +- client_app/lib/features/home/home_screen.dart | 13 +- .../providers/bestie_history_provider.dart | 26 +- .../lib/features/onboarding/esp_state.dart | 12 - .../lib/features/onboarding/esp_topic.dart | 34 - .../onboarding/onboarding_screen.dart | 2 +- .../onboarding/screens/esp_screen.dart | 132 -- .../onboarding/screens/usp_screen.dart | 26 +- .../onboarding/usp_seen_provider.dart | 81 ++ .../onboarding/usp_seen_provider.g.dart | 33 + .../payment/screens/payment_screen.dart | 11 +- .../screens/waiting_payment_screen.dart | 31 +- .../lib/features/profile/profile_screen.dart | 366 +++++ client_app/lib/main.dart | 11 + client_app/pubspec.lock | 7 + client_app/pubspec.yaml | 5 + halo_lints/lib/halo_lints.dart | 76 + halo_lints/pubspec.lock | 349 +++++ halo_lints/pubspec.yaml | 12 + mitra_app/CLAUDE.md | 24 + mitra_app/analysis_options.yaml | 8 + .../lib/core/chat/mitra_chat_notifier.dart | 28 +- .../chat/screens/mitra_chat_screen.dart | 462 +++--- mitra_app/pubspec.lock | 7 + mitra_app/pubspec.yaml | 5 + requirement/phase4-chat-screen-figma.md | 82 ++ requirement/phase4-esp-removal-usp-gate.md | 224 +++ requirement/resume-2026-05-15.md | 78 + 56 files changed, 3417 insertions(+), 1093 deletions(-) create mode 100644 backend/src/routes/public/public.bestie-availability.routes.js create mode 100644 backend/test/routes/client.usp-seen.routes.test.js create mode 100644 backend/test/services/extension.service.test.js delete mode 100644 client_app/lib/features/auth/screens/welcome_screen.dart delete mode 100644 client_app/lib/features/onboarding/esp_state.dart delete mode 100644 client_app/lib/features/onboarding/esp_topic.dart delete mode 100644 client_app/lib/features/onboarding/screens/esp_screen.dart create mode 100644 client_app/lib/features/onboarding/usp_seen_provider.dart create mode 100644 client_app/lib/features/onboarding/usp_seen_provider.g.dart create mode 100644 client_app/lib/features/profile/profile_screen.dart create mode 100644 halo_lints/lib/halo_lints.dart create mode 100644 halo_lints/pubspec.lock create mode 100644 halo_lints/pubspec.yaml create mode 100644 requirement/phase4-chat-screen-figma.md create mode 100644 requirement/phase4-esp-removal-usp-gate.md create mode 100644 requirement/resume-2026-05-15.md diff --git a/backend/src/app.public.js b/backend/src/app.public.js index 8a0a89f..a4f01ea 100644 --- a/backend/src/app.public.js +++ b/backend/src/app.public.js @@ -11,6 +11,7 @@ import { mitraChatRoutes } from './routes/public/mitra.chat.routes.js' import { clientChatRoutes } from './routes/public/client.chat.routes.js' import { clientPaymentRoutes } from './routes/public/client.payment.routes.js' import { clientMitraAvailabilityRoutes } from './routes/public/client.mitra-availability.routes.js' +import { publicBestieAvailabilityRoutes } from './routes/public/public.bestie-availability.routes.js' import { clientOnboardingRoutes } from './routes/public/client.onboarding.routes.js' import { clientSupportRoutes } from './routes/public/client.support.routes.js' import { sharedChatRoutes } from './routes/public/shared.chat.routes.js' @@ -36,6 +37,7 @@ export const buildPublicApp = async () => { app.register(clientChatRoutes, { prefix: '/api/client/chat' }) app.register(clientPaymentRoutes, { prefix: '/api/client/payment-sessions' }) app.register(clientMitraAvailabilityRoutes, { prefix: '/api/client/mitra-availability' }) + app.register(publicBestieAvailabilityRoutes, { prefix: '/api/public/bestie' }) // Phase 4: onboarding-state + support handles. Both are tiny so they live in their // own files rather than bloating client.auth.routes / shared.config.routes. app.register(clientOnboardingRoutes, { prefix: '/api/client' }) diff --git a/backend/src/routes/public/client.mitra-availability.routes.js b/backend/src/routes/public/client.mitra-availability.routes.js index 5034d9b..b95b00d 100644 --- a/backend/src/routes/public/client.mitra-availability.routes.js +++ b/backend/src/routes/public/client.mitra-availability.routes.js @@ -3,15 +3,17 @@ import { countAvailableMitrasFromCache } from '../../services/mitra-status.servi import { UserType } from '../../constants.js' /** - * Customer-home availability poll. + * Customer-authed availability poll (kept for CC/debug callers that want the + * raw count). * - * GET /api/client/mitra-availability → 200 { available: bool, count?: number } + * GET /api/client/mitra-availability → 200 { available: bool, count: number } * - * Hot endpoint by design — polled every 5s per active customer while their home is - * foregrounded. Backed by a 10s in-memory cache (see mitra-status.service.js) so DB load + * The customer home polls `/api/public/bestie/available` instead — that route + * is unauthenticated and returns only the boolean, since SHome1st renders + * before the user has any JWT (see `requirement/flow_customer.mermaid.md` §1). + * + * Backed by a 10s in-memory cache (see mitra-status.service.js) so DB load * stays bounded regardless of poller count. No rate limit by intent. - * - * `count` is included for CC/debug; the customer UI must read only `available`. */ export const clientMitraAvailabilityRoutes = async (app) => { app.get('/', { preHandler: [authenticate] }, async (request, reply) => { diff --git a/backend/src/routes/public/public.bestie-availability.routes.js b/backend/src/routes/public/public.bestie-availability.routes.js new file mode 100644 index 0000000..be4f123 --- /dev/null +++ b/backend/src/routes/public/public.bestie-availability.routes.js @@ -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 } }) + }) +} diff --git a/backend/src/services/extension.service.js b/backend/src/services/extension.service.js index 467ccbe..10b6aec 100644 --- a/backend/src/services/extension.service.js +++ b/backend/src/services/extension.service.js @@ -182,7 +182,7 @@ const finalizeExtension = async (extensionId, sessionId, accepted, viaTimeout) = clearClosureGraceTimer(sessionId) // Extend the session - await extendSessionTimer(extension.session_id, extension.requested_duration_minutes) + const extended = await extendSessionTimer(extension.session_id, extension.requested_duration_minutes) // Resume session await sql`UPDATE chat_sessions SET status = ${SessionStatus.ACTIVE} WHERE id = ${extension.session_id}` @@ -194,11 +194,15 @@ const finalizeExtension = async (extensionId, sessionId, accepted, viaTimeout) = FROM chat_sessions WHERE id = ${extension.session_id} ` - // Notify both parties + // Notify both parties. Include the freshly-extended `expires_at` so the + // customer's local seconds-left ticker can resume immediately — without it, + // the client has to wait until the next 60s SESSION_TIMER ping to pick up + // the new deadline, leaving the floating expired banner stuck on-screen. sendToSessionParticipant(sessionId, UserType.CUSTOMER, { type: WsMessage.EXTENSION_RESPONSE, accepted: true, duration_minutes: extension.requested_duration_minutes, + expires_at: extended?.expires_at ?? null, via_timeout: viaTimeout, }) sendToSessionParticipant(sessionId, UserType.CUSTOMER, { diff --git a/backend/src/services/otp.service.js b/backend/src/services/otp.service.js index 09ef08a..91e7a54 100644 --- a/backend/src/services/otp.service.js +++ b/backend/src/services/otp.service.js @@ -22,6 +22,11 @@ const OTP_TTL_MINUTES = 5 // ------------------------------------------------------------------- const generate6DigitCode = () => { + // Dev escape hatch: when OTP_STATIC_CODE is set (6 digits), every stub OTP + // returns this exact value. Lets manual testers skip the peek round-trip. + // Leave unset in production — real Fazpass owns the code there. + const staticCode = process.env.OTP_STATIC_CODE + if (staticCode && /^\d{6}$/.test(staticCode)) return staticCode // Avoid Math.random for OTP generation — use crypto.randomInt return String(crypto.randomInt(0, 1_000_000)).padStart(6, '0') } diff --git a/backend/src/services/pairing.service.js b/backend/src/services/pairing.service.js index 6be07b3..bdd80ec 100644 --- a/backend/src/services/pairing.service.js +++ b/backend/src/services/pairing.service.js @@ -527,16 +527,26 @@ export const declinePairingRequest = async (sessionId, mitraId) => { pairingTimeouts.delete(sessionId) } + // Intermediate failure: payment stays confirmed so the customer can re-blast + // from the S7 timeout CTA. Audit row is still written. if (session.payment_session_id) { - await failPaymentSession(session.payment_session_id, PairingFailureCause.ALL_MITRAS_REJECTED) + const paySession = await getPaymentSession(session.payment_session_id) + if (paySession) { + await recordIntermediateFailure({ + paymentSessionId: session.payment_session_id, + customerId: session.customer_id, + causeTag: PairingFailureCause.ALL_MITRAS_REJECTED, + amount: paySession.amount, + }) + } } - // Terminal: customer is in a searching state and the search just ended with no chat. await notifyCustomer(session.customer_id, { type: WsMessage.PAIRING_FAILED, session_id: sessionId, payment_session_id: session.payment_session_id, cause_tag: PairingFailureCause.ALL_MITRAS_REJECTED, + is_terminal: false, }) } } @@ -686,19 +696,27 @@ export const expirePairingRequest = async (sessionId, causeTag = PairingFailureC WHERE session_id = ${sessionId} AND response IS NULL ` - // Fail the payment session (if any) — terminal. + // Intermediate failure: payment session stays `confirmed` so the customer can + // re-blast on the same payment from the S7 timeout CTA. Audit row is still + // written so the failed-pairing CC view captures every attempt. if (session.payment_session_id) { - await failPaymentSession(session.payment_session_id, causeTag) + const paySession = await getPaymentSession(session.payment_session_id) + if (paySession) { + await recordIntermediateFailure({ + paymentSessionId: session.payment_session_id, + customerId: session.customer_id, + causeTag, + amount: paySession.amount, + }) + } } - // Notify customer via WebSocket (FCM fallback). Terminal pairing failure → PAIRING_FAILED - // so the client can route to the failed-pairing screen consistently with the other - // terminal paths (cancel / all-rejected / payment-expired-mid-search). await notifyCustomer(session.customer_id, { type: WsMessage.PAIRING_FAILED, session_id: sessionId, payment_session_id: session.payment_session_id, cause_tag: causeTag, + is_terminal: false, }) // Notify mitras to dismiss (request expired) — independent fan-out, run in parallel. diff --git a/backend/src/services/payment.service.js b/backend/src/services/payment.service.js index e003252..4ba1f74 100644 --- a/backend/src/services/payment.service.js +++ b/backend/src/services/payment.service.js @@ -273,6 +273,7 @@ export const expireStalePaymentSessions = async () => { type: WsMessage.PAIRING_FAILED, payment_session_id: row.id, cause_tag: PairingFailureCause.PAYMENT_SESSION_EXPIRED, + is_terminal: true, }) if (!wsSent) { await sendPushNotification(UserType.CUSTOMER, row.customer_id, { diff --git a/backend/test/routes/client.usp-seen.routes.test.js b/backend/test/routes/client.usp-seen.routes.test.js new file mode 100644 index 0000000..ff87327 --- /dev/null +++ b/backend/test/routes/client.usp-seen.routes.test.js @@ -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) + }) + }) +}) diff --git a/backend/test/services/extension.service.test.js b/backend/test/services/extension.service.test.js new file mode 100644 index 0000000..abc9073 --- /dev/null +++ b/backend/test/services/extension.service.test.js @@ -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() + }) +}) diff --git a/backend/test/services/pairing.service.test.js b/backend/test/services/pairing.service.test.js index afaf98c..42e452f 100644 --- a/backend/test/services/pairing.service.test.js +++ b/backend/test/services/pairing.service.test.js @@ -61,7 +61,7 @@ describe('pairing.service', () => { vi.clearAllMocks() }) - it('single-recipient general blast → mitra declines → terminates with ALL_MITRAS_REJECTED', async () => { + it('single-recipient general blast → mitra declines → retryable ALL_MITRAS_REJECTED, payment stays confirmed', async () => { // Arrange: confirmed, non-targeted payment session. const pay = await createPaymentSession({ customerId: customer.id, @@ -80,7 +80,7 @@ describe('pairing.service', () => { // classified as a general-blast all-rejected, NOT a targeted reject. await declinePairingRequest(session.id, mitra.id) - // Assert: pairing_failures row carries ALL_MITRAS_REJECTED, not TARGETED_*. + // Assert: pairing_failures audit row carries ALL_MITRAS_REJECTED. const sql = db() const failures = await sql` SELECT cause_tag FROM pairing_failures WHERE payment_session_id = ${pay.id} @@ -88,16 +88,19 @@ describe('pairing.service', () => { expect(failures).toHaveLength(1) expect(failures[0].cause_tag).toBe(PairingFailureCause.ALL_MITRAS_REJECTED) - // Payment session is terminal (failed_pairing) — terminal failures consume the payment. + // Payment session stays CONFIRMED — the customer can re-blast on the same + // payment via the S7 Timeout "coba cari lagi" CTA. const [paySession] = await sql`SELECT status FROM payment_sessions WHERE id = ${pay.id}` - expect(paySession.status).toBe(PaymentSessionStatus.FAILED_PAIRING) + expect(paySession.status).toBe(PaymentSessionStatus.CONFIRMED) - // Customer was notified with PAIRING_FAILED carrying the same cause tag. + // Customer was notified with PAIRING_FAILED carrying is_terminal=false so + // the client renders the retryable variant of the S7 timeout screen. const pairingFailedCalls = sendToUser.mock.calls.filter( ([, , data]) => data?.type === WsMessage.PAIRING_FAILED, ) expect(pairingFailedCalls).toHaveLength(1) expect(pairingFailedCalls[0][2].cause_tag).toBe(PairingFailureCause.ALL_MITRAS_REJECTED) + expect(pairingFailedCalls[0][2].is_terminal).toBe(false) }) it('cancelPairingRequest does NOT push PAIRING_FAILED to the customer', async () => { diff --git a/client_app/.maestro/flows/02_onboarding_verified.yaml b/client_app/.maestro/flows/02_onboarding_verified.yaml index a970113..65354d8 100644 --- a/client_app/.maestro/flows/02_onboarding_verified.yaml +++ b/client_app/.maestro/flows/02_onboarding_verified.yaml @@ -1,6 +1,6 @@ -# Phase 4 Stage 2 — verified onboarding path: -# Splash → onboarding carousel → Welcome → Display Name → Verif Choice Sheet -# (verifikasi nomor HP) → ESP (pick a chip) → USP → Register → OTP (6-digit) +# Phase 4 — verified onboarding path (post-ESP retirement, 2026-05-12): +# Splash → Display Name → Verif Choice Sheet (verifikasi nomor HP) → +# USP one-time gate (first-time user → USP screen) → Register → OTP (6-digit) # → S6 paywall (when first-session-discount eligible) or duration picker. # # Run: @@ -26,6 +26,7 @@ env: BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL} - launchApp: clearState: true +# Onboarding carousel (still present per Phase 4 Stage 9 minimum-touch). - extendedWaitUntil: visible: text: "Mulai" @@ -33,12 +34,14 @@ env: - tapOn: text: "Mulai" retryTapIfNoChange: true +# Phase 4 Stage 9: /welcome is retired. SHome1st CTA "aku mau curhat" +# leads into the onboarding flow. - extendedWaitUntil: visible: - text: "Lanjut sebagai Tamu" + text: "aku mau curhat" timeout: 10000 - tapOn: - text: "Lanjut sebagai Tamu" + text: "aku mau curhat" retryTapIfNoChange: true - extendedWaitUntil: visible: @@ -59,21 +62,14 @@ env: - tapOn: text: "verifikasi nomor HP" retryTapIfNoChange: true -# ESP screen — pick at least one chip then tap "lanjut" -- extendedWaitUntil: - visible: - text: "Lagi mikirin apa?" - timeout: 10000 -- tapOn: - text: "Hubungan" -- tapOn: - text: "lanjut" - retryTapIfNoChange: true -# USP screen +# USP one-time gate — first run after clearState, so usp_seen=false → USP shown. +# Explicitly assert ESP "Lagi mikirin apa?" is NOT visible to catch regression. - extendedWaitUntil: visible: text: "Sebelum mulai" timeout: 10000 +- assertNotVisible: + text: "Lagi mikirin apa?" - tapOn: text: "aku ngerti, lanjut" retryTapIfNoChange: true diff --git a/client_app/.maestro/flows/03_onboarding_anon.yaml b/client_app/.maestro/flows/03_onboarding_anon.yaml index 3c22f0f..c1046d0 100644 --- a/client_app/.maestro/flows/03_onboarding_anon.yaml +++ b/client_app/.maestro/flows/03_onboarding_anon.yaml @@ -1,7 +1,6 @@ -# Phase 4 Stage 2 — anonymous onboarding path: -# Splash → onboarding carousel → Welcome → Display Name → Verif Choice Sheet -# (curhat anonim) → ESP → USP → arrival at /payment/method-pick (Stage 3 -# owns the screen body; this flow stops at route arrival). +# Phase 4 — anonymous onboarding path (post-ESP retirement, 2026-05-12): +# Splash → Display Name → Verif Choice Sheet (curhat anonim) → USP one-time +# gate (first-time user → USP screen) → arrival at /payment/method-pick. # # Run: # maestro test client_app/.maestro/flows/03_onboarding_anon.yaml @@ -14,6 +13,7 @@ appId: com.halobestie.client.client_app --- - launchApp: clearState: true +# Onboarding carousel. - extendedWaitUntil: visible: text: "Mulai" @@ -21,12 +21,15 @@ appId: com.halobestie.client.client_app - tapOn: text: "Mulai" retryTapIfNoChange: true +# Phase 4 Stage 9: SHome1st "aku mau curhat" CTA replaces the old +# /welcome "Lanjut sebagai Tamu" step. Bumped timeout: SHome1st has to +# wait on mitra-availability before the CTA renders. - extendedWaitUntil: visible: - text: "Lanjut sebagai Tamu" - timeout: 10000 + text: "aku mau curhat" + timeout: 20000 - tapOn: - text: "Lanjut sebagai Tamu" + text: "aku mau curhat" retryTapIfNoChange: true - extendedWaitUntil: visible: @@ -47,19 +50,14 @@ appId: com.halobestie.client.client_app - tapOn: text: "curhat anonim" retryTapIfNoChange: true -# ESP screen — leave empty + tap lewati to exercise the skip path -- extendedWaitUntil: - visible: - text: "Lagi mikirin apa?" - timeout: 10000 -- tapOn: - text: "lewati" - retryTapIfNoChange: true -# USP screen +# USP one-time gate — first run after clearState, so usp_seen=false → USP shown. +# Assert ESP "Lagi mikirin apa?" is NOT visible to catch regression. - extendedWaitUntil: visible: text: "Sebelum mulai" timeout: 10000 +- assertNotVisible: + text: "Lagi mikirin apa?" - tapOn: text: "aku ngerti, lanjut" retryTapIfNoChange: true diff --git a/client_app/CLAUDE.md b/client_app/CLAUDE.md index 2724792..68979d4 100644 --- a/client_app/CLAUDE.md +++ b/client_app/CLAUDE.md @@ -26,3 +26,27 @@ Flutter mobile application for end users (clients) seeking mental health support - API calls go through `ApiClient`; it auto-attaches the JWT from `AuthBridge` and auto-refreshes on 401 - WebSocket handshake (`/api/shared/ws`) reads the access token from `AuthBridge` in the first frame's `{type:"auth", token, session_id?}` message - Read `authProvidersProvider` (`core/auth/auth_providers_provider.dart`) to gate any Google/Apple UI — never call `loginGoogle` / `loginApple` from a path reachable when `providers.google` / `providers.apple` is `false` + +## Pitfalls (HARD rules — silent failure modes) + +### Never call `ref.read` / `ref.watch` / `ref.listen` from `State.dispose()` + +In a `ConsumerStatefulWidget`, Riverpod invalidates `ref` the instant `dispose()` starts. Any `ref.*` call throws `Bad state: Cannot use "ref" after the widget was disposed.`. Flutter catches it inside `BuildOwner.finalizeTree` — **so it does not surface as a red-screen crash**. Instead the widget tree is left half-finalized and the NEXT screen freezes (looks like a hang; the app process is alive). Two real cases (2026-05-14): [home_screen.dart] + [payment_screen.dart]. + +**Rule:** any cleanup that needs `ref` goes in `deactivate()`, which runs *before* `dispose()` while `ref` is still valid. Non-Riverpod cleanup (`TextEditingController.dispose()`, `WidgetsBinding.removeObserver`, `StreamSubscription.cancel`) stays in `dispose()`. + +```dart +@override +void deactivate() { + ref.read(someProvider.notifier).cleanup(); + super.deactivate(); +} + +@override +void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); +} +``` + +When debugging "screen frozen after navigation", grep the *previous* screen's State for `void dispose()` followed by `ref\.` — that's the first suspect. diff --git a/client_app/analysis_options.yaml b/client_app/analysis_options.yaml index 0d29021..b704da1 100644 --- a/client_app/analysis_options.yaml +++ b/client_app/analysis_options.yaml @@ -9,6 +9,14 @@ # packages, and plugins designed to encourage good coding practices. include: package:flutter_lints/flutter.yaml +analyzer: + plugins: + # Activates custom_lint, which loads: + # - riverpod_lint (dev_dep) — upstream Riverpod rules + # - halo_lints (path: halo_lints/) — repo-specific rules, e.g. + # `no_ref_in_dispose`. See client_app/CLAUDE.md → Pitfalls. + - custom_lint + linter: # The lint rules applied to this project can be customized in the # section below to disable rules from the `package:flutter_lints/flutter.yaml` diff --git a/client_app/lib/core/api/api_client.dart b/client_app/lib/core/api/api_client.dart index a5ebf37..7da721e 100644 --- a/client_app/lib/core/api/api_client.dart +++ b/client_app/lib/core/api/api_client.dart @@ -54,8 +54,16 @@ class ApiClient { )); } - Future> get(String path, {Map? queryParameters}) async { - final response = await _dio.get(path, queryParameters: queryParameters); + Future> get( + String path, { + Map? 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; } diff --git a/client_app/lib/core/availability/mitra_availability_notifier.dart b/client_app/lib/core/availability/mitra_availability_notifier.dart index e1059c3..0063756 100644 --- a/client_app/lib/core/availability/mitra_availability_notifier.dart +++ b/client_app/lib/core/availability/mitra_availability_notifier.dart @@ -6,16 +6,16 @@ part 'mitra_availability_notifier.g.dart'; /// Customer-home availability poll. /// -/// Polls `GET /api/client/mitra-availability` every 5 seconds while the home -/// screen is in the foreground. Polling is gated by the home screen calling -/// [setActive] from `WidgetsBindingObserver.didChangeAppLifecycleState`: +/// Polls `GET /api/public/bestie/available` every 5 seconds while the home +/// screen is in the foreground. The endpoint is unauthenticated by design — +/// SHome1st renders before any JWT exists, and the CTA's enabled state needs +/// to reflect global availability so users see whether bestie is online +/// before committing to onboarding. Polling is gated by the home screen +/// calling [setActive] from `WidgetsBindingObserver.didChangeAppLifecycleState`: /// - resumed → setActive(true) /// - paused/inactive → setActive(false) /// /// On any HTTP error we emit `false` (never display stale state). -/// -/// The endpoint also returns a `count`, but the customer UI must only read the -/// binary `available` field — the count is for CC/debug only. @Riverpod(keepAlive: true) class MitraAvailability extends _$MitraAvailability { Timer? _pollTimer; @@ -63,7 +63,10 @@ class MitraAvailability extends _$MitraAvailability { bool available; try { final api = ref.read(apiClientProvider); - final response = await api.get('/api/client/mitra-availability'); + final response = await api.get( + '/api/public/bestie/available', + skipAuth: true, + ); final data = response['data'] as Map?; available = data?['available'] as bool? ?? false; } catch (_) { diff --git a/client_app/lib/core/availability/mitra_availability_notifier.g.dart b/client_app/lib/core/availability/mitra_availability_notifier.g.dart index 4624bd1..1fb60f9 100644 --- a/client_app/lib/core/availability/mitra_availability_notifier.g.dart +++ b/client_app/lib/core/availability/mitra_availability_notifier.g.dart @@ -6,21 +6,21 @@ part of 'mitra_availability_notifier.dart'; // RiverpodGenerator // ************************************************************************** -String _$mitraAvailabilityHash() => r'036de8fea66c6a0114abd4a422af12186c1447d7'; +String _$mitraAvailabilityHash() => r'e385c671720973cd1cea4b15933cd59421f035f0'; /// Customer-home availability poll. /// -/// Polls `GET /api/client/mitra-availability` every 5 seconds while the home -/// screen is in the foreground. Polling is gated by the home screen calling -/// [setActive] from `WidgetsBindingObserver.didChangeAppLifecycleState`: +/// Polls `GET /api/public/bestie/available` every 5 seconds while the home +/// screen is in the foreground. The endpoint is unauthenticated by design — +/// SHome1st renders before any JWT exists, and the CTA's enabled state needs +/// to reflect global availability so users see whether bestie is online +/// before committing to onboarding. Polling is gated by the home screen +/// calling [setActive] from `WidgetsBindingObserver.didChangeAppLifecycleState`: /// - resumed → setActive(true) /// - paused/inactive → setActive(false) /// /// On any HTTP error we emit `false` (never display stale state). /// -/// The endpoint also returns a `count`, but the customer UI must only read the -/// binary `available` field — the count is for CC/debug only. -/// /// Copied from [MitraAvailability]. @ProviderFor(MitraAvailability) final mitraAvailabilityProvider = diff --git a/client_app/lib/core/chat/chat_notifier.dart b/client_app/lib/core/chat/chat_notifier.dart index e3d1dfd..046cc18 100644 --- a/client_app/lib/core/chat/chat_notifier.dart +++ b/client_app/lib/core/chat/chat_notifier.dart @@ -456,10 +456,18 @@ class Chat extends _$Chat { case WsMessage.extensionResponse: final accepted = data['accepted'] as bool? ?? false; + // On accept, the backend includes the freshly-extended `expires_at` so + // the local ticker can resume immediately (otherwise it would be stuck + // at 0 / the just-expired moment until the next SESSION_TIMER ping). + final extendedExpiresAtRaw = data['expires_at'] as String?; + final extendedExpiresAt = (accepted && extendedExpiresAtRaw != null) + ? DateTime.tryParse(extendedExpiresAtRaw)?.toLocal() + : null; state = current.copyWith( extensionResponse: data, sessionPaused: accepted ? false : current.sessionPaused, sessionExpired: accepted ? false : current.sessionExpired, + expiresAt: extendedExpiresAt ?? current.expiresAt, ); break; diff --git a/client_app/lib/core/chat/chat_notifier.g.dart b/client_app/lib/core/chat/chat_notifier.g.dart index 6c17cf3..f2cef9a 100644 --- a/client_app/lib/core/chat/chat_notifier.g.dart +++ b/client_app/lib/core/chat/chat_notifier.g.dart @@ -30,7 +30,7 @@ final chatRemainingSecondsProvider = AutoDisposeStreamProvider.internal( @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element typedef ChatRemainingSecondsRef = AutoDisposeStreamProviderRef; -String _$chatHash() => r'f9d77e176acb682dc6477635e0e80a09e846988b'; +String _$chatHash() => r'a42bf404f5b38034c6c9511259b64b1092af3caa'; /// See also [Chat]. @ProviderFor(Chat) diff --git a/client_app/lib/core/chat/session_closure_notifier.g.dart b/client_app/lib/core/chat/session_closure_notifier.g.dart index 421db05..b25b261 100644 --- a/client_app/lib/core/chat/session_closure_notifier.g.dart +++ b/client_app/lib/core/chat/session_closure_notifier.g.dart @@ -6,7 +6,7 @@ part of 'session_closure_notifier.dart'; // RiverpodGenerator // ************************************************************************** -String _$sessionClosureHash() => r'521e57f74805faf01f11778872cb37ceae683a5b'; +String _$sessionClosureHash() => r'a9aaf26da64d3489497d057e2b05741c08444e07'; /// See also [SessionClosure]. @ProviderFor(SessionClosure) diff --git a/client_app/lib/core/pairing/pairing_notifier.dart b/client_app/lib/core/pairing/pairing_notifier.dart index 331752a..6176a15 100644 --- a/client_app/lib/core/pairing/pairing_notifier.dart +++ b/client_app/lib/core/pairing/pairing_notifier.dart @@ -30,9 +30,14 @@ class PairingSearchingData extends PairingData { /// the payment-session-scoped cancel endpoint without re-prompting. final String paymentSessionId; + /// Carried so a retryable PAIRING_FAILED can preserve the customer's original + /// topic choice when looping back into Blast via retryBlast(). + final TopicSensitivity topicSensitivity; + const PairingSearchingData({ required this.sessionId, required this.paymentSessionId, + required this.topicSensitivity, }); } @@ -105,13 +110,26 @@ class PairingTargetedUnavailableData extends PairingData { }); } -/// Terminal pairing failure — payment session is in `failed_pairing`. Routes -/// to the failed-pairing screen (no_bestie_screen). +/// Pairing failure surfaced on the S7 Timeout screen. +/// +/// `isRetryable=true` means the backend kept the payment session `confirmed` +/// (audit-only failure) so the customer can re-blast on the same payment via +/// `retryBlast()`. `isRetryable=false` means the payment is in `failed_pairing` +/// and any retry must start from a fresh payment session. class PairingFailedData extends PairingData { final PairingFailureCause cause; final String? paymentSessionId; + final bool isRetryable; + // Carried so retryBlast() can re-issue the blast with the customer's original + // topic choice. Null when the failure originated before any topic was known. + final TopicSensitivity? topicSensitivity; - const PairingFailedData({required this.cause, this.paymentSessionId}); + const PairingFailedData({ + required this.cause, + this.paymentSessionId, + this.isRetryable = false, + this.topicSensitivity, + }); } class PairingCancelledData extends PairingData { @@ -156,6 +174,7 @@ class Pairing extends _$Pairing { state = PairingSearchingData( sessionId: sessionId, paymentSessionId: paymentSessionId, + topicSensitivity: topicSensitivity, ); } on DioException catch (e) { _cleanup(); @@ -279,6 +298,7 @@ class Pairing extends _$Pairing { state = PairingSearchingData( sessionId: sessionId, paymentSessionId: paymentSessionId, + topicSensitivity: topicSensitivity, ); } on DioException catch (e) { _cleanup(); @@ -302,6 +322,25 @@ class Pairing extends _$Pairing { state = const PairingInitialData(); } + /// "Coba Cari Lagi" CTA on the S7 Timeout screen when the payment was kept + /// `confirmed` (retryable failure). Re-blasts on the same payment session. + /// + /// Caller should only invoke this when `state is PairingFailedData && + /// state.isRetryable && paymentSessionId != null && topicSensitivity != null`. + Future retryBlast() async { + final current = state; + if (current is! PairingFailedData + || !current.isRetryable + || current.paymentSessionId == null + || current.topicSensitivity == null) { + return; + } + await startSearch( + paymentSessionId: current.paymentSessionId!, + topicSensitivity: current.topicSensitivity!, + ); + } + // ---- Internal --------------------------------------------------------- Future _connectWebSocket() async { @@ -348,13 +387,20 @@ class Pairing extends _$Pairing { } if (type == WsMessage.pairingFailed) { - // Terminal — payment_session is in failed_pairing server-side. final causeTag = data['cause_tag'] as String?; final paymentSessionId = data['payment_session_id'] as String?; + // Missing flag = terminal (backward-compat with older emit sites). When + // false, the backend kept the payment confirmed and we can re-blast. + final isRetryable = data['is_terminal'] == false; + final carriedTopic = current is PairingSearchingData + ? current.topicSensitivity + : null; _cleanup(); state = PairingFailedData( cause: PairingFailureCause.fromString(causeTag), paymentSessionId: paymentSessionId, + isRetryable: isRetryable, + topicSensitivity: carriedTopic, ); return; } diff --git a/client_app/lib/features/auth/screens/display_name_screen.dart b/client_app/lib/features/auth/screens/display_name_screen.dart index 55c35f5..c6f57b9 100644 --- a/client_app/lib/features/auth/screens/display_name_screen.dart +++ b/client_app/lib/features/auth/screens/display_name_screen.dart @@ -89,7 +89,7 @@ class _DisplayNameScreenState extends ConsumerState { return; } if (!mounted) return; - routeForVerifChoice(context, choice); + await routeForVerifChoice(context, ref, choice); } @override diff --git a/client_app/lib/features/auth/screens/register_screen.dart b/client_app/lib/features/auth/screens/register_screen.dart index 7e76e58..660e980 100644 --- a/client_app/lib/features/auth/screens/register_screen.dart +++ b/client_app/lib/features/auth/screens/register_screen.dart @@ -1,13 +1,28 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../core/auth/auth_notifier.dart'; -import '../../../core/auth/auth_providers_provider.dart'; import '../../../core/constants.dart'; import '../../../core/theme/halo_tokens.dart'; import '../../../core/theme/widgets/widgets.dart'; +/// S3a — WhatsApp input screen. +/// +/// Visual contract is `Figma/screens/onboarding.jsx::S3Phone`: +/// - HaloStepDots at the top (step 3 of 4: S2 Nama → S5 ESP → S5b USP → S3a) +/// - Personalised display-title `"nomor wa-mu, {name}?"` +/// - +62 prefix as static chip; user types only the trailing digits +/// - Privacy reassurance card +/// - Primary CTA `"kirim kode"` (disabled until ≥9 digits) +/// - Ghost link `"lanjut tanpa verifikasi (harga normal)"` → anonymous path +/// +/// Two callers route here: +/// 1. New-user verified onboarding (USP → here) — auth state has the +/// anonymous display_name set by S2. +/// 2. SHome1st "masuk →" banner for returning-user recovery — auth state +/// is initial; the name greeting falls back to "kamu". class RegisterScreen extends ConsumerStatefulWidget { const RegisterScreen({super.key}); @@ -19,8 +34,9 @@ class _RegisterScreenState extends ConsumerState { final _phoneController = TextEditingController(); ProviderSubscription>? _authSub; - // Server-imposed lockout: when /otp/request returns 429, the backend - // includes retry_after_seconds. We disable "Kirim OTP" for that window. + // Server-imposed lockout from /otp/request 429s. Backend embeds + // retry_after_seconds in the AuthErrorInfo so we can disable the CTA + // until the next slot opens. int _lockoutSeconds = 0; Timer? _lockoutTimer; String? _errorMessage; @@ -28,13 +44,14 @@ class _RegisterScreenState extends ConsumerState { @override void initState() { super.initState(); + _phoneController.addListener(() => setState(() {})); _authSub = ref.listenManual>(authProvider, (prev, next) { if (!mounted) return; final data = next.valueOrNull; if (data is AuthOtpSentData) { - // Use go (replace) so re-submitting the phone form doesn't stack - // multiple OtpScreen instances with active listeners. - context.go('/auth/otp', extra: _phoneController.text.trim()); + // go (replace) so re-submitting the form doesn't stack OtpScreens + // with leftover listeners. + context.go('/auth/otp', extra: _e164Phone()); return; } if (next is AsyncError) { @@ -76,107 +93,130 @@ class _RegisterScreenState extends ConsumerState { }); } + /// Subscriber digits with leading zeros stripped. Users commonly type + /// `0812…` (local format); the backend wants `+62812…`, so the 0 must go. + String _subscriberDigits() { + final digits = _phoneController.text.replaceAll(RegExp(r'\D'), ''); + return digits.replaceFirst(RegExp(r'^0+'), ''); + } + + /// Local digits (no country code) → E.164 string the backend expects. + String _e164Phone() => '+62${_subscriberDigits()}'; + + String _greetingName(AuthData? data) => switch (data) { + AuthAnonymousData d => d.displayName, + AuthAuthenticatedData d => (d.profile['display_name'] as String?) ?? '', + AuthNeedsDisplayNameData d => (d.profile['display_name'] as String?) ?? '', + _ => '', + }; + @override Widget build(BuildContext context) { final authState = ref.watch(authProvider); final isLoading = authState is AsyncLoading; final isLockedOut = _lockoutSeconds > 0; - final canSubmit = !isLoading && !isLockedOut; - final providersAsync = ref.watch(authProvidersProvider); - final providers = - providersAsync.valueOrNull ?? AuthProvidersConfig.fallback; + final hasMinDigits = _subscriberDigits().length >= 9; + final canSubmit = hasMinDigits && !isLoading && !isLockedOut; + + final name = _greetingName(authState.valueOrNull); + final shownName = name.isEmpty ? 'kamu' : name; return Scaffold( - appBar: AppBar(title: const Text('Masuk / Daftar')), + backgroundColor: HaloTokens.bg, body: SafeArea( child: Padding( - padding: const EdgeInsets.all(HaloSpacing.s24), + padding: const EdgeInsets.fromLTRB(28, 8, 28, 28), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (providers.hasAnySocial) ...[ - if (providers.google) ...[ - HaloButton( - label: 'lanjut dengan Google', - icon: const Icon(Icons.g_mobiledata), - variant: HaloButtonVariant.secondary, - fullWidth: true, - onPressed: isLoading - ? null - : () => ref.read(authProvider.notifier).loginGoogle(), - ), - const SizedBox(height: HaloSpacing.s12), - ], - if (providers.apple) ...[ - HaloButton( - label: 'lanjut dengan Apple', - icon: const Icon(Icons.apple), - variant: HaloButtonVariant.secondary, - fullWidth: true, - onPressed: isLoading - ? null - : () => ref.read(authProvider.notifier).loginApple(), - ), - const SizedBox(height: HaloSpacing.s12), - ], - const Padding( - padding: EdgeInsets.symmetric(vertical: HaloSpacing.s16), - child: Row( - children: [ - Expanded(child: Divider(color: HaloTokens.border)), - Padding( - padding: - EdgeInsets.symmetric(horizontal: HaloSpacing.s12), - child: Text( - 'atau', - style: TextStyle( - fontFamily: HaloTokens.fontBody, - color: HaloTokens.inkMuted, - fontSize: 13, - ), + const Padding( + padding: EdgeInsets.only(top: 8, bottom: 8), + child: HaloStepDots(total: 4, current: 3), + ), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'nomor wa-mu, $shownName?', + style: const TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 28, + fontWeight: FontWeight.w700, + color: HaloTokens.brandDark, + height: 1.15, + letterSpacing: -0.56, + ), + ), + const SizedBox(height: 10), + const Text( + 'supaya bisa lanjut kapan aja, dan dapat harga khusus pengguna baru.', + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 14.5, + color: HaloTokens.inkSoft, + height: 1.5, + ), + ), + const SizedBox(height: 24), + _PhoneRow( + controller: _phoneController, + borderColor: hasMinDigits + ? HaloTokens.brand + : HaloTokens.border, + ), + const SizedBox(height: 16), + const _PrivacyCard(), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + Text( + _errorMessage!, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: HaloTokens.fontBody, + color: HaloTokens.danger, + fontSize: 13, ), ), - Expanded(child: Divider(color: HaloTokens.border)), ], - ), + ], ), - ], - TextField( - controller: _phoneController, - decoration: const InputDecoration( - labelText: 'Nomor HP', - hintText: '+628xxxxxxxxxx', - ), - keyboardType: TextInputType.phone, ), - const SizedBox(height: HaloSpacing.s16), HaloButton( label: isLoading ? 'memproses...' : isLockedOut ? 'coba lagi dalam ${formatCountdown(_lockoutSeconds)}' - : 'kirim OTP', + : 'kirim kode', fullWidth: true, onPressed: canSubmit - ? () { - final phone = _phoneController.text.trim(); - if (phone.isEmpty) return; - ref.read(authProvider.notifier).requestOtp(phone); - } + ? () => ref + .read(authProvider.notifier) + .requestOtp(_e164Phone()) : null, ), - if (_errorMessage != null) ...[ - const SizedBox(height: HaloSpacing.s12), - Text( - _errorMessage!, - textAlign: TextAlign.center, - style: const TextStyle( + 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, - color: HaloTokens.danger, - fontSize: 13, + fontSize: 12.5, + decoration: TextDecoration.underline, + decorationColor: HaloTokens.inkSoft, ), ), - ], + ), ], ), ), @@ -184,3 +224,105 @@ class _RegisterScreenState extends ConsumerState { ); } } + +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, + ), + ), + ), + ], + ), + ); + } +} diff --git a/client_app/lib/features/auth/screens/welcome_screen.dart b/client_app/lib/features/auth/screens/welcome_screen.dart deleted file mode 100644 index c865a24..0000000 --- a/client_app/lib/features/auth/screens/welcome_screen.dart +++ /dev/null @@ -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'), - ), - ], - ), - ), - ), - ); - } -} diff --git a/client_app/lib/features/auth/widgets/otp_blocked_popup.dart b/client_app/lib/features/auth/widgets/otp_blocked_popup.dart index fd2e679..45ef388 100644 --- a/client_app/lib/features/auth/widgets/otp_blocked_popup.dart +++ b/client_app/lib/features/auth/widgets/otp_blocked_popup.dart @@ -7,8 +7,12 @@ import '../../support/widgets/tanya_admin_sheet.dart'; /// Modal shown when OTP delivery / verification is exhausted (rate-limited /// 429 from `OTP_RATE_LIMIT_PHONE`, `OTP_RATE_LIMIT_IP`, `OTP_COOLDOWN`, or /// `OTP_ATTEMPTS_EXCEEDED`). Offers a "lanjut tanpa verif" exit into the -/// anonymous flow (preserving any ESP/USP state) and a "hubungi admin" CTA -/// that opens the Tanya Admin sheet. +/// anonymous flow and a "hubungi admin" CTA that opens the Tanya Admin sheet. +/// +/// By the time this popup can fire, the USP one-time gate has already been +/// evaluated upstream on `VerifChoiceSheet` (either shown + marked seen, or +/// skipped because already seen). The exit can therefore jump straight into +/// `/payment/method-pick` regardless. class OtpBlockedPopup { const OtpBlockedPopup._(); @@ -35,12 +39,7 @@ class OtpBlockedPopup { ), primary: HaloPopupAction( label: 'lanjut tanpa verif', - onPressed: () { - // ESP/USP picks live in Riverpod providers (espSelectionProvider, - // espSkippedProvider) and survive this navigation — no need to pass - // them as `extra`. - context.go('/onboarding/anon/method'); - }, + onPressed: () => context.go('/onboarding/anon/method'), ), secondary: HaloPopupAction( label: 'hubungi admin', diff --git a/client_app/lib/features/auth/widgets/verif_choice_sheet.dart b/client_app/lib/features/auth/widgets/verif_choice_sheet.dart index 36ccb74..2c085de 100644 --- a/client_app/lib/features/auth/widgets/verif_choice_sheet.dart +++ b/client_app/lib/features/auth/widgets/verif_choice_sheet.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../core/theme/halo_tokens.dart'; import '../../../core/theme/widgets/widgets.dart'; +import '../../onboarding/usp_seen_provider.dart'; /// Result of the post-name Verif Choice Sheet. Caller routes to the matching /// onboarding sub-flow. @@ -65,13 +67,26 @@ class VerifChoiceSheet extends StatelessWidget { } /// Helper: route to the right onboarding sub-flow for a verif choice. -void routeForVerifChoice(BuildContext context, VerifChoice choice) { +/// +/// Phase 4 (2026-05-12): the S5 ESP screen is retired and S5b USP is now a +/// one-time gate. If the user has already seen USP (local SharedPreferences +/// flag, OR-merged with `customers.usp_seen` on login), we skip USP entirely +/// and jump to the per-branch next step. +Future routeForVerifChoice( + BuildContext context, + WidgetRef ref, + VerifChoice choice, +) async { + final seen = await ref.read(uspSeenProvider.future); + if (!context.mounted) return; switch (choice) { case VerifChoice.verified: - context.push('/onboarding/verif/esp'); + context.push(seen ? '/auth/register' : '/onboarding/verif/usp'); break; case VerifChoice.anonymous: - context.push('/onboarding/anon/esp'); + // `/onboarding/anon/method` redirects to `/payment/method-pick`; use the + // canonical destination here. + context.push(seen ? '/payment/method-pick' : '/onboarding/anon/usp'); break; } } diff --git a/client_app/lib/features/chat/screens/chat_screen.dart b/client_app/lib/features/chat/screens/chat_screen.dart index 4d0b4de..adedf48 100644 --- a/client_app/lib/features/chat/screens/chat_screen.dart +++ b/client_app/lib/features/chat/screens/chat_screen.dart @@ -5,25 +5,19 @@ import 'package:go_router/go_router.dart'; import '../../../core/chat/active_session_notifier.dart'; import '../../../core/chat/chat_notifier.dart'; import '../../../core/chat/session_closure_notifier.dart'; -import '../../../core/config/app_config_provider.dart'; import '../../../core/constants.dart'; import '../../../core/theme/halo_tokens.dart'; import '../../../core/theme/widgets/halo_snackbar.dart'; import '../widgets/bestie_unavailable_dialog.dart'; import '../widgets/chat_expired_banner.dart'; -import '../widgets/closing_message_sheet.dart'; -import '../widgets/confirm_end_step1.dart'; -import '../widgets/confirm_end_step2.dart'; import '../widgets/pricing_bottom_sheet.dart'; -// Chat theme colors -const _kUserBubbleColor = Color(0xFFD4929A); -const _kBgTint = Color(0xFFF5D0D6); -const _kBannerColor = Color(0xFFC4868F); -const _kAccentPink = Color(0xFFBE7C8A); -const _kEndedBannerColor = Color(0xFFFFE0B2); // soft amber for early-end notice -const _kEndedBannerText = Color(0xFF8B5A00); - +/// S10 Chat Room — strict Figma implementation (Phase 4, 2026-05-12). +/// +/// Source-of-truth: `requirement/Figma/screens/session.jsx::S10Chat` (lines +/// 150–284) + `v3.jsx::HBChatExpiredBanner` (line 423). Phase 4 deltas the +/// older design had (entry banners, AppBar `akhiri` button, doodle bg) are +/// dropped — see [requirement/flow_customer.mermaid.md] §5. class ChatScreen extends ConsumerStatefulWidget { final String sessionId; final String mitraName; @@ -40,31 +34,16 @@ class _ChatScreenState extends ConsumerState { final _scrollController = ScrollController(); Timer? _typingThrottle; StreamSubscription? _warningSub; - bool _showBestieBanner = true; - bool _showUserBanner = true; bool _rejectPopupShown = false; - // Per-session-mount idempotency flag for the 3-min snackbar. The backend - // also guards once-per-session (timers.threeMinFired), but a fresh mount - // could still receive the event on a refreshed status pull, so we belt- - // and-braces here. bool _threeMinShown = false; @override void initState() { super.initState(); - // The chat WebSocket is owned globally by `App` (see main.dart). - // We just ask it to be on this session — no-op if it already is. Future.microtask(() { - // Reset any closure state left over from a prior session view (the - // closure notifier is keepAlive, so e.g. `ClosureCompleteData` from - // the last goodbye submission would otherwise leak into this mount - // and suppress the goodbye composer for a fresh `closing` session). ref.read(sessionClosureProvider.notifier).reset(); ref.read(chatProvider.notifier).connectIfNotConnected(widget.sessionId); }); - // Subscribe to the chat notifier's session-warning stream. Using stream - // subscription rather than a `ref.listen` on state because the warning is - // a one-shot signal, not a persistent state field. _warningSub = ref.read(chatProvider.notifier).warningStream.listen((kind) { if (kind == 'three_minutes_left' && !_threeMinShown && mounted) { _threeMinShown = true; @@ -85,23 +64,23 @@ class _ChatScreenState extends ConsumerState { _typingThrottle?.cancel(); _warningSub?.cancel(); super.dispose(); - // Intentionally do NOT disconnect the WS here. The global lifecycle in - // `App` decides when to disconnect (logout / no active session). } void _scrollToBottom() { + void doScroll() { + if (!mounted || !_scrollController.hasClients) return; + _scrollController.jumpTo(_scrollController.position.maxScrollExtent); + } + // Two passes: first captures the new bubble after the rebuild's layout; + // second catches up once the keyboard animation finishes growing + // maxScrollExtent. ~320ms covers the Android soft-keyboard rise. WidgetsBinding.instance.addPostFrameCallback((_) { - if (_scrollController.hasClients) { - _scrollController.animateTo( - _scrollController.position.maxScrollExtent, - duration: const Duration(milliseconds: 200), - curve: Curves.easeOut, - ); - } + doScroll(); + Future.delayed(const Duration(milliseconds: 320), doScroll); }); } - void _onTextChanged(String text) { + void _onTextChanged(String _) { if (_typingThrottle?.isActive ?? false) return; ref.read(chatProvider.notifier).sendTyping(); _typingThrottle = Timer(const Duration(seconds: 2), () {}); @@ -123,43 +102,6 @@ class _ChatScreenState extends ConsumerState { } } - /// Stage 7 entry point — wired to both the AppBar "akhiri sesi" button and - /// the menu equivalent. Reads `endSessionTwoStepConfirmProvider`: when the - /// flag is `true` the user sees step-1 first; when `false` (A/B variant) we - /// jump straight to step-2 (write-message vs skip). - Future _onAkhiriSesiTapped() async { - final twoStep = ref.read(endSessionTwoStepConfirmProvider); - if (!twoStep) { - _showStep2(); - return; - } - await ConfirmEndStep1.show(context, onConfirm: _showStep2); - } - - void _showStep2() { - if (!mounted) return; - ConfirmEndStep2.show( - context, - onWriteMessage: _showClosingSheet, - onSkip: _closeWithoutMessage, - ); - } - - void _showClosingSheet() { - if (!mounted) return; - ClosingMessageSheet.show( - context, - sessionId: widget.sessionId, - onCompleted: _goToThankYou, - ); - } - - Future _closeWithoutMessage() async { - await ref.read(sessionClosureProvider.notifier).closeSession(widget.sessionId); - // Navigation is driven by the closure listener (success path) or the - // ClosureRejectedByMitraData branch (409 fallback popup). - } - void _goToThankYou() { if (!mounted) return; context.go('/chat/thank-you'); @@ -175,18 +117,17 @@ class _ChatScreenState extends ConsumerState { mitraName: widget.mitraName, ); _rejectPopupShown = false; - // Reset closure state so the user can retry without a stale-error block. ref.read(sessionClosureProvider.notifier).reset(); } @override Widget build(BuildContext context) { - final chatState = ref.watch(chatProvider); - final closureState = ref.watch(sessionClosureProvider); + // All `ref.listen` calls — pure side effects, never trigger rebuilds. The + // parent ChatScreen used to `ref.watch(chatProvider)` + `ref.watch(timer)` + // which forced a full-tree rebuild every second (timer ticks) and on every + // WS frame; now those watches live in the leaf widgets that actually need + // them (_ChatHeader for the timer, _ChatBodySection for the message list). - // Stage 7 — closure outcomes drive routing. Success ends in S11 thank-you; - // 409 surfaces the bestie-returning fallback popup (Stage 8 owns the - // dedicated component). ref.listen(sessionClosureProvider, (prev, next) { if (next is ClosureCompleteData) { ref.invalidate(activeSessionProvider); @@ -196,13 +137,8 @@ class _ChatScreenState extends ConsumerState { } }); - // Listen for chat state changes to manage closure state. Stage 7 removed - // the legacy `_showSessionExpiredDialog` modal — the Stage 6 ChatExpiredBanner - // is the in-place replacement, and the user reaches the closing flow via - // the AppBar "akhiri" button. ref.listen(chatProvider, (prev, next) { if (next is ChatConnectedData) { - // Early-end (mitra/customer ended before timer): show goodbye composer. if (next.sessionClosing && !next.sessionExpired) { final closure = ref.read(sessionClosureProvider); if (closure is ClosureInitialData) { @@ -222,16 +158,27 @@ class _ChatScreenState extends ConsumerState { .toList(); if (unread.isNotEmpty) { ref.read(chatProvider.notifier).markRead(unread); - // Optimistically clear the home badge. ref.read(activeSessionProvider.notifier).markRead(); } } }); - // Phase 4 — derived ticker drives the danger pill / expired banner. - // Only watched when there's a connected session with a known expires_at. - final remainingAsync = ref.watch(chatRemainingSecondsProvider); - final remainingTick = remainingAsync.value; + // 3-min snackbar side effect on the timer stream. Listening (not watching) + // means parent doesn't rebuild every second — only this callback fires. + // Backend also emits `session_warning kind=three_minutes_left` (handled in + // initState via `warningStream`); `_threeMinShown` dedupes either path. + ref.listen(chatRemainingSecondsProvider, (prev, next) { + final tick = next.valueOrNull; + if (tick == null) return; + if (tick > 0 && tick <= 180 && !_threeMinShown && mounted) { + _threeMinShown = true; + HaloSnackbar.show(context, 'sisa 3 menit lagi ya 🤍', icon: '⏳'); + } + // Re-arm when the session is extended back above 180s. + if (tick > 180 && _threeMinShown) { + _threeMinShown = false; + } + }); return PopScope( canPop: false, @@ -239,98 +186,63 @@ class _ChatScreenState extends ConsumerState { if (!didPop) _exitChat(); }, child: Scaffold( - appBar: AppBar( - backgroundColor: Colors.white, - foregroundColor: Colors.black, - elevation: 0.5, - centerTitle: true, - leading: IconButton( - icon: const Icon(Icons.chevron_left, size: 28), - onPressed: _exitChat, - ), - title: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, + backgroundColor: HaloTokens.brandSofter, + body: SafeArea( + bottom: false, + child: Column( children: [ - Flexible( - child: Text( - widget.mitraName, - overflow: TextOverflow.ellipsis, + _ChatHeader(mitraName: widget.mitraName, onBack: _exitChat), + Expanded( + child: _ChatBodySection( + sessionId: widget.sessionId, + mitraName: widget.mitraName, + messageController: _messageController, + goodbyeController: _goodbyeController, + scrollController: _scrollController, + onSend: _sendMessage, + onTextChanged: _onTextChanged, ), ), - if (chatState is ChatConnectedData && chatState.mode == SessionMode.call) ...[ - const SizedBox(width: 8), - _buildVoiceCallPill(), - ], ], ), - actions: [ - if (chatState is ChatConnectedData && remainingTick != null) - Padding( - padding: const EdgeInsets.only(right: 4), - child: Center(child: _buildTimerPill(remainingTick)), - ), - if (chatState is ChatConnectedData && - !chatState.sessionClosing) - TextButton( - onPressed: _onAkhiriSesiTapped, - style: TextButton.styleFrom( - foregroundColor: HaloTokens.brandDark, - textStyle: const TextStyle( - fontFamily: HaloTokens.fontBody, - fontSize: 13, - fontWeight: FontWeight.w600, - ), - ), - child: const Text('akhiri'), - ), - ], - ), - body: _buildBody(chatState, closureState, remainingTick), - ), - ); - } - - Widget _buildVoiceCallPill() { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: const BoxDecoration( - color: HaloTokens.accent, - borderRadius: HaloRadius.pill, - ), - child: const Text( - '📞 Voice Call', - style: TextStyle( - fontFamily: HaloTokens.fontBody, - fontSize: 11, - fontWeight: FontWeight.w600, - color: Colors.white, ), ), ); } - Widget _buildTimerPill(int remaining) { - final danger = remaining <= 120; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: danger ? HaloTokens.danger : Colors.transparent, - borderRadius: HaloRadius.pill, - ), - child: Text( - formatCountdown(remaining), - style: TextStyle( - fontFamily: HaloTokens.fontMono, - fontSize: 13, - fontWeight: danger ? FontWeight.w700 : FontWeight.w600, - color: danger ? Colors.white : HaloTokens.ink, - ), - ), - ); - } +} + +// ─── Body section ────────────────────────────────────────────────────────── +// +// Watches `chatProvider` + `sessionClosureProvider` and rebuilds only on those. +// The timer stream is NOT watched here — the lowTime/expired banners that need +// it live inside a tiny dedicated `Consumer` so timer ticks rebuild ONLY that +// banner, not the message list or the input bar. + +class _ChatBodySection extends ConsumerWidget { + final String sessionId; + final String mitraName; + final TextEditingController messageController; + final TextEditingController goodbyeController; + final ScrollController scrollController; + final VoidCallback onSend; + final ValueChanged onTextChanged; + + const _ChatBodySection({ + required this.sessionId, + required this.mitraName, + required this.messageController, + required this.goodbyeController, + required this.scrollController, + required this.onSend, + required this.onTextChanged, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final chatState = ref.watch(chatProvider); + final closureState = ref.watch(sessionClosureProvider); - Widget _buildBody(ChatData chatState, SessionClosureData closureState, int? remainingTick) { if (chatState is ChatConnectingData) { return const Center(child: CircularProgressIndicator()); } @@ -338,19 +250,17 @@ class _ChatScreenState extends ConsumerState { return Center(child: Text(chatState.message)); } if (chatState is ChatConnectedData) { - return _buildChatBody(chatState, closureState, remainingTick); + return _buildChatBody(context, ref, chatState, closureState); } return const SizedBox.shrink(); } - Widget _buildChatBody(ChatConnectedData state, SessionClosureData closureState, int? remainingTick) { - // Show goodbye composer when closure flow is in goodbye/submitting OR when - // we mounted directly into a `closing` session (e.g. opened from history). - // The chatProvider listener can't catch this case because it only fires on - // transitions, not the current state at mount time. - // Suppress when the customer has already submitted their goodbye — the - // session can stay in `closing` while waiting for the mitra to submit - // their own message or for the 5-min grace timer to auto-complete. + Widget _buildChatBody( + BuildContext context, + WidgetRef ref, + ChatConnectedData state, + SessionClosureData closureState, + ) { final shouldShowGoodbye = !state.goodbyeSubmitted && (closureState is ClosureShowGoodbyeData || closureState is ClosureSubmittingData || @@ -358,259 +268,147 @@ class _ChatScreenState extends ConsumerState { !state.sessionExpired && closureState is! ClosureCompleteData)); if (shouldShowGoodbye) { - return _buildGoodbyeView(closureState); + return _buildGoodbyeView(ref, closureState); } - if (state.sessionClosing && state.goodbyeSubmitted) { return _buildAwaitingMitraGoodbyeView(state); } - if (state.sessionPaused) { return _buildPausedView(); } - return Stack( + return Column( children: [ - // Background pattern - Positioned.fill( - child: Container( - color: _kBgTint, - child: Image.asset( - 'assets/images/chat_pattern.png', - repeat: ImageRepeat.repeat, - fit: BoxFit.none, - ), + Expanded( + child: ListView.builder( + controller: scrollController, + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + itemCount: state.messages.length + (state.isOtherTyping ? 1 : 0), + itemBuilder: (listCtx, index) { + if (state.isOtherTyping && index == state.messages.length) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 4), + child: Align( + alignment: Alignment.centerLeft, + child: _TypingPill(), + ), + ); + } + final msg = state.messages[index]; + final isMe = msg.senderType == UserType.customer; + return _MessageBubble(msg: msg, isMe: isMe); + }, ), ), - // Content - Column( - children: [ - // Entry banners - if (_showBestieBanner) - _buildEntryBanner( - '[Bestie] Sudah Memasuki Ruangan', - () => setState(() => _showBestieBanner = false), - ), - if (_showUserBanner) - _buildEntryBanner( - '[User] Sudah Memasuki Ruangan', - () => setState(() => _showUserBanner = false), - ), - // Messages - Expanded( - child: ListView.builder( - controller: _scrollController, - padding: const EdgeInsets.all(16), - itemCount: state.messages.length, - itemBuilder: (context, index) { - final msg = state.messages[index]; - final isMe = msg.senderType == UserType.customer; - return _buildMessageBubble(msg, isMe); - }, - ), - ), - // Typing indicator - if (state.isOtherTyping) - const Padding( - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: Align( - alignment: Alignment.centerLeft, - child: Text('Bestie sedang mengetik...', style: TextStyle(color: Colors.grey, fontSize: 12)), - ), - ), - // Floating expired banner — visible while the timer has hit zero - // and the session hasn't been finalized yet (still in closing - // grace). Tapping `perpanjang` opens the time-up sheet, same as - // the modal route. - if (remainingTick != null && remainingTick <= 0) - ChatExpiredBanner( - onExtend: () => PricingBottomSheet.showForExtension( - context, - sessionId: widget.sessionId, - ), - ), - // Input bar — disabled when timer expired (modal handles next step) - if (!state.sessionExpired) _buildInputBar(), - ], - ), + // Banner gating runs on the timer stream — scoped to its own Consumer + // so only the banner widget rebuilds every second, not the list or + // input bar above/below. + _TimerBanner(sessionId: sessionId, mitraName: mitraName), + if (!state.sessionExpired) ...[ + _InputBar( + controller: messageController, + onChanged: onTextChanged, + onSend: onSend, + ), + const _EncryptedFooter(), + ], ], ); } - Widget _buildEntryBanner(String text, VoidCallback onDismiss) { - return Container( - color: _kBannerColor, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Row( - children: [ - const Icon(Icons.volume_up, color: Colors.white, size: 18), - const SizedBox(width: 8), - Expanded( - child: Text(text, style: const TextStyle(color: Colors.white, fontSize: 13)), - ), - GestureDetector( - onTap: onDismiss, - child: const Icon(Icons.close, color: Colors.white, size: 18), - ), - ], - ), - ); - } - - Widget _buildMessageBubble(ChatMessage msg, bool isMe) { - return Align( - alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, - child: Container( - margin: const EdgeInsets.symmetric(vertical: 4), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.75), - decoration: BoxDecoration( - color: isMe ? _kUserBubbleColor : Colors.white, - borderRadius: BorderRadius.circular(16), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text(msg.content, style: const TextStyle(fontSize: 15)), - const SizedBox(height: 4), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '${msg.createdAt.hour.toString().padLeft(2, '0')}:${msg.createdAt.minute.toString().padLeft(2, '0')}', - style: TextStyle(fontSize: 10, color: isMe ? Colors.white70 : Colors.grey), - ), - if (isMe) ...[ - const SizedBox(width: 4), - _buildStatusIcon(msg.status), - ], - ], - ), - ], - ), - ), - ); - } - - Widget _buildStatusIcon(String status) { - switch (status) { - case 'sending': - return const Icon(Icons.access_time, size: 14, color: Colors.white70); - case MessageStatus.sent: - return const Icon(Icons.check, size: 14, color: Colors.white70); - case MessageStatus.delivered: - return const Icon(Icons.done_all, size: 14, color: Colors.white70); - case MessageStatus.read: - return const Icon(Icons.done_all, size: 14, color: Colors.white); - default: - return const SizedBox.shrink(); - } - } - - Widget _buildInputBar() { - return SafeArea( - child: Container( - padding: const EdgeInsets.all(8), - color: Colors.white, - child: Row( - children: [ - Expanded( - child: TextField( - controller: _messageController, - onChanged: _onTextChanged, - textInputAction: TextInputAction.send, - onSubmitted: (_) => _sendMessage(), - decoration: InputDecoration( - hintText: 'Ketik Pesan', - hintStyle: TextStyle(color: Colors.grey.shade400), - filled: true, - fillColor: Colors.grey.shade100, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), - borderSide: BorderSide.none, - ), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - ), - ), - ), - const SizedBox(width: 8), - Container( - decoration: const BoxDecoration( - color: _kAccentPink, - shape: BoxShape.circle, - ), - child: IconButton( - icon: const Icon(Icons.send, color: Colors.white, size: 20), - onPressed: _sendMessage, - ), - ), - ], - ), - ), - ); - } - - // TODO(phase4-followup): Stage 7 moved the customer-initiated goodbye flow - // to ClosingMessageSheet. This inline composer is still reachable when the - // mitra ends a session early (sessionClosing fired by the server). Migrate - // that path to the new sheet too once the early-end UX is finalised. - Widget _buildGoodbyeView(SessionClosureData closureState) { + // Inline goodbye composer for the mitra-initiated early-end case + // (sessionClosing true, customer hasn't been routed through the + // ClosingMessageSheet). Primary path is the dedicated [ClosingMessageSheet]. + Widget _buildGoodbyeView(WidgetRef ref, SessionClosureData closureState) { return SingleChildScrollView( padding: const EdgeInsets.all(32), child: Column( children: [ - // Early-end banner — visually distinct, separate from the closing - // composer below. We don't surface "Perpanjang" in this flow. Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( - color: _kEndedBannerColor, - borderRadius: BorderRadius.circular(8), + color: HaloTokens.accentSoft, + borderRadius: BorderRadius.circular(12), ), child: const Row( children: [ - Icon(Icons.info_outline, color: _kEndedBannerText, size: 20), + Icon(Icons.info_outline, color: HaloTokens.brandDark, size: 20), SizedBox(width: 8), Expanded( child: Text( - 'Sesi telah ditutup oleh Bestie', - style: TextStyle(color: _kEndedBannerText, fontWeight: FontWeight.w600), + 'sesi telah ditutup oleh bestie', + style: TextStyle( + fontFamily: HaloTokens.fontBody, + color: HaloTokens.brandDark, + fontWeight: FontWeight.w600, + ), ), ), ], ), ), const SizedBox(height: 32), - const Icon(Icons.waving_hand, size: 64, color: Colors.amber), + const Text('🤍', style: TextStyle(fontSize: 48)), const SizedBox(height: 16), - const Text('Pesan Penutup', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const Text( + 'pesan penutup', + style: TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 22, + fontWeight: FontWeight.w700, + color: HaloTokens.ink, + ), + ), const SizedBox(height: 8), - const Text('Tuliskan pesan terakhirmu untuk Bestie', textAlign: TextAlign.center), + const Text( + 'tuliskan pesan terakhirmu untuk bestie', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: HaloTokens.fontBody, + color: HaloTokens.inkSoft, + ), + ), const SizedBox(height: 24), TextField( - controller: _goodbyeController, + controller: goodbyeController, maxLines: 3, decoration: InputDecoration( - hintText: 'Terima kasih, Bestie...', - border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + hintText: 'terima kasih, bestie...', + filled: true, + fillColor: HaloTokens.surface, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: HaloTokens.border), + ), ), ), const SizedBox(height: 16), ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: HaloTokens.brand, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)), + ), onPressed: closureState is ClosureSubmittingData ? null : () { - final text = _goodbyeController.text.trim(); + final text = goodbyeController.text.trim(); if (text.isNotEmpty) { ref.read(sessionClosureProvider.notifier).submitGoodbye( - widget.sessionId, text, - ); + sessionId, + text, + ); } }, child: closureState is ClosureSubmittingData - ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) - : const Text('Kirim & Selesai'), + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + : const Text('kirim & selesai'), ), ], ), @@ -626,9 +424,22 @@ class _ChatScreenState extends ConsumerState { children: [ CircularProgressIndicator(), SizedBox(height: 24), - Text('Menunggu konfirmasi Bestie...', style: TextStyle(fontSize: 18)), + Text( + 'menunggu konfirmasi bestie...', + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 16, + color: HaloTokens.ink, + ), + ), SizedBox(height: 8), - Text('Chat dijeda sementara', style: TextStyle(color: Colors.grey)), + Text( + 'chat dijeda sementara', + style: TextStyle( + fontFamily: HaloTokens.fontBody, + color: HaloTokens.inkSoft, + ), + ), ], ), ), @@ -636,52 +447,658 @@ class _ChatScreenState extends ConsumerState { } Widget _buildAwaitingMitraGoodbyeView(ChatConnectedData state) { - return Stack( + return Column( children: [ - Positioned.fill( - child: Container( - color: _kBgTint, - child: Image.asset( - 'assets/images/chat_pattern.png', - repeat: ImageRepeat.repeat, - fit: BoxFit.none, - ), + Container( + width: double.infinity, + color: HaloTokens.accentSoft, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: const Row( + children: [ + Icon(Icons.hourglass_top, color: HaloTokens.brandDark, size: 20), + SizedBox(width: 8), + Expanded( + child: Text( + 'pesan penutupmu sudah terkirim. menunggu bestie...', + style: TextStyle( + fontFamily: HaloTokens.fontBody, + color: HaloTokens.brandDark, + fontWeight: FontWeight.w600, + ), + ), + ), + ], ), ), - Column( - children: [ - Container( - width: double.infinity, - color: _kEndedBannerColor, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: const Row( - children: [ - Icon(Icons.hourglass_top, color: _kEndedBannerText, size: 20), - SizedBox(width: 8), - Expanded( - child: Text( - 'Pesan penutupmu sudah terkirim. Menunggu Bestie...', - style: TextStyle(color: _kEndedBannerText, fontWeight: FontWeight.w600), - ), - ), - ], - ), - ), - Expanded( - child: ListView.builder( - controller: _scrollController, - padding: const EdgeInsets.all(16), - itemCount: state.messages.length, - itemBuilder: (context, index) { - final msg = state.messages[index]; - final isMe = msg.senderType == UserType.customer; - return _buildMessageBubble(msg, isMe); - }, - ), - ), - ], + Expanded( + child: ListView.builder( + controller: scrollController, + padding: const EdgeInsets.all(16), + itemCount: state.messages.length, + itemBuilder: (context, index) { + final msg = state.messages[index]; + final isMe = msg.senderType == UserType.customer; + return _MessageBubble(msg: msg, isMe: isMe); + }, + ), ), ], ); } } + +// Tiny dedicated consumer for the in-chat low-time / expired banner. Scoped +// here so timer ticks rebuild only this widget — the message list above and +// input bar below stay still. Uses `.select` to collapse the timer stream to +// a 3-state enum so the rebuild only fires on banner-state transitions, not +// every second. +enum _BannerKind { none, lowTime, expired } + +class _TimerBanner extends ConsumerWidget { + final String sessionId; + final String mitraName; + const _TimerBanner({required this.sessionId, required this.mitraName}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final kind = ref.watch(chatRemainingSecondsProvider.select((async) { + final r = async.valueOrNull; + if (r == null) return _BannerKind.none; + if (r <= 0) return _BannerKind.expired; + if (r < 120) return _BannerKind.lowTime; + return _BannerKind.none; + })); + void onExtend() { + PricingBottomSheet.showForExtension(context, sessionId: sessionId); + } + switch (kind) { + case _BannerKind.lowTime: + return _SoftWarningBanner(mitraName: mitraName, onExtend: onExtend); + case _BannerKind.expired: + return ChatExpiredBanner(mitraName: mitraName, onExtend: onExtend); + case _BannerKind.none: + return const SizedBox.shrink(); + } + } +} + +// ─── Header (back · orb · name+status · timer pill) + progress bar ────────── + +class _ChatHeader extends ConsumerStatefulWidget { + final String mitraName; + final VoidCallback onBack; + + const _ChatHeader({required this.mitraName, required this.onBack}); + + @override + ConsumerState<_ChatHeader> createState() => _ChatHeaderState(); +} + +class _ChatHeaderState extends ConsumerState<_ChatHeader> { + // Progress-bar denominator. ChatConnectedData doesn't carry the session's + // total duration, so we infer it as the max remaining we've seen since + // mount. First tick after a fresh connect is effectively `total`; later + // extensions raise it back up. + int? _observedTotalSeconds; + + @override + Widget build(BuildContext context) { + final remainingSeconds = ref.watch(chatRemainingSecondsProvider).valueOrNull; + // Only the `isOtherTyping` field of the chat state matters here. `.select` + // means this widget rebuilds only when that boolean flips, not on every + // message / status update. + final isOtherTyping = ref.watch(chatProvider.select( + (s) => s is ChatConnectedData && s.isOtherTyping, + )); + + if (remainingSeconds != null && + remainingSeconds > 0 && + (_observedTotalSeconds == null || remainingSeconds > _observedTotalSeconds!)) { + _observedTotalSeconds = remainingSeconds; + } + final totalSeconds = _observedTotalSeconds; + final lowTime = remainingSeconds != null && + remainingSeconds > 0 && + remainingSeconds < 120; + final progress = (remainingSeconds != null && totalSeconds != null && totalSeconds > 0) + ? (remainingSeconds / totalSeconds).clamp(0.0, 1.0) + : null; + + return Container( + color: HaloTokens.surface, + child: Column( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: HaloTokens.border)), + ), + child: Row( + children: [ + _CircleIconButton(icon: Icons.chevron_left, onTap: widget.onBack), + const SizedBox(width: 12), + _MitraOrb(seed: widget.mitraName.hashCode), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.mitraName, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 14, + fontWeight: FontWeight.w600, + color: HaloTokens.ink, + ), + ), + const SizedBox(height: 2), + Row( + children: [ + Container( + width: 6, + height: 6, + decoration: const BoxDecoration( + color: HaloTokens.success, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 4), + Text( + isOtherTyping ? 'online · ngetik...' : 'online', + style: const TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 11, + color: HaloTokens.success, + ), + ), + ], + ), + ], + ), + ), + if (remainingSeconds != null && remainingSeconds > 0) + _TimerPill(seconds: remainingSeconds, lowTime: lowTime), + ], + ), + ), + // Progress bar (3px) below the header + if (progress != null) + Container( + height: 3, + color: HaloTokens.border, + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: progress, + child: AnimatedContainer( + duration: const Duration(seconds: 1), + color: lowTime ? const Color(0xFFFF8848) : HaloTokens.brand, + ), + ), + ), + ], + ), + ); + } +} + +class _CircleIconButton extends StatelessWidget { + final IconData icon; + final VoidCallback onTap; + const _CircleIconButton({required this.icon, required this.onTap}); + + @override + Widget build(BuildContext context) { + return InkResponse( + onTap: onTap, + radius: 22, + child: Container( + width: 36, + height: 36, + decoration: const BoxDecoration( + color: HaloTokens.brandSofter, + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Icon(icon, color: HaloTokens.brandDark, size: 22), + ), + ); + } +} + +/// Stand-in for the Figma `HBOrb` gradient avatar. Memory tracks this as a +/// Phase-4 follow-up — for now a deterministic two-stop gradient circle keeps +/// the same visual weight without depending on the unported component. +class _MitraOrb extends StatelessWidget { + final int seed; + const _MitraOrb({required this.seed}); + + @override + Widget build(BuildContext context) { + final palette = _palettes[seed.abs() % _palettes.length]; + return Container( + width: 36, + height: 36, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: palette, + ), + ), + ); + } + + static const _palettes = >[ + [Color(0xFFE17A9D), Color(0xFFF7B26A)], + [Color(0xFFB8DBC8), Color(0xFFE17A9D)], + [Color(0xFFD4C5E8), Color(0xFFF7B26A)], + [Color(0xFFF7B26A), Color(0xFFE17A9D)], + ]; +} + +class _TimerPill extends StatelessWidget { + final int seconds; + final bool lowTime; + const _TimerPill({required this.seconds, required this.lowTime}); + + @override + Widget build(BuildContext context) { + final mm = (seconds ~/ 60).toString().padLeft(2, '0'); + final ss = (seconds % 60).toString().padLeft(2, '0'); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: lowTime ? const Color(0xFFFFF0E5) : HaloTokens.brandSofter, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: lowTime ? const Color(0xFFFFB088) : HaloTokens.brandSoft, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + 'SISA WAKTU', + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 9.5, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + color: lowTime ? const Color(0xFFA8410E) : HaloTokens.brandDark, + ), + ), + const SizedBox(height: 2), + Text( + '$mm:$ss', + style: TextStyle( + fontFamily: HaloTokens.fontMono, + fontSize: 16, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + color: lowTime ? const Color(0xFFA8410E) : HaloTokens.brandDark, + ), + ), + ], + ), + ); + } +} + +// ─── Messages ────────────────────────────────────────────────────────────── + +class _MessageBubble extends StatelessWidget { + final ChatMessage msg; + final bool isMe; + const _MessageBubble({required this.msg, required this.isMe}); + + @override + Widget build(BuildContext context) { + final hh = msg.createdAt.hour.toString().padLeft(2, '0'); + final mm = msg.createdAt.minute.toString().padLeft(2, '0'); + final bubbleColor = isMe ? HaloTokens.brand : HaloTokens.surface; + final textColor = isMe ? Colors.white : HaloTokens.ink; + final timeColor = isMe ? Colors.white70 : HaloTokens.inkMuted; + return Align( + alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.78, + ), + decoration: BoxDecoration( + color: bubbleColor, + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(18), + topRight: const Radius.circular(18), + bottomLeft: Radius.circular(isMe ? 18 : 4), + bottomRight: Radius.circular(isMe ? 4 : 18), + ), + boxShadow: isMe + ? null + : [ + BoxShadow( + color: HaloTokens.brandDark.withValues(alpha: 0.06), + blurRadius: 2, + offset: const Offset(0, 1), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + msg.content, + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 14, + height: 1.45, + color: textColor, + ), + ), + const SizedBox(height: 4), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '$hh:$mm', + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 10, + color: timeColor, + ), + ), + if (isMe) ...[ + const SizedBox(width: 4), + _StatusIcon(status: msg.status), + ], + ], + ), + ], + ), + ), + ); + } +} + +class _StatusIcon extends StatelessWidget { + final String status; + const _StatusIcon({required this.status}); + + @override + Widget build(BuildContext context) { + switch (status) { + case 'sending': + return const Icon(Icons.access_time, size: 14, color: Colors.white70); + case MessageStatus.sent: + return const Icon(Icons.check, size: 14, color: Colors.white70); + case MessageStatus.delivered: + return const Icon(Icons.done_all, size: 14, color: Colors.white70); + case MessageStatus.read: + return const Icon(Icons.done_all, size: 14, color: Colors.white); + default: + return const SizedBox.shrink(); + } + } +} + +/// Three-dot animated typing pill, rendered as a bestie-side message bubble. +class _TypingPill extends StatefulWidget { + const _TypingPill(); + + @override + State<_TypingPill> createState() => _TypingPillState(); +} + +class _TypingPillState extends State<_TypingPill> with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1400), + )..repeat(); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: HaloTokens.surface, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(18), + topRight: Radius.circular(18), + bottomLeft: Radius.circular(4), + bottomRight: Radius.circular(18), + ), + boxShadow: [ + BoxShadow( + color: HaloTokens.brandDark.withValues(alpha: 0.06), + blurRadius: 2, + offset: const Offset(0, 1), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(3, (i) { + return Padding( + padding: EdgeInsets.only(right: i == 2 ? 0 : 4), + child: AnimatedBuilder( + animation: _ctrl, + builder: (_, __) { + final phase = (_ctrl.value + i * 0.2) % 1.0; + final t = phase < 0.4 ? phase / 0.4 : 1 - (phase - 0.4) / 0.6; + final opacity = 0.3 + (0.7 * t.clamp(0.0, 1.0)); + return Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: HaloTokens.brand.withValues(alpha: opacity), + shape: BoxShape.circle, + ), + ); + }, + ), + ); + }), + ), + ); + } +} + +// ─── 2-minute soft-warning banner ────────────────────────────────────────── + +class _SoftWarningBanner extends StatelessWidget { + final String mitraName; + final VoidCallback onExtend; + const _SoftWarningBanner({required this.mitraName, required this.onExtend}); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.fromLTRB(12, 0, 12, 8), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: const Color(0xFFFFF0E5), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFFD8B8)), + ), + child: Row( + children: [ + const Text('⏳', style: TextStyle(fontSize: 16)), + const SizedBox(width: 10), + Expanded( + child: RichText( + text: TextSpan( + style: const TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 12, + height: 1.4, + color: Color(0xFF7A3E08), + ), + children: [ + const TextSpan( + text: 'habis... ', + style: TextStyle(fontWeight: FontWeight.w700), + ), + TextSpan(text: 'mau lanjutin curhat sama $mitraName?'), + ], + ), + ), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: onExtend, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFFF8848), + foregroundColor: Colors.white, + 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: 12, + fontWeight: FontWeight.w600, + ), + ), + child: const Text('+30 menit'), + ), + ], + ), + ); + } +} + +// ─── Input bar (+ button · rounded field · send arrow) ───────────────────── + +class _InputBar extends StatelessWidget { + final TextEditingController controller; + final ValueChanged onChanged; + final VoidCallback onSend; + const _InputBar({ + required this.controller, + required this.onChanged, + required this.onSend, + }); + + @override + Widget build(BuildContext context) { + return Container( + color: HaloTokens.surface, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + // `+` attachment — placeholder (no attachment flow yet in this phase). + _CircleIconButton(icon: Icons.add, onTap: () {}), + const SizedBox(width: 8), + Expanded( + child: SizedBox( + height: 40, + child: Material( + color: HaloTokens.bg, + shape: const StadiumBorder(), + clipBehavior: Clip.antiAlias, + // Center wraps the (intentionally collapsed) TextField so it + // sits vertically centered in the 40px pill — without it the + // field anchors to the top because `isCollapsed: true` zeroes + // out the decoration's vertical padding, and + // `textAlignVertical` is a no-op on a collapsed field. + child: Center( + child: TextField( + controller: controller, + onChanged: onChanged, + textInputAction: TextInputAction.send, + onSubmitted: (_) => onSend(), + maxLines: 1, + textAlignVertical: TextAlignVertical.center, + style: const TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 14, + color: HaloTokens.ink, + ), + // The app-wide InputDecorationTheme (halo_theme.dart) ships + // form-style defaults — filled white, 64px min-height, brand + // focused border. None of those are wanted on the chat input + // pill, so override every relevant property explicitly here + // rather than rely on `border: none` (which only nukes the + // default border, not focused/enabled variants or the fill). + decoration: const InputDecoration( + filled: false, + fillColor: Colors.transparent, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + errorBorder: InputBorder.none, + focusedErrorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + isCollapsed: true, + contentPadding: EdgeInsets.symmetric(horizontal: 16), + constraints: BoxConstraints(), + hintText: 'tulis sesuatu...', + hintStyle: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 14, + color: HaloTokens.inkMuted, + ), + ), + ), + ), + ), + ), + ), + const SizedBox(width: 8), + InkResponse( + onTap: onSend, + radius: 22, + child: Container( + width: 40, + height: 40, + decoration: const BoxDecoration( + color: HaloTokens.brand, + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: const Icon(Icons.arrow_upward, color: Colors.white, size: 18), + ), + ), + ], + ), + ); + } +} + +class _EncryptedFooter extends StatelessWidget { + const _EncryptedFooter(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + color: HaloTokens.surface, + padding: const EdgeInsets.fromLTRB(12, 0, 12, 16), + alignment: Alignment.centerRight, + child: const Text( + 'terenkripsi · gak disimpan 🔒', + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 11, + color: HaloTokens.inkMuted, + ), + ), + ); + } +} diff --git a/client_app/lib/features/chat/screens/searching_screen.dart b/client_app/lib/features/chat/screens/searching_screen.dart index b8c26ba..eede89f 100644 --- a/client_app/lib/features/chat/screens/searching_screen.dart +++ b/client_app/lib/features/chat/screens/searching_screen.dart @@ -191,8 +191,14 @@ class _SearchingBody extends ConsumerWidget { fullWidth: true, size: HaloButtonSize.lg, onPressed: () { - ref.read(pairingProvider.notifier).reset(); - context.go('/payment/entry'); + final notifier = ref.read(pairingProvider.notifier); + final s = state; + if (s is PairingFailedData && s.isRetryable) { + notifier.retryBlast(); + } else { + notifier.reset(); + context.go('/payment/entry'); + } }, ), const SizedBox(height: HaloSpacing.s8), diff --git a/client_app/lib/features/chat/widgets/chat_expired_banner.dart b/client_app/lib/features/chat/widgets/chat_expired_banner.dart index 9632e65..fb594e8 100644 --- a/client_app/lib/features/chat/widgets/chat_expired_banner.dart +++ b/client_app/lib/features/chat/widgets/chat_expired_banner.dart @@ -1,56 +1,78 @@ import 'package:flutter/material.dart'; import '../../../core/theme/halo_tokens.dart'; -import '../../../core/theme/widgets/halo_button.dart'; -/// Floating banner injected above the chat input bar when the session timer -/// has hit zero but the session is still in `closing` grace. Phase 4 Stage 6: -/// gives the customer a soft, in-place way to extend instead of the modal-only -/// flow from Phase 3. +/// Floating expired banner shown above the chat input when the session +/// timer has hit zero but the session is still in `closing` grace. +/// +/// Mirrors Figma `v3.jsx::HBChatExpiredBanner` (line 423): brand-pink +/// background, white text, `⏰` icon, "habis nih... mau lanjutin curhat +/// sama {name}?" copy, white `perpanjang` button. class ChatExpiredBanner extends StatelessWidget { + final String mitraName; final VoidCallback onExtend; - const ChatExpiredBanner({super.key, required this.onExtend}); + const ChatExpiredBanner({ + super.key, + required this.mitraName, + required this.onExtend, + }); @override Widget build(BuildContext context) { return Container( - margin: const EdgeInsets.fromLTRB( - HaloSpacing.s12, - HaloSpacing.s8, - HaloSpacing.s12, - HaloSpacing.s8, - ), - padding: const EdgeInsets.fromLTRB( - HaloSpacing.s16, - HaloSpacing.s12, - HaloSpacing.s12, - HaloSpacing.s12, - ), - decoration: const BoxDecoration( - color: HaloTokens.danger, - borderRadius: HaloRadius.lg, - boxShadow: HaloShadows.card, + margin: const EdgeInsets.fromLTRB(12, 0, 12, 8), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: HaloTokens.brand, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: HaloTokens.brand.withValues(alpha: 0.31), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], ), child: Row( children: [ - const Text('⏰', style: TextStyle(fontSize: 20)), - const SizedBox(width: HaloSpacing.s12), - const Expanded( - child: Text( - 'waktu curhat habis', - style: TextStyle( - fontFamily: HaloTokens.fontBody, - fontSize: 14, - fontWeight: FontWeight.w600, - color: Colors.white, + const Text('⏰', style: TextStyle(fontSize: 16)), + const SizedBox(width: 10), + Expanded( + child: RichText( + text: TextSpan( + style: const TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 12.5, + height: 1.4, + color: Colors.white, + ), + children: [ + const TextSpan( + text: 'habis nih...', + style: TextStyle(fontWeight: FontWeight.w600), + ), + TextSpan(text: ' mau lanjutin curhat sama $mitraName?'), + ], ), ), ), - HaloButton( - label: 'perpanjang', - size: HaloButtonSize.sm, - variant: HaloButtonVariant.secondary, + const SizedBox(width: 8), + ElevatedButton( onPressed: onExtend, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: HaloTokens.brand, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + minimumSize: const Size(0, 32), + elevation: 0, + textStyle: const TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 11.5, + fontWeight: FontWeight.w700, + ), + ), + child: const Text('perpanjang'), ), ], ), diff --git a/client_app/lib/features/home/home_screen.dart b/client_app/lib/features/home/home_screen.dart index d32d221..b5bfb70 100644 --- a/client_app/lib/features/home/home_screen.dart +++ b/client_app/lib/features/home/home_screen.dart @@ -47,8 +47,19 @@ class _HomeScreenState extends ConsumerState with WidgetsBindingObse } @override - void dispose() { + void deactivate() { + // setActive(false) lives here, NOT in dispose(): modern Riverpod + // invalidates `ref` as soon as the State enters dispose(), so calling + // `ref.read` from there throws `Bad state: Cannot use "ref" after the + // widget was disposed.` That exception fires inside `finalizeTree` and + // leaves the widget tree in a half-finalized state — observed symptom + // is a frozen screen on the next push (e.g. Home → Chat). ref.read(mitraAvailabilityProvider.notifier).setActive(false); + super.deactivate(); + } + + @override + void dispose() { WidgetsBinding.instance.removeObserver(this); super.dispose(); } diff --git a/client_app/lib/features/home/providers/bestie_history_provider.dart b/client_app/lib/features/home/providers/bestie_history_provider.dart index 74625f7..96e646f 100644 --- a/client_app/lib/features/home/providers/bestie_history_provider.dart +++ b/client_app/lib/features/home/providers/bestie_history_provider.dart @@ -1,5 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/api/api_client_provider.dart'; +import '../../../core/auth/auth_notifier.dart'; class BestieHistoryItem { final String sessionId; @@ -28,13 +29,34 @@ class BestieHistoryItem { mitraName: json['mitra_display_name'] as String? ?? 'Bestie', endedAt: endedAtRaw is String ? DateTime.tryParse(endedAtRaw)?.toLocal() : null, topics: (json['topics'] as List?)?.cast() ?? const [], - sessionsCount: (json['sessions_count'] as num?)?.toInt() ?? 1, + // sessions_count comes from PostgreSQL COUNT(*) which postgres.js + // stringifies (bigint precision). Accept both shapes so the factory + // doesn't crash when the backend forgets to ::int. + sessionsCount: switch (json['sessions_count']) { + num n => n.toInt(), + String s => int.tryParse(s) ?? 1, + _ => 1, + }, mitraIsOnline: json['mitra_is_online'] as bool? ?? false, ); } } -final bestieHistoryProvider = FutureProvider>((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>((ref) async { + final customerId = ref.watch(authProvider.select((s) { + final data = s.valueOrNull; + return switch (data) { + AuthAuthenticatedData d => d.profile['id'] as String?, + AuthAnonymousData d => d.customerId, + _ => null, + }; + })); + if (customerId == null) return const []; final api = ref.read(apiClientProvider); final response = await api.get('/api/client/chat/history'); final items = (response['data']['items'] as List? ?? []) diff --git a/client_app/lib/features/onboarding/esp_state.dart b/client_app/lib/features/onboarding/esp_state.dart deleted file mode 100644 index 8077787..0000000 --- a/client_app/lib/features/onboarding/esp_state.dart +++ /dev/null @@ -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 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((_) => false); diff --git a/client_app/lib/features/onboarding/esp_topic.dart b/client_app/lib/features/onboarding/esp_topic.dart deleted file mode 100644 index 2ed4853..0000000 --- a/client_app/lib/features/onboarding/esp_topic.dart +++ /dev/null @@ -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; - } -} diff --git a/client_app/lib/features/onboarding/onboarding_screen.dart b/client_app/lib/features/onboarding/onboarding_screen.dart index c9870db..8d790fa 100644 --- a/client_app/lib/features/onboarding/onboarding_screen.dart +++ b/client_app/lib/features/onboarding/onboarding_screen.dart @@ -84,7 +84,7 @@ class _OnboardingScreenState extends ConsumerState { await prefs.setBool(_kOnboardingDone, true); ref.invalidate(onboardingDoneProvider); if (mounted) { - context.go('/welcome'); + context.go('/home'); } } diff --git a/client_app/lib/features/onboarding/screens/esp_screen.dart b/client_app/lib/features/onboarding/screens/esp_screen.dart deleted file mode 100644 index 8ed47c9..0000000 --- a/client_app/lib/features/onboarding/screens/esp_screen.dart +++ /dev/null @@ -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.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 = {}; - 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); - } -} diff --git a/client_app/lib/features/onboarding/screens/usp_screen.dart b/client_app/lib/features/onboarding/screens/usp_screen.dart index 8ff5e25..72613aa 100644 --- a/client_app/lib/features/onboarding/screens/usp_screen.dart +++ b/client_app/lib/features/onboarding/screens/usp_screen.dart @@ -1,13 +1,17 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../core/theme/halo_tokens.dart'; import '../../../core/theme/widgets/widgets.dart'; +import '../usp_seen_provider.dart'; -/// Onboarding step 2 — static value-prop ("USP") cards. No state; just a -/// terminal CTA that routes onward to the auth/payment fork. -class UspScreen extends StatelessWidget { - /// `verified` ➞ USP → OTP (`/auth/register`). - /// `anonymous` ➞ USP → `/payment/method-pick` (Stage 3 owns this route). +/// Onboarding step 2 — static value-prop ("USP") cards. One-time gate +/// (Phase 4, 2026-05-12): on Continue we mark the local `usp_seen` flag and +/// best-effort persist to DB so this screen never shows again for this user. +/// +/// `verified` ➞ USP → OTP (`/auth/register`). +/// `anonymous` ➞ USP → `/payment/method-pick`. +class UspScreen extends ConsumerWidget { final bool verified; const UspScreen({super.key, required this.verified}); @@ -36,7 +40,7 @@ class UspScreen extends StatelessWidget { ]; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { return Scaffold( appBar: AppBar( title: const Padding( @@ -90,7 +94,7 @@ class UspScreen extends StatelessWidget { HaloButton( label: 'aku ngerti, lanjut', fullWidth: true, - onPressed: () => _onContinue(context), + onPressed: () => _onContinue(context, ref), ), ], ), @@ -99,12 +103,14 @@ class UspScreen extends StatelessWidget { ); } - void _onContinue(BuildContext context) { + Future _onContinue(BuildContext context, WidgetRef ref) async { + // Persist the local + server flag before leaving — next time the user + // hits VerifChoice, this screen is skipped. + await ref.read(uspSeenProvider.notifier).markSeen(); + if (!context.mounted) return; if (verified) { context.push('/auth/register'); } else { - // Stage 3 owns /payment/method-pick. Until then, route there as a - // placeholder; Maestro flow 03 stops at the route arrival. context.push('/payment/method-pick'); } } diff --git a/client_app/lib/features/onboarding/usp_seen_provider.dart b/client_app/lib/features/onboarding/usp_seen_provider.dart new file mode 100644 index 0000000..47cc75a --- /dev/null +++ b/client_app/lib/features/onboarding/usp_seen_provider.dart @@ -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 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>(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? _profileOf(AuthData? data) => switch (data) { + AuthAuthenticatedData d => d.profile, + AuthAnonymousData d => d.profile, + AuthForceRegisterData d => d.profile, + AuthNeedsDisplayNameData d => d.profile, + _ => null, + }; + + Future _hydrateFromProfile(Map 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 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. + } + } +} diff --git a/client_app/lib/features/onboarding/usp_seen_provider.g.dart b/client_app/lib/features/onboarding/usp_seen_provider.g.dart new file mode 100644 index 0000000..fa20ffa --- /dev/null +++ b/client_app/lib/features/onboarding/usp_seen_provider.g.dart @@ -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.internal( + UspSeen.new, + name: r'uspSeenProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$uspSeenHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$UspSeen = AsyncNotifier; +// 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 diff --git a/client_app/lib/features/payment/screens/payment_screen.dart b/client_app/lib/features/payment/screens/payment_screen.dart index 88132fd..4d0c00f 100644 --- a/client_app/lib/features/payment/screens/payment_screen.dart +++ b/client_app/lib/features/payment/screens/payment_screen.dart @@ -60,13 +60,16 @@ class _PaymentScreenState extends ConsumerState { } @override - void dispose() { - // Best-effort cancel on back/dispose if we still have a `pending` row. + void deactivate() { + // Best-effort cancel on back/leave if we still have a `pending` row. // The notifier checks state before calling the API, so this is safe to - // call unconditionally. + // call unconditionally. Lives in deactivate(), not dispose(), because + // modern Riverpod invalidates `ref` once dispose() starts — the resulting + // `Bad state: Cannot use "ref" after the widget was disposed.` corrupts + // the widget-tree finalize and leaves the next screen frozen. // ignore: discarded_futures ref.read(paymentProvider.notifier).cancelIfPending(); - super.dispose(); + super.deactivate(); } diff --git a/client_app/lib/features/payment/screens/waiting_payment_screen.dart b/client_app/lib/features/payment/screens/waiting_payment_screen.dart index dd07a53..a29042c 100644 --- a/client_app/lib/features/payment/screens/waiting_payment_screen.dart +++ b/client_app/lib/features/payment/screens/waiting_payment_screen.dart @@ -125,20 +125,35 @@ class _WaitingPaymentScreenState extends ConsumerState if (status == PaymentSessionStatus.confirmed || status == PaymentSessionStatus.consumed) { _markTerminal(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - context.go('/onboarding/notif-gate'); - }); + _navigateTerminal('/onboarding/notif-gate'); } else if (status == PaymentSessionStatus.expired || status == PaymentSessionStatus.abandoned) { _markTerminal(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - context.go('/payment/expired/${widget.paymentId}'); - }); + _navigateTerminal('/payment/expired/${widget.paymentId}'); } } + /// Routes off the waiting screen once the payment session reached a + /// terminal status. Belt-and-braces: + /// - `Future.microtask` runs after the current event loop turn (after any + /// pending setState), so we don't fight an in-flight build. + /// - `addPostFrameCallback` is a fallback in case the microtask is + /// pre-empted (observed once on release builds where the screen stayed + /// visually stuck on "menunggu pembayaran" despite polling having + /// stopped — see 2026-05-14 thread). + void _navigateTerminal(String route) { + Future.microtask(() { + if (!mounted) return; + context.go(route); + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + // No-op if the microtask already navigated — `go` to the same location + // is idempotent in GoRouter. + context.go(route); + }); + } + void _markTerminal() { _terminal = true; _ticker?.cancel(); diff --git a/client_app/lib/features/profile/profile_screen.dart b/client_app/lib/features/profile/profile_screen.dart new file mode 100644 index 0000000..ff0a441 --- /dev/null +++ b/client_app/lib/features/profile/profile_screen.dart @@ -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 _confirmLogout(BuildContext context, WidgetRef ref) async { + final ok = await showDialog( + 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, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/client_app/lib/main.dart b/client_app/lib/main.dart index 619e465..1d9d33c 100644 --- a/client_app/lib/main.dart +++ b/client_app/lib/main.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; @@ -5,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'core/api/api_client_provider.dart'; import 'core/auth/auth_notifier.dart'; import 'core/auth/auth_providers_provider.dart'; +import 'core/auth/token_storage.dart'; import 'core/chat/active_session_notifier.dart'; import 'core/chat/chat_notifier.dart'; import 'core/notifications/notification_service.dart'; @@ -14,6 +17,14 @@ import 'router.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + + // Pre-warm flutter_secure_storage. The first call triggers AndroidX + // Security MasterKey generation (RSA in Keystore) — fast on hardware-backed + // keystores but multi-second on emulator's software-emulated TEE. Kicking + // it off here in parallel with Firebase init hides the latency behind the + // splash instead of paying it on the user's first interaction. + unawaited(TokenStorage().readRefreshToken()); + await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); final messaging = FirebaseMessaging.instance; diff --git a/client_app/pubspec.lock b/client_app/pubspec.lock index 38fd631..2053ccd 100644 --- a/client_app/pubspec.lock +++ b/client_app/pubspec.lock @@ -552,6 +552,13 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + halo_lints: + dependency: "direct dev" + description: + path: "../halo_lints" + relative: true + source: path + version: "0.0.1" hooks: dependency: transitive description: diff --git a/client_app/pubspec.yaml b/client_app/pubspec.yaml index 5a98738..badd98c 100644 --- a/client_app/pubspec.yaml +++ b/client_app/pubspec.yaml @@ -58,6 +58,11 @@ dev_dependencies: build_runner: ^2.4.13 custom_lint: ^0.7.0 riverpod_lint: ^2.6.2 + # In-repo lint rules — lives at the repo root so client_app + mitra_app + # share the same set. Adds `no_ref_in_dispose` and any future repo-wide + # guardrails. See halo_lints/lib/halo_lints.dart. + halo_lints: + path: ../halo_lints flutter: uses-material-design: true diff --git a/halo_lints/lib/halo_lints.dart b/halo_lints/lib/halo_lints.dart new file mode 100644 index 0000000..e9ae6db --- /dev/null +++ b/halo_lints/lib/halo_lints.dart @@ -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 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 { + 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); + } +} diff --git a/halo_lints/pubspec.lock b/halo_lints/pubspec.lock new file mode 100644 index 0000000..8e51db7 --- /dev/null +++ b/halo_lints/pubspec.lock @@ -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" diff --git a/halo_lints/pubspec.yaml b/halo_lints/pubspec.yaml new file mode 100644 index 0000000..37c0e02 --- /dev/null +++ b/halo_lints/pubspec.yaml @@ -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 diff --git a/mitra_app/CLAUDE.md b/mitra_app/CLAUDE.md index 5638fee..4f38f3b 100644 --- a/mitra_app/CLAUDE.md +++ b/mitra_app/CLAUDE.md @@ -22,3 +22,27 @@ Flutter mobile application for mental health professionals (mitra/partners). - API calls go through `ApiClient`; it auto-attaches the JWT from `AuthBridge` and auto-refreshes on 401 - WebSocket handshake (`/api/shared/ws`) sends the same access token in the first frame's `{type:"auth", token}` message - Mitra role is encoded in the JWT claims (`user_type: "mitra"`) — the backend enforces the role per route; never trust client state alone + +## Pitfalls (HARD rules — silent failure modes) + +### Never call `ref.read` / `ref.watch` / `ref.listen` from `State.dispose()` + +In a `ConsumerStatefulWidget`, Riverpod invalidates `ref` the instant `dispose()` starts. Any `ref.*` call throws `Bad state: Cannot use "ref" after the widget was disposed.`. Flutter catches it inside `BuildOwner.finalizeTree` — **so it does not surface as a red-screen crash**. Instead the widget tree is left half-finalized and the NEXT screen freezes (looks like a hang; the app process is alive). Real case in this app: `mitra_chat_screen.dart` (2026-05-14). + +**Rule:** any cleanup that needs `ref` goes in `deactivate()`, which runs *before* `dispose()` while `ref` is still valid. Non-Riverpod cleanup (`TextEditingController.dispose()`, `WidgetsBinding.removeObserver`, `StreamSubscription.cancel`) stays in `dispose()`. + +```dart +@override +void deactivate() { + ref.read(someProvider.notifier).cleanup(); + super.deactivate(); +} + +@override +void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); +} +``` + +A lint rule (`no_ref_in_dispose` in `halo_lints`) fails `dart run custom_lint` on this pattern. When debugging "screen frozen after navigation", grep the *previous* screen's State for `void dispose()` followed by `ref\.` — that's the first suspect. diff --git a/mitra_app/analysis_options.yaml b/mitra_app/analysis_options.yaml index 0d29021..1326c6c 100644 --- a/mitra_app/analysis_options.yaml +++ b/mitra_app/analysis_options.yaml @@ -9,6 +9,14 @@ # packages, and plugins designed to encourage good coding practices. include: package:flutter_lints/flutter.yaml +analyzer: + plugins: + # Activates custom_lint, which loads: + # - riverpod_lint (dev_dep) — upstream Riverpod rules + # - halo_lints (path: ../halo_lints) — repo-wide rules, e.g. + # `no_ref_in_dispose`. See mitra_app/CLAUDE.md → Pitfalls. + - custom_lint + linter: # The lint rules applied to this project can be customized in the # section below to disable rules from the `package:flutter_lints/flutter.yaml` diff --git a/mitra_app/lib/core/chat/mitra_chat_notifier.dart b/mitra_app/lib/core/chat/mitra_chat_notifier.dart index 4b94860..c3c5ca4 100644 --- a/mitra_app/lib/core/chat/mitra_chat_notifier.dart +++ b/mitra_app/lib/core/chat/mitra_chat_notifier.dart @@ -10,6 +10,28 @@ import '../constants.dart'; part 'mitra_chat_notifier.g.dart'; +/// Per-second session countdown, decoupled from `mitraChatProvider` so the +/// `session_tick` WS frame doesn't invalidate the entire chat state (which +/// would force `ref.watch(mitraChatProvider)` callers — including the chat +/// screen — to rebuild every second). Watched only by the small timer +/// indicator in the chat AppBar. See mitra_app/CLAUDE.md for the wider perf +/// rationale. +/// +/// Manually declared (no @Riverpod annotation) to keep .g.dart codegen +/// minimal — one-line Notifier with no special config. +class MitraChatRemainingSecondsNotifier + extends AutoDisposeNotifier { + @override + int? build() => null; + void update(int? seconds) => state = seconds; + void clear() => state = null; +} + +final mitraChatRemainingSecondsProvider = + NotifierProvider.autoDispose( + MitraChatRemainingSecondsNotifier.new, +); + // States sealed class MitraChatData { const MitraChatData(); @@ -304,7 +326,11 @@ class MitraChat extends _$MitraChat { break; case WsMessage.sessionTimer: - state = current.copyWith(remainingSeconds: data['remaining_seconds'] as int?); + // Route timer ticks to the dedicated provider so the chat state isn't + // invalidated every second. See [mitraChatRemainingSecondsProvider]. + ref + .read(mitraChatRemainingSecondsProvider.notifier) + .update(data['remaining_seconds'] as int?); break; case WsMessage.sessionExpired: diff --git a/mitra_app/lib/features/chat/screens/mitra_chat_screen.dart b/mitra_app/lib/features/chat/screens/mitra_chat_screen.dart index bcfc8da..405e3d7 100644 --- a/mitra_app/lib/features/chat/screens/mitra_chat_screen.dart +++ b/mitra_app/lib/features/chat/screens/mitra_chat_screen.dart @@ -32,8 +32,6 @@ class _MitraChatScreenState extends ConsumerState { final _goodbyeController = TextEditingController(); final _scrollController = ScrollController(); Timer? _typingThrottle; - bool _showBestieBanner = true; - bool _showUserBanner = true; @override void initState() { @@ -43,15 +41,25 @@ class _MitraChatScreenState extends ConsumerState { }); } + @override + void deactivate() { + // Disconnect runs here, NOT in dispose(): modern Riverpod invalidates + // `ref` the instant dispose() starts, and the resulting silent error + // corrupts the widget-tree finalize (next screen freezes). deactivate() + // runs BEFORE dispose() while `ref` is still valid. Same fix pattern + // applied in client_app/home_screen + payment_screen on 2026-05-14. + // ignore: discarded_futures + ref.read(mitraChatProvider.notifier).disconnect(); + super.deactivate(); + } + @override void dispose() { - final notifier = ref.read(mitraChatProvider.notifier); _messageController.dispose(); _goodbyeController.dispose(); _scrollController.dispose(); _typingThrottle?.cancel(); super.dispose(); - Future.microtask(() => notifier.disconnect()); } void _scrollToBottom() { @@ -82,17 +90,20 @@ class _MitraChatScreenState extends ConsumerState { @override Widget build(BuildContext context) { - final chatState = ref.watch(mitraChatProvider); - final extState = ref.watch(mitraExtensionProvider); - - // Listen for extension complete -> navigate home + // Parent build runs ONCE per lifecycle — there are no ref.watch calls here. + // State changes (messages, typing, status updates, mode flip, sensitivity + // flip) all rebuild only the leaf consumers that watch them: + // - _MitraChatVoicePill → mode flag (via .select) + // - _MitraChatTopicToggle → topicSensitivity (via .select) + config + // - _MitraChatTimerAction → mitraChatRemainingSecondsProvider + // - _MitraChatBodyContent → full chatProvider + extensionProvider + // Pattern mirrors client_app/chat_screen post-refactor (2026-05-14). ref.listen(mitraExtensionProvider, (prev, next) { if (next is ExtensionCompleteData) { context.go('/home'); } }); - // Listen for chat state changes ref.listen(mitraChatProvider, (prev, next) { if (next is MitraChatConnectedData) { _scrollToBottom(); @@ -106,10 +117,6 @@ class _MitraChatScreenState extends ConsumerState { } }); - final currentSensitivity = chatState is MitraChatConnectedData - ? chatState.topicSensitivity - : TopicSensitivity.regular; - return Scaffold( appBar: AppBar( backgroundColor: Colors.white, @@ -130,41 +137,53 @@ class _MitraChatScreenState extends ConsumerState { overflow: TextOverflow.ellipsis, ), ), - if (chatState is MitraChatConnectedData && - chatState.mode == SessionMode.call) ...[ - const SizedBox(width: 8), - _buildVoiceCallPill(), - ], + const _MitraChatVoicePill(), ], ), actions: [ - if (chatState is MitraChatConnectedData) _buildTopicToggle(chatState), - if (chatState is MitraChatConnectedData && chatState.remainingSeconds != null) - Padding( - padding: const EdgeInsets.only(right: 16), - child: Center( - child: Text( - '${chatState.remainingSeconds}s', - style: TextStyle( - color: chatState.remainingSeconds! < 30 ? Colors.red : Colors.black, - fontWeight: FontWeight.bold, - ), - ), - ), - ), + _MitraChatTopicToggle(sessionId: widget.sessionId), + const _MitraChatTimerAction(), ], ), - body: Column( - children: [ - if (currentSensitivity == TopicSensitivity.sensitive) - _buildSensitivityHeader(), - Expanded(child: _buildBody(chatState, extState)), - ], + body: _MitraChatBodyContent( + sessionId: widget.sessionId, + customerName: widget.customerName, + messageController: _messageController, + goodbyeController: _goodbyeController, + scrollController: _scrollController, + onSend: _sendMessage, + onTextChanged: _onTextChanged, ), ); } +} - Widget _buildVoiceCallPill() { +/// AppBar voice-call mode badge. Watches only the `mode` field of the chat +/// state — the conditional collapses to a bool via `.select`, so this widget +/// rebuilds only when the mode actually flips (essentially never during a +/// session) and the surrounding AppBar stays still on every message/typing +/// state change. +class _MitraChatVoicePill extends ConsumerWidget { + const _MitraChatVoicePill(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isCall = ref.watch(mitraChatProvider.select( + (s) => s is MitraChatConnectedData && s.mode == SessionMode.call, + )); + if (!isCall) return const SizedBox.shrink(); + return const Padding( + padding: EdgeInsets.only(left: 8), + child: _VoiceCallPillBody(), + ); + } +} + +class _VoiceCallPillBody extends StatelessWidget { + const _VoiceCallPillBody(); + + @override + Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: const BoxDecoration( @@ -181,35 +200,31 @@ class _MitraChatScreenState extends ConsumerState { ), ); } +} - Widget _buildSensitivityHeader() { - const theme = SensitivityTheme.sensitive; - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12), - color: theme.badgeBg, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.warning_amber_rounded, size: 16, color: theme.badgeFg), - const SizedBox(width: 6), - Text( - 'Topik sensitif', - style: TextStyle( - color: theme.badgeFg, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ); - } +/// AppBar topic-sensitivity flag/lock action. Watches only `topicSensitivity` +/// (via `.select`) plus the `sensitivityConfigProvider`. Confirmation dialog +/// + snackbars + `flipTopic` call all live here so the parent screen doesn't +/// need to know about topic state. +class _MitraChatTopicToggle extends ConsumerStatefulWidget { + final String sessionId; + const _MitraChatTopicToggle({required this.sessionId}); - Widget _buildTopicToggle(MitraChatConnectedData state) { + @override + ConsumerState<_MitraChatTopicToggle> createState() => _MitraChatTopicToggleState(); +} + +class _MitraChatTopicToggleState extends ConsumerState<_MitraChatTopicToggle> { + @override + Widget build(BuildContext context) { + final sensitivity = ref.watch(mitraChatProvider.select((s) { + if (s is MitraChatConnectedData) return s.topicSensitivity; + return null; + })); + if (sensitivity == null) return const SizedBox.shrink(); final configAsync = ref.watch(sensitivityConfigProvider); final config = configAsync.value ?? SensitivityConfig.defaults; - final isSensitive = state.topicSensitivity == TopicSensitivity.sensitive; + final isSensitive = sensitivity == TopicSensitivity.sensitive; final locked = config.oneWayLatch && isSensitive; return Tooltip( @@ -223,16 +238,16 @@ class _MitraChatScreenState extends ConsumerState { isSensitive ? Icons.flag : Icons.outlined_flag, color: isSensitive ? SensitivityTheme.sensitive.badgeBg : Colors.grey.shade600, ), - onPressed: locked ? null : () => _onTopicTogglePressed(state, config), + onPressed: locked ? null : () => _onTopicTogglePressed(sensitivity, config), ), ); } Future _onTopicTogglePressed( - MitraChatConnectedData state, + TopicSensitivity current, SensitivityConfig config, ) async { - final toValue = state.topicSensitivity == TopicSensitivity.sensitive + final toValue = current == TopicSensitivity.sensitive ? TopicSensitivity.regular : TopicSensitivity.sensitive; @@ -285,6 +300,95 @@ class _MitraChatScreenState extends ConsumerState { ); } } +} + +/// 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 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 _espTopicLabels = { + 'relationship': 'Hubungan', + 'family': 'Keluarga', + 'work': 'Pekerjaan', + 'study': 'Sekolah / Kuliah', + 'finance': 'Keuangan', + 'health': 'Kesehatan', + 'friendship': 'Pertemanan', + 'self_worth': 'Self-worth', + 'anxiety': 'Kecemasan', + 'loneliness': 'Kesepian', + 'grief': 'Kehilangan', + 'identity': 'Identitas', + }; + + @override + Widget build(BuildContext context) { + final chatState = ref.watch(mitraChatProvider); + final extState = ref.watch(mitraExtensionProvider); + final isSensitive = chatState is MitraChatConnectedData && + chatState.topicSensitivity == TopicSensitivity.sensitive; + + return Column( + children: [ + if (isSensitive) _buildSensitivityHeader(), + Expanded(child: _buildBody(chatState, extState)), + ], + ); + } + + Widget _buildSensitivityHeader() { + const theme = SensitivityTheme.sensitive; + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12), + color: theme.badgeBg, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.warning_amber_rounded, size: 16, color: theme.badgeFg), + const SizedBox(width: 6), + Text( + 'Topik sensitif', + style: TextStyle( + color: theme.badgeFg, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } Widget _buildBody(MitraChatData chatState, ExtensionData extState) { if (chatState is MitraChatConnectingData) { @@ -352,7 +456,7 @@ class _MitraChatScreenState extends ConsumerState { // item (above the first message bubble). Info-only. Expanded( child: ListView.builder( - controller: _scrollController, + controller: widget.scrollController, padding: const EdgeInsets.all(16), itemCount: state.messages.length + (state.topics.isNotEmpty ? 1 : 0), @@ -385,23 +489,6 @@ class _MitraChatScreenState extends ConsumerState { ); } - // 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 _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 topics) { return Container( margin: const EdgeInsets.only(bottom: 12), @@ -510,10 +597,10 @@ class _MitraChatScreenState extends ConsumerState { children: [ Expanded( child: TextField( - controller: _messageController, - onChanged: _onTextChanged, + controller: widget.messageController, + onChanged: widget.onTextChanged, textInputAction: TextInputAction.send, - onSubmitted: (_) => _sendMessage(), + onSubmitted: (_) => widget.onSend(), decoration: InputDecoration( hintText: 'Ketik Pesan', hintStyle: TextStyle(color: Colors.grey.shade400), @@ -535,7 +622,7 @@ class _MitraChatScreenState extends ConsumerState { ), child: IconButton( icon: const Icon(Icons.send, color: Colors.white, size: 20), - onPressed: _sendMessage, + onPressed: widget.onSend, ), ), ], @@ -558,64 +645,64 @@ class _MitraChatScreenState extends ConsumerState { return Container( color: isSensitive ? SensitivityTheme.sensitive.bgTint : null, child: Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.timer, size: 64, color: Colors.orange), - const SizedBox(height: 16), - const Text('Permintaan Perpanjangan', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), - if (isSensitive) ...[ + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.timer, size: 64, color: Colors.orange), + const SizedBox(height: 16), + const Text('Permintaan Perpanjangan', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + if (isSensitive) ...[ + const SizedBox(height: 8), + SensitivityBadge(sensitivity: topic), + ], const SizedBox(height: 8), - SensitivityBadge(sensitivity: topic), - ], - const SizedBox(height: 8), - Text('Customer ingin perpanjang $duration menit', textAlign: TextAlign.center), - if (timeoutSeconds != null) ...[ - const SizedBox(height: 12), - Text( - 'Tidak menjawab dalam $timeoutSeconds detik = otomatis disetujui', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 13, - color: Colors.grey.shade700, - fontStyle: FontStyle.italic, + Text('Customer ingin perpanjang $duration menit', textAlign: TextAlign.center), + if (timeoutSeconds != null) ...[ + const SizedBox(height: 12), + Text( + 'Tidak menjawab dalam $timeoutSeconds detik = otomatis disetujui', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade700, + fontStyle: FontStyle.italic, + ), + ), + ], + const SizedBox(height: 24), + if (isResponding) + const CircularProgressIndicator() + else + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: Colors.green), + onPressed: extensionId == null ? null : () => ref.read(mitraExtensionProvider.notifier).respond( + widget.sessionId, + extensionId: extensionId, + accepted: true, + ), + child: const Text('Terima', style: TextStyle(color: Colors.white)), + ), + const SizedBox(width: 16), + ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: Colors.red), + onPressed: extensionId == null ? null : () => ref.read(mitraExtensionProvider.notifier).respond( + widget.sessionId, + extensionId: extensionId, + accepted: false, + ), + child: const Text('Tolak', style: TextStyle(color: Colors.white)), + ), + ], ), - ), ], - const SizedBox(height: 24), - if (isResponding) - const CircularProgressIndicator() - else - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ElevatedButton( - style: ElevatedButton.styleFrom(backgroundColor: Colors.green), - onPressed: extensionId == null ? null : () => ref.read(mitraExtensionProvider.notifier).respond( - widget.sessionId, - extensionId: extensionId, - accepted: true, - ), - child: const Text('Terima', style: TextStyle(color: Colors.white)), - ), - const SizedBox(width: 16), - ElevatedButton( - style: ElevatedButton.styleFrom(backgroundColor: Colors.red), - onPressed: extensionId == null ? null : () => ref.read(mitraExtensionProvider.notifier).respond( - widget.sessionId, - extensionId: extensionId, - accepted: false, - ), - child: const Text('Tolak', style: TextStyle(color: Colors.white)), - ), - ], - ), - ], + ), ), ), - ), ); } @@ -625,38 +712,38 @@ class _MitraChatScreenState extends ConsumerState { child: Column( children: [ const SizedBox(height: 48), - const Icon(Icons.waving_hand, size: 64, color: Colors.amber), - const SizedBox(height: 16), - const Text('Pesan Penutup', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), - const SizedBox(height: 8), - const Text('Tuliskan pesan terakhirmu untuk Customer', textAlign: TextAlign.center), - const SizedBox(height: 24), - TextField( - controller: _goodbyeController, - maxLines: 3, - decoration: InputDecoration( - hintText: 'Terima kasih sudah curhat...', - border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), - ), + const Icon(Icons.waving_hand, size: 64, color: Colors.amber), + const SizedBox(height: 16), + const Text('Pesan Penutup', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + const Text('Tuliskan pesan terakhirmu untuk Customer', textAlign: TextAlign.center), + const SizedBox(height: 24), + TextField( + controller: widget.goodbyeController, + maxLines: 3, + decoration: InputDecoration( + hintText: 'Terima kasih sudah curhat...', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), ), - const SizedBox(height: 16), - ElevatedButton( - onPressed: extState is ExtensionSubmittingData - ? null - : () { - final text = _goodbyeController.text.trim(); - if (text.isNotEmpty) { - ref.read(mitraExtensionProvider.notifier).submitGoodbye( - widget.sessionId, text, - ); - } - }, - child: extState is ExtensionSubmittingData - ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) - : const Text('Kirim & Selesai'), - ), - ], - ), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: extState is ExtensionSubmittingData + ? null + : () { + final text = widget.goodbyeController.text.trim(); + if (text.isNotEmpty) { + ref.read(mitraExtensionProvider.notifier).submitGoodbye( + widget.sessionId, text, + ); + } + }, + child: extState is ExtensionSubmittingData + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('Kirim & Selesai'), + ), + ], + ), ); } @@ -695,7 +782,7 @@ class _MitraChatScreenState extends ConsumerState { ), Expanded( child: ListView.builder( - controller: _scrollController, + controller: widget.scrollController, padding: const EdgeInsets.all(16), itemCount: state.messages.length, itemBuilder: (context, index) { @@ -711,3 +798,30 @@ class _MitraChatScreenState extends ConsumerState { ); } } + +/// 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, + ), + ), + ), + ); + } +} diff --git a/mitra_app/pubspec.lock b/mitra_app/pubspec.lock index fc3e1b6..8cf2041 100644 --- a/mitra_app/pubspec.lock +++ b/mitra_app/pubspec.lock @@ -504,6 +504,13 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + halo_lints: + dependency: "direct dev" + description: + path: "../halo_lints" + relative: true + source: path + version: "0.0.1" hooks: dependency: transitive description: diff --git a/mitra_app/pubspec.yaml b/mitra_app/pubspec.yaml index 9d137f6..ff29c83 100644 --- a/mitra_app/pubspec.yaml +++ b/mitra_app/pubspec.yaml @@ -40,6 +40,11 @@ dev_dependencies: build_runner: ^2.4.13 custom_lint: ^0.7.0 riverpod_lint: ^2.6.2 + # In-repo lint rules — shared with client_app from the repo root. Adds + # the `no_ref_in_dispose` rule and any future repo-wide guardrails. + # See halo_lints/lib/halo_lints.dart and mitra_app/CLAUDE.md → Pitfalls. + halo_lints: + path: ../halo_lints flutter: uses-material-design: true diff --git a/requirement/phase4-chat-screen-figma.md b/requirement/phase4-chat-screen-figma.md new file mode 100644 index 0000000..6d8b900 --- /dev/null +++ b/requirement/phase4-chat-screen-figma.md @@ -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. diff --git a/requirement/phase4-esp-removal-usp-gate.md b/requirement/phase4-esp-removal-usp-gate.md new file mode 100644 index 0000000..ec00d73 --- /dev/null +++ b/requirement/phase4-esp-removal-usp-gate.md @@ -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 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. diff --git a/requirement/resume-2026-05-15.md b/requirement/resume-2026-05-15.md new file mode 100644 index 0000000..294e449 --- /dev/null +++ b/requirement/resume-2026-05-15.md @@ -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 --release --dart-define=API_BASE_URL=http://: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.