Compare commits

...

11 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Maestro 06_chat_countdown.yaml + force_session_expires_at.js helper.

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

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

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

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

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

Maestro 05_searching_timeout.yaml + force_pairing_timeout.js helper.

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

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

Manifest + Info.plist permission entries added.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:19:22 +08:00
d33d4419ea Phase 4 Stage 1: backend foundation (additive endpoints + schema)
Schema (idempotent migration):
- payment_sessions.is_free_trial -> is_first_session_discount (data copied)
- payment_sessions.mode TEXT NOT NULL DEFAULT 'chat' CHECK (chat|call)
- chat_sessions.topics TEXT[] for ESP picks (info-only)

New endpoints:
- GET /api/client/onboarding-state (drives verif sheet + S6 paywall gate)
- GET /api/client/chat-pricing (rewrite: chat+call groups + first-session
  discount block, per-customer eligibility)
- GET /api/shared/auth-providers (env-probed; replaces ENABLE_SOCIAL_AUTH
  build flag — frontend cutover lands in stage 2)
- GET /api/client/support-handles (Tanya Admin handles, CC-config-driven)

session_warning WS event fires once at 180s remaining.

app_config seeds (mock pricing tiers, first-session discount, support
handles, payment method order, end-session 2-step toggle).

CC SettingsPage: 3 new sections (first-session discount, pricing tiers
JSON editors, support handles).

15/15 Vitest passing. chat_sessions.is_free_trial also renamed for
consistency (plan only specified payment_sessions; pairing.service.js
read both).

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:56:00 +08:00
132 changed files with 10061 additions and 760 deletions

View File

@@ -10,6 +10,7 @@ import { internalConfigRoutes } from './routes/internal/config.routes.js'
import { sessionManagementRoutes } from './routes/internal/session.routes.js' import { sessionManagementRoutes } from './routes/internal/session.routes.js'
import { mitraActivityRoutes } from './routes/internal/mitra-activity.routes.js' import { mitraActivityRoutes } from './routes/internal/mitra-activity.routes.js'
import { failedPairingsRoutes } from './routes/internal/failed-pairings.routes.js' import { failedPairingsRoutes } from './routes/internal/failed-pairings.routes.js'
import { internalTestRoutes } from './routes/internal/_test.routes.js'
import { errorHandler } from './plugins/error-handler.js' import { errorHandler } from './plugins/error-handler.js'
export const buildInternalApp = async () => { export const buildInternalApp = async () => {
@@ -38,5 +39,10 @@ export const buildInternalApp = async () => {
app.register(mitraActivityRoutes, { prefix: '/internal/mitra-activity' }) app.register(mitraActivityRoutes, { prefix: '/internal/mitra-activity' })
app.register(failedPairingsRoutes, { prefix: '/internal/failed-pairings' }) app.register(failedPairingsRoutes, { prefix: '/internal/failed-pairings' })
// Dev/test-only — never registered in production builds.
if (process.env.NODE_ENV !== 'production') {
app.register(internalTestRoutes, { prefix: '/internal/_test' })
}
return app return app
} }

View File

@@ -5,11 +5,14 @@ import { sharedAuthRoutes } from './routes/public/shared.auth.routes.js'
import { clientAuthRoutes } from './routes/public/client.auth.routes.js' import { clientAuthRoutes } from './routes/public/client.auth.routes.js'
import { mitraAuthRoutes } from './routes/public/mitra.auth.routes.js' import { mitraAuthRoutes } from './routes/public/mitra.auth.routes.js'
import { sharedConfigRoutes } from './routes/public/shared.config.routes.js' import { sharedConfigRoutes } from './routes/public/shared.config.routes.js'
import { sharedAuthProvidersRoutes } from './routes/public/shared.auth-providers.routes.js'
import { mitraStatusRoutes } from './routes/public/mitra.status.routes.js' import { mitraStatusRoutes } from './routes/public/mitra.status.routes.js'
import { mitraChatRoutes } from './routes/public/mitra.chat.routes.js' import { mitraChatRoutes } from './routes/public/mitra.chat.routes.js'
import { clientChatRoutes } from './routes/public/client.chat.routes.js' import { clientChatRoutes } from './routes/public/client.chat.routes.js'
import { clientPaymentRoutes } from './routes/public/client.payment.routes.js' import { clientPaymentRoutes } from './routes/public/client.payment.routes.js'
import { clientMitraAvailabilityRoutes } from './routes/public/client.mitra-availability.routes.js' import { clientMitraAvailabilityRoutes } from './routes/public/client.mitra-availability.routes.js'
import { 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' import { sharedChatRoutes } from './routes/public/shared.chat.routes.js'
import { errorHandler } from './plugins/error-handler.js' import { errorHandler } from './plugins/error-handler.js'
import { registerWebSocketPlugin, registerWebSocketRoute } from './plugins/websocket.js' import { registerWebSocketPlugin, registerWebSocketRoute } from './plugins/websocket.js'
@@ -24,6 +27,7 @@ export const buildPublicApp = async () => {
app.register(sharedAuthRoutes, { prefix: '/api/shared/auth' }) app.register(sharedAuthRoutes, { prefix: '/api/shared/auth' })
app.register(sharedConfigRoutes, { prefix: '/api/shared/config' }) app.register(sharedConfigRoutes, { prefix: '/api/shared/config' })
app.register(sharedAuthProvidersRoutes, { prefix: '/api/shared/auth-providers' })
app.register(sharedChatRoutes, { prefix: '/api/shared' }) app.register(sharedChatRoutes, { prefix: '/api/shared' })
app.register(clientAuthRoutes, { prefix: '/api/client/auth' }) app.register(clientAuthRoutes, { prefix: '/api/client/auth' })
app.register(mitraAuthRoutes, { prefix: '/api/mitra/auth' }) app.register(mitraAuthRoutes, { prefix: '/api/mitra/auth' })
@@ -32,6 +36,10 @@ export const buildPublicApp = async () => {
app.register(clientChatRoutes, { prefix: '/api/client/chat' }) app.register(clientChatRoutes, { prefix: '/api/client/chat' })
app.register(clientPaymentRoutes, { prefix: '/api/client/payment-sessions' }) app.register(clientPaymentRoutes, { prefix: '/api/client/payment-sessions' })
app.register(clientMitraAvailabilityRoutes, { prefix: '/api/client/mitra-availability' }) app.register(clientMitraAvailabilityRoutes, { prefix: '/api/client/mitra-availability' })
// 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' })
app.register(clientSupportRoutes, { prefix: '/api/client' })
// WebSocket route (registered at app level, not prefixed) // WebSocket route (registered at app level, not prefixed)
registerWebSocketRoute(app) registerWebSocketRoute(app)

View File

@@ -48,11 +48,20 @@ export const ExtensionStatus = Object.freeze({
// Customer transaction types // Customer transaction types
export const TransactionType = Object.freeze({ export const TransactionType = Object.freeze({
FREE_TRIAL: 'free_trial', // Phase 4: replaces FREE_TRIAL. Eligibility = phone-verified + no completed sessions
// + first_session_discount_enabled. Discounted price comes from app_config, not 0.
FIRST_SESSION_DISCOUNT: 'first_session_discount',
PAID: 'paid', PAID: 'paid',
EXTENSION: 'extension', EXTENSION: 'extension',
}) })
// Mode of a chat/payment session — chat (default) or voice call. Voice call is just
// chat with a different price group + a header badge; no extra media handling.
export const SessionMode = Object.freeze({
CHAT: 'chat',
CALL: 'call',
})
// Payment session lifecycle // Payment session lifecycle
export const PaymentSessionStatus = Object.freeze({ export const PaymentSessionStatus = Object.freeze({
PENDING: 'pending', PENDING: 'pending',
@@ -144,6 +153,9 @@ export const WsMessage = Object.freeze({
// Session lifecycle // Session lifecycle
SESSION_TIMER: 'session_timer', SESSION_TIMER: 'session_timer',
// Phase 4: in-session early warning. Currently fires once at 3 minutes left ("kind:
// three_minutes_left"). Customer-only — mitra has no countdown UI.
SESSION_WARNING: 'session_warning',
SESSION_EXPIRED: 'session_expired', SESSION_EXPIRED: 'session_expired',
SESSION_CLOSING: 'session_closing', SESSION_CLOSING: 'session_closing',
SESSION_COMPLETED: 'session_completed', SESSION_COMPLETED: 'session_completed',

View File

@@ -549,6 +549,111 @@ const migrate = async () => {
ON CONFLICT (key) DO NOTHING ON CONFLICT (key) DO NOTHING
` `
// --- Phase 4 — Customer Flow Redesign ---
// 1. payment_sessions + chat_sessions: replace is_free_trial with is_first_session_discount.
// Phase 3.7 was the first ship of is_free_trial and never went live with real users
// (per project memory), so we copy whatever values exist and drop the old column.
// Idempotent: ADD/DROP both use IF [NOT] EXISTS, and each UPDATE is gated on the
// old column still existing.
await sql`
ALTER TABLE payment_sessions
ADD COLUMN IF NOT EXISTS is_first_session_discount BOOLEAN NOT NULL DEFAULT false
`
await sql`
ALTER TABLE chat_sessions
ADD COLUMN IF NOT EXISTS is_first_session_discount BOOLEAN NOT NULL DEFAULT false
`
// Copy values from the legacy column to the new one. We must use dynamic SQL
// (EXECUTE) inside the DO block — a static reference to is_free_trial would fail
// to parse when the column has already been dropped on a previous re-run.
//
// The IF EXISTS check resolves the column against the *current* search_path so
// test schemas don't false-positive on the dev `public` schema's leftover columns.
// We use to_regclass + pg_attribute (which is search_path-aware) instead of
// information_schema.columns (which lists every schema).
await sql`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_attribute
WHERE attrelid = to_regclass('payment_sessions')
AND attname = 'is_free_trial'
AND NOT attisdropped
) THEN
EXECUTE 'UPDATE payment_sessions
SET is_first_session_discount = is_free_trial
WHERE is_free_trial = true
AND is_first_session_discount = false';
END IF;
IF EXISTS (
SELECT 1 FROM pg_attribute
WHERE attrelid = to_regclass('chat_sessions')
AND attname = 'is_free_trial'
AND NOT attisdropped
) THEN
EXECUTE 'UPDATE chat_sessions
SET is_first_session_discount = is_free_trial
WHERE is_free_trial = true
AND is_first_session_discount = false';
END IF;
END
$$
`
await sql`ALTER TABLE payment_sessions DROP COLUMN IF EXISTS is_free_trial`
await sql`ALTER TABLE chat_sessions DROP COLUMN IF EXISTS is_free_trial`
// 2. payment_sessions.mode — chat (default) vs voice call. Voice call is just chat
// with a different price group + a header badge; no extra media handling.
await sql`
ALTER TABLE payment_sessions
ADD COLUMN IF NOT EXISTS mode TEXT NOT NULL DEFAULT 'chat'
CHECK (mode IN ('chat', 'call'))
`
// 3. chat_sessions.topics — ESP picks persisted for info-only display to mitra.
// Does NOT affect matching, pricing, or routing.
await sql`
ALTER TABLE chat_sessions
ADD COLUMN IF NOT EXISTS topics TEXT[]
`
// 4. Phase 4 app_config rows. Use ON CONFLICT (key) DO NOTHING so re-runs don't
// clobber operator edits, and the migration is idempotent against partially
// populated DBs.
await sql`
INSERT INTO app_config (key, value) VALUES
('payment_method_qris_first', ${sql.json({ value: true })}),
('searching_timeout_minutes', ${sql.json({ value: 5 })}),
('end_session_two_step_confirm', ${sql.json({ value: true })}),
('three_minute_warning_enabled', ${sql.json({ value: true })}),
('first_session_discount_enabled', ${sql.json({ value: true })}),
('first_session_discount_actual_price_idr', ${sql.json({ value: 2000 })}),
('first_session_discount_gimmick_price_idr', ${sql.json({ value: 12000 })}),
('first_session_discount_duration_minutes', ${sql.json({ value: 12 })}),
('first_session_discount_modes', ${sql.json({ value: ['chat'] })}),
('pricing_chat_tiers_json', ${sql.json({ tiers: [
{ id: '5', minutes: 5, price_idr: 5000, tag: null },
{ id: '12', minutes: 12, price_idr: 12000, tag: 'paling pas' },
{ id: '30', minutes: 30, price_idr: 25000, tag: 'hemat' },
{ id: '60', minutes: 60, price_idr: 45000, tag: null },
{ id: '120', minutes: 120, price_idr: 80000, tag: 'best deal' },
]})}),
('pricing_call_tiers_json', ${sql.json({ tiers: [
{ id: '10', minutes: 10, price_idr: 9000, tag: null },
{ id: '20', minutes: 20, price_idr: 17000, tag: 'paling pas' },
{ id: '45', minutes: 45, price_idr: 35000, tag: null },
{ id: '60', minutes: 60, price_idr: 45000, tag: 'hemat' },
]})}),
('support_handles_json', ${sql.json({
wa: { label: 'WhatsApp', deeplink: 'https://wa.me/6285173310010' },
telegram: { label: 'Telegram', deeplink: 'https://t.me/halobestie' },
})})
ON CONFLICT (key) DO NOTHING
`
console.log('Migration complete.') console.log('Migration complete.')
await sql.end() await sql.end()
} }

View File

@@ -0,0 +1,200 @@
// Dev/test-only routes. Registration in app.internal.js is gated on
// NODE_ENV !== 'production' so these endpoints never exist in prod builds.
//
// Used by Maestro flows + curl harnesses to read state that the OTP stub
// keeps in memory (the stub-generated code per phone), without baking
// test phone numbers or fixed codes into production code paths.
import { peekStubOtp } from '../../services/otp.service.js'
import { expirePairingRequest } from '../../services/pairing.service.js'
import { startSessionTimer, _resetThreeMinFiredForTest, _broadcastTimerResyncForTest } from '../../services/session-timer.service.js'
import { getDb } from '../../db/client.js'
import { PairingFailureCause, SessionStatus } from '../../constants.js'
const sql = getDb()
export const internalTestRoutes = async (fastify) => {
fastify.get('/peek-otp', async (request, reply) => {
const phone = request.query?.phone
if (!phone) {
return reply.code(400).send({ error: 'phone query param required' })
}
const entry = peekStubOtp(phone)
if (!entry) {
return reply.code(404).send({ error: 'no_otp_for_phone', phone })
}
return entry
})
// Wipe rate-limit + cooldown state for a phone so flows can re-run quickly.
// Deletes otp_requests rows for the phone and (optionally) the customer row
// so identity-upgrade flows start fresh.
fastify.post('/reset-phone', async (request, reply) => {
const phone = request.body?.phone
if (!phone) {
return reply.code(400).send({ error: 'phone required in body' })
}
const dropCustomer = request.body?.drop_customer === true
await sql`DELETE FROM otp_requests WHERE phone = ${phone}`
if (dropCustomer) {
await sql`DELETE FROM customers WHERE phone = ${phone}`
}
return { ok: true, phone, dropped_customer: dropCustomer }
})
// Force-expire a `pending` payment session (used by Maestro Stage 3 flow to
// drive the waiting-payment screen into the expired state without waiting
// 20 minutes). Sets `expires_at` to the past and status to `expired` so the
// next poll from the client sees the terminal state.
//
// Body shape:
// { payment_id: '<uuid>' } → expire this specific session
// { latest: true } → expire the most-recently-created pending
fastify.post('/force-expire-payment', async (request, reply) => {
const { payment_id, latest } = request.body ?? {}
let target
if (latest === true) {
const [row] = await sql`
SELECT id FROM payment_sessions
WHERE status = 'pending'
ORDER BY created_at DESC
LIMIT 1
`
if (!row) {
return reply.code(404).send({ error: 'no_pending_payment' })
}
target = row.id
} else if (payment_id) {
target = payment_id
} else {
return reply.code(400).send({ error: 'payment_id or latest:true required in body' })
}
const [updated] = await sql`
UPDATE payment_sessions
SET status = 'expired', expires_at = NOW() - INTERVAL '1 minute'
WHERE id = ${target} AND status = 'pending'
RETURNING id, status
`
if (!updated) {
return reply.code(404).send({ error: 'no_pending_payment_for_id', payment_id: target })
}
return { ok: true, ...updated }
})
// Force-expire a pairing blast (used by Maestro Stage 5 flow to drive the
// searching screen into the timeout state without waiting 5 minutes). Marks
// the most-recently-created blast chat_session as no_mitra_available.
//
// Body shape:
// { session_id: '<uuid>' } → expire this specific session
// { latest: true } → expire the most-recent SEARCHING session
fastify.post('/force-pairing-timeout', async (request, reply) => {
const { session_id, latest } = request.body ?? {}
let target = session_id
if (latest === true) {
const [row] = await sql`
SELECT id FROM chat_sessions
WHERE status = ${SessionStatus.SEARCHING}
ORDER BY created_at DESC
LIMIT 1
`
if (!row) {
return reply.code(404).send({ error: 'no_searching_session' })
}
target = row.id
}
if (!target) {
return reply.code(400).send({ error: 'session_id or latest:true required in body' })
}
await expirePairingRequest(target, PairingFailureCause.NO_MITRA_AVAILABLE)
return { ok: true, session_id: target }
})
// Force-set the expires_at of an active chat_session to drive Phase 4
// Stage 6 countdown UX (3-min snackbar, last-2-min danger, expired banner)
// without waiting in real time. Reschedules the in-memory session timer so
// `session_warning` / `session_timer` / `session_expired` WS events fire on
// the new schedule.
//
// Body shape:
// { seconds_from_now: 175 } → expire latest active session in N seconds
// { session_id: '<uuid>', seconds_from_now } → expire specific session
fastify.post('/force-session-expires-at', async (request, reply) => {
const { session_id, seconds_from_now } = request.body ?? {}
if (typeof seconds_from_now !== 'number') {
return reply.code(400).send({ error: 'seconds_from_now (number) required' })
}
let target = session_id
if (!target) {
const [row] = await sql`
SELECT id FROM chat_sessions
WHERE status = ${SessionStatus.ACTIVE}
ORDER BY created_at DESC
LIMIT 1
`
if (!row) {
return reply.code(404).send({ error: 'no_active_session' })
}
target = row.id
}
const [updated] = await sql`
UPDATE chat_sessions
SET expires_at = NOW() + (${seconds_from_now} || ' seconds')::interval
WHERE id = ${target} AND status = ${SessionStatus.ACTIVE}
RETURNING id, expires_at
`
if (!updated) {
return reply.code(404).send({ error: 'no_active_session_for_id', session_id: target })
}
// Allow the 3-min warning to fire again on the new schedule.
_resetThreeMinFiredForTest(updated.id)
startSessionTimer(updated.id, updated.expires_at)
// Push an immediate WS resync so the customer UI's local ticker tracks
// the new schedule without waiting for the next scheduled event.
_broadcastTimerResyncForTest(updated.id, updated.expires_at)
return { ok: true, session_id: updated.id, expires_at: updated.expires_at }
})
// Seed a completed chat_sessions row for the customer linked to `phone`,
// pairing them with the most-recent online mitra. Used by Maestro Stage 8
// flow (08_returning_targeted.yaml) so the bestie history list isn't empty.
//
// Body shape:
// { phone: '+62...' } — the customer; mitra is auto-picked.
fastify.post('/seed-history-session', async (request, reply) => {
const phone = request.body?.phone
if (!phone) {
return reply.code(400).send({ error: 'phone required in body' })
}
const [customer] = await sql`
SELECT id FROM customers WHERE phone = ${phone} LIMIT 1
`
if (!customer) {
return reply.code(404).send({ error: 'no_customer_for_phone', phone })
}
const [mitra] = await sql`
SELECT m.id, m.display_name FROM mitras m
INNER JOIN mitra_online_status s ON s.mitra_id = m.id
WHERE s.is_online = true
ORDER BY s.last_heartbeat_at DESC NULLS LAST
LIMIT 1
`
if (!mitra) {
return reply.code(404).send({ error: 'no_online_mitra' })
}
const [session] = await sql`
INSERT INTO chat_sessions (
customer_id, mitra_id, status, topic_sensitivity, topics,
created_at, paired_at, ended_at, duration_minutes, price
) VALUES (
${customer.id}, ${mitra.id}, ${SessionStatus.COMPLETED}, 'regular',
${sql.array(['hubungan'])},
NOW() - INTERVAL '1 day', NOW() - INTERVAL '1 day',
NOW() - INTERVAL '1 day' + INTERVAL '15 minutes',
15, 30000
)
RETURNING id
`
return { ok: true, session_id: session.id, mitra_id: mitra.id, mitra_name: mitra.display_name }
})
}

View File

@@ -14,6 +14,9 @@ import {
getReturningChatConfirmationTimeoutSeconds, setReturningChatConfirmationTimeoutSeconds, getReturningChatConfirmationTimeoutSeconds, setReturningChatConfirmationTimeoutSeconds,
getExtensionDefaultActionOnTimeout, setExtensionDefaultActionOnTimeout, getExtensionDefaultActionOnTimeout, setExtensionDefaultActionOnTimeout,
getPairingBlastTimeoutSeconds, setPairingBlastTimeoutSeconds, getPairingBlastTimeoutSeconds, setPairingBlastTimeoutSeconds,
getFirstSessionDiscountConfig, setFirstSessionDiscountConfig,
getSupportHandles, setSupportHandles,
getPricingTierGroups, setPricingTierGroup,
} from '../../services/config.service.js' } from '../../services/config.service.js'
const attachCcUser = async (request, reply) => { const attachCcUser = async (request, reply) => {
@@ -284,4 +287,104 @@ export const internalConfigRoutes = async (app) => {
await publishConfigInvalidate('pairing_blast_timeout_seconds') await publishConfigInvalidate('pairing_blast_timeout_seconds')
return reply.send({ success: true, data: config }) return reply.send({ success: true, data: config })
}) })
// --- Phase 4: First-session discount ---
app.get('/first-session-discount', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
}, async (_req, reply) => {
return reply.send({ success: true, data: await getFirstSessionDiscountConfig() })
})
app.patch('/first-session-discount', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
}, async (request, reply) => {
const { enabled, actual_price_idr, gimmick_price_idr, duration_minutes, modes } = request.body ?? {}
const patch = {}
if (enabled !== undefined) {
if (typeof enabled !== 'boolean') {
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'enabled must be a boolean' } })
}
patch.enabled = enabled
}
for (const [field, value] of [
['actual_price_idr', actual_price_idr],
['gimmick_price_idr', gimmick_price_idr],
['duration_minutes', duration_minutes],
]) {
if (value !== undefined) {
if (typeof value !== 'number' || value < 0 || !Number.isFinite(value)) {
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: `${field} must be a non-negative number` } })
}
patch[field] = Math.round(value)
}
}
if (modes !== undefined) {
if (!Array.isArray(modes) || modes.some((m) => m !== 'chat' && m !== 'call')) {
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'modes must be an array of "chat" | "call"' } })
}
patch.modes = modes
}
const config = await setFirstSessionDiscountConfig(patch)
await publishConfigInvalidate('first_session_discount')
return reply.send({ success: true, data: config })
})
// --- Phase 4: Pricing tier groups (chat / call) ---
app.get('/pricing-tiers', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
}, async (_req, reply) => {
return reply.send({ success: true, data: await getPricingTierGroups() })
})
app.patch('/pricing-tiers/:mode', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
}, async (request, reply) => {
const mode = request.params.mode
if (mode !== 'chat' && mode !== 'call') {
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'mode must be chat or call' } })
}
const { tiers } = request.body ?? {}
if (!Array.isArray(tiers) || tiers.length === 0) {
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'tiers must be a non-empty array' } })
}
for (const t of tiers) {
if (
typeof t.id !== 'string'
|| typeof t.minutes !== 'number' || t.minutes <= 0
|| typeof t.price_idr !== 'number' || t.price_idr < 0
) {
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'each tier needs id (string), minutes (number > 0), price_idr (number >= 0)' } })
}
}
const config = await setPricingTierGroup(mode, tiers)
await publishConfigInvalidate(`pricing_${mode}_tiers_json`)
return reply.send({ success: true, data: config })
})
// --- Phase 4: Support handles ---
app.get('/support-handles', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
}, async (_req, reply) => {
return reply.send({ success: true, data: await getSupportHandles() })
})
app.patch('/support-handles', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
}, async (request, reply) => {
const { wa, telegram } = request.body ?? {}
const validateHandle = (h, name) => {
if (h === undefined) return null
if (typeof h !== 'object' || h === null) return `${name} must be an object`
if (h.label !== undefined && typeof h.label !== 'string') return `${name}.label must be a string`
if (h.deeplink !== undefined && typeof h.deeplink !== 'string') return `${name}.deeplink must be a string`
return null
}
for (const [name, value] of [['wa', wa], ['telegram', telegram]]) {
const err = validateHandle(value, name)
if (err) return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: err } })
}
const config = await setSupportHandles({ wa, telegram })
await publishConfigInvalidate('support_handles_json')
return reply.send({ success: true, data: config })
})
} }

View File

@@ -35,7 +35,10 @@ const resolveCustomer = async (request, reply) => {
} }
export const clientChatRoutes = async (app) => { export const clientChatRoutes = async (app) => {
// Get pricing tiers + free trial eligibility // Get chat + call pricing tiers + first-session-discount eligibility (per-customer).
// Phase 4 reshape — tiers come from `app_config.pricing_{chat,call}_tiers_json` and
// discount eligibility is the AND of: phone-verified + no completed sessions +
// first_session_discount_enabled.
app.get('/pricing', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { app.get('/pricing', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const pricing = await getPricingForCustomer(request.customer.id) const pricing = await getPricingForCustomer(request.customer.id)
return reply.send({ success: true, data: pricing }) return reply.send({ success: true, data: pricing })
@@ -171,7 +174,7 @@ export const clientChatRoutes = async (app) => {
/** /**
* Extension request REQUIRES `extension_payment_session_id`. * Extension request REQUIRES `extension_payment_session_id`.
* The payment session must be is_extension=true and is_free_trial=false. * The payment session must be is_extension=true and is_first_session_discount=false.
* Pricing/duration come from the payment session via the extension service. * Pricing/duration come from the payment session via the extension service.
*/ */
app.post('/session/:sessionId/extend', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { app.post('/session/:sessionId/extend', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {

View File

@@ -0,0 +1,60 @@
import { authenticate } from '../../plugins/auth.js'
import { getCustomerById } from '../../services/customer.service.js'
import { isCustomerEligibleForFirstSessionDiscount } from '../../services/pricing.service.js'
import { getDb } from '../../db/client.js'
import { UserType, SessionStatus } from '../../constants.js'
const sql = getDb()
/**
* Phase 4 onboarding-state endpoint. Drives:
* - Verif Choice Sheet visibility on the post-name screen.
* - S6 paywall vs Pilih cara routing decision.
*
* Eligibility predicate (server-authoritative — client never decides):
* first_session_discount_enabled AND phone-verified AND no completed sessions.
*
* NOTE: deviates from the plan's `users.phone_verified_at` reference — there is no
* such column. `customers.phone IS NOT NULL` is equivalent in this schema (phone is
* only ever set by the OTP-verify path).
*/
export const clientOnboardingRoutes = async (app) => {
app.get('/onboarding-state', { preHandler: authenticate }, async (request, reply) => {
if (request.auth?.userType !== UserType.CUSTOMER) {
return reply.code(403).send({
success: false,
error: { code: 'FORBIDDEN', message: 'Customer account required' },
})
}
const customer = await getCustomerById(request.auth.userId)
if (!customer) {
return reply.code(404).send({
success: false,
error: { code: 'ACCOUNT_NOT_FOUND', message: 'Customer account not found' },
})
}
const isPhoneVerified = !!customer.phone
const [prior] = await sql`
SELECT id FROM chat_sessions
WHERE customer_id = ${customer.id}
AND status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING})
LIMIT 1
`
const hasConsultedBefore = !!prior
// Use the same predicate the pricing endpoint uses, so the two stay in lock-step.
const isFirstSessionDiscountEligible = await isCustomerEligibleForFirstSessionDiscount(customer.id)
return reply.send({
success: true,
data: {
has_consulted_before: hasConsultedBefore,
is_phone_verified: isPhoneVerified,
is_first_session_discount_eligible: isFirstSessionDiscountEligible,
is_anonymous: !!customer.is_anonymous,
},
})
})
}

View File

@@ -7,11 +7,14 @@ import {
getPaymentSession, getPaymentSession,
} from '../../services/payment.service.js' } from '../../services/payment.service.js'
import { import {
isCustomerEligibleForFreeTrial, isCustomerEligibleForFirstSessionDiscount,
isValidTier, isValidTier,
getPriceTiers, findTier,
} from '../../services/pricing.service.js' } from '../../services/pricing.service.js'
import { UserType } from '../../constants.js' import { getDb } from '../../db/client.js'
import { UserType, SessionMode } from '../../constants.js'
const sql = getDb()
const resolveCustomer = async (request, reply) => { const resolveCustomer = async (request, reply) => {
if (request.auth?.userType !== UserType.CUSTOMER) { if (request.auth?.userType !== UserType.CUSTOMER) {
@@ -30,6 +33,25 @@ const resolveCustomer = async (request, reply) => {
request.customer = customer request.customer = customer
} }
const readDiscountConfig = async () => {
const rows = await sql`
SELECT key, value FROM app_config
WHERE key IN (
'first_session_discount_enabled',
'first_session_discount_actual_price_idr',
'first_session_discount_duration_minutes',
'first_session_discount_modes'
)
`
const byKey = Object.fromEntries(rows.map((r) => [r.key, r.value?.value]))
return {
enabled: byKey.first_session_discount_enabled ?? true,
actual_price_idr: byKey.first_session_discount_actual_price_idr ?? 2000,
duration_minutes: byKey.first_session_discount_duration_minutes ?? 12,
modes: byKey.first_session_discount_modes ?? ['chat'],
}
}
/** /**
* Payment session lifecycle (mocked — no Xendit yet). * Payment session lifecycle (mocked — no Xendit yet).
* *
@@ -39,12 +61,13 @@ const resolveCustomer = async (request, reply) => {
* GET /api/client/payment-sessions/:id * GET /api/client/payment-sessions/:id
*/ */
export const clientPaymentRoutes = async (app) => { export const clientPaymentRoutes = async (app) => {
// Create a payment session (status = pending). Free-trial logic is server-side: if the // Create a payment session (status = pending). First-session-discount is server-authoritative:
// customer is eligible AND this is NOT an extension, amount is forced to 0 and // if the customer is eligible AND this is NOT an extension AND mode is in the configured
// is_free_trial = true regardless of what the client passes. // modes list, amount is forced to the configured discount price.
app.post('/', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { app.post('/', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const { const {
duration_minutes, duration_minutes,
mode = SessionMode.CHAT,
targeted_mitra_id = null, targeted_mitra_id = null,
is_extension = false, is_extension = false,
} = request.body ?? {} } = request.body ?? {}
@@ -55,33 +78,44 @@ export const clientPaymentRoutes = async (app) => {
error: { code: 'VALIDATION_ERROR', message: 'duration_minutes must be a positive number' }, error: { code: 'VALIDATION_ERROR', message: 'duration_minutes must be a positive number' },
}) })
} }
if (mode !== SessionMode.CHAT && mode !== SessionMode.CALL) {
return reply.code(422).send({
success: false,
error: { code: 'VALIDATION_ERROR', message: 'mode must be chat or call' },
})
}
// Free trial: never for extensions. let isFirstSessionDiscount = false
let isFreeTrial = false
let amount let amount
if (!is_extension) { if (!is_extension) {
const eligible = await isCustomerEligibleForFreeTrial(request.customer.id) const eligible = await isCustomerEligibleForFirstSessionDiscount(request.customer.id)
if (eligible) { if (eligible) {
isFreeTrial = true const discount = await readDiscountConfig()
amount = 0 // Discount is mode-gated. With default config (modes: ['chat']) call-mode never
// gets the discount even if the user is eligible.
if (
discount.enabled
&& discount.modes.includes(mode)
&& duration_minutes === discount.duration_minutes
) {
isFirstSessionDiscount = true
amount = discount.actual_price_idr
}
} }
} }
if (!isFreeTrial) { if (!isFirstSessionDiscount) {
// Resolve amount from the price tiers (duration-keyed). The client passes // Resolve amount from the configured tier list for the requested mode.
// duration_minutes; we look up the matching tier to get the canonical price. const tier = await findTier({ mode, durationMinutes: duration_minutes })
const tiers = await getPriceTiers()
const tier = tiers.find((t) => t.duration_minutes === duration_minutes)
if (!tier) { if (!tier) {
return reply.code(400).send({ return reply.code(400).send({
success: false, success: false,
error: { code: 'INVALID_TIER', message: 'No price tier matches the requested duration' }, error: { code: 'INVALID_TIER', message: 'No price tier matches the requested mode/duration' },
}) })
} }
amount = tier.price amount = tier.price_idr
// Sanity check (defense-in-depth) — duration+price should match a known tier. if (!(await isValidTier({ mode, durationMinutes: duration_minutes, priceIdr: amount }))) {
if (!(await isValidTier(duration_minutes, amount))) {
return reply.code(400).send({ return reply.code(400).send({
success: false, success: false,
error: { code: 'INVALID_TIER', message: 'Invalid price tier selection' }, error: { code: 'INVALID_TIER', message: 'Invalid price tier selection' },
@@ -93,9 +127,10 @@ export const clientPaymentRoutes = async (app) => {
customerId: request.customer.id, customerId: request.customer.id,
durationMinutes: duration_minutes, durationMinutes: duration_minutes,
amount, amount,
isFreeTrial, isFirstSessionDiscount,
isExtension: Boolean(is_extension), isExtension: Boolean(is_extension),
targetedMitraId: targeted_mitra_id || null, targetedMitraId: targeted_mitra_id || null,
mode,
}) })
return reply.code(201).send({ return reply.code(201).send({
@@ -104,8 +139,9 @@ export const clientPaymentRoutes = async (app) => {
id: session.id, id: session.id,
amount: session.amount, amount: session.amount,
duration_minutes: session.duration_minutes, duration_minutes: session.duration_minutes,
is_free_trial: session.is_free_trial, is_first_session_discount: session.is_first_session_discount,
is_extension: session.is_extension, is_extension: session.is_extension,
mode: session.mode,
targeted_mitra_id: session.targeted_mitra_id, targeted_mitra_id: session.targeted_mitra_id,
expires_at: session.expires_at, expires_at: session.expires_at,
status: session.status, status: session.status,

View File

@@ -0,0 +1,14 @@
import { authenticate } from '../../plugins/auth.js'
import { getSupportHandles } from '../../services/config.service.js'
/**
* Phase 4 — Tanya Admin sheet handles. Sourced from `app_config.support_handles_json`,
* editable by CC. Authenticated so unauthenticated callers can't enumerate the
* support channels (rate-limit hardening, not a secret).
*/
export const clientSupportRoutes = async (app) => {
app.get('/support-handles', { preHandler: authenticate }, async (_request, reply) => {
const handles = await getSupportHandles()
return reply.send({ success: true, data: handles })
})
}

View File

@@ -0,0 +1,14 @@
import { getAuthProviders } from '../../services/auth-providers.service.js'
/**
* GET /api/shared/auth-providers — public, no auth required.
*
* Tells the client which auth entry points are wired up server-side. The client uses
* this to hide Google/Apple buttons when the corresponding OAuth env vars aren't
* configured (avoids a "press button → mysterious 500" UX).
*/
export const sharedAuthProvidersRoutes = async (app) => {
app.get('/', async (_request, reply) => {
return reply.send({ success: true, data: getAuthProviders() })
})
}

View File

@@ -0,0 +1,51 @@
/**
* Phase 4 — server-driven auth-provider gating.
*
* Probes env at module load. The result is captured at boot, NOT on every request:
* - matches the ops contract (operators set the env, restart the backend, the flag
* flips). In dev this means a backend restart is required after editing .env.
* - keeps the endpoint dirt cheap (no DB / env reads on the hot path).
*
* Replaces the old `--dart-define=ENABLE_SOCIAL_AUTH` build flag in client_app:
* the client now reads `GET /api/shared/auth-providers` once on cold start and
* hides Google/Apple buttons when the corresponding flag is `false`.
*/
const isPresent = (key) => {
const v = process.env[key]
return typeof v === 'string' && v.trim().length > 0
}
const allPresent = (...keys) => keys.every(isPresent)
// Snapshot taken at module load. If callers need a live value (rare — only env
// hot-reload tooling does), they can call `probeAuthProviders()` directly.
export const probeAuthProviders = () => ({
google: {
enabled: allPresent('GOOGLE_OAUTH_CLIENT_ID', 'GOOGLE_OAUTH_CLIENT_SECRET'),
},
apple: {
enabled: allPresent(
'APPLE_OAUTH_CLIENT_ID',
'APPLE_OAUTH_TEAM_ID',
'APPLE_OAUTH_KEY_ID',
'APPLE_OAUTH_PRIVATE_KEY',
),
},
// Phone OTP is always available — we don't gate it on env. (The OTP stub or the
// Fazpass integration is the only thing that varies, but neither prevents the
// phone-OTP entry point from being available.)
phone: { enabled: true },
})
let cached = null
export const getAuthProviders = () => {
if (!cached) cached = probeAuthProviders()
return cached
}
// Test-only: drop the cache so tests that mutate env between cases see the change.
export const _resetAuthProvidersCache = () => {
cached = null
}

View File

@@ -35,33 +35,113 @@ export const setMaxCustomersPerMitra = async (value) => {
return { max_customers_per_mitra: value } return { max_customers_per_mitra: value }
} }
// --- Phase 3 config --- // --- Phase 4: First-session discount (replaces Phase 3 free-trial config) ---
export const getFreeTrialConfig = async () => { export const getFirstSessionDiscountConfig = async () => {
const [enabledRow] = await sql`SELECT value FROM app_config WHERE key = 'free_trial_enabled'` const rows = await sql`
const [durationRow] = await sql`SELECT value FROM app_config WHERE key = 'free_trial_duration_minutes'` SELECT key, value FROM app_config
WHERE key IN (
'first_session_discount_enabled',
'first_session_discount_actual_price_idr',
'first_session_discount_gimmick_price_idr',
'first_session_discount_duration_minutes',
'first_session_discount_modes'
)
`
const byKey = Object.fromEntries(rows.map((r) => [r.key, r.value?.value]))
return { return {
enabled: enabledRow?.value?.value ?? false, enabled: byKey.first_session_discount_enabled ?? true,
duration_minutes: durationRow?.value?.value ?? 5, actual_price_idr: byKey.first_session_discount_actual_price_idr ?? 2000,
gimmick_price_idr: byKey.first_session_discount_gimmick_price_idr ?? 12000,
duration_minutes: byKey.first_session_discount_duration_minutes ?? 12,
modes: byKey.first_session_discount_modes ?? ['chat'],
}
}
export const setFirstSessionDiscountConfig = async (patch) => {
const map = {
enabled: 'first_session_discount_enabled',
actual_price_idr: 'first_session_discount_actual_price_idr',
gimmick_price_idr: 'first_session_discount_gimmick_price_idr',
duration_minutes: 'first_session_discount_duration_minutes',
modes: 'first_session_discount_modes',
}
for (const [field, key] of Object.entries(map)) {
if (patch[field] === undefined) continue
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES (${key}, ${sql.json({ value: patch[field] })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
}
return getFirstSessionDiscountConfig()
}
// Back-compat shim — CC settings page still calls /internal/config/free-trial.
// Phase 4 routes will be added; until the CC UI is migrated this maps to the new keys.
export const getFreeTrialConfig = async () => {
const cfg = await getFirstSessionDiscountConfig()
return {
enabled: cfg.enabled,
duration_minutes: cfg.duration_minutes,
} }
} }
export const setFreeTrialConfig = async ({ enabled, duration_minutes }) => { export const setFreeTrialConfig = async ({ enabled, duration_minutes }) => {
if (enabled !== undefined) { return setFirstSessionDiscountConfig({
await sql` ...(enabled !== undefined ? { enabled } : {}),
INSERT INTO app_config (key, value, updated_at) ...(duration_minutes !== undefined ? { duration_minutes } : {}),
VALUES ('free_trial_enabled', ${sql.json({ value: enabled })}, NOW()) })
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() }
`
// --- Phase 4: Support handles ---
export const getSupportHandles = async () => {
const [row] = await sql`SELECT value FROM app_config WHERE key = 'support_handles_json'`
// Stored shape: { wa: {...}, telegram: {...} }. Fall back to a safe empty payload
// so the client renders an empty Tanya Admin sheet rather than crashing.
return row?.value ?? {
wa: { label: 'WhatsApp', deeplink: '' },
telegram: { label: 'Telegram', deeplink: '' },
} }
if (duration_minutes !== undefined) { }
await sql`
INSERT INTO app_config (key, value, updated_at) export const setSupportHandles = async ({ wa, telegram }) => {
VALUES ('free_trial_duration_minutes', ${sql.json({ value: duration_minutes })}, NOW()) const current = await getSupportHandles()
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() const next = {
` wa: { ...current.wa, ...(wa || {}) },
telegram: { ...current.telegram, ...(telegram || {}) },
} }
return getFreeTrialConfig() await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES ('support_handles_json', ${sql.json(next)}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
return next
}
// --- Phase 4: Pricing tier groups ---
export const getPricingTierGroups = async () => {
const rows = await sql`
SELECT key, value FROM app_config
WHERE key IN ('pricing_chat_tiers_json', 'pricing_call_tiers_json')
`
const byKey = Object.fromEntries(rows.map((r) => [r.key, r.value]))
return {
chat: byKey.pricing_chat_tiers_json?.tiers ?? [],
call: byKey.pricing_call_tiers_json?.tiers ?? [],
}
}
export const setPricingTierGroup = async (mode, tiers) => {
const key = mode === 'call' ? 'pricing_call_tiers_json' : 'pricing_chat_tiers_json'
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES (${key}, ${sql.json({ tiers })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
return getPricingTierGroups()
} }
export const getExtensionTimeoutConfig = async () => { export const getExtensionTimeoutConfig = async () => {

View File

@@ -42,7 +42,7 @@ const getExtensionTimeoutAction = async () => {
* - belong to this customer * - belong to this customer
* - be in `confirmed` status (not yet consumed) * - be in `confirmed` status (not yet consumed)
* - have `is_extension = true` * - have `is_extension = true`
* - have `is_free_trial = false` (extensions never use free trial) * - have `is_first_session_discount = false` (extensions never use the first-session discount)
* *
* The payment session is NOT consumed at request time. It is consumed at approval moment * The payment session is NOT consumed at request time. It is consumed at approval moment
* (mitra explicit accept OR auto-approve fires). * (mitra explicit accept OR auto-approve fires).
@@ -83,9 +83,9 @@ export const requestExtension = async (sessionId, customerId, { duration_minutes
code: 'INVALID_STATE', statusCode: 409, code: 'INVALID_STATE', statusCode: 409,
}) })
} }
if (paySession.is_free_trial) { if (paySession.is_first_session_discount) {
throw Object.assign(new Error('Free trial is not available for extensions'), { throw Object.assign(new Error('First-session discount is not available for extensions'), {
code: 'FREE_TRIAL_NOT_ALLOWED', statusCode: 400, code: 'FIRST_SESSION_DISCOUNT_NOT_ALLOWED', statusCode: 400,
}) })
} }

View File

@@ -26,9 +26,17 @@ const generate6DigitCode = () => {
return String(crypto.randomInt(0, 1_000_000)).padStart(6, '0') return String(crypto.randomInt(0, 1_000_000)).padStart(6, '0')
} }
// Dev-only in-memory cache of latest stub OTP per phone, read by the
// /internal/_test/peek-otp endpoint to make Maestro flows deterministic
// without baking test phone numbers into production code paths.
const stubOtpByPhone = new Map()
export const peekStubOtp = (phone) => stubOtpByPhone.get(phone) ?? null
const fazpassSendStub = async ({ phone, channel }) => { const fazpassSendStub = async ({ phone, channel }) => {
const reference = `stub_${crypto.randomUUID()}` const reference = `stub_${crypto.randomUUID()}`
const code = generate6DigitCode() const code = generate6DigitCode()
stubOtpByPhone.set(phone, { code, reference, channel, generated_at: new Date().toISOString() })
// Log the code so developers can read it during dev testing. // Log the code so developers can read it during dev testing.
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(`[OTP STUB] phone=${phone} channel=${channel} code=${code} ref=${reference}`) console.log(`[OTP STUB] phone=${phone} channel=${channel} code=${code} ref=${reference}`)

View File

@@ -136,7 +136,7 @@ const requireConfirmedPaymentSession = async (paymentSessionId, customerId, { al
/** /**
* General-blast pairing request. Requires a confirmed payment_session_id. * General-blast pairing request. Requires a confirmed payment_session_id.
* *
* The duration_minutes / price / is_free_trial values for the chat_session row are * The duration_minutes / price / is_first_session_discount values for the chat_session row are
* sourced from the payment session — the client does not dictate pricing here. * sourced from the payment session — the client does not dictate pricing here.
* *
* `allowTargetedPayment` is set true by the fallback-to-blast path: the original payment * `allowTargetedPayment` is set true by the fallback-to-blast path: the original payment
@@ -183,14 +183,14 @@ export const createPairingRequest = async (customerId, { paymentSessionId, topic
// Create session sourced from the payment session. // Create session sourced from the payment session.
const [session] = await sql` const [session] = await sql`
INSERT INTO chat_sessions ( INSERT INTO chat_sessions (
customer_id, status, duration_minutes, price, is_free_trial, topic_sensitivity, payment_session_id customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_session_id
) )
VALUES ( VALUES (
${customerId}, ${SessionStatus.PENDING_ACCEPTANCE}, ${customerId}, ${SessionStatus.PENDING_ACCEPTANCE},
${paySession.duration_minutes}, ${paySession.amount}, ${paySession.is_free_trial}, ${paySession.duration_minutes}, ${paySession.amount}, ${paySession.is_first_session_discount},
${resolvedTopic}, ${paymentSessionId} ${resolvedTopic}, ${paymentSessionId}
) )
RETURNING id, customer_id, status, duration_minutes, price, is_free_trial, topic_sensitivity, payment_session_id, created_at RETURNING id, customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_session_id, created_at
` `
// Fan out to all available mitras in parallel — DB inserts and notifications are // Fan out to all available mitras in parallel — DB inserts and notifications are
@@ -206,7 +206,7 @@ export const createPairingRequest = async (customerId, { paymentSessionId, topic
request_type: PairingRequestType.GENERAL, request_type: PairingRequestType.GENERAL,
created_at: session.created_at, created_at: session.created_at,
duration_minutes: session.duration_minutes, duration_minutes: session.duration_minutes,
is_free_trial: session.is_free_trial, is_first_session_discount: session.is_first_session_discount,
topic_sensitivity: session.topic_sensitivity, topic_sensitivity: session.topic_sensitivity,
}) })
})) }))
@@ -305,14 +305,14 @@ export const createTargetedPairingRequest = async (customerId, { paymentSessionI
// Create session sourced from the payment session, status = pending_acceptance. // Create session sourced from the payment session, status = pending_acceptance.
const [session] = await sql` const [session] = await sql`
INSERT INTO chat_sessions ( INSERT INTO chat_sessions (
customer_id, status, duration_minutes, price, is_free_trial, topic_sensitivity, payment_session_id customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_session_id
) )
VALUES ( VALUES (
${customerId}, ${SessionStatus.PENDING_ACCEPTANCE}, ${customerId}, ${SessionStatus.PENDING_ACCEPTANCE},
${paySession.duration_minutes}, ${paySession.amount}, ${paySession.is_free_trial}, ${paySession.duration_minutes}, ${paySession.amount}, ${paySession.is_first_session_discount},
${resolvedTopic}, ${paymentSessionId} ${resolvedTopic}, ${paymentSessionId}
) )
RETURNING id, customer_id, status, duration_minutes, price, is_free_trial, topic_sensitivity, payment_session_id, created_at RETURNING id, customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_session_id, created_at
` `
// Single notification to the targeted mitra // Single notification to the targeted mitra
@@ -330,7 +330,7 @@ export const createTargetedPairingRequest = async (customerId, { paymentSessionI
request_type: PairingRequestType.RETURNING, request_type: PairingRequestType.RETURNING,
created_at: session.created_at, created_at: session.created_at,
duration_minutes: session.duration_minutes, duration_minutes: session.duration_minutes,
is_free_trial: session.is_free_trial, is_first_session_discount: session.is_first_session_discount,
topic_sensitivity: session.topic_sensitivity, topic_sensitivity: session.topic_sensitivity,
confirmation_timeout_seconds: returning_chat_confirmation_timeout_seconds, confirmation_timeout_seconds: returning_chat_confirmation_timeout_seconds,
}) })
@@ -399,12 +399,12 @@ export const acceptPairingRequest = async (sessionId, mitraId) => {
ELSE NULL ELSE NULL
END END
WHERE id = ${sessionId} WHERE id = ${sessionId}
RETURNING id, customer_id, mitra_id, status, paired_at, duration_minutes, price, is_free_trial, expires_at, payment_session_id RETURNING id, customer_id, mitra_id, status, paired_at, duration_minutes, price, is_first_session_discount, expires_at, payment_session_id
` `
// Record transaction // Record transaction
if (activeSession.duration_minutes) { if (activeSession.duration_minutes) {
const txType = activeSession.is_free_trial ? TransactionType.FREE_TRIAL : TransactionType.PAID const txType = activeSession.is_first_session_discount ? TransactionType.FIRST_SESSION_DISCOUNT : TransactionType.PAID
await sql` await sql`
INSERT INTO customer_transactions (customer_id, session_id, type, amount) INSERT INTO customer_transactions (customer_id, session_id, type, amount)
VALUES (${activeSession.customer_id}, ${sessionId}, ${txType}, ${activeSession.price || 0}) VALUES (${activeSession.customer_id}, ${sessionId}, ${txType}, ${activeSession.price || 0})
@@ -787,7 +787,7 @@ export const getPendingRequestsForMitra = async (mitraId) => {
SELECT SELECT
cs.id AS session_id, cs.id AS session_id,
cs.duration_minutes, cs.duration_minutes,
cs.is_free_trial, cs.is_first_session_discount,
cs.topic_sensitivity, cs.topic_sensitivity,
cs.created_at, cs.created_at,
CASE CASE

View File

@@ -1,5 +1,5 @@
import { getDb } from '../db/client.js' import { getDb } from '../db/client.js'
import { PaymentSessionStatus, PairingFailureCause, UserType, WsMessage } from '../constants.js' import { PaymentSessionStatus, PairingFailureCause, UserType, WsMessage, SessionMode } from '../constants.js'
import { recordFailure } from './pairing-failure.service.js' import { recordFailure } from './pairing-failure.service.js'
import { sendToUser } from '../plugins/websocket.js' import { sendToUser } from '../plugins/websocket.js'
import { sendPushNotification } from './notification.service.js' import { sendPushNotification } from './notification.service.js'
@@ -15,14 +15,19 @@ const getPaymentSessionTimeoutMinutes = async () => {
/** /**
* Create a new payment session in `pending` status. * Create a new payment session in `pending` status.
* Reads `payment_session_timeout_minutes` from config to compute expires_at. * Reads `payment_session_timeout_minutes` from config to compute expires_at.
*
* Phase 4: `isFirstSessionDiscount` replaces the old `isFreeTrial` flag. Voice-call
* mode is a routing/badge thing — the price comes from the call tier group, not from
* the mode itself.
*/ */
export const createPaymentSession = async ({ export const createPaymentSession = async ({
customerId, customerId,
durationMinutes, durationMinutes,
amount, amount,
isFreeTrial = false, isFirstSessionDiscount = false,
isExtension = false, isExtension = false,
targetedMitraId = null, targetedMitraId = null,
mode = SessionMode.CHAT,
}) => { }) => {
if (!customerId) { if (!customerId) {
throw Object.assign(new Error('customerId is required'), { code: 'VALIDATION_ERROR', statusCode: 422 }) throw Object.assign(new Error('customerId is required'), { code: 'VALIDATION_ERROR', statusCode: 422 })
@@ -33,21 +38,24 @@ export const createPaymentSession = async ({
if (typeof amount !== 'number' || amount < 0) { if (typeof amount !== 'number' || amount < 0) {
throw Object.assign(new Error('amount must be a non-negative number'), { code: 'VALIDATION_ERROR', statusCode: 422 }) throw Object.assign(new Error('amount must be a non-negative number'), { code: 'VALIDATION_ERROR', statusCode: 422 })
} }
if (mode !== SessionMode.CHAT && mode !== SessionMode.CALL) {
throw Object.assign(new Error('mode must be chat or call'), { code: 'VALIDATION_ERROR', statusCode: 422 })
}
const ttlMinutes = await getPaymentSessionTimeoutMinutes() const ttlMinutes = await getPaymentSessionTimeoutMinutes()
const [row] = await sql` const [row] = await sql`
INSERT INTO payment_sessions ( INSERT INTO payment_sessions (
customer_id, amount, duration_minutes, is_free_trial, is_extension, customer_id, amount, duration_minutes, is_first_session_discount, is_extension,
status, targeted_mitra_id, expires_at status, targeted_mitra_id, mode, expires_at
) )
VALUES ( VALUES (
${customerId}, ${amount}, ${durationMinutes}, ${isFreeTrial}, ${isExtension}, ${customerId}, ${amount}, ${durationMinutes}, ${isFirstSessionDiscount}, ${isExtension},
${PaymentSessionStatus.PENDING}, ${targetedMitraId}, ${PaymentSessionStatus.PENDING}, ${targetedMitraId}, ${mode},
NOW() + (${ttlMinutes} || ' minutes')::interval NOW() + (${ttlMinutes} || ' minutes')::interval
) )
RETURNING id, customer_id, amount, duration_minutes, is_free_trial, is_extension, RETURNING id, customer_id, amount, duration_minutes, is_first_session_discount, is_extension,
status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at mode, status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at
` `
return row return row
@@ -87,8 +95,8 @@ export const confirmPaymentSession = async (paymentSessionId, customerId) => {
UPDATE payment_sessions UPDATE payment_sessions
SET status = ${PaymentSessionStatus.CONFIRMED}, confirmed_at = NOW() SET status = ${PaymentSessionStatus.CONFIRMED}, confirmed_at = NOW()
WHERE id = ${paymentSessionId} AND status = ${PaymentSessionStatus.PENDING} WHERE id = ${paymentSessionId} AND status = ${PaymentSessionStatus.PENDING}
RETURNING id, customer_id, amount, duration_minutes, is_free_trial, is_extension, RETURNING id, customer_id, amount, duration_minutes, is_first_session_discount, is_extension,
status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at mode, status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at
` `
if (!updated) { if (!updated) {
throw Object.assign(new Error('Payment session state changed during confirm'), { code: 'CONFLICT', statusCode: 409 }) throw Object.assign(new Error('Payment session state changed during confirm'), { code: 'CONFLICT', statusCode: 409 })
@@ -289,8 +297,8 @@ export const expireStalePaymentSessions = async () => {
export const getPaymentSession = async (id) => { export const getPaymentSession = async (id) => {
const [row] = await sql` const [row] = await sql`
SELECT id, customer_id, amount, duration_minutes, is_free_trial, is_extension, SELECT id, customer_id, amount, duration_minutes, is_first_session_discount, is_extension,
status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at mode, status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at
FROM payment_sessions FROM payment_sessions
WHERE id = ${id} WHERE id = ${id}
` `

View File

@@ -1,75 +1,175 @@
import { getDb } from '../db/client.js' import { getDb } from '../db/client.js'
import { SessionStatus } from '../constants.js'
const sql = getDb() const sql = getDb()
// Default tiers as fallback // Default tiers as fallback (used if app_config row is missing). Match the seed
const DEFAULT_TIERS = [ // values in migrate.js so a missing row never breaks pricing in the wild.
{ duration_minutes: 1, price: 5000, label: '1 Menit (Test)' }, const DEFAULT_CHAT_TIERS = [
{ duration_minutes: 15, price: 30000, label: '15 Menit' }, { id: '5', minutes: 5, price_idr: 5000, tag: null },
{ duration_minutes: 30, price: 60000, label: '30 Menit' }, { id: '12', minutes: 12, price_idr: 12000, tag: 'paling pas' },
{ duration_minutes: 45, price: 100000, label: '45 Menit' }, { id: '30', minutes: 30, price_idr: 25000, tag: 'hemat' },
{ duration_minutes: 60, price: 150000, label: '60 Menit' }, { id: '60', minutes: 60, price_idr: 45000, tag: null },
{ duration_minutes: 1440, price: 250000, label: '24 Jam' }, { id: '120', minutes: 120, price_idr: 80000, tag: 'best deal' },
] ]
const DEFAULT_CALL_TIERS = [
export const getPriceTiers = async () => { { id: '10', minutes: 10, price_idr: 9000, tag: null },
const [row] = await sql`SELECT value FROM app_config WHERE key = 'price_tiers'` { id: '20', minutes: 20, price_idr: 17000, tag: 'paling pas' },
return row?.value?.tiers ?? DEFAULT_TIERS { id: '45', minutes: 45, price_idr: 35000, tag: null },
{ id: '60', minutes: 60, price_idr: 45000, tag: 'hemat' },
]
const DEFAULT_DISCOUNT = {
enabled: true,
actual_price_idr: 2000,
gimmick_price_idr: 12000,
duration_minutes: 12,
modes: ['chat'],
} }
export const isValidTier = async (durationMinutes, price) => { const readChatTiers = async () => {
const tiers = await getPriceTiers() const [row] = await sql`SELECT value FROM app_config WHERE key = 'pricing_chat_tiers_json'`
return tiers.some( return row?.value?.tiers ?? DEFAULT_CHAT_TIERS
(t) => t.duration_minutes === durationMinutes && t.price === price
)
} }
export const getFreeTrial = async () => { const readCallTiers = async () => {
const [enabledRow] = await sql`SELECT value FROM app_config WHERE key = 'free_trial_enabled'` const [row] = await sql`SELECT value FROM app_config WHERE key = 'pricing_call_tiers_json'`
const [durationRow] = await sql`SELECT value FROM app_config WHERE key = 'free_trial_duration_minutes'` return row?.value?.tiers ?? DEFAULT_CALL_TIERS
return {
enabled: enabledRow?.value?.value ?? false,
duration_minutes: durationRow?.value?.value ?? 5,
}
} }
export const isCustomerEligibleForFreeTrial = async (customerId) => { const readDiscountConfig = async () => {
const freeTrial = await getFreeTrial() const keys = [
if (!freeTrial.enabled) return false 'first_session_discount_enabled',
'first_session_discount_actual_price_idr',
const [tx] = await sql` 'first_session_discount_gimmick_price_idr',
SELECT id FROM customer_transactions 'first_session_discount_duration_minutes',
WHERE customer_id = ${customerId} 'first_session_discount_modes',
LIMIT 1 ]
const rows = await sql`
SELECT key, value FROM app_config WHERE key IN ${sql(keys)}
` `
return !tx // Eligible only if no transactions at all const byKey = Object.fromEntries(rows.map((r) => [r.key, r.value?.value]))
}
export const getPricingForCustomer = async (customerId) => {
const tiers = await getPriceTiers()
const freeTrialEligible = await isCustomerEligibleForFreeTrial(customerId)
const freeTrial = await getFreeTrial()
return { return {
tiers, enabled: byKey.first_session_discount_enabled ?? DEFAULT_DISCOUNT.enabled,
free_trial: freeTrialEligible actual_price_idr: byKey.first_session_discount_actual_price_idr ?? DEFAULT_DISCOUNT.actual_price_idr,
? { eligible: true, duration_minutes: freeTrial.duration_minutes } gimmick_price_idr: byKey.first_session_discount_gimmick_price_idr ?? DEFAULT_DISCOUNT.gimmick_price_idr,
: { eligible: false }, duration_minutes: byKey.first_session_discount_duration_minutes ?? DEFAULT_DISCOUNT.duration_minutes,
modes: byKey.first_session_discount_modes ?? DEFAULT_DISCOUNT.modes,
} }
} }
/** /**
* Extension pricing tiers. * Per-customer first-session-discount eligibility.
* *
* Same shape as `getPricingForCustomer`, but free trial is NEVER eligible for extensions. * Predicate (Phase 4):
* The customerId is accepted for API symmetry/future tier personalization. * - app_config.first_session_discount_enabled == true, AND
* - customer is phone-verified (customers.phone IS NOT NULL — phone only gets set
* via the OTP-verify path, so non-null is proof of verification), AND
* - customer has no completed/closing chat_sessions row (returning users pay full price).
*
* Note: deviates from the plan's `users.phone_verified_at` reference — there is no such
* column. `phone IS NOT NULL` is the equivalent invariant in this schema.
*/
export const isCustomerEligibleForFirstSessionDiscount = async (customerId) => {
const discount = await readDiscountConfig()
if (!discount.enabled) return false
const [customer] = await sql`
SELECT phone FROM customers WHERE id = ${customerId}
`
if (!customer || !customer.phone) return false
const [prior] = await sql`
SELECT id FROM chat_sessions
WHERE customer_id = ${customerId}
AND status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING})
LIMIT 1
`
return !prior
}
/**
* Pricing payload for the client. Returns chat + call tier groups plus a per-customer
* first-session-discount block.
*
* Shape:
* { chat: { tiers: [...] },
* call: { tiers: [...] },
* first_session_discount: {
* eligible: boolean,
* actual_price_idr, gimmick_price_idr, duration_minutes, modes: string[]
* } }
*/
export const getPricingForCustomer = async (customerId) => {
const [chatTiers, callTiers, discount, eligible] = await Promise.all([
readChatTiers(),
readCallTiers(),
readDiscountConfig(),
isCustomerEligibleForFirstSessionDiscount(customerId),
])
return {
chat: { tiers: chatTiers },
call: { tiers: callTiers },
first_session_discount: {
eligible,
actual_price_idr: discount.actual_price_idr,
gimmick_price_idr: discount.gimmick_price_idr,
duration_minutes: discount.duration_minutes,
modes: discount.modes,
},
}
}
/**
* Validate a (mode, duration_minutes, price_idr) selection against the configured tiers.
* Used by payment-session creation as a defense-in-depth check.
*/
export const isValidTier = async ({ mode, durationMinutes, priceIdr }) => {
const tiers = mode === 'call' ? await readCallTiers() : await readChatTiers()
return tiers.some((t) => t.minutes === durationMinutes && t.price_idr === priceIdr)
}
/**
* Look up the canonical tier for (mode, duration_minutes). Returns null if no match.
*/
export const findTier = async ({ mode, durationMinutes }) => {
const tiers = mode === 'call' ? await readCallTiers() : await readChatTiers()
return tiers.find((t) => t.minutes === durationMinutes) ?? null
}
/**
* Extension pricing — same chat tiers, but first-session discount NEVER applies.
* (Kept for parity with the old pricing.service shape; voice-call extensions are not
* a current feature, so we return chat tiers only.)
*/ */
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
export const getExtensionPriceTiers = async (customerId) => { export const getExtensionPriceTiers = async (customerId) => {
const tiers = await getPriceTiers() const tiers = await readChatTiers()
return { return {
tiers, tiers,
free_trial: { eligible: false }, first_session_discount: { eligible: false },
is_free_trial: false,
} }
} }
// ---- Back-compat aliases (will be removed after Phase 4 frontend cutover) ----
/**
* @deprecated Use isCustomerEligibleForFirstSessionDiscount.
* Kept so route handlers and migrated services still resolve while we cut over.
*/
export const isCustomerEligibleForFreeTrial = isCustomerEligibleForFirstSessionDiscount
/**
* @deprecated Tiers now live in `chat`/`call` groups; callers should pick one.
* Returns chat tiers in the legacy shape (single array, no group wrapper).
*/
export const getPriceTiers = async () => {
const tiers = await readChatTiers()
// Legacy callers expected `{duration_minutes, price, label}` keys. Map.
return tiers.map((t) => ({
duration_minutes: t.minutes,
price: t.price_idr,
label: `${t.minutes} Menit`,
id: t.id,
tag: t.tag,
}))
}

View File

@@ -6,18 +6,61 @@ import { UserType, SessionStatus, WsMessage, EndedBy } from '../constants.js'
const sql = getDb() const sql = getDb()
// Active session timers: sessionId → { warningTimeout, expiryTimeout } // Active session timers: sessionId → { threeMinTimeout, warningTimeout, expiryTimeout, threeMinFired }
// `threeMinFired` is a per-session idempotency flag — once the 3-min warning has been
// emitted for a session it never fires again, even if startSessionTimer is called twice
// (e.g. extension reschedule). This matches the Phase 4 spec: "fire once".
const sessionTimers = new Map() const sessionTimers = new Map()
/**
* Dev/test-only — clear the per-session "3-min warning already fired" flag so
* the warning can fire again after `force-session-expires-at` reschedules a
* session backwards. Production code never needs this.
*/
export const _resetThreeMinFiredForTest = (sessionId) => {
const timers = sessionTimers.get(sessionId)
if (timers) timers.threeMinFired = false
}
/**
* Dev/test-only — push an immediate WS resync of the timer state so a Maestro
* flow can drive the customer UI through the danger pill / expired banner
* states without waiting for the next scheduled tick. Production code drives
* UX off the scheduled `session_timer` / `session_warning` / `session_expired`
* events instead.
*/
export const _broadcastTimerResyncForTest = (sessionId, expiresAt) => {
const remaining = Math.max(0, Math.floor((new Date(expiresAt).getTime() - Date.now()) / 1000))
sendToSessionParticipant(sessionId, UserType.CUSTOMER, {
type: WsMessage.SESSION_TIMER,
remaining_seconds: remaining,
expires_at: expiresAt,
session_id: sessionId,
})
}
export const startSessionTimer = (sessionId, expiresAt) => { export const startSessionTimer = (sessionId, expiresAt) => {
const now = Date.now() const now = Date.now()
const expiresMs = new Date(expiresAt).getTime() const expiresMs = new Date(expiresAt).getTime()
const warningMs = expiresMs - 60_000 // 1 minute before expiry const threeMinMs = expiresMs - 180_000 // 3 minutes before expiry (Phase 4)
const warningMs = expiresMs - 60_000 // 1 minute before expiry
// Clear any existing timers // Preserve idempotency flag across reschedules (e.g. extension extends expires_at).
const previous = sessionTimers.get(sessionId)
const threeMinFired = previous?.threeMinFired ?? false
// Clear any existing timers (but keep the threeMinFired flag captured above).
clearSessionTimer(sessionId) clearSessionTimer(sessionId)
const timers = {} const timers = { threeMinFired }
// 3-min warning timer — Phase 4. Skip if already fired this session, or if the
// remaining window is already ≤ 3 min (don't fire belatedly mid-session).
if (!threeMinFired && threeMinMs > now) {
timers.threeMinTimeout = setTimeout(() => {
onThreeMinuteWarning(sessionId)
}, threeMinMs - now)
}
// Warning timer (1 min before expiry) // Warning timer (1 min before expiry)
if (warningMs > now) { if (warningMs > now) {
@@ -43,6 +86,7 @@ export const startSessionTimer = (sessionId, expiresAt) => {
export const clearSessionTimer = (sessionId) => { export const clearSessionTimer = (sessionId) => {
const timers = sessionTimers.get(sessionId) const timers = sessionTimers.get(sessionId)
if (timers) { if (timers) {
if (timers.threeMinTimeout) clearTimeout(timers.threeMinTimeout)
if (timers.warningTimeout) clearTimeout(timers.warningTimeout) if (timers.warningTimeout) clearTimeout(timers.warningTimeout)
if (timers.expiryTimeout) clearTimeout(timers.expiryTimeout) if (timers.expiryTimeout) clearTimeout(timers.expiryTimeout)
sessionTimers.delete(sessionId) sessionTimers.delete(sessionId)
@@ -69,6 +113,29 @@ const onSessionWarning = (sessionId) => {
sendToSessionParticipant(sessionId, UserType.MITRA, data) sendToSessionParticipant(sessionId, UserType.MITRA, data)
} }
/**
* Phase 4 — 3-min warning. Customer-only event (mitra has no countdown UI).
* Idempotent per session via the `threeMinFired` flag captured by startSessionTimer.
*
* Includes `remaining_seconds` and `expires_at` so the client can resync its
* local ticker against the server's view of when the session ends. The
* customer-side ticker drives the last-2-min danger pill + expired banner,
* neither of which the server emits a discrete event for.
*/
const onThreeMinuteWarning = async (sessionId) => {
const timers = sessionTimers.get(sessionId)
if (timers?.threeMinFired) return // belt-and-braces — should not happen
if (timers) timers.threeMinFired = true
const [row] = await sql`SELECT expires_at FROM chat_sessions WHERE id = ${sessionId}`
sendToSessionParticipant(sessionId, UserType.CUSTOMER, {
type: WsMessage.SESSION_WARNING,
kind: 'three_minutes_left',
session_id: sessionId,
remaining_seconds: 180,
expires_at: row?.expires_at ?? null,
})
}
// Grace period timers for auto-completing abandoned sessions // Grace period timers for auto-completing abandoned sessions
const closureGraceTimers = new Map() const closureGraceTimers = new Map()

View File

@@ -7,7 +7,7 @@ const sql = getDb()
export const getActiveSessionByCustomer = async (customerId) => { export const getActiveSessionByCustomer = async (customerId) => {
const [session] = await sql` const [session] = await sql`
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.created_at, cs.paired_at, SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.created_at, cs.paired_at,
cs.duration_minutes, cs.price, cs.is_free_trial, cs.expires_at, cs.extended_minutes, cs.duration_minutes, cs.price, cs.is_first_session_discount, cs.expires_at, cs.extended_minutes,
m.display_name AS mitra_display_name m.display_name AS mitra_display_name
FROM chat_sessions cs FROM chat_sessions cs
LEFT JOIN mitras m ON m.id = cs.mitra_id LEFT JOIN mitras m ON m.id = cs.mitra_id
@@ -149,15 +149,20 @@ export const listSessions = async ({ page = 1, limit = 20, status, topic_sensiti
} }
export const getSessionById = async (sessionId) => { export const getSessionById = async (sessionId) => {
// `mode` lives on payment_sessions (chat | call), introduced in Phase 4.1.
// The chat header pill needs it, so surface it on every session.info read.
// Falls back to 'chat' for pre-3.7 rows where payment_session_id is null.
const [session] = await sql` const [session] = await sql`
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.topic_sensitivity, SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.topics,
cs.created_at, cs.paired_at, cs.ended_at, cs.ended_by, cs.created_at, cs.paired_at, cs.ended_at, cs.ended_by,
cs.duration_minutes, cs.price, cs.is_free_trial, cs.expires_at, cs.extended_minutes, cs.duration_minutes, cs.price, cs.is_first_session_discount, cs.expires_at, cs.extended_minutes,
COALESCE(ps.mode, 'chat') AS mode,
c.display_name AS customer_display_name, c.display_name AS customer_display_name,
m.display_name AS mitra_display_name m.display_name AS mitra_display_name
FROM chat_sessions cs FROM chat_sessions cs
INNER JOIN customers c ON c.id = cs.customer_id INNER JOIN customers c ON c.id = cs.customer_id
LEFT JOIN mitras m ON m.id = cs.mitra_id LEFT JOIN mitras m ON m.id = cs.mitra_id
LEFT JOIN payment_sessions ps ON ps.id = cs.payment_session_id
WHERE cs.id = ${sessionId} WHERE cs.id = ${sessionId}
` `
return session return session
@@ -168,7 +173,7 @@ export const getSessionById = async (sessionId) => {
export const getActiveSessionByCustomerWithUnread = async (customerId) => { export const getActiveSessionByCustomerWithUnread = async (customerId) => {
const [session] = await sql` const [session] = await sql`
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.created_at, cs.paired_at, SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.created_at, cs.paired_at,
cs.duration_minutes, cs.price, cs.is_free_trial, cs.expires_at, cs.extended_minutes, cs.duration_minutes, cs.price, cs.is_first_session_discount, cs.expires_at, cs.extended_minutes,
m.display_name AS mitra_display_name, m.display_name AS mitra_display_name,
(SELECT COUNT(*) FROM chat_messages cm (SELECT COUNT(*) FROM chat_messages cm
WHERE cm.session_id = cs.id AND cm.sender_type = ${UserType.MITRA} WHERE cm.session_id = cs.id AND cm.sender_type = ${UserType.MITRA}
@@ -202,13 +207,18 @@ export const getActiveSessionsByMitraWithUnread = async (mitraId) => {
export const getCustomerHistory = async (customerId, { page = 1, limit = 20 } = {}) => { export const getCustomerHistory = async (customerId, { page = 1, limit = 20 } = {}) => {
const offset = (page - 1) * limit const offset = (page - 1) * limit
const items = await sql` const items = await sql`
SELECT cs.id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.created_at, cs.paired_at, cs.ended_at, SELECT cs.id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.topics, cs.created_at, cs.paired_at, cs.ended_at,
cs.duration_minutes, cs.price, cs.is_free_trial, cs.extended_minutes, cs.duration_minutes, cs.price, cs.is_first_session_discount, cs.extended_minutes,
m.display_name AS mitra_display_name, m.display_name AS mitra_display_name,
COALESCE(mos.is_online, false) AS mitra_is_online,
(SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.MITRA} LIMIT 1) AS mitra_closure_message, (SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.MITRA} LIMIT 1) AS mitra_closure_message,
(SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.CUSTOMER} LIMIT 1) AS customer_closure_message (SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.CUSTOMER} LIMIT 1) AS customer_closure_message,
(SELECT COUNT(*) FROM chat_sessions x
WHERE x.customer_id = ${customerId} AND x.mitra_id = cs.mitra_id
AND x.status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING})) AS sessions_count
FROM chat_sessions cs FROM chat_sessions cs
LEFT JOIN mitras m ON m.id = cs.mitra_id LEFT JOIN mitras m ON m.id = cs.mitra_id
LEFT JOIN mitra_online_status mos ON mos.mitra_id = cs.mitra_id
WHERE cs.customer_id = ${customerId} WHERE cs.customer_id = ${customerId}
AND cs.status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING}) AND cs.status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING})
ORDER BY COALESCE(cs.ended_at, cs.created_at) DESC ORDER BY COALESCE(cs.ended_at, cs.created_at) DESC
@@ -225,7 +235,7 @@ export const getMitraHistory = async (mitraId, { page = 1, limit = 20 } = {}) =>
const offset = (page - 1) * limit const offset = (page - 1) * limit
const items = await sql` const items = await sql`
SELECT cs.id, cs.customer_id, cs.status, cs.topic_sensitivity, cs.created_at, cs.paired_at, cs.ended_at, SELECT cs.id, cs.customer_id, cs.status, cs.topic_sensitivity, cs.created_at, cs.paired_at, cs.ended_at,
cs.duration_minutes, cs.price, cs.is_free_trial, cs.extended_minutes, cs.duration_minutes, cs.price, cs.is_first_session_discount, cs.extended_minutes,
c.display_name AS customer_display_name, c.display_name AS customer_display_name,
(SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.MITRA} LIMIT 1) AS mitra_closure_message, (SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.MITRA} LIMIT 1) AS mitra_closure_message,
(SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.CUSTOMER} LIMIT 1) AS customer_closure_message (SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.CUSTOMER} LIMIT 1) AS customer_closure_message

View File

@@ -52,7 +52,8 @@ export const resetDbHard = async () => {
/** /**
* Drop and re-seed the configurable app_config rows back to their canonical defaults. * Drop and re-seed the configurable app_config rows back to their canonical defaults.
* Tests that mutate config (e.g. flipping free_trial_enabled) call this in afterEach. * Tests that mutate config (e.g. flipping first_session_discount_enabled) call this
* in afterEach.
*/ */
export const resetAppConfig = async () => { export const resetAppConfig = async () => {
const sql = db() const sql = db()
@@ -61,8 +62,6 @@ export const resetAppConfig = async () => {
const defaults = [ const defaults = [
['anonymity', { enabled: false }], ['anonymity', { enabled: false }],
['max_customers_per_mitra', { value: 3 }], ['max_customers_per_mitra', { value: 3 }],
['free_trial_enabled', { value: true }],
['free_trial_duration_minutes', { value: 5 }],
['extension_timeout_seconds', { value: 60 }], ['extension_timeout_seconds', { value: 60 }],
['early_end_mitra_enabled', { value: false }], ['early_end_mitra_enabled', { value: false }],
['early_end_customer_enabled', { value: false }], ['early_end_customer_enabled', { value: false }],
@@ -70,6 +69,13 @@ export const resetAppConfig = async () => {
['returning_chat_confirmation_timeout_seconds', { value: 20 }], ['returning_chat_confirmation_timeout_seconds', { value: 20 }],
['extension_default_action_on_timeout', { value: 'auto_approve' }], ['extension_default_action_on_timeout', { value: 'auto_approve' }],
['pairing_blast_timeout_seconds', { value: 60 }], ['pairing_blast_timeout_seconds', { value: 60 }],
// Phase 4
['first_session_discount_enabled', { value: true }],
['first_session_discount_actual_price_idr', { value: 2000 }],
['first_session_discount_gimmick_price_idr', { value: 12000 }],
['first_session_discount_duration_minutes', { value: 12 }],
['first_session_discount_modes', { value: ['chat'] }],
['three_minute_warning_enabled', { value: true }],
] ]
for (const [key, value] of defaults) { for (const [key, value] of defaults) {
await sql` await sql`

View File

@@ -0,0 +1,98 @@
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest'
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 { resetDb, resetAppConfig, db } = await import('../helpers/db.js')
const { createCustomer } = await import('../helpers/fixtures.js')
const { customerJwt, authHeader } = await import('../helpers/jwt.js')
describe('GET /api/client/chat/pricing (Phase 4)', () => {
let app
let customer
let token
beforeAll(async () => {
await resetAppConfig()
app = await buildPublic()
})
beforeEach(async () => {
await resetDb()
const phone = `+628${Math.floor(Math.random() * 1e10).toString().padStart(10, '0')}`
customer = await createCustomer({ callName: 'PricingTester', phone })
token = customerJwt(customer.id)
})
afterAll(async () => {
await app?.close()
})
it('returns chat + call tier groups and a discount block; eligibility flips when the customer has a completed session', async () => {
const res = await app.inject({
method: 'GET',
url: '/api/client/chat/pricing',
headers: authHeader(token),
})
expect(res.statusCode).toBe(200)
const body = res.json()
expect(body.success).toBe(true)
const data = body.data
// Two tier groups, both non-empty
expect(Array.isArray(data.chat?.tiers)).toBe(true)
expect(Array.isArray(data.call?.tiers)).toBe(true)
expect(data.chat.tiers.length).toBeGreaterThan(0)
expect(data.call.tiers.length).toBeGreaterThan(0)
// Tier shape (chat 12-min should match the seed config)
const chat12 = data.chat.tiers.find((t) => t.minutes === 12)
expect(chat12).toBeDefined()
expect(chat12.price_idr).toBe(12000)
// Discount block — eligible (phone-verified + no completed sessions)
expect(data.first_session_discount.eligible).toBe(true)
expect(data.first_session_discount.actual_price_idr).toBe(2000)
expect(data.first_session_discount.gimmick_price_idr).toBe(12000)
expect(data.first_session_discount.duration_minutes).toBe(12)
expect(data.first_session_discount.modes).toEqual(['chat'])
// Insert a completed session — eligibility must flip.
const sql = db()
await sql`
INSERT INTO chat_sessions (customer_id, status, duration_minutes, price)
VALUES (${customer.id}, 'completed', 12, 12000)
`
const after = await app.inject({
method: 'GET',
url: '/api/client/chat/pricing',
headers: authHeader(token),
})
expect(after.statusCode).toBe(200)
expect(after.json().data.first_session_discount.eligible).toBe(false)
})
it('eligibility is false when phone is not set (anonymous customer)', async () => {
const anon = await createCustomer({ callName: 'AnonCust', phone: null })
const anonToken = customerJwt(anon.id)
const res = await app.inject({
method: 'GET',
url: '/api/client/chat/pricing',
headers: authHeader(anonToken),
})
expect(res.statusCode).toBe(200)
expect(res.json().data.first_session_discount.eligible).toBe(false)
})
})

View File

@@ -34,7 +34,11 @@ describe('POST /api/client/payment-sessions', () => {
beforeEach(async () => { beforeEach(async () => {
await resetDb() await resetDb()
customer = await createCustomer({ callName: 'PaymentTester' }) // Phone-verified customer (phone non-null) is required for first-session-discount
// eligibility under the Phase 4 predicate.
// Random suffix avoids the unique-phone constraint clashing with parallel test files.
const phone = `+628${Math.floor(Math.random() * 1e10).toString().padStart(10, '0')}`
customer = await createCustomer({ callName: 'PaymentTester', phone })
token = customerJwt(customer.id) token = customerJwt(customer.id)
}) })
@@ -42,25 +46,25 @@ describe('POST /api/client/payment-sessions', () => {
await app?.close() await app?.close()
}) })
it('happy path returns 201 + a pending payment-session row', async () => { it('happy path returns 201 + a pending payment-session row at the discounted price for an eligible customer', async () => {
const res = await app.inject({ const res = await app.inject({
method: 'POST', method: 'POST',
url: '/api/client/payment-sessions', url: '/api/client/payment-sessions',
headers: authHeader(token), headers: authHeader(token),
payload: { duration_minutes: 15 }, // Discount duration default is 12 minutes (config seed). Eligible customer →
// amount forced to actual_price_idr (2000), is_first_session_discount=true.
payload: { duration_minutes: 12 },
}) })
expect(res.statusCode).toBe(201) expect(res.statusCode).toBe(201)
const body = res.json() const body = res.json()
expect(body.success).toBe(true) expect(body.success).toBe(true)
expect(body.data.status).toBe(PaymentSessionStatus.PENDING) expect(body.data.status).toBe(PaymentSessionStatus.PENDING)
expect(body.data.duration_minutes).toBe(15) expect(body.data.duration_minutes).toBe(12)
// Default tier for 15min from migrate.js is 30000 — but the eligibility logic expect(body.data.is_first_session_discount).toBe(true)
// also needs `free_trial_enabled` to be true (default) AND no prior tx. Customer is expect(body.data.amount).toBe(2000)
// brand-new so they get the trial → amount=0, is_free_trial=true. Verify accordingly.
expect(body.data.is_free_trial).toBe(true)
expect(body.data.amount).toBe(0)
expect(body.data.is_extension).toBe(false) expect(body.data.is_extension).toBe(false)
expect(body.data.mode).toBe('chat')
// Verify persistence // Verify persistence
const sql = db() const sql = db()
@@ -69,35 +73,41 @@ describe('POST /api/client/payment-sessions', () => {
expect(row.customer_id).toBe(customer.id) expect(row.customer_id).toBe(customer.id)
}) })
it('POST /:id/confirm transitions the row and returns 200', async () => { it('non-eligible customer pays the standard tier price', async () => {
// Create a paid (non-trial) tier so the row has a non-zero amount and we exercise the // Drop first-session-discount eligibility by inserting a completed session.
// confirm path with a "real" payment. Insert a transaction first so the customer is
// ineligible for the free trial.
const sql = db() const sql = db()
// Bootstrap: create a fake prior chat session + transaction so the customer is no
// longer eligible for the free trial. (The simpler alternative — flipping
// free_trial_enabled in app_config — would impact other tests.)
const [prior] = await sql`
INSERT INTO chat_sessions (customer_id, status, duration_minutes, price)
VALUES (${customer.id}, 'completed', 15, 30000)
RETURNING id
`
await sql` await sql`
INSERT INTO customer_transactions (customer_id, session_id, type, amount) INSERT INTO chat_sessions (customer_id, status, duration_minutes, price)
VALUES (${customer.id}, ${prior.id}, 'paid', 30000) VALUES (${customer.id}, 'completed', 12, 12000)
` `
const res = await app.inject({
method: 'POST',
url: '/api/client/payment-sessions',
headers: authHeader(token),
payload: { duration_minutes: 12 },
})
expect(res.statusCode).toBe(201)
const body = res.json()
expect(body.data.is_first_session_discount).toBe(false)
// 12-minute tier in Phase 4 chat tiers = 12000 IDR.
expect(body.data.amount).toBe(12000)
})
it('POST /:id/confirm transitions the row and returns 200', async () => {
// Use a non-discount tier (5 min @ 5000 IDR) so we exercise the regular confirm path.
const createRes = await app.inject({ const createRes = await app.inject({
method: 'POST', method: 'POST',
url: '/api/client/payment-sessions', url: '/api/client/payment-sessions',
headers: authHeader(token), headers: authHeader(token),
payload: { duration_minutes: 15 }, payload: { duration_minutes: 5 },
}) })
expect(createRes.statusCode).toBe(201) expect(createRes.statusCode).toBe(201)
const created = createRes.json().data const created = createRes.json().data
expect(created.status).toBe(PaymentSessionStatus.PENDING) expect(created.status).toBe(PaymentSessionStatus.PENDING)
expect(created.is_free_trial).toBe(false) expect(created.is_first_session_discount).toBe(false)
expect(created.amount).toBe(30000) expect(created.amount).toBe(5000)
const confirmRes = await app.inject({ const confirmRes = await app.inject({
method: 'POST', method: 'POST',
@@ -112,4 +122,21 @@ describe('POST /api/client/payment-sessions', () => {
expect(confirmed.status).toBe(PaymentSessionStatus.CONFIRMED) expect(confirmed.status).toBe(PaymentSessionStatus.CONFIRMED)
expect(confirmed.confirmed_at).toBeTruthy() expect(confirmed.confirmed_at).toBeTruthy()
}) })
it('call-mode payment session uses the call tier price group', async () => {
// 20-minute call tier in Phase 4 = 17000 IDR.
const res = await app.inject({
method: 'POST',
url: '/api/client/payment-sessions',
headers: authHeader(token),
payload: { duration_minutes: 20, mode: 'call' },
})
expect(res.statusCode).toBe(201)
const body = res.json()
expect(body.data.mode).toBe('call')
// Eligible customer but discount modes default = ['chat'], so call is full price.
expect(body.data.is_first_session_discount).toBe(false)
expect(body.data.amount).toBe(17000)
})
}) })

View File

@@ -0,0 +1,87 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
// Same pattern as the other route tests — keep the websocket plugin no-op so
// buildPublic doesn't try to open real WS upgrades.
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 () => {}),
}))
describe('GET /api/shared/auth-providers (Phase 4)', () => {
// Snapshot env so we can mutate freely and restore.
const ENV_KEYS = [
'GOOGLE_OAUTH_CLIENT_ID',
'GOOGLE_OAUTH_CLIENT_SECRET',
'APPLE_OAUTH_CLIENT_ID',
'APPLE_OAUTH_TEAM_ID',
'APPLE_OAUTH_KEY_ID',
'APPLE_OAUTH_PRIVATE_KEY',
]
let original
beforeEach(() => {
original = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]))
for (const k of ENV_KEYS) delete process.env[k]
})
afterEach(() => {
for (const k of ENV_KEYS) {
if (original[k] === undefined) delete process.env[k]
else process.env[k] = original[k]
}
})
it('returns enabled:false for google + apple when env vars are unset; phone always true', async () => {
// Re-import service to drop the module-load cache, then reset its in-memory cache.
const svc = await import('../../src/services/auth-providers.service.js')
svc._resetAuthProvidersCache()
const { buildPublic } = await import('../helpers/server.js')
const app = await buildPublic()
try {
const res = await app.inject({ method: 'GET', url: '/api/shared/auth-providers' })
expect(res.statusCode).toBe(200)
const body = res.json()
expect(body.success).toBe(true)
expect(body.data.google.enabled).toBe(false)
expect(body.data.apple.enabled).toBe(false)
expect(body.data.phone.enabled).toBe(true)
} finally {
await app.close()
}
})
it('returns enabled:true for google + apple when all env vars are set', async () => {
process.env.GOOGLE_OAUTH_CLIENT_ID = 'id'
process.env.GOOGLE_OAUTH_CLIENT_SECRET = 'secret'
process.env.APPLE_OAUTH_CLIENT_ID = 'apple-id'
process.env.APPLE_OAUTH_TEAM_ID = 'team'
process.env.APPLE_OAUTH_KEY_ID = 'key'
process.env.APPLE_OAUTH_PRIVATE_KEY = 'priv'
const svc = await import('../../src/services/auth-providers.service.js')
svc._resetAuthProvidersCache()
const { buildPublic } = await import('../helpers/server.js')
const app = await buildPublic()
try {
const res = await app.inject({ method: 'GET', url: '/api/shared/auth-providers' })
expect(res.statusCode).toBe(200)
const body = res.json()
expect(body.data.google.enabled).toBe(true)
expect(body.data.apple.enabled).toBe(true)
expect(body.data.phone.enabled).toBe(true)
} finally {
await app.close()
}
})
})

View File

@@ -38,8 +38,9 @@ describe('payment.service', () => {
expect(session.customer_id).toBe(customer.id) expect(session.customer_id).toBe(customer.id)
expect(session.duration_minutes).toBe(15) expect(session.duration_minutes).toBe(15)
expect(session.amount).toBe(30000) expect(session.amount).toBe(30000)
expect(session.is_free_trial).toBe(false) expect(session.is_first_session_discount).toBe(false)
expect(session.is_extension).toBe(false) expect(session.is_extension).toBe(false)
expect(session.mode).toBe('chat')
expect(new Date(session.expires_at).getTime()).toBeGreaterThan(before) expect(new Date(session.expires_at).getTime()).toBeGreaterThan(before)
// Verify it's actually persisted (not just returned from the INSERT) // Verify it's actually persisted (not just returned from the INSERT)

View File

@@ -0,0 +1,91 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
// Capture calls to sendToSessionParticipant so we can assert the 3-min warning event.
vi.mock('../../src/plugins/websocket.js', () => ({
sendToUser: vi.fn(() => true),
sendToSessionParticipant: vi.fn(() => true),
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 () => {}),
}))
vi.mock('../../src/plugins/valkey.js', () => ({
publish: vi.fn(async () => {}),
subscribe: vi.fn(() => () => {}),
}))
const { sendToSessionParticipant } = await import('../../src/plugins/websocket.js')
const { startSessionTimer, clearSessionTimer } = await import('../../src/services/session-timer.service.js')
const { WsMessage, UserType } = await import('../../src/constants.js')
describe('session-timer 3-minute warning (Phase 4)', () => {
beforeEach(() => {
vi.useFakeTimers()
sendToSessionParticipant.mockClear()
})
afterEach(() => {
vi.useRealTimers()
})
it('emits session_warning kind:three_minutes_left exactly once at the 3-min mark', async () => {
const sessionId = 'sess-3min-test'
const expiresAt = new Date(Date.now() + 5 * 60_000) // 5 minutes from now
startSessionTimer(sessionId, expiresAt)
// Advance 1 min 59 s — well before the 2-min mark when the 3-min warning fires.
await vi.advanceTimersByTimeAsync(60_000 + 59_000)
const warnCallsEarly = sendToSessionParticipant.mock.calls.filter(
([, , data]) => data?.type === WsMessage.SESSION_WARNING,
)
expect(warnCallsEarly).toHaveLength(0)
// Cross the 3-min-left threshold. 5 min total - 3 min = warning fires at t=2:00.
await vi.advanceTimersByTimeAsync(2_000)
// sendToSessionParticipant signature: (sessionId, userType, data)
const warnCalls = sendToSessionParticipant.mock.calls.filter(
([, , data]) => data?.type === WsMessage.SESSION_WARNING,
)
expect(warnCalls).toHaveLength(1)
const [calledSessionId, userType, data] = warnCalls[0]
expect(calledSessionId).toBe(sessionId)
expect(userType).toBe(UserType.CUSTOMER)
expect(data.kind).toBe('three_minutes_left')
expect(data.session_id).toBe(sessionId)
// Cleanup before expiry hits.
clearSessionTimer(sessionId)
})
it('does NOT re-fire the 3-min warning when the timer is rescheduled (e.g. extension)', async () => {
const sessionId = 'sess-rescheduled'
const initial = new Date(Date.now() + 5 * 60_000)
startSessionTimer(sessionId, initial)
// Cross the 3-min mark on the original schedule.
await vi.advanceTimersByTimeAsync(2 * 60_000 + 1_000)
let warnCalls = sendToSessionParticipant.mock.calls.filter(
([, , data]) => data?.type === WsMessage.SESSION_WARNING,
)
expect(warnCalls).toHaveLength(1)
// Extension reschedules — give a new 5-min window. The 3-min warning must NOT fire again.
const extended = new Date(Date.now() + 5 * 60_000)
startSessionTimer(sessionId, extended)
await vi.advanceTimersByTimeAsync(2 * 60_000 + 1_000)
warnCalls = sendToSessionParticipant.mock.calls.filter(
([, , data]) => data?.type === WsMessage.SESSION_WARNING,
)
expect(warnCalls).toHaveLength(1) // still 1, no double-fire
clearSessionTimer(sessionId)
})
})

View File

@@ -1,14 +1,99 @@
# Smoke test: launch the app and assert the home screen renders. # Smoke test: cold-start onboarding, registers a new customer via the
# Use this flow first to verify Maestro can talk to your device/emulator at all. # anonymity-disabled force-register path, lands on home screen.
#
# Exercises (in order): onboarding carousel -> welcome -> display name ->
# force-register (because anonymity_enabled=false in dev) -> OTP via peek
# endpoint -> home.
# #
# Run: # Run:
# maestro test client_app/.maestro/flows/01_smoke.yaml # maestro test client_app/.maestro/flows/01_smoke.yaml
# #
# Pre-req: client_app debug APK installed on the connected device, signed in as a customer. # Pre-req: client_app debug APK installed, backend reachable at
appId: ${APP_ID_ANDROID} # Maestro uses APP_ID_IOS automatically when running against an iOS device # BACKEND_URL/BACKEND_INTERNAL_URL, NODE_ENV != 'production' (so the
# /internal/_test/peek-otp + /internal/_test/reset-phone routes register).
appId: com.halobestie.client.client_app
env:
TEST_PHONE: "+628155556677"
BACKEND_INTERNAL_URL: http://localhost:3001
--- ---
# Wipe any prior state for TEST_PHONE so repeated runs don't trip cooldowns
# or hit IDENTITY_CONFLICT on a previously-claimed customer row.
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp: - launchApp:
clearState: false # keep existing auth — set to true to test cold-start onboarding clearState: true
- assertVisible: - extendedWaitUntil:
text: "Mulai Curhat" visible:
timeout: 10000 # 10s — give Riverpod time to hydrate the home screen text: "Mulai"
timeout: 15000 # onboarding carousel auto-advances; "Mulai" appears on slide 3
- tapOn:
text: "Mulai"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "Lanjut sebagai Tamu"
timeout: 10000
- tapOn:
text: "Lanjut sebagai Tamu"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "Nama panggilan"
timeout: 10000
- tapOn:
text: "Nama panggilan"
- inputText: "Maestro"
- hideKeyboard
- tapOn:
text: "Lanjut"
retryTapIfNoChange: true
# Force-register kicks in (anonymity_enabled=false in dev DB)
- extendedWaitUntil:
visible:
text: "Verifikasi Akun"
timeout: 15000
- tapOn:
text: "Nomor HP"
- inputText: ${TEST_PHONE}
- hideKeyboard
- tapOn:
text: "Kirim OTP"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "Masukkan OTP"
timeout: 15000
# Pull the stub-generated OTP code from the in-memory map on the backend
- runScript:
file: ../scripts/peek_otp.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# inputText fills the autofocused first box; Flutter's onChanged advances
# focus per char, so all 6 digits land in the right boxes and auto-submit.
- inputText: ${output.OTP}
# Post-OTP, force-register flow lands on /auth/set-name (anonymous display
# name doesn't carry to the upgraded row). Wait for OTP screen to fade,
# then re-fill display name and continue to home.
- extendedWaitUntil:
notVisible:
text: "Masukkan OTP"
timeout: 15000
- extendedWaitUntil:
visible:
text: "Nama panggilan"
timeout: 10000
- tapOn:
text: "Nama panggilan"
- inputText: "Maestro"
- hideKeyboard
- tapOn:
text: "Lanjut"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "Mulai Curhat"
timeout: 20000

View File

@@ -0,0 +1,115 @@
# 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)
# → S6 paywall (when first-session-discount eligible) or duration picker.
#
# Run:
# maestro test client_app/.maestro/flows/02_onboarding_verified.yaml
#
# Pre-reqs: client_app debug APK installed, backend reachable at
# BACKEND_URL/BACKEND_INTERNAL_URL, NODE_ENV != 'production' (so the
# /internal/_test/peek-otp + /reset-phone routes register), and
# `anonymity_enabled = true` in the dev DB so the verif choice sheet shows.
#
# NOTE: numeric prefix conflicts with the existing
# 02_cta_disabled_when_no_mitra.yaml — Stage 9 will reorganize the flow
# directory once the full Phase 4 suite lands.
appId: com.halobestie.client.client_app
env:
TEST_PHONE: "+628155557701"
BACKEND_INTERNAL_URL: http://localhost:3001
---
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
- extendedWaitUntil:
visible:
text: "Mulai"
timeout: 15000
- tapOn:
text: "Mulai"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "Lanjut sebagai Tamu"
timeout: 10000
- tapOn:
text: "Lanjut sebagai Tamu"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "Nama panggilan"
timeout: 10000
- tapOn:
text: "Nama panggilan"
- inputText: "Maestro"
- hideKeyboard
- tapOn:
text: "lanjut"
retryTapIfNoChange: true
# Verif Choice Sheet
- extendedWaitUntil:
visible:
text: "verifikasi nomor HP"
timeout: 10000
- 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
- extendedWaitUntil:
visible:
text: "Sebelum mulai"
timeout: 10000
- tapOn:
text: "aku ngerti, lanjut"
retryTapIfNoChange: true
# Register (S3a) — phone entry
- extendedWaitUntil:
visible:
text: "Nomor HP"
timeout: 10000
- tapOn:
text: "Nomor HP"
- inputText: ${TEST_PHONE}
- hideKeyboard
- tapOn:
text: "kirim OTP"
retryTapIfNoChange: true
# OTP screen (S3b)
- extendedWaitUntil:
visible:
text: "Masukkan OTP"
timeout: 15000
- runScript:
file: ../scripts/peek_otp.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- inputText: ${output.OTP}
# Verified path: first-session-discount eligible customers land on the S6
# paywall; non-eligibles land on the duration picker. Either is acceptable
# arrival for this flow.
- extendedWaitUntil:
visible:
text: "Masukkan OTP"
timeout: 15000
notVisible: true
- extendedWaitUntil:
visible:
text: "harga sesi pertama"
timeout: 15000
optional: true

View File

@@ -0,0 +1,71 @@
# 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).
#
# Run:
# maestro test client_app/.maestro/flows/03_onboarding_anon.yaml
#
# Pre-reqs: same as 02_onboarding_verified.yaml.
#
# NOTE: numeric prefix conflicts with the existing 03_payment_to_chat_happy.yaml
# — Stage 9 will reorganize the flow directory once the full Phase 4 suite lands.
appId: com.halobestie.client.client_app
---
- launchApp:
clearState: true
- extendedWaitUntil:
visible:
text: "Mulai"
timeout: 15000
- tapOn:
text: "Mulai"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "Lanjut sebagai Tamu"
timeout: 10000
- tapOn:
text: "Lanjut sebagai Tamu"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "Nama panggilan"
timeout: 10000
- tapOn:
text: "Nama panggilan"
- inputText: "Maestro"
- hideKeyboard
- tapOn:
text: "lanjut"
retryTapIfNoChange: true
# Verif Choice Sheet — pick anonymous branch
- extendedWaitUntil:
visible:
text: "curhat anonim"
timeout: 10000
- 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
- extendedWaitUntil:
visible:
text: "Sebelum mulai"
timeout: 10000
- tapOn:
text: "aku ngerti, lanjut"
retryTapIfNoChange: true
# Stage 3 owns /payment/method-pick — arrival is the success signal.
- extendedWaitUntil:
visible:
text: "Sebelum mulai"
timeout: 10000
notVisible: true

View File

@@ -0,0 +1,94 @@
# Stage 3 acceptance: drive a payment session into the expired state and
# verify the expired screen renders.
#
# Flow:
# home → tap CTA → /payment/entry → /payment/method-pick (or
# discount-paywall — both arrive at /payment/method) → /payment/method →
# tap bayar → /payment/waiting/:id → force-expire via dev endpoint →
# poller transitions to /payment/expired/:id.
#
# Pre-req:
# 1. The customer is already onboarded + on /home (run flow 01 first, or
# launchApp with clearState=false on a state past onboarding).
# 2. At least one mitra is ONLINE on the target backend (so the CTA is
# enabled). Use mitra_app or the manual seed.
# 3. Backend reachable at BACKEND_INTERNAL_URL with NODE_ENV != 'production'
# (so the _test routes register).
#
# Run:
# maestro test client_app/.maestro/flows/04_payment_expired.yaml
appId: ${APP_ID_ANDROID}
env:
BACKEND_INTERNAL_URL: http://localhost:3001
---
- launchApp:
clearState: false
- assertVisible: "Mulai Curhat"
# Step 1: tap CTA — home routes to /payment/entry which decides the next leg
# based on first-session-discount eligibility.
- tapOn: "Mulai Curhat"
# Step 2: regardless of which entry path was chosen, the customer ends up at
# /payment/method-pick (non-eligible) or /payment/discount-paywall (eligible).
# Both have a way forward to /payment/method. Wait for either landmark.
- extendedWaitUntil:
visible:
text: "pilih cara curhat|sesi pertama|pilih durasi"
timeout: 10000
# Step 3: pick chat (if on method-pick) and a tier (if on duration-pick),
# or tap mulai (if on discount paywall). Each branch funnels into
# /payment/method.
- runFlow:
when:
visible:
text: "pilih cara curhat"
commands:
- tapOn: "chat"
- extendedWaitUntil:
visible:
text: "pilih durasi"
timeout: 5000
- tapOn:
text: "5 menit"
retryTapIfNoChange: true
- tapOn:
text: "bayar"
retryTapIfNoChange: true
- runFlow:
when:
visible:
text: "sesi pertama"
commands:
- tapOn:
text: "mulai"
retryTapIfNoChange: true
# Step 4: on the cara-bayar screen, QRIS is preselected. Tap pay.
- extendedWaitUntil:
visible:
text: "cara bayar"
timeout: 10000
- tapOn:
text: "bayar"
retryTapIfNoChange: true
# Step 5: we should now be on the QR/waiting screen. The header shows the
# countdown ("kedaluwarsa dalam"). Force-expire via the dev endpoint.
- extendedWaitUntil:
visible:
text: "kedaluwarsa dalam"
timeout: 10000
- runScript:
file: ../scripts/force_expire_latest_payment.js
env:
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# Step 6: poller picks up `expired` within ~3s and routes to expired screen.
- extendedWaitUntil:
visible:
text: "pembayaran kedaluwarsa"
timeout: 10000
- assertVisible: "coba lagi"
- assertVisible: "kembali ke home"

View File

@@ -0,0 +1,89 @@
# Stage 5 acceptance: drive the searching screen into the 5-min timeout
# state without waiting 5 minutes, verify the new copy + both CTAs render.
#
# Flow:
# home → tap CTA → payment funnel → confirm → /chat/searching →
# force-timeout via dev endpoint → verify timeout panel + CTAs.
#
# Pre-req:
# 1. Customer is already onboarded + on /home (run flow 01 first).
# 2. At least one mitra is ONLINE on the target backend (so the home
# "Mulai Curhat" CTA is enabled — we then force-timeout server-side
# regardless of mitra availability).
# 3. Backend reachable at BACKEND_INTERNAL_URL with NODE_ENV != 'production'
# (so the _test routes register).
#
# Run:
# maestro test client_app/.maestro/flows/05_searching_timeout.yaml
appId: ${APP_ID_ANDROID}
env:
BACKEND_INTERNAL_URL: http://localhost:3001
---
- launchApp:
clearState: false
- assertVisible: "Mulai Curhat"
# Step 1: enter payment funnel.
- tapOn: "Mulai Curhat"
- extendedWaitUntil:
visible:
text: "pilih cara curhat|sesi pertama|pilih durasi"
timeout: 10000
# Step 2: regardless of branch, end up on /payment/method.
- runFlow:
when:
visible:
text: "pilih cara curhat"
commands:
- tapOn: "chat"
- extendedWaitUntil:
visible:
text: "pilih durasi"
timeout: 5000
- tapOn:
text: "5 menit"
retryTapIfNoChange: true
- tapOn:
text: "bayar"
retryTapIfNoChange: true
- runFlow:
when:
visible:
text: "sesi pertama"
commands:
- tapOn:
text: "mulai"
retryTapIfNoChange: true
# Step 3: cara-bayar → tap bayar → waiting screen.
- extendedWaitUntil:
visible:
text: "cara bayar"
timeout: 10000
- tapOn:
text: "bayar"
retryTapIfNoChange: true
# Step 4: payment confirms via mock; the searching screen opens. The
# soft-prompt copy ships in Stage 5 — we wait for that landmark.
- extendedWaitUntil:
visible:
text: "sambil nunggu"
timeout: 15000
- assertVisible: "lagi nyari bestie..."
# Step 5: force the 5-min timeout server-side; the WS event lands within
# ~1s and the screen flips to the timeout panel.
- runScript:
file: ../scripts/force_pairing_timeout.js
env:
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# Step 6: verify timeout panel + both CTAs render.
- extendedWaitUntil:
visible:
text: "masih nyari nih"
timeout: 10000
- assertVisible: "coba cari lagi"
- assertVisible: "kembali ke home"

View File

@@ -0,0 +1,74 @@
# Stage 6 acceptance: drive a live chat session through the countdown UX
# in one run.
#
# Flow:
# 1. Customer is on the chat screen of an ACTIVE session (run flow 03 first).
# 2. Force expires_at = now + 175s → backend fires `session_warning` at 175s
# (180s threshold, fudge 5s for clock drift) within ~1s.
# 3. Verify the 3-min snackbar copy renders.
# 4. Force expires_at = now + 90s → timer pill flips to danger styling at
# remaining <= 120s (well within the 90s window).
# 5. Force expires_at = now + 0s → expired banner appears above input bar.
#
# Pre-req:
# 1. A live chat session is on screen (paired + active). The simplest way is
# to chain this after flow 03_payment_to_chat_happy.yaml.
# 2. Backend reachable at BACKEND_INTERNAL_URL with NODE_ENV != 'production'.
#
# Run (chained):
# maestro test client_app/.maestro/flows/03_payment_to_chat_happy.yaml \
# client_app/.maestro/flows/06_chat_countdown.yaml
appId: ${APP_ID_ANDROID}
env:
BACKEND_INTERNAL_URL: http://localhost:3001
---
- launchApp:
clearState: false
# Step 0: assert we're already on the chat screen (input hint is the landmark).
- extendedWaitUntil:
visible:
text: "Ketik Pesan"
timeout: 10000
# Step 1: force expires_at to 175s — fires the 3-min warning within ~1s.
- runScript:
file: ../scripts/force_session_expires_at.js
env:
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
SECONDS_FROM_NOW: "175"
# Step 2: verify the 3-min snackbar.
- extendedWaitUntil:
visible:
text: "sisa 3 menit lagi"
timeout: 5000
# Step 3: force expires_at to 90s — last-2-min danger pill territory.
- runScript:
file: ../scripts/force_session_expires_at.js
env:
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
SECONDS_FROM_NOW: "90"
# Step 4: assert the danger-styled timer pill renders. The pill content is a
# minutes-and-seconds string ("1m Xd"); we only assert the unit suffix here
# because the exact seconds drift between assertion and render.
- extendedWaitUntil:
visible:
text: "1m"
timeout: 5000
# Step 5: force expires_at to 0s — expired banner appears.
- runScript:
file: ../scripts/force_session_expires_at.js
env:
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
SECONDS_FROM_NOW: "0"
# Step 6: verify the floating expired banner + perpanjang CTA.
- extendedWaitUntil:
visible:
text: "waktu curhat habis"
timeout: 8000
- assertVisible: "perpanjang"

View File

@@ -0,0 +1,76 @@
# Stage 7 acceptance: customer-initiated end-of-session 2-step flow.
#
# Flow:
# 1. Customer is on the chat screen of an ACTIVE session (run flow 03 first).
# 2. Tap "akhiri" in the AppBar → step-1 confirm popup ("yakin mau akhiri sesi?").
# 3. Tap "lanjut akhiri" → step-2 confirm popup ("mau tinggalin pesan penutup?").
# 4. Tap "tulis pesan penutup" → closing-message bottom sheet.
# 5. Type a message → "kirim & akhiri sesi" → POSTs goodbye + closes session.
# 6. Verify navigation to S11 thank-you screen ("makasih udah curhat").
# 7. Tap "balik ke home" → home screen ("Mulai Curhat").
#
# Pre-req:
# 1. A live chat session is on screen (paired + active). Chain after flow
# 03_payment_to_chat_happy.yaml.
#
# Run (chained):
# maestro test client_app/.maestro/flows/03_payment_to_chat_happy.yaml \
# client_app/.maestro/flows/07_end_session_2step.yaml
appId: ${APP_ID_ANDROID}
---
- launchApp:
clearState: false
# Step 0: assert we're on the chat screen.
- extendedWaitUntil:
visible:
text: "Ketik Pesan"
timeout: 10000
# Step 1: tap "akhiri" in the AppBar → step-1 popup.
- tapOn: "akhiri"
- extendedWaitUntil:
visible:
text: "yakin mau akhiri sesi?"
timeout: 5000
- assertVisible: "lanjut akhiri"
- assertVisible: "gak jadi, balik"
# Step 2: tap "lanjut akhiri" → step-2 popup.
- tapOn: "lanjut akhiri"
- extendedWaitUntil:
visible:
text: "mau tinggalin pesan penutup?"
timeout: 5000
- assertVisible: "tulis pesan penutup"
- assertVisible: "lewati saja"
# Step 3: tap "tulis pesan penutup" → closing-message bottom sheet.
- tapOn: "tulis pesan penutup"
- extendedWaitUntil:
visible:
text: "pesan penutup"
timeout: 5000
- assertVisible: "kirim & akhiri sesi"
- assertVisible: "lewat — langsung akhiri"
# Step 4: type a message + send.
- tapOn:
text: "makasih ya bestie..."
- inputText: "makasih bestie, sesi ini ngebantu banget"
- hideKeyboard
- tapOn: "kirim & akhiri sesi"
# Step 5: verify S11 thank-you screen.
- extendedWaitUntil:
visible:
text: "makasih udah curhat"
timeout: 10000
- assertVisible: "balik ke home"
# Step 6: tap "balik ke home" → home.
- tapOn: "balik ke home"
- extendedWaitUntil:
visible:
text: "Mulai Curhat"
timeout: 5000

View File

@@ -0,0 +1,132 @@
# Stage 8 acceptance: returning-user shell.
#
# Flow:
# 1. Cold-start onboarding flow (mirrors 01_smoke) lands customer on home.
# 2. Seed a completed chat_sessions row so the bestie history list isn't empty.
# 3. Tap "Mulai Curhat" → Bestie Choice Sheet appears.
# 4. Tap "bestie yang udah kenal" → bestie history list appears.
# 5. Verify ONLINE pill renders for the seeded (online) mitra.
# 6. Tap "curhat lagi" on the row → targeted-wait screen appears with 20s
# countdown overlay, then matches via the running mitra.
#
# Pre-req: client_app debug APK installed, backend reachable, NODE_ENV != 'production'
# so the dev-only /internal/_test routes are registered, AND a mitra is currently
# online in the dev DB (see backend/src/db/seed.js or run mitra_app to sign in).
#
# Run:
# maestro test client_app/.maestro/flows/08_returning_targeted.yaml
appId: com.halobestie.client.client_app
env:
TEST_PHONE: "+628155556677"
BACKEND_INTERNAL_URL: http://localhost:3001
---
# Wipe prior state for TEST_PHONE so the run is hermetic.
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
# Onboarding → welcome → display name → force-register → OTP → home (matches 01_smoke).
- extendedWaitUntil:
visible:
text: "Mulai"
timeout: 15000
- tapOn:
text: "Mulai"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "Lanjut sebagai Tamu"
timeout: 10000
- tapOn:
text: "Lanjut sebagai Tamu"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "Nama panggilan"
timeout: 10000
- tapOn:
text: "Nama panggilan"
- inputText: "Maestro"
- hideKeyboard
- tapOn:
text: "Lanjut"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "Verifikasi Akun"
timeout: 15000
- tapOn:
text: "Nomor HP"
- inputText: ${TEST_PHONE}
- hideKeyboard
- tapOn:
text: "Kirim OTP"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "Masukkan OTP"
timeout: 15000
- runScript:
file: ../scripts/peek_otp.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- inputText: ${output.OTP}
- extendedWaitUntil:
notVisible:
text: "Masukkan OTP"
timeout: 15000
- extendedWaitUntil:
visible:
text: "Nama panggilan"
timeout: 10000
- tapOn:
text: "Nama panggilan"
- inputText: "Maestro"
- hideKeyboard
- tapOn:
text: "Lanjut"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "Mulai Curhat"
timeout: 20000
# Seed a prior session against an online mitra.
- runScript:
file: ../scripts/seed_history_session.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# Tap "Mulai Curhat" → Bestie Choice Sheet (returning-user variant).
- tapOn:
text: "Mulai Curhat"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "mau curhat sama siapa?"
timeout: 5000
- assertVisible: "bestie yang udah kenal"
- assertVisible: "bestie baru"
# Choose the known bestie path → history list with v4 layout.
- tapOn: "bestie yang udah kenal"
- extendedWaitUntil:
visible:
text: "Riwayat Chat"
timeout: 5000
- assertVisible: "ONLINE"
- assertVisible: "curhat lagi"
# Tap "curhat lagi" → /payment (legacy targeted-payment route). Verify the
# screen title; the targeted-payment flow itself is covered by Stage 5.
- tapOn: "curhat lagi"
- extendedWaitUntil:
visible:
text: "Chat lagi dengan"
timeout: 10000

View File

@@ -0,0 +1,21 @@
// Force-expire the latest pending payment_session by hitting the dev-only
// /internal/_test/force-expire-payment endpoint. Used by the Stage 3 maestro
// flow (04_payment_expired.yaml) to drive the waiting screen into expired
// without waiting 20 minutes.
//
// Strategy: query the latest pending payment_session via raw SQL through the
// reset-phone endpoint? — actually no, we don't have an SQL surface. Instead,
// we expose a tiny "expire-latest-pending" variant: pass `latest=true` and
// the backend looks up the most-recent pending row.
//
// Reads BACKEND_INTERNAL_URL from env (Maestro injects it from the flow).
const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
const resp = http.post(`${url}/internal/_test/force-expire-payment`, {
body: JSON.stringify({ latest: true }),
headers: { 'Content-Type': 'application/json' },
})
if (resp.status !== 200) {
throw new Error(`force-expire-payment failed (${resp.status}): ${resp.body}`)
}
const data = json(resp.body)
output.PAYMENT_ID = data.id

View File

@@ -0,0 +1,16 @@
// Force-expire the most-recent searching chat_session by hitting the dev-only
// /internal/_test/force-pairing-timeout endpoint. Used by the Stage 5 maestro
// flow (05_searching_timeout.yaml) to drive the searching screen into the
// timeout state without waiting 5 minutes.
//
// Reads BACKEND_INTERNAL_URL from env (Maestro injects it from the flow).
const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
const resp = http.post(`${url}/internal/_test/force-pairing-timeout`, {
body: JSON.stringify({ latest: true }),
headers: { 'Content-Type': 'application/json' },
})
if (resp.status !== 200) {
throw new Error(`force-pairing-timeout failed (${resp.status}): ${resp.body}`)
}
const data = json(resp.body)
output.SESSION_ID = data.session_id

View File

@@ -0,0 +1,21 @@
// Force-set the expires_at of the most-recent ACTIVE chat_session by hitting
// the dev-only /internal/_test/force-session-expires-at endpoint. Used by the
// Stage 6 maestro flow (06_chat_countdown.yaml) to drive the 3-min snackbar,
// last-2-min danger pill, and expired banner without waiting in real time.
//
// Reads BACKEND_INTERNAL_URL + SECONDS_FROM_NOW from env (Maestro injects them
// from the flow). The backend re-runs startSessionTimer with the new schedule
// AND clears the per-session "3-min warning fired" flag so the warning fires
// again on the new schedule.
const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
const seconds = parseInt(SECONDS_FROM_NOW || '175', 10)
const resp = http.post(`${url}/internal/_test/force-session-expires-at`, {
body: JSON.stringify({ latest: true, seconds_from_now: seconds }),
headers: { 'Content-Type': 'application/json' },
})
if (resp.status !== 200) {
throw new Error(`force-session-expires-at failed (${resp.status}): ${resp.body}`)
}
const data = json(resp.body)
output.SESSION_ID = data.session_id
output.EXPIRES_AT = data.expires_at

View File

@@ -0,0 +1,13 @@
// Read the latest stub-generated OTP code for TEST_PHONE from the
// backend's dev-only /internal/_test/peek-otp endpoint.
//
// Writes the 6-digit code to output.OTP so the calling flow can use ${output.OTP}.
const phone = TEST_PHONE
const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
const encoded = encodeURIComponent(phone)
const resp = http.get(`${url}/internal/_test/peek-otp?phone=${encoded}`)
if (resp.status !== 200) {
throw new Error(`peek-otp failed (${resp.status}): ${resp.body}`)
}
const data = json(resp.body)
output.OTP = data.code

View File

@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Read the latest stub-generated OTP code for ${TEST_PHONE} from the
# backend's dev-only /internal/_test/peek-otp endpoint.
#
# Echoes the 6-digit code to stdout. Maestro captures the last line of
# stdout into the variable named by the calling runScript step.
set -euo pipefail
phone="${TEST_PHONE:-}"
url="${BACKEND_INTERNAL_URL:-http://localhost:3001}"
if [[ -z "$phone" ]]; then
echo "TEST_PHONE env var required" >&2
exit 1
fi
# url-encode the leading +
encoded_phone="$(printf %s "$phone" | sed 's/+/%2B/')"
resp="$(curl -fsS "${url}/internal/_test/peek-otp?phone=${encoded_phone}")"
echo "$resp" | jq -r .code

View File

@@ -0,0 +1,11 @@
// Wipe otp_requests rows + customer row for TEST_PHONE so repeated runs
// don't trip the 60s cooldown or hit IDENTITY_CONFLICT.
const phone = TEST_PHONE
const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
const resp = http.post(`${url}/internal/_test/reset-phone`, {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone, drop_customer: true }),
})
if (resp.status !== 200) {
throw new Error(`reset-phone failed (${resp.status}): ${resp.body}`)
}

View File

@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Wipe otp_requests rows + (optionally) customer row for ${TEST_PHONE} so
# repeated test runs don't trip the 60s cooldown or hit IDENTITY_CONFLICT.
#
# Runs against backend's dev-only /internal/_test/reset-phone endpoint.
set -euo pipefail
phone="${TEST_PHONE:-}"
url="${BACKEND_INTERNAL_URL:-http://localhost:3001}"
drop_customer="${DROP_CUSTOMER:-true}"
if [[ -z "$phone" ]]; then
echo "TEST_PHONE env var required" >&2
exit 1
fi
curl -fsS -X POST "${url}/internal/_test/reset-phone" \
-H "Content-Type: application/json" \
-d "{\"phone\":\"${phone}\",\"drop_customer\":${drop_customer}}" >/dev/null
echo "reset complete: ${phone}"

View File

@@ -0,0 +1,18 @@
// Seed a completed chat_sessions row for TEST_PHONE so the bestie history
// list isn't empty when the Stage 8 flow opens it. Pairs the customer with
// the most-recently-online mitra in the dev DB.
//
// Hits the dev-only /internal/_test/seed-history-session endpoint.
const phone = TEST_PHONE
const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
const resp = http.post(`${url}/internal/_test/seed-history-session`, {
body: JSON.stringify({ phone }),
headers: { 'Content-Type': 'application/json' },
})
if (resp.status !== 200) {
throw new Error(`seed-history-session failed (${resp.status}): ${resp.body}`)
}
const data = json(resp.body)
output.SESSION_ID = data.session_id
output.MITRA_ID = data.mitra_id
output.MITRA_NAME = data.mitra_name

View File

@@ -9,7 +9,7 @@ Flutter mobile application for end users (clients) seeking mental health support
- **Framework:** Flutter (iOS + Android) - **Framework:** Flutter (iOS + Android)
- **Auth:** Self-managed (Phase 3.4). Anonymous-first + phone OTP + (Google / Apple when creds arrive). - **Auth:** Self-managed (Phase 3.4). Anonymous-first + phone OTP + (Google / Apple when creds arrive).
- Access token in memory on `AuthBridge`; refresh token persisted via `flutter_secure_storage`. - Access token in memory on `AuthBridge`; refresh token persisted via `flutter_secure_storage`.
- Google + Apple SDKs installed but buttons are hidden behind `--dart-define=ENABLE_SOCIAL_AUTH=true` until backend OAuth credentials exist. - Google + Apple SDKs installed; buttons are gated server-side via `GET /api/shared/auth-providers` (cached on cold start in `authProvidersProvider`). Buttons render only when the corresponding env-driven flag returns `enabled: true`.
- `firebase_auth` removed; `firebase_messaging` kept for FCM push. - `firebase_auth` removed; `firebase_messaging` kept for FCM push.
- **API:** Calls public Fastify backend (`/api/client/` and `/api/shared/` routes). Refresh + logout live on `shared.auth`. - **API:** Calls public Fastify backend (`/api/client/` and `/api/shared/` routes). Refresh + logout live on `shared.auth`.
- **Payment:** Xendit (paid sessions, optional trial) - **Payment:** Xendit (paid sessions, optional trial)
@@ -25,4 +25,4 @@ Flutter mobile application for end users (clients) seeking mental health support
- Never call `/api/mitra/` or `/internal/` routes from this app - Never call `/api/mitra/` or `/internal/` routes from this app
- API calls go through `ApiClient`; it auto-attaches the JWT from `AuthBridge` and auto-refreshes on 401 - API calls go through `ApiClient`; it auto-attaches the JWT from `AuthBridge` and auto-refreshes on 401
- WebSocket handshake (`/api/shared/ws`) reads the access token from `AuthBridge` in the first frame's `{type:"auth", token, session_id?}` message - WebSocket handshake (`/api/shared/ws`) reads the access token from `AuthBridge` in the first frame's `{type:"auth", token, session_id?}` message
- Use `const bool.fromEnvironment('ENABLE_SOCIAL_AUTH')` (via `social_auth_enabled.dart`) to gate any Google/Apple UI — never call `loginGoogle` / `loginApple` from a path reachable without that flag - 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`

View File

@@ -1,4 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Android 13+ runtime notification permission. Requested by the
Phase 4 Stage 4 notif-gate via permission_handler. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application <application
android:label="client_app" android:label="client_app"
android:name="${applicationName}" android:name="${applicationName}"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,15 @@
# HaloBestie font assets
Stage 0 design-system fonts. All licensed under the SIL Open Font License.
| File | Source |
|-----------------------------------|--------|
| `BricolageGrotesque-Variable.ttf` | https://github.com/google/fonts/tree/main/ofl/bricolagegrotesque |
| `Poppins-Regular.ttf` | https://github.com/google/fonts/tree/main/ofl/poppins |
| `Poppins-Medium.ttf` | https://github.com/google/fonts/tree/main/ofl/poppins |
| `Poppins-SemiBold.ttf` | https://github.com/google/fonts/tree/main/ofl/poppins |
| `Poppins-Bold.ttf` | https://github.com/google/fonts/tree/main/ofl/poppins |
| `JetBrainsMono-Variable.ttf` | https://github.com/google/fonts/tree/main/ofl/jetbrainsmono |
Wired into `client_app/pubspec.yaml` and consumed via `HaloTokens.fontDisplay`,
`fontBody`, `fontMono` in `client_app/lib/core/theme/halo_tokens.dart`.

View File

@@ -4,6 +4,8 @@
<dict> <dict>
<key>CADisableMinimumFrameDurationOnPhone</key> <key>CADisableMinimumFrameDurationOnPhone</key>
<true/> <true/>
<key>NSUserNotificationsUsageDescription</key>
<string>Halo Bestie kirim notifikasi pas bestie udah siap dengerin dan pas ada chat baru.</string>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
@@ -47,6 +49,13 @@
</array> </array>
</dict> </dict>
</dict> </dict>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
<string>http</string>
<string>whatsapp</string>
<string>tg</string>
</array>
<key>UIApplicationSupportsIndirectInputEvents</key> <key>UIApplicationSupportsIndirectInputEvents</key>
<true/> <true/>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>

View File

@@ -0,0 +1,51 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../api/api_client_provider.dart';
part 'auth_providers_provider.g.dart';
class AuthProvidersConfig {
final bool google;
final bool apple;
final bool phone;
const AuthProvidersConfig({
required this.google,
required this.apple,
required this.phone,
});
/// Conservative fallback used when the network probe fails. Phone OTP is
/// always available; social sign-in is hidden until the backend confirms.
static const fallback = AuthProvidersConfig(
google: false,
apple: false,
phone: true,
);
bool get hasAnySocial => google || apple;
}
/// Cached server-driven flag set for which auth entry points are wired up.
///
/// Replaces the old `--dart-define=ENABLE_SOCIAL_AUTH` build flag: the client
/// now reads `GET /api/shared/auth-providers` once on cold start and hides
/// Google/Apple buttons when the corresponding flag is `false`.
@Riverpod(keepAlive: true)
Future<AuthProvidersConfig> authProviders(Ref ref) async {
try {
final response = await ref.read(apiClientProvider).get('/api/shared/auth-providers');
final data = response['data'] as Map<String, dynamic>?;
if (data == null) return AuthProvidersConfig.fallback;
final google = data['google'] as Map<String, dynamic>?;
final apple = data['apple'] as Map<String, dynamic>?;
final phone = data['phone'] as Map<String, dynamic>?;
return AuthProvidersConfig(
google: (google?['enabled'] as bool?) ?? false,
apple: (apple?['enabled'] as bool?) ?? false,
phone: (phone?['enabled'] as bool?) ?? true,
);
} catch (_) {
return AuthProvidersConfig.fallback;
}
}

View File

@@ -0,0 +1,33 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'auth_providers_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$authProvidersHash() => r'cadec65217f3280bbd1b36568eefb93a7fcdd6f9';
/// Cached server-driven flag set for which auth entry points are wired up.
///
/// Replaces the old `--dart-define=ENABLE_SOCIAL_AUTH` build flag: the client
/// now reads `GET /api/shared/auth-providers` once on cold start and hides
/// Google/Apple buttons when the corresponding flag is `false`.
///
/// Copied from [authProviders].
@ProviderFor(authProviders)
final authProvidersProvider = FutureProvider<AuthProvidersConfig>.internal(
authProviders,
name: r'authProvidersProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$authProvidersHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef AuthProvidersRef = FutureProviderRef<AuthProvidersConfig>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -1,7 +0,0 @@
/// Build-time flag controlling whether Google / Apple sign-in buttons
/// are shown. Default: false until backend OAuth credentials are
/// provisioned. Enable with `--dart-define=ENABLE_SOCIAL_AUTH=true`.
const bool kSocialAuthEnabled = bool.fromEnvironment(
'ENABLE_SOCIAL_AUTH',
defaultValue: false,
);

View File

@@ -6,9 +6,9 @@ part of 'mitra_availability_notifier.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$mitraAvailabilityHash() => r'7429862ccffbae1fc7bbe7368359e1624fe28ec9'; String _$mitraAvailabilityHash() => r'036de8fea66c6a0114abd4a422af12186c1447d7';
/// Phase 3.7 §1: customer-home availability poll. /// Customer-home availability poll.
/// ///
/// Polls `GET /api/client/mitra-availability` every 5 seconds while the home /// 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 /// screen is in the foreground. Polling is gated by the home screen calling
@@ -16,10 +16,10 @@ String _$mitraAvailabilityHash() => r'7429862ccffbae1fc7bbe7368359e1624fe28ec9';
/// - resumed → setActive(true) /// - resumed → setActive(true)
/// - paused/inactive → setActive(false) /// - paused/inactive → setActive(false)
/// ///
/// On any HTTP error we emit `false` (PRD §1.3: never display stale state). /// On any HTTP error we emit `false` (never display stale state).
/// ///
/// The endpoint also returns a `count`, but per PRD §1.3 the customer UI must /// The endpoint also returns a `count`, but the customer UI must only read the
/// only read the binary `available` field — the count is for CC/debug only. /// binary `available` field — the count is for CC/debug only.
/// ///
/// Copied from [MitraAvailability]. /// Copied from [MitraAvailability].
@ProviderFor(MitraAvailability) @ProviderFor(MitraAvailability)

View File

@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:web_socket_channel/web_socket_channel.dart';
import '../api/api_client.dart'; import '../api/api_client.dart';
@@ -32,6 +33,12 @@ class ChatConnectedData extends ChatData {
final bool sessionClosing; final bool sessionClosing;
final bool goodbyeSubmitted; final bool goodbyeSubmitted;
final Map<String, dynamic>? extensionResponse; final Map<String, dynamic>? extensionResponse;
// Phase 4 — voice-call mode badge in header. `chat` is the default (no pill).
final SessionMode mode;
// Phase 4 — drives the client-side seconds-left ticker. Backend only emits
// discrete `session_timer` (60s) + `session_warning` (180s) events, so we
// tick locally off this absolute timestamp for the danger pill / banner.
final DateTime? expiresAt;
const ChatConnectedData({ const ChatConnectedData({
required this.messages, required this.messages,
@@ -42,6 +49,8 @@ class ChatConnectedData extends ChatData {
this.sessionClosing = false, this.sessionClosing = false,
this.goodbyeSubmitted = false, this.goodbyeSubmitted = false,
this.extensionResponse, this.extensionResponse,
this.mode = SessionMode.chat,
this.expiresAt,
}); });
ChatConnectedData copyWith({ ChatConnectedData copyWith({
@@ -53,6 +62,8 @@ class ChatConnectedData extends ChatData {
bool? sessionClosing, bool? sessionClosing,
bool? goodbyeSubmitted, bool? goodbyeSubmitted,
Map<String, dynamic>? extensionResponse, Map<String, dynamic>? extensionResponse,
SessionMode? mode,
DateTime? expiresAt,
}) { }) {
return ChatConnectedData( return ChatConnectedData(
messages: messages ?? this.messages, messages: messages ?? this.messages,
@@ -63,6 +74,8 @@ class ChatConnectedData extends ChatData {
sessionClosing: sessionClosing ?? this.sessionClosing, sessionClosing: sessionClosing ?? this.sessionClosing,
goodbyeSubmitted: goodbyeSubmitted ?? this.goodbyeSubmitted, goodbyeSubmitted: goodbyeSubmitted ?? this.goodbyeSubmitted,
extensionResponse: extensionResponse ?? this.extensionResponse, extensionResponse: extensionResponse ?? this.extensionResponse,
mode: mode ?? this.mode,
expiresAt: expiresAt ?? this.expiresAt,
); );
} }
} }
@@ -102,6 +115,25 @@ class ChatMessage {
} }
} }
/// Phase 4 — derived seconds-left ticker for the chat screen countdown UX.
/// Backend only emits discrete `session_timer` (60s remaining) and
/// `session_warning` (180s remaining) events; the danger pill / expired banner
/// transitions need a smooth tick. Computes remaining off `expiresAt` from the
/// chat state and re-emits every second while a session is connected.
@riverpod
Stream<int> chatRemainingSeconds(Ref ref) async* {
final chatState = ref.watch(chatProvider);
if (chatState is! ChatConnectedData) return;
final expiresAt = chatState.expiresAt;
if (expiresAt == null) return;
while (true) {
final remaining = expiresAt.difference(DateTime.now()).inSeconds;
yield remaining < 0 ? 0 : remaining;
if (remaining <= 0) return;
await Future<void>.delayed(const Duration(seconds: 1));
}
}
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true)
class Chat extends _$Chat { class Chat extends _$Chat {
WebSocketChannel? _channel; WebSocketChannel? _channel;
@@ -109,10 +141,22 @@ class Chat extends _$Chat {
Timer? _typingTimer; Timer? _typingTimer;
String? _connectedSessionId; String? _connectedSessionId;
// Phase 4 — broadcast stream of `session_warning.kind` strings (e.g.
// `three_minutes_left`). Screens listen via [warningStream] to fire one-shot
// UI like the 3-min snackbar. Kept separate from state so the warning
// doesn't accidentally re-fire on rebuild.
final _warningController = StreamController<String>.broadcast();
Stream<String> get warningStream => _warningController.stream;
ApiClient get _apiClient => ref.read(apiClientProvider); ApiClient get _apiClient => ref.read(apiClientProvider);
@override @override
ChatData build() => const ChatInitialData(); ChatData build() {
ref.onDispose(() {
_warningController.close();
});
return const ChatInitialData();
}
/// Idempotent connect: if we're already connected to [sessionId], refresh /// Idempotent connect: if we're already connected to [sessionId], refresh
/// the session status from the server (in case it transitioned to closing / /// the session status from the server (in case it transitioned to closing /
@@ -155,11 +199,16 @@ class Chat extends _$Chat {
return; return;
} }
final goodbyeSubmittedByMe = data?['goodbye_submitted_by_me'] as bool? ?? false; final goodbyeSubmittedByMe = data?['goodbye_submitted_by_me'] as bool? ?? false;
final mode = SessionMode.fromString(data?['mode'] as String?);
final expiresAtRaw = data?['expires_at'] as String?;
final expiresAt = expiresAtRaw != null ? DateTime.tryParse(expiresAtRaw)?.toLocal() : null;
state = current.copyWith( state = current.copyWith(
sessionClosing: status == SessionStatus.closing, sessionClosing: status == SessionStatus.closing,
sessionPaused: status == SessionStatus.extending, sessionPaused: status == SessionStatus.extending,
sessionExpired: false, sessionExpired: false,
goodbyeSubmitted: goodbyeSubmittedByMe, goodbyeSubmitted: goodbyeSubmittedByMe,
mode: mode,
expiresAt: expiresAt,
); );
} catch (e) { } catch (e) {
// ignore: avoid_print // ignore: avoid_print
@@ -184,6 +233,9 @@ class Chat extends _$Chat {
final isClosing = sessionStatus == SessionStatus.closing; final isClosing = sessionStatus == SessionStatus.closing;
final goodbyeSubmittedByMe = sessionData?['goodbye_submitted_by_me'] as bool? ?? false; final goodbyeSubmittedByMe = sessionData?['goodbye_submitted_by_me'] as bool? ?? false;
final mode = SessionMode.fromString(sessionData?['mode'] as String?);
final expiresAtRaw = sessionData?['expires_at'] as String?;
final expiresAt = expiresAtRaw != null ? DateTime.tryParse(expiresAtRaw)?.toLocal() : null;
final response = await _apiClient.get('/api/shared/chat/$sessionId/messages'); final response = await _apiClient.get('/api/shared/chat/$sessionId/messages');
final messagesData = response['data'] as List<dynamic>; final messagesData = response['data'] as List<dynamic>;
@@ -225,6 +277,8 @@ class Chat extends _$Chat {
messages: messages, messages: messages,
sessionClosing: isClosing, sessionClosing: isClosing,
goodbyeSubmitted: goodbyeSubmittedByMe, goodbyeSubmitted: goodbyeSubmittedByMe,
mode: mode,
expiresAt: expiresAt,
); );
} catch (e) { } catch (e) {
state = const ChatErrorData('Gagal terhubung ke chat.'); state = const ChatErrorData('Gagal terhubung ke chat.');
@@ -351,11 +405,41 @@ class Chat extends _$Chat {
case WsMessage.sessionTimer: case WsMessage.sessionTimer:
final remaining = data['remaining_seconds'] as int?; final remaining = data['remaining_seconds'] as int?;
state = current.copyWith(remainingSeconds: remaining); // When the server includes expires_at (Phase 4 dev resync + future
// periodic ticks), update the local ticker reference. Backwards-
// compatible: pre-Phase-4 events without `expires_at` are no-ops here.
final expiresAtRaw = data['expires_at'] as String?;
final expiresAt = expiresAtRaw != null ? DateTime.tryParse(expiresAtRaw)?.toLocal() : null;
state = current.copyWith(
remainingSeconds: remaining,
expiresAt: expiresAt,
);
break;
case WsMessage.sessionWarning:
// Forward to listeners (chat screen drives a one-shot snackbar). Stream
// is broadcast — subscribers may or may not be present; cheap if not.
final kind = data['kind'] as String?;
// Resync the local ticker — server may have shifted expires_at since
// we last connected (e.g. extension, dev shortcut). Without this, the
// last-2-min danger pill / expired banner can't track real time.
final expiresAtRaw = data['expires_at'] as String?;
final expiresAt = expiresAtRaw != null ? DateTime.tryParse(expiresAtRaw)?.toLocal() : null;
if (expiresAt != null) {
state = current.copyWith(expiresAt: expiresAt);
}
if (kind != null) _warningController.add(kind);
break; break;
case WsMessage.sessionExpired: case WsMessage.sessionExpired:
state = current.copyWith(sessionExpired: true); // Snap the local ticker to 0 so the floating expired banner appears
// immediately. The server-side expires_at may have shifted (e.g.
// dev /force-session-expires-at) ahead of our last refresh, so we
// can't rely on the existing expiresAt value to reach 0 on its own.
state = current.copyWith(
sessionExpired: true,
expiresAt: DateTime.now(),
);
break; break;
case WsMessage.sessionPaused: case WsMessage.sessionPaused:

View File

@@ -6,7 +6,31 @@ part of 'chat_notifier.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$chatHash() => r'35388d2977db9cee4307697524620b22691c54f5'; String _$chatRemainingSecondsHash() =>
r'd7bce1bffe7d3034b6f4905194ead4dfaf473c92';
/// Phase 4 — derived seconds-left ticker for the chat screen countdown UX.
/// Backend only emits discrete `session_timer` (60s remaining) and
/// `session_warning` (180s remaining) events; the danger pill / expired banner
/// transitions need a smooth tick. Computes remaining off `expiresAt` from the
/// chat state and re-emits every second while a session is connected.
///
/// Copied from [chatRemainingSeconds].
@ProviderFor(chatRemainingSeconds)
final chatRemainingSecondsProvider = AutoDisposeStreamProvider<int>.internal(
chatRemainingSeconds,
name: r'chatRemainingSecondsProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$chatRemainingSecondsHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef ChatRemainingSecondsRef = AutoDisposeStreamProviderRef<int>;
String _$chatHash() => r'f9d77e176acb682dc6477635e0e80a09e846988b';
/// See also [Chat]. /// See also [Chat].
@ProviderFor(Chat) @ProviderFor(Chat)

View File

@@ -8,27 +8,97 @@ class PriceTier {
final int durationMinutes; final int durationMinutes;
final int price; final int price;
final String label; final String label;
final String? id;
final String? tag;
PriceTier({required this.durationMinutes, required this.price, required this.label}); const PriceTier({
required this.durationMinutes,
required this.price,
required this.label,
this.id,
this.tag,
});
/// Phase 4 shape: `{ id, minutes, price_idr, tag }` — used by the new
/// chat/call tier groups. Falls back to the legacy free-trial-pricing shape
/// (`{ duration_minutes, price, label }`) for back-compat with the Phase 3
/// `/api/client/chat/pricing` payload still consumed by the legacy payment
/// screen + bottom sheet.
factory PriceTier.fromJson(Map<String, dynamic> json) { factory PriceTier.fromJson(Map<String, dynamic> json) {
final minutes = (json['minutes'] ?? json['duration_minutes']) as int;
final price = (json['price_idr'] ?? json['price']) as int;
final label = (json['label'] as String?) ?? '$minutes Menit';
return PriceTier( return PriceTier(
durationMinutes: json['duration_minutes'] as int, durationMinutes: minutes,
price: json['price'] as int, price: price,
label: json['label'] as String, label: label,
id: (json['id'] as String?) ?? minutes.toString(),
tag: json['tag'] as String?,
);
}
}
/// First-session discount block. Mirrors backend
/// `pricing.first_session_discount`. Server-authoritative — the client only
/// reads this; the actual discount price is re-validated on the backend when
/// the payment session is created.
class FirstSessionDiscount {
final bool eligible;
final int actualPriceIDR;
final int gimmickPriceIDR;
final int durationMinutes;
final List<String> modes;
const FirstSessionDiscount({
required this.eligible,
required this.actualPriceIDR,
required this.gimmickPriceIDR,
required this.durationMinutes,
required this.modes,
});
factory FirstSessionDiscount.fromJson(Map<String, dynamic> json) {
final modesRaw = json['modes'];
final modes = modesRaw is List
? modesRaw.map((e) => e.toString()).toList()
: const <String>['chat'];
return FirstSessionDiscount(
eligible: json['eligible'] as bool? ?? false,
actualPriceIDR: json['actual_price_idr'] as int? ?? 0,
gimmickPriceIDR: json['gimmick_price_idr'] as int? ?? 0,
durationMinutes: json['duration_minutes'] as int? ?? 0,
modes: modes,
); );
} }
} }
class PricingData { class PricingData {
/// Legacy single-list tiers. Populated from `data.tiers` when the response
/// uses the Phase 3 shape; populated from `data.chat.tiers` when the
/// Phase 4 shape is returned (so existing callers keep working).
final List<PriceTier> tiers; final List<PriceTier> tiers;
final bool freeTrialEligible; final bool freeTrialEligible;
final int freeTrialDurationMinutes; final int freeTrialDurationMinutes;
/// Phase 4 chat-mode tiers (`pricing.chat.tiers`). Empty when the backend
/// still returns the Phase 3 shape.
final List<PriceTier> chatTiers;
/// Phase 4 call-mode tiers (`pricing.call.tiers`). Empty when the backend
/// still returns the Phase 3 shape.
final List<PriceTier> callTiers;
/// Phase 4 first-session discount block. Null when the backend still
/// returns the Phase 3 shape.
final FirstSessionDiscount? firstSessionDiscount;
const PricingData({ const PricingData({
required this.tiers, required this.tiers,
required this.freeTrialEligible, required this.freeTrialEligible,
this.freeTrialDurationMinutes = 5, this.freeTrialDurationMinutes = 5,
this.chatTiers = const [],
this.callTiers = const [],
this.firstSessionDiscount,
}); });
} }
@@ -37,9 +107,35 @@ Future<PricingData> chatPricing(Ref ref) async {
final apiClient = ref.read(apiClientProvider); final apiClient = ref.read(apiClientProvider);
final response = await apiClient.get('/api/client/chat/pricing'); final response = await apiClient.get('/api/client/chat/pricing');
final data = response['data'] as Map<String, dynamic>; final data = response['data'] as Map<String, dynamic>;
// Phase 4 shape — `data.chat.tiers` + `data.call.tiers` + `first_session_discount`.
// Phase 3 shape — `data.tiers` + `data.free_trial`. Detect which we got and
// populate the model accordingly.
final hasPhase4Groups = data['chat'] is Map<String, dynamic>;
if (hasPhase4Groups) {
final chat = data['chat'] as Map<String, dynamic>;
final call = (data['call'] as Map<String, dynamic>?) ?? const {};
final chatTiers = (chat['tiers'] as List<dynamic>? ?? const [])
.map((t) => PriceTier.fromJson(t as Map<String, dynamic>))
.toList();
final callTiers = (call['tiers'] as List<dynamic>? ?? const [])
.map((t) => PriceTier.fromJson(t as Map<String, dynamic>))
.toList();
final discountJson = data['first_session_discount'] as Map<String, dynamic>?;
final discount = discountJson != null ? FirstSessionDiscount.fromJson(discountJson) : null;
return PricingData(
tiers: chatTiers,
freeTrialEligible: false,
chatTiers: chatTiers,
callTiers: callTiers,
firstSessionDiscount: discount,
);
}
final tiersJson = data['tiers'] as List<dynamic>; final tiersJson = data['tiers'] as List<dynamic>;
final tiers = tiersJson.map((t) => PriceTier.fromJson(t as Map<String, dynamic>)).toList(); final tiers = tiersJson.map((t) => PriceTier.fromJson(t as Map<String, dynamic>)).toList();
final freeTrial = data['free_trial'] as Map<String, dynamic>; final freeTrial = (data['free_trial'] as Map<String, dynamic>?) ?? const {};
return PricingData( return PricingData(
tiers: tiers, tiers: tiers,

View File

@@ -6,7 +6,7 @@ part of 'chat_opening_provider.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$chatPricingHash() => r'53ca829d46f7ba3b481e7a6b54482c62f9fe3ad0'; String _$chatPricingHash() => r'6dfbdf77942a67d3da689849eda89fc1fa3e6e39';
/// See also [chatPricing]. /// See also [chatPricing].
@ProviderFor(chatPricing) @ProviderFor(chatPricing)

View File

@@ -30,6 +30,13 @@ class ClosureCompleteData extends SessionClosureData {
const ClosureCompleteData(); const ClosureCompleteData();
} }
/// Stage 7 — emitted when the close-session API returns 409 (mitra-rejects-
/// close path). The chat screen surfaces a "bestie offline / returning"
/// fallback popup; Stage 8 will own the proper variant.
class ClosureRejectedByMitraData extends SessionClosureData {
const ClosureRejectedByMitraData();
}
class ClosureErrorData extends SessionClosureData { class ClosureErrorData extends SessionClosureData {
final String message; final String message;
const ClosureErrorData(this.message); const ClosureErrorData(this.message);
@@ -111,4 +118,37 @@ class SessionClosure extends _$SessionClosure {
state = const ClosureErrorData('Gagal mengirim pesan penutup.'); state = const ClosureErrorData('Gagal mengirim pesan penutup.');
} }
} }
/// Stage 7 — customer-initiated close. Calls
/// `POST /api/client/session/:sessionId/end`. On success, emits
/// `ClosureCompleteData` and refreshes the active-session snapshot so the
/// home CTA flips back to "Mulai Curhat" without waiting for the next poll.
/// On 409, emits `ClosureRejectedByMitraData` so the chat screen can show
/// the bestie-returning fallback popup. Other errors fall back to
/// `ClosureErrorData`.
Future<void> closeSession(String sessionId) async {
try {
await ref.read(apiClientProvider).post(
'/api/client/session/$sessionId/end',
data: const <String, dynamic>{},
);
state = const ClosureCompleteData();
ref.invalidate(activeSessionProvider);
} on DioException catch (e) {
if (e.response?.statusCode == 409) {
state = const ClosureRejectedByMitraData();
} else {
final code = e.response?.data?['error']?['code'];
if (code == 'SESSION_NOT_ACTIVE') {
// Server treats it as already closed — equivalent to success.
state = const ClosureCompleteData();
ref.invalidate(activeSessionProvider);
} else {
state = const ClosureErrorData('Gagal mengakhiri sesi.');
}
}
} catch (_) {
state = const ClosureErrorData('Gagal mengakhiri sesi.');
}
}
} }

View File

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

View File

@@ -0,0 +1,13 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// Phase 4 Stage 7 — UX A/B toggle for the two-step end-session confirm.
///
/// Backed by `app_config.end_session_two_step_confirm` (seeded `true` in
/// Phase 4 Stage 1.5). The plan mentions an A/B switch but no client-facing
/// endpoint is exposed yet — Stage 1.5 only seeded the row. Until a public
/// `/api/shared/config/app-flags` (or similar) is added, this provider keeps
/// the seed default on-device. When the endpoint lands, swap the override
/// for a `FutureProvider` that fetches it.
///
/// TODO(phase4-followup): wire to backend once the read-side endpoint is added.
final endSessionTwoStepConfirmProvider = Provider<bool>((ref) => true);

View File

@@ -64,6 +64,21 @@ class ExtensionStatus {
ExtensionStatus._(); ExtensionStatus._();
} }
/// Session mode — chat or voice call. Mirrors backend `payment_sessions.mode`
/// (added in Phase 4 stage 1). A `call` session is functionally a chat with a
/// "voice call" badge and (eventually) a Meet link the mitra pastes manually;
/// no real audio transport is built yet.
enum SessionMode {
chat('chat'),
call('call');
final String value;
const SessionMode(this.value);
static SessionMode fromString(String? v) =>
values.firstWhere((e) => e.value == v, orElse: () => SessionMode.chat);
}
/// Session topic sensitivity /// Session topic sensitivity
enum TopicSensitivity { enum TopicSensitivity {
regular('regular'), regular('regular'),
@@ -101,6 +116,9 @@ class WsMessage {
static const sessionCompleted = 'session_completed'; static const sessionCompleted = 'session_completed';
static const sessionPaused = 'session_paused'; static const sessionPaused = 'session_paused';
static const sessionResumed = 'session_resumed'; static const sessionResumed = 'session_resumed';
// Phase 4 — soft countdown warning (`kind: 'three_minutes_left'`).
// Customer-only: mitra never sees a countdown.
static const sessionWarning = 'session_warning';
// Extension // Extension
static const extensionRequest = 'extension_request'; static const extensionRequest = 'extension_request';

View File

@@ -0,0 +1,115 @@
import 'dart:async';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/widgets.dart';
import 'package:permission_handler/permission_handler.dart' as ph;
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'notif_permission.g.dart';
enum NotifPermStatus { notDetermined, granted, denied }
/// Wraps `firebase_messaging` + `permission_handler` for the Phase 4 Stage 4
/// notif gate. Reads/requests are platform-routed:
/// - iOS uses Firebase Messaging (which surfaces the system UNNotification
/// authorization status).
/// - Android 13+ uses `permission_handler` for `Permission.notification`
/// (POST_NOTIFICATIONS runtime). Older Android always reports granted.
class NotifPermission {
const NotifPermission();
Future<NotifPermStatus> readStatus() async {
final phStatus = await ph.Permission.notification.status;
return _mapPh(phStatus);
}
/// Shows the OS prompt only when status is [NotifPermStatus.notDetermined].
/// Otherwise returns the current status without re-prompting (the OS would
/// no-op anyway on a previously-resolved permission).
Future<NotifPermStatus> request() async {
final current = await readStatus();
if (current != NotifPermStatus.notDetermined) return current;
// Firebase Messaging requestPermission triggers the iOS prompt; on Android
// it is a no-op for permission UI but registers the FCM iOS APNS token.
// We still call permission_handler.request() so Android 13+ shows the
// POST_NOTIFICATIONS dialog.
await FirebaseMessaging.instance.requestPermission();
final phResult = await ph.Permission.notification.request();
return _mapPh(phResult);
}
Future<void> openAppSettings() => ph.openAppSettings();
NotifPermStatus _mapPh(ph.PermissionStatus s) {
if (s.isGranted || s.isLimited || s.isProvisional) {
return NotifPermStatus.granted;
}
if (s.isDenied) return NotifPermStatus.notDetermined;
// permanentlyDenied + restricted both behave like "denied — open settings".
return NotifPermStatus.denied;
}
}
final _helperProvider = Provider<NotifPermission>((_) => const NotifPermission());
/// Cached notif permission status. Auto-refreshes on app foreground via an
/// internal `WidgetsBindingObserver` — there is no shared `appLifecycleProvider`
/// in this codebase yet, so the observer is owned here.
@Riverpod(keepAlive: true)
class NotifPermissionStatus extends _$NotifPermissionStatus {
_LifecycleHook? _hook;
@override
Future<NotifPermStatus> build() async {
_hook ??= _LifecycleHook(_onResumed);
ref.onDispose(() {
_hook?.detach();
_hook = null;
});
return ref.read(_helperProvider).readStatus();
}
/// Triggers the OS prompt (only if [NotifPermStatus.notDetermined]) and
/// re-publishes the resolved status.
Future<NotifPermStatus> request() async {
final result = await ref.read(_helperProvider).request();
state = AsyncData(result);
return result;
}
Future<void> openAppSettings() =>
ref.read(_helperProvider).openAppSettings();
/// Force a re-read — used after returning from app settings.
Future<void> refresh() async {
final s = await ref.read(_helperProvider).readStatus();
if (state.valueOrNull == s) return;
state = AsyncData(s);
}
void _onResumed() {
// Fire-and-forget; refresh is idempotent.
// ignore: unawaited_futures
refresh();
}
}
class _LifecycleHook with WidgetsBindingObserver {
_LifecycleHook(this._onResumed) {
WidgetsBinding.instance.addObserver(this);
}
final VoidCallback _onResumed;
void detach() {
WidgetsBinding.instance.removeObserver(this);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_onResumed();
}
}
}

View File

@@ -0,0 +1,31 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'notif_permission.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$notifPermissionStatusHash() =>
r'16c81af5e48dab2c7d0cf33c985e0ca7c3d01006';
/// Cached notif permission status. Auto-refreshes on app foreground via an
/// internal `WidgetsBindingObserver` — there is no shared `appLifecycleProvider`
/// in this codebase yet, so the observer is owned here.
///
/// Copied from [NotifPermissionStatus].
@ProviderFor(NotifPermissionStatus)
final notifPermissionStatusProvider =
AsyncNotifierProvider<NotifPermissionStatus, NotifPermStatus>.internal(
NotifPermissionStatus.new,
name: r'notifPermissionStatusProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$notifPermissionStatusHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$NotifPermissionStatus = AsyncNotifier<NotifPermStatus>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -6,7 +6,7 @@ part of 'pairing_notifier.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$pairingHash() => r'0fe1e5646fe70b90f5489919ec8dd557c998daad'; String _$pairingHash() => r'd39980fe5b82348d03485006d0534ab597b93ceb';
/// See also [Pairing]. /// See also [Pairing].
@ProviderFor(Pairing) @ProviderFor(Pairing)

View File

@@ -0,0 +1,289 @@
import 'package:flutter/material.dart';
import 'halo_tokens.dart';
import 'widgets/widgets.dart';
const bool kThemePreviewEnabled = bool.fromEnvironment(
'THEME_PREVIEW',
defaultValue: false,
);
class ThemePreviewScreen extends StatefulWidget {
const ThemePreviewScreen({super.key});
@override
State<ThemePreviewScreen> createState() => _ThemePreviewScreenState();
}
class _ThemePreviewScreenState extends State<ThemePreviewScreen> {
final Set<String> _selectedChips = {'gak nyenyak'};
bool _disablePrimary = false;
@override
Widget build(BuildContext context) {
final text = Theme.of(context).textTheme;
return Scaffold(
appBar: AppBar(title: const Text('Halo theme preview')),
body: ListView(
padding: const EdgeInsets.all(HaloSpacing.s20),
children: [
_section('Typography'),
Text('display large 36/700', style: text.displayLarge),
const SizedBox(height: HaloSpacing.s8),
Text('title large 22/700', style: text.titleLarge),
const SizedBox(height: HaloSpacing.s8),
Text(
'body medium 15/400 — Poppins',
style: text.bodyMedium,
),
const SizedBox(height: HaloSpacing.s8),
Text(
'label small 10/600 — caption tracking',
style: text.labelSmall,
),
_divider(),
_section('Color tokens'),
const Wrap(
spacing: HaloSpacing.s8,
runSpacing: HaloSpacing.s8,
children: [
_Swatch('brand', HaloTokens.brand),
_Swatch('brandDark', HaloTokens.brandDark),
_Swatch('brandSoft', HaloTokens.brandSoft),
_Swatch('accent', HaloTokens.accent),
_Swatch('mint', HaloTokens.mint),
_Swatch('lilac', HaloTokens.lilac),
_Swatch('success', HaloTokens.success),
_Swatch('danger', HaloTokens.danger),
_Swatch('ink', HaloTokens.ink, label: Colors.white),
_Swatch('inkSoft', HaloTokens.inkSoft, label: Colors.white),
],
),
_divider(),
_section('HaloButton'),
Wrap(
spacing: HaloSpacing.s8,
runSpacing: HaloSpacing.s8,
children: [
HaloButton(
label: 'primary md',
onPressed: _disablePrimary ? null : () {},
),
HaloButton(
label: 'secondary',
variant: HaloButtonVariant.secondary,
onPressed: () {},
),
HaloButton(
label: 'ghost',
variant: HaloButtonVariant.ghost,
onPressed: () {},
),
HaloButton(
label: 'small',
size: HaloButtonSize.sm,
onPressed: () {},
),
HaloButton(
label: 'large with icon',
size: HaloButtonSize.lg,
icon: const Icon(Icons.send_rounded),
onPressed: () {},
),
const HaloButton(
label: 'disabled',
onPressed: null,
),
],
),
const SizedBox(height: HaloSpacing.s12),
Row(
children: [
Switch(
value: _disablePrimary,
onChanged: (v) => setState(() => _disablePrimary = v),
),
const Text('disable primary'),
],
),
_divider(),
_section('HaloOrb'),
Wrap(
spacing: HaloSpacing.s12,
runSpacing: HaloSpacing.s12,
children: List.generate(
6,
(i) => HaloOrb(seed: i, label: 'ABCDEF'[i]),
),
),
_divider(),
_section('HaloStepDots'),
for (int c = 1; c <= 4; c++) ...[
Padding(
padding: const EdgeInsets.only(bottom: HaloSpacing.s8),
child: HaloStepDots(total: 4, current: c),
),
],
_divider(),
_section('HaloChip (ESP-style multi-select)'),
Wrap(
spacing: HaloSpacing.s8,
runSpacing: HaloSpacing.s8,
children: [
for (final t in const [
'gak nyenyak',
'overthinking',
'putus',
'kerjaan',
'keluarga',
'sendiri',
])
HaloChip(
label: t,
selected: _selectedChips.contains(t),
onTap: () => setState(() {
if (!_selectedChips.add(t)) _selectedChips.remove(t);
}),
),
],
),
_divider(),
_section('HaloBottomSheet / HaloPopup / HaloSnackbar'),
Wrap(
spacing: HaloSpacing.s8,
runSpacing: HaloSpacing.s8,
children: [
HaloButton(
label: 'show bottom sheet',
variant: HaloButtonVariant.secondary,
onPressed: () => HaloBottomSheet.show<void>(
context,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('halo bestie', style: text.titleLarge),
const SizedBox(height: HaloSpacing.s8),
Text(
'mau verif nomor dulu, atau ngobrol anonim?',
style: text.bodyMedium,
),
const SizedBox(height: HaloSpacing.s24),
HaloButton(
label: 'verif nomor',
fullWidth: true,
onPressed: () => Navigator.of(context).pop(),
),
const SizedBox(height: HaloSpacing.s8),
HaloButton(
label: 'lanjut anonim',
variant: HaloButtonVariant.ghost,
fullWidth: true,
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
),
HaloButton(
label: 'show popup',
variant: HaloButtonVariant.secondary,
onPressed: () => HaloPopup.show<void>(
context,
title: 'verif lagi penuh',
body: 'coba lagi nanti, atau lanjut tanpa verif aja.',
icon: const Icon(
Icons.lock_clock_rounded,
size: 40,
color: HaloTokens.brand,
),
primary: HaloPopupAction(
label: 'lanjut tanpa verif',
onPressed: () {},
),
secondary: HaloPopupAction(
label: 'hubungi admin',
onPressed: () {},
),
),
),
HaloButton(
label: 'show snackbar',
variant: HaloButtonVariant.secondary,
onPressed: () => HaloSnackbar.show(
context,
'sisa 3 menit lagi ya',
icon: '',
),
),
],
),
_divider(),
_section('Input'),
const TextField(
decoration: InputDecoration(
hintText: 'mau dipanggil apa?',
labelText: 'nama panggilan',
),
),
const SizedBox(height: HaloSpacing.s48),
],
),
);
}
Widget _section(String title) => Padding(
padding: const EdgeInsets.only(top: HaloSpacing.s16, bottom: HaloSpacing.s8),
child: Text(
title,
style: const TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 18,
fontWeight: FontWeight.w700,
color: HaloTokens.brandDark,
),
),
);
Widget _divider() => const Padding(
padding: EdgeInsets.symmetric(vertical: HaloSpacing.s16),
child: Divider(),
);
}
class _Swatch extends StatelessWidget {
const _Swatch(this.name, this.color, {this.label = HaloTokens.ink});
final String name;
final Color color;
final Color label;
@override
Widget build(BuildContext context) {
return Container(
width: 110,
height: 64,
padding: const EdgeInsets.all(HaloSpacing.s8),
alignment: Alignment.bottomLeft,
decoration: BoxDecoration(
color: color,
borderRadius: HaloRadius.md,
border: Border.all(color: HaloTokens.border),
),
child: Text(
name,
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 12,
fontWeight: FontWeight.w600,
color: label,
),
),
);
}
}

View File

@@ -0,0 +1,316 @@
import 'package:flutter/material.dart';
import 'halo_tokens.dart';
ThemeData haloThemeData() {
final base = ColorScheme.fromSeed(
seedColor: HaloTokens.brand,
brightness: Brightness.light,
);
final colorScheme = base.copyWith(
primary: HaloTokens.brand,
onPrimary: Colors.white,
primaryContainer: HaloTokens.brandSoft,
onPrimaryContainer: HaloTokens.brandDark,
secondary: HaloTokens.accent,
onSecondary: HaloTokens.ink,
secondaryContainer: HaloTokens.accentSoft,
onSecondaryContainer: HaloTokens.brandDark,
surface: HaloTokens.surface,
onSurface: HaloTokens.ink,
surfaceContainerHighest: HaloTokens.bg,
error: HaloTokens.danger,
onError: Colors.white,
outline: HaloTokens.border,
);
const textTheme = TextTheme(
displayLarge: TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 36,
height: 40 / 36,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
color: HaloTokens.ink,
),
displayMedium: TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 30,
height: 34 / 30,
fontWeight: FontWeight.w700,
letterSpacing: -0.4,
color: HaloTokens.ink,
),
displaySmall: TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 26,
height: 30 / 26,
fontWeight: FontWeight.w700,
letterSpacing: -0.3,
color: HaloTokens.ink,
),
headlineMedium: TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 22,
height: 28 / 22,
fontWeight: FontWeight.w700,
color: HaloTokens.ink,
),
titleLarge: TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 22,
height: 28 / 22,
fontWeight: FontWeight.w700,
color: HaloTokens.ink,
),
titleMedium: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 18,
height: 24 / 18,
fontWeight: FontWeight.w600,
color: HaloTokens.ink,
),
titleSmall: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 15,
height: 22 / 15,
fontWeight: FontWeight.w600,
color: HaloTokens.ink,
),
bodyLarge: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 16,
height: 24 / 16,
fontWeight: FontWeight.w400,
color: HaloTokens.ink,
),
bodyMedium: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 15,
height: 22 / 15,
fontWeight: FontWeight.w400,
color: HaloTokens.ink,
),
bodySmall: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 13,
height: 18 / 13,
fontWeight: FontWeight.w500,
color: HaloTokens.inkSoft,
),
labelLarge: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 15,
height: 22 / 15,
fontWeight: FontWeight.w600,
color: HaloTokens.ink,
),
labelMedium: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 12,
height: 16 / 12,
fontWeight: FontWeight.w500,
color: HaloTokens.inkSoft,
),
labelSmall: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 10,
height: 14 / 10,
fontWeight: FontWeight.w600,
letterSpacing: 0.4,
color: HaloTokens.inkMuted,
),
);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
scaffoldBackgroundColor: HaloTokens.bg,
textTheme: textTheme,
fontFamily: HaloTokens.fontBody,
appBarTheme: const AppBarTheme(
backgroundColor: HaloTokens.bg,
foregroundColor: HaloTokens.ink,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: false,
titleTextStyle: TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 22,
height: 28 / 22,
fontWeight: FontWeight.w700,
color: HaloTokens.ink,
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: HaloTokens.brand,
foregroundColor: Colors.white,
disabledBackgroundColor: HaloTokens.brandSoft,
disabledForegroundColor: HaloTokens.inkMuted,
elevation: 0,
shadowColor: const Color(0x59E17A9D),
padding: const EdgeInsets.symmetric(
horizontal: HaloSpacing.s24,
vertical: HaloSpacing.s16,
),
shape: const RoundedRectangleBorder(borderRadius: HaloRadius.pill),
textStyle: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: HaloTokens.brandDark,
side: const BorderSide(color: HaloTokens.border),
padding: const EdgeInsets.symmetric(
horizontal: HaloSpacing.s24,
vertical: HaloSpacing.s16,
),
shape: const RoundedRectangleBorder(borderRadius: HaloRadius.pill),
textStyle: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: HaloTokens.brandDark,
padding: const EdgeInsets.symmetric(
horizontal: HaloSpacing.s16,
vertical: HaloSpacing.s12,
),
shape: const RoundedRectangleBorder(borderRadius: HaloRadius.pill),
textStyle: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
inputDecorationTheme: const InputDecorationTheme(
filled: true,
fillColor: HaloTokens.surface,
contentPadding: EdgeInsets.symmetric(
horizontal: HaloSpacing.s20,
vertical: HaloSpacing.s20,
),
constraints: BoxConstraints(minHeight: 64),
hintStyle: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 15,
color: HaloTokens.inkMuted,
),
labelStyle: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 13,
color: HaloTokens.inkSoft,
),
border: OutlineInputBorder(
borderRadius: HaloRadius.lg,
borderSide: BorderSide(color: HaloTokens.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: HaloRadius.lg,
borderSide: BorderSide(color: HaloTokens.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: HaloRadius.lg,
borderSide: BorderSide(color: HaloTokens.brand, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: HaloRadius.lg,
borderSide: BorderSide(color: HaloTokens.danger),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: HaloRadius.lg,
borderSide: BorderSide(color: HaloTokens.danger, width: 2),
),
),
bottomSheetTheme: const BottomSheetThemeData(
backgroundColor: HaloTokens.surface,
surfaceTintColor: HaloTokens.surface,
modalBackgroundColor: HaloTokens.surface,
modalBarrierColor: Color(0x66000000),
elevation: 0,
modalElevation: 0,
showDragHandle: true,
dragHandleColor: HaloTokens.brandSoft,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(24),
topRight: Radius.circular(24),
),
),
clipBehavior: Clip.antiAlias,
),
dialogTheme: const DialogThemeData(
backgroundColor: HaloTokens.surface,
surfaceTintColor: HaloTokens.surface,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: HaloRadius.xl),
titleTextStyle: TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 22,
fontWeight: FontWeight.w700,
color: HaloTokens.ink,
),
contentTextStyle: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 15,
height: 22 / 15,
color: HaloTokens.inkSoft,
),
),
snackBarTheme: const SnackBarThemeData(
behavior: SnackBarBehavior.floating,
backgroundColor: HaloTokens.ink,
contentTextStyle: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.white,
),
shape: RoundedRectangleBorder(borderRadius: HaloRadius.pill),
elevation: 4,
insetPadding: EdgeInsets.symmetric(
horizontal: HaloSpacing.s16,
vertical: HaloSpacing.s12,
),
actionTextColor: HaloTokens.brandSoft,
),
chipTheme: ChipThemeData(
backgroundColor: HaloTokens.surface,
selectedColor: HaloTokens.brand,
disabledColor: HaloTokens.brandSoft.withValues(alpha: 0.5),
labelStyle: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 14,
fontWeight: FontWeight.w500,
color: HaloTokens.ink,
),
secondaryLabelStyle: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.white,
),
padding: const EdgeInsets.symmetric(
horizontal: HaloSpacing.s16,
vertical: HaloSpacing.s8,
),
side: const BorderSide(color: HaloTokens.border),
shape: const RoundedRectangleBorder(borderRadius: HaloRadius.pill),
),
dividerTheme: const DividerThemeData(
color: HaloTokens.border,
thickness: 1,
space: 1,
),
);
}

View File

@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
/// Design tokens for the HaloBestie warm palette.
///
/// Mirrors `requirement/Figma/handoff/tokens.json`. Three palettes
/// (warm/calm/playful) exist in the source-of-truth JSON; only `warm`
/// ships in code today — the others are stubbed for phase 5.
///
/// Naming convention: every token prefixed with `Halo*` and grouped into
/// purpose classes (`HaloTokens` for colors, `HaloSpacing`, `HaloRadius`,
/// `HaloMotion`, `HaloShadows`).
class HaloTokens {
const HaloTokens._();
// Warm palette — default.
static const Color bg = Color(0xFFFDF7F4);
static const Color surface = Color(0xFFFFFFFF);
static const Color ink = Color(0xFF2A1820);
static const Color inkSoft = Color(0xFF6B5560);
static const Color inkMuted = Color(0xFF9C8590);
static const Color brand = Color(0xFFE17A9D);
static const Color brandDark = Color(0xFF8C3255);
static const Color brandSoft = Color(0xFFF7E4E9);
static const Color brandSofter = Color(0xFFFBEFF3);
static const Color accent = Color(0xFFF7B26A);
static const Color accentSoft = Color(0xFFFCEAD3);
static const Color mint = Color(0xFFB8DBC8);
static const Color lilac = Color(0xFFD4C5E8);
static const Color success = Color(0xFF5BA67F);
static const Color danger = Color(0xFFD86B6B);
static const Color border = Color(0xFFF0E4E8);
// Font family names — must match the `family:` entries in pubspec.yaml.
// Falls back to system fonts when the .ttf assets are not bundled.
static const String fontDisplay = 'BricolageGrotesque';
static const String fontBody = 'Poppins';
static const String fontMono = 'JetBrainsMono';
// TODO: phase5 — calm palette
// static const Color calmBg = Color(0xFFF6F4F8);
// static const Color calmBrand = Color(0xFF9B8BC4);
// ...
// TODO: phase5 — playful palette
// static const Color playfulBg = Color(0xFFFFF5F8);
// static const Color playfulBrand = Color(0xFFFF69A0);
// ...
}
class HaloSpacing {
const HaloSpacing._();
static const double s0 = 0;
static const double s4 = 4;
static const double s8 = 8;
static const double s12 = 12;
static const double s16 = 16;
static const double s20 = 20;
static const double s24 = 24;
static const double s32 = 32;
static const double s40 = 40;
static const double s48 = 48;
static const double s64 = 64;
static const double s80 = 80;
}
class HaloRadius {
const HaloRadius._();
static const Radius _sm = Radius.circular(8);
static const Radius _md = Radius.circular(12);
static const Radius _lg = Radius.circular(16);
static const Radius _xl = Radius.circular(22);
static const Radius _pill = Radius.circular(9999);
static const BorderRadius sm = BorderRadius.all(_sm);
static const BorderRadius md = BorderRadius.all(_md);
static const BorderRadius lg = BorderRadius.all(_lg);
static const BorderRadius xl = BorderRadius.all(_xl);
static const BorderRadius pill = BorderRadius.all(_pill);
}
class HaloMotion {
const HaloMotion._();
static const Duration fast = Duration(milliseconds: 180);
static const Duration normal = Duration(milliseconds: 280);
static const Duration slow = Duration(milliseconds: 420);
static const Cubic ease = Cubic(0.2, 0.8, 0.2, 1);
}
class HaloShadows {
const HaloShadows._();
static const List<BoxShadow> soft = [
BoxShadow(
color: Color(0x0A8C3255),
offset: Offset(0, 1),
blurRadius: 2,
),
BoxShadow(
color: Color(0x0F8C3255),
offset: Offset(0, 8),
blurRadius: 24,
),
];
static const List<BoxShadow> card = [
BoxShadow(
color: Color(0x0D8C3255),
offset: Offset(0, 2),
blurRadius: 6,
),
BoxShadow(
color: Color(0x1A8C3255),
offset: Offset(0, 18),
blurRadius: 40,
),
];
static const List<BoxShadow> button = [
BoxShadow(
color: Color(0x59E17A9D),
offset: Offset(0, 4),
blurRadius: 14,
),
];
}

View File

@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import '../halo_tokens.dart';
class HaloBottomSheet {
const HaloBottomSheet._();
static Future<T?> show<T>(
BuildContext context, {
required Widget child,
bool isDismissible = true,
bool enableDrag = true,
bool isScrollControlled = false,
}) {
return showModalBottomSheet<T>(
context: context,
isDismissible: isDismissible,
enableDrag: enableDrag,
isScrollControlled: isScrollControlled,
backgroundColor: HaloTokens.surface,
barrierColor: const Color(0x66000000),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(24),
topRight: Radius.circular(24),
),
),
showDragHandle: true,
builder: (ctx) => SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(
HaloSpacing.s24,
HaloSpacing.s8,
HaloSpacing.s24,
HaloSpacing.s24,
),
child: child,
),
),
);
}
}

View File

@@ -0,0 +1,152 @@
import 'package:flutter/material.dart';
import '../halo_tokens.dart';
enum HaloButtonVariant { primary, secondary, ghost }
enum HaloButtonSize { sm, md, lg }
class HaloButton extends StatelessWidget {
const HaloButton({
super.key,
required this.label,
required this.onPressed,
this.variant = HaloButtonVariant.primary,
this.size = HaloButtonSize.md,
this.icon,
this.fullWidth = false,
});
final String label;
final VoidCallback? onPressed;
final HaloButtonVariant variant;
final HaloButtonSize size;
final Widget? icon;
final bool fullWidth;
@override
Widget build(BuildContext context) {
final disabled = onPressed == null;
final padding = _padding();
final fontSize = _fontSize();
const shape = RoundedRectangleBorder(borderRadius: HaloRadius.pill);
final textStyle = TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: fontSize,
fontWeight: FontWeight.w600,
);
Widget child = _content(textStyle);
Widget button;
switch (variant) {
case HaloButtonVariant.primary:
button = Container(
decoration: disabled
? null
: const BoxDecoration(
borderRadius: HaloRadius.pill,
boxShadow: HaloShadows.button,
),
child: ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: HaloTokens.brand,
foregroundColor: Colors.white,
disabledBackgroundColor: HaloTokens.brandSoft,
disabledForegroundColor: HaloTokens.inkMuted,
elevation: 0,
padding: padding,
shape: shape,
textStyle: textStyle,
),
child: child,
),
);
break;
case HaloButtonVariant.secondary:
button = OutlinedButton(
onPressed: onPressed,
style: OutlinedButton.styleFrom(
foregroundColor: HaloTokens.brandDark,
disabledForegroundColor: HaloTokens.inkMuted,
backgroundColor: HaloTokens.surface,
side: BorderSide(
color: disabled ? HaloTokens.border : HaloTokens.brandSoft,
),
padding: padding,
shape: shape,
textStyle: textStyle,
),
child: child,
);
break;
case HaloButtonVariant.ghost:
button = TextButton(
onPressed: onPressed,
style: TextButton.styleFrom(
foregroundColor: HaloTokens.brandDark,
disabledForegroundColor: HaloTokens.inkMuted,
padding: padding,
shape: shape,
textStyle: textStyle,
),
child: child,
);
break;
}
if (fullWidth) {
return SizedBox(width: double.infinity, child: button);
}
return button;
}
Widget _content(TextStyle textStyle) {
if (icon == null) {
return Text(label, style: textStyle);
}
return Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconTheme(
data: IconThemeData(size: textStyle.fontSize! + 2),
child: icon!,
),
const SizedBox(width: HaloSpacing.s8),
Text(label, style: textStyle),
],
);
}
EdgeInsets _padding() {
switch (size) {
case HaloButtonSize.sm:
return const EdgeInsets.symmetric(
horizontal: HaloSpacing.s16,
vertical: HaloSpacing.s8,
);
case HaloButtonSize.md:
return const EdgeInsets.symmetric(
horizontal: HaloSpacing.s24,
vertical: HaloSpacing.s12,
);
case HaloButtonSize.lg:
return const EdgeInsets.symmetric(
horizontal: HaloSpacing.s32,
vertical: HaloSpacing.s16,
);
}
}
double _fontSize() {
switch (size) {
case HaloButtonSize.sm:
return 13;
case HaloButtonSize.md:
return 15;
case HaloButtonSize.lg:
return 16;
}
}
}

View File

@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import '../halo_tokens.dart';
class HaloChip extends StatelessWidget {
const HaloChip({
super.key,
required this.label,
required this.selected,
required this.onTap,
this.icon,
});
final String label;
final bool selected;
final VoidCallback? onTap;
final Widget? icon;
@override
Widget build(BuildContext context) {
final disabled = onTap == null;
final bgColor = selected
? HaloTokens.brand
: disabled
? HaloTokens.brandSofter
: HaloTokens.surface;
final fgColor = selected
? Colors.white
: disabled
? HaloTokens.inkMuted
: HaloTokens.ink;
final borderColor = selected ? HaloTokens.brand : HaloTokens.border;
return Material(
color: bgColor,
borderRadius: HaloRadius.pill,
child: InkWell(
onTap: onTap,
borderRadius: HaloRadius.pill,
child: AnimatedContainer(
duration: HaloMotion.fast,
curve: HaloMotion.ease,
padding: const EdgeInsets.symmetric(
horizontal: HaloSpacing.s16,
vertical: HaloSpacing.s8,
),
decoration: BoxDecoration(
border: Border.all(color: borderColor),
borderRadius: HaloRadius.pill,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
IconTheme(
data: IconThemeData(size: 16, color: fgColor),
child: icon!,
),
const SizedBox(width: HaloSpacing.s8),
],
Text(
label,
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 14,
fontWeight: FontWeight.w500,
color: fgColor,
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import '../halo_tokens.dart';
/// A soft gradient circle used as an avatar/identity glyph.
///
/// `seed` deterministically picks a hue blend from the warm palette.
class HaloOrb extends StatelessWidget {
const HaloOrb({
super.key,
required this.seed,
this.size = 64,
this.label,
});
final int seed;
final double size;
final String? label;
static const List<List<Color>> _gradients = [
[HaloTokens.brand, HaloTokens.brandDark],
[HaloTokens.accent, HaloTokens.brand],
[HaloTokens.lilac, HaloTokens.brand],
[HaloTokens.mint, HaloTokens.accent],
[HaloTokens.brandSoft, HaloTokens.brand],
[HaloTokens.accentSoft, HaloTokens.accent],
];
@override
Widget build(BuildContext context) {
final colors = _gradients[seed.abs() % _gradients.length];
final initial = (label ?? '').isNotEmpty
? label!.substring(0, 1).toUpperCase()
: null;
return Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: colors,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: HaloShadows.soft,
),
alignment: Alignment.center,
child: initial == null
? null
: Text(
initial,
style: TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: size * 0.42,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
);
}
}

View File

@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import '../halo_tokens.dart';
import 'halo_button.dart';
class HaloPopupAction {
const HaloPopupAction({required this.label, required this.onPressed});
final String label;
final VoidCallback onPressed;
}
class HaloPopup {
const HaloPopup._();
static Future<T?> show<T>(
BuildContext context, {
required String title,
String? body,
Widget? icon,
HaloPopupAction? primary,
HaloPopupAction? secondary,
bool barrierDismissible = true,
}) {
return showDialog<T>(
context: context,
barrierDismissible: barrierDismissible,
barrierColor: const Color(0x66000000),
builder: (ctx) => Dialog(
backgroundColor: HaloTokens.surface,
elevation: 0,
shape: const RoundedRectangleBorder(borderRadius: HaloRadius.xl),
insetPadding: const EdgeInsets.symmetric(horizontal: HaloSpacing.s24),
child: Padding(
padding: const EdgeInsets.all(HaloSpacing.s24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (icon != null) ...[
Center(child: icon),
const SizedBox(height: HaloSpacing.s16),
],
Text(
title,
style: const TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 22,
height: 28 / 22,
fontWeight: FontWeight.w700,
color: HaloTokens.ink,
),
textAlign: TextAlign.center,
),
if (body != null) ...[
const SizedBox(height: HaloSpacing.s12),
Text(
body,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 15,
height: 22 / 15,
color: HaloTokens.inkSoft,
),
textAlign: TextAlign.center,
),
],
const SizedBox(height: HaloSpacing.s24),
if (primary != null)
HaloButton(
label: primary.label,
fullWidth: true,
onPressed: () {
Navigator.of(ctx).pop();
primary.onPressed();
},
),
if (secondary != null) ...[
const SizedBox(height: HaloSpacing.s8),
HaloButton(
label: secondary.label,
variant: HaloButtonVariant.ghost,
fullWidth: true,
onPressed: () {
Navigator.of(ctx).pop();
secondary.onPressed();
},
),
],
],
),
),
),
);
}
}

View File

@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import '../halo_tokens.dart';
class HaloSnackbar {
const HaloSnackbar._();
static void show(
BuildContext context,
String message, {
String? icon,
Duration duration = const Duration(seconds: 4),
}) {
final messenger = ScaffoldMessenger.maybeOf(context);
if (messenger == null) return;
messenger
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
duration: duration,
behavior: SnackBarBehavior.floating,
backgroundColor: HaloTokens.ink,
elevation: 4,
shape: const RoundedRectangleBorder(borderRadius: HaloRadius.pill),
margin: const EdgeInsets.symmetric(
horizontal: HaloSpacing.s16,
vertical: HaloSpacing.s12,
),
padding: const EdgeInsets.symmetric(
horizontal: HaloSpacing.s20,
vertical: HaloSpacing.s12,
),
content: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Text(
icon,
style: const TextStyle(fontSize: 18),
),
const SizedBox(width: HaloSpacing.s8),
],
Flexible(
child: Text(
message,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import '../halo_tokens.dart';
class HaloStepDots extends StatelessWidget {
const HaloStepDots({
super.key,
required this.total,
required this.current,
}) : assert(total > 0),
assert(current >= 1);
final int total;
final int current;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(total, (index) {
final step = index + 1;
final active = step == current;
final past = step < current;
return Padding(
padding: EdgeInsets.only(right: index == total - 1 ? 0 : HaloSpacing.s8),
child: AnimatedContainer(
duration: HaloMotion.fast,
curve: HaloMotion.ease,
width: active ? 24 : 8,
height: 8,
decoration: BoxDecoration(
color: active
? HaloTokens.brand
: past
? HaloTokens.brandSoft
: HaloTokens.border,
borderRadius: HaloRadius.pill,
),
),
);
}),
);
}
}

View File

@@ -0,0 +1,7 @@
export 'halo_bottom_sheet.dart';
export 'halo_button.dart';
export 'halo_chip.dart';
export 'halo_orb.dart';
export 'halo_popup.dart';
export 'halo_snackbar.dart';
export 'halo_step_dots.dart';

View File

@@ -1,6 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/api/api_client_provider.dart';
import '../../../core/auth/auth_notifier.dart'; import '../../../core/auth/auth_notifier.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart';
import '../widgets/verif_choice_sheet.dart';
class DisplayNameScreen extends ConsumerStatefulWidget { class DisplayNameScreen extends ConsumerStatefulWidget {
const DisplayNameScreen({super.key}); const DisplayNameScreen({super.key});
@@ -11,9 +16,33 @@ class DisplayNameScreen extends ConsumerStatefulWidget {
class _DisplayNameScreenState extends ConsumerState<DisplayNameScreen> { class _DisplayNameScreenState extends ConsumerState<DisplayNameScreen> {
final _controller = TextEditingController(); final _controller = TextEditingController();
ProviderSubscription<AsyncValue<AuthData>>? _authSub;
String? _errorMessage;
bool _routedAfterLogin = false;
@override
void initState() {
super.initState();
// Listener registered once in initState (see feedback_riverpod_listen_in_build).
// We need to react to auth state changes once the anonymous login resolves
// to drive the post-name onboarding fork.
_authSub = ref.listenManual<AsyncValue<AuthData>>(authProvider, (prev, next) {
if (!mounted) return;
if (next is AsyncError) {
setState(() => _errorMessage = next.error.toString());
return;
}
final data = next.valueOrNull;
if (data is AuthAnonymousData && !_routedAfterLogin) {
_routedAfterLogin = true;
_proceedAfterLogin();
}
});
}
@override @override
void dispose() { void dispose() {
_authSub?.close();
_controller.dispose(); _controller.dispose();
super.dispose(); super.dispose();
} }
@@ -21,46 +50,99 @@ class _DisplayNameScreenState extends ConsumerState<DisplayNameScreen> {
void _submit() { void _submit() {
final name = _controller.text.trim(); final name = _controller.text.trim();
if (name.isEmpty) return; if (name.isEmpty) return;
setState(() => _errorMessage = null);
ref.read(authProvider.notifier).loginAnonymous(name); ref.read(authProvider.notifier).loginAnonymous(name);
} }
/// After an anonymous login succeeds, decide where to send the user.
///
/// 1. Read `/api/client/onboarding-state`. If `has_consulted_before`, the
/// user is a returning customer — skip the onboarding sequence and
/// jump straight to the duration picker (Stage 3 owns that route).
/// 2. Otherwise show the Verif Choice Sheet and route based on the picked
/// branch.
Future<void> _proceedAfterLogin() async {
bool hasConsultedBefore = false;
try {
final response =
await ref.read(apiClientProvider).get('/api/client/onboarding-state');
final data = response['data'] as Map<String, dynamic>?;
hasConsultedBefore =
(data?['has_consulted_before'] as bool?) ?? false;
} catch (_) {
// Treat as first-time on failure — safer to over-collect onboarding
// info than to silently strand a returning user.
}
if (!mounted) return;
if (hasConsultedBefore) {
// TODO(stage3): Stage 3 will own /payment/duration-pick — for now
// route there as a placeholder so returning users can continue.
context.go('/payment/duration-pick');
return;
}
final choice = await VerifChoiceSheet.show(context);
if (!mounted || choice == null) {
// User dismissed the sheet — let them tap Lanjut again to retry.
_routedAfterLogin = false;
return;
}
if (!mounted) return;
routeForVerifChoice(context, choice);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final authState = ref.watch(authProvider); final authState = ref.watch(authProvider);
final isLoading = authState is AsyncLoading; final isLoading = authState is AsyncLoading;
ref.listen(authProvider, (prev, next) {
if (next is AsyncError) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString())));
}
});
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Siapa namamu?')), appBar: AppBar(title: const Text('Siapa namamu?')),
body: Padding( body: SafeArea(
padding: const EdgeInsets.all(24), child: Padding(
child: Column( padding: const EdgeInsets.all(HaloSpacing.s24),
crossAxisAlignment: CrossAxisAlignment.stretch, child: Column(
children: [ crossAxisAlignment: CrossAxisAlignment.stretch,
const Text('Pilih nama yang ingin kamu gunakan. Nama ini tidak akan terlihat oleh siapapun selain mitra kamu.'), children: [
const SizedBox(height: 24), const Text(
TextField( 'Pilih nama yang ingin kamu gunakan. Nama ini akan terlihat oleh bestie kamu.',
controller: _controller, style: TextStyle(
decoration: const InputDecoration( fontFamily: HaloTokens.fontBody,
labelText: 'Nama panggilan', fontSize: 15,
border: OutlineInputBorder(), height: 22 / 15,
color: HaloTokens.inkSoft,
),
), ),
textInputAction: TextInputAction.done, const SizedBox(height: HaloSpacing.s24),
onSubmitted: (_) => _submit(), TextField(
), controller: _controller,
const SizedBox(height: 24), decoration: const InputDecoration(
ElevatedButton( labelText: 'Nama panggilan',
onPressed: isLoading ? null : _submit, ),
child: isLoading textInputAction: TextInputAction.done,
? const CircularProgressIndicator() onSubmitted: (_) => _submit(),
: const Text('Lanjut'), ),
), if (_errorMessage != null) ...[
], const SizedBox(height: HaloSpacing.s12),
Text(
_errorMessage!,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
color: HaloTokens.danger,
fontSize: 13,
),
),
],
const SizedBox(height: HaloSpacing.s24),
HaloButton(
label: isLoading ? 'memproses...' : 'lanjut',
fullWidth: true,
onPressed: isLoading ? null : _submit,
),
],
),
), ),
), ),
); );

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../core/auth/auth_notifier.dart'; import '../../../core/auth/auth_notifier.dart';
import '../../../core/auth/social_auth_enabled.dart'; import '../../../core/auth/auth_providers_provider.dart';
/// Shown when anonymity is disabled by admin. /// Shown when anonymity is disabled by admin.
/// User must identify themselves (phone OTP / Google / Apple). /// User must identify themselves (phone OTP / Google / Apple).
@@ -28,6 +28,9 @@ class _ForceRegisterScreenState extends ConsumerState<ForceRegisterScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final authState = ref.watch(authProvider); final authState = ref.watch(authProvider);
final isLoading = authState is AsyncLoading; final isLoading = authState is AsyncLoading;
final providersAsync = ref.watch(authProvidersProvider);
final providers =
providersAsync.valueOrNull ?? AuthProvidersConfig.fallback;
ref.listen(authProvider, (prev, next) { ref.listen(authProvider, (prev, next) {
final data = next.valueOrNull; final data = next.valueOrNull;
@@ -51,20 +54,24 @@ class _ForceRegisterScreenState extends ConsumerState<ForceRegisterScreen> {
style: TextStyle(fontSize: 16), style: TextStyle(fontSize: 16),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
if (kSocialAuthEnabled) ...[ if (providers.hasAnySocial) ...[
ElevatedButton.icon( if (providers.google) ...[
icon: const Icon(Icons.g_mobiledata), ElevatedButton.icon(
onPressed: isLoading ? null icon: const Icon(Icons.g_mobiledata),
: () => ref.read(authProvider.notifier).loginGoogle(), onPressed: isLoading ? null
label: const Text('Lanjut dengan Google'), : () => ref.read(authProvider.notifier).loginGoogle(),
), label: const Text('Lanjut dengan Google'),
const SizedBox(height: 12), ),
ElevatedButton.icon( const SizedBox(height: 12),
icon: const Icon(Icons.apple), ],
onPressed: isLoading ? null if (providers.apple) ...[
: () => ref.read(authProvider.notifier).loginApple(), ElevatedButton.icon(
label: const Text('Lanjut dengan Apple'), icon: const Icon(Icons.apple),
), onPressed: isLoading ? null
: () => ref.read(authProvider.notifier).loginApple(),
label: const Text('Lanjut dengan Apple'),
),
],
const Padding( const Padding(
padding: EdgeInsets.symmetric(vertical: 24), padding: EdgeInsets.symmetric(vertical: 24),
child: Row(children: [ child: Row(children: [

View File

@@ -5,12 +5,20 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_client_provider.dart'; import '../../../core/api/api_client_provider.dart';
import '../../../core/auth/auth_notifier.dart'; import '../../../core/auth/auth_notifier.dart';
import '../../../core/constants.dart'; import '../../../core/constants.dart';
import '../../../core/theme/halo_tokens.dart';
import '../widgets/otp_blocked_popup.dart';
const int _kOtpLength = 6; const int _kOtpLength = 6;
const int _kFallbackResendCooldownSeconds = 60; const int _kFallbackResendCooldownSeconds = 60;
const Color _kAccentPink = Color(0xFFBE7C8A); // Codes that mean "the user cannot make progress without waiting" — these
const Color _kBoxBorder = Color(0xFFE0E0E0); // trip the OTP-blocked popup. Mirrors backend `otp.service.js`.
const _kOtpBlockedCodes = {
'OTP_RATE_LIMIT_PHONE',
'OTP_RATE_LIMIT_IP',
'OTP_COOLDOWN',
'OTP_ATTEMPTS_EXCEEDED',
};
class OtpScreen extends ConsumerStatefulWidget { class OtpScreen extends ConsumerStatefulWidget {
final String phone; final String phone;
@@ -29,6 +37,7 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
String? _otpRequestId; String? _otpRequestId;
bool _autoSubmitted = false; bool _autoSubmitted = false;
String? _errorMessage; String? _errorMessage;
bool _blockedPopupShown = false;
int _resendSeconds = _kFallbackResendCooldownSeconds; int _resendSeconds = _kFallbackResendCooldownSeconds;
int _resendCooldown = _kFallbackResendCooldownSeconds; int _resendCooldown = _kFallbackResendCooldownSeconds;
@@ -41,24 +50,26 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
final data = ref.read(authProvider).valueOrNull; final data = ref.read(authProvider).valueOrNull;
if (data is AuthOtpSentData) _otpRequestId = data.otpRequestId; if (data is AuthOtpSentData) _otpRequestId = data.otpRequestId;
// Register the auth listener ONCE — must NOT live in build(), or the
// resend countdown's setState will pile up duplicate listeners every
// second and the error toast will fire many times per state change.
_authSub = ref.listenManual<AsyncValue<AuthData>>(authProvider, (prev, next) { _authSub = ref.listenManual<AsyncValue<AuthData>>(authProvider, (prev, next) {
if (next is AsyncError) { if (next is AsyncError) {
if (!mounted) return; if (!mounted) return;
final err = next.error; final err = next.error;
setState(() => _errorMessage = err.toString()); setState(() => _errorMessage = err.toString());
_clearBoxes(); _clearBoxes();
// If the server says we're rate-limited, extend the resend countdown if (err is AuthErrorInfo) {
// to match — disables "Kirim ulang kode" until the lockout clears. if (_kOtpBlockedCodes.contains(err.code) && !_blockedPopupShown) {
if (err is AuthErrorInfo && _blockedPopupShown = true;
err.retryAfterSeconds != null && OtpBlockedPopup.show(context).then((_) {
(err.code == 'OTP_COOLDOWN' || if (mounted) _blockedPopupShown = false;
err.code == 'OTP_RATE_LIMIT_PHONE' || });
err.code == 'OTP_RATE_LIMIT_IP')) { }
_resendCooldown = err.retryAfterSeconds!; if (err.retryAfterSeconds != null &&
_startResendCountdown(); (err.code == 'OTP_COOLDOWN' ||
err.code == 'OTP_RATE_LIMIT_PHONE' ||
err.code == 'OTP_RATE_LIMIT_IP')) {
_resendCooldown = err.retryAfterSeconds!;
_startResendCountdown();
}
} }
} else if (next is AsyncLoading || next is AsyncData) { } else if (next is AsyncLoading || next is AsyncData) {
if (_errorMessage != null && mounted) { if (_errorMessage != null && mounted) {
@@ -131,7 +142,6 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
} }
void _onDigitChanged(int index, String value) { void _onDigitChanged(int index, String value) {
// Move forward when a digit is entered, back when cleared.
if (value.isNotEmpty && index < _kOtpLength - 1) { if (value.isNotEmpty && index < _kOtpLength - 1) {
_focusNodes[index + 1].requestFocus(); _focusNodes[index + 1].requestFocus();
} }
@@ -142,9 +152,6 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
final code = _readCode(); final code = _readCode();
if (code.length == _kOtpLength && !_autoSubmitted && _otpRequestId != null) { if (code.length == _kOtpLength && !_autoSubmitted && _otpRequestId != null) {
_autoSubmitted = true; _autoSubmitted = true;
// Keep keyboard open during verify — dismissing it caused a Scaffold
// layout shift mid-snackbar-animation, which made the error toast
// visually duplicate.
ref.read(authProvider.notifier).verifyOtp(_otpRequestId!, code); ref.read(authProvider.notifier).verifyOtp(_otpRequestId!, code);
} }
} }
@@ -169,47 +176,76 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Masukkan OTP')), appBar: AppBar(title: const Text('Masukkan OTP')),
body: Padding( body: SafeArea(
padding: const EdgeInsets.all(24), child: Padding(
child: Column( padding: const EdgeInsets.all(HaloSpacing.s24),
crossAxisAlignment: CrossAxisAlignment.stretch, child: Column(
children: [ crossAxisAlignment: CrossAxisAlignment.stretch,
Text('Kode OTP telah dikirim ke ${widget.phone}'), children: [
const SizedBox(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: List.generate(_kOtpLength, _buildBox),
),
const SizedBox(height: 12),
if (_errorMessage != null)
Text( Text(
_errorMessage!, 'Kode OTP telah dikirim ke ${widget.phone}',
textAlign: TextAlign.center, style: const TextStyle(
style: TextStyle(color: Colors.red.shade700, fontSize: 13), fontFamily: HaloTokens.fontBody,
), fontSize: 15,
const SizedBox(height: 12), color: HaloTokens.inkSoft,
if (isLoading)
const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: CircularProgressIndicator(),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: HaloSpacing.s32),
_buildResendRow(), LayoutBuilder(
], builder: (ctx, constraints) {
// 6 boxes laid out across the row. Tighter spacing than the
// legacy 4-box layout (Figma reference) so the form still
// fits a 320pt-wide screen.
const gap = HaloSpacing.s8;
final boxWidth =
(constraints.maxWidth - gap * (_kOtpLength - 1)) /
_kOtpLength;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: List.generate(_kOtpLength, (i) {
return Padding(
padding: EdgeInsets.only(
right: i == _kOtpLength - 1 ? 0 : gap,
),
child: _buildBox(i, boxWidth),
);
}),
);
},
),
const SizedBox(height: HaloSpacing.s12),
if (_errorMessage != null)
Text(
_errorMessage!,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
color: HaloTokens.danger,
fontSize: 13,
),
),
const SizedBox(height: HaloSpacing.s12),
if (isLoading)
const Center(
child: Padding(
padding:
EdgeInsets.symmetric(vertical: HaloSpacing.s8),
child: CircularProgressIndicator(),
),
),
const SizedBox(height: HaloSpacing.s16),
_buildResendRow(),
],
),
), ),
), ),
); );
} }
Widget _buildBox(int index) { Widget _buildBox(int index, double width) {
return SizedBox( return SizedBox(
width: 48, width: width,
height: 56, height: 56,
// Wrap with Focus to intercept hardware backspace BEFORE the TextField:
// when the current box is empty, TextField.onChanged doesn't fire on
// backspace, so we'd be stuck. We catch it here and rewind one box.
child: Focus( child: Focus(
canRequestFocus: false, canRequestFocus: false,
onKeyEvent: (node, event) { onKeyEvent: (node, event) {
@@ -230,18 +266,25 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
textAlign: TextAlign.center, textAlign: TextAlign.center,
maxLength: 1, maxLength: 1,
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w600), style: const TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 22,
fontWeight: FontWeight.w700,
color: HaloTokens.ink,
),
inputFormatters: [FilteringTextInputFormatter.digitsOnly], inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: InputDecoration( decoration: InputDecoration(
counterText: '', counterText: '',
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
filled: true,
fillColor: HaloTokens.surface,
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: _kBoxBorder, width: 1.5), borderSide: const BorderSide(color: HaloTokens.border, width: 1.5),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: _kAccentPink, width: 2), borderSide: const BorderSide(color: HaloTokens.brand, width: 2),
), ),
), ),
onChanged: (v) => _onDigitChanged(index, v), onChanged: (v) => _onDigitChanged(index, v),
@@ -259,7 +302,8 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
child: const Text( child: const Text(
'Kirim ulang kode', 'Kirim ulang kode',
style: TextStyle( style: TextStyle(
color: _kAccentPink, fontFamily: HaloTokens.fontBody,
color: HaloTokens.brandDark,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
decoration: TextDecoration.underline, decoration: TextDecoration.underline,
), ),
@@ -267,7 +311,10 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
) )
: Text( : Text(
'Kirim ulang dalam ${formatCountdown(_resendSeconds)}', 'Kirim ulang dalam ${formatCountdown(_resendSeconds)}',
style: TextStyle(color: Colors.grey.shade600), style: const TextStyle(
fontFamily: HaloTokens.fontBody,
color: HaloTokens.inkMuted,
),
), ),
); );
} }

View File

@@ -3,8 +3,10 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../core/auth/auth_notifier.dart'; import '../../../core/auth/auth_notifier.dart';
import '../../../core/auth/social_auth_enabled.dart'; import '../../../core/auth/auth_providers_provider.dart';
import '../../../core/constants.dart'; import '../../../core/constants.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart';
class RegisterScreen extends ConsumerStatefulWidget { class RegisterScreen extends ConsumerStatefulWidget {
const RegisterScreen({super.key}); const RegisterScreen({super.key});
@@ -26,8 +28,6 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Listener registered once in initState — keeps it independent of the
// build cycle so it doesn't accumulate (see feedback_riverpod_listen_in_build).
_authSub = ref.listenManual<AsyncValue<AuthData>>(authProvider, (prev, next) { _authSub = ref.listenManual<AsyncValue<AuthData>>(authProvider, (prev, next) {
if (!mounted) return; if (!mounted) return;
final data = next.valueOrNull; final data = next.valueOrNull;
@@ -82,68 +82,103 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
final isLoading = authState is AsyncLoading; final isLoading = authState is AsyncLoading;
final isLockedOut = _lockoutSeconds > 0; final isLockedOut = _lockoutSeconds > 0;
final canSubmit = !isLoading && !isLockedOut; final canSubmit = !isLoading && !isLockedOut;
final providersAsync = ref.watch(authProvidersProvider);
final providers =
providersAsync.valueOrNull ?? AuthProvidersConfig.fallback;
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Masuk / Daftar')), appBar: AppBar(title: const Text('Masuk / Daftar')),
body: Padding( body: SafeArea(
padding: const EdgeInsets.all(24), child: Padding(
child: Column( padding: const EdgeInsets.all(HaloSpacing.s24),
crossAxisAlignment: CrossAxisAlignment.stretch, child: Column(
children: [ crossAxisAlignment: CrossAxisAlignment.stretch,
if (kSocialAuthEnabled) ...[ children: [
ElevatedButton.icon( if (providers.hasAnySocial) ...[
icon: const Icon(Icons.g_mobiledata), if (providers.google) ...[
onPressed: isLoading ? null HaloButton(
: () => ref.read(authProvider.notifier).loginGoogle(), label: 'lanjut dengan Google',
label: const Text('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,
),
),
),
Expanded(child: Divider(color: HaloTokens.border)),
],
),
),
],
TextField(
controller: _phoneController,
decoration: const InputDecoration(
labelText: 'Nomor HP',
hintText: '+628xxxxxxxxxx',
),
keyboardType: TextInputType.phone,
), ),
const SizedBox(height: 12), const SizedBox(height: HaloSpacing.s16),
ElevatedButton.icon( HaloButton(
icon: const Icon(Icons.apple), label: isLoading
onPressed: isLoading ? null ? 'memproses...'
: () => ref.read(authProvider.notifier).loginApple(), : isLockedOut
label: const Text('Lanjut dengan Apple'), ? 'coba lagi dalam ${formatCountdown(_lockoutSeconds)}'
), : 'kirim OTP',
const Padding( fullWidth: true,
padding: EdgeInsets.symmetric(vertical: 24), onPressed: canSubmit
child: Row(children: [ ? () {
Expanded(child: Divider()), final phone = _phoneController.text.trim();
Padding(padding: EdgeInsets.symmetric(horizontal: 12), child: Text('atau')), if (phone.isEmpty) return;
Expanded(child: Divider()), ref.read(authProvider.notifier).requestOtp(phone);
]), }
: null,
), ),
if (_errorMessage != null) ...[
const SizedBox(height: HaloSpacing.s12),
Text(
_errorMessage!,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
color: HaloTokens.danger,
fontSize: 13,
),
),
],
], ],
TextField( ),
controller: _phoneController,
decoration: const InputDecoration(
labelText: 'Nomor HP',
hintText: '+628xxxxxxxxxx',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.phone,
),
const SizedBox(height: 12),
ElevatedButton(
onPressed: canSubmit ? () {
final phone = _phoneController.text.trim();
if (phone.isEmpty) return;
ref.read(authProvider.notifier).requestOtp(phone);
} : null,
child: isLoading
? const CircularProgressIndicator()
: Text(isLockedOut
? 'Coba lagi dalam ${formatCountdown(_lockoutSeconds)}'
: 'Kirim OTP'),
),
if (_errorMessage != null) ...[
const SizedBox(height: 12),
Text(
_errorMessage!,
textAlign: TextAlign.center,
style: TextStyle(color: Colors.red.shade700, fontSize: 13),
),
],
],
), ),
), ),
); );

View File

@@ -1,39 +1,84 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../core/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 StatelessWidget { class WelcomeScreen extends ConsumerWidget {
const WelcomeScreen({super.key}); const WelcomeScreen({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final providersAsync = ref.watch(authProvidersProvider);
final providers =
providersAsync.valueOrNull ?? AuthProvidersConfig.fallback;
return Scaffold( return Scaffold(
body: SafeArea( body: SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(HaloSpacing.s24),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const Center(child: HaloOrb(seed: 0, size: 96, label: 'H')),
const SizedBox(height: HaloSpacing.s24),
const Text( const Text(
'Halo Bestie', 'Halo Bestie',
style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold), style: TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 32,
fontWeight: FontWeight.w700,
color: HaloTokens.ink,
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 8), const SizedBox(height: HaloSpacing.s8),
const Text( const Text(
'Tempat curhat kamu', 'Tempat curhat kamu',
style: TextStyle(fontSize: 16, color: Colors.grey), style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 15,
color: HaloTokens.inkSoft,
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 48), const SizedBox(height: HaloSpacing.s48),
ElevatedButton( HaloButton(
label: 'Lanjut sebagai Tamu',
fullWidth: true,
onPressed: () => context.push('/auth/display-name'), onPressed: () => context.push('/auth/display-name'),
child: const Text('Lanjut sebagai Tamu'),
), ),
const SizedBox(height: 12), const SizedBox(height: HaloSpacing.s12),
OutlinedButton( 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'), onPressed: () => context.push('/auth/register'),
child: const Text('Daftar / Masuk'),
), ),
], ],
), ),

View File

@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart';
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.
class OtpBlockedPopup {
const OtpBlockedPopup._();
static Future<void> show(BuildContext context) {
return HaloPopup.show<void>(
context,
title: 'Verifikasi nomor lagi penuh',
body:
'Sistem lagi nahan permintaan OTP buat keamanan. Kamu bisa lanjut '
'tanpa verifikasi, atau hubungi admin biar dibantu manual.',
icon: Container(
width: 64,
height: 64,
decoration: const BoxDecoration(
color: HaloTokens.brandSofter,
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: const Icon(
Icons.lock_clock_outlined,
color: HaloTokens.brandDark,
size: 28,
),
),
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');
},
),
secondary: HaloPopupAction(
label: 'hubungi admin',
onPressed: () {
// ignore: discarded_futures
TanyaAdminSheet.show(context);
},
),
);
}
}

View File

@@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart';
/// Result of the post-name Verif Choice Sheet. Caller routes to the matching
/// onboarding sub-flow.
enum VerifChoice { verified, anonymous }
class VerifChoiceSheet extends StatelessWidget {
const VerifChoiceSheet({super.key});
/// Show the sheet and return the user's choice (`null` if dismissed).
static Future<VerifChoice?> show(BuildContext context) {
return HaloBottomSheet.show<VerifChoice>(
context,
child: const VerifChoiceSheet(),
);
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Mau curhat sebagai siapa?',
style: TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 22,
height: 28 / 22,
fontWeight: FontWeight.w700,
color: HaloTokens.ink,
),
),
const SizedBox(height: HaloSpacing.s8),
const Text(
'Verifikasi nomor HP biar bisa dapet diskon sesi pertama dan riwayat curhatmu kesimpan. Atau langsung curhat anonim, nggak perlu daftar.',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 14,
height: 20 / 14,
color: HaloTokens.inkSoft,
),
),
const SizedBox(height: HaloSpacing.s24),
HaloButton(
label: 'verifikasi nomor HP',
fullWidth: true,
onPressed: () =>
Navigator.of(context).pop(VerifChoice.verified),
),
const SizedBox(height: HaloSpacing.s12),
HaloButton(
label: 'curhat anonim',
variant: HaloButtonVariant.secondary,
fullWidth: true,
onPressed: () =>
Navigator.of(context).pop(VerifChoice.anonymous),
),
],
);
}
}
/// Helper: route to the right onboarding sub-flow for a verif choice.
void routeForVerifChoice(BuildContext context, VerifChoice choice) {
switch (choice) {
case VerifChoice.verified:
context.push('/onboarding/verif/esp');
break;
case VerifChoice.anonymous:
context.push('/onboarding/anon/esp');
break;
}
}

View File

@@ -1,9 +1,23 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../core/chat/active_session_notifier.dart';
import '../../../core/pairing/pairing_notifier.dart'; import '../../../core/pairing/pairing_notifier.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart';
class BestieFoundScreen extends ConsumerWidget { /// Phase 4 Stage 5 — S9 Match-found screen.
///
/// Reskinned from the v4 mock (`v4.jsx::S9MatchV4`). Shows the matched
/// bestie's orb + a small online status dot, the matched-line copy, and a
/// primary CTA `mulai sesi {N} menit →`. The duration is read from the active
/// session payload (which the pairing notifier kicks via
/// `activeSessionProvider.refresh()` on the WS `paired` event).
///
/// `PairingActiveData` is the auto-advance signal — fired by the notifier
/// ~2s after WS `paired` lands. The same advance is also reachable manually
/// via the CTA in case the user is faster than the auto-advance timer.
class BestieFoundScreen extends ConsumerStatefulWidget {
final String sessionId; final String sessionId;
final String mitraName; final String mitraName;
@@ -14,34 +28,127 @@ class BestieFoundScreen extends ConsumerWidget {
}); });
@override @override
Widget build(BuildContext context, WidgetRef ref) { ConsumerState<BestieFoundScreen> createState() => _BestieFoundScreenState();
ref.listen(pairingProvider, (prev, next) { }
class _BestieFoundScreenState extends ConsumerState<BestieFoundScreen> {
@override
void initState() {
super.initState();
ref.listenManual<PairingData>(pairingProvider, (prev, next) {
if (!mounted) return;
if (next is PairingActiveData) { if (next is PairingActiveData) {
context.go('/chat/session/${next.sessionId}', extra: next.mitraName); context.go('/chat/session/${next.sessionId}', extra: next.mitraName);
} }
}); });
}
void _enterChat() {
context.go('/chat/session/${widget.sessionId}', extra: widget.mitraName);
}
@override
Widget build(BuildContext context) {
final activeSession = ref.watch(activeSessionProvider).valueOrNull;
final durationMinutes =
activeSession?.session?['duration_minutes'] as int?;
final ctaLabel = durationMinutes != null
? 'mulai sesi $durationMinutes menit →'
: 'mulai sesi →';
final subtitle = durationMinutes != null
? 'siap nemenin kamu $durationMinutes menit ke depan. cerita aja pelan-pelan ya 🤍'
: 'siap nemenin kamu. cerita aja pelan-pelan ya 🤍';
return Scaffold( return Scaffold(
body: Center( backgroundColor: HaloTokens.bg,
body: SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.all(32), padding: const EdgeInsets.fromLTRB(
HaloSpacing.s24,
HaloSpacing.s24,
HaloSpacing.s24,
HaloSpacing.s24,
),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const Icon(Icons.check_circle, size: 80, color: Colors.green), Expanded(
const SizedBox(height: 24), child: Center(
const Text( child: Column(
'Bestie ditemukan!', mainAxisSize: MainAxisSize.min,
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), children: [
Stack(
children: [
HaloOrb(
size: 140,
seed: widget.mitraName.hashCode,
label: widget.mitraName,
),
Positioned(
right: 4,
bottom: 4,
child: Container(
width: 28,
height: 28,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: HaloTokens.success,
border: Border.all(
color: HaloTokens.bg,
width: 3,
),
),
),
),
],
),
const SizedBox(height: HaloSpacing.s20),
const Text(
'◦ MATCHED ◦',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 12,
fontWeight: FontWeight.w700,
letterSpacing: 1.6,
color: HaloTokens.brand,
),
),
const SizedBox(height: HaloSpacing.s8),
Text(
'halo, aku bestie ${widget.mitraName}',
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 26,
height: 32 / 26,
fontWeight: FontWeight.w700,
color: HaloTokens.brandDark,
),
),
const SizedBox(height: HaloSpacing.s8),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 280),
child: Text(
subtitle,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 14,
height: 22 / 14,
color: HaloTokens.inkSoft,
),
),
),
],
),
),
), ),
const SizedBox(height: 8), HaloButton(
Text( label: ctaLabel,
'Menghubungkan kamu ke $mitraName', fullWidth: true,
textAlign: TextAlign.center, size: HaloButtonSize.lg,
style: const TextStyle(fontSize: 16, color: Colors.grey), onPressed: _enterChat,
), ),
const SizedBox(height: 24),
const CircularProgressIndicator(),
], ],
), ),
), ),

View File

@@ -3,140 +3,323 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../core/api/api_client_provider.dart'; import '../../../core/api/api_client_provider.dart';
import '../../../core/constants.dart'; import '../../../core/constants.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart';
import '../../home/providers/bestie_history_provider.dart';
/// Chat history with per-row "Curhat lagi" CTA. /// Phase 4 Stage 8 — `BestieHistoryList`.
/// ///
/// Tapping "Curhat lagi" routes to the payment screen with the targeted /// Renders past sessions with the v4 visual: orb + name + last-session date
/// mitra id + display name as extras. The payment screen then: /// + topic chips + sessions count + ONLINE pill (per-row, sourced from the
/// 1. POSTs `/api/client/payment-sessions` with `targeted_mitra_id` /// `mitra_is_online` field on the history payload).
/// 2. On confirm, fires `pairingNotifier.startTargetedSearch(...)` instead
/// of the general `startSearch(...)`.
/// ///
/// The CTA is per-row (not per-unique-mitra). /// Tapping a row routes to the targeted "Curhat lagi" payment flow when the
class ChatHistoryScreen extends ConsumerStatefulWidget { /// row references a known mitra; closing-state rows still drop into the
/// session screen so the user can finish the goodbye composer. Otherwise we
/// fall back to the transcript view.
class ChatHistoryScreen extends ConsumerWidget {
const ChatHistoryScreen({super.key}); const ChatHistoryScreen({super.key});
@override @override
ConsumerState<ChatHistoryScreen> createState() => _ChatHistoryScreenState(); Widget build(BuildContext context, WidgetRef ref) {
} final historyAsync = ref.watch(bestieHistoryProvider);
final fullSessionsAsync = ref.watch(_rawHistoryProvider);
class _ChatHistoryScreenState extends ConsumerState<ChatHistoryScreen> {
List<Map<String, dynamic>> _sessions = [];
bool _loading = true;
@override
void initState() {
super.initState();
_loadHistory();
}
Future<void> _loadHistory() async {
try {
final api = ref.read(apiClientProvider);
final response = await api.get('/api/client/chat/history');
final items = (response['data']['items'] as List<dynamic>).cast<Map<String, dynamic>>();
setState(() {
_sessions = items;
_loading = false;
});
} catch (_) {
setState(() => _loading = false);
}
}
void _onCurhatLagiPressed(Map<String, dynamic> session) {
// The mitra id field on the history payload is `mitra_id` per existing
// backend convention. If absent (older rows), don't render the CTA.
final mitraId = session['mitra_id'] as String?;
if (mitraId == null) return;
final mitraName = session['mitra_display_name'] as String? ?? 'Bestie';
context.push('/payment', extra: <String, dynamic>{
'targetedMitraId': mitraId,
'mitraName': mitraName,
'topicSensitivity': TopicSensitivity.regular,
});
}
@override
Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Riwayat Chat')), backgroundColor: HaloTokens.bg,
body: _loading appBar: AppBar(
? const Center(child: CircularProgressIndicator()) backgroundColor: HaloTokens.bg,
: _sessions.isEmpty foregroundColor: HaloTokens.ink,
? const Center(child: Text('Belum ada riwayat chat')) elevation: 0,
: ListView.separated( title: const Text(
itemCount: _sessions.length, 'Riwayat Chat',
separatorBuilder: (_, __) => const Divider(height: 1), style: TextStyle(
itemBuilder: (context, index) { fontFamily: HaloTokens.fontDisplay,
final s = _sessions[index]; fontWeight: FontWeight.w700,
final sessionId = s['id'] as String; ),
final mitraId = s['mitra_id'] as String?; ),
final mitraName = s['mitra_display_name'] as String? ?? 'Bestie'; ),
final status = s['status'] as String?; body: historyAsync.when(
final isClosing = status == 'closing'; loading: () => const Center(child: CircularProgressIndicator()),
final endedAt = s['ended_at'] != null error: (_, __) => const Center(
? DateTime.parse(s['ended_at'] as String).toLocal() child: Text(
: null; 'gagal memuat riwayat. tarik untuk muat ulang.',
final duration = s['duration_minutes'] as int?; style: TextStyle(fontFamily: HaloTokens.fontBody),
final closureMsg = s['customer_closure_message'] as String?; ),
),
return ListTile( data: (items) {
leading: const CircleAvatar(child: Icon(Icons.person)), if (items.isEmpty) {
title: Row( return const Center(
children: [ child: Text(
Flexible(child: Text(mitraName, overflow: TextOverflow.ellipsis)), 'Belum ada riwayat chat',
if (isClosing) ...[ style: TextStyle(
const SizedBox(width: 8), fontFamily: HaloTokens.fontBody,
const _OutstandingClosureBadge(), color: HaloTokens.inkSoft,
],
],
),
subtitle: Text([
if (endedAt != null) '${endedAt.day}/${endedAt.month}/${endedAt.year}',
if (duration != null) '$duration menit',
if (closureMsg != null) '"$closureMsg"',
].join(' - ')),
// Curhat-lagi CTA renders inline; transcript view is
// still reachable by tapping the row body (or, for
// closing sessions, the active chat — same as before).
trailing: !isClosing && mitraId != null
? OutlinedButton(
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
),
onPressed: () => _onCurhatLagiPressed(s),
child: const Text('Curhat lagi'),
)
: const Icon(Icons.chevron_right),
onTap: () => isClosing
? context.push('/chat/session/$sessionId', extra: mitraName)
: context.push('/chat/history/$sessionId'),
);
},
), ),
),
);
}
return RefreshIndicator(
onRefresh: () async {
ref.invalidate(bestieHistoryProvider);
ref.invalidate(_rawHistoryProvider);
await ref.read(bestieHistoryProvider.future);
},
child: ListView.separated(
padding: const EdgeInsets.symmetric(
horizontal: HaloSpacing.s16,
vertical: HaloSpacing.s12,
),
itemCount: items.length,
separatorBuilder: (_, __) => const SizedBox(height: HaloSpacing.s12),
itemBuilder: (context, index) {
final item = items[index];
final raw = fullSessionsAsync.valueOrNull?[index];
final isClosing = raw?['status'] == SessionStatus.closing;
return _BestieRow(
item: item,
isClosing: isClosing,
onTap: () {
if (isClosing && raw != null) {
context.push(
'/chat/session/${item.sessionId}',
extra: item.mitraName,
);
return;
}
context.push('/chat/history/${item.sessionId}');
},
onCurhatLagi: item.mitraId == null || isClosing
? null
: () => context.push('/payment', extra: <String, dynamic>{
'targetedMitraId': item.mitraId,
'mitraName': item.mitraName,
'topicSensitivity': TopicSensitivity.regular,
}),
);
},
),
);
},
),
); );
} }
} }
class _OutstandingClosureBadge extends StatelessWidget { /// Raw history payload — used to read fields the v4 `BestieHistoryItem`
const _OutstandingClosureBadge(); /// model doesn't surface (currently `status`, for the closing-row branch).
final _rawHistoryProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async {
final api = ref.read(apiClientProvider);
final response = await api.get('/api/client/chat/history');
return ((response['data']['items'] as List?) ?? const []).cast<Map<String, dynamic>>();
});
class _BestieRow extends StatelessWidget {
final BestieHistoryItem item;
final bool isClosing;
final VoidCallback onTap;
final VoidCallback? onCurhatLagi;
const _BestieRow({
required this.item,
required this.isClosing,
required this.onTap,
required this.onCurhatLagi,
});
@override
Widget build(BuildContext context) {
return Material(
color: HaloTokens.surface,
borderRadius: HaloRadius.lg,
child: InkWell(
onTap: onTap,
borderRadius: HaloRadius.lg,
child: Padding(
padding: const EdgeInsets.all(HaloSpacing.s16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
HaloOrb(
size: 56,
seed: (item.mitraId ?? item.mitraName).hashCode,
label: item.mitraName,
),
const SizedBox(width: HaloSpacing.s12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
item.mitraName,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 16,
fontWeight: FontWeight.w700,
color: HaloTokens.ink,
),
),
),
if (item.mitraIsOnline) ...[
const SizedBox(width: HaloSpacing.s8),
const _OnlinePill(),
],
if (isClosing) ...[
const SizedBox(width: HaloSpacing.s8),
const _ClosingBadge(),
],
],
),
const SizedBox(height: 2),
Text(
[
if (item.endedAt != null) _formatDate(item.endedAt!),
'${item.sessionsCount} sesi',
].join(' · '),
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 12.5,
color: HaloTokens.inkSoft,
),
),
],
),
),
],
),
if (item.topics.isNotEmpty) ...[
const SizedBox(height: HaloSpacing.s12),
Wrap(
spacing: HaloSpacing.s8,
runSpacing: HaloSpacing.s8,
children: item.topics
.take(3)
.map((t) => _TopicPill(label: t))
.toList(),
),
],
if (onCurhatLagi != null) ...[
const SizedBox(height: HaloSpacing.s12),
Align(
alignment: Alignment.centerRight,
child: HaloButton(
label: 'curhat lagi',
size: HaloButtonSize.sm,
variant: HaloButtonVariant.secondary,
onPressed: onCurhatLagi,
),
),
],
],
),
),
),
);
}
String _formatDate(DateTime d) =>
'${d.day.toString().padLeft(2, '0')}/${d.month.toString().padLeft(2, '0')}/${d.year}';
}
class _OnlinePill extends StatelessWidget {
const _OnlinePill();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.amber.shade100, color: HaloTokens.success.withAlpha(36),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(999),
border: Border.all(color: Colors.amber.shade700, width: 0.5), ),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
_Dot(color: HaloTokens.success),
SizedBox(width: 4),
Text(
'ONLINE',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.6,
color: HaloTokens.success,
),
),
],
),
);
}
}
class _Dot extends StatelessWidget {
final Color color;
const _Dot({required this.color});
@override
Widget build(BuildContext context) {
return Container(
width: 6,
height: 6,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
);
}
}
class _TopicPill extends StatelessWidget {
final String label;
const _TopicPill({required this.label});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: HaloSpacing.s12,
vertical: 4,
),
decoration: BoxDecoration(
color: HaloTokens.brandSofter,
borderRadius: BorderRadius.circular(999),
), ),
child: Text( child: Text(
'Belum ditutup', label,
style: TextStyle( style: const TextStyle(
fontSize: 10, fontFamily: HaloTokens.fontBody,
color: Colors.amber.shade900, fontSize: 11.5,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: HaloTokens.brandDark,
),
),
);
}
}
class _ClosingBadge extends StatelessWidget {
const _ClosingBadge();
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: HaloTokens.accentSoft,
borderRadius: BorderRadius.circular(999),
),
child: const Text(
'Belum ditutup',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 10,
fontWeight: FontWeight.w600,
color: HaloTokens.brandDark,
), ),
), ),
); );

View File

@@ -5,7 +5,15 @@ import 'package:go_router/go_router.dart';
import '../../../core/chat/active_session_notifier.dart'; import '../../../core/chat/active_session_notifier.dart';
import '../../../core/chat/chat_notifier.dart'; import '../../../core/chat/chat_notifier.dart';
import '../../../core/chat/session_closure_notifier.dart'; import '../../../core/chat/session_closure_notifier.dart';
import '../../../core/config/app_config_provider.dart';
import '../../../core/constants.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'; import '../widgets/pricing_bottom_sheet.dart';
// Chat theme colors // Chat theme colors
@@ -31,9 +39,15 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
final _goodbyeController = TextEditingController(); final _goodbyeController = TextEditingController();
final _scrollController = ScrollController(); final _scrollController = ScrollController();
Timer? _typingThrottle; Timer? _typingThrottle;
StreamSubscription<String>? _warningSub;
bool _showBestieBanner = true; bool _showBestieBanner = true;
bool _showUserBanner = true; bool _showUserBanner = true;
bool _expiredDialogShown = false; 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 @override
void initState() { void initState() {
@@ -48,6 +62,19 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
ref.read(sessionClosureProvider.notifier).reset(); ref.read(sessionClosureProvider.notifier).reset();
ref.read(chatProvider.notifier).connectIfNotConnected(widget.sessionId); 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;
HaloSnackbar.show(
context,
'sisa 3 menit lagi ya 🤍',
icon: '',
);
}
});
} }
@override @override
@@ -56,6 +83,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
_goodbyeController.dispose(); _goodbyeController.dispose();
_scrollController.dispose(); _scrollController.dispose();
_typingThrottle?.cancel(); _typingThrottle?.cancel();
_warningSub?.cancel();
super.dispose(); super.dispose();
// Intentionally do NOT disconnect the WS here. The global lifecycle in // Intentionally do NOT disconnect the WS here. The global lifecycle in
// `App` decides when to disconnect (logout / no active session). // `App` decides when to disconnect (logout / no active session).
@@ -95,53 +123,83 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
} }
} }
Future<void> _showSessionExpiredDialog() async { /// Stage 7 entry point — wired to both the AppBar "akhiri sesi" button and
if (_expiredDialogShown) return; /// the menu equivalent. Reads `endSessionTwoStepConfirmProvider`: when the
_expiredDialogShown = true; /// 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<void> _onAkhiriSesiTapped() async {
final twoStep = ref.read(endSessionTwoStepConfirmProvider);
if (!twoStep) {
_showStep2();
return;
}
await ConfirmEndStep1.show(context, onConfirm: _showStep2);
}
void _showStep2() {
if (!mounted) return; if (!mounted) return;
await showDialog<void>( ConfirmEndStep2.show(
context: context, context,
barrierDismissible: false, onWriteMessage: _showClosingSheet,
builder: (dialogContext) => AlertDialog( onSkip: _closeWithoutMessage,
title: const Text('Waktu Curhat Berakhir'),
content: const Text(
'Sesi curhatmu sudah habis waktunya. Kamu bisa menutup obrolan atau memperpanjang waktu untuk lanjut bicara.',
),
actions: [
TextButton(
onPressed: () {
Navigator.of(dialogContext).pop();
_exitChat();
},
child: const Text('Tutup'),
),
ElevatedButton(
onPressed: () {
Navigator.of(dialogContext).pop();
PricingBottomSheet.showForExtension(context, sessionId: widget.sessionId);
},
child: const Text('Perpanjang'),
),
],
),
); );
} }
void _showClosingSheet() {
if (!mounted) return;
ClosingMessageSheet.show(
context,
sessionId: widget.sessionId,
onCompleted: _goToThankYou,
);
}
Future<void> _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');
}
Future<void> _showBestieReturningPopup() async {
if (_rejectPopupShown) return;
_rejectPopupShown = true;
if (!mounted) return;
await BestieOfflinePopup.show(
context,
variant: BestieOfflineVariant.returning,
mitraName: widget.mitraName,
);
_rejectPopupShown = false;
// Reset closure state so the user can retry without a stale-error block.
ref.read(sessionClosureProvider.notifier).reset();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final chatState = ref.watch(chatProvider); final chatState = ref.watch(chatProvider);
final closureState = ref.watch(sessionClosureProvider); final closureState = ref.watch(sessionClosureProvider);
// Listen for closure complete to navigate home // 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) { ref.listen(sessionClosureProvider, (prev, next) {
if (next is ClosureCompleteData) { if (next is ClosureCompleteData) {
// Make doubly sure home picks up the cleared session.
ref.invalidate(activeSessionProvider); ref.invalidate(activeSessionProvider);
context.go('/home'); _goToThankYou();
} else if (next is ClosureRejectedByMitraData) {
_showBestieReturningPopup();
} }
}); });
// Listen for chat state changes to manage closure state and timer-expired modal // 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) { ref.listen(chatProvider, (prev, next) {
if (next is ChatConnectedData) { if (next is ChatConnectedData) {
// Early-end (mitra/customer ended before timer): show goodbye composer. // Early-end (mitra/customer ended before timer): show goodbye composer.
@@ -151,19 +209,11 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
ref.read(sessionClosureProvider.notifier).declineExtension(); ref.read(sessionClosureProvider.notifier).declineExtension();
} }
} }
// Timer-expired: show non-dismissible modal once on false→true flip.
final wasExpired = prev is ChatConnectedData && prev.sessionExpired;
if (next.sessionExpired && !wasExpired) {
_showSessionExpiredDialog();
}
if (!next.sessionPaused && !next.sessionExpired && !next.sessionClosing) { if (!next.sessionPaused && !next.sessionExpired && !next.sessionClosing) {
final closure = ref.read(sessionClosureProvider); final closure = ref.read(sessionClosureProvider);
if (closure is! ClosureInitialData) { if (closure is! ClosureInitialData) {
ref.read(sessionClosureProvider.notifier).reset(); ref.read(sessionClosureProvider.notifier).reset();
} }
// If we're back to a healthy active state, allow the modal to fire
// again on a later expiry (e.g. after extension then re-expiry).
_expiredDialogShown = false;
} }
_scrollToBottom(); _scrollToBottom();
final unread = next.messages final unread = next.messages
@@ -178,6 +228,11 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
} }
}); });
// 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;
return PopScope( return PopScope(
canPop: false, canPop: false,
onPopInvokedWithResult: (didPop, _) { onPopInvokedWithResult: (didPop, _) {
@@ -193,29 +248,89 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
icon: const Icon(Icons.chevron_left, size: 28), icon: const Icon(Icons.chevron_left, size: 28),
onPressed: _exitChat, onPressed: _exitChat,
), ),
title: Text(widget.mitraName), title: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Text(
widget.mitraName,
overflow: TextOverflow.ellipsis,
),
),
if (chatState is ChatConnectedData && chatState.mode == SessionMode.call) ...[
const SizedBox(width: 8),
_buildVoiceCallPill(),
],
],
),
actions: [ actions: [
if (chatState is ChatConnectedData && chatState.remainingSeconds != null) if (chatState is ChatConnectedData && remainingTick != null)
Padding( Padding(
padding: const EdgeInsets.only(right: 16), padding: const EdgeInsets.only(right: 4),
child: Center( child: Center(child: _buildTimerPill(remainingTick)),
child: Text( ),
'${chatState.remainingSeconds}s', if (chatState is ChatConnectedData &&
style: TextStyle( !chatState.sessionClosing)
color: chatState.remainingSeconds! < 30 ? Colors.red : Colors.black, TextButton(
fontWeight: FontWeight.bold, 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), body: _buildBody(chatState, closureState, remainingTick),
), ),
); );
} }
Widget _buildBody(ChatData chatState, SessionClosureData closureState) { 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,
),
),
);
}
Widget _buildBody(ChatData chatState, SessionClosureData closureState, int? remainingTick) {
if (chatState is ChatConnectingData) { if (chatState is ChatConnectingData) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
@@ -223,12 +338,12 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
return Center(child: Text(chatState.message)); return Center(child: Text(chatState.message));
} }
if (chatState is ChatConnectedData) { if (chatState is ChatConnectedData) {
return _buildChatBody(chatState, closureState); return _buildChatBody(chatState, closureState, remainingTick);
} }
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
Widget _buildChatBody(ChatConnectedData state, SessionClosureData closureState) { Widget _buildChatBody(ChatConnectedData state, SessionClosureData closureState, int? remainingTick) {
// Show goodbye composer when closure flow is in goodbye/submitting OR when // Show goodbye composer when closure flow is in goodbye/submitting OR when
// we mounted directly into a `closing` session (e.g. opened from history). // 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 // The chatProvider listener can't catch this case because it only fires on
@@ -303,6 +418,17 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
child: Text('Bestie sedang mengetik...', style: TextStyle(color: Colors.grey, fontSize: 12)), 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) // Input bar — disabled when timer expired (modal handles next step)
if (!state.sessionExpired) _buildInputBar(), if (!state.sessionExpired) _buildInputBar(),
], ],
@@ -424,6 +550,10 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
); );
} }
// 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) { Widget _buildGoodbyeView(SessionClosureData closureState) {
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.all(32), padding: const EdgeInsets.all(32),

View File

@@ -2,23 +2,30 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../core/pairing/pairing_notifier.dart'; import '../../../core/pairing/pairing_notifier.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart';
import '../widgets/bestie_unavailable_dialog.dart'; import '../widgets/bestie_unavailable_dialog.dart';
import '../widgets/targeted_waiting_overlay.dart'; import '../widgets/targeted_waiting_overlay.dart';
/// Searching screen, also responsible for routing all downstream pairing /// Searching screen — Phase 4 Stage 5 reskin of the SSearchPrompt soft-prompt
/// transitions: /// + searching panel. Renders three pairing-driven phases inline:
/// ///
/// - PairingTargetedWaitingData → render the targeted waiting overlay above /// - `PairingSearchingData` → reflective-prompt cards + pulsing-dots panel.
/// the searching shell (the customer sees the 20s countdown + cancel CTA). /// - `PairingFailedData` (cause = noMitraAvailable / allMitrasRejected /
/// - PairingTargetedUnavailableData → show the bestie-unavailable dialog /// targetedMitraTimeout / targetedMitraRejected — i.e. the 5-minute blast
/// timeout) → moon panel + `coba cari lagi` / `kembali ke home` CTAs.
/// - `PairingTargetedWaitingData` → 20s targeted-wait overlay above the body.
///
/// Other transitions still route away as before:
///
/// - `PairingBestieFoundData` → `/chat/found` (S9 Match screen).
/// - `PairingActiveData` → `/chat/session/:id`.
/// - `PairingTargetedUnavailableData` → bestie-unavailable dialog overlay
/// (intermediate; payment stays confirmed; offers fallback-to-blast). /// (intermediate; payment stays confirmed; offers fallback-to-blast).
/// - PairingFailedData → terminal; route to no-bestie screen. /// - `PairingCancelledData``/home`.
/// - PairingBestieFoundData → existing transition to bestie-found screen.
/// - PairingCancelledData → customer cancelled; back home.
/// ///
/// Per project memory ("Riverpod ref.listen in build is unsafe"), we use /// Per project memory ("Riverpod ref.listen in build is unsafe"), one-shot
/// ref.listenManual in initState for one-shot side effects rather than /// transitions are wired through `ref.listenManual` in initState.
/// build-scoped listeners.
class SearchingScreen extends ConsumerStatefulWidget { class SearchingScreen extends ConsumerStatefulWidget {
const SearchingScreen({super.key}); const SearchingScreen({super.key});
@@ -27,19 +34,12 @@ class SearchingScreen extends ConsumerStatefulWidget {
} }
class _SearchingScreenState extends ConsumerState<SearchingScreen> { class _SearchingScreenState extends ConsumerState<SearchingScreen> {
/// Guard against re-firing the bestie-unavailable dialog if the notifier
/// briefly emits multiple intermediate states (e.g. WS event arrives just
/// after a 409 already opened the dialog).
bool _unavailableDialogShown = false; bool _unavailableDialogShown = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
ref.listenManual<PairingData>(pairingProvider, _onPairingState); ref.listenManual<PairingData>(pairingProvider, _onPairingState);
// The pairing state can already be PairingTargetedUnavailableData by
// the time we mount (the payment screen awaits startTargetedSearch
// before navigating; a 409 lands while we're still on the previous
// screen). Inspect once after first frame to handle that case.
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
_onPairingState(null, ref.read(pairingProvider)); _onPairingState(null, ref.read(pairingProvider));
@@ -58,18 +58,10 @@ class _SearchingScreenState extends ConsumerState<SearchingScreen> {
} }
if (next is PairingActiveData) { if (next is PairingActiveData) {
// Direct route into the active chat — happens after the brief "found"
// animation if the user is already on this screen.
context.go('/chat/session/${next.sessionId}', extra: next.mitraName); context.go('/chat/session/${next.sessionId}', extra: next.mitraName);
return; return;
} }
if (next is PairingFailedData) {
// Terminal — payment_session is failed_pairing.
context.go('/chat/no-bestie');
return;
}
if (next is PairingCancelledData) { if (next is PairingCancelledData) {
context.go('/home'); context.go('/home');
return; return;
@@ -78,22 +70,17 @@ class _SearchingScreenState extends ConsumerState<SearchingScreen> {
if (next is PairingTargetedUnavailableData && !_unavailableDialogShown) { if (next is PairingTargetedUnavailableData && !_unavailableDialogShown) {
_unavailableDialogShown = true; _unavailableDialogShown = true;
// ignore: discarded_futures // ignore: discarded_futures
BestieUnavailableDialog.show( BestieOfflinePopup.show(
context, context,
paymentSessionId: next.paymentSessionId, variant: BestieOfflineVariant.returning,
mitraName: next.mitraName, mitraName: next.mitraName,
paymentSessionId: next.paymentSessionId,
topicSensitivity: next.topicSensitivity, topicSensitivity: next.topicSensitivity,
).then((_) { ).then((_) {
if (mounted) _unavailableDialogShown = false; if (mounted) _unavailableDialogShown = false;
}); });
return; return;
} }
if (next is PairingErrorData) {
// Inline error UX is preferred over SnackBars (project memory:
// "Avoid SnackBars for provider errors"). The build below renders
// a banner when the state is PairingErrorData.
}
} }
@override @override
@@ -101,6 +88,7 @@ class _SearchingScreenState extends ConsumerState<SearchingScreen> {
final pairingState = ref.watch(pairingProvider); final pairingState = ref.watch(pairingProvider);
return Scaffold( return Scaffold(
backgroundColor: HaloTokens.bg,
body: SafeArea( body: SafeArea(
child: Stack( child: Stack(
children: [ children: [
@@ -120,52 +108,314 @@ class _SearchingBody extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final isTimeout = state is PairingFailedData;
final isTargetedWaiting = state is PairingTargetedWaitingData; final isTargetedWaiting = state is PairingTargetedWaitingData;
final errorMessage = state is PairingErrorData ? (state as PairingErrorData).message : null; final errorMessage =
state is PairingErrorData ? (state as PairingErrorData).message : null;
return Center( return Padding(
child: Padding( padding: const EdgeInsets.fromLTRB(
padding: const EdgeInsets.all(32), HaloSpacing.s24,
child: Column( HaloSpacing.s24,
mainAxisAlignment: MainAxisAlignment.center, HaloSpacing.s24,
children: [ HaloSpacing.s32,
const CircularProgressIndicator(), ),
const SizedBox(height: 32), child: Column(
Text( crossAxisAlignment: CrossAxisAlignment.stretch,
isTargetedWaiting ? 'Menghubungi bestie...' : 'Mencari Bestie...', children: [
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold), const Expanded(
), child: SingleChildScrollView(
const SizedBox(height: 8), child: Column(
const Text( crossAxisAlignment: CrossAxisAlignment.stretch,
'Tunggu sebentar ya, kami sedang mencarikan Bestie untukmu', children: [
textAlign: TextAlign.center, Text(
style: TextStyle(fontSize: 16, color: Colors.grey), 'sambil nunggu, coba pikirin sebentar 🤍',
), style: TextStyle(
if (errorMessage != null) ...[ fontFamily: HaloTokens.fontDisplay,
const SizedBox(height: 24), fontSize: 24,
Container( height: 30 / 24,
padding: const EdgeInsets.all(12), fontWeight: FontWeight.w700,
decoration: BoxDecoration( color: HaloTokens.brandDark,
color: Colors.red.shade50, letterSpacing: -0.4,
borderRadius: BorderRadius.circular(8), ),
border: Border.all(color: Colors.red.shade200), ),
), SizedBox(height: HaloSpacing.s8),
child: Text( Text(
errorMessage, 'gausah dipikirin formatnya. ngalir aja gimana enaknya buat kamu.',
style: TextStyle(color: Colors.red.shade900), style: TextStyle(
textAlign: TextAlign.center, fontFamily: HaloTokens.fontBody,
), fontSize: 14,
), height: 22 / 14,
], color: HaloTokens.inkSoft,
const SizedBox(height: 48), ),
// The targeted-waiting overlay owns its own cancel button — only ),
// show the general cancel CTA when we're in a non-overlay state. SizedBox(height: HaloSpacing.s20),
if (!isTargetedWaiting) _PromptCard('apa yang lagi paling kamu rasain hari ini?'),
OutlinedButton( SizedBox(height: HaloSpacing.s8),
onPressed: () => ref.read(pairingProvider.notifier).cancelSearch(), _PromptCard('kapan terakhir kamu ngerasa lega?'),
child: const Text('Batalkan'), SizedBox(height: HaloSpacing.s8),
_PromptCard('ada satu hal yang pengen banget kamu cerita...'),
],
), ),
),
),
if (errorMessage != null) ...[
const SizedBox(height: HaloSpacing.s16),
_ErrorBanner(message: errorMessage),
], ],
const SizedBox(height: HaloSpacing.s16),
isTimeout
? const _TimeoutPanel()
: _SearchingPanel(targetedWaiting: isTargetedWaiting),
if (isTimeout) ...[
const SizedBox(height: HaloSpacing.s12),
HaloButton(
label: 'coba cari lagi',
fullWidth: true,
size: HaloButtonSize.lg,
onPressed: () {
ref.read(pairingProvider.notifier).reset();
context.go('/payment/entry');
},
),
const SizedBox(height: HaloSpacing.s8),
HaloButton(
label: 'kembali ke home',
variant: HaloButtonVariant.ghost,
fullWidth: true,
onPressed: () {
ref.read(pairingProvider.notifier).reset();
context.go('/home');
},
),
] else if (!isTargetedWaiting) ...[
const SizedBox(height: HaloSpacing.s12),
HaloButton(
label: 'batalkan',
variant: HaloButtonVariant.ghost,
fullWidth: true,
onPressed: () =>
ref.read(pairingProvider.notifier).cancelSearch(),
),
],
],
),
);
}
}
class _PromptCard extends StatelessWidget {
final String text;
const _PromptCard(this.text);
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(HaloSpacing.s16),
decoration: BoxDecoration(
color: HaloTokens.surface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: HaloTokens.border),
),
child: Text(
text,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 13,
height: 20 / 13,
color: HaloTokens.ink,
),
),
);
}
}
class _SearchingPanel extends StatelessWidget {
final bool targetedWaiting;
const _SearchingPanel({required this.targetedWaiting});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(HaloSpacing.s20),
decoration: BoxDecoration(
color: HaloTokens.brandSofter,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: HaloTokens.brandSoft),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const _PulsingDots(),
const SizedBox(width: HaloSpacing.s16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
targetedWaiting ? 'menghubungi bestie...' : 'lagi nyari bestie...',
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 13,
fontWeight: FontWeight.w600,
color: HaloTokens.brandDark,
),
),
const SizedBox(height: 2),
const Text(
'biasanya 30 detik · sambil baca prompt aja',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 11.5,
color: HaloTokens.inkSoft,
),
),
],
),
),
],
),
);
}
}
class _TimeoutPanel extends StatelessWidget {
const _TimeoutPanel();
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(HaloSpacing.s20),
decoration: BoxDecoration(
color: HaloTokens.brandSofter,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: HaloTokens.brandSoft),
),
child: const Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text('🌙', style: TextStyle(fontSize: 26)),
SizedBox(width: HaloSpacing.s16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'masih nyari nih...',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 13,
fontWeight: FontWeight.w600,
color: HaloTokens.brandDark,
),
),
SizedBox(height: 2),
Text(
'bestie lagi rame. coba cari lagi atau kembali nanti',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 11.5,
height: 16 / 11.5,
color: HaloTokens.inkSoft,
),
),
],
),
),
],
),
);
}
}
class _PulsingDots extends StatefulWidget {
const _PulsingDots();
@override
State<_PulsingDots> createState() => _PulsingDotsState();
}
class _PulsingDotsState extends State<_PulsingDots>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1400),
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
double _scaleAt(double t, double phase) {
// Mirrors the keyframe in v3.jsx: (0,80,100) → 0.6 ; 40 → 1.
final shifted = (t - phase) % 1.0;
final eased = shifted < 0 ? shifted + 1.0 : shifted;
if (eased < 0.4) {
return 0.6 + (1.0 - 0.6) * (eased / 0.4);
} else if (eased < 0.8) {
return 1.0 - (1.0 - 0.6) * ((eased - 0.4) / 0.4);
}
return 0.6;
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, _) {
return Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(3, (i) {
final scale = _scaleAt(_controller.value, i * 0.16);
return Padding(
padding: EdgeInsets.only(right: i == 2 ? 0 : 4),
child: Transform.scale(
scale: scale,
child: Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: HaloTokens.brand,
shape: BoxShape.circle,
),
),
),
);
}),
);
},
);
}
}
class _ErrorBanner extends StatelessWidget {
final String message;
const _ErrorBanner({required this.message});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(HaloSpacing.s12),
decoration: BoxDecoration(
color: const Color(0x14D86B6B),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: HaloTokens.danger.withValues(alpha: 0.4)),
),
child: Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 13,
color: HaloTokens.danger,
), ),
), ),
); );

View File

@@ -0,0 +1,191 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/pairing/pairing_notifier.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart';
import '../widgets/bestie_unavailable_dialog.dart';
/// Phase 4 Stage 5 — `SWaitingBestie` overlay.
///
/// Entry route: `/chat/waiting-targeted/:mitraId` — pushed from the chat
/// history "Curhat lagi" CTA after the targeted payment session is confirmed.
///
/// Three sub-states mapped from `pairingProvider`:
///
/// - `waiting` (PairingTargetedWaitingData) — orb + 20s countdown + cancel.
/// The countdown is purely cosmetic; the server owns the auto-reject timer.
/// - `accepted` (PairingBestieFoundData / PairingActiveData) — routes into
/// the chat screen immediately.
/// - `declined` (PairingTargetedUnavailableData) — shows the
/// [BestieOfflinePopup] returning variant; the popup may offer a
/// fallback-to-blast CTA when other besties are reachable.
class TargetedWaitingScreen extends ConsumerStatefulWidget {
final String mitraId;
const TargetedWaitingScreen({super.key, required this.mitraId});
@override
ConsumerState<TargetedWaitingScreen> createState() =>
_TargetedWaitingScreenState();
}
class _TargetedWaitingScreenState extends ConsumerState<TargetedWaitingScreen> {
bool _popupShown = false;
@override
void initState() {
super.initState();
ref.listenManual<PairingData>(pairingProvider, _onPairingState);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_onPairingState(null, ref.read(pairingProvider));
});
}
void _onPairingState(PairingData? prev, PairingData next) {
if (!mounted) return;
if (next is PairingBestieFoundData) {
context.go('/chat/session/${next.sessionId}', extra: next.mitraName);
return;
}
if (next is PairingActiveData) {
context.go('/chat/session/${next.sessionId}', extra: next.mitraName);
return;
}
if (next is PairingCancelledData) {
context.go('/home');
return;
}
if (next is PairingTargetedUnavailableData && !_popupShown) {
_popupShown = true;
// ignore: discarded_futures
BestieOfflinePopup.show(
context,
variant: BestieOfflineVariant.returning,
mitraName: next.mitraName,
paymentSessionId: next.paymentSessionId,
topicSensitivity: next.topicSensitivity,
).then((_) {
if (mounted) _popupShown = false;
});
}
}
@override
Widget build(BuildContext context) {
final state = ref.watch(pairingProvider);
final waiting = state is PairingTargetedWaitingData ? state : null;
final mitraName = waiting?.mitraName ?? 'bestie';
final secondsRemaining = waiting?.secondsRemaining ?? 0;
return PopScope(
// Targeted-wait is reachable directly from chat history; per the
// deep-link pop-fallback rule (project memory), we drop the user
// back to home if they swipe back rather than into a stale stack.
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
ref.read(pairingProvider.notifier).cancelSearch();
},
child: Scaffold(
backgroundColor: HaloTokens.bg,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
HaloSpacing.s24,
HaloSpacing.s24,
HaloSpacing.s24,
HaloSpacing.s24,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
HaloOrb(
size: 120,
seed: mitraName.hashCode,
label: mitraName,
),
const SizedBox(height: HaloSpacing.s20),
const Text(
'◦ MENUNGGU JAWABAN ◦',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 12,
fontWeight: FontWeight.w700,
letterSpacing: 1.6,
color: HaloTokens.brand,
),
),
const SizedBox(height: HaloSpacing.s8),
Text(
'lagi nungguin $mitraName',
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 24,
height: 30 / 24,
fontWeight: FontWeight.w700,
color: HaloTokens.brandDark,
),
),
const SizedBox(height: HaloSpacing.s12),
Container(
padding: const EdgeInsets.symmetric(
horizontal: HaloSpacing.s16,
vertical: HaloSpacing.s8,
),
decoration: BoxDecoration(
color: HaloTokens.brandSofter,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: HaloTokens.brandSoft),
),
child: Text(
'${secondsRemaining}d',
style: const TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 22,
fontWeight: FontWeight.w700,
color: HaloTokens.brandDark,
),
),
),
const SizedBox(height: HaloSpacing.s12),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 280),
child: const Text(
'kalau bestie nggak respon dalam 20 detik, kami bantu cariin yang lain.',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 13,
height: 20 / 13,
color: HaloTokens.inkSoft,
),
),
),
],
),
),
),
HaloButton(
label: 'batalkan',
variant: HaloButtonVariant.ghost,
fullWidth: true,
onPressed: () =>
ref.read(pairingProvider.notifier).cancelSearch(),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/halo_button.dart';
/// S11 — landing screen after a session has been closed. Replaces the
/// previous "navigate straight to home" behavior so the user gets a soft
/// acknowledgement before re-entering the home shell.
class ThankYouScreen extends StatelessWidget {
const ThankYouScreen({super.key});
@override
Widget build(BuildContext context) {
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) context.go('/home');
},
child: Scaffold(
backgroundColor: HaloTokens.bg,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: HaloSpacing.s32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'🤍',
style: TextStyle(fontSize: 72),
textAlign: TextAlign.center,
),
const SizedBox(height: HaloSpacing.s24),
const Text(
'makasih udah curhat',
style: TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 26,
fontWeight: FontWeight.w700,
color: HaloTokens.ink,
),
textAlign: TextAlign.center,
),
const SizedBox(height: HaloSpacing.s12),
const Text(
'semoga kamu lebih plong sekarang. kalau butuh, bestie selalu siap nemenin lagi.',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 15,
height: 22 / 15,
color: HaloTokens.inkSoft,
),
textAlign: TextAlign.center,
),
const SizedBox(height: HaloSpacing.s40),
HaloButton(
label: 'balik ke home',
fullWidth: true,
onPressed: () => context.go('/home'),
),
],
),
),
),
),
);
}
}

View File

@@ -4,50 +4,56 @@ import 'package:go_router/go_router.dart';
import '../../../core/availability/mitra_availability_notifier.dart'; import '../../../core/availability/mitra_availability_notifier.dart';
import '../../../core/constants.dart'; import '../../../core/constants.dart';
import '../../../core/pairing/pairing_notifier.dart'; import '../../../core/pairing/pairing_notifier.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart';
import '../../support/widgets/tanya_admin_sheet.dart';
/// Shown when a "Curhat lagi" attempt against a specific bestie can't proceed /// Phase 4 Stage 8 — `BestieOfflinePopup`.
/// — either a 409 `targeted_mitra_offline` response on the targeted POST, or
/// one of the intermediate WS events (`returning_chat_timeout`,
/// `returning_chat_rejected`).
/// ///
/// CTAs: /// Two variants:
/// - "Chat dengan bestie lain" — only rendered when /// - [BestieOfflineVariant.returning] — the customer tried to chat with a
/// [mitraAvailabilityProvider] reports `available == true` at the time of /// specific mitra (history "Curhat lagi"); the targeted attempt failed
/// build. Tapping calls [Pairing.fallbackToBlast] (reuses the same payment /// (409 `targeted_mitra_offline`, or WS `returning_chat_timeout` /
/// session — no double-charge) and closes the dialog. The caller is expected /// `returning_chat_rejected`). Payment session is still `confirmed`, so we
/// to be the searching screen, which will transition into PairingSearchingData /// surface a `Chat dengan bestie lain` primary CTA when other besties are
/// and stay put. /// reachable (calls [Pairing.fallbackToBlast]).
/// - "Kembali" — pops dialog and routes home. Backend has already audit-logged /// - [BestieOfflineVariant.new_] — the customer triggered a general blast
/// the targeted failure; payment session stays `confirmed` until the sweeper /// that bottomed out (no online besties). No fallback button; just a
/// expires it. /// ghost `tanya admin` and a `kembali ke home` exit.
class BestieUnavailableDialog extends ConsumerWidget { ///
final String paymentSessionId; /// Both variants expose `tanya admin` via a ghost CTA that opens the
final String mitraName; /// [TanyaAdminSheet].
final TopicSensitivity topicSensitivity; enum BestieOfflineVariant { returning, new_ }
const BestieUnavailableDialog({ class BestieOfflinePopup extends ConsumerWidget {
final BestieOfflineVariant variant;
final String mitraName;
final String? paymentSessionId;
final TopicSensitivity? topicSensitivity;
const BestieOfflinePopup({
super.key, super.key,
required this.paymentSessionId, required this.variant,
required this.mitraName, required this.mitraName,
required this.topicSensitivity, this.paymentSessionId,
this.topicSensitivity,
}); });
/// Convenience: show this dialog and return when it closes. Per project
/// memory ("Riverpod ref.listen in build is unsafe"), callers should
/// invoke this from `ref.listenManual` callbacks in `initState`, not from
/// `build`.
static Future<void> show( static Future<void> show(
BuildContext context, { BuildContext context, {
required String paymentSessionId, required BestieOfflineVariant variant,
required String mitraName, required String mitraName,
required TopicSensitivity topicSensitivity, String? paymentSessionId,
TopicSensitivity? topicSensitivity,
}) { }) {
return showDialog<void>( return showDialog<void>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (_) => BestieUnavailableDialog( barrierColor: const Color(0x66000000),
paymentSessionId: paymentSessionId, builder: (_) => BestieOfflinePopup(
variant: variant,
mitraName: mitraName, mitraName: mitraName,
paymentSessionId: paymentSessionId,
topicSensitivity: topicSensitivity, topicSensitivity: topicSensitivity,
), ),
); );
@@ -55,44 +61,122 @@ class BestieUnavailableDialog extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
// Snapshot at dialog-open time — we don't keep listening, we just check
// whether other bestie are around right now.
final availabilityAsync = ref.watch(mitraAvailabilityProvider); final availabilityAsync = ref.watch(mitraAvailabilityProvider);
final hasOtherAvailable = availabilityAsync.valueOrNull ?? false; final hasOtherAvailable = availabilityAsync.valueOrNull ?? false;
return AlertDialog( final isReturning = variant == BestieOfflineVariant.returning;
title: const Text('Bestie sedang tidak online'), final title = isReturning ? '$mitraName lagi nggak online' : 'semua bestie lagi istirahat';
content: Text( final body = isReturning
'$mitraName sedang tidak bisa menerima chat saat ini. ' ? 'bestie kamu belum bisa nerima chat sekarang. coba bestie lain atau balik ke beranda dulu ya.'
'Kamu bisa coba chat dengan bestie lain atau kembali ke beranda.', : 'lagi nggak ada bestie yang siap dengerin. coba lagi bentar, atau hubungin admin biar dibantu.';
),
actions: [ final canFallbackToBlast = isReturning &&
TextButton( hasOtherAvailable &&
onPressed: () { paymentSessionId != null &&
// Reset pairing state and route home. Payment session stays topicSensitivity != null;
// confirmed until sweeper expires it — no extra API call needed.
ref.read(pairingProvider.notifier).reset(); return Dialog(
Navigator.of(context).pop(); backgroundColor: HaloTokens.surface,
context.go('/home'); elevation: 0,
}, shape: const RoundedRectangleBorder(borderRadius: HaloRadius.xl),
child: const Text('Kembali'), insetPadding: const EdgeInsets.symmetric(horizontal: HaloSpacing.s24),
child: Padding(
padding: const EdgeInsets.all(HaloSpacing.s24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: 64,
height: 64,
decoration: const BoxDecoration(
color: HaloTokens.brandSofter,
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: const Icon(
Icons.cloud_off_outlined,
color: HaloTokens.brandDark,
size: 28,
),
),
),
const SizedBox(height: HaloSpacing.s16),
Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 22,
height: 28 / 22,
fontWeight: FontWeight.w700,
color: HaloTokens.ink,
),
),
const SizedBox(height: HaloSpacing.s12),
Text(
body,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 15,
height: 22 / 15,
color: HaloTokens.inkSoft,
),
),
const SizedBox(height: HaloSpacing.s24),
if (canFallbackToBlast)
HaloButton(
label: 'chat dengan bestie lain',
fullWidth: true,
onPressed: () {
Navigator.of(context).pop();
// ignore: discarded_futures
ref.read(pairingProvider.notifier).fallbackToBlast(
paymentSessionId: paymentSessionId!,
topicSensitivity: topicSensitivity!,
);
},
)
else
HaloButton(
label: 'kembali ke home',
fullWidth: true,
onPressed: () {
ref.read(pairingProvider.notifier).reset();
Navigator.of(context).pop();
context.go('/home');
},
),
const SizedBox(height: HaloSpacing.s8),
HaloButton(
label: 'tanya admin',
variant: HaloButtonVariant.ghost,
fullWidth: true,
onPressed: () {
// Keep the popup open underneath; the sheet sits on top and
// closes back to it.
// ignore: discarded_futures
TanyaAdminSheet.show(context);
},
),
if (canFallbackToBlast) ...[
const SizedBox(height: HaloSpacing.s4),
HaloButton(
label: 'kembali ke home',
variant: HaloButtonVariant.ghost,
fullWidth: true,
onPressed: () {
ref.read(pairingProvider.notifier).reset();
Navigator.of(context).pop();
context.go('/home');
},
),
],
],
), ),
if (hasOtherAvailable) ),
ElevatedButton(
onPressed: () {
// Close the dialog first, then kick off the fallback. The
// searching screen will pick up the new PairingSearchingData
// state and render normally (no targeted overlay).
Navigator.of(context).pop();
// ignore: discarded_futures
ref.read(pairingProvider.notifier).fallbackToBlast(
paymentSessionId: paymentSessionId,
topicSensitivity: topicSensitivity,
);
},
child: const Text('Chat dengan bestie lain'),
),
],
); );
} }
} }

View File

@@ -0,0 +1,59 @@
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.
class ChatExpiredBanner extends StatelessWidget {
final VoidCallback onExtend;
const ChatExpiredBanner({super.key, 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,
),
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,
),
),
),
HaloButton(
label: 'perpanjang',
size: HaloButtonSize.sm,
variant: HaloButtonVariant.secondary,
onPressed: onExtend,
),
],
),
);
}
}

View File

@@ -0,0 +1,138 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/chat/session_closure_notifier.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/halo_bottom_sheet.dart';
import '../../../core/theme/widgets/halo_button.dart';
/// Stage 7 — replaces the legacy goodbye-composer screen with a bottom sheet.
/// The sheet is launched after the two-step confirm; it submits the goodbye
/// message AND closes the session. Both CTAs end the session — the difference
/// is whether a closing message is sent first.
class ClosingMessageSheet {
const ClosingMessageSheet._();
static Future<void> show(
BuildContext context, {
required String sessionId,
required VoidCallback onCompleted,
}) {
return HaloBottomSheet.show<void>(
context,
isScrollControlled: true,
child: _ClosingMessageBody(
sessionId: sessionId,
onCompleted: onCompleted,
),
);
}
}
class _ClosingMessageBody extends ConsumerStatefulWidget {
final String sessionId;
final VoidCallback onCompleted;
const _ClosingMessageBody({
required this.sessionId,
required this.onCompleted,
});
@override
ConsumerState<_ClosingMessageBody> createState() =>
_ClosingMessageBodyState();
}
class _ClosingMessageBodyState extends ConsumerState<_ClosingMessageBody> {
final _controller = TextEditingController();
bool _busy = false;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _sendAndEnd() async {
final text = _controller.text.trim();
if (text.isEmpty || _busy) return;
setState(() => _busy = true);
final notifier = ref.read(sessionClosureProvider.notifier);
await notifier.submitGoodbye(widget.sessionId, text);
await notifier.closeSession(widget.sessionId);
if (!mounted) return;
Navigator.of(context).pop();
widget.onCompleted();
}
Future<void> _skipAndEnd() async {
if (_busy) return;
setState(() => _busy = true);
await ref.read(sessionClosureProvider.notifier).closeSession(widget.sessionId);
if (!mounted) return;
Navigator.of(context).pop();
widget.onCompleted();
}
@override
Widget build(BuildContext context) {
final viewInsets = MediaQuery.of(context).viewInsets.bottom;
return Padding(
padding: EdgeInsets.only(bottom: viewInsets),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'pesan penutup',
style: TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 20,
fontWeight: FontWeight.w700,
color: HaloTokens.ink,
),
textAlign: TextAlign.center,
),
const SizedBox(height: HaloSpacing.s8),
const Text(
'tulis sesuatu buat bestie sebelum sesi ditutup',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 14,
color: HaloTokens.inkSoft,
),
textAlign: TextAlign.center,
),
const SizedBox(height: HaloSpacing.s16),
TextField(
controller: _controller,
maxLines: 4,
minLines: 3,
enabled: !_busy,
decoration: const InputDecoration(
hintText: 'makasih ya bestie...',
filled: true,
fillColor: HaloTokens.brandSofter,
border: OutlineInputBorder(
borderRadius: HaloRadius.lg,
borderSide: BorderSide.none,
),
),
),
const SizedBox(height: HaloSpacing.s16),
HaloButton(
label: 'kirim & akhiri sesi',
fullWidth: true,
onPressed: _busy ? null : _sendAndEnd,
),
const SizedBox(height: HaloSpacing.s8),
HaloButton(
label: 'lewat — langsung akhiri',
variant: HaloButtonVariant.ghost,
fullWidth: true,
onPressed: _busy ? null : _skipAndEnd,
),
],
),
);
}
}

View File

@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
import '../../../core/theme/widgets/halo_popup.dart';
/// Stage 7 — first of two confirm popups before ending the session. Surface
/// the soft "balik" exit prominently because the most common path here is the
/// user mis-tapping "akhiri sesi" while still wanting to continue.
class ConfirmEndStep1 {
const ConfirmEndStep1._();
static Future<void> show(
BuildContext context, {
required VoidCallback onConfirm,
}) {
return HaloPopup.show<void>(
context,
title: 'yakin mau akhiri sesi?',
body: 'sesi akan ditutup dan kamu balik ke home',
icon: const Text('🤔', style: TextStyle(fontSize: 40)),
primary: HaloPopupAction(label: 'lanjut akhiri', onPressed: onConfirm),
secondary: HaloPopupAction(
label: 'gak jadi, balik',
onPressed: () {},
),
);
}
}

View File

@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import '../../../core/theme/widgets/halo_popup.dart';
/// Stage 7 — second confirm popup. Customer has already chosen to end; this
/// step nudges (not forces) them to leave a closing message, with `lewati saja`
/// as the bypass into the close-session API directly.
class ConfirmEndStep2 {
const ConfirmEndStep2._();
static Future<void> show(
BuildContext context, {
required VoidCallback onWriteMessage,
required VoidCallback onSkip,
}) {
return HaloPopup.show<void>(
context,
title: 'mau tinggalin pesan penutup?',
body: 'kamu bisa tulis pesan terakhir buat bestie sebelum sesi ditutup',
icon: const Text('💌', style: TextStyle(fontSize: 40)),
primary: HaloPopupAction(
label: 'tulis pesan penutup',
onPressed: onWriteMessage,
),
secondary: HaloPopupAction(label: 'lewati saja', onPressed: onSkip),
);
}
}

View File

@@ -3,15 +3,21 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/chat/chat_opening_provider.dart'; import '../../../core/chat/chat_opening_provider.dart';
import '../../../core/chat/session_closure_notifier.dart'; import '../../../core/chat/session_closure_notifier.dart';
import '../../../core/constants.dart'; import '../../../core/constants.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/halo_button.dart';
import '../../payment/state/payment_draft_provider.dart';
/// Extension-only pricing sheet. /// Extension-only pricing sheet — Phase 4 Stage 6 layout.
/// ///
/// Used solely for in-session extension requests; the initial pairing flow /// Used solely for in-session extension requests; the initial pairing flow
/// goes through `/payment` instead. Free-trial is never offered for extensions. /// goes through `/payment` instead. Free-trial is never offered for extensions.
/// ///
/// Submit triggers [SessionClosure.requestExtension], which internally /// Mirrors the Stage 3 duration-pick screen: chat/call toggle on top,
/// runs the payment-session create+confirm and then the extend POST. /// 5-option tier list below, single CTA at the bottom. The `perpanjang`
class PricingBottomSheet extends ConsumerWidget { /// behavior is unchanged from Phase 3.7 — submit calls
/// [SessionClosure.requestExtension], which runs the payment-session
/// create+confirm and then the extend POST.
class PricingBottomSheet extends ConsumerStatefulWidget {
/// Required — the in-progress chat session id this extension targets. /// Required — the in-progress chat session id this extension targets.
final String extensionSessionId; final String extensionSessionId;
@@ -22,62 +28,372 @@ class PricingBottomSheet extends ConsumerWidget {
return showModalBottomSheet( return showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
backgroundColor: HaloTokens.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(24),
topRight: Radius.circular(24),
),
),
builder: (_) => PricingBottomSheet(extensionSessionId: sessionId), builder: (_) => PricingBottomSheet(extensionSessionId: sessionId),
); );
} }
@override @override
Widget build(BuildContext context, WidgetRef ref) { ConsumerState<PricingBottomSheet> createState() => _PricingBottomSheetState();
}
class _PricingBottomSheetState extends ConsumerState<PricingBottomSheet> {
PaymentMode _mode = PaymentMode.chat;
String? _selectedDurationId;
List<PriceTier> _tiersForMode(PricingData pricing) {
// Phase 4 — chat/call tier groups. Falls back to legacy `tiers` when the
// backend hasn't been cut over yet (so the sheet still works locally
// against an old backend).
if (_mode == PaymentMode.call) {
return pricing.callTiers.isNotEmpty ? pricing.callTiers : pricing.tiers;
}
return pricing.chatTiers.isNotEmpty ? pricing.chatTiers : pricing.tiers;
}
void _onTierTap(PriceTier tier) {
setState(() {
_selectedDurationId = tier.id ?? tier.durationMinutes.toString();
});
}
void _onConfirm(PriceTier tier) {
Navigator.of(context).pop();
ref.read(sessionClosureProvider.notifier).requestExtension(
widget.extensionSessionId,
durationMinutes: tier.durationMinutes,
price: tier.price,
);
}
@override
Widget build(BuildContext context) {
final pricingAsync = ref.watch(chatPricingProvider); final pricingAsync = ref.watch(chatPricingProvider);
return pricingAsync.when( return DraggableScrollableSheet(
loading: () => const SizedBox( initialChildSize: 0.65,
height: 200, minChildSize: 0.5,
child: Center(child: CircularProgressIndicator()), maxChildSize: 0.92,
), expand: false,
error: (error, _) => const SizedBox( builder: (_, scrollController) {
height: 200, return SafeArea(
child: Center(child: Text('Gagal memuat harga. Coba lagi.')), top: false,
), child: pricingAsync.when(
data: (pricing) => DraggableScrollableSheet( loading: () => const SizedBox(
initialChildSize: 0.6, height: 240,
minChildSize: 0.4, child: Center(child: CircularProgressIndicator()),
maxChildSize: 0.8,
expand: false,
builder: (_, scrollController) {
return Padding(
padding: const EdgeInsets.all(24),
child: ListView(
controller: scrollController,
children: [
const Text(
'Perpanjang Durasi',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
// No free-trial path for extensions.
...pricing.tiers.map((tier) => Card(
child: ListTile(
title: Text(tier.label),
trailing: Text(
formatRupiah(tier.price),
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
onTap: () {
Navigator.of(context).pop();
ref.read(sessionClosureProvider.notifier).requestExtension(
extensionSessionId,
durationMinutes: tier.durationMinutes,
price: tier.price,
);
},
),
)),
],
), ),
); error: (_, __) => const SizedBox(
}, height: 240,
child: Center(child: Text('Gagal memuat harga. Coba lagi.')),
),
data: (pricing) => _Body(
pricing: pricing,
mode: _mode,
selectedDurationId: _selectedDurationId,
tiers: _tiersForMode(pricing),
scrollController: scrollController,
onModeChanged: (m) => setState(() {
_mode = m;
_selectedDurationId = null;
}),
onTierTap: _onTierTap,
onConfirm: _onConfirm,
),
),
);
},
);
}
}
class _Body extends StatelessWidget {
final PricingData pricing;
final PaymentMode mode;
final String? selectedDurationId;
final List<PriceTier> tiers;
final ScrollController scrollController;
final ValueChanged<PaymentMode> onModeChanged;
final ValueChanged<PriceTier> onTierTap;
final ValueChanged<PriceTier> onConfirm;
const _Body({
required this.pricing,
required this.mode,
required this.selectedDurationId,
required this.tiers,
required this.scrollController,
required this.onModeChanged,
required this.onTierTap,
required this.onConfirm,
});
@override
Widget build(BuildContext context) {
final selectedTier = tiers.firstWhere(
(t) => (t.id ?? t.durationMinutes.toString()) == selectedDurationId,
orElse: () => const PriceTier(durationMinutes: 0, price: 0, label: ''),
);
final hasSelection = selectedTier.durationMinutes > 0;
final ctaLabel = hasSelection
? '${mode == PaymentMode.call ? '📞' : '💬'} perpanjang ${formatRupiah(selectedTier.price)}'
: 'pilih durasi dulu';
return Column(
children: [
const SizedBox(height: HaloSpacing.s8),
Container(
width: 40,
height: 4,
decoration: const BoxDecoration(
color: HaloTokens.border,
borderRadius: HaloRadius.pill,
),
),
const SizedBox(height: HaloSpacing.s12),
const Text(
'waktu curhat habis',
style: TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 18,
fontWeight: FontWeight.w700,
color: HaloTokens.brandDark,
),
),
const SizedBox(height: HaloSpacing.s4),
const Text(
'mau tambah waktu?',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 13,
color: HaloTokens.inkSoft,
),
),
const SizedBox(height: HaloSpacing.s16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: HaloSpacing.s24),
child: _ModeToggle(mode: mode, onChanged: onModeChanged),
),
const SizedBox(height: HaloSpacing.s12),
Expanded(
child: tiers.isEmpty
? const _EmptyState()
: ListView.separated(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(
HaloSpacing.s24,
HaloSpacing.s4,
HaloSpacing.s24,
HaloSpacing.s16,
),
itemCount: tiers.length,
separatorBuilder: (_, __) => const SizedBox(height: HaloSpacing.s8),
itemBuilder: (context, i) {
final tier = tiers[i];
final id = tier.id ?? tier.durationMinutes.toString();
final selected = id == selectedDurationId;
return _TierCard(
tier: tier,
selected: selected,
onTap: () => onTierTap(tier),
);
},
),
),
Container(
padding: const EdgeInsets.fromLTRB(
HaloSpacing.s24,
HaloSpacing.s8,
HaloSpacing.s24,
HaloSpacing.s24,
),
decoration: const BoxDecoration(
border: Border(top: BorderSide(color: HaloTokens.border)),
),
child: HaloButton(
label: ctaLabel,
size: HaloButtonSize.lg,
fullWidth: true,
onPressed: hasSelection ? () => onConfirm(selectedTier) : null,
),
),
],
);
}
}
class _ModeToggle extends StatelessWidget {
final PaymentMode mode;
final ValueChanged<PaymentMode> onChanged;
const _ModeToggle({required this.mode, required this.onChanged});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: HaloTokens.brandSofter,
borderRadius: HaloRadius.pill,
),
child: Row(
children: [
Expanded(child: _Pill(label: 'chat', selected: mode == PaymentMode.chat, onTap: () => onChanged(PaymentMode.chat))),
Expanded(child: _Pill(label: 'call', selected: mode == PaymentMode.call, onTap: () => onChanged(PaymentMode.call))),
],
),
);
}
}
class _Pill extends StatelessWidget {
final String label;
final bool selected;
final VoidCallback onTap;
const _Pill({required this.label, required this.selected, required this.onTap});
@override
Widget build(BuildContext context) {
return Material(
color: selected ? HaloTokens.surface : Colors.transparent,
borderRadius: HaloRadius.pill,
child: InkWell(
borderRadius: HaloRadius.pill,
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(vertical: HaloSpacing.s8),
alignment: Alignment.center,
child: Text(
label,
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 13.5,
fontWeight: FontWeight.w600,
color: selected ? HaloTokens.brandDark : HaloTokens.inkSoft,
),
),
),
),
);
}
}
class _TierCard extends StatelessWidget {
final PriceTier tier;
final bool selected;
final VoidCallback onTap;
const _TierCard({required this.tier, required this.selected, required this.onTap});
@override
Widget build(BuildContext context) {
return Material(
color: selected ? HaloTokens.brandSofter : HaloTokens.surface,
borderRadius: HaloRadius.lg,
child: InkWell(
borderRadius: HaloRadius.lg,
onTap: onTap,
child: AnimatedContainer(
duration: HaloMotion.fast,
padding: const EdgeInsets.all(HaloSpacing.s16),
decoration: BoxDecoration(
border: Border.all(
color: selected ? HaloTokens.brand : HaloTokens.border,
width: selected ? 2 : 1,
),
borderRadius: HaloRadius.lg,
),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: const BoxDecoration(
color: HaloTokens.surface,
borderRadius: HaloRadius.md,
),
alignment: Alignment.center,
child: Text(
'${tier.durationMinutes}',
style: const TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 16,
fontWeight: FontWeight.w700,
color: HaloTokens.brandDark,
),
),
),
const SizedBox(width: HaloSpacing.s12),
Expanded(
child: Row(
children: [
Text(
'${tier.durationMinutes} menit',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: HaloTokens.ink,
),
),
if (tier.tag != null) ...[
const SizedBox(width: HaloSpacing.s8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: HaloSpacing.s8,
vertical: 2,
),
decoration: const BoxDecoration(
color: HaloTokens.mint,
borderRadius: HaloRadius.pill,
),
child: Text(
tier.tag!,
style: const TextStyle(
fontSize: 9,
fontWeight: FontWeight.w700,
color: Color(0xFF1F4D34),
letterSpacing: 0.4,
),
),
),
],
],
),
),
Text(
formatRupiah(tier.price),
style: const TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 16,
fontWeight: FontWeight.w700,
color: HaloTokens.brandDark,
),
),
],
),
),
),
);
}
}
class _EmptyState extends StatelessWidget {
const _EmptyState();
@override
Widget build(BuildContext context) {
return const Center(
child: Padding(
padding: EdgeInsets.all(HaloSpacing.s24),
child: Text(
'Belum ada paket untuk mode ini.',
style: TextStyle(color: HaloTokens.inkSoft),
),
), ),
); );
} }

View File

@@ -4,7 +4,14 @@ import 'package:go_router/go_router.dart';
import '../../core/auth/auth_notifier.dart'; import '../../core/auth/auth_notifier.dart';
import '../../core/availability/mitra_availability_notifier.dart'; import '../../core/availability/mitra_availability_notifier.dart';
import '../../core/chat/active_session_notifier.dart'; import '../../core/chat/active_session_notifier.dart';
import '../chat/widgets/topic_selection_bottom_sheet.dart'; import '../../core/notifications/notif_permission.dart';
import '../../core/theme/halo_tokens.dart';
import 'providers/bestie_history_provider.dart';
import 'widgets/bestie_choice_sheet.dart';
/// Session-only dismiss flag for the "notif denied" banner. Resets on cold
/// restart by design — `StateProvider` lives in memory only.
final homeNotifBannerDismissedProvider = StateProvider<bool>((_) => false);
/// Home screen. /// Home screen.
/// ///
@@ -54,9 +61,27 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
} }
Future<void> _onStartChatPressed(BuildContext context) async { Future<void> _onStartChatPressed(BuildContext context) async {
final topic = await TopicSelectionBottomSheet.show(context); // Phase 4 Stage 2 removes the home-screen topic sensitivity prompt; the
if (topic == null || !context.mounted) return; // ESP picks collected during onboarding feed the same column server-side
context.push('/payment', extra: {'topicSensitivity': topic}); // (info-only — no longer drives matching). Mitras still flip
// `topic_sensitivity` mid-session via the AppBar toggle.
//
// Phase 4 Stage 8: returning users get the bestie-choice sheet first; new
// users skip straight to the multi-screen payment shell. We fetch the
// history-has-items flag on-tap so a stale cache from logout/login doesn't
// mis-route. On error (e.g. offline), fall back to the new-user path.
bool hasHistory;
try {
hasHistory = await ref.read(bestieHistoryHasItemsProvider.future);
} catch (_) {
hasHistory = false;
}
if (!context.mounted) return;
if (hasHistory) {
await BestieChoiceSheet.show(context);
return;
}
context.push('/payment/entry');
} }
@override @override
@@ -101,9 +126,13 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
child: ListView( child: ListView(
// Force-scroll so RefreshIndicator can fire even on a short body. // Force-scroll so RefreshIndicator can fire even on a short body.
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(32), padding: EdgeInsets.zero,
children: [ children: [
const SizedBox(height: 32), const _NotifDeniedBanner(),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 32),
child: SizedBox(height: 32),
),
Center(child: Text('Halo, $displayName!', style: const TextStyle(fontSize: 24))), Center(child: Text('Halo, $displayName!', style: const TextStyle(fontSize: 24))),
const SizedBox(height: 32), const SizedBox(height: 32),
Center( Center(
@@ -229,3 +258,76 @@ class _ActiveSessionCard extends StatelessWidget {
); );
} }
} }
/// Above-the-fold amber banner shown when notif permission is denied. Tap
/// "nyalain" → opens app settings; tap the close icon → hides for the
/// in-memory session only (cold restart re-shows it).
class _NotifDeniedBanner extends ConsumerWidget {
const _NotifDeniedBanner();
@override
Widget build(BuildContext context, WidgetRef ref) {
final statusAsync = ref.watch(notifPermissionStatusProvider);
final dismissed = ref.watch(homeNotifBannerDismissedProvider);
final isDenied = statusAsync.valueOrNull == NotifPermStatus.denied;
if (!isDenied || dismissed) {
return const SizedBox.shrink();
}
return Container(
width: double.infinity,
color: HaloTokens.accentSoft,
padding: const EdgeInsets.symmetric(
horizontal: HaloSpacing.s16,
vertical: HaloSpacing.s8,
),
child: Row(
children: [
const Icon(
Icons.notifications_off_outlined,
size: 18,
color: HaloTokens.brandDark,
),
const SizedBox(width: HaloSpacing.s8),
const Expanded(
child: Text(
'notifikasi off — kamu bisa kelewat chat dari bestie',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 12.5,
color: HaloTokens.ink,
),
),
),
TextButton(
style: TextButton.styleFrom(
foregroundColor: HaloTokens.brandDark,
padding: const EdgeInsets.symmetric(
horizontal: HaloSpacing.s8,
),
minimumSize: const Size(0, 32),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
textStyle: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
onPressed: () =>
ref.read(notifPermissionStatusProvider.notifier).openAppSettings(),
child: const Text('nyalain'),
),
IconButton(
iconSize: 18,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
color: HaloTokens.inkSoft,
icon: const Icon(Icons.close),
onPressed: () =>
ref.read(homeNotifBannerDismissedProvider.notifier).state = true,
),
],
),
);
}
}

View File

@@ -0,0 +1,50 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_client_provider.dart';
class BestieHistoryItem {
final String sessionId;
final String? mitraId;
final String mitraName;
final DateTime? endedAt;
final List<String> topics;
final int sessionsCount;
final bool mitraIsOnline;
const BestieHistoryItem({
required this.sessionId,
required this.mitraId,
required this.mitraName,
required this.endedAt,
required this.topics,
required this.sessionsCount,
required this.mitraIsOnline,
});
factory BestieHistoryItem.fromJson(Map<String, dynamic> json) {
final endedAtRaw = json['ended_at'];
return BestieHistoryItem(
sessionId: json['id'] as String,
mitraId: json['mitra_id'] as String?,
mitraName: json['mitra_display_name'] as String? ?? 'Bestie',
endedAt: endedAtRaw is String ? DateTime.tryParse(endedAtRaw)?.toLocal() : null,
topics: (json['topics'] as List?)?.cast<String>() ?? const [],
sessionsCount: (json['sessions_count'] as num?)?.toInt() ?? 1,
mitraIsOnline: json['mitra_is_online'] as bool? ?? false,
);
}
}
final bestieHistoryProvider = FutureProvider<List<BestieHistoryItem>>((ref) async {
final api = ref.read(apiClientProvider);
final response = await api.get('/api/client/chat/history');
final items = (response['data']['items'] as List<dynamic>? ?? [])
.cast<Map<String, dynamic>>();
return items.map(BestieHistoryItem.fromJson).toList();
});
/// Cheap derived provider used by the home CTA to decide whether to show the
/// bestie-choice sheet or skip straight into the new-payment flow.
final bestieHistoryHasItemsProvider = FutureProvider<bool>((ref) async {
final items = await ref.watch(bestieHistoryProvider.future);
return items.isNotEmpty;
});

View File

@@ -0,0 +1,141 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart';
/// Phase 4 Stage 8 — Bestie Choice Sheet.
///
/// Triggered from the home `Mulai Curhat` CTA when the user has at least one
/// prior session. Two cards: continue with a known bestie (→ history list)
/// vs. find a new bestie (→ soft-prompt + blast).
class BestieChoiceSheet extends StatelessWidget {
const BestieChoiceSheet({super.key});
static Future<void> show(BuildContext context) {
return HaloBottomSheet.show<void>(
context,
child: const BestieChoiceSheet(),
);
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'mau curhat sama siapa?',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 22,
fontWeight: FontWeight.w700,
color: HaloTokens.ink,
),
),
const SizedBox(height: HaloSpacing.s8),
const Text(
'pilih lanjut sama bestie yang udah kenal, atau coba bestie baru.',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 14,
color: HaloTokens.inkSoft,
),
),
const SizedBox(height: HaloSpacing.s24),
_ChoiceCard(
title: 'bestie yang udah kenal',
subtitle: 'lanjut cerita ke bestie yang pernah dengerin kamu.',
icon: Icons.favorite_outline,
onTap: () {
Navigator.of(context).pop();
context.push('/chat/history');
},
),
const SizedBox(height: HaloSpacing.s12),
_ChoiceCard(
title: 'bestie baru',
subtitle: 'cari bestie baru yang siap dengerin sekarang.',
icon: Icons.auto_awesome_outlined,
onTap: () {
Navigator.of(context).pop();
context.push('/payment/entry');
},
),
],
);
}
}
class _ChoiceCard extends StatelessWidget {
final String title;
final String subtitle;
final IconData icon;
final VoidCallback onTap;
const _ChoiceCard({
required this.title,
required this.subtitle,
required this.icon,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Material(
color: HaloTokens.brandSofter,
borderRadius: HaloRadius.lg,
child: InkWell(
onTap: onTap,
borderRadius: HaloRadius.lg,
child: Padding(
padding: const EdgeInsets.all(HaloSpacing.s16),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: const BoxDecoration(
color: HaloTokens.brandSoft,
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Icon(icon, color: HaloTokens.brandDark, size: 24),
),
const SizedBox(width: HaloSpacing.s12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 16,
fontWeight: FontWeight.w700,
color: HaloTokens.ink,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 13,
height: 18 / 13,
color: HaloTokens.inkSoft,
),
),
],
),
),
const Icon(Icons.chevron_right, color: HaloTokens.brandDark),
],
),
),
),
);
}
}

Some files were not shown because too many files have changed in this diff Show More