Phase 4 §2 + §1/§4: OnboardingIntent post-OTP routing + test naming + register-screen overflow

Spec §2 (flow_customer.mermaid) routes post-OTP based on user-lookup +
has_transacted, but the implementation previously dumped every OTP
success on /home. Introduce `OnboardingIntent` provider: set to
`onboarding` by routeForVerifChoice's verified branch (the "aku mau
curhat" transaction journey), set to `recover` by SHome1st's masuk →
banner. Router redirect on AuthAuthenticatedData+isAuthRoute consumes it:
`onboarding` → /payment/entry (dispatches S6 paywall vs PickMethod via
first_session_discount.eligible); `recover` → /home. Intent is reset in
/payment/entry's initState so subsequent masuk → flows don't inherit it.

auth_notifier.verifyOtp uses .copyWithPrevious on AsyncError so
valueOrNull retains AuthOtpSentData/AuthAnonymousData through OTP
failures — required for the OTP-blocked recovery path
(/onboarding/anon/method → /payment/method-pick) to clear the global
redirect without bouncing to /home. Router also extends the
isAuthRoute/isOnboardingFlow carve-out to AuthOtpSentData.

Maestro tests adopt `ts-<app>-<NN>-<MM>-<descriptor>.yaml` convention:
NN = mermaid section, MM = sub-flow index. New ts-customer-02-01..05
cover the §2 branches (verified brand-new → S6, existing-no-tx → S6,
existing-tx → method-pick, OTP-blocked → method-pick, anonymous first-
timer → method-pick); deferred 02-06/07/08/09 documented in
README_section_02.md. TS-07 → ts-customer-02-10 (masuk → recovery);
TS-01..06 → ts-customer-04-01..06 (§4 returning-user). Shared
onboarding_new_user_verified.yaml subflow extracted.

Register screen's body Column now uses LayoutBuilder + SingleChildScrollView
+ ConstrainedBox + IntrinsicHeight so the keyboard-open layout no
longer overflows by 1.3 px (verified visually).

Spec prose updated at flow_customer.mermaid §2 to describe the
intent-driven routing + login-vs-transaction divergence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 21:50:04 +08:00
parent 938954bbe8
commit 093256ff7d
22 changed files with 666 additions and 76 deletions

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'core/auth/auth_notifier.dart';
import 'core/auth/onboarding_intent_provider.dart';
import 'features/auth/screens/display_name_screen.dart';
import 'features/auth/screens/register_screen.dart';
import 'features/auth/screens/otp_screen.dart';
@@ -96,23 +97,47 @@ GoRouter buildRouter(Ref ref) {
if (data == null) {
// Error state — drop onto Home; SHome1st variant handles the
// unauthenticated render (login banner overlay).
// EXCEPTION: /onboarding/* routes are the post-OTP-blocked popup
// fallback path (`/onboarding/anon/method` alias → method-pick).
// They must transit freely even when authProvider is in AsyncError
// (which is how OTP_ATTEMPTS_EXCEEDED leaves the state), otherwise
// the redirect to /home wins over the route-level alias.
if (isOnboardingFlow) return null;
if (!isAuthRoute && !isSplash) return '/home';
if (isSplash) return '/home';
return null;
}
if (data is AuthAuthenticatedData || data is AuthAnonymousData) {
if (data is AuthAuthenticatedData ||
data is AuthAnonymousData ||
data is AuthOtpSentData) {
// Allow the Phase 4 onboarding flow (ESP/USP) to stay put even when
// the user is already anonymous-authenticated — display_name_screen
// intentionally pushes into /onboarding/* after loginAnonymous.
if (isOnboardingFlow) return null;
// While AuthAnonymousData, the user may legitimately be mid-flow on
// /home → /auth/display-name (push) → about to open the Verif Choice
// Sheet. When refreshListenable fires after loginAnonymous resolves,
// GoRouter re-evaluates the bottom of the navigation stack — without
// this carve-out an /auth/* push would be torn down before the sheet
// can open. Allow any auth route to stay put under AuthAnonymousData.
if (data is AuthAnonymousData && isAuthRoute) return null;
// While AuthAnonymousData OR AuthOtpSentData, the user may
// legitimately be mid-flow on /home → /auth/display-name (push) →
// VerifChoice → /auth/register → /auth/otp. When refreshListenable
// fires after loginAnonymous resolves OR after requestOtp returns
// AuthOtpSentData, GoRouter re-evaluates the bottom of the
// navigation stack — without this carve-out an /auth/* push would
// be torn down before the next screen can open.
if ((data is AuthAnonymousData || data is AuthOtpSentData) &&
isAuthRoute) {
return null;
}
// §2 spec north star: when the user reached an auth route from a
// transaction CTA ("aku mau curhat" / "curhat sama bestie baru"),
// post-OTP must land at /payment/entry — which dispatches to S6
// paywall vs PickMethod via `first_session_discount.eligible`. The
// login-recover banner path keeps the default `recover` intent and
// lands on /home (preserves user expectation of seeing history).
if (data is AuthAuthenticatedData && isAuthRoute) {
final intent = ref.read(onboardingIntentProvider);
if (intent == OnboardingIntent.onboarding) {
return '/payment/entry';
}
}
return (isSplash || isAuthRoute) ? '/home' : null;
}
if (data is AuthNeedsDisplayNameData) return '/auth/set-name';