/** * 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, }, })