feat: timer engine + full-screen timer UI
Implement the core timer feature following the src/features/ architecture: - useTimerEngine hook: drift-free Date.now() delta countdown (100ms tick), explicit state machine (IDLE → GET_READY → WORK → REST → COMPLETE), event emitter for external consumers (PHASE_CHANGED, ROUND_COMPLETED, COUNTDOWN_TICK, SESSION_COMPLETE), auto-pause on AppState interruption (phone calls, background), expo-keep-awake during session - TimerDisplay component: full-screen animated UI with 600ms color transitions between phases, pulse animation on countdown, flash red on last 3 seconds, round progress dots, IDLE/active/COMPLETE views - TimerControls component: stop/pause-resume/skip buttons with Ionicons - Timer route (app/timer.tsx): fullScreenModal wiring hook → display - Home screen: dark theme with START button navigating to /timer - Project docs: CLAUDE.md (constitution), PRD v1.1, skill files - Shared constants: PHASE_COLORS, TIMER_DEFAULTS, formatTime utility - Types: TimerPhase, TimerState, TimerConfig, TimerActions, TimerEvent Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
428
.claude/skills/exercises/SKILL.md
Normal file
428
.claude/skills/exercises/SKILL.md
Normal file
@@ -0,0 +1,428 @@
|
||||
# Skill — Exercices (useExercise + ExerciseDisplay)
|
||||
> Lis ce skill AVANT d'implémenter quoi que ce soit lié aux exercices.
|
||||
|
||||
## Responsabilité de ce module
|
||||
Gérer la bibliothèque d'exercices, la sélection selon le programme,
|
||||
et l'affichage contextuel selon la phase du timer.
|
||||
**Offline-first** : toutes les données et les GIFs sont dans le bundle.
|
||||
|
||||
---
|
||||
|
||||
## Architecture du module exercices
|
||||
|
||||
```
|
||||
src/features/exercises/
|
||||
types.ts
|
||||
hooks/
|
||||
useExercise.ts ← sélection + navigation entre exercices
|
||||
useExerciseLibrary.ts ← recherche, filtrage, favoris
|
||||
components/
|
||||
ExerciseDisplay.tsx ← affichage contextuel selon la phase timer
|
||||
ExerciseGif.tsx ← Image GIF avec fallback + accessibilité
|
||||
ExerciseCard.tsx ← carte pour la bibliothèque/programme
|
||||
ExerciseCues.tsx ← affichage des cues de forme
|
||||
data/
|
||||
exercises.ts ← les 38 exercices (source de vérité)
|
||||
categories.ts ← définition des catégories
|
||||
index.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Types
|
||||
|
||||
```typescript
|
||||
// src/features/exercises/types.ts
|
||||
|
||||
export type ExerciseCategory =
|
||||
| 'CARDIO'
|
||||
| 'LOWER_BODY'
|
||||
| 'UPPER_BODY'
|
||||
| 'CORE'
|
||||
| 'LOW_IMPACT'
|
||||
| 'EQUIPMENT'
|
||||
|
||||
export type ExerciseDifficulty = 'BEGINNER' | 'INTERMEDIATE' | 'ADVANCED'
|
||||
|
||||
export interface Exercise {
|
||||
id: string
|
||||
name: Record<string, string> // clés = codes langue : { fr, en, es, de, pt }
|
||||
category: ExerciseCategory
|
||||
difficulty: ExerciseDifficulty
|
||||
musclesTargeted: string[]
|
||||
description: Record<string, string> // max 80 caractères par langue
|
||||
cues: Record<string, string[]> // 2-3 points clés de forme par langue
|
||||
gifAsset: number | null // require(...) ou null si pas de GIF
|
||||
thumbnailAsset: number | null
|
||||
hasModification: boolean // variante plus facile disponible
|
||||
modificationId: string | null // id de l'exercice modifié
|
||||
equipmentNeeded: string[] // [] = aucun matériel
|
||||
estimatedCaloriesPerMinute: number // approximatif
|
||||
}
|
||||
|
||||
export type ProgramMode = 'SINGLE' | 'DUO' | 'CIRCUIT_4' | 'FREE'
|
||||
|
||||
export interface ExerciseProgram {
|
||||
id: string
|
||||
name: Record<string, string>
|
||||
description: Record<string, string>
|
||||
mode: ProgramMode
|
||||
// Pour SINGLE : [exerciseId] — même exercice répété
|
||||
// Pour DUO : [exerciseIdA, exerciseIdB] — alternance
|
||||
// Pour CIRCUIT_4 : [a, b, c, d] — 4 exercices × 2 rounds
|
||||
// Pour FREE : exerciseId par round [r1, r2, r3, r4, r5, r6, r7, r8]
|
||||
exerciseIds: string[]
|
||||
difficulty: ExerciseDifficulty
|
||||
totalRounds: number
|
||||
isDefault: boolean // programmes pré-définis vs créés par user
|
||||
}
|
||||
|
||||
// Résout l'exercice pour un round donné selon le mode du programme
|
||||
export interface ExerciseState {
|
||||
current: Exercise
|
||||
next: Exercise | null // null sur le dernier round
|
||||
roundIndex: number // 0-indexé
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Données — les 38 exercices V1
|
||||
|
||||
Structure à respecter pour chaque exercice :
|
||||
|
||||
```typescript
|
||||
// src/features/exercises/data/exercises.ts
|
||||
import { Exercise } from '../types'
|
||||
|
||||
export const EXERCISES: Exercise[] = [
|
||||
{
|
||||
id: 'burpee_classic',
|
||||
name: { fr: 'Burpee', en: 'Burpee', es: 'Burpee', de: 'Burpee', pt: 'Burpee' },
|
||||
category: 'CARDIO',
|
||||
difficulty: 'INTERMEDIATE',
|
||||
musclesTargeted: ['quadriceps', 'pectoraux', 'épaules', 'cardio'],
|
||||
description: {
|
||||
fr: 'Position debout → squat → planche → pompe → saut vertical',
|
||||
en: 'Stand → squat → plank → push-up → jump',
|
||||
es: 'De pie → sentadilla → plancha → flexión → salto',
|
||||
de: 'Stehen → Hocke → Plank → Liegestütz → Sprung',
|
||||
pt: 'Em pé → agachamento → prancha → flexão → salto',
|
||||
},
|
||||
cues: {
|
||||
fr: ['Atterris doucement sur les orteils', 'Garde le dos droit en planche', 'Explose vers le haut'],
|
||||
en: ['Land softly on toes', 'Keep back flat in plank', 'Explode upward'],
|
||||
es: ['Aterriza suavemente', 'Espalda recta en plancha', 'Explota hacia arriba'],
|
||||
de: ['Sanft auf den Zehen landen', 'Rücken gerade halten', 'Nach oben explodieren'],
|
||||
pt: ['Aterrissar suavemente', 'Costas retas na prancha', 'Explodir para cima'],
|
||||
},
|
||||
gifAsset: require('../../../assets/exercises/burpee_classic.gif'),
|
||||
thumbnailAsset: require('../../../assets/exercises/thumbs/burpee_classic.jpg'),
|
||||
hasModification: true,
|
||||
modificationId: 'burpee_modified',
|
||||
equipmentNeeded: [],
|
||||
estimatedCaloriesPerMinute: 12,
|
||||
},
|
||||
// ... 37 autres exercices
|
||||
]
|
||||
|
||||
// Accès rapide par ID
|
||||
export const EXERCISES_MAP = Object.fromEntries(
|
||||
EXERCISES.map(ex => [ex.id, ex])
|
||||
) as Record<string, Exercise>
|
||||
|
||||
export function getExerciseById(id: string): Exercise {
|
||||
const ex = EXERCISES_MAP[id]
|
||||
if (!ex) throw new Error(`Exercice non trouvé : ${id}`)
|
||||
return ex
|
||||
}
|
||||
```
|
||||
|
||||
**Liste des 38 exercices V1 à implémenter :**
|
||||
```
|
||||
CARDIO (8) : burpee_classic, burpee_modified, jumping_jacks,
|
||||
mountain_climbers, high_knees, jump_rope_sim,
|
||||
box_jump_sim, lateral_shuffles
|
||||
|
||||
LOWER_BODY (8) : squat_classic, squat_jump, lunge_alternating,
|
||||
lunge_jump, glute_bridge, wall_sit, calf_raises,
|
||||
sumo_squat
|
||||
|
||||
UPPER_BODY (6) : pushup_classic, pushup_modified, pike_pushup,
|
||||
tricep_dip, shoulder_taps, inchworm
|
||||
|
||||
CORE (6) : crunch_classic, plank_hold, russian_twist,
|
||||
bicycle_crunch, leg_raises, dead_bug
|
||||
|
||||
LOW_IMPACT (6) : march_in_place, step_touch, modified_squat,
|
||||
standing_oblique, slow_pushup, chair_stand
|
||||
|
||||
EQUIPMENT (4) : kb_swing, db_thruster, resistance_band_row,
|
||||
medicine_ball_slam
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Hook useExercise
|
||||
|
||||
```typescript
|
||||
// src/features/exercises/hooks/useExercise.ts
|
||||
|
||||
export function useExercise(program: ExerciseProgram): {
|
||||
getExerciseForRound: (round: number) => ExerciseState
|
||||
currentExercise: Exercise
|
||||
nextExercise: Exercise | null
|
||||
} {
|
||||
function getExerciseForRound(round: number): ExerciseState {
|
||||
// round est 1-indexé
|
||||
const index = round - 1
|
||||
|
||||
let currentId: string
|
||||
let nextId: string | null
|
||||
|
||||
switch (program.mode) {
|
||||
case 'SINGLE':
|
||||
currentId = program.exerciseIds[0]
|
||||
nextId = null // même exercice → pas de "suivant" différent
|
||||
break
|
||||
|
||||
case 'DUO':
|
||||
currentId = program.exerciseIds[index % 2]
|
||||
nextId = program.exerciseIds[(index + 1) % 2]
|
||||
break
|
||||
|
||||
case 'CIRCUIT_4':
|
||||
currentId = program.exerciseIds[index % 4]
|
||||
nextId = program.exerciseIds[(index + 1) % 4]
|
||||
break
|
||||
|
||||
case 'FREE':
|
||||
currentId = program.exerciseIds[index] ?? program.exerciseIds[0]
|
||||
nextId = program.exerciseIds[index + 1] ?? null
|
||||
break
|
||||
}
|
||||
|
||||
return {
|
||||
current: getExerciseById(currentId),
|
||||
next: nextId ? getExerciseById(nextId) : null,
|
||||
roundIndex: index,
|
||||
}
|
||||
}
|
||||
|
||||
return { getExerciseForRound, /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Composant ExerciseDisplay — affichage contextuel
|
||||
|
||||
Le composant adapte son layout selon la phase du timer.
|
||||
|
||||
```typescript
|
||||
interface ExerciseDisplayProps {
|
||||
phase: TimerPhase
|
||||
exercise: Exercise
|
||||
nextExercise: Exercise | null
|
||||
lang: string // code langue actuel
|
||||
}
|
||||
|
||||
export function ExerciseDisplay({ phase, exercise, nextExercise, lang }: ExerciseDisplayProps) {
|
||||
switch (phase) {
|
||||
case 'GET_READY':
|
||||
return <GetReadyView exercise={exercise} lang={lang} />
|
||||
// ↑ GIF grand format + nom + "Prépare-toi !"
|
||||
|
||||
case 'WORK':
|
||||
return <WorkView exercise={exercise} lang={lang} />
|
||||
// ↑ Nom en haut + 2 cues + GIF petit coin bas-droit
|
||||
|
||||
case 'REST':
|
||||
return <RestView exercise={exercise} nextExercise={nextExercise} lang={lang} />
|
||||
// ↑ "Repos" + si nextExercise : "Prochain : [nom]" + vignette
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GetReadyView
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ │
|
||||
│ ┌─────────────────┐ │
|
||||
│ │ [GIF 200x200] │ │ ← Grand GIF centré
|
||||
│ └─────────────────┘ │
|
||||
│ │
|
||||
│ Burpees │ ← Nom, police bold 28px
|
||||
│ ← Dos droit en planche │ ← Cue #1
|
||||
│ ← Explose vers le haut │ ← Cue #2
|
||||
│ │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
### WorkView
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ Burpees │ ← Nom, 22px, coin haut-gauche
|
||||
│ ← Dos droit │ ← Cue #1, 16px
|
||||
│ ← Explose vers le haut │ ← Cue #2, 16px
|
||||
│ │
|
||||
│ [Timer central] │
|
||||
│ │
|
||||
│ ┌────┐ │
|
||||
│ │GIF │ │ ← GIF 80x80, coin bas-droit
|
||||
│ └────┘ │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
### RestView
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ │
|
||||
│ REPOS │ ← "REPOS" centré, grand
|
||||
│ │
|
||||
│ Prochain : Mountain │ ← Si nextExercise != null
|
||||
│ Climbers │
|
||||
│ ┌──────┐ │
|
||||
│ │ GIF │ │ ← Vignette 60x60 de nextExercise
|
||||
│ └──────┘ │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Composant ExerciseGif — robuste et accessible
|
||||
|
||||
```typescript
|
||||
interface ExerciseGifProps {
|
||||
exercise: Exercise
|
||||
size: 'small' | 'medium' | 'large'
|
||||
lang: string
|
||||
}
|
||||
|
||||
const GIF_SIZES = { small: 80, medium: 120, large: 200 }
|
||||
|
||||
export function ExerciseGif({ exercise, size, lang }: ExerciseGifProps) {
|
||||
const [hasError, setHasError] = useState(false)
|
||||
const dimension = GIF_SIZES[size]
|
||||
|
||||
if (!exercise.gifAsset || hasError) {
|
||||
// Fallback : icône + initiales de l'exercice
|
||||
return (
|
||||
<View
|
||||
style={[styles.fallback, { width: dimension, height: dimension }]}
|
||||
accessible={true}
|
||||
accessibilityLabel={exercise.name[lang] ?? exercise.name['en']}
|
||||
>
|
||||
<Text style={styles.fallbackIcon}>💪</Text>
|
||||
<Text style={styles.fallbackText}>{getInitials(exercise.name[lang])}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
source={exercise.gifAsset}
|
||||
style={{ width: dimension, height: dimension }}
|
||||
onError={() => setHasError(true)}
|
||||
accessible={true}
|
||||
accessibilityLabel={`Démonstration de ${exercise.name[lang]}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Localisation des exercices
|
||||
|
||||
```typescript
|
||||
// Utilitaire pour extraire la bonne langue avec fallback
|
||||
export function getLocalizedText(
|
||||
record: Record<string, string>,
|
||||
lang: string
|
||||
): string {
|
||||
return record[lang] ?? record['en'] ?? Object.values(record)[0] ?? ''
|
||||
}
|
||||
|
||||
// Utilisation
|
||||
const name = getLocalizedText(exercise.name, userLang)
|
||||
const cues = (exercise.cues[userLang] ?? exercise.cues['en'] ?? [])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Programmes pré-définis V1
|
||||
|
||||
```typescript
|
||||
// src/features/exercises/data/defaultPrograms.ts
|
||||
|
||||
export const DEFAULT_PROGRAMS: ExerciseProgram[] = [
|
||||
{
|
||||
id: 'beginner_classic',
|
||||
name: { fr: 'Débutant Classic', en: 'Beginner Classic' },
|
||||
description: { fr: 'Parfait pour commencer le Tabata', en: 'Perfect to start Tabata' },
|
||||
mode: 'SINGLE',
|
||||
exerciseIds: ['squat_classic'],
|
||||
difficulty: 'BEGINNER',
|
||||
totalRounds: 8,
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
id: 'full_body_duo',
|
||||
name: { fr: 'Full Body Duo', en: 'Full Body Duo' },
|
||||
description: { fr: 'Squats + Pompes — le duo parfait', en: 'Squats + Push-ups — the perfect duo' },
|
||||
mode: 'DUO',
|
||||
exerciseIds: ['squat_jump', 'pushup_classic'],
|
||||
difficulty: 'INTERMEDIATE',
|
||||
totalRounds: 8,
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
id: 'cardio_blast',
|
||||
name: { fr: 'Cardio Blast', en: 'Cardio Blast' },
|
||||
description: { fr: 'Circuit intense 4 exercices cardio', en: '4-exercise intense cardio circuit' },
|
||||
mode: 'CIRCUIT_4',
|
||||
exerciseIds: ['burpee_classic', 'high_knees', 'mountain_climbers', 'jumping_jacks'],
|
||||
difficulty: 'ADVANCED',
|
||||
totalRounds: 8,
|
||||
isDefault: true,
|
||||
},
|
||||
// + programmes par objectif (perte de poids, force, low impact, etc.)
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tests minimaux
|
||||
|
||||
```typescript
|
||||
describe('useExercise', () => {
|
||||
it('MODE SINGLE : retourne le même exercice à chaque round', () => {})
|
||||
it('MODE DUO : alterne A/B correctement sur 8 rounds', () => {})
|
||||
it('MODE CIRCUIT_4 : cycle sur 4 exercices', () => {})
|
||||
it('retourne null pour nextExercise sur le dernier round en FREE', () => {})
|
||||
it('getExerciseById lance une erreur si id inconnu', () => {})
|
||||
})
|
||||
|
||||
describe('ExerciseDisplay', () => {
|
||||
it('affiche le GIF grand format en GET_READY', () => {})
|
||||
it('affiche les cues en WORK', () => {})
|
||||
it('affiche nextExercise en REST si disponible', () => {})
|
||||
it('affiche le fallback si gifAsset est null', () => {})
|
||||
it('est accessible (accessibilityLabel présent)', () => {})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist assets exercices
|
||||
|
||||
Avant de lancer en production, vérifier :
|
||||
- [ ] 38 GIFs présents dans `assets/exercises/` (200×200px, loop, < 200KB chacun)
|
||||
- [ ] 38 thumbnails présents dans `assets/exercises/thumbs/` (< 30KB chacun)
|
||||
- [ ] Toutes les traductions présentes pour EN, FR, ES, DE, PT
|
||||
- [ ] Tous les `modificationId` pointent vers un exercice existant
|
||||
- [ ] Bundle total des assets exercices < 10MB
|
||||
Reference in New Issue
Block a user