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:
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (_) {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -6,7 +6,7 @@ part of 'session_closure_notifier.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$sessionClosureHash() => r'521e57f74805faf01f11778872cb37ceae683a5b';
|
||||
String _$sessionClosureHash() => r'a9aaf26da64d3489497d057e2b05741c08444e07';
|
||||
|
||||
/// See also [SessionClosure].
|
||||
@ProviderFor(SessionClosure)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ class _DisplayNameScreenState extends ConsumerState<DisplayNameScreen> {
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
routeForVerifChoice(context, choice);
|
||||
await routeForVerifChoice(context, ref, choice);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>? ?? [])
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
81
client_app/lib/features/onboarding/usp_seen_provider.dart
Normal file
81
client_app/lib/features/onboarding/usp_seen_provider.dart
Normal 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
33
client_app/lib/features/onboarding/usp_seen_provider.g.dart
Normal file
33
client_app/lib/features/onboarding/usp_seen_provider.g.dart
Normal 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
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
366
client_app/lib/features/profile/profile_screen.dart
Normal file
366
client_app/lib/features/profile/profile_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user