fix: align program detail screen with Dark Medical design system
Replace hardcoded #000 backgrounds with NAVY[900], use design system tokens for badges (TYPOGRAPHY.LABEL, RADIUS.SM), progress bar (RADIUS.PILL, 4px height), and CTA container (DARK.SCRIM). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,592 +1,260 @@
|
||||
/**
|
||||
* TabataFit Program Detail Screen
|
||||
* Clean scrollable layout — native header, Apple Fitness+ style
|
||||
* Tabata Kine Program Detail Screen
|
||||
* Displays program overview, weeks, sessions, and progression for kiné programs
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text as RNText,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
Pressable,
|
||||
Animated,
|
||||
} from 'react-native'
|
||||
import React from 'react'
|
||||
import { View, Text, StyleSheet, ScrollView, Pressable } from 'react-native'
|
||||
import { Stack, 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 { useProgramStore } from '@/src/shared/stores'
|
||||
import { PROGRAMS } from '@/src/shared/data/programs'
|
||||
import { track } from '@/src/shared/services/analytics'
|
||||
|
||||
import { useThemeColors, BRAND } from '@/src/shared/theme'
|
||||
import { useKineProgramStore } from '@/src/shared/stores/kineProgramStore'
|
||||
import { getKineProgramById, getKineSessionsByWeek } from '@/src/shared/data/kine'
|
||||
import { canAccessProgram } from '@/src/shared/services/access'
|
||||
import { useUserStore } from '@/src/shared/stores/userStore'
|
||||
import type { KineProgramId } from '@/src/shared/types/program'
|
||||
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
|
||||
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { SPRING } from '@/src/shared/constants/animations'
|
||||
import type { ProgramId } from '@/src/shared/types'
|
||||
import type { IconName } from '@/src/shared/components/Icon'
|
||||
import { TEXT, NAVY, GREEN, BORDER_COLORS, AMBER, DARK } from '@/src/shared/constants/colors'
|
||||
import { withOpacity } from '@/src/shared/utils/color'
|
||||
|
||||
// Per-program accent colors (matches home screen cards)
|
||||
const PROGRAM_ACCENT: Record<ProgramId, { color: string; icon: IconName }> = {
|
||||
'upper-body': { color: '#FF6B35', icon: 'dumbbell' },
|
||||
'lower-body': { color: '#30D158', icon: 'figure.walk' },
|
||||
'full-body': { color: '#5AC8FA', icon: 'flame' },
|
||||
}
|
||||
|
||||
export default function ProgramDetailScreen() {
|
||||
export default function KineProgramDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>()
|
||||
const programId = id as ProgramId
|
||||
const { t } = useTranslation('screens')
|
||||
const insets = useSafeAreaInsets()
|
||||
const router = useRouter()
|
||||
const haptics = useHaptics()
|
||||
const colors = useThemeColors()
|
||||
const isDark = colors.colorScheme === 'dark'
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
const program = PROGRAMS[programId]
|
||||
const accent = PROGRAM_ACCENT[programId] ?? PROGRAM_ACCENT['full-body']
|
||||
const selectProgram = useProgramStore((s) => s.selectProgram)
|
||||
const progress = useProgramStore((s) => s.programsProgress[programId])
|
||||
const isWeekUnlocked = useProgramStore((s) => s.isWeekUnlocked)
|
||||
const getCurrentWorkout = useProgramStore((s) => s.getCurrentWorkout)
|
||||
const completion = useProgramStore((s) => s.getProgramCompletion(programId))
|
||||
const programId = id as KineProgramId
|
||||
const program = getKineProgramById(programId)
|
||||
|
||||
// CTA entrance animation
|
||||
const ctaAnim = useRef(new Animated.Value(0)).current
|
||||
useEffect(() => {
|
||||
Animated.sequence([
|
||||
Animated.delay(300),
|
||||
Animated.spring(ctaAnim, {
|
||||
toValue: 1,
|
||||
...SPRING.GENTLE,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start()
|
||||
}, [])
|
||||
const selectProgram = useKineProgramStore(s => s.selectProgram)
|
||||
const progress = useKineProgramStore(s => s.programsProgress[programId])
|
||||
const isWeekUnlocked = useKineProgramStore(s => s.isWeekUnlocked)
|
||||
const getCurrentSession = useKineProgramStore(s => s.getCurrentSession)
|
||||
const completion = useKineProgramStore(s => s.getProgramCompletion(programId))
|
||||
const getProgramStatus = useKineProgramStore(s => s.getProgramStatus)
|
||||
|
||||
useEffect(() => {
|
||||
if (program) {
|
||||
track('program_detail_viewed', {
|
||||
program_id: programId,
|
||||
program_title: program.title,
|
||||
})
|
||||
}
|
||||
}, [programId])
|
||||
const isPremium = useUserStore(s => s.profile.subscription) !== 'free'
|
||||
const canAccess = canAccessProgram(programId, isPremium)
|
||||
const status = getProgramStatus(programId)
|
||||
|
||||
if (!program) {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ headerTitle: '' }} />
|
||||
<View style={[s.container, s.centered, { backgroundColor: colors.bg.base }]}>
|
||||
<RNText style={[TYPOGRAPHY.BODY, { color: colors.text.primary }]}>
|
||||
{t('programs.notFound', { defaultValue: 'Program not found' })}
|
||||
</RNText>
|
||||
</View>
|
||||
</>
|
||||
<View style={styles.container}>
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<Text style={styles.errorText}>Programme non trouvé</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const handleStartProgram = () => {
|
||||
haptics.phaseChange()
|
||||
selectProgram(programId)
|
||||
const currentWorkout = getCurrentWorkout(programId)
|
||||
if (currentWorkout) {
|
||||
router.push(`/workout/${currentWorkout.id}`)
|
||||
const session = getCurrentSession(programId)
|
||||
if (session) {
|
||||
router.push(`/workout/${session.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleWorkoutPress = (workoutId: string) => {
|
||||
haptics.buttonTap()
|
||||
router.push(`/workout/${workoutId}`)
|
||||
const handleSessionPress = (sessionId: string) => {
|
||||
router.push(`/workout/${sessionId}`)
|
||||
}
|
||||
|
||||
const hasStarted = progress.completedWorkoutIds.length > 0
|
||||
const ctaBg = isDark ? '#FFFFFF' : '#000000'
|
||||
const ctaTextColor = isDark ? '#000000' : '#FFFFFF'
|
||||
|
||||
const ctaLabel = hasStarted
|
||||
? progress.isProgramCompleted
|
||||
? t('programs.restartProgram')
|
||||
: t('programs.continueTraining')
|
||||
: t('programs.startProgram')
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ headerTitle: '' }} />
|
||||
<View style={styles.container}>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerTitle: program.title,
|
||||
headerStyle: { backgroundColor: NAVY[900] },
|
||||
headerTintColor: TEXT.PRIMARY,
|
||||
headerBackTitle: 'Retour',
|
||||
}}
|
||||
/>
|
||||
|
||||
<View style={[s.container, { backgroundColor: colors.bg.base }]}>
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
contentContainerStyle={[
|
||||
s.scrollContent,
|
||||
{ paddingBottom: insets.bottom + 100 },
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Icon + Title */}
|
||||
<View style={s.titleRow}>
|
||||
<View style={[s.programIcon, { backgroundColor: accent.color + '18' }]}>
|
||||
<Icon name={accent.icon} size={22} tintColor={accent.color} />
|
||||
<ScrollView style={styles.scrollView} contentContainerStyle={{ paddingBottom: insets.bottom + 100 }}>
|
||||
{/* Program header */}
|
||||
<View style={[styles.heroSection, { backgroundColor: withOpacity(program.accentColor, 0.12) }]}>
|
||||
<View style={[styles.iconCircle, { backgroundColor: withOpacity(program.accentColor, 0.19) }]}>
|
||||
<Icon name={program.icon as any} size={32} tintColor={program.accentColor} />
|
||||
</View>
|
||||
<Text style={styles.programTitle}>{program.title}</Text>
|
||||
<Text style={styles.programDescription}>{program.description}</Text>
|
||||
|
||||
{/* Tier badge */}
|
||||
<View style={[styles.tierBadge, { borderColor: program.accentColor }]}>
|
||||
<Text style={[styles.tierBadgeText, { color: program.accentColor }]}>
|
||||
{program.tier === 'free' ? 'GRATUIT' : 'PREMIUM'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Stats row */}
|
||||
<View style={styles.statsRow}>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={styles.statValue}>{program.durationWeeks}</Text>
|
||||
<Text style={styles.statLabel}>Semaines</Text>
|
||||
</View>
|
||||
<View style={s.titleContent}>
|
||||
<RNText selectable style={[s.title, { color: colors.text.primary }]}>
|
||||
{program.title}
|
||||
</RNText>
|
||||
<RNText style={[s.subtitle, { color: colors.text.tertiary }]}>
|
||||
{program.durationWeeks} {t('programs.weeks')} · {program.totalWorkouts} {t('programs.workouts')}
|
||||
</RNText>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={styles.statValue}>{program.sessionsPerWeek}</Text>
|
||||
<Text style={styles.statLabel}>Séances/sem</Text>
|
||||
</View>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={styles.statValue}>{program.totalSessions}</Text>
|
||||
<Text style={styles.statLabel}>Séances</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Description */}
|
||||
<RNText style={[s.description, { color: colors.text.secondary }]}>
|
||||
{program.description}
|
||||
</RNText>
|
||||
|
||||
{/* Stats Card */}
|
||||
<View style={[s.card, { backgroundColor: colors.bg.surface }]}>
|
||||
<View style={s.statsRow}>
|
||||
<View style={s.statItem}>
|
||||
<RNText style={[s.statValue, { color: accent.color }]}>
|
||||
{program.durationWeeks}
|
||||
</RNText>
|
||||
<RNText style={[s.statLabel, { color: colors.text.tertiary }]}>
|
||||
{t('programs.weeks')}
|
||||
</RNText>
|
||||
{/* Progress */}
|
||||
{status === 'in-progress' && (
|
||||
<View style={styles.progressSection}>
|
||||
<View style={styles.progressBar}>
|
||||
<View style={[styles.progressFill, { width: `${completion}%`, backgroundColor: program.accentColor }]} />
|
||||
</View>
|
||||
<View style={[s.statDivider, { backgroundColor: colors.border.glassLight }]} />
|
||||
<View style={s.statItem}>
|
||||
<RNText style={[s.statValue, { color: accent.color }]}>
|
||||
{program.totalWorkouts}
|
||||
</RNText>
|
||||
<RNText style={[s.statLabel, { color: colors.text.tertiary }]}>
|
||||
{t('programs.workouts')}
|
||||
</RNText>
|
||||
</View>
|
||||
<View style={[s.statDivider, { backgroundColor: colors.border.glassLight }]} />
|
||||
<View style={s.statItem}>
|
||||
<RNText style={[s.statValue, { color: accent.color }]}>
|
||||
4
|
||||
</RNText>
|
||||
<RNText style={[s.statLabel, { color: colors.text.tertiary }]}>
|
||||
{t('programs.minutes')}
|
||||
</RNText>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Equipment & Focus */}
|
||||
<View style={s.tagsSection}>
|
||||
{program.equipment.required.length > 0 && (
|
||||
<>
|
||||
<RNText style={[s.tagSectionLabel, { color: colors.text.secondary }]}>
|
||||
{t('programs.equipment')}
|
||||
</RNText>
|
||||
<View style={s.tagRow}>
|
||||
{program.equipment.required.map((item) => (
|
||||
<View key={item} style={[s.tag, { backgroundColor: colors.bg.surface }]}>
|
||||
<RNText style={[s.tagText, { color: colors.text.primary }]}>
|
||||
{item}
|
||||
</RNText>
|
||||
</View>
|
||||
))}
|
||||
{program.equipment.optional.map((item) => (
|
||||
<View key={item} style={[s.tag, { backgroundColor: colors.bg.surface, opacity: 0.7 }]}>
|
||||
<RNText style={[s.tagText, { color: colors.text.tertiary }]}>
|
||||
{item} {t('programs.optional')}
|
||||
</RNText>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
<RNText style={[s.tagSectionLabel, { color: colors.text.secondary, marginTop: SPACING[4] }]}>
|
||||
{t('programs.focusAreas')}
|
||||
</RNText>
|
||||
<View style={s.tagRow}>
|
||||
{program.focusAreas.map((area) => (
|
||||
<View key={area} style={[s.tag, { backgroundColor: accent.color + '15' }]}>
|
||||
<RNText style={[s.tagText, { color: accent.color }]}>
|
||||
{area}
|
||||
</RNText>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Separator */}
|
||||
<View style={[s.separator, { backgroundColor: colors.border.glassLight }]} />
|
||||
|
||||
{/* Progress (if started) */}
|
||||
{hasStarted && (
|
||||
<View style={[s.card, { backgroundColor: colors.bg.surface, marginBottom: SPACING[5] }]}>
|
||||
<View style={s.progressHeader}>
|
||||
<RNText style={[TYPOGRAPHY.HEADLINE, { color: colors.text.primary }]}>
|
||||
{t('programs.yourProgress')}
|
||||
</RNText>
|
||||
<RNText style={[TYPOGRAPHY.HEADLINE, { color: accent.color, fontVariant: ['tabular-nums'] }]}>
|
||||
{completion}%
|
||||
</RNText>
|
||||
</View>
|
||||
<View style={[s.progressTrack, { backgroundColor: colors.border.glassLight }]}>
|
||||
<View
|
||||
style={[
|
||||
s.progressFill,
|
||||
{
|
||||
width: `${completion}%`,
|
||||
backgroundColor: accent.color,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
<RNText style={[TYPOGRAPHY.FOOTNOTE, { color: colors.text.tertiary, marginTop: SPACING[2] }]}>
|
||||
{progress.completedWorkoutIds.length} {t('programs.of')} {program.totalWorkouts} {t('programs.workoutsComplete')}
|
||||
</RNText>
|
||||
<Text style={styles.progressText}>{completion}% complété</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Training Plan */}
|
||||
<RNText style={[s.sectionTitle, { color: colors.text.primary }]}>
|
||||
{t('programs.trainingPlan')}
|
||||
</RNText>
|
||||
|
||||
{program.weeks.map((week) => {
|
||||
const isUnlocked = isWeekUnlocked(programId, week.weekNumber)
|
||||
const isCurrentWeek = progress.currentWeek === week.weekNumber
|
||||
const weekCompletion = week.workouts.filter((w) =>
|
||||
progress.completedWorkoutIds.includes(w.id)
|
||||
).length
|
||||
|
||||
return (
|
||||
<View
|
||||
key={week.weekNumber}
|
||||
style={[s.card, { backgroundColor: colors.bg.surface, marginBottom: SPACING[3] }]}
|
||||
>
|
||||
{/* Week Header */}
|
||||
<View style={s.weekHeader}>
|
||||
<View style={s.weekTitleRow}>
|
||||
<RNText style={[TYPOGRAPHY.HEADLINE, { color: colors.text.primary, flex: 1 }]}>
|
||||
{week.title}
|
||||
</RNText>
|
||||
{!isUnlocked && (
|
||||
<Icon name="lock.fill" size={16} color={colors.text.hint} />
|
||||
)}
|
||||
{isCurrentWeek && isUnlocked && (
|
||||
<View style={[s.currentBadge, { backgroundColor: accent.color }]}>
|
||||
<RNText style={[s.currentBadgeText, { color: '#FFFFFF' }]}>
|
||||
{t('programs.current')}
|
||||
</RNText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<RNText style={[TYPOGRAPHY.FOOTNOTE, { color: colors.text.secondary, marginTop: 2 }]}>
|
||||
{week.description}
|
||||
</RNText>
|
||||
{weekCompletion > 0 && (
|
||||
<RNText style={[TYPOGRAPHY.CAPTION_1, { color: colors.text.hint, marginTop: SPACING[2] }]}>
|
||||
{weekCompletion}/{week.workouts.length} {t('programs.complete')}
|
||||
</RNText>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Week Workouts */}
|
||||
{isUnlocked &&
|
||||
week.workouts.map((workout, index) => {
|
||||
const isCompleted = progress.completedWorkoutIds.includes(workout.id)
|
||||
const isWorkoutLocked =
|
||||
!isCompleted &&
|
||||
index > 0 &&
|
||||
!progress.completedWorkoutIds.includes(week.workouts[index - 1].id) &&
|
||||
week.weekNumber === progress.currentWeek
|
||||
|
||||
return (
|
||||
<View key={workout.id}>
|
||||
<View style={[s.workoutSep, { backgroundColor: colors.border.glassLight }]} />
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
s.workoutRow,
|
||||
isWorkoutLocked && { opacity: 0.4 },
|
||||
pressed && !isWorkoutLocked && { opacity: 0.6 },
|
||||
]}
|
||||
onPress={() => !isWorkoutLocked && handleWorkoutPress(workout.id)}
|
||||
disabled={isWorkoutLocked}
|
||||
>
|
||||
<View style={s.workoutIcon}>
|
||||
{isCompleted ? (
|
||||
<Icon name="checkmark.circle.fill" size={22} color={BRAND.SUCCESS} />
|
||||
) : isWorkoutLocked ? (
|
||||
<Icon name="lock.fill" size={18} color={colors.text.hint} />
|
||||
) : (
|
||||
<RNText style={[s.workoutIndex, { color: colors.text.tertiary }]}>
|
||||
{index + 1}
|
||||
</RNText>
|
||||
)}
|
||||
</View>
|
||||
<View style={s.workoutInfo}>
|
||||
<RNText
|
||||
style={[
|
||||
TYPOGRAPHY.BODY,
|
||||
{ color: isWorkoutLocked ? colors.text.hint : colors.text.primary },
|
||||
isCompleted && { textDecorationLine: 'line-through' },
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{workout.title}
|
||||
</RNText>
|
||||
<RNText style={[TYPOGRAPHY.CAPTION_1, { color: colors.text.tertiary }]}>
|
||||
{workout.exercises.length} {t('programs.exercises')} · {workout.duration} {t('programs.min')}
|
||||
</RNText>
|
||||
</View>
|
||||
{!isWorkoutLocked && !isCompleted && (
|
||||
<Icon name="chevron.right" size={16} color={colors.text.hint} />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
{/* Principles */}
|
||||
{program.principles.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Principes</Text>
|
||||
{program.principles.map((p, i) => (
|
||||
<View key={i} style={styles.principleItem}>
|
||||
<Text style={styles.principleBullet}>•</Text>
|
||||
<Text style={styles.principleText}>{p}</Text>
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</ScrollView>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 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],
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
s.ctaButton,
|
||||
{ backgroundColor: ctaBg },
|
||||
pressed && { opacity: 0.85, transform: [{ scale: 0.98 }] },
|
||||
]}
|
||||
onPress={handleStartProgram}
|
||||
>
|
||||
<RNText style={[s.ctaText, { color: ctaTextColor }]}>
|
||||
{ctaLabel}
|
||||
</RNText>
|
||||
{/* Weeks */}
|
||||
{program.weeks.map(week => {
|
||||
const unlocked = isWeekUnlocked(programId, week.weekNumber)
|
||||
return (
|
||||
<View key={week.weekNumber} style={styles.weekSection}>
|
||||
<View style={styles.weekHeader}>
|
||||
<Text style={styles.weekTitle}>Semaine {week.weekNumber}: {week.title}</Text>
|
||||
{week.isDeload && (
|
||||
<View style={styles.deloadBadge}>
|
||||
<Text style={styles.deloadText}>Décharge</Text>
|
||||
</View>
|
||||
)}
|
||||
{!unlocked && (
|
||||
<Icon name="lock" size={16} tintColor={TEXT.TERTIARY} />
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.weekFocus}>{week.focus}</Text>
|
||||
|
||||
{/* Sessions */}
|
||||
{week.sessions.map(session => {
|
||||
const isCompleted = progress?.completedSessionIds.includes(session.id) ?? false
|
||||
const sessionLocked = !unlocked
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={session.id}
|
||||
style={[styles.sessionCard, sessionLocked && styles.sessionCardLocked]}
|
||||
onPress={() => !sessionLocked && canAccess && handleSessionPress(session.id)}
|
||||
disabled={sessionLocked || !canAccess}
|
||||
>
|
||||
<View style={styles.sessionInfo}>
|
||||
<View style={[styles.sessionDot, {
|
||||
backgroundColor: isCompleted ? GREEN[500] : sessionLocked ? BORDER_COLORS.DIM : program.accentColor,
|
||||
}]} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={[styles.sessionTitle, sessionLocked && { opacity: 0.4 }]}>
|
||||
Séance {session.order}: {session.title}
|
||||
</Text>
|
||||
<Text style={styles.sessionMeta}>
|
||||
{session.blocks.length} bloc{session.blocks.length > 1 ? 's' : ''} · {session.totalDuration} min · {session.calories} cal
|
||||
</Text>
|
||||
</View>
|
||||
{isCompleted && <Icon name="checkmark.circle" size={20} tintColor={GREEN[500]} />}
|
||||
{!canAccess && !sessionLocked && <Icon name="lock" size={16} tintColor={TEXT.TERTIARY} />}
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Completion criteria */}
|
||||
{program.completionCriteria.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Critères de passage</Text>
|
||||
{program.completionCriteria.map((c, i) => (
|
||||
<View key={i} style={styles.principleItem}>
|
||||
<Text style={styles.principleBullet}>✓</Text>
|
||||
<Text style={styles.principleText}>{c}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* CTA */}
|
||||
<View style={[styles.ctaContainer, { paddingBottom: insets.bottom + SPACING[4] }]}>
|
||||
{canAccess ? (
|
||||
<Pressable style={[styles.ctaButton, { backgroundColor: program.accentColor }]} onPress={handleStartProgram}>
|
||||
<Text style={styles.ctaText}>
|
||||
{status === 'in-progress' ? 'Continuer le programme' : status === 'completed' ? 'Recommencer' : 'Commencer le programme'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
) : (
|
||||
<Pressable style={[styles.ctaButton, { backgroundColor: GREEN[500] }]} onPress={() => router.push('/paywall')}>
|
||||
<Text style={styles.ctaText}>Débloquer avec Premium</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Styles ──────────────────────────────────────────────────────────────────
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: NAVY[900] },
|
||||
scrollView: { flex: 1 },
|
||||
|
||||
const s = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
centered: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
paddingTop: SPACING[2],
|
||||
},
|
||||
heroSection: { padding: SPACING[6], alignItems: 'center' },
|
||||
iconCircle: { width: 64, height: 64, borderRadius: 32, alignItems: 'center', justifyContent: 'center', marginBottom: SPACING[3] },
|
||||
programTitle: { ...TYPOGRAPHY.LARGE_TITLE, color: TEXT.PRIMARY, textAlign: 'center' },
|
||||
programDescription: { ...TYPOGRAPHY.BODY, color: TEXT.SECONDARY, textAlign: 'center', marginTop: SPACING[2], lineHeight: 22 },
|
||||
tierBadge: { marginTop: SPACING[3], paddingHorizontal: SPACING[2], paddingVertical: 3, borderRadius: RADIUS.SM, borderWidth: 1 },
|
||||
tierBadgeText: { ...TYPOGRAPHY.LABEL },
|
||||
|
||||
// Title
|
||||
titleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: SPACING[3],
|
||||
marginBottom: SPACING[3],
|
||||
},
|
||||
programIcon: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: RADIUS.MD,
|
||||
borderCurve: 'continuous',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
titleContent: {
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
...TYPOGRAPHY.TITLE_1,
|
||||
},
|
||||
subtitle: {
|
||||
...TYPOGRAPHY.SUBHEADLINE,
|
||||
marginTop: 2,
|
||||
},
|
||||
statsRow: { flexDirection: 'row', marginTop: SPACING[6], gap: SPACING[8] },
|
||||
statItem: { alignItems: 'center' },
|
||||
statValue: { ...TYPOGRAPHY.TITLE_2, color: TEXT.PRIMARY },
|
||||
statLabel: { ...TYPOGRAPHY.CAPTION_1, color: TEXT.TERTIARY, marginTop: 2 },
|
||||
|
||||
// Description
|
||||
description: {
|
||||
...TYPOGRAPHY.BODY,
|
||||
lineHeight: 24,
|
||||
marginBottom: SPACING[5],
|
||||
},
|
||||
progressSection: { marginTop: SPACING[4], width: '100%' },
|
||||
progressBar: { height: 4, borderRadius: RADIUS.PILL, backgroundColor: BORDER_COLORS.DIM, overflow: 'hidden' },
|
||||
progressFill: { height: '100%', borderRadius: RADIUS.PILL },
|
||||
progressText: { ...TYPOGRAPHY.CAPTION_1, color: TEXT.TERTIARY, marginTop: SPACING[1], textAlign: 'center' },
|
||||
|
||||
// Card
|
||||
card: {
|
||||
borderRadius: RADIUS.LG,
|
||||
borderCurve: 'continuous',
|
||||
overflow: 'hidden',
|
||||
padding: SPACING[4],
|
||||
},
|
||||
section: { paddingHorizontal: SPACING[5], marginTop: SPACING[6] },
|
||||
sectionTitle: { ...TYPOGRAPHY.HEADLINE, color: TEXT.PRIMARY, marginBottom: SPACING[3] },
|
||||
principleItem: { flexDirection: 'row', gap: SPACING[2], marginBottom: SPACING[2] },
|
||||
principleBullet: { color: TEXT.TERTIARY, fontSize: 14 },
|
||||
principleText: { ...TYPOGRAPHY.SUBHEADLINE, color: TEXT.SECONDARY, flex: 1, lineHeight: 20 },
|
||||
|
||||
// Stats
|
||||
statsRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
statItem: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
},
|
||||
statValue: {
|
||||
...TYPOGRAPHY.TITLE_1,
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
statLabel: {
|
||||
...TYPOGRAPHY.CAPTION_2,
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
statDivider: {
|
||||
width: StyleSheet.hairlineWidth,
|
||||
height: 32,
|
||||
},
|
||||
weekSection: { paddingHorizontal: SPACING[5], marginTop: SPACING[6] },
|
||||
weekHeader: { flexDirection: 'row', alignItems: 'center', gap: SPACING[2] },
|
||||
weekTitle: { ...TYPOGRAPHY.TITLE_3, color: TEXT.PRIMARY, flex: 1 },
|
||||
weekFocus: { ...TYPOGRAPHY.CAPTION_1, color: TEXT.TERTIARY, marginTop: SPACING[1], marginBottom: SPACING[3] },
|
||||
deloadBadge: { backgroundColor: withOpacity(AMBER[500], 0.2), paddingHorizontal: SPACING[2], paddingVertical: 2, borderRadius: RADIUS.SM },
|
||||
deloadText: { ...TYPOGRAPHY.LABEL, color: AMBER[500] },
|
||||
|
||||
// Tags
|
||||
tagsSection: {
|
||||
marginTop: SPACING[5],
|
||||
marginBottom: SPACING[5],
|
||||
},
|
||||
tagSectionLabel: {
|
||||
...TYPOGRAPHY.FOOTNOTE,
|
||||
fontWeight: '600',
|
||||
marginBottom: SPACING[2],
|
||||
},
|
||||
tagRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: SPACING[2],
|
||||
},
|
||||
tag: {
|
||||
paddingHorizontal: SPACING[3],
|
||||
paddingVertical: SPACING[1],
|
||||
borderRadius: RADIUS.FULL,
|
||||
},
|
||||
tagText: {
|
||||
...TYPOGRAPHY.CAPTION_1,
|
||||
},
|
||||
sessionCard: { backgroundColor: NAVY[800], borderRadius: RADIUS.MD, padding: SPACING[3], marginBottom: SPACING[2], borderWidth: 1, borderColor: BORDER_COLORS.DIM },
|
||||
sessionCardLocked: { opacity: 0.5 },
|
||||
sessionInfo: { flexDirection: 'row', alignItems: 'center', gap: SPACING[3] },
|
||||
sessionDot: { width: 8, height: 8, borderRadius: 4 },
|
||||
sessionTitle: { ...TYPOGRAPHY.SUBHEADLINE, color: TEXT.PRIMARY },
|
||||
sessionMeta: { ...TYPOGRAPHY.CAPTION_2, color: TEXT.TERTIARY, marginTop: 2 },
|
||||
|
||||
// Separator
|
||||
separator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
marginBottom: SPACING[5],
|
||||
},
|
||||
|
||||
// Progress
|
||||
progressHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: SPACING[3],
|
||||
},
|
||||
progressTrack: {
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
borderRadius: 3,
|
||||
},
|
||||
|
||||
// Section title
|
||||
sectionTitle: {
|
||||
...TYPOGRAPHY.TITLE_2,
|
||||
marginBottom: SPACING[4],
|
||||
},
|
||||
|
||||
// Week header
|
||||
weekHeader: {
|
||||
marginBottom: SPACING[1],
|
||||
},
|
||||
weekTitleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: SPACING[2],
|
||||
},
|
||||
currentBadge: {
|
||||
paddingHorizontal: SPACING[2],
|
||||
paddingVertical: 2,
|
||||
borderRadius: RADIUS.SM,
|
||||
},
|
||||
currentBadgeText: {
|
||||
...TYPOGRAPHY.CAPTION_2,
|
||||
fontWeight: '600',
|
||||
},
|
||||
|
||||
// Workout row
|
||||
workoutSep: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
marginLeft: SPACING[4] + 28,
|
||||
},
|
||||
workoutRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: SPACING[3],
|
||||
gap: SPACING[3],
|
||||
},
|
||||
workoutIcon: {
|
||||
width: 28,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
workoutIndex: {
|
||||
...TYPOGRAPHY.SUBHEADLINE,
|
||||
fontVariant: ['tabular-nums'],
|
||||
fontWeight: '600',
|
||||
},
|
||||
workoutInfo: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
|
||||
// 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,
|
||||
},
|
||||
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, alignItems: 'center', justifyContent: 'center' },
|
||||
ctaText: { ...TYPOGRAPHY.BUTTON_MEDIUM, color: NAVY[900], letterSpacing: 0.5 },
|
||||
errorText: { color: TEXT.SECONDARY, textAlign: 'center', marginTop: 100 },
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user