feat: implement React Query for Supabase data fetching

- Install @tanstack/react-query and async-storage-persister
- Wrap app with QueryClientProvider in root layout
- Migrate useSupabaseData hooks to React Query
- Add loading skeleton components for better UX
- Configure 5-minute stale time and 24-hour cache
- Add query keys for proper cache management
- Remove duplicate files and clean up structure
This commit is contained in:
Millian Lamiaux
2026-03-17 14:25:41 +01:00
parent e13d917466
commit b1741e965c
4 changed files with 345 additions and 383 deletions

View File

@@ -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 (
<View
style={[
styles.skeleton,
{
width,
height,
borderRadius,
backgroundColor: colors.bg.overlay2,
},
style,
]}
>
<Animated.View
style={[
styles.shimmer,
{
transform: [{ translateX }],
},
]}
/>
</View>
)
}
/**
* Workout Card Skeleton
*/
export function WorkoutCardSkeleton() {
const colors = useThemeColors()
return (
<View style={[styles.card, { backgroundColor: colors.bg.surface }]}>
<Skeleton width="100%" height={160} borderRadius={RADIUS.LG} />
<View style={styles.cardContent}>
<Skeleton width="70%" height={20} />
<View style={styles.row}>
<Skeleton width="40%" height={16} />
<Skeleton width="30%" height={16} />
</View>
</View>
</View>
)
}
/**
* Trainer Card Skeleton
*/
export function TrainerCardSkeleton() {
const colors = useThemeColors()
return (
<View style={[styles.trainerCard, { backgroundColor: colors.bg.surface }]}>
<Skeleton width={80} height={80} borderRadius={40} />
<View style={styles.trainerInfo}>
<Skeleton width="80%" height={18} />
<Skeleton width="60%" height={14} />
</View>
</View>
)
}
/**
* Collection Card Skeleton
*/
export function CollectionCardSkeleton() {
return (
<View style={styles.collectionCard}>
<Skeleton width={120} height={120} borderRadius={RADIUS.XL} />
<Skeleton width="80%" height={18} style={{ marginTop: SPACING[3] }} />
</View>
)
}
/**
* Stats Card Skeleton
*/
export function StatsCardSkeleton() {
const colors = useThemeColors()
return (
<View style={[styles.statsCard, { backgroundColor: colors.bg.surface }]}>
<View style={styles.statsHeader}>
<Skeleton width="60%" height={14} />
<Skeleton width={24} height={24} borderRadius={12} />
</View>
<Skeleton width="50%" height={32} style={{ marginTop: SPACING[2] }} />
</View>
)
}
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',
},
})

View File

@@ -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<Workout[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(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<Workout | undefined>()
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(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<Workout[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(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<Trainer[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(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<Trainer | undefined>()
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(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<Collection[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(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<Collection | undefined>()
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(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<Program[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(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<Workout[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(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<Workout[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(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,
})
}