From 2645bcd0e5c22196a8309bd36d2457ff634cb501 Mon Sep 17 00:00:00 2001 From: ramadhan sjamsani Date: Sun, 10 May 2026 16:23:57 +0800 Subject: [PATCH] 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) --- backend/src/services/session.service.js | 2 +- .../flows/02_onboarding_verified.yaml | 115 ++++++++++++ .../.maestro/flows/03_onboarding_anon.yaml | 71 +++++++ client_app/CLAUDE.md | 4 +- .../core/auth/auth_providers_provider.dart | 51 +++++ .../core/auth/auth_providers_provider.g.dart | 33 ++++ .../lib/core/auth/social_auth_enabled.dart | 7 - .../mitra_availability_notifier.g.dart | 10 +- .../core/chat/session_closure_notifier.g.dart | 2 +- .../lib/core/pairing/pairing_notifier.g.dart | 2 +- .../auth/screens/display_name_screen.dart | 140 +++++++++++--- .../auth/screens/force_register_screen.dart | 37 ++-- .../lib/features/auth/screens/otp_screen.dart | 155 +++++++++------ .../auth/screens/register_screen.dart | 153 +++++++++------ .../features/auth/screens/welcome_screen.dart | 69 +++++-- .../auth/widgets/otp_blocked_popup.dart | 57 ++++++ .../auth/widgets/verif_choice_sheet.dart | 77 ++++++++ .../lib/features/onboarding/esp_state.dart | 12 ++ .../lib/features/onboarding/esp_topic.dart | 34 ++++ .../onboarding/screens/esp_screen.dart | 132 +++++++++++++ .../onboarding/screens/usp_screen.dart | 177 ++++++++++++++++++ client_app/lib/main.dart | 14 ++ client_app/lib/router.dart | 45 +++++ .../lib/core/chat/mitra_chat_notifier.dart | 12 ++ .../chat/screens/mitra_chat_screen.dart | 60 +++++- 25 files changed, 1282 insertions(+), 189 deletions(-) create mode 100644 client_app/.maestro/flows/02_onboarding_verified.yaml create mode 100644 client_app/.maestro/flows/03_onboarding_anon.yaml create mode 100644 client_app/lib/core/auth/auth_providers_provider.dart create mode 100644 client_app/lib/core/auth/auth_providers_provider.g.dart delete mode 100644 client_app/lib/core/auth/social_auth_enabled.dart create mode 100644 client_app/lib/features/auth/widgets/otp_blocked_popup.dart create mode 100644 client_app/lib/features/auth/widgets/verif_choice_sheet.dart create mode 100644 client_app/lib/features/onboarding/esp_state.dart create mode 100644 client_app/lib/features/onboarding/esp_topic.dart create mode 100644 client_app/lib/features/onboarding/screens/esp_screen.dart create mode 100644 client_app/lib/features/onboarding/screens/usp_screen.dart diff --git a/backend/src/services/session.service.js b/backend/src/services/session.service.js index b597884..d92213f 100644 --- a/backend/src/services/session.service.js +++ b/backend/src/services/session.service.js @@ -150,7 +150,7 @@ export const listSessions = async ({ page = 1, limit = 20, status, topic_sensiti export const getSessionById = async (sessionId) => { const [session] = await sql` - SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.topic_sensitivity, + SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.topics, cs.created_at, cs.paired_at, cs.ended_at, cs.ended_by, cs.duration_minutes, cs.price, cs.is_first_session_discount, cs.expires_at, cs.extended_minutes, c.display_name AS customer_display_name, diff --git a/client_app/.maestro/flows/02_onboarding_verified.yaml b/client_app/.maestro/flows/02_onboarding_verified.yaml new file mode 100644 index 0000000..6a4d64a --- /dev/null +++ b/client_app/.maestro/flows/02_onboarding_verified.yaml @@ -0,0 +1,115 @@ +# Phase 4 Stage 2 — verified onboarding path: +# Splash → onboarding carousel → Welcome → Display Name → Verif Choice Sheet +# (verifikasi nomor HP) → ESP (pick a chip) → USP → Register → OTP (6-digit) +# → S6 paywall (when first-session-discount eligible) or duration picker. +# +# Run: +# maestro test client_app/.maestro/flows/02_onboarding_verified.yaml +# +# Pre-reqs: client_app debug APK installed, backend reachable at +# BACKEND_URL/BACKEND_INTERNAL_URL, NODE_ENV != 'production' (so the +# /internal/_test/peek-otp + /reset-phone routes register), and +# `anonymity_enabled = true` in the dev DB so the verif choice sheet shows. +# +# NOTE: numeric prefix conflicts with the existing +# 02_cta_disabled_when_no_mitra.yaml — Stage 9 will reorganize the flow +# directory once the full Phase 4 suite lands. +appId: com.halobestie.client.client_app +env: + TEST_PHONE: "+628155557701" + BACKEND_INTERNAL_URL: http://localhost:3001 +--- +- runScript: + file: ../scripts/reset_phone.js + env: + TEST_PHONE: ${TEST_PHONE} + BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL} +- launchApp: + clearState: true +- extendedWaitUntil: + visible: + text: "Mulai" + timeout: 15000 +- tapOn: + text: "Mulai" + retryTapIfNoChange: true +- extendedWaitUntil: + visible: + text: "Lanjut sebagai Tamu" + timeout: 10000 +- tapOn: + text: "Lanjut sebagai Tamu" + retryTapIfNoChange: true +- extendedWaitUntil: + visible: + text: "Nama panggilan" + timeout: 10000 +- tapOn: + text: "Nama panggilan" +- inputText: "Maestro" +- hideKeyboard +- tapOn: + text: "lanjut" + retryTapIfNoChange: true +# Verif Choice Sheet +- extendedWaitUntil: + visible: + text: "verifikasi nomor HP" + timeout: 10000 +- tapOn: + text: "verifikasi nomor HP" + retryTapIfNoChange: true +# ESP screen — pick at least one chip then tap "lanjut" +- extendedWaitUntil: + visible: + text: "Lagi mikirin apa?" + timeout: 10000 +- tapOn: + text: "Hubungan" +- tapOn: + text: "lanjut" + retryTapIfNoChange: true +# USP screen +- extendedWaitUntil: + visible: + text: "Sebelum mulai" + timeout: 10000 +- tapOn: + text: "aku ngerti, lanjut" + retryTapIfNoChange: true +# Register (S3a) — phone entry +- extendedWaitUntil: + visible: + text: "Nomor HP" + timeout: 10000 +- tapOn: + text: "Nomor HP" +- inputText: ${TEST_PHONE} +- hideKeyboard +- tapOn: + text: "kirim OTP" + retryTapIfNoChange: true +# OTP screen (S3b) +- extendedWaitUntil: + visible: + text: "Masukkan OTP" + timeout: 15000 +- runScript: + file: ../scripts/peek_otp.js + env: + TEST_PHONE: ${TEST_PHONE} + BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL} +- inputText: ${output.OTP} +# Verified path: first-session-discount eligible customers land on the S6 +# paywall; non-eligibles land on the duration picker. Either is acceptable +# arrival for this flow. +- extendedWaitUntil: + visible: + text: "Masukkan OTP" + timeout: 15000 + notVisible: true +- extendedWaitUntil: + visible: + text: "harga sesi pertama" + timeout: 15000 + optional: true diff --git a/client_app/.maestro/flows/03_onboarding_anon.yaml b/client_app/.maestro/flows/03_onboarding_anon.yaml new file mode 100644 index 0000000..f7da6ca --- /dev/null +++ b/client_app/.maestro/flows/03_onboarding_anon.yaml @@ -0,0 +1,71 @@ +# Phase 4 Stage 2 — anonymous onboarding path: +# Splash → onboarding carousel → Welcome → Display Name → Verif Choice Sheet +# (curhat anonim) → ESP → USP → arrival at /payment/method-pick (Stage 3 +# owns the screen body; this flow stops at route arrival). +# +# Run: +# maestro test client_app/.maestro/flows/03_onboarding_anon.yaml +# +# Pre-reqs: same as 02_onboarding_verified.yaml. +# +# NOTE: numeric prefix conflicts with the existing 03_payment_to_chat_happy.yaml +# — Stage 9 will reorganize the flow directory once the full Phase 4 suite lands. +appId: com.halobestie.client.client_app +--- +- launchApp: + clearState: true +- extendedWaitUntil: + visible: + text: "Mulai" + timeout: 15000 +- tapOn: + text: "Mulai" + retryTapIfNoChange: true +- extendedWaitUntil: + visible: + text: "Lanjut sebagai Tamu" + timeout: 10000 +- tapOn: + text: "Lanjut sebagai Tamu" + retryTapIfNoChange: true +- extendedWaitUntil: + visible: + text: "Nama panggilan" + timeout: 10000 +- tapOn: + text: "Nama panggilan" +- inputText: "Maestro" +- hideKeyboard +- tapOn: + text: "lanjut" + retryTapIfNoChange: true +# Verif Choice Sheet — pick anonymous branch +- extendedWaitUntil: + visible: + text: "curhat anonim" + timeout: 10000 +- tapOn: + text: "curhat anonim" + retryTapIfNoChange: true +# ESP screen — leave empty + tap lewati to exercise the skip path +- extendedWaitUntil: + visible: + text: "Lagi mikirin apa?" + timeout: 10000 +- tapOn: + text: "lewati" + retryTapIfNoChange: true +# USP screen +- extendedWaitUntil: + visible: + text: "Sebelum mulai" + timeout: 10000 +- tapOn: + text: "aku ngerti, lanjut" + retryTapIfNoChange: true +# Stage 3 owns /payment/method-pick — arrival is the success signal. +- extendedWaitUntil: + visible: + text: "Sebelum mulai" + timeout: 10000 + notVisible: true diff --git a/client_app/CLAUDE.md b/client_app/CLAUDE.md index 76e9e07..2724792 100644 --- a/client_app/CLAUDE.md +++ b/client_app/CLAUDE.md @@ -9,7 +9,7 @@ Flutter mobile application for end users (clients) seeking mental health support - **Framework:** Flutter (iOS + Android) - **Auth:** Self-managed (Phase 3.4). Anonymous-first + phone OTP + (Google / Apple when creds arrive). - Access token in memory on `AuthBridge`; refresh token persisted via `flutter_secure_storage`. - - Google + Apple SDKs installed but buttons are hidden behind `--dart-define=ENABLE_SOCIAL_AUTH=true` until backend OAuth credentials exist. + - Google + Apple SDKs installed; buttons are gated server-side via `GET /api/shared/auth-providers` (cached on cold start in `authProvidersProvider`). Buttons render only when the corresponding env-driven flag returns `enabled: true`. - `firebase_auth` removed; `firebase_messaging` kept for FCM push. - **API:** Calls public Fastify backend (`/api/client/` and `/api/shared/` routes). Refresh + logout live on `shared.auth`. - **Payment:** Xendit (paid sessions, optional trial) @@ -25,4 +25,4 @@ Flutter mobile application for end users (clients) seeking mental health support - Never call `/api/mitra/` or `/internal/` routes from this app - API calls go through `ApiClient`; it auto-attaches the JWT from `AuthBridge` and auto-refreshes on 401 - WebSocket handshake (`/api/shared/ws`) reads the access token from `AuthBridge` in the first frame's `{type:"auth", token, session_id?}` message -- Use `const bool.fromEnvironment('ENABLE_SOCIAL_AUTH')` (via `social_auth_enabled.dart`) to gate any Google/Apple UI — never call `loginGoogle` / `loginApple` from a path reachable without that flag +- Read `authProvidersProvider` (`core/auth/auth_providers_provider.dart`) to gate any Google/Apple UI — never call `loginGoogle` / `loginApple` from a path reachable when `providers.google` / `providers.apple` is `false` diff --git a/client_app/lib/core/auth/auth_providers_provider.dart b/client_app/lib/core/auth/auth_providers_provider.dart new file mode 100644 index 0000000..dc45fc4 --- /dev/null +++ b/client_app/lib/core/auth/auth_providers_provider.dart @@ -0,0 +1,51 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../api/api_client_provider.dart'; + +part 'auth_providers_provider.g.dart'; + +class AuthProvidersConfig { + final bool google; + final bool apple; + final bool phone; + + const AuthProvidersConfig({ + required this.google, + required this.apple, + required this.phone, + }); + + /// Conservative fallback used when the network probe fails. Phone OTP is + /// always available; social sign-in is hidden until the backend confirms. + static const fallback = AuthProvidersConfig( + google: false, + apple: false, + phone: true, + ); + + bool get hasAnySocial => google || apple; +} + +/// Cached server-driven flag set for which auth entry points are wired up. +/// +/// Replaces the old `--dart-define=ENABLE_SOCIAL_AUTH` build flag: the client +/// now reads `GET /api/shared/auth-providers` once on cold start and hides +/// Google/Apple buttons when the corresponding flag is `false`. +@Riverpod(keepAlive: true) +Future authProviders(Ref ref) async { + try { + final response = await ref.read(apiClientProvider).get('/api/shared/auth-providers'); + final data = response['data'] as Map?; + if (data == null) return AuthProvidersConfig.fallback; + final google = data['google'] as Map?; + final apple = data['apple'] as Map?; + final phone = data['phone'] as Map?; + return AuthProvidersConfig( + google: (google?['enabled'] as bool?) ?? false, + apple: (apple?['enabled'] as bool?) ?? false, + phone: (phone?['enabled'] as bool?) ?? true, + ); + } catch (_) { + return AuthProvidersConfig.fallback; + } +} diff --git a/client_app/lib/core/auth/auth_providers_provider.g.dart b/client_app/lib/core/auth/auth_providers_provider.g.dart new file mode 100644 index 0000000..aad01c3 --- /dev/null +++ b/client_app/lib/core/auth/auth_providers_provider.g.dart @@ -0,0 +1,33 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'auth_providers_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$authProvidersHash() => r'cadec65217f3280bbd1b36568eefb93a7fcdd6f9'; + +/// Cached server-driven flag set for which auth entry points are wired up. +/// +/// Replaces the old `--dart-define=ENABLE_SOCIAL_AUTH` build flag: the client +/// now reads `GET /api/shared/auth-providers` once on cold start and hides +/// Google/Apple buttons when the corresponding flag is `false`. +/// +/// Copied from [authProviders]. +@ProviderFor(authProviders) +final authProvidersProvider = FutureProvider.internal( + authProviders, + name: r'authProvidersProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$authProvidersHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef AuthProvidersRef = FutureProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/client_app/lib/core/auth/social_auth_enabled.dart b/client_app/lib/core/auth/social_auth_enabled.dart deleted file mode 100644 index df544d1..0000000 --- a/client_app/lib/core/auth/social_auth_enabled.dart +++ /dev/null @@ -1,7 +0,0 @@ -/// Build-time flag controlling whether Google / Apple sign-in buttons -/// are shown. Default: false until backend OAuth credentials are -/// provisioned. Enable with `--dart-define=ENABLE_SOCIAL_AUTH=true`. -const bool kSocialAuthEnabled = bool.fromEnvironment( - 'ENABLE_SOCIAL_AUTH', - defaultValue: false, -); diff --git a/client_app/lib/core/availability/mitra_availability_notifier.g.dart b/client_app/lib/core/availability/mitra_availability_notifier.g.dart index 7a428d6..4624bd1 100644 --- a/client_app/lib/core/availability/mitra_availability_notifier.g.dart +++ b/client_app/lib/core/availability/mitra_availability_notifier.g.dart @@ -6,9 +6,9 @@ part of 'mitra_availability_notifier.dart'; // RiverpodGenerator // ************************************************************************** -String _$mitraAvailabilityHash() => r'7429862ccffbae1fc7bbe7368359e1624fe28ec9'; +String _$mitraAvailabilityHash() => r'036de8fea66c6a0114abd4a422af12186c1447d7'; -/// Phase 3.7 §1: customer-home availability poll. +/// Customer-home availability poll. /// /// Polls `GET /api/client/mitra-availability` every 5 seconds while the home /// screen is in the foreground. Polling is gated by the home screen calling @@ -16,10 +16,10 @@ String _$mitraAvailabilityHash() => r'7429862ccffbae1fc7bbe7368359e1624fe28ec9'; /// - resumed → setActive(true) /// - paused/inactive → setActive(false) /// -/// On any HTTP error we emit `false` (PRD §1.3: never display stale state). +/// On any HTTP error we emit `false` (never display stale state). /// -/// The endpoint also returns a `count`, but per PRD §1.3 the customer UI must -/// only read the binary `available` field — the count is for CC/debug only. +/// The endpoint also returns a `count`, but the customer UI must only read the +/// binary `available` field — the count is for CC/debug only. /// /// Copied from [MitraAvailability]. @ProviderFor(MitraAvailability) diff --git a/client_app/lib/core/chat/session_closure_notifier.g.dart b/client_app/lib/core/chat/session_closure_notifier.g.dart index dd05331..421db05 100644 --- a/client_app/lib/core/chat/session_closure_notifier.g.dart +++ b/client_app/lib/core/chat/session_closure_notifier.g.dart @@ -6,7 +6,7 @@ part of 'session_closure_notifier.dart'; // RiverpodGenerator // ************************************************************************** -String _$sessionClosureHash() => r'f238e4098aefdd942314a13eb3fb77ed19cd2b77'; +String _$sessionClosureHash() => r'521e57f74805faf01f11778872cb37ceae683a5b'; /// See also [SessionClosure]. @ProviderFor(SessionClosure) diff --git a/client_app/lib/core/pairing/pairing_notifier.g.dart b/client_app/lib/core/pairing/pairing_notifier.g.dart index cd31f06..ae23d29 100644 --- a/client_app/lib/core/pairing/pairing_notifier.g.dart +++ b/client_app/lib/core/pairing/pairing_notifier.g.dart @@ -6,7 +6,7 @@ part of 'pairing_notifier.dart'; // RiverpodGenerator // ************************************************************************** -String _$pairingHash() => r'0fe1e5646fe70b90f5489919ec8dd557c998daad'; +String _$pairingHash() => r'd39980fe5b82348d03485006d0534ab597b93ceb'; /// See also [Pairing]. @ProviderFor(Pairing) diff --git a/client_app/lib/features/auth/screens/display_name_screen.dart b/client_app/lib/features/auth/screens/display_name_screen.dart index 7834b14..55c35f5 100644 --- a/client_app/lib/features/auth/screens/display_name_screen.dart +++ b/client_app/lib/features/auth/screens/display_name_screen.dart @@ -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 { final _controller = TextEditingController(); + ProviderSubscription>? _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>(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 { 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 _proceedAfterLogin() async { + bool hasConsultedBefore = false; + try { + final response = + await ref.read(apiClientProvider).get('/api/client/onboarding-state'); + final data = response['data'] as Map?; + 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, + ), + ], + ), ), ), ); diff --git a/client_app/lib/features/auth/screens/force_register_screen.dart b/client_app/lib/features/auth/screens/force_register_screen.dart index 1f4164b..cc3d715 100644 --- a/client_app/lib/features/auth/screens/force_register_screen.dart +++ b/client_app/lib/features/auth/screens/force_register_screen.dart @@ -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 { 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 { 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: [ diff --git a/client_app/lib/features/auth/screens/otp_screen.dart b/client_app/lib/features/auth/screens/otp_screen.dart index e834b80..2110eab 100644 --- a/client_app/lib/features/auth/screens/otp_screen.dart +++ b/client_app/lib/features/auth/screens/otp_screen.dart @@ -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 { String? _otpRequestId; bool _autoSubmitted = false; String? _errorMessage; + bool _blockedPopupShown = false; int _resendSeconds = _kFallbackResendCooldownSeconds; int _resendCooldown = _kFallbackResendCooldownSeconds; @@ -41,24 +50,26 @@ class _OtpScreenState extends ConsumerState { 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>(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 { } 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 { 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 { 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 { 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 { 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 { ) : Text( 'Kirim ulang dalam ${formatCountdown(_resendSeconds)}', - style: TextStyle(color: Colors.grey.shade600), + style: const TextStyle( + fontFamily: HaloTokens.fontBody, + color: HaloTokens.inkMuted, + ), ), ); } diff --git a/client_app/lib/features/auth/screens/register_screen.dart b/client_app/lib/features/auth/screens/register_screen.dart index 8fddc6e..7e76e58 100644 --- a/client_app/lib/features/auth/screens/register_screen.dart +++ b/client_app/lib/features/auth/screens/register_screen.dart @@ -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 { @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>(authProvider, (prev, next) { if (!mounted) return; final data = next.valueOrNull; @@ -82,68 +82,103 @@ class _RegisterScreenState extends ConsumerState { 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), - ), - ], - ], + ), ), ), ); diff --git a/client_app/lib/features/auth/screens/welcome_screen.dart b/client_app/lib/features/auth/screens/welcome_screen.dart index 8221033..c865a24 100644 --- a/client_app/lib/features/auth/screens/welcome_screen.dart +++ b/client_app/lib/features/auth/screens/welcome_screen.dart @@ -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'), ), ], ), diff --git a/client_app/lib/features/auth/widgets/otp_blocked_popup.dart b/client_app/lib/features/auth/widgets/otp_blocked_popup.dart new file mode 100644 index 0000000..93aee82 --- /dev/null +++ b/client_app/lib/features/auth/widgets/otp_blocked_popup.dart @@ -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 show(BuildContext context) { + return HaloPopup.show( + 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.'), + ), + ); + }, + ), + ); + } +} diff --git a/client_app/lib/features/auth/widgets/verif_choice_sheet.dart b/client_app/lib/features/auth/widgets/verif_choice_sheet.dart new file mode 100644 index 0000000..36ccb74 --- /dev/null +++ b/client_app/lib/features/auth/widgets/verif_choice_sheet.dart @@ -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 show(BuildContext context) { + return HaloBottomSheet.show( + 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; + } +} diff --git a/client_app/lib/features/onboarding/esp_state.dart b/client_app/lib/features/onboarding/esp_state.dart new file mode 100644 index 0000000..8077787 --- /dev/null +++ b/client_app/lib/features/onboarding/esp_state.dart @@ -0,0 +1,12 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'esp_topic.dart'; + +/// Ephemeral selection from the ESP screen. Survives across the +/// onboarding flow (Verif Sheet → ESP → USP → OTP / Pilih cara). Cleared +/// when a chat session is created server-side. +final espSelectionProvider = StateProvider>((_) => {}); + +/// Set to `true` when the user tapped "lewati" on the ESP screen. Distinct +/// from "user picked nothing then pressed Lanjut" — the backend wants to +/// know whether the empty set was intentional or a deliberate skip. +final espSkippedProvider = StateProvider((_) => false); diff --git a/client_app/lib/features/onboarding/esp_topic.dart b/client_app/lib/features/onboarding/esp_topic.dart new file mode 100644 index 0000000..2ed4853 --- /dev/null +++ b/client_app/lib/features/onboarding/esp_topic.dart @@ -0,0 +1,34 @@ +/// Twelve emotional-state-pick (ESP) topic chips shown on the onboarding ESP +/// screen. Multi-select, info-only — the picks are persisted on the chat +/// session at session start and surfaced to the mitra as a chip row above +/// the first message bubble. They do NOT affect matching, pricing, or routing. +/// +/// `value` is the wire-format string sent to the backend +/// (`chat_sessions.topics TEXT[]`). Lowercase snake_case to keep it stable +/// across UI label tweaks. +enum EspTopic { + relationship('relationship', 'Hubungan'), + family('family', 'Keluarga'), + work('work', 'Pekerjaan'), + study('study', 'Sekolah / Kuliah'), + finance('finance', 'Keuangan'), + health('health', 'Kesehatan'), + friendship('friendship', 'Pertemanan'), + selfWorth('self_worth', 'Self-worth'), + anxiety('anxiety', 'Kecemasan'), + loneliness('loneliness', 'Kesepian'), + grief('grief', 'Kehilangan'), + identity('identity', 'Identitas'); + + final String value; + final String label; + const EspTopic(this.value, this.label); + + static EspTopic? fromValue(String? v) { + if (v == null) return null; + for (final t in values) { + if (t.value == v) return t; + } + return null; + } +} diff --git a/client_app/lib/features/onboarding/screens/esp_screen.dart b/client_app/lib/features/onboarding/screens/esp_screen.dart new file mode 100644 index 0000000..8ed47c9 --- /dev/null +++ b/client_app/lib/features/onboarding/screens/esp_screen.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../../core/theme/halo_tokens.dart'; +import '../../../core/theme/widgets/widgets.dart'; +import '../esp_state.dart'; +import '../esp_topic.dart'; + +/// Onboarding step 1 — multi-select chip grid for ESP topics. Picks are +/// persisted on the chat session at session-start time and surfaced read-only +/// to the mitra. They do NOT affect matching, pricing, or routing. +/// +/// Routed under both `/onboarding/verif/esp` and `/onboarding/anon/esp` — +/// the parent flow path determines the next destination after Lanjut. +class EspScreen extends ConsumerWidget { + /// `verified` ➞ ESP → USP → OTP. + /// `anonymous` ➞ ESP → USP → /payment/method-pick (Stage 3). + final bool verified; + + const EspScreen({super.key, required this.verified}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final selected = ref.watch(espSelectionProvider); + + return Scaffold( + appBar: AppBar( + title: const Padding( + padding: EdgeInsets.only(top: HaloSpacing.s4), + child: HaloStepDots(total: 4, current: 1), + ), + centerTitle: true, + actions: [ + TextButton( + onPressed: () => _onSkip(context, ref), + child: const Text('lewati'), + ), + ], + ), + body: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB( + HaloSpacing.s24, + HaloSpacing.s8, + HaloSpacing.s24, + HaloSpacing.s24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Lagi mikirin apa?', + style: TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 26, + height: 30 / 26, + fontWeight: FontWeight.w700, + color: HaloTokens.ink, + ), + ), + const SizedBox(height: HaloSpacing.s8), + const Text( + 'Pilih topik yang nyangkut sama ceritamu. Nggak ada yang nyambung pun nggak apa-apa, bisa dilewati.', + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 14, + height: 20 / 14, + color: HaloTokens.inkSoft, + ), + ), + const SizedBox(height: HaloSpacing.s24), + Expanded( + child: SingleChildScrollView( + child: Wrap( + spacing: HaloSpacing.s8, + runSpacing: HaloSpacing.s8, + children: EspTopic.values.map((topic) { + final isOn = selected.contains(topic); + return HaloChip( + label: topic.label, + selected: isOn, + onTap: () => _toggle(ref, topic), + ); + }).toList(), + ), + ), + ), + const SizedBox(height: HaloSpacing.s16), + HaloButton( + label: 'lanjut', + fullWidth: true, + onPressed: () => _onContinue(context, ref), + ), + ], + ), + ), + ), + ); + } + + void _toggle(WidgetRef ref, EspTopic topic) { + final current = ref.read(espSelectionProvider); + final next = Set.from(current); + if (!next.add(topic)) next.remove(topic); + ref.read(espSelectionProvider.notifier).state = next; + if (ref.read(espSkippedProvider)) { + ref.read(espSkippedProvider.notifier).state = false; + } + } + + void _onSkip(BuildContext context, WidgetRef ref) { + ref.read(espSelectionProvider.notifier).state = {}; + ref.read(espSkippedProvider.notifier).state = true; + _goNext(context); + } + + void _onContinue(BuildContext context, WidgetRef ref) { + if (ref.read(espSelectionProvider).isEmpty) { + ref.read(espSkippedProvider.notifier).state = true; + } else { + ref.read(espSkippedProvider.notifier).state = false; + } + _goNext(context); + } + + void _goNext(BuildContext context) { + final next = + verified ? '/onboarding/verif/usp' : '/onboarding/anon/usp'; + context.push(next); + } +} diff --git a/client_app/lib/features/onboarding/screens/usp_screen.dart b/client_app/lib/features/onboarding/screens/usp_screen.dart new file mode 100644 index 0000000..8ff5e25 --- /dev/null +++ b/client_app/lib/features/onboarding/screens/usp_screen.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import '../../../core/theme/halo_tokens.dart'; +import '../../../core/theme/widgets/widgets.dart'; + +/// Onboarding step 2 — static value-prop ("USP") cards. No state; just a +/// terminal CTA that routes onward to the auth/payment fork. +class UspScreen extends StatelessWidget { + /// `verified` ➞ USP → OTP (`/auth/register`). + /// `anonymous` ➞ USP → `/payment/method-pick` (Stage 3 owns this route). + final bool verified; + + const UspScreen({super.key, required this.verified}); + + static const _cards = [ + _UspCard( + icon: Icons.bolt_outlined, + title: 'Langsung Curhat', + body: 'Nggak perlu janji, nggak perlu nunggu. Buka, ngobrol, plong.', + ), + _UspCard( + icon: Icons.shield_outlined, + title: 'Tetap Anonim', + body: 'Identitasmu disembunyikan. Cerita apa adanya, tanpa khawatir.', + ), + _UspCard( + icon: Icons.favorite_outline, + title: 'Bestie yang Relate', + body: 'Diisi orang yang udah dilatih buat dengerin, bukan ngehakimin.', + ), + _UspCard( + icon: Icons.payments_outlined, + title: 'Bayar Sesuai Pakai', + body: 'Pilih durasi yang pas. Nggak ada langganan, nggak ada jebakan.', + ), + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Padding( + padding: EdgeInsets.only(top: HaloSpacing.s4), + child: HaloStepDots(total: 4, current: 2), + ), + centerTitle: true, + ), + body: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB( + HaloSpacing.s24, + HaloSpacing.s8, + HaloSpacing.s24, + HaloSpacing.s24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Sebelum mulai', + style: TextStyle( + fontFamily: HaloTokens.fontDisplay, + fontSize: 26, + height: 30 / 26, + fontWeight: FontWeight.w700, + color: HaloTokens.ink, + ), + ), + const SizedBox(height: HaloSpacing.s8), + const Text( + 'Hal-hal kecil yang bikin Halo Bestie beda.', + style: TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 14, + height: 20 / 14, + color: HaloTokens.inkSoft, + ), + ), + const SizedBox(height: HaloSpacing.s24), + Expanded( + child: ListView.separated( + itemCount: _cards.length, + separatorBuilder: (_, __) => + const SizedBox(height: HaloSpacing.s12), + itemBuilder: (_, i) => _cards[i], + ), + ), + const SizedBox(height: HaloSpacing.s16), + HaloButton( + label: 'aku ngerti, lanjut', + fullWidth: true, + onPressed: () => _onContinue(context), + ), + ], + ), + ), + ), + ); + } + + void _onContinue(BuildContext context) { + if (verified) { + context.push('/auth/register'); + } else { + // Stage 3 owns /payment/method-pick. Until then, route there as a + // placeholder; Maestro flow 03 stops at the route arrival. + context.push('/payment/method-pick'); + } + } +} + +class _UspCard extends StatelessWidget { + final IconData icon; + final String title; + final String body; + + const _UspCard({ + required this.icon, + required this.title, + required this.body, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(HaloSpacing.s16), + decoration: BoxDecoration( + color: HaloTokens.surface, + borderRadius: HaloRadius.lg, + border: Border.all(color: HaloTokens.border), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 40, + height: 40, + decoration: const BoxDecoration( + color: HaloTokens.brandSofter, + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Icon(icon, color: HaloTokens.brandDark, size: 20), + ), + const SizedBox(width: HaloSpacing.s12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 16, + fontWeight: FontWeight.w600, + color: HaloTokens.ink, + ), + ), + const SizedBox(height: HaloSpacing.s4), + Text( + body, + style: const TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 13, + height: 18 / 13, + color: HaloTokens.inkSoft, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/client_app/lib/main.dart b/client_app/lib/main.dart index 9fe0ce7..619e465 100644 --- a/client_app/lib/main.dart +++ b/client_app/lib/main.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'core/api/api_client_provider.dart'; import 'core/auth/auth_notifier.dart'; +import 'core/auth/auth_providers_provider.dart'; import 'core/chat/active_session_notifier.dart'; import 'core/chat/chat_notifier.dart'; import 'core/notifications/notification_service.dart'; @@ -30,6 +31,19 @@ class App extends ConsumerStatefulWidget { class _AppState extends ConsumerState { bool _fcmRegistered = false; + bool _authProvidersPreloaded = false; + + @override + void initState() { + super.initState(); + // Phase 4: preload server-driven auth-provider gating once on cold start. + // Cached via @Riverpod(keepAlive: true) — subsequent reads are instant. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_authProvidersPreloaded) return; + _authProvidersPreloaded = true; + ref.read(authProvidersProvider.future); + }); + } void _registerFcmToken() { if (_fcmRegistered) return; diff --git a/client_app/lib/router.dart b/client_app/lib/router.dart index e188cf0..3f5cb20 100644 --- a/client_app/lib/router.dart +++ b/client_app/lib/router.dart @@ -9,6 +9,8 @@ import 'features/auth/screens/otp_screen.dart'; import 'features/auth/screens/force_register_screen.dart'; import 'features/auth/screens/set_display_name_screen.dart'; import 'features/onboarding/onboarding_screen.dart'; +import 'features/onboarding/screens/esp_screen.dart'; +import 'features/onboarding/screens/usp_screen.dart'; import 'features/splash/splash_screen.dart'; import 'features/home/home_screen.dart'; import 'core/constants.dart'; @@ -59,6 +61,11 @@ GoRouter buildRouter(Ref ref) { final isOnboarding = state.matchedLocation == '/onboarding'; final isAuthRoute = state.matchedLocation.startsWith('/auth') || state.matchedLocation == '/welcome'; + // Phase 4 onboarding flow (Verif Choice → ESP → USP) — must transit + // freely while authState is AuthAnonymousData so the router doesn't + // boot the user back to /home before they finish onboarding. + final isOnboardingFlow = + state.matchedLocation.startsWith('/onboarding/'); // Show splash only during initial load if (authState is AsyncLoading) { @@ -84,6 +91,18 @@ GoRouter buildRouter(Ref ref) { } if (data is AuthAuthenticatedData || data is AuthAnonymousData) { + // Allow the Phase 4 onboarding flow (ESP/USP) to stay put even when + // the user is already anonymous-authenticated — display_name_screen + // intentionally pushes into /onboarding/* after loginAnonymous. + if (isOnboardingFlow) return null; + // display_name_screen owns the post-anonymous-login routing decision + // (onboarding-state lookup → Verif Choice Sheet vs returning-user + // jump). Don't preempt it by redirecting to /home the instant the + // anonymous login resolves — wait until the screen pushes onward. + if (data is AuthAnonymousData && + state.matchedLocation == '/auth/display-name') { + return null; + } return (isSplash || isAuthRoute) ? '/home' : null; } if (data is AuthNeedsDisplayNameData) return '/auth/set-name'; @@ -103,6 +122,32 @@ GoRouter buildRouter(Ref ref) { GoRoute(path: '/auth/otp', builder: (context, state) => OtpScreen(phone: state.extra as String)), GoRoute(path: '/auth/set-name', builder: (_, __) => const SetDisplayNameScreen()), GoRoute(path: '/auth/force-register', builder: (_, __) => const ForceRegisterScreen()), + // Phase 4 onboarding sub-flow (Stage 2). Verified vs anonymous branch + // share ESP + USP screens; the parent path drives the post-USP fork. + GoRoute( + path: '/onboarding/verif/esp', + builder: (_, __) => const EspScreen(verified: true), + ), + GoRoute( + path: '/onboarding/verif/usp', + builder: (_, __) => const UspScreen(verified: true), + ), + GoRoute( + path: '/onboarding/anon/esp', + builder: (_, __) => const EspScreen(verified: false), + ), + GoRoute( + path: '/onboarding/anon/usp', + builder: (_, __) => const UspScreen(verified: false), + ), + // Alias for the OTP-blocked popup's "lanjut tanpa verif" exit. The + // popup may fire from any point in the verified branch (after the + // user has already passed ESP+USP), so we expose a stable terminal + // landing-zone alias rather than rewriting all upstream pushes. + GoRoute( + path: '/onboarding/anon/method', + redirect: (_, __) => '/payment/method-pick', + ), GoRoute(path: '/home', builder: (_, __) => const HomeScreen()), GoRoute(path: '/payment', builder: (context, state) { // Payment screen reachable from diff --git a/mitra_app/lib/core/chat/mitra_chat_notifier.dart b/mitra_app/lib/core/chat/mitra_chat_notifier.dart index c41603e..47c3ec4 100644 --- a/mitra_app/lib/core/chat/mitra_chat_notifier.dart +++ b/mitra_app/lib/core/chat/mitra_chat_notifier.dart @@ -32,6 +32,10 @@ class MitraChatConnectedData extends MitraChatData { final bool goodbyeSubmitted; final Map? extensionRequest; final TopicSensitivity topicSensitivity; + // Phase 4 ESP picks the customer made during onboarding. Read-only, + // info-only — does not affect matching, pricing, or routing. Sourced from + // `chat_sessions.topics` via the session info payload. + final List topics; const MitraChatConnectedData({ required this.messages, @@ -42,6 +46,7 @@ class MitraChatConnectedData extends MitraChatData { this.goodbyeSubmitted = false, this.extensionRequest, this.topicSensitivity = TopicSensitivity.regular, + this.topics = const [], }); MitraChatConnectedData copyWith({ @@ -54,6 +59,7 @@ class MitraChatConnectedData extends MitraChatData { Map? extensionRequest, bool clearExtensionRequest = false, TopicSensitivity? topicSensitivity, + List? topics, }) { return MitraChatConnectedData( messages: messages ?? this.messages, @@ -64,6 +70,7 @@ class MitraChatConnectedData extends MitraChatData { goodbyeSubmitted: goodbyeSubmitted ?? this.goodbyeSubmitted, extensionRequest: clearExtensionRequest ? null : (extensionRequest ?? this.extensionRequest), topicSensitivity: topicSensitivity ?? this.topicSensitivity, + topics: topics ?? this.topics, ); } } @@ -130,6 +137,10 @@ class MitraChat extends _$MitraChat { final isClosing = sessionStatus == SessionStatus.closing; final goodbyeSubmittedByMe = sessionData?['goodbye_submitted_by_me'] as bool? ?? false; final sessionTopic = TopicSensitivity.fromString(sessionData?['topic_sensitivity'] as String?); + final rawTopics = sessionData?['topics']; + final espTopics = rawTopics is List + ? rawTopics.whereType().toList(growable: false) + : const []; final response = await _apiClient.get('/api/shared/chat/$sessionId/messages'); final messagesData = response['data'] as List; @@ -172,6 +183,7 @@ class MitraChat extends _$MitraChat { sessionClosing: isClosing, goodbyeSubmitted: goodbyeSubmittedByMe, topicSensitivity: sessionTopic, + topics: espTopics, ); } catch (e) { state = const MitraChatErrorData('Gagal terhubung ke chat.'); diff --git a/mitra_app/lib/features/chat/screens/mitra_chat_screen.dart b/mitra_app/lib/features/chat/screens/mitra_chat_screen.dart index 01b89bd..e6041da 100644 --- a/mitra_app/lib/features/chat/screens/mitra_chat_screen.dart +++ b/mitra_app/lib/features/chat/screens/mitra_chat_screen.dart @@ -310,14 +310,22 @@ class _MitraChatScreenState extends ConsumerState { '[User] Sudah Memasuki Ruangan', () => setState(() => _showUserBanner = false), ), - // Messages + // Messages — when the customer picked ESP topics during + // onboarding, render a read-only chip row as the first list + // item (above the first message bubble). Info-only. Expanded( child: ListView.builder( controller: _scrollController, padding: const EdgeInsets.all(16), - itemCount: state.messages.length, + itemCount: state.messages.length + + (state.topics.isNotEmpty ? 1 : 0), itemBuilder: (context, index) { - final msg = state.messages[index]; + if (state.topics.isNotEmpty && index == 0) { + return _buildTopicChipsRow(state.topics); + } + final msgIndex = + state.topics.isNotEmpty ? index - 1 : index; + final msg = state.messages[msgIndex]; final isMe = msg.senderType == UserType.mitra; return _buildMessageBubble(msg, isMe); }, @@ -340,6 +348,52 @@ class _MitraChatScreenState extends ConsumerState { ); } + // Phase 4 ESP topic display labels. Mirrors the customer-side `EspTopic` + // enum's `label` property — we only need to read these here, not write. + static const Map _espTopicLabels = { + 'relationship': 'Hubungan', + 'family': 'Keluarga', + 'work': 'Pekerjaan', + 'study': 'Sekolah / Kuliah', + 'finance': 'Keuangan', + 'health': 'Kesehatan', + 'friendship': 'Pertemanan', + 'self_worth': 'Self-worth', + 'anxiety': 'Kecemasan', + 'loneliness': 'Kesepian', + 'grief': 'Kehilangan', + 'identity': 'Identitas', + }; + + Widget _buildTopicChipsRow(List topics) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + child: Wrap( + spacing: 6, + runSpacing: 6, + children: topics.map((value) { + final label = _espTopicLabels[value] ?? value; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: const Color(0xFFE0CDD1)), + ), + child: Text( + label, + style: const TextStyle( + fontSize: 12, + color: _kAccentPink, + fontWeight: FontWeight.w500, + ), + ), + ); + }).toList(), + ), + ); + } + Widget _buildEntryBanner(String text, VoidCallback onDismiss) { return Container( color: _kBannerColor,