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:
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user