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:
@@ -1,98 +1,67 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { Platform, StyleSheet } from 'react-native';
|
||||
|
||||
import { HelloWave } from '@/components/hello-wave';
|
||||
import ParallaxScrollView from '@/components/parallax-scroll-view';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { Link } from 'expo-router';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { StatusBar } from 'expo-status-bar'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
|
||||
export default function HomeScreen() {
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerBackgroundColor={{ light: '#A1CEDC', dark: '#1D3D47' }}
|
||||
headerImage={
|
||||
<Image
|
||||
source={require('@/assets/images/partial-react-logo.png')}
|
||||
style={styles.reactLogo}
|
||||
/>
|
||||
}>
|
||||
<ThemedView style={styles.titleContainer}>
|
||||
<ThemedText type="title">Welcome!</ThemedText>
|
||||
<HelloWave />
|
||||
</ThemedView>
|
||||
<ThemedView style={styles.stepContainer}>
|
||||
<ThemedText type="subtitle">Step 1: Try it</ThemedText>
|
||||
<ThemedText>
|
||||
Edit <ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> to see changes.
|
||||
Press{' '}
|
||||
<ThemedText type="defaultSemiBold">
|
||||
{Platform.select({
|
||||
ios: 'cmd + d',
|
||||
android: 'cmd + m',
|
||||
web: 'F12',
|
||||
})}
|
||||
</ThemedText>{' '}
|
||||
to open developer tools.
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedView style={styles.stepContainer}>
|
||||
<Link href="/modal">
|
||||
<Link.Trigger>
|
||||
<ThemedText type="subtitle">Step 2: Explore</ThemedText>
|
||||
</Link.Trigger>
|
||||
<Link.Preview />
|
||||
<Link.Menu>
|
||||
<Link.MenuAction title="Action" icon="cube" onPress={() => alert('Action pressed')} />
|
||||
<Link.MenuAction
|
||||
title="Share"
|
||||
icon="square.and.arrow.up"
|
||||
onPress={() => alert('Share pressed')}
|
||||
/>
|
||||
<Link.Menu title="More" icon="ellipsis">
|
||||
<Link.MenuAction
|
||||
title="Delete"
|
||||
icon="trash"
|
||||
destructive
|
||||
onPress={() => alert('Delete pressed')}
|
||||
/>
|
||||
</Link.Menu>
|
||||
</Link.Menu>
|
||||
</Link>
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
<ThemedText>
|
||||
{`Tap the Explore tab to learn more about what's included in this starter app.`}
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedView style={styles.stepContainer}>
|
||||
<ThemedText type="subtitle">Step 3: Get a fresh start</ThemedText>
|
||||
<ThemedText>
|
||||
{`When you're ready, run `}
|
||||
<ThemedText type="defaultSemiBold">npm run reset-project</ThemedText> to get a fresh{' '}
|
||||
<ThemedText type="defaultSemiBold">app</ThemedText> directory. This will move the current{' '}
|
||||
<ThemedText type="defaultSemiBold">app</ThemedText> to{' '}
|
||||
<ThemedText type="defaultSemiBold">app-example</ThemedText>.
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
</ParallaxScrollView>
|
||||
);
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top + 24 }]}>
|
||||
<StatusBar style="light" />
|
||||
|
||||
<Text style={styles.title}>TABATAGO</Text>
|
||||
<Text style={styles.subtitle}>Entraînement Tabata</Text>
|
||||
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.startButton,
|
||||
pressed && styles.startButtonPressed,
|
||||
]}
|
||||
onPress={() => router.push('/timer')}
|
||||
>
|
||||
<Text style={styles.startButtonText}>START</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
titleContainer: {
|
||||
flexDirection: 'row',
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#1E1E2E',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
stepContainer: {
|
||||
gap: 8,
|
||||
marginBottom: 8,
|
||||
title: {
|
||||
fontSize: 44,
|
||||
fontWeight: '900',
|
||||
color: '#FFFFFF',
|
||||
letterSpacing: 6,
|
||||
},
|
||||
reactLogo: {
|
||||
height: 178,
|
||||
width: 290,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
position: 'absolute',
|
||||
subtitle: {
|
||||
fontSize: 18,
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
startButton: {
|
||||
width: 160,
|
||||
height: 160,
|
||||
borderRadius: 80,
|
||||
backgroundColor: '#F97316',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: 60,
|
||||
},
|
||||
startButtonPressed: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
startButtonText: {
|
||||
fontSize: 32,
|
||||
fontWeight: '900',
|
||||
color: '#FFFFFF',
|
||||
letterSpacing: 4,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -16,6 +16,14 @@ export default function RootLayout() {
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Stack>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="timer"
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: 'fullScreenModal',
|
||||
animation: 'fade',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
|
||||
</Stack>
|
||||
<StatusBar style="auto" />
|
||||
|
||||
41
app/timer.tsx
Normal file
41
app/timer.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useTimerEngine } from '@/src/features/timer'
|
||||
import { TimerDisplay } from '@/src/features/timer/components/TimerDisplay'
|
||||
|
||||
export default function TimerScreen() {
|
||||
const router = useRouter()
|
||||
const timer = useTimerEngine()
|
||||
|
||||
function handleStart() {
|
||||
timer.start()
|
||||
}
|
||||
|
||||
function handleStop() {
|
||||
timer.stop()
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user