Compare commits
70 Commits
67e2bdc8c3
...
fix/dynami
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
174f4ebf7a | ||
|
|
560b056dc9 | ||
|
|
a309b2b593 | ||
| 98cbcd4d52 | |||
| 741376229d | |||
| de63ae0546 | |||
| 7d9a89e637 | |||
| 97b61d3afb | |||
| a9eda61967 | |||
| a382ad62dc | |||
| 069b7d417f | |||
| 63fbae3698 | |||
| 4b7548501a | |||
| 5079de0fb1 | |||
| 52346fb4ac | |||
| abf926e4bc | |||
| 7c50282284 | |||
| 6cc25300d1 | |||
| 09e77b477c | |||
| 5c73d6bf29 | |||
| d614e72031 | |||
| 3ca3e54837 | |||
| 4e2e807fd9 | |||
| b48178f167 | |||
| c45e85edb6 | |||
| 00e02b7166 | |||
| 2aa312ab61 | |||
| 595140e2f9 | |||
| afc161c8ce | |||
| 58177102a4 | |||
| 9739a739f5 | |||
| 7fb44198e7 | |||
| 8ce38332b8 | |||
| 3202ea9b5b | |||
| f14186aeab | |||
| 60ac624487 | |||
| a6ea4ca904 | |||
| dde01ce957 | |||
| 5b67aea7ce | |||
| ebfaa15e38 | |||
| 86fb683428 | |||
| a48ee0007a | |||
| cfe8e8cd1b | |||
| fdf4b6d558 | |||
| 44cebf834c | |||
| da62d6fa58 | |||
| 96654d61c7 | |||
| 1b3ce24fc9 | |||
| f5a777aee8 | |||
| 836cf9a16d | |||
| 7464357787 | |||
| 8f3979c8a0 | |||
| 1d4d43f3f2 | |||
| e43f197139 | |||
| 46bf8cae9b | |||
| 9b06c0ae5c | |||
| 6a659c41fa | |||
| 7a02a8949e | |||
| 001062c6d3 | |||
| bd81ce92ad | |||
| 77c17046d5 | |||
|
|
310124ad63 | ||
|
|
72ad247136 | ||
| f71ba55e8b | |||
|
|
38576fd528 | ||
|
|
df9fd48964 | ||
|
|
e42c1217db | ||
|
|
cd6fea9b53 | ||
|
|
d31b769ab8 | ||
|
|
c152c22ffb |
@@ -1,11 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Feb 20, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #5236 | 1:28 PM | 🔵 | Archived TabataGo PRD v1.1 discovered - comprehensive product requirements document | ~462 |
|
||||
</claude-mem-context>
|
||||
@@ -1,599 +0,0 @@
|
||||
# TabataGo — Brand Design Specification Document (BDSD) v1.0
|
||||
|
||||
> Ce document définit l'identité visuelle, le positionnement premium, et les principes esthétiques de TabataGo. Il guide toutes les décisions de design UI.
|
||||
|
||||
---
|
||||
|
||||
## 1. Vision de Marque
|
||||
|
||||
### 1.1 Essence
|
||||
|
||||
**TabataGo = Énergie pure, designé pour performer.**
|
||||
|
||||
Si TabataGo était une marque existante, ce serait **Gymshark** : jeune, audacieux, communauté, et visuellement impactant. Mais avec une touche **premium exclusive** qui élève l'expérience.
|
||||
|
||||
### 1.2 Positionnement
|
||||
|
||||
| Attribut | Définition |
|
||||
|-----------|------------|
|
||||
| **Émotion primaire** | Énergie / Vitalité — l'utilisateur doit se sentir vivant et motivé au premier regard |
|
||||
| **Positionnement** | Premium exclusif — haut de gamme, design soigné, pas une app "gratuite générique" |
|
||||
| **Adjectifs clés** | Dynamic • Vibrant • Energetic |
|
||||
| **Mood global** | Action Sports — comme être dans une salle de sport high-end, la nuit, avec des néons |
|
||||
|
||||
### 1.3 Personnalité de Marque
|
||||
|
||||
TabataGo est :
|
||||
- **Audacieux** — pas peur d'être vibrant et lumineux
|
||||
- **Énergique** — chaque élément respire le mouvement
|
||||
- **Premium** — jamais cheap, toujours soigné
|
||||
- **Motivant** — l'UI pousse l'utilisateur à se dépasser
|
||||
|
||||
---
|
||||
|
||||
## 2. Direction Visuelle
|
||||
|
||||
### 2.1 Palette de Couleurs
|
||||
|
||||
#### Couleur Primaire : Orange Energy
|
||||
|
||||
L'orange est le cœur de l'identité — il représente l'énergie, la chaleur, l'action.
|
||||
|
||||
| Token | Hex | Usage |
|
||||
|-------|-----|-------|
|
||||
| `ENERGY_500` | `#F97316` | Brand primary, CTA, accents principaux |
|
||||
| `ENERGY_400` | `#FB923C` | Glow effects, hover states |
|
||||
| `ENERGY_600` | `#EA580C` | Phase WORK, intensity |
|
||||
|
||||
#### Fond : Soft Dark
|
||||
|
||||
Un dark mode doux et riche, pas un noir pur agressif.
|
||||
|
||||
| Token | Hex | Usage |
|
||||
|-------|-----|-------|
|
||||
| `DARK_BASE` | `#1A1A1E` | Background principal |
|
||||
| `DARK_RAISED` | `#222226` | Cards level 1 |
|
||||
| `DARK_ELEVATED` | `#2A2A2E` | Cards level 2, modals |
|
||||
| `DARK_SURFACE` | `#0A0A0E` | Zones de focus, timer |
|
||||
|
||||
#### Accents & Phases
|
||||
|
||||
| Token | Hex | Usage |
|
||||
|-------|-----|-------|
|
||||
| `GLOW_NEON` | `#F97316` + glow | Flamme streak, highlights |
|
||||
| `PHASE_WORK` | `#F97316` | Gradient work |
|
||||
| `PHASE_REST` | `#3B82F6` | Gradient rest (bleu calme) |
|
||||
| `PHASE_READY` | `#EAB308` | Gradient préparation |
|
||||
| `SUCCESS` | `#22C55E` | Complétion, streak maintenu |
|
||||
|
||||
#### Textes
|
||||
|
||||
| Token | Valeur | Usage |
|
||||
|-------|--------|-------|
|
||||
| `TEXT_PRIMARY` | `#FFFFFF` | Titres, éléments principaux |
|
||||
| `TEXT_SECONDARY` | `rgba(255,255,255,0.85)` | Body text |
|
||||
| `TEXT_MUTED` | `rgba(255,255,255,0.60)` | Labels secondaires |
|
||||
| `TEXT_HINT` | `rgba(255,255,255,0.45)` | Placeholders, hints |
|
||||
|
||||
### 2.2 Gradients
|
||||
|
||||
**Philosophie : Subtils mais présents.** Les gradients ajoutent de la profondeur sans être agressifs.
|
||||
|
||||
#### Gradients de Phase
|
||||
|
||||
```css
|
||||
/* GET_READY - Ambre chaleureux */
|
||||
--gradient-ready: linear-gradient(135deg, #451A03, #92400E, #D97706);
|
||||
|
||||
/* WORK - Orange intense avec touche rouge */
|
||||
--gradient-work: linear-gradient(135deg, #450A0A, #991B1B, #EA580C);
|
||||
|
||||
/* REST - Bleu calme, récupération */
|
||||
--gradient-rest: linear-gradient(135deg, #0C1929, #1E3A5F, #2563EB);
|
||||
|
||||
/* COMPLETE - Vert succès */
|
||||
--gradient-complete: linear-gradient(135deg, #052E16, #166534, #16A34A);
|
||||
```
|
||||
|
||||
#### Gradients UI
|
||||
|
||||
```css
|
||||
/* Hero backgrounds - subtle */
|
||||
--gradient-dark: linear-gradient(135deg, #0A0A14, #1A0E28, #2D1810);
|
||||
|
||||
/* Card hover - très subtil */
|
||||
--gradient-card-hover: linear-gradient(135deg, rgba(249,115,22,0.08), rgba(249,115,22,0.02));
|
||||
|
||||
/* Button CTA - dimension */
|
||||
--gradient-cta: linear-gradient(135deg, #F97316, #FB923C);
|
||||
```
|
||||
|
||||
### 2.3 Glow & Neon Effects
|
||||
|
||||
L'identité TabataGo repose sur des **effets lumineux** qui donnent vie à l'interface.
|
||||
|
||||
#### Glow Colors
|
||||
|
||||
```css
|
||||
/* Glow orange - principal */
|
||||
--glow-energy: 0 0 40px rgba(249, 115, 22, 0.5);
|
||||
--glow-energy-intense: 0 0 80px rgba(249, 115, 22, 0.7);
|
||||
|
||||
/* Glow blanc - éléments actifs */
|
||||
--glow-white: 0 0 20px rgba(255, 255, 255, 0.3);
|
||||
|
||||
/* Neon flame effect */
|
||||
--neon-flame:
|
||||
0 0 10px rgba(249, 115, 22, 0.8),
|
||||
0 0 20px rgba(249, 115, 22, 0.6),
|
||||
0 0 40px rgba(249, 115, 22, 0.4);
|
||||
```
|
||||
|
||||
#### Applications du Glow
|
||||
|
||||
| Élément | Effet |
|
||||
|---------|-------|
|
||||
| Bouton START | Glow breathing animé (pulse) |
|
||||
| Flamme streak | Neon flame effect |
|
||||
| Card sélectionnée | Subtle glow border |
|
||||
| Phase WORK active | Ambient glow en fond |
|
||||
| Countdown ≤3s | Glow rouge qui pulse |
|
||||
|
||||
---
|
||||
|
||||
## 3. Typographie
|
||||
|
||||
### 3.1 Police : Inter
|
||||
|
||||
**Pourquoi Inter ?**
|
||||
- Excellent rendu sur écran
|
||||
- Large gamme de weights pour le contrast mix
|
||||
- Tabular nums pour le timer (pas de jitter)
|
||||
- Google Fonts — gratuit et performant
|
||||
|
||||
### 3.2 Contrast Mix Philosophy
|
||||
|
||||
**Heavy pour les titres, light pour le body.** Ce contraste crée de l'impact et de l'élégance.
|
||||
|
||||
| Usage | Weight | Size | Exemple |
|
||||
|-------|--------|------|---------|
|
||||
| Timer countdown | 900 (Black) | 140px | **20** |
|
||||
| Brand title | 900 (Black) | 56px | **TABATA** |
|
||||
| Headlines | 800 (ExtraBold) | 32px | **Terminé !** |
|
||||
| Body text | 400 (Regular) | 16px | Description du programme |
|
||||
| Captions | 500 (Medium) | 14px | Labels, hints |
|
||||
|
||||
### 3.3 Scale Typographique
|
||||
|
||||
```css
|
||||
/* Display - Impact maximal */
|
||||
--font-countdown: 900 140px/1 Inter;
|
||||
--font-display: 900 42px/1.1 Inter;
|
||||
--font-headline: 800 28px/1.2 Inter;
|
||||
|
||||
/* Interactive */
|
||||
--font-button-hero: 900 30px/1 Inter;
|
||||
--font-button: 700 18px/1 Inter;
|
||||
|
||||
/* Body */
|
||||
--font-body: 400 16px/1.5 Inter;
|
||||
--font-caption: 500 14px/1.4 Inter;
|
||||
--font-overline: 600 11px/1.2 Inter;
|
||||
```
|
||||
|
||||
### 3.4 Règles Typographiques
|
||||
|
||||
1. **Toujours tabular-nums** pour les chiffres du timer
|
||||
2. **Letter-spacing généreux** sur les all-caps (+2px minimum)
|
||||
3. **Jamais de italic** sauf pour les citations
|
||||
4. **Line-height serré** sur les grands titres (1.1)
|
||||
|
||||
---
|
||||
|
||||
## 4. Iconographie
|
||||
|
||||
### 4.1 Style : iOS Native
|
||||
|
||||
Utiliser des icônes qui s'intègrent naturellement à l'écosystème iOS, avec le style SF Symbols.
|
||||
|
||||
**Caractéristiques :**
|
||||
- Lignes medium (ni trop fines ni trop épaisses)
|
||||
- Coins arrondis
|
||||
- Consistance visuelle
|
||||
- Reconnaissance instantanée
|
||||
|
||||
### 4.2 Icônes Système
|
||||
|
||||
| Action | Icône | Style |
|
||||
|--------|-------|-------|
|
||||
| Play | ▶️ | SF Symbol `play.fill` |
|
||||
| Pause | ⏸️ | SF Symbol `pause.fill` |
|
||||
| Stop | ⏹️ | SF Symbol `stop.fill` |
|
||||
| Skip | ⏭️ | SF Symbol `forward.fill` |
|
||||
| Settings | ⚙️ | SF Symbol `gearshape.fill` |
|
||||
| Home | 🏠 | SF Symbol `house.fill` |
|
||||
| History | 📊 | SF Symbol `clock.fill` |
|
||||
|
||||
### 4.3 Tailles d'Icônes
|
||||
|
||||
| Contexte | Taille |
|
||||
|----------|--------|
|
||||
| Navigation tabs | 24×24px |
|
||||
| Boutons controls | 28×28px |
|
||||
| Cards (badges) | 16×16px |
|
||||
| Headers | 20×20px |
|
||||
|
||||
---
|
||||
|
||||
## 5. Imagerie & Illustrations
|
||||
|
||||
### 5.1 Style : Realistic Photos
|
||||
|
||||
**Pas de cartoon, pas d'abstrait.** Des vraies personnes qui font du sport.
|
||||
|
||||
**Caractéristiques des photos :**
|
||||
- Athlètes en action, pas poses statiques
|
||||
- Éclairage dynamique, contrasté
|
||||
- Background flou/sombre
|
||||
- Diversité de corps et d'origines
|
||||
- Qualité haute résolution
|
||||
|
||||
### 5.2 Traitement des Photos
|
||||
|
||||
```css
|
||||
/* Overlay pour intégration dark mode */
|
||||
.photo-overlay {
|
||||
background: linear-gradient(180deg, transparent 0%, rgba(0,0,0,0.6) 100%);
|
||||
}
|
||||
|
||||
/* Warm tint pour cohérence brand */
|
||||
.photo-tint {
|
||||
filter: saturate(1.1) sepia(0.1);
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 GIFs d'Exercices
|
||||
|
||||
- Durée : 1-2 secondes, loop seamless
|
||||
- Format : 200×200px minimum
|
||||
- Fond transparent ou sombre
|
||||
- Modèle en tenue fitness neutre
|
||||
|
||||
---
|
||||
|
||||
## 6. Composants UI
|
||||
|
||||
### 6.1 Boutons
|
||||
|
||||
#### Primary CTA (Pill Filled)
|
||||
|
||||
```css
|
||||
.cta-primary {
|
||||
background: linear-gradient(135deg, #F97316, #FB923C);
|
||||
border-radius: 9999px; /* Full pill */
|
||||
padding: 16px 40px;
|
||||
box-shadow: 0 0 40px rgba(249, 115, 22, 0.4);
|
||||
}
|
||||
|
||||
.cta-primary:active {
|
||||
transform: scale(0.95);
|
||||
box-shadow: 0 0 20px rgba(249, 115, 22, 0.6);
|
||||
}
|
||||
```
|
||||
|
||||
#### Secondary Button
|
||||
|
||||
```css
|
||||
.cta-secondary {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 16px;
|
||||
padding: 12px 24px;
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 Cards
|
||||
|
||||
#### Soft Elevated Cards
|
||||
|
||||
```css
|
||||
.card {
|
||||
background: rgba(34, 34, 38, 0.8); /* DARK_RAISED */
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.card-selected {
|
||||
border-color: rgba(249, 115, 22, 0.3);
|
||||
background: linear-gradient(135deg, rgba(249,115,22,0.08), rgba(249,115,22,0.02));
|
||||
box-shadow: 0 0 30px rgba(249, 115, 22, 0.2);
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 Progress Indicators
|
||||
|
||||
#### Progress Bar
|
||||
|
||||
```css
|
||||
.progress-bar {
|
||||
height: 6px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
background: linear-gradient(90deg, #F97316, #FB923C);
|
||||
border-radius: 9999px;
|
||||
transition: width 0.3s ease-out;
|
||||
}
|
||||
```
|
||||
|
||||
#### Creative Loading
|
||||
|
||||
Style : barre de progression stylisée avec glow.
|
||||
|
||||
```css
|
||||
.loading-bar {
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.loading-fill {
|
||||
width: 30%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #F97316, #FB923C);
|
||||
box-shadow: 0 0 10px rgba(249, 115, 22, 0.8);
|
||||
animation: loading-slide 1.5s ease-in-out infinite;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Motion & Animation
|
||||
|
||||
### 7.1 Philosophie : Bouncy & Playful
|
||||
|
||||
**Les animations doivent sentir vivantes, pas robotiques.**
|
||||
|
||||
| Type | Durée | Easing |
|
||||
|------|-------|--------|
|
||||
| Micro-interactions | 100-200ms | Ease-out |
|
||||
| Transitions standard | 300-400ms | Ease-in-out |
|
||||
| Animations bouncy | 400-600ms | Spring (damping: 12, stiffness: 180) |
|
||||
| Breathing loops | 2000-4000ms | Ease-in-out (loop) |
|
||||
|
||||
### 7.2 Animations Clés
|
||||
|
||||
#### Glow Breathing (Bouton START)
|
||||
|
||||
```css
|
||||
@keyframes glow-breathe {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 30px rgba(249, 115, 22, 0.3);
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 60px rgba(249, 115, 22, 0.5);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Pulse (Countdown)
|
||||
|
||||
```css
|
||||
@keyframes countdown-pulse {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.03); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
/* 200ms per tick */
|
||||
```
|
||||
|
||||
#### Bouncy Pop (Victory)
|
||||
|
||||
```css
|
||||
@keyframes victory-pop {
|
||||
0% { transform: scale(0.5); opacity: 0; }
|
||||
60% { transform: scale(1.1); }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
/* 500ms total */
|
||||
```
|
||||
|
||||
#### Pulsing Circles (Hero)
|
||||
|
||||
```css
|
||||
@keyframes hero-pulse {
|
||||
0% {
|
||||
transform: scale(0.8);
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
/* 2000ms, staggered */
|
||||
```
|
||||
|
||||
### 7.3 Micro-interactions Premium
|
||||
|
||||
| Interaction | Feedback |
|
||||
|-------------|----------|
|
||||
| Tap bouton | `scale(0.95)` + haptic light |
|
||||
| Tap card | `scale(0.98)` + border glow fade |
|
||||
| Focus input | Border glow animation |
|
||||
| Swipe | Resistance elastique |
|
||||
| Phase change | Cross-fade 400ms + haptic medium |
|
||||
|
||||
---
|
||||
|
||||
## 8. Effets Décoratifs
|
||||
|
||||
### 8.1 Flamme Neon (Streak)
|
||||
|
||||
La flamme représente le streak — elle doit être **lumineuse, dynamic, motivante**.
|
||||
|
||||
**Spécifications :**
|
||||
- Style : Neon glow flame
|
||||
- Animation : Scale pulse + opacity flicker
|
||||
- Couleurs : Orange avec halo lumineux
|
||||
- Taille : 48-80px selon contexte
|
||||
|
||||
```css
|
||||
.neon-flame {
|
||||
filter: drop-shadow(0 0 10px rgba(249, 115, 22, 0.8))
|
||||
drop-shadow(0 0 20px rgba(249, 115, 22, 0.6))
|
||||
drop-shadow(0 0 40px rgba(249, 115, 22, 0.4));
|
||||
animation: flame-flicker 800ms ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes flame-flicker {
|
||||
0%, 100% { opacity: 0.9; transform: scale(1); }
|
||||
50% { opacity: 1; transform: scale(1.05); }
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 Pulsing Circles (Background Hero)
|
||||
|
||||
Utilisés pour les moments de victoire et les écrans hero.
|
||||
|
||||
```css
|
||||
.hero-circles::before,
|
||||
.hero-circles::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(249,115,22,0.3), transparent 70%);
|
||||
animation: hero-pulse 2000ms ease-out infinite;
|
||||
}
|
||||
|
||||
.hero-circles::after {
|
||||
animation-delay: 1000ms;
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 Floating Particles
|
||||
|
||||
Particules subtiles qui flottent en arrière-plan pour ajouter de la vie.
|
||||
|
||||
**Spécifications :**
|
||||
- Nombre : 20-50 particules
|
||||
- Taille : 2-6px
|
||||
- Couleur : `rgba(255,255,255,0.1)` à `rgba(249,115,22,0.2)`
|
||||
- Mouvement : Slow drift, random
|
||||
- Opacité : 0.1-0.3
|
||||
|
||||
---
|
||||
|
||||
## 9. Spacing & Layout
|
||||
|
||||
### 9.1 Whitespace Généreux
|
||||
|
||||
Le premium respire. **Plus d'espace = plus d'impact.**
|
||||
|
||||
| Token | Valeur | Usage |
|
||||
|-------|--------|-------|
|
||||
| `space-xs` | 4px | Inline gaps |
|
||||
| `space-sm` | 8px | Between related items |
|
||||
| `space-md` | 16px | Section padding |
|
||||
| `space-lg` | 24px | Between sections |
|
||||
| `space-xl` | 40px | Major sections |
|
||||
| `space-2xl` | 64px | Hero spacing |
|
||||
|
||||
### 9.2 Rounded Corners
|
||||
|
||||
| Token | Valeur | Usage |
|
||||
|-------|--------|-------|
|
||||
| `radius-sm` | 8px | Tags, small elements |
|
||||
| `radius-md` | 12px | Buttons medium |
|
||||
| `radius-lg` | 16px | Cards |
|
||||
| `radius-xl` | 20px | Large cards, bottom sheets |
|
||||
| `radius-full` | 9999px | Pills, circles |
|
||||
|
||||
---
|
||||
|
||||
## 10. Son Design
|
||||
|
||||
### 10.1 Philosophie
|
||||
|
||||
Les sons doivent être **subtils mais mémorables**. Ils renforcent le feedback sans être intrusifs.
|
||||
|
||||
### 10.2 Sons Clés
|
||||
|
||||
| Événement | Son | Caractéristiques |
|
||||
|-----------|-----|------------------|
|
||||
| Bip phase start | Court, percutant | 200ms, fréquence montante |
|
||||
| Countdown tick | Click subtil | 100ms, tone medium |
|
||||
| Phase WORK start | "Go!" ou bip fort | 300ms, énergique |
|
||||
| Victoire complète | Fanfare courte | 1000ms, célébration |
|
||||
| Haptic sync | Synchronisé au son | Feedback tactile |
|
||||
|
||||
### 10.3 Intégration
|
||||
|
||||
- Volume par défaut : 80% pour sons, 60% pour musique
|
||||
- Ducking : Sons de phase prioritaire sur musique
|
||||
- Respect silent mode (option override)
|
||||
|
||||
---
|
||||
|
||||
## 11. Inspirations & Références
|
||||
|
||||
### 11.1 Apple Fitness+
|
||||
|
||||
**Ce qu'on en reprend :**
|
||||
- Dark mode élégant avec accents colorés
|
||||
- Gradients subtils de phase
|
||||
- Typography bold et impactante
|
||||
- Animations fluides et premium
|
||||
- Rings de progression stylisés
|
||||
|
||||
### 11.2 Gymshark
|
||||
|
||||
**Ce qu'on en reprend :**
|
||||
- Énergie et dynamisme
|
||||
- Communauté et motivation
|
||||
- Photography realistic fitness
|
||||
- Brand bold et young
|
||||
|
||||
### 11.3 Gaming UI (référence secondaire)
|
||||
|
||||
**Ce qu'on en reprend :**
|
||||
- Effets neon/glow
|
||||
- Animations bouncy
|
||||
- Feedback immédiat et satisfaisant
|
||||
- Progress bars créatives
|
||||
|
||||
---
|
||||
|
||||
## 12. Checklist Brand Consistency
|
||||
|
||||
Avant chaque release, vérifier :
|
||||
|
||||
- [ ] **Orange utilisé comme accent dominant** (pas dilué)
|
||||
- [ ] **Glow effects présents** sur les éléments interactifs clés
|
||||
- [ ] **Animations bouncy** et non robotiques
|
||||
- [ ] **Whitespace généreux** — pas de surcharge visuelle
|
||||
- [ ] **Rounded corners** partout (pas de corners carrés)
|
||||
- [ ] **Photos realistic** et non illustrées
|
||||
- [ ] **Haptics synchronisés** avec les interactions
|
||||
- [ ] **Dark mode soft grey** et non true black
|
||||
- [ ] **Contrast typo** (heavy titles, light body)
|
||||
- [ ] **Feedback sonore subtil** mais présent
|
||||
|
||||
---
|
||||
|
||||
## 13. Versions
|
||||
|
||||
| Version | Date | Changements |
|
||||
|---------|------|-------------|
|
||||
| 1.0 | 18 février 2026 | Création initiale |
|
||||
|
||||
---
|
||||
|
||||
*Document créé le 18 février 2026 — TabataGo BDSD v1.0*
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,597 +0,0 @@
|
||||
|
||||
|
||||
| 🔥 TABATAGO Application Mobile de Minuterie Tabata *Product Requirements Document (PRD) v1.1* Framework : Expo (React Native) · Cible : iOS & Android · Février 2026 |
|
||||
| :---: |
|
||||
|
||||
# **1\. Résumé Exécutif**
|
||||
|
||||
TabataGo est une application mobile premium dédiée à l'entraînement par intervalles en méthode Tabata (20 secondes d'effort / 10 secondes de repos). Elle cible les personnes qui veulent des séances courtes, intenses et guidées — sans abonnement à une salle. Le marché des applications de fitness dépasse 1,5 milliard de dollars en revenus annuels et le Tabata reste un mot-clé à forte demande (popularité \> 70\) avec une difficulté ASO modérée (\< 50).
|
||||
|
||||
| Indicateur | Valeur cible |
|
||||
| :---- | :---- |
|
||||
| Marché visé | Fitness enthousiasts 25-45 ans, Home workout, HIIT lovers |
|
||||
| Plateformes | iOS (priorité) \+ Android |
|
||||
| Framework | Expo (React Native) — SDK 52+ |
|
||||
| Modèle économique | Freemium \+ Abonnement mensuel/annuel via RevenueCat |
|
||||
| Prix cible (US) | $4.99/mois ou $29.99/an |
|
||||
| Objectif J+30 | 5 000 téléchargements, taux de conversion essai \> 30% |
|
||||
| Objectif J+90 | MRR $5 000, note App Store ≥ 4.6 |
|
||||
|
||||
# **2\. Fonctionnalités Core — Spécifications Détaillées**
|
||||
|
||||
TabataGo repose sur trois piliers fonctionnels indissociables : le Timer (moteur de l'expérience), la Musique (moteur émotionnel), et l'Exercice (moteur pédagogique). Ces trois composants doivent fonctionner en parfaite synchronisation.
|
||||
|
||||
## **2.1 Le Timer — Moteur Central**
|
||||
|
||||
Le timer est la fonctionnalité principale et vitale de l'application. Son implémentation doit être irréprochable : précision, fluidité visuelle, comportement en arrière-plan.
|
||||
|
||||
### **2.1.1 Structure d'une Séance Tabata Standard**
|
||||
|
||||
| Phase | Durée par défaut | Configurable ? | Couleur écran | Signal |
|
||||
| :---- | :---- | :---- | :---- | :---- |
|
||||
| Préparation (Get Ready) | 10 secondes | Oui (5–30s) | Jaune \#EAB308 | Bip court × 3 \+ vibration légère |
|
||||
| Travail (Work) | 20 secondes | Oui (5–60s) | Orange vif \#F97316 | Bip long au démarrage \+ haptique fort |
|
||||
| Repos (Rest) | 10 secondes | Oui (5–60s) | Bleu calme \#3B82F6 | Double bip court \+ haptique léger |
|
||||
| Fin de round | Instantané | — | Flash blanc | Son de cloche \+ vibration |
|
||||
| Fin de séance | Écran résultat | — | Vert \#22C55E | Fanfare \+ longue vibration |
|
||||
|
||||
### **2.1.2 Paramètres Configurables du Timer**
|
||||
|
||||
| Paramètre | Valeur par défaut | Min | Max | Accès |
|
||||
| :---- | :---- | :---- | :---- | :---- |
|
||||
| Durée Work | 20s | 5s | 60s | Tous |
|
||||
| Durée Rest | 10s | 5s | 60s | Tous |
|
||||
| Nombre de rounds | 8 | 1 | 30 | Tous |
|
||||
| Durée Get Ready | 10s | 0s | 30s | Tous |
|
||||
| Nombre de cycles | 1 | 1 | 10 | Premium |
|
||||
| Pause entre cycles | 60s | 10s | 300s | Premium |
|
||||
| Cycles de récupération | — | — | — | Premium |
|
||||
|
||||
### **2.1.3 Affichage Timer — Écran Séance (plein écran)**
|
||||
|
||||
| 📱 LAYOUT — Écran Timer Plein Écran |
|
||||
| :---- |
|
||||
| ▸ Zone HAUTE (20%) : Nom de l'exercice en cours \+ numéro de round (ex: "Burpees — Round 3/8") |
|
||||
| ▸ Zone CENTRALE (50%) : Chiffre du compte à rebours — très grand (96-120px), police monospace, couleur de phase |
|
||||
| ▸ Zone BASSE HAUTE (15%) : Barre de progression de la séance complète (rounds) \+ indicateur phase actuelle |
|
||||
| ▸ Zone BASSE (15%) : Boutons Pause / Stop / Skip — discrets pour ne pas distraire |
|
||||
| ▸ FOND : Couleur dynamique selon la phase (orange work, bleu rest, jaune prep) avec transition animée |
|
||||
| ▸ ANIMATION : Pulsation subtile du chiffre à chaque seconde \+ ring circulaire de progression |
|
||||
|
||||
### **2.1.4 Comportement Technique du Timer**
|
||||
|
||||
* Précision : Utiliser expo-background-fetch \+ Date.now() delta pour compenser les drifts — tolérance \< 50ms
|
||||
|
||||
* Background : Le timer continue en arrière-plan (notification sticky affichant le compte à rebours)
|
||||
|
||||
* Verrouillage écran : Écran reste allumé pendant la séance (expo-keep-awake)
|
||||
|
||||
* Interruptions : Pause automatique si appel téléphonique entrant (AppState listener)
|
||||
|
||||
* Reprise : Si l'app est tuée, afficher une notification "Séance interrompue — Reprendre ?" au retour
|
||||
|
||||
* OTA update safe : Le state du timer est isolé du cycle de render React pour éviter les glitches
|
||||
|
||||
## **2.2 La Musique — Moteur Émotionnel**
|
||||
|
||||
La musique transforme une simple minuterie en expérience motivante. Elle doit s'adapter dynamiquement à chaque phase de la séance (work vs rest) et ne jamais entrer en conflit avec la musique de l'utilisateur ou les signaux sonores.
|
||||
|
||||
### **2.2.1 Architecture Sonore**
|
||||
|
||||
| Couche audio | Description | Technologie | Contrôle utilisateur |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| Musique d'ambiance | Tracks BPM-synchronisées intégrées à l'app | expo-av (AVAudioSession) | Volume indépendant, on/off |
|
||||
| Signaux de phase | Bips, voix, cloche — changement work/rest | expo-av (priorité haute) | Volume indépendant, choix du son |
|
||||
| Voix coach | Annonces vocales (optionnel) : "Go\!", "Rest", "Last round\!" | expo-av (TTS ou fichiers pré-enregistrés) | On/off, langue |
|
||||
| Haptiques | Vibrations synchronisées aux signaux | expo-haptics | On/off |
|
||||
|
||||
### **2.2.2 Catalogue Musical Intégré (Offline)**
|
||||
|
||||
| 🎵 TRACKS INTÉGRÉES — 3 ambiances × 3 intensités \= 9 tracks minimum |
|
||||
| :---- |
|
||||
| ▸ Ambiance ELECTRO : Low (récup), Medium (standard), High (intense) — BPM 120/140/160 |
|
||||
| ▸ Ambiance HIP-HOP : Low (récup), Medium (standard), High (intense) — BPM 85/100/115 |
|
||||
| ▸ Ambiance ROCK/METAL : Low (récup), Medium (standard), High (intense) — BPM 130/150/170 |
|
||||
| ▸ Mode SILENCE : Aucune musique, uniquement les signaux sonores de phase |
|
||||
| ▸ Mode SPOTIFY/APPLE MUSIC : L'app n'interfère pas avec la musique de l'utilisateur (coexistence audio) |
|
||||
| ▸ Toutes les tracks sont royalty-free et embarquées dans le bundle — aucun streaming requis |
|
||||
|
||||
### **2.2.3 Synchronisation Musique ↔ Timer**
|
||||
|
||||
* Phase WORK : Passer automatiquement à la track haute intensité de l'ambiance sélectionnée
|
||||
|
||||
* Phase REST : Transition en fade-out 1s vers la track basse intensité (ambiance calme)
|
||||
|
||||
* Phase GET READY : Intro de 10s sur la track principale
|
||||
|
||||
* Transition douce : Cross-fade 500ms entre les phases pour éviter les coupures brutales
|
||||
|
||||
* BPM adaptatif (Premium) : L'app détecte le rythme de la track et aligne le bip de fin de phase sur le beat
|
||||
|
||||
* Pas de conflit : Si l'utilisateur a sa propre musique, les signaux de phase s'y superposent en ducking audio (baisse temporaire du volume)
|
||||
|
||||
### **2.2.4 Signaux Sonores de Phase — Options**
|
||||
|
||||
| Signal | Options disponibles | Par défaut | Premium uniquement ? |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| Début Work | Bip long, Whistle, Voix "Go\!", Air horn, Clap | Bip long | Non |
|
||||
| Début Rest | Double bip, Voix "Rest", Bell, Ding | Double bip | Non |
|
||||
| Décompte 3-2-1 | Bips courts, Voix "3, 2, 1", Silence | Bips courts | Non |
|
||||
| Fin de round | Cloche, Applaudissements, Voix "Round X done\!" | Cloche | Oui |
|
||||
| Fin de séance | Fanfare, Applaudissements, Voix "Workout complete\!" | Fanfare | Non |
|
||||
| Dernier round | Voix "Last round\!", Alarm, Son spécial | Voix | Oui |
|
||||
|
||||
### **2.2.5 Gestion Technique Audio (expo-av)**
|
||||
|
||||
* Session audio iOS : AVAudioSessionCategoryPlayback avec MixWithOthers — permet de jouer avec la musique utilisateur
|
||||
|
||||
* Focus audio Android : AudioManager.AUDIOFOCUS\_GAIN\_TRANSIENT\_MAY\_DUCK pour les signaux
|
||||
|
||||
* Préchargement : Tous les sons de phase sont chargés en mémoire au démarrage de la séance (zéro latence)
|
||||
|
||||
* Mode silencieux iOS : Les signaux de phase respectent le switch mute SAUF si l'utilisateur a activé "override" dans les settings
|
||||
|
||||
* Headphones détection : Si écouteurs branchés, désactiver les haptiques de phase par défaut
|
||||
|
||||
## **2.3 L'Exercice — Moteur Pédagogique**
|
||||
|
||||
Chaque round du timer doit être associé à un exercice spécifique, affiché clairement pendant la phase de travail. C'est ce qui différencie TabataGo d'une simple minuterie générique.
|
||||
|
||||
### **2.3.1 Bibliothèque d'Exercices**
|
||||
|
||||
| Catégorie | Exemples d'exercices | Nb exercices V1 | Nb exercices V2 |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| Cardio / Full body | Burpees, Jumping Jacks, Mountain Climbers, High Knees | 8 | 20 |
|
||||
| Bas du corps | Squats, Fentes, Jump Squats, Glute Bridges, Wall Sit | 8 | 20 |
|
||||
| Haut du corps | Push-ups, Pike Push-ups, Tricep Dips, Shoulder Taps | 6 | 15 |
|
||||
| Abdos / Core | Crunches, Planche, Russian Twists, Bicycle Crunches | 6 | 15 |
|
||||
| Sans saut (low impact) | Slow Squats, Modified Push-ups, Step Touch, March | 6 | 15 |
|
||||
| Avec matériel | Kettlebell Swings, Dumbbell Thrusters, Jump Rope | 4 | 10 |
|
||||
|
||||
### **2.3.2 Fiche Exercice — Données par Exercice**
|
||||
|
||||
| 📋 MODÈLE DE DONNÉES — Exercice |
|
||||
| :---- |
|
||||
| ▸ id : identifiant unique (ex: "burpee\_classic") |
|
||||
| ▸ name : Nom localisé (FR: "Burpee", EN: "Burpee", ES: "Burpee") |
|
||||
| ▸ category : cardio | lower\_body | upper\_body | core | low\_impact | equipment |
|
||||
| ▸ difficulty : beginner | intermediate | advanced |
|
||||
| ▸ musclesTargeted : string\[\] (ex: \["quadriceps", "pectoraux", "cardio"\]) |
|
||||
| ▸ description : Instruction courte (max 80 car.) — affichée pendant la phase Rest |
|
||||
| ▸ cues : string\[\] — 2-3 points clés de forme (ex: "Dos droit", "Genoux alignés") |
|
||||
| ▸ gifUrl : Animation GIF courte (1-2s, loop) — 200×200px max — embarquée offline |
|
||||
| ▸ thumbnailUrl : Image statique pour la bibliothèque |
|
||||
| ▸ hasModification : bool — si une variante plus facile existe |
|
||||
| ▸ modificationId : id de l'exercice de remplacement (ex: "burpee\_modified") |
|
||||
| ▸ equipmentNeeded : string\[\] (ex: \[\] pour aucun, \["tapis"\] pour matériel simple) |
|
||||
|
||||
### **2.3.3 Affichage de l'Exercice Pendant la Séance**
|
||||
|
||||
| Phase | Affichage exercice | Taille | Information complémentaire |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| GET READY (10s) | Nom \+ GIF animé de démonstration | Grande — focus total | "Voici l'exercice suivant" — prépare mentalement |
|
||||
| WORK (20s) | Nom en haut \+ compteur central — GIF en petit coin | Nom moyen, timer dominant | 1-2 cues de forme affichés sous le nom |
|
||||
| REST (10s) | Nom de l'EXERCICE SUIVANT \+ vignette | Taille moyenne | "Prochain : \[Nom\]" — anticipation |
|
||||
| FIN DE ROUND | Résumé rapide du round (1s) | Plein écran flash | Round X complété \+ prochain exercice |
|
||||
|
||||
### **2.3.4 Types de Programmes Tabata**
|
||||
|
||||
| 🗂️ PROGRAMMES DISPONIBLES — V1 |
|
||||
| :---- |
|
||||
| ▸ MODE 1 — Exercice unique répété : Le même exercice sur les 8 rounds (ex: 8 rounds de Burpees) — Tabata classique |
|
||||
| ▸ MODE 2 — Circuit 2 exercices : Alternance A/B sur 8 rounds (ex: Squats / Push-ups × 4 répétitions) — Tabata duo |
|
||||
| ▸ MODE 3 — Circuit 4 exercices : 4 exercices × 2 rounds chacun — Tabata circuit (Premium) |
|
||||
| ▸ MODE 4 — Programme libre : L'utilisateur assigne manuellement un exercice à chaque round (Premium) |
|
||||
| ▸ MODE 5 — Programme IA : Sélection automatique selon niveau, objectif et historique (Premium V2) |
|
||||
|
||||
### **2.3.5 Créateur de Programme Personnalisé (Premium)**
|
||||
|
||||
* Interface drag & drop pour assigner les exercices à chaque round
|
||||
|
||||
* Sauvegarde illimitée de programmes personnalisés (nom, description, tags)
|
||||
|
||||
* Partage de programme par lien deep link (ex: tabatago://program/abc123)
|
||||
|
||||
* Import de programme depuis un lien partagé par un autre utilisateur
|
||||
|
||||
* Favoris : marquer des exercices pour les retrouver rapidement
|
||||
|
||||
### **2.3.6 Contenu Offline & Performance**
|
||||
|
||||
* Tous les GIFs d'exercices V1 (38 exercices × 1 GIF ≈ 150KB chacun ≈ \~6MB total) — embarqués dans le bundle
|
||||
|
||||
* Lazy loading pour les exercices V2+ : téléchargement à la demande, mis en cache localement
|
||||
|
||||
* Fallback : Si pas de GIF disponible, afficher une icône \+ description textuelle
|
||||
|
||||
* Accessibilité : alt text sur chaque GIF pour VoiceOver/TalkBack
|
||||
|
||||
## **2.4 Synchronisation Timer × Musique × Exercice**
|
||||
|
||||
Les trois composants doivent former une expérience unifiée et cohérente. Ce tableau décrit les événements et leurs effets croisés :
|
||||
|
||||
| Événement Timer | Effet sur la Musique | Effet sur l'Exercice | Haptique |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| Début GET READY | Fade-in track principale | Afficher GIF exercice Round 1 | Léger |
|
||||
| Décompte 3-2-1 | Volume augmente | Animation pulsation sur le GIF | Bip × 3 |
|
||||
| Début WORK | Switch vers track high BPM | Afficher nom \+ cues en grand | Fort |
|
||||
| Milieu WORK (10s) | Rien | "Halfway\!" en overlay 1s | Aucun |
|
||||
| Fin WORK | Switch vers track low BPM | Afficher exercice SUIVANT | Moyen |
|
||||
| Début REST | Track calme | "Next: \[Exercice\]" \+ vignette | Léger |
|
||||
| Dernier round warning | Effet sonore spécial | Badge "LAST ROUND" sur nom | Vibration longue |
|
||||
| Fin de séance | Fade-out \+ fanfare | Écran résultat avec tous les exercices | Célébration |
|
||||
|
||||
# **3\. Validation du Marché & Stratégie ASO**
|
||||
|
||||
## **3.1 Analyse des Mots-Clés Cibles**
|
||||
|
||||
Les mots-clés suivants ont été validés via Astro (difficulté \< 55, popularité \> 20\) :
|
||||
|
||||
| Mot-clé | Popularité | Difficulté | Marché |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| tabata timer | 72 | 48 | EN 🇺🇸 |
|
||||
| hiit timer app | 68 | 52 | EN 🇺🇸 |
|
||||
| minuterie tabata | 35 | 28 | FR 🇫🇷 |
|
||||
| temporizador tabata | 41 | 31 | ES 🇪🇸 |
|
||||
| tabata training timer | 55 | 44 | EN 🇬🇧 |
|
||||
| intervall timer workout | 38 | 29 | DE 🇩🇪 |
|
||||
|
||||
## **3.2 Analyse Concurrentielle**
|
||||
|
||||
Principaux concurrents identifiés et opportunités de différenciation :
|
||||
|
||||
| Concurrent | Forces | Faiblesses (notre opportunité) |
|
||||
| :---- | :---- | :---- |
|
||||
| Tabata Timer (App Store Top 1\) | Notoriété, simplicité | UI datée, pas de suivi streak, pas de localisation |
|
||||
| Seconds Pro | Très complet, flexible | Trop complexe, prix élevé, courbe d'apprentissage |
|
||||
| HIIT Interval Training Timer | Gratuit, fonctionnel | Pub intrusive, pas d'onboarding émotionnel |
|
||||
| Tabata Stopwatch Pro | Simple, rapide | Pas de personnalisation, no streak, no widget |
|
||||
|
||||
Notre avantage : onboarding émotionnel fort \+ design moderne \+ widgets iOS/Android \+ streaks \+ localisation dans 5 langues.
|
||||
|
||||
# **4\. Onboarding — La Séquence Critique**
|
||||
|
||||
L'onboarding est la priorité absolue : 80% des revenus sont générés ici. L'objectif est de créer un investissement émotionnel avant d'afficher le paywall. La séquence suit le schéma : Problème → Empathie → Solution → Moment Wow → Paywall.
|
||||
|
||||
## **4.1 Écrans d'Onboarding (séquence de 6 écrans)**
|
||||
|
||||
### **Écran 1 — Le Problème (Identifier la douleur)**
|
||||
|
||||
| 🎯 OBJECTIF : Identification |
|
||||
| :---- |
|
||||
| ▸ Titre : "Tu n'as pas 1 heure pour la salle. Personne n'en a." |
|
||||
| ▸ Sous-titre : "Et pourtant tu veux progresser. On a la solution." |
|
||||
| ▸ Visuel : Animation subtile d'une horloge qui se fragmente puis se reconstruit en 20 min |
|
||||
| ▸ CTA : "Montre-moi comment" (bouton orange pleine largeur) |
|
||||
|
||||
### **Écran 2 — L'Empathie (L'utilisateur se sent compris)**
|
||||
|
||||
| 💬 OBJECTIF : Connexion émotionnelle |
|
||||
| :---- |
|
||||
| ▸ Titre : "Qu'est-ce qui t'empêche de t'entraîner ?" |
|
||||
| ▸ Choix interactifs (tap) : Manque de temps / Motivation en berne / Je ne sais pas quoi faire / Je n'ai pas accès à une salle |
|
||||
| ▸ Mécanisme : stocker la réponse → personnaliser le reste de l'onboarding |
|
||||
| ▸ Transition : "On a conçu TabataGo exactement pour ça." |
|
||||
|
||||
### **Écran 3 — La Solution (Présenter la méthode)**
|
||||
|
||||
| ⚡ OBJECTIF : Comprendre la valeur |
|
||||
| :---- |
|
||||
| ▸ Titre : "4 minutes. Vraiment transformatrices." |
|
||||
| ▸ Animation interactive : timeline Tabata (20s work / 10s rest × 8 rounds) |
|
||||
| ▸ Stats affichées : Brûle autant de calories qu'un jogging de 30 min / Prouvé scientifiquement depuis 1996 (Dr. Tabata) |
|
||||
| ▸ Visuel : compteur animé qui tourne — preview de l'app |
|
||||
|
||||
### **Écran 4 — Le Moment "Wow" (Démo interactive)**
|
||||
|
||||
| 🔥 OBJECTIF : Engagement actif (ne pas juste regarder) |
|
||||
| :---- |
|
||||
| ▸ Titre : "Essaie maintenant. 20 secondes." |
|
||||
| ▸ Mini-minuterie Tabata LIVE intégrée dans l'écran d'onboarding |
|
||||
| ▸ L'utilisateur tape sur "Go" et vit 20s de compte à rebours \+ son \+ vibration |
|
||||
| ▸ Après : "Tu viens de faire ta première série Tabata. 7 de plus et c'est une séance complète." |
|
||||
| ▸ Note : Ce moment est le plus différenciant — créer une mini-expérience réelle |
|
||||
|
||||
### **Écran 5 — Personnalisation (Engagement supplémentaire)**
|
||||
|
||||
| ⚙️ OBJECTIF : Investissement personnel |
|
||||
| :---- |
|
||||
| ▸ Titre : "Configurons ta première semaine." |
|
||||
| ▸ Sélection : Niveau (Débutant / Intermédiaire / Avancé) |
|
||||
| ▸ Sélection : Objectif (Perte de poids / Cardio / Force / Bien-être) |
|
||||
| ▸ Sélection : Fréquence souhaitée (2x / 3x / 5x par semaine) |
|
||||
| ▸ Résultat : "Ton programme personnalisé est prêt." (sensation de valeur avant paiement) |
|
||||
|
||||
### **Écran 6 — Paywall (Après l'investissement émotionnel)**
|
||||
|
||||
| 💳 OBJECTIF : Conversion |
|
||||
| :---- |
|
||||
| ▸ Titre : "Continue sur ta lancée. Sans limite." |
|
||||
| ▸ Présenter l'essai gratuit 7 jours en premier (bouton principal orange) |
|
||||
| ▸ Options : Mensuel $4.99 / Annuel $29.99 (économie 50% mise en évidence) |
|
||||
| ▸ Garantie visible : "Annule à tout moment" \+ "Satisfait ou remboursé 30j" |
|
||||
| ▸ Lien "Continuer sans abonnement" en petit en bas (ne pas le cacher) |
|
||||
| ▸ Intégration RevenueCat — avec A/B test activé dès le lancement |
|
||||
|
||||
# **5\. Design & Identité Visuelle**
|
||||
|
||||
## **5.1 Palette de Couleurs**
|
||||
|
||||
| Rôle | Couleur | Hex | Usage |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| Primaire / Action | Orange Tabata | \#F97316 | Boutons CTA, accents, timer actif |
|
||||
| Fond Dark (défaut) | Charcoal Night | \#1E1E2E | Background principal mode sombre |
|
||||
| Fond Light | Warm White | \#FFF7ED | Background mode clair |
|
||||
| Texte principal Dark | Stone 900 | \#1C1917 | Titres mode clair |
|
||||
| Texte secondaire | Stone 600 | \#57534E | Body text, descriptions |
|
||||
| Succès / Streak | Green 500 | \#22C55E | Streaks, complétion, feedback positif |
|
||||
| Danger / Alerte | Red 500 | \#EF4444 | Erreurs, derniers secondes |
|
||||
|
||||
## **5.2 Typographie**
|
||||
|
||||
| Usage | Police | Poids | Taille |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| Timer principal | Inter (monospace fallback) | Black (900) | 96-120px |
|
||||
| Titres H1 | Inter | Bold (700) | 28-32px |
|
||||
| Titres H2 | Inter | SemiBold (600) | 22-24px |
|
||||
| Body / Labels | Inter | Regular (400) | 14-16px |
|
||||
| Micro-labels | Inter | Medium (500) | 11-12px |
|
||||
|
||||
## **5.3 Principes Design**
|
||||
|
||||
* Mode sombre par défaut (immersif pendant l'entraînement)
|
||||
|
||||
* Timer en plein écran pendant la séance — aucune distraction
|
||||
|
||||
* Animations fluides (60fps) pour les transitions et le compteur
|
||||
|
||||
* Haptiques natifs : vibration légère à chaque changement de phase
|
||||
|
||||
* Support Dynamic Type (iOS) et font scaling (Android)
|
||||
|
||||
* Icône app : fond noir, lettre T stylisée en orange avec une flamme
|
||||
|
||||
# **6\. Architecture Technique & Développement (Expo)**
|
||||
|
||||
## **6.1 Stack Technique**
|
||||
|
||||
| Composant | Solution choisie | Justification |
|
||||
| :---- | :---- | :---- |
|
||||
| Framework | Expo SDK 52 (React Native) | Cross-platform, OTA updates, accès natif facile |
|
||||
| Navigation | Expo Router v3 (file-based) | Standard moderne, deep linking natif |
|
||||
| State management | Zustand \+ AsyncStorage | Léger, performant, persistance simple |
|
||||
| Timer engine | expo-background-fetch \+ useInterval custom | Précision \+ exécution background |
|
||||
| Audio | expo-av | Sons de décompte et alertes phase |
|
||||
| Haptiques | expo-haptics | Retour tactile natif iOS/Android |
|
||||
| Notifications | expo-notifications | Rappels d'entraînement \+ streaks |
|
||||
| Widget | react-native-widget-extension | Widget iOS 14+ (Live Activity) |
|
||||
| Paiements | react-native-purchases (RevenueCat) | Abonnements \+ A/B testing \+ analytics |
|
||||
| Analytics | PostHog (react-native-posthog) | Funnel tracking, drop-off analysis |
|
||||
| Storage | expo-secure-store \+ AsyncStorage | Données utilisateur \+ préférences |
|
||||
| In-App Review | expo-store-review | Prompt après streak 7 jours |
|
||||
|
||||
## **6.2 Features — MoSCoW Priorisation**
|
||||
|
||||
### **Must Have (V1 — Lancement)**
|
||||
|
||||
| ✅ MUST HAVE — Indispensables au lancement |
|
||||
| :---- |
|
||||
| ▸ Timer Tabata complet — voir Section 2.1 (toutes phases, sons, haptiques, background) |
|
||||
| ▸ Musique intégrée — voir Section 2.2 (3 ambiances × 3 intensités, signaux de phase, coexistence audio) |
|
||||
| ▸ Exercices avec GIFs — voir Section 2.3 (38 exercices, modes 1 et 2, affichage GET READY/WORK/REST) |
|
||||
| ▸ Synchronisation Timer × Musique × Exercice — voir Section 2.4 |
|
||||
| ▸ Modes : Tabata classique, HIIT personnalisé, Pause active |
|
||||
| ▸ Affichage plein écran pendant la séance (mode portrait \+ paysage) |
|
||||
| ▸ Historique des séances (date, durée, rounds complétés) |
|
||||
| ▸ Système de Streak (consécutivité quotidienne, animation de feu) |
|
||||
| ▸ Notifications de rappel configurables |
|
||||
| ▸ Onboarding 6 écrans avec mini-démo live |
|
||||
| ▸ Paywall \+ RevenueCat (essai 7j, mensuel, annuel) |
|
||||
| ▸ Dark mode \+ Light mode |
|
||||
| ▸ Localisation : EN, FR, ES, DE, PT |
|
||||
|
||||
### **Should Have (V1.1 — Semaine 4-8)**
|
||||
|
||||
| 🟡 SHOULD HAVE — Valeur ajoutée forte |
|
||||
| :---- |
|
||||
| ▸ Widget iOS (Home Screen) — affiche le streak et le dernier entraînement |
|
||||
| ▸ Widget Android (Glance API) |
|
||||
| ▸ Bibliothèque de programmes pré-définis (Débutant / Cardio / Force) |
|
||||
| ▸ Statistiques hebdomadaires et mensuelles avec graphiques |
|
||||
| ▸ Sons personnalisés (voix, bip, musique d'ambiance) |
|
||||
| ▸ Integration Apple Health / Google Fit (calories, activité) |
|
||||
| ▸ Partage social (carte récapitulative de la séance) |
|
||||
|
||||
### **Could Have (V2 — Mois 3+)**
|
||||
|
||||
| 🔵 COULD HAVE — Différenciation long terme |
|
||||
| :---- |
|
||||
| ▸ Mode Coach IA : suggestions de séances basées sur l'historique |
|
||||
| ▸ Challenges communautaires (leaderboard hebdomadaire) |
|
||||
| ▸ Apple Watch companion app |
|
||||
| ▸ Import/Export de programmes (partage entre utilisateurs) |
|
||||
| ▸ Mode TV / AirPlay pour entraînement sur grand écran |
|
||||
|
||||
# **7\. Structure de Navigation (Expo Router)**
|
||||
|
||||
Architecture file-based avec Expo Router v3 :
|
||||
|
||||
| Route | Écran | Accès |
|
||||
| :---- | :---- | :---- |
|
||||
| / | Home — Hub central avec raccourcis et streak | Tous |
|
||||
| /onboarding | Séquence onboarding 6 étapes | Nouveaux utilisateurs |
|
||||
| /timer | Minuterie plein écran — séance active | Tous |
|
||||
| /programs | Bibliothèque de programmes | Premium |
|
||||
| /history | Historique des séances \+ stats | Tous (limité free) |
|
||||
| /settings | Préférences, sons, notifications, compte | Tous |
|
||||
| /paywall | Écran d'abonnement RevenueCat | Free users |
|
||||
| /(modals)/review | In-App Review prompt | Streak 7j |
|
||||
|
||||
# **8\. Paiements, Pricing & RevenueCat**
|
||||
|
||||
## **8.1 Structure des Offres**
|
||||
|
||||
| Offre | Prix US | Prix FR | Prix BR | Contenu |
|
||||
| :---- | :---- | :---- | :---- | :---- |
|
||||
| Gratuit | Free | Free | Free | Timer standard, 7j historique, pas de widget |
|
||||
| Essai Premium | 7 jours gratuits | 7 jours gratuits | 7 jours gratuits | Accès complet, pas de CB requise si possible |
|
||||
| Premium Mensuel | $4.99/mois | €4.99/mois | R$9.99/mois | Accès complet illimité |
|
||||
| Premium Annuel | $29.99/an | €27.99/an | R$59.99/an | Tout Premium — économie 50% mise en avant |
|
||||
|
||||
Note : Pricing localisé via RevenueCat Purchasing Power Parity (PPP). Adapter automatiquement les prix pour le Brésil, l'Inde, l'Indonésie et l'Europe de l'Est pour maximiser les conversions mondiales.
|
||||
|
||||
## **8.2 Configuration RevenueCat**
|
||||
|
||||
* Entitlements : "premium" (accès à toutes les features payantes)
|
||||
|
||||
* Products : tabatago\_monthly, tabatago\_annual, tabatago\_trial\_7d
|
||||
|
||||
* A/B Tests dès J+7 : tester $3.99 vs $4.99 vs $6.99 mensuel
|
||||
|
||||
* A/B Tests paywall : liste vs comparaison de plans vs "best value" badge
|
||||
|
||||
* Webhook RevenueCat → PostHog pour corréler revenus et comportement
|
||||
|
||||
# **9\. Analytics & Instrumentation (PostHog)**
|
||||
|
||||
## **9.1 Événements Critiques à Tracker**
|
||||
|
||||
| Événement | Propriétés | Objectif |
|
||||
| :---- | :---- | :---- |
|
||||
| onboarding\_step\_viewed | step\_number, step\_name | Identifier le drop-off |
|
||||
| onboarding\_demo\_started | duration\_seconds | Mesurer l'engagement Wow |
|
||||
| onboarding\_completed | persona, goal, frequency | Segmentation |
|
||||
| paywall\_viewed | source, variant | Funnel paiement |
|
||||
| trial\_started | plan\_selected | Conversion essai |
|
||||
| subscription\_purchased | plan, price, currency | Revenue |
|
||||
| session\_started | program\_id, rounds, duration | Engagement produit |
|
||||
| session\_completed | rounds\_done, streak\_day | Rétention |
|
||||
| streak\_milestone | days\_count | Gamification KPIs |
|
||||
| widget\_added | widget\_type | Stickiness |
|
||||
| notification\_tapped | notification\_type | Rappels efficacité |
|
||||
|
||||
## **9.2 Funnels à Monitorer**
|
||||
|
||||
* Funnel Acquisition : Impression App Store → Téléchargement → Onboarding Start → Onboarding Complete → Paywall View → Trial Start → Purchase
|
||||
|
||||
* Funnel Rétention : J+1 / J+7 / J+30 ouverture de l'app après téléchargement
|
||||
|
||||
* Funnel Engagement : Séance démarrée → Séance complétée → Streak maintenu
|
||||
|
||||
Règle d'or : Si impressions mais pas de téléchargements → Réparer screenshots/icône. Si téléchargements mais pas d'essais → Réparer onboarding. Si essais mais pas de paiements → Réparer paywall/pricing.
|
||||
|
||||
# **10\. Localisation & Growth Hacking**
|
||||
|
||||
## **10.1 Stratégie de Localisation (5 Langues)**
|
||||
|
||||
| Langue | Marché cible | Mots-clés spécifiques | Priorité |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| Anglais (EN) | US, UK, AU, CA | tabata timer, hiit timer app | 1 — Lancement |
|
||||
| Français (FR) | France, Belgique, Québec | minuterie tabata, chrono hiit | 1 — Lancement |
|
||||
| Espagnol (ES) | Espagne, Mexique, Argentine | temporizador tabata, ejercicio hiit | 2 — Semaine 2 |
|
||||
| Allemand (DE) | Allemagne, Autriche, CH | tabata timer app, intervall training | 2 — Semaine 2 |
|
||||
| Portugais (PT-BR) | Brésil | timer tabata, treino hiit | 3 — Mois 2 |
|
||||
|
||||
## **10.2 Éléments à Localiser**
|
||||
|
||||
* App content : tous les textes UI, onboarding, notifications, erreurs
|
||||
|
||||
* App Store listing : titre, sous-titre, description courte, description longue
|
||||
|
||||
* Screenshots : localisés avec texte natif (utiliser Fastlane Frameit)
|
||||
|
||||
* Prix : PPP via RevenueCat (automatique par pays)
|
||||
|
||||
* Mots-clés App Store : 100 caractères spécifiques par marché
|
||||
|
||||
Outil recommandé : i18next \+ expo-localization pour la gestion des traductions en développement.
|
||||
|
||||
# **11\. App Store Optimization (ASO)**
|
||||
|
||||
## **11.1 Fiche App Store (iOS — EN)**
|
||||
|
||||
| Champ | Valeur optimisée |
|
||||
| :---- | :---- |
|
||||
| Nom de l'app (30 car.) | TabataGo — HIIT Timer |
|
||||
| Sous-titre (30 car.) | Tabata & Interval Workout |
|
||||
| Mots-clés (100 car.) | tabata,hiit,timer,interval,workout,fitness,training,countdown,sport,exercise |
|
||||
| Description (1ère ligne) | The most motivating Tabata timer. 20 seconds of effort. Life-changing results. |
|
||||
| Screenshots (10 max) | Voir section 10.2 |
|
||||
| Preview video | 15-30s montrant la minuterie en action \+ streak \+ widget |
|
||||
|
||||
## **11.2 Plan des Screenshots (6 obligatoires)**
|
||||
|
||||
| \# | Contenu | Message clé | Background |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| 1 | Timer plein écran en action (20s) | "Train smarter, not longer" | Dark \+ orange |
|
||||
| 2 | Écran d'onboarding moment Wow | "Your first Tabata in 20 seconds" | Gradient sombre |
|
||||
| 3 | Widget iOS home screen | "Train from anywhere — even your lock screen" | iPhone mockup |
|
||||
| 4 | Historique \+ Streak en feu | "Build the habit. Keep the streak." | Dark \+ vert |
|
||||
| 5 | Bibliothèque de programmes | "100+ ready-to-go programs" | Gradient |
|
||||
| 6 | Statistiques mensuelles | "See your progress. Stay motivated." | Fond clair |
|
||||
|
||||
# **12\. Plan de Lancement en 72h**
|
||||
|
||||
## **12.1 Timeline de Développement (72 heures)**
|
||||
|
||||
| Phase | Durée | Livrables | Responsable |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| Setup & Architecture | H0 → H8 | Expo project init, navigation, design system, RevenueCat config | Dev Lead |
|
||||
| Onboarding | H8 → H20 | 6 écrans onboarding \+ mini-timer démo \+ animation | Dev \+ Design |
|
||||
| Core Timer | H20 → H36 | Minuterie plein écran, son, haptiques, background timer | Dev |
|
||||
| Streak \+ Historique | H36 → H48 | Système streak, storage, affichage historique, notifications | Dev |
|
||||
| Paywall \+ Analytics | H48 → H58 | RevenueCat paywall, PostHog events, A/B test config | Dev |
|
||||
| Polish & Testing | H58 → H68 | Bug fixes, dark/light mode, performance, edge cases | Dev \+ QA |
|
||||
| Submission | H68 → H72 | Screenshots, App Store listing (5 langues), soumission Apple | PM \+ Dev |
|
||||
|
||||
## **12.2 Stratégie de Validation Post-Lancement**
|
||||
|
||||
Profiter du boost de visibilité des 3 premiers jours accordé par l'App Store aux nouvelles applications :
|
||||
|
||||
1. Jour 1-3 : Monitoring organique — Analyser impressions, téléchargements, et premiers essais
|
||||
|
||||
2. Jour 4-7 : Analyse funnel — Identifier le premier point de friction (onboarding? paywall?)
|
||||
|
||||
3. Jour 8-14 : Première itération — Corriger le problème prioritaire et soumettre mise à jour OTA (Expo)
|
||||
|
||||
4. Jour 15-30 : Activation publicité — Apple Search Ads seulement si CVR organique \> 15%
|
||||
|
||||
5. Jour 30+ : Scale — Augmenter budget pub sur les marchés qui convertissent le mieux
|
||||
|
||||
Règle absolue : Ne jamais dépenser en publicité avant d'avoir validé que le funnel organique convertit.
|
||||
|
||||
# **13\. KPIs & Métriques de Succès**
|
||||
|
||||
| Métrique | Semaine 1 | Mois 1 | Mois 3 | Outil de mesure |
|
||||
| :---- | :---- | :---- | :---- | :---- |
|
||||
| Téléchargements | 500+ | 5 000+ | 20 000+ | App Store Connect |
|
||||
| Taux conversion Impression→DL | \> 5% | \> 8% | \> 10% | App Store Connect |
|
||||
| Taux essai démarré | \> 25% | \> 30% | \> 35% | RevenueCat \+ PostHog |
|
||||
| Taux conversion essai→payant | \> 30% | \> 35% | \> 40% | RevenueCat |
|
||||
| Rétention J+7 | \> 30% | \> 35% | \> 40% | PostHog |
|
||||
| Rétention J+30 | — | \> 20% | \> 25% | PostHog |
|
||||
| MRR | — | $2 000+ | $8 000+ | RevenueCat |
|
||||
| Note App Store | — | ≥ 4.5 | ≥ 4.6 | App Store Connect |
|
||||
| Streak moyen (actifs) | — | \> 5 jours | \> 10 jours | PostHog custom |
|
||||
|
||||
# **14\. Risques & Mitigations**
|
||||
|
||||
| Risque | Probabilité | Impact | Mitigation |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| Refus App Store (guidelines) | Moyen | Élevé | Respecter HIG Apple, tester paywall sur TestFlight, pas de dark patterns |
|
||||
| Mauvais taux de conversion paywall | Moyen | Élevé | A/B test RevenueCat dès J+7, 3 variantes de prix/layout |
|
||||
| Précision du timer en background | Faible | Élevé | expo-background-fetch \+ notification locale comme fallback |
|
||||
| Faible rétention sans streaks | Moyen | Moyen | Streak J+1 core feature, reminder push personnalisé par heure d'entraînement |
|
||||
| Concurrence sur mots-clés EN | Élevé | Moyen | Agressivité sur marchés FR/ES/DE dès J+14 |
|
||||
| Revue Apple lente (\> 48h) | Faible | Faible | Soumettre J-5 avant objectif lancement, utiliser Expo OTA pour hotfixes |
|
||||
|
||||
# **15\. Approbations & Versions**
|
||||
|
||||
| Rôle | Nom | Date | Signature |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| Product Owner | | Février 2026 | |
|
||||
| Tech Lead | | Février 2026 | |
|
||||
| Design Lead | | Février 2026 | |
|
||||
| Marketing | | Février 2026 | |
|
||||
|
||||
*Document créé le 15 février 2026 — TabataGo PRD v1.1*
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,369 +0,0 @@
|
||||
# Skill — Audio Engine (useAudioEngine)
|
||||
> Lis ce skill AVANT d'implémenter quoi que ce soit lié à l'audio.
|
||||
|
||||
## Responsabilité de ce module
|
||||
L'audio engine gère 4 couches sonores indépendantes qui s'activent
|
||||
en réponse aux événements du timer. Il ne connaît pas le timer
|
||||
directement — il s'abonne à ses événements via useTimerSync.
|
||||
|
||||
**Objectif principal : zéro latence audio sur les transitions de phase.**
|
||||
Les sons de phase doivent jouer dans les 50ms suivant l'événement.
|
||||
|
||||
---
|
||||
|
||||
## Architecture du module audio
|
||||
|
||||
```
|
||||
src/features/audio/
|
||||
types.ts
|
||||
hooks/
|
||||
useAudioEngine.ts ← moteur central expo-av
|
||||
useAudioSettings.ts ← préférences utilisateur persistées
|
||||
data/
|
||||
tracks.ts ← catalogue musique (offline, paths locaux)
|
||||
sounds.ts ← catalogue signaux de phase
|
||||
components/
|
||||
MusicPicker.tsx ← sélecteur d'ambiance musicale
|
||||
SoundPicker.tsx ← sélecteur de signal de phase
|
||||
AudioSettingsPanel.tsx ← panneau complet des réglages
|
||||
index.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Types
|
||||
|
||||
```typescript
|
||||
// src/features/audio/types.ts
|
||||
|
||||
export type MusicAmbiance = 'ELECTRO' | 'HIP_HOP' | 'ROCK' | 'SILENCE'
|
||||
export type MusicIntensity = 'LOW' | 'MEDIUM' | 'HIGH'
|
||||
export type PhaseSound = 'beep' | 'whistle' | 'voice_go' | 'air_horn' | 'bell' | 'silence'
|
||||
|
||||
export interface AudioTrack {
|
||||
id: string
|
||||
ambiance: MusicAmbiance
|
||||
intensity: MusicIntensity
|
||||
asset: number // require('../assets/audio/...') — offline
|
||||
bpm: number
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
export interface AudioSettings {
|
||||
musicEnabled: boolean
|
||||
ambiance: MusicAmbiance
|
||||
musicVolume: number // 0.0 → 1.0
|
||||
soundsEnabled: boolean
|
||||
soundsVolume: number // 0.0 → 1.0
|
||||
hapticsEnabled: boolean
|
||||
voiceEnabled: boolean // annonces vocales
|
||||
overrideMuteSwitch: boolean // iOS : jouer même en mode silencieux
|
||||
workPhaseSound: PhaseSound
|
||||
restPhaseSound: PhaseSound
|
||||
countdownSound: PhaseSound
|
||||
completionSound: PhaseSound
|
||||
}
|
||||
|
||||
export interface AudioState {
|
||||
isLoaded: boolean
|
||||
isMusicPlaying: boolean
|
||||
currentAmbiance: MusicAmbiance
|
||||
currentIntensity: MusicIntensity
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface AudioActions {
|
||||
preloadAll: () => Promise<void>
|
||||
startMusic: (ambiance: MusicAmbiance, intensity: MusicIntensity) => Promise<void>
|
||||
switchIntensity: (intensity: MusicIntensity, fadeMs?: number) => Promise<void>
|
||||
stopMusic: (fadeMs?: number) => Promise<void>
|
||||
playPhaseSound: (sound: PhaseSound) => Promise<void>
|
||||
playCountdown: (seconds: number) => Promise<void> // 3, 2, 1
|
||||
unloadAll: () => Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implémentation — useAudioEngine
|
||||
|
||||
### Configuration de la session audio iOS/Android
|
||||
|
||||
**Critique à faire en premier, avant tout chargement de son.**
|
||||
|
||||
```typescript
|
||||
import { Audio } from 'expo-av'
|
||||
|
||||
// À appeler une seule fois au démarrage de l'app (dans _layout.tsx)
|
||||
export async function configureAudioSession(): Promise<void> {
|
||||
await Audio.setAudioModeAsync({
|
||||
// iOS : jouer par-dessus la musique de l'utilisateur
|
||||
playsInSilentModeIOS: true, // ignore le switch mute (si overrideMuteSwitch)
|
||||
allowsRecordingIOS: false,
|
||||
staysActiveInBackground: true, // CRITIQUE — timer continue en background
|
||||
|
||||
// Android
|
||||
shouldDuckAndroid: true, // baisser le volume autres apps sur nos signaux
|
||||
playThroughEarpieceAndroid: false,
|
||||
interruptionModeIOS: 1, // DO_NOT_MIX = 1 (nos sons ont priorité sur signaux)
|
||||
interruptionModeAndroid: 1,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Preloading — charger AVANT la séance
|
||||
|
||||
Tous les sons doivent être chargés en mémoire AVANT que la séance commence.
|
||||
Si on charge pendant la séance → latence inacceptable.
|
||||
|
||||
```typescript
|
||||
const soundRefs = useRef<Record<string, Audio.Sound>>({})
|
||||
|
||||
async function preloadAll(settings: AudioSettings): Promise<void> {
|
||||
// 1. Charger la track musicale correspondante à l'ambiance + les 2 intensités
|
||||
const trackWork = getTrack(settings.ambiance, 'HIGH')
|
||||
const trackRest = getTrack(settings.ambiance, 'LOW')
|
||||
|
||||
soundRefs.current['music_work'] = await loadSound(trackWork.asset)
|
||||
soundRefs.current['music_rest'] = await loadSound(trackRest.asset)
|
||||
|
||||
// 2. Charger tous les signaux de phase
|
||||
const soundAssets = getSoundAssets(settings)
|
||||
for (const [key, asset] of Object.entries(soundAssets)) {
|
||||
soundRefs.current[key] = await loadSound(asset)
|
||||
}
|
||||
|
||||
// 3. Mettre en loop les tracks musicales
|
||||
await soundRefs.current['music_work'].setIsLoopingAsync(true)
|
||||
await soundRefs.current['music_rest'].setIsLoopingAsync(true)
|
||||
}
|
||||
|
||||
async function loadSound(asset: number): Promise<Audio.Sound> {
|
||||
const { sound } = await Audio.Sound.createAsync(asset, {
|
||||
shouldPlay: false,
|
||||
volume: 1.0,
|
||||
})
|
||||
return sound
|
||||
}
|
||||
```
|
||||
|
||||
### Crossfade entre phases
|
||||
|
||||
```typescript
|
||||
async function switchIntensity(
|
||||
to: MusicIntensity,
|
||||
fadeMs: number = 500
|
||||
): Promise<void> {
|
||||
const from = currentIntensityRef.current
|
||||
if (from === to) return
|
||||
|
||||
const outKey = from === 'HIGH' ? 'music_work' : 'music_rest'
|
||||
const inKey = to === 'HIGH' ? 'music_work' : 'music_rest'
|
||||
|
||||
const outSound = soundRefs.current[outKey]
|
||||
const inSound = soundRefs.current[inKey]
|
||||
|
||||
if (!outSound || !inSound) return
|
||||
|
||||
// Démarrer la track entrante depuis le début (ou depuis la position actuelle ?)
|
||||
await inSound.setPositionAsync(0)
|
||||
await inSound.setVolumeAsync(0)
|
||||
await inSound.playAsync()
|
||||
|
||||
// Fade simultané
|
||||
const steps = 10
|
||||
const stepMs = fadeMs / steps
|
||||
const volumeStep = settings.musicVolume / steps
|
||||
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
await Promise.all([
|
||||
outSound.setVolumeAsync(Math.max(0, settings.musicVolume - i * volumeStep)),
|
||||
inSound.setVolumeAsync(Math.min(settings.musicVolume, i * volumeStep)),
|
||||
])
|
||||
await delay(stepMs)
|
||||
}
|
||||
|
||||
await outSound.stopAsync()
|
||||
currentIntensityRef.current = to
|
||||
}
|
||||
|
||||
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
||||
```
|
||||
|
||||
### Répondre aux événements timer
|
||||
|
||||
```typescript
|
||||
// Dans useTimerSync — pont entre timer et audio
|
||||
|
||||
import { useTimerEngine } from '../timer'
|
||||
import { useAudioEngine } from '../audio'
|
||||
|
||||
export function useTimerSync() {
|
||||
const timer = useTimerEngine()
|
||||
const audio = useAudioEngine()
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = timer.addEventListener((event) => {
|
||||
switch (event.type) {
|
||||
case 'PHASE_CHANGED':
|
||||
handlePhaseChange(event.from, event.to)
|
||||
break
|
||||
case 'COUNTDOWN_TICK':
|
||||
audio.playCountdown(event.secondsLeft)
|
||||
break
|
||||
case 'SESSION_COMPLETE':
|
||||
audio.playPhaseSound('completion')
|
||||
audio.stopMusic(1000)
|
||||
break
|
||||
}
|
||||
})
|
||||
return unsubscribe
|
||||
}, [])
|
||||
|
||||
async function handlePhaseChange(from: TimerPhase, to: TimerPhase) {
|
||||
switch (to) {
|
||||
case 'GET_READY':
|
||||
await audio.startMusic(settings.ambiance, 'MEDIUM')
|
||||
break
|
||||
case 'WORK':
|
||||
await audio.playPhaseSound(settings.workPhaseSound)
|
||||
await audio.switchIntensity('HIGH', 500)
|
||||
break
|
||||
case 'REST':
|
||||
await audio.playPhaseSound(settings.restPhaseSound)
|
||||
await audio.switchIntensity('LOW', 500)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Catalogue des tracks (offline)
|
||||
|
||||
```typescript
|
||||
// src/features/audio/data/tracks.ts
|
||||
import { MusicAmbiance, MusicIntensity, AudioTrack } from '../types'
|
||||
|
||||
export const TRACKS: AudioTrack[] = [
|
||||
{
|
||||
id: 'electro_high',
|
||||
ambiance: 'ELECTRO',
|
||||
intensity: 'HIGH',
|
||||
asset: require('../../../assets/audio/music/electro_high.mp3'),
|
||||
bpm: 140,
|
||||
durationMs: 180000,
|
||||
},
|
||||
{
|
||||
id: 'electro_low',
|
||||
ambiance: 'ELECTRO',
|
||||
intensity: 'LOW',
|
||||
asset: require('../../../assets/audio/music/electro_low.mp3'),
|
||||
bpm: 90,
|
||||
durationMs: 180000,
|
||||
},
|
||||
// ... (9 tracks au total)
|
||||
]
|
||||
|
||||
export function getTrack(ambiance: MusicAmbiance, intensity: MusicIntensity): AudioTrack {
|
||||
const track = TRACKS.find(t => t.ambiance === ambiance && t.intensity === intensity)
|
||||
if (!track) throw new Error(`Track non trouvée : ${ambiance} ${intensity}`)
|
||||
return track
|
||||
}
|
||||
```
|
||||
|
||||
## Catalogue des sons de phase
|
||||
|
||||
```typescript
|
||||
// src/features/audio/data/sounds.ts
|
||||
export const PHASE_SOUNDS = {
|
||||
beep: require('../../../assets/audio/sounds/beep_long.mp3'),
|
||||
whistle: require('../../../assets/audio/sounds/whistle.mp3'),
|
||||
voice_go: require('../../../assets/audio/sounds/voice_go.mp3'),
|
||||
air_horn: require('../../../assets/audio/sounds/air_horn.mp3'),
|
||||
bell: require('../../../assets/audio/sounds/bell.mp3'),
|
||||
double_beep: require('../../../assets/audio/sounds/beep_double.mp3'),
|
||||
countdown_3: require('../../../assets/audio/sounds/count_3.mp3'),
|
||||
countdown_2: require('../../../assets/audio/sounds/count_2.mp3'),
|
||||
countdown_1: require('../../../assets/audio/sounds/count_1.mp3'),
|
||||
fanfare: require('../../../assets/audio/sounds/fanfare.mp3'),
|
||||
clap: require('../../../assets/audio/sounds/applause.mp3'),
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Gestion du switch silencieux iOS
|
||||
|
||||
```typescript
|
||||
// Si overrideMuteSwitch = true dans les settings
|
||||
await Audio.setAudioModeAsync({
|
||||
playsInSilentModeIOS: settings.overrideMuteSwitch,
|
||||
})
|
||||
```
|
||||
|
||||
**Règle UX :** Les signaux de phase ont l'override activé par défaut
|
||||
(l'utilisateur a besoin de savoir quand changer d'exercice même en mode silencieux).
|
||||
La musique d'ambiance respecte le switch silencieux par défaut.
|
||||
|
||||
---
|
||||
|
||||
## Cleanup obligatoire
|
||||
|
||||
```typescript
|
||||
// Dans useAudioEngine — cleanup au unmount ou fin de séance
|
||||
async function unloadAll(): Promise<void> {
|
||||
await Promise.all(
|
||||
Object.values(soundRefs.current).map(sound => sound.unloadAsync())
|
||||
)
|
||||
soundRefs.current = {}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Cleanup synchrone au unmount
|
||||
Object.values(soundRefs.current).forEach(sound => {
|
||||
sound.unloadAsync().catch(console.error)
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tests minimaux
|
||||
|
||||
```typescript
|
||||
describe('useAudioEngine', () => {
|
||||
it('configure la session audio au mount', () => {})
|
||||
it('preloadAll charge les sons sans erreur', () => {})
|
||||
it('playPhaseSound joue le bon son dans les 50ms', () => {})
|
||||
it('switchIntensity effectue un crossfade', () => {})
|
||||
it('stopMusic fait un fade-out', () => {})
|
||||
it('unloadAll libère toutes les ressources', () => {})
|
||||
it('ne plante pas si un asset est manquant (fallback)', () => {})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Erreurs classiques à éviter
|
||||
|
||||
```typescript
|
||||
// ❌ Charger les sons à la demande → latence
|
||||
case 'PHASE_CHANGED':
|
||||
const { sound } = await Audio.Sound.createAsync(asset) // ← trop lent !
|
||||
await sound.playAsync()
|
||||
|
||||
// ✅ Utiliser les sons préchargés
|
||||
case 'PHASE_CHANGED':
|
||||
await soundRefs.current['work_start'].replayAsync() // ← instantané
|
||||
|
||||
// ❌ Oublier de configurer la session audio → muet sur iOS background
|
||||
await sound.playAsync() // sans Audio.setAudioModeAsync() → silencieux en background
|
||||
|
||||
// ❌ Pas de gestion du duck Android → nos sons se mélangent avec d'autres apps
|
||||
interruptionModeAndroid: 2 // DUCK_OTHERS — OK pour la musique d'ambiance
|
||||
interruptionModeAndroid: 1 // DO_NOT_MIX — pour les signaux de phase
|
||||
```
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/building-native-ui
|
||||
@@ -1,576 +0,0 @@
|
||||
---
|
||||
name: tabata-kine-design-system
|
||||
description: >
|
||||
Design system complet pour l'application Tabata Kiné. Utilise ce skill pour
|
||||
toute tâche liée au design, aux composants UI, aux écrans, aux couleurs, à la
|
||||
typographie ou aux décisions d'interface de l'app Tabata Kiné. Déclenche ce
|
||||
skill dès que l'utilisateur mentionne : un écran de l'app (onboarding, séance,
|
||||
dashboard, paywall, programmes), un composant (bouton, carte, timer, badge,
|
||||
input), une couleur, une typographie, une animation, un espacement, ou demande
|
||||
à coder un élément UI. Ce skill contient les règles non négociables du design
|
||||
"Dark Medical" — le style qui différencie l'app de tous les concurrents fitness.
|
||||
---
|
||||
|
||||
# Design System — Tabata Kiné
|
||||
|
||||
## Principe directeur : Dark Medical
|
||||
|
||||
L'app Tabata Kiné n'est **pas** une app fitness classique. C'est une app médicale
|
||||
qui utilise le format tabata. Le style "Dark Medical" traduit visuellement ce
|
||||
positionnement : fond sombre professionnel, vert santé comme seule couleur d'action,
|
||||
expertise kiné visible à chaque écran.
|
||||
|
||||
**Règles absolues :**
|
||||
- Pas de mode clair. Dark only, sans exception.
|
||||
- Le vert (#00C896) ne sert qu'aux actions et à la validation.
|
||||
- L'orange (#FF8A5C) ne sert qu'aux conseils kiné et alertes positives.
|
||||
- Le rouge (#FF4444) est réservé au timer en phase d'urgence (<10s).
|
||||
- Touch target minimum : 44×44px pour tous les éléments interactifs.
|
||||
|
||||
---
|
||||
|
||||
## 1. Tokens de couleur
|
||||
|
||||
### Fonds — Navy
|
||||
|
||||
| Token | Valeur | Usage |
|
||||
|-------|--------|-------|
|
||||
| `navy-900` | `#0D1B2A` | Fond principal de l'app |
|
||||
| `navy-800` | `#112240` | Surface 1 — cartes par défaut |
|
||||
| `navy-700` | `#1A3050` | Surface 2 — cartes surélevées |
|
||||
| `navy-600` | `#243C5E` | Bordures actives |
|
||||
|
||||
### Vert Kiné — action & santé
|
||||
|
||||
| Token | Valeur | Usage |
|
||||
|-------|--------|-------|
|
||||
| `green-500` | `#00C896` | CTA principal, timer effort, progress |
|
||||
| `green-600` | `#00A67C` | État hover / pressed |
|
||||
| `green-700` | `#00875F` | État active deep |
|
||||
| `green-dim` | `rgba(0,200,150,0.12)` | Fond badge, chip, card accent |
|
||||
| `green-border` | `rgba(0,200,150,0.35)` | Bordure card accent |
|
||||
|
||||
### Texte & bordures
|
||||
|
||||
| Token | Valeur | Usage |
|
||||
|-------|--------|-------|
|
||||
| `white-100` | `#E6F1FF` | Texte primaire |
|
||||
| `slate-300` | `#A8B2D8` | Texte secondaire |
|
||||
| `slate-400` | `#8892B0` | Texte tertiaire, placeholders |
|
||||
| `border-dim` | `rgba(168,178,216,0.15)` | Bordure par défaut |
|
||||
| `border-hover` | `rgba(168,178,216,0.25)` | Bordure hover |
|
||||
|
||||
### Orange — conseils kiné uniquement
|
||||
|
||||
| Token | Valeur | Usage |
|
||||
|-------|--------|-------|
|
||||
| `orange-500` | `#FF8A5C` | Tip card border, badge Kiné+ |
|
||||
| `orange-600` | `#E06A3C` | Hover orange |
|
||||
| `orange-dim` | `rgba(255,138,92,0.12)` | Fond tip card |
|
||||
|
||||
### Sémantique
|
||||
|
||||
| Token | Valeur | Usage |
|
||||
|-------|--------|-------|
|
||||
| `red-500` | `#FF4444` | Timer urgence <10s UNIQUEMENT |
|
||||
|
||||
---
|
||||
|
||||
## 2. Typographie
|
||||
|
||||
### Familles
|
||||
|
||||
| Rôle | Famille | Notes |
|
||||
|------|---------|-------|
|
||||
| Titres émotionnels | Serif italique (ex: DM Serif Display, Georgia) | Célébration, fin de séance, accroches |
|
||||
| Interface & corps | Sans-serif géométrique (ex: Outfit, DM Sans) | Navigation, descriptions, labels |
|
||||
| Données & timer | Monospace (ex: DM Mono, JetBrains Mono) | Timer, stats, codes, metadata |
|
||||
|
||||
### Échelle
|
||||
|
||||
| Style | Famille | Taille | Poids | Usage |
|
||||
|-------|---------|--------|-------|-------|
|
||||
| `display` | Serif italic | 28–32px | 400 | Fin de séance, titres forts |
|
||||
| `heading-1` | Serif | 22–24px | 500 | Titres de section |
|
||||
| `heading-2` | Sans | 18px | 500 | Titre exercice, carte programme |
|
||||
| `body` | Sans | 15–16px | 400 | Corps, conseil kiné |
|
||||
| `label` | Mono | 11–13px | 500 | Tags, metadata, uppercase tracking |
|
||||
| `timer` | Mono | **80–100px** | 500 | Timer séance — lisible à 2 mètres |
|
||||
| `caption` | Sans | 12px | 400 | Sous-labels, hints |
|
||||
|
||||
**Règle typographie :** La taille du timer est la décision de design la plus
|
||||
importante de l'écran séance. Tout se dimensionne autour de lui.
|
||||
|
||||
---
|
||||
|
||||
## 3. Espacement
|
||||
|
||||
Base : **4px**
|
||||
|
||||
| Token | Valeur | Usage |
|
||||
|-------|--------|-------|
|
||||
| `space-1` | 4px | Gap minimal entre éléments liés |
|
||||
| `space-2` | 8px | Gap interne composant |
|
||||
| `space-3` | 12px | Gap entre composants proches |
|
||||
| `space-4` | 16px | Padding carte, gap standard |
|
||||
| `space-6` | 24px | Espacement sections |
|
||||
| `space-8` | 32px | Padding écran horizontal |
|
||||
| `space-12` | 48px | Espacement majeur |
|
||||
| `space-16` | 64px | Espacement entre blocs screens |
|
||||
|
||||
---
|
||||
|
||||
## 4. Border Radius
|
||||
|
||||
| Token | Valeur | Usage |
|
||||
|-------|--------|-------|
|
||||
| `radius-sm` | 4px | Badge, chip, tag |
|
||||
| `radius-md` | 8px | Bouton, input, tip card |
|
||||
| `radius-lg` | 12px | Carte programme standard |
|
||||
| `radius-xl` | 16px | Carte large, modal |
|
||||
| `radius-pill` | 9999px | Pill, toggle, progress bar |
|
||||
| `radius-circle` | 50% | Icon button, avatar, streak dot |
|
||||
|
||||
---
|
||||
|
||||
## 5. Système d'élévation (surfaces)
|
||||
|
||||
```
|
||||
Fond (navy-900)
|
||||
└── Surface 1 (navy-800) — cartes par défaut
|
||||
└── Surface 2 (navy-700) — cartes surélevées / hover
|
||||
└── Surface active (navy-800 + border green-500 1.5px)
|
||||
```
|
||||
|
||||
Différencier les surfaces **uniquement par la couleur de fond**, jamais par des
|
||||
ombres portées (box-shadow : non). La bordure active verte est le seul signal
|
||||
d'état sélectionné.
|
||||
|
||||
---
|
||||
|
||||
## 6. Composants
|
||||
|
||||
### Boutons
|
||||
|
||||
```
|
||||
PrimaryButton
|
||||
background: green-500
|
||||
color: navy-900
|
||||
padding: 14px 24px
|
||||
height: 52–56px
|
||||
border-radius: radius-md
|
||||
font: sans 15px 500
|
||||
width: 100% (full-width dans les screens)
|
||||
hover: background green-600
|
||||
active: background green-700 + scale(0.98)
|
||||
|
||||
SecondaryButton
|
||||
background: transparent
|
||||
color: green-500
|
||||
border: 1.5px solid green-500
|
||||
padding: 13px 24px
|
||||
hover: background green-dim
|
||||
|
||||
GhostButton
|
||||
background: transparent
|
||||
color: slate-300
|
||||
no border
|
||||
usage: actions secondaires (Passer, Annuler)
|
||||
|
||||
DangerButton
|
||||
background: rgba(255,68,68,0.12)
|
||||
color: #FF6B6B
|
||||
border: 1px solid rgba(255,68,68,0.3)
|
||||
usage: Quitter la séance UNIQUEMENT
|
||||
|
||||
IconButton
|
||||
width: 44px
|
||||
height: 44px
|
||||
border-radius: 50%
|
||||
background: rgba(168,178,216,0.10)
|
||||
color: slate-300
|
||||
JAMAIS en dessous de 44×44px (accessibilité)
|
||||
```
|
||||
|
||||
### Inputs
|
||||
|
||||
```
|
||||
TextField
|
||||
background: navy-800
|
||||
border: 1px solid border-dim
|
||||
border-radius: radius-md
|
||||
padding: 12px 16px
|
||||
color: white-100
|
||||
font: sans 15px 400
|
||||
focus: border green-500
|
||||
error: border red-500
|
||||
height: 48px
|
||||
```
|
||||
|
||||
### Badges & Pills
|
||||
|
||||
```
|
||||
Badge (tier)
|
||||
font: mono 11px 500
|
||||
padding: 3px 10px
|
||||
border-radius: radius-sm
|
||||
UPPERCASE + letter-spacing: 0.08em
|
||||
|
||||
.free: background green-dim, color green-500
|
||||
.premium: background orange-dim, color orange-500
|
||||
.kine: background rgba(168,178,216,0.12), color slate-300
|
||||
|
||||
Pill (metadata)
|
||||
font: sans 12px 400
|
||||
padding: 4px 12px
|
||||
border-radius: radius-pill
|
||||
border: 1px solid (couleur correspondante à 0.3 opacity)
|
||||
```
|
||||
|
||||
### Cartes
|
||||
|
||||
```
|
||||
CardDefault
|
||||
background: navy-800
|
||||
border: 1px solid border-dim
|
||||
border-radius: radius-lg
|
||||
padding: 16px
|
||||
|
||||
CardAccent (CTA, prochaine séance)
|
||||
background: rgba(0,200,150,0.05)
|
||||
border: 1.5px solid green-border
|
||||
border-radius: radius-lg
|
||||
|
||||
CardTip (conseil kiné)
|
||||
background: orange-dim
|
||||
border-left: 3px solid orange-500
|
||||
border-radius: 0 radius-lg radius-lg 0
|
||||
NE PAS arrondir le côté gauche (border-left unique)
|
||||
Structure: icône 💡 + texte + signature "— Prénom, kiné"
|
||||
|
||||
CardProgram
|
||||
border-radius: radius-xl
|
||||
overflow: hidden
|
||||
Thumbnail: 120px height, gradient navy-700→navy-600
|
||||
Body: padding 14px
|
||||
Toujours afficher: progression bar + "X/12 séances"
|
||||
```
|
||||
|
||||
### Timer
|
||||
|
||||
```
|
||||
Timer (composant le plus critique de l'app)
|
||||
font: mono 80–100px 500
|
||||
text-align: center
|
||||
|
||||
État effort normal (>10s):
|
||||
color: green-500
|
||||
|
||||
État urgence (<10s):
|
||||
color: red-500
|
||||
animation: pulse subtil (scale 1→1.02→1, 1s infinite)
|
||||
|
||||
Label sous le chiffre:
|
||||
font: mono 14px 400
|
||||
color: slate-400
|
||||
letter-spacing: 0.1em
|
||||
text: "SECONDES"
|
||||
|
||||
Contexte repos:
|
||||
color: slate-300 (pas de vert, signal visuel de repos)
|
||||
```
|
||||
|
||||
### Progress Bar
|
||||
|
||||
```
|
||||
ProgressBar
|
||||
track: background rgba(168,178,216,0.12), height 4px, border-radius pill
|
||||
fill: background green-500, border-radius pill
|
||||
|
||||
Variante séance (épaisseur réduite):
|
||||
height: 3px
|
||||
|
||||
Variante programme:
|
||||
height: 4px
|
||||
Afficher le % à droite en mono 11px green-500
|
||||
|
||||
Animation: transition width 300ms ease
|
||||
```
|
||||
|
||||
### Feedback ressenti
|
||||
|
||||
```
|
||||
FeedbackButton
|
||||
width: flex (3 boutons égaux)
|
||||
height: 72px
|
||||
border-radius: radius-lg
|
||||
background: navy-800
|
||||
border: 1px solid border-dim
|
||||
flex-direction: column
|
||||
gap: 4px
|
||||
|
||||
Emoji: 28px
|
||||
Label: sans 12px slate-400
|
||||
|
||||
État sélectionné:
|
||||
border: 1.5px solid green-500
|
||||
background: green-dim
|
||||
```
|
||||
|
||||
### Streak hebdomadaire
|
||||
|
||||
```
|
||||
StreakDot
|
||||
width: 32px
|
||||
height: 32px
|
||||
border-radius: 50%
|
||||
|
||||
.done: background rgba(0,200,150,0.15) → afficher ✓
|
||||
.today: background green-500 → afficher ✓
|
||||
.empty: background rgba(168,178,216,0.06), border 1px border-dim
|
||||
|
||||
Label jour: mono 10px slate-400, centré sous chaque dot
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Écran séance — règles spéciales
|
||||
|
||||
L'écran séance est le plus critique de l'app. Il doit être utilisable **les mains
|
||||
sur les genoux, en sueur, à 2 mètres de l'écran**. Chaque décision de design doit
|
||||
passer ce test.
|
||||
|
||||
### Architecture visuelle
|
||||
|
||||
```
|
||||
[Vidéo plein écran en boucle — fond de tout l'écran]
|
||||
↓ Gradient top navy→transparent (40% opacité, 100px height)
|
||||
→ Contrôles pause/audio en overlay
|
||||
→ Indicateur exercice X/8 centré
|
||||
↓ Zone centrale nette (pas de gradient — l'utilisateur voit le mouvement)
|
||||
↓ Gradient bottom transparent→navy (70% opacité, 220px height)
|
||||
→ Timer géant centré
|
||||
→ Progress bar
|
||||
→ Tip card conseil kiné
|
||||
```
|
||||
|
||||
### Transitions séance
|
||||
|
||||
```
|
||||
Effort → Repos:
|
||||
Fond passe de vidéo plein écran → navy-800 uni
|
||||
Transition: fade 300ms
|
||||
Vibration haptique légère (si disponible)
|
||||
Le repos a une identité visuelle différente (pas de vidéo, couleur unie)
|
||||
|
||||
Repos → Effort:
|
||||
Countdown audio "3... 2... 1..."
|
||||
Vibration haptique + transition fade
|
||||
|
||||
Exercice suivant pendant le repos:
|
||||
Afficher un thumbnail 56×56px du prochain exercice
|
||||
Nom en sans 14px 500
|
||||
Label "PROCHAIN EXERCICE" en mono 11px slate-400
|
||||
```
|
||||
|
||||
### Phase repos
|
||||
|
||||
L'écran repos doit être **visuellement différent** de l'écran effort.
|
||||
|
||||
- Fond : `navy-800` uni (plus de vidéo plein écran)
|
||||
- Timer couleur : `slate-300` (pas de vert — c'est le repos)
|
||||
- Mot "REPOS" en mono 13px slate-400, letter-spacing 0.15em
|
||||
- Aperçu prochain exercice centré
|
||||
|
||||
---
|
||||
|
||||
## 8. Navigation
|
||||
|
||||
```
|
||||
Tab Bar (5 onglets, fixé en bas)
|
||||
height: 56px + safe area inset
|
||||
background: navy-800
|
||||
border-top: 1px solid border-dim
|
||||
|
||||
Onglets:
|
||||
- Accueil (home icon)
|
||||
- Programmes (grid icon)
|
||||
- Minuteur (timer icon)
|
||||
- Progression (chart icon)
|
||||
- Profil (person icon)
|
||||
|
||||
Onglet actif: icône green-500 + label green-500
|
||||
Onglet inactif: icône slate-400 + label slate-400
|
||||
|
||||
Font label: sans 11px 400
|
||||
Icon size: 22×22px
|
||||
Touch target: 44×44px minimum
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Animations & micro-interactions
|
||||
|
||||
```
|
||||
FadeIn:
|
||||
opacity: 0 → 1
|
||||
duration: 300ms
|
||||
easing: ease
|
||||
|
||||
SlideUp (bottom sheet, modal):
|
||||
translateY(100%) → 0
|
||||
duration: 400ms
|
||||
easing: cubic-bezier(0.4, 0, 0.2, 1)
|
||||
|
||||
Pulse (CTA bouton, timer urgence):
|
||||
scale: 1 → 1.02 → 1
|
||||
duration: 2s
|
||||
infinite, ease-in-out
|
||||
|
||||
Bounce (célébration fin de séance):
|
||||
scale: 0.5 → 1.05 → 0.98 → 1
|
||||
duration: 600ms
|
||||
easing: spring
|
||||
|
||||
StaggerList (items qui apparaissent en séquence):
|
||||
Délai: 100ms entre chaque item
|
||||
Chaque item: FadeIn + translateY(12px→0)
|
||||
|
||||
ScalePress (tous les boutons):
|
||||
active: scale(0.97)
|
||||
duration: 100ms
|
||||
```
|
||||
|
||||
**Règle d'or animations :** Une animation bien exécutée au chargement d'écran
|
||||
vaut mieux que des micro-interactions dispersées partout.
|
||||
|
||||
---
|
||||
|
||||
## 10. Paywall — règles de design conversion
|
||||
|
||||
```
|
||||
Structure obligatoire du paywall:
|
||||
1. Célébration des accomplissements (TOUJOURS en premier)
|
||||
→ Font serif italic, emoji, stats concrètes
|
||||
2. Valeur du contenu débloqué (liste concrète)
|
||||
3. Pricing transparent (pas de dark patterns)
|
||||
→ "Essai gratuit 7 jours · puis 24,99€/an · soit 2,08€/mois"
|
||||
4. CTA principal (PrimaryButton full-width)
|
||||
5. Réassurance ("Annulation facile à tout moment")
|
||||
6. Alternative gratuite visible (GhostButton ou lien)
|
||||
|
||||
Couleur encadré pricing: CardAccent (vert)
|
||||
Bouton fermeture: TOUJOURS visible en haut à gauche
|
||||
Pas de compte à rebours fictif, pas de stock limité : anti dark patterns
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Accessibilité
|
||||
|
||||
```
|
||||
Contraste texte:
|
||||
Texte primaire (#E6F1FF) sur navy-900 → ratio 15:1 ✓
|
||||
Texte secondaire (#A8B2D8) sur navy-900 → ratio 7:1 ✓
|
||||
Vert (#00C896) sur navy-900 → ratio 8:1 ✓
|
||||
Tous conformes WCAG AA (4.5:1 minimum requis)
|
||||
|
||||
Touch targets:
|
||||
Minimum 44×44px pour TOUS les éléments interactifs
|
||||
Espacement minimum 8px entre deux éléments interactifs adjacents
|
||||
|
||||
Timer:
|
||||
La couleur n'est pas le seul signal d'urgence
|
||||
Ajouter aussi: pulse animation + vibration haptique + signal audio
|
||||
|
||||
Audio:
|
||||
Toujours proposer une alternative visuelle à chaque signal audio
|
||||
Le toggle audio est accessible en 1 tap depuis l'écran séance
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Tokens React Native / Expo
|
||||
|
||||
```typescript
|
||||
// design-tokens.ts
|
||||
export const colors = {
|
||||
// Navy
|
||||
navy900: '#0D1B2A',
|
||||
navy800: '#112240',
|
||||
navy700: '#1A3050',
|
||||
navy600: '#243C5E',
|
||||
|
||||
// Green
|
||||
green500: '#00C896',
|
||||
green600: '#00A67C',
|
||||
green700: '#00875F',
|
||||
greenDim: 'rgba(0,200,150,0.12)',
|
||||
greenBorder: 'rgba(0,200,150,0.35)',
|
||||
|
||||
// Text
|
||||
white100: '#E6F1FF',
|
||||
slate300: '#A8B2D8',
|
||||
slate400: '#8892B0',
|
||||
|
||||
// Borders
|
||||
borderDim: 'rgba(168,178,216,0.15)',
|
||||
borderHover: 'rgba(168,178,216,0.25)',
|
||||
|
||||
// Orange (tip/kine only)
|
||||
orange500: '#FF8A5C',
|
||||
orange600: '#E06A3C',
|
||||
orangeDim: 'rgba(255,138,92,0.12)',
|
||||
|
||||
// Semantic
|
||||
red500: '#FF4444', // timer urgence ONLY
|
||||
} as const
|
||||
|
||||
export const spacing = {
|
||||
1: 4,
|
||||
2: 8,
|
||||
3: 12,
|
||||
4: 16,
|
||||
6: 24,
|
||||
8: 32,
|
||||
12: 48,
|
||||
16: 64,
|
||||
} as const
|
||||
|
||||
export const radius = {
|
||||
sm: 4,
|
||||
md: 8,
|
||||
lg: 12,
|
||||
xl: 16,
|
||||
pill: 9999,
|
||||
} as const
|
||||
|
||||
export const fontSizes = {
|
||||
caption: 12,
|
||||
label: 13,
|
||||
body: 15,
|
||||
heading2: 18,
|
||||
heading1: 22,
|
||||
display: 28,
|
||||
timer: 88, // taille par défaut du timer
|
||||
} as const
|
||||
|
||||
export const timerThreshold = 10 // secondes — passage vert → rouge
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist avant livraison d'un écran
|
||||
|
||||
- [ ] Fond `navy-900` utilisé comme base
|
||||
- [ ] Aucun shadow/élévation — différenciation par couleur uniquement
|
||||
- [ ] Tous les touch targets ≥ 44×44px
|
||||
- [ ] Le vert n'est utilisé que pour des actions ou validations
|
||||
- [ ] L'orange n'est utilisé que pour des conseils kiné ou alertes positives
|
||||
- [ ] Le rouge n'apparaît que sur le timer en urgence
|
||||
- [ ] Timer ≥ 80px de haut sur l'écran séance
|
||||
- [ ] Vidéo plein écran sur l'écran séance (pas un bloc vidéo)
|
||||
- [ ] Gradients top + bottom sur l'écran séance pour la lisibilité
|
||||
- [ ] Phase repos visuellement différente de la phase effort
|
||||
- [ ] Paywall : célébration en premier, alternative gratuite visible
|
||||
- [ ] Typographie : serif pour les moments émotionnels, mono pour les données
|
||||
@@ -1,428 +0,0 @@
|
||||
# Skill — Exercices (useExercise + ExerciseDisplay)
|
||||
> Lis ce skill AVANT d'implémenter quoi que ce soit lié aux exercices.
|
||||
|
||||
## Responsabilité de ce module
|
||||
Gérer la bibliothèque d'exercices, la sélection selon le programme,
|
||||
et l'affichage contextuel selon la phase du timer.
|
||||
**Offline-first** : toutes les données et les GIFs sont dans le bundle.
|
||||
|
||||
---
|
||||
|
||||
## Architecture du module exercices
|
||||
|
||||
```
|
||||
src/features/exercises/
|
||||
types.ts
|
||||
hooks/
|
||||
useExercise.ts ← sélection + navigation entre exercices
|
||||
useExerciseLibrary.ts ← recherche, filtrage, favoris
|
||||
components/
|
||||
ExerciseDisplay.tsx ← affichage contextuel selon la phase timer
|
||||
ExerciseGif.tsx ← Image GIF avec fallback + accessibilité
|
||||
ExerciseCard.tsx ← carte pour la bibliothèque/programme
|
||||
ExerciseCues.tsx ← affichage des cues de forme
|
||||
data/
|
||||
exercises.ts ← les 38 exercices (source de vérité)
|
||||
categories.ts ← définition des catégories
|
||||
index.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Types
|
||||
|
||||
```typescript
|
||||
// src/features/exercises/types.ts
|
||||
|
||||
export type ExerciseCategory =
|
||||
| 'CARDIO'
|
||||
| 'LOWER_BODY'
|
||||
| 'UPPER_BODY'
|
||||
| 'CORE'
|
||||
| 'LOW_IMPACT'
|
||||
| 'EQUIPMENT'
|
||||
|
||||
export type ExerciseDifficulty = 'BEGINNER' | 'INTERMEDIATE' | 'ADVANCED'
|
||||
|
||||
export interface Exercise {
|
||||
id: string
|
||||
name: Record<string, string> // clés = codes langue : { fr, en, es, de, pt }
|
||||
category: ExerciseCategory
|
||||
difficulty: ExerciseDifficulty
|
||||
musclesTargeted: string[]
|
||||
description: Record<string, string> // max 80 caractères par langue
|
||||
cues: Record<string, string[]> // 2-3 points clés de forme par langue
|
||||
gifAsset: number | null // require(...) ou null si pas de GIF
|
||||
thumbnailAsset: number | null
|
||||
hasModification: boolean // variante plus facile disponible
|
||||
modificationId: string | null // id de l'exercice modifié
|
||||
equipmentNeeded: string[] // [] = aucun matériel
|
||||
estimatedCaloriesPerMinute: number // approximatif
|
||||
}
|
||||
|
||||
export type ProgramMode = 'SINGLE' | 'DUO' | 'CIRCUIT_4' | 'FREE'
|
||||
|
||||
export interface ExerciseProgram {
|
||||
id: string
|
||||
name: Record<string, string>
|
||||
description: Record<string, string>
|
||||
mode: ProgramMode
|
||||
// Pour SINGLE : [exerciseId] — même exercice répété
|
||||
// Pour DUO : [exerciseIdA, exerciseIdB] — alternance
|
||||
// Pour CIRCUIT_4 : [a, b, c, d] — 4 exercices × 2 rounds
|
||||
// Pour FREE : exerciseId par round [r1, r2, r3, r4, r5, r6, r7, r8]
|
||||
exerciseIds: string[]
|
||||
difficulty: ExerciseDifficulty
|
||||
totalRounds: number
|
||||
isDefault: boolean // programmes pré-définis vs créés par user
|
||||
}
|
||||
|
||||
// Résout l'exercice pour un round donné selon le mode du programme
|
||||
export interface ExerciseState {
|
||||
current: Exercise
|
||||
next: Exercise | null // null sur le dernier round
|
||||
roundIndex: number // 0-indexé
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Données — les 38 exercices V1
|
||||
|
||||
Structure à respecter pour chaque exercice :
|
||||
|
||||
```typescript
|
||||
// src/features/exercises/data/exercises.ts
|
||||
import { Exercise } from '../types'
|
||||
|
||||
export const EXERCISES: Exercise[] = [
|
||||
{
|
||||
id: 'burpee_classic',
|
||||
name: { fr: 'Burpee', en: 'Burpee', es: 'Burpee', de: 'Burpee', pt: 'Burpee' },
|
||||
category: 'CARDIO',
|
||||
difficulty: 'INTERMEDIATE',
|
||||
musclesTargeted: ['quadriceps', 'pectoraux', 'épaules', 'cardio'],
|
||||
description: {
|
||||
fr: 'Position debout → squat → planche → pompe → saut vertical',
|
||||
en: 'Stand → squat → plank → push-up → jump',
|
||||
es: 'De pie → sentadilla → plancha → flexión → salto',
|
||||
de: 'Stehen → Hocke → Plank → Liegestütz → Sprung',
|
||||
pt: 'Em pé → agachamento → prancha → flexão → salto',
|
||||
},
|
||||
cues: {
|
||||
fr: ['Atterris doucement sur les orteils', 'Garde le dos droit en planche', 'Explose vers le haut'],
|
||||
en: ['Land softly on toes', 'Keep back flat in plank', 'Explode upward'],
|
||||
es: ['Aterriza suavemente', 'Espalda recta en plancha', 'Explota hacia arriba'],
|
||||
de: ['Sanft auf den Zehen landen', 'Rücken gerade halten', 'Nach oben explodieren'],
|
||||
pt: ['Aterrissar suavemente', 'Costas retas na prancha', 'Explodir para cima'],
|
||||
},
|
||||
gifAsset: require('../../../assets/exercises/burpee_classic.gif'),
|
||||
thumbnailAsset: require('../../../assets/exercises/thumbs/burpee_classic.jpg'),
|
||||
hasModification: true,
|
||||
modificationId: 'burpee_modified',
|
||||
equipmentNeeded: [],
|
||||
estimatedCaloriesPerMinute: 12,
|
||||
},
|
||||
// ... 37 autres exercices
|
||||
]
|
||||
|
||||
// Accès rapide par ID
|
||||
export const EXERCISES_MAP = Object.fromEntries(
|
||||
EXERCISES.map(ex => [ex.id, ex])
|
||||
) as Record<string, Exercise>
|
||||
|
||||
export function getExerciseById(id: string): Exercise {
|
||||
const ex = EXERCISES_MAP[id]
|
||||
if (!ex) throw new Error(`Exercice non trouvé : ${id}`)
|
||||
return ex
|
||||
}
|
||||
```
|
||||
|
||||
**Liste des 38 exercices V1 à implémenter :**
|
||||
```
|
||||
CARDIO (8) : burpee_classic, burpee_modified, jumping_jacks,
|
||||
mountain_climbers, high_knees, jump_rope_sim,
|
||||
box_jump_sim, lateral_shuffles
|
||||
|
||||
LOWER_BODY (8) : squat_classic, squat_jump, lunge_alternating,
|
||||
lunge_jump, glute_bridge, wall_sit, calf_raises,
|
||||
sumo_squat
|
||||
|
||||
UPPER_BODY (6) : pushup_classic, pushup_modified, pike_pushup,
|
||||
tricep_dip, shoulder_taps, inchworm
|
||||
|
||||
CORE (6) : crunch_classic, plank_hold, russian_twist,
|
||||
bicycle_crunch, leg_raises, dead_bug
|
||||
|
||||
LOW_IMPACT (6) : march_in_place, step_touch, modified_squat,
|
||||
standing_oblique, slow_pushup, chair_stand
|
||||
|
||||
EQUIPMENT (4) : kb_swing, db_thruster, resistance_band_row,
|
||||
medicine_ball_slam
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Hook useExercise
|
||||
|
||||
```typescript
|
||||
// src/features/exercises/hooks/useExercise.ts
|
||||
|
||||
export function useExercise(program: ExerciseProgram): {
|
||||
getExerciseForRound: (round: number) => ExerciseState
|
||||
currentExercise: Exercise
|
||||
nextExercise: Exercise | null
|
||||
} {
|
||||
function getExerciseForRound(round: number): ExerciseState {
|
||||
// round est 1-indexé
|
||||
const index = round - 1
|
||||
|
||||
let currentId: string
|
||||
let nextId: string | null
|
||||
|
||||
switch (program.mode) {
|
||||
case 'SINGLE':
|
||||
currentId = program.exerciseIds[0]
|
||||
nextId = null // même exercice → pas de "suivant" différent
|
||||
break
|
||||
|
||||
case 'DUO':
|
||||
currentId = program.exerciseIds[index % 2]
|
||||
nextId = program.exerciseIds[(index + 1) % 2]
|
||||
break
|
||||
|
||||
case 'CIRCUIT_4':
|
||||
currentId = program.exerciseIds[index % 4]
|
||||
nextId = program.exerciseIds[(index + 1) % 4]
|
||||
break
|
||||
|
||||
case 'FREE':
|
||||
currentId = program.exerciseIds[index] ?? program.exerciseIds[0]
|
||||
nextId = program.exerciseIds[index + 1] ?? null
|
||||
break
|
||||
}
|
||||
|
||||
return {
|
||||
current: getExerciseById(currentId),
|
||||
next: nextId ? getExerciseById(nextId) : null,
|
||||
roundIndex: index,
|
||||
}
|
||||
}
|
||||
|
||||
return { getExerciseForRound, /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Composant ExerciseDisplay — affichage contextuel
|
||||
|
||||
Le composant adapte son layout selon la phase du timer.
|
||||
|
||||
```typescript
|
||||
interface ExerciseDisplayProps {
|
||||
phase: TimerPhase
|
||||
exercise: Exercise
|
||||
nextExercise: Exercise | null
|
||||
lang: string // code langue actuel
|
||||
}
|
||||
|
||||
export function ExerciseDisplay({ phase, exercise, nextExercise, lang }: ExerciseDisplayProps) {
|
||||
switch (phase) {
|
||||
case 'GET_READY':
|
||||
return <GetReadyView exercise={exercise} lang={lang} />
|
||||
// ↑ GIF grand format + nom + "Prépare-toi !"
|
||||
|
||||
case 'WORK':
|
||||
return <WorkView exercise={exercise} lang={lang} />
|
||||
// ↑ Nom en haut + 2 cues + GIF petit coin bas-droit
|
||||
|
||||
case 'REST':
|
||||
return <RestView exercise={exercise} nextExercise={nextExercise} lang={lang} />
|
||||
// ↑ "Repos" + si nextExercise : "Prochain : [nom]" + vignette
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GetReadyView
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ │
|
||||
│ ┌─────────────────┐ │
|
||||
│ │ [GIF 200x200] │ │ ← Grand GIF centré
|
||||
│ └─────────────────┘ │
|
||||
│ │
|
||||
│ Burpees │ ← Nom, police bold 28px
|
||||
│ ← Dos droit en planche │ ← Cue #1
|
||||
│ ← Explose vers le haut │ ← Cue #2
|
||||
│ │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
### WorkView
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ Burpees │ ← Nom, 22px, coin haut-gauche
|
||||
│ ← Dos droit │ ← Cue #1, 16px
|
||||
│ ← Explose vers le haut │ ← Cue #2, 16px
|
||||
│ │
|
||||
│ [Timer central] │
|
||||
│ │
|
||||
│ ┌────┐ │
|
||||
│ │GIF │ │ ← GIF 80x80, coin bas-droit
|
||||
│ └────┘ │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
### RestView
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ │
|
||||
│ REPOS │ ← "REPOS" centré, grand
|
||||
│ │
|
||||
│ Prochain : Mountain │ ← Si nextExercise != null
|
||||
│ Climbers │
|
||||
│ ┌──────┐ │
|
||||
│ │ GIF │ │ ← Vignette 60x60 de nextExercise
|
||||
│ └──────┘ │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Composant ExerciseGif — robuste et accessible
|
||||
|
||||
```typescript
|
||||
interface ExerciseGifProps {
|
||||
exercise: Exercise
|
||||
size: 'small' | 'medium' | 'large'
|
||||
lang: string
|
||||
}
|
||||
|
||||
const GIF_SIZES = { small: 80, medium: 120, large: 200 }
|
||||
|
||||
export function ExerciseGif({ exercise, size, lang }: ExerciseGifProps) {
|
||||
const [hasError, setHasError] = useState(false)
|
||||
const dimension = GIF_SIZES[size]
|
||||
|
||||
if (!exercise.gifAsset || hasError) {
|
||||
// Fallback : icône + initiales de l'exercice
|
||||
return (
|
||||
<View
|
||||
style={[styles.fallback, { width: dimension, height: dimension }]}
|
||||
accessible={true}
|
||||
accessibilityLabel={exercise.name[lang] ?? exercise.name['en']}
|
||||
>
|
||||
<Text style={styles.fallbackIcon}>💪</Text>
|
||||
<Text style={styles.fallbackText}>{getInitials(exercise.name[lang])}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
source={exercise.gifAsset}
|
||||
style={{ width: dimension, height: dimension }}
|
||||
onError={() => setHasError(true)}
|
||||
accessible={true}
|
||||
accessibilityLabel={`Démonstration de ${exercise.name[lang]}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Localisation des exercices
|
||||
|
||||
```typescript
|
||||
// Utilitaire pour extraire la bonne langue avec fallback
|
||||
export function getLocalizedText(
|
||||
record: Record<string, string>,
|
||||
lang: string
|
||||
): string {
|
||||
return record[lang] ?? record['en'] ?? Object.values(record)[0] ?? ''
|
||||
}
|
||||
|
||||
// Utilisation
|
||||
const name = getLocalizedText(exercise.name, userLang)
|
||||
const cues = (exercise.cues[userLang] ?? exercise.cues['en'] ?? [])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Programmes pré-définis V1
|
||||
|
||||
```typescript
|
||||
// src/features/exercises/data/defaultPrograms.ts
|
||||
|
||||
export const DEFAULT_PROGRAMS: ExerciseProgram[] = [
|
||||
{
|
||||
id: 'beginner_classic',
|
||||
name: { fr: 'Débutant Classic', en: 'Beginner Classic' },
|
||||
description: { fr: 'Parfait pour commencer le Tabata', en: 'Perfect to start Tabata' },
|
||||
mode: 'SINGLE',
|
||||
exerciseIds: ['squat_classic'],
|
||||
difficulty: 'BEGINNER',
|
||||
totalRounds: 8,
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
id: 'full_body_duo',
|
||||
name: { fr: 'Full Body Duo', en: 'Full Body Duo' },
|
||||
description: { fr: 'Squats + Pompes — le duo parfait', en: 'Squats + Push-ups — the perfect duo' },
|
||||
mode: 'DUO',
|
||||
exerciseIds: ['squat_jump', 'pushup_classic'],
|
||||
difficulty: 'INTERMEDIATE',
|
||||
totalRounds: 8,
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
id: 'cardio_blast',
|
||||
name: { fr: 'Cardio Blast', en: 'Cardio Blast' },
|
||||
description: { fr: 'Circuit intense 4 exercices cardio', en: '4-exercise intense cardio circuit' },
|
||||
mode: 'CIRCUIT_4',
|
||||
exerciseIds: ['burpee_classic', 'high_knees', 'mountain_climbers', 'jumping_jacks'],
|
||||
difficulty: 'ADVANCED',
|
||||
totalRounds: 8,
|
||||
isDefault: true,
|
||||
},
|
||||
// + programmes par objectif (perte de poids, force, low impact, etc.)
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tests minimaux
|
||||
|
||||
```typescript
|
||||
describe('useExercise', () => {
|
||||
it('MODE SINGLE : retourne le même exercice à chaque round', () => {})
|
||||
it('MODE DUO : alterne A/B correctement sur 8 rounds', () => {})
|
||||
it('MODE CIRCUIT_4 : cycle sur 4 exercices', () => {})
|
||||
it('retourne null pour nextExercise sur le dernier round en FREE', () => {})
|
||||
it('getExerciseById lance une erreur si id inconnu', () => {})
|
||||
})
|
||||
|
||||
describe('ExerciseDisplay', () => {
|
||||
it('affiche le GIF grand format en GET_READY', () => {})
|
||||
it('affiche les cues en WORK', () => {})
|
||||
it('affiche nextExercise en REST si disponible', () => {})
|
||||
it('affiche le fallback si gifAsset est null', () => {})
|
||||
it('est accessible (accessibilityLabel présent)', () => {})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist assets exercices
|
||||
|
||||
Avant de lancer en production, vérifier :
|
||||
- [ ] 38 GIFs présents dans `assets/exercises/` (200×200px, loop, < 200KB chacun)
|
||||
- [ ] 38 thumbnails présents dans `assets/exercises/thumbs/` (< 30KB chacun)
|
||||
- [ ] Toutes les traductions présentes pour EN, FR, ES, DE, PT
|
||||
- [ ] Tous les `modificationId` pointent vers un exercice existant
|
||||
- [ ] Bundle total des assets exercices < 10MB
|
||||
@@ -21,8 +21,9 @@ Run from the project root. This parses all source files, builds the knowledge gr
|
||||
| -------------- | ---------------------------------------------------------------- |
|
||||
| `--force` | Force full re-index even if up to date |
|
||||
| `--embeddings` | Enable embedding generation for semantic search (off by default) |
|
||||
| `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. |
|
||||
|
||||
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook runs `analyze` automatically after `git commit` and `git merge`, preserving embeddings if previously generated.
|
||||
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook detects staleness after `git commit` and `git merge` and notifies the agent to run `analyze` — the hook does not run analyze itself, to avoid blocking the agent for up to 120s and risking KuzuDB corruption on timeout.
|
||||
|
||||
### status — Check index freshness
|
||||
|
||||
|
||||
@@ -1,349 +0,0 @@
|
||||
# Skill — Timer Engine (useTimerEngine)
|
||||
> Lis ce skill AVANT d'implémenter quoi que ce soit lié au timer.
|
||||
|
||||
## Responsabilité de ce module
|
||||
Le timer est le cœur battant de l'app. Il doit être :
|
||||
- **Précis** : drift < 50ms sur une séance complète (8 rounds)
|
||||
- **Résilient** : continue en background, survit aux appels téléphoniques
|
||||
- **Découplé** : aucune dépendance vers l'audio ou les exercices
|
||||
- **Testable** : logique pure, facile à tester sans simulateur
|
||||
|
||||
---
|
||||
|
||||
## Architecture du module timer
|
||||
|
||||
```
|
||||
src/features/timer/
|
||||
types.ts ← Commencer ICI
|
||||
hooks/
|
||||
useTimerEngine.ts ← Moteur central (logique pure)
|
||||
useTimerSync.ts ← Pont vers audio + exercices
|
||||
useTimerPersistence.ts ← Sauvegarde config dans AsyncStorage
|
||||
components/
|
||||
TimerDisplay.tsx ← Écran plein écran (props-only)
|
||||
TimerControls.tsx ← Boutons start/pause/stop/skip
|
||||
TimerPhaseIndicator.tsx ← Barre de progression des rounds
|
||||
TimerRing.tsx ← Anneau circulaire animé
|
||||
index.ts ← Barrel export
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Types — commencer par ici
|
||||
|
||||
```typescript
|
||||
// src/features/timer/types.ts
|
||||
|
||||
export type TimerPhase = 'IDLE' | 'GET_READY' | 'WORK' | 'REST' | 'COMPLETE'
|
||||
|
||||
export interface TimerConfig {
|
||||
workDuration: number // défaut : 20s
|
||||
restDuration: number // défaut : 10s
|
||||
rounds: number // défaut : 8
|
||||
getReadyDuration: number // défaut : 10s
|
||||
cycles: number // défaut : 1 (Premium : jusqu'à 10)
|
||||
cyclePauseDuration: number // défaut : 60s (pause entre cycles, Premium)
|
||||
}
|
||||
|
||||
export interface TimerState {
|
||||
phase: TimerPhase
|
||||
secondsLeft: number
|
||||
currentRound: number // 1-indexé
|
||||
totalRounds: number
|
||||
currentCycle: number // 1-indexé
|
||||
totalCycles: number
|
||||
isRunning: boolean
|
||||
isPaused: boolean
|
||||
totalElapsedSeconds: number // pour le résumé de séance
|
||||
}
|
||||
|
||||
export interface TimerActions {
|
||||
start: (config: TimerConfig) => void
|
||||
pause: () => void
|
||||
resume: () => void
|
||||
stop: () => void
|
||||
skip: () => void // passer à la phase suivante immédiatement
|
||||
}
|
||||
|
||||
// Événements émis par le timer — consommés par useTimerSync
|
||||
export type TimerEvent =
|
||||
| { type: 'PHASE_CHANGED'; from: TimerPhase; to: TimerPhase }
|
||||
| { type: 'ROUND_COMPLETED'; round: number }
|
||||
| { type: 'SESSION_COMPLETE'; totalSeconds: number }
|
||||
| { type: 'COUNTDOWN_TICK'; secondsLeft: number } // pour les dernières 3s
|
||||
|
||||
export type TimerEventListener = (event: TimerEvent) => void
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implémentation — useTimerEngine
|
||||
|
||||
### Principe de précision : Date.now() delta
|
||||
|
||||
**JAMAIS** se fier uniquement à setInterval pour le compte à rebours.
|
||||
setInterval drift de 10-100ms par tick sur mobile. Sur 8 rounds
|
||||
de 20s + 10s, ça peut représenter plusieurs secondes d'écart.
|
||||
|
||||
```typescript
|
||||
// ✅ Approche correcte — delta sur Date.now()
|
||||
const tickRef = useRef<number | null>(null)
|
||||
const targetEndTimeRef = useRef<number>(0)
|
||||
|
||||
function tick() {
|
||||
const now = Date.now()
|
||||
const remaining = Math.max(0, targetEndTimeRef.current - now)
|
||||
const secondsLeft = Math.ceil(remaining / 1000)
|
||||
|
||||
setSecondsLeft(secondsLeft)
|
||||
|
||||
if (remaining <= 0) {
|
||||
advancePhase()
|
||||
} else {
|
||||
// Planifier le prochain tick dans ~100ms (pas 1000ms)
|
||||
// pour une réactivité maximale sur les transitions
|
||||
tickRef.current = setTimeout(tick, 100)
|
||||
}
|
||||
}
|
||||
|
||||
function startPhase(duration: number) {
|
||||
targetEndTimeRef.current = Date.now() + duration * 1000
|
||||
tickRef.current = setTimeout(tick, 100)
|
||||
}
|
||||
```
|
||||
|
||||
### Gestion du background (AppState)
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener('change', (nextState) => {
|
||||
if (nextState === 'background' || nextState === 'inactive') {
|
||||
// Sauvegarder le timestamp cible — pas l'état courant
|
||||
// Quand on revient en foreground, on recalcule
|
||||
saveBackgroundTimestamp(targetEndTimeRef.current)
|
||||
}
|
||||
|
||||
if (nextState === 'active') {
|
||||
const savedTarget = loadBackgroundTimestamp()
|
||||
if (savedTarget && isRunningRef.current) {
|
||||
// Recalculer le temps restant depuis le timestamp sauvegardé
|
||||
targetEndTimeRef.current = savedTarget
|
||||
// Si le temps est dépassé, avancer aux phases manquées
|
||||
reconcilePhaseAfterBackground(savedTarget, Date.now())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return () => subscription.remove()
|
||||
}, [])
|
||||
```
|
||||
|
||||
### Notification sticky pendant la séance
|
||||
|
||||
Utiliser expo-notifications pour afficher le décompte en background :
|
||||
|
||||
```typescript
|
||||
async function updateBackgroundNotification(phase: TimerPhase, secondsLeft: number) {
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
identifier: 'tabata-timer-sticky',
|
||||
content: {
|
||||
title: phase === 'WORK' ? '🔥 Travail !' : '💨 Repos',
|
||||
body: `${secondsLeft}s — Round ${currentRound}/${totalRounds}`,
|
||||
sticky: true,
|
||||
autoDismiss: false,
|
||||
},
|
||||
trigger: null,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Ordre des transitions de phase
|
||||
|
||||
```
|
||||
IDLE
|
||||
↓ start()
|
||||
GET_READY (getReadyDuration)
|
||||
↓ temps écoulé
|
||||
WORK (workDuration)
|
||||
↓ temps écoulé
|
||||
REST (restDuration)
|
||||
↓ temps écoulé
|
||||
→ si currentRound < totalRounds : retour à WORK
|
||||
→ si currentRound === totalRounds ET currentCycle < totalCycles :
|
||||
PAUSE_BETWEEN_CYCLES → WORK (round 1 du cycle suivant)
|
||||
→ si currentRound === totalRounds ET currentCycle === totalCycles :
|
||||
COMPLETE
|
||||
↓ stop() ou auto-reset après 3s
|
||||
IDLE
|
||||
```
|
||||
|
||||
### Événements à émettre aux abonnés
|
||||
|
||||
```typescript
|
||||
// À chaque changement de phase
|
||||
emitEvent({ type: 'PHASE_CHANGED', from: prevPhase, to: nextPhase })
|
||||
|
||||
// À chaque fin de round
|
||||
emitEvent({ type: 'ROUND_COMPLETED', round: currentRound })
|
||||
|
||||
// Décompte final (3, 2, 1)
|
||||
if (secondsLeft <= 3) {
|
||||
emitEvent({ type: 'COUNTDOWN_TICK', secondsLeft })
|
||||
}
|
||||
|
||||
// Fin de séance
|
||||
emitEvent({ type: 'SESSION_COMPLETE', totalSeconds: totalElapsedSeconds })
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Interface publique du hook
|
||||
|
||||
```typescript
|
||||
export function useTimerEngine(): TimerState & TimerActions & {
|
||||
addEventListener: (listener: TimerEventListener) => () => void
|
||||
config: TimerConfig
|
||||
}
|
||||
```
|
||||
|
||||
Le retour doit permettre :
|
||||
```typescript
|
||||
const {
|
||||
phase, secondsLeft, currentRound, totalRounds, isRunning, isPaused,
|
||||
start, pause, resume, stop, skip,
|
||||
addEventListener,
|
||||
config,
|
||||
} = useTimerEngine()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Composant TimerDisplay — layout plein écran
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐ ← StatusBar cachée
|
||||
│ [Exercice] Round 3/8 ●○○ │ ← Zone haute (20%) — fond semi-transparent
|
||||
├─────────────────────────────┤
|
||||
│ │
|
||||
│ │
|
||||
│ ┌───────┐ │
|
||||
│ │ :17 │ │ ← Anneau + chiffre (50% de l'écran)
|
||||
│ └───────┘ │ ← Police monospace, taille ~120px
|
||||
│ │
|
||||
│ ████████████░░░░░░░░░░░░░ │ ← Barre de progression totale (15%)
|
||||
│ │
|
||||
│ ⏸ ■ ⏭ │ ← Boutons discrets (15%) — Pause/Stop/Skip
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
Props obligatoires du composant :
|
||||
```typescript
|
||||
interface TimerDisplayProps {
|
||||
state: TimerState
|
||||
exerciseName: string
|
||||
nextExerciseName: string
|
||||
onPause: () => void
|
||||
onStop: () => void
|
||||
onSkip: () => void
|
||||
}
|
||||
```
|
||||
|
||||
### Animations requises
|
||||
- **Fond** : transition de couleur animée entre phases (600ms ease-in-out)
|
||||
- `Animated.timing` sur la backgroundColor avec interpolation de couleurs
|
||||
- **Chiffre** : légère pulsation à chaque seconde (`Animated.sequence` scale 1→1.05→1)
|
||||
- **Anneau** : `Animated.Value` sur stroke-dashoffset (SVG) ou rotation
|
||||
- **Décompte final** : flash rouge sur les 3 dernières secondes
|
||||
|
||||
```typescript
|
||||
// Interpolation de couleur entre phases
|
||||
const backgroundAnim = useRef(new Animated.Value(0)).current
|
||||
|
||||
const backgroundColor = backgroundAnim.interpolate({
|
||||
inputRange: [0, 1, 2, 3],
|
||||
outputRange: [
|
||||
PHASE_COLORS.IDLE,
|
||||
PHASE_COLORS.GET_READY,
|
||||
PHASE_COLORS.WORK,
|
||||
PHASE_COLORS.REST,
|
||||
],
|
||||
})
|
||||
|
||||
// Déclencher quand la phase change
|
||||
useEffect(() => {
|
||||
const phaseIndex = { IDLE: 0, GET_READY: 1, WORK: 2, REST: 3, COMPLETE: 0 }
|
||||
Animated.timing(backgroundAnim, {
|
||||
toValue: phaseIndex[phase],
|
||||
duration: 600,
|
||||
useNativeDriver: false, // obligatoire pour backgroundColor
|
||||
}).start()
|
||||
}, [phase])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## expo-keep-awake — écran allumé pendant la séance
|
||||
|
||||
```typescript
|
||||
import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake'
|
||||
|
||||
// Dans useTimerEngine
|
||||
useEffect(() => {
|
||||
if (isRunning) {
|
||||
activateKeepAwakeAsync('tabata-session')
|
||||
} else {
|
||||
deactivateKeepAwake('tabata-session')
|
||||
}
|
||||
}, [isRunning])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tests minimaux à écrire
|
||||
|
||||
```typescript
|
||||
describe('useTimerEngine', () => {
|
||||
it('démarre en phase IDLE avec la config par défaut', () => {})
|
||||
it('passe à GET_READY quand start() est appelé', () => {})
|
||||
it('passe à WORK après la fin de GET_READY', () => {})
|
||||
it('passe à REST après la fin de WORK', () => {})
|
||||
it('incrémente currentRound après chaque REST', () => {})
|
||||
it('passe à COMPLETE après le dernier round', () => {})
|
||||
it('retourne à IDLE après stop()', () => {})
|
||||
it('skip() avance immédiatement à la phase suivante', () => {})
|
||||
it('émet PHASE_CHANGED à chaque transition', () => {})
|
||||
it('émet COUNTDOWN_TICK pour 3, 2, 1', () => {})
|
||||
it('cleanup useEffect enlève tous les listeners', () => {})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Erreurs classiques à éviter
|
||||
|
||||
```typescript
|
||||
// ❌ Drift — setInterval seul
|
||||
setInterval(() => setSecondsLeft(prev => prev - 1), 1000)
|
||||
|
||||
// ✅ Delta sur Date.now()
|
||||
const remaining = Math.max(0, targetEndTimeRef.current - Date.now())
|
||||
setSecondsLeft(Math.ceil(remaining / 1000))
|
||||
|
||||
// ❌ State React pour les valeurs temps-critique
|
||||
const [targetEndTime, setTargetEndTime] = useState(0)
|
||||
|
||||
// ✅ Ref pour les valeurs utilisées dans les closures de timeout
|
||||
const targetEndTimeRef = useRef(0)
|
||||
|
||||
// ❌ Pas de cleanup — memory leak
|
||||
useEffect(() => {
|
||||
setTimeout(tick, 100)
|
||||
})
|
||||
|
||||
// ✅ Cleanup systématique
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (tickRef.current) clearTimeout(tickRef.current)
|
||||
}
|
||||
}, [])
|
||||
```
|
||||
@@ -1,233 +0,0 @@
|
||||
# Skill — Workflow de Développement TabataGo
|
||||
> Lis ce skill en début de session ou quand tu commences une nouvelle feature.
|
||||
|
||||
## Principe fondamental
|
||||
**Plan d'abord, code ensuite.** Pour toute feature non triviale :
|
||||
1. Analyse les specs dans le PRD (`docs/PRD.md`)
|
||||
2. Liste les edge cases et les dépendances
|
||||
3. Propose l'architecture (types, hooks, composants)
|
||||
4. Attends validation avant de générer du code
|
||||
|
||||
Ne jamais écrire de code avant d'avoir une architecture approuvée
|
||||
sur les features cœur (timer, audio, exercices).
|
||||
|
||||
---
|
||||
|
||||
## Cycle de développement par feature
|
||||
|
||||
### Étape 1 — Lire les specs
|
||||
Avant toute implémentation, lis la section PRD correspondante.
|
||||
Identifie exactement :
|
||||
- Les entrées/sorties du système
|
||||
- Les états possibles
|
||||
- Les cas limites (interruptions, background, erreurs)
|
||||
|
||||
### Étape 2 — Typer en premier
|
||||
Commence TOUJOURS par le fichier `types.ts` de la feature.
|
||||
Les types sont le contrat — ils précèdent l'implémentation.
|
||||
|
||||
```typescript
|
||||
// Exemple pour le timer — types.ts
|
||||
export type TimerPhase = 'IDLE' | 'GET_READY' | 'WORK' | 'REST' | 'COMPLETE'
|
||||
|
||||
export interface TimerState {
|
||||
phase: TimerPhase
|
||||
secondsLeft: number
|
||||
currentRound: number
|
||||
totalRounds: number
|
||||
isRunning: boolean
|
||||
}
|
||||
|
||||
export interface TimerConfig {
|
||||
workDuration: number // secondes, défaut 20
|
||||
restDuration: number // secondes, défaut 10
|
||||
rounds: number // défaut 8
|
||||
getReadyDuration: number // secondes, défaut 10
|
||||
}
|
||||
|
||||
export interface TimerActions {
|
||||
start: () => void
|
||||
pause: () => void
|
||||
resume: () => void
|
||||
stop: () => void
|
||||
skip: () => void
|
||||
}
|
||||
```
|
||||
|
||||
**Ne passe à l'étape 3 que si les types semblent corrects.**
|
||||
|
||||
### Étape 3 — Implémenter le hook (logique métier)
|
||||
Implémenter le hook central de la feature.
|
||||
Règles :
|
||||
- Aucun import depuis React Native UI dans les hooks métier
|
||||
- Chaque hook expose un état + des actions (pattern Zustand-like)
|
||||
- Les side effects sont dans useEffect avec cleanup systématique
|
||||
- Logger les transitions d'état en dev (`__DEV__ && console.log(...)`)
|
||||
|
||||
### Étape 4 — Implémenter les composants
|
||||
Les composants reçoivent tout par props — ils ne fetchent rien,
|
||||
ne font aucune logique métier.
|
||||
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
function TimerDisplay({ phase, secondsLeft, currentRound }: TimerDisplayProps) {
|
||||
return <Text>{secondsLeft}</Text>
|
||||
}
|
||||
|
||||
// ❌ Interdit
|
||||
function TimerDisplay() {
|
||||
const timer = useTimerEngine() // logique dans le composant
|
||||
return <Text>{timer.secondsLeft}</Text>
|
||||
}
|
||||
```
|
||||
|
||||
### Étape 5 — Tester avant de continuer
|
||||
Avant de passer à la feature suivante, écrire les tests minimaux :
|
||||
- État initial correct
|
||||
- Transitions de phase correctes
|
||||
- Cleanup des side effects (pas de memory leak)
|
||||
|
||||
---
|
||||
|
||||
## Patterns obligatoires
|
||||
|
||||
### Gestion d'état avec state machine explicite
|
||||
Toujours utiliser une state machine pour les flows complexes.
|
||||
Jamais de booléens en cascade (`isRunning && !isPaused && !isComplete`).
|
||||
|
||||
```typescript
|
||||
// ✅ State machine explicite
|
||||
type TimerPhase = 'IDLE' | 'GET_READY' | 'WORK' | 'REST' | 'COMPLETE'
|
||||
|
||||
const VALID_TRANSITIONS: Record<TimerPhase, TimerPhase[]> = {
|
||||
IDLE: ['GET_READY'],
|
||||
GET_READY: ['WORK', 'IDLE'],
|
||||
WORK: ['REST', 'IDLE'],
|
||||
REST: ['WORK', 'COMPLETE', 'IDLE'],
|
||||
COMPLETE: ['IDLE'],
|
||||
}
|
||||
|
||||
function canTransition(from: TimerPhase, to: TimerPhase): boolean {
|
||||
return VALID_TRANSITIONS[from].includes(to)
|
||||
}
|
||||
```
|
||||
|
||||
### Cleanup systématique des useEffect
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener('change', handleAppState)
|
||||
return () => subscription.remove() // TOUJOURS un cleanup
|
||||
}, [])
|
||||
```
|
||||
|
||||
### AsyncStorage — wrapper typé uniquement
|
||||
Ne jamais appeler AsyncStorage directement dans les composants.
|
||||
Passer par un hook ou un service :
|
||||
|
||||
```typescript
|
||||
// shared/utils/storage.ts
|
||||
export async function saveTimerConfig(config: TimerConfig): Promise<void> {
|
||||
await AsyncStorage.setItem('timer_config', JSON.stringify(config))
|
||||
}
|
||||
|
||||
export async function loadTimerConfig(): Promise<TimerConfig | null> {
|
||||
const raw = await AsyncStorage.getItem('timer_config')
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}
|
||||
```
|
||||
|
||||
### Constantes centralisées
|
||||
```typescript
|
||||
// shared/constants/timer.ts
|
||||
export const TIMER_DEFAULTS = {
|
||||
WORK_DURATION: 20,
|
||||
REST_DURATION: 10,
|
||||
ROUNDS: 8,
|
||||
GET_READY_DURATION: 10,
|
||||
} as const
|
||||
|
||||
// shared/constants/colors.ts
|
||||
export const PHASE_COLORS = {
|
||||
GET_READY: '#EAB308', // Jaune
|
||||
WORK: '#F97316', // Orange
|
||||
REST: '#3B82F6', // Bleu
|
||||
COMPLETE: '#22C55E', // Vert
|
||||
IDLE: '#1E1E2E', // Dark
|
||||
} as const
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Gestion des erreurs
|
||||
|
||||
### Pattern try/catch dans les hooks async
|
||||
```typescript
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
const data = await fetchSomething()
|
||||
setData(data)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Erreur inconnue')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Fallback dans les composants
|
||||
Tout composant qui charge des ressources (GIF, audio) doit avoir
|
||||
un état de fallback visible et non-bloquant.
|
||||
|
||||
---
|
||||
|
||||
## Checklist avant de soumettre du code
|
||||
|
||||
Avant de considérer une feature terminée, vérifier :
|
||||
|
||||
- [ ] Les types sont définis dans `types.ts`
|
||||
- [ ] La logique est dans des hooks, pas dans des composants
|
||||
- [ ] Tous les `useEffect` ont un cleanup
|
||||
- [ ] Aucun `console.log` en dehors de `if (__DEV__)`
|
||||
- [ ] Les constantes sont dans `shared/constants/`
|
||||
- [ ] Les fonctions async ont un try/catch
|
||||
- [ ] Un test minimal existe pour les cas principaux
|
||||
- [ ] Le barrel export `index.ts` est mis à jour
|
||||
|
||||
---
|
||||
|
||||
## Workflow de debugging avec les logs Expo
|
||||
|
||||
Pour surveiller les logs en temps réel et donner du contexte à Claude :
|
||||
|
||||
```bash
|
||||
# Terminal 1 — démarrer expo avec logs dans un fichier
|
||||
npx expo start 2>&1 | tee .expo-logs/dev.log
|
||||
|
||||
# Ensuite dans Claude Code
|
||||
> Surveille .expo-logs/dev.log et identifie les warnings
|
||||
liés au module [nom du module en cours]
|
||||
```
|
||||
|
||||
Ajouter des logs structurés dans les hooks critiques :
|
||||
```typescript
|
||||
if (__DEV__) {
|
||||
console.log('[TimerEngine]', { phase, secondsLeft, currentRound })
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commandes Claude Code utiles
|
||||
|
||||
| Situation | Commande |
|
||||
|---|---|
|
||||
| Début de projet / onboarding | `/init` |
|
||||
| Session longue (>45 min) | `/compact` — focus sur la feature en cours |
|
||||
| Avant chaque commit | `/review` |
|
||||
| Quand Claude part en vrille | Donner un fichier précis : `@src/features/timer/hooks/useTimerEngine.ts` |
|
||||
| Architecture à valider | Demander explicitement "ne génère pas de code, propose seulement l'architecture" |
|
||||
232
.github/workflows/ci.yml
vendored
232
.github/workflows/ci.yml
vendored
@@ -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"
|
||||
|
||||
192
.github/workflows/pr-iphone-deploy.yml
vendored
Normal file
192
.github/workflows/pr-iphone-deploy.yml
vendored
Normal file
@@ -0,0 +1,192 @@
|
||||
# =============================================================================
|
||||
# TabataGo — PR → Build → WiFi/USB Direct Deploy → Wait LGTM → Merge
|
||||
# =============================================================================
|
||||
|
||||
name: PR → Build → WiFi/USB Deploy → LGTM
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
|
||||
build-deploy:
|
||||
name: Build & Deploy to iPhone (WiFi/USB)
|
||||
runs-on: macos
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
git clone --depth 1 -b ${{ github.head_ref }} https://x-access-token:${PR_TOKEN}@gitea.1000co.fr/${{ github.repository }}.git .
|
||||
env:
|
||||
PR_TOKEN: ${{ secrets.PR_API_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
|
||||
- name: Setup Xcode
|
||||
run: |
|
||||
xcodebuild -version
|
||||
|
||||
- name: Setup PATH
|
||||
run: echo "/opt/homebrew/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Clean build artifacts
|
||||
run: |
|
||||
echo "🧹 Cleaning build artifacts..."
|
||||
|
||||
# Act runner caches
|
||||
rm -rf ~/.cache/act/*/hostexecutor/tabatago-swift/build 2>/dev/null || true
|
||||
|
||||
# SPM caches are KEPT between runs — RevenueCat is 1.10 GiB and takes
|
||||
# 5+ minutes to clone; re-cloning on every CI run would waste time
|
||||
# and cause random failures on the act_runner sandbox.
|
||||
|
||||
# Xcode DerivedData — suppression COMPLÈTE (le bundle ID a changé,
|
||||
# l'ancien dossier TabataGo-* peut être ignoré par le nouveau build)
|
||||
rm -rf ~/Library/Developer/Xcode/DerivedData/*
|
||||
|
||||
# Module cache Xcode — des .pcm stale peuvent segfault le linker
|
||||
rm -rf ~/Library/Developer/Xcode/DerivedData/ModuleCache.noindex 2>/dev/null || true
|
||||
rm -rf ~/Library/Caches/com.apple.dt.Xcode
|
||||
|
||||
# Build artifacts locaux (utilisés par le step "Build app")
|
||||
rm -rf build/
|
||||
rm -rf .build
|
||||
rm -rf tabatago-swift/.build
|
||||
rm -rf tabatago-swift/TabataGo.xcodeproj
|
||||
rm -rf tabatago-swift/*.xcworkspace 2>/dev/null || true
|
||||
|
||||
# Package.resolved — peut être stale après un changement de bundle ID
|
||||
find . -name "Package.resolved" -delete 2>/dev/null || true
|
||||
find . -name ".package.resolved" -delete 2>/dev/null || true
|
||||
|
||||
echo "✅ Caches nettoyés"
|
||||
|
||||
- name: Install tools (xcodegen, node, ios-deploy)
|
||||
run: |
|
||||
brew install xcodegen node ios-deploy || true
|
||||
|
||||
- name: Generate Xcode project
|
||||
run: |
|
||||
cd tabatago-swift
|
||||
xcodegen generate
|
||||
|
||||
- name: Resolve SPM packages
|
||||
run: |
|
||||
cd tabatago-swift
|
||||
xcodebuild -resolvePackageDependencies \
|
||||
-project TabataGo.xcodeproj \
|
||||
-scmProvider system
|
||||
|
||||
- name: Build app
|
||||
run: |
|
||||
cd tabatago-swift
|
||||
xcodebuild build \
|
||||
-project TabataGo.xcodeproj \
|
||||
-scheme TabataGo \
|
||||
-configuration Debug \
|
||||
-allowProvisioningUpdates \
|
||||
-skipPackagePluginValidation \
|
||||
-derivedDataPath ../build/derived \
|
||||
-clonedSourcePackagesDirPath ../build/spm-cache \
|
||||
SWIFT_ENABLE_EXPLICIT_MODULES=NO \
|
||||
ONLY_ACTIVE_ARCH=NO \
|
||||
CODE_SIGN_STYLE=Automatic \
|
||||
DEVELOPMENT_TEAM=2MJF39L8VY
|
||||
|
||||
- name: Install to iPhone (WiFi → USB fallback)
|
||||
run: |
|
||||
UDID="00008120-000925CE3672201E"
|
||||
APP=$(find build/derived -name 'TabataGo.app' -type d | head -1)
|
||||
|
||||
# 1. Try WiFi first (Xcode 15+ devicectl network discovery)
|
||||
echo "📱 Trying WiFi install via devicectl..."
|
||||
xcrun devicectl device install app --device "$UDID" "$APP" 2>&1 | tail -3
|
||||
if [ ${PIPESTATUS[0]} -eq 0 ]; then
|
||||
echo "✅ Installed via WiFi"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 2. Fallback to USB via ios-deploy
|
||||
echo "🔌 WiFi failed, trying USB..."
|
||||
ios-deploy --bundle "$APP" --id "$UDID" --justlaunch
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ Installed via USB"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "❌ Install failed (both WiFi and USB)"
|
||||
exit 1
|
||||
|
||||
- name: Post "Ready to test" comment
|
||||
env:
|
||||
GT_TOKEN: ${{ secrets.PR_API_TOKEN }}
|
||||
GITEA_URL: https://gitea.1000co.fr
|
||||
run: |
|
||||
PR="${{ github.event.pull_request.number }}"
|
||||
REPO="${{ github.repository }}"
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${GT_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"body\":\"## 📱 Prêt à tester !\\n\\nL'app est déployée sur l'iPhone (USB/WiFi).\\n\\n- Teste les changements\\n- Reply **LGTM** pour merger\\n- Reply **KO** pour bloquer\"}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/issues/${PR}/comments"
|
||||
|
||||
wait-approval:
|
||||
name: Wait for LGTM comment
|
||||
needs: build-deploy
|
||||
runs-on: macos
|
||||
timeout-minutes: 120
|
||||
|
||||
steps:
|
||||
- name: Poll for LGTM
|
||||
env:
|
||||
GT_TOKEN: ${{ secrets.PR_API_TOKEN }}
|
||||
GITEA_URL: https://gitea.1000co.fr
|
||||
run: |
|
||||
PR="${{ github.event.pull_request.number }}"
|
||||
REPO="${{ github.repository }}"
|
||||
PR_TITLE="${{ github.event.pull_request.title }}"
|
||||
API="${GITEA_URL}/api/v1/repos/${REPO}"
|
||||
MAX=240
|
||||
TRIES=0
|
||||
|
||||
echo "⏳ Attente LGTM sur PR #${PR}..."
|
||||
|
||||
LAST_ID=$(curl -s -H "Authorization: token ${GT_TOKEN}" \
|
||||
"${API}/issues/${PR}/comments" | \
|
||||
python3 -c "import json,sys; cs=json.load(sys.stdin); print(cs[-1]['id'] if cs else 0)" 2>/dev/null || echo 0)
|
||||
|
||||
while [ $TRIES -lt $MAX ]; do
|
||||
sleep 30
|
||||
TRIES=$((TRIES + 1))
|
||||
|
||||
COMMENTS=$(curl -s -H "Authorization: token ${GT_TOKEN}" \
|
||||
"${API}/issues/${PR}/comments?since_id=${LAST_ID}")
|
||||
|
||||
if echo "$COMMENTS" | grep -qi '"body": *"LGTM"'; then
|
||||
echo "✅ LGTM reçu ! Merge..."
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${GT_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"Do\":\"merge\"}" \
|
||||
"${API}/pulls/${PR}/merge"
|
||||
echo "✅ Mergée."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if echo "$COMMENTS" | grep -qi '"body": *"KO"'; then
|
||||
echo "❌ KO reçu. Bloquée."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ $((TRIES % 4)) -eq 0 ]; then
|
||||
echo " ⏳ ... (${TRIES}/240, $(date +%H:%M))"
|
||||
fi
|
||||
done
|
||||
echo "❌ Timeout 2h."
|
||||
exit 1
|
||||
16
.gitignore
vendored
16
.gitignore
vendored
@@ -54,3 +54,19 @@ coverage/
|
||||
node-compile-cache/
|
||||
.gitnexus
|
||||
Config/Secrets.xcconfig
|
||||
|
||||
_Users_*
|
||||
swift-generated-sources/
|
||||
tabatago-swift/build/
|
||||
|
||||
# Swift Package Manager
|
||||
Package.resolved
|
||||
.build/
|
||||
DerivedData/
|
||||
|
||||
# XcodeGen generated — NOT in version control
|
||||
TabataGo.xcodeproj
|
||||
build/
|
||||
|
||||
# Xcode user state
|
||||
*.xcuserstate
|
||||
759
AGENTS.md
759
AGENTS.md
@@ -1,549 +1,272 @@
|
||||
# AGENTS.md
|
||||
# TabataGo — Référence canonique pour agents IA
|
||||
|
||||
This file contains optimizations and best practices for working with this TabataFit Expo project.
|
||||
> **Une seule source de vérité.** Ce document décrit le projet *réel*. Si un autre
|
||||
> fichier (CLAUDE.md, schema.sql) contredit, ce document gagne.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Command Optimizations
|
||||
## 0. ⚠️ Fichiers obsolètes — NE PAS LIRE
|
||||
|
||||
Use the following `rtk` commands for optimal performance:
|
||||
| Fichier | Statut | Pourquoi |
|
||||
|---------|--------|----------|
|
||||
| `CLAUDE.md` (racine) | ❌ OBSOLÈTE | Décrit **TabataFit**, un concept Expo/React Native abandonné |
|
||||
| `supabase/schema.sql` | ❌ OBSOLÈTE | Header littéral "TabataFit" — décrit l'ancien schéma |
|
||||
| `supabase/seed.sql`, `supabase/setup-admin.sql` | ⚠️ Vérifier | Peuvent être périmés ; préférer `supabase/migrations/` |
|
||||
| `AGENTS.md` (ancien, racine) | 🔄 Remplacé par celui-ci | Mentionne PostHog 3.x comme actif (faux, voir §3) |
|
||||
|
||||
| Command | rtk Equivalent | Speedup | Tokens Before | Tokens After | Savings |
|
||||
|---------|----------------|---------|---------------|--------------|---------|
|
||||
| `ls` / `tree` | `rtk ls` | 10x | 2,000 | 400 | -80% |
|
||||
| `cat` / `read` | `rtk read` | 20x | 40,000 | 12,000 | -70% |
|
||||
| `grep` / `rg` | `rtk grep` | 8x | 16,000 | 3,200 | -80% |
|
||||
| `git status` | `rtk git status` | 10x | 3,000 | 600 | -80% |
|
||||
| `git diff` | `rtk git diff` | 5x | 10,000 | 2,500 | -75% |
|
||||
| `git log` | `rtk git log` | 5x | 2,500 | 500 | -80% |
|
||||
| `git add/commit/push` | `rtk git` | 8x | 1,600 | 120 | -92% |
|
||||
| `cargo test` / `npm test` | `rtk test` | 5x | 25,000 | 2,500 | -90% |
|
||||
| `ruff check` | `rtk lint` | 3x | 3,000 | 600 | -80% |
|
||||
| `pytest` | `rtk pytest` | 4x | 8,000 | 800 | -90% |
|
||||
| `go test` | `rtk gotest` | 3x | 6,000 | 600 | -90% |
|
||||
| `docker ps` | `rtk docker` | - | - | - | - |
|
||||
|
||||
### Usage Examples
|
||||
|
||||
```bash
|
||||
# Instead of: ls -la src/
|
||||
rtk ls src/
|
||||
|
||||
# Instead of: cat package.json
|
||||
rtk read package.json
|
||||
|
||||
# Instead of: grep -r "useEffect" src/
|
||||
rtk grep "useEffect" src/
|
||||
|
||||
# Instead of: git status
|
||||
rtk git status
|
||||
|
||||
# Instead of: npm test
|
||||
rtk test
|
||||
|
||||
# Instead of: ruff check .
|
||||
rtk lint
|
||||
```
|
||||
**Source de vérité du schéma : `supabase/migrations/001` → `006`.** Jamais `schema.sql`.
|
||||
|
||||
---
|
||||
|
||||
## 📱 Expo Best Practices
|
||||
## 1. Identité produit
|
||||
|
||||
### Development Workflow
|
||||
|
||||
**CRITICAL: Always try Expo Go first before creating custom builds.**
|
||||
|
||||
1. **Start with Expo Go**: Run `npx expo start` and scan the QR code with Expo Go
|
||||
2. **Check if features work**: Test your app thoroughly in Expo Go
|
||||
3. **Only create custom builds when required**
|
||||
|
||||
#### When Custom Builds Are Required
|
||||
|
||||
Use `npx expo run:ios/android` or `eas build` ONLY when using:
|
||||
- **Local Expo modules** (custom native code in `modules/`)
|
||||
- **Apple targets** (widgets, app clips, extensions via `@bacons/apple-targets`)
|
||||
- **Third-party native modules** not included in Expo Go
|
||||
- **Custom native configuration** that can't be expressed in `app.json`
|
||||
|
||||
#### When Expo Go Works
|
||||
|
||||
Expo Go supports a huge range of features out of the box:
|
||||
- All `expo-*` packages (camera, location, notifications, etc.)
|
||||
- Expo Router navigation
|
||||
- Most UI libraries (reanimated, gesture handler, etc.)
|
||||
- Push notifications, deep links, and more
|
||||
|
||||
### Common Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
npx expo start # Start development server
|
||||
npx expo start --tunnel # If network issues
|
||||
npx expo start --clear # Clear cache
|
||||
npx tsc --noEmit # Type check
|
||||
npx expo install <pkg> # Install Expo-compatible packages
|
||||
|
||||
# Building
|
||||
npx eas-cli@latest build --profile development # Dev build
|
||||
npx eas-cli@latest build -p ios --profile production --submit # Build and submit iOS
|
||||
npx testflight # Quick TestFlight shortcut
|
||||
|
||||
# Development Client (when native code changes)
|
||||
npx expo start --dev-client
|
||||
```
|
||||
|
||||
### Code Style Rules
|
||||
|
||||
#### File Naming
|
||||
- Use kebab-case for file names (e.g., `workout-card.tsx`)
|
||||
- Never use special characters in file names
|
||||
- Always remove old route files when moving or restructuring navigation
|
||||
|
||||
#### Imports
|
||||
- Use path aliases from `tsconfig.json` instead of relative imports
|
||||
- Prefer aliases over relative imports for refactors
|
||||
- Always use import statements at the top of the file
|
||||
|
||||
#### Library Preferences
|
||||
- ✅ `expo-audio` not `expo-av`
|
||||
- ✅ `expo-video` not `expo-av`
|
||||
- ✅ `expo-image` with `source="sf:name"` for SF Symbols
|
||||
- ✅ `react-native-safe-area-context` not react-native SafeAreaView
|
||||
- ✅ `process.env.EXPO_OS` not `Platform.OS`
|
||||
- ✅ `React.use` not `React.useContext`
|
||||
- ❌ Never use modules removed from React Native: Picker, WebView, SafeAreaView, AsyncStorage
|
||||
- ❌ Never use legacy expo-permissions
|
||||
- ❌ Never use intrinsic elements like 'img' or 'div' unless in webview
|
||||
|
||||
### Route Structure
|
||||
|
||||
```
|
||||
app/
|
||||
_layout.tsx # Root layout with tabs
|
||||
(tabs)/ # Group routes for tabs
|
||||
_layout.tsx # Tabs layout
|
||||
index.tsx # Home tab
|
||||
workouts.tsx # Workouts tab
|
||||
activity.tsx # Activity tab
|
||||
browse.tsx # Browse tab
|
||||
profile.tsx # Profile tab
|
||||
player/
|
||||
[id].tsx # Workout player
|
||||
```
|
||||
|
||||
#### Route Rules
|
||||
- Routes belong in the `app` directory
|
||||
- Never co-locate components, types, or utilities in the app directory
|
||||
- Ensure the app always has a route that matches "/"
|
||||
- Always use `_layout.tsx` files to define stacks
|
||||
|
||||
### UI Guidelines
|
||||
|
||||
#### Responsiveness
|
||||
- Always wrap root component in a scroll view for responsiveness
|
||||
- Use `<ScrollView contentInsetAdjustmentBehavior="automatic" />` instead of `<SafeAreaView>`
|
||||
- `contentInsetAdjustmentBehavior="automatic"` should be applied to FlatList and SectionList
|
||||
- Use flexbox instead of Dimensions API
|
||||
- ALWAYS prefer `useWindowDimensions` over `Dimensions.get()`
|
||||
|
||||
#### Styling
|
||||
- Prefer flex gap over margin and padding styles
|
||||
- Prefer padding over margin where possible
|
||||
- Inline styles not StyleSheet.create unless reusing styles is faster
|
||||
- Use `{ borderCurve: 'continuous' }` for rounded corners
|
||||
- ALWAYS use a navigation stack title instead of a custom text element
|
||||
- When padding a ScrollView, use `contentContainerStyle` padding
|
||||
- Add entering and exiting animations for state changes
|
||||
|
||||
#### Shadows
|
||||
Use CSS `boxShadow` style prop. NEVER use legacy React Native shadow or elevation styles.
|
||||
|
||||
```tsx
|
||||
<View style={{ boxShadow: "0 1px 2px rgba(0, 0, 0, 0.05)" }} />
|
||||
```
|
||||
|
||||
#### Text Styling
|
||||
- Add the `selectable` prop to every `<Text/>` element displaying important data
|
||||
- Counters should use `{ fontVariant: 'tabular-nums' }` for alignment
|
||||
|
||||
#### Safe Area
|
||||
- Always account for safe area with stack headers, tabs, or ScrollView/FlatList `contentInsetAdjustmentBehavior="automatic"`
|
||||
- Ensure both top and bottom safe area insets are accounted for
|
||||
|
||||
### Navigation Patterns
|
||||
|
||||
#### Stack Configuration
|
||||
|
||||
```tsx
|
||||
import { Stack } from 'expo-router/stack';
|
||||
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerTransparent: true,
|
||||
headerShadowVisible: false,
|
||||
headerLargeTitleShadowVisible: false,
|
||||
headerLargeStyle: { backgroundColor: "transparent" },
|
||||
headerTitleStyle: { color: PlatformColor("label") },
|
||||
headerLargeTitle: true,
|
||||
headerBlurEffect: "none",
|
||||
headerBackButtonDisplayMode: "minimal",
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="index" options={{ title: "Home" }} />
|
||||
</Stack>
|
||||
```
|
||||
|
||||
#### Links with Previews
|
||||
|
||||
```tsx
|
||||
import { Link } from 'expo-router';
|
||||
|
||||
<Link href="/settings">
|
||||
<Link.Trigger>
|
||||
<Pressable>
|
||||
<Card />
|
||||
</Pressable>
|
||||
</Link.Trigger>
|
||||
<Link.Preview />
|
||||
<Link.Menu>
|
||||
<Link.MenuAction title="Share" icon="square.and.arrow.up" />
|
||||
<Link.MenuAction title="Delete" icon="trash" destructive />
|
||||
</Link.Menu>
|
||||
</Link>
|
||||
```
|
||||
|
||||
#### Modal/Sheet Presentations
|
||||
|
||||
```tsx
|
||||
// Modal
|
||||
<Stack.Screen name="modal" options={{ presentation: "modal" }} />
|
||||
|
||||
// Form Sheet
|
||||
<Stack.Screen
|
||||
name="sheet"
|
||||
options={{
|
||||
presentation: "formSheet",
|
||||
sheetGrabberVisible: true,
|
||||
sheetAllowedDetents: [0.5, 1.0],
|
||||
contentStyle: { backgroundColor: "transparent" },
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Data Fetching
|
||||
|
||||
#### Environment Variables
|
||||
- Use `EXPO_PUBLIC_` prefix for client-side environment variables
|
||||
- Never put secrets in `EXPO_PUBLIC_` variables (visible in built app)
|
||||
- Restart the dev server after changing `.env` files
|
||||
|
||||
```bash
|
||||
# .env
|
||||
EXPO_PUBLIC_API_URL=https://api.example.com
|
||||
EXPO_PUBLIC_SUPABASE_URL=your-supabase-url
|
||||
```
|
||||
|
||||
#### API Client Pattern
|
||||
|
||||
```tsx
|
||||
const BASE_URL = process.env.EXPO_PUBLIC_API_URL;
|
||||
|
||||
export const apiClient = {
|
||||
get: async <T,>(path: string): Promise<T> => {
|
||||
const response = await fetch(`${BASE_URL}${path}`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json();
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
#### React Query Setup
|
||||
|
||||
```tsx
|
||||
// app/_layout.tsx
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
retry: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### EAS Build Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 16.0.1",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"production": {
|
||||
"autoIncrement": true,
|
||||
"ios": {
|
||||
"resourceClass": "m-medium"
|
||||
}
|
||||
},
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Testing & Type Checking
|
||||
|
||||
```bash
|
||||
# TypeScript
|
||||
npx tsc --noEmit
|
||||
|
||||
# Run unit tests (Vitest)
|
||||
npm test
|
||||
|
||||
# Run tests in watch mode
|
||||
npm run test:watch
|
||||
|
||||
# Run tests with coverage report
|
||||
npm run test:coverage
|
||||
|
||||
# Run Maestro E2E tests
|
||||
npm run test:maestro
|
||||
|
||||
# Lint
|
||||
npx eslint .
|
||||
```
|
||||
|
||||
#### Test Structure
|
||||
```
|
||||
src/__tests__/
|
||||
setup.ts # Mocks and test configuration
|
||||
stores/ # Zustand store tests
|
||||
hooks/ # React hooks tests
|
||||
services/ # Service layer tests
|
||||
components/ # Component logic tests
|
||||
data/ # Data validation tests
|
||||
```
|
||||
|
||||
#### Coverage Goals
|
||||
- **Stores**: 80%+ (business logic)
|
||||
- **Services**: 80%+ (API integration)
|
||||
- **Hooks**: 70%+ (timer, purchases)
|
||||
- **Components**: 50%+ (critical UI)
|
||||
|
||||
### Key Takeaways
|
||||
|
||||
1. **Start simple**: Always test in Expo Go before creating custom builds
|
||||
2. **Follow conventions**: Use Expo Router patterns, kebab-case filenames
|
||||
3. **Use modern APIs**: Prefer `expo-audio`, `expo-video`, `expo-image`
|
||||
4. **Handle safe areas**: Use `contentInsetAdjustmentBehavior="automatic"`
|
||||
5. **Style with CSS**: Use `boxShadow` instead of legacy shadow props
|
||||
6. **Type everything**: Use TypeScript strict mode, no `any`
|
||||
7. **Secure tokens**: Use `expo-secure-store` for sensitive data
|
||||
8. **Environment config**: Use `EXPO_PUBLIC_` prefix for client env vars
|
||||
| | |
|
||||
|---|---|
|
||||
| **Nom** | TabataGo |
|
||||
| **Bundle** | `fr.millianlmx.tabatago` |
|
||||
| **Team** | `2MJF39L8VY` |
|
||||
| **But** | App d'entraînement Tabata (intervalles 20s travail / 10s repos) sur iOS + watchOS, avec programmes par zone corporelle, HealthKit, musique, et abonnements RevenueCat |
|
||||
| **Version** | 1.0 (build 2) — non publiée App Store |
|
||||
| **Propriétaire** | Millian LMX (CEO) |
|
||||
| **Gitea** | `https://gitea.1000co.fr/millianlmx/tabatago` |
|
||||
|
||||
---
|
||||
|
||||
## 📝 Project Context
|
||||
## 2. Stack technique
|
||||
|
||||
**TabataFit** — "Apple Fitness+ for Tabata"
|
||||
|
||||
- **Framework**: Expo SDK 52 (managed)
|
||||
- **Navigation**: Expo Router v3
|
||||
- **State**: Zustand + AsyncStorage
|
||||
- **Video**: expo-av → HLS streaming
|
||||
- **Audio**: expo-av (coaching + music)
|
||||
- **Animations**: React Native Animated
|
||||
- **Payments**: RevenueCat
|
||||
- **Analytics**: PostHog
|
||||
|
||||
### Design System
|
||||
|
||||
```typescript
|
||||
BACKGROUND: '#000000' // Pure black
|
||||
SURFACE: '#1C1C1E' // Charcoal
|
||||
BRAND: '#FF6B35' // Flame orange
|
||||
REST: '#5AC8FA' // Ice blue
|
||||
SUCCESS: '#30D158' // Energy green
|
||||
```
|
||||
|
||||
### Phase Colors (Critical)
|
||||
|
||||
```typescript
|
||||
PREP: '#FF9500' // Orange-yellow
|
||||
WORK: '#FF6B35' // Flame orange
|
||||
REST: '#5AC8FA' // Ice blue
|
||||
COMPLETE: '#30D158' // Green
|
||||
```
|
||||
| Couche | Réalité vérifiée |
|
||||
|--------|------------------|
|
||||
| Langage | **Swift 6.0**, concurrence stricte (`SWIFT_STRICT_CONCURRENCY: complete`) |
|
||||
| UI | **SwiftUI** uniquement |
|
||||
| iOS | **26.0+** |
|
||||
| watchOS | **11.0+** |
|
||||
| Build | **XcodeGen** — `project.yml` est la source, `.xcodeproj` est généré |
|
||||
| SPM | **Supabase** `2.5.0+`, **RevenueCat** `5.0.0+` |
|
||||
| ❌ NON installé | **PostHog** — absent de `project.yml` packages. Voir §3. |
|
||||
| Architecture | **MVVM + `@Observable`**, `AppState.shared` central |
|
||||
| Backend | **Supabase** (PostgreSQL, Auth, Storage, Edge Functions Deno) |
|
||||
| Local cache | **SwiftData** (`TabataGoSchema.container`) |
|
||||
| Santé | **HealthKit** (fréquence cardiaque, calories, workouts) |
|
||||
| CI/CD | **Gitea Actions** sur runner macOS auto-hébergé (label `macos`) |
|
||||
|
||||
---
|
||||
|
||||
*Last updated: March 14, 2026*
|
||||
## 3. Architecture iOS
|
||||
|
||||
# context-mode — MANDATORY routing rules
|
||||
### Cibles (`tabatago-swift/project.yml`)
|
||||
|
||||
You have context-mode MCP tools available. These rules are NOT optional — they protect your context window from flooding. A single unrouted command can dump 56 KB into context and waste the entire session.
|
||||
| Cible | Type | Plateforme | Bundle ID |
|
||||
|-------|------|------------|-----------|
|
||||
| `TabataGo` | application | iOS 26.0 | `fr.millianlmx.tabatago` |
|
||||
| `TabataGoWatch` | application | watchOS 11.0 | `fr.millianlmx.tabatago.watchkitapp` |
|
||||
| `TabataGoWatchWidget` | app-extension | watchOS 11.0 | `fr.millianlmx.tabatago.watchkitapp.widget` |
|
||||
| `TabataGoTests` | bundle.unit-test | iOS | `.tests` |
|
||||
| `TabataGoUITests` | bundle.ui-testing | iOS | `.uitests` |
|
||||
|
||||
## BLOCKED commands — do NOT attempt these
|
||||
**Dépendances d'intégration** : `TabataGo` embed `TabataGoWatch` qui embed `TabataGoWatchWidget`.
|
||||
Fichier partagé entre iOS et watch : `TabataGo/Services/WatchConnectivityTypes.swift` (compilé dans les deux targets via `group: TabataGoWatch/Services`).
|
||||
|
||||
### curl / wget — BLOCKED
|
||||
Any shell command containing `curl` or `wget` will be intercepted and blocked by the context-mode plugin. Do NOT retry.
|
||||
Instead use:
|
||||
- `context-mode_ctx_fetch_and_index(url, source)` to fetch and index web pages
|
||||
- `context-mode_ctx_execute(language: "javascript", code: "const r = await fetch(...)")` to run HTTP calls in sandbox
|
||||
### Bootstrap & data flow
|
||||
|
||||
### Inline HTTP — BLOCKED
|
||||
Any shell command containing `fetch('http`, `requests.get(`, `requests.post(`, `http.get(`, or `http.request(` will be intercepted and blocked. Do NOT retry with shell.
|
||||
Instead use:
|
||||
- `context-mode_ctx_execute(language, code)` to run HTTP calls in sandbox — only stdout enters context
|
||||
|
||||
### Direct web fetching — BLOCKED
|
||||
Do NOT use any direct URL fetching tool. Use the sandbox equivalent.
|
||||
Instead use:
|
||||
- `context-mode_ctx_fetch_and_index(url, source)` then `context-mode_ctx_search(queries)` to query the indexed content
|
||||
|
||||
## REDIRECTED tools — use sandbox equivalents
|
||||
|
||||
### Shell (>20 lines output)
|
||||
Shell is ONLY for: `git`, `mkdir`, `rm`, `mv`, `cd`, `ls`, `npm install`, `pip install`, and other short-output commands.
|
||||
For everything else, use:
|
||||
- `context-mode_ctx_batch_execute(commands, queries)` — run multiple commands + search in ONE call
|
||||
- `context-mode_ctx_execute(language: "shell", code: "...")` — run in sandbox, only stdout enters context
|
||||
|
||||
### File reading (for analysis)
|
||||
If you are reading a file to **edit** it → reading is correct (edit needs content in context).
|
||||
If you are reading to **analyze, explore, or summarize** → use `context-mode_ctx_execute_file(path, language, code)` instead. Only your printed summary enters context.
|
||||
|
||||
### grep / search (large results)
|
||||
Search results can flood context. Use `context-mode_ctx_execute(language: "shell", code: "grep ...")` to run searches in sandbox. Only your printed summary enters context.
|
||||
|
||||
## Tool selection hierarchy
|
||||
|
||||
1. **GATHER**: `context-mode_ctx_batch_execute(commands, queries)` — Primary tool. Runs all commands, auto-indexes output, returns search results. ONE call replaces 30+ individual calls.
|
||||
2. **FOLLOW-UP**: `context-mode_ctx_search(queries: ["q1", "q2", ...])` — Query indexed content. Pass ALL questions as array in ONE call.
|
||||
3. **PROCESSING**: `context-mode_ctx_execute(language, code)` | `context-mode_ctx_execute_file(path, language, code)` — Sandbox execution. Only stdout enters context.
|
||||
4. **WEB**: `context-mode_ctx_fetch_and_index(url, source)` then `context-mode_ctx_search(queries)` — Fetch, chunk, index, query. Raw HTML never enters context.
|
||||
5. **INDEX**: `context-mode_ctx_index(content, source)` — Store content in FTS5 knowledge base for later search.
|
||||
|
||||
## Output constraints
|
||||
|
||||
- Keep responses under 500 words.
|
||||
- Write artifacts (code, configs, PRDs) to FILES — never return them as inline text. Return only: file path + 1-line description.
|
||||
- When indexing content, use descriptive source labels so others can `search(source: "label")` later.
|
||||
|
||||
## ctx commands
|
||||
|
||||
| Command | Action |
|
||||
|---------|--------|
|
||||
| `ctx stats` | Call the `stats` MCP tool and display the full output verbatim |
|
||||
| `ctx doctor` | Call the `doctor` MCP tool, run the returned shell command, display as checklist |
|
||||
| `ctx upgrade` | Call the `upgrade` MCP tool, run the returned shell command, display as checklist |
|
||||
|
||||
<!-- 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.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
## Always Do
|
||||
|
||||
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- 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.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||
- 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 |
|
||||
|----------|---------|
|
||||
| `gitnexus://repo/tabatago/context` | Codebase overview, check index freshness |
|
||||
| `gitnexus://repo/tabatago/clusters` | All functional areas |
|
||||
| `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
|
||||
```
|
||||
TabataGoApp (@main)
|
||||
└─ RootView()
|
||||
├─ .environment(AppState.shared) // @Observable singleton
|
||||
├─ .modelContainer(TabataGoSchema.container) // SwiftData
|
||||
└─ .task { await AppState.shared.bootstrap() }
|
||||
```
|
||||
|
||||
If the index previously included embeddings, preserve them by adding `--embeddings`:
|
||||
`AppState.bootstrap()` (MainActor, idempotent, skip en preview) :
|
||||
1. `PurchaseService.shared.initialize()` (RevenueCat)
|
||||
2. `AnalyticsService.shared.initialize()` — ⚠️ **no-op par défaut** car PostHog n'est pas dans SPM : tout est gardé par `#if canImport(PostHog)` qui est toujours faux. La taxonomie d'events existe mais n'émet rien tant que la dépendance n'est pas ajoutée.
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze --embeddings
|
||||
### Navigation (PAS de NavigationStack)
|
||||
|
||||
`MainTabView` = **`TabView` + `Tab(value:)`** (Liquid Glass, iOS 26), 4 onglets dans cet ordre exact du code :
|
||||
|
||||
```
|
||||
home → programs → activity → profile
|
||||
```
|
||||
|
||||
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.**
|
||||
Modals full-screen via `.sheet`/`fullScreenCover` au-dessus :
|
||||
`PlayerView`, `CompletionView`, `PaywallView`, `OnboardingView`.
|
||||
|
||||
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
|
||||
### Services (`TabataGo/Services/`)
|
||||
|
||||
## CLI
|
||||
| Service | Rôle |
|
||||
|---------|------|
|
||||
| `SupabaseService` | Client Supabase (Auth, DB, Storage) |
|
||||
| `HealthKitService` | Lecture/écriture HealthKit |
|
||||
| `PurchaseService` | RevenueCat IAP, état d'abonnement |
|
||||
| `MusicService` | Apple Music + intégration piste YouTube |
|
||||
| `AudioService` | Playback audio (coachs sonores, alerts) |
|
||||
| `AnalyticsService` | **Stub PostHog** (voir ci-dessus) |
|
||||
| `PhoneConnectivityManager` | Côté iOS — WatchConnectivity (échange avec la montre) |
|
||||
| `WatchConnectivityTypes` | Protocoles/messages partagés iOS↔Watch (compilé dans les 2 targets) |
|
||||
|
||||
| Task | Read this skill file |
|
||||
|------|---------------------|
|
||||
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
||||
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
||||
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
||||
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||
> Le miroir côté watch est `TabataGoWatch/Services/WatchConnectivityManager.swift` (cible watchOS, pas iOS).
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
### ViewModels (`@Observable`)
|
||||
|
||||
`HomeViewModel`, `HealthViewModel`, `PlayerViewModel`, `PurchaseViewModel`, `MusicPlayerViewModel`.
|
||||
|
||||
### Models (`TabataGo/Models/`)
|
||||
|
||||
`WorkoutProgram`, `WorkoutSession`, `UserProfile`, `HealthSnapshot`, `MusicTrack`, `WorkoutActivityAttributes`, `MusicActivityAttributes` (Live Activities), `TabataGoSchema` (SwiftData), `PreviewData`/`MockPrograms` (previews).
|
||||
|
||||
### Watch (`TabataGoWatch/`)
|
||||
|
||||
`TabataGoWatchApp` → `WatchRootView` → états `WatchIdleView` / `WatchActivityView` / `WatchPlayerView`.
|
||||
Moteur : `WatchPlayerEngine`. Connectivité : `WatchConnectivityManager` (coté watch).
|
||||
Complications : `TabataGoComplication`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Base de données
|
||||
|
||||
### Supabase — source = `supabase/migrations/` (001→006)
|
||||
|
||||
| # | Fichier | Contenu |
|
||||
|---|---------|---------|
|
||||
| 001 | `001_initial_schema.sql` | `trainers`, `workouts`, `collections`, `achievements`, `admin_users` |
|
||||
| 002 | `002_download_jobs.sql` | `download_jobs`, `download_items` (jobs d'import playlist YouTube) |
|
||||
| 003 | `003_music_genre.sql` | `music_genre` |
|
||||
| 004 | `004_download_items_public_read.sql` | RLS : lecture publique de `download_items` |
|
||||
| 005 | `005_workout_programs.sql` | **DROP** `programs`/`program_workouts` → **CREATE** `workout_programs` (body_zone enum `upper-body`/`lower-body`/`full-body` + level `Beginner`/`Intermediate`/`Advanced`) et `program_tabatas` (3 tabatas/program, 2 exercices chacun, 8 rounds × 20s/10s) + RLS public read + admin all |
|
||||
| 006 | `006_seed_workout_programs.sql` | **18 programmes seedés** (12 free : 4 par zone en 2B+1I+1A ; 6 premium) + **54 tabatas** (18×3), UUIDs stables |
|
||||
|
||||
**Tables actives post-migration** : `trainers`, `workouts`, `collections`, `achievements`, `admin_users`, `download_jobs`, `download_items`, `music_genre`, `workout_programs`, `program_tabatas`.
|
||||
|
||||
**Tables supprimées (ne pas recréer)** : `programs`, `program_workouts` (DROP en 005).
|
||||
|
||||
**RLS** : lecture publique sur `workout_programs`/`program_tabatas`/`download_items` ; écriture restreinte aux `admin_users` (`EXISTS (SELECT 1 FROM admin_users WHERE id = auth.uid())`).
|
||||
|
||||
### SwiftData — cache local
|
||||
|
||||
`TabataGoSchema.container` (et `.previewContainer` pour les `#Preview`). Persistance offline des sessions/programmes côté app. Ne pas confondre avec Supabase.
|
||||
|
||||
---
|
||||
|
||||
## 5. CI/CD
|
||||
|
||||
### Workflow : `.github/workflows/pr-iphone-deploy.yml`
|
||||
|
||||
**Trigger** : PR ouverte/synchronize/reopened sur `main`.
|
||||
**Runner** : label `macos` (auto-hébergé).
|
||||
**Déroulé** :
|
||||
|
||||
1. **Checkout** (shallow, branche head) via `PR_API_TOKEN`.
|
||||
2. **Setup PATH** : `echo "/opt/homebrew/bin" >> $GITHUB_PATH` — requis car Rosetta ne le voit pas.
|
||||
3. **Clean ciblé** :
|
||||
- Supprime build artifacts, `TabataGo.xcodeproj`, `Package.resolved`, DerivedData, ModuleCache.
|
||||
- **GARDE le cache SPM** (`../build/spm-cache`) — RevenueCat ≈ 1.1 GiB, re-cloner = 5+ min et échecs aléatoires du sandbox.
|
||||
4. **Install tools** : `brew install xcodegen node ios-deploy`.
|
||||
5. **xcodegen generate** → `xcodebuild -resolvePackageDependencies` → **build** (`-scheme TabataGo`, Debug, auto-provisioning, team `2MJF39L8VY`).
|
||||
6. **Deploy iPhone** UDID `00008120-000925CE3672201E` : `devicectl` WiFi d'abord, fallback `ios-deploy` USB.
|
||||
7. **Post comment** "Prêt à tester" sur la PR.
|
||||
8. **Job `wait-approval`** : poll (30s, max 240 = 2h) les commentaires de la PR. `LGTM` → auto-merge. `KO` → blocage. Timeout → fail.
|
||||
|
||||
### Secrets
|
||||
|
||||
| Secret | Usage |
|
||||
|--------|-------|
|
||||
| `PR_API_TOKEN` | Checkout + API Gitea (comment, merge). **JAMAIS `GITEA_TOKEN`/`GITHUB_TOKEN`** |
|
||||
| `SUPABASE_URL`, `SUPABASE_ANON_KEY`, `REVENUECAT_API_KEY`, `POSTHOG_API_KEY` | Injectés via `Config/Secrets.xcconfig` → Info.plist |
|
||||
|
||||
### ⚠️ Pitfalls CI
|
||||
|
||||
- `/opt/homebrew/bin` **hors PATH par défaut** sous Rosetta → toujours l'ajouter.
|
||||
- **xcodegen 2.45.4 ne génère pas de schemes auto** → scheme `TabataGo` défini explicitement dans `project.yml` (ne pas le supprimer).
|
||||
- **Ne jamais `rm -rf` le cache SPM** dans le CI (c'est l'inverse des build artifacts).
|
||||
- `SWIFT_ENABLE_EXPLICIT_MODULES=NO` au build — requis sinon segfault linker sur `.pcm` stale.
|
||||
- `-skipPackagePluginValidation -allowProvisioningUpdates`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Sous-projets
|
||||
|
||||
### `admin-web/` — Dashboard admin
|
||||
|
||||
Next.js 15 App Router, shadcn/ui. Gestion workouts / trainers / collections / programs.
|
||||
Auth Supabase → table `admin_users`. Stack : TypeScript, `middleware.ts`, tests Playwright (`e2e/`) + Vitest.
|
||||
|
||||
### `youtube-worker/` — Worker YouTube
|
||||
|
||||
Node.js (`server.js`, `package.json`, `Dockerfile`). Télécharge l'audio de playlists YouTube → **Supabase Storage**. Piloté par les Edge Functions ci-dessous.
|
||||
|
||||
### `supabase/functions/` — Edge Functions (Deno)
|
||||
|
||||
| Function | Rôle |
|
||||
|----------|------|
|
||||
| `youtube-playlist/` | Crée un `download_job`, liste les vidéos |
|
||||
| `youtube-process/` | Orchestre le téléchargement (via youtube-worker) |
|
||||
| `youtube-status/` | Statut d'un job |
|
||||
| `youtube-classify/` | Classification genre musical |
|
||||
| `main/` | Auth JWT (helper `jose`) |
|
||||
| `_shared/` | `auth.ts`, `cors.ts`, `supabase-client.ts`, `youtube-client.ts` |
|
||||
|
||||
> Le pipeline musical complet : Edge Functions orchestrent → `youtube-worker` (Node.js) télécharge via **yt-dlp** + **Innertube** → audio dans Supabase Storage (`workout-audio` bucket) → classification automatique par **Gemini** (`gemini-3.1-flash-lite-preview`) → métadonnées dans `download_jobs`/`download_items` → genre dans `music_genre`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Règles d'or pour les agents
|
||||
|
||||
1. **Source de vérité = `project.yml` + `supabase/migrations/`.** Tout le reste est dérivé ou obsolète.
|
||||
2. **SwiftUI only.** Pas d'UIKit sauf nécessité absolue démontrée.
|
||||
3. **`@Observable`** (macro Observation), **jamais `@ObservableObject`/`@Published`**.
|
||||
4. **Concurrency stricte.** `async/await` partout, `@MainActor` sur l'UI, pas de completion handlers.
|
||||
5. **Pas de force-unwrap.** `guard let`/`if let`, jamais `!`.
|
||||
6. **Navigation = TabView + sheet/fullScreenCover.** **Pas de `NavigationStack`.**
|
||||
7. **Tout en français** côté user-facing (`Localizable.xcstrings` / `L10n`). Codes/types restent en anglais.
|
||||
8. **XcodeGen.** Éditer `project.yml`, pas `.xcodeproj`. Régénérer avec `xcodegen generate`.
|
||||
9. **Secrets.** Jamais committer `Config/Secrets.xcconfig`. Template = `.example`.
|
||||
10. **Lazy d'abord.** Avant d'ajouter une dépendance : stdlib Apple, puis ce qui existe déjà dans le repo, puis SPM déjà installé. PostHog n'est pas installé — ne pas l'ajouter sans justification.
|
||||
|
||||
---
|
||||
|
||||
## 8. Anti-patterns / pièges documentés
|
||||
|
||||
| ❌ Ne pas faire | ✅ Faire |
|
||||
|-----------------|---------|
|
||||
| Lire `CLAUDE.md` ou `schema.sql` pour le schéma | Lire `supabase/migrations/001→006` |
|
||||
| Importer PostHog comme si c'était actif | Savoir que `AnalyticsService` est un **no-op** (PostHog pas en SPM) |
|
||||
| Utiliser `NavigationStack` | `TabView` + modals |
|
||||
| `@ObservableObject` / `@Published` | `@Observable` |
|
||||
| Force-unwrap `!` | `guard let` |
|
||||
| Strings UI en anglais | Françaises dans `L10n`/`Localizable.xcstrings` |
|
||||
| `rm -rf` le cache SPM en CI | Garder `build/spm-cache` |
|
||||
| Utiliser `GITEA_TOKEN` en CI | `PR_API_TOKEN` uniquement |
|
||||
| Supprimer le scheme explicite dans `project.yml` | xcodegen 2.45.4 n'en crée pas |
|
||||
| Compter sur `admin-web/` pour la app iOS | Dashboard admin séparé, communique via Supabase uniquement |
|
||||
| Recréer `programs`/`program_workouts` | Remplacés par `workout_programs`/`program_tabatas` (migration 005) |
|
||||
|
||||
---
|
||||
|
||||
## 9. Contexte projet & skills
|
||||
|
||||
- **Repo Gitea** : `https://gitea.1000co.fr/millianlmx/tabatago` (branche intégration : `main`).
|
||||
- **Équipe** : "Millian Team" — équipe d'agents IA. Skills à loader selon la tâche :
|
||||
- `senior-ios` — SwiftUI, Xcode, iOS/watchOS
|
||||
- `senior-backend` — Supabase, SQL, Edge Functions
|
||||
- `senior-devops` — CI/CD Gitea Actions, deploy
|
||||
- `po-pm` — vision produit, specs, validation
|
||||
- **Team skill TabataGo** : documente les anti-patterns ci-dessus ; aligne tous les agents sur les mêmes conventions.
|
||||
- **Hermes profile actif** : `default`. Les skills vivent dans le profile sous `skills/`.
|
||||
- **Docs utiles** : `docs/ci-cd-setup.md`, `docs/app-store-submission.md`, `docs/maestro-e2e-testing-strategy.md`, `docs/ui-feature-brief.md`.
|
||||
- **Scripts** : `scripts/ci-status.py`, `scripts/deploy-functions.sh`.
|
||||
|
||||
### Commandes usuelles
|
||||
|
||||
```bash
|
||||
# Générer le projet Xcode
|
||||
cd tabatago-swift && xcodegen generate
|
||||
|
||||
# Ouvrir
|
||||
open tabatago-swift/TabataGo.xcodeproj
|
||||
|
||||
# Déployer les Edge Functions
|
||||
bash scripts/deploy-functions.sh
|
||||
|
||||
# Statut CI
|
||||
python3 scripts/ci-status.py
|
||||
```
|
||||
|
||||
142
AGENTS.md.old
Normal file
142
AGENTS.md.old
Normal file
@@ -0,0 +1,142 @@
|
||||
# TabataGo — iOS Tabata Workout App
|
||||
|
||||
> Tabata interval training app for iOS + watchOS with HealthKit integration.
|
||||
|
||||
## Agent Context
|
||||
|
||||
This project is part of the **Millian Team** — an AI agent engineering team.
|
||||
When working here, agents should load the relevant skill:
|
||||
- `senior-ios` — SwiftUI, Xcode, iOS/watchOS development
|
||||
- `senior-backend` — Supabase backend
|
||||
- `senior-devops` — Deploy infrastructure
|
||||
- `po-pm` — Product vision, specs, validation
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Tech |
|
||||
|-------|------|
|
||||
| App | SwiftUI, iOS 26.0+ |
|
||||
| Watch | SwiftUI, watchOS 11.0+ |
|
||||
| Widget | WidgetKit, Live Activities |
|
||||
| Language | Swift 6.0 (strict concurrency) |
|
||||
| Architecture | MVVM, @Observable, AppState central |
|
||||
| Backend | Supabase (PostgreSQL, Auth, Storage) |
|
||||
| Subscriptions | RevenueCat 5.x |
|
||||
| Analytics | PostHog 3.x |
|
||||
| Health | HealthKit (heart rate, calories, workouts) |
|
||||
| Music | Apple Music integration + YouTube worker |
|
||||
| Build | XcodeGen (project.yml → .xcodeproj) |
|
||||
| Package Manager | Swift Package Manager |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
tabatago/
|
||||
├── tabatago-swift/ # Main Xcode project
|
||||
│ ├── project.yml # XcodeGen spec
|
||||
│ ├── Config/
|
||||
│ │ ├── Secrets.xcconfig # API keys (not committed)
|
||||
│ │ └── Secrets.xcconfig.example
|
||||
│ ├── TabataGo/ # iOS App
|
||||
│ │ ├── App/ # App entry, state, root view
|
||||
│ │ │ ├── TabataGoApp.swift
|
||||
│ │ │ ├── RootView.swift
|
||||
│ │ │ └── AppState.swift
|
||||
│ │ ├── Views/
|
||||
│ │ │ ├── Tabs/ # MainTabView, Home, Activity, Programs, Profile
|
||||
│ │ │ ├── Player/ # Workout player (PlayerView, NowPlaying)
|
||||
│ │ │ ├── Programs/ # ProgramDetail, BodyZone
|
||||
│ │ │ ├── Onboarding/ # Onboarding flow
|
||||
│ │ │ ├── Paywall/ # RevenueCat paywall
|
||||
│ │ │ ├── Complete/ # Workout completion screen
|
||||
│ │ │ ├── Settings/ # Settings, policies
|
||||
│ │ │ └── Components/ # Shared UI components
|
||||
│ │ ├── ViewModels/ # HomeVM, HealthVM, PlayerVM, PurchaseVM, MusicVM
|
||||
│ │ ├── Models/ # WorkoutProgram, WorkoutSession, UserProfile, etc.
|
||||
│ │ ├── Services/ # Supabase, HealthKit, Music, Audio, Purchase, Analytics
|
||||
│ │ ├── Theme/ # App theme
|
||||
│ │ ├── Utilities/ # Strings, Environment
|
||||
│ │ └── Resources/ # Assets, Localizable, entitlements
|
||||
│ ├── TabataGoWatch/ # watchOS App
|
||||
│ │ ├── Views/ # WatchRoot, WatchIdle, WatchActivity, WatchPlayer
|
||||
│ │ ├── Services/ # WatchConnectivity, WatchPlayerEngine
|
||||
│ │ ├── Complications/ # Watch face complications
|
||||
│ │ └── Resources/
|
||||
│ ├── TabataGoWidget/ # iOS Widget + Live Activities
|
||||
│ ├── TabataGoTests/ # Unit tests
|
||||
│ └── TabataGoUITests/ # UI tests
|
||||
├── supabase/ # Supabase config
|
||||
│ ├── schema.sql
|
||||
│ ├── seed.sql
|
||||
│ ├── setup-admin.sql
|
||||
│ └── migrations/ # SQL migrations (001-006)
|
||||
└── youtube-worker/ # Node.js worker for YouTube music
|
||||
├── server.js
|
||||
├── package.json
|
||||
└── Dockerfile
|
||||
```
|
||||
|
||||
## Dependencies (SPM)
|
||||
|
||||
- **Supabase** 2.5+ — Backend SDK (Auth, Database, Storage)
|
||||
- **RevenueCat** 5.0+ — In-app purchases
|
||||
- **PostHog** 3.0+ — Analytics
|
||||
|
||||
## Features
|
||||
|
||||
- Tabata/interval workout player with configurable timers
|
||||
- Pre-built workout programs by body zone
|
||||
- HealthKit integration (heart rate, calories, workout saving)
|
||||
- Apple Watch companion with real-time metrics
|
||||
- Live Activities + Dynamic Island
|
||||
- Music integration (Apple Music + YouTube)
|
||||
- RevenueCat subscriptions
|
||||
- Onboarding flow
|
||||
- Activity tracking & workout history
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# From tabatago-swift/
|
||||
xcodegen generate # Generate .xcodeproj
|
||||
open TabataGo.xcodeproj # Open in Xcode
|
||||
|
||||
# In Xcode
|
||||
Cmd+R → Run on simulator
|
||||
Cmd+U → Run tests
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
- `Config/Secrets.xcconfig` contains API keys (not committed)
|
||||
- Template: `Config/Secrets.xcconfig.example`
|
||||
- Env vars: `SUPABASE_URL`, `SUPABASE_ANON_KEY`, `REVENUECAT_API_KEY`, `POSTHOG_API_KEY`
|
||||
|
||||
## Current State
|
||||
|
||||
- **Version**: 1.0 (build 2) — not yet released
|
||||
- **State**: App functional, needs finishing work
|
||||
- **Platforms**: iOS 26.0+, watchOS 11.0+
|
||||
- **Requires**: macOS with Xcode 26+ to build
|
||||
|
||||
## Supabase Schema
|
||||
|
||||
Tables: `profiles`, `workout_sessions`, `workout_programs`, `download_jobs`, `music_genre`, etc.
|
||||
See `supabase/schema.sql` and `supabase/migrations/` for full schema.
|
||||
|
||||
## Team Rules
|
||||
|
||||
1. **Swift Concurrency** — async/await everywhere, no completion handlers
|
||||
2. **@Observable** — not @ObservableObject
|
||||
3. **No force unwrap** — `guard let` always, never `!`
|
||||
4. **SwiftUI only** — no UIKit unless unavoidable
|
||||
5. **XcodeGen** — project.yml is source of truth, not .xcodeproj
|
||||
6. **Secrets** — never commit Secrets.xcconfig
|
||||
7. **French** — all user-facing strings in French (Localizable.xcstrings)
|
||||
|
||||
## Project Context
|
||||
|
||||
- **User**: Millian LMX (CEO)
|
||||
- **Gitea**: https://gitea.1000co.fr/millianlmx/tabatago
|
||||
- **Supabase**: Managed instance (URL in secrets)
|
||||
- **App Store**: Not yet published
|
||||
@@ -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 |
|
||||
@@ -1,101 +0,0 @@
|
||||
# Authentication Setup Guide
|
||||
|
||||
## Overview
|
||||
This guide sets up full server-side authentication for the TabataFit Admin panel.
|
||||
|
||||
## Prerequisites
|
||||
- Supabase project running (local or hosted)
|
||||
- Admin-web Next.js app running
|
||||
- Database schema already applied
|
||||
|
||||
## Step 1: Configure Supabase Dashboard
|
||||
|
||||
1. Go to your Supabase Dashboard → Authentication → Providers
|
||||
2. **Enable Email Provider**
|
||||
3. **Disable "Confirm email"** (for easier testing, re-enable for production)
|
||||
4. Go to Authentication → URL Configuration
|
||||
5. Set **Site URL**: `http://localhost:3000` (or your production URL)
|
||||
6. Add **Redirect URLs**: `http://localhost:3000/**`
|
||||
|
||||
## Step 2: Create Admin User
|
||||
|
||||
### Option A: Via Supabase Dashboard (Easiest)
|
||||
1. Go to Supabase Dashboard → Authentication → Users
|
||||
2. Click "Add user" or "Invite user"
|
||||
3. Enter email: `admin@tabatafit.com`
|
||||
4. Set password: Your chosen password
|
||||
5. Click "Create user"
|
||||
6. Copy the user's UUID (click on the user to see details)
|
||||
|
||||
### Option B: Via Login Page
|
||||
1. Go to `http://localhost:3000/login`
|
||||
2. Click "Sign up" (if available) or use dashboard method above
|
||||
|
||||
## Step 3: Add User to Admin Table
|
||||
|
||||
**Important**: After creating the user, you MUST add them to the admin_users table.
|
||||
|
||||
Run this SQL in Supabase SQL Editor:
|
||||
|
||||
```sql
|
||||
-- Replace with your actual email or UUID
|
||||
INSERT INTO public.admin_users (id, email, role)
|
||||
SELECT id, email, 'admin'
|
||||
FROM auth.users
|
||||
WHERE email = 'admin@tabatafit.com'
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
```
|
||||
|
||||
Or if you have the UUID:
|
||||
```sql
|
||||
INSERT INTO public.admin_users (id, email, role)
|
||||
VALUES ('paste-uuid-here', 'admin@tabatafit.com', 'admin');
|
||||
```
|
||||
|
||||
## Step 4: Verify Setup
|
||||
|
||||
Run this to confirm:
|
||||
```sql
|
||||
SELECT au.id, au.email, au.role, u.email as auth_email
|
||||
FROM public.admin_users au
|
||||
JOIN auth.users u ON au.id = u.id;
|
||||
```
|
||||
|
||||
You should see your admin user listed.
|
||||
|
||||
## Step 5: Login
|
||||
|
||||
1. Go to `http://localhost:3000/login`
|
||||
2. Enter email: `admin@tabatafit.com`
|
||||
3. Enter password: (the one you set)
|
||||
4. You should be redirected to the dashboard
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Not authorized as admin" error
|
||||
- User exists in auth.users but not in admin_users table
|
||||
- Run Step 3 SQL to add them
|
||||
|
||||
### "Failed to fetch" errors
|
||||
- Check that EXPO_PUBLIC_SUPABASE_URL is set in .env.local
|
||||
- For admin-web, also add NEXT_PUBLIC_SUPABASE_URL with same value
|
||||
|
||||
### Still can't create workouts
|
||||
- Verify you're logged in (check browser cookies)
|
||||
- Check that admin_users table has your user
|
||||
- Check RLS policies are applied correctly
|
||||
|
||||
## Default Credentials (Example)
|
||||
|
||||
**Email**: `admin@tabatafit.com`
|
||||
**Password**: (You choose during setup)
|
||||
|
||||
**Note**: Change this to a secure password in production!
|
||||
|
||||
## Security Notes
|
||||
|
||||
1. **Production**: Enable email confirmations
|
||||
2. **Production**: Use strong passwords
|
||||
3. **Production**: Enable 2FA if available
|
||||
4. **Production**: Restrict CORS origins in Supabase
|
||||
5. **Production**: Use environment-specific admin credentials
|
||||
@@ -1,218 +0,0 @@
|
||||
# TabataFit Admin Dashboard - Test Implementation Summary
|
||||
|
||||
## ✅ Completed Implementation
|
||||
|
||||
### 1. Unit Tests
|
||||
|
||||
#### UI Components (100% Coverage)
|
||||
- **button.test.tsx** ✓ (already existed)
|
||||
- **select.test.tsx** ✓ (already existed)
|
||||
- **switch.test.tsx** ✓ (already existed)
|
||||
- **dialog.test.tsx** ✓ **NEW** - 8 test cases
|
||||
- Dialog rendering
|
||||
- Open/close behavior
|
||||
- Click outside to close
|
||||
- Escape key handling
|
||||
- Footer rendering
|
||||
- Custom styling
|
||||
- Accessibility features
|
||||
|
||||
#### Custom Components
|
||||
- **exercise-list.test.tsx** ✓ (already existed)
|
||||
- **media-upload.test.tsx** ✓ (42 tests, 90%+ coverage)
|
||||
- **tag-input.test.tsx** ✓ (already existed)
|
||||
- **sidebar.test.tsx** ✓ **NEW** - 11 test cases
|
||||
- Logo and navigation rendering
|
||||
- Active state highlighting
|
||||
- Navigation links
|
||||
- Logout functionality
|
||||
- Error handling
|
||||
- Icon rendering
|
||||
- Styling verification
|
||||
|
||||
### 2. Integration Tests
|
||||
|
||||
#### Page Tests
|
||||
- **login/page.test.tsx** ✓ **NEW** - 11 test cases
|
||||
- Form rendering
|
||||
- Input validation
|
||||
- Successful login
|
||||
- Invalid credentials
|
||||
- Non-admin user handling
|
||||
- Loading states
|
||||
- Network error handling
|
||||
|
||||
- **page.test.tsx** (Dashboard) ✓ **NEW** - 12 test cases
|
||||
- Header rendering
|
||||
- Stats cards display
|
||||
- Loading states
|
||||
- Quick actions section
|
||||
- Getting started section
|
||||
- Route links
|
||||
- Error handling
|
||||
- Icon verification
|
||||
- Color classes
|
||||
|
||||
### 3. E2E Tests
|
||||
|
||||
#### Playwright Configuration ✓ **NEW**
|
||||
- Installed @playwright/test
|
||||
- Configured for Chromium and Firefox
|
||||
- Automatic dev server startup
|
||||
- Screenshot on failure
|
||||
- HTML reporting
|
||||
|
||||
#### E2E Test Suite
|
||||
- **e2e/auth.spec.ts** ✓ **NEW**
|
||||
- Login form display
|
||||
- Validation errors
|
||||
- Route protection
|
||||
- Navigation between pages
|
||||
|
||||
### 4. Test Scripts Added
|
||||
|
||||
```json
|
||||
{
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:headed": "playwright test --headed",
|
||||
"test:all": "npm run test && npm run test:e2e"
|
||||
}
|
||||
```
|
||||
|
||||
## 📊 Current Test Coverage
|
||||
|
||||
| Metric | Before | After | Target |
|
||||
|--------|--------|-------|--------|
|
||||
| Lines | 88% | ~92% | 95% |
|
||||
| Functions | 92% | ~94% | 95% |
|
||||
| Branches | 78% | ~82% | 85% |
|
||||
| Statements | 85% | ~90% | 90% |
|
||||
|
||||
## 🧪 Test Files Created
|
||||
|
||||
### New Test Files (7 files)
|
||||
1. `components/ui/dialog.test.tsx`
|
||||
2. `components/sidebar.test.tsx`
|
||||
3. `app/login/page.test.tsx`
|
||||
4. `app/page.test.tsx`
|
||||
5. `e2e/auth.spec.ts`
|
||||
6. `playwright.config.ts`
|
||||
|
||||
### Modified Files
|
||||
- `package.json` - Added E2E test scripts
|
||||
|
||||
## 🎯 Test Categories
|
||||
|
||||
### Unit Tests
|
||||
- ✅ Component rendering
|
||||
- ✅ User interactions (clicks, input)
|
||||
- ✅ State changes
|
||||
- ✅ Props validation
|
||||
- ✅ Styling verification
|
||||
- ✅ Accessibility checks
|
||||
|
||||
### Integration Tests
|
||||
- ✅ Page routing
|
||||
- ✅ Data fetching
|
||||
- ✅ Form submissions
|
||||
- ✅ Error handling
|
||||
- ✅ Loading states
|
||||
|
||||
### E2E Tests
|
||||
- ✅ Authentication flows
|
||||
- ✅ Navigation
|
||||
- ✅ Route protection
|
||||
- ✅ Cross-browser testing
|
||||
|
||||
## 🚀 How to Run Tests
|
||||
|
||||
### Unit Tests
|
||||
```bash
|
||||
# Run all unit tests
|
||||
npm run test
|
||||
|
||||
# Run with coverage
|
||||
npm run test:coverage
|
||||
|
||||
# Watch mode
|
||||
npm run test:watch
|
||||
```
|
||||
|
||||
### E2E Tests
|
||||
```bash
|
||||
# Run E2E tests
|
||||
npm run test:e2e
|
||||
|
||||
# Run with UI
|
||||
npm run test:e2e:ui
|
||||
|
||||
# Run in headed mode (see browser)
|
||||
npm run test:e2e:headed
|
||||
```
|
||||
|
||||
### All Tests
|
||||
```bash
|
||||
# Run both unit and E2E tests
|
||||
npm run test:all
|
||||
```
|
||||
|
||||
## 📋 Remaining Work (Optional)
|
||||
|
||||
### High Priority
|
||||
- [ ] workouts/page.test.tsx - Full CRUD tests
|
||||
- [ ] trainers/page.test.tsx - Delete dialog tests
|
||||
- [ ] workout-form.tsx coverage improvement (currently 71%)
|
||||
|
||||
### Medium Priority
|
||||
- [ ] collections/page.test.tsx
|
||||
- [ ] media/page.test.tsx
|
||||
- [ ] Additional E2E tests for CRUD operations
|
||||
|
||||
### Low Priority
|
||||
- [ ] Visual regression tests with Storybook
|
||||
- [ ] Performance benchmarks
|
||||
- [ ] Accessibility audit with axe
|
||||
|
||||
## 🎉 Achievements
|
||||
|
||||
✅ **All Critical Paths Tested**
|
||||
- Authentication flow
|
||||
- Dashboard stats
|
||||
- Navigation
|
||||
- Sidebar functionality
|
||||
- UI component library
|
||||
|
||||
✅ **Test Infrastructure Complete**
|
||||
- Vitest configured
|
||||
- Playwright configured
|
||||
- Mock utilities ready
|
||||
- CI/CD scripts prepared
|
||||
|
||||
✅ **Coverage Improved**
|
||||
- Added 42+ new test cases
|
||||
- Improved dialog component to 100%
|
||||
- Added sidebar component tests
|
||||
- Added page integration tests
|
||||
|
||||
## 🔧 Testing Best Practices Implemented
|
||||
|
||||
1. **Mock External Dependencies** - Supabase, Next.js router
|
||||
2. **Test User Interactions** - Clicking, typing, navigation
|
||||
3. **Verify Error States** - Network errors, auth failures
|
||||
4. **Check Accessibility** - Roles, labels, ARIA attributes
|
||||
5. **Test Responsive Behavior** - Different viewports (E2E)
|
||||
6. **Isolate Tests** - Clean mocks between tests
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- Each test file is self-documenting
|
||||
- Descriptive test names explain the behavior
|
||||
- Comments for complex mock setups
|
||||
- Type safety with TypeScript
|
||||
|
||||
---
|
||||
|
||||
**Status**: ✅ **Phase 1 Complete** - Core testing infrastructure implemented
|
||||
|
||||
**Next Steps**: Run `npm run test:all` to execute full test suite!
|
||||
71
docs/app-store-submission.md
Normal file
71
docs/app-store-submission.md
Normal 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: `fr.millianlmx.tabatago`)
|
||||
|
||||
## 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 `fr.millianlmx.tabatago` 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`
|
||||
200
docs/ci-cd-setup.md
Normal file
200
docs/ci-cd-setup.md
Normal file
@@ -0,0 +1,200 @@
|
||||
# CI/CD Setup — iPhone Deploy Workflow
|
||||
|
||||
This guide covers setting up a **Gitea Actions** macOS runner to automatically build, test, and deploy **TabataGo** to a physical iPhone on every PR to `main`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Hardware
|
||||
- **Mac** running macOS 15 (Sequoia) or later
|
||||
- **Xcode 26+** installed (required for iOS 26.0 / watchOS 11.0 targets)
|
||||
- **iPhone** (iOS 26+) with the Firebase App Tester app installed
|
||||
|
||||
### Software
|
||||
- [Gitea Actions Runner](https://docs.gitea.com/usage/actions/act-runner) installed and registered
|
||||
- `brew` (Homebrew) installed
|
||||
- `xcodegen` — `brew install xcodegen` (also installed automatically by the workflow)
|
||||
|
||||
## 1. Register the macOS Runner
|
||||
|
||||
```bash
|
||||
# On the Mac:
|
||||
./act_runner register \
|
||||
--instance https://gitea.1000co.fr \
|
||||
--token <RUNNER_TOKEN> \
|
||||
--name "macos-runner" \
|
||||
--labels macos
|
||||
|
||||
# Start the runner
|
||||
./act_runner daemon
|
||||
```
|
||||
|
||||
> **Important:** The runner must have the label `macos` because the workflow uses `runs-on: macos`.
|
||||
|
||||
## 2. Configure Secrets
|
||||
|
||||
Add these secrets in the Gitea repository:
|
||||
`Settings → Actions → Secrets`
|
||||
|
||||
| Secret Name | Description | How to Get |
|
||||
|-------------|-------------|------------|
|
||||
| `FIREBASE_APP_ID` | Firebase App ID (iOS app) | Firebase Console → Project Settings → General → App ID |
|
||||
| `FIREBASE_SERVICE_ACCOUNT` | Base64-encoded Firebase service account JSON key | See [Firebase Setup](#6-firebase-app-distribution-setup) below |
|
||||
| `GITEA_TOKEN` | Gitea API token with repo + pull request scope | `Gitea → Settings → Applications → Generate Token` |
|
||||
|
||||
### Gitea Token Permissions
|
||||
The token needs:
|
||||
- `read:repository` — to read PR metadata
|
||||
- `write:issue` — to post LGTM comments
|
||||
- `write:pull_request` — to merge approved PRs
|
||||
|
||||
## 3. Xcode Signing
|
||||
|
||||
The workflow uses **automatic code signing**. Ensure:
|
||||
|
||||
1. The Mac runner is signed into an Apple Developer account in Xcode:
|
||||
```bash
|
||||
# Verify:
|
||||
xcrun security find-identity -v -p codesigning
|
||||
```
|
||||
|
||||
2. The Apple ID used must be part of the team `2MJF39L8VY` (configured in `ExportOptions.plist`).
|
||||
|
||||
3. For first-time setup, open Xcode on the runner Mac and sign in:
|
||||
```
|
||||
Xcode → Settings → Accounts → Add Apple ID
|
||||
```
|
||||
|
||||
## 4. Workflow Overview
|
||||
|
||||
The workflow at `.github/workflows/pr-iphone-deploy.yml` triggers on PRs to `main`:
|
||||
|
||||
```
|
||||
PR Opened / Updated
|
||||
↓
|
||||
Checkout + XcodeGen generate
|
||||
↓
|
||||
Resolve SPM packages
|
||||
↓
|
||||
Build + Archive IPA
|
||||
↓
|
||||
Upload to Firebase App Distribution
|
||||
↓
|
||||
Post "Ready to test" comment on PR
|
||||
↓
|
||||
Wait for LGTM → Auto-merge
|
||||
```
|
||||
|
||||
## 5. Manual Test Run
|
||||
|
||||
To test the workflow locally on the runner Mac:
|
||||
|
||||
```bash
|
||||
# Simulate the build:
|
||||
cd tabatago-swift
|
||||
xcodegen generate
|
||||
xcodebuild build \
|
||||
-project TabataGo.xcodeproj \
|
||||
-target TabataGo \
|
||||
-sdk iphoneos \
|
||||
-destination "generic/platform=iOS" \
|
||||
-configuration Release \
|
||||
-allowProvisioningUpdates \
|
||||
-skipPackagePluginValidation \
|
||||
CODE_SIGN_STYLE=Automatic
|
||||
```
|
||||
|
||||
## 6. Firebase App Distribution Setup
|
||||
|
||||
### 6.1 Create a Firebase Project
|
||||
|
||||
1. Go to [Firebase Console](https://console.firebase.google.com)
|
||||
2. Click **Add project** (or select an existing one)
|
||||
3. Follow the wizard to create the project (Google Analytics is optional)
|
||||
|
||||
### 6.2 Enable App Distribution
|
||||
|
||||
1. In the Firebase Console, open your project
|
||||
2. Navigate to **App Distribution** in the left sidebar
|
||||
3. Click **Get started** to enable the service
|
||||
|
||||
### 6.3 Get the App ID
|
||||
|
||||
1. Go to **Project Settings** (gear icon) → **General**
|
||||
2. Scroll down to **Your apps** → find the iOS app
|
||||
3. Copy the **App ID** (looks like `1:123456789012:ios:abc123def456`)
|
||||
|
||||
### 6.4 Create a Service Account
|
||||
|
||||
1. Go to **Project Settings** → **Service accounts**
|
||||
2. Click **Create service account** (or use Firebase Admin SDK → Generate new private key)
|
||||
3. Give it a name like `github-actions-deploy`
|
||||
4. Assign the role: **Firebase App Distribution Admin**
|
||||
5. Click **Generate new private key** → download the JSON file
|
||||
|
||||
### 6.5 Encode the Service Account Key
|
||||
|
||||
```bash
|
||||
# Convert the JSON key to a base64 string (single line, no wrapping)
|
||||
base64 -i firebase-key.json | tr -d '\n'
|
||||
```
|
||||
|
||||
Copy the output — this is your `FIREBASE_SERVICE_ACCOUNT` secret value.
|
||||
|
||||
### 6.6 Add Testers and Groups
|
||||
|
||||
1. In Firebase Console, go to **App Distribution** → **Testers & Groups**
|
||||
2. Click **Add group** → name it `millian-test`
|
||||
3. Click **Add testers** → enter Millian's email
|
||||
4. Add the tester to the `millian-test` group
|
||||
|
||||
### 6.7 Install the Firebase App Tester on the iPhone
|
||||
|
||||
1. Send an invite to Millian's iPhone from the Firebase Console
|
||||
2. Millian accepts the invite and installs the **Firebase App Tester** app from the App Store
|
||||
3. Open the app and sign in with the invited Google account
|
||||
4. The app will now receive builds pushed from the CI workflow
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| "Code signing failed" | Sign into Xcode on the runner Mac, verify team membership |
|
||||
| "No provisioning profile" | Run `-allowProvisioningUpdates` or open project in Xcode once |
|
||||
| `xcodegen: command not found` | `brew install xcodegen` (workflow handles this) |
|
||||
| Runner doesn't pick up job | Verify runner label is `macos` and runner is connected |
|
||||
| Token permission errors | Ensure `GITEA_TOKEN` has `write:issue` and `write:pull_request` scopes |
|
||||
| Firebase upload fails | Verify `FIREBASE_APP_ID` and `FIREBASE_SERVICE_ACCOUNT` secrets are set correctly |
|
||||
| "App not found" in Firebase | Check that the iOS app is registered in Firebase with the correct bundle ID |
|
||||
| Testers don't receive build | Verify tester is in the `millian-test` group and has accepted the invite |
|
||||
|
||||
## Compilation Error Troubleshooting
|
||||
|
||||
### xcodegen Clearing Signing Team
|
||||
|
||||
After running `xcodegen generate`, the `.xcodeproj` may have a cleared signing team. Always re-check your team settings:
|
||||
|
||||
```bash
|
||||
cd tabatago-swift
|
||||
xcodegen generate
|
||||
# Then check that project.yml has the correct team ID in settings
|
||||
```
|
||||
|
||||
### Complication Targets Missing Team ID
|
||||
|
||||
Targets like **TabataGoWatchWidget** (watchOS complications) require an explicit team ID in `project.yml` settings. If you see signing errors for the widget target:
|
||||
|
||||
1. Open `tabatago-swift/project.yml`
|
||||
2. Find the `TabataGoWatchWidget` target section
|
||||
3. Ensure it has an explicit `settings:` block with the team ID:
|
||||
```yaml
|
||||
target: TabataGoWatchWidget
|
||||
settings:
|
||||
base:
|
||||
DEVELOPMENT_TEAM: "2MJF39L8VY"
|
||||
```
|
||||
|
||||
### General Build Debugging
|
||||
|
||||
- Run `xcodegen generate` before every build to ensure the project is in sync
|
||||
- Check `xcodebuild` output for specific file/line errors
|
||||
- Verify SPM packages resolve: `xcodebuild -resolvePackageDependencies`
|
||||
@@ -1,643 +0,0 @@
|
||||
# Maestro E2E Testing Strategy for TabataFit
|
||||
|
||||
## Executive Summary
|
||||
|
||||
**Maestro** is a mobile UI testing framework that uses YAML-based test flows. It's ideal for TabataFit because:
|
||||
- ✅ Declarative YAML syntax (no code required)
|
||||
- ✅ Built-in support for React Native
|
||||
- ✅ Handles animations and async operations gracefully
|
||||
- ✅ Excellent for regression testing critical user flows
|
||||
- ✅ Can run on physical devices and simulators
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before implementing these tests, ensure the following features are complete:
|
||||
|
||||
### Required Features (Implement First)
|
||||
- [ ] Onboarding flow (5 screens + paywall)
|
||||
- [ ] Workout player with timer controls
|
||||
- [ ] Browse/Workouts tab with workout cards
|
||||
- [ ] Category filtering (Full Body, Core, Cardio, etc.)
|
||||
- [ ] Collections feature
|
||||
- [ ] Trainer profiles
|
||||
- [ ] Subscription/paywall integration
|
||||
- [ ] Workout completion screen
|
||||
- [ ] Profile/settings screen
|
||||
|
||||
### Nice to Have (Can Add Later)
|
||||
- [ ] Activity history tracking
|
||||
- [ ] Offline mode support
|
||||
- [ ] Deep linking
|
||||
- [ ] Push notifications
|
||||
|
||||
---
|
||||
|
||||
## Priority Test Flows
|
||||
|
||||
### **P0 - Critical Flows (Must Test Every Release)**
|
||||
1. **Onboarding → First Workout**
|
||||
2. **Browse → Select Workout → Play → Complete**
|
||||
3. **Subscription Purchase Flow**
|
||||
4. **Background/Foreground During Workout**
|
||||
|
||||
### **P1 - High Priority**
|
||||
5. **Category Filtering**
|
||||
6. **Collection Navigation**
|
||||
7. **Trainer Workout Discovery**
|
||||
8. **Profile Settings & Data Persistence**
|
||||
|
||||
### **P2 - Medium Priority**
|
||||
9. **Activity History Tracking**
|
||||
10. **Offline Mode Behavior**
|
||||
11. **Deep Linking**
|
||||
12. **Push Notifications**
|
||||
|
||||
---
|
||||
|
||||
## Test Suite Structure
|
||||
|
||||
```
|
||||
.maestro/
|
||||
├── config.yaml # Global test configuration
|
||||
├── flows/
|
||||
│ ├── critical/ # P0 flows - Run on every PR
|
||||
│ │ ├── onboarding.yaml
|
||||
│ │ ├── workoutComplete.yaml
|
||||
│ │ └── subscription.yaml
|
||||
│ ├── core/ # P1 flows - Run before release
|
||||
│ │ ├── browseAndPlay.yaml
|
||||
│ │ ├── categoryFilter.yaml
|
||||
│ │ ├── collections.yaml
|
||||
│ │ └── trainers.yaml
|
||||
│ └── regression/ # P2 flows - Run nightly
|
||||
│ ├── activityHistory.yaml
|
||||
│ ├── offlineMode.yaml
|
||||
│ └── settings.yaml
|
||||
├── helpers/
|
||||
│ ├── common.yaml # Shared test steps
|
||||
│ ├── assertions.yaml # Custom assertions
|
||||
│ └── mock-data.yaml # Test data
|
||||
└── environments/
|
||||
├── staging.yaml
|
||||
└── production.yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Installation & Setup
|
||||
|
||||
### 1. Install Maestro CLI
|
||||
|
||||
```bash
|
||||
# macOS/Linux
|
||||
curl -Ls "https://get.maestro.mobile.dev" | bash
|
||||
|
||||
# Verify installation
|
||||
maestro --version
|
||||
```
|
||||
|
||||
### 2. Setup Test Directory Structure
|
||||
|
||||
```bash
|
||||
mkdir -p .maestro/flows/{critical,core,regression}
|
||||
mkdir -p .maestro/helpers
|
||||
mkdir -p .maestro/environments
|
||||
```
|
||||
|
||||
### 3. Maestro Configuration (`config.yaml`)
|
||||
|
||||
```yaml
|
||||
# .maestro/config.yaml
|
||||
appId: com.tabatafit.app
|
||||
name: TabataFit E2E Tests
|
||||
|
||||
# Timeouts
|
||||
timeout: 30000 # 30 seconds default
|
||||
retries: 2
|
||||
|
||||
# Environment variables
|
||||
env:
|
||||
TEST_USER_NAME: "Test User"
|
||||
TEST_USER_EMAIL: "test@example.com"
|
||||
|
||||
# Include flows
|
||||
include:
|
||||
- flows/critical/*.yaml
|
||||
- flows/core/*.yaml
|
||||
|
||||
# Exclude on CI
|
||||
exclude:
|
||||
- flows/regression/offlineMode.yaml # Requires airplane mode
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## P0 Critical Test Flows
|
||||
|
||||
### Test 1: Complete Onboarding Flow
|
||||
|
||||
**File:** `.maestro/flows/critical/onboarding.yaml`
|
||||
|
||||
```yaml
|
||||
appId: com.tabatafit.app
|
||||
name: Complete Onboarding & First Workout
|
||||
|
||||
onFlowStart:
|
||||
- clearState
|
||||
|
||||
steps:
|
||||
# Splash/Loading
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 5000
|
||||
|
||||
# Screen 1: Problem - "Not Enough Time"
|
||||
- assertVisible: "Not enough time"
|
||||
- tapOn: "Continue"
|
||||
|
||||
# Screen 2: Empathy
|
||||
- assertVisible: "We get it"
|
||||
- tapOn: "Continue"
|
||||
|
||||
# Screen 3: Solution
|
||||
- assertVisible: "4-minute workouts"
|
||||
- tapOn: "Continue"
|
||||
|
||||
# Screen 4: Wow Moment
|
||||
- assertVisible: "Transform your body"
|
||||
- tapOn: "Get Started"
|
||||
|
||||
# Screen 5: Personalization
|
||||
- tapOn: "Name input"
|
||||
- inputText: "Test User"
|
||||
- tapOn: "Beginner"
|
||||
- tapOn: "Lose Weight"
|
||||
- tapOn: "3 times per week"
|
||||
- tapOn: "Start My Journey"
|
||||
|
||||
# Screen 6: Paywall (or skip in test env)
|
||||
- runScript: |
|
||||
if (maestro.env.SKIP_PAYWALL === 'true') {
|
||||
maestro.tapOn('Maybe Later');
|
||||
}
|
||||
|
||||
# Should land on Home
|
||||
- assertVisible: "Good morning|Good afternoon|Good evening"
|
||||
- assertVisible: "Test User"
|
||||
|
||||
onFlowComplete:
|
||||
- takeScreenshot: "onboarding-complete"
|
||||
```
|
||||
|
||||
### Test 2: Browse, Select, and Complete Workout
|
||||
|
||||
**File:** `.maestro/flows/critical/workoutComplete.yaml`
|
||||
|
||||
```yaml
|
||||
appId: com.tabatafit.app
|
||||
name: Browse, Play & Complete Workout
|
||||
|
||||
steps:
|
||||
# Navigate to Workouts tab
|
||||
- tapOn: "Workouts"
|
||||
- waitForAnimationToEnd
|
||||
|
||||
# Wait for data to load
|
||||
- assertVisible: "All|Full Body|Core|Upper Body"
|
||||
|
||||
# Select first workout
|
||||
- tapOn:
|
||||
id: "workout-card-0"
|
||||
optional: false
|
||||
|
||||
# Workout Detail Screen
|
||||
- assertVisible: "Start Workout"
|
||||
- tapOn: "Start Workout"
|
||||
|
||||
# Player Screen
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
|
||||
# Verify timer is running
|
||||
- assertVisible: "Get Ready|WORK|REST"
|
||||
|
||||
# Fast-forward through workout (simulation)
|
||||
- repeat:
|
||||
times: 3
|
||||
commands:
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 5000
|
||||
- assertVisible: "WORK|REST"
|
||||
|
||||
# Complete workout
|
||||
- tapOn:
|
||||
id: "done-button"
|
||||
optional: true
|
||||
|
||||
# Complete Screen
|
||||
- assertVisible: "Workout Complete|Great Job"
|
||||
- assertVisible: "Calories"
|
||||
- assertVisible: "Duration"
|
||||
|
||||
# Return to home
|
||||
- tapOn: "Done|Continue"
|
||||
- assertVisible: "Home|Workouts"
|
||||
|
||||
onFlowComplete:
|
||||
- takeScreenshot: "workout-completed"
|
||||
```
|
||||
|
||||
### Test 3: Subscription Purchase Flow
|
||||
|
||||
**File:** `.maestro/flows/critical/subscription.yaml`
|
||||
|
||||
```yaml
|
||||
appId: com.tabatafit.app
|
||||
name: Subscription Purchase Flow
|
||||
|
||||
steps:
|
||||
# Trigger paywall (via profile or workout limit)
|
||||
- tapOn: "Profile"
|
||||
- tapOn: "Upgrade to Premium"
|
||||
|
||||
# Paywall Screen
|
||||
- assertVisible: "Unlock Everything|Premium"
|
||||
- assertVisible: "yearly|monthly"
|
||||
|
||||
# Select plan
|
||||
- tapOn:
|
||||
id: "yearly-plan"
|
||||
|
||||
# Verify Apple Pay/Google Pay sheet appears
|
||||
- assertVisible: "Subscribe|Confirm"
|
||||
|
||||
# Note: Actual purchase is mocked in test env
|
||||
- runScript: |
|
||||
if (maestro.env.USE_MOCK_PURCHASE === 'true') {
|
||||
maestro.tapOn('Mock Purchase Success');
|
||||
}
|
||||
|
||||
# Verify premium activated
|
||||
- assertVisible: "Premium Active|You're all set"
|
||||
|
||||
tags:
|
||||
- purchase
|
||||
- revenue-critical
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## P1 Core Test Flows
|
||||
|
||||
### Test 4: Category Filtering
|
||||
|
||||
**File:** `.maestro/flows/core/categoryFilter.yaml`
|
||||
|
||||
```yaml
|
||||
appId: com.tabatafit.app
|
||||
name: Category Filtering
|
||||
|
||||
steps:
|
||||
- tapOn: "Workouts"
|
||||
- waitForAnimationToEnd
|
||||
|
||||
# Test each category
|
||||
- tapOn: "Full Body"
|
||||
- assertVisible: "Full Body"
|
||||
|
||||
- tapOn: "Core"
|
||||
- assertVisible: "Core"
|
||||
|
||||
- tapOn: "Cardio"
|
||||
- assertVisible: "Cardio"
|
||||
|
||||
- tapOn: "All"
|
||||
- assertVisible: "All Workouts"
|
||||
|
||||
# Verify filter changes content
|
||||
- runScript: |
|
||||
const before = maestro.getElementText('workout-count');
|
||||
maestro.tapOn('Core');
|
||||
const after = maestro.getElementText('workout-count');
|
||||
assert(before !== after, 'Filter should change workout count');
|
||||
```
|
||||
|
||||
### Test 5: Collection Navigation
|
||||
|
||||
**File:** `.maestro/flows/core/collections.yaml`
|
||||
|
||||
```yaml
|
||||
appId: com.tabatafit.app
|
||||
name: Collection Navigation
|
||||
|
||||
steps:
|
||||
- tapOn: "Browse"
|
||||
- waitForAnimationToEnd
|
||||
|
||||
# Scroll to collections
|
||||
- swipe:
|
||||
direction: UP
|
||||
duration: 1000
|
||||
|
||||
# Tap first collection
|
||||
- tapOn:
|
||||
id: "collection-card-0"
|
||||
|
||||
# Collection Detail Screen
|
||||
- assertVisible: "Collection"
|
||||
- assertVisible: "workouts"
|
||||
|
||||
# Start collection workout
|
||||
- tapOn: "Start"
|
||||
- assertVisible: "Player|Timer"
|
||||
|
||||
onFlowComplete:
|
||||
- takeScreenshot: "collection-navigation"
|
||||
```
|
||||
|
||||
### Test 6: Trainer Discovery
|
||||
|
||||
**File:** `.maestro/flows/core/trainers.yaml`
|
||||
|
||||
```yaml
|
||||
appId: com.tabatafit.app
|
||||
name: Trainer Discovery
|
||||
|
||||
steps:
|
||||
- tapOn: "Browse"
|
||||
|
||||
# Navigate to trainers section
|
||||
- swipe:
|
||||
direction: UP
|
||||
|
||||
# Select trainer
|
||||
- tapOn:
|
||||
id: "trainer-card-0"
|
||||
|
||||
# Verify trainer profile
|
||||
- assertVisible: "workouts"
|
||||
|
||||
# Select trainer's workout
|
||||
- tapOn:
|
||||
id: "workout-card-0"
|
||||
|
||||
# Should show workout detail
|
||||
- assertVisible: "Start Workout"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reusable Test Helpers
|
||||
|
||||
### Common Actions (`helpers/common.yaml`)
|
||||
|
||||
```yaml
|
||||
# .maestro/helpers/common.yaml
|
||||
appId: com.tabatafit.app
|
||||
|
||||
# Launch app fresh
|
||||
- launchApp:
|
||||
clearState: true
|
||||
|
||||
# Wait for data loading
|
||||
- waitForDataLoad:
|
||||
commands:
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- assertVisible: ".*" # Any content loaded
|
||||
|
||||
# Handle permission dialogs
|
||||
- handlePermissions:
|
||||
commands:
|
||||
- tapOn:
|
||||
text: "Allow"
|
||||
optional: true
|
||||
- tapOn:
|
||||
text: "OK"
|
||||
optional: true
|
||||
|
||||
# Navigate to tab
|
||||
- navigateToTab:
|
||||
params:
|
||||
tabName: ${tab}
|
||||
commands:
|
||||
- tapOn: ${tab}
|
||||
|
||||
# Start workout from detail
|
||||
- startWorkout:
|
||||
commands:
|
||||
- tapOn: "Start Workout"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 5000
|
||||
- assertVisible: "Get Ready|WORK"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Local Development
|
||||
|
||||
```bash
|
||||
# Install Maestro
|
||||
curl -Ls "https://get.maestro.mobile.dev" | bash
|
||||
|
||||
# Run single test
|
||||
maestro test .maestro/flows/critical/onboarding.yaml
|
||||
|
||||
# Run all critical tests
|
||||
maestro test .maestro/flows/critical/
|
||||
|
||||
# Run with specific environment
|
||||
maestro test --env SKIP_PAYWALL=true .maestro/flows/
|
||||
|
||||
# Record video of test
|
||||
maestro record .maestro/flows/critical/workoutComplete.yaml
|
||||
|
||||
# Run with tags
|
||||
maestro test --include-tags=critical .maestro/flows/
|
||||
```
|
||||
|
||||
### CI/CD Integration (GitHub Actions)
|
||||
|
||||
```yaml
|
||||
# .github/workflows/maestro.yml
|
||||
name: Maestro E2E Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
e2e-tests:
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v3
|
||||
|
||||
- name: Install Maestro
|
||||
run: curl -Ls "https://get.maestro.mobile.dev" | bash
|
||||
|
||||
- name: Start iOS Simulator
|
||||
run: |
|
||||
xcrun simctl boot "iPhone 15"
|
||||
sleep 10
|
||||
|
||||
- name: Install App
|
||||
run: |
|
||||
npm install
|
||||
npx expo prebuild
|
||||
npx pod install
|
||||
npx react-native run-ios --simulator="iPhone 15"
|
||||
|
||||
- name: Run Critical Tests
|
||||
run: |
|
||||
export MAESTRO_DRIVER_STARTUP_TIMEOUT=120000
|
||||
maestro test .maestro/flows/critical/
|
||||
|
||||
- name: Upload Test Results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: maestro-results
|
||||
path: |
|
||||
~/.maestro/tests/
|
||||
*.png
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Coverage Matrix
|
||||
|
||||
| Feature | Test File | Priority | Frequency | Status |
|
||||
|---------|-----------|----------|-----------|--------|
|
||||
| Onboarding | `onboarding.yaml` | P0 | Every PR | ⏳ Pending |
|
||||
| Workout Play | `workoutComplete.yaml` | P0 | Every PR | ⏳ Pending |
|
||||
| Purchase | `subscription.yaml` | P0 | Every PR | ⏳ Pending |
|
||||
| Category Filter | `categoryFilter.yaml` | P1 | Pre-release | ⏳ Pending |
|
||||
| Collections | `collections.yaml` | P1 | Pre-release | ⏳ Pending |
|
||||
| Trainers | `trainers.yaml` | P1 | Pre-release | ⏳ Pending |
|
||||
| Activity | `activityHistory.yaml` | P2 | Nightly | ⏳ Pending |
|
||||
| Offline | `offlineMode.yaml` | P2 | Weekly | ⏳ Pending |
|
||||
|
||||
---
|
||||
|
||||
## React Native Prerequisites
|
||||
|
||||
Before running tests, add `testID` props to components for reliable selectors:
|
||||
|
||||
```tsx
|
||||
// WorkoutCard.tsx
|
||||
<Pressable testID={`workout-card-${index}`}>
|
||||
{/* ... */}
|
||||
</Pressable>
|
||||
|
||||
// WorkoutPlayer.tsx
|
||||
<Button testID="done-button" title="Done" />
|
||||
|
||||
// Paywall.tsx
|
||||
<Pressable testID="yearly-plan">
|
||||
{/* ... */}
|
||||
</Pressable>
|
||||
```
|
||||
|
||||
### Required testIDs Checklist
|
||||
|
||||
- [ ] `workout-card-{index}` - Workout list items
|
||||
- [ ] `collection-card-{index}` - Collection items
|
||||
- [ ] `trainer-card-{index}` - Trainer items
|
||||
- [ ] `done-button` - Complete workout button
|
||||
- [ ] `yearly-plan` / `monthly-plan` - Subscription plans
|
||||
- [ ] `start-workout-button` - Start workout CTA
|
||||
- [ ] `category-{name}` - Category filter buttons
|
||||
- [ ] `tab-{name}` - Bottom navigation tabs
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Create `.env.maestro` file:
|
||||
|
||||
```bash
|
||||
# Test Configuration
|
||||
SKIP_PAYWALL=true
|
||||
USE_MOCK_PURCHASE=true
|
||||
TEST_USER_NAME=Test User
|
||||
TEST_USER_EMAIL=test@example.com
|
||||
|
||||
# API Configuration (if needed)
|
||||
API_BASE_URL=https://api-staging.tabatafit.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Tests fail on first run**
|
||||
- Clear app state: `maestro test --clear-state`
|
||||
- Increase timeout in config.yaml
|
||||
|
||||
2. **Element not found**
|
||||
- Verify testID is set correctly
|
||||
- Add wait times before assertions
|
||||
- Check for animations completing
|
||||
|
||||
3. **Purchase tests fail**
|
||||
- Ensure `USE_MOCK_PURCHASE=true` in test env
|
||||
- Use sandbox/test products
|
||||
|
||||
4. **Slow tests**
|
||||
- Use `waitForAnimationToEnd` with shorter timeouts
|
||||
- Disable animations in test builds
|
||||
|
||||
### Debug Commands
|
||||
|
||||
```bash
|
||||
# Interactive mode
|
||||
maestro studio
|
||||
|
||||
# View hierarchy
|
||||
maestro hierarchy
|
||||
|
||||
# Record test execution
|
||||
maestro record <test-file>
|
||||
|
||||
# Verbose logging
|
||||
maestro test --verbose <test-file>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Steps (After Features Are Complete)
|
||||
|
||||
1. ✅ Create `.maestro/` directory structure
|
||||
2. ✅ Write `config.yaml`
|
||||
3. ✅ Implement P0 critical test flows
|
||||
4. ✅ Add testIDs to React Native components
|
||||
5. ✅ Run tests locally
|
||||
6. ✅ Setup CI/CD pipeline
|
||||
7. ⏳ Implement P1 core test flows
|
||||
8. ⏳ Add visual regression tests
|
||||
9. ⏳ Setup nightly regression suite
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
- [Maestro Documentation](https://maestro.mobile.dev/)
|
||||
- [Maestro YAML Reference](https://maestro.mobile.dev/api-reference/commands)
|
||||
- [React Native Testing with Maestro](https://maestro.mobile.dev/platform-support/react-native)
|
||||
- [Maestro Best Practices](https://maestro.mobile.dev/advanced/best-practices)
|
||||
|
||||
---
|
||||
|
||||
**Created:** March 17, 2026
|
||||
**Status:** Implementation Pending (Waiting for feature completion)
|
||||
@@ -1,486 +0,0 @@
|
||||
# TabataFit — UI Feature Brief
|
||||
|
||||
> Generated for Google Stitch design handoff.
|
||||
> Covers all end-user screens, interactions, states, and navigation flows.
|
||||
|
||||
---
|
||||
|
||||
## App Overview
|
||||
|
||||
**TabataFit** is a mobile fitness app ("Apple Fitness+ for Tabata") built with React Native / Expo. It delivers guided Tabata HIIT workouts with video, voice coaching, music, and Apple Watch heart-rate sync.
|
||||
|
||||
### Design System
|
||||
|
||||
| Token | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| Background | `#000000` | Pure black base |
|
||||
| Surface | `#1C1C1E` | Cards, sheets |
|
||||
| Brand | `#FF6B35` | Flame orange — primary accent |
|
||||
| Rest | `#5AC8FA` | Ice blue — rest phases |
|
||||
| Success | `#30D158` | Energy green — completion |
|
||||
| Prep phase | `#FF9500` | Orange-yellow |
|
||||
| Work phase | `#FF6B35` | Flame orange |
|
||||
| Rest phase | `#5AC8FA` | Ice blue |
|
||||
| Complete phase | `#30D158` | Green |
|
||||
|
||||
- Supports **dark and light mode**
|
||||
- Multi-language (i18n)
|
||||
- Haptic feedback throughout
|
||||
|
||||
---
|
||||
|
||||
## Navigation Structure
|
||||
|
||||
```
|
||||
Root Stack
|
||||
├── Onboarding (6-step flow)
|
||||
├── (tabs)
|
||||
│ ├── Home — index
|
||||
│ ├── Explore — browse workouts
|
||||
│ ├── Activity — stats & history
|
||||
│ └── Profile — settings & account
|
||||
├── workout/[id] — Workout detail (push)
|
||||
├── program/[id] — Program detail (push)
|
||||
├── collection/[id] — Collection detail (push)
|
||||
├── player/[id] — Workout player (push, full-screen)
|
||||
├── complete/[id] — Post-workout celebration (push)
|
||||
├── paywall — Premium upsell (modal)
|
||||
├── explore-filters — Filter sheet (form sheet modal)
|
||||
└── privacy — Privacy policy (push)
|
||||
```
|
||||
|
||||
**Tab bar**: 4 tabs — Home, Explore, Activity, Profile. SF Symbol icons. Badge support.
|
||||
|
||||
---
|
||||
|
||||
## 1. Onboarding Flow
|
||||
|
||||
A 6-screen linear funnel shown on first launch. Progress dots at top. Skip available on some steps.
|
||||
|
||||
### 1.1 Problem Screen
|
||||
- **Purpose**: Motivational hook about time constraints
|
||||
- **Elements**: Headline text, subtitle, illustration
|
||||
- **CTA**: Continue
|
||||
|
||||
### 1.2 Empathy Screen
|
||||
- **Purpose**: User selects fitness barriers to build rapport
|
||||
- **Elements**: Grid of 4 selectable cards — "No time", "Low motivation", "No knowledge", "No gym"
|
||||
- **Interaction**: Tap to select, max 2 selections, visual highlight on selected
|
||||
- **CTA**: Continue (enabled after 1+ selection)
|
||||
|
||||
### 1.3 Solution Screen
|
||||
- **Purpose**: Show Tabata's effectiveness
|
||||
- **Elements**: Animated comparison bar chart (Tabata vs traditional cardio calorie burn)
|
||||
- **CTA**: Continue
|
||||
|
||||
### 1.4 Wow Screen
|
||||
- **Purpose**: Reveal key app features
|
||||
- **Elements**: 4 feature cards with staggered entrance animations — Timer, Exercises, Voice Coaching, Progress Tracking
|
||||
- **CTA**: Continue
|
||||
|
||||
### 1.5 Personalization Screen
|
||||
- **Purpose**: Collect user preferences to personalize experience
|
||||
- **Inputs**:
|
||||
- Name (text input)
|
||||
- Fitness level: Beginner / Intermediate / Advanced (single select chips)
|
||||
- Goal: Weight Loss / Cardio / Strength / Wellness (single select chips)
|
||||
- Weekly frequency: 2x / 3x / 5x per week (single select chips)
|
||||
- **CTA**: Continue (enabled when all fields filled)
|
||||
|
||||
### 1.6 Paywall Screen (Onboarding variant)
|
||||
- **Purpose**: Premium conversion at end of onboarding
|
||||
- **Elements**: Premium features list, yearly/monthly plan toggle with real prices from RevenueCat
|
||||
- **CTAs**: Subscribe, Restore Purchases, Skip (close button)
|
||||
- **States**: Loading prices, purchase in progress, error
|
||||
|
||||
---
|
||||
|
||||
## 2. Home Tab
|
||||
|
||||
Personalized dashboard and primary entry point.
|
||||
|
||||
### Elements
|
||||
- **Greeting header**: Time-based ("Good morning/afternoon/evening") + user's name + animated mascot
|
||||
- **Streak badge**: Current streak count with flame icon
|
||||
- **Quick stats row**: 3 stat pills — Current streak, This week (workouts), Total minutes
|
||||
- **Assessment card**: Feature-flagged (currently OFF) — fitness assessment prompt
|
||||
- **Program cards**: 3 horizontal cards (Upper Body, Lower Body, Full Body)
|
||||
- Each shows: icon, title, progress bar (% complete), status badge (Not Started / In Progress / Completed)
|
||||
- CTA per card: Start / Continue / Restart (depends on state)
|
||||
- **Switch program button**: Opens program selection
|
||||
|
||||
### Navigation
|
||||
- Tap program card → `program/[id]`
|
||||
- Tap "Start" on program → `player/[id]` (first workout)
|
||||
- Tap "Continue" → `player/[id]` (next incomplete workout)
|
||||
|
||||
### States
|
||||
- **New user**: 0 stats, no streak, programs at 0%
|
||||
- **Returning user**: Populated stats, active streak, program progress
|
||||
|
||||
---
|
||||
|
||||
## 3. Explore Tab
|
||||
|
||||
Browse, search, and filter the full workout catalog.
|
||||
|
||||
### Elements
|
||||
- **Search bar**: Search by workout title, trainer name, exercise name, category. Real-time filtering.
|
||||
- **Featured collection**: Hero card at top with image, title, workout count. Tap → `collection/[id]`
|
||||
- **Trainer avatars**: Horizontal scroll of circular trainer photos. Tap to filter workouts by trainer.
|
||||
- **Collections carousel**: Horizontal scroll of collection cards. Tap → `collection/[id]`
|
||||
- **Recommended For You**: Horizontal workout card list, personalized based on workout history
|
||||
- **Featured workouts**: Grid of highlighted workouts
|
||||
- **All Workouts section**:
|
||||
- Category filter pills: All, Full Body, Upper Body, Lower Body, Core, Cardio
|
||||
- Filter button → opens `explore-filters` sheet
|
||||
- Active filter indicator + clear filters button
|
||||
- 2-column workout card grid
|
||||
|
||||
### Workout Card
|
||||
- Thumbnail image
|
||||
- Duration badge overlay
|
||||
- Title, trainer name, level indicator
|
||||
- Lock icon if premium-only and user is free tier
|
||||
|
||||
### Navigation
|
||||
- Tap workout card → `workout/[id]`
|
||||
- Tap collection → `collection/[id]`
|
||||
- Tap trainer avatar → filters workout list by that trainer
|
||||
- Tap filter button → `explore-filters` (form sheet modal)
|
||||
|
||||
### States
|
||||
- **Loading**: Skeleton placeholders for cards
|
||||
- **Error**: Error message with Retry button
|
||||
- **Empty search**: "No workouts found" message
|
||||
- **Filtered**: Active filter chips shown, clear all button
|
||||
|
||||
---
|
||||
|
||||
## 4. Activity Tab
|
||||
|
||||
Workout history, statistics, and achievements.
|
||||
|
||||
### Elements
|
||||
- **Streak banner**: Current streak + longest streak (flame icons)
|
||||
- **Stats grid** (2x2): Each stat in a card with circular progress ring
|
||||
- Total workouts (ring fills toward goal)
|
||||
- Total minutes
|
||||
- Total calories
|
||||
- Best streak
|
||||
- **Weekly bar chart**: Sun–Sat, each bar filled if a workout was completed that day, current day highlighted
|
||||
- **Recent workouts list**: Last 5 workouts
|
||||
- Each row: workout title, relative time ("2h ago"), duration, calories
|
||||
- Tap → `workout/[id]`
|
||||
- **Achievements grid**: 4 achievement badges displayed
|
||||
- Types: workouts milestone, streak milestone, minutes milestone, calories milestone
|
||||
- States: locked (greyed out) / unlocked (colored with checkmark)
|
||||
|
||||
### States
|
||||
- **Empty**: No workouts yet — motivational message + "Start Your First Workout" CTA → `explore`
|
||||
- **Populated**: All sections visible with data
|
||||
|
||||
---
|
||||
|
||||
## 5. Profile Tab
|
||||
|
||||
User settings, account management, and app info.
|
||||
|
||||
### Elements
|
||||
- **User header**: Avatar circle with initial, display name, plan label ("Free" or "TabataFit+")
|
||||
- **Stats row**: 3 inline stats — workouts count, streak, calories
|
||||
- **Upgrade CTA** (free users only): Gradient button → `paywall`
|
||||
|
||||
#### Workout Settings Section
|
||||
- Haptic feedback toggle
|
||||
- Sound effects toggle
|
||||
- Voice coaching toggle
|
||||
|
||||
#### Notifications Section
|
||||
- Daily reminders toggle
|
||||
- Reminder time display (when enabled)
|
||||
|
||||
#### Personalization Section (premium only)
|
||||
- Sync status indicator
|
||||
|
||||
#### About Section
|
||||
- Version number
|
||||
- Rate App → opens App Store rating prompt
|
||||
- Contact Us → opens email compose
|
||||
- FAQ → opens external web link
|
||||
- Privacy Policy → `privacy` screen
|
||||
|
||||
#### Account Section (premium only)
|
||||
- Restore Purchases → triggers RevenueCat restore
|
||||
|
||||
#### Danger Zone
|
||||
- Sign Out button
|
||||
- Data deletion: triggers confirmation modal
|
||||
|
||||
### Data Deletion Modal
|
||||
- Warning text explaining data loss
|
||||
- Cancel / Delete buttons
|
||||
- Delete is destructive (red)
|
||||
|
||||
---
|
||||
|
||||
## 6. Workout Detail Screen
|
||||
|
||||
Pre-workout information screen. Reached by tapping any workout card.
|
||||
|
||||
**Route**: `workout/[id]`
|
||||
|
||||
### Elements
|
||||
- **Header**: Thumbnail or video preview, back button, heart/save button (toggle)
|
||||
- **Title**: Workout name
|
||||
- **Trainer**: Trainer name (colored text)
|
||||
- **Metadata row**: Duration (minutes), Calories estimate, Level badge (Beginner/Intermediate/Advanced)
|
||||
- **Equipment list**: Icons + labels for required equipment (or "No equipment")
|
||||
- **Timing card**: Prep time, Work time, Rest time, Rounds — displayed in a structured card
|
||||
- **Exercise list**: Ordered list of exercises with individual durations
|
||||
- **Repeat rounds indicator**: Shows if rounds repeat the exercise sequence
|
||||
- **Music vibe label**: Genre/mood of the workout soundtrack
|
||||
|
||||
### CTAs
|
||||
- **Start Workout** → `player/[id]` (if unlocked or user is premium)
|
||||
- **Unlock with TabataFit+** → `paywall` (if locked and user is free tier)
|
||||
|
||||
### Header Actions
|
||||
- **Back**: Navigate back
|
||||
- **Save/Unsave**: Heart icon toggle — saves workout to favorites
|
||||
|
||||
### States
|
||||
- **Loading**: Skeleton layout
|
||||
- **Unlocked**: Full detail visible, "Start Workout" CTA
|
||||
- **Locked**: Full detail visible but "Unlock with TabataFit+" CTA replaces start button
|
||||
|
||||
---
|
||||
|
||||
## 7. Program Detail Screen
|
||||
|
||||
Multi-week training program overview with per-week workout breakdown.
|
||||
|
||||
**Route**: `program/[id]`
|
||||
|
||||
### Elements
|
||||
- **Header**: Program icon, title, subtitle (e.g., "4 weeks · 12 workouts")
|
||||
- **Description**: Program summary text
|
||||
- **Stats card**: 3 stats — Weeks, Workouts, Total Minutes
|
||||
- **Tags**: Equipment required (e.g., Dumbbells, Mat) + Equipment optional + Focus areas (e.g., Arms, Core)
|
||||
- **Progress bar** (if started): Percentage complete with label
|
||||
- **Training plan**: Expandable week sections
|
||||
- Each week shows its workouts in order
|
||||
- Workout rows show: title, duration, completion checkmark (if done)
|
||||
- Current week has a "Current" badge
|
||||
- Future weeks may show lock icons (progressive unlock)
|
||||
|
||||
### CTAs
|
||||
- **Start Program** (not started) → `player/[id]` (first workout)
|
||||
- **Continue Training** (in progress) → `player/[id]` (next incomplete workout)
|
||||
- **Restart** (completed) → resets progress, starts from week 1
|
||||
|
||||
### States
|
||||
- **Not Started**: 0% progress, all weeks shown, "Start Program" CTA
|
||||
- **In Progress**: Progress bar filled, completed workouts checked, "Continue Training" CTA
|
||||
- **Completed**: 100% progress, all checked, "Restart" CTA
|
||||
|
||||
---
|
||||
|
||||
## 8. Collection Detail Screen
|
||||
|
||||
A curated group of workouts.
|
||||
|
||||
**Route**: `collection/[id]`
|
||||
|
||||
### Elements
|
||||
- **Header**: Collection title, description, workout count
|
||||
- **Workout list**: Vertical list of workout cards in the collection
|
||||
- Each card: thumbnail, title, trainer, duration, level
|
||||
- Lock icon for premium-gated workouts
|
||||
|
||||
### Navigation
|
||||
- Tap workout → `workout/[id]`
|
||||
- Back button → previous screen
|
||||
|
||||
---
|
||||
|
||||
## 9. Player Screen
|
||||
|
||||
Full-screen workout execution with timer, video, audio, and Watch sync.
|
||||
|
||||
**Route**: `player/[id]`
|
||||
|
||||
### Layout
|
||||
- **Full-screen dark mode** — no tab bar, no status bar chrome
|
||||
- **Background**: Workout video (HLS streaming) or gradient fallback
|
||||
- **Phase-colored tint**: Background overlay changes color per phase (prep=orange, work=flame, rest=blue, complete=green)
|
||||
|
||||
### Timer Section
|
||||
- **Timer ring**: Large circular progress indicator, fills as phase progresses
|
||||
- **Phase label**: PREP / WORK / REST / COMPLETE (color-coded)
|
||||
- **Countdown**: Large MM:SS timer (uses tabular-nums for alignment)
|
||||
- **Round indicator**: "Round 3 of 8" text
|
||||
|
||||
### Exercise Info
|
||||
- **Current exercise name**: Large text
|
||||
- **Next exercise preview**: Smaller text ("Up next: Burpees")
|
||||
- **Coach encouragement**: Motivational text overlays (e.g., "Keep going!", "Almost there!")
|
||||
|
||||
### Controls
|
||||
- **Start**: Begins the workout (shown before first start)
|
||||
- **Pause / Resume**: Toggle button during workout
|
||||
- **Stop**: Ends workout early (confirmation prompt)
|
||||
- **Skip**: Skip to next phase
|
||||
|
||||
### Stats Overlay
|
||||
- **Calories**: Running calorie count
|
||||
- **Heart rate**: BPM from Apple Watch (if connected)
|
||||
- **Rounds**: Current / total
|
||||
|
||||
### Burn Bar
|
||||
- Horizontal bar comparing user's current calorie burn vs. average for this workout
|
||||
- Updates in real-time
|
||||
|
||||
### Now Playing Pill
|
||||
- Shows current music track name
|
||||
- Skip track button
|
||||
|
||||
### Audio & Haptics
|
||||
- **Sound effects**: Phase start chime, 3-2-1 countdown beeps, workout complete fanfare
|
||||
- **Haptic feedback**: Phase transitions, countdown ticks, button presses
|
||||
- **Voice coaching**: Audio cues for exercises and encouragement
|
||||
- **Screen stays awake** (useKeepAwake)
|
||||
|
||||
### Apple Watch Integration
|
||||
- Sends: workout state (phase, timer, exercise)
|
||||
- Receives: play/pause, skip, stop commands, heart rate data
|
||||
|
||||
### Completion State
|
||||
- Timer ring shows 100%
|
||||
- Phase label: COMPLETE
|
||||
- Summary: Rounds completed, calories burned, total minutes
|
||||
- **Done** CTA → `complete/[id]`
|
||||
|
||||
### States
|
||||
- **Ready**: Before starting — shows workout info, Start CTA
|
||||
- **Active**: Timer running, video playing, stats updating
|
||||
- **Paused**: Timer frozen, controls show Resume
|
||||
- **Complete**: Summary shown, Done CTA
|
||||
|
||||
---
|
||||
|
||||
## 10. Workout Complete Screen
|
||||
|
||||
Post-workout celebration and next steps.
|
||||
|
||||
**Route**: `complete/[id]`
|
||||
|
||||
### Elements
|
||||
- **Celebration animation**: Concentric emoji rings spinning (fire, muscle, lightning emojis)
|
||||
- **Stats grid**: 3 stats — Calories, Minutes, 100% completion
|
||||
- **Burn bar result**: Percentile comparison ("You burned more than 73% of users")
|
||||
- **Streak display**: Current streak count + subtitle ("Keep it going!")
|
||||
- **Share button**: Opens native share sheet with workout summary
|
||||
- **Recommended next workouts**: 3 horizontal workout cards
|
||||
- Tap → `workout/[id]`
|
||||
- **Back to Home** CTA → navigates to Home tab
|
||||
|
||||
### Sync Consent Modal
|
||||
- Appears after first workout for premium users
|
||||
- Prompts to enable cross-device data sync
|
||||
- Accept / Decline buttons
|
||||
|
||||
---
|
||||
|
||||
## 11. Paywall Screen
|
||||
|
||||
Premium subscription purchase flow.
|
||||
|
||||
**Route**: `paywall` (presented as modal)
|
||||
|
||||
### Elements
|
||||
- **Header**: "TabataFit+" branding
|
||||
- **Features grid**: 6 premium feature cards with icons
|
||||
- Music during workouts
|
||||
- Unlimited workouts
|
||||
- Detailed stats
|
||||
- Calorie tracking
|
||||
- Smart reminders
|
||||
- No ads
|
||||
- **Plan selection**: Two radio-style options
|
||||
- Yearly: Price/year + "Save 50%" badge (highlighted as best value)
|
||||
- Monthly: Price/month
|
||||
- Prices fetched live from RevenueCat
|
||||
- **Subscribe CTA**: Gradient button, shows selected plan price
|
||||
- **Restore purchases**: Text link below CTA
|
||||
- **Terms**: Privacy policy + terms of service links
|
||||
- **Close button**: X in top corner to dismiss
|
||||
|
||||
### States
|
||||
- **Loading**: Skeleton while fetching prices from RevenueCat
|
||||
- **Ready**: Plans displayed with real prices
|
||||
- **Purchasing**: Loading spinner on CTA, inputs disabled
|
||||
- **Error**: Error message with retry
|
||||
- **Success**: Dismisses modal, unlocks premium features
|
||||
|
||||
---
|
||||
|
||||
## 12. Explore Filters Sheet
|
||||
|
||||
Filter modal for the Explore tab workout grid.
|
||||
|
||||
**Route**: `explore-filters` (form sheet modal with grabber)
|
||||
|
||||
### Elements
|
||||
- **Level filter chips**: All / Beginner / Intermediate / Advanced (single select)
|
||||
- **Equipment filter chips**: All / None / Dumbbells / Band / Mat (dynamic from data, single select)
|
||||
- **Apply**: Dismiss sheet, filters persist in shared store
|
||||
- **Clear**: Reset all filters to "All"
|
||||
|
||||
---
|
||||
|
||||
## 13. Privacy Policy Screen
|
||||
|
||||
Static content screen.
|
||||
|
||||
**Route**: `privacy`
|
||||
|
||||
### Elements
|
||||
- Privacy policy text content
|
||||
- Back navigation
|
||||
|
||||
---
|
||||
|
||||
## Cross-Cutting Features
|
||||
|
||||
### Premium Gating
|
||||
- Free users see all workouts but some are locked (lock icon overlay)
|
||||
- Tapping a locked workout's "Start" CTA redirects to `paywall`
|
||||
- Premium users have full access to all workouts, stats sync, and personalization
|
||||
|
||||
### Internationalization (i18n)
|
||||
- All user-facing strings are translated via i18n system
|
||||
- Multi-language support throughout
|
||||
|
||||
### Haptic Feedback
|
||||
- Configurable via Profile settings toggle
|
||||
- Triggered on: button presses, phase changes, countdown ticks, achievements
|
||||
|
||||
### Analytics (PostHog)
|
||||
- Events tracked across all screens: screen views, button taps, workout starts/completions, purchases, onboarding steps
|
||||
|
||||
### Dark / Light Mode
|
||||
- Full theme support — colors adapt to system appearance
|
||||
- Player screen is always dark mode regardless of system setting
|
||||
|
||||
### Loading & Error States
|
||||
- Skeleton placeholders during data fetches
|
||||
- Error states with descriptive message + Retry button
|
||||
- Empty states with motivational messaging + CTAs
|
||||
|
||||
### Animations
|
||||
- Onboarding: staggered card reveals, animated charts
|
||||
- Home: mascot animation
|
||||
- Player: timer ring fill, phase color transitions
|
||||
- Complete: spinning emoji celebration rings
|
||||
- Navigation: standard iOS push/pop + modal presentations
|
||||
47
skills-lock.json
Normal file
47
skills-lock.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
-- ⚠️ OBSOLÈTE — Ce fichier décrit l'ancien concept "TabataFit" (abandonné).
|
||||
-- Le schéma actif est dans supabase/migrations/001 → 006.
|
||||
-- Voir AGENTS.md pour la référence canonique.
|
||||
-- ============================================================
|
||||
-- TabataFit Supabase Schema
|
||||
-- ============================================================
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
-- ⚠️ OBSOLÈTE — Ce fichier date du concept "TabataFit" (abandonné).
|
||||
-- Le vrai seed est dans supabase/migrations/006_seed_workout_programs.sql.
|
||||
-- ============================================================
|
||||
-- TabataFit Seed Data
|
||||
-- ============================================================
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
-- ⚠️ PARTIELLEMENT OBSOLÈTE — Référence TabataFit.
|
||||
-- La fonction is_admin() reste valide pour admin_users.
|
||||
-- ============================================================
|
||||
-- Setup: Initial Admin User and Authentication
|
||||
-- ============================================================
|
||||
|
||||
18
tabatago-swift/ExportOptions.plist
Normal file
18
tabatago-swift/ExportOptions.plist
Normal 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>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -1,96 +0,0 @@
|
||||
{
|
||||
"originHash" : "42d1f35b4500c2779457daf99841f2333a14d9a2965835305d89fd95beda836e",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "plcrashreporter",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/microsoft/plcrashreporter.git",
|
||||
"state" : {
|
||||
"revision" : "0254f941c646b1ed17b243654723d0f071e990d0",
|
||||
"version" : "1.12.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "posthog-ios",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/PostHog/posthog-ios",
|
||||
"state" : {
|
||||
"revision" : "c3efdae383a5e7a5a88c34fd774e9d7dc915b9d4",
|
||||
"version" : "3.55.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "purchases-ios",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/RevenueCat/purchases-ios",
|
||||
"state" : {
|
||||
"revision" : "bd63241b2258ea519020eb32a349db44fb44b119",
|
||||
"version" : "5.68.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "supabase-swift",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/supabase/supabase-swift",
|
||||
"state" : {
|
||||
"revision" : "17261e93c60aa721e3c17312bfeb2ae6de3d6f8a",
|
||||
"version" : "2.43.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-asn1",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-asn1.git",
|
||||
"state" : {
|
||||
"revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab",
|
||||
"version" : "1.7.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-clocks",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-clocks",
|
||||
"state" : {
|
||||
"revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e",
|
||||
"version" : "1.0.6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-concurrency-extras",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
|
||||
"state" : {
|
||||
"revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
|
||||
"version" : "1.3.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-crypto",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-crypto.git",
|
||||
"state" : {
|
||||
"revision" : "476538ccb827f2dd18efc5de754cc87d77127a47",
|
||||
"version" : "4.4.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-http-types",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-http-types.git",
|
||||
"state" : {
|
||||
"revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca",
|
||||
"version" : "1.5.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "xctest-dynamic-overlay",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
|
||||
"state" : {
|
||||
"revision" : "dfd70507def84cb5fb821278448a262c6ff2bbad",
|
||||
"version" : "1.9.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
Binary file not shown.
@@ -1,79 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2640"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "92991789C3A5B2A5FACF07A1"
|
||||
BuildableName = "TabataGo.app"
|
||||
BlueprintName = "TabataGo"
|
||||
ReferencedContainer = "container:TabataGo.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES"
|
||||
queueDebuggingEnableBacktraceRecording = "Yes">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "92991789C3A5B2A5FACF07A1"
|
||||
BuildableName = "TabataGo.app"
|
||||
BlueprintName = "TabataGo"
|
||||
ReferencedContainer = "container:TabataGo.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "92991789C3A5B2A5FACF07A1"
|
||||
BuildableName = "TabataGo.app"
|
||||
BlueprintName = "TabataGo"
|
||||
ReferencedContainer = "container:TabataGo.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -1,55 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2640"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "D1E2CCCC7C9BD41029959883"
|
||||
BuildableName = "TabataGoTests.xctest"
|
||||
BlueprintName = "TabataGoTests"
|
||||
ReferencedContainer = "container:TabataGo.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES"
|
||||
queueDebuggingEnableBacktraceRecording = "Yes">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -1,57 +0,0 @@
|
||||
<?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>SchemeUserState</key>
|
||||
<dict>
|
||||
<key>TabataGo.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>4</integer>
|
||||
</dict>
|
||||
<key>TabataGoTests.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>3</integer>
|
||||
</dict>
|
||||
<key>TabataGoUITests.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>2</integer>
|
||||
</dict>
|
||||
<key>TabataGoWatch.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
<key>TabataGoWatchWidget.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
<dict>
|
||||
<key>3945C3998B4B66F30759718C</key>
|
||||
<dict>
|
||||
<key>primary</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>90BAF2DB5D7456CD45975E26</key>
|
||||
<dict>
|
||||
<key>primary</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>92991789C3A5B2A5FACF07A1</key>
|
||||
<dict>
|
||||
<key>primary</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>D1E2CCCC7C9BD41029959883</key>
|
||||
<dict>
|
||||
<key>primary</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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() {}
|
||||
}
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,30 +1,38 @@
|
||||
import Foundation
|
||||
#if canImport(PostHog)
|
||||
import PostHog
|
||||
#endif
|
||||
|
||||
/// PostHog analytics — mirrors the event taxonomy from the Expo app.
|
||||
final class AnalyticsService: @unchecked Sendable {
|
||||
|
||||
static let shared = AnalyticsService()
|
||||
|
||||
#if canImport(PostHog)
|
||||
private let apiKey: String =
|
||||
Bundle.main.infoDictionary?["POSTHOG_API_KEY"] as? String ?? ""
|
||||
|
||||
private let host = "https://eu.posthog.com"
|
||||
#endif
|
||||
|
||||
private init() {}
|
||||
|
||||
func initialize() {
|
||||
#if canImport(PostHog)
|
||||
guard !apiKey.isEmpty else { return }
|
||||
let config = PostHogConfig(apiKey: apiKey, host: host)
|
||||
config.captureApplicationLifecycleEvents = true
|
||||
config.captureScreenViews = false // manual tracking
|
||||
PostHogSDK.shared.setup(config)
|
||||
#endif
|
||||
}
|
||||
|
||||
// ─── Screens ────────────────────────────────────────────────
|
||||
|
||||
func screen(_ name: String, properties: [String: Any] = [:]) {
|
||||
#if canImport(PostHog)
|
||||
PostHogSDK.shared.screen(name, properties: properties)
|
||||
#endif
|
||||
}
|
||||
|
||||
// ─── Onboarding ─────────────────────────────────────────────
|
||||
@@ -100,6 +108,8 @@ final class AnalyticsService: @unchecked Sendable {
|
||||
// ─── Private ─────────────────────────────────────────────────
|
||||
|
||||
private func capture(_ event: String, properties: [String: Any] = [:]) {
|
||||
#if canImport(PostHog)
|
||||
PostHogSDK.shared.capture(event, properties: properties.isEmpty ? nil : properties)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -317,5 +317,5 @@ struct ProgramRow: View {
|
||||
#Preview {
|
||||
HomeTab(previewVM: HomeViewModel(previewPrograms: [PreviewData.sampleProgram]))
|
||||
.modelContainer(TabataGoSchema.previewContainer)
|
||||
.environment(AppState())
|
||||
.environment(AppState.shared)
|
||||
}
|
||||
|
||||
@@ -48,5 +48,5 @@ struct MainTabView: View {
|
||||
#Preview {
|
||||
MainTabView()
|
||||
.modelContainer(TabataGoSchema.previewContainer)
|
||||
.environment(AppState())
|
||||
.environment(AppState.shared)
|
||||
}
|
||||
|
||||
@@ -139,5 +139,5 @@ struct ProfileRow: View {
|
||||
#Preview {
|
||||
ProfileTab()
|
||||
.modelContainer(TabataGoSchema.previewContainer)
|
||||
.environment(AppState())
|
||||
.environment(AppState.shared)
|
||||
}
|
||||
|
||||
@@ -103,5 +103,5 @@ struct ProgramsTab: View {
|
||||
#Preview {
|
||||
ProgramsTab()
|
||||
.modelContainer(TabataGoSchema.previewContainer)
|
||||
.environment(AppState())
|
||||
.environment(AppState.shared)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ struct TabataEntry: TimelineEntry {
|
||||
|
||||
struct TabataProvider: TimelineProvider {
|
||||
|
||||
private let sharedDefaults = UserDefaults(suiteName: "group.com.tabatago.app")
|
||||
private let sharedDefaults = UserDefaults(suiteName: "group.fr.millianlmx.tabatago")
|
||||
|
||||
func placeholder(in context: Context) -> TabataEntry {
|
||||
TabataEntry(date: Date(), streak: 7, lastWorkoutLabel: "Today")
|
||||
@@ -98,6 +98,8 @@ struct RectangularComplicationView: View {
|
||||
}
|
||||
|
||||
/// `.accessoryCorner` — tiny bolt + streak digit in the corner
|
||||
#if canImport(WatchKit)
|
||||
@available(watchOS 10.0, *)
|
||||
struct CornerComplicationView: View {
|
||||
let entry: TabataEntry
|
||||
|
||||
@@ -111,6 +113,7 @@ struct CornerComplicationView: View {
|
||||
.widgetLabel(String(format: String(localized: LocalizedStringResource("watch.complication.dayStreakFmt")), entry.streak))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// ─── Widget definition ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -124,11 +127,11 @@ struct TabataGoComplication: Widget {
|
||||
}
|
||||
.configurationDisplayName("TabataGo")
|
||||
.description(String(localized: LocalizedStringResource("watch.complication.description")))
|
||||
.supportedFamilies([
|
||||
.accessoryCircular,
|
||||
.accessoryRectangular,
|
||||
.accessoryCorner
|
||||
])
|
||||
#if canImport(WatchKit)
|
||||
.supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryCorner])
|
||||
#else
|
||||
.supportedFamilies([.accessoryCircular, .accessoryRectangular])
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,8 +145,10 @@ struct TabataComplicationEntryView: View {
|
||||
CircularComplicationView(entry: entry)
|
||||
case .accessoryRectangular:
|
||||
RectangularComplicationView(entry: entry)
|
||||
#if canImport(WatchKit)
|
||||
case .accessoryCorner:
|
||||
CornerComplicationView(entry: entry)
|
||||
#endif
|
||||
default:
|
||||
CircularComplicationView(entry: entry)
|
||||
}
|
||||
@@ -163,3 +168,12 @@ struct TabataComplicationEntryView: View {
|
||||
} timeline: {
|
||||
TabataEntry(date: .now, streak: 7, lastWorkoutLabel: "Today")
|
||||
}
|
||||
|
||||
#if canImport(WatchKit)
|
||||
@available(watchOS 10.0, *)
|
||||
#Preview("Corner", as: .accessoryCorner) {
|
||||
TabataGoComplication()
|
||||
} timeline: {
|
||||
TabataEntry(date: .now, streak: 7, lastWorkoutLabel: "Today")
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -27,6 +27,6 @@
|
||||
<key>WKApplication</key>
|
||||
<true/>
|
||||
<key>WKCompanionAppBundleIdentifier</key>
|
||||
<string>com.tabatago.app</string>
|
||||
<string>fr.millianlmx.tabatago</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.tabatago.app</string>
|
||||
<string>group.fr.millianlmx.tabatago</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -13,7 +13,7 @@ struct WatchActivityView: View {
|
||||
@State private var isLoading = true
|
||||
|
||||
private let healthStore = HKHealthStore()
|
||||
private let sharedDefaults = UserDefaults(suiteName: "group.com.tabatago.app")
|
||||
private let sharedDefaults = UserDefaults(suiteName: "group.fr.millianlmx.tabatago")
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<string>2</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: TabataGo
|
||||
|
||||
options:
|
||||
bundleIdPrefix: com.tabatago
|
||||
bundleIdPrefix: fr.millianlmx.tabatago
|
||||
deploymentTarget:
|
||||
iOS: "26.0"
|
||||
watchOS: "11.0"
|
||||
@@ -15,7 +15,11 @@ settings:
|
||||
SWIFT_VERSION: "6.0"
|
||||
IPHONEOS_DEPLOYMENT_TARGET: "26.0"
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
SWIFT_EMIT_MODULE_FOR_EXPLICIT_BUILD: NO
|
||||
GCC_TREAT_WARNINGS_AS_ERRORS: NO
|
||||
SWIFT_TREAT_WARNINGS_AS_ERRORS: NO
|
||||
DEBUG_INFORMATION_FORMAT: dwarf-with-dsym
|
||||
DEVELOPMENT_TEAM: "2MJF39L8VY"
|
||||
|
||||
packages:
|
||||
Supabase:
|
||||
@@ -24,9 +28,6 @@ packages:
|
||||
RevenueCat:
|
||||
url: https://github.com/RevenueCat/purchases-ios
|
||||
from: "5.0.0"
|
||||
PostHog:
|
||||
url: https://github.com/PostHog/posthog-ios
|
||||
from: "3.0.0"
|
||||
|
||||
targets:
|
||||
TabataGo:
|
||||
@@ -46,7 +47,7 @@ targets:
|
||||
properties:
|
||||
CFBundleDisplayName: TabataGo
|
||||
CFBundleShortVersionString: "1.0"
|
||||
CFBundleVersion: "1"
|
||||
CFBundleVersion: "2"
|
||||
UILaunchScreen:
|
||||
UIColorName: ""
|
||||
UIImageName: ""
|
||||
@@ -66,19 +67,20 @@ targets:
|
||||
com.apple.developer.healthkit.access:
|
||||
- health-records
|
||||
com.apple.security.application-groups:
|
||||
- group.com.tabatago.app
|
||||
- group.fr.millianlmx.tabatago
|
||||
dependencies:
|
||||
- package: Supabase
|
||||
product: Supabase
|
||||
- package: RevenueCat
|
||||
product: RevenueCat
|
||||
- package: PostHog
|
||||
product: PostHog
|
||||
|
||||
- target: TabataGoWatch
|
||||
embed: true
|
||||
- target: TabataGoWidget
|
||||
embed: true
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.tabatago.app
|
||||
PRODUCT_BUNDLE_IDENTIFIER: fr.millianlmx.tabatago
|
||||
INFOPLIST_FILE: TabataGo/Resources/Info.plist
|
||||
CODE_SIGN_ENTITLEMENTS: TabataGo/Resources/TabataGo.entitlements
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
@@ -110,9 +112,9 @@ targets:
|
||||
properties:
|
||||
CFBundleDisplayName: TabataGo
|
||||
CFBundleShortVersionString: "1.0"
|
||||
CFBundleVersion: "1"
|
||||
CFBundleVersion: "2"
|
||||
WKApplication: true
|
||||
WKCompanionAppBundleIdentifier: com.tabatago.app
|
||||
WKCompanionAppBundleIdentifier: fr.millianlmx.tabatago
|
||||
NSHealthShareUsageDescription: "TabataGo reads your heart rate and calories during workouts."
|
||||
NSHealthUpdateUsageDescription: "TabataGo saves workout data to Apple Health directly from your Watch."
|
||||
entitlements:
|
||||
@@ -120,13 +122,13 @@ targets:
|
||||
properties:
|
||||
com.apple.developer.healthkit: true
|
||||
com.apple.security.application-groups:
|
||||
- group.com.tabatago.app
|
||||
- group.fr.millianlmx.tabatago
|
||||
dependencies:
|
||||
- target: TabataGoWatchWidget
|
||||
embed: true
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.tabatago.app.watchkitapp
|
||||
PRODUCT_BUNDLE_IDENTIFIER: fr.millianlmx.tabatago.watchkitapp
|
||||
INFOPLIST_FILE: TabataGoWatch/Resources/Info.plist
|
||||
CODE_SIGN_ENTITLEMENTS: TabataGoWatch/Resources/TabataGoWatch.entitlements
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
@@ -147,15 +149,46 @@ targets:
|
||||
properties:
|
||||
CFBundleDisplayName: TabataGoWidget
|
||||
CFBundleShortVersionString: "1.0"
|
||||
CFBundleVersion: "1"
|
||||
CFBundleVersion: "2"
|
||||
NSExtension:
|
||||
NSExtensionPointIdentifier: com.apple.widgetkit-extension
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.tabatago.app.watchkitapp.widget
|
||||
PRODUCT_BUNDLE_IDENTIFIER: fr.millianlmx.tabatago.watchkitapp.widget
|
||||
TARGETED_DEVICE_FAMILY: "4"
|
||||
WATCHOS_DEPLOYMENT_TARGET: "11.0"
|
||||
|
||||
TabataGoWidget:
|
||||
type: app-extension
|
||||
platform: iOS
|
||||
deploymentTarget: "26.0"
|
||||
sources:
|
||||
- path: TabataGoWidget
|
||||
excludes:
|
||||
- "**/.DS_Store"
|
||||
# Shared attribute types — needed by both main app and widget extension
|
||||
- path: TabataGo/Models/WorkoutActivityAttributes.swift
|
||||
group: TabataGoWidget
|
||||
- path: TabataGo/Models/MusicActivityAttributes.swift
|
||||
group: TabataGoWidget
|
||||
info:
|
||||
path: TabataGoWidget/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: TabataGoWidget
|
||||
CFBundleShortVersionString: "1.0"
|
||||
CFBundleVersion: "2"
|
||||
NSExtension:
|
||||
NSExtensionPointIdentifier: com.apple.widgetkit-extension
|
||||
NSSupportsLiveActivities: true
|
||||
NSSupportsLiveActivitiesFrequentUpdates: true
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: fr.millianlmx.tabatago.widget
|
||||
INFOPLIST_FILE: TabataGoWidget/Info.plist
|
||||
IPHONEOS_DEPLOYMENT_TARGET: "26.0"
|
||||
TARGETED_DEVICE_FAMILY: "1"
|
||||
ENABLE_PREVIEWS: YES
|
||||
|
||||
TabataGoTests:
|
||||
type: bundle.unit-test
|
||||
platform: iOS
|
||||
@@ -166,7 +199,7 @@ targets:
|
||||
- target: TabataGo
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.tabatago.app.tests
|
||||
PRODUCT_BUNDLE_IDENTIFIER: fr.millianlmx.tabatago.tests
|
||||
|
||||
TabataGoUITests:
|
||||
type: bundle.ui-testing
|
||||
@@ -178,4 +211,15 @@ targets:
|
||||
- target: TabataGo
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.tabatago.app.uitests
|
||||
PRODUCT_BUNDLE_IDENTIFIER: fr.millianlmx.tabatago.uitests
|
||||
|
||||
schemes:
|
||||
TabataGo:
|
||||
build:
|
||||
targets:
|
||||
TabataGo: all
|
||||
TabataGoWidget: all
|
||||
run:
|
||||
config: Debug
|
||||
archive:
|
||||
config: Release
|
||||
|
||||
Reference in New Issue
Block a user