feat: Apple Watch app + Paywall + Privacy Policy + rebranding

## Major Features
- Apple Watch companion app (6 phases complete)
  - WatchConnectivity iPhone ↔ Watch
  - HealthKit integration (HR, calories)
  - SwiftUI premium UI
  - 9 complication types
  - Always-On Display support

- Paywall screen with RevenueCat integration
- Privacy Policy screen
- App rebranding: tabatago → TabataFit
- Bundle ID: com.millianlmx.tabatafit

## Changes
- New: ios/TabataFit Watch App/ (complete Watch app)
- New: app/paywall.tsx (subscription UI)
- New: app/privacy.tsx (privacy policy)
- New: src/features/watch/ (Watch sync hooks)
- New: admin-web/ (admin dashboard)
- Updated: app.json, package.json (branding)
- Updated: profile.tsx (paywall + privacy links)
- Updated: i18n translations (EN/FR/DE/ES)
- New: app icon 1024x1024

## Watch App Files
- TabataFitWatchApp.swift (entry point)
- ContentView.swift (premium UI)
- HealthKitManager.swift (HR + calories)
- WatchSessionManager.swift (communication)
- Complications/ (WidgetKit)
- UserDefaults+Shared.swift (data sharing)
This commit is contained in:
Millian Lamiaux
2026-03-11 09:43:53 +01:00
parent f80798069b
commit 2ad7ae3a34
86 changed files with 19648 additions and 365 deletions

View File

@@ -0,0 +1,204 @@
-- Supabase Schema for TabataFit
-- Run this in your Supabase SQL Editor
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Trainers table
CREATE TABLE trainers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
specialty TEXT NOT NULL,
color TEXT NOT NULL,
avatar_url TEXT,
workout_count INTEGER DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Workouts table
CREATE TABLE workouts (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
trainer_id TEXT NOT NULL REFERENCES trainers(id) ON DELETE CASCADE,
category TEXT NOT NULL CHECK (category IN ('full-body', 'core', 'upper-body', 'lower-body', 'cardio')),
level TEXT NOT NULL CHECK (level IN ('Beginner', 'Intermediate', 'Advanced')),
duration INTEGER NOT NULL CHECK (duration IN (4, 8, 12, 20)),
calories INTEGER NOT NULL,
rounds INTEGER NOT NULL,
prep_time INTEGER NOT NULL DEFAULT 10,
work_time INTEGER NOT NULL DEFAULT 20,
rest_time INTEGER NOT NULL DEFAULT 10,
equipment TEXT[] DEFAULT '{}',
music_vibe TEXT NOT NULL CHECK (music_vibe IN ('electronic', 'hip-hop', 'pop', 'rock', 'chill')),
exercises JSONB NOT NULL DEFAULT '[]',
thumbnail_url TEXT,
video_url TEXT,
is_featured BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Collections table
CREATE TABLE collections (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL,
icon TEXT NOT NULL,
gradient TEXT[],
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Collection-Workout junction table
CREATE TABLE collection_workouts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
collection_id TEXT NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
workout_id TEXT NOT NULL REFERENCES workouts(id) ON DELETE CASCADE,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(collection_id, workout_id)
);
-- Programs table
CREATE TABLE programs (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL,
weeks INTEGER NOT NULL,
workouts_per_week INTEGER NOT NULL,
level TEXT NOT NULL CHECK (level IN ('Beginner', 'Intermediate', 'Advanced')),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Program-Workout junction table
CREATE TABLE program_workouts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
program_id TEXT NOT NULL REFERENCES programs(id) ON DELETE CASCADE,
workout_id TEXT NOT NULL REFERENCES workouts(id) ON DELETE CASCADE,
week_number INTEGER NOT NULL,
day_number INTEGER NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(program_id, week_number, day_number)
);
-- Achievements table
CREATE TABLE achievements (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL,
icon TEXT NOT NULL,
requirement INTEGER NOT NULL,
type TEXT NOT NULL CHECK (type IN ('workouts', 'streak', 'minutes', 'calories')),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Admin users table (for dashboard access)
CREATE TABLE admin_users (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
email TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'editor' CHECK (role IN ('admin', 'editor')),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
last_login TIMESTAMP WITH TIME ZONE
);
-- Create storage buckets
INSERT INTO storage.buckets (id, name, public) VALUES
('videos', 'videos', true),
('thumbnails', 'thumbnails', true),
('avatars', 'avatars', true)
ON CONFLICT (id) DO NOTHING;
-- Set up storage policies
CREATE POLICY "Public videos access"
ON storage.objects FOR SELECT
USING (bucket_id = 'videos');
CREATE POLICY "Admin videos upload"
ON storage.objects FOR INSERT
WITH CHECK (
bucket_id = 'videos'
AND EXISTS (SELECT 1 FROM admin_users WHERE id = auth.uid())
);
CREATE POLICY "Public thumbnails access"
ON storage.objects FOR SELECT
USING (bucket_id = 'thumbnails');
CREATE POLICY "Admin thumbnails upload"
ON storage.objects FOR INSERT
WITH CHECK (
bucket_id = 'thumbnails'
AND EXISTS (SELECT 1 FROM admin_users WHERE id = auth.uid())
);
CREATE POLICY "Public avatars access"
ON storage.objects FOR SELECT
USING (bucket_id = 'avatars');
CREATE POLICY "Admin avatars upload"
ON storage.objects FOR INSERT
WITH CHECK (
bucket_id = 'avatars'
AND EXISTS (SELECT 1 FROM admin_users WHERE id = auth.uid())
);
-- Row Level Security policies
ALTER TABLE trainers ENABLE ROW LEVEL SECURITY;
ALTER TABLE workouts ENABLE ROW LEVEL SECURITY;
ALTER TABLE collections ENABLE ROW LEVEL SECURITY;
ALTER TABLE collection_workouts ENABLE ROW LEVEL SECURITY;
ALTER TABLE programs ENABLE ROW LEVEL SECURITY;
ALTER TABLE program_workouts ENABLE ROW LEVEL SECURITY;
ALTER TABLE achievements ENABLE ROW LEVEL SECURITY;
ALTER TABLE admin_users ENABLE ROW LEVEL SECURITY;
-- Public read access for all content
CREATE POLICY "Public trainers read" ON trainers FOR SELECT USING (true);
CREATE POLICY "Public workouts read" ON workouts FOR SELECT USING (true);
CREATE POLICY "Public collections read" ON collections FOR SELECT USING (true);
CREATE POLICY "Public collection_workouts read" ON collection_workouts FOR SELECT USING (true);
CREATE POLICY "Public programs read" ON programs FOR SELECT USING (true);
CREATE POLICY "Public program_workouts read" ON program_workouts FOR SELECT USING (true);
CREATE POLICY "Public achievements read" ON achievements FOR SELECT USING (true);
-- Admin write access
CREATE POLICY "Admin trainers all" ON trainers
FOR ALL USING (EXISTS (SELECT 1 FROM admin_users WHERE id = auth.uid()));
CREATE POLICY "Admin workouts all" ON workouts
FOR ALL USING (EXISTS (SELECT 1 FROM admin_users WHERE id = auth.uid()));
CREATE POLICY "Admin collections all" ON collections
FOR ALL USING (EXISTS (SELECT 1 FROM admin_users WHERE id = auth.uid()));
CREATE POLICY "Admin collection_workouts all" ON collection_workouts
FOR ALL USING (EXISTS (SELECT 1 FROM admin_users WHERE id = auth.uid()));
CREATE POLICY "Admin programs all" ON programs
FOR ALL USING (EXISTS (SELECT 1 FROM admin_users WHERE id = auth.uid()));
CREATE POLICY "Admin program_workouts all" ON program_workouts
FOR ALL USING (EXISTS (SELECT 1 FROM admin_users WHERE id = auth.uid()));
CREATE POLICY "Admin achievements all" ON achievements
FOR ALL USING (EXISTS (SELECT 1 FROM admin_users WHERE id = auth.uid()));
CREATE POLICY "Admin users manage" ON admin_users
FOR ALL USING (EXISTS (SELECT 1 FROM admin_users WHERE id = auth.uid()));
-- Function to update updated_at timestamp
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
-- Triggers for updated_at
CREATE TRIGGER update_trainers_updated_at BEFORE UPDATE ON trainers
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_workouts_updated_at BEFORE UPDATE ON workouts
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_collections_updated_at BEFORE UPDATE ON collections
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_programs_updated_at BEFORE UPDATE ON programs
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_achievements_updated_at BEFORE UPDATE ON achievements
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();

183
supabase/seed.ts Normal file
View File

@@ -0,0 +1,183 @@
/**
* TabataFit Seed Script
*
* This script seeds your Supabase database with the initial data.
* Run this after setting up your Supabase project.
*
* Usage:
* 1. Set your Supabase credentials in .env
* 2. Run: npx ts-node supabase/seed.ts
*/
import { createClient } from '@supabase/supabase-js'
import { WORKOUTS } from '../src/shared/data/workouts'
import { TRAINERS } from '../src/shared/data/trainers'
import { COLLECTIONS, PROGRAMS } from '../src/shared/data/collections'
import { ACHIEVEMENTS } from '../src/shared/data/achievements'
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL
const supabaseKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY
if (!supabaseUrl || !supabaseKey) {
console.error('Missing Supabase credentials. Please set EXPO_PUBLIC_SUPABASE_URL and EXPO_PUBLIC_SUPABASE_ANON_KEY')
process.exit(1)
}
const supabase = createClient(supabaseUrl, supabaseKey)
async function seedTrainers() {
console.log('Seeding trainers...')
const trainers = TRAINERS.map(t => ({
id: t.id,
name: t.name,
specialty: t.specialty,
color: t.color,
avatar_url: t.avatarUrl || null,
workout_count: t.workoutCount,
}))
const { error } = await supabase.from('trainers').upsert(trainers)
if (error) throw error
console.log(`✅ Seeded ${trainers.length} trainers`)
}
async function seedWorkouts() {
console.log('Seeding workouts...')
const workouts = WORKOUTS.map(w => ({
id: w.id,
title: w.title,
trainer_id: w.trainerId,
category: w.category,
level: w.level,
duration: w.duration,
calories: w.calories,
rounds: w.rounds,
prep_time: w.prepTime,
work_time: w.workTime,
rest_time: w.restTime,
equipment: w.equipment,
music_vibe: w.musicVibe,
exercises: w.exercises,
thumbnail_url: w.thumbnailUrl || null,
video_url: w.videoUrl || null,
is_featured: w.isFeatured || false,
}))
const { error } = await supabase.from('workouts').upsert(workouts)
if (error) throw error
console.log(`✅ Seeded ${workouts.length} workouts`)
}
async function seedCollections() {
console.log('Seeding collections...')
const collections = COLLECTIONS.map(c => ({
id: c.id,
title: c.title,
description: c.description,
icon: c.icon,
gradient: c.gradient || null,
}))
const { error } = await supabase.from('collections').upsert(collections)
if (error) throw error
// Seed collection-workout relationships
const collectionWorkouts = COLLECTIONS.flatMap(c =>
c.workoutIds.map((workoutId, index) => ({
collection_id: c.id,
workout_id: workoutId,
sort_order: index,
}))
)
const { error: linkError } = await supabase.from('collection_workouts').upsert(collectionWorkouts)
if (linkError) throw linkError
console.log(`✅ Seeded ${collections.length} collections with ${collectionWorkouts.length} workout links`)
}
async function seedPrograms() {
console.log('Seeding programs...')
const programs = PROGRAMS.map(p => ({
id: p.id,
title: p.title,
description: p.description,
weeks: p.weeks,
workouts_per_week: p.workoutsPerWeek,
level: p.level,
}))
const { error } = await supabase.from('programs').upsert(programs)
if (error) throw error
// Seed program-workout relationships
let programWorkouts: { program_id: string; workout_id: string; week_number: number; day_number: number }[] = []
PROGRAMS.forEach(program => {
let week = 1
let day = 1
program.workoutIds.forEach((workoutId, index) => {
programWorkouts.push({
program_id: program.id,
workout_id: workoutId,
week_number: week,
day_number: day,
})
day++
if (day > program.workoutsPerWeek) {
day = 1
week++
}
})
})
const { error: linkError } = await supabase.from('program_workouts').upsert(programWorkouts)
if (linkError) throw linkError
console.log(`✅ Seeded ${programs.length} programs with ${programWorkouts.length} workout links`)
}
async function seedAchievements() {
console.log('Seeding achievements...')
const achievements = ACHIEVEMENTS.map(a => ({
id: a.id,
title: a.title,
description: a.description,
icon: a.icon,
requirement: a.requirement,
type: a.type,
}))
const { error } = await supabase.from('achievements').upsert(achievements)
if (error) throw error
console.log(`✅ Seeded ${achievements.length} achievements`)
}
async function main() {
try {
console.log('🌱 Starting database seed...\n')
await seedTrainers()
await seedWorkouts()
await seedCollections()
await seedPrograms()
await seedAchievements()
console.log('\n✨ Database seeded successfully!')
console.log('\nNext steps:')
console.log('1. Set up storage buckets in Supabase Dashboard')
console.log('2. Upload video and thumbnail files')
console.log('3. Create an admin user in the admin_users table')
console.log('4. Access the admin dashboard at /admin')
} catch (error) {
console.error('\n❌ Seeding failed:', error)
process.exit(1)
}
}
main()