Files
tabatago/app/player/[id].tsx
Millian Lamiaux 2ad7ae3a34 feat: Apple Watch app + Paywall + Privacy Policy + rebranding
## Major Features
- Apple Watch companion app (6 phases complete)
  - WatchConnectivity iPhone ↔ Watch
  - HealthKit integration (HR, calories)
  - SwiftUI premium UI
  - 9 complication types
  - Always-On Display support

- Paywall screen with RevenueCat integration
- Privacy Policy screen
- App rebranding: tabatago → TabataFit
- Bundle ID: com.millianlmx.tabatafit

## Changes
- New: ios/TabataFit Watch App/ (complete Watch app)
- New: app/paywall.tsx (subscription UI)
- New: app/privacy.tsx (privacy policy)
- New: src/features/watch/ (Watch sync hooks)
- New: admin-web/ (admin dashboard)
- Updated: app.json, package.json (branding)
- Updated: profile.tsx (paywall + privacy links)
- Updated: i18n translations (EN/FR/DE/ES)
- New: app icon 1024x1024

## Watch App Files
- TabataFitWatchApp.swift (entry point)
- ContentView.swift (premium UI)
- HealthKitManager.swift (HR + calories)
- WatchSessionManager.swift (communication)
- Complications/ (WidgetKit)
- UserDefaults+Shared.swift (data sharing)
2026-03-11 09:43:53 +01:00

843 lines
25 KiB
TypeScript

/**
* 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 { useMusicPlayer } from '@/src/shared/hooks/useMusicPlayer'
import { useActivityStore } from '@/src/shared/stores'
import { getWorkoutById } from '@/src/shared/data'
import { useTranslatedWorkout } from '@/src/shared/data/useTranslatedData'
import { useWatchSync } from '@/src/features/watch'
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 (
<View style={[timerStyles.timerRingContainer, { width: size, height: size }]}>
<Svg width={size} height={size}>
{/* Background track */}
<Circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke={colors.border.glass}
strokeWidth={strokeWidth}
fill="none"
/>
{/* Progress arc */}
<AnimatedCircle
cx={size / 2}
cy={size / 2}
r={radius}
stroke={phaseColor}
strokeWidth={strokeWidth}
fill="none"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
rotation="-90"
origin={`${size / 2}, ${size / 2}`}
/>
</Svg>
</View>
)
}
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<TimerPhase, string> = {
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 (
<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
}) {
const { t } = useTranslation()
const colors = darkColors
const timerStyles = useMemo(() => createTimerStyles(colors), [colors])
return (
<View style={timerStyles.exerciseDisplay}>
<Text style={timerStyles.currentExerciseLabel}>{t('screens:player.current')}</Text>
<Text style={timerStyles.currentExercise}>{exercise}</Text>
{nextExercise && (
<View style={timerStyles.nextExerciseContainer}>
<Text style={timerStyles.nextExerciseLabel}>{t('screens:player.next')}</Text>
<Text style={timerStyles.nextExercise}>{nextExercise}</Text>
</View>
)}
</View>
)
}
function RoundIndicator({ current, total }: { current: number; total: number }) {
const { t } = useTranslation()
const colors = darkColors
const timerStyles = useMemo(() => createTimerStyles(colors), [colors])
return (
<View style={timerStyles.roundIndicator}>
<Text style={timerStyles.roundText}>
{t('screens:player.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 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 (
<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={colors.text.primary} />
</Pressable>
</Animated.View>
)
}
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 (
<View style={timerStyles.burnBar}>
<View style={timerStyles.burnBarHeader}>
<Text style={timerStyles.burnBarLabel}>{t('screens:player.burnBar')}</Text>
<Text style={timerStyles.burnBarValue}>{t('units.calUnit', { count: currentCalories })}</Text>
</View>
<View style={timerStyles.burnBarTrack}>
<View style={[timerStyles.burnBarFill, { width: `${percentage}%` }]} />
<View style={[timerStyles.burnBarAvg, { left: '50%' }]} />
</View>
<Text style={timerStyles.burnBarAvgLabel}>{t('screens:player.communityAvg', { calories: avgCalories })}</Text>
</View>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// 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()
// Music player - synced with workout timer
useMusicPlayer({
vibe: workout?.musicVibe ?? 'electronic',
isPlaying: timer.isRunning && !timer.isPaused,
})
const [showControls, setShowControls] = useState(true)
// Watch sync integration
const { isAvailable: isWatchAvailable, sendWorkoutState } = useWatchSync({
onPlay: () => {
timer.resume()
track('watch_control_play', { workout_id: workout?.id ?? id })
},
onPause: () => {
timer.pause()
track('watch_control_pause', { workout_id: workout?.id ?? id })
},
onSkip: () => {
timer.skip()
haptics.selection()
track('watch_control_skip', { workout_id: workout?.id ?? id })
},
onStop: () => {
haptics.phaseChange()
timer.stop()
router.back()
track('watch_control_stop', { workout_id: workout?.id ?? id })
},
})
// 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])
// Sync workout state with Apple Watch
useEffect(() => {
if (!isWatchAvailable || !timer.isRunning) return;
sendWorkoutState({
phase: timer.phase,
timeRemaining: timer.timeRemaining,
currentRound: timer.currentRound,
totalRounds: timer.totalRounds,
currentExercise: timer.currentExercise,
nextExercise: timer.nextExercise,
calories: timer.calories,
isPaused: timer.isPaused,
isPlaying: timer.isRunning && !timer.isPaused,
});
}, [
timer.phase,
timer.timeRemaining,
timer.currentRound,
timer.totalRounds,
timer.currentExercise,
timer.nextExercise,
timer.calories,
timer.isPaused,
timer.isRunning,
isWatchAvailable,
]);
return (
<View style={styles.container}>
<StatusBar hidden />
{/* Background gradient */}
<LinearGradient
colors={[colors.bg.base, colors.bg.surface]}
style={StyleSheet.absoluteFill}
/>
{/* Phase background color */}
<View style={[styles.phaseBackground, { 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={colors.glass.blurLight} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
<Ionicons name="close" size={24} color={colors.text.primary} />
</Pressable>
<View style={styles.headerCenter}>
<Text style={styles.workoutTitle}>{workout?.title ?? 'Workout'}</Text>
<Text style={styles.workoutTrainer}>{t('durationLevel', { duration: workout?.duration ?? 0, level: t(`levels.${(workout?.level ?? 'beginner').toLowerCase()}`) })}</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}>{t('screens:player.workoutComplete')}</Text>
<Text style={styles.completeSubtitle}>{t('screens:player.greatJob')}</Text>
<View style={styles.completeStats}>
<View style={styles.completeStat}>
<Text style={styles.completeStatValue}>{timer.totalRounds}</Text>
<Text style={styles.completeStatLabel}>{t('screens:player.rounds')}</Text>
</View>
<View style={styles.completeStat}>
<Text style={styles.completeStatValue}>{timer.calories}</Text>
<Text style={styles.completeStatLabel}>{t('screens:player.calories')}</Text>
</View>
<View style={styles.completeStat}>
<Text style={styles.completeStatValue}>{workout?.duration ?? 4}</Text>
<Text style={styles.completeStatLabel}>{t('screens:player.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={colors.glass.blurMedium} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
<BurnBar currentCalories={timer.calories} avgCalories={workout?.calories ?? 45} />
</View>
)}
</Pressable>
</View>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// STYLES
// ═══════════════════════════════════════════════════════════════════════════
function createTimerStyles(colors: ThemeColors) {
return StyleSheet.create({
timerRingContainer: {
alignItems: 'center',
justifyContent: 'center',
},
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: colors.text.primary,
},
roundIndicator: {
marginTop: SPACING[2],
},
roundText: {
...TYPOGRAPHY.BODY,
color: colors.text.tertiary,
},
roundCurrent: {
color: colors.text.primary,
fontWeight: '700',
},
// Exercise
exerciseDisplay: {
alignItems: 'center',
marginTop: SPACING[6],
paddingHorizontal: SPACING[6],
},
currentExerciseLabel: {
...TYPOGRAPHY.CAPTION_1,
color: colors.text.tertiary,
textTransform: 'uppercase',
letterSpacing: 1,
},
currentExercise: {
...TYPOGRAPHY.TITLE_1,
color: colors.text.primary,
textAlign: 'center',
marginTop: SPACING[1],
},
nextExerciseContainer: {
flexDirection: 'row',
marginTop: SPACING[2],
},
nextExerciseLabel: {
...TYPOGRAPHY.BODY,
color: colors.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: colors.text.tertiary,
},
burnBarValue: {
...TYPOGRAPHY.CALLOUT,
color: BRAND.PRIMARY,
fontWeight: '600',
},
burnBarTrack: {
height: 6,
backgroundColor: colors.border.glass,
borderRadius: 3,
overflow: 'hidden',
},
burnBarFill: {
height: '100%',
backgroundColor: BRAND.PRIMARY,
borderRadius: 3,
},
burnBarAvg: {
position: 'absolute',
top: -2,
width: 2,
height: 10,
backgroundColor: colors.text.tertiary,
},
burnBarAvgLabel: {
...TYPOGRAPHY.CAPTION_2,
color: colors.text.tertiary,
marginTop: SPACING[1],
textAlign: 'right',
},
})
}
function createStyles(colors: ThemeColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bg.base,
},
phaseBackground: {
...StyleSheet.absoluteFillObject,
opacity: 0.15,
},
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: colors.border.glass,
},
headerCenter: {
alignItems: 'center',
},
workoutTitle: {
...TYPOGRAPHY.HEADLINE,
color: colors.text.primary,
},
workoutTrainer: {
...TYPOGRAPHY.CAPTION_1,
color: colors.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: colors.border.glass,
padding: SPACING[3],
},
// Complete
completeContainer: {
alignItems: 'center',
marginTop: SPACING[8],
},
completeTitle: {
...TYPOGRAPHY.LARGE_TITLE,
color: colors.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: colors.text.primary,
},
completeStatLabel: {
...TYPOGRAPHY.CAPTION_1,
color: colors.text.tertiary,
marginTop: SPACING[1],
},
doneButton: {
width: 200,
height: 56,
borderRadius: RADIUS.GLASS_BUTTON,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
...colors.shadow.BRAND_GLOW,
},
doneButtonText: {
...TYPOGRAPHY.BUTTON_MEDIUM,
color: colors.text.primary,
letterSpacing: 1,
},
})
}