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:
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
49
app/onboarding/index.tsx
Normal 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} />
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user