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>
53 KiB
Phase 4 — Implementation Plan
Status (2026-05-18): Stages 0–10 + 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/paymentroute + screen retired.- Maestro suite (commits
e09f76c+93fa5f1): seven flowsts-01..ts-07covering 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_testendpoints 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-timeoutnow branches targeted-vs-blast so the targeted-fail-post-payment path firesRETURNING_CHAT_TIMEOUT(drives the BestieOfflinePopup → fallback-to-blast UX).Older status notes for stages 0–8 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 0–1 are pure groundwork that unblocks everything else; stages 2–8 are feature-shaped and shippable independently; stage 9 is verification.
- Design system foundation — tokens, fonts, ThemeData, reusable widgets (no new screens yet)
- Backend foundation — additive endpoints + config rows
- Onboarding redesign — Verif Choice Sheet, ESP multi-select, USP, OTP-blocked popup
- Payment shell — Pilih cara, Pemilihan harga, Cara bayar (QRIS-first), Waiting Payment, Pembayaran expired
- Notif gate + home banner
- Pairing UX upgrades — Soft-prompt, Searching state, S7 timeout, S9 Match, targeted-wait overlay
- Chat-room countdown UX — 3-min snackbar, last-2-min danger, expired floating banner
- End-of-session sequence — 2-step confirm, closing-message sheet, S11 thank-you
- Returning-user shell — Bestie Choice Sheet, Bestie history visual upgrade, Tanya Admin sheet
- 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 overrideprimary/onPrimary/surface/onSurface/errorto match tokenstextThememapped to the Figma scale (displayLarge→ Bricolage 36/700,titleLarge→ Bricolage 22/700,bodyMedium→ Poppins 15/400, etc.)elevatedButtonThemewith pill radius +HaloShadows.buttoninputDecorationThemematching the 64px-tall S2 Nama inputbottomSheetThemewith 24px top corners + soft shadowsnackBarThemematchingHBSnackbar(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 analyzeclean.flutter runlaunches 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_previewwith a build flag) renders allHalo*widgets — used as a visual reference during stages 2–8.
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_trialwill break — grep + cut over in §1.2. There is no production data to protect (Phase 3.7 was the first ship ofis_free_trialand it never went live in real users perproject_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_idretc. now). - Eligibility logic now reads:
users.phone_verified_at IS NOT NULL(verified user), AND- no
chat_sessionsrow withstatus 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 extendauth.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_eligibleis the AND of:is_phone_verified&&!has_consulted_before&&app_config.first_session_discount_enabled == 'true'.- Drives both
VerifChoiceSheetvisibility 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_jsonandpricing_call_tiers_json(JSON arrays). The discount block reads its four config values and the per-customer eligibility check. first_session_discount.eligibleis per-customer — uses the same predicate asonboarding-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-pricingreturns chat + call groups; eligibility flips when the customer has a completed session.auth-providersreturns{enabled:false}when env vars unset,truewhen set.session_warning3-min ping fires once.
- Migration is idempotent (re-run
migrate.json 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.dartReplaces 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/methodwith theespSelectionProviderand any USP-acknowledged flag preserved (no re-prompt). - Secondary:
hubungi admin→ opens Tanya Admin sheet (built in stage 8; stub a TODOSnackBarfor 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.dartEdit: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 ofbool.fromEnvironment. Or delete this file and inline the check at button render sites.
Logic:
- On app cold start (in
main.dartpost-bootstrap),ref.read(authProvidersProvider.future)once and cache. - Provider exposes
{google: bool, apple: bool, phone: bool}. - Each social button site reads the corresponding flag and renders nothing if
false. - 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.yamlcovers 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.yamlcovers 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 prominentRp{actual_price_idr}. - Subtitle: "untuk {duration} menit ngobrol".
- CTA:
mulai · Rp{actual_price_idr}→ routes to/payment/methodwithpaymentDraft = { 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:
- Calls existing
POST /api/client/payment-sessions(Phase 3.7) with{ mode, duration_minutes, price_idr, is_first_session_discount, method }. - 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_sessionsrow withis_first_session_discount=truegoes topaidvia the existing CC "mark as paid" tool → app advances. - Maestro:
04_payment_expired.yamlexercises the timeout path. - Visual sanity: run
flutter runfor 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→ callsrequest(). 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 pairingretryaction). - ghost
kembali ke home→context.go('/').
- primary
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.dartand 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 Callpill inHaloTokens.accentcolor.mode == 'chat'→ either no pill, or a subtle💬 Chatpill (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.dangerbackground + boldJetBrainsMonotext) and the progress bar color.
6.3 Floating expired banner
- When
remaining == 0and the session is in closing-grace (existing flag — see memoryPhase 3 Session-End Overhaul), injectChatExpiredBannerwidget 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'sexpires_atto 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 home → context.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.yamlcovers 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.yamlcovers 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 +
WelcomeScreenimport +welcome_screen.dartfile all removed. - Router redirects (formerly pointing at
/welcomeforAuthInitialData,AsyncError, and post-onboarding-carousel cases) now point at/home. - The router carve-out comment that referenced
/welcomeas the bottom of the navigation stack updated to reference/home. - Stage 2.6 of this plan is stale: it described editing
welcome_screen.dartto readauthProvidersProvider; that screen no longer exists. TheauthProvidersProvideritself 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'tAuthAuthenticatedData/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 baruprimary CTA, "curhatan sebelumnya" history section (live data viabestieHistoryProvider), bottom 4-tabHBTabBarfooter (home / chat / kamu / premium SOON — only home + chat wired). _NotifDeniedBanner(Stage 4) preserved at the top._ActiveSessionCardpreserved on SHomeReturning so a user mid-session can rejoin (not in Figma §1 but a hard UX requirement).- Material
AppBarremoved — the Figma layout has none. Logout will land on thekamutab when that's built.
C.3 Onboarding carousel destination fixed
OnboardingScreen._finish()now navigates to/homeinstead 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 (deleteOnboardingScreen+onboardingDoneProvider+ the/onboardingroute + the gate at the top ofrouter.dart) is a follow-up.
C.4 Defensive variant gate
HomeScreennow treats anything that is notAuthAuthenticatedDataorAuthAnonymousDataas "fresh" → rendersSHome1st. This avoids the unauthenticated-but-erroring user seeinghalo, 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:
SChatListin requirement/Figma/screens/extras.jsx (line 22+). Not yet inflow_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 visualsS_pembayaran_kedaluwarsa(same file, ~line 600) — expired-payment full screen. Copy says 20 menit, see §10.6.- Item visuals:
HBOrb(avatar) + optional greensuccess-color live dot; name (who) bold; preview text muted; right-aligned timestamp (● livewhen active); below-preview chips:bayar Rp X.XXXchip (amber) onpembayaranitemsX menitduration suffix onselesaiitems
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:
HaloTabBarchattabonTap:/chat/history→/chat(which then redirects to/chat/aktif).- Old
/chat/historyroute +bestie_history_screen.dart+bestie_history_provider.dartdeleted. /chat/history/:sessionId(read-only transcript) renamed to/chat/transcript/:sessionIdso no route lives under the retired/chat/historyparent. All inboundcontext.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 viaactiveSessionProvider). 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
- Initial-session:
- Amber
bayar Rp X.XXXchip 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
- 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. - Voice-call sessions live in the same list with a Call pill. Per memory
project_phase4_chat_ux_improvementsand Stage 6.0. - Pembayaran TTL reuses existing
payment_session_timeout_minutes. Payment is still mocked (per memoryproject_pricing_still_mocked_3_7); real Xendit is not wired yet. Theapp_config.payment_session_timeout_minutesrow (default20) already drivesexpires_atonpayment_sessionsrows viacreatePaymentSession. 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. - 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 ofended_at + id) andlimit(default 20, max 50) query params. - Response shape changes from
{ items, total, page, limit }to{ items, next_cursor, has_more }. totalremoved; if a future UI needs it, expose a separate/countendpoint.bestie_history_provideris deleted along with the screen — the newselesai_history_provideruses 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 viaShellRoute. - 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 bypendingPaymentsProvider.total. - Modify:
client_app/lib/router.dart:- Add
/chat(redirect →/chat/aktif),/chat/aktif,/chat/pembayaran,/chat/selesai(wrapped in a singleShellRoute). - Rename
/chat/history/:sessionId→/chat/transcript/:sessionId. - Remove
/chat/history.
- Add
- 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
- Tapping
💬 chatnavigates to/chat, which redirects to/chat/aktif. Direct navigation to/chat/pembayaranor/chat/selesailands on the correct tab. Tapping a sub-tab pill updates the URL accordingly. - With an active session: row appears in
aktif, tap → live chat room with composer focused. Returning to/chatkeeps the row visible. - With a pending initial-session payment: row appears in
pembayaranwithbayar Rp X.XXXchip; tap → Stage 3.5 waiting-payment screen. - With a pending extension payment: row appears in
pembayaranwith the extension preview copy; tap → extension payment screen. - After 20 minutes without payment: row disappears from
pembayaran; the "pembayaran kedaluwarsa" screen shows on re-entry (Stage 3.6 behavior unchanged). - Bottom-nav
💬 chatshows a red dot iffpembayarantotal > 0. aktifsub-tab pill shows unread count when > 0.pembayaransub-tab pill shows pending count when > 0.selesaisub-tab pill shows no badge regardless.selesaiscrolls past 20 items via cursor pagination without duplicates or gaps.- Voice-call sessions render with the 📞 Call pill in both
aktifandselesai. /chat/historyis gone fromrouter.dart;/chat/history/:sessionIdis renamed to/chat/transcript/:sessionId; no dead inboundcontext.pushreferences remain.- Maestro: new flow
09_chat_tab.yamlcovering aktif → resume, pembayaran → payment, selesai → transcript. - Backend tests cover
getCustomerPendingPayments(initial only, extension only, mixed, expired excluded) and the new cursor-paginatedgetCustomerHistory.
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_trial → is_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 + progressproject_design_system_setup.md— that we now have a tokenized themeproject_otp_followups_resolved.md— closes the OTP rate-limit followup itemfeedback_*— only if a new convention is established (e.g., a Halo* widget naming rule)
Definition of Done (Phase 4)
- All PRD acceptance criteria pass on the real-device run.
- All 8 Maestro flows green on CI (or local single-emulator run).
flutter analyzeclean across client_app.npm testclean in backend; new endpoints covered.- Figma
png/reference vs. live app: spot-check sweep ≥ 95% visual parity, copy 100% exact. - PRD's "Out of scope" list confirmed not introduced (no rating, no SOS, no subscription).
- The 7 open questions above are answered + recorded in this plan (replace the section with "Resolved decisions").