Files
tabatago/app/program/[id].tsx
Millian Lamiaux 5888aac08e refactor screens, navigation & player for new architecture
Simplify Home, Activity, Profile, Complete, Player, and Program screens
to work with the new Supabase-driven data layer. Update root and tab
layouts. Add Settings, Terms, and Zone routes. Add OfflineBanner
component and progressStore. Update all player sub-components to use
the refreshed design system tokens.
2026-04-21 21:50:48 +02:00

297 lines
9.9 KiB
TypeScript

/**
* Workout Program Detail Screen
* Shows Warmup → 3 Tabatas → Stretch preview, CTA to player.
*/
import { useEffect, useState } from 'react'
import { View, Text, StyleSheet, ScrollView, Pressable, ActivityIndicator } from 'react-native'
import { Stack, useRouter, useLocalSearchParams } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useTranslation } from 'react-i18next'
import { Icon } from '@/src/shared/components/Icon'
import { fetchProgramById, buildWorkoutProgramId } from '@/src/shared/data/workoutPrograms'
import type { WorkoutProgram } from '@/src/shared/types/workoutProgram'
import { BODY_ZONE_META, LEVEL_META } from '@/src/shared/types/workoutProgram'
import { useUserStore } from '@/src/shared/stores/userStore'
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
import { SPACING } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { TEXT, NAVY, GREEN, BORDER_COLORS, DARK } from '@/src/shared/constants/colors'
import { withOpacity } from '@/src/shared/utils/color'
const FALLBACK_ACCENT = '#FF6B35'
export default function WorkoutProgramDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>()
const router = useRouter()
const insets = useSafeAreaInsets()
const { t } = useTranslation()
const [program, setProgram] = useState<WorkoutProgram | null>(null)
const [loading, setLoading] = useState(true)
const isPremium = useUserStore(s => s.profile.subscription) !== 'free'
useEffect(() => {
let cancelled = false
setLoading(true)
fetchProgramById(id)
.then(p => {
if (!cancelled) setProgram(p)
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [id])
if (loading) {
return (
<View style={[styles.container, styles.center]}>
<Stack.Screen options={{ headerShown: false }} />
<ActivityIndicator color={TEXT.PRIMARY} />
</View>
)
}
if (!program) {
return (
<View style={[styles.container, styles.center]}>
<Stack.Screen options={{ headerShown: false }} />
<Text style={styles.errorText}>{t('screens:program.notFound')}</Text>
</View>
)
}
const accent = program.accentColor ?? BODY_ZONE_META[program.bodyZone].color ?? FALLBACK_ACCENT
const level = LEVEL_META[program.level]
const zone = BODY_ZONE_META[program.bodyZone]
const canAccess = program.isFree || isPremium
const handleStart = () => {
if (!canAccess) {
router.push('/paywall')
return
}
router.push(`/player/${buildWorkoutProgramId(program.id)}`)
}
const warmupMinutes = Math.round(program.warmup.totalDuration / 60)
const stretchMinutes = Math.round(program.stretch.totalDuration / 60)
return (
<View style={styles.container}>
<Stack.Screen
options={{
headerShown: true,
headerTitle: program.title,
headerStyle: { backgroundColor: NAVY[900] },
headerTintColor: TEXT.PRIMARY,
headerBackTitle: t('common:back'),
}}
/>
<ScrollView style={styles.scroll} contentContainerStyle={{ paddingBottom: insets.bottom + 120 }}>
{/* Hero */}
<View style={[styles.hero, { backgroundColor: withOpacity(accent, 0.12) }]}>
<View style={[styles.iconCircle, { backgroundColor: withOpacity(accent, 0.2) }]}>
<Icon name={(program.icon ?? zone.icon) as any} size={32} tintColor={accent} />
</View>
<Text style={styles.title}>{program.title}</Text>
{program.description && <Text style={styles.description}>{program.description}</Text>}
<View style={styles.badgeRow}>
<View style={[styles.badge, { borderColor: level.color }]}>
<Text style={[styles.badgeText, { color: level.color }]}>{level.label}</Text>
</View>
<View style={[styles.badge, { borderColor: zone.color }]}>
<Text style={[styles.badgeText, { color: zone.color }]}>{zone.label}</Text>
</View>
{!program.isFree && (
<View style={[styles.badge, { borderColor: accent }]}>
<Text style={[styles.badgeText, { color: accent }]}>{t('screens:home.premiumBadge')}</Text>
</View>
)}
</View>
<View style={styles.statsRow}>
<Stat value={program.estimatedDuration} label={t('common:units.min')} />
<Stat value={program.tabatas.length} label="Tabatas" />
<Stat value={program.estimatedCalories} label={t('common:units.cal')} />
</View>
</View>
{/* Warmup */}
<Section
title={t('screens:program.warmup')}
subtitle={`${warmupMinutes} ${t('common:units.min')}`}
accent="#FFC86B"
>
{program.warmup.exercises.map((ex, i) => (
<Row key={`w-${i}`} label={ex.name} detail={`${ex.duration}s`} />
))}
</Section>
{/* Tabatas */}
{program.tabatas.map((tabata, i) => (
<Section
key={tabata.id}
title={t('screens:program.tabataLabel', { num: i + 1 })}
subtitle={t('screens:program.tabataSubtitle', {
rounds: tabata.rounds,
work: tabata.workTime,
rest: tabata.restTime,
})}
accent={accent}
>
<Row label={tabata.exercise1.name} detail={t('screens:program.exercise1')} />
<Row label={tabata.exercise2.name} detail={t('screens:program.exercise2')} />
</Section>
))}
{/* Stretch */}
<Section
title={t('screens:program.stretch')}
subtitle={`${stretchMinutes} ${t('common:units.min')}`}
accent="#B4A7E5"
>
{program.stretch.exercises.map((ex, i) => (
<Row key={`s-${i}`} label={ex.name} detail={`${ex.duration}s`} />
))}
</Section>
</ScrollView>
<View style={[styles.ctaContainer, { paddingBottom: insets.bottom + SPACING[4] }]}>
<Pressable
style={[styles.ctaButton, { backgroundColor: canAccess ? accent : GREEN[500] }]}
onPress={handleStart}
>
<Text style={styles.ctaText}>
{canAccess ? t('screens:program.startSession') : t('screens:program.unlockPremium')}
</Text>
</Pressable>
</View>
</View>
)
}
function Stat({ value, label }: { value: number; label: string }) {
return (
<View style={styles.statItem}>
<Text style={styles.statValue}>{value}</Text>
<Text style={styles.statLabel}>{label}</Text>
</View>
)
}
function Section({
title,
subtitle,
accent,
children,
}: {
title: string
subtitle: string
accent: string
children: React.ReactNode
}) {
return (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<View style={[styles.sectionDot, { backgroundColor: accent }]} />
<Text style={styles.sectionTitle}>{title}</Text>
<Text style={styles.sectionSubtitle}>{subtitle}</Text>
</View>
<View style={styles.sectionBody}>{children}</View>
</View>
)
}
function Row({ label, detail }: { label: string; detail: string }) {
return (
<View style={styles.row}>
<Text style={styles.rowLabel} numberOfLines={1}>
{label}
</Text>
<Text style={styles.rowDetail}>{detail}</Text>
</View>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: NAVY[900] },
center: { alignItems: 'center', justifyContent: 'center' },
scroll: { flex: 1 },
errorText: { color: TEXT.SECONDARY, ...TYPOGRAPHY.BODY },
hero: { padding: SPACING[6], alignItems: 'center' },
iconCircle: {
width: 64,
height: 64,
borderRadius: 32,
alignItems: 'center',
justifyContent: 'center',
marginBottom: SPACING[3],
},
title: { ...TYPOGRAPHY.LARGE_TITLE, color: TEXT.PRIMARY, textAlign: 'center' },
description: {
...TYPOGRAPHY.BODY,
color: TEXT.SECONDARY,
textAlign: 'center',
marginTop: SPACING[2],
lineHeight: 22,
},
badgeRow: { flexDirection: 'row', gap: SPACING[2], marginTop: SPACING[3], flexWrap: 'wrap', justifyContent: 'center' },
badge: { paddingHorizontal: SPACING[2], paddingVertical: 3, borderRadius: RADIUS.SM, borderWidth: 1 },
badgeText: { ...TYPOGRAPHY.LABEL },
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 },
section: { paddingHorizontal: SPACING[5], marginTop: SPACING[6] },
sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: SPACING[2], marginBottom: SPACING[3] },
sectionDot: { width: 8, height: 8, borderRadius: 4 },
sectionTitle: { ...TYPOGRAPHY.HEADLINE, color: TEXT.PRIMARY, flex: 1 },
sectionSubtitle: { ...TYPOGRAPHY.CAPTION_1, color: TEXT.TERTIARY },
sectionBody: {
backgroundColor: NAVY[800],
borderRadius: RADIUS.MD,
borderWidth: 1,
borderColor: BORDER_COLORS.DIM,
overflow: 'hidden',
},
row: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: SPACING[3],
paddingVertical: SPACING[3],
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: BORDER_COLORS.DIM,
gap: SPACING[3],
},
rowLabel: { ...TYPOGRAPHY.SUBHEADLINE, color: TEXT.PRIMARY, flex: 1 },
rowDetail: { ...TYPOGRAPHY.CAPTION_1, color: TEXT.TERTIARY },
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 },
})