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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user