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 ( - + + ) } +// ────────────────────────────────────────────────────── +// 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