- Extract player UI into src/features/player/ components (TimerRing, BurnBar, etc.) - Add transparent stack headers for workout/[id] and program/[id] screens - Refactor workout/[id], program/[id], complete/[id] screens - Add player feature tests and useTimer integration tests - Add data layer exports and test setup improvements
593 lines
19 KiB
TypeScript
593 lines
19 KiB
TypeScript
/**
|
|
* TabataFit Program Detail Screen
|
|
* Clean scrollable layout — native header, Apple Fitness+ style
|
|
*/
|
|
|
|
import React, { useEffect, useRef } from 'react'
|
|
import {
|
|
View,
|
|
Text as RNText,
|
|
StyleSheet,
|
|
ScrollView,
|
|
Pressable,
|
|
Animated,
|
|
} 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 { 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 type { ProgramId } from '@/src/shared/types'
|
|
import type { IconName } from '@/src/shared/components/Icon'
|
|
|
|
// 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() {
|
|
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 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))
|
|
|
|
// 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()
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (program) {
|
|
track('program_detail_viewed', {
|
|
program_id: programId,
|
|
program_title: program.title,
|
|
})
|
|
}
|
|
}, [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>
|
|
</>
|
|
)
|
|
}
|
|
|
|
const handleStartProgram = () => {
|
|
haptics.phaseChange()
|
|
selectProgram(programId)
|
|
const currentWorkout = getCurrentWorkout(programId)
|
|
if (currentWorkout) {
|
|
router.push(`/workout/${currentWorkout.id}`)
|
|
}
|
|
}
|
|
|
|
const handleWorkoutPress = (workoutId: string) => {
|
|
haptics.buttonTap()
|
|
router.push(`/workout/${workoutId}`)
|
|
}
|
|
|
|
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={[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} />
|
|
</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>
|
|
</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>
|
|
</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>
|
|
</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>
|
|
)
|
|
})}
|
|
</View>
|
|
)
|
|
})}
|
|
</ScrollView>
|
|
|
|
{/* CTA */}
|
|
<Animated.View
|
|
style={[
|
|
s.bottomBar,
|
|
{
|
|
backgroundColor: colors.bg.base,
|
|
paddingBottom: insets.bottom + SPACING[3],
|
|
opacity: ctaAnim,
|
|
transform: [
|
|
{
|
|
translateY: ctaAnim.interpolate({
|
|
inputRange: [0, 1],
|
|
outputRange: [30, 0],
|
|
}),
|
|
},
|
|
],
|
|
},
|
|
]}
|
|
>
|
|
<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>
|
|
</Pressable>
|
|
</Animated.View>
|
|
</View>
|
|
</>
|
|
)
|
|
}
|
|
|
|
// ─── Styles ──────────────────────────────────────────────────────────────────
|
|
|
|
const s = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
},
|
|
centered: {
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
scrollContent: {
|
|
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
|
paddingTop: SPACING[2],
|
|
},
|
|
|
|
// 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,
|
|
},
|
|
|
|
// Description
|
|
description: {
|
|
...TYPOGRAPHY.BODY,
|
|
lineHeight: 24,
|
|
marginBottom: SPACING[5],
|
|
},
|
|
|
|
// Card
|
|
card: {
|
|
borderRadius: RADIUS.LG,
|
|
borderCurve: 'continuous',
|
|
overflow: 'hidden',
|
|
padding: SPACING[4],
|
|
},
|
|
|
|
// 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,
|
|
},
|
|
|
|
// 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,
|
|
},
|
|
|
|
// 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,
|
|
},
|
|
})
|