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:
64
src/admin/components/AdminAuthProvider.tsx
Normal file
64
src/admin/components/AdminAuthProvider.tsx
Normal 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
|
||||
}
|
||||
282
src/admin/services/adminService.ts
Normal file
282
src/admin/services/adminService.ts
Normal 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()
|
||||
2
src/features/watch/index.ts
Normal file
2
src/features/watch/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './types';
|
||||
export { useWatchSync } from './useWatchSync';
|
||||
82
src/features/watch/types.ts
Normal file
82
src/features/watch/types.ts
Normal 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;
|
||||
}
|
||||
215
src/features/watch/useWatchSync.ts
Normal file
215
src/features/watch/useWatchSync.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
314
src/shared/data/dataService.ts
Normal file
314
src/shared/data/dataService.ts
Normal 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()
|
||||
@@ -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'
|
||||
|
||||
240
src/shared/hooks/useMusicPlayer.ts
Normal file
240
src/shared/hooks/useMusicPlayer.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
397
src/shared/hooks/useSupabaseData.ts
Normal file
397
src/shared/hooks/useSupabaseData.ts
Normal 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 }
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 à :"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
140
src/shared/services/music.ts
Normal file
140
src/shared/services/music.ts
Normal 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()
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
37
src/shared/supabase/client.ts
Normal file
37
src/shared/supabase/client.ts
Normal 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'
|
||||
}
|
||||
291
src/shared/supabase/database.types.ts
Normal file
291
src/shared/supabase/database.types.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
2
src/shared/supabase/index.ts
Normal file
2
src/shared/supabase/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { Database } from './database.types'
|
||||
export { supabase, isSupabaseConfigured } from './client'
|
||||
@@ -8,6 +8,8 @@ export interface UserSettings {
|
||||
haptics: boolean
|
||||
soundEffects: boolean
|
||||
voiceCoaching: boolean
|
||||
musicEnabled: boolean
|
||||
musicVolume: number
|
||||
reminders: boolean
|
||||
reminderTime: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user