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

@@ -1,6 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/api/api_client_provider.dart';
import '../../../core/auth/auth_notifier.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart';
import '../widgets/verif_choice_sheet.dart';
class DisplayNameScreen extends ConsumerStatefulWidget {
const DisplayNameScreen({super.key});
@@ -11,9 +16,33 @@ class DisplayNameScreen extends ConsumerStatefulWidget {
class _DisplayNameScreenState extends ConsumerState<DisplayNameScreen> {
final _controller = TextEditingController();
ProviderSubscription<AsyncValue<AuthData>>? _authSub;
String? _errorMessage;
bool _routedAfterLogin = false;
@override
void initState() {
super.initState();
// Listener registered once in initState (see feedback_riverpod_listen_in_build).
// We need to react to auth state changes once the anonymous login resolves
// to drive the post-name onboarding fork.
_authSub = ref.listenManual<AsyncValue<AuthData>>(authProvider, (prev, next) {
if (!mounted) return;
if (next is AsyncError) {
setState(() => _errorMessage = next.error.toString());
return;
}
final data = next.valueOrNull;
if (data is AuthAnonymousData && !_routedAfterLogin) {
_routedAfterLogin = true;
_proceedAfterLogin();
}
});
}
@override
void dispose() {
_authSub?.close();
_controller.dispose();
super.dispose();
}
@@ -21,46 +50,99 @@ class _DisplayNameScreenState extends ConsumerState<DisplayNameScreen> {
void _submit() {
final name = _controller.text.trim();
if (name.isEmpty) return;
setState(() => _errorMessage = null);
ref.read(authProvider.notifier).loginAnonymous(name);
}
/// After an anonymous login succeeds, decide where to send the user.
///
/// 1. Read `/api/client/onboarding-state`. If `has_consulted_before`, the
/// user is a returning customer — skip the onboarding sequence and
/// jump straight to the duration picker (Stage 3 owns that route).
/// 2. Otherwise show the Verif Choice Sheet and route based on the picked
/// branch.
Future<void> _proceedAfterLogin() async {
bool hasConsultedBefore = false;
try {
final response =
await ref.read(apiClientProvider).get('/api/client/onboarding-state');
final data = response['data'] as Map<String, dynamic>?;
hasConsultedBefore =
(data?['has_consulted_before'] as bool?) ?? false;
} catch (_) {
// Treat as first-time on failure — safer to over-collect onboarding
// info than to silently strand a returning user.
}
if (!mounted) return;
if (hasConsultedBefore) {
// TODO(stage3): Stage 3 will own /payment/duration-pick — for now
// route there as a placeholder so returning users can continue.
context.go('/payment/duration-pick');
return;
}
final choice = await VerifChoiceSheet.show(context);
if (!mounted || choice == null) {
// User dismissed the sheet — let them tap Lanjut again to retry.
_routedAfterLogin = false;
return;
}
if (!mounted) return;
routeForVerifChoice(context, choice);
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
final isLoading = authState is AsyncLoading;
ref.listen(authProvider, (prev, next) {
if (next is AsyncError) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString())));
}
});
return Scaffold(
appBar: AppBar(title: const Text('Siapa namamu?')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('Pilih nama yang ingin kamu gunakan. Nama ini tidak akan terlihat oleh siapapun selain mitra kamu.'),
const SizedBox(height: 24),
TextField(
controller: _controller,
decoration: const InputDecoration(
labelText: 'Nama panggilan',
border: OutlineInputBorder(),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(HaloSpacing.s24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Pilih nama yang ingin kamu gunakan. Nama ini akan terlihat oleh bestie kamu.',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 15,
height: 22 / 15,
color: HaloTokens.inkSoft,
),
),
textInputAction: TextInputAction.done,
onSubmitted: (_) => _submit(),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: isLoading ? null : _submit,
child: isLoading
? const CircularProgressIndicator()
: const Text('Lanjut'),
),
],
const SizedBox(height: HaloSpacing.s24),
TextField(
controller: _controller,
decoration: const InputDecoration(
labelText: 'Nama panggilan',
),
textInputAction: TextInputAction.done,
onSubmitted: (_) => _submit(),
),
if (_errorMessage != null) ...[
const SizedBox(height: HaloSpacing.s12),
Text(
_errorMessage!,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
color: HaloTokens.danger,
fontSize: 13,
),
),
],
const SizedBox(height: HaloSpacing.s24),
HaloButton(
label: isLoading ? 'memproses...' : 'lanjut',
fullWidth: true,
onPressed: isLoading ? null : _submit,
),
],
),
),
),
);

View File

@@ -2,7 +2,7 @@ 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/social_auth_enabled.dart';
import '../../../core/auth/auth_providers_provider.dart';
/// Shown when anonymity is disabled by admin.
/// User must identify themselves (phone OTP / Google / Apple).
@@ -28,6 +28,9 @@ class _ForceRegisterScreenState extends ConsumerState<ForceRegisterScreen> {
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
final isLoading = authState is AsyncLoading;
final providersAsync = ref.watch(authProvidersProvider);
final providers =
providersAsync.valueOrNull ?? AuthProvidersConfig.fallback;
ref.listen(authProvider, (prev, next) {
final data = next.valueOrNull;
@@ -51,20 +54,24 @@ class _ForceRegisterScreenState extends ConsumerState<ForceRegisterScreen> {
style: TextStyle(fontSize: 16),
),
const SizedBox(height: 24),
if (kSocialAuthEnabled) ...[
ElevatedButton.icon(
icon: const Icon(Icons.g_mobiledata),
onPressed: isLoading ? null
: () => ref.read(authProvider.notifier).loginGoogle(),
label: const Text('Lanjut dengan Google'),
),
const SizedBox(height: 12),
ElevatedButton.icon(
icon: const Icon(Icons.apple),
onPressed: isLoading ? null
: () => ref.read(authProvider.notifier).loginApple(),
label: const Text('Lanjut dengan Apple'),
),
if (providers.hasAnySocial) ...[
if (providers.google) ...[
ElevatedButton.icon(
icon: const Icon(Icons.g_mobiledata),
onPressed: isLoading ? null
: () => ref.read(authProvider.notifier).loginGoogle(),
label: const Text('Lanjut dengan Google'),
),
const SizedBox(height: 12),
],
if (providers.apple) ...[
ElevatedButton.icon(
icon: const Icon(Icons.apple),
onPressed: isLoading ? null
: () => ref.read(authProvider.notifier).loginApple(),
label: const Text('Lanjut dengan Apple'),
),
],
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Row(children: [

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

View File

@@ -3,8 +3,10 @@ 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/social_auth_enabled.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});
@@ -26,8 +28,6 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
@override
void initState() {
super.initState();
// Listener registered once in initState — keeps it independent of the
// build cycle so it doesn't accumulate (see feedback_riverpod_listen_in_build).
_authSub = ref.listenManual<AsyncValue<AuthData>>(authProvider, (prev, next) {
if (!mounted) return;
final data = next.valueOrNull;
@@ -82,68 +82,103 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
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: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (kSocialAuthEnabled) ...[
ElevatedButton.icon(
icon: const Icon(Icons.g_mobiledata),
onPressed: isLoading ? null
: () => ref.read(authProvider.notifier).loginGoogle(),
label: const Text('Lanjut dengan Google'),
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: 12),
ElevatedButton.icon(
icon: const Icon(Icons.apple),
onPressed: isLoading ? null
: () => ref.read(authProvider.notifier).loginApple(),
label: const Text('Lanjut dengan Apple'),
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Row(children: [
Expanded(child: Divider()),
Padding(padding: EdgeInsets.symmetric(horizontal: 12), child: Text('atau')),
Expanded(child: Divider()),
]),
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,
),
),
],
],
TextField(
controller: _phoneController,
decoration: const InputDecoration(
labelText: 'Nomor HP',
hintText: '+628xxxxxxxxxx',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.phone,
),
const SizedBox(height: 12),
ElevatedButton(
onPressed: canSubmit ? () {
final phone = _phoneController.text.trim();
if (phone.isEmpty) return;
ref.read(authProvider.notifier).requestOtp(phone);
} : null,
child: isLoading
? const CircularProgressIndicator()
: Text(isLockedOut
? 'Coba lagi dalam ${formatCountdown(_lockoutSeconds)}'
: 'Kirim OTP'),
),
if (_errorMessage != null) ...[
const SizedBox(height: 12),
Text(
_errorMessage!,
textAlign: TextAlign.center,
style: TextStyle(color: Colors.red.shade700, fontSize: 13),
),
],
],
),
),
),
);

View File

@@ -1,39 +1,84 @@
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/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart';
class WelcomeScreen extends StatelessWidget {
class WelcomeScreen extends ConsumerWidget {
const WelcomeScreen({super.key});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final providersAsync = ref.watch(authProvidersProvider);
final providers =
providersAsync.valueOrNull ?? AuthProvidersConfig.fallback;
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
padding: const EdgeInsets.all(HaloSpacing.s24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Center(child: HaloOrb(seed: 0, size: 96, label: 'H')),
const SizedBox(height: HaloSpacing.s24),
const Text(
'Halo Bestie',
style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
style: TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 32,
fontWeight: FontWeight.w700,
color: HaloTokens.ink,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
const SizedBox(height: HaloSpacing.s8),
const Text(
'Tempat curhat kamu',
style: TextStyle(fontSize: 16, color: Colors.grey),
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 15,
color: HaloTokens.inkSoft,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
ElevatedButton(
const SizedBox(height: HaloSpacing.s48),
HaloButton(
label: 'Lanjut sebagai Tamu',
fullWidth: true,
onPressed: () => context.push('/auth/display-name'),
child: const Text('Lanjut sebagai Tamu'),
),
const SizedBox(height: 12),
OutlinedButton(
const SizedBox(height: HaloSpacing.s12),
if (providers.google) ...[
HaloButton(
label: 'lanjut dengan Google',
icon: const Icon(Icons.g_mobiledata),
variant: HaloButtonVariant.secondary,
fullWidth: true,
onPressed: () =>
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: () =>
ref.read(authProvider.notifier).loginApple(),
),
const SizedBox(height: HaloSpacing.s12),
],
HaloButton(
label: 'Daftar / Masuk',
variant: HaloButtonVariant.ghost,
fullWidth: true,
onPressed: () => context.push('/auth/register'),
child: const Text('Daftar / Masuk'),
),
],
),