Compare commits
30 Commits
197324188c
...
fix/health
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9943dce82d | ||
|
|
cf096f2068 | ||
|
|
d74c47b1a8 | ||
|
|
0f5b7b9e18 | ||
|
|
e28bebea79 | ||
|
|
9f15ae2d79 | ||
|
|
877f836f19 | ||
|
|
2413bc0356 | ||
|
|
89cca25e22 | ||
|
|
8c90b73d90 | ||
|
|
d4edf54aeb | ||
|
|
5888aac08e | ||
|
|
04b83fc419 | ||
|
|
13262305e5 | ||
|
|
3fe9d926ad | ||
|
|
d82205cd71 | ||
|
|
791f432334 | ||
|
|
e0e02c4550 | ||
|
|
0990ec8e11 | ||
|
|
4458044d0e | ||
|
|
edcd857c70 | ||
|
|
3d8d9efd70 | ||
|
|
8926de58e5 | ||
|
|
569a9e178f | ||
|
|
b833198e9d | ||
|
|
f11eb6b9ae | ||
|
|
4fa8be600c | ||
|
|
a042c348c1 | ||
|
|
cd065d07c3 | ||
|
|
8703c484e8 |
576
.claude/skills/design_system/SKILL.md
Normal file
576
.claude/skills/design_system/SKILL.md
Normal file
@@ -0,0 +1,576 @@
|
||||
---
|
||||
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
|
||||
82
.claude/skills/gitnexus/gitnexus-cli/SKILL.md
Normal file
82
.claude/skills/gitnexus/gitnexus-cli/SKILL.md
Normal file
@@ -0,0 +1,82 @@
|
||||
---
|
||||
name: gitnexus-cli
|
||||
description: "Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: \"Index this repo\", \"Reanalyze the codebase\", \"Generate a wiki\""
|
||||
---
|
||||
|
||||
# GitNexus CLI Commands
|
||||
|
||||
All commands work via `npx` — no global install required.
|
||||
|
||||
## Commands
|
||||
|
||||
### analyze — Build or refresh the index
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze
|
||||
```
|
||||
|
||||
Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files.
|
||||
|
||||
| Flag | Effect |
|
||||
| -------------- | ---------------------------------------------------------------- |
|
||||
| `--force` | Force full re-index even if up to date |
|
||||
| `--embeddings` | Enable embedding generation for semantic search (off by default) |
|
||||
|
||||
**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.
|
||||
|
||||
### status — Check index freshness
|
||||
|
||||
```bash
|
||||
npx gitnexus status
|
||||
```
|
||||
|
||||
Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed.
|
||||
|
||||
### clean — Delete the index
|
||||
|
||||
```bash
|
||||
npx gitnexus clean
|
||||
```
|
||||
|
||||
Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project.
|
||||
|
||||
| Flag | Effect |
|
||||
| --------- | ------------------------------------------------- |
|
||||
| `--force` | Skip confirmation prompt |
|
||||
| `--all` | Clean all indexed repos, not just the current one |
|
||||
|
||||
### wiki — Generate documentation from the graph
|
||||
|
||||
```bash
|
||||
npx gitnexus wiki
|
||||
```
|
||||
|
||||
Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use).
|
||||
|
||||
| Flag | Effect |
|
||||
| ------------------- | ----------------------------------------- |
|
||||
| `--force` | Force full regeneration |
|
||||
| `--model <model>` | LLM model (default: minimax/minimax-m2.5) |
|
||||
| `--base-url <url>` | LLM API base URL |
|
||||
| `--api-key <key>` | LLM API key |
|
||||
| `--concurrency <n>` | Parallel LLM calls (default: 3) |
|
||||
| `--gist` | Publish wiki as a public GitHub Gist |
|
||||
|
||||
### list — Show all indexed repos
|
||||
|
||||
```bash
|
||||
npx gitnexus list
|
||||
```
|
||||
|
||||
Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information.
|
||||
|
||||
## After Indexing
|
||||
|
||||
1. **Read `gitnexus://repo/{name}/context`** to verify the index loaded
|
||||
2. Use the other GitNexus skills (`exploring`, `debugging`, `impact-analysis`, `refactoring`) for your task
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"Not inside a git repository"**: Run from a directory inside a git repo
|
||||
- **Index is stale after re-analyzing**: Restart Claude Code to reload the MCP server
|
||||
- **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding
|
||||
89
.claude/skills/gitnexus/gitnexus-debugging/SKILL.md
Normal file
89
.claude/skills/gitnexus/gitnexus-debugging/SKILL.md
Normal file
@@ -0,0 +1,89 @@
|
||||
---
|
||||
name: gitnexus-debugging
|
||||
description: "Use when the user is debugging a bug, tracing an error, or asking why something fails. Examples: \"Why is X failing?\", \"Where does this error come from?\", \"Trace this bug\""
|
||||
---
|
||||
|
||||
# Debugging with GitNexus
|
||||
|
||||
## When to Use
|
||||
|
||||
- "Why is this function failing?"
|
||||
- "Trace where this error comes from"
|
||||
- "Who calls this method?"
|
||||
- "This endpoint returns 500"
|
||||
- Investigating bugs, errors, or unexpected behavior
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. gitnexus_query({query: "<error or symptom>"}) → Find related execution flows
|
||||
2. gitnexus_context({name: "<suspect>"}) → See callers/callees/processes
|
||||
3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow
|
||||
4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||
|
||||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] Understand the symptom (error message, unexpected behavior)
|
||||
- [ ] gitnexus_query for error text or related code
|
||||
- [ ] Identify the suspect function from returned processes
|
||||
- [ ] gitnexus_context to see callers and callees
|
||||
- [ ] Trace execution flow via process resource if applicable
|
||||
- [ ] gitnexus_cypher for custom call chain traces if needed
|
||||
- [ ] Read source files to confirm root cause
|
||||
```
|
||||
|
||||
## Debugging Patterns
|
||||
|
||||
| Symptom | GitNexus Approach |
|
||||
| -------------------- | ---------------------------------------------------------- |
|
||||
| Error message | `gitnexus_query` for error text → `context` on throw sites |
|
||||
| Wrong return value | `context` on the function → trace callees for data flow |
|
||||
| Intermittent failure | `context` → look for external calls, async deps |
|
||||
| Performance issue | `context` → find symbols with many callers (hot paths) |
|
||||
| Recent regression | `detect_changes` to see what your changes affect |
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_query** — find code related to error:
|
||||
|
||||
```
|
||||
gitnexus_query({query: "payment validation error"})
|
||||
→ Processes: CheckoutFlow, ErrorHandling
|
||||
→ Symbols: validatePayment, handlePaymentError, PaymentException
|
||||
```
|
||||
|
||||
**gitnexus_context** — full context for a suspect:
|
||||
|
||||
```
|
||||
gitnexus_context({name: "validatePayment"})
|
||||
→ Incoming calls: processCheckout, webhookHandler
|
||||
→ Outgoing calls: verifyCard, fetchRates (external API!)
|
||||
→ Processes: CheckoutFlow (step 3/7)
|
||||
```
|
||||
|
||||
**gitnexus_cypher** — custom call chain traces:
|
||||
|
||||
```cypher
|
||||
MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"})
|
||||
RETURN [n IN nodes(path) | n.name] AS chain
|
||||
```
|
||||
|
||||
## Example: "Payment endpoint returns 500 intermittently"
|
||||
|
||||
```
|
||||
1. gitnexus_query({query: "payment error handling"})
|
||||
→ Processes: CheckoutFlow, ErrorHandling
|
||||
→ Symbols: validatePayment, handlePaymentError
|
||||
|
||||
2. gitnexus_context({name: "validatePayment"})
|
||||
→ Outgoing calls: verifyCard, fetchRates (external API!)
|
||||
|
||||
3. READ gitnexus://repo/my-app/process/CheckoutFlow
|
||||
→ Step 3: validatePayment → calls fetchRates (external)
|
||||
|
||||
4. Root cause: fetchRates calls external API without proper timeout
|
||||
```
|
||||
78
.claude/skills/gitnexus/gitnexus-exploring/SKILL.md
Normal file
78
.claude/skills/gitnexus/gitnexus-exploring/SKILL.md
Normal file
@@ -0,0 +1,78 @@
|
||||
---
|
||||
name: gitnexus-exploring
|
||||
description: "Use when the user asks how code works, wants to understand architecture, trace execution flows, or explore unfamiliar parts of the codebase. Examples: \"How does X work?\", \"What calls this function?\", \"Show me the auth flow\""
|
||||
---
|
||||
|
||||
# Exploring Codebases with GitNexus
|
||||
|
||||
## When to Use
|
||||
|
||||
- "How does authentication work?"
|
||||
- "What's the project structure?"
|
||||
- "Show me the main components"
|
||||
- "Where is the database logic?"
|
||||
- Understanding code you haven't seen before
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. READ gitnexus://repos → Discover indexed repos
|
||||
2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness
|
||||
3. gitnexus_query({query: "<what you want to understand>"}) → Find related execution flows
|
||||
4. gitnexus_context({name: "<symbol>"}) → Deep dive on specific symbol
|
||||
5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
|
||||
```
|
||||
|
||||
> If step 2 says "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||
|
||||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] READ gitnexus://repo/{name}/context
|
||||
- [ ] gitnexus_query for the concept you want to understand
|
||||
- [ ] Review returned processes (execution flows)
|
||||
- [ ] gitnexus_context on key symbols for callers/callees
|
||||
- [ ] READ process resource for full execution traces
|
||||
- [ ] Read source files for implementation details
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | What you get |
|
||||
| --------------------------------------- | ------------------------------------------------------- |
|
||||
| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) |
|
||||
| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) |
|
||||
| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) |
|
||||
| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) |
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_query** — find execution flows related to a concept:
|
||||
|
||||
```
|
||||
gitnexus_query({query: "payment processing"})
|
||||
→ Processes: CheckoutFlow, RefundFlow, WebhookHandler
|
||||
→ Symbols grouped by flow with file locations
|
||||
```
|
||||
|
||||
**gitnexus_context** — 360-degree view of a symbol:
|
||||
|
||||
```
|
||||
gitnexus_context({name: "validateUser"})
|
||||
→ Incoming calls: loginHandler, apiMiddleware
|
||||
→ Outgoing calls: checkToken, getUserById
|
||||
→ Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3)
|
||||
```
|
||||
|
||||
## Example: "How does payment processing work?"
|
||||
|
||||
```
|
||||
1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes
|
||||
2. gitnexus_query({query: "payment processing"})
|
||||
→ CheckoutFlow: processPayment → validateCard → chargeStripe
|
||||
→ RefundFlow: initiateRefund → calculateRefund → processRefund
|
||||
3. gitnexus_context({name: "processPayment"})
|
||||
→ Incoming: checkoutHandler, webhookHandler
|
||||
→ Outgoing: validateCard, chargeStripe, saveTransaction
|
||||
4. Read src/payments/processor.ts for implementation details
|
||||
```
|
||||
64
.claude/skills/gitnexus/gitnexus-guide/SKILL.md
Normal file
64
.claude/skills/gitnexus/gitnexus-guide/SKILL.md
Normal file
@@ -0,0 +1,64 @@
|
||||
---
|
||||
name: gitnexus-guide
|
||||
description: "Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: \"What GitNexus tools are available?\", \"How do I use GitNexus?\""
|
||||
---
|
||||
|
||||
# GitNexus Guide
|
||||
|
||||
Quick reference for all GitNexus MCP tools, resources, and the knowledge graph schema.
|
||||
|
||||
## Always Start Here
|
||||
|
||||
For any task involving code understanding, debugging, impact analysis, or refactoring:
|
||||
|
||||
1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness
|
||||
2. **Match your task to a skill below** and **read that skill file**
|
||||
3. **Follow the skill's workflow and checklist**
|
||||
|
||||
> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first.
|
||||
|
||||
## Skills
|
||||
|
||||
| Task | Skill to read |
|
||||
| -------------------------------------------- | ------------------- |
|
||||
| Understand architecture / "How does X work?" | `gitnexus-exploring` |
|
||||
| Blast radius / "What breaks if I change X?" | `gitnexus-impact-analysis` |
|
||||
| Trace bugs / "Why is X failing?" | `gitnexus-debugging` |
|
||||
| Rename / extract / split / refactor | `gitnexus-refactoring` |
|
||||
| Tools, resources, schema reference | `gitnexus-guide` (this file) |
|
||||
| Index, status, clean, wiki CLI commands | `gitnexus-cli` |
|
||||
|
||||
## Tools Reference
|
||||
|
||||
| Tool | What it gives you |
|
||||
| ---------------- | ------------------------------------------------------------------------ |
|
||||
| `query` | Process-grouped code intelligence — execution flows related to a concept |
|
||||
| `context` | 360-degree symbol view — categorized refs, processes it participates in |
|
||||
| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence |
|
||||
| `detect_changes` | Git-diff impact — what do your current changes affect |
|
||||
| `rename` | Multi-file coordinated rename with confidence-tagged edits |
|
||||
| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
|
||||
| `list_repos` | Discover indexed repos |
|
||||
|
||||
## Resources Reference
|
||||
|
||||
Lightweight reads (~100-500 tokens) for navigation:
|
||||
|
||||
| Resource | Content |
|
||||
| ---------------------------------------------- | ----------------------------------------- |
|
||||
| `gitnexus://repo/{name}/context` | Stats, staleness check |
|
||||
| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores |
|
||||
| `gitnexus://repo/{name}/cluster/{clusterName}` | Area members |
|
||||
| `gitnexus://repo/{name}/processes` | All execution flows |
|
||||
| `gitnexus://repo/{name}/process/{processName}` | Step-by-step trace |
|
||||
| `gitnexus://repo/{name}/schema` | Graph schema for Cypher |
|
||||
|
||||
## Graph Schema
|
||||
|
||||
**Nodes:** File, Function, Class, Interface, Method, Community, Process
|
||||
**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS
|
||||
|
||||
```cypher
|
||||
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"})
|
||||
RETURN caller.name, caller.filePath
|
||||
```
|
||||
97
.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md
Normal file
97
.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md
Normal file
@@ -0,0 +1,97 @@
|
||||
---
|
||||
name: gitnexus-impact-analysis
|
||||
description: "Use when the user wants to know what will break if they change something, or needs safety analysis before editing code. Examples: \"Is it safe to change X?\", \"What depends on this?\", \"What will break?\""
|
||||
---
|
||||
|
||||
# Impact Analysis with GitNexus
|
||||
|
||||
## When to Use
|
||||
|
||||
- "Is it safe to change this function?"
|
||||
- "What will break if I modify X?"
|
||||
- "Show me the blast radius"
|
||||
- "Who uses this code?"
|
||||
- Before making non-trivial code changes
|
||||
- Before committing — to understand what your changes affect
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. gitnexus_impact({target: "X", direction: "upstream"}) → What depends on this
|
||||
2. READ gitnexus://repo/{name}/processes → Check affected execution flows
|
||||
3. gitnexus_detect_changes() → Map current git changes to affected flows
|
||||
4. Assess risk and report to user
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||
|
||||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] gitnexus_impact({target, direction: "upstream"}) to find dependents
|
||||
- [ ] Review d=1 items first (these WILL BREAK)
|
||||
- [ ] Check high-confidence (>0.8) dependencies
|
||||
- [ ] READ processes to check affected execution flows
|
||||
- [ ] gitnexus_detect_changes() for pre-commit check
|
||||
- [ ] Assess risk level and report to user
|
||||
```
|
||||
|
||||
## Understanding Output
|
||||
|
||||
| Depth | Risk Level | Meaning |
|
||||
| ----- | ---------------- | ------------------------ |
|
||||
| d=1 | **WILL BREAK** | Direct callers/importers |
|
||||
| d=2 | LIKELY AFFECTED | Indirect dependencies |
|
||||
| d=3 | MAY NEED TESTING | Transitive effects |
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Affected | Risk |
|
||||
| ------------------------------ | -------- |
|
||||
| <5 symbols, few processes | LOW |
|
||||
| 5-15 symbols, 2-5 processes | MEDIUM |
|
||||
| >15 symbols or many processes | HIGH |
|
||||
| Critical path (auth, payments) | CRITICAL |
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_impact** — the primary tool for symbol blast radius:
|
||||
|
||||
```
|
||||
gitnexus_impact({
|
||||
target: "validateUser",
|
||||
direction: "upstream",
|
||||
minConfidence: 0.8,
|
||||
maxDepth: 3
|
||||
})
|
||||
|
||||
→ d=1 (WILL BREAK):
|
||||
- loginHandler (src/auth/login.ts:42) [CALLS, 100%]
|
||||
- apiMiddleware (src/api/middleware.ts:15) [CALLS, 100%]
|
||||
|
||||
→ d=2 (LIKELY AFFECTED):
|
||||
- authRouter (src/routes/auth.ts:22) [CALLS, 95%]
|
||||
```
|
||||
|
||||
**gitnexus_detect_changes** — git-diff based impact analysis:
|
||||
|
||||
```
|
||||
gitnexus_detect_changes({scope: "staged"})
|
||||
|
||||
→ Changed: 5 symbols in 3 files
|
||||
→ Affected: LoginFlow, TokenRefresh, APIMiddlewarePipeline
|
||||
→ Risk: MEDIUM
|
||||
```
|
||||
|
||||
## Example: "What breaks if I change validateUser?"
|
||||
|
||||
```
|
||||
1. gitnexus_impact({target: "validateUser", direction: "upstream"})
|
||||
→ d=1: loginHandler, apiMiddleware (WILL BREAK)
|
||||
→ d=2: authRouter, sessionManager (LIKELY AFFECTED)
|
||||
|
||||
2. READ gitnexus://repo/my-app/processes
|
||||
→ LoginFlow and TokenRefresh touch validateUser
|
||||
|
||||
3. Risk: 2 direct callers, 2 processes = MEDIUM
|
||||
```
|
||||
121
.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md
Normal file
121
.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md
Normal file
@@ -0,0 +1,121 @@
|
||||
---
|
||||
name: gitnexus-refactoring
|
||||
description: "Use when the user wants to rename, extract, split, move, or restructure code safely. Examples: \"Rename this function\", \"Extract this into a module\", \"Refactor this class\", \"Move this to a separate file\""
|
||||
---
|
||||
|
||||
# Refactoring with GitNexus
|
||||
|
||||
## When to Use
|
||||
|
||||
- "Rename this function safely"
|
||||
- "Extract this into a module"
|
||||
- "Split this service"
|
||||
- "Move this to a new file"
|
||||
- Any task involving renaming, extracting, splitting, or restructuring code
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. gitnexus_impact({target: "X", direction: "upstream"}) → Map all dependents
|
||||
2. gitnexus_query({query: "X"}) → Find execution flows involving X
|
||||
3. gitnexus_context({name: "X"}) → See all incoming/outgoing refs
|
||||
4. Plan update order: interfaces → implementations → callers → tests
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
|
||||
|
||||
## Checklists
|
||||
|
||||
### Rename Symbol
|
||||
|
||||
```
|
||||
- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
|
||||
- [ ] Review graph edits (high confidence) and ast_search edits (review carefully)
|
||||
- [ ] If satisfied: gitnexus_rename({..., dry_run: false}) — apply edits
|
||||
- [ ] gitnexus_detect_changes() — verify only expected files changed
|
||||
- [ ] Run tests for affected processes
|
||||
```
|
||||
|
||||
### Extract Module
|
||||
|
||||
```
|
||||
- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs
|
||||
- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers
|
||||
- [ ] Define new module interface
|
||||
- [ ] Extract code, update imports
|
||||
- [ ] gitnexus_detect_changes() — verify affected scope
|
||||
- [ ] Run tests for affected processes
|
||||
```
|
||||
|
||||
### Split Function/Service
|
||||
|
||||
```
|
||||
- [ ] gitnexus_context({name: target}) — understand all callees
|
||||
- [ ] Group callees by responsibility
|
||||
- [ ] gitnexus_impact({target, direction: "upstream"}) — map callers to update
|
||||
- [ ] Create new functions/services
|
||||
- [ ] Update callers
|
||||
- [ ] gitnexus_detect_changes() — verify affected scope
|
||||
- [ ] Run tests for affected processes
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
**gitnexus_rename** — automated multi-file rename:
|
||||
|
||||
```
|
||||
gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
→ 12 edits across 8 files
|
||||
→ 10 graph edits (high confidence), 2 ast_search edits (review)
|
||||
→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}]
|
||||
```
|
||||
|
||||
**gitnexus_impact** — map all dependents first:
|
||||
|
||||
```
|
||||
gitnexus_impact({target: "validateUser", direction: "upstream"})
|
||||
→ d=1: loginHandler, apiMiddleware, testUtils
|
||||
→ Affected Processes: LoginFlow, TokenRefresh
|
||||
```
|
||||
|
||||
**gitnexus_detect_changes** — verify your changes after refactoring:
|
||||
|
||||
```
|
||||
gitnexus_detect_changes({scope: "all"})
|
||||
→ Changed: 8 files, 12 symbols
|
||||
→ Affected processes: LoginFlow, TokenRefresh
|
||||
→ Risk: MEDIUM
|
||||
```
|
||||
|
||||
**gitnexus_cypher** — custom reference queries:
|
||||
|
||||
```cypher
|
||||
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"})
|
||||
RETURN caller.name, caller.filePath ORDER BY caller.filePath
|
||||
```
|
||||
|
||||
## Risk Rules
|
||||
|
||||
| Risk Factor | Mitigation |
|
||||
| ------------------- | ----------------------------------------- |
|
||||
| Many callers (>5) | Use gitnexus_rename for automated updates |
|
||||
| Cross-area refs | Use detect_changes after to verify scope |
|
||||
| String/dynamic refs | gitnexus_query to find them |
|
||||
| External/public API | Version and deprecate properly |
|
||||
|
||||
## Example: Rename `validateUser` to `authenticateUser`
|
||||
|
||||
```
|
||||
1. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
→ 12 edits: 10 graph (safe), 2 ast_search (review)
|
||||
→ Files: validator.ts, login.ts, middleware.ts, config.json...
|
||||
|
||||
2. Review ast_search edits (config.json: dynamic reference!)
|
||||
|
||||
3. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false})
|
||||
→ Applied 12 edits across 8 files
|
||||
|
||||
4. gitnexus_detect_changes({scope: "all"})
|
||||
→ Affected: LoginFlow, TokenRefresh
|
||||
→ Risk: MEDIUM — run tests for these flows
|
||||
```
|
||||
4
.env
4
.env
@@ -1,4 +0,0 @@
|
||||
# TabataFit Environment Variables
|
||||
# Supabase Configuration
|
||||
EXPO_PUBLIC_SUPABASE_URL=https://supabase.1000co.fr
|
||||
EXPO_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzcyMjMzMjAwLCJleHAiOjE5Mjk5OTk2MDB9.SlYN046eGvUSObW0tFQHcMRUqFvtMqBLfFRlZliSx_w
|
||||
@@ -1,9 +0,0 @@
|
||||
# TabataFit Environment Variables
|
||||
# Copy this file to .env and fill in your Supabase credentials
|
||||
|
||||
# Supabase Configuration
|
||||
EXPO_PUBLIC_SUPABASE_URL=your_supabase_project_url
|
||||
EXPO_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
|
||||
|
||||
# Admin Dashboard (optional - for admin authentication)
|
||||
EXPO_PUBLIC_ADMIN_EMAIL=admin@tabatafit.app
|
||||
227
.github/workflows/ci.yml
vendored
Normal file
227
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,227 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
name: TypeScript
|
||||
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: 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,
|
||||
});
|
||||
}
|
||||
|
||||
admin-web-test:
|
||||
name: Admin Web Tests
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: admin-web
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: admin-web/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Type check
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- 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
|
||||
runs-on: ubuntu-latest
|
||||
needs: [typecheck, lint, test]
|
||||
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: Export web build
|
||||
run: npx expo export --platform web
|
||||
continue-on-error: true
|
||||
|
||||
deploy-functions:
|
||||
name: Deploy Edge Functions
|
||||
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
|
||||
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
|
||||
|
||||
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"
|
||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -41,3 +41,16 @@ app-example
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
|
||||
# Maestro
|
||||
.maestro/screenshots/
|
||||
.maestro/recordings/
|
||||
.maestro/env.local.yaml
|
||||
|
||||
# Test coverage
|
||||
coverage/
|
||||
|
||||
# Node compile cache
|
||||
node-compile-cache/
|
||||
.gitnexus
|
||||
Config/Secrets.xcconfig
|
||||
|
||||
158
.maestro/README.md
Normal file
158
.maestro/README.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# Maestro UI Testing
|
||||
|
||||
This directory contains Maestro UI tests for TabataFit.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Install Maestro CLI:
|
||||
```bash
|
||||
brew tap mobile-dev-inc/tap
|
||||
brew install maestro
|
||||
```
|
||||
|
||||
2. Build and install the app on your simulator/device:
|
||||
```bash
|
||||
# iOS
|
||||
npx expo run:ios
|
||||
|
||||
# Android
|
||||
npx expo run:android
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Run All Tests
|
||||
```bash
|
||||
npm run test:maestro:all
|
||||
```
|
||||
|
||||
### Run Individual Tests
|
||||
```bash
|
||||
# Onboarding flow
|
||||
npm run test:maestro:onboarding
|
||||
|
||||
# Program browsing
|
||||
npm run test:maestro:programs
|
||||
|
||||
# Tab navigation
|
||||
npm run test:maestro:tabs
|
||||
|
||||
# Paywall/subscription
|
||||
npm run test:maestro:paywall
|
||||
|
||||
# Reset app state
|
||||
npm run test:maestro:reset
|
||||
```
|
||||
|
||||
### Run with Maestro CLI directly
|
||||
```bash
|
||||
# Run specific flow
|
||||
maestro test .maestro/flows/onboarding.yaml
|
||||
|
||||
# Run all flows
|
||||
maestro test .maestro/flows
|
||||
|
||||
# Run with device selection
|
||||
maestro test --device "iPhone 15" .maestro/flows/onboarding.yaml
|
||||
```
|
||||
|
||||
## Test Flows
|
||||
|
||||
| Flow | Description | Prerequisites |
|
||||
|------|-------------|---------------|
|
||||
| `onboarding.yaml` | Complete 6-step onboarding | Fresh install |
|
||||
| `program-browse.yaml` | Browse and select programs | Completed onboarding |
|
||||
| `tab-navigation.yaml` | Navigate between tabs | Completed onboarding |
|
||||
| `subscription.yaml` | Test paywall interactions | Fresh install |
|
||||
| `assessment.yaml` | Start assessment workout | Completed onboarding, not assessment |
|
||||
| `reset-state.yaml` | Reset app to fresh state | None |
|
||||
| `all-tests.yaml` | Run all test flows | None |
|
||||
|
||||
## Test IDs
|
||||
|
||||
Key UI elements have `testID` props for reliable element selection:
|
||||
|
||||
### Onboarding
|
||||
- `onboarding-problem-cta` - Step 1 continue button
|
||||
- `barrier-{id}` - Barrier selection cards (no-time, low-motivation, no-knowledge, no-gym)
|
||||
- `onboarding-empathy-continue` - Step 2 continue button
|
||||
- `onboarding-solution-cta` - Step 3 continue button
|
||||
- `onboarding-wow-cta` - Step 4 continue button
|
||||
- `name-input` - Name text input
|
||||
- `level-{level}` - Fitness level buttons (beginner, intermediate, advanced)
|
||||
- `goal-{goal}` - Goal buttons (weight-loss, cardio, strength, wellness)
|
||||
- `frequency-{n}x` - Frequency buttons (2x, 3x, 5x)
|
||||
- `onboarding-personalization-continue` - Step 5 continue button
|
||||
- `plan-yearly` - Annual subscription card
|
||||
- `plan-monthly` - Monthly subscription card
|
||||
- `subscribe-button` - Subscribe CTA
|
||||
- `restore-purchases` - Restore purchases link
|
||||
- `skip-paywall` - Skip paywall link
|
||||
|
||||
### Home Screen
|
||||
- `program-card-{id}` - Program cards (upper-body, lower-body, full-body)
|
||||
- `program-{id}-cta` - Program CTA buttons
|
||||
- `assessment-card` - Assessment workout card
|
||||
|
||||
## Writing New Tests
|
||||
|
||||
1. Add `testID` prop to interactive elements in your component:
|
||||
```tsx
|
||||
<Pressable testID="my-button" onPress={handlePress}>
|
||||
<Text>Click me</Text>
|
||||
</Pressable>
|
||||
```
|
||||
|
||||
2. Create a new YAML file in `.maestro/flows/`:
|
||||
```yaml
|
||||
appId: com.millianlmx.tabatafit
|
||||
name: My Test
|
||||
|
||||
---
|
||||
- assertVisible: "my-button"
|
||||
- tapOn: "my-button"
|
||||
```
|
||||
|
||||
3. Add npm script to `package.json`:
|
||||
```json
|
||||
"test:maestro:mytest": "maestro test .maestro/flows/my-test.yaml"
|
||||
```
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
For GitHub Actions, add:
|
||||
|
||||
```yaml
|
||||
- name: Run Maestro Tests
|
||||
run: |
|
||||
brew tap mobile-dev-inc/tap
|
||||
brew install maestro
|
||||
npm run test:maestro:all
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- Use `assertVisible` to wait for elements
|
||||
- Use `optional: true` for elements that may not exist
|
||||
- Use `extendedWaitUntil` for longer timeouts
|
||||
- Use `runFlow` to compose tests from smaller flows
|
||||
- Use `env` to parameterize tests
|
||||
|
||||
## Debugging
|
||||
|
||||
```bash
|
||||
# Verbose output
|
||||
maestro test --verbose .maestro/flows/onboarding.yaml
|
||||
|
||||
# Take screenshot on failure
|
||||
maestro test --screenshot .maestro/flows/onboarding.yaml
|
||||
|
||||
# Record video
|
||||
maestro record .maestro/flows/onboarding.yaml
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Maestro Documentation](https://maestro.mobile.dev/)
|
||||
- [Maestro CLI Reference](https://maestro.mobile.dev/cli)
|
||||
- [Element Selectors](https://maestro.mobile.dev/platform-support/react-native)
|
||||
17
.maestro/config.yaml
Normal file
17
.maestro/config.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
# Maestro Configuration for TabataFit
|
||||
# https://maestro.mobile.dev/
|
||||
|
||||
# App identifiers (iOS bundleIdentifier / Android package)
|
||||
appId: com.millianlmx.tabatafit
|
||||
|
||||
# Default flows directory
|
||||
flows:
|
||||
- .maestro/flows
|
||||
|
||||
# Global settings
|
||||
defaultTimeout: 15000
|
||||
|
||||
# Environment variables (override in .maestro/env.yaml)
|
||||
env:
|
||||
TEST_USER_NAME: Test User
|
||||
TEST_USER_EMAIL: test@example.com
|
||||
82
.maestro/flows/activity-tab.yaml
Normal file
82
.maestro/flows/activity-tab.yaml
Normal file
@@ -0,0 +1,82 @@
|
||||
# Activity Tab Flow Test
|
||||
# Tests the activity/stats dashboard screen
|
||||
# Prerequisite: User must have completed onboarding
|
||||
|
||||
appId: com.millianlmx.tabatafit
|
||||
name: Activity Tab
|
||||
|
||||
---
|
||||
# Start from home screen
|
||||
- assertVisible: "program-card-upper-body"
|
||||
|
||||
# Navigate to Activity tab
|
||||
- tapOn:
|
||||
text: "Activity"
|
||||
optional: true
|
||||
- tapOn:
|
||||
id: "activity-tab"
|
||||
optional: true
|
||||
|
||||
# Verify activity screen loaded — check for stats elements
|
||||
- assertVisible:
|
||||
text: ".*Activity.*"
|
||||
timeout: 5000
|
||||
|
||||
# Check for streak display
|
||||
- assertVisible:
|
||||
text: ".*streak.*"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Check for workout count stats
|
||||
- assertVisible:
|
||||
text: ".*workout.*"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Check for calories display
|
||||
- assertVisible:
|
||||
text: ".*cal.*"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Scroll down to see weekly chart or history
|
||||
- scroll:
|
||||
direction: DOWN
|
||||
duration: 500
|
||||
|
||||
# Check for weekly chart or activity history section
|
||||
- assertVisible:
|
||||
text: ".*week.*"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Scroll down further to see history
|
||||
- scroll:
|
||||
direction: DOWN
|
||||
duration: 500
|
||||
|
||||
# Check for achievement badges if present
|
||||
- assertVisible:
|
||||
text: ".*achievement.*"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Scroll back to top
|
||||
- scroll:
|
||||
direction: UP
|
||||
duration: 1000
|
||||
|
||||
# Navigate back to Home
|
||||
- tapOn:
|
||||
text: "Home"
|
||||
optional: true
|
||||
- tapOn:
|
||||
id: "home-tab"
|
||||
optional: true
|
||||
|
||||
# Verify home screen
|
||||
- assertVisible:
|
||||
id: "program-card-upper-body"
|
||||
timeout: 5000
|
||||
optional: true
|
||||
33
.maestro/flows/all-tests.yaml
Normal file
33
.maestro/flows/all-tests.yaml
Normal file
@@ -0,0 +1,33 @@
|
||||
# All Tests Suite
|
||||
# Run all test flows sequentially
|
||||
|
||||
appId: com.millianlmx.tabatafit
|
||||
name: Full Test Suite
|
||||
|
||||
env:
|
||||
TEST_USER_NAME: Maestro Test User
|
||||
|
||||
---
|
||||
# Run onboarding flow
|
||||
- runFlow: ./onboarding.yaml
|
||||
|
||||
# Run program browsing
|
||||
- runFlow: ./program-browse.yaml
|
||||
|
||||
# Run tab navigation
|
||||
- runFlow: ./tab-navigation.yaml
|
||||
|
||||
# Run explore freemium (lock badges, paywall gating)
|
||||
- runFlow: ./explore-freemium.yaml
|
||||
|
||||
# Run collection detail
|
||||
- runFlow: ./collection-detail.yaml
|
||||
|
||||
# Run workout player
|
||||
- runFlow: ./workout-player.yaml
|
||||
|
||||
# Run activity tab
|
||||
- runFlow: ./activity-tab.yaml
|
||||
|
||||
# Run profile & settings
|
||||
- runFlow: ./profile-settings.yaml
|
||||
16
.maestro/flows/assessment.yaml
Normal file
16
.maestro/flows/assessment.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
# Assessment Flow Test
|
||||
# Tests starting the assessment workout from home screen
|
||||
# Prerequisite: User must have completed onboarding but not assessment
|
||||
|
||||
appId: com.millianlmx.tabatafit
|
||||
name: Assessment Flow
|
||||
|
||||
---
|
||||
# Look for assessment card (only visible if not completed)
|
||||
- assertVisible: "assessment-card"
|
||||
- tapOn: "assessment-card"
|
||||
|
||||
# Verify we're on assessment screen
|
||||
- assertVisible:
|
||||
text: ".*Assessment.*"
|
||||
timeout: 5000
|
||||
93
.maestro/flows/collection-detail.yaml
Normal file
93
.maestro/flows/collection-detail.yaml
Normal file
@@ -0,0 +1,93 @@
|
||||
# Collection Detail Test
|
||||
# Tests navigating to a collection and viewing its workouts
|
||||
# Prerequisite: User must have completed onboarding
|
||||
|
||||
appId: com.millianlmx.tabatafit
|
||||
name: Collection Detail
|
||||
|
||||
---
|
||||
# Navigate to Explore tab
|
||||
- tapOn:
|
||||
text: "Explore"
|
||||
optional: true
|
||||
- tapOn:
|
||||
id: "explore-tab"
|
||||
optional: true
|
||||
|
||||
# Verify Explore screen loaded
|
||||
- assertVisible:
|
||||
id: "explore-screen"
|
||||
timeout: 5000
|
||||
|
||||
# Verify collections section
|
||||
- assertVisible:
|
||||
id: "collections-section"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Tap the first collection card
|
||||
- tapOn:
|
||||
text: ".*collection.*"
|
||||
optional: true
|
||||
|
||||
# If collection-card testIDs are visible, tap by testID instead
|
||||
- tapOn:
|
||||
id: "collection-card-.*"
|
||||
optional: true
|
||||
|
||||
# Verify collection detail screen loaded
|
||||
- assertVisible:
|
||||
id: "collection-detail-screen"
|
||||
timeout: 5000
|
||||
optional: true
|
||||
|
||||
# Verify hero card is visible
|
||||
- assertVisible:
|
||||
id: "collection-hero"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Verify back button exists
|
||||
- assertVisible:
|
||||
id: "collection-back-button"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Verify workouts are listed
|
||||
- assertVisible:
|
||||
text: ".*Workout.*"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Scroll to see more workouts
|
||||
- scroll:
|
||||
direction: DOWN
|
||||
duration: 500
|
||||
|
||||
# Tap a workout in the collection
|
||||
- tapOn:
|
||||
id: "collection-workout-.*"
|
||||
optional: true
|
||||
|
||||
# Verify workout detail opened
|
||||
- assertVisible:
|
||||
id: "workout-detail-screen"
|
||||
timeout: 5000
|
||||
optional: true
|
||||
|
||||
# Go back to collection
|
||||
- pressKey: back
|
||||
optional: true
|
||||
|
||||
# Go back to explore via back button
|
||||
- tapOn:
|
||||
id: "collection-back-button"
|
||||
optional: true
|
||||
|
||||
# Navigate back to Home
|
||||
- tapOn:
|
||||
text: "Home"
|
||||
optional: true
|
||||
- tapOn:
|
||||
id: "home-tab"
|
||||
optional: true
|
||||
106
.maestro/flows/explore-freemium.yaml
Normal file
106
.maestro/flows/explore-freemium.yaml
Normal file
@@ -0,0 +1,106 @@
|
||||
# Explore Tab Freemium Test
|
||||
# Tests lock badges on non-free workouts, free workout access,
|
||||
# and paywall gating for locked workouts.
|
||||
# Prerequisite: User must have completed onboarding (free user, not premium)
|
||||
|
||||
appId: com.millianlmx.tabatafit
|
||||
name: Explore Freemium
|
||||
|
||||
---
|
||||
# Navigate to Explore tab
|
||||
- tapOn:
|
||||
text: "Explore"
|
||||
optional: true
|
||||
- tapOn:
|
||||
id: "explore-tab"
|
||||
optional: true
|
||||
|
||||
# Verify Explore screen loaded
|
||||
- assertVisible:
|
||||
id: "explore-screen"
|
||||
timeout: 5000
|
||||
|
||||
# Verify collections section is visible
|
||||
- assertVisible:
|
||||
id: "collections-section"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Verify featured section is visible
|
||||
- assertVisible:
|
||||
id: "featured-section"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Verify filters section is visible
|
||||
- assertVisible:
|
||||
id: "filters-section"
|
||||
timeout: 3000
|
||||
|
||||
# Scroll down to see workout cards
|
||||
- scroll:
|
||||
direction: DOWN
|
||||
duration: 500
|
||||
|
||||
# Tap a free workout (ID 1 — Full Body Ignite) — should go to detail, not paywall
|
||||
- tapOn:
|
||||
id: "workout-card-1"
|
||||
optional: true
|
||||
|
||||
# On workout detail: verify start button (not unlock)
|
||||
- assertVisible:
|
||||
id: "workout-start-button"
|
||||
timeout: 5000
|
||||
optional: true
|
||||
|
||||
# Verify video preview is rendered
|
||||
- assertVisible:
|
||||
id: "workout-video-preview"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Go back to explore
|
||||
- pressKey: back
|
||||
optional: true
|
||||
- tapOn:
|
||||
text: "Explore"
|
||||
optional: true
|
||||
|
||||
# Scroll to find a locked workout
|
||||
- scroll:
|
||||
direction: DOWN
|
||||
duration: 800
|
||||
|
||||
# Tap a locked workout (ID 2 — not in free tier)
|
||||
- tapOn:
|
||||
id: "workout-card-2"
|
||||
optional: true
|
||||
|
||||
# On workout detail: verify unlock/locked button
|
||||
- assertVisible:
|
||||
id: "workout-unlock-button"
|
||||
timeout: 5000
|
||||
optional: true
|
||||
|
||||
# Tap unlock button — should navigate to paywall
|
||||
- tapOn:
|
||||
id: "workout-unlock-button"
|
||||
optional: true
|
||||
|
||||
# Verify paywall screen appeared
|
||||
- assertVisible:
|
||||
text: ".*Premium.*"
|
||||
timeout: 5000
|
||||
optional: true
|
||||
|
||||
# Go back from paywall
|
||||
- pressKey: back
|
||||
optional: true
|
||||
|
||||
# Navigate back to Home
|
||||
- tapOn:
|
||||
text: "Home"
|
||||
optional: true
|
||||
- tapOn:
|
||||
id: "home-tab"
|
||||
optional: true
|
||||
46
.maestro/flows/onboarding.yaml
Normal file
46
.maestro/flows/onboarding.yaml
Normal file
@@ -0,0 +1,46 @@
|
||||
# Onboarding Flow Test
|
||||
# Tests the complete 6-step onboarding process
|
||||
|
||||
appId: com.millianlmx.tabatafit
|
||||
name: Onboarding Flow
|
||||
|
||||
---
|
||||
- launchApp
|
||||
# Step 1: Problem Screen
|
||||
- assertVisible: "onboarding-problem-cta"
|
||||
- tapOn: "onboarding-problem-cta"
|
||||
|
||||
# Step 2: Empathy Screen - Select barriers
|
||||
- assertVisible: "barrier-no-time"
|
||||
- tapOn: "barrier-no-time"
|
||||
- tapOn: "barrier-low-motivation"
|
||||
- assertVisible: "onboarding-empathy-continue"
|
||||
- tapOn: "onboarding-empathy-continue"
|
||||
|
||||
# Step 3: Solution Screen
|
||||
- assertVisible: "onboarding-solution-cta"
|
||||
- tapOn: "onboarding-solution-cta"
|
||||
|
||||
# Step 4: Wow Screen (features reveal)
|
||||
- assertVisible: "onboarding-wow-cta"
|
||||
- tapOn: "onboarding-wow-cta"
|
||||
|
||||
# Step 5: Personalization
|
||||
- assertVisible: "name-input"
|
||||
- tapOn: "name-input"
|
||||
- inputText: "Test User"
|
||||
|
||||
- tapOn: "level-intermediate"
|
||||
- tapOn: "goal-strength"
|
||||
- tapOn: "frequency-3x"
|
||||
|
||||
- assertVisible: "onboarding-personalization-continue"
|
||||
- tapOn: "onboarding-personalization-continue"
|
||||
|
||||
# Step 6: Paywall - Skip subscription
|
||||
- assertVisible: "subscribe-button"
|
||||
- assertVisible: "skip-paywall"
|
||||
- tapOn: "skip-paywall"
|
||||
|
||||
# Verify we're on the home screen
|
||||
- assertVisible: "program-card-upper-body"
|
||||
119
.maestro/flows/profile-settings.yaml
Normal file
119
.maestro/flows/profile-settings.yaml
Normal file
@@ -0,0 +1,119 @@
|
||||
# Profile & Settings Flow Test
|
||||
# Tests the profile screen, settings toggles, and navigation
|
||||
# Prerequisite: User must have completed onboarding
|
||||
|
||||
appId: com.millianlmx.tabatafit
|
||||
name: Profile Settings
|
||||
|
||||
---
|
||||
# Start from home screen
|
||||
- assertVisible: "program-card-upper-body"
|
||||
|
||||
# Navigate to Profile tab
|
||||
- tapOn:
|
||||
text: "Profile"
|
||||
optional: true
|
||||
- tapOn:
|
||||
id: "profile-tab"
|
||||
optional: true
|
||||
|
||||
# Verify profile screen loaded
|
||||
- assertVisible:
|
||||
text: ".*Profile.*"
|
||||
timeout: 5000
|
||||
|
||||
# Check user avatar/name is displayed
|
||||
- assertVisible:
|
||||
text: ".*Test User.*"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Check stats section — real activity store data (may show 0 if no workouts done)
|
||||
- assertVisible:
|
||||
text: ".*workout.*"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
- assertVisible:
|
||||
text: ".*min.*"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
- assertVisible:
|
||||
text: ".*cal.*"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Scroll to settings section
|
||||
- scroll:
|
||||
direction: DOWN
|
||||
duration: 500
|
||||
|
||||
# Check for Haptic Feedback toggle
|
||||
- assertVisible:
|
||||
text: ".*aptic.*"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Check for Sound Effects toggle
|
||||
- assertVisible:
|
||||
text: ".*ound.*"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Check for Voice Coaching toggle
|
||||
- assertVisible:
|
||||
text: ".*oice.*"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Scroll down to notifications section
|
||||
- scroll:
|
||||
direction: DOWN
|
||||
duration: 500
|
||||
|
||||
# Check for Reminders toggle
|
||||
- assertVisible:
|
||||
text: ".*eminder.*"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Scroll down to support section
|
||||
- scroll:
|
||||
direction: DOWN
|
||||
duration: 500
|
||||
|
||||
# Check for Rate App option
|
||||
- assertVisible:
|
||||
text: ".*Rate.*"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Check for Contact Us option
|
||||
- assertVisible:
|
||||
text: ".*Contact.*"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Check for app version
|
||||
- assertVisible:
|
||||
text: ".*1\\..*"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Scroll back to top
|
||||
- scroll:
|
||||
direction: UP
|
||||
duration: 1500
|
||||
|
||||
# Navigate back to Home
|
||||
- tapOn:
|
||||
text: "Home"
|
||||
optional: true
|
||||
- tapOn:
|
||||
id: "home-tab"
|
||||
optional: true
|
||||
|
||||
# Verify home screen
|
||||
- assertVisible:
|
||||
id: "program-card-upper-body"
|
||||
timeout: 5000
|
||||
optional: true
|
||||
42
.maestro/flows/program-browse.yaml
Normal file
42
.maestro/flows/program-browse.yaml
Normal file
@@ -0,0 +1,42 @@
|
||||
# Program Browsing Test
|
||||
# Tests navigation through programs from home screen
|
||||
# Prerequisite: User must have completed onboarding
|
||||
|
||||
appId: com.millianlmx.tabatafit
|
||||
name: Program Browsing
|
||||
|
||||
---
|
||||
# Verify home screen loaded
|
||||
- assertVisible: "program-card-upper-body"
|
||||
- assertVisible: "program-card-lower-body"
|
||||
- assertVisible: "program-card-full-body"
|
||||
|
||||
# Tap Upper Body program
|
||||
- tapOn: "program-upper-body-cta"
|
||||
|
||||
# Wait for program detail screen
|
||||
- assertVisible:
|
||||
text: ".*Upper Body.*"
|
||||
timeout: 5000
|
||||
|
||||
# Navigate back
|
||||
- back
|
||||
|
||||
# Tap Lower Body program
|
||||
- assertVisible: "program-card-lower-body"
|
||||
- tapOn: "program-lower-body-cta"
|
||||
- assertVisible:
|
||||
text: ".*Lower Body.*"
|
||||
timeout: 5000
|
||||
- back
|
||||
|
||||
# Tap Full Body program
|
||||
- assertVisible: "program-card-full-body"
|
||||
- tapOn: "program-full-body-cta"
|
||||
- assertVisible:
|
||||
text: ".*Full Body.*"
|
||||
timeout: 5000
|
||||
- back
|
||||
|
||||
# Verify we're back on home
|
||||
- assertVisible: "program-card-upper-body"
|
||||
17
.maestro/flows/reset-state.yaml
Normal file
17
.maestro/flows/reset-state.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
# Reset App State Helper
|
||||
# Use this to reset the app to a fresh state for testing
|
||||
|
||||
appId: com.millianlmx.tabatafit
|
||||
name: Reset App State
|
||||
|
||||
---
|
||||
# Kill the app
|
||||
- killApp
|
||||
|
||||
# Clear app data (iOS Simulator)
|
||||
# Note: On Android, use: adb shell pm clear com.millianlmx.tabatafit
|
||||
- launchApp:
|
||||
clearState: true
|
||||
|
||||
# App should start at onboarding
|
||||
- assertVisible: "onboarding-problem-cta"
|
||||
38
.maestro/flows/subscription.yaml
Normal file
38
.maestro/flows/subscription.yaml
Normal file
@@ -0,0 +1,38 @@
|
||||
# Subscription Paywall Test
|
||||
# Tests the paywall subscription flow
|
||||
# This test requires a fresh install (onboarding not completed)
|
||||
|
||||
appId: com.millianlmx.tabatafit
|
||||
name: Subscription Paywall
|
||||
|
||||
---
|
||||
# Navigate through onboarding to paywall (steps 1-5)
|
||||
- tapOn: "onboarding-problem-cta"
|
||||
- tapOn: "barrier-no-time"
|
||||
- tapOn: "onboarding-empathy-continue"
|
||||
- tapOn: "onboarding-solution-cta"
|
||||
- tapOn: "onboarding-wow-cta"
|
||||
|
||||
# Enter name to enable continue
|
||||
- tapOn: "name-input"
|
||||
- inputText: "Premium User"
|
||||
- tapOn: "onboarding-personalization-continue"
|
||||
|
||||
# On paywall screen
|
||||
- assertVisible: "plan-yearly"
|
||||
- assertVisible: "plan-monthly"
|
||||
- assertVisible: "subscribe-button"
|
||||
- assertVisible: "skip-paywall"
|
||||
|
||||
# Test plan selection
|
||||
- tapOn: "plan-monthly"
|
||||
- assertVisible: "subscribe-button"
|
||||
|
||||
# Test restore purchases
|
||||
- tapOn: "restore-purchases"
|
||||
|
||||
# Skip paywall
|
||||
- tapOn: "skip-paywall"
|
||||
|
||||
# Verify home screen
|
||||
- assertVisible: "program-card-upper-body"
|
||||
55
.maestro/flows/tab-navigation.yaml
Normal file
55
.maestro/flows/tab-navigation.yaml
Normal file
@@ -0,0 +1,55 @@
|
||||
# Tab Navigation Test
|
||||
# Tests switching between all tabs in the app
|
||||
# Prerequisite: User must have completed onboarding
|
||||
|
||||
appId: com.millianlmx.tabatafit
|
||||
name: Tab Navigation
|
||||
|
||||
---
|
||||
# Start on home tab
|
||||
- assertVisible: "program-card-upper-body"
|
||||
|
||||
# Navigate to Explore tab
|
||||
- tapOn:
|
||||
text: "Explore"
|
||||
optional: true
|
||||
- tapOn:
|
||||
id: "explore-tab"
|
||||
optional: true
|
||||
|
||||
# Verify Explore screen loaded with key sections
|
||||
- assertVisible:
|
||||
id: "explore-screen"
|
||||
timeout: 5000
|
||||
optional: true
|
||||
- assertVisible:
|
||||
id: "filters-section"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Navigate to Activity tab
|
||||
- tapOn:
|
||||
text: "Activity"
|
||||
optional: true
|
||||
- tapOn:
|
||||
id: "activity-tab"
|
||||
optional: true
|
||||
|
||||
# Navigate to Profile tab
|
||||
- tapOn:
|
||||
text: "Profile"
|
||||
optional: true
|
||||
- tapOn:
|
||||
id: "profile-tab"
|
||||
optional: true
|
||||
|
||||
# Navigate back to Home
|
||||
- tapOn:
|
||||
text: "Home"
|
||||
optional: true
|
||||
- tapOn:
|
||||
id: "home-tab"
|
||||
optional: true
|
||||
|
||||
# Verify home screen
|
||||
- assertVisible: "program-card-upper-body"
|
||||
102
.maestro/flows/workout-player.yaml
Normal file
102
.maestro/flows/workout-player.yaml
Normal file
@@ -0,0 +1,102 @@
|
||||
# Workout Player Flow Test
|
||||
# Tests starting a workout, timer controls, and completion
|
||||
# Prerequisite: User must have completed onboarding
|
||||
|
||||
appId: com.millianlmx.tabatafit
|
||||
name: Workout Player
|
||||
|
||||
---
|
||||
# Start from home screen
|
||||
- assertVisible: "program-card-upper-body"
|
||||
|
||||
# Open the Upper Body program
|
||||
- tapOn: "program-upper-body-cta"
|
||||
|
||||
# Wait for program detail screen to load
|
||||
- assertVisible:
|
||||
text: ".*Upper Body.*"
|
||||
timeout: 5000
|
||||
|
||||
# Tap on first workout in the program
|
||||
- tapOn:
|
||||
text: ".*Start.*"
|
||||
index: 0
|
||||
optional: true
|
||||
- tapOn:
|
||||
text: ".*Begin.*"
|
||||
index: 0
|
||||
optional: true
|
||||
|
||||
# Wait for player screen to load — look for the play button
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: ".*PREP.*"
|
||||
timeout: 10000
|
||||
optional: true
|
||||
|
||||
# If no PREP text, look for the play icon or workout title
|
||||
- assertVisible:
|
||||
text: ".*Workout.*"
|
||||
timeout: 5000
|
||||
optional: true
|
||||
|
||||
# Start the workout — tap the play button (center of screen)
|
||||
- tapOn:
|
||||
point: "50%,50%"
|
||||
|
||||
# Wait for timer to start — PREP phase should appear
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: ".*PREP.*"
|
||||
timeout: 5000
|
||||
optional: true
|
||||
|
||||
# Wait a few seconds for the timer to tick
|
||||
- swipe:
|
||||
direction: UP
|
||||
duration: 100
|
||||
optional: true
|
||||
|
||||
# Verify timer is running — time display should be visible
|
||||
- assertVisible:
|
||||
text: ".*:.*"
|
||||
timeout: 5000
|
||||
|
||||
# Test pause — tap the pause button (center area)
|
||||
- tapOn:
|
||||
point: "50%,80%"
|
||||
optional: true
|
||||
|
||||
# Wait briefly
|
||||
- swipe:
|
||||
direction: UP
|
||||
duration: 100
|
||||
optional: true
|
||||
|
||||
# Resume — tap again
|
||||
- tapOn:
|
||||
point: "50%,80%"
|
||||
optional: true
|
||||
|
||||
# Close the player — look for close/stop button (top-left area)
|
||||
- tapOn:
|
||||
point: "10%,8%"
|
||||
optional: true
|
||||
|
||||
# If close button was in a different location, try the stop button
|
||||
- tapOn:
|
||||
text: ".*close.*"
|
||||
optional: true
|
||||
|
||||
# Verify we're back on the program screen or home
|
||||
- assertVisible:
|
||||
text: ".*Upper Body.*"
|
||||
timeout: 5000
|
||||
optional: true
|
||||
|
||||
# Go back to home
|
||||
- back
|
||||
- assertVisible:
|
||||
id: "program-card-upper-body"
|
||||
timeout: 5000
|
||||
optional: true
|
||||
321
.opencode/skills/building-native-ui/SKILL.md
Normal file
321
.opencode/skills/building-native-ui/SKILL.md
Normal file
@@ -0,0 +1,321 @@
|
||||
---
|
||||
name: building-native-ui
|
||||
description: Complete guide for building beautiful apps with Expo Router. Covers fundamentals, styling, components, navigation, animations, patterns, and native tabs.
|
||||
version: 1.0.1
|
||||
license: MIT
|
||||
---
|
||||
|
||||
# Expo UI Guidelines
|
||||
|
||||
## References
|
||||
|
||||
Consult these resources as needed:
|
||||
|
||||
```
|
||||
references/
|
||||
animations.md Reanimated: entering, exiting, layout, scroll-driven, gestures
|
||||
controls.md Native iOS: Switch, Slider, SegmentedControl, DateTimePicker, Picker
|
||||
form-sheet.md Form sheets in expo-router: configuration, footers and background interaction.
|
||||
gradients.md CSS gradients via experimental_backgroundImage (New Arch only)
|
||||
icons.md SF Symbols via expo-image (sf: source), names, animations, weights
|
||||
media.md Camera, audio, video, and file saving
|
||||
route-structure.md Route conventions, dynamic routes, groups, folder organization
|
||||
search.md Search bar with headers, useSearch hook, filtering patterns
|
||||
storage.md SQLite, AsyncStorage, SecureStore
|
||||
tabs.md NativeTabs, migration from JS tabs, iOS 26 features
|
||||
toolbar-and-headers.md Stack headers and toolbar buttons, menus, search (iOS only)
|
||||
visual-effects.md Blur (expo-blur) and liquid glass (expo-glass-effect)
|
||||
webgpu-three.md 3D graphics, games, GPU visualizations with WebGPU and Three.js
|
||||
zoom-transitions.md Apple Zoom: fluid zoom transitions with Link.AppleZoom (iOS 18+)
|
||||
```
|
||||
|
||||
## Running the App
|
||||
|
||||
**CRITICAL: Always try Expo Go first before creating custom builds.**
|
||||
|
||||
Most Expo apps work in Expo Go without any custom native code. Before running `npx expo run:ios` or `npx expo run:android`:
|
||||
|
||||
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** - see below
|
||||
|
||||
### When Custom Builds Are Required
|
||||
|
||||
You need `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
|
||||
|
||||
**If you're unsure, try Expo Go first.** Creating custom builds adds complexity, slower iteration, and requires Xcode/Android Studio setup.
|
||||
|
||||
## Code Style
|
||||
|
||||
- Be cautious of unterminated strings. Ensure nested backticks are escaped; never forget to escape quotes correctly.
|
||||
- Always use import statements at the top of the file.
|
||||
- Always use kebab-case for file names, e.g. `comment-card.tsx`
|
||||
- Always remove old route files when moving or restructuring navigation
|
||||
- Never use special characters in file names
|
||||
- Configure tsconfig.json with path aliases, and prefer aliases over relative imports for refactors.
|
||||
|
||||
## Routes
|
||||
|
||||
See `./references/route-structure.md` for detailed route conventions.
|
||||
|
||||
- Routes belong in the `app` directory.
|
||||
- Never co-locate components, types, or utilities in the app directory. This is an anti-pattern.
|
||||
- Ensure the app always has a route that matches "/", it may be inside a group route.
|
||||
|
||||
## Library Preferences
|
||||
|
||||
- Never use modules removed from React Native such as Picker, WebView, SafeAreaView, or AsyncStorage
|
||||
- Never use legacy expo-permissions
|
||||
- `expo-audio` not `expo-av`
|
||||
- `expo-video` not `expo-av`
|
||||
- `expo-image` with `source="sf:name"` for SF Symbols, not `expo-symbols` or `@expo/vector-icons`
|
||||
- `react-native-safe-area-context` not react-native SafeAreaView
|
||||
- `process.env.EXPO_OS` not `Platform.OS`
|
||||
- `React.use` not `React.useContext`
|
||||
- `expo-image` Image component instead of intrinsic element `img`
|
||||
- `expo-glass-effect` for liquid glass backdrops
|
||||
|
||||
## Responsiveness
|
||||
|
||||
- Always wrap root component in a scroll view for responsiveness
|
||||
- Use `<ScrollView contentInsetAdjustmentBehavior="automatic" />` instead of `<SafeAreaView>` for smarter safe area insets
|
||||
- `contentInsetAdjustmentBehavior="automatic"` should be applied to FlatList and SectionList as well
|
||||
- Use flexbox instead of Dimensions API
|
||||
- ALWAYS prefer `useWindowDimensions` over `Dimensions.get()` to measure screen size
|
||||
|
||||
## Behavior
|
||||
|
||||
- Use expo-haptics conditionally on iOS to make more delightful experiences
|
||||
- Use views with built-in haptics like `<Switch />` from React Native and `@react-native-community/datetimepicker`
|
||||
- When a route belongs to a Stack, its first child should almost always be a ScrollView with `contentInsetAdjustmentBehavior="automatic"` set
|
||||
- When adding a `ScrollView` to the page it should almost always be the first component inside the route component
|
||||
- Prefer `headerSearchBarOptions` in Stack.Screen options to add a search bar
|
||||
- Use the `<Text selectable />` prop on text containing data that could be copied
|
||||
- Consider formatting large numbers like 1.4M or 38k
|
||||
- Never use intrinsic elements like 'img' or 'div' unless in a webview or Expo DOM component
|
||||
|
||||
# Styling
|
||||
|
||||
Follow Apple Human Interface Guidelines.
|
||||
|
||||
## General Styling Rules
|
||||
|
||||
- Prefer flex gap over margin and padding styles
|
||||
- Prefer padding over margin where possible
|
||||
- Always account for safe area, either with stack headers, tabs, or ScrollView/FlatList `contentInsetAdjustmentBehavior="automatic"`
|
||||
- Ensure both top and bottom safe area insets are accounted for
|
||||
- Inline styles not StyleSheet.create unless reusing styles is faster
|
||||
- Add entering and exiting animations for state changes
|
||||
- Use `{ borderCurve: 'continuous' }` for rounded corners unless creating a capsule shape
|
||||
- ALWAYS use a navigation stack title instead of a custom text element on the page
|
||||
- When padding a ScrollView, use `contentContainerStyle` padding and gap instead of padding on the ScrollView itself (reduces clipping)
|
||||
- CSS and Tailwind are not supported - use inline styles
|
||||
|
||||
## Text Styling
|
||||
|
||||
- Add the `selectable` prop to every `<Text/>` element displaying important data or error messages
|
||||
- Counters should use `{ fontVariant: 'tabular-nums' }` for alignment
|
||||
|
||||
## 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)" }} />
|
||||
```
|
||||
|
||||
'inset' shadows are supported.
|
||||
|
||||
# Navigation
|
||||
|
||||
## Link
|
||||
|
||||
Use `<Link href="/path" />` from 'expo-router' for navigation between routes.
|
||||
|
||||
```tsx
|
||||
import { Link } from 'expo-router';
|
||||
|
||||
// Basic link
|
||||
<Link href="/path" />
|
||||
|
||||
// Wrapping custom components
|
||||
<Link href="/path" asChild>
|
||||
<Pressable>...</Pressable>
|
||||
</Link>
|
||||
```
|
||||
|
||||
Whenever possible, include a `<Link.Preview>` to follow iOS conventions. Add context menus and previews frequently to enhance navigation.
|
||||
|
||||
## Stack
|
||||
|
||||
- ALWAYS use `_layout.tsx` files to define stacks
|
||||
- Use Stack from 'expo-router/stack' for native navigation stacks
|
||||
|
||||
### Page Title
|
||||
|
||||
Set the page title in Stack.Screen options:
|
||||
|
||||
```tsx
|
||||
<Stack.Screen options={{ title: "Home" }} />
|
||||
```
|
||||
|
||||
## Context Menus
|
||||
|
||||
Add long press context menus to Link components:
|
||||
|
||||
```tsx
|
||||
import { Link } from "expo-router";
|
||||
|
||||
<Link href="/settings" asChild>
|
||||
<Link.Trigger>
|
||||
<Pressable>
|
||||
<Card />
|
||||
</Pressable>
|
||||
</Link.Trigger>
|
||||
<Link.Menu>
|
||||
<Link.MenuAction
|
||||
title="Share"
|
||||
icon="square.and.arrow.up"
|
||||
onPress={handleSharePress}
|
||||
/>
|
||||
<Link.MenuAction
|
||||
title="Block"
|
||||
icon="nosign"
|
||||
destructive
|
||||
onPress={handleBlockPress}
|
||||
/>
|
||||
<Link.Menu title="More" icon="ellipsis">
|
||||
<Link.MenuAction title="Copy" icon="doc.on.doc" onPress={() => {}} />
|
||||
<Link.MenuAction
|
||||
title="Delete"
|
||||
icon="trash"
|
||||
destructive
|
||||
onPress={() => {}}
|
||||
/>
|
||||
</Link.Menu>
|
||||
</Link.Menu>
|
||||
</Link>;
|
||||
```
|
||||
|
||||
## Link Previews
|
||||
|
||||
Use link previews frequently to enhance navigation:
|
||||
|
||||
```tsx
|
||||
<Link href="/settings">
|
||||
<Link.Trigger>
|
||||
<Pressable>
|
||||
<Card />
|
||||
</Pressable>
|
||||
</Link.Trigger>
|
||||
<Link.Preview />
|
||||
</Link>
|
||||
```
|
||||
|
||||
Link preview can be used with context menus.
|
||||
|
||||
## Modal
|
||||
|
||||
Present a screen as a modal:
|
||||
|
||||
```tsx
|
||||
<Stack.Screen name="modal" options={{ presentation: "modal" }} />
|
||||
```
|
||||
|
||||
Prefer this to building a custom modal component.
|
||||
|
||||
## Sheet
|
||||
|
||||
Present a screen as a dynamic form sheet:
|
||||
|
||||
```tsx
|
||||
<Stack.Screen
|
||||
name="sheet"
|
||||
options={{
|
||||
presentation: "formSheet",
|
||||
sheetGrabberVisible: true,
|
||||
sheetAllowedDetents: [0.5, 1.0],
|
||||
contentStyle: { backgroundColor: "transparent" },
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
- Using `contentStyle: { backgroundColor: "transparent" }` makes the background liquid glass on iOS 26+.
|
||||
|
||||
## Common route structure
|
||||
|
||||
A standard app layout with tabs and stacks inside each tab:
|
||||
|
||||
```
|
||||
app/
|
||||
_layout.tsx — <NativeTabs />
|
||||
(index,search)/
|
||||
_layout.tsx — <Stack />
|
||||
index.tsx — Main list
|
||||
search.tsx — Search view
|
||||
```
|
||||
|
||||
```tsx
|
||||
// app/_layout.tsx
|
||||
import { NativeTabs, Icon, Label } from "expo-router/unstable-native-tabs";
|
||||
import { Theme } from "../components/theme";
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<Theme>
|
||||
<NativeTabs>
|
||||
<NativeTabs.Trigger name="(index)">
|
||||
<Icon sf="list.dash" />
|
||||
<Label>Items</Label>
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name="(search)" role="search" />
|
||||
</NativeTabs>
|
||||
</Theme>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Create a shared group route so both tabs can push common screens:
|
||||
|
||||
```tsx
|
||||
// app/(index,search)/_layout.tsx
|
||||
import { Stack } from "expo-router/stack";
|
||||
import { PlatformColor } from "react-native";
|
||||
|
||||
export default function Layout({ segment }) {
|
||||
const screen = segment.match(/\((.*)\)/)?.[1]!;
|
||||
const titles: Record<string, string> = { index: "Items", search: "Search" };
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerTransparent: true,
|
||||
headerShadowVisible: false,
|
||||
headerLargeTitleShadowVisible: false,
|
||||
headerLargeStyle: { backgroundColor: "transparent" },
|
||||
headerTitleStyle: { color: PlatformColor("label") },
|
||||
headerLargeTitle: true,
|
||||
headerBlurEffect: "none",
|
||||
headerBackButtonDisplayMode: "minimal",
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name={screen} options={{ title: titles[screen] }} />
|
||||
<Stack.Screen name="i/[id]" options={{ headerLargeTitle: false }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
```
|
||||
220
.opencode/skills/building-native-ui/references/animations.md
Normal file
220
.opencode/skills/building-native-ui/references/animations.md
Normal file
@@ -0,0 +1,220 @@
|
||||
# Animations
|
||||
|
||||
Use Reanimated v4. Avoid React Native's built-in Animated API.
|
||||
|
||||
## Entering and Exiting Animations
|
||||
|
||||
Use Animated.View with entering and exiting animations. Layout animations can animate state changes.
|
||||
|
||||
```tsx
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
LinearTransition,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeIn}
|
||||
exiting={FadeOut}
|
||||
layout={LinearTransition}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## On-Scroll Animations
|
||||
|
||||
Create high-performance scroll animations using Reanimated's hooks:
|
||||
|
||||
```tsx
|
||||
import Animated, {
|
||||
useAnimatedRef,
|
||||
useScrollViewOffset,
|
||||
useAnimatedStyle,
|
||||
interpolate,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
function Page() {
|
||||
const ref = useAnimatedRef();
|
||||
const scroll = useScrollViewOffset(ref);
|
||||
|
||||
const style = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(scroll.value, [0, 30], [0, 1], "clamp"),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.ScrollView ref={ref}>
|
||||
<Animated.View style={style} />
|
||||
</Animated.ScrollView>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Common Animation Presets
|
||||
|
||||
### Entering Animations
|
||||
|
||||
- `FadeIn`, `FadeInUp`, `FadeInDown`, `FadeInLeft`, `FadeInRight`
|
||||
- `SlideInUp`, `SlideInDown`, `SlideInLeft`, `SlideInRight`
|
||||
- `ZoomIn`, `ZoomInUp`, `ZoomInDown`
|
||||
- `BounceIn`, `BounceInUp`, `BounceInDown`
|
||||
|
||||
### Exiting Animations
|
||||
|
||||
- `FadeOut`, `FadeOutUp`, `FadeOutDown`, `FadeOutLeft`, `FadeOutRight`
|
||||
- `SlideOutUp`, `SlideOutDown`, `SlideOutLeft`, `SlideOutRight`
|
||||
- `ZoomOut`, `ZoomOutUp`, `ZoomOutDown`
|
||||
- `BounceOut`, `BounceOutUp`, `BounceOutDown`
|
||||
|
||||
### Layout Animations
|
||||
|
||||
- `LinearTransition` — Smooth linear interpolation
|
||||
- `SequencedTransition` — Sequenced property changes
|
||||
- `FadingTransition` — Fade between states
|
||||
|
||||
## Customizing Animations
|
||||
|
||||
```tsx
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(500).delay(200)}
|
||||
exiting={FadeOut.duration(300)}
|
||||
/>
|
||||
```
|
||||
|
||||
### Modifiers
|
||||
|
||||
```tsx
|
||||
// Duration in milliseconds
|
||||
FadeIn.duration(300);
|
||||
|
||||
// Delay before starting
|
||||
FadeIn.delay(100);
|
||||
|
||||
// Spring physics
|
||||
FadeIn.springify();
|
||||
FadeIn.springify().damping(15).stiffness(100);
|
||||
|
||||
// Easing curves
|
||||
FadeIn.easing(Easing.bezier(0.25, 0.1, 0.25, 1));
|
||||
|
||||
// Chaining
|
||||
FadeInDown.duration(400).delay(200).springify();
|
||||
```
|
||||
|
||||
## Shared Value Animations
|
||||
|
||||
For imperative control over animations:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
const offset = useSharedValue(0);
|
||||
|
||||
// Spring animation
|
||||
offset.value = withSpring(100);
|
||||
|
||||
// Timing animation
|
||||
offset.value = withTiming(100, { duration: 300 });
|
||||
|
||||
// Use in styles
|
||||
const style = useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: offset.value }],
|
||||
}));
|
||||
```
|
||||
|
||||
## Gesture Animations
|
||||
|
||||
Combine with React Native Gesture Handler:
|
||||
|
||||
```tsx
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withSpring,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
function DraggableBox() {
|
||||
const translateX = useSharedValue(0);
|
||||
const translateY = useSharedValue(0);
|
||||
|
||||
const gesture = Gesture.Pan()
|
||||
.onUpdate((e) => {
|
||||
translateX.value = e.translationX;
|
||||
translateY.value = e.translationY;
|
||||
})
|
||||
.onEnd(() => {
|
||||
translateX.value = withSpring(0);
|
||||
translateY.value = withSpring(0);
|
||||
});
|
||||
|
||||
const style = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{ translateX: translateX.value },
|
||||
{ translateY: translateY.value },
|
||||
],
|
||||
}));
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={gesture}>
|
||||
<Animated.View style={[styles.box, style]} />
|
||||
</GestureDetector>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Keyboard Animations
|
||||
|
||||
Animate with keyboard height changes:
|
||||
|
||||
```tsx
|
||||
import Animated, {
|
||||
useAnimatedKeyboard,
|
||||
useAnimatedStyle,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
function KeyboardAwareView() {
|
||||
const keyboard = useAnimatedKeyboard();
|
||||
|
||||
const style = useAnimatedStyle(() => ({
|
||||
paddingBottom: keyboard.height.value,
|
||||
}));
|
||||
|
||||
return <Animated.View style={style}>{/* content */}</Animated.View>;
|
||||
}
|
||||
```
|
||||
|
||||
## Staggered List Animations
|
||||
|
||||
Animate list items with delays:
|
||||
|
||||
```tsx
|
||||
{
|
||||
items.map((item, index) => (
|
||||
<Animated.View
|
||||
key={item.id}
|
||||
entering={FadeInUp.delay(index * 50)}
|
||||
exiting={FadeOutUp}
|
||||
>
|
||||
<ListItem item={item} />
|
||||
</Animated.View>
|
||||
));
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Add entering and exiting animations for state changes
|
||||
- Use layout animations when items are added/removed from lists
|
||||
- Use `useAnimatedStyle` for scroll-driven animations
|
||||
- Prefer `interpolate` with "clamp" for bounded values
|
||||
- You can't pass PlatformColors to reanimated views or styles; use static colors instead
|
||||
- Keep animations under 300ms for responsive feel
|
||||
- Use spring animations for natural movement
|
||||
- Avoid animating layout properties (width, height) when possible — prefer transforms
|
||||
270
.opencode/skills/building-native-ui/references/controls.md
Normal file
270
.opencode/skills/building-native-ui/references/controls.md
Normal file
@@ -0,0 +1,270 @@
|
||||
# Native Controls
|
||||
|
||||
Native iOS controls provide built-in haptics, accessibility, and platform-appropriate styling.
|
||||
|
||||
## Switch
|
||||
|
||||
Use for binary on/off settings. Has built-in haptics.
|
||||
|
||||
```tsx
|
||||
import { Switch } from "react-native";
|
||||
import { useState } from "react";
|
||||
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
<Switch value={enabled} onValueChange={setEnabled} />;
|
||||
```
|
||||
|
||||
### Customization
|
||||
|
||||
```tsx
|
||||
<Switch
|
||||
value={enabled}
|
||||
onValueChange={setEnabled}
|
||||
trackColor={{ false: "#767577", true: "#81b0ff" }}
|
||||
thumbColor={enabled ? "#f5dd4b" : "#f4f3f4"}
|
||||
ios_backgroundColor="#3e3e3e"
|
||||
/>
|
||||
```
|
||||
|
||||
## Segmented Control
|
||||
|
||||
Use for non-navigational tabs or mode selection. Avoid changing default colors.
|
||||
|
||||
```tsx
|
||||
import SegmentedControl from "@react-native-segmented-control/segmented-control";
|
||||
import { useState } from "react";
|
||||
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
<SegmentedControl
|
||||
values={["All", "Active", "Done"]}
|
||||
selectedIndex={index}
|
||||
onChange={({ nativeEvent }) => setIndex(nativeEvent.selectedSegmentIndex)}
|
||||
/>;
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
- Maximum 4 options — use a picker for more
|
||||
- Keep labels short (1-2 words)
|
||||
- Avoid custom colors — native styling adapts to dark mode
|
||||
|
||||
### With Icons (iOS 14+)
|
||||
|
||||
```tsx
|
||||
<SegmentedControl
|
||||
values={[
|
||||
{ label: "List", icon: "list.bullet" },
|
||||
{ label: "Grid", icon: "square.grid.2x2" },
|
||||
]}
|
||||
selectedIndex={index}
|
||||
onChange={({ nativeEvent }) => setIndex(nativeEvent.selectedSegmentIndex)}
|
||||
/>
|
||||
```
|
||||
|
||||
## Slider
|
||||
|
||||
Continuous value selection.
|
||||
|
||||
```tsx
|
||||
import Slider from "@react-native-community/slider";
|
||||
import { useState } from "react";
|
||||
|
||||
const [value, setValue] = useState(0.5);
|
||||
|
||||
<Slider
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
minimumValue={0}
|
||||
maximumValue={1}
|
||||
/>;
|
||||
```
|
||||
|
||||
### Customization
|
||||
|
||||
```tsx
|
||||
<Slider
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
minimumValue={0}
|
||||
maximumValue={100}
|
||||
step={1}
|
||||
minimumTrackTintColor="#007AFF"
|
||||
maximumTrackTintColor="#E5E5EA"
|
||||
thumbTintColor="#007AFF"
|
||||
/>
|
||||
```
|
||||
|
||||
### Discrete Steps
|
||||
|
||||
```tsx
|
||||
<Slider
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
minimumValue={0}
|
||||
maximumValue={10}
|
||||
step={1}
|
||||
/>
|
||||
```
|
||||
|
||||
## Date/Time Picker
|
||||
|
||||
Compact pickers with popovers. Has built-in haptics.
|
||||
|
||||
```tsx
|
||||
import DateTimePicker from "@react-native-community/datetimepicker";
|
||||
import { useState } from "react";
|
||||
|
||||
const [date, setDate] = useState(new Date());
|
||||
|
||||
<DateTimePicker
|
||||
value={date}
|
||||
onChange={(event, selectedDate) => {
|
||||
if (selectedDate) setDate(selectedDate);
|
||||
}}
|
||||
mode="datetime"
|
||||
/>;
|
||||
```
|
||||
|
||||
### Modes
|
||||
|
||||
- `date` — Date only
|
||||
- `time` — Time only
|
||||
- `datetime` — Date and time
|
||||
|
||||
### Display Styles
|
||||
|
||||
```tsx
|
||||
// Compact inline (default)
|
||||
<DateTimePicker value={date} mode="date" />
|
||||
|
||||
// Spinner wheel
|
||||
<DateTimePicker
|
||||
value={date}
|
||||
mode="date"
|
||||
display="spinner"
|
||||
style={{ width: 200, height: 150 }}
|
||||
/>
|
||||
|
||||
// Full calendar
|
||||
<DateTimePicker value={date} mode="date" display="inline" />
|
||||
```
|
||||
|
||||
### Time Intervals
|
||||
|
||||
```tsx
|
||||
<DateTimePicker
|
||||
value={date}
|
||||
mode="time"
|
||||
minuteInterval={15}
|
||||
/>
|
||||
```
|
||||
|
||||
### Min/Max Dates
|
||||
|
||||
```tsx
|
||||
<DateTimePicker
|
||||
value={date}
|
||||
mode="date"
|
||||
minimumDate={new Date(2020, 0, 1)}
|
||||
maximumDate={new Date(2030, 11, 31)}
|
||||
/>
|
||||
```
|
||||
|
||||
## Stepper
|
||||
|
||||
Increment/decrement numeric values.
|
||||
|
||||
```tsx
|
||||
import { Stepper } from "react-native";
|
||||
import { useState } from "react";
|
||||
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
<Stepper
|
||||
value={count}
|
||||
onValueChange={setCount}
|
||||
minimumValue={0}
|
||||
maximumValue={10}
|
||||
/>;
|
||||
```
|
||||
|
||||
## TextInput
|
||||
|
||||
Native text input with various keyboard types.
|
||||
|
||||
```tsx
|
||||
import { TextInput } from "react-native";
|
||||
|
||||
<TextInput
|
||||
placeholder="Enter text..."
|
||||
placeholderTextColor="#999"
|
||||
style={{
|
||||
padding: 12,
|
||||
fontSize: 16,
|
||||
borderRadius: 8,
|
||||
backgroundColor: "#f0f0f0",
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Keyboard Types
|
||||
|
||||
```tsx
|
||||
// Email
|
||||
<TextInput keyboardType="email-address" autoCapitalize="none" />
|
||||
|
||||
// Phone
|
||||
<TextInput keyboardType="phone-pad" />
|
||||
|
||||
// Number
|
||||
<TextInput keyboardType="numeric" />
|
||||
|
||||
// Password
|
||||
<TextInput secureTextEntry />
|
||||
|
||||
// Search
|
||||
<TextInput
|
||||
returnKeyType="search"
|
||||
enablesReturnKeyAutomatically
|
||||
/>
|
||||
```
|
||||
|
||||
### Multiline
|
||||
|
||||
```tsx
|
||||
<TextInput
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
textAlignVertical="top"
|
||||
style={{ minHeight: 100 }}
|
||||
/>
|
||||
```
|
||||
|
||||
## Picker (Wheel)
|
||||
|
||||
For selection from many options (5+ items).
|
||||
|
||||
```tsx
|
||||
import { Picker } from "@react-native-picker/picker";
|
||||
import { useState } from "react";
|
||||
|
||||
const [selected, setSelected] = useState("js");
|
||||
|
||||
<Picker selectedValue={selected} onValueChange={setSelected}>
|
||||
<Picker.Item label="JavaScript" value="js" />
|
||||
<Picker.Item label="TypeScript" value="ts" />
|
||||
<Picker.Item label="Python" value="py" />
|
||||
<Picker.Item label="Go" value="go" />
|
||||
</Picker>;
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Haptics**: Switch and DateTimePicker have built-in haptics — don't add extra
|
||||
- **Accessibility**: Native controls have proper accessibility labels by default
|
||||
- **Dark Mode**: Avoid custom colors — native styling adapts automatically
|
||||
- **Spacing**: Use consistent padding around controls (12-16pt)
|
||||
- **Labels**: Place labels above or to the left of controls
|
||||
- **Grouping**: Group related controls in sections with headers
|
||||
253
.opencode/skills/building-native-ui/references/form-sheet.md
Normal file
253
.opencode/skills/building-native-ui/references/form-sheet.md
Normal file
@@ -0,0 +1,253 @@
|
||||
# Form Sheets in Expo Router
|
||||
|
||||
This skill covers implementing form sheets with footers using Expo Router's Stack navigator and react-native-screens.
|
||||
|
||||
## Overview
|
||||
|
||||
Form sheets are modal presentations that appear as a card sliding up from the bottom of the screen. They're ideal for:
|
||||
|
||||
- Quick actions and confirmations
|
||||
- Settings panels
|
||||
- Login/signup flows
|
||||
- Action sheets with custom content
|
||||
|
||||
**Requirements:**
|
||||
|
||||
- Expo Router Stack navigator
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Form Sheet with Footer
|
||||
|
||||
Configure the Stack.Screen with transparent backgrounds and sheet presentation:
|
||||
|
||||
```tsx
|
||||
// app/_layout.tsx
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen
|
||||
name="about"
|
||||
options={{
|
||||
presentation: "formSheet",
|
||||
sheetAllowedDetents: [0.25],
|
||||
headerTransparent: true,
|
||||
contentStyle: { backgroundColor: "transparent" },
|
||||
sheetGrabberVisible: true,
|
||||
}}
|
||||
>
|
||||
<Stack.Header style={{ backgroundColor: "transparent" }}></Stack.Header>
|
||||
</Stack.Screen>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Form Sheet Screen Content
|
||||
|
||||
> Requires Expo SDK 55 or later.
|
||||
|
||||
Use `flex: 1` to allow the content to fill available space, enabling footer positioning:
|
||||
|
||||
```tsx
|
||||
// app/about.tsx
|
||||
import { View, Text, StyleSheet } from "react-native";
|
||||
|
||||
export default function AboutSheet() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Main content */}
|
||||
<View style={styles.content}>
|
||||
<Text>Sheet Content</Text>
|
||||
</View>
|
||||
|
||||
{/* Footer - stays at bottom */}
|
||||
<View style={styles.footer}>
|
||||
<Text>Footer Content</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
},
|
||||
footer: {
|
||||
padding: 16,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Formsheet with interactive content below
|
||||
|
||||
Use `sheetLargestUndimmedDetentIndex` (zero-indexed) to keep content behind the form sheet interactive — e.g. letting users pan a map beneath it. Setting it to `1` allows interaction at the first two detents but dims on the third.
|
||||
|
||||
```tsx
|
||||
// app/_layout.tsx
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen
|
||||
name="info-sheet"
|
||||
options={{
|
||||
presentation: "formSheet",
|
||||
sheetAllowedDetents: [0.2, 0.5, 1.0],
|
||||
sheetLargestUndimmedDetentIndex: 1,
|
||||
/* other options */
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Key Options
|
||||
|
||||
| Option | Type | Description |
|
||||
| --------------------- | ---------- | ----------------------------------------------------------- |
|
||||
| `presentation` | `string` | Set to `'formSheet'` for sheet presentation |
|
||||
| `sheetGrabberVisible` | `boolean` | Shows the drag handle at the top of the sheet |
|
||||
| `sheetAllowedDetents` | `number[]` | Array of detent heights (0-1 range, e.g., `[0.25]` for 25%) |
|
||||
| `headerTransparent` | `boolean` | Makes header background transparent |
|
||||
| `contentStyle` | `object` | Style object for the screen content container |
|
||||
| `title` | `string` | Screen title (set to `''` for no title) |
|
||||
|
||||
## Common Detent Values
|
||||
|
||||
- `[0.25]` - Quarter sheet (compact actions)
|
||||
- `[0.5]` - Half sheet (medium content)
|
||||
- `[0.75]` - Three-quarter sheet (detailed forms)
|
||||
- `[0.25, 0.5, 1]` - Multiple stops (expandable sheet)
|
||||
|
||||
## Complete Example
|
||||
|
||||
```tsx
|
||||
// _layout.tsx
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen name="index" options={{ title: "Home" }} />
|
||||
<Stack.Screen
|
||||
name="confirm"
|
||||
options={{
|
||||
contentStyle: { backgroundColor: "transparent" },
|
||||
presentation: "formSheet",
|
||||
title: "",
|
||||
sheetGrabberVisible: true,
|
||||
sheetAllowedDetents: [0.25],
|
||||
headerTransparent: true,
|
||||
}}
|
||||
>
|
||||
<Stack.Header style={{ backgroundColor: "transparent" }}>
|
||||
<Stack.Header.Right />
|
||||
</Stack.Header>
|
||||
</Stack.Screen>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// app/confirm.tsx
|
||||
import { View, Text, Pressable, StyleSheet } from "react-native";
|
||||
import { router } from "expo-router";
|
||||
|
||||
export default function ConfirmSheet() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.title}>Confirm Action</Text>
|
||||
<Text style={styles.description}>
|
||||
Are you sure you want to proceed?
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Pressable style={styles.cancelButton} onPress={() => router.back()}>
|
||||
<Text style={styles.cancelText}>Cancel</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.confirmButton} onPress={() => router.back()}>
|
||||
<Text style={styles.confirmText}>Confirm</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: "600",
|
||||
marginBottom: 8,
|
||||
},
|
||||
description: {
|
||||
fontSize: 14,
|
||||
color: "#666",
|
||||
textAlign: "center",
|
||||
},
|
||||
footer: {
|
||||
flexDirection: "row",
|
||||
padding: 16,
|
||||
gap: 12,
|
||||
},
|
||||
cancelButton: {
|
||||
flex: 1,
|
||||
padding: 14,
|
||||
borderRadius: 10,
|
||||
backgroundColor: "#f0f0f0",
|
||||
alignItems: "center",
|
||||
},
|
||||
cancelText: {
|
||||
fontSize: 16,
|
||||
fontWeight: "500",
|
||||
},
|
||||
confirmButton: {
|
||||
flex: 1,
|
||||
padding: 14,
|
||||
borderRadius: 10,
|
||||
backgroundColor: "#007AFF",
|
||||
alignItems: "center",
|
||||
},
|
||||
confirmText: {
|
||||
fontSize: 16,
|
||||
fontWeight: "500",
|
||||
color: "white",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Content not filling sheet
|
||||
|
||||
Make sure the root View uses `flex: 1`:
|
||||
|
||||
```tsx
|
||||
<View style={{ flex: 1 }}>{/* content */}</View>
|
||||
```
|
||||
|
||||
### Sheet background showing through
|
||||
|
||||
Set `contentStyle: { backgroundColor: 'transparent' }` in options and style your content container with the desired background color instead.
|
||||
106
.opencode/skills/building-native-ui/references/gradients.md
Normal file
106
.opencode/skills/building-native-ui/references/gradients.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# CSS Gradients
|
||||
|
||||
> **New Architecture Only**: CSS gradients require React Native's New Architecture (Fabric). They are not available in the old architecture or Expo Go.
|
||||
|
||||
Use CSS gradients with the `experimental_backgroundImage` style property.
|
||||
|
||||
## Linear Gradients
|
||||
|
||||
```tsx
|
||||
// Top to bottom
|
||||
<View style={{
|
||||
experimental_backgroundImage: 'linear-gradient(to bottom, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 1) 100%)'
|
||||
}} />
|
||||
|
||||
// Left to right
|
||||
<View style={{
|
||||
experimental_backgroundImage: 'linear-gradient(to right, #ff0000 0%, #0000ff 100%)'
|
||||
}} />
|
||||
|
||||
// Diagonal
|
||||
<View style={{
|
||||
experimental_backgroundImage: 'linear-gradient(45deg, #ff0000 0%, #00ff00 50%, #0000ff 100%)'
|
||||
}} />
|
||||
|
||||
// Using degrees
|
||||
<View style={{
|
||||
experimental_backgroundImage: 'linear-gradient(135deg, transparent 0%, black 100%)'
|
||||
}} />
|
||||
```
|
||||
|
||||
## Radial Gradients
|
||||
|
||||
```tsx
|
||||
// Circle at center
|
||||
<View style={{
|
||||
experimental_backgroundImage: 'radial-gradient(circle at center, rgba(255, 0, 0, 1) 0%, rgba(0, 0, 255, 1) 100%)'
|
||||
}} />
|
||||
|
||||
// Ellipse
|
||||
<View style={{
|
||||
experimental_backgroundImage: 'radial-gradient(ellipse at center, #fff 0%, #000 100%)'
|
||||
}} />
|
||||
|
||||
// Positioned
|
||||
<View style={{
|
||||
experimental_backgroundImage: 'radial-gradient(circle at top left, #ff0000 0%, transparent 70%)'
|
||||
}} />
|
||||
```
|
||||
|
||||
## Multiple Gradients
|
||||
|
||||
Stack multiple gradients by comma-separating them:
|
||||
|
||||
```tsx
|
||||
<View style={{
|
||||
experimental_backgroundImage: `
|
||||
linear-gradient(to bottom, transparent 0%, black 100%),
|
||||
radial-gradient(circle at top right, rgba(255, 0, 0, 0.5) 0%, transparent 50%)
|
||||
`
|
||||
}} />
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Overlay on Image
|
||||
|
||||
```tsx
|
||||
<View style={{ position: 'relative' }}>
|
||||
<Image source={{ uri: '...' }} style={{ width: '100%', height: 200 }} />
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
experimental_backgroundImage: 'linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, transparent 50%)'
|
||||
}} />
|
||||
</View>
|
||||
```
|
||||
|
||||
### Frosted Glass Effect
|
||||
|
||||
```tsx
|
||||
<View style={{
|
||||
experimental_backgroundImage: 'linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.05) 100%)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
}} />
|
||||
```
|
||||
|
||||
### Button Gradient
|
||||
|
||||
```tsx
|
||||
<Pressable style={{
|
||||
experimental_backgroundImage: 'linear-gradient(to bottom, #4CAF50 0%, #388E3C 100%)',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
}}>
|
||||
<Text style={{ color: 'white', textAlign: 'center' }}>Submit</Text>
|
||||
</Pressable>
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
- Do NOT use `expo-linear-gradient` — use CSS gradients instead
|
||||
- Gradients are strings, not objects
|
||||
- Use `rgba()` for transparency, or `transparent` keyword
|
||||
- Color stops use percentages (0%, 50%, 100%)
|
||||
- Direction keywords: `to top`, `to bottom`, `to left`, `to right`, `to top left`, etc.
|
||||
- Degree values: `45deg`, `90deg`, `135deg`, etc.
|
||||
213
.opencode/skills/building-native-ui/references/icons.md
Normal file
213
.opencode/skills/building-native-ui/references/icons.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# Icons (SF Symbols)
|
||||
|
||||
Use SF Symbols for native feel. Never use FontAwesome or Ionicons.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```tsx
|
||||
import { SymbolView } from "expo-symbols";
|
||||
import { PlatformColor } from "react-native";
|
||||
|
||||
<SymbolView
|
||||
tintColor={PlatformColor("label")}
|
||||
resizeMode="scaleAspectFit"
|
||||
name="square.and.arrow.down"
|
||||
style={{ width: 16, height: 16 }}
|
||||
/>;
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
```tsx
|
||||
<SymbolView
|
||||
name="star.fill" // SF Symbol name (required)
|
||||
tintColor={PlatformColor("label")} // Icon color
|
||||
size={24} // Shorthand for width/height
|
||||
resizeMode="scaleAspectFit" // How to scale
|
||||
weight="regular" // thin | ultraLight | light | regular | medium | semibold | bold | heavy | black
|
||||
scale="medium" // small | medium | large
|
||||
style={{ width: 16, height: 16 }} // Standard style props
|
||||
/>
|
||||
```
|
||||
|
||||
## Common Icons
|
||||
|
||||
### Navigation & Actions
|
||||
- `house.fill` - home
|
||||
- `gear` - settings
|
||||
- `magnifyingglass` - search
|
||||
- `plus` - add
|
||||
- `xmark` - close
|
||||
- `chevron.left` - back
|
||||
- `chevron.right` - forward
|
||||
- `arrow.left` - back arrow
|
||||
- `arrow.right` - forward arrow
|
||||
|
||||
### Media
|
||||
- `play.fill` - play
|
||||
- `pause.fill` - pause
|
||||
- `stop.fill` - stop
|
||||
- `backward.fill` - rewind
|
||||
- `forward.fill` - fast forward
|
||||
- `speaker.wave.2.fill` - volume
|
||||
- `speaker.slash.fill` - mute
|
||||
|
||||
### Camera
|
||||
- `camera` - camera
|
||||
- `camera.fill` - camera filled
|
||||
- `arrow.triangle.2.circlepath` - flip camera
|
||||
- `photo` - gallery/photos
|
||||
- `bolt` - flash
|
||||
- `bolt.slash` - flash off
|
||||
|
||||
### Communication
|
||||
- `message` - message
|
||||
- `message.fill` - message filled
|
||||
- `envelope` - email
|
||||
- `envelope.fill` - email filled
|
||||
- `phone` - phone
|
||||
- `phone.fill` - phone filled
|
||||
- `video` - video call
|
||||
- `video.fill` - video call filled
|
||||
|
||||
### Social
|
||||
- `heart` - like
|
||||
- `heart.fill` - liked
|
||||
- `star` - favorite
|
||||
- `star.fill` - favorited
|
||||
- `hand.thumbsup` - thumbs up
|
||||
- `hand.thumbsdown` - thumbs down
|
||||
- `person` - profile
|
||||
- `person.fill` - profile filled
|
||||
- `person.2` - people
|
||||
- `person.2.fill` - people filled
|
||||
|
||||
### Content Actions
|
||||
- `square.and.arrow.up` - share
|
||||
- `square.and.arrow.down` - download
|
||||
- `doc.on.doc` - copy
|
||||
- `trash` - delete
|
||||
- `pencil` - edit
|
||||
- `folder` - folder
|
||||
- `folder.fill` - folder filled
|
||||
- `bookmark` - bookmark
|
||||
- `bookmark.fill` - bookmarked
|
||||
|
||||
### Status & Feedback
|
||||
- `checkmark` - success/done
|
||||
- `checkmark.circle.fill` - completed
|
||||
- `xmark.circle.fill` - error/failed
|
||||
- `exclamationmark.triangle` - warning
|
||||
- `info.circle` - info
|
||||
- `questionmark.circle` - help
|
||||
- `bell` - notification
|
||||
- `bell.fill` - notification filled
|
||||
|
||||
### Misc
|
||||
- `ellipsis` - more options
|
||||
- `ellipsis.circle` - more in circle
|
||||
- `line.3.horizontal` - menu/hamburger
|
||||
- `slider.horizontal.3` - filters
|
||||
- `arrow.clockwise` - refresh
|
||||
- `location` - location
|
||||
- `location.fill` - location filled
|
||||
- `map` - map
|
||||
- `mappin` - pin
|
||||
- `clock` - time
|
||||
- `calendar` - calendar
|
||||
- `link` - link
|
||||
- `nosign` - block/prohibited
|
||||
|
||||
## Animated Symbols
|
||||
|
||||
```tsx
|
||||
<SymbolView
|
||||
name="checkmark.circle"
|
||||
animationSpec={{
|
||||
effect: {
|
||||
type: "bounce",
|
||||
direction: "up",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Animation Effects
|
||||
|
||||
- `bounce` - Bouncy animation
|
||||
- `pulse` - Pulsing effect
|
||||
- `variableColor` - Color cycling
|
||||
- `scale` - Scale animation
|
||||
|
||||
```tsx
|
||||
// Bounce with direction
|
||||
animationSpec={{
|
||||
effect: { type: "bounce", direction: "up" } // up | down
|
||||
}}
|
||||
|
||||
// Pulse
|
||||
animationSpec={{
|
||||
effect: { type: "pulse" }
|
||||
}}
|
||||
|
||||
// Variable color (multicolor symbols)
|
||||
animationSpec={{
|
||||
effect: {
|
||||
type: "variableColor",
|
||||
cumulative: true,
|
||||
reversing: true
|
||||
}
|
||||
}}
|
||||
```
|
||||
|
||||
## Symbol Weights
|
||||
|
||||
```tsx
|
||||
// Lighter weights
|
||||
<SymbolView name="star" weight="ultraLight" />
|
||||
<SymbolView name="star" weight="thin" />
|
||||
<SymbolView name="star" weight="light" />
|
||||
|
||||
// Default
|
||||
<SymbolView name="star" weight="regular" />
|
||||
|
||||
// Heavier weights
|
||||
<SymbolView name="star" weight="medium" />
|
||||
<SymbolView name="star" weight="semibold" />
|
||||
<SymbolView name="star" weight="bold" />
|
||||
<SymbolView name="star" weight="heavy" />
|
||||
<SymbolView name="star" weight="black" />
|
||||
```
|
||||
|
||||
## Symbol Scales
|
||||
|
||||
```tsx
|
||||
<SymbolView name="star" scale="small" />
|
||||
<SymbolView name="star" scale="medium" /> // default
|
||||
<SymbolView name="star" scale="large" />
|
||||
```
|
||||
|
||||
## Multicolor Symbols
|
||||
|
||||
Some symbols support multiple colors:
|
||||
|
||||
```tsx
|
||||
<SymbolView
|
||||
name="cloud.sun.rain.fill"
|
||||
type="multicolor"
|
||||
/>
|
||||
```
|
||||
|
||||
## Finding Symbol Names
|
||||
|
||||
1. Use the SF Symbols app on macOS (free from Apple)
|
||||
2. Search at https://developer.apple.com/sf-symbols/
|
||||
3. Symbol names use dot notation: `square.and.arrow.up`
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Always use SF Symbols over vector icon libraries
|
||||
- Match symbol weight to nearby text weight
|
||||
- Use `.fill` variants for selected/active states
|
||||
- Use PlatformColor for tint to support dark mode
|
||||
- Keep icons at consistent sizes (16, 20, 24, 32)
|
||||
198
.opencode/skills/building-native-ui/references/media.md
Normal file
198
.opencode/skills/building-native-ui/references/media.md
Normal file
@@ -0,0 +1,198 @@
|
||||
# Media
|
||||
|
||||
## Camera
|
||||
|
||||
- Hide navigation headers when there's a full screen camera
|
||||
- Ensure to flip the camera with `mirror` to emulate social apps
|
||||
- Use liquid glass buttons on cameras
|
||||
- Icons: `arrow.triangle.2.circlepath` (flip), `photo` (gallery), `bolt` (flash)
|
||||
- Eagerly request camera permission
|
||||
- Lazily request media library permission
|
||||
|
||||
```tsx
|
||||
import React, { useRef, useState } from "react";
|
||||
import { View, TouchableOpacity, Text, Alert } from "react-native";
|
||||
import { CameraView, CameraType, useCameraPermissions } from "expo-camera";
|
||||
import * as MediaLibrary from "expo-media-library";
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { SymbolView } from "expo-symbols";
|
||||
import { PlatformColor } from "react-native";
|
||||
import { GlassView } from "expo-glass-effect";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
function Camera({ onPicture }: { onPicture: (uri: string) => Promise<void> }) {
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const cameraRef = useRef<CameraView>(null);
|
||||
const [type, setType] = useState<CameraType>("back");
|
||||
const { bottom } = useSafeAreaInsets();
|
||||
|
||||
if (!permission?.granted) {
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: "center", alignItems: "center", backgroundColor: PlatformColor("systemBackground") }}>
|
||||
<Text style={{ color: PlatformColor("label"), padding: 16 }}>Camera access is required</Text>
|
||||
<GlassView isInteractive tintColor={PlatformColor("systemBlue")} style={{ borderRadius: 12 }}>
|
||||
<TouchableOpacity onPress={requestPermission} style={{ padding: 12, borderRadius: 12 }}>
|
||||
<Text style={{ color: "white" }}>Grant Permission</Text>
|
||||
</TouchableOpacity>
|
||||
</GlassView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const takePhoto = async () => {
|
||||
await Haptics.selectionAsync();
|
||||
if (!cameraRef.current) return;
|
||||
const photo = await cameraRef.current.takePictureAsync({ quality: 0.8 });
|
||||
await onPicture(photo.uri);
|
||||
};
|
||||
|
||||
const selectPhoto = async () => {
|
||||
await Haptics.selectionAsync();
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: "images",
|
||||
allowsEditing: false,
|
||||
quality: 0.8,
|
||||
});
|
||||
if (!result.canceled && result.assets?.[0]) {
|
||||
await onPicture(result.assets[0].uri);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: "black" }}>
|
||||
<CameraView ref={cameraRef} mirror style={{ flex: 1 }} facing={type} />
|
||||
<View style={{ position: "absolute", left: 0, right: 0, bottom: bottom, gap: 16, alignItems: "center" }}>
|
||||
<GlassView isInteractive style={{ padding: 8, borderRadius: 99 }}>
|
||||
<TouchableOpacity onPress={takePhoto} style={{ width: 64, height: 64, borderRadius: 99, backgroundColor: "white" }} />
|
||||
</GlassView>
|
||||
<View style={{ flexDirection: "row", justifyContent: "space-around", paddingHorizontal: 8 }}>
|
||||
<GlassButton onPress={selectPhoto} icon="photo" />
|
||||
<GlassButton onPress={() => setType(t => t === "back" ? "front" : "back")} icon="arrow.triangle.2.circlepath" />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Audio Playback
|
||||
|
||||
Use `expo-audio` not `expo-av`:
|
||||
|
||||
```tsx
|
||||
import { useAudioPlayer } from 'expo-audio';
|
||||
|
||||
const player = useAudioPlayer({ uri: 'https://stream.nightride.fm/rektory.mp3' });
|
||||
|
||||
<Button title="Play" onPress={() => player.play()} />
|
||||
```
|
||||
|
||||
## Audio Recording (Microphone)
|
||||
|
||||
```tsx
|
||||
import {
|
||||
useAudioRecorder,
|
||||
AudioModule,
|
||||
RecordingPresets,
|
||||
setAudioModeAsync,
|
||||
useAudioRecorderState,
|
||||
} from 'expo-audio';
|
||||
import { useEffect } from 'react';
|
||||
import { Alert, Button } from 'react-native';
|
||||
|
||||
function App() {
|
||||
const audioRecorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
|
||||
const recorderState = useAudioRecorderState(audioRecorder);
|
||||
|
||||
const record = async () => {
|
||||
await audioRecorder.prepareToRecordAsync();
|
||||
audioRecorder.record();
|
||||
};
|
||||
|
||||
const stop = () => audioRecorder.stop();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const status = await AudioModule.requestRecordingPermissionsAsync();
|
||||
if (status.granted) {
|
||||
setAudioModeAsync({ playsInSilentMode: true, allowsRecording: true });
|
||||
} else {
|
||||
Alert.alert('Permission to access microphone was denied');
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Button
|
||||
title={recorderState.isRecording ? 'Stop' : 'Start'}
|
||||
onPress={recorderState.isRecording ? stop : record}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Video Playback
|
||||
|
||||
Use `expo-video` not `expo-av`:
|
||||
|
||||
```tsx
|
||||
import { useVideoPlayer, VideoView } from 'expo-video';
|
||||
import { useEvent } from 'expo';
|
||||
|
||||
const videoSource = 'https://example.com/video.mp4';
|
||||
|
||||
const player = useVideoPlayer(videoSource, player => {
|
||||
player.loop = true;
|
||||
player.play();
|
||||
});
|
||||
|
||||
const { isPlaying } = useEvent(player, 'playingChange', { isPlaying: player.playing });
|
||||
|
||||
<VideoView player={player} fullscreenOptions={{}} allowsPictureInPicture />
|
||||
```
|
||||
|
||||
VideoView options:
|
||||
- `allowsPictureInPicture`: boolean
|
||||
- `contentFit`: 'contain' | 'cover' | 'fill'
|
||||
- `nativeControls`: boolean
|
||||
- `playsInline`: boolean
|
||||
- `startsPictureInPictureAutomatically`: boolean
|
||||
|
||||
## Saving Media
|
||||
|
||||
```tsx
|
||||
import * as MediaLibrary from "expo-media-library";
|
||||
|
||||
const { granted } = await MediaLibrary.requestPermissionsAsync();
|
||||
if (granted) {
|
||||
await MediaLibrary.saveToLibraryAsync(uri);
|
||||
}
|
||||
```
|
||||
|
||||
### Saving Base64 Images
|
||||
|
||||
`MediaLibrary.saveToLibraryAsync` only accepts local file paths. Save base64 strings to disk first:
|
||||
|
||||
```tsx
|
||||
import { File, Paths } from "expo-file-system/next";
|
||||
|
||||
function base64ToLocalUri(base64: string, filename?: string) {
|
||||
if (!filename) {
|
||||
const match = base64.match(/^data:(image\/[a-zA-Z]+);base64,/);
|
||||
const ext = match ? match[1].split("/")[1] : "jpg";
|
||||
filename = `generated-${Date.now()}.${ext}`;
|
||||
}
|
||||
|
||||
if (base64.startsWith("data:")) base64 = base64.split(",")[1];
|
||||
const binaryString = atob(base64);
|
||||
const len = binaryString.length;
|
||||
const bytes = new Uint8Array(new ArrayBuffer(len));
|
||||
for (let i = 0; i < len; i++) bytes[i] = binaryString.charCodeAt(i);
|
||||
|
||||
const f = new File(Paths.cache, filename);
|
||||
f.create({ overwrite: true });
|
||||
f.write(bytes);
|
||||
return f.uri;
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,229 @@
|
||||
# Route Structure
|
||||
|
||||
## File Conventions
|
||||
|
||||
- Routes belong in the `app` directory
|
||||
- Use `[]` for dynamic routes, e.g. `[id].tsx`
|
||||
- Routes can never be named `(foo).tsx` - use `(foo)/index.tsx` instead
|
||||
- Use `(group)` routes to simplify the public URL structure
|
||||
- NEVER co-locate components, types, or utilities in the app directory - these should be in separate directories like `components/`, `utils/`, etc.
|
||||
- The app directory should only contain route and `_layout` files; every file should export a default component
|
||||
- Ensure the app always has a route that matches "/" so the app is never blank
|
||||
- ALWAYS use `_layout.tsx` files to define stacks
|
||||
|
||||
## Dynamic Routes
|
||||
|
||||
Use square brackets for dynamic segments:
|
||||
|
||||
```
|
||||
app/
|
||||
users/
|
||||
[id].tsx # Matches /users/123, /users/abc
|
||||
[id]/
|
||||
posts.tsx # Matches /users/123/posts
|
||||
```
|
||||
|
||||
### Catch-All Routes
|
||||
|
||||
Use `[...slug]` for catch-all routes:
|
||||
|
||||
```
|
||||
app/
|
||||
docs/
|
||||
[...slug].tsx # Matches /docs/a, /docs/a/b, /docs/a/b/c
|
||||
```
|
||||
|
||||
## Query Parameters
|
||||
|
||||
Access query parameters with the `useLocalSearchParams` hook:
|
||||
|
||||
```tsx
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
|
||||
function Page() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
}
|
||||
```
|
||||
|
||||
For dynamic routes, the parameter name matches the file name:
|
||||
|
||||
- `[id].tsx` → `useLocalSearchParams<{ id: string }>()`
|
||||
- `[slug].tsx` → `useLocalSearchParams<{ slug: string }>()`
|
||||
|
||||
## Pathname
|
||||
|
||||
Access the current pathname with the `usePathname` hook:
|
||||
|
||||
```tsx
|
||||
import { usePathname } from "expo-router";
|
||||
|
||||
function Component() {
|
||||
const pathname = usePathname(); // e.g. "/users/123"
|
||||
}
|
||||
```
|
||||
|
||||
## Group Routes
|
||||
|
||||
Use parentheses for groups that don't affect the URL:
|
||||
|
||||
```
|
||||
app/
|
||||
(auth)/
|
||||
login.tsx # URL: /login
|
||||
register.tsx # URL: /register
|
||||
(main)/
|
||||
index.tsx # URL: /
|
||||
settings.tsx # URL: /settings
|
||||
```
|
||||
|
||||
Groups are useful for:
|
||||
|
||||
- Organizing related routes
|
||||
- Applying different layouts to route groups
|
||||
- Keeping URLs clean
|
||||
|
||||
## Stacks and Tabs Structure
|
||||
|
||||
When an app has tabs, the header and title should be set in a Stack that is nested INSIDE each tab. This allows tabs to have their own headers and distinct histories. The root layout should often not have a header.
|
||||
|
||||
- Set the 'headerShown' option to false on the tab layout
|
||||
- Use (group) routes to simplify the public URL structure
|
||||
- You may need to delete or refactor existing routes to fit this structure
|
||||
|
||||
Example structure:
|
||||
|
||||
```
|
||||
app/
|
||||
_layout.tsx — <Tabs />
|
||||
(home)/
|
||||
_layout.tsx — <Stack />
|
||||
index.tsx — <ScrollView />
|
||||
(settings)/
|
||||
_layout.tsx — <Stack />
|
||||
index.tsx — <ScrollView />
|
||||
(home,settings)/
|
||||
info.tsx — <ScrollView /> (shared across tabs)
|
||||
```
|
||||
|
||||
## Array Routes for Multiple Stacks
|
||||
|
||||
Use array routes '(index,settings)' to create multiple stacks. This is useful for tabs that need to share screens across stacks.
|
||||
|
||||
```
|
||||
app/
|
||||
_layout.tsx — <Tabs />
|
||||
(index,settings)/
|
||||
_layout.tsx — <Stack />
|
||||
index.tsx — <ScrollView />
|
||||
settings.tsx — <ScrollView />
|
||||
```
|
||||
|
||||
This requires a specialized layout with explicit anchor routes:
|
||||
|
||||
```tsx
|
||||
// app/(index,settings)/_layout.tsx
|
||||
import { useMemo } from "react";
|
||||
import Stack from "expo-router/stack";
|
||||
|
||||
export const unstable_settings = {
|
||||
index: { anchor: "index" },
|
||||
settings: { anchor: "settings" },
|
||||
};
|
||||
|
||||
export default function Layout({ segment }: { segment: string }) {
|
||||
const screen = segment.match(/\((.*)\)/)?.[1]!;
|
||||
|
||||
const options = useMemo(() => {
|
||||
switch (screen) {
|
||||
case "index":
|
||||
return { headerRight: () => <></> };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}, [screen]);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen name={screen} options={options} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Complete App Structure Example
|
||||
|
||||
```
|
||||
app/
|
||||
_layout.tsx — <NativeTabs />
|
||||
(index,search)/
|
||||
_layout.tsx — <Stack />
|
||||
index.tsx — Main list
|
||||
search.tsx — Search view
|
||||
i/[id].tsx — Detail page
|
||||
components/
|
||||
theme.tsx
|
||||
list.tsx
|
||||
utils/
|
||||
storage.ts
|
||||
use-search.ts
|
||||
```
|
||||
|
||||
## Layout Files
|
||||
|
||||
Every directory can have a `_layout.tsx` file that wraps all routes in that directory:
|
||||
|
||||
```tsx
|
||||
// app/_layout.tsx
|
||||
import { Stack } from "expo-router/stack";
|
||||
|
||||
export default function RootLayout() {
|
||||
return <Stack />;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// app/(tabs)/_layout.tsx
|
||||
import { NativeTabs, Icon, Label } from "expo-router/unstable-native-tabs";
|
||||
|
||||
export default function TabLayout() {
|
||||
return (
|
||||
<NativeTabs>
|
||||
<NativeTabs.Trigger name="index">
|
||||
<Label>Home</Label>
|
||||
<Icon sf="house.fill" />
|
||||
</NativeTabs.Trigger>
|
||||
</NativeTabs>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Route Settings
|
||||
|
||||
Export `unstable_settings` to configure route behavior:
|
||||
|
||||
```tsx
|
||||
export const unstable_settings = {
|
||||
anchor: "index",
|
||||
};
|
||||
```
|
||||
|
||||
- `initialRouteName` was renamed to `anchor` in v4
|
||||
|
||||
## Not Found Routes
|
||||
|
||||
Create a `+not-found.tsx` file to handle unmatched routes:
|
||||
|
||||
```tsx
|
||||
// app/+not-found.tsx
|
||||
import { Link } from "expo-router";
|
||||
import { View, Text } from "react-native";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<View>
|
||||
<Text>Page not found</Text>
|
||||
<Link href="/">Go home</Link>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
248
.opencode/skills/building-native-ui/references/search.md
Normal file
248
.opencode/skills/building-native-ui/references/search.md
Normal file
@@ -0,0 +1,248 @@
|
||||
# Search
|
||||
|
||||
## Header Search Bar
|
||||
|
||||
Add a search bar to the stack header with `headerSearchBarOptions`:
|
||||
|
||||
```tsx
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{
|
||||
headerSearchBarOptions: {
|
||||
placeholder: "Search",
|
||||
onChangeText: (event) => console.log(event.nativeEvent.text),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
```tsx
|
||||
headerSearchBarOptions: {
|
||||
// Placeholder text
|
||||
placeholder: "Search items...",
|
||||
|
||||
// Auto-capitalize behavior
|
||||
autoCapitalize: "none",
|
||||
|
||||
// Input type
|
||||
inputType: "text", // "text" | "phone" | "number" | "email"
|
||||
|
||||
// Cancel button text (iOS)
|
||||
cancelButtonText: "Cancel",
|
||||
|
||||
// Hide when scrolling (iOS)
|
||||
hideWhenScrolling: true,
|
||||
|
||||
// Hide navigation bar during search (iOS)
|
||||
hideNavigationBar: true,
|
||||
|
||||
// Obscure background during search (iOS)
|
||||
obscureBackground: true,
|
||||
|
||||
// Placement
|
||||
placement: "automatic", // "automatic" | "inline" | "stacked"
|
||||
|
||||
// Callbacks
|
||||
onChangeText: (event) => {},
|
||||
onSearchButtonPress: (event) => {},
|
||||
onCancelButtonPress: (event) => {},
|
||||
onFocus: () => {},
|
||||
onBlur: () => {},
|
||||
}
|
||||
```
|
||||
|
||||
## useSearch Hook
|
||||
|
||||
Reusable hook for search state management:
|
||||
|
||||
```tsx
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigation } from "expo-router";
|
||||
|
||||
export function useSearch(options: any = {}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const navigation = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerShown: true,
|
||||
headerSearchBarOptions: {
|
||||
...options,
|
||||
onChangeText(e: any) {
|
||||
setSearch(e.nativeEvent.text);
|
||||
options.onChangeText?.(e);
|
||||
},
|
||||
onSearchButtonPress(e: any) {
|
||||
setSearch(e.nativeEvent.text);
|
||||
options.onSearchButtonPress?.(e);
|
||||
},
|
||||
onCancelButtonPress(e: any) {
|
||||
setSearch("");
|
||||
options.onCancelButtonPress?.(e);
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [options, navigation]);
|
||||
|
||||
return search;
|
||||
}
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```tsx
|
||||
function SearchScreen() {
|
||||
const search = useSearch({ placeholder: "Search items..." });
|
||||
|
||||
const filteredItems = items.filter(item =>
|
||||
item.name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={filteredItems}
|
||||
renderItem={({ item }) => <ItemRow item={item} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Filtering Patterns
|
||||
|
||||
### Simple Text Filter
|
||||
|
||||
```tsx
|
||||
const filtered = items.filter(item =>
|
||||
item.name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
```
|
||||
|
||||
### Multiple Fields
|
||||
|
||||
```tsx
|
||||
const filtered = items.filter(item => {
|
||||
const query = search.toLowerCase();
|
||||
return (
|
||||
item.name.toLowerCase().includes(query) ||
|
||||
item.description.toLowerCase().includes(query) ||
|
||||
item.tags.some(tag => tag.toLowerCase().includes(query))
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### Debounced Search
|
||||
|
||||
For expensive filtering or API calls:
|
||||
|
||||
```tsx
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
|
||||
function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebounced(value), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
|
||||
return debounced;
|
||||
}
|
||||
|
||||
function SearchScreen() {
|
||||
const search = useSearch();
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
|
||||
const filteredItems = useMemo(() =>
|
||||
items.filter(item =>
|
||||
item.name.toLowerCase().includes(debouncedSearch.toLowerCase())
|
||||
),
|
||||
[debouncedSearch]
|
||||
);
|
||||
|
||||
return <FlatList data={filteredItems} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Search with Native Tabs
|
||||
|
||||
When using NativeTabs with a search role, the search bar integrates with the tab bar:
|
||||
|
||||
```tsx
|
||||
// app/_layout.tsx
|
||||
<NativeTabs>
|
||||
<NativeTabs.Trigger name="(home)">
|
||||
<Label>Home</Label>
|
||||
<Icon sf="house.fill" />
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name="(search)" role="search">
|
||||
<Label>Search</Label>
|
||||
</NativeTabs.Trigger>
|
||||
</NativeTabs>
|
||||
```
|
||||
|
||||
```tsx
|
||||
// app/(search)/_layout.tsx
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{
|
||||
headerSearchBarOptions: {
|
||||
placeholder: "Search...",
|
||||
onChangeText: (e) => setSearch(e.nativeEvent.text),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
```
|
||||
|
||||
## Empty States
|
||||
|
||||
Show appropriate UI when search returns no results:
|
||||
|
||||
```tsx
|
||||
function SearchResults({ search, items }) {
|
||||
const filtered = items.filter(/* ... */);
|
||||
|
||||
if (search && filtered.length === 0) {
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
|
||||
<Text style={{ color: PlatformColor("secondaryLabel") }}>
|
||||
No results for "{search}"
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return <FlatList data={filtered} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Search Suggestions
|
||||
|
||||
Show recent searches or suggestions:
|
||||
|
||||
```tsx
|
||||
function SearchScreen() {
|
||||
const search = useSearch();
|
||||
const [recentSearches, setRecentSearches] = useState<string[]>([]);
|
||||
|
||||
if (!search && recentSearches.length > 0) {
|
||||
return (
|
||||
<View>
|
||||
<Text style={{ color: PlatformColor("secondaryLabel") }}>
|
||||
Recent Searches
|
||||
</Text>
|
||||
{recentSearches.map((term) => (
|
||||
<Pressable key={term} onPress={() => /* apply search */}>
|
||||
<Text>{term}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return <SearchResults search={search} />;
|
||||
}
|
||||
```
|
||||
121
.opencode/skills/building-native-ui/references/storage.md
Normal file
121
.opencode/skills/building-native-ui/references/storage.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Storage
|
||||
|
||||
## Key-Value Storage
|
||||
|
||||
Use the localStorage polyfill for key-value storage. **Never use AsyncStorage**
|
||||
|
||||
```tsx
|
||||
import "expo-sqlite/localStorage/install";
|
||||
|
||||
// Simple get/set
|
||||
localStorage.setItem("key", "value");
|
||||
localStorage.getItem("key");
|
||||
|
||||
// Store objects as JSON
|
||||
localStorage.setItem("user", JSON.stringify({ name: "John", id: 1 }));
|
||||
const user = JSON.parse(localStorage.getItem("user") ?? "{}");
|
||||
```
|
||||
|
||||
## When to Use What
|
||||
|
||||
| Use Case | Solution |
|
||||
| ---------------------------------------------------- | ----------------------- |
|
||||
| Simple key-value (settings, preferences, small data) | `localStorage` polyfill |
|
||||
| Large datasets, complex queries, relational data | Full `expo-sqlite` |
|
||||
| Sensitive data (tokens, passwords) | `expo-secure-store` |
|
||||
|
||||
## Storage with React State
|
||||
|
||||
Create a storage utility with subscriptions for reactive updates:
|
||||
|
||||
```tsx
|
||||
// utils/storage.ts
|
||||
import "expo-sqlite/localStorage/install";
|
||||
|
||||
type Listener = () => void;
|
||||
const listeners = new Map<string, Set<Listener>>();
|
||||
|
||||
export const storage = {
|
||||
get<T>(key: string, defaultValue: T): T {
|
||||
const value = localStorage.getItem(key);
|
||||
return value ? JSON.parse(value) : defaultValue;
|
||||
},
|
||||
|
||||
set<T>(key: string, value: T): void {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
listeners.get(key)?.forEach((fn) => fn());
|
||||
},
|
||||
|
||||
subscribe(key: string, listener: Listener): () => void {
|
||||
if (!listeners.has(key)) listeners.set(key, new Set());
|
||||
listeners.get(key)!.add(listener);
|
||||
return () => listeners.get(key)?.delete(listener);
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## React Hook for Storage
|
||||
|
||||
```tsx
|
||||
// hooks/use-storage.ts
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { storage } from "@/utils/storage";
|
||||
|
||||
export function useStorage<T>(
|
||||
key: string,
|
||||
defaultValue: T
|
||||
): [T, (value: T) => void] {
|
||||
const value = useSyncExternalStore(
|
||||
(cb) => storage.subscribe(key, cb),
|
||||
() => storage.get(key, defaultValue)
|
||||
);
|
||||
|
||||
return [value, (newValue: T) => storage.set(key, newValue)];
|
||||
}
|
||||
```
|
||||
|
||||
Usage:
|
||||
|
||||
```tsx
|
||||
function Settings() {
|
||||
const [theme, setTheme] = useStorage("theme", "light");
|
||||
|
||||
return (
|
||||
<Switch
|
||||
value={theme === "dark"}
|
||||
onValueChange={(dark) => setTheme(dark ? "dark" : "light")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Full SQLite for Complex Data
|
||||
|
||||
For larger datasets or complex queries, use expo-sqlite directly:
|
||||
|
||||
```tsx
|
||||
import * as SQLite from "expo-sqlite";
|
||||
|
||||
const db = await SQLite.openDatabaseAsync("app.db");
|
||||
|
||||
// Create table
|
||||
await db.execAsync(`
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
location TEXT
|
||||
)
|
||||
`);
|
||||
|
||||
// Insert
|
||||
await db.runAsync("INSERT INTO events (title, date) VALUES (?, ?)", [
|
||||
"Meeting",
|
||||
"2024-01-15",
|
||||
]);
|
||||
|
||||
// Query
|
||||
const events = await db.getAllAsync("SELECT * FROM events WHERE date > ?", [
|
||||
"2024-01-01",
|
||||
]);
|
||||
```
|
||||
433
.opencode/skills/building-native-ui/references/tabs.md
Normal file
433
.opencode/skills/building-native-ui/references/tabs.md
Normal file
@@ -0,0 +1,433 @@
|
||||
# Native Tabs
|
||||
|
||||
Always prefer NativeTabs from 'expo-router/unstable-native-tabs' for the best iOS experience.
|
||||
|
||||
**SDK 54+. SDK 55 recommended.**
|
||||
|
||||
## SDK Compatibility
|
||||
|
||||
| Aspect | SDK 54 | SDK 55+ |
|
||||
| ------------- | ------------------------------------------------------- | ----------------------------------------------------------- |
|
||||
| Import | `import { NativeTabs, Icon, Label, Badge, VectorIcon }` | `import { NativeTabs }` only |
|
||||
| Icon | `<Icon sf="house.fill" />` | `<NativeTabs.Trigger.Icon sf="house.fill" />` |
|
||||
| Label | `<Label>Home</Label>` | `<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>` |
|
||||
| Badge | `<Badge>9+</Badge>` | `<NativeTabs.Trigger.Badge>9+</NativeTabs.Trigger.Badge>` |
|
||||
| Android icons | `drawable` prop | `md` prop (Material Symbols) |
|
||||
|
||||
All examples below use SDK 55 syntax. For SDK 54, replace `NativeTabs.Trigger.Icon/Label/Badge` with standalone `Icon`, `Label`, `Badge` imports.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```tsx
|
||||
import { NativeTabs } from "expo-router/unstable-native-tabs";
|
||||
|
||||
export default function TabLayout() {
|
||||
return (
|
||||
<NativeTabs minimizeBehavior="onScrollDown">
|
||||
<NativeTabs.Trigger name="index">
|
||||
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
|
||||
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
||||
<NativeTabs.Trigger.Badge>9+</NativeTabs.Trigger.Badge>
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name="settings">
|
||||
<NativeTabs.Trigger.Icon sf="gear" md="settings" />
|
||||
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name="(search)" role="search">
|
||||
<NativeTabs.Trigger.Label>Search</NativeTabs.Trigger.Label>
|
||||
</NativeTabs.Trigger>
|
||||
</NativeTabs>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- You must include a trigger for each tab
|
||||
- The `NativeTabs.Trigger` 'name' must match the route name, including parentheses (e.g. `<NativeTabs.Trigger name="(search)">`)
|
||||
- Prefer search tab to be last in the list so it can combine with the search bar
|
||||
- Use the 'role' prop for common tab types
|
||||
- Tabs must be static — no dynamic addition/removal at runtime (remounts navigator, loses state)
|
||||
|
||||
## Platform Features
|
||||
|
||||
Native Tabs use platform-specific tab bar implementations:
|
||||
|
||||
- **iOS 26+**: Liquid glass effects with system-native appearance
|
||||
- **Android**: Material 3 bottom navigation
|
||||
- Better performance and native feel
|
||||
|
||||
## Icon Component
|
||||
|
||||
```tsx
|
||||
// SF Symbol (iOS) + Material Symbol (Android)
|
||||
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
|
||||
|
||||
// State variants
|
||||
<NativeTabs.Trigger.Icon sf={{ default: "house", selected: "house.fill" }} md="home" />
|
||||
|
||||
// Custom image
|
||||
<NativeTabs.Trigger.Icon src={require('./icon.png')} />
|
||||
|
||||
// Xcode asset catalog — iOS only (SDK 55+)
|
||||
<NativeTabs.Trigger.Icon xcasset="home-icon" />
|
||||
<NativeTabs.Trigger.Icon xcasset={{ default: "home-outline", selected: "home-filled" }} />
|
||||
|
||||
// Rendering mode — iOS only (SDK 55+)
|
||||
<NativeTabs.Trigger.Icon src={require('./icon.png')} renderingMode="template" />
|
||||
<NativeTabs.Trigger.Icon src={require('./gradient.png')} renderingMode="original" />
|
||||
```
|
||||
|
||||
`renderingMode`: `"template"` applies tint color (single-color icons), `"original"` preserves source colors (gradients). Android always uses original.
|
||||
|
||||
## Label & Badge
|
||||
|
||||
```tsx
|
||||
// Label
|
||||
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
||||
<NativeTabs.Trigger.Label hidden>Home</NativeTabs.Trigger.Label> {/* icon-only tab */}
|
||||
|
||||
// Badge
|
||||
<NativeTabs.Trigger.Badge>9+</NativeTabs.Trigger.Badge>
|
||||
<NativeTabs.Trigger.Badge /> {/* dot indicator */}
|
||||
```
|
||||
|
||||
## iOS 26 Features
|
||||
|
||||
### Liquid Glass Tab Bar
|
||||
|
||||
The tab bar automatically adopts liquid glass appearance on iOS 26+.
|
||||
|
||||
### Minimize on Scroll
|
||||
|
||||
```tsx
|
||||
<NativeTabs minimizeBehavior="onScrollDown">
|
||||
```
|
||||
|
||||
### Search Tab
|
||||
|
||||
```tsx
|
||||
<NativeTabs.Trigger name="(search)" role="search">
|
||||
<NativeTabs.Trigger.Label>Search</NativeTabs.Trigger.Label>
|
||||
</NativeTabs.Trigger>
|
||||
```
|
||||
|
||||
**Note**: Place search tab last for best UX.
|
||||
|
||||
### Role Prop
|
||||
|
||||
Use semantic roles for special tab types:
|
||||
|
||||
```tsx
|
||||
<NativeTabs.Trigger name="search" role="search" />
|
||||
<NativeTabs.Trigger name="favorites" role="favorites" />
|
||||
<NativeTabs.Trigger name="more" role="more" />
|
||||
```
|
||||
|
||||
Available roles: `search` | `more` | `favorites` | `bookmarks` | `contacts` | `downloads` | `featured` | `history` | `mostRecent` | `mostViewed` | `recents` | `topRated`
|
||||
|
||||
## Customization
|
||||
|
||||
### Tint Color
|
||||
|
||||
```tsx
|
||||
<NativeTabs tintColor="#007AFF">
|
||||
```
|
||||
|
||||
### Dynamic Colors (iOS)
|
||||
|
||||
Use DynamicColorIOS for colors that adapt to liquid glass:
|
||||
|
||||
```tsx
|
||||
import { DynamicColorIOS, Platform } from 'react-native';
|
||||
|
||||
const adaptiveBlue = Platform.select({
|
||||
ios: DynamicColorIOS({ light: '#007AFF', dark: '#0A84FF' }),
|
||||
default: '#007AFF',
|
||||
});
|
||||
|
||||
<NativeTabs tintColor={adaptiveBlue}>
|
||||
```
|
||||
|
||||
## Conditional Tabs
|
||||
|
||||
```tsx
|
||||
<NativeTabs.Trigger name="admin" hidden={!isAdmin}>
|
||||
<NativeTabs.Trigger.Label>Admin</NativeTabs.Trigger.Label>
|
||||
<NativeTabs.Trigger.Icon sf="shield.fill" md="shield" />
|
||||
</NativeTabs.Trigger>
|
||||
```
|
||||
|
||||
**Don't hide the tabs when they are visible - toggling visibility remounts the navigator; Do it only during the initial render.**
|
||||
|
||||
**Note**: Hidden tabs cannot be navigated to!
|
||||
|
||||
## Behavior Options
|
||||
|
||||
```tsx
|
||||
<NativeTabs.Trigger
|
||||
name="home"
|
||||
disablePopToTop // Don't pop stack when tapping active tab
|
||||
disableScrollToTop // Don't scroll to top when tapping active tab
|
||||
disableAutomaticContentInsets // Opt out of automatic safe area insets (SDK 55+)
|
||||
>
|
||||
```
|
||||
|
||||
## Hidden Tab Bar (SDK 55+)
|
||||
|
||||
Use `hidden` prop on `NativeTabs` to hide the entire tab bar dynamically:
|
||||
|
||||
```tsx
|
||||
<NativeTabs hidden={isTabBarHidden}>{/* triggers */}</NativeTabs>
|
||||
```
|
||||
|
||||
## Bottom Accessory (SDK 55+)
|
||||
|
||||
`NativeTabs.BottomAccessory` renders content above the tab bar (iOS 26+). Uses `usePlacement()` to adapt between `'regular'` and `'inline'` layouts.
|
||||
|
||||
**Important**: Two instances render simultaneously — store state outside the component (props, context, or external store).
|
||||
|
||||
```tsx
|
||||
import { NativeTabs } from "expo-router/unstable-native-tabs";
|
||||
import { useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
|
||||
function MiniPlayer({
|
||||
isPlaying,
|
||||
onToggle,
|
||||
}: {
|
||||
isPlaying: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const placement = NativeTabs.BottomAccessory.usePlacement();
|
||||
if (placement === "inline") {
|
||||
return (
|
||||
<Pressable onPress={onToggle}>
|
||||
<SymbolView name={isPlaying ? "pause.fill" : "play.fill"} />
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
return <View>{/* full player UI */}</View>;
|
||||
}
|
||||
|
||||
export default function TabLayout() {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
return (
|
||||
<NativeTabs>
|
||||
<NativeTabs.BottomAccessory>
|
||||
<MiniPlayer
|
||||
isPlaying={isPlaying}
|
||||
onToggle={() => setIsPlaying(!isPlaying)}
|
||||
/>
|
||||
</NativeTabs.BottomAccessory>
|
||||
<NativeTabs.Trigger name="index">
|
||||
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
|
||||
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
||||
</NativeTabs.Trigger>
|
||||
</NativeTabs>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Safe Area Handling (SDK 55+)
|
||||
|
||||
SDK 55 handles safe areas automatically:
|
||||
|
||||
- **Android**: Content wrapped in SafeAreaView (bottom inset)
|
||||
- **iOS**: First ScrollView gets automatic `contentInsetAdjustmentBehavior`
|
||||
|
||||
To opt out per-tab, use `disableAutomaticContentInsets` and manage manually:
|
||||
|
||||
```tsx
|
||||
<NativeTabs.Trigger name="index" disableAutomaticContentInsets>
|
||||
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
||||
</NativeTabs.Trigger>
|
||||
```
|
||||
|
||||
```tsx
|
||||
// In the screen
|
||||
import { SafeAreaView } from "react-native-screens/experimental";
|
||||
|
||||
export default function HomeScreen() {
|
||||
return (
|
||||
<SafeAreaView edges={{ bottom: true }} style={{ flex: 1 }}>
|
||||
{/* content */}
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Using Vector Icons
|
||||
|
||||
If you must use @expo/vector-icons instead of SF Symbols:
|
||||
|
||||
```tsx
|
||||
import { NativeTabs } from "expo-router/unstable-native-tabs";
|
||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||
|
||||
<NativeTabs.Trigger name="home">
|
||||
<NativeTabs.Trigger.VectorIcon vector={Ionicons} name="home" />
|
||||
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
||||
</NativeTabs.Trigger>
|
||||
```
|
||||
|
||||
**Prefer SF Symbols + `md` prop over vector icons for native feel.**
|
||||
|
||||
If you are using SDK 55 and later **use the md prop to specify Material Symbols used on Android**.
|
||||
|
||||
## Structure with Stacks
|
||||
|
||||
Native tabs don't render headers. Nest Stacks inside each tab for navigation headers:
|
||||
|
||||
```tsx
|
||||
// app/(tabs)/_layout.tsx
|
||||
import { NativeTabs } from "expo-router/unstable-native-tabs";
|
||||
|
||||
export default function TabLayout() {
|
||||
return (
|
||||
<NativeTabs>
|
||||
<NativeTabs.Trigger name="(home)">
|
||||
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
||||
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
|
||||
</NativeTabs.Trigger>
|
||||
</NativeTabs>
|
||||
);
|
||||
}
|
||||
|
||||
// app/(tabs)/(home)/_layout.tsx
|
||||
import Stack from "expo-router/stack";
|
||||
|
||||
export default function HomeStack() {
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{ title: "Home", headerLargeTitle: true }}
|
||||
/>
|
||||
<Stack.Screen name="details" options={{ title: "Details" }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Web Layout
|
||||
|
||||
Use platform-specific files for separate native and web tab layouts:
|
||||
|
||||
```
|
||||
app/
|
||||
_layout.tsx # NativeTabs for iOS/Android
|
||||
_layout.web.tsx # Headless tabs for web (expo-router/ui)
|
||||
```
|
||||
|
||||
Or extract to a component: `components/app-tabs.tsx` + `components/app-tabs.web.tsx`.
|
||||
|
||||
## Migration from JS Tabs
|
||||
|
||||
### Before (JS Tabs)
|
||||
|
||||
```tsx
|
||||
import { Tabs } from "expo-router";
|
||||
|
||||
<Tabs>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: "Home",
|
||||
tabBarIcon: ({ color }) => <IconSymbol name="house.fill" color={color} />,
|
||||
tabBarBadge: 3,
|
||||
}}
|
||||
/>
|
||||
</Tabs>;
|
||||
```
|
||||
|
||||
### After (Native Tabs)
|
||||
|
||||
```tsx
|
||||
import { NativeTabs } from "expo-router/unstable-native-tabs";
|
||||
|
||||
<NativeTabs>
|
||||
<NativeTabs.Trigger name="index">
|
||||
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
||||
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
|
||||
<NativeTabs.Trigger.Badge>3</NativeTabs.Trigger.Badge>
|
||||
</NativeTabs.Trigger>
|
||||
</NativeTabs>;
|
||||
```
|
||||
|
||||
### Key Differences
|
||||
|
||||
| JS Tabs | Native Tabs |
|
||||
| -------------------------- | ---------------------------- |
|
||||
| `<Tabs.Screen>` | `<NativeTabs.Trigger>` |
|
||||
| `options={{ title }}` | `<NativeTabs.Trigger.Label>` |
|
||||
| `options={{ tabBarIcon }}` | `<NativeTabs.Trigger.Icon>` |
|
||||
| `tabBarBadge` option | `<NativeTabs.Trigger.Badge>` |
|
||||
| Props-based API | Component-based API |
|
||||
| Headers built-in | Nest `<Stack>` for headers |
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Android**: Maximum 5 tabs (Material Design constraint)
|
||||
- **Nesting**: Native tabs cannot nest inside other native tabs
|
||||
- **Tab bar height**: Cannot be measured programmatically
|
||||
- **FlatList transparency**: Use `disableTransparentOnScrollEdge` to fix issues
|
||||
- **Dynamic tabs**: Tabs must be static; changes remount navigator and lose state
|
||||
|
||||
## Keyboard Handling (Android)
|
||||
|
||||
Configure in app.json:
|
||||
|
||||
```json
|
||||
{
|
||||
"expo": {
|
||||
"android": {
|
||||
"softwareKeyboardLayoutMode": "resize"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Issues
|
||||
|
||||
1. **Icons not showing on Android**: Add `md` prop (SDK 55) or use VectorIcon
|
||||
2. **Headers missing**: Nest a Stack inside each tab group
|
||||
3. **Trigger name mismatch**: `name` must match exact route name including parentheses
|
||||
4. **Badge not visible**: Badge must be a child of Trigger, not a prop
|
||||
5. **Tab bar transparent on iOS 18 and earlier**: If the screen uses a `ScrollView` or `FlatList`, make sure it is the first opaque child of the screen component. If it needs to be wrapped in another `View`, ensure the wrapper uses `collapsable={false}`. If the screen does not use a `ScrollView` or `FlatList`, set `disableTransparentOnScrollEdge` to `true` in the `NativeTabs.Trigger` options, to make the tab bar opaque.
|
||||
6. **Scroll to top not working**: Ensure `disableScrollToTop` is not set on the active tab's Trigger and `ScrollView` is the first child of the screen component.
|
||||
7. **Header buttons flicker when navigating between tabs**: Make sure the app is wrapped in a `ThemeProvider`
|
||||
|
||||
```tsx
|
||||
import {
|
||||
ThemeProvider,
|
||||
DarkTheme,
|
||||
DefaultTheme,
|
||||
} from "@react-navigation/native";
|
||||
import { useColorScheme } from "react-native";
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
export default function Layout() {
|
||||
const colorScheme = useColorScheme();
|
||||
return (
|
||||
<ThemeProvider theme={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
|
||||
<Stack />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
If the app only uses a light or dark theme, you can directly pass `DarkTheme` or `DefaultTheme` to `ThemeProvider` without checking the color scheme.
|
||||
|
||||
```tsx
|
||||
import { ThemeProvider, DarkTheme } from "@react-navigation/native";
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<ThemeProvider theme={DarkTheme}>
|
||||
<Stack />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,284 @@
|
||||
# Toolbars and headers
|
||||
|
||||
Add native iOS toolbar items to Stack screens. Items can be placed in the header (left/right) or in a bottom toolbar area.
|
||||
|
||||
**Important:** iOS only. Available in Expo SDK 55+.
|
||||
|
||||
## Notes app example
|
||||
|
||||
```tsx
|
||||
import { Stack } from "expo-router";
|
||||
import { ScrollView } from "react-native";
|
||||
|
||||
export default function FoldersScreen() {
|
||||
return (
|
||||
<>
|
||||
{/* ScrollView must be the first child of the screen */}
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
>
|
||||
{/* Screen content */}
|
||||
</ScrollView>
|
||||
<Stack.Screen.Title large>Folders</Stack.Screen.Title>
|
||||
<Stack.SearchBar placeholder="Search" onChangeText={() => {}} />
|
||||
{/* Header toolbar - right side */}
|
||||
<Stack.Toolbar placement="right">
|
||||
<Stack.Toolbar.Button icon="folder.badge.plus" onPress={() => {}} />
|
||||
<Stack.Toolbar.Button onPress={() => {}}>Edit</Stack.Toolbar.Button>
|
||||
</Stack.Toolbar>
|
||||
|
||||
{/* Bottom toolbar */}
|
||||
<Stack.Toolbar placement="bottom">
|
||||
<Stack.Toolbar.SearchBarSlot />
|
||||
<Stack.Toolbar.Button
|
||||
icon="square.and.pencil"
|
||||
onPress={() => {}}
|
||||
separateBackground
|
||||
/>
|
||||
</Stack.Toolbar>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Mail inbox example
|
||||
|
||||
```tsx
|
||||
import { Color, Stack } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
|
||||
export default function InboxScreen() {
|
||||
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
contentContainerStyle={{ paddingHorizontal: 16 }}
|
||||
>
|
||||
{/* Screen content */}
|
||||
</ScrollView>
|
||||
<Stack.Screen options={{ headerTransparent: true }} />
|
||||
<Stack.Screen.Title>Inbox</Stack.Screen.Title>
|
||||
<Stack.SearchBar placeholder="Search" onChangeText={() => {}} />
|
||||
{/* Header toolbar - right side */}
|
||||
<Stack.Toolbar placement="right">
|
||||
<Stack.Toolbar.Button onPress={() => {}}>Select</Stack.Toolbar.Button>
|
||||
<Stack.Toolbar.Menu icon="ellipsis">
|
||||
<Stack.Toolbar.Menu inline>
|
||||
<Stack.Toolbar.Menu inline title="Sort By">
|
||||
<Stack.Toolbar.MenuAction isOn>
|
||||
Categories
|
||||
</Stack.Toolbar.MenuAction>
|
||||
<Stack.Toolbar.MenuAction>List</Stack.Toolbar.MenuAction>
|
||||
</Stack.Toolbar.Menu>
|
||||
<Stack.Toolbar.MenuAction icon="info.circle">
|
||||
About categories
|
||||
</Stack.Toolbar.MenuAction>
|
||||
</Stack.Toolbar.Menu>
|
||||
<Stack.Toolbar.MenuAction icon="person.circle">
|
||||
Show Contact Photos
|
||||
</Stack.Toolbar.MenuAction>
|
||||
</Stack.Toolbar.Menu>
|
||||
</Stack.Toolbar>
|
||||
|
||||
{/* Bottom toolbar */}
|
||||
<Stack.Toolbar placement="bottom">
|
||||
<Stack.Toolbar.Button
|
||||
icon="line.3.horizontal.decrease"
|
||||
selected={isFilterOpen}
|
||||
onPress={() => setIsFilterOpen((prev) => !prev)}
|
||||
/>
|
||||
<Stack.Toolbar.View hidden={!isFilterOpen}>
|
||||
<View style={{ width: 70, height: 32, justifyContent: "center" }}>
|
||||
<Text style={{ fontSize: 12, fontWeight: 700 }}>Filter by</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: Color.ios.systemBlue,
|
||||
}}
|
||||
>
|
||||
Unread
|
||||
</Text>
|
||||
</View>
|
||||
</Stack.Toolbar.View>
|
||||
<Stack.Toolbar.Spacer />
|
||||
<Stack.Toolbar.SearchBarSlot />
|
||||
<Stack.Toolbar.Button
|
||||
icon="square.and.pencil"
|
||||
onPress={() => {}}
|
||||
separateBackground
|
||||
/>
|
||||
</Stack.Toolbar>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Placement
|
||||
|
||||
- `"left"` - Header left
|
||||
- `"right"` - Header right
|
||||
- `"bottom"` (default) - Bottom toolbar
|
||||
|
||||
## Components
|
||||
|
||||
### Button
|
||||
|
||||
- Icon button: `<Stack.Toolbar.Button icon="star.fill" onPress={() => {}} />`
|
||||
- Text button: `<Stack.Toolbar.Button onPress={() => {}}>Done</Stack.Toolbar.Button>`
|
||||
|
||||
**Props:** `icon`, `image`, `onPress`, `disabled`, `hidden`, `variant` (`"plain"` | `"done"` | `"prominent"`), `tintColor`
|
||||
|
||||
### Menu
|
||||
|
||||
Dropdown menu for grouping actions.
|
||||
|
||||
```tsx
|
||||
<Stack.Toolbar.Menu icon="ellipsis">
|
||||
<Stack.Toolbar.Menu inline>
|
||||
<Stack.Toolbar.MenuAction>Sort by Recently Added</Stack.Toolbar.MenuAction>
|
||||
<Stack.Toolbar.MenuAction isOn>
|
||||
Sort by Date Captured
|
||||
</Stack.Toolbar.MenuAction>
|
||||
</Stack.Toolbar.Menu>
|
||||
<Stack.Toolbar.Menu title="Filter">
|
||||
<Stack.Toolbar.Menu inline>
|
||||
<Stack.Toolbar.MenuAction isOn icon="square.grid.2x2">
|
||||
All Items
|
||||
</Stack.Toolbar.MenuAction>
|
||||
</Stack.Toolbar.Menu>
|
||||
<Stack.Toolbar.MenuAction icon="heart">Favorites</Stack.Toolbar.MenuAction>
|
||||
<Stack.Toolbar.MenuAction icon="photo">Photos</Stack.Toolbar.MenuAction>
|
||||
<Stack.Toolbar.MenuAction icon="video">Videos</Stack.Toolbar.MenuAction>
|
||||
</Stack.Toolbar.Menu>
|
||||
</Stack.Toolbar.Menu>
|
||||
```
|
||||
|
||||
**Menu Props:** All Button props plus `title`, `inline`, `palette`, `elementSize` (`"small"` | `"medium"` | `"large"`)
|
||||
|
||||
**MenuAction Props:** `icon`, `onPress`, `isOn`, `destructive`, `disabled`, `subtitle`
|
||||
|
||||
When creating a palette with dividers, use `inline` combined with `elementSize="small"`. `palette` will not apply dividers on iOS 26.
|
||||
|
||||
### Spacer
|
||||
|
||||
```tsx
|
||||
<Stack.Toolbar.Spacer /> // Bottom toolbar - flexible
|
||||
<Stack.Toolbar.Spacer width={16} /> // Header - requires explicit width
|
||||
```
|
||||
|
||||
### View
|
||||
|
||||
Embed custom React Native components. When adding a custom view make sure that there is only a single child with **explicit width and height**.
|
||||
|
||||
```tsx
|
||||
<Stack.Toolbar.View>
|
||||
<View style={{ width: 70, height: 32, justifyContent: "center" }}>
|
||||
<Text style={{ fontSize: 12, fontWeight: 700 }}>Filter by</Text>
|
||||
</View>
|
||||
</Stack.Toolbar.View>
|
||||
```
|
||||
|
||||
You can pass custom components to views as well:
|
||||
|
||||
```tsx
|
||||
function CustomFilterView() {
|
||||
return (
|
||||
<View style={{ width: 70, height: 32, justifyContent: "center" }}>
|
||||
<Text style={{ fontSize: 12, fontWeight: 700 }}>Filter by</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
...
|
||||
<Stack.Toolbar.View>
|
||||
<CustomFilterView />
|
||||
</Stack.Toolbar.View>
|
||||
```
|
||||
|
||||
## Recommendations
|
||||
|
||||
- When creating more complex headers, extract them to a single component
|
||||
|
||||
```tsx
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<ScrollView>{/* Screen content */}</ScrollView>
|
||||
<InboxHeader />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function InboxHeader() {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen.Title>Inbox</Stack.Screen.Title>
|
||||
<Stack.SearchBar placeholder="Search" onChangeText={() => {}} />
|
||||
<Stack.Toolbar placement="right">{/* Toolbar buttons */}</Stack.Toolbar>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- When using `Stack.Toolbar`, make sure that all `Stack.Toolbar.*` components are wrapped inside `Stack.Toolbar` component.
|
||||
|
||||
This will **not work**:
|
||||
|
||||
```tsx
|
||||
function Buttons() {
|
||||
return (
|
||||
<>
|
||||
<Stack.Toolbar.Button icon="star.fill" onPress={() => {}} />
|
||||
<Stack.Toolbar.Button onPress={() => {}}>Done</Stack.Toolbar.Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Page() {
|
||||
return (
|
||||
<>
|
||||
<ScrollView>{/* Screen content */}</ScrollView>
|
||||
<Stack.Toolbar placement="right">
|
||||
<Buttons /> {/* ❌ This will NOT work */}
|
||||
</Stack.Toolbar>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
This will work:
|
||||
|
||||
```tsx
|
||||
function ToolbarWithButtons() {
|
||||
return (
|
||||
<Stack.Toolbar>
|
||||
<Stack.Toolbar.Button icon="star.fill" onPress={() => {}} />
|
||||
<Stack.Toolbar.Button onPress={() => {}}>Done</Stack.Toolbar.Button>
|
||||
</Stack.Toolbar>
|
||||
);
|
||||
}
|
||||
|
||||
function Page() {
|
||||
return (
|
||||
<>
|
||||
<ScrollView>{/* Screen content */}</ScrollView>
|
||||
<ToolbarWithButtons /> {/* ✅ This will work */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
- iOS only
|
||||
- `placement="bottom"` can only be used inside screen components (not in layout files)
|
||||
- `Stack.Toolbar.Badge` only works with `placement="left"` or `"right"`
|
||||
- Header Spacers require explicit `width`
|
||||
|
||||
## Reference
|
||||
|
||||
Docs https://docs.expo.dev/versions/unversioned/sdk/router - read to see the full API.
|
||||
197
.opencode/skills/building-native-ui/references/visual-effects.md
Normal file
197
.opencode/skills/building-native-ui/references/visual-effects.md
Normal file
@@ -0,0 +1,197 @@
|
||||
# Visual Effects
|
||||
|
||||
## Backdrop Blur
|
||||
|
||||
Use `expo-blur` for blur effects. Prefer systemMaterial tints as they adapt to dark mode.
|
||||
|
||||
```tsx
|
||||
import { BlurView } from "expo-blur";
|
||||
|
||||
<BlurView tint="systemMaterial" intensity={100} />;
|
||||
```
|
||||
|
||||
### Tint Options
|
||||
|
||||
```tsx
|
||||
// System materials (adapt to dark mode)
|
||||
<BlurView tint="systemMaterial" />
|
||||
<BlurView tint="systemThinMaterial" />
|
||||
<BlurView tint="systemUltraThinMaterial" />
|
||||
<BlurView tint="systemThickMaterial" />
|
||||
<BlurView tint="systemChromeMaterial" />
|
||||
|
||||
// Basic tints
|
||||
<BlurView tint="light" />
|
||||
<BlurView tint="dark" />
|
||||
<BlurView tint="default" />
|
||||
|
||||
// Prominent (more visible)
|
||||
<BlurView tint="prominent" />
|
||||
|
||||
// Extra light/dark
|
||||
<BlurView tint="extraLight" />
|
||||
```
|
||||
|
||||
### Intensity
|
||||
|
||||
Control blur strength with `intensity` (0-100):
|
||||
|
||||
```tsx
|
||||
<BlurView tint="systemMaterial" intensity={50} /> // Subtle
|
||||
<BlurView tint="systemMaterial" intensity={100} /> // Full
|
||||
```
|
||||
|
||||
### Rounded Corners
|
||||
|
||||
BlurView requires `overflow: 'hidden'` to clip rounded corners:
|
||||
|
||||
```tsx
|
||||
<BlurView
|
||||
tint="systemMaterial"
|
||||
intensity={100}
|
||||
style={{
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Overlay Pattern
|
||||
|
||||
Common pattern for overlaying blur on content:
|
||||
|
||||
```tsx
|
||||
<View style={{ position: 'relative' }}>
|
||||
<Image source={{ uri: '...' }} style={{ width: '100%', height: 200 }} />
|
||||
<BlurView
|
||||
tint="systemUltraThinMaterial"
|
||||
intensity={80}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: 'white' }}>Caption</Text>
|
||||
</BlurView>
|
||||
</View>
|
||||
```
|
||||
|
||||
## Glass Effects (iOS 26+)
|
||||
|
||||
Use `expo-glass-effect` for liquid glass backdrops on iOS 26+.
|
||||
|
||||
```tsx
|
||||
import { GlassView } from "expo-glass-effect";
|
||||
|
||||
<GlassView style={{ borderRadius: 16, padding: 16 }}>
|
||||
<Text>Content inside glass</Text>
|
||||
</GlassView>
|
||||
```
|
||||
|
||||
### Interactive Glass
|
||||
|
||||
Add `isInteractive` for buttons and pressable glass:
|
||||
|
||||
```tsx
|
||||
import { GlassView } from "expo-glass-effect";
|
||||
import { SymbolView } from "expo-symbols";
|
||||
import { PlatformColor } from "react-native";
|
||||
|
||||
<GlassView isInteractive style={{ borderRadius: 50 }}>
|
||||
<Pressable style={{ padding: 12 }} onPress={handlePress}>
|
||||
<SymbolView name="plus" tintColor={PlatformColor("label")} size={36} />
|
||||
</Pressable>
|
||||
</GlassView>
|
||||
```
|
||||
|
||||
### Glass Buttons
|
||||
|
||||
Create liquid glass buttons:
|
||||
|
||||
```tsx
|
||||
function GlassButton({ icon, onPress }) {
|
||||
return (
|
||||
<GlassView isInteractive style={{ borderRadius: 50 }}>
|
||||
<Pressable style={{ padding: 12 }} onPress={onPress}>
|
||||
<SymbolView name={icon} tintColor={PlatformColor("label")} size={24} />
|
||||
</Pressable>
|
||||
</GlassView>
|
||||
);
|
||||
}
|
||||
|
||||
// Usage
|
||||
<GlassButton icon="plus" onPress={handleAdd} />
|
||||
<GlassButton icon="gear" onPress={handleSettings} />
|
||||
```
|
||||
|
||||
### Glass Card
|
||||
|
||||
```tsx
|
||||
<GlassView style={{ borderRadius: 20, padding: 20 }}>
|
||||
<Text style={{ fontSize: 18, fontWeight: '600', color: PlatformColor("label") }}>
|
||||
Card Title
|
||||
</Text>
|
||||
<Text style={{ color: PlatformColor("secondaryLabel"), marginTop: 8 }}>
|
||||
Card content goes here
|
||||
</Text>
|
||||
</GlassView>
|
||||
```
|
||||
|
||||
### Checking Availability
|
||||
|
||||
```tsx
|
||||
import { isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
|
||||
if (isLiquidGlassAvailable()) {
|
||||
// Use GlassView
|
||||
} else {
|
||||
// Fallback to BlurView or solid background
|
||||
}
|
||||
```
|
||||
|
||||
### Fallback Pattern
|
||||
|
||||
```tsx
|
||||
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
import { BlurView } from "expo-blur";
|
||||
|
||||
function AdaptiveGlass({ children, style }) {
|
||||
if (isLiquidGlassAvailable()) {
|
||||
return <GlassView style={style}>{children}</GlassView>;
|
||||
}
|
||||
|
||||
return (
|
||||
<BlurView tint="systemMaterial" intensity={80} style={style}>
|
||||
{children}
|
||||
</BlurView>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Sheet with Glass Background
|
||||
|
||||
Make sheet backgrounds liquid glass on iOS 26+:
|
||||
|
||||
```tsx
|
||||
<Stack.Screen
|
||||
name="sheet"
|
||||
options={{
|
||||
presentation: "formSheet",
|
||||
sheetGrabberVisible: true,
|
||||
sheetAllowedDetents: [0.5, 1.0],
|
||||
contentStyle: { backgroundColor: "transparent" },
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Use `systemMaterial` tints for automatic dark mode support
|
||||
- Always set `overflow: 'hidden'` on BlurView for rounded corners
|
||||
- Use `isInteractive` on GlassView for buttons and pressables
|
||||
- Check `isLiquidGlassAvailable()` and provide fallbacks
|
||||
- Avoid nesting blur views (performance impact)
|
||||
- Keep blur intensity reasonable (50-100) for readability
|
||||
605
.opencode/skills/building-native-ui/references/webgpu-three.md
Normal file
605
.opencode/skills/building-native-ui/references/webgpu-three.md
Normal file
@@ -0,0 +1,605 @@
|
||||
# WebGPU & Three.js for Expo
|
||||
|
||||
**Use this skill for ANY 3D graphics, games, GPU compute, or Three.js features in React Native.**
|
||||
|
||||
## Locked Versions (Tested & Working)
|
||||
|
||||
```json
|
||||
{
|
||||
"react-native-wgpu": "^0.4.1",
|
||||
"three": "0.172.0",
|
||||
"@react-three/fiber": "^9.4.0",
|
||||
"wgpu-matrix": "^3.0.2",
|
||||
"@types/three": "0.172.0"
|
||||
}
|
||||
```
|
||||
|
||||
**Critical:** These versions are tested together. Mismatched versions cause type errors and runtime issues.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install react-native-wgpu@^0.4.1 three@0.172.0 @react-three/fiber@^9.4.0 wgpu-matrix@^3.0.2 @types/three@0.172.0 --legacy-peer-deps
|
||||
```
|
||||
|
||||
**Note:** `--legacy-peer-deps` may be required due to peer dependency conflicts with canary Expo versions.
|
||||
|
||||
## Metro Configuration
|
||||
|
||||
Create `metro.config.js` in project root:
|
||||
|
||||
```js
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
config.resolver.resolveRequest = (context, moduleName, platform) => {
|
||||
// Force 'three' to webgpu build
|
||||
if (moduleName.startsWith("three")) {
|
||||
moduleName = "three/webgpu";
|
||||
}
|
||||
|
||||
// Use standard react-three/fiber instead of React Native version
|
||||
if (platform !== "web" && moduleName.startsWith("@react-three/fiber")) {
|
||||
return context.resolveRequest(
|
||||
{
|
||||
...context,
|
||||
unstable_conditionNames: ["module"],
|
||||
mainFields: ["module"],
|
||||
},
|
||||
moduleName,
|
||||
platform
|
||||
);
|
||||
}
|
||||
return context.resolveRequest(context, moduleName, platform);
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
```
|
||||
|
||||
## Required Lib Files
|
||||
|
||||
Create these files in `src/lib/`:
|
||||
|
||||
### 1. make-webgpu-renderer.ts
|
||||
|
||||
```ts
|
||||
import type { NativeCanvas } from "react-native-wgpu";
|
||||
import * as THREE from "three/webgpu";
|
||||
|
||||
export class ReactNativeCanvas {
|
||||
constructor(private canvas: NativeCanvas) {}
|
||||
|
||||
get width() {
|
||||
return this.canvas.width;
|
||||
}
|
||||
get height() {
|
||||
return this.canvas.height;
|
||||
}
|
||||
set width(width: number) {
|
||||
this.canvas.width = width;
|
||||
}
|
||||
set height(height: number) {
|
||||
this.canvas.height = height;
|
||||
}
|
||||
get clientWidth() {
|
||||
return this.canvas.width;
|
||||
}
|
||||
get clientHeight() {
|
||||
return this.canvas.height;
|
||||
}
|
||||
set clientWidth(width: number) {
|
||||
this.canvas.width = width;
|
||||
}
|
||||
set clientHeight(height: number) {
|
||||
this.canvas.height = height;
|
||||
}
|
||||
|
||||
addEventListener(_type: string, _listener: EventListener) {}
|
||||
removeEventListener(_type: string, _listener: EventListener) {}
|
||||
dispatchEvent(_event: Event) {}
|
||||
setPointerCapture() {}
|
||||
releasePointerCapture() {}
|
||||
}
|
||||
|
||||
export const makeWebGPURenderer = (
|
||||
context: GPUCanvasContext,
|
||||
{ antialias = true }: { antialias?: boolean } = {}
|
||||
) =>
|
||||
new THREE.WebGPURenderer({
|
||||
antialias,
|
||||
// @ts-expect-error
|
||||
canvas: new ReactNativeCanvas(context.canvas),
|
||||
context,
|
||||
});
|
||||
```
|
||||
|
||||
### 2. fiber-canvas.tsx
|
||||
|
||||
```tsx
|
||||
import * as THREE from "three/webgpu";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import type { ReconcilerRoot, RootState } from "@react-three/fiber";
|
||||
import {
|
||||
extend,
|
||||
createRoot,
|
||||
unmountComponentAtNode,
|
||||
events,
|
||||
} from "@react-three/fiber";
|
||||
import type { ViewProps } from "react-native";
|
||||
import { PixelRatio } from "react-native";
|
||||
import { Canvas, type CanvasRef } from "react-native-wgpu";
|
||||
|
||||
import {
|
||||
makeWebGPURenderer,
|
||||
ReactNativeCanvas,
|
||||
} from "@/lib/make-webgpu-renderer";
|
||||
|
||||
// Extend THREE namespace for R3F - add all components you use
|
||||
extend({
|
||||
AmbientLight: THREE.AmbientLight,
|
||||
DirectionalLight: THREE.DirectionalLight,
|
||||
PointLight: THREE.PointLight,
|
||||
SpotLight: THREE.SpotLight,
|
||||
Mesh: THREE.Mesh,
|
||||
Group: THREE.Group,
|
||||
Points: THREE.Points,
|
||||
BoxGeometry: THREE.BoxGeometry,
|
||||
SphereGeometry: THREE.SphereGeometry,
|
||||
CylinderGeometry: THREE.CylinderGeometry,
|
||||
ConeGeometry: THREE.ConeGeometry,
|
||||
DodecahedronGeometry: THREE.DodecahedronGeometry,
|
||||
BufferGeometry: THREE.BufferGeometry,
|
||||
BufferAttribute: THREE.BufferAttribute,
|
||||
MeshStandardMaterial: THREE.MeshStandardMaterial,
|
||||
MeshBasicMaterial: THREE.MeshBasicMaterial,
|
||||
PointsMaterial: THREE.PointsMaterial,
|
||||
PerspectiveCamera: THREE.PerspectiveCamera,
|
||||
Scene: THREE.Scene,
|
||||
});
|
||||
|
||||
interface FiberCanvasProps {
|
||||
children: React.ReactNode;
|
||||
style?: ViewProps["style"];
|
||||
camera?: THREE.PerspectiveCamera;
|
||||
scene?: THREE.Scene;
|
||||
}
|
||||
|
||||
export const FiberCanvas = ({
|
||||
children,
|
||||
style,
|
||||
scene,
|
||||
camera,
|
||||
}: FiberCanvasProps) => {
|
||||
const root = useRef<ReconcilerRoot<OffscreenCanvas>>(null!);
|
||||
const canvasRef = useRef<CanvasRef>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const context = canvasRef.current!.getContext("webgpu")!;
|
||||
const renderer = makeWebGPURenderer(context);
|
||||
|
||||
// @ts-expect-error - ReactNativeCanvas wraps native canvas
|
||||
const canvas = new ReactNativeCanvas(context.canvas) as HTMLCanvasElement;
|
||||
canvas.width = canvas.clientWidth * PixelRatio.get();
|
||||
canvas.height = canvas.clientHeight * PixelRatio.get();
|
||||
const size = {
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: canvas.clientWidth,
|
||||
height: canvas.clientHeight,
|
||||
};
|
||||
|
||||
if (!root.current) {
|
||||
root.current = createRoot(canvas);
|
||||
}
|
||||
root.current.configure({
|
||||
size,
|
||||
events,
|
||||
scene,
|
||||
camera,
|
||||
gl: renderer,
|
||||
frameloop: "always",
|
||||
dpr: 1,
|
||||
onCreated: async (state: RootState) => {
|
||||
// @ts-expect-error - WebGPU renderer has init method
|
||||
await state.gl.init();
|
||||
const renderFrame = state.gl.render.bind(state.gl);
|
||||
state.gl.render = (s: THREE.Scene, c: THREE.Camera) => {
|
||||
renderFrame(s, c);
|
||||
context?.present();
|
||||
};
|
||||
},
|
||||
});
|
||||
root.current.render(children);
|
||||
return () => {
|
||||
if (canvas != null) {
|
||||
unmountComponentAtNode(canvas!);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return <Canvas ref={canvasRef} style={style} />;
|
||||
};
|
||||
```
|
||||
|
||||
## Basic 3D Scene
|
||||
|
||||
```tsx
|
||||
import * as THREE from "three/webgpu";
|
||||
import { View } from "react-native";
|
||||
import { useRef } from "react";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { FiberCanvas } from "@/lib/fiber-canvas";
|
||||
|
||||
function RotatingBox() {
|
||||
const ref = useRef<THREE.Mesh>(null!);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
ref.current.rotation.x += delta;
|
||||
ref.current.rotation.y += delta * 0.5;
|
||||
});
|
||||
|
||||
return (
|
||||
<mesh ref={ref}>
|
||||
<boxGeometry args={[1, 1, 1]} />
|
||||
<meshStandardMaterial color="hotpink" />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
function Scene() {
|
||||
const { camera } = useThree();
|
||||
|
||||
useEffect(() => {
|
||||
camera.position.set(0, 2, 5);
|
||||
camera.lookAt(0, 0, 0);
|
||||
}, [camera]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ambientLight intensity={0.5} />
|
||||
<directionalLight position={[10, 10, 5]} intensity={1} />
|
||||
<RotatingBox />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<FiberCanvas style={{ flex: 1 }}>
|
||||
<Scene />
|
||||
</FiberCanvas>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Lazy Loading (Recommended)
|
||||
|
||||
Use React.lazy to code-split Three.js for better loading:
|
||||
|
||||
```tsx
|
||||
import React, { Suspense } from "react";
|
||||
import { ActivityIndicator, View } from "react-native";
|
||||
|
||||
const Scene = React.lazy(() => import("@/components/scene"));
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<Suspense fallback={<ActivityIndicator size="large" />}>
|
||||
<Scene />
|
||||
</Suspense>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Common Geometries
|
||||
|
||||
```tsx
|
||||
// Box
|
||||
<mesh>
|
||||
<boxGeometry args={[width, height, depth]} />
|
||||
<meshStandardMaterial color="red" />
|
||||
</mesh>
|
||||
|
||||
// Sphere
|
||||
<mesh>
|
||||
<sphereGeometry args={[radius, widthSegments, heightSegments]} />
|
||||
<meshStandardMaterial color="blue" />
|
||||
</mesh>
|
||||
|
||||
// Cylinder
|
||||
<mesh>
|
||||
<cylinderGeometry args={[radiusTop, radiusBottom, height, segments]} />
|
||||
<meshStandardMaterial color="green" />
|
||||
</mesh>
|
||||
|
||||
// Cone
|
||||
<mesh>
|
||||
<coneGeometry args={[radius, height, segments]} />
|
||||
<meshStandardMaterial color="yellow" />
|
||||
</mesh>
|
||||
```
|
||||
|
||||
## Lighting
|
||||
|
||||
```tsx
|
||||
// Ambient (uniform light everywhere)
|
||||
<ambientLight intensity={0.5} />
|
||||
|
||||
// Directional (sun-like)
|
||||
<directionalLight position={[10, 10, 5]} intensity={1} />
|
||||
|
||||
// Point (light bulb)
|
||||
<pointLight position={[0, 5, 0]} intensity={2} distance={10} />
|
||||
|
||||
// Spot (flashlight)
|
||||
<spotLight position={[0, 10, 0]} angle={0.3} penumbra={1} intensity={2} />
|
||||
```
|
||||
|
||||
## Animation with useFrame
|
||||
|
||||
```tsx
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { useRef } from "react";
|
||||
import * as THREE from "three/webgpu";
|
||||
|
||||
function AnimatedMesh() {
|
||||
const ref = useRef<THREE.Mesh>(null!);
|
||||
|
||||
// Runs every frame - delta is time since last frame
|
||||
useFrame((state, delta) => {
|
||||
// Rotate
|
||||
ref.current.rotation.y += delta;
|
||||
|
||||
// Oscillate position
|
||||
ref.current.position.y = Math.sin(state.clock.elapsedTime) * 2;
|
||||
});
|
||||
|
||||
return (
|
||||
<mesh ref={ref}>
|
||||
<boxGeometry />
|
||||
<meshStandardMaterial color="orange" />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Particle Systems
|
||||
|
||||
```tsx
|
||||
import * as THREE from "three/webgpu";
|
||||
import { useRef, useEffect } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
|
||||
function Particles({ count = 500 }) {
|
||||
const ref = useRef<THREE.Points>(null!);
|
||||
const positions = useRef<Float32Array>(new Float32Array(count * 3));
|
||||
|
||||
useEffect(() => {
|
||||
for (let i = 0; i < count; i++) {
|
||||
positions.current[i * 3] = (Math.random() - 0.5) * 50;
|
||||
positions.current[i * 3 + 1] = (Math.random() - 0.5) * 50;
|
||||
positions.current[i * 3 + 2] = (Math.random() - 0.5) * 50;
|
||||
}
|
||||
}, [count]);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
// Animate particles
|
||||
for (let i = 0; i < count; i++) {
|
||||
positions.current[i * 3 + 1] -= delta * 2;
|
||||
if (positions.current[i * 3 + 1] < -25) {
|
||||
positions.current[i * 3 + 1] = 25;
|
||||
}
|
||||
}
|
||||
ref.current.geometry.attributes.position.needsUpdate = true;
|
||||
});
|
||||
|
||||
return (
|
||||
<points ref={ref}>
|
||||
<bufferGeometry>
|
||||
<bufferAttribute
|
||||
attach="attributes-position"
|
||||
args={[positions.current, 3]}
|
||||
/>
|
||||
</bufferGeometry>
|
||||
<pointsMaterial color="#ffffff" size={0.2} sizeAttenuation />
|
||||
</points>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Touch Controls (Orbit)
|
||||
|
||||
See the full `orbit-controls.tsx` implementation in the lib files. Usage:
|
||||
|
||||
```tsx
|
||||
import { View } from "react-native";
|
||||
import { FiberCanvas } from "@/lib/fiber-canvas";
|
||||
import useControls from "@/lib/orbit-controls";
|
||||
|
||||
function Scene() {
|
||||
const [OrbitControls, events] = useControls();
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }} {...events}>
|
||||
<FiberCanvas style={{ flex: 1 }}>
|
||||
<OrbitControls />
|
||||
{/* Your 3D content */}
|
||||
</FiberCanvas>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Common Issues & Solutions
|
||||
|
||||
### 1. "X is not part of the THREE namespace"
|
||||
|
||||
**Problem:** Error like `AmbientLight is not part of the THREE namespace`
|
||||
|
||||
**Solution:** Add the missing component to the `extend()` call in fiber-canvas.tsx:
|
||||
|
||||
```tsx
|
||||
extend({
|
||||
AmbientLight: THREE.AmbientLight,
|
||||
// Add other missing components...
|
||||
});
|
||||
```
|
||||
|
||||
### 2. TypeScript Errors with Three.js
|
||||
|
||||
**Problem:** Type mismatches between three.js and R3F
|
||||
|
||||
**Solution:** Use `@ts-expect-error` comments where needed:
|
||||
|
||||
```tsx
|
||||
// @ts-expect-error - WebGPU renderer types don't match
|
||||
await state.gl.init();
|
||||
```
|
||||
|
||||
### 3. Blank Screen
|
||||
|
||||
**Problem:** Canvas renders but nothing visible
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Ensure camera is positioned correctly and looking at scene
|
||||
2. Add lighting (objects are black without light)
|
||||
3. Check that `extend()` includes all components used
|
||||
|
||||
### 4. Performance Issues
|
||||
|
||||
**Problem:** Low frame rate or stuttering
|
||||
|
||||
**Solution:**
|
||||
|
||||
- Reduce polygon count in geometries
|
||||
- Use `useMemo` for static data
|
||||
- Limit particle count
|
||||
- Use `instancedMesh` for many identical objects
|
||||
|
||||
### 5. Peer Dependency Errors
|
||||
|
||||
**Problem:** npm install fails with ERESOLVE
|
||||
|
||||
**Solution:** Use `--legacy-peer-deps`:
|
||||
|
||||
```bash
|
||||
npm install <packages> --legacy-peer-deps
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
WebGPU requires a custom build:
|
||||
|
||||
```bash
|
||||
npx expo prebuild
|
||||
npx expo run:ios
|
||||
```
|
||||
|
||||
**Note:** WebGPU does NOT work in Expo Go.
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ └── index.tsx # Entry point with lazy loading
|
||||
├── components/
|
||||
│ ├── scene.tsx # Main 3D scene
|
||||
│ └── game.tsx # Game logic
|
||||
└── lib/
|
||||
├── fiber-canvas.tsx # R3F canvas wrapper
|
||||
├── make-webgpu-renderer.ts # WebGPU renderer
|
||||
└── orbit-controls.tsx # Touch controls
|
||||
```
|
||||
|
||||
## Decision Tree
|
||||
|
||||
```
|
||||
Need 3D graphics?
|
||||
├── Simple shapes → mesh + geometry + material
|
||||
├── Animated objects → useFrame + refs
|
||||
├── Many objects → instancedMesh
|
||||
├── Particles → Points + BufferGeometry
|
||||
│
|
||||
Need interaction?
|
||||
├── Orbit camera → useControls hook
|
||||
├── Touch objects → onClick on mesh
|
||||
├── Gestures → react-native-gesture-handler
|
||||
│
|
||||
Performance critical?
|
||||
├── Static geometry → useMemo
|
||||
├── Many instances → InstancedMesh
|
||||
└── Complex scenes → LOD (Level of Detail)
|
||||
```
|
||||
|
||||
## Example: Complete Game Scene
|
||||
|
||||
```tsx
|
||||
import * as THREE from "three/webgpu";
|
||||
import { View, Text, Pressable } from "react-native";
|
||||
import { useRef, useState, useCallback } from "react";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { FiberCanvas } from "@/lib/fiber-canvas";
|
||||
|
||||
function Player({ position }: { position: THREE.Vector3 }) {
|
||||
const ref = useRef<THREE.Mesh>(null!);
|
||||
|
||||
useFrame(() => {
|
||||
ref.current.position.copy(position);
|
||||
});
|
||||
|
||||
return (
|
||||
<mesh ref={ref}>
|
||||
<coneGeometry args={[0.5, 1, 8]} />
|
||||
<meshStandardMaterial color="#00ffff" />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
function GameScene({ playerX }: { playerX: number }) {
|
||||
const { camera } = useThree();
|
||||
const playerPos = useRef(new THREE.Vector3(0, 0, 0));
|
||||
|
||||
playerPos.current.x = playerX;
|
||||
|
||||
useEffect(() => {
|
||||
camera.position.set(0, 10, 15);
|
||||
camera.lookAt(0, 0, 0);
|
||||
}, [camera]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ambientLight intensity={0.5} />
|
||||
<directionalLight position={[5, 10, 5]} />
|
||||
<Player position={playerPos.current} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Game() {
|
||||
const [playerX, setPlayerX] = useState(0);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: "#000" }}>
|
||||
<FiberCanvas style={{ flex: 1 }}>
|
||||
<GameScene playerX={playerX} />
|
||||
</FiberCanvas>
|
||||
|
||||
<View style={{ position: "absolute", bottom: 40, flexDirection: "row" }}>
|
||||
<Pressable onPress={() => setPlayerX((x) => x - 1)}>
|
||||
<Text style={{ color: "#fff", fontSize: 32 }}>◀</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={() => setPlayerX((x) => x + 1)}>
|
||||
<Text style={{ color: "#fff", fontSize: 32 }}>▶</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,158 @@
|
||||
# Apple Zoom Transitions
|
||||
|
||||
Fluid zoom transitions for navigating between screens. iOS 18+, Expo SDK 55+, Stack navigator only.
|
||||
|
||||
```tsx
|
||||
import { Link } from "expo-router";
|
||||
```
|
||||
|
||||
## Basic Zoom
|
||||
|
||||
Use `withAppleZoom` on `Link.Trigger` to zoom the entire trigger element into the destination screen:
|
||||
|
||||
```tsx
|
||||
<Link href="/photo" asChild>
|
||||
<Link.Trigger withAppleZoom>
|
||||
<Pressable>
|
||||
<Image
|
||||
source={{ uri: "https://example.com/thumb.jpg" }}
|
||||
style={{ width: 120, height: 120, borderRadius: 12 }}
|
||||
/>
|
||||
</Pressable>
|
||||
</Link.Trigger>
|
||||
</Link>
|
||||
```
|
||||
|
||||
## Targeted Zoom with `Link.AppleZoom`
|
||||
|
||||
Wrap only the element that should animate. Siblings outside `Link.AppleZoom` are not part of the transition:
|
||||
|
||||
```tsx
|
||||
<Link href="/photo" asChild>
|
||||
<Link.Trigger>
|
||||
<Pressable style={{ alignItems: "center" }}>
|
||||
<Link.AppleZoom>
|
||||
<Image
|
||||
source={{ uri: "https://example.com/thumb.jpg" }}
|
||||
style={{ width: 200, aspectRatio: 4 / 3 }}
|
||||
/>
|
||||
</Link.AppleZoom>
|
||||
<Text>Caption text (not zoomed)</Text>
|
||||
</Pressable>
|
||||
</Link.Trigger>
|
||||
</Link>
|
||||
```
|
||||
|
||||
`Link.AppleZoom` accepts only a single child element.
|
||||
|
||||
## Destination Target
|
||||
|
||||
Use `Link.AppleZoomTarget` on the destination screen to align the zoom animation to a specific element:
|
||||
|
||||
```tsx
|
||||
// Destination screen (e.g., app/photo.tsx)
|
||||
import { Link } from "expo-router";
|
||||
|
||||
export default function PhotoScreen() {
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<Link.AppleZoomTarget>
|
||||
<Image
|
||||
source={{ uri: "https://example.com/full.jpg" }}
|
||||
style={{ width: "100%", aspectRatio: 4 / 3 }}
|
||||
/>
|
||||
</Link.AppleZoomTarget>
|
||||
<Text>Photo details below</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Without a target, the zoom animates to fill the entire destination screen.
|
||||
|
||||
## Custom Alignment Rectangle
|
||||
|
||||
For manual control over where the zoom lands on the destination, use `alignmentRect` instead of `Link.AppleZoomTarget`:
|
||||
|
||||
```tsx
|
||||
<Link.AppleZoom alignmentRect={{ x: 0, y: 0, width: 200, height: 300 }}>
|
||||
<Image source={{ uri: "https://example.com/thumb.jpg" }} />
|
||||
</Link.AppleZoom>
|
||||
```
|
||||
|
||||
Coordinates are in the destination screen's coordinate space. Prefer `Link.AppleZoomTarget` when possible — use `alignmentRect` only when the target element isn't available as a React component.
|
||||
|
||||
## Controlling Dismissal
|
||||
|
||||
Zoom screens support interactive dismissal gestures by default (pinch, swipe down when scrolled to top, swipe from leading edge). Use `usePreventZoomTransitionDismissal` on the destination screen to control this.
|
||||
|
||||
### Disable all dismissal gestures
|
||||
|
||||
```tsx
|
||||
import { usePreventZoomTransitionDismissal } from "expo-router";
|
||||
|
||||
export default function PhotoScreen() {
|
||||
usePreventZoomTransitionDismissal();
|
||||
return <Image source={{ uri: "https://example.com/full.jpg" }} />;
|
||||
}
|
||||
```
|
||||
|
||||
### Restrict dismissal to a specific area
|
||||
|
||||
Use `unstable_dismissalBoundsRect` to prevent conflicts with scrollable content:
|
||||
|
||||
```tsx
|
||||
usePreventZoomTransitionDismissal({
|
||||
unstable_dismissalBoundsRect: {
|
||||
minX: 0,
|
||||
minY: 0,
|
||||
maxX: 300,
|
||||
maxY: 300,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
This is useful when the destination contains a zoomable scroll view — the system gives that scroll view precedence over the dismiss gesture.
|
||||
|
||||
## Combining with Link.Preview
|
||||
|
||||
Zoom transitions work alongside long-press previews:
|
||||
|
||||
```tsx
|
||||
<Link href="/photo" asChild>
|
||||
<Link.Trigger withAppleZoom>
|
||||
<Pressable>
|
||||
<Image
|
||||
source={{ uri: "https://example.com/thumb.jpg" }}
|
||||
style={{ width: 120, height: 120 }}
|
||||
/>
|
||||
</Pressable>
|
||||
</Link.Trigger>
|
||||
<Link.Preview />
|
||||
</Link>
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
**Good use cases:**
|
||||
- Thumbnail → full image (gallery, profile photos)
|
||||
- Card → detail screen with similar visual content
|
||||
- Source and destination with similar aspect ratios
|
||||
|
||||
**Avoid:**
|
||||
- Skinny full-width list rows as zoom sources — the transition looks unnatural
|
||||
- Mismatched aspect ratios between source and destination without `alignmentRect`
|
||||
- Using zoom with sheets or popovers — only works in Stack navigator
|
||||
- Hiding the navigation bar — known issues with header visibility during transitions
|
||||
|
||||
**Tips:**
|
||||
- Always provide a close or back button — dismissal gestures are not discoverable
|
||||
- If the destination has a zoomable scroll view, use `unstable_dismissalBoundsRect` to avoid gesture conflicts
|
||||
- Source view doesn't need to match the tap target — only the `Link.AppleZoom` wrapped element animates
|
||||
- When source is unavailable (e.g., scrolled off screen), the transition zooms from the center of the screen
|
||||
|
||||
## References
|
||||
|
||||
- Expo Router Zoom Transitions: https://docs.expo.dev/router/advanced/zoom-transition/
|
||||
- Link.AppleZoom API: https://docs.expo.dev/versions/v55.0.0/sdk/router/#linkapplezoom
|
||||
- Apple UIKit Fluid Transitions: https://developer.apple.com/documentation/uikit/enhancing-your-app-with-fluid-transitions
|
||||
368
.opencode/skills/expo-api-routes/SKILL.md
Normal file
368
.opencode/skills/expo-api-routes/SKILL.md
Normal file
@@ -0,0 +1,368 @@
|
||||
---
|
||||
name: expo-api-routes
|
||||
description: Guidelines for creating API routes in Expo Router with EAS Hosting
|
||||
version: 1.0.0
|
||||
license: MIT
|
||||
---
|
||||
|
||||
## When to Use API Routes
|
||||
|
||||
Use API routes when you need:
|
||||
|
||||
- **Server-side secrets** — API keys, database credentials, or tokens that must never reach the client
|
||||
- **Database operations** — Direct database queries that shouldn't be exposed
|
||||
- **Third-party API proxies** — Hide API keys when calling external services (OpenAI, Stripe, etc.)
|
||||
- **Server-side validation** — Validate data before database writes
|
||||
- **Webhook endpoints** — Receive callbacks from services like Stripe or GitHub
|
||||
- **Rate limiting** — Control access at the server level
|
||||
- **Heavy computation** — Offload processing that would be slow on mobile
|
||||
|
||||
## When NOT to Use API Routes
|
||||
|
||||
Avoid API routes when:
|
||||
|
||||
- **Data is already public** — Use direct fetch to public APIs instead
|
||||
- **No secrets required** — Static data or client-safe operations
|
||||
- **Real-time updates needed** — Use WebSockets or services like Supabase Realtime
|
||||
- **Simple CRUD** — Consider Firebase, Supabase, or Convex for managed backends
|
||||
- **File uploads** — Use direct-to-storage uploads (S3 presigned URLs, Cloudflare R2)
|
||||
- **Authentication only** — Use Clerk, Auth0, or Firebase Auth instead
|
||||
|
||||
## File Structure
|
||||
|
||||
API routes live in the `app` directory with `+api.ts` suffix:
|
||||
|
||||
```
|
||||
app/
|
||||
api/
|
||||
hello+api.ts → GET /api/hello
|
||||
users+api.ts → /api/users
|
||||
users/[id]+api.ts → /api/users/:id
|
||||
(tabs)/
|
||||
index.tsx
|
||||
```
|
||||
|
||||
## Basic API Route
|
||||
|
||||
```ts
|
||||
// app/api/hello+api.ts
|
||||
export function GET(request: Request) {
|
||||
return Response.json({ message: "Hello from Expo!" });
|
||||
}
|
||||
```
|
||||
|
||||
## HTTP Methods
|
||||
|
||||
Export named functions for each HTTP method:
|
||||
|
||||
```ts
|
||||
// app/api/items+api.ts
|
||||
export function GET(request: Request) {
|
||||
return Response.json({ items: [] });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
return Response.json({ created: body }, { status: 201 });
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
const body = await request.json();
|
||||
return Response.json({ updated: body });
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
```
|
||||
|
||||
## Dynamic Routes
|
||||
|
||||
```ts
|
||||
// app/api/users/[id]+api.ts
|
||||
export function GET(request: Request, { id }: { id: string }) {
|
||||
return Response.json({ userId: id });
|
||||
}
|
||||
```
|
||||
|
||||
## Request Handling
|
||||
|
||||
### Query Parameters
|
||||
|
||||
```ts
|
||||
export function GET(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
const page = url.searchParams.get("page") ?? "1";
|
||||
const limit = url.searchParams.get("limit") ?? "10";
|
||||
|
||||
return Response.json({ page, limit });
|
||||
}
|
||||
```
|
||||
|
||||
### Headers
|
||||
|
||||
```ts
|
||||
export function GET(request: Request) {
|
||||
const auth = request.headers.get("Authorization");
|
||||
|
||||
if (!auth) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
return Response.json({ authenticated: true });
|
||||
}
|
||||
```
|
||||
|
||||
### JSON Body
|
||||
|
||||
```ts
|
||||
export async function POST(request: Request) {
|
||||
const { email, password } = await request.json();
|
||||
|
||||
if (!email || !password) {
|
||||
return Response.json({ error: "Missing fields" }, { status: 400 });
|
||||
}
|
||||
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Use `process.env` for server-side secrets:
|
||||
|
||||
```ts
|
||||
// app/api/ai+api.ts
|
||||
export async function POST(request: Request) {
|
||||
const { prompt } = await request.json();
|
||||
|
||||
const response = await fetch("https://api.openai.com/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "gpt-4",
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
return Response.json(data);
|
||||
}
|
||||
```
|
||||
|
||||
Set environment variables:
|
||||
|
||||
- **Local**: Create `.env` file (never commit)
|
||||
- **EAS Hosting**: Use `eas env:create` or Expo dashboard
|
||||
|
||||
## CORS Headers
|
||||
|
||||
Add CORS for web clients:
|
||||
|
||||
```ts
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
};
|
||||
|
||||
export function OPTIONS() {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
export function GET() {
|
||||
return Response.json({ data: "value" }, { headers: corsHeaders });
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```ts
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
// Process...
|
||||
return Response.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("API error:", error);
|
||||
return Response.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Locally
|
||||
|
||||
Start the development server with API routes:
|
||||
|
||||
```bash
|
||||
npx expo serve
|
||||
```
|
||||
|
||||
This starts a local server at `http://localhost:8081` with full API route support.
|
||||
|
||||
Test with curl:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8081/api/hello
|
||||
curl -X POST http://localhost:8081/api/users -H "Content-Type: application/json" -d '{"name":"Test"}'
|
||||
```
|
||||
|
||||
## Deployment to EAS Hosting
|
||||
|
||||
### Prerequisites
|
||||
|
||||
```bash
|
||||
npm install -g eas-cli
|
||||
eas login
|
||||
```
|
||||
|
||||
### Deploy
|
||||
|
||||
```bash
|
||||
eas deploy
|
||||
```
|
||||
|
||||
This builds and deploys your API routes to EAS Hosting (Cloudflare Workers).
|
||||
|
||||
### Environment Variables for Production
|
||||
|
||||
```bash
|
||||
# Create a secret
|
||||
eas env:create --name OPENAI_API_KEY --value sk-xxx --environment production
|
||||
|
||||
# Or use the Expo dashboard
|
||||
```
|
||||
|
||||
### Custom Domain
|
||||
|
||||
Configure in `eas.json` or Expo dashboard.
|
||||
|
||||
## EAS Hosting Runtime (Cloudflare Workers)
|
||||
|
||||
API routes run on Cloudflare Workers. Key limitations:
|
||||
|
||||
### Missing/Limited APIs
|
||||
|
||||
- **No Node.js filesystem** — `fs` module unavailable
|
||||
- **No native Node modules** — Use Web APIs or polyfills
|
||||
- **Limited execution time** — 30 second timeout for CPU-intensive tasks
|
||||
- **No persistent connections** — WebSockets require Durable Objects
|
||||
- **fetch is available** — Use standard fetch for HTTP requests
|
||||
|
||||
### Use Web APIs Instead
|
||||
|
||||
```ts
|
||||
// Use Web Crypto instead of Node crypto
|
||||
const hash = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
new TextEncoder().encode("data")
|
||||
);
|
||||
|
||||
// Use fetch instead of node-fetch
|
||||
const response = await fetch("https://api.example.com");
|
||||
|
||||
// Use Response/Request (already available)
|
||||
return new Response(JSON.stringify(data), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
```
|
||||
|
||||
### Database Options
|
||||
|
||||
Since filesystem is unavailable, use cloud databases:
|
||||
|
||||
- **Cloudflare D1** — SQLite at the edge
|
||||
- **Turso** — Distributed SQLite
|
||||
- **PlanetScale** — Serverless MySQL
|
||||
- **Supabase** — Postgres with REST API
|
||||
- **Neon** — Serverless Postgres
|
||||
|
||||
Example with Turso:
|
||||
|
||||
```ts
|
||||
// app/api/users+api.ts
|
||||
import { createClient } from "@libsql/client/web";
|
||||
|
||||
const db = createClient({
|
||||
url: process.env.TURSO_URL!,
|
||||
authToken: process.env.TURSO_AUTH_TOKEN!,
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
const result = await db.execute("SELECT * FROM users");
|
||||
return Response.json(result.rows);
|
||||
}
|
||||
```
|
||||
|
||||
## Calling API Routes from Client
|
||||
|
||||
```ts
|
||||
// From React Native components
|
||||
const response = await fetch("/api/hello");
|
||||
const data = await response.json();
|
||||
|
||||
// With body
|
||||
const response = await fetch("/api/users", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: "John" }),
|
||||
});
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Authentication Middleware
|
||||
|
||||
```ts
|
||||
// utils/auth.ts
|
||||
export async function requireAuth(request: Request) {
|
||||
const token = request.headers.get("Authorization")?.replace("Bearer ", "");
|
||||
|
||||
if (!token) {
|
||||
throw new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Verify token...
|
||||
return { userId: "123" };
|
||||
}
|
||||
|
||||
// app/api/protected+api.ts
|
||||
import { requireAuth } from "../../utils/auth";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { userId } = await requireAuth(request);
|
||||
return Response.json({ userId });
|
||||
}
|
||||
```
|
||||
|
||||
### Proxy External API
|
||||
|
||||
```ts
|
||||
// app/api/weather+api.ts
|
||||
export async function GET(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
const city = url.searchParams.get("city");
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.weather.com/v1/current?city=${city}&key=${process.env.WEATHER_API_KEY}`
|
||||
);
|
||||
|
||||
return Response.json(await response.json());
|
||||
}
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- NEVER expose API keys or secrets in client code
|
||||
- ALWAYS validate and sanitize user input
|
||||
- Use proper HTTP status codes (200, 201, 400, 401, 404, 500)
|
||||
- Handle errors gracefully with try/catch
|
||||
- Keep API routes focused — one responsibility per endpoint
|
||||
- Use TypeScript for type safety
|
||||
- Log errors server-side for debugging
|
||||
92
.opencode/skills/expo-cicd-workflows/SKILL.md
Normal file
92
.opencode/skills/expo-cicd-workflows/SKILL.md
Normal file
@@ -0,0 +1,92 @@
|
||||
---
|
||||
name: expo-cicd-workflows
|
||||
description: Helps understand and write EAS workflow YAML files for Expo projects. Use this skill when the user asks about CI/CD or workflows in an Expo or EAS context, mentions .eas/workflows/, or wants help with EAS build pipelines or deployment automation.
|
||||
allowed-tools: "Read,Write,Bash(node:*)"
|
||||
version: 1.0.0
|
||||
license: MIT License
|
||||
---
|
||||
|
||||
# EAS Workflows Skill
|
||||
|
||||
Help developers write and edit EAS CI/CD workflow YAML files.
|
||||
|
||||
## Reference Documentation
|
||||
|
||||
Fetch these resources before generating or validating workflow files. Use the fetch script (implemented using Node.js) in this skill's `scripts/` directory; it caches responses using ETags for efficiency:
|
||||
|
||||
```bash
|
||||
# Fetch resources
|
||||
node {baseDir}/scripts/fetch.js <url>
|
||||
```
|
||||
|
||||
1. **JSON Schema** — https://api.expo.dev/v2/workflows/schema
|
||||
- It is NECESSARY to fetch this schema
|
||||
- Source of truth for validation
|
||||
- All job types and their required/optional parameters
|
||||
- Trigger types and configurations
|
||||
- Runner types, VM images, and all enums
|
||||
|
||||
2. **Syntax Documentation** — https://raw.githubusercontent.com/expo/expo/refs/heads/main/docs/pages/eas/workflows/syntax.mdx
|
||||
- Overview of workflow YAML syntax
|
||||
- Examples and English explanations
|
||||
- Expression syntax and contexts
|
||||
|
||||
3. **Pre-packaged Jobs** — https://raw.githubusercontent.com/expo/expo/refs/heads/main/docs/pages/eas/workflows/pre-packaged-jobs.mdx
|
||||
- Documentation for supported pre-packaged job types
|
||||
- Job-specific parameters and outputs
|
||||
|
||||
Do not rely on memorized values; these resources evolve as new features are added.
|
||||
|
||||
## Workflow File Location
|
||||
|
||||
Workflows live in `.eas/workflows/*.yml` (or `.yaml`).
|
||||
|
||||
## Top-Level Structure
|
||||
|
||||
A workflow file has these top-level keys:
|
||||
|
||||
- `name` — Display name for the workflow
|
||||
- `on` — Triggers that start the workflow (at least one required)
|
||||
- `jobs` — Job definitions (required)
|
||||
- `defaults` — Shared defaults for all jobs
|
||||
- `concurrency` — Control parallel workflow runs
|
||||
|
||||
Consult the schema for the full specification of each section.
|
||||
|
||||
## Expressions
|
||||
|
||||
Use `${{ }}` syntax for dynamic values. The schema defines available contexts:
|
||||
|
||||
- `github.*` — GitHub repository and event information
|
||||
- `inputs.*` — Values from `workflow_dispatch` inputs
|
||||
- `needs.*` — Outputs and status from dependent jobs
|
||||
- `jobs.*` — Job outputs (alternative syntax)
|
||||
- `steps.*` — Step outputs within custom jobs
|
||||
- `workflow.*` — Workflow metadata
|
||||
|
||||
## Generating Workflows
|
||||
|
||||
When generating or editing workflows:
|
||||
|
||||
1. Fetch the schema to get current job types, parameters, and allowed values
|
||||
2. Validate that required fields are present for each job type
|
||||
3. Verify job references in `needs` and `after` exist in the workflow
|
||||
4. Check that expressions reference valid contexts and outputs
|
||||
5. Ensure `if` conditions respect the schema's length constraints
|
||||
|
||||
## Validation
|
||||
|
||||
After generating or editing a workflow file, validate it against the schema:
|
||||
|
||||
```sh
|
||||
# Install dependencies if missing
|
||||
[ -d "{baseDir}/scripts/node_modules" ] || npm install --prefix {baseDir}/scripts
|
||||
|
||||
node {baseDir}/scripts/validate.js <workflow.yml> [workflow2.yml ...]
|
||||
```
|
||||
|
||||
The validator fetches the latest schema and checks the YAML structure. Fix any reported errors before considering the workflow complete.
|
||||
|
||||
## Answering Questions
|
||||
|
||||
When users ask about available options (job types, triggers, runner types, etc.), fetch the schema and derive the answer from it rather than relying on potentially outdated information.
|
||||
109
.opencode/skills/expo-cicd-workflows/scripts/fetch.js
Normal file
109
.opencode/skills/expo-cicd-workflows/scripts/fetch.js
Normal file
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
const CACHE_DIRECTORY = resolve(import.meta.dirname, '.cache');
|
||||
const DEFAULT_TTL_SECONDS = 15 * 60; // 15 minutes
|
||||
|
||||
export async function fetchCached(url) {
|
||||
await mkdir(CACHE_DIRECTORY, { recursive: true });
|
||||
|
||||
const cacheFile = resolve(CACHE_DIRECTORY, hashUrl(url) + '.json');
|
||||
const cached = await loadCacheEntry(cacheFile);
|
||||
if (cached && cached.expires > Math.floor(Date.now() / 1000)) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
// Make request, with conditional If-None-Match if we have an ETag.
|
||||
// Cache-Control: max-age=0 overrides Node's default 'no-cache' to allow 304 responses.
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Cache-Control': 'max-age=0',
|
||||
...(cached?.etag && { 'If-None-Match': cached.etag }),
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 304 && cached) {
|
||||
// Refresh expiration and return cached data
|
||||
const entry = { ...cached, expires: getExpires(response.headers) };
|
||||
await saveCacheEntry(cacheFile, entry);
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const etag = response.headers.get('etag');
|
||||
const data = await response.text();
|
||||
const expires = getExpires(response.headers);
|
||||
|
||||
await saveCacheEntry(cacheFile, { url, etag, expires, data });
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function hashUrl(url) {
|
||||
return createHash('sha256').update(url).digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
async function loadCacheEntry(cacheFile) {
|
||||
try {
|
||||
return JSON.parse(await readFile(cacheFile, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCacheEntry(cacheFile, entry) {
|
||||
await writeFile(cacheFile, JSON.stringify(entry, null, 2));
|
||||
}
|
||||
|
||||
function getExpires(headers) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Prefer Cache-Control: max-age
|
||||
const maxAgeSeconds = parseMaxAge(headers.get('cache-control'));
|
||||
if (maxAgeSeconds != null) {
|
||||
return now + maxAgeSeconds;
|
||||
}
|
||||
|
||||
// Fall back to Expires header
|
||||
const expires = headers.get('expires');
|
||||
if (expires) {
|
||||
const expiresTime = Date.parse(expires);
|
||||
if (!Number.isNaN(expiresTime)) {
|
||||
return Math.floor(expiresTime / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// Default TTL
|
||||
return now + DEFAULT_TTL_SECONDS;
|
||||
}
|
||||
|
||||
function parseMaxAge(cacheControl) {
|
||||
if (!cacheControl) {
|
||||
return null;
|
||||
}
|
||||
const match = cacheControl.match(/max-age=(\d+)/i);
|
||||
return match ? parseInt(match[1], 10) : null;
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const url = process.argv[2];
|
||||
|
||||
if (!url || url === '--help' || url === '-h') {
|
||||
console.log(`Usage: fetch <url>
|
||||
|
||||
Fetches a URL with HTTP caching (ETags + Cache-Control/Expires).
|
||||
Default TTL: ${DEFAULT_TTL_SECONDS / 60} minutes.
|
||||
Cache is stored in: ${CACHE_DIRECTORY}/`);
|
||||
process.exit(url ? 0 : 1);
|
||||
}
|
||||
|
||||
const data = await fetchCached(url);
|
||||
console.log(data);
|
||||
}
|
||||
84
.opencode/skills/expo-cicd-workflows/scripts/validate.js
Normal file
84
.opencode/skills/expo-cicd-workflows/scripts/validate.js
Normal file
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
import Ajv2020 from 'ajv/dist/2020.js';
|
||||
import addFormats from 'ajv-formats';
|
||||
import yaml from 'js-yaml';
|
||||
|
||||
import { fetchCached } from './fetch.js';
|
||||
|
||||
const SCHEMA_URL = 'https://api.expo.dev/v2/workflows/schema';
|
||||
|
||||
async function fetchSchema() {
|
||||
const data = await fetchCached(SCHEMA_URL);
|
||||
const body = JSON.parse(data);
|
||||
return body.data;
|
||||
}
|
||||
|
||||
function createValidator(schema) {
|
||||
const ajv = new Ajv2020({ allErrors: true, strict: true });
|
||||
addFormats(ajv);
|
||||
return ajv.compile(schema);
|
||||
}
|
||||
|
||||
async function validateFile(validator, filePath) {
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
|
||||
let doc;
|
||||
try {
|
||||
doc = yaml.load(content);
|
||||
} catch (e) {
|
||||
return { valid: false, error: `YAML parse error: ${e.message}` };
|
||||
}
|
||||
|
||||
const valid = validator(doc);
|
||||
if (!valid) {
|
||||
return { valid: false, error: formatErrors(validator.errors) };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
function formatErrors(errors) {
|
||||
return errors
|
||||
.map((error) => {
|
||||
const path = error.instancePath || '(root)';
|
||||
const allowed = error.params?.allowedValues?.join(', ');
|
||||
return ` ${path}: ${error.message}${allowed ? ` (allowed: ${allowed})` : ''}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const args = process.argv.slice(2);
|
||||
const files = args.filter((a) => !a.startsWith('-'));
|
||||
|
||||
if (files.length === 0 || args.includes('--help') || args.includes('-h')) {
|
||||
console.log(`Usage: validate <workflow.yml> [workflow2.yml ...]
|
||||
|
||||
Validates EAS workflow YAML files against the official schema.`);
|
||||
process.exit(files.length === 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
const schema = await fetchSchema();
|
||||
const validator = createValidator(schema);
|
||||
|
||||
let hasErrors = false;
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = resolve(process.cwd(), file);
|
||||
const result = await validateFile(validator, filePath);
|
||||
|
||||
if (result.valid) {
|
||||
console.log(`✓ ${file}`);
|
||||
} else {
|
||||
console.error(`✗ ${file}\n${result.error}`);
|
||||
hasErrors = true;
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(hasErrors ? 1 : 0);
|
||||
}
|
||||
190
.opencode/skills/expo-deployment/SKILL.md
Normal file
190
.opencode/skills/expo-deployment/SKILL.md
Normal file
@@ -0,0 +1,190 @@
|
||||
---
|
||||
name: expo-deployment
|
||||
description: Deploying Expo apps to iOS App Store, Android Play Store, web hosting, and API routes
|
||||
version: 1.0.0
|
||||
license: MIT
|
||||
---
|
||||
|
||||
# Deployment
|
||||
|
||||
This skill covers deploying Expo applications across all platforms using EAS (Expo Application Services).
|
||||
|
||||
## References
|
||||
|
||||
Consult these resources as needed:
|
||||
|
||||
- ./references/workflows.md -- CI/CD workflows for automated deployments and PR previews
|
||||
- ./references/testflight.md -- Submitting iOS builds to TestFlight for beta testing
|
||||
- ./references/app-store-metadata.md -- Managing App Store metadata and ASO optimization
|
||||
- ./references/play-store.md -- Submitting Android builds to Google Play Store
|
||||
- ./references/ios-app-store.md -- iOS App Store submission and review process
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Install EAS CLI
|
||||
|
||||
```bash
|
||||
npm install -g eas-cli
|
||||
eas login
|
||||
```
|
||||
|
||||
### Initialize EAS
|
||||
|
||||
```bash
|
||||
npx eas-cli@latest init
|
||||
```
|
||||
|
||||
This creates `eas.json` with build profiles.
|
||||
|
||||
## Build Commands
|
||||
|
||||
### Production Builds
|
||||
|
||||
```bash
|
||||
# iOS App Store build
|
||||
npx eas-cli@latest build -p ios --profile production
|
||||
|
||||
# Android Play Store build
|
||||
npx eas-cli@latest build -p android --profile production
|
||||
|
||||
# Both platforms
|
||||
npx eas-cli@latest build --profile production
|
||||
```
|
||||
|
||||
### Submit to Stores
|
||||
|
||||
```bash
|
||||
# iOS: Build and submit to App Store Connect
|
||||
npx eas-cli@latest build -p ios --profile production --submit
|
||||
|
||||
# Android: Build and submit to Play Store
|
||||
npx eas-cli@latest build -p android --profile production --submit
|
||||
|
||||
# Shortcut for iOS TestFlight
|
||||
npx testflight
|
||||
```
|
||||
|
||||
## Web Deployment
|
||||
|
||||
Deploy web apps using EAS Hosting:
|
||||
|
||||
```bash
|
||||
# Deploy to production
|
||||
npx expo export -p web
|
||||
npx eas-cli@latest deploy --prod
|
||||
|
||||
# Deploy PR preview
|
||||
npx eas-cli@latest deploy
|
||||
```
|
||||
|
||||
## EAS Configuration
|
||||
|
||||
Standard `eas.json` for production deployments:
|
||||
|
||||
```json
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 16.0.1",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"production": {
|
||||
"autoIncrement": true,
|
||||
"ios": {
|
||||
"resourceClass": "m-medium"
|
||||
}
|
||||
},
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {
|
||||
"ios": {
|
||||
"appleId": "your@email.com",
|
||||
"ascAppId": "1234567890"
|
||||
},
|
||||
"android": {
|
||||
"serviceAccountKeyPath": "./google-service-account.json",
|
||||
"track": "internal"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Platform-Specific Guides
|
||||
|
||||
### iOS
|
||||
|
||||
- Use `npx testflight` for quick TestFlight submissions
|
||||
- Configure Apple credentials via `eas credentials`
|
||||
- See ./reference/testflight.md for credential setup
|
||||
- See ./reference/ios-app-store.md for App Store submission
|
||||
|
||||
### Android
|
||||
|
||||
- Set up Google Play Console service account
|
||||
- Configure tracks: internal → closed → open → production
|
||||
- See ./reference/play-store.md for detailed setup
|
||||
|
||||
### Web
|
||||
|
||||
- EAS Hosting provides preview URLs for PRs
|
||||
- Production deploys to your custom domain
|
||||
- See ./reference/workflows.md for CI/CD automation
|
||||
|
||||
## Automated Deployments
|
||||
|
||||
Use EAS Workflows for CI/CD:
|
||||
|
||||
```yaml
|
||||
# .eas/workflows/release.yml
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build-ios:
|
||||
type: build
|
||||
params:
|
||||
platform: ios
|
||||
profile: production
|
||||
|
||||
submit-ios:
|
||||
type: submit
|
||||
needs: [build-ios]
|
||||
params:
|
||||
platform: ios
|
||||
profile: production
|
||||
```
|
||||
|
||||
See ./reference/workflows.md for more workflow examples.
|
||||
|
||||
## Version Management
|
||||
|
||||
EAS manages version numbers automatically with `appVersionSource: "remote"`:
|
||||
|
||||
```bash
|
||||
# Check current versions
|
||||
eas build:version:get
|
||||
|
||||
# Manually set version
|
||||
eas build:version:set -p ios --build-number 42
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
```bash
|
||||
# List recent builds
|
||||
eas build:list
|
||||
|
||||
# Check build status
|
||||
eas build:view
|
||||
|
||||
# View submission status
|
||||
eas submit:list
|
||||
```
|
||||
@@ -0,0 +1,479 @@
|
||||
# App Store Metadata
|
||||
|
||||
Manage App Store metadata and optimize for ASO using EAS Metadata.
|
||||
|
||||
## What is EAS Metadata?
|
||||
|
||||
EAS Metadata automates App Store presence management from the command line using a `store.config.json` file instead of manually filling forms in App Store Connect. It includes built-in validation to catch common rejection pitfalls.
|
||||
|
||||
**Current Status:** Preview, Apple App Store only.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Pull Existing Metadata
|
||||
|
||||
If your app is already published, pull current metadata:
|
||||
|
||||
```bash
|
||||
eas metadata:pull
|
||||
```
|
||||
|
||||
This creates `store.config.json` with your current App Store configuration.
|
||||
|
||||
### Push Metadata Updates
|
||||
|
||||
After editing your config, push changes:
|
||||
|
||||
```bash
|
||||
eas metadata:push
|
||||
```
|
||||
|
||||
**Important:** You must submit a binary via `eas submit` before pushing metadata for new apps.
|
||||
|
||||
## Configuration File
|
||||
|
||||
Create `store.config.json` at your project root:
|
||||
|
||||
```json
|
||||
{
|
||||
"configVersion": 0,
|
||||
"apple": {
|
||||
"copyright": "2025 Your Company",
|
||||
"categories": ["UTILITIES", "PRODUCTIVITY"],
|
||||
"info": {
|
||||
"en-US": {
|
||||
"title": "App Name",
|
||||
"subtitle": "Your compelling tagline",
|
||||
"description": "Full app description...",
|
||||
"keywords": ["keyword1", "keyword2", "keyword3"],
|
||||
"releaseNotes": "What's new in this version...",
|
||||
"promoText": "Limited time offer!",
|
||||
"privacyPolicyUrl": "https://example.com/privacy",
|
||||
"supportUrl": "https://example.com/support",
|
||||
"marketingUrl": "https://example.com"
|
||||
}
|
||||
},
|
||||
"advisory": {
|
||||
"alcoholTobaccoOrDrugUseOrReferences": "NONE",
|
||||
"gamblingSimulated": "NONE",
|
||||
"medicalOrTreatmentInformation": "NONE",
|
||||
"profanityOrCrudeHumor": "NONE",
|
||||
"sexualContentGraphicAndNudity": "NONE",
|
||||
"sexualContentOrNudity": "NONE",
|
||||
"horrorOrFearThemes": "NONE",
|
||||
"matureOrSuggestiveThemes": "NONE",
|
||||
"violenceCartoonOrFantasy": "NONE",
|
||||
"violenceRealistic": "NONE",
|
||||
"violenceRealisticProlongedGraphicOrSadistic": "NONE",
|
||||
"contests": "NONE",
|
||||
"gambling": false,
|
||||
"unrestrictedWebAccess": false,
|
||||
"seventeenPlus": false
|
||||
},
|
||||
"release": {
|
||||
"automaticRelease": true,
|
||||
"phasedRelease": true
|
||||
},
|
||||
"review": {
|
||||
"firstName": "John",
|
||||
"lastName": "Doe",
|
||||
"email": "review@example.com",
|
||||
"phone": "+1 555-123-4567",
|
||||
"notes": "Demo account: test@example.com / password123"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## App Store Optimization (ASO)
|
||||
|
||||
### Title Optimization (30 characters max)
|
||||
|
||||
The title is the most important ranking factor. Include your brand name and 1-2 strongest keywords.
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Budgetly - Money Tracker"
|
||||
}
|
||||
```
|
||||
|
||||
**Best Practices:**
|
||||
|
||||
- Brand name first for recognition
|
||||
- Include highest-volume keyword
|
||||
- Avoid generic words like "app" or "the"
|
||||
- Title keywords boost rankings by ~10%
|
||||
|
||||
### Subtitle Optimization (30 characters max)
|
||||
|
||||
The subtitle appears below your title in search results. Use it for your unique value proposition.
|
||||
|
||||
```json
|
||||
{
|
||||
"subtitle": "Smart Expense & Budget Planner"
|
||||
}
|
||||
```
|
||||
|
||||
**Best Practices:**
|
||||
|
||||
- Don't duplicate keywords from title (Apple counts each word once)
|
||||
- Highlight your main differentiator
|
||||
- Include secondary high-value keywords
|
||||
- Focus on benefits, not features
|
||||
|
||||
### Keywords Field (100 characters max)
|
||||
|
||||
Hidden from users but crucial for discoverability. Use comma-separated keywords without spaces after commas.
|
||||
|
||||
```json
|
||||
{
|
||||
"keywords": [
|
||||
"finance,budget,expense,money,tracker,savings,bills,income,spending,wallet,personal,weekly,monthly"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Best Practices:**
|
||||
|
||||
- Use all 100 characters
|
||||
- Separate with commas only (no spaces)
|
||||
- No duplicates from title/subtitle
|
||||
- Include singular forms (Apple handles plurals)
|
||||
- Add synonyms and alternate spellings
|
||||
- Include competitor brand names (carefully)
|
||||
- Use digits instead of spelled numbers ("5" not "five")
|
||||
- Skip articles and prepositions
|
||||
|
||||
### Description Optimization
|
||||
|
||||
The iOS description is NOT indexed for search but critical for conversion. Focus on convincing users to download.
|
||||
|
||||
```json
|
||||
{
|
||||
"description": "Take control of your finances with Budgetly, the intuitive money management app trusted by over 1 million users.\n\nKEY FEATURES:\n• Smart budget tracking - Set limits and watch your progress\n• Expense categorization - Know exactly where your money goes\n• Bill reminders - Never miss a payment\n• Beautiful charts - Visualize your financial health\n• Bank sync - Connect 10,000+ institutions\n• Cloud backup - Your data, always safe\n\nWHY BUDGETLY?\nUnlike complex spreadsheets or basic calculators, Budgetly learns your spending habits and provides personalized insights. Our users save an average of $300/month within 3 months.\n\nPRIVACY FIRST\nYour financial data is encrypted end-to-end. We never sell your information.\n\nDownload Budgetly today and start your journey to financial freedom!"
|
||||
}
|
||||
```
|
||||
|
||||
**Best Practices:**
|
||||
|
||||
- Front-load the first 3 lines (visible before "more")
|
||||
- Use bullet points for features
|
||||
- Include social proof (user counts, ratings, awards)
|
||||
- Add a clear call-to-action
|
||||
- Mention privacy/security for sensitive apps
|
||||
- Update with each release
|
||||
|
||||
### Release Notes
|
||||
|
||||
Shown to existing users deciding whether to update.
|
||||
|
||||
```json
|
||||
{
|
||||
"releaseNotes": "Version 2.5 brings exciting improvements:\n\n• NEW: Dark mode support\n• NEW: Widget for home screen\n• IMPROVED: 50% faster sync\n• FIXED: Notification timing issues\n\nLove Budgetly? Please leave a review!"
|
||||
}
|
||||
```
|
||||
|
||||
### Promo Text (170 characters max)
|
||||
|
||||
Appears above description; can be updated without new binary. Great for time-sensitive promotions.
|
||||
|
||||
```json
|
||||
{
|
||||
"promoText": "🎉 New Year Special: Premium features free for 30 days! Start 2025 with better finances."
|
||||
}
|
||||
```
|
||||
|
||||
## Categories
|
||||
|
||||
Primary category is most important for browsing and rankings.
|
||||
|
||||
```json
|
||||
{
|
||||
"categories": ["FINANCE", "PRODUCTIVITY"]
|
||||
}
|
||||
```
|
||||
|
||||
**Available Categories:**
|
||||
|
||||
- BOOKS, BUSINESS, DEVELOPER_TOOLS, EDUCATION
|
||||
- ENTERTAINMENT, FINANCE, FOOD_AND_DRINK
|
||||
- GAMES (with subcategories), GRAPHICS_AND_DESIGN
|
||||
- HEALTH_AND_FITNESS, KIDS (age-gated)
|
||||
- LIFESTYLE, MAGAZINES_AND_NEWSPAPERS
|
||||
- MEDICAL, MUSIC, NAVIGATION, NEWS
|
||||
- PHOTO_AND_VIDEO, PRODUCTIVITY, REFERENCE
|
||||
- SHOPPING, SOCIAL_NETWORKING, SPORTS
|
||||
- STICKERS (with subcategories), TRAVEL
|
||||
- UTILITIES, WEATHER
|
||||
|
||||
## Localization
|
||||
|
||||
Localize metadata for each target market. Keywords should be researched per locale—direct translations often miss regional search terms.
|
||||
|
||||
```json
|
||||
{
|
||||
"info": {
|
||||
"en-US": {
|
||||
"title": "Budgetly - Money Tracker",
|
||||
"subtitle": "Smart Expense Planner",
|
||||
"keywords": ["budget,finance,money,expense,tracker"]
|
||||
},
|
||||
"es-ES": {
|
||||
"title": "Budgetly - Control de Gastos",
|
||||
"subtitle": "Planificador de Presupuesto",
|
||||
"keywords": ["presupuesto,finanzas,dinero,gastos,ahorro"]
|
||||
},
|
||||
"ja": {
|
||||
"title": "Budgetly - 家計簿アプリ",
|
||||
"subtitle": "簡単支出管理",
|
||||
"keywords": ["家計簿,支出,予算,節約,お金"]
|
||||
},
|
||||
"de-DE": {
|
||||
"title": "Budgetly - Haushaltsbuch",
|
||||
"subtitle": "Ausgaben Verwalten",
|
||||
"keywords": ["budget,finanzen,geld,ausgaben,sparen"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Supported Locales:**
|
||||
`ar-SA`, `ca`, `cs`, `da`, `de-DE`, `el`, `en-AU`, `en-CA`, `en-GB`, `en-US`, `es-ES`, `es-MX`, `fi`, `fr-CA`, `fr-FR`, `he`, `hi`, `hr`, `hu`, `id`, `it`, `ja`, `ko`, `ms`, `nl-NL`, `no`, `pl`, `pt-BR`, `pt-PT`, `ro`, `ru`, `sk`, `sv`, `th`, `tr`, `uk`, `vi`, `zh-Hans`, `zh-Hant`
|
||||
|
||||
## Dynamic Configuration
|
||||
|
||||
Use JavaScript for dynamic values like copyright year or fetched translations.
|
||||
|
||||
### Basic Dynamic Config
|
||||
|
||||
```js
|
||||
// store.config.js
|
||||
const baseConfig = require("./store.config.json");
|
||||
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
module.exports = {
|
||||
...baseConfig,
|
||||
apple: {
|
||||
...baseConfig.apple,
|
||||
copyright: `${year} Your Company, Inc.`,
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Async Configuration (External Localization)
|
||||
|
||||
```js
|
||||
// store.config.js
|
||||
module.exports = async () => {
|
||||
const baseConfig = require("./store.config.json");
|
||||
|
||||
// Fetch translations from CMS/localization service
|
||||
const translations = await fetch(
|
||||
"https://api.example.com/app-store-copy"
|
||||
).then((r) => r.json());
|
||||
|
||||
return {
|
||||
...baseConfig,
|
||||
apple: {
|
||||
...baseConfig.apple,
|
||||
info: translations,
|
||||
},
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### Environment-Based Config
|
||||
|
||||
```js
|
||||
// store.config.js
|
||||
const baseConfig = require("./store.config.json");
|
||||
|
||||
const isProduction = process.env.EAS_BUILD_PROFILE === "production";
|
||||
|
||||
module.exports = {
|
||||
...baseConfig,
|
||||
apple: {
|
||||
...baseConfig.apple,
|
||||
info: {
|
||||
"en-US": {
|
||||
...baseConfig.apple.info["en-US"],
|
||||
promoText: isProduction
|
||||
? "Download now and get started!"
|
||||
: "[BETA] Help us test new features!",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Update `eas.json` to use JS config:
|
||||
|
||||
```json
|
||||
{
|
||||
"cli": {
|
||||
"metadataPath": "./store.config.js"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Age Rating (Advisory)
|
||||
|
||||
Answer content questions honestly to get an appropriate age rating.
|
||||
|
||||
**Content Descriptors:**
|
||||
|
||||
- `NONE` - Content not present
|
||||
- `INFREQUENT_OR_MILD` - Occasional mild content
|
||||
- `FREQUENT_OR_INTENSE` - Regular or strong content
|
||||
|
||||
```json
|
||||
{
|
||||
"advisory": {
|
||||
"alcoholTobaccoOrDrugUseOrReferences": "NONE",
|
||||
"contests": "NONE",
|
||||
"gambling": false,
|
||||
"gamblingSimulated": "NONE",
|
||||
"horrorOrFearThemes": "NONE",
|
||||
"matureOrSuggestiveThemes": "NONE",
|
||||
"medicalOrTreatmentInformation": "NONE",
|
||||
"profanityOrCrudeHumor": "NONE",
|
||||
"sexualContentGraphicAndNudity": "NONE",
|
||||
"sexualContentOrNudity": "NONE",
|
||||
"unrestrictedWebAccess": false,
|
||||
"violenceCartoonOrFantasy": "NONE",
|
||||
"violenceRealistic": "NONE",
|
||||
"violenceRealisticProlongedGraphicOrSadistic": "NONE",
|
||||
"seventeenPlus": false,
|
||||
"kidsAgeBand": "NINE_TO_ELEVEN"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Kids Age Bands:** `FIVE_AND_UNDER`, `SIX_TO_EIGHT`, `NINE_TO_ELEVEN`
|
||||
|
||||
## Release Strategy
|
||||
|
||||
Control how your app rolls out to users.
|
||||
|
||||
```json
|
||||
{
|
||||
"release": {
|
||||
"automaticRelease": true,
|
||||
"phasedRelease": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
- `automaticRelease: true` - Release immediately upon approval
|
||||
- `automaticRelease: false` - Manual release after approval
|
||||
- `automaticRelease: "2025-02-01T10:00:00Z"` - Schedule release (RFC 3339)
|
||||
- `phasedRelease: true` - 7-day gradual rollout (1%, 2%, 5%, 10%, 20%, 50%, 100%)
|
||||
|
||||
## Review Information
|
||||
|
||||
Provide contact info and test credentials for the App Review team.
|
||||
|
||||
```json
|
||||
{
|
||||
"review": {
|
||||
"firstName": "Jane",
|
||||
"lastName": "Smith",
|
||||
"email": "app-review@company.com",
|
||||
"phone": "+1 (555) 123-4567",
|
||||
"demoUsername": "demo@example.com",
|
||||
"demoPassword": "ReviewDemo2025!",
|
||||
"notes": "To test premium features:\n1. Log in with demo credentials\n2. Navigate to Settings > Subscription\n3. Tap 'Restore Purchase' - sandbox purchase will be restored\n\nFor location features, allow location access when prompted."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## ASO Checklist
|
||||
|
||||
### Before Each Release
|
||||
|
||||
- [ ] Update keywords based on performance data
|
||||
- [ ] Refresh description with new features
|
||||
- [ ] Write compelling release notes
|
||||
- [ ] Update promo text if running campaigns
|
||||
- [ ] Verify all URLs are valid
|
||||
|
||||
### Monthly ASO Tasks
|
||||
|
||||
- [ ] Analyze keyword rankings
|
||||
- [ ] Research competitor keywords
|
||||
- [ ] Check conversion rates in App Analytics
|
||||
- [ ] Review user feedback for keyword ideas
|
||||
- [ ] A/B test screenshots in App Store Connect
|
||||
|
||||
### Keyword Research Tips
|
||||
|
||||
1. **Brainstorm features** - List all app capabilities
|
||||
2. **Mine reviews** - Find words users actually use
|
||||
3. **Analyze competitors** - Check their titles/subtitles
|
||||
4. **Use long-tail keywords** - Less competition, higher intent
|
||||
5. **Consider misspellings** - Common typos can drive traffic
|
||||
6. **Track seasonality** - Some keywords peak at certain times
|
||||
|
||||
### Metrics to Monitor
|
||||
|
||||
- **Impressions** - How often your app appears in search
|
||||
- **Product Page Views** - Users who tap to learn more
|
||||
- **Conversion Rate** - Views → Downloads
|
||||
- **Keyword Rankings** - Position for target keywords
|
||||
- **Category Ranking** - Position in your categories
|
||||
|
||||
## VS Code Integration
|
||||
|
||||
Install the [Expo Tools extension](https://marketplace.visualstudio.com/items?itemName=expo.vscode-expo-tools) for:
|
||||
|
||||
- Auto-complete for all schema properties
|
||||
- Inline validation and warnings
|
||||
- Quick fixes for common issues
|
||||
|
||||
## Common Issues
|
||||
|
||||
### "Binary not found"
|
||||
|
||||
Push a binary with `eas submit` before pushing metadata.
|
||||
|
||||
### "Invalid keywords"
|
||||
|
||||
- Check total length is ≤100 characters
|
||||
- Remove spaces after commas
|
||||
- Remove duplicate words
|
||||
|
||||
### "Description too long"
|
||||
|
||||
Description maximum is 4000 characters.
|
||||
|
||||
### Pull doesn't update JS config
|
||||
|
||||
`eas metadata:pull` creates a JSON file; import it into your JS config.
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
Automate metadata updates in your deployment pipeline:
|
||||
|
||||
```yaml
|
||||
# .eas/workflows/release.yml
|
||||
jobs:
|
||||
submit-and-metadata:
|
||||
steps:
|
||||
- name: Submit to App Store
|
||||
run: eas submit -p ios --latest
|
||||
|
||||
- name: Push Metadata
|
||||
run: eas metadata:push
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- Update metadata every 4-6 weeks for optimal ASO
|
||||
- 70% of App Store visitors use search to find apps
|
||||
- Apps with 4+ star ratings get featured more often
|
||||
- Localized apps see 128% more downloads per country
|
||||
- First 3 lines of description are most critical (shown before "more")
|
||||
- Use all 100 keyword characters—every character counts
|
||||
355
.opencode/skills/expo-deployment/references/ios-app-store.md
Normal file
355
.opencode/skills/expo-deployment/references/ios-app-store.md
Normal file
@@ -0,0 +1,355 @@
|
||||
# Submitting to iOS App Store
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Apple Developer Account** - Enroll at [developer.apple.com](https://developer.apple.com)
|
||||
2. **App Store Connect App** - Create your app record before first submission
|
||||
3. **Apple Credentials** - Configure via EAS or environment variables
|
||||
|
||||
## Credential Setup
|
||||
|
||||
### Using EAS Credentials
|
||||
|
||||
```bash
|
||||
eas credentials -p ios
|
||||
```
|
||||
|
||||
This interactive flow helps you:
|
||||
- Create or select a distribution certificate
|
||||
- Create or select a provisioning profile
|
||||
- Configure App Store Connect API key (recommended)
|
||||
|
||||
### App Store Connect API Key (Recommended)
|
||||
|
||||
API keys avoid 2FA prompts in CI/CD:
|
||||
|
||||
1. Go to App Store Connect → Users and Access → Keys
|
||||
2. Click "+" to create a new key
|
||||
3. Select "App Manager" role (minimum for submissions)
|
||||
4. Download the `.p8` key file
|
||||
|
||||
Configure in `eas.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"submit": {
|
||||
"production": {
|
||||
"ios": {
|
||||
"ascApiKeyPath": "./AuthKey_XXXXX.p8",
|
||||
"ascApiKeyIssuerId": "xxxxx-xxxx-xxxx-xxxx-xxxxx",
|
||||
"ascApiKeyId": "XXXXXXXXXX"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or use environment variables:
|
||||
|
||||
```bash
|
||||
EXPO_ASC_API_KEY_PATH=./AuthKey.p8
|
||||
EXPO_ASC_API_KEY_ISSUER_ID=xxxxx-xxxx-xxxx-xxxx-xxxxx
|
||||
EXPO_ASC_API_KEY_ID=XXXXXXXXXX
|
||||
```
|
||||
|
||||
### Apple ID Authentication (Alternative)
|
||||
|
||||
For manual submissions, you can use Apple ID:
|
||||
|
||||
```bash
|
||||
EXPO_APPLE_ID=your@email.com
|
||||
EXPO_APPLE_TEAM_ID=XXXXXXXXXX
|
||||
```
|
||||
|
||||
Note: Requires app-specific password for accounts with 2FA.
|
||||
|
||||
## Submission Commands
|
||||
|
||||
```bash
|
||||
# Build and submit to App Store Connect
|
||||
eas build -p ios --profile production --submit
|
||||
|
||||
# Submit latest build
|
||||
eas submit -p ios --latest
|
||||
|
||||
# Submit specific build
|
||||
eas submit -p ios --id BUILD_ID
|
||||
|
||||
# Quick TestFlight submission
|
||||
npx testflight
|
||||
```
|
||||
|
||||
## App Store Connect Configuration
|
||||
|
||||
### First-Time Setup
|
||||
|
||||
Before submitting, complete in App Store Connect:
|
||||
|
||||
1. **App Information**
|
||||
- Primary language
|
||||
- Bundle ID (must match `app.json`)
|
||||
- SKU (unique identifier)
|
||||
|
||||
2. **Pricing and Availability**
|
||||
- Price tier
|
||||
- Available countries
|
||||
|
||||
3. **App Privacy**
|
||||
- Privacy policy URL
|
||||
- Data collection declarations
|
||||
|
||||
4. **App Review Information**
|
||||
- Contact information
|
||||
- Demo account (if login required)
|
||||
- Notes for reviewers
|
||||
|
||||
### EAS Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 16.0.1",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"production": {
|
||||
"ios": {
|
||||
"resourceClass": "m-medium",
|
||||
"autoIncrement": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {
|
||||
"ios": {
|
||||
"appleId": "your@email.com",
|
||||
"ascAppId": "1234567890",
|
||||
"appleTeamId": "XXXXXXXXXX"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Find `ascAppId` in App Store Connect → App Information → Apple ID.
|
||||
|
||||
## TestFlight vs App Store
|
||||
|
||||
### TestFlight (Beta Testing)
|
||||
|
||||
- Builds go to TestFlight automatically after submission
|
||||
- Internal testers (up to 100) - immediate access
|
||||
- External testers (up to 10,000) - requires beta review
|
||||
- Builds expire after 90 days
|
||||
|
||||
### App Store (Production)
|
||||
|
||||
- Requires passing App Review
|
||||
- Submit for review from App Store Connect
|
||||
- Choose release timing (immediate, scheduled, manual)
|
||||
|
||||
## App Review Process
|
||||
|
||||
### What Reviewers Check
|
||||
|
||||
1. **Functionality** - App works as described
|
||||
2. **UI/UX** - Follows Human Interface Guidelines
|
||||
3. **Content** - Appropriate and accurate
|
||||
4. **Privacy** - Data handling matches declarations
|
||||
5. **Legal** - Complies with local laws
|
||||
|
||||
### Common Rejection Reasons
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| Crashes/bugs | Test thoroughly before submission |
|
||||
| Incomplete metadata | Fill all required fields |
|
||||
| Placeholder content | Remove "lorem ipsum" and test data |
|
||||
| Missing login credentials | Provide demo account |
|
||||
| Privacy policy missing | Add URL in App Store Connect |
|
||||
| Guideline 4.2 (minimum functionality) | Ensure app provides value |
|
||||
|
||||
### Expedited Review
|
||||
|
||||
Request expedited review for:
|
||||
- Critical bug fixes
|
||||
- Time-sensitive events
|
||||
- Security issues
|
||||
|
||||
Go to App Store Connect → your app → App Review → Request Expedited Review.
|
||||
|
||||
## Version and Build Numbers
|
||||
|
||||
iOS uses two version identifiers:
|
||||
|
||||
- **Version** (`CFBundleShortVersionString`): User-facing, e.g., "1.2.3"
|
||||
- **Build Number** (`CFBundleVersion`): Internal, must increment for each upload
|
||||
|
||||
Configure in `app.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"expo": {
|
||||
"version": "1.2.3",
|
||||
"ios": {
|
||||
"buildNumber": "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
With `autoIncrement: true`, EAS handles build numbers automatically.
|
||||
|
||||
## Release Options
|
||||
|
||||
### Automatic Release
|
||||
|
||||
Release immediately when approved:
|
||||
|
||||
```json
|
||||
{
|
||||
"apple": {
|
||||
"release": {
|
||||
"automaticRelease": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Scheduled Release
|
||||
|
||||
```json
|
||||
{
|
||||
"apple": {
|
||||
"release": {
|
||||
"automaticRelease": "2025-03-01T10:00:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Phased Release
|
||||
|
||||
Gradual rollout over 7 days:
|
||||
|
||||
```json
|
||||
{
|
||||
"apple": {
|
||||
"release": {
|
||||
"phasedRelease": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rollout: Day 1 (1%) → Day 2 (2%) → Day 3 (5%) → Day 4 (10%) → Day 5 (20%) → Day 6 (50%) → Day 7 (100%)
|
||||
|
||||
## Certificates and Provisioning
|
||||
|
||||
### Distribution Certificate
|
||||
|
||||
- Required for App Store submissions
|
||||
- Limited to 3 per Apple Developer account
|
||||
- Valid for 1 year
|
||||
- EAS manages automatically
|
||||
|
||||
### Provisioning Profile
|
||||
|
||||
- Links app, certificate, and entitlements
|
||||
- App Store profiles don't include device UDIDs
|
||||
- EAS creates and manages automatically
|
||||
|
||||
### Check Current Credentials
|
||||
|
||||
```bash
|
||||
eas credentials -p ios
|
||||
|
||||
# Sync with Apple Developer Portal
|
||||
eas credentials -p ios --sync
|
||||
```
|
||||
|
||||
## App Store Metadata
|
||||
|
||||
Use EAS Metadata to manage App Store listing from code:
|
||||
|
||||
```bash
|
||||
# Pull existing metadata
|
||||
eas metadata:pull
|
||||
|
||||
# Push changes
|
||||
eas metadata:push
|
||||
```
|
||||
|
||||
See ./app-store-metadata.md for detailed configuration.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "No suitable application records found"
|
||||
|
||||
Create the app in App Store Connect first with matching bundle ID.
|
||||
|
||||
### "The bundle version must be higher"
|
||||
|
||||
Increment build number. With `autoIncrement: true`, this is automatic.
|
||||
|
||||
### "Missing compliance information"
|
||||
|
||||
Add export compliance to `app.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"expo": {
|
||||
"ios": {
|
||||
"config": {
|
||||
"usesNonExemptEncryption": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### "Invalid provisioning profile"
|
||||
|
||||
```bash
|
||||
eas credentials -p ios --sync
|
||||
```
|
||||
|
||||
### Build stuck in "Processing"
|
||||
|
||||
App Store Connect processing can take 5-30 minutes. Check status in App Store Connect → TestFlight.
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
For automated submissions in CI/CD:
|
||||
|
||||
```yaml
|
||||
# .eas/workflows/release.yml
|
||||
name: Release to App Store
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
jobs:
|
||||
build:
|
||||
type: build
|
||||
params:
|
||||
platform: ios
|
||||
profile: production
|
||||
|
||||
submit:
|
||||
type: submit
|
||||
needs: [build]
|
||||
params:
|
||||
platform: ios
|
||||
profile: production
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- Submit to TestFlight early and often for feedback
|
||||
- Use beta app review for external testers to catch issues before App Store review
|
||||
- Respond to reviewer questions promptly in App Store Connect
|
||||
- Keep demo account credentials up to date
|
||||
- Monitor App Store Connect notifications for review updates
|
||||
- Use phased release for major updates to catch issues early
|
||||
246
.opencode/skills/expo-deployment/references/play-store.md
Normal file
246
.opencode/skills/expo-deployment/references/play-store.md
Normal file
@@ -0,0 +1,246 @@
|
||||
# Submitting to Google Play Store
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Google Play Console Account** - Register at [play.google.com/console](https://play.google.com/console)
|
||||
2. **App Created in Console** - Create your app listing before first submission
|
||||
3. **Service Account** - For automated submissions via EAS
|
||||
|
||||
## Service Account Setup
|
||||
|
||||
### 1. Create Service Account
|
||||
|
||||
1. Go to Google Cloud Console → IAM & Admin → Service Accounts
|
||||
2. Create a new service account
|
||||
3. Grant the "Service Account User" role
|
||||
4. Create and download a JSON key
|
||||
|
||||
### 2. Link to Play Console
|
||||
|
||||
1. Go to Play Console → Setup → API access
|
||||
2. Click "Link" next to your Google Cloud project
|
||||
3. Under "Service accounts", click "Manage Play Console permissions"
|
||||
4. Grant "Release to production" permission (or appropriate track permissions)
|
||||
|
||||
### 3. Configure EAS
|
||||
|
||||
Add the service account key path to `eas.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"submit": {
|
||||
"production": {
|
||||
"android": {
|
||||
"serviceAccountKeyPath": "./google-service-account.json",
|
||||
"track": "internal"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Store the key file securely and add it to `.gitignore`.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
For CI/CD, use environment variables instead of file paths:
|
||||
|
||||
```bash
|
||||
# Base64-encoded service account JSON
|
||||
EXPO_ANDROID_SERVICE_ACCOUNT_KEY_BASE64=...
|
||||
```
|
||||
|
||||
Or use EAS Secrets:
|
||||
|
||||
```bash
|
||||
eas secret:create --name GOOGLE_SERVICE_ACCOUNT --value "$(cat google-service-account.json)" --type file
|
||||
```
|
||||
|
||||
Then reference in `eas.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"submit": {
|
||||
"production": {
|
||||
"android": {
|
||||
"serviceAccountKeyPath": "@secret:GOOGLE_SERVICE_ACCOUNT"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Release Tracks
|
||||
|
||||
Google Play uses tracks for staged rollouts:
|
||||
|
||||
| Track | Purpose |
|
||||
|-------|---------|
|
||||
| `internal` | Internal testing (up to 100 testers) |
|
||||
| `alpha` | Closed testing |
|
||||
| `beta` | Open testing |
|
||||
| `production` | Public release |
|
||||
|
||||
### Track Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"submit": {
|
||||
"production": {
|
||||
"android": {
|
||||
"track": "production",
|
||||
"releaseStatus": "completed"
|
||||
}
|
||||
},
|
||||
"internal": {
|
||||
"android": {
|
||||
"track": "internal",
|
||||
"releaseStatus": "completed"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Release Status Options
|
||||
|
||||
- `completed` - Immediately available on the track
|
||||
- `draft` - Upload only, release manually in Console
|
||||
- `halted` - Pause an in-progress rollout
|
||||
- `inProgress` - Staged rollout (requires `rollout` percentage)
|
||||
|
||||
## Staged Rollout
|
||||
|
||||
```json
|
||||
{
|
||||
"submit": {
|
||||
"production": {
|
||||
"android": {
|
||||
"track": "production",
|
||||
"releaseStatus": "inProgress",
|
||||
"rollout": 0.1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This releases to 10% of users. Increase via Play Console or subsequent submissions.
|
||||
|
||||
## Submission Commands
|
||||
|
||||
```bash
|
||||
# Build and submit to internal track
|
||||
eas build -p android --profile production --submit
|
||||
|
||||
# Submit existing build to Play Store
|
||||
eas submit -p android --latest
|
||||
|
||||
# Submit specific build
|
||||
eas submit -p android --id BUILD_ID
|
||||
```
|
||||
|
||||
## App Signing
|
||||
|
||||
### Google Play App Signing (Recommended)
|
||||
|
||||
EAS uses Google Play App Signing by default:
|
||||
|
||||
1. First upload: EAS creates upload key, Play Store manages signing key
|
||||
2. Play Store re-signs your app with the signing key
|
||||
3. Upload key can be reset if compromised
|
||||
|
||||
### Checking Signing Status
|
||||
|
||||
```bash
|
||||
eas credentials -p android
|
||||
```
|
||||
|
||||
## Version Codes
|
||||
|
||||
Android requires incrementing `versionCode` for each upload:
|
||||
|
||||
```json
|
||||
{
|
||||
"build": {
|
||||
"production": {
|
||||
"autoIncrement": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
With `appVersionSource: "remote"`, EAS tracks version codes automatically.
|
||||
|
||||
## First Submission Checklist
|
||||
|
||||
Before your first Play Store submission:
|
||||
|
||||
- [ ] Create app in Google Play Console
|
||||
- [ ] Complete app content declaration (privacy policy, ads, etc.)
|
||||
- [ ] Set up store listing (title, description, screenshots)
|
||||
- [ ] Complete content rating questionnaire
|
||||
- [ ] Set up pricing and distribution
|
||||
- [ ] Create service account with proper permissions
|
||||
- [ ] Configure `eas.json` with service account path
|
||||
|
||||
## Common Issues
|
||||
|
||||
### "App not found"
|
||||
|
||||
The app must exist in Play Console before EAS can submit. Create it manually first.
|
||||
|
||||
### "Version code already used"
|
||||
|
||||
Increment `versionCode` in `app.json` or use `autoIncrement: true` in `eas.json`.
|
||||
|
||||
### "Service account lacks permission"
|
||||
|
||||
Ensure the service account has "Release to production" permission in Play Console → API access.
|
||||
|
||||
### "APK not acceptable"
|
||||
|
||||
Play Store requires AAB (Android App Bundle) for new apps:
|
||||
|
||||
```json
|
||||
{
|
||||
"build": {
|
||||
"production": {
|
||||
"android": {
|
||||
"buildType": "app-bundle"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Internal Testing Distribution
|
||||
|
||||
For quick internal distribution without Play Store:
|
||||
|
||||
```bash
|
||||
# Build with internal distribution
|
||||
eas build -p android --profile development
|
||||
|
||||
# Share the APK link with testers
|
||||
```
|
||||
|
||||
Or use EAS Update for OTA updates to existing installs.
|
||||
|
||||
## Monitoring Submissions
|
||||
|
||||
```bash
|
||||
# Check submission status
|
||||
eas submit:list -p android
|
||||
|
||||
# View specific submission
|
||||
eas submit:view SUBMISSION_ID
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- Start with `internal` track for testing before production
|
||||
- Use staged rollouts for production releases
|
||||
- Keep service account key secure - never commit to git
|
||||
- Set up Play Console notifications for review status
|
||||
- Pre-launch reports in Play Console catch issues before review
|
||||
58
.opencode/skills/expo-deployment/references/testflight.md
Normal file
58
.opencode/skills/expo-deployment/references/testflight.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# TestFlight
|
||||
|
||||
Always ship to TestFlight first. Internal testers, then external testers, then App Store. Never skip this.
|
||||
|
||||
## Submit
|
||||
|
||||
```bash
|
||||
npx testflight
|
||||
```
|
||||
|
||||
That's it. One command builds and submits to TestFlight.
|
||||
|
||||
## Skip the Prompts
|
||||
|
||||
Set these once and forget:
|
||||
|
||||
```bash
|
||||
EXPO_APPLE_ID=you@email.com
|
||||
EXPO_APPLE_TEAM_ID=XXXXXXXXXX
|
||||
```
|
||||
|
||||
The CLI prints your Team ID when you run `npx testflight`. Copy it.
|
||||
|
||||
## Why TestFlight First
|
||||
|
||||
- Internal testers get builds instantly (no review)
|
||||
- External testers require one Beta App Review, then instant updates
|
||||
- Catch crashes before App Store review rejects you
|
||||
- TestFlight crash reports are better than App Store crash reports
|
||||
- 90 days to test before builds expire
|
||||
- Real users on real devices, not simulators
|
||||
|
||||
## Tester Strategy
|
||||
|
||||
**Internal (100 max)**: Your team. Immediate access. Use for every build.
|
||||
|
||||
**External (10,000 max)**: Beta users. First build needs review (~24h), then instant. Always have an external group—even if it's just friends. Real feedback beats assumptions.
|
||||
|
||||
## Tips
|
||||
|
||||
- Submit to external TestFlight the moment internal looks stable
|
||||
- Beta App Review is faster and more lenient than App Store Review
|
||||
- Add release notes—testers actually read them
|
||||
- Use TestFlight's built-in feedback and screenshots
|
||||
- Never go straight to App Store. Ever.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"No suitable application records found"**
|
||||
Create the app in App Store Connect first. Bundle ID must match.
|
||||
|
||||
**"The bundle version must be higher"**
|
||||
Use `autoIncrement: true` in `eas.json`. Problem solved.
|
||||
|
||||
**Credentials issues**
|
||||
```bash
|
||||
eas credentials -p ios
|
||||
```
|
||||
200
.opencode/skills/expo-deployment/references/workflows.md
Normal file
200
.opencode/skills/expo-deployment/references/workflows.md
Normal file
@@ -0,0 +1,200 @@
|
||||
# EAS Workflows
|
||||
|
||||
Automate builds, submissions, and deployments with EAS Workflows.
|
||||
|
||||
## Web Deployment
|
||||
|
||||
Deploy web apps on push to main:
|
||||
|
||||
`.eas/workflows/deploy.yml`
|
||||
|
||||
```yaml
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
# https://docs.expo.dev/eas/workflows/syntax/#deploy
|
||||
jobs:
|
||||
deploy_web:
|
||||
type: deploy
|
||||
params:
|
||||
prod: true
|
||||
```
|
||||
|
||||
## PR Previews
|
||||
|
||||
### Web PR Previews
|
||||
|
||||
```yaml
|
||||
name: Web PR Preview
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
|
||||
jobs:
|
||||
preview:
|
||||
type: deploy
|
||||
params:
|
||||
prod: false
|
||||
```
|
||||
|
||||
### Native PR Previews with EAS Updates
|
||||
|
||||
Deploy OTA updates for pull requests:
|
||||
|
||||
```yaml
|
||||
name: PR Preview
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
type: update
|
||||
params:
|
||||
branch: "pr-${{ github.event.pull_request.number }}"
|
||||
message: "PR #${{ github.event.pull_request.number }}"
|
||||
```
|
||||
|
||||
## Production Release
|
||||
|
||||
Complete release workflow for both platforms:
|
||||
|
||||
```yaml
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
jobs:
|
||||
build-ios:
|
||||
type: build
|
||||
params:
|
||||
platform: ios
|
||||
profile: production
|
||||
|
||||
build-android:
|
||||
type: build
|
||||
params:
|
||||
platform: android
|
||||
profile: production
|
||||
|
||||
submit-ios:
|
||||
type: submit
|
||||
needs: [build-ios]
|
||||
params:
|
||||
platform: ios
|
||||
profile: production
|
||||
|
||||
submit-android:
|
||||
type: submit
|
||||
needs: [build-android]
|
||||
params:
|
||||
platform: android
|
||||
profile: production
|
||||
```
|
||||
|
||||
## Build on Push
|
||||
|
||||
Trigger builds when pushing to specific branches:
|
||||
|
||||
```yaml
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- release/*
|
||||
|
||||
jobs:
|
||||
build:
|
||||
type: build
|
||||
params:
|
||||
platform: all
|
||||
profile: production
|
||||
```
|
||||
|
||||
## Conditional Jobs
|
||||
|
||||
Run jobs based on conditions:
|
||||
|
||||
```yaml
|
||||
name: Conditional Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
check-changes:
|
||||
type: run
|
||||
params:
|
||||
command: |
|
||||
if git diff --name-only HEAD~1 | grep -q "^src/"; then
|
||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
build:
|
||||
type: build
|
||||
needs: [check-changes]
|
||||
if: needs.check-changes.outputs.has_changes == 'true'
|
||||
params:
|
||||
platform: all
|
||||
profile: production
|
||||
```
|
||||
|
||||
## Workflow Syntax Reference
|
||||
|
||||
### Triggers
|
||||
|
||||
```yaml
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
schedule:
|
||||
- cron: '0 0 * * *' # Daily at midnight
|
||||
workflow_dispatch: # Manual trigger
|
||||
```
|
||||
|
||||
### Job Types
|
||||
|
||||
| Type | Purpose |
|
||||
|------|---------|
|
||||
| `build` | Create app builds |
|
||||
| `submit` | Submit to app stores |
|
||||
| `update` | Publish OTA updates |
|
||||
| `deploy` | Deploy web apps |
|
||||
| `run` | Execute custom commands |
|
||||
|
||||
### Job Dependencies
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
first:
|
||||
type: build
|
||||
params:
|
||||
platform: ios
|
||||
|
||||
second:
|
||||
type: submit
|
||||
needs: [first] # Runs after 'first' completes
|
||||
params:
|
||||
platform: ios
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- Use `workflow_dispatch` for manual production releases
|
||||
- Combine PR previews with GitHub status checks
|
||||
- Use tags for versioned releases
|
||||
- Keep sensitive values in EAS Secrets, not workflow files
|
||||
164
.opencode/skills/expo-dev-client/SKILL.md
Normal file
164
.opencode/skills/expo-dev-client/SKILL.md
Normal file
@@ -0,0 +1,164 @@
|
||||
---
|
||||
name: expo-dev-client
|
||||
description: Build and distribute Expo development clients locally or via TestFlight
|
||||
version: 1.0.0
|
||||
license: MIT
|
||||
---
|
||||
|
||||
Use EAS Build to create development clients for testing native code changes on physical devices. Use this for creating custom Expo Go clients for testing branches of your app.
|
||||
|
||||
## Important: When Development Clients Are Needed
|
||||
|
||||
**Only create development clients when your app requires custom native code.** Most apps work fine in Expo Go.
|
||||
|
||||
You need a dev client ONLY when using:
|
||||
- Local Expo modules (custom native code)
|
||||
- Apple targets (widgets, app clips, extensions)
|
||||
- Third-party native modules not in Expo Go
|
||||
|
||||
**Try Expo Go first** with `npx expo start`. If everything works, you don't need a dev client.
|
||||
|
||||
## EAS Configuration
|
||||
|
||||
Ensure `eas.json` has a development profile:
|
||||
|
||||
```json
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 16.0.1",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"production": {
|
||||
"autoIncrement": true
|
||||
},
|
||||
"development": {
|
||||
"autoIncrement": true,
|
||||
"developmentClient": true
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {},
|
||||
"development": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Key settings:
|
||||
- `developmentClient: true` - Bundles expo-dev-client for development builds
|
||||
- `autoIncrement: true` - Automatically increments build numbers
|
||||
- `appVersionSource: "remote"` - Uses EAS as the source of truth for version numbers
|
||||
|
||||
## Building for TestFlight
|
||||
|
||||
Build iOS dev client and submit to TestFlight in one command:
|
||||
|
||||
```bash
|
||||
eas build -p ios --profile development --submit
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Build the development client in the cloud
|
||||
2. Automatically submit to App Store Connect
|
||||
3. Send you an email when the build is ready in TestFlight
|
||||
|
||||
After receiving the TestFlight email:
|
||||
1. Download the build from TestFlight on your device
|
||||
2. Launch the app to see the expo-dev-client UI
|
||||
3. Connect to your local Metro bundler or scan a QR code
|
||||
|
||||
## Building Locally
|
||||
|
||||
Build a development client on your machine:
|
||||
|
||||
```bash
|
||||
# iOS (requires Xcode)
|
||||
eas build -p ios --profile development --local
|
||||
|
||||
# Android
|
||||
eas build -p android --profile development --local
|
||||
```
|
||||
|
||||
Local builds output:
|
||||
- iOS: `.ipa` file
|
||||
- Android: `.apk` or `.aab` file
|
||||
|
||||
## Installing Local Builds
|
||||
|
||||
Install iOS build on simulator:
|
||||
|
||||
```bash
|
||||
# Find the .app in the .tar.gz output
|
||||
tar -xzf build-*.tar.gz
|
||||
xcrun simctl install booted ./path/to/App.app
|
||||
```
|
||||
|
||||
Install iOS build on device (requires signing):
|
||||
|
||||
```bash
|
||||
# Use Xcode Devices window or ideviceinstaller
|
||||
ideviceinstaller -i build.ipa
|
||||
```
|
||||
|
||||
Install Android build:
|
||||
|
||||
```bash
|
||||
adb install build.apk
|
||||
```
|
||||
|
||||
## Building for Specific Platform
|
||||
|
||||
```bash
|
||||
# iOS only
|
||||
eas build -p ios --profile development
|
||||
|
||||
# Android only
|
||||
eas build -p android --profile development
|
||||
|
||||
# Both platforms
|
||||
eas build --profile development
|
||||
```
|
||||
|
||||
## Checking Build Status
|
||||
|
||||
```bash
|
||||
# List recent builds
|
||||
eas build:list
|
||||
|
||||
# View build details
|
||||
eas build:view
|
||||
```
|
||||
|
||||
## Using the Dev Client
|
||||
|
||||
Once installed, the dev client provides:
|
||||
- **Development server connection** - Enter your Metro bundler URL or scan QR
|
||||
- **Build information** - View native build details
|
||||
- **Launcher UI** - Switch between development servers
|
||||
|
||||
Connect to local development:
|
||||
|
||||
```bash
|
||||
# Start Metro bundler
|
||||
npx expo start --dev-client
|
||||
|
||||
# Scan QR code with dev client or enter URL manually
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Build fails with signing errors:**
|
||||
```bash
|
||||
eas credentials
|
||||
```
|
||||
|
||||
**Clear build cache:**
|
||||
```bash
|
||||
eas build -p ios --profile development --clear-cache
|
||||
```
|
||||
|
||||
**Check EAS CLI version:**
|
||||
```bash
|
||||
eas --version
|
||||
eas update
|
||||
```
|
||||
480
.opencode/skills/expo-tailwind-setup/SKILL.md
Normal file
480
.opencode/skills/expo-tailwind-setup/SKILL.md
Normal file
@@ -0,0 +1,480 @@
|
||||
---
|
||||
name: expo-tailwind-setup
|
||||
description: Set up Tailwind CSS v4 in Expo with react-native-css and NativeWind v5 for universal styling
|
||||
version: 1.0.0
|
||||
license: MIT
|
||||
---
|
||||
|
||||
# Tailwind CSS Setup for Expo with react-native-css
|
||||
|
||||
This guide covers setting up Tailwind CSS v4 in Expo using react-native-css and NativeWind v5 for universal styling across iOS, Android, and Web.
|
||||
|
||||
## Overview
|
||||
|
||||
This setup uses:
|
||||
|
||||
- **Tailwind CSS v4** - Modern CSS-first configuration
|
||||
- **react-native-css** - CSS runtime for React Native
|
||||
- **NativeWind v5** - Metro transformer for Tailwind in React Native
|
||||
- **@tailwindcss/postcss** - PostCSS plugin for Tailwind v4
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npx expo install tailwindcss@^4 nativewind@5.0.0-preview.2 react-native-css@0.0.0-nightly.5ce6396 @tailwindcss/postcss tailwind-merge clsx
|
||||
```
|
||||
|
||||
Add resolutions for lightningcss compatibility:
|
||||
|
||||
```json
|
||||
// package.json
|
||||
{
|
||||
"resolutions": {
|
||||
"lightningcss": "1.30.1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- autoprefixer is not needed in Expo because of lightningcss
|
||||
- postcss is included in expo by default
|
||||
|
||||
## Configuration Files
|
||||
|
||||
### Metro Config
|
||||
|
||||
Create or update `metro.config.js`:
|
||||
|
||||
```js
|
||||
// metro.config.js
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
const { withNativewind } = require("nativewind/metro");
|
||||
|
||||
/** @type {import('expo/metro-config').MetroConfig} */
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
module.exports = withNativewind(config, {
|
||||
// inline variables break PlatformColor in CSS variables
|
||||
inlineVariables: false,
|
||||
// We add className support manually
|
||||
globalClassNamePolyfill: false,
|
||||
});
|
||||
```
|
||||
|
||||
### PostCSS Config
|
||||
|
||||
Create `postcss.config.mjs`:
|
||||
|
||||
```js
|
||||
// postcss.config.mjs
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Global CSS
|
||||
|
||||
Create `src/global.css`:
|
||||
|
||||
```css
|
||||
@import "tailwindcss/theme.css" layer(theme);
|
||||
@import "tailwindcss/preflight.css" layer(base);
|
||||
@import "tailwindcss/utilities.css";
|
||||
|
||||
/* Platform-specific font families */
|
||||
@media android {
|
||||
:root {
|
||||
--font-mono: monospace;
|
||||
--font-rounded: normal;
|
||||
--font-serif: serif;
|
||||
--font-sans: normal;
|
||||
}
|
||||
}
|
||||
|
||||
@media ios {
|
||||
:root {
|
||||
--font-mono: ui-monospace;
|
||||
--font-serif: ui-serif;
|
||||
--font-sans: system-ui;
|
||||
--font-rounded: ui-rounded;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## IMPORTANT: No Babel Config Needed
|
||||
|
||||
With Tailwind v4 and NativeWind v5, you do NOT need a babel.config.js for Tailwind. Remove any NativeWind babel presets if present:
|
||||
|
||||
```js
|
||||
// DELETE babel.config.js if it only contains NativeWind config
|
||||
// The following is NO LONGER needed:
|
||||
// module.exports = function (api) {
|
||||
// api.cache(true);
|
||||
// return {
|
||||
// presets: [
|
||||
// ["babel-preset-expo", { jsxImportSource: "nativewind" }],
|
||||
// "nativewind/babel",
|
||||
// ],
|
||||
// };
|
||||
// };
|
||||
```
|
||||
|
||||
## CSS Component Wrappers
|
||||
|
||||
Since react-native-css requires explicit CSS element wrapping, create reusable components:
|
||||
|
||||
### Main Components (`src/tw/index.tsx`)
|
||||
|
||||
```tsx
|
||||
import {
|
||||
useCssElement,
|
||||
useNativeVariable as useFunctionalVariable,
|
||||
} from "react-native-css";
|
||||
|
||||
import { Link as RouterLink } from "expo-router";
|
||||
import Animated from "react-native-reanimated";
|
||||
import React from "react";
|
||||
import {
|
||||
View as RNView,
|
||||
Text as RNText,
|
||||
Pressable as RNPressable,
|
||||
ScrollView as RNScrollView,
|
||||
TouchableHighlight as RNTouchableHighlight,
|
||||
TextInput as RNTextInput,
|
||||
StyleSheet,
|
||||
} from "react-native";
|
||||
|
||||
// CSS-enabled Link
|
||||
export const Link = (
|
||||
props: React.ComponentProps<typeof RouterLink> & { className?: string }
|
||||
) => {
|
||||
return useCssElement(RouterLink, props, { className: "style" });
|
||||
};
|
||||
|
||||
Link.Trigger = RouterLink.Trigger;
|
||||
Link.Menu = RouterLink.Menu;
|
||||
Link.MenuAction = RouterLink.MenuAction;
|
||||
Link.Preview = RouterLink.Preview;
|
||||
|
||||
// CSS Variable hook
|
||||
export const useCSSVariable =
|
||||
process.env.EXPO_OS !== "web"
|
||||
? useFunctionalVariable
|
||||
: (variable: string) => `var(${variable})`;
|
||||
|
||||
// View
|
||||
export type ViewProps = React.ComponentProps<typeof RNView> & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const View = (props: ViewProps) => {
|
||||
return useCssElement(RNView, props, { className: "style" });
|
||||
};
|
||||
View.displayName = "CSS(View)";
|
||||
|
||||
// Text
|
||||
export const Text = (
|
||||
props: React.ComponentProps<typeof RNText> & { className?: string }
|
||||
) => {
|
||||
return useCssElement(RNText, props, { className: "style" });
|
||||
};
|
||||
Text.displayName = "CSS(Text)";
|
||||
|
||||
// ScrollView
|
||||
export const ScrollView = (
|
||||
props: React.ComponentProps<typeof RNScrollView> & {
|
||||
className?: string;
|
||||
contentContainerClassName?: string;
|
||||
}
|
||||
) => {
|
||||
return useCssElement(RNScrollView, props, {
|
||||
className: "style",
|
||||
contentContainerClassName: "contentContainerStyle",
|
||||
});
|
||||
};
|
||||
ScrollView.displayName = "CSS(ScrollView)";
|
||||
|
||||
// Pressable
|
||||
export const Pressable = (
|
||||
props: React.ComponentProps<typeof RNPressable> & { className?: string }
|
||||
) => {
|
||||
return useCssElement(RNPressable, props, { className: "style" });
|
||||
};
|
||||
Pressable.displayName = "CSS(Pressable)";
|
||||
|
||||
// TextInput
|
||||
export const TextInput = (
|
||||
props: React.ComponentProps<typeof RNTextInput> & { className?: string }
|
||||
) => {
|
||||
return useCssElement(RNTextInput, props, { className: "style" });
|
||||
};
|
||||
TextInput.displayName = "CSS(TextInput)";
|
||||
|
||||
// AnimatedScrollView
|
||||
export const AnimatedScrollView = (
|
||||
props: React.ComponentProps<typeof Animated.ScrollView> & {
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
contentContainerClassName?: string;
|
||||
}
|
||||
) => {
|
||||
return useCssElement(Animated.ScrollView, props, {
|
||||
className: "style",
|
||||
contentClassName: "contentContainerStyle",
|
||||
contentContainerClassName: "contentContainerStyle",
|
||||
});
|
||||
};
|
||||
|
||||
// TouchableHighlight with underlayColor extraction
|
||||
function XXTouchableHighlight(
|
||||
props: React.ComponentProps<typeof RNTouchableHighlight>
|
||||
) {
|
||||
const { underlayColor, ...style } = StyleSheet.flatten(props.style) || {};
|
||||
return (
|
||||
<RNTouchableHighlight
|
||||
underlayColor={underlayColor}
|
||||
{...props}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const TouchableHighlight = (
|
||||
props: React.ComponentProps<typeof RNTouchableHighlight>
|
||||
) => {
|
||||
return useCssElement(XXTouchableHighlight, props, { className: "style" });
|
||||
};
|
||||
TouchableHighlight.displayName = "CSS(TouchableHighlight)";
|
||||
```
|
||||
|
||||
### Image Component (`src/tw/image.tsx`)
|
||||
|
||||
```tsx
|
||||
import { useCssElement } from "react-native-css";
|
||||
import React from "react";
|
||||
import { StyleSheet } from "react-native";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { Image as RNImage } from "expo-image";
|
||||
|
||||
const AnimatedExpoImage = Animated.createAnimatedComponent(RNImage);
|
||||
|
||||
export type ImageProps = React.ComponentProps<typeof Image>;
|
||||
|
||||
function CSSImage(props: React.ComponentProps<typeof AnimatedExpoImage>) {
|
||||
// @ts-expect-error: Remap objectFit style to contentFit property
|
||||
const { objectFit, objectPosition, ...style } =
|
||||
StyleSheet.flatten(props.style) || {};
|
||||
|
||||
return (
|
||||
<AnimatedExpoImage
|
||||
contentFit={objectFit}
|
||||
contentPosition={objectPosition}
|
||||
{...props}
|
||||
source={
|
||||
typeof props.source === "string" ? { uri: props.source } : props.source
|
||||
}
|
||||
// @ts-expect-error: Style is remapped above
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const Image = (
|
||||
props: React.ComponentProps<typeof CSSImage> & { className?: string }
|
||||
) => {
|
||||
return useCssElement(CSSImage, props, { className: "style" });
|
||||
};
|
||||
|
||||
Image.displayName = "CSS(Image)";
|
||||
```
|
||||
|
||||
### Animated Components (`src/tw/animated.tsx`)
|
||||
|
||||
```tsx
|
||||
import * as TW from "./index";
|
||||
import RNAnimated from "react-native-reanimated";
|
||||
|
||||
export const Animated = {
|
||||
...RNAnimated,
|
||||
View: RNAnimated.createAnimatedComponent(TW.View),
|
||||
};
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Import CSS-wrapped components from your tw directory:
|
||||
|
||||
```tsx
|
||||
import { View, Text, ScrollView, Image } from "@/tw";
|
||||
|
||||
export default function MyScreen() {
|
||||
return (
|
||||
<ScrollView className="flex-1 bg-white">
|
||||
<View className="p-4 gap-4">
|
||||
<Text className="text-xl font-bold text-gray-900">Hello Tailwind!</Text>
|
||||
<Image
|
||||
className="w-full h-48 rounded-lg object-cover"
|
||||
source={{ uri: "https://example.com/image.jpg" }}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Theme Variables
|
||||
|
||||
Add custom theme variables in your global.css using `@theme`:
|
||||
|
||||
```css
|
||||
@layer theme {
|
||||
@theme {
|
||||
/* Custom fonts */
|
||||
--font-rounded: "SF Pro Rounded", sans-serif;
|
||||
|
||||
/* Custom line heights */
|
||||
--text-xs--line-height: calc(1em / 0.75);
|
||||
--text-sm--line-height: calc(1.25em / 0.875);
|
||||
--text-base--line-height: calc(1.5em / 1);
|
||||
|
||||
/* Custom leading scales */
|
||||
--leading-tight: 1.25em;
|
||||
--leading-snug: 1.375em;
|
||||
--leading-normal: 1.5em;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Platform-Specific Styles
|
||||
|
||||
Use platform media queries for platform-specific styling:
|
||||
|
||||
```css
|
||||
@media ios {
|
||||
:root {
|
||||
--font-sans: system-ui;
|
||||
--font-rounded: ui-rounded;
|
||||
}
|
||||
}
|
||||
|
||||
@media android {
|
||||
:root {
|
||||
--font-sans: normal;
|
||||
--font-rounded: normal;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Apple System Colors with CSS Variables
|
||||
|
||||
Create a CSS file for Apple semantic colors:
|
||||
|
||||
```css
|
||||
/* src/css/sf.css */
|
||||
@layer base {
|
||||
html {
|
||||
color-scheme: light;
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Accent colors with light/dark mode */
|
||||
--sf-blue: light-dark(rgb(0 122 255), rgb(10 132 255));
|
||||
--sf-green: light-dark(rgb(52 199 89), rgb(48 209 89));
|
||||
--sf-red: light-dark(rgb(255 59 48), rgb(255 69 58));
|
||||
|
||||
/* Gray scales */
|
||||
--sf-gray: light-dark(rgb(142 142 147), rgb(142 142 147));
|
||||
--sf-gray-2: light-dark(rgb(174 174 178), rgb(99 99 102));
|
||||
|
||||
/* Text colors */
|
||||
--sf-text: light-dark(rgb(0 0 0), rgb(255 255 255));
|
||||
--sf-text-2: light-dark(rgb(60 60 67 / 0.6), rgb(235 235 245 / 0.6));
|
||||
|
||||
/* Background colors */
|
||||
--sf-bg: light-dark(rgb(255 255 255), rgb(0 0 0));
|
||||
--sf-bg-2: light-dark(rgb(242 242 247), rgb(28 28 30));
|
||||
}
|
||||
|
||||
/* iOS native colors via platformColor */
|
||||
@media ios {
|
||||
:root {
|
||||
--sf-blue: platformColor(systemBlue);
|
||||
--sf-green: platformColor(systemGreen);
|
||||
--sf-red: platformColor(systemRed);
|
||||
--sf-gray: platformColor(systemGray);
|
||||
--sf-text: platformColor(label);
|
||||
--sf-text-2: platformColor(secondaryLabel);
|
||||
--sf-bg: platformColor(systemBackground);
|
||||
--sf-bg-2: platformColor(secondarySystemBackground);
|
||||
}
|
||||
}
|
||||
|
||||
/* Register as Tailwind theme colors */
|
||||
@layer theme {
|
||||
@theme {
|
||||
--color-sf-blue: var(--sf-blue);
|
||||
--color-sf-green: var(--sf-green);
|
||||
--color-sf-red: var(--sf-red);
|
||||
--color-sf-gray: var(--sf-gray);
|
||||
--color-sf-text: var(--sf-text);
|
||||
--color-sf-text-2: var(--sf-text-2);
|
||||
--color-sf-bg: var(--sf-bg);
|
||||
--color-sf-bg-2: var(--sf-bg-2);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then use in components:
|
||||
|
||||
```tsx
|
||||
<Text className="text-sf-text">Primary text</Text>
|
||||
<Text className="text-sf-text-2">Secondary text</Text>
|
||||
<View className="bg-sf-bg">...</View>
|
||||
```
|
||||
|
||||
## Using CSS Variables in JavaScript
|
||||
|
||||
Use the `useCSSVariable` hook:
|
||||
|
||||
```tsx
|
||||
import { useCSSVariable } from "@/tw";
|
||||
|
||||
function MyComponent() {
|
||||
const blue = useCSSVariable("--sf-blue");
|
||||
|
||||
return <View style={{ borderColor: blue }} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Key Differences from NativeWind v4 / Tailwind v3
|
||||
|
||||
1. **No babel.config.js** - Configuration is now CSS-first
|
||||
2. **PostCSS plugin** - Uses `@tailwindcss/postcss` instead of `tailwindcss`
|
||||
3. **CSS imports** - Use `@import "tailwindcss/..."` instead of `@tailwind` directives
|
||||
4. **Theme config** - Use `@theme` in CSS instead of `tailwind.config.js`
|
||||
5. **Component wrappers** - Must wrap components with `useCssElement` for className support
|
||||
6. **Metro config** - Use `withNativewind` with different options (`inlineVariables: false`)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Styles not applying
|
||||
|
||||
1. Ensure you have the CSS file imported in your app entry
|
||||
2. Check that components are wrapped with `useCssElement`
|
||||
3. Verify Metro config has `withNativewind` applied
|
||||
|
||||
### Platform colors not working
|
||||
|
||||
1. Use `platformColor()` in `@media ios` blocks
|
||||
2. Fall back to `light-dark()` for web/Android
|
||||
|
||||
### TypeScript errors
|
||||
|
||||
Add className to component props:
|
||||
|
||||
```tsx
|
||||
type Props = React.ComponentProps<typeof RNView> & { className?: string };
|
||||
```
|
||||
507
.opencode/skills/native-data-fetching/SKILL.md
Normal file
507
.opencode/skills/native-data-fetching/SKILL.md
Normal file
@@ -0,0 +1,507 @@
|
||||
---
|
||||
name: native-data-fetching
|
||||
description: Use when implementing or debugging ANY network request, API call, or data fetching. Covers fetch API, React Query, SWR, error handling, caching, offline support, and Expo Router data loaders (useLoaderData).
|
||||
version: 1.0.0
|
||||
license: MIT
|
||||
---
|
||||
|
||||
# Expo Networking
|
||||
|
||||
**You MUST use this skill for ANY networking work including API requests, data fetching, caching, or network debugging.**
|
||||
|
||||
## References
|
||||
|
||||
Consult these resources as needed:
|
||||
|
||||
```
|
||||
references/
|
||||
expo-router-loaders.md Route-level data loading with Expo Router loaders (web, SDK 55+)
|
||||
```
|
||||
|
||||
## When to Use
|
||||
|
||||
Use this skill when:
|
||||
|
||||
- Implementing API requests
|
||||
- Setting up data fetching (React Query, SWR)
|
||||
- Using Expo Router data loaders (`useLoaderData`, web SDK 55+)
|
||||
- Debugging network failures
|
||||
- Implementing caching strategies
|
||||
- Handling offline scenarios
|
||||
- Authentication/token management
|
||||
- Configuring API URLs and environment variables
|
||||
|
||||
## Preferences
|
||||
|
||||
- Avoid axios, prefer expo/fetch
|
||||
|
||||
## Common Issues & Solutions
|
||||
|
||||
### 1. Basic Fetch Usage
|
||||
|
||||
**Simple GET request**:
|
||||
|
||||
```tsx
|
||||
const fetchUser = async (userId: string) => {
|
||||
const response = await fetch(`https://api.example.com/users/${userId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
```
|
||||
|
||||
**POST request with body**:
|
||||
|
||||
```tsx
|
||||
const createUser = async (userData: UserData) => {
|
||||
const response = await fetch("https://api.example.com/users", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(userData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. React Query (TanStack 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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Stack />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Fetching data**:
|
||||
|
||||
```tsx
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
function UserProfile({ userId }: { userId: string }) {
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ["user", userId],
|
||||
queryFn: () => fetchUser(userId),
|
||||
});
|
||||
|
||||
if (isLoading) return <Loading />;
|
||||
if (error) return <Error message={error.message} />;
|
||||
|
||||
return <Profile user={data} />;
|
||||
}
|
||||
```
|
||||
|
||||
**Mutations**:
|
||||
|
||||
```tsx
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
function CreateUserForm() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: createUser,
|
||||
onSuccess: () => {
|
||||
// Invalidate and refetch
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (data: UserData) => {
|
||||
mutation.mutate(data);
|
||||
};
|
||||
|
||||
return <Form onSubmit={handleSubmit} isLoading={mutation.isPending} />;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
**Comprehensive error handling**:
|
||||
|
||||
```tsx
|
||||
class ApiError extends Error {
|
||||
constructor(message: string, public status: number, public code?: string) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
const fetchWithErrorHandling = async (url: string, options?: RequestInit) => {
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
throw new ApiError(
|
||||
error.message || "Request failed",
|
||||
response.status,
|
||||
error.code
|
||||
);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
throw error;
|
||||
}
|
||||
// Network error (no internet, timeout, etc.)
|
||||
throw new ApiError("Network error", 0, "NETWORK_ERROR");
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Retry logic**:
|
||||
|
||||
```tsx
|
||||
const fetchWithRetry = async (
|
||||
url: string,
|
||||
options?: RequestInit,
|
||||
retries = 3
|
||||
) => {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
return await fetchWithErrorHandling(url, options);
|
||||
} catch (error) {
|
||||
if (i === retries - 1) throw error;
|
||||
// Exponential backoff
|
||||
await new Promise((r) => setTimeout(r, Math.pow(2, i) * 1000));
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Authentication
|
||||
|
||||
**Token management**:
|
||||
|
||||
```tsx
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
|
||||
const TOKEN_KEY = "auth_token";
|
||||
|
||||
export const auth = {
|
||||
getToken: () => SecureStore.getItemAsync(TOKEN_KEY),
|
||||
setToken: (token: string) => SecureStore.setItemAsync(TOKEN_KEY, token),
|
||||
removeToken: () => SecureStore.deleteItemAsync(TOKEN_KEY),
|
||||
};
|
||||
|
||||
// Authenticated fetch wrapper
|
||||
const authFetch = async (url: string, options: RequestInit = {}) => {
|
||||
const token = await auth.getToken();
|
||||
|
||||
return fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...options.headers,
|
||||
Authorization: token ? `Bearer ${token}` : "",
|
||||
},
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
**Token refresh**:
|
||||
|
||||
```tsx
|
||||
let isRefreshing = false;
|
||||
let refreshPromise: Promise<string> | null = null;
|
||||
|
||||
const getValidToken = async (): Promise<string> => {
|
||||
const token = await auth.getToken();
|
||||
|
||||
if (!token || isTokenExpired(token)) {
|
||||
if (!isRefreshing) {
|
||||
isRefreshing = true;
|
||||
refreshPromise = refreshToken().finally(() => {
|
||||
isRefreshing = false;
|
||||
refreshPromise = null;
|
||||
});
|
||||
}
|
||||
return refreshPromise!;
|
||||
}
|
||||
|
||||
return token;
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Offline Support
|
||||
|
||||
**Check network status**:
|
||||
|
||||
```tsx
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
|
||||
// Hook for network status
|
||||
function useNetworkStatus() {
|
||||
const [isOnline, setIsOnline] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
return NetInfo.addEventListener((state) => {
|
||||
setIsOnline(state.isConnected ?? true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return isOnline;
|
||||
}
|
||||
```
|
||||
|
||||
**Offline-first with React Query**:
|
||||
|
||||
```tsx
|
||||
import { onlineManager } from "@tanstack/react-query";
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
|
||||
// Sync React Query with network status
|
||||
onlineManager.setEventListener((setOnline) => {
|
||||
return NetInfo.addEventListener((state) => {
|
||||
setOnline(state.isConnected ?? true);
|
||||
});
|
||||
});
|
||||
|
||||
// Queries will pause when offline and resume when online
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Environment Variables
|
||||
|
||||
**Using environment variables for API configuration**:
|
||||
|
||||
Expo supports environment variables with the `EXPO_PUBLIC_` prefix. These are inlined at build time and available in your JavaScript code.
|
||||
|
||||
```tsx
|
||||
// .env
|
||||
EXPO_PUBLIC_API_URL=https://api.example.com
|
||||
EXPO_PUBLIC_API_VERSION=v1
|
||||
|
||||
// Usage in code
|
||||
const API_URL = process.env.EXPO_PUBLIC_API_URL;
|
||||
|
||||
const fetchUsers = async () => {
|
||||
const response = await fetch(`${API_URL}/users`);
|
||||
return response.json();
|
||||
};
|
||||
```
|
||||
|
||||
**Environment-specific configuration**:
|
||||
|
||||
```tsx
|
||||
// .env.development
|
||||
EXPO_PUBLIC_API_URL=http://localhost:3000
|
||||
|
||||
// .env.production
|
||||
EXPO_PUBLIC_API_URL=https://api.production.com
|
||||
```
|
||||
|
||||
**Creating an API client with environment config**:
|
||||
|
||||
```tsx
|
||||
// api/client.ts
|
||||
const BASE_URL = process.env.EXPO_PUBLIC_API_URL;
|
||||
|
||||
if (!BASE_URL) {
|
||||
throw new Error("EXPO_PUBLIC_API_URL is not defined");
|
||||
}
|
||||
|
||||
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();
|
||||
},
|
||||
|
||||
post: async <T,>(path: string, body: unknown): Promise<T> => {
|
||||
const response = await fetch(`${BASE_URL}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json();
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
**Important notes**:
|
||||
|
||||
- Only variables prefixed with `EXPO_PUBLIC_` are exposed to the client bundle
|
||||
- Never put secrets (API keys with write access, database passwords) in `EXPO_PUBLIC_` variables—they're visible in the built app
|
||||
- Environment variables are inlined at **build time**, not runtime
|
||||
- Restart the dev server after changing `.env` files
|
||||
- For server-side secrets in API routes, use variables without the `EXPO_PUBLIC_` prefix
|
||||
|
||||
**TypeScript support**:
|
||||
|
||||
```tsx
|
||||
// types/env.d.ts
|
||||
declare global {
|
||||
namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
EXPO_PUBLIC_API_URL: string;
|
||||
EXPO_PUBLIC_API_VERSION?: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. Request Cancellation
|
||||
|
||||
**Cancel on unmount**:
|
||||
|
||||
```tsx
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
fetch(url, { signal: controller.signal })
|
||||
.then((response) => response.json())
|
||||
.then(setData)
|
||||
.catch((error) => {
|
||||
if (error.name !== "AbortError") {
|
||||
setError(error);
|
||||
}
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [url]);
|
||||
```
|
||||
|
||||
**With React Query** (automatic):
|
||||
|
||||
```tsx
|
||||
// React Query automatically cancels requests when queries are invalidated
|
||||
// or components unmount
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Decision Tree
|
||||
|
||||
```
|
||||
User asks about networking
|
||||
|-- Route-level data loading (web, SDK 55+)?
|
||||
| \-- Expo Router loaders — see references/expo-router-loaders.md
|
||||
|
|
||||
|-- Basic fetch?
|
||||
| \-- Use fetch API with error handling
|
||||
|
|
||||
|-- Need caching/state management?
|
||||
| |-- Complex app -> React Query (TanStack Query)
|
||||
| \-- Simpler needs -> SWR or custom hooks
|
||||
|
|
||||
|-- Authentication?
|
||||
| |-- Token storage -> expo-secure-store
|
||||
| \-- Token refresh -> Implement refresh flow
|
||||
|
|
||||
|-- Error handling?
|
||||
| |-- Network errors -> Check connectivity first
|
||||
| |-- HTTP errors -> Parse response, throw typed errors
|
||||
| \-- Retries -> Exponential backoff
|
||||
|
|
||||
|-- Offline support?
|
||||
| |-- Check status -> NetInfo
|
||||
| \-- Queue requests -> React Query persistence
|
||||
|
|
||||
|-- Environment/API config?
|
||||
| |-- Client-side URLs -> EXPO_PUBLIC_ prefix in .env
|
||||
| |-- Server secrets -> Non-prefixed env vars (API routes only)
|
||||
| \-- Multiple environments -> .env.development, .env.production
|
||||
|
|
||||
\-- Performance?
|
||||
|-- Caching -> React Query with staleTime
|
||||
|-- Deduplication -> React Query handles this
|
||||
\-- Cancellation -> AbortController or React Query
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
**Wrong: No error handling**
|
||||
|
||||
```tsx
|
||||
const data = await fetch(url).then((r) => r.json());
|
||||
```
|
||||
|
||||
**Right: Check response status**
|
||||
|
||||
```tsx
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
```
|
||||
|
||||
**Wrong: Storing tokens in AsyncStorage**
|
||||
|
||||
```tsx
|
||||
await AsyncStorage.setItem("token", token); // Not secure!
|
||||
```
|
||||
|
||||
**Right: Use SecureStore for sensitive data**
|
||||
|
||||
```tsx
|
||||
await SecureStore.setItemAsync("token", token);
|
||||
```
|
||||
|
||||
## Example Invocations
|
||||
|
||||
User: "How do I make API calls in React Native?"
|
||||
-> Use fetch, wrap with error handling
|
||||
|
||||
User: "Should I use React Query or SWR?"
|
||||
-> React Query for complex apps, SWR for simpler needs
|
||||
|
||||
User: "My app needs to work offline"
|
||||
-> Use NetInfo for status, React Query persistence for caching
|
||||
|
||||
User: "How do I handle authentication tokens?"
|
||||
-> Store in expo-secure-store, implement refresh flow
|
||||
|
||||
User: "API calls are slow"
|
||||
-> Check caching strategy, use React Query staleTime
|
||||
|
||||
User: "How do I configure different API URLs for dev and prod?"
|
||||
-> Use EXPO*PUBLIC* env vars with .env.development and .env.production files
|
||||
|
||||
User: "Where should I put my API key?"
|
||||
-> Client-safe keys: EXPO*PUBLIC* in .env. Secret keys: non-prefixed env vars in API routes only
|
||||
|
||||
User: "How do I load data for a page in Expo Router?"
|
||||
-> See references/expo-router-loaders.md for route-level loaders (web, SDK 55+). For native, use React Query or fetch.
|
||||
@@ -0,0 +1,341 @@
|
||||
# Expo Router Data Loaders
|
||||
|
||||
Route-level data loading for web apps using Expo SDK 55+. Loaders are async functions exported from route files that load data before the route renders, following the Remix/React Router loader model.
|
||||
|
||||
**Dual execution model:**
|
||||
|
||||
- **Initial page load (SSR):** The loader runs server-side. Its return value is serialized as JSON and embedded in the HTML response.
|
||||
- **Client-side navigation:** The browser fetches the loader data from the server via HTTP. The route renders once the data arrives.
|
||||
|
||||
You write one function and the framework manages when and how it executes.
|
||||
|
||||
## Configuration
|
||||
|
||||
**Requirements:** Expo SDK 55+, web output mode (`npx expo serve` or `npx expo export --platform web`) set in `app.json` or `app.config.js`.
|
||||
|
||||
**Server rendering:**
|
||||
|
||||
```json
|
||||
{
|
||||
"expo": {
|
||||
"web": {
|
||||
"output": "server"
|
||||
},
|
||||
"plugins": [
|
||||
["expo-router", {
|
||||
"unstable_useServerDataLoaders": true,
|
||||
"unstable_useServerRendering": true
|
||||
}]
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Static/SSG:**
|
||||
|
||||
```json
|
||||
{
|
||||
"expo": {
|
||||
"web": {
|
||||
"output": "static"
|
||||
},
|
||||
"plugins": [
|
||||
["expo-router", {
|
||||
"unstable_useServerDataLoaders": true
|
||||
}]
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| | `"server"` | `"static"` |
|
||||
|---|-----------|------------|
|
||||
| `unstable_useServerDataLoaders` | Required | Required |
|
||||
| `unstable_useServerRendering` | Required | Not required |
|
||||
| Loader runs on | Live server (every request) | Build time (static generation) |
|
||||
| `request` object | Full access (headers, cookies) | Not available |
|
||||
| Hosting | Node.js server (EAS Hosting) | Any static host (Netlify, Vercel, S3) |
|
||||
|
||||
## Imports
|
||||
|
||||
Loaders use two packages:
|
||||
|
||||
- **`expo-router`** — `useLoaderData` hook
|
||||
- **`expo-server`** — `LoaderFunction` type, `StatusError`, `setResponseHeaders`. Always available (dependency of `expo-router`), no install needed.
|
||||
|
||||
## Basic Loader
|
||||
|
||||
For loaders without params, a plain async function works:
|
||||
|
||||
```tsx
|
||||
// app/posts/index.tsx
|
||||
import { Suspense } from "react";
|
||||
import { useLoaderData } from "expo-router";
|
||||
import { ActivityIndicator, View, Text } from "react-native";
|
||||
|
||||
export async function loader() {
|
||||
const response = await fetch("https://api.example.com/posts");
|
||||
const posts = await response.json();
|
||||
return { posts };
|
||||
}
|
||||
|
||||
function PostList() {
|
||||
const { posts } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<View>
|
||||
{posts.map((post) => (
|
||||
<Text key={post.id}>{post.title}</Text>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Posts() {
|
||||
return (
|
||||
<Suspense fallback={<ActivityIndicator size="large" />}>
|
||||
<PostList />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`useLoaderData` is typed via `typeof loader` — the generic parameter infers the return type.
|
||||
|
||||
## Dynamic Routes
|
||||
|
||||
For loaders with params, use the `LoaderFunction<T>` type from `expo-server`. The first argument is the request (an immutable `Request`-like object, or `undefined` in static mode). The second is `params` (`Record<string, string | string[]>`), which contains **path parameters only**. Access individual params with a cast like `params.id as string`. For query parameters, use `new URL(request.url).searchParams`:
|
||||
|
||||
```tsx
|
||||
// app/posts/[id].tsx
|
||||
import { Suspense } from "react";
|
||||
import { useLoaderData } from "expo-router";
|
||||
import { StatusError, type LoaderFunction } from "expo-server";
|
||||
import { ActivityIndicator, View, Text } from "react-native";
|
||||
|
||||
type Post = {
|
||||
id: number;
|
||||
title: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
export const loader: LoaderFunction<{ post: Post }> = async (
|
||||
request,
|
||||
params,
|
||||
) => {
|
||||
const id = params.id as string;
|
||||
const response = await fetch(`https://api.example.com/posts/${id}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new StatusError(404, `Post ${id} not found`);
|
||||
}
|
||||
|
||||
const post: Post = await response.json();
|
||||
return { post };
|
||||
};
|
||||
|
||||
function PostContent() {
|
||||
const { post } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Text>{post.title}</Text>
|
||||
<Text>{post.body}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PostDetail() {
|
||||
return (
|
||||
<Suspense fallback={<ActivityIndicator size="large" />}>
|
||||
<PostContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Catch-all routes access `params.slug` the same way:
|
||||
|
||||
```tsx
|
||||
// app/docs/[...slug].tsx
|
||||
import { type LoaderFunction } from "expo-server";
|
||||
|
||||
type Doc = { title: string; content: string };
|
||||
|
||||
export const loader: LoaderFunction<{ doc: Doc }> = async (request, params) => {
|
||||
const slug = params.slug as string[];
|
||||
const path = slug.join("/");
|
||||
const doc = await fetchDoc(path);
|
||||
return { doc };
|
||||
};
|
||||
```
|
||||
|
||||
Query parameters are available via the `request` object (server output mode only):
|
||||
|
||||
```tsx
|
||||
// app/search.tsx
|
||||
import { type LoaderFunction } from "expo-server";
|
||||
|
||||
export const loader: LoaderFunction<{ results: any[]; query: string }> = async (request) => {
|
||||
// Assuming request.url is `/search?q=expo&page=2`
|
||||
const url = new URL(request!.url);
|
||||
const query = url.searchParams.get("q") ?? "";
|
||||
const page = Number(url.searchParams.get("page") ?? "1");
|
||||
|
||||
const results = await fetchSearchResults(query, page);
|
||||
return { results, query };
|
||||
};
|
||||
```
|
||||
|
||||
## Server-Side Secrets & Request Access
|
||||
|
||||
Loaders run on the server, so you can access secrets and server-only resources directly:
|
||||
|
||||
```tsx
|
||||
// app/dashboard.tsx
|
||||
import { type LoaderFunction } from "expo-server";
|
||||
|
||||
export const loader: LoaderFunction<{ balance: any; isAuthenticated: boolean }> = async (
|
||||
request,
|
||||
params,
|
||||
) => {
|
||||
const data = await fetch("https://api.stripe.com/v1/balance", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,
|
||||
},
|
||||
});
|
||||
|
||||
const sessionToken = request?.headers.get("cookie")?.match(/session=([^;]+)/)?.[1];
|
||||
|
||||
const balance = await data.json();
|
||||
return { balance, isAuthenticated: !!sessionToken };
|
||||
};
|
||||
```
|
||||
|
||||
The `request` object is available in server output mode. In static output mode, `request` is always `undefined`.
|
||||
|
||||
## Response Utilities
|
||||
|
||||
### Setting Response Headers
|
||||
|
||||
```tsx
|
||||
// app/products.tsx
|
||||
import { setResponseHeaders } from "expo-server";
|
||||
|
||||
export async function loader() {
|
||||
setResponseHeaders({
|
||||
"Cache-Control": "public, max-age=300",
|
||||
});
|
||||
|
||||
const products = await fetchProducts();
|
||||
return { products };
|
||||
}
|
||||
```
|
||||
|
||||
### Throwing HTTP Errors
|
||||
|
||||
```tsx
|
||||
// app/products/[id].tsx
|
||||
import { StatusError, type LoaderFunction } from "expo-server";
|
||||
|
||||
export const loader: LoaderFunction<{ product: Product }> = async (request, params) => {
|
||||
const id = params.id as string;
|
||||
const product = await fetchProduct(id);
|
||||
|
||||
if (!product) {
|
||||
throw new StatusError(404, "Product not found");
|
||||
}
|
||||
|
||||
return { product };
|
||||
};
|
||||
```
|
||||
|
||||
## Suspense & Error Boundaries
|
||||
|
||||
### Loading States with Suspense
|
||||
|
||||
`useLoaderData()` suspends during client-side navigation. Push it into a child component and wrap with `<Suspense>`:
|
||||
|
||||
```tsx
|
||||
// app/posts/index.tsx
|
||||
import { Suspense } from "react";
|
||||
import { useLoaderData } from "expo-router";
|
||||
import { ActivityIndicator, View, Text } from "react-native";
|
||||
|
||||
export async function loader() {
|
||||
const response = await fetch("https://api.example.com/posts");
|
||||
return { posts: await response.json() };
|
||||
}
|
||||
|
||||
function PostList() {
|
||||
const { posts } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<View>
|
||||
{posts.map((post) => (
|
||||
<Text key={post.id}>{post.title}</Text>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Posts() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
|
||||
<ActivityIndicator size="large" />
|
||||
</View>
|
||||
}
|
||||
>
|
||||
<PostList />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The `<Suspense>` boundary must be above the component calling `useLoaderData()`. On initial page load the data is already in the HTML, suspension only occurs during client-side navigation.
|
||||
|
||||
### Error Boundaries
|
||||
|
||||
```tsx
|
||||
// app/posts/[id].tsx
|
||||
export function ErrorBoundary({ error }: { error: Error }) {
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
|
||||
<Text>Error: {error.message}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
When a loader throws (including `StatusError`), the nearest `ErrorBoundary` catches it.
|
||||
|
||||
## Static vs Server Rendering
|
||||
|
||||
| | Server (`"server"`) | Static (`"static"`) |
|
||||
|---|---|---|
|
||||
| **When loader runs** | Every request (live) | At build time (`npx expo export`) |
|
||||
| **Data freshness** | Fresh on initial server request | Stale until next build |
|
||||
| **`request` object** | Full access | Not available |
|
||||
| **Hosting** | Node.js server (EAS Hosting) | Any static host |
|
||||
| **Use case** | Personalized/dynamic content | Marketing pages, blogs, docs |
|
||||
|
||||
**Choose server** when data changes frequently or content is personalized (cookies, auth, headers).
|
||||
|
||||
**Choose static** when content is the same for all users and changes infrequently.
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Loaders are web-only; use client-side fetching (React Query, fetch) for native
|
||||
- Loaders cannot be used in `_layout` files — only in route files
|
||||
- Use `LoaderFunction<T>` from `expo-server` to type loaders that use params
|
||||
- The request object is immutable — use optional chaining (`request?.headers`) as it may be `undefined` in static mode
|
||||
- Return only JSON-serializable values (no `Date`, `Map`, `Set`, class instances, functions)
|
||||
- Use non-prefixed `process.env` vars for secrets in loaders, not `EXPO_PUBLIC_` (which is embedded in the client bundle)
|
||||
- Use `StatusError` from `expo-server` for HTTP error responses
|
||||
- Use `setResponseHeaders` from `expo-server` to set headers
|
||||
- Export `ErrorBoundary` from route files to handle loader failures gracefully
|
||||
- Validate and sanitize user input (params, query strings) before using in database queries or API calls
|
||||
- Handle errors gracefully with try/catch; log server-side for debugging
|
||||
- Loader data is currently cached for the session. This is a known limitation that will be lifted in a future release
|
||||
355
.opencode/skills/tabatago-production-tracker/SKILL.md
Normal file
355
.opencode/skills/tabatago-production-tracker/SKILL.md
Normal file
@@ -0,0 +1,355 @@
|
||||
---
|
||||
name: tabatago-production-tracker
|
||||
description: >
|
||||
Inventaire complet des features et tracker de production pour l'application TabataGo.
|
||||
Utilise ce skill pour savoir si l'app est prête pour la production, connaître le statut
|
||||
de chaque feature, créer des tickets, mettre à jour la progression, ou générer un rapport
|
||||
de production-readiness. Déclenche ce skill dès que l'utilisateur mentionne : "prêt pour
|
||||
la prod", "statut des features", "qu'est-ce qu'il reste à faire", "production checklist",
|
||||
"feature inventory", "roadmap", ou demande si une feature spécifique de TabataGo est
|
||||
implementée. Ce skill est la source de vérité pour savoir ce qui est fait, ce qui est
|
||||
en cours, et ce qui bloque la mise en production.
|
||||
---
|
||||
|
||||
# TabataGo — Feature Inventory & Production Readiness
|
||||
|
||||
## Comment utiliser ce skill
|
||||
|
||||
1. **Rapport de statut** → Lire la section FEATURE INVENTORY et afficher un tableau de bord visuel
|
||||
2. **Mettre à jour une feature** → Modifier son statut dans FEATURE INVENTORY ci-dessous
|
||||
3. **Savoir si on est prêt pour la prod** → Lire la section PRODUCTION GATE CONDITIONS
|
||||
4. **Ajouter une feature** → Ajouter une entrée dans la bonne épopée
|
||||
|
||||
Statuts possibles :
|
||||
- `[ ]` — À faire
|
||||
- `[~]` — En cours
|
||||
- `[x]` — Fait et testé
|
||||
- `[!]` — Bloquant / Problème
|
||||
|
||||
---
|
||||
|
||||
## FEATURE INVENTORY
|
||||
|
||||
### ÉPOPÉE 1 — FUNNEL D'ACQUISITION (Onboarding)
|
||||
|
||||
> **Note :** L'onboarding est implémenté comme un funnel 6 écrans dans `app/onboarding.tsx` (1342 lignes) :
|
||||
> ProblemScreen → EmpathyScreen → SolutionScreen → WowScreen → PersonalizationScreen → PaywallScreen.
|
||||
> Les US-01 à US-04 sont mappées sur ces 6 écrans.
|
||||
|
||||
#### US-01 · Écran Problème (Pain Point)
|
||||
**Story :** En tant que nouvel utilisateur, je vois en premier l'écran qui met en évidence mon problème (manque de temps, pas de salle, manque de motivation) afin de me sentir compris avant qu'on me propose une solution.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-001 | Écran hero avec headline "Le sport sans excuses" | `[x]` | P0 | `ProblemScreen` avec animations clock + text |
|
||||
| F-002 | 3 pain points illustrés (pas de temps / pas d'équipement / pas de motivation) | `[x]` | P0 | `EmpathyScreen` — barriers grid avec icônes |
|
||||
| F-003 | CTA "Commencer" → navigation vers choix raison | `[x]` | P0 | `onNext()` entre chaque step |
|
||||
| F-004 | Animation d'entrée (FadeIn + SlideUp) | `[x]` | P1 | Spring + timing animations avec Animated API |
|
||||
|
||||
#### US-02 · Écran Raison (Why Screen)
|
||||
**Story :** En tant que nouvel utilisateur, je sélectionne ma raison principale de ne pas faire de sport afin que l'app me propose un parcours personnalisé.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-005 | Sélection parmi : Pas le temps / Ne sais pas comment / Pas d'équipement / Manque de motivation | `[x]` | P0 | `EmpathyScreen` — 4 barrier options avec multi-select (max 2) |
|
||||
| F-006 | Chaque option avec icône + label + sous-titre court | `[x]` | P0 | `barrierCard` avec icône + i18n labels |
|
||||
| F-007 | Enregistrement du choix dans le profil local | `[x]` | P0 | Via `useUserStore` + `completeOnboarding({barriers})` |
|
||||
| F-008 | Progression (barre step 1/4) | `[x]` | P1 | TOTAL_STEPS = 6, step indicator intégré |
|
||||
|
||||
#### US-03 · Écran Solution (Tabata Pitch)
|
||||
**Story :** En tant qu'utilisateur ayant identifié son problème, je découvre comment Tabata résout précisément MON problème afin d'être convaincu de continuer.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-009 | Affichage dynamique selon la raison choisie (US-02) | `[x]` | P0 | `SolutionScreen` — contexte basé sur barriers |
|
||||
| F-010 | Explication Tabata : 20s effort / 10s repos / 8 rounds / 4 min | `[x]` | P0 | Dans `SolutionScreen` |
|
||||
| F-011 | 3 avantages clés avec icônes (scientifiquement prouvé, sans matériel, court) | `[x]` | P0 | Avec icônes SF Symbols |
|
||||
| F-012 | CTA "Je veux essayer" → navigation vers config profil | `[x]` | P0 | `onNext()` → PersonalizationScreen |
|
||||
| F-013 | Animation des bénéfices (StaggerList) | `[x]` | P2 | `WowScreen` — stagger animation rows + CTA fadeIn |
|
||||
|
||||
#### US-04 · Configuration Profil
|
||||
**Story :** En tant que nouvel utilisateur convaincu, je renseigne mon prénom et mon objectif afin que l'app me personnalise l'expérience.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-014 | Champ prénom (TextField) avec validation non-vide | `[x]` | P0 | `PersonalizationScreen` — TextInput |
|
||||
| F-015 | Choix nombre de séances/semaine (1 à 6, picker ou slider) | `[x]` | P0 | `weeklyFrequency` picker |
|
||||
| F-016 | Choix objectif : Forme générale / Cardio / Énergie / Perte de poids / Renforcement | `[x]` | P0 | `fitnessGoal` selector |
|
||||
| F-017 | Enregistrement en AsyncStorage / profil local | `[x]` | P0 | Via Zustand `userStore` persist middleware |
|
||||
| F-018 | Validation et navigation vers Paywall | `[x]` | P0 | Navigates to `PaywallScreen` (step 6) then `router.push('/paywall')` |
|
||||
|
||||
---
|
||||
|
||||
### ÉPOPÉE 2 — PAYWALL & MONÉTISATION
|
||||
|
||||
#### US-05 · Paywall
|
||||
**Story :** En tant qu'utilisateur ayant complété le funnel, je vois une offre claire et honnête avant d'accéder au contenu complet, afin de prendre une décision éclairée.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-019 | Écran paywall avec structure obligatoire : célébration → valeur → pricing → CTA | `[x]` | P0 | `app/paywall.tsx` (442 lignes) — PREMIUM_FEATURES list + PlanCard + CTA |
|
||||
| F-020 | Intégration RevenueCat (iOS App Store + Google Play) | `[x]` | P0 | `react-native-purchases` + `usePurchases` hook + `purchases.ts` service |
|
||||
| F-021 | Prix dynamique selon la localisation (géolocalisation ou store) | `[x]` | P0 | RevenueCat gère automatiquement via store pricing |
|
||||
| F-022 | Offre annuelle (prix mensuel affiché) + option mensuelle | `[x]` | P0 | `hasAnnual: true, hasMonthly: true` — PlanCard component |
|
||||
| F-023 | Essai gratuit 7 jours (configurable RevenueCat) | `[x]` | P0 | Trial support intégré dans paywall |
|
||||
| F-024 | CTA "Démarrer l'essai gratuit" (PrimaryButton full-width) | `[x]` | P0 | NativeButton CTA |
|
||||
| F-025 | Bouton fermeture toujours visible (accès mode free) | `[x]` | P0 | `closeButton` positionné avec `top: insets.top` |
|
||||
| F-026 | Lien "Restaurer les achats" | `[x]` | P0 | `handleRestore` → `restorePurchases()` |
|
||||
| F-027 | Lien CGU + Politique de confidentialité | `[x]` | P0 | Privacy + Terms links dans paywall, `app/terms.tsx` créé |
|
||||
| F-028 | Gestion état Premium (hook usePremium) | `[x]` | P0 | `usePurchases` hook avec state management |
|
||||
| F-029 | Zéro dark pattern (pas de compte à rebours fictif) | `[x]` | P0 | Aucun countdown/timer/urgency détecté |
|
||||
|
||||
---
|
||||
|
||||
### ÉPOPÉE 3 — ONBOARDING CONTENU (post-paywall)
|
||||
|
||||
#### US-06 · Découverte des 3 Sections
|
||||
**Story :** En tant que nouvel abonné, je découvre les 3 grandes sections de l'app afin de comprendre comment le contenu est organisé.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-030 | Écran onboarding 3 sections avec swipe/pagination | `[x]` | P0 | `app/discovery.tsx` — 3 SectionCards animées |
|
||||
| F-031 | Section "Haut du corps" — illustration + description | `[x]` | P0 | Discovery screen — Upper Body avec icône + description |
|
||||
| F-032 | Section "Bas du corps" — illustration + description | `[x]` | P0 | Discovery screen — Lower Body avec icône + description |
|
||||
| F-033 | Section "Corps complet" — illustration + description | `[x]` | P0 | Discovery screen — Full Body avec icône + description |
|
||||
| F-034 | CTA "Explorer les programmes" → Tab Programmes | `[x]` | P0 | CTA → `router.replace('/(tabs)')` |
|
||||
|
||||
---
|
||||
|
||||
### ÉPOPÉE 4 — PROGRAMMES & CATALOGUE
|
||||
|
||||
#### US-07 · Liste des Programmes
|
||||
**Story :** En tant qu'utilisateur connecté, je consulte le catalogue de programmes organisé par section et niveau afin de choisir celui qui correspond à mon niveau.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-035 | Écran Programmes avec onglets : Haut / Bas / Corps complet | `[~]` | P0 | Home tab a `BodyZoneCard` — pas d'onglets dédiés mais navigation par zone |
|
||||
| F-036 | CardProgram pour chaque programme (thumbnail, nom, level, durée, nb séances) | `[x]` | P0 | WorkoutCard + programme cards dans home |
|
||||
| F-037 | Filtre par niveau : Débutant / Intermédiaire / Avancé | `[ ]` | P0 | **MANQUANT** — Pas de filtres niveau |
|
||||
| F-038 | Progression visible sur chaque card (X/12 séances) | `[x]` | P0 | Progress tracking dans `programStore` + `workoutProgramStore` |
|
||||
| F-039 | Programme "recommandé" selon objectif choisi au profil | `[ ]` | P1 | Pas implémenté |
|
||||
| F-040 | Lock icon sur contenu premium (mode free) | `[x]` | P0 | `sessionLocked` dans `program/[id].tsx` |
|
||||
|
||||
#### US-08 · Détail d'un Programme
|
||||
**Story :** En tant qu'utilisateur, je consulte le détail d'un programme afin de savoir ce qui m'attend avant de commencer.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-041 | Écran détail : header image, nom, niveau, durée totale | `[x]` | P0 | `app/program/[id].tsx` (261 lignes) |
|
||||
| F-042 | Description du programme et objectifs | `[x]` | P0 | Programme detail avec description |
|
||||
| F-043 | Liste ordonnée des séances avec statut (fait / à venir) | `[x]` | P0 | `week.sessions.map` avec `isCompleted` check |
|
||||
| F-044 | CTA "Commencer la prochaine séance" | `[x]` | P0 | `getCurrentSession` → `router.push(/workout/...)` |
|
||||
| F-045 | Progression globale du programme (progress bar) | `[x]` | P0 | Progress tracking intégré |
|
||||
|
||||
#### US-09 · Structure des Programmes
|
||||
**Story :** En tant que product owner, chaque section dispose de 3 niveaux avec des séances progressives.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-046 | Haut du corps — Débutant (8–12 séances) | `[x]` | P0 | `src/shared/data/tabata/debutant.ts` |
|
||||
| F-047 | Haut du corps — Intermédiaire (8–12 séances) | `[x]` | P0 | `src/shared/data/tabata/intermediaire.ts` |
|
||||
| F-048 | Haut du corps — Avancé (8–12 séances) | `[x]` | P1 | `src/shared/data/tabata/avance.ts` |
|
||||
| F-049 | Bas du corps — Débutant (8–12 séances) | `[x]` | P0 | Dans les data files tabata |
|
||||
| F-050 | Bas du corps — Intermédiaire (8–12 séances) | `[x]` | P0 | Dans les data files tabata |
|
||||
| F-051 | Bas du corps — Avancé (8–12 séances) | `[x]` | P1 | Dans les data files tabata |
|
||||
| F-052 | Corps complet — Débutant (8–12 séances) | `[x]` | P0 | Dans les data files tabata |
|
||||
| F-053 | Corps complet — Intermédiaire (8–12 séances) | `[x]` | P0 | Dans les data files tabata |
|
||||
| F-054 | Corps complet — Avancé (8–12 séances) | `[x]` | P1 | Dans les data files tabata |
|
||||
|
||||
---
|
||||
|
||||
### ÉPOPÉE 5 — SÉANCE TABATA (Core Feature)
|
||||
|
||||
#### US-10 · Phase d'Échauffement
|
||||
**Story :** En tant qu'utilisateur sur le point de commencer une séance, je passe par une phase d'échauffement guidée avec les exercices expliqués visuellement afin de me préparer sans risque de blessure.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-055 | Écran échauffement avec liste des exercices de la séance | `[x]` | P0 | `WarmupOverlay` component |
|
||||
| F-056 | Fiche exercice : nom, muscles ciblés, description gestuelle | `[x]` | P0 | Exercise display dans warmup |
|
||||
| F-057 | Vidéo de démonstration en boucle (coach IA) par exercice | `[!]` | P0 | **BLOQUANT** — Code prêt (`VideoPlayer`) mais vidéos non créées |
|
||||
| F-058 | Conseil kiné (CardTip) pour chaque exercice | `[x]` | P0 | `TabataTip` component |
|
||||
| F-059 | Minuteur échauffement (ex: 5 min) avec CTA skip | `[x]` | P1 | WARMUP phase dans useTabataTimer |
|
||||
| F-060 | Navigation entre les exercices de l'échauffement (swipe ou bouton) | `[x]` | P0 | Dans WarmupOverlay |
|
||||
| F-061 | CTA "Je suis prêt(e)" → lancement séance | `[x]` | P0 | Transition WARMUP → WORK |
|
||||
|
||||
#### US-11 · Séance Tabata (Timer)
|
||||
**Story :** En tant qu'utilisateur en séance, je vois clairement la phase (effort / repos), le bloc et le round en cours afin de suivre ma séance sans regarder mon téléphone de près.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-062 | Timer géant (≥80px) : vert (effort 20s) / slate (repos 10s) / rouge (<10s) | `[x]` | P0 | `TimerRing` component avec PHASE_COLORS |
|
||||
| F-063 | Moteur de séquence : 3 blocs × 2 exercices × 3 rounds, alternance correcte | `[x]` | P0 | `useTabataTimer` — phases WARMUP/WORK/REST/INTER_BLOCK_REST/COOLDOWN/COMPLETE |
|
||||
| F-064 | Vidéo exercice plein écran en boucle (phase effort) | `[!]` | P0 | **BLOQUANT** — Code prêt mais vidéos non créées |
|
||||
| F-065 | Fond navy-800 uni (phase repos court 10s) | `[x]` | P0 | `TABATA_PHASE_COLORS.REST: PHASE.REST` |
|
||||
| F-066 | Écran repos long entre blocs (~60s) avec indication "PROCHAIN BLOC" | `[x]` | P0 | `INTER_BLOCK_REST` phase avec `AMBER[500]` |
|
||||
| F-067 | Indicateur de position : Bloc X/3 + Round Y/3 + Exercice A ou B | `[x]` | P0 | `BlockIndicator` + `RoundIndicator` + exercise name |
|
||||
| F-068 | Nom de l'exercice en cours en overlay | `[x]` | P0 | `ExerciseDisplay` component |
|
||||
| F-069 | Aperçu prochain exercice pendant le repos (thumbnail + nom) | `[x]` | P0 | Next preview dans player |
|
||||
| F-070 | Progress bar séance globale (sur les 18 intervalles) | `[x]` | P0 | `BurnBar` + `StatsOverlay` |
|
||||
| F-071 | Son (bip effort, bip repos, countdown 3-2-1, gong repos long) | `[x]` | P0 | `useAudio` + assets: beep_short, beep_double, beep_long, count_1/2/3, bell, fanfare |
|
||||
| F-072 | Toggle mute accessible en 1 tap | `[x]` | P0 | Mute toggle dans PlayerControls, volume 0 sur musique + SFX |
|
||||
| F-073 | Vibration haptique aux transitions effort/repos et fin de bloc | `[x]` | P1 | `useHaptics` intégré dans le player |
|
||||
| F-074 | Bouton pause (centré, accessible) | `[x]` | P0 | Dans `PlayerControls` — pause/resume |
|
||||
| F-075 | Bouton quitter (DangerButton, confirmation requise) | `[x]` | P0 | `Alert.alert` confirmation dans PlayerControls `handleQuit` |
|
||||
| F-076 | Pas de mise en veille écran pendant la séance (keepAwake) | `[x]` | P0 | `useKeepAwake()` dans les deux player screens |
|
||||
| F-077 | Gestion interruption (appel téléphonique → pause auto) | `[ ]` | P1 | Non implémenté |
|
||||
|
||||
#### US-12 · Fin de Séance
|
||||
**Story :** En tant qu'utilisateur ayant complété une séance, je vois un écran de célébration avec mes stats afin de me sentir accompli et motivé à revenir.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-078 | Écran célébration (BounceAnimation + serif italic) | `[x]` | P0 | `app/complete/[id].tsx` (712 lignes) — Animated celebrations |
|
||||
| F-079 | Stats : calories estimées, durée réelle, blocs complétés (X/3) | `[x]` | P0 | Calories + duration displayed |
|
||||
| F-080 | Marquage séance comme "completée" dans le programme | `[x]` | P0 | Via `activityStore.addWorkoutResult` |
|
||||
| F-081 | Feedback ressenti (FeedbackButton : Dur / Parfait / Trop facile) | `[x]` | P0 | 3 feedback chips dans `app/complete/[id].tsx` |
|
||||
| F-082 | Mise à jour streak hebdomadaire | `[x]` | P0 | `activityStore` streak calculation |
|
||||
| F-083 | CTA "Prochaine séance" + CTA "Retour accueil" | `[x]` | P0 | Next workout + return home CTAs |
|
||||
| F-084 | Conseil kiné post-séance (étirements recommandés) | `[ ]` | P1 | Non implémenté |
|
||||
|
||||
---
|
||||
|
||||
### ÉPOPÉE 6 — ACCUEIL & DASHBOARD
|
||||
|
||||
#### US-13 · Écran Accueil
|
||||
**Story :** En tant qu'utilisateur régulier, je vois en un coup d'œil mon prochain entraînement et ma progression cette semaine afin de rester motivé.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-085 | Greeting personnalisé "Bonjour [Prénom]" | `[x]` | P0 | `app/(tabs)/index.tsx` (463 lignes) — greeting avec nom |
|
||||
| F-086 | CardAccent "Prochaine séance" avec nom programme + CTA | `[x]` | P0 | `ContinueSessionCard` component |
|
||||
| F-087 | Streak hebdomadaire (StreakDot × 7 jours) | `[x]` | P0 | Streak display intégré |
|
||||
| F-088 | Stat rapide : séances faites cette semaine vs objectif | `[x]` | P0 | `QuickStats` component avec weekly stats |
|
||||
| F-089 | Section "Reprendre là où j'en suis" | `[x]` | P0 | `ContinueSessionCard` — reprise de programme |
|
||||
| F-090 | Notifications de rappel configurables | `[~]` | P1 | `expo-notifications` installé, `useNotifications` hook existe, toggle dans profile |
|
||||
|
||||
---
|
||||
|
||||
### ÉPOPÉE 7 — PROGRESSION & STATISTIQUES
|
||||
|
||||
#### US-14 · Écran Progression
|
||||
**Story :** En tant qu'utilisateur fidèle, je consulte mon historique et mes statistiques afin de voir ma progression concrète dans le temps.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-091 | Historique des séances (liste chronologique) | `[x]` | P0 | `app/(tabs)/activity.tsx` (581 lignes) — history list |
|
||||
| F-092 | Graphique hebdomadaire (barres ou ligne) | `[x]` | P1 | `WeeklyBar` component |
|
||||
| F-093 | Totaux : séances totales, minutes totales, calories totales | `[x]` | P0 | `StatCard` × 4 avec totaux |
|
||||
| F-094 | Record streak (max consécutif) | `[x]` | P1 | `activityStore.streak.longest` |
|
||||
| F-095 | Progression par programme (% complété) | `[~]` | P0 | Progression dans `programStore` mais pas affichée dans activity tab |
|
||||
|
||||
---
|
||||
|
||||
### ÉPOPÉE 8 — PROFIL & PARAMÈTRES
|
||||
|
||||
#### US-15 · Écran Profil
|
||||
**Story :** En tant qu'utilisateur, je peux modifier mon profil et gérer mon abonnement afin de garder le contrôle sur mon expérience.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-096 | Édition prénom, objectif, nb séances/semaine | `[~]` | P0 | Personalization section exists mais **édition directe non confirmée** |
|
||||
| F-097 | Statut abonnement (Free / Premium) avec date renouvellement | `[x]` | P0 | Subscription status affiché |
|
||||
| F-098 | Bouton gérer/annuler abonnement (lien store) | `[~]` | P0 | À vérifier — manage subscription pas confirmé |
|
||||
| F-099 | Toggle notifications | `[x]` | P1 | `NativeLabeledRow` avec daily reminders toggle |
|
||||
| F-100 | Liens : CGU, Politique de confidentialité, Contact support | `[x]` | P0 | Terms + Privacy dans paywall, CGU row dans profile, contact ✓ |
|
||||
| F-101 | Version de l'app | `[x]` | P0 | Version affichée dans about section |
|
||||
|
||||
---
|
||||
|
||||
### ÉPOPÉE 9 — INFRASTRUCTURE & QUALITÉ
|
||||
|
||||
#### US-16 · Architecture Technique
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-102 | React Native + Expo SDK setup | `[x]` | P0 | Expo SDK 54, React Native 0.81.5, React 19.1 |
|
||||
| F-103 | Navigation (Expo Router ou React Navigation) | `[x]` | P0 | Expo Router v6 avec tabs (index/activity/profile) |
|
||||
| F-104 | Design tokens implémentés (design-tokens.ts) | `[x]` | P0 | `colors.ts`, `typography.ts`, `spacing.ts`, `borderRadius.ts`, `animations.ts`, `ThemeContext` |
|
||||
| F-105 | State management (Zustand ou Context) | `[x]` | P0 | Zustand v5 — 6 stores: user, activity, player, program, tabataProgram, workoutProgram |
|
||||
| F-106 | Persistence locale (AsyncStorage) | `[x]` | P0 | AsyncStorage + React Query persist |
|
||||
| F-107 | RevenueCat SDK intégré (iOS + Android) | `[x]` | P0 | `react-native-purchases` v9 + service + hook + tests |
|
||||
| F-108 | Gestion erreurs globale (ErrorBoundary) | `[x]` | P0 | ErrorBoundary class dans `app/_layout.tsx` avec retry |
|
||||
| F-109 | Analytics (Mixpanel ou PostHog) — events funnel + rétention | `[x]` | P1 | PostHog + session replay, `track()` calls dans onboarding/player/complete |
|
||||
|
||||
#### US-17 · Contenu & Assets
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-110 | Vidéos coach IA — Haut du corps (tous exercices) | `[ ]` | P0 | **MANQUANT** — Vidéos à créer |
|
||||
| F-111 | Vidéos coach IA — Bas du corps (tous exercices) | `[ ]` | P0 | **MANQUANT** — Vidéos à créer |
|
||||
| F-112 | Vidéos coach IA — Corps complet (tous exercices) | `[ ]` | P0 | **MANQUANT** — Vidéos à créer |
|
||||
| F-113 | Sons : bip effort, bip repos, countdown, gong repos long, fanfare fin | `[x]` | P0 | 10 fichiers audio: beep_short, beep_double, beep_long, count_1/2/3, bell, fanfare, countdown.wav, complete.wav, phase-start.wav + 2 musiques |
|
||||
| F-114 | Illustrations sections (onboarding) | `[~]` | P1 | Discovery screen utilise des icônes SF Symbols, pas d'illustrations custom |
|
||||
| F-115 | Icônes app (toutes tailles iOS + Android) | `[~]` | P0 | Icons existent (icon.png, android-icon-*, favicon) mais **contiennent encore des logos React placeholder** |
|
||||
| F-116 | Splash screen | `[x]` | P0 | `expo-splash-screen` + splash-icon.png |
|
||||
|
||||
#### US-18 · Tests & QA
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-117 | Tests unitaires moteur de séquence tabata (3 blocs × 2 exercices × 3 rounds) | `[x]` | P0 | `tabataProgramStore.test.ts` + `playerStore.test.ts` |
|
||||
| F-118 | Tests unitaires timer (20s effort / 10s repos / repos long inter-blocs) | `[x]` | P0 | `useTimer.test.ts` + `useTimer.integration.test.ts` |
|
||||
| F-119 | Tests unitaires RevenueCat (purchase flow) | `[x]` | P0 | `usePurchases.test.ts` + `purchases.test.ts` |
|
||||
| F-120 | Tests E2E funnel complet (Detox ou Maestro) | `[ ]` | P1 | Non implémenté |
|
||||
| F-121 | Test sur iPhone SE (petit écran) | `[ ]` | P0 | Non vérifié |
|
||||
| F-122 | Test sur Android (Samsung Galaxy S-series) | `[ ]` | P0 | Non vérifié |
|
||||
| F-123 | Test mode avion (gestion offline) | `[ ]` | P1 | Non vérifié |
|
||||
|
||||
#### US-19 · Store & Publication
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-124 | Screenshots App Store (6.5" + 5.5" + iPad) | `[ ]` | P0 | Non créé |
|
||||
| F-125 | Screenshots Google Play | `[ ]` | P0 | Non créé |
|
||||
| F-126 | Description App Store (FR + EN) | `[ ]` | P0 | Non rédigé |
|
||||
| F-127 | Description Google Play (FR + EN) | `[ ]` | P0 | Non rédigé |
|
||||
| F-128 | Politique de confidentialité publiée (URL) | `[~]` | P0 | `app/privacy.tsx` existe mais pas publié en URL externe |
|
||||
| F-129 | CGU publiées (URL) | `[~]` | P0 | `app/terms.tsx` existe in-app, URL externe non publiée |
|
||||
| F-130 | Soumission TestFlight (iOS) | `[ ]` | P0 | Non soumis |
|
||||
| F-131 | Soumission Google Play Beta (Android) | `[ ]` | P0 | Non soumis |
|
||||
| F-132 | Review Apple (délai ~2-3 jours) | `[ ]` | P0 | Non soumis |
|
||||
| F-133 | Review Google Play (délai ~1-3 jours) | `[ ]` | P0 | Non soumis |
|
||||
|
||||
---
|
||||
|
||||
## PRODUCTION GATE CONDITIONS
|
||||
|
||||
Pour que TabataGo soit considéré **prêt pour la production**, les conditions suivantes doivent être remplies :
|
||||
|
||||
### BLOQUANTS ABSOLUS (toutes les P0 doivent être `[x]`)
|
||||
|
||||
- [x] **FUNNEL COMPLET** : F-001 à F-018 tous `[x]`
|
||||
- [x] **PAYWALL FONCTIONNEL** : F-019 à F-028 — Tous complétés (CGU + privacy links ajoutés)
|
||||
- [x] **AU MOINS 1 PROGRAMME COMPLET** par section (Débutant minimum) : F-046, F-049, F-052 `[x]`
|
||||
- [x] **SÉANCE TABATA CORE** : F-062 à F-076 — Mute toggle + quit confirmation ajoutés
|
||||
- [x] **FIN DE SÉANCE** : F-078 à F-083 — Feedback buttons ajoutés
|
||||
- [ ] **LÉGAL** : F-128 partiellement, F-129 manquant, F-130/131 non soumis
|
||||
- [ ] **CONTENU** : F-110 à F-112 vidéos non créées
|
||||
|
||||
### REQUIS AVANT SCALE (peuvent être post-lancement v1.1)
|
||||
|
||||
- Notifications (F-090) — en cours
|
||||
- Analytics (F-109) — fait (PostHog)
|
||||
- Tests E2E (F-120) — non fait
|
||||
- Programmes Avancés (F-048, F-051, F-054) — fait
|
||||
- Gestion offline (F-123) — non vérifié
|
||||
|
||||
---
|
||||
|
||||
## COMMENT AFFICHER LE STATUT
|
||||
|
||||
Quand l'utilisateur demande le statut de production, générer un tableau de bord avec :
|
||||
|
||||
1. **Score global** : `[x]` / total features P0
|
||||
2. **Score par épopée** : tableau avec % de complétion
|
||||
3. **Features bloquantes** : liste des P0 encore à `[ ]` ou `[!]`
|
||||
4. **Verdict** : PRET / PRESQUE PRET (X bloquants) / NON PRET (X bloquants)
|
||||
|
||||
---
|
||||
|
||||
## NOTES DE MISE À JOUR
|
||||
|
||||
| Date | Changement | Auteur |
|
||||
|------|-----------|--------|
|
||||
| 2026-04-18 | Création initiale de l'inventaire (130 features) | TabataGo Team |
|
||||
| 2026-04-18 | Mise à jour structure séance : 3 blocs × 2 exercices × 3 rounds (133 features) | TabataGo Team |
|
||||
| 2026-04-18 | Audit complet code vs tracker — 85+ features marquées done, gaps identifiés | OpenCode Audit |
|
||||
| 2026-04-18 | Implémentation : ErrorBoundary, feedback buttons, mute/quit, legal links, discovery screen, i18n DE/ES | OpenCode |
|
||||
| 2026-04-18 | Mise à jour tracker : F-027/030-034/072/075/081/100/108 → [x], groupes paywall/séance/fin → [x] | OpenCode |
|
||||
189
AGENTS.md
189
AGENTS.md
@@ -305,13 +305,39 @@ const queryClient = new QueryClient({
|
||||
# TypeScript
|
||||
npx tsc --noEmit
|
||||
|
||||
# Run tests
|
||||
# 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
|
||||
@@ -360,3 +386,164 @@ COMPLETE: '#30D158' // Green
|
||||
---
|
||||
|
||||
*Last updated: March 14, 2026*
|
||||
|
||||
# context-mode — MANDATORY routing rules
|
||||
|
||||
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.
|
||||
|
||||
## BLOCKED commands — do NOT attempt these
|
||||
|
||||
### 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
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
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 |
|
||||
|------|---------------------|
|
||||
| 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` |
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
|
||||
108
CLAUDE.md
108
CLAUDE.md
@@ -192,11 +192,13 @@ COMPLETE: '#30D158' // Green
|
||||
|
||||
## 🚀 Commands
|
||||
|
||||
Use `rtk` (token-optimized CLI proxy) for all non-interactive commands.
|
||||
|
||||
```bash
|
||||
npx expo start # Development
|
||||
npx expo start # Development (interactive, no rtk)
|
||||
npx expo start --tunnel # If network issues
|
||||
npx expo start --clear # Clear cache
|
||||
npx tsc --noEmit # Type check
|
||||
rtk tsc --noEmit # Type check (grouped errors)
|
||||
eas build --profile dev # Dev build
|
||||
```
|
||||
|
||||
@@ -211,3 +213,105 @@ Voir `.claude/skills/` pour les guides spécialisés.
|
||||
*Document updated: February 18, 2026*
|
||||
*Version: 2.0*
|
||||
*Project: TabataFit — Apple Fitness+ for Tabata*
|
||||
|
||||
<!-- 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
|
||||
```
|
||||
|
||||
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 |
|
||||
|------|---------------------|
|
||||
| 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` |
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
|
||||
75
README.md
75
README.md
@@ -1,75 +0,0 @@
|
||||
# TabataFit
|
||||
|
||||
> **Apple Fitness+ for Tabata** — The Premium HIIT Experience
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## Vision
|
||||
|
||||
TabataFit est l'Apple Fitness+ du Tabata. Une expérience premium, video-first, guidée par des coachs, qui transforme 4 minutes d'exercice en une expérience de fitness immersive.
|
||||
|
||||
## Features
|
||||
|
||||
- 🎬 **Video-led workouts** — HD video demonstrations by professional trainers
|
||||
- ⏱️ **Smart timer** — Tabata timer with work/rest phases
|
||||
- 🔥 **Burn Bar** — Compare your calories with the community
|
||||
- 📊 **Activity tracking** — Streaks, stats, and trends
|
||||
- 🎵 **Music sync** — Curated playlists for each workout
|
||||
- ⌚ **Apple Watch** — Heart rate and activity rings
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: Expo SDK 52
|
||||
- **Navigation**: Expo Router v3
|
||||
- **State**: Zustand
|
||||
- **Video**: expo-av (HLS streaming)
|
||||
- **Payments**: RevenueCat
|
||||
- **Analytics**: PostHog
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start development server
|
||||
npx expo start
|
||||
|
||||
# Run on device (scan QR with Expo Go)
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [PRD v2.0](./TabataFit_PRD_v2.0.md) | Product Requirements |
|
||||
| [PDD v2.0](./TabataFit_PDD_v2.0.md) | Product Design |
|
||||
| [BDSD v2.0](./TabataFit_BDSD_v2.0.md) | Brand Design |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
features/
|
||||
home/ # Home tab
|
||||
workouts/ # Workouts browser
|
||||
player/ # Video player + timer
|
||||
activity/ # Stats & history
|
||||
browse/ # Filters & trainers
|
||||
profile/ # User settings
|
||||
shared/
|
||||
components/ # Reusable UI
|
||||
hooks/ # Custom hooks
|
||||
constants/ # Design tokens
|
||||
app/ # Expo Router routes
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Proprietary — All rights reserved.
|
||||
|
||||
---
|
||||
|
||||
Built with ❤️ for HIIT lovers
|
||||
@@ -1,182 +0,0 @@
|
||||
# Supabase Music Storage Setup
|
||||
|
||||
This guide walks you through setting up the Supabase Storage bucket for music tracks in TabataFit.
|
||||
|
||||
## Overview
|
||||
|
||||
TabataFit loads background music from Supabase Storage based on workout `musicVibe` values. The music service organizes tracks by vibe folders.
|
||||
|
||||
## Step 1: Create the Storage Bucket
|
||||
|
||||
1. Go to your Supabase Dashboard → Storage
|
||||
2. Click **New bucket**
|
||||
3. Name: `music`
|
||||
4. Enable **Public access** (tracks are streamed to authenticated users)
|
||||
5. Click **Create bucket**
|
||||
|
||||
## Step 2: Set Up Folder Structure
|
||||
|
||||
Create folders for each music vibe:
|
||||
|
||||
```
|
||||
music/
|
||||
├── electronic/
|
||||
├── hip-hop/
|
||||
├── pop/
|
||||
├── rock/
|
||||
└── chill/
|
||||
```
|
||||
|
||||
### Via Dashboard:
|
||||
1. Open the `music` bucket
|
||||
2. Click **New folder** for each vibe
|
||||
3. Name folders exactly as the `MusicVibe` type values
|
||||
|
||||
### Via SQL (optional):
|
||||
```sql
|
||||
-- Storage folders are virtual in Supabase
|
||||
-- Just upload files with path prefixes like "electronic/track.mp3"
|
||||
```
|
||||
|
||||
## Step 3: Upload Music Tracks
|
||||
|
||||
### Supported Formats
|
||||
- MP3 (recommended)
|
||||
- M4A (AAC)
|
||||
- OGG (if needed)
|
||||
|
||||
### File Naming Convention
|
||||
Name files with artist and title for best results:
|
||||
```
|
||||
Artist Name - Track Title.mp3
|
||||
```
|
||||
|
||||
Examples:
|
||||
```
|
||||
Neon Dreams - Energy Pulse.mp3
|
||||
Urban Flow - Street Heat.mp3
|
||||
The Popstars - Summer Energy.mp3
|
||||
```
|
||||
|
||||
### Upload via Dashboard:
|
||||
1. Open a vibe folder (e.g., `electronic/`)
|
||||
2. Click **Upload files**
|
||||
3. Select your audio files
|
||||
4. Ensure the path shows `music/electronic/`
|
||||
|
||||
### Upload via CLI:
|
||||
```bash
|
||||
# Install Supabase CLI if not already installed
|
||||
npm install -g supabase
|
||||
|
||||
# Login
|
||||
supabase login
|
||||
|
||||
# Link your project
|
||||
supabase link --project-ref your-project-ref
|
||||
|
||||
# Upload tracks
|
||||
supabase storage upload music/electronic/ "path/to/your/tracks/*.mp3"
|
||||
```
|
||||
|
||||
## Step 4: Configure Storage Policies
|
||||
|
||||
### RLS Policy for Authenticated Users
|
||||
|
||||
Go to Supabase Dashboard → Storage → Policies → `music` bucket
|
||||
|
||||
Add these policies:
|
||||
|
||||
#### 1. Select Policy (Read Access)
|
||||
```sql
|
||||
CREATE POLICY "Allow authenticated users to read music"
|
||||
ON storage.objects FOR SELECT
|
||||
TO authenticated
|
||||
USING (bucket_id = 'music');
|
||||
```
|
||||
|
||||
#### 2. Insert Policy (Admin Upload Only)
|
||||
```sql
|
||||
CREATE POLICY "Allow admin uploads"
|
||||
ON storage.objects FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (
|
||||
bucket_id = 'music'
|
||||
AND (auth.jwt() ->> 'role') = 'admin'
|
||||
);
|
||||
```
|
||||
|
||||
### Via Dashboard UI:
|
||||
1. Go to **Storage** → **Policies**
|
||||
2. Under `music` bucket, click **New policy**
|
||||
3. Select **For SELECT** (get)
|
||||
4. Allowed operation: **Authenticated**
|
||||
5. Policy definition: `true` (or custom check)
|
||||
|
||||
## Step 5: Test the Setup
|
||||
|
||||
1. Start your Expo app: `npx expo start`
|
||||
2. Start a workout with music enabled
|
||||
3. Check console logs for:
|
||||
```
|
||||
[Music] Loaded X tracks for vibe: electronic
|
||||
```
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
**No tracks loading:**
|
||||
- Check Supabase credentials in `.env`
|
||||
- Verify folder names match `MusicVibe` type exactly
|
||||
- Check Storage RLS policies allow read access
|
||||
|
||||
**Tracks not playing:**
|
||||
- Ensure files are accessible (try signed URL in browser)
|
||||
- Check audio format is supported by expo-av
|
||||
- Verify CORS settings in Supabase
|
||||
|
||||
**CORS errors:**
|
||||
Add to Supabase Dashboard → Settings → API → CORS:
|
||||
```
|
||||
app://*
|
||||
http://localhost:8081
|
||||
```
|
||||
|
||||
## Step 6: Environment Variables
|
||||
|
||||
Ensure your `.env` file has:
|
||||
|
||||
```env
|
||||
EXPO_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
|
||||
EXPO_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
|
||||
```
|
||||
|
||||
## Music Vibes Reference
|
||||
|
||||
The app supports these vibe categories:
|
||||
|
||||
| Vibe | Description | Typical BPM |
|
||||
|------|-------------|-------------|
|
||||
| `electronic` | EDM, House, Techno | 128-140 |
|
||||
| `hip-hop` | Rap, Trap, Beats | 85-110 |
|
||||
| `pop` | Pop hits, Dance-pop | 100-130 |
|
||||
| `rock` | Rock, Alternative | 120-160 |
|
||||
| `chill` | Lo-fi, Ambient, Downtempo | 60-90 |
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Track Duration**: 3-5 minutes ideal for Tabata workouts
|
||||
2. **File Size**: Keep under 10MB for faster loading
|
||||
3. **Bitrate**: 128-192kbps MP3 for good quality/size balance
|
||||
4. **Loudness**: Normalize tracks to similar levels (-14 LUFS)
|
||||
5. **Metadata**: Include ID3 tags for artist/title info
|
||||
|
||||
## Alternative: Local Development
|
||||
|
||||
If Supabase is not configured, the app uses mock tracks automatically. To force mock data, temporarily set invalid Supabase credentials.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [ ] Upload initial track library (5-10 tracks per vibe)
|
||||
- [ ] Test on physical device
|
||||
- [ ] Consider CDN for production scale
|
||||
- [ ] Implement track favoriting/personal playlists
|
||||
@@ -1,228 +0,0 @@
|
||||
# TabataFit Supabase Integration
|
||||
|
||||
This document explains how to set up and use the Supabase backend for TabataFit.
|
||||
|
||||
## Overview
|
||||
|
||||
TabataFit now uses Supabase as its backend for:
|
||||
- **Database**: Storing workouts, trainers, collections, programs, and achievements
|
||||
- **Storage**: Managing video files, thumbnails, and trainer avatars
|
||||
- **Authentication**: Admin dashboard access control
|
||||
- **Real-time**: Future support for live features
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Create Supabase Project
|
||||
|
||||
1. Go to [Supabase Dashboard](https://app.supabase.com)
|
||||
2. Create a new project
|
||||
3. Note your project URL and anon key
|
||||
|
||||
### 2. Configure Environment Variables
|
||||
|
||||
Create a `.env` file in the project root:
|
||||
|
||||
```bash
|
||||
EXPO_PUBLIC_SUPABASE_URL=your_supabase_project_url
|
||||
EXPO_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
|
||||
```
|
||||
|
||||
### 3. Run Database Migrations
|
||||
|
||||
1. Go to your Supabase project's SQL Editor
|
||||
2. Open `supabase/migrations/001_initial_schema.sql`
|
||||
3. Run the entire script
|
||||
|
||||
This creates:
|
||||
- All necessary tables (workouts, trainers, collections, programs, achievements)
|
||||
- Storage buckets (videos, thumbnails, avatars)
|
||||
- Row Level Security policies
|
||||
- Triggers for auto-updating timestamps
|
||||
|
||||
### 4. Seed the Database
|
||||
|
||||
Run the seed script to populate your database with initial data:
|
||||
|
||||
```bash
|
||||
npx ts-node supabase/seed.ts
|
||||
```
|
||||
|
||||
This will import all 50 workouts, 5 trainers, 6 collections, 3 programs, and 8 achievements.
|
||||
|
||||
### 5. Set Up Admin User
|
||||
|
||||
1. Go to Supabase Dashboard → Authentication → Users
|
||||
2. Create a new user with email/password
|
||||
3. Go to SQL Editor and run:
|
||||
|
||||
```sql
|
||||
INSERT INTO admin_users (id, email, role)
|
||||
VALUES ('USER_UUID_HERE', 'admin@example.com', 'admin');
|
||||
```
|
||||
|
||||
Replace `USER_UUID_HERE` with the actual user UUID from step 2.
|
||||
|
||||
### 6. Configure Storage
|
||||
|
||||
In Supabase Dashboard → Storage:
|
||||
|
||||
1. Verify buckets exist: `videos`, `thumbnails`, `avatars`
|
||||
2. Set bucket privacy to public for all three
|
||||
3. Configure CORS if needed for your domain
|
||||
|
||||
## Architecture
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────┐ ┌─────────────────┐
|
||||
│ React Native │────▶│ Supabase │────▶│ PostgreSQL │
|
||||
│ Client │ │ Client │ │ Database │
|
||||
└─────────────────┘ └──────────────┘ └─────────────────┘
|
||||
│
|
||||
│ ┌──────────────┐
|
||||
└──────────────▶│ Admin │
|
||||
│ Dashboard │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
### Key Components
|
||||
|
||||
1. **Supabase Client** (`src/shared/supabase/`)
|
||||
- `client.ts`: Configured Supabase client
|
||||
- `database.types.ts`: TypeScript definitions for tables
|
||||
|
||||
2. **Data Service** (`src/shared/data/dataService.ts`)
|
||||
- `SupabaseDataService`: Handles all database operations
|
||||
- Falls back to mock data if Supabase is not configured
|
||||
|
||||
3. **React Hooks** (`src/shared/hooks/useSupabaseData.ts`)
|
||||
- `useWorkouts`, `useTrainers`, `useCollections`, etc.
|
||||
- Automatic loading states and error handling
|
||||
|
||||
4. **Admin Service** (`src/admin/services/adminService.ts`)
|
||||
- CRUD operations for content management
|
||||
- File upload/delete for storage
|
||||
- Admin authentication
|
||||
|
||||
5. **Admin Dashboard** (`app/admin/`)
|
||||
- `/admin/login`: Authentication screen
|
||||
- `/admin`: Main dashboard with stats
|
||||
- `/admin/workouts`: Manage workouts
|
||||
- `/admin/trainers`: Manage trainers
|
||||
- `/admin/collections`: Manage collections
|
||||
- `/admin/media`: Storage management
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Tables
|
||||
|
||||
| Table | Description |
|
||||
|-------|-------------|
|
||||
| `trainers` | Trainer profiles with colors and avatars |
|
||||
| `workouts` | Workout definitions with exercises and metadata |
|
||||
| `collections` | Curated workout collections |
|
||||
| `collection_workouts` | Many-to-many link between collections and workouts |
|
||||
| `programs` | Multi-week workout programs |
|
||||
| `program_workouts` | Link between programs and workouts with week/day |
|
||||
| `achievements` | User achievement definitions |
|
||||
| `admin_users` | Admin dashboard access control |
|
||||
|
||||
### Storage Buckets
|
||||
|
||||
| Bucket | Purpose | Access |
|
||||
|--------|---------|--------|
|
||||
| `videos` | Workout videos | Public read, Admin write |
|
||||
| `thumbnails` | Workout thumbnails | Public read, Admin write |
|
||||
| `avatars` | Trainer avatars | Public read, Admin write |
|
||||
|
||||
## Using the App
|
||||
|
||||
### As a User
|
||||
|
||||
The app works seamlessly:
|
||||
- If Supabase is configured: Loads data from the cloud
|
||||
- If not configured: Falls back to local mock data
|
||||
|
||||
### As an Admin
|
||||
|
||||
1. Navigate to `/admin` in the app
|
||||
2. Sign in with your admin credentials
|
||||
3. Manage content through the dashboard:
|
||||
- View all workouts, trainers, collections
|
||||
- Delete items (create/edit coming soon)
|
||||
- Upload media files
|
||||
|
||||
## Development
|
||||
|
||||
### Adding New Workouts
|
||||
|
||||
```typescript
|
||||
import { adminService } from '@/src/admin/services/adminService'
|
||||
|
||||
await adminService.createWorkout({
|
||||
title: 'New Workout',
|
||||
trainer_id: 'emma',
|
||||
category: 'full-body',
|
||||
level: 'Beginner',
|
||||
duration: 4,
|
||||
calories: 45,
|
||||
rounds: 8,
|
||||
prep_time: 10,
|
||||
work_time: 20,
|
||||
rest_time: 10,
|
||||
equipment: ['No equipment'],
|
||||
music_vibe: 'electronic',
|
||||
exercises: [
|
||||
{ name: 'Jumping Jacks', duration: 20 },
|
||||
// ...
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
### Uploading Media
|
||||
|
||||
```typescript
|
||||
const videoUrl = await adminService.uploadVideo(file, 'workout-1.mp4')
|
||||
const thumbnailUrl = await adminService.uploadThumbnail(file, 'workout-1.jpg')
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Supabase is not configured" Warning
|
||||
|
||||
This is expected if environment variables are not set. The app will use mock data.
|
||||
|
||||
### Authentication Errors
|
||||
|
||||
1. Verify admin user exists in `admin_users` table
|
||||
2. Check that email/password match
|
||||
3. Ensure user is confirmed in Supabase Auth
|
||||
|
||||
### Storage Upload Failures
|
||||
|
||||
1. Verify storage buckets exist
|
||||
2. Check RLS policies allow admin uploads
|
||||
3. Ensure file size is within limits
|
||||
|
||||
### Data Not Syncing
|
||||
|
||||
1. Check network connection
|
||||
2. Verify Supabase URL and key are correct
|
||||
3. Check browser console for errors
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Row Level Security (RLS) is enabled on all tables
|
||||
- Public can only read content
|
||||
- Only admin users can write content
|
||||
- Storage buckets have public read access
|
||||
- Storage uploads restricted to admin users
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Real-time workout tracking
|
||||
- [ ] User progress sync across devices
|
||||
- [ ] Offline support with local caching
|
||||
- [ ] Push notifications
|
||||
- [ ] Analytics and user insights
|
||||
@@ -1,594 +0,0 @@
|
||||
# TabataFit — Brand Design Specification v2.0
|
||||
> Apple Liquid Glass Design for Tabata
|
||||
|
||||
---
|
||||
|
||||
## Brand Positioning
|
||||
|
||||
**TabataFit = Apple Fitness+ avec Liquid Glass (iOS 18.4)**
|
||||
|
||||
| Attribute | Description |
|
||||
|-----------|-------------|
|
||||
| **Analogy** | "If Apple made a Tabata app in 2026" |
|
||||
| **Vibe** | Premium, glassy, fluid, immersive |
|
||||
| **Emotion** | Confident, empowering, sleek |
|
||||
| **Differentiator** | Video-led + Liquid Glass UI |
|
||||
|
||||
---
|
||||
|
||||
## 🪟 Liquid Glass System (iOS 18.4 Style)
|
||||
|
||||
### Concept
|
||||
Le **Liquid Glass** est le nouveau design language d'Apple qui combine :
|
||||
- **Glassmorphism avancé** — Blur dynamique, transparence multicouche
|
||||
- **Fluidité organique** — Formes arrondies, animations liquides
|
||||
- **Lumière réactive** — Reflets, glows qui répondent au contenu
|
||||
- **Profondeur atmosphérique** — Layers de verre empilés
|
||||
|
||||
### Glass Layers
|
||||
|
||||
```typescript
|
||||
const GLASS = {
|
||||
// Base glass (surfaces)
|
||||
BASE: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
backdropFilter: 'blur(40px)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 32,
|
||||
},
|
||||
|
||||
// Elevated glass (cards, modals)
|
||||
ELEVATED: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
backdropFilter: 'blur(60px)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.15)',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 12 },
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 48,
|
||||
},
|
||||
|
||||
// Inset glass (input fields, controls)
|
||||
INSET: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.2)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.05)',
|
||||
},
|
||||
|
||||
// Tinted glass (accent overlays)
|
||||
TINTED: {
|
||||
backgroundColor: 'rgba(255, 107, 53, 0.15)', // Brand tint
|
||||
backdropFilter: 'blur(40px)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 107, 53, 0.3)',
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Liquid Animations
|
||||
|
||||
```typescript
|
||||
const LIQUID = {
|
||||
// Morphing shapes
|
||||
MORPH: {
|
||||
duration: 600,
|
||||
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
},
|
||||
|
||||
// Ripple effect on tap
|
||||
RIPPLE: {
|
||||
scale: { from: 0.8, to: 1 },
|
||||
opacity: { from: 0.5, to: 0 },
|
||||
duration: 400,
|
||||
},
|
||||
|
||||
// Breathing glow
|
||||
BREATHE: {
|
||||
scale: { from: 1, to: 1.02 },
|
||||
shadowRadius: { from: 20, to: 30 },
|
||||
duration: 2000,
|
||||
loop: true,
|
||||
},
|
||||
|
||||
// Slide with liquid easing
|
||||
SLIDE: {
|
||||
damping: 20,
|
||||
stiffness: 300,
|
||||
mass: 1,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Visual Identity
|
||||
|
||||
### Logo Concept
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ │
|
||||
│ TABATA │ ← Bold, black
|
||||
│ FIT │ ← Accent orange
|
||||
│ │
|
||||
│ [Flame icon] │ ← Optional mark
|
||||
│ │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
### Brand Voice
|
||||
|
||||
| DO | DON'T |
|
||||
|----|-------|
|
||||
| "Let's burn." | "Get ripped fast!" |
|
||||
| "4 minutes to stronger." | "Lose weight now!" |
|
||||
| "Your daily dose of HIIT." | "The #1 fitness app!" |
|
||||
| Minimal, confident copy | Excessive exclamation marks!! |
|
||||
|
||||
---
|
||||
|
||||
## Color Palette
|
||||
|
||||
### Primary Colors
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ BLACK ████████████ #000000 Background │
|
||||
│ CHARCOAL ████████████ #1C1C1E Surfaces │
|
||||
│ SLATE ████████████ #2C2C2E Elevated │
|
||||
│ │
|
||||
│ FLAME ████████████ #FF6B35 Brand accent │
|
||||
│ FLAME LIGHT ████████████ #FF8C5A Highlights │
|
||||
│ │
|
||||
│ ICE ████████████ #5AC8FA Rest phases │
|
||||
│ GLOW ████████████ #FFD60A Achievement │
|
||||
│ ENERGY ████████████ #30D158 Success/Complete│
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Semantic Usage
|
||||
|
||||
| Color | Hex | Usage |
|
||||
|-------|-----|-------|
|
||||
| **Black** | #000000 | Main background, video frames |
|
||||
| **Charcoal** | #1C1C1E | Cards, raised surfaces |
|
||||
| **Slate** | #2C2C2E | Modals, elevated elements |
|
||||
| **Flame** | #FF6B35 | Work phase, CTAs, brand |
|
||||
| **Ice** | #5AC8FA | Rest phase, calm states |
|
||||
| **Glow** | #FFD60A | Achievement badges, streaks |
|
||||
| **Energy** | #30D158 | Complete states, success |
|
||||
|
||||
### Phase Colors (Critical)
|
||||
|
||||
```typescript
|
||||
const PHASE_COLORS = {
|
||||
PREP: '#FF9500', // Orange-yellow - get ready
|
||||
WORK: '#FF6B35', // Flame orange - WORK!
|
||||
REST: '#5AC8FA', // Ice blue - recover
|
||||
COMPLETE: '#30D158', // Energy green - done!
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Typography
|
||||
|
||||
### Font Stack
|
||||
|
||||
**Primary**: Inter (Google Fonts)
|
||||
- Clean, modern, excellent readability
|
||||
- Variable weight support
|
||||
- Apple SF Pro alternative
|
||||
|
||||
### Type Scale
|
||||
|
||||
| Style | Size | Weight | Use Case |
|
||||
|-------|------|--------|----------|
|
||||
| **HERO** | 48px | 900 | Marketing, celebration |
|
||||
| **TITLE_1** | 34px | 700 | Screen titles |
|
||||
| **TITLE_2** | 28px | 700 | Section headers |
|
||||
| **TITLE_3** | 22px | 600 | Card titles |
|
||||
| **BODY** | 17px | 400 | Default text |
|
||||
| **BODY_BOLD** | 17px | 600 | Emphasis |
|
||||
| **CAPTION** | 15px | 400 | Metadata |
|
||||
| **MICRO** | 13px | 400 | Small labels |
|
||||
| **TIMER** | 96px | 900 | Countdown display |
|
||||
|
||||
### Timer Typography (Special)
|
||||
|
||||
```typescript
|
||||
const TIMER_STYLES = {
|
||||
// Main countdown number
|
||||
NUMBER: {
|
||||
fontFamily: 'Inter_900Black',
|
||||
fontSize: 96,
|
||||
fontVariant: ['tabular-nums'], // Monospace digits
|
||||
letterSpacing: -2,
|
||||
},
|
||||
|
||||
// Phase label (WORK, REST)
|
||||
PHASE: {
|
||||
fontFamily: 'Inter_700Bold',
|
||||
fontSize: 24,
|
||||
letterSpacing: 2,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
|
||||
// Round indicator
|
||||
ROUND: {
|
||||
fontFamily: 'Inter_500Medium',
|
||||
fontSize: 17,
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Spacing System
|
||||
|
||||
```typescript
|
||||
const SPACING = {
|
||||
// Base unit: 4px
|
||||
0: 0,
|
||||
1: 4,
|
||||
2: 8,
|
||||
3: 12,
|
||||
4: 16,
|
||||
5: 20,
|
||||
6: 24,
|
||||
8: 32,
|
||||
10: 40,
|
||||
12: 48,
|
||||
16: 64,
|
||||
|
||||
// Semantic
|
||||
XS: 4,
|
||||
SM: 8,
|
||||
MD: 16,
|
||||
LG: 24,
|
||||
XL: 32,
|
||||
XXL: 48,
|
||||
}
|
||||
|
||||
const LAYOUT = {
|
||||
SCREEN_PADDING: 24, // Horizontal screen padding
|
||||
CARD_RADIUS: 16, // Standard card radius
|
||||
BUTTON_RADIUS: 12, // Button radius
|
||||
TAB_BAR_HEIGHT: 80, // Bottom tab bar
|
||||
STATUS_BAR: 44, // iOS status bar
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Styles
|
||||
|
||||
### Cards
|
||||
|
||||
```typescript
|
||||
const CARD = {
|
||||
CONTAINER: {
|
||||
backgroundColor: '#1C1C1E',
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
|
||||
THUMBNAIL: {
|
||||
aspectRatio: 16/9,
|
||||
backgroundColor: '#2C2C2E',
|
||||
},
|
||||
|
||||
CONTENT: {
|
||||
padding: 16,
|
||||
},
|
||||
|
||||
// Variants
|
||||
FEATURED: {
|
||||
borderRadius: 20,
|
||||
},
|
||||
|
||||
COMPACT: {
|
||||
flexDirection: 'row',
|
||||
borderRadius: 12,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Buttons
|
||||
|
||||
```typescript
|
||||
const BUTTON = {
|
||||
PRIMARY: {
|
||||
backgroundColor: '#FF6B35',
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 24,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
|
||||
PRIMARY_TEXT: {
|
||||
color: '#FFFFFF',
|
||||
fontFamily: 'Inter_600SemiBold',
|
||||
fontSize: 17,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
|
||||
SECONDARY: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: '#3A3A3C',
|
||||
},
|
||||
|
||||
GHOST: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Timer Display
|
||||
|
||||
```typescript
|
||||
const TIMER = {
|
||||
CONTAINER: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: 24,
|
||||
background: 'linear-gradient(transparent, rgba(0,0,0,0.8))',
|
||||
},
|
||||
|
||||
NUMBER: {
|
||||
fontFamily: 'Inter_900Black',
|
||||
fontSize: 96,
|
||||
color: '#FFFFFF',
|
||||
textAlign: 'center',
|
||||
},
|
||||
|
||||
PROGRESS_BAR: {
|
||||
height: 4,
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
borderRadius: 2,
|
||||
marginTop: 16,
|
||||
},
|
||||
|
||||
PROGRESS_FILL: {
|
||||
height: '100%',
|
||||
borderRadius: 2,
|
||||
// Color based on phase
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Animation Specifications
|
||||
|
||||
### Timing Constants
|
||||
|
||||
```typescript
|
||||
const DURATION = {
|
||||
INSTANT: 100,
|
||||
FAST: 200,
|
||||
NORMAL: 300,
|
||||
SLOW: 500,
|
||||
XSLow: 800,
|
||||
}
|
||||
|
||||
const EASING = {
|
||||
// Standard iOS ease
|
||||
DEFAULT: 'ease-out',
|
||||
|
||||
// Spring animations
|
||||
BOUNCY: {
|
||||
damping: 15,
|
||||
stiffness: 180,
|
||||
},
|
||||
|
||||
GENTLE: {
|
||||
damping: 20,
|
||||
stiffness: 100,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Key Animations
|
||||
|
||||
**Timer Countdown:**
|
||||
```typescript
|
||||
// Number pulses slightly each second
|
||||
Animated.sequence([
|
||||
Animated.timing(scale, { toValue: 1.05, duration: 100 }),
|
||||
Animated.timing(scale, { toValue: 1, duration: 100 }),
|
||||
])
|
||||
```
|
||||
|
||||
**Progress Bar:**
|
||||
```typescript
|
||||
// Smooth linear fill during phase
|
||||
Animated.timing(width, {
|
||||
toValue: 100,
|
||||
duration: phaseDuration,
|
||||
easing: Easing.linear,
|
||||
})
|
||||
```
|
||||
|
||||
**Phase Transition:**
|
||||
```typescript
|
||||
// Color crossfade
|
||||
Animated.timing(colorProgress, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
})
|
||||
```
|
||||
|
||||
**Workout Complete:**
|
||||
```typescript
|
||||
// Celebration with scale + fade
|
||||
Animated.parallel([
|
||||
Animated.spring(scale, { toValue: 1, ...BOUNCY }),
|
||||
Animated.timing(opacity, { toValue: 1, duration: 500 }),
|
||||
])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Icon System
|
||||
|
||||
Using SF Symbols / Ionicons equivalent:
|
||||
|
||||
| Context | Icon | Size |
|
||||
|---------|------|------|
|
||||
| Home Tab | house.fill | 24 |
|
||||
| Workouts Tab | flame.fill | 24 |
|
||||
| Activity Tab | chart.bar.fill | 24 |
|
||||
| Browse Tab | square.grid.2x2.fill | 24 |
|
||||
| Profile Tab | person.fill | 24 |
|
||||
| Play | play.fill | 20 |
|
||||
| Pause | pause.fill | 20 |
|
||||
| Close | xmark | 20 |
|
||||
| Back | chevron.left | 20 |
|
||||
| Forward | chevron.right | 20 |
|
||||
| Heart | heart.fill | 20 |
|
||||
| Search | magnifyingglass | 20 |
|
||||
|
||||
---
|
||||
|
||||
## Video Player UI
|
||||
|
||||
### Overlay Layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ [Close X] [♥︎] [···] │ ← Top bar
|
||||
│ │
|
||||
│ │
|
||||
│ [VIDEO CONTENT] │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────┐ │
|
||||
│ │ 🔥 WORK │ │
|
||||
│ │ │ │
|
||||
│ │ 00:14 │ │ ← Timer overlay
|
||||
│ │ │ │
|
||||
│ │ Round 3 of 8 │ │
|
||||
│ │ ████████████░░░░ │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Video Requirements
|
||||
|
||||
- **Resolution**: 1080p minimum
|
||||
- **Aspect**: 16:9 (landscape) or 9:16 (portrait mode)
|
||||
- **Format**: HLS (m3u8) for streaming
|
||||
- **Audio**: AAC, 128kbps minimum
|
||||
- **Thumbnail**: JPG, same aspect ratio
|
||||
|
||||
---
|
||||
|
||||
## Sound Design
|
||||
|
||||
### Sound Effects
|
||||
|
||||
| Event | Sound | Description |
|
||||
|-------|-------|-------------|
|
||||
| Phase Start | "ding" | Quick, bright chime |
|
||||
| Count 3-2-1 | "tick" | Subtle tick sound |
|
||||
| Workout Start | "whoosh" | Energy whoosh |
|
||||
| Workout Complete | "success" | Celebratory chime |
|
||||
| Button Tap | "tap" | Soft tap feedback |
|
||||
| Streak Achieved | "fire" | Crackling fire |
|
||||
|
||||
### Haptics
|
||||
|
||||
| Event | Haptic |
|
||||
|-------|--------|
|
||||
| Phase change | Medium |
|
||||
| Button tap | Light |
|
||||
| Countdown tick | Selection |
|
||||
| Workout complete | Success |
|
||||
| Error | Error |
|
||||
|
||||
---
|
||||
|
||||
## Dark Mode Only
|
||||
|
||||
TabataFit is **dark mode only** — no light mode. This is a deliberate design choice matching Apple Fitness+ and creating an immersive, cinematic experience.
|
||||
|
||||
Reasons:
|
||||
1. Better video contrast
|
||||
2. Less eye strain during workouts
|
||||
3. Premium feel
|
||||
4. Consistent with fitness studio lighting
|
||||
|
||||
---
|
||||
|
||||
## Image Guidelines
|
||||
|
||||
### Trainer Photos
|
||||
|
||||
- Professional, high-quality headshots
|
||||
- Warm, approachable expressions
|
||||
- Fitness attire visible
|
||||
- Consistent lighting style
|
||||
- Background: Dark or studio setting
|
||||
|
||||
### Workout Thumbnails
|
||||
|
||||
- Action shot from the workout
|
||||
- Clear exercise demonstration
|
||||
- Trainer visible
|
||||
- Dark/gradient background
|
||||
- Text overlay: Title, duration, level
|
||||
|
||||
### Collection Banners
|
||||
|
||||
- 16:9 aspect ratio
|
||||
- Composite of trainer + exercises
|
||||
- Gradient overlay for text
|
||||
- Brand accent color elements
|
||||
|
||||
---
|
||||
|
||||
## Copy Guidelines
|
||||
|
||||
### Tone
|
||||
|
||||
- **Confident** but not arrogant
|
||||
- **Encouraging** but not cheesy
|
||||
- **Clear** and direct
|
||||
- **Minimal** — let the content speak
|
||||
|
||||
### Examples
|
||||
|
||||
| Context | Good | Bad |
|
||||
|---------|------|-----|
|
||||
| CTA | "Start Workout" | "Start Your Workout Now!" |
|
||||
| Empty state | "No workouts yet" | "You haven't done any workouts :(" |
|
||||
| Error | "Connection lost" | "Oh no! Something went wrong!" |
|
||||
| Success | "Workout complete" | "AMAZING! You crushed it!!!" |
|
||||
|
||||
### Coach Dialogue
|
||||
|
||||
- Motivational but authentic
|
||||
- Form cues during exercises
|
||||
- Breathing reminders during rest
|
||||
- Encouragement without clichés
|
||||
|
||||
---
|
||||
|
||||
*Document created: February 18, 2026*
|
||||
*Version: 2.0*
|
||||
*Brand: Apple Fitness+ inspired*
|
||||
@@ -1,765 +0,0 @@
|
||||
# TabataFit — Product Design Document v2.0
|
||||
> Apple Fitness+ Design Language for Tabata
|
||||
|
||||
---
|
||||
|
||||
## Design Philosophy
|
||||
|
||||
**"Make it feel like a premium fitness studio in your pocket."**
|
||||
|
||||
TabataFit adopts le design language d'Apple Fitness+ :
|
||||
- **Dark mode premium** — Fond noir profond, couleurs vibrantes
|
||||
- **Video-first** — Le contenu est le héros
|
||||
- **Typography bold** — Gros titres, textes épurés
|
||||
- **Subtle animations** — Transitions fluides, feedback délicat
|
||||
- **Inclusive imagery** — Diversité des coachs et body types
|
||||
|
||||
---
|
||||
|
||||
## Color System — Dark Premium
|
||||
|
||||
### Background Colors
|
||||
|
||||
```typescript
|
||||
const COLORS = {
|
||||
// Backgrounds
|
||||
BACKGROUND: '#000000', // Pure black — comme Apple TV
|
||||
SURFACE: '#1C1C1E', // Raised surfaces
|
||||
ELEVATED: '#2C2C2E', // Cards, modals
|
||||
OVERLAY: 'rgba(0,0,0,0.6)', // Video overlays
|
||||
|
||||
// Brand Accent (Vibrant Orange-Red)
|
||||
BRAND: '#FF6B35', // Energy, action
|
||||
BRAND_LIGHT: '#FF8C5A', // Highlights
|
||||
BRAND_DARK: '#E55A25', // Pressed states
|
||||
|
||||
// Secondary Accents
|
||||
SUCCESS: '#34C759', // Completed, streaks
|
||||
WARNING: '#FF9500', // Rest phases
|
||||
INFO: '#5AC8FA', // Tips, info
|
||||
|
||||
// Text
|
||||
TEXT_PRIMARY: '#FFFFFF', // Main text
|
||||
TEXT_SECONDARY: '#EBEBF5', // Secondary (87% opacity)
|
||||
TEXT_TERTIARY: '#EBEBF599', // Tertiary (60% opacity)
|
||||
TEXT_DISABLED: '#3A3A3C', // Disabled text
|
||||
|
||||
// Semantic
|
||||
WORK: '#FF6B35', // Active work phase
|
||||
REST: '#5AC8FA', // Rest phase (calm blue)
|
||||
PREP: '#FF9500', // Countdown prep
|
||||
}
|
||||
```
|
||||
|
||||
### Gradient Presets
|
||||
|
||||
```typescript
|
||||
const GRADIENTS = {
|
||||
// Hero banners
|
||||
HERO_WORK: ['#FF6B35', '#E55A25'],
|
||||
HERO_REST: ['#5AC8FA', '#007AFF'],
|
||||
HERO_FEAT: ['#1C1C1E', '#000000'],
|
||||
|
||||
// Video overlays
|
||||
VIDEO_OVERLAY: ['transparent', 'rgba(0,0,0,0.8)'],
|
||||
VIDEO_TOP: ['rgba(0,0,0,0.4)', 'transparent'],
|
||||
|
||||
// Buttons
|
||||
CTA: ['#FF6B35', '#FF8C5A'],
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Typography System — Apple SF Pro Style
|
||||
|
||||
```typescript
|
||||
const TYPOGRAPHY = {
|
||||
// Hero/Display
|
||||
HERO: {
|
||||
fontFamily: 'Inter_900Black',
|
||||
fontSize: 48,
|
||||
lineHeight: 56,
|
||||
letterSpacing: -1,
|
||||
},
|
||||
|
||||
// Section Headers (like Apple Fitness+)
|
||||
TITLE_1: {
|
||||
fontFamily: 'Inter_700Bold',
|
||||
fontSize: 34,
|
||||
lineHeight: 41,
|
||||
letterSpacing: 0.37,
|
||||
},
|
||||
|
||||
TITLE_2: {
|
||||
fontFamily: 'Inter_700Bold',
|
||||
fontSize: 28,
|
||||
lineHeight: 34,
|
||||
letterSpacing: 0.36,
|
||||
},
|
||||
|
||||
TITLE_3: {
|
||||
fontFamily: 'Inter_600SemiBold',
|
||||
fontSize: 22,
|
||||
lineHeight: 28,
|
||||
letterSpacing: 0.35,
|
||||
},
|
||||
|
||||
// Body
|
||||
BODY: {
|
||||
fontFamily: 'Inter_400Regular',
|
||||
fontSize: 17,
|
||||
lineHeight: 22,
|
||||
letterSpacing: -0.41,
|
||||
},
|
||||
|
||||
BODY_BOLD: {
|
||||
fontFamily: 'Inter_600SemiBold',
|
||||
fontSize: 17,
|
||||
lineHeight: 22,
|
||||
letterSpacing: -0.41,
|
||||
},
|
||||
|
||||
// Metadata
|
||||
CAPTION_1: {
|
||||
fontFamily: 'Inter_400Regular',
|
||||
fontSize: 15,
|
||||
lineHeight: 20,
|
||||
letterSpacing: -0.24,
|
||||
},
|
||||
|
||||
CAPTION_2: {
|
||||
fontFamily: 'Inter_400Regular',
|
||||
fontSize: 14,
|
||||
lineHeight: 18,
|
||||
letterSpacing: -0.15,
|
||||
},
|
||||
|
||||
// Timer (special)
|
||||
TIMER: {
|
||||
fontFamily: 'Inter_900Black',
|
||||
fontSize: 96,
|
||||
lineHeight: 96,
|
||||
letterSpacing: -2,
|
||||
},
|
||||
|
||||
TIMER_PHASE: {
|
||||
fontFamily: 'Inter_700Bold',
|
||||
fontSize: 24,
|
||||
lineHeight: 28,
|
||||
letterSpacing: 2, // Uppercase tracking
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Screen Designs
|
||||
|
||||
### 1. Home Tab — "For You"
|
||||
|
||||
#### Layout Structure
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ status bar │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Bonjour, Alex [Profile] │ ← 24px padding
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ [VIDEO PREVIEW - LOOPING] │ │ ← Hero Card
|
||||
│ │ │ │ 16:9 aspect
|
||||
│ │ ┌─────────────────────────────────┐ │ │
|
||||
│ │ │ 🔥 FEATURED │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ FULL BODY BURN │ │ │ ← Text overlay
|
||||
│ │ │ 4 min • Beginner • Emma │ │ │ on video
|
||||
│ │ │ │ │ │
|
||||
│ │ │ [▶️ START] [♡ Save] │ │ │
|
||||
│ │ └─────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Continue See All → │ ← Section header
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ [Thumb] │ │ [Thumb] │ │ [Thumb] │ │ ← Horizontal scroll
|
||||
│ │ ━━━━━ │ │ ━━━━━ │ │ ━━━━━ │ │ 140x200px cards
|
||||
│ │ 65% │ │ 30% │ │ 10% │ │
|
||||
│ │ Core │ │ HIIT │ │ Full │ │
|
||||
│ │ Burn │ │ Extreme │ │ Body │ │
|
||||
│ └─────────┘ └─────────┘ └─────────┘ │
|
||||
│ │
|
||||
│ Popular This Week │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ [Thumb] │ │ [Thumb] │ │ [Thumb] │ │ [Thumb] │ │ ← Smaller cards
|
||||
│ │ │ │ │ │ │ │ │ │ │ 120x120px
|
||||
│ │ Quick │ │ Strength│ │ Cardio │ │ Core │ │
|
||||
│ │ Burn │ │ Tabata │ │ Blast │ │ Crush │ │
|
||||
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
|
||||
│ │
|
||||
│ Collections │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ 🌅 Morning Energizer │ │ ← Full-width cards
|
||||
│ │ 5 workouts • 20 min total │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ 🔥 7-Day Challenge │ │
|
||||
│ │ 7 workouts • Progressive intensity │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ [Home] [Workouts] [Activity] [Browse] [Profile] │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Component Specs
|
||||
|
||||
**Hero Card:**
|
||||
- Full width - 48px padding
|
||||
- 16:9 aspect ratio
|
||||
- Video preview looping (muted)
|
||||
- Gradient overlay: transparent → rgba(0,0,0,0.8)
|
||||
- Featured badge top-left
|
||||
- Title, metadata, CTA bottom
|
||||
|
||||
**Continue Watching Card:**
|
||||
- 140px width × 200px height
|
||||
- Thumbnail with progress bar overlay
|
||||
- Progress percentage badge
|
||||
- Workout name + duration
|
||||
|
||||
**Popular Card:**
|
||||
- 120px × 120px square
|
||||
- Thumbnail only
|
||||
- Category name below
|
||||
|
||||
**Collection Card:**
|
||||
- Full width - 48px padding
|
||||
- 80px height
|
||||
- Icon + title + description
|
||||
- Chevron right
|
||||
|
||||
---
|
||||
|
||||
### 2. Workouts Tab
|
||||
|
||||
#### Layout Structure
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ status bar │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Workouts [🔍 Search] │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ [LARGE CATEGORY THUMBNAIL] │ │
|
||||
│ │ │ │
|
||||
│ │ 🔥 QUICK BURN │ │
|
||||
│ │ 4 min • All levels │ │
|
||||
│ │ 12 workouts │ │
|
||||
│ │ [→] │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ [LARGE CATEGORY THUMBNAIL] │ │
|
||||
│ │ │ │
|
||||
│ │ 💪 STRENGTH TABATA │ │
|
||||
│ │ 8 min • Intermediate │ │
|
||||
│ │ 8 workouts │ │
|
||||
│ │ [→] │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ 🏃 CARDIO BLAST │ │
|
||||
│ │ 4-12 min • All levels • 15 workouts [→] │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ 🧘 CORE & FLEXIBILITY │ │
|
||||
│ │ 4 min • Beginner • 6 workouts [→] │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ ⚡ HIIT EXTREME │ │
|
||||
│ │ 12-20 min • Advanced • 10 workouts [→] │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ [Home] [Workouts] [Activity] [Browse] [Profile] │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Category Detail View
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ ← Quick Burn │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ 4 min • All levels • 12 workouts │ │
|
||||
│ │ │ │
|
||||
│ │ Filter: [All] [Beginner] [Intermediate] │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ ┌──────┐ │ │
|
||||
│ │ │[Vid] │ Full Body Ignite │ │
|
||||
│ │ │ │ 4 min • Beginner • Emma │ │
|
||||
│ │ └──────┘ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ ┌──────┐ │ │
|
||||
│ │ │[Vid] │ Cardio Crusher │ │
|
||||
│ │ │ │ 4 min • Intermediate • Alex │ │
|
||||
│ │ └──────┘ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ ┌──────┐ │ │
|
||||
│ │ │[Vid] │ Lower Body Blast │ │
|
||||
│ │ │ │ 4 min • Intermediate • Jake │ │
|
||||
│ │ └──────┘ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Pre-Workout Detail Screen
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ ← [♡] [···] │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ [VIDEO PREVIEW - LOOPING] │ │
|
||||
│ │ │ │
|
||||
│ │ Coach Emma in action │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ FULL BODY IGNITE │
|
||||
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
|
||||
│ │
|
||||
│ 👩 Emma • 💪 Beginner • ⏱️ 4 min • 🔥 45cal │
|
||||
│ │
|
||||
│ ───────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ What You'll Need │
|
||||
│ ○ No equipment required │
|
||||
│ ○ Yoga mat optional │
|
||||
│ │
|
||||
│ ───────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ Exercises (8 rounds) │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ 1. Jump Squats 20s work │ │
|
||||
│ │ 2. Mountain Climbers 20s work │ │
|
||||
│ │ 3. Burpees 20s work │ │
|
||||
│ │ 4. High Knees 20s work │ │
|
||||
│ │ ─────────────────────── │ │
|
||||
│ │ Repeat × 2 rounds │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ───────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ Music │
|
||||
│ 🎵 Electronic Energy │
|
||||
│ Upbeat, high-energy electronic tracks │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ ▶️ START WORKOUT │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Active Workout Screen — The Core Experience
|
||||
|
||||
#### Work Phase
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ [FULL SCREEN VIDEO] │ │
|
||||
│ │ │ │
|
||||
│ │ Coach doing Jump Squats │ │
|
||||
│ │ in perfect form │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ ┌───────────────────────────────────────┐ │ │
|
||||
│ │ │ ┌─────────────────────────────────┐ │ │ │
|
||||
│ │ │ │ 🔥 WORK │ │ │ │ ← Timer overlay
|
||||
│ │ │ │ │ │ │ │ bottom gradient
|
||||
│ │ │ │ 00:14 │ │ │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ │ │ Round 3 of 8 │ │ │ │
|
||||
│ │ │ │ ████████████░░░░ 65% │ │ │ │
|
||||
│ │ │ └─────────────────────────────────┘ │ │ │
|
||||
│ │ └───────────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ JUMP SQUATS │
|
||||
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
|
||||
│ │
|
||||
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
|
||||
│ │ 52 │ │ 142 │ │ 85% │ │
|
||||
│ │ CALORIES │ │ BPM │ │ EFFORT │ │
|
||||
│ └───────────┘ └───────────┘ └───────────┘ │
|
||||
│ │
|
||||
│ Burn Bar │
|
||||
│ ░░░░░░░░████████████████░░░░ 72nd percentile │
|
||||
│ │
|
||||
│ [⏸️ Pause] [⛶ Fullscreen]│
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Rest Phase
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ [COACH IN REST POSITION] │ │
|
||||
│ │ │ │
|
||||
│ │ "Shake it out, take a breath" │ │
|
||||
│ │ │ │
|
||||
│ │ ┌───────────────────────────────────────┐ │ │
|
||||
│ │ │ ┌─────────────────────────────────┐ │ │ │
|
||||
│ │ │ │ 💙 REST │ │ │ │ ← Blue rest theme
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ │ │ 00:08 │ │ │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ │ │ Next: Mountain Climbers │ │ │ │
|
||||
│ │ │ │ ░░░░░░░░░░░░░░░░░░░ 40% │ │ │ │
|
||||
│ │ │ └─────────────────────────────────┘ │ │ │
|
||||
│ │ └───────────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ UP NEXT │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ ┌──────┐ │ │
|
||||
│ │ │ GIF │ Mountain Climbers │ │
|
||||
│ │ │preview│ "Core engaged, drive knees forward" │ │
|
||||
│ │ └──────┘ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [⏸️ Pause] [⛶ Fullscreen]│
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 3-2-1 Countdown (Pre-Work)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ GET READY! │ │
|
||||
│ │ │ │
|
||||
│ │ 3 │ │ ← Giant number
|
||||
│ │ │ │ centered
|
||||
│ │ │ │
|
||||
│ │ JUMP SQUATS │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Workout Complete Screen
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ │
|
||||
│ 🎉 │
|
||||
│ │
|
||||
│ WORKOUT COMPLETE │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ [ANIMATED CELEBRATION RINGS] │ │
|
||||
│ │ │ │
|
||||
│ │ 🔥 💪 ⚡ │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
|
||||
│ │ 52 │ │ 4 │ │ 100% │ │
|
||||
│ │ CALORIES │ │ MINUTES │ │ COMPLETE │ │
|
||||
│ └───────────┘ └───────────┘ └───────────┘ │
|
||||
│ │
|
||||
│ Burn Bar │
|
||||
│ You beat 73% of users! │
|
||||
│ ░░░░░░░░████████████████░░░░ │
|
||||
│ │
|
||||
│ ───────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ 🔥 7 Day Streak! │
|
||||
│ Keep the momentum going! │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ 📤 SHARE YOUR WORKOUT │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ ← BACK TO HOME │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Recommended Next │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ [Thumb] │ │ [Thumb] │ │ [Thumb] │ │
|
||||
│ │ Core │ │ Upper │ │ Cardio │ │
|
||||
│ │ Crush │ │ Body │ │ Blast │ │
|
||||
│ └─────────┘ └─────────┘ └─────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Activity Tab
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Activity │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ 🔥 STREAK │ │
|
||||
│ │ │ │
|
||||
│ │ 7 │ │
|
||||
│ │ DAYS │ │
|
||||
│ │ │ │
|
||||
│ │ ● ● ● ● ● ● ● ○ ○ ○ │ │
|
||||
│ │ M T W T F S S M T W │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ This Week │
|
||||
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
|
||||
│ │ 5 │ │ 156 │ │ 20 │ │
|
||||
│ │ WORKOUTS │ │ CALORIES │ │ MINUTES │ │
|
||||
│ │ 5 goal │ │ 150 goal │ │ 20 goal │ │
|
||||
│ └───────────┘ └───────────┘ └───────────┘ │
|
||||
│ │
|
||||
│ Monthly Summary │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ [CALENDAR HEAT MAP] │ │
|
||||
│ │ │ │
|
||||
│ │ Jan 2026 │ │
|
||||
│ │ S M T W T F S │ │
|
||||
│ │ 1 2 3 4 5 6 │ │
|
||||
│ │ 7 8 9 10 11 12 13 │ │
|
||||
│ │ 14 15 16 17 18 19 20 │ │
|
||||
│ │ ░ ░ █ █ ░ █ █ │ │
|
||||
│ │ █ █ ░ █ █ ░ ░ │ │
|
||||
│ │ ░ █ █ █ █ █ █ │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Trends │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ 📈 Workouts trending up! │ │
|
||||
│ │ +23% vs last month │ │
|
||||
│ │ │ │
|
||||
│ │ [WEEKLY CHART] │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Burn Bar Position │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ Your average: 45 cal/workout │ │
|
||||
│ │ ████████████░░░░ 68th percentile │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. Browse Tab
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Browse │
|
||||
│ │
|
||||
│ Filters [Edit] │
|
||||
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │
|
||||
│ │ All ▼ │ │ 4 min │ │ 8 min │ │ Begin │ │
|
||||
│ └────────┘ └────────┘ └────────┘ └────────┘ │
|
||||
│ │
|
||||
│ Trainers │
|
||||
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │
|
||||
│ │ 👩 │ │ 👨 │ │ 👩 │ │ 👨 │ │
|
||||
│ │ Emma │ │ Jake │ │ Mia │ │ Alex │ │
|
||||
│ │ 12 wk │ │ 8 wk │ │ 10 wk │ │ 6 wk │ │
|
||||
│ └────────┘ └────────┘ └────────┘ └────────┘ │
|
||||
│ │
|
||||
│ Duration │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ ○ 4 min (Classic Tabata) 20 │ │
|
||||
│ │ ○ 8 min (Double Tabata) 15 │ │
|
||||
│ │ ○ 12 min (Triple Tabata) 10 │ │
|
||||
│ │ ○ 20 min (Tabata Marathon) 5 │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Focus Area │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ ○ Full Body 20 │ │
|
||||
│ │ ○ Upper Body 8 │ │
|
||||
│ │ ○ Lower Body 8 │ │
|
||||
│ │ ○ Core 8 │ │
|
||||
│ │ ○ Cardio 6 │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Music Vibe │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ ○ Electronic Energy 18 │ │
|
||||
│ │ ○ Hip-Hop Beats 12 │ │
|
||||
│ │ ○ Rock Power 10 │ │
|
||||
│ │ ○ Chill Focus 10 │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Collections │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ 🌅 Morning Energizer • 5 workouts │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ 💪 No Equipment • 15 workouts │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ 🔥 7-Day Challenge • 7 workouts │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8. Profile Tab
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Profile │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ 👤 Alex Martin │ │
|
||||
│ │ Member since Jan 2026 │ │
|
||||
│ │ ✨ Premium │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Weekly Goal │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ 5 workouts per week │ │
|
||||
│ │ ████████████████░░░░ 4/5 this week │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Achievements │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ 🏆 7-Day Streak 🥵 First Sweat │ │
|
||||
│ │ 💯 100 Workouts 🌅 Early Bird │ │
|
||||
│ │ 🔥 500 Calories ⚡ Speed Demon │ │
|
||||
│ │ │ │
|
||||
│ │ [See All Achievements →] │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Settings │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ Notifications [→] │ │
|
||||
│ │ Apple Watch [→] │ │
|
||||
│ │ Music Preferences [→] │ │
|
||||
│ │ Workout Preferences [→] │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Account │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ Subscription [→] │ │
|
||||
│ │ Privacy & Security [→] │ │
|
||||
│ │ Help & Support [→] │ │
|
||||
│ │ Sign Out │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ TabataFit v1.0.0 │
|
||||
│ Made with ❤️ for HIIT lovers │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Animation Specifications
|
||||
|
||||
### Screen Transitions
|
||||
- **Push/Pop**: 300ms ease-out
|
||||
- **Modal**: Slide up from bottom, 350ms
|
||||
|
||||
### Micro-interactions
|
||||
- **Button press**: Scale to 0.96, 100ms
|
||||
- **Card tap**: Scale to 0.98, 150ms
|
||||
- **Toggle**: 200ms spring animation
|
||||
|
||||
### Timer Animations
|
||||
- **Countdown**: Number scales up/down each second
|
||||
- **Progress bar**: Smooth width animation
|
||||
- **Phase change**: Color crossfade 300ms
|
||||
|
||||
### Celebration
|
||||
- **Confetti**: Lottie animation on workout complete
|
||||
- **Rings**: Animated fill when stats update
|
||||
- **Streak badge**: Pulse animation
|
||||
|
||||
---
|
||||
|
||||
## Accessibility
|
||||
|
||||
- **Dynamic Type**: Support up to 200% text scaling
|
||||
- **VoiceOver**: Full screen reader support
|
||||
- **Reduce Motion**: Disable animations when requested
|
||||
- **High Contrast**: Alternative color scheme option
|
||||
|
||||
---
|
||||
|
||||
*Document created: February 18, 2026*
|
||||
*Version: 2.0*
|
||||
*Design System: Apple Fitness+ Inspired*
|
||||
@@ -1,545 +0,0 @@
|
||||
# TabataFit — Product Requirements Document v2.0
|
||||
> Apple Fitness+ for Tabata — The Premium HIIT Experience
|
||||
|
||||
---
|
||||
|
||||
## Vision Statement
|
||||
|
||||
**TabataFit est l'Apple Fitness+ du Tabata.** Une expérience premium, visuellement stunante, guidée par des coachs, qui transforme 4 minutes d'exercice en une expérience de fitness immersive.
|
||||
|
||||
*"Workouts that work. Beautifully."*
|
||||
|
||||
---
|
||||
|
||||
## Positionnement
|
||||
|
||||
| Aspect | Apple Fitness+ | TabataFit |
|
||||
|--------|---------------|-----------|
|
||||
| **Focus** | Multi-activité (Yoga, HIIT, Strength, etc.) | Spécialiste Tabata/HIIT |
|
||||
| **Durée** | 5-45 min | 4-20 min (format Tabata) |
|
||||
| **Différenciateur** | Intégration Apple Watch | Timer intelligent + Coaching audio |
|
||||
| **Cible** | Grand public fitness | Athlètes HIIT, busy professionals |
|
||||
| **Vibe** | Studio californien | Énergie explosive, motivational |
|
||||
|
||||
---
|
||||
|
||||
## Core Philosophy — Apple Fitness+ Principles
|
||||
|
||||
1. **Content is King** — Vidéos HD, coachs charismatiques, production Netflix-quality
|
||||
2. **Inclusive** — Tous niveaux, modifications montrées
|
||||
3. **Personalized** — Recommandations basées sur l'historique
|
||||
4. **Immersive** — Music sync, Burn Bar, stats temps réel
|
||||
5. **Beautiful** — Design épuré, animations fluides, dark theme élégant
|
||||
|
||||
---
|
||||
|
||||
## Architecture Produit
|
||||
|
||||
### Tab Bar (5 onglets — comme Apple Fitness+)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ 🏠 Home 🔥 Workouts 📊 Activity 🔍 Browse 👤 Profile │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 1. Home Tab — "For You"
|
||||
|
||||
**Inspiration**: Apple Fitness+ Home — grande bannière, collections, recommandations
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ ☀️ Bonjour Alex │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ 🎬 FEATURED WORKOUT │ │
|
||||
│ │ ─────────────────────── │ │
|
||||
│ │ Full Body Burn │ │
|
||||
│ │ 4 min • Beginner • Emma │ │
|
||||
│ │ │ │
|
||||
│ │ [▶️ START NOW] │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Continue Watching │
|
||||
│ ┌─────┐ ┌─────┐ ┌─────┐ │
|
||||
│ │ 65%│ │ 30%│ │ 10%│ │
|
||||
│ └─────┘ └─────┘ └─────┘ │
|
||||
│ │
|
||||
│ Popular This Week │
|
||||
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
|
||||
│ └─────┘ └─────┘ └─────┘ └─────┘ │
|
||||
│ │
|
||||
│ Collections │
|
||||
│ 🌅 Morning Energizer │
|
||||
│ 💪 No Equipment Needed │
|
||||
│ 🔥 7-Day Challenge │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Elements clés:**
|
||||
- Hero banner avec vidéo preview en boucle
|
||||
- "Continue Watching" — workouts non terminés
|
||||
- "Popular This Week" — trending workouts
|
||||
- Collections thématiques
|
||||
- Coach du moment
|
||||
|
||||
### 2. Workouts Tab — Parcourir par type
|
||||
|
||||
**Inspiration**: Apple Fitness+ workout browser — categories visuelles
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ WORKOUTS 🔍 │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ 🔥 QUICK BURN │ │
|
||||
│ │ 4 min • All levels │ │
|
||||
│ │ 12 workouts │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ 💪 STRENGTH TABATA │ │
|
||||
│ │ 8 min • Intermediate │ │
|
||||
│ │ 8 workouts │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ 🏃 CARDIO BLAST │ │
|
||||
│ │ 4-12 min • All levels │ │
|
||||
│ │ 15 workouts │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ 🧘 CORE & FLEXIBILITY │ │
|
||||
│ │ 4 min • Beginner friendly │ │
|
||||
│ │ 6 workouts │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ ⚡ HIIT EXTREME │ │
|
||||
│ │ 12-20 min • Advanced │ │
|
||||
│ │ 10 workouts │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Categories:**
|
||||
1. **Quick Burn** — 4 min, perfect for beginners
|
||||
2. **Strength Tabata** — Resistance exercises
|
||||
3. **Cardio Blast** — Pure cardio, no equipment
|
||||
4. **Core & Flexibility** — Abs, stretching
|
||||
5. **HIIT Extreme** — Advanced, longer sessions
|
||||
|
||||
### 3. Activity Tab — Stats & Progress
|
||||
|
||||
**Inspiration**: Apple Fitness+ Activity rings + trends
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ ACTIVITY │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ 🔥 STREAK │ │
|
||||
│ │ ───────── │ │
|
||||
│ │ 7 │ │
|
||||
│ │ DAYS │ │
|
||||
│ │ │ │
|
||||
│ │ ○ ○ ○ ○ ○ ○ ○ ● ○ ○ │ │
|
||||
│ │ M T W T F S S M T │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ This Week │
|
||||
│ ┌───────┐ ┌───────┐ ┌───────┐ │
|
||||
│ │ 5 │ │ 156 │ │ 32 │ │
|
||||
│ │Workout│ │ Calories│ │ Minutes│ │
|
||||
│ └───────┘ └───────┘ └───────┘ │
|
||||
│ │
|
||||
│ Trends │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ 📈 Workouts are trending up! │ │
|
||||
│ │ +23% vs last month │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Burn Bar Position │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ Your avg: 45 cal/workout │ │
|
||||
│ │ ████████░░░░ 68th percentile │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Monthly Summary │
|
||||
│ [ Calendar view with heat map ] │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Streak counter avec calendrier visuel
|
||||
- Stats hebdomadaires (workouts, calories, minutes)
|
||||
- Trends ("You're on fire! 🔥")
|
||||
- Burn Bar — comparaison avec autres utilisateurs
|
||||
- Calendar heat map
|
||||
|
||||
### 4. Browse Tab — Tout le contenu
|
||||
|
||||
**Inspiration**: Apple Fitness+ Browse — filtres, trainers, music
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ BROWSE │
|
||||
│ │
|
||||
│ Filters [Edit]│
|
||||
│ [All ▼] [4 min] [8 min] [Beginner] │
|
||||
│ │
|
||||
│ By Trainer │
|
||||
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
|
||||
│ │ 👩 │ │ 👨 │ │ 👩 │ │ 👨 │ │
|
||||
│ │Emma │ │Jake │ │Mia │ │Alex │ │
|
||||
│ └─────┘ └─────┘ └─────┘ └─────┘ │
|
||||
│ │
|
||||
│ By Duration │
|
||||
│ ○ 4 min (Classic Tabata) │
|
||||
│ ○ 8 min (Double Tabata) │
|
||||
│ ○ 12 min (Triple Tabata) │
|
||||
│ ○ 20 min (Tabata Marathon) │
|
||||
│ │
|
||||
│ By Focus Area │
|
||||
│ ○ Full Body │
|
||||
│ ○ Upper Body │
|
||||
│ ○ Lower Body │
|
||||
│ ○ Core │
|
||||
│ ○ Cardio │
|
||||
│ │
|
||||
│ Music Vibe │
|
||||
│ ○ Electronic Energy │
|
||||
│ ○ Hip-Hop Beats │
|
||||
│ ○ Rock Power │
|
||||
│ ○ Chill Focus │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 5. Profile Tab — Settings & Account
|
||||
|
||||
**Inspiration**: Apple Fitness+ Profile — minimal, clean
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ PROFILE │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ 👤 Alex Martin │ │
|
||||
│ │ Member since Jan 2026 │ │
|
||||
│ │ Premium ✨ │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Goals │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ Weekly Goal: 5 workouts │ │
|
||||
│ │ ████████░░ 4/5 this week │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Achievements │
|
||||
│ 🏆 7-Day Streak │
|
||||
│ 🥵 First Sweat │
|
||||
│ 💯 100 Workouts │
|
||||
│ [See all →] │
|
||||
│ │
|
||||
│ Settings │
|
||||
│ • Notifications │
|
||||
│ • Apple Watch │
|
||||
│ • Music Preferences │
|
||||
│ • Account │
|
||||
│ • Subscription │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## The Workout Experience — Cœur du Produit
|
||||
|
||||
### Pre-Workout Screen
|
||||
|
||||
**Inspiration**: Apple Fitness+ workout preview — trailer, details, start
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ ← │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ [VIDEO PREVIEW LOOP] │ │
|
||||
│ │ Coach Emma demonstrating │ │
|
||||
│ │ Jump Squats │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ │
|
||||
│ FULL BODY BURN │
|
||||
│ ───────────────────────────────────── │
|
||||
│ │
|
||||
│ 👩 Emma • 💪 Intermediate • ⏱️ 4 min │
|
||||
│ │
|
||||
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
|
||||
│ │
|
||||
│ What You'll Need │
|
||||
│ ○ No equipment │
|
||||
│ ○ Mat recommended │
|
||||
│ │
|
||||
│ Exercises Preview │
|
||||
│ 1. Jump Squats (20s work) │
|
||||
│ 2. Mountain Climbers (20s work) │
|
||||
│ 3. Burpees (20s work) │
|
||||
│ 4. High Knees (20s work) │
|
||||
│ × 2 rounds │
|
||||
│ │
|
||||
│ Music │
|
||||
│ 🎵 Electronic Energy playlist │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ ▶️ START WORKOUT │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Active Workout Screen — Apple Fitness+ Style
|
||||
|
||||
**Inspiration**: Apple Fitness+ player — video dominant, stats overlay
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ [FULL SCREEN VIDEO] │ │
|
||||
│ │ │ │
|
||||
│ │ Coach doing exercise │ │
|
||||
│ │ in perfect form │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────────────────────────┐ │ │
|
||||
│ │ │ 🔥 WORK • 00:14 │ │ │
|
||||
│ │ │ Round 3 of 8 │ │ │
|
||||
│ │ │ ████████████░░░░ 65% │ │ │
|
||||
│ │ └─────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ │
|
||||
│ JUMP SQUATS │
|
||||
│ ───────────────────────────────────── │
|
||||
│ │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ 14 │ │ 52 │ │ 85% │ │
|
||||
│ │ cal │ │ bpm │ │ effort │ │
|
||||
│ └─────────┘ └─────────┘ └─────────┘ │
|
||||
│ │
|
||||
│ Burn Bar: ████████░░░░ 72% │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Key Features:**
|
||||
- Video full screen avec coach
|
||||
- Timer overlay (phase + countdown)
|
||||
- Round indicator
|
||||
- Progress bar
|
||||
- Stats temps réel (calories, bpm si Apple Watch)
|
||||
- Burn Bar
|
||||
|
||||
### During Rest Phase
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ [COACH IN REST POSE] │ │
|
||||
│ │ Stretching / breathing │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────────────────────────┐ │ │
|
||||
│ │ │ 💙 REST • 00:08 │ │ │
|
||||
│ │ │ Next: Mountain Climbers │ │ │
|
||||
│ │ │ ░░░░░░░░░░░░░░░░░░ 40% │ │ │
|
||||
│ │ └─────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ "Shake it out, you're │ │
|
||||
│ │ doing great!" │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Up Next: Mountain Climbers │
|
||||
│ [GIF preview of next exercise] │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Workout Complete — Celebration
|
||||
|
||||
**Inspiration**: Apple Fitness+ celebration screen
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ │
|
||||
│ 🎉 WORKOUT COMPLETE! │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ [ANIMATED RINGS] │ │
|
||||
│ │ 🔥 🔥 🔥 │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ │
|
||||
│ YOUR STATS │
|
||||
│ ───────────────────────────────────── │
|
||||
│ │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ 52 │ │ 4 │ │ 100% │ │
|
||||
│ │ CALORIES│ │ MINUTES │ │ COMPLETE│ │
|
||||
│ └─────────┘ └─────────┘ └─────────┘ │
|
||||
│ │
|
||||
│ Burn Bar │
|
||||
│ ████████████░░░ You beat 73%! │
|
||||
│ │
|
||||
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
|
||||
│ │
|
||||
│ 🔥 7 Day Streak! Keep it going! │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ SHARE YOUR WORKOUT │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ ← BACK TO HOME │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Content Strategy — 50+ Workouts au Launch
|
||||
|
||||
### Par Durée
|
||||
|
||||
| Duration | Format | Rounds | Count |
|
||||
|----------|--------|--------|-------|
|
||||
| 4 min | Classic Tabata | 8 rounds | 20 workouts |
|
||||
| 8 min | Double Tabata | 16 rounds | 15 workouts |
|
||||
| 12 min | Triple Tabata | 24 rounds | 10 workouts |
|
||||
| 20 min | Tabata Marathon | 40 rounds | 5 workouts |
|
||||
|
||||
### Par Focus
|
||||
|
||||
- **Full Body** — 20 workouts
|
||||
- **Upper Body** — 8 workouts
|
||||
- **Lower Body** — 8 workouts
|
||||
- **Core** — 8 workouts
|
||||
- **Cardio Only** — 6 workouts
|
||||
|
||||
### Par Niveau
|
||||
|
||||
- **Beginner** — 15 workouts
|
||||
- **Intermediate** — 20 workouts
|
||||
- **Advanced** — 15 workouts
|
||||
|
||||
### Trainers (5 au launch)
|
||||
|
||||
1. **Emma** — Energy queen, beginner-friendly
|
||||
2. **Jake** — Strength focus, motivating
|
||||
3. **Mia** — Form perfectionist, technical
|
||||
4. **Alex** — Cardio beast, intense
|
||||
5. **Sofia** — Chill but effective, recovery
|
||||
|
||||
---
|
||||
|
||||
## Technical Requirements
|
||||
|
||||
### Video Pipeline
|
||||
- HLS streaming (adaptive bitrate)
|
||||
- 1080p minimum, 4K for featured
|
||||
- Offline download for Premium
|
||||
- Preload next exercise during rest
|
||||
|
||||
### Audio
|
||||
- Multiple music tracks (by vibe)
|
||||
- Coach voice-over (can be muted)
|
||||
- Sound effects (beeps, transitions)
|
||||
- Haptic feedback sync
|
||||
|
||||
### Apple Watch Integration
|
||||
- Heart rate display
|
||||
- Calories calculation
|
||||
- Activity rings update
|
||||
- Now Playing controls
|
||||
|
||||
### Offline Support
|
||||
- Download workouts for offline
|
||||
- Sync when back online
|
||||
- Local stats caching
|
||||
|
||||
---
|
||||
|
||||
## Monetization
|
||||
|
||||
### Free Tier
|
||||
- 3 workouts free forever
|
||||
- Basic stats
|
||||
- Ads between workouts
|
||||
|
||||
### Premium ($6.99/mo or $49.99/yr)
|
||||
- Unlimited workouts
|
||||
- All trainers
|
||||
- Offline downloads
|
||||
- Advanced stats & trends
|
||||
- Apple Watch integration
|
||||
- No ads
|
||||
- Family Sharing (up to 5)
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
|
||||
| Metric | Target (Month 3) |
|
||||
|--------|------------------|
|
||||
| DAU | 10,000 |
|
||||
| Workout completion rate | 75% |
|
||||
| 7-day retention | 40% |
|
||||
| Premium conversion | 8% |
|
||||
| Average workouts/user/week | 3.5 |
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Phase 1 — MVP (Weeks 1-4)
|
||||
- [ ] Home + Workouts tabs
|
||||
- [ ] 20 workouts (4 min only)
|
||||
- [ ] 2 trainers
|
||||
- [ ] Basic timer + video player
|
||||
|
||||
### Phase 2 — Core (Weeks 5-8)
|
||||
- [ ] Activity tab with stats
|
||||
- [ ] 30 workouts total
|
||||
- [ ] 4 trainers
|
||||
- [ ] Apple Watch integration
|
||||
|
||||
### Phase 3 — Premium (Weeks 9-12)
|
||||
- [ ] Browse + Profile tabs
|
||||
- [ ] 50+ workouts
|
||||
- [ ] 5 trainers
|
||||
- [ ] Offline downloads
|
||||
- [ ] Burn Bar
|
||||
- [ ] Subscription system
|
||||
|
||||
---
|
||||
|
||||
*Document created: February 18, 2026*
|
||||
*Version: 2.0*
|
||||
*Status: Ready for Design Phase*
|
||||
BIN
admin-login.png
BIN
admin-login.png
Binary file not shown.
|
Before Width: | Height: | Size: 31 KiB |
86
admin-web/app/api/ai/generate-avatar/route.ts
Normal file
86
admin-web/app/api/ai/generate-avatar/route.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { GoogleGenAI } from '@google/genai'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { prompt, trainerId, filename } = await request.json()
|
||||
|
||||
const apiKey = process.env.GEMINI_API_KEY
|
||||
if (!apiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'GEMINI_API_KEY not configured' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const ai = new GoogleGenAI({ apiKey })
|
||||
|
||||
const config = {
|
||||
imageConfig: {
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
},
|
||||
responseModalities: ['IMAGE', 'TEXT'] as string[],
|
||||
}
|
||||
|
||||
const response = await ai.models.generateContentStream({
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
config,
|
||||
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
||||
})
|
||||
|
||||
const images: { buffer: Buffer; mimeType: string }[] = []
|
||||
|
||||
for await (const chunk of response) {
|
||||
const parts = chunk.candidates?.[0]?.content?.parts
|
||||
if (!parts) continue
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.inlineData) {
|
||||
images.push({
|
||||
buffer: Buffer.from(part.inlineData.data || '', 'base64'),
|
||||
mimeType: part.inlineData.mimeType || 'image/png',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (images.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No images generated' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Save first image to storage
|
||||
const image = images[0]
|
||||
const path = `${trainerId}/${filename}`
|
||||
|
||||
const { error } = await supabase.storage
|
||||
.from('trainer-avatars')
|
||||
.upload(path, image.buffer, {
|
||||
contentType: image.mimeType,
|
||||
upsert: true,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to upload avatar: ${error.message}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const { data: { publicUrl } } = supabase.storage
|
||||
.from('trainer-avatars')
|
||||
.getPublicUrl(path)
|
||||
|
||||
return NextResponse.json({ url: publicUrl })
|
||||
} catch (error) {
|
||||
console.error('Avatar generation failed:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Generation failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
297
admin-web/app/api/ai/generate-video/route.ts
Normal file
297
admin-web/app/api/ai/generate-video/route.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import { GoogleGenAI, VideoGenerationReferenceType } from '@google/genai'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
|
||||
interface Exercise {
|
||||
name: string
|
||||
duration: number
|
||||
}
|
||||
|
||||
interface GeneratedVideo {
|
||||
exerciseName: string
|
||||
videoType: 'exercise' | 'rest'
|
||||
videoUrl: string
|
||||
videoPath: string
|
||||
durationSeconds: number
|
||||
}
|
||||
|
||||
async function pollOperation(ai: GoogleGenAI, operation: any): Promise<any> {
|
||||
while (!operation.done) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10000))
|
||||
operation = await ai.operations.getVideosOperation({ operation })
|
||||
}
|
||||
return operation
|
||||
}
|
||||
|
||||
async function uploadVideo(
|
||||
bucket: string,
|
||||
path: string,
|
||||
buffer: Buffer
|
||||
): Promise<string> {
|
||||
const { error } = await supabase.storage
|
||||
.from(bucket)
|
||||
.upload(path, buffer, {
|
||||
contentType: 'video/mp4',
|
||||
upsert: true,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to upload video: ${error.message}`)
|
||||
}
|
||||
|
||||
const { data: { publicUrl } } = supabase.storage
|
||||
.from(bucket)
|
||||
.getPublicUrl(path)
|
||||
|
||||
return publicUrl
|
||||
}
|
||||
|
||||
async function getMimeTypeFromUrl(url: string): Promise<string> {
|
||||
try {
|
||||
const urlPath = new URL(url).pathname
|
||||
const extension = urlPath.split('.').pop()?.toLowerCase()
|
||||
switch (extension) {
|
||||
case 'png':
|
||||
return 'image/png'
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
return 'image/jpeg'
|
||||
case 'webp':
|
||||
return 'image/webp'
|
||||
case 'gif':
|
||||
return 'image/gif'
|
||||
default:
|
||||
return 'image/png'
|
||||
}
|
||||
} catch {
|
||||
return 'image/png'
|
||||
}
|
||||
}
|
||||
|
||||
async function generateVideoWithReference(
|
||||
ai: GoogleGenAI,
|
||||
apiKey: string,
|
||||
prompt: string,
|
||||
avatarUrl: string,
|
||||
durationSeconds: number = 8
|
||||
): Promise<{ uri: string; buffer: Buffer } | null> {
|
||||
// Fetch avatar image from URL and convert to base64
|
||||
const avatarResponse = await fetch(avatarUrl)
|
||||
const avatarBuffer = await avatarResponse.arrayBuffer()
|
||||
const avatarBase64 = Buffer.from(avatarBuffer).toString('base64')
|
||||
const mimeType = await getMimeTypeFromUrl(avatarUrl)
|
||||
|
||||
const operation = await ai.models.generateVideos({
|
||||
model: 'veo-3.1-fast-generate-preview',
|
||||
prompt,
|
||||
config: {
|
||||
numberOfVideos: 1,
|
||||
aspectRatio: '16:9',
|
||||
resolution: '1080p',
|
||||
durationSeconds,
|
||||
referenceImages: [{
|
||||
image: {
|
||||
imageBytes: avatarBase64,
|
||||
mimeType,
|
||||
},
|
||||
referenceType: 'character' as any,
|
||||
}],
|
||||
},
|
||||
})
|
||||
|
||||
const completedOperation = await pollOperation(ai, operation)
|
||||
const videoUri = completedOperation.response?.generatedVideos?.[0]?.video?.uri
|
||||
|
||||
if (!videoUri) {
|
||||
return null
|
||||
}
|
||||
|
||||
const response = await fetch(`${videoUri}&key=${apiKey}`)
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
const videoBuffer = Buffer.from(arrayBuffer)
|
||||
|
||||
return { uri: videoUri, buffer: videoBuffer }
|
||||
}
|
||||
|
||||
async function extendVideo(
|
||||
ai: GoogleGenAI,
|
||||
apiKey: string,
|
||||
videoUri: string,
|
||||
prompt: string,
|
||||
durationSeconds: number = 8
|
||||
): Promise<{ uri: string; buffer: Buffer } | null> {
|
||||
const operation = await ai.models.generateVideos({
|
||||
model: 'veo-3.1-fast-generate-preview',
|
||||
prompt,
|
||||
video: videoUri as any,
|
||||
config: {
|
||||
numberOfVideos: 1,
|
||||
durationSeconds,
|
||||
},
|
||||
})
|
||||
|
||||
const completedOperation = await pollOperation(ai, operation)
|
||||
const newVideoUri = completedOperation.response?.generatedVideos?.[0]?.video?.uri
|
||||
|
||||
if (!newVideoUri) {
|
||||
return null
|
||||
}
|
||||
|
||||
const response = await fetch(`${newVideoUri}&key=${apiKey}`)
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
const videoBuffer = Buffer.from(arrayBuffer)
|
||||
|
||||
return { uri: newVideoUri, buffer: videoBuffer }
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { workoutId, trainerId, trainerAvatarUrl, exercises } = await request.json()
|
||||
|
||||
if (!trainerAvatarUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Trainer avatar URL is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const apiKey = process.env.GEMINI_API_KEY
|
||||
if (!apiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'GEMINI_API_KEY not configured' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const ai = new GoogleGenAI({ apiKey })
|
||||
const generatedVideos: GeneratedVideo[] = []
|
||||
|
||||
for (const exercise of exercises) {
|
||||
const exerciseName = exercise.name
|
||||
|
||||
console.log(`Processing exercise: ${exerciseName}`)
|
||||
|
||||
const safeName = exerciseName.toLowerCase().replace(/[^a-z0-9]/g, '_')
|
||||
|
||||
// 1. Generate 8s exercise video with avatar reference
|
||||
console.log(`Generating base exercise video for: ${exerciseName}`)
|
||||
const exercisePrompt = `A fitness trainer demonstrating ${exerciseName} exercise with correct form and technique. The person should be energetic and perform the exercise properly.`
|
||||
|
||||
let exerciseVideo = await generateVideoWithReference(
|
||||
ai,
|
||||
apiKey,
|
||||
exercisePrompt,
|
||||
trainerAvatarUrl,
|
||||
8
|
||||
)
|
||||
|
||||
if (!exerciseVideo) {
|
||||
console.error(`Failed to generate base exercise video for: ${exerciseName}`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 2. Extend exercise video to 22s (8s + 7s + 7s)
|
||||
console.log(`Extending exercise video for: ${exerciseName}`)
|
||||
|
||||
const extendPrompt1 = `Continue the ${exerciseName} exercise motion seamlessly`
|
||||
let extended1 = await extendVideo(ai, apiKey, exerciseVideo.uri, extendPrompt1, 8)
|
||||
|
||||
if (extended1) {
|
||||
const extendPrompt2 = `Continue the ${exerciseName} exercise motion seamlessly`
|
||||
const extended2 = await extendVideo(ai, apiKey, extended1.uri, extendPrompt2, 8)
|
||||
|
||||
if (extended2) {
|
||||
exerciseVideo = extended2
|
||||
}
|
||||
}
|
||||
|
||||
const exercisePath = `${trainerId}/${workoutId}/${safeName}.mp4`
|
||||
const exerciseUrl = await uploadVideo('trainer-videos', exercisePath, exerciseVideo.buffer)
|
||||
|
||||
generatedVideos.push({
|
||||
exerciseName,
|
||||
videoType: 'exercise',
|
||||
videoUrl: exerciseUrl,
|
||||
videoPath: exercisePath,
|
||||
durationSeconds: 22,
|
||||
})
|
||||
|
||||
console.log(`Exercise video uploaded: ${exerciseUrl}`)
|
||||
|
||||
// 3. Generate 8s rest video with avatar reference
|
||||
console.log(`Generating rest video for: ${exerciseName}`)
|
||||
const restPrompt = `A fitness trainer resting, standing still, breathing calmly with arms at sides. The person looks relaxed.`
|
||||
|
||||
const restVideo = await generateVideoWithReference(
|
||||
ai,
|
||||
apiKey,
|
||||
restPrompt,
|
||||
trainerAvatarUrl,
|
||||
8
|
||||
)
|
||||
|
||||
if (restVideo) {
|
||||
const restPath = `${trainerId}/${workoutId}/${safeName}_rest.mp4`
|
||||
const restUrl = await uploadVideo('trainer-videos', restPath, restVideo.buffer)
|
||||
|
||||
generatedVideos.push({
|
||||
exerciseName,
|
||||
videoType: 'rest',
|
||||
videoUrl: restUrl,
|
||||
videoPath: restPath,
|
||||
durationSeconds: 8,
|
||||
})
|
||||
|
||||
console.log(`Rest video uploaded: ${restUrl}`)
|
||||
} else {
|
||||
console.error(`Failed to generate rest video for: ${exerciseName}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (generatedVideos.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No videos were generated' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Save all videos to exercise_videos table
|
||||
for (const video of generatedVideos) {
|
||||
await (supabase.from('exercise_videos') as any)
|
||||
.insert({
|
||||
workout_id: workoutId,
|
||||
trainer_id: trainerId,
|
||||
exercise_name: video.exerciseName,
|
||||
video_url: video.videoUrl,
|
||||
video_path: video.videoPath,
|
||||
video_type: video.videoType,
|
||||
duration_seconds: video.durationSeconds,
|
||||
})
|
||||
}
|
||||
|
||||
// Update workout with primary video_url (first exercise video)
|
||||
const primaryVideo = generatedVideos.find(v => v.videoType === 'exercise')
|
||||
if (primaryVideo) {
|
||||
await (supabase.from('workouts') as any)
|
||||
.update({ video_url: primaryVideo.videoUrl })
|
||||
.eq('id', workoutId)
|
||||
}
|
||||
|
||||
// Mark workout as generated in program_workouts if applicable
|
||||
await (supabase.from('program_workouts') as any)
|
||||
.update({ video_generated: true })
|
||||
.eq('workout_id', workoutId)
|
||||
|
||||
return NextResponse.json({
|
||||
workoutId,
|
||||
videos: generatedVideos,
|
||||
totalVideos: generatedVideos.length,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Video generation failed:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Generation failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
1249
admin-web/app/music/page.tsx
Normal file
1249
admin-web/app/music/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,10 +3,9 @@
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Feb 20, 2026
|
||||
### Apr 16, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #5391 | 10:20 PM | 🟣 | RevenueCat subscription system fully integrated and tested | ~656 |
|
||||
| #5384 | 9:28 PM | 🟣 | Implemented RevenueCat subscription system | ~190 |
|
||||
| #6319 | 9:38 PM | 🔵 | Discovered admin UI program management pages created | ~267 |
|
||||
</claude-mem-context>
|
||||
@@ -3,10 +3,9 @@
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Feb 20, 2026
|
||||
### Apr 16, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #5541 | 11:52 PM | 🔄 | Converted 4 detail screens to use theme system | ~264 |
|
||||
| #5230 | 1:25 PM | 🟣 | Implemented category and collection detail screens with Inter font loading | ~481 |
|
||||
| #6319 | 9:38 PM | 🔵 | Discovered admin UI program management pages created | ~267 |
|
||||
</claude-mem-context>
|
||||
11
admin-web/app/programs/[id]/edit/CLAUDE.md
Normal file
11
admin-web/app/programs/[id]/edit/CLAUDE.md
Normal file
@@ -0,0 +1,11 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Apr 16, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6319 | 9:38 PM | 🔵 | Discovered admin UI program management pages created | ~267 |
|
||||
</claude-mem-context>
|
||||
94
admin-web/app/programs/[id]/edit/page.tsx
Normal file
94
admin-web/app/programs/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Metadata } from "next"
|
||||
import Link from "next/link"
|
||||
import { notFound } from "next/navigation"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import ProgramForm from "@/components/program-form"
|
||||
import { supabase } from "@/lib/supabase"
|
||||
|
||||
interface EditProgramPageProps {
|
||||
params: Promise<{
|
||||
id: string
|
||||
}>
|
||||
}
|
||||
|
||||
async function getProgram(id: string) {
|
||||
const { data, error } = await (supabase.from("workout_programs") as any)
|
||||
.select(`
|
||||
*,
|
||||
program_tabatas (*),
|
||||
workout_warmup_exercises (*),
|
||||
workout_stretch_exercises (*)
|
||||
`)
|
||||
.eq("id", id)
|
||||
.single()
|
||||
|
||||
if (error || !data) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Sort tabatas by position
|
||||
if (data.program_tabatas) {
|
||||
data.program_tabatas.sort((a: any, b: any) => a.position - b.position)
|
||||
}
|
||||
if (data.workout_warmup_exercises) {
|
||||
data.workout_warmup_exercises.sort((a: any, b: any) => a.position - b.position)
|
||||
}
|
||||
if (data.workout_stretch_exercises) {
|
||||
data.workout_stretch_exercises.sort((a: any, b: any) => a.position - b.position)
|
||||
}
|
||||
|
||||
// Map to ProgramForm's expected shape
|
||||
data.tabatas = data.program_tabatas
|
||||
data.warmup = data.workout_warmup_exercises
|
||||
data.stretch = data.workout_stretch_exercises
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: EditProgramPageProps): Promise<Metadata> {
|
||||
const resolvedParams = await params
|
||||
const program = await getProgram(resolvedParams.id)
|
||||
|
||||
if (!program) {
|
||||
return {
|
||||
title: "Program Not Found | TabataFit Admin",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: `Edit ${program.title} | TabataFit Admin`,
|
||||
}
|
||||
}
|
||||
|
||||
export default async function EditProgramPage({ params }: EditProgramPageProps) {
|
||||
const resolvedParams = await params
|
||||
const program = await getProgram(resolvedParams.id)
|
||||
|
||||
if (!program) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-4xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<Button variant="ghost" asChild className="mb-4 text-neutral-400 hover:text-white">
|
||||
<Link href={`/programs/${program.id}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Program
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Edit Program</h1>
|
||||
<p className="text-neutral-400">
|
||||
Update the details for "{program.title}"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-neutral-800 bg-neutral-900 p-6">
|
||||
<ProgramForm initialData={program} mode="edit" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
292
admin-web/app/programs/[id]/page.tsx
Normal file
292
admin-web/app/programs/[id]/page.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import { Metadata } from "next"
|
||||
import Link from "next/link"
|
||||
import { notFound } from "next/navigation"
|
||||
import { ArrowLeft, Edit, Trash2, Clock, Flame, Dumbbell, Zap, Timer, Target } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { supabase } from "@/lib/supabase"
|
||||
|
||||
interface ProgramDetailPageProps {
|
||||
params: Promise<{
|
||||
id: string
|
||||
}>
|
||||
}
|
||||
|
||||
const BODY_ZONE_COLORS: Record<string, string> = {
|
||||
"upper-body": "bg-green-500/20 text-green-500",
|
||||
"lower-body": "bg-purple-500/20 text-purple-500",
|
||||
"full-body": "bg-orange-500/20 text-orange-500",
|
||||
}
|
||||
|
||||
const LEVEL_COLORS: Record<string, string> = {
|
||||
"Beginner": "bg-emerald-500/20 text-emerald-500",
|
||||
"Intermediate": "bg-yellow-500/20 text-yellow-500",
|
||||
"Advanced": "bg-red-500/20 text-red-500",
|
||||
}
|
||||
|
||||
async function getProgram(id: string) {
|
||||
const { data, error } = await (supabase.from("workout_programs") as any)
|
||||
.select(`
|
||||
*,
|
||||
program_tabatas (*)
|
||||
`)
|
||||
.eq("id", id)
|
||||
.single()
|
||||
|
||||
if (error || !data) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Sort tabatas by position
|
||||
if (data.program_tabatas) {
|
||||
data.program_tabatas.sort((a: any, b: any) => a.position - b.position)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: ProgramDetailPageProps): Promise<Metadata> {
|
||||
const resolvedParams = await params
|
||||
const program = await getProgram(resolvedParams.id)
|
||||
|
||||
if (!program) {
|
||||
return {
|
||||
title: "Program Not Found | TabataFit Admin",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${program.title} | TabataFit Admin`,
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ProgramDetailPage({ params }: ProgramDetailPageProps) {
|
||||
const resolvedParams = await params
|
||||
const program = await getProgram(resolvedParams.id)
|
||||
|
||||
if (!program) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const tabatas = program.program_tabatas || []
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-8">
|
||||
<div>
|
||||
<Button variant="ghost" asChild className="mb-4 text-neutral-400 hover:text-white">
|
||||
<Link href="/programs">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Programs
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
{program.accent_color && (
|
||||
<div
|
||||
className="h-4 w-4 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: program.accent_color }}
|
||||
/>
|
||||
)}
|
||||
<h1 className="text-3xl font-bold text-white">{program.title}</h1>
|
||||
<Badge className={program.is_free ? "bg-emerald-500/20 text-emerald-500" : "bg-amber-500/20 text-amber-500"}>
|
||||
{program.is_free ? "Free" : "Premium"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-neutral-400">
|
||||
<Badge className={BODY_ZONE_COLORS[program.body_zone] || "bg-neutral-500/20 text-neutral-500"}>
|
||||
{program.body_zone}
|
||||
</Badge>
|
||||
<span className="text-neutral-600">|</span>
|
||||
<Badge className={LEVEL_COLORS[program.level] || "bg-neutral-500/20 text-neutral-500"}>
|
||||
{program.level}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{program.description && (
|
||||
<p className="text-neutral-400 mt-3 max-w-2xl">{program.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" asChild className="border-neutral-700">
|
||||
<Link href={`/programs/${program.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<Card className="bg-neutral-900 border-neutral-800">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-neutral-400 mb-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="text-sm">Duration</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">{program.estimated_duration} min</p>
|
||||
<p className="text-xs text-neutral-500">estimated total</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-neutral-900 border-neutral-800">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-neutral-400 mb-2">
|
||||
<Flame className="h-4 w-4" />
|
||||
<span className="text-sm">Calories</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">{program.estimated_calories}</p>
|
||||
<p className="text-xs text-neutral-500">estimated burn</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-neutral-900 border-neutral-800">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-neutral-400 mb-2">
|
||||
<Dumbbell className="h-4 w-4" />
|
||||
<span className="text-sm">Tabatas</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">{tabatas.length}</p>
|
||||
<p className="text-xs text-neutral-500">exercise pairs</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-neutral-900 border-neutral-800">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-neutral-400 mb-2">
|
||||
<Target className="h-4 w-4" />
|
||||
<span className="text-sm">Sort Order</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">{program.sort_order}</p>
|
||||
<p className="text-xs text-neutral-500">display position</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabatas */}
|
||||
<div className="space-y-6">
|
||||
{tabatas.map((tabata: any) => (
|
||||
<Card key={tabata.id} className="bg-neutral-900 border-neutral-800">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-orange-500/20 text-orange-500 font-bold text-sm">
|
||||
{tabata.position}
|
||||
</div>
|
||||
<CardTitle className="text-white">Tabata {tabata.position}</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-neutral-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<Timer className="h-3.5 w-3.5" />
|
||||
{tabata.rounds} rounds
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Zap className="h-3.5 w-3.5" />
|
||||
{tabata.work_time}s work
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{tabata.rest_time}s rest
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Exercise 1 */}
|
||||
<div className="rounded-lg border border-neutral-800 bg-neutral-950 p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="bg-orange-500/20 text-orange-500">
|
||||
Exercise 1
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white font-medium">{tabata.exercise_1_name}</p>
|
||||
{tabata.exercise_1_name_en && (
|
||||
<p className="text-sm text-neutral-500">{tabata.exercise_1_name_en}</p>
|
||||
)}
|
||||
</div>
|
||||
{tabata.exercise_1_tip && (
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500 mb-0.5">Tip</p>
|
||||
<p className="text-sm text-neutral-300">{tabata.exercise_1_tip}</p>
|
||||
{tabata.exercise_1_tip_en && (
|
||||
<p className="text-xs text-neutral-500">{tabata.exercise_1_tip_en}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{tabata.exercise_1_modification && (
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500 mb-0.5">Modification</p>
|
||||
<p className="text-sm text-neutral-300">{tabata.exercise_1_modification}</p>
|
||||
{tabata.exercise_1_modification_en && (
|
||||
<p className="text-xs text-neutral-500">{tabata.exercise_1_modification_en}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{tabata.exercise_1_progression && (
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500 mb-0.5">Progression</p>
|
||||
<p className="text-sm text-neutral-300">{tabata.exercise_1_progression}</p>
|
||||
{tabata.exercise_1_progression_en && (
|
||||
<p className="text-xs text-neutral-500">{tabata.exercise_1_progression_en}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Exercise 2 */}
|
||||
<div className="rounded-lg border border-neutral-800 bg-neutral-950 p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="bg-blue-500/20 text-blue-500">
|
||||
Exercise 2
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white font-medium">{tabata.exercise_2_name}</p>
|
||||
{tabata.exercise_2_name_en && (
|
||||
<p className="text-sm text-neutral-500">{tabata.exercise_2_name_en}</p>
|
||||
)}
|
||||
</div>
|
||||
{tabata.exercise_2_tip && (
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500 mb-0.5">Tip</p>
|
||||
<p className="text-sm text-neutral-300">{tabata.exercise_2_tip}</p>
|
||||
{tabata.exercise_2_tip_en && (
|
||||
<p className="text-xs text-neutral-500">{tabata.exercise_2_tip_en}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{tabata.exercise_2_modification && (
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500 mb-0.5">Modification</p>
|
||||
<p className="text-sm text-neutral-300">{tabata.exercise_2_modification}</p>
|
||||
{tabata.exercise_2_modification_en && (
|
||||
<p className="text-xs text-neutral-500">{tabata.exercise_2_modification_en}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{tabata.exercise_2_progression && (
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500 mb-0.5">Progression</p>
|
||||
<p className="text-sm text-neutral-300">{tabata.exercise_2_progression}</p>
|
||||
{tabata.exercise_2_progression_en && (
|
||||
<p className="text-xs text-neutral-500">{tabata.exercise_2_progression_en}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
11
admin-web/app/programs/new/CLAUDE.md
Normal file
11
admin-web/app/programs/new/CLAUDE.md
Normal file
@@ -0,0 +1,11 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Apr 16, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6319 | 9:38 PM | 🔵 | Discovered admin UI program management pages created | ~267 |
|
||||
</claude-mem-context>
|
||||
34
admin-web/app/programs/new/page.tsx
Normal file
34
admin-web/app/programs/new/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Metadata } from "next"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import ProgramForm from "@/components/program-form"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "New Program | TabataFit Admin",
|
||||
description: "Create a new workout program",
|
||||
}
|
||||
|
||||
export default function NewProgramPage() {
|
||||
return (
|
||||
<div className="p-8 max-w-4xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<Button variant="ghost" asChild className="mb-4 text-neutral-400 hover:text-white">
|
||||
<Link href="/programs">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Programs
|
||||
</Link>
|
||||
</Button>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Create New Program</h1>
|
||||
<p className="text-neutral-400">
|
||||
Fill in the details below to create a new workout program with 3 tabatas
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-neutral-800 bg-neutral-900 p-6">
|
||||
<ProgramForm mode="create" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
318
admin-web/app/programs/page.tsx
Normal file
318
admin-web/app/programs/page.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Plus, Trash2, Edit, Loader2, Eye } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { Database } from "@/lib/supabase";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
type WorkoutProgram = Database["public"]["Tables"]["workout_programs"]["Row"];
|
||||
|
||||
const BODY_ZONE_OPTIONS = [
|
||||
{ value: "all", label: "All Zones" },
|
||||
{ value: "upper-body", label: "Upper Body" },
|
||||
{ value: "lower-body", label: "Lower Body" },
|
||||
{ value: "full-body", label: "Full Body" },
|
||||
]
|
||||
|
||||
const LEVEL_OPTIONS = [
|
||||
{ value: "all", label: "All Levels" },
|
||||
{ value: "Beginner", label: "Beginner" },
|
||||
{ value: "Intermediate", label: "Intermediate" },
|
||||
{ value: "Advanced", label: "Advanced" },
|
||||
]
|
||||
|
||||
export default function ProgramsPage() {
|
||||
const router = useRouter();
|
||||
const [programs, setPrograms] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [programToDelete, setProgramToDelete] = useState<WorkoutProgram | null>(null);
|
||||
const [filterZone, setFilterZone] = useState("all");
|
||||
const [filterLevel, setFilterLevel] = useState("all");
|
||||
|
||||
useEffect(() => {
|
||||
fetchPrograms();
|
||||
}, []);
|
||||
|
||||
const fetchPrograms = async () => {
|
||||
try {
|
||||
const { data, error } = await (supabase.from("workout_programs") as any)
|
||||
.select(`
|
||||
*,
|
||||
program_tabatas (id)
|
||||
`)
|
||||
.order("sort_order", { ascending: true });
|
||||
|
||||
if (error) throw error;
|
||||
setPrograms(data || []);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch programs:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openDeleteDialog = (program: WorkoutProgram) => {
|
||||
setProgramToDelete(program);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!programToDelete) return;
|
||||
|
||||
setDeletingId(programToDelete.id);
|
||||
try {
|
||||
// Delete tabatas first (foreign key constraint)
|
||||
const { error: tabataError } = await (supabase.from("program_tabatas") as any)
|
||||
.delete()
|
||||
.eq("program_id", programToDelete.id);
|
||||
if (tabataError) throw tabataError;
|
||||
|
||||
const { error } = await (supabase.from("workout_programs") as any)
|
||||
.delete()
|
||||
.eq("id", programToDelete.id);
|
||||
if (error) throw error;
|
||||
|
||||
setPrograms(programs.filter((p) => p.id !== programToDelete.id));
|
||||
toast.success("Program deleted", {
|
||||
description: "The program has been removed successfully."
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to delete program:", error);
|
||||
toast.error("Failed to delete program");
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
setDeleteDialogOpen(false);
|
||||
setProgramToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getBodyZoneColor = (zone: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
"upper-body": "bg-green-500/20 text-green-500",
|
||||
"lower-body": "bg-purple-500/20 text-purple-500",
|
||||
"full-body": "bg-orange-500/20 text-orange-500",
|
||||
};
|
||||
return colors[zone] || "bg-neutral-500/20 text-neutral-500";
|
||||
};
|
||||
|
||||
const getLevelColor = (level: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
"Beginner": "bg-emerald-500/20 text-emerald-500",
|
||||
"Intermediate": "bg-yellow-500/20 text-yellow-500",
|
||||
"Advanced": "bg-red-500/20 text-red-500",
|
||||
};
|
||||
return colors[level] || "bg-neutral-500/20 text-neutral-500";
|
||||
};
|
||||
|
||||
const filteredPrograms = programs.filter((p) => {
|
||||
if (filterZone !== "all" && p.body_zone !== filterZone) return false;
|
||||
if (filterLevel !== "all" && p.level !== filterLevel) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Programs</h1>
|
||||
<p className="text-neutral-400">Manage your workout programs</p>
|
||||
</div>
|
||||
<Button className="bg-orange-500 hover:bg-orange-600" asChild>
|
||||
<Link href="/programs/new">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Program
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-4 mb-6">
|
||||
<div className="w-48">
|
||||
<Select
|
||||
value={filterZone}
|
||||
onValueChange={setFilterZone}
|
||||
options={BODY_ZONE_OPTIONS}
|
||||
placeholder="Filter by zone"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-48">
|
||||
<Select
|
||||
value={filterLevel}
|
||||
onValueChange={setFilterLevel}
|
||||
options={LEVEL_OPTIONS}
|
||||
placeholder="Filter by level"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="bg-neutral-900 border-neutral-800">
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="p-8 flex justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
|
||||
</div>
|
||||
) : filteredPrograms.length === 0 ? (
|
||||
<div className="p-8 text-center text-neutral-500">
|
||||
{programs.length === 0
|
||||
? "No programs yet. Create your first program to get started."
|
||||
: "No programs match your filters."}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-neutral-800">
|
||||
<TableHead className="text-neutral-400">Title</TableHead>
|
||||
<TableHead className="text-neutral-400">Body Zone</TableHead>
|
||||
<TableHead className="text-neutral-400">Level</TableHead>
|
||||
<TableHead className="text-neutral-400">Access</TableHead>
|
||||
<TableHead className="text-neutral-400">Duration</TableHead>
|
||||
<TableHead className="text-neutral-400">Calories</TableHead>
|
||||
<TableHead className="text-neutral-400">Tabatas</TableHead>
|
||||
<TableHead className="text-neutral-400 text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredPrograms.map((program) => (
|
||||
<TableRow
|
||||
key={program.id}
|
||||
className="border-neutral-800 cursor-pointer hover:bg-neutral-800/50 transition-colors"
|
||||
onClick={() => router.push(`/programs/${program.id}`)}
|
||||
>
|
||||
<TableCell className="text-white font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
{program.accent_color && (
|
||||
<div
|
||||
className="h-3 w-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: program.accent_color }}
|
||||
/>
|
||||
)}
|
||||
{program.title}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={getBodyZoneColor(program.body_zone)}>
|
||||
{program.body_zone}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={getLevelColor(program.level)}>
|
||||
{program.level}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={program.is_free ? "bg-emerald-500/20 text-emerald-500" : "bg-amber-500/20 text-amber-500"}>
|
||||
{program.is_free ? "Free" : "Premium"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-neutral-300">{program.estimated_duration} min</TableCell>
|
||||
<TableCell className="text-neutral-300">{program.estimated_calories}</TableCell>
|
||||
<TableCell className="text-neutral-300">
|
||||
{program.program_tabatas?.length || 0}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-neutral-400 hover:text-white"
|
||||
asChild
|
||||
>
|
||||
<Link href={`/programs/${program.id}`}>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-neutral-400 hover:text-white"
|
||||
asChild
|
||||
>
|
||||
<Link href={`/programs/${program.id}/edit`}>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-neutral-400 hover:text-red-500"
|
||||
onClick={() => openDeleteDialog(program)}
|
||||
disabled={deletingId === program.id}
|
||||
>
|
||||
{deletingId === program.id ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent className="bg-neutral-900 border-neutral-800 text-white">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Program</DialogTitle>
|
||||
<DialogDescription className="text-neutral-400">
|
||||
Are you sure you want to delete "{programToDelete?.title}"? This will also delete all associated tabatas. This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(false)}
|
||||
className="border-neutral-700 text-neutral-300 hover:bg-neutral-800"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
disabled={!!deletingId}
|
||||
className="bg-red-500 hover:bg-red-600"
|
||||
>
|
||||
{deletingId ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
"Delete"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
admin-web/app/trainers/[id]/edit/page.tsx
Normal file
66
admin-web/app/trainers/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Metadata } from "next"
|
||||
import Link from "next/link"
|
||||
import { notFound } from "next/navigation"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import TrainerForm from "@/components/trainer-form"
|
||||
import { supabase } from "@/lib/supabase"
|
||||
|
||||
interface EditTrainerPageProps {
|
||||
params: Promise<{
|
||||
id: string
|
||||
}>
|
||||
}
|
||||
|
||||
async function getTrainer(id: string) {
|
||||
const { data, error } = await (supabase.from("trainers") as any)
|
||||
.select("*")
|
||||
.eq("id", id)
|
||||
.single()
|
||||
|
||||
if (error || !data) {
|
||||
return null
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: EditTrainerPageProps): Promise<Metadata> {
|
||||
const resolvedParams = await params
|
||||
const trainer = await getTrainer(resolvedParams.id)
|
||||
|
||||
if (!trainer) {
|
||||
return {
|
||||
title: "Trainer Not Found | TabataFit Admin",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: `Edit ${trainer.name} | TabataFit Admin`,
|
||||
}
|
||||
}
|
||||
|
||||
export default async function EditTrainerPage({ params }: EditTrainerPageProps) {
|
||||
const resolvedParams = await params
|
||||
const trainer = await getTrainer(resolvedParams.id)
|
||||
|
||||
if (!trainer) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-2xl">
|
||||
<Button variant="ghost" asChild className="mb-4 text-neutral-400 hover:text-white">
|
||||
<Link href="/trainers">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Trainers
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<h1 className="text-2xl font-bold text-white mb-6">Edit Trainer</h1>
|
||||
|
||||
<TrainerForm initialData={trainer} mode="edit" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
10
admin-web/app/trainers/new/page.tsx
Normal file
10
admin-web/app/trainers/new/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import TrainerForm from "@/components/trainer-form"
|
||||
|
||||
export default function NewTrainerPage() {
|
||||
return (
|
||||
<div className="p-8 max-w-2xl">
|
||||
<h1 className="text-2xl font-bold text-white mb-6">Add New Trainer</h1>
|
||||
<TrainerForm mode="create" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { Plus, Trash2, Edit, Loader2, Users, AlertCircle } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
import type { Database } from "@/lib/supabase";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type Trainer = Database["public"]["Tables"]["trainers"]["Row"];
|
||||
|
||||
@@ -145,12 +146,21 @@ export default function TrainersPage() {
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className="w-12 h-12 rounded-full flex items-center justify-center text-white font-bold"
|
||||
style={{ backgroundColor: trainer.color }}
|
||||
>
|
||||
{trainer.name[0]}
|
||||
</div>
|
||||
{trainer.avatar_url ? (
|
||||
<img
|
||||
src={trainer.avatar_url}
|
||||
alt={trainer.name}
|
||||
className="w-12 h-12 rounded-full object-cover border-2"
|
||||
style={{ borderColor: trainer.color }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-12 h-12 rounded-full flex items-center justify-center text-white font-bold"
|
||||
style={{ backgroundColor: trainer.color }}
|
||||
>
|
||||
{trainer.name[0]}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">{trainer.name}</h3>
|
||||
<p className="text-neutral-400">{trainer.specialty}</p>
|
||||
@@ -161,9 +171,11 @@ export default function TrainersPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="icon" className="text-neutral-400 hover:text-white">
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Link href={`/trainers/${trainer.id}/edit`}>
|
||||
<Button variant="ghost" size="icon" className="text-neutral-400 hover:text-white">
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
@@ -14,8 +14,7 @@ interface EditWorkoutPageProps {
|
||||
}
|
||||
|
||||
async function getWorkout(id: string) {
|
||||
const { data, error } = await supabase
|
||||
.from("workouts")
|
||||
const { data, error } = await (supabase.from("workouts") as any)
|
||||
.select("*")
|
||||
.eq("id", id)
|
||||
.single()
|
||||
|
||||
283
admin-web/app/workouts/[id]/generate-video/page.tsx
Normal file
283
admin-web/app/workouts/[id]/generate-video/page.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter, useParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { Loader2, Play, CheckCircle, AlertCircle, ArrowLeft, Video } from "lucide-react"
|
||||
import { supabase } from "@/lib/supabase"
|
||||
import { getWorkoutVideoStatus } from "@/lib/ai"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { toast } from "sonner"
|
||||
|
||||
type Workout = {
|
||||
id: string
|
||||
title: string
|
||||
trainer_id: string
|
||||
exercises: { name: string; duration: number }[]
|
||||
video_url: string | null
|
||||
}
|
||||
|
||||
type Trainer = {
|
||||
id: string
|
||||
name: string
|
||||
avatar_url: string | null
|
||||
specialty: string
|
||||
}
|
||||
|
||||
export default function GenerateVideoPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const workoutId = params.id as string
|
||||
|
||||
const [workout, setWorkout] = useState<Workout | null>(null)
|
||||
const [trainer, setTrainer] = useState<Trainer | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [status, setStatus] = useState<"idle" | "generating" | "done" | "error">("idle")
|
||||
const [videoStatus, setVideoStatus] = useState<{
|
||||
hasVideo: boolean
|
||||
videoUrl: string | null
|
||||
inProgram: boolean
|
||||
videoGenerated: boolean
|
||||
} | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (workoutId) {
|
||||
loadData(workoutId)
|
||||
}
|
||||
}, [workoutId])
|
||||
|
||||
const loadData = async (id: string) => {
|
||||
setLoading(true)
|
||||
|
||||
const { data: w } = await (supabase.from("workouts") as any)
|
||||
.select("*")
|
||||
.eq("id", id)
|
||||
.single()
|
||||
|
||||
if (!w) {
|
||||
toast.error("Workout not found")
|
||||
router.push("/workouts")
|
||||
return
|
||||
}
|
||||
setWorkout(w)
|
||||
|
||||
if (w.trainer_id) {
|
||||
const { data: t } = await (supabase.from("trainers") as any)
|
||||
.select("*")
|
||||
.eq("id", w.trainer_id)
|
||||
.single()
|
||||
setTrainer(t)
|
||||
}
|
||||
|
||||
const videoStatus = await getWorkoutVideoStatus(id)
|
||||
setVideoStatus(videoStatus)
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!workout || !trainer) return
|
||||
|
||||
setStatus("generating")
|
||||
setGenerating(true)
|
||||
setProgress(0)
|
||||
|
||||
try {
|
||||
const progressInterval = setInterval(() => {
|
||||
setProgress(p => Math.min(p + 5, 90))
|
||||
}, 3000)
|
||||
|
||||
const response = await fetch('/api/ai/generate-video', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
workoutId: workout.id,
|
||||
trainerId: trainer.id,
|
||||
trainerAvatarUrl: trainer.avatar_url,
|
||||
exercises: workout.exercises,
|
||||
}),
|
||||
})
|
||||
|
||||
clearInterval(progressInterval)
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Generation failed')
|
||||
}
|
||||
|
||||
const { videos, videoCount } = await response.json()
|
||||
|
||||
setProgress(100)
|
||||
setVideoStatus(prev => prev ? { ...prev, hasVideo: true, videoUrl: videos[0]?.videoUrl } : null)
|
||||
|
||||
if (videoStatus?.inProgram) {
|
||||
await (supabase.from("program_workouts") as any)
|
||||
.update({ video_generated: true })
|
||||
.eq("workout_id", workout.id)
|
||||
}
|
||||
|
||||
setStatus("done")
|
||||
toast.success(`Generated ${videoCount} exercise video(s)!`)
|
||||
} catch (err) {
|
||||
console.error("Generation failed:", err)
|
||||
toast.error(err instanceof Error ? err.message : "Failed to generate video")
|
||||
setStatus("error")
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-[400px]">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!workout) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
<p className="text-neutral-400">Workout not found</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const needsGeneration = !videoStatus?.hasVideo
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-3xl">
|
||||
<Button variant="ghost" asChild className="mb-4 text-neutral-400 hover:text-white">
|
||||
<Link href={`/workouts/${workout.id}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Workout
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<h1 className="text-2xl font-bold text-white mb-6">Generate Workout Video</h1>
|
||||
|
||||
<Card className="bg-neutral-900 border-neutral-800">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-white">{workout.title}</CardTitle>
|
||||
{videoStatus?.inProgram && (
|
||||
<Badge variant="outline" className="border-amber-500 text-amber-500">
|
||||
In Program
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center gap-4 p-4 rounded-lg bg-neutral-800">
|
||||
{trainer?.avatar_url && (
|
||||
<img
|
||||
src={trainer.avatar_url}
|
||||
alt={trainer.name}
|
||||
className="w-16 h-16 rounded-full object-cover border-2 border-neutral-700"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-white">{trainer?.name}</p>
|
||||
<p className="text-sm text-neutral-400">{trainer?.specialty}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{videoStatus?.inProgram && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20">
|
||||
<AlertCircle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="text-amber-500 font-medium">Workout is in a program</p>
|
||||
<p className="text-neutral-400">
|
||||
The video_generated flag will be set to true after generation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-3">Exercises</h3>
|
||||
<div className="space-y-2">
|
||||
{workout.exercises?.map((ex, i) => (
|
||||
<div key={i} className="flex items-center gap-3 text-sm">
|
||||
<span className="w-8 h-8 rounded-full bg-neutral-800 flex items-center justify-center text-neutral-400 text-xs">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="text-neutral-300">{ex.name}</span>
|
||||
<span className="text-neutral-500 ml-auto">{ex.duration}s</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{videoStatus?.hasVideo && videoStatus.videoUrl && !generating && (
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-3">Generated Video</h3>
|
||||
<video
|
||||
src={videoStatus.videoUrl}
|
||||
controls
|
||||
autoPlay
|
||||
loop
|
||||
className="w-full max-h-80 rounded-lg border border-neutral-700"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-2">
|
||||
Video loops seamlessly (8 seconds)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{needsGeneration && (
|
||||
<div className="pt-4 border-t border-neutral-800">
|
||||
{status === "generating" ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-purple-500" />
|
||||
<span className="text-white">Generating video with Veo 3.1...</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-neutral-800">
|
||||
<div
|
||||
className="h-full bg-purple-500 transition-all duration-1000"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-neutral-400">
|
||||
<Video className="w-4 h-4" />
|
||||
<span>Creating 8-second looping video with {trainer?.name}</span>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500">
|
||||
This may take up to 2 minutes. Please wait...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleGenerate}
|
||||
className="w-full bg-purple-500 hover:bg-purple-600"
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
Generate 8s Looping Video
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "done" && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-green-500/10 border border-green-500/20">
|
||||
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||
<span className="text-green-500 font-medium">Video generated and saved!</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "error" && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||
<AlertCircle className="w-5 h-5 text-red-500" />
|
||||
<span className="text-red-500">Video generation failed. Check console for details.</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Metadata } from "next"
|
||||
import Link from "next/link"
|
||||
import { notFound } from "next/navigation"
|
||||
import { ArrowLeft, Edit, Trash2, Clock, Flame, Dumbbell, Music } from "lucide-react"
|
||||
import { ArrowLeft, Edit, Trash2, Clock, Flame, Dumbbell, Music, Video } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
@@ -23,8 +23,7 @@ const CATEGORY_COLORS: Record<string, string> = {
|
||||
}
|
||||
|
||||
async function getWorkout(id: string) {
|
||||
const { data, error } = await supabase
|
||||
.from("workouts")
|
||||
const { data, error } = await (supabase.from("workouts") as any)
|
||||
.select(`
|
||||
*,
|
||||
trainers (name, specialty, color)
|
||||
@@ -95,6 +94,12 @@ export default async function WorkoutDetailPage({ params }: WorkoutDetailPagePro
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" asChild className="border-neutral-700">
|
||||
<Link href={`/workouts/${workout.id}/generate-video`}>
|
||||
<Video className="mr-2 h-4 w-4" />
|
||||
Generate Video
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild className="border-neutral-700">
|
||||
<Link href={`/workouts/${workout.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
|
||||
12
admin-web/components/CLAUDE.md
Normal file
12
admin-web/components/CLAUDE.md
Normal file
@@ -0,0 +1,12 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Apr 16, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6321 | 9:38 PM | 🟣 | Created TabataEditor component for admin workout program management | ~327 |
|
||||
| #6325 | " | 🟣 | Added Programs navigation link to admin sidebar | ~260 |
|
||||
</claude-mem-context>
|
||||
575
admin-web/components/program-form.tsx
Normal file
575
admin-web/components/program-form.tsx
Normal file
@@ -0,0 +1,575 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Loader2, Save, X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { supabase } from "@/lib/supabase"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Select } from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import TabataEditor, { TabataData } from "@/components/tabata-editor"
|
||||
import { TimedExerciseList } from "@/components/timed-exercise-list"
|
||||
import { TimedExerciseData, createEmptyTimedExercise } from "@/components/timed-exercise-editor"
|
||||
import { toast } from "sonner"
|
||||
import type { Database } from "@/lib/supabase"
|
||||
|
||||
type WorkoutProgram = Database["public"]["Tables"]["workout_programs"]["Row"]
|
||||
type ProgramTabata = Database["public"]["Tables"]["program_tabatas"]["Row"]
|
||||
type WarmupRow = Database["public"]["Tables"]["workout_warmup_exercises"]["Row"]
|
||||
type StretchRow = Database["public"]["Tables"]["workout_stretch_exercises"]["Row"]
|
||||
|
||||
interface ProgramFormProps {
|
||||
initialData?: WorkoutProgram & {
|
||||
tabatas?: ProgramTabata[]
|
||||
warmup?: WarmupRow[]
|
||||
stretch?: StretchRow[]
|
||||
}
|
||||
mode?: "create" | "edit"
|
||||
}
|
||||
|
||||
const BODY_ZONE_OPTIONS = [
|
||||
{ value: "upper-body", label: "Upper Body" },
|
||||
{ value: "lower-body", label: "Lower Body" },
|
||||
{ value: "full-body", label: "Full Body" },
|
||||
]
|
||||
|
||||
const LEVEL_OPTIONS = [
|
||||
{ value: "Beginner", label: "Beginner" },
|
||||
{ value: "Intermediate", label: "Intermediate" },
|
||||
{ value: "Advanced", label: "Advanced" },
|
||||
]
|
||||
|
||||
export default function ProgramForm({ initialData, mode = "create" }: ProgramFormProps) {
|
||||
const router = useRouter()
|
||||
const [isLoading, setIsLoading] = React.useState(false)
|
||||
const [errors, setErrors] = React.useState<Record<string, string>>({})
|
||||
|
||||
// Basics state
|
||||
const [title, setTitle] = React.useState(initialData?.title || "")
|
||||
const [description, setDescription] = React.useState(initialData?.description || "")
|
||||
const [bodyZone, setBodyZone] = React.useState(initialData?.body_zone || "full-body")
|
||||
const [level, setLevel] = React.useState(initialData?.level || "Beginner")
|
||||
const [isFree, setIsFree] = React.useState(initialData?.is_free || false)
|
||||
const [estimatedCalories, setEstimatedCalories] = React.useState(
|
||||
String(initialData?.estimated_calories || "")
|
||||
)
|
||||
const [icon, setIcon] = React.useState(initialData?.icon || "")
|
||||
const [accentColor, setAccentColor] = React.useState(initialData?.accent_color || "")
|
||||
const [sortOrder, setSortOrder] = React.useState(
|
||||
String(initialData?.sort_order ?? "0")
|
||||
)
|
||||
|
||||
// Tabatas state
|
||||
const [tabatas, setTabatas] = React.useState<TabataData[]>(() => {
|
||||
if (initialData?.tabatas && initialData.tabatas.length > 0) {
|
||||
return initialData.tabatas
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((t) => ({
|
||||
position: t.position,
|
||||
exercise_1_name: t.exercise_1_name || "",
|
||||
exercise_1_name_en: t.exercise_1_name_en || "",
|
||||
exercise_1_tip: t.exercise_1_tip || "",
|
||||
exercise_1_tip_en: t.exercise_1_tip_en || "",
|
||||
exercise_1_modification: t.exercise_1_modification || "",
|
||||
exercise_1_modification_en: t.exercise_1_modification_en || "",
|
||||
exercise_1_progression: t.exercise_1_progression || "",
|
||||
exercise_1_progression_en: t.exercise_1_progression_en || "",
|
||||
exercise_1_video_url: t.exercise_1_video_url || "",
|
||||
exercise_2_name: t.exercise_2_name || "",
|
||||
exercise_2_name_en: t.exercise_2_name_en || "",
|
||||
exercise_2_tip: t.exercise_2_tip || "",
|
||||
exercise_2_tip_en: t.exercise_2_tip_en || "",
|
||||
exercise_2_modification: t.exercise_2_modification || "",
|
||||
exercise_2_modification_en: t.exercise_2_modification_en || "",
|
||||
exercise_2_progression: t.exercise_2_progression || "",
|
||||
exercise_2_progression_en: t.exercise_2_progression_en || "",
|
||||
exercise_2_video_url: t.exercise_2_video_url || "",
|
||||
rounds: t.rounds || 8,
|
||||
work_time: t.work_time || 20,
|
||||
rest_time: t.rest_time || 10,
|
||||
}))
|
||||
}
|
||||
return [
|
||||
{ position: 1, exercise_1_name: "", exercise_1_name_en: "", exercise_1_tip: "", exercise_1_tip_en: "", exercise_1_modification: "", exercise_1_modification_en: "", exercise_1_progression: "", exercise_1_progression_en: "", exercise_1_video_url: "", exercise_2_name: "", exercise_2_name_en: "", exercise_2_tip: "", exercise_2_tip_en: "", exercise_2_modification: "", exercise_2_modification_en: "", exercise_2_progression: "", exercise_2_progression_en: "", exercise_2_video_url: "", rounds: 8, work_time: 20, rest_time: 10 },
|
||||
{ position: 2, exercise_1_name: "", exercise_1_name_en: "", exercise_1_tip: "", exercise_1_tip_en: "", exercise_1_modification: "", exercise_1_modification_en: "", exercise_1_progression: "", exercise_1_progression_en: "", exercise_1_video_url: "", exercise_2_name: "", exercise_2_name_en: "", exercise_2_tip: "", exercise_2_tip_en: "", exercise_2_modification: "", exercise_2_modification_en: "", exercise_2_progression: "", exercise_2_progression_en: "", exercise_2_video_url: "", rounds: 8, work_time: 20, rest_time: 10 },
|
||||
{ position: 3, exercise_1_name: "", exercise_1_name_en: "", exercise_1_tip: "", exercise_1_tip_en: "", exercise_1_modification: "", exercise_1_modification_en: "", exercise_1_progression: "", exercise_1_progression_en: "", exercise_1_video_url: "", exercise_2_name: "", exercise_2_name_en: "", exercise_2_tip: "", exercise_2_tip_en: "", exercise_2_modification: "", exercise_2_modification_en: "", exercise_2_progression: "", exercise_2_progression_en: "", exercise_2_video_url: "", rounds: 8, work_time: 20, rest_time: 10 },
|
||||
]
|
||||
})
|
||||
|
||||
// Warmup state
|
||||
const [warmup, setWarmup] = React.useState<TimedExerciseData[]>(() => {
|
||||
if (initialData?.warmup && initialData.warmup.length > 0) {
|
||||
return initialData.warmup
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((w, i) => ({
|
||||
position: i + 1,
|
||||
name: w.name || "",
|
||||
name_en: w.name_en || "",
|
||||
tip: w.tip || "",
|
||||
tip_en: w.tip_en || "",
|
||||
duration: w.duration || 30,
|
||||
video_url: w.video_url || "",
|
||||
}))
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
// Stretch state
|
||||
const [stretch, setStretch] = React.useState<TimedExerciseData[]>(() => {
|
||||
if (initialData?.stretch && initialData.stretch.length > 0) {
|
||||
return initialData.stretch
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((s, i) => ({
|
||||
position: i + 1,
|
||||
name: s.name || "",
|
||||
name_en: s.name_en || "",
|
||||
tip: s.tip || "",
|
||||
tip_en: s.tip_en || "",
|
||||
duration: s.duration || 30,
|
||||
video_url: s.video_url || "",
|
||||
}))
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
const validate = () => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
|
||||
if (!title.trim()) newErrors.title = "Title is required"
|
||||
|
||||
tabatas.forEach((tabata, i) => {
|
||||
if (!tabata.exercise_1_name.trim()) {
|
||||
newErrors[`tabata_${i + 1}_ex1`] = `Tabata ${i + 1}: Exercise 1 name is required`
|
||||
}
|
||||
if (!tabata.exercise_2_name.trim()) {
|
||||
newErrors[`tabata_${i + 1}_ex2`] = `Tabata ${i + 1}: Exercise 2 name is required`
|
||||
}
|
||||
})
|
||||
|
||||
warmup.forEach((w, i) => {
|
||||
if (!w.name.trim()) newErrors[`warmup_${i}`] = `Warmup ${i + 1}: Name is required`
|
||||
if (!w.duration || w.duration < 1) newErrors[`warmup_${i}_dur`] = `Warmup ${i + 1}: Duration must be >= 1`
|
||||
})
|
||||
|
||||
stretch.forEach((s, i) => {
|
||||
if (!s.name.trim()) newErrors[`stretch_${i}`] = `Stretch ${i + 1}: Name is required`
|
||||
if (!s.duration || s.duration < 1) newErrors[`stretch_${i}_dur`] = `Stretch ${i + 1}: Duration must be >= 1`
|
||||
})
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!validate()) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
// Calculate total estimated duration from tabatas
|
||||
const totalSeconds = tabatas.reduce(
|
||||
(sum, t) => sum + t.rounds * (t.work_time + t.rest_time),
|
||||
0
|
||||
)
|
||||
const estimatedDuration = Math.ceil(totalSeconds / 60)
|
||||
|
||||
const programData = {
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
body_zone: bodyZone as WorkoutProgram["body_zone"],
|
||||
level: level as WorkoutProgram["level"],
|
||||
is_free: isFree,
|
||||
estimated_duration: estimatedDuration,
|
||||
estimated_calories: parseInt(estimatedCalories) || 0,
|
||||
icon: icon.trim() || null,
|
||||
accent_color: accentColor.trim() || null,
|
||||
sort_order: parseInt(sortOrder) || 0,
|
||||
}
|
||||
|
||||
let programId: string
|
||||
|
||||
if (mode === "edit" && initialData) {
|
||||
const result = await (supabase.from("workout_programs") as any)
|
||||
.update(programData)
|
||||
.eq("id", initialData.id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (result.error) throw result.error
|
||||
programId = initialData.id
|
||||
} else {
|
||||
const result = await (supabase.from("workout_programs") as any)
|
||||
.insert(programData)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (result.error) throw result.error
|
||||
programId = result.data.id
|
||||
}
|
||||
|
||||
// Upsert tabatas
|
||||
for (const tabata of tabatas) {
|
||||
const tabataPayload = {
|
||||
program_id: programId,
|
||||
position: tabata.position,
|
||||
exercise_1_name: tabata.exercise_1_name.trim(),
|
||||
exercise_1_name_en: tabata.exercise_1_name_en.trim() || null,
|
||||
exercise_1_tip: tabata.exercise_1_tip.trim() || null,
|
||||
exercise_1_tip_en: tabata.exercise_1_tip_en.trim() || null,
|
||||
exercise_1_modification: tabata.exercise_1_modification.trim() || null,
|
||||
exercise_1_modification_en: tabata.exercise_1_modification_en.trim() || null,
|
||||
exercise_1_progression: tabata.exercise_1_progression.trim() || null,
|
||||
exercise_1_progression_en: tabata.exercise_1_progression_en.trim() || null,
|
||||
exercise_1_video_url: tabata.exercise_1_video_url.trim() || null,
|
||||
exercise_2_name: tabata.exercise_2_name.trim(),
|
||||
exercise_2_name_en: tabata.exercise_2_name_en.trim() || null,
|
||||
exercise_2_tip: tabata.exercise_2_tip.trim() || null,
|
||||
exercise_2_tip_en: tabata.exercise_2_tip_en.trim() || null,
|
||||
exercise_2_modification: tabata.exercise_2_modification.trim() || null,
|
||||
exercise_2_modification_en: tabata.exercise_2_modification_en.trim() || null,
|
||||
exercise_2_progression: tabata.exercise_2_progression.trim() || null,
|
||||
exercise_2_progression_en: tabata.exercise_2_progression_en.trim() || null,
|
||||
exercise_2_video_url: tabata.exercise_2_video_url.trim() || null,
|
||||
rounds: tabata.rounds,
|
||||
work_time: tabata.work_time,
|
||||
rest_time: tabata.rest_time,
|
||||
}
|
||||
|
||||
// In edit mode, check if tabata exists for this position
|
||||
if (mode === "edit") {
|
||||
const existing = initialData?.tabatas?.find((t) => t.position === tabata.position)
|
||||
if (existing) {
|
||||
const { error } = await (supabase.from("program_tabatas") as any)
|
||||
.update(tabataPayload)
|
||||
.eq("id", existing.id)
|
||||
if (error) throw error
|
||||
} else {
|
||||
const { error } = await (supabase.from("program_tabatas") as any)
|
||||
.insert(tabataPayload)
|
||||
if (error) throw error
|
||||
}
|
||||
} else {
|
||||
const { error } = await (supabase.from("program_tabatas") as any)
|
||||
.insert(tabataPayload)
|
||||
if (error) throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Replace warmup exercises (delete all + insert)
|
||||
{
|
||||
const { error: delErr } = await (supabase.from("workout_warmup_exercises") as any)
|
||||
.delete()
|
||||
.eq("program_id", programId)
|
||||
if (delErr) throw delErr
|
||||
|
||||
if (warmup.length > 0) {
|
||||
const warmupPayload = warmup.map((w, i) => ({
|
||||
program_id: programId,
|
||||
position: i + 1,
|
||||
name: w.name.trim(),
|
||||
name_en: w.name_en.trim() || null,
|
||||
tip: w.tip.trim() || null,
|
||||
tip_en: w.tip_en.trim() || null,
|
||||
duration: w.duration,
|
||||
video_url: w.video_url.trim() || null,
|
||||
}))
|
||||
const { error: insErr } = await (supabase.from("workout_warmup_exercises") as any)
|
||||
.insert(warmupPayload)
|
||||
if (insErr) throw insErr
|
||||
}
|
||||
}
|
||||
|
||||
// Replace stretch exercises (delete all + insert)
|
||||
{
|
||||
const { error: delErr } = await (supabase.from("workout_stretch_exercises") as any)
|
||||
.delete()
|
||||
.eq("program_id", programId)
|
||||
if (delErr) throw delErr
|
||||
|
||||
if (stretch.length > 0) {
|
||||
const stretchPayload = stretch.map((s, i) => ({
|
||||
program_id: programId,
|
||||
position: i + 1,
|
||||
name: s.name.trim(),
|
||||
name_en: s.name_en.trim() || null,
|
||||
tip: s.tip.trim() || null,
|
||||
tip_en: s.tip_en.trim() || null,
|
||||
duration: s.duration,
|
||||
video_url: s.video_url.trim() || null,
|
||||
}))
|
||||
const { error: insErr } = await (supabase.from("workout_stretch_exercises") as any)
|
||||
.insert(stretchPayload)
|
||||
if (insErr) throw insErr
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(mode === "edit" ? "Program updated" : "Program created", {
|
||||
description: `"${title}" has been ${mode === "edit" ? "updated" : "created"} successfully.`
|
||||
})
|
||||
|
||||
router.push(`/programs/${programId}`)
|
||||
} catch (err) {
|
||||
console.error("Failed to save program:", err)
|
||||
toast.error("Failed to save program. Please try again.")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
if (mode === "edit" && initialData) {
|
||||
router.push(`/programs/${initialData.id}`)
|
||||
} else {
|
||||
router.push("/programs")
|
||||
}
|
||||
}
|
||||
|
||||
const handleTabataChange = (position: number, data: TabataData) => {
|
||||
setTabatas((prev) =>
|
||||
prev.map((t) => (t.position === position ? { ...data, position } : t))
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<Tabs defaultValue="basics" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-4 bg-neutral-900">
|
||||
<TabsTrigger value="basics" className="data-[state=active]:bg-neutral-800">
|
||||
Basics
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="warmup" className="data-[state=active]:bg-neutral-800">
|
||||
Warmup
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tabatas" className="data-[state=active]:bg-neutral-800">
|
||||
Tabatas
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="stretch" className="data-[state=active]:bg-neutral-800">
|
||||
Stretch
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Tab 1: Basics */}
|
||||
<TabsContent value="basics" className="space-y-4">
|
||||
<div className="grid gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Program Title *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g., Full Body Blast"
|
||||
className={cn(errors.title && "border-red-500")}
|
||||
/>
|
||||
{errors.title && <p className="text-xs text-red-500">{errors.title}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Describe this program..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bodyZone">Body Zone *</Label>
|
||||
<Select
|
||||
id="bodyZone"
|
||||
value={bodyZone}
|
||||
onValueChange={(value) => setBodyZone(value as typeof bodyZone)}
|
||||
options={BODY_ZONE_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="level">Level *</Label>
|
||||
<Select
|
||||
id="level"
|
||||
value={level}
|
||||
onValueChange={(value) => setLevel(value as typeof level)}
|
||||
options={LEVEL_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border border-neutral-800 bg-neutral-900/50 p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="isFree" className="text-base">
|
||||
Free Program
|
||||
</Label>
|
||||
<p className="text-sm text-neutral-500">
|
||||
Make this program available to free users
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="isFree" checked={isFree} onCheckedChange={setIsFree} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="estimatedCalories">Estimated Calories</Label>
|
||||
<Input
|
||||
id="estimatedCalories"
|
||||
type="number"
|
||||
value={estimatedCalories}
|
||||
onChange={(e) => setEstimatedCalories(e.target.value)}
|
||||
min={0}
|
||||
placeholder="e.g., 120"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sortOrder">Sort Order</Label>
|
||||
<Input
|
||||
id="sortOrder"
|
||||
type="number"
|
||||
value={sortOrder}
|
||||
onChange={(e) => setSortOrder(e.target.value)}
|
||||
min={0}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="icon">Icon Name</Label>
|
||||
<Input
|
||||
id="icon"
|
||||
value={icon}
|
||||
onChange={(e) => setIcon(e.target.value)}
|
||||
placeholder="e.g., flame, dumbbell"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="accentColor">Accent Color</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="accentColor"
|
||||
value={accentColor}
|
||||
onChange={(e) => setAccentColor(e.target.value)}
|
||||
placeholder="#FF6B35"
|
||||
className="flex-1"
|
||||
/>
|
||||
{accentColor && (
|
||||
<div
|
||||
className="h-10 w-10 rounded-md border border-neutral-700 flex-shrink-0"
|
||||
style={{ backgroundColor: accentColor }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab 2: Warmup */}
|
||||
<TabsContent value="warmup" className="space-y-4">
|
||||
<TimedExerciseList
|
||||
title="Warmup exercises"
|
||||
description="Dynamic warmup sequence before the tabatas. Amber accent in the player."
|
||||
emptyLabel="No warmup exercises yet. Add the first one to get started."
|
||||
accentColor="text-amber-400"
|
||||
items={warmup}
|
||||
onChange={setWarmup}
|
||||
errors={Object.fromEntries(
|
||||
warmup.map((_, i) => [i, errors[`warmup_${i}`] || errors[`warmup_${i}_dur`] || ""]).filter(([, v]) => v)
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab 3: Tabatas */}
|
||||
<TabsContent value="tabatas" className="space-y-4">
|
||||
<div className="space-y-1 mb-4">
|
||||
<p className="text-sm text-neutral-500">
|
||||
Each program has 3 tabatas (exercise pairs). Every tabata alternates between two exercises for the specified number of rounds.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{errors.tabata_1_ex1 && (
|
||||
<p className="text-xs text-red-500 bg-red-500/10 px-3 py-2 rounded-md">
|
||||
{errors.tabata_1_ex1}
|
||||
</p>
|
||||
)}
|
||||
{errors.tabata_1_ex2 && (
|
||||
<p className="text-xs text-red-500 bg-red-500/10 px-3 py-2 rounded-md">
|
||||
{errors.tabata_1_ex2}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{([1, 2, 3] as const).map((pos) => {
|
||||
const tabata = tabatas.find((t) => t.position === pos) || null
|
||||
return (
|
||||
<TabataEditor
|
||||
key={pos}
|
||||
tabata={tabata}
|
||||
position={pos}
|
||||
onChange={(data) => handleTabataChange(pos, data)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab 4: Stretch */}
|
||||
<TabsContent value="stretch" className="space-y-4">
|
||||
<TimedExerciseList
|
||||
title="Stretch exercises"
|
||||
description="Cool-down stretches after the tabatas. Lavender accent in the player."
|
||||
emptyLabel="No stretch exercises yet. Add the first one to get started."
|
||||
accentColor="text-violet-300"
|
||||
items={stretch}
|
||||
onChange={setStretch}
|
||||
errors={Object.fromEntries(
|
||||
stretch.map((_, i) => [i, errors[`stretch_${i}`] || errors[`stretch_${i}_dur`] || ""]).filter(([, v]) => v)
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-neutral-800">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={isLoading}
|
||||
className="border-neutral-700 text-neutral-400 hover:bg-neutral-800 hover:text-white"
|
||||
>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{mode === "edit" ? "Update Program" : "Create Program"}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -10,16 +10,20 @@ import {
|
||||
Users,
|
||||
FolderOpen,
|
||||
ImageIcon,
|
||||
Music,
|
||||
LogOut,
|
||||
Flame,
|
||||
LayoutGrid,
|
||||
} from "lucide-react";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ href: "/workouts", label: "Workouts", icon: Dumbbell },
|
||||
{ href: "/programs", label: "Programs", icon: LayoutGrid },
|
||||
{ href: "/trainers", label: "Trainers", icon: Users },
|
||||
{ href: "/collections", label: "Collections", icon: FolderOpen },
|
||||
{ href: "/media", label: "Media", icon: ImageIcon },
|
||||
{ href: "/music", label: "Music", icon: Music },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
|
||||
311
admin-web/components/tabata-editor.tsx
Normal file
311
admin-web/components/tabata-editor.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ChevronDown, ChevronRight, Clock, Dumbbell } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { MediaUpload } from "@/components/media-upload"
|
||||
import type { Database } from "@/lib/supabase"
|
||||
|
||||
type ProgramTabata = Database["public"]["Tables"]["program_tabatas"]["Row"]
|
||||
|
||||
interface TabataData {
|
||||
position: number
|
||||
exercise_1_name: string
|
||||
exercise_1_name_en: string
|
||||
exercise_1_tip: string
|
||||
exercise_1_tip_en: string
|
||||
exercise_1_modification: string
|
||||
exercise_1_modification_en: string
|
||||
exercise_1_progression: string
|
||||
exercise_1_progression_en: string
|
||||
exercise_1_video_url: string
|
||||
exercise_2_name: string
|
||||
exercise_2_name_en: string
|
||||
exercise_2_tip: string
|
||||
exercise_2_tip_en: string
|
||||
exercise_2_modification: string
|
||||
exercise_2_modification_en: string
|
||||
exercise_2_progression: string
|
||||
exercise_2_progression_en: string
|
||||
exercise_2_video_url: string
|
||||
rounds: number
|
||||
work_time: number
|
||||
rest_time: number
|
||||
}
|
||||
|
||||
export type { TabataData }
|
||||
|
||||
interface TabataEditorProps {
|
||||
tabata: TabataData | null
|
||||
position: 1 | 2 | 3
|
||||
onChange: (data: TabataData) => void
|
||||
}
|
||||
|
||||
function getDefaultTabata(position: number): TabataData {
|
||||
return {
|
||||
position,
|
||||
exercise_1_name: "",
|
||||
exercise_1_name_en: "",
|
||||
exercise_1_tip: "",
|
||||
exercise_1_tip_en: "",
|
||||
exercise_1_modification: "",
|
||||
exercise_1_modification_en: "",
|
||||
exercise_1_progression: "",
|
||||
exercise_1_progression_en: "",
|
||||
exercise_1_video_url: "",
|
||||
exercise_2_name: "",
|
||||
exercise_2_name_en: "",
|
||||
exercise_2_tip: "",
|
||||
exercise_2_tip_en: "",
|
||||
exercise_2_modification: "",
|
||||
exercise_2_modification_en: "",
|
||||
exercise_2_progression: "",
|
||||
exercise_2_progression_en: "",
|
||||
exercise_2_video_url: "",
|
||||
rounds: 8,
|
||||
work_time: 20,
|
||||
rest_time: 10,
|
||||
}
|
||||
}
|
||||
|
||||
interface ExerciseSectionProps {
|
||||
label: string
|
||||
number: 1 | 2
|
||||
data: TabataData
|
||||
onChange: (field: string, value: string) => void
|
||||
errors: Record<string, string>
|
||||
}
|
||||
|
||||
function ExerciseSection({ label, number, data, onChange, errors }: ExerciseSectionProps) {
|
||||
const [isOpen, setIsOpen] = React.useState(true)
|
||||
const prefix = `exercise_${number}` as const
|
||||
|
||||
const nameField = `${prefix}_name` as keyof TabataData
|
||||
const nameValue = data[nameField] as string
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-neutral-800 bg-neutral-950 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center justify-between w-full px-4 py-3 text-sm font-medium text-white hover:bg-neutral-800/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isOpen ? <ChevronDown className="h-4 w-4 text-neutral-500" /> : <ChevronRight className="h-4 w-4 text-neutral-500" />}
|
||||
<Dumbbell className="h-4 w-4 text-orange-500" />
|
||||
<span>{label}</span>
|
||||
{nameValue && (
|
||||
<span className="text-neutral-500 font-normal">- {nameValue}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="px-4 pb-4 space-y-3 border-t border-neutral-800">
|
||||
{/* Name fields */}
|
||||
<div className="grid grid-cols-2 gap-3 pt-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Name (FR) *</Label>
|
||||
<Input
|
||||
value={data[nameField] as string}
|
||||
onChange={(e) => onChange(`${prefix}_name`, e.target.value)}
|
||||
placeholder="e.g., Squats"
|
||||
className={cn("h-9 text-sm", errors[`${prefix}_name`] && "border-red-500")}
|
||||
/>
|
||||
{errors[`${prefix}_name`] && <p className="text-xs text-red-500">{errors[`${prefix}_name`]}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Name (EN)</Label>
|
||||
<Input
|
||||
value={data[`${prefix}_name_en` as keyof TabataData] as string}
|
||||
onChange={(e) => onChange(`${prefix}_name_en`, e.target.value)}
|
||||
placeholder="e.g., Squats"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tip fields */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Tip (FR)</Label>
|
||||
<Input
|
||||
value={data[`${prefix}_tip` as keyof TabataData] as string}
|
||||
onChange={(e) => onChange(`${prefix}_tip`, e.target.value)}
|
||||
placeholder="Conseil en français"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Tip (EN)</Label>
|
||||
<Input
|
||||
value={data[`${prefix}_tip_en` as keyof TabataData] as string}
|
||||
onChange={(e) => onChange(`${prefix}_tip_en`, e.target.value)}
|
||||
placeholder="Tip in English"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modification fields */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Modification (FR)</Label>
|
||||
<Input
|
||||
value={data[`${prefix}_modification` as keyof TabataData] as string}
|
||||
onChange={(e) => onChange(`${prefix}_modification`, e.target.value)}
|
||||
placeholder="Version plus facile"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Modification (EN)</Label>
|
||||
<Input
|
||||
value={data[`${prefix}_modification_en` as keyof TabataData] as string}
|
||||
onChange={(e) => onChange(`${prefix}_modification_en`, e.target.value)}
|
||||
placeholder="Easier variation"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progression fields */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Progression (FR)</Label>
|
||||
<Input
|
||||
value={data[`${prefix}_progression` as keyof TabataData] as string}
|
||||
onChange={(e) => onChange(`${prefix}_progression`, e.target.value)}
|
||||
placeholder="Version plus difficile"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Progression (EN)</Label>
|
||||
<Input
|
||||
value={data[`${prefix}_progression_en` as keyof TabataData] as string}
|
||||
onChange={(e) => onChange(`${prefix}_progression_en`, e.target.value)}
|
||||
placeholder="Harder variation"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background video */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Background Video</Label>
|
||||
<MediaUpload
|
||||
type="video"
|
||||
value={(data[`${prefix}_video_url` as keyof TabataData] as string) || undefined}
|
||||
onChange={(url) => onChange(`${prefix}_video_url`, url)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TabataEditor({ tabata, position, onChange }: TabataEditorProps) {
|
||||
const data = tabata || getDefaultTabata(position)
|
||||
const [errors, setErrors] = React.useState<Record<string, string>>({})
|
||||
|
||||
const handleChange = (field: string, value: string) => {
|
||||
const updated = { ...data, [field]: value }
|
||||
onChange(updated)
|
||||
}
|
||||
|
||||
const handleTimingChange = (field: "rounds" | "work_time" | "rest_time", value: string) => {
|
||||
const numValue = parseInt(value) || 0
|
||||
const updated = { ...data, [field]: numValue }
|
||||
onChange(updated)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-neutral-700 bg-neutral-900 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 bg-neutral-800/50 border-b border-neutral-700">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-orange-500/20 text-orange-500 font-bold text-sm">
|
||||
{position}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-white font-medium">Tabata {position}</h3>
|
||||
<p className="text-xs text-neutral-500">
|
||||
{data.exercise_1_name && data.exercise_2_name
|
||||
? `${data.exercise_1_name} / ${data.exercise_2_name}`
|
||||
: "Configure exercises and timing"
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2 text-xs text-neutral-500">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
<span>{data.rounds * (data.work_time + data.rest_time)}s total</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Exercise sections */}
|
||||
<ExerciseSection
|
||||
label="Exercise 1"
|
||||
number={1}
|
||||
data={data}
|
||||
onChange={handleChange}
|
||||
errors={errors}
|
||||
/>
|
||||
|
||||
<ExerciseSection
|
||||
label="Exercise 2"
|
||||
number={2}
|
||||
data={data}
|
||||
onChange={handleChange}
|
||||
errors={errors}
|
||||
/>
|
||||
|
||||
{/* Timing */}
|
||||
<div className="rounded-lg border border-neutral-800 bg-neutral-950 p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Clock className="h-4 w-4 text-orange-500" />
|
||||
<span className="text-sm font-medium text-white">Timing</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Rounds</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={data.rounds}
|
||||
onChange={(e) => handleTimingChange("rounds", e.target.value)}
|
||||
min={1}
|
||||
max={20}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Work (sec)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={data.work_time}
|
||||
onChange={(e) => handleTimingChange("work_time", e.target.value)}
|
||||
min={1}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Rest (sec)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={data.rest_time}
|
||||
onChange={(e) => handleTimingChange("rest_time", e.target.value)}
|
||||
min={0}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
177
admin-web/components/timed-exercise-editor.tsx
Normal file
177
admin-web/components/timed-exercise-editor.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ArrowDown, ArrowUp, Trash2, Clock } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { MediaUpload } from "@/components/media-upload"
|
||||
|
||||
export interface TimedExerciseData {
|
||||
position: number
|
||||
name: string
|
||||
name_en: string
|
||||
tip: string
|
||||
tip_en: string
|
||||
duration: number
|
||||
video_url: string
|
||||
}
|
||||
|
||||
interface TimedExerciseRowProps {
|
||||
data: TimedExerciseData
|
||||
index: number
|
||||
total: number
|
||||
onChange: (data: TimedExerciseData) => void
|
||||
onRemove: () => void
|
||||
onMoveUp: () => void
|
||||
onMoveDown: () => void
|
||||
accentColor?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function TimedExerciseRow({
|
||||
data,
|
||||
index,
|
||||
total,
|
||||
onChange,
|
||||
onRemove,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
accentColor = "text-orange-500",
|
||||
error,
|
||||
}: TimedExerciseRowProps) {
|
||||
const update = <K extends keyof TimedExerciseData>(key: K, value: TimedExerciseData[K]) => {
|
||||
onChange({ ...data, [key]: value })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-neutral-800 bg-neutral-950 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-neutral-900 border-b border-neutral-800">
|
||||
<div className={cn("flex h-6 w-6 items-center justify-center rounded-full bg-neutral-800 text-xs font-semibold", accentColor)}>
|
||||
{index + 1}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-white flex-1">
|
||||
{data.name || `Exercise ${index + 1}`}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 text-xs text-neutral-500 mr-2">
|
||||
<Clock className="h-3 w-3" />
|
||||
{data.duration}s
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onMoveUp}
|
||||
disabled={index === 0}
|
||||
className="h-7 w-7 p-0 text-neutral-400 hover:text-white disabled:opacity-30"
|
||||
>
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onMoveDown}
|
||||
disabled={index === total - 1}
|
||||
className="h-7 w-7 p-0 text-neutral-400 hover:text-white disabled:opacity-30"
|
||||
>
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRemove}
|
||||
className="h-7 w-7 p-0 text-red-400 hover:bg-red-500/10 hover:text-red-400"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fields */}
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Name (FR) *</Label>
|
||||
<Input
|
||||
value={data.name}
|
||||
onChange={(e) => update("name", e.target.value)}
|
||||
placeholder="e.g., Jumping Jacks"
|
||||
className={cn("h-9 text-sm", error && "border-red-500")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Name (EN)</Label>
|
||||
<Input
|
||||
value={data.name_en}
|
||||
onChange={(e) => update("name_en", e.target.value)}
|
||||
placeholder="e.g., Jumping Jacks"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Tip (FR)</Label>
|
||||
<Input
|
||||
value={data.tip}
|
||||
onChange={(e) => update("tip", e.target.value)}
|
||||
placeholder="Conseil en français"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Tip (EN)</Label>
|
||||
<Input
|
||||
value={data.tip_en}
|
||||
onChange={(e) => update("tip_en", e.target.value)}
|
||||
placeholder="Tip in English"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Duration (seconds) *</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={data.duration}
|
||||
onChange={(e) => update("duration", parseInt(e.target.value) || 0)}
|
||||
min={1}
|
||||
max={300}
|
||||
className="h-9 text-sm w-32"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-neutral-400">Background Video</Label>
|
||||
<MediaUpload
|
||||
type="video"
|
||||
value={data.video_url || undefined}
|
||||
onChange={(url) => update("video_url", url)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function createEmptyTimedExercise(position: number): TimedExerciseData {
|
||||
return {
|
||||
position,
|
||||
name: "",
|
||||
name_en: "",
|
||||
tip: "",
|
||||
tip_en: "",
|
||||
duration: 30,
|
||||
video_url: "",
|
||||
}
|
||||
}
|
||||
90
admin-web/components/timed-exercise-list.tsx
Normal file
90
admin-web/components/timed-exercise-list.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Plus } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { TimedExerciseRow, TimedExerciseData, createEmptyTimedExercise } from "@/components/timed-exercise-editor"
|
||||
|
||||
interface TimedExerciseListProps {
|
||||
title: string
|
||||
description: string
|
||||
emptyLabel: string
|
||||
accentColor: string
|
||||
items: TimedExerciseData[]
|
||||
onChange: (items: TimedExerciseData[]) => void
|
||||
errors?: Record<number, string>
|
||||
}
|
||||
|
||||
export function TimedExerciseList({
|
||||
title,
|
||||
description,
|
||||
emptyLabel,
|
||||
accentColor,
|
||||
items,
|
||||
onChange,
|
||||
errors = {},
|
||||
}: TimedExerciseListProps) {
|
||||
const addItem = () => {
|
||||
onChange([...items, createEmptyTimedExercise(items.length + 1)])
|
||||
}
|
||||
|
||||
const updateItem = (index: number, data: TimedExerciseData) => {
|
||||
const next = [...items]
|
||||
next[index] = data
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
const next = items.filter((_, i) => i !== index).map((item, i) => ({ ...item, position: i + 1 }))
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
const move = (from: number, to: number) => {
|
||||
if (to < 0 || to >= items.length) return
|
||||
const next = [...items]
|
||||
const [item] = next.splice(from, 1)
|
||||
next.splice(to, 0, item)
|
||||
onChange(next.map((item, i) => ({ ...item, position: i + 1 })))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium text-white">{title}</h3>
|
||||
<p className="text-sm text-neutral-500">{description}</p>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-neutral-800 bg-neutral-950/50 p-8 text-center">
|
||||
<p className="text-sm text-neutral-500 mb-4">{emptyLabel}</p>
|
||||
<Button type="button" onClick={addItem} variant="outline" className="border-neutral-700">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add exercise
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{items.map((item, i) => (
|
||||
<TimedExerciseRow
|
||||
key={i}
|
||||
data={item}
|
||||
index={i}
|
||||
total={items.length}
|
||||
accentColor={accentColor}
|
||||
onChange={(data) => updateItem(i, data)}
|
||||
onRemove={() => removeItem(i)}
|
||||
onMoveUp={() => move(i, i - 1)}
|
||||
onMoveDown={() => move(i, i + 1)}
|
||||
error={errors[i]}
|
||||
/>
|
||||
))}
|
||||
<Button type="button" onClick={addItem} variant="outline" className="w-full border-neutral-700 border-dashed">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add exercise
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
352
admin-web/components/trainer-form.tsx
Normal file
352
admin-web/components/trainer-form.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Loader2, Sparkles, Save, X, Upload } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { supabase } from "@/lib/supabase"
|
||||
import type { Database } from "@/lib/supabase"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select } from "@/components/ui/select"
|
||||
import { toast } from "sonner"
|
||||
|
||||
type Trainer = Database["public"]["Tables"]["trainers"]["Row"]
|
||||
type TrainerInsert = Database["public"]["Tables"]["trainers"]["Insert"]
|
||||
type TrainerUpdate = Database["public"]["Tables"]["trainers"]["Update"]
|
||||
|
||||
interface TrainerFormProps {
|
||||
initialData?: Trainer
|
||||
mode?: "create" | "edit"
|
||||
}
|
||||
|
||||
const SPECIALTY_OPTIONS = [
|
||||
{ value: "Core", label: "Core" },
|
||||
{ value: "Strength", label: "Strength" },
|
||||
{ value: "Cardio", label: "Cardio" },
|
||||
{ value: "Full Body", label: "Full Body" },
|
||||
{ value: "Recovery", label: "Recovery" },
|
||||
]
|
||||
|
||||
const PRESET_COLORS = [
|
||||
"#FF6B35", "#FFD60A", "#30D158", "#5AC8FA", "#BF5AF2",
|
||||
"#FF9500", "#FF2D55", "#5856D6", "#FF3B30", "#34C759",
|
||||
]
|
||||
|
||||
export default function TrainerForm({ initialData, mode = "create" }: TrainerFormProps) {
|
||||
const router = useRouter()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [generatedImages, setGeneratedImages] = useState<{ buffer: Buffer; url?: string }[]>([])
|
||||
const [selectedImage, setSelectedImage] = useState<string | null>(null)
|
||||
const [generatingPrompt, setGeneratingPrompt] = useState("")
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null)
|
||||
|
||||
const [name, setName] = useState(initialData?.name || "")
|
||||
const [specialty, setSpecialty] = useState(initialData?.specialty || "Core")
|
||||
const [color, setColor] = useState(initialData?.color || "#FF6B35")
|
||||
const [avatarUrl, setAvatarUrl] = useState(initialData?.avatar_url || "")
|
||||
|
||||
const validate = () => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
if (!name.trim()) newErrors.name = "Name is required"
|
||||
if (!specialty) newErrors.specialty = "Specialty is required"
|
||||
if (!color) newErrors.color = "Color is required"
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
const handleGenerateImages = async () => {
|
||||
if (!name.trim()) {
|
||||
toast.error("Please enter a name first")
|
||||
return
|
||||
}
|
||||
|
||||
const prompt = generatingPrompt ||
|
||||
`Professional portrait photo of ${name}, fitness trainer, friendly smile, gym setting, high quality, facing camera directly, energetic and professional`
|
||||
|
||||
setIsGenerating(true)
|
||||
try {
|
||||
const trainerId = initialData?.id || "new-trainer"
|
||||
const filename = `avatar_${Date.now()}.png`
|
||||
|
||||
const response = await fetch('/api/ai/generate-avatar', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt, trainerId, filename }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Generation failed')
|
||||
}
|
||||
|
||||
const { url } = await response.json()
|
||||
|
||||
const savedImages = [{ buffer: Buffer.alloc(0), url }]
|
||||
setGeneratedImages(savedImages)
|
||||
setSelectedImage(url)
|
||||
setAvatarUrl(url)
|
||||
toast.success('Avatar generated')
|
||||
} catch (err) {
|
||||
console.error("Generation failed:", err)
|
||||
toast.error(err instanceof Error ? err.message : "Failed to generate images")
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectGenerated = (url: string) => {
|
||||
setSelectedImage(url)
|
||||
setAvatarUrl(url)
|
||||
setAvatarFile(null)
|
||||
}
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
setAvatarFile(file)
|
||||
setSelectedImage(null)
|
||||
|
||||
setIsUploading(true)
|
||||
try {
|
||||
const trainerId = initialData?.id || "new-trainer"
|
||||
const filename = `upload_${Date.now()}.png`
|
||||
const path = `${trainerId}/${filename}`
|
||||
|
||||
const { error } = await supabase.storage
|
||||
.from('trainer-avatars')
|
||||
.upload(path, file, {
|
||||
contentType: file.type,
|
||||
upsert: true,
|
||||
})
|
||||
|
||||
if (error) throw new Error(error.message)
|
||||
|
||||
const { data: { publicUrl } } = supabase.storage
|
||||
.from('trainer-avatars')
|
||||
.getPublicUrl(path)
|
||||
|
||||
setAvatarUrl(publicUrl)
|
||||
setGeneratedImages([])
|
||||
toast.success("Avatar uploaded")
|
||||
} catch (err) {
|
||||
console.error("Upload failed:", err)
|
||||
toast.error(err instanceof Error ? err.message : "Failed to upload avatar")
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!validate()) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const trainerData = {
|
||||
name: name.trim(),
|
||||
specialty,
|
||||
color,
|
||||
avatar_url: avatarUrl || null,
|
||||
workout_count: initialData?.workout_count || 0,
|
||||
}
|
||||
|
||||
let result
|
||||
if (mode === "edit" && initialData) {
|
||||
result = await (supabase
|
||||
.from("trainers") as any)
|
||||
.update(trainerData)
|
||||
.eq("id", initialData.id)
|
||||
.select()
|
||||
.single()
|
||||
} else {
|
||||
result = await (supabase
|
||||
.from("trainers") as any)
|
||||
.insert(trainerData)
|
||||
.select()
|
||||
.single()
|
||||
}
|
||||
|
||||
if (result.error) throw result.error
|
||||
|
||||
toast.success(mode === "edit" ? "Trainer updated" : "Trainer created")
|
||||
router.push("/trainers")
|
||||
} catch (err) {
|
||||
console.error("Failed to save:", err)
|
||||
toast.error("Failed to save trainer")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-white">Basic Information</h2>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Félia"
|
||||
className={cn(errors.name && "border-red-500")}
|
||||
/>
|
||||
{errors.name && <p className="text-xs text-red-500">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="specialty">Specialty *</Label>
|
||||
<Select
|
||||
id="specialty"
|
||||
value={specialty}
|
||||
onValueChange={setSpecialty}
|
||||
options={SPECIALTY_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Color</Label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{PRESET_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setColor(c)}
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-full border-2 transition-all",
|
||||
color === c ? "border-white scale-110" : "border-transparent"
|
||||
)}
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
))}
|
||||
<input
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="w-8 h-8 rounded-full cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-white">Trainer Avatar</h2>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Generate with AI (Nano Banana Pro)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={generatingPrompt}
|
||||
onChange={(e) => setGeneratingPrompt(e.target.value)}
|
||||
placeholder={`Portrait of ${name || 'trainer'}, fitness, friendly...`}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleGenerateImages}
|
||||
disabled={isGenerating || !name.trim()}
|
||||
className="bg-purple-500 hover:bg-purple-600"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="w-4 h-4" />
|
||||
)}
|
||||
Generate
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{generatedImages.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>Select Generated Image</Label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{generatedImages.map((img, i) => (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => img.url && handleSelectGenerated(img.url)}
|
||||
className={cn(
|
||||
"relative aspect-square rounded-lg overflow-hidden cursor-pointer border-2 transition-all",
|
||||
selectedImage === img.url ? "border-orange-500 scale-105" : "border-transparent hover:border-neutral-600"
|
||||
)}
|
||||
>
|
||||
{img.url && <img src={img.url} alt={`Generated ${i + 1}`} className="w-full h-full object-cover" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Or Upload Custom Image</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => document.getElementById('avatar-upload')?.click()}
|
||||
disabled={isUploading}
|
||||
className="border-neutral-700"
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="w-4 h-4" />
|
||||
)}
|
||||
Choose File
|
||||
</Button>
|
||||
<input
|
||||
id="avatar-upload"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
{avatarFile && <span className="text-sm text-neutral-400">{avatarFile.name}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{avatarUrl && (
|
||||
<div className="space-y-2">
|
||||
<Label>Current Avatar</Label>
|
||||
<div className="relative w-32 h-32">
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt="Current avatar"
|
||||
className="w-full h-full object-cover rounded-full border-2 border-neutral-700"
|
||||
/>
|
||||
{selectedImage === avatarUrl && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50 rounded-full">
|
||||
<Sparkles className="w-8 h-8 text-orange-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-neutral-800">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.back()}
|
||||
className="border-neutral-700"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading} className="bg-orange-500 hover:bg-orange-600">
|
||||
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
{mode === "edit" ? "Update Trainer" : "Create Trainer"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -163,15 +163,13 @@ export default function WorkoutForm({ initialData, mode = "create" }: WorkoutFor
|
||||
|
||||
let result
|
||||
if (mode === "edit" && initialData) {
|
||||
result = await supabase
|
||||
.from("workouts")
|
||||
result = await (supabase.from("workouts") as any)
|
||||
.update(workoutData)
|
||||
.eq("id", initialData.id)
|
||||
.select()
|
||||
.single()
|
||||
} else {
|
||||
result = await supabase
|
||||
.from("workouts")
|
||||
result = await (supabase.from("workouts") as any)
|
||||
.insert(workoutData)
|
||||
.select()
|
||||
.single()
|
||||
@@ -199,8 +197,7 @@ export default function WorkoutForm({ initialData, mode = "create" }: WorkoutFor
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await supabase
|
||||
.from("workouts")
|
||||
await (supabase.from("workouts") as any)
|
||||
.update(updateData)
|
||||
.eq("id", result.data.id)
|
||||
}
|
||||
|
||||
130
admin-web/e2e/collections.spec.ts
Normal file
130
admin-web/e2e/collections.spec.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Collections List Page', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/collections')
|
||||
})
|
||||
|
||||
test('should display collections page header', async ({ page }) => {
|
||||
const heading = page.getByRole('heading', { name: /collections|tabatafit admin/i })
|
||||
await expect(heading).toBeVisible()
|
||||
})
|
||||
|
||||
test('should have Add Collection button', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/collections')) return
|
||||
|
||||
const addButton = page.getByRole('button', { name: /add collection/i })
|
||||
await expect(addButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('should display subtitle text', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/collections')) return
|
||||
|
||||
await expect(page.getByText(/organize workouts into collections/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test('should display collection cards after loading', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/collections')) return
|
||||
|
||||
// Wait for loading to finish
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
// Should show collection cards in a grid layout
|
||||
const grid = page.locator('[class*="grid"]')
|
||||
await expect(grid).toBeVisible()
|
||||
})
|
||||
|
||||
test('should display collection title and description on cards', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/collections')) return
|
||||
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
// Find collection cards
|
||||
const cards = page.locator('[class*="bg-neutral-900"]').filter({ has: page.locator('h3') })
|
||||
const count = await cards.count()
|
||||
|
||||
if (count > 0) {
|
||||
const firstCard = cards.first()
|
||||
// Card should have a title (h3)
|
||||
await expect(firstCard.locator('h3')).toBeVisible()
|
||||
// Card should have description text (p element)
|
||||
const description = firstCard.locator('p').first()
|
||||
await expect(description).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('should display collection icon', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/collections')) return
|
||||
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
// Icon containers have specific styling
|
||||
const iconContainers = page.locator('[class*="w-12"][class*="h-12"][class*="rounded-xl"]')
|
||||
const count = await iconContainers.count()
|
||||
|
||||
if (count > 0) {
|
||||
await expect(iconContainers.first()).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('should display gradient bars for collections that have them', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/collections')) return
|
||||
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
// Gradient bars have inline background style with linear-gradient
|
||||
const gradientBars = page.locator('[class*="h-2"][class*="rounded-full"]')
|
||||
const count = await gradientBars.count()
|
||||
|
||||
// Gradient bars are optional (only shown if collection has gradient property)
|
||||
if (count > 0) {
|
||||
const firstBar = gradientBars.first()
|
||||
const style = await firstBar.getAttribute('style')
|
||||
expect(style).toContain('linear-gradient')
|
||||
}
|
||||
})
|
||||
|
||||
test('should have edit and delete buttons on cards', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/collections')) return
|
||||
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
const editButtons = page.locator('button').filter({ has: page.locator('svg.lucide-edit') })
|
||||
const deleteButtons = page.locator('button').filter({ has: page.locator('svg.lucide-trash-2') })
|
||||
|
||||
const editCount = await editButtons.count()
|
||||
const deleteCount = await deleteButtons.count()
|
||||
|
||||
// If collections are displayed, they should have action buttons
|
||||
if (editCount > 0) {
|
||||
expect(editCount).toBeGreaterThan(0)
|
||||
expect(deleteCount).toBeGreaterThan(0)
|
||||
// Each collection should have both edit and delete
|
||||
expect(editCount).toBe(deleteCount)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Collections Page Loading State', () => {
|
||||
test('should show loading spinner initially', async ({ page }) => {
|
||||
// Navigate and check for spinner before data loads
|
||||
await page.goto('/collections')
|
||||
|
||||
const url = page.url()
|
||||
if (!url.includes('/collections')) return
|
||||
|
||||
// The spinner might be very brief, so we just verify the page loads
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// After loading, spinner should be gone
|
||||
const spinner = page.locator('[class*="animate-spin"]')
|
||||
await expect(spinner).not.toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
})
|
||||
160
admin-web/e2e/trainers.spec.ts
Normal file
160
admin-web/e2e/trainers.spec.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Trainers List Page', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/trainers')
|
||||
})
|
||||
|
||||
test('should display trainers page header', async ({ page }) => {
|
||||
const heading = page.getByRole('heading', { name: /trainers|tabatafit admin/i })
|
||||
await expect(heading).toBeVisible()
|
||||
})
|
||||
|
||||
test('should have Add Trainer button', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/trainers')) return
|
||||
|
||||
const addButton = page.getByRole('link', { name: /add trainer/i }).or(
|
||||
page.getByRole('button', { name: /add trainer/i })
|
||||
)
|
||||
await expect(addButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('should display trainer cards or empty state', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/trainers')) return
|
||||
|
||||
// Wait for loading to finish
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
// Should show either trainer cards or empty state
|
||||
const hasTrainerCards = await page.locator('[class*="grid"]').locator('[class*="bg-neutral-900"]').count() > 0
|
||||
const hasEmptyState = await page.getByText(/no trainers yet/i).isVisible().catch(() => false)
|
||||
const hasError = await page.getByText(/failed to load/i).isVisible().catch(() => false)
|
||||
|
||||
expect(hasTrainerCards || hasEmptyState || hasError).toBeTruthy()
|
||||
})
|
||||
|
||||
test('should display trainer name and specialty on cards', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/trainers')) return
|
||||
|
||||
// Wait for loading to finish
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
// If there are trainer cards, check they have name and specialty
|
||||
const cards = page.locator('[class*="bg-neutral-900"]').filter({ has: page.locator('h3') })
|
||||
const count = await cards.count()
|
||||
|
||||
if (count > 0) {
|
||||
const firstCard = cards.first()
|
||||
// Card should have a name (h3 element)
|
||||
await expect(firstCard.locator('h3')).toBeVisible()
|
||||
// Card should have specialty text
|
||||
const specialtyText = firstCard.locator('p').first()
|
||||
await expect(specialtyText).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('should show workout count on trainer cards', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/trainers')) return
|
||||
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
// Check for "X workouts" text
|
||||
const workoutCountText = page.getByText(/\d+ workouts/i)
|
||||
const visible = await workoutCountText.first().isVisible().catch(() => false)
|
||||
|
||||
// Only assert if trainers exist
|
||||
if (visible) {
|
||||
await expect(workoutCountText.first()).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('should have edit and delete action buttons on cards', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/trainers')) return
|
||||
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
// Find edit and delete buttons (icon buttons with svg)
|
||||
const editButtons = page.locator('button').filter({ has: page.locator('svg.lucide-edit') })
|
||||
const deleteButtons = page.locator('button').filter({ has: page.locator('svg.lucide-trash-2') })
|
||||
|
||||
const editCount = await editButtons.count()
|
||||
const deleteCount = await deleteButtons.count()
|
||||
|
||||
// If trainers are displayed, they should have action buttons
|
||||
if (editCount > 0) {
|
||||
expect(editCount).toBeGreaterThan(0)
|
||||
expect(deleteCount).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Trainers Delete Dialog', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/trainers')
|
||||
})
|
||||
|
||||
test('should open delete confirmation dialog', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/trainers')) return
|
||||
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
const deleteButtons = page.locator('button').filter({ has: page.locator('svg.lucide-trash-2') })
|
||||
const count = await deleteButtons.count()
|
||||
|
||||
if (count > 0) {
|
||||
await deleteButtons.first().click()
|
||||
|
||||
// Dialog should appear
|
||||
await expect(page.getByRole('heading', { name: /delete trainer/i })).toBeVisible()
|
||||
await expect(page.getByText(/are you sure/i)).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: /cancel/i })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: /^delete$/i })).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('should close delete dialog on Cancel', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/trainers')) return
|
||||
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
const deleteButtons = page.locator('button').filter({ has: page.locator('svg.lucide-trash-2') })
|
||||
const count = await deleteButtons.count()
|
||||
|
||||
if (count > 0) {
|
||||
await deleteButtons.first().click()
|
||||
await expect(page.getByRole('heading', { name: /delete trainer/i })).toBeVisible()
|
||||
|
||||
// Click cancel
|
||||
await page.getByRole('button', { name: /cancel/i }).click()
|
||||
|
||||
// Dialog should close
|
||||
await expect(page.getByRole('heading', { name: /delete trainer/i })).not.toBeVisible()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Trainers Error State', () => {
|
||||
test('should show error state with retry button on failure', async ({ page }) => {
|
||||
// This test verifies the error UI exists in the component
|
||||
// In actual failure scenarios, it would show the error state
|
||||
await page.goto('/trainers')
|
||||
|
||||
const url = page.url()
|
||||
if (!url.includes('/trainers')) return
|
||||
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
// Check if error state is shown (only if Supabase is unreachable)
|
||||
const hasError = await page.getByText(/failed to load trainers/i).isVisible().catch(() => false)
|
||||
if (hasError) {
|
||||
await expect(page.getByRole('button', { name: /try again/i })).toBeVisible()
|
||||
}
|
||||
})
|
||||
})
|
||||
207
admin-web/e2e/workouts.spec.ts
Normal file
207
admin-web/e2e/workouts.spec.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Workouts List Page', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/workouts')
|
||||
})
|
||||
|
||||
test('should display workouts page header', async ({ page }) => {
|
||||
// May redirect to login if not authenticated
|
||||
const heading = page.getByRole('heading', { name: /workouts|tabatafit admin/i })
|
||||
await expect(heading).toBeVisible()
|
||||
})
|
||||
|
||||
test('should have Add Workout button', async ({ page }) => {
|
||||
// If authenticated, should see the Add Workout button
|
||||
const addButton = page.getByRole('link', { name: /add workout/i })
|
||||
|
||||
// Page might redirect to login — check if we're on workouts page
|
||||
const url = page.url()
|
||||
if (url.includes('/workouts')) {
|
||||
await expect(addButton).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('should display workouts table with correct columns', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/workouts')) return
|
||||
|
||||
// Wait for loading to finish (loader disappears)
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
const table = page.locator('table')
|
||||
// Table may or may not be visible depending on data
|
||||
const tableVisible = await table.isVisible().catch(() => false)
|
||||
|
||||
if (tableVisible) {
|
||||
await expect(page.getByRole('columnheader', { name: /title/i })).toBeVisible()
|
||||
await expect(page.getByRole('columnheader', { name: /category/i })).toBeVisible()
|
||||
await expect(page.getByRole('columnheader', { name: /level/i })).toBeVisible()
|
||||
await expect(page.getByRole('columnheader', { name: /duration/i })).toBeVisible()
|
||||
await expect(page.getByRole('columnheader', { name: /rounds/i })).toBeVisible()
|
||||
await expect(page.getByRole('columnheader', { name: /actions/i })).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('should navigate to new workout page', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/workouts')) return
|
||||
|
||||
const addButton = page.getByRole('link', { name: /add workout/i })
|
||||
if (await addButton.isVisible().catch(() => false)) {
|
||||
await addButton.click()
|
||||
await expect(page).toHaveURL(/.*workouts\/new/)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('New Workout Page', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/workouts/new')
|
||||
})
|
||||
|
||||
test('should display create workout heading', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/workouts/new')) return
|
||||
|
||||
await expect(page.getByRole('heading', { name: /create new workout/i })).toBeVisible()
|
||||
})
|
||||
|
||||
test('should have back to workouts link', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/workouts/new')) return
|
||||
|
||||
const backLink = page.getByRole('link', { name: /back to workouts/i })
|
||||
await expect(backLink).toBeVisible()
|
||||
})
|
||||
|
||||
test('should display workout form with tabs', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/workouts/new')) return
|
||||
|
||||
// Form should have 4 tabs: Basics, Timing, Content, Media
|
||||
await expect(page.getByRole('tab', { name: /basics/i })).toBeVisible()
|
||||
await expect(page.getByRole('tab', { name: /timing/i })).toBeVisible()
|
||||
await expect(page.getByRole('tab', { name: /content/i })).toBeVisible()
|
||||
await expect(page.getByRole('tab', { name: /media/i })).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show basics tab fields by default', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/workouts/new')) return
|
||||
|
||||
// Basics tab should be active by default
|
||||
await expect(page.getByLabel(/workout title/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test('should switch between form tabs', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/workouts/new')) return
|
||||
|
||||
// Click Timing tab
|
||||
await page.getByRole('tab', { name: /timing/i }).click()
|
||||
await expect(page.getByLabel(/total rounds/i)).toBeVisible()
|
||||
|
||||
// Click Content tab
|
||||
await page.getByRole('tab', { name: /content/i }).click()
|
||||
await expect(page.getByText(/exercises/i).first()).toBeVisible()
|
||||
|
||||
// Click Media tab
|
||||
await page.getByRole('tab', { name: /media/i }).click()
|
||||
await expect(page.getByText(/music vibe/i).first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('should have Cancel and Create Workout buttons', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/workouts/new')) return
|
||||
|
||||
await expect(page.getByRole('button', { name: /cancel/i })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: /create workout/i })).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show validation errors on empty submit', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/workouts/new')) return
|
||||
|
||||
// Clear the title field and submit
|
||||
const titleInput = page.getByLabel(/workout title/i)
|
||||
await titleInput.fill('')
|
||||
|
||||
// Click submit
|
||||
await page.getByRole('button', { name: /create workout/i }).click()
|
||||
|
||||
// Should show validation error for title
|
||||
await expect(page.getByText(/title is required/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test('should navigate back when Cancel is clicked', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/workouts/new')) return
|
||||
|
||||
await page.getByRole('button', { name: /cancel/i }).click()
|
||||
await expect(page).toHaveURL(/.*workouts$/)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Workout Detail Page', () => {
|
||||
test('should show 404 or redirect for non-existent workout', async ({ page }) => {
|
||||
await page.goto('/workouts/non-existent-id')
|
||||
|
||||
// Should either show not found or redirect
|
||||
const url = page.url()
|
||||
const hasNotFound = await page.getByText(/not found/i).isVisible().catch(() => false)
|
||||
const redirectedToLogin = url.includes('/login')
|
||||
const redirectedToWorkouts = url.match(/\/workouts\/?$/)
|
||||
|
||||
expect(hasNotFound || redirectedToLogin || redirectedToWorkouts).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Workout Delete Dialog', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/workouts')
|
||||
})
|
||||
|
||||
test('should open delete confirmation dialog', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/workouts')) return
|
||||
|
||||
// Wait for loading to finish
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
// Find a delete button in the table actions
|
||||
const deleteButtons = page.locator('button').filter({ has: page.locator('svg.lucide-trash-2') })
|
||||
const count = await deleteButtons.count()
|
||||
|
||||
if (count > 0) {
|
||||
await deleteButtons.first().click()
|
||||
|
||||
// Dialog should appear
|
||||
await expect(page.getByRole('heading', { name: /delete workout/i })).toBeVisible()
|
||||
await expect(page.getByText(/are you sure/i)).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: /cancel/i })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: /^delete$/i })).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('should close delete dialog on Cancel', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/workouts')) return
|
||||
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
const deleteButtons = page.locator('button').filter({ has: page.locator('svg.lucide-trash-2') })
|
||||
const count = await deleteButtons.count()
|
||||
|
||||
if (count > 0) {
|
||||
await deleteButtons.first().click()
|
||||
await expect(page.getByRole('heading', { name: /delete workout/i })).toBeVisible()
|
||||
|
||||
// Click cancel
|
||||
await page.getByRole('button', { name: /cancel/i }).click()
|
||||
|
||||
// Dialog should close
|
||||
await expect(page.getByRole('heading', { name: /delete workout/i })).not.toBeVisible()
|
||||
}
|
||||
})
|
||||
})
|
||||
15
admin-web/lib/CLAUDE.md
Normal file
15
admin-web/lib/CLAUDE.md
Normal file
@@ -0,0 +1,15 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Apr 16, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6329 | 9:39 PM | 🔄 | Refactored admin AI library to remove legacy program_workouts table references | ~356 |
|
||||
| #6328 | " | 🔄 | Removed ProgramWorkoutRow type definition from admin AI integration | ~364 |
|
||||
| #6327 | 9:38 PM | 🔵 | Identified legacy database schema references in admin AI library | ~316 |
|
||||
| #6326 | " | 🔵 | Discovered AI integration file with legacy database schema references | ~336 |
|
||||
| #6324 | " | 🔄 | Updated Supabase database type definitions for workout program schema | ~457 |
|
||||
</claude-mem-context>
|
||||
171
admin-web/lib/ai.ts
Normal file
171
admin-web/lib/ai.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { GoogleGenAI } from '@google/genai'
|
||||
import { supabase } from './supabase'
|
||||
import type { Database } from './supabase'
|
||||
|
||||
type WorkoutRow = Database['public']['Tables']['workouts']['Row']
|
||||
|
||||
function getAI(): GoogleGenAI {
|
||||
const apiKey = process.env['GEMINI_API_KEY']
|
||||
if (!apiKey) {
|
||||
throw new Error('GEMINI_API_KEY environment variable is not set')
|
||||
}
|
||||
return new GoogleGenAI({ apiKey })
|
||||
}
|
||||
|
||||
export interface GeneratedImage {
|
||||
buffer: Buffer
|
||||
mimeType: string
|
||||
}
|
||||
|
||||
export interface VideoGenerationResult {
|
||||
workoutId: string
|
||||
trainerId: string
|
||||
videoUrl: string
|
||||
videoPath: string
|
||||
}
|
||||
|
||||
export async function generateTrainerAvatar(
|
||||
prompt: string,
|
||||
_trainerName: string
|
||||
): Promise<GeneratedImage[]> {
|
||||
const config = {
|
||||
imageConfig: {
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
personGeneration: 'ALLOW_ADULT',
|
||||
},
|
||||
responseModalities: ['IMAGE', 'TEXT'],
|
||||
}
|
||||
|
||||
const response = await getAI().models.generateContentStream({
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
config,
|
||||
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
||||
})
|
||||
|
||||
const images: GeneratedImage[] = []
|
||||
|
||||
for await (const chunk of response) {
|
||||
const parts = chunk.candidates?.[0]?.content?.parts
|
||||
if (!parts) continue
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.inlineData) {
|
||||
images.push({
|
||||
buffer: Buffer.from(part.inlineData.data || '', 'base64'),
|
||||
mimeType: part.inlineData.mimeType || 'image/png',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return images
|
||||
}
|
||||
|
||||
export async function saveAvatarToStorage(
|
||||
trainerId: string,
|
||||
imageBuffer: Buffer,
|
||||
filename: string
|
||||
): Promise<string> {
|
||||
const path = `${trainerId}/${filename}`
|
||||
|
||||
const { error } = await supabase.storage
|
||||
.from('trainer-avatars')
|
||||
.upload(path, imageBuffer, {
|
||||
contentType: 'image/png',
|
||||
upsert: true,
|
||||
})
|
||||
|
||||
if (error) throw new Error(`Failed to upload avatar: ${error.message}`)
|
||||
|
||||
const { data: { publicUrl } } = supabase.storage
|
||||
.from('trainer-avatars')
|
||||
.getPublicUrl(path)
|
||||
|
||||
return publicUrl
|
||||
}
|
||||
|
||||
export async function generateWorkoutVideo(
|
||||
workoutId: string,
|
||||
trainerId: string,
|
||||
_trainerAvatarUrl: string,
|
||||
workoutTitle: string,
|
||||
exercises: { name: string; duration: number }[]
|
||||
): Promise<VideoGenerationResult> {
|
||||
const exerciseList = exercises.map(e => e.name).join(', ')
|
||||
|
||||
const prompt = `${workoutTitle}. Exercises: ${exerciseList}. The video must loop seamlessly - start and end in the exact same standing position with arms at sides. Person should appear energetic and properly demonstrate each exercise. 16:9 aspect ratio.`
|
||||
|
||||
const operation = await getAI().models.generateVideos({
|
||||
model: 'veo-3.1-fast-generate-preview',
|
||||
source: { prompt },
|
||||
config: {
|
||||
numberOfVideos: 1,
|
||||
aspectRatio: '16:9',
|
||||
resolution: '1080p',
|
||||
personGeneration: 'dont_allow',
|
||||
durationSeconds: 8,
|
||||
},
|
||||
})
|
||||
|
||||
let currentOperation = operation
|
||||
while (!currentOperation.done) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10000))
|
||||
currentOperation = await getAI().operations.getVideosOperation({ operation: currentOperation })
|
||||
}
|
||||
|
||||
const videoUri = currentOperation.response?.generatedVideos?.[0]?.video?.uri
|
||||
if (!videoUri) throw new Error('No video generated')
|
||||
|
||||
const response = await fetch(`${videoUri}&key=${process.env['GEMINI_API_KEY']}`)
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
const videoBuffer = Buffer.from(arrayBuffer)
|
||||
|
||||
const videoPath = `${trainerId}/${workoutId}.mp4`
|
||||
const { error } = await supabase.storage
|
||||
.from('trainers-videos')
|
||||
.upload(videoPath, videoBuffer, {
|
||||
contentType: 'video/mp4',
|
||||
upsert: true,
|
||||
})
|
||||
|
||||
if (error) throw new Error(`Failed to upload video: ${error.message}`)
|
||||
|
||||
const { data: { publicUrl } } = supabase.storage
|
||||
.from('trainers-videos')
|
||||
.getPublicUrl(videoPath)
|
||||
|
||||
return { workoutId, trainerId, videoUrl: publicUrl, videoPath }
|
||||
}
|
||||
|
||||
export async function isWorkoutInProgram(_workoutId: string): Promise<boolean> {
|
||||
// Legacy program_workouts table removed — workout programs now use program_tabatas
|
||||
return false
|
||||
}
|
||||
|
||||
export async function isVideoGeneratedForWorkout(_workoutId: string): Promise<boolean> {
|
||||
// Legacy program_workouts table removed — video status tracked in workouts table
|
||||
return false
|
||||
}
|
||||
|
||||
export async function getWorkoutVideoStatus(workoutId: string): Promise<{
|
||||
hasVideo: boolean
|
||||
videoUrl: string | null
|
||||
inProgram: boolean
|
||||
videoGenerated: boolean
|
||||
}> {
|
||||
const workoutResult = await supabase
|
||||
.from('workouts')
|
||||
.select('video_url')
|
||||
.eq('id', workoutId)
|
||||
.single()
|
||||
|
||||
const workoutVideoUrl = (workoutResult.data as WorkoutRow | null)?.video_url ?? null
|
||||
|
||||
return {
|
||||
hasVideo: !!workoutVideoUrl,
|
||||
videoUrl: workoutVideoUrl,
|
||||
inProgram: false,
|
||||
videoGenerated: false,
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,28 @@ export type Json =
|
||||
| { [key: string]: Json | undefined }
|
||||
| Json[]
|
||||
|
||||
export const MUSIC_GENRES = [
|
||||
'edm', 'hip-hop', 'pop', 'rock', 'latin', 'house',
|
||||
'drum-and-bass', 'dubstep', 'r-and-b', 'country', 'metal', 'ambient',
|
||||
] as const
|
||||
|
||||
export type MusicGenre = typeof MUSIC_GENRES[number]
|
||||
|
||||
export const GENRE_LABELS: Record<MusicGenre, string> = {
|
||||
'edm': 'EDM',
|
||||
'hip-hop': 'Hip Hop',
|
||||
'pop': 'Pop',
|
||||
'rock': 'Rock',
|
||||
'latin': 'Latin',
|
||||
'house': 'House',
|
||||
'drum-and-bass': 'Drum & Bass',
|
||||
'dubstep': 'Dubstep',
|
||||
'r-and-b': 'R&B',
|
||||
'country': 'Country',
|
||||
'metal': 'Metal',
|
||||
'ambient': 'Ambient',
|
||||
}
|
||||
|
||||
export interface Database {
|
||||
public: {
|
||||
Tables: {
|
||||
@@ -128,6 +150,194 @@ export interface Database {
|
||||
sort_order: number
|
||||
}
|
||||
}
|
||||
workout_programs: {
|
||||
Row: {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
body_zone: 'upper-body' | 'lower-body' | 'full-body'
|
||||
level: 'Beginner' | 'Intermediate' | 'Advanced'
|
||||
is_free: boolean
|
||||
estimated_duration: number
|
||||
estimated_calories: number
|
||||
icon: string | null
|
||||
accent_color: string | null
|
||||
sort_order: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
title: string
|
||||
description?: string
|
||||
body_zone: 'upper-body' | 'lower-body' | 'full-body'
|
||||
level: 'Beginner' | 'Intermediate' | 'Advanced'
|
||||
is_free?: boolean
|
||||
estimated_duration?: number
|
||||
estimated_calories?: number
|
||||
icon?: string | null
|
||||
accent_color?: string | null
|
||||
sort_order?: number
|
||||
}
|
||||
Update: Partial<Omit<Database['public']['Tables']['workout_programs']['Insert'], 'id'>>
|
||||
}
|
||||
program_tabatas: {
|
||||
Row: {
|
||||
id: string
|
||||
program_id: string
|
||||
position: number
|
||||
exercise_1_name: string
|
||||
exercise_1_name_en: string | null
|
||||
exercise_1_tip: string | null
|
||||
exercise_1_tip_en: string | null
|
||||
exercise_1_modification: string | null
|
||||
exercise_1_modification_en: string | null
|
||||
exercise_1_progression: string | null
|
||||
exercise_1_progression_en: string | null
|
||||
exercise_2_name: string
|
||||
exercise_2_name_en: string | null
|
||||
exercise_2_tip: string | null
|
||||
exercise_2_tip_en: string | null
|
||||
exercise_2_modification: string | null
|
||||
exercise_2_modification_en: string | null
|
||||
exercise_2_progression: string | null
|
||||
exercise_2_progression_en: string | null
|
||||
exercise_1_video_url: string | null
|
||||
exercise_2_video_url: string | null
|
||||
rounds: number
|
||||
work_time: number
|
||||
rest_time: number
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
program_id: string
|
||||
position: number
|
||||
exercise_1_name: string
|
||||
exercise_1_name_en?: string | null
|
||||
exercise_1_tip?: string | null
|
||||
exercise_1_tip_en?: string | null
|
||||
exercise_1_modification?: string | null
|
||||
exercise_1_modification_en?: string | null
|
||||
exercise_1_progression?: string | null
|
||||
exercise_1_progression_en?: string | null
|
||||
exercise_2_name: string
|
||||
exercise_2_name_en?: string | null
|
||||
exercise_2_tip?: string | null
|
||||
exercise_2_tip_en?: string | null
|
||||
exercise_2_modification?: string | null
|
||||
exercise_2_modification_en?: string | null
|
||||
exercise_2_progression?: string | null
|
||||
exercise_2_progression_en?: string | null
|
||||
exercise_1_video_url?: string | null
|
||||
exercise_2_video_url?: string | null
|
||||
rounds?: number
|
||||
work_time?: number
|
||||
rest_time?: number
|
||||
}
|
||||
Update: Partial<Omit<Database['public']['Tables']['program_tabatas']['Insert'], 'id' | 'program_id'>>
|
||||
}
|
||||
workout_warmup_exercises: {
|
||||
Row: {
|
||||
id: string
|
||||
program_id: string
|
||||
position: number
|
||||
name: string
|
||||
name_en: string | null
|
||||
tip: string | null
|
||||
tip_en: string | null
|
||||
duration: number
|
||||
video_url: string | null
|
||||
created_at: string
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
program_id: string
|
||||
position: number
|
||||
name: string
|
||||
name_en?: string | null
|
||||
tip?: string | null
|
||||
tip_en?: string | null
|
||||
duration: number
|
||||
video_url?: string | null
|
||||
}
|
||||
Update: Partial<Omit<Database['public']['Tables']['workout_warmup_exercises']['Insert'], 'id' | 'program_id'>>
|
||||
}
|
||||
workout_stretch_exercises: {
|
||||
Row: {
|
||||
id: string
|
||||
program_id: string
|
||||
position: number
|
||||
name: string
|
||||
name_en: string | null
|
||||
tip: string | null
|
||||
tip_en: string | null
|
||||
duration: number
|
||||
video_url: string | null
|
||||
created_at: string
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
program_id: string
|
||||
position: number
|
||||
name: string
|
||||
name_en?: string | null
|
||||
tip?: string | null
|
||||
tip_en?: string | null
|
||||
duration: number
|
||||
video_url?: string | null
|
||||
}
|
||||
Update: Partial<Omit<Database['public']['Tables']['workout_stretch_exercises']['Insert'], 'id' | 'program_id'>>
|
||||
}
|
||||
workout_videos: {
|
||||
Row: {
|
||||
id: string
|
||||
workout_id: string
|
||||
trainer_id: string
|
||||
video_url: string
|
||||
video_path: string
|
||||
created_at: string
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
workout_id: string
|
||||
trainer_id: string
|
||||
video_url: string
|
||||
video_path: string
|
||||
}
|
||||
Update: {
|
||||
video_url?: string
|
||||
video_path?: string
|
||||
}
|
||||
}
|
||||
exercise_videos: {
|
||||
Row: {
|
||||
id: string
|
||||
workout_id: string
|
||||
trainer_id: string
|
||||
exercise_name: string
|
||||
video_url: string
|
||||
video_path: string
|
||||
video_type: 'exercise' | 'rest'
|
||||
duration_seconds: number | null
|
||||
created_at: string
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
workout_id: string
|
||||
trainer_id: string
|
||||
exercise_name: string
|
||||
video_url: string
|
||||
video_path: string
|
||||
video_type?: 'exercise' | 'rest'
|
||||
duration_seconds?: number | null
|
||||
}
|
||||
Update: {
|
||||
video_url?: string
|
||||
video_path?: string
|
||||
video_type?: 'exercise' | 'rest'
|
||||
duration_seconds?: number | null
|
||||
}
|
||||
}
|
||||
admin_users: {
|
||||
Row: {
|
||||
id: string
|
||||
@@ -137,6 +347,47 @@ export interface Database {
|
||||
last_login: string | null
|
||||
}
|
||||
}
|
||||
download_jobs: {
|
||||
Row: {
|
||||
id: string
|
||||
playlist_url: string
|
||||
playlist_title: string | null
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed'
|
||||
total_items: number
|
||||
completed_items: number
|
||||
failed_items: number
|
||||
created_by: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
playlist_url: string
|
||||
playlist_title?: string | null
|
||||
status?: 'pending' | 'processing' | 'completed' | 'failed'
|
||||
total_items?: number
|
||||
completed_items?: number
|
||||
failed_items?: number
|
||||
created_by: string
|
||||
}
|
||||
Update: Partial<Omit<Database['public']['Tables']['download_jobs']['Insert'], 'id'>>
|
||||
}
|
||||
download_items: {
|
||||
Row: {
|
||||
id: string
|
||||
job_id: string
|
||||
video_id: string
|
||||
title: string | null
|
||||
duration_seconds: number | null
|
||||
thumbnail_url: string | null
|
||||
status: 'pending' | 'downloading' | 'completed' | 'failed'
|
||||
storage_path: string | null
|
||||
public_url: string | null
|
||||
error_message: string | null
|
||||
genre: MusicGenre | null
|
||||
created_at: string
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
412
admin-web/lib/use-youtube-download.ts
Normal file
412
admin-web/lib/use-youtube-download.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import type { Database, MusicGenre } from "@/lib/supabase";
|
||||
|
||||
type DownloadJob = Database["public"]["Tables"]["download_jobs"]["Row"];
|
||||
type DownloadItem = Database["public"]["Tables"]["download_items"]["Row"];
|
||||
|
||||
export interface JobWithItems extends DownloadJob {
|
||||
items: DownloadItem[];
|
||||
}
|
||||
|
||||
const PROCESS_DELAY_MS = 1000;
|
||||
|
||||
/**
|
||||
* Construct a GET request to a Supabase edge function with query params.
|
||||
* supabase.functions.invoke() doesn't support query params, so we use fetch.
|
||||
*/
|
||||
async function invokeGet<T>(
|
||||
functionName: string,
|
||||
params?: Record<string, string>
|
||||
): Promise<T> {
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
if (!session) throw new Error("Not authenticated");
|
||||
|
||||
const supabaseUrl =
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||
|
||||
process.env.EXPO_PUBLIC_SUPABASE_URL ||
|
||||
"http://localhost:54321";
|
||||
|
||||
const url = new URL(`${supabaseUrl}/functions/v1/${functionName}`);
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
|
||||
}
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(body.error || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a DELETE request to a Supabase edge function with a JSON body.
|
||||
*/
|
||||
async function invokeDelete<T>(
|
||||
functionName: string,
|
||||
body: Record<string, unknown>
|
||||
): Promise<T> {
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
if (!session) throw new Error("Not authenticated");
|
||||
|
||||
const supabaseUrl =
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||
|
||||
process.env.EXPO_PUBLIC_SUPABASE_URL ||
|
||||
"http://localhost:54321";
|
||||
|
||||
const res = await fetch(`${supabaseUrl}/functions/v1/${functionName}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(data.error || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a PATCH request to a Supabase edge function with a JSON body.
|
||||
*/
|
||||
async function invokePatch<T>(
|
||||
functionName: string,
|
||||
body: Record<string, unknown>
|
||||
): Promise<T> {
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
if (!session) throw new Error("Not authenticated");
|
||||
|
||||
const supabaseUrl =
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||
|
||||
process.env.EXPO_PUBLIC_SUPABASE_URL ||
|
||||
"http://localhost:54321";
|
||||
|
||||
const res = await fetch(`${supabaseUrl}/functions/v1/${functionName}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(data.error || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface ItemWithPlaylist extends DownloadItem {
|
||||
playlist_title: string | null;
|
||||
}
|
||||
|
||||
export function useYouTubeDownload() {
|
||||
const [jobs, setJobs] = useState<DownloadJob[]>([]);
|
||||
const [allItems, setAllItems] = useState<ItemWithPlaylist[]>([]);
|
||||
const [activeJob, setActiveJob] = useState<JobWithItems | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [isClassifying, setIsClassifying] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
/** Fetch all jobs (list view). */
|
||||
const fetchJobs = useCallback(async () => {
|
||||
const data = await invokeGet<{ jobs: DownloadJob[] }>("youtube-status");
|
||||
setJobs(data.jobs);
|
||||
return data.jobs;
|
||||
}, []);
|
||||
|
||||
/** Fetch ALL download items across all jobs, enriched with playlist title. */
|
||||
const fetchAllItems = useCallback(async () => {
|
||||
// Fetch all items via Supabase directly (RLS ensures admin-only).
|
||||
// Cast needed because the Database type only defines Row (no Insert/Update)
|
||||
// for download_items, causing Supabase client to infer `never`.
|
||||
const { data: items, error: itemsErr } = (await supabase
|
||||
.from("download_items")
|
||||
.select("*")
|
||||
.order("created_at", { ascending: false })) as {
|
||||
data: DownloadItem[] | null;
|
||||
error: { message: string } | null;
|
||||
};
|
||||
|
||||
if (itemsErr) throw new Error(itemsErr.message);
|
||||
|
||||
// Build a map of job_id -> playlist_title from the current jobs list,
|
||||
// or fetch jobs if we don't have them yet.
|
||||
let jobMap: Record<string, string | null> = {};
|
||||
let currentJobs = jobs;
|
||||
if (currentJobs.length === 0) {
|
||||
const data = await invokeGet<{ jobs: DownloadJob[] }>("youtube-status");
|
||||
currentJobs = data.jobs;
|
||||
setJobs(currentJobs);
|
||||
}
|
||||
for (const j of currentJobs) {
|
||||
jobMap[j.id] = j.playlist_title;
|
||||
}
|
||||
|
||||
const enriched: ItemWithPlaylist[] = (items ?? []).map((item) => ({
|
||||
...item,
|
||||
playlist_title: jobMap[item.job_id] ?? null,
|
||||
}));
|
||||
|
||||
setAllItems(enriched);
|
||||
return enriched;
|
||||
}, [jobs]);
|
||||
|
||||
/** Fetch a single job with its items. */
|
||||
const refreshStatus = useCallback(async (jobId: string) => {
|
||||
const data = await invokeGet<{ job: DownloadJob; items: DownloadItem[] }>(
|
||||
"youtube-status",
|
||||
{ jobId }
|
||||
);
|
||||
const jobWithItems: JobWithItems = { ...data.job, items: data.items };
|
||||
setActiveJob(jobWithItems);
|
||||
|
||||
// Also update the job in the list
|
||||
setJobs((prev) =>
|
||||
prev.map((j) => (j.id === jobId ? data.job : j))
|
||||
);
|
||||
|
||||
return jobWithItems;
|
||||
}, []);
|
||||
|
||||
/** Import a playlist: creates a job + download_items rows. */
|
||||
const importPlaylist = useCallback(
|
||||
async (playlistUrl: string, genre?: MusicGenre) => {
|
||||
setIsImporting(true);
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke(
|
||||
"youtube-playlist",
|
||||
{ body: { playlistUrl, genre: genre || null } }
|
||||
);
|
||||
if (error) throw new Error(error.message ?? "Import failed");
|
||||
if (data?.error) throw new Error(data.error);
|
||||
|
||||
// Refresh the jobs list and select the new job
|
||||
await fetchJobs();
|
||||
if (data.jobId) {
|
||||
await refreshStatus(data.jobId);
|
||||
}
|
||||
|
||||
return data as {
|
||||
jobId: string;
|
||||
playlistTitle: string;
|
||||
totalItems: number;
|
||||
};
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
},
|
||||
[fetchJobs, refreshStatus]
|
||||
);
|
||||
|
||||
/** Process all pending items for a job, one at a time. */
|
||||
const startProcessing = useCallback(
|
||||
async (jobId: string) => {
|
||||
// Abort any existing processing loop
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
setIsProcessing(true);
|
||||
|
||||
try {
|
||||
let done = false;
|
||||
|
||||
while (!done && !controller.signal.aborted) {
|
||||
const { data, error } = await supabase.functions.invoke(
|
||||
"youtube-process",
|
||||
{ body: { jobId } }
|
||||
);
|
||||
|
||||
if (error) throw new Error(error.message ?? "Processing failed");
|
||||
if (data?.error) throw new Error(data.error);
|
||||
|
||||
done = data.done === true;
|
||||
|
||||
// Refresh the job status to get updated items
|
||||
await refreshStatus(jobId);
|
||||
|
||||
// Delay between calls to avoid hammering the function
|
||||
if (!done && !controller.signal.aborted) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, PROCESS_DELAY_MS);
|
||||
controller.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
// Graceful stop — not an error
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
abortRef.current = null;
|
||||
// Final refresh to get latest state
|
||||
await refreshStatus(jobId).catch(() => {});
|
||||
}
|
||||
},
|
||||
[refreshStatus]
|
||||
);
|
||||
|
||||
/** Stop the current processing loop. */
|
||||
const stopProcessing = useCallback(() => {
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
abortRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** Delete a job, its items, and associated storage files. */
|
||||
const deleteJob = useCallback(
|
||||
async (jobId: string) => {
|
||||
await invokeDelete<{ deleted: boolean }>("youtube-status", { jobId });
|
||||
|
||||
// Remove the job from local state
|
||||
setJobs((prev) => prev.filter((j) => j.id !== jobId));
|
||||
|
||||
// Clear active job if it was the deleted one
|
||||
setActiveJob((prev) => (prev?.id === jobId ? null : prev));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/** Delete a single download item and its audio file from storage. */
|
||||
const deleteItem = useCallback(
|
||||
async (itemId: string) => {
|
||||
const result = await invokeDelete<{
|
||||
deleted: boolean;
|
||||
itemId: string;
|
||||
jobId: string;
|
||||
}>("youtube-status", { itemId });
|
||||
|
||||
// Remove from allItems
|
||||
setAllItems((prev) => prev.filter((i) => i.id !== itemId));
|
||||
|
||||
// Remove from activeJob items if present
|
||||
setActiveJob((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
items: prev.items.filter((i) => i.id !== itemId),
|
||||
};
|
||||
});
|
||||
|
||||
// Update the parent job counters in jobs list
|
||||
setJobs((prev) =>
|
||||
prev.map((j) => {
|
||||
if (j.id !== result.jobId) return j;
|
||||
// We don't know the item status here, so just decrement total.
|
||||
// The next fetchJobs() will reconcile exact counts from the server.
|
||||
return {
|
||||
...j,
|
||||
total_items: Math.max(0, j.total_items - 1),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return result;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/** Update the genre on a single download item. */
|
||||
const updateItemGenre = useCallback(
|
||||
async (itemId: string, genre: MusicGenre | null) => {
|
||||
await invokePatch<{ updated: boolean }>("youtube-status", {
|
||||
itemId,
|
||||
genre,
|
||||
});
|
||||
|
||||
// Update local state
|
||||
setActiveJob((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
items: prev.items.map((item) =>
|
||||
item.id === itemId ? { ...item, genre } : item
|
||||
),
|
||||
};
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/** Re-classify genres for a job's items via YouTube metadata + Gemini. */
|
||||
const reclassifyJob = useCallback(
|
||||
async (jobId: string, force = false) => {
|
||||
setIsClassifying(true);
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke(
|
||||
"youtube-classify",
|
||||
{ body: { jobId, force } }
|
||||
);
|
||||
if (error) throw new Error(error.message ?? "Classification failed");
|
||||
if (data?.error) throw new Error(data.error);
|
||||
|
||||
// Refresh job items and library to reflect updated genres
|
||||
await refreshStatus(jobId);
|
||||
await fetchAllItems().catch(() => {});
|
||||
|
||||
return data as { classified: number; skipped: number };
|
||||
} finally {
|
||||
setIsClassifying(false);
|
||||
}
|
||||
},
|
||||
[refreshStatus, fetchAllItems]
|
||||
);
|
||||
|
||||
return {
|
||||
jobs,
|
||||
allItems,
|
||||
activeJob,
|
||||
isProcessing,
|
||||
isImporting,
|
||||
isClassifying,
|
||||
fetchJobs,
|
||||
fetchAllItems,
|
||||
refreshStatus,
|
||||
importPlaylist,
|
||||
startProcessing,
|
||||
stopProcessing,
|
||||
deleteJob,
|
||||
deleteItem,
|
||||
updateItemGenre,
|
||||
reclassifyJob,
|
||||
};
|
||||
}
|
||||
80
admin-web/migrations/001_reset_trainers.sql
Normal file
80
admin-web/migrations/001_reset_trainers.sql
Normal file
@@ -0,0 +1,80 @@
|
||||
-- Migration Script: Reset trainers to Félia and Félix only
|
||||
-- Run this in your Supabase SQL Editor
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
felia_id UUID;
|
||||
felix_id UUID;
|
||||
old_trainer_ids UUID[];
|
||||
BEGIN
|
||||
-- Step 1: Get IDs of trainers to be replaced
|
||||
SELECT ARRAY[
|
||||
(SELECT id FROM trainers WHERE name = 'Emma'),
|
||||
(SELECT id FROM trainers WHERE name = 'Jake'),
|
||||
(SELECT id FROM trainers WHERE name = 'Mia'),
|
||||
(SELECT id FROM trainers WHERE name = 'Alex'),
|
||||
(SELECT id FROM trainers WHERE name = 'Sofia')
|
||||
] INTO old_trainer_ids;
|
||||
|
||||
-- Step 2: Create Félia if not exists
|
||||
SELECT COALESCE(
|
||||
(SELECT id FROM trainers WHERE name = 'Félia' LIMIT 1),
|
||||
gen_random_uuid()
|
||||
) INTO felia_id;
|
||||
|
||||
INSERT INTO trainers (id, name, specialty, color, workout_count, avatar_url)
|
||||
VALUES (felia_id, 'Félia', 'Core', '#30D158', 0, NULL)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = 'Félia',
|
||||
specialty = 'Core',
|
||||
color = '#30D158';
|
||||
|
||||
-- Step 3: Create Félix if not exists
|
||||
SELECT COALESCE(
|
||||
(SELECT id FROM trainers WHERE name = 'Félix' LIMIT 1),
|
||||
gen_random_uuid()
|
||||
) INTO felix_id;
|
||||
|
||||
INSERT INTO trainers (id, name, specialty, color, workout_count, avatar_url)
|
||||
VALUES (felix_id, 'Félix', 'Strength', '#FFD60A', 0, NULL)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = 'Félix',
|
||||
specialty = 'Strength',
|
||||
color = '#FFD60A';
|
||||
|
||||
-- Step 4: Get the final trainer IDs
|
||||
SELECT id INTO felia_id FROM trainers WHERE name = 'Félia';
|
||||
SELECT id INTO felix_id FROM trainers WHERE name = 'Félix';
|
||||
|
||||
-- Step 5: Update workouts - female trainers → felia, male trainers → felix
|
||||
UPDATE workouts
|
||||
SET trainer_id = felia_id
|
||||
WHERE trainer_id IN (
|
||||
SELECT id FROM trainers WHERE name IN ('Emma', 'Mia', 'Sofia')
|
||||
);
|
||||
|
||||
UPDATE workouts
|
||||
SET trainer_id = felix_id
|
||||
WHERE trainer_id IN (
|
||||
SELECT id FROM trainers WHERE name IN ('Jake', 'Alex')
|
||||
);
|
||||
|
||||
-- Step 6: Update workout counts
|
||||
UPDATE trainers SET workout_count = (
|
||||
SELECT COUNT(*) FROM workouts WHERE trainer_id = felia_id
|
||||
) WHERE id = felia_id;
|
||||
|
||||
UPDATE trainers SET workout_count = (
|
||||
SELECT COUNT(*) FROM workouts WHERE trainer_id = felix_id
|
||||
) WHERE id = felix_id;
|
||||
|
||||
-- Step 7: Delete old trainers
|
||||
DELETE FROM trainers WHERE name IN ('Emma', 'Jake', 'Mia', 'Alex', 'Sofia');
|
||||
END $$;
|
||||
|
||||
-- Step 8: Verify
|
||||
SELECT 'Trainers:' AS result;
|
||||
SELECT id::text, name, specialty, color, workout_count FROM trainers ORDER BY name;
|
||||
|
||||
SELECT 'Workout trainer distribution:' AS result;
|
||||
SELECT trainer_id::text, COUNT(*) as workout_count FROM workouts GROUP BY trainer_id;
|
||||
60
admin-web/migrations/002_storage_policies.sql
Normal file
60
admin-web/migrations/002_storage_policies.sql
Normal file
@@ -0,0 +1,60 @@
|
||||
-- Storage RLS Policies for Trainer Assets
|
||||
-- Run this in your Supabase SQL Editor
|
||||
|
||||
-- Enable RLS on storage buckets (if not already enabled)
|
||||
ALTER TABLE storage.buckets ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Trainer Avatars Bucket Policies
|
||||
DROP POLICY IF EXISTS "Public read access" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "Public upload access" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "Public update access" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "Public delete access" ON storage.objects;
|
||||
|
||||
-- Read access (anyone can view)
|
||||
CREATE POLICY "Public read access" ON storage.objects
|
||||
FOR SELECT USING (bucket_id = 'trainer-avatars');
|
||||
|
||||
-- Upload access (anyone can upload)
|
||||
CREATE POLICY "Public upload access" ON storage.objects
|
||||
FOR INSERT WITH CHECK (bucket_id = 'trainer-avatars');
|
||||
|
||||
-- Update access (anyone can update)
|
||||
CREATE POLICY "Public update access" ON storage.objects
|
||||
FOR UPDATE USING (bucket_id = 'trainer-avatars');
|
||||
|
||||
-- Delete access (anyone can delete)
|
||||
CREATE POLICY "Public delete access" ON storage.objects
|
||||
FOR DELETE USING (bucket_id = 'trainer-avatars');
|
||||
|
||||
-- Trainer Videos Bucket Policies
|
||||
DROP POLICY IF EXISTS "Public read access videos" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "Public upload access videos" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "Public update access videos" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "Public delete access videos" ON storage.objects;
|
||||
|
||||
-- Read access (anyone can view)
|
||||
CREATE POLICY "Public read access videos" ON storage.objects
|
||||
FOR SELECT USING (bucket_id = 'trainer-videos');
|
||||
|
||||
-- Upload access (anyone can upload)
|
||||
CREATE POLICY "Public upload access videos" ON storage.objects
|
||||
FOR INSERT WITH CHECK (bucket_id = 'trainer-videos');
|
||||
|
||||
-- Update access (anyone can update)
|
||||
CREATE POLICY "Public update access videos" ON storage.objects
|
||||
FOR UPDATE USING (bucket_id = 'trainer-videos');
|
||||
|
||||
-- Delete access (anyone can delete)
|
||||
CREATE POLICY "Public delete access videos" ON storage.objects
|
||||
FOR DELETE USING (bucket_id = 'trainer-videos');
|
||||
|
||||
-- Exercise Videos Table Policies (if table exists)
|
||||
DROP POLICY IF EXISTS "Public read access exercise_videos" ON exercise_videos;
|
||||
DROP POLICY IF EXISTS "Public insert access exercise_videos" ON exercise_videos;
|
||||
DROP POLICY IF EXISTS "Public update access exercise_videos" ON exercise_videos;
|
||||
DROP POLICY IF EXISTS "Public delete access exercise_videos" ON exercise_videos;
|
||||
|
||||
CREATE POLICY "Public read access exercise_videos" ON exercise_videos FOR SELECT USING (true);
|
||||
CREATE POLICY "Public insert access exercise_videos" ON exercise_videos FOR INSERT WITH CHECK (true);
|
||||
CREATE POLICY "Public update access exercise_videos" ON exercise_videos FOR UPDATE USING (true);
|
||||
CREATE POLICY "Public delete access exercise_videos" ON exercise_videos FOR DELETE USING (true);
|
||||
24
admin-web/migrations/003_create_exercise_videos.sql
Normal file
24
admin-web/migrations/003_create_exercise_videos.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
-- Create exercise_videos table
|
||||
CREATE TABLE IF NOT EXISTS exercise_videos (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
workout_id UUID NOT NULL,
|
||||
trainer_id UUID NOT NULL,
|
||||
exercise_name TEXT NOT NULL,
|
||||
video_url TEXT NOT NULL,
|
||||
video_path TEXT NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE exercise_videos ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Public policies
|
||||
DROP POLICY IF EXISTS "Public read access exercise_videos" ON exercise_videos;
|
||||
DROP POLICY IF EXISTS "Public insert access exercise_videos" ON exercise_videos;
|
||||
DROP POLICY IF EXISTS "Public update access exercise_videos" ON exercise_videos;
|
||||
DROP POLICY IF EXISTS "Public delete access exercise_videos" ON exercise_videos;
|
||||
|
||||
CREATE POLICY "Public read access exercise_videos" ON exercise_videos FOR SELECT USING (true);
|
||||
CREATE POLICY "Public insert access exercise_videos" ON exercise_videos FOR INSERT WITH CHECK (true);
|
||||
CREATE POLICY "Public update access exercise_videos" ON exercise_videos FOR UPDATE USING (true);
|
||||
CREATE POLICY "Public delete access exercise_videos" ON exercise_videos FOR DELETE USING (true);
|
||||
11
admin-web/migrations/004_add_video_type_column.sql
Normal file
11
admin-web/migrations/004_add_video_type_column.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- Add video_type and duration_seconds columns to exercise_videos table
|
||||
ALTER TABLE exercise_videos
|
||||
ADD COLUMN IF NOT EXISTS video_type TEXT DEFAULT 'exercise'
|
||||
CHECK (video_type IN ('exercise', 'rest'));
|
||||
|
||||
ALTER TABLE exercise_videos
|
||||
ADD COLUMN IF NOT EXISTS duration_seconds INTEGER;
|
||||
|
||||
-- Update existing rows to have appropriate values
|
||||
UPDATE exercise_videos SET video_type = 'exercise' WHERE video_type IS NULL;
|
||||
UPDATE exercise_videos SET duration_seconds = 8 WHERE duration_seconds IS NULL;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user