Phase 4 Stage 2: onboarding redesign (client_app + mitra_app)

Verif Choice Sheet on display_name_screen drives the user into either
the verified or anonymous onboarding sub-flow. ESP screen (12 chips,
multi-select, info-only) + USP screen are shared between both branches;
selections persist through to chat_sessions.topics on session start.

OTP-blocked popup (HaloPopup) listens for the four real OTP-rate-limit
error codes (OTP_RATE_LIMIT_PHONE, OTP_RATE_LIMIT_IP, OTP_COOLDOWN,
OTP_ATTEMPTS_EXCEEDED) and drops the user onto the anonymous path with
ESP/USP state preserved.

Auth-providers gating replaces the --dart-define=ENABLE_SOCIAL_AUTH
build flag with server-driven discovery. authProvidersProvider preloads
GET /api/shared/auth-providers at cold start; welcome/register/
force-register screens render Google/Apple buttons only when the
backend reports enabled:true. Falls back to phone-OTP-only when both
providers are off. social_auth_enabled.dart deleted; client_app/CLAUDE.md
updated to reflect the new gating contract.

Mitra app: chat screen renders an ESP chip strip above the first message
bubble when chat_sessions.topics is non-empty.

Backend session.service.js getSessionById SELECTs cs.topics so the mitra
side can read the customer's selected topics.

Maestro flows 02_onboarding_verified.yaml + 03_onboarding_anon.yaml.

Deviation from plan: plan referenced OTP error code 'otp_retry_exhausted';
real codes are OTP_RATE_LIMIT_*/OTP_COOLDOWN/OTP_ATTEMPTS_EXCEEDED -
popup listens for all four. Plan said 'has_paid_first_session'; live
endpoint returns 'has_consulted_before' - used the live field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 16:23:57 +08:00
parent 4680c36e34
commit 2645bcd0e5
25 changed files with 1282 additions and 189 deletions

View File

@@ -0,0 +1,115 @@
# Phase 4 Stage 2 — verified onboarding path:
# Splash → onboarding carousel → Welcome → Display Name → Verif Choice Sheet
# (verifikasi nomor HP) → ESP (pick a chip) → USP → Register → OTP (6-digit)
# → S6 paywall (when first-session-discount eligible) or duration picker.
#
# Run:
# maestro test client_app/.maestro/flows/02_onboarding_verified.yaml
#
# Pre-reqs: client_app debug APK installed, backend reachable at
# BACKEND_URL/BACKEND_INTERNAL_URL, NODE_ENV != 'production' (so the
# /internal/_test/peek-otp + /reset-phone routes register), and
# `anonymity_enabled = true` in the dev DB so the verif choice sheet shows.
#
# NOTE: numeric prefix conflicts with the existing
# 02_cta_disabled_when_no_mitra.yaml — Stage 9 will reorganize the flow
# directory once the full Phase 4 suite lands.
appId: com.halobestie.client.client_app
env:
TEST_PHONE: "+628155557701"
BACKEND_INTERNAL_URL: http://localhost:3001
---
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
- extendedWaitUntil:
visible:
text: "Mulai"
timeout: 15000
- tapOn:
text: "Mulai"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "Lanjut sebagai Tamu"
timeout: 10000
- tapOn:
text: "Lanjut sebagai Tamu"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "Nama panggilan"
timeout: 10000
- tapOn:
text: "Nama panggilan"
- inputText: "Maestro"
- hideKeyboard
- tapOn:
text: "lanjut"
retryTapIfNoChange: true
# Verif Choice Sheet
- extendedWaitUntil:
visible:
text: "verifikasi nomor HP"
timeout: 10000
- tapOn:
text: "verifikasi nomor HP"
retryTapIfNoChange: true
# ESP screen — pick at least one chip then tap "lanjut"
- extendedWaitUntil:
visible:
text: "Lagi mikirin apa?"
timeout: 10000
- tapOn:
text: "Hubungan"
- tapOn:
text: "lanjut"
retryTapIfNoChange: true
# USP screen
- extendedWaitUntil:
visible:
text: "Sebelum mulai"
timeout: 10000
- tapOn:
text: "aku ngerti, lanjut"
retryTapIfNoChange: true
# Register (S3a) — phone entry
- extendedWaitUntil:
visible:
text: "Nomor HP"
timeout: 10000
- tapOn:
text: "Nomor HP"
- inputText: ${TEST_PHONE}
- hideKeyboard
- tapOn:
text: "kirim OTP"
retryTapIfNoChange: true
# OTP screen (S3b)
- extendedWaitUntil:
visible:
text: "Masukkan OTP"
timeout: 15000
- runScript:
file: ../scripts/peek_otp.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- inputText: ${output.OTP}
# Verified path: first-session-discount eligible customers land on the S6
# paywall; non-eligibles land on the duration picker. Either is acceptable
# arrival for this flow.
- extendedWaitUntil:
visible:
text: "Masukkan OTP"
timeout: 15000
notVisible: true
- extendedWaitUntil:
visible:
text: "harga sesi pertama"
timeout: 15000
optional: true

View File

@@ -0,0 +1,71 @@
# Phase 4 Stage 2 — anonymous onboarding path:
# Splash → onboarding carousel → Welcome → Display Name → Verif Choice Sheet
# (curhat anonim) → ESP → USP → arrival at /payment/method-pick (Stage 3
# owns the screen body; this flow stops at route arrival).
#
# Run:
# maestro test client_app/.maestro/flows/03_onboarding_anon.yaml
#
# Pre-reqs: same as 02_onboarding_verified.yaml.
#
# NOTE: numeric prefix conflicts with the existing 03_payment_to_chat_happy.yaml
# — Stage 9 will reorganize the flow directory once the full Phase 4 suite lands.
appId: com.halobestie.client.client_app
---
- launchApp:
clearState: true
- extendedWaitUntil:
visible:
text: "Mulai"
timeout: 15000
- tapOn:
text: "Mulai"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "Lanjut sebagai Tamu"
timeout: 10000
- tapOn:
text: "Lanjut sebagai Tamu"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "Nama panggilan"
timeout: 10000
- tapOn:
text: "Nama panggilan"
- inputText: "Maestro"
- hideKeyboard
- tapOn:
text: "lanjut"
retryTapIfNoChange: true
# Verif Choice Sheet — pick anonymous branch
- extendedWaitUntil:
visible:
text: "curhat anonim"
timeout: 10000
- tapOn:
text: "curhat anonim"
retryTapIfNoChange: true
# ESP screen — leave empty + tap lewati to exercise the skip path
- extendedWaitUntil:
visible:
text: "Lagi mikirin apa?"
timeout: 10000
- tapOn:
text: "lewati"
retryTapIfNoChange: true
# USP screen
- extendedWaitUntil:
visible:
text: "Sebelum mulai"
timeout: 10000
- tapOn:
text: "aku ngerti, lanjut"
retryTapIfNoChange: true
# Stage 3 owns /payment/method-pick — arrival is the success signal.
- extendedWaitUntil:
visible:
text: "Sebelum mulai"
timeout: 10000
notVisible: true

View File

@@ -9,7 +9,7 @@ Flutter mobile application for end users (clients) seeking mental health support
- **Framework:** Flutter (iOS + Android)
- **Auth:** Self-managed (Phase 3.4). Anonymous-first + phone OTP + (Google / Apple when creds arrive).
- Access token in memory on `AuthBridge`; refresh token persisted via `flutter_secure_storage`.
- Google + Apple SDKs installed but buttons are hidden behind `--dart-define=ENABLE_SOCIAL_AUTH=true` until backend OAuth credentials exist.
- Google + Apple SDKs installed; buttons are gated server-side via `GET /api/shared/auth-providers` (cached on cold start in `authProvidersProvider`). Buttons render only when the corresponding env-driven flag returns `enabled: true`.
- `firebase_auth` removed; `firebase_messaging` kept for FCM push.
- **API:** Calls public Fastify backend (`/api/client/` and `/api/shared/` routes). Refresh + logout live on `shared.auth`.
- **Payment:** Xendit (paid sessions, optional trial)
@@ -25,4 +25,4 @@ Flutter mobile application for end users (clients) seeking mental health support
- Never call `/api/mitra/` or `/internal/` routes from this app
- API calls go through `ApiClient`; it auto-attaches the JWT from `AuthBridge` and auto-refreshes on 401
- WebSocket handshake (`/api/shared/ws`) reads the access token from `AuthBridge` in the first frame's `{type:"auth", token, session_id?}` message
- Use `const bool.fromEnvironment('ENABLE_SOCIAL_AUTH')` (via `social_auth_enabled.dart`) to gate any Google/Apple UI — never call `loginGoogle` / `loginApple` from a path reachable without that flag
- Read `authProvidersProvider` (`core/auth/auth_providers_provider.dart`) to gate any Google/Apple UI — never call `loginGoogle` / `loginApple` from a path reachable when `providers.google` / `providers.apple` is `false`

View File

@@ -0,0 +1,51 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../api/api_client_provider.dart';
part 'auth_providers_provider.g.dart';
class AuthProvidersConfig {
final bool google;
final bool apple;
final bool phone;
const AuthProvidersConfig({
required this.google,
required this.apple,
required this.phone,
});
/// Conservative fallback used when the network probe fails. Phone OTP is
/// always available; social sign-in is hidden until the backend confirms.
static const fallback = AuthProvidersConfig(
google: false,
apple: false,
phone: true,
);
bool get hasAnySocial => google || apple;
}
/// Cached server-driven flag set for which auth entry points are wired up.
///
/// Replaces the old `--dart-define=ENABLE_SOCIAL_AUTH` build flag: the client
/// now reads `GET /api/shared/auth-providers` once on cold start and hides
/// Google/Apple buttons when the corresponding flag is `false`.
@Riverpod(keepAlive: true)
Future<AuthProvidersConfig> authProviders(Ref ref) async {
try {
final response = await ref.read(apiClientProvider).get('/api/shared/auth-providers');
final data = response['data'] as Map<String, dynamic>?;
if (data == null) return AuthProvidersConfig.fallback;
final google = data['google'] as Map<String, dynamic>?;
final apple = data['apple'] as Map<String, dynamic>?;
final phone = data['phone'] as Map<String, dynamic>?;
return AuthProvidersConfig(
google: (google?['enabled'] as bool?) ?? false,
apple: (apple?['enabled'] as bool?) ?? false,
phone: (phone?['enabled'] as bool?) ?? true,
);
} catch (_) {
return AuthProvidersConfig.fallback;
}
}

View File

@@ -0,0 +1,33 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'auth_providers_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$authProvidersHash() => r'cadec65217f3280bbd1b36568eefb93a7fcdd6f9';
/// Cached server-driven flag set for which auth entry points are wired up.
///
/// Replaces the old `--dart-define=ENABLE_SOCIAL_AUTH` build flag: the client
/// now reads `GET /api/shared/auth-providers` once on cold start and hides
/// Google/Apple buttons when the corresponding flag is `false`.
///
/// Copied from [authProviders].
@ProviderFor(authProviders)
final authProvidersProvider = FutureProvider<AuthProvidersConfig>.internal(
authProviders,
name: r'authProvidersProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$authProvidersHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef AuthProvidersRef = FutureProviderRef<AuthProvidersConfig>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -1,7 +0,0 @@
/// Build-time flag controlling whether Google / Apple sign-in buttons
/// are shown. Default: false until backend OAuth credentials are
/// provisioned. Enable with `--dart-define=ENABLE_SOCIAL_AUTH=true`.
const bool kSocialAuthEnabled = bool.fromEnvironment(
'ENABLE_SOCIAL_AUTH',
defaultValue: false,
);

View File

@@ -6,9 +6,9 @@ part of 'mitra_availability_notifier.dart';
// RiverpodGenerator
// **************************************************************************
String _$mitraAvailabilityHash() => r'7429862ccffbae1fc7bbe7368359e1624fe28ec9';
String _$mitraAvailabilityHash() => r'036de8fea66c6a0114abd4a422af12186c1447d7';
/// Phase 3.7 §1: customer-home availability poll.
/// Customer-home availability poll.
///
/// Polls `GET /api/client/mitra-availability` every 5 seconds while the home
/// screen is in the foreground. Polling is gated by the home screen calling
@@ -16,10 +16,10 @@ String _$mitraAvailabilityHash() => r'7429862ccffbae1fc7bbe7368359e1624fe28ec9';
/// - resumed → setActive(true)
/// - paused/inactive → setActive(false)
///
/// On any HTTP error we emit `false` (PRD §1.3: never display stale state).
/// On any HTTP error we emit `false` (never display stale state).
///
/// The endpoint also returns a `count`, but per PRD §1.3 the customer UI must
/// only read the binary `available` field — the count is for CC/debug only.
/// The endpoint also returns a `count`, but the customer UI must only read the
/// binary `available` field — the count is for CC/debug only.
///
/// Copied from [MitraAvailability].
@ProviderFor(MitraAvailability)

View File

@@ -6,7 +6,7 @@ part of 'session_closure_notifier.dart';
// RiverpodGenerator
// **************************************************************************
String _$sessionClosureHash() => r'f238e4098aefdd942314a13eb3fb77ed19cd2b77';
String _$sessionClosureHash() => r'521e57f74805faf01f11778872cb37ceae683a5b';
/// See also [SessionClosure].
@ProviderFor(SessionClosure)

View File

@@ -6,7 +6,7 @@ part of 'pairing_notifier.dart';
// RiverpodGenerator
// **************************************************************************
String _$pairingHash() => r'0fe1e5646fe70b90f5489919ec8dd557c998daad';
String _$pairingHash() => r'd39980fe5b82348d03485006d0534ab597b93ceb';
/// See also [Pairing].
@ProviderFor(Pairing)

View File

@@ -1,6 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/api/api_client_provider.dart';
import '../../../core/auth/auth_notifier.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart';
import '../widgets/verif_choice_sheet.dart';
class DisplayNameScreen extends ConsumerStatefulWidget {
const DisplayNameScreen({super.key});
@@ -11,9 +16,33 @@ class DisplayNameScreen extends ConsumerStatefulWidget {
class _DisplayNameScreenState extends ConsumerState<DisplayNameScreen> {
final _controller = TextEditingController();
ProviderSubscription<AsyncValue<AuthData>>? _authSub;
String? _errorMessage;
bool _routedAfterLogin = false;
@override
void initState() {
super.initState();
// Listener registered once in initState (see feedback_riverpod_listen_in_build).
// We need to react to auth state changes once the anonymous login resolves
// to drive the post-name onboarding fork.
_authSub = ref.listenManual<AsyncValue<AuthData>>(authProvider, (prev, next) {
if (!mounted) return;
if (next is AsyncError) {
setState(() => _errorMessage = next.error.toString());
return;
}
final data = next.valueOrNull;
if (data is AuthAnonymousData && !_routedAfterLogin) {
_routedAfterLogin = true;
_proceedAfterLogin();
}
});
}
@override
void dispose() {
_authSub?.close();
_controller.dispose();
super.dispose();
}
@@ -21,46 +50,99 @@ class _DisplayNameScreenState extends ConsumerState<DisplayNameScreen> {
void _submit() {
final name = _controller.text.trim();
if (name.isEmpty) return;
setState(() => _errorMessage = null);
ref.read(authProvider.notifier).loginAnonymous(name);
}
/// After an anonymous login succeeds, decide where to send the user.
///
/// 1. Read `/api/client/onboarding-state`. If `has_consulted_before`, the
/// user is a returning customer — skip the onboarding sequence and
/// jump straight to the duration picker (Stage 3 owns that route).
/// 2. Otherwise show the Verif Choice Sheet and route based on the picked
/// branch.
Future<void> _proceedAfterLogin() async {
bool hasConsultedBefore = false;
try {
final response =
await ref.read(apiClientProvider).get('/api/client/onboarding-state');
final data = response['data'] as Map<String, dynamic>?;
hasConsultedBefore =
(data?['has_consulted_before'] as bool?) ?? false;
} catch (_) {
// Treat as first-time on failure — safer to over-collect onboarding
// info than to silently strand a returning user.
}
if (!mounted) return;
if (hasConsultedBefore) {
// TODO(stage3): Stage 3 will own /payment/duration-pick — for now
// route there as a placeholder so returning users can continue.
context.go('/payment/duration-pick');
return;
}
final choice = await VerifChoiceSheet.show(context);
if (!mounted || choice == null) {
// User dismissed the sheet — let them tap Lanjut again to retry.
_routedAfterLogin = false;
return;
}
if (!mounted) return;
routeForVerifChoice(context, choice);
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
final isLoading = authState is AsyncLoading;
ref.listen(authProvider, (prev, next) {
if (next is AsyncError) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString())));
}
});
return Scaffold(
appBar: AppBar(title: const Text('Siapa namamu?')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('Pilih nama yang ingin kamu gunakan. Nama ini tidak akan terlihat oleh siapapun selain mitra kamu.'),
const SizedBox(height: 24),
TextField(
controller: _controller,
decoration: const InputDecoration(
labelText: 'Nama panggilan',
border: OutlineInputBorder(),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(HaloSpacing.s24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Pilih nama yang ingin kamu gunakan. Nama ini akan terlihat oleh bestie kamu.',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 15,
height: 22 / 15,
color: HaloTokens.inkSoft,
),
),
textInputAction: TextInputAction.done,
onSubmitted: (_) => _submit(),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: isLoading ? null : _submit,
child: isLoading
? const CircularProgressIndicator()
: const Text('Lanjut'),
),
],
const SizedBox(height: HaloSpacing.s24),
TextField(
controller: _controller,
decoration: const InputDecoration(
labelText: 'Nama panggilan',
),
textInputAction: TextInputAction.done,
onSubmitted: (_) => _submit(),
),
if (_errorMessage != null) ...[
const SizedBox(height: HaloSpacing.s12),
Text(
_errorMessage!,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
color: HaloTokens.danger,
fontSize: 13,
),
),
],
const SizedBox(height: HaloSpacing.s24),
HaloButton(
label: isLoading ? 'memproses...' : 'lanjut',
fullWidth: true,
onPressed: isLoading ? null : _submit,
),
],
),
),
),
);

View File

@@ -2,7 +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/social_auth_enabled.dart';
import '../../../core/auth/auth_providers_provider.dart';
/// Shown when anonymity is disabled by admin.
/// User must identify themselves (phone OTP / Google / Apple).
@@ -28,6 +28,9 @@ class _ForceRegisterScreenState extends ConsumerState<ForceRegisterScreen> {
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
final isLoading = authState is AsyncLoading;
final providersAsync = ref.watch(authProvidersProvider);
final providers =
providersAsync.valueOrNull ?? AuthProvidersConfig.fallback;
ref.listen(authProvider, (prev, next) {
final data = next.valueOrNull;
@@ -51,20 +54,24 @@ class _ForceRegisterScreenState extends ConsumerState<ForceRegisterScreen> {
style: TextStyle(fontSize: 16),
),
const SizedBox(height: 24),
if (kSocialAuthEnabled) ...[
ElevatedButton.icon(
icon: const Icon(Icons.g_mobiledata),
onPressed: isLoading ? null
: () => ref.read(authProvider.notifier).loginGoogle(),
label: const Text('Lanjut dengan Google'),
),
const SizedBox(height: 12),
ElevatedButton.icon(
icon: const Icon(Icons.apple),
onPressed: isLoading ? null
: () => ref.read(authProvider.notifier).loginApple(),
label: const Text('Lanjut dengan Apple'),
),
if (providers.hasAnySocial) ...[
if (providers.google) ...[
ElevatedButton.icon(
icon: const Icon(Icons.g_mobiledata),
onPressed: isLoading ? null
: () => ref.read(authProvider.notifier).loginGoogle(),
label: const Text('Lanjut dengan Google'),
),
const SizedBox(height: 12),
],
if (providers.apple) ...[
ElevatedButton.icon(
icon: const Icon(Icons.apple),
onPressed: isLoading ? null
: () => ref.read(authProvider.notifier).loginApple(),
label: const Text('Lanjut dengan Apple'),
),
],
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Row(children: [

View File

@@ -5,12 +5,20 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_client_provider.dart';
import '../../../core/auth/auth_notifier.dart';
import '../../../core/constants.dart';
import '../../../core/theme/halo_tokens.dart';
import '../widgets/otp_blocked_popup.dart';
const int _kOtpLength = 6;
const int _kFallbackResendCooldownSeconds = 60;
const Color _kAccentPink = Color(0xFFBE7C8A);
const Color _kBoxBorder = Color(0xFFE0E0E0);
// Codes that mean "the user cannot make progress without waiting" — these
// trip the OTP-blocked popup. Mirrors backend `otp.service.js`.
const _kOtpBlockedCodes = {
'OTP_RATE_LIMIT_PHONE',
'OTP_RATE_LIMIT_IP',
'OTP_COOLDOWN',
'OTP_ATTEMPTS_EXCEEDED',
};
class OtpScreen extends ConsumerStatefulWidget {
final String phone;
@@ -29,6 +37,7 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
String? _otpRequestId;
bool _autoSubmitted = false;
String? _errorMessage;
bool _blockedPopupShown = false;
int _resendSeconds = _kFallbackResendCooldownSeconds;
int _resendCooldown = _kFallbackResendCooldownSeconds;
@@ -41,24 +50,26 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
final data = ref.read(authProvider).valueOrNull;
if (data is AuthOtpSentData) _otpRequestId = data.otpRequestId;
// Register the auth listener ONCE — must NOT live in build(), or the
// resend countdown's setState will pile up duplicate listeners every
// second and the error toast will fire many times per state change.
_authSub = ref.listenManual<AsyncValue<AuthData>>(authProvider, (prev, next) {
if (next is AsyncError) {
if (!mounted) return;
final err = next.error;
setState(() => _errorMessage = err.toString());
_clearBoxes();
// If the server says we're rate-limited, extend the resend countdown
// to match — disables "Kirim ulang kode" until the lockout clears.
if (err is AuthErrorInfo &&
err.retryAfterSeconds != null &&
(err.code == 'OTP_COOLDOWN' ||
err.code == 'OTP_RATE_LIMIT_PHONE' ||
err.code == 'OTP_RATE_LIMIT_IP')) {
_resendCooldown = err.retryAfterSeconds!;
_startResendCountdown();
if (err is AuthErrorInfo) {
if (_kOtpBlockedCodes.contains(err.code) && !_blockedPopupShown) {
_blockedPopupShown = true;
OtpBlockedPopup.show(context).then((_) {
if (mounted) _blockedPopupShown = false;
});
}
if (err.retryAfterSeconds != null &&
(err.code == 'OTP_COOLDOWN' ||
err.code == 'OTP_RATE_LIMIT_PHONE' ||
err.code == 'OTP_RATE_LIMIT_IP')) {
_resendCooldown = err.retryAfterSeconds!;
_startResendCountdown();
}
}
} else if (next is AsyncLoading || next is AsyncData) {
if (_errorMessage != null && mounted) {
@@ -131,7 +142,6 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
}
void _onDigitChanged(int index, String value) {
// Move forward when a digit is entered, back when cleared.
if (value.isNotEmpty && index < _kOtpLength - 1) {
_focusNodes[index + 1].requestFocus();
}
@@ -142,9 +152,6 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
final code = _readCode();
if (code.length == _kOtpLength && !_autoSubmitted && _otpRequestId != null) {
_autoSubmitted = true;
// Keep keyboard open during verify — dismissing it caused a Scaffold
// layout shift mid-snackbar-animation, which made the error toast
// visually duplicate.
ref.read(authProvider.notifier).verifyOtp(_otpRequestId!, code);
}
}
@@ -169,47 +176,76 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
return Scaffold(
appBar: AppBar(title: const Text('Masukkan OTP')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Kode OTP telah dikirim ke ${widget.phone}'),
const SizedBox(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: List.generate(_kOtpLength, _buildBox),
),
const SizedBox(height: 12),
if (_errorMessage != null)
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(HaloSpacing.s24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
_errorMessage!,
textAlign: TextAlign.center,
style: TextStyle(color: Colors.red.shade700, fontSize: 13),
),
const SizedBox(height: 12),
if (isLoading)
const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: CircularProgressIndicator(),
'Kode OTP telah dikirim ke ${widget.phone}',
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 15,
color: HaloTokens.inkSoft,
),
),
const SizedBox(height: 16),
_buildResendRow(),
],
const SizedBox(height: HaloSpacing.s32),
LayoutBuilder(
builder: (ctx, constraints) {
// 6 boxes laid out across the row. Tighter spacing than the
// legacy 4-box layout (Figma reference) so the form still
// fits a 320pt-wide screen.
const gap = HaloSpacing.s8;
final boxWidth =
(constraints.maxWidth - gap * (_kOtpLength - 1)) /
_kOtpLength;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: List.generate(_kOtpLength, (i) {
return Padding(
padding: EdgeInsets.only(
right: i == _kOtpLength - 1 ? 0 : gap,
),
child: _buildBox(i, boxWidth),
);
}),
);
},
),
const SizedBox(height: HaloSpacing.s12),
if (_errorMessage != null)
Text(
_errorMessage!,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
color: HaloTokens.danger,
fontSize: 13,
),
),
const SizedBox(height: HaloSpacing.s12),
if (isLoading)
const Center(
child: Padding(
padding:
EdgeInsets.symmetric(vertical: HaloSpacing.s8),
child: CircularProgressIndicator(),
),
),
const SizedBox(height: HaloSpacing.s16),
_buildResendRow(),
],
),
),
),
);
}
Widget _buildBox(int index) {
Widget _buildBox(int index, double width) {
return SizedBox(
width: 48,
width: width,
height: 56,
// Wrap with Focus to intercept hardware backspace BEFORE the TextField:
// when the current box is empty, TextField.onChanged doesn't fire on
// backspace, so we'd be stuck. We catch it here and rewind one box.
child: Focus(
canRequestFocus: false,
onKeyEvent: (node, event) {
@@ -230,18 +266,25 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
maxLength: 1,
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w600),
style: const TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 22,
fontWeight: FontWeight.w700,
color: HaloTokens.ink,
),
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: InputDecoration(
counterText: '',
contentPadding: EdgeInsets.zero,
filled: true,
fillColor: HaloTokens.surface,
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: _kBoxBorder, width: 1.5),
borderSide: const BorderSide(color: HaloTokens.border, width: 1.5),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: _kAccentPink, width: 2),
borderSide: const BorderSide(color: HaloTokens.brand, width: 2),
),
),
onChanged: (v) => _onDigitChanged(index, v),
@@ -259,7 +302,8 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
child: const Text(
'Kirim ulang kode',
style: TextStyle(
color: _kAccentPink,
fontFamily: HaloTokens.fontBody,
color: HaloTokens.brandDark,
fontWeight: FontWeight.w600,
decoration: TextDecoration.underline,
),
@@ -267,7 +311,10 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
)
: Text(
'Kirim ulang dalam ${formatCountdown(_resendSeconds)}',
style: TextStyle(color: Colors.grey.shade600),
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
color: HaloTokens.inkMuted,
),
),
);
}

View File

@@ -3,8 +3,10 @@ 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/social_auth_enabled.dart';
import '../../../core/auth/auth_providers_provider.dart';
import '../../../core/constants.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart';
class RegisterScreen extends ConsumerStatefulWidget {
const RegisterScreen({super.key});
@@ -26,8 +28,6 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
@override
void initState() {
super.initState();
// Listener registered once in initState — keeps it independent of the
// build cycle so it doesn't accumulate (see feedback_riverpod_listen_in_build).
_authSub = ref.listenManual<AsyncValue<AuthData>>(authProvider, (prev, next) {
if (!mounted) return;
final data = next.valueOrNull;
@@ -82,68 +82,103 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
final isLoading = authState is AsyncLoading;
final isLockedOut = _lockoutSeconds > 0;
final canSubmit = !isLoading && !isLockedOut;
final providersAsync = ref.watch(authProvidersProvider);
final providers =
providersAsync.valueOrNull ?? AuthProvidersConfig.fallback;
return Scaffold(
appBar: AppBar(title: const Text('Masuk / Daftar')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (kSocialAuthEnabled) ...[
ElevatedButton.icon(
icon: const Icon(Icons.g_mobiledata),
onPressed: isLoading ? null
: () => ref.read(authProvider.notifier).loginGoogle(),
label: const Text('Lanjut dengan Google'),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(HaloSpacing.s24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (providers.hasAnySocial) ...[
if (providers.google) ...[
HaloButton(
label: 'lanjut dengan Google',
icon: const Icon(Icons.g_mobiledata),
variant: HaloButtonVariant.secondary,
fullWidth: true,
onPressed: isLoading
? null
: () => ref.read(authProvider.notifier).loginGoogle(),
),
const SizedBox(height: HaloSpacing.s12),
],
if (providers.apple) ...[
HaloButton(
label: 'lanjut dengan Apple',
icon: const Icon(Icons.apple),
variant: HaloButtonVariant.secondary,
fullWidth: true,
onPressed: isLoading
? null
: () => ref.read(authProvider.notifier).loginApple(),
),
const SizedBox(height: HaloSpacing.s12),
],
const Padding(
padding: EdgeInsets.symmetric(vertical: HaloSpacing.s16),
child: Row(
children: [
Expanded(child: Divider(color: HaloTokens.border)),
Padding(
padding:
EdgeInsets.symmetric(horizontal: HaloSpacing.s12),
child: Text(
'atau',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
color: HaloTokens.inkMuted,
fontSize: 13,
),
),
),
Expanded(child: Divider(color: HaloTokens.border)),
],
),
),
],
TextField(
controller: _phoneController,
decoration: const InputDecoration(
labelText: 'Nomor HP',
hintText: '+628xxxxxxxxxx',
),
keyboardType: TextInputType.phone,
),
const SizedBox(height: 12),
ElevatedButton.icon(
icon: const Icon(Icons.apple),
onPressed: isLoading ? null
: () => ref.read(authProvider.notifier).loginApple(),
label: const Text('Lanjut dengan Apple'),
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Row(children: [
Expanded(child: Divider()),
Padding(padding: EdgeInsets.symmetric(horizontal: 12), child: Text('atau')),
Expanded(child: Divider()),
]),
const SizedBox(height: HaloSpacing.s16),
HaloButton(
label: isLoading
? 'memproses...'
: isLockedOut
? 'coba lagi dalam ${formatCountdown(_lockoutSeconds)}'
: 'kirim OTP',
fullWidth: true,
onPressed: canSubmit
? () {
final phone = _phoneController.text.trim();
if (phone.isEmpty) return;
ref.read(authProvider.notifier).requestOtp(phone);
}
: null,
),
if (_errorMessage != null) ...[
const SizedBox(height: HaloSpacing.s12),
Text(
_errorMessage!,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
color: HaloTokens.danger,
fontSize: 13,
),
),
],
],
TextField(
controller: _phoneController,
decoration: const InputDecoration(
labelText: 'Nomor HP',
hintText: '+628xxxxxxxxxx',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.phone,
),
const SizedBox(height: 12),
ElevatedButton(
onPressed: canSubmit ? () {
final phone = _phoneController.text.trim();
if (phone.isEmpty) return;
ref.read(authProvider.notifier).requestOtp(phone);
} : null,
child: isLoading
? const CircularProgressIndicator()
: Text(isLockedOut
? 'Coba lagi dalam ${formatCountdown(_lockoutSeconds)}'
: 'Kirim OTP'),
),
if (_errorMessage != null) ...[
const SizedBox(height: 12),
Text(
_errorMessage!,
textAlign: TextAlign.center,
style: TextStyle(color: Colors.red.shade700, fontSize: 13),
),
],
],
),
),
),
);

View File

@@ -1,39 +1,84 @@
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/auth_providers_provider.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart';
class WelcomeScreen extends StatelessWidget {
class WelcomeScreen extends ConsumerWidget {
const WelcomeScreen({super.key});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final providersAsync = ref.watch(authProvidersProvider);
final providers =
providersAsync.valueOrNull ?? AuthProvidersConfig.fallback;
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
padding: const EdgeInsets.all(HaloSpacing.s24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Center(child: HaloOrb(seed: 0, size: 96, label: 'H')),
const SizedBox(height: HaloSpacing.s24),
const Text(
'Halo Bestie',
style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
style: TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 32,
fontWeight: FontWeight.w700,
color: HaloTokens.ink,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
const SizedBox(height: HaloSpacing.s8),
const Text(
'Tempat curhat kamu',
style: TextStyle(fontSize: 16, color: Colors.grey),
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 15,
color: HaloTokens.inkSoft,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
ElevatedButton(
const SizedBox(height: HaloSpacing.s48),
HaloButton(
label: 'Lanjut sebagai Tamu',
fullWidth: true,
onPressed: () => context.push('/auth/display-name'),
child: const Text('Lanjut sebagai Tamu'),
),
const SizedBox(height: 12),
OutlinedButton(
const SizedBox(height: HaloSpacing.s12),
if (providers.google) ...[
HaloButton(
label: 'lanjut dengan Google',
icon: const Icon(Icons.g_mobiledata),
variant: HaloButtonVariant.secondary,
fullWidth: true,
onPressed: () =>
ref.read(authProvider.notifier).loginGoogle(),
),
const SizedBox(height: HaloSpacing.s12),
],
if (providers.apple) ...[
HaloButton(
label: 'lanjut dengan Apple',
icon: const Icon(Icons.apple),
variant: HaloButtonVariant.secondary,
fullWidth: true,
onPressed: () =>
ref.read(authProvider.notifier).loginApple(),
),
const SizedBox(height: HaloSpacing.s12),
],
HaloButton(
label: 'Daftar / Masuk',
variant: HaloButtonVariant.ghost,
fullWidth: true,
onPressed: () => context.push('/auth/register'),
child: const Text('Daftar / Masuk'),
),
],
),

View File

@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart';
/// Modal shown when OTP delivery / verification is exhausted (rate-limited
/// 429 from `OTP_RATE_LIMIT_PHONE`, `OTP_RATE_LIMIT_IP`, `OTP_COOLDOWN`, or
/// `OTP_ATTEMPTS_EXCEEDED`). Offers a "lanjut tanpa verif" exit into the
/// anonymous flow (preserving any ESP/USP state) and a stub "hubungi admin"
/// affordance — Stage 8 will wire the real Tanya Admin sheet.
class OtpBlockedPopup {
const OtpBlockedPopup._();
static Future<void> show(BuildContext context) {
return HaloPopup.show<void>(
context,
title: 'Verifikasi nomor lagi penuh',
body:
'Sistem lagi nahan permintaan OTP buat keamanan. Kamu bisa lanjut '
'tanpa verifikasi, atau hubungi admin biar dibantu manual.',
icon: Container(
width: 64,
height: 64,
decoration: const BoxDecoration(
color: HaloTokens.brandSofter,
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: const Icon(
Icons.lock_clock_outlined,
color: HaloTokens.brandDark,
size: 28,
),
),
primary: HaloPopupAction(
label: 'lanjut tanpa verif',
onPressed: () {
// ESP/USP picks live in Riverpod providers (espSelectionProvider,
// espSkippedProvider) and survive this navigation — no need to pass
// them as `extra`.
context.go('/onboarding/anon/method');
},
),
secondary: HaloPopupAction(
label: 'hubungi admin',
onPressed: () {
// TODO(stage8): replace with Tanya Admin sheet.
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Tanya Admin akan tersedia segera.'),
),
);
},
),
);
}
}

View File

@@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart';
/// Result of the post-name Verif Choice Sheet. Caller routes to the matching
/// onboarding sub-flow.
enum VerifChoice { verified, anonymous }
class VerifChoiceSheet extends StatelessWidget {
const VerifChoiceSheet({super.key});
/// Show the sheet and return the user's choice (`null` if dismissed).
static Future<VerifChoice?> show(BuildContext context) {
return HaloBottomSheet.show<VerifChoice>(
context,
child: const VerifChoiceSheet(),
);
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Mau curhat sebagai siapa?',
style: TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 22,
height: 28 / 22,
fontWeight: FontWeight.w700,
color: HaloTokens.ink,
),
),
const SizedBox(height: HaloSpacing.s8),
const Text(
'Verifikasi nomor HP biar bisa dapet diskon sesi pertama dan riwayat curhatmu kesimpan. Atau langsung curhat anonim, nggak perlu daftar.',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 14,
height: 20 / 14,
color: HaloTokens.inkSoft,
),
),
const SizedBox(height: HaloSpacing.s24),
HaloButton(
label: 'verifikasi nomor HP',
fullWidth: true,
onPressed: () =>
Navigator.of(context).pop(VerifChoice.verified),
),
const SizedBox(height: HaloSpacing.s12),
HaloButton(
label: 'curhat anonim',
variant: HaloButtonVariant.secondary,
fullWidth: true,
onPressed: () =>
Navigator.of(context).pop(VerifChoice.anonymous),
),
],
);
}
}
/// Helper: route to the right onboarding sub-flow for a verif choice.
void routeForVerifChoice(BuildContext context, VerifChoice choice) {
switch (choice) {
case VerifChoice.verified:
context.push('/onboarding/verif/esp');
break;
case VerifChoice.anonymous:
context.push('/onboarding/anon/esp');
break;
}
}

View File

@@ -0,0 +1,12 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'esp_topic.dart';
/// Ephemeral selection from the ESP screen. Survives across the
/// onboarding flow (Verif Sheet → ESP → USP → OTP / Pilih cara). Cleared
/// when a chat session is created server-side.
final espSelectionProvider = StateProvider<Set<EspTopic>>((_) => <EspTopic>{});
/// Set to `true` when the user tapped "lewati" on the ESP screen. Distinct
/// from "user picked nothing then pressed Lanjut" — the backend wants to
/// know whether the empty set was intentional or a deliberate skip.
final espSkippedProvider = StateProvider<bool>((_) => false);

View File

@@ -0,0 +1,34 @@
/// Twelve emotional-state-pick (ESP) topic chips shown on the onboarding ESP
/// screen. Multi-select, info-only — the picks are persisted on the chat
/// session at session start and surfaced to the mitra as a chip row above
/// the first message bubble. They do NOT affect matching, pricing, or routing.
///
/// `value` is the wire-format string sent to the backend
/// (`chat_sessions.topics TEXT[]`). Lowercase snake_case to keep it stable
/// across UI label tweaks.
enum EspTopic {
relationship('relationship', 'Hubungan'),
family('family', 'Keluarga'),
work('work', 'Pekerjaan'),
study('study', 'Sekolah / Kuliah'),
finance('finance', 'Keuangan'),
health('health', 'Kesehatan'),
friendship('friendship', 'Pertemanan'),
selfWorth('self_worth', 'Self-worth'),
anxiety('anxiety', 'Kecemasan'),
loneliness('loneliness', 'Kesepian'),
grief('grief', 'Kehilangan'),
identity('identity', 'Identitas');
final String value;
final String label;
const EspTopic(this.value, this.label);
static EspTopic? fromValue(String? v) {
if (v == null) return null;
for (final t in values) {
if (t.value == v) return t;
}
return null;
}
}

View File

@@ -0,0 +1,132 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart';
import '../esp_state.dart';
import '../esp_topic.dart';
/// Onboarding step 1 — multi-select chip grid for ESP topics. Picks are
/// persisted on the chat session at session-start time and surfaced read-only
/// to the mitra. They do NOT affect matching, pricing, or routing.
///
/// Routed under both `/onboarding/verif/esp` and `/onboarding/anon/esp` —
/// the parent flow path determines the next destination after Lanjut.
class EspScreen extends ConsumerWidget {
/// `verified` ➞ ESP → USP → OTP.
/// `anonymous` ➞ ESP → USP → /payment/method-pick (Stage 3).
final bool verified;
const EspScreen({super.key, required this.verified});
@override
Widget build(BuildContext context, WidgetRef ref) {
final selected = ref.watch(espSelectionProvider);
return Scaffold(
appBar: AppBar(
title: const Padding(
padding: EdgeInsets.only(top: HaloSpacing.s4),
child: HaloStepDots(total: 4, current: 1),
),
centerTitle: true,
actions: [
TextButton(
onPressed: () => _onSkip(context, ref),
child: const Text('lewati'),
),
],
),
body: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(
HaloSpacing.s24,
HaloSpacing.s8,
HaloSpacing.s24,
HaloSpacing.s24,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Lagi mikirin apa?',
style: TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 26,
height: 30 / 26,
fontWeight: FontWeight.w700,
color: HaloTokens.ink,
),
),
const SizedBox(height: HaloSpacing.s8),
const Text(
'Pilih topik yang nyangkut sama ceritamu. Nggak ada yang nyambung pun nggak apa-apa, bisa dilewati.',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 14,
height: 20 / 14,
color: HaloTokens.inkSoft,
),
),
const SizedBox(height: HaloSpacing.s24),
Expanded(
child: SingleChildScrollView(
child: Wrap(
spacing: HaloSpacing.s8,
runSpacing: HaloSpacing.s8,
children: EspTopic.values.map((topic) {
final isOn = selected.contains(topic);
return HaloChip(
label: topic.label,
selected: isOn,
onTap: () => _toggle(ref, topic),
);
}).toList(),
),
),
),
const SizedBox(height: HaloSpacing.s16),
HaloButton(
label: 'lanjut',
fullWidth: true,
onPressed: () => _onContinue(context, ref),
),
],
),
),
),
);
}
void _toggle(WidgetRef ref, EspTopic topic) {
final current = ref.read(espSelectionProvider);
final next = Set<EspTopic>.from(current);
if (!next.add(topic)) next.remove(topic);
ref.read(espSelectionProvider.notifier).state = next;
if (ref.read(espSkippedProvider)) {
ref.read(espSkippedProvider.notifier).state = false;
}
}
void _onSkip(BuildContext context, WidgetRef ref) {
ref.read(espSelectionProvider.notifier).state = <EspTopic>{};
ref.read(espSkippedProvider.notifier).state = true;
_goNext(context);
}
void _onContinue(BuildContext context, WidgetRef ref) {
if (ref.read(espSelectionProvider).isEmpty) {
ref.read(espSkippedProvider.notifier).state = true;
} else {
ref.read(espSkippedProvider.notifier).state = false;
}
_goNext(context);
}
void _goNext(BuildContext context) {
final next =
verified ? '/onboarding/verif/usp' : '/onboarding/anon/usp';
context.push(next);
}
}

View File

@@ -0,0 +1,177 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart';
/// Onboarding step 2 — static value-prop ("USP") cards. No state; just a
/// terminal CTA that routes onward to the auth/payment fork.
class UspScreen extends StatelessWidget {
/// `verified` ➞ USP → OTP (`/auth/register`).
/// `anonymous` ➞ USP → `/payment/method-pick` (Stage 3 owns this route).
final bool verified;
const UspScreen({super.key, required this.verified});
static const _cards = [
_UspCard(
icon: Icons.bolt_outlined,
title: 'Langsung Curhat',
body: 'Nggak perlu janji, nggak perlu nunggu. Buka, ngobrol, plong.',
),
_UspCard(
icon: Icons.shield_outlined,
title: 'Tetap Anonim',
body: 'Identitasmu disembunyikan. Cerita apa adanya, tanpa khawatir.',
),
_UspCard(
icon: Icons.favorite_outline,
title: 'Bestie yang Relate',
body: 'Diisi orang yang udah dilatih buat dengerin, bukan ngehakimin.',
),
_UspCard(
icon: Icons.payments_outlined,
title: 'Bayar Sesuai Pakai',
body: 'Pilih durasi yang pas. Nggak ada langganan, nggak ada jebakan.',
),
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Padding(
padding: EdgeInsets.only(top: HaloSpacing.s4),
child: HaloStepDots(total: 4, current: 2),
),
centerTitle: true,
),
body: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(
HaloSpacing.s24,
HaloSpacing.s8,
HaloSpacing.s24,
HaloSpacing.s24,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Sebelum mulai',
style: TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 26,
height: 30 / 26,
fontWeight: FontWeight.w700,
color: HaloTokens.ink,
),
),
const SizedBox(height: HaloSpacing.s8),
const Text(
'Hal-hal kecil yang bikin Halo Bestie beda.',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 14,
height: 20 / 14,
color: HaloTokens.inkSoft,
),
),
const SizedBox(height: HaloSpacing.s24),
Expanded(
child: ListView.separated(
itemCount: _cards.length,
separatorBuilder: (_, __) =>
const SizedBox(height: HaloSpacing.s12),
itemBuilder: (_, i) => _cards[i],
),
),
const SizedBox(height: HaloSpacing.s16),
HaloButton(
label: 'aku ngerti, lanjut',
fullWidth: true,
onPressed: () => _onContinue(context),
),
],
),
),
),
);
}
void _onContinue(BuildContext context) {
if (verified) {
context.push('/auth/register');
} else {
// Stage 3 owns /payment/method-pick. Until then, route there as a
// placeholder; Maestro flow 03 stops at the route arrival.
context.push('/payment/method-pick');
}
}
}
class _UspCard extends StatelessWidget {
final IconData icon;
final String title;
final String body;
const _UspCard({
required this.icon,
required this.title,
required this.body,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(HaloSpacing.s16),
decoration: BoxDecoration(
color: HaloTokens.surface,
borderRadius: HaloRadius.lg,
border: Border.all(color: HaloTokens.border),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 40,
height: 40,
decoration: const BoxDecoration(
color: HaloTokens.brandSofter,
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Icon(icon, color: HaloTokens.brandDark, size: 20),
),
const SizedBox(width: HaloSpacing.s12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 16,
fontWeight: FontWeight.w600,
color: HaloTokens.ink,
),
),
const SizedBox(height: HaloSpacing.s4),
Text(
body,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 13,
height: 18 / 13,
color: HaloTokens.inkSoft,
),
),
],
),
),
],
),
);
}
}

View File

@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/api/api_client_provider.dart';
import 'core/auth/auth_notifier.dart';
import 'core/auth/auth_providers_provider.dart';
import 'core/chat/active_session_notifier.dart';
import 'core/chat/chat_notifier.dart';
import 'core/notifications/notification_service.dart';
@@ -30,6 +31,19 @@ class App extends ConsumerStatefulWidget {
class _AppState extends ConsumerState<App> {
bool _fcmRegistered = false;
bool _authProvidersPreloaded = false;
@override
void initState() {
super.initState();
// Phase 4: preload server-driven auth-provider gating once on cold start.
// Cached via @Riverpod(keepAlive: true) — subsequent reads are instant.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_authProvidersPreloaded) return;
_authProvidersPreloaded = true;
ref.read(authProvidersProvider.future);
});
}
void _registerFcmToken() {
if (_fcmRegistered) return;

View File

@@ -9,6 +9,8 @@ import 'features/auth/screens/otp_screen.dart';
import 'features/auth/screens/force_register_screen.dart';
import 'features/auth/screens/set_display_name_screen.dart';
import 'features/onboarding/onboarding_screen.dart';
import 'features/onboarding/screens/esp_screen.dart';
import 'features/onboarding/screens/usp_screen.dart';
import 'features/splash/splash_screen.dart';
import 'features/home/home_screen.dart';
import 'core/constants.dart';
@@ -59,6 +61,11 @@ GoRouter buildRouter(Ref ref) {
final isOnboarding = state.matchedLocation == '/onboarding';
final isAuthRoute = state.matchedLocation.startsWith('/auth') ||
state.matchedLocation == '/welcome';
// Phase 4 onboarding flow (Verif Choice → ESP → USP) — must transit
// freely while authState is AuthAnonymousData so the router doesn't
// boot the user back to /home before they finish onboarding.
final isOnboardingFlow =
state.matchedLocation.startsWith('/onboarding/');
// Show splash only during initial load
if (authState is AsyncLoading) {
@@ -84,6 +91,18 @@ GoRouter buildRouter(Ref ref) {
}
if (data is AuthAuthenticatedData || data is AuthAnonymousData) {
// 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;
// display_name_screen owns the post-anonymous-login routing decision
// (onboarding-state lookup → Verif Choice Sheet vs returning-user
// jump). Don't preempt it by redirecting to /home the instant the
// anonymous login resolves — wait until the screen pushes onward.
if (data is AuthAnonymousData &&
state.matchedLocation == '/auth/display-name') {
return null;
}
return (isSplash || isAuthRoute) ? '/home' : null;
}
if (data is AuthNeedsDisplayNameData) return '/auth/set-name';
@@ -103,6 +122,32 @@ GoRouter buildRouter(Ref ref) {
GoRoute(path: '/auth/otp', builder: (context, state) => OtpScreen(phone: state.extra as String)),
GoRoute(path: '/auth/set-name', builder: (_, __) => const SetDisplayNameScreen()),
GoRoute(path: '/auth/force-register', builder: (_, __) => const ForceRegisterScreen()),
// Phase 4 onboarding sub-flow (Stage 2). Verified vs anonymous branch
// share ESP + USP screens; the parent path drives the post-USP fork.
GoRoute(
path: '/onboarding/verif/esp',
builder: (_, __) => const EspScreen(verified: true),
),
GoRoute(
path: '/onboarding/verif/usp',
builder: (_, __) => const UspScreen(verified: true),
),
GoRoute(
path: '/onboarding/anon/esp',
builder: (_, __) => const EspScreen(verified: false),
),
GoRoute(
path: '/onboarding/anon/usp',
builder: (_, __) => const UspScreen(verified: false),
),
// Alias for the OTP-blocked popup's "lanjut tanpa verif" exit. The
// popup may fire from any point in the verified branch (after the
// user has already passed ESP+USP), so we expose a stable terminal
// landing-zone alias rather than rewriting all upstream pushes.
GoRoute(
path: '/onboarding/anon/method',
redirect: (_, __) => '/payment/method-pick',
),
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
GoRoute(path: '/payment', builder: (context, state) {
// Payment screen reachable from