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:
13
src/features/audio/data/sounds.ts
Normal file
13
src/features/audio/data/sounds.ts
Normal 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'),
|
||||
}
|
||||
27
src/features/audio/data/tracks.ts
Normal file
27
src/features/audio/data/tracks.ts
Normal 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
|
||||
}
|
||||
173
src/features/audio/hooks/useAudioEngine.ts
Normal file
173
src/features/audio/hooks/useAudioEngine.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
8
src/features/audio/index.ts
Normal file
8
src/features/audio/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { useAudioEngine } from './hooks/useAudioEngine'
|
||||
export type {
|
||||
MusicAmbiance,
|
||||
MusicIntensity,
|
||||
PhaseSound,
|
||||
AudioSettings,
|
||||
AudioEngine,
|
||||
} from './types'
|
||||
31
src/features/audio/types.ts
Normal file
31
src/features/audio/types.ts
Normal 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>
|
||||
}
|
||||
100
src/features/onboarding/components/ChoiceButton.tsx
Normal file
100
src/features/onboarding/components/ChoiceButton.tsx
Normal 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],
|
||||
},
|
||||
})
|
||||
178
src/features/onboarding/components/MiniTimerDemo.tsx
Normal file
178
src/features/onboarding/components/MiniTimerDemo.tsx
Normal 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,
|
||||
},
|
||||
})
|
||||
46
src/features/onboarding/components/OnboardingScreen.tsx
Normal file
46
src/features/onboarding/components/OnboardingScreen.tsx
Normal 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],
|
||||
},
|
||||
})
|
||||
158
src/features/onboarding/components/PaywallCard.tsx
Normal file
158
src/features/onboarding/components/PaywallCard.tsx
Normal 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,
|
||||
},
|
||||
})
|
||||
85
src/features/onboarding/components/PrimaryButton.tsx
Normal file
85
src/features/onboarding/components/PrimaryButton.tsx
Normal 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,
|
||||
},
|
||||
})
|
||||
89
src/features/onboarding/components/ProgressBar.tsx
Normal file
89
src/features/onboarding/components/ProgressBar.tsx
Normal 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,
|
||||
},
|
||||
})
|
||||
6
src/features/onboarding/components/index.ts
Normal file
6
src/features/onboarding/components/index.ts
Normal 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'
|
||||
15
src/features/onboarding/data/barriers.ts
Normal file
15
src/features/onboarding/data/barriers.ts
Normal 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' },
|
||||
]
|
||||
15
src/features/onboarding/data/goals.ts
Normal file
15
src/features/onboarding/data/goals.ts
Normal 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' },
|
||||
]
|
||||
5
src/features/onboarding/data/index.ts
Normal file
5
src/features/onboarding/data/index.ts
Normal 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'
|
||||
14
src/features/onboarding/data/levels.ts
Normal file
14
src/features/onboarding/data/levels.ts
Normal 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' },
|
||||
]
|
||||
137
src/features/onboarding/hooks/useOnboarding.ts
Normal file
137
src/features/onboarding/hooks/useOnboarding.ts
Normal 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)
|
||||
26
src/features/onboarding/index.ts
Normal file
26
src/features/onboarding/index.ts
Normal 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'
|
||||
165
src/features/onboarding/screens/Screen1Problem.tsx
Normal file
165
src/features/onboarding/screens/Screen1Problem.tsx
Normal 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,
|
||||
},
|
||||
})
|
||||
104
src/features/onboarding/screens/Screen2Empathy.tsx
Normal file
104
src/features/onboarding/screens/Screen2Empathy.tsx
Normal 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%',
|
||||
},
|
||||
})
|
||||
251
src/features/onboarding/screens/Screen3Solution.tsx
Normal file
251
src/features/onboarding/screens/Screen3Solution.tsx
Normal 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,
|
||||
},
|
||||
})
|
||||
149
src/features/onboarding/screens/Screen4WowMoment.tsx
Normal file
149
src/features/onboarding/screens/Screen4WowMoment.tsx
Normal 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,
|
||||
},
|
||||
})
|
||||
186
src/features/onboarding/screens/Screen5Personalization.tsx
Normal file
186
src/features/onboarding/screens/Screen5Personalization.tsx
Normal 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],
|
||||
},
|
||||
})
|
||||
234
src/features/onboarding/screens/Screen6Paywall.tsx
Normal file
234
src/features/onboarding/screens/Screen6Paywall.tsx
Normal 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',
|
||||
},
|
||||
})
|
||||
2
src/features/onboarding/screens/index.ts
Normal file
2
src/features/onboarding/screens/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Screen5Personalization } from './Screen5Personalization'
|
||||
export { Screen6Paywall } from './Screen6Paywall'
|
||||
20
src/features/onboarding/types.ts
Normal file
20
src/features/onboarding/types.ts
Normal 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
|
||||
}
|
||||
@@ -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 }],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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}>🔥</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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
32
src/shared/components/GlassView.tsx
Normal file
32
src/shared/components/GlassView.tsx
Normal 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,
|
||||
},
|
||||
})
|
||||
25
src/shared/components/Typography.tsx
Normal file
25
src/shared/components/Typography.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
51
src/shared/constants/animations.ts
Normal file
51
src/shared/constants/animations.ts
Normal 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
|
||||
11
src/shared/constants/borderRadius.ts
Normal file
11
src/shared/constants/borderRadius.ts
Normal 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
|
||||
@@ -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
|
||||
|
||||
16
src/shared/constants/index.ts
Normal file
16
src/shared/constants/index.ts
Normal 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'
|
||||
44
src/shared/constants/shadows.ts
Normal file
44
src/shared/constants/shadows.ts
Normal 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
|
||||
25
src/shared/constants/spacing.ts
Normal file
25
src/shared/constants/spacing.ts
Normal 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
|
||||
73
src/shared/constants/typography.ts
Normal file
73
src/shared/constants/typography.ts
Normal 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
|
||||
Reference in New Issue
Block a user