Files
tabatago/app/(tabs)/index.tsx
Millian Lamiaux b833198e9d feat: migrate icons to SF Symbols, refactor explore tab, add collections/programs data layer
- Replace all Ionicons with native SF Symbols via expo-symbols SymbolView
- Create reusable Icon wrapper component (src/shared/components/Icon.tsx)
- Remove @expo/vector-icons and lucide-react dependencies
- Refactor explore tab with filters, search, and category browsing
- Add collections and programs data with Supabase integration
- Add explore filter store and filter sheet
- Update i18n strings (en, de, es, fr) for new explore features
- Update test mocks and remove stale snapshots
- Add user fitness level to user store and types
2026-03-25 23:28:51 +01:00

736 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* TabataFit Home Screen - 3 Program Design
* Premium Apple Fitness+ inspired layout
*/
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 { Icon, type IconName } from '@/src/shared/components/Icon'
import { useMemo, useRef, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
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 { 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'
// Feature flags — disable incomplete features
const FEATURE_FLAGS = {
ASSESSMENT_ENABLED: false, // Assessment player not yet implemented
}
const FONTS = {
LARGE_TITLE: 34,
TITLE: 28,
TITLE_2: 22,
HEADLINE: 17,
BODY: 16,
CAPTION: 13,
}
// Program metadata for display
const PROGRAM_META: Record<ProgramId, { icon: IconName; gradient: [string, string]; accent: string }> = {
'upper-body': {
icon: 'dumbbell',
gradient: ['#FF6B35', '#FF3B30'],
accent: '#FF6B35',
},
'lower-body': {
icon: 'figure.walk',
gradient: ['#30D158', '#28A745'],
accent: '#30D158',
},
'full-body': {
icon: 'flame',
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}>
<Icon name={meta.icon} size={24} tintColor="#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>
<Icon
name={programStatus === 'completed' ? 'arrow.clockwise' : 'arrow.right'}
size={17}
tintColor="#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.fill' as const, value: streak.current, label: t('home.statsStreak'), color: BRAND.PRIMARY },
{ icon: 'calendar' as const, value: `${thisWeekCount}/7`, label: t('home.statsThisWeek'), color: '#5AC8FA' },
{ icon: 'clock' 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}
/>
<Icon name={stat.icon} size={16} tintColor={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}>
<Icon name="clipboard" size={22} tintColor="#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}>
<Icon name="arrow.right" size={16} tintColor={BRAND.PRIMARY} />
</View>
</View>
</Pressable>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// MAIN SCREEN
// ═══════════════════════════════════════════════════════════════════════════
export default function HomeScreen() {
const { t } = useTranslation('screens')
const insets = useSafeAreaInsets()
const router = useRouter()
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
const haptics = useHaptics()
const userName = useUserStore((s) => s.profile.name)
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('common:greetings.morning')
if (hour < 18) return t('common:greetings.afternoon')
return t('common:greetings.evening')
})()
const handleProgramPress = (programId: ProgramId) => {
// Navigate to program detail
router.push(`/program/${programId}` as any)
}
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 }]}
showsVerticalScrollIndicator={false}
>
{/* Hero Section */}
<View style={styles.heroSection}>
<View style={styles.heroGreetingRow}>
<StyledText size={FONTS.BODY} color={colors.text.tertiary}>
{greeting}
</StyledText>
{/* Inline streak badge */}
{streak.current > 0 && (
<View style={styles.streakBadge}>
<Icon name="flame.fill" size={13} tintColor={BRAND.PRIMARY} />
<StyledText size={12} weight="bold" color={BRAND.PRIMARY} style={{ fontVariant: ['tabular-nums'] }}>
{streak.current}
</StyledText>
</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>
{/* Quick Stats Row */}
<QuickStats />
{/* Assessment Card (if not completed and feature enabled) */}
{FEATURE_FLAGS.ASSESSMENT_ENABLED && (
<AssessmentCard onPress={handleAssessmentPress} />
)}
{/* Program Cards */}
<View style={styles.programsSection}>
<View style={styles.sectionHeader}>
<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>
</View>
{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}
>
<BlurView
intensity={colors.glass.blurLight}
tint={colors.glass.blurTint}
style={StyleSheet.absoluteFill}
/>
<Icon name="shuffle" size={16} tintColor={colors.text.secondary} />
<StyledText size={14} weight="medium" color={colors.text.secondary}>
{t('home.switchProgram')}
</StyledText>
</Pressable>
)}
</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,
},
// Ambient gradient glow
ambientGlow: {
position: 'absolute',
top: 0,
left: 0,
width: 300,
height: 300,
borderRadius: 150,
},
// Hero Section
heroSection: {
marginTop: SPACING[4],
marginBottom: SPACING[7],
},
heroGreetingRow: {
flexDirection: 'row',
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],
},
// Quick Stats Row
quickStatsRow: {
flexDirection: 'row',
gap: SPACING[3],
marginBottom: SPACING[7],
},
quickStatPill: {
flex: 1,
alignItems: 'center',
paddingVertical: SPACING[4],
borderRadius: RADIUS.GLASS_CARD,
overflow: 'hidden',
borderWidth: 1,
borderColor: colors.border.glass,
borderCurve: 'continuous',
gap: SPACING[1],
backgroundColor: colors.glass.base.backgroundColor,
},
// 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,
},
assessmentContent: {
flexDirection: 'row',
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,
},
})
}