/** * TabataFit Root Layout * Expo Router v3 — SF Pro system font (no custom font loading) * Waits for store hydration before rendering */ import '@/src/shared/i18n' import '@/src/shared/i18n/types' import { useState, useEffect, useCallback, Component } from 'react' import { Stack } from 'expo-router' import { StatusBar } from 'expo-status-bar' import { View, Text, Pressable, StyleSheet } from 'react-native' import * as SplashScreen from 'expo-splash-screen' import * as Notifications from 'expo-notifications' import { PostHogProvider } from 'posthog-react-native' import { ThemeProvider, useThemeColors } from '@/src/shared/theme' import { TEXT, NAVY, GREEN } from '@/src/shared/constants/colors' import { useUserStore } from '@/src/shared/stores' import { useNotifications } from '@/src/shared/hooks' import { OfflineBanner } from '@/src/shared/components/OfflineBanner' import { initializePurchases } from '@/src/shared/services/purchases' import { initializeAnalytics, getPostHogClient, trackScreen } from '@/src/shared/services/analytics' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' Notifications.setNotificationHandler({ handleNotification: async () => ({ shouldShowAlert: false, shouldPlaySound: false, shouldSetBadge: false, shouldShowBanner: false, shouldShowList: false, }), }) SplashScreen.preventAutoHideAsync() // ─── Error Boundary (F-108) ──────────────────────────────────────────────── interface ErrorBoundaryState { hasError: boolean error: Error | null } class ErrorBoundary extends Component<{ children: React.ReactNode }, ErrorBoundaryState> { state: ErrorBoundaryState = { hasError: false, error: null } static getDerivedStateFromError(error: Error): ErrorBoundaryState { return { hasError: true, error } } componentDidCatch(error: Error, info: React.ErrorInfo) { console.error('ErrorBoundary caught:', error, info.componentStack) } private handleRetry = () => { this.setState({ hasError: false, error: null }) } render() { if (this.state.hasError) { return ( ⚠️ Something went wrong {this.state.error?.message ?? 'An unexpected error occurred.'} Try again ) } return this.props.children } } const errorStyles = StyleSheet.create({ container: { flex: 1, backgroundColor: NAVY[900], alignItems: 'center', justifyContent: 'center', padding: 32, }, emoji: { fontSize: 48, marginBottom: 16 }, title: { fontSize: 22, fontWeight: '700', color: TEXT.PRIMARY, marginBottom: 8, }, message: { fontSize: 15, fontWeight: '400', color: TEXT.SECONDARY, textAlign: 'center', marginBottom: 24, lineHeight: 20, }, button: { backgroundColor: '#00C896', paddingHorizontal: 32, paddingVertical: 14, borderRadius: 12, borderCurve: 'continuous', minHeight: 44, }, buttonText: { fontSize: 17, fontWeight: '600', color: NAVY[900], }, }) // Create React Query Client const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 1000 * 60 * 5, // 5 minutes gcTime: 1000 * 60 * 60 * 24, // 24 hours retry: 2, refetchOnWindowFocus: false, }, }, }) function RootLayoutInner() { const colors = useThemeColors() useNotifications() // Wait for persisted store to hydrate from AsyncStorage const [hydrated, setHydrated] = useState(useUserStore.persist.hasHydrated()) useEffect(() => { const unsub = useUserStore.persist.onFinishHydration(() => setHydrated(true)) return unsub }, []) // Initialize RevenueCat + PostHog after hydration useEffect(() => { if (hydrated) { initializePurchases().catch((err) => { console.error('Failed to initialize RevenueCat:', err) }) initializeAnalytics().catch((err) => { console.error('Failed to initialize PostHog:', err) }) } }, [hydrated]) const onLayoutRootView = useCallback(async () => { if (hydrated) { await SplashScreen.hideAsync() } }, [hydrated]) if (!hydrated) { return null } const content = ( ) const posthogClient = getPostHogClient() // Only wrap with PostHogProvider if client is initialized if (!posthogClient) { return content } return ( {content} ) } export default function RootLayout() { return ( ) }