chore: remove v1 features and old scaffolding

Remove onboarding flow (6 screens), timer engine, audio engine,
old components (themed-text/view, parallax-scroll, hello-wave),
old constants (theme, shadows, timer), and utility files that
were replaced by the v2 architecture.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Millian Lamiaux
2026-02-20 13:23:04 +01:00
parent fa189fe72e
commit 54ac8326fa
55 changed files with 0 additions and 4922 deletions

View File

@@ -1,13 +0,0 @@
import type { PhaseSound } from '../types'
/* eslint-disable @typescript-eslint/no-require-imports */
export const PHASE_SOUNDS: Record<PhaseSound, number> = {
beep_long: require('@/assets/audio/sounds/beep_long.mp3'),
beep_double: require('@/assets/audio/sounds/beep_double.mp3'),
beep_short: require('@/assets/audio/sounds/beep_short.mp3'),
bell: require('@/assets/audio/sounds/bell.mp3'),
fanfare: require('@/assets/audio/sounds/fanfare.mp3'),
count_3: require('@/assets/audio/sounds/count_3.mp3'),
count_2: require('@/assets/audio/sounds/count_2.mp3'),
count_1: require('@/assets/audio/sounds/count_1.mp3'),
}

View File

@@ -1,27 +0,0 @@
import type { MusicIntensity } from '../types'
interface MusicTrack {
id: string
intensity: MusicIntensity
asset: number
}
/* eslint-disable @typescript-eslint/no-require-imports */
export const TRACKS: MusicTrack[] = [
{
id: 'electro_high',
intensity: 'HIGH',
asset: require('@/assets/audio/music/electro_high.mp3'),
},
{
id: 'electro_low',
intensity: 'LOW',
asset: require('@/assets/audio/music/electro_low.mp3'),
},
]
export function getTrack(intensity: MusicIntensity): MusicTrack {
const track = TRACKS.find((t) => t.intensity === intensity)
if (!track) throw new Error(`Track not found: ${intensity}`)
return track
}

View File

@@ -1,173 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Audio } from 'expo-av'
import type { AudioEngine, MusicIntensity, PhaseSound } from '../types'
import { PHASE_SOUNDS } from '../data/sounds'
import { getTrack } from '../data/tracks'
const FADE_STEPS = 10
async function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
export function useAudioEngine(): AudioEngine {
const [isLoaded, setIsLoaded] = useState(false)
const soundsRef = useRef<Record<string, Audio.Sound>>({})
const currentIntensityRef = useRef<MusicIntensity>('LOW')
const musicVolumeRef = useRef(0.5)
// Configure audio session once
useEffect(() => {
Audio.setAudioModeAsync({
playsInSilentModeIOS: true,
allowsRecordingIOS: false,
staysActiveInBackground: true,
shouldDuckAndroid: true,
playThroughEarpieceAndroid: false,
}).catch((e) => {
if (__DEV__) console.warn('[AudioEngine] Failed to configure audio session:', e)
})
}, [])
const preloadAll = useCallback(async () => {
try {
// Preload phase sounds
for (const [key, asset] of Object.entries(PHASE_SOUNDS)) {
const { sound } = await Audio.Sound.createAsync(asset, {
shouldPlay: false,
volume: 1.0,
})
soundsRef.current[key] = sound
}
// Preload music tracks
const highTrack = getTrack('HIGH')
const lowTrack = getTrack('LOW')
const { sound: musicHigh } = await Audio.Sound.createAsync(highTrack.asset, {
shouldPlay: false,
volume: 0,
isLooping: true,
})
soundsRef.current['music_high'] = musicHigh
const { sound: musicLow } = await Audio.Sound.createAsync(lowTrack.asset, {
shouldPlay: false,
volume: 0,
isLooping: true,
})
soundsRef.current['music_low'] = musicLow
setIsLoaded(true)
if (__DEV__) console.log('[AudioEngine] All sounds preloaded')
} catch (e) {
if (__DEV__) console.warn('[AudioEngine] Preload error:', e)
}
}, [])
const playPhaseSound = useCallback(async (sound: PhaseSound) => {
const s = soundsRef.current[sound]
if (!s) return
try {
await s.setPositionAsync(0)
await s.playAsync()
} catch (e) {
if (__DEV__) console.warn('[AudioEngine] Play error:', sound, e)
}
}, [])
const startMusic = useCallback(async (intensity: MusicIntensity) => {
const key = intensity === 'HIGH' ? 'music_high' : 'music_low'
const s = soundsRef.current[key]
if (!s) return
try {
currentIntensityRef.current = intensity
await s.setPositionAsync(0)
await s.setVolumeAsync(musicVolumeRef.current)
await s.playAsync()
} catch (e) {
if (__DEV__) console.warn('[AudioEngine] startMusic error:', e)
}
}, [])
const switchIntensity = useCallback(async (to: MusicIntensity) => {
const from = currentIntensityRef.current
if (from === to) return
const outKey = from === 'HIGH' ? 'music_high' : 'music_low'
const inKey = to === 'HIGH' ? 'music_high' : 'music_low'
const outSound = soundsRef.current[outKey]
const inSound = soundsRef.current[inKey]
if (!outSound || !inSound) return
try {
const vol = musicVolumeRef.current
await inSound.setPositionAsync(0)
await inSound.setVolumeAsync(0)
await inSound.playAsync()
// Crossfade
const stepMs = 500 / FADE_STEPS
for (let i = 1; i <= FADE_STEPS; i++) {
const progress = i / FADE_STEPS
await Promise.all([
outSound.setVolumeAsync(vol * (1 - progress)),
inSound.setVolumeAsync(vol * progress),
])
await delay(stepMs)
}
await outSound.stopAsync()
currentIntensityRef.current = to
} catch (e) {
if (__DEV__) console.warn('[AudioEngine] switchIntensity error:', e)
}
}, [])
const stopMusic = useCallback(async (fadeMs: number = 500) => {
const key = currentIntensityRef.current === 'HIGH' ? 'music_high' : 'music_low'
const s = soundsRef.current[key]
if (!s) return
try {
const stepMs = fadeMs / FADE_STEPS
for (let i = FADE_STEPS; i >= 0; i--) {
await s.setVolumeAsync(musicVolumeRef.current * (i / FADE_STEPS))
await delay(stepMs)
}
await s.stopAsync()
} catch (e) {
if (__DEV__) console.warn('[AudioEngine] stopMusic error:', e)
}
}, [])
const unloadAll = useCallback(async () => {
await Promise.all(
Object.values(soundsRef.current).map((s) =>
s.unloadAsync().catch(() => {})
)
)
soundsRef.current = {}
setIsLoaded(false)
}, [])
// Cleanup on unmount
useEffect(() => {
return () => {
Object.values(soundsRef.current).forEach((s) => {
s.unloadAsync().catch(() => {})
})
}
}, [])
return {
isLoaded,
preloadAll,
playPhaseSound,
startMusic,
switchIntensity,
stopMusic,
unloadAll,
}
}

View File

@@ -1,8 +0,0 @@
export { useAudioEngine } from './hooks/useAudioEngine'
export type {
MusicAmbiance,
MusicIntensity,
PhaseSound,
AudioSettings,
AudioEngine,
} from './types'

View File

@@ -1,31 +0,0 @@
export type MusicAmbiance = 'ELECTRO' | 'SILENCE'
export type MusicIntensity = 'LOW' | 'HIGH'
export type PhaseSound =
| 'beep_long'
| 'beep_double'
| 'beep_short'
| 'bell'
| 'fanfare'
| 'count_3'
| 'count_2'
| 'count_1'
export interface AudioSettings {
musicEnabled: boolean
ambiance: MusicAmbiance
musicVolume: number
soundsEnabled: boolean
soundsVolume: number
hapticsEnabled: boolean
}
export interface AudioEngine {
isLoaded: boolean
preloadAll: () => Promise<void>
playPhaseSound: (sound: PhaseSound) => Promise<void>
startMusic: (intensity: MusicIntensity) => Promise<void>
switchIntensity: (intensity: MusicIntensity) => Promise<void>
stopMusic: (fadeMs?: number) => Promise<void>
unloadAll: () => Promise<void>
}

View File

@@ -1,100 +0,0 @@
import { StyleSheet, View, Text, Pressable } from 'react-native'
import { Ionicons } from '@expo/vector-icons'
import { BRAND, GLASS, TEXT, BORDER, SURFACE } from '@/src/shared/constants/colors'
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { SPACING } from '@/src/shared/constants/spacing'
interface ChoiceButtonProps {
label: string
description?: string
icon: keyof typeof Ionicons.glyphMap
selected: boolean
onPress: () => void
}
export function ChoiceButton({
label,
description,
icon,
selected,
onPress,
}: ChoiceButtonProps) {
return (
<Pressable onPress={onPress} style={styles.pressable}>
<View style={[styles.container, selected && styles.containerSelected]}>
<View style={[styles.iconContainer, selected && styles.iconContainerSelected]}>
<Ionicons
name={icon}
size={24}
color={selected ? BRAND.PRIMARY : TEXT.MUTED}
/>
</View>
<View style={styles.textContainer}>
<Text style={[styles.label, selected && styles.labelSelected]}>
{label}
</Text>
{description && (
<Text style={styles.description}>{description}</Text>
)}
</View>
{selected && (
<View style={styles.checkmark}>
<Ionicons name="checkmark-circle" size={24} color={BRAND.PRIMARY} />
</View>
)}
</View>
</Pressable>
)
}
const styles = StyleSheet.create({
pressable: {
width: '100%',
},
container: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: GLASS.FILL,
borderWidth: 1,
borderColor: GLASS.BORDER,
borderRadius: RADIUS.LG,
paddingVertical: SPACING[4],
paddingHorizontal: SPACING[4],
gap: SPACING[3],
},
containerSelected: {
backgroundColor: SURFACE.OVERLAY_LIGHT,
borderColor: BRAND.PRIMARY,
borderWidth: 2,
},
iconContainer: {
width: 48,
height: 48,
borderRadius: RADIUS.MD,
backgroundColor: SURFACE.OVERLAY_LIGHT,
alignItems: 'center',
justifyContent: 'center',
},
iconContainerSelected: {
backgroundColor: SURFACE.OVERLAY_MEDIUM,
},
textContainer: {
flex: 1,
},
label: {
...TYPOGRAPHY.body,
color: TEXT.PRIMARY,
},
labelSelected: {
color: BRAND.PRIMARY,
},
description: {
...TYPOGRAPHY.caption,
color: TEXT.MUTED,
marginTop: 4,
},
checkmark: {
marginLeft: SPACING[2],
},
})

View File

@@ -1,178 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import { StyleSheet, View, Text, Animated } from 'react-native'
import { useTimerEngine } from '@/src/features/timer/hooks/useTimerEngine'
import { PHASE_COLORS, TEXT } from '@/src/shared/constants/colors'
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { SPACING } from '@/src/shared/constants/spacing'
interface MiniTimerDemoProps {
onComplete?: () => void
onPhaseChange?: (phase: string) => void
onCountdownTick?: (seconds: number) => void
autoStartDelay?: number
}
export function MiniTimerDemo({
onComplete,
onPhaseChange,
onCountdownTick,
autoStartDelay = 500,
}: MiniTimerDemoProps) {
const timer = useTimerEngine()
const [hasCompleted, setHasCompleted] = useState(false)
const pulseAnim = useRef(new Animated.Value(1)).current
const glowAnim = useRef(new Animated.Value(0)).current
useEffect(() => {
// Auto-start after a short delay
const startTimeout = setTimeout(() => {
timer.start({
workDuration: 20,
restDuration: 0,
rounds: 1,
getReadyDuration: 3,
cycles: 1,
})
}, autoStartDelay)
return () => clearTimeout(startTimeout)
}, [timer, autoStartDelay])
useEffect(() => {
// Listen for all timer events
const unsubscribe = timer.addEventListener((event) => {
switch (event.type) {
case 'SESSION_COMPLETE':
setHasCompleted(true)
onComplete?.()
break
case 'PHASE_CHANGED':
onPhaseChange?.(event.to)
break
case 'COUNTDOWN_TICK':
onCountdownTick?.(event.secondsLeft)
break
}
})
return unsubscribe
}, [timer, onComplete, onPhaseChange, onCountdownTick])
useEffect(() => {
// Pulse animation when running
if (timer.isRunning) {
Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.05,
duration: 800,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 800,
useNativeDriver: true,
}),
])
).start()
} else {
pulseAnim.setValue(1)
}
}, [timer.isRunning, pulseAnim])
useEffect(() => {
// Glow animation
Animated.loop(
Animated.sequence([
Animated.timing(glowAnim, {
toValue: 1,
duration: 1500,
useNativeDriver: true,
}),
Animated.timing(glowAnim, {
toValue: 0,
duration: 1500,
useNativeDriver: true,
}),
])
).start()
}, [glowAnim])
const getPhaseText = (): string => {
if (hasCompleted) return 'DONE!'
if (timer.phase === 'GET_READY') return 'GET READY'
if (timer.phase === 'WORK') return 'GO!'
if (timer.phase === 'COMPLETE') return 'DONE!'
return ''
}
const phaseColor = PHASE_COLORS[timer.phase] || PHASE_COLORS.IDLE
const displaySeconds = timer.secondsLeft > 0 ? timer.secondsLeft : 0
return (
<View style={styles.container}>
<Animated.View
style={[
styles.timerCircle,
{ borderColor: phaseColor },
{ transform: [{ scale: pulseAnim }] },
]}
>
<Animated.View
style={[
styles.glowOverlay,
{
backgroundColor: phaseColor,
opacity: glowAnim.interpolate({
inputRange: [0, 1],
outputRange: [0.05, 0.15],
}),
},
]}
/>
<Text style={[styles.countdown, { color: phaseColor }]}>
{displaySeconds}
</Text>
<Text style={[styles.phaseText, { color: phaseColor }]}>
{getPhaseText()}
</Text>
</Animated.View>
</View>
)
}
const styles = StyleSheet.create({
container: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: SPACING[6],
},
timerCircle: {
width: 200,
height: 200,
borderRadius: 100,
borderWidth: 4,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.3)',
overflow: 'hidden',
},
glowOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: 100,
},
countdown: {
...TYPOGRAPHY.timeDisplay,
fontVariant: ['tabular-nums'],
},
phaseText: {
...TYPOGRAPHY.label,
marginTop: SPACING[2],
letterSpacing: 3,
},
})

View File

@@ -1,46 +0,0 @@
import { ReactNode } from 'react'
import { StyleSheet, View, SafeAreaView } from 'react-native'
import { LinearGradient } from 'expo-linear-gradient'
import { APP_GRADIENTS } from '@/src/shared/constants/colors'
import { LAYOUT, SPACING } from '@/src/shared/constants/spacing'
import { ProgressBar } from './ProgressBar'
interface OnboardingScreenProps {
children: ReactNode
currentStep: number
totalSteps?: number
}
export function OnboardingScreen({
children,
currentStep,
totalSteps = 6,
}: OnboardingScreenProps) {
return (
<LinearGradient
colors={APP_GRADIENTS.HOME}
style={styles.gradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<SafeAreaView style={styles.container}>
<ProgressBar currentStep={currentStep} totalSteps={totalSteps} />
<View style={styles.content}>{children}</View>
</SafeAreaView>
</LinearGradient>
)
}
const styles = StyleSheet.create({
gradient: {
flex: 1,
},
container: {
flex: 1,
},
content: {
flex: 1,
paddingHorizontal: LAYOUT.PAGE_HORIZONTAL,
paddingBottom: SPACING[6],
},
})

View File

@@ -1,158 +0,0 @@
import { StyleSheet, View, Text, Pressable } from 'react-native'
import { BRAND, GLASS, TEXT, SURFACE, BORDER } from '@/src/shared/constants/colors'
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { SPACING } from '@/src/shared/constants/spacing'
interface PaywallCardProps {
title: string
price: string
period: string
features: string[]
selected: boolean
onPress: () => void
badge?: string
}
export function PaywallCard({
title,
price,
period,
features,
selected,
onPress,
badge,
}: PaywallCardProps) {
return (
<Pressable onPress={onPress} style={styles.pressable}>
<View style={[styles.container, selected && styles.containerSelected]}>
{badge && (
<View style={styles.badge}>
<Text style={styles.badgeText}>{badge}</Text>
</View>
)}
<View style={styles.header}>
<Text style={[styles.title, selected && styles.titleSelected]}>
{title}
</Text>
<View style={styles.priceRow}>
<Text style={styles.price}>{price}</Text>
<Text style={styles.period}>/{period}</Text>
</View>
</View>
<View style={styles.divider} />
<View style={styles.features}>
{features.map((feature, index) => (
<View key={index} style={styles.featureRow}>
<View style={styles.featureDot} />
<Text style={styles.featureText}>{feature}</Text>
</View>
))}
</View>
{selected && (
<View style={styles.selectedIndicator}>
<View style={styles.selectedDot} />
</View>
)}
</View>
</Pressable>
)
}
const styles = StyleSheet.create({
pressable: {
width: '100%',
},
container: {
backgroundColor: GLASS.FILL_MEDIUM,
borderWidth: 1,
borderColor: GLASS.BORDER,
borderRadius: RADIUS.XL,
padding: SPACING[4],
position: 'relative',
overflow: 'hidden',
},
containerSelected: {
backgroundColor: SURFACE.OVERLAY_MEDIUM,
borderColor: BRAND.PRIMARY,
borderWidth: 2,
},
badge: {
position: 'absolute',
top: 0,
right: 0,
backgroundColor: BRAND.PRIMARY,
paddingHorizontal: SPACING[3],
paddingVertical: SPACING[1.5],
borderBottomLeftRadius: RADIUS.MD,
},
badgeText: {
...TYPOGRAPHY.overline,
color: TEXT.PRIMARY,
fontWeight: '700',
},
header: {
marginBottom: SPACING[3],
},
title: {
...TYPOGRAPHY.heading,
color: TEXT.PRIMARY,
marginBottom: SPACING[2],
},
titleSelected: {
color: BRAND.PRIMARY,
},
priceRow: {
flexDirection: 'row',
alignItems: 'baseline',
},
price: {
...TYPOGRAPHY.displaySmall,
color: TEXT.PRIMARY,
fontWeight: '900',
},
period: {
...TYPOGRAPHY.caption,
color: TEXT.MUTED,
marginLeft: SPACING[1],
},
divider: {
height: 1,
backgroundColor: BORDER.LIGHT,
marginBottom: SPACING[3],
},
features: {
gap: SPACING[2],
},
featureRow: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING[2],
},
featureDot: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: BRAND.PRIMARY,
},
featureText: {
...TYPOGRAPHY.caption,
color: TEXT.SECONDARY,
},
selectedIndicator: {
position: 'absolute',
top: SPACING[3],
left: SPACING[3],
},
selectedDot: {
width: 12,
height: 12,
borderRadius: 6,
backgroundColor: BRAND.PRIMARY,
shadowColor: BRAND.PRIMARY,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.6,
shadowRadius: 6,
elevation: 3,
},
})

View File

@@ -1,85 +0,0 @@
import { StyleSheet, Text, Pressable, Animated } from 'react-native'
import { BRAND, TEXT } from '@/src/shared/constants/colors'
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
import { RADIUS } from '@/src/shared/constants/borderRadius'
interface PrimaryButtonProps {
title: string
onPress: () => void
disabled?: boolean
}
export function PrimaryButton({
title,
onPress,
disabled = false,
}: PrimaryButtonProps) {
const animatedValue = new Animated.Value(1)
const handlePressIn = () => {
Animated.spring(animatedValue, {
toValue: 0.96,
useNativeDriver: true,
}).start()
}
const handlePressOut = () => {
Animated.spring(animatedValue, {
toValue: 1,
friction: 3,
useNativeDriver: true,
}).start()
}
return (
<Pressable
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
disabled={disabled}
style={{ width: '100%' }}
>
<Animated.View
style={[
styles.button,
disabled && styles.buttonDisabled,
{ transform: [{ scale: animatedValue }] },
]}
>
<Text style={[styles.text, disabled && styles.textDisabled]}>
{title}
</Text>
</Animated.View>
</Pressable>
)
}
const styles = StyleSheet.create({
button: {
backgroundColor: BRAND.PRIMARY,
borderRadius: RADIUS['2XL'],
paddingVertical: 18,
paddingHorizontal: 32,
alignItems: 'center',
justifyContent: 'center',
shadowColor: BRAND.PRIMARY,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.4,
shadowRadius: 12,
elevation: 8,
},
buttonDisabled: {
backgroundColor: BRAND.PRIMARY,
opacity: 0.5,
shadowOpacity: 0,
elevation: 0,
},
text: {
...TYPOGRAPHY.buttonMedium,
color: TEXT.PRIMARY,
},
textDisabled: {
color: TEXT.PRIMARY,
opacity: 0.7,
},
})

View File

@@ -1,89 +0,0 @@
import { useEffect, useRef } from 'react'
import { StyleSheet, View, Animated } from 'react-native'
import { BRAND, SURFACE } from '@/src/shared/constants/colors'
interface ProgressBarProps {
currentStep: number
totalSteps?: number
}
export function ProgressBar({
currentStep,
totalSteps = 6,
}: ProgressBarProps) {
const scaleAnims = useRef(
Array.from({ length: totalSteps }, () => new Animated.Value(1))
).current
useEffect(() => {
// Animate the newly active dot
if (currentStep >= 0 && currentStep < totalSteps) {
Animated.sequence([
Animated.timing(scaleAnims[currentStep], {
toValue: 1.3,
duration: 150,
useNativeDriver: true,
}),
Animated.timing(scaleAnims[currentStep], {
toValue: 1,
duration: 150,
useNativeDriver: true,
}),
]).start()
}
}, [currentStep, totalSteps, scaleAnims])
return (
<View style={styles.container}>
{Array.from({ length: totalSteps }).map((_, index) => {
const isActive = index === currentStep
const isCompleted = index < currentStep
return (
<Animated.View
key={index}
style={[
styles.dot,
isActive && styles.dotActive,
isCompleted && styles.dotCompleted,
{ transform: [{ scale: scaleAnims[index] }] },
]}
/>
)
})}
</View>
)
}
const DOT_SIZE = 10
const DOT_SIZE_ACTIVE = 12
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
gap: 12,
paddingVertical: 16,
},
dot: {
width: DOT_SIZE,
height: DOT_SIZE,
borderRadius: DOT_SIZE / 2,
backgroundColor: SURFACE.OVERLAY_LIGHT,
},
dotActive: {
width: DOT_SIZE_ACTIVE,
height: DOT_SIZE_ACTIVE,
borderRadius: DOT_SIZE_ACTIVE / 2,
backgroundColor: BRAND.PRIMARY,
shadowColor: BRAND.PRIMARY,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.8,
shadowRadius: 8,
elevation: 4,
},
dotCompleted: {
backgroundColor: BRAND.PRIMARY,
},
})

View File

@@ -1,6 +0,0 @@
export { OnboardingScreen } from './OnboardingScreen'
export { ProgressBar } from './ProgressBar'
export { PrimaryButton } from './PrimaryButton'
export { ChoiceButton } from './ChoiceButton'
export { MiniTimerDemo } from './MiniTimerDemo'
export { PaywallCard } from './PaywallCard'

View File

@@ -1,15 +0,0 @@
import type { Barrier } from '../types'
export interface BarrierOption {
id: Barrier
label: string
description: string
icon: string // Ionicons name
}
export const BARRIERS: BarrierOption[] = [
{ id: 'time', label: 'Le temps', description: 'Je n\'ai pas assez de temps', icon: 'time-outline' },
{ id: 'motivation', label: 'La motivation', description: 'Je n\'arrive pas à rester motivé(e)', icon: 'flash-outline' },
{ id: 'knowledge', label: 'Le savoir', description: 'Je ne sais pas quoi faire', icon: 'book-outline' },
{ id: 'gym', label: 'La salle', description: 'Je n\'ai pas accès à une salle', icon: 'barbell-outline' },
]

View File

@@ -1,15 +0,0 @@
import type { Goal } from '../types'
export interface GoalOption {
id: Goal
label: string
description: string
icon: string
}
export const GOALS: GoalOption[] = [
{ id: 'weight_loss', label: 'Perte de poids', description: 'Brûler des graisses efficacement', icon: 'flame-outline' },
{ id: 'cardio', label: 'Cardio', description: 'Améliorer mon endurance', icon: 'heart-outline' },
{ id: 'strength', label: 'Force', description: 'Développer ma musculature', icon: 'barbell-outline' },
{ id: 'wellness', label: 'Bien-être', description: 'Me sentir mieux dans mon corps', icon: 'happy-outline' },
]

View File

@@ -1,5 +0,0 @@
// Barrel export for onboarding data
export { BARRIERS, type BarrierOption } from './barriers'
export { GOALS, type GoalOption } from './goals'
export { LEVELS, type LevelOption } from './levels'

View File

@@ -1,14 +0,0 @@
import type { Level } from '../types'
export interface LevelOption {
id: Level
label: string
description: string
icon: string
}
export const LEVELS: LevelOption[] = [
{ id: 'beginner', label: 'Débutant', description: 'Je commence le sport', icon: 'leaf-outline' },
{ id: 'intermediate', label: 'Intermédiaire', description: 'Je fais du sport régulièrement', icon: 'fitness-outline' },
{ id: 'advanced', label: 'Avancé', description: 'Je suis très actif(ve)', icon: 'trophy-outline' },
]

View File

@@ -1,137 +0,0 @@
import { create } from 'zustand'
import {
createJSONStorage,
persist,
type StateStorage,
} from 'zustand/middleware'
import AsyncStorage from '@react-native-async-storage/async-storage'
import type {
Barrier,
Frequency,
Goal,
Level,
OnboardingData,
OnboardingState,
} from '../types'
const STORAGE_KEY_COMPLETE = 'tabatago_onboarding_complete'
const STORAGE_KEY_DATA = 'tabatago_onboarding_data'
// Custom storage that uses AsyncStorage
const onboardingStorage: StateStorage = {
getItem: async (name: string): Promise<string | null> => {
return await AsyncStorage.getItem(name)
},
setItem: async (name: string, value: string): Promise<void> => {
await AsyncStorage.setItem(name, value)
},
removeItem: async (name: string): Promise<void> => {
await AsyncStorage.removeItem(name)
},
}
const initialData: OnboardingData = {
barrier: null,
level: null,
goal: null,
frequency: null,
}
interface OnboardingActions {
setStep: (step: number) => void
nextStep: () => void
prevStep: () => void
setData: (data: Partial<OnboardingData>) => void
setBarrier: (barrier: Barrier) => void
setLevel: (level: Level) => void
setGoal: (goal: Goal) => void
setFrequency: (frequency: Frequency) => void
completeOnboarding: () => void
resetOnboarding: () => void
}
type OnboardingStore = OnboardingState & OnboardingActions
export const useOnboarding = create<OnboardingStore>()(
persist(
(set, get) => ({
// Initial state
currentStep: 0,
isOnboardingComplete: false,
data: initialData,
// Actions
setStep: (step: number) => {
set({ currentStep: step })
},
nextStep: () => {
const { currentStep } = get()
set({ currentStep: currentStep + 1 })
},
prevStep: () => {
const { currentStep } = get()
if (currentStep > 0) {
set({ currentStep: currentStep - 1 })
}
},
setData: (data: Partial<OnboardingData>) => {
set((state) => ({
data: { ...state.data, ...data },
}))
},
setBarrier: (barrier: Barrier) => {
set((state) => ({
data: { ...state.data, barrier },
}))
},
setLevel: (level: Level) => {
set((state) => ({
data: { ...state.data, level },
}))
},
setGoal: (goal: Goal) => {
set((state) => ({
data: { ...state.data, goal },
}))
},
setFrequency: (frequency: Frequency) => {
set((state) => ({
data: { ...state.data, frequency },
}))
},
completeOnboarding: () => {
set({ isOnboardingComplete: true })
},
resetOnboarding: () => {
set({
currentStep: 0,
isOnboardingComplete: false,
data: initialData,
})
},
}),
{
name: STORAGE_KEY_DATA,
storage: createJSONStorage(() => onboardingStorage),
partialize: (state) => ({
isOnboardingComplete: state.isOnboardingComplete,
data: state.data,
}),
}
)
)
// Selector hooks for better performance
export const useOnboardingStep = () => useOnboarding((state) => state.currentStep)
export const useIsOnboardingComplete = () =>
useOnboarding((state) => state.isOnboardingComplete)
export const useOnboardingData = () => useOnboarding((state) => state.data)

View File

@@ -1,26 +0,0 @@
// Types
export * from './types'
// Hooks
export { useOnboarding, useOnboardingStep, useIsOnboardingComplete, useOnboardingData } from './hooks/useOnboarding'
// Components
export { OnboardingScreen } from './components/OnboardingScreen'
export { PrimaryButton } from './components/PrimaryButton'
export { ChoiceButton } from './components/ChoiceButton'
export { PaywallCard } from './components/PaywallCard'
export { MiniTimerDemo } from './components/MiniTimerDemo'
export { ProgressBar } from './components/ProgressBar'
// Screens
export { Screen1Problem } from './screens/Screen1Problem'
export { Screen2Empathy } from './screens/Screen2Empathy'
export { Screen3Solution } from './screens/Screen3Solution'
export { Screen4WowMoment } from './screens/Screen4WowMoment'
export { Screen5Personalization } from './screens/Screen5Personalization'
export { Screen6Paywall } from './screens/Screen6Paywall'
// Data
export { BARRIERS } from './data/barriers'
export { LEVELS } from './data/levels'
export { GOALS } from './data/goals'

View File

@@ -1,165 +0,0 @@
import { useEffect, useRef } from 'react'
import { StyleSheet, View, Text, Animated } from 'react-native'
import { Ionicons } from '@expo/vector-icons'
import { OnboardingScreen } from '../components/OnboardingScreen'
import { PrimaryButton } from '../components/PrimaryButton'
import { BRAND, TEXT } from '@/src/shared/constants/colors'
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
import { SPACING } from '@/src/shared/constants/spacing'
interface Screen1ProblemProps {
onNext: () => void
}
export function Screen1Problem({ onNext }: Screen1ProblemProps) {
const clockScale = useRef(new Animated.Value(1)).current
const clockRotation = useRef(new Animated.Value(0)).current
const opacityAnim = useRef(new Animated.Value(0)).current
const translateYAnim = useRef(new Animated.Value(20)).current
useEffect(() => {
// Entrance animation
Animated.parallel([
Animated.timing(opacityAnim, {
toValue: 1,
duration: 600,
useNativeDriver: true,
}),
Animated.timing(translateYAnim, {
toValue: 0,
duration: 600,
useNativeDriver: true,
}),
]).start()
// Clock pulse animation
const pulseAnimation = Animated.loop(
Animated.sequence([
Animated.timing(clockScale, {
toValue: 1.1,
duration: 800,
useNativeDriver: true,
}),
Animated.timing(clockScale, {
toValue: 1,
duration: 800,
useNativeDriver: true,
}),
])
)
// Clock rotation animation
const rotationAnimation = Animated.loop(
Animated.timing(clockRotation, {
toValue: 1,
duration: 4000,
useNativeDriver: true,
})
)
pulseAnimation.start()
rotationAnimation.start()
return () => {
pulseAnimation.stop()
rotationAnimation.stop()
}
}, [clockScale, clockRotation, opacityAnim, translateYAnim])
const rotationInterpolate = clockRotation.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '360deg'],
})
return (
<OnboardingScreen currentStep={0}>
<Animated.View
style={[
styles.container,
{
opacity: opacityAnim,
transform: [{ translateY: translateYAnim }],
},
]}
>
{/* Clock Icon Animation */}
<View style={styles.iconContainer}>
<Animated.View
style={[
styles.clockCircle,
{ transform: [{ scale: clockScale }] },
]}
>
<Animated.View style={{ transform: [{ rotate: rotationInterpolate }] }}>
<Ionicons name="time-outline" size={80} color={BRAND.PRIMARY} />
</Animated.View>
</Animated.View>
<View style={styles.crossMark}>
<Ionicons name="close" size={32} color={BRAND.DANGER} />
</View>
</View>
{/* Title and Subtitle */}
<View style={styles.textContainer}>
<Text style={styles.title}>Tu n'as pas 1 heure pour t'entrainer ?</Text>
<Text style={styles.subtitle}>Ni 30 minutes ? Ni meme 10 ?</Text>
</View>
{/* Spacer */}
<View style={styles.spacer} />
{/* Continue Button */}
<PrimaryButton title="Continuer" onPress={onNext} />
</Animated.View>
</OnboardingScreen>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: SPACING[4],
},
iconContainer: {
position: 'relative',
marginBottom: SPACING[10],
},
clockCircle: {
width: 140,
height: 140,
borderRadius: 70,
backgroundColor: 'rgba(249, 115, 22, 0.1)',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2,
borderColor: 'rgba(249, 115, 22, 0.3)',
},
crossMark: {
position: 'absolute',
bottom: -5,
right: -5,
backgroundColor: 'rgba(239, 68, 68, 0.2)',
borderRadius: 20,
padding: SPACING[1],
},
textContainer: {
alignItems: 'center',
marginBottom: SPACING[8],
},
title: {
...TYPOGRAPHY.displayLarge,
color: TEXT.PRIMARY,
textAlign: 'center',
marginBottom: SPACING[4],
},
subtitle: {
...TYPOGRAPHY.heading,
color: TEXT.MUTED,
textAlign: 'center',
},
spacer: {
flex: 1,
},
})

View File

@@ -1,104 +0,0 @@
import { useState, useEffect, useRef } from 'react'
import { StyleSheet, View, Text, Animated } from 'react-native'
import { OnboardingScreen } from '../components/OnboardingScreen'
import { ChoiceButton } from '../components/ChoiceButton'
import { useOnboarding } from '../hooks/useOnboarding'
import { BARRIERS } from '../data/barriers'
import { TEXT } from '@/src/shared/constants/colors'
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
import { SPACING } from '@/src/shared/constants/spacing'
import type { Barrier } from '../types'
interface Screen2EmpathyProps {
onNext: () => void
}
export function Screen2Empathy({ onNext }: Screen2EmpathyProps) {
const [selectedBarrier, setSelectedBarrier] = useState<Barrier | null>(null)
const { setBarrier } = useOnboarding()
const opacityAnim = useRef(new Animated.Value(0)).current
const translateYAnim = useRef(new Animated.Value(20)).current
useEffect(() => {
// Entrance animation
Animated.parallel([
Animated.timing(opacityAnim, {
toValue: 1,
duration: 600,
useNativeDriver: true,
}),
Animated.timing(translateYAnim, {
toValue: 0,
duration: 600,
useNativeDriver: true,
}),
]).start()
}, [opacityAnim, translateYAnim])
const handleBarrierSelect = (barrier: Barrier) => {
setSelectedBarrier(barrier)
setBarrier(barrier)
// Auto-advance after selection
setTimeout(() => {
onNext()
}, 300)
}
return (
<OnboardingScreen currentStep={1}>
<Animated.View
style={[
styles.container,
{
opacity: opacityAnim,
transform: [{ translateY: translateYAnim }],
},
]}
>
{/* Title */}
<View style={styles.titleContainer}>
<Text style={styles.title}>
Qu'est-ce qui t'empeche de t'entrainer ?
</Text>
</View>
{/* Choice Buttons */}
<View style={styles.choicesContainer}>
{BARRIERS.map((barrier, index) => (
<View key={barrier.id} style={styles.choiceWrapper}>
<ChoiceButton
label={barrier.label}
description={barrier.description}
icon={barrier.icon as never}
selected={selectedBarrier === barrier.id}
onPress={() => handleBarrierSelect(barrier.id)}
/>
</View>
))}
</View>
</Animated.View>
</OnboardingScreen>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingTop: SPACING[8],
},
titleContainer: {
marginBottom: SPACING[8],
paddingHorizontal: SPACING[2],
},
title: {
...TYPOGRAPHY.heading,
color: TEXT.PRIMARY,
textAlign: 'center',
},
choicesContainer: {
gap: SPACING[3],
},
choiceWrapper: {
width: '100%',
},
})

View File

@@ -1,251 +0,0 @@
import { useEffect, useRef } from 'react'
import { StyleSheet, View, Text, Animated } from 'react-native'
import { OnboardingScreen } from '../components/OnboardingScreen'
import { PrimaryButton } from '../components/PrimaryButton'
import { BRAND, TEXT, SURFACE, PHASE_COLORS } 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'
interface Screen3SolutionProps {
onNext: () => void
}
const TABATA_ROUNDS = 8
const WORK_DURATION = 20
const REST_DURATION = 10
export function Screen3Solution({ onNext }: Screen3SolutionProps) {
const opacityAnim = useRef(new Animated.Value(0)).current
const translateYAnim = useRef(new Animated.Value(20)).current
const activeRound = useRef(new Animated.Value(0)).current
const pulseAnim = useRef(new Animated.Value(1)).current
useEffect(() => {
// Entrance animation
Animated.parallel([
Animated.timing(opacityAnim, {
toValue: 1,
duration: 600,
useNativeDriver: true,
}),
Animated.timing(translateYAnim, {
toValue: 0,
duration: 600,
useNativeDriver: true,
}),
]).start()
// Round cycling animation
const roundAnimation = Animated.loop(
Animated.sequence([
...Array.from({ length: TABATA_ROUNDS }, (_, i) =>
Animated.timing(activeRound, {
toValue: i + 1,
duration: 800,
useNativeDriver: false,
})
),
Animated.timing(activeRound, {
toValue: 0,
duration: 500,
useNativeDriver: false,
}),
])
)
// Pulse animation for the "4 minutes" text
const pulseAnimation = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.05,
duration: 600,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 600,
useNativeDriver: true,
}),
])
)
roundAnimation.start()
pulseAnimation.start()
return () => {
roundAnimation.stop()
pulseAnimation.stop()
}
}, [activeRound, opacityAnim, translateYAnim, pulseAnim])
const renderTabataTimeline = () => {
return (
<View style={styles.timelineContainer}>
{Array.from({ length: TABATA_ROUNDS }, (_, index) => {
const isWork = index % 2 === 0
const baseColor = isWork ? PHASE_COLORS.WORK : PHASE_COLORS.REST
return (
<Animated.View
key={index}
style={[
styles.timelineBlock,
{
backgroundColor: activeRound.interpolate({
inputRange: [index, index + 1, index + 1.01, TABATA_ROUNDS + 1],
outputRange: [
'rgba(255, 255, 255, 0.1)',
baseColor,
'rgba(255, 255, 255, 0.1)',
'rgba(255, 255, 255, 0.1)',
],
extrapolate: 'clamp',
}),
},
]}
>
<Text style={styles.timelineText}>
{isWork ? WORK_DURATION : REST_DURATION}s
</Text>
</Animated.View>
)
})}
</View>
)
}
return (
<OnboardingScreen currentStep={2}>
<Animated.View
style={[
styles.container,
{
opacity: opacityAnim,
transform: [{ translateY: translateYAnim }],
},
]}
>
{/* Animated Title */}
<Animated.View style={[styles.titleContainer, { transform: [{ scale: pulseAnim }] }]}>
<Text style={styles.title}>4 minutes</Text>
</Animated.View>
{/* Subtitle */}
<Text style={styles.subtitle}>Vraiment transformatrices.</Text>
{/* Tabata Timeline Animation */}
<View style={styles.animationContainer}>
{renderTabataTimeline()}
{/* Legend */}
<View style={styles.legendContainer}>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: PHASE_COLORS.WORK }]} />
<Text style={styles.legendText}>20s travail</Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: PHASE_COLORS.REST }]} />
<Text style={styles.legendText}>10s repos</Text>
</View>
<Text style={styles.legendText}>x 8 rounds</Text>
</View>
</View>
{/* Scientific Explanation */}
<View style={styles.explanationContainer}>
<Text style={styles.explanationText}>
Protocole scientifique HIIT = resultats max en temps min
</Text>
</View>
{/* Spacer */}
<View style={styles.spacer} />
{/* Continue Button */}
<PrimaryButton title="Voir comment ca marche" onPress={onNext} />
</Animated.View>
</OnboardingScreen>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
paddingTop: SPACING[10],
},
titleContainer: {
marginBottom: SPACING[2],
},
title: {
...TYPOGRAPHY.brandTitle,
color: BRAND.PRIMARY,
textAlign: 'center',
},
subtitle: {
...TYPOGRAPHY.heading,
color: TEXT.SECONDARY,
textAlign: 'center',
marginBottom: SPACING[8],
},
animationContainer: {
width: '100%',
paddingVertical: SPACING[6],
paddingHorizontal: SPACING[4],
},
timelineContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'center',
gap: SPACING[2],
marginBottom: SPACING[4],
},
timelineBlock: {
width: 40,
height: 40,
borderRadius: RADIUS.MD,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: SURFACE.OVERLAY_LIGHT,
},
timelineText: {
...TYPOGRAPHY.overline,
color: TEXT.PRIMARY,
fontWeight: '700',
},
legendContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: SPACING[4],
flexWrap: 'wrap',
},
legendItem: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING[1],
},
legendDot: {
width: 10,
height: 10,
borderRadius: 5,
},
legendText: {
...TYPOGRAPHY.caption,
color: TEXT.MUTED,
},
explanationContainer: {
paddingHorizontal: SPACING[4],
marginTop: SPACING[4],
},
explanationText: {
...TYPOGRAPHY.body,
color: TEXT.TERTIARY,
textAlign: 'center',
lineHeight: 26,
},
spacer: {
flex: 1,
},
})

View File

@@ -1,149 +0,0 @@
import { useState, useEffect, useCallback } from 'react'
import { StyleSheet, View, Text, Animated } from 'react-native'
import * as Haptics from 'expo-haptics'
import { OnboardingScreen } from '../components/OnboardingScreen'
import { PrimaryButton } from '../components/PrimaryButton'
import { MiniTimerDemo } from '../components/MiniTimerDemo'
import { useOnboarding } from '../hooks/useOnboarding'
import { TEXT } from '@/src/shared/constants/colors'
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
import { SPACING } from '@/src/shared/constants/spacing'
export function Screen4WowMoment() {
const nextStep = useOnboarding((state) => state.nextStep)
const [isComplete, setIsComplete] = useState(false)
const [currentPhase, setCurrentPhase] = useState<string>('IDLE')
const fadeAnim = useState(new Animated.Value(0))[0]
// Fade in animation for the button when complete
useEffect(() => {
if (isComplete) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
Animated.timing(fadeAnim, {
toValue: 1,
duration: 400,
useNativeDriver: true,
}).start()
}
}, [isComplete, fadeAnim])
const getInstructionText = (): string => {
if (isComplete) {
return 'Bravo ! Tu viens de completer ton premier mini-Tabata.'
}
if (currentPhase === 'GET_READY') {
return 'Prepare-toi... Le timer va bientot commencer !'
}
if (currentPhase === 'WORK') {
return 'Donne tout ! 20 secondes, c est parti !'
}
return 'Un mini-Tabata de 20 secondes. Juste pour sentir.'
}
const handlePhaseChange = useCallback((phase: string) => {
setCurrentPhase(phase)
if (phase === 'WORK') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium)
} else if (phase === 'GET_READY') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)
}
}, [])
const handleCountdownTick = useCallback((seconds: number) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)
}, [])
const handleComplete = useCallback(() => {
setIsComplete(true)
}, [])
const handleContinue = () => {
nextStep()
}
return (
<OnboardingScreen currentStep={3}>
<View style={styles.container}>
{/* Title Section */}
<View style={styles.header}>
<Text style={styles.title}>Essaie maintenant</Text>
<Text style={styles.subtitle}>20 secondes. Juste pour sentir.</Text>
</View>
{/* Timer Demo - The "Wow" Moment */}
<View style={styles.timerContainer}>
<MiniTimerDemo
onComplete={handleComplete}
onPhaseChange={handlePhaseChange}
onCountdownTick={handleCountdownTick}
/>
</View>
{/* Instruction Text */}
<View style={styles.instructionContainer}>
<Text style={styles.instructionText}>{getInstructionText()}</Text>
</View>
{/* Continue Button - Only visible after completion */}
<View style={styles.buttonContainer}>
{isComplete ? (
<Animated.View style={{ opacity: fadeAnim, width: '100%' }}>
<PrimaryButton title="C'etait facile !" onPress={handleContinue} />
</Animated.View>
) : (
<View style={styles.placeholderButton} />
)}
</View>
</View>
</OnboardingScreen>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'space-between',
paddingTop: SPACING[10],
},
header: {
alignItems: 'center',
paddingHorizontal: SPACING[4],
},
title: {
...TYPOGRAPHY.displayLarge,
color: TEXT.PRIMARY,
textAlign: 'center',
marginBottom: SPACING[3],
},
subtitle: {
...TYPOGRAPHY.body,
color: TEXT.SECONDARY,
textAlign: 'center',
},
timerContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
instructionContainer: {
paddingHorizontal: SPACING[6],
paddingVertical: SPACING[4],
alignItems: 'center',
},
instructionText: {
...TYPOGRAPHY.caption,
color: TEXT.TERTIARY,
textAlign: 'center',
lineHeight: 24,
},
buttonContainer: {
paddingHorizontal: SPACING[4],
paddingBottom: SPACING[4],
minHeight: 70,
alignItems: 'center',
justifyContent: 'center',
},
placeholderButton: {
height: 54,
},
})

View File

@@ -1,186 +0,0 @@
import { useState } from 'react'
import { StyleSheet, View, Text, ScrollView } from 'react-native'
import { Ionicons } from '@expo/vector-icons'
import { useOnboarding } from '../hooks/useOnboarding'
import { LEVELS } from '../data/levels'
import { GOALS } from '../data/goals'
import { OnboardingScreen } from '../components/OnboardingScreen'
import { ChoiceButton } from '../components/ChoiceButton'
import { PrimaryButton } from '../components/PrimaryButton'
import { BRAND, TEXT } from '@/src/shared/constants/colors'
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
import { SPACING } from '@/src/shared/constants/spacing'
import type { Level, Goal, Frequency } from '../types'
interface Screen5PersonalizationProps {
onNext: () => void
}
interface FrequencyOption {
id: Frequency
label: string
description: string
}
const FREQUENCIES: FrequencyOption[] = [
{ id: 2, label: '2x/semaine', description: 'Démarrage en douceur' },
{ id: 3, label: '3x/semaine', description: 'Rythme equilibre' },
{ id: 5, label: '5x/semaine', description: 'Entrainement intensif' },
]
export function Screen5Personalization({ onNext }: Screen5PersonalizationProps) {
const { data, setLevel, setGoal, setFrequency } = useOnboarding()
// Local state for selections
const [selectedLevel, setSelectedLevel] = useState<Level | null>(data.level)
const [selectedGoal, setSelectedGoal] = useState<Goal | null>(data.goal)
const [selectedFrequency, setSelectedFrequency] = useState<Frequency | null>(data.frequency)
const handleLevelSelect = (level: Level) => {
setSelectedLevel(level)
setLevel(level)
}
const handleGoalSelect = (goal: Goal) => {
setSelectedGoal(goal)
setGoal(goal)
}
const handleFrequencySelect = (frequency: Frequency) => {
setSelectedFrequency(frequency)
setFrequency(frequency)
}
const handleContinue = () => {
onNext()
}
const isFormComplete = selectedLevel && selectedGoal && selectedFrequency
return (
<OnboardingScreen currentStep={4}>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<View style={styles.header}>
<Text style={styles.title}>Personnalise ton experience</Text>
<Text style={styles.subtitle}>
Dis-nous en plus sur toi pour un programme sur mesure
</Text>
</View>
{/* Level Section */}
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Ionicons name="fitness-outline" size={20} color={BRAND.PRIMARY} />
<Text style={styles.sectionTitle}>Niveau</Text>
</View>
<View style={styles.optionsContainer}>
{LEVELS.map((level) => (
<ChoiceButton
key={level.id}
label={level.label}
description={level.description}
icon={level.icon as keyof typeof Ionicons.glyphMap}
selected={selectedLevel === level.id}
onPress={() => handleLevelSelect(level.id)}
/>
))}
</View>
</View>
{/* Goal Section */}
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Ionicons name="flag-outline" size={20} color={BRAND.PRIMARY} />
<Text style={styles.sectionTitle}>Objectif</Text>
</View>
<View style={styles.optionsContainer}>
{GOALS.map((goal) => (
<ChoiceButton
key={goal.id}
label={goal.label}
description={goal.description}
icon={goal.icon as keyof typeof Ionicons.glyphMap}
selected={selectedGoal === goal.id}
onPress={() => handleGoalSelect(goal.id)}
/>
))}
</View>
</View>
{/* Frequency Section */}
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Ionicons name="calendar-outline" size={20} color={BRAND.PRIMARY} />
<Text style={styles.sectionTitle}>Frequence</Text>
</View>
<View style={styles.optionsContainer}>
{FREQUENCIES.map((freq) => (
<ChoiceButton
key={freq.id}
label={freq.label}
description={freq.description}
icon="time-outline"
selected={selectedFrequency === freq.id}
onPress={() => handleFrequencySelect(freq.id)}
/>
))}
</View>
</View>
</ScrollView>
<View style={styles.footer}>
<PrimaryButton
title="Continuer"
onPress={handleContinue}
disabled={!isFormComplete}
/>
</View>
</OnboardingScreen>
)
}
const styles = StyleSheet.create({
scrollView: {
flex: 1,
},
scrollContent: {
paddingBottom: SPACING[6],
},
header: {
marginBottom: SPACING[6],
},
title: {
...TYPOGRAPHY.heading,
color: TEXT.PRIMARY,
marginBottom: SPACING[2],
},
subtitle: {
...TYPOGRAPHY.caption,
color: TEXT.MUTED,
},
section: {
marginBottom: SPACING[6],
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING[2],
marginBottom: SPACING[3],
},
sectionTitle: {
...TYPOGRAPHY.label,
color: TEXT.SECONDARY,
textTransform: 'uppercase',
letterSpacing: 1,
},
optionsContainer: {
gap: SPACING[3],
},
footer: {
paddingTop: SPACING[4],
},
})

View File

@@ -1,234 +0,0 @@
import { useState } from 'react'
import { StyleSheet, View, Text, ScrollView, Pressable } from 'react-native'
import { useOnboarding } from '../hooks/useOnboarding'
import { OnboardingScreen } from '../components/OnboardingScreen'
import { PaywallCard } from '../components/PaywallCard'
import { BRAND, TEXT } from '@/src/shared/constants/colors'
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
import { SPACING } from '@/src/shared/constants/spacing'
interface Screen6PaywallProps {
onComplete: () => void
}
interface PlanOption {
id: 'trial' | 'monthly' | 'annual'
title: string
price: string
period: string
features: string[]
badge?: string
}
const PLANS: PlanOption[] = [
{
id: 'trial',
title: 'Essai gratuit',
price: '0',
period: '7 jours',
features: [
'Acces complet pendant 7 jours',
'Tous les programmes debloques',
'Annule a tout moment',
],
},
{
id: 'monthly',
title: 'Mensuel',
price: '4.99',
period: 'mois',
features: [
'Acces illimite',
'Tous les programmes',
'Support prioritaire',
'Nouvelles fonctionnalites',
],
},
{
id: 'annual',
title: 'Annuel',
price: '29.99',
period: 'an',
badge: 'Economise 50%',
features: [
'Acces illimite',
'Tous les programmes',
'Support prioritaire',
'Nouvelles fonctionnalites',
'Entrainements exclusifs',
],
},
]
export function Screen6Paywall({ onComplete }: Screen6PaywallProps) {
const { completeOnboarding } = useOnboarding()
const [selectedPlan, setSelectedPlan] = useState<'trial' | 'monthly' | 'annual'>('annual')
const [isLoading, setIsLoading] = useState(false)
const handlePlanSelect = (planId: 'trial' | 'monthly' | 'annual') => {
setSelectedPlan(planId)
}
const handlePurchase = async () => {
setIsLoading(true)
try {
// Mock RevenueCat purchase in dev mode
// In production, this would call the actual RevenueCat SDK
await mockPurchase(selectedPlan)
// Complete onboarding and navigate to home
completeOnboarding()
onComplete()
} catch (error) {
console.error('Purchase failed:', error)
// In production, show error to user
} finally {
setIsLoading(false)
}
}
const handleSkip = () => {
// Skip paywall and go to home
completeOnboarding()
onComplete()
}
return (
<OnboardingScreen currentStep={5}>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<View style={styles.header}>
<Text style={styles.title}>Debloque ton potentiel</Text>
<Text style={styles.subtitle}>
7 jours gratuits, annule quand tu veux
</Text>
</View>
<View style={styles.plansContainer}>
{PLANS.map((plan) => (
<PaywallCard
key={plan.id}
title={plan.title}
price={plan.price}
period={plan.period}
features={plan.features}
badge={plan.badge}
selected={selectedPlan === plan.id}
onPress={() => handlePlanSelect(plan.id)}
/>
))}
</View>
<View style={styles.guarantee}>
<Text style={styles.guaranteeText}>
Garantie satisfait ou rembourse sous 30 jours
</Text>
</View>
</ScrollView>
<View style={styles.footer}>
<Pressable
style={[styles.continueButton, isLoading && styles.buttonLoading]}
onPress={handlePurchase}
disabled={isLoading}
>
<Text style={styles.continueButtonText}>
{isLoading ? 'Traitement...' : selectedPlan === 'trial' ? 'Essayer gratuitement' : "S'abonner"}
</Text>
</Pressable>
<Pressable style={styles.skipButton} onPress={handleSkip}>
<Text style={styles.skipButtonText}>
Continuer sans abonnement
</Text>
</Pressable>
</View>
</OnboardingScreen>
)
}
// Mock RevenueCat purchase function for dev mode
async function mockPurchase(planId: string): Promise<void> {
// Simulate network delay
await new Promise((resolve) => setTimeout(resolve, 1500))
// Simulate successful purchase
console.log(`[MOCK] Purchase successful for plan: ${planId}`)
// In production with RevenueCat:
// const { customerInfo } = await Purchases.purchasePackage(package)
// if (customerInfo.entitlements.active['pro']) { ... }
}
const styles = StyleSheet.create({
scrollView: {
flex: 1,
},
scrollContent: {
paddingBottom: SPACING[6],
},
header: {
marginBottom: SPACING[6],
alignItems: 'center',
},
title: {
...TYPOGRAPHY.heading,
color: TEXT.PRIMARY,
marginBottom: SPACING[2],
textAlign: 'center',
},
subtitle: {
...TYPOGRAPHY.caption,
color: TEXT.MUTED,
textAlign: 'center',
},
plansContainer: {
gap: SPACING[4],
},
guarantee: {
marginTop: SPACING[6],
alignItems: 'center',
},
guaranteeText: {
...TYPOGRAPHY.caption,
color: TEXT.HINT,
textAlign: 'center',
},
footer: {
paddingTop: SPACING[4],
},
continueButton: {
backgroundColor: BRAND.PRIMARY,
borderRadius: 28,
paddingVertical: 18,
paddingHorizontal: 32,
alignItems: 'center',
justifyContent: 'center',
shadowColor: BRAND.PRIMARY,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.4,
shadowRadius: 12,
elevation: 8,
},
buttonLoading: {
opacity: 0.7,
},
continueButtonText: {
...TYPOGRAPHY.buttonMedium,
color: TEXT.PRIMARY,
},
skipButton: {
marginTop: SPACING[4],
paddingVertical: SPACING[2],
alignItems: 'center',
},
skipButtonText: {
...TYPOGRAPHY.caption,
color: TEXT.HINT,
textDecorationLine: 'underline',
},
})

View File

@@ -1,2 +0,0 @@
export { Screen5Personalization } from './Screen5Personalization'
export { Screen6Paywall } from './Screen6Paywall'

View File

@@ -1,20 +0,0 @@
export type Barrier = 'time' | 'motivation' | 'knowledge' | 'gym'
export type Level = 'beginner' | 'intermediate' | 'advanced'
export type Goal = 'weight_loss' | 'cardio' | 'strength' | 'wellness'
export type Frequency = 2 | 3 | 5
export interface OnboardingData {
barrier: Barrier | null
level: Level | null
goal: Goal | null
frequency: Frequency | null
}
export interface OnboardingState {
currentStep: number
isOnboardingComplete: boolean
data: OnboardingData
}

View File

@@ -1,83 +0,0 @@
import { Pressable, StyleSheet, View } from 'react-native'
import Ionicons from '@expo/vector-icons/Ionicons'
import { SURFACE, TEXT } from '@/src/shared/constants/colors'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { LAYOUT } from '@/src/shared/constants/spacing'
interface TimerControlsProps {
isRunning: boolean
isPaused: boolean
onPause: () => void
onResume: () => void
onStop: () => void
onSkip: () => void
}
export function TimerControls({
isRunning,
isPaused,
onPause,
onResume,
onStop,
onSkip,
}: TimerControlsProps) {
return (
<View style={styles.container}>
<Pressable
style={({ pressed }) => [styles.button, pressed && styles.pressed]}
onPress={onStop}
>
<Ionicons name="stop" size={28} color={TEXT.PRIMARY} />
</Pressable>
<Pressable
style={({ pressed }) => [
styles.button,
styles.mainButton,
pressed && styles.pressed,
]}
onPress={isPaused ? onResume : onPause}
>
<Ionicons
name={isPaused ? 'play' : 'pause'}
size={36}
color={TEXT.PRIMARY}
/>
</Pressable>
<Pressable
style={({ pressed }) => [styles.button, pressed && styles.pressed]}
onPress={onSkip}
>
<Ionicons name="play-skip-forward" size={28} color={TEXT.PRIMARY} />
</Pressable>
</View>
)
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: LAYOUT.CONTROLS_GAP,
},
button: {
width: 56,
height: 56,
borderRadius: RADIUS['2XL'],
backgroundColor: SURFACE.OVERLAY_MEDIUM,
alignItems: 'center',
justifyContent: 'center',
},
mainButton: {
width: 72,
height: 72,
borderRadius: RADIUS['4XL'],
backgroundColor: SURFACE.OVERLAY_STRONG,
},
pressed: {
transform: [{ scale: 0.92 }],
},
})

View File

@@ -1,615 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import {
Animated,
Pressable,
StyleSheet,
Text,
View,
} from 'react-native'
import { LinearGradient } from 'expo-linear-gradient'
import { BlurView } from 'expo-blur'
import { StatusBar } from 'expo-status-bar'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import {
PHASE_GRADIENTS,
ACCENT,
BRAND,
SURFACE,
TEXT,
BORDER,
APP_GRADIENTS,
GLASS,
} from '@/src/shared/constants/colors'
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
import { SHADOW, TEXT_SHADOW } from '@/src/shared/constants/shadows'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { LAYOUT, SPACING } from '@/src/shared/constants/spacing'
import { SPRING, ANIMATION } from '@/src/shared/constants/animations'
import { formatTime } from '@/src/shared/utils/formatTime'
import type { TimerConfig, TimerPhase, TimerState } from '../types'
import { TimerControls } from './TimerControls'
const PHASE_LABELS: Record<TimerPhase, string> = {
IDLE: '',
GET_READY: 'PRÉPARE-TOI',
WORK: 'GO !',
REST: 'REPOS',
COMPLETE: 'TERMINÉ !',
}
interface TimerDisplayProps {
state: TimerState
config: TimerConfig
exerciseName: string
nextExerciseName: string
onStart: () => void
onPause: () => void
onResume: () => void
onStop: () => void
onSkip: () => void
}
export function TimerDisplay({
state,
config,
exerciseName,
nextExerciseName,
onStart,
onPause,
onResume,
onStop,
onSkip,
}: TimerDisplayProps) {
const insets = useSafeAreaInsets()
const top = insets.top || 20
const bottom = insets.bottom || 20
if (state.phase === 'IDLE') {
return <IdleView config={config} onStart={onStart} top={top} bottom={bottom} />
}
if (state.phase === 'COMPLETE') {
return (
<CompleteView
totalElapsedSeconds={state.totalElapsedSeconds}
totalRounds={state.totalRounds}
onStop={onStop}
top={top}
bottom={bottom}
/>
)
}
return (
<ActiveView
state={state}
exerciseName={exerciseName}
nextExerciseName={nextExerciseName}
onPause={onPause}
onResume={onResume}
onStop={onStop}
onSkip={onSkip}
top={top}
bottom={bottom}
/>
)
}
// ──────────────────────────────────────────────────────
// IDLE — Start screen
// ──────────────────────────────────────────────────────
function IdleView({
config,
onStart,
top,
bottom,
}: {
config: TimerConfig
onStart: () => void
top: number
bottom: number
}) {
const glowAnim = useRef(new Animated.Value(0)).current
useEffect(() => {
Animated.loop(
Animated.sequence([
Animated.timing(glowAnim, {
toValue: 1,
...ANIMATION.BREATH_HALF,
}),
Animated.timing(glowAnim, {
toValue: 0,
...ANIMATION.BREATH_HALF,
}),
])
).start()
}, [])
const glowOpacity = glowAnim.interpolate({
inputRange: [0, 1],
outputRange: [0.15, 0.4],
})
const glowScale = glowAnim.interpolate({
inputRange: [0, 1],
outputRange: [1, 1.12],
})
return (
<LinearGradient
colors={APP_GRADIENTS.HOME}
style={[styles.screen, { paddingTop: top, paddingBottom: bottom }]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<StatusBar style="light" />
<View style={styles.idleContent}>
<Text style={styles.idleTitle}>TABATA</Text>
<Text style={styles.idleSubtitle}>GO</Text>
<View style={styles.configSummary}>
<View style={styles.configRow}>
<ConfigBadge label={`${config.workDuration}s`} sub="travail" />
<ConfigBadge label={`${config.restDuration}s`} sub="repos" />
<ConfigBadge label={`${config.rounds}`} sub="rounds" />
</View>
</View>
<View style={styles.startButtonContainer}>
<Animated.View
style={[
styles.startButtonGlow,
{ opacity: glowOpacity, transform: [{ scale: glowScale }] },
]}
/>
<Pressable
style={({ pressed }) => [
styles.startButton,
pressed && styles.buttonPressed,
]}
onPress={onStart}
>
<Text style={styles.startButtonText}>START</Text>
</Pressable>
</View>
</View>
</LinearGradient>
)
}
function ConfigBadge({ label, sub }: { label: string; sub: string }) {
return (
<View style={styles.configBadge}>
<Text style={styles.configBadgeLabel}>{label}</Text>
<Text style={styles.configBadgeSub}>{sub}</Text>
</View>
)
}
// ──────────────────────────────────────────────────────
// COMPLETE — Victory screen
// ──────────────────────────────────────────────────────
function CompleteView({
totalElapsedSeconds,
totalRounds,
onStop,
top,
bottom,
}: {
totalElapsedSeconds: number
totalRounds: number
onStop: () => void
top: number
bottom: number
}) {
const scaleAnim = useRef(new Animated.Value(0.5)).current
const opacityAnim = useRef(new Animated.Value(0)).current
useEffect(() => {
Animated.parallel([
Animated.spring(scaleAnim, {
toValue: 1,
...SPRING.BOUNCY,
useNativeDriver: true,
}),
Animated.timing(opacityAnim, ANIMATION.FADE_IN),
]).start()
}, [])
return (
<LinearGradient
colors={PHASE_GRADIENTS.COMPLETE}
style={[styles.screen, { paddingTop: top, paddingBottom: bottom }]}
>
<StatusBar style="light" />
<Animated.View
style={[
styles.completeContent,
{ opacity: opacityAnim, transform: [{ scale: scaleAnim }] },
]}
>
<Text style={styles.completeEmoji}>&#x1F525;</Text>
<Text style={styles.completeTitle}>TERMINÉ !</Text>
<Text style={styles.completeTime}>{formatTime(totalElapsedSeconds)}</Text>
<Text style={styles.completeRounds}>
{totalRounds} rounds complétés
</Text>
<Pressable
style={({ pressed }) => [
styles.doneButton,
pressed && styles.buttonPressed,
]}
onPress={onStop}
>
<Text style={styles.doneButtonText}>Terminer</Text>
</Pressable>
</Animated.View>
</LinearGradient>
)
}
// ──────────────────────────────────────────────────────
// ACTIVE — Countdown with native blur toolbar
// ──────────────────────────────────────────────────────
function ActiveView({
state,
exerciseName,
nextExerciseName,
onPause,
onResume,
onStop,
onSkip,
top,
bottom,
}: {
state: TimerState
exerciseName: string
nextExerciseName: string
onPause: () => void
onResume: () => void
onStop: () => void
onSkip: () => void
top: number
bottom: number
}) {
const gradient = PHASE_GRADIENTS[state.phase] ?? PHASE_GRADIENTS.IDLE
// --- Gradient crossfade ---
const [prevGradient, setPrevGradient] = useState<readonly string[]>(PHASE_GRADIENTS.IDLE)
const fadeAnim = useRef(new Animated.Value(1)).current
useEffect(() => {
fadeAnim.setValue(0)
Animated.timing(fadeAnim, ANIMATION.GRADIENT_CROSSFADE).start(() => {
setPrevGradient(gradient)
})
}, [state.phase])
// --- Countdown pulse ---
const pulseAnim = useRef(new Animated.Value(1)).current
useEffect(() => {
if (state.isRunning) {
Animated.sequence([
Animated.timing(pulseAnim, ANIMATION.PULSE_UP),
Animated.timing(pulseAnim, ANIMATION.PULSE_DOWN),
]).start()
}
}, [state.secondsLeft])
const isLastSeconds = state.secondsLeft <= 3 && state.secondsLeft > 0
const topLabel =
state.phase === 'GET_READY'
? exerciseName
: state.phase === 'REST'
? `Prochain : ${nextExerciseName}`
: exerciseName
return (
<View style={styles.screen}>
<StatusBar hidden />
{/* Gradient layers */}
<LinearGradient
colors={prevGradient as [string, string, ...string[]]}
style={StyleSheet.absoluteFill}
/>
<Animated.View style={[StyleSheet.absoluteFill, { opacity: fadeAnim }]}>
<LinearGradient
colors={gradient as unknown as [string, string, ...string[]]}
style={StyleSheet.absoluteFill}
/>
</Animated.View>
{/* Content */}
<View style={[styles.activeContent, { paddingTop: top }]}>
{/* Top bar */}
<View style={styles.topZone}>
<Text style={styles.exerciseName} numberOfLines={1}>
{topLabel}
</Text>
{state.currentRound > 0 && (
<View style={styles.roundBadge}>
<Text style={styles.roundBadgeText}>
{state.currentRound}/{state.totalRounds}
</Text>
</View>
)}
</View>
{/* Center — countdown */}
<View style={styles.centerZone}>
<Text style={styles.phaseLabel}>
{PHASE_LABELS[state.phase]}
</Text>
<Animated.Text
style={[
styles.countdown,
{ transform: [{ scale: pulseAnim }] },
isLastSeconds && styles.countdownFlash,
]}
>
{state.secondsLeft}
</Animated.Text>
{state.isPaused && (
<View style={styles.pausedContainer}>
<Text style={styles.pausedLabel}>EN PAUSE</Text>
</View>
)}
</View>
{/* Bottom toolbar — native blur like iOS UIToolbar */}
<BlurView
intensity={GLASS.BLUR_HEAVY}
tint="dark"
style={[styles.bottomBar, { paddingBottom: bottom + 8 }]}
>
<View style={styles.roundDots}>
{Array.from({ length: state.totalRounds }, (_, i) => (
<View
key={i}
style={[
styles.dot,
i < state.currentRound && styles.dotFilled,
i === state.currentRound - 1 &&
state.phase === 'WORK' &&
styles.dotActive,
]}
/>
))}
</View>
<TimerControls
isRunning={state.isRunning}
isPaused={state.isPaused}
onPause={onPause}
onResume={onResume}
onStop={onStop}
onSkip={onSkip}
/>
</BlurView>
</View>
</View>
)
}
// ──────────────────────────────────────────────────────
// Styles
// ──────────────────────────────────────────────────────
const styles = StyleSheet.create({
screen: {
flex: 1,
},
// --- IDLE ---
idleContent: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
idleTitle: {
...TYPOGRAPHY.brandTitle,
color: ACCENT.ORANGE,
...TEXT_SHADOW.BRAND,
},
idleSubtitle: {
...TYPOGRAPHY.displaySmall,
color: ACCENT.WHITE,
marginTop: -6,
},
configSummary: {
marginTop: SPACING[10],
},
configRow: {
flexDirection: 'row',
gap: SPACING[3],
},
configBadge: {
backgroundColor: SURFACE.OVERLAY_LIGHT,
borderRadius: RADIUS.LG,
paddingVertical: SPACING[3],
paddingHorizontal: SPACING[5],
alignItems: 'center',
borderWidth: 1,
borderColor: BORDER.LIGHT,
},
configBadgeLabel: {
...TYPOGRAPHY.heading,
color: ACCENT.WHITE,
},
configBadgeSub: {
...TYPOGRAPHY.overline,
color: TEXT.MUTED,
marginTop: 2,
},
startButtonContainer: {
marginTop: SPACING[14],
alignItems: 'center',
justifyContent: 'center',
},
startButtonGlow: {
position: 'absolute',
width: 172,
height: 172,
borderRadius: 86,
backgroundColor: ACCENT.ORANGE,
},
startButton: {
width: 150,
height: 150,
borderRadius: 75,
backgroundColor: ACCENT.ORANGE,
alignItems: 'center',
justifyContent: 'center',
...SHADOW.BRAND_GLOW,
},
buttonPressed: {
transform: [{ scale: 0.95 }],
},
startButtonText: {
...TYPOGRAPHY.buttonHero,
color: ACCENT.WHITE,
},
// --- COMPLETE ---
completeContent: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: SPACING[2],
},
completeEmoji: {
fontSize: 72,
marginBottom: SPACING[2],
},
completeTitle: {
...TYPOGRAPHY.displayLarge,
color: ACCENT.WHITE,
},
completeTime: {
...TYPOGRAPHY.timeDisplay,
color: ACCENT.WHITE,
...TEXT_SHADOW.WHITE_MEDIUM,
},
completeRounds: {
...TYPOGRAPHY.body,
color: TEXT.TERTIARY,
fontWeight: '600',
},
doneButton: {
marginTop: SPACING[10],
paddingHorizontal: SPACING[12],
paddingVertical: SPACING[4],
borderRadius: RADIUS['3XL'],
backgroundColor: SURFACE.OVERLAY_MEDIUM,
borderWidth: 1,
borderColor: BORDER.STRONG,
},
doneButtonText: {
...TYPOGRAPHY.buttonMedium,
color: ACCENT.WHITE,
},
// --- ACTIVE ---
activeContent: {
flex: 1,
},
topZone: {
paddingHorizontal: LAYOUT.PAGE_HORIZONTAL,
paddingTop: SPACING[3],
paddingBottom: SPACING[2],
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
exerciseName: {
...TYPOGRAPHY.body,
color: TEXT.SECONDARY,
flex: 1,
},
roundBadge: {
backgroundColor: SURFACE.OVERLAY_MEDIUM,
borderRadius: RADIUS.MD,
paddingVertical: SPACING[1.5],
paddingHorizontal: SPACING[3.5],
marginLeft: SPACING[3],
},
roundBadgeText: {
...TYPOGRAPHY.label,
color: ACCENT.WHITE,
},
centerZone: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
phaseLabel: {
...TYPOGRAPHY.heading,
color: TEXT.TERTIARY,
letterSpacing: 6,
marginBottom: SPACING[1],
},
countdown: {
...TYPOGRAPHY.countdown,
color: ACCENT.WHITE,
...TEXT_SHADOW.WHITE_SOFT,
},
countdownFlash: {
color: ACCENT.RED_HOT,
...TEXT_SHADOW.DANGER,
},
pausedContainer: {
marginTop: SPACING[4],
paddingVertical: SPACING[2],
paddingHorizontal: SPACING[6],
borderRadius: RADIUS.XL,
backgroundColor: SURFACE.SCRIM,
},
pausedLabel: {
...TYPOGRAPHY.caption,
fontWeight: '800',
color: TEXT.MUTED,
letterSpacing: 6,
},
// --- Bottom toolbar (native blur) ---
bottomBar: {
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: BORDER.MEDIUM,
paddingTop: SPACING[4],
gap: SPACING[5],
},
roundDots: {
flexDirection: 'row',
justifyContent: 'center',
gap: SPACING[2.5],
},
dot: {
width: 8,
height: 8,
borderRadius: RADIUS.XS,
backgroundColor: SURFACE.OVERLAY_MEDIUM,
},
dotFilled: {
backgroundColor: TEXT.SECONDARY,
},
dotActive: {
backgroundColor: ACCENT.WHITE,
width: 12,
height: 12,
borderRadius: SPACING[1.5],
...SHADOW.WHITE_GLOW,
},
})

View File

@@ -1,380 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { AppState, type AppStateStatus } from 'react-native'
import {
activateKeepAwakeAsync,
deactivateKeepAwake,
} from 'expo-keep-awake'
import { TIMER_DEFAULTS, TICK_INTERVAL_MS } from '@/src/shared/constants/timer'
import type {
TimerConfig,
TimerEngine,
TimerEvent,
TimerEventListener,
TimerPhase,
} from '../types'
const VALID_TRANSITIONS: Record<TimerPhase, TimerPhase[]> = {
IDLE: ['GET_READY'],
GET_READY: ['WORK', 'IDLE'],
WORK: ['REST', 'IDLE'],
REST: ['WORK', 'COMPLETE', 'IDLE'],
COMPLETE: ['IDLE'],
}
function canTransition(from: TimerPhase, to: TimerPhase): boolean {
return VALID_TRANSITIONS[from].includes(to)
}
function buildDefaultConfig(): TimerConfig {
return {
workDuration: TIMER_DEFAULTS.WORK_DURATION,
restDuration: TIMER_DEFAULTS.REST_DURATION,
rounds: TIMER_DEFAULTS.ROUNDS,
getReadyDuration: TIMER_DEFAULTS.GET_READY_DURATION,
cycles: TIMER_DEFAULTS.CYCLES,
cyclePauseDuration: TIMER_DEFAULTS.CYCLE_PAUSE_DURATION,
}
}
function getDurationForPhase(phase: TimerPhase, config: TimerConfig): number {
switch (phase) {
case 'GET_READY':
return config.getReadyDuration
case 'WORK':
return config.workDuration
case 'REST':
return config.restDuration
default:
return 0
}
}
export function useTimerEngine(): TimerEngine {
// --- UI state (triggers re-renders) ---
const [phase, setPhase] = useState<TimerPhase>('IDLE')
const [secondsLeft, setSecondsLeft] = useState(0)
const [currentRound, setCurrentRound] = useState(0)
const [currentCycle, setCurrentCycle] = useState(0)
const [isRunning, setIsRunning] = useState(false)
const [isPaused, setIsPaused] = useState(false)
const [totalElapsedSeconds, setTotalElapsedSeconds] = useState(0)
const [config, setConfig] = useState<TimerConfig>(buildDefaultConfig)
// --- Refs for time-critical values (no stale closures) ---
const targetEndTimeRef = useRef(0)
const remainingMsRef = useRef(0)
const tickRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const phaseRef = useRef<TimerPhase>('IDLE')
const currentRoundRef = useRef(0)
const currentCycleRef = useRef(0)
const configRef = useRef<TimerConfig>(buildDefaultConfig())
const isRunningRef = useRef(false)
const isPausedRef = useRef(false)
const elapsedRef = useRef(0)
const lastTickTimeRef = useRef(0)
const lastEmittedSecondRef = useRef(-1)
const listenersRef = useRef<Set<TimerEventListener>>(new Set())
// --- Helpers ---
function emit(event: TimerEvent): void {
listenersRef.current.forEach((listener) => {
try {
listener(event)
} catch (e) {
if (__DEV__) console.warn('[TimerEngine] Listener error:', e)
}
})
}
function clearTick(): void {
if (tickRef.current !== null) {
clearTimeout(tickRef.current)
tickRef.current = null
}
}
function transitionTo(nextPhase: TimerPhase): void {
const prevPhase = phaseRef.current
if (!canTransition(prevPhase, nextPhase)) {
if (__DEV__) {
console.warn(
`[TimerEngine] Invalid transition: ${prevPhase}${nextPhase}`
)
}
return
}
if (__DEV__) {
console.log(`[TimerEngine] ${prevPhase}${nextPhase}`)
}
phaseRef.current = nextPhase
setPhase(nextPhase)
emit({ type: 'PHASE_CHANGED', from: prevPhase, to: nextPhase })
const duration = getDurationForPhase(nextPhase, configRef.current)
if (duration > 0) {
startCountdown(duration)
} else {
// Phase with 0 duration - immediately advance to next phase
advancePhase()
}
}
function startCountdown(durationSeconds: number): void {
clearTick()
targetEndTimeRef.current = Date.now() + durationSeconds * 1000
lastEmittedSecondRef.current = -1
scheduleTick()
}
function scheduleTick(): void {
tickRef.current = setTimeout(tick, TICK_INTERVAL_MS)
}
function tick(): void {
const now = Date.now()
// Accumulate elapsed time
if (lastTickTimeRef.current > 0) {
const delta = (now - lastTickTimeRef.current) / 1000
elapsedRef.current += delta
setTotalElapsedSeconds(Math.floor(elapsedRef.current))
}
lastTickTimeRef.current = now
const remainingMs = Math.max(0, targetEndTimeRef.current - now)
const seconds = Math.ceil(remainingMs / 1000)
setSecondsLeft(seconds)
// Emit COUNTDOWN_TICK for the last 3 seconds (once per second)
if (seconds <= 3 && seconds > 0 && seconds !== lastEmittedSecondRef.current) {
lastEmittedSecondRef.current = seconds
emit({ type: 'COUNTDOWN_TICK', secondsLeft: seconds })
}
if (remainingMs <= 0) {
advancePhase()
} else {
scheduleTick()
}
}
function advancePhase(): void {
clearTick()
const current = phaseRef.current
const cfg = configRef.current
switch (current) {
case 'GET_READY':
currentRoundRef.current = 1
setCurrentRound(1)
transitionTo('WORK')
break
case 'WORK':
transitionTo('REST')
break
case 'REST': {
const round = currentRoundRef.current
emit({ type: 'ROUND_COMPLETED', round })
if (round < cfg.rounds) {
// Next round
currentRoundRef.current = round + 1
setCurrentRound(round + 1)
transitionTo('WORK')
} else if (currentCycleRef.current < cfg.cycles) {
// Next cycle (Premium, future) — for V1 cycles=1, so this is dead code
currentCycleRef.current += 1
setCurrentCycle(currentCycleRef.current)
currentRoundRef.current = 1
setCurrentRound(1)
transitionTo('WORK')
} else {
// Session complete
const totalSeconds = Math.floor(elapsedRef.current)
setTotalElapsedSeconds(totalSeconds)
emit({ type: 'SESSION_COMPLETE', totalSeconds })
transitionTo('COMPLETE')
setIsRunning(false)
isRunningRef.current = false
}
break
}
default:
break
}
}
// --- Actions ---
const start = useCallback((overrides?: Partial<TimerConfig>) => {
if (phaseRef.current !== 'IDLE') return
const cfg: TimerConfig = { ...buildDefaultConfig(), ...overrides }
configRef.current = cfg
setConfig(cfg)
// Reset all state
currentRoundRef.current = 0
currentCycleRef.current = 1
elapsedRef.current = 0
lastTickTimeRef.current = Date.now()
lastEmittedSecondRef.current = -1
setCurrentRound(0)
setCurrentCycle(1)
setTotalElapsedSeconds(0)
setIsRunning(true)
setIsPaused(false)
isRunningRef.current = true
isPausedRef.current = false
transitionTo('GET_READY')
}, [])
const pause = useCallback(() => {
if (!isRunningRef.current || isPausedRef.current) return
clearTick()
remainingMsRef.current = Math.max(0, targetEndTimeRef.current - Date.now())
isPausedRef.current = true
setIsPaused(true)
setIsRunning(false)
isRunningRef.current = false
if (__DEV__) {
console.log('[TimerEngine] Paused, remaining:', remainingMsRef.current, 'ms')
}
}, [])
const resume = useCallback(() => {
if (!isPausedRef.current) return
targetEndTimeRef.current = Date.now() + remainingMsRef.current
lastTickTimeRef.current = Date.now()
isPausedRef.current = false
isRunningRef.current = true
setIsPaused(false)
setIsRunning(true)
scheduleTick()
if (__DEV__) {
console.log('[TimerEngine] Resumed')
}
}, [])
const stop = useCallback(() => {
if (phaseRef.current === 'IDLE') return
clearTick()
phaseRef.current = 'IDLE'
isRunningRef.current = false
isPausedRef.current = false
lastTickTimeRef.current = 0
setPhase('IDLE')
setSecondsLeft(0)
setCurrentRound(0)
setCurrentCycle(0)
setIsRunning(false)
setIsPaused(false)
if (__DEV__) {
console.log('[TimerEngine] Stopped')
}
}, [])
const skip = useCallback(() => {
if (phaseRef.current === 'IDLE' || phaseRef.current === 'COMPLETE') return
if (!isRunningRef.current && !isPausedRef.current) return
// If paused, un-pause first
if (isPausedRef.current) {
isPausedRef.current = false
isRunningRef.current = true
setIsPaused(false)
setIsRunning(true)
}
clearTick()
advancePhase()
}, [])
const addEventListener = useCallback((listener: TimerEventListener) => {
listenersRef.current.add(listener)
return () => {
listenersRef.current.delete(listener)
}
}, [])
// --- AppState: auto-pause on interruption ---
// iOS: active → inactive (phone call, control center) → background
// Android: active → background (directly)
// With auto-pause, we pause on any departure from 'active'.
// User must manually resume — no reconcile needed.
useEffect(() => {
const handleAppState = (nextState: AppStateStatus) => {
if (nextState !== 'active') {
if (isRunningRef.current && !isPausedRef.current) {
pause()
if (__DEV__) {
console.log(`[TimerEngine] Auto-paused (${nextState})`)
}
}
}
}
const subscription = AppState.addEventListener('change', handleAppState)
return () => subscription.remove()
}, [pause])
// --- Keep-awake ---
useEffect(() => {
if (isRunning || isPaused) {
activateKeepAwakeAsync('tabata-session')
} else {
deactivateKeepAwake('tabata-session')
}
}, [isRunning, isPaused])
// --- Cleanup on unmount ---
useEffect(() => {
return () => {
clearTick()
deactivateKeepAwake('tabata-session')
}
}, [])
return {
phase,
secondsLeft,
currentRound,
totalRounds: config.rounds,
currentCycle,
totalCycles: config.cycles,
isRunning,
isPaused,
totalElapsedSeconds,
start,
pause,
resume,
stop,
skip,
addEventListener,
config,
}
}

View File

@@ -1,12 +0,0 @@
export { useTimerEngine } from './hooks/useTimerEngine'
export { TimerDisplay } from './components/TimerDisplay'
export { TimerControls } from './components/TimerControls'
export type {
TimerPhase,
TimerConfig,
TimerState,
TimerActions,
TimerEvent,
TimerEventListener,
TimerEngine,
} from './types'

View File

@@ -1,43 +0,0 @@
export type TimerPhase = 'IDLE' | 'GET_READY' | 'WORK' | 'REST' | 'COMPLETE'
export interface TimerConfig {
workDuration: number
restDuration: number
rounds: number
getReadyDuration: number
cycles: number
cyclePauseDuration: number
}
export interface TimerState {
phase: TimerPhase
secondsLeft: number
currentRound: number
totalRounds: number
currentCycle: number
totalCycles: number
isRunning: boolean
isPaused: boolean
totalElapsedSeconds: number
}
export interface TimerActions {
start: (config?: Partial<TimerConfig>) => void
pause: () => void
resume: () => void
stop: () => void
skip: () => void
}
export type TimerEvent =
| { type: 'PHASE_CHANGED'; from: TimerPhase; to: TimerPhase }
| { type: 'ROUND_COMPLETED'; round: number }
| { type: 'SESSION_COMPLETE'; totalSeconds: number }
| { type: 'COUNTDOWN_TICK'; secondsLeft: number }
export type TimerEventListener = (event: TimerEvent) => void
export interface TimerEngine extends TimerState, TimerActions {
addEventListener: (listener: TimerEventListener) => () => void
config: TimerConfig
}

View File

@@ -1,32 +0,0 @@
import { StyleSheet, View, type ViewProps } from 'react-native'
import { BlurView } from 'expo-blur'
import { GLASS } from '../constants/colors'
interface GlassViewProps extends ViewProps {
intensity?: number
children: React.ReactNode
}
export function GlassView({
intensity = GLASS.BLUR_MEDIUM,
style,
children,
...rest
}: GlassViewProps) {
return (
<View style={[styles.container, style]} {...rest}>
<BlurView intensity={intensity} tint="dark" style={StyleSheet.absoluteFill} />
{children}
</View>
)
}
const styles = StyleSheet.create({
container: {
overflow: 'hidden',
backgroundColor: GLASS.FILL,
borderWidth: 0.5,
borderColor: GLASS.BORDER,
borderTopColor: GLASS.BORDER_TOP,
},
})

View File

@@ -1,25 +0,0 @@
import { Text, type TextProps } from 'react-native'
import { TYPOGRAPHY } from '../constants/typography'
import { TEXT } from '../constants/colors'
type TypographyVariant = keyof typeof TYPOGRAPHY
interface TypographyProps extends TextProps {
variant: TypographyVariant
color?: string
children: React.ReactNode
}
export function Typography({
variant,
color = TEXT.PRIMARY,
style,
children,
...rest
}: TypographyProps) {
return (
<Text style={[TYPOGRAPHY[variant], { color }, style]} {...rest}>
{children}
</Text>
)
}

View File

@@ -1,44 +0,0 @@
import type { TextStyle, ViewStyle } from 'react-native'
export const SHADOW = {
BRAND_GLOW: {
shadowColor: '#F97316',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.5,
shadowRadius: 20,
elevation: 12,
} as ViewStyle,
WHITE_GLOW: {
shadowColor: '#FFFFFF',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.8,
shadowRadius: 6,
} as ViewStyle,
} as const
export const TEXT_SHADOW = {
BRAND: {
textShadowColor: 'rgba(249, 115, 22, 0.5)',
textShadowOffset: { width: 0, height: 0 },
textShadowRadius: 30,
} as TextStyle,
WHITE_SOFT: {
textShadowColor: 'rgba(255, 255, 255, 0.25)',
textShadowOffset: { width: 0, height: 0 },
textShadowRadius: 30,
} as TextStyle,
WHITE_MEDIUM: {
textShadowColor: 'rgba(255, 255, 255, 0.3)',
textShadowOffset: { width: 0, height: 0 },
textShadowRadius: 20,
} as TextStyle,
DANGER: {
textShadowColor: 'rgba(239, 68, 68, 0.6)',
textShadowOffset: { width: 0, height: 0 },
textShadowRadius: 40,
} as TextStyle,
} as const

View File

@@ -1,10 +0,0 @@
export const TIMER_DEFAULTS = {
WORK_DURATION: 20,
REST_DURATION: 10,
ROUNDS: 8,
GET_READY_DURATION: 10,
CYCLES: 1,
CYCLE_PAUSE_DURATION: 60,
} as const
export const TICK_INTERVAL_MS = 100

View File

@@ -1,8 +0,0 @@
export function formatTime(totalSeconds: number): string {
const mins = Math.floor(totalSeconds / 60)
const secs = totalSeconds % 60
if (mins > 0) {
return `${mins}:${secs.toString().padStart(2, '0')}`
}
return `${secs}`
}