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 'features/auth/screens/welcome_screen.dart'; import 'features/auth/screens/display_name_screen.dart'; import 'features/auth/screens/register_screen.dart'; import 'features/auth/screens/otp_screen.dart'; import 'features/auth/screens/force_register_screen.dart'; import 'features/auth/screens/set_display_name_screen.dart'; import 'features/onboarding/onboarding_screen.dart'; import 'features/onboarding/screens/esp_screen.dart'; import 'features/onboarding/screens/notif_gate_screen.dart'; import 'features/onboarding/screens/usp_screen.dart'; import 'features/splash/splash_screen.dart'; import 'features/home/home_screen.dart'; import 'core/constants.dart'; import 'features/chat/screens/searching_screen.dart'; import 'features/chat/screens/bestie_found_screen.dart'; import 'features/chat/screens/no_bestie_screen.dart'; import 'features/chat/screens/chat_screen.dart'; import 'features/chat/screens/chat_history_screen.dart'; import 'features/chat/screens/chat_transcript_screen.dart'; import 'features/chat/screens/targeted_waiting_screen.dart'; import 'features/payment/screens/payment_screen.dart'; import 'features/payment/screens/payment_entry_screen.dart'; import 'features/payment/screens/discount_paywall_screen.dart'; import 'features/payment/screens/method_pick_screen.dart'; import 'features/payment/screens/duration_pick_screen.dart'; import 'features/payment/screens/payment_method_screen.dart'; import 'features/payment/screens/waiting_payment_screen.dart'; import 'features/payment/screens/payment_expired_screen.dart'; import 'core/theme/_preview.dart'; class RouterNotifier extends ChangeNotifier { final Ref _ref; RouterNotifier(this._ref) { _ref.listen(authProvider, (prev, next) { // Errors are handled locally by screens (toast) — they should never // trigger router/Navigator rebuilds, otherwise the active SnackBar // re-animates and looks like a duplicate toast. if (next is AsyncError) return; // Skip transient AsyncLoading where the data variant didn't change. if (prev?.valueOrNull?.runtimeType == next.valueOrNull?.runtimeType) { return; } notifyListeners(); }); } } /// Cached onboarding status — loaded once at startup, invalidated after onboarding completes final onboardingDoneProvider = FutureProvider((ref) => isOnboardingDone()); final routerProvider = Provider((ref) => buildRouter(ref)); GoRouter buildRouter(Ref ref) { final notifier = RouterNotifier(ref); return GoRouter( initialLocation: kThemePreviewEnabled ? '/_theme_preview' : '/splash', refreshListenable: notifier, redirect: (context, state) { // Theme preview is dev-only and intentionally bypasses auth + onboarding // gates so it can be opened on any device build. if (state.matchedLocation == '/_theme_preview') return null; final authState = ref.read(authProvider); final isSplash = state.matchedLocation == '/splash'; final isOnboarding = state.matchedLocation == '/onboarding'; final isAuthRoute = state.matchedLocation.startsWith('/auth') || state.matchedLocation == '/welcome'; // Phase 4 onboarding flow (Verif Choice → ESP → USP) — must transit // freely while authState is AuthAnonymousData so the router doesn't // boot the user back to /home before they finish onboarding. final isOnboardingFlow = state.matchedLocation.startsWith('/onboarding/'); // Show splash only during initial load if (authState is AsyncLoading) { if (isSplash || isAuthRoute || isOnboarding) return null; return '/splash'; } // Check onboarding status — must complete before anything else final onboardingDone = ref.read(onboardingDoneProvider).valueOrNull ?? false; if (!onboardingDone) { return isOnboarding ? null : '/onboarding'; } if (isOnboarding) { return '/welcome'; } final data = authState.valueOrNull; if (data == null) { // Error state — show login if (!isAuthRoute && !isSplash) return '/welcome'; if (isSplash) return '/welcome'; return null; } if (data is AuthAuthenticatedData || data is AuthAnonymousData) { // Allow the Phase 4 onboarding flow (ESP/USP) to stay put even when // the user is already anonymous-authenticated — display_name_screen // intentionally pushes into /onboarding/* after loginAnonymous. if (isOnboardingFlow) return null; // display_name_screen owns the post-anonymous-login routing decision // (onboarding-state lookup → Verif Choice Sheet vs returning-user // jump). Don't preempt it by redirecting to /home the instant the // anonymous login resolves — wait until the screen pushes onward. if (data is AuthAnonymousData && state.matchedLocation == '/auth/display-name') { return null; } return (isSplash || isAuthRoute) ? '/home' : null; } if (data is AuthNeedsDisplayNameData) return '/auth/set-name'; if (data is AuthForceRegisterData) return '/auth/force-register'; if (!isAuthRoute && !isSplash) return '/welcome'; if (isSplash) return '/welcome'; return null; }, routes: [ if (kThemePreviewEnabled) GoRoute(path: '/_theme_preview', builder: (_, __) => const ThemePreviewScreen()), GoRoute(path: '/splash', builder: (_, __) => const SplashScreen()), GoRoute(path: '/onboarding', builder: (_, __) => const OnboardingScreen()), GoRoute(path: '/welcome', builder: (_, __) => const WelcomeScreen()), GoRoute(path: '/auth/display-name', builder: (_, __) => const DisplayNameScreen()), GoRoute(path: '/auth/register', builder: (_, __) => const RegisterScreen()), GoRoute(path: '/auth/otp', builder: (context, state) => OtpScreen(phone: state.extra as String)), GoRoute(path: '/auth/set-name', builder: (_, __) => const SetDisplayNameScreen()), GoRoute(path: '/auth/force-register', builder: (_, __) => const ForceRegisterScreen()), // Phase 4 onboarding sub-flow (Stage 2). Verified vs anonymous branch // share ESP + USP screens; the parent path drives the post-USP fork. GoRoute( path: '/onboarding/verif/esp', builder: (_, __) => const EspScreen(verified: true), ), GoRoute( path: '/onboarding/verif/usp', builder: (_, __) => const UspScreen(verified: true), ), GoRoute( path: '/onboarding/anon/esp', builder: (_, __) => const EspScreen(verified: false), ), GoRoute( path: '/onboarding/anon/usp', builder: (_, __) => const UspScreen(verified: false), ), // Phase 4 Stage 4 — post-payment OS-notif gate. Sits under the // `/onboarding/*` carve-out so the auth/onboarding redirect rules let // anonymous + authenticated users transit through unhindered. GoRoute( path: '/onboarding/notif-gate', builder: (_, __) => const NotifGateScreen(), ), // Alias for the OTP-blocked popup's "lanjut tanpa verif" exit. The // popup may fire from any point in the verified branch (after the // user has already passed ESP+USP), so we expose a stable terminal // landing-zone alias rather than rewriting all upstream pushes. GoRoute( path: '/onboarding/anon/method', redirect: (_, __) => '/payment/method-pick', ), GoRoute(path: '/home', builder: (_, __) => const HomeScreen()), GoRoute(path: '/payment', builder: (context, state) { // Legacy Phase 3.7 single-screen payment. Still reachable from // - Home "Mulai Curhat" CTA → no extras (general blast follows confirm) // - Chat history "Curhat lagi" CTA → extras carry targetedMitraId/mitraName // for the returning-chat flow, plus optional topicSensitivity. // Phase 4 Stage 3 introduces sibling routes under `/payment/*`; the new // entry point is `/payment/entry`. This route is preserved until Stage 5 // migrates the chat-history "Curhat lagi" flow. final extra = state.extra; if (extra is Map) { final topic = extra['topicSensitivity']; return PaymentScreen( targetedMitraId: extra['targetedMitraId'] as String?, mitraName: extra['mitraName'] as String?, topicSensitivity: topic is TopicSensitivity ? topic : TopicSensitivity.regular, ); } return const PaymentScreen(); }), // Phase 4 Stage 3 — multi-screen payment shell. GoRoute(path: '/payment/entry', builder: (_, __) => const PaymentEntryScreen()), GoRoute(path: '/payment/discount-paywall', builder: (_, __) => const DiscountPaywallScreen()), GoRoute(path: '/payment/method-pick', builder: (_, __) => const MethodPickScreen()), GoRoute(path: '/payment/duration-pick', builder: (_, __) => const DurationPickScreen()), GoRoute(path: '/payment/method', builder: (_, __) => const PaymentMethodScreen()), GoRoute( path: '/payment/waiting/:paymentId', builder: (context, state) => WaitingPaymentScreen( paymentId: state.pathParameters['paymentId']!, ), ), GoRoute( path: '/payment/expired/:paymentId', builder: (context, state) => PaymentExpiredScreen( paymentId: state.pathParameters['paymentId']!, ), ), GoRoute(path: '/chat/searching', builder: (_, __) => const SearchingScreen()), GoRoute(path: '/chat/found', builder: (context, state) { final extra = state.extra as Map; return BestieFoundScreen( sessionId: extra['sessionId'] as String, mitraName: extra['mitraName'] as String, ); }), GoRoute(path: '/chat/no-bestie', builder: (_, __) => const NoBestieScreen()), // Phase 4 Stage 5 — targeted "Curhat lagi" 20s wait overlay. Pushed // after a confirmed targeted payment session; the pairing notifier // emits PairingTargetedWaitingData synchronously after the POST. GoRoute( path: '/chat/waiting-targeted/:mitraId', builder: (context, state) => TargetedWaitingScreen( mitraId: state.pathParameters['mitraId']!, ), ), GoRoute(path: '/chat/session/:sessionId', builder: (context, state) { final extra = state.extra; final mitraName = extra is String ? extra : (extra is Map ? extra['mitraName'] as String? : null); return ChatScreen( sessionId: state.pathParameters['sessionId']!, mitraName: mitraName ?? 'Bestie', ); }), GoRoute(path: '/chat/history', builder: (_, __) => const ChatHistoryScreen()), GoRoute(path: '/chat/history/:sessionId', builder: (context, state) { return ChatTranscriptScreen(sessionId: state.pathParameters['sessionId']!); }), ], ); }