refactor: code quality cleanup — remove any types, add logger, rename Kine to Tabata

- Phase 0: Rename all Kine references to Tabata (types, files, imports, i18n, analytics events)
- Phase 1: Add test coverage for tabataProgramStore, workoutProgramStore, and color utils (47 tests)
- Phase 2: Remove all `any` types from production code with proper typed replacements
- Phase 3: Replace ~60 raw console.* calls with __DEV__-gated logger utility
- Phase 4: Verify .DS_Store housekeeping (already clean)

0 TypeScript errors, 583/583 tests passing.
This commit is contained in:
Millian Lamiaux
2026-04-17 18:56:24 +02:00
parent e0e02c4550
commit 791f432334
176 changed files with 16508 additions and 2305 deletions

View File

@@ -11,4 +11,10 @@
| #5044 | " | ✅ | Removed opening Host tag from workout detail screen | ~158 |
| #5032 | 8:19 AM | ✅ | Removed Host import from workout detail screen | ~194 |
| #5025 | 8:18 AM | 🔵 | Workout detail screen properly wraps content with Host component | ~244 |
### Apr 17, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #6366 | 10:21 AM | 🔵 | Verified workout program player integration in workout/[id].tsx | ~348 |
</claude-mem-context>

View File

@@ -3,7 +3,7 @@
* Clean scrollable layout — native header, no hero
*/
import React, { useEffect, useRef } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import {
View,
Text as RNText,
@@ -24,7 +24,11 @@ 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 } from '@/src/shared/services/access'
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'
@@ -34,6 +38,8 @@ 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) ───────────────────────────────────────────────
@@ -50,12 +56,15 @@ function SaveButton({
<Pressable
onPress={onPress}
hitSlop={8}
style={({ pressed }) => pressed && { opacity: 0.6 }}
style={({ pressed }) => [
{ width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
pressed && { opacity: 0.6 },
]}
>
<Icon
name={isSaved ? 'heart.fill' : 'heart'}
size={22}
color={isSaved ? '#FF3B30' : colors.text.primary}
color={isSaved ? BRAND.DANGER : colors.text.primary}
/>
</Pressable>
)
@@ -69,6 +78,215 @@ export default function WorkoutDetailScreen() {
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()
@@ -80,7 +298,7 @@ export default function WorkoutDetailScreen() {
const workout = useTranslatedWorkout(rawWorkout)
const musicVibeLabel = useMusicVibeLabel(rawWorkout?.musicVibe ?? '')
const trainer = rawWorkout ? getTrainerById(rawWorkout.trainerId) : undefined
const accentColor = getWorkoutAccentColor(id ?? '1')
const accentColor = GREEN[500]
// CTA entrance
const ctaAnim = useRef(new Animated.Value(0)).current
@@ -141,8 +359,8 @@ export default function WorkoutDetailScreen() {
router.push(`/player/${workout.id}`)
}
const ctaBg = isDark ? '#FFFFFF' : '#000000'
const ctaText = isDark ? '#000000' : '#FFFFFF'
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
@@ -183,7 +401,7 @@ export default function WorkoutDetailScreen() {
<View style={s.mediaContainer}>
<VideoPlayer
videoUrl={rawWorkout.videoUrl}
gradientColors={['#1C1C1E', '#2C2C2E']}
gradientColors={[NAVY[800], NAVY[700]]}
mode="preview"
isPlaying={false}
style={s.thumbnail}
@@ -208,14 +426,14 @@ export default function WorkoutDetailScreen() {
<View style={s.metaItem}>
<Icon name="clock" size={15} tintColor={colors.text.tertiary} />
<RNText style={[s.metaText, { color: colors.text.secondary }]}>
{workout.duration} {t('units.minUnit', { count: workout.duration })}
{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 }]}>
{workout.calories} {t('units.calUnit', { count: workout.calories })}
{t('units.calUnit', { count: workout.calories })}
</RNText>
</View>
<RNText style={[s.metaDot, { color: colors.text.hint }]}>·</RNText>
@@ -230,7 +448,7 @@ export default function WorkoutDetailScreen() {
</RNText>
{/* Separator */}
<View style={[s.separator, { backgroundColor: colors.border.glassLight }]} />
<View style={[s.separator, { backgroundColor: colors.border.dim }]} />
{/* Timing Card */}
<View style={[s.card, { backgroundColor: colors.bg.surface }]}>
@@ -243,7 +461,7 @@ export default function WorkoutDetailScreen() {
{t('screens:workout.prep', { defaultValue: 'Prep' })}
</RNText>
</View>
<View style={[s.timingDivider, { backgroundColor: colors.border.glassLight }]} />
<View style={[s.timingDivider, { backgroundColor: colors.border.dim }]} />
<View style={s.timingItem}>
<RNText style={[s.timingValue, { color: accentColor }]}>
{workout.workTime}s
@@ -252,7 +470,7 @@ export default function WorkoutDetailScreen() {
{t('screens:workout.work', { defaultValue: 'Work' })}
</RNText>
</View>
<View style={[s.timingDivider, { backgroundColor: colors.border.glassLight }]} />
<View style={[s.timingDivider, { backgroundColor: colors.border.dim }]} />
<View style={s.timingItem}>
<RNText style={[s.timingValue, { color: accentColor }]}>
{workout.restTime}s
@@ -261,7 +479,7 @@ export default function WorkoutDetailScreen() {
{t('screens:workout.rest', { defaultValue: 'Rest' })}
</RNText>
</View>
<View style={[s.timingDivider, { backgroundColor: colors.border.glassLight }]} />
<View style={[s.timingDivider, { backgroundColor: colors.border.dim }]} />
<View style={s.timingItem}>
<RNText style={[s.timingValue, { color: accentColor }]}>
{workout.rounds}
@@ -293,7 +511,7 @@ export default function WorkoutDetailScreen() {
</RNText>
</View>
{index < workout.exercises.length - 1 && (
<View style={[s.exerciseSep, { backgroundColor: colors.border.glassLight }]} />
<View style={[s.exerciseSep, { backgroundColor: colors.border.dim }]} />
)}
</View>
))}
@@ -336,26 +554,15 @@ export default function WorkoutDetailScreen() {
},
]}
>
<Pressable
testID={isLocked ? 'workout-unlock-button' : 'workout-start-button'}
style={({ pressed }) => [
s.ctaButton,
{ backgroundColor: isLocked ? ctaLockedBg : ctaBg },
isLocked && { borderWidth: 1, borderColor: colors.border.glass },
pressed && { opacity: 0.85, transform: [{ scale: 0.98 }] },
]}
<NativeButton
variant={isLocked ? 'secondary' : 'primary'}
title={isLocked ? t('screens:workout.unlockWithPremium') : t('screens:workout.startWorkout')}
systemImage={isLocked ? 'lock.fill' : 'play.fill'}
onPress={handleStartWorkout}
>
{isLocked && (
<Icon name="lock.fill" size={16} color={ctaLockedText} style={{ marginRight: 8 }} />
)}
<RNText
testID="workout-cta-text"
style={[s.ctaText, { color: isLocked ? ctaLockedText : ctaText }]}
>
{isLocked ? t('screens:workout.unlockWithPremium') : t('screens:workout.startWorkout')}
</RNText>
</Pressable>
fullWidth
controlSize="large"
testID={isLocked ? 'workout-unlock-button' : 'workout-start-button'}
/>
</Animated.View>
</View>
</>
@@ -364,6 +571,33 @@ export default function WorkoutDetailScreen() {
// ─── 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,

View File

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

View File

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

View File

@@ -3,11 +3,9 @@
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
### Feb 20, 2026
### Apr 11, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #5541 | 11:52 PM | 🔄 | Converted 4 detail screens to use theme system | ~264 |
| #5291 | 2:56 PM | 🔵 | Category detail screen implementation examined | ~305 |
| #5230 | 1:25 PM | 🟣 | Implemented category and collection detail screens with Inter font loading | ~481 |
| #6114 | 7:39 PM | 🔵 | Category detail screen imports reviewed | ~298 |
</claude-mem-context>

View File

@@ -8,10 +8,6 @@ import { View, StyleSheet, ScrollView, Pressable, Text as RNText } from 'react-n
import { useRouter, useLocalSearchParams } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { Icon } from '@/src/shared/components/Icon'
import {
Host,
Picker,
} from '@expo/ui/swift-ui'
import { useTranslation } from 'react-i18next'
@@ -26,7 +22,7 @@ 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 } from '@/src/shared/constants/colors'
import { TEXT, GREEN } from '@/src/shared/constants/colors'
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
const LEVEL_IDS: (WorkoutLevel | 'all')[] = ['all', 'Beginner', 'Intermediate', 'Advanced']
@@ -89,20 +85,24 @@ export default function CategoryDetailScreen() {
<View style={styles.backButton} />
</View>
{/* Level Filter */}
{/* Level Filter — segmented pills */}
<View style={styles.filterContainer}>
<Host matchContents useViewportSizeMeasurement colorScheme={colors.colorScheme}>
<Picker
selectedIndex={selectedLevelIndex}
onOptionSelected={(e) => {
haptics.selection()
setSelectedLevelIndex(e.nativeEvent.index)
}}
variant="segmented"
options={levelLabels}
color={BRAND.PRIMARY}
/>
</Host>
<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
@@ -175,6 +175,33 @@ function createStyles(colors: ThemeColors) {
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,
},