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:
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
|
||||
214
app/_layout.tsx
214
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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -999,7 +999,7 @@ export default function OnboardingScreen() {
|
||||
if (plan !== 'free') {
|
||||
setSubscription(plan)
|
||||
}
|
||||
router.replace('/(tabs)')
|
||||
router.replace('/')
|
||||
},
|
||||
[name, level, goal, frequency, barriers, step]
|
||||
)
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
|
||||
@@ -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
223
app/settings.tsx
Normal 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
106
app/terms.tsx
Normal 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
244
app/zone/[bodyZone].tsx
Normal 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],
|
||||
},
|
||||
})
|
||||
@@ -10,19 +10,18 @@ import {
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useKeepAwake } from 'expo-keep-awake'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useTabataTimer } from '@/src/shared/hooks/useTabataTimer'
|
||||
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 { useTabataProgramStore } from '@/src/shared/stores/tabataProgramStore'
|
||||
import { useWorkoutProgramStore } from '@/src/shared/stores/workoutProgramStore'
|
||||
import { getSessionProgramId } from '@/src/shared/services/access'
|
||||
import { useProgressStore } from '@/src/shared/stores'
|
||||
import { track } from '@/src/shared/services/analytics'
|
||||
import type { TabataSession } from '@/src/shared/types/program'
|
||||
import { PHASE_COLORS, darkColors } from '@/src/shared/theme'
|
||||
import { TYPOGRAPHY, FONT_FAMILY } from '@/src/shared/constants/typography'
|
||||
import type { WorkoutProgram } from '@/src/shared/types/workoutProgram'
|
||||
import { PHASE_COLORS } 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, PHASE, AMBER } from '@/src/shared/constants/colors'
|
||||
@@ -33,6 +32,9 @@ import { TabataTip } from '@/src/features/player/components/TabataTip'
|
||||
import { BlockIndicator } from '@/src/features/player/components/BlockIndicator'
|
||||
import { WarmupOverlay } from '@/src/features/player/components/WarmupOverlay'
|
||||
|
||||
// Lavender for STRETCH (COOLDOWN) phase per design spec
|
||||
const LAVENDER = '#B4A7E5'
|
||||
|
||||
function formatTime(seconds: number) {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
@@ -40,34 +42,44 @@ function formatTime(seconds: number) {
|
||||
}
|
||||
|
||||
const TABATA_PHASE_COLORS: Record<string, string> = {
|
||||
WARMUP: PHASE.PREP,
|
||||
WARMUP: AMBER[500],
|
||||
WORK: PHASE.WORK,
|
||||
REST: PHASE.REST,
|
||||
INTER_BLOCK_REST: AMBER[500],
|
||||
COOLDOWN: GREEN[500],
|
||||
COOLDOWN: LAVENDER,
|
||||
COMPLETE: GREEN[500],
|
||||
}
|
||||
|
||||
interface TabataPlayerScreenProps {
|
||||
session: TabataSession
|
||||
program?: WorkoutProgram
|
||||
}
|
||||
|
||||
export function TabataPlayerScreen({ session }: TabataPlayerScreenProps) {
|
||||
export function TabataPlayerScreen({ session, program }: TabataPlayerScreenProps) {
|
||||
useKeepAwake()
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
const haptics = useHaptics()
|
||||
const audio = useAudio()
|
||||
const addWorkoutResult = useActivityStore(s => s.addWorkoutResult)
|
||||
const completeSession = useTabataProgramStore(s => s.completeSession)
|
||||
const completeProgram = useWorkoutProgramStore(s => s.completeProgram)
|
||||
const completeProgress = useProgressStore(s => s.completeProgram)
|
||||
|
||||
const timer = useTabataTimer(session)
|
||||
const [showControls, setShowControls] = useState(true)
|
||||
const [isMuted, setIsMuted] = useState(false)
|
||||
|
||||
// Music muted during WARMUP/COOLDOWN (stretch) per spec
|
||||
const musicActive =
|
||||
timer.isRunning &&
|
||||
!timer.isPaused &&
|
||||
timer.phase !== 'WARMUP' &&
|
||||
timer.phase !== 'COOLDOWN'
|
||||
|
||||
const music = useMusicPlayer({
|
||||
vibe: session.musicVibe ?? 'electronic',
|
||||
isPlaying: timer.isRunning && !timer.isPaused && timer.phase !== 'WARMUP',
|
||||
isPlaying: musicActive,
|
||||
volume: isMuted ? 0 : 0.5,
|
||||
})
|
||||
const [showControls, setShowControls] = useState(true)
|
||||
const timerScaleAnim = useRef(new Animated.Value(0.8)).current
|
||||
|
||||
const phaseColor = TABATA_PHASE_COLORS[timer.phase] ?? PHASE.WORK
|
||||
@@ -80,17 +92,6 @@ export function TabataPlayerScreen({ session }: TabataPlayerScreenProps) {
|
||||
track('tabata_session_started', { session_id: session.id, blocks: session.blocks.length })
|
||||
}, [timer, haptics, session])
|
||||
|
||||
const togglePause = useCallback(() => {
|
||||
if (timer.isPaused) {
|
||||
timer.resume()
|
||||
track('tabata_session_resumed', { session_id: session.id })
|
||||
} else {
|
||||
timer.pause()
|
||||
track('tabata_session_paused', { session_id: session.id })
|
||||
}
|
||||
haptics.selection()
|
||||
}, [timer, haptics, session])
|
||||
|
||||
const stopWorkout = useCallback(() => {
|
||||
haptics.phaseChange()
|
||||
timer.stop()
|
||||
@@ -105,27 +106,17 @@ export function TabataPlayerScreen({ session }: TabataPlayerScreenProps) {
|
||||
total_rounds: timer.totalRounds,
|
||||
blocks: timer.totalBlocks,
|
||||
})
|
||||
addWorkoutResult({
|
||||
id: Date.now().toString(),
|
||||
workoutId: session.id,
|
||||
// Record session in unified progress store
|
||||
const programId = program?.id ?? session.id.replace(/^wp-/, '')
|
||||
completeProgress({
|
||||
programId,
|
||||
completedAt: Date.now(),
|
||||
calories: timer.calories,
|
||||
durationMinutes: session.totalDuration,
|
||||
rounds: timer.totalRounds,
|
||||
completionRate: 1,
|
||||
})
|
||||
// Mark session complete in program store
|
||||
if (session.id.startsWith('wp-')) {
|
||||
const programId = session.id.slice(3)
|
||||
completeProgram(programId)
|
||||
} else {
|
||||
const programId = getSessionProgramId(session.id)
|
||||
if (programId) {
|
||||
completeSession(programId, session.id)
|
||||
}
|
||||
}
|
||||
durationSeconds: session.totalDuration * 60,
|
||||
bodyZone: program?.bodyZone ?? 'full',
|
||||
level: program?.level ?? 'Beginner',
|
||||
}).catch(() => {})
|
||||
router.replace(`/complete/${session.id}`)
|
||||
}, [router, session, timer, haptics, addWorkoutResult, completeSession, completeProgram])
|
||||
}, [router, session, timer, haptics, completeProgress, program])
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
timer.skip()
|
||||
@@ -147,15 +138,15 @@ export function TabataPlayerScreen({ session }: TabataPlayerScreenProps) {
|
||||
}).start()
|
||||
haptics.phaseChange()
|
||||
if (timer.phase === 'COMPLETE') {
|
||||
audio.workoutComplete()
|
||||
if (!isMuted) audio.workoutComplete()
|
||||
} else if (timer.isRunning) {
|
||||
audio.phaseStart()
|
||||
if (!isMuted) audio.phaseStart()
|
||||
}
|
||||
}, [timer.phase])
|
||||
|
||||
useEffect(() => {
|
||||
if (timer.isRunning && timer.timeRemaining <= 3 && timer.timeRemaining > 0) {
|
||||
audio.countdownBeep()
|
||||
if (!isMuted) audio.countdownBeep()
|
||||
haptics.countdownTick()
|
||||
}
|
||||
}, [timer.timeRemaining])
|
||||
@@ -204,7 +195,7 @@ export function TabataPlayerScreen({ session }: TabataPlayerScreenProps) {
|
||||
{(isWarmup || isCooldown) && timer.currentWarmupMovement && (
|
||||
<WarmupOverlay
|
||||
movementName={isWarmup ? timer.currentWarmupMovement.name : (timer.currentCooldownMovement?.name ?? '')}
|
||||
movementIndex={isWarmup ? (timer as ReturnType<typeof useTabataTimer>).currentBlockIndex : 0}
|
||||
movementIndex={isWarmup ? timer.currentBlockIndex : 0}
|
||||
totalMovements={isWarmup ? session.warmup.movements.length : session.cooldown.movements.length}
|
||||
timeRemaining={timer.timeRemaining}
|
||||
isCooldown={isCooldown}
|
||||
@@ -214,14 +205,14 @@ export function TabataPlayerScreen({ session }: TabataPlayerScreenProps) {
|
||||
{/* Inter-block rest */}
|
||||
{isInterBlockRest && (
|
||||
<View style={styles.interBlockContainer}>
|
||||
<Text style={styles.interBlockLabel}>RÉCUPÉRATION</Text>
|
||||
<Text style={styles.interBlockLabel}>{t('screens:player.phases.trans')}</Text>
|
||||
<Text style={styles.interBlockTime}>{formatTime(timer.timeRemaining)}</Text>
|
||||
<BlockIndicator
|
||||
currentBlock={timer.currentBlockIndex}
|
||||
totalBlocks={timer.totalBlocks}
|
||||
/>
|
||||
<Text style={styles.interBlockNext}>
|
||||
Prochain: Bloc {timer.currentBlockIndex + 1}
|
||||
{t('screens:player.nextBlock', { num: timer.currentBlockIndex + 1 })}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -242,7 +233,6 @@ export function TabataPlayerScreen({ session }: TabataPlayerScreenProps) {
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/* Exercise + tabata tip */}
|
||||
<Text style={styles.exerciseName}>{timer.currentExercise?.name}</Text>
|
||||
<TabataTip tip={timer.currentConseil} visible={timer.phase === 'WORK'} />
|
||||
|
||||
@@ -257,27 +247,27 @@ export function TabataPlayerScreen({ session }: TabataPlayerScreenProps) {
|
||||
{/* Complete state */}
|
||||
{timer.isComplete && (
|
||||
<View style={styles.completeSection}>
|
||||
<Text style={styles.completeTitle}>Séance terminée !</Text>
|
||||
<Text style={[styles.completeSubtitle, { color: GREEN[500] }]}>Excellent travail</Text>
|
||||
<Text style={styles.completeTitle}>{t('screens:player.sessionComplete')}</Text>
|
||||
<Text style={[styles.completeSubtitle, { color: GREEN[500] }]}>{t('screens:player.greatWork')}</Text>
|
||||
<View style={styles.completeStats}>
|
||||
<View style={styles.completeStat}>
|
||||
<Text selectable style={styles.completeStatValue}>{timer.totalBlocks}</Text>
|
||||
<Text style={styles.completeStatLabel}>Blocs</Text>
|
||||
<Text style={styles.completeStatLabel}>{t('screens:player.blocks')}</Text>
|
||||
</View>
|
||||
<View style={styles.completeStat}>
|
||||
<Text selectable style={styles.completeStatValue}>{timer.totalRounds}</Text>
|
||||
<Text style={styles.completeStatLabel}>Rounds</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}>Calories</Text>
|
||||
<Text style={styles.completeStatLabel}>{t('screens:player.calories')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Now Playing music pill */}
|
||||
{showControls && timer.isRunning && !timer.isComplete && (
|
||||
{showControls && timer.isRunning && !timer.isComplete && musicActive && (
|
||||
<View style={[styles.nowPlayingContainer, { bottom: insets.bottom + 100 }]}>
|
||||
<NowPlaying
|
||||
track={music.currentTrack}
|
||||
@@ -293,11 +283,13 @@ export function TabataPlayerScreen({ session }: TabataPlayerScreenProps) {
|
||||
<PlayerControls
|
||||
isRunning={timer.isRunning}
|
||||
isPaused={timer.isPaused}
|
||||
isMuted={isMuted}
|
||||
onStart={startTimer}
|
||||
onPause={() => { timer.pause(); haptics.selection() }}
|
||||
onResume={() => { timer.resume(); haptics.selection() }}
|
||||
onStop={stopWorkout}
|
||||
onSkip={handleSkip}
|
||||
onToggleMute={() => { setIsMuted(m => !m); haptics.selection() }}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
@@ -335,7 +327,7 @@ const styles = StyleSheet.create({
|
||||
exerciseName: { ...TYPOGRAPHY.TITLE_2, color: TEXT.PRIMARY, textAlign: 'center', marginTop: SPACING[4], marginHorizontal: SPACING[4] },
|
||||
|
||||
interBlockContainer: { alignItems: 'center', justifyContent: 'center', flex: 1 },
|
||||
interBlockLabel: { ...TYPOGRAPHY.FOOTNOTE, fontFamily: FONT_FAMILY.SANS_BOLD, letterSpacing: 2, color: AMBER[500], marginBottom: SPACING[2] },
|
||||
interBlockLabel: { ...TYPOGRAPHY.FOOTNOTE, fontWeight: '700', letterSpacing: 2, color: AMBER[500], marginBottom: SPACING[2] },
|
||||
interBlockTime: { ...TYPOGRAPHY.TIMER_NUMBER, color: TEXT.PRIMARY, fontVariant: ['tabular-nums'] },
|
||||
interBlockNext: { ...TYPOGRAPHY.CAPTION_1, color: TEXT.TERTIARY, marginTop: SPACING[3] },
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import React from 'react'
|
||||
import { View, Text, StyleSheet } from 'react-native'
|
||||
import { GREEN, TEXT } from '@/src/shared/constants/colors'
|
||||
import { FONT_FAMILY } from '@/src/shared/constants/typography'
|
||||
|
||||
import { SPACING, RADIUS } from '@/src/shared/constants'
|
||||
|
||||
interface BlockIndicatorProps {
|
||||
@@ -51,7 +51,7 @@ const styles = StyleSheet.create({
|
||||
gap: SPACING[2],
|
||||
},
|
||||
label: {
|
||||
fontFamily: FONT_FAMILY.SANS_SEMIBOLD,
|
||||
fontWeight: '600',
|
||||
fontSize: 14,
|
||||
color: TEXT.SECONDARY,
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { BRAND, darkColors } from '@/src/shared/theme'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { TYPOGRAPHY, FONT_FAMILY_SANS_SEMIBOLD } from '@/src/shared/constants/typography'
|
||||
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
|
||||
interface BurnBarProps {
|
||||
@@ -55,7 +55,7 @@ const styles = StyleSheet.create({
|
||||
value: {
|
||||
...TYPOGRAPHY.CALLOUT,
|
||||
color: BRAND.PRIMARY,
|
||||
fontFamily: FONT_FAMILY_SANS_SEMIBOLD,
|
||||
fontWeight: '600',
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
track: {
|
||||
|
||||
@@ -7,7 +7,7 @@ import React, { useRef, useEffect } from 'react'
|
||||
import { View, Text, Pressable, StyleSheet, Animated } from 'react-native'
|
||||
|
||||
import { Icon } from '@/src/shared/components/Icon'
|
||||
import { TYPOGRAPHY, FONT_FAMILY_SANS_SEMIBOLD } from '@/src/shared/constants/typography'
|
||||
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { SPRING } from '@/src/shared/constants/animations'
|
||||
@@ -117,7 +117,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
title: {
|
||||
...TYPOGRAPHY.CAPTION_1,
|
||||
fontFamily: FONT_FAMILY_SANS_SEMIBOLD,
|
||||
fontWeight: '600',
|
||||
},
|
||||
artist: {
|
||||
...TYPOGRAPHY.CAPTION_2,
|
||||
|
||||
@@ -7,11 +7,11 @@ import { View, Text, StyleSheet } from 'react-native'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { PHASE_COLORS, darkColors } from '@/src/shared/theme'
|
||||
import { TYPOGRAPHY, FONT_FAMILY_SANS_BOLD } from '@/src/shared/constants/typography'
|
||||
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
|
||||
type TimerPhase = 'PREP' | 'WORK' | 'REST' | 'COMPLETE'
|
||||
type TimerPhase = 'PREP' | 'WORK' | 'REST' | 'COMPLETE' | 'WARMUP' | 'COOLDOWN' | 'TRANSITION'
|
||||
|
||||
interface PhaseIndicatorProps {
|
||||
phase: TimerPhase
|
||||
@@ -19,12 +19,16 @@ interface PhaseIndicatorProps {
|
||||
|
||||
export function PhaseIndicator({ phase }: PhaseIndicatorProps) {
|
||||
const { t } = useTranslation()
|
||||
const phaseColor = PHASE_COLORS[phase].fill
|
||||
// WARMUP/COOLDOWN/TRANSITION fall back to PREP colors if not in PHASE_COLORS map
|
||||
const phaseColor = (PHASE_COLORS as Record<string, { fill: string }>)[phase]?.fill ?? PHASE_COLORS.PREP.fill
|
||||
const phaseLabels: Record<TimerPhase, string> = {
|
||||
PREP: t('screens:player.phases.prep'),
|
||||
WORK: t('screens:player.phases.work'),
|
||||
REST: t('screens:player.phases.rest'),
|
||||
COMPLETE: t('screens:player.phases.complete'),
|
||||
WARMUP: t('screens:player.phases.warmup'),
|
||||
COOLDOWN: t('screens:player.phases.stretch'),
|
||||
TRANSITION: t('screens:player.phases.trans'),
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -44,7 +48,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
text: {
|
||||
...TYPOGRAPHY.CALLOUT,
|
||||
fontFamily: FONT_FAMILY_SANS_BOLD,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 1,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
/**
|
||||
* PlayerControls — Play/Pause/Stop/Skip control bar
|
||||
* PlayerControls — Play/Pause/Stop/Skip/Mute control bar
|
||||
* F-072: Mute toggle accessible in 1 tap
|
||||
* F-074: Pause button (centred, accessible)
|
||||
* F-075: Quit button (DangerButton, confirmation required)
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { View, StyleSheet } from 'react-native'
|
||||
import React, { useCallback } from 'react'
|
||||
import { View, StyleSheet, Alert } from 'react-native'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { ControlButton } from './ControlButton'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
@@ -11,22 +15,39 @@ import { SPACING } from '@/src/shared/constants/spacing'
|
||||
interface PlayerControlsProps {
|
||||
isRunning: boolean
|
||||
isPaused: boolean
|
||||
isMuted?: boolean
|
||||
onStart: () => void
|
||||
onPause: () => void
|
||||
onResume: () => void
|
||||
onStop: () => void
|
||||
onSkip: () => void
|
||||
onToggleMute?: () => void
|
||||
}
|
||||
|
||||
export function PlayerControls({
|
||||
isRunning,
|
||||
isPaused,
|
||||
isMuted = false,
|
||||
onStart,
|
||||
onPause,
|
||||
onResume,
|
||||
onStop,
|
||||
onSkip,
|
||||
onToggleMute,
|
||||
}: PlayerControlsProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleQuit = useCallback(() => {
|
||||
Alert.alert(
|
||||
t('screens:player.quitTitle'),
|
||||
t('screens:player.quitMessage'),
|
||||
[
|
||||
{ text: t('screens:player.quitCancel'), style: 'cancel' },
|
||||
{ text: t('screens:player.quitConfirm'), style: 'destructive', onPress: onStop },
|
||||
],
|
||||
)
|
||||
}, [onStop, t])
|
||||
|
||||
if (!isRunning) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -39,11 +60,19 @@ export function PlayerControls({
|
||||
<View style={styles.container}>
|
||||
<View style={styles.row}>
|
||||
<ControlButton
|
||||
icon="stop.fill"
|
||||
onPress={onStop}
|
||||
size={56}
|
||||
icon="xmark"
|
||||
onPress={handleQuit}
|
||||
size={48}
|
||||
variant="danger"
|
||||
/>
|
||||
{onToggleMute && (
|
||||
<ControlButton
|
||||
icon={isMuted ? 'speaker.slash.fill' : 'speaker.wave.2.fill'}
|
||||
onPress={onToggleMute}
|
||||
size={48}
|
||||
variant="secondary"
|
||||
/>
|
||||
)}
|
||||
<ControlButton
|
||||
icon={isPaused ? 'play.fill' : 'pause.fill'}
|
||||
onPress={isPaused ? onResume : onPause}
|
||||
@@ -52,7 +81,7 @@ export function PlayerControls({
|
||||
<ControlButton
|
||||
icon="forward.end.fill"
|
||||
onPress={onSkip}
|
||||
size={56}
|
||||
size={48}
|
||||
variant="secondary"
|
||||
/>
|
||||
</View>
|
||||
@@ -67,6 +96,6 @@ const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: SPACING[6],
|
||||
gap: SPACING[4],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ import { View, Text, StyleSheet } from 'react-native'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { darkColors } from '@/src/shared/theme'
|
||||
import { TYPOGRAPHY, FONT_FAMILY_SANS_BOLD } from '@/src/shared/constants/typography'
|
||||
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
|
||||
interface RoundIndicatorProps {
|
||||
@@ -38,7 +38,7 @@ const styles = StyleSheet.create({
|
||||
...TYPOGRAPHY.BODY,
|
||||
},
|
||||
current: {
|
||||
fontFamily: FONT_FAMILY_SANS_BOLD,
|
||||
fontWeight: '700',
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ import { View, Text, StyleSheet, Animated } from 'react-native'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Icon } from '@/src/shared/components/Icon'
|
||||
import { TYPOGRAPHY, FONT_FAMILY_SANS_BOLD } from '@/src/shared/constants/typography'
|
||||
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { SPRING } from '@/src/shared/constants/animations'
|
||||
@@ -128,7 +128,7 @@ const styles = StyleSheet.create({
|
||||
statValue: {
|
||||
...TYPOGRAPHY.TITLE_2,
|
||||
fontVariant: ['tabular-nums'],
|
||||
fontFamily: FONT_FAMILY_SANS_BOLD,
|
||||
fontWeight: '700',
|
||||
},
|
||||
statLabel: {
|
||||
...TYPOGRAPHY.CAPTION_2,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import React from 'react'
|
||||
import { View, Text, StyleSheet } from 'react-native'
|
||||
import { NAVY, ORANGE, TEXT } from '@/src/shared/constants/colors'
|
||||
import { FONT_FAMILY } from '@/src/shared/constants/typography'
|
||||
|
||||
import { SPACING, RADIUS, LAYOUT } from '@/src/shared/constants'
|
||||
|
||||
interface TabataTipProps {
|
||||
@@ -42,8 +42,8 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
tip: {
|
||||
flex: 1,
|
||||
fontFamily: FONT_FAMILY.SANS,
|
||||
fontSize: 13,
|
||||
fontWeight: '400',
|
||||
lineHeight: 18,
|
||||
color: TEXT.PRIMARY,
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import React from 'react'
|
||||
import { View, Text, StyleSheet } from 'react-native'
|
||||
import { PHASE, GREEN, TEXT } from '@/src/shared/constants/colors'
|
||||
import { FONT_FAMILY } from '@/src/shared/constants/typography'
|
||||
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
|
||||
interface WarmupOverlayProps {
|
||||
@@ -44,26 +44,26 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: SPACING[5],
|
||||
},
|
||||
phaseLabel: {
|
||||
fontFamily: FONT_FAMILY.SANS_BOLD,
|
||||
fontWeight: '700',
|
||||
fontSize: 14,
|
||||
letterSpacing: 2,
|
||||
marginBottom: SPACING[2],
|
||||
},
|
||||
progress: {
|
||||
fontFamily: FONT_FAMILY.SANS,
|
||||
fontWeight: '400',
|
||||
fontSize: 13,
|
||||
color: TEXT.TERTIARY,
|
||||
marginBottom: SPACING[4],
|
||||
},
|
||||
movement: {
|
||||
fontFamily: FONT_FAMILY.SANS_SEMIBOLD,
|
||||
fontWeight: '600',
|
||||
fontSize: 22,
|
||||
color: TEXT.PRIMARY,
|
||||
textAlign: 'center',
|
||||
marginBottom: SPACING[3],
|
||||
},
|
||||
countdown: {
|
||||
fontFamily: FONT_FAMILY.SANS_BOLD,
|
||||
fontWeight: '700',
|
||||
fontSize: 48,
|
||||
color: TEXT.SECONDARY,
|
||||
},
|
||||
|
||||
72
src/shared/components/OfflineBanner.tsx
Normal file
72
src/shared/components/OfflineBanner.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* OfflineBanner — Shows a thin banner when device is offline
|
||||
* Dark Medical: AMBER background, compact, animated
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { View, StyleSheet, Animated } from 'react-native'
|
||||
import * as Network from 'expo-network'
|
||||
import { StyledText } from './StyledText'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AMBER, NAVY } from '@/src/shared/constants/colors'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
|
||||
export function OfflineBanner() {
|
||||
const [isOffline, setIsOffline] = useState(false)
|
||||
const slideAnim = useRef(new Animated.Value(-36)).current
|
||||
const { t } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
|
||||
const check = async () => {
|
||||
try {
|
||||
const state = await Network.getNetworkStateAsync()
|
||||
if (mounted) setIsOffline(!state.isConnected || !state.isInternetReachable)
|
||||
} catch {
|
||||
// Assume online if check fails
|
||||
}
|
||||
}
|
||||
|
||||
check()
|
||||
const interval = setInterval(check, 5000)
|
||||
|
||||
return () => {
|
||||
mounted = false
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
Animated.spring(slideAnim, {
|
||||
toValue: isOffline ? 0 : -36,
|
||||
useNativeDriver: true,
|
||||
friction: 8,
|
||||
}).start()
|
||||
}, [isOffline])
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[styles.container, { transform: [{ translateY: slideAnim }] }]}
|
||||
pointerEvents="none"
|
||||
>
|
||||
<StyledText size={12} weight="semibold" color={NAVY[900]}>
|
||||
{t('common:offline')}
|
||||
</StyledText>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 36,
|
||||
backgroundColor: AMBER[500],
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 999,
|
||||
},
|
||||
})
|
||||
158
src/shared/stores/progressStore.ts
Normal file
158
src/shared/stores/progressStore.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Progress Store — Simplified
|
||||
*
|
||||
* Unique source de vérité pour le progrès utilisateur :
|
||||
* - Streak (current + longest, basé sur jours uniques)
|
||||
* - History (capped à 30 dernières séances, pour perf mobile)
|
||||
* - Completed programs (set d'IDs)
|
||||
* - Weekly count (séances cette semaine)
|
||||
*
|
||||
* Remplace activityStore + workoutProgramStore.
|
||||
*/
|
||||
|
||||
import { create } from 'zustand'
|
||||
import { persist, createJSONStorage } from 'zustand/middleware'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { useUserStore } from './userStore'
|
||||
import { syncWorkoutSession } from '@/src/shared/services/sync'
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────
|
||||
|
||||
export interface ProgramSession {
|
||||
/** Program ID (e.g. "wp-upper-beginner-01") */
|
||||
programId: string
|
||||
/** Epoch ms */
|
||||
completedAt: number
|
||||
/** Total duration in seconds */
|
||||
durationSeconds: number
|
||||
/** Body zone: "upper" | "lower" | "full" */
|
||||
bodyZone: string
|
||||
/** Level: "Beginner" | "Intermediate" | "Advanced" */
|
||||
level: string
|
||||
}
|
||||
|
||||
interface ProgressState {
|
||||
history: ProgramSession[]
|
||||
completedProgramIds: string[]
|
||||
streak: { current: number; longest: number }
|
||||
|
||||
// Actions
|
||||
completeProgram: (session: ProgramSession) => Promise<void>
|
||||
resetProgress: () => void
|
||||
|
||||
// Getters
|
||||
isProgramCompleted: (programId: string) => boolean
|
||||
getCompletedCount: () => number
|
||||
getWeeklyCount: () => number
|
||||
getStreak: () => { current: number; longest: number }
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
const HISTORY_CAP = 30
|
||||
const MS_PER_DAY = 86_400_000
|
||||
|
||||
function toDateKey(timestamp: number): string {
|
||||
return new Date(timestamp).toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
function computeStreak(history: ProgramSession[]): { current: number; longest: number } {
|
||||
if (history.length === 0) return { current: 0, longest: 0 }
|
||||
|
||||
const uniqueDays = Array.from(new Set(history.map((s) => toDateKey(s.completedAt)))).sort().reverse()
|
||||
const today = toDateKey(Date.now())
|
||||
const yesterday = toDateKey(Date.now() - MS_PER_DAY)
|
||||
const isActive = uniqueDays[0] === today || uniqueDays[0] === yesterday
|
||||
|
||||
const longest = computeLongest(uniqueDays)
|
||||
if (!isActive) return { current: 0, longest }
|
||||
|
||||
let current = 1
|
||||
for (let i = 0; i < uniqueDays.length - 1; i++) {
|
||||
const diff = new Date(uniqueDays[i]).getTime() - new Date(uniqueDays[i + 1]).getTime()
|
||||
if (diff <= MS_PER_DAY) current++
|
||||
else break
|
||||
}
|
||||
return { current, longest: Math.max(current, longest) }
|
||||
}
|
||||
|
||||
function computeLongest(sortedDays: string[]): number {
|
||||
if (sortedDays.length === 0) return 0
|
||||
let longest = 1
|
||||
let run = 1
|
||||
for (let i = 0; i < sortedDays.length - 1; i++) {
|
||||
const diff = new Date(sortedDays[i]).getTime() - new Date(sortedDays[i + 1]).getTime()
|
||||
if (diff <= MS_PER_DAY) {
|
||||
run++
|
||||
longest = Math.max(longest, run)
|
||||
} else {
|
||||
run = 1
|
||||
}
|
||||
}
|
||||
return longest
|
||||
}
|
||||
|
||||
function countThisWeek(history: ProgramSession[]): number {
|
||||
const now = new Date()
|
||||
const startOfWeek = new Date(now)
|
||||
startOfWeek.setDate(now.getDate() - now.getDay())
|
||||
startOfWeek.setHours(0, 0, 0, 0)
|
||||
const cutoff = startOfWeek.getTime()
|
||||
return history.filter((s) => s.completedAt >= cutoff).length
|
||||
}
|
||||
|
||||
// ─── Store ──────────────────────────────────────────────────────
|
||||
|
||||
export const useProgressStore = create<ProgressState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
history: [],
|
||||
completedProgramIds: [],
|
||||
streak: { current: 0, longest: 0 },
|
||||
|
||||
completeProgram: async (session) => {
|
||||
const existing = get()
|
||||
const newHistory = [session, ...existing.history].slice(0, HISTORY_CAP)
|
||||
const newCompleted = existing.completedProgramIds.includes(session.programId)
|
||||
? existing.completedProgramIds
|
||||
: [...existing.completedProgramIds, session.programId]
|
||||
const newStreak = computeStreak(newHistory)
|
||||
|
||||
set({
|
||||
history: newHistory,
|
||||
completedProgramIds: newCompleted,
|
||||
streak: newStreak,
|
||||
})
|
||||
|
||||
// Sync to Supabase if premium + synced
|
||||
const userStore = useUserStore.getState()
|
||||
const isPremium = userStore.profile.subscription !== 'free'
|
||||
if (userStore.profile.syncStatus === 'synced') {
|
||||
await syncWorkoutSession({
|
||||
workoutId: session.programId,
|
||||
completedAt: new Date(session.completedAt).toISOString(),
|
||||
durationSeconds: session.durationSeconds,
|
||||
caloriesBurned: 0,
|
||||
feelingRating: undefined,
|
||||
})
|
||||
} else if (userStore.profile.syncStatus === 'never-synced' && isPremium) {
|
||||
userStore.setPromptPending()
|
||||
}
|
||||
},
|
||||
|
||||
resetProgress: () => {
|
||||
set({ history: [], completedProgramIds: [], streak: { current: 0, longest: 0 } })
|
||||
},
|
||||
|
||||
isProgramCompleted: (programId) => get().completedProgramIds.includes(programId),
|
||||
getCompletedCount: () => get().completedProgramIds.length,
|
||||
getWeeklyCount: () => countThisWeek(get().history),
|
||||
getStreak: () => get().streak,
|
||||
}),
|
||||
{
|
||||
name: 'tabatago-progress',
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
version: 1,
|
||||
},
|
||||
),
|
||||
)
|
||||
Reference in New Issue
Block a user