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.
4
.env
@@ -1,4 +0,0 @@
|
||||
# TabataFit Environment Variables
|
||||
# Supabase Configuration
|
||||
EXPO_PUBLIC_SUPABASE_URL=https://supabase.1000co.fr
|
||||
EXPO_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzcyMjMzMjAwLCJleHAiOjE5Mjk5OTk2MDB9.SlYN046eGvUSObW0tFQHcMRUqFvtMqBLfFRlZliSx_w
|
||||
13
.env.example
@@ -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
@@ -1,112 +0,0 @@
|
||||
# TabataFit
|
||||
|
||||
> **Apple Fitness+ for Tabata** — The Premium HIIT Experience
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## Vision
|
||||
|
||||
TabataFit est l'Apple Fitness+ du Tabata. Une expérience premium, video-first, guidée par des coachs, qui transforme 4 minutes d'exercice en une expérience de fitness immersive.
|
||||
|
||||
## Features
|
||||
|
||||
- 🎬 **Video-led workouts** — HD video demonstrations by professional trainers
|
||||
- ⏱️ **Smart timer** — Tabata timer with work/rest phases
|
||||
- 🔥 **Burn Bar** — Compare your calories with the community
|
||||
- 📊 **Activity tracking** — Streaks, stats, and trends
|
||||
- 🎵 **Music sync** — Curated playlists for each workout
|
||||
- ⌚ **Apple Watch** — Heart rate and activity rings
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: Expo SDK 52
|
||||
- **Navigation**: Expo Router v3
|
||||
- **State**: Zustand
|
||||
- **Video**: expo-av (HLS streaming)
|
||||
- **Payments**: RevenueCat
|
||||
- **Analytics**: PostHog
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start development server
|
||||
npx expo start
|
||||
|
||||
# Run on device (scan QR with Expo Go)
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [PRD v2.0](./TabataFit_PRD_v2.0.md) | Product Requirements |
|
||||
| [PDD v2.0](./TabataFit_PDD_v2.0.md) | Product Design |
|
||||
| [BDSD v2.0](./TabataFit_BDSD_v2.0.md) | Brand Design |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
features/
|
||||
home/ # Home tab
|
||||
workouts/ # Workouts browser
|
||||
player/ # Video player + timer
|
||||
activity/ # Stats & history
|
||||
browse/ # Filters & trainers
|
||||
profile/ # User settings
|
||||
shared/
|
||||
components/ # Reusable UI
|
||||
hooks/ # Custom hooks
|
||||
constants/ # Design tokens
|
||||
app/ # Expo Router routes
|
||||
```
|
||||
|
||||
## 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
|
||||
@@ -1,182 +0,0 @@
|
||||
# Supabase Music Storage Setup
|
||||
|
||||
This guide walks you through setting up the Supabase Storage bucket for music tracks in TabataFit.
|
||||
|
||||
## Overview
|
||||
|
||||
TabataFit loads background music from Supabase Storage based on workout `musicVibe` values. The music service organizes tracks by vibe folders.
|
||||
|
||||
## Step 1: Create the Storage Bucket
|
||||
|
||||
1. Go to your Supabase Dashboard → Storage
|
||||
2. Click **New bucket**
|
||||
3. Name: `music`
|
||||
4. Enable **Public access** (tracks are streamed to authenticated users)
|
||||
5. Click **Create bucket**
|
||||
|
||||
## Step 2: Set Up Folder Structure
|
||||
|
||||
Create folders for each music vibe:
|
||||
|
||||
```
|
||||
music/
|
||||
├── electronic/
|
||||
├── hip-hop/
|
||||
├── pop/
|
||||
├── rock/
|
||||
└── chill/
|
||||
```
|
||||
|
||||
### Via Dashboard:
|
||||
1. Open the `music` bucket
|
||||
2. Click **New folder** for each vibe
|
||||
3. Name folders exactly as the `MusicVibe` type values
|
||||
|
||||
### Via SQL (optional):
|
||||
```sql
|
||||
-- Storage folders are virtual in Supabase
|
||||
-- Just upload files with path prefixes like "electronic/track.mp3"
|
||||
```
|
||||
|
||||
## Step 3: Upload Music Tracks
|
||||
|
||||
### Supported Formats
|
||||
- MP3 (recommended)
|
||||
- M4A (AAC)
|
||||
- OGG (if needed)
|
||||
|
||||
### File Naming Convention
|
||||
Name files with artist and title for best results:
|
||||
```
|
||||
Artist Name - Track Title.mp3
|
||||
```
|
||||
|
||||
Examples:
|
||||
```
|
||||
Neon Dreams - Energy Pulse.mp3
|
||||
Urban Flow - Street Heat.mp3
|
||||
The Popstars - Summer Energy.mp3
|
||||
```
|
||||
|
||||
### Upload via Dashboard:
|
||||
1. Open a vibe folder (e.g., `electronic/`)
|
||||
2. Click **Upload files**
|
||||
3. Select your audio files
|
||||
4. Ensure the path shows `music/electronic/`
|
||||
|
||||
### Upload via CLI:
|
||||
```bash
|
||||
# Install Supabase CLI if not already installed
|
||||
npm install -g supabase
|
||||
|
||||
# Login
|
||||
supabase login
|
||||
|
||||
# Link your project
|
||||
supabase link --project-ref your-project-ref
|
||||
|
||||
# Upload tracks
|
||||
supabase storage upload music/electronic/ "path/to/your/tracks/*.mp3"
|
||||
```
|
||||
|
||||
## Step 4: Configure Storage Policies
|
||||
|
||||
### RLS Policy for Authenticated Users
|
||||
|
||||
Go to Supabase Dashboard → Storage → Policies → `music` bucket
|
||||
|
||||
Add these policies:
|
||||
|
||||
#### 1. Select Policy (Read Access)
|
||||
```sql
|
||||
CREATE POLICY "Allow authenticated users to read music"
|
||||
ON storage.objects FOR SELECT
|
||||
TO authenticated
|
||||
USING (bucket_id = 'music');
|
||||
```
|
||||
|
||||
#### 2. Insert Policy (Admin Upload Only)
|
||||
```sql
|
||||
CREATE POLICY "Allow admin uploads"
|
||||
ON storage.objects FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (
|
||||
bucket_id = 'music'
|
||||
AND (auth.jwt() ->> 'role') = 'admin'
|
||||
);
|
||||
```
|
||||
|
||||
### Via Dashboard UI:
|
||||
1. Go to **Storage** → **Policies**
|
||||
2. Under `music` bucket, click **New policy**
|
||||
3. Select **For SELECT** (get)
|
||||
4. Allowed operation: **Authenticated**
|
||||
5. Policy definition: `true` (or custom check)
|
||||
|
||||
## Step 5: Test the Setup
|
||||
|
||||
1. Start your Expo app: `npx expo start`
|
||||
2. Start a workout with music enabled
|
||||
3. Check console logs for:
|
||||
```
|
||||
[Music] Loaded X tracks for vibe: electronic
|
||||
```
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
**No tracks loading:**
|
||||
- Check Supabase credentials in `.env`
|
||||
- Verify folder names match `MusicVibe` type exactly
|
||||
- Check Storage RLS policies allow read access
|
||||
|
||||
**Tracks not playing:**
|
||||
- Ensure files are accessible (try signed URL in browser)
|
||||
- Check audio format is supported by expo-av
|
||||
- Verify CORS settings in Supabase
|
||||
|
||||
**CORS errors:**
|
||||
Add to Supabase Dashboard → Settings → API → CORS:
|
||||
```
|
||||
app://*
|
||||
http://localhost:8081
|
||||
```
|
||||
|
||||
## Step 6: Environment Variables
|
||||
|
||||
Ensure your `.env` file has:
|
||||
|
||||
```env
|
||||
EXPO_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
|
||||
EXPO_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
|
||||
```
|
||||
|
||||
## Music Vibes Reference
|
||||
|
||||
The app supports these vibe categories:
|
||||
|
||||
| Vibe | Description | Typical BPM |
|
||||
|------|-------------|-------------|
|
||||
| `electronic` | EDM, House, Techno | 128-140 |
|
||||
| `hip-hop` | Rap, Trap, Beats | 85-110 |
|
||||
| `pop` | Pop hits, Dance-pop | 100-130 |
|
||||
| `rock` | Rock, Alternative | 120-160 |
|
||||
| `chill` | Lo-fi, Ambient, Downtempo | 60-90 |
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Track Duration**: 3-5 minutes ideal for Tabata workouts
|
||||
2. **File Size**: Keep under 10MB for faster loading
|
||||
3. **Bitrate**: 128-192kbps MP3 for good quality/size balance
|
||||
4. **Loudness**: Normalize tracks to similar levels (-14 LUFS)
|
||||
5. **Metadata**: Include ID3 tags for artist/title info
|
||||
|
||||
## Alternative: Local Development
|
||||
|
||||
If Supabase is not configured, the app uses mock tracks automatically. To force mock data, temporarily set invalid Supabase credentials.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [ ] Upload initial track library (5-10 tracks per vibe)
|
||||
- [ ] Test on physical device
|
||||
- [ ] Consider CDN for production scale
|
||||
- [ ] Implement track favoriting/personal playlists
|
||||
@@ -1,228 +0,0 @@
|
||||
# TabataFit Supabase Integration
|
||||
|
||||
This document explains how to set up and use the Supabase backend for TabataFit.
|
||||
|
||||
## Overview
|
||||
|
||||
TabataFit now uses Supabase as its backend for:
|
||||
- **Database**: Storing workouts, trainers, collections, programs, and achievements
|
||||
- **Storage**: Managing video files, thumbnails, and trainer avatars
|
||||
- **Authentication**: Admin dashboard access control
|
||||
- **Real-time**: Future support for live features
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Create Supabase Project
|
||||
|
||||
1. Go to [Supabase Dashboard](https://app.supabase.com)
|
||||
2. Create a new project
|
||||
3. Note your project URL and anon key
|
||||
|
||||
### 2. Configure Environment Variables
|
||||
|
||||
Create a `.env` file in the project root:
|
||||
|
||||
```bash
|
||||
EXPO_PUBLIC_SUPABASE_URL=your_supabase_project_url
|
||||
EXPO_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
|
||||
```
|
||||
|
||||
### 3. Run Database Migrations
|
||||
|
||||
1. Go to your Supabase project's SQL Editor
|
||||
2. Open `supabase/migrations/001_initial_schema.sql`
|
||||
3. Run the entire script
|
||||
|
||||
This creates:
|
||||
- All necessary tables (workouts, trainers, collections, programs, achievements)
|
||||
- Storage buckets (videos, thumbnails, avatars)
|
||||
- Row Level Security policies
|
||||
- Triggers for auto-updating timestamps
|
||||
|
||||
### 4. Seed the Database
|
||||
|
||||
Run the seed script to populate your database with initial data:
|
||||
|
||||
```bash
|
||||
npx ts-node supabase/seed.ts
|
||||
```
|
||||
|
||||
This will import all 50 workouts, 5 trainers, 6 collections, 3 programs, and 8 achievements.
|
||||
|
||||
### 5. Set Up Admin User
|
||||
|
||||
1. Go to Supabase Dashboard → Authentication → Users
|
||||
2. Create a new user with email/password
|
||||
3. Go to SQL Editor and run:
|
||||
|
||||
```sql
|
||||
INSERT INTO admin_users (id, email, role)
|
||||
VALUES ('USER_UUID_HERE', 'admin@example.com', 'admin');
|
||||
```
|
||||
|
||||
Replace `USER_UUID_HERE` with the actual user UUID from step 2.
|
||||
|
||||
### 6. Configure Storage
|
||||
|
||||
In Supabase Dashboard → Storage:
|
||||
|
||||
1. Verify buckets exist: `videos`, `thumbnails`, `avatars`
|
||||
2. Set bucket privacy to public for all three
|
||||
3. Configure CORS if needed for your domain
|
||||
|
||||
## Architecture
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────┐ ┌─────────────────┐
|
||||
│ React Native │────▶│ Supabase │────▶│ PostgreSQL │
|
||||
│ Client │ │ Client │ │ Database │
|
||||
└─────────────────┘ └──────────────┘ └─────────────────┘
|
||||
│
|
||||
│ ┌──────────────┐
|
||||
└──────────────▶│ Admin │
|
||||
│ Dashboard │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
### Key Components
|
||||
|
||||
1. **Supabase Client** (`src/shared/supabase/`)
|
||||
- `client.ts`: Configured Supabase client
|
||||
- `database.types.ts`: TypeScript definitions for tables
|
||||
|
||||
2. **Data Service** (`src/shared/data/dataService.ts`)
|
||||
- `SupabaseDataService`: Handles all database operations
|
||||
- Falls back to mock data if Supabase is not configured
|
||||
|
||||
3. **React Hooks** (`src/shared/hooks/useSupabaseData.ts`)
|
||||
- `useWorkouts`, `useTrainers`, `useCollections`, etc.
|
||||
- Automatic loading states and error handling
|
||||
|
||||
4. **Admin Service** (`src/admin/services/adminService.ts`)
|
||||
- CRUD operations for content management
|
||||
- File upload/delete for storage
|
||||
- Admin authentication
|
||||
|
||||
5. **Admin Dashboard** (`app/admin/`)
|
||||
- `/admin/login`: Authentication screen
|
||||
- `/admin`: Main dashboard with stats
|
||||
- `/admin/workouts`: Manage workouts
|
||||
- `/admin/trainers`: Manage trainers
|
||||
- `/admin/collections`: Manage collections
|
||||
- `/admin/media`: Storage management
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Tables
|
||||
|
||||
| Table | Description |
|
||||
|-------|-------------|
|
||||
| `trainers` | Trainer profiles with colors and avatars |
|
||||
| `workouts` | Workout definitions with exercises and metadata |
|
||||
| `collections` | Curated workout collections |
|
||||
| `collection_workouts` | Many-to-many link between collections and workouts |
|
||||
| `programs` | Multi-week workout programs |
|
||||
| `program_workouts` | Link between programs and workouts with week/day |
|
||||
| `achievements` | User achievement definitions |
|
||||
| `admin_users` | Admin dashboard access control |
|
||||
|
||||
### Storage Buckets
|
||||
|
||||
| Bucket | Purpose | Access |
|
||||
|--------|---------|--------|
|
||||
| `videos` | Workout videos | Public read, Admin write |
|
||||
| `thumbnails` | Workout thumbnails | Public read, Admin write |
|
||||
| `avatars` | Trainer avatars | Public read, Admin write |
|
||||
|
||||
## Using the App
|
||||
|
||||
### As a User
|
||||
|
||||
The app works seamlessly:
|
||||
- If Supabase is configured: Loads data from the cloud
|
||||
- If not configured: Falls back to local mock data
|
||||
|
||||
### As an Admin
|
||||
|
||||
1. Navigate to `/admin` in the app
|
||||
2. Sign in with your admin credentials
|
||||
3. Manage content through the dashboard:
|
||||
- View all workouts, trainers, collections
|
||||
- Delete items (create/edit coming soon)
|
||||
- Upload media files
|
||||
|
||||
## Development
|
||||
|
||||
### Adding New Workouts
|
||||
|
||||
```typescript
|
||||
import { adminService } from '@/src/admin/services/adminService'
|
||||
|
||||
await adminService.createWorkout({
|
||||
title: 'New Workout',
|
||||
trainer_id: 'emma',
|
||||
category: 'full-body',
|
||||
level: 'Beginner',
|
||||
duration: 4,
|
||||
calories: 45,
|
||||
rounds: 8,
|
||||
prep_time: 10,
|
||||
work_time: 20,
|
||||
rest_time: 10,
|
||||
equipment: ['No equipment'],
|
||||
music_vibe: 'electronic',
|
||||
exercises: [
|
||||
{ name: 'Jumping Jacks', duration: 20 },
|
||||
// ...
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
### Uploading Media
|
||||
|
||||
```typescript
|
||||
const videoUrl = await adminService.uploadVideo(file, 'workout-1.mp4')
|
||||
const thumbnailUrl = await adminService.uploadThumbnail(file, 'workout-1.jpg')
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Supabase is not configured" Warning
|
||||
|
||||
This is expected if environment variables are not set. The app will use mock data.
|
||||
|
||||
### Authentication Errors
|
||||
|
||||
1. Verify admin user exists in `admin_users` table
|
||||
2. Check that email/password match
|
||||
3. Ensure user is confirmed in Supabase Auth
|
||||
|
||||
### Storage Upload Failures
|
||||
|
||||
1. Verify storage buckets exist
|
||||
2. Check RLS policies allow admin uploads
|
||||
3. Ensure file size is within limits
|
||||
|
||||
### Data Not Syncing
|
||||
|
||||
1. Check network connection
|
||||
2. Verify Supabase URL and key are correct
|
||||
3. Check browser console for errors
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Row Level Security (RLS) is enabled on all tables
|
||||
- Public can only read content
|
||||
- Only admin users can write content
|
||||
- Storage buckets have public read access
|
||||
- Storage uploads restricted to admin users
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Real-time workout tracking
|
||||
- [ ] User progress sync across devices
|
||||
- [ ] Offline support with local caching
|
||||
- [ ] Push notifications
|
||||
- [ ] Analytics and user insights
|
||||
@@ -1,594 +0,0 @@
|
||||
# TabataFit — Brand Design Specification v2.0
|
||||
> Apple Liquid Glass Design for Tabata
|
||||
|
||||
---
|
||||
|
||||
## Brand Positioning
|
||||
|
||||
**TabataFit = Apple Fitness+ avec Liquid Glass (iOS 18.4)**
|
||||
|
||||
| Attribute | Description |
|
||||
|-----------|-------------|
|
||||
| **Analogy** | "If Apple made a Tabata app in 2026" |
|
||||
| **Vibe** | Premium, glassy, fluid, immersive |
|
||||
| **Emotion** | Confident, empowering, sleek |
|
||||
| **Differentiator** | Video-led + Liquid Glass UI |
|
||||
|
||||
---
|
||||
|
||||
## 🪟 Liquid Glass System (iOS 18.4 Style)
|
||||
|
||||
### Concept
|
||||
Le **Liquid Glass** est le nouveau design language d'Apple qui combine :
|
||||
- **Glassmorphism avancé** — Blur dynamique, transparence multicouche
|
||||
- **Fluidité organique** — Formes arrondies, animations liquides
|
||||
- **Lumière réactive** — Reflets, glows qui répondent au contenu
|
||||
- **Profondeur atmosphérique** — Layers de verre empilés
|
||||
|
||||
### Glass Layers
|
||||
|
||||
```typescript
|
||||
const GLASS = {
|
||||
// Base glass (surfaces)
|
||||
BASE: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
backdropFilter: 'blur(40px)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 32,
|
||||
},
|
||||
|
||||
// Elevated glass (cards, modals)
|
||||
ELEVATED: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
backdropFilter: 'blur(60px)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.15)',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 12 },
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 48,
|
||||
},
|
||||
|
||||
// Inset glass (input fields, controls)
|
||||
INSET: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.2)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.05)',
|
||||
},
|
||||
|
||||
// Tinted glass (accent overlays)
|
||||
TINTED: {
|
||||
backgroundColor: 'rgba(255, 107, 53, 0.15)', // Brand tint
|
||||
backdropFilter: 'blur(40px)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 107, 53, 0.3)',
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Liquid Animations
|
||||
|
||||
```typescript
|
||||
const LIQUID = {
|
||||
// Morphing shapes
|
||||
MORPH: {
|
||||
duration: 600,
|
||||
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
},
|
||||
|
||||
// Ripple effect on tap
|
||||
RIPPLE: {
|
||||
scale: { from: 0.8, to: 1 },
|
||||
opacity: { from: 0.5, to: 0 },
|
||||
duration: 400,
|
||||
},
|
||||
|
||||
// Breathing glow
|
||||
BREATHE: {
|
||||
scale: { from: 1, to: 1.02 },
|
||||
shadowRadius: { from: 20, to: 30 },
|
||||
duration: 2000,
|
||||
loop: true,
|
||||
},
|
||||
|
||||
// Slide with liquid easing
|
||||
SLIDE: {
|
||||
damping: 20,
|
||||
stiffness: 300,
|
||||
mass: 1,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Visual Identity
|
||||
|
||||
### Logo Concept
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ │
|
||||
│ TABATA │ ← Bold, black
|
||||
│ FIT │ ← Accent orange
|
||||
│ │
|
||||
│ [Flame icon] │ ← Optional mark
|
||||
│ │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
### Brand Voice
|
||||
|
||||
| DO | DON'T |
|
||||
|----|-------|
|
||||
| "Let's burn." | "Get ripped fast!" |
|
||||
| "4 minutes to stronger." | "Lose weight now!" |
|
||||
| "Your daily dose of HIIT." | "The #1 fitness app!" |
|
||||
| Minimal, confident copy | Excessive exclamation marks!! |
|
||||
|
||||
---
|
||||
|
||||
## Color Palette
|
||||
|
||||
### Primary Colors
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ BLACK ████████████ #000000 Background │
|
||||
│ CHARCOAL ████████████ #1C1C1E Surfaces │
|
||||
│ SLATE ████████████ #2C2C2E Elevated │
|
||||
│ │
|
||||
│ FLAME ████████████ #FF6B35 Brand accent │
|
||||
│ FLAME LIGHT ████████████ #FF8C5A Highlights │
|
||||
│ │
|
||||
│ ICE ████████████ #5AC8FA Rest phases │
|
||||
│ GLOW ████████████ #FFD60A Achievement │
|
||||
│ ENERGY ████████████ #30D158 Success/Complete│
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Semantic Usage
|
||||
|
||||
| Color | Hex | Usage |
|
||||
|-------|-----|-------|
|
||||
| **Black** | #000000 | Main background, video frames |
|
||||
| **Charcoal** | #1C1C1E | Cards, raised surfaces |
|
||||
| **Slate** | #2C2C2E | Modals, elevated elements |
|
||||
| **Flame** | #FF6B35 | Work phase, CTAs, brand |
|
||||
| **Ice** | #5AC8FA | Rest phase, calm states |
|
||||
| **Glow** | #FFD60A | Achievement badges, streaks |
|
||||
| **Energy** | #30D158 | Complete states, success |
|
||||
|
||||
### Phase Colors (Critical)
|
||||
|
||||
```typescript
|
||||
const PHASE_COLORS = {
|
||||
PREP: '#FF9500', // Orange-yellow - get ready
|
||||
WORK: '#FF6B35', // Flame orange - WORK!
|
||||
REST: '#5AC8FA', // Ice blue - recover
|
||||
COMPLETE: '#30D158', // Energy green - done!
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Typography
|
||||
|
||||
### Font Stack
|
||||
|
||||
**Primary**: Inter (Google Fonts)
|
||||
- Clean, modern, excellent readability
|
||||
- Variable weight support
|
||||
- Apple SF Pro alternative
|
||||
|
||||
### Type Scale
|
||||
|
||||
| Style | Size | Weight | Use Case |
|
||||
|-------|------|--------|----------|
|
||||
| **HERO** | 48px | 900 | Marketing, celebration |
|
||||
| **TITLE_1** | 34px | 700 | Screen titles |
|
||||
| **TITLE_2** | 28px | 700 | Section headers |
|
||||
| **TITLE_3** | 22px | 600 | Card titles |
|
||||
| **BODY** | 17px | 400 | Default text |
|
||||
| **BODY_BOLD** | 17px | 600 | Emphasis |
|
||||
| **CAPTION** | 15px | 400 | Metadata |
|
||||
| **MICRO** | 13px | 400 | Small labels |
|
||||
| **TIMER** | 96px | 900 | Countdown display |
|
||||
|
||||
### Timer Typography (Special)
|
||||
|
||||
```typescript
|
||||
const TIMER_STYLES = {
|
||||
// Main countdown number
|
||||
NUMBER: {
|
||||
fontFamily: 'Inter_900Black',
|
||||
fontSize: 96,
|
||||
fontVariant: ['tabular-nums'], // Monospace digits
|
||||
letterSpacing: -2,
|
||||
},
|
||||
|
||||
// Phase label (WORK, REST)
|
||||
PHASE: {
|
||||
fontFamily: 'Inter_700Bold',
|
||||
fontSize: 24,
|
||||
letterSpacing: 2,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
|
||||
// Round indicator
|
||||
ROUND: {
|
||||
fontFamily: 'Inter_500Medium',
|
||||
fontSize: 17,
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Spacing System
|
||||
|
||||
```typescript
|
||||
const SPACING = {
|
||||
// Base unit: 4px
|
||||
0: 0,
|
||||
1: 4,
|
||||
2: 8,
|
||||
3: 12,
|
||||
4: 16,
|
||||
5: 20,
|
||||
6: 24,
|
||||
8: 32,
|
||||
10: 40,
|
||||
12: 48,
|
||||
16: 64,
|
||||
|
||||
// Semantic
|
||||
XS: 4,
|
||||
SM: 8,
|
||||
MD: 16,
|
||||
LG: 24,
|
||||
XL: 32,
|
||||
XXL: 48,
|
||||
}
|
||||
|
||||
const LAYOUT = {
|
||||
SCREEN_PADDING: 24, // Horizontal screen padding
|
||||
CARD_RADIUS: 16, // Standard card radius
|
||||
BUTTON_RADIUS: 12, // Button radius
|
||||
TAB_BAR_HEIGHT: 80, // Bottom tab bar
|
||||
STATUS_BAR: 44, // iOS status bar
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Styles
|
||||
|
||||
### Cards
|
||||
|
||||
```typescript
|
||||
const CARD = {
|
||||
CONTAINER: {
|
||||
backgroundColor: '#1C1C1E',
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
|
||||
THUMBNAIL: {
|
||||
aspectRatio: 16/9,
|
||||
backgroundColor: '#2C2C2E',
|
||||
},
|
||||
|
||||
CONTENT: {
|
||||
padding: 16,
|
||||
},
|
||||
|
||||
// Variants
|
||||
FEATURED: {
|
||||
borderRadius: 20,
|
||||
},
|
||||
|
||||
COMPACT: {
|
||||
flexDirection: 'row',
|
||||
borderRadius: 12,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Buttons
|
||||
|
||||
```typescript
|
||||
const BUTTON = {
|
||||
PRIMARY: {
|
||||
backgroundColor: '#FF6B35',
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 24,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
|
||||
PRIMARY_TEXT: {
|
||||
color: '#FFFFFF',
|
||||
fontFamily: 'Inter_600SemiBold',
|
||||
fontSize: 17,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
|
||||
SECONDARY: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: '#3A3A3C',
|
||||
},
|
||||
|
||||
GHOST: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Timer Display
|
||||
|
||||
```typescript
|
||||
const TIMER = {
|
||||
CONTAINER: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: 24,
|
||||
background: 'linear-gradient(transparent, rgba(0,0,0,0.8))',
|
||||
},
|
||||
|
||||
NUMBER: {
|
||||
fontFamily: 'Inter_900Black',
|
||||
fontSize: 96,
|
||||
color: '#FFFFFF',
|
||||
textAlign: 'center',
|
||||
},
|
||||
|
||||
PROGRESS_BAR: {
|
||||
height: 4,
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
borderRadius: 2,
|
||||
marginTop: 16,
|
||||
},
|
||||
|
||||
PROGRESS_FILL: {
|
||||
height: '100%',
|
||||
borderRadius: 2,
|
||||
// Color based on phase
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Animation Specifications
|
||||
|
||||
### Timing Constants
|
||||
|
||||
```typescript
|
||||
const DURATION = {
|
||||
INSTANT: 100,
|
||||
FAST: 200,
|
||||
NORMAL: 300,
|
||||
SLOW: 500,
|
||||
XSLow: 800,
|
||||
}
|
||||
|
||||
const EASING = {
|
||||
// Standard iOS ease
|
||||
DEFAULT: 'ease-out',
|
||||
|
||||
// Spring animations
|
||||
BOUNCY: {
|
||||
damping: 15,
|
||||
stiffness: 180,
|
||||
},
|
||||
|
||||
GENTLE: {
|
||||
damping: 20,
|
||||
stiffness: 100,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Key Animations
|
||||
|
||||
**Timer Countdown:**
|
||||
```typescript
|
||||
// Number pulses slightly each second
|
||||
Animated.sequence([
|
||||
Animated.timing(scale, { toValue: 1.05, duration: 100 }),
|
||||
Animated.timing(scale, { toValue: 1, duration: 100 }),
|
||||
])
|
||||
```
|
||||
|
||||
**Progress Bar:**
|
||||
```typescript
|
||||
// Smooth linear fill during phase
|
||||
Animated.timing(width, {
|
||||
toValue: 100,
|
||||
duration: phaseDuration,
|
||||
easing: Easing.linear,
|
||||
})
|
||||
```
|
||||
|
||||
**Phase Transition:**
|
||||
```typescript
|
||||
// Color crossfade
|
||||
Animated.timing(colorProgress, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
})
|
||||
```
|
||||
|
||||
**Workout Complete:**
|
||||
```typescript
|
||||
// Celebration with scale + fade
|
||||
Animated.parallel([
|
||||
Animated.spring(scale, { toValue: 1, ...BOUNCY }),
|
||||
Animated.timing(opacity, { toValue: 1, duration: 500 }),
|
||||
])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Icon System
|
||||
|
||||
Using SF Symbols / Ionicons equivalent:
|
||||
|
||||
| Context | Icon | Size |
|
||||
|---------|------|------|
|
||||
| Home Tab | house.fill | 24 |
|
||||
| Workouts Tab | flame.fill | 24 |
|
||||
| Activity Tab | chart.bar.fill | 24 |
|
||||
| Browse Tab | square.grid.2x2.fill | 24 |
|
||||
| Profile Tab | person.fill | 24 |
|
||||
| Play | play.fill | 20 |
|
||||
| Pause | pause.fill | 20 |
|
||||
| Close | xmark | 20 |
|
||||
| Back | chevron.left | 20 |
|
||||
| Forward | chevron.right | 20 |
|
||||
| Heart | heart.fill | 20 |
|
||||
| Search | magnifyingglass | 20 |
|
||||
|
||||
---
|
||||
|
||||
## Video Player UI
|
||||
|
||||
### Overlay Layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ [Close X] [♥︎] [···] │ ← Top bar
|
||||
│ │
|
||||
│ │
|
||||
│ [VIDEO CONTENT] │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────┐ │
|
||||
│ │ 🔥 WORK │ │
|
||||
│ │ │ │
|
||||
│ │ 00:14 │ │ ← Timer overlay
|
||||
│ │ │ │
|
||||
│ │ Round 3 of 8 │ │
|
||||
│ │ ████████████░░░░ │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Video Requirements
|
||||
|
||||
- **Resolution**: 1080p minimum
|
||||
- **Aspect**: 16:9 (landscape) or 9:16 (portrait mode)
|
||||
- **Format**: HLS (m3u8) for streaming
|
||||
- **Audio**: AAC, 128kbps minimum
|
||||
- **Thumbnail**: JPG, same aspect ratio
|
||||
|
||||
---
|
||||
|
||||
## Sound Design
|
||||
|
||||
### Sound Effects
|
||||
|
||||
| Event | Sound | Description |
|
||||
|-------|-------|-------------|
|
||||
| Phase Start | "ding" | Quick, bright chime |
|
||||
| Count 3-2-1 | "tick" | Subtle tick sound |
|
||||
| Workout Start | "whoosh" | Energy whoosh |
|
||||
| Workout Complete | "success" | Celebratory chime |
|
||||
| Button Tap | "tap" | Soft tap feedback |
|
||||
| Streak Achieved | "fire" | Crackling fire |
|
||||
|
||||
### Haptics
|
||||
|
||||
| Event | Haptic |
|
||||
|-------|--------|
|
||||
| Phase change | Medium |
|
||||
| Button tap | Light |
|
||||
| Countdown tick | Selection |
|
||||
| Workout complete | Success |
|
||||
| Error | Error |
|
||||
|
||||
---
|
||||
|
||||
## Dark Mode Only
|
||||
|
||||
TabataFit is **dark mode only** — no light mode. This is a deliberate design choice matching Apple Fitness+ and creating an immersive, cinematic experience.
|
||||
|
||||
Reasons:
|
||||
1. Better video contrast
|
||||
2. Less eye strain during workouts
|
||||
3. Premium feel
|
||||
4. Consistent with fitness studio lighting
|
||||
|
||||
---
|
||||
|
||||
## Image Guidelines
|
||||
|
||||
### Trainer Photos
|
||||
|
||||
- Professional, high-quality headshots
|
||||
- Warm, approachable expressions
|
||||
- Fitness attire visible
|
||||
- Consistent lighting style
|
||||
- Background: Dark or studio setting
|
||||
|
||||
### Workout Thumbnails
|
||||
|
||||
- Action shot from the workout
|
||||
- Clear exercise demonstration
|
||||
- Trainer visible
|
||||
- Dark/gradient background
|
||||
- Text overlay: Title, duration, level
|
||||
|
||||
### Collection Banners
|
||||
|
||||
- 16:9 aspect ratio
|
||||
- Composite of trainer + exercises
|
||||
- Gradient overlay for text
|
||||
- Brand accent color elements
|
||||
|
||||
---
|
||||
|
||||
## Copy Guidelines
|
||||
|
||||
### Tone
|
||||
|
||||
- **Confident** but not arrogant
|
||||
- **Encouraging** but not cheesy
|
||||
- **Clear** and direct
|
||||
- **Minimal** — let the content speak
|
||||
|
||||
### Examples
|
||||
|
||||
| Context | Good | Bad |
|
||||
|---------|------|-----|
|
||||
| CTA | "Start Workout" | "Start Your Workout Now!" |
|
||||
| Empty state | "No workouts yet" | "You haven't done any workouts :(" |
|
||||
| Error | "Connection lost" | "Oh no! Something went wrong!" |
|
||||
| Success | "Workout complete" | "AMAZING! You crushed it!!!" |
|
||||
|
||||
### Coach Dialogue
|
||||
|
||||
- Motivational but authentic
|
||||
- Form cues during exercises
|
||||
- Breathing reminders during rest
|
||||
- Encouragement without clichés
|
||||
|
||||
---
|
||||
|
||||
*Document created: February 18, 2026*
|
||||
*Version: 2.0*
|
||||
*Brand: Apple Fitness+ inspired*
|
||||
@@ -1,765 +0,0 @@
|
||||
# TabataFit — Product Design Document v2.0
|
||||
> Apple Fitness+ Design Language for Tabata
|
||||
|
||||
---
|
||||
|
||||
## Design Philosophy
|
||||
|
||||
**"Make it feel like a premium fitness studio in your pocket."**
|
||||
|
||||
TabataFit adopts le design language d'Apple Fitness+ :
|
||||
- **Dark mode premium** — Fond noir profond, couleurs vibrantes
|
||||
- **Video-first** — Le contenu est le héros
|
||||
- **Typography bold** — Gros titres, textes épurés
|
||||
- **Subtle animations** — Transitions fluides, feedback délicat
|
||||
- **Inclusive imagery** — Diversité des coachs et body types
|
||||
|
||||
---
|
||||
|
||||
## Color System — Dark Premium
|
||||
|
||||
### Background Colors
|
||||
|
||||
```typescript
|
||||
const COLORS = {
|
||||
// Backgrounds
|
||||
BACKGROUND: '#000000', // Pure black — comme Apple TV
|
||||
SURFACE: '#1C1C1E', // Raised surfaces
|
||||
ELEVATED: '#2C2C2E', // Cards, modals
|
||||
OVERLAY: 'rgba(0,0,0,0.6)', // Video overlays
|
||||
|
||||
// Brand Accent (Vibrant Orange-Red)
|
||||
BRAND: '#FF6B35', // Energy, action
|
||||
BRAND_LIGHT: '#FF8C5A', // Highlights
|
||||
BRAND_DARK: '#E55A25', // Pressed states
|
||||
|
||||
// Secondary Accents
|
||||
SUCCESS: '#34C759', // Completed, streaks
|
||||
WARNING: '#FF9500', // Rest phases
|
||||
INFO: '#5AC8FA', // Tips, info
|
||||
|
||||
// Text
|
||||
TEXT_PRIMARY: '#FFFFFF', // Main text
|
||||
TEXT_SECONDARY: '#EBEBF5', // Secondary (87% opacity)
|
||||
TEXT_TERTIARY: '#EBEBF599', // Tertiary (60% opacity)
|
||||
TEXT_DISABLED: '#3A3A3C', // Disabled text
|
||||
|
||||
// Semantic
|
||||
WORK: '#FF6B35', // Active work phase
|
||||
REST: '#5AC8FA', // Rest phase (calm blue)
|
||||
PREP: '#FF9500', // Countdown prep
|
||||
}
|
||||
```
|
||||
|
||||
### Gradient Presets
|
||||
|
||||
```typescript
|
||||
const GRADIENTS = {
|
||||
// Hero banners
|
||||
HERO_WORK: ['#FF6B35', '#E55A25'],
|
||||
HERO_REST: ['#5AC8FA', '#007AFF'],
|
||||
HERO_FEAT: ['#1C1C1E', '#000000'],
|
||||
|
||||
// Video overlays
|
||||
VIDEO_OVERLAY: ['transparent', 'rgba(0,0,0,0.8)'],
|
||||
VIDEO_TOP: ['rgba(0,0,0,0.4)', 'transparent'],
|
||||
|
||||
// Buttons
|
||||
CTA: ['#FF6B35', '#FF8C5A'],
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Typography System — Apple SF Pro Style
|
||||
|
||||
```typescript
|
||||
const TYPOGRAPHY = {
|
||||
// Hero/Display
|
||||
HERO: {
|
||||
fontFamily: 'Inter_900Black',
|
||||
fontSize: 48,
|
||||
lineHeight: 56,
|
||||
letterSpacing: -1,
|
||||
},
|
||||
|
||||
// Section Headers (like Apple Fitness+)
|
||||
TITLE_1: {
|
||||
fontFamily: 'Inter_700Bold',
|
||||
fontSize: 34,
|
||||
lineHeight: 41,
|
||||
letterSpacing: 0.37,
|
||||
},
|
||||
|
||||
TITLE_2: {
|
||||
fontFamily: 'Inter_700Bold',
|
||||
fontSize: 28,
|
||||
lineHeight: 34,
|
||||
letterSpacing: 0.36,
|
||||
},
|
||||
|
||||
TITLE_3: {
|
||||
fontFamily: 'Inter_600SemiBold',
|
||||
fontSize: 22,
|
||||
lineHeight: 28,
|
||||
letterSpacing: 0.35,
|
||||
},
|
||||
|
||||
// Body
|
||||
BODY: {
|
||||
fontFamily: 'Inter_400Regular',
|
||||
fontSize: 17,
|
||||
lineHeight: 22,
|
||||
letterSpacing: -0.41,
|
||||
},
|
||||
|
||||
BODY_BOLD: {
|
||||
fontFamily: 'Inter_600SemiBold',
|
||||
fontSize: 17,
|
||||
lineHeight: 22,
|
||||
letterSpacing: -0.41,
|
||||
},
|
||||
|
||||
// Metadata
|
||||
CAPTION_1: {
|
||||
fontFamily: 'Inter_400Regular',
|
||||
fontSize: 15,
|
||||
lineHeight: 20,
|
||||
letterSpacing: -0.24,
|
||||
},
|
||||
|
||||
CAPTION_2: {
|
||||
fontFamily: 'Inter_400Regular',
|
||||
fontSize: 14,
|
||||
lineHeight: 18,
|
||||
letterSpacing: -0.15,
|
||||
},
|
||||
|
||||
// Timer (special)
|
||||
TIMER: {
|
||||
fontFamily: 'Inter_900Black',
|
||||
fontSize: 96,
|
||||
lineHeight: 96,
|
||||
letterSpacing: -2,
|
||||
},
|
||||
|
||||
TIMER_PHASE: {
|
||||
fontFamily: 'Inter_700Bold',
|
||||
fontSize: 24,
|
||||
lineHeight: 28,
|
||||
letterSpacing: 2, // Uppercase tracking
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Screen Designs
|
||||
|
||||
### 1. Home Tab — "For You"
|
||||
|
||||
#### Layout Structure
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ status bar │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Bonjour, Alex [Profile] │ ← 24px padding
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ [VIDEO PREVIEW - LOOPING] │ │ ← Hero Card
|
||||
│ │ │ │ 16:9 aspect
|
||||
│ │ ┌─────────────────────────────────┐ │ │
|
||||
│ │ │ 🔥 FEATURED │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ FULL BODY BURN │ │ │ ← Text overlay
|
||||
│ │ │ 4 min • Beginner • Emma │ │ │ on video
|
||||
│ │ │ │ │ │
|
||||
│ │ │ [▶️ START] [♡ Save] │ │ │
|
||||
│ │ └─────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Continue See All → │ ← Section header
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ [Thumb] │ │ [Thumb] │ │ [Thumb] │ │ ← Horizontal scroll
|
||||
│ │ ━━━━━ │ │ ━━━━━ │ │ ━━━━━ │ │ 140x200px cards
|
||||
│ │ 65% │ │ 30% │ │ 10% │ │
|
||||
│ │ Core │ │ HIIT │ │ Full │ │
|
||||
│ │ Burn │ │ Extreme │ │ Body │ │
|
||||
│ └─────────┘ └─────────┘ └─────────┘ │
|
||||
│ │
|
||||
│ Popular This Week │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ [Thumb] │ │ [Thumb] │ │ [Thumb] │ │ [Thumb] │ │ ← Smaller cards
|
||||
│ │ │ │ │ │ │ │ │ │ │ 120x120px
|
||||
│ │ Quick │ │ Strength│ │ Cardio │ │ Core │ │
|
||||
│ │ Burn │ │ Tabata │ │ Blast │ │ Crush │ │
|
||||
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
|
||||
│ │
|
||||
│ Collections │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ 🌅 Morning Energizer │ │ ← Full-width cards
|
||||
│ │ 5 workouts • 20 min total │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ 🔥 7-Day Challenge │ │
|
||||
│ │ 7 workouts • Progressive intensity │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ [Home] [Workouts] [Activity] [Browse] [Profile] │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Component Specs
|
||||
|
||||
**Hero Card:**
|
||||
- Full width - 48px padding
|
||||
- 16:9 aspect ratio
|
||||
- Video preview looping (muted)
|
||||
- Gradient overlay: transparent → rgba(0,0,0,0.8)
|
||||
- Featured badge top-left
|
||||
- Title, metadata, CTA bottom
|
||||
|
||||
**Continue Watching Card:**
|
||||
- 140px width × 200px height
|
||||
- Thumbnail with progress bar overlay
|
||||
- Progress percentage badge
|
||||
- Workout name + duration
|
||||
|
||||
**Popular Card:**
|
||||
- 120px × 120px square
|
||||
- Thumbnail only
|
||||
- Category name below
|
||||
|
||||
**Collection Card:**
|
||||
- Full width - 48px padding
|
||||
- 80px height
|
||||
- Icon + title + description
|
||||
- Chevron right
|
||||
|
||||
---
|
||||
|
||||
### 2. Workouts Tab
|
||||
|
||||
#### Layout Structure
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ status bar │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Workouts [🔍 Search] │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ [LARGE CATEGORY THUMBNAIL] │ │
|
||||
│ │ │ │
|
||||
│ │ 🔥 QUICK BURN │ │
|
||||
│ │ 4 min • All levels │ │
|
||||
│ │ 12 workouts │ │
|
||||
│ │ [→] │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ [LARGE CATEGORY THUMBNAIL] │ │
|
||||
│ │ │ │
|
||||
│ │ 💪 STRENGTH TABATA │ │
|
||||
│ │ 8 min • Intermediate │ │
|
||||
│ │ 8 workouts │ │
|
||||
│ │ [→] │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ 🏃 CARDIO BLAST │ │
|
||||
│ │ 4-12 min • All levels • 15 workouts [→] │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ 🧘 CORE & FLEXIBILITY │ │
|
||||
│ │ 4 min • Beginner • 6 workouts [→] │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ ⚡ HIIT EXTREME │ │
|
||||
│ │ 12-20 min • Advanced • 10 workouts [→] │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ [Home] [Workouts] [Activity] [Browse] [Profile] │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Category Detail View
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ ← Quick Burn │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ 4 min • All levels • 12 workouts │ │
|
||||
│ │ │ │
|
||||
│ │ Filter: [All] [Beginner] [Intermediate] │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ ┌──────┐ │ │
|
||||
│ │ │[Vid] │ Full Body Ignite │ │
|
||||
│ │ │ │ 4 min • Beginner • Emma │ │
|
||||
│ │ └──────┘ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ ┌──────┐ │ │
|
||||
│ │ │[Vid] │ Cardio Crusher │ │
|
||||
│ │ │ │ 4 min • Intermediate • Alex │ │
|
||||
│ │ └──────┘ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ ┌──────┐ │ │
|
||||
│ │ │[Vid] │ Lower Body Blast │ │
|
||||
│ │ │ │ 4 min • Intermediate • Jake │ │
|
||||
│ │ └──────┘ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Pre-Workout Detail Screen
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ ← [♡] [···] │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ [VIDEO PREVIEW - LOOPING] │ │
|
||||
│ │ │ │
|
||||
│ │ Coach Emma in action │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ FULL BODY IGNITE │
|
||||
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
|
||||
│ │
|
||||
│ 👩 Emma • 💪 Beginner • ⏱️ 4 min • 🔥 45cal │
|
||||
│ │
|
||||
│ ───────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ What You'll Need │
|
||||
│ ○ No equipment required │
|
||||
│ ○ Yoga mat optional │
|
||||
│ │
|
||||
│ ───────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ Exercises (8 rounds) │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ 1. Jump Squats 20s work │ │
|
||||
│ │ 2. Mountain Climbers 20s work │ │
|
||||
│ │ 3. Burpees 20s work │ │
|
||||
│ │ 4. High Knees 20s work │ │
|
||||
│ │ ─────────────────────── │ │
|
||||
│ │ Repeat × 2 rounds │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ───────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ Music │
|
||||
│ 🎵 Electronic Energy │
|
||||
│ Upbeat, high-energy electronic tracks │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ ▶️ START WORKOUT │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Active Workout Screen — The Core Experience
|
||||
|
||||
#### Work Phase
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ [FULL SCREEN VIDEO] │ │
|
||||
│ │ │ │
|
||||
│ │ Coach doing Jump Squats │ │
|
||||
│ │ in perfect form │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ ┌───────────────────────────────────────┐ │ │
|
||||
│ │ │ ┌─────────────────────────────────┐ │ │ │
|
||||
│ │ │ │ 🔥 WORK │ │ │ │ ← Timer overlay
|
||||
│ │ │ │ │ │ │ │ bottom gradient
|
||||
│ │ │ │ 00:14 │ │ │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ │ │ Round 3 of 8 │ │ │ │
|
||||
│ │ │ │ ████████████░░░░ 65% │ │ │ │
|
||||
│ │ │ └─────────────────────────────────┘ │ │ │
|
||||
│ │ └───────────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ JUMP SQUATS │
|
||||
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
|
||||
│ │
|
||||
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
|
||||
│ │ 52 │ │ 142 │ │ 85% │ │
|
||||
│ │ CALORIES │ │ BPM │ │ EFFORT │ │
|
||||
│ └───────────┘ └───────────┘ └───────────┘ │
|
||||
│ │
|
||||
│ Burn Bar │
|
||||
│ ░░░░░░░░████████████████░░░░ 72nd percentile │
|
||||
│ │
|
||||
│ [⏸️ Pause] [⛶ Fullscreen]│
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Rest Phase
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ [COACH IN REST POSITION] │ │
|
||||
│ │ │ │
|
||||
│ │ "Shake it out, take a breath" │ │
|
||||
│ │ │ │
|
||||
│ │ ┌───────────────────────────────────────┐ │ │
|
||||
│ │ │ ┌─────────────────────────────────┐ │ │ │
|
||||
│ │ │ │ 💙 REST │ │ │ │ ← Blue rest theme
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ │ │ 00:08 │ │ │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ │ │ Next: Mountain Climbers │ │ │ │
|
||||
│ │ │ │ ░░░░░░░░░░░░░░░░░░░ 40% │ │ │ │
|
||||
│ │ │ └─────────────────────────────────┘ │ │ │
|
||||
│ │ └───────────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ UP NEXT │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ ┌──────┐ │ │
|
||||
│ │ │ GIF │ Mountain Climbers │ │
|
||||
│ │ │preview│ "Core engaged, drive knees forward" │ │
|
||||
│ │ └──────┘ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [⏸️ Pause] [⛶ Fullscreen]│
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 3-2-1 Countdown (Pre-Work)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ GET READY! │ │
|
||||
│ │ │ │
|
||||
│ │ 3 │ │ ← Giant number
|
||||
│ │ │ │ centered
|
||||
│ │ │ │
|
||||
│ │ JUMP SQUATS │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Workout Complete Screen
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ │
|
||||
│ 🎉 │
|
||||
│ │
|
||||
│ WORKOUT COMPLETE │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ [ANIMATED CELEBRATION RINGS] │ │
|
||||
│ │ │ │
|
||||
│ │ 🔥 💪 ⚡ │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
|
||||
│ │ 52 │ │ 4 │ │ 100% │ │
|
||||
│ │ CALORIES │ │ MINUTES │ │ COMPLETE │ │
|
||||
│ └───────────┘ └───────────┘ └───────────┘ │
|
||||
│ │
|
||||
│ Burn Bar │
|
||||
│ You beat 73% of users! │
|
||||
│ ░░░░░░░░████████████████░░░░ │
|
||||
│ │
|
||||
│ ───────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ 🔥 7 Day Streak! │
|
||||
│ Keep the momentum going! │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ 📤 SHARE YOUR WORKOUT │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ ← BACK TO HOME │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Recommended Next │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ [Thumb] │ │ [Thumb] │ │ [Thumb] │ │
|
||||
│ │ Core │ │ Upper │ │ Cardio │ │
|
||||
│ │ Crush │ │ Body │ │ Blast │ │
|
||||
│ └─────────┘ └─────────┘ └─────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Activity Tab
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Activity │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ 🔥 STREAK │ │
|
||||
│ │ │ │
|
||||
│ │ 7 │ │
|
||||
│ │ DAYS │ │
|
||||
│ │ │ │
|
||||
│ │ ● ● ● ● ● ● ● ○ ○ ○ │ │
|
||||
│ │ M T W T F S S M T W │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ This Week │
|
||||
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
|
||||
│ │ 5 │ │ 156 │ │ 20 │ │
|
||||
│ │ WORKOUTS │ │ CALORIES │ │ MINUTES │ │
|
||||
│ │ 5 goal │ │ 150 goal │ │ 20 goal │ │
|
||||
│ └───────────┘ └───────────┘ └───────────┘ │
|
||||
│ │
|
||||
│ Monthly Summary │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ [CALENDAR HEAT MAP] │ │
|
||||
│ │ │ │
|
||||
│ │ Jan 2026 │ │
|
||||
│ │ S M T W T F S │ │
|
||||
│ │ 1 2 3 4 5 6 │ │
|
||||
│ │ 7 8 9 10 11 12 13 │ │
|
||||
│ │ 14 15 16 17 18 19 20 │ │
|
||||
│ │ ░ ░ █ █ ░ █ █ │ │
|
||||
│ │ █ █ ░ █ █ ░ ░ │ │
|
||||
│ │ ░ █ █ █ █ █ █ │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Trends │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ 📈 Workouts trending up! │ │
|
||||
│ │ +23% vs last month │ │
|
||||
│ │ │ │
|
||||
│ │ [WEEKLY CHART] │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Burn Bar Position │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ Your average: 45 cal/workout │ │
|
||||
│ │ ████████████░░░░ 68th percentile │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. Browse Tab
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Browse │
|
||||
│ │
|
||||
│ Filters [Edit] │
|
||||
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │
|
||||
│ │ All ▼ │ │ 4 min │ │ 8 min │ │ Begin │ │
|
||||
│ └────────┘ └────────┘ └────────┘ └────────┘ │
|
||||
│ │
|
||||
│ Trainers │
|
||||
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │
|
||||
│ │ 👩 │ │ 👨 │ │ 👩 │ │ 👨 │ │
|
||||
│ │ Emma │ │ Jake │ │ Mia │ │ Alex │ │
|
||||
│ │ 12 wk │ │ 8 wk │ │ 10 wk │ │ 6 wk │ │
|
||||
│ └────────┘ └────────┘ └────────┘ └────────┘ │
|
||||
│ │
|
||||
│ Duration │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ ○ 4 min (Classic Tabata) 20 │ │
|
||||
│ │ ○ 8 min (Double Tabata) 15 │ │
|
||||
│ │ ○ 12 min (Triple Tabata) 10 │ │
|
||||
│ │ ○ 20 min (Tabata Marathon) 5 │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Focus Area │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ ○ Full Body 20 │ │
|
||||
│ │ ○ Upper Body 8 │ │
|
||||
│ │ ○ Lower Body 8 │ │
|
||||
│ │ ○ Core 8 │ │
|
||||
│ │ ○ Cardio 6 │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Music Vibe │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ ○ Electronic Energy 18 │ │
|
||||
│ │ ○ Hip-Hop Beats 12 │ │
|
||||
│ │ ○ Rock Power 10 │ │
|
||||
│ │ ○ Chill Focus 10 │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Collections │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ 🌅 Morning Energizer • 5 workouts │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ 💪 No Equipment • 15 workouts │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ 🔥 7-Day Challenge • 7 workouts │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8. Profile Tab
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Profile │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ 👤 Alex Martin │ │
|
||||
│ │ Member since Jan 2026 │ │
|
||||
│ │ ✨ Premium │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Weekly Goal │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ 5 workouts per week │ │
|
||||
│ │ ████████████████░░░░ 4/5 this week │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Achievements │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ 🏆 7-Day Streak 🥵 First Sweat │ │
|
||||
│ │ 💯 100 Workouts 🌅 Early Bird │ │
|
||||
│ │ 🔥 500 Calories ⚡ Speed Demon │ │
|
||||
│ │ │ │
|
||||
│ │ [See All Achievements →] │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Settings │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ Notifications [→] │ │
|
||||
│ │ Apple Watch [→] │ │
|
||||
│ │ Music Preferences [→] │ │
|
||||
│ │ Workout Preferences [→] │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Account │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ Subscription [→] │ │
|
||||
│ │ Privacy & Security [→] │ │
|
||||
│ │ Help & Support [→] │ │
|
||||
│ │ Sign Out │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ TabataFit v1.0.0 │
|
||||
│ Made with ❤️ for HIIT lovers │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Animation Specifications
|
||||
|
||||
### Screen Transitions
|
||||
- **Push/Pop**: 300ms ease-out
|
||||
- **Modal**: Slide up from bottom, 350ms
|
||||
|
||||
### Micro-interactions
|
||||
- **Button press**: Scale to 0.96, 100ms
|
||||
- **Card tap**: Scale to 0.98, 150ms
|
||||
- **Toggle**: 200ms spring animation
|
||||
|
||||
### Timer Animations
|
||||
- **Countdown**: Number scales up/down each second
|
||||
- **Progress bar**: Smooth width animation
|
||||
- **Phase change**: Color crossfade 300ms
|
||||
|
||||
### Celebration
|
||||
- **Confetti**: Lottie animation on workout complete
|
||||
- **Rings**: Animated fill when stats update
|
||||
- **Streak badge**: Pulse animation
|
||||
|
||||
---
|
||||
|
||||
## Accessibility
|
||||
|
||||
- **Dynamic Type**: Support up to 200% text scaling
|
||||
- **VoiceOver**: Full screen reader support
|
||||
- **Reduce Motion**: Disable animations when requested
|
||||
- **High Contrast**: Alternative color scheme option
|
||||
|
||||
---
|
||||
|
||||
*Document created: February 18, 2026*
|
||||
*Version: 2.0*
|
||||
*Design System: Apple Fitness+ Inspired*
|
||||
@@ -1,545 +0,0 @@
|
||||
# TabataFit — Product Requirements Document v2.0
|
||||
> Apple Fitness+ for Tabata — The Premium HIIT Experience
|
||||
|
||||
---
|
||||
|
||||
## Vision Statement
|
||||
|
||||
**TabataFit est l'Apple Fitness+ du Tabata.** Une expérience premium, visuellement stunante, guidée par des coachs, qui transforme 4 minutes d'exercice en une expérience de fitness immersive.
|
||||
|
||||
*"Workouts that work. Beautifully."*
|
||||
|
||||
---
|
||||
|
||||
## Positionnement
|
||||
|
||||
| Aspect | Apple Fitness+ | TabataFit |
|
||||
|--------|---------------|-----------|
|
||||
| **Focus** | Multi-activité (Yoga, HIIT, Strength, etc.) | Spécialiste Tabata/HIIT |
|
||||
| **Durée** | 5-45 min | 4-20 min (format Tabata) |
|
||||
| **Différenciateur** | Intégration Apple Watch | Timer intelligent + Coaching audio |
|
||||
| **Cible** | Grand public fitness | Athlètes HIIT, busy professionals |
|
||||
| **Vibe** | Studio californien | Énergie explosive, motivational |
|
||||
|
||||
---
|
||||
|
||||
## Core Philosophy — Apple Fitness+ Principles
|
||||
|
||||
1. **Content is King** — Vidéos HD, coachs charismatiques, production Netflix-quality
|
||||
2. **Inclusive** — Tous niveaux, modifications montrées
|
||||
3. **Personalized** — Recommandations basées sur l'historique
|
||||
4. **Immersive** — Music sync, Burn Bar, stats temps réel
|
||||
5. **Beautiful** — Design épuré, animations fluides, dark theme élégant
|
||||
|
||||
---
|
||||
|
||||
## Architecture Produit
|
||||
|
||||
### Tab Bar (5 onglets — comme Apple Fitness+)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ 🏠 Home 🔥 Workouts 📊 Activity 🔍 Browse 👤 Profile │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 1. Home Tab — "For You"
|
||||
|
||||
**Inspiration**: Apple Fitness+ Home — grande bannière, collections, recommandations
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ ☀️ Bonjour Alex │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ 🎬 FEATURED WORKOUT │ │
|
||||
│ │ ─────────────────────── │ │
|
||||
│ │ Full Body Burn │ │
|
||||
│ │ 4 min • Beginner • Emma │ │
|
||||
│ │ │ │
|
||||
│ │ [▶️ START NOW] │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Continue Watching │
|
||||
│ ┌─────┐ ┌─────┐ ┌─────┐ │
|
||||
│ │ 65%│ │ 30%│ │ 10%│ │
|
||||
│ └─────┘ └─────┘ └─────┘ │
|
||||
│ │
|
||||
│ Popular This Week │
|
||||
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
|
||||
│ └─────┘ └─────┘ └─────┘ └─────┘ │
|
||||
│ │
|
||||
│ Collections │
|
||||
│ 🌅 Morning Energizer │
|
||||
│ 💪 No Equipment Needed │
|
||||
│ 🔥 7-Day Challenge │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Elements clés:**
|
||||
- Hero banner avec vidéo preview en boucle
|
||||
- "Continue Watching" — workouts non terminés
|
||||
- "Popular This Week" — trending workouts
|
||||
- Collections thématiques
|
||||
- Coach du moment
|
||||
|
||||
### 2. Workouts Tab — Parcourir par type
|
||||
|
||||
**Inspiration**: Apple Fitness+ workout browser — categories visuelles
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ WORKOUTS 🔍 │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ 🔥 QUICK BURN │ │
|
||||
│ │ 4 min • All levels │ │
|
||||
│ │ 12 workouts │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ 💪 STRENGTH TABATA │ │
|
||||
│ │ 8 min • Intermediate │ │
|
||||
│ │ 8 workouts │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ 🏃 CARDIO BLAST │ │
|
||||
│ │ 4-12 min • All levels │ │
|
||||
│ │ 15 workouts │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ 🧘 CORE & FLEXIBILITY │ │
|
||||
│ │ 4 min • Beginner friendly │ │
|
||||
│ │ 6 workouts │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ ⚡ HIIT EXTREME │ │
|
||||
│ │ 12-20 min • Advanced │ │
|
||||
│ │ 10 workouts │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Categories:**
|
||||
1. **Quick Burn** — 4 min, perfect for beginners
|
||||
2. **Strength Tabata** — Resistance exercises
|
||||
3. **Cardio Blast** — Pure cardio, no equipment
|
||||
4. **Core & Flexibility** — Abs, stretching
|
||||
5. **HIIT Extreme** — Advanced, longer sessions
|
||||
|
||||
### 3. Activity Tab — Stats & Progress
|
||||
|
||||
**Inspiration**: Apple Fitness+ Activity rings + trends
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ ACTIVITY │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ 🔥 STREAK │ │
|
||||
│ │ ───────── │ │
|
||||
│ │ 7 │ │
|
||||
│ │ DAYS │ │
|
||||
│ │ │ │
|
||||
│ │ ○ ○ ○ ○ ○ ○ ○ ● ○ ○ │ │
|
||||
│ │ M T W T F S S M T │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ This Week │
|
||||
│ ┌───────┐ ┌───────┐ ┌───────┐ │
|
||||
│ │ 5 │ │ 156 │ │ 32 │ │
|
||||
│ │Workout│ │ Calories│ │ Minutes│ │
|
||||
│ └───────┘ └───────┘ └───────┘ │
|
||||
│ │
|
||||
│ Trends │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ 📈 Workouts are trending up! │ │
|
||||
│ │ +23% vs last month │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Burn Bar Position │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ Your avg: 45 cal/workout │ │
|
||||
│ │ ████████░░░░ 68th percentile │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Monthly Summary │
|
||||
│ [ Calendar view with heat map ] │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Streak counter avec calendrier visuel
|
||||
- Stats hebdomadaires (workouts, calories, minutes)
|
||||
- Trends ("You're on fire! 🔥")
|
||||
- Burn Bar — comparaison avec autres utilisateurs
|
||||
- Calendar heat map
|
||||
|
||||
### 4. Browse Tab — Tout le contenu
|
||||
|
||||
**Inspiration**: Apple Fitness+ Browse — filtres, trainers, music
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ BROWSE │
|
||||
│ │
|
||||
│ Filters [Edit]│
|
||||
│ [All ▼] [4 min] [8 min] [Beginner] │
|
||||
│ │
|
||||
│ By Trainer │
|
||||
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
|
||||
│ │ 👩 │ │ 👨 │ │ 👩 │ │ 👨 │ │
|
||||
│ │Emma │ │Jake │ │Mia │ │Alex │ │
|
||||
│ └─────┘ └─────┘ └─────┘ └─────┘ │
|
||||
│ │
|
||||
│ By Duration │
|
||||
│ ○ 4 min (Classic Tabata) │
|
||||
│ ○ 8 min (Double Tabata) │
|
||||
│ ○ 12 min (Triple Tabata) │
|
||||
│ ○ 20 min (Tabata Marathon) │
|
||||
│ │
|
||||
│ By Focus Area │
|
||||
│ ○ Full Body │
|
||||
│ ○ Upper Body │
|
||||
│ ○ Lower Body │
|
||||
│ ○ Core │
|
||||
│ ○ Cardio │
|
||||
│ │
|
||||
│ Music Vibe │
|
||||
│ ○ Electronic Energy │
|
||||
│ ○ Hip-Hop Beats │
|
||||
│ ○ Rock Power │
|
||||
│ ○ Chill Focus │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 5. Profile Tab — Settings & Account
|
||||
|
||||
**Inspiration**: Apple Fitness+ Profile — minimal, clean
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ PROFILE │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ 👤 Alex Martin │ │
|
||||
│ │ Member since Jan 2026 │ │
|
||||
│ │ Premium ✨ │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Goals │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ Weekly Goal: 5 workouts │ │
|
||||
│ │ ████████░░ 4/5 this week │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Achievements │
|
||||
│ 🏆 7-Day Streak │
|
||||
│ 🥵 First Sweat │
|
||||
│ 💯 100 Workouts │
|
||||
│ [See all →] │
|
||||
│ │
|
||||
│ Settings │
|
||||
│ • Notifications │
|
||||
│ • Apple Watch │
|
||||
│ • Music Preferences │
|
||||
│ • Account │
|
||||
│ • Subscription │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## The Workout Experience — Cœur du Produit
|
||||
|
||||
### Pre-Workout Screen
|
||||
|
||||
**Inspiration**: Apple Fitness+ workout preview — trailer, details, start
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ ← │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ [VIDEO PREVIEW LOOP] │ │
|
||||
│ │ Coach Emma demonstrating │ │
|
||||
│ │ Jump Squats │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ │
|
||||
│ FULL BODY BURN │
|
||||
│ ───────────────────────────────────── │
|
||||
│ │
|
||||
│ 👩 Emma • 💪 Intermediate • ⏱️ 4 min │
|
||||
│ │
|
||||
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
|
||||
│ │
|
||||
│ What You'll Need │
|
||||
│ ○ No equipment │
|
||||
│ ○ Mat recommended │
|
||||
│ │
|
||||
│ Exercises Preview │
|
||||
│ 1. Jump Squats (20s work) │
|
||||
│ 2. Mountain Climbers (20s work) │
|
||||
│ 3. Burpees (20s work) │
|
||||
│ 4. High Knees (20s work) │
|
||||
│ × 2 rounds │
|
||||
│ │
|
||||
│ Music │
|
||||
│ 🎵 Electronic Energy playlist │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ ▶️ START WORKOUT │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Active Workout Screen — Apple Fitness+ Style
|
||||
|
||||
**Inspiration**: Apple Fitness+ player — video dominant, stats overlay
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ [FULL SCREEN VIDEO] │ │
|
||||
│ │ │ │
|
||||
│ │ Coach doing exercise │ │
|
||||
│ │ in perfect form │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────────────────────────┐ │ │
|
||||
│ │ │ 🔥 WORK • 00:14 │ │ │
|
||||
│ │ │ Round 3 of 8 │ │ │
|
||||
│ │ │ ████████████░░░░ 65% │ │ │
|
||||
│ │ └─────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ │
|
||||
│ JUMP SQUATS │
|
||||
│ ───────────────────────────────────── │
|
||||
│ │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ 14 │ │ 52 │ │ 85% │ │
|
||||
│ │ cal │ │ bpm │ │ effort │ │
|
||||
│ └─────────┘ └─────────┘ └─────────┘ │
|
||||
│ │
|
||||
│ Burn Bar: ████████░░░░ 72% │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Key Features:**
|
||||
- Video full screen avec coach
|
||||
- Timer overlay (phase + countdown)
|
||||
- Round indicator
|
||||
- Progress bar
|
||||
- Stats temps réel (calories, bpm si Apple Watch)
|
||||
- Burn Bar
|
||||
|
||||
### During Rest Phase
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ [COACH IN REST POSE] │ │
|
||||
│ │ Stretching / breathing │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────────────────────────┐ │ │
|
||||
│ │ │ 💙 REST • 00:08 │ │ │
|
||||
│ │ │ Next: Mountain Climbers │ │ │
|
||||
│ │ │ ░░░░░░░░░░░░░░░░░░ 40% │ │ │
|
||||
│ │ └─────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ "Shake it out, you're │ │
|
||||
│ │ doing great!" │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Up Next: Mountain Climbers │
|
||||
│ [GIF preview of next exercise] │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Workout Complete — Celebration
|
||||
|
||||
**Inspiration**: Apple Fitness+ celebration screen
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ │
|
||||
│ 🎉 WORKOUT COMPLETE! │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ [ANIMATED RINGS] │ │
|
||||
│ │ 🔥 🔥 🔥 │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ │
|
||||
│ YOUR STATS │
|
||||
│ ───────────────────────────────────── │
|
||||
│ │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ 52 │ │ 4 │ │ 100% │ │
|
||||
│ │ CALORIES│ │ MINUTES │ │ COMPLETE│ │
|
||||
│ └─────────┘ └─────────┘ └─────────┘ │
|
||||
│ │
|
||||
│ Burn Bar │
|
||||
│ ████████████░░░ You beat 73%! │
|
||||
│ │
|
||||
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
|
||||
│ │
|
||||
│ 🔥 7 Day Streak! Keep it going! │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ SHARE YOUR WORKOUT │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ ← BACK TO HOME │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Content Strategy — 50+ Workouts au Launch
|
||||
|
||||
### Par Durée
|
||||
|
||||
| Duration | Format | Rounds | Count |
|
||||
|----------|--------|--------|-------|
|
||||
| 4 min | Classic Tabata | 8 rounds | 20 workouts |
|
||||
| 8 min | Double Tabata | 16 rounds | 15 workouts |
|
||||
| 12 min | Triple Tabata | 24 rounds | 10 workouts |
|
||||
| 20 min | Tabata Marathon | 40 rounds | 5 workouts |
|
||||
|
||||
### Par Focus
|
||||
|
||||
- **Full Body** — 20 workouts
|
||||
- **Upper Body** — 8 workouts
|
||||
- **Lower Body** — 8 workouts
|
||||
- **Core** — 8 workouts
|
||||
- **Cardio Only** — 6 workouts
|
||||
|
||||
### Par Niveau
|
||||
|
||||
- **Beginner** — 15 workouts
|
||||
- **Intermediate** — 20 workouts
|
||||
- **Advanced** — 15 workouts
|
||||
|
||||
### Trainers (5 au launch)
|
||||
|
||||
1. **Emma** — Energy queen, beginner-friendly
|
||||
2. **Jake** — Strength focus, motivating
|
||||
3. **Mia** — Form perfectionist, technical
|
||||
4. **Alex** — Cardio beast, intense
|
||||
5. **Sofia** — Chill but effective, recovery
|
||||
|
||||
---
|
||||
|
||||
## Technical Requirements
|
||||
|
||||
### Video Pipeline
|
||||
- HLS streaming (adaptive bitrate)
|
||||
- 1080p minimum, 4K for featured
|
||||
- Offline download for Premium
|
||||
- Preload next exercise during rest
|
||||
|
||||
### Audio
|
||||
- Multiple music tracks (by vibe)
|
||||
- Coach voice-over (can be muted)
|
||||
- Sound effects (beeps, transitions)
|
||||
- Haptic feedback sync
|
||||
|
||||
### Apple Watch Integration
|
||||
- Heart rate display
|
||||
- Calories calculation
|
||||
- Activity rings update
|
||||
- Now Playing controls
|
||||
|
||||
### Offline Support
|
||||
- Download workouts for offline
|
||||
- Sync when back online
|
||||
- Local stats caching
|
||||
|
||||
---
|
||||
|
||||
## Monetization
|
||||
|
||||
### Free Tier
|
||||
- 3 workouts free forever
|
||||
- Basic stats
|
||||
- Ads between workouts
|
||||
|
||||
### Premium ($6.99/mo or $49.99/yr)
|
||||
- Unlimited workouts
|
||||
- All trainers
|
||||
- Offline downloads
|
||||
- Advanced stats & trends
|
||||
- Apple Watch integration
|
||||
- No ads
|
||||
- Family Sharing (up to 5)
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
|
||||
| Metric | Target (Month 3) |
|
||||
|--------|------------------|
|
||||
| DAU | 10,000 |
|
||||
| Workout completion rate | 75% |
|
||||
| 7-day retention | 40% |
|
||||
| Premium conversion | 8% |
|
||||
| Average workouts/user/week | 3.5 |
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Phase 1 — MVP (Weeks 1-4)
|
||||
- [ ] Home + Workouts tabs
|
||||
- [ ] 20 workouts (4 min only)
|
||||
- [ ] 2 trainers
|
||||
- [ ] Basic timer + video player
|
||||
|
||||
### Phase 2 — Core (Weeks 5-8)
|
||||
- [ ] Activity tab with stats
|
||||
- [ ] 30 workouts total
|
||||
- [ ] 4 trainers
|
||||
- [ ] Apple Watch integration
|
||||
|
||||
### Phase 3 — Premium (Weeks 9-12)
|
||||
- [ ] Browse + Profile tabs
|
||||
- [ ] 50+ workouts
|
||||
- [ ] 5 trainers
|
||||
- [ ] Offline downloads
|
||||
- [ ] Burn Bar
|
||||
- [ ] Subscription system
|
||||
|
||||
---
|
||||
|
||||
*Document created: February 18, 2026*
|
||||
*Version: 2.0*
|
||||
*Status: Ready for Design Phase*
|
||||
91
app.json
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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' },
|
||||
})
|
||||
}
|
||||
@@ -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 },
|
||||
})
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
260
app/_layout.tsx
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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' },
|
||||
})
|
||||
}
|
||||
1341
app/onboarding.tsx
460
app/paywall.tsx
@@ -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],
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
212
app/privacy.tsx
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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 },
|
||||
})
|
||||
223
app/settings.tsx
@@ -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],
|
||||
},
|
||||
})
|
||||
106
app/terms.tsx
@@ -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],
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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],
|
||||
},
|
||||
})
|
||||
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 151 KiB |
@@ -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 |
|
Before Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 18 MiB |
|
Before Width: | Height: | Size: 435 KiB |
|
Before Width: | Height: | Size: 10 MiB |
BIN
assets/model.glb
36
eas.json
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/*'],
|
||||
},
|
||||
]);
|
||||
20
fix_i18n.js
@@ -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
97
package.json
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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!()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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!()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
},
|
||||
}))
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)')
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
@@ -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 },
|
||||
})
|
||||