Chat-screen performance (customer + mitra): - Parent screens have zero `ref.watch` — only `ref.listen` for side effects - Body extracted into its own `ConsumerStatefulWidget`; AppBar parts split into narrow `.select` consumers (mode, sensitivity, timer) - Per-second timer ticks routed to dedicated providers (`chatRemainingSecondsProvider` + new `mitraChatRemainingSecondsProvider`) so WS `session_tick` frames don't invalidate the rest of the chat state Dispose-in-ref bug fix: - `home_screen.dart`, `payment_screen.dart`, `mitra_chat_screen.dart` — ref-using cleanup moved from `dispose()` to `deactivate()`. Modern Riverpod invalidates `ref` the moment `dispose()` runs; the resulting silent error corrupts the widget-tree finalize and the next screen appears frozen - `halo_lints` package added at repo root with `no_ref_in_dispose` rule to catch this pattern in CI / IDE analysis - `custom_lint` activated in both apps' `analysis_options.yaml` (was installed but never wired in — also brings `riverpod_lint`'s `avoid_ref_inside_state_dispose` online) - CLAUDE.md Pitfalls section added to client_app + mitra_app Phase 4 §3 retryable blast-failure (Option A): - Backend `expirePairingRequest` + all-rejected use `recordIntermediateFailure` instead of `failPaymentSession` so the payment session stays `confirmed` for re-blast - WS `pairing_failed` payload carries `is_terminal: false` on the retryable paths; client parses the flag and exposes `retryBlast()` - "Coba cari lagi" CTA on S7 Timeout now re-blasts on the same payment - Pairing service test updated to reflect the new semantics Customer waiting-payment screen navigation patch: - `_navigateTerminal` uses `Future.microtask` + `addPostFrameCallback` redundancy after a release-mode bug where polling stopped but `context.go` never fired, leaving the screen visually stuck on "menunggu pembayaran" See requirement/resume-2026-05-15.md for next-day pickup checklist (mitra release rebuild + S21 Ultra install + retest is the gating item). Bundles unrelated in-flight Phase 4 §2.x work that was already on disk (ESP screen removal, USP one-time gate scaffolding, bestie-availability public route, OTP service edits, Maestro flow tweaks) — kept together to avoid a partial-rebase mess. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
13 KiB
ESP Removal + USP One-Time Gate — Implementation & Test Plan
Sub-plan of phase4-customer-flow-plan.md. Source-of-truth diagram: flow_customer.mermaid.md §2. Business decision: 2026-05-12 — retire S5 ESP entirely; show S5b USP at most once per user.
Scope
- Remove the S5 ESP screen + state from
client_app. - Rewire
VerifChoiceSheetto go straight to ausp_seen?gate, then USP, then the original next step (S3a for verified, PickMethod for anon). - Add a
customers.usp_seenboolean to the backend; expose on/api/client/me; addPOST /api/client/usp-seento set it. - Add a Riverpod
uspSeenProviderbacked bySharedPreferenceswith DB sync on login and on dismissal. - Update Maestro flows + add new flows for the gate behaviour.
Out of scope: changing USP copy/visuals; reordering USP relative to OTP (business accepted the cross-device first-view edge case).
Build order
The work splits into 5 ordered stages. Backend lands first so the client has a real endpoint to call.
Stage 1 — Backend: schema + read + write
Files:
backend/src/db/migrate.js— append a Phase-4 ALTER block:ALTER TABLE customers ADD COLUMN IF NOT EXISTS usp_seen BOOLEAN NOT NULL DEFAULT FALSE;backend/src/services/customer.service.js- Add
usp_seentoCUSTOMER_SELECTso every read includes it. - Add
markCustomerUspSeen(customerId)— idempotent UPDATE that setsusp_seen = TRUEand returns the row viaCUSTOMER_SELECT.
- Add
backend/src/routes/public/client.auth.routes.js- Already returns the customer from
getCustomerByIdon/api/client/me. The new column rides along automatically once it's inCUSTOMER_SELECT. - Add
POST /api/client/usp-seenhandler: requires JWT, callsmarkCustomerUspSeen, returns the updated customer. No request body needed.
- Already returns the customer from
Acceptance:
- New customer row has
usp_seen = false. POST /api/client/usp-seenflips it; second POST is a no-op (still returns true)./api/client/meresponse includesusp_seenfor both first-time and returning users.
Stage 2 — client_app: uspSeenProvider + DB hydrate
New file: client_app/lib/features/onboarding/usp_seen_provider.dart
- Async-init
Notifier(orAsyncNotifier) that:- On build, reads SharedPreferences key
usp_seen(default false). - Exposes
bool get hasSeensynchronously after init. Future<void> markSeen():- Write
trueto SharedPreferences. - If JWT is present (authProvider state is
AuthAuthenticatedData), callPOST /api/client/usp-seenviaApiClient— fire-and-forget with logging; don't block UX on the network call.
- Write
- On build, reads SharedPreferences key
- Add
hydrateFromCustomer(Customer c)— call from auth bootstrap (e.g. wherever/api/client/meis fetched and stored inAuthAuthenticatedData). OR-merge: ifc.uspSeen == true, writetrueto SharedPreferences.
Edit: the auth notifier that already calls /api/client/me on app boot — add a call to uspSeenProvider.hydrateFromCustomer(...) after the response lands. (Per Explore: auth_notifier.dart has the AuthAuthenticatedData carrying the profile.)
Acceptance:
- Fresh install: provider returns
false. - After
markSeen(): provider returnstrue; SharedPreferences key set; backend hit (if auth'd). - Login on a fresh device where DB has
usp_seen=true: provider returnstrueafter auth hydrate completes.
Stage 3 — client_app: rewire VerifChoiceSheet → USP gate
Edit: client_app/lib/features/auth/widgets/verif_choice_sheet.dart
- Replace
routeForVerifChoice()body. New logic (pseudo):(Exact target routes per existingfinal seen = ref.read(uspSeenProvider).hasSeen; if (choice == VerifChoice.verifWA) { if (seen) context.push('/auth/register'); // straight to S3a else context.push('/onboarding/verif/usp'); } else { if (seen) context.push('/payment/method-pick'); // straight to PickMethod else context.push('/onboarding/anon/usp'); }router.dartregistrations.)
Edit: client_app/lib/features/onboarding/screens/usp_screen.dart
- On the primary "Continue" / next CTA tap,
await ref.read(uspSeenProvider.notifier).markSeen()BEFORE navigating to the next route. - The existing post-USP routing (verified → S3a, anon → PickMethod) stays — the
markSeen()call just precedes it.
Edit: client_app/lib/features/auth/screens/otp_screen.dart (or wherever the OTP-Blocked popup lives) — the fallback to anon path. Currently it pushes ESP; change to the same USP-gate logic above (uspSeenProvider.hasSeen ? PickMethod : USP).
Acceptance:
- First-time verified flow: VerifChoice "verif WA" → USP (with markSeen on continue) → S3a.
- Second-time verified flow: VerifChoice "verif WA" → S3a directly.
- First-time anon flow: VerifChoice "tanpa verif" → USP → PickMethod.
- Second-time anon flow: VerifChoice "tanpa verif" → PickMethod directly.
- OTP-Blocked fallback respects the gate.
Stage 4 — client_app: delete ESP
This is the cleanup step; it intentionally runs after the new gate is wired so we never have a moment where the build is broken.
Delete:
client_app/lib/features/onboarding/screens/esp_screen.dartclient_app/lib/features/onboarding/esp_state.dart(the twoespSelectionProvider/espSkippedProvider)
Edit: client_app/lib/router.dart
- Remove the two
/onboarding/verif/espand/onboarding/anon/espGoRouteentries. - Leave the
/onboarding/*redirect carve-out intact (USP still uses it).
Verify: flutter analyze clean — no dangling imports, no orphan references to EspTopic, espSelectionProvider, espSkippedProvider.
Stage 5 — Tests
Detailed below in the Test plan section. Updates:
- Existing Maestro flows
02_onboarding_verified.yaml+03_onboarding_anon.yamlneed to drop ESP steps and add gate-aware assertions. - New Vitest cases for the migration + service + route.
- New Maestro flow for the USP-skip-on-second-run case.
Data contract
customers.usp_seen (new column)
- Type:
BOOLEAN NOT NULL DEFAULT FALSE - Set true only on
POST /api/client/usp-seenor via direct backfill. - Never read by anything except
/api/client/meand the dedicated POST handler.
POST /api/client/usp-seen
- Auth: JWT required (same middleware as
/me). - Request: empty body.
- Response: 200 with the updated customer object (same shape as
/me). - Errors: 401 on missing/invalid JWT; 404 if customer row doesn't exist (shouldn't happen in normal flow).
- Idempotent: calling twice is fine.
/api/client/me (extended)
- Existing payload +
usp_seen: boolean.
SharedPreferences key (client)
- Key:
usp_seen - Value:
bool(defaultfalse). - Owned by
uspSeenProvider; no other code reads/writes it directly.
Test plan
Unit tests (Vitest, in backend/test/)
New file: backend/test/customer.usp-seen.test.js
| # | Test | Setup | Assert |
|---|---|---|---|
| 1 | Migration default | Insert customer via createCustomerWithIdentity with no usp_seen |
getCustomerById(...).usp_seen === false |
| 2 | markCustomerUspSeen flips flag |
Customer with usp_seen=false |
After call, row has usp_seen=true; return value's usp_seen is true |
| 3 | Idempotent | Customer with usp_seen=true |
Calling again still returns usp_seen=true; no error |
| 4 | POST /api/client/usp-seen requires auth |
No Authorization header |
401 |
| 5 | POST /api/client/usp-seen happy path |
Authed customer, usp_seen=false |
200, response usp_seen=true, DB row usp_seen=true |
| 6 | /api/client/me includes flag |
Authed customer | Response has usp_seen key (true or false) |
Target: 6/6 green via npm test in backend/.
Flutter widget/integration tests
These are not currently a major surface in this repo (Maestro is the main client-side gate). Skip Flutter-level widget tests unless something breaks.
Maestro flows (client_app/.maestro/flows/)
Existing flow updates:
-
02_onboarding_verified.yaml— first-time-verified path- Remove: any
assertVisible/ tap targets on ESP chips ("Hubungan", "Lewati"). - Update sequence: VerifChoiceSheet → tap "Verif WA Rp2k" → assert USP visible → tap continue → S3a WhatsApp input → ...
- Add at the start:
runScript: scripts/reset_phone_and_local.jsso the run always starts from a clean state (nousp_seenin DB, no SharedPreferences value).
- Remove: any
-
03_onboarding_anon.yaml— first-time-anonymous path- Remove: ESP chip taps / "Lewati" tap.
- Update sequence: VerifChoiceSheet → tap "tanpa verif Rp5k" → assert USP visible → tap continue →
/payment/method-pick. - Add reset script at start.
New flows / deferrals:
The "second-run skip" and "DB hydrate" cases turned out hard to script in Maestro: once a customer logs in (anonymously or with phone), the app is in an authenticated session and the only path back to VerifChoiceSheet (where the gate is consulted) is via logout, which clears local SharedPreferences too. Returning-user CTAs go through BestieChoiceSheet, not VerifChoiceSheet, so the gate is never re-evaluated on the returning path.
What we do have:
- Flows
02and03(first run, USP visible, ESP not visible) — covered above. - Vitest 8/8 covers the backend: column default,
markCustomerUspSeen, route 401/200/403,/mepayload before + after the flag flips.
What stays in manual smoke (operator-driven, documented in the next section):
- Local flag persists across
stopApp/launchApp—adb shell pm clearshould NOT happen between runs; verify USP is skipped on second walk through VerifChoice. - DB hydrate — pre-seed a customer row with
usp_seen=truevia control center or psql, sign in via phone OTP, verify USP is skipped on first ever appearance. - OTP-blocked popup — exit via "lanjut tanpa verif" still lands at
/payment/method-pick. (Pre-USP-gate this was a direct redirect; the gate doesn't fire on this path because USP has already been shown/skipped upstream.)
Known blocker (2026-05-12): flows 02 and 03 had their ESP steps removed and USP gate assertions added, but the runtime can't currently execute them end-to-end. The new SHome1st view (Phase 4 Stage 9) wraps the home column in a single Semantics node, so the "aku mau curhat" CTA's text attribute reads as empty (its label only lives in the parent's merged accessibilityText). Maestro's text: matcher can't locate the button, blocking the entire flow before USP is even reached. This is a Stage-9 accessibility regression, not an ESP/USP issue — the flow YAML edits are correct and will pass once SHome1st's CTA is wrapped in its own Semantics(label: '…', button: true) or given a Key.
Manual smoke (real device)
After Maestro is green on emulator, hand-run on the physical Samsung:
- Fresh install → "aku mau curhat" → name → VerifChoice → verif WA → USP (visible) → S3a → OTP → S6 paywall.
- Force-stop → relaunch → "aku mau curhat" → VerifChoice → verif WA → S3a (USP skipped) → ...
- Same but anon path.
- Uninstall + reinstall → login with same phone → "aku mau curhat" → verify USP skipped (DB hydrate proved end-to-end).
Visual regression
flutter analyze clean. Spot-check VerifChoiceSheet, USP screen, OTP-Blocked popup in the emulator — no broken navigation, no leftover ESP icon/copy.
Rollout & migration
- Migration is additive (NOT NULL DEFAULT FALSE), safe to run on existing DB. All existing customers come out with
usp_seen=false— meaning every returning user will see USP one more time on next "aku mau curhat". Business accepted this. - No backfill needed.
- Cloud Run rollout: backend first (migration runs on boot), then client_app build.
Risks
- Provider init race — if
uspSeenProvideris read before SharedPreferences finishes loading, the gate could return false (default) and show USP unnecessarily. Mitigation: useAsyncNotifierand gate the VerifChoice navigation on the loaded state, or read SharedPreferences synchronously at app boot before the first VerifChoice render. - Network failure on
markSeen()— ifPOST /usp-seenfails after local flag is set, DB stays false. Next session uses local (still true) so user UX is fine. On a new device, USP shows once more. Acceptable per the cross-device edge case decision. - Two-tab race — not applicable; mobile app.
Done criteria
customers.usp_seencolumn exists in dev DB.POST /api/client/usp-seenreturns 200 for authed call./api/client/mepayload includesusp_seen.- Vitest 6/6 green on the new test file.
- No
esp_screen.dart/esp_state.dart//onboarding/*/esproutes in client_app. flutter analyzeclean on client_app.- Maestro:
02,03,04,05,06green on the Client_Phone emulator. - Manual smoke 1–4 above pass on physical device.
TECH_DEBT.md"S5 ESP screen retired" entry closed / removed once cleanup lands.