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

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

View File

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

View File

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

View File

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