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'; /// 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. /// /// 1. The "Mulai Curhat" CTA is gated on real-time mitra availability /// (polling owned by the [mitraAvailabilityProvider]). Polling is paused /// on background and resumed on foreground via [WidgetsBindingObserver]. /// 2. Tapping the enabled CTA pushes `/payment` so the customer must confirm /// a payment session before any blast fires. 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); // Kick the availability poll on once the first frame settles. Doing it // here (rather than in build) avoids re-firing on every rebuild. WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; ref.read(mitraAvailabilityProvider.notifier).setActive(true); }); } @override void dispose() { // Stop polling when leaving home. ref.read(mitraAvailabilityProvider.notifier).setActive(false); WidgetsBinding.instance.removeObserver(this); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { final notifier = ref.read(mitraAvailabilityProvider.notifier); if (state == AppLifecycleState.resumed) { // Re-fetch in case a session ended/started while backgrounded. ref.read(activeSessionProvider.notifier).refresh(); notifier.setActive(true); } else if (state == AppLifecycleState.paused || state == AppLifecycleState.inactive) { notifier.setActive(false); } } Future _onStartChatPressed(BuildContext context) async { // Phase 4 Stage 2 removes the home-screen topic sensitivity prompt; the // ESP picks collected during onboarding feed the same column server-side // (info-only — no longer drives matching). Mitras still flip // `topic_sensitivity` mid-session via the AppBar toggle. // // Phase 4 Stage 8: returning users get the bestie-choice sheet first; new // users skip straight to the multi-screen payment shell. We fetch the // history-has-items flag on-tap so a stale cache from logout/login doesn't // mis-route. On error (e.g. offline), fall back to the new-user path. 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'); } @override Widget build(BuildContext context) { final authState = ref.watch(authProvider); final authData = authState.valueOrNull; final activeSessionAsync = ref.watch(activeSessionProvider); final availabilityAsync = ref.watch(mitraAvailabilityProvider); final displayName = switch (authData) { AuthAuthenticatedData d => d.profile['display_name'] as String? ?? '', AuthAnonymousData d => d.displayName, _ => '', }; // Poll-failure / loading both default to "no bestie available" (greyed-out). // Never optimistically enable. final mitraAvailable = availabilityAsync.valueOrNull ?? false; return Scaffold( appBar: AppBar( title: const Text('Halo Bestie'), actions: [ IconButton( icon: const Icon(Icons.history), onPressed: () => context.push('/chat/history'), ), IconButton( icon: const Icon(Icons.logout), onPressed: () => ref.read(authProvider.notifier).logout(), ), ], ), body: RefreshIndicator( onRefresh: () async { // Pull-to-refresh kicks both the active-session and availability polls. await Future.wait([ ref.read(activeSessionProvider.notifier).refresh(), ref.read(mitraAvailabilityProvider.notifier).refresh(), ]); }, child: ListView( // Force-scroll so RefreshIndicator can fire even on a short body. physics: const AlwaysScrollableScrollPhysics(), padding: EdgeInsets.zero, children: [ const _NotifDeniedBanner(), const Padding( padding: EdgeInsets.symmetric(horizontal: 32), child: SizedBox(height: 32), ), Center(child: Text('Halo, $displayName!', style: const TextStyle(fontSize: 24))), const SizedBox(height: 32), Center( child: activeSessionAsync.when( loading: () => const CircularProgressIndicator(), error: (_, __) => _StartChatButton( enabled: mitraAvailable, onPressed: () => _onStartChatPressed(context), ), data: (snapshot) { // Hide the "Sesi Aktif" CTA when the session is in `closing` // — the conversation is over, only the goodbye composer // remains. Backend auto-completes such sessions after a // grace period; until then the user shouldn't be invited // back into them from home. 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 _StartChatButton( enabled: mitraAvailable, onPressed: () => _onStartChatPressed(context), ); }, ), ), ], ), ), ); } } class _StartChatButton extends StatelessWidget { final bool enabled; final VoidCallback onPressed; const _StartChatButton({required this.enabled, required this.onPressed}); @override Widget build(BuildContext context) { return Column( children: [ const SizedBox(height: 16), ElevatedButton( style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 16), ), onPressed: enabled ? onPressed : null, child: const Text('Mulai Curhat', style: TextStyle(fontSize: 18)), ), const SizedBox(height: 12), if (!enabled) Text( 'Belum ada bestie tersedia', style: TextStyle(fontSize: 14, color: Colors.grey.shade600), ), ], ); } } 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 Card( elevation: 2, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(12), child: Padding( padding: const EdgeInsets.all(20), child: Row( children: [ Badge( isLabelVisible: unreadCount > 0, label: Text('$unreadCount'), child: const CircleAvatar( backgroundColor: Colors.green, child: Icon(Icons.chat, color: Colors.white), ), ), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'Sesi Aktif', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), ), const SizedBox(height: 4), Text( 'Sedang curhat dengan $mitraName', style: const TextStyle(fontSize: 14, color: Colors.grey), ), ], ), ), const Icon(Icons.chevron_right), ], ), ), ), ); } } /// Above-the-fold amber banner shown when notif permission is denied. Tap /// "nyalain" → opens app settings; tap the close icon → hides for the /// in-memory session only (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, ), ], ), ); } }