remove legacy admin panel, assessment, collection & workout routes

Remove the in-app admin panel (app/admin/, src/admin/), assessment
screen, collection detail routes, and workout detail/category/body-zone
routes. These features have been superseded by the admin-web dashboard
and the new program-based navigation. Also removes stale CLAUDE.md
context files and an accidentally committed image blob.
This commit is contained in:
Millian Lamiaux
2026-04-21 21:49:58 +02:00
parent 791f432334
commit d82205cd71
20 changed files with 0 additions and 3614 deletions

View File

@@ -1,43 +0,0 @@
<claude-mem-context>
# Recent Activity
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
### Feb 20, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #5056 | 8:24 AM | ✅ | Completed Host wrapper restoration in home screen | ~258 |
| #5055 | " | ✅ | Re-added Host wrapper to home screen JSX | ~187 |
| #5054 | " | ✅ | Re-added Host import to home screen | ~184 |
| #5043 | 8:22 AM | ✅ | Removed closing Host tag from profile screen | ~210 |
| #5042 | " | ✅ | Removed opening Host tag from profile screen | ~164 |
| #5041 | " | ✅ | Removed closing Host tag from browse screen | ~187 |
| #5040 | " | ✅ | Removed opening Host tag from browse screen | ~159 |
| #5039 | 8:21 AM | ✅ | Removed closing Host tag from activity screen | ~193 |
| #5038 | " | ✅ | Removed opening Host tag from activity screen | ~154 |
### Apr 11, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #6124 | 7:41 PM | 🔵 | Home screen uses theme-based colors properly | ~229 |
### Apr 13, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #6166 | 10:03 PM | ✅ | Updated Tab Layout Documentation | ~137 |
| #6154 | 10:01 PM | 🔵 | Explored Explore Tab Structure | ~174 |
### Apr 17, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #6349 | 9:48 AM | 🔄 | Removed usePurchases import from home screen | ~271 |
| #6348 | " | 🔄 | Removed usePurchases hook from home screen | ~277 |
| #6346 | 9:47 AM | 🔄 | Cleaned up unused imports in home screen after removing direct program navigation | ~321 |
| #6343 | 9:46 AM | 🔄 | Refactored home screen body zone sections to clickable cards | ~400 |
| #6342 | 9:44 AM | 🔄 | Removed direct program navigation handler from home screen | ~305 |
| #6336 | 9:39 AM | 🔵 | Reviewed complete home screen implementation for body-zone workout programs | ~386 |
</claude-mem-context>

View File

@@ -1,35 +0,0 @@
import { Stack } from 'expo-router'
import { AdminAuthProvider, useAdminAuth } from '../../src/admin/components/AdminAuthProvider'
import { View, ActivityIndicator } from 'react-native'
import { Redirect } from 'expo-router'
function AdminLayoutContent({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isAdmin, isLoading } = useAdminAuth()
if (isLoading) {
return (
<View style={{ flex: 1, backgroundColor: '#000', justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" color="#FF6B35" />
</View>
)
}
if (!isAuthenticated || !isAdmin) {
return <Redirect href="/admin/login" />
}
return (
<>
<Stack.Screen options={{ headerShown: false }} />
{children}
</>
)
}
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return (
<AdminAuthProvider>
<AdminLayoutContent>{children}</AdminLayoutContent>
</AdminAuthProvider>
)
}

View File

@@ -1,201 +0,0 @@
import { useState } from 'react'
import {
View,
Text,
ScrollView,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
Alert,
} from 'react-native'
import { useRouter } from 'expo-router'
import { useCollections } from '../../src/shared/hooks/useSupabaseData'
import { adminService } from '../../src/admin/services/adminService'
import type { Collection } from '../../src/shared/types'
export default function AdminCollectionsScreen() {
const router = useRouter()
const { data: collections = [], isLoading: loading, refetch } = useCollections()
const [updatingId, setUpdatingId] = useState<string | null>(null)
const handleDelete = (collection: Collection) => {
Alert.alert(
'Delete Collection',
`Are you sure you want to delete "${collection.title}"?`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
Alert.alert('Info', 'Collection deletion not yet implemented')
}
},
]
)
}
if (loading) {
return (
<View style={[styles.container, styles.centered]}>
<ActivityIndicator size="large" color="#FF6B35" />
</View>
)
}
return (
<View style={styles.container}>
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
<Text style={styles.backText}> Back</Text>
</TouchableOpacity>
<Text style={styles.title}>Collections</Text>
<TouchableOpacity style={styles.addButton}>
<Text style={styles.addButtonText}>+ Add</Text>
</TouchableOpacity>
</View>
<ScrollView style={styles.content}>
{collections.map((collection: Collection) => (
<View key={collection.id} style={styles.collectionCard}>
<View style={styles.iconContainer}>
<Text style={styles.icon}>{collection.icon}</Text>
</View>
<View style={styles.collectionInfo}>
<Text style={styles.collectionTitle}>{collection.title}</Text>
<Text style={styles.collectionDescription}>{collection.description}</Text>
<Text style={styles.collectionMeta}>
{collection.workoutIds.length} workouts
</Text>
</View>
<View style={styles.actions}>
<TouchableOpacity style={styles.editButton}>
<Text style={styles.editText}>Edit</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.deleteButton, updatingId === collection.id && styles.disabledButton]}
onPress={() => handleDelete(collection)}
disabled={updatingId === collection.id}
>
<Text style={styles.deleteText}>
{updatingId === collection.id ? '...' : 'Delete'}
</Text>
</TouchableOpacity>
</View>
</View>
))}
</ScrollView>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
centered: {
justifyContent: 'center',
alignItems: 'center',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 20,
paddingTop: 60,
borderBottomWidth: 1,
borderBottomColor: '#1C1C1E',
},
backButton: {
padding: 8,
},
backText: {
color: '#FF6B35',
fontSize: 16,
},
title: {
fontSize: 20,
fontWeight: 'bold',
color: '#fff',
},
addButton: {
backgroundColor: '#FF6B35',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
},
addButtonText: {
color: '#000',
fontWeight: 'bold',
},
content: {
flex: 1,
padding: 16,
},
collectionCard: {
backgroundColor: '#1C1C1E',
borderRadius: 12,
padding: 16,
marginBottom: 12,
flexDirection: 'row',
alignItems: 'center',
},
iconContainer: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: '#2C2C2E',
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
icon: {
fontSize: 24,
},
collectionInfo: {
flex: 1,
},
collectionTitle: {
fontSize: 16,
fontWeight: 'bold',
color: '#fff',
marginBottom: 4,
},
collectionDescription: {
fontSize: 14,
color: '#999',
marginBottom: 4,
},
collectionMeta: {
fontSize: 12,
color: '#666',
},
actions: {
flexDirection: 'row',
gap: 8,
},
editButton: {
backgroundColor: '#2C2C2E',
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 6,
},
editText: {
color: '#5AC8FA',
},
deleteButton: {
backgroundColor: '#2C2C2E',
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 6,
},
disabledButton: {
opacity: 0.5,
},
deleteText: {
color: '#FF3B30',
},
})

View File

@@ -1,212 +0,0 @@
import { useState, useCallback } from 'react'
import {
View,
Text,
ScrollView,
TouchableOpacity,
StyleSheet,
RefreshControl,
} from 'react-native'
import { useRouter } from 'expo-router'
import { useAdminAuth } from '../../src/admin/components/AdminAuthProvider'
import { useWorkouts, useTrainers, useCollections } from '../../src/shared/hooks/useSupabaseData'
export default function AdminDashboardScreen() {
const router = useRouter()
const { signOut } = useAdminAuth()
const [refreshing, setRefreshing] = useState(false)
const {
data: workouts = [],
isLoading: workoutsLoading,
refetch: refetchWorkouts
} = useWorkouts()
const {
data: trainers = [],
isLoading: trainersLoading,
refetch: refetchTrainers
} = useTrainers()
const {
data: collections = [],
isLoading: collectionsLoading,
refetch: refetchCollections
} = useCollections()
const onRefresh = useCallback(async () => {
setRefreshing(true)
await Promise.all([
refetchWorkouts(),
refetchTrainers(),
refetchCollections(),
])
setRefreshing(false)
}, [refetchWorkouts, refetchTrainers, refetchCollections])
const handleLogout = async () => {
await signOut()
router.replace('/admin/login')
}
const isLoading = workoutsLoading || trainersLoading || collectionsLoading
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Admin Dashboard</Text>
<TouchableOpacity onPress={handleLogout} style={styles.logoutButton}>
<Text style={styles.logoutText}>Logout</Text>
</TouchableOpacity>
</View>
<ScrollView
style={styles.content}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor="#FF6B35" />
}
>
<View style={styles.statsGrid}>
<View style={styles.statCard}>
<Text style={styles.statNumber}>{workouts.length}</Text>
<Text style={styles.statLabel}>Workouts</Text>
</View>
<View style={styles.statCard}>
<Text style={styles.statNumber}>{trainers.length}</Text>
<Text style={styles.statLabel}>Trainers</Text>
</View>
<View style={styles.statCard}>
<Text style={styles.statNumber}>{collections.length}</Text>
<Text style={styles.statLabel}>Collections</Text>
</View>
</View>
<Text style={styles.sectionTitle}>Quick Actions</Text>
<View style={styles.actionsGrid}>
<TouchableOpacity
style={styles.actionCard}
onPress={() => router.push('/admin/workouts')}
>
<Text style={styles.actionIcon}>💪</Text>
<Text style={styles.actionTitle}>Manage Workouts</Text>
<Text style={styles.actionDescription}>Add, edit, or delete workouts</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.actionCard}
onPress={() => router.push('/admin/trainers')}
>
<Text style={styles.actionIcon}>👥</Text>
<Text style={styles.actionTitle}>Manage Trainers</Text>
<Text style={styles.actionDescription}>Update trainer profiles</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.actionCard}
onPress={() => router.push('/admin/collections')}
>
<Text style={styles.actionIcon}>📁</Text>
<Text style={styles.actionTitle}>Manage Collections</Text>
<Text style={styles.actionDescription}>Organize workout collections</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.actionCard}
onPress={() => router.push('/admin/media')}
>
<Text style={styles.actionIcon}>🎬</Text>
<Text style={styles.actionTitle}>Media Library</Text>
<Text style={styles.actionDescription}>Upload videos and images</Text>
</TouchableOpacity>
</View>
</ScrollView>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 20,
paddingTop: 60,
borderBottomWidth: 1,
borderBottomColor: '#1C1C1E',
},
title: {
fontSize: 28,
fontWeight: 'bold',
color: '#fff',
},
logoutButton: {
padding: 8,
},
logoutText: {
color: '#FF6B35',
fontSize: 16,
},
content: {
flex: 1,
padding: 20,
},
statsGrid: {
flexDirection: 'row',
gap: 12,
marginBottom: 32,
},
statCard: {
flex: 1,
backgroundColor: '#1C1C1E',
borderRadius: 12,
padding: 16,
alignItems: 'center',
},
statNumber: {
fontSize: 32,
fontWeight: 'bold',
color: '#FF6B35',
},
statLabel: {
fontSize: 14,
color: '#999',
marginTop: 4,
},
sectionTitle: {
fontSize: 20,
fontWeight: 'bold',
color: '#fff',
marginBottom: 16,
},
actionsGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
},
actionCard: {
width: '47%',
backgroundColor: '#1C1C1E',
borderRadius: 12,
padding: 20,
marginBottom: 12,
},
actionIcon: {
fontSize: 32,
marginBottom: 12,
},
actionTitle: {
fontSize: 16,
fontWeight: 'bold',
color: '#fff',
marginBottom: 4,
},
actionDescription: {
fontSize: 14,
color: '#999',
},
})

View File

@@ -1,124 +0,0 @@
import { useState } from 'react'
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
} from 'react-native'
import { useAdminAuth } from '../../src/admin/components/AdminAuthProvider'
export default function AdminLoginScreen() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const { signIn, isLoading } = useAdminAuth()
const handleLogin = async () => {
if (!email || !password) {
setError('Please enter both email and password')
return
}
setError('')
try {
await signIn(email, password)
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed')
}
}
return (
<View style={styles.container}>
<View style={styles.card}>
<Text style={styles.title}>TabataFit Admin</Text>
<Text style={styles.subtitle}>Sign in to manage content</Text>
{error ? <Text style={styles.errorText}>{error}</Text> : null}
<TextInput
style={styles.input}
placeholder="Email"
placeholderTextColor="#666"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
/>
<TextInput
style={styles.input}
placeholder="Password"
placeholderTextColor="#666"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
<TouchableOpacity
style={styles.button}
onPress={handleLogin}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator color="#000" />
) : (
<Text style={styles.buttonText}>Sign In</Text>
)}
</TouchableOpacity>
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
card: {
backgroundColor: '#1C1C1E',
borderRadius: 16,
padding: 32,
width: '100%',
maxWidth: 400,
},
title: {
fontSize: 28,
fontWeight: 'bold',
color: '#fff',
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: '#999',
marginBottom: 24,
},
errorText: {
color: '#FF3B30',
marginBottom: 16,
},
input: {
backgroundColor: '#2C2C2E',
borderRadius: 8,
padding: 16,
marginBottom: 16,
color: '#fff',
fontSize: 16,
},
button: {
backgroundColor: '#FF6B35',
borderRadius: 8,
padding: 16,
alignItems: 'center',
},
buttonText: {
color: '#000',
fontSize: 16,
fontWeight: 'bold',
},
})

View File

@@ -1,201 +0,0 @@
import { useState } from 'react'
import {
View,
Text,
ScrollView,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
Alert,
} from 'react-native'
import { useRouter } from 'expo-router'
import { supabase } from '../../src/shared/supabase'
export default function AdminMediaScreen() {
const router = useRouter()
const [uploading, setUploading] = useState(false)
const [activeTab, setActiveTab] = useState<'videos' | 'thumbnails' | 'avatars'>('videos')
const handleUpload = async () => {
Alert.alert('Info', 'File upload requires file picker integration. This is a placeholder.')
}
const handleDelete = async (path: string) => {
Alert.alert(
'Delete File',
`Are you sure you want to delete "${path}"?`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
try {
const { error } = await supabase.storage
.from(activeTab)
.remove([path])
if (error) throw error
Alert.alert('Success', 'File deleted')
} catch (err) {
Alert.alert('Error', err instanceof Error ? err.message : 'Failed to delete')
}
}
},
]
)
}
return (
<View style={styles.container}>
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
<Text style={styles.backText}> Back</Text>
</TouchableOpacity>
<Text style={styles.title}>Media Library</Text>
<TouchableOpacity style={styles.uploadButton} onPress={handleUpload}>
<Text style={styles.uploadButtonText}>Upload</Text>
</TouchableOpacity>
</View>
<View style={styles.tabs}>
{(['videos', 'thumbnails', 'avatars'] as const).map((tab) => (
<TouchableOpacity
key={tab}
style={[styles.tab, activeTab === tab && styles.activeTab]}
onPress={() => setActiveTab(tab)}
>
<Text style={[styles.tabText, activeTab === tab && styles.activeTabText]}>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
</Text>
</TouchableOpacity>
))}
</View>
<ScrollView style={styles.content}>
<View style={styles.infoCard}>
<Text style={styles.infoTitle}>Storage Buckets</Text>
<Text style={styles.infoText}>
videos - Workout videos (MP4, MOV){'\n'}
thumbnails - Workout thumbnails (JPG, PNG){'\n'}
avatars - Trainer avatars (JPG, PNG)
</Text>
</View>
<View style={styles.placeholderCard}>
<Text style={styles.placeholderIcon}>🎬</Text>
<Text style={styles.placeholderTitle}>Media Management</Text>
<Text style={styles.placeholderText}>
Upload and manage media files for workouts and trainers.{'\n\n'}
This feature requires file picker integration.{'\n'}
Files will be stored in Supabase Storage.
</Text>
</View>
</ScrollView>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 20,
paddingTop: 60,
borderBottomWidth: 1,
borderBottomColor: '#1C1C1E',
},
backButton: {
padding: 8,
},
backText: {
color: '#FF6B35',
fontSize: 16,
},
title: {
fontSize: 20,
fontWeight: 'bold',
color: '#fff',
},
uploadButton: {
backgroundColor: '#FF6B35',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
},
uploadButtonText: {
color: '#000',
fontWeight: 'bold',
},
tabs: {
flexDirection: 'row',
padding: 16,
gap: 8,
},
tab: {
flex: 1,
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 8,
backgroundColor: '#1C1C1E',
alignItems: 'center',
},
activeTab: {
backgroundColor: '#FF6B35',
},
tabText: {
color: '#999',
fontWeight: '600',
},
activeTabText: {
color: '#000',
},
content: {
flex: 1,
padding: 16,
},
infoCard: {
backgroundColor: '#1C1C1E',
borderRadius: 12,
padding: 16,
marginBottom: 16,
},
infoTitle: {
fontSize: 16,
fontWeight: 'bold',
color: '#fff',
marginBottom: 8,
},
infoText: {
fontSize: 14,
color: '#999',
lineHeight: 20,
},
placeholderCard: {
backgroundColor: '#1C1C1E',
borderRadius: 12,
padding: 32,
alignItems: 'center',
},
placeholderIcon: {
fontSize: 48,
marginBottom: 16,
},
placeholderTitle: {
fontSize: 18,
fontWeight: 'bold',
color: '#fff',
marginBottom: 8,
},
placeholderText: {
fontSize: 14,
color: '#999',
textAlign: 'center',
lineHeight: 20,
},
})

View File

@@ -1,194 +0,0 @@
import { useState } from 'react'
import {
View,
Text,
ScrollView,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
Alert,
} from 'react-native'
import { useRouter } from 'expo-router'
import { useTrainers } from '../../src/shared/hooks/useSupabaseData'
import { adminService } from '../../src/admin/services/adminService'
import type { Trainer } from '../../src/shared/types'
export default function AdminTrainersScreen() {
const router = useRouter()
const { data: trainers = [], isLoading: loading, refetch } = useTrainers()
const [deletingId, setDeletingId] = useState<string | null>(null)
const handleDelete = (trainer: Trainer) => {
Alert.alert(
'Delete Trainer',
`Are you sure you want to delete "${trainer.name}"?`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
setDeletingId(trainer.id)
try {
await adminService.deleteTrainer(trainer.id)
await refetch()
} catch (err) {
Alert.alert('Error', err instanceof Error ? err.message : 'Failed to delete')
} finally {
setDeletingId(null)
}
}
},
]
)
}
if (loading) {
return (
<View style={[styles.container, styles.centered]}>
<ActivityIndicator size="large" color="#FF6B35" />
</View>
)
}
return (
<View style={styles.container}>
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
<Text style={styles.backText}> Back</Text>
</TouchableOpacity>
<Text style={styles.title}>Trainers</Text>
<TouchableOpacity style={styles.addButton}>
<Text style={styles.addButtonText}>+ Add</Text>
</TouchableOpacity>
</View>
<ScrollView style={styles.content}>
{trainers.map((trainer) => (
<View key={trainer.id} style={styles.trainerCard}>
<View style={[styles.colorIndicator, { backgroundColor: trainer.color }]} />
<View style={styles.trainerInfo}>
<Text style={styles.trainerName}>{trainer.name}</Text>
<Text style={styles.trainerMeta}>
{trainer.specialty} {trainer.workoutCount} workouts
</Text>
</View>
<View style={styles.actions}>
<TouchableOpacity style={styles.editButton}>
<Text style={styles.editText}>Edit</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.deleteButton, deletingId === trainer.id && styles.disabledButton]}
onPress={() => handleDelete(trainer)}
disabled={deletingId === trainer.id}
>
<Text style={styles.deleteText}>
{deletingId === trainer.id ? '...' : 'Delete'}
</Text>
</TouchableOpacity>
</View>
</View>
))}
</ScrollView>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
centered: {
justifyContent: 'center',
alignItems: 'center',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 20,
paddingTop: 60,
borderBottomWidth: 1,
borderBottomColor: '#1C1C1E',
},
backButton: {
padding: 8,
},
backText: {
color: '#FF6B35',
fontSize: 16,
},
title: {
fontSize: 20,
fontWeight: 'bold',
color: '#fff',
},
addButton: {
backgroundColor: '#FF6B35',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
},
addButtonText: {
color: '#000',
fontWeight: 'bold',
},
content: {
flex: 1,
padding: 16,
},
trainerCard: {
backgroundColor: '#1C1C1E',
borderRadius: 12,
padding: 16,
marginBottom: 12,
flexDirection: 'row',
alignItems: 'center',
},
colorIndicator: {
width: 12,
height: 12,
borderRadius: 6,
marginRight: 12,
},
trainerInfo: {
flex: 1,
},
trainerName: {
fontSize: 16,
fontWeight: 'bold',
color: '#fff',
marginBottom: 4,
},
trainerMeta: {
fontSize: 14,
color: '#999',
},
actions: {
flexDirection: 'row',
gap: 8,
},
editButton: {
backgroundColor: '#2C2C2E',
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 6,
},
editText: {
color: '#5AC8FA',
},
deleteButton: {
backgroundColor: '#2C2C2E',
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 6,
},
disabledButton: {
opacity: 0.5,
},
deleteText: {
color: '#FF3B30',
},
})

View File

@@ -1,190 +0,0 @@
import { useState } from 'react'
import {
View,
Text,
ScrollView,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
Alert,
} from 'react-native'
import { useRouter } from 'expo-router'
import { useWorkouts } from '../../src/shared/hooks/useSupabaseData'
import { adminService } from '../../src/admin/services/adminService'
import type { Workout } from '../../src/shared/types'
export default function AdminWorkoutsScreen() {
const router = useRouter()
const { data: workouts = [], isLoading: loading, error, refetch } = useWorkouts()
const [deletingId, setDeletingId] = useState<string | null>(null)
const handleDelete = (workout: Workout) => {
Alert.alert(
'Delete Workout',
`Are you sure you want to delete "${workout.title}"?`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
setDeletingId(workout.id)
try {
await adminService.deleteWorkout(workout.id)
await refetch()
} catch (err) {
Alert.alert('Error', err instanceof Error ? err.message : 'Failed to delete')
} finally {
setDeletingId(null)
}
}
},
]
)
}
if (loading) {
return (
<View style={[styles.container, styles.centered]}>
<ActivityIndicator size="large" color="#FF6B35" />
</View>
)
}
return (
<View style={styles.container}>
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
<Text style={styles.backText}> Back</Text>
</TouchableOpacity>
<Text style={styles.title}>Workouts</Text>
<TouchableOpacity style={styles.addButton}>
<Text style={styles.addButtonText}>+ Add</Text>
</TouchableOpacity>
</View>
<ScrollView style={styles.content}>
{workouts.map((workout) => (
<View key={workout.id} style={styles.workoutCard}>
<View style={styles.workoutInfo}>
<Text style={styles.workoutTitle}>{workout.title}</Text>
<Text style={styles.workoutMeta}>
{workout.category} {workout.level} {workout.duration}min
</Text>
<Text style={styles.workoutMeta}>
{workout.rounds} rounds {workout.calories} cal
</Text>
</View>
<View style={styles.actions}>
<TouchableOpacity style={styles.editButton}>
<Text style={styles.editText}>Edit</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.deleteButton, deletingId === workout.id && styles.disabledButton]}
onPress={() => handleDelete(workout)}
disabled={deletingId === workout.id}
>
<Text style={styles.deleteText}>
{deletingId === workout.id ? '...' : 'Delete'}
</Text>
</TouchableOpacity>
</View>
</View>
))}
</ScrollView>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
centered: {
justifyContent: 'center',
alignItems: 'center',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 20,
paddingTop: 60,
borderBottomWidth: 1,
borderBottomColor: '#1C1C1E',
},
backButton: {
padding: 8,
},
backText: {
color: '#FF6B35',
fontSize: 16,
},
title: {
fontSize: 20,
fontWeight: 'bold',
color: '#fff',
},
addButton: {
backgroundColor: '#FF6B35',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
},
addButtonText: {
color: '#000',
fontWeight: 'bold',
},
content: {
flex: 1,
padding: 16,
},
workoutCard: {
backgroundColor: '#1C1C1E',
borderRadius: 12,
padding: 16,
marginBottom: 12,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
workoutInfo: {
flex: 1,
},
workoutTitle: {
fontSize: 16,
fontWeight: 'bold',
color: '#fff',
marginBottom: 4,
},
workoutMeta: {
fontSize: 14,
color: '#999',
},
actions: {
flexDirection: 'row',
gap: 8,
},
editButton: {
backgroundColor: '#2C2C2E',
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 6,
},
editText: {
color: '#5AC8FA',
},
deleteButton: {
backgroundColor: '#2C2C2E',
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 6,
},
disabledButton: {
opacity: 0.5,
},
deleteText: {
color: '#FF3B30',
},
})

View File

@@ -1,445 +0,0 @@
/**
* TabataFit Assessment Screen
* Initial movement assessment to personalize experience
*/
import { View, StyleSheet, ScrollView, Pressable } from 'react-native'
import { useRouter } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { LinearGradient } from 'expo-linear-gradient'
import { Icon } from '@/src/shared/components/Icon'
import { NativeButton } from '@/src/shared/components/native'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useHaptics } from '@/src/shared/hooks'
import { useProgramStore } from '@/src/shared/stores'
import { ASSESSMENT_WORKOUT } from '@/src/shared/data/programs'
import { StyledText } from '@/src/shared/components/StyledText'
import { useThemeColors, BRAND } from '@/src/shared/theme'
import { withOpacity } from '@/src/shared/utils/color'
import type { ThemeColors } from '@/src/shared/theme/types'
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { TEXT, GRADIENTS } from '@/src/shared/constants/colors'
const FONTS = {
LARGE_TITLE: 28,
TITLE: 24,
HEADLINE: 17,
BODY: 16,
CAPTION: 13,
}
export default function AssessmentScreen() {
const { t } = useTranslation('screens')
const insets = useSafeAreaInsets()
const router = useRouter()
const haptics = useHaptics()
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
const [showIntro, setShowIntro] = useState(true)
const skipAssessment = useProgramStore((s) => s.skipAssessment)
const completeAssessment = useProgramStore((s) => s.completeAssessment)
const handleSkip = () => {
haptics.buttonTap()
skipAssessment()
router.back()
}
const handleStart = () => {
haptics.buttonTap()
setShowIntro(false)
}
const handleComplete = () => {
haptics.workoutComplete()
completeAssessment({
completedAt: new Date().toISOString(),
exercisesCompleted: ASSESSMENT_WORKOUT.exercises.map(e => e.name),
})
router.back()
}
if (!showIntro) {
// Here we'd show the actual assessment workout player
// For now, just show a completion screen
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
<View style={styles.header}>
<Pressable style={styles.backButton} onPress={() => setShowIntro(true)}>
<Icon name="arrow.left" size={24} color={colors.text.primary} />
</Pressable>
<StyledText size={FONTS.TITLE} weight="bold" color={colors.text.primary}>
{t('assessment.title')}
</StyledText>
<View style={styles.placeholder} />
</View>
<ScrollView
style={styles.scrollView}
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 120 }]}
>
<View style={styles.assessmentContainer}>
<View style={styles.exerciseList}>
{ASSESSMENT_WORKOUT.exercises.map((exercise, index) => (
<View key={exercise.name} style={styles.exerciseItem}>
<View style={styles.exerciseNumber}>
<StyledText size={14} weight="bold" color={colors.text.primary}>
{index + 1}
</StyledText>
</View>
<View style={styles.exerciseInfo}>
<StyledText size={16} weight="semibold" color={colors.text.primary}>
{exercise.name}
</StyledText>
<StyledText size={13} color={colors.text.secondary}>
{exercise.duration}s {exercise.purpose}
</StyledText>
</View>
</View>
))}
</View>
<View style={styles.tipsSection}>
<StyledText size={FONTS.HEADLINE} weight="semibold" color={colors.text.primary} style={styles.tipsTitle}>
{t('assessment.tips')}
</StyledText>
{[1, 2, 3, 4].map((index) => (
<View key={index} style={styles.tipItem}>
<Icon name="checkmark.circle" size={18} color={BRAND.PRIMARY} />
<StyledText size={14} color={colors.text.secondary} style={styles.tipText}>
{t(`assessment.tip${index}`)}
</StyledText>
</View>
))}
</View>
</View>
</ScrollView>
<View style={[styles.bottomBar, { paddingBottom: insets.bottom + SPACING[4] }]}>
<NativeButton
variant="primary"
title={t('assessment.startAssessment')}
systemImage="play.fill"
onPress={handleComplete}
fullWidth
controlSize="large"
/>
</View>
</View>
)
}
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
{/* Header */}
<View style={styles.header}>
<Pressable style={styles.backButton} onPress={handleSkip}>
<Icon name="xmark" size={24} color={colors.text.primary} />
</Pressable>
<View style={styles.placeholder} />
</View>
<ScrollView
style={styles.scrollView}
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 120 }]}
showsVerticalScrollIndicator={false}
>
{/* Hero */}
<View style={styles.heroSection}>
<View style={styles.iconContainer}>
<Icon name="clipboard" size={48} color={BRAND.PRIMARY} />
</View>
<StyledText size={FONTS.LARGE_TITLE} weight="bold" color={colors.text.primary} style={styles.heroTitle}>
{t('assessment.welcomeTitle')}
</StyledText>
<StyledText size={FONTS.BODY} color={colors.text.secondary} style={styles.heroDescription}>
{t('assessment.welcomeDescription')}
</StyledText>
</View>
{/* Features */}
<View style={styles.featuresSection}>
<View style={styles.featureItem}>
<View style={styles.featureIcon}>
<Icon name="clock" size={24} color={BRAND.PRIMARY} />
</View>
<View style={styles.featureText}>
<StyledText size={16} weight="semibold" color={colors.text.primary}>
{ASSESSMENT_WORKOUT.duration} {t('assessment.minutes')}
</StyledText>
<StyledText size={14} color={colors.text.secondary}>
{t('assessment.quickCheck')}
</StyledText>
</View>
</View>
<View style={styles.featureItem}>
<View style={styles.featureIcon}>
<Icon name="figure.stand" size={24} color={BRAND.PRIMARY} />
</View>
<View style={styles.featureText}>
<StyledText size={16} weight="semibold" color={colors.text.primary}>
{ASSESSMENT_WORKOUT.exercises.length} {t('assessment.movements')}
</StyledText>
<StyledText size={14} color={colors.text.secondary}>
{t('assessment.testMovements')}
</StyledText>
</View>
</View>
<View style={styles.featureItem}>
<View style={styles.featureIcon}>
<Icon name="dumbbell" size={24} color={BRAND.PRIMARY} />
</View>
<View style={styles.featureText}>
<StyledText size={16} weight="semibold" color={colors.text.primary}>
{t('assessment.noEquipment')}
</StyledText>
<StyledText size={14} color={colors.text.secondary}>
{t('assessment.justYourBody')}
</StyledText>
</View>
</View>
</View>
{/* Benefits */}
<View style={styles.benefitsSection}>
<StyledText size={FONTS.HEADLINE} weight="semibold" color={colors.text.primary} style={styles.benefitsTitle}>
{t('assessment.whatWeCheck')}
</StyledText>
<View style={styles.benefitsList}>
<View style={styles.benefitTag}>
<StyledText size={13} color={colors.text.primary}>
{t('assessment.mobility')}
</StyledText>
</View>
<View style={styles.benefitTag}>
<StyledText size={13} color={colors.text.primary}>
{t('assessment.strength')}
</StyledText>
</View>
<View style={styles.benefitTag}>
<StyledText size={13} color={colors.text.primary}>
{t('assessment.stability')}
</StyledText>
</View>
<View style={styles.benefitTag}>
<StyledText size={13} color={colors.text.primary}>
{t('assessment.balance')}
</StyledText>
</View>
</View>
</View>
</ScrollView>
{/* Bottom Actions */}
<View style={[styles.bottomBar, { paddingBottom: insets.bottom + SPACING[4] }]}>
<NativeButton
variant="primary"
title={t('assessment.takeAssessment')}
systemImage="arrow.right"
onPress={handleStart}
fullWidth
controlSize="large"
/>
<NativeButton
variant="ghost"
title={t('assessment.skipForNow')}
onPress={handleSkip}
/>
</View>
</View>
)
}
function createStyles(colors: ThemeColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bg.base,
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingHorizontal: LAYOUT.SCREEN_PADDING,
},
// Header
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: LAYOUT.SCREEN_PADDING,
paddingVertical: SPACING[3],
},
backButton: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
placeholder: {
width: 40,
},
// Hero
heroSection: {
alignItems: 'center',
marginTop: SPACING[4],
marginBottom: SPACING[8],
},
iconContainer: {
width: 100,
height: 100,
borderRadius: RADIUS.FULL,
backgroundColor: withOpacity(BRAND.PRIMARY, 0.08),
alignItems: 'center',
justifyContent: 'center',
marginBottom: SPACING[5],
},
heroTitle: {
textAlign: 'center',
marginBottom: SPACING[3],
},
heroDescription: {
textAlign: 'center',
lineHeight: 24,
paddingHorizontal: SPACING[4],
},
// Features
featuresSection: {
marginBottom: SPACING[8],
},
featureItem: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: SPACING[4],
backgroundColor: colors.bg.surface,
padding: SPACING[4],
borderRadius: RADIUS.LG,
},
featureIcon: {
width: 48,
height: 48,
borderRadius: RADIUS.FULL,
backgroundColor: withOpacity(BRAND.PRIMARY, 0.08),
alignItems: 'center',
justifyContent: 'center',
marginRight: SPACING[3],
},
featureText: {
flex: 1,
},
// Benefits
benefitsSection: {
marginBottom: SPACING[6],
},
benefitsTitle: {
marginBottom: SPACING[3],
},
benefitsList: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: SPACING[2],
},
benefitTag: {
backgroundColor: colors.bg.surface,
paddingHorizontal: SPACING[4],
paddingVertical: SPACING[2],
borderRadius: RADIUS.FULL,
borderWidth: 1,
borderColor: colors.border.dim,
},
// Assessment Container
assessmentContainer: {
marginTop: SPACING[2],
},
exerciseList: {
marginBottom: SPACING[6],
},
exerciseItem: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.bg.surface,
padding: SPACING[4],
borderRadius: RADIUS.LG,
marginBottom: SPACING[2],
},
exerciseNumber: {
width: 36,
height: 36,
borderRadius: RADIUS.FULL,
backgroundColor: withOpacity(BRAND.PRIMARY, 0.08),
alignItems: 'center',
justifyContent: 'center',
marginRight: SPACING[3],
},
exerciseInfo: {
flex: 1,
},
// Tips
tipsSection: {
backgroundColor: colors.bg.surface,
borderRadius: RADIUS.LG,
padding: SPACING[5],
},
tipsTitle: {
marginBottom: SPACING[4],
},
tipItem: {
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: SPACING[3],
},
tipText: {
marginLeft: SPACING[2],
flex: 1,
lineHeight: 20,
},
// Bottom Bar
bottomBar: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
backgroundColor: colors.bg.base,
paddingHorizontal: LAYOUT.SCREEN_PADDING,
paddingTop: SPACING[3],
borderTopWidth: 1,
borderTopColor: colors.border.dim,
},
ctaButton: {
borderRadius: RADIUS.LG,
overflow: 'hidden',
marginBottom: SPACING[3],
},
ctaGradient: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: SPACING[4],
},
ctaIcon: {
marginLeft: SPACING[2],
},
skipButton: {
alignItems: 'center',
paddingVertical: SPACING[2],
},
})
}

View File

@@ -1,11 +0,0 @@
<claude-mem-context>
# Recent Activity
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
### Apr 13, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #6159 | 10:02 PM | 🔵 | Examined Collection Screen Explore Reference | ~150 |
</claude-mem-context>

View File

@@ -1,245 +0,0 @@
/**
* TabataFit Collection Detail Screen
* Shows collection info + list of workouts in that collection
*/
import { useMemo } from 'react'
import { View, StyleSheet, ScrollView, Pressable } from 'react-native'
import { useRouter, useLocalSearchParams } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { Icon } from '@/src/shared/components/Icon'
import { useTranslation } from 'react-i18next'
import { useHaptics } from '@/src/shared/hooks'
import { useCollection } from '@/src/shared/hooks/useSupabaseData'
import { getWorkoutById } from '@/src/shared/data'
import { useTranslatedWorkouts } from '@/src/shared/data/useTranslatedData'
import { StyledText } from '@/src/shared/components/StyledText'
import { track } from '@/src/shared/services/analytics'
import { useThemeColors, BRAND } from '@/src/shared/theme'
import type { ThemeColors } from '@/src/shared/theme/types'
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { TEXT, NAVY } from '@/src/shared/constants/colors'
export default function CollectionDetailScreen() {
const insets = useSafeAreaInsets()
const router = useRouter()
const haptics = useHaptics()
const { t } = useTranslation()
const { id } = useLocalSearchParams<{ id: string }>()
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
const { data: collection, isLoading } = useCollection(id)
// Resolve workouts from collection's workoutIds
const rawWorkouts = useMemo(() => {
if (!collection) return []
return collection.workoutIds
.map((wId) => getWorkoutById(wId))
.filter(Boolean) as NonNullable<ReturnType<typeof getWorkoutById>>[]
}, [collection])
const workouts = useTranslatedWorkouts(rawWorkouts)
const handleBack = () => {
haptics.selection()
router.back()
}
const handleWorkoutPress = (workoutId: string) => {
haptics.buttonTap()
track('collection_workout_tapped', {
collection_id: id,
workout_id: workoutId,
})
router.push(`/workout/${workoutId}`)
}
if (isLoading) {
return (
<View style={[styles.container, styles.centered, { paddingTop: insets.top }]}>
<StyledText size={17} color={colors.text.tertiary}>Loading...</StyledText>
</View>
)
}
if (!collection) {
return (
<View style={[styles.container, styles.centered, { paddingTop: insets.top }]}>
<Icon name="folder" size={48} color={colors.text.tertiary} />
<StyledText size={17} color={colors.text.tertiary} style={{ marginTop: SPACING[3] }}>
Collection not found
</StyledText>
</View>
)
}
return (
<View testID="collection-detail-screen" style={[styles.container, { paddingTop: insets.top }]}>
{/* Header */}
<View style={styles.header}>
<Pressable testID="collection-back-button" onPress={handleBack} style={styles.backButton}>
<Icon name="chevron.left" size={24} color={colors.text.primary} />
</Pressable>
<StyledText size={22} weight="bold" color={colors.text.primary} numberOfLines={1} style={{ flex: 1, textAlign: 'center' }}>
{collection.title}
</StyledText>
<View style={styles.backButton} />
</View>
<ScrollView
style={styles.scrollView}
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 100 }]}
showsVerticalScrollIndicator={false}
>
{/* Hero Card */}
<View testID="collection-hero" style={styles.heroCard}>
<View style={StyleSheet.absoluteFill} />
<View style={styles.heroContent}>
<StyledText size={48} color={TEXT.PRIMARY} style={styles.heroIcon}>
{collection.icon}
</StyledText>
<StyledText size={28} weight="bold" color={TEXT.PRIMARY}>
{collection.title}
</StyledText>
<StyledText size={15} color={TEXT.SECONDARY} style={{ marginTop: SPACING[1] }}>
{collection.description}
</StyledText>
<StyledText size={13} weight="semibold" color={TEXT.TERTIARY} style={{ marginTop: SPACING[2] }}>
{t('plurals.workout', { count: workouts.length })}
</StyledText>
</View>
</View>
{/* Workout List */}
<StyledText
size={20}
weight="bold"
color={colors.text.primary}
style={{ marginTop: SPACING[6], marginBottom: SPACING[3] }}
>
{t('screens:explore.workouts')}
</StyledText>
{workouts.map((workout) => (
<Pressable
key={workout.id}
testID={`collection-workout-${workout.id}`}
style={styles.workoutCard}
onPress={() => handleWorkoutPress(workout.id)}
>
<View style={[styles.workoutAvatar, { backgroundColor: BRAND.PRIMARY }]}>
<Icon name="flame.fill" size={20} color={TEXT.PRIMARY} />
</View>
<View style={styles.workoutInfo}>
<StyledText size={17} weight="semibold" color={colors.text.primary}>
{workout.title}
</StyledText>
<StyledText size={13} color={colors.text.tertiary}>
{t('durationLevel', {
duration: workout.duration,
level: t(`levels.${(workout.level ?? 'Beginner').toLowerCase()}`),
})}
</StyledText>
</View>
<View style={styles.workoutMeta}>
<StyledText size={13} color={BRAND.PRIMARY}>
{t('units.calUnit', { count: workout.calories })}
</StyledText>
<Icon name="chevron.right" size={16} color={colors.text.tertiary} />
</View>
</Pressable>
))}
{workouts.length === 0 && (
<View style={styles.emptyState}>
<Icon name="dumbbell" size={48} color={colors.text.tertiary} />
<StyledText size={17} color={colors.text.tertiary} style={{ marginTop: SPACING[3] }}>
No workouts in this collection
</StyledText>
</View>
)}
</ScrollView>
</View>
)
}
function createStyles(colors: ThemeColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bg.base,
},
centered: {
alignItems: 'center',
justifyContent: 'center',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: LAYOUT.SCREEN_PADDING,
paddingVertical: SPACING[3],
},
backButton: {
width: 44,
height: 44,
alignItems: 'center',
justifyContent: 'center',
},
heroCard: {
height: 200,
borderRadius: RADIUS.XL,
overflow: 'hidden',
backgroundColor: NAVY[700],
},
heroContent: {
flex: 1,
padding: SPACING[5],
justifyContent: 'flex-end',
},
heroIcon: {
marginBottom: SPACING[2],
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingHorizontal: LAYOUT.SCREEN_PADDING,
},
workoutCard: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: SPACING[3],
paddingHorizontal: SPACING[4],
backgroundColor: colors.bg.surface,
borderRadius: RADIUS.LG,
marginBottom: SPACING[2],
gap: SPACING[3],
},
workoutAvatar: {
width: 44,
height: 44,
borderRadius: RADIUS.FULL,
alignItems: 'center',
justifyContent: 'center',
},
workoutInfo: {
flex: 1,
gap: 2,
},
workoutMeta: {
alignItems: 'flex-end',
gap: 4,
},
emptyState: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: SPACING[12],
},
})
}

View File

@@ -1,20 +0,0 @@
<claude-mem-context>
# Recent Activity
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
### Feb 20, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #5045 | 8:22 AM | ✅ | Removed closing Host tag from workout detail screen | ~188 |
| #5044 | " | ✅ | Removed opening Host tag from workout detail screen | ~158 |
| #5032 | 8:19 AM | ✅ | Removed Host import from workout detail screen | ~194 |
| #5025 | 8:18 AM | 🔵 | Workout detail screen properly wraps content with Host component | ~244 |
### Apr 17, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #6366 | 10:21 AM | 🔵 | Verified workout program player integration in workout/[id].tsx | ~348 |
</claude-mem-context>

View File

@@ -1,777 +0,0 @@
/**
* TabataFit Pre-Workout Detail Screen
* Clean scrollable layout — native header, no hero
*/
import React, { useEffect, useRef, useState } from 'react'
import {
View,
Text as RNText,
StyleSheet,
ScrollView,
Pressable,
Animated,
} from 'react-native'
import { Stack } from 'expo-router'
import { useRouter, useLocalSearchParams } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { Icon } from '@/src/shared/components/Icon'
import { VideoPlayer } from '@/src/shared/components/VideoPlayer'
import { Image } from 'expo-image'
import { useTranslation } from 'react-i18next'
import { useHaptics } from '@/src/shared/hooks'
import { usePurchases } from '@/src/shared/hooks/usePurchases'
import { useUserStore } from '@/src/shared/stores'
import { track } from '@/src/shared/services/analytics'
import { canAccessWorkout, canAccessSession } from '@/src/shared/services/access'
import { getTabataSessionById, isTabataSession } from '@/src/shared/data/tabata'
import { isWorkoutProgramId, parseWorkoutProgramId, fetchProgramById, workoutProgramToTabataSession } from '@/src/shared/data/workoutPrograms'
import { BODY_ZONE_META } from '@/src/shared/types/workoutProgram'
import type { TabataSession } from '@/src/shared/types/program'
import { getWorkoutById, getTrainerById, getWorkoutAccentColor } from '@/src/shared/data'
import { useTranslatedWorkout, useMusicVibeLabel } from '@/src/shared/data/useTranslatedData'
import { useThemeColors } from '@/src/shared/theme'
import type { ThemeColors } from '@/src/shared/theme/types'
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { SPRING } from '@/src/shared/constants/animations'
import { TEXT, NAVY, BRAND, GREEN, AMBER, RED, DARK, BORDER_COLORS } from '@/src/shared/constants/colors'
import { NativeButton } from '@/src/shared/components/native'
// ─── Save Button (headerRight) ───────────────────────────────────────────────
function SaveButton({
isSaved,
onPress,
colors,
}: {
isSaved: boolean
onPress: () => void
colors: ThemeColors
}) {
return (
<Pressable
onPress={onPress}
hitSlop={8}
style={({ pressed }) => [
{ width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
pressed && { opacity: 0.6 },
]}
>
<Icon
name={isSaved ? 'heart.fill' : 'heart'}
size={22}
color={isSaved ? BRAND.DANGER : colors.text.primary}
/>
</Pressable>
)
}
// ─── Main Screen ─────────────────────────────────────────────────────────────
export default function WorkoutDetailScreen() {
const insets = useSafeAreaInsets()
const router = useRouter()
const haptics = useHaptics()
const { t } = useTranslation()
const { id } = useLocalSearchParams<{ id: string }>()
// ─── Dispatch: Workout Program → Tabata session → Legacy workout ─
if (isWorkoutProgramId(id ?? '')) {
return <WorkoutProgramDetailScreen compositeId={id ?? ''} />
}
if (isTabataSession(id ?? '')) {
return <TabataSessionDetailScreen sessionId={id ?? ''} />
}
return <LegacyWorkoutDetailScreen id={id ?? '1'} />
}
/**
* Workout Program Detail — loads a program tabata and delegates to TabataSessionDetailScreen
*/
function WorkoutProgramDetailScreen({ compositeId }: { compositeId: string }) {
const [session, setSession] = React.useState<TabataSession | null | undefined>(undefined)
const [accent, setAccent] = React.useState<string>(GREEN[500])
const [isFree, setIsFree] = React.useState<boolean>(false)
React.useEffect(() => {
let cancelled = false
async function load() {
const parsed = parseWorkoutProgramId(compositeId)
if (!parsed) { if (!cancelled) setSession(null); return }
const program = await fetchProgramById(parsed.programId)
if (cancelled) return
if (!program) { setSession(null); return }
const tabataSession = workoutProgramToTabataSession(program)
setSession(tabataSession)
setIsFree(program.isFree === true)
const zoneMeta = BODY_ZONE_META[program.bodyZone]
setAccent(program.accentColor ?? zoneMeta.color)
}
load()
return () => { cancelled = true }
}, [compositeId])
if (session === undefined) {
return (
<View style={{ flex: 1, backgroundColor: NAVY[900], justifyContent: 'center', alignItems: 'center' }}>
<Stack.Screen options={{ headerShown: true, headerTitle: '', headerStyle: { backgroundColor: NAVY[900] }, headerTintColor: TEXT.PRIMARY }} />
<RNText style={{ color: TEXT.SECONDARY }}>Chargement...</RNText>
</View>
)
}
if (session === null) {
return (
<View style={{ flex: 1, backgroundColor: NAVY[900], justifyContent: 'center', alignItems: 'center' }}>
<Stack.Screen options={{ headerShown: true, headerTitle: '', headerStyle: { backgroundColor: NAVY[900] }, headerTintColor: TEXT.PRIMARY }} />
<RNText style={{ color: TEXT.SECONDARY }}>Programme non trouvé</RNText>
</View>
)
}
return <TabataSessionDetailScreen sessionId={session.id} sessionOverride={session} accentOverride={accent} isFreeOverride={isFree} />
}
/**
* Tabata Session Detail — shows warmup, blocks, cooldown, tabata tips
*/
function TabataSessionDetailScreen({
sessionId,
sessionOverride,
accentOverride,
isFreeOverride,
}: {
sessionId: string
sessionOverride?: TabataSession
accentOverride?: string
isFreeOverride?: boolean
}) {
const router = useRouter()
const insets = useSafeAreaInsets()
const haptics = useHaptics()
const session = sessionOverride ?? getTabataSessionById(sessionId)
const { isPremium } = usePurchases()
const canAccess = isFreeOverride !== undefined
? (isPremium || isFreeOverride)
: canAccessSession(sessionId, isPremium)
if (!session) {
return (
<View style={{ flex: 1, backgroundColor: NAVY[900], justifyContent: 'center', alignItems: 'center' }}>
<Stack.Screen options={{ headerShown: true, headerTitle: '', headerStyle: { backgroundColor: NAVY[900] }, headerTintColor: TEXT.PRIMARY }} />
<RNText style={{ color: TEXT.SECONDARY }}>Séance non trouvée</RNText>
</View>
)
}
const programId = sessionId.startsWith('deb-') ? 'debutant' : sessionId.startsWith('int-') ? 'intermediaire' : sessionId.startsWith('avc-') ? 'avance' : 'bureau'
const accentMap: Record<string, string> = { debutant: GREEN[500], intermediaire: BRAND.INFO, avance: RED[500], bureau: AMBER[500] }
const accent = accentOverride ?? accentMap[programId] ?? GREEN[500]
const handleStart = () => {
haptics.buttonTap()
track('tabata_session_start_pressed', { session_id: sessionId })
router.push(`/player/${sessionId}`)
}
return (
<View style={styles.container}>
<Stack.Screen options={{ headerShown: true, headerTitle: session.title, headerStyle: { backgroundColor: NAVY[900] }, headerTintColor: TEXT.PRIMARY }} />
<ScrollView contentContainerStyle={{ paddingBottom: insets.bottom + 100 }}>
{/* Session info */}
<View style={[styles.heroSection, { backgroundColor: accent + '15' }]}>
<RNText style={styles.sessionTitle}>{session.title}</RNText>
<RNText style={styles.sessionDesc}>{session.description}</RNText>
<View style={styles.metaRow}>
<RNText style={styles.metaText}>{session.blocks.length} bloc{session.blocks.length > 1 ? 's' : ''}</RNText>
<RNText style={styles.metaText}>·</RNText>
<RNText style={styles.metaText}>{session.totalDuration} min</RNText>
<RNText style={styles.metaText}>·</RNText>
<RNText style={styles.metaText}>{session.calories} cal</RNText>
</View>
{/* Focus tags */}
<View style={styles.focusRow}>
{session.focus.map((f, i) => (
<View key={i} style={[styles.focusTag, { borderColor: accent }]}>
<RNText style={[styles.focusTagText, { color: accent }]}>{f}</RNText>
</View>
))}
</View>
</View>
{/* Warmup */}
{session.warmup.movements.length > 0 && (
<View style={styles.section}>
<RNText style={styles.sectionTitle}>Échauffement · {Math.floor(session.warmup.totalDuration / 60)} min</RNText>
{session.warmup.movements.map((m, i) => (
<View key={i} style={styles.movementRow}>
<RNText style={styles.movementDot}></RNText>
<RNText style={styles.movementName}>{m.name}</RNText>
<RNText style={styles.movementDuration}>{m.duration}s</RNText>
</View>
))}
</View>
)}
{/* Blocks */}
{session.blocks.map((block, bi) => (
<View key={block.id} style={styles.section}>
<RNText style={styles.sectionTitle}>Bloc {bi + 1} · {block.rounds} rounds · {block.workTime}/{block.restTime}s</RNText>
<View style={styles.exercisePair}>
<View style={[styles.exerciseCard, { borderLeftColor: accent }]}>
<RNText style={styles.exerciseLabel}>Rounds impairs</RNText>
<RNText style={styles.exerciseName}>{block.oddExercise.name}</RNText>
{block.oddExercise.conseil ? <RNText style={styles.exerciseTip}>📋 {block.oddExercise.conseil}</RNText> : null}
</View>
<View style={[styles.exerciseCard, { borderLeftColor: BRAND.INFO }]}>
<RNText style={styles.exerciseLabel}>Rounds pairs</RNText>
<RNText style={styles.exerciseName}>{block.evenExercise.name}</RNText>
{block.evenExercise.conseil ? <RNText style={styles.exerciseTip}>📋 {block.evenExercise.conseil}</RNText> : null}
</View>
</View>
</View>
))}
{/* Cooldown */}
{session.cooldown.movements.length > 0 && (
<View style={styles.section}>
<RNText style={styles.sectionTitle}>Retour au calme · {Math.floor(session.cooldown.totalDuration / 60)} min</RNText>
{session.cooldown.movements.map((m, i) => (
<View key={i} style={styles.movementRow}>
<RNText style={styles.movementDot}></RNText>
<RNText style={styles.movementName}>{m.name}</RNText>
<RNText style={styles.movementDuration}>{m.duration}s</RNText>
</View>
))}
</View>
)}
{/* Equipment */}
{session.equipment.length > 0 && (
<View style={styles.section}>
<RNText style={styles.sectionTitle}>Matériel</RNText>
{session.equipment.map((eq, i) => (
<RNText key={i} style={styles.equipText}> {eq}</RNText>
))}
</View>
)}
</ScrollView>
{/* CTA */}
<View style={[styles.ctaContainer, { paddingBottom: insets.bottom + SPACING[4] }]}>
{canAccess ? (
<Pressable style={[styles.ctaButton, { backgroundColor: accent }]} onPress={handleStart}>
<RNText style={styles.ctaText}>Commencer la séance</RNText>
</Pressable>
) : (
<Pressable style={[styles.ctaButton, { backgroundColor: GREEN[500] }]} onPress={() => router.push('/paywall')}>
<RNText style={styles.ctaText}>Débloquer avec Premium</RNText>
</Pressable>
)}
</View>
</View>
)
}
/**
* Legacy workout detail — original format
*/
function LegacyWorkoutDetailScreen({ id }: { id: string }) {
const insets = useSafeAreaInsets()
const router = useRouter()
const haptics = useHaptics()
const { t } = useTranslation()
const savedWorkouts = useUserStore((s) => s.savedWorkouts)
const toggleSavedWorkout = useUserStore((s) => s.toggleSavedWorkout)
const { isPremium } = usePurchases()
const colors = useThemeColors()
const isDark = colors.colorScheme === 'dark'
const rawWorkout = getWorkoutById(id ?? '1')
const workout = useTranslatedWorkout(rawWorkout)
const musicVibeLabel = useMusicVibeLabel(rawWorkout?.musicVibe ?? '')
const trainer = rawWorkout ? getTrainerById(rawWorkout.trainerId) : undefined
const accentColor = GREEN[500]
// CTA entrance
const ctaAnim = useRef(new Animated.Value(0)).current
useEffect(() => {
Animated.sequence([
Animated.delay(300),
Animated.spring(ctaAnim, {
toValue: 1,
...SPRING.GENTLE,
useNativeDriver: true,
}),
]).start()
}, [])
useEffect(() => {
if (workout) {
track('workout_detail_viewed', {
workout_id: workout.id,
workout_title: workout.title,
level: workout.level,
duration: workout.duration,
})
}
}, [workout?.id])
const isSaved = savedWorkouts.includes(workout?.id?.toString() ?? '')
const toggleSave = () => {
if (!workout) return
haptics.selection()
toggleSavedWorkout(workout.id.toString())
}
if (!workout) {
return (
<>
<Stack.Screen options={{ headerTitle: '' }} />
<View style={[s.container, s.centered, { backgroundColor: colors.bg.base }]}>
<RNText style={{ color: colors.text.primary, fontSize: 17 }}>
{t('screens:workout.notFound')}
</RNText>
</View>
</>
)
}
const isLocked = !canAccessWorkout(workout.id, isPremium)
const exerciseCount = workout.exercises?.length || 1
const repeatCount = Math.max(1, Math.floor((workout.rounds || exerciseCount) / exerciseCount))
const handleStartWorkout = () => {
if (isLocked) {
haptics.buttonTap()
track('paywall_triggered', { source: 'workout_detail', workout_id: workout.id })
router.push('/paywall')
return
}
haptics.phaseChange()
router.push(`/player/${workout.id}`)
}
const ctaBg = isDark ? TEXT.PRIMARY : NAVY[900]
const ctaText = isDark ? NAVY[900] : TEXT.PRIMARY
const ctaLockedBg = isDark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.08)'
const ctaLockedText = colors.text.primary
const equipmentText = workout.equipment.length > 0
? workout.equipment.join(' · ')
: t('screens:workout.noEquipment', { defaultValue: 'No equipment needed' })
return (
<>
<Stack.Screen
options={{
headerRight: () => (
<SaveButton isSaved={isSaved} onPress={toggleSave} colors={colors} />
),
}}
/>
<View testID="workout-detail-screen" style={[s.container, { backgroundColor: colors.bg.base }]}>
<ScrollView
contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={[
s.scrollContent,
{ paddingBottom: insets.bottom + 100 },
]}
showsVerticalScrollIndicator={false}
>
{/* Thumbnail / Video Preview */}
{rawWorkout?.thumbnailUrl ? (
<View style={s.mediaContainer}>
<Image
source={rawWorkout.thumbnailUrl}
style={s.thumbnail}
contentFit="cover"
transition={200}
/>
</View>
) : rawWorkout?.videoUrl ? (
<View style={s.mediaContainer}>
<VideoPlayer
videoUrl={rawWorkout.videoUrl}
gradientColors={[NAVY[800], NAVY[700]]}
mode="preview"
isPlaying={false}
style={s.thumbnail}
/>
</View>
) : null}
{/* Title */}
<RNText selectable style={[s.title, { color: colors.text.primary }]}>
{workout.title}
</RNText>
{/* Trainer */}
{trainer && (
<RNText style={[s.trainerName, { color: accentColor }]}>
with {trainer.name}
</RNText>
)}
{/* Inline metadata */}
<View style={s.metaRow}>
<View style={s.metaItem}>
<Icon name="clock" size={15} tintColor={colors.text.tertiary} />
<RNText style={[s.metaText, { color: colors.text.secondary }]}>
{t('units.minUnit', { count: workout.duration })}
</RNText>
</View>
<RNText style={[s.metaDot, { color: colors.text.hint }]}>·</RNText>
<View style={s.metaItem}>
<Icon name="flame" size={15} tintColor={colors.text.tertiary} />
<RNText style={[s.metaText, { color: colors.text.secondary }]}>
{t('units.calUnit', { count: workout.calories })}
</RNText>
</View>
<RNText style={[s.metaDot, { color: colors.text.hint }]}>·</RNText>
<RNText style={[s.metaText, { color: colors.text.secondary }]}>
{t(`levels.${(workout.level ?? 'Beginner').toLowerCase()}`)}
</RNText>
</View>
{/* Equipment */}
<RNText style={[s.equipmentText, { color: colors.text.tertiary }]}>
{equipmentText}
</RNText>
{/* Separator */}
<View style={[s.separator, { backgroundColor: colors.border.dim }]} />
{/* Timing Card */}
<View style={[s.card, { backgroundColor: colors.bg.surface }]}>
<View style={s.timingRow}>
<View style={s.timingItem}>
<RNText style={[s.timingValue, { color: accentColor }]}>
{workout.prepTime}s
</RNText>
<RNText style={[s.timingLabel, { color: colors.text.tertiary }]}>
{t('screens:workout.prep', { defaultValue: 'Prep' })}
</RNText>
</View>
<View style={[s.timingDivider, { backgroundColor: colors.border.dim }]} />
<View style={s.timingItem}>
<RNText style={[s.timingValue, { color: accentColor }]}>
{workout.workTime}s
</RNText>
<RNText style={[s.timingLabel, { color: colors.text.tertiary }]}>
{t('screens:workout.work', { defaultValue: 'Work' })}
</RNText>
</View>
<View style={[s.timingDivider, { backgroundColor: colors.border.dim }]} />
<View style={s.timingItem}>
<RNText style={[s.timingValue, { color: accentColor }]}>
{workout.restTime}s
</RNText>
<RNText style={[s.timingLabel, { color: colors.text.tertiary }]}>
{t('screens:workout.rest', { defaultValue: 'Rest' })}
</RNText>
</View>
<View style={[s.timingDivider, { backgroundColor: colors.border.dim }]} />
<View style={s.timingItem}>
<RNText style={[s.timingValue, { color: accentColor }]}>
{workout.rounds}
</RNText>
<RNText style={[s.timingLabel, { color: colors.text.tertiary }]}>
{t('screens:workout.rounds', { defaultValue: 'Rounds' })}
</RNText>
</View>
</View>
</View>
{/* Exercises Card */}
<RNText style={[s.sectionTitle, { color: colors.text.primary }]}>
{t('screens:workout.exercises', { count: workout.rounds })}
</RNText>
<View style={[s.card, { backgroundColor: colors.bg.surface }]}>
{workout.exercises.map((exercise, index) => (
<View key={index}>
<View style={s.exerciseRow}>
<RNText style={[s.exerciseIndex, { color: accentColor }]}>
{index + 1}
</RNText>
<RNText selectable style={[s.exerciseName, { color: colors.text.primary }]}>
{exercise.name}
</RNText>
<RNText style={[s.exerciseDuration, { color: colors.text.tertiary }]}>
{exercise.duration}s
</RNText>
</View>
{index < workout.exercises.length - 1 && (
<View style={[s.exerciseSep, { backgroundColor: colors.border.dim }]} />
)}
</View>
))}
</View>
{repeatCount > 1 && (
<View style={s.repeatRow}>
<Icon name="repeat" size={13} color={colors.text.hint} />
<RNText style={[s.repeatText, { color: colors.text.hint }]}>
{t('screens:workout.repeatRounds', { count: repeatCount })}
</RNText>
</View>
)}
{/* Music */}
<View style={s.musicRow}>
<Icon name="music.note" size={14} tintColor={colors.text.hint} />
<RNText style={[s.musicText, { color: colors.text.tertiary }]}>
{t('screens:workout.musicMix', { vibe: musicVibeLabel })}
</RNText>
</View>
</ScrollView>
{/* CTA */}
<Animated.View
style={[
s.bottomBar,
{
backgroundColor: colors.bg.base,
paddingBottom: insets.bottom + SPACING[3],
opacity: ctaAnim,
transform: [
{
translateY: ctaAnim.interpolate({
inputRange: [0, 1],
outputRange: [30, 0],
}),
},
],
},
]}
>
<NativeButton
variant={isLocked ? 'secondary' : 'primary'}
title={isLocked ? t('screens:workout.unlockWithPremium') : t('screens:workout.startWorkout')}
systemImage={isLocked ? 'lock.fill' : 'play.fill'}
onPress={handleStartWorkout}
fullWidth
controlSize="large"
testID={isLocked ? 'workout-unlock-button' : 'workout-start-button'}
/>
</Animated.View>
</View>
</>
)
}
// ─── Styles ──────────────────────────────────────────────────────────────────
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: NAVY[900] },
heroSection: { padding: SPACING[5], alignItems: 'center' },
sessionTitle: { ...TYPOGRAPHY.LARGE_TITLE, color: TEXT.PRIMARY, textAlign: 'center' },
sessionDesc: { ...TYPOGRAPHY.BODY, color: TEXT.SECONDARY, textAlign: 'center', marginTop: SPACING[2], lineHeight: 22 },
metaRow: { flexDirection: 'row', marginTop: SPACING[4], gap: SPACING[2], justifyContent: 'center' },
metaText: { ...TYPOGRAPHY.SUBHEADLINE, color: TEXT.SECONDARY },
focusRow: { flexDirection: 'row', flexWrap: 'wrap', gap: SPACING[2], marginTop: SPACING[3], justifyContent: 'center' },
focusTag: { paddingHorizontal: 10, paddingVertical: 3, borderRadius: 12, borderWidth: 1 },
focusTagText: { fontSize: 12, fontWeight: '600' },
section: { paddingHorizontal: SPACING[5], marginTop: SPACING[6] },
sectionTitle: { ...TYPOGRAPHY.HEADLINE, color: TEXT.PRIMARY, marginBottom: SPACING[3] },
movementRow: { flexDirection: 'row', alignItems: 'center', gap: SPACING[2], marginBottom: SPACING[2] },
movementDot: { fontSize: 8, color: TEXT.TERTIARY },
movementName: { ...TYPOGRAPHY.BODY, color: TEXT.PRIMARY, flex: 1 },
movementDuration: { ...TYPOGRAPHY.CAPTION_1, color: TEXT.TERTIARY },
exercisePair: { gap: SPACING[3] },
exerciseCard: { backgroundColor: NAVY[800], borderRadius: RADIUS.MD, padding: SPACING[3], borderLeftWidth: 3 },
exerciseLabel: { ...TYPOGRAPHY.CAPTION_2, color: TEXT.TERTIARY, textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 4 },
exerciseName: { ...TYPOGRAPHY.TITLE_3, color: TEXT.PRIMARY },
exerciseTip: { ...TYPOGRAPHY.CAPTION_1, color: TEXT.SECONDARY, marginTop: SPACING[1], lineHeight: 18 },
equipText: { ...TYPOGRAPHY.SUBHEADLINE, color: TEXT.SECONDARY, marginBottom: SPACING[1] },
ctaContainer: { position: 'absolute', bottom: 0, left: 0, right: 0, paddingHorizontal: SPACING[5], paddingTop: SPACING[3], backgroundColor: DARK.SCRIM, borderTopWidth: 1, borderTopColor: BORDER_COLORS.DIM },
ctaButton: { height: 52, borderRadius: RADIUS.MD, borderCurve: 'continuous', alignItems: 'center', justifyContent: 'center' },
ctaText: { ...TYPOGRAPHY.BUTTON_MEDIUM, color: NAVY[900], letterSpacing: 0.5 },
})
const s = StyleSheet.create({
container: {
flex: 1,
},
centered: {
alignItems: 'center',
justifyContent: 'center',
},
scrollContent: {
paddingHorizontal: LAYOUT.SCREEN_PADDING,
paddingTop: SPACING[2],
},
// Media
mediaContainer: {
height: 200,
borderRadius: RADIUS.LG,
borderCurve: 'continuous',
overflow: 'hidden',
marginBottom: SPACING[4],
},
thumbnail: {
width: '100%',
height: '100%',
},
// Title
title: {
...TYPOGRAPHY.TITLE_1,
marginBottom: SPACING[2],
},
// Trainer
trainerName: {
...TYPOGRAPHY.SUBHEADLINE,
marginBottom: SPACING[3],
},
// Metadata
metaRow: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
gap: SPACING[2],
marginBottom: SPACING[2],
},
metaItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
metaText: {
...TYPOGRAPHY.SUBHEADLINE,
},
metaDot: {
...TYPOGRAPHY.SUBHEADLINE,
},
// Equipment
equipmentText: {
...TYPOGRAPHY.FOOTNOTE,
marginBottom: SPACING[4],
},
// Separator
separator: {
height: StyleSheet.hairlineWidth,
marginBottom: SPACING[4],
},
// Card
card: {
borderRadius: RADIUS.LG,
borderCurve: 'continuous',
overflow: 'hidden',
marginBottom: SPACING[4],
},
// Timing
timingRow: {
flexDirection: 'row',
paddingVertical: SPACING[4],
},
timingItem: {
flex: 1,
alignItems: 'center',
gap: 2,
},
timingDivider: {
width: StyleSheet.hairlineWidth,
alignSelf: 'stretch',
},
timingValue: {
...TYPOGRAPHY.HEADLINE,
fontVariant: ['tabular-nums'],
},
timingLabel: {
...TYPOGRAPHY.CAPTION_2,
textTransform: 'uppercase' as const,
letterSpacing: 0.5,
},
// Section
sectionTitle: {
...TYPOGRAPHY.HEADLINE,
marginBottom: SPACING[3],
},
// Exercise
exerciseRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: SPACING[3],
paddingHorizontal: SPACING[4],
},
exerciseIndex: {
...TYPOGRAPHY.FOOTNOTE,
fontVariant: ['tabular-nums'],
width: 24,
},
exerciseName: {
...TYPOGRAPHY.BODY,
flex: 1,
},
exerciseDuration: {
...TYPOGRAPHY.SUBHEADLINE,
fontVariant: ['tabular-nums'],
marginLeft: SPACING[3],
},
exerciseSep: {
height: StyleSheet.hairlineWidth,
marginLeft: SPACING[4] + 24,
marginRight: SPACING[4],
},
repeatRow: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING[2],
marginTop: SPACING[2],
paddingLeft: 24,
},
repeatText: {
...TYPOGRAPHY.FOOTNOTE,
},
// Music
musicRow: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING[2],
marginTop: SPACING[5],
},
musicText: {
...TYPOGRAPHY.FOOTNOTE,
},
// Bottom bar
bottomBar: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
paddingHorizontal: LAYOUT.SCREEN_PADDING,
paddingTop: SPACING[3],
},
ctaButton: {
height: 54,
borderRadius: RADIUS.MD,
borderCurve: 'continuous',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
},
ctaText: {
...TYPOGRAPHY.BUTTON_LARGE,
},
})

View File

@@ -1,16 +0,0 @@
<claude-mem-context>
# Recent Activity
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
### Apr 17, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #6377 | 10:28 AM | 🔴 | Fixed duplicate ScrollView opening tag in body zone detail screen | ~223 |
| #6374 | 10:26 AM | 🔄 | Removed header section from body zone detail screen | ~260 |
| #6363 | 10:20 AM | 🔄 | Changed program navigation to exclude explicit tabata position | ~319 |
| #6353 | 10:02 AM | 🔄 | Simplified difficulty pill styling in body-zone detail screen | ~281 |
| #6352 | 10:01 AM | 🔄 | Removed program count badges from difficulty filter pills | ~319 |
| #6351 | " | 🔵 | Discovered body zone detail page with difficulty level filtering | ~364 |
</claude-mem-context>

View File

@@ -1,296 +0,0 @@
/**
* Body Zone Detail Screen
* Shows workout programs filtered by body zone with difficulty pills
*/
import { useState, useMemo, useEffect, useCallback } from 'react'
import { View, StyleSheet, ScrollView, Pressable } from 'react-native'
import { useRouter, useLocalSearchParams } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { Icon } from '@/src/shared/components/Icon'
import { useTranslation } from 'react-i18next'
import { useHaptics } from '@/src/shared/hooks'
import { usePurchases } from '@/src/shared/hooks/usePurchases'
import { StyledText } from '@/src/shared/components/StyledText'
import { useThemeColors } from '@/src/shared/theme'
import type { ThemeColors } from '@/src/shared/theme/types'
import { BRAND, GREEN, TEXT, NAVY, BORDER_COLORS } from '@/src/shared/constants/colors'
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { canAccessWorkoutProgram } from '@/src/shared/services/access'
import { useWorkoutProgramStore } from '@/src/shared/stores/workoutProgramStore'
import { fetchProgramsByBodyZone, buildWorkoutProgramId } from '@/src/shared/data/workoutPrograms'
import type { WorkoutProgram, BodyZone, ProgramLevel } from '@/src/shared/types/workoutProgram'
import { BODY_ZONE_META, LEVEL_META } from '@/src/shared/types/workoutProgram'
const LEVELS: ProgramLevel[] = ['Beginner', 'Intermediate', 'Advanced']
export default function BodyZoneDetailScreen() {
const insets = useSafeAreaInsets()
const router = useRouter()
const haptics = useHaptics()
const { t } = useTranslation('screens')
const { id } = useLocalSearchParams<{ id: string }>()
const colors = useThemeColors()
const { isPremium } = usePurchases()
const isProgramCompleted = useWorkoutProgramStore(s => s.isProgramCompleted)
const bodyZone = (id ?? 'full-body') as BodyZone
const meta = BODY_ZONE_META[bodyZone]
const [programs, setPrograms] = useState<WorkoutProgram[]>([])
const [selectedLevel, setSelectedLevel] = useState<ProgramLevel>('Beginner')
useEffect(() => {
fetchProgramsByBodyZone(bodyZone).then((data) => {
setPrograms(data)
// Default to first level that has programs
const firstAvailable = LEVELS.find(l => data.some(p => p.level === l))
if (firstAvailable) setSelectedLevel(firstAvailable)
})
}, [bodyZone])
const filteredPrograms = useMemo(
() => programs.filter(p => p.level === selectedLevel),
[programs, selectedLevel],
)
const handleProgramPress = (program: WorkoutProgram) => {
haptics.buttonTap()
const isLocked = !canAccessWorkoutProgram(program, isPremium)
if (isLocked) {
router.push('/paywall')
return
}
router.push(`/workout/${buildWorkoutProgramId(program.id)}` as any)
}
const handleLevelPress = (level: ProgramLevel) => {
haptics.buttonTap()
setSelectedLevel(level)
}
const accentColor = meta.color
const styles = useMemo(() => createStyles(colors, accentColor), [colors, accentColor])
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
<ScrollView
style={styles.scrollView}
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 20 }]}
showsVerticalScrollIndicator={false}
>
{/* Difficulty Pills */}
<View style={styles.pillsRow}>
{LEVELS.map((level) => {
const levelMeta = LEVEL_META[level]
const isActive = selectedLevel === level
return (
<Pressable
key={level}
onPress={() => handleLevelPress(level)}
style={[
styles.pill,
{
backgroundColor: isActive ? accentColor + '20' : NAVY[800],
borderColor: isActive ? accentColor : BORDER_COLORS.DIM,
},
]}
>
<StyledText
size={14}
weight={isActive ? 'semibold' : 'regular'}
color={isActive ? accentColor : colors.text.secondary}
>
{levelMeta.label}
</StyledText>
</Pressable>
)
})}
</View>
{/* Program Count */}
<StyledText size={13} color={colors.text.tertiary} style={styles.resultCount}>
{filteredPrograms.length} programme{filteredPrograms.length !== 1 ? 's' : ''} {LEVEL_META[selectedLevel].label.toLowerCase()}
</StyledText>
{/* Program List */}
{filteredPrograms.map((program) => (
<ProgramCard
key={program.id}
program={program}
accentColor={accentColor}
onPress={() => handleProgramPress(program)}
isPremium={isPremium}
isCompleted={isProgramCompleted(program.id)}
/>
))}
{filteredPrograms.length === 0 && (
<View style={styles.emptyState}>
<Icon name="dumbbell" size={32} tintColor={colors.text.tertiary} />
<StyledText preset="CALLOUT" color={colors.text.tertiary} style={{ marginTop: SPACING[3], textAlign: 'center' }}>
Aucun programme disponible pour ce niveau
</StyledText>
</View>
)}
</ScrollView>
</View>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// PROGRAM CARD (full-width)
// ═══════════════════════════════════════════════════════════════════════════
function ProgramCard({
program,
accentColor,
onPress,
isPremium,
isCompleted,
}: {
program: WorkoutProgram
accentColor: string
onPress: () => void
isPremium: boolean
isCompleted: boolean
}) {
const { t } = useTranslation('screens')
const colors = useThemeColors()
const isLocked = !canAccessWorkoutProgram(program, isPremium)
const levelMeta = LEVEL_META[program.level]
const color = program.accentColor ?? accentColor
return (
<Pressable
onPress={onPress}
style={({ pressed }) => [
{
borderRadius: RADIUS.XL,
overflow: 'hidden',
borderWidth: 1,
borderCurve: 'continuous' as const,
borderColor: colors.border.dim,
backgroundColor: colors.surface.default.backgroundColor,
marginBottom: SPACING[3],
opacity: pressed ? 0.85 : 1,
},
]}
>
{/* Accent line */}
<View style={{ height: 3, width: '100%', backgroundColor: color }} />
<View style={{ padding: SPACING[5] }}>
{/* Title row */}
<View style={{ flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between' }}>
<StyledText preset="TITLE_3" color={colors.text.primary} style={{ flex: 1, marginRight: SPACING[3] }}>
{program.title}
</StyledText>
{isCompleted ? (
<View style={{ flexDirection: 'row', alignItems: 'center', paddingHorizontal: SPACING[3], paddingVertical: SPACING[1], borderRadius: RADIUS.PILL, backgroundColor: GREEN['500'] + '20' }}>
<Icon name="checkmark" size={12} tintColor={GREEN['500']} />
<StyledText size={11} weight="semibold" color={GREEN['500']} style={{ marginLeft: 4 }}>
Complété
</StyledText>
</View>
) : isLocked ? (
<View style={{ flexDirection: 'row', alignItems: 'center', paddingHorizontal: SPACING[3], paddingVertical: SPACING[1], borderRadius: RADIUS.PILL, backgroundColor: color + '15' }}>
<Icon name="lock" size={12} tintColor={color} />
<StyledText size={11} weight="semibold" color={color} style={{ marginLeft: 4 }}>
{t('home.premiumBadge')}
</StyledText>
</View>
) : (
<View style={{ flexDirection: 'row', alignItems: 'center', paddingHorizontal: SPACING[3], paddingVertical: SPACING[1], borderRadius: RADIUS.PILL, backgroundColor: GREEN.DIM }}>
<StyledText size={11} weight="semibold" color={GREEN['500']}>
{t('home.freeBadge')}
</StyledText>
</View>
)}
</View>
{/* Description */}
{program.description ? (
<StyledText size={13} color={colors.text.tertiary} style={{ marginTop: SPACING[2] }} numberOfLines={2}>
{program.description}
</StyledText>
) : null}
{/* Meta row */}
<View style={{ flexDirection: 'row', alignItems: 'center', gap: SPACING[4], marginTop: SPACING[4] }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: SPACING[1] }}>
<Icon name="timer" size={14} tintColor={colors.text.tertiary} />
<StyledText size={12} color={colors.text.tertiary}>{program.estimatedDuration} min</StyledText>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: SPACING[1] }}>
<Icon name="flame" size={14} tintColor={colors.text.tertiary} />
<StyledText size={12} color={colors.text.tertiary}>{program.estimatedCalories} kcal</StyledText>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: SPACING[1] }}>
<Icon name="list.bullet" size={14} tintColor={colors.text.tertiary} />
<StyledText size={12} color={colors.text.tertiary}>{program.tabatas.length} tabatas</StyledText>
</View>
</View>
{/* CTA */}
<View style={{ marginTop: SPACING[4], alignSelf: 'flex-start', flexDirection: 'row', alignItems: 'center', paddingHorizontal: SPACING[4], paddingVertical: SPACING[2], borderRadius: RADIUS.PILL, backgroundColor: isLocked ? color + '15' : GREEN.DIM }}>
<Icon name={isLocked ? 'lock' : 'play.fill'} size={12} tintColor={isLocked ? color : GREEN['500']} />
<StyledText size={13} weight="semibold" color={isLocked ? color : GREEN['500']} style={{ marginLeft: SPACING[2] }}>
{isLocked ? t('home.unlockPremium') : t('home.startProgram')}
</StyledText>
</View>
</View>
</Pressable>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// STYLES
// ═══════════════════════════════════════════════════════════════════════════
const createStyles = (colors: ThemeColors, accentColor: string) =>
StyleSheet.create({
container: {
flex: 1,
backgroundColor: NAVY[900],
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingHorizontal: LAYOUT.SCREEN_PADDING,
},
// Difficulty pills
pillsRow: {
flexDirection: 'row',
gap: SPACING[2],
marginBottom: SPACING[5],
},
pill: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: SPACING[3],
borderRadius: RADIUS.PILL,
borderWidth: 1,
borderCurve: 'continuous',
},
// Results
resultCount: {
marginBottom: SPACING[4],
},
// Empty state
emptyState: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: SPACING[10],
},
})

View File

@@ -1,11 +0,0 @@
<claude-mem-context>
# Recent Activity
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
### Apr 11, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #6114 | 7:39 PM | 🔵 | Category detail screen imports reviewed | ~298 |
</claude-mem-context>

View File

@@ -1,246 +0,0 @@
/**
* TabataFit Category Detail Screen
* Filtered workout list for a specific category with level sub-filter
*/
import { useState, useMemo } from 'react'
import { View, StyleSheet, ScrollView, Pressable, Text as RNText } from 'react-native'
import { useRouter, useLocalSearchParams } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { Icon } from '@/src/shared/components/Icon'
import { useTranslation } from 'react-i18next'
import { useHaptics } from '@/src/shared/hooks'
import { getWorkoutsByCategory, CATEGORIES } from '@/src/shared/data'
import type { ProgramWorkout } from '@/src/shared/types/program'
import { useTranslatedCategories, useTranslatedWorkouts } from '@/src/shared/data/useTranslatedData'
import { StyledText } from '@/src/shared/components/StyledText'
import type { WorkoutCategory, WorkoutLevel } from '@/src/shared/types'
import { useThemeColors, BRAND } from '@/src/shared/theme'
import type { ThemeColors } from '@/src/shared/theme/types'
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { TEXT, GREEN } from '@/src/shared/constants/colors'
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
const LEVEL_IDS: (WorkoutLevel | 'all')[] = ['all', 'Beginner', 'Intermediate', 'Advanced']
export default function CategoryDetailScreen() {
const insets = useSafeAreaInsets()
const router = useRouter()
const haptics = useHaptics()
const { t } = useTranslation()
const { id } = useLocalSearchParams<{ id: string }>()
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
const [selectedLevelIndex, setSelectedLevelIndex] = useState(0)
const translatedCategories = useTranslatedCategories()
const levelLabels = [
t('screens:category.allLevels'),
t('levels.beginner'),
t('levels.intermediate'),
t('levels.advanced'),
]
const selectedLevel = LEVEL_IDS[selectedLevelIndex]
const category = translatedCategories.find(c => c.id === id)
const categoryLabel = category?.label ?? id ?? 'Category'
const allWorkouts = useMemo(
() => (id && id !== 'all') ? getWorkoutsByCategory(id as WorkoutCategory) : [],
[id]
)
const filteredWorkouts = useMemo(() => {
if (selectedLevel === 'all') return allWorkouts
return allWorkouts.filter((w: ProgramWorkout) => w.focus?.[0] === selectedLevel)
}, [allWorkouts, selectedLevel])
const translatedWorkouts = useTranslatedWorkouts(filteredWorkouts)
const handleBack = () => {
haptics.selection()
router.back()
}
const handleWorkoutPress = (workoutId: string) => {
haptics.buttonTap()
router.push(`/workout/${workoutId}`)
}
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
{/* Header */}
<View style={styles.header}>
<Pressable onPress={handleBack} style={styles.backButton}>
<Icon name="chevron.left" size={24} color={colors.text.primary} />
</Pressable>
<StyledText size={22} weight="bold" color={colors.text.primary}>{categoryLabel}</StyledText>
<View style={styles.backButton} />
</View>
{/* Level Filter — segmented pills */}
<View style={styles.filterContainer}>
<View style={styles.segmentedRow}>
{levelLabels.map((label, idx) => (
<Pressable
key={idx}
style={[styles.segment, idx === selectedLevelIndex && styles.segmentActive]}
onPress={() => {
haptics.selection()
setSelectedLevelIndex(idx)
}}
>
<RNText style={[styles.segmentText, idx === selectedLevelIndex && styles.segmentTextActive]}>
{label}
</RNText>
</Pressable>
))}
</View>
</View>
<StyledText
size={13}
color={colors.text.tertiary}
style={{ paddingHorizontal: LAYOUT.SCREEN_PADDING, marginBottom: SPACING[3] }}
>
{t('plurals.workout', { count: translatedWorkouts.length })}
</StyledText>
<ScrollView
style={styles.scrollView}
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 100 }]}
showsVerticalScrollIndicator={false}
>
{translatedWorkouts.map((workout: ProgramWorkout & { title: string }) => (
<Pressable
key={workout.id}
style={styles.workoutCard}
onPress={() => handleWorkoutPress(workout.id)}
>
<View style={[styles.workoutAvatar, { backgroundColor: BRAND.PRIMARY }]}>
<Icon name="flame.fill" size={20} color={TEXT.PRIMARY} />
</View>
<View style={styles.workoutInfo}>
<StyledText size={17} weight="semibold" color={colors.text.primary}>{workout.title}</StyledText>
<StyledText size={13} color={colors.text.tertiary}>
{workout.duration}min {workout.focus?.[0] ?? 'Full Body'}
</StyledText>
</View>
<View style={styles.workoutMeta}>
<Icon name="chevron.right" size={16} color={colors.text.tertiary} />
</View>
</Pressable>
))}
{translatedWorkouts.length === 0 && (
<View style={styles.emptyState}>
<Icon name="dumbbell" size={48} color={colors.text.tertiary} />
<StyledText size={17} color={colors.text.tertiary} style={{ marginTop: SPACING[3] }}>
No workouts found
</StyledText>
</View>
)}
</ScrollView>
</View>
)
}
function createStyles(colors: ThemeColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bg.base,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: LAYOUT.SCREEN_PADDING,
paddingVertical: SPACING[3],
},
backButton: {
width: 44,
height: 44,
alignItems: 'center',
justifyContent: 'center',
},
filterContainer: {
paddingHorizontal: LAYOUT.SCREEN_PADDING,
marginBottom: SPACING[4],
},
segmentedRow: {
flexDirection: 'row',
gap: SPACING[2],
},
segment: {
flex: 1,
paddingVertical: SPACING[2],
paddingHorizontal: SPACING[3],
borderRadius: RADIUS.MD,
backgroundColor: colors.bg.surface,
borderWidth: 1,
borderColor: colors.border.dim,
alignItems: 'center',
},
segmentActive: {
backgroundColor: GREEN.DIM,
borderColor: GREEN.BORDER,
},
segmentText: {
fontSize: 13,
fontWeight: '500',
color: TEXT.TERTIARY,
},
segmentTextActive: {
color: BRAND.PRIMARY,
fontWeight: '600',
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingHorizontal: LAYOUT.SCREEN_PADDING,
},
workoutCard: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: SPACING[3],
paddingHorizontal: SPACING[4],
backgroundColor: colors.bg.surface,
borderRadius: RADIUS.LG,
marginBottom: SPACING[2],
gap: SPACING[3],
},
workoutAvatar: {
width: 44,
height: 44,
borderRadius: RADIUS.FULL,
alignItems: 'center',
justifyContent: 'center',
},
workoutInitial: {
...TYPOGRAPHY.HEADING_2,
color: colors.text.primary,
},
workoutInfo: {
flex: 1,
gap: 2,
},
workoutMeta: {
alignItems: 'flex-end',
gap: 4,
},
emptyState: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: SPACING[12],
},
})
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

View File

@@ -1,65 +0,0 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
import { adminService } from '../services/adminService'
import { logger } from '@/src/shared/utils/logger'
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) {
logger.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

@@ -1,282 +0,0 @@
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 as any)
.select('id')
.single()
if (error) {
throw new Error(`Failed to create workout: ${error.message}`)
}
return (data as any).id
}
async updateWorkout(id: string, workout: WorkoutUpdate): Promise<void> {
this.checkConfiguration()
const { error } = await (supabase
.from('workouts') as any)
.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 as any)
.select('id')
.single()
if (error) {
throw new Error(`Failed to create trainer: ${error.message}`)
}
return (data as any).id
}
async updateTrainer(id: string, trainer: TrainerUpdate): Promise<void> {
this.checkConfiguration()
const { error } = await (supabase
.from('trainers') as any)
.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 as any)
.select('id')
.single()
if (collectionError) {
throw new Error(`Failed to create collection: ${collectionError.message}`)
}
const collectionWorkouts: CollectionWorkoutInsert[] = workoutIds.map((workoutId, index) => ({
collection_id: (collectionData as any).id,
workout_id: workoutId,
sort_order: index,
}))
const { error: linkError } = await supabase
.from('collection_workouts')
.insert(collectionWorkouts as any)
if (linkError) {
throw new Error(`Failed to link workouts to collection: ${linkError.message}`)
}
return (collectionData as any).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 as any)
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()