Spec §2 (flow_customer.mermaid) routes post-OTP based on user-lookup + has_transacted, but the implementation previously dumped every OTP success on /home. Introduce `OnboardingIntent` provider: set to `onboarding` by routeForVerifChoice's verified branch (the "aku mau curhat" transaction journey), set to `recover` by SHome1st's masuk → banner. Router redirect on AuthAuthenticatedData+isAuthRoute consumes it: `onboarding` → /payment/entry (dispatches S6 paywall vs PickMethod via first_session_discount.eligible); `recover` → /home. Intent is reset in /payment/entry's initState so subsequent masuk → flows don't inherit it. auth_notifier.verifyOtp uses .copyWithPrevious on AsyncError so valueOrNull retains AuthOtpSentData/AuthAnonymousData through OTP failures — required for the OTP-blocked recovery path (/onboarding/anon/method → /payment/method-pick) to clear the global redirect without bouncing to /home. Router also extends the isAuthRoute/isOnboardingFlow carve-out to AuthOtpSentData. Maestro tests adopt `ts-<app>-<NN>-<MM>-<descriptor>.yaml` convention: NN = mermaid section, MM = sub-flow index. New ts-customer-02-01..05 cover the §2 branches (verified brand-new → S6, existing-no-tx → S6, existing-tx → method-pick, OTP-blocked → method-pick, anonymous first- timer → method-pick); deferred 02-06/07/08/09 documented in README_section_02.md. TS-07 → ts-customer-02-10 (masuk → recovery); TS-01..06 → ts-customer-04-01..06 (§4 returning-user). Shared onboarding_new_user_verified.yaml subflow extracted. Register screen's body Column now uses LayoutBuilder + SingleChildScrollView + ConstrainedBox + IntrinsicHeight so the keyboard-open layout no longer overflows by 1.3 px (verified visually). Spec prose updated at flow_customer.mermaid §2 to describe the intent-driven routing + login-vs-transaction divergence. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
833 lines
28 KiB
Dart
833 lines
28 KiB
Dart
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/onboarding_intent_provider.dart';
|
|
import '../../core/availability/mitra_availability_notifier.dart';
|
|
import '../../core/chat/active_session_notifier.dart';
|
|
import '../../core/notifications/notif_permission.dart';
|
|
import '../../core/theme/halo_tokens.dart';
|
|
import '../payment/state/payment_draft_provider.dart';
|
|
import 'providers/bestie_history_provider.dart';
|
|
import 'widgets/bestie_choice_sheet.dart';
|
|
import 'widgets/halo_tab_bar.dart';
|
|
|
|
/// Session-only dismiss flag for the "notif denied" banner. Resets on cold
|
|
/// restart by design — `StateProvider` lives in memory only.
|
|
final homeNotifBannerDismissedProvider = StateProvider<bool>((_) => false);
|
|
|
|
/// Home screen — Phase 4 redesign.
|
|
///
|
|
/// Renders one of two variants depending on auth state, mirroring the Figma
|
|
/// `SHome1st` / `SHomeReturning` components named in
|
|
/// `requirement/flow_customer.mermaid.md` §1:
|
|
/// - `AuthInitialData` (no JWT) → SHome1st: top login-recover banner +
|
|
/// `aku mau curhat` CTA + empty curhatan card.
|
|
/// - `AuthAnonymousData` / `AuthAuthenticatedData` → SHomeReturning:
|
|
/// `halo, {name}` greeting + `curhat sama bestie baru` CTA + history list.
|
|
class HomeScreen extends ConsumerStatefulWidget {
|
|
const HomeScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<HomeScreen> createState() => _HomeScreenState();
|
|
}
|
|
|
|
class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObserver {
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addObserver(this);
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
ref.read(mitraAvailabilityProvider.notifier).setActive(true);
|
|
// Re-fetch history every time we re-enter /home. Covers post-chat
|
|
// return via thank_you_screen's `context.go('/home')`, where the
|
|
// home widget is freshly constructed and we want the just-completed
|
|
// session reflected without requiring pull-to-refresh.
|
|
ref.invalidate(bestieHistoryProvider);
|
|
});
|
|
}
|
|
|
|
@override
|
|
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();
|
|
}
|
|
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
final notifier = ref.read(mitraAvailabilityProvider.notifier);
|
|
if (state == AppLifecycleState.resumed) {
|
|
ref.read(activeSessionProvider.notifier).refresh();
|
|
ref.invalidate(bestieHistoryProvider);
|
|
notifier.setActive(true);
|
|
} else if (state == AppLifecycleState.paused || state == AppLifecycleState.inactive) {
|
|
notifier.setActive(false);
|
|
}
|
|
}
|
|
|
|
/// CTA path for SHomeReturning. Returning users get the bestie-choice sheet
|
|
/// when they have prior history, otherwise jump to the new-payment shell.
|
|
Future<void> _onCurhatBestieBaruPressed(BuildContext context) async {
|
|
bool hasHistory;
|
|
try {
|
|
hasHistory = await ref.read(bestieHistoryHasItemsProvider.future);
|
|
} catch (_) {
|
|
hasHistory = false;
|
|
}
|
|
if (!context.mounted) return;
|
|
if (hasHistory) {
|
|
await BestieChoiceSheet.show(context);
|
|
return;
|
|
}
|
|
// explicit reset — this branch is blast-only, clear any stale targeted mitra
|
|
ref.read(paymentDraftNotifierProvider.notifier).reset();
|
|
context.push('/payment/entry');
|
|
}
|
|
|
|
/// CTA path for SHome1st. Per mermaid §2, fresh users hit S2 Nama first
|
|
/// (the call-sign check); display_name_screen kicks off `loginAnonymous`
|
|
/// and pushes into the verif-choice sheet.
|
|
void _onAkuMauCurhatPressed(BuildContext context) {
|
|
context.push('/auth/display-name');
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final authState = ref.watch(authProvider);
|
|
final authData = authState.valueOrNull;
|
|
// SHomeReturning needs a real session; everything else (AuthInitialData,
|
|
// AsyncError, transient OTP states) renders SHome1st with the login
|
|
// banner so an unauthenticated user is never shown the returning view.
|
|
final isReturning =
|
|
authData is AuthAuthenticatedData || authData is AuthAnonymousData;
|
|
final isFresh = !isReturning;
|
|
|
|
return Scaffold(
|
|
backgroundColor: HaloTokens.bg,
|
|
body: SafeArea(
|
|
bottom: false,
|
|
child: Column(
|
|
children: [
|
|
const _NotifDeniedBanner(),
|
|
Expanded(
|
|
child: RefreshIndicator(
|
|
onRefresh: () async {
|
|
await Future.wait([
|
|
ref.read(activeSessionProvider.notifier).refresh(),
|
|
ref.read(mitraAvailabilityProvider.notifier).refresh(),
|
|
ref.refresh(bestieHistoryProvider.future),
|
|
]);
|
|
},
|
|
child: isFresh
|
|
? _SHome1stView(onCTA: () => _onAkuMauCurhatPressed(context))
|
|
: _SHomeReturningView(
|
|
onCTA: () => _onCurhatBestieBaruPressed(context),
|
|
),
|
|
),
|
|
),
|
|
const HaloTabBar(active: 'home'),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── SHome1st ──────────────────────────────────────────────────────────────
|
|
|
|
class _SHome1stView extends ConsumerWidget {
|
|
final VoidCallback onCTA;
|
|
const _SHome1stView({required this.onCTA});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final availabilityAsync = ref.watch(mitraAvailabilityProvider);
|
|
final mitraAvailable = availabilityAsync.valueOrNull ?? false;
|
|
|
|
return ListView(
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
|
padding: EdgeInsets.zero,
|
|
children: [
|
|
const _LoginRecoverBanner(),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(28, 24, 28, 16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const _GreetingHaloOnly(),
|
|
const SizedBox(height: 4),
|
|
const _GreetingSubtitle(),
|
|
const SizedBox(height: 32),
|
|
_PrimaryCTA(
|
|
label: 'aku mau curhat',
|
|
enabled: mitraAvailable,
|
|
onPressed: onCTA,
|
|
),
|
|
if (!mitraAvailable) ...[
|
|
const SizedBox(height: 12),
|
|
const Text(
|
|
'belum ada bestie tersedia',
|
|
style: TextStyle(
|
|
fontFamily: HaloTokens.fontBody,
|
|
fontSize: 13,
|
|
color: HaloTokens.inkMuted,
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 28),
|
|
const _SectionLabel('curhatan sebelumnya'),
|
|
const SizedBox(height: 10),
|
|
const _HistoryEmptyCard(),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _LoginRecoverBanner extends ConsumerWidget {
|
|
const _LoginRecoverBanner();
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
|
child: Material(
|
|
color: HaloTokens.brandSofter,
|
|
borderRadius: HaloRadius.md,
|
|
child: InkWell(
|
|
borderRadius: HaloRadius.md,
|
|
onTap: () {
|
|
// Recovery flow — post-OTP should land on /home (the user wants
|
|
// their history), NOT /payment/entry. Defensive reset in case a
|
|
// prior onboarding run left the intent dirty.
|
|
ref.read(onboardingIntentProvider.notifier).state =
|
|
OnboardingIntent.recover;
|
|
context.push('/auth/register');
|
|
},
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
|
decoration: BoxDecoration(
|
|
borderRadius: HaloRadius.md,
|
|
border: Border.all(color: HaloTokens.brand.withValues(alpha: 0.2)),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 32,
|
|
height: 32,
|
|
decoration: const BoxDecoration(
|
|
color: HaloTokens.brand,
|
|
borderRadius: BorderRadius.all(Radius.circular(10)),
|
|
),
|
|
alignment: Alignment.center,
|
|
child: const Text('👋', style: TextStyle(fontSize: 15)),
|
|
),
|
|
const SizedBox(width: 10),
|
|
const Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'udah pernah pakai HaloBestie?',
|
|
style: TextStyle(
|
|
fontFamily: HaloTokens.fontBody,
|
|
fontSize: 12.5,
|
|
fontWeight: FontWeight.w700,
|
|
color: HaloTokens.brandDark,
|
|
height: 1.3,
|
|
),
|
|
),
|
|
SizedBox(height: 1),
|
|
Text(
|
|
'login buat lanjutin obrolan & history kamu',
|
|
style: TextStyle(
|
|
fontFamily: HaloTokens.fontBody,
|
|
fontSize: 11,
|
|
color: HaloTokens.inkSoft,
|
|
height: 1.3,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: HaloTokens.surface,
|
|
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
|
border: Border.all(color: HaloTokens.brand.withValues(alpha: 0.33)),
|
|
),
|
|
child: const Text(
|
|
'masuk →',
|
|
style: TextStyle(
|
|
fontFamily: HaloTokens.fontBody,
|
|
fontSize: 12.5,
|
|
fontWeight: FontWeight.w700,
|
|
color: HaloTokens.brand,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _GreetingHaloOnly extends StatelessWidget {
|
|
const _GreetingHaloOnly();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return const Text(
|
|
'halo,',
|
|
style: TextStyle(
|
|
fontFamily: HaloTokens.fontDisplay,
|
|
fontSize: 32,
|
|
fontWeight: FontWeight.w700,
|
|
color: HaloTokens.brandDark,
|
|
height: 1.15,
|
|
letterSpacing: -0.64,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _GreetingSubtitle extends StatelessWidget {
|
|
const _GreetingSubtitle();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 290),
|
|
child: const Text(
|
|
'lagi ngerasa gimana hari ini? bestie akan nemenin kamu kok 🤍',
|
|
style: TextStyle(
|
|
fontFamily: HaloTokens.fontBody,
|
|
fontSize: 14.5,
|
|
color: HaloTokens.inkSoft,
|
|
height: 1.55,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _HistoryEmptyCard extends StatelessWidget {
|
|
const _HistoryEmptyCard();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 32),
|
|
decoration: BoxDecoration(
|
|
color: HaloTokens.surface,
|
|
borderRadius: HaloRadius.lg,
|
|
border: Border.all(
|
|
color: HaloTokens.border,
|
|
width: 1,
|
|
style: BorderStyle.solid, // Flutter has no built-in dashed border;
|
|
// a CustomPainter could draw dashes — visual parity is close enough
|
|
// for v1 (border color + radius match Figma exactly).
|
|
),
|
|
),
|
|
alignment: Alignment.center,
|
|
child: const Text(
|
|
'belum ada curhatan. mulai aja, bestie udah siap 🌷',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontFamily: HaloTokens.fontBody,
|
|
fontSize: 13,
|
|
color: HaloTokens.inkMuted,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── SHomeReturning ────────────────────────────────────────────────────────
|
|
|
|
class _SHomeReturningView extends ConsumerWidget {
|
|
final VoidCallback onCTA;
|
|
const _SHomeReturningView({required this.onCTA});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final authData = ref.watch(authProvider).valueOrNull;
|
|
final activeSessionAsync = ref.watch(activeSessionProvider);
|
|
final availabilityAsync = ref.watch(mitraAvailabilityProvider);
|
|
final historyAsync = ref.watch(bestieHistoryProvider);
|
|
final mitraAvailable = availabilityAsync.valueOrNull ?? false;
|
|
|
|
final displayName = switch (authData) {
|
|
AuthAuthenticatedData d => d.profile['display_name'] as String? ?? '',
|
|
AuthAnonymousData d => d.displayName,
|
|
_ => '',
|
|
};
|
|
|
|
return ListView(
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
|
padding: const EdgeInsets.fromLTRB(24, 32, 24, 16),
|
|
children: [
|
|
_GreetingHaloName(name: displayName),
|
|
const SizedBox(height: 4),
|
|
const _GreetingSubtitle(),
|
|
const SizedBox(height: 24),
|
|
activeSessionAsync.when(
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error: (_, __) => _PrimaryCTA(
|
|
label: 'curhat sama bestie baru',
|
|
enabled: mitraAvailable,
|
|
onPressed: onCTA,
|
|
),
|
|
data: (snapshot) {
|
|
final status = snapshot.session?['status'] as String?;
|
|
final isCurhatable = snapshot.hasSession && status != 'closing';
|
|
if (isCurhatable) {
|
|
return _ActiveSessionCard(
|
|
mitraName: snapshot.mitraName,
|
|
unreadCount: snapshot.unreadCount,
|
|
onTap: () {
|
|
final sessionId = snapshot.sessionId;
|
|
if (sessionId == null) return;
|
|
context.push('/chat/session/$sessionId',
|
|
extra: snapshot.mitraName);
|
|
},
|
|
);
|
|
}
|
|
return _PrimaryCTA(
|
|
label: 'curhat sama bestie baru',
|
|
enabled: mitraAvailable,
|
|
onPressed: onCTA,
|
|
);
|
|
},
|
|
),
|
|
if (!mitraAvailable) ...[
|
|
const SizedBox(height: 12),
|
|
const Text(
|
|
'belum ada bestie tersedia',
|
|
style: TextStyle(
|
|
fontFamily: HaloTokens.fontBody,
|
|
fontSize: 13,
|
|
color: HaloTokens.inkMuted,
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 28),
|
|
const _SectionLabel('curhatan sebelumnya'),
|
|
const SizedBox(height: 10),
|
|
historyAsync.when(
|
|
loading: () => const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 24),
|
|
child: Center(child: CircularProgressIndicator()),
|
|
),
|
|
error: (_, __) => const _HistoryEmptyCard(),
|
|
data: (items) {
|
|
if (items.isEmpty) return const _HistoryEmptyCard();
|
|
return Column(
|
|
children: [
|
|
for (var i = 0; i < items.length; i++) ...[
|
|
_HistoryItemTile(item: items[i], seed: i + 1),
|
|
if (i < items.length - 1) const SizedBox(height: 10),
|
|
],
|
|
],
|
|
);
|
|
},
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _GreetingHaloName extends StatelessWidget {
|
|
final String name;
|
|
const _GreetingHaloName({required this.name});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final shown = name.trim().isEmpty ? 'kamu' : name;
|
|
return Text.rich(
|
|
TextSpan(
|
|
style: const TextStyle(
|
|
fontFamily: HaloTokens.fontDisplay,
|
|
fontSize: 32,
|
|
fontWeight: FontWeight.w700,
|
|
height: 1.15,
|
|
letterSpacing: -0.64,
|
|
),
|
|
children: [
|
|
const TextSpan(
|
|
text: 'halo, ',
|
|
style: TextStyle(color: HaloTokens.brandDark),
|
|
),
|
|
TextSpan(
|
|
text: shown,
|
|
style: const TextStyle(color: HaloTokens.brand),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _HistoryItemTile extends StatelessWidget {
|
|
final BestieHistoryItem item;
|
|
final int seed;
|
|
const _HistoryItemTile({required this.item, required this.seed});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final relative = _relativeWhen(item.endedAt);
|
|
final topicLine = item.topics.isEmpty ? '' : '"${item.topics.first}"';
|
|
return Material(
|
|
color: HaloTokens.surface,
|
|
borderRadius: HaloRadius.lg,
|
|
child: InkWell(
|
|
borderRadius: HaloRadius.lg,
|
|
onTap: () => context.push('/chat/transcript/${item.sessionId}'),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
borderRadius: HaloRadius.lg,
|
|
border: Border.all(color: HaloTokens.border),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
_OrbPlaceholder(seed: seed),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
item.mitraName,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
fontFamily: HaloTokens.fontBody,
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
color: HaloTokens.ink,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
relative,
|
|
style: const TextStyle(
|
|
fontFamily: HaloTokens.fontBody,
|
|
fontSize: 10.5,
|
|
color: HaloTokens.inkMuted,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (topicLine.isNotEmpty) ...[
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
topicLine,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
fontFamily: HaloTokens.fontBody,
|
|
fontSize: 12,
|
|
color: HaloTokens.inkSoft,
|
|
height: 1.4,
|
|
),
|
|
),
|
|
],
|
|
if (item.endedAt != null) ...[
|
|
const SizedBox(height: 4),
|
|
const Text(
|
|
'sesi habis · tap untuk lanjutin curhat',
|
|
style: TextStyle(
|
|
fontFamily: HaloTokens.fontBody,
|
|
fontSize: 10,
|
|
fontStyle: FontStyle.italic,
|
|
color: HaloTokens.inkMuted,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
static String _relativeWhen(DateTime? when) {
|
|
if (when == null) return '';
|
|
final diff = DateTime.now().difference(when);
|
|
if (diff.inDays >= 7) return '${(diff.inDays / 7).floor()} mgg lalu';
|
|
if (diff.inDays >= 1) return '${diff.inDays} hari lalu';
|
|
if (diff.inHours >= 1) return '${diff.inHours} jam lalu';
|
|
if (diff.inMinutes >= 1) return '${diff.inMinutes} mnt lalu';
|
|
return 'baru saja';
|
|
}
|
|
}
|
|
|
|
/// Placeholder for the Figma `HBOrb` gradient component. v1 ships as a
|
|
/// solid-color disc keyed off `seed`; a real animated orb is a follow-up.
|
|
class _OrbPlaceholder extends StatelessWidget {
|
|
final int seed;
|
|
const _OrbPlaceholder({required this.seed});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
const palette = [
|
|
HaloTokens.brand,
|
|
HaloTokens.lilac,
|
|
HaloTokens.mint,
|
|
HaloTokens.accent,
|
|
];
|
|
final color = palette[seed % palette.length];
|
|
return Container(
|
|
width: 42,
|
|
height: 42,
|
|
decoration: BoxDecoration(
|
|
gradient: RadialGradient(
|
|
colors: [color.withValues(alpha: 0.95), color.withValues(alpha: 0.55)],
|
|
),
|
|
shape: BoxShape.circle,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Shared ────────────────────────────────────────────────────────────────
|
|
|
|
class _SectionLabel extends StatelessWidget {
|
|
final String text;
|
|
const _SectionLabel(this.text);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Text(
|
|
text,
|
|
style: const TextStyle(
|
|
fontFamily: HaloTokens.fontBody,
|
|
fontSize: 11.5,
|
|
fontWeight: FontWeight.w600,
|
|
color: HaloTokens.inkMuted,
|
|
letterSpacing: 0.69,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PrimaryCTA extends StatelessWidget {
|
|
final String label;
|
|
final bool enabled;
|
|
final VoidCallback onPressed;
|
|
const _PrimaryCTA({
|
|
required this.label,
|
|
required this.enabled,
|
|
required this.onPressed,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SizedBox(
|
|
width: double.infinity,
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
borderRadius: HaloRadius.xl,
|
|
boxShadow: enabled ? HaloShadows.button : const [],
|
|
),
|
|
child: Material(
|
|
color: enabled ? HaloTokens.brand : HaloTokens.brandSofter,
|
|
borderRadius: HaloRadius.xl,
|
|
child: InkWell(
|
|
borderRadius: HaloRadius.xl,
|
|
onTap: enabled ? onPressed : null,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 18),
|
|
child: Center(
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontFamily: HaloTokens.fontBody,
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w600,
|
|
color: enabled ? Colors.white : HaloTokens.inkMuted,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ActiveSessionCard extends StatelessWidget {
|
|
final String mitraName;
|
|
final int unreadCount;
|
|
final VoidCallback onTap;
|
|
|
|
const _ActiveSessionCard({
|
|
required this.mitraName,
|
|
required this.unreadCount,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Material(
|
|
color: HaloTokens.surface,
|
|
borderRadius: HaloRadius.lg,
|
|
elevation: 2,
|
|
shadowColor: HaloTokens.brand.withValues(alpha: 0.2),
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
borderRadius: HaloRadius.lg,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Row(
|
|
children: [
|
|
Badge(
|
|
isLabelVisible: unreadCount > 0,
|
|
label: Text('$unreadCount'),
|
|
child: const CircleAvatar(
|
|
backgroundColor: HaloTokens.success,
|
|
child: Icon(Icons.chat, color: Colors.white),
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'sesi aktif',
|
|
style: TextStyle(
|
|
fontFamily: HaloTokens.fontBody,
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w700,
|
|
color: HaloTokens.ink,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'sedang curhat dengan $mitraName',
|
|
style: const TextStyle(
|
|
fontFamily: HaloTokens.fontBody,
|
|
fontSize: 14,
|
|
color: HaloTokens.inkSoft,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Icon(Icons.chevron_right, color: HaloTokens.inkMuted),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Above-the-fold amber banner shown when notif permission is denied. Tap
|
|
/// "nyalain" → opens app settings; tap close → hides for the in-memory
|
|
/// session (cold restart re-shows it).
|
|
class _NotifDeniedBanner extends ConsumerWidget {
|
|
const _NotifDeniedBanner();
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final statusAsync = ref.watch(notifPermissionStatusProvider);
|
|
final dismissed = ref.watch(homeNotifBannerDismissedProvider);
|
|
final isDenied = statusAsync.valueOrNull == NotifPermStatus.denied;
|
|
if (!isDenied || dismissed) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
return Container(
|
|
width: double.infinity,
|
|
color: HaloTokens.accentSoft,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: HaloSpacing.s16,
|
|
vertical: HaloSpacing.s8,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
const Icon(
|
|
Icons.notifications_off_outlined,
|
|
size: 18,
|
|
color: HaloTokens.brandDark,
|
|
),
|
|
const SizedBox(width: HaloSpacing.s8),
|
|
const Expanded(
|
|
child: Text(
|
|
'notifikasi off — kamu bisa kelewat chat dari bestie',
|
|
style: TextStyle(
|
|
fontFamily: HaloTokens.fontBody,
|
|
fontSize: 12.5,
|
|
color: HaloTokens.ink,
|
|
),
|
|
),
|
|
),
|
|
OutlinedButton(
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: HaloTokens.brandDark,
|
|
side: const BorderSide(color: HaloTokens.brandDark, width: 1),
|
|
shape: const StadiumBorder(),
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: HaloSpacing.s12,
|
|
),
|
|
minimumSize: const Size(0, 32),
|
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
textStyle: const TextStyle(
|
|
fontFamily: HaloTokens.fontBody,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
onPressed: () =>
|
|
ref.read(notifPermissionStatusProvider.notifier).openAppSettings(),
|
|
child: const Text('nyalain'),
|
|
),
|
|
IconButton(
|
|
iconSize: 18,
|
|
visualDensity: VisualDensity.compact,
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(),
|
|
color: HaloTokens.inkSoft,
|
|
icon: const Icon(Icons.close),
|
|
onPressed: () =>
|
|
ref.read(homeNotifBannerDismissedProvider.notifier).state = true,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|