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.
This commit is contained in:
Millian Lamiaux
2026-04-21 21:55:00 +02:00
parent 8c90b73d90
commit 89cca25e22
285 changed files with 11212 additions and 44392 deletions

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,13 +0,0 @@
# TabataFit Environment Variables
# Copy this file to .env and fill in your credentials
# Supabase Configuration
EXPO_PUBLIC_SUPABASE_URL=your_supabase_project_url
EXPO_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
# RevenueCat (Apple subscriptions)
# Defaults to test_ sandbox key if not set
EXPO_PUBLIC_REVENUECAT_API_KEY=your_revenuecat_api_key
# Admin Dashboard (optional - for admin authentication)
EXPO_PUBLIC_ADMIN_EMAIL=admin@tabatafit.app

112
README.md
View File

@@ -1,112 +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)
![Tests](https://img.shields.io/badge/Tests-546%20passing-brightgreen)
![Coverage](https://img.shields.io/badge/Coverage-Statements%20%7C%20Branches%20%7C%20Functions%20%7C%20Lines-blue)
## 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
```
## Testing
```bash
# Unit tests with coverage
npm run test:coverage
# Component render tests
npm run test:render
# All unit + render tests
npm test && npm run test:render
# Maestro E2E (requires Expo dev server + simulator)
npm run test:maestro
# Admin-web tests
cd admin-web && npm test # Unit tests
cd admin-web && npm run test:e2e # Playwright E2E
```
### Test Coverage
| Layer | Target | Tests |
|-------|--------|-------|
| Stores | 80%+ | playerStore, activityStore, userStore, programStore |
| Services | 80%+ | analytics, music, purchases, sync |
| Hooks | 70%+ | useTimer, useHaptics, useAudio, usePurchases, useMusicPlayer, useNotifications, useSupabaseData |
| Components | 50%+ | StyledText, VideoPlayer, WorkoutCard, GlassCard, CollectionCard, modals, Skeleton |
| Data | 80%+ | achievements, collections, programs, trainers, workouts |
### E2E Tests
- **Mobile (Maestro)**: Onboarding, tab navigation, program browse, workout player, activity, profile/settings
- **Admin Web (Playwright)**: Auth, navigation, workouts CRUD, trainers, collections
## 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*

File diff suppressed because it is too large Load Diff

View File

@@ -1,91 +0,0 @@
{
"expo": {
"name": "TabataFit",
"slug": "tabatafit",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "tabatafit",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.millianlmx.tabatafit",
"buildNumber": "1",
"infoPlist": {
"NSHealthShareUsageDescription": "TabataFit uses HealthKit to read and write workout data including heart rate, calories burned, and exercise minutes.",
"NSHealthUpdateUsageDescription": "TabataFit saves your workout sessions to Apple Health so you can track your fitness progress.",
"NSCameraUsageDescription": "TabataFit uses the camera for profile photos and workout form checks.",
"NSUserTrackingUsageDescription": "TabataFit uses this to provide personalized workout recommendations.",
"ITSAppUsesNonExemptEncryption": false
},
"config": {
"usesNonExemptEncryption": false
},
"associatedDomains": [
"applinks:tabatafit.app"
]
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false,
"package": "com.millianlmx.tabatafit",
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [
{
"scheme": "https",
"host": "tabatafit.app",
"pathPrefix": "/workout"
},
{
"scheme": "https",
"host": "tabatafit.app",
"pathPrefix": "/player"
},
{
"scheme": "https",
"host": "tabatafit.app",
"pathPrefix": "/program"
}
],
"category": ["BROWSABLE", "DEFAULT"]
}
]
},
"web": {
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"image": "./assets/images/splash-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#ffffff",
"dark": {
"backgroundColor": "#000000"
}
}
],
"expo-video",
"expo-localization",
"./plugins/withStoreKitConfig"
],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
}
}
}

View File

@@ -1,48 +0,0 @@
/**
* TabataGo Tab Layout
* Native liquid glass tab bar (iOS 26+) via expo-router/unstable-native-tabs
* 3 tabs: Home, Activity, Profile
*/
import { Redirect } from 'expo-router'
import { NativeTabs, Icon, Label } from 'expo-router/unstable-native-tabs'
import { useTranslation } from 'react-i18next'
import { BRAND, TEXT, NAVY } from '@/src/shared/constants/colors'
import { useUserStore } from '@/src/shared/stores'
export default function TabLayout() {
const { t } = useTranslation()
const onboardingCompleted = useUserStore((s) => s.profile.onboardingCompleted)
if (!onboardingCompleted) {
return <Redirect href="/onboarding" />
}
return (
<NativeTabs
backgroundColor={NAVY[800]}
iconColor={{ default: TEXT.TERTIARY, selected: BRAND.PRIMARY }}
labelStyle={{
fontSize: 11,
fontWeight: '400',
color: TEXT.TERTIARY,
}}
>
<NativeTabs.Trigger name="index" options={{ title: t('screens:tabs.home') }}>
<Icon sf={{ default: 'house', selected: 'house.fill' }} />
<Label>{t('screens:tabs.home')}</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="activity" options={{ title: t('screens:tabs.progression') }}>
<Icon sf={{ default: 'chart.bar', selected: 'chart.bar.fill' }} />
<Label>{t('screens:tabs.progression')}</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="profile" options={{ title: t('screens:tabs.profile') }}>
<Icon sf={{ default: 'person', selected: 'person.fill' }} />
<Label>{t('screens:tabs.profile')}</Label>
</NativeTabs.Trigger>
</NativeTabs>
)
}

View File

@@ -1,179 +0,0 @@
/**
* TabataGo Activity Tab
* Streak, weekly sessions, program history — driven by progressStore.
*/
import { useMemo } from 'react'
import { View, Text, StyleSheet, ScrollView } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useTranslation } from 'react-i18next'
import { Icon } from '@/src/shared/components/Icon'
import { useProgressStore } from '@/src/shared/stores/progressStore'
import { useThemeColors } from '@/src/shared/theme'
import type { ThemeColors } from '@/src/shared/theme/types'
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { GREEN, NAVY, TEXT, BORDER_COLORS } from '@/src/shared/constants/colors'
export default function ActivityScreen() {
const { t } = useTranslation()
const insets = useSafeAreaInsets()
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
const history = useProgressStore(s => s.history)
const streak = useProgressStore(s => s.streak)
const weeklyCount = useProgressStore(s => s.getWeeklyCount())
const completedCount = useProgressStore(s => s.getCompletedCount())
const totalMinutes = useMemo(
() => history.reduce((sum, s) => sum + Math.round(s.durationSeconds / 60), 0),
[history],
)
return (
<ScrollView
style={styles.container}
contentContainerStyle={[styles.content, { paddingTop: insets.top + SPACING[4], paddingBottom: insets.bottom + SPACING[6] }]}
contentInsetAdjustmentBehavior="automatic"
>
<Text style={styles.title}>{t('screens:tabs.progression')}</Text>
{/* Streak hero */}
<View style={styles.streakHero}>
<Icon name="flame.fill" size={32} tintColor={GREEN[500]} />
<Text selectable style={styles.streakCount}>{streak.current}</Text>
<Text style={styles.streakLabel}>{t('screens:activity.dayStreak')}</Text>
<Text style={styles.streakRecord}>
{t('screens:activity.longest')}: {streak.longest}
</Text>
</View>
{/* Stats grid */}
<View style={styles.grid}>
<StatCard
icon="checkmark.circle.fill"
value={completedCount}
label={t('screens:programs.completed')}
color={GREEN[500]}
/>
<StatCard
icon="calendar"
value={weeklyCount}
label={t('screens:activity.thisWeek')}
color="#5AC8FA"
/>
<StatCard
icon="clock.fill"
value={totalMinutes}
label={t('screens:player.minutes')}
color="#FF6B35"
/>
</View>
{/* Recent history */}
{history.length > 0 && (
<View style={styles.historySection}>
<Text style={styles.sectionTitle}>{t('screens:activity.recent')}</Text>
{history.slice(0, 10).map((session, i) => (
<View key={i} style={styles.historyRow}>
<Icon name="checkmark.circle.fill" size={18} tintColor={GREEN[500]} />
<View style={{ flex: 1 }}>
<Text style={styles.historyTitle} numberOfLines={1}>{session.programId}</Text>
<Text style={styles.historyMeta}>
{Math.round(session.durationSeconds / 60)} min
{' · '}
{new Date(session.completedAt).toLocaleDateString()}
</Text>
</View>
</View>
))}
</View>
)}
{history.length === 0 && (
<View style={styles.emptyState}>
<Text style={styles.emptyTitle}>{t('screens:activity.emptyTitle')}</Text>
<Text style={styles.emptySubtitle}>{t('screens:activity.emptySubtitle')}</Text>
</View>
)}
</ScrollView>
)
}
function StatCard({ icon, value, label, color }: { icon: any; value: number; label: string; color: string }) {
return (
<View style={cardStyles.card}>
<Icon name={icon} size={22} tintColor={color} />
<Text selectable style={cardStyles.value}>{value}</Text>
<Text style={cardStyles.label}>{label}</Text>
</View>
)
}
const cardStyles = StyleSheet.create({
card: {
flex: 1,
alignItems: 'center',
padding: SPACING[3],
borderRadius: RADIUS.LG,
backgroundColor: NAVY[800],
borderWidth: 1,
borderColor: BORDER_COLORS.DIM,
gap: SPACING[1],
borderCurve: 'continuous',
},
value: { ...TYPOGRAPHY.TITLE_2, color: TEXT.PRIMARY, fontVariant: ['tabular-nums'] },
label: { ...TYPOGRAPHY.CAPTION_2, color: TEXT.TERTIARY, textAlign: 'center' },
})
function createStyles(colors: ThemeColors) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bg.base },
content: { paddingHorizontal: LAYOUT.SCREEN_PADDING },
title: { ...TYPOGRAPHY.LARGE_TITLE, color: TEXT.PRIMARY, marginBottom: SPACING[5] },
streakHero: {
alignItems: 'center',
paddingVertical: SPACING[6],
marginBottom: SPACING[4],
backgroundColor: NAVY[800],
borderRadius: RADIUS.XL,
borderWidth: 1,
borderColor: BORDER_COLORS.DIM,
gap: SPACING[1],
},
streakCount: { ...TYPOGRAPHY.LARGE_TITLE, color: TEXT.PRIMARY, fontSize: 56, fontVariant: ['tabular-nums'] },
streakLabel: { ...TYPOGRAPHY.HEADLINE, color: TEXT.SECONDARY },
streakRecord: { ...TYPOGRAPHY.CAPTION_1, color: TEXT.TERTIARY, marginTop: SPACING[1] },
grid: { flexDirection: 'row', gap: SPACING[3], marginBottom: SPACING[6] },
historySection: { gap: SPACING[2] },
sectionTitle: {
...TYPOGRAPHY.CAPTION_1,
color: TEXT.TERTIARY,
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: SPACING[1],
},
historyRow: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING[3],
padding: SPACING[3],
backgroundColor: colors.surface.default.backgroundColor,
borderRadius: RADIUS.MD,
borderWidth: 1,
borderColor: colors.surface.default.borderColor,
},
historyTitle: { ...TYPOGRAPHY.SUBHEADLINE, color: TEXT.PRIMARY },
historyMeta: { ...TYPOGRAPHY.CAPTION_1, color: TEXT.TERTIARY, marginTop: 2 },
emptyState: { alignItems: 'center', marginTop: SPACING[12], gap: SPACING[2] },
emptyTitle: { ...TYPOGRAPHY.HEADLINE, color: TEXT.PRIMARY },
emptySubtitle: { ...TYPOGRAPHY.BODY, color: TEXT.TERTIARY, textAlign: 'center' },
})
}

View File

@@ -1,212 +0,0 @@
/**
* TabataGo Home Screen
* Mascot + 3 stat pills + 3 body zone cards + settings button.
*/
import { View, Text, StyleSheet, ScrollView, Pressable } from 'react-native'
import { useRouter } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useTranslation } from 'react-i18next'
import { Icon } from '@/src/shared/components/Icon'
import { Mascot } from '@/src/shared/components/Mascot'
import { useUserStore } from '@/src/shared/stores/userStore'
import { useProgressStore } from '@/src/shared/stores/progressStore'
import { BODY_ZONE_META, type BodyZone } from '@/src/shared/types/workoutProgram'
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
import { SPACING } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { TEXT, NAVY, GREEN, BORDER_COLORS } from '@/src/shared/constants/colors'
import { withOpacity } from '@/src/shared/utils/color'
const BODY_ZONES: BodyZone[] = ['upper-body', 'lower-body', 'full-body']
export default function HomeScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
const { t } = useTranslation()
const firstName = useUserStore(s => s.profile.name)
const streak = useProgressStore(s => s.streak.current)
const weeklyCount = useProgressStore(s => s.getWeeklyCount())
const completedCount = useProgressStore(s => s.getCompletedCount())
const nameSuffix = firstName ? `, ${firstName}` : ''
const mascotMessage = streak > 0
? t('screens:home.mascotStreak', { count: streak, name: nameSuffix })
: t('screens:home.mascotReady', { name: nameSuffix })
return (
<ScrollView
style={styles.container}
contentContainerStyle={[styles.content, { paddingTop: insets.top + SPACING[4], paddingBottom: insets.bottom + SPACING[6] }]}
>
{/* Header with settings */}
<View style={styles.header}>
<Text style={styles.brand}>TabataGo</Text>
<Pressable onPress={() => router.push('/settings')} style={styles.iconBtn} hitSlop={8}>
<Icon name="gearshape" size={22} tintColor={TEXT.PRIMARY} />
</Pressable>
</View>
{/* Mascot */}
<View style={styles.mascotWrap}>
<Mascot message={mascotMessage} />
</View>
{/* Stats pills */}
<View style={styles.statsRow}>
<StatPill value={streak} label={t('screens:home.statsStreak')} icon="flame.fill" color="#FF6B35" />
<StatPill value={weeklyCount} label={t('screens:home.statsThisWeek')} icon="calendar" color={GREEN[500]} />
<StatPill value={completedCount} label={t('screens:home.statsCompleted')} icon="checkmark.seal.fill" color="#5AC8FA" />
</View>
{/* Body zone cards */}
<Text style={styles.sectionTitle}>{t('screens:zone.chooseYourFocus')}</Text>
<View style={styles.zoneList}>
{BODY_ZONES.map(zone => (
<ZoneCard key={zone} zone={zone} onPress={() => router.push(`/zone/${zone}`)} />
))}
</View>
</ScrollView>
)
}
function StatPill({
value,
label,
icon,
color,
}: {
value: number
label: string
icon: any
color: string
}) {
return (
<View style={[styles.pill, { borderColor: withOpacity(color, 0.4) }]}>
<Icon name={icon} size={16} tintColor={color} />
<Text style={styles.pillValue}>{value}</Text>
<Text style={styles.pillLabel}>{label}</Text>
</View>
)
}
function ZoneCard({ zone, onPress }: { zone: BodyZone; onPress: () => void }) {
const meta = BODY_ZONE_META[zone]
const { t } = useTranslation()
return (
<Pressable
onPress={onPress}
style={({ pressed }) => [
styles.zoneCard,
{ borderColor: withOpacity(meta.color, 0.3) },
pressed && { opacity: 0.85, transform: [{ scale: 0.98 }] },
]}
>
{/* Colored top strip with large icon */}
<View style={[styles.zoneTopStrip, { backgroundColor: withOpacity(meta.color, 0.12) }]}>
<View style={[styles.zoneIconCircle, { backgroundColor: withOpacity(meta.color, 0.22) }]}>
<Icon name={meta.icon as any} size={34} tintColor={meta.color} />
</View>
</View>
{/* Content area */}
<View style={styles.zoneContent}>
<Text style={styles.zoneTitle}>{meta.label}</Text>
<Text style={styles.zoneDesc} numberOfLines={2}>
{t(meta.descKey)}
</Text>
{/* Bottom row: level badge + chevron */}
<View style={styles.zoneFooter}>
<View style={[styles.zoneBadge, { backgroundColor: withOpacity(meta.color, 0.15) }]}>
<Text style={[styles.zoneBadgeText, { color: meta.color }]}>
{t('screens:home.zoneLevels')}
</Text>
</View>
<Icon name="chevron.right" size={16} tintColor={TEXT.TERTIARY} />
</View>
</View>
</Pressable>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: NAVY[900] },
content: { paddingHorizontal: SPACING[5] },
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: SPACING[4],
},
brand: { ...TYPOGRAPHY.TITLE_1, color: TEXT.PRIMARY, letterSpacing: -0.5 },
iconBtn: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: NAVY[800],
borderWidth: 1,
borderColor: BORDER_COLORS.DIM,
},
mascotWrap: { alignItems: 'center', marginVertical: SPACING[4] },
statsRow: { flexDirection: 'row', gap: SPACING[2], marginBottom: SPACING[6] },
pill: {
flex: 1,
alignItems: 'center',
paddingVertical: SPACING[3],
paddingHorizontal: SPACING[2],
borderRadius: RADIUS.MD,
borderWidth: 1,
backgroundColor: NAVY[800],
gap: 4,
},
pillValue: { ...TYPOGRAPHY.TITLE_2, color: TEXT.PRIMARY, fontVariant: ['tabular-nums'] },
pillLabel: { ...TYPOGRAPHY.CAPTION_2, color: TEXT.TERTIARY, textAlign: 'center' },
sectionTitle: { ...TYPOGRAPHY.HEADLINE, color: TEXT.PRIMARY, marginBottom: SPACING[3] },
zoneList: { gap: SPACING[4] },
zoneCard: {
borderRadius: RADIUS.XL,
borderWidth: 1,
backgroundColor: NAVY[800],
overflow: 'hidden' as const,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.3)',
},
zoneTopStrip: {
alignItems: 'center' as const,
justifyContent: 'center' as const,
paddingVertical: SPACING[5],
},
zoneIconCircle: {
width: 72,
height: 72,
borderRadius: 36,
alignItems: 'center' as const,
justifyContent: 'center' as const,
},
zoneContent: {
padding: SPACING[4],
gap: SPACING[2],
},
zoneTitle: { ...TYPOGRAPHY.TITLE_2, color: TEXT.PRIMARY },
zoneDesc: { ...TYPOGRAPHY.SUBHEADLINE, color: TEXT.SECONDARY, lineHeight: 20 },
zoneFooter: {
flexDirection: 'row' as const,
alignItems: 'center' as const,
justifyContent: 'space-between' as const,
marginTop: SPACING[1],
},
zoneBadge: {
paddingHorizontal: SPACING[3],
paddingVertical: SPACING[1],
borderRadius: RADIUS.SM,
},
zoneBadgeText: { ...TYPOGRAPHY.CAPTION_1, fontWeight: '600' as const },
})

View File

@@ -1,181 +0,0 @@
/**
* TabataGo Profile Tab
* User info, subscription status, quick stats. Settings via form sheet.
*/
import { useMemo } from 'react'
import { View, Text, StyleSheet, ScrollView, Pressable } from 'react-native'
import { useRouter } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useTranslation } from 'react-i18next'
import { Icon } from '@/src/shared/components/Icon'
import { useUserStore } from '@/src/shared/stores/userStore'
import { useProgressStore } from '@/src/shared/stores/progressStore'
import { usePurchases } from '@/src/shared/hooks'
import { useThemeColors } from '@/src/shared/theme'
import type { ThemeColors } from '@/src/shared/theme/types'
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { GREEN, NAVY, TEXT, BORDER_COLORS } from '@/src/shared/constants/colors'
export default function ProfileScreen() {
const { t } = useTranslation()
const router = useRouter()
const insets = useSafeAreaInsets()
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
const profile = useUserStore(s => s.profile)
const { isPremium } = usePurchases()
const completedCount = useProgressStore(s => s.getCompletedCount())
const streak = useProgressStore(s => s.streak)
const weeklyCount = useProgressStore(s => s.getWeeklyCount())
const avatarLetter = profile.name?.[0]?.toUpperCase() || '?'
return (
<ScrollView
style={styles.container}
contentContainerStyle={[styles.content, { paddingTop: insets.top + SPACING[4], paddingBottom: insets.bottom + SPACING[6] }]}
contentInsetAdjustmentBehavior="automatic"
>
{/* Avatar + name */}
<View style={styles.profileHeader}>
<View style={styles.avatar}>
<Text style={styles.avatarLetter}>{avatarLetter}</Text>
</View>
<View style={{ flex: 1 }}>
<Text style={styles.name}>{profile.name || t('screens:profile.guest')}</Text>
<View style={[styles.planBadge, { borderColor: isPremium ? GREEN[500] : BORDER_COLORS.DIM }]}>
<Text style={[styles.planText, { color: isPremium ? GREEN[500] : TEXT.TERTIARY }]}>
{isPremium ? t('screens:settings.premium') : t('screens:settings.free')}
</Text>
</View>
</View>
<Pressable onPress={() => router.push('/settings')} hitSlop={8}>
<Icon name="gearshape.fill" size={22} tintColor={TEXT.TERTIARY} />
</Pressable>
</View>
{/* Stats row */}
<View style={styles.statsRow}>
<StatPill value={streak.current} label={t('screens:home.statsStreak')} icon="flame.fill" color="#FF6B35" />
<StatPill value={weeklyCount} label={t('screens:activity.thisWeek')} icon="calendar" color="#5AC8FA" />
<StatPill value={completedCount} label={t('screens:home.statsCompleted')} icon="checkmark.seal.fill" color={GREEN[500]} />
</View>
{/* Upgrade banner (free users) */}
{!isPremium && (
<Pressable
style={[styles.upgradeBanner, { borderColor: GREEN[500] }]}
onPress={() => router.push('/paywall')}
>
<Icon name="sparkles" size={20} tintColor={GREEN[500]} />
<View style={{ flex: 1 }}>
<Text style={styles.upgradeTitle}>{t('screens:profile.upgradeTitle')}</Text>
<Text style={styles.upgradeDesc}>{t('screens:profile.upgradeDescription')}</Text>
</View>
<Icon name="chevron.right" size={16} tintColor={TEXT.TERTIARY} />
</Pressable>
)}
{/* Settings link */}
<Pressable style={styles.settingsRow} onPress={() => router.push('/settings')}>
<Icon name="gearshape" size={20} tintColor={TEXT.SECONDARY} />
<Text style={styles.settingsLabel}>{t('screens:settings.title')}</Text>
<Icon name="chevron.right" size={16} tintColor={TEXT.TERTIARY} />
</Pressable>
</ScrollView>
)
}
function StatPill({ value, label, icon, color }: { value: number; label: string; icon: any; color: string }) {
return (
<View style={pillStyles.pill}>
<Icon name={icon} size={18} tintColor={color} />
<Text selectable style={pillStyles.value}>{value}</Text>
<Text style={pillStyles.label}>{label}</Text>
</View>
)
}
const pillStyles = StyleSheet.create({
pill: {
flex: 1,
alignItems: 'center',
paddingVertical: SPACING[3],
borderRadius: RADIUS.MD,
borderWidth: 1,
backgroundColor: NAVY[800],
borderColor: BORDER_COLORS.DIM,
gap: 4,
},
value: { ...TYPOGRAPHY.TITLE_2, color: TEXT.PRIMARY, fontVariant: ['tabular-nums'] },
label: { ...TYPOGRAPHY.CAPTION_2, color: TEXT.TERTIARY, textAlign: 'center' },
})
function createStyles(colors: ThemeColors) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bg.base },
content: { paddingHorizontal: LAYOUT.SCREEN_PADDING },
profileHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING[3],
marginBottom: SPACING[5],
},
avatar: {
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: NAVY[700] ?? NAVY[800],
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2,
borderColor: BORDER_COLORS.DIM,
},
avatarLetter: { ...TYPOGRAPHY.TITLE_1, color: TEXT.PRIMARY },
name: { ...TYPOGRAPHY.TITLE_2, color: TEXT.PRIMARY },
planBadge: {
alignSelf: 'flex-start',
marginTop: 4,
paddingHorizontal: SPACING[2],
paddingVertical: 2,
borderRadius: RADIUS.SM,
borderWidth: 1,
},
planText: { ...TYPOGRAPHY.CAPTION_2, fontWeight: '600' },
statsRow: { flexDirection: 'row', gap: SPACING[2], marginBottom: SPACING[5] },
upgradeBanner: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING[3],
padding: SPACING[4],
borderRadius: RADIUS.LG,
borderWidth: 1,
backgroundColor: colors.surface.default.backgroundColor,
marginBottom: SPACING[3],
borderCurve: 'continuous',
},
upgradeTitle: { ...TYPOGRAPHY.HEADLINE, color: TEXT.PRIMARY },
upgradeDesc: { ...TYPOGRAPHY.CAPTION_1, color: TEXT.SECONDARY, marginTop: 2 },
settingsRow: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING[3],
padding: SPACING[4],
borderRadius: RADIUS.LG,
borderWidth: 1,
borderColor: BORDER_COLORS.DIM,
backgroundColor: colors.surface.default.backgroundColor,
borderCurve: 'continuous',
},
settingsLabel: { ...TYPOGRAPHY.BODY, color: TEXT.PRIMARY, flex: 1 },
})
}

View File

@@ -1,70 +0,0 @@
<claude-mem-context>
# Recent Activity
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
### Feb 19, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #5001 | 9:35 AM | 🔵 | Host Wrapper Located at Root Layout Level | ~153 |
### Feb 20, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #5115 | 8:57 AM | 🔵 | Root Layout Stack Configuration with Screen Animations | ~256 |
| #5061 | 8:47 AM | 🔵 | Expo Router Tab Navigation Structure Found | ~196 |
| #5053 | 8:23 AM | ✅ | Completed removal of all Host wrappers from application | ~255 |
| #5052 | " | ✅ | Removed Host wrapper from root layout entirely | ~224 |
| #5019 | 8:13 AM | 🔵 | Root layout properly wraps Stack with Host component | ~198 |
### Feb 28, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #5598 | 9:22 PM | 🟣 | Enabled PostHog analytics in development mode | ~253 |
| #5597 | " | 🔄 | PostHogProvider initialization updated with client check and autocapture config | ~303 |
| #5589 | 7:51 PM | 🟣 | PostHog screen tracking added to onboarding flow | ~246 |
| #5588 | 7:50 PM | ✅ | Added trackScreen function to onboarding analytics imports | ~203 |
| #5585 | " | ✅ | Enhanced PostHogProvider initialization with null safety | ~239 |
| #5584 | 7:49 PM | ✅ | Imported trackScreen function in root layout | ~202 |
| #5583 | " | 🟣 | PostHog user identification added to onboarding completion | ~291 |
| #5582 | " | ✅ | Enhanced onboarding analytics with user identification | ~187 |
| #5579 | 7:47 PM | 🔵 | Comprehensive analytics tracking in onboarding flow | ~345 |
| #5575 | 7:44 PM | 🔵 | PostHog integration architecture in root layout | ~279 |
| #5572 | 7:43 PM | 🔵 | PostHog integration points identified | ~228 |
### Apr 10, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #6017 | 10:06 AM | 🔵 | Explore Filter Sheet for Level and Equipment | ~307 |
| #6000 | 10:01 AM | 🔵 | Root App Architecture Examined | ~316 |
### Apr 11, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #6129 | 7:42 PM | 🔄 | Onboarding Wow icon circle opacity refactored | ~295 |
| #6126 | 7:41 PM | 🔵 | Assessment screen imports reviewed | ~293 |
| #6122 | " | 🔵 | Onboarding screen uses dynamic color with hex transparency | ~277 |
### Apr 13, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #6175 | 10:04 PM | 🟣 | Completed Explore Tab Removal | ~196 |
| #6170 | 10:03 PM | 🟣 | Removed Explore Filters Modal Route | ~143 |
| #6165 | 10:02 PM | 🔵 | Located Explore Filters Screen Configuration | ~141 |
| #6160 | " | 🔵 | Identified Explore Filters Screen Configuration | ~141 |
| #6156 | " | 🔵 | Found Explore Tab References | ~155 |
### Apr 17, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #6373 | 10:26 AM | 🟣 | Registered body zone detail screen route in app navigation | ~296 |
| #6372 | 10:25 AM | 🔵 | Reviewed app layout navigation configuration for workout and program screens | ~318 |
| #6371 | " | 🔵 | Examined app routing layout structure for workout routes | ~247 |
</claude-mem-context>

View File

@@ -1,260 +0,0 @@
/**
* TabataFit Root Layout
* Expo Router v3 — SF Pro system font (no custom font loading)
* Waits for store hydration before rendering
*/
import '@/src/shared/i18n'
import '@/src/shared/i18n/types'
import { useState, useEffect, useCallback, Component } from 'react'
import { Stack } from 'expo-router'
import { StatusBar } from 'expo-status-bar'
import { View, Text, Pressable, StyleSheet } from 'react-native'
import * as SplashScreen from 'expo-splash-screen'
import * as Notifications from 'expo-notifications'
import { PostHogProvider } from 'posthog-react-native'
import { ThemeProvider, useThemeColors } from '@/src/shared/theme'
import { TEXT, NAVY, GREEN } from '@/src/shared/constants/colors'
import { useUserStore } from '@/src/shared/stores'
import { useNotifications } from '@/src/shared/hooks'
import { OfflineBanner } from '@/src/shared/components/OfflineBanner'
import { initializePurchases } from '@/src/shared/services/purchases'
import { initializeAnalytics, getPostHogClient, trackScreen } from '@/src/shared/services/analytics'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: false,
shouldPlaySound: false,
shouldSetBadge: false,
shouldShowBanner: false,
shouldShowList: false,
}),
})
SplashScreen.preventAutoHideAsync()
// ─── Error Boundary (F-108) ────────────────────────────────────────────────
interface ErrorBoundaryState {
hasError: boolean
error: Error | null
}
class ErrorBoundary extends Component<{ children: React.ReactNode }, ErrorBoundaryState> {
state: ErrorBoundaryState = { hasError: false, error: null }
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error }
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error('ErrorBoundary caught:', error, info.componentStack)
}
private handleRetry = () => {
this.setState({ hasError: false, error: null })
}
render() {
if (this.state.hasError) {
return (
<View style={errorStyles.container}>
<Text style={errorStyles.emoji}></Text>
<Text style={errorStyles.title}>Something went wrong</Text>
<Text style={errorStyles.message}>
{this.state.error?.message ?? 'An unexpected error occurred.'}
</Text>
<Pressable style={errorStyles.button} onPress={this.handleRetry}>
<Text style={errorStyles.buttonText}>Try again</Text>
</Pressable>
</View>
)
}
return this.props.children
}
}
const errorStyles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: NAVY[900],
alignItems: 'center',
justifyContent: 'center',
padding: 32,
},
emoji: { fontSize: 48, marginBottom: 16 },
title: {
fontSize: 22,
fontWeight: '700',
color: TEXT.PRIMARY,
marginBottom: 8,
},
message: {
fontSize: 15,
fontWeight: '400',
color: TEXT.SECONDARY,
textAlign: 'center',
marginBottom: 24,
lineHeight: 20,
},
button: {
backgroundColor: '#00C896',
paddingHorizontal: 32,
paddingVertical: 14,
borderRadius: 12,
borderCurve: 'continuous',
minHeight: 44,
},
buttonText: {
fontSize: 17,
fontWeight: '600',
color: NAVY[900],
},
})
// Create React Query Client
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 60 * 24, // 24 hours
retry: 2,
refetchOnWindowFocus: false,
},
},
})
function RootLayoutInner() {
const colors = useThemeColors()
useNotifications()
// Wait for persisted store to hydrate from AsyncStorage
const [hydrated, setHydrated] = useState(useUserStore.persist.hasHydrated())
useEffect(() => {
const unsub = useUserStore.persist.onFinishHydration(() => setHydrated(true))
return unsub
}, [])
// Initialize RevenueCat + PostHog after hydration
useEffect(() => {
if (hydrated) {
initializePurchases().catch((err) => {
console.error('Failed to initialize RevenueCat:', err)
})
initializeAnalytics().catch((err) => {
console.error('Failed to initialize PostHog:', err)
})
}
}, [hydrated])
const onLayoutRootView = useCallback(async () => {
if (hydrated) {
await SplashScreen.hideAsync()
}
}, [hydrated])
if (!hydrated) {
return null
}
const content = (
<QueryClientProvider client={queryClient}>
<View style={{ flex: 1, backgroundColor: colors.bg.base }} onLayout={onLayoutRootView}>
<StatusBar style={colors.statusBarStyle} />
<OfflineBanner />
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.bg.base },
animation: 'default',
headerBackButtonDisplayMode: 'minimal',
headerTintColor: GREEN[500],
headerStyle: { backgroundColor: colors.bg.base },
headerShadowVisible: false,
headerTitleStyle: { fontWeight: '600', fontSize: 17, color: colors.text.primary },
}}
>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="onboarding"
options={{ animation: 'fade' }}
/>
<Stack.Screen
name="zone/[bodyZone]"
options={{ headerShown: true, headerTitle: '' }}
/>
<Stack.Screen
name="program/[id]"
options={{ headerShown: true, headerTitle: '' }}
/>
<Stack.Screen
name="player/[id]"
options={{ presentation: 'fullScreenModal', animation: 'fade' }}
/>
<Stack.Screen
name="complete/[id]"
options={{ animation: 'fade' }}
/>
<Stack.Screen
name="settings"
options={{
presentation: 'formSheet',
sheetGrabberVisible: true,
sheetAllowedDetents: [0.75, 1.0],
}}
/>
<Stack.Screen
name="terms"
options={{ headerShown: true, headerTitle: '' }}
/>
<Stack.Screen
name="privacy"
options={{ headerShown: true, headerTitle: '' }}
/>
<Stack.Screen
name="paywall"
options={{
presentation: 'formSheet',
sheetGrabberVisible: true,
sheetAllowedDetents: [0.85, 1.0],
}}
/>
</Stack>
</View>
</QueryClientProvider>
)
const posthogClient = getPostHogClient()
// Only wrap with PostHogProvider if client is initialized
if (!posthogClient) {
return content
}
return (
<PostHogProvider
client={posthogClient}
autocapture={{
captureScreens: true,
captureTouches: true,
}}
>
{content}
</PostHogProvider>
)
}
export default function RootLayout() {
return (
<ErrorBoundary>
<ThemeProvider>
<RootLayoutInner />
</ThemeProvider>
</ErrorBoundary>
)
}

View File

@@ -1,14 +0,0 @@
<claude-mem-context>
# Recent Activity
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
### Feb 20, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #5047 | 8:22 AM | ✅ | Completed Host wrapper removal from all screens | ~241 |
| #5046 | " | ✅ | Removed opening Host tag from workout complete screen | ~165 |
| #5033 | 8:20 AM | ✅ | Removed Host import from workout complete screen | ~212 |
| #5026 | 8:18 AM | 🔵 | Workout complete screen properly wraps content with Host component | ~252 |
</claude-mem-context>

View File

@@ -1,211 +0,0 @@
/**
* TabataFit Workout Complete Screen
* Celebration + stats driven by progressStore.
*/
import { useEffect, useMemo, useRef } from 'react'
import {
View,
Text as RNText,
StyleSheet,
ScrollView,
Animated,
} from 'react-native'
import { useRouter, useLocalSearchParams } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { Icon, type IconName } from '@/src/shared/components/Icon'
import { useTranslation } from 'react-i18next'
import * as Sharing from 'expo-sharing'
import { useHaptics } from '@/src/shared/hooks'
import { useProgressStore } from '@/src/shared/stores'
import { NativeButton } from '@/src/shared/components/native'
import { useThemeColors } from '@/src/shared/theme'
import type { ThemeColors } from '@/src/shared/theme/types'
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { SPRING } from '@/src/shared/constants/animations'
import { GREEN, NAVY, TEXT, BORDER_COLORS } from '@/src/shared/constants/colors'
function StatCard({
value,
label,
icon,
delay = 0,
}: {
value: string | number
label: string
icon: IconName
delay?: number
}) {
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
const scaleAnim = useRef(new Animated.Value(0)).current
useEffect(() => {
Animated.sequence([
Animated.delay(delay),
Animated.spring(scaleAnim, { toValue: 1, ...SPRING.BOUNCY, useNativeDriver: true }),
]).start()
}, [delay])
return (
<Animated.View style={[styles.statCard, { transform: [{ scale: scaleAnim }] }]}>
<Icon name={icon} size={24} tintColor={GREEN['500']} />
<RNText selectable style={styles.statValue}>{value}</RNText>
<RNText style={styles.statLabel}>{label}</RNText>
</Animated.View>
)
}
export default function WorkoutCompleteScreen() {
const insets = useSafeAreaInsets()
const router = useRouter()
const haptics = useHaptics()
const { t } = useTranslation()
const { id } = useLocalSearchParams<{ id: string }>()
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
const history = useProgressStore((s) => s.history)
const streak = useProgressStore((s) => s.streak)
const weeklyCount = useProgressStore((s) => s.getWeeklyCount())
// Latest session (the one we just completed)
const latest = history[0]
const resultMinutes = latest ? Math.round(latest.durationSeconds / 60) : 0
const handleGoHome = () => {
haptics.buttonTap()
router.replace('/')
}
const handleShare = async () => {
haptics.selection()
const isAvailable = await Sharing.isAvailableAsync()
if (isAvailable) {
await Sharing.shareAsync('https://tabatafit.app', {
dialogTitle: t('screens:complete.shareTitle', { minutes: resultMinutes }),
})
}
}
useEffect(() => {
haptics.workoutComplete()
}, [])
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
<ScrollView
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 120 }]}
contentInsetAdjustmentBehavior="automatic"
showsVerticalScrollIndicator={false}
>
{/* Celebration */}
<View style={styles.celebrationSection}>
<RNText style={styles.celebrationEmoji}>🎉</RNText>
<RNText style={styles.celebrationTitle}>{t('screens:complete.title')}</RNText>
</View>
{/* Stats Grid */}
<View style={styles.statsGrid}>
<StatCard value={resultMinutes} label={t('screens:complete.minutesLabel')} icon="clock.fill" delay={100} />
<StatCard value={streak.current} label={t('screens:home.statsStreak')} icon="flame.fill" delay={200} />
<StatCard value={weeklyCount} label={t('screens:complete.thisWeek')} icon="calendar" delay={300} />
</View>
<View style={styles.divider} />
{/* Streak */}
<View style={styles.streakSection}>
<View style={[styles.streakBadge, { backgroundColor: GREEN.DIM }]}>
<Icon name="flame.fill" size={32} tintColor={GREEN['500']} />
</View>
<View style={styles.streakInfo}>
<RNText selectable style={styles.streakTitle}>
{t('screens:complete.streakDays', { count: streak.current })}
</RNText>
<RNText style={styles.streakSubtitle}>
{t('screens:complete.streakRecord', { count: streak.longest })}
</RNText>
</View>
</View>
<View style={styles.divider} />
{/* Share */}
<View style={styles.shareSection}>
<NativeButton
variant="ghost"
title={t('screens:complete.share')}
systemImage="square.and.arrow.up"
onPress={handleShare}
fullWidth
/>
</View>
</ScrollView>
{/* Fixed Bottom Button */}
<View style={[styles.bottomBar, { paddingBottom: insets.bottom + SPACING[4] }]}>
<View style={styles.homeButtonContainer}>
<NativeButton
variant="primary"
title={t('screens:complete.backToHome')}
onPress={handleGoHome}
fullWidth
controlSize="large"
/>
</View>
</View>
</View>
)
}
function createStyles(colors: ThemeColors) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bg.base },
scrollContent: { paddingHorizontal: LAYOUT.SCREEN_PADDING },
celebrationSection: { alignItems: 'center', paddingVertical: SPACING[8] },
celebrationEmoji: { fontSize: 64, marginBottom: SPACING[4] },
celebrationTitle: { ...TYPOGRAPHY.TITLE_1, color: TEXT.PRIMARY, letterSpacing: 1 },
statsGrid: { flexDirection: 'row', gap: SPACING[3], marginBottom: SPACING[6] },
statCard: {
flex: 1,
padding: SPACING[3],
borderRadius: RADIUS.LG,
backgroundColor: colors.surface.default.backgroundColor,
alignItems: 'center',
borderWidth: 1,
borderColor: colors.surface.default.borderColor,
borderCurve: 'continuous',
},
statValue: { ...TYPOGRAPHY.TITLE_1, color: TEXT.PRIMARY, marginTop: SPACING[2], fontVariant: ['tabular-nums'] },
statLabel: { ...TYPOGRAPHY.CAPTION_2, color: TEXT.TERTIARY, marginTop: SPACING[1] },
divider: { height: 1, backgroundColor: BORDER_COLORS.DIM, marginVertical: SPACING[2] },
streakSection: { flexDirection: 'row', alignItems: 'center', paddingVertical: SPACING[4], gap: SPACING[4] },
streakBadge: { width: 64, height: 64, borderRadius: RADIUS.FULL, alignItems: 'center', justifyContent: 'center' },
streakInfo: { flex: 1 },
streakTitle: { ...TYPOGRAPHY.TITLE_2, color: TEXT.PRIMARY },
streakSubtitle: { ...TYPOGRAPHY.BODY, color: TEXT.TERTIARY, marginTop: SPACING[1] },
shareSection: { paddingVertical: SPACING[4], alignItems: 'center' },
bottomBar: {
position: 'absolute',
bottom: 0, left: 0, right: 0,
paddingHorizontal: LAYOUT.SCREEN_PADDING,
paddingTop: SPACING[4],
backgroundColor: colors.bg.base,
borderTopWidth: 1,
borderTopColor: BORDER_COLORS.DIM,
},
homeButtonContainer: { height: 56, justifyContent: 'center' },
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,460 +0,0 @@
/**
* TabataFit Paywall Screen
* Premium subscription purchase flow
*/
import React, { useMemo } from 'react'
import {
View,
StyleSheet,
ScrollView,
Pressable,
} from 'react-native'
import { useRouter } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { Icon, type IconName } from '@/src/shared/components/Icon'
import { useTranslation } from 'react-i18next'
import { useHaptics, usePurchases } from '@/src/shared/hooks'
import { StyledText } from '@/src/shared/components/StyledText'
import { useThemeColors } from '@/src/shared/theme'
import { NativeButton } from '@/src/shared/components/native'
import type { ThemeColors } from '@/src/shared/theme/types'
import { SPACING } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { GREEN, NAVY, BORDER_COLORS } from '@/src/shared/constants/colors'
// ═══════════════════════════════════════════════════════════════════════════
// FEATURES LIST
// ═══════════════════════════════════════════════════════════════════════════
const PREMIUM_FEATURES: { icon: IconName; key: string }[] = [
{ icon: 'music.note.list', key: 'music' },
{ icon: 'infinity', key: 'workouts' },
{ icon: 'chart.bar.fill', key: 'stats' },
{ icon: 'flame.fill', key: 'calories' },
{ icon: 'bell.fill', key: 'reminders' },
{ icon: 'xmark.circle.fill', key: 'ads' },
]
// ═══════════════════════════════════════════════════════════════════════════
// COMPONENTS
// ═══════════════════════════════════════════════════════════════════════════
interface PlanCardStyles {
planCard: object
planCardPressed: object
savingsBadge: object
savingsText: object
planInfo: object
planTitle: object
planPeriod: object
planPrice: object
checkmark: object
}
function PlanCard({
title,
price,
period,
savings,
isSelected,
onPress,
colors,
styles,
}: {
title: string
price: string
period: string
savings?: string
isSelected: boolean
onPress: () => void
colors: ThemeColors
styles: PlanCardStyles
}) {
const haptics = useHaptics()
const handlePress = () => {
haptics.selection()
onPress()
}
return (
<Pressable
onPress={handlePress}
style={({ pressed }) => [
styles.planCard,
isSelected && { borderColor: GREEN.BORDER },
pressed && styles.planCardPressed,
{
backgroundColor: colors.bg.surface,
borderColor: isSelected ? GREEN.BORDER : BORDER_COLORS.DIM,
},
]}
>
{savings && (
<View style={styles.savingsBadge}>
<StyledText size={10} weight="bold" color={NAVY[900]}>{savings}</StyledText>
</View>
)}
<View style={styles.planInfo}>
<StyledText size={16} weight="semibold" color={colors.text.primary}>
{title}
</StyledText>
<StyledText size={13} color={colors.text.tertiary} style={{ marginTop: 2 }}>
{period}
</StyledText>
</View>
<StyledText size={20} weight="bold" color={GREEN[500]}>
{price}
</StyledText>
{isSelected && (
<View style={styles.checkmark}>
<Icon name="checkmark.circle.fill" size={24} color={GREEN[500]} />
</View>
)}
</Pressable>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// MAIN SCREEN
// ═══════════════════════════════════════════════════════════════════════════
export default function PaywallScreen() {
const { t } = useTranslation('screens')
const router = useRouter()
const insets = useSafeAreaInsets()
const haptics = useHaptics()
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
// Extract plan card styles for the child component
const planCardStyles = useMemo(
() => ({
planCard: styles.planCard,
planCardPressed: styles.planCardPressed,
savingsBadge: styles.savingsBadge,
savingsText: styles.savingsText,
planInfo: styles.planInfo,
planTitle: styles.planTitle,
planPeriod: styles.planPeriod,
planPrice: styles.planPrice,
checkmark: styles.checkmark,
}),
[styles],
)
const {
monthlyPackage,
annualPackage,
purchasePackage,
restorePurchases,
isLoading,
} = usePurchases()
const [selectedPlan, setSelectedPlan] = React.useState<'monthly' | 'annual'>('annual')
// Get prices from RevenueCat packages
const monthlyPrice = monthlyPackage?.product.priceString ?? '$4.99'
const annualPrice = annualPackage?.product.priceString ?? '$29.99'
const annualMonthlyEquivalent = annualPackage
? (annualPackage.product.price / 12).toFixed(2)
: '2.49'
const handlePurchase = async () => {
haptics.buttonTap()
const pkg = selectedPlan === 'monthly' ? monthlyPackage : annualPackage
if (!pkg) {
console.log('[Paywall] No package available for purchase')
return
}
const result = await purchasePackage(pkg)
if (result.success) {
haptics.workoutComplete()
router.back()
} else if (!result.cancelled) {
console.log('[Paywall] Purchase error:', result.error)
}
}
const handleRestore = async () => {
haptics.selection()
const restored = await restorePurchases()
if (restored) {
haptics.workoutComplete()
router.back()
}
}
const handleClose = () => {
haptics.selection()
router.back()
}
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
{/* Close Button */}
<View style={[styles.closeButton, { top: insets.top + SPACING[2] }]}>
<NativeButton
variant="icon"
systemImage="xmark"
onPress={handleClose}
/>
</View>
<ScrollView
style={styles.scrollView}
contentContainerStyle={[
styles.scrollContent,
{ paddingBottom: insets.bottom + 100 },
]}
showsVerticalScrollIndicator={false}
>
{/* Header */}
<View style={styles.header}>
<StyledText size={32} weight="bold" color={colors.text.primary} style={{ textAlign: 'center' }}>
TabataFit+
</StyledText>
<StyledText size={16} color={colors.text.secondary} style={{ textAlign: 'center', marginTop: SPACING[2] }}>
{t('paywall.subtitle')}
</StyledText>
</View>
{/* Features Grid */}
<View style={styles.featuresGrid}>
{PREMIUM_FEATURES.map((feature) => (
<View key={feature.key} style={styles.featureItem}>
<View style={[styles.featureIcon, { backgroundColor: GREEN.DIM }]}>
<Icon name={feature.icon} size={22} color={GREEN[500]} />
</View>
<StyledText size={13} color={colors.text.secondary} style={{ textAlign: 'center' }}>
{t(`paywall.features.${feature.key}`)}
</StyledText>
</View>
))}
</View>
{/* Plan Selection */}
<View style={styles.plansContainer}>
<PlanCard
title={t('paywall.yearly')}
price={annualPrice}
period={t('paywall.perYear')}
savings={t('paywall.save50')}
isSelected={selectedPlan === 'annual'}
onPress={() => setSelectedPlan('annual')}
colors={colors}
styles={planCardStyles}
/>
<PlanCard
title={t('paywall.monthly')}
price={monthlyPrice}
period={t('paywall.perMonth')}
isSelected={selectedPlan === 'monthly'}
onPress={() => setSelectedPlan('monthly')}
colors={colors}
styles={planCardStyles}
/>
</View>
{/* Price Note */}
{selectedPlan === 'annual' && (
<StyledText size={13} color={colors.text.tertiary} style={{ textAlign: 'center', marginTop: SPACING[3] }}>
{t('paywall.equivalent', { price: annualMonthlyEquivalent })}
</StyledText>
)}
{/* CTA Button */}
<NativeButton
variant="primary"
title={isLoading ? t('paywall.processing') : t('paywall.trialCta')}
onPress={handlePurchase}
disabled={isLoading}
fullWidth
controlSize="large"
/>
{/* Restore & Terms */}
<View style={styles.footer}>
<NativeButton
variant="ghost"
title={t('paywall.restore')}
onPress={handleRestore}
/>
<View style={styles.legalLinks}>
<Pressable onPress={() => router.push('/terms')}>
<StyledText size={11} color={colors.text.tertiary} style={{ textDecorationLine: 'underline' }}>
{t('paywall.termsLink')}
</StyledText>
</Pressable>
<StyledText size={11} color={colors.text.tertiary}> · </StyledText>
<Pressable onPress={() => router.push('/terms')}>
<StyledText size={11} color={colors.text.tertiary} style={{ textDecorationLine: 'underline' }}>
{t('paywall.privacyLink')}
</StyledText>
</Pressable>
</View>
<StyledText size={11} color={colors.text.tertiary} style={{ textAlign: 'center', lineHeight: 18, paddingHorizontal: SPACING[4] }}>
{t('paywall.terms')}
</StyledText>
</View>
</ScrollView>
</View>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// STYLES
// ═══════════════════════════════════════════════════════════════════════════
function createStyles(colors: ThemeColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bg.base,
},
closeButton: {
position: 'absolute',
top: SPACING[4],
right: SPACING[4],
width: 44,
height: 44,
alignItems: 'center',
justifyContent: 'center',
zIndex: 10,
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingHorizontal: SPACING[5],
paddingTop: SPACING[8],
},
header: {
alignItems: 'center',
},
title: {
fontSize: 32,
fontWeight: '700',
color: colors.text.primary,
textAlign: 'center',
},
subtitle: {
fontSize: 16,
color: colors.text.secondary,
textAlign: 'center',
marginTop: SPACING[2],
},
featuresGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
marginTop: SPACING[6],
marginHorizontal: -SPACING[2],
},
featureItem: {
width: '33%',
alignItems: 'center',
paddingVertical: SPACING[3],
},
featureIcon: {
width: 48,
height: 48,
borderRadius: 24,
alignItems: 'center',
justifyContent: 'center',
marginBottom: SPACING[2],
},
featureText: {
fontSize: 13,
textAlign: 'center',
},
plansContainer: {
marginTop: SPACING[6],
gap: SPACING[3],
},
planCard: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: RADIUS.LG,
padding: SPACING[4],
borderWidth: 2,
},
planCardPressed: {
opacity: 0.8,
},
savingsBadge: {
position: 'absolute',
top: -8,
right: SPACING[3],
backgroundColor: GREEN[500],
paddingHorizontal: SPACING[2],
paddingVertical: 2,
borderRadius: RADIUS.SM,
},
savingsText: {
fontSize: 10,
fontWeight: '700',
color: colors.text.primary,
},
planInfo: {
flex: 1,
},
planTitle: {
fontSize: 16,
fontWeight: '600',
},
planPeriod: {
fontSize: 13,
marginTop: 2,
},
planPrice: {
fontSize: 20,
fontWeight: '700',
},
checkmark: {
marginLeft: SPACING[2],
},
priceNote: {
fontSize: 13,
textAlign: 'center',
marginTop: SPACING[3],
},
ctaButton: {
borderRadius: RADIUS.LG,
marginTop: SPACING[6],
paddingVertical: SPACING[4],
alignItems: 'center',
backgroundColor: GREEN[500],
},
ctaButtonDisabled: {
opacity: 0.6,
},
ctaText: {
fontSize: 17,
fontWeight: '600',
},
footer: {
marginTop: SPACING[5],
alignItems: 'center',
gap: SPACING[4],
},
restoreText: {
fontSize: 14,
},
termsText: {
fontSize: 11,
textAlign: 'center',
lineHeight: 18,
paddingHorizontal: SPACING[4],
},
legalLinks: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING[1],
},
})
}

View File

@@ -1,27 +0,0 @@
<claude-mem-context>
# Recent Activity
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
### Feb 19, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #5000 | 9:35 AM | 🔵 | Reviewed Player Screen Implementation | ~522 |
| #4912 | 8:16 AM | 🔵 | Found doneButton component in player screen | ~104 |
### Apr 9, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #5997 | 10:46 AM | 🟣 | Tabata Kine programs system fully implemented with four programs and specialized UI | ~563 |
| #5996 | 10:41 AM | 🟣 | Tabata Kine programs system implementation completed | ~460 |
| #5975 | 9:43 AM | 🟣 | Player screen updated to support kiné session detection and routing | ~316 |
### Apr 10, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #6005 | 10:02 AM | 🔵 | Player Screen Routing Between Kine and Legacy Workouts | ~335 |
| #5998 | 9:52 AM | 🟣 | Tabata Kine programs system implementation completed | ~711 |
</claude-mem-context>

View File

@@ -1,82 +0,0 @@
/**
* TabataFit Player Screen
* Loads a WorkoutProgram from Supabase and renders the Tabata player.
*/
import React from 'react'
import { View, Text } from 'react-native'
import { useLocalSearchParams } from 'expo-router'
import {
isWorkoutProgramId,
parseWorkoutProgramId,
fetchProgramById,
workoutProgramToTabataSession,
} from '@/src/shared/data/workoutPrograms'
import { TabataPlayerScreen } from '@/src/features/player/TabataPlayerScreen'
import type { TabataSession } from '@/src/shared/types/program'
import type { WorkoutProgram } from '@/src/shared/types/workoutProgram'
import { NAVY, TEXT } from '@/src/shared/constants/colors'
export default function PlayerScreen() {
const { id } = useLocalSearchParams<{ id: string }>()
const sessionId = id ?? ''
if (!isWorkoutProgramId(sessionId)) {
return <Message text="Programme invalide" />
}
return <WorkoutProgramPlayerScreen compositeId={sessionId} />
}
function WorkoutProgramPlayerScreen({ compositeId }: { compositeId: string }) {
const [state, setState] = React.useState<
| { status: 'loading' }
| { status: 'error' }
| { status: 'ready'; session: TabataSession; program: WorkoutProgram }
>({ status: 'loading' })
React.useEffect(() => {
let cancelled = false
async function load() {
const parsed = parseWorkoutProgramId(compositeId)
if (!parsed) {
if (!cancelled) setState({ status: 'error' })
return
}
const program = await fetchProgramById(parsed.programId)
if (cancelled) return
if (!program) {
setState({ status: 'error' })
return
}
setState({
status: 'ready',
session: workoutProgramToTabataSession(program),
program,
})
}
load()
return () => {
cancelled = true
}
}, [compositeId])
if (state.status === 'loading') return <Message text="Chargement..." />
if (state.status === 'error') return <Message text="Programme non trouvé" />
return <TabataPlayerScreen session={state.session} program={state.program} />
}
function Message({ text }: { text: string }) {
return (
<View
style={{
flex: 1,
backgroundColor: NAVY[900],
justifyContent: 'center',
alignItems: 'center',
}}
>
<Text style={{ color: TEXT.SECONDARY }}>{text}</Text>
</View>
)
}

View File

@@ -1,212 +0,0 @@
/**
* TabataFit Privacy Policy Screen
* Required for App Store submission
*/
import React from 'react'
import { View, ScrollView, StyleSheet, Text, Pressable } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { Icon } from '@/src/shared/components/Icon'
import { useTranslation } from 'react-i18next'
import { useHaptics } from '@/src/shared/hooks'
import { darkColors, BRAND } from '@/src/shared/theme'
import { SPACING } from '@/src/shared/constants/spacing'
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
export default function PrivacyPolicyScreen() {
const { t } = useTranslation('screens')
const router = useRouter()
const insets = useSafeAreaInsets()
const haptics = useHaptics()
const handleClose = () => {
haptics.selection()
router.back()
}
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
{/* Header */}
<View style={styles.header}>
<Pressable style={styles.backButton} onPress={handleClose}>
<Icon name="chevron.left" size={28} color={darkColors.text.primary} />
</Pressable>
<Text style={styles.headerTitle}>{t('privacy.title')}</Text>
<View style={{ width: 44 }} />
</View>
<ScrollView
style={styles.scrollView}
contentContainerStyle={[
styles.content,
{ paddingBottom: insets.bottom + 40 },
]}
showsVerticalScrollIndicator={false}
>
<Section title={t('privacy.lastUpdated')} />
<Section title={t('privacy.intro.title')}>
<Paragraph>{t('privacy.intro.content')}</Paragraph>
</Section>
<Section title={t('privacy.dataCollection.title')}>
<Paragraph>{t('privacy.dataCollection.content')}</Paragraph>
<BulletList
items={[
t('privacy.dataCollection.items.workouts'),
t('privacy.dataCollection.items.settings'),
t('privacy.dataCollection.items.device'),
]}
/>
</Section>
<Section title={t('privacy.usage.title')}>
<Paragraph>{t('privacy.usage.content')}</Paragraph>
</Section>
<Section title={t('privacy.sharing.title')}>
<Paragraph>{t('privacy.sharing.content')}</Paragraph>
</Section>
<Section title={t('privacy.security.title')}>
<Paragraph>{t('privacy.security.content')}</Paragraph>
</Section>
<Section title={t('privacy.rights.title')}>
<Paragraph>{t('privacy.rights.content')}</Paragraph>
</Section>
<Section title={t('privacy.contact.title')}>
<Paragraph>{t('privacy.contact.content')}</Paragraph>
<Text style={styles.email}>privacy@tabatafit.app</Text>
</Section>
<View style={styles.footer}>
<Text style={styles.footerText}>
TabataFit v1.0.0
</Text>
</View>
</ScrollView>
</View>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// HELPER COMPONENTS
// ═══════════════════════════════════════════════════════════════════════════
function Section({
title,
children,
}: {
title: string
children?: React.ReactNode
}) {
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{title}</Text>
{children}
</View>
)
}
function Paragraph({ children }: { children: string }) {
return <Text style={styles.paragraph}>{children}</Text>
}
function BulletList({ items }: { items: string[] }) {
return (
<View style={styles.bulletList}>
{items.map((item, index) => (
<View key={index} style={styles.bulletItem}>
<Text style={styles.bullet}></Text>
<Text style={styles.bulletText}>{item}</Text>
</View>
))}
</View>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// STYLES
// ═══════════════════════════════════════════════════════════════════════════
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: darkColors.bg.base,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: SPACING[4],
paddingVertical: SPACING[3],
borderBottomWidth: 1,
borderBottomColor: darkColors.border.dim,
},
backButton: {
width: 44,
height: 44,
alignItems: 'center',
justifyContent: 'center',
},
headerTitle: {
...TYPOGRAPHY.HEADLINE,
color: darkColors.text.primary,
},
scrollView: {
flex: 1,
},
content: {
paddingHorizontal: SPACING[5],
paddingTop: SPACING[4],
},
section: {
marginBottom: SPACING[6],
},
sectionTitle: {
...TYPOGRAPHY.TITLE_3,
fontWeight: '700',
color: darkColors.text.primary,
marginBottom: SPACING[3],
},
paragraph: {
...TYPOGRAPHY.BODY,
lineHeight: 22,
color: darkColors.text.secondary,
},
bulletList: {
marginTop: SPACING[3],
},
bulletItem: {
flexDirection: 'row',
marginBottom: SPACING[2],
},
bullet: {
...TYPOGRAPHY.BODY,
color: BRAND.PRIMARY,
marginRight: SPACING[2],
},
bulletText: {
flex: 1,
...TYPOGRAPHY.BODY,
lineHeight: 22,
color: darkColors.text.secondary,
},
email: {
...TYPOGRAPHY.BODY,
color: BRAND.PRIMARY,
marginTop: SPACING[2],
},
footer: {
marginTop: SPACING[8],
alignItems: 'center',
},
footerText: {
fontSize: 13,
color: darkColors.text.tertiary,
},
})

View File

@@ -1,28 +0,0 @@
<claude-mem-context>
# Recent Activity
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
### Apr 9, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #5997 | 10:46 AM | 🟣 | Tabata Kine programs system fully implemented with four programs and specialized UI | ~563 |
| #5996 | 10:41 AM | 🟣 | Tabata Kine programs system implementation completed | ~460 |
| #5978 | 9:53 AM | 🟣 | Kine program detail screen implemented | ~452 |
### Apr 10, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #6027 | 10:08 AM | 🔵 | Program Detail Screen Re-Referenced for Kine Program Display | ~458 |
| #6004 | 10:02 AM | 🔵 | Kine Program Detail Screen Architecture | ~337 |
| #5998 | 9:52 AM | 🟣 | Tabata Kine programs system implementation completed | ~711 |
### Apr 11, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #6150 | 7:49 PM | 🔵 | Program detail unlock button contains hardcoded orange | ~253 |
| #6134 | 7:43 PM | 🔄 | Program detail screen added withOpacity import | ~237 |
</claude-mem-context>

View File

@@ -1,296 +0,0 @@
/**
* Workout Program Detail Screen
* Shows Warmup → 3 Tabatas → Stretch preview, CTA to player.
*/
import { useEffect, useState } from 'react'
import { View, Text, StyleSheet, ScrollView, Pressable, ActivityIndicator } from 'react-native'
import { Stack, useRouter, useLocalSearchParams } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useTranslation } from 'react-i18next'
import { Icon } from '@/src/shared/components/Icon'
import { fetchProgramById, buildWorkoutProgramId } from '@/src/shared/data/workoutPrograms'
import type { WorkoutProgram } from '@/src/shared/types/workoutProgram'
import { BODY_ZONE_META, LEVEL_META } from '@/src/shared/types/workoutProgram'
import { useUserStore } from '@/src/shared/stores/userStore'
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
import { SPACING } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { TEXT, NAVY, GREEN, BORDER_COLORS, DARK } from '@/src/shared/constants/colors'
import { withOpacity } from '@/src/shared/utils/color'
const FALLBACK_ACCENT = '#FF6B35'
export default function WorkoutProgramDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>()
const router = useRouter()
const insets = useSafeAreaInsets()
const { t } = useTranslation()
const [program, setProgram] = useState<WorkoutProgram | null>(null)
const [loading, setLoading] = useState(true)
const isPremium = useUserStore(s => s.profile.subscription) !== 'free'
useEffect(() => {
let cancelled = false
setLoading(true)
fetchProgramById(id)
.then(p => {
if (!cancelled) setProgram(p)
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [id])
if (loading) {
return (
<View style={[styles.container, styles.center]}>
<Stack.Screen options={{ headerShown: false }} />
<ActivityIndicator color={TEXT.PRIMARY} />
</View>
)
}
if (!program) {
return (
<View style={[styles.container, styles.center]}>
<Stack.Screen options={{ headerShown: false }} />
<Text style={styles.errorText}>{t('screens:program.notFound')}</Text>
</View>
)
}
const accent = program.accentColor ?? BODY_ZONE_META[program.bodyZone].color ?? FALLBACK_ACCENT
const level = LEVEL_META[program.level]
const zone = BODY_ZONE_META[program.bodyZone]
const canAccess = program.isFree || isPremium
const handleStart = () => {
if (!canAccess) {
router.push('/paywall')
return
}
router.push(`/player/${buildWorkoutProgramId(program.id)}`)
}
const warmupMinutes = Math.round(program.warmup.totalDuration / 60)
const stretchMinutes = Math.round(program.stretch.totalDuration / 60)
return (
<View style={styles.container}>
<Stack.Screen
options={{
headerShown: true,
headerTitle: program.title,
headerStyle: { backgroundColor: NAVY[900] },
headerTintColor: TEXT.PRIMARY,
headerBackTitle: t('common:back'),
}}
/>
<ScrollView style={styles.scroll} contentContainerStyle={{ paddingBottom: insets.bottom + 120 }}>
{/* Hero */}
<View style={[styles.hero, { backgroundColor: withOpacity(accent, 0.12) }]}>
<View style={[styles.iconCircle, { backgroundColor: withOpacity(accent, 0.2) }]}>
<Icon name={(program.icon ?? zone.icon) as any} size={32} tintColor={accent} />
</View>
<Text style={styles.title}>{program.title}</Text>
{program.description && <Text style={styles.description}>{program.description}</Text>}
<View style={styles.badgeRow}>
<View style={[styles.badge, { borderColor: level.color }]}>
<Text style={[styles.badgeText, { color: level.color }]}>{level.label}</Text>
</View>
<View style={[styles.badge, { borderColor: zone.color }]}>
<Text style={[styles.badgeText, { color: zone.color }]}>{zone.label}</Text>
</View>
{!program.isFree && (
<View style={[styles.badge, { borderColor: accent }]}>
<Text style={[styles.badgeText, { color: accent }]}>{t('screens:home.premiumBadge')}</Text>
</View>
)}
</View>
<View style={styles.statsRow}>
<Stat value={program.estimatedDuration} label={t('common:units.min')} />
<Stat value={program.tabatas.length} label="Tabatas" />
<Stat value={program.estimatedCalories} label={t('common:units.cal')} />
</View>
</View>
{/* Warmup */}
<Section
title={t('screens:program.warmup')}
subtitle={`${warmupMinutes} ${t('common:units.min')}`}
accent="#FFC86B"
>
{program.warmup.exercises.map((ex, i) => (
<Row key={`w-${i}`} label={ex.name} detail={`${ex.duration}s`} />
))}
</Section>
{/* Tabatas */}
{program.tabatas.map((tabata, i) => (
<Section
key={tabata.id}
title={t('screens:program.tabataLabel', { num: i + 1 })}
subtitle={t('screens:program.tabataSubtitle', {
rounds: tabata.rounds,
work: tabata.workTime,
rest: tabata.restTime,
})}
accent={accent}
>
<Row label={tabata.exercise1.name} detail={t('screens:program.exercise1')} />
<Row label={tabata.exercise2.name} detail={t('screens:program.exercise2')} />
</Section>
))}
{/* Stretch */}
<Section
title={t('screens:program.stretch')}
subtitle={`${stretchMinutes} ${t('common:units.min')}`}
accent="#B4A7E5"
>
{program.stretch.exercises.map((ex, i) => (
<Row key={`s-${i}`} label={ex.name} detail={`${ex.duration}s`} />
))}
</Section>
</ScrollView>
<View style={[styles.ctaContainer, { paddingBottom: insets.bottom + SPACING[4] }]}>
<Pressable
style={[styles.ctaButton, { backgroundColor: canAccess ? accent : GREEN[500] }]}
onPress={handleStart}
>
<Text style={styles.ctaText}>
{canAccess ? t('screens:program.startSession') : t('screens:program.unlockPremium')}
</Text>
</Pressable>
</View>
</View>
)
}
function Stat({ value, label }: { value: number; label: string }) {
return (
<View style={styles.statItem}>
<Text style={styles.statValue}>{value}</Text>
<Text style={styles.statLabel}>{label}</Text>
</View>
)
}
function Section({
title,
subtitle,
accent,
children,
}: {
title: string
subtitle: string
accent: string
children: React.ReactNode
}) {
return (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<View style={[styles.sectionDot, { backgroundColor: accent }]} />
<Text style={styles.sectionTitle}>{title}</Text>
<Text style={styles.sectionSubtitle}>{subtitle}</Text>
</View>
<View style={styles.sectionBody}>{children}</View>
</View>
)
}
function Row({ label, detail }: { label: string; detail: string }) {
return (
<View style={styles.row}>
<Text style={styles.rowLabel} numberOfLines={1}>
{label}
</Text>
<Text style={styles.rowDetail}>{detail}</Text>
</View>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: NAVY[900] },
center: { alignItems: 'center', justifyContent: 'center' },
scroll: { flex: 1 },
errorText: { color: TEXT.SECONDARY, ...TYPOGRAPHY.BODY },
hero: { padding: SPACING[6], alignItems: 'center' },
iconCircle: {
width: 64,
height: 64,
borderRadius: 32,
alignItems: 'center',
justifyContent: 'center',
marginBottom: SPACING[3],
},
title: { ...TYPOGRAPHY.LARGE_TITLE, color: TEXT.PRIMARY, textAlign: 'center' },
description: {
...TYPOGRAPHY.BODY,
color: TEXT.SECONDARY,
textAlign: 'center',
marginTop: SPACING[2],
lineHeight: 22,
},
badgeRow: { flexDirection: 'row', gap: SPACING[2], marginTop: SPACING[3], flexWrap: 'wrap', justifyContent: 'center' },
badge: { paddingHorizontal: SPACING[2], paddingVertical: 3, borderRadius: RADIUS.SM, borderWidth: 1 },
badgeText: { ...TYPOGRAPHY.LABEL },
statsRow: { flexDirection: 'row', marginTop: SPACING[6], gap: SPACING[8] },
statItem: { alignItems: 'center' },
statValue: { ...TYPOGRAPHY.TITLE_2, color: TEXT.PRIMARY },
statLabel: { ...TYPOGRAPHY.CAPTION_1, color: TEXT.TERTIARY, marginTop: 2 },
section: { paddingHorizontal: SPACING[5], marginTop: SPACING[6] },
sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: SPACING[2], marginBottom: SPACING[3] },
sectionDot: { width: 8, height: 8, borderRadius: 4 },
sectionTitle: { ...TYPOGRAPHY.HEADLINE, color: TEXT.PRIMARY, flex: 1 },
sectionSubtitle: { ...TYPOGRAPHY.CAPTION_1, color: TEXT.TERTIARY },
sectionBody: {
backgroundColor: NAVY[800],
borderRadius: RADIUS.MD,
borderWidth: 1,
borderColor: BORDER_COLORS.DIM,
overflow: 'hidden',
},
row: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: SPACING[3],
paddingVertical: SPACING[3],
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: BORDER_COLORS.DIM,
gap: SPACING[3],
},
rowLabel: { ...TYPOGRAPHY.SUBHEADLINE, color: TEXT.PRIMARY, flex: 1 },
rowDetail: { ...TYPOGRAPHY.CAPTION_1, color: TEXT.TERTIARY },
ctaContainer: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
paddingHorizontal: SPACING[5],
paddingTop: SPACING[3],
backgroundColor: DARK.SCRIM,
borderTopWidth: 1,
borderTopColor: BORDER_COLORS.DIM,
},
ctaButton: { height: 52, borderRadius: RADIUS.MD, alignItems: 'center', justifyContent: 'center' },
ctaText: { ...TYPOGRAPHY.BUTTON_MEDIUM, color: NAVY[900], letterSpacing: 0.5 },
})

View File

@@ -1,223 +0,0 @@
/**
* Settings FormSheet
* Profile, preferences, premium, legal, reset progress.
*/
import { View, Text, StyleSheet, ScrollView, Pressable, Switch, Alert } from 'react-native'
import { useRouter } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useTranslation } from 'react-i18next'
import { Icon, type IconName } from '@/src/shared/components/Icon'
import { useUserStore } from '@/src/shared/stores/userStore'
import { useProgressStore } from '@/src/shared/stores/progressStore'
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
import { SPACING } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { TEXT, NAVY, GREEN, BORDER_COLORS } from '@/src/shared/constants/colors'
export default function SettingsScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
const { t } = useTranslation()
const profile = useUserStore(s => s.profile)
const settings = useUserStore(s => s.settings)
const updateSettings = useUserStore(s => s.updateSettings)
const resetProgress = useProgressStore(s => s.resetProgress)
const isPremium = profile.subscription !== 'free'
const handleResetProgress = () => {
Alert.alert(
t('screens:settings.resetTitle'),
t('screens:settings.resetMessage'),
[
{ text: t('common:cancel'), style: 'cancel' },
{
text: t('screens:settings.resetConfirm'),
style: 'destructive',
onPress: () => {
resetProgress()
},
},
],
)
}
return (
<ScrollView
style={styles.container}
contentContainerStyle={{ padding: SPACING[5], paddingBottom: insets.bottom + SPACING[8] }}
>
<Text style={styles.header}>{t('screens:settings.title')}</Text>
{/* Profile */}
<Section title={t('screens:settings.sectionProfile')}>
<Row label={t('screens:settings.name')} value={profile.name || '—'} />
<Row
label={t('screens:settings.subscription')}
value={isPremium ? t('screens:settings.premium') : t('screens:settings.free')}
accent={isPremium ? GREEN[500] : undefined}
/>
{!isPremium && (
<LinkRow
icon="sparkles"
label={t('screens:settings.upgradePremium')}
onPress={() => router.push('/paywall')}
accent={GREEN[500]}
/>
)}
</Section>
{/* Preferences */}
<Section title={t('screens:settings.sectionPrefs')}>
<SwitchRow
label={t('screens:settings.haptics')}
value={settings.haptics}
onChange={v => updateSettings({ haptics: v })}
/>
<SwitchRow
label={t('screens:settings.soundEffects')}
value={settings.soundEffects}
onChange={v => updateSettings({ soundEffects: v })}
/>
<SwitchRow
label={t('screens:settings.voiceCoaching')}
value={settings.voiceCoaching}
onChange={v => updateSettings({ voiceCoaching: v })}
/>
<SwitchRow
label={t('screens:settings.music')}
value={settings.musicEnabled}
onChange={v => updateSettings({ musicEnabled: v })}
/>
</Section>
{/* Legal */}
<Section title={t('screens:settings.sectionLegal')}>
<LinkRow icon="doc.text" label={t('screens:settings.terms')} onPress={() => router.push('/terms')} />
<LinkRow icon="lock.shield" label={t('screens:settings.privacy')} onPress={() => router.push('/privacy')} />
</Section>
{/* Danger */}
<Section title={t('screens:settings.sectionData')}>
<Pressable onPress={handleResetProgress} style={styles.dangerRow}>
<Icon name="trash" size={18} tintColor="#FF453A" />
<Text style={styles.dangerText}>{t('screens:settings.resetProgress')}</Text>
</Pressable>
</Section>
<Text style={styles.version}>{t('screens:settings.version')}</Text>
</ScrollView>
)
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{title}</Text>
<View style={styles.sectionBody}>{children}</View>
</View>
)
}
function Row({ label, value, accent }: { label: string; value: string; accent?: string }) {
return (
<View style={styles.row}>
<Text style={styles.rowLabel}>{label}</Text>
<Text style={[styles.rowValue, accent && { color: accent }]}>{value}</Text>
</View>
)
}
function SwitchRow({
label,
value,
onChange,
}: {
label: string
value: boolean
onChange: (value: boolean) => void
}) {
return (
<View style={styles.row}>
<Text style={styles.rowLabel}>{label}</Text>
<Switch value={value} onValueChange={onChange} trackColor={{ true: GREEN[500] }} />
</View>
)
}
function LinkRow({
icon,
label,
onPress,
accent,
}: {
icon: IconName
label: string
onPress: () => void
accent?: string
}) {
return (
<Pressable onPress={onPress} style={styles.row}>
<View style={styles.linkLeft}>
<Icon name={icon} size={18} tintColor={accent ?? TEXT.SECONDARY} />
<Text style={[styles.rowLabel, accent && { color: accent, fontWeight: '600' }]}>{label}</Text>
</View>
<Icon name="chevron.right" size={16} tintColor={TEXT.TERTIARY} />
</Pressable>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: NAVY[900] },
header: { ...TYPOGRAPHY.LARGE_TITLE, color: TEXT.PRIMARY, marginBottom: SPACING[4] },
section: { marginBottom: SPACING[6] },
sectionTitle: {
...TYPOGRAPHY.CAPTION_1,
color: TEXT.TERTIARY,
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: SPACING[2],
paddingHorizontal: SPACING[2],
},
sectionBody: {
backgroundColor: NAVY[800],
borderRadius: RADIUS.MD,
borderWidth: 1,
borderColor: BORDER_COLORS.DIM,
overflow: 'hidden',
},
row: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: SPACING[4],
paddingVertical: SPACING[3],
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: BORDER_COLORS.DIM,
gap: SPACING[3],
},
rowLabel: { ...TYPOGRAPHY.BODY, color: TEXT.PRIMARY },
rowValue: { ...TYPOGRAPHY.BODY, color: TEXT.SECONDARY },
linkLeft: { flexDirection: 'row', alignItems: 'center', gap: SPACING[3] },
dangerRow: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING[3],
paddingHorizontal: SPACING[4],
paddingVertical: SPACING[3],
},
dangerText: { ...TYPOGRAPHY.BODY, color: '#FF453A' },
version: {
...TYPOGRAPHY.CAPTION_1,
color: TEXT.TERTIARY,
textAlign: 'center',
marginTop: SPACING[4],
},
})

View File

@@ -1,106 +0,0 @@
/**
* TabataFit Terms of Service Screen
* Features: F-027, F-029, F-100, F-129
*/
import { useMemo } from 'react'
import { View, StyleSheet, ScrollView } from 'react-native'
import { Stack } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useTranslation } from 'react-i18next'
import { StyledText } from '@/src/shared/components/StyledText'
import { useThemeColors } from '@/src/shared/theme'
import type { ThemeColors } from '@/src/shared/theme/types'
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { NAVY, BORDER_COLORS, GREEN } from '@/src/shared/constants/colors'
const SECTIONS = [
'acceptance',
'service',
'subscriptions',
'cancellation',
'liability',
'contact',
] as const
export default function TermsScreen() {
const { t } = useTranslation()
const colors = useThemeColors()
const insets = useSafeAreaInsets()
const styles = useMemo(() => createStyles(colors), [colors])
return (
<>
<Stack.Screen
options={{
headerShown: true,
headerStyle: { backgroundColor: colors.bg.base },
headerShadowVisible: false,
headerTitle: t('screens:terms.title'),
headerTintColor: colors.text.primary,
headerBackButtonDisplayMode: 'minimal',
}}
/>
<ScrollView
style={styles.container}
contentContainerStyle={[
styles.content,
{ paddingBottom: insets.bottom + SPACING[8] },
]}
contentInsetAdjustmentBehavior="automatic"
>
<StyledText preset="CAPTION_1" style={styles.lastUpdated}>
{t('screens:terms.lastUpdated')}
</StyledText>
{SECTIONS.map((section) => (
<View key={section} style={styles.section}>
<StyledText preset="HEADING_2" style={styles.sectionTitle}>
{t(`screens:terms.${section}.title`)}
</StyledText>
<StyledText preset="BODY" style={styles.sectionContent}>
{t(`screens:terms.${section}.content`)}
</StyledText>
</View>
))}
<StyledText preset="CAPTION_1" style={styles.email}>
support@tabatafit.app
</StyledText>
</ScrollView>
</>
)
}
function createStyles(colors: ThemeColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bg.base,
},
content: {
paddingHorizontal: LAYOUT.SCREEN_PADDING,
paddingTop: SPACING[4],
gap: SPACING[6],
},
lastUpdated: {
color: colors.text.tertiary,
},
section: {
gap: SPACING[2],
},
sectionTitle: {
color: colors.text.primary,
},
sectionContent: {
color: colors.text.secondary,
lineHeight: 24,
},
email: {
color: GREEN[500],
textAlign: 'center',
marginTop: SPACING[4],
},
})
}

View File

@@ -1,244 +0,0 @@
/**
* Body Zone Detail Screen
* Segmented level selector (Beginner default) + program list filtered by zone+level.
*/
import { useEffect, useMemo, useState } from 'react'
import { View, Text, StyleSheet, ScrollView, Pressable, ActivityIndicator } from 'react-native'
import { Stack, useRouter, useLocalSearchParams } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useTranslation } from 'react-i18next'
import { Icon } from '@/src/shared/components/Icon'
import { fetchProgramsByBodyZone } from '@/src/shared/data/workoutPrograms'
import {
BODY_ZONE_META,
LEVEL_META,
type BodyZone,
type ProgramLevel,
type WorkoutProgram,
} from '@/src/shared/types/workoutProgram'
import { useProgressStore } from '@/src/shared/stores/progressStore'
import { useUserStore } from '@/src/shared/stores/userStore'
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
import { SPACING } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { TEXT, NAVY, GREEN, BORDER_COLORS } from '@/src/shared/constants/colors'
import { withOpacity } from '@/src/shared/utils/color'
const LEVELS: ProgramLevel[] = ['Beginner', 'Intermediate', 'Advanced']
const VALID_ZONES: BodyZone[] = ['upper-body', 'lower-body', 'full-body']
export default function BodyZoneScreen() {
const { bodyZone } = useLocalSearchParams<{ bodyZone: string }>()
const router = useRouter()
const insets = useSafeAreaInsets()
const { t } = useTranslation()
const zone = (VALID_ZONES.includes(bodyZone as BodyZone) ? bodyZone : 'full-body') as BodyZone
const meta = BODY_ZONE_META[zone]
const [programs, setPrograms] = useState<WorkoutProgram[]>([])
const [loading, setLoading] = useState(true)
const [selectedLevel, setSelectedLevel] = useState<ProgramLevel>('Beginner')
const isProgramCompleted = useProgressStore(s => s.isProgramCompleted)
const isPremium = useUserStore(s => s.profile.subscription) !== 'free'
useEffect(() => {
let cancelled = false
setLoading(true)
fetchProgramsByBodyZone(zone)
.then(list => {
if (!cancelled) setPrograms(list)
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [zone])
const filtered = useMemo(
() => programs.filter(p => p.level === selectedLevel).sort((a, b) => a.sortOrder - b.sortOrder),
[programs, selectedLevel],
)
return (
<View style={styles.container}>
<Stack.Screen
options={{
headerShown: true,
headerTitle: meta.label,
headerStyle: { backgroundColor: NAVY[900] },
headerTintColor: TEXT.PRIMARY,
headerBackTitle: t('common:back'),
}}
/>
<ScrollView
style={styles.scroll}
contentContainerStyle={{ padding: SPACING[5], paddingBottom: insets.bottom + SPACING[6] }}
>
{/* Zone header */}
<View style={[styles.zoneHeader, { backgroundColor: withOpacity(meta.color, 0.12) }]}>
<View style={[styles.iconCircle, { backgroundColor: withOpacity(meta.color, 0.25) }]}>
<Icon name={meta.icon as any} size={28} tintColor={meta.color} />
</View>
<View style={{ flex: 1 }}>
<Text style={styles.zoneTitle}>{meta.label}</Text>
<Text style={styles.zoneSubtitle}>{t('screens:zone.chooseLevel')}</Text>
</View>
</View>
{/* Level segmented */}
<View style={styles.segmented}>
{LEVELS.map(level => {
const active = selectedLevel === level
const levelMeta = LEVEL_META[level]
return (
<Pressable
key={level}
onPress={() => setSelectedLevel(level)}
style={[
styles.segment,
active && {
backgroundColor: withOpacity(levelMeta.color, 0.2),
borderColor: levelMeta.color,
},
]}
>
<Text
style={[
styles.segmentText,
active && { color: levelMeta.color, fontWeight: '600' },
]}
>
{levelMeta.label}
</Text>
</Pressable>
)
})}
</View>
{/* Program list */}
{loading ? (
<ActivityIndicator color={TEXT.PRIMARY} style={{ marginTop: SPACING[8] }} />
) : filtered.length === 0 ? (
<Text style={styles.empty}>{t('screens:zone.emptyPrograms')}</Text>
) : (
<View style={styles.programList}>
{filtered.map(program => (
<ProgramCard
key={program.id}
program={program}
completed={isProgramCompleted(program.id)}
locked={!program.isFree && !isPremium}
onPress={() => router.push(`/program/${program.id}`)}
/>
))}
</View>
)}
</ScrollView>
</View>
)
}
function ProgramCard({
program,
completed,
locked,
onPress,
}: {
program: WorkoutProgram
completed: boolean
locked: boolean
onPress: () => void
}) {
const accent = program.accentColor ?? BODY_ZONE_META[program.bodyZone].color
return (
<Pressable
onPress={onPress}
style={({ pressed }) => [styles.programCard, pressed && { opacity: 0.85 }]}
>
<View style={[styles.programDot, { backgroundColor: accent }]} />
<View style={{ flex: 1 }}>
<Text style={styles.programTitle} numberOfLines={1}>
{program.title}
</Text>
<Text style={styles.programMeta}>
{program.estimatedDuration} min · {program.tabatas.length} tabatas · {program.estimatedCalories} cal
</Text>
</View>
{completed && <Icon name="checkmark.circle.fill" size={20} tintColor={GREEN[500]} />}
{locked && <Icon name="lock.fill" size={16} tintColor={TEXT.TERTIARY} />}
{!completed && !locked && <Icon name="chevron.right" size={16} tintColor={TEXT.TERTIARY} />}
</Pressable>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: NAVY[900] },
scroll: { flex: 1 },
zoneHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING[3],
padding: SPACING[4],
borderRadius: RADIUS.LG,
marginBottom: SPACING[5],
},
iconCircle: {
width: 56,
height: 56,
borderRadius: 28,
alignItems: 'center',
justifyContent: 'center',
},
zoneTitle: { ...TYPOGRAPHY.TITLE_2, color: TEXT.PRIMARY },
zoneSubtitle: { ...TYPOGRAPHY.SUBHEADLINE, color: TEXT.SECONDARY, marginTop: 2 },
segmented: {
flexDirection: 'row',
backgroundColor: NAVY[800],
borderRadius: RADIUS.MD,
padding: 4,
gap: 4,
marginBottom: SPACING[5],
borderWidth: 1,
borderColor: BORDER_COLORS.DIM,
},
segment: {
flex: 1,
paddingVertical: SPACING[2],
borderRadius: RADIUS.SM,
alignItems: 'center',
borderWidth: 1,
borderColor: 'transparent',
},
segmentText: { ...TYPOGRAPHY.SUBHEADLINE, color: TEXT.SECONDARY },
programList: { gap: SPACING[3] },
programCard: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING[3],
padding: SPACING[4],
backgroundColor: NAVY[800],
borderRadius: RADIUS.MD,
borderWidth: 1,
borderColor: BORDER_COLORS.DIM,
},
programDot: { width: 10, height: 10, borderRadius: 5 },
programTitle: { ...TYPOGRAPHY.HEADLINE, color: TEXT.PRIMARY },
programMeta: { ...TYPOGRAPHY.CAPTION_1, color: TEXT.TERTIARY, marginTop: 2 },
empty: {
...TYPOGRAPHY.BODY,
color: TEXT.SECONDARY,
textAlign: 'center',
marginTop: SPACING[8],
},
})

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

View File

@@ -1,67 +0,0 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Background gradient -->
<defs>
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1a1a2e"/>
<stop offset="100%" style="stop-color:#16213e"/>
</linearGradient>
<linearGradient id="timerGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#FF6B35"/>
<stop offset="100%" style="stop-color:#FF8A5B"/>
</linearGradient>
<linearGradient id="flameGradient" x1="0%" y1="100%" x2="0%" y2="0%">
<stop offset="0%" style="stop-color:#FF6B35"/>
<stop offset="50%" style="stop-color:#FF8A5B"/>
<stop offset="100%" style="stop-color:#FFB347"/>
</linearGradient>
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="8" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- Background -->
<rect width="1024" height="1024" rx="220" fill="url(#bgGradient)"/>
<!-- Timer ring background -->
<circle cx="512" cy="512" r="320" stroke="#2a2a4a" stroke-width="40" fill="none"/>
<!-- Timer ring (animated feel - 75% complete) -->
<circle cx="512" cy="512" r="320" stroke="url(#timerGradient)" stroke-width="40" fill="none"
stroke-linecap="round" stroke-dasharray="1508" stroke-dashoffset="377"
transform="rotate(-90 512 512)" filter="url(#glow)"/>
<!-- Timer ticks -->
<g stroke="#3a3a5a" stroke-width="8" opacity="0.6">
<line x1="512" y1="152" x2="512" y2="192"/>
<line x1="512" y1="832" x2="512" y2="872"/>
<line x1="152" y1="512" x2="192" y2="512"/>
<line x1="832" y1="512" x2="872" y2="512"/>
</g>
<!-- Flame -->
<g transform="translate(512, 480)" filter="url(#glow)">
<!-- Outer flame -->
<path d="M0,-200 C60,-160 100,-80 80,0 C70,50 40,80 0,100 C-40,80 -70,50 -80,0 C-100,-80 -60,-160 0,-200 Z"
fill="url(#flameGradient)" opacity="0.9"/>
<!-- Middle flame -->
<path d="M0,-160 C40,-130 60,-60 50,0 C40,30 20,50 0,60 C-20,50 -40,30 -50,0 C-60,-60 -40,-130 0,-160 Z"
fill="#FFB347" opacity="0.8"/>
<!-- Inner flame (hot core) -->
<path d="M0,-100 C20,-80 30,-40 25,0 C20,15 10,25 0,30 C-10,25 -20,15 -25,0 C-30,-40 -20,-80 0,-100 Z"
fill="#FFF5E6" opacity="0.9"/>
</g>
<!-- Timer indicator dot -->
<circle cx="512" cy="192" r="24" fill="url(#timerGradient)" filter="url(#glow)"/>
<!-- Play button hint at bottom of flame -->
<g transform="translate(512, 620)">
<polygon points="0,-30 26,15 -26,15" fill="#FF6B35" opacity="0.8"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 435 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 MiB

Binary file not shown.

View File

@@ -1,36 +0,0 @@
{
"cli": {
"version": ">= 16.0.1",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"ios": {
"simulator": true
}
},
"preview": {
"distribution": "internal",
"ios": {
"resourceClass": "m-medium"
}
},
"production": {
"autoIncrement": true,
"ios": {
"resourceClass": "m-medium"
}
}
},
"submit": {
"production": {
"ios": {
"appleId": "millianlmx@icloud.com",
"ascAppId": "REPLACE_WITH_APP_STORE_CONNECT_APP_ID",
"appleTeamId": "REPLACE_WITH_APPLE_TEAM_ID"
}
}
}
}

View File

@@ -1,10 +0,0 @@
// https://docs.expo.dev/guides/using-eslint/
const { defineConfig } = require('eslint/config');
const expoConfig = require('eslint-config-expo/flat');
module.exports = defineConfig([
expoConfig,
{
ignores: ['dist/*'],
},
]);

View File

@@ -1,20 +0,0 @@
const fs = require('fs');
const langs = [
{ code: 'fr', text: 'Prêt à tout casser aujourd\'hui ?' },
{ code: 'es', text: '¿Listo para arrasar hoy?' },
{ code: 'de', text: 'Bereit, heute alles zu geben?' }
];
langs.forEach(({ code, text }) => {
const path = `src/shared/i18n/locales/${code}/screens.json`;
if (fs.existsSync(path)) {
let content = fs.readFileSync(path, 'utf8');
content = content.replace(
/"home":\s*\{/,
`"home": {\n "readyToCrush": "${text}",`
);
fs.writeFileSync(path, content);
console.log(`Updated ${code}`);
}
});

15598
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,97 +0,0 @@
{
"name": "tabatafit",
"main": "expo-router/entry",
"version": "1.0.0",
"scripts": {
"start": "expo start",
"reset-project": "node ./scripts/reset-project.js",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"lint": "expo lint",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:render": "vitest run --config vitest.config.render.ts",
"test:maestro": "maestro test .maestro/flows",
"test:maestro:onboarding": "maestro test .maestro/flows/onboarding.yaml",
"test:maestro:programs": "maestro test .maestro/flows/program-browse.yaml",
"test:maestro:tabs": "maestro test .maestro/flows/tab-navigation.yaml",
"test:maestro:paywall": "maestro test .maestro/flows/subscription.yaml",
"test:maestro:player": "maestro test .maestro/flows/workout-player.yaml",
"test:maestro:activity": "maestro test .maestro/flows/activity-tab.yaml",
"test:maestro:profile": "maestro test .maestro/flows/profile-settings.yaml",
"test:maestro:all": "maestro test .maestro/flows/all-tests.yaml",
"test:maestro:reset": "maestro test .maestro/flows/reset-state.yaml"
},
"dependencies": {
"@expo-google-fonts/dm-mono": "^0.4.2",
"@expo-google-fonts/dm-serif-display": "^0.4.2",
"@expo-google-fonts/inter": "^0.4.2",
"@expo-google-fonts/outfit": "^0.4.3",
"@expo/ui": "~0.2.0-beta.9",
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"@supabase/supabase-js": "^2.98.0",
"@tanstack/query-async-storage-persister": "^5.90.24",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-query-persist-client": "^5.90.24",
"expo": "~54.0.33",
"expo-application": "~7.0.8",
"expo-av": "~16.0.8",
"expo-blur": "~15.0.8",
"expo-constants": "~18.0.13",
"expo-device": "~8.0.10",
"expo-file-system": "~19.0.21",
"expo-font": "~14.0.11",
"expo-gl": "~16.0.10",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
"expo-keep-awake": "~15.0.8",
"expo-linear-gradient": "~15.0.8",
"expo-linking": "~8.0.11",
"expo-localization": "~17.0.8",
"expo-network": "~8.0.8",
"expo-notifications": "~0.32.16",
"expo-router": "~6.0.23",
"expo-sharing": "~14.0.8",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",
"expo-store-review": "~9.0.9",
"expo-symbols": "~1.0.8",
"expo-system-ui": "~6.0.9",
"expo-video": "~3.0.16",
"expo-web-browser": "~15.0.10",
"i18next": "^25.8.12",
"posthog-react-native": "^4.36.0",
"posthog-react-native-session-replay": "^1.5.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-i18next": "^16.5.4",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-purchases": "^9.10.3",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "15.12.1",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1",
"zustand": "^5.0.11"
},
"devDependencies": {
"@testing-library/jest-native": "^5.4.3",
"@testing-library/react-native": "^13.3.3",
"@types/react": "~19.1.0",
"@vitest/coverage-v8": "^4.1.1",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
"jsdom": "^29.0.1",
"react-test-renderer": "^19.1.0",
"typescript": "~5.9.2",
"vitest": "^4.1.1"
},
"private": true
}

View File

@@ -1,7 +0,0 @@
<claude-mem-context>
# Recent Activity
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
*No recent activity*
</claude-mem-context>

View File

@@ -1,148 +0,0 @@
/**
* Expo Config Plugin: StoreKit Configuration for Sandbox Testing
*
* Adds TabataFit.storekit to the Xcode project and configures the scheme
* to use it for StoreKit testing. This enables purchase testing in the
* iOS simulator without real Apple Pay charges.
*/
const {
withXcodeProject,
withDangerousMod,
} = require('@expo/config-plugins')
const fs = require('fs')
const path = require('path')
const STOREKIT_FILENAME = 'TabataFit.storekit'
/**
* Step 1: Copy the .storekit file and add it to the Xcode project (project.pbxproj)
*/
function withStoreKitXcodeProject(config) {
return withXcodeProject(config, (config) => {
const project = config.modResults
const projectName = config.modRequest.projectName
const platformRoot = config.modRequest.platformProjectRoot
// Copy .storekit file into the iOS app directory
const sourceFile = path.resolve(__dirname, '..', 'storekit', STOREKIT_FILENAME)
const destDir = path.join(platformRoot, projectName)
const destFile = path.join(destDir, STOREKIT_FILENAME)
fs.copyFileSync(sourceFile, destFile)
// Add file to the Xcode project manually via pbxproj APIs
// Find the app's PBXGroup
const mainGroupKey = project.getFirstProject().firstProject.mainGroup
const mainGroup = project.getPBXGroupByKey(mainGroupKey)
// Find the app target group (e.g., "tabatago")
let appGroupKey = null
if (mainGroup && mainGroup.children) {
for (const child of mainGroup.children) {
if (child.comment === projectName) {
appGroupKey = child.value
break
}
}
}
if (!appGroupKey) {
console.warn('[withStoreKitConfig] Could not find app group in Xcode project')
return config
}
const appGroup = project.getPBXGroupByKey(appGroupKey)
// Check if already added
const alreadyExists = appGroup.children?.some(
(child) => child.comment === STOREKIT_FILENAME
)
if (!alreadyExists) {
// Generate a unique UUID for the file reference
const fileRefUuid = project.generateUuid()
// Add PBXFileReference — NOT added to any build phase
// .storekit files are testing configs, not app resources
const pbxFileRef = project.hash.project.objects['PBXFileReference']
pbxFileRef[fileRefUuid] = {
isa: 'PBXFileReference',
lastKnownFileType: 'text.json',
path: STOREKIT_FILENAME,
sourceTree: '"<group>"',
}
pbxFileRef[`${fileRefUuid}_comment`] = STOREKIT_FILENAME
// Add to the app group's children (visible in Xcode navigator)
appGroup.children.push({
value: fileRefUuid,
comment: STOREKIT_FILENAME,
})
console.log('[withStoreKitConfig] Added', STOREKIT_FILENAME, 'to Xcode project')
}
return config
})
}
/**
* Step 2: Configure the Xcode scheme to use the StoreKit configuration
*/
function withStoreKitScheme(config) {
return withDangerousMod(config, [
'ios',
(config) => {
const projectName = config.modRequest.projectName
const schemePath = path.join(
config.modRequest.platformProjectRoot,
`${projectName}.xcodeproj`,
'xcshareddata',
'xcschemes',
`${projectName}.xcscheme`
)
if (!fs.existsSync(schemePath)) {
console.warn('[withStoreKitConfig] Scheme not found:', schemePath)
return config
}
let scheme = fs.readFileSync(schemePath, 'utf8')
// Skip if already configured
if (scheme.includes('storeKitConfigurationFileReference')) {
console.log('[withStoreKitConfig] StoreKit config already in scheme')
return config
}
// Insert StoreKitConfigurationFileReference as a child element of LaunchAction
// The identifier path is relative to the workspace root (ios/ directory)
const storeKitRef = `
<StoreKitConfigurationFileReference
identifier = "${projectName}/${STOREKIT_FILENAME}">
</StoreKitConfigurationFileReference>`
// Insert before the closing </LaunchAction> tag
scheme = scheme.replace(
'</LaunchAction>',
`${storeKitRef}\n </LaunchAction>`
)
fs.writeFileSync(schemePath, scheme, 'utf8')
console.log('[withStoreKitConfig] Added StoreKit config to scheme')
return config
},
])
}
/**
* Main plugin
*/
function withStoreKitConfig(config) {
config = withStoreKitXcodeProject(config)
config = withStoreKitScheme(config)
return config
}
module.exports = withStoreKitConfig

View File

@@ -1,40 +0,0 @@
{
"version": 1,
"skills": {
"building-native-ui": {
"source": "expo/skills",
"sourceType": "github",
"computedHash": "342df93f481a0dba919f372d6c7b40d2b4bf5b51dd24363aea2e5d0bae27a6fa"
},
"expo-api-routes": {
"source": "expo/skills",
"sourceType": "github",
"computedHash": "015c6b849507fda73fcc32d2448f033aaaaa21f5229085342b8421727a90cafb"
},
"expo-cicd-workflows": {
"source": "expo/skills",
"sourceType": "github",
"computedHash": "700b20b575fcbe75ad238b41a0bd57938abe495e62dc53e05400712ab01ee7c0"
},
"expo-deployment": {
"source": "expo/skills",
"sourceType": "github",
"computedHash": "9ea9f16374765c1b16764a51bd43a64098921b33f48e94d9c5c1cce24b335c10"
},
"expo-dev-client": {
"source": "expo/skills",
"sourceType": "github",
"computedHash": "234e2633b7fbcef2d479f8fe8ab20d53d08ed3e4beec7c965da4aff5b43affe7"
},
"expo-tailwind-setup": {
"source": "expo/skills",
"sourceType": "github",
"computedHash": "d39e806942fe880347f161056729b588a3cb0f1796270eebf52633fe11cfdce1"
},
"native-data-fetching": {
"source": "expo/skills",
"sourceType": "github",
"computedHash": "6c14e4efb34a9c4759e8b959f82dec328f87dd89a022957c6737086984b9b106"
}
}
}

View File

@@ -1,142 +0,0 @@
import { describe, it, expect } from 'vitest'
type FontWeight = 'regular' | 'medium' | 'semibold' | 'bold'
const WEIGHT_MAP: Record<FontWeight, string> = {
regular: '400',
medium: '500',
semibold: '600',
bold: '700',
}
describe('StyledText', () => {
describe('weight mapping', () => {
it('should map regular to 400', () => {
expect(WEIGHT_MAP['regular']).toBe('400')
})
it('should map medium to 500', () => {
expect(WEIGHT_MAP['medium']).toBe('500')
})
it('should map semibold to 600', () => {
expect(WEIGHT_MAP['semibold']).toBe('600')
})
it('should map bold to 700', () => {
expect(WEIGHT_MAP['bold']).toBe('700')
})
})
describe('default values', () => {
it('should have default size of 17', () => {
const defaultSize = 17
expect(defaultSize).toBe(17)
})
it('should have default weight of regular', () => {
const defaultWeight: FontWeight = 'regular'
expect(WEIGHT_MAP[defaultWeight]).toBe('400')
})
})
describe('style computation', () => {
const computeTextStyle = (size: number, weight: FontWeight, color: string) => ({
fontSize: size,
fontWeight: WEIGHT_MAP[weight],
color,
})
it('should compute correct style with defaults', () => {
const style = computeTextStyle(17, 'regular', '#FFFFFF')
expect(style.fontSize).toBe(17)
expect(style.fontWeight).toBe('400')
expect(style.color).toBe('#FFFFFF')
})
it('should compute correct style with custom size', () => {
const style = computeTextStyle(24, 'regular', '#FFFFFF')
expect(style.fontSize).toBe(24)
})
it('should compute correct style with bold weight', () => {
const style = computeTextStyle(17, 'bold', '#FFFFFF')
expect(style.fontWeight).toBe('700')
})
it('should compute correct style with custom color', () => {
const style = computeTextStyle(17, 'regular', '#FF0000')
expect(style.color).toBe('#FF0000')
})
it('should compute correct style with all custom props', () => {
const style = computeTextStyle(20, 'semibold', '#5AC8FA')
expect(style.fontSize).toBe(20)
expect(style.fontWeight).toBe('600')
expect(style.color).toBe('#5AC8FA')
})
})
describe('numberOfLines handling', () => {
it('should accept numberOfLines prop', () => {
const numberOfLines = 2
expect(numberOfLines).toBe(2)
})
it('should handle undefined numberOfLines', () => {
const numberOfLines: number | undefined = undefined
expect(numberOfLines).toBeUndefined()
})
})
describe('style merging', () => {
const mergeStyles = (baseStyle: object, customStyle: object | undefined) => {
return customStyle ? [baseStyle, customStyle] : [baseStyle]
}
it('should merge custom style with base style', () => {
const base = { fontSize: 17, fontWeight: '400' }
const custom = { marginTop: 10 }
const merged = mergeStyles(base, custom)
expect(merged).toHaveLength(2)
expect(merged[0]).toEqual(base)
expect(merged[1]).toEqual(custom)
})
it('should return only base style when no custom style', () => {
const base = { fontSize: 17, fontWeight: '400' }
const merged = mergeStyles(base, undefined)
expect(merged).toHaveLength(1)
expect(merged[0]).toEqual(base)
})
})
describe('theme color integration', () => {
const mockThemeColors = {
text: {
primary: '#FFFFFF',
secondary: '#8E8E93',
tertiary: '#636366',
},
}
it('should use primary text color as default', () => {
const defaultColor = mockThemeColors.text.primary
expect(defaultColor).toBe('#FFFFFF')
})
it('should allow color override', () => {
const customColor = '#FF0000'
const resolvedColor = customColor || mockThemeColors.text.primary
expect(resolvedColor).toBe('#FF0000')
})
it('should fallback to theme color when no override', () => {
const customColor: string | undefined = undefined
const resolvedColor = customColor || mockThemeColors.text.primary
expect(resolvedColor).toBe('#FFFFFF')
})
})
})

View File

@@ -1,113 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { BRAND } from '../../shared/constants/colors'
type VideoPlayerMode = 'preview' | 'background'
interface VideoPlayerConfig {
loop: boolean
muted: boolean
volume: number
}
function getVideoPlayerConfig(mode: VideoPlayerMode): VideoPlayerConfig {
return {
loop: true,
muted: mode === 'preview',
volume: mode === 'background' ? 0.3 : 0,
}
}
function shouldShowGradient(videoUrl: string | undefined): boolean {
return !videoUrl
}
function shouldPlayVideo(isPlaying: boolean, videoUrl: string | undefined): boolean {
return isPlaying && !!videoUrl
}
describe('VideoPlayer', () => {
describe('video player configuration', () => {
it('should configure preview mode with muted audio', () => {
const config = getVideoPlayerConfig('preview')
expect(config.loop).toBe(true)
expect(config.muted).toBe(true)
expect(config.volume).toBe(0)
})
it('should configure background mode with low volume', () => {
const config = getVideoPlayerConfig('background')
expect(config.loop).toBe(true)
expect(config.muted).toBe(false)
expect(config.volume).toBe(0.3)
})
it('should always loop regardless of mode', () => {
expect(getVideoPlayerConfig('preview').loop).toBe(true)
expect(getVideoPlayerConfig('background').loop).toBe(true)
})
})
describe('gradient fallback', () => {
it('should show gradient when no video URL', () => {
expect(shouldShowGradient(undefined)).toBe(true)
expect(shouldShowGradient('')).toBe(true)
})
it('should not show gradient when video URL exists', () => {
expect(shouldShowGradient('https://example.com/video.m3u8')).toBe(false)
expect(shouldShowGradient('https://example.com/video.mp4')).toBe(false)
})
})
describe('playback control', () => {
it('should play when isPlaying is true and video exists', () => {
expect(shouldPlayVideo(true, 'https://example.com/video.mp4')).toBe(true)
})
it('should not play when isPlaying is false', () => {
expect(shouldPlayVideo(false, 'https://example.com/video.mp4')).toBe(false)
})
it('should not play when no video URL', () => {
expect(shouldPlayVideo(true, undefined)).toBe(false)
expect(shouldPlayVideo(true, '')).toBe(false)
})
})
describe('default gradient colors', () => {
it('should use brand colors as default gradient', () => {
const defaultColors = [BRAND.PRIMARY, BRAND.SECONDARY]
expect(defaultColors[0]).toBe(BRAND.PRIMARY)
expect(defaultColors[1]).toBe(BRAND.SECONDARY)
})
})
describe('video URL validation', () => {
it('should accept HLS streams', () => {
const hlsUrl = 'https://example.com/video.m3u8'
expect(shouldShowGradient(hlsUrl)).toBe(false)
})
it('should accept MP4 files', () => {
const mp4Url = 'https://example.com/video.mp4'
expect(shouldShowGradient(mp4Url)).toBe(false)
})
it('should handle null/undefined', () => {
expect(shouldShowGradient(null as any)).toBe(true)
expect(shouldShowGradient(undefined)).toBe(true)
})
})
describe('mode-specific behavior', () => {
it('preview mode should be silent', () => {
const previewConfig = getVideoPlayerConfig('preview')
expect(previewConfig.muted || previewConfig.volume === 0).toBe(true)
})
it('background mode should have audible audio', () => {
const bgConfig = getVideoPlayerConfig('background')
expect(bgConfig.volume).toBeGreaterThan(0)
})
})
})

View File

@@ -1,115 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import React from 'react'
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react-native'
import { DataDeletionModal } from '@/src/shared/components/DataDeletionModal'
describe('DataDeletionModal', () => {
const defaultProps = {
visible: true,
onDelete: vi.fn().mockResolvedValue(undefined),
onCancel: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('renders when visible is true', () => {
render(<DataDeletionModal {...defaultProps} />)
// Title key from i18n mock
expect(screen.getByText('dataDeletion.title')).toBeTruthy()
})
it('renders warning icon', () => {
render(<DataDeletionModal {...defaultProps} />)
expect(screen.getByTestId('icon-warning')).toBeTruthy()
})
it('renders description and note text', () => {
render(<DataDeletionModal {...defaultProps} />)
expect(screen.getByText('dataDeletion.description')).toBeTruthy()
expect(screen.getByText('dataDeletion.note')).toBeTruthy()
})
it('renders delete and cancel buttons', () => {
render(<DataDeletionModal {...defaultProps} />)
expect(screen.getByText('dataDeletion.deleteButton')).toBeTruthy()
expect(screen.getByText('dataDeletion.cancelButton')).toBeTruthy()
})
it('calls onCancel when cancel button is pressed', () => {
render(<DataDeletionModal {...defaultProps} />)
fireEvent.press(screen.getByText('dataDeletion.cancelButton'))
expect(defaultProps.onCancel).toHaveBeenCalledTimes(1)
})
it('calls onDelete when delete button is pressed', async () => {
render(<DataDeletionModal {...defaultProps} />)
await act(async () => {
fireEvent.press(screen.getByText('dataDeletion.deleteButton'))
})
expect(defaultProps.onDelete).toHaveBeenCalledTimes(1)
})
it('shows loading text while deleting', async () => {
let resolveDelete: () => void
const slowDelete = new Promise<void>((resolve) => {
resolveDelete = resolve
})
const onDelete = vi.fn(() => slowDelete)
render(<DataDeletionModal visible={true} onDelete={onDelete} onCancel={vi.fn()} />)
// Start delete
await act(async () => {
fireEvent.press(screen.getByText('dataDeletion.deleteButton'))
})
// Should show 'Deleting...' while in progress
expect(screen.getByText('Deleting...')).toBeTruthy()
// Complete the delete
await act(async () => {
resolveDelete!()
})
})
it('does not render content when visible is false', () => {
render(<DataDeletionModal {...defaultProps} visible={false} />)
// Modal with visible=false won't render its children
expect(screen.queryByText('dataDeletion.title')).toBeNull()
})
it('full modal structure snapshot', () => {
const { toJSON } = render(<DataDeletionModal {...defaultProps} />)
expect(toJSON()).toMatchSnapshot()
})
it('delete button shows disabled state while deleting', async () => {
let resolveDelete: () => void
const slowDelete = new Promise<void>((resolve) => {
resolveDelete = resolve
})
const onDelete = vi.fn(() => slowDelete)
const { toJSON } = render(
<DataDeletionModal visible={true} onDelete={onDelete} onCancel={vi.fn()} />
)
await act(async () => {
fireEvent.press(screen.getByText('dataDeletion.deleteButton'))
})
// While deleting, the button text changes to loading state
expect(screen.getByText('Deleting...')).toBeTruthy()
// Verify the tree has the disabled styling applied (opacity: 0.6)
const tree = toJSON()
const treeStr = JSON.stringify(tree)
expect(treeStr).toContain('"opacity":0.6')
await act(async () => {
resolveDelete!()
})
})
})

View File

@@ -1,100 +0,0 @@
import { describe, it, expect } from 'vitest'
import React from 'react'
import { render, screen } from '@testing-library/react-native'
import { Text } from 'react-native'
import { Card, CardAccent, CardTip, GlassCard, GlassCardElevated, GlassCardInset, GlassCardTinted } from '@/src/shared/components/GlassCard'
describe('Card', () => {
it('renders children', () => {
render(
<Card>
<Text testID="child">Hello</Text>
</Card>
)
expect(screen.getByTestId('child')).toBeTruthy()
})
it('applies custom style prop to root container', () => {
const customStyle = { padding: 20 }
const { toJSON } = render(
<Card style={customStyle}>
<Text>Content</Text>
</Card>
)
const tree = toJSON()
const rootStyle = tree?.props?.style
expect(rootStyle).toBeDefined()
const flatStyles = Array.isArray(rootStyle) ? rootStyle : [rootStyle]
const hasPadding = flatStyles.some(
(s: any) => s && typeof s === 'object' && s.padding === 20
)
expect(hasPadding).toBe(true)
})
})
describe('Card variants', () => {
it('renders default variant (snapshot)', () => {
const { toJSON } = render(
<Card>
<Text>Default</Text>
</Card>
)
expect(toJSON()).toMatchSnapshot()
})
it('renders accent variant (snapshot)', () => {
const { toJSON } = render(
<CardAccent>
<Text>Accent</Text>
</CardAccent>
)
expect(toJSON()).toMatchSnapshot()
})
it('renders tip variant (snapshot)', () => {
const { toJSON } = render(
<CardTip>
<Text>Tip</Text>
</CardTip>
)
expect(toJSON()).toMatchSnapshot()
})
})
describe('Backward-compatible aliases', () => {
it('GlassCard renders children', () => {
render(
<GlassCard>
<Text testID="bc-child">Backward compat</Text>
</GlassCard>
)
expect(screen.getByTestId('bc-child')).toBeTruthy()
})
it('GlassCardElevated renders children', () => {
render(
<GlassCardElevated>
<Text testID="elevated-child">Elevated</Text>
</GlassCardElevated>
)
expect(screen.getByTestId('elevated-child')).toBeTruthy()
})
it('GlassCardInset renders children', () => {
render(
<GlassCardInset>
<Text testID="inset-child">Inset</Text>
</GlassCardInset>
)
expect(screen.getByTestId('inset-child')).toBeTruthy()
})
it('GlassCardTinted renders children', () => {
render(
<GlassCardTinted>
<Text testID="tinted-child">Tinted</Text>
</GlassCardTinted>
)
expect(screen.getByTestId('tinted-child')).toBeTruthy()
})
})

View File

@@ -1,123 +0,0 @@
import { describe, it, expect } from 'vitest'
import React from 'react'
import { render, screen } from '@testing-library/react-native'
import { Text } from 'react-native'
import { OnboardingStep } from '@/src/shared/components/OnboardingStep'
/**
* Helper to recursively find a node in the rendered tree by its element type name.
* Returns the first match or null.
*/
function findByType(tree: any, typeName: string): any {
if (!tree) return null
if (tree.type === typeName) return tree
if (tree.children && Array.isArray(tree.children)) {
for (const child of tree.children) {
if (typeof child === 'object') {
const found = findByType(child, typeName)
if (found) return found
}
}
}
return null
}
/**
* Helper to count nodes of a given type in the tree
*/
function countByType(tree: any, typeName: string): number {
if (!tree) return 0
let count = tree.type === typeName ? 1 : 0
if (tree.children && Array.isArray(tree.children)) {
for (const child of tree.children) {
if (typeof child === 'object') {
count += countByType(child, typeName)
}
}
}
return count
}
describe('OnboardingStep', () => {
it('renders children', () => {
render(
<OnboardingStep step={1} totalSteps={6}>
<Text testID="child-content">Welcome</Text>
</OnboardingStep>
)
expect(screen.getByTestId('child-content')).toBeTruthy()
})
it('renders progress bar with track and fill Views', () => {
const { toJSON } = render(
<OnboardingStep step={1} totalSteps={6}>
<Text>Step 1</Text>
</OnboardingStep>
)
const tree = toJSON()
// OnboardingStep should have:
// - A root View (container)
// - A View (progressTrack)
// - An Animated.View (progressFill) — rendered as View by mock
// - An Animated.View (content wrapper)
expect(tree).toBeTruthy()
expect(tree?.type).toBe('View') // root container
expect(tree?.children).toBeDefined()
expect(tree!.children!.length).toBeGreaterThanOrEqual(2) // progress track + content
})
it('step 1 of 6 snapshot', () => {
const { toJSON } = render(
<OnboardingStep step={1} totalSteps={6}>
<Text>First step</Text>
</OnboardingStep>
)
expect(toJSON()).toMatchSnapshot()
})
it('step 6 of 6 (final step) snapshot', () => {
const { toJSON } = render(
<OnboardingStep step={6} totalSteps={6}>
<Text>Final step</Text>
</OnboardingStep>
)
expect(toJSON()).toMatchSnapshot()
})
it('renders multiple children inside content area', () => {
render(
<OnboardingStep step={3} totalSteps={6}>
<Text testID="title">Title</Text>
<Text testID="description">Description</Text>
</OnboardingStep>
)
expect(screen.getByTestId('title')).toBeTruthy()
expect(screen.getByTestId('description')).toBeTruthy()
})
it('does not crash with step 0 (edge case snapshot)', () => {
const { toJSON } = render(
<OnboardingStep step={0} totalSteps={6}>
<Text>Edge case</Text>
</OnboardingStep>
)
// Should render without error — snapshot captures structure
expect(toJSON()).toMatchSnapshot()
})
it('container uses safe area top inset for paddingTop', () => {
const { toJSON } = render(
<OnboardingStep step={1} totalSteps={6}>
<Text>Check padding</Text>
</OnboardingStep>
)
const tree = toJSON()
// Root container should have paddingTop accounting for safe area (mock returns top=47)
const rootStyle = tree?.props?.style
const flatStyles = Array.isArray(rootStyle) ? rootStyle : [rootStyle]
const hasPaddingTop = flatStyles.some(
(s: any) => s && typeof s === 'object' && typeof s.paddingTop === 'number' && s.paddingTop > 0
)
expect(hasPaddingTop).toBe(true)
})
})

View File

@@ -1,125 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import React from 'react'
import { render, screen, fireEvent, act } from '@testing-library/react-native'
import { SyncConsentModal } from '@/src/shared/components/SyncConsentModal'
describe('SyncConsentModal', () => {
const defaultProps = {
visible: true,
onAccept: vi.fn().mockResolvedValue(undefined),
onDecline: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('renders when visible is true', () => {
render(<SyncConsentModal {...defaultProps} />)
expect(screen.getByText('sync.title')).toBeTruthy()
})
it('renders sparkles icon', () => {
render(<SyncConsentModal {...defaultProps} />)
expect(screen.getByTestId('icon-sparkles')).toBeTruthy()
})
it('renders benefit rows', () => {
render(<SyncConsentModal {...defaultProps} />)
expect(screen.getByText('sync.benefits.recommendations')).toBeTruthy()
expect(screen.getByText('sync.benefits.adaptive')).toBeTruthy()
expect(screen.getByText('sync.benefits.sync')).toBeTruthy()
expect(screen.getByText('sync.benefits.secure')).toBeTruthy()
})
it('renders benefit icons', () => {
render(<SyncConsentModal {...defaultProps} />)
expect(screen.getByTestId('icon-trending-up')).toBeTruthy()
expect(screen.getByTestId('icon-fitness')).toBeTruthy()
expect(screen.getByTestId('icon-sync')).toBeTruthy()
expect(screen.getByTestId('icon-shield-checkmark')).toBeTruthy()
})
it('renders privacy note', () => {
render(<SyncConsentModal {...defaultProps} />)
expect(screen.getByText('sync.privacy')).toBeTruthy()
})
it('renders primary and secondary buttons', () => {
render(<SyncConsentModal {...defaultProps} />)
expect(screen.getByText('sync.primaryButton')).toBeTruthy()
expect(screen.getByText('sync.secondaryButton')).toBeTruthy()
})
it('calls onDecline when secondary button is pressed', () => {
render(<SyncConsentModal {...defaultProps} />)
fireEvent.press(screen.getByText('sync.secondaryButton'))
expect(defaultProps.onDecline).toHaveBeenCalledTimes(1)
})
it('calls onAccept when primary button is pressed', async () => {
render(<SyncConsentModal {...defaultProps} />)
await act(async () => {
fireEvent.press(screen.getByText('sync.primaryButton'))
})
expect(defaultProps.onAccept).toHaveBeenCalledTimes(1)
})
it('shows loading text while accepting', async () => {
let resolveAccept: () => void
const slowAccept = new Promise<void>((resolve) => {
resolveAccept = resolve
})
const onAccept = vi.fn(() => slowAccept)
render(<SyncConsentModal visible={true} onAccept={onAccept} onDecline={vi.fn()} />)
await act(async () => {
fireEvent.press(screen.getByText('sync.primaryButton'))
})
expect(screen.getByText('Setting up...')).toBeTruthy()
await act(async () => {
resolveAccept!()
})
})
it('does not render content when visible is false', () => {
render(<SyncConsentModal {...defaultProps} visible={false} />)
expect(screen.queryByText('sync.title')).toBeNull()
})
it('full modal structure snapshot', () => {
const { toJSON } = render(<SyncConsentModal {...defaultProps} />)
expect(toJSON()).toMatchSnapshot()
})
it('primary button shows disabled state while loading', async () => {
let resolveAccept: () => void
const slowAccept = new Promise<void>((resolve) => {
resolveAccept = resolve
})
const onAccept = vi.fn(() => slowAccept)
const { toJSON } = render(
<SyncConsentModal visible={true} onAccept={onAccept} onDecline={vi.fn()} />
)
await act(async () => {
fireEvent.press(screen.getByText('sync.primaryButton'))
})
// While loading, button text changes to loading state
expect(screen.getByText('Setting up...')).toBeTruthy()
// Verify the tree has the disabled styling applied (opacity: 0.6)
const tree = toJSON()
const treeStr = JSON.stringify(tree)
expect(treeStr).toContain('"opacity":0.6')
await act(async () => {
resolveAccept!()
})
})
})

View File

@@ -1,82 +0,0 @@
import { describe, it, expect } from 'vitest'
import React from 'react'
import { render } from '@testing-library/react-native'
import { VideoPlayer } from '@/src/shared/components/VideoPlayer'
describe('VideoPlayer rendering', () => {
describe('preview mode', () => {
it('renders gradient fallback when no videoUrl', () => {
const { toJSON } = render(
<VideoPlayer mode="preview" isPlaying={false} />
)
const tree = toJSON()
expect(tree).toBeTruthy()
expect(tree).toMatchSnapshot()
})
it('renders video view when videoUrl is provided', () => {
const { toJSON } = render(
<VideoPlayer
videoUrl="https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/master.m3u8"
mode="preview"
isPlaying={true}
/>
)
const tree = toJSON()
expect(tree).toBeTruthy()
expect(tree).toMatchSnapshot()
})
it('renders with custom style', () => {
const { toJSON } = render(
<VideoPlayer
mode="preview"
style={{ height: 220, borderRadius: 20 }}
/>
)
expect(toJSON()).toMatchSnapshot()
})
it('renders with testID prop', () => {
const { getByTestId } = render(
<VideoPlayer
mode="preview"
testID="my-video-player"
/>
)
expect(getByTestId('my-video-player')).toBeTruthy()
})
})
describe('background mode', () => {
it('renders gradient fallback when no videoUrl', () => {
const { toJSON } = render(
<VideoPlayer mode="background" isPlaying={false} />
)
expect(toJSON()).toMatchSnapshot()
})
it('renders video view when videoUrl is provided', () => {
const { toJSON } = render(
<VideoPlayer
videoUrl="https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/master.m3u8"
mode="background"
isPlaying={true}
/>
)
expect(toJSON()).toMatchSnapshot()
})
})
describe('custom gradient colors', () => {
it('renders with custom gradient colors when no video', () => {
const { toJSON } = render(
<VideoPlayer
gradientColors={['#FF0000', '#0000FF']}
mode="preview"
/>
)
expect(toJSON()).toMatchSnapshot()
})
})
})

View File

@@ -1,324 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CollectionCard > renders without onPress (no crash) 1`] = `
<Pressable
ref={null}
style={
{
"aspectRatio": 1,
"borderRadius": 20,
"overflow": "hidden",
"shadowColor": "#000",
"shadowOffset": {
"height": 2,
"width": 0,
},
"shadowOpacity": 0.25,
"shadowRadius": 4,
"width": 157.5,
}
}
>
<LinearGradient
colors={
[
"#FF6B35",
"#FF3B30",
]
}
end={
{
"x": 1,
"y": 1,
}
}
start={
{
"x": 0,
"y": 0,
}
}
style={
[
{
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
},
{
"borderRadius": 20,
},
]
}
testID="linear-gradient"
/>
<View
ref={null}
style={
{
"backgroundColor": "rgba(0,0,0,0.3)",
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
>
<BlurView
intensity={20}
style={
{
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
testID="blur-view"
tint="dark"
/>
</View>
<View
ref={null}
style={
{
"flex": 1,
"justifyContent": "flex-end",
"padding": 16,
}
}
>
<View
ref={null}
style={
{
"alignItems": "center",
"backgroundColor": "rgba(255,255,255,0.15)",
"borderColor": "rgba(255,255,255,0.2)",
"borderRadius": 14,
"borderWidth": 1,
"height": 48,
"justifyContent": "center",
"marginBottom": 12,
"width": 48,
}
}
>
<Text
ref={null}
style={
{
"fontSize": 24,
}
}
>
💪
</Text>
</View>
<Text
numberOfLines={2}
ref={null}
style={
[
{
"color": "#FFFFFF",
"fontSize": 17,
"fontWeight": "700",
},
{
"marginBottom": 4,
},
]
}
>
Upper Body Blast
</Text>
<Text
numberOfLines={1}
ref={null}
style={
[
{
"color": "rgba(255,255,255,0.7)",
"fontSize": 13,
"fontWeight": "500",
},
undefined,
]
}
>
3
workouts
</Text>
</View>
</Pressable>
`;
exports[`CollectionCard > snapshot with imageUrl (different rendering path) 1`] = `
<Pressable
ref={null}
style={
{
"aspectRatio": 1,
"borderRadius": 20,
"overflow": "hidden",
"shadowColor": "#000",
"shadowOffset": {
"height": 2,
"width": 0,
},
"shadowOpacity": 0.25,
"shadowRadius": 4,
"width": 157.5,
}
}
>
<ImageBackground
imageStyle={
{
"borderRadius": 20,
}
}
ref={null}
resizeMode="cover"
source={
{
"uri": "https://example.com/image.jpg",
}
}
style={
{
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
>
<LinearGradient
colors={
[
"transparent",
"rgba(0,0,0,0.8)",
]
}
style={
{
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
testID="linear-gradient"
/>
</ImageBackground>
<View
ref={null}
style={
{
"backgroundColor": "rgba(0,0,0,0.3)",
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
>
<BlurView
intensity={20}
style={
{
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
testID="blur-view"
tint="dark"
/>
</View>
<View
ref={null}
style={
{
"flex": 1,
"justifyContent": "flex-end",
"padding": 16,
}
}
>
<View
ref={null}
style={
{
"alignItems": "center",
"backgroundColor": "rgba(255,255,255,0.15)",
"borderColor": "rgba(255,255,255,0.2)",
"borderRadius": 14,
"borderWidth": 1,
"height": 48,
"justifyContent": "center",
"marginBottom": 12,
"width": 48,
}
}
>
<Text
ref={null}
style={
{
"fontSize": 24,
}
}
>
💪
</Text>
</View>
<Text
numberOfLines={2}
ref={null}
style={
[
{
"color": "#FFFFFF",
"fontSize": 17,
"fontWeight": "700",
},
{
"marginBottom": 4,
},
]
}
>
Upper Body Blast
</Text>
<Text
numberOfLines={1}
ref={null}
style={
[
{
"color": "rgba(255,255,255,0.7)",
"fontSize": 13,
"fontWeight": "500",
},
undefined,
]
}
>
3
workouts
</Text>
</View>
</Pressable>
`;

View File

@@ -1,283 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`GlassCard presets > GlassCardElevated snapshot 1`] = `
<View
ref={null}
style={
[
{
"borderRadius": 24,
"overflow": "hidden",
},
{
"backgroundColor": "rgba(255, 255, 255, 0.08)",
"borderColor": "rgba(255, 255, 255, 0.12)",
"borderWidth": 1,
},
{
"shadowColor": "#000",
"shadowOffset": {
"height": 2,
"width": 0,
},
"shadowOpacity": 0.25,
"shadowRadius": 4,
},
undefined,
]
}
>
<BlurView
intensity={40}
style={
{
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
testID="blur-view"
tint="dark"
/>
<View
ref={null}
style={
{
"flex": 1,
}
}
>
<Text
ref={null}
>
Elevated preset
</Text>
</View>
</View>
`;
exports[`GlassCard variants > renders base variant (snapshot) 1`] = `
<View
ref={null}
style={
[
{
"borderRadius": 24,
"overflow": "hidden",
},
{
"backgroundColor": "rgba(255, 255, 255, 0.05)",
"borderColor": "rgba(255, 255, 255, 0.1)",
"borderWidth": 1,
},
{
"shadowColor": "#000",
"shadowOffset": {
"height": 1,
"width": 0,
},
"shadowOpacity": 0.2,
"shadowRadius": 2,
},
undefined,
]
}
>
<BlurView
intensity={40}
style={
{
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
testID="blur-view"
tint="dark"
/>
<View
ref={null}
style={
{
"flex": 1,
}
}
>
<Text
ref={null}
>
Base
</Text>
</View>
</View>
`;
exports[`GlassCard variants > renders elevated variant (snapshot) 1`] = `
<View
ref={null}
style={
[
{
"borderRadius": 24,
"overflow": "hidden",
},
{
"backgroundColor": "rgba(255, 255, 255, 0.08)",
"borderColor": "rgba(255, 255, 255, 0.12)",
"borderWidth": 1,
},
{
"shadowColor": "#000",
"shadowOffset": {
"height": 2,
"width": 0,
},
"shadowOpacity": 0.25,
"shadowRadius": 4,
},
undefined,
]
}
>
<BlurView
intensity={40}
style={
{
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
testID="blur-view"
tint="dark"
/>
<View
ref={null}
style={
{
"flex": 1,
}
}
>
<Text
ref={null}
>
Elevated
</Text>
</View>
</View>
`;
exports[`GlassCard variants > renders inset variant (snapshot) 1`] = `
<View
ref={null}
style={
[
{
"borderRadius": 24,
"overflow": "hidden",
},
{
"backgroundColor": "rgba(0, 0, 0, 0.2)",
"borderColor": "rgba(255, 255, 255, 0.05)",
"borderWidth": 1,
},
{},
undefined,
]
}
>
<BlurView
intensity={40}
style={
{
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
testID="blur-view"
tint="dark"
/>
<View
ref={null}
style={
{
"flex": 1,
}
}
>
<Text
ref={null}
>
Inset
</Text>
</View>
</View>
`;
exports[`GlassCard variants > renders tinted variant (snapshot) 1`] = `
<View
ref={null}
style={
[
{
"borderRadius": 24,
"overflow": "hidden",
},
{
"backgroundColor": "rgba(255, 107, 53, 0.1)",
"borderColor": "rgba(255, 107, 53, 0.2)",
"borderWidth": 1,
},
{
"shadowColor": "#000",
"shadowOffset": {
"height": 1,
"width": 0,
},
"shadowOpacity": 0.2,
"shadowRadius": 2,
},
undefined,
]
}
>
<BlurView
intensity={40}
style={
{
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
testID="blur-view"
tint="dark"
/>
<View
ref={null}
style={
{
"flex": 1,
}
}
>
<Text
ref={null}
>
Tinted
</Text>
</View>
</View>
`;

View File

@@ -1,277 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`OnboardingStep > does not crash with step 0 (edge case snapshot) 1`] = `
<View
ref={null}
style={
[
{
"backgroundColor": "#000000",
"flex": 1,
},
{
"paddingTop": 59,
},
]
}
>
<View
ref={null}
style={
{
"backgroundColor": "#1C1C1E",
"borderRadius": 2,
"height": 3,
"marginHorizontal": 24,
"overflow": "hidden",
}
}
>
<Animated.View
ref={null}
style={
[
{
"backgroundColor": "#FF6B35",
"borderRadius": 2,
"height": "100%",
},
{
"width": {
"__isAnimatedInterpolation": true,
"inputRange": [
0,
1,
],
"interpolate": [Function],
"outputRange": [
"0%",
"100%",
],
},
},
]
}
/>
</View>
<Animated.View
ref={null}
style={
[
{
"flex": 1,
"paddingHorizontal": 24,
"paddingTop": 32,
},
{
"opacity": AnimatedValue {
"_listeners": Map {},
"_value": 0,
},
"transform": [
{
"translateX": AnimatedValue {
"_listeners": Map {},
"_value": 375,
},
},
],
},
{
"paddingBottom": 58,
},
]
}
>
<Text
ref={null}
>
Edge case
</Text>
</Animated.View>
</View>
`;
exports[`OnboardingStep > step 1 of 6 snapshot 1`] = `
<View
ref={null}
style={
[
{
"backgroundColor": "#000000",
"flex": 1,
},
{
"paddingTop": 59,
},
]
}
>
<View
ref={null}
style={
{
"backgroundColor": "#1C1C1E",
"borderRadius": 2,
"height": 3,
"marginHorizontal": 24,
"overflow": "hidden",
}
}
>
<Animated.View
ref={null}
style={
[
{
"backgroundColor": "#FF6B35",
"borderRadius": 2,
"height": "100%",
},
{
"width": {
"__isAnimatedInterpolation": true,
"inputRange": [
0,
1,
],
"interpolate": [Function],
"outputRange": [
"0%",
"100%",
],
},
},
]
}
/>
</View>
<Animated.View
ref={null}
style={
[
{
"flex": 1,
"paddingHorizontal": 24,
"paddingTop": 32,
},
{
"opacity": AnimatedValue {
"_listeners": Map {},
"_value": 0,
},
"transform": [
{
"translateX": AnimatedValue {
"_listeners": Map {},
"_value": 375,
},
},
],
},
{
"paddingBottom": 58,
},
]
}
>
<Text
ref={null}
>
First step
</Text>
</Animated.View>
</View>
`;
exports[`OnboardingStep > step 6 of 6 (final step) snapshot 1`] = `
<View
ref={null}
style={
[
{
"backgroundColor": "#000000",
"flex": 1,
},
{
"paddingTop": 59,
},
]
}
>
<View
ref={null}
style={
{
"backgroundColor": "#1C1C1E",
"borderRadius": 2,
"height": 3,
"marginHorizontal": 24,
"overflow": "hidden",
}
}
>
<Animated.View
ref={null}
style={
[
{
"backgroundColor": "#FF6B35",
"borderRadius": 2,
"height": "100%",
},
{
"width": {
"__isAnimatedInterpolation": true,
"inputRange": [
0,
1,
],
"interpolate": [Function],
"outputRange": [
"0%",
"100%",
],
},
},
]
}
/>
</View>
<Animated.View
ref={null}
style={
[
{
"flex": 1,
"paddingHorizontal": 24,
"paddingTop": 32,
},
{
"opacity": AnimatedValue {
"_listeners": Map {},
"_value": 0,
},
"transform": [
{
"translateX": AnimatedValue {
"_listeners": Map {},
"_value": 375,
},
},
],
},
{
"paddingBottom": 58,
},
]
}
>
<Text
ref={null}
>
Final step
</Text>
</Animated.View>
</View>
`;

View File

@@ -1,799 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CollectionCardSkeleton > renders correct structure (snapshot) 1`] = `
<View
ref={null}
style={
{
"alignItems": "center",
"padding": 16,
}
}
>
<View
ref={null}
style={
[
{
"overflow": "hidden",
},
{
"backgroundColor": undefined,
"borderRadius": 20,
"height": 120,
"width": 120,
},
undefined,
]
}
>
<Animated.View
ref={null}
style={
[
{
"backgroundColor": "rgba(255, 255, 255, 0.1)",
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
"width": 100,
},
{
"transform": [
{
"translateX": {
"__isAnimatedInterpolation": true,
"inputRange": [
0,
1,
],
"interpolate": [Function],
"outputRange": [
-200,
200,
],
},
},
],
},
]
}
/>
</View>
<View
ref={null}
style={
[
{
"overflow": "hidden",
},
{
"backgroundColor": undefined,
"borderRadius": 12,
"height": 18,
"width": "80%",
},
{
"marginTop": 12,
},
]
}
>
<Animated.View
ref={null}
style={
[
{
"backgroundColor": "rgba(255, 255, 255, 0.1)",
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
"width": 100,
},
{
"transform": [
{
"translateX": {
"__isAnimatedInterpolation": true,
"inputRange": [
0,
1,
],
"interpolate": [Function],
"outputRange": [
-200,
200,
],
},
},
],
},
]
}
/>
</View>
</View>
`;
exports[`Skeleton > renders with default dimensions (snapshot) 1`] = `
<View
ref={null}
style={
[
{
"overflow": "hidden",
},
{
"backgroundColor": undefined,
"borderRadius": 12,
"height": 20,
"width": "100%",
},
undefined,
]
}
>
<Animated.View
ref={null}
style={
[
{
"backgroundColor": "rgba(255, 255, 255, 0.1)",
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
"width": 100,
},
{
"transform": [
{
"translateX": {
"__isAnimatedInterpolation": true,
"inputRange": [
0,
1,
],
"interpolate": [Function],
"outputRange": [
-200,
200,
],
},
},
],
},
]
}
/>
</View>
`;
exports[`StatsCardSkeleton > renders correct structure (snapshot) 1`] = `
<View
ref={null}
style={
[
{
"borderRadius": 16,
"minWidth": 140,
"padding": 16,
},
{
"backgroundColor": "#1C1C1E",
},
]
}
>
<View
ref={null}
style={
{
"alignItems": "center",
"flexDirection": "row",
"justifyContent": "space-between",
}
}
>
<View
ref={null}
style={
[
{
"overflow": "hidden",
},
{
"backgroundColor": undefined,
"borderRadius": 12,
"height": 14,
"width": "60%",
},
undefined,
]
}
>
<Animated.View
ref={null}
style={
[
{
"backgroundColor": "rgba(255, 255, 255, 0.1)",
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
"width": 100,
},
{
"transform": [
{
"translateX": {
"__isAnimatedInterpolation": true,
"inputRange": [
0,
1,
],
"interpolate": [Function],
"outputRange": [
-200,
200,
],
},
},
],
},
]
}
/>
</View>
<View
ref={null}
style={
[
{
"overflow": "hidden",
},
{
"backgroundColor": undefined,
"borderRadius": 12,
"height": 24,
"width": 24,
},
undefined,
]
}
>
<Animated.View
ref={null}
style={
[
{
"backgroundColor": "rgba(255, 255, 255, 0.1)",
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
"width": 100,
},
{
"transform": [
{
"translateX": {
"__isAnimatedInterpolation": true,
"inputRange": [
0,
1,
],
"interpolate": [Function],
"outputRange": [
-200,
200,
],
},
},
],
},
]
}
/>
</View>
</View>
<View
ref={null}
style={
[
{
"overflow": "hidden",
},
{
"backgroundColor": undefined,
"borderRadius": 12,
"height": 32,
"width": "50%",
},
{
"marginTop": 8,
},
]
}
>
<Animated.View
ref={null}
style={
[
{
"backgroundColor": "rgba(255, 255, 255, 0.1)",
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
"width": 100,
},
{
"transform": [
{
"translateX": {
"__isAnimatedInterpolation": true,
"inputRange": [
0,
1,
],
"interpolate": [Function],
"outputRange": [
-200,
200,
],
},
},
],
},
]
}
/>
</View>
</View>
`;
exports[`TrainerCardSkeleton > renders correct structure (snapshot) 1`] = `
<View
ref={null}
style={
[
{
"alignItems": "center",
"borderRadius": 16,
"flexDirection": "row",
"marginBottom": 12,
"padding": 16,
},
{
"backgroundColor": "#1C1C1E",
},
]
}
>
<View
ref={null}
style={
[
{
"overflow": "hidden",
},
{
"backgroundColor": undefined,
"borderRadius": 40,
"height": 80,
"width": 80,
},
undefined,
]
}
>
<Animated.View
ref={null}
style={
[
{
"backgroundColor": "rgba(255, 255, 255, 0.1)",
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
"width": 100,
},
{
"transform": [
{
"translateX": {
"__isAnimatedInterpolation": true,
"inputRange": [
0,
1,
],
"interpolate": [Function],
"outputRange": [
-200,
200,
],
},
},
],
},
]
}
/>
</View>
<View
ref={null}
style={
{
"flex": 1,
"gap": 8,
"marginLeft": 16,
}
}
>
<View
ref={null}
style={
[
{
"overflow": "hidden",
},
{
"backgroundColor": undefined,
"borderRadius": 12,
"height": 18,
"width": "80%",
},
undefined,
]
}
>
<Animated.View
ref={null}
style={
[
{
"backgroundColor": "rgba(255, 255, 255, 0.1)",
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
"width": 100,
},
{
"transform": [
{
"translateX": {
"__isAnimatedInterpolation": true,
"inputRange": [
0,
1,
],
"interpolate": [Function],
"outputRange": [
-200,
200,
],
},
},
],
},
]
}
/>
</View>
<View
ref={null}
style={
[
{
"overflow": "hidden",
},
{
"backgroundColor": undefined,
"borderRadius": 12,
"height": 14,
"width": "60%",
},
undefined,
]
}
>
<Animated.View
ref={null}
style={
[
{
"backgroundColor": "rgba(255, 255, 255, 0.1)",
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
"width": 100,
},
{
"transform": [
{
"translateX": {
"__isAnimatedInterpolation": true,
"inputRange": [
0,
1,
],
"interpolate": [Function],
"outputRange": [
-200,
200,
],
},
},
],
},
]
}
/>
</View>
</View>
</View>
`;
exports[`WorkoutCardSkeleton > renders correct structure (snapshot) 1`] = `
<View
ref={null}
style={
[
{
"borderRadius": 16,
"marginBottom": 16,
"overflow": "hidden",
},
{
"backgroundColor": "#1C1C1E",
},
]
}
>
<View
ref={null}
style={
[
{
"overflow": "hidden",
},
{
"backgroundColor": undefined,
"borderRadius": 16,
"height": 160,
"width": "100%",
},
undefined,
]
}
>
<Animated.View
ref={null}
style={
[
{
"backgroundColor": "rgba(255, 255, 255, 0.1)",
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
"width": 100,
},
{
"transform": [
{
"translateX": {
"__isAnimatedInterpolation": true,
"inputRange": [
0,
1,
],
"interpolate": [Function],
"outputRange": [
-200,
200,
],
},
},
],
},
]
}
/>
</View>
<View
ref={null}
style={
{
"gap": 8,
"padding": 16,
}
}
>
<View
ref={null}
style={
[
{
"overflow": "hidden",
},
{
"backgroundColor": undefined,
"borderRadius": 12,
"height": 20,
"width": "70%",
},
undefined,
]
}
>
<Animated.View
ref={null}
style={
[
{
"backgroundColor": "rgba(255, 255, 255, 0.1)",
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
"width": 100,
},
{
"transform": [
{
"translateX": {
"__isAnimatedInterpolation": true,
"inputRange": [
0,
1,
],
"interpolate": [Function],
"outputRange": [
-200,
200,
],
},
},
],
},
]
}
/>
</View>
<View
ref={null}
style={
{
"flexDirection": "row",
"justifyContent": "space-between",
"marginTop": 8,
}
}
>
<View
ref={null}
style={
[
{
"overflow": "hidden",
},
{
"backgroundColor": undefined,
"borderRadius": 12,
"height": 16,
"width": "40%",
},
undefined,
]
}
>
<Animated.View
ref={null}
style={
[
{
"backgroundColor": "rgba(255, 255, 255, 0.1)",
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
"width": 100,
},
{
"transform": [
{
"translateX": {
"__isAnimatedInterpolation": true,
"inputRange": [
0,
1,
],
"interpolate": [Function],
"outputRange": [
-200,
200,
],
},
},
],
},
]
}
/>
</View>
<View
ref={null}
style={
[
{
"overflow": "hidden",
},
{
"backgroundColor": undefined,
"borderRadius": 12,
"height": 16,
"width": "30%",
},
undefined,
]
}
>
<Animated.View
ref={null}
style={
[
{
"backgroundColor": "rgba(255, 255, 255, 0.1)",
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
"width": 100,
},
{
"transform": [
{
"translateX": {
"__isAnimatedInterpolation": true,
"inputRange": [
0,
1,
],
"interpolate": [Function],
"outputRange": [
-200,
200,
],
},
},
],
},
]
}
/>
</View>
</View>
</View>
</View>
`;

View File

@@ -1,292 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`VideoPlayer rendering > background mode > renders gradient fallback when no videoUrl 1`] = `
<View
ref={null}
style={
[
{
"backgroundColor": "#000",
"overflow": "hidden",
},
undefined,
]
}
>
<LinearGradient
colors={
[
"#FF6B35",
"#E55A25",
]
}
end={
{
"x": 1,
"y": 1,
}
}
start={
{
"x": 0,
"y": 0,
}
}
style={
{
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
testID="linear-gradient"
/>
</View>
`;
exports[`VideoPlayer rendering > background mode > renders video view when videoUrl is provided 1`] = `
<View
ref={null}
style={
[
{
"backgroundColor": "#000",
"overflow": "hidden",
},
undefined,
]
}
>
<VideoView
contentFit="cover"
nativeControls={false}
player={
{
"currentTime": 0,
"duration": 100,
"muted": false,
"pause": [MockFunction],
"play": [MockFunction] {
"calls": [
[],
],
"results": [
{
"type": "return",
"value": undefined,
},
],
},
"playing": false,
"replace": [MockFunction],
"volume": 1,
}
}
style={
{
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
testID="video-view"
/>
</View>
`;
exports[`VideoPlayer rendering > custom gradient colors > renders with custom gradient colors when no video 1`] = `
<View
ref={null}
style={
[
{
"backgroundColor": "#000",
"overflow": "hidden",
},
undefined,
]
}
>
<LinearGradient
colors={
[
"#FF0000",
"#0000FF",
]
}
end={
{
"x": 1,
"y": 1,
}
}
start={
{
"x": 0,
"y": 0,
}
}
style={
{
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
testID="linear-gradient"
/>
</View>
`;
exports[`VideoPlayer rendering > preview mode > renders gradient fallback when no videoUrl 1`] = `
<View
ref={null}
style={
[
{
"backgroundColor": "#000",
"overflow": "hidden",
},
undefined,
]
}
>
<LinearGradient
colors={
[
"#FF6B35",
"#E55A25",
]
}
end={
{
"x": 1,
"y": 1,
}
}
start={
{
"x": 0,
"y": 0,
}
}
style={
{
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
testID="linear-gradient"
/>
</View>
`;
exports[`VideoPlayer rendering > preview mode > renders video view when videoUrl is provided 1`] = `
<View
ref={null}
style={
[
{
"backgroundColor": "#000",
"overflow": "hidden",
},
undefined,
]
}
>
<VideoView
contentFit="cover"
nativeControls={false}
player={
{
"currentTime": 0,
"duration": 100,
"muted": false,
"pause": [MockFunction],
"play": [MockFunction] {
"calls": [
[],
],
"results": [
{
"type": "return",
"value": undefined,
},
],
},
"playing": false,
"replace": [MockFunction],
"volume": 1,
}
}
style={
{
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
testID="video-view"
/>
</View>
`;
exports[`VideoPlayer rendering > preview mode > renders with custom style 1`] = `
<View
ref={null}
style={
[
{
"backgroundColor": "#000",
"overflow": "hidden",
},
{
"borderRadius": 20,
"height": 220,
},
]
}
>
<LinearGradient
colors={
[
"#FF6B35",
"#E55A25",
]
}
end={
{
"x": 1,
"y": 1,
}
}
start={
{
"x": 0,
"y": 0,
}
}
style={
{
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
testID="linear-gradient"
/>
</View>
`;

View File

@@ -1,181 +0,0 @@
/**
* Player feature unit tests
* Tests constants, getCoachMessage, and component prop contracts
*/
import { describe, it, expect } from 'vitest'
import {
TIMER_RING_SIZE,
TIMER_RING_STROKE,
COACH_MESSAGES,
getCoachMessage,
} from '../../features/player/constants'
describe('Player constants', () => {
describe('TIMER_RING_SIZE', () => {
it('should be a positive number', () => {
expect(TIMER_RING_SIZE).toBeGreaterThan(0)
expect(TIMER_RING_SIZE).toBe(280)
})
})
describe('TIMER_RING_STROKE', () => {
it('should be a positive number', () => {
expect(TIMER_RING_STROKE).toBeGreaterThan(0)
expect(TIMER_RING_STROKE).toBe(12)
})
it('should be smaller than half the ring size', () => {
expect(TIMER_RING_STROKE).toBeLessThan(TIMER_RING_SIZE / 2)
})
})
describe('COACH_MESSAGES', () => {
it('should have early, mid, late, and prep pools', () => {
expect(COACH_MESSAGES.early).toBeDefined()
expect(COACH_MESSAGES.mid).toBeDefined()
expect(COACH_MESSAGES.late).toBeDefined()
expect(COACH_MESSAGES.prep).toBeDefined()
})
it('each pool should have at least one message', () => {
expect(COACH_MESSAGES.early.length).toBeGreaterThan(0)
expect(COACH_MESSAGES.mid.length).toBeGreaterThan(0)
expect(COACH_MESSAGES.late.length).toBeGreaterThan(0)
expect(COACH_MESSAGES.prep.length).toBeGreaterThan(0)
})
it('all messages should be non-empty strings', () => {
const allMessages = [
...COACH_MESSAGES.early,
...COACH_MESSAGES.mid,
...COACH_MESSAGES.late,
...COACH_MESSAGES.prep,
]
for (const msg of allMessages) {
expect(typeof msg).toBe('string')
expect(msg.length).toBeGreaterThan(0)
}
})
})
})
describe('getCoachMessage', () => {
it('should return an early message for round 1 of 10', () => {
const msg = getCoachMessage(1, 10)
expect(COACH_MESSAGES.early).toContain(msg)
})
it('should return an early message for round 3 of 10 (30%)', () => {
const msg = getCoachMessage(3, 10)
expect(COACH_MESSAGES.early).toContain(msg)
})
it('should return a mid message for round 5 of 10 (50%)', () => {
const msg = getCoachMessage(5, 10)
expect(COACH_MESSAGES.mid).toContain(msg)
})
it('should return a mid message for round 6 of 10 (60%)', () => {
const msg = getCoachMessage(6, 10)
expect(COACH_MESSAGES.mid).toContain(msg)
})
it('should return a late message for round 7 of 10 (70%)', () => {
const msg = getCoachMessage(7, 10)
expect(COACH_MESSAGES.late).toContain(msg)
})
it('should return a late message for round 10 of 10 (100%)', () => {
const msg = getCoachMessage(10, 10)
expect(COACH_MESSAGES.late).toContain(msg)
})
it('should return a string for edge case round 1 of 1', () => {
const msg = getCoachMessage(1, 1)
expect(typeof msg).toBe('string')
expect(msg.length).toBeGreaterThan(0)
})
it('should not throw for very large round numbers', () => {
expect(() => getCoachMessage(100, 200)).not.toThrow()
const msg = getCoachMessage(100, 200)
expect(typeof msg).toBe('string')
})
it('should cycle through messages deterministically', () => {
// Same round/total should always return the same message
const msg1 = getCoachMessage(3, 10)
const msg2 = getCoachMessage(3, 10)
expect(msg1).toBe(msg2)
})
it('boundary: 33% should be early', () => {
const msg = getCoachMessage(33, 100)
expect(COACH_MESSAGES.early).toContain(msg)
})
it('boundary: 34% should be mid', () => {
const msg = getCoachMessage(34, 100)
expect(COACH_MESSAGES.mid).toContain(msg)
})
it('boundary: 66% should be mid', () => {
const msg = getCoachMessage(66, 100)
expect(COACH_MESSAGES.mid).toContain(msg)
})
it('boundary: 67% should be late', () => {
const msg = getCoachMessage(67, 100)
expect(COACH_MESSAGES.late).toContain(msg)
})
})
describe('Player barrel exports', () => {
// NOTE: Dynamically importing components triggers react-native/index.js
// parsing (Flow syntax) which Rolldown/Vite cannot handle. We verify
// constants are re-exported correctly (they don't import RN) and check
// that the barrel index file declares all expected export lines.
it('should re-export constants from barrel', async () => {
// Import constants directly through barrel — these don't touch RN
const { TIMER_RING_SIZE, TIMER_RING_STROKE, COACH_MESSAGES, getCoachMessage } =
await import('../../features/player/constants')
expect(TIMER_RING_SIZE).toBe(280)
expect(TIMER_RING_STROKE).toBe(12)
expect(COACH_MESSAGES).toBeDefined()
expect(typeof getCoachMessage).toBe('function')
})
it('should declare all component exports in barrel index', async () => {
// Read barrel source to verify all components are listed
// This is a static check that the barrel file has the right exports
const fs = await import('node:fs')
const path = await import('node:path')
const barrelPath = path.resolve(__dirname, '../../features/player/index.ts')
const barrelSource = fs.readFileSync(barrelPath, 'utf-8')
const expectedComponents = [
'TimerRing',
'PhaseIndicator',
'ExerciseDisplay',
'RoundIndicator',
'ControlButton',
'PlayerControls',
'BurnBar',
'StatsOverlay',
'CoachEncouragement',
'NowPlaying',
]
for (const comp of expectedComponents) {
expect(barrelSource).toContain(`export { ${comp} }`)
}
// Constants
expect(barrelSource).toContain('TIMER_RING_SIZE')
expect(barrelSource).toContain('TIMER_RING_STROKE')
expect(barrelSource).toContain('COACH_MESSAGES')
expect(barrelSource).toContain('getCoachMessage')
})
})

View File

@@ -1,219 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { Audio } from 'expo-av'
import { useUserStore } from '../../shared/stores/userStore'
vi.mock('expo-av', () => ({
Audio: {
Sound: {
createAsync: vi.fn().mockResolvedValue({
sound: {
playAsync: vi.fn(),
pauseAsync: vi.fn(),
stopAsync: vi.fn(),
unloadAsync: vi.fn(),
setPositionAsync: vi.fn(),
setVolumeAsync: vi.fn(),
},
status: { isLoaded: true },
}),
},
setAudioModeAsync: vi.fn(),
},
}))
describe('useAudio logic', () => {
beforeEach(() => {
vi.clearAllMocks()
useUserStore.setState({
settings: {
haptics: true,
soundEffects: true,
voiceCoaching: true,
musicEnabled: true,
musicVolume: 0.5,
reminders: false,
reminderTime: '09:00',
hasPromptedReview: false,
},
})
})
describe('audio mode configuration', () => {
it('should configure audio with correct settings', async () => {
const expectedConfig = {
playsInSilentModeIOS: true,
staysActiveInBackground: false,
shouldDuckAndroid: true,
}
await Audio.setAudioModeAsync(expectedConfig)
expect(Audio.setAudioModeAsync).toHaveBeenCalledWith(expectedConfig)
})
})
describe('sound creation', () => {
it('should create sound with createAsync', async () => {
const mockSound = {
playAsync: vi.fn(),
setPositionAsync: vi.fn(),
unloadAsync: vi.fn(),
}
vi.mocked(Audio.Sound.createAsync).mockResolvedValueOnce({
sound: mockSound,
status: { isLoaded: true },
} as any)
const result = await Audio.Sound.createAsync({} as any)
expect(result.sound).toBeDefined()
expect(result.status.isLoaded).toBe(true)
})
it('should handle sound creation failure gracefully', async () => {
vi.mocked(Audio.Sound.createAsync).mockRejectedValueOnce(new Error('Failed to load'))
await expect(Audio.Sound.createAsync({} as any)).rejects.toThrow('Failed to load')
})
})
describe('sound playback', () => {
const createSoundCallbacks = (soundEnabled: boolean) => {
const play = async (soundKey: string) => {
if (!soundEnabled) return
try {
const { sound } = await Audio.Sound.createAsync({} as any)
await sound.setPositionAsync(0)
await sound.playAsync()
} catch (error) {
// Handle error silently
}
}
return {
countdownBeep: () => play('countdown'),
phaseStart: () => play('phaseStart'),
workoutComplete: () => play('complete'),
}
}
describe('when sound enabled', () => {
it('should play countdown beep', async () => {
const mockSound = {
playAsync: vi.fn(),
setPositionAsync: vi.fn(),
unloadAsync: vi.fn(),
}
vi.mocked(Audio.Sound.createAsync).mockResolvedValueOnce({
sound: mockSound,
status: { isLoaded: true },
} as any)
const callbacks = createSoundCallbacks(true)
await callbacks.countdownBeep()
expect(Audio.Sound.createAsync).toHaveBeenCalled()
expect(mockSound.setPositionAsync).toHaveBeenCalledWith(0)
expect(mockSound.playAsync).toHaveBeenCalled()
})
it('should play phase start sound', async () => {
const mockSound = {
playAsync: vi.fn(),
setPositionAsync: vi.fn(),
unloadAsync: vi.fn(),
}
vi.mocked(Audio.Sound.createAsync).mockResolvedValueOnce({
sound: mockSound,
status: { isLoaded: true },
} as any)
const callbacks = createSoundCallbacks(true)
await callbacks.phaseStart()
expect(Audio.Sound.createAsync).toHaveBeenCalled()
expect(mockSound.playAsync).toHaveBeenCalled()
})
it('should play workout complete sound', async () => {
const mockSound = {
playAsync: vi.fn(),
setPositionAsync: vi.fn(),
unloadAsync: vi.fn(),
}
vi.mocked(Audio.Sound.createAsync).mockResolvedValueOnce({
sound: mockSound,
status: { isLoaded: true },
} as any)
const callbacks = createSoundCallbacks(true)
await callbacks.workoutComplete()
expect(Audio.Sound.createAsync).toHaveBeenCalled()
expect(mockSound.playAsync).toHaveBeenCalled()
})
})
describe('when sound disabled', () => {
it('should not play countdown beep', async () => {
const callbacks = createSoundCallbacks(false)
await callbacks.countdownBeep()
expect(Audio.Sound.createAsync).not.toHaveBeenCalled()
})
it('should not play phase start sound', async () => {
const callbacks = createSoundCallbacks(false)
await callbacks.phaseStart()
expect(Audio.Sound.createAsync).not.toHaveBeenCalled()
})
it('should not play workout complete sound', async () => {
const callbacks = createSoundCallbacks(false)
await callbacks.workoutComplete()
expect(Audio.Sound.createAsync).not.toHaveBeenCalled()
})
})
})
describe('sound cleanup', () => {
it('should unload sound on cleanup', async () => {
const mockUnload = vi.fn()
const mockSound = {
playAsync: vi.fn(),
setPositionAsync: vi.fn(),
unloadAsync: mockUnload,
}
vi.mocked(Audio.Sound.createAsync).mockResolvedValueOnce({
sound: mockSound,
status: { isLoaded: true },
} as any)
const { sound } = await Audio.Sound.createAsync({} as any)
await sound.unloadAsync()
expect(mockUnload).toHaveBeenCalled()
})
})
describe('error handling', () => {
it('should handle playback errors gracefully', async () => {
vi.mocked(Audio.Sound.createAsync).mockRejectedValueOnce(new Error('Playback failed'))
const play = async () => {
try {
await Audio.Sound.createAsync({} as any)
} catch {
// Silently handle
}
}
await expect(play()).resolves.not.toThrow()
})
})
})

View File

@@ -1,177 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import * as Haptics from 'expo-haptics'
import { useUserStore } from '../../shared/stores/userStore'
vi.mock('expo-haptics', () => ({
impactAsync: vi.fn(),
ImpactFeedbackStyle: {
Light: 'light',
Medium: 'medium',
Heavy: 'heavy',
},
notificationAsync: vi.fn(),
NotificationFeedbackType: {
Success: 'success',
Warning: 'warning',
Error: 'error',
},
selectionAsync: vi.fn(),
}))
describe('useHaptics logic', () => {
beforeEach(() => {
vi.clearAllMocks()
useUserStore.setState({
settings: {
haptics: true,
soundEffects: true,
voiceCoaching: true,
musicEnabled: true,
musicVolume: 0.5,
reminders: false,
reminderTime: '09:00',
hasPromptedReview: false,
},
})
})
describe('haptic feedback functions', () => {
const createHapticCallbacks = () => {
const hapticsEnabled = useUserStore.getState().settings.haptics
const phaseChange = () => {
if (!hapticsEnabled) return
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)
}
const buttonTap = () => {
if (!hapticsEnabled) return
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium)
}
const countdownTick = () => {
if (!hapticsEnabled) return
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)
}
const workoutComplete = () => {
if (!hapticsEnabled) return
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
}
const selection = () => {
if (!hapticsEnabled) return
Haptics.selectionAsync()
}
return { phaseChange, buttonTap, countdownTick, workoutComplete, selection }
}
describe('when haptics enabled', () => {
it('should call impactAsync with Heavy for phaseChange', () => {
const callbacks = createHapticCallbacks()
callbacks.phaseChange()
expect(Haptics.impactAsync).toHaveBeenCalledWith(Haptics.ImpactFeedbackStyle.Heavy)
})
it('should call impactAsync with Medium for buttonTap', () => {
const callbacks = createHapticCallbacks()
callbacks.buttonTap()
expect(Haptics.impactAsync).toHaveBeenCalledWith(Haptics.ImpactFeedbackStyle.Medium)
})
it('should call impactAsync with Light for countdownTick', () => {
const callbacks = createHapticCallbacks()
callbacks.countdownTick()
expect(Haptics.impactAsync).toHaveBeenCalledWith(Haptics.ImpactFeedbackStyle.Light)
})
it('should call notificationAsync with Success for workoutComplete', () => {
const callbacks = createHapticCallbacks()
callbacks.workoutComplete()
expect(Haptics.notificationAsync).toHaveBeenCalledWith(Haptics.NotificationFeedbackType.Success)
})
it('should call selectionAsync for selection', () => {
const callbacks = createHapticCallbacks()
callbacks.selection()
expect(Haptics.selectionAsync).toHaveBeenCalled()
})
})
describe('when haptics disabled', () => {
beforeEach(() => {
useUserStore.setState({
settings: {
haptics: false,
soundEffects: true,
voiceCoaching: true,
musicEnabled: true,
musicVolume: 0.5,
reminders: false,
reminderTime: '09:00',
hasPromptedReview: false,
},
})
})
it('should not call impactAsync for phaseChange', () => {
const callbacks = createHapticCallbacks()
callbacks.phaseChange()
expect(Haptics.impactAsync).not.toHaveBeenCalled()
})
it('should not call impactAsync for buttonTap', () => {
const callbacks = createHapticCallbacks()
callbacks.buttonTap()
expect(Haptics.impactAsync).not.toHaveBeenCalled()
})
it('should not call impactAsync for countdownTick', () => {
const callbacks = createHapticCallbacks()
callbacks.countdownTick()
expect(Haptics.impactAsync).not.toHaveBeenCalled()
})
it('should not call notificationAsync for workoutComplete', () => {
const callbacks = createHapticCallbacks()
callbacks.workoutComplete()
expect(Haptics.notificationAsync).not.toHaveBeenCalled()
})
it('should not call selectionAsync for selection', () => {
const callbacks = createHapticCallbacks()
callbacks.selection()
expect(Haptics.selectionAsync).not.toHaveBeenCalled()
})
})
})
describe('feedback style mapping', () => {
it('should map phase change to heavy impact', () => {
expect(Haptics.ImpactFeedbackStyle.Heavy).toBe('heavy')
})
it('should map button tap to medium impact', () => {
expect(Haptics.ImpactFeedbackStyle.Medium).toBe('medium')
})
it('should map countdown tick to light impact', () => {
expect(Haptics.ImpactFeedbackStyle.Light).toBe('light')
})
it('should map workout complete to success notification', () => {
expect(Haptics.NotificationFeedbackType.Success).toBe('success')
})
})
})

View File

@@ -1,214 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { Audio } from 'expo-av'
import { useUserStore } from '../../shared/stores/userStore'
import type { MusicTrack } from '../../shared/services/music'
import type { MusicVibe } from '../../shared/types'
const mockTracks: MusicTrack[] = [
{ id: '1', title: 'Energy Pulse', artist: 'Neon Dreams', duration: 240, url: '', vibe: 'electronic' },
{ id: '2', title: 'Cyber Sprint', artist: 'Digital Flux', duration: 180, url: '', vibe: 'electronic' },
{ id: '3', title: 'High Voltage', artist: 'Circuit Breakers', duration: 200, url: '', vibe: 'electronic' },
]
const mockHipHopTracks: MusicTrack[] = [
{ id: '4', title: 'Street Heat', artist: 'Urban Flow', duration: 210, url: '', vibe: 'hip-hop' },
]
function getRandomTrackIndex(tracks: MusicTrack[]): number {
if (tracks.length === 0) return -1
return Math.floor(Math.random() * tracks.length)
}
function getNextTrackIndex(currentIndex: number, tracksLength: number): number {
if (tracksLength <= 1) return 0
return (currentIndex + 1) % tracksLength
}
function clampVolume(volume: number): number {
return Math.max(0, Math.min(1, volume))
}
describe('useMusicPlayer', () => {
beforeEach(() => {
vi.clearAllMocks()
useUserStore.setState({
settings: {
haptics: true,
soundEffects: true,
voiceCoaching: true,
musicEnabled: true,
musicVolume: 0.5,
reminders: false,
reminderTime: '09:00',
hasPromptedReview: false,
},
})
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('audio mode configuration', () => {
it('should configure audio with correct settings', async () => {
const expectedConfig = {
playsInSilentModeIOS: true,
staysActiveInBackground: true,
shouldDuckAndroid: true,
interruptionModeIOS: 1,
interruptionModeAndroid: 1,
}
await Audio.setAudioModeAsync(expectedConfig)
expect(Audio.setAudioModeAsync).toHaveBeenCalledWith(expectedConfig)
})
})
describe('track selection', () => {
it('should return valid random track index', () => {
const index = getRandomTrackIndex(mockTracks)
expect(index).toBeGreaterThanOrEqual(0)
expect(index).toBeLessThan(mockTracks.length)
})
it('should return -1 for empty track list', () => {
const index = getRandomTrackIndex([])
expect(index).toBe(-1)
})
it('should cycle to next track', () => {
const nextIndex = getNextTrackIndex(0, 3)
expect(nextIndex).toBe(1)
})
it('should wrap around to first track', () => {
const nextIndex = getNextTrackIndex(2, 3)
expect(nextIndex).toBe(0)
})
it('should return 0 for single track list', () => {
const nextIndex = getNextTrackIndex(0, 1)
expect(nextIndex).toBe(0)
})
})
describe('volume control', () => {
it('should clamp volume above 1 to 1', () => {
expect(clampVolume(1.5)).toBe(1)
})
it('should clamp volume below 0 to 0', () => {
expect(clampVolume(-0.5)).toBe(0)
})
it('should keep valid volume unchanged', () => {
expect(clampVolume(0.7)).toBe(0.7)
})
it('should handle edge cases', () => {
expect(clampVolume(0)).toBe(0)
expect(clampVolume(1)).toBe(1)
})
})
describe('music enabled state', () => {
it('should check music enabled from store', () => {
const musicEnabled = useUserStore.getState().settings.musicEnabled
expect(musicEnabled).toBe(true)
})
it('should respect music disabled state', () => {
useUserStore.setState({
settings: {
...useUserStore.getState().settings,
musicEnabled: false,
},
})
const musicEnabled = useUserStore.getState().settings.musicEnabled
expect(musicEnabled).toBe(false)
})
})
describe('track filtering by vibe', () => {
it('should filter tracks by vibe', () => {
const electronicTracks = mockTracks.filter(t => t.vibe === 'electronic')
expect(electronicTracks).toHaveLength(3)
})
it('should return empty array for unmatched vibe', () => {
const rockTracks = mockTracks.filter(t => t.vibe === 'rock')
expect(rockTracks).toHaveLength(0)
})
})
describe('playback status', () => {
it('should create sound with correct initial status', async () => {
const mockSound = {
playAsync: vi.fn(),
pauseAsync: vi.fn(),
stopAsync: vi.fn(),
unloadAsync: vi.fn(),
getStatusAsync: vi.fn().mockResolvedValue({ isLoaded: true, isPlaying: false }),
setVolumeAsync: vi.fn(),
}
vi.mocked(Audio.Sound.createAsync).mockResolvedValueOnce({
sound: mockSound,
status: { isLoaded: true },
} as any)
const result = await Audio.Sound.createAsync({} as any, {
shouldPlay: false,
volume: 0.5,
isLooping: false,
})
expect(result.status.isLoaded).toBe(true)
})
})
describe('error handling', () => {
it('should handle empty track list', () => {
const tracks: MusicTrack[] = []
expect(tracks.length).toBe(0)
})
it('should handle tracks without URL', () => {
const tracksWithoutUrl = mockTracks.filter(t => !t.url)
expect(tracksWithoutUrl).toHaveLength(3)
})
})
describe('vibe type', () => {
it('should accept valid vibe types', () => {
const vibes: MusicVibe[] = ['electronic', 'hip-hop', 'pop', 'rock', 'chill']
expect(vibes).toHaveLength(5)
})
})
describe('sound cleanup', () => {
it('should unload sound on cleanup', async () => {
const mockUnload = vi.fn()
const mockSound = {
playAsync: vi.fn(),
pauseAsync: vi.fn(),
stopAsync: vi.fn(),
unloadAsync: mockUnload,
getStatusAsync: vi.fn().mockResolvedValue({ isLoaded: true }),
setVolumeAsync: vi.fn(),
}
vi.mocked(Audio.Sound.createAsync).mockResolvedValueOnce({
sound: mockSound,
status: { isLoaded: true },
} as any)
const { sound } = await Audio.Sound.createAsync({} as any)
await sound.unloadAsync()
expect(mockUnload).toHaveBeenCalled()
})
})
})

View File

@@ -1,190 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import * as Notifications from 'expo-notifications'
// Mock useUserStore before importing the hook
const mockUserStoreState = {
settings: {
reminders: false,
reminderTime: '09:00',
hasPromptedReview: false,
},
}
vi.mock('@/src/shared/stores', () => ({
useUserStore: (selector: (s: typeof mockUserStoreState) => any) => selector(mockUserStoreState),
}))
vi.mock('@/src/shared/i18n', () => ({
default: {
t: (key: string) => key,
},
}))
// Additional expo-notifications mocks beyond setup.ts
vi.mock('expo-notifications', () => ({
getPermissionsAsync: vi.fn(),
requestPermissionsAsync: vi.fn(),
scheduleNotificationAsync: vi.fn().mockResolvedValue('notification-id'),
cancelAllScheduledNotificationsAsync: vi.fn().mockResolvedValue(undefined),
cancelScheduledNotificationAsync: vi.fn(),
getAllScheduledNotificationsAsync: vi.fn().mockResolvedValue([]),
setNotificationHandler: vi.fn(),
SchedulableTriggerInputTypes: {
DAILY: 'daily',
},
}))
import { requestNotificationPermissions } from '../../shared/hooks/useNotifications'
describe('useNotifications', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUserStoreState.settings.reminders = false
mockUserStoreState.settings.reminderTime = '09:00'
})
describe('requestNotificationPermissions', () => {
it('should return true if permissions already granted', async () => {
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
expires: 'never',
granted: true,
canAskAgain: true,
} as any)
const result = await requestNotificationPermissions()
expect(result).toBe(true)
expect(Notifications.getPermissionsAsync).toHaveBeenCalled()
expect(Notifications.requestPermissionsAsync).not.toHaveBeenCalled()
})
it('should request permissions when not yet granted', async () => {
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'undetermined',
expires: 'never',
granted: false,
canAskAgain: true,
} as any)
vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue({
status: 'granted',
expires: 'never',
granted: true,
canAskAgain: true,
} as any)
const result = await requestNotificationPermissions()
expect(result).toBe(true)
expect(Notifications.requestPermissionsAsync).toHaveBeenCalled()
})
it('should return false when permissions denied', async () => {
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'denied',
expires: 'never',
granted: false,
canAskAgain: false,
} as any)
vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue({
status: 'denied',
expires: 'never',
granted: false,
canAskAgain: false,
} as any)
const result = await requestNotificationPermissions()
expect(result).toBe(false)
})
})
describe('scheduling logic', () => {
// We test the scheduleDaily and cancelAll functions through their
// observable effects since they're module-private. We import the
// module dynamically to trigger the useEffect side effects.
it('should parse time string correctly and schedule notification', async () => {
// Directly test the scheduling by calling the internal logic
// We can't directly call scheduleDaily since it's not exported,
// but we can verify the mock calls pattern
const { scheduleNotificationAsync, cancelAllScheduledNotificationsAsync } = Notifications
// Simulate what scheduleDaily('08:30') would do
await cancelAllScheduledNotificationsAsync()
await scheduleNotificationAsync({
identifier: 'daily-reminder',
content: {
title: 'notifications:dailyReminder.title',
body: 'notifications:dailyReminder.body',
sound: true,
},
trigger: {
type: Notifications.SchedulableTriggerInputTypes.DAILY,
hour: 8,
minute: 30,
},
})
expect(cancelAllScheduledNotificationsAsync).toHaveBeenCalled()
expect(scheduleNotificationAsync).toHaveBeenCalledWith(
expect.objectContaining({
identifier: 'daily-reminder',
content: expect.objectContaining({
sound: true,
}),
trigger: expect.objectContaining({
type: 'daily',
hour: 8,
minute: 30,
}),
})
)
})
it('should handle midnight time (00:00)', () => {
const time = '00:00'
const [hour, minute] = time.split(':').map(Number)
expect(hour).toBe(0)
expect(minute).toBe(0)
})
it('should handle evening time (23:59)', () => {
const time = '23:59'
const [hour, minute] = time.split(':').map(Number)
expect(hour).toBe(23)
expect(minute).toBe(59)
})
it('should handle typical morning time (09:00)', () => {
const time = '09:00'
const [hour, minute] = time.split(':').map(Number)
expect(hour).toBe(9)
expect(minute).toBe(0)
})
})
describe('useNotifications hook behavior', () => {
it('should read reminders setting from user store', () => {
mockUserStoreState.settings.reminders = true
mockUserStoreState.settings.reminderTime = '08:30'
// Verify store is accessible
expect(mockUserStoreState.settings.reminders).toBe(true)
expect(mockUserStoreState.settings.reminderTime).toBe('08:30')
})
it('should have reminders disabled by default', () => {
mockUserStoreState.settings.reminders = false
expect(mockUserStoreState.settings.reminders).toBe(false)
})
it('should use correct default reminder time', () => {
expect(mockUserStoreState.settings.reminderTime).toBe('09:00')
})
})
describe('cancelAll', () => {
it('should call cancelAllScheduledNotificationsAsync', async () => {
await Notifications.cancelAllScheduledNotificationsAsync()
expect(Notifications.cancelAllScheduledNotificationsAsync).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -1,240 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { useUserStore } from '../../shared/stores/userStore'
import { ENTITLEMENT_ID } from '../../shared/services/purchases'
import type { SubscriptionPlan } from '../../shared/types'
interface MockCustomerInfo {
entitlements: {
active: Record<string, { identifier: string; isActive: boolean }>
all: Record<string, unknown>
}
activeSubscriptions: string[]
allPurchasedProductIdentifiers: string[]
}
const mockCustomerInfoFree: MockCustomerInfo = {
entitlements: { active: {}, all: {} },
activeSubscriptions: [],
allPurchasedProductIdentifiers: [],
}
const mockCustomerInfoPremium: MockCustomerInfo = {
entitlements: {
active: {
[ENTITLEMENT_ID]: {
identifier: ENTITLEMENT_ID,
isActive: true,
},
},
all: {},
},
activeSubscriptions: ['tabatafit.premium.yearly'],
allPurchasedProductIdentifiers: ['tabatafit.premium.yearly'],
}
const mockCustomerInfoMonthly: MockCustomerInfo = {
entitlements: {
active: {
[ENTITLEMENT_ID]: {
identifier: ENTITLEMENT_ID,
isActive: true,
},
},
all: {},
},
activeSubscriptions: ['tabatafit.premium.monthly'],
allPurchasedProductIdentifiers: ['tabatafit.premium.monthly'],
}
function hasPremiumEntitlement(info: MockCustomerInfo | null): boolean {
if (!info) return false
return ENTITLEMENT_ID in info.entitlements.active
}
function determineSubscriptionPlan(info: MockCustomerInfo): SubscriptionPlan {
if (!hasPremiumEntitlement(info)) return 'free'
const activeSubscriptions = info.activeSubscriptions
if (activeSubscriptions.length === 0) return 'free'
const subId = activeSubscriptions[0].toLowerCase()
if (subId.includes('yearly') || subId.includes('annual')) {
return 'premium-yearly'
} else if (subId.includes('monthly')) {
return 'premium-monthly'
}
return 'premium-yearly'
}
describe('usePurchases', () => {
beforeEach(() => {
vi.clearAllMocks()
useUserStore.setState({
profile: {
name: 'Test User',
email: 'test@example.com',
joinDate: new Date().toISOString(),
subscription: 'free',
onboardingCompleted: true,
fitnessLevel: 'beginner',
goal: 'strength',
weeklyFrequency: 3,
barriers: [],
syncStatus: 'never-synced',
supabaseUserId: null,
savedWorkouts: [],
},
})
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('hasPremiumEntitlement', () => {
it('should return false for null customerInfo', () => {
expect(hasPremiumEntitlement(null)).toBe(false)
})
it('should return false for free user', () => {
expect(hasPremiumEntitlement(mockCustomerInfoFree)).toBe(false)
})
it('should return true for premium user', () => {
expect(hasPremiumEntitlement(mockCustomerInfoPremium)).toBe(true)
})
})
describe('determineSubscriptionPlan', () => {
it('should return free for user without entitlement', () => {
const plan = determineSubscriptionPlan(mockCustomerInfoFree)
expect(plan).toBe('free')
})
it('should return premium-yearly for annual subscription', () => {
const plan = determineSubscriptionPlan(mockCustomerInfoPremium)
expect(plan).toBe('premium-yearly')
})
it('should return premium-monthly for monthly subscription', () => {
const plan = determineSubscriptionPlan(mockCustomerInfoMonthly)
expect(plan).toBe('premium-monthly')
})
it('should return free when activeSubscriptions is empty', () => {
const info: MockCustomerInfo = {
...mockCustomerInfoPremium,
activeSubscriptions: [],
}
const plan = determineSubscriptionPlan(info)
expect(plan).toBe('free')
})
})
describe('purchasePackage', () => {
it('should detect user cancellation', () => {
const error = { userCancelled: true }
expect(error.userCancelled).toBe(true)
})
it('should detect purchase success', () => {
const success = hasPremiumEntitlement(mockCustomerInfoPremium)
expect(success).toBe(true)
})
it('should detect purchase failure', () => {
const success = hasPremiumEntitlement(mockCustomerInfoFree)
expect(success).toBe(false)
})
})
describe('restorePurchases', () => {
it('should return true when premium is restored', () => {
const hasPremium = hasPremiumEntitlement(mockCustomerInfoPremium)
expect(hasPremium).toBe(true)
})
it('should return false when no purchases to restore', () => {
const hasPremium = hasPremiumEntitlement(mockCustomerInfoFree)
expect(hasPremium).toBe(false)
})
})
describe('subscription sync to store', () => {
it('should sync premium-yearly to userStore', () => {
const plan = determineSubscriptionPlan(mockCustomerInfoPremium)
useUserStore.getState().setSubscription(plan)
expect(useUserStore.getState().profile.subscription).toBe('premium-yearly')
})
it('should sync premium-monthly to userStore', () => {
const plan = determineSubscriptionPlan(mockCustomerInfoMonthly)
useUserStore.getState().setSubscription(plan)
expect(useUserStore.getState().profile.subscription).toBe('premium-monthly')
})
it('should sync free to userStore', () => {
const plan = determineSubscriptionPlan(mockCustomerInfoFree)
useUserStore.getState().setSubscription(plan)
expect(useUserStore.getState().profile.subscription).toBe('free')
})
})
describe('package identification', () => {
it('should identify monthly package by identifier', () => {
const pkg = {
identifier: 'monthly',
product: { identifier: 'tabatafit.premium.monthly', priceString: '$9.99' },
}
const isMonthly = pkg.identifier === 'monthly'
expect(isMonthly).toBe(true)
})
it('should identify annual package by identifier', () => {
const pkg = {
identifier: 'annual',
product: { identifier: 'tabatafit.premium.yearly', priceString: '$79.99' },
}
const isAnnual = pkg.identifier === 'annual'
expect(isAnnual).toBe(true)
})
})
describe('price calculations', () => {
it('should format monthly price', () => {
const price = '$9.99'
expect(price).toBe('$9.99')
})
it('should format annual price', () => {
const price = '$79.99'
expect(price).toBe('$79.99')
})
it('should calculate annual savings', () => {
const monthlyPrice = 9.99
const annualPrice = 79.99
const yearlyFromMonthly = monthlyPrice * 12
const savings = yearlyFromMonthly - annualPrice
const savingsPercent = Math.round((savings / yearlyFromMonthly) * 100)
expect(savings).toBeCloseTo(39.89, 0)
expect(savingsPercent).toBe(33)
})
it('should calculate monthly equivalent of annual', () => {
const annualPrice = 79.99
const monthlyEquivalent = annualPrice / 12
expect(monthlyEquivalent).toBeCloseTo(6.67, 1)
})
})
describe('entitlement ID', () => {
it('should use correct entitlement ID', () => {
expect(ENTITLEMENT_ID).toBe('1000 Corp Pro')
})
})
})

View File

@@ -1,460 +0,0 @@
/**
* useTimer integration tests
*
* Tests the timer's phase-transition state machine by simulating interval ticks
* through the playerStore. Because renderHook from @testing-library/react-native
* tries to import real react-native (with Flow syntax that Vite/Rolldown can't
* parse), we replicate the interval-tick logic from useTimer.ts directly here
* and drive it with vi.advanceTimersByTime.
*
* This gives us true integration coverage of PREP→WORK→REST→COMPLETE transitions,
* calorie accumulation, skip, pause/resume, and progress calculation — without
* needing a React render tree.
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { usePlayerStore } from '../../shared/stores/playerStore'
import type { Workout } from '../../shared/types'
// ---------------------------------------------------------------------------
// Helpers that mirror the core useTimer logic (src/shared/hooks/useTimer.ts)
// ---------------------------------------------------------------------------
const mockWorkout: Workout = {
id: 'integration-test',
title: 'Integration Test Workout',
trainerId: 'trainer-1',
category: 'full-body',
level: 'Beginner',
duration: 4,
calories: 48,
rounds: 4,
prepTime: 3,
workTime: 5,
restTime: 3,
equipment: [],
musicVibe: 'electronic',
exercises: [
{ name: 'Jumping Jacks', duration: 5 },
{ name: 'Squats', duration: 5 },
{ name: 'Push-ups', duration: 5 },
{ name: 'High Knees', duration: 5 },
],
}
/** Replicates the setInterval tick logic from useTimer.ts */
function tick(workout: Workout): void {
const s = usePlayerStore.getState()
// Don't tick when paused or complete
if (s.isPaused || s.phase === 'COMPLETE') return
if (s.timeRemaining <= 0) {
if (s.phase === 'PREP') {
s.setPhase('WORK')
s.setTimeRemaining(workout.workTime)
} else if (s.phase === 'WORK') {
const caloriesPerRound = Math.round(workout.calories / workout.rounds)
s.addCalories(caloriesPerRound)
s.setPhase('REST')
s.setTimeRemaining(workout.restTime)
} else if (s.phase === 'REST') {
if (s.currentRound >= workout.rounds) {
s.setPhase('COMPLETE')
s.setTimeRemaining(0)
s.setRunning(false)
} else {
s.setPhase('WORK')
s.setTimeRemaining(workout.workTime)
s.setCurrentRound(s.currentRound + 1)
}
}
} else {
s.setTimeRemaining(s.timeRemaining - 1)
}
}
/** Replicates the skip logic from useTimer.ts */
function skip(workout: Workout): void {
const s = usePlayerStore.getState()
if (s.phase === 'PREP') {
s.setPhase('WORK')
s.setTimeRemaining(workout.workTime)
} else if (s.phase === 'WORK') {
s.setPhase('REST')
s.setTimeRemaining(workout.restTime)
} else if (s.phase === 'REST') {
if (s.currentRound >= workout.rounds) {
s.setPhase('COMPLETE')
s.setTimeRemaining(0)
s.setRunning(false)
} else {
s.setPhase('WORK')
s.setTimeRemaining(workout.workTime)
s.setCurrentRound(s.currentRound + 1)
}
}
}
function getPhaseDuration(phase: string, workout: Workout): number {
switch (phase) {
case 'PREP': return workout.prepTime
case 'WORK': return workout.workTime
case 'REST': return workout.restTime
default: return 0
}
}
function calcProgress(timeRemaining: number, phaseDuration: number): number {
return phaseDuration > 0 ? 1 - timeRemaining / phaseDuration : 1
}
function currentExercise(round: number, workout: Workout): string {
const idx = (round - 1) % workout.exercises.length
return workout.exercises[idx]?.name ?? ''
}
function nextExercise(round: number, workout: Workout): string | undefined {
const idx = round % workout.exercises.length
return workout.exercises[idx]?.name
}
/** Start an interval that calls tick() every 1 s (fake-timer aware) */
let intervalId: ReturnType<typeof setInterval> | null = null
function startInterval(workout: Workout): void {
stopInterval()
intervalId = setInterval(() => tick(workout), 1000)
}
function stopInterval(): void {
if (intervalId !== null) {
clearInterval(intervalId)
intervalId = null
}
}
/** Advance fake timers by `seconds` full seconds */
function advanceSeconds(seconds: number): void {
vi.advanceTimersByTime(seconds * 1000)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('useTimer integration', () => {
beforeEach(() => {
vi.useFakeTimers()
usePlayerStore.getState().reset()
usePlayerStore.getState().loadWorkout(mockWorkout)
})
afterEach(() => {
stopInterval()
vi.useRealTimers()
vi.clearAllMocks()
})
// ── Initial state ──────────────────────────────────────────────────────
describe('initial state', () => {
it('should initialize in PREP phase with correct time', () => {
const s = usePlayerStore.getState()
expect(s.phase).toBe('PREP')
expect(s.timeRemaining).toBe(mockWorkout.prepTime)
expect(s.currentRound).toBe(1)
expect(s.isRunning).toBe(false)
expect(s.isPaused).toBe(false)
expect(s.calories).toBe(0)
})
it('should show correct exercise for round 1', () => {
expect(currentExercise(1, mockWorkout)).toBe('Jumping Jacks')
})
it('should return totalRounds from workout', () => {
expect(mockWorkout.rounds).toBe(4)
})
it('should calculate progress as 0 at phase start', () => {
const s = usePlayerStore.getState()
const dur = getPhaseDuration(s.phase, mockWorkout)
expect(calcProgress(s.timeRemaining, dur)).toBe(0)
})
})
// ── Start / Pause / Resume ─────────────────────────────────────────────
describe('start / pause / resume', () => {
it('should start timer when start is called', () => {
const s = usePlayerStore.getState()
s.setRunning(true)
s.setPaused(false)
startInterval(mockWorkout)
expect(usePlayerStore.getState().isRunning).toBe(true)
expect(usePlayerStore.getState().isPaused).toBe(false)
})
it('should pause timer', () => {
const s = usePlayerStore.getState()
s.setRunning(true)
startInterval(mockWorkout)
s.setPaused(true)
expect(usePlayerStore.getState().isRunning).toBe(true)
expect(usePlayerStore.getState().isPaused).toBe(true)
})
it('should resume timer after pause', () => {
const s = usePlayerStore.getState()
s.setRunning(true)
startInterval(mockWorkout)
s.setPaused(true)
s.setPaused(false)
expect(usePlayerStore.getState().isPaused).toBe(false)
})
it('should stop and reset timer', () => {
const s = usePlayerStore.getState()
s.setRunning(true)
startInterval(mockWorkout)
advanceSeconds(2) // advance a bit
stopInterval()
usePlayerStore.getState().reset()
const after = usePlayerStore.getState()
expect(after.isRunning).toBe(false)
expect(after.phase).toBe('PREP')
expect(after.calories).toBe(0)
})
it('should not tick when paused', () => {
const s = usePlayerStore.getState()
s.setRunning(true)
startInterval(mockWorkout)
s.setPaused(true)
const timeBefore = usePlayerStore.getState().timeRemaining
advanceSeconds(5) // 5 ticks fire but tick() early-returns because isPaused
expect(usePlayerStore.getState().timeRemaining).toBe(timeBefore)
})
})
// ── Countdown & Phase Transitions ──────────────────────────────────────
describe('countdown', () => {
it('should decrement timeRemaining each second', () => {
const s = usePlayerStore.getState()
s.setRunning(true)
startInterval(mockWorkout)
const initial = usePlayerStore.getState().timeRemaining
advanceSeconds(1)
expect(usePlayerStore.getState().timeRemaining).toBe(initial - 1)
})
it('should transition from PREP to WORK when time expires', () => {
const s = usePlayerStore.getState()
s.setRunning(true)
startInterval(mockWorkout)
// PREP is 3s: tick at 1s→2, 2s→1, 3s→0, 4s triggers transition
advanceSeconds(mockWorkout.prepTime + 1)
const after = usePlayerStore.getState()
expect(after.phase).toBe('WORK')
expect(after.timeRemaining).toBeLessThanOrEqual(mockWorkout.workTime)
})
it('should transition from WORK to REST and add calories', () => {
const s = usePlayerStore.getState()
s.setRunning(true)
startInterval(mockWorkout)
// Through PREP (3s + 1 transition tick)
advanceSeconds(mockWorkout.prepTime + 1)
expect(usePlayerStore.getState().phase).toBe('WORK')
// Through WORK (5s + 1 transition tick)
advanceSeconds(mockWorkout.workTime + 1)
const after = usePlayerStore.getState()
expect(after.phase).toBe('REST')
expect(after.calories).toBeGreaterThan(0)
})
it('should advance rounds after REST phase', () => {
const s = usePlayerStore.getState()
s.setRunning(true)
startInterval(mockWorkout)
// PREP→WORK→REST→WORK(round 2)
// prep: 3+1, work: 5+1, rest: 3+1 = 14s
advanceSeconds(mockWorkout.prepTime + 1 + mockWorkout.workTime + 1 + mockWorkout.restTime + 1)
const after = usePlayerStore.getState()
expect(after.currentRound).toBeGreaterThanOrEqual(2)
expect(after.phase).not.toBe('COMPLETE')
})
})
// ── Workout Completion ─────────────────────────────────────────────────
describe('workout completion', () => {
it('should complete after all rounds', () => {
const s = usePlayerStore.getState()
s.setRunning(true)
startInterval(mockWorkout)
// Total = prep + (work + rest) * rounds + enough transition ticks
// Each phase needs +1 tick for the transition at 0
// PREP: 3+1 = 4
// Per round: WORK 5+1 + REST 3+1 = 10 (except last round REST→COMPLETE)
// 4 rounds × 10 + 4 (prep) = 44, add generous buffer
advanceSeconds(60)
const after = usePlayerStore.getState()
expect(after.phase).toBe('COMPLETE')
expect(after.isRunning).toBe(false)
})
it('should accumulate calories for all rounds', () => {
const s = usePlayerStore.getState()
s.setRunning(true)
startInterval(mockWorkout)
advanceSeconds(60)
const after = usePlayerStore.getState()
const caloriesPerRound = Math.round(mockWorkout.calories / mockWorkout.rounds)
expect(after.calories).toBe(caloriesPerRound * mockWorkout.rounds)
})
})
// ── Skip ───────────────────────────────────────────────────────────────
describe('skip', () => {
it('should skip from PREP to WORK', () => {
skip(mockWorkout)
const after = usePlayerStore.getState()
expect(after.phase).toBe('WORK')
expect(after.timeRemaining).toBe(mockWorkout.workTime)
})
it('should skip from WORK to REST', () => {
skip(mockWorkout) // PREP → WORK
skip(mockWorkout) // WORK → REST
const after = usePlayerStore.getState()
expect(after.phase).toBe('REST')
expect(after.timeRemaining).toBe(mockWorkout.restTime)
})
it('should skip from REST to next WORK round', () => {
skip(mockWorkout) // PREP → WORK
skip(mockWorkout) // WORK → REST
skip(mockWorkout) // REST → WORK (round 2)
const after = usePlayerStore.getState()
expect(after.phase).toBe('WORK')
expect(after.currentRound).toBe(2)
})
it('should complete when skipping REST on final round', () => {
// Manually set to final round REST
const s = usePlayerStore.getState()
s.setCurrentRound(mockWorkout.rounds)
s.setPhase('REST')
s.setTimeRemaining(mockWorkout.restTime)
skip(mockWorkout)
const after = usePlayerStore.getState()
expect(after.phase).toBe('COMPLETE')
expect(after.isRunning).toBe(false)
})
})
// ── Progress ───────────────────────────────────────────────────────────
describe('progress calculation', () => {
it('should be 0 at phase start', () => {
const s = usePlayerStore.getState()
const dur = getPhaseDuration(s.phase, mockWorkout)
expect(calcProgress(s.timeRemaining, dur)).toBe(0)
})
it('should increase as time counts down', () => {
const s = usePlayerStore.getState()
s.setRunning(true)
startInterval(mockWorkout)
advanceSeconds(1)
const after = usePlayerStore.getState()
const dur = getPhaseDuration(after.phase, mockWorkout)
const progress = calcProgress(after.timeRemaining, dur)
expect(progress).toBeGreaterThan(0)
expect(progress).toBeLessThan(1)
})
it('should be 1 when COMPLETE (phaseDuration 0)', () => {
const progress = calcProgress(0, 0)
expect(progress).toBe(1)
})
})
// ── Next Exercise ──────────────────────────────────────────────────────
describe('nextExercise', () => {
it('should return next exercise based on round', () => {
// Round 1 → next is index 1 = Squats
expect(nextExercise(1, mockWorkout)).toBe('Squats')
})
it('should cycle back to first exercise', () => {
// Round 4 → next is index 0 = Jumping Jacks
expect(nextExercise(4, mockWorkout)).toBe('Jumping Jacks')
})
it('should only be shown during REST phase (hook returns undefined otherwise)', () => {
// Simulate what the hook does: nextExercise only when phase === 'REST'
const s = usePlayerStore.getState()
const showNext = s.phase === 'REST' ? nextExercise(s.currentRound, mockWorkout) : undefined
expect(showNext).toBeUndefined() // phase is PREP
s.setPhase('REST')
const showNextRest = usePlayerStore.getState().phase === 'REST'
? nextExercise(usePlayerStore.getState().currentRound, mockWorkout)
: undefined
expect(showNextRest).toBeDefined()
})
})
// ── Exercise cycling ───────────────────────────────────────────────────
describe('exercise cycling', () => {
it('should return correct exercise per round', () => {
expect(currentExercise(1, mockWorkout)).toBe('Jumping Jacks')
expect(currentExercise(2, mockWorkout)).toBe('Squats')
expect(currentExercise(3, mockWorkout)).toBe('Push-ups')
expect(currentExercise(4, mockWorkout)).toBe('High Knees')
})
it('should wrap around when rounds exceed exercise count', () => {
expect(currentExercise(5, mockWorkout)).toBe('Jumping Jacks')
expect(currentExercise(8, mockWorkout)).toBe('High Knees')
})
})
})

View File

@@ -1,428 +0,0 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { usePlayerStore } from '../../shared/stores/playerStore'
import type { Workout } from '../../shared/types'
const mockWorkout: Workout = {
id: 'test-workout',
title: 'Test Workout',
trainerId: 'trainer-1',
category: 'full-body',
level: 'Beginner',
duration: 4,
calories: 48,
rounds: 8,
prepTime: 10,
workTime: 20,
restTime: 10,
equipment: [],
musicVibe: 'electronic',
exercises: [
{ name: 'Jumping Jacks', duration: 20 },
{ name: 'Squats', duration: 20 },
{ name: 'Push-ups', duration: 20 },
{ name: 'High Knees', duration: 20 },
],
}
function getExerciseForRound(round: number, exercises: typeof mockWorkout.exercises): string {
const index = (round - 1) % exercises.length
return exercises[index]?.name ?? ''
}
function getNextExercise(round: number, exercises: typeof mockWorkout.exercises): string | undefined {
const index = round % exercises.length
return exercises[index]?.name
}
function calculateProgress(timeRemaining: number, phaseDuration: number): number {
return phaseDuration > 0 ? 1 - timeRemaining / phaseDuration : 1
}
function getPhaseDuration(phase: string, workout: typeof mockWorkout): number {
switch (phase) {
case 'PREP': return workout.prepTime
case 'WORK': return workout.workTime
case 'REST': return workout.restTime
default: return 0
}
}
describe('useTimer', () => {
beforeEach(() => {
vi.useFakeTimers()
usePlayerStore.getState().reset()
})
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
describe('initialization', () => {
it('should have correct default values', () => {
const state = usePlayerStore.getState()
expect(state.phase).toBe('PREP')
expect(state.timeRemaining).toBe(10)
expect(state.currentRound).toBe(1)
expect(state.isPaused).toBe(false)
expect(state.isRunning).toBe(false)
expect(state.calories).toBe(0)
})
it('should load workout and set prepTime', () => {
usePlayerStore.getState().loadWorkout(mockWorkout)
const state = usePlayerStore.getState()
expect(state.workout).toEqual(mockWorkout)
expect(state.timeRemaining).toBe(mockWorkout.prepTime)
})
it('should return correct totalRounds from workout', () => {
const totalRounds = mockWorkout.rounds
expect(totalRounds).toBe(8)
})
it('should return correct currentExercise for round 1', () => {
const exercise = getExerciseForRound(1, mockWorkout.exercises)
expect(exercise).toBe('Jumping Jacks')
})
})
describe('progress calculation', () => {
it('should calculate progress as 0 at start', () => {
usePlayerStore.getState().loadWorkout(mockWorkout)
const state = usePlayerStore.getState()
const phaseDuration = getPhaseDuration(state.phase, mockWorkout)
const progress = calculateProgress(state.timeRemaining, phaseDuration)
expect(progress).toBe(0)
})
it('should calculate progress correctly mid-phase', () => {
usePlayerStore.getState().loadWorkout(mockWorkout)
usePlayerStore.getState().setTimeRemaining(5)
const state = usePlayerStore.getState()
const phaseDuration = getPhaseDuration(state.phase, mockWorkout)
const progress = calculateProgress(state.timeRemaining, phaseDuration)
expect(progress).toBe(0.5)
})
it('should calculate progress as 1 when time is 0', () => {
usePlayerStore.getState().loadWorkout(mockWorkout)
usePlayerStore.getState().setTimeRemaining(0)
const state = usePlayerStore.getState()
const phaseDuration = getPhaseDuration(state.phase, mockWorkout)
const progress = calculateProgress(state.timeRemaining, phaseDuration)
expect(progress).toBe(1)
})
})
describe('exercise tracking', () => {
it('should return correct exercise for each round', () => {
const expectedOrder = [
'Jumping Jacks', 'Squats', 'Push-ups', 'High Knees',
'Jumping Jacks', 'Squats', 'Push-ups', 'High Knees'
]
for (let i = 0; i < 8; i++) {
const round = i + 1
const exercise = getExerciseForRound(round, mockWorkout.exercises)
expect(exercise).toBe(expectedOrder[i])
}
})
it('should cycle through exercises continuously', () => {
expect(getExerciseForRound(1, mockWorkout.exercises)).toBe('Jumping Jacks')
expect(getExerciseForRound(5, mockWorkout.exercises)).toBe('Jumping Jacks')
expect(getExerciseForRound(9, mockWorkout.exercises)).toBe('Jumping Jacks')
})
})
describe('controls', () => {
describe('start', () => {
it('should set running and clear pause', () => {
const store = usePlayerStore.getState()
store.setRunning(true)
store.setPaused(false)
const state = usePlayerStore.getState()
expect(state.isRunning).toBe(true)
expect(state.isPaused).toBe(false)
})
})
describe('pause', () => {
it('should set paused state', () => {
const store = usePlayerStore.getState()
store.setRunning(true)
store.setPaused(true)
const state = usePlayerStore.getState()
expect(state.isRunning).toBe(true)
expect(state.isPaused).toBe(true)
})
})
describe('resume', () => {
it('should clear paused state', () => {
const store = usePlayerStore.getState()
store.setRunning(true)
store.setPaused(true)
store.setPaused(false)
const state = usePlayerStore.getState()
expect(state.isPaused).toBe(false)
})
})
describe('stop', () => {
it('should reset all state', () => {
const store = usePlayerStore.getState()
store.loadWorkout(mockWorkout)
store.setRunning(true)
store.addCalories(25)
store.setPhase('WORK')
store.reset()
const state = usePlayerStore.getState()
expect(state.isRunning).toBe(false)
expect(state.phase).toBe('PREP')
expect(state.calories).toBe(0)
})
})
})
describe('skip functionality', () => {
it('should skip from PREP to WORK', () => {
const store = usePlayerStore.getState()
store.loadWorkout(mockWorkout)
expect(store.phase).toBe('PREP')
store.setPhase('WORK')
store.setTimeRemaining(mockWorkout.workTime)
const state = usePlayerStore.getState()
expect(state.phase).toBe('WORK')
expect(state.timeRemaining).toBe(mockWorkout.workTime)
})
it('should skip from WORK to REST', () => {
const store = usePlayerStore.getState()
store.loadWorkout(mockWorkout)
store.setPhase('WORK')
store.setPhase('REST')
store.setTimeRemaining(mockWorkout.restTime)
const state = usePlayerStore.getState()
expect(state.phase).toBe('REST')
expect(state.timeRemaining).toBe(mockWorkout.restTime)
})
it('should skip from REST to next WORK round', () => {
const store = usePlayerStore.getState()
store.loadWorkout(mockWorkout)
store.setPhase('REST')
store.setCurrentRound(1)
store.setPhase('WORK')
store.setTimeRemaining(mockWorkout.workTime)
store.setCurrentRound(2)
const state = usePlayerStore.getState()
expect(state.phase).toBe('WORK')
expect(state.currentRound).toBe(2)
})
it('should complete workout when skipping REST on final round', () => {
const store = usePlayerStore.getState()
store.loadWorkout(mockWorkout)
store.setPhase('REST')
store.setCurrentRound(mockWorkout.rounds)
store.setPhase('COMPLETE')
store.setRunning(false)
const state = usePlayerStore.getState()
expect(state.phase).toBe('COMPLETE')
expect(state.isRunning).toBe(false)
})
})
describe('timer tick simulation', () => {
it('should decrement timeRemaining', () => {
const store = usePlayerStore.getState()
store.loadWorkout(mockWorkout)
const initial = store.timeRemaining
store.setTimeRemaining(initial - 1)
expect(usePlayerStore.getState().timeRemaining).toBe(initial - 1)
})
it('should transition from PREP to WORK when time expires', () => {
const store = usePlayerStore.getState()
store.loadWorkout(mockWorkout)
store.setRunning(true)
store.setTimeRemaining(0)
store.setPhase('WORK')
store.setTimeRemaining(mockWorkout.workTime)
const state = usePlayerStore.getState()
expect(state.phase).toBe('WORK')
expect(state.timeRemaining).toBe(mockWorkout.workTime)
})
it('should transition from WORK to REST and add calories', () => {
const store = usePlayerStore.getState()
store.loadWorkout(mockWorkout)
store.setPhase('WORK')
store.setRunning(true)
store.setTimeRemaining(0)
const caloriesPerRound = Math.round(mockWorkout.calories / mockWorkout.rounds)
store.addCalories(caloriesPerRound)
store.setPhase('REST')
store.setTimeRemaining(mockWorkout.restTime)
const state = usePlayerStore.getState()
expect(state.phase).toBe('REST')
expect(state.timeRemaining).toBe(mockWorkout.restTime)
const expectedCalories = Math.round(mockWorkout.calories / mockWorkout.rounds)
expect(state.calories).toBe(expectedCalories)
})
it('should transition from REST to WORK for next round', () => {
const store = usePlayerStore.getState()
store.loadWorkout(mockWorkout)
store.setPhase('REST')
store.setCurrentRound(1)
store.setRunning(true)
store.setTimeRemaining(0)
store.setPhase('WORK')
store.setTimeRemaining(mockWorkout.workTime)
store.setCurrentRound(2)
const state = usePlayerStore.getState()
expect(state.phase).toBe('WORK')
expect(state.currentRound).toBe(2)
})
it('should complete workout after final round', () => {
const store = usePlayerStore.getState()
store.loadWorkout(mockWorkout)
store.setPhase('REST')
store.setCurrentRound(mockWorkout.rounds)
store.setRunning(true)
store.setTimeRemaining(0)
store.setPhase('COMPLETE')
store.setTimeRemaining(0)
store.setRunning(false)
const state = usePlayerStore.getState()
expect(state.phase).toBe('COMPLETE')
expect(state.isRunning).toBe(false)
})
})
describe('pause/resume behavior', () => {
it('should not update timeRemaining when paused', () => {
const store = usePlayerStore.getState()
store.loadWorkout(mockWorkout)
store.setRunning(true)
store.setPaused(true)
const pausedTime = store.timeRemaining
vi.advanceTimersByTime(5000)
expect(usePlayerStore.getState().timeRemaining).toBe(pausedTime)
})
it('should resume timer when resumed', () => {
const store = usePlayerStore.getState()
store.loadWorkout(mockWorkout)
store.setRunning(true)
store.setPaused(false)
expect(store.isPaused).toBe(false)
})
})
describe('isComplete flag', () => {
it('should be false initially', () => {
const store = usePlayerStore.getState()
expect(store.phase === 'COMPLETE').toBe(false)
})
it('should be true when phase is COMPLETE', () => {
const store = usePlayerStore.getState()
store.setPhase('COMPLETE')
expect(usePlayerStore.getState().phase === 'COMPLETE').toBe(true)
})
})
describe('nextExercise', () => {
it('should return next exercise during REST phase', () => {
const nextExercise = getNextExercise(1, mockWorkout.exercises)
expect(nextExercise).toBe('Squats')
})
it('should cycle to first exercise after last', () => {
const nextExercise = getNextExercise(4, mockWorkout.exercises)
expect(nextExercise).toBe('Jumping Jacks')
})
})
describe('calorie tracking', () => {
it('should accumulate calories for each WORK phase completed', () => {
const store = usePlayerStore.getState()
const caloriesPerRound = Math.round(mockWorkout.calories / mockWorkout.rounds)
store.addCalories(caloriesPerRound)
expect(usePlayerStore.getState().calories).toBe(caloriesPerRound)
store.addCalories(caloriesPerRound)
expect(usePlayerStore.getState().calories).toBe(caloriesPerRound * 2)
})
})
describe('startedAt tracking', () => {
it('should set startedAt when first running', () => {
const store = usePlayerStore.getState()
const beforeSet = Date.now()
store.setRunning(true)
const state = usePlayerStore.getState()
expect(state.startedAt).toBeGreaterThanOrEqual(beforeSet)
})
it('should not update startedAt if already set', () => {
const store = usePlayerStore.getState()
store.setRunning(true)
const firstStartedAt = usePlayerStore.getState().startedAt
store.setRunning(false)
store.setRunning(true)
expect(usePlayerStore.getState().startedAt).toBe(firstStartedAt)
})
})
})

View File

@@ -1,24 +0,0 @@
/**
* Node.js --require preload script
*
* Patches Module._resolveFilename so that ANY require('react-native') call
* (including CJS requires from @testing-library/react-native's build files)
* gets redirected to our compiled mock at react-native.cjs.
*
* This runs before vitest starts, so it intercepts at the earliest possible point.
*/
'use strict'
const Module = require('module')
const path = require('path')
const mockPath = path.resolve(__dirname, 'react-native.cjs')
const originalResolveFilename = Module._resolveFilename
Module._resolveFilename = function (request, parent, isMain, options) {
// Intercept 'react-native' and any subpath like 'react-native/index'
if (request === 'react-native' || request.startsWith('react-native/')) {
return mockPath
}
return originalResolveFilename.call(this, request, parent, isMain, options)
}

View File

@@ -1,423 +0,0 @@
"use strict";
/**
* react-native mock for component rendering tests (vitest + jsdom)
*
* This file is used as a resolve.alias for 'react-native' in vitest.config.render.ts.
* It provides real React component implementations so @testing-library/react-native
* can render and query them in jsdom. The real react-native package cannot be loaded
* in Node 22 due to ESM/typeof issues.
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.LayoutAnimation = exports.I18nManager = exports.AccessibilityInfo = exports.NativeModules = exports.Appearance = exports.BackHandler = exports.Linking = exports.Keyboard = exports.AppState = exports.PixelRatio = exports.Easing = exports.Animated = exports.Alert = exports.Platform = exports.Dimensions = exports.StyleSheet = exports.FlatList = exports.Modal = exports.Pressable = exports.SectionList = exports.RefreshControl = exports.KeyboardAvoidingView = exports.StatusBar = exports.Switch = exports.TouchableWithoutFeedback = exports.TouchableHighlight = exports.TouchableOpacity = exports.ActivityIndicator = exports.SafeAreaView = exports.ScrollView = exports.ImageBackground = exports.Image = exports.TextInput = exports.Text = exports.View = void 0;
exports.useWindowDimensions = useWindowDimensions;
exports.PlatformColor = PlatformColor;
exports.useColorScheme = useColorScheme;
const react_1 = __importDefault(require("react"));
// ---------------------------------------------------------------------------
// Helper: create a simple host component that forwards props to a DOM element
// ---------------------------------------------------------------------------
function createMockComponent(name) {
const Component = react_1.default.forwardRef((props, ref) => {
const { children, testID, ...rest } = props;
return react_1.default.createElement(name, { ...rest, testID, 'data-testid': testID, ref }, children);
});
Component.displayName = name;
return Component;
}
// ---------------------------------------------------------------------------
// Core RN Components
// ---------------------------------------------------------------------------
exports.View = createMockComponent('View');
exports.Text = createMockComponent('Text');
exports.TextInput = createMockComponent('TextInput');
exports.Image = createMockComponent('Image');
exports.ImageBackground = createMockComponent('ImageBackground');
exports.ScrollView = createMockComponent('ScrollView');
exports.SafeAreaView = createMockComponent('SafeAreaView');
exports.ActivityIndicator = createMockComponent('ActivityIndicator');
exports.TouchableOpacity = createMockComponent('TouchableOpacity');
exports.TouchableHighlight = createMockComponent('TouchableHighlight');
exports.TouchableWithoutFeedback = createMockComponent('TouchableWithoutFeedback');
exports.Switch = createMockComponent('Switch');
exports.StatusBar = createMockComponent('StatusBar');
exports.KeyboardAvoidingView = createMockComponent('KeyboardAvoidingView');
exports.RefreshControl = createMockComponent('RefreshControl');
exports.SectionList = createMockComponent('SectionList');
// Pressable needs onPress / disabled support
exports.Pressable = react_1.default.forwardRef((props, ref) => {
const { children, testID, onPress, disabled, ...rest } = props;
return react_1.default.createElement('Pressable', {
...rest,
testID,
'data-testid': testID,
onClick: disabled ? undefined : onPress,
disabled,
onPress,
ref,
}, typeof children === 'function' ? children({ pressed: false }) : children);
});
exports.Pressable.displayName = 'Pressable';
// Modal
exports.Modal = react_1.default.forwardRef((props, ref) => {
const { children, visible, testID, onRequestClose, ...rest } = props;
if (!visible)
return null;
return react_1.default.createElement('Modal', { ...rest, testID, 'data-testid': testID, visible, onRequestClose, ref }, children);
});
exports.Modal.displayName = 'Modal';
// FlatList — simplified: just render items in a ScrollView-like wrapper
exports.FlatList = react_1.default.forwardRef((props, ref) => {
const { data, renderItem, keyExtractor, testID, ListHeaderComponent, ListFooterComponent, ListEmptyComponent, ...rest } = props;
const items = data?.map((item, index) => {
const key = keyExtractor ? keyExtractor(item, index) : String(index);
return react_1.default.createElement(react_1.default.Fragment, { key }, renderItem({ item, index, separators: {} }));
}) ?? [];
const children = [
ListHeaderComponent ? react_1.default.createElement(react_1.default.Fragment, { key: '__header' }, typeof ListHeaderComponent === 'function' ? react_1.default.createElement(ListHeaderComponent) : ListHeaderComponent) : null,
...(items.length === 0 && ListEmptyComponent
? [react_1.default.createElement(react_1.default.Fragment, { key: '__empty' }, typeof ListEmptyComponent === 'function' ? react_1.default.createElement(ListEmptyComponent) : ListEmptyComponent)]
: items),
ListFooterComponent ? react_1.default.createElement(react_1.default.Fragment, { key: '__footer' }, typeof ListFooterComponent === 'function' ? react_1.default.createElement(ListFooterComponent) : ListFooterComponent) : null,
].filter(Boolean);
return react_1.default.createElement('FlatList', { ...rest, testID, 'data-testid': testID, ref }, ...children);
});
exports.FlatList.displayName = 'FlatList';
// ---------------------------------------------------------------------------
// StyleSheet
// ---------------------------------------------------------------------------
const absoluteFillValue = {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
};
exports.StyleSheet = {
create: (styles) => styles,
flatten: (style) => {
if (Array.isArray(style)) {
return Object.assign({}, ...style.filter(Boolean).map((s) => exports.StyleSheet.flatten(s)));
}
return style ?? {};
},
absoluteFill: absoluteFillValue,
absoluteFillObject: absoluteFillValue,
hairlineWidth: 1,
compose: (a, b) => [a, b],
};
// ---------------------------------------------------------------------------
// Dimensions
// ---------------------------------------------------------------------------
const dimensionsValues = { width: 375, height: 812, scale: 2, fontScale: 1 };
exports.Dimensions = {
get: (_dim) => dimensionsValues,
addEventListener: () => ({ remove: () => { } }),
set: () => { },
};
// ---------------------------------------------------------------------------
// useWindowDimensions
// ---------------------------------------------------------------------------
function useWindowDimensions() {
return dimensionsValues;
}
// ---------------------------------------------------------------------------
// Platform
// ---------------------------------------------------------------------------
exports.Platform = {
OS: 'ios',
Version: 18,
isPad: false,
isTVOS: false,
isTV: false,
select: (obj) => obj.ios ?? obj.default,
constants: {
reactNativeVersion: { major: 0, minor: 81, patch: 5 },
},
};
// ---------------------------------------------------------------------------
// Alert
// ---------------------------------------------------------------------------
exports.Alert = {
alert: (() => { }),
};
// ---------------------------------------------------------------------------
// Animated
// ---------------------------------------------------------------------------
class AnimatedValue {
_value;
_listeners = new Map();
constructor(value = 0) {
this._value = value;
}
setValue(value) {
this._value = value;
}
setOffset(_offset) { }
flattenOffset() { }
extractOffset() { }
addListener(cb) {
const id = String(Math.random());
this._listeners.set(id, cb);
return id;
}
removeListener(id) {
this._listeners.delete(id);
}
removeAllListeners() {
this._listeners.clear();
}
stopAnimation(cb) {
cb?.(this._value);
}
resetAnimation(cb) {
cb?.(this._value);
}
interpolate(config) {
return {
...config,
__isAnimatedInterpolation: true,
interpolate: (c) => ({ ...c, __isAnimatedInterpolation: true }),
};
}
// Arithmetic methods for combined animations
__getValue() { return this._value; }
}
class AnimatedValueXY {
x;
y;
constructor(value) {
this.x = new AnimatedValue(value?.x ?? 0);
this.y = new AnimatedValue(value?.y ?? 0);
}
setValue(value) {
this.x.setValue(value.x);
this.y.setValue(value.y);
}
setOffset(offset) {
this.x.setOffset(offset.x);
this.y.setOffset(offset.y);
}
flattenOffset() {
this.x.flattenOffset();
this.y.flattenOffset();
}
extractOffset() {
this.x.extractOffset();
this.y.extractOffset();
}
stopAnimation(cb) {
this.x.stopAnimation();
this.y.stopAnimation();
cb?.({ x: this.x._value, y: this.y._value });
}
addListener() { return ''; }
removeListener() { }
removeAllListeners() { }
getLayout() {
return { left: this.x, top: this.y };
}
getTranslateTransform() {
return [{ translateX: this.x }, { translateY: this.y }];
}
}
const mockAnimationResult = { start: (cb) => cb?.({ finished: true }), stop: () => { }, reset: () => { } };
exports.Animated = {
Value: AnimatedValue,
ValueXY: AnimatedValueXY,
View: createMockComponent('Animated.View'),
Text: createMockComponent('Animated.Text'),
Image: createMockComponent('Animated.Image'),
ScrollView: createMockComponent('Animated.ScrollView'),
FlatList: createMockComponent('Animated.FlatList'),
SectionList: createMockComponent('Animated.SectionList'),
createAnimatedComponent: (comp) => comp,
timing: (_value, _config) => mockAnimationResult,
spring: (_value, _config) => mockAnimationResult,
decay: (_value, _config) => mockAnimationResult,
parallel: (_animations) => mockAnimationResult,
sequence: (_animations) => mockAnimationResult,
stagger: (_delay, _animations) => mockAnimationResult,
delay: (_time) => mockAnimationResult,
loop: (_animation, _config) => mockAnimationResult,
event: (_argMapping, _config) => () => { },
add: (a, b) => a,
subtract: (a, b) => a,
divide: (a, b) => a,
multiply: (a, b) => a,
diffClamp: (a, _min, _max) => a,
};
// ---------------------------------------------------------------------------
// Easing
// ---------------------------------------------------------------------------
exports.Easing = {
linear: (t) => t,
ease: (t) => t,
quad: (t) => t * t,
cubic: (t) => t * t * t,
poly: (_n) => (t) => t,
sin: (t) => t,
circle: (t) => t,
exp: (t) => t,
elastic: (_bounciness) => (t) => t,
back: (_s) => (t) => t,
bounce: (t) => t,
bezier: (_x1, _y1, _x2, _y2) => (t) => t,
in: (fn) => fn,
out: (fn) => fn,
inOut: (fn) => fn,
step0: (t) => t,
step1: (t) => t,
};
// ---------------------------------------------------------------------------
// PixelRatio
// ---------------------------------------------------------------------------
exports.PixelRatio = {
get: () => 2,
getFontScale: () => 1,
getPixelSizeForLayoutSize: (size) => size * 2,
roundToNearestPixel: (size) => Math.round(size * 2) / 2,
};
// ---------------------------------------------------------------------------
// AppState
// ---------------------------------------------------------------------------
exports.AppState = {
currentState: 'active',
addEventListener: () => ({ remove: () => { } }),
removeEventListener: () => { },
};
// ---------------------------------------------------------------------------
// Keyboard
// ---------------------------------------------------------------------------
exports.Keyboard = {
addListener: () => ({ remove: () => { } }),
removeListener: () => { },
dismiss: () => { },
isVisible: () => false,
metrics: () => undefined,
};
// ---------------------------------------------------------------------------
// Linking
// ---------------------------------------------------------------------------
exports.Linking = {
openURL: async () => { },
canOpenURL: async () => true,
getInitialURL: async () => null,
addEventListener: () => ({ remove: () => { } }),
};
// ---------------------------------------------------------------------------
// BackHandler
// ---------------------------------------------------------------------------
exports.BackHandler = {
addEventListener: () => ({ remove: () => { } }),
removeEventListener: () => { },
exitApp: () => { },
};
// ---------------------------------------------------------------------------
// Appearance
// ---------------------------------------------------------------------------
exports.Appearance = {
getColorScheme: () => 'dark',
addChangeListener: () => ({ remove: () => { } }),
setColorScheme: () => { },
};
// ---------------------------------------------------------------------------
// PlatformColor (no-op stub)
// ---------------------------------------------------------------------------
function PlatformColor(..._args) {
return '';
}
// ---------------------------------------------------------------------------
// NativeModules
// ---------------------------------------------------------------------------
exports.NativeModules = {};
// ---------------------------------------------------------------------------
// AccessibilityInfo
// ---------------------------------------------------------------------------
exports.AccessibilityInfo = {
isScreenReaderEnabled: async () => false,
addEventListener: () => ({ remove: () => { } }),
setAccessibilityFocus: () => { },
announceForAccessibility: () => { },
isReduceMotionEnabled: async () => false,
isBoldTextEnabled: async () => false,
isGrayscaleEnabled: async () => false,
isInvertColorsEnabled: async () => false,
prefersCrossFadeTransitions: async () => false,
};
// ---------------------------------------------------------------------------
// I18nManager
// ---------------------------------------------------------------------------
exports.I18nManager = {
isRTL: false,
doLeftAndRightSwapInRTL: true,
allowRTL: () => { },
forceRTL: () => { },
swapLeftAndRightInRTL: () => { },
};
// ---------------------------------------------------------------------------
// LayoutAnimation
// ---------------------------------------------------------------------------
exports.LayoutAnimation = {
configureNext: () => { },
create: () => ({}),
Types: { spring: 'spring', linear: 'linear', easeInEaseOut: 'easeInEaseOut', easeIn: 'easeIn', easeOut: 'easeOut' },
Properties: { opacity: 'opacity', scaleX: 'scaleX', scaleY: 'scaleY', scaleXY: 'scaleXY' },
Presets: {
easeInEaseOut: {},
linear: {},
spring: {},
},
};
// ---------------------------------------------------------------------------
// useColorScheme
// ---------------------------------------------------------------------------
function useColorScheme() {
return 'dark';
}
// ---------------------------------------------------------------------------
// Default export (some code does `import RN from 'react-native'`)
// ---------------------------------------------------------------------------
const RN = {
View: exports.View,
Text: exports.Text,
TextInput: exports.TextInput,
Image: exports.Image,
ImageBackground: exports.ImageBackground,
ScrollView: exports.ScrollView,
FlatList: exports.FlatList,
SectionList: exports.SectionList,
SafeAreaView: exports.SafeAreaView,
ActivityIndicator: exports.ActivityIndicator,
TouchableOpacity: exports.TouchableOpacity,
TouchableHighlight: exports.TouchableHighlight,
TouchableWithoutFeedback: exports.TouchableWithoutFeedback,
Pressable: exports.Pressable,
Modal: exports.Modal,
Switch: exports.Switch,
StatusBar: exports.StatusBar,
KeyboardAvoidingView: exports.KeyboardAvoidingView,
RefreshControl: exports.RefreshControl,
StyleSheet: exports.StyleSheet,
Dimensions: exports.Dimensions,
Platform: exports.Platform,
Alert: exports.Alert,
Animated: exports.Animated,
Easing: exports.Easing,
PixelRatio: exports.PixelRatio,
AppState: exports.AppState,
Keyboard: exports.Keyboard,
Linking: exports.Linking,
BackHandler: exports.BackHandler,
Appearance: exports.Appearance,
PlatformColor,
NativeModules: exports.NativeModules,
AccessibilityInfo: exports.AccessibilityInfo,
I18nManager: exports.I18nManager,
LayoutAnimation: exports.LayoutAnimation,
useColorScheme,
useWindowDimensions,
};
exports.default = RN;

View File

@@ -1,458 +0,0 @@
/**
* react-native mock for component rendering tests (vitest + jsdom)
*
* This file is used as a resolve.alias for 'react-native' in vitest.config.render.ts.
* It provides real React component implementations so @testing-library/react-native
* can render and query them in jsdom. The real react-native package cannot be loaded
* in Node 22 due to ESM/typeof issues.
*/
import React from 'react'
// ---------------------------------------------------------------------------
// Helper: create a simple host component that forwards props to a DOM element
// ---------------------------------------------------------------------------
function createMockComponent(name: string) {
const Component = React.forwardRef((props: any, ref: any) => {
const { children, testID, ...rest } = props
return React.createElement(
name,
{ ...rest, testID, 'data-testid': testID, ref },
children
)
})
Component.displayName = name
return Component
}
// ---------------------------------------------------------------------------
// Core RN Components
// ---------------------------------------------------------------------------
export const View = createMockComponent('View')
export const Text = createMockComponent('Text')
export const TextInput = createMockComponent('TextInput')
export const Image = createMockComponent('Image')
export const ImageBackground = createMockComponent('ImageBackground')
export const ScrollView = createMockComponent('ScrollView')
export const SafeAreaView = createMockComponent('SafeAreaView')
export const ActivityIndicator = createMockComponent('ActivityIndicator')
export const TouchableOpacity = createMockComponent('TouchableOpacity')
export const TouchableHighlight = createMockComponent('TouchableHighlight')
export const TouchableWithoutFeedback = createMockComponent('TouchableWithoutFeedback')
export const Switch = createMockComponent('Switch')
export const StatusBar = createMockComponent('StatusBar')
export const KeyboardAvoidingView = createMockComponent('KeyboardAvoidingView')
export const RefreshControl = createMockComponent('RefreshControl')
export const SectionList = createMockComponent('SectionList')
// Pressable needs onPress / disabled support
export const Pressable = React.forwardRef((props: any, ref: any) => {
const { children, testID, onPress, disabled, ...rest } = props
return React.createElement(
'Pressable',
{
...rest,
testID,
'data-testid': testID,
onClick: disabled ? undefined : onPress,
disabled,
onPress,
ref,
},
typeof children === 'function' ? children({ pressed: false }) : children
)
})
;(Pressable as any).displayName = 'Pressable'
// Modal
export const Modal = React.forwardRef((props: any, ref: any) => {
const { children, visible, testID, onRequestClose, ...rest } = props
if (!visible) return null
return React.createElement(
'Modal',
{ ...rest, testID, 'data-testid': testID, visible, onRequestClose, ref },
children
)
})
;(Modal as any).displayName = 'Modal'
// FlatList — simplified: just render items in a ScrollView-like wrapper
export const FlatList = React.forwardRef((props: any, ref: any) => {
const { data, renderItem, keyExtractor, testID, ListHeaderComponent, ListFooterComponent, ListEmptyComponent, ...rest } = props
const items = data?.map((item: any, index: number) => {
const key = keyExtractor ? keyExtractor(item, index) : String(index)
return React.createElement(React.Fragment, { key }, renderItem({ item, index, separators: {} }))
}) ?? []
const children = [
ListHeaderComponent ? React.createElement(React.Fragment, { key: '__header' }, typeof ListHeaderComponent === 'function' ? React.createElement(ListHeaderComponent) : ListHeaderComponent) : null,
...(items.length === 0 && ListEmptyComponent
? [React.createElement(React.Fragment, { key: '__empty' }, typeof ListEmptyComponent === 'function' ? React.createElement(ListEmptyComponent) : ListEmptyComponent)]
: items),
ListFooterComponent ? React.createElement(React.Fragment, { key: '__footer' }, typeof ListFooterComponent === 'function' ? React.createElement(ListFooterComponent) : ListFooterComponent) : null,
].filter(Boolean)
return React.createElement('FlatList', { ...rest, testID, 'data-testid': testID, ref }, ...children)
})
;(FlatList as any).displayName = 'FlatList'
// ---------------------------------------------------------------------------
// StyleSheet
// ---------------------------------------------------------------------------
const absoluteFillValue = {
position: 'absolute' as const,
left: 0,
right: 0,
top: 0,
bottom: 0,
}
export const StyleSheet = {
create: <T extends Record<string, any>>(styles: T): T => styles,
flatten: (style: any): any => {
if (Array.isArray(style)) {
return Object.assign({}, ...style.filter(Boolean).map((s: any) => StyleSheet.flatten(s)))
}
return style ?? {}
},
absoluteFill: absoluteFillValue,
absoluteFillObject: absoluteFillValue,
hairlineWidth: 1,
compose: (a: any, b: any) => [a, b],
}
// ---------------------------------------------------------------------------
// Dimensions
// ---------------------------------------------------------------------------
const dimensionsValues = { width: 375, height: 812, scale: 2, fontScale: 1 }
export const Dimensions = {
get: (_dim?: string) => dimensionsValues,
addEventListener: () => ({ remove: () => {} }),
set: () => {},
}
// ---------------------------------------------------------------------------
// useWindowDimensions
// ---------------------------------------------------------------------------
export function useWindowDimensions() {
return dimensionsValues
}
// ---------------------------------------------------------------------------
// Platform
// ---------------------------------------------------------------------------
export const Platform = {
OS: 'ios' as const,
Version: 18,
isPad: false,
isTVOS: false,
isTV: false,
select: (obj: any) => obj.ios ?? obj.default,
constants: {
reactNativeVersion: { major: 0, minor: 81, patch: 5 },
},
}
// ---------------------------------------------------------------------------
// Alert
// ---------------------------------------------------------------------------
export const Alert = {
alert: (() => {}) as any,
}
// ---------------------------------------------------------------------------
// Animated
// ---------------------------------------------------------------------------
class AnimatedValue {
_value: number
_listeners: Map<string, Function> = new Map()
constructor(value: number = 0) {
this._value = value
}
setValue(value: number) {
this._value = value
}
setOffset(_offset: number) {}
flattenOffset() {}
extractOffset() {}
addListener(cb: Function) {
const id = String(Math.random())
this._listeners.set(id, cb)
return id
}
removeListener(id: string) {
this._listeners.delete(id)
}
removeAllListeners() {
this._listeners.clear()
}
stopAnimation(cb?: Function) {
cb?.(this._value)
}
resetAnimation(cb?: Function) {
cb?.(this._value)
}
interpolate(config: any) {
return {
...config,
__isAnimatedInterpolation: true,
interpolate: (c: any) => ({ ...c, __isAnimatedInterpolation: true }),
}
}
// Arithmetic methods for combined animations
__getValue() { return this._value }
}
class AnimatedValueXY {
x: AnimatedValue
y: AnimatedValue
constructor(value?: { x?: number; y?: number }) {
this.x = new AnimatedValue(value?.x ?? 0)
this.y = new AnimatedValue(value?.y ?? 0)
}
setValue(value: { x: number; y: number }) {
this.x.setValue(value.x)
this.y.setValue(value.y)
}
setOffset(offset: { x: number; y: number }) {
this.x.setOffset(offset.x)
this.y.setOffset(offset.y)
}
flattenOffset() {
this.x.flattenOffset()
this.y.flattenOffset()
}
extractOffset() {
this.x.extractOffset()
this.y.extractOffset()
}
stopAnimation(cb?: Function) {
this.x.stopAnimation()
this.y.stopAnimation()
cb?.({ x: this.x._value, y: this.y._value })
}
addListener() { return '' }
removeListener() {}
removeAllListeners() {}
getLayout() {
return { left: this.x, top: this.y }
}
getTranslateTransform() {
return [{ translateX: this.x }, { translateY: this.y }]
}
}
const mockAnimationResult = { start: (cb?: Function) => cb?.({ finished: true }), stop: () => {}, reset: () => {} }
export const Animated = {
Value: AnimatedValue,
ValueXY: AnimatedValueXY,
View: createMockComponent('Animated.View'),
Text: createMockComponent('Animated.Text'),
Image: createMockComponent('Animated.Image'),
ScrollView: createMockComponent('Animated.ScrollView'),
FlatList: createMockComponent('Animated.FlatList'),
SectionList: createMockComponent('Animated.SectionList'),
createAnimatedComponent: (comp: any) => comp,
timing: (_value: any, _config: any) => mockAnimationResult,
spring: (_value: any, _config: any) => mockAnimationResult,
decay: (_value: any, _config: any) => mockAnimationResult,
parallel: (_animations: any[]) => mockAnimationResult,
sequence: (_animations: any[]) => mockAnimationResult,
stagger: (_delay: number, _animations: any[]) => mockAnimationResult,
delay: (_time: number) => mockAnimationResult,
loop: (_animation: any, _config?: any) => mockAnimationResult,
event: (_argMapping: any[], _config?: any) => () => {},
add: (a: any, b: any) => a,
subtract: (a: any, b: any) => a,
divide: (a: any, b: any) => a,
multiply: (a: any, b: any) => a,
diffClamp: (a: any, _min: number, _max: number) => a,
}
// ---------------------------------------------------------------------------
// Easing
// ---------------------------------------------------------------------------
export const Easing = {
linear: (t: number) => t,
ease: (t: number) => t,
quad: (t: number) => t * t,
cubic: (t: number) => t * t * t,
poly: (_n: number) => (t: number) => t,
sin: (t: number) => t,
circle: (t: number) => t,
exp: (t: number) => t,
elastic: (_bounciness?: number) => (t: number) => t,
back: (_s?: number) => (t: number) => t,
bounce: (t: number) => t,
bezier: (_x1: number, _y1: number, _x2: number, _y2: number) => (t: number) => t,
in: (fn: Function) => fn,
out: (fn: Function) => fn,
inOut: (fn: Function) => fn,
step0: (t: number) => t,
step1: (t: number) => t,
}
// ---------------------------------------------------------------------------
// PixelRatio
// ---------------------------------------------------------------------------
export const PixelRatio = {
get: () => 2,
getFontScale: () => 1,
getPixelSizeForLayoutSize: (size: number) => size * 2,
roundToNearestPixel: (size: number) => Math.round(size * 2) / 2,
}
// ---------------------------------------------------------------------------
// AppState
// ---------------------------------------------------------------------------
export const AppState = {
currentState: 'active',
addEventListener: () => ({ remove: () => {} }),
removeEventListener: () => {},
}
// ---------------------------------------------------------------------------
// Keyboard
// ---------------------------------------------------------------------------
export const Keyboard = {
addListener: () => ({ remove: () => {} }),
removeListener: () => {},
dismiss: () => {},
isVisible: () => false,
metrics: () => undefined,
}
// ---------------------------------------------------------------------------
// Linking
// ---------------------------------------------------------------------------
export const Linking = {
openURL: async () => {},
canOpenURL: async () => true,
getInitialURL: async () => null,
addEventListener: () => ({ remove: () => {} }),
}
// ---------------------------------------------------------------------------
// BackHandler
// ---------------------------------------------------------------------------
export const BackHandler = {
addEventListener: () => ({ remove: () => {} }),
removeEventListener: () => {},
exitApp: () => {},
}
// ---------------------------------------------------------------------------
// Appearance
// ---------------------------------------------------------------------------
export const Appearance = {
getColorScheme: () => 'dark',
addChangeListener: () => ({ remove: () => {} }),
setColorScheme: () => {},
}
// ---------------------------------------------------------------------------
// PlatformColor (no-op stub)
// ---------------------------------------------------------------------------
export function PlatformColor(..._args: string[]) {
return ''
}
// ---------------------------------------------------------------------------
// NativeModules
// ---------------------------------------------------------------------------
export const NativeModules = {}
// ---------------------------------------------------------------------------
// AccessibilityInfo
// ---------------------------------------------------------------------------
export const AccessibilityInfo = {
isScreenReaderEnabled: async () => false,
addEventListener: () => ({ remove: () => {} }),
setAccessibilityFocus: () => {},
announceForAccessibility: () => {},
isReduceMotionEnabled: async () => false,
isBoldTextEnabled: async () => false,
isGrayscaleEnabled: async () => false,
isInvertColorsEnabled: async () => false,
prefersCrossFadeTransitions: async () => false,
}
// ---------------------------------------------------------------------------
// I18nManager
// ---------------------------------------------------------------------------
export const I18nManager = {
isRTL: false,
doLeftAndRightSwapInRTL: true,
allowRTL: () => {},
forceRTL: () => {},
swapLeftAndRightInRTL: () => {},
}
// ---------------------------------------------------------------------------
// LayoutAnimation
// ---------------------------------------------------------------------------
export const LayoutAnimation = {
configureNext: () => {},
create: () => ({}),
Types: { spring: 'spring', linear: 'linear', easeInEaseOut: 'easeInEaseOut', easeIn: 'easeIn', easeOut: 'easeOut' },
Properties: { opacity: 'opacity', scaleX: 'scaleX', scaleY: 'scaleY', scaleXY: 'scaleXY' },
Presets: {
easeInEaseOut: {},
linear: {},
spring: {},
},
}
// ---------------------------------------------------------------------------
// useColorScheme
// ---------------------------------------------------------------------------
export function useColorScheme() {
return 'dark'
}
// ---------------------------------------------------------------------------
// Default export (some code does `import RN from 'react-native'`)
// ---------------------------------------------------------------------------
const RN = {
View,
Text,
TextInput,
Image,
ImageBackground,
ScrollView,
FlatList,
SectionList,
SafeAreaView,
ActivityIndicator,
TouchableOpacity,
TouchableHighlight,
TouchableWithoutFeedback,
Pressable,
Modal,
Switch,
StatusBar,
KeyboardAvoidingView,
RefreshControl,
StyleSheet,
Dimensions,
Platform,
Alert,
Animated,
Easing,
PixelRatio,
AppState,
Keyboard,
Linking,
BackHandler,
Appearance,
PlatformColor,
NativeModules,
AccessibilityInfo,
I18nManager,
LayoutAnimation,
useColorScheme,
useWindowDimensions,
}
export default RN

View File

@@ -1,123 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
FREE_WORKOUT_IDS,
FREE_WORKOUT_COUNT,
isFreeWorkout,
canAccessWorkout,
} from '../../shared/services/access'
describe('access service', () => {
describe('FREE_WORKOUT_IDS', () => {
it('should contain exactly 3 free workout IDs', () => {
expect(FREE_WORKOUT_IDS).toHaveLength(3)
})
it('should include workout 1 (Full Body Ignite)', () => {
expect(FREE_WORKOUT_IDS).toContain('1')
})
it('should include workout 11 (Core Crusher)', () => {
expect(FREE_WORKOUT_IDS).toContain('11')
})
it('should include workout 43 (Dance Cardio)', () => {
expect(FREE_WORKOUT_IDS).toContain('43')
})
it('should be readonly (immutable)', () => {
// TypeScript enforces readonly at compile time; at runtime we verify the array content is stable
const snapshot = [...FREE_WORKOUT_IDS]
expect(FREE_WORKOUT_IDS).toEqual(snapshot)
})
})
describe('FREE_WORKOUT_COUNT', () => {
it('should equal the length of FREE_WORKOUT_IDS', () => {
expect(FREE_WORKOUT_COUNT).toBe(FREE_WORKOUT_IDS.length)
})
it('should be 3', () => {
expect(FREE_WORKOUT_COUNT).toBe(3)
})
})
describe('isFreeWorkout', () => {
it('should return true for free workout ID "1"', () => {
expect(isFreeWorkout('1')).toBe(true)
})
it('should return true for free workout ID "11"', () => {
expect(isFreeWorkout('11')).toBe(true)
})
it('should return true for free workout ID "43"', () => {
expect(isFreeWorkout('43')).toBe(true)
})
it('should return false for non-free workout ID "2"', () => {
expect(isFreeWorkout('2')).toBe(false)
})
it('should return false for non-free workout ID "10"', () => {
expect(isFreeWorkout('10')).toBe(false)
})
it('should return false for non-free workout ID "44"', () => {
expect(isFreeWorkout('44')).toBe(false)
})
it('should return false for empty string', () => {
expect(isFreeWorkout('')).toBe(false)
})
it('should return false for non-existent ID', () => {
expect(isFreeWorkout('999')).toBe(false)
})
})
describe('canAccessWorkout', () => {
describe('premium user', () => {
it('should access free workout', () => {
expect(canAccessWorkout('1', true)).toBe(true)
})
it('should access non-free workout', () => {
expect(canAccessWorkout('2', true)).toBe(true)
})
it('should access any workout ID', () => {
expect(canAccessWorkout('999', true)).toBe(true)
})
})
describe('free user', () => {
it('should access free workout ID "1"', () => {
expect(canAccessWorkout('1', false)).toBe(true)
})
it('should access free workout ID "11"', () => {
expect(canAccessWorkout('11', false)).toBe(true)
})
it('should access free workout ID "43"', () => {
expect(canAccessWorkout('43', false)).toBe(true)
})
it('should NOT access non-free workout ID "2"', () => {
expect(canAccessWorkout('2', false)).toBe(false)
})
it('should NOT access non-free workout ID "10"', () => {
expect(canAccessWorkout('10', false)).toBe(false)
})
it('should NOT access non-free workout ID "50"', () => {
expect(canAccessWorkout('50', false)).toBe(false)
})
it('should NOT access empty string ID', () => {
expect(canAccessWorkout('', false)).toBe(false)
})
})
})
})

View File

@@ -1,147 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
vi.mock('posthog-react-native', () => {
const mockPostHog = {
capture: vi.fn(),
identify: vi.fn(),
setPersonProperties: vi.fn(),
startSessionRecording: vi.fn(),
stopSessionRecording: vi.fn(),
isSessionReplayActive: vi.fn().mockResolvedValue(true),
}
return {
default: vi.fn().mockImplementation(() => mockPostHog),
}
})
describe('analytics service', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('initializeAnalytics', () => {
it('should initialize PostHog client', async () => {
const { initializeAnalytics } = await import('../../shared/services/analytics')
const client = await initializeAnalytics()
expect(client).toBeDefined()
})
it('should return existing client if already initialized', async () => {
const { initializeAnalytics } = await import('../../shared/services/analytics')
const client1 = await initializeAnalytics()
const client2 = await initializeAnalytics()
expect(client1).toBe(client2)
})
})
describe('getPostHogClient', () => {
it('should return null before initialization', async () => {
vi.resetModules()
const { getPostHogClient } = await import('../../shared/services/analytics')
expect(getPostHogClient()).toBeNull()
})
it('should return client after initialization', async () => {
vi.resetModules()
const { initializeAnalytics, getPostHogClient } = await import('../../shared/services/analytics')
await initializeAnalytics()
expect(getPostHogClient()).toBeDefined()
})
})
describe('track', () => {
it('should track event with properties', async () => {
const { initializeAnalytics, track } = await import('../../shared/services/analytics')
await initializeAnalytics()
track('test_event', { prop1: 'value1' })
})
it('should track event without properties', async () => {
const { initializeAnalytics, track } = await import('../../shared/services/analytics')
await initializeAnalytics()
track('test_event')
})
})
describe('trackScreen', () => {
it('should track screen view', async () => {
const { initializeAnalytics, trackScreen } = await import('../../shared/services/analytics')
await initializeAnalytics()
trackScreen('home', { source: 'tab' })
})
})
describe('identifyUser', () => {
it('should identify user with traits', async () => {
const { initializeAnalytics, identifyUser } = await import('../../shared/services/analytics')
await initializeAnalytics()
identifyUser('user-123', { name: 'Test User' })
})
it('should identify user without traits', async () => {
const { initializeAnalytics, identifyUser } = await import('../../shared/services/analytics')
await initializeAnalytics()
identifyUser('user-123')
})
})
describe('setUserProperties', () => {
it('should set user properties', async () => {
const { initializeAnalytics, setUserProperties } = await import('../../shared/services/analytics')
await initializeAnalytics()
setUserProperties({ plan: 'pro', workouts: 10 })
})
})
describe('session recording', () => {
it('should start session recording', async () => {
const { initializeAnalytics, startSessionRecording } = await import('../../shared/services/analytics')
await initializeAnalytics()
startSessionRecording()
})
it('should stop session recording', async () => {
const { initializeAnalytics, stopSessionRecording } = await import('../../shared/services/analytics')
await initializeAnalytics()
stopSessionRecording()
})
it('should check session replay status', async () => {
const { initializeAnalytics, isSessionReplayActive } = await import('../../shared/services/analytics')
await initializeAnalytics()
const isActive = await isSessionReplayActive()
expect(typeof isActive).toBe('boolean')
})
})
})

View File

@@ -1,157 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { musicService, MusicTrack } from '../../shared/services/music'
import type { MusicVibe } from '../../shared/types'
vi.mock('../../shared/supabase', () => ({
isSupabaseConfigured: () => false,
supabase: {},
}))
describe('music service', () => {
const vibes: MusicVibe[] = ['electronic', 'hip-hop', 'pop', 'rock', 'chill']
beforeEach(() => {
vi.clearAllMocks()
musicService.clearCache()
})
describe('loadTracksForVibe', () => {
it('should return mock tracks when Supabase not configured', async () => {
const tracks = await musicService.loadTracksForVibe('electronic')
expect(tracks).toBeDefined()
expect(tracks.length).toBeGreaterThan(0)
})
it('should return tracks for each vibe', async () => {
for (const vibe of vibes) {
const tracks = await musicService.loadTracksForVibe(vibe)
expect(tracks).toBeDefined()
expect(tracks.length).toBeGreaterThan(0)
}
})
it('should cache tracks after first load', async () => {
const tracks1 = await musicService.loadTracksForVibe('electronic')
const tracks2 = await musicService.loadTracksForVibe('electronic')
expect(tracks1).toBe(tracks2)
})
it('should return tracks with correct vibe property', async () => {
for (const vibe of vibes) {
const tracks = await musicService.loadTracksForVibe(vibe)
tracks.forEach(track => {
expect(track.vibe).toBe(vibe)
})
}
})
it('should return tracks with required properties', async () => {
const tracks = await musicService.loadTracksForVibe('electronic')
tracks.forEach(track => {
expect(track.id).toBeDefined()
expect(track.title).toBeDefined()
expect(track.artist).toBeDefined()
expect(track.duration).toBeDefined()
expect(track.url).toBeDefined()
expect(track.vibe).toBeDefined()
})
})
})
describe('clearCache', () => {
it('should clear specific vibe cache', async () => {
await musicService.loadTracksForVibe('electronic')
await musicService.loadTracksForVibe('hip-hop')
musicService.clearCache('electronic')
const tracks = await musicService.loadTracksForVibe('hip-hop')
expect(tracks).toBeDefined()
})
it('should clear all cache when no vibe specified', async () => {
await musicService.loadTracksForVibe('electronic')
await musicService.loadTracksForVibe('hip-hop')
musicService.clearCache()
expect(musicService).toBeDefined()
})
})
describe('getRandomTrack', () => {
it('should return null for empty array', () => {
const track = musicService.getRandomTrack([])
expect(track).toBeNull()
})
it('should return a track from the array', () => {
const tracks = [
{ id: '1', title: 'Track 1', artist: 'Artist 1', duration: 180, url: '', vibe: 'electronic' as MusicVibe },
{ id: '2', title: 'Track 2', artist: 'Artist 2', duration: 200, url: '', vibe: 'electronic' as MusicVibe },
]
const track = musicService.getRandomTrack(tracks)
expect(track).not.toBeNull()
expect(['1', '2']).toContain(track!.id)
})
it('should return the only track for single-element array', () => {
const tracks = [
{ id: '1', title: 'Track 1', artist: 'Artist 1', duration: 180, url: '', vibe: 'electronic' as MusicVibe },
]
const track = musicService.getRandomTrack(tracks)
expect(track).not.toBeNull()
expect(track!.id).toBe('1')
})
})
describe('getNextTrack', () => {
const tracks = [
{ id: '1', title: 'Track 1', artist: 'Artist 1', duration: 180, url: '', vibe: 'electronic' as MusicVibe },
{ id: '2', title: 'Track 2', artist: 'Artist 2', duration: 200, url: '', vibe: 'electronic' as MusicVibe },
{ id: '3', title: 'Track 3', artist: 'Artist 3', duration: 220, url: '', vibe: 'electronic' as MusicVibe },
]
it('should return null for empty array', () => {
const track = musicService.getNextTrack([], '1')
expect(track).toBeNull()
})
it('should return the only track for single-element array', () => {
const singleTrack = [tracks[0]]
const track = musicService.getNextTrack(singleTrack, '1')
expect(track).not.toBeNull()
expect(track!.id).toBe('1')
})
it('should return next track in sequence', () => {
const track = musicService.getNextTrack(tracks, '1', false)
expect(track).not.toBeNull()
expect(track!.id).toBe('2')
})
it('should wrap around to first track', () => {
const track = musicService.getNextTrack(tracks, '3', false)
expect(track).not.toBeNull()
expect(track!.id).toBe('1')
})
it('should return random track when shuffle is true', () => {
const track = musicService.getNextTrack(tracks, '1', true)
expect(track).not.toBeNull()
expect(track!.id).not.toBe('1')
})
it('should return different track when shuffling single remaining', () => {
const twoTracks = [tracks[0], tracks[1]]
const track = musicService.getNextTrack(twoTracks, '1', true)
expect(track).not.toBeNull()
expect(track!.id).toBe('2')
})
})
})

View File

@@ -1,96 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import Purchases, { LOG_LEVEL } from 'react-native-purchases'
vi.mock('react-native-purchases', () => ({
default: {
configure: vi.fn(),
setLogLevel: vi.fn(),
},
LOG_LEVEL: {
VERBOSE: 'VERBOSE',
DEBUG: 'DEBUG',
WARN: 'WARN',
ERROR: 'ERROR',
},
}))
describe('purchases service', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('constants', () => {
it('should export REVENUECAT_API_KEY', async () => {
const { REVENUECAT_API_KEY } = await import('../../shared/services/purchases')
expect(REVENUECAT_API_KEY).toBeDefined()
expect(REVENUECAT_API_KEY).toContain('test_')
})
it('should export ENTITLEMENT_ID', async () => {
const { ENTITLEMENT_ID } = await import('../../shared/services/purchases')
expect(ENTITLEMENT_ID).toBeDefined()
expect(ENTITLEMENT_ID).toBe('1000 Corp Pro')
})
})
describe('initializePurchases', () => {
it('should call configure with API key', async () => {
const { initializePurchases } = await import('../../shared/services/purchases')
await initializePurchases()
expect(Purchases.configure).toHaveBeenCalled()
})
it('should set log level in dev mode', async () => {
const { initializePurchases } = await import('../../shared/services/purchases')
await initializePurchases()
if (__DEV__) {
expect(Purchases.setLogLevel).toHaveBeenCalledWith(LOG_LEVEL.VERBOSE)
}
})
it('should only initialize once', async () => {
const { initializePurchases } = await import('../../shared/services/purchases')
await initializePurchases()
await initializePurchases()
expect(Purchases.configure).toHaveBeenCalledTimes(1)
})
it('should throw on configuration error', async () => {
vi.mocked(Purchases.configure).mockRejectedValueOnce(new Error('Config failed'))
vi.resetModules()
const { initializePurchases } = await import('../../shared/services/purchases')
await expect(initializePurchases()).rejects.toThrow('Config failed')
})
})
describe('isPurchasesInitialized', () => {
it('should return false before initialization', async () => {
vi.resetModules()
const { isPurchasesInitialized } = await import('../../shared/services/purchases')
expect(isPurchasesInitialized()).toBe(false)
})
it('should return true after initialization', async () => {
vi.resetModules()
const { initializePurchases, isPurchasesInitialized } = await import('../../shared/services/purchases')
await initializePurchases()
expect(isPurchasesInitialized()).toBe(true)
})
})
})

View File

@@ -1,254 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { enableSync, syncWorkoutSession, deleteSyncedData, getSyncState, hasSyncedData, isAuthenticated } from '../../shared/services/sync'
import { supabase } from '@/src/shared/supabase'
vi.mock('@/src/shared/supabase', () => ({
supabase: {
auth: {
signInAnonymously: vi.fn(),
getUser: vi.fn(),
getSession: vi.fn(),
signOut: vi.fn(),
},
from: vi.fn(),
},
}))
describe('sync service', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('enableSync', () => {
it('should return success with userId when sync enabled', async () => {
vi.mocked(supabase.auth.signInAnonymously).mockResolvedValue({
data: { user: { id: 'test-user-id' } },
error: null,
} as any)
vi.mocked(supabase.from).mockReturnValue({
insert: vi.fn().mockResolvedValue({ error: null }),
} as any)
const profileData = {
name: 'Test User',
fitnessLevel: 'intermediate' as const,
goal: 'strength' as const,
weeklyFrequency: 3 as const,
barriers: ['no-time'],
onboardingCompletedAt: new Date().toISOString(),
}
const workoutHistory: Array<{
workoutId: string
completedAt: string
durationSeconds: number
caloriesBurned: number
}> = []
const result = await enableSync(profileData, workoutHistory)
expect(result.success).toBe(true)
expect(result.userId).toBe('test-user-id')
})
it('should return error when auth fails', async () => {
vi.mocked(supabase.auth.signInAnonymously).mockResolvedValue({
data: { user: null },
error: { message: 'Auth failed' },
} as any)
const result = await enableSync({
name: 'Test',
fitnessLevel: 'beginner',
goal: 'cardio',
weeklyFrequency: 3,
barriers: [],
onboardingCompletedAt: new Date().toISOString(),
}, [])
expect(result.success).toBe(false)
expect(result.error).toBeDefined()
})
it('should sync workout history when provided', async () => {
vi.mocked(supabase.auth.signInAnonymously).mockResolvedValue({
data: { user: { id: 'test-user-id' } },
error: null,
} as any)
const mockInsert = vi.fn().mockResolvedValue({ error: null })
vi.mocked(supabase.from).mockReturnValue({
insert: mockInsert,
} as any)
const profileData = {
name: 'Test User',
fitnessLevel: 'intermediate' as const,
goal: 'strength' as const,
weeklyFrequency: 3 as const,
barriers: ['no-time'],
onboardingCompletedAt: new Date().toISOString(),
}
const workoutHistory = [
{
workoutId: 'workout-1',
completedAt: new Date().toISOString(),
durationSeconds: 240,
caloriesBurned: 45,
},
]
await enableSync(profileData, workoutHistory)
expect(mockInsert).toHaveBeenCalled()
})
})
describe('syncWorkoutSession', () => {
it('should return error when no authenticated user', async () => {
vi.mocked(supabase.auth.getUser).mockResolvedValue({
data: { user: null },
} as any)
const result = await syncWorkoutSession({
workoutId: 'workout-1',
completedAt: new Date().toISOString(),
durationSeconds: 240,
caloriesBurned: 45,
})
expect(result.success).toBe(false)
expect(result.error).toBe('No authenticated user')
})
it('should sync session when user is authenticated', async () => {
vi.mocked(supabase.auth.getUser).mockResolvedValue({
data: { user: { id: 'test-user-id' } },
} as any)
vi.mocked(supabase.from).mockReturnValue({
insert: vi.fn().mockResolvedValue({ error: null }),
} as any)
const result = await syncWorkoutSession({
workoutId: 'workout-1',
completedAt: new Date().toISOString(),
durationSeconds: 240,
caloriesBurned: 45,
})
expect(result.success).toBe(true)
})
})
describe('deleteSyncedData', () => {
it('should return error when no authenticated user', async () => {
vi.mocked(supabase.auth.getUser).mockResolvedValue({
data: { user: null },
} as any)
const result = await deleteSyncedData()
expect(result.success).toBe(false)
expect(result.error).toBe('No authenticated user')
})
it('should delete all user data when authenticated', async () => {
vi.mocked(supabase.auth.getUser).mockResolvedValue({
data: { user: { id: 'test-user-id' } },
} as any)
const mockDelete = vi.fn().mockReturnThis()
const mockEq = vi.fn().mockResolvedValue({ error: null })
vi.mocked(supabase.from).mockReturnValue({
delete: mockDelete,
eq: mockEq,
} as any)
vi.mocked(supabase.auth.signOut).mockResolvedValue({ error: null } as any)
const result = await deleteSyncedData()
expect(result.success).toBe(true)
expect(mockDelete).toHaveBeenCalledTimes(3)
})
})
describe('getSyncState', () => {
it('should return never-synced when no session', async () => {
vi.mocked(supabase.auth.getSession).mockResolvedValue({
data: { session: null },
} as any)
const state = await getSyncState()
expect(state.status).toBe('never-synced')
expect(state.userId).toBeNull()
})
it('should return synced state with user info', async () => {
vi.mocked(supabase.auth.getSession).mockResolvedValue({
data: { session: { user: { id: 'test-user-id' } } },
} as any)
vi.mocked(supabase.from).mockReturnValue({
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
order: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue({ data: [] }),
} as any)
const state = await getSyncState()
expect(state.status).toBe('synced')
expect(state.userId).toBe('test-user-id')
})
})
describe('hasSyncedData', () => {
it('should return false when no session', async () => {
vi.mocked(supabase.auth.getSession).mockResolvedValue({
data: { session: null },
} as any)
const result = await hasSyncedData()
expect(result).toBe(false)
})
it('should return true when session exists', async () => {
vi.mocked(supabase.auth.getSession).mockResolvedValue({
data: { session: { user: { id: 'test-user-id' } } },
} as any)
const result = await hasSyncedData()
expect(result).toBe(true)
})
})
describe('isAuthenticated', () => {
it('should return false when no session', async () => {
vi.mocked(supabase.auth.getSession).mockResolvedValue({
data: { session: null },
} as any)
const result = await isAuthenticated()
expect(result).toBe(false)
})
it('should return true when session exists', async () => {
vi.mocked(supabase.auth.getSession).mockResolvedValue({
data: { session: { user: { id: 'test-user-id' } } },
} as any)
const result = await isAuthenticated()
expect(result).toBe(true)
})
})
})

View File

@@ -1,431 +0,0 @@
import { vi, afterEach, expect } from 'vitest'
import React from 'react'
import { cleanup } from '@testing-library/react-native'
// Mock __DEV__
vi.stubGlobal('__DEV__', true)
// Quieter test output
vi.stubGlobal('console', {
...console,
log: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
vi.mock('expo-blur', () => ({
BlurView: ({ intensity, tint, children, style }: any) => {
return React.createElement('BlurView', { intensity, tint, style, testID: 'blur-view' }, children)
},
}))
vi.mock('expo-linear-gradient', () => ({
LinearGradient: ({ colors, start, end, style, children }: any) => {
return React.createElement(
'LinearGradient',
{ colors, start, end, style, testID: 'linear-gradient' },
children
)
},
}))
vi.mock('expo-video', () => ({
useVideoPlayer: vi.fn(() => ({
play: vi.fn(),
pause: vi.fn(),
replace: vi.fn(),
currentTime: 0,
duration: 100,
playing: false,
muted: false,
volume: 1,
})),
VideoView: ({ player, style, contentFit, nativeControls }: any) => {
return React.createElement('VideoView', { player, style, contentFit, nativeControls, testID: 'video-view' })
},
}))
vi.mock('expo-symbols', () => ({
SymbolView: ({ name, size, tintColor, style, weight, type }: any) => {
return React.createElement('SymbolView', { name, size, tintColor, style, weight, type, testID: `icon-${name}` })
},
}))
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 47, bottom: 34, left: 0, right: 0 }),
SafeAreaProvider: ({ children }: any) => children,
}))
vi.mock('expo-constants', () => ({
default: {
expoConfig: { version: '1.0.0' },
systemFonts: [],
},
}))
vi.mock('expo-haptics', () => ({
impactAsync: vi.fn(),
ImpactFeedbackStyle: { Light: 'light', Medium: 'medium', Heavy: 'heavy' },
notificationAsync: vi.fn(),
NotificationFeedbackType: { Success: 'success', Warning: 'warning', Error: 'error' },
}))
vi.mock('expo-linking', () => ({
default: {
openURL: vi.fn(),
createURL: vi.fn(),
},
}))
const mockThemeColors = {
bg: {
base: '#0D1B2A',
surface: '#112240',
elevated: '#1A3050',
overlay1: 'rgba(168,178,216,0.06)',
overlay2: 'rgba(168,178,216,0.10)',
overlay3: 'rgba(168,178,216,0.15)',
scrim: 'rgba(0,0,0,0.6)',
},
text: {
primary: '#E6F1FF',
secondary: '#A8B2D8',
tertiary: '#8892B0',
muted: '#8892B0',
hint: '#8892B0',
disabled: '#3A3A3C',
},
surface: {
default: {
backgroundColor: '#112240',
borderColor: 'rgba(168,178,216,0.15)',
borderWidth: 1,
},
accent: {
backgroundColor: 'rgba(0,200,150,0.05)',
borderColor: 'rgba(0,200,150,0.35)',
borderWidth: 1.5,
},
tip: {
backgroundColor: 'rgba(255,138,92,0.12)',
borderColor: '#FF8A5C',
borderWidth: 1,
},
},
border: {
dim: 'rgba(168,178,216,0.15)',
hover: 'rgba(168,178,216,0.25)',
brand: 'rgba(0,200,150,0.35)',
},
gradients: {
videoOverlay: ['transparent', 'rgba(0,0,0,0.8)'],
videoTop: ['rgba(0,0,0,0.5)', 'transparent'],
},
colorScheme: 'dark' as const,
statusBarStyle: 'light' as const,
}
vi.mock('@/src/shared/theme', () => ({
useThemeColors: () => mockThemeColors,
ThemeProvider: ({ children }: any) => children,
}))
vi.mock('@/src/shared/constants/borderRadius', () => ({
RADIUS: {
NONE: 0,
SM: 4,
MD: 8,
LG: 12,
XL: 16,
PILL: 9999,
FULL: 9999,
},
}))
vi.mock('@/src/shared/constants/spacing', () => ({
SPACING: {
1: 4,
2: 8,
3: 12,
4: 16,
5: 20,
6: 24,
8: 32,
},
LAYOUT: {
SCREEN_PADDING: 24,
BUTTON_HEIGHT: 52,
},
}))
vi.mock('@/src/shared/constants/animations', () => ({
DURATION: {
FAST: 150,
NORMAL: 250,
SLOW: 400,
},
EASE: {
EASE_OUT: { x: 0.25, y: 0.1, x2: 0.25, y2: 1 },
},
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { changeLanguage: vi.fn(), language: 'en' },
}),
}))
// ---------------------------------------------------------------------------
// Mocks from setup.ts that are needed for render tests
// (react-native is NOT mocked here — it's provided by resolve.alias)
// ---------------------------------------------------------------------------
vi.mock('expo-av', () => ({
Audio: {
Sound: {
createAsync: vi.fn().mockResolvedValue({
sound: {
playAsync: vi.fn(),
pauseAsync: vi.fn(),
stopAsync: vi.fn(),
unloadAsync: vi.fn(),
setPositionAsync: vi.fn(),
setVolumeAsync: vi.fn(),
},
status: { isLoaded: true },
}),
},
setAudioModeAsync: vi.fn(),
},
Video: 'Video',
}))
vi.mock('expo-image', () => ({
Image: 'Image',
}))
vi.mock('expo-router', () => ({
useRouter: vi.fn(() => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
})),
useLocalSearchParams: vi.fn(() => ({})),
Link: 'Link',
Stack: {
Screen: 'Screen',
},
Tabs: {
Tab: 'Tab',
},
}))
vi.mock('expo-localization', () => ({
getLocales: vi.fn(() => [{ languageTag: 'en-US' }]),
}))
vi.mock('expo-keep-awake', () => ({
activateKeepAwakeAsync: vi.fn(),
deactivateKeepAwake: vi.fn(),
}))
vi.mock('expo-notifications', () => ({
scheduleNotificationAsync: vi.fn(),
cancelScheduledNotificationAsync: vi.fn(),
getAllScheduledNotificationsAsync: vi.fn().mockResolvedValue([]),
setNotificationHandler: vi.fn(),
}))
vi.mock('expo-splash-screen', () => ({
preventAutoHideAsync: vi.fn(),
hideAsync: vi.fn(),
}))
vi.mock('expo-font', () => ({
loadAsync: vi.fn(),
}))
vi.mock('expo-file-system', () => ({
documentDirectory: '/tmp/',
cacheDirectory: '/tmp/cache/',
readAsStringAsync: vi.fn(),
writeAsStringAsync: vi.fn(),
deleteAsync: vi.fn(),
getInfoAsync: vi.fn().mockResolvedValue({ exists: false }),
}))
vi.mock('expo-device', () => ({
isDevice: true,
model: 'iPhone 15',
}))
vi.mock('expo-application', () => ({
nativeApplicationVersion: '1.0.0',
nativeBuildVersion: '1',
}))
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn().mockResolvedValue(null),
setItem: vi.fn().mockResolvedValue(undefined),
removeItem: vi.fn().mockResolvedValue(undefined),
clear: vi.fn().mockResolvedValue(undefined),
getAllKeys: vi.fn().mockResolvedValue([]),
},
}))
vi.mock('react-native-purchases', () => ({
default: {
configure: vi.fn().mockResolvedValue(undefined),
getOfferings: vi.fn().mockResolvedValue({
current: {
monthly: {
identifier: 'monthly',
product: {
identifier: 'tabatafit.premium.monthly',
priceString: '$9.99',
},
},
annual: {
identifier: 'annual',
product: {
identifier: 'tabatafit.premium.yearly',
priceString: '$79.99',
},
},
},
}),
getCustomerInfo: vi.fn().mockResolvedValue({
entitlements: { active: {}, all: {} },
activeSubscriptions: [],
allPurchasedProductIdentifiers: [],
}),
purchasePackage: vi.fn().mockResolvedValue({
customerInfo: {
entitlements: { active: {}, all: {} },
activeSubscriptions: [],
},
}),
restorePurchases: vi.fn().mockResolvedValue({
entitlements: { active: {}, all: {} },
activeSubscriptions: [],
}),
addCustomerInfoUpdateListener: vi.fn(),
removeCustomerInfoUpdateListener: vi.fn(),
setLogLevel: vi.fn(),
LOG_LEVEL: {
VERBOSE: 'verbose',
DEBUG: 'debug',
INFO: 'info',
WARN: 'warn',
ERROR: 'error',
},
},
LOG_LEVEL: {
VERBOSE: 'verbose',
DEBUG: 'debug',
INFO: 'info',
WARN: 'warn',
ERROR: 'error',
},
}))
vi.mock('react-native-reanimated', () => ({
default: {
createAnimatedComponent: (comp: any) => comp,
View: 'View',
Text: 'Text',
Image: 'Image',
ScrollView: 'ScrollView',
},
useAnimatedStyle: vi.fn(() => ({})),
useSharedValue: vi.fn(() => ({ value: 0 })),
withSpring: vi.fn((v) => v),
withTiming: vi.fn((v) => v),
withRepeat: vi.fn((v) => v),
withSequence: vi.fn((...v) => v[0]),
withDelay: vi.fn((_, v) => v),
Easing: {
linear: vi.fn(),
ease: vi.fn(),
bezier: vi.fn(),
},
runOnJS: vi.fn((fn) => fn),
}))
vi.mock('react-native-gesture-handler', () => ({
Gesture: {
Pan: vi.fn(() => ({ onStart: vi.fn(), onUpdate: vi.fn(), onEnd: vi.fn() })),
Tap: vi.fn(() => ({ onStart: vi.fn(), onEnd: vi.fn() })),
},
GestureDetector: 'GestureDetector',
PanGestureHandler: 'PanGestureHandler',
TapGestureHandler: 'TapGestureHandler',
State: {},
}))
vi.mock('react-native-screens', () => ({
enableScreens: vi.fn(),
}))
vi.mock('react-native-svg', () => ({
Svg: 'Svg',
Path: 'Path',
Circle: 'Circle',
Rect: 'Rect',
G: 'G',
Defs: 'Defs',
Use: 'Use',
}))
vi.mock('@/src/shared/supabase', () => ({
supabase: {
auth: {
signInAnonymously: vi.fn().mockResolvedValue({
data: { user: { id: 'test-user-id' } },
error: null,
}),
getUser: vi.fn().mockResolvedValue({
data: { user: { id: 'test-user-id' } },
}),
getSession: vi.fn().mockResolvedValue({
data: { session: { user: { id: 'test-user-id' } } },
}),
signOut: vi.fn().mockResolvedValue({ error: null }),
},
from: vi.fn(() => ({
insert: vi.fn().mockResolvedValue({ error: null }),
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
order: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue({ data: [], error: null }),
delete: vi.fn().mockReturnThis(),
update: vi.fn().mockReturnThis(),
})),
},
}))
vi.mock('posthog-react-native', () => ({
PostHogProvider: ({ children }: { children: React.ReactNode }) => children,
usePostHog: vi.fn(() => ({
capture: vi.fn(),
identify: vi.fn(),
screen: vi.fn(),
reset: vi.fn(),
})),
}))
vi.mock('@/src/shared/i18n', () => ({
default: {
t: vi.fn((key: string) => key),
changeLanguage: vi.fn(),
language: 'en',
},
}))

View File

@@ -1,406 +0,0 @@
import { vi, afterEach } from 'vitest'
import React from 'react'
// Extend globals
vi.stubGlobal('vi', vi)
// Mock React Native core
vi.mock('react-native', () => {
const RN = vi.importActual('react-native')
return {
...RN,
Alert: {
alert: vi.fn(),
},
Platform: {
OS: 'ios',
select: vi.fn((obj) => obj.ios),
},
StyleSheet: {
create: (styles: any) => styles,
hairlineWidth: 1,
},
Dimensions: {
get: vi.fn(() => ({ width: 375, height: 812 })),
addEventListener: vi.fn(() => ({ remove: vi.fn() })),
},
View: 'View',
Text: 'Text',
TouchableOpacity: 'TouchableOpacity',
Pressable: 'Pressable',
Image: 'Image',
ScrollView: 'ScrollView',
FlatList: 'FlatList',
ActivityIndicator: 'ActivityIndicator',
SafeAreaView: 'SafeAreaView',
Easing: {
linear: vi.fn((v: number) => v),
ease: vi.fn((v: number) => v),
bezier: vi.fn(() => vi.fn((v: number) => v)),
quad: vi.fn((v: number) => v),
cubic: vi.fn((v: number) => v),
poly: vi.fn(() => vi.fn((v: number) => v)),
sin: vi.fn((v: number) => v),
circle: vi.fn((v: number) => v),
exp: vi.fn((v: number) => v),
elastic: vi.fn(() => vi.fn((v: number) => v)),
back: vi.fn(() => vi.fn((v: number) => v)),
bounce: vi.fn((v: number) => v),
in: vi.fn((easing: any) => easing),
out: vi.fn((easing: any) => easing),
inOut: vi.fn((easing: any) => easing),
},
Animated: {
Value: vi.fn(() => ({
interpolate: vi.fn(() => 0),
setValue: vi.fn(),
})),
View: 'Animated.View',
Text: 'Animated.Text',
Image: 'Animated.Image',
ScrollView: 'Animated.ScrollView',
FlatList: 'Animated.FlatList',
createAnimatedComponent: vi.fn((comp: any) => comp),
timing: vi.fn(() => ({ start: vi.fn() })),
spring: vi.fn(() => ({ start: vi.fn() })),
decay: vi.fn(() => ({ start: vi.fn() })),
sequence: vi.fn(() => ({ start: vi.fn() })),
parallel: vi.fn(() => ({ start: vi.fn() })),
loop: vi.fn(() => ({ start: vi.fn() })),
event: vi.fn(),
add: vi.fn(),
multiply: vi.fn(),
diffClamp: vi.fn(),
},
}
})
// Mock expo modules
vi.mock('expo-constants', () => ({
default: {
expoConfig: { version: '1.0.0' },
systemFonts: [],
},
}))
vi.mock('expo-linking', () => ({
default: {
openURL: vi.fn(),
createURL: vi.fn(),
},
}))
vi.mock('expo-haptics', () => ({
impactAsync: vi.fn(),
ImpactFeedbackStyle: {
Light: 'light',
Medium: 'medium',
Heavy: 'heavy',
},
notificationAsync: vi.fn(),
NotificationFeedbackType: {
Success: 'success',
Warning: 'warning',
Error: 'error',
},
}))
vi.mock('expo-av', () => ({
Audio: {
Sound: {
createAsync: vi.fn().mockResolvedValue({
sound: {
playAsync: vi.fn(),
pauseAsync: vi.fn(),
stopAsync: vi.fn(),
unloadAsync: vi.fn(),
setPositionAsync: vi.fn(),
setVolumeAsync: vi.fn(),
},
status: { isLoaded: true },
}),
},
setAudioModeAsync: vi.fn(),
},
Video: 'Video',
}))
vi.mock('expo-video', () => ({
useVideoPlayer: vi.fn(() => ({
play: vi.fn(),
pause: vi.fn(),
replace: vi.fn(),
currentTime: 0,
duration: 100,
playing: false,
muted: false,
volume: 1,
})),
VideoView: 'VideoView',
VideoSource: vi.fn(),
}))
vi.mock('expo-image', () => ({
Image: 'Image',
}))
vi.mock('expo-linear-gradient', () => ({
LinearGradient: 'LinearGradient',
}))
vi.mock('expo-blur', () => ({
BlurView: 'BlurView',
}))
vi.mock('expo-router', () => ({
useRouter: vi.fn(() => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
})),
useLocalSearchParams: vi.fn(() => ({})),
Link: 'Link',
Stack: {
Screen: 'Screen',
},
Tabs: {
Tab: 'Tab',
},
}))
vi.mock('expo-localization', () => ({
getLocales: vi.fn(() => [{ languageTag: 'en-US' }]),
}))
vi.mock('expo-keep-awake', () => ({
activateKeepAwakeAsync: vi.fn(),
deactivateKeepAwake: vi.fn(),
}))
vi.mock('expo-notifications', () => ({
scheduleNotificationAsync: vi.fn(),
cancelScheduledNotificationAsync: vi.fn(),
getAllScheduledNotificationsAsync: vi.fn().mockResolvedValue([]),
setNotificationHandler: vi.fn(),
}))
vi.mock('expo-splash-screen', () => ({
preventAutoHideAsync: vi.fn(),
hideAsync: vi.fn(),
}))
vi.mock('expo-font', () => ({
loadAsync: vi.fn(),
}))
vi.mock('expo-file-system', () => ({
documentDirectory: '/tmp/',
cacheDirectory: '/tmp/cache/',
readAsStringAsync: vi.fn(),
writeAsStringAsync: vi.fn(),
deleteAsync: vi.fn(),
getInfoAsync: vi.fn().mockResolvedValue({ exists: false }),
}))
vi.mock('expo-device', () => ({
isDevice: true,
model: 'iPhone 15',
}))
vi.mock('expo-application', () => ({
nativeApplicationVersion: '1.0.0',
nativeBuildVersion: '1',
}))
// Mock AsyncStorage
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn().mockResolvedValue(null),
setItem: vi.fn().mockResolvedValue(undefined),
removeItem: vi.fn().mockResolvedValue(undefined),
clear: vi.fn().mockResolvedValue(undefined),
getAllKeys: vi.fn().mockResolvedValue([]),
},
}))
// Mock react-native-purchases (RevenueCat)
vi.mock('react-native-purchases', () => ({
default: {
configure: vi.fn().mockResolvedValue(undefined),
getOfferings: vi.fn().mockResolvedValue({
current: {
monthly: {
identifier: 'monthly',
product: {
identifier: 'tabatafit.premium.monthly',
priceString: '$9.99',
},
},
annual: {
identifier: 'annual',
product: {
identifier: 'tabatafit.premium.yearly',
priceString: '$79.99',
},
},
},
}),
getCustomerInfo: vi.fn().mockResolvedValue({
entitlements: { active: {}, all: {} },
activeSubscriptions: [],
allPurchasedProductIdentifiers: [],
}),
purchasePackage: vi.fn().mockResolvedValue({
customerInfo: {
entitlements: { active: {}, all: {} },
activeSubscriptions: [],
},
}),
restorePurchases: vi.fn().mockResolvedValue({
entitlements: { active: {}, all: {} },
activeSubscriptions: [],
}),
addCustomerInfoUpdateListener: vi.fn(),
removeCustomerInfoUpdateListener: vi.fn(),
setLogLevel: vi.fn(),
LOG_LEVEL: {
VERBOSE: 'verbose',
DEBUG: 'debug',
INFO: 'info',
WARN: 'warn',
ERROR: 'error',
},
},
LOG_LEVEL: {
VERBOSE: 'verbose',
DEBUG: 'debug',
INFO: 'info',
WARN: 'warn',
ERROR: 'error',
},
}))
// Mock react-native-reanimated
vi.mock('react-native-reanimated', () => ({
default: {
createAnimatedComponent: (comp: any) => comp,
View: 'View',
Text: 'Text',
Image: 'Image',
ScrollView: 'ScrollView',
},
useAnimatedStyle: vi.fn(() => ({})),
useSharedValue: vi.fn(() => ({ value: 0 })),
withSpring: vi.fn((v) => v),
withTiming: vi.fn((v) => v),
withRepeat: vi.fn((v) => v),
withSequence: vi.fn((...v) => v[0]),
withDelay: vi.fn((_, v) => v),
Easing: {
linear: vi.fn(),
ease: vi.fn(),
bezier: vi.fn(),
},
runOnJS: vi.fn((fn) => fn),
}))
// Mock react-native-gesture-handler
vi.mock('react-native-gesture-handler', () => ({
Gesture: {
Pan: vi.fn(() => ({ onStart: vi.fn(), onUpdate: vi.fn(), onEnd: vi.fn() })),
Tap: vi.fn(() => ({ onStart: vi.fn(), onEnd: vi.fn() })),
},
GestureDetector: 'GestureDetector',
PanGestureHandler: 'PanGestureHandler',
TapGestureHandler: 'TapGestureHandler',
State: {},
}))
// Mock react-native-screens
vi.mock('react-native-screens', () => ({
enableScreens: vi.fn(),
}))
// Mock react-native-safe-area-context
vi.mock('react-native-safe-area-context', () => ({
SafeAreaProvider: ({ children }: { children: React.ReactNode }) => children,
useSafeAreaInsets: vi.fn(() => ({ top: 44, bottom: 34, left: 0, right: 0 })),
SafeAreaView: 'SafeAreaView',
}))
// Mock react-native-svg
vi.mock('react-native-svg', () => ({
Svg: 'Svg',
Path: 'Path',
Circle: 'Circle',
Rect: 'Rect',
G: 'G',
Defs: 'Defs',
Use: 'Use',
}))
// Mock Supabase
vi.mock('@/src/shared/supabase', () => ({
supabase: {
auth: {
signInAnonymously: vi.fn().mockResolvedValue({
data: { user: { id: 'test-user-id' } },
error: null,
}),
getUser: vi.fn().mockResolvedValue({
data: { user: { id: 'test-user-id' } },
}),
getSession: vi.fn().mockResolvedValue({
data: { session: { user: { id: 'test-user-id' } } },
}),
signOut: vi.fn().mockResolvedValue({ error: null }),
},
from: vi.fn(() => ({
insert: vi.fn().mockResolvedValue({ error: null }),
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
order: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue({ data: [], error: null }),
delete: vi.fn().mockReturnThis(),
update: vi.fn().mockReturnThis(),
})),
},
}))
// Mock PostHog
vi.mock('posthog-react-native', () => ({
PostHogProvider: ({ children }: { children: React.ReactNode }) => children,
usePostHog: vi.fn(() => ({
capture: vi.fn(),
identify: vi.fn(),
screen: vi.fn(),
reset: vi.fn(),
})),
}))
// Mock i18next
vi.mock('@/src/shared/i18n', () => ({
default: {
t: vi.fn((key: string) => key),
changeLanguage: vi.fn(),
language: 'en',
},
}))
// Mock __DEV__
vi.stubGlobal('__DEV__', true)
// Mock console methods for cleaner test output
const originalConsole = { ...console }
vi.stubGlobal('console', {
...console,
log: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
})
// Restore console after each test if needed
afterEach(() => {
vi.clearAllMocks()
})

View File

@@ -1,220 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { usePlayerStore } from '../../shared/stores/playerStore'
import type { Workout } from '../../shared/types'
const mockWorkout: Workout = {
id: 'test-workout',
title: 'Test Workout',
trainerId: 'trainer-1',
category: 'full-body',
level: 'Beginner',
duration: 4,
calories: 45,
rounds: 8,
prepTime: 10,
workTime: 20,
restTime: 10,
equipment: [],
musicVibe: 'electronic',
exercises: [
{ name: 'Jumping Jacks', duration: 20 },
{ name: 'Squats', duration: 20 },
{ name: 'Push-ups', duration: 20 },
{ name: 'High Knees', duration: 20 },
],
}
describe('playerStore', () => {
beforeEach(() => {
usePlayerStore.getState().reset()
})
describe('initial state', () => {
it('should have correct default values', () => {
const state = usePlayerStore.getState()
expect(state.workout).toBeNull()
expect(state.phase).toBe('PREP')
expect(state.timeRemaining).toBe(10)
expect(state.currentRound).toBe(1)
expect(state.isPaused).toBe(false)
expect(state.isRunning).toBe(false)
expect(state.calories).toBe(0)
expect(state.startedAt).toBeNull()
})
})
describe('loadWorkout', () => {
it('should load workout and reset all state', () => {
const store = usePlayerStore.getState()
store.loadWorkout(mockWorkout)
const state = usePlayerStore.getState()
expect(state.workout).toEqual(mockWorkout)
expect(state.phase).toBe('PREP')
expect(state.timeRemaining).toBe(mockWorkout.prepTime)
expect(state.currentRound).toBe(1)
expect(state.isPaused).toBe(false)
expect(state.isRunning).toBe(false)
expect(state.calories).toBe(0)
expect(state.startedAt).toBeNull()
})
it('should use workout prepTime for timeRemaining', () => {
const workoutWithCustomPrep: Workout = { ...mockWorkout, prepTime: 15 }
usePlayerStore.getState().loadWorkout(workoutWithCustomPrep)
expect(usePlayerStore.getState().timeRemaining).toBe(15)
})
})
describe('setPhase', () => {
it('should update phase', () => {
const store = usePlayerStore.getState()
store.setPhase('WORK')
expect(usePlayerStore.getState().phase).toBe('WORK')
store.setPhase('REST')
expect(usePlayerStore.getState().phase).toBe('REST')
store.setPhase('COMPLETE')
expect(usePlayerStore.getState().phase).toBe('COMPLETE')
})
})
describe('setTimeRemaining', () => {
it('should update timeRemaining', () => {
const store = usePlayerStore.getState()
store.setTimeRemaining(5)
expect(usePlayerStore.getState().timeRemaining).toBe(5)
store.setTimeRemaining(0)
expect(usePlayerStore.getState().timeRemaining).toBe(0)
})
})
describe('setCurrentRound', () => {
it('should update currentRound', () => {
const store = usePlayerStore.getState()
store.setCurrentRound(3)
expect(usePlayerStore.getState().currentRound).toBe(3)
store.setCurrentRound(8)
expect(usePlayerStore.getState().currentRound).toBe(8)
})
})
describe('setPaused', () => {
it('should toggle pause state', () => {
const store = usePlayerStore.getState()
store.setPaused(true)
expect(usePlayerStore.getState().isPaused).toBe(true)
store.setPaused(false)
expect(usePlayerStore.getState().isPaused).toBe(false)
})
})
describe('setRunning', () => {
it('should set running state', () => {
const store = usePlayerStore.getState()
store.setRunning(true)
expect(usePlayerStore.getState().isRunning).toBe(true)
})
it('should set startedAt when first running', () => {
const store = usePlayerStore.getState()
const beforeSet = Date.now()
store.setRunning(true)
const state = usePlayerStore.getState()
expect(state.startedAt).toBeGreaterThanOrEqual(beforeSet)
expect(state.startedAt).toBeLessThanOrEqual(Date.now())
})
it('should not update startedAt if already set', () => {
const store = usePlayerStore.getState()
store.setRunning(true)
const firstStartedAt = usePlayerStore.getState().startedAt
store.setRunning(false)
store.setRunning(true)
expect(usePlayerStore.getState().startedAt).toBe(firstStartedAt)
})
})
describe('addCalories', () => {
it('should accumulate calories', () => {
const store = usePlayerStore.getState()
store.addCalories(5)
expect(usePlayerStore.getState().calories).toBe(5)
store.addCalories(5)
expect(usePlayerStore.getState().calories).toBe(10)
store.addCalories(3)
expect(usePlayerStore.getState().calories).toBe(13)
})
})
describe('reset', () => {
it('should reset all state to defaults', () => {
const store = usePlayerStore.getState()
store.loadWorkout(mockWorkout)
store.setPhase('WORK')
store.setCurrentRound(5)
store.setRunning(true)
store.addCalories(25)
store.reset()
const state = usePlayerStore.getState()
expect(state.workout).toBeNull()
expect(state.phase).toBe('PREP')
expect(state.timeRemaining).toBe(10)
expect(state.currentRound).toBe(1)
expect(state.isPaused).toBe(false)
expect(state.isRunning).toBe(false)
expect(state.calories).toBe(0)
expect(state.startedAt).toBeNull()
})
})
describe('workout phase simulation', () => {
it('should track complete workout flow', () => {
const store = usePlayerStore.getState()
store.loadWorkout(mockWorkout)
expect(store.phase).toBe('PREP')
expect(store.timeRemaining).toBe(10)
store.setPhase('WORK')
store.setTimeRemaining(20)
expect(usePlayerStore.getState().phase).toBe('WORK')
store.setTimeRemaining(0)
store.addCalories(6)
store.setPhase('REST')
store.setTimeRemaining(10)
expect(usePlayerStore.getState().calories).toBe(6)
expect(usePlayerStore.getState().phase).toBe('REST')
store.setCurrentRound(2)
store.setPhase('WORK')
expect(usePlayerStore.getState().currentRound).toBe(2)
})
})
})

View File

@@ -1,213 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { useUserStore } from '../../shared/stores/userStore'
import type { FitnessLevel, FitnessGoal, WeeklyFrequency } from '../../shared/types'
describe('userStore', () => {
beforeEach(() => {
useUserStore.setState({
profile: {
name: '',
email: '',
joinDate: 'March 2026',
subscription: 'free',
onboardingCompleted: false,
fitnessLevel: 'beginner',
goal: 'cardio',
weeklyFrequency: 3,
barriers: [],
syncStatus: 'never-synced',
supabaseUserId: null,
savedWorkouts: [],
},
settings: {
haptics: true,
soundEffects: true,
voiceCoaching: true,
musicEnabled: true,
musicVolume: 0.5,
reminders: false,
reminderTime: '09:00',
hasPromptedReview: false,
},
})
})
describe('initial state', () => {
it('should have correct default profile values', () => {
const { profile } = useUserStore.getState()
expect(profile.name).toBe('')
expect(profile.email).toBe('')
expect(profile.subscription).toBe('free')
expect(profile.onboardingCompleted).toBe(false)
expect(profile.fitnessLevel).toBe('beginner')
expect(profile.goal).toBe('cardio')
expect(profile.weeklyFrequency).toBe(3)
expect(profile.barriers).toEqual([])
expect(profile.syncStatus).toBe('never-synced')
expect(profile.supabaseUserId).toBeNull()
})
it('should have correct default settings values', () => {
const { settings } = useUserStore.getState()
expect(settings.haptics).toBe(true)
expect(settings.soundEffects).toBe(true)
expect(settings.voiceCoaching).toBe(true)
expect(settings.musicEnabled).toBe(true)
expect(settings.musicVolume).toBe(0.5)
expect(settings.reminders).toBe(false)
expect(settings.reminderTime).toBe('09:00')
})
})
describe('updateProfile', () => {
it('should update profile fields', () => {
const store = useUserStore.getState()
store.updateProfile({ name: 'John Doe' })
expect(useUserStore.getState().profile.name).toBe('John Doe')
})
it('should merge updates with existing profile', () => {
const store = useUserStore.getState()
store.updateProfile({ name: 'Jane' })
store.updateProfile({ email: 'jane@example.com' })
const profile = useUserStore.getState().profile
expect(profile.name).toBe('Jane')
expect(profile.email).toBe('jane@example.com')
})
it('should update fitness level', () => {
const store = useUserStore.getState()
const levels: FitnessLevel[] = ['beginner', 'intermediate', 'advanced']
levels.forEach((level) => {
store.updateProfile({ fitnessLevel: level })
expect(useUserStore.getState().profile.fitnessLevel).toBe(level)
})
})
it('should update fitness goal', () => {
const store = useUserStore.getState()
const goals: FitnessGoal[] = ['weight-loss', 'cardio', 'strength', 'wellness']
goals.forEach((goal) => {
store.updateProfile({ goal })
expect(useUserStore.getState().profile.goal).toBe(goal)
})
})
it('should update weekly frequency', () => {
const store = useUserStore.getState()
const frequencies: WeeklyFrequency[] = [2, 3, 5]
frequencies.forEach((freq) => {
store.updateProfile({ weeklyFrequency: freq })
expect(useUserStore.getState().profile.weeklyFrequency).toBe(freq)
})
})
it('should update barriers array', () => {
const store = useUserStore.getState()
store.updateProfile({ barriers: ['no-time', 'low-motivation'] })
expect(useUserStore.getState().profile.barriers).toEqual(['no-time', 'low-motivation'])
})
})
describe('updateSettings', () => {
it('should update individual settings', () => {
const store = useUserStore.getState()
store.updateSettings({ haptics: false })
expect(useUserStore.getState().settings.haptics).toBe(false)
})
it('should merge updates with existing settings', () => {
const store = useUserStore.getState()
store.updateSettings({ haptics: false })
store.updateSettings({ soundEffects: false })
store.updateSettings({ musicVolume: 0.8 })
const settings = useUserStore.getState().settings
expect(settings.haptics).toBe(false)
expect(settings.soundEffects).toBe(false)
expect(settings.musicVolume).toBe(0.8)
expect(settings.voiceCoaching).toBe(true)
})
it('should update reminder settings', () => {
const store = useUserStore.getState()
store.updateSettings({ reminders: true, reminderTime: '07:30' })
const settings = useUserStore.getState().settings
expect(settings.reminders).toBe(true)
expect(settings.reminderTime).toBe('07:30')
})
})
describe('setSubscription', () => {
it('should update subscription plan', () => {
const store = useUserStore.getState()
store.setSubscription('premium-monthly')
expect(useUserStore.getState().profile.subscription).toBe('premium-monthly')
store.setSubscription('premium-yearly')
expect(useUserStore.getState().profile.subscription).toBe('premium-yearly')
store.setSubscription('free')
expect(useUserStore.getState().profile.subscription).toBe('free')
})
})
describe('completeOnboarding', () => {
it('should set onboarding completed flag', () => {
const store = useUserStore.getState()
store.completeOnboarding({
name: 'Test User',
fitnessLevel: 'intermediate',
goal: 'strength',
weeklyFrequency: 5,
barriers: ['no-time'],
})
const profile = useUserStore.getState().profile
expect(profile.onboardingCompleted).toBe(true)
expect(profile.name).toBe('Test User')
expect(profile.fitnessLevel).toBe('intermediate')
expect(profile.goal).toBe('strength')
expect(profile.weeklyFrequency).toBe(5)
expect(profile.barriers).toEqual(['no-time'])
})
})
describe('setSyncStatus', () => {
it('should update sync status', () => {
const store = useUserStore.getState()
store.setSyncStatus('prompt-pending')
expect(useUserStore.getState().profile.syncStatus).toBe('prompt-pending')
store.setSyncStatus('synced', 'user-123')
const profile = useUserStore.getState().profile
expect(profile.syncStatus).toBe('synced')
expect(profile.supabaseUserId).toBe('user-123')
})
})
describe('setPromptPending', () => {
it('should set sync status to prompt-pending', () => {
const store = useUserStore.getState()
store.setPromptPending()
expect(useUserStore.getState().profile.syncStatus).toBe('prompt-pending')
})
})
})

View File

@@ -1,32 +0,0 @@
import { describe, it, expect } from 'vitest'
import { withOpacity } from '../../shared/utils/color'
describe('withOpacity', () => {
it('should convert 6-digit hex with opacity', () => {
expect(withOpacity('#FF6B35', 0.5)).toBe('rgba(255,107,53,0.5)')
})
it('should convert 3-digit hex with opacity', () => {
expect(withOpacity('#FFF', 1)).toBe('rgba(255,255,255,1)')
})
it('should handle hex without # prefix', () => {
expect(withOpacity('000000', 0)).toBe('rgba(0,0,0,0)')
})
it('should handle 3-digit hex without # prefix', () => {
expect(withOpacity('F00', 0.8)).toBe('rgba(255,0,0,0.8)')
})
it('should handle pure black', () => {
expect(withOpacity('#000000', 1)).toBe('rgba(0,0,0,1)')
})
it('should handle pure white', () => {
expect(withOpacity('#FFFFFF', 0.12)).toBe('rgba(255,255,255,0.12)')
})
it('should handle lowercase hex', () => {
expect(withOpacity('#ff6b35', 0.5)).toBe('rgba(255,107,53,0.5)')
})
})

View File

@@ -1,57 +0,0 @@
import React, { ReactElement } from 'react'
import { render, RenderOptions } from '@testing-library/react-native'
const mockThemeColors = {
bg: {
base: '#000000',
surface: '#1C1C1E',
elevated: '#2C2C2E',
},
text: {
primary: '#FFFFFF',
secondary: '#8E8E93',
tertiary: '#636366',
},
glass: {
base: {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderColor: 'rgba(255, 255, 255, 0.1)',
borderWidth: 1,
},
elevated: {
backgroundColor: 'rgba(255, 255, 255, 0.08)',
borderColor: 'rgba(255, 255, 255, 0.12)',
borderWidth: 1,
},
inset: {
backgroundColor: 'rgba(0, 0, 0, 0.2)',
borderColor: 'rgba(255, 255, 255, 0.05)',
borderWidth: 1,
},
tinted: {
backgroundColor: 'rgba(255, 107, 53, 0.1)',
borderColor: 'rgba(255, 107, 53, 0.2)',
borderWidth: 1,
},
blurTint: 'dark',
blurMedium: 40,
},
shadow: {
sm: { shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.2, shadowRadius: 1 },
md: { shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.25, shadowRadius: 4 },
lg: { shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.3, shadowRadius: 8 },
},
}
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
theme?: typeof mockThemeColors
}
export function renderWithTheme(
ui: ReactElement,
options: CustomRenderOptions = {}
) {
return render(ui, options)
}
export { mockThemeColors }

View File

@@ -1,15 +0,0 @@
<claude-mem-context>
# Recent Activity
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
### Apr 13, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #6298 | 11:39 PM | 🟣 | YouTube music system fully integrated with workout timers - database-driven architecture replaces storage buckets | ~617 |
| #6282 | 11:06 PM | 🟣 | Music playback disabled during warm-up phase in Kine sessions | ~293 |
| #6275 | 10:53 PM | 🔴 | React hook execution order fixed in KinePlayerScreen | ~327 |
| #6272 | " | 🟣 | NowPlaying component integrated into KinePlayerScreen UI | ~368 |
| #6271 | 10:52 PM | 🟣 | useMusicPlayer hook added to KinePlayerScreen for music synchronization | ~359 |
</claude-mem-context>

View File

@@ -1,346 +0,0 @@
/**
* Tabata Player Screen
* Handles multi-block tabata sessions with warmup, blocks, inter-block rest, cooldown
*/
import React, { useRef, useEffect, useCallback, useState } from 'react'
import {
View, Text, StyleSheet, Pressable, Animated, StatusBar,
} from 'react-native'
import { useRouter } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useKeepAwake } from 'expo-keep-awake'
import { useTranslation } from 'react-i18next'
import { useTabataTimer } from '@/src/shared/hooks/useTabataTimer'
import { useHaptics } from '@/src/shared/hooks/useHaptics'
import { useAudio } from '@/src/shared/hooks/useAudio'
import { useMusicPlayer } from '@/src/shared/hooks/useMusicPlayer'
import { useProgressStore } from '@/src/shared/stores'
import { track } from '@/src/shared/services/analytics'
import type { TabataSession } from '@/src/shared/types/program'
import type { WorkoutProgram } from '@/src/shared/types/workoutProgram'
import { PHASE_COLORS } from '@/src/shared/theme'
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
import { SPACING } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { GREEN, NAVY, BORDER_COLORS, TEXT, PHASE, AMBER } from '@/src/shared/constants/colors'
import { Icon } from '@/src/shared/components/Icon'
import { TimerRing, PhaseIndicator, RoundIndicator, PlayerControls, StatsOverlay, CoachEncouragement, NowPlaying } from '@/src/features/player'
import { TabataTip } from '@/src/features/player/components/TabataTip'
import { BlockIndicator } from '@/src/features/player/components/BlockIndicator'
import { WarmupOverlay } from '@/src/features/player/components/WarmupOverlay'
// Lavender for STRETCH (COOLDOWN) phase per design spec
const LAVENDER = '#B4A7E5'
function formatTime(seconds: number) {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}:${secs.toString().padStart(2, '0')}`
}
const TABATA_PHASE_COLORS: Record<string, string> = {
WARMUP: AMBER[500],
WORK: PHASE.WORK,
REST: PHASE.REST,
INTER_BLOCK_REST: AMBER[500],
COOLDOWN: LAVENDER,
COMPLETE: GREEN[500],
}
interface TabataPlayerScreenProps {
session: TabataSession
program?: WorkoutProgram
}
export function TabataPlayerScreen({ session, program }: TabataPlayerScreenProps) {
useKeepAwake()
const { t } = useTranslation()
const router = useRouter()
const insets = useSafeAreaInsets()
const haptics = useHaptics()
const audio = useAudio()
const completeProgress = useProgressStore(s => s.completeProgram)
const timer = useTabataTimer(session)
const [showControls, setShowControls] = useState(true)
const [isMuted, setIsMuted] = useState(false)
// Music muted during WARMUP/COOLDOWN (stretch) per spec
const musicActive =
timer.isRunning &&
!timer.isPaused &&
timer.phase !== 'WARMUP' &&
timer.phase !== 'COOLDOWN'
const music = useMusicPlayer({
vibe: session.musicVibe ?? 'electronic',
isPlaying: musicActive,
volume: isMuted ? 0 : 0.5,
})
const timerScaleAnim = useRef(new Animated.Value(0.8)).current
const phaseColor = TABATA_PHASE_COLORS[timer.phase] ?? PHASE.WORK
// ─── Actions ─────────────────────────────────────────────────
const startTimer = useCallback(() => {
timer.start()
haptics.buttonTap()
track('tabata_session_started', { session_id: session.id, blocks: session.blocks.length })
}, [timer, haptics, session])
const stopWorkout = useCallback(() => {
haptics.phaseChange()
timer.stop()
router.back()
}, [router, timer, haptics])
const completeWorkout = useCallback(() => {
haptics.workoutComplete()
track('tabata_session_completed', {
session_id: session.id,
calories: timer.calories,
total_rounds: timer.totalRounds,
blocks: timer.totalBlocks,
})
// Record session in unified progress store
const programId = program?.id ?? session.id.replace(/^wp-/, '')
completeProgress({
programId,
completedAt: Date.now(),
durationSeconds: session.totalDuration * 60,
bodyZone: program?.bodyZone ?? 'full',
level: program?.level ?? 'Beginner',
}).catch(() => {})
router.replace(`/complete/${session.id}`)
}, [router, session, timer, haptics, completeProgress, program])
const handleSkip = useCallback(() => {
timer.skip()
haptics.selection()
}, [timer, haptics])
// ─── Animations & side-effects ───────────────────────────────
useEffect(() => {
Animated.spring(timerScaleAnim, {
toValue: 1, friction: 6, tension: 100, useNativeDriver: true,
}).start()
}, [])
useEffect(() => {
timerScaleAnim.setValue(0.9)
Animated.spring(timerScaleAnim, {
toValue: 1, friction: 4, tension: 150, useNativeDriver: true,
}).start()
haptics.phaseChange()
if (timer.phase === 'COMPLETE') {
if (!isMuted) audio.workoutComplete()
} else if (timer.isRunning) {
if (!isMuted) audio.phaseStart()
}
}, [timer.phase])
useEffect(() => {
if (timer.isRunning && timer.timeRemaining <= 3 && timer.timeRemaining > 0) {
if (!isMuted) audio.countdownBeep()
haptics.countdownTick()
}
}, [timer.timeRemaining])
// ─── Render ──────────────────────────────────────────────────
const isWarmup = timer.phase === 'WARMUP'
const isCooldown = timer.phase === 'COOLDOWN'
const isInterBlockRest = timer.phase === 'INTER_BLOCK_REST'
const isBlockPhase = timer.phase === 'WORK' || timer.phase === 'REST'
return (
<View style={styles.container}>
<StatusBar hidden />
<View style={[styles.phaseBg, { backgroundColor: phaseColor }]} />
<Pressable style={styles.content} onPress={() => setShowControls(s => !s)}>
{/* Header */}
{showControls && (
<View style={[styles.header, { paddingTop: insets.top + SPACING[4] }]}>
<Pressable onPress={stopWorkout} style={styles.closeBtn}>
<View style={StyleSheet.absoluteFill} />
<Icon name="xmark" size={24} tintColor={TEXT.PRIMARY} />
</Pressable>
<View style={styles.headerCenter}>
<Text style={styles.title}>{session.title}</Text>
<Text style={styles.subtitle}>Semaine {session.week} · Séance {session.order}</Text>
</View>
<View style={styles.closeBtn} />
</View>
)}
{/* Stats overlay */}
{showControls && timer.isRunning && !timer.isComplete && !isWarmup && !isCooldown && (
<View style={styles.statsContainer}>
<StatsOverlay
calories={timer.calories}
heartRate={null}
elapsedRounds={timer.currentRound - 1}
totalRounds={timer.totalRounds}
/>
</View>
)}
{/* Warmup/Cooldown overlay */}
{(isWarmup || isCooldown) && timer.currentWarmupMovement && (
<WarmupOverlay
movementName={isWarmup ? timer.currentWarmupMovement.name : (timer.currentCooldownMovement?.name ?? '')}
movementIndex={isWarmup ? timer.currentBlockIndex : 0}
totalMovements={isWarmup ? session.warmup.movements.length : session.cooldown.movements.length}
timeRemaining={timer.timeRemaining}
isCooldown={isCooldown}
/>
)}
{/* Inter-block rest */}
{isInterBlockRest && (
<View style={styles.interBlockContainer}>
<Text style={styles.interBlockLabel}>{t('screens:player.phases.trans')}</Text>
<Text style={styles.interBlockTime}>{formatTime(timer.timeRemaining)}</Text>
<BlockIndicator
currentBlock={timer.currentBlockIndex}
totalBlocks={timer.totalBlocks}
/>
<Text style={styles.interBlockNext}>
{t('screens:player.nextBlock', { num: timer.currentBlockIndex + 1 })}
</Text>
</View>
)}
{/* Main timer ring for WORK/REST phases */}
{isBlockPhase && (
<>
<BlockIndicator
currentBlock={timer.currentBlockIndex}
totalBlocks={timer.totalBlocks}
/>
<Animated.View style={[styles.timerContainer, { transform: [{ scale: timerScaleAnim }] }]}>
<TimerRing progress={timer.progress} phase={timer.phase === 'WORK' ? 'WORK' : 'REST'} />
<View style={styles.timerInner}>
<PhaseIndicator phase={timer.phase === 'WORK' ? 'WORK' : 'REST'} />
<Text selectable style={styles.timerTime}>{formatTime(timer.timeRemaining)}</Text>
<RoundIndicator current={timer.currentRound} total={session.blocks[timer.currentBlockIndex]?.rounds ?? 8} />
</View>
</Animated.View>
<Text style={styles.exerciseName}>{timer.currentExercise?.name}</Text>
<TabataTip tip={timer.currentConseil} visible={timer.phase === 'WORK'} />
<CoachEncouragement
phase={timer.phase === 'WORK' ? 'WORK' : 'REST'}
currentRound={timer.currentRound}
totalRounds={timer.totalRounds}
/>
</>
)}
{/* Complete state */}
{timer.isComplete && (
<View style={styles.completeSection}>
<Text style={styles.completeTitle}>{t('screens:player.sessionComplete')}</Text>
<Text style={[styles.completeSubtitle, { color: GREEN[500] }]}>{t('screens:player.greatWork')}</Text>
<View style={styles.completeStats}>
<View style={styles.completeStat}>
<Text selectable style={styles.completeStatValue}>{timer.totalBlocks}</Text>
<Text style={styles.completeStatLabel}>{t('screens:player.blocks')}</Text>
</View>
<View style={styles.completeStat}>
<Text selectable style={styles.completeStatValue}>{timer.totalRounds}</Text>
<Text style={styles.completeStatLabel}>{t('screens:player.rounds')}</Text>
</View>
<View style={styles.completeStat}>
<Text selectable style={styles.completeStatValue}>{timer.calories}</Text>
<Text style={styles.completeStatLabel}>{t('screens:player.calories')}</Text>
</View>
</View>
</View>
)}
{/* Now Playing music pill */}
{showControls && timer.isRunning && !timer.isComplete && musicActive && (
<View style={[styles.nowPlayingContainer, { bottom: insets.bottom + 100 }]}>
<NowPlaying
track={music.currentTrack}
isReady={music.isReady}
onSkipTrack={music.nextTrack}
/>
</View>
)}
{/* Controls */}
{showControls && !timer.isComplete && (
<View style={[styles.controls, { paddingBottom: insets.bottom + SPACING[6] }]}>
<PlayerControls
isRunning={timer.isRunning}
isPaused={timer.isPaused}
isMuted={isMuted}
onStart={startTimer}
onPause={() => { timer.pause(); haptics.selection() }}
onResume={() => { timer.resume(); haptics.selection() }}
onStop={stopWorkout}
onSkip={handleSkip}
onToggleMute={() => { setIsMuted(m => !m); haptics.selection() }}
/>
</View>
)}
{/* Complete CTA */}
{timer.isComplete && (
<View style={[styles.controls, { paddingBottom: insets.bottom + SPACING[6] }]}>
<Pressable style={styles.doneButton} onPress={completeWorkout}>
<Text style={styles.doneButtonText}>Terminé</Text>
</Pressable>
</View>
)}
</Pressable>
</View>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: NAVY[900] },
phaseBg: { ...StyleSheet.absoluteFillObject, opacity: 0.15 },
content: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: SPACING[4] },
closeBtn: { width: 44, height: 44, borderRadius: RADIUS.FULL, alignItems: 'center', justifyContent: 'center', overflow: 'hidden', borderWidth: 1, borderColor: BORDER_COLORS.DIM, backgroundColor: NAVY[800] },
headerCenter: { alignItems: 'center' },
title: { ...TYPOGRAPHY.HEADLINE, color: TEXT.PRIMARY },
subtitle: { ...TYPOGRAPHY.CAPTION_1, color: TEXT.TERTIARY },
statsContainer: { marginTop: SPACING[4], marginHorizontal: SPACING[4] },
timerContainer: { alignItems: 'center', justifyContent: 'center', marginTop: SPACING[6] },
timerInner: { position: 'absolute', alignItems: 'center' },
timerTime: { ...TYPOGRAPHY.TIMER_NUMBER, color: TEXT.PRIMARY, fontVariant: ['tabular-nums'] },
exerciseName: { ...TYPOGRAPHY.TITLE_2, color: TEXT.PRIMARY, textAlign: 'center', marginTop: SPACING[4], marginHorizontal: SPACING[4] },
interBlockContainer: { alignItems: 'center', justifyContent: 'center', flex: 1 },
interBlockLabel: { ...TYPOGRAPHY.FOOTNOTE, fontWeight: '700', letterSpacing: 2, color: AMBER[500], marginBottom: SPACING[2] },
interBlockTime: { ...TYPOGRAPHY.TIMER_NUMBER, color: TEXT.PRIMARY, fontVariant: ['tabular-nums'] },
interBlockNext: { ...TYPOGRAPHY.CAPTION_1, color: TEXT.TERTIARY, marginTop: SPACING[3] },
controls: { position: 'absolute', bottom: 0, left: 0, right: 0, alignItems: 'center' },
nowPlayingContainer: { position: 'absolute', left: SPACING[6], right: SPACING[6] },
completeSection: { alignItems: 'center', marginTop: SPACING[8] },
completeTitle: { ...TYPOGRAPHY.LARGE_TITLE, color: TEXT.PRIMARY },
completeSubtitle: { ...TYPOGRAPHY.TITLE_3, marginTop: SPACING[1] },
completeStats: { flexDirection: 'row', marginTop: SPACING[6], gap: SPACING[8] },
completeStat: { alignItems: 'center' },
completeStatValue: { ...TYPOGRAPHY.TITLE_1, color: TEXT.PRIMARY, fontVariant: ['tabular-nums'] },
completeStatLabel: { ...TYPOGRAPHY.CAPTION_1, color: TEXT.TERTIARY, marginTop: SPACING[1] },
doneButton: { width: 200, height: 56, borderRadius: RADIUS.MD, borderCurve: 'continuous', alignItems: 'center', justifyContent: 'center', overflow: 'hidden', backgroundColor: GREEN[500] },
doneButtonText: { ...TYPOGRAPHY.BUTTON_MEDIUM, color: NAVY[900], letterSpacing: 1 },
})

Some files were not shown because too many files have changed in this diff Show More