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

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