Files
halobestie-clone/client_app/lib/features/auth/screens/otp_screen.dart
ramadhan sjamsani 2645bcd0e5 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>
2026-05-10 16:23:57 +08:00

322 lines
10 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
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;
// 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;
const OtpScreen({super.key, required this.phone});
@override
ConsumerState<OtpScreen> createState() => _OtpScreenState();
}
class _OtpScreenState extends ConsumerState<OtpScreen> {
final List<TextEditingController> _controllers =
List.generate(_kOtpLength, (_) => TextEditingController());
final List<FocusNode> _focusNodes =
List.generate(_kOtpLength, (_) => FocusNode());
String? _otpRequestId;
bool _autoSubmitted = false;
String? _errorMessage;
bool _blockedPopupShown = false;
int _resendSeconds = _kFallbackResendCooldownSeconds;
int _resendCooldown = _kFallbackResendCooldownSeconds;
Timer? _resendTimer;
ProviderSubscription<AsyncValue<AuthData>>? _authSub;
@override
void initState() {
super.initState();
final data = ref.read(authProvider).valueOrNull;
if (data is AuthOtpSentData) _otpRequestId = data.otpRequestId;
_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 (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) {
setState(() => _errorMessage = null);
}
}
});
_fetchResendCooldown();
_startResendCountdown();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _focusNodes.first.requestFocus();
});
}
@override
void dispose() {
_authSub?.close();
_resendTimer?.cancel();
for (final c in _controllers) {
c.dispose();
}
for (final f in _focusNodes) {
f.dispose();
}
super.dispose();
}
Future<void> _fetchResendCooldown() async {
try {
final response =
await ref.read(apiClientProvider).get('/api/shared/config/otp');
final data = response['data'] as Map<String, dynamic>?;
final value = data?['resend_cooldown_seconds'] as int?;
if (value != null && value > 0 && mounted) {
setState(() {
_resendCooldown = value;
_resendSeconds = value;
});
}
} catch (_) {
// Stick with fallback.
}
}
void _startResendCountdown() {
_resendTimer?.cancel();
setState(() => _resendSeconds = _resendCooldown);
_resendTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (!mounted) {
timer.cancel();
return;
}
setState(() {
if (_resendSeconds > 0) _resendSeconds--;
if (_resendSeconds <= 0) timer.cancel();
});
});
}
String _readCode() => _controllers.map((c) => c.text).join();
void _clearBoxes({bool refocusFirst = true}) {
for (final c in _controllers) {
c.clear();
}
_autoSubmitted = false;
if (refocusFirst && mounted) _focusNodes.first.requestFocus();
}
void _onDigitChanged(int index, String value) {
if (value.isNotEmpty && index < _kOtpLength - 1) {
_focusNodes[index + 1].requestFocus();
}
if (value.isEmpty && index > 0) {
_focusNodes[index - 1].requestFocus();
}
final code = _readCode();
if (code.length == _kOtpLength && !_autoSubmitted && _otpRequestId != null) {
_autoSubmitted = true;
ref.read(authProvider.notifier).verifyOtp(_otpRequestId!, code);
}
}
Future<void> _resend() async {
if (_resendSeconds > 0) return;
_clearBoxes();
await ref.read(authProvider.notifier).requestOtp(widget.phone);
if (!mounted) return;
final next = ref.read(authProvider).valueOrNull;
if (next is AuthOtpSentData) _otpRequestId = next.otpRequestId;
_startResendCountdown();
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
final isLoading = authState is AsyncLoading;
final data = authState.valueOrNull;
if (data is AuthOtpSentData) _otpRequestId = data.otpRequestId;
return Scaffold(
appBar: AppBar(title: const Text('Masukkan OTP')),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(HaloSpacing.s24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Kode OTP telah dikirim ke ${widget.phone}',
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 15,
color: HaloTokens.inkSoft,
),
),
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, double width) {
return SizedBox(
width: width,
height: 56,
child: Focus(
canRequestFocus: false,
onKeyEvent: (node, event) {
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.backspace &&
_controllers[index].text.isEmpty &&
index > 0) {
_controllers[index - 1].clear();
_focusNodes[index - 1].requestFocus();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: TextField(
controller: _controllers[index],
focusNode: _focusNodes[index],
autofocus: index == 0,
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
maxLength: 1,
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: HaloTokens.border, width: 1.5),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: HaloTokens.brand, width: 2),
),
),
onChanged: (v) => _onDigitChanged(index, v),
),
),
);
}
Widget _buildResendRow() {
final canResend = _resendSeconds <= 0;
return Center(
child: canResend
? GestureDetector(
onTap: _resend,
child: const Text(
'Kirim ulang kode',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
color: HaloTokens.brandDark,
fontWeight: FontWeight.w600,
decoration: TextDecoration.underline,
),
),
)
: Text(
'Kirim ulang dalam ${formatCountdown(_resendSeconds)}',
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
color: HaloTokens.inkMuted,
),
),
);
}
}