feat: explore tab, React Query data layer, programs, sync, analytics, testing infrastructure

- Replace browse tab with Supabase-connected explore tab with filters
- Add React Query for data fetching with loading states
- Add 3 structured programs with weekly progression
- Add Supabase anonymous auth sync service
- Add PostHog analytics with screen tracking and events
- Add comprehensive test strategy (Vitest + Maestro E2E)
- Add RevenueCat subscription system with DEV simulation
- Add i18n translations for new screens (EN/FR/DE/ES)
- Add data deletion modal, sync consent modal
- Add assessment screen and program routes
- Add GitHub Actions CI workflow
- Update activity store with sync integration
This commit is contained in:
Millian Lamiaux
2026-03-24 12:04:48 +01:00
parent 8703c484e8
commit cd065d07c3
138 changed files with 26819 additions and 1043 deletions

View File

@@ -1,356 +0,0 @@
/**
* TabataFit Browse Screen - Premium Redesign
* React Native UI with glassmorphism
*/
import { View, StyleSheet, ScrollView, Pressable, Dimensions, TextInput } from 'react-native'
import { useRouter } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { BlurView } from 'expo-blur'
import Ionicons from '@expo/vector-icons/Ionicons'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useHaptics } from '@/src/shared/hooks'
import {
COLLECTIONS,
getFeaturedCollection,
WORKOUTS,
} from '@/src/shared/data'
import { useTranslatedCollections, useTranslatedWorkouts } from '@/src/shared/data/useTranslatedData'
import { StyledText } from '@/src/shared/components/StyledText'
import { WorkoutCard } from '@/src/shared/components/WorkoutCard'
import { CollectionCard } from '@/src/shared/components/CollectionCard'
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 type { WorkoutCategory } from '@/src/shared/types'
const { width: SCREEN_WIDTH } = Dimensions.get('window')
const FONTS = {
LARGE_TITLE: 34,
TITLE: 28,
TITLE_2: 22,
HEADLINE: 17,
SUBHEADLINE: 15,
CAPTION_1: 12,
CAPTION_2: 11,
}
const CATEGORIES: { id: WorkoutCategory | 'all'; translationKey: string }[] = [
{ id: 'all', translationKey: 'common:categories.all' },
{ id: 'full-body', translationKey: 'common:categories.fullBody' },
{ id: 'core', translationKey: 'common:categories.core' },
{ id: 'upper-body', translationKey: 'common:categories.upperBody' },
{ id: 'lower-body', translationKey: 'common:categories.lowerBody' },
{ id: 'cardio', translationKey: 'common:categories.cardio' },
]
// ═══════════════════════════════════════════════════════════════════════════
// MAIN SCREEN
// ═══════════════════════════════════════════════════════════════════════════
export default function BrowseScreen() {
const { t } = useTranslation()
const insets = useSafeAreaInsets()
const router = useRouter()
const haptics = useHaptics()
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
const [searchQuery, setSearchQuery] = useState('')
const [selectedCategory, setSelectedCategory] = useState<WorkoutCategory | 'all'>('all')
const featuredCollection = getFeaturedCollection()
const translatedCollections = useTranslatedCollections(COLLECTIONS)
const translatedWorkouts = useTranslatedWorkouts(WORKOUTS)
// Filter workouts based on search and category
const filteredWorkouts = useMemo(() => {
let filtered = translatedWorkouts
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase()
filtered = filtered.filter(
(w) =>
w.title.toLowerCase().includes(query) ||
w.category.toLowerCase().includes(query)
)
}
if (selectedCategory !== 'all') {
filtered = filtered.filter((w) => w.category === selectedCategory)
}
return filtered
}, [translatedWorkouts, searchQuery, selectedCategory])
const handleWorkoutPress = (id: string) => {
haptics.buttonTap()
router.push(`/workout/${id}`)
}
const handleCollectionPress = (id: string) => {
haptics.buttonTap()
router.push(`/collection/${id}`)
}
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
<ScrollView
style={styles.scrollView}
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 100 }]}
showsVerticalScrollIndicator={false}
>
{/* Header */}
<View style={styles.header}>
<StyledText size={FONTS.LARGE_TITLE} weight="bold" color={colors.text.primary}>
{t('screens:browse.title')}
</StyledText>
</View>
{/* Search Bar */}
<View style={styles.searchContainer}>
<BlurView
intensity={colors.glass.blurLight}
tint={colors.glass.blurTint}
style={StyleSheet.absoluteFill}
/>
<Ionicons name="search" size={20} color={colors.text.tertiary} />
<TextInput
style={styles.searchInput}
placeholder={t('screens:browse.searchPlaceholder') || 'Search workouts...'}
placeholderTextColor={colors.text.tertiary}
value={searchQuery}
onChangeText={setSearchQuery}
/>
{searchQuery.length > 0 && (
<Pressable
onPress={() => setSearchQuery('')}
hitSlop={8}
>
<Ionicons name="close-circle" size={20} color={colors.text.tertiary} />
</Pressable>
)}
</View>
{/* Category Filter Chips */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.categoriesScroll}
style={styles.categoriesContainer}
>
{CATEGORIES.map((cat) => (
<Pressable
key={cat.id}
style={[
styles.categoryChip,
selectedCategory === cat.id && styles.categoryChipActive,
]}
onPress={() => {
haptics.buttonTap()
setSelectedCategory(cat.id)
}}
>
{selectedCategory === cat.id && (
<BlurView
intensity={colors.glass.blurMedium}
tint={colors.glass.blurTint}
style={StyleSheet.absoluteFill}
/>
)}
<StyledText
size={14}
weight={selectedCategory === cat.id ? 'semibold' : 'medium'}
color={selectedCategory === cat.id ? '#FFFFFF' : colors.text.secondary}
>
{t(cat.translationKey)}
</StyledText>
</Pressable>
))}
</ScrollView>
{/* Featured Collection */}
{featuredCollection && !searchQuery && selectedCategory === 'all' && (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<StyledText size={FONTS.TITLE_2} weight="semibold" color={colors.text.primary}>
{t('screens:browse.featured')}
</StyledText>
</View>
<CollectionCard
collection={featuredCollection}
onPress={() => handleCollectionPress(featuredCollection.id)}
/>
</View>
)}
{/* Collections Grid */}
{!searchQuery && selectedCategory === 'all' && (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<StyledText size={FONTS.TITLE_2} weight="semibold" color={colors.text.primary}>
{t('screens:browse.collections')}
</StyledText>
</View>
<View style={styles.collectionsGrid}>
{translatedCollections.map((collection) => (
<CollectionCard
key={collection.id}
collection={collection}
onPress={() => handleCollectionPress(collection.id)}
/>
))}
</View>
</View>
)}
{/* All Workouts Grid */}
<View style={styles.section}>
<View style={styles.sectionHeader}>
<StyledText size={FONTS.TITLE_2} weight="semibold" color={colors.text.primary}>
{searchQuery ? t('screens:browse.searchResults') || 'Results' : t('screens:browse.allWorkouts') || 'All Workouts'}
</StyledText>
<StyledText size={FONTS.CAPTION_1} color={colors.text.tertiary}>
{filteredWorkouts.length} {t('plurals.workout', { count: filteredWorkouts.length })}
</StyledText>
</View>
{filteredWorkouts.length > 0 ? (
<View style={styles.workoutsGrid}>
{filteredWorkouts.map((workout) => (
<WorkoutCard
key={workout.id}
workout={workout}
variant="grid"
onPress={() => handleWorkoutPress(workout.id)}
/>
))}
</View>
) : (
<View style={styles.emptyState}>
<Ionicons name="fitness-outline" size={48} color={colors.text.tertiary} />
<StyledText
size={FONTS.HEADLINE}
weight="medium"
color={colors.text.secondary}
style={{ marginTop: SPACING[4] }}
>
{t('screens:browse.noResults') || 'No workouts found'}
</StyledText>
<StyledText
size={FONTS.SUBHEADLINE}
color={colors.text.tertiary}
style={{ marginTop: SPACING[2], textAlign: 'center' }}
>
{t('screens:browse.tryDifferentSearch') || 'Try a different search or category'}
</StyledText>
</View>
)}
</View>
</ScrollView>
</View>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// STYLES
// ═══════════════════════════════════════════════════════════════════════════
function createStyles(colors: ThemeColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bg.base,
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingHorizontal: LAYOUT.SCREEN_PADDING,
},
// Header
header: {
marginBottom: SPACING[4],
},
// Search Bar
searchContainer: {
flexDirection: 'row',
alignItems: 'center',
height: 48,
borderRadius: RADIUS.LG,
borderWidth: 1,
borderColor: colors.border.glass,
paddingHorizontal: SPACING[4],
marginBottom: SPACING[4],
overflow: 'hidden',
},
searchInput: {
flex: 1,
marginLeft: SPACING[3],
marginRight: SPACING[2],
fontSize: FONTS.HEADLINE,
color: colors.text.primary,
height: '100%',
},
// Categories
categoriesContainer: {
marginBottom: SPACING[6],
},
categoriesScroll: {
gap: SPACING[2],
paddingRight: SPACING[4],
},
categoryChip: {
paddingHorizontal: SPACING[4],
paddingVertical: SPACING[2],
borderRadius: RADIUS.FULL,
overflow: 'hidden',
borderWidth: 1,
borderColor: colors.border.glass,
},
categoryChipActive: {
borderColor: BRAND.PRIMARY,
backgroundColor: `${BRAND.PRIMARY}30`,
},
// Sections
section: {
marginBottom: SPACING[8],
},
sectionHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: SPACING[4],
},
// Collections
collectionsGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: SPACING[3],
},
// Workouts Grid
workoutsGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: SPACING[3],
},
// Empty State
emptyState: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: SPACING[12],
},
})
}

View File

@@ -1,99 +1,396 @@
/**
* TabataFit Home Screen - Premium Redesign
* React Native UI with glassmorphism
* TabataFit Home Screen - 3 Program Design
* Premium Apple Fitness+ inspired layout
*/
import { View, StyleSheet, ScrollView, Pressable, Dimensions, Text as RNText } from 'react-native'
import { View, StyleSheet, ScrollView, Pressable, Animated } from 'react-native'
import { useRouter } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { LinearGradient } from 'expo-linear-gradient'
import { BlurView } from 'expo-blur'
import Ionicons from '@expo/vector-icons/Ionicons'
import { useMemo, useState } from 'react'
import { useMemo, useRef, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useHaptics, useFeaturedWorkouts, usePopularWorkouts, useCollections } from '@/src/shared/hooks'
import { useUserStore, useActivityStore } from '@/src/shared/stores'
import { useHaptics } from '@/src/shared/hooks'
import { useUserStore, useProgramStore, useActivityStore, getWeeklyActivity } from '@/src/shared/stores'
import { PROGRAMS, ASSESSMENT_WORKOUT } from '@/src/shared/data/programs'
import { StyledText } from '@/src/shared/components/StyledText'
import { WorkoutCard } from '@/src/shared/components/WorkoutCard'
import { CollectionCard } from '@/src/shared/components/CollectionCard'
import { WorkoutCardSkeleton, CollectionCardSkeleton, StatsCardSkeleton } from '@/src/shared/components/loading/Skeleton'
import { useThemeColors, BRAND, GRADIENTS } from '@/src/shared/theme'
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 type { WorkoutCategory } from '@/src/shared/types'
const { width: SCREEN_WIDTH } = Dimensions.get('window')
import type { ProgramId } from '@/src/shared/types'
const FONTS = {
LARGE_TITLE: 34,
TITLE: 28,
TITLE_2: 22,
HEADLINE: 17,
SUBHEADLINE: 15,
CAPTION_1: 12,
CAPTION_2: 11,
BODY: 16,
CAPTION: 13,
}
const CATEGORIES: { id: WorkoutCategory | 'all'; key: string }[] = [
{ id: 'all', key: 'all' },
{ id: 'full-body', key: 'fullBody' },
{ id: 'core', key: 'core' },
{ id: 'upper-body', key: 'upperBody' },
{ id: 'lower-body', key: 'lowerBody' },
{ id: 'cardio', key: 'cardio' },
]
// Program metadata for display
const PROGRAM_META: Record<ProgramId, { icon: keyof typeof Ionicons.glyphMap; gradient: [string, string]; accent: string }> = {
'upper-body': {
icon: 'barbell-outline',
gradient: ['#FF6B35', '#FF3B30'],
accent: '#FF6B35',
},
'lower-body': {
icon: 'footsteps-outline',
gradient: ['#30D158', '#28A745'],
accent: '#30D158',
},
'full-body': {
icon: 'flame-outline',
gradient: ['#5AC8FA', '#007AFF'],
accent: '#5AC8FA',
},
}
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
// ═══════════════════════════════════════════════════════════════════════════
// PROGRAM CARD
// ═══════════════════════════════════════════════════════════════════════════
function ProgramCard({
programId,
onPress,
}: {
programId: ProgramId
onPress: () => void
}) {
const { t } = useTranslation('screens')
const haptics = useHaptics()
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
const program = PROGRAMS[programId]
const meta = PROGRAM_META[programId]
const programStatus = useProgramStore((s) => s.getProgramStatus(programId))
const completion = useProgramStore((s) => s.getProgramCompletion(programId))
// Press animation
const scaleValue = useRef(new Animated.Value(1)).current
const handlePressIn = useCallback(() => {
Animated.spring(scaleValue, {
toValue: 0.97,
useNativeDriver: true,
speed: 50,
bounciness: 4,
}).start()
}, [scaleValue])
const handlePressOut = useCallback(() => {
Animated.spring(scaleValue, {
toValue: 1,
useNativeDriver: true,
speed: 30,
bounciness: 6,
}).start()
}, [scaleValue])
const statusText = {
'not-started': t('programs.status.notStarted'),
'in-progress': `${completion}% ${t('programs.status.complete')}`,
'completed': t('programs.status.completed'),
}[programStatus]
const handlePress = () => {
haptics.buttonTap()
onPress()
}
return (
<View
style={styles.programCard}
testID={`program-card-${programId}`}
>
{/* Glass Background */}
<BlurView
intensity={colors.glass.blurMedium}
tint={colors.glass.blurTint}
style={StyleSheet.absoluteFill}
/>
{/* Color Gradient Overlay */}
<LinearGradient
colors={[meta.gradient[0] + '40', meta.gradient[1] + '18', 'transparent']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={StyleSheet.absoluteFill}
/>
{/* Top Accent Line */}
<LinearGradient
colors={meta.gradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.accentLine}
/>
<View style={styles.programCardContent}>
{/* Icon + Title Row */}
<View style={styles.programCardHeader}>
{/* Gradient Icon Circle */}
<View style={styles.programIconWrapper}>
<LinearGradient
colors={meta.gradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.programIconGradient}
/>
<View style={styles.programIconInner}>
<Ionicons name={meta.icon} size={24} color="#FFFFFF" />
</View>
</View>
<View style={styles.programHeaderText}>
<View style={styles.programTitleRow}>
<StyledText size={FONTS.HEADLINE} weight="bold" color={colors.text.primary} style={{ flex: 1 }}>
{t(`content:programs.${program.id}.title`)}
</StyledText>
{programStatus !== 'not-started' && (
<View style={[styles.statusBadge, { backgroundColor: meta.accent + '20', borderColor: meta.accent + '35' }]}>
<StyledText size={11} weight="semibold" color={meta.accent}>
{statusText}
</StyledText>
</View>
)}
</View>
<StyledText size={FONTS.CAPTION} color={colors.text.secondary} numberOfLines={2} style={{ lineHeight: 18 }}>
{t(`content:programs.${program.id}.description`)}
</StyledText>
</View>
</View>
{/* Progress Bar (if started) */}
{programStatus !== 'not-started' && (
<View style={styles.progressContainer}>
<View style={styles.progressBar}>
<View style={styles.progressFillWrapper}>
<LinearGradient
colors={meta.gradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={[
styles.progressFill,
{ width: `${Math.max(completion, 2)}%` },
]}
/>
</View>
</View>
<StyledText size={11} color={colors.text.tertiary}>
{programStatus === 'completed'
? t('programs.allWorkoutsComplete')
: `${completion}% ${t('programs.complete')}`
}
</StyledText>
</View>
)}
{/* Stats — inline text, not chips */}
<StyledText size={12} color={colors.text.tertiary} style={styles.programMeta}>
{program.durationWeeks} {t('programs.weeks')} · {program.workoutsPerWeek}×{t('programs.perWeek')} · {program.totalWorkouts} {t('programs.workouts')}
</StyledText>
{/* Premium CTA Button — only interactive element */}
<AnimatedPressable
style={[
styles.ctaButtonWrapper,
{ transform: [{ scale: scaleValue }] },
]}
onPress={handlePress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
testID={`program-${programId}-cta`}
>
<LinearGradient
colors={meta.gradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.ctaButton}
>
<StyledText size={15} weight="semibold" color="#FFFFFF">
{programStatus === 'not-started'
? t('programs.startProgram')
: programStatus === 'completed'
? t('programs.restart')
: t('programs.continue')
}
</StyledText>
<Ionicons
name={programStatus === 'completed' ? 'refresh' : 'arrow-forward'}
size={17}
color="#FFFFFF"
style={styles.ctaIcon}
/>
</LinearGradient>
</AnimatedPressable>
</View>
</View>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// QUICK STATS ROW
// ═══════════════════════════════════════════════════════════════════════════
function QuickStats() {
const { t } = useTranslation('screens')
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
const streak = useActivityStore((s) => s.streak)
const history = useActivityStore((s) => s.history)
const weeklyActivity = useMemo(() => getWeeklyActivity(history), [history])
const thisWeekCount = weeklyActivity.filter((d) => d.completed).length
const totalMinutes = useMemo(() => history.reduce((sum, r) => sum + r.durationMinutes, 0), [history])
const stats = [
{ icon: 'flame' as const, value: streak.current, label: t('home.statsStreak'), color: BRAND.PRIMARY },
{ icon: 'calendar-outline' as const, value: `${thisWeekCount}/7`, label: t('home.statsThisWeek'), color: '#5AC8FA' },
{ icon: 'time-outline' as const, value: totalMinutes, label: t('home.statsMinutes'), color: '#30D158' },
]
return (
<View style={styles.quickStatsRow}>
{stats.map((stat) => (
<View key={stat.label} style={styles.quickStatPill}>
<BlurView
intensity={colors.glass.blurLight}
tint={colors.glass.blurTint}
style={StyleSheet.absoluteFill}
/>
<Ionicons name={stat.icon} size={16} color={stat.color} />
<StyledText size={17} weight="bold" color={colors.text.primary} style={{ fontVariant: ['tabular-nums'] }}>
{String(stat.value)}
</StyledText>
<StyledText size={11} color={colors.text.tertiary}>
{stat.label}
</StyledText>
</View>
))}
</View>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// ASSESSMENT CARD
// ═══════════════════════════════════════════════════════════════════════════
function AssessmentCard({ onPress }: { onPress: () => void }) {
const { t } = useTranslation('screens')
const haptics = useHaptics()
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
const isCompleted = useProgramStore((s) => s.assessment.isCompleted)
if (isCompleted) return null
const handlePress = () => {
haptics.buttonTap()
onPress()
}
return (
<Pressable
style={styles.assessmentCard}
onPress={handlePress}
testID="assessment-card"
>
{/* Glass Background */}
<BlurView
intensity={colors.glass.blurMedium}
tint={colors.glass.blurTint}
style={StyleSheet.absoluteFill}
/>
{/* Subtle brand gradient overlay */}
<LinearGradient
colors={[`${BRAND.PRIMARY}18`, 'transparent']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={StyleSheet.absoluteFill}
/>
<View style={styles.assessmentContent}>
{/* Gradient Icon Circle */}
<View style={styles.assessmentIconCircle}>
<LinearGradient
colors={[BRAND.PRIMARY, '#FF3B30']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={StyleSheet.absoluteFill}
/>
<View style={styles.assessmentIconInner}>
<Ionicons name="clipboard-outline" size={22} color="#FFFFFF" />
</View>
</View>
<View style={styles.assessmentText}>
<StyledText size={FONTS.HEADLINE} weight="semibold" color={colors.text.primary}>
{t('assessment.title')}
</StyledText>
<StyledText size={FONTS.CAPTION} color={colors.text.secondary} style={{ marginTop: 2 }}>
{ASSESSMENT_WORKOUT.duration} {t('assessment.duration')} · {ASSESSMENT_WORKOUT.exercises.length} {t('assessment.movements')}
</StyledText>
</View>
<View style={styles.assessmentArrow}>
<Ionicons name="arrow-forward" size={16} color={BRAND.PRIMARY} />
</View>
</View>
</Pressable>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// MAIN SCREEN
// ═══════════════════════════════════════════════════════════════════════════
export default function HomeScreen() {
const { t } = useTranslation()
const { t } = useTranslation('screens')
const insets = useSafeAreaInsets()
const router = useRouter()
const haptics = useHaptics()
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
const haptics = useHaptics()
const userName = useUserStore((s) => s.profile.name)
const history = useActivityStore((s) => s.history)
const recentWorkouts = useMemo(() => history.slice(0, 3), [history])
const [selectedCategory, setSelectedCategory] = useState<WorkoutCategory | 'all'>('all')
// React Query hooks for live data
const { data: featuredWorkouts = [], isLoading: isLoadingFeatured } = useFeaturedWorkouts()
const { data: popularWorkouts = [], isLoading: isLoadingPopular } = usePopularWorkouts(6)
const { data: collections = [], isLoading: isLoadingCollections } = useCollections()
const featured = featuredWorkouts[0]
const filteredWorkouts = useMemo(() => {
if (selectedCategory === 'all') return popularWorkouts
return popularWorkouts.filter((w) => w.category === selectedCategory)
}, [popularWorkouts, selectedCategory])
const selectedProgram = useProgramStore((s) => s.selectedProgramId)
const changeProgram = useProgramStore((s) => s.changeProgram)
const streak = useActivityStore((s) => s.streak)
const greeting = (() => {
const hour = new Date().getHours()
if (hour < 12) return t('greetings.morning')
if (hour < 18) return t('greetings.afternoon')
return t('greetings.evening')
if (hour < 12) return t('common:greetings.morning')
if (hour < 18) return t('common:greetings.afternoon')
return t('common:greetings.evening')
})()
const handleWorkoutPress = (id: string) => {
haptics.buttonTap()
router.push(`/workout/${id}`)
const handleProgramPress = (programId: ProgramId) => {
// Navigate to program detail
router.push(`/program/${programId}` as any)
}
const handleCollectionPress = (id: string) => {
haptics.buttonTap()
router.push(`/collection/${id}`)
const handleAssessmentPress = () => {
router.push('/assessment' as any)
}
const handleSwitchProgram = () => {
haptics.buttonTap()
changeProgram(null as any)
}
const programOrder: ProgramId[] = ['upper-body', 'lower-body', 'full-body']
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
{/* Ambient gradient glow at top */}
<LinearGradient
colors={['rgba(255, 107, 53, 0.06)', 'rgba(255, 107, 53, 0.02)', 'transparent']}
start={{ x: 0, y: 0 }}
end={{ x: 0.5, y: 1 }}
style={styles.ambientGlow}
/>
<ScrollView
style={styles.scrollView}
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 100 }]}
@@ -101,142 +398,74 @@ export default function HomeScreen() {
>
{/* Hero Section */}
<View style={styles.heroSection}>
<StyledText size={FONTS.SUBHEADLINE} color={colors.text.tertiary}>
{greeting}
</StyledText>
<View style={styles.heroHeader}>
<StyledText
size={FONTS.LARGE_TITLE}
weight="bold"
color={colors.text.primary}
style={styles.heroTitle}
>
{userName}
<View style={styles.heroGreetingRow}>
<StyledText size={FONTS.BODY} color={colors.text.tertiary}>
{greeting}
</StyledText>
<Pressable style={styles.profileButton}>
<Ionicons name="person-circle-outline" size={40} color={colors.text.primary} />
</Pressable>
</View>
</View>
{/* Featured Workout */}
<View style={styles.section}>
<View style={styles.sectionHeader}>
<StyledText size={FONTS.TITLE_2} weight="semibold" color={colors.text.primary}>
{t('screens:home.featured')}
</StyledText>
</View>
{isLoadingFeatured ? (
<WorkoutCardSkeleton />
) : featured ? (
<WorkoutCard
workout={featured}
variant="featured"
onPress={() => handleWorkoutPress(featured.id)}
/>
) : null}
</View>
{/* Category Filter */}
<View style={styles.section}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.categoriesScroll}
>
{CATEGORIES.map((cat) => (
<Pressable
key={cat.id}
style={[
styles.categoryChip,
selectedCategory === cat.id && styles.categoryChipActive,
]}
onPress={() => {
haptics.buttonTap()
setSelectedCategory(cat.id)
}}
>
{selectedCategory === cat.id && (
<BlurView
intensity={colors.glass.blurMedium}
tint={colors.glass.blurTint}
style={StyleSheet.absoluteFill}
/>
)}
<StyledText
size={14}
weight={selectedCategory === cat.id ? 'semibold' : 'medium'}
color={selectedCategory === cat.id ? colors.text.primary : colors.text.secondary}
>
{t(`categories.${cat.key}`)}
{/* Inline streak badge */}
{streak.current > 0 && (
<View style={styles.streakBadge}>
<Ionicons name="flame" size={13} color={BRAND.PRIMARY} />
<StyledText size={12} weight="bold" color={BRAND.PRIMARY} style={{ fontVariant: ['tabular-nums'] }}>
{streak.current}
</StyledText>
</Pressable>
))}
</ScrollView>
</View>
)}
</View>
<StyledText size={FONTS.LARGE_TITLE} weight="bold" color={colors.text.primary} style={styles.heroName}>
{userName}
</StyledText>
<StyledText size={FONTS.CAPTION} color={colors.text.secondary} style={styles.heroSubtitle}>
{selectedProgram
? t('home.continueYourJourney')
: t('home.chooseYourPath')
}
</StyledText>
</View>
{/* Popular Workouts - Horizontal */}
<View style={styles.section}>
{/* Quick Stats Row */}
<QuickStats />
{/* Assessment Card (if not completed) */}
<AssessmentCard onPress={handleAssessmentPress} />
{/* Program Cards */}
<View style={styles.programsSection}>
<View style={styles.sectionHeader}>
<StyledText size={FONTS.TITLE_2} weight="semibold" color={colors.text.primary}>
{t('screens:home.popularThisWeek')}
<StyledText size={FONTS.TITLE} weight="bold" color={colors.text.primary}>
{t('home.yourPrograms')}
</StyledText>
<StyledText size={FONTS.CAPTION} color={colors.text.tertiary} style={styles.sectionSubtitle}>
{t('home.programsSubtitle')}
</StyledText>
<Pressable hitSlop={8}>
<StyledText size={FONTS.SUBHEADLINE} color={BRAND.PRIMARY} weight="medium">
{t('seeAll')}
</StyledText>
</Pressable>
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.workoutsScroll}
{programOrder.map((programId) => (
<ProgramCard
key={programId}
programId={programId}
onPress={() => handleProgramPress(programId)}
/>
))}
</View>
{/* Switch Program Option (if has progress) */}
{selectedProgram && (
<Pressable
style={styles.switchProgramButton}
onPress={handleSwitchProgram}
>
{isLoadingPopular ? (
// Loading skeletons
<>
<WorkoutCardSkeleton />
<WorkoutCardSkeleton />
<WorkoutCardSkeleton />
</>
) : (
filteredWorkouts.map((workout: any) => (
<WorkoutCard
key={workout.id}
workout={workout}
variant="horizontal"
onPress={() => handleWorkoutPress(workout.id)}
/>
))
)}
</ScrollView>
</View>
{/* Collections Grid */}
<View style={styles.section}>
<View style={styles.sectionHeader}>
<StyledText size={FONTS.TITLE_2} weight="semibold" color={colors.text.primary}>
{t('screens:home.collections')}
<BlurView
intensity={colors.glass.blurLight}
tint={colors.glass.blurTint}
style={StyleSheet.absoluteFill}
/>
<Ionicons name="shuffle-outline" size={16} color={colors.text.secondary} />
<StyledText size={14} weight="medium" color={colors.text.secondary}>
{t('home.switchProgram')}
</StyledText>
</View>
<View style={styles.collectionsGrid}>
{isLoadingCollections ? (
// Loading skeletons for collections
<>
<CollectionCardSkeleton />
<CollectionCardSkeleton />
</>
) : (
collections.map((collection) => (
<CollectionCard
key={collection.id}
collection={collection}
onPress={() => handleCollectionPress(collection.id)}
/>
))
)}
</View>
</View>
</Pressable>
)}
</ScrollView>
</View>
)
@@ -259,67 +488,241 @@ function createStyles(colors: ThemeColors) {
paddingHorizontal: LAYOUT.SCREEN_PADDING,
},
// Ambient gradient glow
ambientGlow: {
position: 'absolute',
top: 0,
left: 0,
width: 300,
height: 300,
borderRadius: 150,
},
// Hero Section
heroSection: {
marginBottom: SPACING[6],
marginTop: SPACING[4],
marginBottom: SPACING[7],
},
heroHeader: {
heroGreetingRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
justifyContent: 'space-between',
},
streakBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING[1],
paddingHorizontal: SPACING[3],
paddingVertical: SPACING[1],
borderRadius: RADIUS.FULL,
backgroundColor: `${BRAND.PRIMARY}15`,
borderWidth: 1,
borderColor: `${BRAND.PRIMARY}30`,
borderCurve: 'continuous',
},
heroName: {
marginTop: SPACING[1],
},
heroSubtitle: {
marginTop: SPACING[2],
},
heroTitle: {
flex: 1,
marginRight: SPACING[3],
},
profileButton: {
width: 44,
height: 44,
alignItems: 'center',
justifyContent: 'center',
},
// Sections
section: {
marginBottom: SPACING[8],
},
sectionHeader: {
// Quick Stats Row
quickStatsRow: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: SPACING[3],
marginBottom: SPACING[7],
},
quickStatPill: {
flex: 1,
alignItems: 'center',
marginBottom: SPACING[4],
},
// Categories
categoriesScroll: {
gap: SPACING[2],
paddingRight: SPACING[4],
},
categoryChip: {
paddingHorizontal: SPACING[4],
paddingVertical: SPACING[2],
borderRadius: RADIUS.FULL,
paddingVertical: SPACING[4],
borderRadius: RADIUS.GLASS_CARD,
overflow: 'hidden',
borderWidth: 1,
borderColor: colors.border.glass,
},
categoryChipActive: {
borderColor: BRAND.PRIMARY,
backgroundColor: `${BRAND.PRIMARY}30`,
borderCurve: 'continuous',
gap: SPACING[1],
backgroundColor: colors.glass.base.backgroundColor,
},
// Workouts Scroll
workoutsScroll: {
gap: SPACING[3],
paddingRight: SPACING[4],
// Assessment Card
assessmentCard: {
borderRadius: RADIUS.GLASS_CARD,
overflow: 'hidden',
padding: SPACING[5],
marginBottom: SPACING[8],
borderWidth: 1,
borderColor: colors.border.glassStrong,
borderCurve: 'continuous',
backgroundColor: colors.glass.base.backgroundColor,
},
// Collections Grid
collectionsGrid: {
assessmentContent: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: SPACING[3],
alignItems: 'center',
},
assessmentIconCircle: {
width: 44,
height: 44,
borderRadius: 22,
overflow: 'hidden',
borderCurve: 'continuous',
marginRight: SPACING[4],
},
assessmentIconInner: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
},
assessmentText: {
flex: 1,
},
assessmentArrow: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: `${BRAND.PRIMARY}18`,
alignItems: 'center',
justifyContent: 'center',
borderCurve: 'continuous',
},
// Programs Section
programsSection: {
marginTop: SPACING[2],
},
sectionHeader: {
marginBottom: SPACING[6],
},
sectionSubtitle: {
marginTop: SPACING[1],
},
// Program Card
programCard: {
borderRadius: RADIUS.XL,
marginBottom: SPACING[6],
overflow: 'hidden',
borderWidth: 1,
borderColor: colors.border.glassStrong,
borderCurve: 'continuous',
backgroundColor: colors.glass.base.backgroundColor,
},
accentLine: {
height: 2,
width: '100%',
},
programCardContent: {
padding: SPACING[5],
paddingRight: SPACING[6],
},
programCardHeader: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: SPACING[4],
marginBottom: SPACING[4],
},
// Gradient icon circle
programIconWrapper: {
width: 48,
height: 48,
borderRadius: 24,
overflow: 'hidden',
borderCurve: 'continuous',
},
programIconGradient: {
...StyleSheet.absoluteFillObject,
},
programIconInner: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
},
programHeaderText: {
flex: 1,
paddingBottom: SPACING[1],
},
programTitleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING[2],
marginBottom: SPACING[1],
},
statusBadge: {
paddingHorizontal: SPACING[2],
paddingVertical: 2,
borderRadius: RADIUS.FULL,
borderWidth: 1,
},
programTitle: {
marginBottom: SPACING[1],
},
programDescription: {
marginBottom: SPACING[4],
lineHeight: 20,
},
// Progress
progressContainer: {
marginBottom: SPACING[4],
},
progressBar: {
height: 8,
borderRadius: 4,
marginBottom: SPACING[2],
overflow: 'hidden',
backgroundColor: colors.glass.inset.backgroundColor,
borderCurve: 'continuous',
},
progressFillWrapper: {
flex: 1,
},
progressFill: {
height: '100%',
borderRadius: 4,
borderCurve: 'continuous',
},
// Stats as inline meta text
programMeta: {
marginBottom: SPACING[4],
},
// Premium CTA Button
ctaButtonWrapper: {
borderRadius: RADIUS.LG,
overflow: 'hidden',
borderCurve: 'continuous',
},
ctaButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: SPACING[4],
paddingHorizontal: SPACING[5],
borderRadius: RADIUS.LG,
borderCurve: 'continuous',
},
ctaIcon: {
marginLeft: SPACING[2],
},
// Switch Program — glass pill
switchProgramButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
alignSelf: 'center',
gap: SPACING[2],
paddingVertical: SPACING[3],
paddingHorizontal: SPACING[6],
marginTop: SPACING[2],
borderRadius: RADIUS.FULL,
borderWidth: 1,
borderColor: colors.border.glass,
borderCurve: 'continuous',
overflow: 'hidden',
backgroundColor: colors.glass.base.backgroundColor,
},
})
}

View File

@@ -17,11 +17,13 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'
import * as Linking from 'expo-linking'
import Constants from 'expo-constants'
import { useTranslation } from 'react-i18next'
import { useMemo } from 'react'
import { useMemo, useState } from 'react'
import { useUserStore } from '@/src/shared/stores'
import { requestNotificationPermissions, usePurchases } from '@/src/shared/hooks'
import { useThemeColors, BRAND } from '@/src/shared/theme'
import type { ThemeColors } from '@/src/shared/theme/types'
import { DataDeletionModal } from '@/src/shared/components/DataDeletionModal'
import { deleteSyncedData } from '@/src/shared/services/sync'
// ═══════════════════════════════════════════════════════════════════════════
// STYLED TEXT COMPONENT
@@ -69,7 +71,9 @@ export default function ProfileScreen() {
const settings = useUserStore((s) => s.settings)
const updateSettings = useUserStore((s) => s.updateSettings)
const updateProfile = useUserStore((s) => s.updateProfile)
const setSyncStatus = useUserStore((s) => s.setSyncStatus)
const { restorePurchases, isPremium } = usePurchases()
const [showDeleteModal, setShowDeleteModal] = useState(false)
const planLabel = isPremium ? 'TabataFit+' : t('profile.freePlan')
const avatarInitial = profile.name?.[0]?.toUpperCase() || 'U'
@@ -95,6 +99,14 @@ export default function ProfileScreen() {
await restorePurchases()
}
const handleDeleteData = async () => {
const result = await deleteSyncedData()
if (result.success) {
setSyncStatus('unsynced', null)
setShowDeleteModal(false)
}
}
const handleReminderToggle = async (enabled: boolean) => {
if (enabled) {
const granted = await requestNotificationPermissions()
@@ -268,6 +280,28 @@ export default function ProfileScreen() {
)}
</View>
{/* ════════════════════════════════════════════════════════════════════
PERSONALIZATION (PREMIUM ONLY)
═══════════════════════════════════════════════════════════════════ */}
{isPremium && (
<>
<Text style={styles.sectionHeader}>Personalization</Text>
<View style={styles.section}>
<View style={[styles.row, styles.rowLast]}>
<Text style={styles.rowLabel}>
{profile.syncStatus === 'synced' ? 'Personalization Enabled' : 'Generic Programs'}
</Text>
<Text
size={14}
color={profile.syncStatus === 'synced' ? BRAND.SUCCESS : colors.text.tertiary}
>
{profile.syncStatus === 'synced' ? '✓' : '○'}
</Text>
</View>
</View>
</>
)}
{/* ════════════════════════════════════════════════════════════════════
ABOUT
═══════════════════════════════════════════════════════════════════ */}
@@ -319,6 +353,13 @@ export default function ProfileScreen() {
</TouchableOpacity>
</View>
</ScrollView>
{/* Data Deletion Modal */}
<DataDeletionModal
visible={showDeleteModal}
onDelete={handleDeleteData}
onCancel={() => setShowDeleteModal(false)}
/>
</View>
)
}

View File

@@ -134,6 +134,12 @@ function RootLayoutInner() {
animation: 'slide_from_right',
}}
/>
<Stack.Screen
name="assessment"
options={{
animation: 'slide_from_right',
}}
/>
<Stack.Screen
name="player/[id]"
options={{

448
app/assessment.tsx Normal file
View File

@@ -0,0 +1,448 @@
/**
* TabataFit Assessment Screen
* Initial movement assessment to personalize experience
*/
import { View, StyleSheet, ScrollView, Pressable } from 'react-native'
import { useRouter } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { LinearGradient } from 'expo-linear-gradient'
import Ionicons from '@expo/vector-icons/Ionicons'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useHaptics } from '@/src/shared/hooks'
import { useProgramStore } from '@/src/shared/stores'
import { ASSESSMENT_WORKOUT } from '@/src/shared/data/programs'
import { StyledText } from '@/src/shared/components/StyledText'
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'
const FONTS = {
LARGE_TITLE: 28,
TITLE: 24,
HEADLINE: 17,
BODY: 16,
CAPTION: 13,
}
export default function AssessmentScreen() {
const { t } = useTranslation('screens')
const insets = useSafeAreaInsets()
const router = useRouter()
const haptics = useHaptics()
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
const [showIntro, setShowIntro] = useState(true)
const skipAssessment = useProgramStore((s) => s.skipAssessment)
const completeAssessment = useProgramStore((s) => s.completeAssessment)
const handleSkip = () => {
haptics.buttonTap()
skipAssessment()
router.back()
}
const handleStart = () => {
haptics.buttonTap()
setShowIntro(false)
}
const handleComplete = () => {
haptics.workoutComplete()
completeAssessment({
completedAt: new Date().toISOString(),
exercisesCompleted: ASSESSMENT_WORKOUT.exercises.map(e => e.name),
})
router.back()
}
if (!showIntro) {
// Here we'd show the actual assessment workout player
// For now, just show a completion screen
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
<View style={styles.header}>
<Pressable style={styles.backButton} onPress={() => setShowIntro(true)}>
<Ionicons name="arrow-back" size={24} color={colors.text.primary} />
</Pressable>
<StyledText size={FONTS.TITLE} weight="bold" color={colors.text.primary}>
{t('assessment.title')}
</StyledText>
<View style={styles.placeholder} />
</View>
<ScrollView
style={styles.scrollView}
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 120 }]}
>
<View style={styles.assessmentContainer}>
<View style={styles.exerciseList}>
{ASSESSMENT_WORKOUT.exercises.map((exercise, index) => (
<View key={exercise.name} style={styles.exerciseItem}>
<View style={styles.exerciseNumber}>
<StyledText size={14} weight="bold" color={colors.text.primary}>
{index + 1}
</StyledText>
</View>
<View style={styles.exerciseInfo}>
<StyledText size={16} weight="semibold" color={colors.text.primary}>
{exercise.name}
</StyledText>
<StyledText size={13} color={colors.text.secondary}>
{exercise.duration}s {exercise.purpose}
</StyledText>
</View>
</View>
))}
</View>
<View style={styles.tipsSection}>
<StyledText size={FONTS.HEADLINE} weight="semibold" color={colors.text.primary} style={styles.tipsTitle}>
{t('assessment.tips')}
</StyledText>
{ASSESSMENT_WORKOUT.tips.map((tip, index) => (
<View key={index} style={styles.tipItem}>
<Ionicons name="checkmark-circle-outline" size={18} color={BRAND.PRIMARY} />
<StyledText size={14} color={colors.text.secondary} style={styles.tipText}>
{tip}
</StyledText>
</View>
))}
</View>
</View>
</ScrollView>
<View style={[styles.bottomBar, { paddingBottom: insets.bottom + SPACING[4] }]}>
<Pressable style={styles.ctaButton} onPress={handleComplete}>
<LinearGradient
colors={['#FF6B35', '#FF3B30']}
style={styles.ctaGradient}
>
<StyledText size={16} weight="bold" color="#FFFFFF">
{t('assessment.startAssessment')}
</StyledText>
<Ionicons name="play" size={20} color="#FFFFFF" style={styles.ctaIcon} />
</LinearGradient>
</Pressable>
</View>
</View>
)
}
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
{/* Header */}
<View style={styles.header}>
<Pressable style={styles.backButton} onPress={handleSkip}>
<Ionicons name="close" size={24} color={colors.text.primary} />
</Pressable>
<View style={styles.placeholder} />
</View>
<ScrollView
style={styles.scrollView}
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 120 }]}
showsVerticalScrollIndicator={false}
>
{/* Hero */}
<View style={styles.heroSection}>
<View style={styles.iconContainer}>
<Ionicons name="clipboard-outline" size={48} color={BRAND.PRIMARY} />
</View>
<StyledText size={FONTS.LARGE_TITLE} weight="bold" color={colors.text.primary} style={styles.heroTitle}>
{t('assessment.welcomeTitle')}
</StyledText>
<StyledText size={FONTS.BODY} color={colors.text.secondary} style={styles.heroDescription}>
{t('assessment.welcomeDescription')}
</StyledText>
</View>
{/* Features */}
<View style={styles.featuresSection}>
<View style={styles.featureItem}>
<View style={styles.featureIcon}>
<Ionicons name="time-outline" size={24} color={BRAND.PRIMARY} />
</View>
<View style={styles.featureText}>
<StyledText size={16} weight="semibold" color={colors.text.primary}>
{ASSESSMENT_WORKOUT.duration} {t('assessment.minutes')}
</StyledText>
<StyledText size={14} color={colors.text.secondary}>
{t('assessment.quickCheck')}
</StyledText>
</View>
</View>
<View style={styles.featureItem}>
<View style={styles.featureIcon}>
<Ionicons name="body-outline" size={24} color={BRAND.PRIMARY} />
</View>
<View style={styles.featureText}>
<StyledText size={16} weight="semibold" color={colors.text.primary}>
{ASSESSMENT_WORKOUT.exercises.length} {t('assessment.movements')}
</StyledText>
<StyledText size={14} color={colors.text.secondary}>
{t('assessment.testMovements')}
</StyledText>
</View>
</View>
<View style={styles.featureItem}>
<View style={styles.featureIcon}>
<Ionicons name="barbell-outline" size={24} color={BRAND.PRIMARY} />
</View>
<View style={styles.featureText}>
<StyledText size={16} weight="semibold" color={colors.text.primary}>
{t('assessment.noEquipment')}
</StyledText>
<StyledText size={14} color={colors.text.secondary}>
{t('assessment.justYourBody')}
</StyledText>
</View>
</View>
</View>
{/* Benefits */}
<View style={styles.benefitsSection}>
<StyledText size={FONTS.HEADLINE} weight="semibold" color={colors.text.primary} style={styles.benefitsTitle}>
{t('assessment.whatWeCheck')}
</StyledText>
<View style={styles.benefitsList}>
<View style={styles.benefitTag}>
<StyledText size={13} color={colors.text.primary}>
{t('assessment.mobility')}
</StyledText>
</View>
<View style={styles.benefitTag}>
<StyledText size={13} color={colors.text.primary}>
{t('assessment.strength')}
</StyledText>
</View>
<View style={styles.benefitTag}>
<StyledText size={13} color={colors.text.primary}>
{t('assessment.stability')}
</StyledText>
</View>
<View style={styles.benefitTag}>
<StyledText size={13} color={colors.text.primary}>
{t('assessment.balance')}
</StyledText>
</View>
</View>
</View>
</ScrollView>
{/* Bottom Actions */}
<View style={[styles.bottomBar, { paddingBottom: insets.bottom + SPACING[4] }]}>
<Pressable style={styles.ctaButton} onPress={handleStart}>
<LinearGradient
colors={['#FF6B35', '#FF3B30']}
style={styles.ctaGradient}
>
<StyledText size={16} weight="bold" color="#FFFFFF">
{t('assessment.takeAssessment')}
</StyledText>
<Ionicons name="arrow-forward" size={20} color="#FFFFFF" style={styles.ctaIcon} />
</LinearGradient>
</Pressable>
<Pressable style={styles.skipButton} onPress={handleSkip}>
<StyledText size={15} color={colors.text.tertiary}>
{t('assessment.skipForNow')}
</StyledText>
</Pressable>
</View>
</View>
)
}
function createStyles(colors: ThemeColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bg.base,
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingHorizontal: LAYOUT.SCREEN_PADDING,
},
// Header
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: LAYOUT.SCREEN_PADDING,
paddingVertical: SPACING[3],
},
backButton: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
placeholder: {
width: 40,
},
// Hero
heroSection: {
alignItems: 'center',
marginTop: SPACING[4],
marginBottom: SPACING[8],
},
iconContainer: {
width: 100,
height: 100,
borderRadius: 50,
backgroundColor: `${BRAND.PRIMARY}15`,
alignItems: 'center',
justifyContent: 'center',
marginBottom: SPACING[5],
},
heroTitle: {
textAlign: 'center',
marginBottom: SPACING[3],
},
heroDescription: {
textAlign: 'center',
lineHeight: 24,
paddingHorizontal: SPACING[4],
},
// Features
featuresSection: {
marginBottom: SPACING[8],
},
featureItem: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: SPACING[4],
backgroundColor: colors.bg.surface,
padding: SPACING[4],
borderRadius: RADIUS.LG,
},
featureIcon: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: `${BRAND.PRIMARY}15`,
alignItems: 'center',
justifyContent: 'center',
marginRight: SPACING[3],
},
featureText: {
flex: 1,
},
// Benefits
benefitsSection: {
marginBottom: SPACING[6],
},
benefitsTitle: {
marginBottom: SPACING[3],
},
benefitsList: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: SPACING[2],
},
benefitTag: {
backgroundColor: colors.bg.surface,
paddingHorizontal: SPACING[4],
paddingVertical: SPACING[2],
borderRadius: RADIUS.FULL,
borderWidth: 1,
borderColor: colors.border.glass,
},
// Assessment Container
assessmentContainer: {
marginTop: SPACING[2],
},
exerciseList: {
marginBottom: SPACING[6],
},
exerciseItem: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.bg.surface,
padding: SPACING[4],
borderRadius: RADIUS.LG,
marginBottom: SPACING[2],
},
exerciseNumber: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: `${BRAND.PRIMARY}15`,
alignItems: 'center',
justifyContent: 'center',
marginRight: SPACING[3],
},
exerciseInfo: {
flex: 1,
},
// Tips
tipsSection: {
backgroundColor: colors.bg.surface,
borderRadius: RADIUS.LG,
padding: SPACING[5],
},
tipsTitle: {
marginBottom: SPACING[4],
},
tipItem: {
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: SPACING[3],
},
tipText: {
marginLeft: SPACING[2],
flex: 1,
lineHeight: 20,
},
// Bottom Bar
bottomBar: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
backgroundColor: colors.bg.base,
paddingHorizontal: LAYOUT.SCREEN_PADDING,
paddingTop: SPACING[3],
borderTopWidth: 1,
borderTopColor: colors.border.glass,
},
ctaButton: {
borderRadius: RADIUS.LG,
overflow: 'hidden',
marginBottom: SPACING[3],
},
ctaGradient: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: SPACING[4],
},
ctaIcon: {
marginLeft: SPACING[2],
},
skipButton: {
alignItems: 'center',
paddingVertical: SPACING[2],
},
})
}

View File

@@ -1,12 +0,0 @@
<claude-mem-context>
# Recent Activity
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
### Feb 20, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #5541 | 11:52 PM | 🔄 | Converted 4 detail screens to use theme system | ~264 |
| #5230 | 1:25 PM | 🟣 | Implemented category and collection detail screens with Inter font loading | ~481 |
</claude-mem-context>

View File

@@ -1,219 +0,0 @@
/**
* TabataFit Collection Detail Screen
* Shows collection info + ordered workout list
*/
import { useMemo } from 'react'
import { View, StyleSheet, ScrollView, Pressable, Text as RNText } from 'react-native'
import { useRouter, useLocalSearchParams } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { LinearGradient } from 'expo-linear-gradient'
import Ionicons from '@expo/vector-icons/Ionicons'
import { useTranslation } from 'react-i18next'
import { useHaptics } from '@/src/shared/hooks'
import { getCollectionById, getCollectionWorkouts, COLLECTION_COLORS } from '@/src/shared/data'
import { useTranslatedCollections, useTranslatedWorkouts } from '@/src/shared/data/useTranslatedData'
import { StyledText } from '@/src/shared/components/StyledText'
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'
export default function CollectionDetailScreen() {
const insets = useSafeAreaInsets()
const router = useRouter()
const haptics = useHaptics()
const { t } = useTranslation()
const { id } = useLocalSearchParams<{ id: string }>()
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
const rawCollection = id ? getCollectionById(id) : null
const translatedCollections = useTranslatedCollections(rawCollection ? [rawCollection] : [])
const collection = translatedCollections.length > 0 ? translatedCollections[0] : null
const rawWorkouts = useMemo(
() => id ? getCollectionWorkouts(id).filter((w): w is NonNullable<typeof w> => w != null) : [],
[id]
)
const workouts = useTranslatedWorkouts(rawWorkouts)
const collectionColor = COLLECTION_COLORS[id ?? ''] ?? BRAND.PRIMARY
const handleBack = () => {
haptics.selection()
router.back()
}
const handleWorkoutPress = (workoutId: string) => {
haptics.buttonTap()
router.push(`/workout/${workoutId}`)
}
if (!collection) {
return (
<View style={[styles.container, { paddingTop: insets.top, alignItems: 'center', justifyContent: 'center' }]}>
<RNText style={{ color: colors.text.primary, fontSize: 17 }}>{t('screens:collection.notFound')}</RNText>
</View>
)
}
const totalMinutes = workouts.reduce((sum, w) => sum + (w?.duration ?? 0), 0)
const totalCalories = workouts.reduce((sum, w) => sum + (w?.calories ?? 0), 0)
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
<ScrollView
style={styles.scrollView}
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 100 }]}
showsVerticalScrollIndicator={false}
>
{/* Hero Header — on gradient, text stays white */}
<View style={styles.hero}>
<LinearGradient
colors={collection.gradient ?? [collectionColor, BRAND.PRIMARY_DARK]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={StyleSheet.absoluteFill}
/>
<Pressable onPress={handleBack} style={styles.backButton}>
<Ionicons name="chevron-back" size={24} color="#FFFFFF" />
</Pressable>
<View style={styles.heroContent}>
<RNText style={styles.heroIcon}>{collection.icon}</RNText>
<StyledText size={28} weight="bold" color="#FFFFFF">{collection.title}</StyledText>
<StyledText size={15} color="rgba(255, 255, 255, 0.8)">{collection.description}</StyledText>
<View style={styles.heroStats}>
<View style={styles.heroStat}>
<Ionicons name="fitness" size={14} color="#FFFFFF" />
<StyledText size={13} color="#FFFFFF">{t('plurals.workout', { count: workouts.length })}</StyledText>
</View>
<View style={styles.heroStat}>
<Ionicons name="time" size={14} color="#FFFFFF" />
<StyledText size={13} color="#FFFFFF">{t('screens:collection.minTotal', { count: totalMinutes })}</StyledText>
</View>
<View style={styles.heroStat}>
<Ionicons name="flame" size={14} color="#FFFFFF" />
<StyledText size={13} color="#FFFFFF">{t('units.calUnit', { count: totalCalories })}</StyledText>
</View>
</View>
</View>
</View>
{/* Workout List — on base bg, use theme tokens */}
<View style={styles.workoutList}>
{workouts.map((workout, index) => {
if (!workout) return null
return (
<Pressable
key={workout.id}
style={styles.workoutCard}
onPress={() => handleWorkoutPress(workout.id)}
>
<View style={[styles.workoutNumber, { backgroundColor: `${collectionColor}20` }]}>
<RNText style={[styles.workoutNumberText, { color: collectionColor }]}>{index + 1}</RNText>
</View>
<View style={styles.workoutInfo}>
<StyledText size={17} weight="semibold" color={colors.text.primary}>{workout.title}</StyledText>
<StyledText size={13} color={colors.text.tertiary}>
{t('durationLevel', { duration: workout.duration, level: t(`levels.${workout.level.toLowerCase()}`) })}
</StyledText>
</View>
<View style={styles.workoutMeta}>
<StyledText size={13} color={BRAND.PRIMARY}>{t('units.calUnit', { count: workout.calories })}</StyledText>
<Ionicons name="play-circle" size={28} color={collectionColor} />
</View>
</Pressable>
)
})}
</View>
</ScrollView>
</View>
)
}
function createStyles(colors: ThemeColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bg.base,
},
scrollView: {
flex: 1,
},
scrollContent: {},
// Hero
hero: {
height: 260,
overflow: 'hidden',
},
backButton: {
width: 44,
height: 44,
alignItems: 'center',
justifyContent: 'center',
margin: SPACING[3],
},
heroContent: {
position: 'absolute',
bottom: SPACING[5],
left: SPACING[5],
right: SPACING[5],
},
heroIcon: {
fontSize: 40,
marginBottom: SPACING[2],
},
heroStats: {
flexDirection: 'row',
gap: SPACING[4],
marginTop: SPACING[3],
},
heroStat: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING[1],
},
// Workout List
workoutList: {
paddingHorizontal: LAYOUT.SCREEN_PADDING,
paddingTop: SPACING[4],
gap: SPACING[2],
},
workoutCard: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: SPACING[3],
paddingHorizontal: SPACING[4],
backgroundColor: colors.bg.surface,
borderRadius: RADIUS.LG,
gap: SPACING[3],
},
workoutNumber: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
},
workoutNumberText: {
fontSize: 15,
fontWeight: '700',
},
workoutInfo: {
flex: 1,
gap: 2,
},
workoutMeta: {
alignItems: 'flex-end',
gap: 4,
},
})
}

View File

@@ -3,7 +3,7 @@
* Celebration with real data from activity store
*/
import { useRef, useEffect, useMemo } from 'react'
import { useRef, useEffect, useMemo, useState } from 'react'
import {
View,
Text as RNText,
@@ -23,9 +23,12 @@ import * as Sharing from 'expo-sharing'
import { useTranslation } from 'react-i18next'
import { useHaptics } from '@/src/shared/hooks'
import { useActivityStore } from '@/src/shared/stores'
import { useActivityStore, useUserStore } from '@/src/shared/stores'
import { getWorkoutById, getPopularWorkouts } from '@/src/shared/data'
import { useTranslatedWorkout, useTranslatedWorkouts } from '@/src/shared/data/useTranslatedData'
import { SyncConsentModal } from '@/src/shared/components/SyncConsentModal'
import { enableSync } from '@/src/shared/services/sync'
import type { WorkoutSessionData } from '@/src/shared/types'
import { useThemeColors, BRAND } from '@/src/shared/theme'
import type { ThemeColors } from '@/src/shared/theme/types'
@@ -270,6 +273,10 @@ export default function WorkoutCompleteScreen() {
const history = useActivityStore((s) => s.history)
const recentWorkouts = history.slice(0, 1)
// Sync consent modal state
const [showSyncPrompt, setShowSyncPrompt] = useState(false)
const { profile, setSyncStatus } = useUserStore()
// Get the most recent result for this workout
const latestResult = recentWorkouts[0]
const resultCalories = latestResult?.calories ?? workout?.calories ?? 45
@@ -299,6 +306,54 @@ export default function WorkoutCompleteScreen() {
router.push(`/workout/${workoutId}`)
}
// Check if we should show sync prompt (after first workout for premium users)
useEffect(() => {
if (profile.syncStatus === 'prompt-pending') {
// Wait a moment for the user to see their results first
const timer = setTimeout(() => {
setShowSyncPrompt(true)
}, 1500)
return () => clearTimeout(timer)
}
}, [profile.syncStatus])
const handleSyncAccept = async () => {
setShowSyncPrompt(false)
// Prepare data for sync
const profileData = {
name: profile.name,
fitnessLevel: profile.fitnessLevel,
goal: profile.goal,
weeklyFrequency: profile.weeklyFrequency,
barriers: profile.barriers,
onboardingCompletedAt: new Date().toISOString(),
}
// Get all workout history for retroactive sync
const workoutHistory: WorkoutSessionData[] = history.map((w) => ({
workoutId: w.workoutId,
completedAt: new Date(w.completedAt).toISOString(),
durationSeconds: w.durationMinutes * 60,
caloriesBurned: w.calories,
}))
// Enable sync
const result = await enableSync(profileData, workoutHistory)
if (result.success) {
setSyncStatus('synced', result.userId || null)
} else {
// Show error - sync failed
setSyncStatus('never-synced')
}
}
const handleSyncDecline = () => {
setShowSyncPrompt(false)
setSyncStatus('never-synced') // Reset so we don't ask again
}
// Simulate percentile
const burnBarPercentile = Math.min(95, Math.max(40, Math.round((resultCalories / (workout?.calories ?? 45)) * 70)))
@@ -385,6 +440,13 @@ export default function WorkoutCompleteScreen() {
</PrimaryButton>
</View>
</View>
{/* Sync Consent Modal */}
<SyncConsentModal
visible={showSyncPrompt}
onAccept={handleSyncAccept}
onDecline={handleSyncDecline}
/>
</View>
)
}

View File

@@ -116,6 +116,7 @@ function ProblemScreen({ onNext }: { onNext: () => void }) {
<View style={styles.bottomAction}>
<Pressable
style={styles.ctaButton}
testID="onboarding-problem-cta"
onPress={() => {
haptics.buttonTap()
onNext()
@@ -179,6 +180,7 @@ function EmpathyScreen({
return (
<Pressable
key={item.id}
testID={`barrier-${item.id}`}
style={[
styles.barrierCard,
selected && styles.barrierCardSelected,
@@ -206,6 +208,7 @@ function EmpathyScreen({
<View style={styles.bottomAction}>
<Pressable
style={[styles.ctaButton, barriers.length === 0 && styles.ctaButtonDisabled]}
testID="onboarding-empathy-continue"
onPress={() => {
if (barriers.length > 0) {
haptics.buttonTap()
@@ -350,6 +353,7 @@ function SolutionScreen({ onNext }: { onNext: () => void }) {
<View style={styles.bottomAction}>
<Pressable
style={styles.ctaButton}
testID="onboarding-solution-cta"
onPress={() => {
haptics.buttonTap()
onNext()
@@ -467,6 +471,7 @@ function WowScreen({ onNext }: { onNext: () => void }) {
<Animated.View style={[styles.bottomAction, { opacity: ctaOpacity }]}>
<Pressable
style={styles.ctaButton}
testID="onboarding-wow-cta"
onPress={() => {
if (ctaReady) {
haptics.buttonTap()
@@ -556,6 +561,7 @@ function PersonalizationScreen({
placeholderTextColor={colors.text.hint}
autoCapitalize="words"
autoCorrect={false}
testID="name-input"
/>
</View>
@@ -568,6 +574,7 @@ function PersonalizationScreen({
{LEVELS.map((item) => (
<Pressable
key={item.value}
testID={`level-${item.value}`}
style={[
styles.segmentButton,
level === item.value && styles.segmentButtonActive,
@@ -598,6 +605,7 @@ function PersonalizationScreen({
{GOALS.map((item) => (
<Pressable
key={item.value}
testID={`goal-${item.value}`}
style={[
styles.segmentButton,
goal === item.value && styles.segmentButtonActive,
@@ -628,6 +636,7 @@ function PersonalizationScreen({
{FREQUENCIES.map((item) => (
<Pressable
key={item.value}
testID={`frequency-${item.value}x`}
style={[
styles.segmentButton,
frequency === item.value && styles.segmentButtonActive,
@@ -658,6 +667,7 @@ function PersonalizationScreen({
<View style={{ marginTop: SPACING[8] }}>
<Pressable
style={[styles.ctaButton, !name.trim() && styles.ctaButtonDisabled]}
testID="onboarding-personalization-continue"
onPress={() => {
if (name.trim()) {
haptics.buttonTap()
@@ -828,6 +838,7 @@ function PaywallScreen({
<View style={styles.pricingCards}>
{/* Annual */}
<Pressable
testID="plan-yearly"
style={[
styles.pricingCard,
selectedPlan === 'premium-yearly' && styles.pricingCardSelected,
@@ -852,6 +863,7 @@ function PaywallScreen({
{/* Monthly */}
<Pressable
testID="plan-monthly"
style={[
styles.pricingCard,
selectedPlan === 'premium-monthly' && styles.pricingCardSelected,
@@ -870,6 +882,7 @@ function PaywallScreen({
{/* CTA */}
<Pressable
style={[styles.trialButton, isPurchasing && styles.ctaButtonDisabled]}
testID="subscribe-button"
onPress={handlePurchase}
disabled={isPurchasing}
>
@@ -886,17 +899,21 @@ function PaywallScreen({
</View>
{/* Restore Purchases */}
<Pressable style={styles.restoreButton} onPress={handleRestore}>
<Pressable style={styles.restoreButton} onPress={handleRestore} testID="restore-purchases">
<StyledText size={14} color={colors.text.hint}>
{t('onboarding.paywall.restorePurchases')}
</StyledText>
</Pressable>
{/* Skip */}
<Pressable style={styles.skipButton} onPress={() => {
track('onboarding_paywall_skipped')
onSkip()
}}>
<Pressable
style={styles.skipButton}
testID="skip-paywall"
onPress={() => {
track('onboarding_paywall_skipped')
onSkip()
}}
>
<StyledText size={14} color={colors.text.hint}>
{t('onboarding.paywall.skipButton')}
</StyledText>
@@ -1241,6 +1258,7 @@ function createStyles(colors: ThemeColors) {
flex: 1,
paddingVertical: SPACING[5],
alignItems: 'center',
justifyContent: 'center',
borderRadius: RADIUS.GLASS_CARD,
...colors.glass.base,
},

525
app/program/[id].tsx Normal file
View File

@@ -0,0 +1,525 @@
/**
* TabataFit Program Detail Screen
* Shows week progression and workout list
*/
import { View, StyleSheet, ScrollView, Pressable } from 'react-native'
import { useRouter, useLocalSearchParams } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { LinearGradient } from 'expo-linear-gradient'
import Ionicons from '@expo/vector-icons/Ionicons'
import { useMemo } from 'react'
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 { StyledText } from '@/src/shared/components/StyledText'
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 type { ProgramId } from '@/src/shared/types'
const FONTS = {
LARGE_TITLE: 28,
TITLE: 24,
TITLE_2: 20,
HEADLINE: 17,
BODY: 16,
CAPTION: 13,
SMALL: 12,
}
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 styles = useMemo(() => createStyles(colors), [colors])
const program = PROGRAMS[programId]
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))
if (!program) {
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
<StyledText color={colors.text.primary}>Program not found</StyledText>
</View>
)
}
const handleStartProgram = () => {
haptics.buttonTap()
selectProgram(programId)
const currentWorkout = getCurrentWorkout(programId)
if (currentWorkout) {
router.push(`/workout/${currentWorkout.id}`)
}
}
const handleWorkoutPress = (workoutId: string, weekNumber: number) => {
haptics.buttonTap()
router.push(`/workout/${workoutId}`)
}
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
{/* Header */}
<View style={styles.header}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={colors.text.primary} />
</Pressable>
<StyledText size={FONTS.TITLE} weight="bold" color={colors.text.primary}>
{program.title}
</StyledText>
<View style={styles.placeholder} />
</View>
<ScrollView
style={styles.scrollView}
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 120 }]}
showsVerticalScrollIndicator={false}
>
{/* Program Overview */}
<View style={styles.overviewSection}>
<StyledText size={FONTS.BODY} color={colors.text.secondary} style={styles.description}>
{program.description}
</StyledText>
{/* Stats Row */}
<View style={styles.statsRow}>
<View style={styles.statBox}>
<StyledText size={FONTS.LARGE_TITLE} weight="bold" color={colors.text.primary}>
{program.durationWeeks}
</StyledText>
<StyledText size={FONTS.SMALL} color={colors.text.tertiary}>
{t('programs.weeks')}
</StyledText>
</View>
<View style={styles.statBox}>
<StyledText size={FONTS.LARGE_TITLE} weight="bold" color={colors.text.primary}>
{program.totalWorkouts}
</StyledText>
<StyledText size={FONTS.SMALL} color={colors.text.tertiary}>
{t('programs.workouts')}
</StyledText>
</View>
<View style={styles.statBox}>
<StyledText size={FONTS.LARGE_TITLE} weight="bold" color={colors.text.primary}>
4
</StyledText>
<StyledText size={FONTS.SMALL} color={colors.text.tertiary}>
{t('programs.minutes')}
</StyledText>
</View>
</View>
{/* Equipment */}
<View style={styles.equipmentSection}>
<StyledText size={FONTS.CAPTION} weight="semibold" color={colors.text.secondary}>
{t('programs.equipment')}
</StyledText>
<View style={styles.equipmentList}>
{program.equipment.required.map((item) => (
<View key={item} style={styles.equipmentTag}>
<StyledText size={12} color={colors.text.primary}>
{item}
</StyledText>
</View>
))}
{program.equipment.optional.map((item) => (
<View key={item} style={[styles.equipmentTag, styles.optionalTag]}>
<StyledText size={12} color={colors.text.tertiary}>
{item} {t('programs.optional')}
</StyledText>
</View>
))}
</View>
</View>
{/* Focus Areas */}
<View style={styles.focusSection}>
<StyledText size={FONTS.CAPTION} weight="semibold" color={colors.text.secondary}>
{t('programs.focusAreas')}
</StyledText>
<View style={styles.focusList}>
{program.focusAreas.map((area) => (
<View key={area} style={styles.focusTag}>
<StyledText size={12} color={colors.text.primary}>
{area}
</StyledText>
</View>
))}
</View>
</View>
</View>
{/* Progress Overview */}
{progress.completedWorkoutIds.length > 0 && (
<View style={styles.progressSection}>
<View style={styles.progressHeader}>
<StyledText size={FONTS.TITLE_2} weight="semibold" color={colors.text.primary}>
{t('programs.yourProgress')}
</StyledText>
<StyledText size={FONTS.BODY} weight="semibold" color={BRAND.PRIMARY}>
{completion}%
</StyledText>
</View>
<View style={styles.progressBarContainer}>
<View style={[styles.progressBar, { backgroundColor: colors.bg.surface }]}>
<View
style={[
styles.progressFill,
{
width: `${completion}%`,
backgroundColor: BRAND.PRIMARY,
}
]}
/>
</View>
</View>
<StyledText size={FONTS.CAPTION} color={colors.text.tertiary}>
{progress.completedWorkoutIds.length} {t('programs.of')} {program.totalWorkouts} {t('programs.workoutsComplete')}
</StyledText>
</View>
)}
{/* Weeks */}
<View style={styles.weeksSection}>
<StyledText size={FONTS.TITLE_2} weight="semibold" color={colors.text.primary} style={styles.weeksTitle}>
{t('programs.trainingPlan')}
</StyledText>
{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={styles.weekCard}>
{/* Week Header */}
<View style={styles.weekHeader}>
<View style={styles.weekTitleRow}>
<StyledText size={FONTS.HEADLINE} weight="semibold" color={colors.text.primary}>
{week.title}
</StyledText>
{!isUnlocked && (
<Ionicons name="lock-closed" size={16} color={colors.text.tertiary} />
)}
{isCurrentWeek && isUnlocked && (
<View style={styles.currentBadge}>
<StyledText size={11} weight="semibold" color="#FFFFFF">
{t('programs.current')}
</StyledText>
</View>
)}
</View>
<StyledText size={FONTS.CAPTION} color={colors.text.secondary}>
{week.description}
</StyledText>
{weekCompletion > 0 && (
<StyledText size={FONTS.SMALL} color={colors.text.tertiary} style={styles.weekProgress}>
{weekCompletion}/{week.workouts.length} {t('programs.complete')}
</StyledText>
)}
</View>
{/* Week Workouts */}
{isUnlocked && (
<View style={styles.workoutsList}>
{week.workouts.map((workout, index) => {
const isCompleted = progress.completedWorkoutIds.includes(workout.id)
const isLocked = !isCompleted && index > 0 &&
!progress.completedWorkoutIds.includes(week.workouts[index - 1].id) &&
week.weekNumber === progress.currentWeek
return (
<Pressable
key={workout.id}
style={[
styles.workoutItem,
isCompleted && styles.workoutCompleted,
isLocked && styles.workoutLocked,
]}
onPress={() => !isLocked && handleWorkoutPress(workout.id, week.weekNumber)}
disabled={isLocked}
>
<View style={styles.workoutNumber}>
{isCompleted ? (
<Ionicons name="checkmark-circle" size={24} color={BRAND.SUCCESS} />
) : isLocked ? (
<Ionicons name="lock-closed" size={20} color={colors.text.tertiary} />
) : (
<StyledText size={14} weight="semibold" color={colors.text.primary}>
{index + 1}
</StyledText>
)}
</View>
<View style={styles.workoutInfo}>
<StyledText
size={14}
weight={isCompleted ? "medium" : "semibold"}
color={isLocked ? colors.text.tertiary : colors.text.primary}
style={isCompleted && styles.completedText}
>
{workout.title}
</StyledText>
<StyledText size={12} color={colors.text.tertiary}>
{workout.exercises.length} {t('programs.exercises')} {workout.duration} {t('programs.min')}
</StyledText>
</View>
{!isLocked && !isCompleted && (
<Ionicons name="chevron-forward" size={20} color={colors.text.tertiary} />
)}
</Pressable>
)
})}
</View>
)}
</View>
)
})}
</View>
</ScrollView>
{/* Bottom CTA */}
<View style={[styles.bottomBar, { paddingBottom: insets.bottom + SPACING[4] }]}>
<Pressable style={styles.ctaButton} onPress={handleStartProgram}>
<LinearGradient
colors={['#FF6B35', '#FF3B30']}
style={styles.ctaGradient}
>
<StyledText size={16} weight="bold" color="#FFFFFF">
{progress.completedWorkoutIds.length === 0
? t('programs.startProgram')
: progress.isProgramCompleted
? t('programs.restartProgram')
: t('programs.continueTraining')
}
</StyledText>
<Ionicons name="arrow-forward" size={20} color="#FFFFFF" style={styles.ctaIcon} />
</LinearGradient>
</Pressable>
</View>
</View>
)
}
function createStyles(colors: ThemeColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bg.base,
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingHorizontal: LAYOUT.SCREEN_PADDING,
},
// Header
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: LAYOUT.SCREEN_PADDING,
paddingVertical: SPACING[3],
},
backButton: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
placeholder: {
width: 40,
},
// Overview
overviewSection: {
marginTop: SPACING[2],
marginBottom: SPACING[6],
},
description: {
marginBottom: SPACING[5],
lineHeight: 24,
},
statsRow: {
flexDirection: 'row',
justifyContent: 'space-around',
marginBottom: SPACING[5],
paddingVertical: SPACING[4],
backgroundColor: colors.bg.surface,
borderRadius: RADIUS.LG,
},
statBox: {
alignItems: 'center',
},
// Equipment
equipmentSection: {
marginBottom: SPACING[4],
},
equipmentList: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: SPACING[2],
marginTop: SPACING[2],
},
equipmentTag: {
backgroundColor: colors.bg.surface,
paddingHorizontal: SPACING[3],
paddingVertical: SPACING[1],
borderRadius: RADIUS.FULL,
},
optionalTag: {
opacity: 0.7,
},
// Focus
focusSection: {
marginBottom: SPACING[4],
},
focusList: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: SPACING[2],
marginTop: SPACING[2],
},
focusTag: {
backgroundColor: `${BRAND.PRIMARY}15`,
paddingHorizontal: SPACING[3],
paddingVertical: SPACING[1],
borderRadius: RADIUS.FULL,
},
// Progress
progressSection: {
backgroundColor: colors.bg.surface,
borderRadius: RADIUS.LG,
padding: SPACING[4],
marginBottom: SPACING[6],
},
progressHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: SPACING[3],
},
progressBarContainer: {
marginBottom: SPACING[2],
},
progressBar: {
height: 8,
borderRadius: 4,
overflow: 'hidden',
},
progressFill: {
height: '100%',
borderRadius: 4,
},
// Weeks
weeksSection: {
marginBottom: SPACING[6],
},
weeksTitle: {
marginBottom: SPACING[4],
},
weekCard: {
backgroundColor: colors.bg.surface,
borderRadius: RADIUS.LG,
marginBottom: SPACING[4],
overflow: 'hidden',
},
weekHeader: {
padding: SPACING[4],
borderBottomWidth: 1,
borderBottomColor: colors.border.glass,
},
weekTitleRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: SPACING[1],
},
currentBadge: {
backgroundColor: BRAND.PRIMARY,
paddingHorizontal: SPACING[2],
paddingVertical: 2,
borderRadius: RADIUS.SM,
},
weekProgress: {
marginTop: SPACING[2],
},
// Workouts List
workoutsList: {
padding: SPACING[2],
},
workoutItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: SPACING[3],
paddingHorizontal: SPACING[3],
borderRadius: RADIUS.MD,
},
workoutCompleted: {
opacity: 0.7,
},
workoutLocked: {
opacity: 0.5,
},
workoutNumber: {
width: 32,
height: 32,
alignItems: 'center',
justifyContent: 'center',
marginRight: SPACING[3],
},
workoutInfo: {
flex: 1,
},
completedText: {
textDecorationLine: 'line-through',
},
// Bottom Bar
bottomBar: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
backgroundColor: colors.bg.base,
paddingHorizontal: LAYOUT.SCREEN_PADDING,
paddingTop: SPACING[3],
borderTopWidth: 1,
borderTopColor: colors.border.glass,
},
ctaButton: {
borderRadius: RADIUS.LG,
overflow: 'hidden',
},
ctaGradient: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: SPACING[4],
},
ctaIcon: {
marginLeft: SPACING[2],
},
})
}