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/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 '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((_) => 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 createState() => _HomeScreenState(); } class _HomeScreenState extends ConsumerState 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 _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; } 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 StatelessWidget { const _LoginRecoverBanner(); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), child: Material( color: HaloTokens.brandSofter, borderRadius: HaloRadius.md, child: InkWell( borderRadius: HaloRadius.md, onTap: () => 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, ), ), ), TextButton( style: TextButton.styleFrom( foregroundColor: HaloTokens.brandDark, padding: const EdgeInsets.symmetric( horizontal: HaloSpacing.s8, ), 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, ), ], ), ); } }