refactor: extract player components, add stack headers, add tests

- Extract player UI into src/features/player/ components (TimerRing, BurnBar, etc.)
- Add transparent stack headers for workout/[id] and program/[id] screens
- Refactor workout/[id], program/[id], complete/[id] screens
- Add player feature tests and useTimer integration tests
- Add data layer exports and test setup improvements
This commit is contained in:
Millian Lamiaux
2026-03-26 10:46:47 +01:00
parent 569a9e178f
commit 8926de58e5
22 changed files with 2930 additions and 1335 deletions

View File

@@ -119,6 +119,13 @@ function RootLayoutInner() {
<Stack.Screen
name="workout/[id]"
options={{
headerShown: true,
headerTransparent: true,
headerBlurEffect: colors.colorScheme === 'dark' ? 'dark' : 'light',
headerShadowVisible: false,
headerTitle: '',
headerBackButtonDisplayMode: 'minimal',
headerTintColor: colors.colorScheme === 'dark' ? '#FFFFFF' : '#000000',
animation: 'slide_from_right',
}}
/>
@@ -128,6 +135,19 @@ function RootLayoutInner() {
animation: 'slide_from_right',
}}
/>
<Stack.Screen
name="program/[id]"
options={{
headerShown: true,
headerTransparent: true,
headerBlurEffect: colors.colorScheme === 'dark' ? 'dark' : 'light',
headerShadowVisible: false,
headerTitle: '',
headerBackButtonDisplayMode: 'minimal',
headerTintColor: colors.colorScheme === 'dark' ? '#FFFFFF' : '#000000',
animation: 'slide_from_right',
}}
/>
<Stack.Screen
name="collection/[id]"
options={{

View File

@@ -15,7 +15,6 @@ import {
} from 'react-native'
import { useRouter, useLocalSearchParams } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { LinearGradient } from 'expo-linear-gradient'
import { BlurView } from 'expo-blur'
import { Icon, type IconName } from '@/src/shared/components/Icon'
@@ -24,7 +23,7 @@ import { useTranslation } from 'react-i18next'
import { useHaptics } from '@/src/shared/hooks'
import { useActivityStore, useUserStore } from '@/src/shared/stores'
import { getWorkoutById, getPopularWorkouts } from '@/src/shared/data'
import { getWorkoutById, getPopularWorkouts, getTrainerById, getWorkoutAccentColor } from '@/src/shared/data'
import { useTranslatedWorkout, useTranslatedWorkouts } from '@/src/shared/data/useTranslatedData'
import { SyncConsentModal } from '@/src/shared/components/SyncConsentModal'
import { enableSync } from '@/src/shared/services/sync'
@@ -95,6 +94,7 @@ function PrimaryButton({
children: React.ReactNode
}) {
const colors = useThemeColors()
const isDark = colors.colorScheme === 'dark'
const styles = useMemo(() => createStyles(colors), [colors])
const scaleAnim = useRef(new Animated.Value(1)).current
@@ -121,14 +121,15 @@ function PrimaryButton({
onPressOut={handlePressOut}
style={{ width: '100%' }}
>
<Animated.View style={[styles.primaryButton, { transform: [{ scale: scaleAnim }] }]}>
<LinearGradient
colors={[BRAND.PRIMARY, BRAND.PRIMARY_LIGHT]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={StyleSheet.absoluteFill}
/>
<RNText style={styles.primaryButtonText}>{children}</RNText>
<Animated.View
style={[
styles.primaryButton,
{ backgroundColor: isDark ? '#FFFFFF' : '#000000', transform: [{ scale: scaleAnim }] },
]}
>
<RNText style={[styles.primaryButtonText, { color: isDark ? '#000000' : '#FFFFFF' }]}>
{children}
</RNText>
</Animated.View>
</Pressable>
)
@@ -190,11 +191,13 @@ function StatCard({
value,
label,
icon,
accentColor,
delay = 0,
}: {
value: string | number
label: string
icon: IconName
accentColor: string
delay?: number
}) {
const colors = useThemeColors()
@@ -215,14 +218,14 @@ function StatCard({
return (
<Animated.View style={[styles.statCard, { transform: [{ scale: scaleAnim }] }]}>
<BlurView intensity={colors.glass.blurMedium} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
<Icon name={icon} size={24} tintColor={BRAND.PRIMARY} />
<Icon name={icon} size={24} tintColor={accentColor} />
<RNText style={styles.statValue}>{value}</RNText>
<RNText style={styles.statLabel}>{label}</RNText>
</Animated.View>
)
}
function BurnBarResult({ percentile }: { percentile: number }) {
function BurnBarResult({ percentile, accentColor }: { percentile: number; accentColor: string }) {
const { t } = useTranslation()
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
@@ -245,9 +248,9 @@ function BurnBarResult({ percentile }: { percentile: number }) {
return (
<View style={styles.burnBarContainer}>
<RNText style={styles.burnBarTitle}>{t('screens:complete.burnBar')}</RNText>
<RNText style={styles.burnBarResult}>{t('screens:complete.burnBarResult', { percentile })}</RNText>
<RNText style={[styles.burnBarResult, { color: accentColor }]}>{t('screens:complete.burnBarResult', { percentile })}</RNText>
<View style={styles.burnBarTrack}>
<Animated.View style={[styles.burnBarFill, { width: barWidth }]} />
<Animated.View style={[styles.burnBarFill, { width: barWidth, backgroundColor: accentColor }]} />
</View>
</View>
)
@@ -269,6 +272,8 @@ export default function WorkoutCompleteScreen() {
const rawWorkout = getWorkoutById(id ?? '1')
const workout = useTranslatedWorkout(rawWorkout)
const trainer = rawWorkout ? getTrainerById(rawWorkout.trainerId) : undefined
const trainerColor = getWorkoutAccentColor(id ?? '1')
const streak = useActivityStore((s) => s.streak)
const history = useActivityStore((s) => s.history)
const recentWorkouts = history.slice(0, 1)
@@ -378,20 +383,20 @@ export default function WorkoutCompleteScreen() {
{/* Stats Grid */}
<View style={styles.statsGrid}>
<StatCard value={resultCalories} label={t('screens:complete.caloriesLabel')} icon="flame.fill" delay={100} />
<StatCard value={resultMinutes} label={t('screens:complete.minutesLabel')} icon="clock.fill" delay={200} />
<StatCard value="100%" label={t('screens:complete.completeLabel')} icon="checkmark.circle.fill" delay={300} />
<StatCard value={resultCalories} label={t('screens:complete.caloriesLabel')} icon="flame.fill" accentColor={trainerColor} delay={100} />
<StatCard value={resultMinutes} label={t('screens:complete.minutesLabel')} icon="clock.fill" accentColor={trainerColor} delay={200} />
<StatCard value="100%" label={t('screens:complete.completeLabel')} icon="checkmark.circle.fill" accentColor={trainerColor} delay={300} />
</View>
{/* Burn Bar */}
<BurnBarResult percentile={burnBarPercentile} />
<BurnBarResult percentile={burnBarPercentile} accentColor={trainerColor} />
<View style={styles.divider} />
{/* Streak */}
<View style={styles.streakSection}>
<View style={styles.streakBadge}>
<Icon name="flame.fill" size={32} tintColor={BRAND.PRIMARY} />
<View style={[styles.streakBadge, { backgroundColor: trainerColor + '26' }]}>
<Icon name="flame.fill" size={32} tintColor={trainerColor} />
</View>
<View style={styles.streakInfo}>
<RNText style={styles.streakTitle}>{t('screens:complete.streakTitle', { count: streak.current })}</RNText>
@@ -421,12 +426,8 @@ export default function WorkoutCompleteScreen() {
style={styles.recommendedCard}
>
<BlurView intensity={colors.glass.blurLight} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
<View style={styles.recommendedThumb}>
<LinearGradient
colors={[BRAND.PRIMARY, BRAND.PRIMARY_LIGHT]}
style={StyleSheet.absoluteFill}
/>
<Icon name="flame.fill" size={24} tintColor="#FFFFFF" />
<View style={[styles.recommendedThumb, { backgroundColor: trainerColor + '20' }]}>
<Icon name="flame.fill" size={24} tintColor={trainerColor} />
</View>
<RNText style={styles.recommendedTitleText} numberOfLines={1}>{w.title}</RNText>
<RNText style={styles.recommendedDurationText}>{t('units.minUnit', { count: w.duration })}</RNText>
@@ -500,7 +501,6 @@ function createStyles(colors: ThemeColors) {
},
primaryButtonText: {
...TYPOGRAPHY.HEADLINE,
color: '#FFFFFF',
fontWeight: '700',
},
buttonIcon: {
@@ -588,7 +588,6 @@ function createStyles(colors: ThemeColors) {
},
burnBarResult: {
...TYPOGRAPHY.BODY,
color: BRAND.PRIMARY,
marginTop: SPACING[1],
marginBottom: SPACING[3],
},
@@ -600,7 +599,6 @@ function createStyles(colors: ThemeColors) {
},
burnBarFill: {
height: '100%',
backgroundColor: BRAND.PRIMARY,
borderRadius: 4,
},
@@ -622,7 +620,6 @@ function createStyles(colors: ThemeColors) {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: 'rgba(255, 107, 53, 0.15)',
alignItems: 'center',
justifyContent: 'center',
},

File diff suppressed because it is too large Load Diff

View File

@@ -1,35 +1,40 @@
/**
* TabataFit Program Detail Screen
* Shows week progression and workout list
* Clean scrollable layout — native header, Apple Fitness+ style
*/
import { View, StyleSheet, ScrollView, Pressable } from 'react-native'
import { useRouter, useLocalSearchParams } from 'expo-router'
import React, { useEffect, useRef } from 'react'
import {
View,
Text as RNText,
StyleSheet,
ScrollView,
Pressable,
Animated,
} from 'react-native'
import { Stack, 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 { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useHaptics } from '@/src/shared/hooks'
import { useProgramStore } from '@/src/shared/stores'
import { PROGRAMS } from '@/src/shared/data/programs'
import { StyledText } from '@/src/shared/components/StyledText'
import { track } from '@/src/shared/services/analytics'
import { useThemeColors, BRAND } from '@/src/shared/theme'
import type { ThemeColors } from '@/src/shared/theme/types'
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { SPRING } from '@/src/shared/constants/animations'
import type { ProgramId } from '@/src/shared/types'
import type { IconName } from '@/src/shared/components/Icon'
const FONTS = {
LARGE_TITLE: 28,
TITLE: 24,
TITLE_2: 20,
HEADLINE: 17,
BODY: 16,
CAPTION: 13,
SMALL: 12,
// Per-program accent colors (matches home screen cards)
const PROGRAM_ACCENT: Record<ProgramId, { color: string; icon: IconName }> = {
'upper-body': { color: '#FF6B35', icon: 'dumbbell' },
'lower-body': { color: '#30D158', icon: 'figure.walk' },
'full-body': { color: '#5AC8FA', icon: 'flame' },
}
export default function ProgramDetailScreen() {
@@ -40,25 +45,53 @@ export default function ProgramDetailScreen() {
const router = useRouter()
const haptics = useHaptics()
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
const isDark = colors.colorScheme === 'dark'
const program = PROGRAMS[programId]
const accent = PROGRAM_ACCENT[programId] ?? PROGRAM_ACCENT['full-body']
const selectProgram = useProgramStore((s) => s.selectProgram)
const progress = useProgramStore((s) => s.programsProgress[programId])
const isWeekUnlocked = useProgramStore((s) => s.isWeekUnlocked)
const getCurrentWorkout = useProgramStore((s) => s.getCurrentWorkout)
const completion = useProgramStore((s) => s.getProgramCompletion(programId))
// CTA entrance animation
const ctaAnim = useRef(new Animated.Value(0)).current
useEffect(() => {
Animated.sequence([
Animated.delay(300),
Animated.spring(ctaAnim, {
toValue: 1,
...SPRING.GENTLE,
useNativeDriver: true,
}),
]).start()
}, [])
useEffect(() => {
if (program) {
track('program_detail_viewed', {
program_id: programId,
program_title: program.title,
})
}
}, [programId])
if (!program) {
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
<StyledText color={colors.text.primary}>Program not found</StyledText>
</View>
<>
<Stack.Screen options={{ headerTitle: '' }} />
<View style={[s.container, s.centered, { backgroundColor: colors.bg.base }]}>
<RNText style={[TYPOGRAPHY.BODY, { color: colors.text.primary }]}>
{t('programs.notFound', { defaultValue: 'Program not found' })}
</RNText>
</View>
</>
)
}
const handleStartProgram = () => {
haptics.buttonTap()
haptics.phaseChange()
selectProgram(programId)
const currentWorkout = getCurrentWorkout(programId)
if (currentWorkout) {
@@ -66,460 +99,494 @@ export default function ProgramDetailScreen() {
}
}
const handleWorkoutPress = (workoutId: string, weekNumber: number) => {
const handleWorkoutPress = (workoutId: string) => {
haptics.buttonTap()
router.push(`/workout/${workoutId}`)
}
const hasStarted = progress.completedWorkoutIds.length > 0
const ctaBg = isDark ? '#FFFFFF' : '#000000'
const ctaTextColor = isDark ? '#000000' : '#FFFFFF'
const ctaLabel = hasStarted
? progress.isProgramCompleted
? t('programs.restartProgram')
: t('programs.continueTraining')
: t('programs.startProgram')
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
{/* Header */}
<View style={styles.header}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<Icon name="arrow.left" size={24} color={colors.text.primary} />
</Pressable>
<StyledText size={FONTS.TITLE} weight="bold" color={colors.text.primary}>
{program.title}
</StyledText>
<View style={styles.placeholder} />
</View>
<>
<Stack.Screen options={{ headerTitle: '' }} />
<ScrollView
style={styles.scrollView}
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 120 }]}
showsVerticalScrollIndicator={false}
>
{/* Program Overview */}
<View style={styles.overviewSection}>
<StyledText size={FONTS.BODY} color={colors.text.secondary} style={styles.description}>
<View style={[s.container, { backgroundColor: colors.bg.base }]}>
<ScrollView
contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={[
s.scrollContent,
{ paddingBottom: insets.bottom + 100 },
]}
showsVerticalScrollIndicator={false}
>
{/* Icon + Title */}
<View style={s.titleRow}>
<View style={[s.programIcon, { backgroundColor: accent.color + '18' }]}>
<Icon name={accent.icon} size={22} tintColor={accent.color} />
</View>
<View style={s.titleContent}>
<RNText selectable style={[s.title, { color: colors.text.primary }]}>
{program.title}
</RNText>
<RNText style={[s.subtitle, { color: colors.text.tertiary }]}>
{program.durationWeeks} {t('programs.weeks')} · {program.totalWorkouts} {t('programs.workouts')}
</RNText>
</View>
</View>
{/* Description */}
<RNText style={[s.description, { color: colors.text.secondary }]}>
{program.description}
</StyledText>
</RNText>
{/* Stats Row */}
<View style={styles.statsRow}>
<View style={styles.statBox}>
<StyledText size={FONTS.LARGE_TITLE} weight="bold" color={colors.text.primary}>
{program.durationWeeks}
</StyledText>
<StyledText size={FONTS.SMALL} color={colors.text.tertiary}>
{t('programs.weeks')}
</StyledText>
</View>
<View style={styles.statBox}>
<StyledText size={FONTS.LARGE_TITLE} weight="bold" color={colors.text.primary}>
{program.totalWorkouts}
</StyledText>
<StyledText size={FONTS.SMALL} color={colors.text.tertiary}>
{t('programs.workouts')}
</StyledText>
</View>
<View style={styles.statBox}>
<StyledText size={FONTS.LARGE_TITLE} weight="bold" color={colors.text.primary}>
4
</StyledText>
<StyledText size={FONTS.SMALL} color={colors.text.tertiary}>
{t('programs.minutes')}
</StyledText>
</View>
</View>
{/* Equipment */}
<View style={styles.equipmentSection}>
<StyledText size={FONTS.CAPTION} weight="semibold" color={colors.text.secondary}>
{t('programs.equipment')}
</StyledText>
<View style={styles.equipmentList}>
{program.equipment.required.map((item) => (
<View key={item} style={styles.equipmentTag}>
<StyledText size={12} color={colors.text.primary}>
{item}
</StyledText>
</View>
))}
{program.equipment.optional.map((item) => (
<View key={item} style={[styles.equipmentTag, styles.optionalTag]}>
<StyledText size={12} color={colors.text.tertiary}>
{item} {t('programs.optional')}
</StyledText>
</View>
))}
</View>
</View>
{/* Focus Areas */}
<View style={styles.focusSection}>
<StyledText size={FONTS.CAPTION} weight="semibold" color={colors.text.secondary}>
{t('programs.focusAreas')}
</StyledText>
<View style={styles.focusList}>
{program.focusAreas.map((area) => (
<View key={area} style={styles.focusTag}>
<StyledText size={12} color={colors.text.primary}>
{area}
</StyledText>
</View>
))}
</View>
</View>
</View>
{/* Progress Overview */}
{progress.completedWorkoutIds.length > 0 && (
<View style={styles.progressSection}>
<View style={styles.progressHeader}>
<StyledText size={FONTS.TITLE_2} weight="semibold" color={colors.text.primary}>
{t('programs.yourProgress')}
</StyledText>
<StyledText size={FONTS.BODY} weight="semibold" color={BRAND.PRIMARY}>
{completion}%
</StyledText>
</View>
<View style={styles.progressBarContainer}>
<View style={[styles.progressBar, { backgroundColor: colors.bg.surface }]}>
<View
style={[
styles.progressFill,
{
width: `${completion}%`,
backgroundColor: BRAND.PRIMARY,
}
]}
/>
{/* Stats Card */}
<View style={[s.card, { backgroundColor: colors.bg.surface }]}>
<View style={s.statsRow}>
<View style={s.statItem}>
<RNText style={[s.statValue, { color: accent.color }]}>
{program.durationWeeks}
</RNText>
<RNText style={[s.statLabel, { color: colors.text.tertiary }]}>
{t('programs.weeks')}
</RNText>
</View>
<View style={[s.statDivider, { backgroundColor: colors.border.glassLight }]} />
<View style={s.statItem}>
<RNText style={[s.statValue, { color: accent.color }]}>
{program.totalWorkouts}
</RNText>
<RNText style={[s.statLabel, { color: colors.text.tertiary }]}>
{t('programs.workouts')}
</RNText>
</View>
<View style={[s.statDivider, { backgroundColor: colors.border.glassLight }]} />
<View style={s.statItem}>
<RNText style={[s.statValue, { color: accent.color }]}>
4
</RNText>
<RNText style={[s.statLabel, { color: colors.text.tertiary }]}>
{t('programs.minutes')}
</RNText>
</View>
</View>
<StyledText size={FONTS.CAPTION} color={colors.text.tertiary}>
{progress.completedWorkoutIds.length} {t('programs.of')} {program.totalWorkouts} {t('programs.workoutsComplete')}
</StyledText>
</View>
)}
{/* Weeks */}
<View style={styles.weeksSection}>
<StyledText size={FONTS.TITLE_2} weight="semibold" color={colors.text.primary} style={styles.weeksTitle}>
{/* Equipment & Focus */}
<View style={s.tagsSection}>
{program.equipment.required.length > 0 && (
<>
<RNText style={[s.tagSectionLabel, { color: colors.text.secondary }]}>
{t('programs.equipment')}
</RNText>
<View style={s.tagRow}>
{program.equipment.required.map((item) => (
<View key={item} style={[s.tag, { backgroundColor: colors.bg.surface }]}>
<RNText style={[s.tagText, { color: colors.text.primary }]}>
{item}
</RNText>
</View>
))}
{program.equipment.optional.map((item) => (
<View key={item} style={[s.tag, { backgroundColor: colors.bg.surface, opacity: 0.7 }]}>
<RNText style={[s.tagText, { color: colors.text.tertiary }]}>
{item} {t('programs.optional')}
</RNText>
</View>
))}
</View>
</>
)}
<RNText style={[s.tagSectionLabel, { color: colors.text.secondary, marginTop: SPACING[4] }]}>
{t('programs.focusAreas')}
</RNText>
<View style={s.tagRow}>
{program.focusAreas.map((area) => (
<View key={area} style={[s.tag, { backgroundColor: accent.color + '15' }]}>
<RNText style={[s.tagText, { color: accent.color }]}>
{area}
</RNText>
</View>
))}
</View>
</View>
{/* Separator */}
<View style={[s.separator, { backgroundColor: colors.border.glassLight }]} />
{/* Progress (if started) */}
{hasStarted && (
<View style={[s.card, { backgroundColor: colors.bg.surface, marginBottom: SPACING[5] }]}>
<View style={s.progressHeader}>
<RNText style={[TYPOGRAPHY.HEADLINE, { color: colors.text.primary }]}>
{t('programs.yourProgress')}
</RNText>
<RNText style={[TYPOGRAPHY.HEADLINE, { color: accent.color, fontVariant: ['tabular-nums'] }]}>
{completion}%
</RNText>
</View>
<View style={[s.progressTrack, { backgroundColor: colors.border.glassLight }]}>
<View
style={[
s.progressFill,
{
width: `${completion}%`,
backgroundColor: accent.color,
},
]}
/>
</View>
<RNText style={[TYPOGRAPHY.FOOTNOTE, { color: colors.text.tertiary, marginTop: SPACING[2] }]}>
{progress.completedWorkoutIds.length} {t('programs.of')} {program.totalWorkouts} {t('programs.workoutsComplete')}
</RNText>
</View>
)}
{/* Training Plan */}
<RNText style={[s.sectionTitle, { color: colors.text.primary }]}>
{t('programs.trainingPlan')}
</StyledText>
</RNText>
{program.weeks.map((week) => {
const isUnlocked = isWeekUnlocked(programId, week.weekNumber)
const isCurrentWeek = progress.currentWeek === week.weekNumber
const weekCompletion = week.workouts.filter(w =>
const weekCompletion = week.workouts.filter((w) =>
progress.completedWorkoutIds.includes(w.id)
).length
return (
<View key={week.weekNumber} style={styles.weekCard}>
<View
key={week.weekNumber}
style={[s.card, { backgroundColor: colors.bg.surface, marginBottom: SPACING[3] }]}
>
{/* Week Header */}
<View style={styles.weekHeader}>
<View style={styles.weekTitleRow}>
<StyledText size={FONTS.HEADLINE} weight="semibold" color={colors.text.primary}>
<View style={s.weekHeader}>
<View style={s.weekTitleRow}>
<RNText style={[TYPOGRAPHY.HEADLINE, { color: colors.text.primary, flex: 1 }]}>
{week.title}
</StyledText>
</RNText>
{!isUnlocked && (
<Icon name="lock.fill" size={16} color={colors.text.tertiary} />
<Icon name="lock.fill" size={16} color={colors.text.hint} />
)}
{isCurrentWeek && isUnlocked && (
<View style={styles.currentBadge}>
<StyledText size={11} weight="semibold" color="#FFFFFF">
<View style={[s.currentBadge, { backgroundColor: accent.color }]}>
<RNText style={[s.currentBadgeText, { color: '#FFFFFF' }]}>
{t('programs.current')}
</StyledText>
</RNText>
</View>
)}
</View>
<StyledText size={FONTS.CAPTION} color={colors.text.secondary}>
<RNText style={[TYPOGRAPHY.FOOTNOTE, { color: colors.text.secondary, marginTop: 2 }]}>
{week.description}
</StyledText>
</RNText>
{weekCompletion > 0 && (
<StyledText size={FONTS.SMALL} color={colors.text.tertiary} style={styles.weekProgress}>
<RNText style={[TYPOGRAPHY.CAPTION_1, { color: colors.text.hint, marginTop: SPACING[2] }]}>
{weekCompletion}/{week.workouts.length} {t('programs.complete')}
</StyledText>
</RNText>
)}
</View>
{/* Week Workouts */}
{isUnlocked && (
<View style={styles.workoutsList}>
{week.workouts.map((workout, index) => {
const isCompleted = progress.completedWorkoutIds.includes(workout.id)
const isLocked = !isCompleted && index > 0 &&
!progress.completedWorkoutIds.includes(week.workouts[index - 1].id) &&
week.weekNumber === progress.currentWeek
{isUnlocked &&
week.workouts.map((workout, index) => {
const isCompleted = progress.completedWorkoutIds.includes(workout.id)
const isWorkoutLocked =
!isCompleted &&
index > 0 &&
!progress.completedWorkoutIds.includes(week.workouts[index - 1].id) &&
week.weekNumber === progress.currentWeek
return (
return (
<View key={workout.id}>
<View style={[s.workoutSep, { backgroundColor: colors.border.glassLight }]} />
<Pressable
key={workout.id}
style={[
styles.workoutItem,
isCompleted && styles.workoutCompleted,
isLocked && styles.workoutLocked,
style={({ pressed }) => [
s.workoutRow,
isWorkoutLocked && { opacity: 0.4 },
pressed && !isWorkoutLocked && { opacity: 0.6 },
]}
onPress={() => !isLocked && handleWorkoutPress(workout.id, week.weekNumber)}
disabled={isLocked}
onPress={() => !isWorkoutLocked && handleWorkoutPress(workout.id)}
disabled={isWorkoutLocked}
>
<View style={styles.workoutNumber}>
<View style={s.workoutIcon}>
{isCompleted ? (
<Icon name="checkmark.circle.fill" size={24} color={BRAND.SUCCESS} />
) : isLocked ? (
<Icon name="lock.fill" size={20} color={colors.text.tertiary} />
<Icon name="checkmark.circle.fill" size={22} color={BRAND.SUCCESS} />
) : isWorkoutLocked ? (
<Icon name="lock.fill" size={18} color={colors.text.hint} />
) : (
<StyledText size={14} weight="semibold" color={colors.text.primary}>
<RNText style={[s.workoutIndex, { color: colors.text.tertiary }]}>
{index + 1}
</StyledText>
</RNText>
)}
</View>
<View style={styles.workoutInfo}>
<StyledText
size={14}
weight={isCompleted ? "medium" : "semibold"}
color={isLocked ? colors.text.tertiary : colors.text.primary}
style={isCompleted && styles.completedText}
<View style={s.workoutInfo}>
<RNText
style={[
TYPOGRAPHY.BODY,
{ color: isWorkoutLocked ? colors.text.hint : colors.text.primary },
isCompleted && { textDecorationLine: 'line-through' },
]}
numberOfLines={1}
>
{workout.title}
</StyledText>
<StyledText size={12} color={colors.text.tertiary}>
{workout.exercises.length} {t('programs.exercises')} {workout.duration} {t('programs.min')}
</StyledText>
</RNText>
<RNText style={[TYPOGRAPHY.CAPTION_1, { color: colors.text.tertiary }]}>
{workout.exercises.length} {t('programs.exercises')} · {workout.duration} {t('programs.min')}
</RNText>
</View>
{!isLocked && !isCompleted && (
<Icon name="chevron.right" size={20} color={colors.text.tertiary} />
{!isWorkoutLocked && !isCompleted && (
<Icon name="chevron.right" size={16} color={colors.text.hint} />
)}
</Pressable>
)
})}
</View>
)}
</View>
)
})}
</View>
)
})}
</View>
</ScrollView>
</ScrollView>
{/* Bottom CTA */}
<View style={[styles.bottomBar, { paddingBottom: insets.bottom + SPACING[4] }]}>
<Pressable style={styles.ctaButton} onPress={handleStartProgram}>
<LinearGradient
colors={['#FF6B35', '#FF3B30']}
style={styles.ctaGradient}
{/* CTA */}
<Animated.View
style={[
s.bottomBar,
{
backgroundColor: colors.bg.base,
paddingBottom: insets.bottom + SPACING[3],
opacity: ctaAnim,
transform: [
{
translateY: ctaAnim.interpolate({
inputRange: [0, 1],
outputRange: [30, 0],
}),
},
],
},
]}
>
<Pressable
style={({ pressed }) => [
s.ctaButton,
{ backgroundColor: ctaBg },
pressed && { opacity: 0.85, transform: [{ scale: 0.98 }] },
]}
onPress={handleStartProgram}
>
<StyledText size={16} weight="bold" color="#FFFFFF">
{progress.completedWorkoutIds.length === 0
? t('programs.startProgram')
: progress.isProgramCompleted
? t('programs.restartProgram')
: t('programs.continueTraining')
}
</StyledText>
<Icon name="arrow.right" size={20} color="#FFFFFF" style={styles.ctaIcon} />
</LinearGradient>
</Pressable>
<RNText style={[s.ctaText, { color: ctaTextColor }]}>
{ctaLabel}
</RNText>
</Pressable>
</Animated.View>
</View>
</View>
</>
)
}
function createStyles(colors: ThemeColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bg.base,
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingHorizontal: LAYOUT.SCREEN_PADDING,
},
// ─── Styles ──────────────────────────────────────────────────────────────────
// Header
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: LAYOUT.SCREEN_PADDING,
paddingVertical: SPACING[3],
},
backButton: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
placeholder: {
width: 40,
},
const s = StyleSheet.create({
container: {
flex: 1,
},
centered: {
alignItems: 'center',
justifyContent: 'center',
},
scrollContent: {
paddingHorizontal: LAYOUT.SCREEN_PADDING,
paddingTop: SPACING[2],
},
// Overview
overviewSection: {
marginTop: SPACING[2],
marginBottom: SPACING[6],
},
description: {
marginBottom: SPACING[5],
lineHeight: 24,
},
statsRow: {
flexDirection: 'row',
justifyContent: 'space-around',
marginBottom: SPACING[5],
paddingVertical: SPACING[4],
backgroundColor: colors.bg.surface,
borderRadius: RADIUS.LG,
},
statBox: {
alignItems: 'center',
},
// Title
titleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING[3],
marginBottom: SPACING[3],
},
programIcon: {
width: 44,
height: 44,
borderRadius: RADIUS.MD,
borderCurve: 'continuous',
alignItems: 'center',
justifyContent: 'center',
},
titleContent: {
flex: 1,
},
title: {
...TYPOGRAPHY.TITLE_1,
},
subtitle: {
...TYPOGRAPHY.SUBHEADLINE,
marginTop: 2,
},
// Equipment
equipmentSection: {
marginBottom: SPACING[4],
},
equipmentList: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: SPACING[2],
marginTop: SPACING[2],
},
equipmentTag: {
backgroundColor: colors.bg.surface,
paddingHorizontal: SPACING[3],
paddingVertical: SPACING[1],
borderRadius: RADIUS.FULL,
},
optionalTag: {
opacity: 0.7,
},
// Description
description: {
...TYPOGRAPHY.BODY,
lineHeight: 24,
marginBottom: SPACING[5],
},
// Focus
focusSection: {
marginBottom: SPACING[4],
},
focusList: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: SPACING[2],
marginTop: SPACING[2],
},
focusTag: {
backgroundColor: `${BRAND.PRIMARY}15`,
paddingHorizontal: SPACING[3],
paddingVertical: SPACING[1],
borderRadius: RADIUS.FULL,
},
// Card
card: {
borderRadius: RADIUS.LG,
borderCurve: 'continuous',
overflow: 'hidden',
padding: SPACING[4],
},
// Progress
progressSection: {
backgroundColor: colors.bg.surface,
borderRadius: RADIUS.LG,
padding: SPACING[4],
marginBottom: SPACING[6],
},
progressHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: SPACING[3],
},
progressBarContainer: {
marginBottom: SPACING[2],
},
progressBar: {
height: 8,
borderRadius: 4,
overflow: 'hidden',
},
progressFill: {
height: '100%',
borderRadius: 4,
},
// Stats
statsRow: {
flexDirection: 'row',
alignItems: 'center',
},
statItem: {
flex: 1,
alignItems: 'center',
gap: 2,
},
statValue: {
...TYPOGRAPHY.TITLE_1,
fontVariant: ['tabular-nums'],
},
statLabel: {
...TYPOGRAPHY.CAPTION_2,
textTransform: 'uppercase' as const,
letterSpacing: 0.5,
},
statDivider: {
width: StyleSheet.hairlineWidth,
height: 32,
},
// Weeks
weeksSection: {
marginBottom: SPACING[6],
},
weeksTitle: {
marginBottom: SPACING[4],
},
weekCard: {
backgroundColor: colors.bg.surface,
borderRadius: RADIUS.LG,
marginBottom: SPACING[4],
overflow: 'hidden',
},
weekHeader: {
padding: SPACING[4],
borderBottomWidth: 1,
borderBottomColor: colors.border.glass,
},
weekTitleRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: SPACING[1],
},
currentBadge: {
backgroundColor: BRAND.PRIMARY,
paddingHorizontal: SPACING[2],
paddingVertical: 2,
borderRadius: RADIUS.SM,
},
weekProgress: {
marginTop: SPACING[2],
},
// Tags
tagsSection: {
marginTop: SPACING[5],
marginBottom: SPACING[5],
},
tagSectionLabel: {
...TYPOGRAPHY.FOOTNOTE,
fontWeight: '600',
marginBottom: SPACING[2],
},
tagRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: SPACING[2],
},
tag: {
paddingHorizontal: SPACING[3],
paddingVertical: SPACING[1],
borderRadius: RADIUS.FULL,
},
tagText: {
...TYPOGRAPHY.CAPTION_1,
},
// Workouts List
workoutsList: {
padding: SPACING[2],
},
workoutItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: SPACING[3],
paddingHorizontal: SPACING[3],
borderRadius: RADIUS.MD,
},
workoutCompleted: {
opacity: 0.7,
},
workoutLocked: {
opacity: 0.5,
},
workoutNumber: {
width: 32,
height: 32,
alignItems: 'center',
justifyContent: 'center',
marginRight: SPACING[3],
},
workoutInfo: {
flex: 1,
},
completedText: {
textDecorationLine: 'line-through',
},
// Separator
separator: {
height: StyleSheet.hairlineWidth,
marginBottom: SPACING[5],
},
// Bottom Bar
bottomBar: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
backgroundColor: colors.bg.base,
paddingHorizontal: LAYOUT.SCREEN_PADDING,
paddingTop: SPACING[3],
borderTopWidth: 1,
borderTopColor: colors.border.glass,
},
ctaButton: {
borderRadius: RADIUS.LG,
overflow: 'hidden',
},
ctaGradient: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: SPACING[4],
},
ctaIcon: {
marginLeft: SPACING[2],
},
})
}
// Progress
progressHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: SPACING[3],
},
progressTrack: {
height: 6,
borderRadius: 3,
overflow: 'hidden',
},
progressFill: {
height: '100%',
borderRadius: 3,
},
// Section title
sectionTitle: {
...TYPOGRAPHY.TITLE_2,
marginBottom: SPACING[4],
},
// Week header
weekHeader: {
marginBottom: SPACING[1],
},
weekTitleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING[2],
},
currentBadge: {
paddingHorizontal: SPACING[2],
paddingVertical: 2,
borderRadius: RADIUS.SM,
},
currentBadgeText: {
...TYPOGRAPHY.CAPTION_2,
fontWeight: '600',
},
// Workout row
workoutSep: {
height: StyleSheet.hairlineWidth,
marginLeft: SPACING[4] + 28,
},
workoutRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: SPACING[3],
gap: SPACING[3],
},
workoutIcon: {
width: 28,
alignItems: 'center',
justifyContent: 'center',
},
workoutIndex: {
...TYPOGRAPHY.SUBHEADLINE,
fontVariant: ['tabular-nums'],
fontWeight: '600',
},
workoutInfo: {
flex: 1,
gap: 2,
},
// Bottom bar
bottomBar: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
paddingHorizontal: LAYOUT.SCREEN_PADDING,
paddingTop: SPACING[3],
},
ctaButton: {
height: 54,
borderRadius: RADIUS.MD,
borderCurve: 'continuous',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
},
ctaText: {
...TYPOGRAPHY.BUTTON_LARGE,
},
})

View File

@@ -1,14 +1,23 @@
/**
* TabataFit Pre-Workout Detail Screen
* Clean modal with workout info
* Clean scrollable layout — native header, no hero
*/
import { useEffect, useMemo } from 'react'
import { View, Text as RNText, StyleSheet, ScrollView, Pressable } from 'react-native'
import React, { useEffect, useRef } from 'react'
import {
View,
Text as RNText,
StyleSheet,
ScrollView,
Pressable,
Animated,
} from 'react-native'
import { Stack } from 'expo-router'
import { useRouter, useLocalSearchParams } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { BlurView } from 'expo-blur'
import { Icon } from '@/src/shared/components/Icon'
import { VideoPlayer } from '@/src/shared/components/VideoPlayer'
import { Image } from 'expo-image'
import { useTranslation } from 'react-i18next'
import { useHaptics } from '@/src/shared/hooks'
@@ -16,19 +25,43 @@ 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'
import { getWorkoutById, getTrainerById, getWorkoutAccentColor } from '@/src/shared/data'
import { useTranslatedWorkout, useMusicVibeLabel } from '@/src/shared/data/useTranslatedData'
import { VideoPlayer } from '@/src/shared/components/VideoPlayer'
import { useThemeColors, BRAND } from '@/src/shared/theme'
import { useThemeColors } from '@/src/shared/theme'
import type { ThemeColors } from '@/src/shared/theme/types'
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { SPRING } from '@/src/shared/constants/animations'
// ═══════════════════════════════════════════════════════════════════════════
// MAIN SCREEN
// ═══════════════════════════════════════════════════════════════════════════
// ─── Save Button (headerRight) ───────────────────────────────────────────────
function SaveButton({
isSaved,
onPress,
colors,
}: {
isSaved: boolean
onPress: () => void
colors: ThemeColors
}) {
return (
<Pressable
onPress={onPress}
hitSlop={8}
style={({ pressed }) => pressed && { opacity: 0.6 }}
>
<Icon
name={isSaved ? 'heart.fill' : 'heart'}
size={22}
color={isSaved ? '#FF3B30' : colors.text.primary}
/>
</Pressable>
)
}
// ─── Main Screen ─────────────────────────────────────────────────────────────
export default function WorkoutDetailScreen() {
const insets = useSafeAreaInsets()
@@ -41,11 +74,26 @@ export default function WorkoutDetailScreen() {
const { isPremium } = usePurchases()
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
const isDark = colors.colorScheme === 'dark'
const rawWorkout = getWorkoutById(id ?? '1')
const workout = useTranslatedWorkout(rawWorkout)
const musicVibeLabel = useMusicVibeLabel(rawWorkout?.musicVibe ?? '')
const trainer = rawWorkout ? getTrainerById(rawWorkout.trainerId) : undefined
const accentColor = getWorkoutAccentColor(id ?? '1')
// CTA entrance
const ctaAnim = useRef(new Animated.Value(0)).current
useEffect(() => {
Animated.sequence([
Animated.delay(300),
Animated.spring(ctaAnim, {
toValue: 1,
...SPRING.GENTLE,
useNativeDriver: true,
}),
]).start()
}, [])
useEffect(() => {
if (workout) {
@@ -58,24 +106,34 @@ export default function WorkoutDetailScreen() {
}
}, [workout?.id])
const isSaved = savedWorkouts.includes(workout?.id?.toString() ?? '')
const toggleSave = () => {
if (!workout) return
haptics.selection()
toggleSavedWorkout(workout.id.toString())
}
if (!workout) {
return (
<View style={[styles.container, styles.centered]}>
<RNText style={{ color: colors.text.primary, fontSize: 17 }}>{t('screens:workout.notFound')}</RNText>
</View>
<>
<Stack.Screen options={{ headerTitle: '' }} />
<View style={[s.container, s.centered, { backgroundColor: colors.bg.base }]}>
<RNText style={{ color: colors.text.primary, fontSize: 17 }}>
{t('screens:workout.notFound')}
</RNText>
</View>
</>
)
}
const isSaved = savedWorkouts.includes(workout.id.toString())
const isLocked = !canAccessWorkout(workout.id, isPremium)
const exerciseCount = workout.exercises?.length || 1
const repeatCount = Math.max(1, Math.floor((workout.rounds || exerciseCount) / exerciseCount))
const handleStartWorkout = () => {
if (isLocked) {
haptics.buttonTap()
track('paywall_triggered', {
source: 'workout_detail',
workout_id: workout.id,
})
track('paywall_triggered', { source: 'workout_detail', workout_id: workout.id })
router.push('/paywall')
return
}
@@ -83,356 +141,403 @@ export default function WorkoutDetailScreen() {
router.push(`/player/${workout.id}`)
}
const toggleSave = () => {
haptics.selection()
toggleSavedWorkout(workout.id.toString())
}
const ctaBg = isDark ? '#FFFFFF' : '#000000'
const ctaText = isDark ? '#000000' : '#FFFFFF'
const ctaLockedBg = isDark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.08)'
const ctaLockedText = colors.text.primary
const exerciseCount = workout.exercises?.length || 1
const repeatCount = Math.max(1, Math.floor((workout.rounds || exerciseCount) / exerciseCount))
const equipmentText = workout.equipment.length > 0
? workout.equipment.join(' · ')
: t('screens:workout.noEquipment', { defaultValue: 'No equipment needed' })
return (
<View testID="workout-detail-screen" style={styles.container}>
{/* Header with SwiftUI glass button */}
<View style={[styles.header, { paddingTop: insets.top + SPACING[3] }]}>
<RNText style={styles.headerTitle} numberOfLines={1}>
{workout.title}
</RNText>
<>
<Stack.Screen
options={{
headerRight: () => (
<SaveButton isSaved={isSaved} onPress={toggleSave} colors={colors} />
),
}}
/>
{/* 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>
<View testID="workout-detail-screen" style={[s.container, { backgroundColor: colors.bg.base }]}>
<ScrollView
contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={[
s.scrollContent,
{ paddingBottom: insets.bottom + 100 },
]}
showsVerticalScrollIndicator={false}
>
{/* Thumbnail / Video Preview */}
{rawWorkout?.thumbnailUrl ? (
<View style={s.mediaContainer}>
<Image
source={rawWorkout.thumbnailUrl}
style={s.thumbnail}
contentFit="cover"
transition={200}
/>
</View>
) : rawWorkout?.videoUrl ? (
<View style={s.mediaContainer}>
<VideoPlayer
videoUrl={rawWorkout.videoUrl}
gradientColors={['#1C1C1E', '#2C2C2E']}
mode="preview"
isPlaying={false}
style={s.thumbnail}
/>
</View>
) : null}
{/* Content */}
<ScrollView
style={styles.scrollView}
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 100 }]}
showsVerticalScrollIndicator={false}
>
{/* Video Preview Hero */}
<VideoPlayer
videoUrl={workout.videoUrl}
mode="preview"
isPlaying={true}
style={styles.videoPreview}
testID="workout-video-preview"
/>
{/* Quick stats */}
<View style={styles.quickStats}>
<View style={[styles.statBadge, { backgroundColor: 'rgba(255, 107, 53, 0.15)' }]}>
<Icon name="dumbbell.fill" size={14} color={BRAND.PRIMARY} />
<RNText style={[styles.statBadgeText, { color: BRAND.PRIMARY }]}>
{/* Title */}
<RNText selectable style={[s.title, { color: colors.text.primary }]}>
{workout.title}
</RNText>
{/* Trainer */}
{trainer && (
<RNText style={[s.trainerName, { color: accentColor }]}>
with {trainer.name}
</RNText>
)}
{/* Inline metadata */}
<View style={s.metaRow}>
<View style={s.metaItem}>
<Icon name="clock" size={15} tintColor={colors.text.tertiary} />
<RNText style={[s.metaText, { color: colors.text.secondary }]}>
{workout.duration} {t('units.minUnit', { count: workout.duration })}
</RNText>
</View>
<RNText style={[s.metaDot, { color: colors.text.hint }]}>·</RNText>
<View style={s.metaItem}>
<Icon name="flame" size={15} tintColor={colors.text.tertiary} />
<RNText style={[s.metaText, { color: colors.text.secondary }]}>
{workout.calories} {t('units.calUnit', { count: workout.calories })}
</RNText>
</View>
<RNText style={[s.metaDot, { color: colors.text.hint }]}>·</RNText>
<RNText style={[s.metaText, { color: colors.text.secondary }]}>
{t(`levels.${(workout.level ?? 'Beginner').toLowerCase()}`)}
</RNText>
</View>
<View style={[styles.statBadge, { backgroundColor: colors.bg.surface }]}>
<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 }]}>
<Icon name="flame.fill" size={14} color={colors.text.secondary} />
<RNText style={styles.statBadgeText}>{t('units.calUnit', { count: workout.calories })}</RNText>
</View>
</View>
{/* Equipment */}
<View style={styles.section}>
<RNText style={styles.sectionTitle}>{t('screens:workout.whatYoullNeed')}</RNText>
{workout.equipment.map((item, index) => (
<View key={index} style={styles.equipmentItem}>
<Icon name="checkmark.circle.fill" size={20} color="#30D158" />
<RNText style={styles.equipmentText}>{item}</RNText>
{/* Equipment */}
<RNText style={[s.equipmentText, { color: colors.text.tertiary }]}>
{equipmentText}
</RNText>
{/* Separator */}
<View style={[s.separator, { backgroundColor: colors.border.glassLight }]} />
{/* Timing Card */}
<View style={[s.card, { backgroundColor: colors.bg.surface }]}>
<View style={s.timingRow}>
<View style={s.timingItem}>
<RNText style={[s.timingValue, { color: accentColor }]}>
{workout.prepTime}s
</RNText>
<RNText style={[s.timingLabel, { color: colors.text.tertiary }]}>
{t('screens:workout.prep', { defaultValue: 'Prep' })}
</RNText>
</View>
<View style={[s.timingDivider, { backgroundColor: colors.border.glassLight }]} />
<View style={s.timingItem}>
<RNText style={[s.timingValue, { color: accentColor }]}>
{workout.workTime}s
</RNText>
<RNText style={[s.timingLabel, { color: colors.text.tertiary }]}>
{t('screens:workout.work', { defaultValue: 'Work' })}
</RNText>
</View>
<View style={[s.timingDivider, { backgroundColor: colors.border.glassLight }]} />
<View style={s.timingItem}>
<RNText style={[s.timingValue, { color: accentColor }]}>
{workout.restTime}s
</RNText>
<RNText style={[s.timingLabel, { color: colors.text.tertiary }]}>
{t('screens:workout.rest', { defaultValue: 'Rest' })}
</RNText>
</View>
<View style={[s.timingDivider, { backgroundColor: colors.border.glassLight }]} />
<View style={s.timingItem}>
<RNText style={[s.timingValue, { color: accentColor }]}>
{workout.rounds}
</RNText>
<RNText style={[s.timingLabel, { color: colors.text.tertiary }]}>
{t('screens:workout.rounds', { defaultValue: 'Rounds' })}
</RNText>
</View>
</View>
))}
</View>
</View>
<View style={styles.divider} />
{/* Exercises Card */}
<RNText style={[s.sectionTitle, { color: colors.text.primary }]}>
{t('screens:workout.exercises', { count: workout.rounds })}
</RNText>
{/* Exercises */}
<View style={styles.section}>
<RNText style={styles.sectionTitle}>{t('screens:workout.exercises', { count: workout.rounds })}</RNText>
<View style={styles.exercisesList}>
<View style={[s.card, { backgroundColor: colors.bg.surface }]}>
{workout.exercises.map((exercise, index) => (
<View key={index} style={styles.exerciseRow}>
<View style={styles.exerciseNumber}>
<RNText style={styles.exerciseNumberText}>{index + 1}</RNText>
<View key={index}>
<View style={s.exerciseRow}>
<RNText style={[s.exerciseIndex, { color: accentColor }]}>
{index + 1}
</RNText>
<RNText selectable style={[s.exerciseName, { color: colors.text.primary }]}>
{exercise.name}
</RNText>
<RNText style={[s.exerciseDuration, { color: colors.text.tertiary }]}>
{exercise.duration}s
</RNText>
</View>
<RNText style={styles.exerciseName}>{exercise.name}</RNText>
<RNText style={styles.exerciseDuration}>{exercise.duration}s</RNText>
{index < workout.exercises.length - 1 && (
<View style={[s.exerciseSep, { backgroundColor: colors.border.glassLight }]} />
)}
</View>
))}
<View style={styles.repeatNote}>
<Icon name="repeat" size={16} color={colors.text.tertiary} />
<RNText style={styles.repeatText}>{t('screens:workout.repeatRounds', { count: repeatCount })}</RNText>
</View>
</View>
</View>
<View style={styles.divider} />
{/* Music */}
<View style={styles.section}>
<RNText style={styles.sectionTitle}>{t('screens:workout.music')}</RNText>
<View style={styles.musicCard}>
<View style={styles.musicIcon}>
<Icon name="music.note.list" size={24} color={BRAND.PRIMARY} />
{repeatCount > 1 && (
<View style={s.repeatRow}>
<Icon name="repeat" size={13} color={colors.text.hint} />
<RNText style={[s.repeatText, { color: colors.text.hint }]}>
{t('screens:workout.repeatRounds', { count: repeatCount })}
</RNText>
</View>
<View style={styles.musicInfo}>
<RNText style={styles.musicName}>{t('screens:workout.musicMix', { vibe: musicVibeLabel })}</RNText>
<RNText style={styles.musicDescription}>{t('screens:workout.curatedForWorkout')}</RNText>
</View>
</View>
</View>
</ScrollView>
{/* Fixed Start Button */}
<View style={[styles.bottomBar, { paddingBottom: insets.bottom + SPACING[4] }]}>
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
<Pressable
testID={isLocked ? 'workout-unlock-button' : 'workout-start-button'}
style={({ pressed }) => [
styles.startButton,
isLocked && styles.lockedButton,
pressed && styles.startButtonPressed,
]}
onPress={handleStartWorkout}
>
{isLocked && (
<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')}
</RNText>
</Pressable>
{/* Music */}
<View style={s.musicRow}>
<Icon name="music.note" size={14} tintColor={colors.text.hint} />
<RNText style={[s.musicText, { color: colors.text.tertiary }]}>
{t('screens:workout.musicMix', { vibe: musicVibeLabel })}
</RNText>
</View>
</ScrollView>
{/* CTA */}
<Animated.View
style={[
s.bottomBar,
{
backgroundColor: colors.bg.base,
paddingBottom: insets.bottom + SPACING[3],
opacity: ctaAnim,
transform: [
{
translateY: ctaAnim.interpolate({
inputRange: [0, 1],
outputRange: [30, 0],
}),
},
],
},
]}
>
<Pressable
testID={isLocked ? 'workout-unlock-button' : 'workout-start-button'}
style={({ pressed }) => [
s.ctaButton,
{ backgroundColor: isLocked ? ctaLockedBg : ctaBg },
isLocked && { borderWidth: 1, borderColor: colors.border.glass },
pressed && { opacity: 0.85, transform: [{ scale: 0.98 }] },
]}
onPress={handleStartWorkout}
>
{isLocked && (
<Icon name="lock.fill" size={16} color={ctaLockedText} style={{ marginRight: 8 }} />
)}
<RNText
testID="workout-cta-text"
style={[s.ctaText, { color: isLocked ? ctaLockedText : ctaText }]}
>
{isLocked ? t('screens:workout.unlockWithPremium') : t('screens:workout.startWorkout')}
</RNText>
</Pressable>
</Animated.View>
</View>
</View>
</>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// STYLES
// ═══════════════════════════════════════════════════════════════════════════
// ─── Styles ──────────────────────────────────────────────────────────────────
function createStyles(colors: ThemeColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bg.base,
},
centered: {
alignItems: 'center',
justifyContent: 'center',
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingHorizontal: LAYOUT.SCREEN_PADDING,
},
const s = StyleSheet.create({
container: {
flex: 1,
},
centered: {
alignItems: 'center',
justifyContent: 'center',
},
scrollContent: {
paddingHorizontal: LAYOUT.SCREEN_PADDING,
paddingTop: SPACING[2],
},
// Header
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: SPACING[4],
paddingBottom: SPACING[3],
},
headerTitle: {
flex: 1,
...TYPOGRAPHY.HEADLINE,
color: colors.text.primary,
marginRight: SPACING[3],
},
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',
},
// Media
mediaContainer: {
height: 200,
borderRadius: RADIUS.LG,
borderCurve: 'continuous',
overflow: 'hidden',
marginBottom: SPACING[4],
},
thumbnail: {
width: '100%',
height: '100%',
},
// Video Preview
videoPreview: {
height: 220,
borderRadius: RADIUS.XL,
overflow: 'hidden' as const,
marginBottom: SPACING[4],
},
// Title
title: {
...TYPOGRAPHY.TITLE_1,
marginBottom: SPACING[2],
},
// Quick Stats
quickStats: {
flexDirection: 'row',
gap: SPACING[2],
marginBottom: SPACING[5],
},
statBadge: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: SPACING[3],
paddingVertical: SPACING[2],
borderRadius: RADIUS.FULL,
gap: SPACING[1],
},
statBadgeText: {
...TYPOGRAPHY.CAPTION_1,
color: colors.text.secondary,
fontWeight: '600',
},
// Trainer
trainerName: {
...TYPOGRAPHY.SUBHEADLINE,
marginBottom: SPACING[3],
},
// Section
section: {
paddingVertical: SPACING[3],
},
sectionTitle: {
...TYPOGRAPHY.HEADLINE,
color: colors.text.primary,
marginBottom: SPACING[3],
},
// Metadata
metaRow: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
gap: SPACING[2],
marginBottom: SPACING[2],
},
metaItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
metaText: {
...TYPOGRAPHY.SUBHEADLINE,
},
metaDot: {
...TYPOGRAPHY.SUBHEADLINE,
},
// Divider
divider: {
height: 1,
backgroundColor: colors.border.glass,
marginVertical: SPACING[2],
},
// Equipment
equipmentText: {
...TYPOGRAPHY.FOOTNOTE,
marginBottom: SPACING[4],
},
// Equipment
equipmentItem: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING[3],
marginBottom: SPACING[2],
},
equipmentText: {
...TYPOGRAPHY.BODY,
color: colors.text.secondary,
},
// Separator
separator: {
height: StyleSheet.hairlineWidth,
marginBottom: SPACING[4],
},
// Exercises
exercisesList: {
gap: SPACING[2],
},
exerciseRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: SPACING[3],
paddingHorizontal: SPACING[4],
backgroundColor: colors.bg.surface,
borderRadius: RADIUS.LG,
gap: SPACING[3],
},
exerciseNumber: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: 'rgba(255, 107, 53, 0.15)',
alignItems: 'center',
justifyContent: 'center',
},
exerciseNumberText: {
...TYPOGRAPHY.CALLOUT,
color: BRAND.PRIMARY,
fontWeight: '700',
},
exerciseName: {
...TYPOGRAPHY.BODY,
color: colors.text.primary,
flex: 1,
},
exerciseDuration: {
...TYPOGRAPHY.CAPTION_1,
color: colors.text.tertiary,
},
repeatNote: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING[2],
marginTop: SPACING[2],
paddingHorizontal: SPACING[2],
},
repeatText: {
...TYPOGRAPHY.CAPTION_1,
color: colors.text.tertiary,
},
// Card
card: {
borderRadius: RADIUS.LG,
borderCurve: 'continuous',
overflow: 'hidden',
marginBottom: SPACING[4],
},
// Music
musicCard: {
flexDirection: 'row',
alignItems: 'center',
padding: SPACING[4],
backgroundColor: colors.bg.surface,
borderRadius: RADIUS.LG,
gap: SPACING[3],
},
musicIcon: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: 'rgba(255, 107, 53, 0.15)',
alignItems: 'center',
justifyContent: 'center',
},
musicInfo: {
flex: 1,
},
musicName: {
...TYPOGRAPHY.HEADLINE,
color: colors.text.primary,
},
musicDescription: {
...TYPOGRAPHY.CAPTION_1,
color: colors.text.tertiary,
marginTop: 2,
},
// Timing
timingRow: {
flexDirection: 'row',
paddingVertical: SPACING[4],
},
timingItem: {
flex: 1,
alignItems: 'center',
gap: 2,
},
timingDivider: {
width: StyleSheet.hairlineWidth,
alignSelf: 'stretch',
},
timingValue: {
...TYPOGRAPHY.HEADLINE,
fontVariant: ['tabular-nums'],
},
timingLabel: {
...TYPOGRAPHY.CAPTION_2,
textTransform: 'uppercase' as const,
letterSpacing: 0.5,
},
// Bottom Bar
bottomBar: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
paddingHorizontal: LAYOUT.SCREEN_PADDING,
paddingTop: SPACING[4],
borderTopWidth: 1,
borderTopColor: colors.border.glass,
},
// Section
sectionTitle: {
...TYPOGRAPHY.HEADLINE,
marginBottom: SPACING[3],
},
// Start Button
startButton: {
height: 56,
borderRadius: RADIUS.LG,
backgroundColor: BRAND.PRIMARY,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
},
lockedButton: {
backgroundColor: colors.bg.surface,
borderWidth: 1,
borderColor: BRAND.PRIMARY,
},
startButtonPressed: {
backgroundColor: BRAND.PRIMARY_DARK,
transform: [{ scale: 0.98 }],
},
startButtonText: {
...TYPOGRAPHY.HEADLINE,
color: '#FFFFFF',
letterSpacing: 1,
},
})
}
// Exercise
exerciseRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: SPACING[3],
paddingHorizontal: SPACING[4],
},
exerciseIndex: {
...TYPOGRAPHY.FOOTNOTE,
fontVariant: ['tabular-nums'],
width: 24,
},
exerciseName: {
...TYPOGRAPHY.BODY,
flex: 1,
},
exerciseDuration: {
...TYPOGRAPHY.SUBHEADLINE,
fontVariant: ['tabular-nums'],
marginLeft: SPACING[3],
},
exerciseSep: {
height: StyleSheet.hairlineWidth,
marginLeft: SPACING[4] + 24,
marginRight: SPACING[4],
},
repeatRow: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING[2],
marginTop: SPACING[2],
paddingLeft: 24,
},
repeatText: {
...TYPOGRAPHY.FOOTNOTE,
},
// Music
musicRow: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING[2],
marginTop: SPACING[5],
},
musicText: {
...TYPOGRAPHY.FOOTNOTE,
},
// Bottom bar
bottomBar: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
paddingHorizontal: LAYOUT.SCREEN_PADDING,
paddingTop: SPACING[3],
},
ctaButton: {
height: 54,
borderRadius: RADIUS.MD,
borderCurve: 'continuous',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
},
ctaText: {
...TYPOGRAPHY.BUTTON_LARGE,
},
})