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

@@ -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),

View File

@@ -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:

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 '../../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,

View File

@@ -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;
});
}