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:
@@ -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,
|
||||
|
||||
115
client_app/.maestro/flows/02_onboarding_verified.yaml
Normal file
115
client_app/.maestro/flows/02_onboarding_verified.yaml
Normal file
@@ -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
|
||||
71
client_app/.maestro/flows/03_onboarding_anon.yaml
Normal file
71
client_app/.maestro/flows/03_onboarding_anon.yaml
Normal file
@@ -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
|
||||
@@ -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`
|
||||
|
||||
51
client_app/lib/core/auth/auth_providers_provider.dart
Normal file
51
client_app/lib/core/auth/auth_providers_provider.dart
Normal file
@@ -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<AuthProvidersConfig> authProviders(Ref ref) async {
|
||||
try {
|
||||
final response = await ref.read(apiClientProvider).get('/api/shared/auth-providers');
|
||||
final data = response['data'] as Map<String, dynamic>?;
|
||||
if (data == null) return AuthProvidersConfig.fallback;
|
||||
final google = data['google'] as Map<String, dynamic>?;
|
||||
final apple = data['apple'] as Map<String, dynamic>?;
|
||||
final phone = data['phone'] as Map<String, dynamic>?;
|
||||
return AuthProvidersConfig(
|
||||
google: (google?['enabled'] as bool?) ?? false,
|
||||
apple: (apple?['enabled'] as bool?) ?? false,
|
||||
phone: (phone?['enabled'] as bool?) ?? true,
|
||||
);
|
||||
} catch (_) {
|
||||
return AuthProvidersConfig.fallback;
|
||||
}
|
||||
}
|
||||
33
client_app/lib/core/auth/auth_providers_provider.g.dart
Normal file
33
client_app/lib/core/auth/auth_providers_provider.g.dart
Normal file
@@ -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<AuthProvidersConfig>.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<AuthProvidersConfig>;
|
||||
// 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
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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)
|
||||
|
||||
@@ -6,7 +6,7 @@ part of 'session_closure_notifier.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$sessionClosureHash() => r'f238e4098aefdd942314a13eb3fb77ed19cd2b77';
|
||||
String _$sessionClosureHash() => r'521e57f74805faf01f11778872cb37ceae683a5b';
|
||||
|
||||
/// See also [SessionClosure].
|
||||
@ProviderFor(SessionClosure)
|
||||
|
||||
@@ -6,7 +6,7 @@ part of 'pairing_notifier.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$pairingHash() => r'0fe1e5646fe70b90f5489919ec8dd557c998daad';
|
||||
String _$pairingHash() => r'd39980fe5b82348d03485006d0534ab597b93ceb';
|
||||
|
||||
/// See also [Pairing].
|
||||
@ProviderFor(Pairing)
|
||||
|
||||
@@ -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),
|
||||
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 tidak akan terlihat oleh siapapun selain mitra kamu.'),
|
||||
const SizedBox(height: 24),
|
||||
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,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s24),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Nama panggilan',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: isLoading ? null : _submit,
|
||||
child: isLoading
|
||||
? const CircularProgressIndicator()
|
||||
: const Text('Lanjut'),
|
||||
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,7 +54,8 @@ class _ForceRegisterScreenState extends ConsumerState<ForceRegisterScreen> {
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (kSocialAuthEnabled) ...[
|
||||
if (providers.hasAnySocial) ...[
|
||||
if (providers.google) ...[
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.g_mobiledata),
|
||||
onPressed: isLoading ? null
|
||||
@@ -59,12 +63,15 @@ class _ForceRegisterScreenState extends ConsumerState<ForceRegisterScreen> {
|
||||
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,25 +50,27 @@ 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 &&
|
||||
if (err is AuthErrorInfo) {
|
||||
if (_kOtpBlockedCodes.contains(err.code) && !_blockedPopupShown) {
|
||||
_blockedPopupShown = true;
|
||||
OtpBlockedPopup.show(context).then((_) {
|
||||
if (mounted) _blockedPopupShown = false;
|
||||
});
|
||||
}
|
||||
if (err.retryAfterSeconds != null &&
|
||||
(err.code == 'OTP_COOLDOWN' ||
|
||||
err.code == 'OTP_RATE_LIMIT_PHONE' ||
|
||||
err.code == 'OTP_RATE_LIMIT_IP')) {
|
||||
_resendCooldown = err.retryAfterSeconds!;
|
||||
_startResendCountdown();
|
||||
}
|
||||
}
|
||||
} else if (next is AsyncLoading || next is AsyncData) {
|
||||
if (_errorMessage != null && mounted) {
|
||||
setState(() => _errorMessage = null);
|
||||
@@ -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),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(HaloSpacing.s24),
|
||||
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),
|
||||
Text(
|
||||
'Kode OTP telah dikirim ke ${widget.phone}',
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
fontSize: 15,
|
||||
color: HaloTokens.inkSoft,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
),
|
||||
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: TextStyle(color: Colors.red.shade700, fontSize: 13),
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
color: HaloTokens.danger,
|
||||
fontSize: 13,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s12),
|
||||
if (isLoading)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
padding:
|
||||
EdgeInsets.symmetric(vertical: HaloSpacing.s8),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
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,35 +82,63 @@ 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),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(HaloSpacing.s24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (kSocialAuthEnabled) ...[
|
||||
ElevatedButton.icon(
|
||||
if (providers.hasAnySocial) ...[
|
||||
if (providers.google) ...[
|
||||
HaloButton(
|
||||
label: 'lanjut dengan Google',
|
||||
icon: const Icon(Icons.g_mobiledata),
|
||||
onPressed: isLoading ? null
|
||||
variant: HaloButtonVariant.secondary,
|
||||
fullWidth: true,
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: () => ref.read(authProvider.notifier).loginGoogle(),
|
||||
label: const Text('Lanjut dengan Google'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton.icon(
|
||||
const SizedBox(height: HaloSpacing.s12),
|
||||
],
|
||||
if (providers.apple) ...[
|
||||
HaloButton(
|
||||
label: 'lanjut dengan Apple',
|
||||
icon: const Icon(Icons.apple),
|
||||
onPressed: isLoading ? null
|
||||
variant: HaloButtonVariant.secondary,
|
||||
fullWidth: true,
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: () => ref.read(authProvider.notifier).loginApple(),
|
||||
label: const Text('Lanjut dengan Apple'),
|
||||
),
|
||||
const SizedBox(height: HaloSpacing.s12),
|
||||
],
|
||||
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()),
|
||||
]),
|
||||
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(
|
||||
@@ -118,34 +146,41 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Nomor HP',
|
||||
hintText: '+628xxxxxxxxxx',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.phone,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: canSubmit ? () {
|
||||
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,
|
||||
child: isLoading
|
||||
? const CircularProgressIndicator()
|
||||
: Text(isLockedOut
|
||||
? 'Coba lagi dalam ${formatCountdown(_lockoutSeconds)}'
|
||||
: 'Kirim OTP'),
|
||||
}
|
||||
: null,
|
||||
),
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: HaloSpacing.s12),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.red.shade700, fontSize: 13),
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontBody,
|
||||
color: HaloTokens.danger,
|
||||
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;
|
||||
}
|
||||
}
|
||||
12
client_app/lib/features/onboarding/esp_state.dart
Normal file
12
client_app/lib/features/onboarding/esp_state.dart
Normal file
@@ -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<EspTopic>>((_) => <EspTopic>{});
|
||||
|
||||
/// 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<bool>((_) => false);
|
||||
34
client_app/lib/features/onboarding/esp_topic.dart
Normal file
34
client_app/lib/features/onboarding/esp_topic.dart
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
132
client_app/lib/features/onboarding/screens/esp_screen.dart
Normal file
132
client_app/lib/features/onboarding/screens/esp_screen.dart
Normal file
@@ -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<EspTopic>.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 = <EspTopic>{};
|
||||
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);
|
||||
}
|
||||
}
|
||||
177
client_app/lib/features/onboarding/screens/usp_screen.dart
Normal file
177
client_app/lib/features/onboarding/screens/usp_screen.dart
Normal file
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<App> {
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -32,6 +32,10 @@ class MitraChatConnectedData extends MitraChatData {
|
||||
final bool goodbyeSubmitted;
|
||||
final Map<String, dynamic>? 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<String> 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<String, dynamic>? extensionRequest,
|
||||
bool clearExtensionRequest = false,
|
||||
TopicSensitivity? topicSensitivity,
|
||||
List<String>? 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<String>().toList(growable: false)
|
||||
: const <String>[];
|
||||
|
||||
final response = await _apiClient.get('/api/shared/chat/$sessionId/messages');
|
||||
final messagesData = response['data'] as List<dynamic>;
|
||||
@@ -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.');
|
||||
|
||||
@@ -310,14 +310,22 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
|
||||
'[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<MitraChatScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// 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<String, String> _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<String> 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,
|
||||
|
||||
Reference in New Issue
Block a user