From f11eb6b9ae4878ac9abd3c5ffed1061e5be04115 Mon Sep 17 00:00:00 2001 From: Millian Lamiaux Date: Wed, 25 Mar 2026 23:28:47 +0100 Subject: [PATCH] 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. --- app/collection/[id].tsx | 14 +++---- app/workout/[id].tsx | 76 +++++++++++++++++------------------ app/workout/category/[id].tsx | 12 +++--- src/shared/data/programs.ts | 35 ++++++++++++---- 4 files changed, 78 insertions(+), 59 deletions(-) diff --git a/app/collection/[id].tsx b/app/collection/[id].tsx index 85d855b..8359d27 100644 --- a/app/collection/[id].tsx +++ b/app/collection/[id].tsx @@ -8,7 +8,7 @@ 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 Ionicons from '@expo/vector-icons/Ionicons' +import { Icon } from '@/src/shared/components/Icon' import { useTranslation } from 'react-i18next' import { useHaptics } from '@/src/shared/hooks' @@ -70,7 +70,7 @@ export default function CollectionDetailScreen() { if (!collection) { return ( - + Collection not found @@ -83,7 +83,7 @@ export default function CollectionDetailScreen() { {/* Header */} - + {collection.title} @@ -138,7 +138,7 @@ export default function CollectionDetailScreen() { onPress={() => handleWorkoutPress(workout.id)} > - + @@ -147,7 +147,7 @@ export default function CollectionDetailScreen() { {t('durationLevel', { duration: workout.duration, - level: t(`levels.${workout.level.toLowerCase()}`), + level: t(`levels.${(workout.level ?? 'Beginner').toLowerCase()}`), })} @@ -155,14 +155,14 @@ export default function CollectionDetailScreen() { {t('units.calUnit', { count: workout.calories })} - + ))} {workouts.length === 0 && ( - + No workouts in this collection diff --git a/app/workout/[id].tsx b/app/workout/[id].tsx index 7ae8039..ea2bc7f 100644 --- a/app/workout/[id].tsx +++ b/app/workout/[id].tsx @@ -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 ( @@ -97,26 +99,16 @@ export default function WorkoutDetailScreen() { {workout.title} - {/* SwiftUI glass button */} - - - - - - - + {/* Save button */} + + + + + {/* Content */} @@ -136,17 +128,17 @@ export default function WorkoutDetailScreen() { {/* Quick stats */} - + - {t(`levels.${workout.level.toLowerCase()}`)} + {t(`levels.${(workout.level ?? 'Beginner').toLowerCase()}`)} - + {t('units.minUnit', { count: workout.duration })} - + {t('units.calUnit', { count: workout.calories })} @@ -156,7 +148,7 @@ export default function WorkoutDetailScreen() { {t('screens:workout.whatYoullNeed')} {workout.equipment.map((item, index) => ( - + {item} ))} @@ -178,7 +170,7 @@ export default function WorkoutDetailScreen() { ))} - + {t('screens:workout.repeatRounds', { count: repeatCount })} @@ -191,7 +183,7 @@ export default function WorkoutDetailScreen() { {t('screens:workout.music')} - + {t('screens:workout.musicMix', { vibe: musicVibeLabel })} @@ -214,7 +206,7 @@ export default function WorkoutDetailScreen() { onPress={handleStartWorkout} > {isLocked && ( - + )} {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 diff --git a/app/workout/category/[id].tsx b/app/workout/category/[id].tsx index 506609d..5753b5f 100644 --- a/app/workout/category/[id].tsx +++ b/app/workout/category/[id].tsx @@ -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 */} - + {categoryLabel} @@ -122,24 +122,24 @@ export default function CategoryDetailScreen() { onPress={() => handleWorkoutPress(workout.id)} > - + {workout.title} - {t('durationLevel', { duration: workout.duration, level: t(`levels.${workout.level.toLowerCase()}`) })} + {t('durationLevel', { duration: workout.duration, level: t(`levels.${(workout.level ?? 'Beginner').toLowerCase()}`) })} {t('units.calUnit', { count: workout.calories })} - + ))} {translatedWorkouts.length === 0 && ( - + No workouts found diff --git a/src/shared/data/programs.ts b/src/shared/data/programs.ts index 90ba70c..d6bbc15 100644 --- a/src/shared/data/programs.ts +++ b/src/shared/data/programs.ts @@ -5,6 +5,7 @@ */ import { Program, Assessment, ProgramId, WeekNumber, Week } from '../types/program' +import type { WorkoutLevel, WorkoutCategory, MusicVibe } from '../types/workout' type ProgramWorkoutInput = { id: string @@ -18,6 +19,13 @@ type ProgramWorkoutInput = { tips: string[] } +/** Derive difficulty level from the week number in a progressive program */ +function getLevelFromWeek(week: number): WorkoutLevel { + if (week <= 2) return 'Beginner' + if (week === 3) return 'Intermediate' + return 'Advanced' +} + // Helper to create exercises with consistent structure const createExercise = (name: string, modification?: string, progression?: string) => ({ name, @@ -1571,13 +1579,24 @@ export const ASSESSMENT_WORKOUT: Assessment = { // PROGRAM BUILDER // ═══════════════════════════════════════════════════════════════════════════ -function buildProgramWorkouts(inputs: ProgramWorkoutInput[]): any[] { +function buildProgramWorkouts(inputs: ProgramWorkoutInput[], category: WorkoutCategory): any[] { return inputs.map((input) => ({ ...input, exercises: input.exercises.map((name) => createExercise(name) ), - duration: 4, + duration: 4 as const, + // Workout-compatible fields so screens don't crash + level: getLevelFromWeek(input.week), + category, + trainerId: '', + calories: 50, + rounds: input.exercises.length, + prepTime: 10, + workTime: 20, + restTime: 10, + musicVibe: 'electronic' as MusicVibe, + isFeatured: false, })) } @@ -1661,7 +1680,7 @@ export const PROGRAMS: Record = { optional: ['Wall', 'Elevated surface'], }, focusAreas: ['Shoulders', 'Chest', 'Back', 'Arms', 'Posture'], - weeks: buildWeeks(buildProgramWorkouts(upperBodyWorkouts)), + weeks: buildWeeks(buildProgramWorkouts(upperBodyWorkouts, 'upper-body')), }, 'lower-body': { id: 'lower-body', @@ -1675,7 +1694,7 @@ export const PROGRAMS: Record = { optional: ['Step or bench', 'Wall'], }, focusAreas: ['Legs', 'Glutes', 'Hips', 'Calves', 'Knee Health'], - weeks: buildWeeks(buildProgramWorkouts(lowerBodyWorkouts)), + weeks: buildWeeks(buildProgramWorkouts(lowerBodyWorkouts, 'lower-body')), }, 'full-body': { id: 'full-body', @@ -1689,14 +1708,14 @@ export const PROGRAMS: Record = { optional: ['Wall', 'Elevated surface'], }, focusAreas: ['Total Body', 'Core', 'Cardio', 'Functional Fitness'], - weeks: buildWeeks(buildProgramWorkouts(fullBodyWorkouts)), + weeks: buildWeeks(buildProgramWorkouts(fullBodyWorkouts, 'full-body')), }, } // Export individual arrays for convenience -export const UPPER_BODY_WORKOUTS = buildProgramWorkouts(upperBodyWorkouts) -export const LOWER_BODY_WORKOUTS = buildProgramWorkouts(lowerBodyWorkouts) -export const FULL_BODY_WORKOUTS = buildProgramWorkouts(fullBodyWorkouts) +export const UPPER_BODY_WORKOUTS = buildProgramWorkouts(upperBodyWorkouts, 'upper-body') +export const LOWER_BODY_WORKOUTS = buildProgramWorkouts(lowerBodyWorkouts, 'lower-body') +export const FULL_BODY_WORKOUTS = buildProgramWorkouts(fullBodyWorkouts, 'full-body') // Export all workouts as flat array for player compatibility export const ALL_PROGRAM_WORKOUTS = [