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:
@@ -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>
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
@@ -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],
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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],
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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],
|
||||
},
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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],
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user