diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx
index 54e11d0..8efaea6 100644
--- a/app/(tabs)/_layout.tsx
+++ b/app/(tabs)/_layout.tsx
@@ -1,35 +1,41 @@
-import { Tabs } from 'expo-router';
-import React from 'react';
+import { Tabs } from 'expo-router'
+import Ionicons from '@expo/vector-icons/Ionicons'
-import { HapticTab } from '@/components/haptic-tab';
-import { IconSymbol } from '@/components/ui/icon-symbol';
-import { Colors } from '@/constants/theme';
-import { useColorScheme } from '@/hooks/use-color-scheme';
+import { HapticTab } from '@/components/haptic-tab'
+import { BRAND, SURFACE, TEXT, BORDER } from '@/src/shared/constants/colors'
export default function TabLayout() {
- const colorScheme = useColorScheme();
-
return (
+ tabBarActiveTintColor: BRAND.PRIMARY,
+ tabBarInactiveTintColor: TEXT.HINT,
+ tabBarStyle: {
+ backgroundColor: SURFACE.BASE,
+ borderTopColor: BORDER.SUBTLE,
+ },
+ }}
+ >
,
+ title: 'Accueil',
+ tabBarIcon: ({ color, size }) => (
+
+ ),
}}
/>
,
+ title: 'Explorer',
+ tabBarIcon: ({ color, size }) => (
+
+ ),
}}
/>
- );
+ )
}
diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx
index 0905dc4..9ff99ad 100644
--- a/app/(tabs)/index.tsx
+++ b/app/(tabs)/index.tsx
@@ -1,67 +1,156 @@
-import { Pressable, StyleSheet, Text, View } from 'react-native'
-import { useRouter } from 'expo-router'
+import { useEffect, useRef } from 'react'
+import {
+ Animated,
+ Pressable,
+ StyleSheet,
+ Text,
+ View,
+} from 'react-native'
+import { useRouter, Redirect } from 'expo-router'
import { StatusBar } from 'expo-status-bar'
+import { LinearGradient } from 'expo-linear-gradient'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
+import { BRAND, TEXT, APP_GRADIENTS, ACCENT } from '@/src/shared/constants/colors'
+import { TYPOGRAPHY } from '@/src/shared/constants/typography'
+import { SHADOW, TEXT_SHADOW } from '@/src/shared/constants/shadows'
+import { DURATION, EASING } from '@/src/shared/constants/animations'
+import { useIsOnboardingComplete } from '@/src/features/onboarding/hooks/useOnboarding'
+
export default function HomeScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
+ const isOnboardingComplete = useIsOnboardingComplete()
+
+ const glowAnim = useRef(new Animated.Value(0)).current
+
+ // Show nothing while Zustand hydrates
+ if (isOnboardingComplete === undefined) {
+ return null
+ }
+
+ // Redirect to onboarding if not complete
+ if (isOnboardingComplete === false) {
+ return
+ }
+
+ useEffect(() => {
+ Animated.loop(
+ Animated.sequence([
+ Animated.timing(glowAnim, {
+ toValue: 1,
+ duration: DURATION.BREATH,
+ easing: EASING.STANDARD,
+ useNativeDriver: false,
+ }),
+ Animated.timing(glowAnim, {
+ toValue: 0,
+ duration: DURATION.BREATH,
+ easing: EASING.STANDARD,
+ useNativeDriver: false,
+ }),
+ ])
+ ).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 (
-
+
- TABATAGO
- Entraînement Tabata
+
+ TABATA
+ GO
+ 4 minutes. Tout donner.
+
- [
- styles.startButton,
- pressed && styles.startButtonPressed,
- ]}
- onPress={() => router.push('/timer')}
- >
- START
-
-
+
+
+ [
+ styles.startButton,
+ pressed && styles.startButtonPressed,
+ ]}
+ onPress={() => router.push('/timer')}
+ >
+ START
+
+
+
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
- backgroundColor: '#1E1E2E',
alignItems: 'center',
justifyContent: 'center',
- gap: 12,
+ },
+ brandArea: {
+ alignItems: 'center',
},
title: {
- fontSize: 44,
- fontWeight: '900',
- color: '#FFFFFF',
- letterSpacing: 6,
+ ...TYPOGRAPHY.brandTitle,
+ color: BRAND.PRIMARY,
+ ...TEXT_SHADOW.BRAND,
},
subtitle: {
- fontSize: 18,
- color: 'rgba(255, 255, 255, 0.6)',
- fontWeight: '500',
+ ...TYPOGRAPHY.displaySmall,
+ color: TEXT.PRIMARY,
+ marginTop: -6,
+ },
+ tagline: {
+ ...TYPOGRAPHY.caption,
+ color: TEXT.HINT,
+ fontStyle: 'italic',
+ marginTop: 12,
+ },
+ buttonArea: {
+ marginTop: 72,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ buttonGlow: {
+ position: 'absolute',
+ width: 172,
+ height: 172,
+ borderRadius: 86,
+ backgroundColor: ACCENT.ORANGE,
},
startButton: {
width: 160,
height: 160,
borderRadius: 80,
- backgroundColor: '#F97316',
+ backgroundColor: BRAND.PRIMARY,
alignItems: 'center',
justifyContent: 'center',
- marginTop: 60,
+ ...SHADOW.BRAND_GLOW,
},
startButtonPressed: {
- opacity: 0.7,
+ transform: [{ scale: 0.95 }],
},
startButtonText: {
- fontSize: 32,
- fontWeight: '900',
- color: '#FFFFFF',
+ ...TYPOGRAPHY.buttonHero,
+ color: TEXT.PRIMARY,
letterSpacing: 4,
},
})
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 58bd698..f13a8c1 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -1,9 +1,15 @@
+import { useEffect } from 'react';
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
+import * as SplashScreen from 'expo-splash-screen';
import 'react-native-reanimated';
import { useColorScheme } from '@/hooks/use-color-scheme';
+import { useIsOnboardingComplete } from '@/src/features/onboarding/hooks/useOnboarding';
+
+// Prevent splash screen from auto-hiding
+SplashScreen.preventAutoHideAsync();
export const unstable_settings = {
anchor: '(tabs)',
@@ -11,10 +17,32 @@ export const unstable_settings = {
export default function RootLayout() {
const colorScheme = useColorScheme();
+ const isOnboardingComplete = useIsOnboardingComplete();
+
+ // Hide splash screen once we have a definite state
+ useEffect(() => {
+ if (isOnboardingComplete !== undefined) {
+ SplashScreen.hideAsync();
+ }
+ }, [isOnboardingComplete]);
+
+ // Show nothing while Zustand hydrates from AsyncStorage
+ if (isOnboardingComplete === undefined) {
+ return null;
+ }
return (
+
state.currentStep)
+ const isOnboardingComplete = useOnboarding((state) => state.isOnboardingComplete)
+ const nextStep = useOnboarding((state) => state.nextStep)
+ const completeOnboarding = useOnboarding((state) => state.completeOnboarding)
+
+ const handleNext = () => {
+ nextStep()
+ }
+
+ const handleComplete = () => {
+ completeOnboarding()
+ router.replace('/(tabs)')
+ }
+
+ // Redirect to tabs if onboarding is already complete
+ if (isOnboardingComplete) {
+ return
+ }
+
+ // Render the correct screen based on current step
+ switch (currentStep) {
+ case 0:
+ return
+ case 1:
+ return
+ case 2:
+ return
+ case 3:
+ return
+ case 4:
+ return
+ case 5:
+ return
+ default:
+ // Fallback to first screen if step is out of bounds
+ return
+ }
+}
diff --git a/app/timer.tsx b/app/timer.tsx
index b2b31c8..771d2e1 100644
--- a/app/timer.tsx
+++ b/app/timer.tsx
@@ -1,10 +1,73 @@
+import { useEffect } from 'react'
import { useRouter } from 'expo-router'
+import * as Haptics from 'expo-haptics'
import { useTimerEngine } from '@/src/features/timer'
+import { useAudioEngine } from '@/src/features/audio'
import { TimerDisplay } from '@/src/features/timer/components/TimerDisplay'
+import type { TimerEvent } from '@/src/features/timer/types'
export default function TimerScreen() {
const router = useRouter()
const timer = useTimerEngine()
+ const audio = useAudioEngine()
+
+ // Preload audio on mount
+ useEffect(() => {
+ audio.preloadAll()
+ return () => {
+ audio.unloadAll()
+ }
+ }, [])
+
+ // Subscribe to timer events → trigger audio + haptics
+ useEffect(() => {
+ const unsubscribe = timer.addEventListener(async (event: TimerEvent) => {
+ switch (event.type) {
+ case 'PHASE_CHANGED':
+ await handlePhaseChange(event.to)
+ break
+
+ case 'COUNTDOWN_TICK':
+ await audio.playPhaseSound(
+ event.secondsLeft === 1 ? 'count_1' : event.secondsLeft === 2 ? 'count_2' : 'count_3'
+ )
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)
+ break
+
+ case 'ROUND_COMPLETED':
+ await audio.playPhaseSound('bell')
+ Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
+ break
+
+ case 'SESSION_COMPLETE':
+ await audio.playPhaseSound('fanfare')
+ await audio.stopMusic(1000)
+ Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
+ break
+ }
+ })
+
+ return unsubscribe
+ }, [audio.isLoaded])
+
+ async function handlePhaseChange(to: string) {
+ switch (to) {
+ case 'GET_READY':
+ await audio.startMusic('LOW')
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium)
+ break
+ case 'WORK':
+ await audio.playPhaseSound('beep_long')
+ await audio.switchIntensity('HIGH')
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)
+ break
+ case 'REST':
+ await audio.playPhaseSound('beep_double')
+ await audio.switchIntensity('LOW')
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)
+ break
+ }
+ }
function handleStart() {
timer.start()
@@ -12,6 +75,7 @@ export default function TimerScreen() {
function handleStop() {
timer.stop()
+ audio.stopMusic(300)
router.back()
}
diff --git a/assets/audio/music/electro_high.mp3 b/assets/audio/music/electro_high.mp3
new file mode 100644
index 0000000..3650371
Binary files /dev/null and b/assets/audio/music/electro_high.mp3 differ
diff --git a/assets/audio/music/electro_low.mp3 b/assets/audio/music/electro_low.mp3
new file mode 100644
index 0000000..5bd61b5
Binary files /dev/null and b/assets/audio/music/electro_low.mp3 differ
diff --git a/assets/audio/sounds/beep_double.mp3 b/assets/audio/sounds/beep_double.mp3
new file mode 100644
index 0000000..ce72c91
Binary files /dev/null and b/assets/audio/sounds/beep_double.mp3 differ
diff --git a/assets/audio/sounds/beep_long.mp3 b/assets/audio/sounds/beep_long.mp3
new file mode 100644
index 0000000..68232bf
Binary files /dev/null and b/assets/audio/sounds/beep_long.mp3 differ
diff --git a/assets/audio/sounds/beep_short.mp3 b/assets/audio/sounds/beep_short.mp3
new file mode 100644
index 0000000..bf09b46
Binary files /dev/null and b/assets/audio/sounds/beep_short.mp3 differ
diff --git a/assets/audio/sounds/bell.mp3 b/assets/audio/sounds/bell.mp3
new file mode 100644
index 0000000..d7df1f4
Binary files /dev/null and b/assets/audio/sounds/bell.mp3 differ
diff --git a/assets/audio/sounds/count_1.mp3 b/assets/audio/sounds/count_1.mp3
new file mode 100644
index 0000000..8e002bb
Binary files /dev/null and b/assets/audio/sounds/count_1.mp3 differ
diff --git a/assets/audio/sounds/count_2.mp3 b/assets/audio/sounds/count_2.mp3
new file mode 100644
index 0000000..984e7f8
Binary files /dev/null and b/assets/audio/sounds/count_2.mp3 differ
diff --git a/assets/audio/sounds/count_3.mp3 b/assets/audio/sounds/count_3.mp3
new file mode 100644
index 0000000..984e7f8
Binary files /dev/null and b/assets/audio/sounds/count_3.mp3 differ
diff --git a/assets/audio/sounds/fanfare.mp3 b/assets/audio/sounds/fanfare.mp3
new file mode 100644
index 0000000..1e36c05
Binary files /dev/null and b/assets/audio/sounds/fanfare.mp3 differ
diff --git a/package-lock.json b/package-lock.json
index 6385a5f..681f7d2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,15 +9,19 @@
"version": "1.0.0",
"dependencies": {
"@expo/vector-icons": "^15.0.3",
+ "@react-native-async-storage/async-storage": "^2.2.0",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"expo": "~54.0.33",
+ "expo-av": "~16.0.8",
+ "expo-blur": "~15.0.8",
"expo-constants": "~18.0.13",
"expo-font": "~14.0.11",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
"expo-keep-awake": "~15.0.8",
+ "expo-linear-gradient": "~15.0.8",
"expo-linking": "~8.0.11",
"expo-router": "~6.0.23",
"expo-splash-screen": "~31.0.13",
@@ -33,7 +37,8 @@
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-web": "~0.21.0",
- "react-native-worklets": "0.5.1"
+ "react-native-worklets": "0.5.1",
+ "zustand": "^5.0.11"
},
"devDependencies": {
"@types/react": "~19.1.0",
@@ -2766,6 +2771,18 @@
}
}
},
+ "node_modules/@react-native-async-storage/async-storage": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
+ "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==",
+ "license": "MIT",
+ "dependencies": {
+ "merge-options": "^3.0.4"
+ },
+ "peerDependencies": {
+ "react-native": "^0.0.0-0 || >=0.65 <1.0"
+ }
+ },
"node_modules/@react-native/assets-registry": {
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
@@ -6072,6 +6089,34 @@
"react-native": "*"
}
},
+ "node_modules/expo-av": {
+ "version": "16.0.8",
+ "resolved": "https://registry.npmjs.org/expo-av/-/expo-av-16.0.8.tgz",
+ "integrity": "sha512-cmVPftGR/ca7XBgs7R6ky36lF3OC0/MM/lpgX/yXqfv0jASTsh7AYX9JxHCwFmF+Z6JEB1vne9FDx4GiLcGreQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*",
+ "react": "*",
+ "react-native": "*",
+ "react-native-web": "*"
+ },
+ "peerDependenciesMeta": {
+ "react-native-web": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/expo-blur": {
+ "version": "15.0.8",
+ "resolved": "https://registry.npmjs.org/expo-blur/-/expo-blur-15.0.8.tgz",
+ "integrity": "sha512-rWyE1NBRZEu9WD+X+5l7gyPRszw7n12cW3IRNAb5i6KFzaBp8cxqT5oeaphJapqURvcqhkOZn2k5EtBSbsuU7w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*",
+ "react": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/expo-constants": {
"version": "18.0.13",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
@@ -6146,6 +6191,17 @@
"react": "*"
}
},
+ "node_modules/expo-linear-gradient": {
+ "version": "15.0.8",
+ "resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-15.0.8.tgz",
+ "integrity": "sha512-V2d8Wjn0VzhPHO+rrSBtcl+Fo+jUUccdlmQ6OoL9/XQB7Qk3d9lYrqKDJyccwDxmQT10JdST3Tmf2K52NLc3kw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*",
+ "react": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/expo-linking": {
"version": "8.0.11",
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.11.tgz",
@@ -7842,6 +7898,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-plain-obj": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
+ "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -8835,6 +8900,18 @@
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
"license": "MIT"
},
+ "node_modules/merge-options": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz",
+ "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "is-plain-obj": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@@ -12889,6 +12966,35 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
+ },
+ "node_modules/zustand": {
+ "version": "5.0.11",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz",
+ "integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18.0.0",
+ "immer": ">=9.0.6",
+ "react": ">=18.0.0",
+ "use-sync-external-store": ">=1.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "use-sync-external-store": {
+ "optional": true
+ }
+ }
}
}
}
diff --git a/package.json b/package.json
index 04143e6..398e1f6 100644
--- a/package.json
+++ b/package.json
@@ -12,15 +12,19 @@
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
+ "@react-native-async-storage/async-storage": "^2.2.0",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"expo": "~54.0.33",
+ "expo-av": "~16.0.8",
+ "expo-blur": "~15.0.8",
"expo-constants": "~18.0.13",
"expo-font": "~14.0.11",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
"expo-keep-awake": "~15.0.8",
+ "expo-linear-gradient": "~15.0.8",
"expo-linking": "~8.0.11",
"expo-router": "~6.0.23",
"expo-splash-screen": "~31.0.13",
@@ -36,13 +40,14 @@
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-web": "~0.21.0",
- "react-native-worklets": "0.5.1"
+ "react-native-worklets": "0.5.1",
+ "zustand": "^5.0.11"
},
"devDependencies": {
"@types/react": "~19.1.0",
- "typescript": "~5.9.2",
"eslint": "^9.25.0",
- "eslint-config-expo": "~10.0.0"
+ "eslint-config-expo": "~10.0.0",
+ "typescript": "~5.9.2"
},
"private": true
}
diff --git a/src/features/audio/data/sounds.ts b/src/features/audio/data/sounds.ts
new file mode 100644
index 0000000..df5fced
--- /dev/null
+++ b/src/features/audio/data/sounds.ts
@@ -0,0 +1,13 @@
+import type { PhaseSound } from '../types'
+
+/* eslint-disable @typescript-eslint/no-require-imports */
+export const PHASE_SOUNDS: Record = {
+ 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'),
+}
diff --git a/src/features/audio/data/tracks.ts b/src/features/audio/data/tracks.ts
new file mode 100644
index 0000000..2431840
--- /dev/null
+++ b/src/features/audio/data/tracks.ts
@@ -0,0 +1,27 @@
+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
+}
diff --git a/src/features/audio/hooks/useAudioEngine.ts b/src/features/audio/hooks/useAudioEngine.ts
new file mode 100644
index 0000000..e0f29c3
--- /dev/null
+++ b/src/features/audio/hooks/useAudioEngine.ts
@@ -0,0 +1,173 @@
+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 {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
+
+export function useAudioEngine(): AudioEngine {
+ const [isLoaded, setIsLoaded] = useState(false)
+ const soundsRef = useRef>({})
+ const currentIntensityRef = useRef('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,
+ }
+}
diff --git a/src/features/audio/index.ts b/src/features/audio/index.ts
new file mode 100644
index 0000000..5d596ce
--- /dev/null
+++ b/src/features/audio/index.ts
@@ -0,0 +1,8 @@
+export { useAudioEngine } from './hooks/useAudioEngine'
+export type {
+ MusicAmbiance,
+ MusicIntensity,
+ PhaseSound,
+ AudioSettings,
+ AudioEngine,
+} from './types'
diff --git a/src/features/audio/types.ts b/src/features/audio/types.ts
new file mode 100644
index 0000000..677df8b
--- /dev/null
+++ b/src/features/audio/types.ts
@@ -0,0 +1,31 @@
+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
+ playPhaseSound: (sound: PhaseSound) => Promise
+ startMusic: (intensity: MusicIntensity) => Promise
+ switchIntensity: (intensity: MusicIntensity) => Promise
+ stopMusic: (fadeMs?: number) => Promise
+ unloadAll: () => Promise
+}
diff --git a/src/features/onboarding/components/ChoiceButton.tsx b/src/features/onboarding/components/ChoiceButton.tsx
new file mode 100644
index 0000000..1502e2e
--- /dev/null
+++ b/src/features/onboarding/components/ChoiceButton.tsx
@@ -0,0 +1,100 @@
+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 (
+
+
+
+
+
+
+
+ {label}
+
+ {description && (
+ {description}
+ )}
+
+ {selected && (
+
+
+
+ )}
+
+
+ )
+}
+
+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],
+ },
+})
diff --git a/src/features/onboarding/components/MiniTimerDemo.tsx b/src/features/onboarding/components/MiniTimerDemo.tsx
new file mode 100644
index 0000000..7649ebf
--- /dev/null
+++ b/src/features/onboarding/components/MiniTimerDemo.tsx
@@ -0,0 +1,178 @@
+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 (
+
+
+
+
+ {displaySeconds}
+
+
+ {getPhaseText()}
+
+
+
+ )
+}
+
+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,
+ },
+})
diff --git a/src/features/onboarding/components/OnboardingScreen.tsx b/src/features/onboarding/components/OnboardingScreen.tsx
new file mode 100644
index 0000000..dc1e2c5
--- /dev/null
+++ b/src/features/onboarding/components/OnboardingScreen.tsx
@@ -0,0 +1,46 @@
+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 (
+
+
+
+ {children}
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ gradient: {
+ flex: 1,
+ },
+ container: {
+ flex: 1,
+ },
+ content: {
+ flex: 1,
+ paddingHorizontal: LAYOUT.PAGE_HORIZONTAL,
+ paddingBottom: SPACING[6],
+ },
+})
diff --git a/src/features/onboarding/components/PaywallCard.tsx b/src/features/onboarding/components/PaywallCard.tsx
new file mode 100644
index 0000000..6553604
--- /dev/null
+++ b/src/features/onboarding/components/PaywallCard.tsx
@@ -0,0 +1,158 @@
+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 (
+
+
+ {badge && (
+
+ {badge}
+
+ )}
+
+
+ {title}
+
+
+ {price}
+ /{period}
+
+
+
+
+ {features.map((feature, index) => (
+
+
+ {feature}
+
+ ))}
+
+ {selected && (
+
+
+
+ )}
+
+
+ )
+}
+
+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,
+ },
+})
diff --git a/src/features/onboarding/components/PrimaryButton.tsx b/src/features/onboarding/components/PrimaryButton.tsx
new file mode 100644
index 0000000..4353078
--- /dev/null
+++ b/src/features/onboarding/components/PrimaryButton.tsx
@@ -0,0 +1,85 @@
+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 (
+
+
+
+ {title}
+
+
+
+ )
+}
+
+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,
+ },
+})
diff --git a/src/features/onboarding/components/ProgressBar.tsx b/src/features/onboarding/components/ProgressBar.tsx
new file mode 100644
index 0000000..4d3e0f7
--- /dev/null
+++ b/src/features/onboarding/components/ProgressBar.tsx
@@ -0,0 +1,89 @@
+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 (
+
+ {Array.from({ length: totalSteps }).map((_, index) => {
+ const isActive = index === currentStep
+ const isCompleted = index < currentStep
+
+ return (
+
+ )
+ })}
+
+ )
+}
+
+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,
+ },
+})
diff --git a/src/features/onboarding/components/index.ts b/src/features/onboarding/components/index.ts
new file mode 100644
index 0000000..e5fae39
--- /dev/null
+++ b/src/features/onboarding/components/index.ts
@@ -0,0 +1,6 @@
+export { OnboardingScreen } from './OnboardingScreen'
+export { ProgressBar } from './ProgressBar'
+export { PrimaryButton } from './PrimaryButton'
+export { ChoiceButton } from './ChoiceButton'
+export { MiniTimerDemo } from './MiniTimerDemo'
+export { PaywallCard } from './PaywallCard'
diff --git a/src/features/onboarding/data/barriers.ts b/src/features/onboarding/data/barriers.ts
new file mode 100644
index 0000000..8bc33c4
--- /dev/null
+++ b/src/features/onboarding/data/barriers.ts
@@ -0,0 +1,15 @@
+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' },
+]
diff --git a/src/features/onboarding/data/goals.ts b/src/features/onboarding/data/goals.ts
new file mode 100644
index 0000000..8664f27
--- /dev/null
+++ b/src/features/onboarding/data/goals.ts
@@ -0,0 +1,15 @@
+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' },
+]
diff --git a/src/features/onboarding/data/index.ts b/src/features/onboarding/data/index.ts
new file mode 100644
index 0000000..6612251
--- /dev/null
+++ b/src/features/onboarding/data/index.ts
@@ -0,0 +1,5 @@
+// Barrel export for onboarding data
+
+export { BARRIERS, type BarrierOption } from './barriers'
+export { GOALS, type GoalOption } from './goals'
+export { LEVELS, type LevelOption } from './levels'
diff --git a/src/features/onboarding/data/levels.ts b/src/features/onboarding/data/levels.ts
new file mode 100644
index 0000000..8a3e687
--- /dev/null
+++ b/src/features/onboarding/data/levels.ts
@@ -0,0 +1,14 @@
+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' },
+]
diff --git a/src/features/onboarding/hooks/useOnboarding.ts b/src/features/onboarding/hooks/useOnboarding.ts
new file mode 100644
index 0000000..b5a8b57
--- /dev/null
+++ b/src/features/onboarding/hooks/useOnboarding.ts
@@ -0,0 +1,137 @@
+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 => {
+ return await AsyncStorage.getItem(name)
+ },
+ setItem: async (name: string, value: string): Promise => {
+ await AsyncStorage.setItem(name, value)
+ },
+ removeItem: async (name: string): Promise => {
+ 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) => 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()(
+ 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) => {
+ 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)
diff --git a/src/features/onboarding/index.ts b/src/features/onboarding/index.ts
new file mode 100644
index 0000000..5feec6b
--- /dev/null
+++ b/src/features/onboarding/index.ts
@@ -0,0 +1,26 @@
+// 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'
diff --git a/src/features/onboarding/screens/Screen1Problem.tsx b/src/features/onboarding/screens/Screen1Problem.tsx
new file mode 100644
index 0000000..530c458
--- /dev/null
+++ b/src/features/onboarding/screens/Screen1Problem.tsx
@@ -0,0 +1,165 @@
+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 (
+
+
+ {/* Clock Icon Animation */}
+
+
+
+
+
+
+
+
+
+
+
+ {/* Title and Subtitle */}
+
+ Tu n'as pas 1 heure pour t'entrainer ?
+ Ni 30 minutes ? Ni meme 10 ?
+
+
+ {/* Spacer */}
+
+
+ {/* Continue Button */}
+
+
+
+ )
+}
+
+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,
+ },
+})
diff --git a/src/features/onboarding/screens/Screen2Empathy.tsx b/src/features/onboarding/screens/Screen2Empathy.tsx
new file mode 100644
index 0000000..755125b
--- /dev/null
+++ b/src/features/onboarding/screens/Screen2Empathy.tsx
@@ -0,0 +1,104 @@
+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(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 (
+
+
+ {/* Title */}
+
+
+ Qu'est-ce qui t'empeche de t'entrainer ?
+
+
+
+ {/* Choice Buttons */}
+
+ {BARRIERS.map((barrier, index) => (
+
+ handleBarrierSelect(barrier.id)}
+ />
+
+ ))}
+
+
+
+ )
+}
+
+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%',
+ },
+})
diff --git a/src/features/onboarding/screens/Screen3Solution.tsx b/src/features/onboarding/screens/Screen3Solution.tsx
new file mode 100644
index 0000000..49b8c1e
--- /dev/null
+++ b/src/features/onboarding/screens/Screen3Solution.tsx
@@ -0,0 +1,251 @@
+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 (
+
+ {Array.from({ length: TABATA_ROUNDS }, (_, index) => {
+ const isWork = index % 2 === 0
+ const baseColor = isWork ? PHASE_COLORS.WORK : PHASE_COLORS.REST
+
+ return (
+
+
+ {isWork ? WORK_DURATION : REST_DURATION}s
+
+
+ )
+ })}
+
+ )
+ }
+
+ return (
+
+
+ {/* Animated Title */}
+
+ 4 minutes
+
+
+ {/* Subtitle */}
+ Vraiment transformatrices.
+
+ {/* Tabata Timeline Animation */}
+
+ {renderTabataTimeline()}
+
+ {/* Legend */}
+
+
+
+ 20s travail
+
+
+
+ 10s repos
+
+ x 8 rounds
+
+
+
+ {/* Scientific Explanation */}
+
+
+ Protocole scientifique HIIT = resultats max en temps min
+
+
+
+ {/* Spacer */}
+
+
+ {/* Continue Button */}
+
+
+
+ )
+}
+
+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,
+ },
+})
diff --git a/src/features/onboarding/screens/Screen4WowMoment.tsx b/src/features/onboarding/screens/Screen4WowMoment.tsx
new file mode 100644
index 0000000..0e20ca0
--- /dev/null
+++ b/src/features/onboarding/screens/Screen4WowMoment.tsx
@@ -0,0 +1,149 @@
+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('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 (
+
+
+ {/* Title Section */}
+
+ Essaie maintenant
+ 20 secondes. Juste pour sentir.
+
+
+ {/* Timer Demo - The "Wow" Moment */}
+
+
+
+
+ {/* Instruction Text */}
+
+ {getInstructionText()}
+
+
+ {/* Continue Button - Only visible after completion */}
+
+ {isComplete ? (
+
+
+
+ ) : (
+
+ )}
+
+
+
+ )
+}
+
+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,
+ },
+})
diff --git a/src/features/onboarding/screens/Screen5Personalization.tsx b/src/features/onboarding/screens/Screen5Personalization.tsx
new file mode 100644
index 0000000..1fb7ff1
--- /dev/null
+++ b/src/features/onboarding/screens/Screen5Personalization.tsx
@@ -0,0 +1,186 @@
+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(data.level)
+ const [selectedGoal, setSelectedGoal] = useState(data.goal)
+ const [selectedFrequency, setSelectedFrequency] = useState(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 (
+
+
+
+ Personnalise ton experience
+
+ Dis-nous en plus sur toi pour un programme sur mesure
+
+
+
+ {/* Level Section */}
+
+
+
+ Niveau
+
+
+ {LEVELS.map((level) => (
+ handleLevelSelect(level.id)}
+ />
+ ))}
+
+
+
+ {/* Goal Section */}
+
+
+
+ Objectif
+
+
+ {GOALS.map((goal) => (
+ handleGoalSelect(goal.id)}
+ />
+ ))}
+
+
+
+ {/* Frequency Section */}
+
+
+
+ Frequence
+
+
+ {FREQUENCIES.map((freq) => (
+ handleFrequencySelect(freq.id)}
+ />
+ ))}
+
+
+
+
+
+
+
+
+ )
+}
+
+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],
+ },
+})
diff --git a/src/features/onboarding/screens/Screen6Paywall.tsx b/src/features/onboarding/screens/Screen6Paywall.tsx
new file mode 100644
index 0000000..a2b5bc9
--- /dev/null
+++ b/src/features/onboarding/screens/Screen6Paywall.tsx
@@ -0,0 +1,234 @@
+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 (
+
+
+
+ Debloque ton potentiel
+
+ 7 jours gratuits, annule quand tu veux
+
+
+
+
+ {PLANS.map((plan) => (
+ handlePlanSelect(plan.id)}
+ />
+ ))}
+
+
+
+
+ Garantie satisfait ou rembourse sous 30 jours
+
+
+
+
+
+
+
+ {isLoading ? 'Traitement...' : selectedPlan === 'trial' ? 'Essayer gratuitement' : "S'abonner"}
+
+
+
+
+
+ Continuer sans abonnement
+
+
+
+
+ )
+}
+
+// Mock RevenueCat purchase function for dev mode
+async function mockPurchase(planId: string): Promise {
+ // 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',
+ },
+})
diff --git a/src/features/onboarding/screens/index.ts b/src/features/onboarding/screens/index.ts
new file mode 100644
index 0000000..cdb24e2
--- /dev/null
+++ b/src/features/onboarding/screens/index.ts
@@ -0,0 +1,2 @@
+export { Screen5Personalization } from './Screen5Personalization'
+export { Screen6Paywall } from './Screen6Paywall'
diff --git a/src/features/onboarding/types.ts b/src/features/onboarding/types.ts
new file mode 100644
index 0000000..80c73a2
--- /dev/null
+++ b/src/features/onboarding/types.ts
@@ -0,0 +1,20 @@
+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
+}
diff --git a/src/features/timer/components/TimerControls.tsx b/src/features/timer/components/TimerControls.tsx
index 530a814..46448a2 100644
--- a/src/features/timer/components/TimerControls.tsx
+++ b/src/features/timer/components/TimerControls.tsx
@@ -1,6 +1,10 @@
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
@@ -24,7 +28,7 @@ export function TimerControls({
style={({ pressed }) => [styles.button, pressed && styles.pressed]}
onPress={onStop}
>
-
+
@@ -46,7 +50,7 @@ export function TimerControls({
style={({ pressed }) => [styles.button, pressed && styles.pressed]}
onPress={onSkip}
>
-
+
)
@@ -57,23 +61,23 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
- gap: 32,
+ gap: LAYOUT.CONTROLS_GAP,
},
button: {
width: 56,
height: 56,
- borderRadius: 28,
- backgroundColor: 'rgba(255, 255, 255, 0.15)',
+ borderRadius: RADIUS['2XL'],
+ backgroundColor: SURFACE.OVERLAY_MEDIUM,
alignItems: 'center',
justifyContent: 'center',
},
mainButton: {
width: 72,
height: 72,
- borderRadius: 36,
- backgroundColor: 'rgba(255, 255, 255, 0.25)',
+ borderRadius: RADIUS['4XL'],
+ backgroundColor: SURFACE.OVERLAY_STRONG,
},
pressed: {
- opacity: 0.6,
+ transform: [{ scale: 0.92 }],
},
})
diff --git a/src/features/timer/components/TimerDisplay.tsx b/src/features/timer/components/TimerDisplay.tsx
index aad6f7b..09a6b12 100644
--- a/src/features/timer/components/TimerDisplay.tsx
+++ b/src/features/timer/components/TimerDisplay.tsx
@@ -1,22 +1,38 @@
import { useEffect, useRef, useState } from 'react'
import {
Animated,
- Easing,
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_COLORS } from '@/src/shared/constants/colors'
+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, TimerState } from '../types'
+import type { TimerConfig, TimerPhase, TimerState } from '../types'
import { TimerControls } from './TimerControls'
-const PHASE_LABELS: Record = {
+const PHASE_LABELS: Record = {
+ IDLE: '',
GET_READY: 'PRÉPARE-TOI',
- WORK: 'TRAVAIL',
+ WORK: 'GO !',
REST: 'REPOS',
COMPLETE: 'TERMINÉ !',
}
@@ -45,16 +61,11 @@ export function TimerDisplay({
onSkip,
}: TimerDisplayProps) {
const insets = useSafeAreaInsets()
+ const top = insets.top || 20
+ const bottom = insets.bottom || 20
if (state.phase === 'IDLE') {
- return (
-
- )
+ return
}
if (state.phase === 'COMPLETE') {
@@ -63,8 +74,8 @@ export function TimerDisplay({
totalElapsedSeconds={state.totalElapsedSeconds}
totalRounds={state.totalRounds}
onStop={onStop}
- topInset={insets.top}
- bottomInset={insets.bottom}
+ top={top}
+ bottom={bottom}
/>
)
}
@@ -78,85 +89,151 @@ export function TimerDisplay({
onResume={onResume}
onStop={onStop}
onSkip={onSkip}
- topInset={insets.top}
- bottomInset={insets.bottom}
+ top={top}
+ bottom={bottom}
/>
)
}
-// --- IDLE view ---
+// ──────────────────────────────────────────────────────
+// IDLE — Start screen
+// ──────────────────────────────────────────────────────
function IdleView({
config,
onStart,
- topInset,
- bottomInset,
+ top,
+ bottom,
}: {
config: TimerConfig
onStart: () => void
- topInset: number
- bottomInset: number
+ 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 (
-
- TABATAGO
+ TABATA
+ GO
-
- {config.workDuration}s travail / {config.restDuration}s repos
-
-
- {config.rounds} rounds
-
+
+
+
+
+
- [
- styles.startButton,
- pressed && styles.startButtonPressed,
- ]}
- onPress={onStart}
- >
- START
-
+
+
+ [
+ styles.startButton,
+ pressed && styles.buttonPressed,
+ ]}
+ onPress={onStart}
+ >
+ START
+
+
+
+ )
+}
+
+function ConfigBadge({ label, sub }: { label: string; sub: string }) {
+ return (
+
+ {label}
+ {sub}
)
}
-// --- COMPLETE view ---
+// ──────────────────────────────────────────────────────
+// COMPLETE — Victory screen
+// ──────────────────────────────────────────────────────
function CompleteView({
totalElapsedSeconds,
totalRounds,
onStop,
- topInset,
- bottomInset,
+ top,
+ bottom,
}: {
totalElapsedSeconds: number
totalRounds: number
onStop: () => void
- topInset: number
- bottomInset: number
+ 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 (
-
-
+
+ 🔥
TERMINÉ !
{formatTime(totalElapsedSeconds)}
@@ -166,18 +243,20 @@ function CompleteView({
[
styles.doneButton,
- pressed && styles.startButtonPressed,
+ pressed && styles.buttonPressed,
]}
onPress={onStop}
>
Terminer
-
-
+
+
)
}
-// --- ACTIVE view (GET_READY, WORK, REST) ---
+// ──────────────────────────────────────────────────────
+// ACTIVE — Countdown with native blur toolbar
+// ──────────────────────────────────────────────────────
function ActiveView({
state,
@@ -187,8 +266,8 @@ function ActiveView({
onResume,
onStop,
onSkip,
- topInset,
- bottomInset,
+ top,
+ bottom,
}: {
state: TimerState
exerciseName: string
@@ -197,59 +276,36 @@ function ActiveView({
onResume: () => void
onStop: () => void
onSkip: () => void
- topInset: number
- bottomInset: number
+ top: number
+ bottom: number
}) {
- // --- Background color transition ---
- const [prevColor, setPrevColor] = useState(PHASE_COLORS.IDLE)
+ const gradient = PHASE_GRADIENTS[state.phase] ?? PHASE_GRADIENTS.IDLE
+
+ // --- Gradient crossfade ---
+ const [prevGradient, setPrevGradient] = useState(PHASE_GRADIENTS.IDLE)
const fadeAnim = useRef(new Animated.Value(1)).current
useEffect(() => {
- const targetColor = PHASE_COLORS[state.phase]
fadeAnim.setValue(0)
- Animated.timing(fadeAnim, {
- toValue: 1,
- duration: 600,
- easing: Easing.inOut(Easing.ease),
- useNativeDriver: false,
- }).start(() => {
- setPrevColor(targetColor)
+ Animated.timing(fadeAnim, ANIMATION.GRADIENT_CROSSFADE).start(() => {
+ setPrevGradient(gradient)
})
}, [state.phase])
- const backgroundColor = fadeAnim.interpolate({
- inputRange: [0, 1],
- outputRange: [prevColor, PHASE_COLORS[state.phase]],
- })
-
// --- Countdown pulse ---
const pulseAnim = useRef(new Animated.Value(1)).current
useEffect(() => {
if (state.isRunning) {
Animated.sequence([
- Animated.timing(pulseAnim, {
- toValue: 1.05,
- duration: 80,
- useNativeDriver: true,
- }),
- Animated.timing(pulseAnim, {
- toValue: 1,
- duration: 80,
- useNativeDriver: true,
- }),
+ Animated.timing(pulseAnim, ANIMATION.PULSE_UP),
+ Animated.timing(pulseAnim, ANIMATION.PULSE_DOWN),
]).start()
}
}, [state.secondsLeft])
- // --- Progress ---
- const totalPhaseDuration =
- state.phase === 'GET_READY'
- ? 0
- : (state.currentRound - 1) / state.totalRounds
const isLastSeconds = state.secondsLeft <= 3 && state.secondsLeft > 0
- // Top info line
const topLabel =
state.phase === 'GET_READY'
? exerciseName
@@ -258,86 +314,99 @@ function ActiveView({
: exerciseName
return (
-
+
- {/* Zone haute — exercice + round */}
-
-
- {topLabel}
-
- {state.currentRound > 0 && (
-
- Round {state.currentRound}/{state.totalRounds}
-
- )}
-
-
- {/* Zone centrale — countdown */}
-
-
- {PHASE_LABELS[state.phase] ?? ''}
-
-
- {state.secondsLeft}
-
- {state.isPaused && (
- EN PAUSE
- )}
-
-
- {/* Zone basse haute — progress bar */}
-
-
-
-
-
- {Array.from({ length: state.totalRounds }, (_, i) => (
-
- ))}
-
-
-
- {/* Zone basse — controls */}
-
-
+
+
+
+
+ {/* Content */}
+
+ {/* Top bar */}
+
+
+ {topLabel}
+
+ {state.currentRound > 0 && (
+
+
+ {state.currentRound}/{state.totalRounds}
+
+
+ )}
+
+
+ {/* Center — countdown */}
+
+
+ {PHASE_LABELS[state.phase]}
+
+
+
+ {state.secondsLeft}
+
+
+ {state.isPaused && (
+
+ EN PAUSE
+
+ )}
+
+
+ {/* Bottom toolbar — native blur like iOS UIToolbar */}
+
+
+ {Array.from({ length: state.totalRounds }, (_, i) => (
+
+ ))}
+
+
+
+
-
+
)
}
+// ──────────────────────────────────────────────────────
+// Styles
+// ──────────────────────────────────────────────────────
+
const styles = StyleSheet.create({
screen: {
flex: 1,
@@ -348,94 +417,137 @@ const styles = StyleSheet.create({
flex: 1,
alignItems: 'center',
justifyContent: 'center',
- gap: 24,
},
idleTitle: {
- fontSize: 40,
- fontWeight: '900',
- color: '#FFFFFF',
- letterSpacing: 6,
+ ...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',
- gap: 4,
- marginTop: 16,
+ borderWidth: 1,
+ borderColor: BORDER.LIGHT,
},
- configLine: {
- fontSize: 18,
- color: 'rgba(255, 255, 255, 0.7)',
- fontWeight: '500',
+ configBadgeLabel: {
+ ...TYPOGRAPHY.heading,
+ color: ACCENT.WHITE,
},
- startButton: {
- width: 140,
- height: 140,
- borderRadius: 70,
- backgroundColor: '#F97316',
+ configBadgeSub: {
+ ...TYPOGRAPHY.overline,
+ color: TEXT.MUTED,
+ marginTop: 2,
+ },
+ startButtonContainer: {
+ marginTop: SPACING[14],
alignItems: 'center',
justifyContent: 'center',
- marginTop: 40,
},
- startButtonPressed: {
- opacity: 0.7,
+ 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: {
- fontSize: 28,
- fontWeight: '900',
- color: '#FFFFFF',
- letterSpacing: 3,
+ ...TYPOGRAPHY.buttonHero,
+ color: ACCENT.WHITE,
},
// --- COMPLETE ---
+ completeContent: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: SPACING[2],
+ },
+ completeEmoji: {
+ fontSize: 72,
+ marginBottom: SPACING[2],
+ },
completeTitle: {
- fontSize: 40,
- fontWeight: '900',
- color: '#FFFFFF',
+ ...TYPOGRAPHY.displayLarge,
+ color: ACCENT.WHITE,
},
completeTime: {
- fontSize: 64,
- fontWeight: '900',
- color: '#FFFFFF',
- fontVariant: ['tabular-nums'],
- marginTop: 8,
+ ...TYPOGRAPHY.timeDisplay,
+ color: ACCENT.WHITE,
+ ...TEXT_SHADOW.WHITE_MEDIUM,
},
completeRounds: {
- fontSize: 18,
- color: 'rgba(255, 255, 255, 0.8)',
- fontWeight: '500',
+ ...TYPOGRAPHY.body,
+ color: TEXT.TERTIARY,
+ fontWeight: '600',
},
doneButton: {
- paddingHorizontal: 48,
- paddingVertical: 16,
- borderRadius: 32,
- backgroundColor: 'rgba(255, 255, 255, 0.25)',
- marginTop: 40,
+ marginTop: SPACING[10],
+ paddingHorizontal: SPACING[12],
+ paddingVertical: SPACING[4],
+ borderRadius: RADIUS['3XL'],
+ backgroundColor: SURFACE.OVERLAY_MEDIUM,
+ borderWidth: 1,
+ borderColor: BORDER.STRONG,
},
doneButtonText: {
- fontSize: 20,
- fontWeight: '700',
- color: '#FFFFFF',
+ ...TYPOGRAPHY.buttonMedium,
+ color: ACCENT.WHITE,
},
// --- ACTIVE ---
+ activeContent: {
+ flex: 1,
+ },
topZone: {
- paddingHorizontal: 24,
- paddingTop: 16,
- paddingBottom: 12,
+ paddingHorizontal: LAYOUT.PAGE_HORIZONTAL,
+ paddingTop: SPACING[3],
+ paddingBottom: SPACING[2],
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
exerciseName: {
- fontSize: 18,
- fontWeight: '600',
- color: 'rgba(255, 255, 255, 0.9)',
+ ...TYPOGRAPHY.body,
+ color: TEXT.SECONDARY,
flex: 1,
},
- roundIndicator: {
- fontSize: 16,
- fontWeight: '700',
- color: 'rgba(255, 255, 255, 0.8)',
- marginLeft: 12,
+ 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: {
@@ -444,68 +556,60 @@ const styles = StyleSheet.create({
justifyContent: 'center',
},
phaseLabel: {
- fontSize: 22,
- fontWeight: '700',
- color: 'rgba(255, 255, 255, 0.8)',
- letterSpacing: 3,
- marginBottom: 8,
+ ...TYPOGRAPHY.heading,
+ color: TEXT.TERTIARY,
+ letterSpacing: 6,
+ marginBottom: SPACING[1],
},
countdown: {
- fontSize: 120,
- fontWeight: '900',
- color: '#FFFFFF',
- fontVariant: ['tabular-nums'],
+ ...TYPOGRAPHY.countdown,
+ color: ACCENT.WHITE,
+ ...TEXT_SHADOW.WHITE_SOFT,
},
countdownFlash: {
- color: '#EF4444',
+ color: ACCENT.RED_HOT,
+ ...TEXT_SHADOW.DANGER,
+ },
+ pausedContainer: {
+ marginTop: SPACING[4],
+ paddingVertical: SPACING[2],
+ paddingHorizontal: SPACING[6],
+ borderRadius: RADIUS.XL,
+ backgroundColor: SURFACE.SCRIM,
},
pausedLabel: {
- fontSize: 18,
- fontWeight: '700',
- color: 'rgba(255, 255, 255, 0.6)',
- letterSpacing: 4,
- marginTop: 12,
+ ...TYPOGRAPHY.caption,
+ fontWeight: '800',
+ color: TEXT.MUTED,
+ letterSpacing: 6,
},
- progressZone: {
- paddingHorizontal: 24,
- gap: 12,
- marginBottom: 8,
- },
- progressTrack: {
- height: 4,
- backgroundColor: 'rgba(255, 255, 255, 0.2)',
- borderRadius: 2,
- overflow: 'hidden',
- },
- progressFill: {
- height: '100%',
- backgroundColor: 'rgba(255, 255, 255, 0.8)',
- borderRadius: 2,
+ // --- Bottom toolbar (native blur) ---
+ bottomBar: {
+ borderTopWidth: StyleSheet.hairlineWidth,
+ borderTopColor: BORDER.MEDIUM,
+ paddingTop: SPACING[4],
+ gap: SPACING[5],
},
roundDots: {
flexDirection: 'row',
justifyContent: 'center',
- gap: 8,
+ gap: SPACING[2.5],
},
dot: {
width: 8,
height: 8,
- borderRadius: 4,
- backgroundColor: 'rgba(255, 255, 255, 0.25)',
+ borderRadius: RADIUS.XS,
+ backgroundColor: SURFACE.OVERLAY_MEDIUM,
},
dotFilled: {
- backgroundColor: 'rgba(255, 255, 255, 0.9)',
+ backgroundColor: TEXT.SECONDARY,
},
dotActive: {
- backgroundColor: '#FFFFFF',
- width: 10,
- height: 10,
- borderRadius: 5,
- },
-
- controlsZone: {
- paddingBottom: 24,
- paddingTop: 16,
+ backgroundColor: ACCENT.WHITE,
+ width: 12,
+ height: 12,
+ borderRadius: SPACING[1.5],
+ ...SHADOW.WHITE_GLOW,
},
})
diff --git a/src/features/timer/hooks/useTimerEngine.ts b/src/features/timer/hooks/useTimerEngine.ts
index 8a64f10..933d135 100644
--- a/src/features/timer/hooks/useTimerEngine.ts
+++ b/src/features/timer/hooks/useTimerEngine.ts
@@ -117,6 +117,9 @@ export function useTimerEngine(): TimerEngine {
const duration = getDurationForPhase(nextPhase, configRef.current)
if (duration > 0) {
startCountdown(duration)
+ } else {
+ // Phase with 0 duration - immediately advance to next phase
+ advancePhase()
}
}
diff --git a/src/shared/components/GlassView.tsx b/src/shared/components/GlassView.tsx
new file mode 100644
index 0000000..5341336
--- /dev/null
+++ b/src/shared/components/GlassView.tsx
@@ -0,0 +1,32 @@
+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 (
+
+
+ {children}
+
+ )
+}
+
+const styles = StyleSheet.create({
+ container: {
+ overflow: 'hidden',
+ backgroundColor: GLASS.FILL,
+ borderWidth: 0.5,
+ borderColor: GLASS.BORDER,
+ borderTopColor: GLASS.BORDER_TOP,
+ },
+})
diff --git a/src/shared/components/Typography.tsx b/src/shared/components/Typography.tsx
new file mode 100644
index 0000000..4f5d1b6
--- /dev/null
+++ b/src/shared/components/Typography.tsx
@@ -0,0 +1,25 @@
+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 (
+
+ {children}
+
+ )
+}
diff --git a/src/shared/constants/animations.ts b/src/shared/constants/animations.ts
new file mode 100644
index 0000000..fa3c215
--- /dev/null
+++ b/src/shared/constants/animations.ts
@@ -0,0 +1,51 @@
+import { Easing } from 'react-native'
+
+export const DURATION = {
+ SNAP: 60,
+ QUICK: 120,
+ FAST: 300,
+ NORMAL: 400,
+ SLOW: 600,
+ XSLOW: 1000,
+ BREATH: 1800,
+} as const
+
+export const EASING = {
+ STANDARD: Easing.inOut(Easing.ease),
+ DECELERATE: Easing.out(Easing.ease),
+ LINEAR: Easing.linear,
+} as const
+
+export const SPRING = {
+ BOUNCY: { tension: 50, friction: 7 },
+} as const
+
+export const ANIMATION = {
+ PULSE_UP: {
+ toValue: 1.08,
+ duration: DURATION.SNAP,
+ useNativeDriver: true,
+ },
+ PULSE_DOWN: {
+ toValue: 1,
+ duration: DURATION.QUICK,
+ easing: EASING.DECELERATE,
+ useNativeDriver: true,
+ },
+ GRADIENT_CROSSFADE: {
+ toValue: 1,
+ duration: DURATION.SLOW,
+ easing: EASING.STANDARD,
+ useNativeDriver: true,
+ },
+ FADE_IN: {
+ toValue: 1,
+ duration: DURATION.NORMAL,
+ useNativeDriver: true,
+ },
+ BREATH_HALF: {
+ duration: DURATION.BREATH,
+ easing: EASING.STANDARD,
+ useNativeDriver: false,
+ },
+} as const
diff --git a/src/shared/constants/borderRadius.ts b/src/shared/constants/borderRadius.ts
new file mode 100644
index 0000000..609595f
--- /dev/null
+++ b/src/shared/constants/borderRadius.ts
@@ -0,0 +1,11 @@
+export const RADIUS = {
+ XS: 4,
+ SM: 6,
+ MD: 12,
+ LG: 16,
+ XL: 20,
+ '2XL': 28,
+ '3XL': 32,
+ '4XL': 36,
+ FULL: 9999,
+} as const
diff --git a/src/shared/constants/colors.ts b/src/shared/constants/colors.ts
index 5473b40..2ef4ec5 100644
--- a/src/shared/constants/colors.ts
+++ b/src/shared/constants/colors.ts
@@ -5,3 +5,72 @@ export const PHASE_COLORS = {
REST: '#3B82F6',
COMPLETE: '#22C55E',
} as const
+
+export const PHASE_GRADIENTS = {
+ IDLE: ['#0A0A14', '#12101F', '#1E1E2E'] as const,
+ GET_READY: ['#451A03', '#92400E', '#D97706'] as const,
+ WORK: ['#450A0A', '#991B1B', '#EA580C'] as const,
+ REST: ['#0C1929', '#1E3A5F', '#2563EB'] as const,
+ COMPLETE: ['#052E16', '#166534', '#16A34A'] as const,
+} as const
+
+export const ACCENT = {
+ ORANGE: '#F97316',
+ ORANGE_GLOW: '#FB923C',
+ RED_HOT: '#EF4444',
+ GOLD: '#FBBF24',
+ WHITE: '#FFFFFF',
+ WHITE_DIM: 'rgba(255, 255, 255, 0.6)',
+ WHITE_FAINT: 'rgba(255, 255, 255, 0.15)',
+} as const
+
+export const BRAND = {
+ PRIMARY: '#F97316',
+ PRIMARY_LIGHT: '#FB923C',
+ SECONDARY: '#FBBF24',
+ DANGER: '#EF4444',
+ SUCCESS: '#22C55E',
+ INFO: '#3B82F6',
+} as const
+
+export const SURFACE = {
+ BASE: '#0A0A14',
+ RAISED: '#12101F',
+ ELEVATED: '#1E1E2E',
+ OVERLAY_LIGHT: 'rgba(255, 255, 255, 0.08)',
+ OVERLAY_MEDIUM: 'rgba(255, 255, 255, 0.15)',
+ OVERLAY_STRONG: 'rgba(255, 255, 255, 0.25)',
+ SCRIM: 'rgba(0, 0, 0, 0.3)',
+} as const
+
+export const TEXT = {
+ PRIMARY: '#FFFFFF',
+ SECONDARY: 'rgba(255, 255, 255, 0.85)',
+ TERTIARY: 'rgba(255, 255, 255, 0.75)',
+ MUTED: 'rgba(255, 255, 255, 0.6)',
+ HINT: 'rgba(255, 255, 255, 0.45)',
+ DISABLED: 'rgba(255, 255, 255, 0.15)',
+} as const
+
+export const BORDER = {
+ SUBTLE: 'rgba(255, 255, 255, 0.05)',
+ LIGHT: 'rgba(255, 255, 255, 0.06)',
+ MEDIUM: 'rgba(255, 255, 255, 0.15)',
+ STRONG: 'rgba(255, 255, 255, 0.3)',
+} as const
+
+export const APP_GRADIENTS = {
+ HOME: ['#0A0A14', '#1A0E2E', '#2D1810'] as const,
+} as const
+
+export const GLASS = {
+ FILL: 'rgba(255, 255, 255, 0.03)',
+ FILL_MEDIUM: 'rgba(255, 255, 255, 0.06)',
+ FILL_STRONG: 'rgba(255, 255, 255, 0.10)',
+ BORDER: 'rgba(255, 255, 255, 0.08)',
+ BORDER_TOP: 'rgba(255, 255, 255, 0.15)',
+ BLUR_LIGHT: 15,
+ BLUR_MEDIUM: 25,
+ BLUR_HEAVY: 40,
+ BLUR_ATMOSPHERE: 60,
+} as const
diff --git a/src/shared/constants/index.ts b/src/shared/constants/index.ts
new file mode 100644
index 0000000..ea19544
--- /dev/null
+++ b/src/shared/constants/index.ts
@@ -0,0 +1,16 @@
+export {
+ PHASE_COLORS,
+ PHASE_GRADIENTS,
+ ACCENT,
+ BRAND,
+ SURFACE,
+ TEXT,
+ BORDER,
+ APP_GRADIENTS,
+ GLASS,
+} from './colors'
+export { TYPOGRAPHY } from './typography'
+export { SPACING, LAYOUT } from './spacing'
+export { SHADOW, TEXT_SHADOW } from './shadows'
+export { RADIUS } from './borderRadius'
+export { DURATION, EASING, SPRING, ANIMATION } from './animations'
diff --git a/src/shared/constants/shadows.ts b/src/shared/constants/shadows.ts
new file mode 100644
index 0000000..f9fd811
--- /dev/null
+++ b/src/shared/constants/shadows.ts
@@ -0,0 +1,44 @@
+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
diff --git a/src/shared/constants/spacing.ts b/src/shared/constants/spacing.ts
new file mode 100644
index 0000000..add388c
--- /dev/null
+++ b/src/shared/constants/spacing.ts
@@ -0,0 +1,25 @@
+export const SPACING = {
+ 0: 0,
+ 0.5: 2,
+ 1: 4,
+ 1.5: 6,
+ 2: 8,
+ 2.5: 10,
+ 3: 12,
+ 3.5: 14,
+ 4: 16,
+ 5: 20,
+ 6: 24,
+ 8: 32,
+ 10: 40,
+ 12: 48,
+ 14: 56,
+ 15: 60,
+} as const
+
+export const LAYOUT = {
+ PAGE_HORIZONTAL: 24,
+ SECTION_GAP: 40,
+ INLINE_GAP: 16,
+ CONTROLS_GAP: 32,
+} as const
diff --git a/src/shared/constants/typography.ts b/src/shared/constants/typography.ts
new file mode 100644
index 0000000..5745a1c
--- /dev/null
+++ b/src/shared/constants/typography.ts
@@ -0,0 +1,73 @@
+import type { TextStyle } from 'react-native'
+
+export const TYPOGRAPHY = {
+ countdown: {
+ fontSize: 140,
+ fontWeight: '900',
+ fontVariant: ['tabular-nums'],
+ } as TextStyle,
+
+ timeDisplay: {
+ fontSize: 72,
+ fontWeight: '900',
+ fontVariant: ['tabular-nums'],
+ } as TextStyle,
+
+ brandTitle: {
+ fontSize: 56,
+ fontWeight: '900',
+ letterSpacing: 12,
+ } as TextStyle,
+
+ displayLarge: {
+ fontSize: 42,
+ fontWeight: '900',
+ letterSpacing: 4,
+ } as TextStyle,
+
+ displaySmall: {
+ fontSize: 32,
+ fontWeight: '900',
+ letterSpacing: 20,
+ } as TextStyle,
+
+ buttonHero: {
+ fontSize: 30,
+ fontWeight: '900',
+ letterSpacing: 5,
+ } as TextStyle,
+
+ heading: {
+ fontSize: 24,
+ fontWeight: '800',
+ } as TextStyle,
+
+ buttonMedium: {
+ fontSize: 20,
+ fontWeight: '700',
+ letterSpacing: 2,
+ } as TextStyle,
+
+ body: {
+ fontSize: 18,
+ fontWeight: '700',
+ } as TextStyle,
+
+ caption: {
+ fontSize: 16,
+ fontWeight: '500',
+ } as TextStyle,
+
+ label: {
+ fontSize: 15,
+ fontWeight: '800',
+ letterSpacing: 1,
+ } as TextStyle,
+
+ overline: {
+ fontSize: 11,
+ fontWeight: '600',
+ textTransform: 'uppercase',
+ letterSpacing: 1,
+ } as TextStyle,
+} as const