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:
369
.claude/skills/audio/SKILL.md
Normal file
369
.claude/skills/audio/SKILL.md
Normal file
@@ -0,0 +1,369 @@
|
||||
# Skill — Audio Engine (useAudioEngine)
|
||||
> Lis ce skill AVANT d'implémenter quoi que ce soit lié à l'audio.
|
||||
|
||||
## Responsabilité de ce module
|
||||
L'audio engine gère 4 couches sonores indépendantes qui s'activent
|
||||
en réponse aux événements du timer. Il ne connaît pas le timer
|
||||
directement — il s'abonne à ses événements via useTimerSync.
|
||||
|
||||
**Objectif principal : zéro latence audio sur les transitions de phase.**
|
||||
Les sons de phase doivent jouer dans les 50ms suivant l'événement.
|
||||
|
||||
---
|
||||
|
||||
## Architecture du module audio
|
||||
|
||||
```
|
||||
src/features/audio/
|
||||
types.ts
|
||||
hooks/
|
||||
useAudioEngine.ts ← moteur central expo-av
|
||||
useAudioSettings.ts ← préférences utilisateur persistées
|
||||
data/
|
||||
tracks.ts ← catalogue musique (offline, paths locaux)
|
||||
sounds.ts ← catalogue signaux de phase
|
||||
components/
|
||||
MusicPicker.tsx ← sélecteur d'ambiance musicale
|
||||
SoundPicker.tsx ← sélecteur de signal de phase
|
||||
AudioSettingsPanel.tsx ← panneau complet des réglages
|
||||
index.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Types
|
||||
|
||||
```typescript
|
||||
// src/features/audio/types.ts
|
||||
|
||||
export type MusicAmbiance = 'ELECTRO' | 'HIP_HOP' | 'ROCK' | 'SILENCE'
|
||||
export type MusicIntensity = 'LOW' | 'MEDIUM' | 'HIGH'
|
||||
export type PhaseSound = 'beep' | 'whistle' | 'voice_go' | 'air_horn' | 'bell' | 'silence'
|
||||
|
||||
export interface AudioTrack {
|
||||
id: string
|
||||
ambiance: MusicAmbiance
|
||||
intensity: MusicIntensity
|
||||
asset: number // require('../assets/audio/...') — offline
|
||||
bpm: number
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
export interface AudioSettings {
|
||||
musicEnabled: boolean
|
||||
ambiance: MusicAmbiance
|
||||
musicVolume: number // 0.0 → 1.0
|
||||
soundsEnabled: boolean
|
||||
soundsVolume: number // 0.0 → 1.0
|
||||
hapticsEnabled: boolean
|
||||
voiceEnabled: boolean // annonces vocales
|
||||
overrideMuteSwitch: boolean // iOS : jouer même en mode silencieux
|
||||
workPhaseSound: PhaseSound
|
||||
restPhaseSound: PhaseSound
|
||||
countdownSound: PhaseSound
|
||||
completionSound: PhaseSound
|
||||
}
|
||||
|
||||
export interface AudioState {
|
||||
isLoaded: boolean
|
||||
isMusicPlaying: boolean
|
||||
currentAmbiance: MusicAmbiance
|
||||
currentIntensity: MusicIntensity
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface AudioActions {
|
||||
preloadAll: () => Promise<void>
|
||||
startMusic: (ambiance: MusicAmbiance, intensity: MusicIntensity) => Promise<void>
|
||||
switchIntensity: (intensity: MusicIntensity, fadeMs?: number) => Promise<void>
|
||||
stopMusic: (fadeMs?: number) => Promise<void>
|
||||
playPhaseSound: (sound: PhaseSound) => Promise<void>
|
||||
playCountdown: (seconds: number) => Promise<void> // 3, 2, 1
|
||||
unloadAll: () => Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implémentation — useAudioEngine
|
||||
|
||||
### Configuration de la session audio iOS/Android
|
||||
|
||||
**Critique à faire en premier, avant tout chargement de son.**
|
||||
|
||||
```typescript
|
||||
import { Audio } from 'expo-av'
|
||||
|
||||
// À appeler une seule fois au démarrage de l'app (dans _layout.tsx)
|
||||
export async function configureAudioSession(): Promise<void> {
|
||||
await Audio.setAudioModeAsync({
|
||||
// iOS : jouer par-dessus la musique de l'utilisateur
|
||||
playsInSilentModeIOS: true, // ignore le switch mute (si overrideMuteSwitch)
|
||||
allowsRecordingIOS: false,
|
||||
staysActiveInBackground: true, // CRITIQUE — timer continue en background
|
||||
|
||||
// Android
|
||||
shouldDuckAndroid: true, // baisser le volume autres apps sur nos signaux
|
||||
playThroughEarpieceAndroid: false,
|
||||
interruptionModeIOS: 1, // DO_NOT_MIX = 1 (nos sons ont priorité sur signaux)
|
||||
interruptionModeAndroid: 1,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Preloading — charger AVANT la séance
|
||||
|
||||
Tous les sons doivent être chargés en mémoire AVANT que la séance commence.
|
||||
Si on charge pendant la séance → latence inacceptable.
|
||||
|
||||
```typescript
|
||||
const soundRefs = useRef<Record<string, Audio.Sound>>({})
|
||||
|
||||
async function preloadAll(settings: AudioSettings): Promise<void> {
|
||||
// 1. Charger la track musicale correspondante à l'ambiance + les 2 intensités
|
||||
const trackWork = getTrack(settings.ambiance, 'HIGH')
|
||||
const trackRest = getTrack(settings.ambiance, 'LOW')
|
||||
|
||||
soundRefs.current['music_work'] = await loadSound(trackWork.asset)
|
||||
soundRefs.current['music_rest'] = await loadSound(trackRest.asset)
|
||||
|
||||
// 2. Charger tous les signaux de phase
|
||||
const soundAssets = getSoundAssets(settings)
|
||||
for (const [key, asset] of Object.entries(soundAssets)) {
|
||||
soundRefs.current[key] = await loadSound(asset)
|
||||
}
|
||||
|
||||
// 3. Mettre en loop les tracks musicales
|
||||
await soundRefs.current['music_work'].setIsLoopingAsync(true)
|
||||
await soundRefs.current['music_rest'].setIsLoopingAsync(true)
|
||||
}
|
||||
|
||||
async function loadSound(asset: number): Promise<Audio.Sound> {
|
||||
const { sound } = await Audio.Sound.createAsync(asset, {
|
||||
shouldPlay: false,
|
||||
volume: 1.0,
|
||||
})
|
||||
return sound
|
||||
}
|
||||
```
|
||||
|
||||
### Crossfade entre phases
|
||||
|
||||
```typescript
|
||||
async function switchIntensity(
|
||||
to: MusicIntensity,
|
||||
fadeMs: number = 500
|
||||
): Promise<void> {
|
||||
const from = currentIntensityRef.current
|
||||
if (from === to) return
|
||||
|
||||
const outKey = from === 'HIGH' ? 'music_work' : 'music_rest'
|
||||
const inKey = to === 'HIGH' ? 'music_work' : 'music_rest'
|
||||
|
||||
const outSound = soundRefs.current[outKey]
|
||||
const inSound = soundRefs.current[inKey]
|
||||
|
||||
if (!outSound || !inSound) return
|
||||
|
||||
// Démarrer la track entrante depuis le début (ou depuis la position actuelle ?)
|
||||
await inSound.setPositionAsync(0)
|
||||
await inSound.setVolumeAsync(0)
|
||||
await inSound.playAsync()
|
||||
|
||||
// Fade simultané
|
||||
const steps = 10
|
||||
const stepMs = fadeMs / steps
|
||||
const volumeStep = settings.musicVolume / steps
|
||||
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
await Promise.all([
|
||||
outSound.setVolumeAsync(Math.max(0, settings.musicVolume - i * volumeStep)),
|
||||
inSound.setVolumeAsync(Math.min(settings.musicVolume, i * volumeStep)),
|
||||
])
|
||||
await delay(stepMs)
|
||||
}
|
||||
|
||||
await outSound.stopAsync()
|
||||
currentIntensityRef.current = to
|
||||
}
|
||||
|
||||
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
||||
```
|
||||
|
||||
### Répondre aux événements timer
|
||||
|
||||
```typescript
|
||||
// Dans useTimerSync — pont entre timer et audio
|
||||
|
||||
import { useTimerEngine } from '../timer'
|
||||
import { useAudioEngine } from '../audio'
|
||||
|
||||
export function useTimerSync() {
|
||||
const timer = useTimerEngine()
|
||||
const audio = useAudioEngine()
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = timer.addEventListener((event) => {
|
||||
switch (event.type) {
|
||||
case 'PHASE_CHANGED':
|
||||
handlePhaseChange(event.from, event.to)
|
||||
break
|
||||
case 'COUNTDOWN_TICK':
|
||||
audio.playCountdown(event.secondsLeft)
|
||||
break
|
||||
case 'SESSION_COMPLETE':
|
||||
audio.playPhaseSound('completion')
|
||||
audio.stopMusic(1000)
|
||||
break
|
||||
}
|
||||
})
|
||||
return unsubscribe
|
||||
}, [])
|
||||
|
||||
async function handlePhaseChange(from: TimerPhase, to: TimerPhase) {
|
||||
switch (to) {
|
||||
case 'GET_READY':
|
||||
await audio.startMusic(settings.ambiance, 'MEDIUM')
|
||||
break
|
||||
case 'WORK':
|
||||
await audio.playPhaseSound(settings.workPhaseSound)
|
||||
await audio.switchIntensity('HIGH', 500)
|
||||
break
|
||||
case 'REST':
|
||||
await audio.playPhaseSound(settings.restPhaseSound)
|
||||
await audio.switchIntensity('LOW', 500)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Catalogue des tracks (offline)
|
||||
|
||||
```typescript
|
||||
// src/features/audio/data/tracks.ts
|
||||
import { MusicAmbiance, MusicIntensity, AudioTrack } from '../types'
|
||||
|
||||
export const TRACKS: AudioTrack[] = [
|
||||
{
|
||||
id: 'electro_high',
|
||||
ambiance: 'ELECTRO',
|
||||
intensity: 'HIGH',
|
||||
asset: require('../../../assets/audio/music/electro_high.mp3'),
|
||||
bpm: 140,
|
||||
durationMs: 180000,
|
||||
},
|
||||
{
|
||||
id: 'electro_low',
|
||||
ambiance: 'ELECTRO',
|
||||
intensity: 'LOW',
|
||||
asset: require('../../../assets/audio/music/electro_low.mp3'),
|
||||
bpm: 90,
|
||||
durationMs: 180000,
|
||||
},
|
||||
// ... (9 tracks au total)
|
||||
]
|
||||
|
||||
export function getTrack(ambiance: MusicAmbiance, intensity: MusicIntensity): AudioTrack {
|
||||
const track = TRACKS.find(t => t.ambiance === ambiance && t.intensity === intensity)
|
||||
if (!track) throw new Error(`Track non trouvée : ${ambiance} ${intensity}`)
|
||||
return track
|
||||
}
|
||||
```
|
||||
|
||||
## Catalogue des sons de phase
|
||||
|
||||
```typescript
|
||||
// src/features/audio/data/sounds.ts
|
||||
export const PHASE_SOUNDS = {
|
||||
beep: require('../../../assets/audio/sounds/beep_long.mp3'),
|
||||
whistle: require('../../../assets/audio/sounds/whistle.mp3'),
|
||||
voice_go: require('../../../assets/audio/sounds/voice_go.mp3'),
|
||||
air_horn: require('../../../assets/audio/sounds/air_horn.mp3'),
|
||||
bell: require('../../../assets/audio/sounds/bell.mp3'),
|
||||
double_beep: require('../../../assets/audio/sounds/beep_double.mp3'),
|
||||
countdown_3: require('../../../assets/audio/sounds/count_3.mp3'),
|
||||
countdown_2: require('../../../assets/audio/sounds/count_2.mp3'),
|
||||
countdown_1: require('../../../assets/audio/sounds/count_1.mp3'),
|
||||
fanfare: require('../../../assets/audio/sounds/fanfare.mp3'),
|
||||
clap: require('../../../assets/audio/sounds/applause.mp3'),
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Gestion du switch silencieux iOS
|
||||
|
||||
```typescript
|
||||
// Si overrideMuteSwitch = true dans les settings
|
||||
await Audio.setAudioModeAsync({
|
||||
playsInSilentModeIOS: settings.overrideMuteSwitch,
|
||||
})
|
||||
```
|
||||
|
||||
**Règle UX :** Les signaux de phase ont l'override activé par défaut
|
||||
(l'utilisateur a besoin de savoir quand changer d'exercice même en mode silencieux).
|
||||
La musique d'ambiance respecte le switch silencieux par défaut.
|
||||
|
||||
---
|
||||
|
||||
## Cleanup obligatoire
|
||||
|
||||
```typescript
|
||||
// Dans useAudioEngine — cleanup au unmount ou fin de séance
|
||||
async function unloadAll(): Promise<void> {
|
||||
await Promise.all(
|
||||
Object.values(soundRefs.current).map(sound => sound.unloadAsync())
|
||||
)
|
||||
soundRefs.current = {}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Cleanup synchrone au unmount
|
||||
Object.values(soundRefs.current).forEach(sound => {
|
||||
sound.unloadAsync().catch(console.error)
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tests minimaux
|
||||
|
||||
```typescript
|
||||
describe('useAudioEngine', () => {
|
||||
it('configure la session audio au mount', () => {})
|
||||
it('preloadAll charge les sons sans erreur', () => {})
|
||||
it('playPhaseSound joue le bon son dans les 50ms', () => {})
|
||||
it('switchIntensity effectue un crossfade', () => {})
|
||||
it('stopMusic fait un fade-out', () => {})
|
||||
it('unloadAll libère toutes les ressources', () => {})
|
||||
it('ne plante pas si un asset est manquant (fallback)', () => {})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Erreurs classiques à éviter
|
||||
|
||||
```typescript
|
||||
// ❌ Charger les sons à la demande → latence
|
||||
case 'PHASE_CHANGED':
|
||||
const { sound } = await Audio.Sound.createAsync(asset) // ← trop lent !
|
||||
await sound.playAsync()
|
||||
|
||||
// ✅ Utiliser les sons préchargés
|
||||
case 'PHASE_CHANGED':
|
||||
await soundRefs.current['work_start'].replayAsync() // ← instantané
|
||||
|
||||
// ❌ Oublier de configurer la session audio → muet sur iOS background
|
||||
await sound.playAsync() // sans Audio.setAudioModeAsync() → silencieux en background
|
||||
|
||||
// ❌ Pas de gestion du duck Android → nos sons se mélangent avec d'autres apps
|
||||
interruptionModeAndroid: 2 // DUCK_OTHERS — OK pour la musique d'ambiance
|
||||
interruptionModeAndroid: 1 // DO_NOT_MIX — pour les signaux de phase
|
||||
```
|
||||
428
.claude/skills/exercises/SKILL.md
Normal file
428
.claude/skills/exercises/SKILL.md
Normal file
@@ -0,0 +1,428 @@
|
||||
# Skill — Exercices (useExercise + ExerciseDisplay)
|
||||
> Lis ce skill AVANT d'implémenter quoi que ce soit lié aux exercices.
|
||||
|
||||
## Responsabilité de ce module
|
||||
Gérer la bibliothèque d'exercices, la sélection selon le programme,
|
||||
et l'affichage contextuel selon la phase du timer.
|
||||
**Offline-first** : toutes les données et les GIFs sont dans le bundle.
|
||||
|
||||
---
|
||||
|
||||
## Architecture du module exercices
|
||||
|
||||
```
|
||||
src/features/exercises/
|
||||
types.ts
|
||||
hooks/
|
||||
useExercise.ts ← sélection + navigation entre exercices
|
||||
useExerciseLibrary.ts ← recherche, filtrage, favoris
|
||||
components/
|
||||
ExerciseDisplay.tsx ← affichage contextuel selon la phase timer
|
||||
ExerciseGif.tsx ← Image GIF avec fallback + accessibilité
|
||||
ExerciseCard.tsx ← carte pour la bibliothèque/programme
|
||||
ExerciseCues.tsx ← affichage des cues de forme
|
||||
data/
|
||||
exercises.ts ← les 38 exercices (source de vérité)
|
||||
categories.ts ← définition des catégories
|
||||
index.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Types
|
||||
|
||||
```typescript
|
||||
// src/features/exercises/types.ts
|
||||
|
||||
export type ExerciseCategory =
|
||||
| 'CARDIO'
|
||||
| 'LOWER_BODY'
|
||||
| 'UPPER_BODY'
|
||||
| 'CORE'
|
||||
| 'LOW_IMPACT'
|
||||
| 'EQUIPMENT'
|
||||
|
||||
export type ExerciseDifficulty = 'BEGINNER' | 'INTERMEDIATE' | 'ADVANCED'
|
||||
|
||||
export interface Exercise {
|
||||
id: string
|
||||
name: Record<string, string> // clés = codes langue : { fr, en, es, de, pt }
|
||||
category: ExerciseCategory
|
||||
difficulty: ExerciseDifficulty
|
||||
musclesTargeted: string[]
|
||||
description: Record<string, string> // max 80 caractères par langue
|
||||
cues: Record<string, string[]> // 2-3 points clés de forme par langue
|
||||
gifAsset: number | null // require(...) ou null si pas de GIF
|
||||
thumbnailAsset: number | null
|
||||
hasModification: boolean // variante plus facile disponible
|
||||
modificationId: string | null // id de l'exercice modifié
|
||||
equipmentNeeded: string[] // [] = aucun matériel
|
||||
estimatedCaloriesPerMinute: number // approximatif
|
||||
}
|
||||
|
||||
export type ProgramMode = 'SINGLE' | 'DUO' | 'CIRCUIT_4' | 'FREE'
|
||||
|
||||
export interface ExerciseProgram {
|
||||
id: string
|
||||
name: Record<string, string>
|
||||
description: Record<string, string>
|
||||
mode: ProgramMode
|
||||
// Pour SINGLE : [exerciseId] — même exercice répété
|
||||
// Pour DUO : [exerciseIdA, exerciseIdB] — alternance
|
||||
// Pour CIRCUIT_4 : [a, b, c, d] — 4 exercices × 2 rounds
|
||||
// Pour FREE : exerciseId par round [r1, r2, r3, r4, r5, r6, r7, r8]
|
||||
exerciseIds: string[]
|
||||
difficulty: ExerciseDifficulty
|
||||
totalRounds: number
|
||||
isDefault: boolean // programmes pré-définis vs créés par user
|
||||
}
|
||||
|
||||
// Résout l'exercice pour un round donné selon le mode du programme
|
||||
export interface ExerciseState {
|
||||
current: Exercise
|
||||
next: Exercise | null // null sur le dernier round
|
||||
roundIndex: number // 0-indexé
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Données — les 38 exercices V1
|
||||
|
||||
Structure à respecter pour chaque exercice :
|
||||
|
||||
```typescript
|
||||
// src/features/exercises/data/exercises.ts
|
||||
import { Exercise } from '../types'
|
||||
|
||||
export const EXERCISES: Exercise[] = [
|
||||
{
|
||||
id: 'burpee_classic',
|
||||
name: { fr: 'Burpee', en: 'Burpee', es: 'Burpee', de: 'Burpee', pt: 'Burpee' },
|
||||
category: 'CARDIO',
|
||||
difficulty: 'INTERMEDIATE',
|
||||
musclesTargeted: ['quadriceps', 'pectoraux', 'épaules', 'cardio'],
|
||||
description: {
|
||||
fr: 'Position debout → squat → planche → pompe → saut vertical',
|
||||
en: 'Stand → squat → plank → push-up → jump',
|
||||
es: 'De pie → sentadilla → plancha → flexión → salto',
|
||||
de: 'Stehen → Hocke → Plank → Liegestütz → Sprung',
|
||||
pt: 'Em pé → agachamento → prancha → flexão → salto',
|
||||
},
|
||||
cues: {
|
||||
fr: ['Atterris doucement sur les orteils', 'Garde le dos droit en planche', 'Explose vers le haut'],
|
||||
en: ['Land softly on toes', 'Keep back flat in plank', 'Explode upward'],
|
||||
es: ['Aterriza suavemente', 'Espalda recta en plancha', 'Explota hacia arriba'],
|
||||
de: ['Sanft auf den Zehen landen', 'Rücken gerade halten', 'Nach oben explodieren'],
|
||||
pt: ['Aterrissar suavemente', 'Costas retas na prancha', 'Explodir para cima'],
|
||||
},
|
||||
gifAsset: require('../../../assets/exercises/burpee_classic.gif'),
|
||||
thumbnailAsset: require('../../../assets/exercises/thumbs/burpee_classic.jpg'),
|
||||
hasModification: true,
|
||||
modificationId: 'burpee_modified',
|
||||
equipmentNeeded: [],
|
||||
estimatedCaloriesPerMinute: 12,
|
||||
},
|
||||
// ... 37 autres exercices
|
||||
]
|
||||
|
||||
// Accès rapide par ID
|
||||
export const EXERCISES_MAP = Object.fromEntries(
|
||||
EXERCISES.map(ex => [ex.id, ex])
|
||||
) as Record<string, Exercise>
|
||||
|
||||
export function getExerciseById(id: string): Exercise {
|
||||
const ex = EXERCISES_MAP[id]
|
||||
if (!ex) throw new Error(`Exercice non trouvé : ${id}`)
|
||||
return ex
|
||||
}
|
||||
```
|
||||
|
||||
**Liste des 38 exercices V1 à implémenter :**
|
||||
```
|
||||
CARDIO (8) : burpee_classic, burpee_modified, jumping_jacks,
|
||||
mountain_climbers, high_knees, jump_rope_sim,
|
||||
box_jump_sim, lateral_shuffles
|
||||
|
||||
LOWER_BODY (8) : squat_classic, squat_jump, lunge_alternating,
|
||||
lunge_jump, glute_bridge, wall_sit, calf_raises,
|
||||
sumo_squat
|
||||
|
||||
UPPER_BODY (6) : pushup_classic, pushup_modified, pike_pushup,
|
||||
tricep_dip, shoulder_taps, inchworm
|
||||
|
||||
CORE (6) : crunch_classic, plank_hold, russian_twist,
|
||||
bicycle_crunch, leg_raises, dead_bug
|
||||
|
||||
LOW_IMPACT (6) : march_in_place, step_touch, modified_squat,
|
||||
standing_oblique, slow_pushup, chair_stand
|
||||
|
||||
EQUIPMENT (4) : kb_swing, db_thruster, resistance_band_row,
|
||||
medicine_ball_slam
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Hook useExercise
|
||||
|
||||
```typescript
|
||||
// src/features/exercises/hooks/useExercise.ts
|
||||
|
||||
export function useExercise(program: ExerciseProgram): {
|
||||
getExerciseForRound: (round: number) => ExerciseState
|
||||
currentExercise: Exercise
|
||||
nextExercise: Exercise | null
|
||||
} {
|
||||
function getExerciseForRound(round: number): ExerciseState {
|
||||
// round est 1-indexé
|
||||
const index = round - 1
|
||||
|
||||
let currentId: string
|
||||
let nextId: string | null
|
||||
|
||||
switch (program.mode) {
|
||||
case 'SINGLE':
|
||||
currentId = program.exerciseIds[0]
|
||||
nextId = null // même exercice → pas de "suivant" différent
|
||||
break
|
||||
|
||||
case 'DUO':
|
||||
currentId = program.exerciseIds[index % 2]
|
||||
nextId = program.exerciseIds[(index + 1) % 2]
|
||||
break
|
||||
|
||||
case 'CIRCUIT_4':
|
||||
currentId = program.exerciseIds[index % 4]
|
||||
nextId = program.exerciseIds[(index + 1) % 4]
|
||||
break
|
||||
|
||||
case 'FREE':
|
||||
currentId = program.exerciseIds[index] ?? program.exerciseIds[0]
|
||||
nextId = program.exerciseIds[index + 1] ?? null
|
||||
break
|
||||
}
|
||||
|
||||
return {
|
||||
current: getExerciseById(currentId),
|
||||
next: nextId ? getExerciseById(nextId) : null,
|
||||
roundIndex: index,
|
||||
}
|
||||
}
|
||||
|
||||
return { getExerciseForRound, /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Composant ExerciseDisplay — affichage contextuel
|
||||
|
||||
Le composant adapte son layout selon la phase du timer.
|
||||
|
||||
```typescript
|
||||
interface ExerciseDisplayProps {
|
||||
phase: TimerPhase
|
||||
exercise: Exercise
|
||||
nextExercise: Exercise | null
|
||||
lang: string // code langue actuel
|
||||
}
|
||||
|
||||
export function ExerciseDisplay({ phase, exercise, nextExercise, lang }: ExerciseDisplayProps) {
|
||||
switch (phase) {
|
||||
case 'GET_READY':
|
||||
return <GetReadyView exercise={exercise} lang={lang} />
|
||||
// ↑ GIF grand format + nom + "Prépare-toi !"
|
||||
|
||||
case 'WORK':
|
||||
return <WorkView exercise={exercise} lang={lang} />
|
||||
// ↑ Nom en haut + 2 cues + GIF petit coin bas-droit
|
||||
|
||||
case 'REST':
|
||||
return <RestView exercise={exercise} nextExercise={nextExercise} lang={lang} />
|
||||
// ↑ "Repos" + si nextExercise : "Prochain : [nom]" + vignette
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GetReadyView
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ │
|
||||
│ ┌─────────────────┐ │
|
||||
│ │ [GIF 200x200] │ │ ← Grand GIF centré
|
||||
│ └─────────────────┘ │
|
||||
│ │
|
||||
│ Burpees │ ← Nom, police bold 28px
|
||||
│ ← Dos droit en planche │ ← Cue #1
|
||||
│ ← Explose vers le haut │ ← Cue #2
|
||||
│ │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
### WorkView
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ Burpees │ ← Nom, 22px, coin haut-gauche
|
||||
│ ← Dos droit │ ← Cue #1, 16px
|
||||
│ ← Explose vers le haut │ ← Cue #2, 16px
|
||||
│ │
|
||||
│ [Timer central] │
|
||||
│ │
|
||||
│ ┌────┐ │
|
||||
│ │GIF │ │ ← GIF 80x80, coin bas-droit
|
||||
│ └────┘ │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
### RestView
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ │
|
||||
│ REPOS │ ← "REPOS" centré, grand
|
||||
│ │
|
||||
│ Prochain : Mountain │ ← Si nextExercise != null
|
||||
│ Climbers │
|
||||
│ ┌──────┐ │
|
||||
│ │ GIF │ │ ← Vignette 60x60 de nextExercise
|
||||
│ └──────┘ │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Composant ExerciseGif — robuste et accessible
|
||||
|
||||
```typescript
|
||||
interface ExerciseGifProps {
|
||||
exercise: Exercise
|
||||
size: 'small' | 'medium' | 'large'
|
||||
lang: string
|
||||
}
|
||||
|
||||
const GIF_SIZES = { small: 80, medium: 120, large: 200 }
|
||||
|
||||
export function ExerciseGif({ exercise, size, lang }: ExerciseGifProps) {
|
||||
const [hasError, setHasError] = useState(false)
|
||||
const dimension = GIF_SIZES[size]
|
||||
|
||||
if (!exercise.gifAsset || hasError) {
|
||||
// Fallback : icône + initiales de l'exercice
|
||||
return (
|
||||
<View
|
||||
style={[styles.fallback, { width: dimension, height: dimension }]}
|
||||
accessible={true}
|
||||
accessibilityLabel={exercise.name[lang] ?? exercise.name['en']}
|
||||
>
|
||||
<Text style={styles.fallbackIcon}>💪</Text>
|
||||
<Text style={styles.fallbackText}>{getInitials(exercise.name[lang])}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
source={exercise.gifAsset}
|
||||
style={{ width: dimension, height: dimension }}
|
||||
onError={() => setHasError(true)}
|
||||
accessible={true}
|
||||
accessibilityLabel={`Démonstration de ${exercise.name[lang]}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Localisation des exercices
|
||||
|
||||
```typescript
|
||||
// Utilitaire pour extraire la bonne langue avec fallback
|
||||
export function getLocalizedText(
|
||||
record: Record<string, string>,
|
||||
lang: string
|
||||
): string {
|
||||
return record[lang] ?? record['en'] ?? Object.values(record)[0] ?? ''
|
||||
}
|
||||
|
||||
// Utilisation
|
||||
const name = getLocalizedText(exercise.name, userLang)
|
||||
const cues = (exercise.cues[userLang] ?? exercise.cues['en'] ?? [])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Programmes pré-définis V1
|
||||
|
||||
```typescript
|
||||
// src/features/exercises/data/defaultPrograms.ts
|
||||
|
||||
export const DEFAULT_PROGRAMS: ExerciseProgram[] = [
|
||||
{
|
||||
id: 'beginner_classic',
|
||||
name: { fr: 'Débutant Classic', en: 'Beginner Classic' },
|
||||
description: { fr: 'Parfait pour commencer le Tabata', en: 'Perfect to start Tabata' },
|
||||
mode: 'SINGLE',
|
||||
exerciseIds: ['squat_classic'],
|
||||
difficulty: 'BEGINNER',
|
||||
totalRounds: 8,
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
id: 'full_body_duo',
|
||||
name: { fr: 'Full Body Duo', en: 'Full Body Duo' },
|
||||
description: { fr: 'Squats + Pompes — le duo parfait', en: 'Squats + Push-ups — the perfect duo' },
|
||||
mode: 'DUO',
|
||||
exerciseIds: ['squat_jump', 'pushup_classic'],
|
||||
difficulty: 'INTERMEDIATE',
|
||||
totalRounds: 8,
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
id: 'cardio_blast',
|
||||
name: { fr: 'Cardio Blast', en: 'Cardio Blast' },
|
||||
description: { fr: 'Circuit intense 4 exercices cardio', en: '4-exercise intense cardio circuit' },
|
||||
mode: 'CIRCUIT_4',
|
||||
exerciseIds: ['burpee_classic', 'high_knees', 'mountain_climbers', 'jumping_jacks'],
|
||||
difficulty: 'ADVANCED',
|
||||
totalRounds: 8,
|
||||
isDefault: true,
|
||||
},
|
||||
// + programmes par objectif (perte de poids, force, low impact, etc.)
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tests minimaux
|
||||
|
||||
```typescript
|
||||
describe('useExercise', () => {
|
||||
it('MODE SINGLE : retourne le même exercice à chaque round', () => {})
|
||||
it('MODE DUO : alterne A/B correctement sur 8 rounds', () => {})
|
||||
it('MODE CIRCUIT_4 : cycle sur 4 exercices', () => {})
|
||||
it('retourne null pour nextExercise sur le dernier round en FREE', () => {})
|
||||
it('getExerciseById lance une erreur si id inconnu', () => {})
|
||||
})
|
||||
|
||||
describe('ExerciseDisplay', () => {
|
||||
it('affiche le GIF grand format en GET_READY', () => {})
|
||||
it('affiche les cues en WORK', () => {})
|
||||
it('affiche nextExercise en REST si disponible', () => {})
|
||||
it('affiche le fallback si gifAsset est null', () => {})
|
||||
it('est accessible (accessibilityLabel présent)', () => {})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist assets exercices
|
||||
|
||||
Avant de lancer en production, vérifier :
|
||||
- [ ] 38 GIFs présents dans `assets/exercises/` (200×200px, loop, < 200KB chacun)
|
||||
- [ ] 38 thumbnails présents dans `assets/exercises/thumbs/` (< 30KB chacun)
|
||||
- [ ] Toutes les traductions présentes pour EN, FR, ES, DE, PT
|
||||
- [ ] Tous les `modificationId` pointent vers un exercice existant
|
||||
- [ ] Bundle total des assets exercices < 10MB
|
||||
349
.claude/skills/timer/SKILL.md
Normal file
349
.claude/skills/timer/SKILL.md
Normal file
@@ -0,0 +1,349 @@
|
||||
# Skill — Timer Engine (useTimerEngine)
|
||||
> Lis ce skill AVANT d'implémenter quoi que ce soit lié au timer.
|
||||
|
||||
## Responsabilité de ce module
|
||||
Le timer est le cœur battant de l'app. Il doit être :
|
||||
- **Précis** : drift < 50ms sur une séance complète (8 rounds)
|
||||
- **Résilient** : continue en background, survit aux appels téléphoniques
|
||||
- **Découplé** : aucune dépendance vers l'audio ou les exercices
|
||||
- **Testable** : logique pure, facile à tester sans simulateur
|
||||
|
||||
---
|
||||
|
||||
## Architecture du module timer
|
||||
|
||||
```
|
||||
src/features/timer/
|
||||
types.ts ← Commencer ICI
|
||||
hooks/
|
||||
useTimerEngine.ts ← Moteur central (logique pure)
|
||||
useTimerSync.ts ← Pont vers audio + exercices
|
||||
useTimerPersistence.ts ← Sauvegarde config dans AsyncStorage
|
||||
components/
|
||||
TimerDisplay.tsx ← Écran plein écran (props-only)
|
||||
TimerControls.tsx ← Boutons start/pause/stop/skip
|
||||
TimerPhaseIndicator.tsx ← Barre de progression des rounds
|
||||
TimerRing.tsx ← Anneau circulaire animé
|
||||
index.ts ← Barrel export
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Types — commencer par ici
|
||||
|
||||
```typescript
|
||||
// src/features/timer/types.ts
|
||||
|
||||
export type TimerPhase = 'IDLE' | 'GET_READY' | 'WORK' | 'REST' | 'COMPLETE'
|
||||
|
||||
export interface TimerConfig {
|
||||
workDuration: number // défaut : 20s
|
||||
restDuration: number // défaut : 10s
|
||||
rounds: number // défaut : 8
|
||||
getReadyDuration: number // défaut : 10s
|
||||
cycles: number // défaut : 1 (Premium : jusqu'à 10)
|
||||
cyclePauseDuration: number // défaut : 60s (pause entre cycles, Premium)
|
||||
}
|
||||
|
||||
export interface TimerState {
|
||||
phase: TimerPhase
|
||||
secondsLeft: number
|
||||
currentRound: number // 1-indexé
|
||||
totalRounds: number
|
||||
currentCycle: number // 1-indexé
|
||||
totalCycles: number
|
||||
isRunning: boolean
|
||||
isPaused: boolean
|
||||
totalElapsedSeconds: number // pour le résumé de séance
|
||||
}
|
||||
|
||||
export interface TimerActions {
|
||||
start: (config: TimerConfig) => void
|
||||
pause: () => void
|
||||
resume: () => void
|
||||
stop: () => void
|
||||
skip: () => void // passer à la phase suivante immédiatement
|
||||
}
|
||||
|
||||
// Événements émis par le timer — consommés par useTimerSync
|
||||
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 } // pour les dernières 3s
|
||||
|
||||
export type TimerEventListener = (event: TimerEvent) => void
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implémentation — useTimerEngine
|
||||
|
||||
### Principe de précision : Date.now() delta
|
||||
|
||||
**JAMAIS** se fier uniquement à setInterval pour le compte à rebours.
|
||||
setInterval drift de 10-100ms par tick sur mobile. Sur 8 rounds
|
||||
de 20s + 10s, ça peut représenter plusieurs secondes d'écart.
|
||||
|
||||
```typescript
|
||||
// ✅ Approche correcte — delta sur Date.now()
|
||||
const tickRef = useRef<number | null>(null)
|
||||
const targetEndTimeRef = useRef<number>(0)
|
||||
|
||||
function tick() {
|
||||
const now = Date.now()
|
||||
const remaining = Math.max(0, targetEndTimeRef.current - now)
|
||||
const secondsLeft = Math.ceil(remaining / 1000)
|
||||
|
||||
setSecondsLeft(secondsLeft)
|
||||
|
||||
if (remaining <= 0) {
|
||||
advancePhase()
|
||||
} else {
|
||||
// Planifier le prochain tick dans ~100ms (pas 1000ms)
|
||||
// pour une réactivité maximale sur les transitions
|
||||
tickRef.current = setTimeout(tick, 100)
|
||||
}
|
||||
}
|
||||
|
||||
function startPhase(duration: number) {
|
||||
targetEndTimeRef.current = Date.now() + duration * 1000
|
||||
tickRef.current = setTimeout(tick, 100)
|
||||
}
|
||||
```
|
||||
|
||||
### Gestion du background (AppState)
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener('change', (nextState) => {
|
||||
if (nextState === 'background' || nextState === 'inactive') {
|
||||
// Sauvegarder le timestamp cible — pas l'état courant
|
||||
// Quand on revient en foreground, on recalcule
|
||||
saveBackgroundTimestamp(targetEndTimeRef.current)
|
||||
}
|
||||
|
||||
if (nextState === 'active') {
|
||||
const savedTarget = loadBackgroundTimestamp()
|
||||
if (savedTarget && isRunningRef.current) {
|
||||
// Recalculer le temps restant depuis le timestamp sauvegardé
|
||||
targetEndTimeRef.current = savedTarget
|
||||
// Si le temps est dépassé, avancer aux phases manquées
|
||||
reconcilePhaseAfterBackground(savedTarget, Date.now())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return () => subscription.remove()
|
||||
}, [])
|
||||
```
|
||||
|
||||
### Notification sticky pendant la séance
|
||||
|
||||
Utiliser expo-notifications pour afficher le décompte en background :
|
||||
|
||||
```typescript
|
||||
async function updateBackgroundNotification(phase: TimerPhase, secondsLeft: number) {
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
identifier: 'tabata-timer-sticky',
|
||||
content: {
|
||||
title: phase === 'WORK' ? '🔥 Travail !' : '💨 Repos',
|
||||
body: `${secondsLeft}s — Round ${currentRound}/${totalRounds}`,
|
||||
sticky: true,
|
||||
autoDismiss: false,
|
||||
},
|
||||
trigger: null,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Ordre des transitions de phase
|
||||
|
||||
```
|
||||
IDLE
|
||||
↓ start()
|
||||
GET_READY (getReadyDuration)
|
||||
↓ temps écoulé
|
||||
WORK (workDuration)
|
||||
↓ temps écoulé
|
||||
REST (restDuration)
|
||||
↓ temps écoulé
|
||||
→ si currentRound < totalRounds : retour à WORK
|
||||
→ si currentRound === totalRounds ET currentCycle < totalCycles :
|
||||
PAUSE_BETWEEN_CYCLES → WORK (round 1 du cycle suivant)
|
||||
→ si currentRound === totalRounds ET currentCycle === totalCycles :
|
||||
COMPLETE
|
||||
↓ stop() ou auto-reset après 3s
|
||||
IDLE
|
||||
```
|
||||
|
||||
### Événements à émettre aux abonnés
|
||||
|
||||
```typescript
|
||||
// À chaque changement de phase
|
||||
emitEvent({ type: 'PHASE_CHANGED', from: prevPhase, to: nextPhase })
|
||||
|
||||
// À chaque fin de round
|
||||
emitEvent({ type: 'ROUND_COMPLETED', round: currentRound })
|
||||
|
||||
// Décompte final (3, 2, 1)
|
||||
if (secondsLeft <= 3) {
|
||||
emitEvent({ type: 'COUNTDOWN_TICK', secondsLeft })
|
||||
}
|
||||
|
||||
// Fin de séance
|
||||
emitEvent({ type: 'SESSION_COMPLETE', totalSeconds: totalElapsedSeconds })
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Interface publique du hook
|
||||
|
||||
```typescript
|
||||
export function useTimerEngine(): TimerState & TimerActions & {
|
||||
addEventListener: (listener: TimerEventListener) => () => void
|
||||
config: TimerConfig
|
||||
}
|
||||
```
|
||||
|
||||
Le retour doit permettre :
|
||||
```typescript
|
||||
const {
|
||||
phase, secondsLeft, currentRound, totalRounds, isRunning, isPaused,
|
||||
start, pause, resume, stop, skip,
|
||||
addEventListener,
|
||||
config,
|
||||
} = useTimerEngine()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Composant TimerDisplay — layout plein écran
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐ ← StatusBar cachée
|
||||
│ [Exercice] Round 3/8 ●○○ │ ← Zone haute (20%) — fond semi-transparent
|
||||
├─────────────────────────────┤
|
||||
│ │
|
||||
│ │
|
||||
│ ┌───────┐ │
|
||||
│ │ :17 │ │ ← Anneau + chiffre (50% de l'écran)
|
||||
│ └───────┘ │ ← Police monospace, taille ~120px
|
||||
│ │
|
||||
│ ████████████░░░░░░░░░░░░░ │ ← Barre de progression totale (15%)
|
||||
│ │
|
||||
│ ⏸ ■ ⏭ │ ← Boutons discrets (15%) — Pause/Stop/Skip
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
Props obligatoires du composant :
|
||||
```typescript
|
||||
interface TimerDisplayProps {
|
||||
state: TimerState
|
||||
exerciseName: string
|
||||
nextExerciseName: string
|
||||
onPause: () => void
|
||||
onStop: () => void
|
||||
onSkip: () => void
|
||||
}
|
||||
```
|
||||
|
||||
### Animations requises
|
||||
- **Fond** : transition de couleur animée entre phases (600ms ease-in-out)
|
||||
- `Animated.timing` sur la backgroundColor avec interpolation de couleurs
|
||||
- **Chiffre** : légère pulsation à chaque seconde (`Animated.sequence` scale 1→1.05→1)
|
||||
- **Anneau** : `Animated.Value` sur stroke-dashoffset (SVG) ou rotation
|
||||
- **Décompte final** : flash rouge sur les 3 dernières secondes
|
||||
|
||||
```typescript
|
||||
// Interpolation de couleur entre phases
|
||||
const backgroundAnim = useRef(new Animated.Value(0)).current
|
||||
|
||||
const backgroundColor = backgroundAnim.interpolate({
|
||||
inputRange: [0, 1, 2, 3],
|
||||
outputRange: [
|
||||
PHASE_COLORS.IDLE,
|
||||
PHASE_COLORS.GET_READY,
|
||||
PHASE_COLORS.WORK,
|
||||
PHASE_COLORS.REST,
|
||||
],
|
||||
})
|
||||
|
||||
// Déclencher quand la phase change
|
||||
useEffect(() => {
|
||||
const phaseIndex = { IDLE: 0, GET_READY: 1, WORK: 2, REST: 3, COMPLETE: 0 }
|
||||
Animated.timing(backgroundAnim, {
|
||||
toValue: phaseIndex[phase],
|
||||
duration: 600,
|
||||
useNativeDriver: false, // obligatoire pour backgroundColor
|
||||
}).start()
|
||||
}, [phase])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## expo-keep-awake — écran allumé pendant la séance
|
||||
|
||||
```typescript
|
||||
import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake'
|
||||
|
||||
// Dans useTimerEngine
|
||||
useEffect(() => {
|
||||
if (isRunning) {
|
||||
activateKeepAwakeAsync('tabata-session')
|
||||
} else {
|
||||
deactivateKeepAwake('tabata-session')
|
||||
}
|
||||
}, [isRunning])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tests minimaux à écrire
|
||||
|
||||
```typescript
|
||||
describe('useTimerEngine', () => {
|
||||
it('démarre en phase IDLE avec la config par défaut', () => {})
|
||||
it('passe à GET_READY quand start() est appelé', () => {})
|
||||
it('passe à WORK après la fin de GET_READY', () => {})
|
||||
it('passe à REST après la fin de WORK', () => {})
|
||||
it('incrémente currentRound après chaque REST', () => {})
|
||||
it('passe à COMPLETE après le dernier round', () => {})
|
||||
it('retourne à IDLE après stop()', () => {})
|
||||
it('skip() avance immédiatement à la phase suivante', () => {})
|
||||
it('émet PHASE_CHANGED à chaque transition', () => {})
|
||||
it('émet COUNTDOWN_TICK pour 3, 2, 1', () => {})
|
||||
it('cleanup useEffect enlève tous les listeners', () => {})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Erreurs classiques à éviter
|
||||
|
||||
```typescript
|
||||
// ❌ Drift — setInterval seul
|
||||
setInterval(() => setSecondsLeft(prev => prev - 1), 1000)
|
||||
|
||||
// ✅ Delta sur Date.now()
|
||||
const remaining = Math.max(0, targetEndTimeRef.current - Date.now())
|
||||
setSecondsLeft(Math.ceil(remaining / 1000))
|
||||
|
||||
// ❌ State React pour les valeurs temps-critique
|
||||
const [targetEndTime, setTargetEndTime] = useState(0)
|
||||
|
||||
// ✅ Ref pour les valeurs utilisées dans les closures de timeout
|
||||
const targetEndTimeRef = useRef(0)
|
||||
|
||||
// ❌ Pas de cleanup — memory leak
|
||||
useEffect(() => {
|
||||
setTimeout(tick, 100)
|
||||
})
|
||||
|
||||
// ✅ Cleanup systématique
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (tickRef.current) clearTimeout(tickRef.current)
|
||||
}
|
||||
}, [])
|
||||
```
|
||||
233
.claude/skills/workflow/SKILL.md
Normal file
233
.claude/skills/workflow/SKILL.md
Normal file
@@ -0,0 +1,233 @@
|
||||
# Skill — Workflow de Développement TabataGo
|
||||
> Lis ce skill en début de session ou quand tu commences une nouvelle feature.
|
||||
|
||||
## Principe fondamental
|
||||
**Plan d'abord, code ensuite.** Pour toute feature non triviale :
|
||||
1. Analyse les specs dans le PRD (`docs/PRD.md`)
|
||||
2. Liste les edge cases et les dépendances
|
||||
3. Propose l'architecture (types, hooks, composants)
|
||||
4. Attends validation avant de générer du code
|
||||
|
||||
Ne jamais écrire de code avant d'avoir une architecture approuvée
|
||||
sur les features cœur (timer, audio, exercices).
|
||||
|
||||
---
|
||||
|
||||
## Cycle de développement par feature
|
||||
|
||||
### Étape 1 — Lire les specs
|
||||
Avant toute implémentation, lis la section PRD correspondante.
|
||||
Identifie exactement :
|
||||
- Les entrées/sorties du système
|
||||
- Les états possibles
|
||||
- Les cas limites (interruptions, background, erreurs)
|
||||
|
||||
### Étape 2 — Typer en premier
|
||||
Commence TOUJOURS par le fichier `types.ts` de la feature.
|
||||
Les types sont le contrat — ils précèdent l'implémentation.
|
||||
|
||||
```typescript
|
||||
// Exemple pour le timer — types.ts
|
||||
export type TimerPhase = 'IDLE' | 'GET_READY' | 'WORK' | 'REST' | 'COMPLETE'
|
||||
|
||||
export interface TimerState {
|
||||
phase: TimerPhase
|
||||
secondsLeft: number
|
||||
currentRound: number
|
||||
totalRounds: number
|
||||
isRunning: boolean
|
||||
}
|
||||
|
||||
export interface TimerConfig {
|
||||
workDuration: number // secondes, défaut 20
|
||||
restDuration: number // secondes, défaut 10
|
||||
rounds: number // défaut 8
|
||||
getReadyDuration: number // secondes, défaut 10
|
||||
}
|
||||
|
||||
export interface TimerActions {
|
||||
start: () => void
|
||||
pause: () => void
|
||||
resume: () => void
|
||||
stop: () => void
|
||||
skip: () => void
|
||||
}
|
||||
```
|
||||
|
||||
**Ne passe à l'étape 3 que si les types semblent corrects.**
|
||||
|
||||
### Étape 3 — Implémenter le hook (logique métier)
|
||||
Implémenter le hook central de la feature.
|
||||
Règles :
|
||||
- Aucun import depuis React Native UI dans les hooks métier
|
||||
- Chaque hook expose un état + des actions (pattern Zustand-like)
|
||||
- Les side effects sont dans useEffect avec cleanup systématique
|
||||
- Logger les transitions d'état en dev (`__DEV__ && console.log(...)`)
|
||||
|
||||
### Étape 4 — Implémenter les composants
|
||||
Les composants reçoivent tout par props — ils ne fetchent rien,
|
||||
ne font aucune logique métier.
|
||||
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
function TimerDisplay({ phase, secondsLeft, currentRound }: TimerDisplayProps) {
|
||||
return <Text>{secondsLeft}</Text>
|
||||
}
|
||||
|
||||
// ❌ Interdit
|
||||
function TimerDisplay() {
|
||||
const timer = useTimerEngine() // logique dans le composant
|
||||
return <Text>{timer.secondsLeft}</Text>
|
||||
}
|
||||
```
|
||||
|
||||
### Étape 5 — Tester avant de continuer
|
||||
Avant de passer à la feature suivante, écrire les tests minimaux :
|
||||
- État initial correct
|
||||
- Transitions de phase correctes
|
||||
- Cleanup des side effects (pas de memory leak)
|
||||
|
||||
---
|
||||
|
||||
## Patterns obligatoires
|
||||
|
||||
### Gestion d'état avec state machine explicite
|
||||
Toujours utiliser une state machine pour les flows complexes.
|
||||
Jamais de booléens en cascade (`isRunning && !isPaused && !isComplete`).
|
||||
|
||||
```typescript
|
||||
// ✅ State machine explicite
|
||||
type TimerPhase = 'IDLE' | 'GET_READY' | 'WORK' | 'REST' | 'COMPLETE'
|
||||
|
||||
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)
|
||||
}
|
||||
```
|
||||
|
||||
### Cleanup systématique des useEffect
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener('change', handleAppState)
|
||||
return () => subscription.remove() // TOUJOURS un cleanup
|
||||
}, [])
|
||||
```
|
||||
|
||||
### AsyncStorage — wrapper typé uniquement
|
||||
Ne jamais appeler AsyncStorage directement dans les composants.
|
||||
Passer par un hook ou un service :
|
||||
|
||||
```typescript
|
||||
// shared/utils/storage.ts
|
||||
export async function saveTimerConfig(config: TimerConfig): Promise<void> {
|
||||
await AsyncStorage.setItem('timer_config', JSON.stringify(config))
|
||||
}
|
||||
|
||||
export async function loadTimerConfig(): Promise<TimerConfig | null> {
|
||||
const raw = await AsyncStorage.getItem('timer_config')
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}
|
||||
```
|
||||
|
||||
### Constantes centralisées
|
||||
```typescript
|
||||
// shared/constants/timer.ts
|
||||
export const TIMER_DEFAULTS = {
|
||||
WORK_DURATION: 20,
|
||||
REST_DURATION: 10,
|
||||
ROUNDS: 8,
|
||||
GET_READY_DURATION: 10,
|
||||
} as const
|
||||
|
||||
// shared/constants/colors.ts
|
||||
export const PHASE_COLORS = {
|
||||
GET_READY: '#EAB308', // Jaune
|
||||
WORK: '#F97316', // Orange
|
||||
REST: '#3B82F6', // Bleu
|
||||
COMPLETE: '#22C55E', // Vert
|
||||
IDLE: '#1E1E2E', // Dark
|
||||
} as const
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Gestion des erreurs
|
||||
|
||||
### Pattern try/catch dans les hooks async
|
||||
```typescript
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
const data = await fetchSomething()
|
||||
setData(data)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Erreur inconnue')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Fallback dans les composants
|
||||
Tout composant qui charge des ressources (GIF, audio) doit avoir
|
||||
un état de fallback visible et non-bloquant.
|
||||
|
||||
---
|
||||
|
||||
## Checklist avant de soumettre du code
|
||||
|
||||
Avant de considérer une feature terminée, vérifier :
|
||||
|
||||
- [ ] Les types sont définis dans `types.ts`
|
||||
- [ ] La logique est dans des hooks, pas dans des composants
|
||||
- [ ] Tous les `useEffect` ont un cleanup
|
||||
- [ ] Aucun `console.log` en dehors de `if (__DEV__)`
|
||||
- [ ] Les constantes sont dans `shared/constants/`
|
||||
- [ ] Les fonctions async ont un try/catch
|
||||
- [ ] Un test minimal existe pour les cas principaux
|
||||
- [ ] Le barrel export `index.ts` est mis à jour
|
||||
|
||||
---
|
||||
|
||||
## Workflow de debugging avec les logs Expo
|
||||
|
||||
Pour surveiller les logs en temps réel et donner du contexte à Claude :
|
||||
|
||||
```bash
|
||||
# Terminal 1 — démarrer expo avec logs dans un fichier
|
||||
npx expo start 2>&1 | tee .expo-logs/dev.log
|
||||
|
||||
# Ensuite dans Claude Code
|
||||
> Surveille .expo-logs/dev.log et identifie les warnings
|
||||
liés au module [nom du module en cours]
|
||||
```
|
||||
|
||||
Ajouter des logs structurés dans les hooks critiques :
|
||||
```typescript
|
||||
if (__DEV__) {
|
||||
console.log('[TimerEngine]', { phase, secondsLeft, currentRound })
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commandes Claude Code utiles
|
||||
|
||||
| Situation | Commande |
|
||||
|---|---|
|
||||
| Début de projet / onboarding | `/init` |
|
||||
| Session longue (>45 min) | `/compact` — focus sur la feature en cours |
|
||||
| Avant chaque commit | `/review` |
|
||||
| Quand Claude part en vrille | Donner un fichier précis : `@src/features/timer/hooks/useTimerEngine.ts` |
|
||||
| Architecture à valider | Demander explicitement "ne génère pas de code, propose seulement l'architecture" |
|
||||
123
CLAUDE.md
Normal file
123
CLAUDE.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# TabataGo — CLAUDE.md
|
||||
> Lis ce fichier EN ENTIER avant chaque session. C'est la constitution du projet.
|
||||
|
||||
## Projet
|
||||
Application mobile Tabata (iOS + Android) construite avec Expo SDK 52 + Expo Router v3.
|
||||
Modèle freemium via RevenueCat. Analytics via PostHog. Offline-first.
|
||||
|
||||
## Environnement de développement
|
||||
**Test sur device physique via Expo Go** — pas de simulateur iOS.
|
||||
- Scanner le QR code affiché par `npx expo start` avec l'app Expo Go sur le téléphone
|
||||
- Le téléphone et le Mac doivent être sur le **même réseau Wi-Fi**
|
||||
- En cas de problème réseau : `npx expo start --tunnel` (utilise ngrok, plus lent mais fiable)
|
||||
|
||||
### Contraintes Expo Go à respecter
|
||||
- **Pas de modules natifs custom** — uniquement les modules inclus dans le SDK Expo Go
|
||||
- `react-native-purchases` (RevenueCat) **ne fonctionne PAS dans Expo Go** → mocker le paywall en dev, tester sur build EAS uniquement
|
||||
- `expo-notifications` fonctionne partiellement dans Expo Go → tester les notifications sur build EAS dev
|
||||
- Modules **compatibles Expo Go SDK 52** (utilisables sans build) :
|
||||
`expo-av`, `expo-haptics`, `expo-keep-awake`, `expo-router`, `expo-secure-store`,
|
||||
`@react-native-async-storage/async-storage`, `expo-localization`, `react-native-posthog`
|
||||
|
||||
### Workflow de test
|
||||
1. **Expo Go** → développement UI, timer, audio, exercices (90% du dev)
|
||||
2. **EAS Build (Development)** → tester RevenueCat, notifications avancées
|
||||
3. **EAS Build (Production)** → build final App Store / Play Store
|
||||
|
||||
## Stack technique — règles non-négociables
|
||||
| Domaine | Solution | Interdit |
|
||||
|---|---|---|
|
||||
| Framework | Expo SDK 52 (managed workflow) | Bare workflow sauf si absolument nécessaire |
|
||||
| Navigation | Expo Router v3 (file-based) | React Navigation seul |
|
||||
| State global | Zustand + AsyncStorage | Redux, MobX, Context pour state global |
|
||||
| Timer engine | Date.now() delta dans un hook isolé | setInterval seul (drift inacceptable) |
|
||||
| Audio | expo-av avec AVAudioSession | expo-audio (instable), react-native-sound |
|
||||
| Paiements | react-native-purchases (RevenueCat) | IAP natif direct — **MOCKER en dev Expo Go** |
|
||||
| Analytics | react-native-posthog | Firebase Analytics, Amplitude |
|
||||
| Styles | StyleSheet.create() | Styles inline, Tailwind, NativeWind |
|
||||
| Types | TypeScript strict (`"strict": true`) | any, @ts-ignore sauf justification |
|
||||
|
||||
## Architecture — structure de fichiers
|
||||
```
|
||||
src/
|
||||
features/
|
||||
timer/
|
||||
hooks/useTimerEngine.ts ← moteur central, AUCUN JSX
|
||||
hooks/useTimerSync.ts ← synchronise audio + exercice sur les events timer
|
||||
components/TimerDisplay.tsx ← affichage uniquement, reçoit tout par props
|
||||
components/TimerControls.tsx ← boutons start/pause/stop/skip
|
||||
types.ts ← TimerPhase, TimerState, TimerConfig
|
||||
audio/
|
||||
hooks/useAudioEngine.ts ← gestion expo-av, preload, crossfade
|
||||
hooks/useAudioSettings.ts ← préférences utilisateur (ambiance, volume)
|
||||
data/tracks.ts ← catalogue des tracks (offline)
|
||||
data/sounds.ts ← catalogue des signaux de phase
|
||||
types.ts
|
||||
exercises/
|
||||
hooks/useExercise.ts ← sélection, navigation entre exercices
|
||||
components/ExerciseDisplay.tsx ← rendu selon la phase courante
|
||||
components/ExerciseGif.tsx ← affichage GIF avec fallback
|
||||
data/exercises.ts ← les 38 exercices (offline)
|
||||
types.ts
|
||||
programs/
|
||||
hooks/useProgram.ts
|
||||
components/ProgramBuilder.tsx
|
||||
data/defaultPrograms.ts
|
||||
onboarding/
|
||||
hooks/useOnboarding.ts
|
||||
screens/ ← 6 écrans (1 fichier par écran)
|
||||
paywall/
|
||||
hooks/usePaywall.ts ← RevenueCat wrapper
|
||||
components/PaywallScreen.tsx
|
||||
shared/
|
||||
components/ ← Button, Card, Typography, etc.
|
||||
hooks/ ← useHaptics, useKeepAwake, useAppState
|
||||
utils/ ← formatTime, etc.
|
||||
constants/ ← couleurs, durées par défaut
|
||||
app/ ← Expo Router (fichiers de route uniquement)
|
||||
(tabs)/
|
||||
index.tsx ← Home
|
||||
history.tsx
|
||||
settings.tsx
|
||||
timer/index.tsx
|
||||
programs/[id].tsx
|
||||
onboarding/index.tsx
|
||||
paywall/index.tsx
|
||||
```
|
||||
|
||||
## Règles de code
|
||||
1. **Un fichier = une responsabilité** — si un hook dépasse 150 lignes, il doit être découpé
|
||||
2. **Toute logique métier dans les hooks** — les composants n'ont QUE du JSX + appel de hooks
|
||||
3. **Barrel exports** — chaque feature expose un `index.ts` propre
|
||||
4. **Tests colocalisés** — `ComponentName.test.tsx` dans le même dossier
|
||||
5. **Pas d'effet secondaire dans le render** — tout dans useEffect ou les handlers
|
||||
6. **Typage strict des events** — pas de `any` dans les callbacks de timer/audio
|
||||
|
||||
## Commandes du projet
|
||||
```bash
|
||||
npx expo start # dev — scanner le QR avec Expo Go
|
||||
npx expo start --tunnel # si problème réseau Wi-Fi (ngrok)
|
||||
npx expo start --clear # vider le cache Metro si comportement bizarre
|
||||
jest --watchAll # tests en continu
|
||||
eas build --profile development # build dev (pour tester RevenueCat, notifs)
|
||||
eas build --platform ios # build TestFlight
|
||||
npx expo export # bundle production
|
||||
```
|
||||
|
||||
## Comment utiliser les skills
|
||||
|
||||
Quand tu travailles sur une feature spécifique, lis le skill correspondant :
|
||||
- Timer → `.claude/skills/timer/SKILL.md`
|
||||
- Audio → `.claude/skills/audio/SKILL.md`
|
||||
- Exercices → `.claude/skills/exercises/SKILL.md`
|
||||
- Workflow général → `.claude/skills/workflow/SKILL.md`
|
||||
|
||||
## Priorité d'implémentation V1
|
||||
1. `useTimerEngine` + `TimerDisplay` (plein écran)
|
||||
2. `useAudioEngine` + synchronisation avec timer
|
||||
3. `ExerciseDisplay` + données des 38 exercices
|
||||
4. Synchronisation triple timer × audio × exercice
|
||||
5. Onboarding 6 écrans + mini-démo live
|
||||
6. Paywall RevenueCat
|
||||
7. Streak + historique
|
||||
8. Notifications de rappel
|
||||
597
TabataGo_PRD_v1.1.md
Normal file
597
TabataGo_PRD_v1.1.md
Normal file
@@ -0,0 +1,597 @@
|
||||
|
||||
|
||||
| 🔥 TABATAGO Application Mobile de Minuterie Tabata *Product Requirements Document (PRD) v1.1* Framework : Expo (React Native) · Cible : iOS & Android · Février 2026 |
|
||||
| :---: |
|
||||
|
||||
# **1\. Résumé Exécutif**
|
||||
|
||||
TabataGo est une application mobile premium dédiée à l'entraînement par intervalles en méthode Tabata (20 secondes d'effort / 10 secondes de repos). Elle cible les personnes qui veulent des séances courtes, intenses et guidées — sans abonnement à une salle. Le marché des applications de fitness dépasse 1,5 milliard de dollars en revenus annuels et le Tabata reste un mot-clé à forte demande (popularité \> 70\) avec une difficulté ASO modérée (\< 50).
|
||||
|
||||
| Indicateur | Valeur cible |
|
||||
| :---- | :---- |
|
||||
| Marché visé | Fitness enthousiasts 25-45 ans, Home workout, HIIT lovers |
|
||||
| Plateformes | iOS (priorité) \+ Android |
|
||||
| Framework | Expo (React Native) — SDK 52+ |
|
||||
| Modèle économique | Freemium \+ Abonnement mensuel/annuel via RevenueCat |
|
||||
| Prix cible (US) | $4.99/mois ou $29.99/an |
|
||||
| Objectif J+30 | 5 000 téléchargements, taux de conversion essai \> 30% |
|
||||
| Objectif J+90 | MRR $5 000, note App Store ≥ 4.6 |
|
||||
|
||||
# **2\. Fonctionnalités Core — Spécifications Détaillées**
|
||||
|
||||
TabataGo repose sur trois piliers fonctionnels indissociables : le Timer (moteur de l'expérience), la Musique (moteur émotionnel), et l'Exercice (moteur pédagogique). Ces trois composants doivent fonctionner en parfaite synchronisation.
|
||||
|
||||
## **2.1 Le Timer — Moteur Central**
|
||||
|
||||
Le timer est la fonctionnalité principale et vitale de l'application. Son implémentation doit être irréprochable : précision, fluidité visuelle, comportement en arrière-plan.
|
||||
|
||||
### **2.1.1 Structure d'une Séance Tabata Standard**
|
||||
|
||||
| Phase | Durée par défaut | Configurable ? | Couleur écran | Signal |
|
||||
| :---- | :---- | :---- | :---- | :---- |
|
||||
| Préparation (Get Ready) | 10 secondes | Oui (5–30s) | Jaune \#EAB308 | Bip court × 3 \+ vibration légère |
|
||||
| Travail (Work) | 20 secondes | Oui (5–60s) | Orange vif \#F97316 | Bip long au démarrage \+ haptique fort |
|
||||
| Repos (Rest) | 10 secondes | Oui (5–60s) | Bleu calme \#3B82F6 | Double bip court \+ haptique léger |
|
||||
| Fin de round | Instantané | — | Flash blanc | Son de cloche \+ vibration |
|
||||
| Fin de séance | Écran résultat | — | Vert \#22C55E | Fanfare \+ longue vibration |
|
||||
|
||||
### **2.1.2 Paramètres Configurables du Timer**
|
||||
|
||||
| Paramètre | Valeur par défaut | Min | Max | Accès |
|
||||
| :---- | :---- | :---- | :---- | :---- |
|
||||
| Durée Work | 20s | 5s | 60s | Tous |
|
||||
| Durée Rest | 10s | 5s | 60s | Tous |
|
||||
| Nombre de rounds | 8 | 1 | 30 | Tous |
|
||||
| Durée Get Ready | 10s | 0s | 30s | Tous |
|
||||
| Nombre de cycles | 1 | 1 | 10 | Premium |
|
||||
| Pause entre cycles | 60s | 10s | 300s | Premium |
|
||||
| Cycles de récupération | — | — | — | Premium |
|
||||
|
||||
### **2.1.3 Affichage Timer — Écran Séance (plein écran)**
|
||||
|
||||
| 📱 LAYOUT — Écran Timer Plein Écran |
|
||||
| :---- |
|
||||
| ▸ Zone HAUTE (20%) : Nom de l'exercice en cours \+ numéro de round (ex: "Burpees — Round 3/8") |
|
||||
| ▸ Zone CENTRALE (50%) : Chiffre du compte à rebours — très grand (96-120px), police monospace, couleur de phase |
|
||||
| ▸ Zone BASSE HAUTE (15%) : Barre de progression de la séance complète (rounds) \+ indicateur phase actuelle |
|
||||
| ▸ Zone BASSE (15%) : Boutons Pause / Stop / Skip — discrets pour ne pas distraire |
|
||||
| ▸ FOND : Couleur dynamique selon la phase (orange work, bleu rest, jaune prep) avec transition animée |
|
||||
| ▸ ANIMATION : Pulsation subtile du chiffre à chaque seconde \+ ring circulaire de progression |
|
||||
|
||||
### **2.1.4 Comportement Technique du Timer**
|
||||
|
||||
* Précision : Utiliser expo-background-fetch \+ Date.now() delta pour compenser les drifts — tolérance \< 50ms
|
||||
|
||||
* Background : Le timer continue en arrière-plan (notification sticky affichant le compte à rebours)
|
||||
|
||||
* Verrouillage écran : Écran reste allumé pendant la séance (expo-keep-awake)
|
||||
|
||||
* Interruptions : Pause automatique si appel téléphonique entrant (AppState listener)
|
||||
|
||||
* Reprise : Si l'app est tuée, afficher une notification "Séance interrompue — Reprendre ?" au retour
|
||||
|
||||
* OTA update safe : Le state du timer est isolé du cycle de render React pour éviter les glitches
|
||||
|
||||
## **2.2 La Musique — Moteur Émotionnel**
|
||||
|
||||
La musique transforme une simple minuterie en expérience motivante. Elle doit s'adapter dynamiquement à chaque phase de la séance (work vs rest) et ne jamais entrer en conflit avec la musique de l'utilisateur ou les signaux sonores.
|
||||
|
||||
### **2.2.1 Architecture Sonore**
|
||||
|
||||
| Couche audio | Description | Technologie | Contrôle utilisateur |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| Musique d'ambiance | Tracks BPM-synchronisées intégrées à l'app | expo-av (AVAudioSession) | Volume indépendant, on/off |
|
||||
| Signaux de phase | Bips, voix, cloche — changement work/rest | expo-av (priorité haute) | Volume indépendant, choix du son |
|
||||
| Voix coach | Annonces vocales (optionnel) : "Go\!", "Rest", "Last round\!" | expo-av (TTS ou fichiers pré-enregistrés) | On/off, langue |
|
||||
| Haptiques | Vibrations synchronisées aux signaux | expo-haptics | On/off |
|
||||
|
||||
### **2.2.2 Catalogue Musical Intégré (Offline)**
|
||||
|
||||
| 🎵 TRACKS INTÉGRÉES — 3 ambiances × 3 intensités \= 9 tracks minimum |
|
||||
| :---- |
|
||||
| ▸ Ambiance ELECTRO : Low (récup), Medium (standard), High (intense) — BPM 120/140/160 |
|
||||
| ▸ Ambiance HIP-HOP : Low (récup), Medium (standard), High (intense) — BPM 85/100/115 |
|
||||
| ▸ Ambiance ROCK/METAL : Low (récup), Medium (standard), High (intense) — BPM 130/150/170 |
|
||||
| ▸ Mode SILENCE : Aucune musique, uniquement les signaux sonores de phase |
|
||||
| ▸ Mode SPOTIFY/APPLE MUSIC : L'app n'interfère pas avec la musique de l'utilisateur (coexistence audio) |
|
||||
| ▸ Toutes les tracks sont royalty-free et embarquées dans le bundle — aucun streaming requis |
|
||||
|
||||
### **2.2.3 Synchronisation Musique ↔ Timer**
|
||||
|
||||
* Phase WORK : Passer automatiquement à la track haute intensité de l'ambiance sélectionnée
|
||||
|
||||
* Phase REST : Transition en fade-out 1s vers la track basse intensité (ambiance calme)
|
||||
|
||||
* Phase GET READY : Intro de 10s sur la track principale
|
||||
|
||||
* Transition douce : Cross-fade 500ms entre les phases pour éviter les coupures brutales
|
||||
|
||||
* BPM adaptatif (Premium) : L'app détecte le rythme de la track et aligne le bip de fin de phase sur le beat
|
||||
|
||||
* Pas de conflit : Si l'utilisateur a sa propre musique, les signaux de phase s'y superposent en ducking audio (baisse temporaire du volume)
|
||||
|
||||
### **2.2.4 Signaux Sonores de Phase — Options**
|
||||
|
||||
| Signal | Options disponibles | Par défaut | Premium uniquement ? |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| Début Work | Bip long, Whistle, Voix "Go\!", Air horn, Clap | Bip long | Non |
|
||||
| Début Rest | Double bip, Voix "Rest", Bell, Ding | Double bip | Non |
|
||||
| Décompte 3-2-1 | Bips courts, Voix "3, 2, 1", Silence | Bips courts | Non |
|
||||
| Fin de round | Cloche, Applaudissements, Voix "Round X done\!" | Cloche | Oui |
|
||||
| Fin de séance | Fanfare, Applaudissements, Voix "Workout complete\!" | Fanfare | Non |
|
||||
| Dernier round | Voix "Last round\!", Alarm, Son spécial | Voix | Oui |
|
||||
|
||||
### **2.2.5 Gestion Technique Audio (expo-av)**
|
||||
|
||||
* Session audio iOS : AVAudioSessionCategoryPlayback avec MixWithOthers — permet de jouer avec la musique utilisateur
|
||||
|
||||
* Focus audio Android : AudioManager.AUDIOFOCUS\_GAIN\_TRANSIENT\_MAY\_DUCK pour les signaux
|
||||
|
||||
* Préchargement : Tous les sons de phase sont chargés en mémoire au démarrage de la séance (zéro latence)
|
||||
|
||||
* Mode silencieux iOS : Les signaux de phase respectent le switch mute SAUF si l'utilisateur a activé "override" dans les settings
|
||||
|
||||
* Headphones détection : Si écouteurs branchés, désactiver les haptiques de phase par défaut
|
||||
|
||||
## **2.3 L'Exercice — Moteur Pédagogique**
|
||||
|
||||
Chaque round du timer doit être associé à un exercice spécifique, affiché clairement pendant la phase de travail. C'est ce qui différencie TabataGo d'une simple minuterie générique.
|
||||
|
||||
### **2.3.1 Bibliothèque d'Exercices**
|
||||
|
||||
| Catégorie | Exemples d'exercices | Nb exercices V1 | Nb exercices V2 |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| Cardio / Full body | Burpees, Jumping Jacks, Mountain Climbers, High Knees | 8 | 20 |
|
||||
| Bas du corps | Squats, Fentes, Jump Squats, Glute Bridges, Wall Sit | 8 | 20 |
|
||||
| Haut du corps | Push-ups, Pike Push-ups, Tricep Dips, Shoulder Taps | 6 | 15 |
|
||||
| Abdos / Core | Crunches, Planche, Russian Twists, Bicycle Crunches | 6 | 15 |
|
||||
| Sans saut (low impact) | Slow Squats, Modified Push-ups, Step Touch, March | 6 | 15 |
|
||||
| Avec matériel | Kettlebell Swings, Dumbbell Thrusters, Jump Rope | 4 | 10 |
|
||||
|
||||
### **2.3.2 Fiche Exercice — Données par Exercice**
|
||||
|
||||
| 📋 MODÈLE DE DONNÉES — Exercice |
|
||||
| :---- |
|
||||
| ▸ id : identifiant unique (ex: "burpee\_classic") |
|
||||
| ▸ name : Nom localisé (FR: "Burpee", EN: "Burpee", ES: "Burpee") |
|
||||
| ▸ category : cardio | lower\_body | upper\_body | core | low\_impact | equipment |
|
||||
| ▸ difficulty : beginner | intermediate | advanced |
|
||||
| ▸ musclesTargeted : string\[\] (ex: \["quadriceps", "pectoraux", "cardio"\]) |
|
||||
| ▸ description : Instruction courte (max 80 car.) — affichée pendant la phase Rest |
|
||||
| ▸ cues : string\[\] — 2-3 points clés de forme (ex: "Dos droit", "Genoux alignés") |
|
||||
| ▸ gifUrl : Animation GIF courte (1-2s, loop) — 200×200px max — embarquée offline |
|
||||
| ▸ thumbnailUrl : Image statique pour la bibliothèque |
|
||||
| ▸ hasModification : bool — si une variante plus facile existe |
|
||||
| ▸ modificationId : id de l'exercice de remplacement (ex: "burpee\_modified") |
|
||||
| ▸ equipmentNeeded : string\[\] (ex: \[\] pour aucun, \["tapis"\] pour matériel simple) |
|
||||
|
||||
### **2.3.3 Affichage de l'Exercice Pendant la Séance**
|
||||
|
||||
| Phase | Affichage exercice | Taille | Information complémentaire |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| GET READY (10s) | Nom \+ GIF animé de démonstration | Grande — focus total | "Voici l'exercice suivant" — prépare mentalement |
|
||||
| WORK (20s) | Nom en haut \+ compteur central — GIF en petit coin | Nom moyen, timer dominant | 1-2 cues de forme affichés sous le nom |
|
||||
| REST (10s) | Nom de l'EXERCICE SUIVANT \+ vignette | Taille moyenne | "Prochain : \[Nom\]" — anticipation |
|
||||
| FIN DE ROUND | Résumé rapide du round (1s) | Plein écran flash | Round X complété \+ prochain exercice |
|
||||
|
||||
### **2.3.4 Types de Programmes Tabata**
|
||||
|
||||
| 🗂️ PROGRAMMES DISPONIBLES — V1 |
|
||||
| :---- |
|
||||
| ▸ MODE 1 — Exercice unique répété : Le même exercice sur les 8 rounds (ex: 8 rounds de Burpees) — Tabata classique |
|
||||
| ▸ MODE 2 — Circuit 2 exercices : Alternance A/B sur 8 rounds (ex: Squats / Push-ups × 4 répétitions) — Tabata duo |
|
||||
| ▸ MODE 3 — Circuit 4 exercices : 4 exercices × 2 rounds chacun — Tabata circuit (Premium) |
|
||||
| ▸ MODE 4 — Programme libre : L'utilisateur assigne manuellement un exercice à chaque round (Premium) |
|
||||
| ▸ MODE 5 — Programme IA : Sélection automatique selon niveau, objectif et historique (Premium V2) |
|
||||
|
||||
### **2.3.5 Créateur de Programme Personnalisé (Premium)**
|
||||
|
||||
* Interface drag & drop pour assigner les exercices à chaque round
|
||||
|
||||
* Sauvegarde illimitée de programmes personnalisés (nom, description, tags)
|
||||
|
||||
* Partage de programme par lien deep link (ex: tabatago://program/abc123)
|
||||
|
||||
* Import de programme depuis un lien partagé par un autre utilisateur
|
||||
|
||||
* Favoris : marquer des exercices pour les retrouver rapidement
|
||||
|
||||
### **2.3.6 Contenu Offline & Performance**
|
||||
|
||||
* Tous les GIFs d'exercices V1 (38 exercices × 1 GIF ≈ 150KB chacun ≈ \~6MB total) — embarqués dans le bundle
|
||||
|
||||
* Lazy loading pour les exercices V2+ : téléchargement à la demande, mis en cache localement
|
||||
|
||||
* Fallback : Si pas de GIF disponible, afficher une icône \+ description textuelle
|
||||
|
||||
* Accessibilité : alt text sur chaque GIF pour VoiceOver/TalkBack
|
||||
|
||||
## **2.4 Synchronisation Timer × Musique × Exercice**
|
||||
|
||||
Les trois composants doivent former une expérience unifiée et cohérente. Ce tableau décrit les événements et leurs effets croisés :
|
||||
|
||||
| Événement Timer | Effet sur la Musique | Effet sur l'Exercice | Haptique |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| Début GET READY | Fade-in track principale | Afficher GIF exercice Round 1 | Léger |
|
||||
| Décompte 3-2-1 | Volume augmente | Animation pulsation sur le GIF | Bip × 3 |
|
||||
| Début WORK | Switch vers track high BPM | Afficher nom \+ cues en grand | Fort |
|
||||
| Milieu WORK (10s) | Rien | "Halfway\!" en overlay 1s | Aucun |
|
||||
| Fin WORK | Switch vers track low BPM | Afficher exercice SUIVANT | Moyen |
|
||||
| Début REST | Track calme | "Next: \[Exercice\]" \+ vignette | Léger |
|
||||
| Dernier round warning | Effet sonore spécial | Badge "LAST ROUND" sur nom | Vibration longue |
|
||||
| Fin de séance | Fade-out \+ fanfare | Écran résultat avec tous les exercices | Célébration |
|
||||
|
||||
# **3\. Validation du Marché & Stratégie ASO**
|
||||
|
||||
## **3.1 Analyse des Mots-Clés Cibles**
|
||||
|
||||
Les mots-clés suivants ont été validés via Astro (difficulté \< 55, popularité \> 20\) :
|
||||
|
||||
| Mot-clé | Popularité | Difficulté | Marché |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| tabata timer | 72 | 48 | EN 🇺🇸 |
|
||||
| hiit timer app | 68 | 52 | EN 🇺🇸 |
|
||||
| minuterie tabata | 35 | 28 | FR 🇫🇷 |
|
||||
| temporizador tabata | 41 | 31 | ES 🇪🇸 |
|
||||
| tabata training timer | 55 | 44 | EN 🇬🇧 |
|
||||
| intervall timer workout | 38 | 29 | DE 🇩🇪 |
|
||||
|
||||
## **3.2 Analyse Concurrentielle**
|
||||
|
||||
Principaux concurrents identifiés et opportunités de différenciation :
|
||||
|
||||
| Concurrent | Forces | Faiblesses (notre opportunité) |
|
||||
| :---- | :---- | :---- |
|
||||
| Tabata Timer (App Store Top 1\) | Notoriété, simplicité | UI datée, pas de suivi streak, pas de localisation |
|
||||
| Seconds Pro | Très complet, flexible | Trop complexe, prix élevé, courbe d'apprentissage |
|
||||
| HIIT Interval Training Timer | Gratuit, fonctionnel | Pub intrusive, pas d'onboarding émotionnel |
|
||||
| Tabata Stopwatch Pro | Simple, rapide | Pas de personnalisation, no streak, no widget |
|
||||
|
||||
Notre avantage : onboarding émotionnel fort \+ design moderne \+ widgets iOS/Android \+ streaks \+ localisation dans 5 langues.
|
||||
|
||||
# **4\. Onboarding — La Séquence Critique**
|
||||
|
||||
L'onboarding est la priorité absolue : 80% des revenus sont générés ici. L'objectif est de créer un investissement émotionnel avant d'afficher le paywall. La séquence suit le schéma : Problème → Empathie → Solution → Moment Wow → Paywall.
|
||||
|
||||
## **4.1 Écrans d'Onboarding (séquence de 6 écrans)**
|
||||
|
||||
### **Écran 1 — Le Problème (Identifier la douleur)**
|
||||
|
||||
| 🎯 OBJECTIF : Identification |
|
||||
| :---- |
|
||||
| ▸ Titre : "Tu n'as pas 1 heure pour la salle. Personne n'en a." |
|
||||
| ▸ Sous-titre : "Et pourtant tu veux progresser. On a la solution." |
|
||||
| ▸ Visuel : Animation subtile d'une horloge qui se fragmente puis se reconstruit en 20 min |
|
||||
| ▸ CTA : "Montre-moi comment" (bouton orange pleine largeur) |
|
||||
|
||||
### **Écran 2 — L'Empathie (L'utilisateur se sent compris)**
|
||||
|
||||
| 💬 OBJECTIF : Connexion émotionnelle |
|
||||
| :---- |
|
||||
| ▸ Titre : "Qu'est-ce qui t'empêche de t'entraîner ?" |
|
||||
| ▸ Choix interactifs (tap) : Manque de temps / Motivation en berne / Je ne sais pas quoi faire / Je n'ai pas accès à une salle |
|
||||
| ▸ Mécanisme : stocker la réponse → personnaliser le reste de l'onboarding |
|
||||
| ▸ Transition : "On a conçu TabataGo exactement pour ça." |
|
||||
|
||||
### **Écran 3 — La Solution (Présenter la méthode)**
|
||||
|
||||
| ⚡ OBJECTIF : Comprendre la valeur |
|
||||
| :---- |
|
||||
| ▸ Titre : "4 minutes. Vraiment transformatrices." |
|
||||
| ▸ Animation interactive : timeline Tabata (20s work / 10s rest × 8 rounds) |
|
||||
| ▸ Stats affichées : Brûle autant de calories qu'un jogging de 30 min / Prouvé scientifiquement depuis 1996 (Dr. Tabata) |
|
||||
| ▸ Visuel : compteur animé qui tourne — preview de l'app |
|
||||
|
||||
### **Écran 4 — Le Moment "Wow" (Démo interactive)**
|
||||
|
||||
| 🔥 OBJECTIF : Engagement actif (ne pas juste regarder) |
|
||||
| :---- |
|
||||
| ▸ Titre : "Essaie maintenant. 20 secondes." |
|
||||
| ▸ Mini-minuterie Tabata LIVE intégrée dans l'écran d'onboarding |
|
||||
| ▸ L'utilisateur tape sur "Go" et vit 20s de compte à rebours \+ son \+ vibration |
|
||||
| ▸ Après : "Tu viens de faire ta première série Tabata. 7 de plus et c'est une séance complète." |
|
||||
| ▸ Note : Ce moment est le plus différenciant — créer une mini-expérience réelle |
|
||||
|
||||
### **Écran 5 — Personnalisation (Engagement supplémentaire)**
|
||||
|
||||
| ⚙️ OBJECTIF : Investissement personnel |
|
||||
| :---- |
|
||||
| ▸ Titre : "Configurons ta première semaine." |
|
||||
| ▸ Sélection : Niveau (Débutant / Intermédiaire / Avancé) |
|
||||
| ▸ Sélection : Objectif (Perte de poids / Cardio / Force / Bien-être) |
|
||||
| ▸ Sélection : Fréquence souhaitée (2x / 3x / 5x par semaine) |
|
||||
| ▸ Résultat : "Ton programme personnalisé est prêt." (sensation de valeur avant paiement) |
|
||||
|
||||
### **Écran 6 — Paywall (Après l'investissement émotionnel)**
|
||||
|
||||
| 💳 OBJECTIF : Conversion |
|
||||
| :---- |
|
||||
| ▸ Titre : "Continue sur ta lancée. Sans limite." |
|
||||
| ▸ Présenter l'essai gratuit 7 jours en premier (bouton principal orange) |
|
||||
| ▸ Options : Mensuel $4.99 / Annuel $29.99 (économie 50% mise en évidence) |
|
||||
| ▸ Garantie visible : "Annule à tout moment" \+ "Satisfait ou remboursé 30j" |
|
||||
| ▸ Lien "Continuer sans abonnement" en petit en bas (ne pas le cacher) |
|
||||
| ▸ Intégration RevenueCat — avec A/B test activé dès le lancement |
|
||||
|
||||
# **5\. Design & Identité Visuelle**
|
||||
|
||||
## **5.1 Palette de Couleurs**
|
||||
|
||||
| Rôle | Couleur | Hex | Usage |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| Primaire / Action | Orange Tabata | \#F97316 | Boutons CTA, accents, timer actif |
|
||||
| Fond Dark (défaut) | Charcoal Night | \#1E1E2E | Background principal mode sombre |
|
||||
| Fond Light | Warm White | \#FFF7ED | Background mode clair |
|
||||
| Texte principal Dark | Stone 900 | \#1C1917 | Titres mode clair |
|
||||
| Texte secondaire | Stone 600 | \#57534E | Body text, descriptions |
|
||||
| Succès / Streak | Green 500 | \#22C55E | Streaks, complétion, feedback positif |
|
||||
| Danger / Alerte | Red 500 | \#EF4444 | Erreurs, derniers secondes |
|
||||
|
||||
## **5.2 Typographie**
|
||||
|
||||
| Usage | Police | Poids | Taille |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| Timer principal | Inter (monospace fallback) | Black (900) | 96-120px |
|
||||
| Titres H1 | Inter | Bold (700) | 28-32px |
|
||||
| Titres H2 | Inter | SemiBold (600) | 22-24px |
|
||||
| Body / Labels | Inter | Regular (400) | 14-16px |
|
||||
| Micro-labels | Inter | Medium (500) | 11-12px |
|
||||
|
||||
## **5.3 Principes Design**
|
||||
|
||||
* Mode sombre par défaut (immersif pendant l'entraînement)
|
||||
|
||||
* Timer en plein écran pendant la séance — aucune distraction
|
||||
|
||||
* Animations fluides (60fps) pour les transitions et le compteur
|
||||
|
||||
* Haptiques natifs : vibration légère à chaque changement de phase
|
||||
|
||||
* Support Dynamic Type (iOS) et font scaling (Android)
|
||||
|
||||
* Icône app : fond noir, lettre T stylisée en orange avec une flamme
|
||||
|
||||
# **6\. Architecture Technique & Développement (Expo)**
|
||||
|
||||
## **6.1 Stack Technique**
|
||||
|
||||
| Composant | Solution choisie | Justification |
|
||||
| :---- | :---- | :---- |
|
||||
| Framework | Expo SDK 52 (React Native) | Cross-platform, OTA updates, accès natif facile |
|
||||
| Navigation | Expo Router v3 (file-based) | Standard moderne, deep linking natif |
|
||||
| State management | Zustand \+ AsyncStorage | Léger, performant, persistance simple |
|
||||
| Timer engine | expo-background-fetch \+ useInterval custom | Précision \+ exécution background |
|
||||
| Audio | expo-av | Sons de décompte et alertes phase |
|
||||
| Haptiques | expo-haptics | Retour tactile natif iOS/Android |
|
||||
| Notifications | expo-notifications | Rappels d'entraînement \+ streaks |
|
||||
| Widget | react-native-widget-extension | Widget iOS 14+ (Live Activity) |
|
||||
| Paiements | react-native-purchases (RevenueCat) | Abonnements \+ A/B testing \+ analytics |
|
||||
| Analytics | PostHog (react-native-posthog) | Funnel tracking, drop-off analysis |
|
||||
| Storage | expo-secure-store \+ AsyncStorage | Données utilisateur \+ préférences |
|
||||
| In-App Review | expo-store-review | Prompt après streak 7 jours |
|
||||
|
||||
## **6.2 Features — MoSCoW Priorisation**
|
||||
|
||||
### **Must Have (V1 — Lancement)**
|
||||
|
||||
| ✅ MUST HAVE — Indispensables au lancement |
|
||||
| :---- |
|
||||
| ▸ Timer Tabata complet — voir Section 2.1 (toutes phases, sons, haptiques, background) |
|
||||
| ▸ Musique intégrée — voir Section 2.2 (3 ambiances × 3 intensités, signaux de phase, coexistence audio) |
|
||||
| ▸ Exercices avec GIFs — voir Section 2.3 (38 exercices, modes 1 et 2, affichage GET READY/WORK/REST) |
|
||||
| ▸ Synchronisation Timer × Musique × Exercice — voir Section 2.4 |
|
||||
| ▸ Modes : Tabata classique, HIIT personnalisé, Pause active |
|
||||
| ▸ Affichage plein écran pendant la séance (mode portrait \+ paysage) |
|
||||
| ▸ Historique des séances (date, durée, rounds complétés) |
|
||||
| ▸ Système de Streak (consécutivité quotidienne, animation de feu) |
|
||||
| ▸ Notifications de rappel configurables |
|
||||
| ▸ Onboarding 6 écrans avec mini-démo live |
|
||||
| ▸ Paywall \+ RevenueCat (essai 7j, mensuel, annuel) |
|
||||
| ▸ Dark mode \+ Light mode |
|
||||
| ▸ Localisation : EN, FR, ES, DE, PT |
|
||||
|
||||
### **Should Have (V1.1 — Semaine 4-8)**
|
||||
|
||||
| 🟡 SHOULD HAVE — Valeur ajoutée forte |
|
||||
| :---- |
|
||||
| ▸ Widget iOS (Home Screen) — affiche le streak et le dernier entraînement |
|
||||
| ▸ Widget Android (Glance API) |
|
||||
| ▸ Bibliothèque de programmes pré-définis (Débutant / Cardio / Force) |
|
||||
| ▸ Statistiques hebdomadaires et mensuelles avec graphiques |
|
||||
| ▸ Sons personnalisés (voix, bip, musique d'ambiance) |
|
||||
| ▸ Integration Apple Health / Google Fit (calories, activité) |
|
||||
| ▸ Partage social (carte récapitulative de la séance) |
|
||||
|
||||
### **Could Have (V2 — Mois 3+)**
|
||||
|
||||
| 🔵 COULD HAVE — Différenciation long terme |
|
||||
| :---- |
|
||||
| ▸ Mode Coach IA : suggestions de séances basées sur l'historique |
|
||||
| ▸ Challenges communautaires (leaderboard hebdomadaire) |
|
||||
| ▸ Apple Watch companion app |
|
||||
| ▸ Import/Export de programmes (partage entre utilisateurs) |
|
||||
| ▸ Mode TV / AirPlay pour entraînement sur grand écran |
|
||||
|
||||
# **7\. Structure de Navigation (Expo Router)**
|
||||
|
||||
Architecture file-based avec Expo Router v3 :
|
||||
|
||||
| Route | Écran | Accès |
|
||||
| :---- | :---- | :---- |
|
||||
| / | Home — Hub central avec raccourcis et streak | Tous |
|
||||
| /onboarding | Séquence onboarding 6 étapes | Nouveaux utilisateurs |
|
||||
| /timer | Minuterie plein écran — séance active | Tous |
|
||||
| /programs | Bibliothèque de programmes | Premium |
|
||||
| /history | Historique des séances \+ stats | Tous (limité free) |
|
||||
| /settings | Préférences, sons, notifications, compte | Tous |
|
||||
| /paywall | Écran d'abonnement RevenueCat | Free users |
|
||||
| /(modals)/review | In-App Review prompt | Streak 7j |
|
||||
|
||||
# **8\. Paiements, Pricing & RevenueCat**
|
||||
|
||||
## **8.1 Structure des Offres**
|
||||
|
||||
| Offre | Prix US | Prix FR | Prix BR | Contenu |
|
||||
| :---- | :---- | :---- | :---- | :---- |
|
||||
| Gratuit | Free | Free | Free | Timer standard, 7j historique, pas de widget |
|
||||
| Essai Premium | 7 jours gratuits | 7 jours gratuits | 7 jours gratuits | Accès complet, pas de CB requise si possible |
|
||||
| Premium Mensuel | $4.99/mois | €4.99/mois | R$9.99/mois | Accès complet illimité |
|
||||
| Premium Annuel | $29.99/an | €27.99/an | R$59.99/an | Tout Premium — économie 50% mise en avant |
|
||||
|
||||
Note : Pricing localisé via RevenueCat Purchasing Power Parity (PPP). Adapter automatiquement les prix pour le Brésil, l'Inde, l'Indonésie et l'Europe de l'Est pour maximiser les conversions mondiales.
|
||||
|
||||
## **8.2 Configuration RevenueCat**
|
||||
|
||||
* Entitlements : "premium" (accès à toutes les features payantes)
|
||||
|
||||
* Products : tabatago\_monthly, tabatago\_annual, tabatago\_trial\_7d
|
||||
|
||||
* A/B Tests dès J+7 : tester $3.99 vs $4.99 vs $6.99 mensuel
|
||||
|
||||
* A/B Tests paywall : liste vs comparaison de plans vs "best value" badge
|
||||
|
||||
* Webhook RevenueCat → PostHog pour corréler revenus et comportement
|
||||
|
||||
# **9\. Analytics & Instrumentation (PostHog)**
|
||||
|
||||
## **9.1 Événements Critiques à Tracker**
|
||||
|
||||
| Événement | Propriétés | Objectif |
|
||||
| :---- | :---- | :---- |
|
||||
| onboarding\_step\_viewed | step\_number, step\_name | Identifier le drop-off |
|
||||
| onboarding\_demo\_started | duration\_seconds | Mesurer l'engagement Wow |
|
||||
| onboarding\_completed | persona, goal, frequency | Segmentation |
|
||||
| paywall\_viewed | source, variant | Funnel paiement |
|
||||
| trial\_started | plan\_selected | Conversion essai |
|
||||
| subscription\_purchased | plan, price, currency | Revenue |
|
||||
| session\_started | program\_id, rounds, duration | Engagement produit |
|
||||
| session\_completed | rounds\_done, streak\_day | Rétention |
|
||||
| streak\_milestone | days\_count | Gamification KPIs |
|
||||
| widget\_added | widget\_type | Stickiness |
|
||||
| notification\_tapped | notification\_type | Rappels efficacité |
|
||||
|
||||
## **9.2 Funnels à Monitorer**
|
||||
|
||||
* Funnel Acquisition : Impression App Store → Téléchargement → Onboarding Start → Onboarding Complete → Paywall View → Trial Start → Purchase
|
||||
|
||||
* Funnel Rétention : J+1 / J+7 / J+30 ouverture de l'app après téléchargement
|
||||
|
||||
* Funnel Engagement : Séance démarrée → Séance complétée → Streak maintenu
|
||||
|
||||
Règle d'or : Si impressions mais pas de téléchargements → Réparer screenshots/icône. Si téléchargements mais pas d'essais → Réparer onboarding. Si essais mais pas de paiements → Réparer paywall/pricing.
|
||||
|
||||
# **10\. Localisation & Growth Hacking**
|
||||
|
||||
## **10.1 Stratégie de Localisation (5 Langues)**
|
||||
|
||||
| Langue | Marché cible | Mots-clés spécifiques | Priorité |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| Anglais (EN) | US, UK, AU, CA | tabata timer, hiit timer app | 1 — Lancement |
|
||||
| Français (FR) | France, Belgique, Québec | minuterie tabata, chrono hiit | 1 — Lancement |
|
||||
| Espagnol (ES) | Espagne, Mexique, Argentine | temporizador tabata, ejercicio hiit | 2 — Semaine 2 |
|
||||
| Allemand (DE) | Allemagne, Autriche, CH | tabata timer app, intervall training | 2 — Semaine 2 |
|
||||
| Portugais (PT-BR) | Brésil | timer tabata, treino hiit | 3 — Mois 2 |
|
||||
|
||||
## **10.2 Éléments à Localiser**
|
||||
|
||||
* App content : tous les textes UI, onboarding, notifications, erreurs
|
||||
|
||||
* App Store listing : titre, sous-titre, description courte, description longue
|
||||
|
||||
* Screenshots : localisés avec texte natif (utiliser Fastlane Frameit)
|
||||
|
||||
* Prix : PPP via RevenueCat (automatique par pays)
|
||||
|
||||
* Mots-clés App Store : 100 caractères spécifiques par marché
|
||||
|
||||
Outil recommandé : i18next \+ expo-localization pour la gestion des traductions en développement.
|
||||
|
||||
# **11\. App Store Optimization (ASO)**
|
||||
|
||||
## **11.1 Fiche App Store (iOS — EN)**
|
||||
|
||||
| Champ | Valeur optimisée |
|
||||
| :---- | :---- |
|
||||
| Nom de l'app (30 car.) | TabataGo — HIIT Timer |
|
||||
| Sous-titre (30 car.) | Tabata & Interval Workout |
|
||||
| Mots-clés (100 car.) | tabata,hiit,timer,interval,workout,fitness,training,countdown,sport,exercise |
|
||||
| Description (1ère ligne) | The most motivating Tabata timer. 20 seconds of effort. Life-changing results. |
|
||||
| Screenshots (10 max) | Voir section 10.2 |
|
||||
| Preview video | 15-30s montrant la minuterie en action \+ streak \+ widget |
|
||||
|
||||
## **11.2 Plan des Screenshots (6 obligatoires)**
|
||||
|
||||
| \# | Contenu | Message clé | Background |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| 1 | Timer plein écran en action (20s) | "Train smarter, not longer" | Dark \+ orange |
|
||||
| 2 | Écran d'onboarding moment Wow | "Your first Tabata in 20 seconds" | Gradient sombre |
|
||||
| 3 | Widget iOS home screen | "Train from anywhere — even your lock screen" | iPhone mockup |
|
||||
| 4 | Historique \+ Streak en feu | "Build the habit. Keep the streak." | Dark \+ vert |
|
||||
| 5 | Bibliothèque de programmes | "100+ ready-to-go programs" | Gradient |
|
||||
| 6 | Statistiques mensuelles | "See your progress. Stay motivated." | Fond clair |
|
||||
|
||||
# **12\. Plan de Lancement en 72h**
|
||||
|
||||
## **12.1 Timeline de Développement (72 heures)**
|
||||
|
||||
| Phase | Durée | Livrables | Responsable |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| Setup & Architecture | H0 → H8 | Expo project init, navigation, design system, RevenueCat config | Dev Lead |
|
||||
| Onboarding | H8 → H20 | 6 écrans onboarding \+ mini-timer démo \+ animation | Dev \+ Design |
|
||||
| Core Timer | H20 → H36 | Minuterie plein écran, son, haptiques, background timer | Dev |
|
||||
| Streak \+ Historique | H36 → H48 | Système streak, storage, affichage historique, notifications | Dev |
|
||||
| Paywall \+ Analytics | H48 → H58 | RevenueCat paywall, PostHog events, A/B test config | Dev |
|
||||
| Polish & Testing | H58 → H68 | Bug fixes, dark/light mode, performance, edge cases | Dev \+ QA |
|
||||
| Submission | H68 → H72 | Screenshots, App Store listing (5 langues), soumission Apple | PM \+ Dev |
|
||||
|
||||
## **12.2 Stratégie de Validation Post-Lancement**
|
||||
|
||||
Profiter du boost de visibilité des 3 premiers jours accordé par l'App Store aux nouvelles applications :
|
||||
|
||||
1. Jour 1-3 : Monitoring organique — Analyser impressions, téléchargements, et premiers essais
|
||||
|
||||
2. Jour 4-7 : Analyse funnel — Identifier le premier point de friction (onboarding? paywall?)
|
||||
|
||||
3. Jour 8-14 : Première itération — Corriger le problème prioritaire et soumettre mise à jour OTA (Expo)
|
||||
|
||||
4. Jour 15-30 : Activation publicité — Apple Search Ads seulement si CVR organique \> 15%
|
||||
|
||||
5. Jour 30+ : Scale — Augmenter budget pub sur les marchés qui convertissent le mieux
|
||||
|
||||
Règle absolue : Ne jamais dépenser en publicité avant d'avoir validé que le funnel organique convertit.
|
||||
|
||||
# **13\. KPIs & Métriques de Succès**
|
||||
|
||||
| Métrique | Semaine 1 | Mois 1 | Mois 3 | Outil de mesure |
|
||||
| :---- | :---- | :---- | :---- | :---- |
|
||||
| Téléchargements | 500+ | 5 000+ | 20 000+ | App Store Connect |
|
||||
| Taux conversion Impression→DL | \> 5% | \> 8% | \> 10% | App Store Connect |
|
||||
| Taux essai démarré | \> 25% | \> 30% | \> 35% | RevenueCat \+ PostHog |
|
||||
| Taux conversion essai→payant | \> 30% | \> 35% | \> 40% | RevenueCat |
|
||||
| Rétention J+7 | \> 30% | \> 35% | \> 40% | PostHog |
|
||||
| Rétention J+30 | — | \> 20% | \> 25% | PostHog |
|
||||
| MRR | — | $2 000+ | $8 000+ | RevenueCat |
|
||||
| Note App Store | — | ≥ 4.5 | ≥ 4.6 | App Store Connect |
|
||||
| Streak moyen (actifs) | — | \> 5 jours | \> 10 jours | PostHog custom |
|
||||
|
||||
# **14\. Risques & Mitigations**
|
||||
|
||||
| Risque | Probabilité | Impact | Mitigation |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| Refus App Store (guidelines) | Moyen | Élevé | Respecter HIG Apple, tester paywall sur TestFlight, pas de dark patterns |
|
||||
| Mauvais taux de conversion paywall | Moyen | Élevé | A/B test RevenueCat dès J+7, 3 variantes de prix/layout |
|
||||
| Précision du timer en background | Faible | Élevé | expo-background-fetch \+ notification locale comme fallback |
|
||||
| Faible rétention sans streaks | Moyen | Moyen | Streak J+1 core feature, reminder push personnalisé par heure d'entraînement |
|
||||
| Concurrence sur mots-clés EN | Élevé | Moyen | Agressivité sur marchés FR/ES/DE dès J+14 |
|
||||
| Revue Apple lente (\> 48h) | Faible | Faible | Soumettre J-5 avant objectif lancement, utiliser Expo OTA pour hotfixes |
|
||||
|
||||
# **15\. Approbations & Versions**
|
||||
|
||||
| Rôle | Nom | Date | Signature |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| Product Owner | | Février 2026 | |
|
||||
| Tech Lead | | Février 2026 | |
|
||||
| Design Lead | | Février 2026 | |
|
||||
| Marketing | | Février 2026 | |
|
||||
|
||||
*Document créé le 15 février 2026 — TabataGo PRD v1.1*
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
1
package-lock.json
generated
1
package-lock.json
generated
@@ -17,6 +17,7 @@
|
||||
"expo-font": "~14.0.11",
|
||||
"expo-haptics": "~15.0.8",
|
||||
"expo-image": "~3.0.11",
|
||||
"expo-keep-awake": "~15.0.8",
|
||||
"expo-linking": "~8.0.11",
|
||||
"expo-router": "~6.0.23",
|
||||
"expo-splash-screen": "~31.0.13",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"expo-font": "~14.0.11",
|
||||
"expo-haptics": "~15.0.8",
|
||||
"expo-image": "~3.0.11",
|
||||
"expo-keep-awake": "~15.0.8",
|
||||
"expo-linking": "~8.0.11",
|
||||
"expo-router": "~6.0.23",
|
||||
"expo-splash-screen": "~31.0.13",
|
||||
@@ -31,11 +32,11 @@
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-worklets": "0.5.1",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-web": "~0.21.0"
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-worklets": "0.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.1.0",
|
||||
|
||||
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