feat: timer engine + full-screen timer UI
Implement the core timer feature following the src/features/ architecture: - useTimerEngine hook: drift-free Date.now() delta countdown (100ms tick), explicit state machine (IDLE → GET_READY → WORK → REST → COMPLETE), event emitter for external consumers (PHASE_CHANGED, ROUND_COMPLETED, COUNTDOWN_TICK, SESSION_COMPLETE), auto-pause on AppState interruption (phone calls, background), expo-keep-awake during session - TimerDisplay component: full-screen animated UI with 600ms color transitions between phases, pulse animation on countdown, flash red on last 3 seconds, round progress dots, IDLE/active/COMPLETE views - TimerControls component: stop/pause-resume/skip buttons with Ionicons - Timer route (app/timer.tsx): fullScreenModal wiring hook → display - Home screen: dark theme with START button navigating to /timer - Project docs: CLAUDE.md (constitution), PRD v1.1, skill files - Shared constants: PHASE_COLORS, TIMER_DEFAULTS, formatTime utility - Types: TimerPhase, TimerState, TimerConfig, TimerActions, TimerEvent Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
79
src/features/timer/components/TimerControls.tsx
Normal file
79
src/features/timer/components/TimerControls.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Pressable, StyleSheet, View } from 'react-native'
|
||||
import Ionicons from '@expo/vector-icons/Ionicons'
|
||||
|
||||
interface TimerControlsProps {
|
||||
isRunning: boolean
|
||||
isPaused: boolean
|
||||
onPause: () => void
|
||||
onResume: () => void
|
||||
onStop: () => void
|
||||
onSkip: () => void
|
||||
}
|
||||
|
||||
export function TimerControls({
|
||||
isRunning,
|
||||
isPaused,
|
||||
onPause,
|
||||
onResume,
|
||||
onStop,
|
||||
onSkip,
|
||||
}: TimerControlsProps) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.button, pressed && styles.pressed]}
|
||||
onPress={onStop}
|
||||
>
|
||||
<Ionicons name="stop" size={28} color="#FFFFFF" />
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.button,
|
||||
styles.mainButton,
|
||||
pressed && styles.pressed,
|
||||
]}
|
||||
onPress={isPaused ? onResume : onPause}
|
||||
>
|
||||
<Ionicons
|
||||
name={isPaused ? 'play' : 'pause'}
|
||||
size={36}
|
||||
color="#FFFFFF"
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.button, pressed && styles.pressed]}
|
||||
onPress={onSkip}
|
||||
>
|
||||
<Ionicons name="play-skip-forward" size={28} color="#FFFFFF" />
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 32,
|
||||
},
|
||||
button: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.15)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
mainButton: {
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 36,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.25)',
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
})
|
||||
511
src/features/timer/components/TimerDisplay.tsx
Normal file
511
src/features/timer/components/TimerDisplay.tsx
Normal file
@@ -0,0 +1,511 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Animated,
|
||||
Easing,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import { StatusBar } from 'expo-status-bar'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { PHASE_COLORS } from '@/src/shared/constants/colors'
|
||||
import { formatTime } from '@/src/shared/utils/formatTime'
|
||||
import type { TimerConfig, TimerState } from '../types'
|
||||
import { TimerControls } from './TimerControls'
|
||||
|
||||
const PHASE_LABELS: Record<string, string> = {
|
||||
GET_READY: 'PRÉPARE-TOI',
|
||||
WORK: 'TRAVAIL',
|
||||
REST: 'REPOS',
|
||||
COMPLETE: 'TERMINÉ !',
|
||||
}
|
||||
|
||||
interface TimerDisplayProps {
|
||||
state: TimerState
|
||||
config: TimerConfig
|
||||
exerciseName: string
|
||||
nextExerciseName: string
|
||||
onStart: () => void
|
||||
onPause: () => void
|
||||
onResume: () => void
|
||||
onStop: () => void
|
||||
onSkip: () => void
|
||||
}
|
||||
|
||||
export function TimerDisplay({
|
||||
state,
|
||||
config,
|
||||
exerciseName,
|
||||
nextExerciseName,
|
||||
onStart,
|
||||
onPause,
|
||||
onResume,
|
||||
onStop,
|
||||
onSkip,
|
||||
}: TimerDisplayProps) {
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
if (state.phase === 'IDLE') {
|
||||
return (
|
||||
<IdleView
|
||||
config={config}
|
||||
onStart={onStart}
|
||||
topInset={insets.top}
|
||||
bottomInset={insets.bottom}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (state.phase === 'COMPLETE') {
|
||||
return (
|
||||
<CompleteView
|
||||
totalElapsedSeconds={state.totalElapsedSeconds}
|
||||
totalRounds={state.totalRounds}
|
||||
onStop={onStop}
|
||||
topInset={insets.top}
|
||||
bottomInset={insets.bottom}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ActiveView
|
||||
state={state}
|
||||
exerciseName={exerciseName}
|
||||
nextExerciseName={nextExerciseName}
|
||||
onPause={onPause}
|
||||
onResume={onResume}
|
||||
onStop={onStop}
|
||||
onSkip={onSkip}
|
||||
topInset={insets.top}
|
||||
bottomInset={insets.bottom}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// --- IDLE view ---
|
||||
|
||||
function IdleView({
|
||||
config,
|
||||
onStart,
|
||||
topInset,
|
||||
bottomInset,
|
||||
}: {
|
||||
config: TimerConfig
|
||||
onStart: () => void
|
||||
topInset: number
|
||||
bottomInset: number
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.screen,
|
||||
{ backgroundColor: PHASE_COLORS.IDLE, paddingTop: topInset, paddingBottom: bottomInset },
|
||||
]}
|
||||
>
|
||||
<StatusBar style="light" />
|
||||
|
||||
<View style={styles.idleContent}>
|
||||
<Text style={styles.idleTitle}>TABATAGO</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>
|
||||
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.startButton,
|
||||
pressed && styles.startButtonPressed,
|
||||
]}
|
||||
onPress={onStart}
|
||||
>
|
||||
<Text style={styles.startButtonText}>START</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// --- COMPLETE view ---
|
||||
|
||||
function CompleteView({
|
||||
totalElapsedSeconds,
|
||||
totalRounds,
|
||||
onStop,
|
||||
topInset,
|
||||
bottomInset,
|
||||
}: {
|
||||
totalElapsedSeconds: number
|
||||
totalRounds: number
|
||||
onStop: () => void
|
||||
topInset: number
|
||||
bottomInset: number
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.screen,
|
||||
{ backgroundColor: PHASE_COLORS.COMPLETE, paddingTop: topInset, paddingBottom: bottomInset },
|
||||
]}
|
||||
>
|
||||
<StatusBar style="light" />
|
||||
|
||||
<View style={styles.idleContent}>
|
||||
<Text style={styles.completeTitle}>TERMINÉ !</Text>
|
||||
<Text style={styles.completeTime}>{formatTime(totalElapsedSeconds)}</Text>
|
||||
<Text style={styles.completeRounds}>
|
||||
{totalRounds} rounds complétés
|
||||
</Text>
|
||||
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.doneButton,
|
||||
pressed && styles.startButtonPressed,
|
||||
]}
|
||||
onPress={onStop}
|
||||
>
|
||||
<Text style={styles.doneButtonText}>Terminer</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// --- ACTIVE view (GET_READY, WORK, REST) ---
|
||||
|
||||
function ActiveView({
|
||||
state,
|
||||
exerciseName,
|
||||
nextExerciseName,
|
||||
onPause,
|
||||
onResume,
|
||||
onStop,
|
||||
onSkip,
|
||||
topInset,
|
||||
bottomInset,
|
||||
}: {
|
||||
state: TimerState
|
||||
exerciseName: string
|
||||
nextExerciseName: string
|
||||
onPause: () => void
|
||||
onResume: () => void
|
||||
onStop: () => void
|
||||
onSkip: () => void
|
||||
topInset: number
|
||||
bottomInset: number
|
||||
}) {
|
||||
// --- Background color transition ---
|
||||
const [prevColor, setPrevColor] = useState<string>(PHASE_COLORS.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)
|
||||
})
|
||||
}, [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,
|
||||
}),
|
||||
]).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
|
||||
: state.phase === 'REST'
|
||||
? `Prochain : ${nextExerciseName}`
|
||||
: exerciseName
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.screen,
|
||||
{ backgroundColor, paddingTop: topInset, paddingBottom: bottomInset },
|
||||
]}
|
||||
>
|
||||
<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}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
// --- IDLE ---
|
||||
idleContent: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 24,
|
||||
},
|
||||
idleTitle: {
|
||||
fontSize: 40,
|
||||
fontWeight: '900',
|
||||
color: '#FFFFFF',
|
||||
letterSpacing: 6,
|
||||
},
|
||||
configSummary: {
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
marginTop: 16,
|
||||
},
|
||||
configLine: {
|
||||
fontSize: 18,
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
fontWeight: '500',
|
||||
},
|
||||
startButton: {
|
||||
width: 140,
|
||||
height: 140,
|
||||
borderRadius: 70,
|
||||
backgroundColor: '#F97316',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: 40,
|
||||
},
|
||||
startButtonPressed: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
startButtonText: {
|
||||
fontSize: 28,
|
||||
fontWeight: '900',
|
||||
color: '#FFFFFF',
|
||||
letterSpacing: 3,
|
||||
},
|
||||
|
||||
// --- COMPLETE ---
|
||||
completeTitle: {
|
||||
fontSize: 40,
|
||||
fontWeight: '900',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
completeTime: {
|
||||
fontSize: 64,
|
||||
fontWeight: '900',
|
||||
color: '#FFFFFF',
|
||||
fontVariant: ['tabular-nums'],
|
||||
marginTop: 8,
|
||||
},
|
||||
completeRounds: {
|
||||
fontSize: 18,
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
fontWeight: '500',
|
||||
},
|
||||
doneButton: {
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 16,
|
||||
borderRadius: 32,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.25)',
|
||||
marginTop: 40,
|
||||
},
|
||||
doneButtonText: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
|
||||
// --- ACTIVE ---
|
||||
topZone: {
|
||||
paddingHorizontal: 24,
|
||||
paddingTop: 16,
|
||||
paddingBottom: 12,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
exerciseName: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
flex: 1,
|
||||
},
|
||||
roundIndicator: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
marginLeft: 12,
|
||||
},
|
||||
|
||||
centerZone: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
phaseLabel: {
|
||||
fontSize: 22,
|
||||
fontWeight: '700',
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
letterSpacing: 3,
|
||||
marginBottom: 8,
|
||||
},
|
||||
countdown: {
|
||||
fontSize: 120,
|
||||
fontWeight: '900',
|
||||
color: '#FFFFFF',
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
countdownFlash: {
|
||||
color: '#EF4444',
|
||||
},
|
||||
pausedLabel: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
letterSpacing: 4,
|
||||
marginTop: 12,
|
||||
},
|
||||
|
||||
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,
|
||||
},
|
||||
roundDots: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
dot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.25)',
|
||||
},
|
||||
dotFilled: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
},
|
||||
dotActive: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
},
|
||||
|
||||
controlsZone: {
|
||||
paddingBottom: 24,
|
||||
paddingTop: 16,
|
||||
},
|
||||
})
|
||||
377
src/features/timer/hooks/useTimerEngine.ts
Normal file
377
src/features/timer/hooks/useTimerEngine.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { AppState, type AppStateStatus } from 'react-native'
|
||||
import {
|
||||
activateKeepAwakeAsync,
|
||||
deactivateKeepAwake,
|
||||
} from 'expo-keep-awake'
|
||||
import { TIMER_DEFAULTS, TICK_INTERVAL_MS } from '@/src/shared/constants/timer'
|
||||
import type {
|
||||
TimerConfig,
|
||||
TimerEngine,
|
||||
TimerEvent,
|
||||
TimerEventListener,
|
||||
TimerPhase,
|
||||
} from '../types'
|
||||
|
||||
const VALID_TRANSITIONS: Record<TimerPhase, TimerPhase[]> = {
|
||||
IDLE: ['GET_READY'],
|
||||
GET_READY: ['WORK', 'IDLE'],
|
||||
WORK: ['REST', 'IDLE'],
|
||||
REST: ['WORK', 'COMPLETE', 'IDLE'],
|
||||
COMPLETE: ['IDLE'],
|
||||
}
|
||||
|
||||
function canTransition(from: TimerPhase, to: TimerPhase): boolean {
|
||||
return VALID_TRANSITIONS[from].includes(to)
|
||||
}
|
||||
|
||||
function buildDefaultConfig(): TimerConfig {
|
||||
return {
|
||||
workDuration: TIMER_DEFAULTS.WORK_DURATION,
|
||||
restDuration: TIMER_DEFAULTS.REST_DURATION,
|
||||
rounds: TIMER_DEFAULTS.ROUNDS,
|
||||
getReadyDuration: TIMER_DEFAULTS.GET_READY_DURATION,
|
||||
cycles: TIMER_DEFAULTS.CYCLES,
|
||||
cyclePauseDuration: TIMER_DEFAULTS.CYCLE_PAUSE_DURATION,
|
||||
}
|
||||
}
|
||||
|
||||
function getDurationForPhase(phase: TimerPhase, config: TimerConfig): number {
|
||||
switch (phase) {
|
||||
case 'GET_READY':
|
||||
return config.getReadyDuration
|
||||
case 'WORK':
|
||||
return config.workDuration
|
||||
case 'REST':
|
||||
return config.restDuration
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export function useTimerEngine(): TimerEngine {
|
||||
// --- UI state (triggers re-renders) ---
|
||||
const [phase, setPhase] = useState<TimerPhase>('IDLE')
|
||||
const [secondsLeft, setSecondsLeft] = useState(0)
|
||||
const [currentRound, setCurrentRound] = useState(0)
|
||||
const [currentCycle, setCurrentCycle] = useState(0)
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [isPaused, setIsPaused] = useState(false)
|
||||
const [totalElapsedSeconds, setTotalElapsedSeconds] = useState(0)
|
||||
const [config, setConfig] = useState<TimerConfig>(buildDefaultConfig)
|
||||
|
||||
// --- Refs for time-critical values (no stale closures) ---
|
||||
const targetEndTimeRef = useRef(0)
|
||||
const remainingMsRef = useRef(0)
|
||||
const tickRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const phaseRef = useRef<TimerPhase>('IDLE')
|
||||
const currentRoundRef = useRef(0)
|
||||
const currentCycleRef = useRef(0)
|
||||
const configRef = useRef<TimerConfig>(buildDefaultConfig())
|
||||
const isRunningRef = useRef(false)
|
||||
const isPausedRef = useRef(false)
|
||||
const elapsedRef = useRef(0)
|
||||
const lastTickTimeRef = useRef(0)
|
||||
const lastEmittedSecondRef = useRef(-1)
|
||||
const listenersRef = useRef<Set<TimerEventListener>>(new Set())
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function emit(event: TimerEvent): void {
|
||||
listenersRef.current.forEach((listener) => {
|
||||
try {
|
||||
listener(event)
|
||||
} catch (e) {
|
||||
if (__DEV__) console.warn('[TimerEngine] Listener error:', e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function clearTick(): void {
|
||||
if (tickRef.current !== null) {
|
||||
clearTimeout(tickRef.current)
|
||||
tickRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
function transitionTo(nextPhase: TimerPhase): void {
|
||||
const prevPhase = phaseRef.current
|
||||
if (!canTransition(prevPhase, nextPhase)) {
|
||||
if (__DEV__) {
|
||||
console.warn(
|
||||
`[TimerEngine] Invalid transition: ${prevPhase} → ${nextPhase}`
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (__DEV__) {
|
||||
console.log(`[TimerEngine] ${prevPhase} → ${nextPhase}`)
|
||||
}
|
||||
|
||||
phaseRef.current = nextPhase
|
||||
setPhase(nextPhase)
|
||||
|
||||
emit({ type: 'PHASE_CHANGED', from: prevPhase, to: nextPhase })
|
||||
|
||||
const duration = getDurationForPhase(nextPhase, configRef.current)
|
||||
if (duration > 0) {
|
||||
startCountdown(duration)
|
||||
}
|
||||
}
|
||||
|
||||
function startCountdown(durationSeconds: number): void {
|
||||
clearTick()
|
||||
targetEndTimeRef.current = Date.now() + durationSeconds * 1000
|
||||
lastEmittedSecondRef.current = -1
|
||||
scheduleTick()
|
||||
}
|
||||
|
||||
function scheduleTick(): void {
|
||||
tickRef.current = setTimeout(tick, TICK_INTERVAL_MS)
|
||||
}
|
||||
|
||||
function tick(): void {
|
||||
const now = Date.now()
|
||||
|
||||
// Accumulate elapsed time
|
||||
if (lastTickTimeRef.current > 0) {
|
||||
const delta = (now - lastTickTimeRef.current) / 1000
|
||||
elapsedRef.current += delta
|
||||
setTotalElapsedSeconds(Math.floor(elapsedRef.current))
|
||||
}
|
||||
lastTickTimeRef.current = now
|
||||
|
||||
const remainingMs = Math.max(0, targetEndTimeRef.current - now)
|
||||
const seconds = Math.ceil(remainingMs / 1000)
|
||||
|
||||
setSecondsLeft(seconds)
|
||||
|
||||
// Emit COUNTDOWN_TICK for the last 3 seconds (once per second)
|
||||
if (seconds <= 3 && seconds > 0 && seconds !== lastEmittedSecondRef.current) {
|
||||
lastEmittedSecondRef.current = seconds
|
||||
emit({ type: 'COUNTDOWN_TICK', secondsLeft: seconds })
|
||||
}
|
||||
|
||||
if (remainingMs <= 0) {
|
||||
advancePhase()
|
||||
} else {
|
||||
scheduleTick()
|
||||
}
|
||||
}
|
||||
|
||||
function advancePhase(): void {
|
||||
clearTick()
|
||||
const current = phaseRef.current
|
||||
const cfg = configRef.current
|
||||
|
||||
switch (current) {
|
||||
case 'GET_READY':
|
||||
currentRoundRef.current = 1
|
||||
setCurrentRound(1)
|
||||
transitionTo('WORK')
|
||||
break
|
||||
|
||||
case 'WORK':
|
||||
transitionTo('REST')
|
||||
break
|
||||
|
||||
case 'REST': {
|
||||
const round = currentRoundRef.current
|
||||
emit({ type: 'ROUND_COMPLETED', round })
|
||||
|
||||
if (round < cfg.rounds) {
|
||||
// Next round
|
||||
currentRoundRef.current = round + 1
|
||||
setCurrentRound(round + 1)
|
||||
transitionTo('WORK')
|
||||
} else if (currentCycleRef.current < cfg.cycles) {
|
||||
// Next cycle (Premium, future) — for V1 cycles=1, so this is dead code
|
||||
currentCycleRef.current += 1
|
||||
setCurrentCycle(currentCycleRef.current)
|
||||
currentRoundRef.current = 1
|
||||
setCurrentRound(1)
|
||||
transitionTo('WORK')
|
||||
} else {
|
||||
// Session complete
|
||||
const totalSeconds = Math.floor(elapsedRef.current)
|
||||
setTotalElapsedSeconds(totalSeconds)
|
||||
emit({ type: 'SESSION_COMPLETE', totalSeconds })
|
||||
transitionTo('COMPLETE')
|
||||
setIsRunning(false)
|
||||
isRunningRef.current = false
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
const start = useCallback((overrides?: Partial<TimerConfig>) => {
|
||||
if (phaseRef.current !== 'IDLE') return
|
||||
|
||||
const cfg: TimerConfig = { ...buildDefaultConfig(), ...overrides }
|
||||
configRef.current = cfg
|
||||
setConfig(cfg)
|
||||
|
||||
// Reset all state
|
||||
currentRoundRef.current = 0
|
||||
currentCycleRef.current = 1
|
||||
elapsedRef.current = 0
|
||||
lastTickTimeRef.current = Date.now()
|
||||
lastEmittedSecondRef.current = -1
|
||||
|
||||
setCurrentRound(0)
|
||||
setCurrentCycle(1)
|
||||
setTotalElapsedSeconds(0)
|
||||
setIsRunning(true)
|
||||
setIsPaused(false)
|
||||
isRunningRef.current = true
|
||||
isPausedRef.current = false
|
||||
|
||||
transitionTo('GET_READY')
|
||||
}, [])
|
||||
|
||||
const pause = useCallback(() => {
|
||||
if (!isRunningRef.current || isPausedRef.current) return
|
||||
|
||||
clearTick()
|
||||
remainingMsRef.current = Math.max(0, targetEndTimeRef.current - Date.now())
|
||||
|
||||
isPausedRef.current = true
|
||||
setIsPaused(true)
|
||||
setIsRunning(false)
|
||||
isRunningRef.current = false
|
||||
|
||||
if (__DEV__) {
|
||||
console.log('[TimerEngine] Paused, remaining:', remainingMsRef.current, 'ms')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const resume = useCallback(() => {
|
||||
if (!isPausedRef.current) return
|
||||
|
||||
targetEndTimeRef.current = Date.now() + remainingMsRef.current
|
||||
lastTickTimeRef.current = Date.now()
|
||||
|
||||
isPausedRef.current = false
|
||||
isRunningRef.current = true
|
||||
setIsPaused(false)
|
||||
setIsRunning(true)
|
||||
|
||||
scheduleTick()
|
||||
|
||||
if (__DEV__) {
|
||||
console.log('[TimerEngine] Resumed')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const stop = useCallback(() => {
|
||||
if (phaseRef.current === 'IDLE') return
|
||||
|
||||
clearTick()
|
||||
|
||||
phaseRef.current = 'IDLE'
|
||||
isRunningRef.current = false
|
||||
isPausedRef.current = false
|
||||
lastTickTimeRef.current = 0
|
||||
|
||||
setPhase('IDLE')
|
||||
setSecondsLeft(0)
|
||||
setCurrentRound(0)
|
||||
setCurrentCycle(0)
|
||||
setIsRunning(false)
|
||||
setIsPaused(false)
|
||||
|
||||
if (__DEV__) {
|
||||
console.log('[TimerEngine] Stopped')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const skip = useCallback(() => {
|
||||
if (phaseRef.current === 'IDLE' || phaseRef.current === 'COMPLETE') return
|
||||
if (!isRunningRef.current && !isPausedRef.current) return
|
||||
|
||||
// If paused, un-pause first
|
||||
if (isPausedRef.current) {
|
||||
isPausedRef.current = false
|
||||
isRunningRef.current = true
|
||||
setIsPaused(false)
|
||||
setIsRunning(true)
|
||||
}
|
||||
|
||||
clearTick()
|
||||
advancePhase()
|
||||
}, [])
|
||||
|
||||
const addEventListener = useCallback((listener: TimerEventListener) => {
|
||||
listenersRef.current.add(listener)
|
||||
return () => {
|
||||
listenersRef.current.delete(listener)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// --- AppState: auto-pause on interruption ---
|
||||
// iOS: active → inactive (phone call, control center) → background
|
||||
// Android: active → background (directly)
|
||||
// With auto-pause, we pause on any departure from 'active'.
|
||||
// User must manually resume — no reconcile needed.
|
||||
|
||||
useEffect(() => {
|
||||
const handleAppState = (nextState: AppStateStatus) => {
|
||||
if (nextState !== 'active') {
|
||||
if (isRunningRef.current && !isPausedRef.current) {
|
||||
pause()
|
||||
if (__DEV__) {
|
||||
console.log(`[TimerEngine] Auto-paused (${nextState})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const subscription = AppState.addEventListener('change', handleAppState)
|
||||
return () => subscription.remove()
|
||||
}, [pause])
|
||||
|
||||
// --- Keep-awake ---
|
||||
|
||||
useEffect(() => {
|
||||
if (isRunning || isPaused) {
|
||||
activateKeepAwakeAsync('tabata-session')
|
||||
} else {
|
||||
deactivateKeepAwake('tabata-session')
|
||||
}
|
||||
}, [isRunning, isPaused])
|
||||
|
||||
// --- Cleanup on unmount ---
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTick()
|
||||
deactivateKeepAwake('tabata-session')
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
phase,
|
||||
secondsLeft,
|
||||
currentRound,
|
||||
totalRounds: config.rounds,
|
||||
currentCycle,
|
||||
totalCycles: config.cycles,
|
||||
isRunning,
|
||||
isPaused,
|
||||
totalElapsedSeconds,
|
||||
start,
|
||||
pause,
|
||||
resume,
|
||||
stop,
|
||||
skip,
|
||||
addEventListener,
|
||||
config,
|
||||
}
|
||||
}
|
||||
12
src/features/timer/index.ts
Normal file
12
src/features/timer/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { useTimerEngine } from './hooks/useTimerEngine'
|
||||
export { TimerDisplay } from './components/TimerDisplay'
|
||||
export { TimerControls } from './components/TimerControls'
|
||||
export type {
|
||||
TimerPhase,
|
||||
TimerConfig,
|
||||
TimerState,
|
||||
TimerActions,
|
||||
TimerEvent,
|
||||
TimerEventListener,
|
||||
TimerEngine,
|
||||
} from './types'
|
||||
43
src/features/timer/types.ts
Normal file
43
src/features/timer/types.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export type TimerPhase = 'IDLE' | 'GET_READY' | 'WORK' | 'REST' | 'COMPLETE'
|
||||
|
||||
export interface TimerConfig {
|
||||
workDuration: number
|
||||
restDuration: number
|
||||
rounds: number
|
||||
getReadyDuration: number
|
||||
cycles: number
|
||||
cyclePauseDuration: number
|
||||
}
|
||||
|
||||
export interface TimerState {
|
||||
phase: TimerPhase
|
||||
secondsLeft: number
|
||||
currentRound: number
|
||||
totalRounds: number
|
||||
currentCycle: number
|
||||
totalCycles: number
|
||||
isRunning: boolean
|
||||
isPaused: boolean
|
||||
totalElapsedSeconds: number
|
||||
}
|
||||
|
||||
export interface TimerActions {
|
||||
start: (config?: Partial<TimerConfig>) => void
|
||||
pause: () => void
|
||||
resume: () => void
|
||||
stop: () => void
|
||||
skip: () => void
|
||||
}
|
||||
|
||||
export type TimerEvent =
|
||||
| { type: 'PHASE_CHANGED'; from: TimerPhase; to: TimerPhase }
|
||||
| { type: 'ROUND_COMPLETED'; round: number }
|
||||
| { type: 'SESSION_COMPLETE'; totalSeconds: number }
|
||||
| { type: 'COUNTDOWN_TICK'; secondsLeft: number }
|
||||
|
||||
export type TimerEventListener = (event: TimerEvent) => void
|
||||
|
||||
export interface TimerEngine extends TimerState, TimerActions {
|
||||
addEventListener: (listener: TimerEventListener) => () => void
|
||||
config: TimerConfig
|
||||
}
|
||||
7
src/shared/constants/colors.ts
Normal file
7
src/shared/constants/colors.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const PHASE_COLORS = {
|
||||
IDLE: '#1E1E2E',
|
||||
GET_READY: '#EAB308',
|
||||
WORK: '#F97316',
|
||||
REST: '#3B82F6',
|
||||
COMPLETE: '#22C55E',
|
||||
} as const
|
||||
10
src/shared/constants/timer.ts
Normal file
10
src/shared/constants/timer.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const TIMER_DEFAULTS = {
|
||||
WORK_DURATION: 20,
|
||||
REST_DURATION: 10,
|
||||
ROUNDS: 8,
|
||||
GET_READY_DURATION: 10,
|
||||
CYCLES: 1,
|
||||
CYCLE_PAUSE_DURATION: 60,
|
||||
} as const
|
||||
|
||||
export const TICK_INTERVAL_MS = 100
|
||||
8
src/shared/utils/formatTime.ts
Normal file
8
src/shared/utils/formatTime.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export function formatTime(totalSeconds: number): string {
|
||||
const mins = Math.floor(totalSeconds / 60)
|
||||
const secs = totalSeconds % 60
|
||||
if (mins > 0) {
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
return `${secs}`
|
||||
}
|
||||
Reference in New Issue
Block a user