Phase 4 checkpoint: chat-screen perf refactor + retryable blast-failure + repo-wide dispose-ref guardrail

Chat-screen performance (customer + mitra):
- Parent screens have zero `ref.watch` — only `ref.listen` for side effects
- Body extracted into its own `ConsumerStatefulWidget`; AppBar parts split
  into narrow `.select` consumers (mode, sensitivity, timer)
- Per-second timer ticks routed to dedicated providers
  (`chatRemainingSecondsProvider` + new `mitraChatRemainingSecondsProvider`)
  so WS `session_tick` frames don't invalidate the rest of the chat state

Dispose-in-ref bug fix:
- `home_screen.dart`, `payment_screen.dart`, `mitra_chat_screen.dart` —
  ref-using cleanup moved from `dispose()` to `deactivate()`. Modern
  Riverpod invalidates `ref` the moment `dispose()` runs; the resulting
  silent error corrupts the widget-tree finalize and the next screen
  appears frozen
- `halo_lints` package added at repo root with `no_ref_in_dispose` rule
  to catch this pattern in CI / IDE analysis
- `custom_lint` activated in both apps' `analysis_options.yaml`
  (was installed but never wired in — also brings `riverpod_lint`'s
  `avoid_ref_inside_state_dispose` online)
- CLAUDE.md Pitfalls section added to client_app + mitra_app

Phase 4 §3 retryable blast-failure (Option A):
- Backend `expirePairingRequest` + all-rejected use
  `recordIntermediateFailure` instead of `failPaymentSession` so the
  payment session stays `confirmed` for re-blast
- WS `pairing_failed` payload carries `is_terminal: false` on the
  retryable paths; client parses the flag and exposes `retryBlast()`
- "Coba cari lagi" CTA on S7 Timeout now re-blasts on the same payment
- Pairing service test updated to reflect the new semantics

Customer waiting-payment screen navigation patch:
- `_navigateTerminal` uses `Future.microtask` + `addPostFrameCallback`
  redundancy after a release-mode bug where polling stopped but
  `context.go` never fired, leaving the screen visually stuck on
  "menunggu pembayaran"

See requirement/resume-2026-05-15.md for next-day pickup checklist
(mitra release rebuild + S21 Ultra install + retest is the gating item).

Bundles unrelated in-flight Phase 4 §2.x work that was already on disk
(ESP screen removal, USP one-time gate scaffolding, bestie-availability
public route, OTP service edits, Maestro flow tweaks) — kept together
to avoid a partial-rebase mess.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 19:12:34 +08:00
parent a48f108fc0
commit a09f37135c
56 changed files with 3417 additions and 1093 deletions

View File

@@ -1,6 +1,6 @@
# 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)
# Phase 4 — verified onboarding path (post-ESP retirement, 2026-05-12):
# Splash → Display Name → Verif Choice Sheet (verifikasi nomor HP) →
# USP one-time gate (first-time userUSP screen) → Register → OTP (6-digit)
# → S6 paywall (when first-session-discount eligible) or duration picker.
#
# Run:
@@ -26,6 +26,7 @@ env:
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
# Onboarding carousel (still present per Phase 4 Stage 9 minimum-touch).
- extendedWaitUntil:
visible:
text: "Mulai"
@@ -33,12 +34,14 @@ env:
- tapOn:
text: "Mulai"
retryTapIfNoChange: true
# Phase 4 Stage 9: /welcome is retired. SHome1st CTA "aku mau curhat"
# leads into the onboarding flow.
- extendedWaitUntil:
visible:
text: "Lanjut sebagai Tamu"
text: "aku mau curhat"
timeout: 10000
- tapOn:
text: "Lanjut sebagai Tamu"
text: "aku mau curhat"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
@@ -59,21 +62,14 @@ env:
- 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
# USP one-time gate — first run after clearState, so usp_seen=false → USP shown.
# Explicitly assert ESP "Lagi mikirin apa?" is NOT visible to catch regression.
- extendedWaitUntil:
visible:
text: "Sebelum mulai"
timeout: 10000
- assertNotVisible:
text: "Lagi mikirin apa?"
- tapOn:
text: "aku ngerti, lanjut"
retryTapIfNoChange: true

View File

@@ -1,7 +1,6 @@
# 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).
# Phase 4 — anonymous onboarding path (post-ESP retirement, 2026-05-12):
# Splash → Display Name → Verif Choice Sheet (curhat anonim) → USP one-time
# gate (first-time user → USP screen) → arrival at /payment/method-pick.
#
# Run:
# maestro test client_app/.maestro/flows/03_onboarding_anon.yaml
@@ -14,6 +13,7 @@ appId: com.halobestie.client.client_app
---
- launchApp:
clearState: true
# Onboarding carousel.
- extendedWaitUntil:
visible:
text: "Mulai"
@@ -21,12 +21,15 @@ appId: com.halobestie.client.client_app
- tapOn:
text: "Mulai"
retryTapIfNoChange: true
# Phase 4 Stage 9: SHome1st "aku mau curhat" CTA replaces the old
# /welcome "Lanjut sebagai Tamu" step. Bumped timeout: SHome1st has to
# wait on mitra-availability before the CTA renders.
- extendedWaitUntil:
visible:
text: "Lanjut sebagai Tamu"
timeout: 10000
text: "aku mau curhat"
timeout: 20000
- tapOn:
text: "Lanjut sebagai Tamu"
text: "aku mau curhat"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
@@ -47,19 +50,14 @@ appId: com.halobestie.client.client_app
- 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
# USP one-time gate — first run after clearState, so usp_seen=false → USP shown.
# Assert ESP "Lagi mikirin apa?" is NOT visible to catch regression.
- extendedWaitUntil:
visible:
text: "Sebelum mulai"
timeout: 10000
- assertNotVisible:
text: "Lagi mikirin apa?"
- tapOn:
text: "aku ngerti, lanjut"
retryTapIfNoChange: true

View File

@@ -26,3 +26,27 @@ Flutter mobile application for end users (clients) seeking mental health support
- 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
- 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`
## Pitfalls (HARD rules — silent failure modes)
### Never call `ref.read` / `ref.watch` / `ref.listen` from `State.dispose()`
In a `ConsumerStatefulWidget`, Riverpod invalidates `ref` the instant `dispose()` starts. Any `ref.*` call throws `Bad state: Cannot use "ref" after the widget was disposed.`. Flutter catches it inside `BuildOwner.finalizeTree`**so it does not surface as a red-screen crash**. Instead the widget tree is left half-finalized and the NEXT screen freezes (looks like a hang; the app process is alive). Two real cases (2026-05-14): [home_screen.dart] + [payment_screen.dart].
**Rule:** any cleanup that needs `ref` goes in `deactivate()`, which runs *before* `dispose()` while `ref` is still valid. Non-Riverpod cleanup (`TextEditingController.dispose()`, `WidgetsBinding.removeObserver`, `StreamSubscription.cancel`) stays in `dispose()`.
```dart
@override
void deactivate() {
ref.read(someProvider.notifier).cleanup();
super.deactivate();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
```
When debugging "screen frozen after navigation", grep the *previous* screen's State for `void dispose()` followed by `ref\.` — that's the first suspect.

View File

@@ -9,6 +9,14 @@
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
analyzer:
plugins:
# Activates custom_lint, which loads:
# - riverpod_lint (dev_dep) — upstream Riverpod rules
# - halo_lints (path: halo_lints/) — repo-specific rules, e.g.
# `no_ref_in_dispose`. See client_app/CLAUDE.md → Pitfalls.
- custom_lint
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`

View File

@@ -54,8 +54,16 @@ class ApiClient {
));
}
Future<Map<String, dynamic>> get(String path, {Map<String, dynamic>? queryParameters}) async {
final response = await _dio.get(path, queryParameters: queryParameters);
Future<Map<String, dynamic>> get(
String path, {
Map<String, dynamic>? queryParameters,
bool skipAuth = false,
}) async {
final response = await _dio.get(
path,
queryParameters: queryParameters,
options: skipAuth ? Options(extra: {_skipAuthKey: true}) : null,
);
return response.data as Map<String, dynamic>;
}

View File

@@ -6,16 +6,16 @@ part 'mitra_availability_notifier.g.dart';
/// 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
/// [setActive] from `WidgetsBindingObserver.didChangeAppLifecycleState`:
/// Polls `GET /api/public/bestie/available` every 5 seconds while the home
/// screen is in the foreground. The endpoint is unauthenticated by design —
/// SHome1st renders before any JWT exists, and the CTA's enabled state needs
/// to reflect global availability so users see whether bestie is online
/// before committing to onboarding. Polling is gated by the home screen
/// calling [setActive] from `WidgetsBindingObserver.didChangeAppLifecycleState`:
/// - resumed → setActive(true)
/// - paused/inactive → setActive(false)
///
/// On any HTTP error we emit `false` (never display stale state).
///
/// The endpoint also returns a `count`, but the customer UI must only read the
/// binary `available` field — the count is for CC/debug only.
@Riverpod(keepAlive: true)
class MitraAvailability extends _$MitraAvailability {
Timer? _pollTimer;
@@ -63,7 +63,10 @@ class MitraAvailability extends _$MitraAvailability {
bool available;
try {
final api = ref.read(apiClientProvider);
final response = await api.get('/api/client/mitra-availability');
final response = await api.get(
'/api/public/bestie/available',
skipAuth: true,
);
final data = response['data'] as Map<String, dynamic>?;
available = data?['available'] as bool? ?? false;
} catch (_) {

View File

@@ -6,21 +6,21 @@ part of 'mitra_availability_notifier.dart';
// RiverpodGenerator
// **************************************************************************
String _$mitraAvailabilityHash() => r'036de8fea66c6a0114abd4a422af12186c1447d7';
String _$mitraAvailabilityHash() => r'e385c671720973cd1cea4b15933cd59421f035f0';
/// 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
/// [setActive] from `WidgetsBindingObserver.didChangeAppLifecycleState`:
/// Polls `GET /api/public/bestie/available` every 5 seconds while the home
/// screen is in the foreground. The endpoint is unauthenticated by design —
/// SHome1st renders before any JWT exists, and the CTA's enabled state needs
/// to reflect global availability so users see whether bestie is online
/// before committing to onboarding. Polling is gated by the home screen
/// calling [setActive] from `WidgetsBindingObserver.didChangeAppLifecycleState`:
/// - resumed → setActive(true)
/// - paused/inactive → setActive(false)
///
/// On any HTTP error we emit `false` (never display stale state).
///
/// 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)
final mitraAvailabilityProvider =

View File

@@ -456,10 +456,18 @@ class Chat extends _$Chat {
case WsMessage.extensionResponse:
final accepted = data['accepted'] as bool? ?? false;
// On accept, the backend includes the freshly-extended `expires_at` so
// the local ticker can resume immediately (otherwise it would be stuck
// at 0 / the just-expired moment until the next SESSION_TIMER ping).
final extendedExpiresAtRaw = data['expires_at'] as String?;
final extendedExpiresAt = (accepted && extendedExpiresAtRaw != null)
? DateTime.tryParse(extendedExpiresAtRaw)?.toLocal()
: null;
state = current.copyWith(
extensionResponse: data,
sessionPaused: accepted ? false : current.sessionPaused,
sessionExpired: accepted ? false : current.sessionExpired,
expiresAt: extendedExpiresAt ?? current.expiresAt,
);
break;

View File

@@ -30,7 +30,7 @@ final chatRemainingSecondsProvider = AutoDisposeStreamProvider<int>.internal(
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef ChatRemainingSecondsRef = AutoDisposeStreamProviderRef<int>;
String _$chatHash() => r'f9d77e176acb682dc6477635e0e80a09e846988b';
String _$chatHash() => r'a42bf404f5b38034c6c9511259b64b1092af3caa';
/// See also [Chat].
@ProviderFor(Chat)

View File

@@ -6,7 +6,7 @@ part of 'session_closure_notifier.dart';
// RiverpodGenerator
// **************************************************************************
String _$sessionClosureHash() => r'521e57f74805faf01f11778872cb37ceae683a5b';
String _$sessionClosureHash() => r'a9aaf26da64d3489497d057e2b05741c08444e07';
/// See also [SessionClosure].
@ProviderFor(SessionClosure)

View File

@@ -30,9 +30,14 @@ class PairingSearchingData extends PairingData {
/// the payment-session-scoped cancel endpoint without re-prompting.
final String paymentSessionId;
/// Carried so a retryable PAIRING_FAILED can preserve the customer's original
/// topic choice when looping back into Blast via retryBlast().
final TopicSensitivity topicSensitivity;
const PairingSearchingData({
required this.sessionId,
required this.paymentSessionId,
required this.topicSensitivity,
});
}
@@ -105,13 +110,26 @@ class PairingTargetedUnavailableData extends PairingData {
});
}
/// Terminal pairing failure — payment session is in `failed_pairing`. Routes
/// to the failed-pairing screen (no_bestie_screen).
/// Pairing failure surfaced on the S7 Timeout screen.
///
/// `isRetryable=true` means the backend kept the payment session `confirmed`
/// (audit-only failure) so the customer can re-blast on the same payment via
/// `retryBlast()`. `isRetryable=false` means the payment is in `failed_pairing`
/// and any retry must start from a fresh payment session.
class PairingFailedData extends PairingData {
final PairingFailureCause cause;
final String? paymentSessionId;
final bool isRetryable;
// Carried so retryBlast() can re-issue the blast with the customer's original
// topic choice. Null when the failure originated before any topic was known.
final TopicSensitivity? topicSensitivity;
const PairingFailedData({required this.cause, this.paymentSessionId});
const PairingFailedData({
required this.cause,
this.paymentSessionId,
this.isRetryable = false,
this.topicSensitivity,
});
}
class PairingCancelledData extends PairingData {
@@ -156,6 +174,7 @@ class Pairing extends _$Pairing {
state = PairingSearchingData(
sessionId: sessionId,
paymentSessionId: paymentSessionId,
topicSensitivity: topicSensitivity,
);
} on DioException catch (e) {
_cleanup();
@@ -279,6 +298,7 @@ class Pairing extends _$Pairing {
state = PairingSearchingData(
sessionId: sessionId,
paymentSessionId: paymentSessionId,
topicSensitivity: topicSensitivity,
);
} on DioException catch (e) {
_cleanup();
@@ -302,6 +322,25 @@ class Pairing extends _$Pairing {
state = const PairingInitialData();
}
/// "Coba Cari Lagi" CTA on the S7 Timeout screen when the payment was kept
/// `confirmed` (retryable failure). Re-blasts on the same payment session.
///
/// Caller should only invoke this when `state is PairingFailedData &&
/// state.isRetryable && paymentSessionId != null && topicSensitivity != null`.
Future<void> retryBlast() async {
final current = state;
if (current is! PairingFailedData
|| !current.isRetryable
|| current.paymentSessionId == null
|| current.topicSensitivity == null) {
return;
}
await startSearch(
paymentSessionId: current.paymentSessionId!,
topicSensitivity: current.topicSensitivity!,
);
}
// ---- Internal ---------------------------------------------------------
Future<void> _connectWebSocket() async {
@@ -348,13 +387,20 @@ class Pairing extends _$Pairing {
}
if (type == WsMessage.pairingFailed) {
// Terminal — payment_session is in failed_pairing server-side.
final causeTag = data['cause_tag'] as String?;
final paymentSessionId = data['payment_session_id'] as String?;
// Missing flag = terminal (backward-compat with older emit sites). When
// false, the backend kept the payment confirmed and we can re-blast.
final isRetryable = data['is_terminal'] == false;
final carriedTopic = current is PairingSearchingData
? current.topicSensitivity
: null;
_cleanup();
state = PairingFailedData(
cause: PairingFailureCause.fromString(causeTag),
paymentSessionId: paymentSessionId,
isRetryable: isRetryable,
topicSensitivity: carriedTopic,
);
return;
}

View File

@@ -89,7 +89,7 @@ class _DisplayNameScreenState extends ConsumerState<DisplayNameScreen> {
return;
}
if (!mounted) return;
routeForVerifChoice(context, choice);
await routeForVerifChoice(context, ref, choice);
}
@override

View File

@@ -1,13 +1,28 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/auth/auth_notifier.dart';
import '../../../core/auth/auth_providers_provider.dart';
import '../../../core/constants.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart';
/// S3a — WhatsApp input screen.
///
/// Visual contract is `Figma/screens/onboarding.jsx::S3Phone`:
/// - HaloStepDots at the top (step 3 of 4: S2 Nama → S5 ESP → S5b USP → S3a)
/// - Personalised display-title `"nomor wa-mu, {name}?"`
/// - +62 prefix as static chip; user types only the trailing digits
/// - Privacy reassurance card
/// - Primary CTA `"kirim kode"` (disabled until ≥9 digits)
/// - Ghost link `"lanjut tanpa verifikasi (harga normal)"` → anonymous path
///
/// Two callers route here:
/// 1. New-user verified onboarding (USP → here) — auth state has the
/// anonymous display_name set by S2.
/// 2. SHome1st "masuk →" banner for returning-user recovery — auth state
/// is initial; the name greeting falls back to "kamu".
class RegisterScreen extends ConsumerStatefulWidget {
const RegisterScreen({super.key});
@@ -19,8 +34,9 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
final _phoneController = TextEditingController();
ProviderSubscription<AsyncValue<AuthData>>? _authSub;
// Server-imposed lockout: when /otp/request returns 429, the backend
// includes retry_after_seconds. We disable "Kirim OTP" for that window.
// Server-imposed lockout from /otp/request 429s. Backend embeds
// retry_after_seconds in the AuthErrorInfo so we can disable the CTA
// until the next slot opens.
int _lockoutSeconds = 0;
Timer? _lockoutTimer;
String? _errorMessage;
@@ -28,13 +44,14 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
@override
void initState() {
super.initState();
_phoneController.addListener(() => setState(() {}));
_authSub = ref.listenManual<AsyncValue<AuthData>>(authProvider, (prev, next) {
if (!mounted) return;
final data = next.valueOrNull;
if (data is AuthOtpSentData) {
// Use go (replace) so re-submitting the phone form doesn't stack
// multiple OtpScreen instances with active listeners.
context.go('/auth/otp', extra: _phoneController.text.trim());
// go (replace) so re-submitting the form doesn't stack OtpScreens
// with leftover listeners.
context.go('/auth/otp', extra: _e164Phone());
return;
}
if (next is AsyncError) {
@@ -76,107 +93,130 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
});
}
/// Subscriber digits with leading zeros stripped. Users commonly type
/// `0812…` (local format); the backend wants `+62812…`, so the 0 must go.
String _subscriberDigits() {
final digits = _phoneController.text.replaceAll(RegExp(r'\D'), '');
return digits.replaceFirst(RegExp(r'^0+'), '');
}
/// Local digits (no country code) → E.164 string the backend expects.
String _e164Phone() => '+62${_subscriberDigits()}';
String _greetingName(AuthData? data) => switch (data) {
AuthAnonymousData d => d.displayName,
AuthAuthenticatedData d => (d.profile['display_name'] as String?) ?? '',
AuthNeedsDisplayNameData d => (d.profile['display_name'] as String?) ?? '',
_ => '',
};
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
final isLoading = authState is AsyncLoading;
final isLockedOut = _lockoutSeconds > 0;
final canSubmit = !isLoading && !isLockedOut;
final providersAsync = ref.watch(authProvidersProvider);
final providers =
providersAsync.valueOrNull ?? AuthProvidersConfig.fallback;
final hasMinDigits = _subscriberDigits().length >= 9;
final canSubmit = hasMinDigits && !isLoading && !isLockedOut;
final name = _greetingName(authState.valueOrNull);
final shownName = name.isEmpty ? 'kamu' : name;
return Scaffold(
appBar: AppBar(title: const Text('Masuk / Daftar')),
backgroundColor: HaloTokens.bg,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(HaloSpacing.s24),
padding: const EdgeInsets.fromLTRB(28, 8, 28, 28),
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,
),
const Padding(
padding: EdgeInsets.only(top: 8, bottom: 8),
child: HaloStepDots(total: 4, current: 3),
),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'nomor wa-mu, $shownName?',
style: const TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 28,
fontWeight: FontWeight.w700,
color: HaloTokens.brandDark,
height: 1.15,
letterSpacing: -0.56,
),
),
const SizedBox(height: 10),
const Text(
'supaya bisa lanjut kapan aja, dan dapat harga khusus pengguna baru.',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 14.5,
color: HaloTokens.inkSoft,
height: 1.5,
),
),
const SizedBox(height: 24),
_PhoneRow(
controller: _phoneController,
borderColor: hasMinDigits
? HaloTokens.brand
: HaloTokens.border,
),
const SizedBox(height: 16),
const _PrivacyCard(),
if (_errorMessage != null) ...[
const SizedBox(height: 12),
Text(
_errorMessage!,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
color: HaloTokens.danger,
fontSize: 13,
),
),
Expanded(child: Divider(color: HaloTokens.border)),
],
),
],
),
],
TextField(
controller: _phoneController,
decoration: const InputDecoration(
labelText: 'Nomor HP',
hintText: '+628xxxxxxxxxx',
),
keyboardType: TextInputType.phone,
),
const SizedBox(height: HaloSpacing.s16),
HaloButton(
label: isLoading
? 'memproses...'
: isLockedOut
? 'coba lagi dalam ${formatCountdown(_lockoutSeconds)}'
: 'kirim OTP',
: 'kirim kode',
fullWidth: true,
onPressed: canSubmit
? () {
final phone = _phoneController.text.trim();
if (phone.isEmpty) return;
ref.read(authProvider.notifier).requestOtp(phone);
}
? () => ref
.read(authProvider.notifier)
.requestOtp(_e164Phone())
: null,
),
if (_errorMessage != null) ...[
const SizedBox(height: HaloSpacing.s12),
Text(
_errorMessage!,
textAlign: TextAlign.center,
style: const TextStyle(
const SizedBox(height: 4),
TextButton(
onPressed: isLoading
? null
// Skip ESPb/USPb — the verified branch already ran ESPa+USPa,
// so the redirect alias drops the user straight at PickMethod.
: () => context.go('/onboarding/anon/method'),
style: TextButton.styleFrom(
foregroundColor: HaloTokens.inkSoft,
minimumSize: const Size(0, 40),
),
child: const Text(
'lanjut tanpa verifikasi (harga normal)',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
color: HaloTokens.danger,
fontSize: 13,
fontSize: 12.5,
decoration: TextDecoration.underline,
decorationColor: HaloTokens.inkSoft,
),
),
],
),
],
),
),
@@ -184,3 +224,105 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
);
}
}
class _PhoneRow extends StatelessWidget {
final TextEditingController controller;
final Color borderColor;
const _PhoneRow({required this.controller, required this.borderColor});
@override
Widget build(BuildContext context) {
return Container(
height: 60,
padding: const EdgeInsets.symmetric(horizontal: 18),
decoration: BoxDecoration(
color: HaloTokens.surface,
borderRadius: HaloRadius.lg,
border: Border.all(color: borderColor, width: 1.5),
),
child: Row(
children: [
const Text(
'🇮🇩 +62',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 15,
fontWeight: FontWeight.w600,
color: HaloTokens.ink,
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: controller,
keyboardType: TextInputType.phone,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
maxLength: 13, // enough headroom for 12-digit ID mobiles
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 16,
fontWeight: FontWeight.w500,
color: HaloTokens.ink,
),
decoration: const InputDecoration(
hintText: '812 3456 7890',
hintStyle: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 16,
fontWeight: FontWeight.w500,
color: HaloTokens.inkMuted,
),
// Override the app-wide inputDecorationTheme so the input
// sits flush inside the outer pill — no fill, no border.
filled: false,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
errorBorder: InputBorder.none,
focusedErrorBorder: InputBorder.none,
disabledBorder: InputBorder.none,
isCollapsed: true,
counterText: '',
contentPadding: EdgeInsets.symmetric(vertical: 18),
),
),
),
],
),
);
}
}
class _PrivacyCard extends StatelessWidget {
const _PrivacyCard();
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: HaloTokens.brandSofter,
borderRadius: HaloRadius.md,
border: Border.all(color: HaloTokens.brandSoft),
),
child: const Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('🛡️', style: TextStyle(fontSize: 14)),
SizedBox(width: 10),
Expanded(
child: Text(
'anonim — bestie cuma tau nama panggilan kamu. nomor gak akan dishare.',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 12,
color: HaloTokens.brandDark,
height: 1.45,
),
),
),
],
),
);
}
}

View File

@@ -1,89 +0,0 @@
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 ConsumerWidget {
const WelcomeScreen({super.key});
@override
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(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(
fontFamily: HaloTokens.fontDisplay,
fontSize: 32,
fontWeight: FontWeight.w700,
color: HaloTokens.ink,
),
textAlign: TextAlign.center,
),
const SizedBox(height: HaloSpacing.s8),
const Text(
'Tempat curhat kamu',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 15,
color: HaloTokens.inkSoft,
),
textAlign: TextAlign.center,
),
const SizedBox(height: HaloSpacing.s48),
HaloButton(
label: 'Lanjut sebagai Tamu',
fullWidth: true,
onPressed: () => context.push('/auth/display-name'),
),
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'),
),
],
),
),
),
);
}
}

View File

@@ -7,8 +7,12 @@ import '../../support/widgets/tanya_admin_sheet.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 "hubungi admin" CTA
/// that opens the Tanya Admin sheet.
/// anonymous flow and a "hubungi admin" CTA that opens the Tanya Admin sheet.
///
/// By the time this popup can fire, the USP one-time gate has already been
/// evaluated upstream on `VerifChoiceSheet` (either shown + marked seen, or
/// skipped because already seen). The exit can therefore jump straight into
/// `/payment/method-pick` regardless.
class OtpBlockedPopup {
const OtpBlockedPopup._();
@@ -35,12 +39,7 @@ class OtpBlockedPopup {
),
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');
},
onPressed: () => context.go('/onboarding/anon/method'),
),
secondary: HaloPopupAction(
label: 'hubungi admin',

View File

@@ -1,7 +1,9 @@
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 '../../onboarding/usp_seen_provider.dart';
/// Result of the post-name Verif Choice Sheet. Caller routes to the matching
/// onboarding sub-flow.
@@ -65,13 +67,26 @@ class VerifChoiceSheet extends StatelessWidget {
}
/// Helper: route to the right onboarding sub-flow for a verif choice.
void routeForVerifChoice(BuildContext context, VerifChoice choice) {
///
/// Phase 4 (2026-05-12): the S5 ESP screen is retired and S5b USP is now a
/// one-time gate. If the user has already seen USP (local SharedPreferences
/// flag, OR-merged with `customers.usp_seen` on login), we skip USP entirely
/// and jump to the per-branch next step.
Future<void> routeForVerifChoice(
BuildContext context,
WidgetRef ref,
VerifChoice choice,
) async {
final seen = await ref.read(uspSeenProvider.future);
if (!context.mounted) return;
switch (choice) {
case VerifChoice.verified:
context.push('/onboarding/verif/esp');
context.push(seen ? '/auth/register' : '/onboarding/verif/usp');
break;
case VerifChoice.anonymous:
context.push('/onboarding/anon/esp');
// `/onboarding/anon/method` redirects to `/payment/method-pick`; use the
// canonical destination here.
context.push(seen ? '/payment/method-pick' : '/onboarding/anon/usp');
break;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -191,8 +191,14 @@ class _SearchingBody extends ConsumerWidget {
fullWidth: true,
size: HaloButtonSize.lg,
onPressed: () {
ref.read(pairingProvider.notifier).reset();
context.go('/payment/entry');
final notifier = ref.read(pairingProvider.notifier);
final s = state;
if (s is PairingFailedData && s.isRetryable) {
notifier.retryBlast();
} else {
notifier.reset();
context.go('/payment/entry');
}
},
),
const SizedBox(height: HaloSpacing.s8),

View File

@@ -1,56 +1,78 @@
import 'package:flutter/material.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/halo_button.dart';
/// Floating banner injected above the chat input bar when the session timer
/// has hit zero but the session is still in `closing` grace. Phase 4 Stage 6:
/// gives the customer a soft, in-place way to extend instead of the modal-only
/// flow from Phase 3.
/// Floating expired banner shown above the chat input when the session
/// timer has hit zero but the session is still in `closing` grace.
///
/// Mirrors Figma `v3.jsx::HBChatExpiredBanner` (line 423): brand-pink
/// background, white text, `⏰` icon, "habis nih... mau lanjutin curhat
/// sama {name}?" copy, white `perpanjang` button.
class ChatExpiredBanner extends StatelessWidget {
final String mitraName;
final VoidCallback onExtend;
const ChatExpiredBanner({super.key, required this.onExtend});
const ChatExpiredBanner({
super.key,
required this.mitraName,
required this.onExtend,
});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.fromLTRB(
HaloSpacing.s12,
HaloSpacing.s8,
HaloSpacing.s12,
HaloSpacing.s8,
),
padding: const EdgeInsets.fromLTRB(
HaloSpacing.s16,
HaloSpacing.s12,
HaloSpacing.s12,
HaloSpacing.s12,
),
decoration: const BoxDecoration(
color: HaloTokens.danger,
borderRadius: HaloRadius.lg,
boxShadow: HaloShadows.card,
margin: const EdgeInsets.fromLTRB(12, 0, 12, 8),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: HaloTokens.brand,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: HaloTokens.brand.withValues(alpha: 0.31),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: Row(
children: [
const Text('', style: TextStyle(fontSize: 20)),
const SizedBox(width: HaloSpacing.s12),
const Expanded(
child: Text(
'waktu curhat habis',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.white,
const Text('', style: TextStyle(fontSize: 16)),
const SizedBox(width: 10),
Expanded(
child: RichText(
text: TextSpan(
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 12.5,
height: 1.4,
color: Colors.white,
),
children: [
const TextSpan(
text: 'habis nih...',
style: TextStyle(fontWeight: FontWeight.w600),
),
TextSpan(text: ' mau lanjutin curhat sama $mitraName?'),
],
),
),
),
HaloButton(
label: 'perpanjang',
size: HaloButtonSize.sm,
variant: HaloButtonVariant.secondary,
const SizedBox(width: 8),
ElevatedButton(
onPressed: onExtend,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: HaloTokens.brand,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
minimumSize: const Size(0, 32),
elevation: 0,
textStyle: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 11.5,
fontWeight: FontWeight.w700,
),
),
child: const Text('perpanjang'),
),
],
),

View File

@@ -47,8 +47,19 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
}
@override
void dispose() {
void deactivate() {
// setActive(false) lives here, NOT in dispose(): modern Riverpod
// invalidates `ref` as soon as the State enters dispose(), so calling
// `ref.read` from there throws `Bad state: Cannot use "ref" after the
// widget was disposed.` That exception fires inside `finalizeTree` and
// leaves the widget tree in a half-finalized state — observed symptom
// is a frozen screen on the next push (e.g. Home → Chat).
ref.read(mitraAvailabilityProvider.notifier).setActive(false);
super.deactivate();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_client_provider.dart';
import '../../../core/auth/auth_notifier.dart';
class BestieHistoryItem {
final String sessionId;
@@ -28,13 +29,34 @@ class BestieHistoryItem {
mitraName: json['mitra_display_name'] as String? ?? 'Bestie',
endedAt: endedAtRaw is String ? DateTime.tryParse(endedAtRaw)?.toLocal() : null,
topics: (json['topics'] as List?)?.cast<String>() ?? const [],
sessionsCount: (json['sessions_count'] as num?)?.toInt() ?? 1,
// sessions_count comes from PostgreSQL COUNT(*) which postgres.js
// stringifies (bigint precision). Accept both shapes so the factory
// doesn't crash when the backend forgets to ::int.
sessionsCount: switch (json['sessions_count']) {
num n => n.toInt(),
String s => int.tryParse(s) ?? 1,
_ => 1,
},
mitraIsOnline: json['mitra_is_online'] as bool? ?? false,
);
}
}
final bestieHistoryProvider = FutureProvider<List<BestieHistoryItem>>((ref) async {
/// Scoped to the current customer so logging in as a different account (or
/// from anonymous → phone-verified) triggers a refetch instead of returning
/// the previous customer's cached list. Returns `[]` when no auth — keeps
/// SHome1st from issuing a doomed 401.
final bestieHistoryProvider =
FutureProvider<List<BestieHistoryItem>>((ref) async {
final customerId = ref.watch(authProvider.select((s) {
final data = s.valueOrNull;
return switch (data) {
AuthAuthenticatedData d => d.profile['id'] as String?,
AuthAnonymousData d => d.customerId,
_ => null,
};
}));
if (customerId == null) return const [];
final api = ref.read(apiClientProvider);
final response = await api.get('/api/client/chat/history');
final items = (response['data']['items'] as List<dynamic>? ?? [])

View File

@@ -1,12 +0,0 @@
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);

View File

@@ -1,34 +0,0 @@
/// 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;
}
}

View File

@@ -84,7 +84,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
await prefs.setBool(_kOnboardingDone, true);
ref.invalidate(onboardingDoneProvider);
if (mounted) {
context.go('/welcome');
context.go('/home');
}
}

View File

@@ -1,132 +0,0 @@
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);
}
}

View File

@@ -1,13 +1,17 @@
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 '../usp_seen_provider.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).
/// Onboarding step 2 — static value-prop ("USP") cards. One-time gate
/// (Phase 4, 2026-05-12): on Continue we mark the local `usp_seen` flag and
/// best-effort persist to DB so this screen never shows again for this user.
///
/// `verified` ➞ USP → OTP (`/auth/register`).
/// `anonymous` ➞ USP → `/payment/method-pick`.
class UspScreen extends ConsumerWidget {
final bool verified;
const UspScreen({super.key, required this.verified});
@@ -36,7 +40,7 @@ class UspScreen extends StatelessWidget {
];
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(
title: const Padding(
@@ -90,7 +94,7 @@ class UspScreen extends StatelessWidget {
HaloButton(
label: 'aku ngerti, lanjut',
fullWidth: true,
onPressed: () => _onContinue(context),
onPressed: () => _onContinue(context, ref),
),
],
),
@@ -99,12 +103,14 @@ class UspScreen extends StatelessWidget {
);
}
void _onContinue(BuildContext context) {
Future<void> _onContinue(BuildContext context, WidgetRef ref) async {
// Persist the local + server flag before leaving — next time the user
// hits VerifChoice, this screen is skipped.
await ref.read(uspSeenProvider.notifier).markSeen();
if (!context.mounted) return;
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');
}
}

View File

@@ -0,0 +1,81 @@
import 'dart:async';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../core/api/api_client_provider.dart';
import '../../core/auth/auth_notifier.dart';
part 'usp_seen_provider.g.dart';
const _kPrefsKey = 'usp_seen';
/// One-time gate for the S5b USP onboarding screen (Phase 4, 2026-05-12).
///
/// Local SharedPreferences flag is the runtime source of truth. When an
/// authenticated session is hydrated (bootstrap, OTP verify, social, name
/// patch), the server-side `customers.usp_seen` value is OR-merged into the
/// local flag — true wins. When the user dismisses the USP screen and an
/// account exists, the local true is best-effort propagated to the server via
/// `POST /api/client/auth/usp-seen`.
@Riverpod(keepAlive: true)
class UspSeen extends _$UspSeen {
@override
FutureOr<bool> build() async {
// Watch auth state; whenever an auth-bearing profile arrives, OR-merge the
// server flag into local. Disposed/recreated automatically with the
// notifier so no manual cleanup needed.
ref.listen<AsyncValue<AuthData>>(authProvider, (prev, next) {
final profile = _profileOf(next.valueOrNull);
if (profile != null) {
unawaited(_hydrateFromProfile(profile));
}
});
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_kPrefsKey) ?? false;
}
Map<String, dynamic>? _profileOf(AuthData? data) => switch (data) {
AuthAuthenticatedData d => d.profile,
AuthAnonymousData d => d.profile,
AuthForceRegisterData d => d.profile,
AuthNeedsDisplayNameData d => d.profile,
_ => null,
};
Future<void> _hydrateFromProfile(Map<String, dynamic> profile) async {
final serverSeen = profile['usp_seen'] as bool? ?? false;
if (!serverSeen) return;
if ((state.valueOrNull ?? false) == true) return;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kPrefsKey, true);
state = const AsyncData(true);
}
/// Mark seen locally; if an account exists, also persist to DB best-effort.
/// Safe to call when already seen — no-ops out of the network hit if local
/// is already true AND no account exists yet.
Future<void> markSeen() async {
final alreadySeen = (state.valueOrNull ?? false) == true;
if (!alreadySeen) {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kPrefsKey, true);
state = const AsyncData(true);
}
final authData = ref.read(authProvider).valueOrNull;
final hasAccount = authData is AuthAuthenticatedData ||
authData is AuthAnonymousData ||
authData is AuthForceRegisterData ||
authData is AuthNeedsDisplayNameData;
if (!hasAccount) return;
try {
await ref.read(apiClientProvider).post('/api/client/auth/usp-seen');
} catch (_) {
// Local stays true; next markSeen call (or a successful login on a
// different device) will re-attempt the DB write.
}
}
}

View File

@@ -0,0 +1,33 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'usp_seen_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$uspSeenHash() => r'e8c5b7def4f640fb3933929f099ce7f0b5cbe050';
/// One-time gate for the S5b USP onboarding screen (Phase 4, 2026-05-12).
///
/// Local SharedPreferences flag is the runtime source of truth. When an
/// authenticated session is hydrated (bootstrap, OTP verify, social, name
/// patch), the server-side `customers.usp_seen` value is OR-merged into the
/// local flag — true wins. When the user dismisses the USP screen and an
/// account exists, the local true is best-effort propagated to the server via
/// `POST /api/client/auth/usp-seen`.
///
/// Copied from [UspSeen].
@ProviderFor(UspSeen)
final uspSeenProvider = AsyncNotifierProvider<UspSeen, bool>.internal(
UspSeen.new,
name: r'uspSeenProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$uspSeenHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$UspSeen = AsyncNotifier<bool>;
// 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

View File

@@ -60,13 +60,16 @@ class _PaymentScreenState extends ConsumerState<PaymentScreen> {
}
@override
void dispose() {
// Best-effort cancel on back/dispose if we still have a `pending` row.
void deactivate() {
// Best-effort cancel on back/leave if we still have a `pending` row.
// The notifier checks state before calling the API, so this is safe to
// call unconditionally.
// call unconditionally. Lives in deactivate(), not dispose(), because
// modern Riverpod invalidates `ref` once dispose() starts — the resulting
// `Bad state: Cannot use "ref" after the widget was disposed.` corrupts
// the widget-tree finalize and leaves the next screen frozen.
// ignore: discarded_futures
ref.read(paymentProvider.notifier).cancelIfPending();
super.dispose();
super.deactivate();
}

View File

@@ -125,20 +125,35 @@ class _WaitingPaymentScreenState extends ConsumerState<WaitingPaymentScreen>
if (status == PaymentSessionStatus.confirmed ||
status == PaymentSessionStatus.consumed) {
_markTerminal();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
context.go('/onboarding/notif-gate');
});
_navigateTerminal('/onboarding/notif-gate');
} else if (status == PaymentSessionStatus.expired ||
status == PaymentSessionStatus.abandoned) {
_markTerminal();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
context.go('/payment/expired/${widget.paymentId}');
});
_navigateTerminal('/payment/expired/${widget.paymentId}');
}
}
/// Routes off the waiting screen once the payment session reached a
/// terminal status. Belt-and-braces:
/// - `Future.microtask` runs after the current event loop turn (after any
/// pending setState), so we don't fight an in-flight build.
/// - `addPostFrameCallback` is a fallback in case the microtask is
/// pre-empted (observed once on release builds where the screen stayed
/// visually stuck on "menunggu pembayaran" despite polling having
/// stopped — see 2026-05-14 thread).
void _navigateTerminal(String route) {
Future.microtask(() {
if (!mounted) return;
context.go(route);
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
// No-op if the microtask already navigated — `go` to the same location
// is idempotent in GoRouter.
context.go(route);
});
}
void _markTerminal() {
_terminal = true;
_ticker?.cancel();

View File

@@ -0,0 +1,366 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/auth/auth_notifier.dart';
import '../../core/theme/halo_tokens.dart';
import '../home/widgets/halo_tab_bar.dart';
/// "Kamu" tab — profile screen.
///
/// Mirrors Figma `SProfile` (see `requirement/Figma/screens/extras.jsx::SProfile`):
/// user card → menu list (kontak / syarat / privasi) → action button → version.
///
/// The action button differs from Figma: we ship **logout** here instead of
/// the "hapus akun" CTA from the mockup. Account deletion is a deeper flow
/// (confirmation, server-side data removal, refund policy) and is not in
/// scope yet.
class ProfileScreen extends ConsumerWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final authData = ref.watch(authProvider).valueOrNull;
final (name, phone) = switch (authData) {
AuthAuthenticatedData d => (
(d.profile['display_name'] as String?) ?? 'kamu',
_maskPhone(d.profile['phone'] as String?),
),
AuthAnonymousData d => (
d.displayName.isEmpty ? 'kamu' : d.displayName,
'akun anonim',
),
_ => ('kamu', null),
};
return Scaffold(
backgroundColor: HaloTokens.bg,
body: SafeArea(
bottom: false,
child: Column(
children: [
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 16),
children: [
const Text(
'kamu',
style: TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 26,
fontWeight: FontWeight.w700,
color: HaloTokens.brandDark,
letterSpacing: -0.52,
),
),
const SizedBox(height: 18),
_UserCard(name: name, phone: phone),
const SizedBox(height: 20),
_MenuCard(items: [
_MenuItemData(
icon: Icons.mail_outline,
label: 'kontak kami',
sub: 'halo@halobestie.id',
onTap: () {},
),
_MenuItemData(
icon: Icons.description_outlined,
label: 'syarat & ketentuan',
onTap: () {},
),
_MenuItemData(
icon: Icons.lock_outline,
label: 'kebijakan privasi',
onTap: () {},
),
]),
const SizedBox(height: 16),
_LogoutButton(
onTap: () => _confirmLogout(context, ref),
),
const SizedBox(height: 20),
const Center(
child: Text(
'HaloBestie · v1.0.0',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 11,
color: HaloTokens.inkMuted,
),
),
),
],
),
),
const HaloTabBar(active: 'kamu'),
],
),
),
);
}
Future<void> _confirmLogout(BuildContext context, WidgetRef ref) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: HaloTokens.surface,
shape: const RoundedRectangleBorder(borderRadius: HaloRadius.lg),
title: const Text(
'keluar dari akun?',
style: TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontWeight: FontWeight.w700,
color: HaloTokens.brandDark,
),
),
content: const Text(
'kamu harus login lagi buat lanjutin curhatan.',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
color: HaloTokens.inkSoft,
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text(
'batal',
style: TextStyle(color: HaloTokens.inkMuted),
),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text(
'keluar',
style: TextStyle(
color: HaloTokens.danger,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
if (ok != true) return;
await ref.read(authProvider.notifier).logout();
// RouterNotifier observes the resulting AuthInitialData and sends the user
// to /home (SHome1st), so no manual navigation is needed here.
}
static String? _maskPhone(String? raw) {
if (raw == null || raw.length < 6) return raw;
final tail = raw.substring(raw.length - 4);
final head = raw.substring(0, raw.length - 8);
return '$head ••••$tail';
}
}
class _UserCard extends StatelessWidget {
final String name;
final String? phone;
const _UserCard({required this.name, this.phone});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: HaloTokens.surface,
borderRadius: HaloRadius.xl,
border: Border.all(color: HaloTokens.border),
),
child: Row(
children: [
Container(
width: 56,
height: 56,
decoration: const BoxDecoration(
gradient: RadialGradient(
colors: [HaloTokens.brand, HaloTokens.lilac],
),
shape: BoxShape.circle,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 18,
fontWeight: FontWeight.w700,
color: HaloTokens.ink,
letterSpacing: -0.18,
),
),
if (phone != null) ...[
const SizedBox(height: 2),
Text(
phone!,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 12,
color: HaloTokens.inkSoft,
),
),
],
],
),
),
],
),
);
}
}
class _MenuItemData {
final IconData icon;
final String label;
final String? sub;
final VoidCallback onTap;
const _MenuItemData({
required this.icon,
required this.label,
this.sub,
required this.onTap,
});
}
class _MenuCard extends StatelessWidget {
final List<_MenuItemData> items;
const _MenuCard({required this.items});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: HaloTokens.surface,
borderRadius: HaloRadius.lg,
border: Border.all(color: HaloTokens.border),
),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
for (var i = 0; i < items.length; i++) ...[
_MenuItemRow(item: items[i]),
if (i < items.length - 1)
const Divider(height: 1, thickness: 1, color: HaloTokens.border),
],
],
),
);
}
}
class _MenuItemRow extends StatelessWidget {
final _MenuItemData item;
const _MenuItemRow({required this.item});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: item.onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: const BoxDecoration(
color: HaloTokens.brandSofter,
borderRadius: BorderRadius.all(Radius.circular(10)),
),
alignment: Alignment.center,
child: Icon(item.icon, size: 18, color: HaloTokens.brandDark),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
item.label,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 14,
fontWeight: FontWeight.w500,
color: HaloTokens.ink,
),
),
if (item.sub != null) ...[
const SizedBox(height: 2),
Text(
item.sub!,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 11.5,
color: HaloTokens.inkMuted,
),
),
],
],
),
),
const Icon(
Icons.chevron_right,
size: 18,
color: HaloTokens.inkMuted,
),
],
),
),
),
);
}
}
class _LogoutButton extends StatelessWidget {
final VoidCallback onTap;
const _LogoutButton({required this.onTap});
@override
Widget build(BuildContext context) {
return Material(
color: HaloTokens.surface,
borderRadius: HaloRadius.lg,
child: InkWell(
onTap: onTap,
borderRadius: HaloRadius.lg,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16),
decoration: BoxDecoration(
borderRadius: HaloRadius.lg,
border: Border.all(
color: HaloTokens.danger.withValues(alpha: 0.25),
),
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.logout, size: 18, color: HaloTokens.danger),
SizedBox(width: 8),
Text(
'keluar',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 14,
fontWeight: FontWeight.w500,
color: HaloTokens.danger,
),
),
],
),
),
),
);
}
}

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
@@ -5,6 +7,7 @@ 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/auth/token_storage.dart';
import 'core/chat/active_session_notifier.dart';
import 'core/chat/chat_notifier.dart';
import 'core/notifications/notification_service.dart';
@@ -14,6 +17,14 @@ import 'router.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Pre-warm flutter_secure_storage. The first call triggers AndroidX
// Security MasterKey generation (RSA in Keystore) — fast on hardware-backed
// keystores but multi-second on emulator's software-emulated TEE. Kicking
// it off here in parallel with Firebase init hides the latency behind the
// splash instead of paying it on the user's first interaction.
unawaited(TokenStorage().readRefreshToken());
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
final messaging = FirebaseMessaging.instance;

View File

@@ -552,6 +552,13 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.2"
halo_lints:
dependency: "direct dev"
description:
path: "../halo_lints"
relative: true
source: path
version: "0.0.1"
hooks:
dependency: transitive
description:

View File

@@ -58,6 +58,11 @@ dev_dependencies:
build_runner: ^2.4.13
custom_lint: ^0.7.0
riverpod_lint: ^2.6.2
# In-repo lint rules — lives at the repo root so client_app + mitra_app
# share the same set. Adds `no_ref_in_dispose` and any future repo-wide
# guardrails. See halo_lints/lib/halo_lints.dart.
halo_lints:
path: ../halo_lints
flutter:
uses-material-design: true