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

@@ -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,
},
})