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