diff --git a/.claude/skills/audio/SKILL.md b/.claude/skills/audio/SKILL.md new file mode 100644 index 0000000..2540ec8 --- /dev/null +++ b/.claude/skills/audio/SKILL.md @@ -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 + startMusic: (ambiance: MusicAmbiance, intensity: MusicIntensity) => Promise + switchIntensity: (intensity: MusicIntensity, fadeMs?: number) => Promise + stopMusic: (fadeMs?: number) => Promise + playPhaseSound: (sound: PhaseSound) => Promise + playCountdown: (seconds: number) => Promise // 3, 2, 1 + unloadAll: () => Promise +} +``` + +--- + +## 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 { + 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>({}) + +async function preloadAll(settings: AudioSettings): Promise { + // 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 { + 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 { + 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 { + 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 +``` diff --git a/.claude/skills/exercises/SKILL.md b/.claude/skills/exercises/SKILL.md new file mode 100644 index 0000000..0602ee5 --- /dev/null +++ b/.claude/skills/exercises/SKILL.md @@ -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 // clés = codes langue : { fr, en, es, de, pt } + category: ExerciseCategory + difficulty: ExerciseDifficulty + musclesTargeted: string[] + description: Record // max 80 caractères par langue + cues: Record // 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 + description: Record + 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 + +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 + // ↑ GIF grand format + nom + "Prépare-toi !" + + case 'WORK': + return + // ↑ Nom en haut + 2 cues + GIF petit coin bas-droit + + case 'REST': + return + // ↑ "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 ( + + 💪 + {getInitials(exercise.name[lang])} + + ) + } + + return ( + 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, + 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 diff --git a/.claude/skills/timer/SKILL.md b/.claude/skills/timer/SKILL.md new file mode 100644 index 0000000..03f8e29 --- /dev/null +++ b/.claude/skills/timer/SKILL.md @@ -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(null) +const targetEndTimeRef = useRef(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) + } +}, []) +``` diff --git a/.claude/skills/workflow/SKILL.md b/.claude/skills/workflow/SKILL.md new file mode 100644 index 0000000..6b78621 --- /dev/null +++ b/.claude/skills/workflow/SKILL.md @@ -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 {secondsLeft} +} + +// ❌ Interdit +function TimerDisplay() { + const timer = useTimerEngine() // logique dans le composant + return {timer.secondsLeft} +} +``` + +### É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 = { + 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 { + await AsyncStorage.setItem('timer_config', JSON.stringify(config)) +} + +export async function loadTimerConfig(): Promise { + 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(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" | diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4d298f2 --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/TabataGo_PRD_v1.1.md b/TabataGo_PRD_v1.1.md new file mode 100644 index 0000000..cebeb1a --- /dev/null +++ b/TabataGo_PRD_v1.1.md @@ -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* \ No newline at end of file diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 786b736..0905dc4 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -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 ( - - }> - - Welcome! - - - - Step 1: Try it - - Edit app/(tabs)/index.tsx to see changes. - Press{' '} - - {Platform.select({ - ios: 'cmd + d', - android: 'cmd + m', - web: 'F12', - })} - {' '} - to open developer tools. - - - - - - Step 2: Explore - - - - alert('Action pressed')} /> - alert('Share pressed')} - /> - - alert('Delete pressed')} - /> - - - + const router = useRouter() + const insets = useSafeAreaInsets() - - {`Tap the Explore tab to learn more about what's included in this starter app.`} - - - - Step 3: Get a fresh start - - {`When you're ready, run `} - npm run reset-project to get a fresh{' '} - app directory. This will move the current{' '} - app to{' '} - app-example. - - - - ); + return ( + + + + TABATAGO + Entraînement Tabata + + [ + styles.startButton, + pressed && styles.startButtonPressed, + ]} + onPress={() => router.push('/timer')} + > + START + + + ) } 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, + }, +}) diff --git a/app/_layout.tsx b/app/_layout.tsx index f518c9b..58bd698 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -16,6 +16,14 @@ export default function RootLayout() { + diff --git a/app/timer.tsx b/app/timer.tsx new file mode 100644 index 0000000..b2b31c8 --- /dev/null +++ b/app/timer.tsx @@ -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 ( + + ) +} diff --git a/package-lock.json b/package-lock.json index 31a71dc..6385a5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 049cd88..04143e6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/features/timer/components/TimerControls.tsx b/src/features/timer/components/TimerControls.tsx new file mode 100644 index 0000000..530a814 --- /dev/null +++ b/src/features/timer/components/TimerControls.tsx @@ -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 ( + + [styles.button, pressed && styles.pressed]} + onPress={onStop} + > + + + + [ + styles.button, + styles.mainButton, + pressed && styles.pressed, + ]} + onPress={isPaused ? onResume : onPause} + > + + + + [styles.button, pressed && styles.pressed]} + onPress={onSkip} + > + + + + ) +} + +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, + }, +}) diff --git a/src/features/timer/components/TimerDisplay.tsx b/src/features/timer/components/TimerDisplay.tsx new file mode 100644 index 0000000..aad6f7b --- /dev/null +++ b/src/features/timer/components/TimerDisplay.tsx @@ -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 = { + 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 ( + + ) + } + + if (state.phase === 'COMPLETE') { + return ( + + ) + } + + return ( + + ) +} + +// --- IDLE view --- + +function IdleView({ + config, + onStart, + topInset, + bottomInset, +}: { + config: TimerConfig + onStart: () => void + topInset: number + bottomInset: number +}) { + return ( + + + + + TABATAGO + + + + {config.workDuration}s travail / {config.restDuration}s repos + + + {config.rounds} rounds + + + + [ + styles.startButton, + pressed && styles.startButtonPressed, + ]} + onPress={onStart} + > + START + + + + ) +} + +// --- COMPLETE view --- + +function CompleteView({ + totalElapsedSeconds, + totalRounds, + onStop, + topInset, + bottomInset, +}: { + totalElapsedSeconds: number + totalRounds: number + onStop: () => void + topInset: number + bottomInset: number +}) { + return ( + + + + + TERMINÉ ! + {formatTime(totalElapsedSeconds)} + + {totalRounds} rounds complétés + + + [ + styles.doneButton, + pressed && styles.startButtonPressed, + ]} + onPress={onStop} + > + Terminer + + + + ) +} + +// --- 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(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 ( + + + ) +} + +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, + }, +}) diff --git a/src/features/timer/hooks/useTimerEngine.ts b/src/features/timer/hooks/useTimerEngine.ts new file mode 100644 index 0000000..8a64f10 --- /dev/null +++ b/src/features/timer/hooks/useTimerEngine.ts @@ -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 = { + 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('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(buildDefaultConfig) + + // --- Refs for time-critical values (no stale closures) --- + const targetEndTimeRef = useRef(0) + const remainingMsRef = useRef(0) + const tickRef = useRef | null>(null) + const phaseRef = useRef('IDLE') + const currentRoundRef = useRef(0) + const currentCycleRef = useRef(0) + const configRef = useRef(buildDefaultConfig()) + const isRunningRef = useRef(false) + const isPausedRef = useRef(false) + const elapsedRef = useRef(0) + const lastTickTimeRef = useRef(0) + const lastEmittedSecondRef = useRef(-1) + const listenersRef = useRef>(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) => { + 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, + } +} diff --git a/src/features/timer/index.ts b/src/features/timer/index.ts new file mode 100644 index 0000000..138f7db --- /dev/null +++ b/src/features/timer/index.ts @@ -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' diff --git a/src/features/timer/types.ts b/src/features/timer/types.ts new file mode 100644 index 0000000..1cfa0ae --- /dev/null +++ b/src/features/timer/types.ts @@ -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) => 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 +} diff --git a/src/shared/constants/colors.ts b/src/shared/constants/colors.ts new file mode 100644 index 0000000..5473b40 --- /dev/null +++ b/src/shared/constants/colors.ts @@ -0,0 +1,7 @@ +export const PHASE_COLORS = { + IDLE: '#1E1E2E', + GET_READY: '#EAB308', + WORK: '#F97316', + REST: '#3B82F6', + COMPLETE: '#22C55E', +} as const diff --git a/src/shared/constants/timer.ts b/src/shared/constants/timer.ts new file mode 100644 index 0000000..cb8658b --- /dev/null +++ b/src/shared/constants/timer.ts @@ -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 diff --git a/src/shared/utils/formatTime.ts b/src/shared/utils/formatTime.ts new file mode 100644 index 0000000..58ae655 --- /dev/null +++ b/src/shared/utils/formatTime.ts @@ -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}` +}