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

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