Files
tabatago/app/_layout.tsx
Millian Lamiaux 5888aac08e refactor screens, navigation & player for new architecture
Simplify Home, Activity, Profile, Complete, Player, and Program screens
to work with the new Supabase-driven data layer. Update root and tab
layouts. Add Settings, Terms, and Zone routes. Add OfflineBanner
component and progressStore. Update all player sub-components to use
the refreshed design system tokens.
2026-04-21 21:50:48 +02:00

261 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 (
<View style={errorStyles.container}>
<Text style={errorStyles.emoji}></Text>
<Text style={errorStyles.title}>Something went wrong</Text>
<Text style={errorStyles.message}>
{this.state.error?.message ?? 'An unexpected error occurred.'}
</Text>
<Pressable style={errorStyles.button} onPress={this.handleRetry}>
<Text style={errorStyles.buttonText}>Try again</Text>
</Pressable>
</View>
)
}
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 = (
<QueryClientProvider client={queryClient}>
<View style={{ flex: 1, backgroundColor: colors.bg.base }} onLayout={onLayoutRootView}>
<StatusBar style={colors.statusBarStyle} />
<OfflineBanner />
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.bg.base },
animation: 'default',
headerBackButtonDisplayMode: 'minimal',
headerTintColor: GREEN[500],
headerStyle: { backgroundColor: colors.bg.base },
headerShadowVisible: false,
headerTitleStyle: { fontWeight: '600', fontSize: 17, color: colors.text.primary },
}}
>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="onboarding"
options={{ animation: 'fade' }}
/>
<Stack.Screen
name="zone/[bodyZone]"
options={{ headerShown: true, headerTitle: '' }}
/>
<Stack.Screen
name="program/[id]"
options={{ headerShown: true, headerTitle: '' }}
/>
<Stack.Screen
name="player/[id]"
options={{ presentation: 'fullScreenModal', animation: 'fade' }}
/>
<Stack.Screen
name="complete/[id]"
options={{ animation: 'fade' }}
/>
<Stack.Screen
name="settings"
options={{
presentation: 'formSheet',
sheetGrabberVisible: true,
sheetAllowedDetents: [0.75, 1.0],
}}
/>
<Stack.Screen
name="terms"
options={{ headerShown: true, headerTitle: '' }}
/>
<Stack.Screen
name="privacy"
options={{ headerShown: true, headerTitle: '' }}
/>
<Stack.Screen
name="paywall"
options={{
presentation: 'formSheet',
sheetGrabberVisible: true,
sheetAllowedDetents: [0.85, 1.0],
}}
/>
</Stack>
</View>
</QueryClientProvider>
)
const posthogClient = getPostHogClient()
// Only wrap with PostHogProvider if client is initialized
if (!posthogClient) {
return content
}
return (
<PostHogProvider
client={posthogClient}
autocapture={{
captureScreens: true,
captureTouches: true,
}}
>
{content}
</PostHogProvider>
)
}
export default function RootLayout() {
return (
<ErrorBoundary>
<ThemeProvider>
<RootLayoutInner />
</ThemeProvider>
</ErrorBoundary>
)
}