Program workouts built by buildProgramWorkouts() were missing level, rounds, calories, and other Workout-interface fields, causing workout.level.toLowerCase() to crash on the detail, collection, and category screens. Added derived defaults (level from week number, category from program id, standard Tabata timings) and defensive fallbacks with ?? 'Beginner' at all call sites. Also fixed a potential division-by-zero when exercises array is empty.
251 lines
7.9 KiB
TypeScript
251 lines
7.9 KiB
TypeScript
/**
|
|
* TabataFit Collection Detail Screen
|
|
* Shows collection info + list of workouts in that collection
|
|
*/
|
|
|
|
import { useMemo } from 'react'
|
|
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 { useTranslation } from 'react-i18next'
|
|
|
|
import { useHaptics } from '@/src/shared/hooks'
|
|
import { useCollection } from '@/src/shared/hooks/useSupabaseData'
|
|
import { getWorkoutById } from '@/src/shared/data'
|
|
import { useTranslatedWorkouts } from '@/src/shared/data/useTranslatedData'
|
|
import { StyledText } from '@/src/shared/components/StyledText'
|
|
import { track } from '@/src/shared/services/analytics'
|
|
|
|
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'
|
|
|
|
export default function CollectionDetailScreen() {
|
|
const insets = useSafeAreaInsets()
|
|
const router = useRouter()
|
|
const haptics = useHaptics()
|
|
const { t } = useTranslation()
|
|
const { id } = useLocalSearchParams<{ id: string }>()
|
|
|
|
const colors = useThemeColors()
|
|
const styles = useMemo(() => createStyles(colors), [colors])
|
|
|
|
const { data: collection, isLoading } = useCollection(id)
|
|
|
|
// Resolve workouts from collection's workoutIds
|
|
const rawWorkouts = useMemo(() => {
|
|
if (!collection) return []
|
|
return collection.workoutIds
|
|
.map((wId) => getWorkoutById(wId))
|
|
.filter(Boolean) as NonNullable<ReturnType<typeof getWorkoutById>>[]
|
|
}, [collection])
|
|
|
|
const workouts = useTranslatedWorkouts(rawWorkouts)
|
|
|
|
const handleBack = () => {
|
|
haptics.selection()
|
|
router.back()
|
|
}
|
|
|
|
const handleWorkoutPress = (workoutId: string) => {
|
|
haptics.buttonTap()
|
|
track('collection_workout_tapped', {
|
|
collection_id: id,
|
|
workout_id: workoutId,
|
|
})
|
|
router.push(`/workout/${workoutId}`)
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<View style={[styles.container, styles.centered, { paddingTop: insets.top }]}>
|
|
<StyledText size={17} color={colors.text.tertiary}>Loading...</StyledText>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
if (!collection) {
|
|
return (
|
|
<View style={[styles.container, styles.centered, { paddingTop: insets.top }]}>
|
|
<Icon name="folder" size={48} color={colors.text.tertiary} />
|
|
<StyledText size={17} color={colors.text.tertiary} style={{ marginTop: SPACING[3] }}>
|
|
Collection not found
|
|
</StyledText>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<View testID="collection-detail-screen" style={[styles.container, { paddingTop: insets.top }]}>
|
|
{/* Header */}
|
|
<View style={styles.header}>
|
|
<Pressable testID="collection-back-button" onPress={handleBack} style={styles.backButton}>
|
|
<Icon name="chevron.left" size={24} color={colors.text.primary} />
|
|
</Pressable>
|
|
<StyledText size={22} weight="bold" color={colors.text.primary} numberOfLines={1} style={{ flex: 1, textAlign: 'center' }}>
|
|
{collection.title}
|
|
</StyledText>
|
|
<View style={styles.backButton} />
|
|
</View>
|
|
|
|
<ScrollView
|
|
style={styles.scrollView}
|
|
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 100 }]}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
{/* Hero Card */}
|
|
<View testID="collection-hero" style={styles.heroCard}>
|
|
<LinearGradient
|
|
colors={collection.gradient ?? [BRAND.PRIMARY, '#FF3B30']}
|
|
start={{ x: 0, y: 0 }}
|
|
end={{ x: 1, y: 1 }}
|
|
style={StyleSheet.absoluteFill}
|
|
/>
|
|
<View style={styles.heroContent}>
|
|
<StyledText size={48} color="#FFFFFF" style={styles.heroIcon}>
|
|
{collection.icon}
|
|
</StyledText>
|
|
<StyledText size={28} weight="bold" color="#FFFFFF">
|
|
{collection.title}
|
|
</StyledText>
|
|
<StyledText size={15} color="rgba(255,255,255,0.8)" style={{ marginTop: SPACING[1] }}>
|
|
{collection.description}
|
|
</StyledText>
|
|
<StyledText size={13} weight="semibold" color="rgba(255,255,255,0.6)" style={{ marginTop: SPACING[2] }}>
|
|
{t('plurals.workout', { count: workouts.length })}
|
|
</StyledText>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Workout List */}
|
|
<StyledText
|
|
size={20}
|
|
weight="bold"
|
|
color={colors.text.primary}
|
|
style={{ marginTop: SPACING[6], marginBottom: SPACING[3] }}
|
|
>
|
|
{t('screens:explore.workouts')}
|
|
</StyledText>
|
|
|
|
{workouts.map((workout) => (
|
|
<Pressable
|
|
key={workout.id}
|
|
testID={`collection-workout-${workout.id}`}
|
|
style={styles.workoutCard}
|
|
onPress={() => handleWorkoutPress(workout.id)}
|
|
>
|
|
<View style={[styles.workoutAvatar, { backgroundColor: BRAND.PRIMARY }]}>
|
|
<Icon name="flame.fill" size={20} color="#FFFFFF" />
|
|
</View>
|
|
<View style={styles.workoutInfo}>
|
|
<StyledText size={17} weight="semibold" color={colors.text.primary}>
|
|
{workout.title}
|
|
</StyledText>
|
|
<StyledText size={13} color={colors.text.tertiary}>
|
|
{t('durationLevel', {
|
|
duration: workout.duration,
|
|
level: t(`levels.${(workout.level ?? 'Beginner').toLowerCase()}`),
|
|
})}
|
|
</StyledText>
|
|
</View>
|
|
<View style={styles.workoutMeta}>
|
|
<StyledText size={13} color={BRAND.PRIMARY}>
|
|
{t('units.calUnit', { count: workout.calories })}
|
|
</StyledText>
|
|
<Icon name="chevron.right" size={16} color={colors.text.tertiary} />
|
|
</View>
|
|
</Pressable>
|
|
))}
|
|
|
|
{workouts.length === 0 && (
|
|
<View style={styles.emptyState}>
|
|
<Icon name="dumbbell" size={48} color={colors.text.tertiary} />
|
|
<StyledText size={17} color={colors.text.tertiary} style={{ marginTop: SPACING[3] }}>
|
|
No workouts in this collection
|
|
</StyledText>
|
|
</View>
|
|
)}
|
|
</ScrollView>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
function createStyles(colors: ThemeColors) {
|
|
return StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: colors.bg.base,
|
|
},
|
|
centered: {
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
header: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
|
paddingVertical: SPACING[3],
|
|
},
|
|
backButton: {
|
|
width: 44,
|
|
height: 44,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
heroCard: {
|
|
height: 200,
|
|
borderRadius: RADIUS.XL,
|
|
overflow: 'hidden',
|
|
...colors.shadow.lg,
|
|
},
|
|
heroContent: {
|
|
flex: 1,
|
|
padding: SPACING[5],
|
|
justifyContent: 'flex-end',
|
|
},
|
|
heroIcon: {
|
|
marginBottom: SPACING[2],
|
|
},
|
|
scrollView: {
|
|
flex: 1,
|
|
},
|
|
scrollContent: {
|
|
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
|
},
|
|
workoutCard: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingVertical: SPACING[3],
|
|
paddingHorizontal: SPACING[4],
|
|
backgroundColor: colors.bg.surface,
|
|
borderRadius: RADIUS.LG,
|
|
marginBottom: SPACING[2],
|
|
gap: SPACING[3],
|
|
},
|
|
workoutAvatar: {
|
|
width: 44,
|
|
height: 44,
|
|
borderRadius: 22,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
workoutInfo: {
|
|
flex: 1,
|
|
gap: 2,
|
|
},
|
|
workoutMeta: {
|
|
alignItems: 'flex-end',
|
|
gap: 4,
|
|
},
|
|
emptyState: {
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
paddingVertical: SPACING[12],
|
|
},
|
|
})
|
|
}
|