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

View File

@@ -0,0 +1,2 @@
export * from './types';
export { useWatchSync } from './useWatchSync';

View File

@@ -0,0 +1,82 @@
/**
* Watch Communication Types
* Types for iPhone ↔ Watch bidirectional communication
*/
export type TimerPhase = 'PREP' | 'WORK' | 'REST' | 'COMPLETE';
export type WatchControlAction = 'play' | 'pause' | 'skip' | 'stop' | 'previous';
export interface WorkoutState {
phase: TimerPhase;
timeRemaining: number;
currentRound: number;
totalRounds: number;
currentExercise: string;
nextExercise?: string;
calories: number;
isPaused: boolean;
isPlaying: boolean;
}
export interface WatchAvailability {
isSupported: boolean;
isPaired: boolean;
isWatchAppInstalled: boolean;
isReachable: boolean;
}
export interface WatchMessage {
type: string;
[key: string]: any;
}
export interface WatchControlMessage extends WatchMessage {
type: 'control';
action: WatchControlAction;
timestamp?: number;
}
export interface WatchStateMessage extends WatchMessage {
type: 'workoutState';
phase: TimerPhase;
timeRemaining: number;
currentRound: number;
totalRounds: number;
currentExercise: string;
nextExercise?: string;
calories: number;
isPaused: boolean;
isPlaying: boolean;
}
export interface HeartRateMessage extends WatchMessage {
type: 'heartRate';
value: number;
timestamp: number;
}
export interface WatchConnectivityStatus {
reachable: boolean;
}
export interface WatchStateChanged {
isPaired: boolean;
isWatchAppInstalled: boolean;
isReachable: boolean;
}
export type WatchEventName =
| 'WatchConnectivityStatus'
| 'WatchStateChanged'
| 'WatchControlReceived'
| 'WatchMessageReceived';
export interface WatchBridgeModule {
isWatchAvailable(): Promise<WatchAvailability>;
sendWorkoutState(state: WorkoutState): void;
sendMessage(message: WatchMessage): void;
sendControl(action: WatchControlAction): void;
addListener(eventName: WatchEventName, callback: (data: any) => void): void;
removeListener(eventName: WatchEventName, callback: (data: any) => void): void;
}

View File

@@ -0,0 +1,215 @@
import { useEffect, useRef, useCallback } from 'react';
import {
NativeModules,
NativeEventEmitter,
Platform,
} from 'react-native';
import type {
WorkoutState,
WatchControlAction,
WatchAvailability,
WatchControlMessage,
} from './types';
const { WatchBridge } = NativeModules;
interface UseWatchSyncOptions {
onPlay?: () => void;
onPause?: () => void;
onSkip?: () => void;
onStop?: () => void;
onPrevious?: () => void;
onHeartRateUpdate?: (heartRate: number) => void;
enabled?: boolean;
}
interface UseWatchSyncReturn {
isAvailable: boolean;
isReachable: boolean;
sendWorkoutState: (state: WorkoutState) => void;
checkAvailability: () => Promise<WatchAvailability>;
}
/**
* Hook to sync workout state with Apple Watch
*
* @example
* ```tsx
* const { isAvailable, sendWorkoutState } = useWatchSync({
* onPlay: () => resume(),
* onPause: () => pause(),
* onSkip: () => skip(),
* onStop: () => stop(),
* });
*
* // Send state updates
* useEffect(() => {
* if (isAvailable && isRunning) {
* sendWorkoutState({
* phase,
* timeRemaining,
* currentRound,
* totalRounds,
* currentExercise,
* nextExercise,
* calories,
* isPaused,
* isPlaying: isRunning && !isPaused,
* });
* }
* }, [phase, timeRemaining, currentRound, isPaused]);
* ```
*/
export function useWatchSync(options: UseWatchSyncOptions = {}): UseWatchSyncReturn {
const {
onPlay,
onPause,
onSkip,
onStop,
onPrevious,
onHeartRateUpdate,
enabled = true,
} = options;
const isAvailableRef = useRef(false);
const isReachableRef = useRef(false);
const eventEmitterRef = useRef<NativeEventEmitter | null>(null);
// Initialize event emitter
useEffect(() => {
if (Platform.OS !== 'ios' || !enabled) return;
if (!WatchBridge) {
console.warn('WatchBridge native module not found');
return;
}
eventEmitterRef.current = new NativeEventEmitter(WatchBridge);
return () => {
// Cleanup will be handled by individual subscriptions
};
}, [enabled]);
// Listen for control events from Watch
useEffect(() => {
if (!eventEmitterRef.current || !enabled) return;
const subscriptions: any[] = [];
// Listen for control commands from Watch
const controlSubscription = eventEmitterRef.current.addListener(
'WatchControlReceived',
(data: { action: WatchControlAction }) => {
console.log('Received control from Watch:', data.action);
switch (data.action) {
case 'play':
onPlay?.();
break;
case 'pause':
onPause?.();
break;
case 'skip':
onSkip?.();
break;
case 'stop':
onStop?.();
break;
case 'previous':
onPrevious?.();
break;
}
}
);
subscriptions.push(controlSubscription);
// Listen for connectivity status changes
const statusSubscription = eventEmitterRef.current.addListener(
'WatchConnectivityStatus',
(data: { reachable: boolean }) => {
console.log('Watch connectivity changed:', data.reachable);
isReachableRef.current = data.reachable;
}
);
subscriptions.push(statusSubscription);
// Listen for general messages (including heart rate)
const messageSubscription = eventEmitterRef.current.addListener(
'WatchMessageReceived',
(data: { type: string; value?: number }) => {
if (data.type === 'heartRate' && typeof data.value === 'number') {
onHeartRateUpdate?.(data.value);
}
}
);
subscriptions.push(messageSubscription);
// Listen for watch state changes
const stateSubscription = eventEmitterRef.current.addListener(
'WatchStateChanged',
(data: { isReachable: boolean; isWatchAppInstalled: boolean }) => {
console.log('Watch state changed:', data);
isReachableRef.current = data.isReachable;
isAvailableRef.current = data.isWatchAppInstalled;
}
);
subscriptions.push(stateSubscription);
return () => {
subscriptions.forEach(sub => sub.remove());
};
}, [enabled, onPlay, onPause, onSkip, onStop, onPrevious, onHeartRateUpdate]);
// Check initial availability
useEffect(() => {
if (Platform.OS !== 'ios' || !enabled || !WatchBridge) return;
checkAvailability().catch(console.error);
}, [enabled]);
const checkAvailability = useCallback(async (): Promise<WatchAvailability> => {
if (Platform.OS !== 'ios' || !WatchBridge) {
return {
isSupported: false,
isPaired: false,
isWatchAppInstalled: false,
isReachable: false,
};
}
try {
const availability = await WatchBridge.isWatchAvailable();
isAvailableRef.current = availability.isWatchAppInstalled;
isReachableRef.current = availability.isReachable;
return availability;
} catch (error) {
console.error('Failed to check Watch availability:', error);
return {
isSupported: false,
isPaired: false,
isWatchAppInstalled: false,
isReachable: false,
};
}
}, []);
const sendWorkoutState = useCallback((state: WorkoutState) => {
if (Platform.OS !== 'ios' || !WatchBridge || !isReachableRef.current) {
return;
}
try {
WatchBridge.sendWorkoutState(state);
} catch (error) {
console.error('Failed to send workout state:', error);
}
}, []);
return {
isAvailable: isAvailableRef.current,
isReachable: isReachableRef.current,
sendWorkoutState,
checkAvailability,
};
}

View File

@@ -0,0 +1,314 @@
import { supabase, isSupabaseConfigured } from '../supabase'
import { WORKOUTS, TRAINERS, COLLECTIONS, PROGRAMS, ACHIEVEMENTS } from './index'
import type { Workout, Trainer, Collection, Program } from '../types'
import type { Database } from '../supabase/database.types'
type WorkoutRow = Database['public']['Tables']['workouts']['Row']
type TrainerRow = Database['public']['Tables']['trainers']['Row']
type CollectionRow = Database['public']['Tables']['collections']['Row']
type CollectionWorkoutRow = Database['public']['Tables']['collection_workouts']['Row']
type ProgramRow = Database['public']['Tables']['programs']['Row']
type ProgramWorkoutRow = Database['public']['Tables']['program_workouts']['Row']
function mapWorkoutFromDB(row: WorkoutRow): Workout {
return {
id: row.id,
title: row.title,
trainerId: row.trainer_id,
category: row.category,
level: row.level,
duration: row.duration as 4 | 8 | 12 | 20,
calories: row.calories,
rounds: row.rounds,
prepTime: row.prep_time,
workTime: row.work_time,
restTime: row.rest_time,
equipment: row.equipment,
musicVibe: row.music_vibe,
exercises: row.exercises,
thumbnailUrl: row.thumbnail_url ?? undefined,
videoUrl: row.video_url ?? undefined,
isFeatured: row.is_featured,
}
}
function mapTrainerFromDB(row: TrainerRow): Trainer {
return {
id: row.id,
name: row.name,
specialty: row.specialty,
color: row.color,
avatarUrl: row.avatar_url ?? undefined,
workoutCount: row.workout_count,
}
}
function mapCollectionFromDB(
row: CollectionRow,
workoutIds: string[]
): Collection {
return {
id: row.id,
title: row.title,
description: row.description,
icon: row.icon,
workoutIds,
gradient: row.gradient ? (row.gradient as [string, string]) : undefined,
}
}
function mapProgramFromDB(
row: ProgramRow,
workoutIds: string[]
): Program {
return {
id: row.id,
title: row.title,
description: row.description,
weeks: row.weeks,
workoutsPerWeek: row.workouts_per_week,
level: row.level,
workoutIds,
}
}
class SupabaseDataService {
async getAllWorkouts(): Promise<Workout[]> {
if (!isSupabaseConfigured()) {
return WORKOUTS
}
const { data, error } = await supabase
.from('workouts')
.select('*')
.order('created_at', { ascending: false })
if (error) {
console.error('Error fetching workouts:', error)
return WORKOUTS
}
return data?.map(mapWorkoutFromDB) ?? WORKOUTS
}
async getWorkoutById(id: string): Promise<Workout | undefined> {
if (!isSupabaseConfigured()) {
return WORKOUTS.find((w: Workout) => w.id === id)
}
const { data, error } = await supabase
.from('workouts')
.select('*')
.eq('id', id)
.single()
if (error || !data) {
console.error('Error fetching workout:', error)
return WORKOUTS.find((w: Workout) => w.id === id)
}
return mapWorkoutFromDB(data)
}
async getWorkoutsByCategory(category: string): Promise<Workout[]> {
if (!isSupabaseConfigured()) {
return WORKOUTS.filter((w: Workout) => w.category === category)
}
const { data, error } = await supabase
.from('workouts')
.select('*')
.eq('category', category)
if (error) {
console.error('Error fetching workouts by category:', error)
return WORKOUTS.filter((w: Workout) => w.category === category)
}
return data?.map(mapWorkoutFromDB) ?? WORKOUTS.filter((w: Workout) => w.category === category)
}
async getWorkoutsByTrainer(trainerId: string): Promise<Workout[]> {
if (!isSupabaseConfigured()) {
return WORKOUTS.filter((w: Workout) => w.trainerId === trainerId)
}
const { data, error } = await supabase
.from('workouts')
.select('*')
.eq('trainer_id', trainerId)
if (error) {
console.error('Error fetching workouts by trainer:', error)
return WORKOUTS.filter((w: Workout) => w.trainerId === trainerId)
}
return data?.map(mapWorkoutFromDB) ?? WORKOUTS.filter((w: Workout) => w.trainerId === trainerId)
}
async getFeaturedWorkouts(): Promise<Workout[]> {
if (!isSupabaseConfigured()) {
return WORKOUTS.filter((w: Workout) => w.isFeatured)
}
const { data, error } = await supabase
.from('workouts')
.select('*')
.eq('is_featured', true)
if (error) {
console.error('Error fetching featured workouts:', error)
return WORKOUTS.filter((w: Workout) => w.isFeatured)
}
return data?.map(mapWorkoutFromDB) ?? WORKOUTS.filter((w: Workout) => w.isFeatured)
}
async getAllTrainers(): Promise<Trainer[]> {
if (!isSupabaseConfigured()) {
return TRAINERS
}
const { data, error } = await supabase
.from('trainers')
.select('*')
if (error) {
console.error('Error fetching trainers:', error)
return TRAINERS
}
return data?.map(mapTrainerFromDB) ?? TRAINERS
}
async getTrainerById(id: string): Promise<Trainer | undefined> {
if (!isSupabaseConfigured()) {
return TRAINERS.find((t: Trainer) => t.id === id)
}
const { data, error } = await supabase
.from('trainers')
.select('*')
.eq('id', id)
.single()
if (error || !data) {
console.error('Error fetching trainer:', error)
return TRAINERS.find((t: Trainer) => t.id === id)
}
return mapTrainerFromDB(data)
}
async getAllCollections(): Promise<Collection[]> {
if (!isSupabaseConfigured()) {
return COLLECTIONS
}
const { data: collectionsData, error: collectionsError } = await supabase
.from('collections')
.select('*')
if (collectionsError) {
console.error('Error fetching collections:', collectionsError)
return COLLECTIONS
}
const { data: workoutLinks, error: linksError } = await supabase
.from('collection_workouts')
.select('*')
.order('sort_order')
if (linksError) {
console.error('Error fetching collection workouts:', linksError)
return COLLECTIONS
}
const workoutIdsByCollection: Record<string, string[]> = {}
workoutLinks?.forEach((link: CollectionWorkoutRow) => {
if (!workoutIdsByCollection[link.collection_id]) {
workoutIdsByCollection[link.collection_id] = []
}
workoutIdsByCollection[link.collection_id].push(link.workout_id)
})
return collectionsData?.map((row: CollectionRow) =>
mapCollectionFromDB(row, workoutIdsByCollection[row.id] || [])
) ?? COLLECTIONS
}
async getCollectionById(id: string): Promise<Collection | undefined> {
if (!isSupabaseConfigured()) {
return COLLECTIONS.find((c: Collection) => c.id === id)
}
const { data: collection, error: collectionError } = await supabase
.from('collections')
.select('*')
.eq('id', id)
.single()
if (collectionError || !collection) {
console.error('Error fetching collection:', collectionError)
return COLLECTIONS.find((c: Collection) => c.id === id)
}
const { data: workoutLinks, error: linksError } = await supabase
.from('collection_workouts')
.select('workout_id')
.eq('collection_id', id)
.order('sort_order')
if (linksError) {
console.error('Error fetching collection workouts:', linksError)
return COLLECTIONS.find((c: Collection) => c.id === id)
}
const workoutIds = workoutLinks?.map((link: { workout_id: string }) => link.workout_id) || []
return mapCollectionFromDB(collection, workoutIds)
}
async getAllPrograms(): Promise<Program[]> {
if (!isSupabaseConfigured()) {
return PROGRAMS
}
const { data: programsData, error: programsError } = await supabase
.from('programs')
.select('*')
if (programsError) {
console.error('Error fetching programs:', programsError)
return PROGRAMS
}
const { data: workoutLinks, error: linksError } = await supabase
.from('program_workouts')
.select('*')
.order('week_number')
.order('day_number')
if (linksError) {
console.error('Error fetching program workouts:', linksError)
return PROGRAMS
}
const workoutIdsByProgram: Record<string, string[]> = {}
workoutLinks?.forEach((link: ProgramWorkoutRow) => {
if (!workoutIdsByProgram[link.program_id]) {
workoutIdsByProgram[link.program_id] = []
}
workoutIdsByProgram[link.program_id].push(link.workout_id)
})
return programsData?.map((row: ProgramRow) =>
mapProgramFromDB(row, workoutIdsByProgram[row.id] || [])
) ?? PROGRAMS
}
async getAchievements() {
return ACHIEVEMENTS
}
}
export const dataService = new SupabaseDataService()

View File

@@ -6,5 +6,18 @@ export { useTimer } from './useTimer'
export type { TimerPhase } from './useTimer'
export { useHaptics } from './useHaptics'
export { useAudio } from './useAudio'
export { useMusicPlayer } from './useMusicPlayer'
export { useNotifications, requestNotificationPermissions } from './useNotifications'
export { usePurchases } from './usePurchases'
export {
useWorkouts,
useWorkout,
useWorkoutsByCategory,
useTrainers,
useTrainer,
useCollections,
useCollection,
usePrograms,
useFeaturedWorkouts,
useWorkoutsByTrainer,
} from './useSupabaseData'

View File

@@ -0,0 +1,240 @@
/**
* TabataFit Music Player Hook
* Manages background music playback synced with workout timer
* Loads tracks from Supabase Storage based on workout's musicVibe
*/
import { useRef, useEffect, useCallback, useState } from 'react'
import { Audio } from 'expo-av'
import { useUserStore } from '../stores'
import { musicService, type MusicTrack } from '../services/music'
import type { MusicVibe } from '../types'
interface UseMusicPlayerOptions {
vibe: MusicVibe
isPlaying: boolean
volume?: number
}
interface UseMusicPlayerReturn {
/** Current track being played */
currentTrack: MusicTrack | null
/** Whether music is loaded and ready */
isReady: boolean
/** Error message if loading failed */
error: string | null
/** Set volume (0-1) */
setVolume: (volume: number) => void
/** Skip to next track */
nextTrack: () => void
}
export function useMusicPlayer(options: UseMusicPlayerOptions): UseMusicPlayerReturn {
const { vibe, isPlaying, volume = 0.5 } = options
const musicEnabled = useUserStore((s) => s.settings.musicEnabled)
const soundRef = useRef<Audio.Sound | null>(null)
const tracksRef = useRef<MusicTrack[]>([])
const currentTrackIndexRef = useRef(0)
const [currentTrack, setCurrentTrack] = useState<MusicTrack | null>(null)
const [isReady, setIsReady] = useState(false)
const [error, setError] = useState<string | null>(null)
// Configure audio session for background music
useEffect(() => {
Audio.setAudioModeAsync({
playsInSilentModeIOS: true,
staysActiveInBackground: true,
shouldDuckAndroid: true,
// Mix with other audio (allows sound effects to play over music)
interruptionModeIOS: 1, // INTERRUPTION_MODE_IOS_MIX_WITH_OTHERS
interruptionModeAndroid: 1, // INTERRUPTION_MODE_ANDROID_DUCK_OTHERS
})
return () => {
// Cleanup on unmount
if (soundRef.current) {
soundRef.current.unloadAsync().catch(() => {})
soundRef.current = null
}
}
}, [])
// Load tracks when vibe changes
useEffect(() => {
let isMounted = true
async function loadTracks() {
try {
setIsReady(false)
setError(null)
// Unload current track if any
if (soundRef.current) {
await soundRef.current.unloadAsync()
soundRef.current = null
}
const tracks = await musicService.loadTracksForVibe(vibe)
if (!isMounted) return
tracksRef.current = tracks
if (tracks.length > 0) {
// Select random starting track
currentTrackIndexRef.current = Math.floor(Math.random() * tracks.length)
const track = tracks[currentTrackIndexRef.current]
setCurrentTrack(track)
await loadAndPlayTrack(track, false)
} else {
setError('No tracks available')
}
setIsReady(true)
} catch (err) {
if (!isMounted) return
setError(err instanceof Error ? err.message : 'Failed to load music')
setIsReady(false)
}
}
if (musicEnabled) {
loadTracks()
}
return () => {
isMounted = false
}
}, [vibe, musicEnabled])
// Load and prepare a track
const loadAndPlayTrack = useCallback(async (track: MusicTrack, autoPlay: boolean = true) => {
try {
if (soundRef.current) {
await soundRef.current.unloadAsync()
}
// For mock tracks without URLs, skip loading
if (!track.url) {
console.log(`[MusicPlayer] Mock track: ${track.title} - ${track.artist}`)
return
}
const { sound } = await Audio.Sound.createAsync(
{ uri: track.url },
{
shouldPlay: autoPlay && isPlaying && musicEnabled,
volume: volume,
isLooping: false,
},
onPlaybackStatusUpdate
)
soundRef.current = sound
} catch (err) {
console.error('[MusicPlayer] Error loading track:', err)
}
}, [isPlaying, musicEnabled, volume])
// Handle playback status updates
const onPlaybackStatusUpdate = useCallback((status: Audio.PlaybackStatus) => {
if (!status.isLoaded) return
// Track finished playing - load next
if (status.didJustFinish) {
playNextTrack()
}
}, [])
// Play next track
const playNextTrack = useCallback(async () => {
if (tracksRef.current.length === 0) return
currentTrackIndexRef.current = (currentTrackIndexRef.current + 1) % tracksRef.current.length
const nextTrack = tracksRef.current[currentTrackIndexRef.current]
setCurrentTrack(nextTrack)
await loadAndPlayTrack(nextTrack, isPlaying && musicEnabled)
}, [isPlaying, musicEnabled, loadAndPlayTrack])
// Handle play/pause based on workout state
useEffect(() => {
async function updatePlayback() {
if (!soundRef.current || !musicEnabled) return
try {
const status = await soundRef.current.getStatusAsync()
if (!status.isLoaded) return
if (isPlaying && !status.isPlaying) {
await soundRef.current.playAsync()
} else if (!isPlaying && status.isPlaying) {
await soundRef.current.pauseAsync()
}
} catch (err) {
console.error('[MusicPlayer] Error updating playback:', err)
}
}
updatePlayback()
}, [isPlaying, musicEnabled])
// Update volume when it changes
useEffect(() => {
async function updateVolume() {
if (!soundRef.current) return
try {
const status = await soundRef.current.getStatusAsync()
if (status.isLoaded) {
await soundRef.current.setVolumeAsync(volume)
}
} catch (err) {
console.error('[MusicPlayer] Error updating volume:', err)
}
}
updateVolume()
}, [volume])
// Update music enabled setting
useEffect(() => {
async function handleMusicToggle() {
if (!soundRef.current) return
try {
if (!musicEnabled) {
await soundRef.current.pauseAsync()
} else if (isPlaying) {
await soundRef.current.playAsync()
}
} catch (err) {
console.error('[MusicPlayer] Error toggling music:', err)
}
}
handleMusicToggle()
}, [musicEnabled, isPlaying])
// Set volume function
const setVolume = useCallback((newVolume: number) => {
const clampedVolume = Math.max(0, Math.min(1, newVolume))
// Volume is controlled via the store or parent component
// This function can be used to update the store
}, [])
// Next track function
const nextTrack = useCallback(async () => {
await playNextTrack()
}, [playNextTrack])
return {
currentTrack,
isReady,
error,
setVolume,
nextTrack,
}
}

View File

@@ -0,0 +1,397 @@
import { useState, useEffect, useCallback } from 'react'
import { dataService } from '../data/dataService'
import type { Workout, Trainer, Collection, Program } from '../types'
export function useWorkouts() {
const [workouts, setWorkouts] = useState<Workout[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const fetchWorkouts = useCallback(async () => {
try {
setLoading(true)
const data = await dataService.getAllWorkouts()
setWorkouts(data)
setError(null)
} catch (err) {
setError(err as Error)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
let mounted = true
async function load() {
try {
setLoading(true)
const data = await dataService.getAllWorkouts()
if (mounted) {
setWorkouts(data)
setError(null)
}
} catch (err) {
if (mounted) {
setError(err as Error)
}
} finally {
if (mounted) {
setLoading(false)
}
}
}
load()
return () => { mounted = false }
}, [])
return { workouts, loading, error, refetch: fetchWorkouts }
}
export function useWorkout(id: string | undefined) {
const [workout, setWorkout] = useState<Workout | undefined>()
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
let mounted = true
async function load() {
if (!id) {
setLoading(false)
return
}
try {
setLoading(true)
const data = await dataService.getWorkoutById(id)
if (mounted) {
setWorkout(data)
setError(null)
}
} catch (err) {
if (mounted) {
setError(err as Error)
}
} finally {
if (mounted) {
setLoading(false)
}
}
}
load()
return () => { mounted = false }
}, [id])
return { workout, loading, error }
}
export function useWorkoutsByCategory(category: string) {
const [workouts, setWorkouts] = useState<Workout[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
let mounted = true
async function load() {
try {
setLoading(true)
const data = await dataService.getWorkoutsByCategory(category)
if (mounted) {
setWorkouts(data)
setError(null)
}
} catch (err) {
if (mounted) {
setError(err as Error)
}
} finally {
if (mounted) {
setLoading(false)
}
}
}
load()
return () => { mounted = false }
}, [category])
return { workouts, loading, error }
}
export function useTrainers() {
const [trainers, setTrainers] = useState<Trainer[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const fetchTrainers = useCallback(async () => {
try {
setLoading(true)
const data = await dataService.getAllTrainers()
setTrainers(data)
setError(null)
} catch (err) {
setError(err as Error)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
let mounted = true
async function load() {
try {
setLoading(true)
const data = await dataService.getAllTrainers()
if (mounted) {
setTrainers(data)
setError(null)
}
} catch (err) {
if (mounted) {
setError(err as Error)
}
} finally {
if (mounted) {
setLoading(false)
}
}
}
load()
return () => { mounted = false }
}, [])
return { trainers, loading, error, refetch: fetchTrainers }
}
export function useTrainer(id: string | undefined) {
const [trainer, setTrainer] = useState<Trainer | undefined>()
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
let mounted = true
async function load() {
if (!id) {
setLoading(false)
return
}
try {
setLoading(true)
const data = await dataService.getTrainerById(id)
if (mounted) {
setTrainer(data)
setError(null)
}
} catch (err) {
if (mounted) {
setError(err as Error)
}
} finally {
if (mounted) {
setLoading(false)
}
}
}
load()
return () => { mounted = false }
}, [id])
return { trainer, loading, error }
}
export function useCollections() {
const [collections, setCollections] = useState<Collection[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const fetchCollections = useCallback(async () => {
try {
setLoading(true)
const data = await dataService.getAllCollections()
setCollections(data)
setError(null)
} catch (err) {
setError(err as Error)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
let mounted = true
async function load() {
try {
setLoading(true)
const data = await dataService.getAllCollections()
if (mounted) {
setCollections(data)
setError(null)
}
} catch (err) {
if (mounted) {
setError(err as Error)
}
} finally {
if (mounted) {
setLoading(false)
}
}
}
load()
return () => { mounted = false }
}, [])
return { collections, loading, error, refetch: fetchCollections }
}
export function useCollection(id: string | undefined) {
const [collection, setCollection] = useState<Collection | undefined>()
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
let mounted = true
async function load() {
if (!id) {
setLoading(false)
return
}
try {
setLoading(true)
const data = await dataService.getCollectionById(id)
if (mounted) {
setCollection(data)
setError(null)
}
} catch (err) {
if (mounted) {
setError(err as Error)
}
} finally {
if (mounted) {
setLoading(false)
}
}
}
load()
return () => { mounted = false }
}, [id])
return { collection, loading, error }
}
export function usePrograms() {
const [programs, setPrograms] = useState<Program[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
let mounted = true
async function load() {
try {
setLoading(true)
const data = await dataService.getAllPrograms()
if (mounted) {
setPrograms(data)
setError(null)
}
} catch (err) {
if (mounted) {
setError(err as Error)
}
} finally {
if (mounted) {
setLoading(false)
}
}
}
load()
return () => { mounted = false }
}, [])
return { programs, loading, error }
}
export function useFeaturedWorkouts() {
const [workouts, setWorkouts] = useState<Workout[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
let mounted = true
async function load() {
try {
setLoading(true)
const data = await dataService.getFeaturedWorkouts()
if (mounted) {
setWorkouts(data)
setError(null)
}
} catch (err) {
if (mounted) {
setError(err as Error)
}
} finally {
if (mounted) {
setLoading(false)
}
}
}
load()
return () => { mounted = false }
}, [])
return { workouts, loading, error }
}
export function useWorkoutsByTrainer(trainerId: string) {
const [workouts, setWorkouts] = useState<Workout[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
let mounted = true
async function load() {
try {
setLoading(true)
const data = await dataService.getWorkoutsByTrainer(trainerId)
if (mounted) {
setWorkouts(data)
setError(null)
}
} catch (err) {
if (mounted) {
setError(err as Error)
}
} finally {
if (mounted) {
setLoading(false)
}
}
}
load()
return () => { mounted = false }
}, [trainerId])
return { workouts, loading, error }
}

View File

@@ -50,17 +50,30 @@
"profile": {
"title": "Profil",
"guest": "Gast",
"memberSince": "Mitglied seit {{date}}",
"sectionAccount": "KONTO",
"sectionWorkout": "WORKOUT",
"sectionNotifications": "BENACHRICHTIGUNGEN",
"sectionAbout": "\u00dcBER",
"sectionSubscription": "ABONNEMENT",
"email": "E-Mail",
"plan": "Plan",
"freePlan": "Kostenlos",
"restorePurchases": "K\u00e4ufe wiederherstellen",
"hapticFeedback": "Haptisches Feedback",
"soundEffects": "Soundeffekte",
"voiceCoaching": "Sprachcoaching",
"dailyReminders": "T\u00e4gliche Erinnerungen",
"reminderTime": "Erinnerungszeit",
"version": "TabataFit v1.0.0"
"reminderFooter": "Erhalte eine t\u00e4gliche Erinnerung, um deine Serie zu halten",
"workoutSettingsFooter": "Passe dein Workout-Erlebnis an",
"upgradeTitle": "TabataFit+ freischalten",
"upgradeDescription": "Unbegrenzte Workouts, Offline-Downloads und mehr.",
"learnMore": "Mehr erfahren",
"version": "Version",
"privacyPolicy": "Datenschutzrichtlinie",
"signOut": "Abmelden"
},
"player": {

View File

@@ -50,17 +50,30 @@
"profile": {
"title": "Profile",
"guest": "Guest",
"memberSince": "Member since {{date}}",
"sectionAccount": "ACCOUNT",
"sectionWorkout": "WORKOUT",
"sectionNotifications": "NOTIFICATIONS",
"sectionAbout": "ABOUT",
"sectionSubscription": "SUBSCRIPTION",
"email": "Email",
"plan": "Plan",
"freePlan": "Free",
"restorePurchases": "Restore Purchases",
"hapticFeedback": "Haptic Feedback",
"soundEffects": "Sound Effects",
"voiceCoaching": "Voice Coaching",
"dailyReminders": "Daily Reminders",
"reminderTime": "Reminder Time",
"version": "TabataFit v1.0.0"
"reminderFooter": "Get a daily reminder to keep your streak going",
"workoutSettingsFooter": "Customize your workout experience",
"upgradeTitle": "Unlock TabataFit+",
"upgradeDescription": "Get unlimited workouts, offline downloads, and more.",
"learnMore": "Learn More",
"version": "Version",
"privacyPolicy": "Privacy Policy",
"signOut": "Sign Out"
},
"player": {
@@ -178,21 +191,62 @@
}
},
"paywall": {
"title": "Keep the momentum.\nWithout limits.",
"subtitle": "Unlock all features and reach your goals faster",
"features": {
"unlimited": "Unlimited workouts",
"offline": "Offline downloads",
"stats": "Advanced stats & Apple Watch",
"noAds": "No ads + Family Sharing"
"music": "Premium Music",
"workouts": "Unlimited Workouts",
"stats": "Advanced Stats",
"calories": "Calorie Tracking",
"reminders": "Daily Reminders",
"ads": "No Ads"
},
"bestValue": "BEST VALUE",
"yearlyPrice": "$49.99",
"monthlyPrice": "$6.99",
"savePercent": "Save 42%",
"trialCta": "START FREE TRIAL (7 days)",
"guarantees": "Cancel anytime \u00B7 30-day money-back guarantee",
"restorePurchases": "Restore Purchases",
"skipButton": "Continue without subscription"
"yearly": "Yearly",
"monthly": "Monthly",
"perYear": "per year",
"perMonth": "per month",
"save50": "SAVE 50%",
"equivalent": "Just ${{price}}/month",
"subscribe": "Subscribe Now",
"processing": "Processing...",
"restore": "Restore Purchases",
"terms": "Payment will be charged to your Apple ID at confirmation. Subscription auto-renews unless cancelled at least 24 hours before end of period. Manage in Account Settings."
},
"privacy": {
"title": "Privacy Policy",
"lastUpdated": "Last Updated: March 2026",
"intro": {
"title": "Introduction",
"content": "TabataFit is committed to protecting your privacy. This policy explains how we collect, use, and safeguard your information when you use our fitness app."
},
"dataCollection": {
"title": "Data We Collect",
"content": "We collect only the information necessary to provide you with the best workout experience:",
"items": {
"workouts": "Workout history and preferences",
"settings": "App settings and configurations",
"device": "Device type and OS version for optimization"
}
},
"usage": {
"title": "How We Use Your Data",
"content": "Your data is used solely to: personalize your workout experience, track your progress and achievements, sync your data across devices, and improve our app functionality."
},
"sharing": {
"title": "Data Sharing",
"content": "We do not sell or share your personal information with third parties. Your workout data remains private and secure on your device and in encrypted cloud storage."
},
"security": {
"title": "Security",
"content": "We implement industry-standard security measures to protect your data, including encryption and secure authentication."
},
"rights": {
"title": "Your Rights",
"content": "You have the right to access, modify, or delete your personal data at any time. You can export or delete your data from the app settings."
},
"contact": {
"title": "Contact Us",
"content": "If you have questions about this privacy policy, please contact us at:"
}
}
}
}

View File

@@ -50,17 +50,30 @@
"profile": {
"title": "Perfil",
"guest": "Invitado",
"memberSince": "Miembro desde {{date}}",
"sectionAccount": "CUENTA",
"sectionWorkout": "ENTRENAMIENTO",
"sectionNotifications": "NOTIFICACIONES",
"sectionAbout": "ACERCA DE",
"sectionSubscription": "SUSCRIPCI\u00d3N",
"email": "Correo electr\u00f3nico",
"plan": "Plan",
"freePlan": "Gratis",
"restorePurchases": "Restaurar compras",
"hapticFeedback": "Retroalimentaci\u00f3n h\u00e1ptica",
"soundEffects": "Efectos de sonido",
"voiceCoaching": "Coaching por voz",
"dailyReminders": "Recordatorios diarios",
"reminderTime": "Hora del recordatorio",
"version": "TabataFit v1.0.0"
"reminderFooter": "Recibe un recordatorio diario para mantener tu racha",
"workoutSettingsFooter": "Personaliza tu experiencia de entrenamiento",
"upgradeTitle": "Desbloquear TabataFit+",
"upgradeDescription": "Obt\u00e9n entrenos ilimitados, descargas sin conexi\u00f3n y m\u00e1s.",
"learnMore": "M\u00e1s informaci\u00f3n",
"version": "Versi\u00f3n",
"privacyPolicy": "Pol\u00edtica de privacidad",
"signOut": "Cerrar sesi\u00f3n"
},
"player": {

View File

@@ -50,17 +50,30 @@
"profile": {
"title": "Profil",
"guest": "Invit\u00e9",
"memberSince": "Membre depuis {{date}}",
"sectionAccount": "COMPTE",
"sectionWorkout": "ENTRA\u00ceNEMENT",
"sectionNotifications": "NOTIFICATIONS",
"sectionAbout": "\u00c0 PROPOS",
"sectionSubscription": "ABONNEMENT",
"email": "E-mail",
"plan": "Formule",
"freePlan": "Gratuit",
"restorePurchases": "Restaurer les achats",
"hapticFeedback": "Retour haptique",
"soundEffects": "Effets sonores",
"voiceCoaching": "Coaching vocal",
"dailyReminders": "Rappels quotidiens",
"reminderTime": "Heure du rappel",
"version": "TabataFit v1.0.0"
"reminderFooter": "Recevez un rappel quotidien pour maintenir votre s\u00e9rie",
"workoutSettingsFooter": "Personnalisez votre exp\u00e9rience d'entra\u00eenement",
"upgradeTitle": "D\u00e9bloquer TabataFit+",
"upgradeDescription": "Acc\u00e9dez \u00e0 des entra\u00eenements illimit\u00e9s, t\u00e9l\u00e9chargements hors ligne et plus.",
"learnMore": "En savoir plus",
"version": "Version",
"privacyPolicy": "Politique de confidentialit\u00e9",
"signOut": "Se d\u00e9connecter"
},
"player": {
@@ -178,21 +191,62 @@
}
},
"paywall": {
"title": "Gardez l'\u00e9lan.\nSans limites.",
"subtitle": "Débloquez toutes les fonctionnalités et atteignez vos objectifs plus vite",
"features": {
"unlimited": "Entra\u00eenements illimit\u00e9s",
"offline": "T\u00e9l\u00e9chargements hors ligne",
"stats": "Statistiques avanc\u00e9es & Apple Watch",
"noAds": "Sans pub + Partage familial"
"music": "Musique Premium",
"workouts": "Entraînements illimités",
"stats": "Statistiques avancées",
"calories": "Suivi des calories",
"reminders": "Rappels quotidiens",
"ads": "Sans publicités"
},
"bestValue": "MEILLEURE OFFRE",
"yearlyPrice": "49,99 $",
"monthlyPrice": "6,99 $",
"savePercent": "\u00c9conomisez 42%",
"trialCta": "ESSAI GRATUIT (7 jours)",
"guarantees": "Annulation \u00e0 tout moment \u00B7 Garantie satisfait ou rembours\u00e9 30 jours",
"restorePurchases": "Restaurer les achats",
"skipButton": "Continuer sans abonnement"
"yearly": "Annuel",
"monthly": "Mensuel",
"perYear": "par an",
"perMonth": "par mois",
"save50": "ÉCONOMISEZ 50%",
"equivalent": "Seulement {{price}} $/mois",
"subscribe": "S'abonner maintenant",
"processing": "Traitement...",
"restore": "Restaurer les achats",
"terms": "Le paiement sera débité sur votre identifiant Apple à la confirmation. L'abonnement se renouvelle automatiquement sauf annulation au moins 24h avant la fin de la période. Gérez dans les réglages du compte."
},
"privacy": {
"title": "Politique de Confidentialité",
"lastUpdated": "Dernière mise à jour : Mars 2026",
"intro": {
"title": "Introduction",
"content": "TabataFit s'engage à protéger votre vie privée. Cette politique explique comment nous collectons, utilisons et protégeons vos informations lorsque vous utilisez notre application de fitness."
},
"dataCollection": {
"title": "Données Collectées",
"content": "Nous collectons uniquement les informations nécessaires pour vous offrir la meilleure expérience d'entraînement :",
"items": {
"workouts": "Historique et préférences d'entraînement",
"settings": "Paramètres et configurations de l'app",
"device": "Type d'appareil et version iOS pour l'optimisation"
}
},
"usage": {
"title": "Utilisation des Données",
"content": "Vos données sont utilisées uniquement pour : personnaliser votre expérience, suivre vos progrès, synchroniser vos données sur vos appareils, et améliorer notre application."
},
"sharing": {
"title": "Partage des Données",
"content": "Nous ne vendons ni ne partageons vos informations personnelles avec des tiers. Vos données d'entraînement restent privées et sécurisées."
},
"security": {
"title": "Sécurité",
"content": "Nous mettons en œuvre des mesures de sécurité conformes aux standards de l'industrie pour protéger vos données, incluant le chiffrement et l'authentification sécurisée."
},
"rights": {
"title": "Vos Droits",
"content": "Vous avez le droit d'accéder, modifier ou supprimer vos données personnelles à tout moment. Vous pouvez exporter ou supprimer vos données depuis les paramètres de l'app."
},
"contact": {
"title": "Nous Contacter",
"content": "Si vous avez des questions sur cette politique de confidentialité, contactez-nous à :"
}
}
}
}

View File

@@ -3,14 +3,16 @@
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
### Feb 20, 2026
### Feb 28, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #5392 | 10:22 PM | 🔵 | User requests sandbox purchase testing capability for RevenueCat | ~514 |
| #5391 | 10:20 PM | 🟣 | RevenueCat subscription system fully integrated and tested | ~656 |
| #5386 | 10:16 PM | | Updated RevenueCat API key in production configuration | ~115 |
| #5384 | 9:28 PM | 🟣 | Implemented RevenueCat subscription system | ~190 |
| #5353 | 7:47 PM | 🟣 | RevenueCat Service Initialization Module Created | ~244 |
| #5275 | 2:42 PM | 🟣 | Implemented daily reminder feature for TabataGo app | ~204 |
| #5591 | 7:56 PM | | PostHog analytics enabled in development mode | ~249 |
| #5590 | 7:51 PM | 🔴 | Fixed screenshotMode configuration typo | ~177 |
| #5581 | 7:48 PM | 🟣 | PostHog session replay and advanced analytics enabled | ~370 |
| #5580 | " | 🔵 | PostHog configuration shows session replay disabled | ~229 |
| #5577 | 7:45 PM | 🟣 | PostHog API key configured in analytics service | ~255 |
| #5576 | 7:44 PM | 🔵 | PostHog service uses placeholder API key | ~298 |
| #5574 | " | 🔵 | Analytics service file located | ~193 |
| #5572 | 7:43 PM | 🔵 | PostHog integration points identified | ~228 |
</claude-mem-context>

View File

@@ -1,27 +1,27 @@
/**
* TabataFit PostHog Analytics Service
* Initialize and configure PostHog for user analytics
* Initialize and configure PostHog for user analytics and session replay
*
* Follows the same pattern as purchases.ts:
* - initializeAnalytics() called once at app startup
* - track() helper for type-safe event tracking
* - identifyUser() for user identification
* - Session replay enabled for onboarding funnel analysis
*/
import PostHog from 'posthog-react-native'
import PostHog, { PostHogConfig } from 'posthog-react-native'
type EventProperties = Record<string, string | number | boolean | string[]>
// PostHog configuration
// Replace with your actual PostHog project API key
const POSTHOG_API_KEY = '__YOUR_POSTHOG_API_KEY__'
const POSTHOG_HOST = 'https://us.i.posthog.com'
const POSTHOG_API_KEY = 'phc_9MuXpbtF6LfPycCAzI4xEPhggwwZyGiy9htW0jJ0LTi'
const POSTHOG_HOST = 'https://eu.i.posthog.com'
// Singleton client instance
let posthogClient: PostHog | null = null
/**
* Initialize PostHog SDK
* Initialize PostHog SDK with session replay
* Call this once at app startup (after store hydration)
*/
export async function initializeAnalytics(): Promise<PostHog | null> {
@@ -39,12 +39,30 @@ export async function initializeAnalytics(): Promise<PostHog | null> {
}
try {
posthogClient = new PostHog(POSTHOG_API_KEY, {
const config: PostHogConfig = {
host: POSTHOG_HOST,
enableSessionReplay: false,
})
// Session Replay - enabled for onboarding funnel analysis
enableSessionReplay: true,
sessionReplayConfig: {
// Mask sensitive inputs (passwords, etc.)
maskAllTextInputs: true,
// Capture screenshots for better replay quality
screenshotMode: 'lazy', // Only capture when needed
// Network capture for API debugging in replays
captureNetworkTelemetry: true,
},
// Autocapture configuration
autocapture: {
captureScreens: true,
captureTouches: true,
},
// Flush events more frequently during onboarding
flushAt: 10,
}
console.log('[Analytics] PostHog initialized successfully')
posthogClient = new PostHog(POSTHOG_API_KEY, config)
console.log('[Analytics] PostHog initialized with session replay')
return posthogClient
} catch (error) {
console.error('[Analytics] Failed to initialize PostHog:', error)
@@ -70,8 +88,16 @@ export function track(event: string, properties?: EventProperties): void {
posthogClient?.capture(event, properties)
}
/**
* Track a screen view
*/
export function trackScreen(screenName: string, properties?: EventProperties): void {
track('$screen', { $screen_name: screenName, ...properties })
}
/**
* Identify a user with traits
* Call after onboarding completion to link session replays to user
*/
export function identifyUser(
userId: string,
@@ -82,3 +108,41 @@ export function identifyUser(
}
posthogClient?.identify(userId, traits)
}
/**
* Set user properties (without identifying)
*/
export function setUserProperties(properties: EventProperties): void {
if (__DEV__) {
console.log('[Analytics] set user properties', properties)
}
posthogClient?.personProperties(properties)
}
/**
* Start a new session replay recording
* Useful for key moments like onboarding start
*/
export function startSessionRecording(): void {
if (__DEV__) {
console.log('[Analytics] start session recording')
}
posthogClient?.startSessionRecording()
}
/**
* Stop session replay recording
*/
export function stopSessionRecording(): void {
if (__DEV__) {
console.log('[Analytics] stop session recording')
}
posthogClient?.stopSessionRecording()
}
/**
* Get the current session URL for debugging
*/
export function getSessionReplayUrl(): string | null {
return posthogClient?.getSessionReplayUrl() ?? null
}

View File

@@ -0,0 +1,140 @@
import { supabase, isSupabaseConfigured } from '../supabase'
import type { MusicVibe } from '../types'
export interface MusicTrack {
id: string
title: string
artist: string
duration: number
url: string
vibe: MusicVibe
}
const MOCK_TRACKS: Record<MusicVibe, MusicTrack[]> = {
electronic: [
{ 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' },
],
'hip-hop': [
{ id: '4', title: 'Street Heat', artist: 'Urban Flow', duration: 210, url: '', vibe: 'hip-hop' },
{ id: '5', title: 'Rhythm Power', artist: 'Beat Masters', duration: 195, url: '', vibe: 'hip-hop' },
{ id: '6', title: 'Flow State', artist: 'MC Dynamic', duration: 220, url: '', vibe: 'hip-hop' },
],
pop: [
{ id: '7', title: 'Summer Energy', artist: 'The Popstars', duration: 185, url: '', vibe: 'pop' },
{ id: '8', title: 'Upbeat Vibes', artist: 'Chart Toppers', duration: 200, url: '', vibe: 'pop' },
{ id: '9', title: 'Feel Good', artist: 'Radio Hits', duration: 175, url: '', vibe: 'pop' },
],
rock: [
{ id: '10', title: 'Power Chord', artist: 'The Amplifiers', duration: 230, url: '', vibe: 'rock' },
{ id: '11', title: 'High Gain', artist: ' distortion', duration: 205, url: '', vibe: 'rock' },
{ id: '12', title: ' adrenaline', artist: 'Thunderstruck', duration: 215, url: '', vibe: 'rock' },
],
chill: [
{ id: '13', title: 'Smooth Flow', artist: 'Lo-Fi Beats', duration: 250, url: '', vibe: 'chill' },
{ id: '14', title: 'Zen Mode', artist: 'Calm Collective', duration: 240, url: '', vibe: 'chill' },
{ id: '15', title: 'Deep Breath', artist: 'Mindful Tones', duration: 260, url: '', vibe: 'chill' },
],
}
class MusicService {
private cache: Map<MusicVibe, MusicTrack[]> = new Map()
async loadTracksForVibe(vibe: MusicVibe): Promise<MusicTrack[]> {
if (this.cache.has(vibe)) {
return this.cache.get(vibe)!
}
if (!isSupabaseConfigured()) {
console.log(`[Music] Using mock tracks for vibe: ${vibe}`)
return MOCK_TRACKS[vibe] || MOCK_TRACKS.electronic
}
try {
const { data: files, error } = await supabase
.storage
.from('music')
.list(vibe)
if (error) {
console.error('[Music] Error loading tracks:', error)
return MOCK_TRACKS[vibe] || MOCK_TRACKS.electronic
}
if (!files || files.length === 0) {
console.log(`[Music] No tracks found for vibe: ${vibe}, using mock data`)
return MOCK_TRACKS[vibe] || MOCK_TRACKS.electronic
}
const tracks: MusicTrack[] = await Promise.all(
files
.filter(file => file.name.endsWith('.mp3') || file.name.endsWith('.m4a'))
.map(async (file, index) => {
const { data: urlData } = await supabase
.storage
.from('music')
.createSignedUrl(`${vibe}/${file.name}`, 3600)
const fileName = file.name.replace(/\.[^/.]+$/, '')
const [artist, title] = fileName.includes(' - ')
? fileName.split(' - ')
: ['Unknown Artist', fileName]
return {
id: `${vibe}-${index}`,
title: title || fileName,
artist: artist || 'Unknown Artist',
duration: 180,
url: urlData?.signedUrl || '',
vibe,
}
})
)
const validTracks = tracks.filter(track => track.url)
if (validTracks.length === 0) {
console.log(`[Music] No valid tracks for vibe: ${vibe}, using mock data`)
return MOCK_TRACKS[vibe] || MOCK_TRACKS.electronic
}
this.cache.set(vibe, validTracks)
console.log(`[Music] Loaded ${validTracks.length} tracks for vibe: ${vibe}`)
return validTracks
} catch (error) {
console.error('[Music] Error loading tracks:', error)
return MOCK_TRACKS[vibe] || MOCK_TRACKS.electronic
}
}
clearCache(vibe?: MusicVibe): void {
if (vibe) {
this.cache.delete(vibe)
} else {
this.cache.clear()
}
}
getRandomTrack(tracks: MusicTrack[]): MusicTrack | null {
if (tracks.length === 0) return null
const randomIndex = Math.floor(Math.random() * tracks.length)
return tracks[randomIndex]
}
getNextTrack(tracks: MusicTrack[], currentTrackId: string, shuffle: boolean = false): MusicTrack | null {
if (tracks.length === 0) return null
if (tracks.length === 1) return tracks[0]
if (shuffle) {
return this.getRandomTrack(tracks.filter(t => t.id !== currentTrackId))
}
const currentIndex = tracks.findIndex(t => t.id === currentTrackId)
const nextIndex = (currentIndex + 1) % tracks.length
return tracks[nextIndex]
}
}
export const musicService = new MusicService()

View File

@@ -45,6 +45,8 @@ export const useUserStore = create<UserState>()(
haptics: true,
soundEffects: true,
voiceCoaching: true,
musicEnabled: true,
musicVolume: 0.5,
reminders: false,
reminderTime: '09:00',
},

View File

@@ -0,0 +1,37 @@
/**
* TabataFit Supabase Client
* Initialize Supabase client with environment variables
*/
import { createClient } from '@supabase/supabase-js'
import type { Database } from './database.types'
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL
const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY
if (!supabaseUrl || !supabaseAnonKey) {
console.warn(
'Supabase credentials not found. Using mock data. ' +
'Please set EXPO_PUBLIC_SUPABASE_URL and EXPO_PUBLIC_SUPABASE_ANON_KEY in your .env file'
)
}
// Create Supabase client with type safety
export const supabase = createClient<Database>(
supabaseUrl ?? 'http://localhost:54321',
supabaseAnonKey ?? 'mock-key',
{
auth: {
persistSession: true,
autoRefreshToken: true,
detectSessionInUrl: false,
},
}
)
// Check if Supabase is properly configured
export const isSupabaseConfigured = (): boolean => {
return !!supabaseUrl && !!supabaseAnonKey &&
supabaseUrl !== 'http://localhost:54321' &&
supabaseAnonKey !== 'mock-key'
}

View File

@@ -0,0 +1,291 @@
/**
* TabataFit Supabase Database Types
* Generated types for the Supabase schema
*/
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[]
export interface Database {
public: {
Tables: {
workouts: {
Row: {
id: string
title: string
trainer_id: string
category: 'full-body' | 'core' | 'upper-body' | 'lower-body' | 'cardio'
level: 'Beginner' | 'Intermediate' | 'Advanced'
duration: number
calories: number
rounds: number
prep_time: number
work_time: number
rest_time: number
equipment: string[]
music_vibe: 'electronic' | 'hip-hop' | 'pop' | 'rock' | 'chill'
exercises: {
name: string
duration: number
}[]
thumbnail_url: string | null
video_url: string | null
is_featured: boolean
created_at: string
updated_at: string
}
Insert: {
id?: string
title: string
trainer_id: string
category: 'full-body' | 'core' | 'upper-body' | 'lower-body' | 'cardio'
level: 'Beginner' | 'Intermediate' | 'Advanced'
duration: number
calories: number
rounds: number
prep_time: number
work_time: number
rest_time: number
equipment?: string[]
music_vibe: 'electronic' | 'hip-hop' | 'pop' | 'rock' | 'chill'
exercises: {
name: string
duration: number
}[]
thumbnail_url?: string | null
video_url?: string | null
is_featured?: boolean
created_at?: string
updated_at?: string
}
Update: {
id?: string
title?: string
trainer_id?: string
category?: 'full-body' | 'core' | 'upper-body' | 'lower-body' | 'cardio'
level?: 'Beginner' | 'Intermediate' | 'Advanced'
duration?: number
calories?: number
rounds?: number
prep_time?: number
work_time?: number
rest_time?: number
equipment?: string[]
music_vibe?: 'electronic' | 'hip-hop' | 'pop' | 'rock' | 'chill'
exercises?: {
name: string
duration: number
}[]
thumbnail_url?: string | null
video_url?: string | null
is_featured?: boolean
updated_at?: string
}
}
trainers: {
Row: {
id: string
name: string
specialty: string
color: string
avatar_url: string | null
workout_count: number
created_at: string
updated_at: string
}
Insert: {
id?: string
name: string
specialty: string
color: string
avatar_url?: string | null
workout_count?: number
created_at?: string
updated_at?: string
}
Update: {
id?: string
name?: string
specialty?: string
color?: string
avatar_url?: string | null
workout_count?: number
updated_at?: string
}
}
collections: {
Row: {
id: string
title: string
description: string
icon: string
gradient: string[] | null
created_at: string
updated_at: string
}
Insert: {
id?: string
title: string
description: string
icon: string
gradient?: string[] | null
created_at?: string
updated_at?: string
}
Update: {
id?: string
title?: string
description?: string
icon?: string
gradient?: string[] | null
updated_at?: string
}
}
collection_workouts: {
Row: {
id: string
collection_id: string
workout_id: string
sort_order: number
created_at: string
}
Insert: {
id?: string
collection_id: string
workout_id: string
sort_order?: number
created_at?: string
}
Update: {
id?: string
collection_id?: string
workout_id?: string
sort_order?: number
}
}
programs: {
Row: {
id: string
title: string
description: string
weeks: number
workouts_per_week: number
level: 'Beginner' | 'Intermediate' | 'Advanced'
created_at: string
updated_at: string
}
Insert: {
id?: string
title: string
description: string
weeks: number
workouts_per_week: number
level: 'Beginner' | 'Intermediate' | 'Advanced'
created_at?: string
updated_at?: string
}
Update: {
id?: string
title?: string
description?: string
weeks?: number
workouts_per_week?: number
level?: 'Beginner' | 'Intermediate' | 'Advanced'
updated_at?: string
}
}
program_workouts: {
Row: {
id: string
program_id: string
workout_id: string
week_number: number
day_number: number
created_at: string
}
Insert: {
id?: string
program_id: string
workout_id: string
week_number: number
day_number: number
created_at?: string
}
Update: {
id?: string
program_id?: string
workout_id?: string
week_number?: number
day_number?: number
}
}
achievements: {
Row: {
id: string
title: string
description: string
icon: string
requirement: number
type: 'workouts' | 'streak' | 'minutes' | 'calories'
created_at: string
updated_at: string
}
Insert: {
id?: string
title: string
description: string
icon: string
requirement: number
type: 'workouts' | 'streak' | 'minutes' | 'calories'
created_at?: string
updated_at?: string
}
Update: {
id?: string
title?: string
description?: string
icon?: string
requirement?: number
type?: 'workouts' | 'streak' | 'minutes' | 'calories'
updated_at?: string
}
}
admin_users: {
Row: {
id: string
email: string
role: 'admin' | 'editor'
created_at: string
last_login: string | null
}
Insert: {
id?: string
email: string
role?: 'admin' | 'editor'
created_at?: string
last_login?: string | null
}
Update: {
id?: string
email?: string
role?: 'admin' | 'editor'
last_login?: string | null
}
}
}
Views: {
[_ in never]: never
}
Functions: {
[_ in never]: never
}
Enums: {
[_ in never]: never
}
}
}

View File

@@ -0,0 +1,2 @@
export type { Database } from './database.types'
export { supabase, isSupabaseConfigured } from './client'

View File

@@ -8,6 +8,8 @@ export interface UserSettings {
haptics: boolean
soundEffects: boolean
voiceCoaching: boolean
musicEnabled: boolean
musicVolume: number
reminders: boolean
reminderTime: string
}