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:
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
16
app/workout/body-zone/CLAUDE.md
Normal file
16
app/workout/body-zone/CLAUDE.md
Normal 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>
|
||||
296
app/workout/body-zone/[id].tsx
Normal file
296
app/workout/body-zone/[id].tsx
Normal 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],
|
||||
},
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user