feat: workout flow — detail, player, and complete screens
Workout detail (workout/[id]): - Dynamic data via useLocalSearchParams + getWorkoutById - VideoPlayer hero with trainer color gradient fallback - Exercise list, equipment, music vibe, "Start Workout" CTA Player (player/[id]): - useTimer hook drives phase transitions (PREP/WORK/REST/COMPLETE) - useHaptics for phase changes and countdown ticks - useAudio for sound effects (beeps, dings, completion chime) - Real calorie tracking, progress ring, exercise display - Saves WorkoutResult to activityStore on completion Complete (complete/[id]): - Reads real stats from activityStore history - Burn bar, streak counter, calories/duration/completion stats - Recommended workouts, share via expo-sharing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
14
app/complete/CLAUDE.md
Normal file
14
app/complete/CLAUDE.md
Normal file
@@ -0,0 +1,14 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Feb 20, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #5047 | 8:22 AM | ✅ | Completed Host wrapper removal from all screens | ~241 |
|
||||
| #5046 | " | ✅ | Removed opening Host tag from workout complete screen | ~165 |
|
||||
| #5033 | 8:20 AM | ✅ | Removed Host import from workout complete screen | ~212 |
|
||||
| #5026 | 8:18 AM | 🔵 | Workout complete screen properly wraps content with Host component | ~252 |
|
||||
</claude-mem-context>
|
||||
626
app/complete/[id].tsx
Normal file
626
app/complete/[id].tsx
Normal file
@@ -0,0 +1,626 @@
|
||||
/**
|
||||
* TabataFit Workout Complete Screen
|
||||
* Celebration with real data from activity store
|
||||
*/
|
||||
|
||||
import { useRef, useEffect } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text as RNText,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
Pressable,
|
||||
Animated,
|
||||
Dimensions,
|
||||
} 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 Ionicons from '@expo/vector-icons/Ionicons'
|
||||
|
||||
import * as Sharing from 'expo-sharing'
|
||||
import { useHaptics } from '@/src/shared/hooks'
|
||||
import { useActivityStore } from '@/src/shared/stores'
|
||||
import { getWorkoutById, getTrainerById, getPopularWorkouts } from '@/src/shared/data'
|
||||
|
||||
import {
|
||||
BRAND,
|
||||
DARK,
|
||||
TEXT,
|
||||
GLASS,
|
||||
SHADOW,
|
||||
} from '@/src/shared/constants/colors'
|
||||
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
|
||||
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { SPRING, EASE } from '@/src/shared/constants/animations'
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window')
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// BUTTON COMPONENTS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function SecondaryButton({
|
||||
onPress,
|
||||
children,
|
||||
icon,
|
||||
}: {
|
||||
onPress: () => void
|
||||
children: React.ReactNode
|
||||
icon?: keyof typeof Ionicons.glyphMap
|
||||
}) {
|
||||
const scaleAnim = useRef(new Animated.Value(1)).current
|
||||
|
||||
const handlePressIn = () => {
|
||||
Animated.spring(scaleAnim, {
|
||||
toValue: 0.97,
|
||||
useNativeDriver: true,
|
||||
...SPRING.SNAPPY,
|
||||
}).start()
|
||||
}
|
||||
|
||||
const handlePressOut = () => {
|
||||
Animated.spring(scaleAnim, {
|
||||
toValue: 1,
|
||||
useNativeDriver: true,
|
||||
...SPRING.SNAPPY,
|
||||
}).start()
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Animated.View style={[styles.secondaryButton, { transform: [{ scale: scaleAnim }] }]}>
|
||||
{icon && <Ionicons name={icon} size={18} color={TEXT.PRIMARY} style={styles.buttonIcon} />}
|
||||
<RNText style={styles.secondaryButtonText}>{children}</RNText>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
function PrimaryButton({
|
||||
onPress,
|
||||
children,
|
||||
}: {
|
||||
onPress: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const scaleAnim = useRef(new Animated.Value(1)).current
|
||||
|
||||
const handlePressIn = () => {
|
||||
Animated.spring(scaleAnim, {
|
||||
toValue: 0.97,
|
||||
useNativeDriver: true,
|
||||
...SPRING.SNAPPY,
|
||||
}).start()
|
||||
}
|
||||
|
||||
const handlePressOut = () => {
|
||||
Animated.spring(scaleAnim, {
|
||||
toValue: 1,
|
||||
useNativeDriver: true,
|
||||
...SPRING.SNAPPY,
|
||||
}).start()
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onPressIn={handlePressIn}
|
||||
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>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// COMPONENTS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function CelebrationRings() {
|
||||
const ring1Anim = useRef(new Animated.Value(0)).current
|
||||
const ring2Anim = useRef(new Animated.Value(0)).current
|
||||
const ring3Anim = useRef(new Animated.Value(0)).current
|
||||
|
||||
useEffect(() => {
|
||||
Animated.stagger(200, [
|
||||
Animated.spring(ring1Anim, {
|
||||
toValue: 1,
|
||||
...SPRING.BOUNCY,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.spring(ring2Anim, {
|
||||
toValue: 1,
|
||||
...SPRING.BOUNCY,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.spring(ring3Anim, {
|
||||
toValue: 1,
|
||||
...SPRING.BOUNCY,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<View style={styles.ringsContainer}>
|
||||
<Animated.View
|
||||
style={[styles.ring, styles.ring1, { transform: [{ scale: ring1Anim }], opacity: ring1Anim }]}
|
||||
>
|
||||
<RNText style={styles.ringEmoji}>🔥</RNText>
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
style={[styles.ring, styles.ring2, { transform: [{ scale: ring2Anim }], opacity: ring2Anim }]}
|
||||
>
|
||||
<RNText style={styles.ringEmoji}>💪</RNText>
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
style={[styles.ring, styles.ring3, { transform: [{ scale: ring3Anim }], opacity: ring3Anim }]}
|
||||
>
|
||||
<RNText style={styles.ringEmoji}>⚡</RNText>
|
||||
</Animated.View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
value,
|
||||
label,
|
||||
icon,
|
||||
delay = 0,
|
||||
}: {
|
||||
value: string | number
|
||||
label: string
|
||||
icon: keyof typeof Ionicons.glyphMap
|
||||
delay?: number
|
||||
}) {
|
||||
const scaleAnim = useRef(new Animated.Value(0)).current
|
||||
|
||||
useEffect(() => {
|
||||
Animated.sequence([
|
||||
Animated.delay(delay),
|
||||
Animated.spring(scaleAnim, {
|
||||
toValue: 1,
|
||||
...SPRING.BOUNCY,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start()
|
||||
}, [delay])
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.statCard, { transform: [{ scale: scaleAnim }] }]}>
|
||||
<BlurView intensity={GLASS.BLUR_MEDIUM} tint="dark" style={StyleSheet.absoluteFill} />
|
||||
<Ionicons name={icon} size={24} color={BRAND.PRIMARY} />
|
||||
<RNText style={styles.statValue}>{value}</RNText>
|
||||
<RNText style={styles.statLabel}>{label}</RNText>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
function BurnBarResult({ percentile }: { percentile: number }) {
|
||||
const barAnim = useRef(new Animated.Value(0)).current
|
||||
|
||||
useEffect(() => {
|
||||
Animated.timing(barAnim, {
|
||||
toValue: percentile,
|
||||
duration: 1000,
|
||||
easing: EASE.EASE_OUT,
|
||||
useNativeDriver: false,
|
||||
}).start()
|
||||
}, [percentile])
|
||||
|
||||
const barWidth = barAnim.interpolate({
|
||||
inputRange: [0, 100],
|
||||
outputRange: ['0%', '100%'],
|
||||
})
|
||||
|
||||
return (
|
||||
<View style={styles.burnBarContainer}>
|
||||
<RNText style={styles.burnBarTitle}>Burn Bar</RNText>
|
||||
<RNText style={styles.burnBarResult}>You beat {percentile}% of users!</RNText>
|
||||
<View style={styles.burnBarTrack}>
|
||||
<Animated.View style={[styles.burnBarFill, { width: barWidth }]} />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// MAIN SCREEN
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export default function WorkoutCompleteScreen() {
|
||||
const insets = useSafeAreaInsets()
|
||||
const router = useRouter()
|
||||
const haptics = useHaptics()
|
||||
const { id } = useLocalSearchParams<{ id: string }>()
|
||||
|
||||
const workout = getWorkoutById(id ?? '1')
|
||||
const streak = useActivityStore((s) => s.streak)
|
||||
const history = useActivityStore((s) => s.history)
|
||||
const recentWorkouts = history.slice(0, 1)
|
||||
|
||||
// Get the most recent result for this workout
|
||||
const latestResult = recentWorkouts[0]
|
||||
const resultCalories = latestResult?.calories ?? workout?.calories ?? 45
|
||||
const resultMinutes = latestResult?.durationMinutes ?? workout?.duration ?? 4
|
||||
|
||||
// Recommended workouts (different from current)
|
||||
const recommended = getPopularWorkouts(4).filter(w => w.id !== id).slice(0, 3)
|
||||
|
||||
const handleGoHome = () => {
|
||||
haptics.buttonTap()
|
||||
router.replace('/(tabs)')
|
||||
}
|
||||
|
||||
const handleShare = async () => {
|
||||
haptics.selection()
|
||||
const isAvailable = await Sharing.isAvailableAsync()
|
||||
if (isAvailable) {
|
||||
await Sharing.shareAsync('https://tabatafit.app', {
|
||||
dialogTitle: `I just completed ${workout?.title ?? 'a workout'}! 🔥 ${resultCalories} calories in ${resultMinutes} minutes.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleWorkoutPress = (workoutId: string) => {
|
||||
haptics.buttonTap()
|
||||
router.push(`/workout/${workoutId}`)
|
||||
}
|
||||
|
||||
// Simulate percentile
|
||||
const burnBarPercentile = Math.min(95, Math.max(40, Math.round((resultCalories / (workout?.calories ?? 45)) * 70)))
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 100 }]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Celebration */}
|
||||
<View style={styles.celebrationSection}>
|
||||
<RNText style={styles.celebrationEmoji}>🎉</RNText>
|
||||
<RNText style={styles.celebrationTitle}>WORKOUT COMPLETE</RNText>
|
||||
<CelebrationRings />
|
||||
</View>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<View style={styles.statsGrid}>
|
||||
<StatCard value={resultCalories} label="CALORIES" icon="flame" delay={100} />
|
||||
<StatCard value={resultMinutes} label="MINUTES" icon="time" delay={200} />
|
||||
<StatCard value="100%" label="COMPLETE" icon="checkmark-circle" delay={300} />
|
||||
</View>
|
||||
|
||||
{/* Burn Bar */}
|
||||
<BurnBarResult percentile={burnBarPercentile} />
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
{/* Streak */}
|
||||
<View style={styles.streakSection}>
|
||||
<View style={styles.streakBadge}>
|
||||
<Ionicons name="flame" size={32} color={BRAND.PRIMARY} />
|
||||
</View>
|
||||
<View style={styles.streakInfo}>
|
||||
<RNText style={styles.streakTitle}>{streak.current} Day Streak!</RNText>
|
||||
<RNText style={styles.streakSubtitle}>Keep the momentum going!</RNText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
{/* Share Button */}
|
||||
<View style={styles.shareSection}>
|
||||
<SecondaryButton onPress={handleShare} icon="share-outline">
|
||||
Share Your Workout
|
||||
</SecondaryButton>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
{/* Recommended */}
|
||||
<View style={styles.recommendedSection}>
|
||||
<RNText style={styles.recommendedTitle}>Recommended Next</RNText>
|
||||
<View style={styles.recommendedGrid}>
|
||||
{recommended.map((w) => {
|
||||
const trainer = getTrainerById(w.trainerId)
|
||||
return (
|
||||
<Pressable
|
||||
key={w.id}
|
||||
onPress={() => handleWorkoutPress(w.id)}
|
||||
style={styles.recommendedCard}
|
||||
>
|
||||
<BlurView intensity={GLASS.BLUR_LIGHT} tint="dark" style={StyleSheet.absoluteFill} />
|
||||
<View style={styles.recommendedThumb}>
|
||||
<LinearGradient
|
||||
colors={[trainer?.color ?? BRAND.PRIMARY, BRAND.PRIMARY_LIGHT]}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<RNText style={styles.recommendedInitial}>{trainer?.name[0] ?? 'T'}</RNText>
|
||||
</View>
|
||||
<RNText style={styles.recommendedTitleText} numberOfLines={1}>{w.title}</RNText>
|
||||
<RNText style={styles.recommendedDurationText}>{w.duration} min</RNText>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Fixed Bottom Button */}
|
||||
<View style={[styles.bottomBar, { paddingBottom: insets.bottom + SPACING[4] }]}>
|
||||
<BlurView intensity={GLASS.BLUR_HEAVY} tint="dark" style={StyleSheet.absoluteFill} />
|
||||
<View style={styles.homeButtonContainer}>
|
||||
<PrimaryButton onPress={handleGoHome}>
|
||||
Back to Home
|
||||
</PrimaryButton>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// STYLES
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: DARK.BASE,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
},
|
||||
|
||||
// Buttons
|
||||
secondaryButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: SPACING[3],
|
||||
paddingHorizontal: SPACING[4],
|
||||
borderRadius: RADIUS.LG,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
secondaryButtonText: {
|
||||
...TYPOGRAPHY.BODY,
|
||||
color: TEXT.PRIMARY,
|
||||
fontWeight: '600',
|
||||
},
|
||||
primaryButton: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: SPACING[4],
|
||||
paddingHorizontal: SPACING[6],
|
||||
borderRadius: RADIUS.LG,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
primaryButtonText: {
|
||||
...TYPOGRAPHY.HEADLINE,
|
||||
color: TEXT.PRIMARY,
|
||||
fontWeight: '700',
|
||||
},
|
||||
buttonIcon: {
|
||||
marginRight: SPACING[2],
|
||||
},
|
||||
|
||||
// Celebration
|
||||
celebrationSection: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: SPACING[8],
|
||||
},
|
||||
celebrationEmoji: {
|
||||
fontSize: 64,
|
||||
marginBottom: SPACING[4],
|
||||
},
|
||||
celebrationTitle: {
|
||||
...TYPOGRAPHY.TITLE_1,
|
||||
color: TEXT.PRIMARY,
|
||||
letterSpacing: 2,
|
||||
},
|
||||
ringsContainer: {
|
||||
flexDirection: 'row',
|
||||
marginTop: SPACING[6],
|
||||
gap: SPACING[4],
|
||||
},
|
||||
ring: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 2,
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
},
|
||||
ring1: {
|
||||
borderColor: BRAND.PRIMARY,
|
||||
backgroundColor: 'rgba(255, 107, 53, 0.15)',
|
||||
},
|
||||
ring2: {
|
||||
borderColor: '#30D158',
|
||||
backgroundColor: 'rgba(48, 209, 88, 0.15)',
|
||||
},
|
||||
ring3: {
|
||||
borderColor: '#5AC8FA',
|
||||
backgroundColor: 'rgba(90, 200, 250, 0.15)',
|
||||
},
|
||||
ringEmoji: {
|
||||
fontSize: 28,
|
||||
},
|
||||
|
||||
// Stats Grid
|
||||
statsGrid: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: SPACING[6],
|
||||
},
|
||||
statCard: {
|
||||
width: (SCREEN_WIDTH - LAYOUT.SCREEN_PADDING * 2 - SPACING[4]) / 3,
|
||||
padding: SPACING[3],
|
||||
borderRadius: RADIUS.LG,
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
statValue: {
|
||||
...TYPOGRAPHY.TITLE_1,
|
||||
color: TEXT.PRIMARY,
|
||||
marginTop: SPACING[2],
|
||||
},
|
||||
statLabel: {
|
||||
...TYPOGRAPHY.CAPTION_2,
|
||||
color: TEXT.TERTIARY,
|
||||
marginTop: SPACING[1],
|
||||
},
|
||||
|
||||
// Burn Bar
|
||||
burnBarContainer: {
|
||||
marginBottom: SPACING[6],
|
||||
},
|
||||
burnBarTitle: {
|
||||
...TYPOGRAPHY.HEADLINE,
|
||||
color: TEXT.PRIMARY,
|
||||
},
|
||||
burnBarResult: {
|
||||
...TYPOGRAPHY.BODY,
|
||||
color: BRAND.PRIMARY,
|
||||
marginTop: SPACING[1],
|
||||
marginBottom: SPACING[3],
|
||||
},
|
||||
burnBarTrack: {
|
||||
height: 8,
|
||||
backgroundColor: DARK.SURFACE,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
burnBarFill: {
|
||||
height: '100%',
|
||||
backgroundColor: BRAND.PRIMARY,
|
||||
borderRadius: 4,
|
||||
},
|
||||
|
||||
// Divider
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
marginVertical: SPACING[2],
|
||||
},
|
||||
|
||||
// Streak
|
||||
streakSection: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: SPACING[4],
|
||||
gap: SPACING[4],
|
||||
},
|
||||
streakBadge: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
backgroundColor: 'rgba(255, 107, 53, 0.15)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
streakInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
streakTitle: {
|
||||
...TYPOGRAPHY.TITLE_2,
|
||||
color: TEXT.PRIMARY,
|
||||
},
|
||||
streakSubtitle: {
|
||||
...TYPOGRAPHY.BODY,
|
||||
color: TEXT.TERTIARY,
|
||||
marginTop: SPACING[1],
|
||||
},
|
||||
|
||||
// Share
|
||||
shareSection: {
|
||||
paddingVertical: SPACING[4],
|
||||
alignItems: 'center',
|
||||
},
|
||||
|
||||
// Recommended
|
||||
recommendedSection: {
|
||||
paddingVertical: SPACING[4],
|
||||
},
|
||||
recommendedTitle: {
|
||||
...TYPOGRAPHY.HEADLINE,
|
||||
color: TEXT.PRIMARY,
|
||||
marginBottom: SPACING[4],
|
||||
},
|
||||
recommendedGrid: {
|
||||
flexDirection: 'row',
|
||||
gap: SPACING[3],
|
||||
},
|
||||
recommendedCard: {
|
||||
flex: 1,
|
||||
padding: SPACING[3],
|
||||
borderRadius: RADIUS.LG,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
recommendedThumb: {
|
||||
width: '100%',
|
||||
aspectRatio: 1,
|
||||
borderRadius: RADIUS.MD,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: SPACING[2],
|
||||
overflow: 'hidden',
|
||||
},
|
||||
recommendedInitial: {
|
||||
...TYPOGRAPHY.TITLE_1,
|
||||
color: TEXT.PRIMARY,
|
||||
},
|
||||
recommendedTitleText: {
|
||||
...TYPOGRAPHY.CARD_TITLE,
|
||||
color: TEXT.PRIMARY,
|
||||
},
|
||||
recommendedDurationText: {
|
||||
...TYPOGRAPHY.CARD_METADATA,
|
||||
color: TEXT.TERTIARY,
|
||||
},
|
||||
|
||||
// Bottom Bar
|
||||
bottomBar: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
paddingTop: SPACING[4],
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
homeButtonContainer: {
|
||||
height: 56,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
})
|
||||
12
app/player/CLAUDE.md
Normal file
12
app/player/CLAUDE.md
Normal file
@@ -0,0 +1,12 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Feb 19, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #5000 | 9:35 AM | 🔵 | Reviewed Player Screen Implementation | ~522 |
|
||||
| #4912 | 8:16 AM | 🔵 | Found doneButton component in player screen | ~104 |
|
||||
</claude-mem-context>
|
||||
756
app/player/[id].tsx
Normal file
756
app/player/[id].tsx
Normal file
@@ -0,0 +1,756 @@
|
||||
/**
|
||||
* TabataFit Player Screen
|
||||
* Full-screen workout player with timer overlay
|
||||
* Wired to shared data + useTimer hook
|
||||
*/
|
||||
|
||||
import React, { useRef, useEffect, useCallback, useState } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
Animated,
|
||||
Dimensions,
|
||||
StatusBar,
|
||||
} 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 { useKeepAwake } from 'expo-keep-awake'
|
||||
import Ionicons from '@expo/vector-icons/Ionicons'
|
||||
|
||||
import { useTimer } from '@/src/shared/hooks/useTimer'
|
||||
import { useHaptics } from '@/src/shared/hooks/useHaptics'
|
||||
import { useAudio } from '@/src/shared/hooks/useAudio'
|
||||
import { useActivityStore } from '@/src/shared/stores'
|
||||
import { getWorkoutById, getTrainerById } from '@/src/shared/data'
|
||||
|
||||
import {
|
||||
BRAND,
|
||||
DARK,
|
||||
TEXT,
|
||||
GLASS,
|
||||
SHADOW,
|
||||
PHASE_COLORS,
|
||||
GRADIENTS,
|
||||
} from '@/src/shared/constants/colors'
|
||||
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { DURATION, EASE, SPRING } from '@/src/shared/constants/animations'
|
||||
|
||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window')
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// COMPONENTS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
type TimerPhase = 'PREP' | 'WORK' | 'REST' | 'COMPLETE'
|
||||
|
||||
function TimerRing({
|
||||
progress,
|
||||
phase,
|
||||
size = 280,
|
||||
}: {
|
||||
progress: number
|
||||
phase: TimerPhase
|
||||
size?: number
|
||||
}) {
|
||||
const strokeWidth = 12
|
||||
const phaseColor = PHASE_COLORS[phase].fill
|
||||
|
||||
return (
|
||||
<View style={[timerStyles.timerRingContainer, { width: size, height: size }]}>
|
||||
<View
|
||||
style={[
|
||||
timerStyles.timerRingBg,
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
borderWidth: strokeWidth,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<View style={timerStyles.timerRingContent}>
|
||||
<View
|
||||
style={[
|
||||
timerStyles.timerProgressRing,
|
||||
{
|
||||
width: size - strokeWidth * 2,
|
||||
height: size - strokeWidth * 2,
|
||||
borderRadius: (size - strokeWidth * 2) / 2,
|
||||
borderColor: phaseColor,
|
||||
borderTopWidth: strokeWidth,
|
||||
opacity: progress,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function PhaseIndicator({ phase }: { phase: TimerPhase }) {
|
||||
const phaseColor = PHASE_COLORS[phase].fill
|
||||
const phaseLabels: Record<TimerPhase, string> = {
|
||||
PREP: 'GET READY',
|
||||
WORK: 'WORK',
|
||||
REST: 'REST',
|
||||
COMPLETE: 'COMPLETE',
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[timerStyles.phaseIndicator, { backgroundColor: `${phaseColor}20` }]}>
|
||||
<Text style={[timerStyles.phaseText, { color: phaseColor }]}>{phaseLabels[phase]}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function ExerciseDisplay({
|
||||
exercise,
|
||||
nextExercise,
|
||||
}: {
|
||||
exercise: string
|
||||
nextExercise?: string
|
||||
}) {
|
||||
return (
|
||||
<View style={timerStyles.exerciseDisplay}>
|
||||
<Text style={timerStyles.currentExerciseLabel}>Current</Text>
|
||||
<Text style={timerStyles.currentExercise}>{exercise}</Text>
|
||||
{nextExercise && (
|
||||
<View style={timerStyles.nextExerciseContainer}>
|
||||
<Text style={timerStyles.nextExerciseLabel}>Next: </Text>
|
||||
<Text style={timerStyles.nextExercise}>{nextExercise}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function RoundIndicator({ current, total }: { current: number; total: number }) {
|
||||
return (
|
||||
<View style={timerStyles.roundIndicator}>
|
||||
<Text style={timerStyles.roundText}>
|
||||
Round <Text style={timerStyles.roundCurrent}>{current}</Text>/{total}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function ControlButton({
|
||||
icon,
|
||||
onPress,
|
||||
size = 64,
|
||||
variant = 'primary',
|
||||
}: {
|
||||
icon: keyof typeof Ionicons.glyphMap
|
||||
onPress: () => void
|
||||
size?: number
|
||||
variant?: 'primary' | 'secondary' | 'danger'
|
||||
}) {
|
||||
const scaleAnim = useRef(new Animated.Value(1)).current
|
||||
|
||||
const handlePressIn = () => {
|
||||
Animated.spring(scaleAnim, {
|
||||
toValue: 0.9,
|
||||
...SPRING.SNAPPY,
|
||||
useNativeDriver: true,
|
||||
}).start()
|
||||
}
|
||||
|
||||
const handlePressOut = () => {
|
||||
Animated.spring(scaleAnim, {
|
||||
toValue: 1,
|
||||
...SPRING.BOUNCY,
|
||||
useNativeDriver: true,
|
||||
}).start()
|
||||
}
|
||||
|
||||
const backgroundColor =
|
||||
variant === 'primary'
|
||||
? BRAND.PRIMARY
|
||||
: variant === 'danger'
|
||||
? '#FF3B30'
|
||||
: 'rgba(255, 255, 255, 0.1)'
|
||||
|
||||
return (
|
||||
<Animated.View style={{ transform: [{ scale: scaleAnim }] }}>
|
||||
<Pressable
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
onPress={onPress}
|
||||
style={[timerStyles.controlButton, { width: size, height: size, borderRadius: size / 2 }]}
|
||||
>
|
||||
<View style={[timerStyles.controlButtonBg, { backgroundColor }]} />
|
||||
<Ionicons name={icon} size={size * 0.4} color={TEXT.PRIMARY} />
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
function BurnBar({
|
||||
currentCalories,
|
||||
avgCalories,
|
||||
}: {
|
||||
currentCalories: number
|
||||
avgCalories: number
|
||||
}) {
|
||||
const percentage = Math.min((currentCalories / avgCalories) * 100, 100)
|
||||
|
||||
return (
|
||||
<View style={timerStyles.burnBar}>
|
||||
<View style={timerStyles.burnBarHeader}>
|
||||
<Text style={timerStyles.burnBarLabel}>Burn Bar</Text>
|
||||
<Text style={timerStyles.burnBarValue}>{currentCalories} cal</Text>
|
||||
</View>
|
||||
<View style={timerStyles.burnBarTrack}>
|
||||
<View style={[timerStyles.burnBarFill, { width: `${percentage}%` }]} />
|
||||
<View style={[timerStyles.burnBarAvg, { left: '50%' }]} />
|
||||
</View>
|
||||
<Text style={timerStyles.burnBarAvgLabel}>Community avg: {avgCalories} cal</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// MAIN SCREEN
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export default function PlayerScreen() {
|
||||
useKeepAwake()
|
||||
const router = useRouter()
|
||||
const { id } = useLocalSearchParams<{ id: string }>()
|
||||
const insets = useSafeAreaInsets()
|
||||
const haptics = useHaptics()
|
||||
const addWorkoutResult = useActivityStore((s) => s.addWorkoutResult)
|
||||
|
||||
const workout = getWorkoutById(id ?? '1')
|
||||
const trainer = workout ? getTrainerById(workout.trainerId) : null
|
||||
|
||||
const timer = useTimer(workout ?? null)
|
||||
const audio = useAudio()
|
||||
|
||||
const [showControls, setShowControls] = useState(true)
|
||||
|
||||
// Animation refs
|
||||
const timerScaleAnim = useRef(new Animated.Value(0.8)).current
|
||||
const glowAnim = useRef(new Animated.Value(0)).current
|
||||
|
||||
const phaseColor = PHASE_COLORS[timer.phase].fill
|
||||
|
||||
// Format time
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return mins > 0 ? `${mins}:${secs.toString().padStart(2, '0')}` : `${secs}`
|
||||
}
|
||||
|
||||
// Start timer
|
||||
const startTimer = useCallback(() => {
|
||||
timer.start()
|
||||
haptics.buttonTap()
|
||||
}, [timer, haptics])
|
||||
|
||||
// Pause/Resume
|
||||
const togglePause = useCallback(() => {
|
||||
if (timer.isPaused) {
|
||||
timer.resume()
|
||||
} else {
|
||||
timer.pause()
|
||||
}
|
||||
haptics.selection()
|
||||
}, [timer, haptics])
|
||||
|
||||
// Stop workout
|
||||
const stopWorkout = useCallback(() => {
|
||||
haptics.phaseChange()
|
||||
timer.stop()
|
||||
router.back()
|
||||
}, [router, timer, haptics])
|
||||
|
||||
// Complete workout - go to celebration screen
|
||||
const completeWorkout = useCallback(() => {
|
||||
haptics.workoutComplete()
|
||||
if (workout) {
|
||||
addWorkoutResult({
|
||||
id: Date.now().toString(),
|
||||
workoutId: workout.id,
|
||||
completedAt: Date.now(),
|
||||
calories: timer.calories,
|
||||
durationMinutes: workout.duration,
|
||||
rounds: workout.rounds,
|
||||
completionRate: 1,
|
||||
})
|
||||
}
|
||||
router.replace(`/complete/${workout?.id ?? '1'}`)
|
||||
}, [router, workout, timer.calories, haptics, addWorkoutResult])
|
||||
|
||||
// Skip
|
||||
const handleSkip = useCallback(() => {
|
||||
timer.skip()
|
||||
haptics.selection()
|
||||
}, [timer, haptics])
|
||||
|
||||
// Toggle controls visibility
|
||||
const toggleControls = useCallback(() => {
|
||||
setShowControls(s => !s)
|
||||
}, [])
|
||||
|
||||
// Entrance animation
|
||||
useEffect(() => {
|
||||
Animated.parallel([
|
||||
Animated.spring(timerScaleAnim, {
|
||||
toValue: 1,
|
||||
friction: 6,
|
||||
tension: 100,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(glowAnim, {
|
||||
toValue: 1,
|
||||
duration: DURATION.BREATH,
|
||||
easing: EASE.EASE_IN_OUT,
|
||||
useNativeDriver: false,
|
||||
}),
|
||||
Animated.timing(glowAnim, {
|
||||
toValue: 0,
|
||||
duration: DURATION.BREATH,
|
||||
easing: EASE.EASE_IN_OUT,
|
||||
useNativeDriver: false,
|
||||
}),
|
||||
])
|
||||
),
|
||||
]).start()
|
||||
}, [])
|
||||
|
||||
// Phase change animation + audio
|
||||
useEffect(() => {
|
||||
timerScaleAnim.setValue(0.9)
|
||||
Animated.spring(timerScaleAnim, {
|
||||
toValue: 1,
|
||||
friction: 4,
|
||||
tension: 150,
|
||||
useNativeDriver: true,
|
||||
}).start()
|
||||
haptics.phaseChange()
|
||||
if (timer.phase === 'COMPLETE') {
|
||||
audio.workoutComplete()
|
||||
} else if (timer.isRunning) {
|
||||
audio.phaseStart()
|
||||
}
|
||||
}, [timer.phase])
|
||||
|
||||
// Countdown beep for last 3 seconds
|
||||
useEffect(() => {
|
||||
if (timer.isRunning && timer.timeRemaining <= 3 && timer.timeRemaining > 0) {
|
||||
audio.countdownBeep()
|
||||
}
|
||||
}, [timer.timeRemaining])
|
||||
|
||||
const glowOpacity = glowAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0.3, 0.6],
|
||||
})
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<StatusBar hidden />
|
||||
|
||||
{/* Background gradient */}
|
||||
<LinearGradient
|
||||
colors={[DARK.BASE, DARK.SURFACE]}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
|
||||
{/* Phase glow */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.phaseGlow,
|
||||
{ opacity: glowOpacity, backgroundColor: phaseColor },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Main content */}
|
||||
<Pressable style={styles.content} onPress={toggleControls}>
|
||||
{/* Header */}
|
||||
{showControls && (
|
||||
<View style={[styles.header, { paddingTop: insets.top + SPACING[4] }]}>
|
||||
<Pressable onPress={stopWorkout} style={styles.closeButton}>
|
||||
<BlurView intensity={GLASS.BLUR_LIGHT} tint="dark" style={StyleSheet.absoluteFill} />
|
||||
<Ionicons name="close" size={24} color={TEXT.PRIMARY} />
|
||||
</Pressable>
|
||||
<View style={styles.headerCenter}>
|
||||
<Text style={styles.workoutTitle}>{workout?.title ?? 'Workout'}</Text>
|
||||
<Text style={styles.workoutTrainer}>with {trainer?.name ?? 'Coach'}</Text>
|
||||
</View>
|
||||
<View style={styles.closeButton} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Timer */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.timerContainer,
|
||||
{ transform: [{ scale: timerScaleAnim }] },
|
||||
]}
|
||||
>
|
||||
<TimerRing progress={timer.progress} phase={timer.phase} />
|
||||
|
||||
<View style={timerStyles.timerTextContainer}>
|
||||
<PhaseIndicator phase={timer.phase} />
|
||||
<Text style={timerStyles.timerTime}>{formatTime(timer.timeRemaining)}</Text>
|
||||
<RoundIndicator current={timer.currentRound} total={timer.totalRounds} />
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/* Exercise */}
|
||||
{!timer.isComplete && (
|
||||
<ExerciseDisplay
|
||||
exercise={timer.currentExercise}
|
||||
nextExercise={timer.nextExercise}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Complete state */}
|
||||
{timer.isComplete && (
|
||||
<View style={styles.completeContainer}>
|
||||
<Text style={styles.completeTitle}>Workout Complete!</Text>
|
||||
<Text style={styles.completeSubtitle}>Great job!</Text>
|
||||
<View style={styles.completeStats}>
|
||||
<View style={styles.completeStat}>
|
||||
<Text style={styles.completeStatValue}>{timer.totalRounds}</Text>
|
||||
<Text style={styles.completeStatLabel}>Rounds</Text>
|
||||
</View>
|
||||
<View style={styles.completeStat}>
|
||||
<Text style={styles.completeStatValue}>{timer.calories}</Text>
|
||||
<Text style={styles.completeStatLabel}>Calories</Text>
|
||||
</View>
|
||||
<View style={styles.completeStat}>
|
||||
<Text style={styles.completeStatValue}>{workout?.duration ?? 4}</Text>
|
||||
<Text style={styles.completeStatLabel}>Minutes</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
{showControls && !timer.isComplete && (
|
||||
<View style={[styles.controls, { paddingBottom: insets.bottom + SPACING[6] }]}>
|
||||
{!timer.isRunning ? (
|
||||
<ControlButton icon="play" onPress={startTimer} size={80} />
|
||||
) : (
|
||||
<View style={styles.controlsRow}>
|
||||
<ControlButton
|
||||
icon="stop"
|
||||
onPress={stopWorkout}
|
||||
size={56}
|
||||
variant="danger"
|
||||
/>
|
||||
<ControlButton
|
||||
icon={timer.isPaused ? 'play' : 'pause'}
|
||||
onPress={togglePause}
|
||||
size={80}
|
||||
/>
|
||||
<ControlButton
|
||||
icon="play-skip-forward"
|
||||
onPress={handleSkip}
|
||||
size={56}
|
||||
variant="secondary"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Complete button */}
|
||||
{timer.isComplete && (
|
||||
<View style={[styles.controls, { paddingBottom: insets.bottom + SPACING[6] }]}>
|
||||
<Pressable style={styles.doneButton} onPress={completeWorkout}>
|
||||
<LinearGradient
|
||||
colors={GRADIENTS.CTA}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<Text style={styles.doneButtonText}>Done</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Burn Bar */}
|
||||
{showControls && timer.isRunning && !timer.isComplete && (
|
||||
<View style={[styles.burnBarContainer, { bottom: insets.bottom + 140 }]}>
|
||||
<BlurView intensity={GLASS.BLUR_MEDIUM} tint="dark" style={StyleSheet.absoluteFill} />
|
||||
<BurnBar currentCalories={timer.calories} avgCalories={workout?.calories ?? 45} />
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// STYLES
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const timerStyles = StyleSheet.create({
|
||||
timerRingContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
timerRingBg: {
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
position: 'absolute',
|
||||
},
|
||||
timerRingContent: {
|
||||
position: 'absolute',
|
||||
},
|
||||
timerProgressRing: {
|
||||
position: 'absolute',
|
||||
},
|
||||
timerTextContainer: {
|
||||
position: 'absolute',
|
||||
alignItems: 'center',
|
||||
},
|
||||
phaseIndicator: {
|
||||
paddingHorizontal: SPACING[4],
|
||||
paddingVertical: SPACING[1],
|
||||
borderRadius: RADIUS.FULL,
|
||||
marginBottom: SPACING[2],
|
||||
},
|
||||
phaseText: {
|
||||
...TYPOGRAPHY.CALLOUT,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 1,
|
||||
},
|
||||
timerTime: {
|
||||
...TYPOGRAPHY.TIMER_NUMBER,
|
||||
color: TEXT.PRIMARY,
|
||||
},
|
||||
roundIndicator: {
|
||||
marginTop: SPACING[2],
|
||||
},
|
||||
roundText: {
|
||||
...TYPOGRAPHY.BODY,
|
||||
color: TEXT.TERTIARY,
|
||||
},
|
||||
roundCurrent: {
|
||||
color: TEXT.PRIMARY,
|
||||
fontWeight: '700',
|
||||
},
|
||||
|
||||
// Exercise
|
||||
exerciseDisplay: {
|
||||
alignItems: 'center',
|
||||
marginTop: SPACING[6],
|
||||
paddingHorizontal: SPACING[6],
|
||||
},
|
||||
currentExerciseLabel: {
|
||||
...TYPOGRAPHY.CAPTION_1,
|
||||
color: TEXT.TERTIARY,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
},
|
||||
currentExercise: {
|
||||
...TYPOGRAPHY.TITLE_1,
|
||||
color: TEXT.PRIMARY,
|
||||
textAlign: 'center',
|
||||
marginTop: SPACING[1],
|
||||
},
|
||||
nextExerciseContainer: {
|
||||
flexDirection: 'row',
|
||||
marginTop: SPACING[2],
|
||||
},
|
||||
nextExerciseLabel: {
|
||||
...TYPOGRAPHY.BODY,
|
||||
color: TEXT.TERTIARY,
|
||||
},
|
||||
nextExercise: {
|
||||
...TYPOGRAPHY.BODY,
|
||||
color: BRAND.PRIMARY,
|
||||
},
|
||||
|
||||
// Controls
|
||||
controlButton: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
controlButtonBg: {
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: 100,
|
||||
},
|
||||
|
||||
// Burn Bar
|
||||
burnBar: {},
|
||||
burnBarHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: SPACING[2],
|
||||
},
|
||||
burnBarLabel: {
|
||||
...TYPOGRAPHY.CAPTION_1,
|
||||
color: TEXT.TERTIARY,
|
||||
},
|
||||
burnBarValue: {
|
||||
...TYPOGRAPHY.CALLOUT,
|
||||
color: BRAND.PRIMARY,
|
||||
fontWeight: '600',
|
||||
},
|
||||
burnBarTrack: {
|
||||
height: 6,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
burnBarFill: {
|
||||
height: '100%',
|
||||
backgroundColor: BRAND.PRIMARY,
|
||||
borderRadius: 3,
|
||||
},
|
||||
burnBarAvg: {
|
||||
position: 'absolute',
|
||||
top: -2,
|
||||
width: 2,
|
||||
height: 10,
|
||||
backgroundColor: TEXT.TERTIARY,
|
||||
},
|
||||
burnBarAvgLabel: {
|
||||
...TYPOGRAPHY.CAPTION_2,
|
||||
color: TEXT.TERTIARY,
|
||||
marginTop: SPACING[1],
|
||||
textAlign: 'right',
|
||||
},
|
||||
})
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: DARK.BASE,
|
||||
},
|
||||
phaseGlow: {
|
||||
position: 'absolute',
|
||||
top: -100,
|
||||
left: -100,
|
||||
right: -100,
|
||||
bottom: -100,
|
||||
borderRadius: 500,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
// Header
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: SPACING[4],
|
||||
},
|
||||
closeButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
headerCenter: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
workoutTitle: {
|
||||
...TYPOGRAPHY.HEADLINE,
|
||||
color: TEXT.PRIMARY,
|
||||
},
|
||||
workoutTrainer: {
|
||||
...TYPOGRAPHY.CAPTION_1,
|
||||
color: TEXT.TERTIARY,
|
||||
},
|
||||
|
||||
// Timer
|
||||
timerContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: SPACING[8],
|
||||
},
|
||||
|
||||
// Controls
|
||||
controls: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: 'center',
|
||||
},
|
||||
controlsRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: SPACING[6],
|
||||
},
|
||||
|
||||
// Burn Bar
|
||||
burnBarContainer: {
|
||||
position: 'absolute',
|
||||
left: SPACING[4],
|
||||
right: SPACING[4],
|
||||
height: 72,
|
||||
borderRadius: RADIUS.LG,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
padding: SPACING[3],
|
||||
},
|
||||
|
||||
// Complete
|
||||
completeContainer: {
|
||||
alignItems: 'center',
|
||||
marginTop: SPACING[8],
|
||||
},
|
||||
completeTitle: {
|
||||
...TYPOGRAPHY.LARGE_TITLE,
|
||||
color: TEXT.PRIMARY,
|
||||
},
|
||||
completeSubtitle: {
|
||||
...TYPOGRAPHY.TITLE_3,
|
||||
color: BRAND.PRIMARY,
|
||||
marginTop: SPACING[1],
|
||||
},
|
||||
completeStats: {
|
||||
flexDirection: 'row',
|
||||
marginTop: SPACING[6],
|
||||
gap: SPACING[8],
|
||||
},
|
||||
completeStat: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
completeStatValue: {
|
||||
...TYPOGRAPHY.TITLE_1,
|
||||
color: TEXT.PRIMARY,
|
||||
},
|
||||
completeStatLabel: {
|
||||
...TYPOGRAPHY.CAPTION_1,
|
||||
color: TEXT.TERTIARY,
|
||||
marginTop: SPACING[1],
|
||||
},
|
||||
doneButton: {
|
||||
width: 200,
|
||||
height: 56,
|
||||
borderRadius: RADIUS.GLASS_BUTTON,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
...SHADOW.BRAND_GLOW,
|
||||
},
|
||||
doneButtonText: {
|
||||
...TYPOGRAPHY.BUTTON_MEDIUM,
|
||||
color: TEXT.PRIMARY,
|
||||
letterSpacing: 1,
|
||||
},
|
||||
})
|
||||
14
app/workout/CLAUDE.md
Normal file
14
app/workout/CLAUDE.md
Normal file
@@ -0,0 +1,14 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Feb 20, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #5045 | 8:22 AM | ✅ | Removed closing Host tag from workout detail screen | ~188 |
|
||||
| #5044 | " | ✅ | Removed opening Host tag from workout detail screen | ~158 |
|
||||
| #5032 | 8:19 AM | ✅ | Removed Host import from workout detail screen | ~194 |
|
||||
| #5025 | 8:18 AM | 🔵 | Workout detail screen properly wraps content with Host component | ~244 |
|
||||
</claude-mem-context>
|
||||
449
app/workout/[id].tsx
Normal file
449
app/workout/[id].tsx
Normal file
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* TabataFit Pre-Workout Detail Screen
|
||||
* Dynamic data via route params
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { View, Text as RNText, StyleSheet, ScrollView, Pressable } from 'react-native'
|
||||
import { useRouter, useLocalSearchParams } from 'expo-router'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import { BlurView } from 'expo-blur'
|
||||
import Ionicons from '@expo/vector-icons/Ionicons'
|
||||
|
||||
import { useHaptics } from '@/src/shared/hooks'
|
||||
import { getWorkoutById, getTrainerById } from '@/src/shared/data'
|
||||
import { VideoPlayer } from '@/src/shared/components/VideoPlayer'
|
||||
|
||||
import {
|
||||
BRAND,
|
||||
DARK,
|
||||
TEXT,
|
||||
GLASS,
|
||||
SHADOW,
|
||||
} from '@/src/shared/constants/colors'
|
||||
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
|
||||
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// MAIN SCREEN
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export default function WorkoutDetailScreen() {
|
||||
const insets = useSafeAreaInsets()
|
||||
const router = useRouter()
|
||||
const haptics = useHaptics()
|
||||
const { id } = useLocalSearchParams<{ id: string }>()
|
||||
const [isSaved, setIsSaved] = useState(false)
|
||||
|
||||
const workout = getWorkoutById(id ?? '1')
|
||||
if (!workout) {
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top, alignItems: 'center', justifyContent: 'center' }]}>
|
||||
<RNText style={{ color: TEXT.PRIMARY, fontSize: 17 }}>Workout not found</RNText>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const trainer = getTrainerById(workout.trainerId)
|
||||
|
||||
const handleStartWorkout = () => {
|
||||
haptics.phaseChange()
|
||||
router.push(`/player/${workout.id}`)
|
||||
}
|
||||
|
||||
const handleGoBack = () => {
|
||||
haptics.selection()
|
||||
router.back()
|
||||
}
|
||||
|
||||
const toggleSave = () => {
|
||||
haptics.selection()
|
||||
setIsSaved(!isSaved)
|
||||
}
|
||||
|
||||
const repeatCount = Math.max(1, Math.floor(workout.rounds / workout.exercises.length))
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 100 }]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Video Preview */}
|
||||
<View style={styles.videoPreview}>
|
||||
<VideoPlayer
|
||||
videoUrl={workout.videoUrl}
|
||||
gradientColors={[trainer?.color ?? BRAND.PRIMARY, BRAND.PRIMARY_DARK]}
|
||||
mode="preview"
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<LinearGradient
|
||||
colors={['rgba(0,0,0,0.3)', 'transparent', 'rgba(0,0,0,0.7)']}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
|
||||
{/* Header overlay */}
|
||||
<View style={styles.headerOverlay}>
|
||||
<Pressable onPress={handleGoBack} style={styles.headerButton}>
|
||||
<BlurView intensity={GLASS.BLUR_LIGHT} tint="dark" style={StyleSheet.absoluteFill} />
|
||||
<Ionicons name="chevron-back" size={24} color={TEXT.PRIMARY} />
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.headerRight}>
|
||||
<Pressable onPress={toggleSave} style={styles.headerButton}>
|
||||
<BlurView intensity={GLASS.BLUR_LIGHT} tint="dark" style={StyleSheet.absoluteFill} />
|
||||
<Ionicons
|
||||
name={isSaved ? 'heart' : 'heart-outline'}
|
||||
size={24}
|
||||
color={isSaved ? '#FF3B30' : TEXT.PRIMARY}
|
||||
/>
|
||||
</Pressable>
|
||||
<Pressable style={styles.headerButton}>
|
||||
<BlurView intensity={GLASS.BLUR_LIGHT} tint="dark" style={StyleSheet.absoluteFill} />
|
||||
<Ionicons name="ellipsis-horizontal" size={24} color={TEXT.PRIMARY} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Trainer preview */}
|
||||
<View style={styles.trainerPreview}>
|
||||
<View style={[styles.trainerAvatarLarge, { backgroundColor: trainer?.color ?? BRAND.PRIMARY }]}>
|
||||
<RNText style={styles.trainerInitial}>{trainer?.name[0] ?? 'T'}</RNText>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Title Section */}
|
||||
<View style={styles.titleSection}>
|
||||
<RNText style={styles.title}>{workout.title}</RNText>
|
||||
|
||||
{/* Quick stats */}
|
||||
<View style={styles.quickStats}>
|
||||
<View style={styles.statItem}>
|
||||
<Ionicons name="person" size={16} color={BRAND.PRIMARY} />
|
||||
<RNText style={styles.statText}>{trainer?.name ?? ''}</RNText>
|
||||
</View>
|
||||
<RNText style={styles.statDot}>•</RNText>
|
||||
<View style={styles.statItem}>
|
||||
<Ionicons name="barbell" size={16} color={BRAND.PRIMARY} />
|
||||
<RNText style={styles.statText}>{workout.level}</RNText>
|
||||
</View>
|
||||
<RNText style={styles.statDot}>•</RNText>
|
||||
<View style={styles.statItem}>
|
||||
<Ionicons name="time" size={16} color={BRAND.PRIMARY} />
|
||||
<RNText style={styles.statText}>{workout.duration} min</RNText>
|
||||
</View>
|
||||
<RNText style={styles.statDot}>•</RNText>
|
||||
<View style={styles.statItem}>
|
||||
<Ionicons name="flame" size={16} color={BRAND.PRIMARY} />
|
||||
<RNText style={styles.statText}>{workout.calories} cal</RNText>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
{/* Equipment */}
|
||||
<View style={styles.section}>
|
||||
<RNText style={styles.sectionTitle}>What You'll Need</RNText>
|
||||
{workout.equipment.map((item, index) => (
|
||||
<View key={index} style={styles.equipmentItem}>
|
||||
<Ionicons name="checkmark-circle" size={20} color="#30D158" />
|
||||
<RNText style={styles.equipmentText}>{item}</RNText>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
{/* Exercises */}
|
||||
<View style={styles.section}>
|
||||
<RNText style={styles.sectionTitle}>Exercises ({workout.rounds} rounds)</RNText>
|
||||
<View style={styles.exercisesList}>
|
||||
{workout.exercises.map((exercise, index) => (
|
||||
<View key={index} style={styles.exerciseRow}>
|
||||
<View style={styles.exerciseNumber}>
|
||||
<RNText style={styles.exerciseNumberText}>{index + 1}</RNText>
|
||||
</View>
|
||||
<RNText style={styles.exerciseName}>{exercise.name}</RNText>
|
||||
<RNText style={styles.exerciseDuration}>{exercise.duration}s work</RNText>
|
||||
</View>
|
||||
))}
|
||||
<View style={styles.repeatNote}>
|
||||
<Ionicons name="repeat" size={16} color={TEXT.TERTIARY} />
|
||||
<RNText style={styles.repeatText}>Repeat × {repeatCount} rounds</RNText>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
{/* Music */}
|
||||
<View style={styles.section}>
|
||||
<RNText style={styles.sectionTitle}>Music</RNText>
|
||||
<View style={styles.musicCard}>
|
||||
<View style={styles.musicIcon}>
|
||||
<Ionicons name="musical-notes" size={24} color={BRAND.PRIMARY} />
|
||||
</View>
|
||||
<View style={styles.musicInfo}>
|
||||
<RNText style={styles.musicName}>{workout.musicVibe.charAt(0).toUpperCase() + workout.musicVibe.slice(1)} Mix</RNText>
|
||||
<RNText style={styles.musicDescription}>Curated for your workout</RNText>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Fixed Start Button */}
|
||||
<View style={[styles.bottomBar, { paddingBottom: insets.bottom + SPACING[4] }]}>
|
||||
<BlurView intensity={GLASS.BLUR_HEAVY} tint="dark" style={StyleSheet.absoluteFill} />
|
||||
<View style={styles.startButtonContainer}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.startButton,
|
||||
pressed && styles.startButtonPressed,
|
||||
]}
|
||||
onPress={handleStartWorkout}
|
||||
>
|
||||
<RNText style={styles.startButtonText}>START WORKOUT</RNText>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// STYLES
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: DARK.BASE,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
},
|
||||
|
||||
// Video Preview
|
||||
videoPreview: {
|
||||
height: 280,
|
||||
marginHorizontal: -LAYOUT.SCREEN_PADDING,
|
||||
marginBottom: SPACING[4],
|
||||
backgroundColor: DARK.SURFACE,
|
||||
},
|
||||
headerOverlay: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: SPACING[4],
|
||||
},
|
||||
headerButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
headerRight: {
|
||||
flexDirection: 'row',
|
||||
gap: SPACING[2],
|
||||
},
|
||||
trainerPreview: {
|
||||
position: 'absolute',
|
||||
bottom: SPACING[4],
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: 'center',
|
||||
},
|
||||
trainerAvatarLarge: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 3,
|
||||
borderColor: TEXT.PRIMARY,
|
||||
},
|
||||
trainerInitial: {
|
||||
...TYPOGRAPHY.HERO,
|
||||
color: TEXT.PRIMARY,
|
||||
},
|
||||
|
||||
// Title Section
|
||||
titleSection: {
|
||||
marginBottom: SPACING[4],
|
||||
},
|
||||
title: {
|
||||
...TYPOGRAPHY.LARGE_TITLE,
|
||||
color: TEXT.PRIMARY,
|
||||
marginBottom: SPACING[3],
|
||||
},
|
||||
quickStats: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: SPACING[2],
|
||||
},
|
||||
statItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: SPACING[1],
|
||||
},
|
||||
statText: {
|
||||
...TYPOGRAPHY.BODY,
|
||||
color: TEXT.SECONDARY,
|
||||
},
|
||||
statDot: {
|
||||
color: TEXT.TERTIARY,
|
||||
},
|
||||
|
||||
// Divider
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
marginVertical: SPACING[2],
|
||||
},
|
||||
|
||||
// Section
|
||||
section: {
|
||||
paddingVertical: SPACING[4],
|
||||
},
|
||||
sectionTitle: {
|
||||
...TYPOGRAPHY.HEADLINE,
|
||||
color: TEXT.PRIMARY,
|
||||
marginBottom: SPACING[3],
|
||||
},
|
||||
|
||||
// Equipment
|
||||
equipmentItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: SPACING[3],
|
||||
marginBottom: SPACING[2],
|
||||
},
|
||||
equipmentText: {
|
||||
...TYPOGRAPHY.BODY,
|
||||
color: TEXT.SECONDARY,
|
||||
},
|
||||
|
||||
// Exercises
|
||||
exercisesList: {
|
||||
gap: SPACING[2],
|
||||
},
|
||||
exerciseRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: SPACING[3],
|
||||
paddingHorizontal: SPACING[4],
|
||||
backgroundColor: DARK.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: TEXT.PRIMARY,
|
||||
flex: 1,
|
||||
},
|
||||
exerciseDuration: {
|
||||
...TYPOGRAPHY.CAPTION_1,
|
||||
color: TEXT.TERTIARY,
|
||||
},
|
||||
repeatNote: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: SPACING[2],
|
||||
marginTop: SPACING[2],
|
||||
paddingHorizontal: SPACING[2],
|
||||
},
|
||||
repeatText: {
|
||||
...TYPOGRAPHY.CAPTION_1,
|
||||
color: TEXT.TERTIARY,
|
||||
},
|
||||
|
||||
// Music
|
||||
musicCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: SPACING[4],
|
||||
backgroundColor: DARK.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: TEXT.PRIMARY,
|
||||
},
|
||||
musicDescription: {
|
||||
...TYPOGRAPHY.CAPTION_1,
|
||||
color: TEXT.TERTIARY,
|
||||
marginTop: 2,
|
||||
},
|
||||
|
||||
// Bottom Bar
|
||||
bottomBar: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
paddingTop: SPACING[4],
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
startButtonContainer: {
|
||||
height: 56,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
|
||||
// Start Button
|
||||
startButton: {
|
||||
height: 56,
|
||||
borderRadius: RADIUS.LG,
|
||||
backgroundColor: BRAND.PRIMARY,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
startButtonPressed: {
|
||||
backgroundColor: BRAND.PRIMARY_DARK,
|
||||
transform: [{ scale: 0.98 }],
|
||||
},
|
||||
startButtonText: {
|
||||
...TYPOGRAPHY.HEADLINE,
|
||||
color: TEXT.PRIMARY,
|
||||
letterSpacing: 1,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user