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:
@@ -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],
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
448
app/assessment.tsx
Normal 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],
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
525
app/program/[id].tsx
Normal 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],
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user