diff --git a/app/_layout.tsx b/app/_layout.tsx index 72ab931..a125182 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -29,6 +29,7 @@ import { useUserStore } from '@/src/shared/stores' import { useNotifications } from '@/src/shared/hooks' 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 () => ({ @@ -42,6 +43,18 @@ Notifications.setNotificationHandler({ SplashScreen.preventAutoHideAsync() +// 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() @@ -86,6 +99,7 @@ function RootLayoutInner() { } const content = ( + + ) const posthogClient = getPostHogClient() diff --git a/package.json b/package.json index fdb8ba4..14ea2c0 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,9 @@ "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", "@supabase/supabase-js": "^2.98.0", + "@tanstack/query-async-storage-persister": "^5.90.24", + "@tanstack/react-query": "^5.90.21", + "@tanstack/react-query-persist-client": "^5.90.24", "expo": "~54.0.33", "expo-application": "~7.0.8", "expo-av": "~16.0.8", diff --git a/src/shared/components/loading/Skeleton.tsx b/src/shared/components/loading/Skeleton.tsx new file mode 100644 index 0000000..be7cfb5 --- /dev/null +++ b/src/shared/components/loading/Skeleton.tsx @@ -0,0 +1,190 @@ +/** + * Loading Skeleton Components + * Shimmer loading states for better UX + */ + +import { View, StyleSheet, Animated } from 'react-native' +import { useEffect, useRef } from 'react' +import { useThemeColors } from '@/src/shared/theme' +import { SPACING } from '@/src/shared/constants/spacing' +import { RADIUS } from '@/src/shared/constants/borderRadius' + +interface SkeletonProps { + width?: number | string + height?: number + borderRadius?: number + style?: any +} + +/** + * Shimmer Skeleton Component + */ +export function Skeleton({ width = '100%', height = 20, borderRadius = RADIUS.MD, style }: SkeletonProps) { + const colors = useThemeColors() + const shimmerValue = useRef(new Animated.Value(0)).current + + useEffect(() => { + const shimmer = Animated.loop( + Animated.sequence([ + Animated.timing(shimmerValue, { + toValue: 1, + duration: 1500, + useNativeDriver: true, + }), + Animated.timing(shimmerValue, { + toValue: 0, + duration: 1500, + useNativeDriver: true, + }), + ]) + ) + shimmer.start() + return () => shimmer.stop() + }, []) + + const translateX = shimmerValue.interpolate({ + inputRange: [0, 1], + outputRange: [-200, 200], + }) + + return ( + + + + ) +} + +/** + * Workout Card Skeleton + */ +export function WorkoutCardSkeleton() { + const colors = useThemeColors() + + return ( + + + + + + + + + + + ) +} + +/** + * Trainer Card Skeleton + */ +export function TrainerCardSkeleton() { + const colors = useThemeColors() + + return ( + + + + + + + + ) +} + +/** + * Collection Card Skeleton + */ +export function CollectionCardSkeleton() { + return ( + + + + + ) +} + +/** + * Stats Card Skeleton + */ +export function StatsCardSkeleton() { + const colors = useThemeColors() + + return ( + + + + + + + + ) +} + +const styles = StyleSheet.create({ + skeleton: { + overflow: 'hidden', + }, + shimmer: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(255, 255, 255, 0.1)', + width: 100, + }, + card: { + borderRadius: RADIUS.LG, + overflow: 'hidden', + marginBottom: SPACING[4], + }, + cardContent: { + padding: SPACING[4], + gap: SPACING[2], + }, + row: { + flexDirection: 'row', + justifyContent: 'space-between', + marginTop: SPACING[2], + }, + trainerCard: { + flexDirection: 'row', + alignItems: 'center', + padding: SPACING[4], + borderRadius: RADIUS.LG, + marginBottom: SPACING[3], + }, + trainerInfo: { + marginLeft: SPACING[4], + flex: 1, + gap: SPACING[2], + }, + collectionCard: { + alignItems: 'center', + padding: SPACING[4], + }, + statsCard: { + padding: SPACING[4], + borderRadius: RADIUS.LG, + minWidth: 140, + }, + statsHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, +}) \ No newline at end of file diff --git a/src/shared/hooks/useSupabaseData.ts b/src/shared/hooks/useSupabaseData.ts index fc990a8..b9c1443 100644 --- a/src/shared/hooks/useSupabaseData.ts +++ b/src/shared/hooks/useSupabaseData.ts @@ -1,397 +1,151 @@ -import { useState, useEffect, useCallback } from 'react' +/** + * React Query Data Hooks + * Replaces manual state management with React Query for better caching and performance + */ + +import { useQuery } from '@tanstack/react-query' import { dataService } from '../data/dataService' import type { Workout, Trainer, Collection, Program } from '../types' +// Query Keys +export const queryKeys = { + workouts: 'workouts', + workout: (id: string) => ['workouts', id], + workoutsByCategory: (category: string) => ['workouts', 'category', category], + workoutsByTrainer: (trainerId: string) => ['workouts', 'trainer', trainerId], + featuredWorkouts: ['workouts', 'featured'], + trainers: 'trainers', + trainer: (id: string) => ['trainers', id], + collections: 'collections', + collection: (id: string) => ['collections', id], + programs: 'programs', +} as const + +/** + * Hook to fetch all workouts + */ export function useWorkouts() { - const [workouts, setWorkouts] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - const fetchWorkouts = useCallback(async () => { - try { - setLoading(true) - const data = await dataService.getAllWorkouts() - setWorkouts(data) - setError(null) - } catch (err) { - setError(err as Error) - } finally { - setLoading(false) - } - }, []) - - useEffect(() => { - let mounted = true - - async function load() { - try { - setLoading(true) - const data = await dataService.getAllWorkouts() - if (mounted) { - setWorkouts(data) - setError(null) - } - } catch (err) { - if (mounted) { - setError(err as Error) - } - } finally { - if (mounted) { - setLoading(false) - } - } - } - - load() - return () => { mounted = false } - }, []) - - return { workouts, loading, error, refetch: fetchWorkouts } + return useQuery({ + queryKey: [queryKeys.workouts], + queryFn: () => dataService.getAllWorkouts(), + staleTime: 1000 * 60 * 5, // 5 minutes + }) } +/** + * Hook to fetch single workout + */ export function useWorkout(id: string | undefined) { - const [workout, setWorkout] = useState() - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - useEffect(() => { - let mounted = true - - async function load() { - if (!id) { - setLoading(false) - return - } - - try { - setLoading(true) - const data = await dataService.getWorkoutById(id) - if (mounted) { - setWorkout(data) - setError(null) - } - } catch (err) { - if (mounted) { - setError(err as Error) - } - } finally { - if (mounted) { - setLoading(false) - } - } - } - - load() - return () => { mounted = false } - }, [id]) - - return { workout, loading, error } + return useQuery({ + queryKey: queryKeys.workout(id || ''), + queryFn: () => dataService.getWorkoutById(id!), + enabled: !!id, + staleTime: 1000 * 60 * 5, + }) } +/** + * Hook to fetch workouts by category + */ export function useWorkoutsByCategory(category: string) { - const [workouts, setWorkouts] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - useEffect(() => { - let mounted = true - - async function load() { - try { - setLoading(true) - const data = await dataService.getWorkoutsByCategory(category) - if (mounted) { - setWorkouts(data) - setError(null) - } - } catch (err) { - if (mounted) { - setError(err as Error) - } - } finally { - if (mounted) { - setLoading(false) - } - } - } - - load() - return () => { mounted = false } - }, [category]) - - return { workouts, loading, error } -} - -export function useTrainers() { - const [trainers, setTrainers] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - const fetchTrainers = useCallback(async () => { - try { - setLoading(true) - const data = await dataService.getAllTrainers() - setTrainers(data) - setError(null) - } catch (err) { - setError(err as Error) - } finally { - setLoading(false) - } - }, []) - - useEffect(() => { - let mounted = true - - async function load() { - try { - setLoading(true) - const data = await dataService.getAllTrainers() - if (mounted) { - setTrainers(data) - setError(null) - } - } catch (err) { - if (mounted) { - setError(err as Error) - } - } finally { - if (mounted) { - setLoading(false) - } - } - } - - load() - return () => { mounted = false } - }, []) - - return { trainers, loading, error, refetch: fetchTrainers } -} - -export function useTrainer(id: string | undefined) { - const [trainer, setTrainer] = useState() - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - useEffect(() => { - let mounted = true - - async function load() { - if (!id) { - setLoading(false) - return - } - - try { - setLoading(true) - const data = await dataService.getTrainerById(id) - if (mounted) { - setTrainer(data) - setError(null) - } - } catch (err) { - if (mounted) { - setError(err as Error) - } - } finally { - if (mounted) { - setLoading(false) - } - } - } - - load() - return () => { mounted = false } - }, [id]) - - return { trainer, loading, error } -} - -export function useCollections() { - const [collections, setCollections] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - const fetchCollections = useCallback(async () => { - try { - setLoading(true) - const data = await dataService.getAllCollections() - setCollections(data) - setError(null) - } catch (err) { - setError(err as Error) - } finally { - setLoading(false) - } - }, []) - - useEffect(() => { - let mounted = true - - async function load() { - try { - setLoading(true) - const data = await dataService.getAllCollections() - if (mounted) { - setCollections(data) - setError(null) - } - } catch (err) { - if (mounted) { - setError(err as Error) - } - } finally { - if (mounted) { - setLoading(false) - } - } - } - - load() - return () => { mounted = false } - }, []) - - return { collections, loading, error, refetch: fetchCollections } -} - -export function useCollection(id: string | undefined) { - const [collection, setCollection] = useState() - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - useEffect(() => { - let mounted = true - - async function load() { - if (!id) { - setLoading(false) - return - } - - try { - setLoading(true) - const data = await dataService.getCollectionById(id) - if (mounted) { - setCollection(data) - setError(null) - } - } catch (err) { - if (mounted) { - setError(err as Error) - } - } finally { - if (mounted) { - setLoading(false) - } - } - } - - load() - return () => { mounted = false } - }, [id]) - - return { collection, loading, error } -} - -export function usePrograms() { - const [programs, setPrograms] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - useEffect(() => { - let mounted = true - - async function load() { - try { - setLoading(true) - const data = await dataService.getAllPrograms() - if (mounted) { - setPrograms(data) - setError(null) - } - } catch (err) { - if (mounted) { - setError(err as Error) - } - } finally { - if (mounted) { - setLoading(false) - } - } - } - - load() - return () => { mounted = false } - }, []) - - return { programs, loading, error } -} - -export function useFeaturedWorkouts() { - const [workouts, setWorkouts] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - useEffect(() => { - let mounted = true - - async function load() { - try { - setLoading(true) - const data = await dataService.getFeaturedWorkouts() - if (mounted) { - setWorkouts(data) - setError(null) - } - } catch (err) { - if (mounted) { - setError(err as Error) - } - } finally { - if (mounted) { - setLoading(false) - } - } - } - - load() - return () => { mounted = false } - }, []) - - return { workouts, loading, error } + return useQuery({ + queryKey: queryKeys.workoutsByCategory(category), + queryFn: () => dataService.getWorkoutsByCategory(category), + enabled: !!category, + staleTime: 1000 * 60 * 5, + }) } +/** + * Hook to fetch workouts by trainer + */ export function useWorkoutsByTrainer(trainerId: string) { - const [workouts, setWorkouts] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - useEffect(() => { - let mounted = true - - async function load() { - try { - setLoading(true) - const data = await dataService.getWorkoutsByTrainer(trainerId) - if (mounted) { - setWorkouts(data) - setError(null) - } - } catch (err) { - if (mounted) { - setError(err as Error) - } - } finally { - if (mounted) { - setLoading(false) - } - } - } - - load() - return () => { mounted = false } - }, [trainerId]) - - return { workouts, loading, error } + return useQuery({ + queryKey: queryKeys.workoutsByTrainer(trainerId), + queryFn: () => dataService.getWorkoutsByTrainer(trainerId), + enabled: !!trainerId, + staleTime: 1000 * 60 * 5, + }) } + +/** + * Hook to fetch featured workouts + */ +export function useFeaturedWorkouts() { + return useQuery({ + queryKey: queryKeys.featuredWorkouts, + queryFn: () => dataService.getFeaturedWorkouts(), + staleTime: 1000 * 60 * 5, + }) +} + +/** + * Hook to fetch popular workouts (uses recent workouts as proxy) + */ +export function usePopularWorkouts(count: number = 8) { + return useQuery({ + queryKey: ['workouts', 'popular', count], + queryFn: async () => { + const allWorkouts = await dataService.getAllWorkouts() + return allWorkouts.slice(0, count) + }, + staleTime: 1000 * 60 * 5, + }) +} + +/** + * Hook to fetch all trainers + */ +export function useTrainers() { + return useQuery({ + queryKey: [queryKeys.trainers], + queryFn: () => dataService.getAllTrainers(), + staleTime: 1000 * 60 * 5, + }) +} + +/** + * Hook to fetch single trainer + */ +export function useTrainer(id: string | undefined) { + return useQuery({ + queryKey: queryKeys.trainer(id || ''), + queryFn: () => dataService.getTrainerById(id!), + enabled: !!id, + staleTime: 1000 * 60 * 5, + }) +} + +/** + * Hook to fetch all collections + */ +export function useCollections() { + return useQuery({ + queryKey: [queryKeys.collections], + queryFn: () => dataService.getAllCollections(), + staleTime: 1000 * 60 * 5, + }) +} + +/** + * Hook to fetch single collection + */ +export function useCollection(id: string | undefined) { + return useQuery({ + queryKey: queryKeys.collection(id || ''), + queryFn: () => dataService.getCollectionById(id!), + enabled: !!id, + staleTime: 1000 * 60 * 5, + }) +} + +/** + * Hook to fetch all programs + */ +export function usePrograms() { + return useQuery({ + queryKey: [queryKeys.programs], + queryFn: () => dataService.getAllPrograms(), + staleTime: 1000 * 60 * 5, + }) +} \ No newline at end of file