Files
halobestie-clone/client_app/lib/features/auth/screens/register_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

187 lines
6.5 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/auth/auth_notifier.dart';
import '../../../core/auth/auth_providers_provider.dart';
import '../../../core/constants.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart';
class RegisterScreen extends ConsumerStatefulWidget {
const RegisterScreen({super.key});
@override
ConsumerState<RegisterScreen> createState() => _RegisterScreenState();
}
class _RegisterScreenState extends ConsumerState<RegisterScreen> {
final _phoneController = TextEditingController();
ProviderSubscription<AsyncValue<AuthData>>? _authSub;
// Server-imposed lockout: when /otp/request returns 429, the backend
// includes retry_after_seconds. We disable "Kirim OTP" for that window.
int _lockoutSeconds = 0;
Timer? _lockoutTimer;
String? _errorMessage;
@override
void initState() {
super.initState();
_authSub = ref.listenManual<AsyncValue<AuthData>>(authProvider, (prev, next) {
if (!mounted) return;
final data = next.valueOrNull;
if (data is AuthOtpSentData) {
// Use go (replace) so re-submitting the phone form doesn't stack
// multiple OtpScreen instances with active listeners.
context.go('/auth/otp', extra: _phoneController.text.trim());
return;
}
if (next is AsyncError) {
final err = next.error;
setState(() => _errorMessage = err.toString());
if (err is AuthErrorInfo &&
err.retryAfterSeconds != null &&
(err.code == 'OTP_COOLDOWN' ||
err.code == 'OTP_RATE_LIMIT_PHONE' ||
err.code == 'OTP_RATE_LIMIT_IP')) {
_startLockout(err.retryAfterSeconds!);
}
} else if (next is AsyncData) {
if (_errorMessage != null) setState(() => _errorMessage = null);
}
});
}
@override
void dispose() {
_authSub?.close();
_lockoutTimer?.cancel();
_phoneController.dispose();
super.dispose();
}
void _startLockout(int seconds) {
_lockoutTimer?.cancel();
setState(() => _lockoutSeconds = seconds);
_lockoutTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (!mounted) {
timer.cancel();
return;
}
setState(() {
if (_lockoutSeconds > 0) _lockoutSeconds--;
if (_lockoutSeconds <= 0) timer.cancel();
});
});
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
final isLoading = authState is AsyncLoading;
final isLockedOut = _lockoutSeconds > 0;
final canSubmit = !isLoading && !isLockedOut;
final providersAsync = ref.watch(authProvidersProvider);
final providers =
providersAsync.valueOrNull ?? AuthProvidersConfig.fallback;
return Scaffold(
appBar: AppBar(title: const Text('Masuk / Daftar')),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(HaloSpacing.s24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (providers.hasAnySocial) ...[
if (providers.google) ...[
HaloButton(
label: 'lanjut dengan Google',
icon: const Icon(Icons.g_mobiledata),
variant: HaloButtonVariant.secondary,
fullWidth: true,
onPressed: isLoading
? null
: () => ref.read(authProvider.notifier).loginGoogle(),
),
const SizedBox(height: HaloSpacing.s12),
],
if (providers.apple) ...[
HaloButton(
label: 'lanjut dengan Apple',
icon: const Icon(Icons.apple),
variant: HaloButtonVariant.secondary,
fullWidth: true,
onPressed: isLoading
? null
: () => ref.read(authProvider.notifier).loginApple(),
),
const SizedBox(height: HaloSpacing.s12),
],
const Padding(
padding: EdgeInsets.symmetric(vertical: HaloSpacing.s16),
child: Row(
children: [
Expanded(child: Divider(color: HaloTokens.border)),
Padding(
padding:
EdgeInsets.symmetric(horizontal: HaloSpacing.s12),
child: Text(
'atau',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
color: HaloTokens.inkMuted,
fontSize: 13,
),
),
),
Expanded(child: Divider(color: HaloTokens.border)),
],
),
),
],
TextField(
controller: _phoneController,
decoration: const InputDecoration(
labelText: 'Nomor HP',
hintText: '+628xxxxxxxxxx',
),
keyboardType: TextInputType.phone,
),
const SizedBox(height: HaloSpacing.s16),
HaloButton(
label: isLoading
? 'memproses...'
: isLockedOut
? 'coba lagi dalam ${formatCountdown(_lockoutSeconds)}'
: 'kirim OTP',
fullWidth: true,
onPressed: canSubmit
? () {
final phone = _phoneController.text.trim();
if (phone.isEmpty) return;
ref.read(authProvider.notifier).requestOtp(phone);
}
: null,
),
if (_errorMessage != null) ...[
const SizedBox(height: HaloSpacing.s12),
Text(
_errorMessage!,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
color: HaloTokens.danger,
fontSize: 13,
),
),
],
],
),
),
),
);
}
}