3 Commits

Author SHA1 Message Date
Millian Lamiaux
cd6fea9b53 ci: add App Store submission pipeline via GitHub Actions
Some checks failed
CI / TypeScript (pull_request) Failing after 4s
CI / ESLint (pull_request) Failing after 4s
CI / Tests (pull_request) Failing after 12s
CI / Build Check (pull_request) Has been skipped
CI / Admin Web Tests (pull_request) Successful in 2m7s
CI / Deploy Edge Functions (pull_request) Has been skipped
2026-05-21 10:47:48 +02:00
Millian Lamiaux
d31b769ab8 chore: update docs and remove stale skill files 2026-05-21 10:21:57 +02:00
Millian Lamiaux
c152c22ffb feat: redesign Dynamic Island with phase-driven UI and animations 2026-05-21 10:21:22 +02:00
25 changed files with 650 additions and 2154 deletions

View File

@@ -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
```

View File

@@ -1 +0,0 @@
../../.agents/skills/building-native-ui

View File

@@ -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 | 2832px | 400 | Fin de séance, titres forts |
| `heading-1` | Serif | 2224px | 500 | Titres de section |
| `heading-2` | Sans | 18px | 500 | Titre exercice, carte programme |
| `body` | Sans | 1516px | 400 | Corps, conseil kiné |
| `label` | Mono | 1113px | 500 | Tags, metadata, uppercase tracking |
| `timer` | Mono | **80100px** | 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: 5256px
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 80100px 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

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}
}, [])
```

View File

@@ -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" |

82
.github/workflows/app-store.yml vendored Normal file
View File

@@ -0,0 +1,82 @@
name: App Store Submission
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
upload-to-app-store:
name: Archive & Upload to App Store
runs-on: macos-15
timeout-minutes: 60
defaults:
run:
working-directory: tabatago-swift
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Select Xcode
run: sudo xcode-select -switch /Applications/Xcode.app
- name: Cache SPM dependencies
uses: actions/cache@v4
with:
path: ~/Library/Developer/Xcode/DerivedData/**/SourcePackages
key: spm-macos-${{ hashFiles('tabatago-swift/project.yml') }}
restore-keys: |
spm-macos-
- name: Write App Store Connect API key
env:
API_KEY_P8: ${{ secrets.APP_STORE_CONNECT_API_KEY_P8 }}
run: |
printf '%s' "$API_KEY_P8" > "$RUNNER_TEMP/AuthKey.p8"
- name: Archive
run: |
xcodebuild archive \
-project TabataGo.xcodeproj \
-scheme TabataGo \
-configuration Release \
-archivePath ./build/TabataGo.xcarchive \
-allowProvisioningUpdates \
-authenticationKeyPath "$RUNNER_TEMP/AuthKey.p8" \
-authenticationKeyID "${{ secrets.APP_STORE_CONNECT_KEY_ID }}" \
-authenticationKeyIssuerID "${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}" \
|| { echo "❌ Archive failed — check signing and API key permissions"; exit 1; }
- name: Verify build settings
run: |
echo "Checking version and build number..."
xcodebuild -showBuildSettings \
-project TabataGo.xcodeproj \
-scheme TabataGo \
-configuration Release \
| grep -E "MARKETING_VERSION|CURRENT_PROJECT_VERSION"
- name: Export IPA
run: |
xcodebuild -exportArchive \
-archivePath ./build/TabataGo.xcarchive \
-exportPath ./build/export \
-exportOptionsPlist ExportOptions.plist \
-allowProvisioningUpdates \
-authenticationKeyPath "$RUNNER_TEMP/AuthKey.p8" \
-authenticationKeyID "${{ secrets.APP_STORE_CONNECT_KEY_ID }}" \
-authenticationKeyIssuerID "${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}" \
|| { echo "❌ Export failed — check ExportOptions.plist and provisioning"; exit 1; }
# NOTE: The first upload automatically creates the app record in
# App Store Connect if one does not already exist.
- name: Upload to App Store
run: |
xcrun altool --upload-app \
--type ios \
--file ./build/export/TabataGo.ipa \
--apiKey "${{ secrets.APP_STORE_CONNECT_KEY_ID }}" \
--apiIssuer "${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}" \
|| { echo "❌ Upload failed — check API key permissions and app record"; exit 1; }

View File

@@ -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 |

View File

@@ -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 |

View File

@@ -0,0 +1,71 @@
# App Store Submission
## Prerequisites
Before triggering the CI pipeline, you need:
1. **Apple Developer Program membership** (paid, $99/year)
2. **App Store Connect access** with Admin or App Manager role
## Step 1: Create App Store Connect API Key
1. Go to [App Store Connect → Users and Access → Integrations → App Store Connect API](https://appstoreconnect.apple.com/access/integrations/api)
2. Click **Generate API Key** (or "+" button)
3. Name it: `GitHub Actions CI`
4. Set access to: **App Manager** (required for auto-signing + upload)
5. Download the `.p8` file immediately (you cannot re-download it later)
6. Note the **Key ID** and **Issuer ID** displayed on the page
## Step 2: Add GitHub Secrets
Go to your GitHub repo → **Settings → Secrets and variables → Actions → New repository secret**.
| Secret Name | Value |
|---|---|
| `APP_STORE_CONNECT_KEY_ID` | The Key ID from Step 1 (e.g., `ABC123XYZ`) |
| `APP_STORE_CONNECT_ISSUER_ID` | The Issuer ID from Step 1 (UUID format) |
| `APP_STORE_CONNECT_API_KEY_P8` | The **entire contents** of the `.p8` file, including `-----BEGIN PRIVATE KEY-----` and `-----END PRIVATE KEY-----` lines |
## Step 3: Trigger the Submission
Push a version tag to the repository:
```bash
git tag v1.0.0
git push origin v1.0.0
```
Or run manually:
- Go to **Actions** tab → **App Store Submission****Run workflow**
## What Happens
1. GitHub Actions runner (macOS) checks out your code
2. Archives the app with Xcode auto-signing
3. Exports an App Store IPA
4. Uploads to App Store Connect
5. The first upload **automatically creates** the app record in App Store Connect (bundle ID: `com.tabatago.app`)
## After Upload
1. Go to [App Store Connect → Apps](https://appstoreconnect.apple.com/apps)
2. Select **TabataGo**
3. Complete the **App Information** (description, screenshots, keywords, etc.)
4. Complete the **Pricing and Availability** section
5. Go to the new build under **TestFlight** or **App Store** tab
6. Submit for review
## Troubleshooting
| Symptom | Likely Cause |
|---|---|
| "No accounts with iTunes Connect access" | API key doesn't have App Manager permissions — recreate the key with correct access |
| "No profiles found" | The bundle ID `com.tabatago.app` isn't registered yet — check Apple Developer portal |
| "Duplicate build number" | Build number already used — bump `CFBundleVersion` in `project.yml` and re-tag |
| "Authentication failed" | API key was revoked, expired, or secret is misspelled — verify secrets in repository settings |
## Build Number Increments
Each submission requires a unique build number. Edit `tabatago-swift/project.yml`:
- Change `CFBundleVersion: "2"``"3"` in all three targets (TabataGo, TabataGoWatch, TabataGoWatchWidget)
- Then regenerate the project: `cd tabatago-swift && xcodegen`

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store</string>
<key>teamID</key>
<string>2MJF39L8VY</string>
<key>signingStyle</key>
<string>automatic</string>
<key>uploadBitcode</key>
<false/>
<key>uploadSymbols</key>
<true/>
<key>manageAppVersionAndBuildNumber</key>
<false/>
</dict>
</plist>

View File

@@ -2,12 +2,13 @@ import Foundation
import Observation
/// Global app bootstrap state initialises all services once at launch.
@MainActor
@Observable
final class AppState {
var isBootstrapped = false
static let shared = AppState()
@MainActor
var isBootstrapped = false
func bootstrap() async {
guard !isBootstrapped else { return }
guard !AppEnvironment.isPreview else { isBootstrapped = true; return }
@@ -15,4 +16,6 @@ final class AppState {
AnalyticsService.shared.initialize()
isBootstrapped = true
}
private init() {}
}

View File

@@ -3,20 +3,19 @@ import SwiftData
extension Notification.Name {
static let skipTrackFromActivity = Notification.Name("skipTrackFromActivity")
// togglePauseFromActivity is declared in WorkoutActivityAttributes.swift (shared with widget)
}
@main
struct TabataGoApp: App {
@State private var appState = AppState()
var body: some Scene {
WindowGroup {
RootView()
.environment(appState)
.environment(AppState.shared)
.modelContainer(TabataGoSchema.container)
.task {
await appState.bootstrap()
await AppState.shared.bootstrap()
}
.onOpenURL { url in
if url.scheme == "tabatago", url.host == "skipTrack" {

View File

@@ -1,10 +1,19 @@
import ActivityKit
import Foundation
import AppIntents
#if canImport(SwiftUI)
import SwiftUI
#endif
// MARK: Shared notification names (used by LiveActivityIntent + PlayerView)
extension Notification.Name {
static let togglePauseFromActivity = Notification.Name("togglePauseFromActivity")
}
// MARK: Phase enum
enum WorkoutPhase: String, Codable, Hashable, Sendable, CaseIterable {
case prep
case warmup
@@ -32,9 +41,49 @@ enum WorkoutPhase: String, Codable, Hashable, Sendable, CaseIterable {
case .complete: return Color(red: 0.19, green: 0.82, blue: 0.35)
}
}
var dimColor: Color {
color.opacity(0.3)
}
var glowColor: Color {
color.opacity(0.5)
}
var gradientStops: [Gradient.Stop] {
switch self {
case .work:
return [
.init(color: Color(red: 1.0, green: 0.42, blue: 0.21).opacity(0.15), location: 0),
.init(color: .clear, location: 1),
]
case .rest, .interBlockRest:
return [
.init(color: Color(red: 0.35, green: 0.78, blue: 0.98).opacity(0.12), location: 0),
.init(color: .clear, location: 1),
]
case .prep, .warmup:
return [
.init(color: Color(red: 1.0, green: 0.58, blue: 0.0).opacity(0.12), location: 0),
.init(color: .clear, location: 1),
]
case .cooldown:
return [
.init(color: Color(red: 0.35, green: 0.78, blue: 0.98).opacity(0.08), location: 0),
.init(color: .clear, location: 1),
]
case .complete:
return [
.init(color: Color(red: 0.19, green: 0.82, blue: 0.35).opacity(0.15), location: 0),
.init(color: .clear, location: 1),
]
}
}
#endif
}
// MARK: Activity Attributes
struct WorkoutActivityAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable, Sendable {
var exerciseName: String
@@ -48,5 +97,47 @@ struct WorkoutActivityAttributes: ActivityAttributes {
var trackArtist: String
var isPlaying: Bool
var isPaused: Bool
var blockIndex: Int = 0
var blockCount: Int = 0
var exerciseShortName: String = ""
var phaseElapsedSeconds: TimeInterval = 0
}
}
// MARK: Concurrency-safe throttle
private actor TogglePauseThrottle {
private var lastFireTime: Date = .distantPast
func shouldFire() -> Bool {
let now = Date()
guard now.timeIntervalSince(lastFireTime) > 3.0 else { return false }
lastFireTime = now
return true
}
}
// MARK: Live Activity Intents (run in the main app process)
struct TogglePauseIntent: LiveActivityIntent {
static let title: LocalizedStringResource = "Toggle Pause"
private static let throttle = TogglePauseThrottle()
@MainActor
func perform() async throws -> some IntentResult {
guard await Self.throttle.shouldFire() else {
print("[LiveActivityIntent] TogglePauseIntent throttled")
return .result()
}
print("[LiveActivityIntent] TogglePauseIntent.perform() fired")
NotificationCenter.default.post(name: .togglePauseFromActivity, object: nil)
// Small sleep keeps widget button in a "processing" visual state
try await Task.sleep(for: .milliseconds(400))
return .result()
}
}

View File

@@ -57,6 +57,8 @@ final class MusicPlayerViewModel: ObservableObject {
func play() {
guard audio.isMusicEnabled, player != nil else { return }
// Reactivate the audio session in case the system deactivated it while backgrounded
try? AVAudioSession.sharedInstance().setActive(true)
player?.volume = audio.musicVolume
player?.play()
}

View File

@@ -58,6 +58,8 @@ final class PlayerViewModel: ObservableObject {
private var warmupIndex: Int = 0
// Cooldown phase index
private var cooldownIndex: Int = 0
// Throttle rapid toggle from widget
private var lastToggleTimestamp: Date = .distantPast
private var currentBlock: TabataBlock? {
guard currentBlockIndex < program.blocks.count else { return nil }
@@ -91,6 +93,14 @@ final class PlayerViewModel: ObservableObject {
// Controls
func togglePlayPause() {
let now = Date()
guard now.timeIntervalSince(lastToggleTimestamp) > 0.6 else {
print("[PlayerVM] TogglePlayPause throttled (last tap was < 0.6s ago)")
return
}
lastToggleTimestamp = now
print("[PlayerVM] TogglePlayPause — isPaused=\(isPaused), isRunning=\(isRunning)")
if !isRunning {
startWorkout()
} else if isPaused {
@@ -384,7 +394,11 @@ final class PlayerViewModel: ObservableObject {
trackTitle: currentTrackTitle,
trackArtist: currentTrackArtist,
isPlaying: isPlayingMusic,
isPaused: isPaused
isPaused: isPaused,
blockIndex: currentBlockIndex + 1,
blockCount: program.blocks.count,
exerciseShortName: String(currentExercise?.nameEn.prefix(8) ?? ""),
phaseElapsedSeconds: max(0, Double(totalPhaseTime) - Double(timeRemaining))
)
if let existing = workoutActivity, existing.activityState != .active {
@@ -452,7 +466,11 @@ final class PlayerViewModel: ObservableObject {
trackTitle: safeActivity.content.state.trackTitle,
trackArtist: safeActivity.content.state.trackArtist,
isPlaying: false,
isPaused: false
isPaused: false,
blockIndex: safeActivity.content.state.blockIndex,
blockCount: safeActivity.content.state.blockCount,
exerciseShortName: safeActivity.content.state.exerciseShortName,
phaseElapsedSeconds: safeActivity.content.state.phaseElapsedSeconds
)
await safeActivity.end(ActivityContent(state: finalState, staleDate: nil), dismissalPolicy: .immediate)
}

View File

@@ -166,6 +166,10 @@ struct PlayerView: View {
.onReceive(NotificationCenter.default.publisher(for: .skipTrackFromActivity)) { _ in
musicVM.skipTrack()
}
.onReceive(NotificationCenter.default.publisher(for: .togglePauseFromActivity)) { _ in
print("[PlayerView] Received togglePauseFromActivity notification (fallback)")
vm.togglePlayPause()
}
.navigationDestination(isPresented: $vm.isComplete) {
CompletionView(session: vm.completedSession, program: program, onDone: { dismiss() })
.navigationBarBackButtonHidden()

View File

@@ -317,5 +317,5 @@ struct ProgramRow: View {
#Preview {
HomeTab(previewVM: HomeViewModel(previewPrograms: [PreviewData.sampleProgram]))
.modelContainer(TabataGoSchema.previewContainer)
.environment(AppState())
.environment(AppState.shared)
}

View File

@@ -48,5 +48,5 @@ struct MainTabView: View {
#Preview {
MainTabView()
.modelContainer(TabataGoSchema.previewContainer)
.environment(AppState())
.environment(AppState.shared)
}

View File

@@ -139,5 +139,5 @@ struct ProfileRow: View {
#Preview {
ProfileTab()
.modelContainer(TabataGoSchema.previewContainer)
.environment(AppState())
.environment(AppState.shared)
}

View File

@@ -103,5 +103,5 @@ struct ProgramsTab: View {
#Preview {
ProgramsTab()
.modelContainer(TabataGoSchema.previewContainer)
.environment(AppState())
.environment(AppState.shared)
}

View File

@@ -41,13 +41,38 @@ struct MusicLiveActivity: Widget {
LiveActivityMusicBars()
}
}
.background(
RadialGradient(
stops: [
.init(color: Color.green.opacity(context.state.isPlaying ? 0.1 : 0.03), location: 0),
.init(color: .clear, location: 1)
],
center: .center,
startRadius: 0,
endRadius: 30
)
)
.opacity(context.state.isPlaying ? 1.0 : 0.5)
.animation(.easeInOut(duration: 0.3), value: context.state.isPlaying)
}
DynamicIslandExpandedRegion(.center) {
Text(context.state.artist)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
.accessibilityLabel(context.state.artist)
HStack(spacing: 6) {
RoundedRectangle(cornerRadius: 4, style: .continuous)
.fill(LinearGradient(
colors: [.green.opacity(0.4), .mint.opacity(0.3)],
startPoint: .topLeading,
endPoint: .bottomTrailing
))
.frame(width: 20, height: 20)
Text(context.state.artist)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
.accessibilityLabel(context.state.artist)
}
.opacity(context.state.isPlaying ? 1.0 : 0.5)
.animation(.easeInOut(duration: 0.3), value: context.state.isPlaying)
}
DynamicIslandExpandedRegion(.trailing) {
Button(intent: SkipTrackIntent()) {
@@ -73,10 +98,14 @@ struct MusicLiveActivity: Widget {
.accessibilityLabel("Music playing")
} compactTrailing: {
HStack(spacing: 3) {
Image(systemName: "music.note")
.font(.system(size: 10))
.foregroundStyle(.green)
.accessibilityHidden(true)
if context.state.isPlaying {
LiveActivityMusicBars()
} else {
Image(systemName: "music.note")
.font(.system(size: 10))
.foregroundStyle(.green.opacity(0.5))
.accessibilityHidden(true)
}
Text(context.state.title)
.font(.system(size: 10, weight: .medium))
.lineLimit(1)

View File

@@ -3,18 +3,6 @@ import WidgetKit
import SwiftUI
import AppIntents
// MARK: - App Intents
struct TogglePauseIntent: AppIntent {
static let title: LocalizedStringResource = "Toggle Pause"
@MainActor
func perform() async throws -> some IntentResult & OpensIntent {
let url = URL(string: "tabatago://togglePause")!
return .result(opensIntent: OpenURLIntent(url))
}
}
// MARK: - Phase Icon
struct PhaseIcon: View {
@@ -242,6 +230,109 @@ struct DIBottomInfoRow: View {
}
}
// MARK: - DI Countdown Ring (48pt for Dynamic Island Expanded)
struct DICountdownRing: View {
let endDate: Date
let phaseDuration: TimeInterval
let isPaused: Bool
let frozenSeconds: Int
let phase: WorkoutPhase
let isUrgent: Bool
var isLuminanceReduced: Bool = false
private let diameter: CGFloat = 48
/// Thinner stroke during rest phases for a lighter visual feel.
private var lineWidth: CGFloat {
let isRestOrBreak = phase == .rest || phase == .interBlockRest
return (isRestOrBreak && !isPaused) ? 4 : 5
}
var body: some View {
let isRestOrBreak = phase == .rest || phase == .interBlockRest
TimelineView(.periodic(from: .now, by: 0.5)) { timeline in
let remaining = max(0, endDate.timeIntervalSince(timeline.date))
let activeProgress = phaseDuration > 0 ? CGFloat(remaining / phaseDuration) : 1.0
let frozenProgress = phaseDuration > 0 ? CGFloat(frozenSeconds) / CGFloat(phaseDuration) : 1.0
let arcProgress = isPaused ? frozenProgress : activeProgress
let strokeColor: Color = {
if isPaused { return phase.glowColor }
return isUrgent ? .orange : phase.color
}()
ZStack {
Circle()
.stroke(.white.opacity(0.08), lineWidth: lineWidth)
Circle()
.trim(from: 0, to: arcProgress)
.stroke(
strokeColor,
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
)
.rotationEffect(.degrees(-90))
.animation(isLuminanceReduced ? nil : .linear(duration: 0.3), value: arcProgress)
CountdownText(
endDate: endDate,
isPaused: isPaused,
frozenSeconds: frozenSeconds,
isUrgent: isUrgent,
size: 14
)
}
// Work-phase scale pulse
.scaleEffect(phase == .work && !isPaused ? 1.05 : 1.0)
.animation(isLuminanceReduced ? nil : .spring(response: 0.3, dampingFraction: 0.6), value: phase == .work)
// Rest-phase visual cooling
.opacity(isRestOrBreak && !isPaused ? 0.85 : 1.0)
.animation(isLuminanceReduced ? nil : .easeInOut(duration: 1.0), value: isRestOrBreak)
.frame(width: diameter, height: diameter)
}
.accessibilityElement(children: .combine)
.accessibilityLabel(isPaused
? "Timer paused at \(CountdownText.formatFrozenTime(frozenSeconds))"
: "Countdown timer")
}
}
// MARK: - Compact Countdown Ring (16pt for Dynamic Island Compact)
struct CompactCountdownRing: View {
let endDate: Date
let phaseDuration: TimeInterval
let isPaused: Bool
let frozenSeconds: Int
let phase: WorkoutPhase
let size: CGFloat = 16
let lineWidth: CGFloat = 2.5
var body: some View {
TimelineView(.periodic(from: .now, by: 1.0)) { timeline in
let remaining = max(0, endDate.timeIntervalSince(timeline.date))
let progress = phaseDuration > 0 ? CGFloat(remaining / phaseDuration) : 1.0
let frozen = phaseDuration > 0 ? CGFloat(frozenSeconds) / CGFloat(phaseDuration) : 1.0
let arc = isPaused ? frozen : progress
ZStack {
Circle()
.stroke(.white.opacity(0.12), lineWidth: lineWidth)
Circle()
.trim(from: 0, to: arc)
.stroke(
isPaused ? phase.dimColor : phase.color,
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
)
.rotationEffect(.degrees(-90))
}
.frame(width: size, height: size)
}
}
}
// MARK: - Main Widget
struct WorkoutLiveActivity: Widget {
@@ -292,57 +383,171 @@ struct WorkoutLiveActivity: Widget {
let paused = context.state.isPaused
let frozenSeconds = Int(max(0, context.state.phaseEndDate.timeIntervalSinceNow))
let isUrgent = !paused && frozenSeconds > 0 && frozenSeconds <= 5
let progress = CGFloat(context.state.roundCurrent) / CGFloat(max(context.state.roundTotal, 1))
return DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 4) {
PhaseIcon(phase: phase, isPaused: paused, size: 16)
Text(paused ? "PAUSED" : phase.capitalized.uppercased())
.font(.subheadline.bold())
.foregroundStyle(paused ? .white.opacity(0.5) : phase.color)
}
Text("Rd \(context.state.roundCurrent)/\(context.state.roundTotal)")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
.padding(.vertical, 2)
.accessibilityLabel(paused
? "Paused, round \(context.state.roundCurrent) of \(context.state.roundTotal)"
: "\(phase.capitalized) phase, round \(context.state.roundCurrent) of \(context.state.roundTotal)")
EmptyView()
}
DynamicIslandExpandedRegion(.center) {
Text(context.state.exerciseName)
.font(.body.weight(.semibold))
.foregroundStyle(paused ? .white.opacity(0.6) : .white)
.lineLimit(1)
.accessibilityLabel("Exercise \(context.state.exerciseName)")
@Environment(\.isLuminanceReduced) var isLuminanceReduced
let elapsedFraction: CGFloat = {
let duration = context.state.phaseDuration
guard duration > 0 else { return 0 }
if context.state.isPaused {
return CGFloat(context.state.phaseElapsedSeconds) / CGFloat(duration)
}
return min(max(0, 1 - CGFloat(max(0, context.state.phaseEndDate.timeIntervalSinceNow)) / CGFloat(duration)), 1)
}()
VStack(spacing: 4) {
Text(context.state.exerciseName)
.font(.headline.weight(.bold))
.foregroundStyle(.white)
.lineLimit(1)
.accessibilityLabel("Exercise \(context.state.exerciseName)")
let phaseSymbol: String = {
switch phase {
case .rest, .interBlockRest, .cooldown: return "snowflake"
case .complete: return "checkmark.circle.fill"
default: return "flame.fill"
}
}()
HStack(spacing: 4) {
Image(systemName: phaseSymbol)
.font(.system(size: 10))
.foregroundStyle(phase.color)
Text(paused ? "PAUSED" : phase.capitalized.uppercased())
.font(.caption.weight(.semibold))
.foregroundStyle(paused ? .white.opacity(0.4) : phase.color)
.animation(isLuminanceReduced ? nil : .easeInOut(duration: 0.8), value: phase)
Text("· Rd \(context.state.roundCurrent)/\(context.state.roundTotal)")
.font(.caption)
.foregroundStyle(.secondary)
}
.accessibilityLabel(paused
? "Paused, round \(context.state.roundCurrent) of \(context.state.roundTotal)"
: "\(phase.capitalized), round \(context.state.roundCurrent) of \(context.state.roundTotal)")
HStack(alignment: .center, spacing: 8) {
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 2, style: .continuous)
.fill(.white.opacity(0.1))
RoundedRectangle(cornerRadius: 2, style: .continuous)
.fill(phase.color)
.frame(width: geo.size.width * elapsedFraction)
}
}
.frame(width: 120, height: 4)
CountdownText(endDate: context.state.phaseEndDate, isPaused: paused, frozenSeconds: frozenSeconds, isUrgent: isUrgent, size: 14)
.frame(width: 48, alignment: .trailing)
}
.frame(maxWidth: .infinity, alignment: .center)
}
.frame(maxWidth: .infinity)
.accessibilityElement(children: .combine)
.accessibilityLabel(paused
? "Paused, \(context.state.exerciseName), round \(context.state.roundCurrent) of \(context.state.roundTotal)"
: "\(phase.capitalized) phase, \(context.state.exerciseName), round \(context.state.roundCurrent) of \(context.state.roundTotal), \(CountdownText.formatFrozenTime(frozenSeconds)) remaining")
}
DynamicIslandExpandedRegion(.trailing) {
CountdownText(endDate: context.state.phaseEndDate, isPaused: paused, frozenSeconds: frozenSeconds, isUrgent: isUrgent, size: 26)
.frame(width: 64, alignment: .trailing)
Button(intent: TogglePauseIntent()) {
Image(systemName: paused ? "play.fill" : "pause.fill")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.white.opacity(0.8))
.frame(width: 24, height: 24)
.background(.white.opacity(0.12))
.clipShape(Circle())
}
.buttonStyle(.plain)
.accessibilityLabel(paused ? "Resume workout" : "Pause workout")
.padding(8)
}
DynamicIslandExpandedRegion(.bottom) {
VStack(spacing: 8) {
WorkoutProgressBar(progress: progress, color: phase.color, isPaused: paused)
VStack(spacing: 6) {
// Stats row
ZStack {
HStack {
if context.state.heartRate > 0 {
HStack(spacing: 3) {
Image(systemName: "heart.fill")
.font(.system(size: 9))
.foregroundStyle(.red.opacity(0.7))
Text("\(Int(context.state.heartRate))")
.font(.system(size: 11).monospacedDigit())
.foregroundStyle(.white.opacity(paused ? 0.3 : 0.5))
}
.accessibilityLabel("Heart rate \(Int(context.state.heartRate)) beats per minute")
}
Spacer()
}
if !context.state.trackTitle.isEmpty {
HStack(spacing: 3) {
Image(systemName: "music.note")
.font(.system(size: 9))
.foregroundStyle(.green)
Text(context.state.trackTitle)
.font(.system(size: 10))
.lineLimit(1)
.foregroundStyle(.white.opacity(paused ? 0.3 : 0.5))
}
.accessibilityLabel("Now playing \(context.state.trackTitle)")
}
}
DIBottomInfoRow(
heartRate: context.state.heartRate,
trackTitle: context.state.trackTitle,
isPaused: paused
)
// Block indicator dots
if context.state.blockCount > 1 {
HStack(spacing: 3) {
ForEach(1...context.state.blockCount, id: \.self) { idx in
Circle()
.fill(idx <= context.state.blockIndex ? phase.color : .white.opacity(0.1))
.frame(width: 4, height: 4)
}
}
.accessibilityLabel("Block \(context.state.blockIndex) of \(context.state.blockCount)")
}
}
.padding(.top, 6)
.padding(.bottom, 8)
}
} compactLeading: {
PhaseIcon(phase: phase, isPaused: paused, size: 14)
.accessibilityLabel(paused ? "Paused" : "\(phase.capitalized) phase, workout in progress")
CompactCountdownRing(
endDate: context.state.phaseEndDate,
phaseDuration: context.state.phaseDuration,
isPaused: paused,
frozenSeconds: frozenSeconds,
phase: phase
)
.dynamicIsland(verticalPlacement: .belowIfTooWide)
.accessibilityLabel(paused
? "Workout paused"
: "\(phase.capitalized) phase, workout in progress")
} compactTrailing: {
CountdownText(endDate: context.state.phaseEndDate, isPaused: paused, frozenSeconds: frozenSeconds, isUrgent: isUrgent, size: 12)
if !context.state.exerciseShortName.isEmpty {
Text(context.state.exerciseShortName)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(paused ? .white.opacity(0.3) : .white.opacity(0.7))
.lineLimit(1)
.accessibilityLabel("Exercise \(context.state.exerciseShortName)")
} else {
CountdownText(
endDate: context.state.phaseEndDate,
isPaused: paused,
frozenSeconds: frozenSeconds,
isUrgent: isUrgent,
size: 11
)
}
} minimal: {
PhaseIcon(phase: phase, isPaused: paused, size: 10)
.accessibilityLabel(paused ? "Paused" : "Workout in progress")
Circle()
.fill(phase.color)
.frame(width: 8, height: 8)
.modifier(PulseEffect(active: phase == .work && !paused))
.accessibilityElement(children: .ignore)
.accessibilityLabel(paused ? "Paused workout" : "\(phase.capitalized) phase — workout in progress")
}
.keylineTint(phase.color)
.contentMargins(.leading, 8, for: .expanded)
@@ -536,7 +741,10 @@ private struct PulseEffect: ViewModifier {
trackTitle: "Lose Yourself",
trackArtist: "Eminem",
isPlaying: true,
isPaused: false
isPaused: false,
blockIndex: 1,
blockCount: 4,
exerciseShortName: "BURPEES"
)
}
@@ -554,7 +762,10 @@ private struct PulseEffect: ViewModifier {
trackTitle: "Stronger",
trackArtist: "Kanye West",
isPlaying: true,
isPaused: false
isPaused: false,
blockIndex: 1,
blockCount: 4,
exerciseShortName: "MTN CLMB"
)
}
@@ -572,7 +783,10 @@ private struct PulseEffect: ViewModifier {
trackTitle: "",
trackArtist: "",
isPlaying: false,
isPaused: true
isPaused: true,
blockIndex: 2,
blockCount: 4,
exerciseShortName: "JMP SQTS"
)
}
@@ -590,7 +804,10 @@ private struct PulseEffect: ViewModifier {
trackTitle: "",
trackArtist: "",
isPlaying: false,
isPaused: false
isPaused: false,
blockIndex: 4,
blockCount: 4,
exerciseShortName: ""
)
}
@@ -608,7 +825,10 @@ private struct PulseEffect: ViewModifier {
trackTitle: "Till I Collapse",
trackArtist: "Eminem",
isPlaying: true,
isPaused: false
isPaused: false,
blockIndex: 1,
blockCount: 4,
exerciseShortName: "BURPEES"
)
WorkoutActivityAttributes.ContentState(
exerciseName: "High Knees",
@@ -621,7 +841,31 @@ private struct PulseEffect: ViewModifier {
trackTitle: "",
trackArtist: "",
isPlaying: false,
isPaused: false
isPaused: false,
blockIndex: 2,
blockCount: 4,
exerciseShortName: "HI KNEE"
)
}
#Preview("Dynamic Island Expanded — Rest", as: .dynamicIsland(.expanded), using: WorkoutActivityAttributes()) {
WorkoutLiveActivity()
} contentStates: {
WorkoutActivityAttributes.ContentState(
exerciseName: "Mountain Climbers",
phase: .rest,
phaseEndDate: .now.addingTimeInterval(7),
phaseDuration: 10,
roundCurrent: 1,
roundTotal: 8,
heartRate: 120,
trackTitle: "Stronger",
trackArtist: "Kanye West",
isPlaying: true,
isPaused: false,
blockIndex: 1,
blockCount: 4,
exerciseShortName: "MTN CLMB"
)
}
@@ -639,7 +883,10 @@ private struct PulseEffect: ViewModifier {
trackTitle: "",
trackArtist: "",
isPlaying: false,
isPaused: false
isPaused: false,
blockIndex: 1,
blockCount: 4,
exerciseShortName: "BURPEES"
)
}
@@ -657,6 +904,9 @@ private struct PulseEffect: ViewModifier {
trackTitle: "",
trackArtist: "",
isPlaying: false,
isPaused: false
isPaused: false,
blockIndex: 1,
blockCount: 4,
exerciseShortName: "BURPEES"
)
}

View File

@@ -46,7 +46,7 @@ targets:
properties:
CFBundleDisplayName: TabataGo
CFBundleShortVersionString: "1.0"
CFBundleVersion: "1"
CFBundleVersion: "2"
UILaunchScreen:
UIColorName: ""
UIImageName: ""
@@ -110,7 +110,7 @@ targets:
properties:
CFBundleDisplayName: TabataGo
CFBundleShortVersionString: "1.0"
CFBundleVersion: "1"
CFBundleVersion: "2"
WKApplication: true
WKCompanionAppBundleIdentifier: com.tabatago.app
NSHealthShareUsageDescription: "TabataGo reads your heart rate and calories during workouts."
@@ -147,7 +147,7 @@ targets:
properties:
CFBundleDisplayName: TabataGoWidget
CFBundleShortVersionString: "1.0"
CFBundleVersion: "1"
CFBundleVersion: "2"
NSExtension:
NSExtensionPointIdentifier: com.apple.widgetkit-extension
settings: