- Replace all Ionicons with native SF Symbols via expo-symbols SymbolView - Create reusable Icon wrapper component (src/shared/components/Icon.tsx) - Remove @expo/vector-icons and lucide-react dependencies - Refactor explore tab with filters, search, and category browsing - Add collections and programs data with Supabase integration - Add explore filter store and filter sheet - Update i18n strings (en, de, es, fr) for new explore features - Update test mocks and remove stale snapshots - Add user fitness level to user store and types
526 lines
17 KiB
TypeScript
526 lines
17 KiB
TypeScript
/**
|
|
* TabataFit Program Detail Screen
|
|
* Shows week progression and workout list
|
|
*/
|
|
|
|
import { View, StyleSheet, ScrollView, Pressable } from 'react-native'
|
|
import { useRouter, useLocalSearchParams } from 'expo-router'
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
import { LinearGradient } from 'expo-linear-gradient'
|
|
import { Icon } from '@/src/shared/components/Icon'
|
|
|
|
import { useMemo } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { useHaptics } from '@/src/shared/hooks'
|
|
import { useProgramStore } from '@/src/shared/stores'
|
|
import { PROGRAMS } from '@/src/shared/data/programs'
|
|
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 { ProgramId } from '@/src/shared/types'
|
|
|
|
const FONTS = {
|
|
LARGE_TITLE: 28,
|
|
TITLE: 24,
|
|
TITLE_2: 20,
|
|
HEADLINE: 17,
|
|
BODY: 16,
|
|
CAPTION: 13,
|
|
SMALL: 12,
|
|
}
|
|
|
|
export default function ProgramDetailScreen() {
|
|
const { id } = useLocalSearchParams<{ id: string }>()
|
|
const programId = id as ProgramId
|
|
const { t } = useTranslation('screens')
|
|
const insets = useSafeAreaInsets()
|
|
const router = useRouter()
|
|
const haptics = useHaptics()
|
|
const colors = useThemeColors()
|
|
const styles = useMemo(() => createStyles(colors), [colors])
|
|
|
|
const program = PROGRAMS[programId]
|
|
const selectProgram = useProgramStore((s) => s.selectProgram)
|
|
const progress = useProgramStore((s) => s.programsProgress[programId])
|
|
const isWeekUnlocked = useProgramStore((s) => s.isWeekUnlocked)
|
|
const getCurrentWorkout = useProgramStore((s) => s.getCurrentWorkout)
|
|
const completion = useProgramStore((s) => s.getProgramCompletion(programId))
|
|
|
|
if (!program) {
|
|
return (
|
|
<View style={[styles.container, { paddingTop: insets.top }]}>
|
|
<StyledText color={colors.text.primary}>Program not found</StyledText>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
const handleStartProgram = () => {
|
|
haptics.buttonTap()
|
|
selectProgram(programId)
|
|
const currentWorkout = getCurrentWorkout(programId)
|
|
if (currentWorkout) {
|
|
router.push(`/workout/${currentWorkout.id}`)
|
|
}
|
|
}
|
|
|
|
const handleWorkoutPress = (workoutId: string, weekNumber: number) => {
|
|
haptics.buttonTap()
|
|
router.push(`/workout/${workoutId}`)
|
|
}
|
|
|
|
return (
|
|
<View style={[styles.container, { paddingTop: insets.top }]}>
|
|
{/* Header */}
|
|
<View style={styles.header}>
|
|
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
|
<Icon name="arrow.left" size={24} color={colors.text.primary} />
|
|
</Pressable>
|
|
<StyledText size={FONTS.TITLE} weight="bold" color={colors.text.primary}>
|
|
{program.title}
|
|
</StyledText>
|
|
<View style={styles.placeholder} />
|
|
</View>
|
|
|
|
<ScrollView
|
|
style={styles.scrollView}
|
|
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 120 }]}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
{/* Program Overview */}
|
|
<View style={styles.overviewSection}>
|
|
<StyledText size={FONTS.BODY} color={colors.text.secondary} style={styles.description}>
|
|
{program.description}
|
|
</StyledText>
|
|
|
|
{/* Stats Row */}
|
|
<View style={styles.statsRow}>
|
|
<View style={styles.statBox}>
|
|
<StyledText size={FONTS.LARGE_TITLE} weight="bold" color={colors.text.primary}>
|
|
{program.durationWeeks}
|
|
</StyledText>
|
|
<StyledText size={FONTS.SMALL} color={colors.text.tertiary}>
|
|
{t('programs.weeks')}
|
|
</StyledText>
|
|
</View>
|
|
<View style={styles.statBox}>
|
|
<StyledText size={FONTS.LARGE_TITLE} weight="bold" color={colors.text.primary}>
|
|
{program.totalWorkouts}
|
|
</StyledText>
|
|
<StyledText size={FONTS.SMALL} color={colors.text.tertiary}>
|
|
{t('programs.workouts')}
|
|
</StyledText>
|
|
</View>
|
|
<View style={styles.statBox}>
|
|
<StyledText size={FONTS.LARGE_TITLE} weight="bold" color={colors.text.primary}>
|
|
4
|
|
</StyledText>
|
|
<StyledText size={FONTS.SMALL} color={colors.text.tertiary}>
|
|
{t('programs.minutes')}
|
|
</StyledText>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Equipment */}
|
|
<View style={styles.equipmentSection}>
|
|
<StyledText size={FONTS.CAPTION} weight="semibold" color={colors.text.secondary}>
|
|
{t('programs.equipment')}
|
|
</StyledText>
|
|
<View style={styles.equipmentList}>
|
|
{program.equipment.required.map((item) => (
|
|
<View key={item} style={styles.equipmentTag}>
|
|
<StyledText size={12} color={colors.text.primary}>
|
|
{item}
|
|
</StyledText>
|
|
</View>
|
|
))}
|
|
{program.equipment.optional.map((item) => (
|
|
<View key={item} style={[styles.equipmentTag, styles.optionalTag]}>
|
|
<StyledText size={12} color={colors.text.tertiary}>
|
|
{item} {t('programs.optional')}
|
|
</StyledText>
|
|
</View>
|
|
))}
|
|
</View>
|
|
</View>
|
|
|
|
{/* Focus Areas */}
|
|
<View style={styles.focusSection}>
|
|
<StyledText size={FONTS.CAPTION} weight="semibold" color={colors.text.secondary}>
|
|
{t('programs.focusAreas')}
|
|
</StyledText>
|
|
<View style={styles.focusList}>
|
|
{program.focusAreas.map((area) => (
|
|
<View key={area} style={styles.focusTag}>
|
|
<StyledText size={12} color={colors.text.primary}>
|
|
{area}
|
|
</StyledText>
|
|
</View>
|
|
))}
|
|
</View>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Progress Overview */}
|
|
{progress.completedWorkoutIds.length > 0 && (
|
|
<View style={styles.progressSection}>
|
|
<View style={styles.progressHeader}>
|
|
<StyledText size={FONTS.TITLE_2} weight="semibold" color={colors.text.primary}>
|
|
{t('programs.yourProgress')}
|
|
</StyledText>
|
|
<StyledText size={FONTS.BODY} weight="semibold" color={BRAND.PRIMARY}>
|
|
{completion}%
|
|
</StyledText>
|
|
</View>
|
|
<View style={styles.progressBarContainer}>
|
|
<View style={[styles.progressBar, { backgroundColor: colors.bg.surface }]}>
|
|
<View
|
|
style={[
|
|
styles.progressFill,
|
|
{
|
|
width: `${completion}%`,
|
|
backgroundColor: BRAND.PRIMARY,
|
|
}
|
|
]}
|
|
/>
|
|
</View>
|
|
</View>
|
|
<StyledText size={FONTS.CAPTION} color={colors.text.tertiary}>
|
|
{progress.completedWorkoutIds.length} {t('programs.of')} {program.totalWorkouts} {t('programs.workoutsComplete')}
|
|
</StyledText>
|
|
</View>
|
|
)}
|
|
|
|
{/* Weeks */}
|
|
<View style={styles.weeksSection}>
|
|
<StyledText size={FONTS.TITLE_2} weight="semibold" color={colors.text.primary} style={styles.weeksTitle}>
|
|
{t('programs.trainingPlan')}
|
|
</StyledText>
|
|
|
|
{program.weeks.map((week) => {
|
|
const isUnlocked = isWeekUnlocked(programId, week.weekNumber)
|
|
const isCurrentWeek = progress.currentWeek === week.weekNumber
|
|
const weekCompletion = week.workouts.filter(w =>
|
|
progress.completedWorkoutIds.includes(w.id)
|
|
).length
|
|
|
|
return (
|
|
<View key={week.weekNumber} style={styles.weekCard}>
|
|
{/* Week Header */}
|
|
<View style={styles.weekHeader}>
|
|
<View style={styles.weekTitleRow}>
|
|
<StyledText size={FONTS.HEADLINE} weight="semibold" color={colors.text.primary}>
|
|
{week.title}
|
|
</StyledText>
|
|
{!isUnlocked && (
|
|
<Icon name="lock.fill" size={16} color={colors.text.tertiary} />
|
|
)}
|
|
{isCurrentWeek && isUnlocked && (
|
|
<View style={styles.currentBadge}>
|
|
<StyledText size={11} weight="semibold" color="#FFFFFF">
|
|
{t('programs.current')}
|
|
</StyledText>
|
|
</View>
|
|
)}
|
|
</View>
|
|
<StyledText size={FONTS.CAPTION} color={colors.text.secondary}>
|
|
{week.description}
|
|
</StyledText>
|
|
{weekCompletion > 0 && (
|
|
<StyledText size={FONTS.SMALL} color={colors.text.tertiary} style={styles.weekProgress}>
|
|
{weekCompletion}/{week.workouts.length} {t('programs.complete')}
|
|
</StyledText>
|
|
)}
|
|
</View>
|
|
|
|
{/* Week Workouts */}
|
|
{isUnlocked && (
|
|
<View style={styles.workoutsList}>
|
|
{week.workouts.map((workout, index) => {
|
|
const isCompleted = progress.completedWorkoutIds.includes(workout.id)
|
|
const isLocked = !isCompleted && index > 0 &&
|
|
!progress.completedWorkoutIds.includes(week.workouts[index - 1].id) &&
|
|
week.weekNumber === progress.currentWeek
|
|
|
|
return (
|
|
<Pressable
|
|
key={workout.id}
|
|
style={[
|
|
styles.workoutItem,
|
|
isCompleted && styles.workoutCompleted,
|
|
isLocked && styles.workoutLocked,
|
|
]}
|
|
onPress={() => !isLocked && handleWorkoutPress(workout.id, week.weekNumber)}
|
|
disabled={isLocked}
|
|
>
|
|
<View style={styles.workoutNumber}>
|
|
{isCompleted ? (
|
|
<Icon name="checkmark.circle.fill" size={24} color={BRAND.SUCCESS} />
|
|
) : isLocked ? (
|
|
<Icon name="lock.fill" size={20} color={colors.text.tertiary} />
|
|
) : (
|
|
<StyledText size={14} weight="semibold" color={colors.text.primary}>
|
|
{index + 1}
|
|
</StyledText>
|
|
)}
|
|
</View>
|
|
<View style={styles.workoutInfo}>
|
|
<StyledText
|
|
size={14}
|
|
weight={isCompleted ? "medium" : "semibold"}
|
|
color={isLocked ? colors.text.tertiary : colors.text.primary}
|
|
style={isCompleted && styles.completedText}
|
|
>
|
|
{workout.title}
|
|
</StyledText>
|
|
<StyledText size={12} color={colors.text.tertiary}>
|
|
{workout.exercises.length} {t('programs.exercises')} • {workout.duration} {t('programs.min')}
|
|
</StyledText>
|
|
</View>
|
|
{!isLocked && !isCompleted && (
|
|
<Icon name="chevron.right" size={20} color={colors.text.tertiary} />
|
|
)}
|
|
</Pressable>
|
|
)
|
|
})}
|
|
</View>
|
|
)}
|
|
</View>
|
|
)
|
|
})}
|
|
</View>
|
|
</ScrollView>
|
|
|
|
{/* Bottom CTA */}
|
|
<View style={[styles.bottomBar, { paddingBottom: insets.bottom + SPACING[4] }]}>
|
|
<Pressable style={styles.ctaButton} onPress={handleStartProgram}>
|
|
<LinearGradient
|
|
colors={['#FF6B35', '#FF3B30']}
|
|
style={styles.ctaGradient}
|
|
>
|
|
<StyledText size={16} weight="bold" color="#FFFFFF">
|
|
{progress.completedWorkoutIds.length === 0
|
|
? t('programs.startProgram')
|
|
: progress.isProgramCompleted
|
|
? t('programs.restartProgram')
|
|
: t('programs.continueTraining')
|
|
}
|
|
</StyledText>
|
|
<Icon name="arrow.right" size={20} color="#FFFFFF" style={styles.ctaIcon} />
|
|
</LinearGradient>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
function createStyles(colors: ThemeColors) {
|
|
return StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: colors.bg.base,
|
|
},
|
|
scrollView: {
|
|
flex: 1,
|
|
},
|
|
scrollContent: {
|
|
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
|
},
|
|
|
|
// Header
|
|
header: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
|
paddingVertical: SPACING[3],
|
|
},
|
|
backButton: {
|
|
width: 40,
|
|
height: 40,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
placeholder: {
|
|
width: 40,
|
|
},
|
|
|
|
// Overview
|
|
overviewSection: {
|
|
marginTop: SPACING[2],
|
|
marginBottom: SPACING[6],
|
|
},
|
|
description: {
|
|
marginBottom: SPACING[5],
|
|
lineHeight: 24,
|
|
},
|
|
statsRow: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-around',
|
|
marginBottom: SPACING[5],
|
|
paddingVertical: SPACING[4],
|
|
backgroundColor: colors.bg.surface,
|
|
borderRadius: RADIUS.LG,
|
|
},
|
|
statBox: {
|
|
alignItems: 'center',
|
|
},
|
|
|
|
// Equipment
|
|
equipmentSection: {
|
|
marginBottom: SPACING[4],
|
|
},
|
|
equipmentList: {
|
|
flexDirection: 'row',
|
|
flexWrap: 'wrap',
|
|
gap: SPACING[2],
|
|
marginTop: SPACING[2],
|
|
},
|
|
equipmentTag: {
|
|
backgroundColor: colors.bg.surface,
|
|
paddingHorizontal: SPACING[3],
|
|
paddingVertical: SPACING[1],
|
|
borderRadius: RADIUS.FULL,
|
|
},
|
|
optionalTag: {
|
|
opacity: 0.7,
|
|
},
|
|
|
|
// Focus
|
|
focusSection: {
|
|
marginBottom: SPACING[4],
|
|
},
|
|
focusList: {
|
|
flexDirection: 'row',
|
|
flexWrap: 'wrap',
|
|
gap: SPACING[2],
|
|
marginTop: SPACING[2],
|
|
},
|
|
focusTag: {
|
|
backgroundColor: `${BRAND.PRIMARY}15`,
|
|
paddingHorizontal: SPACING[3],
|
|
paddingVertical: SPACING[1],
|
|
borderRadius: RADIUS.FULL,
|
|
},
|
|
|
|
// Progress
|
|
progressSection: {
|
|
backgroundColor: colors.bg.surface,
|
|
borderRadius: RADIUS.LG,
|
|
padding: SPACING[4],
|
|
marginBottom: SPACING[6],
|
|
},
|
|
progressHeader: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
marginBottom: SPACING[3],
|
|
},
|
|
progressBarContainer: {
|
|
marginBottom: SPACING[2],
|
|
},
|
|
progressBar: {
|
|
height: 8,
|
|
borderRadius: 4,
|
|
overflow: 'hidden',
|
|
},
|
|
progressFill: {
|
|
height: '100%',
|
|
borderRadius: 4,
|
|
},
|
|
|
|
// Weeks
|
|
weeksSection: {
|
|
marginBottom: SPACING[6],
|
|
},
|
|
weeksTitle: {
|
|
marginBottom: SPACING[4],
|
|
},
|
|
weekCard: {
|
|
backgroundColor: colors.bg.surface,
|
|
borderRadius: RADIUS.LG,
|
|
marginBottom: SPACING[4],
|
|
overflow: 'hidden',
|
|
},
|
|
weekHeader: {
|
|
padding: SPACING[4],
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: colors.border.glass,
|
|
},
|
|
weekTitleRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
marginBottom: SPACING[1],
|
|
},
|
|
currentBadge: {
|
|
backgroundColor: BRAND.PRIMARY,
|
|
paddingHorizontal: SPACING[2],
|
|
paddingVertical: 2,
|
|
borderRadius: RADIUS.SM,
|
|
},
|
|
weekProgress: {
|
|
marginTop: SPACING[2],
|
|
},
|
|
|
|
// Workouts List
|
|
workoutsList: {
|
|
padding: SPACING[2],
|
|
},
|
|
workoutItem: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingVertical: SPACING[3],
|
|
paddingHorizontal: SPACING[3],
|
|
borderRadius: RADIUS.MD,
|
|
},
|
|
workoutCompleted: {
|
|
opacity: 0.7,
|
|
},
|
|
workoutLocked: {
|
|
opacity: 0.5,
|
|
},
|
|
workoutNumber: {
|
|
width: 32,
|
|
height: 32,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginRight: SPACING[3],
|
|
},
|
|
workoutInfo: {
|
|
flex: 1,
|
|
},
|
|
completedText: {
|
|
textDecorationLine: 'line-through',
|
|
},
|
|
|
|
// Bottom Bar
|
|
bottomBar: {
|
|
position: 'absolute',
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
backgroundColor: colors.bg.base,
|
|
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
|
paddingTop: SPACING[3],
|
|
borderTopWidth: 1,
|
|
borderTopColor: colors.border.glass,
|
|
},
|
|
ctaButton: {
|
|
borderRadius: RADIUS.LG,
|
|
overflow: 'hidden',
|
|
},
|
|
ctaGradient: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
paddingVertical: SPACING[4],
|
|
},
|
|
ctaIcon: {
|
|
marginLeft: SPACING[2],
|
|
},
|
|
})
|
|
}
|