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:
Millian Lamiaux
2026-02-20 13:24:21 +01:00
parent 99d8fba852
commit b0521ded5a
6 changed files with 1871 additions and 0 deletions

14
app/workout/CLAUDE.md Normal file
View 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
View 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,
},
})