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

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

448
app/assessment.tsx Normal file
View File

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