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:
@@ -241,6 +241,12 @@ class Auth extends _$Auth {
|
||||
}
|
||||
|
||||
Future<void> verifyOtp(String otpRequestId, String code) async {
|
||||
// Preserve the prior auth data (typically AuthAnonymousData from the
|
||||
// pre-OTP loginAnonymous) so AsyncError keeps `valueOrNull` non-null.
|
||||
// The router uses valueOrNull to gate redirects — a wipe-to-null on
|
||||
// OTP failure would bounce the OTP-blocked recovery path
|
||||
// (/onboarding/anon/method → /payment/method-pick) to /home.
|
||||
final previous = state;
|
||||
state = const AsyncLoading();
|
||||
try {
|
||||
// Bearer is attached automatically by ApiClient from AuthBridge — when
|
||||
@@ -259,12 +265,13 @@ class Auth extends _$Auth {
|
||||
final profile = await _applyTokens(response);
|
||||
state = AsyncData(await _stateForProfile(profile));
|
||||
} on DioException catch (e) {
|
||||
state = AsyncError(_otpVerifyErrorInfo(e), StackTrace.current);
|
||||
state = AsyncError<AuthData>(_otpVerifyErrorInfo(e), StackTrace.current)
|
||||
.copyWithPrevious(previous);
|
||||
} catch (_) {
|
||||
state = AsyncError(
|
||||
state = AsyncError<AuthData>(
|
||||
const AuthErrorInfo('Gagal verifikasi. Coba lagi.'),
|
||||
StackTrace.current,
|
||||
);
|
||||
).copyWithPrevious(previous);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
20
client_app/lib/core/auth/onboarding_intent_provider.dart
Normal file
20
client_app/lib/core/auth/onboarding_intent_provider.dart
Normal file
@@ -0,0 +1,20 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// Tracks where the user came from when they entered an auth flow.
|
||||
///
|
||||
/// `onboarding` is set when the user taps a transaction CTA ("aku mau
|
||||
/// curhat" / "curhat sama bestie baru") that drives them into the §2
|
||||
/// New-User Onboarding journey. Post-OTP, the router consumes this and
|
||||
/// pushes /payment/entry (which dispatches S6 paywall vs PickMethod via
|
||||
/// `first_session_discount.eligible`).
|
||||
///
|
||||
/// `recover` (default) is the SHome1st "masuk →" login-recover banner
|
||||
/// path — the spec doesn't route this through /payment/entry; the user
|
||||
/// expects to land on /home with their chat history.
|
||||
///
|
||||
/// Spec ref: requirement/flow_customer.mermaid.md §2 (`UserLookup → S6
|
||||
/// or PickMethod`).
|
||||
enum OnboardingIntent { recover, onboarding }
|
||||
|
||||
final onboardingIntentProvider =
|
||||
StateProvider<OnboardingIntent>((ref) => OnboardingIntent.recover);
|
||||
@@ -45,7 +45,8 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_phoneController.addListener(() => setState(() {}));
|
||||
_authSub = ref.listenManual<AsyncValue<AuthData>>(authProvider, (prev, next) {
|
||||
_authSub =
|
||||
ref.listenManual<AsyncValue<AuthData>>(authProvider, (prev, next) {
|
||||
if (!mounted) return;
|
||||
final data = next.valueOrNull;
|
||||
if (data is AuthOtpSentData) {
|
||||
@@ -106,7 +107,8 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
||||
String _greetingName(AuthData? data) => switch (data) {
|
||||
AuthAnonymousData d => d.displayName,
|
||||
AuthAuthenticatedData d => (d.profile['display_name'] as String?) ?? '',
|
||||
AuthNeedsDisplayNameData d => (d.profile['display_name'] as String?) ?? '',
|
||||
AuthNeedsDisplayNameData d =>
|
||||
(d.profile['display_name'] as String?) ?? '',
|
||||
_ => '',
|
||||
};
|
||||
|
||||
@@ -134,53 +136,63 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
||||
child: HaloStepDots(total: 4, current: 3),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'nomor wa-mu, $shownName?',
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: HaloTokens.brandDark,
|
||||
height: 1.15,
|
||||
letterSpacing: -0.56,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const Text(
|
||||
'supaya bisa lanjut kapan aja, dan dapat harga khusus pengguna baru.',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 14.5,
|
||||
color: HaloTokens.inkSoft,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_PhoneRow(
|
||||
controller: _phoneController,
|
||||
borderColor: hasMinDigits
|
||||
? HaloTokens.brand
|
||||
: HaloTokens.border,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const _PrivacyCard(),
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
color: HaloTokens.danger,
|
||||
fontSize: 13,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) => SingleChildScrollView(
|
||||
child: ConstrainedBox(
|
||||
constraints:
|
||||
BoxConstraints(minHeight: constraints.maxHeight),
|
||||
child: IntrinsicHeight(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'nomor wa-mu, $shownName?',
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: HaloTokens.brandDark,
|
||||
height: 1.15,
|
||||
letterSpacing: -0.56,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const Text(
|
||||
'supaya bisa lanjut kapan aja, dan dapat harga khusus pengguna baru.',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 14.5,
|
||||
color: HaloTokens.inkSoft,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_PhoneRow(
|
||||
controller: _phoneController,
|
||||
borderColor: hasMinDigits
|
||||
? HaloTokens.brand
|
||||
: HaloTokens.border,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const _PrivacyCard(),
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
color: HaloTokens.danger,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
HaloButton(
|
||||
@@ -191,9 +203,8 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
||||
: 'kirim kode',
|
||||
fullWidth: true,
|
||||
onPressed: canSubmit
|
||||
? () => ref
|
||||
.read(authProvider.notifier)
|
||||
.requestOtp(_e164Phone())
|
||||
? () =>
|
||||
ref.read(authProvider.notifier).requestOtp(_e164Phone())
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/auth/onboarding_intent_provider.dart';
|
||||
import '../../../core/theme/halo_tokens.dart';
|
||||
import '../../../core/theme/widgets/widgets.dart';
|
||||
import '../../onboarding/usp_seen_provider.dart';
|
||||
@@ -81,6 +82,10 @@ Future<void> routeForVerifChoice(
|
||||
if (!context.mounted) return;
|
||||
switch (choice) {
|
||||
case VerifChoice.verified:
|
||||
// §2 transaction CTA path — router consumes this post-OTP and routes
|
||||
// to /payment/entry (S6 paywall vs PickMethod via first_session_discount).
|
||||
ref.read(onboardingIntentProvider.notifier).state =
|
||||
OnboardingIntent.onboarding;
|
||||
context.push(seen ? '/auth/register' : '/onboarding/verif/usp');
|
||||
break;
|
||||
case VerifChoice.anonymous:
|
||||
|
||||
@@ -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 '../../core/availability/mitra_availability_notifier.dart';
|
||||
import '../../core/chat/active_session_notifier.dart';
|
||||
import '../../core/notifications/notif_permission.dart';
|
||||
@@ -198,11 +199,11 @@ class _SHome1stView extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _LoginRecoverBanner extends StatelessWidget {
|
||||
class _LoginRecoverBanner extends ConsumerWidget {
|
||||
const _LoginRecoverBanner();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: Material(
|
||||
@@ -210,7 +211,14 @@ class _LoginRecoverBanner extends StatelessWidget {
|
||||
borderRadius: HaloRadius.md,
|
||||
child: InkWell(
|
||||
borderRadius: HaloRadius.md,
|
||||
onTap: () => context.push('/auth/register'),
|
||||
onTap: () {
|
||||
// Recovery flow — post-OTP should land on /home (the user wants
|
||||
// their history), NOT /payment/entry. Defensive reset in case a
|
||||
// prior onboarding run left the intent dirty.
|
||||
ref.read(onboardingIntentProvider.notifier).state =
|
||||
OnboardingIntent.recover;
|
||||
context.push('/auth/register');
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
@@ -786,11 +794,13 @@ class _NotifDeniedBanner extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: HaloTokens.brandDark,
|
||||
side: const BorderSide(color: HaloTokens.brandDark, width: 1),
|
||||
shape: const StadiumBorder(),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: HaloSpacing.s8,
|
||||
horizontal: HaloSpacing.s12,
|
||||
),
|
||||
minimumSize: const Size(0, 32),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/auth/onboarding_intent_provider.dart';
|
||||
import '../../../core/chat/chat_opening_provider.dart';
|
||||
import '../../../core/theme/halo_tokens.dart';
|
||||
import '../state/payment_draft_provider.dart';
|
||||
@@ -31,6 +32,12 @@ class _PaymentEntryScreenState extends ConsumerState<PaymentEntryScreen> {
|
||||
// reset() would wipe targetedMitraId and silently downgrade the
|
||||
// returning-targeted flow to a blast.
|
||||
ref.read(paymentDraftNotifierProvider.notifier).resetExceptTarget();
|
||||
// Consume the onboarding intent — landing here means the router-level
|
||||
// post-OTP redirect has fired (or the user navigated in via another
|
||||
// CTA). Reset to default so a later masuk → recovery flow doesn't
|
||||
// inherit a stale onboarding intent.
|
||||
ref.read(onboardingIntentProvider.notifier).state =
|
||||
OnboardingIntent.recover;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user