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>
106 lines
2.9 KiB
TypeScript
106 lines
2.9 KiB
TypeScript
import { useEffect } from 'react'
|
|
import { useRouter } from 'expo-router'
|
|
import * as Haptics from 'expo-haptics'
|
|
import { useTimerEngine } from '@/src/features/timer'
|
|
import { useAudioEngine } from '@/src/features/audio'
|
|
import { TimerDisplay } from '@/src/features/timer/components/TimerDisplay'
|
|
import type { TimerEvent } from '@/src/features/timer/types'
|
|
|
|
export default function TimerScreen() {
|
|
const router = useRouter()
|
|
const timer = useTimerEngine()
|
|
const audio = useAudioEngine()
|
|
|
|
// Preload audio on mount
|
|
useEffect(() => {
|
|
audio.preloadAll()
|
|
return () => {
|
|
audio.unloadAll()
|
|
}
|
|
}, [])
|
|
|
|
// Subscribe to timer events → trigger audio + haptics
|
|
useEffect(() => {
|
|
const unsubscribe = timer.addEventListener(async (event: TimerEvent) => {
|
|
switch (event.type) {
|
|
case 'PHASE_CHANGED':
|
|
await handlePhaseChange(event.to)
|
|
break
|
|
|
|
case 'COUNTDOWN_TICK':
|
|
await audio.playPhaseSound(
|
|
event.secondsLeft === 1 ? 'count_1' : event.secondsLeft === 2 ? 'count_2' : 'count_3'
|
|
)
|
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)
|
|
break
|
|
|
|
case 'ROUND_COMPLETED':
|
|
await audio.playPhaseSound('bell')
|
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
|
|
break
|
|
|
|
case 'SESSION_COMPLETE':
|
|
await audio.playPhaseSound('fanfare')
|
|
await audio.stopMusic(1000)
|
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
|
|
break
|
|
}
|
|
})
|
|
|
|
return unsubscribe
|
|
}, [audio.isLoaded])
|
|
|
|
async function handlePhaseChange(to: string) {
|
|
switch (to) {
|
|
case 'GET_READY':
|
|
await audio.startMusic('LOW')
|
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium)
|
|
break
|
|
case 'WORK':
|
|
await audio.playPhaseSound('beep_long')
|
|
await audio.switchIntensity('HIGH')
|
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)
|
|
break
|
|
case 'REST':
|
|
await audio.playPhaseSound('beep_double')
|
|
await audio.switchIntensity('LOW')
|
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)
|
|
break
|
|
}
|
|
}
|
|
|
|
function handleStart() {
|
|
timer.start()
|
|
}
|
|
|
|
function handleStop() {
|
|
timer.stop()
|
|
audio.stopMusic(300)
|
|
router.back()
|
|
}
|
|
|
|
return (
|
|
<TimerDisplay
|
|
state={{
|
|
phase: timer.phase,
|
|
secondsLeft: timer.secondsLeft,
|
|
currentRound: timer.currentRound,
|
|
totalRounds: timer.totalRounds,
|
|
currentCycle: timer.currentCycle,
|
|
totalCycles: timer.totalCycles,
|
|
isRunning: timer.isRunning,
|
|
isPaused: timer.isPaused,
|
|
totalElapsedSeconds: timer.totalElapsedSeconds,
|
|
}}
|
|
config={timer.config}
|
|
exerciseName="Burpees"
|
|
nextExerciseName="Squats"
|
|
onStart={handleStart}
|
|
onPause={timer.pause}
|
|
onResume={timer.resume}
|
|
onStop={handleStop}
|
|
onSkip={timer.skip}
|
|
/>
|
|
)
|
|
}
|