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.
This commit is contained in:
Millian Lamiaux
2026-03-25 23:28:47 +01:00
parent 4fa8be600c
commit f11eb6b9ae
4 changed files with 78 additions and 59 deletions

View File

@@ -3,18 +3,17 @@
* Clean modal with workout info
*/
import { useState, useEffect, useMemo } from 'react'
import { useEffect, useMemo } from 'react'
import { View, Text as RNText, StyleSheet, ScrollView, Pressable } from 'react-native'
import { useRouter, useLocalSearchParams } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { BlurView } from 'expo-blur'
import Ionicons from '@expo/vector-icons/Ionicons'
import { Icon } from '@/src/shared/components/Icon'
import { useTranslation } from 'react-i18next'
import { Host, Button, HStack } from '@expo/ui/swift-ui'
import { glassEffect, padding } from '@expo/ui/swift-ui/modifiers'
import { useHaptics } from '@/src/shared/hooks'
import { usePurchases } from '@/src/shared/hooks/usePurchases'
import { useUserStore } from '@/src/shared/stores'
import { track } from '@/src/shared/services/analytics'
import { canAccessWorkout } from '@/src/shared/services/access'
import { getWorkoutById } from '@/src/shared/data'
@@ -37,7 +36,8 @@ export default function WorkoutDetailScreen() {
const haptics = useHaptics()
const { t } = useTranslation()
const { id } = useLocalSearchParams<{ id: string }>()
const [isSaved, setIsSaved] = useState(false)
const savedWorkouts = useUserStore((s) => s.savedWorkouts)
const toggleSavedWorkout = useUserStore((s) => s.toggleSavedWorkout)
const { isPremium } = usePurchases()
const colors = useThemeColors()
@@ -66,6 +66,7 @@ export default function WorkoutDetailScreen() {
)
}
const isSaved = savedWorkouts.includes(workout.id.toString())
const isLocked = !canAccessWorkout(workout.id, isPremium)
const handleStartWorkout = () => {
@@ -84,10 +85,11 @@ export default function WorkoutDetailScreen() {
const toggleSave = () => {
haptics.selection()
setIsSaved(!isSaved)
toggleSavedWorkout(workout.id.toString())
}
const repeatCount = Math.max(1, Math.floor(workout.rounds / workout.exercises.length))
const exerciseCount = workout.exercises?.length || 1
const repeatCount = Math.max(1, Math.floor((workout.rounds || exerciseCount) / exerciseCount))
return (
<View testID="workout-detail-screen" style={styles.container}>
@@ -97,26 +99,16 @@ export default function WorkoutDetailScreen() {
{workout.title}
</RNText>
{/* SwiftUI glass button */}
<View style={styles.glassButtonContainer}>
<Host matchContents useViewportSizeMeasurement colorScheme="dark">
<HStack
alignment="center"
modifiers={[
padding({ all: 8 }),
glassEffect({ glass: { variant: 'regular' } }),
]}
>
<Button
variant="borderless"
onPress={toggleSave}
color={isSaved ? '#FF3B30' : '#FFFFFF'}
>
{isSaved ? '♥' : '♡'}
</Button>
</HStack>
</Host>
</View>
{/* Save button */}
<Pressable style={styles.saveButton} onPress={toggleSave}>
<BlurView intensity={40} tint="dark" style={styles.saveButtonBlur}>
<Icon
name={isSaved ? 'heart.fill' : 'heart'}
size={22}
color={isSaved ? '#FF3B30' : '#FFFFFF'}
/>
</BlurView>
</Pressable>
</View>
{/* Content */}
@@ -136,17 +128,17 @@ export default function WorkoutDetailScreen() {
{/* Quick stats */}
<View style={styles.quickStats}>
<View style={[styles.statBadge, { backgroundColor: 'rgba(255, 107, 53, 0.15)' }]}>
<Ionicons name="barbell" size={14} color={BRAND.PRIMARY} />
<Icon name="dumbbell.fill" size={14} color={BRAND.PRIMARY} />
<RNText style={[styles.statBadgeText, { color: BRAND.PRIMARY }]}>
{t(`levels.${workout.level.toLowerCase()}`)}
{t(`levels.${(workout.level ?? 'Beginner').toLowerCase()}`)}
</RNText>
</View>
<View style={[styles.statBadge, { backgroundColor: colors.bg.surface }]}>
<Ionicons name="time" size={14} color={colors.text.secondary} />
<Icon name="clock.fill" size={14} color={colors.text.secondary} />
<RNText style={styles.statBadgeText}>{t('units.minUnit', { count: workout.duration })}</RNText>
</View>
<View style={[styles.statBadge, { backgroundColor: colors.bg.surface }]}>
<Ionicons name="flame" size={14} color={colors.text.secondary} />
<Icon name="flame.fill" size={14} color={colors.text.secondary} />
<RNText style={styles.statBadgeText}>{t('units.calUnit', { count: workout.calories })}</RNText>
</View>
</View>
@@ -156,7 +148,7 @@ export default function WorkoutDetailScreen() {
<RNText style={styles.sectionTitle}>{t('screens:workout.whatYoullNeed')}</RNText>
{workout.equipment.map((item, index) => (
<View key={index} style={styles.equipmentItem}>
<Ionicons name="checkmark-circle" size={20} color="#30D158" />
<Icon name="checkmark.circle.fill" size={20} color="#30D158" />
<RNText style={styles.equipmentText}>{item}</RNText>
</View>
))}
@@ -178,7 +170,7 @@ export default function WorkoutDetailScreen() {
</View>
))}
<View style={styles.repeatNote}>
<Ionicons name="repeat" size={16} color={colors.text.tertiary} />
<Icon name="repeat" size={16} color={colors.text.tertiary} />
<RNText style={styles.repeatText}>{t('screens:workout.repeatRounds', { count: repeatCount })}</RNText>
</View>
</View>
@@ -191,7 +183,7 @@ export default function WorkoutDetailScreen() {
<RNText style={styles.sectionTitle}>{t('screens:workout.music')}</RNText>
<View style={styles.musicCard}>
<View style={styles.musicIcon}>
<Ionicons name="musical-notes" size={24} color={BRAND.PRIMARY} />
<Icon name="music.note.list" size={24} color={BRAND.PRIMARY} />
</View>
<View style={styles.musicInfo}>
<RNText style={styles.musicName}>{t('screens:workout.musicMix', { vibe: musicVibeLabel })}</RNText>
@@ -214,7 +206,7 @@ export default function WorkoutDetailScreen() {
onPress={handleStartWorkout}
>
{isLocked && (
<Ionicons name="lock-closed" size={18} color="#FFFFFF" style={{ marginRight: 8 }} />
<Icon name="lock.fill" size={18} color="#FFFFFF" style={{ marginRight: 8 }} />
)}
<RNText testID="workout-cta-text" style={styles.startButtonText}>
{isLocked ? t('screens:workout.unlockWithPremium') : t('screens:workout.startWorkout')}
@@ -260,9 +252,17 @@ function createStyles(colors: ThemeColors) {
color: colors.text.primary,
marginRight: SPACING[3],
},
glassButtonContainer: {
width: 44,
height: 44,
saveButton: {
width: LAYOUT.TOUCH_TARGET,
height: LAYOUT.TOUCH_TARGET,
borderRadius: LAYOUT.TOUCH_TARGET / 2,
overflow: 'hidden',
},
saveButtonBlur: {
width: LAYOUT.TOUCH_TARGET,
height: LAYOUT.TOUCH_TARGET,
alignItems: 'center',
justifyContent: 'center',
},
// Video Preview

View File

@@ -7,7 +7,7 @@ import { useState, useMemo } from 'react'
import { View, StyleSheet, ScrollView, Pressable, Text as RNText } from 'react-native'
import { useRouter, useLocalSearchParams } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import Ionicons from '@expo/vector-icons/Ionicons'
import { Icon } from '@/src/shared/components/Icon'
import {
Host,
Picker,
@@ -80,7 +80,7 @@ export default function CategoryDetailScreen() {
{/* Header */}
<View style={styles.header}>
<Pressable onPress={handleBack} style={styles.backButton}>
<Ionicons name="chevron-back" size={24} color={colors.text.primary} />
<Icon name="chevron.left" size={24} color={colors.text.primary} />
</Pressable>
<StyledText size={22} weight="bold" color={colors.text.primary}>{categoryLabel}</StyledText>
<View style={styles.backButton} />
@@ -122,24 +122,24 @@ export default function CategoryDetailScreen() {
onPress={() => handleWorkoutPress(workout.id)}
>
<View style={[styles.workoutAvatar, { backgroundColor: BRAND.PRIMARY }]}>
<Ionicons name="flame" size={20} color="#FFFFFF" />
<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.toLowerCase()}`) })}
{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>
<Ionicons name="chevron-forward" size={16} color={colors.text.tertiary} />
<Icon name="chevron.right" size={16} color={colors.text.tertiary} />
</View>
</Pressable>
))}
{translatedWorkouts.length === 0 && (
<View style={styles.emptyState}>
<Ionicons name="barbell-outline" size={48} color={colors.text.tertiary} />
<Icon name="dumbbell" size={48} color={colors.text.tertiary} />
<StyledText size={17} color={colors.text.tertiary} style={{ marginTop: SPACING[3] }}>
No workouts found
</StyledText>