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:
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
57
client_app/lib/features/auth/widgets/otp_blocked_popup.dart
Normal file
57
client_app/lib/features/auth/widgets/otp_blocked_popup.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/theme/halo_tokens.dart';
|
||||
import '../../../core/theme/widgets/widgets.dart';
|
||||
|
||||
/// Modal shown when OTP delivery / verification is exhausted (rate-limited
|
||||
/// 429 from `OTP_RATE_LIMIT_PHONE`, `OTP_RATE_LIMIT_IP`, `OTP_COOLDOWN`, or
|
||||
/// `OTP_ATTEMPTS_EXCEEDED`). Offers a "lanjut tanpa verif" exit into the
|
||||
/// anonymous flow (preserving any ESP/USP state) and a stub "hubungi admin"
|
||||
/// affordance — Stage 8 will wire the real Tanya Admin sheet.
|
||||
class OtpBlockedPopup {
|
||||
const OtpBlockedPopup._();
|
||||
|
||||
static Future<void> show(BuildContext context) {
|
||||
return HaloPopup.show<void>(
|
||||
context,
|
||||
title: 'Verifikasi nomor lagi penuh',
|
||||
body:
|
||||
'Sistem lagi nahan permintaan OTP buat keamanan. Kamu bisa lanjut '
|
||||
'tanpa verifikasi, atau hubungi admin biar dibantu manual.',
|
||||
icon: Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: const BoxDecoration(
|
||||
color: HaloTokens.brandSofter,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const Icon(
|
||||
Icons.lock_clock_outlined,
|
||||
color: HaloTokens.brandDark,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
primary: HaloPopupAction(
|
||||
label: 'lanjut tanpa verif',
|
||||
onPressed: () {
|
||||
// ESP/USP picks live in Riverpod providers (espSelectionProvider,
|
||||
// espSkippedProvider) and survive this navigation — no need to pass
|
||||
// them as `extra`.
|
||||
context.go('/onboarding/anon/method');
|
||||
},
|
||||
),
|
||||
secondary: HaloPopupAction(
|
||||
label: 'hubungi admin',
|
||||
onPressed: () {
|
||||
// TODO(stage8): replace with Tanya Admin sheet.
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Tanya Admin akan tersedia segera.'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
77
client_app/lib/features/auth/widgets/verif_choice_sheet.dart
Normal file
77
client_app/lib/features/auth/widgets/verif_choice_sheet.dart
Normal file
@@ -0,0 +1,77 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/theme/halo_tokens.dart';
|
||||
import '../../../core/theme/widgets/widgets.dart';
|
||||
|
||||
/// Result of the post-name Verif Choice Sheet. Caller routes to the matching
|
||||
/// onboarding sub-flow.
|
||||
enum VerifChoice { verified, anonymous }
|
||||
|
||||
class VerifChoiceSheet extends StatelessWidget {
|
||||
const VerifChoiceSheet({super.key});
|
||||
|
||||
/// Show the sheet and return the user's choice (`null` if dismissed).
|
||||
static Future<VerifChoice?> show(BuildContext context) {
|
||||
return HaloBottomSheet.show<VerifChoice>(
|
||||
context,
|
||||
child: const VerifChoiceSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'Mau curhat sebagai siapa?',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontSize: 22,
|
||||
height: 28 / 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: HaloTokens.ink,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s8),
|
||||
const Text(
|
||||
'Verifikasi nomor HP biar bisa dapet diskon sesi pertama dan riwayat curhatmu kesimpan. Atau langsung curhat anonim, nggak perlu daftar.',
|
||||
style: TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 14,
|
||||
height: 20 / 14,
|
||||
color: HaloTokens.inkSoft,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s24),
|
||||
HaloButton(
|
||||
label: 'verifikasi nomor HP',
|
||||
fullWidth: true,
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pop(VerifChoice.verified),
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s12),
|
||||
HaloButton(
|
||||
label: 'curhat anonim',
|
||||
variant: HaloButtonVariant.secondary,
|
||||
fullWidth: true,
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pop(VerifChoice.anonymous),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper: route to the right onboarding sub-flow for a verif choice.
|
||||
void routeForVerifChoice(BuildContext context, VerifChoice choice) {
|
||||
switch (choice) {
|
||||
case VerifChoice.verified:
|
||||
context.push('/onboarding/verif/esp');
|
||||
break;
|
||||
case VerifChoice.anonymous:
|
||||
context.push('/onboarding/anon/esp');
|
||||
break;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user