refactor screens, navigation & player for new architecture

Simplify Home, Activity, Profile, Complete, Player, and Program screens
to work with the new Supabase-driven data layer. Update root and tab
layouts. Add Settings, Terms, and Zone routes. Add OfflineBanner
component and progressStore. Update all player sub-components to use
the refreshed design system tokens.
This commit is contained in:
Millian Lamiaux
2026-04-21 21:50:48 +02:00
parent 04b83fc419
commit 5888aac08e
26 changed files with 1836 additions and 2772 deletions

View File

@@ -1,18 +1,18 @@
/**
* TabataFit Tab Layout
* Native liquid glass tab bar (iOS 26+) — Dark Medical design system
* 3 tabs: Home, Progress, Profile
* Redirects to onboarding if not completed
* TabataGo Tab Layout
* Native liquid glass tab bar (iOS 26+) via expo-router/unstable-native-tabs
* 3 tabs: Home, Activity, Profile
*/
import { Redirect } from 'expo-router'
import { NativeTabs, Icon, Label } from 'expo-router/unstable-native-tabs'
import { useTranslation } from 'react-i18next'
import { BRAND, TEXT, NAVY } from '@/src/shared/constants/colors'
import { useUserStore } from '@/src/shared/stores'
export default function TabLayout() {
const { t } = useTranslation('screens')
const { t } = useTranslation()
const onboardingCompleted = useUserStore((s) => s.profile.onboardingCompleted)
if (!onboardingCompleted) {
@@ -29,19 +29,19 @@ export default function TabLayout() {
color: TEXT.TERTIARY,
}}
>
<NativeTabs.Trigger name="index" options={{ title: t('tabs.home') }}>
<NativeTabs.Trigger name="index" options={{ title: t('screens:tabs.home') }}>
<Icon sf={{ default: 'house', selected: 'house.fill' }} />
<Label>{t('tabs.home')}</Label>
<Label>{t('screens:tabs.home')}</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="activity" options={{ title: t('tabs.progression') }}>
<NativeTabs.Trigger name="activity" options={{ title: t('screens:tabs.progression') }}>
<Icon sf={{ default: 'chart.bar', selected: 'chart.bar.fill' }} />
<Label>{t('tabs.progression')}</Label>
<Label>{t('screens:tabs.progression')}</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="profile" options={{ title: t('tabs.profile') }}>
<NativeTabs.Trigger name="profile" options={{ title: t('screens:tabs.profile') }}>
<Icon sf={{ default: 'person', selected: 'person.fill' }} />
<Label>{t('tabs.profile')}</Label>
<Label>{t('screens:tabs.profile')}</Label>
</NativeTabs.Trigger>
</NativeTabs>
)

View File

@@ -1,580 +1,179 @@
/**
* TabataFit Activity Screen
* Premium stats dashboard — streak, rings, weekly chart, history
* TabataGo Activity Tab
* Streak, weekly sessions, program history — driven by progressStore.
*/
import { View, StyleSheet, ScrollView, Dimensions, Pressable } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { Icon, type IconName } from '@/src/shared/components/Icon'
import { useMemo } from 'react'
import { View, Text, StyleSheet, ScrollView } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useTranslation } from 'react-i18next'
import { useActivityStore, getWeeklyActivity } from '@/src/shared/stores'
import { getWorkoutById } from '@/src/shared/data'
import { ACHIEVEMENTS } from '@/src/shared/data'
import { StyledText } from '@/src/shared/components/StyledText'
import { NativeGauge } from '@/src/shared/components/native'
import { NativeButton } from '@/src/shared/components/native'
import { useThemeColors, BRAND, PHASE } from '@/src/shared/theme'
import { Icon } from '@/src/shared/components/Icon'
import { useProgressStore } from '@/src/shared/stores/progressStore'
import { useThemeColors } from '@/src/shared/theme'
import type { ThemeColors } from '@/src/shared/theme/types'
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { GREEN, NAVY, BORDER_COLORS, TEXT } from '@/src/shared/constants/colors'
const { width: SCREEN_WIDTH } = Dimensions.get('window')
// ═══════════════════════════════════════════════════════════════════════════
// STAT RING — Native SwiftUI Gauge
// ═══════════════════════════════════════════════════════════════════════════
function StatRing({
value,
max,
color,
size = 52,
}: {
value: number
max: number
color: string
size?: number
}) {
return (
<NativeGauge value={value} maxValue={max} color={color} size={size} />
)
}
// ═══════════════════════════════════════════════════════════════════════════
// STAT CARD
// ═══════════════════════════════════════════════════════════════════════════
function StatCard({
label,
value,
max,
color,
icon,
}: {
label: string
value: number
max: number
color: string
icon: IconName
}) {
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
return (
<View style={styles.statCard}>
<View style={styles.statCardInner}>
<StatRing value={value} max={max} color={color} size={52} />
<View style={{ flex: 1, marginLeft: SPACING[3] }}>
<StyledText size={24} weight="bold" color={colors.text.primary}>
{String(value)}
</StyledText>
<StyledText size={12} color={colors.text.tertiary}>
{label}
</StyledText>
</View>
<Icon name={icon} size={18} tintColor={color} />
</View>
</View>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// WEEKLY BAR
// ═══════════════════════════════════════════════════════════════════════════
function WeeklyBar({
day,
completed,
isToday,
}: {
day: string
completed: boolean
isToday: boolean
}) {
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
return (
<View style={styles.weekBarColumn}>
<View style={[styles.weekBar, completed && styles.weekBarFilled]} />
<StyledText
size={11}
weight={isToday ? 'bold' : 'regular'}
color={isToday ? colors.text.primary : colors.text.hint}
>
{day}
</StyledText>
</View>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// EMPTY STATE
// ═══════════════════════════════════════════════════════════════════════════
function EmptyState({ onStartWorkout }: { onStartWorkout: () => void }) {
const { t } = useTranslation()
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
return (
<View style={styles.emptyState}>
<View style={styles.emptyIconCircle}>
<Icon name="flame" size={48} tintColor={GREEN[500]} />
</View>
<StyledText size={22} weight="bold" color={colors.text.primary} style={styles.emptyTitle}>
{t('screens:activity.emptyTitle')}
</StyledText>
<StyledText size={15} color={colors.text.tertiary} style={styles.emptySubtitle}>
{t('screens:activity.emptySubtitle')}
</StyledText>
<View style={{ width: '100%', alignItems: 'center' }}>
<NativeButton
variant="primary"
title={t('screens:activity.startFirstWorkout')}
onPress={onStartWorkout}
systemImage="play.fill"
/>
</View>
</View>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// MAIN SCREEN
// ═══════════════════════════════════════════════════════════════════════════
const DAY_KEYS = ['days.sun', 'days.mon', 'days.tue', 'days.wed', 'days.thu', 'days.fri', 'days.sat'] as const
import { GREEN, NAVY, TEXT, BORDER_COLORS } from '@/src/shared/constants/colors'
export default function ActivityScreen() {
const { t } = useTranslation()
const insets = useSafeAreaInsets()
const router = useRouter()
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
const streak = useActivityStore((s) => s.streak)
const history = useActivityStore((s) => s.history)
const totalWorkouts = history.length
const totalMinutes = useMemo(() => history.reduce((sum, r) => sum + r.durationMinutes, 0), [history])
const totalCalories = useMemo(() => history.reduce((sum, r) => sum + r.calories, 0), [history])
const recentWorkouts = useMemo(() => history.slice(0, 5), [history])
const weeklyActivity = useMemo(() => getWeeklyActivity(history), [history])
const today = new Date().getDay() // 0=Sun
const history = useProgressStore(s => s.history)
const streak = useProgressStore(s => s.streak)
const weeklyCount = useProgressStore(s => s.getWeeklyCount())
const completedCount = useProgressStore(s => s.getCompletedCount())
// Check achievements
const unlockedAchievements = ACHIEVEMENTS.filter(a => {
switch (a.type) {
case 'workouts': return totalWorkouts >= a.requirement
case 'streak': return streak.longest >= a.requirement
case 'minutes': return totalMinutes >= a.requirement
case 'calories': return totalCalories >= a.requirement
default: return false
}
})
const displayAchievements = ACHIEVEMENTS.slice(0, 4).map(a => ({
...a,
unlocked: unlockedAchievements.some(u => u.id === a.id),
}))
const formatDate = (timestamp: number) => {
const now = Date.now()
const diff = now - timestamp
if (diff < 86400000) return t('screens:activity.today')
if (diff < 172800000) return t('screens:activity.yesterday')
return t('screens:activity.daysAgo', { count: Math.floor(diff / 86400000) })
}
const totalMinutes = useMemo(
() => history.reduce((sum, s) => sum + Math.round(s.durationSeconds / 60), 0),
[history],
)
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
<ScrollView
style={styles.scrollView}
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 20 }]}
showsVerticalScrollIndicator={false}
>
{/* Header */}
<StyledText
size={34}
weight="bold"
color={colors.text.primary}
style={{ marginBottom: SPACING[6] }}
>
{t('screens:activity.title')}
</StyledText>
<ScrollView
style={styles.container}
contentContainerStyle={[styles.content, { paddingTop: insets.top + SPACING[4], paddingBottom: insets.bottom + SPACING[6] }]}
contentInsetAdjustmentBehavior="automatic"
>
<Text style={styles.title}>{t('screens:tabs.progression')}</Text>
{/* Empty state when no history */}
{history.length === 0 ? (
<EmptyState onStartWorkout={() => router.push('/(tabs)' as any)} />
) : (
<>
{/* Streak Banner */}
<View style={styles.streakBanner}>
<View style={styles.streakRow}>
<View style={styles.streakIconWrap}>
<Icon name="flame.fill" size={28} tintColor={TEXT.PRIMARY} />
</View>
<View style={{ flex: 1 }}>
<StyledText size={28} weight="bold" color={TEXT.PRIMARY}>
{String(streak.current || 0)}
</StyledText>
<StyledText size={13} color={TEXT.PRIMARY}>
{t('screens:activity.dayStreak')}
</StyledText>
</View>
<View style={styles.streakMeta}>
<StyledText size={11} color={TEXT.SECONDARY}>
{t('screens:activity.longest')}
</StyledText>
<StyledText size={20} weight="bold" color={TEXT.PRIMARY}>
{String(streak.longest)}
</StyledText>
</View>
</View>
</View>
{/* Streak hero */}
<View style={styles.streakHero}>
<Icon name="flame.fill" size={32} tintColor={GREEN[500]} />
<Text selectable style={styles.streakCount}>{streak.current}</Text>
<Text style={styles.streakLabel}>{t('screens:activity.dayStreak')}</Text>
<Text style={styles.streakRecord}>
{t('screens:activity.longest')}: {streak.longest}
</Text>
</View>
{/* Stats Grid — 2x2 */}
<View style={styles.statsGrid}>
<StatCard
label={t('screens:activity.workouts')}
value={totalWorkouts}
max={100}
color={GREEN[500]}
icon="dumbbell"
/>
<StatCard
label={t('screens:activity.minutes')}
value={totalMinutes}
max={300}
color={PHASE.REST}
icon="clock"
/>
<StatCard
label={t('screens:activity.calories')}
value={totalCalories}
max={5000}
color={GREEN[600]}
icon="bolt"
/>
<StatCard
label={t('screens:activity.bestStreak')}
value={streak.longest}
max={30}
color={BRAND.SUCCESS}
icon="arrow.up.right"
/>
</View>
{/* Stats grid */}
<View style={styles.grid}>
<StatCard
icon="checkmark.circle.fill"
value={completedCount}
label={t('screens:programs.completed')}
color={GREEN[500]}
/>
<StatCard
icon="calendar"
value={weeklyCount}
label={t('screens:activity.thisWeek')}
color="#5AC8FA"
/>
<StatCard
icon="clock.fill"
value={totalMinutes}
label={t('screens:player.minutes')}
color="#FF6B35"
/>
</View>
{/* This Week */}
<View style={styles.section}>
<StyledText size={20} weight="semibold" color={colors.text.primary} style={{ marginBottom: SPACING[4] }}>
{t('screens:activity.thisWeek')}
</StyledText>
<View style={styles.weekCard}>
<View style={styles.weekBarsRow}>
{weeklyActivity.map((d, i) => (
<WeeklyBar
key={i}
day={t(DAY_KEYS[i])}
completed={d.completed}
isToday={i === today}
/>
))}
</View>
<View style={styles.weekSummary}>
<StyledText size={13} color={colors.text.tertiary}>
{t('screens:activity.ofDays', { completed: weeklyActivity.filter(d => d.completed).length })}
</StyledText>
</View>
</View>
</View>
{/* Recent Workouts */}
{recentWorkouts.length > 0 && (
<View style={styles.section}>
<StyledText size={20} weight="semibold" color={colors.text.primary} style={{ marginBottom: SPACING[4] }}>
{t('screens:activity.recent')}
</StyledText>
<View style={styles.recentCard}>
{recentWorkouts.map((result, idx) => {
const workout = getWorkoutById(result.workoutId)
const workoutTitle = workout ? t(`content:workouts.${workout.id}`, { defaultValue: workout.title }) : t('screens:activity.workouts')
return (
<View key={result.id}>
<View style={styles.recentRow}>
<View style={styles.recentDot}>
<View style={[styles.dot, { backgroundColor: GREEN[500] }]} />
</View>
<View style={{ flex: 1 }}>
<StyledText size={15} weight="semibold" color={colors.text.primary}>
{workoutTitle}
</StyledText>
<StyledText size={12} color={colors.text.tertiary}>
{formatDate(result.completedAt) + ' \u00B7 ' + t('units.minUnit', { count: result.durationMinutes })}
</StyledText>
</View>
<StyledText size={14} weight="semibold" color={GREEN[500]}>
{t('units.calUnit', { count: result.calories })}
</StyledText>
</View>
{idx < recentWorkouts.length - 1 && <View style={styles.recentDivider} />}
</View>
)
})}
</View>
</View>
)}
{/* Achievements */}
<View style={styles.section}>
<StyledText size={20} weight="semibold" color={colors.text.primary} style={{ marginBottom: SPACING[4] }}>
{t('screens:activity.achievements')}
</StyledText>
<View style={styles.achievementsRow}>
{displayAchievements.map((a) => (
<View key={a.id} style={styles.achievementCard}>
<View
style={[
styles.achievementIcon,
a.unlocked
? { backgroundColor: GREEN.DIM }
: { backgroundColor: colors.bg.overlay1 },
]}
>
<Icon
name={a.unlocked ? 'trophy.fill' : 'lock.fill'}
size={22}
tintColor={a.unlocked ? GREEN[500] : colors.text.hint}
/>
</View>
<StyledText
size={11}
weight="semibold"
color={a.unlocked ? colors.text.primary : colors.text.hint}
numberOfLines={1}
style={{ marginTop: SPACING[2], textAlign: 'center' }}
>
{t(`content:achievements.${a.id}.title`, { defaultValue: a.title })}
</StyledText>
{/* Recent history */}
{history.length > 0 && (
<View style={styles.historySection}>
<Text style={styles.sectionTitle}>{t('screens:activity.recent')}</Text>
{history.slice(0, 10).map((session, i) => (
<View key={i} style={styles.historyRow}>
<Icon name="checkmark.circle.fill" size={18} tintColor={GREEN[500]} />
<View style={{ flex: 1 }}>
<Text style={styles.historyTitle} numberOfLines={1}>{session.programId}</Text>
<Text style={styles.historyMeta}>
{Math.round(session.durationSeconds / 60)} min
{' · '}
{new Date(session.completedAt).toLocaleDateString()}
</Text>
</View>
))}
</View>
</View>
))}
</View>
</>
)}
</ScrollView>
)}
{history.length === 0 && (
<View style={styles.emptyState}>
<Text style={styles.emptyTitle}>{t('screens:activity.emptyTitle')}</Text>
<Text style={styles.emptySubtitle}>{t('screens:activity.emptySubtitle')}</Text>
</View>
)}
</ScrollView>
)
}
function StatCard({ icon, value, label, color }: { icon: any; value: number; label: string; color: string }) {
return (
<View style={cardStyles.card}>
<Icon name={icon} size={22} tintColor={color} />
<Text selectable style={cardStyles.value}>{value}</Text>
<Text style={cardStyles.label}>{label}</Text>
</View>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// STYLES
// ═══════════════════════════════════════════════════════════════════════════
const CARD_HALF = (SCREEN_WIDTH - LAYOUT.SCREEN_PADDING * 2 - SPACING[3]) / 2
const cardStyles = StyleSheet.create({
card: {
flex: 1,
alignItems: 'center',
padding: SPACING[3],
borderRadius: RADIUS.LG,
backgroundColor: NAVY[800],
borderWidth: 1,
borderColor: BORDER_COLORS.DIM,
gap: SPACING[1],
borderCurve: 'continuous',
},
value: { ...TYPOGRAPHY.TITLE_2, color: TEXT.PRIMARY, fontVariant: ['tabular-nums'] },
label: { ...TYPOGRAPHY.CAPTION_2, color: TEXT.TERTIARY, textAlign: 'center' },
})
function createStyles(colors: ThemeColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bg.base,
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingHorizontal: LAYOUT.SCREEN_PADDING,
},
container: { flex: 1, backgroundColor: colors.bg.base },
content: { paddingHorizontal: LAYOUT.SCREEN_PADDING },
// Streak
streakBanner: {
borderRadius: RADIUS.LG,
overflow: 'hidden',
marginBottom: SPACING[5],
backgroundColor: GREEN[500],
title: { ...TYPOGRAPHY.LARGE_TITLE, color: TEXT.PRIMARY, marginBottom: SPACING[5] },
streakHero: {
alignItems: 'center',
paddingVertical: SPACING[6],
marginBottom: SPACING[4],
backgroundColor: NAVY[800],
borderRadius: RADIUS.XL,
borderWidth: 1,
borderColor: BORDER_COLORS.DIM,
gap: SPACING[1],
},
streakRow: {
streakCount: { ...TYPOGRAPHY.LARGE_TITLE, color: TEXT.PRIMARY, fontSize: 56, fontVariant: ['tabular-nums'] },
streakLabel: { ...TYPOGRAPHY.HEADLINE, color: TEXT.SECONDARY },
streakRecord: { ...TYPOGRAPHY.CAPTION_1, color: TEXT.TERTIARY, marginTop: SPACING[1] },
grid: { flexDirection: 'row', gap: SPACING[3], marginBottom: SPACING[6] },
historySection: { gap: SPACING[2] },
sectionTitle: {
...TYPOGRAPHY.CAPTION_1,
color: TEXT.TERTIARY,
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: SPACING[1],
},
historyRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: SPACING[5],
paddingVertical: SPACING[5],
gap: SPACING[4],
},
streakIconWrap: {
width: 48,
height: 48,
borderRadius: RADIUS.FULL,
backgroundColor: NAVY[900],
alignItems: 'center',
justifyContent: 'center',
},
streakMeta: {
alignItems: 'center',
backgroundColor: NAVY[900],
paddingHorizontal: SPACING[4],
paddingVertical: SPACING[2],
gap: SPACING[3],
padding: SPACING[3],
backgroundColor: colors.surface.default.backgroundColor,
borderRadius: RADIUS.MD,
},
// Stats 2x2
statsGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: SPACING[3],
marginBottom: SPACING[6],
},
statCard: {
width: CARD_HALF,
borderRadius: RADIUS.LG,
overflow: 'hidden',
borderWidth: 1,
borderColor: colors.border.dim,
backgroundColor: NAVY[800],
},
statCardInner: {
flexDirection: 'row',
alignItems: 'center',
padding: SPACING[4],
borderColor: colors.surface.default.borderColor,
},
historyTitle: { ...TYPOGRAPHY.SUBHEADLINE, color: TEXT.PRIMARY },
historyMeta: { ...TYPOGRAPHY.CAPTION_1, color: TEXT.TERTIARY, marginTop: 2 },
// Section
section: {
marginBottom: SPACING[6],
},
// Weekly
weekCard: {
borderRadius: RADIUS.LG,
overflow: 'hidden',
borderWidth: 1,
borderColor: colors.border.dim,
backgroundColor: NAVY[800],
paddingTop: SPACING[5],
paddingBottom: SPACING[4],
},
weekBarsRow: {
flexDirection: 'row',
justifyContent: 'space-around',
alignItems: 'flex-end',
paddingHorizontal: SPACING[4],
height: 100,
},
weekBarColumn: {
alignItems: 'center',
flex: 1,
gap: SPACING[2],
},
weekBar: {
width: 24,
height: 60,
borderRadius: RADIUS.SM,
backgroundColor: colors.bg.overlay2,
},
weekBarFilled: {
backgroundColor: GREEN[500],
},
weekSummary: {
alignItems: 'center',
marginTop: SPACING[3],
paddingTop: SPACING[3],
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.bg.overlay2,
marginHorizontal: SPACING[4],
},
// Recent
recentCard: {
borderRadius: RADIUS.LG,
overflow: 'hidden',
borderWidth: 1,
borderColor: colors.border.dim,
backgroundColor: NAVY[800],
paddingVertical: SPACING[2],
},
recentRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: SPACING[4],
paddingVertical: SPACING[3],
},
recentDot: {
width: 24,
alignItems: 'center',
marginRight: SPACING[3],
},
dot: {
width: 8,
height: 8,
borderRadius: RADIUS.SM,
},
recentDivider: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.border.dim,
marginLeft: SPACING[4] + 24 + SPACING[3],
},
// Achievements
achievementsRow: {
flexDirection: 'row',
gap: SPACING[3],
},
achievementCard: {
flex: 1,
aspectRatio: 0.9,
borderRadius: RADIUS.LG,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: colors.border.dim,
backgroundColor: NAVY[800],
overflow: 'hidden',
paddingHorizontal: SPACING[1],
},
achievementIcon: {
width: 44,
height: 44,
borderRadius: RADIUS.FULL,
alignItems: 'center',
justifyContent: 'center',
},
// Empty State
emptyState: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingTop: SPACING[10],
paddingHorizontal: SPACING[6],
},
emptyIconCircle: {
width: 96,
height: 96,
borderRadius: RADIUS.FULL,
backgroundColor: GREEN.DIM,
alignItems: 'center',
justifyContent: 'center',
marginBottom: SPACING[6],
},
emptyTitle: {
textAlign: 'center' as const,
marginBottom: SPACING[2],
},
emptySubtitle: {
textAlign: 'center' as const,
lineHeight: 22,
marginBottom: SPACING[8],
},
emptyCtaButton: {
flexDirection: 'row' as const,
alignItems: 'center' as const,
justifyContent: 'center' as const,
height: 52,
paddingHorizontal: SPACING[8],
borderRadius: RADIUS.LG,
overflow: 'hidden' as const,
backgroundColor: GREEN[500],
},
emptyState: { alignItems: 'center', marginTop: SPACING[12], gap: SPACING[2] },
emptyTitle: { ...TYPOGRAPHY.HEADLINE, color: TEXT.PRIMARY },
emptySubtitle: { ...TYPOGRAPHY.BODY, color: TEXT.TERTIARY, textAlign: 'center' },
})
}

View File

@@ -1,462 +1,212 @@
/**
* TabataFit Home Screen — Body Zone Workout Programs
* Programs organized by Upper Body, Lower Body, Full Body
* Dark Medical design system — navy backgrounds, green actions, no glass
* TabataGo Home Screen
* Mascot + 3 stat pills + 3 body zone cards + settings button.
*/
import { View, StyleSheet, ScrollView, Pressable } from 'react-native'
import { View, Text, StyleSheet, ScrollView, Pressable } from 'react-native'
import { useRouter } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { Icon, type IconName } from '@/src/shared/components/Icon'
import { useMemo, useState, useEffect, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useHaptics } from '@/src/shared/hooks'
import { useUserStore, useActivityStore, getWeeklyActivity } from '@/src/shared/stores'
import { useWorkoutProgramStore } from '@/src/shared/stores'
import { StyledText } from '@/src/shared/components/StyledText'
import { Icon } from '@/src/shared/components/Icon'
import { Mascot } from '@/src/shared/components/Mascot'
import { useThemeColors } from '@/src/shared/theme'
import { BRAND, GREEN, TEXT, NAVY, BORDER_COLORS } from '@/src/shared/constants/colors'
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
import { useUserStore } from '@/src/shared/stores/userStore'
import { useProgressStore } from '@/src/shared/stores/progressStore'
import { BODY_ZONE_META, type BodyZone } from '@/src/shared/types/workoutProgram'
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
import { SPACING } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { fetchAllPrograms, buildWorkoutProgramId } from '@/src/shared/data/workoutPrograms'
import type { WorkoutProgram, BodyZone } from '@/src/shared/types/workoutProgram'
import { BODY_ZONE_META } from '@/src/shared/types/workoutProgram'
import { TEXT, NAVY, GREEN, BORDER_COLORS } from '@/src/shared/constants/colors'
import { withOpacity } from '@/src/shared/utils/color'
// Feature flags — disable incomplete features
const FEATURE_FLAGS = {
ASSESSMENT_ENABLED: false, // Assessment player not yet implemented
}
/** Body zone order for display */
const BODY_ZONE_ORDER: BodyZone[] = ['upper-body', 'lower-body', 'full-body']
const AnimatedPressable = Pressable
// ═══════════════════════════════════════════════════════════════════════════
// BODY ZONE CARD (clickable, navigates to detail)
// ═══════════════════════════════════════════════════════════════════════════
function BodyZoneCard({
bodyZone,
programCount,
}: {
bodyZone: BodyZone
programCount: number
}) {
const router = useRouter()
const haptics = useHaptics()
const colors = useThemeColors()
const meta = BODY_ZONE_META[bodyZone]
const handlePress = () => {
haptics.buttonTap()
router.push(`/workout/body-zone/${bodyZone}` as any)
}
return (
<Pressable
onPress={handlePress}
style={({ pressed }) => [
styles.bodyZoneCard,
{
backgroundColor: colors.surface.default.backgroundColor,
borderColor: colors.border.dim,
opacity: pressed ? 0.85 : 1,
},
]}
testID={`zone-card-${bodyZone}`}
>
<View style={styles.bodyZoneCardInner}>
<View style={[styles.bodyZoneCardIcon, { borderColor: meta.color }]}>
<Icon name={meta.icon as IconName} size={20} tintColor={meta.color} />
</View>
<View style={styles.bodyZoneCardInfo}>
<StyledText preset="TITLE_3" color={colors.text.primary}>
{meta.label}
</StyledText>
<StyledText size={13} color={colors.text.tertiary} style={{ marginTop: SPACING[1] }}>
{programCount} programme{programCount !== 1 ? 's' : ''}
</StyledText>
</View>
<Icon name="chevron.right" size={18} tintColor={colors.text.tertiary} />
</View>
</Pressable>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// CONTINUE SESSION CARD — adapted for workout programs
// ═══════════════════════════════════════════════════════════════════════════
function ContinueSessionCard({ programs }: { programs: WorkoutProgram[] }) {
const { t } = useTranslation('screens')
const router = useRouter()
const haptics = useHaptics()
const colors = useThemeColors()
const recommended = useWorkoutProgramStore(
useCallback((s) => s.getRecommendedNext(programs), [programs])
)
if (!recommended) return null
const zoneMeta = BODY_ZONE_META[recommended.bodyZone]
const accentColor = recommended.accentColor ?? zoneMeta.color
const handlePress = () => {
haptics.buttonTap()
router.push(`/workout/${buildWorkoutProgramId(recommended.id)}` as any)
}
return (
<Pressable style={[styles.continueCard, { borderColor: colors.border.dim }]} onPress={handlePress} testID="continue-session-card">
<View style={[styles.continueAccentLine, { backgroundColor: accentColor }]} />
<View style={styles.continueContent}>
<View style={styles.continueHeader}>
<Icon name="play.circle" size={20} tintColor={accentColor} />
<StyledText preset="CALLOUT" weight="semibold" color={colors.text.primary} style={{ flex: 1, marginLeft: SPACING[2] }}>
{t('home.recommendedNext')}
</StyledText>
</View>
<StyledText preset="HEADLINE" color={colors.text.primary}>
{recommended.title}
</StyledText>
<StyledText size={13} color={colors.text.tertiary} style={{ marginTop: SPACING[1] }}>
{zoneMeta.label} · {recommended.estimatedDuration} min · ~{recommended.estimatedCalories} kcal
</StyledText>
</View>
</Pressable>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// QUICK STATS ROW
// ═══════════════════════════════════════════════════════════════════════════
function QuickStats() {
const { t } = useTranslation('screens')
const colors = useThemeColors()
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: GREEN['500'] },
{ icon: 'calendar' as const, value: `${thisWeekCount}/7`, label: t('home.statsThisWeek'), color: BRAND.INFO },
{ icon: 'clock' as const, value: totalMinutes, label: t('home.statsMinutes'), color: GREEN['500'] },
]
return (
<View style={styles.quickStatsRow}>
{stats.map((stat) => (
<View key={stat.label} style={styles.quickStatPill}>
<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>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// KINE LINK CARD (bottom link to physio programs)
// ═══════════════════════════════════════════════════════════════════════════
function TabataLinkCard() {
const { t } = useTranslation('screens')
const router = useRouter()
const haptics = useHaptics()
const colors = useThemeColors()
const handlePress = () => {
haptics.buttonTap()
router.push('/program/debutant' as any)
}
return (
<Pressable
onPress={handlePress}
style={({ pressed }) => [
styles.tabataLinkCard,
{
backgroundColor: colors.surface.default.backgroundColor,
borderColor: colors.border.dim,
opacity: pressed ? 0.85 : 1,
},
]}
>
<View style={styles.tabataLinkLeft}>
<View style={[styles.tabataLinkIcon, { backgroundColor: GREEN.DIM }]}>
<Icon name="heart.text.square" size={22} tintColor={GREEN['500']} />
</View>
<View style={styles.tabataLinkText}>
<StyledText preset="HEADLINE" color={colors.text.primary}>
{t('home.tabataPrograms')}
</StyledText>
<StyledText size={13} color={colors.text.tertiary} style={{ marginTop: SPACING[1] }}>
{t('home.tabataProgramsSubtitle')}
</StyledText>
</View>
</View>
<Icon name="chevron.right" size={18} tintColor={colors.text.tertiary} />
</Pressable>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// MAIN SCREEN
// ═══════════════════════════════════════════════════════════════════════════
const BODY_ZONES: BodyZone[] = ['upper-body', 'lower-body', 'full-body']
export default function HomeScreen() {
const { t } = useTranslation('screens')
const insets = useSafeAreaInsets()
const router = useRouter()
const colors = useThemeColors()
const userName = useUserStore((s) => s.profile.name)
const streak = useActivityStore((s) => s.streak)
// Fetch workout programs
const [programs, setPrograms] = useState<WorkoutProgram[]>([])
const insets = useSafeAreaInsets()
const { t } = useTranslation()
useEffect(() => {
fetchAllPrograms().then(setPrograms)
}, [])
const firstName = useUserStore(s => s.profile.name)
const streak = useProgressStore(s => s.streak.current)
const weeklyCount = useProgressStore(s => s.getWeeklyCount())
const completedCount = useProgressStore(s => s.getCompletedCount())
// Group programs by body zone
const programsByZone = useMemo(() => {
const grouped: Record<BodyZone, WorkoutProgram[]> = {
'upper-body': [],
'lower-body': [],
'full-body': [],
}
for (const program of programs) {
if (grouped[program.bodyZone]) {
grouped[program.bodyZone].push(program)
}
}
return grouped
}, [programs])
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 nameSuffix = firstName ? `, ${firstName}` : ''
const mascotMessage = streak > 0
? t('screens:home.mascotStreak', { count: streak, name: nameSuffix })
: t('screens:home.mascotReady', { name: nameSuffix })
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
<ScrollView
style={styles.scrollView}
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 20 }]}
showsVerticalScrollIndicator={false}
>
{/* Hero Section */}
<View style={styles.heroSection}>
<View style={styles.heroRow}>
<View style={styles.heroTextContent}>
<View style={styles.heroGreetingRow}>
<StyledText preset="CALLOUT" color={colors.text.tertiary}>
{greeting}
</StyledText>
{/* Inline streak badge */}
{streak.current > 0 && (
<View style={styles.streakBadge}>
<Icon name="flame.fill" size={13} tintColor={GREEN['500']} />
<StyledText size={12} weight="bold" color={GREEN['500']} style={{ fontVariant: ['tabular-nums'] }}>
{streak.current}
</StyledText>
</View>
)}
</View>
<StyledText preset="LARGE_TITLE" color={colors.text.primary} style={styles.heroName}>
{userName}
</StyledText>
<StyledText preset="FOOTNOTE" color={colors.text.secondary} style={styles.heroSubtitle}>
{t('home.programsByZone')}
</StyledText>
</View>
<Mascot size={90} style={{ marginLeft: SPACING[3], marginTop: -15 }} />
</View>
</View>
<ScrollView
style={styles.container}
contentContainerStyle={[styles.content, { paddingTop: insets.top + SPACING[4], paddingBottom: insets.bottom + SPACING[6] }]}
>
{/* Header with settings */}
<View style={styles.header}>
<Text style={styles.brand}>TabataGo</Text>
<Pressable onPress={() => router.push('/settings')} style={styles.iconBtn} hitSlop={8}>
<Icon name="gearshape" size={22} tintColor={TEXT.PRIMARY} />
</Pressable>
</View>
{/* Quick Stats Row */}
<QuickStats />
{/* Mascot */}
<View style={styles.mascotWrap}>
<Mascot message={mascotMessage} />
</View>
{/* Continue Session (if in progress) */}
<ContinueSessionCard programs={programs} />
{/* Stats pills */}
<View style={styles.statsRow}>
<StatPill value={streak} label={t('screens:home.statsStreak')} icon="flame.fill" color="#FF6B35" />
<StatPill value={weeklyCount} label={t('screens:home.statsThisWeek')} icon="calendar" color={GREEN[500]} />
<StatPill value={completedCount} label={t('screens:home.statsCompleted')} icon="checkmark.seal.fill" color="#5AC8FA" />
</View>
{/* Body Zone Cards */}
{BODY_ZONE_ORDER.map((zone) => (
<BodyZoneCard
key={zone}
bodyZone={zone}
programCount={programsByZone[zone].length}
/>
{/* Body zone cards */}
<Text style={styles.sectionTitle}>{t('screens:zone.chooseYourFocus')}</Text>
<View style={styles.zoneList}>
{BODY_ZONES.map(zone => (
<ZoneCard key={zone} zone={zone} onPress={() => router.push(`/zone/${zone}`)} />
))}
</View>
</ScrollView>
)
}
{/* Tabata Programs Link */}
<TabataLinkCard />
</ScrollView>
function StatPill({
value,
label,
icon,
color,
}: {
value: number
label: string
icon: any
color: string
}) {
return (
<View style={[styles.pill, { borderColor: withOpacity(color, 0.4) }]}>
<Icon name={icon} size={16} tintColor={color} />
<Text style={styles.pillValue}>{value}</Text>
<Text style={styles.pillLabel}>{label}</Text>
</View>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// STYLES
// ═══════════════════════════════════════════════════════════════════════════
function ZoneCard({ zone, onPress }: { zone: BodyZone; onPress: () => void }) {
const meta = BODY_ZONE_META[zone]
const { t } = useTranslation()
return (
<Pressable
onPress={onPress}
style={({ pressed }) => [
styles.zoneCard,
{ borderColor: withOpacity(meta.color, 0.3) },
pressed && { opacity: 0.85, transform: [{ scale: 0.98 }] },
]}
>
{/* Colored top strip with large icon */}
<View style={[styles.zoneTopStrip, { backgroundColor: withOpacity(meta.color, 0.12) }]}>
<View style={[styles.zoneIconCircle, { backgroundColor: withOpacity(meta.color, 0.22) }]}>
<Icon name={meta.icon as any} size={34} tintColor={meta.color} />
</View>
</View>
{/* Content area */}
<View style={styles.zoneContent}>
<Text style={styles.zoneTitle}>{meta.label}</Text>
<Text style={styles.zoneDesc} numberOfLines={2}>
{t(meta.descKey)}
</Text>
{/* Bottom row: level badge + chevron */}
<View style={styles.zoneFooter}>
<View style={[styles.zoneBadge, { backgroundColor: withOpacity(meta.color, 0.15) }]}>
<Text style={[styles.zoneBadgeText, { color: meta.color }]}>
{t('screens:home.zoneLevels')}
</Text>
</View>
<Icon name="chevron.right" size={16} tintColor={TEXT.TERTIARY} />
</View>
</View>
</Pressable>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: NAVY[900],
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingHorizontal: LAYOUT.SCREEN_PADDING,
},
container: { flex: 1, backgroundColor: NAVY[900] },
content: { paddingHorizontal: SPACING[5] },
// Hero Section
heroSection: {
marginTop: SPACING[4],
marginBottom: SPACING[7],
},
heroRow: {
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
},
heroTextContent: {
flex: 1,
},
heroGreetingRow: {
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: SPACING[4],
},
streakBadge: {
flexDirection: 'row',
brand: { ...TYPOGRAPHY.TITLE_1, color: TEXT.PRIMARY, letterSpacing: -0.5 },
iconBtn: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
gap: SPACING[1],
paddingHorizontal: SPACING[3],
paddingVertical: SPACING[1],
borderRadius: RADIUS.PILL,
backgroundColor: GREEN.DIM,
borderWidth: 1,
borderColor: GREEN.BORDER,
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.LG,
justifyContent: 'center',
backgroundColor: NAVY[800],
borderWidth: 1,
borderColor: BORDER_COLORS.DIM,
borderCurve: 'continuous',
gap: SPACING[1],
backgroundColor: NAVY[800],
},
// Continue Session Card
continueCard: {
borderRadius: RADIUS.XL,
marginBottom: SPACING[7],
overflow: 'hidden',
borderWidth: 1,
borderCurve: 'continuous',
backgroundColor: NAVY[800],
},
continueAccentLine: {
height: 3,
width: '100%',
},
continueContent: {
padding: SPACING[5],
},
continueHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: SPACING[3],
},
mascotWrap: { alignItems: 'center', marginVertical: SPACING[4] },
// Body Zone Card
bodyZoneCard: {
statsRow: { flexDirection: 'row', gap: SPACING[2], marginBottom: SPACING[6] },
pill: {
flex: 1,
alignItems: 'center',
paddingVertical: SPACING[3],
paddingHorizontal: SPACING[2],
borderRadius: RADIUS.MD,
borderWidth: 1,
backgroundColor: NAVY[800],
gap: 4,
},
pillValue: { ...TYPOGRAPHY.TITLE_2, color: TEXT.PRIMARY, fontVariant: ['tabular-nums'] },
pillLabel: { ...TYPOGRAPHY.CAPTION_2, color: TEXT.TERTIARY, textAlign: 'center' },
sectionTitle: { ...TYPOGRAPHY.HEADLINE, color: TEXT.PRIMARY, marginBottom: SPACING[3] },
zoneList: { gap: SPACING[4] },
zoneCard: {
borderRadius: RADIUS.XL,
borderWidth: 1,
borderCurve: 'continuous',
marginBottom: SPACING[3],
backgroundColor: NAVY[800],
overflow: 'hidden' as const,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.3)',
},
bodyZoneCardInner: {
flexDirection: 'row',
alignItems: 'center',
zoneTopStrip: {
alignItems: 'center' as const,
justifyContent: 'center' as const,
paddingVertical: SPACING[5],
},
zoneIconCircle: {
width: 72,
height: 72,
borderRadius: 36,
alignItems: 'center' as const,
justifyContent: 'center' as const,
},
zoneContent: {
padding: SPACING[4],
gap: SPACING[3],
gap: SPACING[2],
},
bodyZoneCardIcon: {
width: 44,
height: 44,
borderRadius: RADIUS.FULL,
borderWidth: 1.5,
borderCurve: 'continuous',
backgroundColor: NAVY[800],
alignItems: 'center',
justifyContent: 'center',
zoneTitle: { ...TYPOGRAPHY.TITLE_2, color: TEXT.PRIMARY },
zoneDesc: { ...TYPOGRAPHY.SUBHEADLINE, color: TEXT.SECONDARY, lineHeight: 20 },
zoneFooter: {
flexDirection: 'row' as const,
alignItems: 'center' as const,
justifyContent: 'space-between' as const,
marginTop: SPACING[1],
},
bodyZoneCardInfo: {
flex: 1,
},
// Tabata Link Card
tabataLinkCard: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: SPACING[4],
borderRadius: RADIUS.XL,
borderWidth: 1,
borderCurve: 'continuous',
marginBottom: SPACING[6],
},
tabataLinkLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
gap: SPACING[3],
},
tabataLinkIcon: {
width: 44,
height: 44,
borderRadius: RADIUS.LG,
borderCurve: 'continuous',
alignItems: 'center',
justifyContent: 'center',
},
tabataLinkText: {
flex: 1,
zoneBadge: {
paddingHorizontal: SPACING[3],
paddingVertical: SPACING[1],
borderRadius: RADIUS.SM,
},
zoneBadgeText: { ...TYPOGRAPHY.CAPTION_1, fontWeight: '600' as const },
})

View File

@@ -1,372 +1,181 @@
/**
* TabataFit Profile Screen — Native iOS
* Dark Medical design with SwiftUI Islands
* TabataGo Profile Tab
* User info, subscription status, quick stats. Settings via form sheet.
*/
import { useMemo } from 'react'
import { View, Text, StyleSheet, ScrollView, Pressable } from 'react-native'
import { useRouter } from 'expo-router'
import {
View,
ScrollView,
StyleSheet,
Pressable,
} from 'react-native'
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, useState } from 'react'
import { useUserStore, useActivityStore } from '@/src/shared/stores'
import { requestNotificationPermissions, usePurchases } from '@/src/shared/hooks'
import { Icon } from '@/src/shared/components/Icon'
import { useUserStore } from '@/src/shared/stores/userStore'
import { useProgressStore } from '@/src/shared/stores/progressStore'
import { usePurchases } from '@/src/shared/hooks'
import { useThemeColors } from '@/src/shared/theme'
import type { ThemeColors } from '@/src/shared/theme/types'
import { StyledText } from '@/src/shared/components/StyledText'
import { SPACING } from '@/src/shared/constants/spacing'
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { DataDeletionModal } from '@/src/shared/components/DataDeletionModal'
import { deleteSyncedData } from '@/src/shared/services/sync'
import { GREEN, NAVY, TEXT } from '@/src/shared/constants/colors'
import { FONT_FAMILY } from '@/src/shared/constants/typography'
import {
NativeList,
NativeSection,
NativeSwitch,
NativeLabeledRow,
NativeButton,
} from '@/src/shared/components/native'
// ═══════════════════════════════════════════════════════════════════════════
// COMPONENT: PROFILE SCREEN
// ═══════════════════════════════════════════════════════════════════════════
import { GREEN, NAVY, TEXT, BORDER_COLORS } from '@/src/shared/constants/colors'
export default function ProfileScreen() {
const { t } = useTranslation('screens')
const { t } = useTranslation()
const router = useRouter()
const insets = useSafeAreaInsets()
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
const profile = useUserStore((s) => s.profile)
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'
const profile = useUserStore(s => s.profile)
const { isPremium } = usePurchases()
const history = useActivityStore((s) => s.history)
const streak = useActivityStore((s) => s.streak)
const stats = useMemo(() => ({
workouts: history.length,
streak: streak.current,
calories: history.reduce((sum, r) => sum + (r.calories ?? 0), 0),
}), [history, streak])
const completedCount = useProgressStore(s => s.getCompletedCount())
const streak = useProgressStore(s => s.streak)
const weeklyCount = useProgressStore(s => s.getWeeklyCount())
const handleSignOut = () => {
updateProfile({
name: '',
email: '',
subscription: 'free',
onboardingCompleted: false,
})
router.replace('/onboarding')
}
const handleRestore = async () => {
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()
if (!granted) return
}
updateSettings({ reminders: enabled })
}
const handleRateApp = () => {
Linking.openURL('https://apps.apple.com/app/tabatafit/id1234567890')
}
const handleContactUs = () => {
Linking.openURL('mailto:contact@tabatafit.app')
}
const handlePrivacyPolicy = () => {
router.push('/privacy')
}
const handleFAQ = () => {
Linking.openURL('https://tabatafit.app/faq')
}
const appVersion = Constants.expoConfig?.version ?? '1.0.0'
const avatarLetter = profile.name?.[0]?.toUpperCase() || '?'
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
<ScrollView
style={styles.scrollView}
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 40 }]}
showsVerticalScrollIndicator={false}
>
{/* ════════════════════════════════════════════════════════════════════
PROFILE HEADER
═══════════════════════════════════════════════════════════════════ */}
<View style={styles.headerContainer}>
<View style={styles.avatarContainer}>
<StyledText size={48} weight="bold" color={TEXT.PRIMARY}>
{avatarInitial}
</StyledText>
<ScrollView
style={styles.container}
contentContainerStyle={[styles.content, { paddingTop: insets.top + SPACING[4], paddingBottom: insets.bottom + SPACING[6] }]}
contentInsetAdjustmentBehavior="automatic"
>
{/* Avatar + name */}
<View style={styles.profileHeader}>
<View style={styles.avatar}>
<Text style={styles.avatarLetter}>{avatarLetter}</Text>
</View>
<View style={styles.nameContainer}>
<StyledText size={22} weight="semibold" style={{ textAlign: 'center' }}>
{profile.name || t('profile.guest')}
</StyledText>
<View style={styles.planContainer}>
<StyledText size={15} color={isPremium ? GREEN[500] : colors.text.tertiary}>
{planLabel}
</StyledText>
{isPremium && (
<StyledText size={12} color={GREEN[500]}></StyledText>
)}
</View>
</View>
<View style={styles.statsContainer}>
<View style={styles.statItem}>
<StyledText size={20} weight="bold" color={GREEN[500]} style={{ textAlign: 'center' }}>
🔥 {stats.workouts}
</StyledText>
<StyledText size={12} color={colors.text.tertiary} style={{ textAlign: 'center' }}>
{t('profile.statsWorkouts')}
</StyledText>
</View>
<View style={styles.statItem}>
<StyledText size={20} weight="bold" color={GREEN[500]} style={{ textAlign: 'center' }}>
📅 {stats.streak}
</StyledText>
<StyledText size={12} color={colors.text.tertiary} style={{ textAlign: 'center' }}>
{t('profile.statsStreak')}
</StyledText>
</View>
<View style={styles.statItem}>
<StyledText size={20} weight="bold" color={GREEN[500]} style={{ textAlign: 'center' }}>
{Math.round(stats.calories / 1000)}k
</StyledText>
<StyledText size={12} color={colors.text.tertiary} style={{ textAlign: 'center' }}>
{t('profile.statsCalories')}
</StyledText>
<View style={{ flex: 1 }}>
<Text style={styles.name}>{profile.name || t('screens:profile.guest')}</Text>
<View style={[styles.planBadge, { borderColor: isPremium ? GREEN[500] : BORDER_COLORS.DIM }]}>
<Text style={[styles.planText, { color: isPremium ? GREEN[500] : TEXT.TERTIARY }]}>
{isPremium ? t('screens:settings.premium') : t('screens:settings.free')}
</Text>
</View>
</View>
<Pressable onPress={() => router.push('/settings')} hitSlop={8}>
<Icon name="gearshape.fill" size={22} tintColor={TEXT.TERTIARY} />
</Pressable>
</View>
{/* ════════════════════════════════════════════════════════════════════
UPGRADE CTA (FREE USERS ONLY)
═══════════════════════════════════════════════════════════════════ */}
{/* Stats row */}
<View style={styles.statsRow}>
<StatPill value={streak.current} label={t('screens:home.statsStreak')} icon="flame.fill" color="#FF6B35" />
<StatPill value={weeklyCount} label={t('screens:activity.thisWeek')} icon="calendar" color="#5AC8FA" />
<StatPill value={completedCount} label={t('screens:home.statsCompleted')} icon="checkmark.seal.fill" color={GREEN[500]} />
</View>
{/* Upgrade banner (free users) */}
{!isPremium && (
<View style={styles.upgradeCard}>
<Pressable onPress={() => router.push('/paywall')}>
<View style={styles.premiumContent}>
<StyledText size={17} weight="semibold" color={GREEN[500]}>
{t('profile.upgradeTitle')}
</StyledText>
<StyledText size={15} color={colors.text.tertiary} style={{ marginTop: SPACING[1] }}>
{t('profile.upgradeDescription')}
</StyledText>
</View>
<StyledText size={15} color={GREEN[500]} style={{ marginTop: SPACING[3] }}>
{t('profile.learnMore')}
</StyledText>
</Pressable>
</View>
<Pressable
style={[styles.upgradeBanner, { borderColor: GREEN[500] }]}
onPress={() => router.push('/paywall')}
>
<Icon name="sparkles" size={20} tintColor={GREEN[500]} />
<View style={{ flex: 1 }}>
<Text style={styles.upgradeTitle}>{t('screens:profile.upgradeTitle')}</Text>
<Text style={styles.upgradeDesc}>{t('screens:profile.upgradeDescription')}</Text>
</View>
<Icon name="chevron.right" size={16} tintColor={TEXT.TERTIARY} />
</Pressable>
)}
{/* ════════════════════════════════════════════════════════════════════
WORKOUT SETTINGS — Native List
═══════════════════════════════════════════════════════════════════ */}
<NativeList>
<NativeSection title={t('profile.sectionWorkout').toUpperCase()}>
<NativeLabeledRow label={t('profile.hapticFeedback')}>
<NativeSwitch
value={settings.haptics}
onValueChange={(v) => updateSettings({ haptics: v })}
/>
</NativeLabeledRow>
<NativeLabeledRow label={t('profile.soundEffects')}>
<NativeSwitch
value={settings.soundEffects}
onValueChange={(v) => updateSettings({ soundEffects: v })}
/>
</NativeLabeledRow>
<NativeLabeledRow label={t('profile.voiceCoaching')}>
<NativeSwitch
value={settings.voiceCoaching}
onValueChange={(v) => updateSettings({ voiceCoaching: v })}
/>
</NativeLabeledRow>
</NativeSection>
</NativeList>
{/* Settings link */}
<Pressable style={styles.settingsRow} onPress={() => router.push('/settings')}>
<Icon name="gearshape" size={20} tintColor={TEXT.SECONDARY} />
<Text style={styles.settingsLabel}>{t('screens:settings.title')}</Text>
<Icon name="chevron.right" size={16} tintColor={TEXT.TERTIARY} />
</Pressable>
</ScrollView>
)
}
{/* ════════════════════════════════════════════════════════════════════
NOTIFICATIONS — Native List
═══════════════════════════════════════════════════════════════════ */}
<NativeList>
<NativeSection title={t('profile.sectionNotifications').toUpperCase()}>
<NativeLabeledRow label={t('profile.dailyReminders')}>
<NativeSwitch
value={settings.reminders}
onValueChange={handleReminderToggle}
/>
</NativeLabeledRow>
{settings.reminders && (
<NativeLabeledRow label={t('profile.reminderTime')} value={settings.reminderTime} />
)}
</NativeSection>
</NativeList>
{/* ════════════════════════════════════════════════════════════════════
PERSONALIZATION (PREMIUM ONLY)
═══════════════════════════════════════════════════════════════════ */}
{isPremium && (
<NativeList>
<NativeSection title={t('profile.sectionPersonalization').toUpperCase()}>
<NativeLabeledRow
label={
profile.syncStatus === 'synced'
? t('profile.personalizationEnabled')
: t('profile.personalizationDisabled')
}
value={profile.syncStatus === 'synced' ? '✓' : '○'}
/>
</NativeSection>
</NativeList>
)}
{/* ════════════════════════════════════════════════════════════════════
ABOUT — Native List
═══════════════════════════════════════════════════════════════════ */}
<NativeList>
<NativeSection title={t('profile.sectionAbout').toUpperCase()}>
<NativeLabeledRow
label="Programmes Kiné"
value="Rééducation et physiothérapie"
chevron
onPress={() => router.push('/program/debutant' as any)}
/>
<NativeLabeledRow label={t('profile.version')} value={appVersion} />
<NativeLabeledRow label={t('profile.rateApp')} chevron onPress={handleRateApp} />
<NativeLabeledRow label={t('profile.contactUs')} chevron onPress={handleContactUs} />
<NativeLabeledRow label={t('profile.faq')} chevron onPress={handleFAQ} />
<NativeLabeledRow label={t('profile.privacyPolicy')} chevron onPress={handlePrivacyPolicy} />
</NativeSection>
</NativeList>
{/* ════════════════════════════════════════════════════════════════════
ACCOUNT (PREMIUM ONLY)
═══════════════════════════════════════════════════════════════════ */}
{isPremium && (
<NativeList>
<NativeSection title={t('profile.sectionAccount').toUpperCase()}>
<NativeLabeledRow label={t('profile.restorePurchases')} chevron onPress={handleRestore} />
</NativeSection>
</NativeList>
)}
{/* ════════════════════════════════════════════════════════════════════
SIGN OUT — Native Button
═══════════════════════════════════════════════════════════════════ */}
<View style={styles.signOutContainer}>
<NativeButton
variant="destructive"
title={t('profile.signOut')}
onPress={handleSignOut}
/>
</View>
</ScrollView>
<DataDeletionModal
visible={showDeleteModal}
onDelete={handleDeleteData}
onCancel={() => setShowDeleteModal(false)}
/>
function StatPill({ value, label, icon, color }: { value: number; label: string; icon: any; color: string }) {
return (
<View style={pillStyles.pill}>
<Icon name={icon} size={18} tintColor={color} />
<Text selectable style={pillStyles.value}>{value}</Text>
<Text style={pillStyles.label}>{label}</Text>
</View>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// STYLES
// ═══════════════════════════════════════════════════════════════════════════
const pillStyles = StyleSheet.create({
pill: {
flex: 1,
alignItems: 'center',
paddingVertical: SPACING[3],
borderRadius: RADIUS.MD,
borderWidth: 1,
backgroundColor: NAVY[800],
borderColor: BORDER_COLORS.DIM,
gap: 4,
},
value: { ...TYPOGRAPHY.TITLE_2, color: TEXT.PRIMARY, fontVariant: ['tabular-nums'] },
label: { ...TYPOGRAPHY.CAPTION_2, color: TEXT.TERTIARY, textAlign: 'center' },
})
function createStyles(colors: ThemeColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bg.base,
},
scrollView: {
flex: 1,
},
scrollContent: {
flexGrow: 1,
},
headerContainer: {
alignItems: 'center',
paddingVertical: SPACING[6],
paddingHorizontal: SPACING[4],
},
avatarContainer: {
width: 90,
height: 90,
borderRadius: RADIUS.FULL,
backgroundColor: NAVY[700],
justifyContent: 'center',
alignItems: 'center',
},
nameContainer: {
marginTop: SPACING[4],
alignItems: 'center',
},
planContainer: {
container: { flex: 1, backgroundColor: colors.bg.base },
content: { paddingHorizontal: LAYOUT.SCREEN_PADDING },
profileHeader: {
flexDirection: 'row',
alignItems: 'center',
marginTop: SPACING[1],
gap: SPACING[1],
gap: SPACING[3],
marginBottom: SPACING[5],
},
statsContainer: {
flexDirection: 'row',
justifyContent: 'center',
marginTop: SPACING[4],
gap: SPACING[8],
},
statItem: {
avatar: {
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: NAVY[700] ?? NAVY[800],
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2,
borderColor: BORDER_COLORS.DIM,
},
upgradeCard: {
marginHorizontal: SPACING[5],
paddingVertical: SPACING[4],
paddingHorizontal: SPACING[4],
backgroundColor: NAVY[800],
borderRadius: RADIUS.LG,
borderCurve: 'continuous' as const,
avatarLetter: { ...TYPOGRAPHY.TITLE_1, color: TEXT.PRIMARY },
name: { ...TYPOGRAPHY.TITLE_2, color: TEXT.PRIMARY },
planBadge: {
alignSelf: 'flex-start',
marginTop: 4,
paddingHorizontal: SPACING[2],
paddingVertical: 2,
borderRadius: RADIUS.SM,
borderWidth: 1,
borderColor: colors.border.dim,
},
premiumContent: {
gap: SPACING[1],
planText: { ...TYPOGRAPHY.CAPTION_2, fontWeight: '600' },
statsRow: { flexDirection: 'row', gap: SPACING[2], marginBottom: SPACING[5] },
upgradeBanner: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING[3],
padding: SPACING[4],
borderRadius: RADIUS.LG,
borderWidth: 1,
backgroundColor: colors.surface.default.backgroundColor,
marginBottom: SPACING[3],
borderCurve: 'continuous',
},
signOutContainer: {
marginTop: SPACING[5],
marginHorizontal: SPACING[5],
upgradeTitle: { ...TYPOGRAPHY.HEADLINE, color: TEXT.PRIMARY },
upgradeDesc: { ...TYPOGRAPHY.CAPTION_1, color: TEXT.SECONDARY, marginTop: 2 },
settingsRow: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING[3],
padding: SPACING[4],
borderRadius: RADIUS.LG,
borderWidth: 1,
borderColor: BORDER_COLORS.DIM,
backgroundColor: colors.surface.default.backgroundColor,
borderCurve: 'continuous',
},
settingsLabel: { ...TYPOGRAPHY.BODY, color: TEXT.PRIMARY, flex: 1 },
})
}

View File

@@ -1,33 +1,26 @@
/**
* TabataFit Root Layout
* Expo Router v3 + Inter font loading
* Waits for font + store hydration before rendering
* Expo Router v3 — SF Pro system font (no custom font loading)
* Waits for store hydration before rendering
*/
import '@/src/shared/i18n'
import '@/src/shared/i18n/types'
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, Component } from 'react'
import { Stack } from 'expo-router'
import { StatusBar } from 'expo-status-bar'
import { View } from 'react-native'
import { View, Text, Pressable, StyleSheet } from 'react-native'
import * as SplashScreen from 'expo-splash-screen'
import * as Notifications from 'expo-notifications'
import {
useFonts,
Inter_400Regular,
Inter_500Medium,
Inter_600SemiBold,
Inter_700Bold,
Inter_900Black,
} from '@expo-google-fonts/inter'
import { PostHogProvider } from 'posthog-react-native'
import { ThemeProvider, useThemeColors } from '@/src/shared/theme'
import { TEXT, NAVY } from '@/src/shared/constants/colors'
import { TEXT, NAVY, GREEN } from '@/src/shared/constants/colors'
import { useUserStore } from '@/src/shared/stores'
import { useNotifications } from '@/src/shared/hooks'
import { OfflineBanner } from '@/src/shared/components/OfflineBanner'
import { initializePurchases } from '@/src/shared/services/purchases'
import { initializeAnalytics, getPostHogClient, trackScreen } from '@/src/shared/services/analytics'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
@@ -44,6 +37,84 @@ Notifications.setNotificationHandler({
SplashScreen.preventAutoHideAsync()
// ─── Error Boundary (F-108) ────────────────────────────────────────────────
interface ErrorBoundaryState {
hasError: boolean
error: Error | null
}
class ErrorBoundary extends Component<{ children: React.ReactNode }, ErrorBoundaryState> {
state: ErrorBoundaryState = { hasError: false, error: null }
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error }
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error('ErrorBoundary caught:', error, info.componentStack)
}
private handleRetry = () => {
this.setState({ hasError: false, error: null })
}
render() {
if (this.state.hasError) {
return (
<View style={errorStyles.container}>
<Text style={errorStyles.emoji}></Text>
<Text style={errorStyles.title}>Something went wrong</Text>
<Text style={errorStyles.message}>
{this.state.error?.message ?? 'An unexpected error occurred.'}
</Text>
<Pressable style={errorStyles.button} onPress={this.handleRetry}>
<Text style={errorStyles.buttonText}>Try again</Text>
</Pressable>
</View>
)
}
return this.props.children
}
}
const errorStyles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: NAVY[900],
alignItems: 'center',
justifyContent: 'center',
padding: 32,
},
emoji: { fontSize: 48, marginBottom: 16 },
title: {
fontSize: 22,
fontWeight: '700',
color: TEXT.PRIMARY,
marginBottom: 8,
},
message: {
fontSize: 15,
fontWeight: '400',
color: TEXT.SECONDARY,
textAlign: 'center',
marginBottom: 24,
lineHeight: 20,
},
button: {
backgroundColor: '#00C896',
paddingHorizontal: 32,
paddingVertical: 14,
borderRadius: 12,
borderCurve: 'continuous',
minHeight: 44,
},
buttonText: {
fontSize: 17,
fontWeight: '600',
color: NAVY[900],
},
})
// Create React Query Client
const queryClient = new QueryClient({
defaultOptions: {
@@ -59,14 +130,6 @@ const queryClient = new QueryClient({
function RootLayoutInner() {
const colors = useThemeColors()
const [fontsLoaded] = useFonts({
Inter_400Regular,
Inter_500Medium,
Inter_600SemiBold,
Inter_700Bold,
Inter_900Black,
})
useNotifications()
// Wait for persisted store to hydrate from AsyncStorage
@@ -90,12 +153,12 @@ function RootLayoutInner() {
}, [hydrated])
const onLayoutRootView = useCallback(async () => {
if (fontsLoaded && hydrated) {
if (hydrated) {
await SplashScreen.hideAsync()
}
}, [fontsLoaded, hydrated])
}, [hydrated])
if (!fontsLoaded || !hydrated) {
if (!hydrated) {
return null
}
@@ -103,85 +166,62 @@ function RootLayoutInner() {
<QueryClientProvider client={queryClient}>
<View style={{ flex: 1, backgroundColor: colors.bg.base }} onLayout={onLayoutRootView}>
<StatusBar style={colors.statusBarStyle} />
<OfflineBanner />
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.bg.base },
animation: 'slide_from_right',
animation: 'default',
headerBackButtonDisplayMode: 'minimal',
headerTintColor: GREEN[500],
headerStyle: { backgroundColor: colors.bg.base },
headerShadowVisible: false,
headerTitleStyle: { fontWeight: '600', fontSize: 17, color: colors.text.primary },
}}
>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="onboarding"
options={{
animation: 'fade',
}}
options={{ animation: 'fade' }}
/>
<Stack.Screen
name="workout/[id]"
options={{
headerShown: true,
headerStyle: { backgroundColor: colors.bg.base },
headerShadowVisible: false,
headerTitle: '',
headerBackButtonDisplayMode: 'minimal',
headerTintColor: colors.text.primary,
animation: 'slide_from_right',
}}
/>
<Stack.Screen
name="workout/category/[id]"
options={{
animation: 'slide_from_right',
}}
/>
<Stack.Screen
name="workout/body-zone/[id]"
options={{
headerShown: true,
headerStyle: { backgroundColor: colors.bg.base },
headerShadowVisible: false,
headerTitle: '',
headerBackButtonDisplayMode: 'minimal',
headerTintColor: colors.text.primary,
animation: 'slide_from_right',
}}
name="zone/[bodyZone]"
options={{ headerShown: true, headerTitle: '' }}
/>
<Stack.Screen
name="program/[id]"
options={{
headerShown: true,
headerStyle: { backgroundColor: colors.bg.base },
headerShadowVisible: false,
headerTitle: '',
headerBackButtonDisplayMode: 'minimal',
headerTintColor: colors.text.primary,
animation: 'slide_from_right',
}}
/>
<Stack.Screen
name="collection/[id]"
options={{
animation: 'slide_from_right',
}}
/>
<Stack.Screen
name="assessment"
options={{
animation: 'slide_from_right',
}}
options={{ headerShown: true, headerTitle: '' }}
/>
<Stack.Screen
name="player/[id]"
options={{
presentation: 'fullScreenModal',
animation: 'fade',
}}
options={{ presentation: 'fullScreenModal', animation: 'fade' }}
/>
<Stack.Screen
name="complete/[id]"
options={{ animation: 'fade' }}
/>
<Stack.Screen
name="settings"
options={{
animation: 'fade',
presentation: 'formSheet',
sheetGrabberVisible: true,
sheetAllowedDetents: [0.75, 1.0],
}}
/>
<Stack.Screen
name="terms"
options={{ headerShown: true, headerTitle: '' }}
/>
<Stack.Screen
name="privacy"
options={{ headerShown: true, headerTitle: '' }}
/>
<Stack.Screen
name="paywall"
options={{
presentation: 'formSheet',
sheetGrabberVisible: true,
sheetAllowedDetents: [0.85, 1.0],
}}
/>
</Stack>
@@ -211,8 +251,10 @@ function RootLayoutInner() {
export default function RootLayout() {
return (
<ThemeProvider>
<RootLayoutInner />
</ThemeProvider>
<ErrorBoundary>
<ThemeProvider>
<RootLayoutInner />
</ThemeProvider>
</ErrorBoundary>
)
}

View File

@@ -1,204 +1,43 @@
/**
* TabataFit Workout Complete Screen
* Celebration with real data from activity store
* Dark Medical design system — navy, green, no glass
* Celebration + stats driven by progressStore.
*/
import { useRef, useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef } from 'react'
import {
View,
Text as RNText,
StyleSheet,
ScrollView,
Pressable,
Animated,
Dimensions,
} from 'react-native'
import { useRouter, useLocalSearchParams } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { Icon, type IconName } from '@/src/shared/components/Icon'
import * as Sharing from 'expo-sharing'
import { useTranslation } from 'react-i18next'
import * as Sharing from 'expo-sharing'
import { useHaptics } from '@/src/shared/hooks'
import { useActivityStore, useUserStore } from '@/src/shared/stores'
import { getWorkoutById, getPopularWorkouts, getTrainerById, getWorkoutAccentColor } from '@/src/shared/data'
import { useTranslatedWorkout, useTranslatedWorkouts } from '@/src/shared/data/useTranslatedData'
import { SyncConsentModal } from '@/src/shared/components/SyncConsentModal'
import { useProgressStore } from '@/src/shared/stores'
import { NativeButton } from '@/src/shared/components/native'
import { enableSync } from '@/src/shared/services/sync'
import type { WorkoutSessionData } from '@/src/shared/types'
import { useThemeColors } from '@/src/shared/theme'
import type { ThemeColors } from '@/src/shared/theme/types'
import { TYPOGRAPHY, FONT_FAMILY } from '@/src/shared/constants/typography'
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { SPRING, EASE } from '@/src/shared/constants/animations'
import { SPRING } from '@/src/shared/constants/animations'
import { GREEN, NAVY, TEXT, BORDER_COLORS } from '@/src/shared/constants/colors'
const { width: SCREEN_WIDTH } = Dimensions.get('window')
// ═══════════════════════════════════════════════════════════════════════════
// BUTTON COMPONENTS
// ═══════════════════════════════════════════════════════════════════════════
function SecondaryButton({
onPress,
children,
icon,
}: {
onPress: () => void
children: React.ReactNode
icon?: IconName
}) {
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
const scaleAnim = useRef(new Animated.Value(1)).current
const handlePressIn = () => {
Animated.spring(scaleAnim, {
toValue: 0.97,
useNativeDriver: true,
...SPRING.SNAPPY,
}).start()
}
const handlePressOut = () => {
Animated.spring(scaleAnim, {
toValue: 1,
useNativeDriver: true,
...SPRING.SNAPPY,
}).start()
}
return (
<Pressable
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
style={{ width: '100%' }}
>
<Animated.View style={[styles.secondaryButton, { transform: [{ scale: scaleAnim }] }]}>
{icon && <Icon name={icon} size={18} tintColor={TEXT.PRIMARY} style={styles.buttonIcon} />}
<RNText style={styles.secondaryButtonText}>{children}</RNText>
</Animated.View>
</Pressable>
)
}
function PrimaryButton({
onPress,
children,
}: {
onPress: () => void
children: React.ReactNode
}) {
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
const scaleAnim = useRef(new Animated.Value(1)).current
const handlePressIn = () => {
Animated.spring(scaleAnim, {
toValue: 0.97,
useNativeDriver: true,
...SPRING.SNAPPY,
}).start()
}
const handlePressOut = () => {
Animated.spring(scaleAnim, {
toValue: 1,
useNativeDriver: true,
...SPRING.SNAPPY,
}).start()
}
return (
<Pressable
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
style={{ width: '100%' }}
>
<Animated.View
style={[
styles.primaryButton,
{ backgroundColor: GREEN['500'], transform: [{ scale: scaleAnim }] },
]}
>
<RNText style={[styles.primaryButtonText, { color: NAVY['900'] }]}>
{children}
</RNText>
</Animated.View>
</Pressable>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// COMPONENTS
// ═══════════════════════════════════════════════════════════════════════════
function CelebrationRings() {
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
const ring1Anim = useRef(new Animated.Value(0)).current
const ring2Anim = useRef(new Animated.Value(0)).current
const ring3Anim = useRef(new Animated.Value(0)).current
useEffect(() => {
Animated.stagger(200, [
Animated.spring(ring1Anim, {
toValue: 1,
...SPRING.BOUNCY,
useNativeDriver: true,
}),
Animated.spring(ring2Anim, {
toValue: 1,
...SPRING.BOUNCY,
useNativeDriver: true,
}),
Animated.spring(ring3Anim, {
toValue: 1,
...SPRING.BOUNCY,
useNativeDriver: true,
}),
]).start()
}, [])
return (
<View style={styles.ringsContainer}>
<Animated.View
style={[styles.ring, styles.ring1, { transform: [{ scale: ring1Anim }], opacity: ring1Anim }]}
>
<RNText style={styles.ringEmoji}>🔥</RNText>
</Animated.View>
<Animated.View
style={[styles.ring, styles.ring2, { transform: [{ scale: ring2Anim }], opacity: ring2Anim }]}
>
<RNText style={styles.ringEmoji}>💪</RNText>
</Animated.View>
<Animated.View
style={[styles.ring, styles.ring3, { transform: [{ scale: ring3Anim }], opacity: ring3Anim }]}
>
<RNText style={styles.ringEmoji}></RNText>
</Animated.View>
</View>
)
}
function StatCard({
value,
label,
icon,
accentColor,
delay = 0,
}: {
value: string | number
label: string
icon: IconName
accentColor: string
delay?: number
}) {
const colors = useThemeColors()
@@ -208,58 +47,19 @@ function StatCard({
useEffect(() => {
Animated.sequence([
Animated.delay(delay),
Animated.spring(scaleAnim, {
toValue: 1,
...SPRING.BOUNCY,
useNativeDriver: true,
}),
Animated.spring(scaleAnim, { toValue: 1, ...SPRING.BOUNCY, useNativeDriver: true }),
]).start()
}, [delay])
return (
<Animated.View style={[styles.statCard, { transform: [{ scale: scaleAnim }] }]}>
<Icon name={icon} size={24} tintColor={GREEN['500']} />
<RNText style={styles.statValue}>{value}</RNText>
<RNText selectable style={styles.statValue}>{value}</RNText>
<RNText style={styles.statLabel}>{label}</RNText>
</Animated.View>
)
}
function BurnBarResult({ percentile, accentColor }: { percentile: number; accentColor: string }) {
const { t } = useTranslation()
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
const barAnim = useRef(new Animated.Value(0)).current
useEffect(() => {
Animated.timing(barAnim, {
toValue: percentile,
duration: 1000,
easing: EASE.EASE_OUT,
useNativeDriver: false,
}).start()
}, [percentile])
const barWidth = barAnim.interpolate({
inputRange: [0, 100],
outputRange: ['0%', '100%'],
})
return (
<View style={styles.burnBarContainer}>
<RNText style={styles.burnBarTitle}>{t('screens:complete.burnBar')}</RNText>
<RNText style={[styles.burnBarResult, { color: GREEN['500'] }]}>{t('screens:complete.burnBarResult', { percentile })}</RNText>
<View style={styles.burnBarTrack}>
<Animated.View style={[styles.burnBarFill, { width: barWidth, backgroundColor: GREEN['500'] }]} />
</View>
</View>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// MAIN SCREEN
// ═══════════════════════════════════════════════════════════════════════════
export default function WorkoutCompleteScreen() {
const insets = useSafeAreaInsets()
const router = useRouter()
@@ -270,30 +70,17 @@ export default function WorkoutCompleteScreen() {
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
const rawWorkout = getWorkoutById(id ?? '1')
const workout = useTranslatedWorkout(rawWorkout)
const trainer = rawWorkout ? getTrainerById(rawWorkout.trainerId) : undefined
const trainerColor = getWorkoutAccentColor(id ?? '1')
const streak = useActivityStore((s) => s.streak)
const history = useActivityStore((s) => s.history)
const recentWorkouts = history.slice(0, 1)
const history = useProgressStore((s) => s.history)
const streak = useProgressStore((s) => s.streak)
const weeklyCount = useProgressStore((s) => s.getWeeklyCount())
// 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
const resultMinutes = latestResult?.durationMinutes ?? workout?.duration ?? 4
// Recommended workouts (different from current)
const rawRecommended = getPopularWorkouts(4).filter(w => w.id !== id).slice(0, 3)
const recommended = useTranslatedWorkouts(rawRecommended)
// Latest session (the one we just completed)
const latest = history[0]
const resultMinutes = latest ? Math.round(latest.durationSeconds / 60) : 0
const handleGoHome = () => {
haptics.buttonTap()
router.replace('/(tabs)')
router.replace('/')
}
const handleShare = async () => {
@@ -301,96 +88,35 @@ export default function WorkoutCompleteScreen() {
const isAvailable = await Sharing.isAvailableAsync()
if (isAvailable) {
await Sharing.shareAsync('https://tabatafit.app', {
dialogTitle: t('screens:complete.shareText', { title: workout?.title ?? 'a workout', calories: resultCalories, duration: resultMinutes }),
dialogTitle: t('screens:complete.shareTitle', { minutes: resultMinutes }),
})
}
}
const handleWorkoutPress = (workoutId: string) => {
haptics.buttonTap()
router.push(`/workout/${workoutId}`)
}
// Fire celebration haptic on mount
useEffect(() => {
haptics.workoutComplete()
}, [])
// 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)))
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
<ScrollView
style={styles.scrollView}
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 100 }]}
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 120 }]}
contentInsetAdjustmentBehavior="automatic"
showsVerticalScrollIndicator={false}
>
{/* Celebration */}
<View style={styles.celebrationSection}>
<RNText style={styles.celebrationEmoji}>🎉</RNText>
<RNText style={styles.celebrationTitle}>{t('screens:complete.title')}</RNText>
<CelebrationRings />
</View>
{/* Stats Grid */}
<View style={styles.statsGrid}>
<StatCard value={resultCalories} label={t('screens:complete.caloriesLabel')} icon="flame.fill" accentColor={GREEN['500']} delay={100} />
<StatCard value={resultMinutes} label={t('screens:complete.minutesLabel')} icon="clock.fill" accentColor={GREEN['500']} delay={200} />
<StatCard value="100%" label={t('screens:complete.completeLabel')} icon="checkmark.circle.fill" accentColor={GREEN['500']} delay={300} />
<StatCard value={resultMinutes} label={t('screens:complete.minutesLabel')} icon="clock.fill" delay={100} />
<StatCard value={streak.current} label={t('screens:home.statsStreak')} icon="flame.fill" delay={200} />
<StatCard value={weeklyCount} label={t('screens:complete.thisWeek')} icon="calendar" delay={300} />
</View>
{/* Burn Bar */}
<BurnBarResult percentile={burnBarPercentile} accentColor={GREEN['500']} />
<View style={styles.divider} />
{/* Streak */}
@@ -399,45 +125,27 @@ export default function WorkoutCompleteScreen() {
<Icon name="flame.fill" size={32} tintColor={GREEN['500']} />
</View>
<View style={styles.streakInfo}>
<RNText style={styles.streakTitle}>{t('screens:complete.streakTitle', { count: streak.current })}</RNText>
<RNText style={styles.streakSubtitle}>{t('screens:complete.streakSubtitle')}</RNText>
<RNText selectable style={styles.streakTitle}>
{t('screens:complete.streakDays', { count: streak.current })}
</RNText>
<RNText style={styles.streakSubtitle}>
{t('screens:complete.streakRecord', { count: streak.longest })}
</RNText>
</View>
</View>
<View style={styles.divider} />
{/* Share Button */}
{/* Share */}
<View style={styles.shareSection}>
<NativeButton
variant="ghost"
title={t('screens:complete.shareWorkout')}
title={t('screens:complete.share')}
systemImage="square.and.arrow.up"
onPress={handleShare}
fullWidth
/>
</View>
<View style={styles.divider} />
{/* Recommended */}
<View style={styles.recommendedSection}>
<RNText style={styles.recommendedTitle}>{t('screens:complete.recommendedNext')}</RNText>
<View style={styles.recommendedGrid}>
{recommended.map((w) => (
<Pressable
key={w.id}
onPress={() => handleWorkoutPress(w.id)}
style={styles.recommendedCard}
>
<View style={[styles.recommendedThumb, { backgroundColor: GREEN.DIM }]}>
<Icon name="flame.fill" size={24} tintColor={GREEN['500']} />
</View>
<RNText style={styles.recommendedTitleText} numberOfLines={1}>{w.title}</RNText>
<RNText style={styles.recommendedDurationText}>{t('units.minUnit', { count: w.duration })}</RNText>
</Pressable>
))}
</View>
</View>
</ScrollView>
{/* Fixed Bottom Button */}
@@ -452,260 +160,52 @@ export default function WorkoutCompleteScreen() {
/>
</View>
</View>
{/* Sync Consent Modal */}
<SyncConsentModal
visible={showSyncPrompt}
onAccept={handleSyncAccept}
onDecline={handleSyncDecline}
/>
</View>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// STYLES
// ═══════════════════════════════════════════════════════════════════════════
function createStyles(colors: ThemeColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bg.base,
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingHorizontal: LAYOUT.SCREEN_PADDING,
},
container: { flex: 1, backgroundColor: colors.bg.base },
scrollContent: { paddingHorizontal: LAYOUT.SCREEN_PADDING },
// Buttons
secondaryButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: SPACING[3],
paddingHorizontal: SPACING[4],
borderRadius: RADIUS.MD,
borderWidth: 1,
borderColor: BORDER_COLORS.DIM,
backgroundColor: 'transparent',
},
secondaryButtonText: {
...TYPOGRAPHY.BODY,
color: TEXT.PRIMARY,
fontFamily: FONT_FAMILY.SANS_SEMIBOLD,
},
primaryButton: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: SPACING[4],
paddingHorizontal: SPACING[6],
borderRadius: RADIUS.MD,
overflow: 'hidden',
},
primaryButtonText: {
...TYPOGRAPHY.HEADLINE,
fontFamily: FONT_FAMILY.SANS_BOLD,
},
buttonIcon: {
marginRight: SPACING[2],
},
celebrationSection: { alignItems: 'center', paddingVertical: SPACING[8] },
celebrationEmoji: { fontSize: 64, marginBottom: SPACING[4] },
celebrationTitle: { ...TYPOGRAPHY.TITLE_1, color: TEXT.PRIMARY, letterSpacing: 1 },
// Celebration
celebrationSection: {
alignItems: 'center',
paddingVertical: SPACING[8],
},
celebrationEmoji: {
fontSize: 64,
marginBottom: SPACING[4],
},
celebrationTitle: {
...TYPOGRAPHY.TITLE_1,
color: TEXT.PRIMARY,
letterSpacing: 2,
},
ringsContainer: {
flexDirection: 'row',
marginTop: SPACING[6],
gap: SPACING[4],
},
ring: {
width: 64,
height: 64,
borderRadius: 32,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2,
},
ring1: {
borderColor: GREEN['500'],
backgroundColor: GREEN.DIM,
},
ring2: {
borderColor: GREEN['500'],
backgroundColor: GREEN.DIM,
},
ring3: {
borderColor: GREEN['500'],
backgroundColor: GREEN.DIM,
},
ringEmoji: {
fontSize: 28,
},
// Stats Grid
statsGrid: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: SPACING[6],
},
statsGrid: { flexDirection: 'row', gap: SPACING[3], marginBottom: SPACING[6] },
statCard: {
width: (SCREEN_WIDTH - LAYOUT.SCREEN_PADDING * 2 - SPACING[4]) / 3,
flex: 1,
padding: SPACING[3],
borderRadius: RADIUS.LG,
backgroundColor: colors.surface.default.backgroundColor,
alignItems: 'center',
borderWidth: 1,
borderColor: colors.surface.default.borderColor,
overflow: 'hidden',
},
statValue: {
...TYPOGRAPHY.TITLE_1,
color: TEXT.PRIMARY,
marginTop: SPACING[2],
},
statLabel: {
...TYPOGRAPHY.CAPTION_2,
color: TEXT.TERTIARY,
marginTop: SPACING[1],
borderCurve: 'continuous',
},
statValue: { ...TYPOGRAPHY.TITLE_1, color: TEXT.PRIMARY, marginTop: SPACING[2], fontVariant: ['tabular-nums'] },
statLabel: { ...TYPOGRAPHY.CAPTION_2, color: TEXT.TERTIARY, marginTop: SPACING[1] },
// Burn Bar
burnBarContainer: {
marginBottom: SPACING[6],
},
burnBarTitle: {
...TYPOGRAPHY.HEADLINE,
color: TEXT.TERTIARY,
},
burnBarResult: {
...TYPOGRAPHY.BODY,
marginTop: SPACING[1],
marginBottom: SPACING[3],
},
burnBarTrack: {
height: 8,
backgroundColor: colors.bg.surface,
borderRadius: RADIUS.SM,
overflow: 'hidden',
},
burnBarFill: {
height: '100%',
borderRadius: RADIUS.SM,
},
divider: { height: 1, backgroundColor: BORDER_COLORS.DIM, marginVertical: SPACING[2] },
// Divider
divider: {
height: 1,
backgroundColor: BORDER_COLORS.DIM,
marginVertical: SPACING[2],
},
streakSection: { flexDirection: 'row', alignItems: 'center', paddingVertical: SPACING[4], gap: SPACING[4] },
streakBadge: { width: 64, height: 64, borderRadius: RADIUS.FULL, alignItems: 'center', justifyContent: 'center' },
streakInfo: { flex: 1 },
streakTitle: { ...TYPOGRAPHY.TITLE_2, color: TEXT.PRIMARY },
streakSubtitle: { ...TYPOGRAPHY.BODY, color: TEXT.TERTIARY, marginTop: SPACING[1] },
// Streak
streakSection: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: SPACING[4],
gap: SPACING[4],
},
streakBadge: {
width: 64,
height: 64,
borderRadius: RADIUS.FULL,
alignItems: 'center',
justifyContent: 'center',
},
streakInfo: {
flex: 1,
},
streakTitle: {
...TYPOGRAPHY.TITLE_2,
color: TEXT.PRIMARY,
},
streakSubtitle: {
...TYPOGRAPHY.BODY,
color: TEXT.TERTIARY,
marginTop: SPACING[1],
},
shareSection: { paddingVertical: SPACING[4], alignItems: 'center' },
// Share
shareSection: {
paddingVertical: SPACING[4],
alignItems: 'center',
},
// Recommended
recommendedSection: {
paddingVertical: SPACING[4],
},
recommendedTitle: {
...TYPOGRAPHY.HEADLINE,
color: TEXT.PRIMARY,
marginBottom: SPACING[4],
},
recommendedGrid: {
flexDirection: 'row',
gap: SPACING[3],
},
recommendedCard: {
flex: 1,
padding: SPACING[3],
borderRadius: RADIUS.LG,
borderWidth: 1,
borderColor: colors.surface.default.borderColor,
backgroundColor: colors.surface.default.backgroundColor,
overflow: 'hidden',
},
recommendedThumb: {
width: '100%',
aspectRatio: 1,
borderRadius: RADIUS.MD,
alignItems: 'center',
justifyContent: 'center',
marginBottom: SPACING[2],
overflow: 'hidden',
},
recommendedInitial: {
...TYPOGRAPHY.TITLE_1,
color: TEXT.PRIMARY,
},
recommendedTitleText: {
...TYPOGRAPHY.CARD_TITLE,
color: TEXT.PRIMARY,
},
recommendedDurationText: {
...TYPOGRAPHY.CARD_METADATA,
color: TEXT.TERTIARY,
},
// Bottom Bar
bottomBar: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
bottom: 0, left: 0, right: 0,
paddingHorizontal: LAYOUT.SCREEN_PADDING,
paddingTop: SPACING[4],
backgroundColor: colors.bg.base,
borderTopWidth: 1,
borderTopColor: BORDER_COLORS.DIM,
},
homeButtonContainer: {
height: 56,
justifyContent: 'center',
},
homeButtonContainer: { height: 56, justifyContent: 'center' },
})
}

View File

@@ -999,7 +999,7 @@ export default function OnboardingScreen() {
if (plan !== 'free') {
setSubscription(plan)
}
router.replace('/(tabs)')
router.replace('/')
},
[name, level, goal, frequency, barriers, step]
)

View File

@@ -284,6 +284,20 @@ export default function PaywallScreen() {
onPress={handleRestore}
/>
<View style={styles.legalLinks}>
<Pressable onPress={() => router.push('/terms')}>
<StyledText size={11} color={colors.text.tertiary} style={{ textDecorationLine: 'underline' }}>
{t('paywall.termsLink')}
</StyledText>
</Pressable>
<StyledText size={11} color={colors.text.tertiary}> · </StyledText>
<Pressable onPress={() => router.push('/terms')}>
<StyledText size={11} color={colors.text.tertiary} style={{ textDecorationLine: 'underline' }}>
{t('paywall.privacyLink')}
</StyledText>
</Pressable>
</View>
<StyledText size={11} color={colors.text.tertiary} style={{ textAlign: 'center', lineHeight: 18, paddingHorizontal: SPACING[4] }}>
{t('paywall.terms')}
</StyledText>
@@ -437,5 +451,10 @@ function createStyles(colors: ThemeColors) {
lineHeight: 18,
paddingHorizontal: SPACING[4],
},
legalLinks: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING[1],
},
})
}

View File

@@ -1,601 +1,82 @@
/**
* TabataFit Player Screen
* Thin orchestrator — all UI extracted to src/features/player/
* FORCE DARK — always uses darkColors regardless of system theme
* Loads a WorkoutProgram from Supabase and renders the Tabata player.
*/
import React, { useRef, useEffect, useCallback, useState } from 'react'
import React from 'react'
import { View, Text } from 'react-native'
import { useLocalSearchParams } from 'expo-router'
import {
View,
Text,
StyleSheet,
Pressable,
Animated,
StatusBar,
useWindowDimensions,
} from 'react-native'
import { useRouter, useLocalSearchParams } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useKeepAwake } from 'expo-keep-awake'
import { Icon } from '@/src/shared/components/Icon'
import { useTranslation } from 'react-i18next'
import { useTimer } from '@/src/shared/hooks/useTimer'
import { isTabataSession, getTabataSessionById } from '@/src/shared/data/tabata'
import { isWorkoutProgramId, parseWorkoutProgramId, fetchProgramById, workoutProgramToTabataSession } from '@/src/shared/data/workoutPrograms'
isWorkoutProgramId,
parseWorkoutProgramId,
fetchProgramById,
workoutProgramToTabataSession,
} from '@/src/shared/data/workoutPrograms'
import { TabataPlayerScreen } from '@/src/features/player/TabataPlayerScreen'
import type { TabataSession } from '@/src/shared/types/program'
import { useHaptics } from '@/src/shared/hooks/useHaptics'
import { useAudio } from '@/src/shared/hooks/useAudio'
import { useMusicPlayer } from '@/src/shared/hooks/useMusicPlayer'
import { useActivityStore } from '@/src/shared/stores'
import { getWorkoutById, getTrainerById, getWorkoutAccentColor } from '@/src/shared/data'
import { useTranslatedWorkout } from '@/src/shared/data/useTranslatedData'
import { useWatchSync } from '@/src/features/watch'
import { track } from '@/src/shared/services/analytics'
import { VideoPlayer } from '@/src/shared/components/VideoPlayer'
import { PHASE_COLORS, darkColors } from '@/src/shared/theme'
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
import { SPACING } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { GREEN, NAVY, BORDER_COLORS, TEXT } from '@/src/shared/constants/colors'
import {
TimerRing,
PhaseIndicator,
ExerciseDisplay,
RoundIndicator,
PlayerControls,
BurnBar,
StatsOverlay,
CoachEncouragement,
NowPlaying,
} from '@/src/features/player'
// ─── Helpers ─────────────────────────────────────────────────────────────────
function formatTime(seconds: number) {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}:${secs.toString().padStart(2, '0')}`
}
// ─── Main Screen ─────────────────────────────────────────────────────────────
import type { WorkoutProgram } from '@/src/shared/types/workoutProgram'
import { NAVY, TEXT } from '@/src/shared/constants/colors'
export default function PlayerScreen() {
useKeepAwake()
const router = useRouter()
const { id } = useLocalSearchParams<{ id: string }>()
const sessionId = id ?? ''
// ─── Dispatch: Workout Program → Tabata session → Legacy workout ─
const sessionId = id ?? '1'
if (isWorkoutProgramId(sessionId)) {
return <WorkoutProgramPlayerScreen compositeId={sessionId} />
if (!isWorkoutProgramId(sessionId)) {
return <Message text="Programme invalide" />
}
if (isTabataSession(sessionId)) {
const session = getTabataSessionById(sessionId)
if (session) {
return <TabataPlayerScreen session={session} />
}
// Fallback to legacy if session not found
}
return <LegacyPlayerScreen id={sessionId} />
return <WorkoutProgramPlayerScreen compositeId={sessionId} />
}
/**
* Workout Program player — async-loads a workout program from Supabase,
* converts to TabataSession (3 tabata blocks), and renders TabataPlayerScreen.
*/
function WorkoutProgramPlayerScreen({ compositeId }: { compositeId: string }) {
const [session, setSession] = React.useState<TabataSession | null | undefined>(undefined)
const [state, setState] = React.useState<
| { status: 'loading' }
| { status: 'error' }
| { status: 'ready'; session: TabataSession; program: WorkoutProgram }
>({ status: 'loading' })
React.useEffect(() => {
let cancelled = false
async function load() {
const parsed = parseWorkoutProgramId(compositeId)
if (!parsed) { if (!cancelled) setSession(null); return }
if (!parsed) {
if (!cancelled) setState({ status: 'error' })
return
}
const program = await fetchProgramById(parsed.programId)
if (cancelled) return
if (!program) { setSession(null); return }
setSession(workoutProgramToTabataSession(program))
if (!program) {
setState({ status: 'error' })
return
}
setState({
status: 'ready',
session: workoutProgramToTabataSession(program),
program,
})
}
load()
return () => { cancelled = true }
return () => {
cancelled = true
}
}, [compositeId])
if (session === undefined) {
return (
<View style={{ flex: 1, backgroundColor: NAVY[900], justifyContent: 'center', alignItems: 'center' }}>
<Text style={{ color: TEXT.SECONDARY }}>Chargement...</Text>
</View>
)
}
if (!session) {
return (
<View style={{ flex: 1, backgroundColor: NAVY[900], justifyContent: 'center', alignItems: 'center' }}>
<Text style={{ color: TEXT.SECONDARY }}>Programme non trouvé</Text>
</View>
)
}
return <TabataPlayerScreen session={session} />
if (state.status === 'loading') return <Message text="Chargement..." />
if (state.status === 'error') return <Message text="Programme non trouvé" />
return <TabataPlayerScreen session={state.session} program={state.program} />
}
/**
* Legacy player for original workout format
*/
function LegacyPlayerScreen({ id }: { id: string }) {
const router = useRouter()
const insets = useSafeAreaInsets()
const haptics = useHaptics()
const { t } = useTranslation()
const { width: SCREEN_WIDTH } = useWindowDimensions()
const addWorkoutResult = useActivityStore((s) => s.addWorkoutResult)
const colors = darkColors
const rawWorkout = getWorkoutById(id ?? '1')
const workout = useTranslatedWorkout(rawWorkout)
const trainer = rawWorkout ? getTrainerById(rawWorkout.trainerId) : undefined
const trainerColor = getWorkoutAccentColor(id ?? '1')
const timer = useTimer(rawWorkout ?? null)
const audio = useAudio()
// Music player — synced with workout timer
const music = useMusicPlayer({
vibe: workout?.musicVibe ?? 'electronic',
isPlaying: timer.isRunning && !timer.isPaused && timer.phase !== 'PREP',
})
const [showControls, setShowControls] = useState(true)
const [heartRate, setHeartRate] = useState<number | null>(null)
// Watch sync integration
const { isAvailable: isWatchAvailable, sendWorkoutState } = useWatchSync({
onPlay: () => {
timer.resume()
track('watch_control_play', { workout_id: workout?.id ?? id })
},
onPause: () => {
timer.pause()
track('watch_control_pause', { workout_id: workout?.id ?? id })
},
onSkip: () => {
timer.skip()
haptics.selection()
track('watch_control_skip', { workout_id: workout?.id ?? id })
},
onStop: () => {
haptics.phaseChange()
timer.stop()
router.back()
track('watch_control_stop', { workout_id: workout?.id ?? id })
},
onHeartRateUpdate: (hr: number) => setHeartRate(hr),
})
// Animation refs
const timerScaleAnim = useRef(new Animated.Value(0.8)).current
const phaseColor = PHASE_COLORS[timer.phase].fill
// ─── Actions ─────────────────────────────────────────────────────────────
const startTimer = useCallback(() => {
timer.start()
haptics.buttonTap()
if (workout) {
track('workout_started', {
workout_id: workout.id,
workout_title: workout.title,
duration: workout.duration,
level: workout.level,
})
}
}, [timer, haptics, workout])
const togglePause = useCallback(() => {
const workoutId = workout?.id ?? id ?? ''
if (timer.isPaused) {
timer.resume()
track('workout_resumed', { workout_id: workoutId })
} else {
timer.pause()
track('workout_paused', { workout_id: workoutId })
}
haptics.selection()
}, [timer, haptics, workout, id])
const stopWorkout = useCallback(() => {
haptics.phaseChange()
timer.stop()
router.back()
}, [router, timer, haptics])
const completeWorkout = useCallback(() => {
haptics.workoutComplete()
if (workout) {
track('workout_completed', {
workout_id: workout.id,
workout_title: workout.title,
calories: timer.calories,
duration: workout.duration,
rounds: workout.rounds,
})
addWorkoutResult({
id: Date.now().toString(),
workoutId: workout.id,
completedAt: Date.now(),
calories: timer.calories,
durationMinutes: workout.duration,
rounds: workout.rounds,
completionRate: 1,
})
}
router.replace(`/complete/${workout?.id ?? '1'}`)
}, [router, workout, timer.calories, haptics, addWorkoutResult])
const handleSkip = useCallback(() => {
timer.skip()
haptics.selection()
}, [timer, haptics])
const toggleControls = useCallback(() => {
setShowControls((s) => !s)
}, [])
// ─── Animations & side-effects ───────────────────────────────────────────
// Entrance animation
useEffect(() => {
Animated.spring(timerScaleAnim, {
toValue: 1,
friction: 6,
tension: 100,
useNativeDriver: true,
}).start()
}, [])
// Phase change animation + audio
useEffect(() => {
timerScaleAnim.setValue(0.9)
Animated.spring(timerScaleAnim, {
toValue: 1,
friction: 4,
tension: 150,
useNativeDriver: true,
}).start()
haptics.phaseChange()
if (timer.phase === 'COMPLETE') {
audio.workoutComplete()
} else if (timer.isRunning) {
audio.phaseStart()
}
}, [timer.phase])
// Countdown beep + haptic for last 3 seconds
useEffect(() => {
if (timer.isRunning && timer.timeRemaining <= 3 && timer.timeRemaining > 0) {
audio.countdownBeep()
haptics.countdownTick()
}
}, [timer.timeRemaining])
// Sync workout state with Apple Watch
useEffect(() => {
if (!isWatchAvailable || !timer.isRunning) return
sendWorkoutState({
phase: timer.phase,
timeRemaining: timer.timeRemaining,
currentRound: timer.currentRound,
totalRounds: timer.totalRounds,
currentExercise: timer.currentExercise,
nextExercise: timer.nextExercise,
calories: timer.calories,
isPaused: timer.isPaused,
isPlaying: timer.isRunning && !timer.isPaused,
})
}, [
timer.phase, timer.timeRemaining, timer.currentRound,
timer.totalRounds, timer.currentExercise, timer.nextExercise,
timer.calories, timer.isPaused, timer.isRunning, isWatchAvailable,
])
// ─── Render ──────────────────────────────────────────────────────────────
function Message({ text }: { text: string }) {
return (
<View style={styles.container}>
<StatusBar hidden />
{/* Background video or gradient fallback */}
<VideoPlayer
videoUrl={workout?.videoUrl}
gradientColors={[colors.bg.base, colors.bg.surface]}
mode="background"
isPlaying={timer.isRunning && !timer.isPaused}
style={StyleSheet.absoluteFill}
/>
{/* Phase background tint */}
<View style={[styles.phaseBg, { backgroundColor: phaseColor }]} />
{/* Main content */}
<Pressable style={styles.content} onPress={toggleControls}>
{/* Header */}
{showControls && (
<View style={[styles.header, { paddingTop: insets.top + SPACING[4] }]}>
<Pressable onPress={stopWorkout} style={styles.closeBtn}>
<View style={StyleSheet.absoluteFill} />
<Icon name="xmark" size={24} tintColor={colors.text.primary} />
</Pressable>
<View style={styles.headerCenter}>
<Text style={styles.title}>{workout?.title ?? 'Workout'}</Text>
<Text style={styles.subtitle}>
{t('durationLevel', {
duration: workout?.duration ?? 0,
level: t(`levels.${(workout?.level ?? 'beginner').toLowerCase()}`),
})}
</Text>
</View>
<View style={styles.closeBtn} />
</View>
)}
{/* Stats overlay — above timer ring */}
{showControls && timer.isRunning && !timer.isComplete && (
<View style={styles.statsContainer}>
<StatsOverlay
calories={timer.calories}
heartRate={heartRate}
elapsedRounds={timer.currentRound - 1}
totalRounds={timer.totalRounds}
/>
</View>
)}
{/* Timer ring + inner text */}
<Animated.View
style={[styles.timerContainer, { transform: [{ scale: timerScaleAnim }] }]}
>
<TimerRing progress={timer.progress} phase={timer.phase} />
<View style={styles.timerInner}>
<PhaseIndicator phase={timer.phase} />
<Text selectable style={styles.timerTime}>
{formatTime(timer.timeRemaining)}
</Text>
<RoundIndicator current={timer.currentRound} total={timer.totalRounds} />
</View>
</Animated.View>
{/* Exercise name + coach encouragement */}
{!timer.isComplete && (
<>
<ExerciseDisplay
exercise={timer.currentExercise}
nextExercise={timer.nextExercise}
/>
<CoachEncouragement
phase={timer.phase}
currentRound={timer.currentRound}
totalRounds={timer.totalRounds}
/>
</>
)}
{/* Complete state */}
{timer.isComplete && (
<View style={styles.completeSection}>
<Text style={styles.completeTitle}>{t('screens:player.workoutComplete')}</Text>
<Text style={[styles.completeSubtitle, { color: trainerColor }]}>{t('screens:player.greatJob')}</Text>
<View style={styles.completeStats}>
<View style={styles.completeStat}>
<Text selectable style={styles.completeStatValue}>{timer.totalRounds}</Text>
<Text style={styles.completeStatLabel}>{t('screens:player.rounds')}</Text>
</View>
<View style={styles.completeStat}>
<Text selectable style={styles.completeStatValue}>{timer.calories}</Text>
<Text style={styles.completeStatLabel}>{t('screens:player.calories')}</Text>
</View>
<View style={styles.completeStat}>
<Text selectable style={styles.completeStatValue}>{workout?.duration ?? 4}</Text>
<Text style={styles.completeStatLabel}>{t('screens:player.minutes')}</Text>
</View>
</View>
</View>
)}
{/* Player controls */}
{showControls && !timer.isComplete && (
<View style={[styles.controls, { paddingBottom: insets.bottom + SPACING[6] }]}>
<PlayerControls
isRunning={timer.isRunning}
isPaused={timer.isPaused}
onStart={startTimer}
onPause={() => { timer.pause(); haptics.selection(); track('workout_paused', { workout_id: workout?.id ?? id ?? '' }) }}
onResume={() => { timer.resume(); haptics.selection(); track('workout_resumed', { workout_id: workout?.id ?? id ?? '' }) }}
onStop={stopWorkout}
onSkip={handleSkip}
/>
</View>
)}
{/* Complete CTA */}
{timer.isComplete && (
<View style={[styles.controls, { paddingBottom: insets.bottom + SPACING[6] }]}>
<Pressable style={styles.doneButton} onPress={completeWorkout}>
<Text style={styles.doneButtonText}>{t('common:done')}</Text>
</Pressable>
</View>
)}
{/* Burn bar */}
{showControls && timer.isRunning && !timer.isComplete && (
<View style={[styles.burnBarContainer, { bottom: insets.bottom + 140 }]}>
<BurnBar currentCalories={timer.calories} avgCalories={workout?.calories ?? 45} />
</View>
)}
{/* Now Playing music pill */}
{showControls && timer.isRunning && !timer.isComplete && (
<View style={[styles.nowPlayingContainer, { bottom: insets.bottom + 220 }]}>
<NowPlaying
track={music.currentTrack}
isReady={music.isReady}
onSkipTrack={music.nextTrack}
/>
</View>
)}
</Pressable>
<View
style={{
flex: 1,
backgroundColor: NAVY[900],
justifyContent: 'center',
alignItems: 'center',
}}
>
<Text style={{ color: TEXT.SECONDARY }}>{text}</Text>
</View>
)
}
// ─── Styles ──────────────────────────────────────────────────────────────────
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: NAVY[900],
},
phaseBg: {
...StyleSheet.absoluteFillObject,
opacity: 0.15,
},
content: {
flex: 1,
},
// Header
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: SPACING[4],
},
closeBtn: {
width: 44,
height: 44,
borderRadius: RADIUS.FULL,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
borderWidth: 1,
borderColor: BORDER_COLORS.DIM,
backgroundColor: NAVY[800],
},
headerCenter: {
alignItems: 'center',
},
title: {
...TYPOGRAPHY.HEADLINE,
color: TEXT.PRIMARY,
},
subtitle: {
...TYPOGRAPHY.CAPTION_1,
color: TEXT.TERTIARY,
},
// Stats overlay
statsContainer: {
marginTop: SPACING[4],
marginHorizontal: SPACING[4],
},
// Timer
timerContainer: {
alignItems: 'center',
justifyContent: 'center',
marginTop: SPACING[6],
},
timerInner: {
position: 'absolute',
alignItems: 'center',
},
timerTime: {
...TYPOGRAPHY.TIMER_NUMBER,
color: TEXT.PRIMARY,
fontVariant: ['tabular-nums'],
},
// Controls
controls: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
alignItems: 'center',
},
// Burn Bar
burnBarContainer: {
position: 'absolute',
left: SPACING[4],
right: SPACING[4],
height: 72,
borderRadius: RADIUS.LG,
borderCurve: 'continuous',
overflow: 'hidden',
borderWidth: 1,
borderColor: BORDER_COLORS.DIM,
backgroundColor: NAVY[800],
padding: SPACING[3],
},
// Now Playing
nowPlayingContainer: {
position: 'absolute',
left: SPACING[6],
right: SPACING[6],
},
// Complete
completeSection: {
alignItems: 'center',
marginTop: SPACING[8],
},
completeTitle: {
...TYPOGRAPHY.LARGE_TITLE,
color: TEXT.PRIMARY,
},
completeSubtitle: {
...TYPOGRAPHY.TITLE_3,
marginTop: SPACING[1],
},
completeStats: {
flexDirection: 'row',
marginTop: SPACING[6],
gap: SPACING[8],
},
completeStat: {
alignItems: 'center',
},
completeStatValue: {
...TYPOGRAPHY.TITLE_1,
color: TEXT.PRIMARY,
fontVariant: ['tabular-nums'],
},
completeStatLabel: {
...TYPOGRAPHY.CAPTION_1,
color: TEXT.TERTIARY,
marginTop: SPACING[1],
},
doneButton: {
width: 200,
height: 56,
borderRadius: RADIUS.MD,
borderCurve: 'continuous',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
backgroundColor: GREEN[500],
},
doneButtonText: {
...TYPOGRAPHY.BUTTON_MEDIUM,
color: NAVY[900],
letterSpacing: 1,
},
})

View File

@@ -13,7 +13,7 @@ import { useTranslation } from 'react-i18next'
import { useHaptics } from '@/src/shared/hooks'
import { darkColors, BRAND } from '@/src/shared/theme'
import { SPACING } from '@/src/shared/constants/spacing'
import { TYPOGRAPHY, FONT_FAMILY } from '@/src/shared/constants/typography'
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
export default function PrivacyPolicyScreen() {
const { t } = useTranslation('screens')
@@ -169,7 +169,7 @@ const styles = StyleSheet.create({
},
sectionTitle: {
...TYPOGRAPHY.TITLE_3,
fontFamily: FONT_FAMILY.SANS_BOLD,
fontWeight: '700',
color: darkColors.text.primary,
marginBottom: SPACING[3],
},

View File

@@ -1,65 +1,87 @@
/**
* Tabata Program Detail Screen
* Displays program overview, weeks, sessions, and progression for kiné programs
* Workout Program Detail Screen
* Shows Warmup → 3 Tabatas → Stretch preview, CTA to player.
*/
import React from 'react'
import { View, Text, StyleSheet, ScrollView, Pressable } from 'react-native'
import { useEffect, useState } from 'react'
import { View, Text, StyleSheet, ScrollView, Pressable, ActivityIndicator } from 'react-native'
import { Stack, useRouter, useLocalSearchParams } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { Icon } from '@/src/shared/components/Icon'
import { useTranslation } from 'react-i18next'
import { useTabataProgramStore } from '@/src/shared/stores/tabataProgramStore'
import { getTabataProgramById, getTabataSessionsByWeek } from '@/src/shared/data/tabata'
import { canAccessProgram } from '@/src/shared/services/access'
import { Icon } from '@/src/shared/components/Icon'
import { fetchProgramById, buildWorkoutProgramId } from '@/src/shared/data/workoutPrograms'
import type { WorkoutProgram } from '@/src/shared/types/workoutProgram'
import { BODY_ZONE_META, LEVEL_META } from '@/src/shared/types/workoutProgram'
import { useUserStore } from '@/src/shared/stores/userStore'
import type { TabataProgramId } from '@/src/shared/types/program'
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
import { SPACING } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { TEXT, NAVY, GREEN, BORDER_COLORS, AMBER, DARK } from '@/src/shared/constants/colors'
import { TEXT, NAVY, GREEN, BORDER_COLORS, DARK } from '@/src/shared/constants/colors'
import { withOpacity } from '@/src/shared/utils/color'
export default function TabataProgramDetailScreen() {
const FALLBACK_ACCENT = '#FF6B35'
export default function WorkoutProgramDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>()
const router = useRouter()
const insets = useSafeAreaInsets()
const { t } = useTranslation()
const programId = id as TabataProgramId
const program = getTabataProgramById(programId)
const selectProgram = useTabataProgramStore(s => s.selectProgram)
const progress = useTabataProgramStore(s => s.programsProgress[programId])
const isWeekUnlocked = useTabataProgramStore(s => s.isWeekUnlocked)
const getCurrentSession = useTabataProgramStore(s => s.getCurrentSession)
const completion = useTabataProgramStore(s => s.getProgramCompletion(programId))
const getProgramStatus = useTabataProgramStore(s => s.getProgramStatus)
const [program, setProgram] = useState<WorkoutProgram | null>(null)
const [loading, setLoading] = useState(true)
const isPremium = useUserStore(s => s.profile.subscription) !== 'free'
const canAccess = canAccessProgram(programId, isPremium)
const status = getProgramStatus(programId)
if (!program) {
useEffect(() => {
let cancelled = false
setLoading(true)
fetchProgramById(id)
.then(p => {
if (!cancelled) setProgram(p)
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [id])
if (loading) {
return (
<View style={styles.container}>
<View style={[styles.container, styles.center]}>
<Stack.Screen options={{ headerShown: false }} />
<Text style={styles.errorText}>Programme non trouvé</Text>
<ActivityIndicator color={TEXT.PRIMARY} />
</View>
)
}
const handleStartProgram = () => {
selectProgram(programId)
const session = getCurrentSession(programId)
if (session) {
router.push(`/workout/${session.id}`)
}
if (!program) {
return (
<View style={[styles.container, styles.center]}>
<Stack.Screen options={{ headerShown: false }} />
<Text style={styles.errorText}>{t('screens:program.notFound')}</Text>
</View>
)
}
const handleSessionPress = (sessionId: string) => {
router.push(`/workout/${sessionId}`)
const accent = program.accentColor ?? BODY_ZONE_META[program.bodyZone].color ?? FALLBACK_ACCENT
const level = LEVEL_META[program.level]
const zone = BODY_ZONE_META[program.bodyZone]
const canAccess = program.isFree || isPremium
const handleStart = () => {
if (!canAccess) {
router.push('/paywall')
return
}
router.push(`/player/${buildWorkoutProgramId(program.id)}`)
}
const warmupMinutes = Math.round(program.warmup.totalDuration / 60)
const stretchMinutes = Math.round(program.stretch.totalDuration / 60)
return (
<View style={styles.container}>
<Stack.Screen
@@ -68,193 +90,207 @@ export default function TabataProgramDetailScreen() {
headerTitle: program.title,
headerStyle: { backgroundColor: NAVY[900] },
headerTintColor: TEXT.PRIMARY,
headerBackTitle: 'Retour',
headerBackTitle: t('common:back'),
}}
/>
<ScrollView style={styles.scrollView} contentContainerStyle={{ paddingBottom: insets.bottom + 100 }}>
{/* Program header */}
<View style={[styles.heroSection, { backgroundColor: withOpacity(program.accentColor, 0.12) }]}>
<View style={[styles.iconCircle, { backgroundColor: withOpacity(program.accentColor, 0.19) }]}>
<Icon name={program.icon as any} size={32} tintColor={program.accentColor} />
<ScrollView style={styles.scroll} contentContainerStyle={{ paddingBottom: insets.bottom + 120 }}>
{/* Hero */}
<View style={[styles.hero, { backgroundColor: withOpacity(accent, 0.12) }]}>
<View style={[styles.iconCircle, { backgroundColor: withOpacity(accent, 0.2) }]}>
<Icon name={(program.icon ?? zone.icon) as any} size={32} tintColor={accent} />
</View>
<Text style={styles.programTitle}>{program.title}</Text>
<Text style={styles.programDescription}>{program.description}</Text>
<Text style={styles.title}>{program.title}</Text>
{program.description && <Text style={styles.description}>{program.description}</Text>}
{/* Tier badge */}
<View style={[styles.tierBadge, { borderColor: program.accentColor }]}>
<Text style={[styles.tierBadgeText, { color: program.accentColor }]}>
{program.tier === 'free' ? 'GRATUIT' : 'PREMIUM'}
</Text>
</View>
{/* Stats row */}
<View style={styles.statsRow}>
<View style={styles.statItem}>
<Text style={styles.statValue}>{program.durationWeeks}</Text>
<Text style={styles.statLabel}>Semaines</Text>
<View style={styles.badgeRow}>
<View style={[styles.badge, { borderColor: level.color }]}>
<Text style={[styles.badgeText, { color: level.color }]}>{level.label}</Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>{program.sessionsPerWeek}</Text>
<Text style={styles.statLabel}>Séances/sem</Text>
<View style={[styles.badge, { borderColor: zone.color }]}>
<Text style={[styles.badgeText, { color: zone.color }]}>{zone.label}</Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>{program.totalSessions}</Text>
<Text style={styles.statLabel}>Séances</Text>
</View>
</View>
{/* Progress */}
{status === 'in-progress' && (
<View style={styles.progressSection}>
<View style={styles.progressBar}>
<View style={[styles.progressFill, { width: `${completion}%`, backgroundColor: program.accentColor }]} />
{!program.isFree && (
<View style={[styles.badge, { borderColor: accent }]}>
<Text style={[styles.badgeText, { color: accent }]}>{t('screens:home.premiumBadge')}</Text>
</View>
<Text style={styles.progressText}>{completion}% complété</Text>
</View>
)}
)}
</View>
<View style={styles.statsRow}>
<Stat value={program.estimatedDuration} label={t('common:units.min')} />
<Stat value={program.tabatas.length} label="Tabatas" />
<Stat value={program.estimatedCalories} label={t('common:units.cal')} />
</View>
</View>
{/* Principles */}
{program.principles.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Principes</Text>
{program.principles.map((p, i) => (
<View key={i} style={styles.principleItem}>
<Text style={styles.principleBullet}></Text>
<Text style={styles.principleText}>{p}</Text>
</View>
))}
</View>
)}
{/* Warmup */}
<Section
title={t('screens:program.warmup')}
subtitle={`${warmupMinutes} ${t('common:units.min')}`}
accent="#FFC86B"
>
{program.warmup.exercises.map((ex, i) => (
<Row key={`w-${i}`} label={ex.name} detail={`${ex.duration}s`} />
))}
</Section>
{/* Weeks */}
{program.weeks.map(week => {
const unlocked = isWeekUnlocked(programId, week.weekNumber)
return (
<View key={week.weekNumber} style={styles.weekSection}>
<View style={styles.weekHeader}>
<Text style={styles.weekTitle}>Semaine {week.weekNumber}: {week.title}</Text>
{week.isDeload && (
<View style={styles.deloadBadge}>
<Text style={styles.deloadText}>Décharge</Text>
</View>
)}
{!unlocked && (
<Icon name="lock" size={16} tintColor={TEXT.TERTIARY} />
)}
</View>
<Text style={styles.weekFocus}>{week.focus}</Text>
{/* Tabatas */}
{program.tabatas.map((tabata, i) => (
<Section
key={tabata.id}
title={t('screens:program.tabataLabel', { num: i + 1 })}
subtitle={t('screens:program.tabataSubtitle', {
rounds: tabata.rounds,
work: tabata.workTime,
rest: tabata.restTime,
})}
accent={accent}
>
<Row label={tabata.exercise1.name} detail={t('screens:program.exercise1')} />
<Row label={tabata.exercise2.name} detail={t('screens:program.exercise2')} />
</Section>
))}
{/* Sessions */}
{week.sessions.map(session => {
const isCompleted = progress?.completedSessionIds.includes(session.id) ?? false
const sessionLocked = !unlocked
return (
<Pressable
key={session.id}
style={[styles.sessionCard, sessionLocked && styles.sessionCardLocked]}
onPress={() => !sessionLocked && canAccess && handleSessionPress(session.id)}
disabled={sessionLocked || !canAccess}
>
<View style={styles.sessionInfo}>
<View style={[styles.sessionDot, {
backgroundColor: isCompleted ? GREEN[500] : sessionLocked ? BORDER_COLORS.DIM : program.accentColor,
}]} />
<View style={{ flex: 1 }}>
<Text style={[styles.sessionTitle, sessionLocked && { opacity: 0.4 }]}>
Séance {session.order}: {session.title}
</Text>
<Text style={styles.sessionMeta}>
{session.blocks.length} bloc{session.blocks.length > 1 ? 's' : ''} · {session.totalDuration} min · {session.calories} cal
</Text>
</View>
{isCompleted && <Icon name="checkmark.circle" size={20} tintColor={GREEN[500]} />}
{!canAccess && !sessionLocked && <Icon name="lock" size={16} tintColor={TEXT.TERTIARY} />}
</View>
</Pressable>
)
})}
</View>
)
})}
{/* Completion criteria */}
{program.completionCriteria.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Critères de passage</Text>
{program.completionCriteria.map((c, i) => (
<View key={i} style={styles.principleItem}>
<Text style={styles.principleBullet}></Text>
<Text style={styles.principleText}>{c}</Text>
</View>
))}
</View>
)}
{/* Stretch */}
<Section
title={t('screens:program.stretch')}
subtitle={`${stretchMinutes} ${t('common:units.min')}`}
accent="#B4A7E5"
>
{program.stretch.exercises.map((ex, i) => (
<Row key={`s-${i}`} label={ex.name} detail={`${ex.duration}s`} />
))}
</Section>
</ScrollView>
{/* CTA */}
<View style={[styles.ctaContainer, { paddingBottom: insets.bottom + SPACING[4] }]}>
{canAccess ? (
<Pressable style={[styles.ctaButton, { backgroundColor: program.accentColor }]} onPress={handleStartProgram}>
<Text style={styles.ctaText}>
{status === 'in-progress' ? 'Continuer le programme' : status === 'completed' ? 'Recommencer' : 'Commencer le programme'}
</Text>
</Pressable>
) : (
<Pressable style={[styles.ctaButton, { backgroundColor: GREEN[500] }]} onPress={() => router.push('/paywall')}>
<Text style={styles.ctaText}>Débloquer avec Premium</Text>
</Pressable>
)}
<Pressable
style={[styles.ctaButton, { backgroundColor: canAccess ? accent : GREEN[500] }]}
onPress={handleStart}
>
<Text style={styles.ctaText}>
{canAccess ? t('screens:program.startSession') : t('screens:program.unlockPremium')}
</Text>
</Pressable>
</View>
</View>
)
}
function Stat({ value, label }: { value: number; label: string }) {
return (
<View style={styles.statItem}>
<Text style={styles.statValue}>{value}</Text>
<Text style={styles.statLabel}>{label}</Text>
</View>
)
}
function Section({
title,
subtitle,
accent,
children,
}: {
title: string
subtitle: string
accent: string
children: React.ReactNode
}) {
return (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<View style={[styles.sectionDot, { backgroundColor: accent }]} />
<Text style={styles.sectionTitle}>{title}</Text>
<Text style={styles.sectionSubtitle}>{subtitle}</Text>
</View>
<View style={styles.sectionBody}>{children}</View>
</View>
)
}
function Row({ label, detail }: { label: string; detail: string }) {
return (
<View style={styles.row}>
<Text style={styles.rowLabel} numberOfLines={1}>
{label}
</Text>
<Text style={styles.rowDetail}>{detail}</Text>
</View>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: NAVY[900] },
scrollView: { flex: 1 },
center: { alignItems: 'center', justifyContent: 'center' },
scroll: { flex: 1 },
errorText: { color: TEXT.SECONDARY, ...TYPOGRAPHY.BODY },
heroSection: { padding: SPACING[6], alignItems: 'center' },
iconCircle: { width: 64, height: 64, borderRadius: 32, alignItems: 'center', justifyContent: 'center', marginBottom: SPACING[3] },
programTitle: { ...TYPOGRAPHY.LARGE_TITLE, color: TEXT.PRIMARY, textAlign: 'center' },
programDescription: { ...TYPOGRAPHY.BODY, color: TEXT.SECONDARY, textAlign: 'center', marginTop: SPACING[2], lineHeight: 22 },
tierBadge: { marginTop: SPACING[3], paddingHorizontal: SPACING[2], paddingVertical: 3, borderRadius: RADIUS.SM, borderWidth: 1 },
tierBadgeText: { ...TYPOGRAPHY.LABEL },
hero: { padding: SPACING[6], alignItems: 'center' },
iconCircle: {
width: 64,
height: 64,
borderRadius: 32,
alignItems: 'center',
justifyContent: 'center',
marginBottom: SPACING[3],
},
title: { ...TYPOGRAPHY.LARGE_TITLE, color: TEXT.PRIMARY, textAlign: 'center' },
description: {
...TYPOGRAPHY.BODY,
color: TEXT.SECONDARY,
textAlign: 'center',
marginTop: SPACING[2],
lineHeight: 22,
},
badgeRow: { flexDirection: 'row', gap: SPACING[2], marginTop: SPACING[3], flexWrap: 'wrap', justifyContent: 'center' },
badge: { paddingHorizontal: SPACING[2], paddingVertical: 3, borderRadius: RADIUS.SM, borderWidth: 1 },
badgeText: { ...TYPOGRAPHY.LABEL },
statsRow: { flexDirection: 'row', marginTop: SPACING[6], gap: SPACING[8] },
statItem: { alignItems: 'center' },
statValue: { ...TYPOGRAPHY.TITLE_2, color: TEXT.PRIMARY },
statLabel: { ...TYPOGRAPHY.CAPTION_1, color: TEXT.TERTIARY, marginTop: 2 },
progressSection: { marginTop: SPACING[4], width: '100%' },
progressBar: { height: 4, borderRadius: RADIUS.PILL, backgroundColor: BORDER_COLORS.DIM, overflow: 'hidden' },
progressFill: { height: '100%', borderRadius: RADIUS.PILL },
progressText: { ...TYPOGRAPHY.CAPTION_1, color: TEXT.TERTIARY, marginTop: SPACING[1], textAlign: 'center' },
section: { paddingHorizontal: SPACING[5], marginTop: SPACING[6] },
sectionTitle: { ...TYPOGRAPHY.HEADLINE, color: TEXT.PRIMARY, marginBottom: SPACING[3] },
principleItem: { flexDirection: 'row', gap: SPACING[2], marginBottom: SPACING[2] },
principleBullet: { color: TEXT.TERTIARY, fontSize: 14 },
principleText: { ...TYPOGRAPHY.SUBHEADLINE, color: TEXT.SECONDARY, flex: 1, lineHeight: 20 },
sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: SPACING[2], marginBottom: SPACING[3] },
sectionDot: { width: 8, height: 8, borderRadius: 4 },
sectionTitle: { ...TYPOGRAPHY.HEADLINE, color: TEXT.PRIMARY, flex: 1 },
sectionSubtitle: { ...TYPOGRAPHY.CAPTION_1, color: TEXT.TERTIARY },
weekSection: { paddingHorizontal: SPACING[5], marginTop: SPACING[6] },
weekHeader: { flexDirection: 'row', alignItems: 'center', gap: SPACING[2] },
weekTitle: { ...TYPOGRAPHY.TITLE_3, color: TEXT.PRIMARY, flex: 1 },
weekFocus: { ...TYPOGRAPHY.CAPTION_1, color: TEXT.TERTIARY, marginTop: SPACING[1], marginBottom: SPACING[3] },
deloadBadge: { backgroundColor: withOpacity(AMBER[500], 0.2), paddingHorizontal: SPACING[2], paddingVertical: 2, borderRadius: RADIUS.SM },
deloadText: { ...TYPOGRAPHY.LABEL, color: AMBER[500] },
sectionBody: {
backgroundColor: NAVY[800],
borderRadius: RADIUS.MD,
borderWidth: 1,
borderColor: BORDER_COLORS.DIM,
overflow: 'hidden',
},
row: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: SPACING[3],
paddingVertical: SPACING[3],
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: BORDER_COLORS.DIM,
gap: SPACING[3],
},
rowLabel: { ...TYPOGRAPHY.SUBHEADLINE, color: TEXT.PRIMARY, flex: 1 },
rowDetail: { ...TYPOGRAPHY.CAPTION_1, color: TEXT.TERTIARY },
sessionCard: { backgroundColor: NAVY[800], borderRadius: RADIUS.MD, padding: SPACING[3], marginBottom: SPACING[2], borderWidth: 1, borderColor: BORDER_COLORS.DIM },
sessionCardLocked: { opacity: 0.5 },
sessionInfo: { flexDirection: 'row', alignItems: 'center', gap: SPACING[3] },
sessionDot: { width: 8, height: 8, borderRadius: 4 },
sessionTitle: { ...TYPOGRAPHY.SUBHEADLINE, color: TEXT.PRIMARY },
sessionMeta: { ...TYPOGRAPHY.CAPTION_2, color: TEXT.TERTIARY, marginTop: 2 },
ctaContainer: { position: 'absolute', bottom: 0, left: 0, right: 0, paddingHorizontal: SPACING[5], paddingTop: SPACING[3], backgroundColor: DARK.SCRIM, borderTopWidth: 1, borderTopColor: BORDER_COLORS.DIM },
ctaContainer: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
paddingHorizontal: SPACING[5],
paddingTop: SPACING[3],
backgroundColor: DARK.SCRIM,
borderTopWidth: 1,
borderTopColor: BORDER_COLORS.DIM,
},
ctaButton: { height: 52, borderRadius: RADIUS.MD, alignItems: 'center', justifyContent: 'center' },
ctaText: { ...TYPOGRAPHY.BUTTON_MEDIUM, color: NAVY[900], letterSpacing: 0.5 },
errorText: { color: TEXT.SECONDARY, textAlign: 'center', marginTop: 100 },
})

223
app/settings.tsx Normal file
View File

@@ -0,0 +1,223 @@
/**
* Settings FormSheet
* Profile, preferences, premium, legal, reset progress.
*/
import { View, Text, StyleSheet, ScrollView, Pressable, Switch, Alert } from 'react-native'
import { useRouter } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useTranslation } from 'react-i18next'
import { Icon, type IconName } from '@/src/shared/components/Icon'
import { useUserStore } from '@/src/shared/stores/userStore'
import { useProgressStore } from '@/src/shared/stores/progressStore'
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
import { SPACING } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { TEXT, NAVY, GREEN, BORDER_COLORS } from '@/src/shared/constants/colors'
export default function SettingsScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
const { t } = useTranslation()
const profile = useUserStore(s => s.profile)
const settings = useUserStore(s => s.settings)
const updateSettings = useUserStore(s => s.updateSettings)
const resetProgress = useProgressStore(s => s.resetProgress)
const isPremium = profile.subscription !== 'free'
const handleResetProgress = () => {
Alert.alert(
t('screens:settings.resetTitle'),
t('screens:settings.resetMessage'),
[
{ text: t('common:cancel'), style: 'cancel' },
{
text: t('screens:settings.resetConfirm'),
style: 'destructive',
onPress: () => {
resetProgress()
},
},
],
)
}
return (
<ScrollView
style={styles.container}
contentContainerStyle={{ padding: SPACING[5], paddingBottom: insets.bottom + SPACING[8] }}
>
<Text style={styles.header}>{t('screens:settings.title')}</Text>
{/* Profile */}
<Section title={t('screens:settings.sectionProfile')}>
<Row label={t('screens:settings.name')} value={profile.name || '—'} />
<Row
label={t('screens:settings.subscription')}
value={isPremium ? t('screens:settings.premium') : t('screens:settings.free')}
accent={isPremium ? GREEN[500] : undefined}
/>
{!isPremium && (
<LinkRow
icon="sparkles"
label={t('screens:settings.upgradePremium')}
onPress={() => router.push('/paywall')}
accent={GREEN[500]}
/>
)}
</Section>
{/* Preferences */}
<Section title={t('screens:settings.sectionPrefs')}>
<SwitchRow
label={t('screens:settings.haptics')}
value={settings.haptics}
onChange={v => updateSettings({ haptics: v })}
/>
<SwitchRow
label={t('screens:settings.soundEffects')}
value={settings.soundEffects}
onChange={v => updateSettings({ soundEffects: v })}
/>
<SwitchRow
label={t('screens:settings.voiceCoaching')}
value={settings.voiceCoaching}
onChange={v => updateSettings({ voiceCoaching: v })}
/>
<SwitchRow
label={t('screens:settings.music')}
value={settings.musicEnabled}
onChange={v => updateSettings({ musicEnabled: v })}
/>
</Section>
{/* Legal */}
<Section title={t('screens:settings.sectionLegal')}>
<LinkRow icon="doc.text" label={t('screens:settings.terms')} onPress={() => router.push('/terms')} />
<LinkRow icon="lock.shield" label={t('screens:settings.privacy')} onPress={() => router.push('/privacy')} />
</Section>
{/* Danger */}
<Section title={t('screens:settings.sectionData')}>
<Pressable onPress={handleResetProgress} style={styles.dangerRow}>
<Icon name="trash" size={18} tintColor="#FF453A" />
<Text style={styles.dangerText}>{t('screens:settings.resetProgress')}</Text>
</Pressable>
</Section>
<Text style={styles.version}>{t('screens:settings.version')}</Text>
</ScrollView>
)
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{title}</Text>
<View style={styles.sectionBody}>{children}</View>
</View>
)
}
function Row({ label, value, accent }: { label: string; value: string; accent?: string }) {
return (
<View style={styles.row}>
<Text style={styles.rowLabel}>{label}</Text>
<Text style={[styles.rowValue, accent && { color: accent }]}>{value}</Text>
</View>
)
}
function SwitchRow({
label,
value,
onChange,
}: {
label: string
value: boolean
onChange: (value: boolean) => void
}) {
return (
<View style={styles.row}>
<Text style={styles.rowLabel}>{label}</Text>
<Switch value={value} onValueChange={onChange} trackColor={{ true: GREEN[500] }} />
</View>
)
}
function LinkRow({
icon,
label,
onPress,
accent,
}: {
icon: IconName
label: string
onPress: () => void
accent?: string
}) {
return (
<Pressable onPress={onPress} style={styles.row}>
<View style={styles.linkLeft}>
<Icon name={icon} size={18} tintColor={accent ?? TEXT.SECONDARY} />
<Text style={[styles.rowLabel, accent && { color: accent, fontWeight: '600' }]}>{label}</Text>
</View>
<Icon name="chevron.right" size={16} tintColor={TEXT.TERTIARY} />
</Pressable>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: NAVY[900] },
header: { ...TYPOGRAPHY.LARGE_TITLE, color: TEXT.PRIMARY, marginBottom: SPACING[4] },
section: { marginBottom: SPACING[6] },
sectionTitle: {
...TYPOGRAPHY.CAPTION_1,
color: TEXT.TERTIARY,
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: SPACING[2],
paddingHorizontal: SPACING[2],
},
sectionBody: {
backgroundColor: NAVY[800],
borderRadius: RADIUS.MD,
borderWidth: 1,
borderColor: BORDER_COLORS.DIM,
overflow: 'hidden',
},
row: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: SPACING[4],
paddingVertical: SPACING[3],
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: BORDER_COLORS.DIM,
gap: SPACING[3],
},
rowLabel: { ...TYPOGRAPHY.BODY, color: TEXT.PRIMARY },
rowValue: { ...TYPOGRAPHY.BODY, color: TEXT.SECONDARY },
linkLeft: { flexDirection: 'row', alignItems: 'center', gap: SPACING[3] },
dangerRow: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING[3],
paddingHorizontal: SPACING[4],
paddingVertical: SPACING[3],
},
dangerText: { ...TYPOGRAPHY.BODY, color: '#FF453A' },
version: {
...TYPOGRAPHY.CAPTION_1,
color: TEXT.TERTIARY,
textAlign: 'center',
marginTop: SPACING[4],
},
})

106
app/terms.tsx Normal file
View File

@@ -0,0 +1,106 @@
/**
* TabataFit Terms of Service Screen
* Features: F-027, F-029, F-100, F-129
*/
import { useMemo } from 'react'
import { View, StyleSheet, ScrollView } from 'react-native'
import { Stack } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useTranslation } from 'react-i18next'
import { StyledText } from '@/src/shared/components/StyledText'
import { useThemeColors } 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 { NAVY, BORDER_COLORS, GREEN } from '@/src/shared/constants/colors'
const SECTIONS = [
'acceptance',
'service',
'subscriptions',
'cancellation',
'liability',
'contact',
] as const
export default function TermsScreen() {
const { t } = useTranslation()
const colors = useThemeColors()
const insets = useSafeAreaInsets()
const styles = useMemo(() => createStyles(colors), [colors])
return (
<>
<Stack.Screen
options={{
headerShown: true,
headerStyle: { backgroundColor: colors.bg.base },
headerShadowVisible: false,
headerTitle: t('screens:terms.title'),
headerTintColor: colors.text.primary,
headerBackButtonDisplayMode: 'minimal',
}}
/>
<ScrollView
style={styles.container}
contentContainerStyle={[
styles.content,
{ paddingBottom: insets.bottom + SPACING[8] },
]}
contentInsetAdjustmentBehavior="automatic"
>
<StyledText preset="CAPTION_1" style={styles.lastUpdated}>
{t('screens:terms.lastUpdated')}
</StyledText>
{SECTIONS.map((section) => (
<View key={section} style={styles.section}>
<StyledText preset="HEADING_2" style={styles.sectionTitle}>
{t(`screens:terms.${section}.title`)}
</StyledText>
<StyledText preset="BODY" style={styles.sectionContent}>
{t(`screens:terms.${section}.content`)}
</StyledText>
</View>
))}
<StyledText preset="CAPTION_1" style={styles.email}>
support@tabatafit.app
</StyledText>
</ScrollView>
</>
)
}
function createStyles(colors: ThemeColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bg.base,
},
content: {
paddingHorizontal: LAYOUT.SCREEN_PADDING,
paddingTop: SPACING[4],
gap: SPACING[6],
},
lastUpdated: {
color: colors.text.tertiary,
},
section: {
gap: SPACING[2],
},
sectionTitle: {
color: colors.text.primary,
},
sectionContent: {
color: colors.text.secondary,
lineHeight: 24,
},
email: {
color: GREEN[500],
textAlign: 'center',
marginTop: SPACING[4],
},
})
}

244
app/zone/[bodyZone].tsx Normal file
View File

@@ -0,0 +1,244 @@
/**
* Body Zone Detail Screen
* Segmented level selector (Beginner default) + program list filtered by zone+level.
*/
import { useEffect, useMemo, useState } from 'react'
import { View, Text, StyleSheet, ScrollView, Pressable, ActivityIndicator } from 'react-native'
import { Stack, useRouter, useLocalSearchParams } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useTranslation } from 'react-i18next'
import { Icon } from '@/src/shared/components/Icon'
import { fetchProgramsByBodyZone } from '@/src/shared/data/workoutPrograms'
import {
BODY_ZONE_META,
LEVEL_META,
type BodyZone,
type ProgramLevel,
type WorkoutProgram,
} from '@/src/shared/types/workoutProgram'
import { useProgressStore } from '@/src/shared/stores/progressStore'
import { useUserStore } from '@/src/shared/stores/userStore'
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
import { SPACING } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { TEXT, NAVY, GREEN, BORDER_COLORS } from '@/src/shared/constants/colors'
import { withOpacity } from '@/src/shared/utils/color'
const LEVELS: ProgramLevel[] = ['Beginner', 'Intermediate', 'Advanced']
const VALID_ZONES: BodyZone[] = ['upper-body', 'lower-body', 'full-body']
export default function BodyZoneScreen() {
const { bodyZone } = useLocalSearchParams<{ bodyZone: string }>()
const router = useRouter()
const insets = useSafeAreaInsets()
const { t } = useTranslation()
const zone = (VALID_ZONES.includes(bodyZone as BodyZone) ? bodyZone : 'full-body') as BodyZone
const meta = BODY_ZONE_META[zone]
const [programs, setPrograms] = useState<WorkoutProgram[]>([])
const [loading, setLoading] = useState(true)
const [selectedLevel, setSelectedLevel] = useState<ProgramLevel>('Beginner')
const isProgramCompleted = useProgressStore(s => s.isProgramCompleted)
const isPremium = useUserStore(s => s.profile.subscription) !== 'free'
useEffect(() => {
let cancelled = false
setLoading(true)
fetchProgramsByBodyZone(zone)
.then(list => {
if (!cancelled) setPrograms(list)
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [zone])
const filtered = useMemo(
() => programs.filter(p => p.level === selectedLevel).sort((a, b) => a.sortOrder - b.sortOrder),
[programs, selectedLevel],
)
return (
<View style={styles.container}>
<Stack.Screen
options={{
headerShown: true,
headerTitle: meta.label,
headerStyle: { backgroundColor: NAVY[900] },
headerTintColor: TEXT.PRIMARY,
headerBackTitle: t('common:back'),
}}
/>
<ScrollView
style={styles.scroll}
contentContainerStyle={{ padding: SPACING[5], paddingBottom: insets.bottom + SPACING[6] }}
>
{/* Zone header */}
<View style={[styles.zoneHeader, { backgroundColor: withOpacity(meta.color, 0.12) }]}>
<View style={[styles.iconCircle, { backgroundColor: withOpacity(meta.color, 0.25) }]}>
<Icon name={meta.icon as any} size={28} tintColor={meta.color} />
</View>
<View style={{ flex: 1 }}>
<Text style={styles.zoneTitle}>{meta.label}</Text>
<Text style={styles.zoneSubtitle}>{t('screens:zone.chooseLevel')}</Text>
</View>
</View>
{/* Level segmented */}
<View style={styles.segmented}>
{LEVELS.map(level => {
const active = selectedLevel === level
const levelMeta = LEVEL_META[level]
return (
<Pressable
key={level}
onPress={() => setSelectedLevel(level)}
style={[
styles.segment,
active && {
backgroundColor: withOpacity(levelMeta.color, 0.2),
borderColor: levelMeta.color,
},
]}
>
<Text
style={[
styles.segmentText,
active && { color: levelMeta.color, fontWeight: '600' },
]}
>
{levelMeta.label}
</Text>
</Pressable>
)
})}
</View>
{/* Program list */}
{loading ? (
<ActivityIndicator color={TEXT.PRIMARY} style={{ marginTop: SPACING[8] }} />
) : filtered.length === 0 ? (
<Text style={styles.empty}>{t('screens:zone.emptyPrograms')}</Text>
) : (
<View style={styles.programList}>
{filtered.map(program => (
<ProgramCard
key={program.id}
program={program}
completed={isProgramCompleted(program.id)}
locked={!program.isFree && !isPremium}
onPress={() => router.push(`/program/${program.id}`)}
/>
))}
</View>
)}
</ScrollView>
</View>
)
}
function ProgramCard({
program,
completed,
locked,
onPress,
}: {
program: WorkoutProgram
completed: boolean
locked: boolean
onPress: () => void
}) {
const accent = program.accentColor ?? BODY_ZONE_META[program.bodyZone].color
return (
<Pressable
onPress={onPress}
style={({ pressed }) => [styles.programCard, pressed && { opacity: 0.85 }]}
>
<View style={[styles.programDot, { backgroundColor: accent }]} />
<View style={{ flex: 1 }}>
<Text style={styles.programTitle} numberOfLines={1}>
{program.title}
</Text>
<Text style={styles.programMeta}>
{program.estimatedDuration} min · {program.tabatas.length} tabatas · {program.estimatedCalories} cal
</Text>
</View>
{completed && <Icon name="checkmark.circle.fill" size={20} tintColor={GREEN[500]} />}
{locked && <Icon name="lock.fill" size={16} tintColor={TEXT.TERTIARY} />}
{!completed && !locked && <Icon name="chevron.right" size={16} tintColor={TEXT.TERTIARY} />}
</Pressable>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: NAVY[900] },
scroll: { flex: 1 },
zoneHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING[3],
padding: SPACING[4],
borderRadius: RADIUS.LG,
marginBottom: SPACING[5],
},
iconCircle: {
width: 56,
height: 56,
borderRadius: 28,
alignItems: 'center',
justifyContent: 'center',
},
zoneTitle: { ...TYPOGRAPHY.TITLE_2, color: TEXT.PRIMARY },
zoneSubtitle: { ...TYPOGRAPHY.SUBHEADLINE, color: TEXT.SECONDARY, marginTop: 2 },
segmented: {
flexDirection: 'row',
backgroundColor: NAVY[800],
borderRadius: RADIUS.MD,
padding: 4,
gap: 4,
marginBottom: SPACING[5],
borderWidth: 1,
borderColor: BORDER_COLORS.DIM,
},
segment: {
flex: 1,
paddingVertical: SPACING[2],
borderRadius: RADIUS.SM,
alignItems: 'center',
borderWidth: 1,
borderColor: 'transparent',
},
segmentText: { ...TYPOGRAPHY.SUBHEADLINE, color: TEXT.SECONDARY },
programList: { gap: SPACING[3] },
programCard: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING[3],
padding: SPACING[4],
backgroundColor: NAVY[800],
borderRadius: RADIUS.MD,
borderWidth: 1,
borderColor: BORDER_COLORS.DIM,
},
programDot: { width: 10, height: 10, borderRadius: 5 },
programTitle: { ...TYPOGRAPHY.HEADLINE, color: TEXT.PRIMARY },
programMeta: { ...TYPOGRAPHY.CAPTION_1, color: TEXT.TERTIARY, marginTop: 2 },
empty: {
...TYPOGRAPHY.BODY,
color: TEXT.SECONDARY,
textAlign: 'center',
marginTop: SPACING[8],
},
})