feat: onboarding flow (6 screens) + audio engine + design system

Onboarding:
- 6-screen flow: Problem → Empathy → Solution → Wow → Personalization → Paywall
- useOnboarding hook with Zustand + AsyncStorage persistence
- MiniTimerDemo with live 20s timer + haptics
- Auto-redirect for first-time users
- Mock RevenueCat for dev testing

Audio:
- useAudioEngine hook with expo-av
- Phase sounds (count_3/2/1, beep, bell, fanfare)
- Placeholder music tracks

Design System:
- Typography component + constants
- GlassView component
- Spacing, shadows, animations, borderRadius constants
- Extended color palette (phase gradients, glass, surfaces)

Timer:
- Fix: handle 0-duration phases (immediate advance)
- Enhanced TimerDisplay with phase gradients

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Millian Lamiaux
2026-02-17 21:52:23 +01:00
parent 31bdb1586f
commit fa189fe72e
55 changed files with 3361 additions and 320 deletions

View File

@@ -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 (
<Tabs
screenOptions={{
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
headerShown: false,
tabBarButton: HapticTab,
}}>
tabBarActiveTintColor: BRAND.PRIMARY,
tabBarInactiveTintColor: TEXT.HINT,
tabBarStyle: {
backgroundColor: SURFACE.BASE,
borderTopColor: BORDER.SUBTLE,
},
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Home',
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
title: 'Accueil',
tabBarIcon: ({ color, size }) => (
<Ionicons name="flame" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="explore"
options={{
title: 'Explore',
tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />,
title: 'Explorer',
tabBarIcon: ({ color, size }) => (
<Ionicons name="compass" size={size} color={color} />
),
}}
/>
</Tabs>
);
)
}

View File

@@ -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 <Redirect href="/onboarding" />
}
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 (
<View style={[styles.container, { paddingTop: insets.top + 24 }]}>
<LinearGradient
colors={APP_GRADIENTS.HOME}
style={[styles.container, { paddingTop: insets.top + 40 }]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<StatusBar style="light" />
<Text style={styles.title}>TABATAGO</Text>
<Text style={styles.subtitle}>Entraînement Tabata</Text>
<View style={styles.brandArea}>
<Text style={styles.title}>TABATA</Text>
<Text style={styles.subtitle}>GO</Text>
<Text style={styles.tagline}>4 minutes. Tout donner.</Text>
</View>
<Pressable
style={({ pressed }) => [
styles.startButton,
pressed && styles.startButtonPressed,
]}
onPress={() => router.push('/timer')}
>
<Text style={styles.startButtonText}>START</Text>
</Pressable>
</View>
<View style={styles.buttonArea}>
<Animated.View
style={[
styles.buttonGlow,
{ opacity: glowOpacity, transform: [{ scale: glowScale }] },
]}
/>
<Pressable
style={({ pressed }) => [
styles.startButton,
pressed && styles.startButtonPressed,
]}
onPress={() => router.push('/timer')}
>
<Text style={styles.startButtonText}>START</Text>
</Pressable>
</View>
</LinearGradient>
)
}
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,
},
})

View File

@@ -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 (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack>
<Stack.Screen
name="onboarding"
options={{
headerShown: false,
presentation: 'fullScreenModal',
animation: 'fade',
gestureEnabled: false,
}}
/>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="timer"

49
app/onboarding/index.tsx Normal file
View File

@@ -0,0 +1,49 @@
import { useRouter, Redirect } from 'expo-router'
import { Screen1Problem } from '@/src/features/onboarding/screens/Screen1Problem'
import { Screen2Empathy } from '@/src/features/onboarding/screens/Screen2Empathy'
import { Screen3Solution } from '@/src/features/onboarding/screens/Screen3Solution'
import { Screen4WowMoment } from '@/src/features/onboarding/screens/Screen4WowMoment'
import { Screen5Personalization } from '@/src/features/onboarding/screens/Screen5Personalization'
import { Screen6Paywall } from '@/src/features/onboarding/screens/Screen6Paywall'
import { useOnboarding } from '@/src/features/onboarding/hooks/useOnboarding'
export default function OnboardingRouter() {
const router = useRouter()
const currentStep = useOnboarding((state) => 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 <Redirect href="/(tabs)" />
}
// Render the correct screen based on current step
switch (currentStep) {
case 0:
return <Screen1Problem onNext={handleNext} />
case 1:
return <Screen2Empathy onNext={handleNext} />
case 2:
return <Screen3Solution onNext={handleNext} />
case 3:
return <Screen4WowMoment />
case 4:
return <Screen5Personalization onNext={handleNext} />
case 5:
return <Screen6Paywall onComplete={handleComplete} />
default:
// Fallback to first screen if step is out of bounds
return <Screen1Problem onNext={handleNext} />
}
}

View File

@@ -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()
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

108
package-lock.json generated
View File

@@ -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
}
}
}
}
}

View File

@@ -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
}

View File

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

View File

@@ -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
}

View File

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

View File

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

View File

@@ -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<void>
playPhaseSound: (sound: PhaseSound) => Promise<void>
startMusic: (intensity: MusicIntensity) => Promise<void>
switchIntensity: (intensity: MusicIntensity) => Promise<void>
stopMusic: (fadeMs?: number) => Promise<void>
unloadAll: () => Promise<void>
}

View File

@@ -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 (
<Pressable onPress={onPress} style={styles.pressable}>
<View style={[styles.container, selected && styles.containerSelected]}>
<View style={[styles.iconContainer, selected && styles.iconContainerSelected]}>
<Ionicons
name={icon}
size={24}
color={selected ? BRAND.PRIMARY : TEXT.MUTED}
/>
</View>
<View style={styles.textContainer}>
<Text style={[styles.label, selected && styles.labelSelected]}>
{label}
</Text>
{description && (
<Text style={styles.description}>{description}</Text>
)}
</View>
{selected && (
<View style={styles.checkmark}>
<Ionicons name="checkmark-circle" size={24} color={BRAND.PRIMARY} />
</View>
)}
</View>
</Pressable>
)
}
const styles = StyleSheet.create({
pressable: {
width: '100%',
},
container: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: GLASS.FILL,
borderWidth: 1,
borderColor: GLASS.BORDER,
borderRadius: RADIUS.LG,
paddingVertical: SPACING[4],
paddingHorizontal: SPACING[4],
gap: SPACING[3],
},
containerSelected: {
backgroundColor: SURFACE.OVERLAY_LIGHT,
borderColor: BRAND.PRIMARY,
borderWidth: 2,
},
iconContainer: {
width: 48,
height: 48,
borderRadius: RADIUS.MD,
backgroundColor: SURFACE.OVERLAY_LIGHT,
alignItems: 'center',
justifyContent: 'center',
},
iconContainerSelected: {
backgroundColor: SURFACE.OVERLAY_MEDIUM,
},
textContainer: {
flex: 1,
},
label: {
...TYPOGRAPHY.body,
color: TEXT.PRIMARY,
},
labelSelected: {
color: BRAND.PRIMARY,
},
description: {
...TYPOGRAPHY.caption,
color: TEXT.MUTED,
marginTop: 4,
},
checkmark: {
marginLeft: SPACING[2],
},
})

View File

@@ -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 (
<View style={styles.container}>
<Animated.View
style={[
styles.timerCircle,
{ borderColor: phaseColor },
{ transform: [{ scale: pulseAnim }] },
]}
>
<Animated.View
style={[
styles.glowOverlay,
{
backgroundColor: phaseColor,
opacity: glowAnim.interpolate({
inputRange: [0, 1],
outputRange: [0.05, 0.15],
}),
},
]}
/>
<Text style={[styles.countdown, { color: phaseColor }]}>
{displaySeconds}
</Text>
<Text style={[styles.phaseText, { color: phaseColor }]}>
{getPhaseText()}
</Text>
</Animated.View>
</View>
)
}
const styles = StyleSheet.create({
container: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: SPACING[6],
},
timerCircle: {
width: 200,
height: 200,
borderRadius: 100,
borderWidth: 4,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.3)',
overflow: 'hidden',
},
glowOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: 100,
},
countdown: {
...TYPOGRAPHY.timeDisplay,
fontVariant: ['tabular-nums'],
},
phaseText: {
...TYPOGRAPHY.label,
marginTop: SPACING[2],
letterSpacing: 3,
},
})

View File

@@ -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 (
<LinearGradient
colors={APP_GRADIENTS.HOME}
style={styles.gradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<SafeAreaView style={styles.container}>
<ProgressBar currentStep={currentStep} totalSteps={totalSteps} />
<View style={styles.content}>{children}</View>
</SafeAreaView>
</LinearGradient>
)
}
const styles = StyleSheet.create({
gradient: {
flex: 1,
},
container: {
flex: 1,
},
content: {
flex: 1,
paddingHorizontal: LAYOUT.PAGE_HORIZONTAL,
paddingBottom: SPACING[6],
},
})

View File

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

View File

@@ -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 (
<Pressable
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
disabled={disabled}
style={{ width: '100%' }}
>
<Animated.View
style={[
styles.button,
disabled && styles.buttonDisabled,
{ transform: [{ scale: animatedValue }] },
]}
>
<Text style={[styles.text, disabled && styles.textDisabled]}>
{title}
</Text>
</Animated.View>
</Pressable>
)
}
const styles = StyleSheet.create({
button: {
backgroundColor: BRAND.PRIMARY,
borderRadius: RADIUS['2XL'],
paddingVertical: 18,
paddingHorizontal: 32,
alignItems: 'center',
justifyContent: 'center',
shadowColor: BRAND.PRIMARY,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.4,
shadowRadius: 12,
elevation: 8,
},
buttonDisabled: {
backgroundColor: BRAND.PRIMARY,
opacity: 0.5,
shadowOpacity: 0,
elevation: 0,
},
text: {
...TYPOGRAPHY.buttonMedium,
color: TEXT.PRIMARY,
},
textDisabled: {
color: TEXT.PRIMARY,
opacity: 0.7,
},
})

View File

@@ -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 (
<View style={styles.container}>
{Array.from({ length: totalSteps }).map((_, index) => {
const isActive = index === currentStep
const isCompleted = index < currentStep
return (
<Animated.View
key={index}
style={[
styles.dot,
isActive && styles.dotActive,
isCompleted && styles.dotCompleted,
{ transform: [{ scale: scaleAnims[index] }] },
]}
/>
)
})}
</View>
)
}
const DOT_SIZE = 10
const DOT_SIZE_ACTIVE = 12
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
gap: 12,
paddingVertical: 16,
},
dot: {
width: DOT_SIZE,
height: DOT_SIZE,
borderRadius: DOT_SIZE / 2,
backgroundColor: SURFACE.OVERLAY_LIGHT,
},
dotActive: {
width: DOT_SIZE_ACTIVE,
height: DOT_SIZE_ACTIVE,
borderRadius: DOT_SIZE_ACTIVE / 2,
backgroundColor: BRAND.PRIMARY,
shadowColor: BRAND.PRIMARY,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.8,
shadowRadius: 8,
elevation: 4,
},
dotCompleted: {
backgroundColor: BRAND.PRIMARY,
},
})

View File

@@ -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'

View File

@@ -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' },
]

View File

@@ -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' },
]

View File

@@ -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'

View File

@@ -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' },
]

View File

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

View File

@@ -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'

View File

@@ -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 (
<OnboardingScreen currentStep={0}>
<Animated.View
style={[
styles.container,
{
opacity: opacityAnim,
transform: [{ translateY: translateYAnim }],
},
]}
>
{/* Clock Icon Animation */}
<View style={styles.iconContainer}>
<Animated.View
style={[
styles.clockCircle,
{ transform: [{ scale: clockScale }] },
]}
>
<Animated.View style={{ transform: [{ rotate: rotationInterpolate }] }}>
<Ionicons name="time-outline" size={80} color={BRAND.PRIMARY} />
</Animated.View>
</Animated.View>
<View style={styles.crossMark}>
<Ionicons name="close" size={32} color={BRAND.DANGER} />
</View>
</View>
{/* Title and Subtitle */}
<View style={styles.textContainer}>
<Text style={styles.title}>Tu n'as pas 1 heure pour t'entrainer ?</Text>
<Text style={styles.subtitle}>Ni 30 minutes ? Ni meme 10 ?</Text>
</View>
{/* Spacer */}
<View style={styles.spacer} />
{/* Continue Button */}
<PrimaryButton title="Continuer" onPress={onNext} />
</Animated.View>
</OnboardingScreen>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: SPACING[4],
},
iconContainer: {
position: 'relative',
marginBottom: SPACING[10],
},
clockCircle: {
width: 140,
height: 140,
borderRadius: 70,
backgroundColor: 'rgba(249, 115, 22, 0.1)',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2,
borderColor: 'rgba(249, 115, 22, 0.3)',
},
crossMark: {
position: 'absolute',
bottom: -5,
right: -5,
backgroundColor: 'rgba(239, 68, 68, 0.2)',
borderRadius: 20,
padding: SPACING[1],
},
textContainer: {
alignItems: 'center',
marginBottom: SPACING[8],
},
title: {
...TYPOGRAPHY.displayLarge,
color: TEXT.PRIMARY,
textAlign: 'center',
marginBottom: SPACING[4],
},
subtitle: {
...TYPOGRAPHY.heading,
color: TEXT.MUTED,
textAlign: 'center',
},
spacer: {
flex: 1,
},
})

View File

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

View File

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

View File

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

View File

@@ -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<Level | null>(data.level)
const [selectedGoal, setSelectedGoal] = useState<Goal | null>(data.goal)
const [selectedFrequency, setSelectedFrequency] = useState<Frequency | null>(data.frequency)
const handleLevelSelect = (level: Level) => {
setSelectedLevel(level)
setLevel(level)
}
const handleGoalSelect = (goal: Goal) => {
setSelectedGoal(goal)
setGoal(goal)
}
const handleFrequencySelect = (frequency: Frequency) => {
setSelectedFrequency(frequency)
setFrequency(frequency)
}
const handleContinue = () => {
onNext()
}
const isFormComplete = selectedLevel && selectedGoal && selectedFrequency
return (
<OnboardingScreen currentStep={4}>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<View style={styles.header}>
<Text style={styles.title}>Personnalise ton experience</Text>
<Text style={styles.subtitle}>
Dis-nous en plus sur toi pour un programme sur mesure
</Text>
</View>
{/* Level Section */}
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Ionicons name="fitness-outline" size={20} color={BRAND.PRIMARY} />
<Text style={styles.sectionTitle}>Niveau</Text>
</View>
<View style={styles.optionsContainer}>
{LEVELS.map((level) => (
<ChoiceButton
key={level.id}
label={level.label}
description={level.description}
icon={level.icon as keyof typeof Ionicons.glyphMap}
selected={selectedLevel === level.id}
onPress={() => handleLevelSelect(level.id)}
/>
))}
</View>
</View>
{/* Goal Section */}
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Ionicons name="flag-outline" size={20} color={BRAND.PRIMARY} />
<Text style={styles.sectionTitle}>Objectif</Text>
</View>
<View style={styles.optionsContainer}>
{GOALS.map((goal) => (
<ChoiceButton
key={goal.id}
label={goal.label}
description={goal.description}
icon={goal.icon as keyof typeof Ionicons.glyphMap}
selected={selectedGoal === goal.id}
onPress={() => handleGoalSelect(goal.id)}
/>
))}
</View>
</View>
{/* Frequency Section */}
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Ionicons name="calendar-outline" size={20} color={BRAND.PRIMARY} />
<Text style={styles.sectionTitle}>Frequence</Text>
</View>
<View style={styles.optionsContainer}>
{FREQUENCIES.map((freq) => (
<ChoiceButton
key={freq.id}
label={freq.label}
description={freq.description}
icon="time-outline"
selected={selectedFrequency === freq.id}
onPress={() => handleFrequencySelect(freq.id)}
/>
))}
</View>
</View>
</ScrollView>
<View style={styles.footer}>
<PrimaryButton
title="Continuer"
onPress={handleContinue}
disabled={!isFormComplete}
/>
</View>
</OnboardingScreen>
)
}
const styles = StyleSheet.create({
scrollView: {
flex: 1,
},
scrollContent: {
paddingBottom: SPACING[6],
},
header: {
marginBottom: SPACING[6],
},
title: {
...TYPOGRAPHY.heading,
color: TEXT.PRIMARY,
marginBottom: SPACING[2],
},
subtitle: {
...TYPOGRAPHY.caption,
color: TEXT.MUTED,
},
section: {
marginBottom: SPACING[6],
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING[2],
marginBottom: SPACING[3],
},
sectionTitle: {
...TYPOGRAPHY.label,
color: TEXT.SECONDARY,
textTransform: 'uppercase',
letterSpacing: 1,
},
optionsContainer: {
gap: SPACING[3],
},
footer: {
paddingTop: SPACING[4],
},
})

View File

@@ -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 (
<OnboardingScreen currentStep={5}>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<View style={styles.header}>
<Text style={styles.title}>Debloque ton potentiel</Text>
<Text style={styles.subtitle}>
7 jours gratuits, annule quand tu veux
</Text>
</View>
<View style={styles.plansContainer}>
{PLANS.map((plan) => (
<PaywallCard
key={plan.id}
title={plan.title}
price={plan.price}
period={plan.period}
features={plan.features}
badge={plan.badge}
selected={selectedPlan === plan.id}
onPress={() => handlePlanSelect(plan.id)}
/>
))}
</View>
<View style={styles.guarantee}>
<Text style={styles.guaranteeText}>
Garantie satisfait ou rembourse sous 30 jours
</Text>
</View>
</ScrollView>
<View style={styles.footer}>
<Pressable
style={[styles.continueButton, isLoading && styles.buttonLoading]}
onPress={handlePurchase}
disabled={isLoading}
>
<Text style={styles.continueButtonText}>
{isLoading ? 'Traitement...' : selectedPlan === 'trial' ? 'Essayer gratuitement' : "S'abonner"}
</Text>
</Pressable>
<Pressable style={styles.skipButton} onPress={handleSkip}>
<Text style={styles.skipButtonText}>
Continuer sans abonnement
</Text>
</Pressable>
</View>
</OnboardingScreen>
)
}
// Mock RevenueCat purchase function for dev mode
async function mockPurchase(planId: string): Promise<void> {
// Simulate network delay
await new Promise((resolve) => setTimeout(resolve, 1500))
// Simulate successful purchase
console.log(`[MOCK] Purchase successful for plan: ${planId}`)
// In production with RevenueCat:
// const { customerInfo } = await Purchases.purchasePackage(package)
// if (customerInfo.entitlements.active['pro']) { ... }
}
const styles = StyleSheet.create({
scrollView: {
flex: 1,
},
scrollContent: {
paddingBottom: SPACING[6],
},
header: {
marginBottom: SPACING[6],
alignItems: 'center',
},
title: {
...TYPOGRAPHY.heading,
color: TEXT.PRIMARY,
marginBottom: SPACING[2],
textAlign: 'center',
},
subtitle: {
...TYPOGRAPHY.caption,
color: TEXT.MUTED,
textAlign: 'center',
},
plansContainer: {
gap: SPACING[4],
},
guarantee: {
marginTop: SPACING[6],
alignItems: 'center',
},
guaranteeText: {
...TYPOGRAPHY.caption,
color: TEXT.HINT,
textAlign: 'center',
},
footer: {
paddingTop: SPACING[4],
},
continueButton: {
backgroundColor: BRAND.PRIMARY,
borderRadius: 28,
paddingVertical: 18,
paddingHorizontal: 32,
alignItems: 'center',
justifyContent: 'center',
shadowColor: BRAND.PRIMARY,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.4,
shadowRadius: 12,
elevation: 8,
},
buttonLoading: {
opacity: 0.7,
},
continueButtonText: {
...TYPOGRAPHY.buttonMedium,
color: TEXT.PRIMARY,
},
skipButton: {
marginTop: SPACING[4],
paddingVertical: SPACING[2],
alignItems: 'center',
},
skipButtonText: {
...TYPOGRAPHY.caption,
color: TEXT.HINT,
textDecorationLine: 'underline',
},
})

View File

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

View File

@@ -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
}

View File

@@ -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}
>
<Ionicons name="stop" size={28} color="#FFFFFF" />
<Ionicons name="stop" size={28} color={TEXT.PRIMARY} />
</Pressable>
<Pressable
@@ -38,7 +42,7 @@ export function TimerControls({
<Ionicons
name={isPaused ? 'play' : 'pause'}
size={36}
color="#FFFFFF"
color={TEXT.PRIMARY}
/>
</Pressable>
@@ -46,7 +50,7 @@ export function TimerControls({
style={({ pressed }) => [styles.button, pressed && styles.pressed]}
onPress={onSkip}
>
<Ionicons name="play-skip-forward" size={28} color="#FFFFFF" />
<Ionicons name="play-skip-forward" size={28} color={TEXT.PRIMARY} />
</Pressable>
</View>
)
@@ -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 }],
},
})

View File

@@ -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<string, string> = {
const PHASE_LABELS: Record<TimerPhase, string> = {
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 (
<IdleView
config={config}
onStart={onStart}
topInset={insets.top}
bottomInset={insets.bottom}
/>
)
return <IdleView config={config} onStart={onStart} top={top} bottom={bottom} />
}
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 (
<View
style={[
styles.screen,
{ backgroundColor: PHASE_COLORS.IDLE, paddingTop: topInset, paddingBottom: bottomInset },
]}
<LinearGradient
colors={APP_GRADIENTS.HOME}
style={[styles.screen, { paddingTop: top, paddingBottom: bottom }]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<StatusBar style="light" />
<View style={styles.idleContent}>
<Text style={styles.idleTitle}>TABATAGO</Text>
<Text style={styles.idleTitle}>TABATA</Text>
<Text style={styles.idleSubtitle}>GO</Text>
<View style={styles.configSummary}>
<Text style={styles.configLine}>
{config.workDuration}s travail / {config.restDuration}s repos
</Text>
<Text style={styles.configLine}>
{config.rounds} rounds
</Text>
<View style={styles.configRow}>
<ConfigBadge label={`${config.workDuration}s`} sub="travail" />
<ConfigBadge label={`${config.restDuration}s`} sub="repos" />
<ConfigBadge label={`${config.rounds}`} sub="rounds" />
</View>
</View>
<Pressable
style={({ pressed }) => [
styles.startButton,
pressed && styles.startButtonPressed,
]}
onPress={onStart}
>
<Text style={styles.startButtonText}>START</Text>
</Pressable>
<View style={styles.startButtonContainer}>
<Animated.View
style={[
styles.startButtonGlow,
{ opacity: glowOpacity, transform: [{ scale: glowScale }] },
]}
/>
<Pressable
style={({ pressed }) => [
styles.startButton,
pressed && styles.buttonPressed,
]}
onPress={onStart}
>
<Text style={styles.startButtonText}>START</Text>
</Pressable>
</View>
</View>
</LinearGradient>
)
}
function ConfigBadge({ label, sub }: { label: string; sub: string }) {
return (
<View style={styles.configBadge}>
<Text style={styles.configBadgeLabel}>{label}</Text>
<Text style={styles.configBadgeSub}>{sub}</Text>
</View>
)
}
// --- COMPLETE 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 (
<View
style={[
styles.screen,
{ backgroundColor: PHASE_COLORS.COMPLETE, paddingTop: topInset, paddingBottom: bottomInset },
]}
<LinearGradient
colors={PHASE_GRADIENTS.COMPLETE}
style={[styles.screen, { paddingTop: top, paddingBottom: bottom }]}
>
<StatusBar style="light" />
<View style={styles.idleContent}>
<Animated.View
style={[
styles.completeContent,
{ opacity: opacityAnim, transform: [{ scale: scaleAnim }] },
]}
>
<Text style={styles.completeEmoji}>&#x1F525;</Text>
<Text style={styles.completeTitle}>TERMINÉ !</Text>
<Text style={styles.completeTime}>{formatTime(totalElapsedSeconds)}</Text>
<Text style={styles.completeRounds}>
@@ -166,18 +243,20 @@ function CompleteView({
<Pressable
style={({ pressed }) => [
styles.doneButton,
pressed && styles.startButtonPressed,
pressed && styles.buttonPressed,
]}
onPress={onStop}
>
<Text style={styles.doneButtonText}>Terminer</Text>
</Pressable>
</View>
</View>
</Animated.View>
</LinearGradient>
)
}
// --- 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<string>(PHASE_COLORS.IDLE)
const gradient = PHASE_GRADIENTS[state.phase] ?? PHASE_GRADIENTS.IDLE
// --- Gradient crossfade ---
const [prevGradient, setPrevGradient] = useState<readonly string[]>(PHASE_GRADIENTS.IDLE)
const fadeAnim = useRef(new Animated.Value(1)).current
useEffect(() => {
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 (
<Animated.View
style={[
styles.screen,
{ backgroundColor, paddingTop: topInset, paddingBottom: bottomInset },
]}
>
<View style={styles.screen}>
<StatusBar hidden />
{/* Zone haute — exercice + round */}
<View style={styles.topZone}>
<Text style={styles.exerciseName} numberOfLines={1}>
{topLabel}
</Text>
{state.currentRound > 0 && (
<Text style={styles.roundIndicator}>
Round {state.currentRound}/{state.totalRounds}
</Text>
)}
</View>
{/* Zone centrale — countdown */}
<View style={styles.centerZone}>
<Text style={styles.phaseLabel}>
{PHASE_LABELS[state.phase] ?? ''}
</Text>
<Animated.Text
style={[
styles.countdown,
{ transform: [{ scale: pulseAnim }] },
isLastSeconds && styles.countdownFlash,
]}
>
{state.secondsLeft}
</Animated.Text>
{state.isPaused && (
<Text style={styles.pausedLabel}>EN PAUSE</Text>
)}
</View>
{/* Zone basse haute — progress bar */}
<View style={styles.progressZone}>
<View style={styles.progressTrack}>
<View
style={[
styles.progressFill,
{ width: `${totalPhaseDuration * 100}%` },
]}
/>
</View>
<View style={styles.roundDots}>
{Array.from({ length: state.totalRounds }, (_, i) => (
<View
key={i}
style={[
styles.dot,
i < state.currentRound && styles.dotFilled,
i === state.currentRound - 1 &&
state.phase === 'WORK' &&
styles.dotActive,
]}
/>
))}
</View>
</View>
{/* Zone basse — controls */}
<View style={styles.controlsZone}>
<TimerControls
isRunning={state.isRunning}
isPaused={state.isPaused}
onPause={onPause}
onResume={onResume}
onStop={onStop}
onSkip={onSkip}
{/* Gradient layers */}
<LinearGradient
colors={prevGradient as [string, string, ...string[]]}
style={StyleSheet.absoluteFill}
/>
<Animated.View style={[StyleSheet.absoluteFill, { opacity: fadeAnim }]}>
<LinearGradient
colors={gradient as unknown as [string, string, ...string[]]}
style={StyleSheet.absoluteFill}
/>
</Animated.View>
{/* Content */}
<View style={[styles.activeContent, { paddingTop: top }]}>
{/* Top bar */}
<View style={styles.topZone}>
<Text style={styles.exerciseName} numberOfLines={1}>
{topLabel}
</Text>
{state.currentRound > 0 && (
<View style={styles.roundBadge}>
<Text style={styles.roundBadgeText}>
{state.currentRound}/{state.totalRounds}
</Text>
</View>
)}
</View>
{/* Center — countdown */}
<View style={styles.centerZone}>
<Text style={styles.phaseLabel}>
{PHASE_LABELS[state.phase]}
</Text>
<Animated.Text
style={[
styles.countdown,
{ transform: [{ scale: pulseAnim }] },
isLastSeconds && styles.countdownFlash,
]}
>
{state.secondsLeft}
</Animated.Text>
{state.isPaused && (
<View style={styles.pausedContainer}>
<Text style={styles.pausedLabel}>EN PAUSE</Text>
</View>
)}
</View>
{/* Bottom toolbar — native blur like iOS UIToolbar */}
<BlurView
intensity={GLASS.BLUR_HEAVY}
tint="dark"
style={[styles.bottomBar, { paddingBottom: bottom + 8 }]}
>
<View style={styles.roundDots}>
{Array.from({ length: state.totalRounds }, (_, i) => (
<View
key={i}
style={[
styles.dot,
i < state.currentRound && styles.dotFilled,
i === state.currentRound - 1 &&
state.phase === 'WORK' &&
styles.dotActive,
]}
/>
))}
</View>
<TimerControls
isRunning={state.isRunning}
isPaused={state.isPaused}
onPause={onPause}
onResume={onResume}
onStop={onStop}
onSkip={onSkip}
/>
</BlurView>
</View>
</Animated.View>
</View>
)
}
// ──────────────────────────────────────────────────────
// 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,
},
})

View File

@@ -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()
}
}

View File

@@ -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 (
<View style={[styles.container, style]} {...rest}>
<BlurView intensity={intensity} tint="dark" style={StyleSheet.absoluteFill} />
{children}
</View>
)
}
const styles = StyleSheet.create({
container: {
overflow: 'hidden',
backgroundColor: GLASS.FILL,
borderWidth: 0.5,
borderColor: GLASS.BORDER,
borderTopColor: GLASS.BORDER_TOP,
},
})

View File

@@ -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 (
<Text style={[TYPOGRAPHY[variant], { color }, style]} {...rest}>
{children}
</Text>
)
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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'

View File

@@ -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

View File

@@ -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

View File

@@ -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