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,64 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
import { adminService } from '../services/adminService'
interface AdminAuthContextType {
isAuthenticated: boolean
isAdmin: boolean
isLoading: boolean
signIn: (email: string, password: string) => Promise<void>
signOut: () => Promise<void>
}
const AdminAuthContext = createContext<AdminAuthContextType | undefined>(undefined)
export function AdminAuthProvider({ children }: { children: ReactNode }) {
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [isAdmin, setIsAdmin] = useState(false)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
checkAuth()
}, [])
async function checkAuth() {
try {
const user = await adminService.getCurrentUser()
if (user) {
setIsAuthenticated(true)
const adminStatus = await adminService.isAdmin()
setIsAdmin(adminStatus)
}
} catch (error) {
console.error('Auth check failed:', error)
} finally {
setIsLoading(false)
}
}
const signIn = async (email: string, password: string) => {
await adminService.signIn(email, password)
setIsAuthenticated(true)
const adminStatus = await adminService.isAdmin()
setIsAdmin(adminStatus)
}
const signOut = async () => {
await adminService.signOut()
setIsAuthenticated(false)
setIsAdmin(false)
}
return (
<AdminAuthContext.Provider value={{ isAuthenticated, isAdmin, isLoading, signIn, signOut }}>
{children}
</AdminAuthContext.Provider>
)
}
export function useAdminAuth() {
const context = useContext(AdminAuthContext)
if (context === undefined) {
throw new Error('useAdminAuth must be used within an AdminAuthProvider')
}
return context
}

View File

@@ -0,0 +1,282 @@
import { supabase, isSupabaseConfigured } from '../../shared/supabase'
import type { Database } from '../../shared/supabase/database.types'
type WorkoutInsert = Database['public']['Tables']['workouts']['Insert']
type WorkoutUpdate = Database['public']['Tables']['workouts']['Update']
type TrainerInsert = Database['public']['Tables']['trainers']['Insert']
type TrainerUpdate = Database['public']['Tables']['trainers']['Update']
type CollectionInsert = Database['public']['Tables']['collections']['Insert']
type CollectionWorkoutInsert = Database['public']['Tables']['collection_workouts']['Insert']
export class AdminService {
private checkConfiguration(): boolean {
if (!isSupabaseConfigured()) {
throw new Error('Supabase is not configured. Please set EXPO_PUBLIC_SUPABASE_URL and EXPO_PUBLIC_SUPABASE_ANON_KEY')
}
return true
}
// Workouts
async createWorkout(workout: Omit<WorkoutInsert, 'id' | 'created_at' | 'updated_at'>): Promise<string> {
this.checkConfiguration()
const { data, error } = await supabase
.from('workouts')
.insert(workout)
.select('id')
.single()
if (error) {
throw new Error(`Failed to create workout: ${error.message}`)
}
return data.id
}
async updateWorkout(id: string, workout: WorkoutUpdate): Promise<void> {
this.checkConfiguration()
const { error } = await supabase
.from('workouts')
.update({ ...workout, updated_at: new Date().toISOString() })
.eq('id', id)
if (error) {
throw new Error(`Failed to update workout: ${error.message}`)
}
}
async deleteWorkout(id: string): Promise<void> {
this.checkConfiguration()
const { error } = await supabase
.from('workouts')
.delete()
.eq('id', id)
if (error) {
throw new Error(`Failed to delete workout: ${error.message}`)
}
}
// Trainers
async createTrainer(trainer: Omit<TrainerInsert, 'id' | 'created_at' | 'updated_at'>): Promise<string> {
this.checkConfiguration()
const { data, error } = await supabase
.from('trainers')
.insert(trainer)
.select('id')
.single()
if (error) {
throw new Error(`Failed to create trainer: ${error.message}`)
}
return data.id
}
async updateTrainer(id: string, trainer: TrainerUpdate): Promise<void> {
this.checkConfiguration()
const { error } = await supabase
.from('trainers')
.update({ ...trainer, updated_at: new Date().toISOString() })
.eq('id', id)
if (error) {
throw new Error(`Failed to update trainer: ${error.message}`)
}
}
async deleteTrainer(id: string): Promise<void> {
this.checkConfiguration()
const { error } = await supabase
.from('trainers')
.delete()
.eq('id', id)
if (error) {
throw new Error(`Failed to delete trainer: ${error.message}`)
}
}
// Collections
async createCollection(
collection: Omit<CollectionInsert, 'id' | 'created_at' | 'updated_at'>,
workoutIds: string[]
): Promise<string> {
this.checkConfiguration()
const { data: collectionData, error: collectionError } = await supabase
.from('collections')
.insert(collection)
.select('id')
.single()
if (collectionError) {
throw new Error(`Failed to create collection: ${collectionError.message}`)
}
const collectionWorkouts: CollectionWorkoutInsert[] = workoutIds.map((workoutId, index) => ({
collection_id: collectionData.id,
workout_id: workoutId,
sort_order: index,
}))
const { error: linkError } = await supabase
.from('collection_workouts')
.insert(collectionWorkouts)
if (linkError) {
throw new Error(`Failed to link workouts to collection: ${linkError.message}`)
}
return collectionData.id
}
async updateCollectionWorkouts(collectionId: string, workoutIds: string[]): Promise<void> {
this.checkConfiguration()
const { error: deleteError } = await supabase
.from('collection_workouts')
.delete()
.eq('collection_id', collectionId)
if (deleteError) {
throw new Error(`Failed to remove existing workouts: ${deleteError.message}`)
}
const collectionWorkouts: CollectionWorkoutInsert[] = workoutIds.map((workoutId, index) => ({
collection_id: collectionId,
workout_id: workoutId,
sort_order: index,
}))
const { error: insertError } = await supabase
.from('collection_workouts')
.insert(collectionWorkouts)
if (insertError) {
throw new Error(`Failed to add new workouts: ${insertError.message}`)
}
}
// Storage
async uploadVideo(file: File, path: string): Promise<string> {
this.checkConfiguration()
const { error: uploadError } = await supabase.storage
.from('videos')
.upload(path, file)
if (uploadError) {
throw new Error(`Failed to upload video: ${uploadError.message}`)
}
const { data: { publicUrl } } = supabase.storage
.from('videos')
.getPublicUrl(path)
return publicUrl
}
async uploadThumbnail(file: File, path: string): Promise<string> {
this.checkConfiguration()
const { error: uploadError } = await supabase.storage
.from('thumbnails')
.upload(path, file)
if (uploadError) {
throw new Error(`Failed to upload thumbnail: ${uploadError.message}`)
}
const { data: { publicUrl } } = supabase.storage
.from('thumbnails')
.getPublicUrl(path)
return publicUrl
}
async uploadAvatar(file: File, path: string): Promise<string> {
this.checkConfiguration()
const { error: uploadError } = await supabase.storage
.from('avatars')
.upload(path, file)
if (uploadError) {
throw new Error(`Failed to upload avatar: ${uploadError.message}`)
}
const { data: { publicUrl } } = supabase.storage
.from('avatars')
.getPublicUrl(path)
return publicUrl
}
async deleteVideo(path: string): Promise<void> {
this.checkConfiguration()
const { error } = await supabase.storage
.from('videos')
.remove([path])
if (error) {
throw new Error(`Failed to delete video: ${error.message}`)
}
}
async deleteThumbnail(path: string): Promise<void> {
this.checkConfiguration()
const { error } = await supabase.storage
.from('thumbnails')
.remove([path])
if (error) {
throw new Error(`Failed to delete thumbnail: ${error.message}`)
}
}
// Admin authentication
async signIn(email: string, password: string): Promise<void> {
this.checkConfiguration()
const { error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) {
throw new Error(`Authentication failed: ${error.message}`)
}
}
async signOut(): Promise<void> {
await supabase.auth.signOut()
}
async getCurrentUser() {
const { data: { user } } = await supabase.auth.getUser()
return user
}
async isAdmin(): Promise<boolean> {
const user = await this.getCurrentUser()
if (!user) return false
const { data, error } = await supabase
.from('admin_users')
.select('*')
.eq('id', user.id)
.single()
return !error && !!data
}
}
export const adminService = new AdminService()