30 Commits

Author SHA1 Message Date
Millian Lamiaux
9943dce82d fix: add HealthKit entitlement and regenerate Xcode project to resolve NSInvalidArgumentException
Some checks failed
CI / TypeScript (pull_request) Failing after 5s
CI / ESLint (pull_request) Failing after 4s
CI / Tests (pull_request) Failing after 5s
CI / Build Check (pull_request) Has been skipped
CI / Admin Web Tests (pull_request) Successful in 2m8s
CI / Deploy Edge Functions (pull_request) Has been skipped
The iOS app target was missing the com.apple.developer.healthkit entitlement
and the Xcode project was out of sync with project.yml, causing a crash when
HealthKitService requested write authorization for active energy, workouts, and
heart rate types.
2026-04-23 22:34:54 +02:00
Millian Lamiaux
cf096f2068 Redesign Activity tab with animated rings, monthly calendar, and global stats
Some checks failed
CI / TypeScript (push) Failing after 7s
CI / ESLint (push) Failing after 3s
CI / Tests (push) Failing after 7s
CI / Build Check (push) Has been skipped
CI / Admin Web Tests (push) Successful in 2m6s
CI / Deploy Edge Functions (push) Has been skipped
2026-04-22 01:18:42 +02:00
Millian Lamiaux
d74c47b1a8 Move level badge to top-left and free badge to top-right in FeaturedProgramCard 2026-04-22 00:54:42 +02:00
Millian Lamiaux
0f5b7b9e18 feat(i18n): complete internationalization for iOS + watchOS across all views
Migrate every hardcoded Text("...") string to the L10n / LocalizedStringResource
type-safe key system with full en/fr/de/es translations (4 languages).

iOS changes (TabataGo target):
- Strings.swift: ~90 new L10n keys across 13 groups (action, tab, home, zone,
  level, programs, programDetail, player, profile, settings, policy, paywall,
  health, complete, activity, onboarding, goal)
- Localizable.xcstrings: 145 → 245+ keys with fr/de/es translations
- Model enums: FitnessLevel.label & FitnessGoal.label changed from String to
  LocalizedStringResource, backed by L10n.level/goal keys
- Component param types changed to LocalizedStringResource: StatBadge,
  SectionHeader, ProfileRow, PolicySection, CompletionStat, FeatureRow,
  OnboardingHeader, PrimaryButton, SelectionCard
- All 18 view files updated: HomeTab, ActivityTab, ProgramsTab, ProfileTab,
  MainTabView, SettingsView, PolicyViews, CompletionView, BodyZoneView,
  ProgramDetailView, PaywallView, OnboardingView, PlayerView

Watch changes (TabataGoWatch target):
- New Localizable.xcstrings: 23 keys with en/fr/de/es (phase labels, idle
  state, activity rings, complication strings)
- New WatchL10n.swift: type-safe enum (needs manual Xcode target membership)
- Updated: WatchPlayerView, WatchIdleView, WatchActivityView,
  TabataGoComplication (inline LocalizedStringResource for widget target)

Both iOS and watchOS targets build with zero errors.
2026-04-22 00:41:19 +02:00
Millian Lamiaux
e28bebea79 Redesign FeaturedProgramCard and reorder Home sections
- Replace external pill badges (level + FREE) with immersive overlay card:
  level tag pinned top-right with ultraThinMaterial blur, FREE shown inline
  with checkmark.seal.fill icon, dark scrim for text legibility
- Remove card drop shadow
- Move Browse by Zone section above Featured in Home tab scroll order
2026-04-21 23:18:15 +02:00
Millian Lamiaux
9f15ae2d79 fix: dismiss paywall and sync premium state after successful purchase
- Add purchaseSucceeded flag to PurchaseViewModel, set on purchase success
- PaywallView observes the flag and dismisses itself automatically
- ProfileTab.syncSubscription() writes PurchaseService.currentPlan back
  to UserProfile.subscriptionRaw via SwiftData on sheet dismiss
2026-04-21 23:00:45 +02:00
Millian Lamiaux
877f836f19 Replace double chip filters with segmented control + dropdown menu in ProgramsTab
Swap the two horizontal FilterChip scroll rows with a native segmented
Picker for body zone and a toolbar Menu for difficulty level. Fix zone
values to match Supabase enum (upper-body, lower-body, full-body).
Remove unused FilterChip struct.
2026-04-21 22:47:47 +02:00
Millian Lamiaux
2413bc0356 Remove 'All Workouts' section from HomeTab 2026-04-21 22:04:56 +02:00
Millian Lamiaux
89cca25e22 remove Expo project and all related files
Remove the entire Expo/React Native application: routes (app/), source
code (src/), assets, iOS native build, config plugins, StoreKit config,
npm dependencies, TypeScript/ESLint/Vitest configs, and Expo-specific
documentation. The repository now contains only: admin-web, supabase,
youtube-worker, tabatago-swift, docs, scripts, and CI/tooling configs.
2026-04-21 21:55:00 +02:00
Millian Lamiaux
8c90b73d90 update config, admin-web tooling & relocate agent skills
Update app.json config and add new dependencies in package.json. Update
.gitignore for new patterns. Add timed-exercise editor/list components,
warmup/stretch video migration, and Supabase helpers in admin-web.
Relocate agent skills from .agents/skills/ to .opencode/skills/.
2026-04-21 21:51:11 +02:00
Millian Lamiaux
d4edf54aeb update data layer, i18n locales & service exports
Rewrite data index, useTranslatedData, and workoutPrograms for the new
Supabase-backed model. Update hooks/stores barrel exports. Extend user
types and workoutProgram types. Refresh all i18n locale files (en, fr,
de, es) with updated screen translations. Add music service helpers.
Fix minor test import updates.
2026-04-21 21:51:01 +02:00
Millian Lamiaux
5888aac08e refactor screens, navigation & player for new architecture
Simplify Home, Activity, Profile, Complete, Player, and Program screens
to work with the new Supabase-driven data layer. Update root and tab
layouts. Add Settings, Terms, and Zone routes. Add OfflineBanner
component and progressStore. Update all player sub-components to use
the refreshed design system tokens.
2026-04-21 21:50:48 +02:00
Millian Lamiaux
04b83fc419 refresh design system: colors, typography, native components
Update color palette, dark theme tokens, typography scale, and border
radius constants. Refactor native UI primitives (NativeButton, NativeList,
NativeSection, NativeLabeledRow) for the new design language. Simplify
OnboardingStep. Remove legacy WorkoutCard and CollectionCard components.
2026-04-21 21:50:31 +02:00
Millian Lamiaux
13262305e5 remove obsolete tests for deleted data, stores & components
Delete tests for removed modules: WorkoutCard, CollectionCard, Skeleton,
achievements, dataService, programs, trainers, useTranslatedData,
workouts, useSupabaseData, activityStore, programStore,
tabataProgramStore, and workoutProgramStore.
2026-04-21 21:50:19 +02:00
Millian Lamiaux
3fe9d926ad remove legacy data layer, stores & Supabase seed
Delete hardcoded programs data (including tabata sub-modules), workouts,
achievements, collections, trainers, and the dataService abstraction.
Remove activityStore, programStore, and tabataProgramStore which
depended on this data. Remove useSupabaseData hook and supabase seed
file. Data now comes from Supabase via the admin-web CMS.
2026-04-21 21:50:09 +02:00
Millian Lamiaux
d82205cd71 remove legacy admin panel, assessment, collection & workout routes
Remove the in-app admin panel (app/admin/, src/admin/), assessment
screen, collection detail routes, and workout detail/category/body-zone
routes. These features have been superseded by the admin-web dashboard
and the new program-based navigation. Also removes stale CLAUDE.md
context files and an accidentally committed image blob.
2026-04-21 21:49:58 +02:00
Millian Lamiaux
791f432334 refactor: code quality cleanup — remove any types, add logger, rename Kine to Tabata
- Phase 0: Rename all Kine references to Tabata (types, files, imports, i18n, analytics events)
- Phase 1: Add test coverage for tabataProgramStore, workoutProgramStore, and color utils (47 tests)
- Phase 2: Remove all `any` types from production code with proper typed replacements
- Phase 3: Replace ~60 raw console.* calls with __DEV__-gated logger utility
- Phase 4: Verify .DS_Store housekeeping (already clean)

0 TypeScript errors, 583/583 tests passing.
2026-04-17 18:56:24 +02:00
Millian Lamiaux
e0e02c4550 fix: align program detail screen with Dark Medical design system
Replace hardcoded #000 backgrounds with NAVY[900], use design system
tokens for badges (TYPOGRAPHY.LABEL, RADIUS.SM), progress bar
(RADIUS.PILL, 4px height), and CTA container (DARK.SCRIM).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 22:19:29 +02:00
Millian Lamiaux
0990ec8e11 refactor: remove explore tab, simplify to 3-tab layout (Home, Progress, Profile)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 22:06:11 +02:00
Millian Lamiaux
4458044d0e fix: resolve all 228 TypeScript errors across the project
- Exclude admin-web and supabase/functions from root tsconfig (185 errors)
- Add missing constant exports: FONT_FAMILY_SANS_BOLD/SEMIBOLD, BRAND_DANGER (9 errors)
- Fix React Query API destructuring in admin screens: data/isLoading aliases (12 errors)
- Add getWorkoutsByCategory to data layer, fix category screen types (5 errors)
- Add type assertions for Supabase never types in adminService and sync.ts (13 errors)
- Add missing vitest imports (vi, beforeEach) and replace removed PRIMARY_DARK (5 errors)
- Fix seed.ts to use program.weeks[].workouts[] instead of missing props (4 errors)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 17:53:02 +02:00
Millian Lamiaux
edcd857c70 feat(admin-web, functions): overhaul music library and add AI genre classification
Some checks failed
CI / TypeScript (push) Failing after 48s
CI / ESLint (push) Failing after 20s
CI / Tests (push) Failing after 33s
CI / Build Check (push) Has been skipped
CI / Admin Web Tests (push) Failing after 18s
CI / Deploy Edge Functions (push) Has been skipped
- admin-web: Added an "All Music" library view with search, genre, and status filters.
- admin-web: Converted Jobs view to use expandable cards instead of a split pane.
- admin-web: Added ability to delete individual tracks from a job.
- functions: Added new `youtube-classify` edge function to automatically categorize tracks using Gemini LLM.
- functions: Integrated AI genre classification during initial playlist import if no manual genre is provided.
- worker: Added `/classify` endpoint for the worker to securely interface with Gemini.
- scripts: Updated deployment script to include `GEMINI_API_KEY`.
2026-03-29 12:52:02 +02:00
Millian Lamiaux
3d8d9efd70 feat: YouTube music download system with admin dashboard
Sidecar architecture: edge functions (auth + DB) → youtube-worker container
(youtubei.js for playlist metadata, yt-dlp for audio downloads).

- Edge functions: youtube-playlist, youtube-process, youtube-status (CRUD)
- youtube-worker sidecar: Node.js + yt-dlp, downloads M4A to Supabase Storage
- Admin music page: import playlists, process queue, inline genre editing
- 12 music genres with per-track assignment and import-time defaults
- DB migrations: download_jobs, download_items tables with genre column
- Deploy script and CI workflow for edge functions + sidecar
2026-03-26 10:47:05 +01:00
Millian Lamiaux
8926de58e5 refactor: extract player components, add stack headers, add tests
- Extract player UI into src/features/player/ components (TimerRing, BurnBar, etc.)
- Add transparent stack headers for workout/[id] and program/[id] screens
- Refactor workout/[id], program/[id], complete/[id] screens
- Add player feature tests and useTimer integration tests
- Add data layer exports and test setup improvements
2026-03-26 10:46:47 +01:00
Millian Lamiaux
569a9e178f fix: add missing getPopularWorkouts export to data layer
The workout complete screen imports getPopularWorkouts to show
recommended workouts, but the function was never implemented. Added it
to the data index — picks the first workout from each program week for
variety across categories and levels.
2026-03-26 00:06:46 +01:00
Millian Lamiaux
b833198e9d feat: migrate icons to SF Symbols, refactor explore tab, add collections/programs data layer
- Replace all Ionicons with native SF Symbols via expo-symbols SymbolView
- Create reusable Icon wrapper component (src/shared/components/Icon.tsx)
- Remove @expo/vector-icons and lucide-react dependencies
- Refactor explore tab with filters, search, and category browsing
- Add collections and programs data with Supabase integration
- Add explore filter store and filter sheet
- Update i18n strings (en, de, es, fr) for new explore features
- Update test mocks and remove stale snapshots
- Add user fitness level to user store and types
2026-03-25 23:28:51 +01:00
Millian Lamiaux
f11eb6b9ae fix: add missing Workout fields to program workouts and guard against undefined level
Program workouts built by buildProgramWorkouts() were missing level,
rounds, calories, and other Workout-interface fields, causing
workout.level.toLowerCase() to crash on the detail, collection, and
category screens. Added derived defaults (level from week number,
category from program id, standard Tabata timings) and defensive
fallbacks with ?? 'Beginner' at all call sites. Also fixed a potential
division-by-zero when exercises array is empty.
2026-03-25 23:28:47 +01:00
Millian Lamiaux
4fa8be600c test: add QA coverage — access unit tests, VideoPlayer snapshots, Maestro E2E flows, testIDs
- Add testIDs to explore, workout detail, and collection detail screens
- Add testID prop to VideoPlayer component
- Create access service unit tests (isFreeWorkout, canAccessWorkout)
- Create VideoPlayer rendering snapshot tests (preview/background modes)
- Create Maestro E2E flows: explore-freemium, collection-detail
- Update tab-navigation flow with Explore screen assertions
- Update profile-settings flow with real activity stat assertions
- Update all-tests suite to include new flows
2026-03-24 12:40:02 +01:00
Millian Lamiaux
a042c348c1 feat: close v1 feature gaps — freemium gating, video/audio infrastructure, EAS build config
- Add access control service with 3 free workouts (IDs 1, 11, 43), paywall gating on workout detail and lock indicators on explore grid
- Wire VideoPlayer into player background and workout detail preview
- Add placeholder HLS video URLs to 5 sample workouts (Apple test streams)
- Add test audio URLs to music service MOCK_TRACKS for all vibes
- Switch RevenueCat API key to env-based with sandbox fallback
- Create eas.json with development/preview/production build profiles
- Update app.json with iOS privacy descriptions (HealthKit, Camera) and non-exempt encryption flag
- Create collection detail screen (route crash fix)
- Replace hardcoded profile stats with real activity store data
- Add unlockWithPremium i18n key in EN/FR/DE/ES
2026-03-24 12:20:56 +01:00
Millian Lamiaux
cd065d07c3 feat: explore tab, React Query data layer, programs, sync, analytics, testing infrastructure
- Replace browse tab with Supabase-connected explore tab with filters
- Add React Query for data fetching with loading states
- Add 3 structured programs with weekly progression
- Add Supabase anonymous auth sync service
- Add PostHog analytics with screen tracking and events
- Add comprehensive test strategy (Vitest + Maestro E2E)
- Add RevenueCat subscription system with DEV simulation
- Add i18n translations for new screens (EN/FR/DE/ES)
- Add data deletion modal, sync consent modal
- Add assessment screen and program routes
- Add GitHub Actions CI workflow
- Update activity store with sync integration
2026-03-24 12:04:48 +01:00
Millian Lamiaux
8703c484e8 Replace workouts tab with explore tab connected to Supabase
- Rename workouts.tsx to explore.tsx with new functionality
- Add horizontal scrolling collections section with gradient cards
- Add featured workouts section
- Implement filtering by category (All, Full Body, Upper Body, Lower Body, Core, Cardio)
- Implement filtering by level (All Levels, Beginner, Intermediate, Advanced)
- Implement filtering by equipment (All, No Equipment, Band, Dumbbells, Mat)
- Add clear filters button when filters are active
- Add loading states with ActivityIndicator
- Add empty state for no results
- Update tab label from "Workouts" to "Explore"
- Add explore translations for en, fr, de, es
2026-03-23 21:27:19 +01:00
350 changed files with 37457 additions and 34080 deletions

View 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 | 2832px | 400 | Fin de séance, titres forts |
| `heading-1` | Serif | 2224px | 500 | Titres de section |
| `heading-2` | Sans | 18px | 500 | Titre exercice, carte programme |
| `body` | Sans | 1516px | 400 | Corps, conseil kiné |
| `label` | Mono | 1113px | 500 | Tags, metadata, uppercase tracking |
| `timer` | Mono | **80100px** | 500 | Timer séance — lisible à 2 mètres |
| `caption` | Sans | 12px | 400 | Sous-labels, hints |
**Règle typographie :** La taille du timer est la décision de design la plus
importante de l'écran séance. Tout se dimensionne autour de lui.
---
## 3. Espacement
Base : **4px**
| Token | Valeur | Usage |
|-------|--------|-------|
| `space-1` | 4px | Gap minimal entre éléments liés |
| `space-2` | 8px | Gap interne composant |
| `space-3` | 12px | Gap entre composants proches |
| `space-4` | 16px | Padding carte, gap standard |
| `space-6` | 24px | Espacement sections |
| `space-8` | 32px | Padding écran horizontal |
| `space-12` | 48px | Espacement majeur |
| `space-16` | 64px | Espacement entre blocs screens |
---
## 4. Border Radius
| Token | Valeur | Usage |
|-------|--------|-------|
| `radius-sm` | 4px | Badge, chip, tag |
| `radius-md` | 8px | Bouton, input, tip card |
| `radius-lg` | 12px | Carte programme standard |
| `radius-xl` | 16px | Carte large, modal |
| `radius-pill` | 9999px | Pill, toggle, progress bar |
| `radius-circle` | 50% | Icon button, avatar, streak dot |
---
## 5. Système d'élévation (surfaces)
```
Fond (navy-900)
└── Surface 1 (navy-800) — cartes par défaut
└── Surface 2 (navy-700) — cartes surélevées / hover
└── Surface active (navy-800 + border green-500 1.5px)
```
Différencier les surfaces **uniquement par la couleur de fond**, jamais par des
ombres portées (box-shadow : non). La bordure active verte est le seul signal
d'état sélectionné.
---
## 6. Composants
### Boutons
```
PrimaryButton
background: green-500
color: navy-900
padding: 14px 24px
height: 5256px
border-radius: radius-md
font: sans 15px 500
width: 100% (full-width dans les screens)
hover: background green-600
active: background green-700 + scale(0.98)
SecondaryButton
background: transparent
color: green-500
border: 1.5px solid green-500
padding: 13px 24px
hover: background green-dim
GhostButton
background: transparent
color: slate-300
no border
usage: actions secondaires (Passer, Annuler)
DangerButton
background: rgba(255,68,68,0.12)
color: #FF6B6B
border: 1px solid rgba(255,68,68,0.3)
usage: Quitter la séance UNIQUEMENT
IconButton
width: 44px
height: 44px
border-radius: 50%
background: rgba(168,178,216,0.10)
color: slate-300
JAMAIS en dessous de 44×44px (accessibilité)
```
### Inputs
```
TextField
background: navy-800
border: 1px solid border-dim
border-radius: radius-md
padding: 12px 16px
color: white-100
font: sans 15px 400
focus: border green-500
error: border red-500
height: 48px
```
### Badges & Pills
```
Badge (tier)
font: mono 11px 500
padding: 3px 10px
border-radius: radius-sm
UPPERCASE + letter-spacing: 0.08em
.free: background green-dim, color green-500
.premium: background orange-dim, color orange-500
.kine: background rgba(168,178,216,0.12), color slate-300
Pill (metadata)
font: sans 12px 400
padding: 4px 12px
border-radius: radius-pill
border: 1px solid (couleur correspondante à 0.3 opacity)
```
### Cartes
```
CardDefault
background: navy-800
border: 1px solid border-dim
border-radius: radius-lg
padding: 16px
CardAccent (CTA, prochaine séance)
background: rgba(0,200,150,0.05)
border: 1.5px solid green-border
border-radius: radius-lg
CardTip (conseil kiné)
background: orange-dim
border-left: 3px solid orange-500
border-radius: 0 radius-lg radius-lg 0
NE PAS arrondir le côté gauche (border-left unique)
Structure: icône 💡 + texte + signature "— Prénom, kiné"
CardProgram
border-radius: radius-xl
overflow: hidden
Thumbnail: 120px height, gradient navy-700→navy-600
Body: padding 14px
Toujours afficher: progression bar + "X/12 séances"
```
### Timer
```
Timer (composant le plus critique de l'app)
font: mono 80100px 500
text-align: center
État effort normal (>10s):
color: green-500
État urgence (<10s):
color: red-500
animation: pulse subtil (scale 1→1.02→1, 1s infinite)
Label sous le chiffre:
font: mono 14px 400
color: slate-400
letter-spacing: 0.1em
text: "SECONDES"
Contexte repos:
color: slate-300 (pas de vert, signal visuel de repos)
```
### Progress Bar
```
ProgressBar
track: background rgba(168,178,216,0.12), height 4px, border-radius pill
fill: background green-500, border-radius pill
Variante séance (épaisseur réduite):
height: 3px
Variante programme:
height: 4px
Afficher le % à droite en mono 11px green-500
Animation: transition width 300ms ease
```
### Feedback ressenti
```
FeedbackButton
width: flex (3 boutons égaux)
height: 72px
border-radius: radius-lg
background: navy-800
border: 1px solid border-dim
flex-direction: column
gap: 4px
Emoji: 28px
Label: sans 12px slate-400
État sélectionné:
border: 1.5px solid green-500
background: green-dim
```
### Streak hebdomadaire
```
StreakDot
width: 32px
height: 32px
border-radius: 50%
.done: background rgba(0,200,150,0.15) → afficher ✓
.today: background green-500 → afficher ✓
.empty: background rgba(168,178,216,0.06), border 1px border-dim
Label jour: mono 10px slate-400, centré sous chaque dot
```
---
## 7. Écran séance — règles spéciales
L'écran séance est le plus critique de l'app. Il doit être utilisable **les mains
sur les genoux, en sueur, à 2 mètres de l'écran**. Chaque décision de design doit
passer ce test.
### Architecture visuelle
```
[Vidéo plein écran en boucle — fond de tout l'écran]
↓ Gradient top navy→transparent (40% opacité, 100px height)
→ Contrôles pause/audio en overlay
→ Indicateur exercice X/8 centré
↓ Zone centrale nette (pas de gradient — l'utilisateur voit le mouvement)
↓ Gradient bottom transparent→navy (70% opacité, 220px height)
→ Timer géant centré
→ Progress bar
→ Tip card conseil kiné
```
### Transitions séance
```
Effort → Repos:
Fond passe de vidéo plein écran → navy-800 uni
Transition: fade 300ms
Vibration haptique légère (si disponible)
Le repos a une identité visuelle différente (pas de vidéo, couleur unie)
Repos → Effort:
Countdown audio "3... 2... 1..."
Vibration haptique + transition fade
Exercice suivant pendant le repos:
Afficher un thumbnail 56×56px du prochain exercice
Nom en sans 14px 500
Label "PROCHAIN EXERCICE" en mono 11px slate-400
```
### Phase repos
L'écran repos doit être **visuellement différent** de l'écran effort.
- Fond : `navy-800` uni (plus de vidéo plein écran)
- Timer couleur : `slate-300` (pas de vert — c'est le repos)
- Mot "REPOS" en mono 13px slate-400, letter-spacing 0.15em
- Aperçu prochain exercice centré
---
## 8. Navigation
```
Tab Bar (5 onglets, fixé en bas)
height: 56px + safe area inset
background: navy-800
border-top: 1px solid border-dim
Onglets:
- Accueil (home icon)
- Programmes (grid icon)
- Minuteur (timer icon)
- Progression (chart icon)
- Profil (person icon)
Onglet actif: icône green-500 + label green-500
Onglet inactif: icône slate-400 + label slate-400
Font label: sans 11px 400
Icon size: 22×22px
Touch target: 44×44px minimum
```
---
## 9. Animations & micro-interactions
```
FadeIn:
opacity: 0 → 1
duration: 300ms
easing: ease
SlideUp (bottom sheet, modal):
translateY(100%) → 0
duration: 400ms
easing: cubic-bezier(0.4, 0, 0.2, 1)
Pulse (CTA bouton, timer urgence):
scale: 1 → 1.02 → 1
duration: 2s
infinite, ease-in-out
Bounce (célébration fin de séance):
scale: 0.5 → 1.05 → 0.98 → 1
duration: 600ms
easing: spring
StaggerList (items qui apparaissent en séquence):
Délai: 100ms entre chaque item
Chaque item: FadeIn + translateY(12px→0)
ScalePress (tous les boutons):
active: scale(0.97)
duration: 100ms
```
**Règle d'or animations :** Une animation bien exécutée au chargement d'écran
vaut mieux que des micro-interactions dispersées partout.
---
## 10. Paywall — règles de design conversion
```
Structure obligatoire du paywall:
1. Célébration des accomplissements (TOUJOURS en premier)
→ Font serif italic, emoji, stats concrètes
2. Valeur du contenu débloqué (liste concrète)
3. Pricing transparent (pas de dark patterns)
→ "Essai gratuit 7 jours · puis 24,99€/an · soit 2,08€/mois"
4. CTA principal (PrimaryButton full-width)
5. Réassurance ("Annulation facile à tout moment")
6. Alternative gratuite visible (GhostButton ou lien)
Couleur encadré pricing: CardAccent (vert)
Bouton fermeture: TOUJOURS visible en haut à gauche
Pas de compte à rebours fictif, pas de stock limité : anti dark patterns
```
---
## 11. Accessibilité
```
Contraste texte:
Texte primaire (#E6F1FF) sur navy-900 → ratio 15:1 ✓
Texte secondaire (#A8B2D8) sur navy-900 → ratio 7:1 ✓
Vert (#00C896) sur navy-900 → ratio 8:1 ✓
Tous conformes WCAG AA (4.5:1 minimum requis)
Touch targets:
Minimum 44×44px pour TOUS les éléments interactifs
Espacement minimum 8px entre deux éléments interactifs adjacents
Timer:
La couleur n'est pas le seul signal d'urgence
Ajouter aussi: pulse animation + vibration haptique + signal audio
Audio:
Toujours proposer une alternative visuelle à chaque signal audio
Le toggle audio est accessible en 1 tap depuis l'écran séance
```
---
## 12. Tokens React Native / Expo
```typescript
// design-tokens.ts
export const colors = {
// Navy
navy900: '#0D1B2A',
navy800: '#112240',
navy700: '#1A3050',
navy600: '#243C5E',
// Green
green500: '#00C896',
green600: '#00A67C',
green700: '#00875F',
greenDim: 'rgba(0,200,150,0.12)',
greenBorder: 'rgba(0,200,150,0.35)',
// Text
white100: '#E6F1FF',
slate300: '#A8B2D8',
slate400: '#8892B0',
// Borders
borderDim: 'rgba(168,178,216,0.15)',
borderHover: 'rgba(168,178,216,0.25)',
// Orange (tip/kine only)
orange500: '#FF8A5C',
orange600: '#E06A3C',
orangeDim: 'rgba(255,138,92,0.12)',
// Semantic
red500: '#FF4444', // timer urgence ONLY
} as const
export const spacing = {
1: 4,
2: 8,
3: 12,
4: 16,
6: 24,
8: 32,
12: 48,
16: 64,
} as const
export const radius = {
sm: 4,
md: 8,
lg: 12,
xl: 16,
pill: 9999,
} as const
export const fontSizes = {
caption: 12,
label: 13,
body: 15,
heading2: 18,
heading1: 22,
display: 28,
timer: 88, // taille par défaut du timer
} as const
export const timerThreshold = 10 // secondes — passage vert → rouge
```
---
## Checklist avant livraison d'un écran
- [ ] Fond `navy-900` utilisé comme base
- [ ] Aucun shadow/élévation — différenciation par couleur uniquement
- [ ] Tous les touch targets ≥ 44×44px
- [ ] Le vert n'est utilisé que pour des actions ou validations
- [ ] L'orange n'est utilisé que pour des conseils kiné ou alertes positives
- [ ] Le rouge n'apparaît que sur le timer en urgence
- [ ] Timer ≥ 80px de haut sur l'écran séance
- [ ] Vidéo plein écran sur l'écran séance (pas un bloc vidéo)
- [ ] Gradients top + bottom sur l'écran séance pour la lisibilité
- [ ] Phase repos visuellement différente de la phase effort
- [ ] Paywall : célébration en premier, alternative gratuite visible
- [ ] Typographie : serif pour les moments émotionnels, mono pour les données

View File

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

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

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

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

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

View 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
View File

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

View File

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

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

View 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

View 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

View 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

View 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

View 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

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

View 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

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

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

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

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

View 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

View 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>
);
}
```

View 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

View 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

View 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.

View 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.

View 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)

View 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;
}
```

View File

@@ -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>
);
}
```

View 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} />;
}
```

View 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",
]);
```

View 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>
);
}
```

View File

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

View 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

View 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>
);
}
```

View File

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

View 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

View 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.

View 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);
}

View 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);
}

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

View File

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

View 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

View 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

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

View 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

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

View 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 };
```

View 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.

View File

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

View 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 (812 séances) | `[x]` | P0 | `src/shared/data/tabata/debutant.ts` |
| F-047 | Haut du corps — Intermédiaire (812 séances) | `[x]` | P0 | `src/shared/data/tabata/intermediaire.ts` |
| F-048 | Haut du corps — Avancé (812 séances) | `[x]` | P1 | `src/shared/data/tabata/avance.ts` |
| F-049 | Bas du corps — Débutant (812 séances) | `[x]` | P0 | Dans les data files tabata |
| F-050 | Bas du corps — Intermédiaire (812 séances) | `[x]` | P0 | Dans les data files tabata |
| F-051 | Bas du corps — Avancé (812 séances) | `[x]` | P1 | Dans les data files tabata |
| F-052 | Corps complet — Débutant (812 séances) | `[x]` | P0 | Dans les data files tabata |
| F-053 | Corps complet — Intermédiaire (812 séances) | `[x]` | P0 | Dans les data files tabata |
| F-054 | Corps complet — Avancé (812 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
View File

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

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

View File

@@ -1,75 +0,0 @@
# TabataFit
> **Apple Fitness+ for Tabata** — The Premium HIIT Experience
![Expo](https://img.shields.io/badge/Expo-52-black)
![TypeScript](https://img.shields.io/badge/TypeScript-5.0-blue)
![License](https://img.shields.io/badge/License-Proprietary-red)
## 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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

View 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 }
)
}
}

View 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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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

View 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 &quot;{program.title}&quot;
</p>
</div>
<div className="rounded-lg border border-neutral-800 bg-neutral-900 p-6">
<ProgramForm initialData={program} mode="edit" />
</div>
</div>
)
}

View 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>
)
}

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

View 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>
)
}

View 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 &quot;{programToDelete?.title}&quot;? 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>
);
}

View 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>
)
}

View 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>
)
}

View File

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

View File

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

View 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>
)
}

View File

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

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

View 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>
)
}

View File

@@ -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() {

View 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>
)
}

View 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: "",
}
}

View 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>
)
}

View 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>
)
}

View File

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

View 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 })
})
})

View 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()
}
})
})

View 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
View 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
View 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,
}
}

View File

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

View 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,
};
}

View 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;

View 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);

View 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);

View 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