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