Replace workouts tab with explore tab connected to Supabase
- Rename workouts.tsx to explore.tsx with new functionality - Add horizontal scrolling collections section with gradient cards - Add featured workouts section - Implement filtering by category (All, Full Body, Upper Body, Lower Body, Core, Cardio) - Implement filtering by level (All Levels, Beginner, Intermediate, Advanced) - Implement filtering by equipment (All, No Equipment, Band, Dumbbells, Mat) - Add clear filters button when filters are active - Add loading states with ActivityIndicator - Add empty state for no results - Update tab label from "Workouts" to "Explore" - Add explore translations for en, fr, de, es
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* TabataFit Tab Layout
|
||||
* Native iOS tabs with liquid glass effect
|
||||
* 5 tabs: Home, Workouts, Activity, Browse, Profile
|
||||
* 4 tabs: Home, Workouts, Activity, Profile
|
||||
* Redirects to onboarding if not completed
|
||||
*/
|
||||
|
||||
@@ -28,9 +28,9 @@ export default function TabLayout() {
|
||||
<Label>{t('tabs.home')}</Label>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="workouts">
|
||||
<NativeTabs.Trigger name="explore">
|
||||
<Icon sf={{ default: 'flame', selected: 'flame.fill' }} />
|
||||
<Label>{t('tabs.workouts')}</Label>
|
||||
<Label>{t('tabs.explore')}</Label>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="activity">
|
||||
@@ -38,11 +38,6 @@ export default function TabLayout() {
|
||||
<Label>{t('tabs.activity')}</Label>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="browse">
|
||||
<Icon sf={{ default: 'square.grid.2x2', selected: 'square.grid.2x2.fill' }} />
|
||||
<Label>{t('tabs.browse')}</Label>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="profile">
|
||||
<Icon sf={{ default: 'person', selected: 'person.fill' }} />
|
||||
<Label>{t('tabs.profile')}</Label>
|
||||
|
||||
549
app/(tabs)/explore.tsx
Normal file
549
app/(tabs)/explore.tsx
Normal file
@@ -0,0 +1,549 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { View, StyleSheet, ScrollView, Pressable, Dimensions, ActivityIndicator, FlatList } from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { BlurView } from 'expo-blur'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import Ionicons from '@expo/vector-icons/Ionicons'
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useHaptics } from '@/src/shared/hooks'
|
||||
import { useWorkouts, useCollections, useFeaturedWorkouts, useTrainers } from '@/src/shared/hooks/useSupabaseData'
|
||||
import { StyledText } from '@/src/shared/components/StyledText'
|
||||
|
||||
import { useThemeColors, BRAND } from '@/src/shared/theme'
|
||||
import type { ThemeColors } from '@/src/shared/theme/types'
|
||||
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import type { Workout, WorkoutCategory, WorkoutLevel } from '@/src/shared/types'
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window')
|
||||
const CARD_WIDTH = (SCREEN_WIDTH - LAYOUT.SCREEN_PADDING * 2 - SPACING[3]) / 2
|
||||
const COLLECTION_CARD_WIDTH = SCREEN_WIDTH * 0.7
|
||||
|
||||
type FilterState = {
|
||||
category: WorkoutCategory | 'all'
|
||||
level: WorkoutLevel | 'all'
|
||||
equipment: string | 'all'
|
||||
}
|
||||
|
||||
const CATEGORY_ICONS: Record<WorkoutCategory | 'all', string> = {
|
||||
all: 'grid-outline',
|
||||
'full-body': 'body-outline',
|
||||
'upper-body': 'barbell-outline',
|
||||
'lower-body': 'footsteps-outline',
|
||||
core: 'fitness-outline',
|
||||
cardio: 'heart-outline',
|
||||
}
|
||||
|
||||
const CATEGORY_COLORS: Record<WorkoutCategory | 'all', string> = {
|
||||
all: BRAND.PRIMARY,
|
||||
'full-body': '#5AC8FA',
|
||||
'upper-body': '#FF6B35',
|
||||
'lower-body': '#30D158',
|
||||
core: '#FF9500',
|
||||
cardio: '#FF3B30',
|
||||
}
|
||||
|
||||
const CATEGORY_TRANSLATION_KEYS: Record<WorkoutCategory | 'all', string> = {
|
||||
all: 'all',
|
||||
'full-body': 'fullBody',
|
||||
'upper-body': 'upperBody',
|
||||
'lower-body': 'lowerBody',
|
||||
core: 'core',
|
||||
cardio: 'cardio',
|
||||
}
|
||||
|
||||
function CollectionCard({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
gradient,
|
||||
workoutCount,
|
||||
onPress,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
icon: string
|
||||
gradient?: [string, string]
|
||||
workoutCount: number
|
||||
onPress: () => void
|
||||
}) {
|
||||
const colors = useThemeColors()
|
||||
const haptics = useHaptics()
|
||||
const gradientColors = gradient || [BRAND.PRIMARY, '#FF3B30']
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={styles.collectionCard}
|
||||
onPress={() => {
|
||||
haptics.buttonTap()
|
||||
onPress()
|
||||
}}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={gradientColors}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<View style={styles.collectionOverlay}>
|
||||
<View style={styles.collectionIcon}>
|
||||
<Ionicons name={icon as any} size={28} color="#FFFFFF" />
|
||||
</View>
|
||||
<StyledText size={18} weight="bold" color="#FFFFFF" style={{ marginTop: SPACING[3] }}>
|
||||
{title}
|
||||
</StyledText>
|
||||
<StyledText size={13} color="rgba(255,255,255,0.8)" numberOfLines={2} style={{ marginTop: SPACING[1] }}>
|
||||
{description}
|
||||
</StyledText>
|
||||
<StyledText size={12} color="rgba(255,255,255,0.6)" style={{ marginTop: SPACING[2] }}>
|
||||
{workoutCount} workouts
|
||||
</StyledText>
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkoutCard({
|
||||
workout,
|
||||
onPress,
|
||||
}: {
|
||||
workout: Workout
|
||||
onPress: () => void
|
||||
}) {
|
||||
const colors = useThemeColors()
|
||||
const categoryColor = CATEGORY_COLORS[workout.category]
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={[styles.workoutCard, { borderColor: colors.border.glassLight }]}
|
||||
onPress={onPress}
|
||||
>
|
||||
<BlurView intensity={colors.glass.blurMedium} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
|
||||
<LinearGradient
|
||||
colors={[categoryColor + '20', 'transparent']}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
|
||||
<View style={[styles.durationBadge, { backgroundColor: categoryColor + '30' }]}>
|
||||
<Ionicons name="time-outline" size={10} color={categoryColor} />
|
||||
<StyledText size={11} weight="semibold" color={categoryColor} style={{ marginLeft: 3 }}>
|
||||
{workout.duration} min
|
||||
</StyledText>
|
||||
</View>
|
||||
|
||||
<View style={styles.playArea}>
|
||||
<View style={[styles.playCircle, { backgroundColor: categoryColor + '20' }]}>
|
||||
<Ionicons name="play" size={18} color={categoryColor} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.workoutInfo}>
|
||||
<StyledText size={14} weight="semibold" color={colors.text.primary} numberOfLines={2}>
|
||||
{workout.title}
|
||||
</StyledText>
|
||||
<StyledText size={11} color={colors.text.tertiary} style={{ marginTop: 2 }}>
|
||||
{workout.level} · {workout.calories} cal
|
||||
</StyledText>
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterPill({
|
||||
label,
|
||||
isSelected,
|
||||
color,
|
||||
onPress,
|
||||
}: {
|
||||
label: string
|
||||
isSelected: boolean
|
||||
color: string
|
||||
onPress: () => void
|
||||
}) {
|
||||
const colors = useThemeColors()
|
||||
const haptics = useHaptics()
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={[
|
||||
styles.filterPill,
|
||||
{
|
||||
backgroundColor: isSelected ? color + '20' : colors.glass.base.backgroundColor,
|
||||
borderColor: isSelected ? color : colors.border.glass,
|
||||
},
|
||||
]}
|
||||
onPress={() => {
|
||||
haptics.selection()
|
||||
onPress()
|
||||
}}
|
||||
>
|
||||
<StyledText
|
||||
size={13}
|
||||
weight={isSelected ? 'semibold' : 'medium'}
|
||||
color={isSelected ? color : colors.text.secondary}
|
||||
>
|
||||
{label}
|
||||
</StyledText>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadingSkeleton() {
|
||||
const colors = useThemeColors()
|
||||
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={BRAND.PRIMARY} />
|
||||
<StyledText size={15} color={colors.text.secondary} style={{ marginTop: SPACING[3] }}>
|
||||
Loading workouts...
|
||||
</StyledText>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ExploreScreen() {
|
||||
const { t } = useTranslation()
|
||||
const insets = useSafeAreaInsets()
|
||||
const router = useRouter()
|
||||
const haptics = useHaptics()
|
||||
const colors = useThemeColors()
|
||||
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
category: 'all',
|
||||
level: 'all',
|
||||
equipment: 'all',
|
||||
})
|
||||
|
||||
const { data: workoutsData, isLoading: workoutsLoading } = useWorkouts()
|
||||
const { data: collectionsData, isLoading: collectionsLoading } = useCollections()
|
||||
const { data: featuredData } = useFeaturedWorkouts()
|
||||
|
||||
const workouts = workoutsData || []
|
||||
const collections = collectionsData || []
|
||||
const featured = featuredData || []
|
||||
|
||||
const filteredWorkouts = useMemo(() => {
|
||||
return workouts.filter((workout) => {
|
||||
if (filters.category !== 'all' && workout.category !== filters.category) return false
|
||||
if (filters.level !== 'all' && workout.level !== filters.level) return false
|
||||
if (filters.equipment !== 'all') {
|
||||
if (filters.equipment === 'none') {
|
||||
return workout.equipment.length === 0
|
||||
}
|
||||
return workout.equipment.some((e) => e.toLowerCase().includes(filters.equipment.toLowerCase()))
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [workouts, filters])
|
||||
|
||||
const categories: (WorkoutCategory | 'all')[] = ['all', 'full-body', 'upper-body', 'lower-body', 'core', 'cardio']
|
||||
const levels: (WorkoutLevel | 'all')[] = ['all', 'Beginner', 'Intermediate', 'Advanced']
|
||||
const equipmentOptions = ['all', 'none', 'band', 'dumbbells', 'mat']
|
||||
|
||||
const handleWorkoutPress = (id: string) => {
|
||||
haptics.buttonTap()
|
||||
router.push(`/workout/${id}`)
|
||||
}
|
||||
|
||||
const handleCollectionPress = (id: string) => {
|
||||
haptics.buttonTap()
|
||||
router.push(`/collection/${id}` as any)
|
||||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
haptics.selection()
|
||||
setFilters({ category: 'all', level: 'all', equipment: 'all' })
|
||||
}
|
||||
|
||||
const hasActiveFilters = filters.category !== 'all' || filters.level !== 'all' || filters.equipment !== 'all'
|
||||
|
||||
if (workoutsLoading || collectionsLoading) {
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.bg.base, paddingTop: insets.top }]}>
|
||||
<LoadingSkeleton />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.bg.base, paddingTop: insets.top }]}>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 100 }]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<StyledText size={34} weight="bold" color={colors.text.primary}>
|
||||
{t('screens:explore.title')}
|
||||
</StyledText>
|
||||
<StyledText size={15} color={colors.text.tertiary}>
|
||||
{t('screens:explore.workoutsCount', { count: workouts.length })}
|
||||
</StyledText>
|
||||
</View>
|
||||
|
||||
{collections.length > 0 && (
|
||||
<View style={styles.collectionsSection}>
|
||||
<StyledText size={22} weight="bold" color={colors.text.primary}>
|
||||
{t('screens:explore.collections')}
|
||||
</StyledText>
|
||||
<FlatList
|
||||
horizontal
|
||||
data={collections}
|
||||
keyExtractor={(item) => item.id}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.collectionsList}
|
||||
renderItem={({ item }) => (
|
||||
<CollectionCard
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
icon={item.icon}
|
||||
gradient={item.gradient}
|
||||
workoutCount={item.workoutIds.length}
|
||||
onPress={() => handleCollectionPress(item.id)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{featured.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<StyledText size={22} weight="bold" color={colors.text.primary}>
|
||||
{t('screens:explore.featured')}
|
||||
</StyledText>
|
||||
<View style={styles.workoutsGrid}>
|
||||
{featured.slice(0, 4).map((workout) => (
|
||||
<WorkoutCard
|
||||
key={workout.id}
|
||||
workout={workout}
|
||||
onPress={() => handleWorkoutPress(workout.id)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.filtersSection}>
|
||||
<View style={styles.filterHeader}>
|
||||
<StyledText size={22} weight="bold" color={colors.text.primary}>
|
||||
{t('screens:explore.allWorkouts')}
|
||||
</StyledText>
|
||||
{hasActiveFilters && (
|
||||
<Pressable onPress={clearFilters}>
|
||||
<StyledText size={14} weight="medium" color={BRAND.PRIMARY}>
|
||||
{t('screens:explore.clearFilters')}
|
||||
</StyledText>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.filterRow}>
|
||||
<StyledText size={12} weight="semibold" color={colors.text.tertiary}>
|
||||
{t('screens:explore.filterCategory')}
|
||||
</StyledText>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.filterScroll}>
|
||||
{categories.map((cat) => (
|
||||
<FilterPill
|
||||
key={cat}
|
||||
label={t(`common:categories.${CATEGORY_TRANSLATION_KEYS[cat]}`)}
|
||||
isSelected={filters.category === cat}
|
||||
color={CATEGORY_COLORS[cat]}
|
||||
onPress={() => setFilters((f) => ({ ...f, category: cat }))}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
<View style={[styles.filterRow, { marginTop: SPACING[3] }]}>
|
||||
<StyledText size={12} weight="semibold" color={colors.text.tertiary}>
|
||||
{t('screens:explore.filterLevel')}
|
||||
</StyledText>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.filterScroll}>
|
||||
{levels.map((level) => (
|
||||
<FilterPill
|
||||
key={level}
|
||||
label={level === 'all' ? 'All Levels' : level}
|
||||
isSelected={filters.level === level}
|
||||
color={BRAND.PRIMARY}
|
||||
onPress={() => setFilters((f) => ({ ...f, level }))}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
<View style={[styles.filterRow, { marginTop: SPACING[3] }]}>
|
||||
<StyledText size={12} weight="semibold" color={colors.text.tertiary}>
|
||||
{t('screens:explore.filterEquipment')}
|
||||
</StyledText>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.filterScroll}>
|
||||
{equipmentOptions.map((eq) => (
|
||||
<FilterPill
|
||||
key={eq}
|
||||
label={eq === 'all' ? 'All Equipment' : t(`screens:explore.equipmentOptions.${eq}`)}
|
||||
isSelected={filters.equipment === eq}
|
||||
color={BRAND.PRIMARY}
|
||||
onPress={() => setFilters((f) => ({ ...f, equipment: eq }))}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{filteredWorkouts.length > 0 ? (
|
||||
<View style={styles.workoutsGrid}>
|
||||
{filteredWorkouts.map((workout) => (
|
||||
<WorkoutCard
|
||||
key={workout.id}
|
||||
workout={workout}
|
||||
onPress={() => handleWorkoutPress(workout.id)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.noResults}>
|
||||
<Ionicons name="search-outline" size={48} color={colors.text.tertiary} />
|
||||
<StyledText size={17} weight="semibold" color={colors.text.secondary} style={{ marginTop: SPACING[3] }}>
|
||||
{t('screens:explore.noResults')}
|
||||
</StyledText>
|
||||
<StyledText size={14} color={colors.text.tertiary} style={{ marginTop: SPACING[1] }}>
|
||||
{t('screens:explore.tryAdjustingFilters')}
|
||||
</StyledText>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
},
|
||||
header: {
|
||||
marginBottom: SPACING[6],
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
|
||||
collectionsSection: {
|
||||
marginBottom: SPACING[6],
|
||||
},
|
||||
collectionsList: {
|
||||
marginTop: SPACING[4],
|
||||
paddingRight: LAYOUT.SCREEN_PADDING,
|
||||
},
|
||||
collectionCard: {
|
||||
width: COLLECTION_CARD_WIDTH,
|
||||
height: 180,
|
||||
borderRadius: RADIUS.XL,
|
||||
marginRight: SPACING[3],
|
||||
overflow: 'hidden',
|
||||
},
|
||||
collectionOverlay: {
|
||||
flex: 1,
|
||||
padding: SPACING[5],
|
||||
backgroundColor: 'rgba(0,0,0,0.3)',
|
||||
},
|
||||
collectionIcon: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
|
||||
section: {
|
||||
marginBottom: SPACING[6],
|
||||
},
|
||||
|
||||
filtersSection: {
|
||||
marginBottom: SPACING[6],
|
||||
},
|
||||
filterHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: SPACING[4],
|
||||
},
|
||||
filterRow: {
|
||||
marginBottom: SPACING[2],
|
||||
},
|
||||
filterScroll: {
|
||||
marginHorizontal: -LAYOUT.SCREEN_PADDING,
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
},
|
||||
filterPill: {
|
||||
paddingHorizontal: SPACING[4],
|
||||
paddingVertical: SPACING[2],
|
||||
borderRadius: RADIUS.FULL,
|
||||
marginRight: SPACING[2],
|
||||
borderWidth: 1,
|
||||
borderCurve: 'continuous',
|
||||
},
|
||||
|
||||
workoutsGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: SPACING[3],
|
||||
},
|
||||
workoutCard: {
|
||||
width: CARD_WIDTH,
|
||||
height: 200,
|
||||
borderRadius: RADIUS.GLASS_CARD,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderCurve: 'continuous',
|
||||
},
|
||||
durationBadge: {
|
||||
position: 'absolute',
|
||||
top: SPACING[3],
|
||||
right: SPACING[3],
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: SPACING[2],
|
||||
paddingVertical: 3,
|
||||
borderRadius: RADIUS.SM,
|
||||
},
|
||||
playArea: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 70,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
playCircle: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
workoutInfo: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: SPACING[3],
|
||||
paddingTop: SPACING[2],
|
||||
},
|
||||
|
||||
noResults: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: SPACING[10],
|
||||
},
|
||||
})
|
||||
@@ -1,354 +0,0 @@
|
||||
/**
|
||||
* TabataFit Workouts Screen
|
||||
* Premium workout browser — scrollable category pills, trainers, workout grid
|
||||
*/
|
||||
|
||||
import { useState, useRef, useMemo } from 'react'
|
||||
import { View, StyleSheet, ScrollView, Pressable, Dimensions, Animated } from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import { BlurView } from 'expo-blur'
|
||||
import Ionicons from '@expo/vector-icons/Ionicons'
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useHaptics } from '@/src/shared/hooks'
|
||||
import { WORKOUTS } from '@/src/shared/data'
|
||||
import { useTranslatedCategories, useTranslatedWorkouts } from '@/src/shared/data/useTranslatedData'
|
||||
import { StyledText } from '@/src/shared/components/StyledText'
|
||||
|
||||
import { useThemeColors, BRAND, GRADIENTS } from '@/src/shared/theme'
|
||||
import type { ThemeColors } from '@/src/shared/theme/types'
|
||||
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window')
|
||||
const CARD_WIDTH = (SCREEN_WIDTH - LAYOUT.SCREEN_PADDING * 2 - SPACING[3]) / 2
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// CATEGORY PILL
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function CategoryPill({
|
||||
label,
|
||||
selected,
|
||||
onPress,
|
||||
}: {
|
||||
label: string
|
||||
selected: boolean
|
||||
onPress: () => void
|
||||
}) {
|
||||
const colors = useThemeColors()
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
return (
|
||||
<Pressable
|
||||
style={[styles.pill, selected && styles.pillSelected]}
|
||||
onPress={onPress}
|
||||
>
|
||||
{selected && (
|
||||
<LinearGradient
|
||||
colors={GRADIENTS.CTA}
|
||||
style={[StyleSheet.absoluteFill, { borderRadius: 20 }]}
|
||||
/>
|
||||
)}
|
||||
<StyledText
|
||||
size={14}
|
||||
weight={selected ? 'semibold' : 'regular'}
|
||||
color={selected ? '#FFFFFF' : colors.text.tertiary}
|
||||
>
|
||||
{label}
|
||||
</StyledText>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// WORKOUT CARD
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function WorkoutCard({
|
||||
title,
|
||||
duration,
|
||||
level,
|
||||
levelLabel,
|
||||
onPress,
|
||||
}: {
|
||||
title: string
|
||||
duration: number
|
||||
level: string
|
||||
levelLabel: string
|
||||
onPress: () => void
|
||||
}) {
|
||||
const colors = useThemeColors()
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
return (
|
||||
<Pressable style={styles.workoutCard} onPress={onPress}>
|
||||
<BlurView intensity={colors.glass.blurMedium} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
|
||||
|
||||
{/* Subtle gradient accent at top */}
|
||||
<LinearGradient
|
||||
colors={[levelColor(level, colors) + '30', 'transparent']}
|
||||
style={styles.cardGradient}
|
||||
/>
|
||||
|
||||
{/* Duration badge */}
|
||||
<View style={styles.durationBadge}>
|
||||
<Ionicons name="time-outline" size={10} color={colors.text.secondary} />
|
||||
<StyledText size={11} weight="semibold" color={colors.text.secondary} style={{ marginLeft: 3 }}>
|
||||
{duration + ' min'}
|
||||
</StyledText>
|
||||
</View>
|
||||
|
||||
{/* Play button */}
|
||||
<View style={styles.playArea}>
|
||||
<View style={styles.playCircle}>
|
||||
<Ionicons name="play" size={18} color={colors.text.primary} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Info */}
|
||||
<View style={styles.workoutInfo}>
|
||||
<StyledText size={14} weight="semibold" color={colors.text.primary} numberOfLines={2}>
|
||||
{title}
|
||||
</StyledText>
|
||||
<View style={styles.levelRow}>
|
||||
<View style={[styles.levelDot, { backgroundColor: levelColor(level, colors) }]} />
|
||||
<StyledText size={11} color={colors.text.tertiary}>
|
||||
{levelLabel}
|
||||
</StyledText>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
function levelColor(level: string, colors: ThemeColors): string {
|
||||
switch (level.toLowerCase()) {
|
||||
case 'beginner': return BRAND.SUCCESS
|
||||
case 'intermediate': return BRAND.SECONDARY
|
||||
case 'advanced': return BRAND.DANGER
|
||||
default: return colors.text.tertiary
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// MAIN SCREEN
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export default function WorkoutsScreen() {
|
||||
const { t } = useTranslation()
|
||||
const insets = useSafeAreaInsets()
|
||||
const router = useRouter()
|
||||
const haptics = useHaptics()
|
||||
const colors = useThemeColors()
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
const [selectedCategory, setSelectedCategory] = useState('all')
|
||||
const categories = useTranslatedCategories()
|
||||
|
||||
const filteredWorkouts = selectedCategory === 'all'
|
||||
? WORKOUTS
|
||||
: WORKOUTS.filter(w => w.category === selectedCategory)
|
||||
|
||||
const translatedFiltered = useTranslatedWorkouts(filteredWorkouts)
|
||||
|
||||
const handleWorkoutPress = (id: string) => {
|
||||
haptics.buttonTap()
|
||||
router.push(`/workout/${id}`)
|
||||
}
|
||||
|
||||
const selectedLabel = categories.find(c => c.id === selectedCategory)?.label ?? t('screens:workouts.allWorkouts')
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 100 }]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<StyledText size={34} weight="bold" color={colors.text.primary}>
|
||||
{t('screens:workouts.title')}
|
||||
</StyledText>
|
||||
<StyledText size={15} color={colors.text.tertiary}>
|
||||
{t('screens:workouts.available', { count: WORKOUTS.length })}
|
||||
</StyledText>
|
||||
</View>
|
||||
|
||||
{/* Category Pills — horizontal scroll, no truncation */}
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.pillsRow}
|
||||
style={styles.pillsScroll}
|
||||
>
|
||||
{categories.map((cat) => (
|
||||
<CategoryPill
|
||||
key={cat.id}
|
||||
label={cat.label}
|
||||
selected={selectedCategory === cat.id}
|
||||
onPress={() => {
|
||||
haptics.selection()
|
||||
setSelectedCategory(cat.id)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
{/* Workouts Grid */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<StyledText size={20} weight="semibold" color={colors.text.primary}>
|
||||
{selectedCategory === 'all' ? t('screens:workouts.allWorkouts') : selectedLabel}
|
||||
</StyledText>
|
||||
{selectedCategory !== 'all' && (
|
||||
<Pressable onPress={() => { haptics.buttonTap(); router.push(`/workout/category/${selectedCategory}`) }}>
|
||||
<StyledText size={14} color={BRAND.PRIMARY} weight="medium">{t('seeAll')}</StyledText>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.workoutsGrid}>
|
||||
{translatedFiltered.map((workout) => (
|
||||
<WorkoutCard
|
||||
key={workout.id}
|
||||
title={workout.title}
|
||||
duration={workout.duration}
|
||||
level={workout.level}
|
||||
levelLabel={t(`levels.${workout.level.toLowerCase()}`)}
|
||||
onPress={() => handleWorkoutPress(workout.id)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// STYLES
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function createStyles(colors: ThemeColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bg.base,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
},
|
||||
|
||||
// Header
|
||||
header: {
|
||||
marginBottom: SPACING[4],
|
||||
},
|
||||
|
||||
// Pills
|
||||
pillsScroll: {
|
||||
marginHorizontal: -LAYOUT.SCREEN_PADDING,
|
||||
marginBottom: SPACING[6],
|
||||
},
|
||||
pillsRow: {
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
gap: SPACING[2],
|
||||
},
|
||||
pill: {
|
||||
paddingHorizontal: SPACING[4],
|
||||
paddingVertical: SPACING[2],
|
||||
borderRadius: 20,
|
||||
backgroundColor: colors.bg.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border.glassLight,
|
||||
},
|
||||
pillSelected: {
|
||||
borderColor: BRAND.PRIMARY,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
|
||||
// Section
|
||||
section: {
|
||||
marginBottom: SPACING[6],
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: SPACING[4],
|
||||
},
|
||||
|
||||
// Workouts Grid
|
||||
workoutsGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: SPACING[3],
|
||||
},
|
||||
workoutCard: {
|
||||
width: CARD_WIDTH,
|
||||
height: 190,
|
||||
borderRadius: RADIUS.GLASS_CARD,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.bg.overlay2,
|
||||
},
|
||||
cardGradient: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 80,
|
||||
},
|
||||
durationBadge: {
|
||||
position: 'absolute',
|
||||
top: SPACING[3],
|
||||
right: SPACING[3],
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
paddingHorizontal: SPACING[2],
|
||||
paddingVertical: 3,
|
||||
borderRadius: RADIUS.SM,
|
||||
},
|
||||
playArea: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 64,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
playCircle: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: colors.border.glassStrong,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.25)',
|
||||
},
|
||||
workoutInfo: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: SPACING[3],
|
||||
paddingTop: SPACING[2],
|
||||
},
|
||||
levelRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: 3,
|
||||
gap: 5,
|
||||
},
|
||||
levelDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user