Files
halobestie-clone/requirement/phase4-customer-flow-plan.md
Ramadhan Sjamsani 938954bbe8 Phase 4 plan: refresh status header (stages 0-10 + §4 migration shipped)
Header was dated 2026-05-10 and described stages 0-8 with Stage 9 in
progress. As of 2026-05-18 Stage 10 (chat tab), the §4 payment-before-pair
migration (Stages 5.1/5.3/5.4), the legacy /payment retirement, and the
TS-01..TS-07 Maestro suite are all on master. Older notes preserved
under "Post-Stage-8 corrections".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:57:19 +08:00

53 KiB
Raw Blame History

Phase 4 — Implementation Plan

Status (2026-05-18): Stages 010 + the §4 returning-user payment migration (Stages 5.1 / 5.3 / 5.4) are code-complete on master. Latest milestones:

  • Pricing relational migration (commit 1c9d81d).
  • §4 payment-before-pair for returning users (commit e09f76c): bestie-history row tap now routes through the full QRIS multi-screen flow before pairing, for both targeted (lama) and blast (baru / cari-bestie-lain) branches. Legacy /payment route + screen retired.
  • Maestro suite (commits e09f76c + 93fa5f1): seven flows ts-01..ts-07 covering every §4 branching point plus the §2 "existing user with name skips set-name" edge. All green end-to-end against a debug APK rebuilt with the new code; shared onboarding prelude under .maestro/subflows/. New backend _test endpoints for offline/online/seed/accept fixtures (/internal/_test/{force-mitra-{offline,online},reset-all-mitras-online,accept-latest-pending,seed-customer}).
  • /internal/_test/force-pairing-timeout now branches targeted-vs-blast so the targeted-fail-post-payment path fires RETURNING_CHAT_TIMEOUT (drives the BestieOfflinePopup → fallback-to-blast UX).

Older status notes for stages 08 preserved in "Post-Stage-8 corrections" below.

See phase4-customer-flow.md for the PRD, flow_customer.md for the source-of-truth flow, flow_customer.mermaid.md for cross-referenced diagrams. Visual reference is in requirement/Figma/ (git-ignored).

This document is the build sequence: what files change, in what order, with API contracts for new endpoints and widget contracts for the reusable UI primitives. The "why" is in the PRD — don't restate it here.


Build Order (10 stages)

The dependency graph forces this order. Stages 01 are pure groundwork that unblocks everything else; stages 28 are feature-shaped and shippable independently; stage 9 is verification.

  1. Design system foundation — tokens, fonts, ThemeData, reusable widgets (no new screens yet)
  2. Backend foundation — additive endpoints + config rows
  3. Onboarding redesign — Verif Choice Sheet, ESP multi-select, USP, OTP-blocked popup
  4. Payment shell — Pilih cara, Pemilihan harga, Cara bayar (QRIS-first), Waiting Payment, Pembayaran expired
  5. Notif gate + home banner
  6. Pairing UX upgrades — Soft-prompt, Searching state, S7 timeout, S9 Match, targeted-wait overlay
  7. Chat-room countdown UX — 3-min snackbar, last-2-min danger, expired floating banner
  8. End-of-session sequence — 2-step confirm, closing-message sheet, S11 thank-you
  9. Returning-user shell — Bestie Choice Sheet, Bestie history visual upgrade, Tanya Admin sheet
  10. Test sweep — Maestro flows, manual real-device run, visual regression

Within each stage, items are listed in dependency order. Each stage is independently mergeable; nothing in stage N+1 hard-depends on stage N's UI (only on stages 0 and 1).


Stage 0 — Design System Foundation

The new flow leans hard on a tokenized look (Bricolage Grotesque + Poppins, warm rose palette, pill buttons, soft shadows). client_app currently uses the default MaterialApp theme — no ThemeData, no token file. Stage 0 sets that up so every subsequent stage can compose screens from primitives.

0.1 Token file

New: client_app/lib/core/theme/halo_tokens.dart

Mirrors requirement/Figma/handoff/tokens.json 1-for-1. Exports a single HaloTokens class with static const Color brand = Color(0xFFE17A9D); etc.

Three palettes (warm, calm, playful) — ship with warm only; expose the structure for the others but leave commented TODO: phase5.

Spacing scale is HaloSpacing.s4, s8, s12, … (matches Figma 1=4, 2=8).

Radius: HaloRadius.sm/md/lg/xl/pill as BorderRadius constants.

Motion: HaloMotion.fast = Duration(milliseconds: 180) etc. Use the cubic curve Cubic(0.2, 0.8, 0.2, 1) as HaloMotion.ease.

0.2 Fonts

Add to client_app/pubspec.yaml:

fonts:
  - family: BricolageGrotesque
    fonts:
      - asset: assets/fonts/BricolageGrotesque-Regular.ttf
      - asset: assets/fonts/BricolageGrotesque-Bold.ttf
        weight: 700
  - family: Poppins
    fonts:
      - asset: assets/fonts/Poppins-Regular.ttf
      - asset: assets/fonts/Poppins-Medium.ttf
        weight: 500
      - asset: assets/fonts/Poppins-SemiBold.ttf
        weight: 600
      - asset: assets/fonts/Poppins-Bold.ttf
        weight: 700
  - family: JetBrainsMono
    fonts:
      - asset: assets/fonts/JetBrainsMono-Regular.ttf
      - asset: assets/fonts/JetBrainsMono-Bold.ttf
        weight: 700

Download .ttf files from the Google Fonts CDN snapshot (fonts.google.com/specimen/...). Place under client_app/assets/fonts/. Add the assets/fonts/ directory to the existing flutter: block.

0.3 ThemeData

New: client_app/lib/core/theme/halo_theme.dart

Single haloThemeData() builder returning a ThemeData with:

  • colorScheme.fromSeed(seedColor: HaloTokens.brand), then override primary/onPrimary/surface/onSurface/error to match tokens
  • textTheme mapped to the Figma scale (displayLarge → Bricolage 36/700, titleLarge → Bricolage 22/700, bodyMedium → Poppins 15/400, etc.)
  • elevatedButtonTheme with pill radius + HaloShadows.button
  • inputDecorationTheme matching the 64px-tall S2 Nama input
  • bottomSheetTheme with 24px top corners + soft shadow
  • snackBarTheme matching HBSnackbar (pill, dark backdrop)

Wire into client_app/lib/main.dart::84 (the MaterialApp.router):

return MaterialApp.router(
  title: 'Halo Bestie',
  theme: haloThemeData(),
  routerConfig: router,
);

0.4 Reusable widgets

New folder: client_app/lib/core/theme/widgets/

Each widget is a thin Flutter port of a Figma primitive:

Widget Port of Notes
HaloButton HBButton variants: primary / secondary / ghost; sizes: sm / md / lg; onPressed: null → disabled visuals
HaloOrb HBOrb gradient circle with seed int → deterministic color blend
HaloStepDots HBStepDots progress dots, e.g. 4 dots for onboarding
HaloBottomSheet HBBottomSheet helper that wraps showModalBottomSheet with the correct shape, drag handle, padding
HaloPopup HBPopup helper that wraps showDialog with title/body/icon/primary/secondary
HaloSnackbar HBSnackbar dark pill snackbar, 4s default; static show(context, message, {icon})
HaloChip ESP chip toggleable pill chip with icon + label

Naming: Halo* prefix (avoids collision with Material's Card, Chip, etc.) and matches the brand. Internal-only; no need for a published package.

0.5 Acceptance for Stage 0

  • flutter analyze clean.
  • flutter run launches with the warm palette visible on the existing Splash and Home screens (they pick up the new ThemeData automatically).
  • A simple harness screen (lib/core/theme/_preview.dart, dev-only, routed at /_theme_preview with a build flag) renders all Halo* widgets — used as a visual reference during stages 28.

Stage 1 — Backend Foundation

Mostly additive endpoints + one migration that touches payment_sessions and chat_sessions. Pricing stays mocked but moves from hardcoded to app_config.

1.1 Schema migration

File: backend/src/db/migrate.js (single-file migration script — append).

-- 1. Replace is_free_trial with is_first_session_discount
ALTER TABLE payment_sessions
  ADD COLUMN IF NOT EXISTS is_first_session_discount BOOLEAN NOT NULL DEFAULT false;

UPDATE payment_sessions
   SET is_first_session_discount = is_free_trial
   WHERE is_free_trial = true
   AND is_first_session_discount = false;

ALTER TABLE payment_sessions DROP COLUMN IF EXISTS is_free_trial;

-- 2. Add mode column for chat vs voice call
ALTER TABLE payment_sessions
  ADD COLUMN IF NOT EXISTS mode TEXT NOT NULL DEFAULT 'chat'
    CHECK (mode IN ('chat', 'call'));

-- 3. Store ESP picks on chat session for info display
ALTER TABLE chat_sessions
  ADD COLUMN IF NOT EXISTS topics TEXT[];

Backwards compat note: any service code still reading is_free_trial will break — grep + cut over in §1.2. There is no production data to protect (Phase 3.7 was the first ship of is_free_trial and it never went live in real users per project_pricing_still_mocked_3_7).

1.2 Free-trial → first-session-discount cutover

Files: backend/src/services/pricing.service.js, backend/src/services/payment.service.js, any other reader.

grep -rn "is_free_trial\|free_trial\|freeTrial" backend/src

For each hit:

  • Replace the read with is_first_session_discount.
  • Replace any "free trial" copy in API response strings with neutral language (the pricing block carries actual_price_idr etc. now).
  • Eligibility logic now reads:
    • users.phone_verified_at IS NOT NULL (verified user), AND
    • no chat_sessions row with status IN ('completed','closed_by_user','closed_by_mitra') for this customer.

1.3 New + rewritten endpoints

GET /api/client/onboarding-state (new)

File: backend/src/routes/client/onboarding.routes.js (new) or extend auth.routes.js.

200 OK
{ "has_consulted_before":            boolean,
  "is_phone_verified":               boolean,
  "is_first_session_discount_eligible": boolean,
  "is_anonymous":                    boolean
}
  • is_first_session_discount_eligible is the AND of: is_phone_verified && !has_consulted_before && app_config.first_session_discount_enabled == 'true'.
  • Drives both VerifChoiceSheet visibility and S6 paywall display.

GET /api/client/chat-pricing (rewrite)

File: backend/src/services/pricing.service.js

200 OK
{ "chat": {
    "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" }
    ]
  },
  "call": {
    "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" }
    ]
  },
  "first_session_discount": {
    "eligible": true,
    "actual_price_idr": 2000,
    "gimmick_price_idr": 12000,
    "duration_minutes": 12,
    "modes": ["chat"]
  }
}
  • Tiers come from app_config.pricing_chat_tiers_json and pricing_call_tiers_json (JSON arrays). The discount block reads its four config values and the per-customer eligibility check.
  • first_session_discount.eligible is per-customer — uses the same predicate as onboarding-state.is_first_session_discount_eligible.

GET /api/shared/auth-providers (new)

File: backend/src/services/auth-providers.service.js (new) + backend/src/routes/shared/auth-providers.routes.js (new).

200 OK
{ "google":   { "enabled": false },
  "apple":    { "enabled": false },
  "phone":    { "enabled": true } }

Probes env at module load:

const enabled = (...keys) => keys.every(k => process.env[k] && process.env[k].trim());
const google  = enabled('GOOGLE_OAUTH_CLIENT_ID', 'GOOGLE_OAUTH_CLIENT_SECRET');
const apple   = enabled('APPLE_OAUTH_CLIENT_ID', 'APPLE_OAUTH_TEAM_ID',
                        'APPLE_OAUTH_KEY_ID', 'APPLE_OAUTH_PRIVATE_KEY');

GET /api/client/support-handles (new)

200 OK
{ "wa":       { "label": "WhatsApp", "deeplink": "https://wa.me/62..." },
  "telegram": { "label": "Telegram", "deeplink": "https://t.me/..." } }

Reads app_config.support_handles_json. CC will get a writable form for this in stage 8 (or earlier if convenient).

1.4 Confirm session_warning event exists

File: backend/src/services/session-timer.service.js

grep -n "session_warning\|three_minutes\|180" backend/src/services/session-timer.service.js

If absent, add: when secondsLeft == 180, emit once over the customer WS:

{ "type": "session_warning", "kind": "three_minutes_left", "session_id": "..." }

1.5 Seed app_config rows

Append to seed step in backend/src/db/migrate.js:

INSERT INTO app_config (key, value) VALUES
  ('payment_method_qris_first',                   'true'),
  ('payment_session_timeout_minutes',             '20'),
  ('searching_timeout_minutes',                   '5'),
  ('end_session_two_step_confirm',                'true'),
  ('three_minute_warning_enabled',                'true'),
  ('first_session_discount_enabled',              'true'),
  ('first_session_discount_actual_price_idr',     '2000'),
  ('first_session_discount_gimmick_price_idr',    '12000'),
  ('first_session_discount_duration_minutes',     '12'),
  ('first_session_discount_modes',                '["chat"]'),
  ('pricing_chat_tiers_json', '[
    {"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', '[
    {"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',
    '{"wa":{"label":"WhatsApp","deeplink":"https://wa.me/6285173310010"},
      "telegram":{"label":"Telegram","deeplink":"https://t.me/halobestie"}}'
  )
ON CONFLICT (key) DO NOTHING;

1.6 CC editor for new config rows

File: control_center/src/pages/AppConfigPage.tsx (existing) — add fields.

A simple form section labeled "First-session discount" with the 5 keys. A second section "Pricing tiers (mock)" with two textareas (JSON-validated) for chat + call. A third section "Support handles" with WA + Telegram inputs. No backend route changes needed — CC already has a generic PUT /internal/_config/:key (verify name).

1.7 Acceptance for Stage 1

  • Curl smoke against all four endpoints returns the documented shape.
  • backend/test/ Vitest covers:
    • chat-pricing returns chat + call groups; eligibility flips when the customer has a completed session.
    • auth-providers returns {enabled:false} when env vars unset, true when set.
    • session_warning 3-min ping fires once.
  • Migration is idempotent (re-run migrate.js on a populated DB does not error or duplicate config rows).
  • No frontend change required to merge stage 1.

Stage 2 — Onboarding Redesign

Resolves PRD §1, §2, §13.

2.1 Verif Choice Sheet

New: client_app/lib/features/auth/widgets/verif_choice_sheet.dart

HaloBottomSheet with two buttons. Built on top of Stage 0 primitives.

Trigger location: display_name_screen.dart — after the user submits a name, read onboarding-state. If has_paid_first_session == true, jump straight to the duration picker; else show the sheet.

Routes the user to one of two GoRouter paths:

  • /onboarding/verif/esp
  • /onboarding/anon/esp

These are sibling shell routes that share the rest of the onboarding sequence.

2.2 ESP screen (multi-select, info-only)

New: client_app/lib/features/onboarding/screens/esp_screen.dart Replaces existing usage of: lib/features/chat/widgets/topic_selection_bottom_sheet.dart

12 chips, multi-select. State held in a StateProvider<Set<EspTopic>> named espSelectionProvider. Skip CTA writes an empty set + a skipped: true flag.

ESP is purely informational — the picks are persisted on chat_sessions.topics (column added in stage 1.1) and surfaced to the mitra on session start as a chip row above the first message bubble. They do not affect matching, pricing, or routing. Existing pairing.service.js topic-classification code stays untouched.

The mitra-side display (chip row above first bubble) is a small mitra_app edit but considered in scope for stage 2 since it's read-only.

2.3 USP screen

New: client_app/lib/features/onboarding/screens/usp_screen.dart

Static — four feature cards + CTA aku ngerti, lanjut →.

HaloStepDots(total: 4, current: 2) in the header.

2.4 OTP screens — visual re-skin only

Edit: client_app/lib/features/auth/screens/{register,otp}_screen.dart

  • Re-style with Stage 0 widgets. Keep 6-digit (resolved decision).
  • The Figma 4-digit boxes become 6 boxes laid out across the same horizontal width — slightly tighter spacing.

2.5 OTP-blocked popup

New: client_app/lib/features/auth/widgets/otp_blocked_popup.dart

HaloPopup shown when otp_screen.dart receives a 429 with error: "otp_retry_exhausted".

  • Primary: lanjut tanpa verif → routes to /onboarding/anon/method with the espSelectionProvider and any USP-acknowledged flag preserved (no re-prompt).
  • Secondary: hubungi admin → opens Tanya Admin sheet (built in stage 8; stub a TODO SnackBar for now).

Backend-side: confirm otp.service.js returns the 429 shape { error: "otp_retry_exhausted", retry_after_seconds: 1800 }. If not, adjust.

2.6 Auth-providers gating (replace ENABLE_SOCIAL_AUTH build flag)

New: client_app/lib/core/auth/auth_providers_provider.dart Edit: client_app/lib/features/auth/screens/welcome_screen.dart, register_screen.dart, any other screen with Google/Apple buttons. Edit: client_app/lib/core/auth/social_auth_enabled.dart — read from Riverpod provider instead of bool.fromEnvironment. Or delete this file and inline the check at button render sites.

Logic:

  1. On app cold start (in main.dart post-bootstrap), ref.read(authProvidersProvider.future) once and cache.
  2. Provider exposes {google: bool, apple: bool, phone: bool}.
  3. Each social button site reads the corresponding flag and renders nothing if false.
  4. If both are false, the welcome screen falls back to the phone-OTP-only layout.

Memory update: the implementer must update client_app/CLAUDE.md to remove the ENABLE_SOCIAL_AUTH build-flag note.

2.7 Acceptance for Stage 2

  • Maestro flow 02_onboarding_verified.yaml covers Splash → Name → Verif Sheet → ESP (pick chip) → USP → S3a → S3b (6-digit) → arrival at S6 paywall (when eligible) or duration picker (when not).
  • Maestro flow 03_onboarding_anon.yaml covers Splash → Name → Verif Sheet ("anon") → ESP → USP → arrival at the (Stage 3) Pilih cara route.
  • Manual: trigger 5x failed OTP → blocked popup → "lanjut tanpa verif" lands on the anonymous path with chips preserved.
  • Auth providers: backend started without OAuth env vars → social buttons hidden on welcome/register screens; setting envs and restarting backend + app → buttons appear.

Stage 3 — Payment Shell

Resolves PRD §3, §4. Backend payment is still mocked — only the UI is being built out.

GoRouter additions (sibling routes under /payment/):

  • /payment/discount-paywall — S6 first-session discount (verified eligibles only)
  • /payment/method-pick — Pilih cara curhat (chat/call) — anonymous + non-eligible verified
  • /payment/duration-pick — Pemilihan harga (rebuilds from selected mode group)
  • /payment/method — Cara bayar (QRIS-first list)
  • /payment/waiting/:paymentId — Waiting Payment with QR + 20-min countdown
  • /payment/expired/:paymentId — Pembayaran expired

State bag: paymentDraftProvider — a Riverpod Notifier<PaymentDraft> that holds mode (chat|call), durationId, priceIDR, paymentId?, isFirstSessionDiscount (bool). Cleared on arrival at the first stage when entered fresh; persisted across back-nav.

3.1 S6 first-session discount paywall

New: client_app/lib/features/payment/screens/discount_paywall_screen.dart

Renders only when chat-pricing.first_session_discount.eligible == true. Layout matches screens/onboarding.jsx::S6Paywall:

  • Struck-through Rp{gimmick_price_idr} next to prominent Rp{actual_price_idr}.
  • Subtitle: "untuk {duration} menit ngobrol".
  • CTA: mulai · Rp{actual_price_idr} → routes to /payment/method with paymentDraft = { mode: 'chat', duration_minutes: discount.duration_minutes, price_idr: discount.actual_price_idr, isFirstSessionDiscount: true }.

If modes config is ["chat","call"] (ops enabled call for first session), render a tiny mode toggle at the top — but in v1 default config, the screen is chat-only and the toggle is hidden.

Routing decision (single point of truth):

// after S5b USP → next:
if (eligible && discount.enabled)  /payment/discount-paywall
else                               /payment/method-pick

3.2 Pilih cara curhat

New: client_app/lib/features/payment/screens/method_pick_screen.dart

Two cards (chat / call). The "premium" call indicator on the card is a visual cue, not a hard-coded multiplier. Tapping a card stores the mode in paymentDraft.mode and routes to /payment/duration-pick.

3.3 Pemilihan harga

New: client_app/lib/features/payment/screens/duration_pick_screen.dart

Reads paymentDraft.mode. Renders the corresponding tier list (pricing.chat.tiers or pricing.call.tiers). Top of the screen shows a chat | call mode toggle — toggling rebuilds the list from the other group and resets the selection.

Selecting a tier sets paymentDraft.priceIDR and paymentDraft.durationMinutes; bottom CTA {mode_icon} bayar Rp{price} routes to /payment/method.

3.4 Cara bayar

New: client_app/lib/features/payment/screens/payment_method_screen.dart

Mirrors screens/extras.jsx::SPaymentMethod. QRIS at top with "DIREKOMENDASIKAN" pill; 4 e-wallet options below. Tapping bayar:

  1. Calls existing POST /api/client/payment-sessions (Phase 3.7) with { mode, duration_minutes, price_idr, is_first_session_discount, method }.
  2. Routes to /payment/waiting/:paymentId.

3.5 Waiting Payment

New: client_app/lib/features/payment/screens/waiting_payment_screen.dart

Renders a placeholder QR (use a qr_flutter package; add to pubspec) — in mock mode the QR encodes the paymentId only. Real QR string comes from the response.

State: Timer.periodic(const Duration(seconds: 1)) ticks the 20-min countdown for the header. Polling: Timer.periodic(const Duration(seconds: 3)) hits GET /api/client/payment-sessions/:id. On paid → route to /onboarding/notif-gate (Stage 4). On expired/payment/expired/:paymentId.

Polling pauses when app is backgrounded (WidgetsBindingObserver).

3.6 Pembayaran expired

New: client_app/lib/features/payment/screens/payment_expired_screen.dart

Static screen + retry CTA → routes back to /payment/method with the paymentDraft retained (so the user re-pays the same plan, same mode, same discount flag if applicable).

3.7 Acceptance for Stage 3

  • Curl: a fresh payment_sessions row with is_first_session_discount=true goes to paid via the existing CC "mark as paid" tool → app advances.
  • Maestro: 04_payment_expired.yaml exercises the timeout path.
  • Visual sanity: run flutter run for both eligible (S6 paywall first) and ineligible (Pilih cara → Pemilihan harga first) users.
  • Mode toggle on duration picker: switching chat → call rebuilds the option list from pricing.call.tiers; selection state resets.

Stage 4 — Notif Gate + Home Banner

Resolves PRD §5.

4.1 OS-permission helper

New: client_app/lib/core/notifications/notif_permission.dart

Wraps firebase_messaging and permission_handler into:

Future<NotifPermStatus> readStatus();   // notDetermined | granted | denied
Future<NotifPermStatus> request();      // shows OS prompt (only if notDetermined)
Future<void> openAppSettings();         // for "denied" path

Status cached in a Riverpod notifPermissionProvider that auto-refreshes when the app foregrounds (existing appLifecycleProvider pattern).

4.2 Notif Gate full screen

New: client_app/lib/features/onboarding/screens/notif_gate_screen.dart

Route: /onboarding/notif-gate. Shown post-payment (Stage 3 routes here).

If status is already granted, redirect immediately to the searching shell (Stage 5). Otherwise render the screen with two CTAs:

  • izinkan notifikasi → calls request(). After resolution (any), advance.
  • nanti aja → advance.

4.3 Home banner

Edit: client_app/lib/features/home/home_screen.dart

Above-the-fold thin amber banner if notifPermissionProvider == denied. Dismissable for the session via a homeNotifBannerDismissedProvider (StateProvider<bool>). Persists nothing across cold-start.

Tap nyalain → calls openAppSettings().

4.4 Acceptance for Stage 4

  • Cold-start with notif denied → banner visible. Dismiss → gone for session.
  • Cold-restart → banner reappears.
  • Notif Gate full screen: "nanti aja" advances; "izinkan" + grant advances; "izinkan" + deny advances and home banner shows.

Stage 5 — Pairing UX Upgrades

Resolves PRD §6, §7, §11.3 (targeted-wait overlay only — choice sheet & list visual upgrade live in stage 8).

5.1 Soft-prompt screen

Edit: client_app/lib/features/chat/screens/searching_screen.dart

The existing screen already has a reflective-prompt-card phase before the blast fires. Re-skin with Stage 0 widgets and confirm the CTA copy aku ngerti, lanjut →.

5.2 Searching state visuals

  • Replace the current spinner with the v3 pulsing-dots panel (screens/v3.jsx::SSearchPrompt).
  • No state-machine change.

5.3 5-min timeout state

  • The pairing notifier (lib/core/pairing/pairing_notifier.dart) already exposes a timeout state. Render the new copy + two CTAs:
    • primary coba cari lagi → re-fires the blast (calls existing pairing retry action).
    • ghost kembali ke homecontext.go('/').

5.4 S9 Match-found re-skin

Edit: client_app/lib/features/chat/screens/bestie_found_screen.dart

Render the S9MatchV4 layout: orb + status dot + halo, aku bestie {name} + CTA mulai sesi {N} menit →. N comes from the chat session's pricing tier.

5.5 Targeted-wait overlay

New: client_app/lib/features/chat/screens/targeted_waiting_screen.dart

Route: /chat/waiting-targeted/:mitraId. Renders the SWaitingBestie component with three sub-states (waiting | accepted | declined). The 20-second countdown pulls from the pairing notifier's existing PairingTargetedWaitingData. On accepted → route to the chat screen; on declined → show the BestieOfflinePopup (built in stage 8) overlaid.

Chat history's "Curhat lagi" button is updated to push this route instead of the current intermediate.

5.6 Acceptance for Stage 5

  • Maestro: 05_searching_timeout.yaml — make the backend return no mitras for 6 minutes, confirm timeout state and both CTAs work.
  • Manual: open chat history → tap "Curhat lagi" on an online bestie → 20s overlay → mitra accepts → chat opens.

Stage 6 — Chat-room Countdown UX

Resolves PRD §8.

6.0 Voice-call mode badge (header)

Edit: client_app/lib/features/chat/screens/chat_screen.dart and the mitra_app equivalent (mitra_app/lib/features/chat/screens/chat_screen.dart).

The session payload (loaded from GET /api/{client|mitra}/chat-sessions/:id) exposes mode: 'chat' | 'call' (sourced from payment_sessions.mode, column added in stage 1.1). The chat header renders a small pill next to the bestie/customer name:

  • mode == 'call'📞 Voice Call pill in HaloTokens.accent color.
  • mode == 'chat' → either no pill, or a subtle 💬 Chat pill (design choice — default to no pill to reduce noise).

URL rendering inside chat bubbles already handles plain links; confirm meet.google.com/... URLs launch the OS handler via url_launcher. No special "join meet" badge — links are plain.

No mitra composer helpers for Meet links — mitra types/pastes the URL manually as a normal message. (Resolved decision; see bottom.)

6.1 3-min snackbar

Edit: client_app/lib/features/chat/screens/chat_screen.dart

Listen on the WS event stream for session_warning.kind == 'three_minutes_left'. On fire, call HaloSnackbar.show(context, 'sisa 3 menit lagi ya 🤍', icon: '⏳'). A bool _threeMinShown per-session flag prevents double-fire.

6.2 Last-2-min danger visuals

  • Compute remaining = secondsLeftProvider.watch(...) (existing).
  • When remaining <= 120, swap the timer pill style (HaloTokens.danger background + bold JetBrainsMono text) and the progress bar color.

6.3 Floating expired banner

  • When remaining == 0 and the session is in closing-grace (existing flag — see memory Phase 3 Session-End Overhaul), inject ChatExpiredBanner widget above the input bar.
  • Tap perpanjang → opens the time-up bottom sheet.

6.4 Time-up sheet upgrade

Edit (or replace): client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart

Cut over to the 5-option layout with chat/call toggle. Behavior of the perpanjang CTA is unchanged from Phase 3.7 — only UI changes.

6.5 Acceptance for Stage 6

  • Maestro: 06_chat_countdown.yaml — manipulate the session's expires_at to drive 3-min snackbar, last-2-min visuals, and expired banner in one run.
  • Manual: chat session, observe the visual transitions.

Stage 7 — End-of-session Sequence

Resolves PRD §10.

7.1 Step-1 confirm popup

New widget; trigger from chat screen.

  • File: client_app/lib/features/chat/widgets/confirm_end_step1.dart
  • Primary lanjut akhiri → opens step-2 popup.
  • Secondary gak jadi, balik → close, stay in chat.

7.2 Step-2 confirm popup

New: client_app/lib/features/chat/widgets/confirm_end_step2.dart

  • Primary tulis pesan penutup → opens closing-message sheet.
  • Secondary lewati saja → calls existing close-session API and routes to S11.

7.3 Closing-message bottom sheet

Replace existing goodbye composer (currently a screen) with a bottom sheet at client_app/lib/features/chat/widgets/closing_message_sheet.dart.

Textarea + two CTAs:

  • kirim & akhiri sesi → POSTs goodbye message + closes session.
  • lewat — langsung akhiri → closes without sending.

7.4 S11 thank-you screen

New: client_app/lib/features/chat/screens/thank_you_screen.dart

Route: /chat/thank-you. Replaces the current "navigate straight home" behavior. CTA balik ke homecontext.go('/').

7.5 Mitra-rejects-close fallback

  • If close API returns 409, show BestieOfflinePopup (built in stage 8) with the "returning" variant. No new asset for this stage.

7.6 Acceptance for Stage 7

  • Maestro: 07_end_session_2step.yaml covers chat → akhiri → step1 → step2 → closing message → thank-you → home.
  • Manual: confirm the "gak jadi, balik" path returns cleanly to chat.

Stage 8 — Returning-User Shell

Resolves PRD §11 (choice + list visual upgrade) and §12.

8.1 Bestie Choice Sheet

New: client_app/lib/features/home/widgets/bestie_choice_sheet.dart

HaloBottomSheet with two cards. Triggered from the home CTA when the user has at least one prior session (bestieHistoryHasItems provider).

  • bestie yang udah kenal → routes to chat history list.
  • bestie baru → routes to soft-prompt + blast (existing).

8.2 Bestie history list — visual upgrade

Edit: client_app/lib/features/chat/screens/chat_history_screen.dart

Render the v4 BestieHistoryList layout: orb + name + last-session date + topic + sessions count + ONLINE pill (live from the existing presence provider).

8.3 Bestie Offline Popup variants

Edit: client_app/lib/features/chat/widgets/bestie_unavailable_dialog.dart

Add a variant: 'returning' | 'new' param. The existing dialog covers the returning case; add the new-user copy ("semua bestie lagi istirahat") and wire tanya admin ghost link.

8.4 Tanya Admin sheet

New: client_app/lib/features/support/widgets/tanya_admin_sheet.dart

HaloBottomSheet with WA + Telegram buttons. Reads handles from Stage 1's endpoint via a supportHandlesProvider.

Tapping launches url_launcher with the deeplink. No webview.

8.5 Acceptance for Stage 8

  • Maestro: 08_returning_targeted.yaml covers home → bestie choice sheet → history list → online pick → 20s overlay → match.
  • Manual: pick offline bestie → BestieOfflinePopup → tanya admin → WA opens.

Post-Stage-8 corrections (2026-05-10, uncommitted)

The first visual sweep of the live app caught that the boot path was still on Phase 1 plumbing — Splash → /welcome (Phase 1 social/phone picker) → forms — instead of the mermaid §1 contract: Splash → Home (1st time / returning). The new home variants (SHome1st, SHomeReturning) had not been built; home_screen.dart was the Phase 1 placeholder with a Material AppBar + "Mulai Curhat" button.

Fixes applied in the working tree (not yet committed):

C.1 /welcome retired

  • Route + WelcomeScreen import + welcome_screen.dart file all removed.
  • Router redirects (formerly pointing at /welcome for AuthInitialData, AsyncError, and post-onboarding-carousel cases) now point at /home.
  • The router carve-out comment that referenced /welcome as the bottom of the navigation stack updated to reference /home.
  • Stage 2.6 of this plan is stale: it described editing welcome_screen.dart to read authProvidersProvider; that screen no longer exists. The authProvidersProvider itself is preserved and is now consumed only at the phone-OTP / future login-recovery surfaces.

C.2 home_screen.dart rewritten to Figma §1 spec

  • Renders SHome1st (screens/v3.jsx::SHome1st) for unauthenticated users (any state that isn't AuthAuthenticatedData / AuthAnonymousData).
  • Renders SHomeReturning (SHomeReturning) for authenticated / anonymous users.
  • Components: login-recover banner, "halo," / "halo, {name}" greeting (brand-colored name on returning), aku mau curhat / curhat sama bestie baru primary CTA, "curhatan sebelumnya" history section (live data via bestieHistoryProvider), bottom 4-tab HBTabBar footer (home / chat / kamu / premium SOON — only home + chat wired).
  • _NotifDeniedBanner (Stage 4) preserved at the top.
  • _ActiveSessionCard preserved on SHomeReturning so a user mid-session can rejoin (not in Figma §1 but a hard UX requirement).
  • Material AppBar removed — the Figma layout has none. Logout will land on the kamu tab when that's built.
  • OnboardingScreen._finish() now navigates to /home instead of /welcome. The 3-page intro carousel (Langsung Curhat / 100% Anonim / Bestie yang Relevan) itself is kept for now — it is not in the mermaid §1, but the operator chose minimum-touch correction. Full retirement (delete OnboardingScreen + onboardingDoneProvider + the /onboarding route + the gate at the top of router.dart) is a follow-up.

C.4 Defensive variant gate

  • HomeScreen now treats anything that is not AuthAuthenticatedData or AuthAnonymousData as "fresh" → renders SHome1st. This avoids the unauthenticated-but-erroring user seeing halo, kamu (the returning view) for a brief moment.

C.5 Open gap — Login flow not in mermaid

SHome1st's masuk → banner button currently routes to /auth/register (phone-OTP entry). This is an interpretation, not a spec: the mermaid (and Figma SHome1st's onLogin callback) doesn't define the login destination. The mermaid needs a Login flow diagram added — destinations from the masuk → banner, OTP success → AuthAuthenticatedData → SHomeReturning. Tracked in agent memory as project_phase4_login_flow_gap.md.


Stage 9 — Test Sweep

9.1 Maestro flows

All under client_app/.maestro/flows/:

  • 01_smoke.yaml — keep passing (existing).
  • 02_onboarding_verified.yaml (stage 2)
  • 03_onboarding_anon.yaml (stage 2)
  • 04_payment_expired.yaml (stage 3)
  • 05_searching_timeout.yaml (stage 5)
  • 06_chat_countdown.yaml (stage 6)
  • 07_end_session_2step.yaml (stage 7)
  • 08_returning_targeted.yaml (stage 8)

Helper scripts (peek_otp.js, reset_phone.js) reused; add force_payment_state.js and force_session_warning.js (call internal CC endpoints).

9.2 Real-device run

  • Single AVD + physical Android per memory Test Infrastructure.
  • Capture screen recordings of each Maestro flow; save under requirement/phase4-testing/<flow>.mp4 (git-ignored — add to .gitignore).

9.3 Visual regression

  • Manual sweep with the Figma handoff/png/ images side-by-side. Goal: 95% visual parity (copy must be exact; visual fidelity within 5%).

Stage 10 — Chat Tab (3 sub-tabs)

Added 2026-05-12 after design review. Figma source: SChatList in requirement/Figma/screens/extras.jsx (line 22+). Not yet in flow_customer.mermaid.md — §10.8 adds it.

10.1 Scope & goal

Replace the existing /chat/history destination (a flat list of closed sessions backed by bestie_history_provider) with a new Chat tab screen that contains three sub-tabs:

Sub-tab Contents Tap behavior
aktif The user's single ongoing session (0 or 1 item) Resume the live chat room
pembayaran Pending initial-session + extension payments Resume the Xendit payment flow
selesai Past sessions (status COMPLETED + CLOSING) — cursor-paginated 20/page Open read-only transcript

The chat icon in HaloTabBar already exists and points to /chat/history — only its destination changes. Bottom-nav structure is unchanged.

bestie_history (screen + provider) is retired in this stage.

10.2 Figma source

  • SChatList — list layout, sub-tab pill counters, per-item visuals
  • S_pembayaran_kedaluwarsa (same file, ~line 600) — expired-payment full screen. Copy says 20 menit, see §10.6.
  • Item visuals: HBOrb (avatar) + optional green success-color live dot; name (who) bold; preview text muted; right-aligned timestamp (● live when active); below-preview chips:
    • bayar Rp X.XXX chip (amber) on pembayaran items
    • X menit duration suffix on selesai items

10.3 Routes & navigation (client_app)

Each sub-tab gets its own path so deep links, back stack, and Maestro tests all agree on the active tab (URL is the source of truth):

Path Sub-tab
/chat Redirect → /chat/aktif
/chat/aktif Aktif (default landing)
/chat/pembayaran Pembayaran
/chat/selesai Selesai

Implementation: a single ShellRoute (or a shared scaffold widget passed the active tab id) so the three paths render the same chrome (heading, sub-tab pills, bottom HaloTabBar) with only the list body swapping. Tapping a sub-tab pill calls context.go('/chat/<id>').

Renames + cleanup:

  • HaloTabBar chat tab onTap: /chat/history/chat (which then redirects to /chat/aktif).
  • Old /chat/history route + bestie_history_screen.dart + bestie_history_provider.dart deleted.
  • /chat/history/:sessionId (read-only transcript) renamed to /chat/transcript/:sessionId so no route lives under the retired /chat/history parent. All inbound context.push('/chat/history/...') updated.

Bottom-nav red-dot tap behavior: the chat tab still calls context.go('/chat') (no special-case for the red dot). The user lands on the default aktif tab. FCM payment-pending pushes (if/when wired) target /chat/pembayaran directly.

10.4 Sub-tab content & item model

aktif

  • Backed by existing /api/client/chat/session/active-with-unread (already wired via activeSessionProvider). No new endpoint.
  • Always renders the active session even when the user is currently inside the chat room (per decision §10.6 below).
  • Voice-call sessions (mode='call') render with a small 📞 Call pill in the same row (consistent with Stage 6.0 header-badge convention).
  • Empty state copy: belum ada chat di sini.

pembayaran

  • Backed by new GET /api/client/payment-sessions/pending (§10.7).
  • Two row kinds (preview copy differentiates):
    • Initial-session: menunggu pembayaran sesi
    • Extension: menunggu pembayaran perpanjangan
  • Amber bayar Rp X.XXX chip per Figma.
  • Empty state: belum ada pembayaran tertunda.

selesai

  • Backed by existing GET /api/client/history — switch from offset (page) to cursor pagination (§10.7) and rename param.
  • Per-item: mins (duration), preview = closing message (mitra's if present, else customer's), relative timestamp.
  • Empty state: belum ada riwayat curhat.

10.5 Badges

Surface Trigger Visual
Bottom-nav chat tab pembayaran count > 0 Red dot (no number)
aktif sub-tab pill Unread message count > 0 Numeric badge (uses existing unread_count from active-with-unread)
pembayaran sub-tab pill Pending payment count > 0 Numeric badge (count from /payments/pending)
selesai sub-tab pill No badge (overrides the Figma count pill)

Bottom-nav red-dot data source: piggy-back on the same /api/client/payment-sessions/pending call (its total field). Polled when the HaloTabBar host screen mounts; refreshed by riverpod invalidation when a payment is created or completed.

10.6 Decisions baked in

  1. Aktif always shows the live session. Even when the user is on the chat screen, the row stays in aktif — it represents state, not navigation.
  2. Voice-call sessions live in the same list with a Call pill. Per memory project_phase4_chat_ux_improvements and Stage 6.0.
  3. Pembayaran TTL reuses existing payment_session_timeout_minutes. Payment is still mocked (per memory project_pricing_still_mocked_3_7); real Xendit is not wired yet. The app_config.payment_session_timeout_minutes row (default 20) already drives expires_at on payment_sessions rows via createPaymentSession. The Figma "pembayaran kedaluwarsa" 20-min copy already matches the default — no new app_config row needed for Stage 10. When real Xendit lands, the same value is reused for the invoice TTL.
  4. Max 1 active session. Aligns with existing pairing constraint; no backend change.

10.7 Backend changes

10.7.1 New: GET /api/client/payment-sessions/pending

Returns pending initial-session + extension payment sessions for the authenticated customer (not yet paid, not yet expired).

Query: payment_sessions WHERE customer_id = $1 AND status = 'pending' AND expires_at > NOW() ORDER BY created_at DESC. is_extension drives the row kind. For extension rows, the originating chat_sessions row is joined for mitra info; for initial rows, mitra info is null until pairing happens.

GET /api/client/payment-sessions/pending
→ 200 {
    success: true,
    data: {
      items: [
        {
          id: "pay_…",            // payment_sessions.id
          kind: "initial" | "extension",
          mitra_id: "…" | null,        // populated only for extension rows
          mitra_display_name: "kak Dimas" | null,
          amount: 2500,
          duration_minutes: 30,
          mode: "chat" | "call",
          created_at: "…",
          expires_at: "…"          // already = created_at + payment_session_timeout_minutes
        }
      ],
      total: 1                    // drives the bottom-nav red dot
    }
  }

Service: getCustomerPendingPayments(customerId) (new fn) in payment.service.js. The existing expireStalePaymentSessions sweeper + inline expiry check in confirmPaymentSession already covers the TTL flip; the endpoint just filters expires_at > NOW() defensively in case the sweeper hasn't run yet for a stale row.

10.7.2 Modify: GET /api/client/history → cursor pagination

  • Add cursor (opaque, base64 of ended_at + id) and limit (default 20, max 50) query params.
  • Response shape changes from { items, total, page, limit } to { items, next_cursor, has_more }.
  • total removed; if a future UI needs it, expose a separate /count endpoint.
  • bestie_history_provider is deleted along with the screen — the new selesai_history_provider uses cursor pagination on this endpoint.

10.7.3 Reuse: GET /api/client/chat/session/active-with-unread

No changes. The aktif tab calls this directly.

10.7.4 No new sweeper needed

The existing expireStalePaymentSessions already flips pending → expired past expires_at. The Pembayaran query filters on expires_at > NOW() to handle the gap between TTL expiry and the next sweep tick, so no additional sweeper is needed for Stage 10.

10.8 Mermaid flow update (flow_customer.mermaid.md)

Add a subgraph chat_tab after the home subgraph:

chat_tab
  chat_tab.entry  — tap "💬 chat" in HaloTabBar
  chat_tab.aktif  — active session row → resume chat
  chat_tab.pembayaran  — pending payment row → resume Xendit
  chat_tab.selesai  — past session row → transcript
  chat_tab.empty.{aktif,pembayaran,selesai}  — empty states

Edges:

  • home_* → chat_tab.entry (from any home variant)
  • chat_tab.aktif → S10_chat_room (existing)
  • chat_tab.pembayaran → S7_waiting_payment (Stage 3.5)
  • chat_tab.selesai → S_transcript (existing read-only transcript)

(Wording above is a description — final mermaid syntax added during the implementation commit.)

10.9 Flutter file changes (preview)

  • New: client_app/lib/features/chat_tab/screens/chat_tab_shell.dart — the shared scaffold (heading + sub-tab pills + body slot) rendered by all three sub-tab paths via ShellRoute.
  • New: client_app/lib/features/chat_tab/screens/{aktif_view.dart,pembayaran_view.dart,selesai_view.dart} — the body of each sub-tab.
  • New: client_app/lib/features/chat_tab/widgets/{chat_row.dart,sub_tab_pill.dart}
  • New: client_app/lib/features/chat_tab/providers/{pending_payments_provider.dart,selesai_history_provider.dart}
  • Delete: client_app/lib/features/home/providers/bestie_history_provider.dart
  • Delete: client_app/lib/features/chat/screens/bestie_history_screen.dart (or wherever it lives — confirm during code stage)
  • Modify: client_app/lib/features/home/widgets/halo_tab_bar.dart — change /chat/history/chat; add red-dot rendering driven by pendingPaymentsProvider.total.
  • Modify: client_app/lib/router.dart:
    • Add /chat (redirect → /chat/aktif), /chat/aktif, /chat/pembayaran, /chat/selesai (wrapped in a single ShellRoute).
    • Rename /chat/history/:sessionId/chat/transcript/:sessionId.
    • Remove /chat/history.
  • Modify: any caller that does context.push('/chat/history/$id') for the transcript — grep and update to /chat/transcript/$id.

10.10 Out of scope (this stage)

  • Failed-payment retry from the list. Pembayaran only shows not-yet-paid + not-yet-expired. Failed/expired surface via the existing S6/S7 "pembayaran kedaluwarsa" screen on direct payment-flow re-entry, not the list.
  • Refund / dispute states. No row kind for these.
  • Search / filter in selesai.
  • Concurrent active sessions. Aktif is 0-or-1 by backend constraint.
  • Voice-call as separate sub-tab. Lives in the same list with a Call pill.

10.11 Acceptance for Stage 10

  1. Tapping 💬 chat navigates to /chat, which redirects to /chat/aktif. Direct navigation to /chat/pembayaran or /chat/selesai lands on the correct tab. Tapping a sub-tab pill updates the URL accordingly.
  2. With an active session: row appears in aktif, tap → live chat room with composer focused. Returning to /chat keeps the row visible.
  3. With a pending initial-session payment: row appears in pembayaran with bayar Rp X.XXX chip; tap → Stage 3.5 waiting-payment screen.
  4. With a pending extension payment: row appears in pembayaran with the extension preview copy; tap → extension payment screen.
  5. After 20 minutes without payment: row disappears from pembayaran; the "pembayaran kedaluwarsa" screen shows on re-entry (Stage 3.6 behavior unchanged).
  6. Bottom-nav 💬 chat shows a red dot iff pembayaran total > 0.
  7. aktif sub-tab pill shows unread count when > 0.
  8. pembayaran sub-tab pill shows pending count when > 0.
  9. selesai sub-tab pill shows no badge regardless.
  10. selesai scrolls past 20 items via cursor pagination without duplicates or gaps.
  11. Voice-call sessions render with the 📞 Call pill in both aktif and selesai.
  12. /chat/history is gone from router.dart; /chat/history/:sessionId is renamed to /chat/transcript/:sessionId; no dead inbound context.push references remain.
  13. Maestro: new flow 09_chat_tab.yaml covering aktif → resume, pembayaran → payment, selesai → transcript.
  14. Backend tests cover getCustomerPendingPayments (initial only, extension only, mixed, expired excluded) and the new cursor-paginated getCustomerHistory.

Resolved Decisions (2026-05-09 — recorded from product review)

# Decision
1 OTP stays 6-digit. Figma 4-digit is stylistic only; we keep backend security parity. The visual row of 6 boxes uses tighter spacing to fit the same width.
2 First-session call-mode lock. First sessions are chat-only by default but configurable via app_config.first_session_discount_modes (default ["chat"]; ops can flip to ["chat","call"] to enable).
3 Tanya Admin handles are CC-config-driven. No hard-coded constants — sourced from app_config.support_handles_json, edited via CC.
4 First-session-chat-only behavior is the configurable knob in #2; when pricing goes real, the same knob is reused.
5 ESP is information-only. Tags are persisted on chat_sessions.topics and shown to the mitra at session start. No matching, no routing, no pricing impact. Topic-aware matching is not in any current phase.
6 OTP-blocked popup carries over ESP/USP within the same OTP attempt — user is never re-prompted after falling back to anon. (Cross-session: the values clear on app close.)
7 Voice-call mode is just chat with a different price group + a 📞 Voice Call badge in the chat header. Mitra shares Google Meet (or any) link as a normal chat message. No validation, no in-app call media, no composer helpers — purely ops-handled.
+ No free trial. The previous Phase 3 free-trial concept is removed. Replaced by a configurable first-session discount (PRD §3.5).
+ Social login is server-driven. Backend probes env at boot; client_app reads GET /api/shared/auth-providers and hides Google/Apple buttons when the corresponding keys are not configured. The --dart-define=ENABLE_SOCIAL_AUTH build flag is removed.

Risk Register

Risk Likelihood Mitigation
Font assets bloat APK by ~3 MB high Subset to latin-ext only — drops to ~600 KB; use flutter pub run flutter_font_subset
ThemeData rollout breaks pre-Phase-3.7 screens medium Stage 0 includes a visual diff sweep on existing screens before merging
20-min QRIS polling kills battery low Polling pauses on background; 3s interval is mild
is_free_trialis_first_session_discount migration leaves a service hot reading the dropped column medium Stage 1.2 grep-and-replace pass before migration runs; CI integration test that boots the backend post-migration
First-session-discount eligibility check races with payment commit low Check is server-authoritative on payment-sessions create; client never decides eligibility
S11 thank-you delay feels slow on flaky network low Optimistic close: navigate to S11 immediately, retry close API in background
Two-step end-session feels naggy medium A/B switch via app_config.end_session_two_step_confirm (already in stage 1.5)
Auth-providers cache stale after env change low Module-load probe is fine for prod (restart on env change); in dev, document that backend restart is needed for the flag to flip
Mitra pastes a non-Meet URL in a mode='call' session low Out of scope — ops handles. Phase 4 does no validation. Optional follow-up: detect link missing in last N seconds → mitra-side reminder snackbar (not in plan).

Memory Touchpoints

This phase will likely add or update these memory entries when work begins:

  • project_phase4_status.md — kick-off + progress
  • project_design_system_setup.md — that we now have a tokenized theme
  • project_otp_followups_resolved.md — closes the OTP rate-limit followup item
  • feedback_* — only if a new convention is established (e.g., a Halo* widget naming rule)

Definition of Done (Phase 4)

  1. All PRD acceptance criteria pass on the real-device run.
  2. All 8 Maestro flows green on CI (or local single-emulator run).
  3. flutter analyze clean across client_app.
  4. npm test clean in backend; new endpoints covered.
  5. Figma png/ reference vs. live app: spot-check sweep ≥ 95% visual parity, copy 100% exact.
  6. PRD's "Out of scope" list confirmed not introduced (no rating, no SOS, no subscription).
  7. The 7 open questions above are answered + recorded in this plan (replace the section with "Resolved decisions").