feat: redesign player with Dynamic Island, compact timer, and fix Live Activity timer drift #2
@@ -1,369 +0,0 @@
|
||||
# Skill — Audio Engine (useAudioEngine)
|
||||
> Lis ce skill AVANT d'implémenter quoi que ce soit lié à l'audio.
|
||||
|
||||
## Responsabilité de ce module
|
||||
L'audio engine gère 4 couches sonores indépendantes qui s'activent
|
||||
en réponse aux événements du timer. Il ne connaît pas le timer
|
||||
directement — il s'abonne à ses événements via useTimerSync.
|
||||
|
||||
**Objectif principal : zéro latence audio sur les transitions de phase.**
|
||||
Les sons de phase doivent jouer dans les 50ms suivant l'événement.
|
||||
|
||||
---
|
||||
|
||||
## Architecture du module audio
|
||||
|
||||
```
|
||||
src/features/audio/
|
||||
types.ts
|
||||
hooks/
|
||||
useAudioEngine.ts ← moteur central expo-av
|
||||
useAudioSettings.ts ← préférences utilisateur persistées
|
||||
data/
|
||||
tracks.ts ← catalogue musique (offline, paths locaux)
|
||||
sounds.ts ← catalogue signaux de phase
|
||||
components/
|
||||
MusicPicker.tsx ← sélecteur d'ambiance musicale
|
||||
SoundPicker.tsx ← sélecteur de signal de phase
|
||||
AudioSettingsPanel.tsx ← panneau complet des réglages
|
||||
index.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Types
|
||||
|
||||
```typescript
|
||||
// src/features/audio/types.ts
|
||||
|
||||
export type MusicAmbiance = 'ELECTRO' | 'HIP_HOP' | 'ROCK' | 'SILENCE'
|
||||
export type MusicIntensity = 'LOW' | 'MEDIUM' | 'HIGH'
|
||||
export type PhaseSound = 'beep' | 'whistle' | 'voice_go' | 'air_horn' | 'bell' | 'silence'
|
||||
|
||||
export interface AudioTrack {
|
||||
id: string
|
||||
ambiance: MusicAmbiance
|
||||
intensity: MusicIntensity
|
||||
asset: number // require('../assets/audio/...') — offline
|
||||
bpm: number
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
export interface AudioSettings {
|
||||
musicEnabled: boolean
|
||||
ambiance: MusicAmbiance
|
||||
musicVolume: number // 0.0 → 1.0
|
||||
soundsEnabled: boolean
|
||||
soundsVolume: number // 0.0 → 1.0
|
||||
hapticsEnabled: boolean
|
||||
voiceEnabled: boolean // annonces vocales
|
||||
overrideMuteSwitch: boolean // iOS : jouer même en mode silencieux
|
||||
workPhaseSound: PhaseSound
|
||||
restPhaseSound: PhaseSound
|
||||
countdownSound: PhaseSound
|
||||
completionSound: PhaseSound
|
||||
}
|
||||
|
||||
export interface AudioState {
|
||||
isLoaded: boolean
|
||||
isMusicPlaying: boolean
|
||||
currentAmbiance: MusicAmbiance
|
||||
currentIntensity: MusicIntensity
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface AudioActions {
|
||||
preloadAll: () => Promise<void>
|
||||
startMusic: (ambiance: MusicAmbiance, intensity: MusicIntensity) => Promise<void>
|
||||
switchIntensity: (intensity: MusicIntensity, fadeMs?: number) => Promise<void>
|
||||
stopMusic: (fadeMs?: number) => Promise<void>
|
||||
playPhaseSound: (sound: PhaseSound) => Promise<void>
|
||||
playCountdown: (seconds: number) => Promise<void> // 3, 2, 1
|
||||
unloadAll: () => Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implémentation — useAudioEngine
|
||||
|
||||
### Configuration de la session audio iOS/Android
|
||||
|
||||
**Critique à faire en premier, avant tout chargement de son.**
|
||||
|
||||
```typescript
|
||||
import { Audio } from 'expo-av'
|
||||
|
||||
// À appeler une seule fois au démarrage de l'app (dans _layout.tsx)
|
||||
export async function configureAudioSession(): Promise<void> {
|
||||
await Audio.setAudioModeAsync({
|
||||
// iOS : jouer par-dessus la musique de l'utilisateur
|
||||
playsInSilentModeIOS: true, // ignore le switch mute (si overrideMuteSwitch)
|
||||
allowsRecordingIOS: false,
|
||||
staysActiveInBackground: true, // CRITIQUE — timer continue en background
|
||||
|
||||
// Android
|
||||
shouldDuckAndroid: true, // baisser le volume autres apps sur nos signaux
|
||||
playThroughEarpieceAndroid: false,
|
||||
interruptionModeIOS: 1, // DO_NOT_MIX = 1 (nos sons ont priorité sur signaux)
|
||||
interruptionModeAndroid: 1,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Preloading — charger AVANT la séance
|
||||
|
||||
Tous les sons doivent être chargés en mémoire AVANT que la séance commence.
|
||||
Si on charge pendant la séance → latence inacceptable.
|
||||
|
||||
```typescript
|
||||
const soundRefs = useRef<Record<string, Audio.Sound>>({})
|
||||
|
||||
async function preloadAll(settings: AudioSettings): Promise<void> {
|
||||
// 1. Charger la track musicale correspondante à l'ambiance + les 2 intensités
|
||||
const trackWork = getTrack(settings.ambiance, 'HIGH')
|
||||
const trackRest = getTrack(settings.ambiance, 'LOW')
|
||||
|
||||
soundRefs.current['music_work'] = await loadSound(trackWork.asset)
|
||||
soundRefs.current['music_rest'] = await loadSound(trackRest.asset)
|
||||
|
||||
// 2. Charger tous les signaux de phase
|
||||
const soundAssets = getSoundAssets(settings)
|
||||
for (const [key, asset] of Object.entries(soundAssets)) {
|
||||
soundRefs.current[key] = await loadSound(asset)
|
||||
}
|
||||
|
||||
// 3. Mettre en loop les tracks musicales
|
||||
await soundRefs.current['music_work'].setIsLoopingAsync(true)
|
||||
await soundRefs.current['music_rest'].setIsLoopingAsync(true)
|
||||
}
|
||||
|
||||
async function loadSound(asset: number): Promise<Audio.Sound> {
|
||||
const { sound } = await Audio.Sound.createAsync(asset, {
|
||||
shouldPlay: false,
|
||||
volume: 1.0,
|
||||
})
|
||||
return sound
|
||||
}
|
||||
```
|
||||
|
||||
### Crossfade entre phases
|
||||
|
||||
```typescript
|
||||
async function switchIntensity(
|
||||
to: MusicIntensity,
|
||||
fadeMs: number = 500
|
||||
): Promise<void> {
|
||||
const from = currentIntensityRef.current
|
||||
if (from === to) return
|
||||
|
||||
const outKey = from === 'HIGH' ? 'music_work' : 'music_rest'
|
||||
const inKey = to === 'HIGH' ? 'music_work' : 'music_rest'
|
||||
|
||||
const outSound = soundRefs.current[outKey]
|
||||
const inSound = soundRefs.current[inKey]
|
||||
|
||||
if (!outSound || !inSound) return
|
||||
|
||||
// Démarrer la track entrante depuis le début (ou depuis la position actuelle ?)
|
||||
await inSound.setPositionAsync(0)
|
||||
await inSound.setVolumeAsync(0)
|
||||
await inSound.playAsync()
|
||||
|
||||
// Fade simultané
|
||||
const steps = 10
|
||||
const stepMs = fadeMs / steps
|
||||
const volumeStep = settings.musicVolume / steps
|
||||
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
await Promise.all([
|
||||
outSound.setVolumeAsync(Math.max(0, settings.musicVolume - i * volumeStep)),
|
||||
inSound.setVolumeAsync(Math.min(settings.musicVolume, i * volumeStep)),
|
||||
])
|
||||
await delay(stepMs)
|
||||
}
|
||||
|
||||
await outSound.stopAsync()
|
||||
currentIntensityRef.current = to
|
||||
}
|
||||
|
||||
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
||||
```
|
||||
|
||||
### Répondre aux événements timer
|
||||
|
||||
```typescript
|
||||
// Dans useTimerSync — pont entre timer et audio
|
||||
|
||||
import { useTimerEngine } from '../timer'
|
||||
import { useAudioEngine } from '../audio'
|
||||
|
||||
export function useTimerSync() {
|
||||
const timer = useTimerEngine()
|
||||
const audio = useAudioEngine()
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = timer.addEventListener((event) => {
|
||||
switch (event.type) {
|
||||
case 'PHASE_CHANGED':
|
||||
handlePhaseChange(event.from, event.to)
|
||||
break
|
||||
case 'COUNTDOWN_TICK':
|
||||
audio.playCountdown(event.secondsLeft)
|
||||
break
|
||||
case 'SESSION_COMPLETE':
|
||||
audio.playPhaseSound('completion')
|
||||
audio.stopMusic(1000)
|
||||
break
|
||||
}
|
||||
})
|
||||
return unsubscribe
|
||||
}, [])
|
||||
|
||||
async function handlePhaseChange(from: TimerPhase, to: TimerPhase) {
|
||||
switch (to) {
|
||||
case 'GET_READY':
|
||||
await audio.startMusic(settings.ambiance, 'MEDIUM')
|
||||
break
|
||||
case 'WORK':
|
||||
await audio.playPhaseSound(settings.workPhaseSound)
|
||||
await audio.switchIntensity('HIGH', 500)
|
||||
break
|
||||
case 'REST':
|
||||
await audio.playPhaseSound(settings.restPhaseSound)
|
||||
await audio.switchIntensity('LOW', 500)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Catalogue des tracks (offline)
|
||||
|
||||
```typescript
|
||||
// src/features/audio/data/tracks.ts
|
||||
import { MusicAmbiance, MusicIntensity, AudioTrack } from '../types'
|
||||
|
||||
export const TRACKS: AudioTrack[] = [
|
||||
{
|
||||
id: 'electro_high',
|
||||
ambiance: 'ELECTRO',
|
||||
intensity: 'HIGH',
|
||||
asset: require('../../../assets/audio/music/electro_high.mp3'),
|
||||
bpm: 140,
|
||||
durationMs: 180000,
|
||||
},
|
||||
{
|
||||
id: 'electro_low',
|
||||
ambiance: 'ELECTRO',
|
||||
intensity: 'LOW',
|
||||
asset: require('../../../assets/audio/music/electro_low.mp3'),
|
||||
bpm: 90,
|
||||
durationMs: 180000,
|
||||
},
|
||||
// ... (9 tracks au total)
|
||||
]
|
||||
|
||||
export function getTrack(ambiance: MusicAmbiance, intensity: MusicIntensity): AudioTrack {
|
||||
const track = TRACKS.find(t => t.ambiance === ambiance && t.intensity === intensity)
|
||||
if (!track) throw new Error(`Track non trouvée : ${ambiance} ${intensity}`)
|
||||
return track
|
||||
}
|
||||
```
|
||||
|
||||
## Catalogue des sons de phase
|
||||
|
||||
```typescript
|
||||
// src/features/audio/data/sounds.ts
|
||||
export const PHASE_SOUNDS = {
|
||||
beep: require('../../../assets/audio/sounds/beep_long.mp3'),
|
||||
whistle: require('../../../assets/audio/sounds/whistle.mp3'),
|
||||
voice_go: require('../../../assets/audio/sounds/voice_go.mp3'),
|
||||
air_horn: require('../../../assets/audio/sounds/air_horn.mp3'),
|
||||
bell: require('../../../assets/audio/sounds/bell.mp3'),
|
||||
double_beep: require('../../../assets/audio/sounds/beep_double.mp3'),
|
||||
countdown_3: require('../../../assets/audio/sounds/count_3.mp3'),
|
||||
countdown_2: require('../../../assets/audio/sounds/count_2.mp3'),
|
||||
countdown_1: require('../../../assets/audio/sounds/count_1.mp3'),
|
||||
fanfare: require('../../../assets/audio/sounds/fanfare.mp3'),
|
||||
clap: require('../../../assets/audio/sounds/applause.mp3'),
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Gestion du switch silencieux iOS
|
||||
|
||||
```typescript
|
||||
// Si overrideMuteSwitch = true dans les settings
|
||||
await Audio.setAudioModeAsync({
|
||||
playsInSilentModeIOS: settings.overrideMuteSwitch,
|
||||
})
|
||||
```
|
||||
|
||||
**Règle UX :** Les signaux de phase ont l'override activé par défaut
|
||||
(l'utilisateur a besoin de savoir quand changer d'exercice même en mode silencieux).
|
||||
La musique d'ambiance respecte le switch silencieux par défaut.
|
||||
|
||||
---
|
||||
|
||||
## Cleanup obligatoire
|
||||
|
||||
```typescript
|
||||
// Dans useAudioEngine — cleanup au unmount ou fin de séance
|
||||
async function unloadAll(): Promise<void> {
|
||||
await Promise.all(
|
||||
Object.values(soundRefs.current).map(sound => sound.unloadAsync())
|
||||
)
|
||||
soundRefs.current = {}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Cleanup synchrone au unmount
|
||||
Object.values(soundRefs.current).forEach(sound => {
|
||||
sound.unloadAsync().catch(console.error)
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tests minimaux
|
||||
|
||||
```typescript
|
||||
describe('useAudioEngine', () => {
|
||||
it('configure la session audio au mount', () => {})
|
||||
it('preloadAll charge les sons sans erreur', () => {})
|
||||
it('playPhaseSound joue le bon son dans les 50ms', () => {})
|
||||
it('switchIntensity effectue un crossfade', () => {})
|
||||
it('stopMusic fait un fade-out', () => {})
|
||||
it('unloadAll libère toutes les ressources', () => {})
|
||||
it('ne plante pas si un asset est manquant (fallback)', () => {})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Erreurs classiques à éviter
|
||||
|
||||
```typescript
|
||||
// ❌ Charger les sons à la demande → latence
|
||||
case 'PHASE_CHANGED':
|
||||
const { sound } = await Audio.Sound.createAsync(asset) // ← trop lent !
|
||||
await sound.playAsync()
|
||||
|
||||
// ✅ Utiliser les sons préchargés
|
||||
case 'PHASE_CHANGED':
|
||||
await soundRefs.current['work_start'].replayAsync() // ← instantané
|
||||
|
||||
// ❌ Oublier de configurer la session audio → muet sur iOS background
|
||||
await sound.playAsync() // sans Audio.setAudioModeAsync() → silencieux en background
|
||||
|
||||
// ❌ Pas de gestion du duck Android → nos sons se mélangent avec d'autres apps
|
||||
interruptionModeAndroid: 2 // DUCK_OTHERS — OK pour la musique d'ambiance
|
||||
interruptionModeAndroid: 1 // DO_NOT_MIX — pour les signaux de phase
|
||||
```
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/building-native-ui
|
||||
@@ -1,576 +0,0 @@
|
||||
---
|
||||
name: tabata-kine-design-system
|
||||
description: >
|
||||
Design system complet pour l'application Tabata Kiné. Utilise ce skill pour
|
||||
toute tâche liée au design, aux composants UI, aux écrans, aux couleurs, à la
|
||||
typographie ou aux décisions d'interface de l'app Tabata Kiné. Déclenche ce
|
||||
skill dès que l'utilisateur mentionne : un écran de l'app (onboarding, séance,
|
||||
dashboard, paywall, programmes), un composant (bouton, carte, timer, badge,
|
||||
input), une couleur, une typographie, une animation, un espacement, ou demande
|
||||
à coder un élément UI. Ce skill contient les règles non négociables du design
|
||||
"Dark Medical" — le style qui différencie l'app de tous les concurrents fitness.
|
||||
---
|
||||
|
||||
# Design System — Tabata Kiné
|
||||
|
||||
## Principe directeur : Dark Medical
|
||||
|
||||
L'app Tabata Kiné n'est **pas** une app fitness classique. C'est une app médicale
|
||||
qui utilise le format tabata. Le style "Dark Medical" traduit visuellement ce
|
||||
positionnement : fond sombre professionnel, vert santé comme seule couleur d'action,
|
||||
expertise kiné visible à chaque écran.
|
||||
|
||||
**Règles absolues :**
|
||||
- Pas de mode clair. Dark only, sans exception.
|
||||
- Le vert (#00C896) ne sert qu'aux actions et à la validation.
|
||||
- L'orange (#FF8A5C) ne sert qu'aux conseils kiné et alertes positives.
|
||||
- Le rouge (#FF4444) est réservé au timer en phase d'urgence (<10s).
|
||||
- Touch target minimum : 44×44px pour tous les éléments interactifs.
|
||||
|
||||
---
|
||||
|
||||
## 1. Tokens de couleur
|
||||
|
||||
### Fonds — Navy
|
||||
|
||||
| Token | Valeur | Usage |
|
||||
|-------|--------|-------|
|
||||
| `navy-900` | `#0D1B2A` | Fond principal de l'app |
|
||||
| `navy-800` | `#112240` | Surface 1 — cartes par défaut |
|
||||
| `navy-700` | `#1A3050` | Surface 2 — cartes surélevées |
|
||||
| `navy-600` | `#243C5E` | Bordures actives |
|
||||
|
||||
### Vert Kiné — action & santé
|
||||
|
||||
| Token | Valeur | Usage |
|
||||
|-------|--------|-------|
|
||||
| `green-500` | `#00C896` | CTA principal, timer effort, progress |
|
||||
| `green-600` | `#00A67C` | État hover / pressed |
|
||||
| `green-700` | `#00875F` | État active deep |
|
||||
| `green-dim` | `rgba(0,200,150,0.12)` | Fond badge, chip, card accent |
|
||||
| `green-border` | `rgba(0,200,150,0.35)` | Bordure card accent |
|
||||
|
||||
### Texte & bordures
|
||||
|
||||
| Token | Valeur | Usage |
|
||||
|-------|--------|-------|
|
||||
| `white-100` | `#E6F1FF` | Texte primaire |
|
||||
| `slate-300` | `#A8B2D8` | Texte secondaire |
|
||||
| `slate-400` | `#8892B0` | Texte tertiaire, placeholders |
|
||||
| `border-dim` | `rgba(168,178,216,0.15)` | Bordure par défaut |
|
||||
| `border-hover` | `rgba(168,178,216,0.25)` | Bordure hover |
|
||||
|
||||
### Orange — conseils kiné uniquement
|
||||
|
||||
| Token | Valeur | Usage |
|
||||
|-------|--------|-------|
|
||||
| `orange-500` | `#FF8A5C` | Tip card border, badge Kiné+ |
|
||||
| `orange-600` | `#E06A3C` | Hover orange |
|
||||
| `orange-dim` | `rgba(255,138,92,0.12)` | Fond tip card |
|
||||
|
||||
### Sémantique
|
||||
|
||||
| Token | Valeur | Usage |
|
||||
|-------|--------|-------|
|
||||
| `red-500` | `#FF4444` | Timer urgence <10s UNIQUEMENT |
|
||||
|
||||
---
|
||||
|
||||
## 2. Typographie
|
||||
|
||||
### Familles
|
||||
|
||||
| Rôle | Famille | Notes |
|
||||
|------|---------|-------|
|
||||
| Titres émotionnels | Serif italique (ex: DM Serif Display, Georgia) | Célébration, fin de séance, accroches |
|
||||
| Interface & corps | Sans-serif géométrique (ex: Outfit, DM Sans) | Navigation, descriptions, labels |
|
||||
| Données & timer | Monospace (ex: DM Mono, JetBrains Mono) | Timer, stats, codes, metadata |
|
||||
|
||||
### Échelle
|
||||
|
||||
| Style | Famille | Taille | Poids | Usage |
|
||||
|-------|---------|--------|-------|-------|
|
||||
| `display` | Serif italic | 28–32px | 400 | Fin de séance, titres forts |
|
||||
| `heading-1` | Serif | 22–24px | 500 | Titres de section |
|
||||
| `heading-2` | Sans | 18px | 500 | Titre exercice, carte programme |
|
||||
| `body` | Sans | 15–16px | 400 | Corps, conseil kiné |
|
||||
| `label` | Mono | 11–13px | 500 | Tags, metadata, uppercase tracking |
|
||||
| `timer` | Mono | **80–100px** | 500 | Timer séance — lisible à 2 mètres |
|
||||
| `caption` | Sans | 12px | 400 | Sous-labels, hints |
|
||||
|
||||
**Règle typographie :** La taille du timer est la décision de design la plus
|
||||
importante de l'écran séance. Tout se dimensionne autour de lui.
|
||||
|
||||
---
|
||||
|
||||
## 3. Espacement
|
||||
|
||||
Base : **4px**
|
||||
|
||||
| Token | Valeur | Usage |
|
||||
|-------|--------|-------|
|
||||
| `space-1` | 4px | Gap minimal entre éléments liés |
|
||||
| `space-2` | 8px | Gap interne composant |
|
||||
| `space-3` | 12px | Gap entre composants proches |
|
||||
| `space-4` | 16px | Padding carte, gap standard |
|
||||
| `space-6` | 24px | Espacement sections |
|
||||
| `space-8` | 32px | Padding écran horizontal |
|
||||
| `space-12` | 48px | Espacement majeur |
|
||||
| `space-16` | 64px | Espacement entre blocs screens |
|
||||
|
||||
---
|
||||
|
||||
## 4. Border Radius
|
||||
|
||||
| Token | Valeur | Usage |
|
||||
|-------|--------|-------|
|
||||
| `radius-sm` | 4px | Badge, chip, tag |
|
||||
| `radius-md` | 8px | Bouton, input, tip card |
|
||||
| `radius-lg` | 12px | Carte programme standard |
|
||||
| `radius-xl` | 16px | Carte large, modal |
|
||||
| `radius-pill` | 9999px | Pill, toggle, progress bar |
|
||||
| `radius-circle` | 50% | Icon button, avatar, streak dot |
|
||||
|
||||
---
|
||||
|
||||
## 5. Système d'élévation (surfaces)
|
||||
|
||||
```
|
||||
Fond (navy-900)
|
||||
└── Surface 1 (navy-800) — cartes par défaut
|
||||
└── Surface 2 (navy-700) — cartes surélevées / hover
|
||||
└── Surface active (navy-800 + border green-500 1.5px)
|
||||
```
|
||||
|
||||
Différencier les surfaces **uniquement par la couleur de fond**, jamais par des
|
||||
ombres portées (box-shadow : non). La bordure active verte est le seul signal
|
||||
d'état sélectionné.
|
||||
|
||||
---
|
||||
|
||||
## 6. Composants
|
||||
|
||||
### Boutons
|
||||
|
||||
```
|
||||
PrimaryButton
|
||||
background: green-500
|
||||
color: navy-900
|
||||
padding: 14px 24px
|
||||
height: 52–56px
|
||||
border-radius: radius-md
|
||||
font: sans 15px 500
|
||||
width: 100% (full-width dans les screens)
|
||||
hover: background green-600
|
||||
active: background green-700 + scale(0.98)
|
||||
|
||||
SecondaryButton
|
||||
background: transparent
|
||||
color: green-500
|
||||
border: 1.5px solid green-500
|
||||
padding: 13px 24px
|
||||
hover: background green-dim
|
||||
|
||||
GhostButton
|
||||
background: transparent
|
||||
color: slate-300
|
||||
no border
|
||||
usage: actions secondaires (Passer, Annuler)
|
||||
|
||||
DangerButton
|
||||
background: rgba(255,68,68,0.12)
|
||||
color: #FF6B6B
|
||||
border: 1px solid rgba(255,68,68,0.3)
|
||||
usage: Quitter la séance UNIQUEMENT
|
||||
|
||||
IconButton
|
||||
width: 44px
|
||||
height: 44px
|
||||
border-radius: 50%
|
||||
background: rgba(168,178,216,0.10)
|
||||
color: slate-300
|
||||
JAMAIS en dessous de 44×44px (accessibilité)
|
||||
```
|
||||
|
||||
### Inputs
|
||||
|
||||
```
|
||||
TextField
|
||||
background: navy-800
|
||||
border: 1px solid border-dim
|
||||
border-radius: radius-md
|
||||
padding: 12px 16px
|
||||
color: white-100
|
||||
font: sans 15px 400
|
||||
focus: border green-500
|
||||
error: border red-500
|
||||
height: 48px
|
||||
```
|
||||
|
||||
### Badges & Pills
|
||||
|
||||
```
|
||||
Badge (tier)
|
||||
font: mono 11px 500
|
||||
padding: 3px 10px
|
||||
border-radius: radius-sm
|
||||
UPPERCASE + letter-spacing: 0.08em
|
||||
|
||||
.free: background green-dim, color green-500
|
||||
.premium: background orange-dim, color orange-500
|
||||
.kine: background rgba(168,178,216,0.12), color slate-300
|
||||
|
||||
Pill (metadata)
|
||||
font: sans 12px 400
|
||||
padding: 4px 12px
|
||||
border-radius: radius-pill
|
||||
border: 1px solid (couleur correspondante à 0.3 opacity)
|
||||
```
|
||||
|
||||
### Cartes
|
||||
|
||||
```
|
||||
CardDefault
|
||||
background: navy-800
|
||||
border: 1px solid border-dim
|
||||
border-radius: radius-lg
|
||||
padding: 16px
|
||||
|
||||
CardAccent (CTA, prochaine séance)
|
||||
background: rgba(0,200,150,0.05)
|
||||
border: 1.5px solid green-border
|
||||
border-radius: radius-lg
|
||||
|
||||
CardTip (conseil kiné)
|
||||
background: orange-dim
|
||||
border-left: 3px solid orange-500
|
||||
border-radius: 0 radius-lg radius-lg 0
|
||||
NE PAS arrondir le côté gauche (border-left unique)
|
||||
Structure: icône 💡 + texte + signature "— Prénom, kiné"
|
||||
|
||||
CardProgram
|
||||
border-radius: radius-xl
|
||||
overflow: hidden
|
||||
Thumbnail: 120px height, gradient navy-700→navy-600
|
||||
Body: padding 14px
|
||||
Toujours afficher: progression bar + "X/12 séances"
|
||||
```
|
||||
|
||||
### Timer
|
||||
|
||||
```
|
||||
Timer (composant le plus critique de l'app)
|
||||
font: mono 80–100px 500
|
||||
text-align: center
|
||||
|
||||
État effort normal (>10s):
|
||||
color: green-500
|
||||
|
||||
État urgence (<10s):
|
||||
color: red-500
|
||||
animation: pulse subtil (scale 1→1.02→1, 1s infinite)
|
||||
|
||||
Label sous le chiffre:
|
||||
font: mono 14px 400
|
||||
color: slate-400
|
||||
letter-spacing: 0.1em
|
||||
text: "SECONDES"
|
||||
|
||||
Contexte repos:
|
||||
color: slate-300 (pas de vert, signal visuel de repos)
|
||||
```
|
||||
|
||||
### Progress Bar
|
||||
|
||||
```
|
||||
ProgressBar
|
||||
track: background rgba(168,178,216,0.12), height 4px, border-radius pill
|
||||
fill: background green-500, border-radius pill
|
||||
|
||||
Variante séance (épaisseur réduite):
|
||||
height: 3px
|
||||
|
||||
Variante programme:
|
||||
height: 4px
|
||||
Afficher le % à droite en mono 11px green-500
|
||||
|
||||
Animation: transition width 300ms ease
|
||||
```
|
||||
|
||||
### Feedback ressenti
|
||||
|
||||
```
|
||||
FeedbackButton
|
||||
width: flex (3 boutons égaux)
|
||||
height: 72px
|
||||
border-radius: radius-lg
|
||||
background: navy-800
|
||||
border: 1px solid border-dim
|
||||
flex-direction: column
|
||||
gap: 4px
|
||||
|
||||
Emoji: 28px
|
||||
Label: sans 12px slate-400
|
||||
|
||||
État sélectionné:
|
||||
border: 1.5px solid green-500
|
||||
background: green-dim
|
||||
```
|
||||
|
||||
### Streak hebdomadaire
|
||||
|
||||
```
|
||||
StreakDot
|
||||
width: 32px
|
||||
height: 32px
|
||||
border-radius: 50%
|
||||
|
||||
.done: background rgba(0,200,150,0.15) → afficher ✓
|
||||
.today: background green-500 → afficher ✓
|
||||
.empty: background rgba(168,178,216,0.06), border 1px border-dim
|
||||
|
||||
Label jour: mono 10px slate-400, centré sous chaque dot
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Écran séance — règles spéciales
|
||||
|
||||
L'écran séance est le plus critique de l'app. Il doit être utilisable **les mains
|
||||
sur les genoux, en sueur, à 2 mètres de l'écran**. Chaque décision de design doit
|
||||
passer ce test.
|
||||
|
||||
### Architecture visuelle
|
||||
|
||||
```
|
||||
[Vidéo plein écran en boucle — fond de tout l'écran]
|
||||
↓ Gradient top navy→transparent (40% opacité, 100px height)
|
||||
→ Contrôles pause/audio en overlay
|
||||
→ Indicateur exercice X/8 centré
|
||||
↓ Zone centrale nette (pas de gradient — l'utilisateur voit le mouvement)
|
||||
↓ Gradient bottom transparent→navy (70% opacité, 220px height)
|
||||
→ Timer géant centré
|
||||
→ Progress bar
|
||||
→ Tip card conseil kiné
|
||||
```
|
||||
|
||||
### Transitions séance
|
||||
|
||||
```
|
||||
Effort → Repos:
|
||||
Fond passe de vidéo plein écran → navy-800 uni
|
||||
Transition: fade 300ms
|
||||
Vibration haptique légère (si disponible)
|
||||
Le repos a une identité visuelle différente (pas de vidéo, couleur unie)
|
||||
|
||||
Repos → Effort:
|
||||
Countdown audio "3... 2... 1..."
|
||||
Vibration haptique + transition fade
|
||||
|
||||
Exercice suivant pendant le repos:
|
||||
Afficher un thumbnail 56×56px du prochain exercice
|
||||
Nom en sans 14px 500
|
||||
Label "PROCHAIN EXERCICE" en mono 11px slate-400
|
||||
```
|
||||
|
||||
### Phase repos
|
||||
|
||||
L'écran repos doit être **visuellement différent** de l'écran effort.
|
||||
|
||||
- Fond : `navy-800` uni (plus de vidéo plein écran)
|
||||
- Timer couleur : `slate-300` (pas de vert — c'est le repos)
|
||||
- Mot "REPOS" en mono 13px slate-400, letter-spacing 0.15em
|
||||
- Aperçu prochain exercice centré
|
||||
|
||||
---
|
||||
|
||||
## 8. Navigation
|
||||
|
||||
```
|
||||
Tab Bar (5 onglets, fixé en bas)
|
||||
height: 56px + safe area inset
|
||||
background: navy-800
|
||||
border-top: 1px solid border-dim
|
||||
|
||||
Onglets:
|
||||
- Accueil (home icon)
|
||||
- Programmes (grid icon)
|
||||
- Minuteur (timer icon)
|
||||
- Progression (chart icon)
|
||||
- Profil (person icon)
|
||||
|
||||
Onglet actif: icône green-500 + label green-500
|
||||
Onglet inactif: icône slate-400 + label slate-400
|
||||
|
||||
Font label: sans 11px 400
|
||||
Icon size: 22×22px
|
||||
Touch target: 44×44px minimum
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Animations & micro-interactions
|
||||
|
||||
```
|
||||
FadeIn:
|
||||
opacity: 0 → 1
|
||||
duration: 300ms
|
||||
easing: ease
|
||||
|
||||
SlideUp (bottom sheet, modal):
|
||||
translateY(100%) → 0
|
||||
duration: 400ms
|
||||
easing: cubic-bezier(0.4, 0, 0.2, 1)
|
||||
|
||||
Pulse (CTA bouton, timer urgence):
|
||||
scale: 1 → 1.02 → 1
|
||||
duration: 2s
|
||||
infinite, ease-in-out
|
||||
|
||||
Bounce (célébration fin de séance):
|
||||
scale: 0.5 → 1.05 → 0.98 → 1
|
||||
duration: 600ms
|
||||
easing: spring
|
||||
|
||||
StaggerList (items qui apparaissent en séquence):
|
||||
Délai: 100ms entre chaque item
|
||||
Chaque item: FadeIn + translateY(12px→0)
|
||||
|
||||
ScalePress (tous les boutons):
|
||||
active: scale(0.97)
|
||||
duration: 100ms
|
||||
```
|
||||
|
||||
**Règle d'or animations :** Une animation bien exécutée au chargement d'écran
|
||||
vaut mieux que des micro-interactions dispersées partout.
|
||||
|
||||
---
|
||||
|
||||
## 10. Paywall — règles de design conversion
|
||||
|
||||
```
|
||||
Structure obligatoire du paywall:
|
||||
1. Célébration des accomplissements (TOUJOURS en premier)
|
||||
→ Font serif italic, emoji, stats concrètes
|
||||
2. Valeur du contenu débloqué (liste concrète)
|
||||
3. Pricing transparent (pas de dark patterns)
|
||||
→ "Essai gratuit 7 jours · puis 24,99€/an · soit 2,08€/mois"
|
||||
4. CTA principal (PrimaryButton full-width)
|
||||
5. Réassurance ("Annulation facile à tout moment")
|
||||
6. Alternative gratuite visible (GhostButton ou lien)
|
||||
|
||||
Couleur encadré pricing: CardAccent (vert)
|
||||
Bouton fermeture: TOUJOURS visible en haut à gauche
|
||||
Pas de compte à rebours fictif, pas de stock limité : anti dark patterns
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Accessibilité
|
||||
|
||||
```
|
||||
Contraste texte:
|
||||
Texte primaire (#E6F1FF) sur navy-900 → ratio 15:1 ✓
|
||||
Texte secondaire (#A8B2D8) sur navy-900 → ratio 7:1 ✓
|
||||
Vert (#00C896) sur navy-900 → ratio 8:1 ✓
|
||||
Tous conformes WCAG AA (4.5:1 minimum requis)
|
||||
|
||||
Touch targets:
|
||||
Minimum 44×44px pour TOUS les éléments interactifs
|
||||
Espacement minimum 8px entre deux éléments interactifs adjacents
|
||||
|
||||
Timer:
|
||||
La couleur n'est pas le seul signal d'urgence
|
||||
Ajouter aussi: pulse animation + vibration haptique + signal audio
|
||||
|
||||
Audio:
|
||||
Toujours proposer une alternative visuelle à chaque signal audio
|
||||
Le toggle audio est accessible en 1 tap depuis l'écran séance
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Tokens React Native / Expo
|
||||
|
||||
```typescript
|
||||
// design-tokens.ts
|
||||
export const colors = {
|
||||
// Navy
|
||||
navy900: '#0D1B2A',
|
||||
navy800: '#112240',
|
||||
navy700: '#1A3050',
|
||||
navy600: '#243C5E',
|
||||
|
||||
// Green
|
||||
green500: '#00C896',
|
||||
green600: '#00A67C',
|
||||
green700: '#00875F',
|
||||
greenDim: 'rgba(0,200,150,0.12)',
|
||||
greenBorder: 'rgba(0,200,150,0.35)',
|
||||
|
||||
// Text
|
||||
white100: '#E6F1FF',
|
||||
slate300: '#A8B2D8',
|
||||
slate400: '#8892B0',
|
||||
|
||||
// Borders
|
||||
borderDim: 'rgba(168,178,216,0.15)',
|
||||
borderHover: 'rgba(168,178,216,0.25)',
|
||||
|
||||
// Orange (tip/kine only)
|
||||
orange500: '#FF8A5C',
|
||||
orange600: '#E06A3C',
|
||||
orangeDim: 'rgba(255,138,92,0.12)',
|
||||
|
||||
// Semantic
|
||||
red500: '#FF4444', // timer urgence ONLY
|
||||
} as const
|
||||
|
||||
export const spacing = {
|
||||
1: 4,
|
||||
2: 8,
|
||||
3: 12,
|
||||
4: 16,
|
||||
6: 24,
|
||||
8: 32,
|
||||
12: 48,
|
||||
16: 64,
|
||||
} as const
|
||||
|
||||
export const radius = {
|
||||
sm: 4,
|
||||
md: 8,
|
||||
lg: 12,
|
||||
xl: 16,
|
||||
pill: 9999,
|
||||
} as const
|
||||
|
||||
export const fontSizes = {
|
||||
caption: 12,
|
||||
label: 13,
|
||||
body: 15,
|
||||
heading2: 18,
|
||||
heading1: 22,
|
||||
display: 28,
|
||||
timer: 88, // taille par défaut du timer
|
||||
} as const
|
||||
|
||||
export const timerThreshold = 10 // secondes — passage vert → rouge
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist avant livraison d'un écran
|
||||
|
||||
- [ ] Fond `navy-900` utilisé comme base
|
||||
- [ ] Aucun shadow/élévation — différenciation par couleur uniquement
|
||||
- [ ] Tous les touch targets ≥ 44×44px
|
||||
- [ ] Le vert n'est utilisé que pour des actions ou validations
|
||||
- [ ] L'orange n'est utilisé que pour des conseils kiné ou alertes positives
|
||||
- [ ] Le rouge n'apparaît que sur le timer en urgence
|
||||
- [ ] Timer ≥ 80px de haut sur l'écran séance
|
||||
- [ ] Vidéo plein écran sur l'écran séance (pas un bloc vidéo)
|
||||
- [ ] Gradients top + bottom sur l'écran séance pour la lisibilité
|
||||
- [ ] Phase repos visuellement différente de la phase effort
|
||||
- [ ] Paywall : célébration en premier, alternative gratuite visible
|
||||
- [ ] Typographie : serif pour les moments émotionnels, mono pour les données
|
||||
@@ -1,428 +0,0 @@
|
||||
# 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
|
||||
@@ -21,8 +21,9 @@ Run from the project root. This parses all source files, builds the knowledge gr
|
||||
| -------------- | ---------------------------------------------------------------- |
|
||||
| `--force` | Force full re-index even if up to date |
|
||||
| `--embeddings` | Enable embedding generation for semantic search (off by default) |
|
||||
| `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. |
|
||||
|
||||
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook runs `analyze` automatically after `git commit` and `git merge`, preserving embeddings if previously generated.
|
||||
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook detects staleness after `git commit` and `git merge` and notifies the agent to run `analyze` — the hook does not run analyze itself, to avoid blocking the agent for up to 120s and risking KuzuDB corruption on timeout.
|
||||
|
||||
### status — Check index freshness
|
||||
|
||||
|
||||
@@ -1,349 +0,0 @@
|
||||
# Skill — Timer Engine (useTimerEngine)
|
||||
> Lis ce skill AVANT d'implémenter quoi que ce soit lié au timer.
|
||||
|
||||
## Responsabilité de ce module
|
||||
Le timer est le cœur battant de l'app. Il doit être :
|
||||
- **Précis** : drift < 50ms sur une séance complète (8 rounds)
|
||||
- **Résilient** : continue en background, survit aux appels téléphoniques
|
||||
- **Découplé** : aucune dépendance vers l'audio ou les exercices
|
||||
- **Testable** : logique pure, facile à tester sans simulateur
|
||||
|
||||
---
|
||||
|
||||
## Architecture du module timer
|
||||
|
||||
```
|
||||
src/features/timer/
|
||||
types.ts ← Commencer ICI
|
||||
hooks/
|
||||
useTimerEngine.ts ← Moteur central (logique pure)
|
||||
useTimerSync.ts ← Pont vers audio + exercices
|
||||
useTimerPersistence.ts ← Sauvegarde config dans AsyncStorage
|
||||
components/
|
||||
TimerDisplay.tsx ← Écran plein écran (props-only)
|
||||
TimerControls.tsx ← Boutons start/pause/stop/skip
|
||||
TimerPhaseIndicator.tsx ← Barre de progression des rounds
|
||||
TimerRing.tsx ← Anneau circulaire animé
|
||||
index.ts ← Barrel export
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Types — commencer par ici
|
||||
|
||||
```typescript
|
||||
// src/features/timer/types.ts
|
||||
|
||||
export type TimerPhase = 'IDLE' | 'GET_READY' | 'WORK' | 'REST' | 'COMPLETE'
|
||||
|
||||
export interface TimerConfig {
|
||||
workDuration: number // défaut : 20s
|
||||
restDuration: number // défaut : 10s
|
||||
rounds: number // défaut : 8
|
||||
getReadyDuration: number // défaut : 10s
|
||||
cycles: number // défaut : 1 (Premium : jusqu'à 10)
|
||||
cyclePauseDuration: number // défaut : 60s (pause entre cycles, Premium)
|
||||
}
|
||||
|
||||
export interface TimerState {
|
||||
phase: TimerPhase
|
||||
secondsLeft: number
|
||||
currentRound: number // 1-indexé
|
||||
totalRounds: number
|
||||
currentCycle: number // 1-indexé
|
||||
totalCycles: number
|
||||
isRunning: boolean
|
||||
isPaused: boolean
|
||||
totalElapsedSeconds: number // pour le résumé de séance
|
||||
}
|
||||
|
||||
export interface TimerActions {
|
||||
start: (config: TimerConfig) => void
|
||||
pause: () => void
|
||||
resume: () => void
|
||||
stop: () => void
|
||||
skip: () => void // passer à la phase suivante immédiatement
|
||||
}
|
||||
|
||||
// Événements émis par le timer — consommés par useTimerSync
|
||||
export type TimerEvent =
|
||||
| { type: 'PHASE_CHANGED'; from: TimerPhase; to: TimerPhase }
|
||||
| { type: 'ROUND_COMPLETED'; round: number }
|
||||
| { type: 'SESSION_COMPLETE'; totalSeconds: number }
|
||||
| { type: 'COUNTDOWN_TICK'; secondsLeft: number } // pour les dernières 3s
|
||||
|
||||
export type TimerEventListener = (event: TimerEvent) => void
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implémentation — useTimerEngine
|
||||
|
||||
### Principe de précision : Date.now() delta
|
||||
|
||||
**JAMAIS** se fier uniquement à setInterval pour le compte à rebours.
|
||||
setInterval drift de 10-100ms par tick sur mobile. Sur 8 rounds
|
||||
de 20s + 10s, ça peut représenter plusieurs secondes d'écart.
|
||||
|
||||
```typescript
|
||||
// ✅ Approche correcte — delta sur Date.now()
|
||||
const tickRef = useRef<number | null>(null)
|
||||
const targetEndTimeRef = useRef<number>(0)
|
||||
|
||||
function tick() {
|
||||
const now = Date.now()
|
||||
const remaining = Math.max(0, targetEndTimeRef.current - now)
|
||||
const secondsLeft = Math.ceil(remaining / 1000)
|
||||
|
||||
setSecondsLeft(secondsLeft)
|
||||
|
||||
if (remaining <= 0) {
|
||||
advancePhase()
|
||||
} else {
|
||||
// Planifier le prochain tick dans ~100ms (pas 1000ms)
|
||||
// pour une réactivité maximale sur les transitions
|
||||
tickRef.current = setTimeout(tick, 100)
|
||||
}
|
||||
}
|
||||
|
||||
function startPhase(duration: number) {
|
||||
targetEndTimeRef.current = Date.now() + duration * 1000
|
||||
tickRef.current = setTimeout(tick, 100)
|
||||
}
|
||||
```
|
||||
|
||||
### Gestion du background (AppState)
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener('change', (nextState) => {
|
||||
if (nextState === 'background' || nextState === 'inactive') {
|
||||
// Sauvegarder le timestamp cible — pas l'état courant
|
||||
// Quand on revient en foreground, on recalcule
|
||||
saveBackgroundTimestamp(targetEndTimeRef.current)
|
||||
}
|
||||
|
||||
if (nextState === 'active') {
|
||||
const savedTarget = loadBackgroundTimestamp()
|
||||
if (savedTarget && isRunningRef.current) {
|
||||
// Recalculer le temps restant depuis le timestamp sauvegardé
|
||||
targetEndTimeRef.current = savedTarget
|
||||
// Si le temps est dépassé, avancer aux phases manquées
|
||||
reconcilePhaseAfterBackground(savedTarget, Date.now())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return () => subscription.remove()
|
||||
}, [])
|
||||
```
|
||||
|
||||
### Notification sticky pendant la séance
|
||||
|
||||
Utiliser expo-notifications pour afficher le décompte en background :
|
||||
|
||||
```typescript
|
||||
async function updateBackgroundNotification(phase: TimerPhase, secondsLeft: number) {
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
identifier: 'tabata-timer-sticky',
|
||||
content: {
|
||||
title: phase === 'WORK' ? '🔥 Travail !' : '💨 Repos',
|
||||
body: `${secondsLeft}s — Round ${currentRound}/${totalRounds}`,
|
||||
sticky: true,
|
||||
autoDismiss: false,
|
||||
},
|
||||
trigger: null,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Ordre des transitions de phase
|
||||
|
||||
```
|
||||
IDLE
|
||||
↓ start()
|
||||
GET_READY (getReadyDuration)
|
||||
↓ temps écoulé
|
||||
WORK (workDuration)
|
||||
↓ temps écoulé
|
||||
REST (restDuration)
|
||||
↓ temps écoulé
|
||||
→ si currentRound < totalRounds : retour à WORK
|
||||
→ si currentRound === totalRounds ET currentCycle < totalCycles :
|
||||
PAUSE_BETWEEN_CYCLES → WORK (round 1 du cycle suivant)
|
||||
→ si currentRound === totalRounds ET currentCycle === totalCycles :
|
||||
COMPLETE
|
||||
↓ stop() ou auto-reset après 3s
|
||||
IDLE
|
||||
```
|
||||
|
||||
### Événements à émettre aux abonnés
|
||||
|
||||
```typescript
|
||||
// À chaque changement de phase
|
||||
emitEvent({ type: 'PHASE_CHANGED', from: prevPhase, to: nextPhase })
|
||||
|
||||
// À chaque fin de round
|
||||
emitEvent({ type: 'ROUND_COMPLETED', round: currentRound })
|
||||
|
||||
// Décompte final (3, 2, 1)
|
||||
if (secondsLeft <= 3) {
|
||||
emitEvent({ type: 'COUNTDOWN_TICK', secondsLeft })
|
||||
}
|
||||
|
||||
// Fin de séance
|
||||
emitEvent({ type: 'SESSION_COMPLETE', totalSeconds: totalElapsedSeconds })
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Interface publique du hook
|
||||
|
||||
```typescript
|
||||
export function useTimerEngine(): TimerState & TimerActions & {
|
||||
addEventListener: (listener: TimerEventListener) => () => void
|
||||
config: TimerConfig
|
||||
}
|
||||
```
|
||||
|
||||
Le retour doit permettre :
|
||||
```typescript
|
||||
const {
|
||||
phase, secondsLeft, currentRound, totalRounds, isRunning, isPaused,
|
||||
start, pause, resume, stop, skip,
|
||||
addEventListener,
|
||||
config,
|
||||
} = useTimerEngine()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Composant TimerDisplay — layout plein écran
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐ ← StatusBar cachée
|
||||
│ [Exercice] Round 3/8 ●○○ │ ← Zone haute (20%) — fond semi-transparent
|
||||
├─────────────────────────────┤
|
||||
│ │
|
||||
│ │
|
||||
│ ┌───────┐ │
|
||||
│ │ :17 │ │ ← Anneau + chiffre (50% de l'écran)
|
||||
│ └───────┘ │ ← Police monospace, taille ~120px
|
||||
│ │
|
||||
│ ████████████░░░░░░░░░░░░░ │ ← Barre de progression totale (15%)
|
||||
│ │
|
||||
│ ⏸ ■ ⏭ │ ← Boutons discrets (15%) — Pause/Stop/Skip
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
Props obligatoires du composant :
|
||||
```typescript
|
||||
interface TimerDisplayProps {
|
||||
state: TimerState
|
||||
exerciseName: string
|
||||
nextExerciseName: string
|
||||
onPause: () => void
|
||||
onStop: () => void
|
||||
onSkip: () => void
|
||||
}
|
||||
```
|
||||
|
||||
### Animations requises
|
||||
- **Fond** : transition de couleur animée entre phases (600ms ease-in-out)
|
||||
- `Animated.timing` sur la backgroundColor avec interpolation de couleurs
|
||||
- **Chiffre** : légère pulsation à chaque seconde (`Animated.sequence` scale 1→1.05→1)
|
||||
- **Anneau** : `Animated.Value` sur stroke-dashoffset (SVG) ou rotation
|
||||
- **Décompte final** : flash rouge sur les 3 dernières secondes
|
||||
|
||||
```typescript
|
||||
// Interpolation de couleur entre phases
|
||||
const backgroundAnim = useRef(new Animated.Value(0)).current
|
||||
|
||||
const backgroundColor = backgroundAnim.interpolate({
|
||||
inputRange: [0, 1, 2, 3],
|
||||
outputRange: [
|
||||
PHASE_COLORS.IDLE,
|
||||
PHASE_COLORS.GET_READY,
|
||||
PHASE_COLORS.WORK,
|
||||
PHASE_COLORS.REST,
|
||||
],
|
||||
})
|
||||
|
||||
// Déclencher quand la phase change
|
||||
useEffect(() => {
|
||||
const phaseIndex = { IDLE: 0, GET_READY: 1, WORK: 2, REST: 3, COMPLETE: 0 }
|
||||
Animated.timing(backgroundAnim, {
|
||||
toValue: phaseIndex[phase],
|
||||
duration: 600,
|
||||
useNativeDriver: false, // obligatoire pour backgroundColor
|
||||
}).start()
|
||||
}, [phase])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## expo-keep-awake — écran allumé pendant la séance
|
||||
|
||||
```typescript
|
||||
import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake'
|
||||
|
||||
// Dans useTimerEngine
|
||||
useEffect(() => {
|
||||
if (isRunning) {
|
||||
activateKeepAwakeAsync('tabata-session')
|
||||
} else {
|
||||
deactivateKeepAwake('tabata-session')
|
||||
}
|
||||
}, [isRunning])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tests minimaux à écrire
|
||||
|
||||
```typescript
|
||||
describe('useTimerEngine', () => {
|
||||
it('démarre en phase IDLE avec la config par défaut', () => {})
|
||||
it('passe à GET_READY quand start() est appelé', () => {})
|
||||
it('passe à WORK après la fin de GET_READY', () => {})
|
||||
it('passe à REST après la fin de WORK', () => {})
|
||||
it('incrémente currentRound après chaque REST', () => {})
|
||||
it('passe à COMPLETE après le dernier round', () => {})
|
||||
it('retourne à IDLE après stop()', () => {})
|
||||
it('skip() avance immédiatement à la phase suivante', () => {})
|
||||
it('émet PHASE_CHANGED à chaque transition', () => {})
|
||||
it('émet COUNTDOWN_TICK pour 3, 2, 1', () => {})
|
||||
it('cleanup useEffect enlève tous les listeners', () => {})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Erreurs classiques à éviter
|
||||
|
||||
```typescript
|
||||
// ❌ Drift — setInterval seul
|
||||
setInterval(() => setSecondsLeft(prev => prev - 1), 1000)
|
||||
|
||||
// ✅ Delta sur Date.now()
|
||||
const remaining = Math.max(0, targetEndTimeRef.current - Date.now())
|
||||
setSecondsLeft(Math.ceil(remaining / 1000))
|
||||
|
||||
// ❌ State React pour les valeurs temps-critique
|
||||
const [targetEndTime, setTargetEndTime] = useState(0)
|
||||
|
||||
// ✅ Ref pour les valeurs utilisées dans les closures de timeout
|
||||
const targetEndTimeRef = useRef(0)
|
||||
|
||||
// ❌ Pas de cleanup — memory leak
|
||||
useEffect(() => {
|
||||
setTimeout(tick, 100)
|
||||
})
|
||||
|
||||
// ✅ Cleanup systématique
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (tickRef.current) clearTimeout(tickRef.current)
|
||||
}
|
||||
}, [])
|
||||
```
|
||||
@@ -1,233 +0,0 @@
|
||||
# Skill — Workflow de Développement TabataGo
|
||||
> Lis ce skill en début de session ou quand tu commences une nouvelle feature.
|
||||
|
||||
## Principe fondamental
|
||||
**Plan d'abord, code ensuite.** Pour toute feature non triviale :
|
||||
1. Analyse les specs dans le PRD (`docs/PRD.md`)
|
||||
2. Liste les edge cases et les dépendances
|
||||
3. Propose l'architecture (types, hooks, composants)
|
||||
4. Attends validation avant de générer du code
|
||||
|
||||
Ne jamais écrire de code avant d'avoir une architecture approuvée
|
||||
sur les features cœur (timer, audio, exercices).
|
||||
|
||||
---
|
||||
|
||||
## Cycle de développement par feature
|
||||
|
||||
### Étape 1 — Lire les specs
|
||||
Avant toute implémentation, lis la section PRD correspondante.
|
||||
Identifie exactement :
|
||||
- Les entrées/sorties du système
|
||||
- Les états possibles
|
||||
- Les cas limites (interruptions, background, erreurs)
|
||||
|
||||
### Étape 2 — Typer en premier
|
||||
Commence TOUJOURS par le fichier `types.ts` de la feature.
|
||||
Les types sont le contrat — ils précèdent l'implémentation.
|
||||
|
||||
```typescript
|
||||
// Exemple pour le timer — types.ts
|
||||
export type TimerPhase = 'IDLE' | 'GET_READY' | 'WORK' | 'REST' | 'COMPLETE'
|
||||
|
||||
export interface TimerState {
|
||||
phase: TimerPhase
|
||||
secondsLeft: number
|
||||
currentRound: number
|
||||
totalRounds: number
|
||||
isRunning: boolean
|
||||
}
|
||||
|
||||
export interface TimerConfig {
|
||||
workDuration: number // secondes, défaut 20
|
||||
restDuration: number // secondes, défaut 10
|
||||
rounds: number // défaut 8
|
||||
getReadyDuration: number // secondes, défaut 10
|
||||
}
|
||||
|
||||
export interface TimerActions {
|
||||
start: () => void
|
||||
pause: () => void
|
||||
resume: () => void
|
||||
stop: () => void
|
||||
skip: () => void
|
||||
}
|
||||
```
|
||||
|
||||
**Ne passe à l'étape 3 que si les types semblent corrects.**
|
||||
|
||||
### Étape 3 — Implémenter le hook (logique métier)
|
||||
Implémenter le hook central de la feature.
|
||||
Règles :
|
||||
- Aucun import depuis React Native UI dans les hooks métier
|
||||
- Chaque hook expose un état + des actions (pattern Zustand-like)
|
||||
- Les side effects sont dans useEffect avec cleanup systématique
|
||||
- Logger les transitions d'état en dev (`__DEV__ && console.log(...)`)
|
||||
|
||||
### Étape 4 — Implémenter les composants
|
||||
Les composants reçoivent tout par props — ils ne fetchent rien,
|
||||
ne font aucune logique métier.
|
||||
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
function TimerDisplay({ phase, secondsLeft, currentRound }: TimerDisplayProps) {
|
||||
return <Text>{secondsLeft}</Text>
|
||||
}
|
||||
|
||||
// ❌ Interdit
|
||||
function TimerDisplay() {
|
||||
const timer = useTimerEngine() // logique dans le composant
|
||||
return <Text>{timer.secondsLeft}</Text>
|
||||
}
|
||||
```
|
||||
|
||||
### Étape 5 — Tester avant de continuer
|
||||
Avant de passer à la feature suivante, écrire les tests minimaux :
|
||||
- État initial correct
|
||||
- Transitions de phase correctes
|
||||
- Cleanup des side effects (pas de memory leak)
|
||||
|
||||
---
|
||||
|
||||
## Patterns obligatoires
|
||||
|
||||
### Gestion d'état avec state machine explicite
|
||||
Toujours utiliser une state machine pour les flows complexes.
|
||||
Jamais de booléens en cascade (`isRunning && !isPaused && !isComplete`).
|
||||
|
||||
```typescript
|
||||
// ✅ State machine explicite
|
||||
type TimerPhase = 'IDLE' | 'GET_READY' | 'WORK' | 'REST' | 'COMPLETE'
|
||||
|
||||
const VALID_TRANSITIONS: Record<TimerPhase, TimerPhase[]> = {
|
||||
IDLE: ['GET_READY'],
|
||||
GET_READY: ['WORK', 'IDLE'],
|
||||
WORK: ['REST', 'IDLE'],
|
||||
REST: ['WORK', 'COMPLETE', 'IDLE'],
|
||||
COMPLETE: ['IDLE'],
|
||||
}
|
||||
|
||||
function canTransition(from: TimerPhase, to: TimerPhase): boolean {
|
||||
return VALID_TRANSITIONS[from].includes(to)
|
||||
}
|
||||
```
|
||||
|
||||
### Cleanup systématique des useEffect
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener('change', handleAppState)
|
||||
return () => subscription.remove() // TOUJOURS un cleanup
|
||||
}, [])
|
||||
```
|
||||
|
||||
### AsyncStorage — wrapper typé uniquement
|
||||
Ne jamais appeler AsyncStorage directement dans les composants.
|
||||
Passer par un hook ou un service :
|
||||
|
||||
```typescript
|
||||
// shared/utils/storage.ts
|
||||
export async function saveTimerConfig(config: TimerConfig): Promise<void> {
|
||||
await AsyncStorage.setItem('timer_config', JSON.stringify(config))
|
||||
}
|
||||
|
||||
export async function loadTimerConfig(): Promise<TimerConfig | null> {
|
||||
const raw = await AsyncStorage.getItem('timer_config')
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}
|
||||
```
|
||||
|
||||
### Constantes centralisées
|
||||
```typescript
|
||||
// shared/constants/timer.ts
|
||||
export const TIMER_DEFAULTS = {
|
||||
WORK_DURATION: 20,
|
||||
REST_DURATION: 10,
|
||||
ROUNDS: 8,
|
||||
GET_READY_DURATION: 10,
|
||||
} as const
|
||||
|
||||
// shared/constants/colors.ts
|
||||
export const PHASE_COLORS = {
|
||||
GET_READY: '#EAB308', // Jaune
|
||||
WORK: '#F97316', // Orange
|
||||
REST: '#3B82F6', // Bleu
|
||||
COMPLETE: '#22C55E', // Vert
|
||||
IDLE: '#1E1E2E', // Dark
|
||||
} as const
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Gestion des erreurs
|
||||
|
||||
### Pattern try/catch dans les hooks async
|
||||
```typescript
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
const data = await fetchSomething()
|
||||
setData(data)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Erreur inconnue')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Fallback dans les composants
|
||||
Tout composant qui charge des ressources (GIF, audio) doit avoir
|
||||
un état de fallback visible et non-bloquant.
|
||||
|
||||
---
|
||||
|
||||
## Checklist avant de soumettre du code
|
||||
|
||||
Avant de considérer une feature terminée, vérifier :
|
||||
|
||||
- [ ] Les types sont définis dans `types.ts`
|
||||
- [ ] La logique est dans des hooks, pas dans des composants
|
||||
- [ ] Tous les `useEffect` ont un cleanup
|
||||
- [ ] Aucun `console.log` en dehors de `if (__DEV__)`
|
||||
- [ ] Les constantes sont dans `shared/constants/`
|
||||
- [ ] Les fonctions async ont un try/catch
|
||||
- [ ] Un test minimal existe pour les cas principaux
|
||||
- [ ] Le barrel export `index.ts` est mis à jour
|
||||
|
||||
---
|
||||
|
||||
## Workflow de debugging avec les logs Expo
|
||||
|
||||
Pour surveiller les logs en temps réel et donner du contexte à Claude :
|
||||
|
||||
```bash
|
||||
# Terminal 1 — démarrer expo avec logs dans un fichier
|
||||
npx expo start 2>&1 | tee .expo-logs/dev.log
|
||||
|
||||
# Ensuite dans Claude Code
|
||||
> Surveille .expo-logs/dev.log et identifie les warnings
|
||||
liés au module [nom du module en cours]
|
||||
```
|
||||
|
||||
Ajouter des logs structurés dans les hooks critiques :
|
||||
```typescript
|
||||
if (__DEV__) {
|
||||
console.log('[TimerEngine]', { phase, secondsLeft, currentRound })
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commandes Claude Code utiles
|
||||
|
||||
| Situation | Commande |
|
||||
|---|---|
|
||||
| Début de projet / onboarding | `/init` |
|
||||
| Session longue (>45 min) | `/compact` — focus sur la feature en cours |
|
||||
| Avant chaque commit | `/review` |
|
||||
| Quand Claude part en vrille | Donner un fichier précis : `@src/features/timer/hooks/useTimerEngine.ts` |
|
||||
| Architecture à valider | Demander explicitement "ne génère pas de code, propose seulement l'architecture" |
|
||||
60
AGENTS.md
60
AGENTS.md
@@ -449,7 +449,7 @@ Search results can flood context. Use `context-mode_ctx_execute(language: "shell
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **tabatago** (1839 symbols, 3401 relationships, 52 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **tabatago** (3362 symbols, 9407 relationships, 129 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
@@ -461,19 +461,6 @@ This project is indexed by GitNexus as **tabatago** (1839 symbols, 3401 relation
|
||||
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
||||
|
||||
## When Debugging
|
||||
|
||||
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
|
||||
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
|
||||
3. `READ gitnexus://repo/tabatago/process/{processName}` — trace the full execution flow step by step
|
||||
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
|
||||
|
||||
## When Refactoring
|
||||
|
||||
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
|
||||
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
|
||||
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
|
||||
|
||||
## Never Do
|
||||
|
||||
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
||||
@@ -481,25 +468,6 @@ This project is indexed by GitNexus as **tabatago** (1839 symbols, 3401 relation
|
||||
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
||||
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
||||
|
||||
## Tools Quick Reference
|
||||
|
||||
| Tool | When to use | Command |
|
||||
|------|-------------|---------|
|
||||
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
|
||||
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
|
||||
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
|
||||
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
|
||||
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
|
||||
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
|
||||
|
||||
## Impact Risk Levels
|
||||
|
||||
| Depth | Meaning | Action |
|
||||
|-------|---------|--------|
|
||||
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
|
||||
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
|
||||
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | Use for |
|
||||
@@ -509,32 +477,6 @@ This project is indexed by GitNexus as **tabatago** (1839 symbols, 3401 relation
|
||||
| `gitnexus://repo/tabatago/processes` | All execution flows |
|
||||
| `gitnexus://repo/tabatago/process/{name}` | Step-by-step execution trace |
|
||||
|
||||
## Self-Check Before Finishing
|
||||
|
||||
Before completing any code modification task, verify:
|
||||
1. `gitnexus_impact` was run for all modified symbols
|
||||
2. No HIGH/CRITICAL risk warnings were ignored
|
||||
3. `gitnexus_detect_changes()` confirms changes match expected scope
|
||||
4. All d=1 (WILL BREAK) dependents were updated
|
||||
|
||||
## Keeping the Index Fresh
|
||||
|
||||
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze
|
||||
```
|
||||
|
||||
If the index previously included embeddings, preserve them by adding `--embeddings`:
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze --embeddings
|
||||
```
|
||||
|
||||
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
|
||||
|
||||
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
|
||||
|
||||
## CLI
|
||||
|
||||
| Task | Read this skill file |
|
||||
|
||||
60
CLAUDE.md
60
CLAUDE.md
@@ -217,7 +217,7 @@ Voir `.claude/skills/` pour les guides spécialisés.
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **tabatago** (1839 symbols, 3401 relationships, 52 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **tabatago** (3362 symbols, 9407 relationships, 129 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
@@ -229,19 +229,6 @@ This project is indexed by GitNexus as **tabatago** (1839 symbols, 3401 relation
|
||||
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
||||
|
||||
## When Debugging
|
||||
|
||||
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
|
||||
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
|
||||
3. `READ gitnexus://repo/tabatago/process/{processName}` — trace the full execution flow step by step
|
||||
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
|
||||
|
||||
## When Refactoring
|
||||
|
||||
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
|
||||
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
|
||||
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
|
||||
|
||||
## Never Do
|
||||
|
||||
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
||||
@@ -249,25 +236,6 @@ This project is indexed by GitNexus as **tabatago** (1839 symbols, 3401 relation
|
||||
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
||||
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
||||
|
||||
## Tools Quick Reference
|
||||
|
||||
| Tool | When to use | Command |
|
||||
|------|-------------|---------|
|
||||
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
|
||||
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
|
||||
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
|
||||
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
|
||||
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
|
||||
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
|
||||
|
||||
## Impact Risk Levels
|
||||
|
||||
| Depth | Meaning | Action |
|
||||
|-------|---------|--------|
|
||||
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
|
||||
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
|
||||
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | Use for |
|
||||
@@ -277,32 +245,6 @@ This project is indexed by GitNexus as **tabatago** (1839 symbols, 3401 relation
|
||||
| `gitnexus://repo/tabatago/processes` | All execution flows |
|
||||
| `gitnexus://repo/tabatago/process/{name}` | Step-by-step execution trace |
|
||||
|
||||
## Self-Check Before Finishing
|
||||
|
||||
Before completing any code modification task, verify:
|
||||
1. `gitnexus_impact` was run for all modified symbols
|
||||
2. No HIGH/CRITICAL risk warnings were ignored
|
||||
3. `gitnexus_detect_changes()` confirms changes match expected scope
|
||||
4. All d=1 (WILL BREAK) dependents were updated
|
||||
|
||||
## Keeping the Index Fresh
|
||||
|
||||
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze
|
||||
```
|
||||
|
||||
If the index previously included embeddings, preserve them by adding `--embeddings`:
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze --embeddings
|
||||
```
|
||||
|
||||
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
|
||||
|
||||
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
|
||||
|
||||
## CLI
|
||||
|
||||
| Task | Read this skill file |
|
||||
|
||||
Reference in New Issue
Block a user