/** * TabataFit Player Screen * Full-screen workout player with timer overlay * Wired to shared data + useTimer hook * FORCE DARK — always uses darkColors regardless of system theme */ import React, { useRef, useEffect, useCallback, useState, useMemo } from 'react' import { View, Text, StyleSheet, Pressable, Animated, Easing, Dimensions, StatusBar, } from 'react-native' import Svg, { Circle } from 'react-native-svg' 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 { useTranslation } from 'react-i18next' 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 } from '@/src/shared/data' import { useTranslatedWorkout } from '@/src/shared/data/useTranslatedData' import { track } from '@/src/shared/services/analytics' import { BRAND, PHASE_COLORS, GRADIENTS, darkColors } from '@/src/shared/theme' import type { ThemeColors } from '@/src/shared/theme/types' import { TYPOGRAPHY } from '@/src/shared/constants/typography' import { SPACING } from '@/src/shared/constants/spacing' import { RADIUS } from '@/src/shared/constants/borderRadius' import { SPRING } from '@/src/shared/constants/animations' const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window') // ═══════════════════════════════════════════════════════════════════════════ // COMPONENTS // ═══════════════════════════════════════════════════════════════════════════ type TimerPhase = 'PREP' | 'WORK' | 'REST' | 'COMPLETE' const AnimatedCircle = Animated.createAnimatedComponent(Circle) function TimerRing({ progress, phase, size = 280, }: { progress: number phase: TimerPhase size?: number }) { const colors = darkColors const strokeWidth = 12 const radius = (size - strokeWidth) / 2 const circumference = 2 * Math.PI * radius const phaseColor = PHASE_COLORS[phase].fill const animatedProgress = useRef(new Animated.Value(0)).current const prevProgress = useRef(0) useEffect(() => { // If progress jumped backwards (new phase started), snap instantly if (progress < prevProgress.current - 0.05) { animatedProgress.setValue(progress) } else { Animated.timing(animatedProgress, { toValue: progress, duration: 1000, easing: Easing.linear, useNativeDriver: false, }).start() } prevProgress.current = progress }, [progress]) const strokeDashoffset = animatedProgress.interpolate({ inputRange: [0, 1], outputRange: [circumference, 0], }) const timerStyles = useMemo(() => createTimerStyles(colors), [colors]) return ( {/* Background track */} {/* Progress arc */} ) } function PhaseIndicator({ phase }: { phase: TimerPhase }) { const { t } = useTranslation() const colors = darkColors const timerStyles = useMemo(() => createTimerStyles(colors), [colors]) const phaseColor = PHASE_COLORS[phase].fill const phaseLabels: Record = { PREP: t('screens:player.phases.prep'), WORK: t('screens:player.phases.work'), REST: t('screens:player.phases.rest'), COMPLETE: t('screens:player.phases.complete'), } return ( {phaseLabels[phase]} ) } function ExerciseDisplay({ exercise, nextExercise, }: { exercise: string nextExercise?: string }) { const { t } = useTranslation() const colors = darkColors const timerStyles = useMemo(() => createTimerStyles(colors), [colors]) return ( {t('screens:player.current')} {exercise} {nextExercise && ( {t('screens:player.next')} {nextExercise} )} ) } function RoundIndicator({ current, total }: { current: number; total: number }) { const { t } = useTranslation() const colors = darkColors const timerStyles = useMemo(() => createTimerStyles(colors), [colors]) return ( {t('screens:player.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 colors = darkColors const timerStyles = useMemo(() => createTimerStyles(colors), [colors]) 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' : colors.border.glass return ( ) } function BurnBar({ currentCalories, avgCalories, }: { currentCalories: number avgCalories: number }) { const { t } = useTranslation() const colors = darkColors const timerStyles = useMemo(() => createTimerStyles(colors), [colors]) const percentage = Math.min((currentCalories / avgCalories) * 100, 100) return ( {t('screens:player.burnBar')} {t('units.calUnit', { count: currentCalories })} {t('screens:player.communityAvg', { calories: avgCalories })} ) } // ═══════════════════════════════════════════════════════════════════════════ // MAIN SCREEN // ═══════════════════════════════════════════════════════════════════════════ export default function PlayerScreen() { useKeepAwake() const router = useRouter() const { id } = useLocalSearchParams<{ id: string }>() const insets = useSafeAreaInsets() const haptics = useHaptics() const { t } = useTranslation() const addWorkoutResult = useActivityStore((s) => s.addWorkoutResult) const colors = darkColors const styles = useMemo(() => createStyles(colors), [colors]) const timerStyles = useMemo(() => createTimerStyles(colors), [colors]) const rawWorkout = getWorkoutById(id ?? '1') const workout = useTranslatedWorkout(rawWorkout) const timer = useTimer(rawWorkout ?? null) const audio = useAudio() const [showControls, setShowControls] = useState(true) // Animation refs const timerScaleAnim = useRef(new Animated.Value(0.8)).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}:${secs.toString().padStart(2, '0')}` } // Start timer const startTimer = useCallback(() => { timer.start() haptics.buttonTap() if (workout) { track('workout_started', { workout_id: workout.id, workout_title: workout.title, duration: workout.duration, level: workout.level, }) } }, [timer, haptics, workout]) // Pause/Resume const togglePause = useCallback(() => { const workoutId = workout?.id ?? id ?? '' if (timer.isPaused) { timer.resume() track('workout_resumed', { workout_id: workoutId }) } else { timer.pause() track('workout_paused', { workout_id: workoutId }) } haptics.selection() }, [timer, haptics, workout, id]) // 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) { track('workout_completed', { workout_id: workout.id, workout_title: workout.title, calories: timer.calories, duration: workout.duration, rounds: workout.rounds, }) } 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.spring(timerScaleAnim, { toValue: 1, friction: 6, tension: 100, useNativeDriver: true, }).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]) return (