diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index d4553b9..a5c65a7 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -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, }} > - + - + - + - + - + - + ) diff --git a/app/(tabs)/activity.tsx b/app/(tabs)/activity.tsx index a42f82d..2cee486 100644 --- a/app/(tabs)/activity.tsx +++ b/app/(tabs)/activity.tsx @@ -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 ( - - ) -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 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 ( - - - - - - {String(value)} - - - {label} - - - - - - ) -} - -// ═══════════════════════════════════════════════════════════════════════════ -// WEEKLY BAR -// ═══════════════════════════════════════════════════════════════════════════ - -function WeeklyBar({ - day, - completed, - isToday, -}: { - day: string - completed: boolean - isToday: boolean -}) { - const colors = useThemeColors() - const styles = useMemo(() => createStyles(colors), [colors]) - return ( - - - - {day} - - - ) -} - -// ═══════════════════════════════════════════════════════════════════════════ -// EMPTY STATE -// ═══════════════════════════════════════════════════════════════════════════ - -function EmptyState({ onStartWorkout }: { onStartWorkout: () => void }) { - const { t } = useTranslation() - const colors = useThemeColors() - const styles = useMemo(() => createStyles(colors), [colors]) - - return ( - - - - - - {t('screens:activity.emptyTitle')} - - - {t('screens:activity.emptySubtitle')} - - - - - - ) -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 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 ( - - - {/* Header */} - - {t('screens:activity.title')} - + + {t('screens:tabs.progression')} - {/* Empty state when no history */} - {history.length === 0 ? ( - router.push('/(tabs)' as any)} /> - ) : ( - <> - {/* Streak Banner */} - - - - - - - - {String(streak.current || 0)} - - - {t('screens:activity.dayStreak')} - - - - - {t('screens:activity.longest')} - - - {String(streak.longest)} - - - - + {/* Streak hero */} + + + {streak.current} + {t('screens:activity.dayStreak')} + + {t('screens:activity.longest')}: {streak.longest} + + - {/* Stats Grid — 2x2 */} - - - - - - + {/* Stats grid */} + + + + + - {/* This Week */} - - - {t('screens:activity.thisWeek')} - - - - {weeklyActivity.map((d, i) => ( - - ))} - - - - {t('screens:activity.ofDays', { completed: weeklyActivity.filter(d => d.completed).length })} - - - - - - {/* Recent Workouts */} - {recentWorkouts.length > 0 && ( - - - {t('screens:activity.recent')} - - - {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 ( - - - - - - - - {workoutTitle} - - - {formatDate(result.completedAt) + ' \u00B7 ' + t('units.minUnit', { count: result.durationMinutes })} - - - - {t('units.calUnit', { count: result.calories })} - - - {idx < recentWorkouts.length - 1 && } - - ) - })} - - - )} - - {/* Achievements */} - - - {t('screens:activity.achievements')} - - - {displayAchievements.map((a) => ( - - - - - - {t(`content:achievements.${a.id}.title`, { defaultValue: a.title })} - + {/* Recent history */} + {history.length > 0 && ( + + {t('screens:activity.recent')} + {history.slice(0, 10).map((session, i) => ( + + + + {session.programId} + + {Math.round(session.durationSeconds / 60)} min + {' · '} + {new Date(session.completedAt).toLocaleDateString()} + - ))} - + + ))} - - )} - + )} + + {history.length === 0 && ( + + {t('screens:activity.emptyTitle')} + {t('screens:activity.emptySubtitle')} + + )} + + ) +} + +function StatCard({ icon, value, label, color }: { icon: any; value: number; label: string; color: string }) { + return ( + + + {value} + {label} ) } -// ═══════════════════════════════════════════════════════════════════════════ -// 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' }, }) } diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index e8add5c..fff039b 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -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 ( - [ - styles.bodyZoneCard, - { - backgroundColor: colors.surface.default.backgroundColor, - borderColor: colors.border.dim, - opacity: pressed ? 0.85 : 1, - }, - ]} - testID={`zone-card-${bodyZone}`} - > - - - - - - - {meta.label} - - - {programCount} programme{programCount !== 1 ? 's' : ''} - - - - - - ) -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 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 ( - - - - - - - {t('home.recommendedNext')} - - - - {recommended.title} - - - {zoneMeta.label} · {recommended.estimatedDuration} min · ~{recommended.estimatedCalories} kcal - - - - ) -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 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 ( - - {stats.map((stat) => ( - - - - {String(stat.value)} - - - {stat.label} - - - ))} - - ) -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 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 ( - [ - styles.tabataLinkCard, - { - backgroundColor: colors.surface.default.backgroundColor, - borderColor: colors.border.dim, - opacity: pressed ? 0.85 : 1, - }, - ]} - > - - - - - - - {t('home.tabataPrograms')} - - - {t('home.tabataProgramsSubtitle')} - - - - - - ) -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 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([]) + 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 = { - '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 ( - - - {/* Hero Section */} - - - - - - {greeting} - - {/* Inline streak badge */} - {streak.current > 0 && ( - - - - {streak.current} - - - )} - - - {userName} - - - {t('home.programsByZone')} - - - - - + + {/* Header with settings */} + + TabataGo + router.push('/settings')} style={styles.iconBtn} hitSlop={8}> + + + - {/* Quick Stats Row */} - + {/* Mascot */} + + + - {/* Continue Session (if in progress) */} - + {/* Stats pills */} + + + + + - {/* Body Zone Cards */} - {BODY_ZONE_ORDER.map((zone) => ( - + {/* Body zone cards */} + {t('screens:zone.chooseYourFocus')} + + {BODY_ZONES.map(zone => ( + router.push(`/zone/${zone}`)} /> ))} + + + ) +} - {/* Tabata Programs Link */} - - +function StatPill({ + value, + label, + icon, + color, +}: { + value: number + label: string + icon: any + color: string +}) { + return ( + + + {value} + {label} ) } -// ═══════════════════════════════════════════════════════════════════════════ -// STYLES -// ═══════════════════════════════════════════════════════════════════════════ +function ZoneCard({ zone, onPress }: { zone: BodyZone; onPress: () => void }) { + const meta = BODY_ZONE_META[zone] + const { t } = useTranslation() + return ( + [ + styles.zoneCard, + { borderColor: withOpacity(meta.color, 0.3) }, + pressed && { opacity: 0.85, transform: [{ scale: 0.98 }] }, + ]} + > + {/* Colored top strip with large icon */} + + + + + + + {/* Content area */} + + {meta.label} + + {t(meta.descKey)} + + + {/* Bottom row: level badge + chevron */} + + + + {t('screens:home.zoneLevels')} + + + + + + + ) +} 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 }, }) diff --git a/app/(tabs)/profile.tsx b/app/(tabs)/profile.tsx index c8e7a53..aecbf56 100644 --- a/app/(tabs)/profile.tsx +++ b/app/(tabs)/profile.tsx @@ -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 ( - - - {/* ════════════════════════════════════════════════════════════════════ - PROFILE HEADER - ═══════════════════════════════════════════════════════════════════ */} - - - - {avatarInitial} - + + {/* Avatar + name */} + + + {avatarLetter} - - - - {profile.name || t('profile.guest')} - - - - {planLabel} - - {isPremium && ( - - )} - - - - - - - 🔥 {stats.workouts} - - - {t('profile.statsWorkouts')} - - - - - 📅 {stats.streak} - - - {t('profile.statsStreak')} - - - - - ⚡️ {Math.round(stats.calories / 1000)}k - - - {t('profile.statsCalories')} - + + {profile.name || t('screens:profile.guest')} + + + {isPremium ? t('screens:settings.premium') : t('screens:settings.free')} + + router.push('/settings')} hitSlop={8}> + + - {/* ════════════════════════════════════════════════════════════════════ - UPGRADE CTA (FREE USERS ONLY) - ═══════════════════════════════════════════════════════════════════ */} + {/* Stats row */} + + + + + + + {/* Upgrade banner (free users) */} {!isPremium && ( - - router.push('/paywall')}> - - - ✨ {t('profile.upgradeTitle')} - - - {t('profile.upgradeDescription')} - - - - {t('profile.learnMore')} → - - - + router.push('/paywall')} + > + + + {t('screens:profile.upgradeTitle')} + {t('screens:profile.upgradeDescription')} + + + )} - {/* ════════════════════════════════════════════════════════════════════ - WORKOUT SETTINGS — Native List - ═══════════════════════════════════════════════════════════════════ */} - - - - updateSettings({ haptics: v })} - /> - - - updateSettings({ soundEffects: v })} - /> - - - updateSettings({ voiceCoaching: v })} - /> - - - + {/* Settings link */} + router.push('/settings')}> + + {t('screens:settings.title')} + + + + ) +} - {/* ════════════════════════════════════════════════════════════════════ - NOTIFICATIONS — Native List - ═══════════════════════════════════════════════════════════════════ */} - - - - - - {settings.reminders && ( - - )} - - - - {/* ════════════════════════════════════════════════════════════════════ - PERSONALIZATION (PREMIUM ONLY) - ═══════════════════════════════════════════════════════════════════ */} - {isPremium && ( - - - - - - )} - - {/* ════════════════════════════════════════════════════════════════════ - ABOUT — Native List - ═══════════════════════════════════════════════════════════════════ */} - - - router.push('/program/debutant' as any)} - /> - - - - - - - - - {/* ════════════════════════════════════════════════════════════════════ - ACCOUNT (PREMIUM ONLY) - ═══════════════════════════════════════════════════════════════════ */} - {isPremium && ( - - - - - - )} - - {/* ════════════════════════════════════════════════════════════════════ - SIGN OUT — Native Button - ═══════════════════════════════════════════════════════════════════ */} - - - - - - setShowDeleteModal(false)} - /> +function StatPill({ value, label, icon, color }: { value: number; label: string; icon: any; color: string }) { + return ( + + + {value} + {label} ) } -// ═══════════════════════════════════════════════════════════════════════════ -// 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 }, }) } diff --git a/app/_layout.tsx b/app/_layout.tsx index c5aca5f..7b8473e 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -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 ( + + ⚠️ + Something went wrong + + {this.state.error?.message ?? 'An unexpected error occurred.'} + + + Try again + + + ) + } + 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() { + - + - - - - + + + + @@ -211,8 +251,10 @@ function RootLayoutInner() { export default function RootLayout() { return ( - - - + + + + + ) } diff --git a/app/complete/[id].tsx b/app/complete/[id].tsx index 64a2eca..d490af5 100644 --- a/app/complete/[id].tsx +++ b/app/complete/[id].tsx @@ -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 ( - - - {icon && } - {children} - - - ) -} - -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 ( - - - - {children} - - - - ) -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 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 ( - - - 🔥 - - - 💪 - - - - - - ) -} - 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 ( - {value} + {value} {label} ) } -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 ( - - {t('screens:complete.burnBar')} - {t('screens:complete.burnBarResult', { percentile })} - - - - - ) -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 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 ( {/* Celebration */} 🎉 {t('screens:complete.title')} - {/* Stats Grid */} - - - + + + - {/* Burn Bar */} - - {/* Streak */} @@ -399,45 +125,27 @@ export default function WorkoutCompleteScreen() { - {t('screens:complete.streakTitle', { count: streak.current })} - {t('screens:complete.streakSubtitle')} + + {t('screens:complete.streakDays', { count: streak.current })} + + + {t('screens:complete.streakRecord', { count: streak.longest })} + - {/* Share Button */} + {/* Share */} - - - - {/* Recommended */} - - {t('screens:complete.recommendedNext')} - - {recommended.map((w) => ( - handleWorkoutPress(w.id)} - style={styles.recommendedCard} - > - - - - {w.title} - {t('units.minUnit', { count: w.duration })} - - ))} - - {/* Fixed Bottom Button */} @@ -452,260 +160,52 @@ export default function WorkoutCompleteScreen() { /> - - {/* Sync Consent Modal */} - ) } -// ═══════════════════════════════════════════════════════════════════════════ -// 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' }, }) } diff --git a/app/onboarding.tsx b/app/onboarding.tsx index cea1636..2ed84ec 100644 --- a/app/onboarding.tsx +++ b/app/onboarding.tsx @@ -999,7 +999,7 @@ export default function OnboardingScreen() { if (plan !== 'free') { setSubscription(plan) } - router.replace('/(tabs)') + router.replace('/') }, [name, level, goal, frequency, barriers, step] ) diff --git a/app/paywall.tsx b/app/paywall.tsx index 5d37321..0f5fc5a 100644 --- a/app/paywall.tsx +++ b/app/paywall.tsx @@ -284,6 +284,20 @@ export default function PaywallScreen() { onPress={handleRestore} /> + + router.push('/terms')}> + + {t('paywall.termsLink')} + + + · + router.push('/terms')}> + + {t('paywall.privacyLink')} + + + + {t('paywall.terms')} @@ -437,5 +451,10 @@ function createStyles(colors: ThemeColors) { lineHeight: 18, paddingHorizontal: SPACING[4], }, + legalLinks: { + flexDirection: 'row', + alignItems: 'center', + gap: SPACING[1], + }, }) } diff --git a/app/player/[id].tsx b/app/player/[id].tsx index f7b352f..30f52d1 100644 --- a/app/player/[id].tsx +++ b/app/player/[id].tsx @@ -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 + if (!isWorkoutProgramId(sessionId)) { + return } - if (isTabataSession(sessionId)) { - const session = getTabataSessionById(sessionId) - if (session) { - return - } - // Fallback to legacy if session not found - } - - return + return } -/** - * 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(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 ( - - Chargement... - - ) - } - - if (!session) { - return ( - - Programme non trouvé - - ) - } - - return + if (state.status === 'loading') return + if (state.status === 'error') return + return } -/** - * 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(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 ( - -