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

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