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>
322 lines
10 KiB
Dart
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|