Files
tabatago/app/collection/[id].tsx
Millian Lamiaux f11eb6b9ae fix: add missing Workout fields to program workouts and guard against undefined level
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.
2026-03-25 23:28:47 +01:00

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],
},
})
}