From b0521ded5a5dc62064cbb0a850866a68bb95682b Mon Sep 17 00:00:00 2001 From: Millian Lamiaux Date: Fri, 20 Feb 2026 13:24:21 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20workout=20flow=20=E2=80=94=20detail,=20?= =?UTF-8?q?player,=20and=20complete=20screens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/complete/CLAUDE.md | 14 + app/complete/[id].tsx | 626 ++++++++++++++++++++++++++++++++++ app/player/CLAUDE.md | 12 + app/player/[id].tsx | 756 +++++++++++++++++++++++++++++++++++++++++ app/workout/CLAUDE.md | 14 + app/workout/[id].tsx | 449 ++++++++++++++++++++++++ 6 files changed, 1871 insertions(+) create mode 100644 app/complete/CLAUDE.md create mode 100644 app/complete/[id].tsx create mode 100644 app/player/CLAUDE.md create mode 100644 app/player/[id].tsx create mode 100644 app/workout/CLAUDE.md create mode 100644 app/workout/[id].tsx diff --git a/app/complete/CLAUDE.md b/app/complete/CLAUDE.md new file mode 100644 index 0000000..bee9fb7 --- /dev/null +++ b/app/complete/CLAUDE.md @@ -0,0 +1,14 @@ + +# Recent Activity + + + +### 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 | + \ No newline at end of file diff --git a/app/complete/[id].tsx b/app/complete/[id].tsx new file mode 100644 index 0000000..9a51638 --- /dev/null +++ b/app/complete/[id].tsx @@ -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 ( + + + {icon && } + {children} + + + ) +} + +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 ( + + + + {children} + + + ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 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 ( + + + 🔥 + + + 💪 + + + + + + ) +} + +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 ( + + + + {value} + {label} + + ) +} + +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 ( + + Burn Bar + You beat {percentile}% of users! + + + + + ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 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 ( + + + {/* Celebration */} + + 🎉 + WORKOUT COMPLETE + + + + {/* Stats Grid */} + + + + + + + {/* Burn Bar */} + + + + + {/* Streak */} + + + + + + {streak.current} Day Streak! + Keep the momentum going! + + + + + + {/* Share Button */} + + + Share Your Workout + + + + + + {/* Recommended */} + + Recommended Next + + {recommended.map((w) => { + const trainer = getTrainerById(w.trainerId) + return ( + handleWorkoutPress(w.id)} + style={styles.recommendedCard} + > + + + + {trainer?.name[0] ?? 'T'} + + {w.title} + {w.duration} min + + ) + })} + + + + + {/* Fixed Bottom Button */} + + + + + Back to Home + + + + + ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 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', + }, +}) diff --git a/app/player/CLAUDE.md b/app/player/CLAUDE.md new file mode 100644 index 0000000..ded1bfc --- /dev/null +++ b/app/player/CLAUDE.md @@ -0,0 +1,12 @@ + +# Recent Activity + + + +### 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 | + \ No newline at end of file diff --git a/app/player/[id].tsx b/app/player/[id].tsx new file mode 100644 index 0000000..998cbe4 --- /dev/null +++ b/app/player/[id].tsx @@ -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 ( + + + + + + + ) +} + +function PhaseIndicator({ phase }: { phase: TimerPhase }) { + const phaseColor = PHASE_COLORS[phase].fill + const phaseLabels: Record = { + PREP: 'GET READY', + WORK: 'WORK', + REST: 'REST', + COMPLETE: 'COMPLETE', + } + + return ( + + {phaseLabels[phase]} + + ) +} + +function ExerciseDisplay({ + exercise, + nextExercise, +}: { + exercise: string + nextExercise?: string +}) { + return ( + + Current + {exercise} + {nextExercise && ( + + Next: + {nextExercise} + + )} + + ) +} + +function RoundIndicator({ current, total }: { current: number; total: number }) { + return ( + + + Round {current}/{total} + + + ) +} + +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 ( + + + + + + + ) +} + +function BurnBar({ + currentCalories, + avgCalories, +}: { + currentCalories: number + avgCalories: number +}) { + const percentage = Math.min((currentCalories / avgCalories) * 100, 100) + + return ( + + + Burn Bar + {currentCalories} cal + + + + + + Community avg: {avgCalories} cal + + ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 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 ( + + + ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 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, + }, +}) diff --git a/app/workout/CLAUDE.md b/app/workout/CLAUDE.md new file mode 100644 index 0000000..84bcc23 --- /dev/null +++ b/app/workout/CLAUDE.md @@ -0,0 +1,14 @@ + +# Recent Activity + + + +### 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 | + \ No newline at end of file diff --git a/app/workout/[id].tsx b/app/workout/[id].tsx new file mode 100644 index 0000000..05a4dd4 --- /dev/null +++ b/app/workout/[id].tsx @@ -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 ( + + Workout not found + + ) + } + + 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 ( + + + {/* Video Preview */} + + + + + {/* Header overlay */} + + + + + + + + + + + + + + + + + + + {/* Trainer preview */} + + + {trainer?.name[0] ?? 'T'} + + + + + {/* Title Section */} + + {workout.title} + + {/* Quick stats */} + + + + {trainer?.name ?? ''} + + + + + {workout.level} + + + + + {workout.duration} min + + + + + {workout.calories} cal + + + + + + + {/* Equipment */} + + What You'll Need + {workout.equipment.map((item, index) => ( + + + {item} + + ))} + + + + + {/* Exercises */} + + Exercises ({workout.rounds} rounds) + + {workout.exercises.map((exercise, index) => ( + + + {index + 1} + + {exercise.name} + {exercise.duration}s work + + ))} + + + Repeat × {repeatCount} rounds + + + + + + + {/* Music */} + + Music + + + + + + {workout.musicVibe.charAt(0).toUpperCase() + workout.musicVibe.slice(1)} Mix + Curated for your workout + + + + + + {/* Fixed Start Button */} + + + + [ + styles.startButton, + pressed && styles.startButtonPressed, + ]} + onPress={handleStartWorkout} + > + START WORKOUT + + + + + ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 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, + }, +})