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.
261 lines
7.3 KiB
TypeScript
261 lines
7.3 KiB
TypeScript
/**
|
||
* 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>
|
||
)
|
||
}
|