import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'core/analytics/analytics_service.dart'; import 'core/auth/auth_notifier.dart'; import 'core/auth/onboarding_intent_provider.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/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 'features/home/screens/bestie_history_list_screen.dart'; import 'features/profile/profile_screen.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_transcript_screen.dart'; import 'features/chat_tab/screens/chat_tab_shell.dart'; import 'features/chat_tab/screens/aktif_view.dart'; import 'features/chat_tab/screens/pembayaran_view.dart'; import 'features/chat_tab/screens/selesai_view.dart'; import 'features/chat/screens/targeted_waiting_screen.dart'; import 'features/chat/screens/thank_you_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(); }); } } final routerProvider = Provider((ref) => buildRouter(ref)); /// Maps a GoRoute path template (`Route.settings.name`) to a stable /// `screen_name`. Keyed on the *template* (e.g. `/chat/session/:sessionId`) /// so path params are never part of the logged name. Routes absent here are /// dropped (return null → observer skips the screen_view). const _screenNameByRoute = { '/splash': AnalyticsScreen.splash, '/auth/display-name': AnalyticsScreen.authDisplayName, '/auth/register': AnalyticsScreen.authRegister, '/auth/otp': AnalyticsScreen.authOtp, '/auth/set-name': AnalyticsScreen.authSetName, '/auth/force-register': AnalyticsScreen.authForceRegister, '/onboarding/verif/usp': AnalyticsScreen.onboardingUspVerified, '/onboarding/anon/usp': AnalyticsScreen.onboardingUspAnon, '/onboarding/notif-gate': AnalyticsScreen.onboardingNotifGate, '/home': AnalyticsScreen.home, '/profile': AnalyticsScreen.profile, '/payment/entry': AnalyticsScreen.paymentEntry, '/payment/discount-paywall': AnalyticsScreen.paymentDiscountPaywall, '/payment/method-pick': AnalyticsScreen.curhatModePick, '/payment/duration-pick': AnalyticsScreen.paymentDurationPick, '/payment/method': AnalyticsScreen.paymentMethod, '/payment/waiting/:paymentId': AnalyticsScreen.paymentWaiting, '/payment/expired/:paymentId': AnalyticsScreen.paymentExpired, '/chat/searching': AnalyticsScreen.chatSearching, '/chat/found': AnalyticsScreen.chatFound, '/chat/no-bestie': AnalyticsScreen.chatNoBestie, '/chat/waiting-targeted/:mitraId': AnalyticsScreen.chatWaitingTargeted, '/chat/session/:sessionId': AnalyticsScreen.chatSession, '/chat/thank-you': AnalyticsScreen.chatThankYou, '/chat/aktif': AnalyticsScreen.chatTabAktif, '/chat/pembayaran': AnalyticsScreen.chatTabPembayaran, '/chat/selesai': AnalyticsScreen.chatTabSelesai, '/chat/transcript/:sessionId': AnalyticsScreen.chatTranscript, '/bestie/history': AnalyticsScreen.bestieHistory, }; /// `nameExtractor` for [FirebaseAnalyticsObserver]. GoRouter sets /// `Route.settings.name` to the route's path template, so this strips path /// params (`:sessionId` etc.) by construction. String? _screenNameFor(RouteSettings settings) { final name = settings.name; if (name == null) return null; return _screenNameByRoute[name]?.value; } GoRouter buildRouter(Ref ref) { final notifier = RouterNotifier(ref); final analyticsObserver = FirebaseAnalyticsObserver( analytics: FirebaseAnalytics.instance, nameExtractor: _screenNameFor, ); return GoRouter( initialLocation: kThemePreviewEnabled ? '/_theme_preview' : '/splash', refreshListenable: notifier, observers: [analyticsObserver], 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 isAuthRoute = state.matchedLocation.startsWith('/auth'); // 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/'); // SplashScreen is self-driving — it waits for both a 1s minimum hold and // for `authProvider` to resolve, then navigates explicitly. The router // must never redirect away from /splash on its own. if (isSplash) return null; if (authState is AsyncLoading) { if (isAuthRoute) return null; return '/splash'; } final data = authState.valueOrNull; if (data == null) { // Error state — drop onto Home; SHome1st variant handles the // unauthenticated render (login banner overlay). // EXCEPTION: /onboarding/* routes are the post-OTP-blocked popup // fallback path (`/onboarding/anon/method` alias → method-pick). // They must transit freely even when authProvider is in AsyncError // (which is how OTP_ATTEMPTS_EXCEEDED leaves the state), otherwise // the redirect to /home wins over the route-level alias. if (isOnboardingFlow) return null; if (!isAuthRoute && !isSplash) return '/home'; if (isSplash) return '/home'; return null; } if (data is AuthAuthenticatedData || data is AuthAnonymousData || data is AuthOtpSentData) { // 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; // While AuthAnonymousData OR AuthOtpSentData, the user may // legitimately be mid-flow on /home → /auth/display-name (push) → // VerifChoice → /auth/register → /auth/otp. When refreshListenable // fires after loginAnonymous resolves OR after requestOtp returns // AuthOtpSentData, GoRouter re-evaluates the bottom of the // navigation stack — without this carve-out an /auth/* push would // be torn down before the next screen can open. if ((data is AuthAnonymousData || data is AuthOtpSentData) && isAuthRoute) { return null; } // §2 spec north star: when the user reached an auth route from a // transaction CTA ("aku mau curhat" / "curhat sama bestie baru"), // post-OTP must land at /payment/entry — which dispatches to S6 // paywall vs PickMethod via `first_session_discount.eligible`. The // login-recover banner path keeps the default `recover` intent and // lands on /home (preserves user expectation of seeing history). if (data is AuthAuthenticatedData && isAuthRoute) { final intent = ref.read(onboardingIntentProvider); if (intent == OnboardingIntent.onboarding) { return '/payment/entry'; } } return (isSplash || isAuthRoute) ? '/home' : null; } if (data is AuthNeedsDisplayNameData) return '/auth/set-name'; if (data is AuthForceRegisterData) return '/auth/force-register'; // Phase 4: per flow_customer.mermaid §1, fresh / unauthenticated users // land on Home directly — the login panel is an overlay on Home, not a // separate /welcome screen. The Home1st login-panel overlay itself is // still a follow-up (audit: Home1st 🟡). if (!isAuthRoute && !isSplash) return '/home'; if (isSplash) return '/home'; return null; }, routes: [ if (kThemePreviewEnabled) GoRoute(path: '/_theme_preview', builder: (_, __) => const ThemePreviewScreen()), GoRoute(path: '/splash', builder: (_, __) => const SplashScreen()), 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; updated 2026-05-12 — ESP retired, // USP is now a one-time gate, see `usp_seen_provider.dart`). Verified vs // anonymous branches share the USP screen; the parent path drives the // post-USP fork. GoRoute( path: '/onboarding/verif/usp', builder: (_, __) => const UspScreen(verified: true), ), 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: '/profile', builder: (_, __) => const ProfileScreen()), // 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/thank-you', builder: (_, __) => const ThankYouScreen()), // Phase 4 Stage 10 — Chat tab with 3 sub-tabs. The bare `/chat` entry // redirects into the default `aktif` sub-tab so tap-on-bottom-nav lands // on a deterministic URL. GoRoute(path: '/chat', redirect: (_, __) => '/chat/aktif'), ShellRoute( builder: (context, state, child) { final active = switch (state.uri.path) { '/chat/pembayaran' => ChatSubTab.pembayaran, '/chat/selesai' => ChatSubTab.selesai, _ => ChatSubTab.aktif, }; return ChatTabShell(active: active, child: child); }, routes: [ GoRoute(path: '/chat/aktif', builder: (_, __) => const AktifView()), GoRoute( path: '/chat/pembayaran', builder: (_, __) => const PembayaranView()), GoRoute( path: '/chat/selesai', builder: (_, __) => const SelesaiView()), ], ), GoRoute(path: '/chat/transcript/:sessionId', builder: (context, state) { return ChatTranscriptScreen(sessionId: state.pathParameters['sessionId']!); }), // Returning-user `curhat lagi` picker (mermaid §4). Reached from // BestieChoiceSheet → "bestie yang udah kenal". Tap row → targeted-pair // payment. Separate from the Chat tab (which is browse-only). GoRoute( path: '/bestie/history', builder: (_, __) => const BestieHistoryListScreen(), ), ], ); }