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:
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],
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user