9 Commits

Author SHA1 Message Date
Millian Lamiaux
310124ad63 feat: move HealthKit permission to onboarding, remove HR write
All checks were successful
CI / Detect Changes (pull_request) Successful in 3s
CI / Admin Web CI (pull_request) Has been skipped
CI / YouTube Worker (pull_request) Has been skipped
CI / Deploy (pull_request) Has been skipped
- Add .health step to onboarding between frequency and ready
- HealthStep with non-blocking permission flow (Not Now skips)
- Remove requestAuthorization() from PlayerViewModel.startWorkout()
- Guard live session start with isAuthorized check
- Remove heart rate write from HealthKit authorization popup
- Remove HR sample writing from saveWorkout (now without permission)
- Add L10n keys: healthAccess, healthAccessSubtitle, allowHealthAccess, notNow
- Add EN/DE/ES/FR translations
- Track permission decisions through analytics
- Entry animation on HealthStep (fade-in + slide-up)

HealthKit permission is now asked once during onboarding,
never interrupting workouts again.
2026-05-24 15:18:11 +02:00
Millian Lamiaux
72ad247136 chore: update .gitignore.
All checks were successful
CI / Detect Changes (push) Successful in 3s
CI / Admin Web CI (push) Has been skipped
CI / YouTube Worker (push) Has been skipped
CI / Deploy (push) Has been skipped
2026-05-23 12:29:54 +02:00
f71ba55e8b Merge pull request 'feat: redesign player with Dynamic Island, compact timer, and fix Live Activity timer drift' (#2) from revamp-timer-video-layout into main
Some checks failed
CI / Detect Changes (push) Successful in 3s
CI / Admin Web CI (push) Failing after 34s
CI / YouTube Worker (push) Failing after 5s
CI / Deploy (push) Failing after 2s
Reviewed-on: #2
2026-05-23 12:24:34 +02:00
Millian Lamiaux
38576fd528 ci: replace dead Expo CI with linux-only monorepo pipeline
Some checks failed
CI / Admin Web CI (pull_request) Failing after 34s
CI / YouTube Worker (pull_request) Failing after 5s
CI / Deploy (pull_request) Has been skipped
CI / Detect Changes (pull_request) Successful in 6s
Remove: root Expo typecheck/lint/test/build-check (project deleted Apr 19)
Remove: swift-build-test + app-store.yml (no macOS runner on Gitea)
Add: path-filtered conditional jobs with dorny/paths-filter@v3
Add: youtube-worker deploy to deploy-functions job
Fix: deploy-functions always() to handle skipped upstream jobs
Fix: SPM cache key includes Package.resolved (before removal)
Fix: remove continue-on-error from vitest/playwright steps
2026-05-23 12:09:28 +02:00
Millian Lamiaux
df9fd48964 chore: update tabatago-swift submodule (Live Activity fix)
Some checks failed
CI / TypeScript (pull_request) Failing after 4s
CI / ESLint (pull_request) Failing after 4s
CI / Tests (pull_request) Failing after 6s
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
Includes fix for stale Live Activity persisting after workout
cancel/background. See submodule commit e42c121 for details.
2026-05-23 00:41:41 +02:00
Millian Lamiaux
e42c1217db fix: Live Activity persists after workout cancel/background
Root cause: observeActivityState() prematurely set workoutActivity=nil
when the activity went .stale (e.g. app backgrounded >2 minutes). This
prevented endActivity() from calling .end() on the stale activity,
leaving it visible on the Lock Screen and Dynamic Island indefinitely.

Fixes (all in PlayerViewModel.swift):

1. observeActivityState(): Split the monolithic stale/ended/dismissed
   handler. .stale now only stops the sync timer but keeps the
   workoutActivity reference so endActivity() can still call .end()
   to properly dismiss the stale Live Activity.

2. syncActivity() nil guard: Changed from != .active to explicit
   == .ended || == .dismissed so stale activities are not prematurely
   discarded when tick() re-enters syncActivity().

3. endActivity(): Added stopActivitySyncTimer() defensive call at top
   to prevent orphaned timer from racing in and recreating the activity
   during .end(). Also relaxed the guard from == .active to
   != .ended && != .dismissed so stale activities can be ended.

4. abandonWorkout(): Explicitly set isRunning=false + isPaused=false
   before cleanup to prevent accidental Live Activity recreation.
2026-05-23 00:40:41 +02:00
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
33 changed files with 918 additions and 2331 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" |

View File

@@ -2,150 +2,42 @@ name: CI
on:
push:
branches: [main, master]
branches: [main]
pull_request:
branches: [main, master]
branches: [main]
jobs:
typecheck:
name: TypeScript
# ── Path filter — determines which downstream jobs run ──
changes:
name: Detect Changes
runs-on: ubuntu-latest
outputs:
admin-web: ${{ steps.filter.outputs.admin-web }}
youtube-worker: ${{ steps.filter.outputs.youtube-worker }}
supabase-functions: ${{ steps.filter.outputs.supabase-functions }}
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
- uses: dorny/paths-filter@v3
id: filter
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Type check
run: npx tsc --noEmit
lint:
name: ESLint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
test:
name: Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests with coverage
run: npm run test:coverage
- name: Run component render tests
run: npm run test:render
- name: Upload coverage report
uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-report
path: coverage/
retention-days: 7
- name: Coverage summary
if: always()
run: |
echo "## Test Coverage Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ -f coverage/coverage-summary.json ]; then
echo '```' >> $GITHUB_STEP_SUMMARY
node -e "
const c = require('./coverage/coverage-summary.json').total;
const fmt = (v) => v.pct + '%';
console.log('Statements: ' + fmt(c.statements));
console.log('Branches: ' + fmt(c.branches));
console.log('Functions: ' + fmt(c.functions));
console.log('Lines: ' + fmt(c.lines));
" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
elif [ -f coverage/coverage-final.json ]; then
echo "Coverage report generated. Download the artifact for details." >> $GITHUB_STEP_SUMMARY
else
echo "Coverage report not found." >> $GITHUB_STEP_SUMMARY
fi
- name: Comment coverage on PR
if: github.event_name == 'pull_request' && always()
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
let body = '## Test Coverage Report\n\n';
try {
const summary = JSON.parse(fs.readFileSync('coverage/coverage-summary.json', 'utf8'));
const total = summary.total;
const fmt = (v) => `${v.pct}%`;
const icon = (v) => v.pct >= 80 ? '✅' : v.pct >= 60 ? '⚠️' : '❌';
body += '| Metric | Coverage | Status |\n';
body += '|--------|----------|--------|\n';
body += `| Statements | ${fmt(total.statements)} | ${icon(total.statements)} |\n`;
body += `| Branches | ${fmt(total.branches)} | ${icon(total.branches)} |\n`;
body += `| Functions | ${fmt(total.functions)} | ${icon(total.functions)} |\n`;
body += `| Lines | ${fmt(total.lines)} | ${icon(total.lines)} |\n`;
} catch (e) {
body += '_Coverage summary not available._\n';
}
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c =>
c.user.type === 'Bot' && c.body.includes('## Test Coverage Report')
);
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
filters: |
admin-web:
- 'admin-web/**'
- '.github/workflows/ci.yml'
youtube-worker:
- 'youtube-worker/**'
- '.github/workflows/ci.yml'
supabase-functions:
- 'supabase/functions/**'
- 'youtube-worker/**'
- '.github/workflows/ci.yml'
# ── Admin Web: Next.js ──
admin-web-test:
name: Admin Web Tests
name: Admin Web CI
needs: changes
if: needs.changes.outputs.admin-web == 'true'
runs-on: ubuntu-latest
defaults:
run:
@@ -168,19 +60,22 @@ jobs:
- name: Run unit tests
run: npx vitest run
continue-on-error: true
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run E2E tests
run: npx playwright test
continue-on-error: true
build-check:
name: Build Check
# ── YouTube Worker: Node.js microservice ──
youtube-worker-check:
name: YouTube Worker
needs: changes
if: needs.changes.outputs.youtube-worker == 'true'
runs-on: ubuntu-latest
needs: [typecheck, lint, test]
defaults:
run:
working-directory: youtube-worker
steps:
- uses: actions/checkout@v4
@@ -189,39 +84,78 @@ jobs:
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: youtube-worker/package-lock.json
- name: Install dependencies
run: npm ci
- name: Export web build
run: npx expo export --platform web
continue-on-error: true
- name: Validate syntax
run: node --check server.js
# ── Deploy: Supabase edge functions + YouTube worker ──
deploy-functions:
name: Deploy Edge Functions
name: Deploy
needs: [changes, admin-web-test, youtube-worker-check]
if: |
always() &&
github.ref == 'refs/heads/main' &&
github.event_name == 'push' &&
!contains(needs.*.result, 'failure') &&
(needs.changes.outputs.supabase-functions == 'true' ||
needs.changes.outputs.youtube-worker == 'true')
runs-on: ubuntu-latest
needs: [typecheck, lint, test]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- name: Deploy to self-hosted Supabase
- name: Setup SSH
env:
DEPLOY_HOST: ${{ secrets.SUPABASE_DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.SUPABASE_DEPLOY_USER }}
DEPLOY_PATH: ${{ secrets.SUPABASE_DEPLOY_PATH }}
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SUPABASE_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H $DEPLOY_HOST >> ~/.ssh/known_hosts 2>/dev/null
- name: Deploy Supabase Edge Functions
env:
DEPLOY_HOST: ${{ secrets.SUPABASE_DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.SUPABASE_DEPLOY_USER }}
DEPLOY_PATH: ${{ secrets.SUPABASE_DEPLOY_PATH }}
run: |
rsync -avz --delete \
--exclude='node_modules' \
--exclude='.DS_Store' \
-e "ssh -i ~/.ssh/deploy_key" \
supabase/functions/ \
"$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/"
ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" \
"docker restart supabase-edge-functions"
- name: Deploy YouTube Worker
env:
DEPLOY_HOST: ${{ secrets.SUPABASE_DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.SUPABASE_DEPLOY_USER }}
WORKER_PATH: /opt/supabase/youtube-worker
run: |
rsync -avz --delete \
--exclude='node_modules' \
--exclude='.DS_Store' \
-e "ssh -i ~/.ssh/deploy_key" \
youtube-worker/ \
"$DEPLOY_USER@$DEPLOY_HOST:$WORKER_PATH/"
ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" "\
cd $WORKER_PATH && \
docker build -t youtube-worker:latest . && \
docker stop youtube-worker 2>/dev/null || true && \
docker rm youtube-worker 2>/dev/null || true && \
docker run -d \
--name youtube-worker \
--restart unless-stopped \
--network supabase_supabase-network \
-e SUPABASE_URL=\$(docker exec supabase-edge-functions printenv SUPABASE_URL) \
-e SUPABASE_SERVICE_ROLE_KEY=\$(docker exec supabase-edge-functions printenv SUPABASE_SERVICE_ROLE_KEY) \
-e SUPABASE_PUBLIC_URL=https://supabase.1000co.fr \
-e GEMINI_API_KEY=\$(cat /opt/supabase/.env.gemini 2>/dev/null || echo '') \
-e STORAGE_BUCKET=workout-audio \
-e PORT=3001 \
youtube-worker:latest"

4
.gitignore vendored
View File

@@ -54,3 +54,7 @@ coverage/
node-compile-cache/
.gitnexus
Config/Secrets.xcconfig
_Users_*
swift-generated-sources/
tabatago-swift/build/

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`

47
skills-lock.json Normal file
View File

@@ -0,0 +1,47 @@
{
"version": 1,
"skills": {
"core-data-expert": {
"source": "avdlee/core-data-agent-skill",
"sourceType": "github",
"skillPath": "core-data-expert/SKILL.md",
"computedHash": "b8d2829005b1f2fefbaa8af2ea7d7d64e2fbeca2f2172033176ad0780edc3970"
},
"swift-architecture-skill": {
"source": "efremidze/swift-architecture-skill",
"sourceType": "github",
"skillPath": "swift-architecture-skill/SKILL.md",
"computedHash": "67d3359424b19084631998def14666fd5a77284a45ac0353c41a86a7ed216923"
},
"swift-concurrency-pro": {
"source": "twostraws/swift-concurrency-agent-skill",
"sourceType": "github",
"skillPath": "swift-concurrency-pro/SKILL.md",
"computedHash": "dec65531b4bd37d15e6243dbb0d2d1f554b4f4087bcb2e8deb7273f570fa4069"
},
"swift-testing-pro": {
"source": "twostraws/swift-testing-agent-skill",
"sourceType": "github",
"skillPath": "swift-testing-pro/SKILL.md",
"computedHash": "90504b29146ccd7e88d8ba7244c6c4e4d2b410fb21bdd4ce578f10583b158481"
},
"swiftdata-pro": {
"source": "twostraws/swiftdata-agent-skill",
"sourceType": "github",
"skillPath": "swiftdata-pro/SKILL.md",
"computedHash": "2f979bad98ea3a6744084c5f93e27897f02e8d0ffe15dd03042e88aaae4da14c"
},
"swiftui-pro": {
"source": "twostraws/swiftui-agent-skill",
"sourceType": "github",
"skillPath": "swiftui-pro/SKILL.md",
"computedHash": "07033426e384295a4b49cf0b2ffdefd4098cae4af53fef16bc1f2d9281118c41"
},
"writing-for-interfaces": {
"source": "andrewgleave/skills",
"sourceType": "github",
"skillPath": "writing-for-interfaces/SKILL.md",
"computedHash": "fff061810c3e63b97fea546da1b86d88629f422a5d38d4ac13497b689a18419e"
}
}
}

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

@@ -7,12 +7,12 @@
<key>TabataGo.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>4</integer>
<integer>1</integer>
</dict>
<key>TabataGoTests.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>3</integer>
<integer>0</integer>
</dict>
<key>TabataGoUITests.xcscheme_^#shared#^_</key>
<dict>
@@ -29,6 +29,11 @@
<key>orderHint</key>
<integer>1</integer>
</dict>
<key>TabataGoWidget.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>2</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
<dict>

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

@@ -6425,6 +6425,122 @@
}
}
}
},
"onboarding.allowHealthAccess" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Health-Zugriff erlauben"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Allow Health Access"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Permitir acceso a Salud"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Autoriser l'accès à Santé"
}
}
}
},
"onboarding.healthAccess" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Mit Apple Health verbinden"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Connect to Apple Health"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Conectar con Apple Salud"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Connecter à Apple Santé"
}
}
}
},
"onboarding.healthAccessSubtitle" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Verfolge Kalorien und Herzfrequenz. Speichere Workouts in der Health App. Deine Daten bleiben privat und auf deinem Gerät."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Track calories and heart rate. Save workouts to your Health app. Your data stays private and on-device."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Registra calorías y frecuencia cardíaca. Guarda entrenamientos en la app Salud. Tus datos permanecen privados y en tu dispositivo."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Suivez les calories et la fréquence cardiaque. Enregistrez vos entraînements dans l'app Santé. Vos données restent privées et sur votre appareil."
}
}
}
},
"onboarding.notNow" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nicht jetzt"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Not Now"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ahora no"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Pas maintenant"
}
}
}
}
},
"version" : "1.0"

View File

@@ -19,7 +19,6 @@ actor HealthKitService {
[
HKWorkoutType.workoutType(),
HKQuantityType(.activeEnergyBurned),
HKQuantityType(.heartRate),
]
}
@@ -87,20 +86,6 @@ actor HealthKitService {
try await builder.addSamples([sample])
}
// Heart rate samples (if captured during workout)
if let avgHR = data.averageHeartRate {
let hrType = HKQuantityType(.heartRate)
let hrUnit = HKUnit.count().unitDivided(by: .minute())
let hrQuantity = HKQuantity(unit: hrUnit, doubleValue: avgHR)
let hrSample = HKQuantitySample(
type: hrType,
quantity: hrQuantity,
start: data.startedAt,
end: data.completedAt
)
try await builder.addSamples([hrSample])
}
try await builder.endCollection(at: data.completedAt)
guard let workout = try await builder.finishWorkout() else {
throw HealthKitError.workoutSaveFailed

View File

@@ -175,7 +175,11 @@ enum L10n {
static let pill4MinWorkouts = LocalizedStringResource("onboarding.pill4MinWorkouts")
static let pillNoEquipment = LocalizedStringResource("onboarding.pillNoEquipment")
static let pillVoiceGuided = LocalizedStringResource("onboarding.pillVoiceGuided")
static let tabataDesc = LocalizedStringResource("onboarding.tabataDesc")
static let healthAccess = LocalizedStringResource("onboarding.healthAccess")
static let healthAccessSubtitle = LocalizedStringResource("onboarding.healthAccessSubtitle")
static let allowHealthAccess = LocalizedStringResource("onboarding.allowHealthAccess")
static let notNow = LocalizedStringResource("onboarding.notNow")
static let tabataDesc = LocalizedStringResource("onboarding.tabataDesc")
enum levelDesc {
static let beginner = LocalizedStringResource("onboarding.level.beginnerDesc")

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 {
@@ -106,6 +116,8 @@ final class PlayerViewModel: ObservableObject {
}
func abandonWorkout() {
isRunning = false
isPaused = false
timer?.invalidate()
stopActivitySyncTimer()
Task { try? await liveSession.end() }
@@ -125,7 +137,11 @@ final class PlayerViewModel: ObservableObject {
// Start HealthKit live session
Task {
try? await HealthKitService.shared.requestAuthorization()
guard await HealthKitService.shared.isAuthorized else {
print("[PlayerVM] HealthKit not authorized — skipping live session")
return
}
liveSession.onHeartRateUpdate = { [weak self] hr in
Task { @MainActor in self?.heartRate = hr }
}
@@ -384,10 +400,15 @@ 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 {
if let existing = workoutActivity,
existing.activityState == .ended || existing.activityState == .dismissed {
workoutActivity = nil
}
@@ -435,12 +456,14 @@ final class PlayerViewModel: ObservableObject {
}
func endActivity() async {
stopActivitySyncTimer()
guard let activity = workoutActivity else { return }
workoutActivity = nil
activityStateTask?.cancel()
activityStateTask = nil
nonisolated(unsafe) let safeActivity = activity
guard safeActivity.activityState == .active else { return }
guard safeActivity.activityState != .ended,
safeActivity.activityState != .dismissed else { return }
let finalState = WorkoutActivityAttributes.ContentState(
exerciseName: safeActivity.content.state.exerciseName,
phase: .complete,
@@ -452,7 +475,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)
}
@@ -479,7 +506,11 @@ final class PlayerViewModel: ObservableObject {
activityStateTask = Task { @MainActor [weak self] in
for await state in activity.activityStateUpdates {
guard let self else { return }
if state == .stale || state == .ended || state == .dismissed {
if state == .stale {
// Stop sync timer, but keep the activity reference
// so endActivity() can still call .end() to properly dismiss it.
self.stopActivitySyncTimer()
} else if state == .ended || state == .dismissed {
self.workoutActivity = nil
self.stopActivitySyncTimer()
}

View File

@@ -12,7 +12,7 @@ struct OnboardingView: View {
@Environment(\.modelContext) private var context
enum Step: Int, CaseIterable {
case welcome, name, level, goal, frequency, ready
case welcome, name, level, goal, frequency, health, ready
var progress: Double { Double(rawValue) / Double(Step.allCases.count - 1) }
}
@@ -67,6 +67,7 @@ struct OnboardingView: View {
case .level: LevelStep(selection: $fitnessLevel)
case .goal: GoalStep(selection: $goal)
case .frequency: FrequencyStep(frequency: $weeklyFrequency, barriers: $selectedBarriers, allBarriers: barriers)
case .health: HealthStep(onContinue: { advance() })
case .ready: ReadyStep(name: name)
}
}
@@ -77,11 +78,13 @@ struct OnboardingView: View {
.animation(.spring(duration: 0.45), value: step)
// Pinned bottom button
PrimaryButton(label: buttonLabel, action: buttonAction)
.disabled(step == .name && name.trimmingCharacters(in: .whitespaces).isEmpty)
.padding(.horizontal, 32)
.padding(.bottom, 48)
.padding(.top, 16)
if step != .health {
PrimaryButton(label: buttonLabel, action: buttonAction)
.disabled(step == .name && name.trimmingCharacters(in: .whitespaces).isEmpty)
.padding(.horizontal, 32)
.padding(.bottom, 48)
.padding(.top, 16)
}
}
}
}
@@ -411,6 +414,68 @@ private struct FrequencyStep: View {
}
}
private struct HealthStep: View {
let onContinue: () -> Void
@State private var isRequesting = false
@State private var appeared = false
var body: some View {
VStack(spacing: 36) {
Spacer()
Image(systemName: "heart.text.square")
.font(.system(size: 80))
.foregroundStyle(Theme.brand.gradient)
OnboardingHeader(title: L10n.onboarding.healthAccess, subtitle: L10n.onboarding.healthAccessSubtitle)
// Primary button reuses shared PrimaryButton component
PrimaryButton(label: L10n.onboarding.allowHealthAccess, action: requestHealthAccess)
.disabled(isRequesting)
.padding(.horizontal, 32)
// Skip option
Button {
onContinue()
} label: {
Text(L10n.onboarding.notNow)
.font(.subheadline.weight(.medium))
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.disabled(isRequesting)
Spacer()
}
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 14)
.onAppear {
withAnimation(.spring(duration: 0.45)) { appeared = true }
}
}
private func requestHealthAccess() {
guard !isRequesting else { return }
isRequesting = true
Task {
do {
try await HealthKitService.shared.requestAuthorization()
} catch {
print("[HealthStep] HealthKit authorization error: \(error)")
// Continue user can try later in Settings
}
let authorized = await HealthKitService.shared.isAuthorized
if authorized {
AnalyticsService.shared.healthKitPermissionGranted()
} else {
AnalyticsService.shared.healthKitPermissionDenied()
}
isRequesting = false
onContinue()
}
}
}
private struct ReadyStep: View {
let name: String
@State private var showContent = false

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: