refactor: code quality cleanup — remove any types, add logger, rename Kine to Tabata

- Phase 0: Rename all Kine references to Tabata (types, files, imports, i18n, analytics events)
- Phase 1: Add test coverage for tabataProgramStore, workoutProgramStore, and color utils (47 tests)
- Phase 2: Remove all `any` types from production code with proper typed replacements
- Phase 3: Replace ~60 raw console.* calls with __DEV__-gated logger utility
- Phase 4: Verify .DS_Store housekeeping (already clean)

0 TypeScript errors, 583/583 tests passing.
This commit is contained in:
Millian Lamiaux
2026-04-17 18:56:24 +02:00
parent e0e02c4550
commit 791f432334
176 changed files with 16508 additions and 2305 deletions

View File

@@ -16,18 +16,28 @@
| #5040 | " | ✅ | Removed opening Host tag from browse screen | ~159 |
| #5039 | 8:21 AM | ✅ | Removed closing Host tag from activity screen | ~193 |
| #5038 | " | ✅ | Removed opening Host tag from activity screen | ~154 |
| #5037 | " | ✅ | Removed closing Host tag from workouts screen | ~195 |
| #5036 | " | ✅ | Removed opening Host tag from workouts screen | ~164 |
| #5035 | " | ✅ | Removed closing Host tag from home screen JSX | ~197 |
| #5034 | " | | Removed Host wrapper from home screen JSX | ~139 |
| #5031 | 8:19 AM | ✅ | Removed Host import from profile screen | ~184 |
| #5030 | " | | Removed Host import from browse screen | ~190 |
| #5029 | 8:18 AM | ✅ | Removed Host import from activity screen | ~183 |
| #5028 | " | ✅ | Removed Host import from workouts screen | ~189 |
| #5027 | " | ✅ | Removed Host import from home screen index.tsx | ~180 |
| #5024 | " | 🔵 | Activity screen properly wraps content with Host component | ~237 |
| #5023 | " | 🔵 | Profile screen properly wraps content with Host component | ~246 |
| #5022 | 8:14 AM | 🔵 | Browse screen properly wraps content with Host component | ~217 |
| #5021 | " | 🔵 | Workouts screen properly wraps content with Host component | ~228 |
| #5020 | 8:13 AM | 🔵 | Home screen properly wraps content with Host component | ~238 |
### Apr 11, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #6124 | 7:41 PM | 🔵 | Home screen uses theme-based colors properly | ~229 |
### Apr 13, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #6166 | 10:03 PM | | Updated Tab Layout Documentation | ~137 |
| #6154 | 10:01 PM | 🔵 | Explored Explore Tab Structure | ~174 |
### Apr 17, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #6349 | 9:48 AM | 🔄 | Removed usePurchases import from home screen | ~271 |
| #6348 | " | 🔄 | Removed usePurchases hook from home screen | ~277 |
| #6346 | 9:47 AM | 🔄 | Cleaned up unused imports in home screen after removing direct program navigation | ~321 |
| #6343 | 9:46 AM | 🔄 | Refactored home screen body zone sections to clickable cards | ~400 |
| #6342 | 9:44 AM | 🔄 | Removed direct program navigation handler from home screen | ~305 |
| #6336 | 9:39 AM | 🔵 | Reviewed complete home screen implementation for body-zone workout programs | ~386 |
</claude-mem-context>

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
/**
* TabataFit Profile Screen — Premium React Native
* Apple Fitness+ inspired design, pure React Native components
* TabataFit Profile Screen — Native iOS
* Dark Medical design with SwiftUI Islands
*/
import { useRouter } from 'expo-router'
@@ -9,7 +9,6 @@ import {
ScrollView,
StyleSheet,
Pressable,
Switch,
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import * as Linking from 'expo-linking'
@@ -18,13 +17,22 @@ 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 { useThemeColors, BRAND } from '@/src/shared/theme'
import { useThemeColors } from '@/src/shared/theme'
import type { ThemeColors } from '@/src/shared/theme/types'
import { StyledText } from '@/src/shared/components/StyledText'
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
import { SPACING } 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
@@ -47,7 +55,6 @@ export default function ProfileScreen() {
const planLabel = isPremium ? 'TabataFit+' : t('profile.freePlan')
const avatarInitial = profile.name?.[0]?.toUpperCase() || 'U'
// Real stats from activity store
const history = useActivityStore((s) => s.history)
const streak = useActivityStore((s) => s.streak)
const stats = useMemo(() => ({
@@ -102,71 +109,63 @@ export default function ProfileScreen() {
Linking.openURL('https://tabatafit.app/faq')
}
// App version
const appVersion = Constants.expoConfig?.version ?? '1.0.0'
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 + 40 }]}
showsVerticalScrollIndicator={false}
>
{/* ════════════════════════════════════════════════════════════════════
PROFILE HEADER CARD
PROFILE HEADER
═══════════════════════════════════════════════════════════════════ */}
<View style={styles.section}>
<View style={styles.headerContainer}>
{/* Avatar with gradient background */}
<View style={styles.avatarContainer}>
<StyledText size={48} weight="bold" color="#FFFFFF">
{avatarInitial}
<View style={styles.headerContainer}>
<View style={styles.avatarContainer}>
<StyledText size={48} weight="bold" color={TEXT.PRIMARY}>
{avatarInitial}
</StyledText>
</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>
{/* Name & Plan */}
<View style={styles.nameContainer}>
<StyledText size={22} weight="semibold" style={{ textAlign: 'center' }}>
{profile.name || t('profile.guest')}
<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 style={styles.planContainer}>
<StyledText size={15} color={isPremium ? BRAND.PRIMARY : colors.text.tertiary}>
{planLabel}
</StyledText>
{isPremium && (
<StyledText size={12} color={BRAND.PRIMARY}>
</StyledText>
)}
</View>
</View>
{/* Stats Row */}
<View style={styles.statsContainer}>
<View style={styles.statItem}>
<StyledText size={20} weight="bold" color={BRAND.PRIMARY} 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={BRAND.PRIMARY} 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={BRAND.PRIMARY} 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>
<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>
</View>
</View>
@@ -175,20 +174,17 @@ export default function ProfileScreen() {
UPGRADE CTA (FREE USERS ONLY)
═══════════════════════════════════════════════════════════════════ */}
{!isPremium && (
<View style={styles.section}>
<Pressable
style={styles.premiumContainer}
onPress={() => router.push('/paywall')}
>
<View style={styles.upgradeCard}>
<Pressable onPress={() => router.push('/paywall')}>
<View style={styles.premiumContent}>
<StyledText size={17} weight="semibold" color={BRAND.PRIMARY}>
<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={BRAND.PRIMARY} style={{ marginTop: SPACING[3] }}>
<StyledText size={15} color={GREEN[500]} style={{ marginTop: SPACING[3] }}>
{t('profile.learnMore')}
</StyledText>
</Pressable>
@@ -196,136 +192,108 @@ export default function ProfileScreen() {
)}
{/* ════════════════════════════════════════════════════════════════════
WORKOUT SETTINGS
WORKOUT SETTINGS — Native List
═══════════════════════════════════════════════════════════════════ */}
<StyledText style={styles.sectionHeader}>{t('profile.sectionWorkout')}</StyledText>
<View style={styles.section}>
<View style={styles.row}>
<StyledText style={styles.rowLabel}>{t('profile.hapticFeedback')}</StyledText>
<Switch
value={settings.haptics}
onValueChange={(v) => updateSettings({ haptics: v })}
trackColor={{ false: colors.bg.overlay1, true: BRAND.PRIMARY }}
thumbColor="#FFFFFF"
/>
</View>
<View style={styles.row}>
<StyledText style={styles.rowLabel}>{t('profile.soundEffects')}</StyledText>
<Switch
value={settings.soundEffects}
onValueChange={(v) => updateSettings({ soundEffects: v })}
trackColor={{ false: colors.bg.overlay1, true: BRAND.PRIMARY }}
thumbColor="#FFFFFF"
/>
</View>
<View style={[styles.row, styles.rowLast]}>
<StyledText style={styles.rowLabel}>{t('profile.voiceCoaching')}</StyledText>
<Switch
value={settings.voiceCoaching}
onValueChange={(v) => updateSettings({ voiceCoaching: v })}
trackColor={{ false: colors.bg.overlay1, true: BRAND.PRIMARY }}
thumbColor="#FFFFFF"
/>
</View>
</View>
<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>
{/* ════════════════════════════════════════════════════════════════════
NOTIFICATIONS
NOTIFICATIONS — Native List
═══════════════════════════════════════════════════════════════════ */}
<StyledText style={styles.sectionHeader}>{t('profile.sectionNotifications')}</StyledText>
<View style={styles.section}>
<View style={styles.row}>
<StyledText style={styles.rowLabel}>{t('profile.dailyReminders')}</StyledText>
<Switch
value={settings.reminders}
onValueChange={handleReminderToggle}
trackColor={{ false: colors.bg.overlay1, true: BRAND.PRIMARY }}
thumbColor="#FFFFFF"
/>
</View>
{settings.reminders && (
<View style={styles.rowTime}>
<StyledText style={styles.rowLabel}>{t('profile.reminderTime')}</StyledText>
<StyledText style={styles.rowValue}>{settings.reminderTime}</StyledText>
</View>
)}
</View>
<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 && (
<>
<StyledText style={styles.sectionHeader}>{t('profile.sectionPersonalization')}</StyledText>
<View style={styles.section}>
<View style={[styles.row, styles.rowLast]}>
<StyledText style={styles.rowLabel}>
{profile.syncStatus === 'synced' ? t('profile.personalizationEnabled') : t('profile.personalizationDisabled')}
</StyledText>
<StyledText
size={14}
color={profile.syncStatus === 'synced' ? BRAND.SUCCESS : colors.text.tertiary}
>
{profile.syncStatus === 'synced' ? '✓' : '○'}
</StyledText>
</View>
</View>
</>
<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
ABOUT — Native List
═══════════════════════════════════════════════════════════════════ */}
<StyledText style={styles.sectionHeader}>{t('profile.sectionAbout')}</StyledText>
<View style={styles.section}>
<View style={styles.row}>
<StyledText style={styles.rowLabel}>{t('profile.version')}</StyledText>
<StyledText style={styles.rowValue}>{appVersion}</StyledText>
</View>
<Pressable style={styles.row} onPress={handleRateApp}>
<StyledText style={styles.rowLabel}>{t('profile.rateApp')}</StyledText>
<StyledText style={styles.rowValue}></StyledText>
</Pressable>
<Pressable style={styles.row} onPress={handleContactUs}>
<StyledText style={styles.rowLabel}>{t('profile.contactUs')}</StyledText>
<StyledText style={styles.rowValue}></StyledText>
</Pressable>
<Pressable style={styles.row} onPress={handleFAQ}>
<StyledText style={styles.rowLabel}>{t('profile.faq')}</StyledText>
<StyledText style={styles.rowValue}></StyledText>
</Pressable>
<Pressable style={[styles.row, styles.rowLast]} onPress={handlePrivacyPolicy}>
<StyledText style={styles.rowLabel}>{t('profile.privacyPolicy')}</StyledText>
<StyledText style={styles.rowValue}></StyledText>
</Pressable>
</View>
<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 USERS ONLY)
ACCOUNT (PREMIUM ONLY)
═══════════════════════════════════════════════════════════════════ */}
{isPremium && (
<>
<StyledText style={styles.sectionHeader}>{t('profile.sectionAccount')}</StyledText>
<View style={styles.section}>
<Pressable style={[styles.row, styles.rowLast]} onPress={handleRestore}>
<StyledText style={styles.rowLabel}>{t('profile.restorePurchases')}</StyledText>
<StyledText style={styles.rowValue}></StyledText>
</Pressable>
</View>
</>
<NativeList>
<NativeSection title={t('profile.sectionAccount').toUpperCase()}>
<NativeLabeledRow label={t('profile.restorePurchases')} chevron onPress={handleRestore} />
</NativeSection>
</NativeList>
)}
{/* ════════════════════════════════════════════════════════════════════
SIGN OUT
SIGN OUT — Native Button
═══════════════════════════════════════════════════════════════════ */}
<View style={[styles.section, styles.signOutSection]}>
<Pressable style={styles.button} onPress={handleSignOut}>
<StyledText style={styles.destructive}>{t('profile.signOut')}</StyledText>
</Pressable>
<View style={styles.signOutContainer}>
<NativeButton
variant="destructive"
title={t('profile.signOut')}
onPress={handleSignOut}
/>
</View>
</ScrollView>
{/* Data Deletion Modal */}
<DataDeletionModal
visible={showDeleteModal}
onDelete={handleDeleteData}
@@ -351,22 +319,6 @@ function createStyles(colors: ThemeColors) {
scrollContent: {
flexGrow: 1,
},
section: {
marginHorizontal: SPACING[4],
marginTop: SPACING[5],
backgroundColor: colors.bg.surface,
borderRadius: RADIUS.MD,
overflow: 'hidden',
},
sectionHeader: {
fontSize: 13,
fontWeight: '600',
color: colors.text.tertiary,
textTransform: 'uppercase',
marginLeft: SPACING[8],
marginTop: SPACING[5],
marginBottom: SPACING[2],
},
headerContainer: {
alignItems: 'center',
paddingVertical: SPACING[6],
@@ -375,11 +327,10 @@ function createStyles(colors: ThemeColors) {
avatarContainer: {
width: 90,
height: 90,
borderRadius: 45,
backgroundColor: BRAND.PRIMARY,
borderRadius: RADIUS.FULL,
backgroundColor: NAVY[700],
justifyContent: 'center',
alignItems: 'center',
boxShadow: `0 4px 20px ${BRAND.PRIMARY}80`,
},
nameContainer: {
marginTop: SPACING[4],
@@ -400,52 +351,22 @@ function createStyles(colors: ThemeColors) {
statItem: {
alignItems: 'center',
},
premiumContainer: {
upgradeCard: {
marginHorizontal: SPACING[5],
paddingVertical: SPACING[4],
paddingHorizontal: SPACING[4],
backgroundColor: NAVY[800],
borderRadius: RADIUS.LG,
borderCurve: 'continuous' as const,
borderWidth: 1,
borderColor: colors.border.dim,
},
premiumContent: {
gap: SPACING[1],
},
row: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: SPACING[3],
paddingHorizontal: SPACING[4],
borderBottomWidth: 0.5,
borderBottomColor: colors.border.glassLight,
},
rowLast: {
borderBottomWidth: 0,
},
rowLabel: {
fontSize: 17,
color: colors.text.primary,
},
rowValue: {
fontSize: 17,
color: colors.text.tertiary,
},
rowTime: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: SPACING[3],
paddingHorizontal: SPACING[4],
borderTopWidth: 0.5,
borderTopColor: colors.border.glassLight,
},
button: {
paddingVertical: SPACING[3] + 2,
alignItems: 'center',
},
destructive: {
fontSize: 17,
color: BRAND.DANGER,
},
signOutSection: {
signOutContainer: {
marginTop: SPACING[5],
marginHorizontal: SPACING[5],
},
})
}

View File

@@ -8,9 +8,6 @@
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #5001 | 9:35 AM | 🔵 | Host Wrapper Located at Root Layout Level | ~153 |
| #4964 | 9:23 AM | 🔴 | Added Host Wrapper to Root Layout | ~228 |
| #4963 | 9:22 AM | ✅ | Root layout wraps Stack in View with pure black background | ~279 |
| #4910 | 8:16 AM | 🟣 | Added Workout Detail and Complete Screen Routes | ~348 |
### Feb 20, 2026
@@ -37,4 +34,37 @@
| #5579 | 7:47 PM | 🔵 | Comprehensive analytics tracking in onboarding flow | ~345 |
| #5575 | 7:44 PM | 🔵 | PostHog integration architecture in root layout | ~279 |
| #5572 | 7:43 PM | 🔵 | PostHog integration points identified | ~228 |
### Apr 10, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #6017 | 10:06 AM | 🔵 | Explore Filter Sheet for Level and Equipment | ~307 |
| #6000 | 10:01 AM | 🔵 | Root App Architecture Examined | ~316 |
### Apr 11, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #6129 | 7:42 PM | 🔄 | Onboarding Wow icon circle opacity refactored | ~295 |
| #6126 | 7:41 PM | 🔵 | Assessment screen imports reviewed | ~293 |
| #6122 | " | 🔵 | Onboarding screen uses dynamic color with hex transparency | ~277 |
### Apr 13, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #6175 | 10:04 PM | 🟣 | Completed Explore Tab Removal | ~196 |
| #6170 | 10:03 PM | 🟣 | Removed Explore Filters Modal Route | ~143 |
| #6165 | 10:02 PM | 🔵 | Located Explore Filters Screen Configuration | ~141 |
| #6160 | " | 🔵 | Identified Explore Filters Screen Configuration | ~141 |
| #6156 | " | 🔵 | Found Explore Tab References | ~155 |
### Apr 17, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #6373 | 10:26 AM | 🟣 | Registered body zone detail screen route in app navigation | ~296 |
| #6372 | 10:25 AM | 🔵 | Reviewed app layout navigation configuration for workout and program screens | ~318 |
| #6371 | " | 🔵 | Examined app routing layout structure for workout routes | ~247 |
</claude-mem-context>

View File

@@ -135,6 +135,18 @@ function RootLayoutInner() {
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',
}}
/>
<Stack.Screen
name="program/[id]"
options={{

View File

@@ -8,6 +8,7 @@ import { useRouter } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { LinearGradient } from 'expo-linear-gradient'
import { Icon } from '@/src/shared/components/Icon'
import { NativeButton } from '@/src/shared/components/native'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -17,9 +18,11 @@ import { ASSESSMENT_WORKOUT } from '@/src/shared/data/programs'
import { StyledText } from '@/src/shared/components/StyledText'
import { useThemeColors, BRAND } from '@/src/shared/theme'
import { withOpacity } from '@/src/shared/utils/color'
import type { ThemeColors } from '@/src/shared/theme/types'
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { TEXT, GRADIENTS } from '@/src/shared/constants/colors'
const FONTS = {
LARGE_TITLE: 28,
@@ -118,17 +121,14 @@ export default function AssessmentScreen() {
</ScrollView>
<View style={[styles.bottomBar, { paddingBottom: insets.bottom + SPACING[4] }]}>
<Pressable style={styles.ctaButton} onPress={handleComplete}>
<LinearGradient
colors={['#FF6B35', '#FF3B30']}
style={styles.ctaGradient}
>
<StyledText size={16} weight="bold" color="#FFFFFF">
{t('assessment.startAssessment')}
</StyledText>
<Icon name="play.fill" size={20} color="#FFFFFF" style={styles.ctaIcon} />
</LinearGradient>
</Pressable>
<NativeButton
variant="primary"
title={t('assessment.startAssessment')}
systemImage="play.fill"
onPress={handleComplete}
fullWidth
controlSize="large"
/>
</View>
</View>
)
@@ -242,23 +242,20 @@ export default function AssessmentScreen() {
{/* Bottom Actions */}
<View style={[styles.bottomBar, { paddingBottom: insets.bottom + SPACING[4] }]}>
<Pressable style={styles.ctaButton} onPress={handleStart}>
<LinearGradient
colors={['#FF6B35', '#FF3B30']}
style={styles.ctaGradient}
>
<StyledText size={16} weight="bold" color="#FFFFFF">
{t('assessment.takeAssessment')}
</StyledText>
<Icon name="arrow.right" size={20} color="#FFFFFF" style={styles.ctaIcon} />
</LinearGradient>
</Pressable>
<NativeButton
variant="primary"
title={t('assessment.takeAssessment')}
systemImage="arrow.right"
onPress={handleStart}
fullWidth
controlSize="large"
/>
<Pressable style={styles.skipButton} onPress={handleSkip}>
<StyledText size={15} color={colors.text.tertiary}>
{t('assessment.skipForNow')}
</StyledText>
</Pressable>
<NativeButton
variant="ghost"
title={t('assessment.skipForNow')}
onPress={handleSkip}
/>
</View>
</View>
)
@@ -304,8 +301,8 @@ function createStyles(colors: ThemeColors) {
iconContainer: {
width: 100,
height: 100,
borderRadius: 50,
backgroundColor: `${BRAND.PRIMARY}15`,
borderRadius: RADIUS.FULL,
backgroundColor: withOpacity(BRAND.PRIMARY, 0.08),
alignItems: 'center',
justifyContent: 'center',
marginBottom: SPACING[5],
@@ -335,8 +332,8 @@ function createStyles(colors: ThemeColors) {
featureIcon: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: `${BRAND.PRIMARY}15`,
borderRadius: RADIUS.FULL,
backgroundColor: withOpacity(BRAND.PRIMARY, 0.08),
alignItems: 'center',
justifyContent: 'center',
marginRight: SPACING[3],
@@ -363,7 +360,7 @@ function createStyles(colors: ThemeColors) {
paddingVertical: SPACING[2],
borderRadius: RADIUS.FULL,
borderWidth: 1,
borderColor: colors.border.glass,
borderColor: colors.border.dim,
},
// Assessment Container
@@ -384,8 +381,8 @@ function createStyles(colors: ThemeColors) {
exerciseNumber: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: `${BRAND.PRIMARY}15`,
borderRadius: RADIUS.FULL,
backgroundColor: withOpacity(BRAND.PRIMARY, 0.08),
alignItems: 'center',
justifyContent: 'center',
marginRight: SPACING[3],
@@ -424,7 +421,7 @@ function createStyles(colors: ThemeColors) {
paddingHorizontal: LAYOUT.SCREEN_PADDING,
paddingTop: SPACING[3],
borderTopWidth: 1,
borderTopColor: colors.border.glass,
borderTopColor: colors.border.dim,
},
ctaButton: {
borderRadius: RADIUS.LG,

11
app/collection/CLAUDE.md Normal file
View File

@@ -0,0 +1,11 @@
<claude-mem-context>
# Recent Activity
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
### Apr 13, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #6159 | 10:02 PM | 🔵 | Examined Collection Screen Explore Reference | ~150 |
</claude-mem-context>

View File

@@ -7,7 +7,6 @@ import { useMemo } from 'react'
import { View, StyleSheet, ScrollView, Pressable } from 'react-native'
import { useRouter, useLocalSearchParams } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { LinearGradient } from 'expo-linear-gradient'
import { Icon } from '@/src/shared/components/Icon'
import { useTranslation } from 'react-i18next'
@@ -18,10 +17,11 @@ import { useTranslatedWorkouts } from '@/src/shared/data/useTranslatedData'
import { StyledText } from '@/src/shared/components/StyledText'
import { track } from '@/src/shared/services/analytics'
import { useThemeColors, BRAND, GRADIENTS } from '@/src/shared/theme'
import { useThemeColors, BRAND } from '@/src/shared/theme'
import type { ThemeColors } from '@/src/shared/theme/types'
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { TEXT, NAVY } from '@/src/shared/constants/colors'
export default function CollectionDetailScreen() {
const insets = useSafeAreaInsets()
@@ -98,23 +98,18 @@ export default function CollectionDetailScreen() {
>
{/* Hero Card */}
<View testID="collection-hero" style={styles.heroCard}>
<LinearGradient
colors={collection.gradient ?? [BRAND.PRIMARY, '#FF3B30']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={StyleSheet.absoluteFill}
/>
<View style={StyleSheet.absoluteFill} />
<View style={styles.heroContent}>
<StyledText size={48} color="#FFFFFF" style={styles.heroIcon}>
<StyledText size={48} color={TEXT.PRIMARY} style={styles.heroIcon}>
{collection.icon}
</StyledText>
<StyledText size={28} weight="bold" color="#FFFFFF">
<StyledText size={28} weight="bold" color={TEXT.PRIMARY}>
{collection.title}
</StyledText>
<StyledText size={15} color="rgba(255,255,255,0.8)" style={{ marginTop: SPACING[1] }}>
<StyledText size={15} color={TEXT.SECONDARY} style={{ marginTop: SPACING[1] }}>
{collection.description}
</StyledText>
<StyledText size={13} weight="semibold" color="rgba(255,255,255,0.6)" style={{ marginTop: SPACING[2] }}>
<StyledText size={13} weight="semibold" color={TEXT.TERTIARY} style={{ marginTop: SPACING[2] }}>
{t('plurals.workout', { count: workouts.length })}
</StyledText>
</View>
@@ -138,7 +133,7 @@ export default function CollectionDetailScreen() {
onPress={() => handleWorkoutPress(workout.id)}
>
<View style={[styles.workoutAvatar, { backgroundColor: BRAND.PRIMARY }]}>
<Icon name="flame.fill" size={20} color="#FFFFFF" />
<Icon name="flame.fill" size={20} color={TEXT.PRIMARY} />
</View>
<View style={styles.workoutInfo}>
<StyledText size={17} weight="semibold" color={colors.text.primary}>
@@ -200,7 +195,7 @@ function createStyles(colors: ThemeColors) {
height: 200,
borderRadius: RADIUS.XL,
overflow: 'hidden',
...colors.shadow.lg,
backgroundColor: NAVY[700],
},
heroContent: {
flex: 1,
@@ -229,7 +224,7 @@ function createStyles(colors: ThemeColors) {
workoutAvatar: {
width: 44,
height: 44,
borderRadius: 22,
borderRadius: RADIUS.FULL,
alignItems: 'center',
justifyContent: 'center',
},

View File

@@ -1,6 +1,7 @@
/**
* TabataFit Workout Complete Screen
* Celebration with real data from activity store
* Dark Medical design system — navy, green, no glass
*/
import { useRef, useEffect, useMemo, useState } from 'react'
@@ -15,7 +16,6 @@ import {
} from 'react-native'
import { useRouter, useLocalSearchParams } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { BlurView } from 'expo-blur'
import { Icon, type IconName } from '@/src/shared/components/Icon'
import * as Sharing from 'expo-sharing'
@@ -26,15 +26,17 @@ 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 { NativeButton } from '@/src/shared/components/native'
import { enableSync } from '@/src/shared/services/sync'
import type { WorkoutSessionData } from '@/src/shared/types'
import { useThemeColors, BRAND } from '@/src/shared/theme'
import { useThemeColors } from '@/src/shared/theme'
import type { ThemeColors } from '@/src/shared/theme/types'
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
import { TYPOGRAPHY, FONT_FAMILY } 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 { GREEN, NAVY, TEXT, BORDER_COLORS } from '@/src/shared/constants/colors'
const { width: SCREEN_WIDTH } = Dimensions.get('window')
@@ -79,7 +81,7 @@ function SecondaryButton({
style={{ width: '100%' }}
>
<Animated.View style={[styles.secondaryButton, { transform: [{ scale: scaleAnim }] }]}>
{icon && <Icon name={icon} size={18} tintColor={colors.text.primary} style={styles.buttonIcon} />}
{icon && <Icon name={icon} size={18} tintColor={TEXT.PRIMARY} style={styles.buttonIcon} />}
<RNText style={styles.secondaryButtonText}>{children}</RNText>
</Animated.View>
</Pressable>
@@ -94,7 +96,6 @@ function PrimaryButton({
children: React.ReactNode
}) {
const colors = useThemeColors()
const isDark = colors.colorScheme === 'dark'
const styles = useMemo(() => createStyles(colors), [colors])
const scaleAnim = useRef(new Animated.Value(1)).current
@@ -124,10 +125,10 @@ function PrimaryButton({
<Animated.View
style={[
styles.primaryButton,
{ backgroundColor: isDark ? '#FFFFFF' : '#000000', transform: [{ scale: scaleAnim }] },
{ backgroundColor: GREEN['500'], transform: [{ scale: scaleAnim }] },
]}
>
<RNText style={[styles.primaryButtonText, { color: isDark ? '#000000' : '#FFFFFF' }]}>
<RNText style={[styles.primaryButtonText, { color: NAVY['900'] }]}>
{children}
</RNText>
</Animated.View>
@@ -217,8 +218,7 @@ function StatCard({
return (
<Animated.View style={[styles.statCard, { transform: [{ scale: scaleAnim }] }]}>
<BlurView intensity={colors.glass.blurMedium} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
<Icon name={icon} size={24} tintColor={accentColor} />
<Icon name={icon} size={24} tintColor={GREEN['500']} />
<RNText style={styles.statValue}>{value}</RNText>
<RNText style={styles.statLabel}>{label}</RNText>
</Animated.View>
@@ -248,9 +248,9 @@ function BurnBarResult({ percentile, accentColor }: { percentile: number; accent
return (
<View style={styles.burnBarContainer}>
<RNText style={styles.burnBarTitle}>{t('screens:complete.burnBar')}</RNText>
<RNText style={[styles.burnBarResult, { color: accentColor }]}>{t('screens:complete.burnBarResult', { percentile })}</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: accentColor }]} />
<Animated.View style={[styles.burnBarFill, { width: barWidth, backgroundColor: GREEN['500'] }]} />
</View>
</View>
)
@@ -383,20 +383,20 @@ export default function WorkoutCompleteScreen() {
{/* Stats Grid */}
<View style={styles.statsGrid}>
<StatCard value={resultCalories} label={t('screens:complete.caloriesLabel')} icon="flame.fill" accentColor={trainerColor} delay={100} />
<StatCard value={resultMinutes} label={t('screens:complete.minutesLabel')} icon="clock.fill" accentColor={trainerColor} delay={200} />
<StatCard value="100%" label={t('screens:complete.completeLabel')} icon="checkmark.circle.fill" accentColor={trainerColor} delay={300} />
<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} />
</View>
{/* Burn Bar */}
<BurnBarResult percentile={burnBarPercentile} accentColor={trainerColor} />
<BurnBarResult percentile={burnBarPercentile} accentColor={GREEN['500']} />
<View style={styles.divider} />
{/* Streak */}
<View style={styles.streakSection}>
<View style={[styles.streakBadge, { backgroundColor: trainerColor + '26' }]}>
<Icon name="flame.fill" size={32} tintColor={trainerColor} />
<View style={[styles.streakBadge, { backgroundColor: GREEN.DIM }]}>
<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>
@@ -408,9 +408,13 @@ export default function WorkoutCompleteScreen() {
{/* Share Button */}
<View style={styles.shareSection}>
<SecondaryButton onPress={handleShare} icon="square.and.arrow.up">
{t('screens:complete.shareWorkout')}
</SecondaryButton>
<NativeButton
variant="ghost"
title={t('screens:complete.shareWorkout')}
systemImage="square.and.arrow.up"
onPress={handleShare}
fullWidth
/>
</View>
<View style={styles.divider} />
@@ -425,9 +429,8 @@ export default function WorkoutCompleteScreen() {
onPress={() => handleWorkoutPress(w.id)}
style={styles.recommendedCard}
>
<BlurView intensity={colors.glass.blurLight} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
<View style={[styles.recommendedThumb, { backgroundColor: trainerColor + '20' }]}>
<Icon name="flame.fill" size={24} tintColor={trainerColor} />
<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>
@@ -439,11 +442,14 @@ export default function WorkoutCompleteScreen() {
{/* Fixed Bottom Button */}
<View style={[styles.bottomBar, { paddingBottom: insets.bottom + SPACING[4] }]}>
<BlurView intensity={colors.glass.blurHeavy} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
<View style={styles.homeButtonContainer}>
<PrimaryButton onPress={handleGoHome}>
{t('screens:complete.backToHome')}
</PrimaryButton>
<NativeButton
variant="primary"
title={t('screens:complete.backToHome')}
onPress={handleGoHome}
fullWidth
controlSize="large"
/>
</View>
</View>
@@ -481,27 +487,27 @@ function createStyles(colors: ThemeColors) {
justifyContent: 'center',
paddingVertical: SPACING[3],
paddingHorizontal: SPACING[4],
borderRadius: RADIUS.LG,
borderRadius: RADIUS.MD,
borderWidth: 1,
borderColor: colors.border.glassStrong,
borderColor: BORDER_COLORS.DIM,
backgroundColor: 'transparent',
},
secondaryButtonText: {
...TYPOGRAPHY.BODY,
color: colors.text.primary,
fontWeight: '600',
color: TEXT.PRIMARY,
fontFamily: FONT_FAMILY.SANS_SEMIBOLD,
},
primaryButton: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: SPACING[4],
paddingHorizontal: SPACING[6],
borderRadius: RADIUS.LG,
borderRadius: RADIUS.MD,
overflow: 'hidden',
},
primaryButtonText: {
...TYPOGRAPHY.HEADLINE,
fontWeight: '700',
fontFamily: FONT_FAMILY.SANS_BOLD,
},
buttonIcon: {
marginRight: SPACING[2],
@@ -518,7 +524,7 @@ function createStyles(colors: ThemeColors) {
},
celebrationTitle: {
...TYPOGRAPHY.TITLE_1,
color: colors.text.primary,
color: TEXT.PRIMARY,
letterSpacing: 2,
},
ringsContainer: {
@@ -530,23 +536,21 @@ function createStyles(colors: ThemeColors) {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: colors.border.glass,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2,
borderColor: colors.border.glassStrong,
},
ring1: {
borderColor: BRAND.PRIMARY,
backgroundColor: 'rgba(255, 107, 53, 0.15)',
borderColor: GREEN['500'],
backgroundColor: GREEN.DIM,
},
ring2: {
borderColor: '#30D158',
backgroundColor: 'rgba(48, 209, 88, 0.15)',
borderColor: GREEN['500'],
backgroundColor: GREEN.DIM,
},
ring3: {
borderColor: '#5AC8FA',
backgroundColor: 'rgba(90, 200, 250, 0.15)',
borderColor: GREEN['500'],
backgroundColor: GREEN.DIM,
},
ringEmoji: {
fontSize: 28,
@@ -562,19 +566,20 @@ function createStyles(colors: ThemeColors) {
width: (SCREEN_WIDTH - LAYOUT.SCREEN_PADDING * 2 - SPACING[4]) / 3,
padding: SPACING[3],
borderRadius: RADIUS.LG,
backgroundColor: colors.surface.default.backgroundColor,
alignItems: 'center',
borderWidth: 1,
borderColor: colors.border.glass,
borderColor: colors.surface.default.borderColor,
overflow: 'hidden',
},
statValue: {
...TYPOGRAPHY.TITLE_1,
color: colors.text.primary,
color: TEXT.PRIMARY,
marginTop: SPACING[2],
},
statLabel: {
...TYPOGRAPHY.CAPTION_2,
color: colors.text.tertiary,
color: TEXT.TERTIARY,
marginTop: SPACING[1],
},
@@ -584,7 +589,7 @@ function createStyles(colors: ThemeColors) {
},
burnBarTitle: {
...TYPOGRAPHY.HEADLINE,
color: colors.text.tertiary,
color: TEXT.TERTIARY,
},
burnBarResult: {
...TYPOGRAPHY.BODY,
@@ -594,18 +599,18 @@ function createStyles(colors: ThemeColors) {
burnBarTrack: {
height: 8,
backgroundColor: colors.bg.surface,
borderRadius: 4,
borderRadius: RADIUS.SM,
overflow: 'hidden',
},
burnBarFill: {
height: '100%',
borderRadius: 4,
borderRadius: RADIUS.SM,
},
// Divider
divider: {
height: 1,
backgroundColor: colors.border.glass,
backgroundColor: BORDER_COLORS.DIM,
marginVertical: SPACING[2],
},
@@ -619,7 +624,7 @@ function createStyles(colors: ThemeColors) {
streakBadge: {
width: 64,
height: 64,
borderRadius: 32,
borderRadius: RADIUS.FULL,
alignItems: 'center',
justifyContent: 'center',
},
@@ -628,11 +633,11 @@ function createStyles(colors: ThemeColors) {
},
streakTitle: {
...TYPOGRAPHY.TITLE_2,
color: colors.text.primary,
color: TEXT.PRIMARY,
},
streakSubtitle: {
...TYPOGRAPHY.BODY,
color: colors.text.tertiary,
color: TEXT.TERTIARY,
marginTop: SPACING[1],
},
@@ -648,7 +653,7 @@ function createStyles(colors: ThemeColors) {
},
recommendedTitle: {
...TYPOGRAPHY.HEADLINE,
color: colors.text.primary,
color: TEXT.PRIMARY,
marginBottom: SPACING[4],
},
recommendedGrid: {
@@ -660,7 +665,8 @@ function createStyles(colors: ThemeColors) {
padding: SPACING[3],
borderRadius: RADIUS.LG,
borderWidth: 1,
borderColor: colors.border.glass,
borderColor: colors.surface.default.borderColor,
backgroundColor: colors.surface.default.backgroundColor,
overflow: 'hidden',
},
recommendedThumb: {
@@ -674,15 +680,15 @@ function createStyles(colors: ThemeColors) {
},
recommendedInitial: {
...TYPOGRAPHY.TITLE_1,
color: colors.text.primary,
color: TEXT.PRIMARY,
},
recommendedTitleText: {
...TYPOGRAPHY.CARD_TITLE,
color: colors.text.primary,
color: TEXT.PRIMARY,
},
recommendedDurationText: {
...TYPOGRAPHY.CARD_METADATA,
color: colors.text.tertiary,
color: TEXT.TERTIARY,
},
// Bottom Bar
@@ -693,8 +699,9 @@ function createStyles(colors: ThemeColors) {
right: 0,
paddingHorizontal: LAYOUT.SCREEN_PADDING,
paddingTop: SPACING[4],
backgroundColor: colors.bg.base,
borderTopWidth: 1,
borderTopColor: colors.border.glass,
borderTopColor: BORDER_COLORS.DIM,
},
homeButtonContainer: {
height: 56,

View File

@@ -24,12 +24,16 @@ import { useUserStore } from '@/src/shared/stores'
import { OnboardingStep } from '@/src/shared/components/OnboardingStep'
import { StyledText } from '@/src/shared/components/StyledText'
import { useThemeColors, BRAND, PHASE } from '@/src/shared/theme'
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 { DURATION, EASE, SPRING } from '@/src/shared/constants/animations'
import { track, identifyUser, setUserProperties, trackScreen } from '@/src/shared/services/analytics'
import { GREEN, NAVY, BORDER_COLORS } from '@/src/shared/constants/colors'
import { withOpacity } from '@/src/shared/utils/color'
import { PHASE } from '@/src/shared/constants/colors'
import { NativeButton } from '@/src/shared/components/native'
import type { FitnessLevel, FitnessGoal, WeeklyFrequency } from '@/src/shared/types'
@@ -85,7 +89,7 @@ function ProblemScreen({ onNext }: { onNext: () => void }) {
marginBottom: SPACING[8],
}}
>
<Icon name="clock.fill" size={80} color={BRAND.PRIMARY} />
<Icon name="clock.fill" size={80} color={GREEN[500]} />
</Animated.View>
<Animated.View style={{ opacity: textOpacity, alignItems: 'center' }}>
@@ -122,7 +126,7 @@ function ProblemScreen({ onNext }: { onNext: () => void }) {
onNext()
}}
>
<StyledText size={17} weight="semibold" color="#FFFFFF">
<StyledText size={17} weight="semibold" color={NAVY[900]}>
{t('onboarding.problem.cta')}
</StyledText>
</Pressable>
@@ -190,7 +194,7 @@ function EmpathyScreen({
<Icon
name={item.icon}
size={28}
color={selected ? BRAND.PRIMARY : colors.text.tertiary}
color={selected ? GREEN[500] : colors.text.tertiary}
/>
<StyledText
size={15}
@@ -219,7 +223,7 @@ function EmpathyScreen({
<StyledText
size={17}
weight="semibold"
color={barriers.length > 0 ? '#FFFFFF' : colors.text.disabled}
color={barriers.length > 0 ? NAVY[900] : colors.text.disabled}
>
{t('common:continue')}
</StyledText>
@@ -280,7 +284,7 @@ function SolutionScreen({ onNext }: { onNext: () => void }) {
<View style={styles.comparisonContainer}>
{/* Tabata bar */}
<View style={styles.barColumn}>
<StyledText size={22} weight="bold" color={BRAND.PRIMARY}>
<StyledText size={22} weight="bold" color={GREEN[500]}>
{t('onboarding.solution.tabataCalories')}
</StyledText>
<View style={styles.barTrack}>
@@ -359,7 +363,7 @@ function SolutionScreen({ onNext }: { onNext: () => void }) {
onNext()
}}
>
<StyledText size={17} weight="semibold" color="#FFFFFF">
<StyledText size={17} weight="semibold" color={NAVY[900]}>
{t('onboarding.solution.cta')}
</StyledText>
</Pressable>
@@ -373,7 +377,7 @@ function SolutionScreen({ onNext }: { onNext: () => void }) {
// ═══════════════════════════════════════════════════════════════════════════
const WOW_FEATURES = [
{ icon: 'timer' as const, iconColor: BRAND.PRIMARY, titleKey: 'onboarding.wow.card1Title', subtitleKey: 'onboarding.wow.card1Subtitle' },
{ icon: 'timer' as const, iconColor: GREEN[500], titleKey: 'onboarding.wow.card1Title', subtitleKey: 'onboarding.wow.card1Subtitle' },
{ icon: 'dumbbell' as const, iconColor: PHASE.REST, titleKey: 'onboarding.wow.card2Title', subtitleKey: 'onboarding.wow.card2Subtitle' },
{ icon: 'mic' as const, iconColor: PHASE.PREP, titleKey: 'onboarding.wow.card3Title', subtitleKey: 'onboarding.wow.card3Subtitle' },
{ icon: 'arrow.up.right' as const, iconColor: PHASE.COMPLETE, titleKey: 'onboarding.wow.card4Title', subtitleKey: 'onboarding.wow.card4Subtitle' },
@@ -452,7 +456,7 @@ function WowScreen({ onNext }: { onNext: () => void }) {
},
]}
>
<View style={[wowStyles.iconCircle, { backgroundColor: `${feature.iconColor}26` }]}>
<View style={[wowStyles.iconCircle, { backgroundColor: withOpacity(feature.iconColor, 0.15) }]}>
<Icon name={feature.icon} size={22} color={feature.iconColor} />
</View>
<View style={wowStyles.textCol}>
@@ -479,7 +483,7 @@ function WowScreen({ onNext }: { onNext: () => void }) {
}
}}
>
<StyledText size={17} weight="semibold" color="#FFFFFF">
<StyledText size={17} weight="semibold" color={NAVY[900]}>
{t('common:next')}
</StyledText>
</Pressable>
@@ -659,7 +663,7 @@ function PersonalizationScreen({
</View>
{name.trim().length > 0 && (
<StyledText size={15} color={BRAND.SUCCESS} style={styles.readyMessage}>
<StyledText size={15} color={GREEN[500]} style={styles.readyMessage}>
{t('onboarding.personalization.readyMessage')}
</StyledText>
)}
@@ -678,7 +682,7 @@ function PersonalizationScreen({
<StyledText
size={17}
weight="semibold"
color={name.trim() ? '#FFFFFF' : colors.text.disabled}
color={name.trim() ? NAVY[900] : colors.text.disabled}
>
{t('common:continue')}
</StyledText>
@@ -822,7 +826,7 @@ function PaywallScreen({
key={featureKey}
style={[styles.featureRow, { opacity: featureAnims[i] }]}
>
<Icon name="checkmark.circle.fill" size={22} color={BRAND.SUCCESS} />
<Icon name="checkmark.circle.fill" size={22} color={GREEN[500]} />
<StyledText
size={16}
color={colors.text.primary}
@@ -846,7 +850,7 @@ function PaywallScreen({
onPress={() => handlePlanSelect('premium-yearly')}
>
<View style={styles.bestValueBadge}>
<StyledText size={11} weight="bold" color="#FFFFFF">
<StyledText size={11} weight="bold" color={NAVY[900]}>
{t('onboarding.paywall.bestValue')}
</StyledText>
</View>
@@ -856,7 +860,7 @@ function PaywallScreen({
<StyledText size={13} color={colors.text.secondary}>
{t('common:units.perYear')}
</StyledText>
<StyledText size={12} weight="semibold" color={BRAND.PRIMARY} style={{ marginTop: SPACING[1] }}>
<StyledText size={12} weight="semibold" color={GREEN[500]} style={{ marginTop: SPACING[1] }}>
{t('onboarding.paywall.savePercent')}
</StyledText>
</Pressable>
@@ -886,7 +890,7 @@ function PaywallScreen({
onPress={handlePurchase}
disabled={isPurchasing}
>
<StyledText size={17} weight="bold" color="#FFFFFF">
<StyledText size={17} weight="bold" color={NAVY[900]}>
{isPurchasing ? '...' : t('onboarding.paywall.trialCta')}
</StyledText>
</Pressable>
@@ -906,8 +910,8 @@ function PaywallScreen({
</Pressable>
{/* Skip */}
<Pressable
style={styles.skipButton}
<Pressable
style={styles.skipButton}
testID="skip-paywall"
onPress={() => {
track('onboarding_paywall_skipped')
@@ -1125,8 +1129,8 @@ function createStyles(colors: ThemeColors) {
// CTA Button
ctaButton: {
height: LAYOUT.BUTTON_HEIGHT,
backgroundColor: BRAND.PRIMARY,
borderRadius: RADIUS.GLASS_BUTTON,
backgroundColor: GREEN[500],
borderRadius: RADIUS.MD,
alignItems: 'center',
justifyContent: 'center',
},
@@ -1146,12 +1150,14 @@ function createStyles(colors: ThemeColors) {
width: (SCREEN_WIDTH - LAYOUT.SCREEN_PADDING * 2 - SPACING[3]) / 2,
paddingVertical: SPACING[6],
alignItems: 'center',
borderRadius: RADIUS.GLASS_CARD,
...colors.glass.base,
borderRadius: RADIUS.LG,
backgroundColor: NAVY[800],
borderWidth: 1,
borderColor: BORDER_COLORS.DIM,
},
barrierCardSelected: {
borderColor: BRAND.PRIMARY,
backgroundColor: 'rgba(255, 107, 53, 0.1)',
borderColor: GREEN.BORDER,
backgroundColor: GREEN.DIM,
},
// ── Screen 3: Comparison ──
@@ -1181,7 +1187,7 @@ function createStyles(colors: ThemeColors) {
borderRadius: RADIUS.SM,
},
barTabata: {
backgroundColor: BRAND.PRIMARY,
backgroundColor: GREEN[500],
},
barCardio: {
backgroundColor: PHASE.REST,
@@ -1222,7 +1228,7 @@ function createStyles(colors: ThemeColors) {
color: colors.text.primary,
fontSize: 17,
borderWidth: 1,
borderColor: colors.border.glass,
borderColor: BORDER_COLORS.DIM,
},
segmentRow: {
flexDirection: 'row',
@@ -1268,16 +1274,18 @@ function createStyles(colors: ThemeColors) {
paddingVertical: SPACING[5],
alignItems: 'center',
justifyContent: 'center',
borderRadius: RADIUS.GLASS_CARD,
...colors.glass.base,
borderRadius: RADIUS.LG,
backgroundColor: NAVY[800],
borderWidth: 1,
borderColor: BORDER_COLORS.DIM,
},
pricingCardSelected: {
borderColor: BRAND.PRIMARY,
borderColor: GREEN.BORDER,
borderWidth: 2,
backgroundColor: 'rgba(255, 107, 53, 0.08)',
backgroundColor: GREEN.DIM,
},
bestValueBadge: {
backgroundColor: BRAND.PRIMARY,
backgroundColor: GREEN[500],
paddingHorizontal: SPACING[3],
paddingVertical: SPACING[1],
borderRadius: RADIUS.SM,
@@ -1285,8 +1293,8 @@ function createStyles(colors: ThemeColors) {
},
trialButton: {
height: LAYOUT.BUTTON_HEIGHT,
backgroundColor: BRAND.PRIMARY,
borderRadius: RADIUS.GLASS_BUTTON,
backgroundColor: GREEN[500],
borderRadius: RADIUS.MD,
alignItems: 'center',
justifyContent: 'center',
marginTop: SPACING[6],
@@ -1322,7 +1330,7 @@ function createWowStyles(colors: ThemeColors) {
iconCircle: {
width: 44,
height: 44,
borderRadius: 22,
borderRadius: RADIUS.FULL,
alignItems: 'center',
justifyContent: 'center',
},

View File

@@ -12,16 +12,17 @@ import {
} from 'react-native'
import { useRouter } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { LinearGradient } from 'expo-linear-gradient'
import { Icon, type IconName } from '@/src/shared/components/Icon'
import { useTranslation } from 'react-i18next'
import { useHaptics, usePurchases } from '@/src/shared/hooks'
import { StyledText } from '@/src/shared/components/StyledText'
import { useThemeColors, BRAND, GRADIENTS } from '@/src/shared/theme'
import { useThemeColors } from '@/src/shared/theme'
import { NativeButton } from '@/src/shared/components/native'
import type { ThemeColors } from '@/src/shared/theme/types'
import { SPACING } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { GREEN, NAVY, BORDER_COLORS } from '@/src/shared/constants/colors'
// ═══════════════════════════════════════════════════════════════════════════
// FEATURES LIST
@@ -83,17 +84,17 @@ function PlanCard({
onPress={handlePress}
style={({ pressed }) => [
styles.planCard,
isSelected && { borderColor: BRAND.PRIMARY },
isSelected && { borderColor: GREEN.BORDER },
pressed && styles.planCardPressed,
{
backgroundColor: colors.bg.surface,
borderColor: isSelected ? BRAND.PRIMARY : colors.border.glass,
borderColor: isSelected ? GREEN.BORDER : BORDER_COLORS.DIM,
},
]}
>
{savings && (
<View style={styles.savingsBadge}>
<StyledText size={10} weight="bold" color={colors.text.primary}>{savings}</StyledText>
<StyledText size={10} weight="bold" color={NAVY[900]}>{savings}</StyledText>
</View>
)}
<View style={styles.planInfo}>
@@ -104,12 +105,12 @@ function PlanCard({
{period}
</StyledText>
</View>
<StyledText size={20} weight="bold" color={BRAND.PRIMARY}>
<StyledText size={20} weight="bold" color={GREEN[500]}>
{price}
</StyledText>
{isSelected && (
<View style={styles.checkmark}>
<Icon name="checkmark.circle.fill" size={24} color={BRAND.PRIMARY} />
<Icon name="checkmark.circle.fill" size={24} color={GREEN[500]} />
</View>
)}
</Pressable>
@@ -195,9 +196,13 @@ export default function PaywallScreen() {
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
{/* Close Button */}
<Pressable style={[styles.closeButton, { top: insets.top + SPACING[2] }]} onPress={handleClose}>
<Icon name="xmark" size={28} color={colors.text.secondary} />
</Pressable>
<View style={[styles.closeButton, { top: insets.top + SPACING[2] }]}>
<NativeButton
variant="icon"
systemImage="xmark"
onPress={handleClose}
/>
</View>
<ScrollView
style={styles.scrollView}
@@ -221,8 +226,8 @@ export default function PaywallScreen() {
<View style={styles.featuresGrid}>
{PREMIUM_FEATURES.map((feature) => (
<View key={feature.key} style={styles.featureItem}>
<View style={[styles.featureIcon, { backgroundColor: colors.glass.tinted.backgroundColor }]}>
<Icon name={feature.icon} size={22} color={BRAND.PRIMARY} />
<View style={[styles.featureIcon, { backgroundColor: GREEN.DIM }]}>
<Icon name={feature.icon} size={22} color={GREEN[500]} />
</View>
<StyledText size={13} color={colors.text.secondary} style={{ textAlign: 'center' }}>
{t(`paywall.features.${feature.key}`)}
@@ -262,30 +267,22 @@ export default function PaywallScreen() {
)}
{/* CTA Button */}
<Pressable
style={[styles.ctaButton, isLoading && styles.ctaButtonDisabled]}
<NativeButton
variant="primary"
title={isLoading ? t('paywall.processing') : t('paywall.trialCta')}
onPress={handlePurchase}
disabled={isLoading}
>
<LinearGradient
colors={GRADIENTS.CTA}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.ctaGradient}
>
<StyledText size={17} weight="semibold" color="#FFFFFF">
{isLoading ? t('paywall.processing') : t('paywall.trialCta')}
</StyledText>
</LinearGradient>
</Pressable>
fullWidth
controlSize="large"
/>
{/* Restore & Terms */}
<View style={styles.footer}>
<Pressable onPress={handleRestore}>
<StyledText size={14} color={colors.text.tertiary}>
{t('paywall.restore')}
</StyledText>
</Pressable>
<NativeButton
variant="ghost"
title={t('paywall.restore')}
onPress={handleRestore}
/>
<StyledText size={11} color={colors.text.tertiary} style={{ textAlign: 'center', lineHeight: 18, paddingHorizontal: SPACING[4] }}>
{t('paywall.terms')}
@@ -379,7 +376,7 @@ function createStyles(colors: ThemeColors) {
position: 'absolute',
top: -8,
right: SPACING[3],
backgroundColor: BRAND.PRIMARY,
backgroundColor: GREEN[500],
paddingHorizontal: SPACING[2],
paddingVertical: 2,
borderRadius: RADIUS.SM,
@@ -414,16 +411,14 @@ function createStyles(colors: ThemeColors) {
},
ctaButton: {
borderRadius: RADIUS.LG,
overflow: 'hidden',
marginTop: SPACING[6],
paddingVertical: SPACING[4],
alignItems: 'center',
backgroundColor: GREEN[500],
},
ctaButtonDisabled: {
opacity: 0.6,
},
ctaGradient: {
paddingVertical: SPACING[4],
alignItems: 'center',
},
ctaText: {
fontSize: 17,
fontWeight: '600',

View File

@@ -10,9 +10,18 @@
| #5000 | 9:35 AM | 🔵 | Reviewed Player Screen Implementation | ~522 |
| #4912 | 8:16 AM | 🔵 | Found doneButton component in player screen | ~104 |
### Feb 21, 2026
### Apr 9, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #5551 | 12:02 AM | 🔄 | Converted onboarding and player screens to theme system | ~261 |
| #5997 | 10:46 AM | 🟣 | Tabata Kine programs system fully implemented with four programs and specialized UI | ~563 |
| #5996 | 10:41 AM | 🟣 | Tabata Kine programs system implementation completed | ~460 |
| #5975 | 9:43 AM | 🟣 | Player screen updated to support kiné session detection and routing | ~316 |
### Apr 10, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #6005 | 10:02 AM | 🔵 | Player Screen Routing Between Kine and Legacy Workouts | ~335 |
| #5998 | 9:52 AM | 🟣 | Tabata Kine programs system implementation completed | ~711 |
</claude-mem-context>

View File

@@ -16,13 +16,15 @@ import {
} from 'react-native'
import { useRouter, useLocalSearchParams } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { LinearGradient } from 'expo-linear-gradient'
import { BlurView } from 'expo-blur'
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'
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'
@@ -33,10 +35,11 @@ import { useWatchSync } from '@/src/features/watch'
import { track } from '@/src/shared/services/analytics'
import { VideoPlayer } from '@/src/shared/components/VideoPlayer'
import { PHASE_COLORS, GRADIENTS, darkColors } from '@/src/shared/theme'
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,
@@ -64,6 +67,70 @@ export default function PlayerScreen() {
useKeepAwake()
const router = useRouter()
const { id } = useLocalSearchParams<{ id: string }>()
// ─── Dispatch: Workout Program → Tabata session → Legacy workout ─
const sessionId = id ?? '1'
if (isWorkoutProgramId(sessionId)) {
return <WorkoutProgramPlayerScreen compositeId={sessionId} />
}
if (isTabataSession(sessionId)) {
const session = getTabataSessionById(sessionId)
if (session) {
return <TabataPlayerScreen session={session} />
}
// Fallback to legacy if session not found
}
return <LegacyPlayerScreen id={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)
React.useEffect(() => {
let cancelled = false
async function load() {
const parsed = parseWorkoutProgramId(compositeId)
if (!parsed) { if (!cancelled) setSession(null); return }
const program = await fetchProgramById(parsed.programId)
if (cancelled) return
if (!program) { setSession(null); return }
setSession(workoutProgramToTabataSession(program))
}
load()
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} />
}
/**
* Legacy player for original workout format
*/
function LegacyPlayerScreen({ id }: { id: string }) {
const router = useRouter()
const insets = useSafeAreaInsets()
const haptics = useHaptics()
const { t } = useTranslation()
@@ -82,7 +149,7 @@ export default function PlayerScreen() {
// Music player — synced with workout timer
const music = useMusicPlayer({
vibe: workout?.musicVibe ?? 'electronic',
isPlaying: timer.isRunning && !timer.isPaused,
isPlaying: timer.isRunning && !timer.isPaused && timer.phase !== 'PREP',
})
const [showControls, setShowControls] = useState(true)
@@ -262,11 +329,7 @@ export default function PlayerScreen() {
{showControls && (
<View style={[styles.header, { paddingTop: insets.top + SPACING[4] }]}>
<Pressable onPress={stopWorkout} style={styles.closeBtn}>
<BlurView
intensity={colors.glass.blurLight}
tint={colors.glass.blurTint}
style={StyleSheet.absoluteFill}
/>
<View style={StyleSheet.absoluteFill} />
<Icon name="xmark" size={24} tintColor={colors.text.primary} />
</Pressable>
<View style={styles.headerCenter}>
@@ -364,12 +427,6 @@ export default function PlayerScreen() {
{timer.isComplete && (
<View style={[styles.controls, { paddingBottom: insets.bottom + SPACING[6] }]}>
<Pressable style={styles.doneButton} onPress={completeWorkout}>
<LinearGradient
colors={GRADIENTS.CTA}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={StyleSheet.absoluteFill}
/>
<Text style={styles.doneButtonText}>{t('common:done')}</Text>
</Pressable>
</View>
@@ -378,11 +435,6 @@ export default function PlayerScreen() {
{/* Burn bar */}
{showControls && timer.isRunning && !timer.isComplete && (
<View style={[styles.burnBarContainer, { bottom: insets.bottom + 140 }]}>
<BlurView
intensity={colors.glass.blurMedium}
tint={colors.glass.blurTint}
style={StyleSheet.absoluteFill}
/>
<BurnBar currentCalories={timer.calories} avgCalories={workout?.calories ?? 45} />
</View>
)}
@@ -404,12 +456,10 @@ export default function PlayerScreen() {
// ─── Styles ──────────────────────────────────────────────────────────────────
const colors = darkColors
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bg.base,
backgroundColor: NAVY[900],
},
phaseBg: {
...StyleSheet.absoluteFillObject,
@@ -429,23 +479,24 @@ const styles = StyleSheet.create({
closeBtn: {
width: 44,
height: 44,
borderRadius: 22,
borderRadius: RADIUS.FULL,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
borderWidth: 1,
borderColor: colors.border.glass,
borderColor: BORDER_COLORS.DIM,
backgroundColor: NAVY[800],
},
headerCenter: {
alignItems: 'center',
},
title: {
...TYPOGRAPHY.HEADLINE,
color: colors.text.primary,
color: TEXT.PRIMARY,
},
subtitle: {
...TYPOGRAPHY.CAPTION_1,
color: colors.text.tertiary,
color: TEXT.TERTIARY,
},
// Stats overlay
@@ -466,7 +517,7 @@ const styles = StyleSheet.create({
},
timerTime: {
...TYPOGRAPHY.TIMER_NUMBER,
color: colors.text.primary,
color: TEXT.PRIMARY,
fontVariant: ['tabular-nums'],
},
@@ -489,7 +540,8 @@ const styles = StyleSheet.create({
borderCurve: 'continuous',
overflow: 'hidden',
borderWidth: 1,
borderColor: colors.border.glass,
borderColor: BORDER_COLORS.DIM,
backgroundColor: NAVY[800],
padding: SPACING[3],
},
@@ -507,7 +559,7 @@ const styles = StyleSheet.create({
},
completeTitle: {
...TYPOGRAPHY.LARGE_TITLE,
color: colors.text.primary,
color: TEXT.PRIMARY,
},
completeSubtitle: {
...TYPOGRAPHY.TITLE_3,
@@ -523,27 +575,27 @@ const styles = StyleSheet.create({
},
completeStatValue: {
...TYPOGRAPHY.TITLE_1,
color: colors.text.primary,
color: TEXT.PRIMARY,
fontVariant: ['tabular-nums'],
},
completeStatLabel: {
...TYPOGRAPHY.CAPTION_1,
color: colors.text.tertiary,
color: TEXT.TERTIARY,
marginTop: SPACING[1],
},
doneButton: {
width: 200,
height: 56,
borderRadius: RADIUS.GLASS_BUTTON,
borderRadius: RADIUS.MD,
borderCurve: 'continuous',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
...colors.shadow.BRAND_GLOW,
backgroundColor: GREEN[500],
},
doneButtonText: {
...TYPOGRAPHY.BUTTON_MEDIUM,
color: colors.text.primary,
color: NAVY[900],
letterSpacing: 1,
},
})

View File

@@ -13,6 +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'
export default function PrivacyPolicyScreen() {
const { t } = useTranslation('screens')
@@ -144,7 +145,7 @@ const styles = StyleSheet.create({
paddingHorizontal: SPACING[4],
paddingVertical: SPACING[3],
borderBottomWidth: 1,
borderBottomColor: darkColors.border.glass,
borderBottomColor: darkColors.border.dim,
},
backButton: {
width: 44,
@@ -153,8 +154,7 @@ const styles = StyleSheet.create({
justifyContent: 'center',
},
headerTitle: {
fontSize: 17,
fontWeight: '600',
...TYPOGRAPHY.HEADLINE,
color: darkColors.text.primary,
},
scrollView: {
@@ -168,13 +168,13 @@ const styles = StyleSheet.create({
marginBottom: SPACING[6],
},
sectionTitle: {
fontSize: 20,
fontWeight: '700',
...TYPOGRAPHY.TITLE_3,
fontFamily: FONT_FAMILY.SANS_BOLD,
color: darkColors.text.primary,
marginBottom: SPACING[3],
},
paragraph: {
fontSize: 15,
...TYPOGRAPHY.BODY,
lineHeight: 22,
color: darkColors.text.secondary,
},
@@ -186,18 +186,18 @@ const styles = StyleSheet.create({
marginBottom: SPACING[2],
},
bullet: {
fontSize: 15,
...TYPOGRAPHY.BODY,
color: BRAND.PRIMARY,
marginRight: SPACING[2],
},
bulletText: {
flex: 1,
fontSize: 15,
...TYPOGRAPHY.BODY,
lineHeight: 22,
color: darkColors.text.secondary,
},
email: {
fontSize: 15,
...TYPOGRAPHY.BODY,
color: BRAND.PRIMARY,
marginTop: SPACING[2],
},

28
app/program/CLAUDE.md Normal file
View File

@@ -0,0 +1,28 @@
<claude-mem-context>
# Recent Activity
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
### Apr 9, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #5997 | 10:46 AM | 🟣 | Tabata Kine programs system fully implemented with four programs and specialized UI | ~563 |
| #5996 | 10:41 AM | 🟣 | Tabata Kine programs system implementation completed | ~460 |
| #5978 | 9:53 AM | 🟣 | Kine program detail screen implemented | ~452 |
### Apr 10, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #6027 | 10:08 AM | 🔵 | Program Detail Screen Re-Referenced for Kine Program Display | ~458 |
| #6004 | 10:02 AM | 🔵 | Kine Program Detail Screen Architecture | ~337 |
| #5998 | 9:52 AM | 🟣 | Tabata Kine programs system implementation completed | ~711 |
### Apr 11, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #6150 | 7:49 PM | 🔵 | Program detail unlock button contains hardcoded orange | ~253 |
| #6134 | 7:43 PM | 🔄 | Program detail screen added withOpacity import | ~237 |
</claude-mem-context>

View File

@@ -1,5 +1,5 @@
/**
* Tabata Kine Program Detail Screen
* Tabata Program Detail Screen
* Displays program overview, weeks, sessions, and progression for kiné programs
*/
@@ -9,31 +9,31 @@ import { Stack, useRouter, useLocalSearchParams } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { Icon } from '@/src/shared/components/Icon'
import { useKineProgramStore } from '@/src/shared/stores/kineProgramStore'
import { getKineProgramById, getKineSessionsByWeek } from '@/src/shared/data/kine'
import { useTabataProgramStore } from '@/src/shared/stores/tabataProgramStore'
import { getTabataProgramById, getTabataSessionsByWeek } from '@/src/shared/data/tabata'
import { canAccessProgram } from '@/src/shared/services/access'
import { useUserStore } from '@/src/shared/stores/userStore'
import type { KineProgramId } from '@/src/shared/types/program'
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 { withOpacity } from '@/src/shared/utils/color'
export default function KineProgramDetailScreen() {
export default function TabataProgramDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>()
const router = useRouter()
const insets = useSafeAreaInsets()
const programId = id as KineProgramId
const program = getKineProgramById(programId)
const programId = id as TabataProgramId
const program = getTabataProgramById(programId)
const selectProgram = useKineProgramStore(s => s.selectProgram)
const progress = useKineProgramStore(s => s.programsProgress[programId])
const isWeekUnlocked = useKineProgramStore(s => s.isWeekUnlocked)
const getCurrentSession = useKineProgramStore(s => s.getCurrentSession)
const completion = useKineProgramStore(s => s.getProgramCompletion(programId))
const getProgramStatus = useKineProgramStore(s => s.getProgramStatus)
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 isPremium = useUserStore(s => s.profile.subscription) !== 'free'
const canAccess = canAccessProgram(programId, isPremium)

View File

@@ -11,4 +11,10 @@
| #5044 | " | ✅ | Removed opening Host tag from workout detail screen | ~158 |
| #5032 | 8:19 AM | ✅ | Removed Host import from workout detail screen | ~194 |
| #5025 | 8:18 AM | 🔵 | Workout detail screen properly wraps content with Host component | ~244 |
### Apr 17, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #6366 | 10:21 AM | 🔵 | Verified workout program player integration in workout/[id].tsx | ~348 |
</claude-mem-context>

View File

@@ -3,7 +3,7 @@
* Clean scrollable layout — native header, no hero
*/
import React, { useEffect, useRef } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import {
View,
Text as RNText,
@@ -24,7 +24,11 @@ import { useHaptics } from '@/src/shared/hooks'
import { usePurchases } from '@/src/shared/hooks/usePurchases'
import { useUserStore } from '@/src/shared/stores'
import { track } from '@/src/shared/services/analytics'
import { canAccessWorkout } from '@/src/shared/services/access'
import { canAccessWorkout, canAccessSession } from '@/src/shared/services/access'
import { getTabataSessionById, isTabataSession } from '@/src/shared/data/tabata'
import { isWorkoutProgramId, parseWorkoutProgramId, fetchProgramById, workoutProgramToTabataSession } from '@/src/shared/data/workoutPrograms'
import { BODY_ZONE_META } from '@/src/shared/types/workoutProgram'
import type { TabataSession } from '@/src/shared/types/program'
import { getWorkoutById, getTrainerById, getWorkoutAccentColor } from '@/src/shared/data'
import { useTranslatedWorkout, useMusicVibeLabel } from '@/src/shared/data/useTranslatedData'
@@ -34,6 +38,8 @@ import { TYPOGRAPHY } from '@/src/shared/constants/typography'
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { SPRING } from '@/src/shared/constants/animations'
import { TEXT, NAVY, BRAND, GREEN, AMBER, RED, DARK, BORDER_COLORS } from '@/src/shared/constants/colors'
import { NativeButton } from '@/src/shared/components/native'
// ─── Save Button (headerRight) ───────────────────────────────────────────────
@@ -50,12 +56,15 @@ function SaveButton({
<Pressable
onPress={onPress}
hitSlop={8}
style={({ pressed }) => pressed && { opacity: 0.6 }}
style={({ pressed }) => [
{ width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
pressed && { opacity: 0.6 },
]}
>
<Icon
name={isSaved ? 'heart.fill' : 'heart'}
size={22}
color={isSaved ? '#FF3B30' : colors.text.primary}
color={isSaved ? BRAND.DANGER : colors.text.primary}
/>
</Pressable>
)
@@ -69,6 +78,215 @@ export default function WorkoutDetailScreen() {
const haptics = useHaptics()
const { t } = useTranslation()
const { id } = useLocalSearchParams<{ id: string }>()
// ─── Dispatch: Workout Program → Tabata session → Legacy workout ─
if (isWorkoutProgramId(id ?? '')) {
return <WorkoutProgramDetailScreen compositeId={id ?? ''} />
}
if (isTabataSession(id ?? '')) {
return <TabataSessionDetailScreen sessionId={id ?? ''} />
}
return <LegacyWorkoutDetailScreen id={id ?? '1'} />
}
/**
* Workout Program Detail — loads a program tabata and delegates to TabataSessionDetailScreen
*/
function WorkoutProgramDetailScreen({ compositeId }: { compositeId: string }) {
const [session, setSession] = React.useState<TabataSession | null | undefined>(undefined)
const [accent, setAccent] = React.useState<string>(GREEN[500])
const [isFree, setIsFree] = React.useState<boolean>(false)
React.useEffect(() => {
let cancelled = false
async function load() {
const parsed = parseWorkoutProgramId(compositeId)
if (!parsed) { if (!cancelled) setSession(null); return }
const program = await fetchProgramById(parsed.programId)
if (cancelled) return
if (!program) { setSession(null); return }
const tabataSession = workoutProgramToTabataSession(program)
setSession(tabataSession)
setIsFree(program.isFree === true)
const zoneMeta = BODY_ZONE_META[program.bodyZone]
setAccent(program.accentColor ?? zoneMeta.color)
}
load()
return () => { cancelled = true }
}, [compositeId])
if (session === undefined) {
return (
<View style={{ flex: 1, backgroundColor: NAVY[900], justifyContent: 'center', alignItems: 'center' }}>
<Stack.Screen options={{ headerShown: true, headerTitle: '', headerStyle: { backgroundColor: NAVY[900] }, headerTintColor: TEXT.PRIMARY }} />
<RNText style={{ color: TEXT.SECONDARY }}>Chargement...</RNText>
</View>
)
}
if (session === null) {
return (
<View style={{ flex: 1, backgroundColor: NAVY[900], justifyContent: 'center', alignItems: 'center' }}>
<Stack.Screen options={{ headerShown: true, headerTitle: '', headerStyle: { backgroundColor: NAVY[900] }, headerTintColor: TEXT.PRIMARY }} />
<RNText style={{ color: TEXT.SECONDARY }}>Programme non trouvé</RNText>
</View>
)
}
return <TabataSessionDetailScreen sessionId={session.id} sessionOverride={session} accentOverride={accent} isFreeOverride={isFree} />
}
/**
* Tabata Session Detail — shows warmup, blocks, cooldown, tabata tips
*/
function TabataSessionDetailScreen({
sessionId,
sessionOverride,
accentOverride,
isFreeOverride,
}: {
sessionId: string
sessionOverride?: TabataSession
accentOverride?: string
isFreeOverride?: boolean
}) {
const router = useRouter()
const insets = useSafeAreaInsets()
const haptics = useHaptics()
const session = sessionOverride ?? getTabataSessionById(sessionId)
const { isPremium } = usePurchases()
const canAccess = isFreeOverride !== undefined
? (isPremium || isFreeOverride)
: canAccessSession(sessionId, isPremium)
if (!session) {
return (
<View style={{ flex: 1, backgroundColor: NAVY[900], justifyContent: 'center', alignItems: 'center' }}>
<Stack.Screen options={{ headerShown: true, headerTitle: '', headerStyle: { backgroundColor: NAVY[900] }, headerTintColor: TEXT.PRIMARY }} />
<RNText style={{ color: TEXT.SECONDARY }}>Séance non trouvée</RNText>
</View>
)
}
const programId = sessionId.startsWith('deb-') ? 'debutant' : sessionId.startsWith('int-') ? 'intermediaire' : sessionId.startsWith('avc-') ? 'avance' : 'bureau'
const accentMap: Record<string, string> = { debutant: GREEN[500], intermediaire: BRAND.INFO, avance: RED[500], bureau: AMBER[500] }
const accent = accentOverride ?? accentMap[programId] ?? GREEN[500]
const handleStart = () => {
haptics.buttonTap()
track('tabata_session_start_pressed', { session_id: sessionId })
router.push(`/player/${sessionId}`)
}
return (
<View style={styles.container}>
<Stack.Screen options={{ headerShown: true, headerTitle: session.title, headerStyle: { backgroundColor: NAVY[900] }, headerTintColor: TEXT.PRIMARY }} />
<ScrollView contentContainerStyle={{ paddingBottom: insets.bottom + 100 }}>
{/* Session info */}
<View style={[styles.heroSection, { backgroundColor: accent + '15' }]}>
<RNText style={styles.sessionTitle}>{session.title}</RNText>
<RNText style={styles.sessionDesc}>{session.description}</RNText>
<View style={styles.metaRow}>
<RNText style={styles.metaText}>{session.blocks.length} bloc{session.blocks.length > 1 ? 's' : ''}</RNText>
<RNText style={styles.metaText}>·</RNText>
<RNText style={styles.metaText}>{session.totalDuration} min</RNText>
<RNText style={styles.metaText}>·</RNText>
<RNText style={styles.metaText}>{session.calories} cal</RNText>
</View>
{/* Focus tags */}
<View style={styles.focusRow}>
{session.focus.map((f, i) => (
<View key={i} style={[styles.focusTag, { borderColor: accent }]}>
<RNText style={[styles.focusTagText, { color: accent }]}>{f}</RNText>
</View>
))}
</View>
</View>
{/* Warmup */}
{session.warmup.movements.length > 0 && (
<View style={styles.section}>
<RNText style={styles.sectionTitle}>Échauffement · {Math.floor(session.warmup.totalDuration / 60)} min</RNText>
{session.warmup.movements.map((m, i) => (
<View key={i} style={styles.movementRow}>
<RNText style={styles.movementDot}></RNText>
<RNText style={styles.movementName}>{m.name}</RNText>
<RNText style={styles.movementDuration}>{m.duration}s</RNText>
</View>
))}
</View>
)}
{/* Blocks */}
{session.blocks.map((block, bi) => (
<View key={block.id} style={styles.section}>
<RNText style={styles.sectionTitle}>Bloc {bi + 1} · {block.rounds} rounds · {block.workTime}/{block.restTime}s</RNText>
<View style={styles.exercisePair}>
<View style={[styles.exerciseCard, { borderLeftColor: accent }]}>
<RNText style={styles.exerciseLabel}>Rounds impairs</RNText>
<RNText style={styles.exerciseName}>{block.oddExercise.name}</RNText>
{block.oddExercise.conseil ? <RNText style={styles.exerciseTip}>📋 {block.oddExercise.conseil}</RNText> : null}
</View>
<View style={[styles.exerciseCard, { borderLeftColor: BRAND.INFO }]}>
<RNText style={styles.exerciseLabel}>Rounds pairs</RNText>
<RNText style={styles.exerciseName}>{block.evenExercise.name}</RNText>
{block.evenExercise.conseil ? <RNText style={styles.exerciseTip}>📋 {block.evenExercise.conseil}</RNText> : null}
</View>
</View>
</View>
))}
{/* Cooldown */}
{session.cooldown.movements.length > 0 && (
<View style={styles.section}>
<RNText style={styles.sectionTitle}>Retour au calme · {Math.floor(session.cooldown.totalDuration / 60)} min</RNText>
{session.cooldown.movements.map((m, i) => (
<View key={i} style={styles.movementRow}>
<RNText style={styles.movementDot}></RNText>
<RNText style={styles.movementName}>{m.name}</RNText>
<RNText style={styles.movementDuration}>{m.duration}s</RNText>
</View>
))}
</View>
)}
{/* Equipment */}
{session.equipment.length > 0 && (
<View style={styles.section}>
<RNText style={styles.sectionTitle}>Matériel</RNText>
{session.equipment.map((eq, i) => (
<RNText key={i} style={styles.equipText}> {eq}</RNText>
))}
</View>
)}
</ScrollView>
{/* CTA */}
<View style={[styles.ctaContainer, { paddingBottom: insets.bottom + SPACING[4] }]}>
{canAccess ? (
<Pressable style={[styles.ctaButton, { backgroundColor: accent }]} onPress={handleStart}>
<RNText style={styles.ctaText}>Commencer la séance</RNText>
</Pressable>
) : (
<Pressable style={[styles.ctaButton, { backgroundColor: GREEN[500] }]} onPress={() => router.push('/paywall')}>
<RNText style={styles.ctaText}>Débloquer avec Premium</RNText>
</Pressable>
)}
</View>
</View>
)
}
/**
* Legacy workout detail — original format
*/
function LegacyWorkoutDetailScreen({ id }: { id: string }) {
const insets = useSafeAreaInsets()
const router = useRouter()
const haptics = useHaptics()
const { t } = useTranslation()
const savedWorkouts = useUserStore((s) => s.savedWorkouts)
const toggleSavedWorkout = useUserStore((s) => s.toggleSavedWorkout)
const { isPremium } = usePurchases()
@@ -80,7 +298,7 @@ export default function WorkoutDetailScreen() {
const workout = useTranslatedWorkout(rawWorkout)
const musicVibeLabel = useMusicVibeLabel(rawWorkout?.musicVibe ?? '')
const trainer = rawWorkout ? getTrainerById(rawWorkout.trainerId) : undefined
const accentColor = getWorkoutAccentColor(id ?? '1')
const accentColor = GREEN[500]
// CTA entrance
const ctaAnim = useRef(new Animated.Value(0)).current
@@ -141,8 +359,8 @@ export default function WorkoutDetailScreen() {
router.push(`/player/${workout.id}`)
}
const ctaBg = isDark ? '#FFFFFF' : '#000000'
const ctaText = isDark ? '#000000' : '#FFFFFF'
const ctaBg = isDark ? TEXT.PRIMARY : NAVY[900]
const ctaText = isDark ? NAVY[900] : TEXT.PRIMARY
const ctaLockedBg = isDark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.08)'
const ctaLockedText = colors.text.primary
@@ -183,7 +401,7 @@ export default function WorkoutDetailScreen() {
<View style={s.mediaContainer}>
<VideoPlayer
videoUrl={rawWorkout.videoUrl}
gradientColors={['#1C1C1E', '#2C2C2E']}
gradientColors={[NAVY[800], NAVY[700]]}
mode="preview"
isPlaying={false}
style={s.thumbnail}
@@ -208,14 +426,14 @@ export default function WorkoutDetailScreen() {
<View style={s.metaItem}>
<Icon name="clock" size={15} tintColor={colors.text.tertiary} />
<RNText style={[s.metaText, { color: colors.text.secondary }]}>
{workout.duration} {t('units.minUnit', { count: workout.duration })}
{t('units.minUnit', { count: workout.duration })}
</RNText>
</View>
<RNText style={[s.metaDot, { color: colors.text.hint }]}>·</RNText>
<View style={s.metaItem}>
<Icon name="flame" size={15} tintColor={colors.text.tertiary} />
<RNText style={[s.metaText, { color: colors.text.secondary }]}>
{workout.calories} {t('units.calUnit', { count: workout.calories })}
{t('units.calUnit', { count: workout.calories })}
</RNText>
</View>
<RNText style={[s.metaDot, { color: colors.text.hint }]}>·</RNText>
@@ -230,7 +448,7 @@ export default function WorkoutDetailScreen() {
</RNText>
{/* Separator */}
<View style={[s.separator, { backgroundColor: colors.border.glassLight }]} />
<View style={[s.separator, { backgroundColor: colors.border.dim }]} />
{/* Timing Card */}
<View style={[s.card, { backgroundColor: colors.bg.surface }]}>
@@ -243,7 +461,7 @@ export default function WorkoutDetailScreen() {
{t('screens:workout.prep', { defaultValue: 'Prep' })}
</RNText>
</View>
<View style={[s.timingDivider, { backgroundColor: colors.border.glassLight }]} />
<View style={[s.timingDivider, { backgroundColor: colors.border.dim }]} />
<View style={s.timingItem}>
<RNText style={[s.timingValue, { color: accentColor }]}>
{workout.workTime}s
@@ -252,7 +470,7 @@ export default function WorkoutDetailScreen() {
{t('screens:workout.work', { defaultValue: 'Work' })}
</RNText>
</View>
<View style={[s.timingDivider, { backgroundColor: colors.border.glassLight }]} />
<View style={[s.timingDivider, { backgroundColor: colors.border.dim }]} />
<View style={s.timingItem}>
<RNText style={[s.timingValue, { color: accentColor }]}>
{workout.restTime}s
@@ -261,7 +479,7 @@ export default function WorkoutDetailScreen() {
{t('screens:workout.rest', { defaultValue: 'Rest' })}
</RNText>
</View>
<View style={[s.timingDivider, { backgroundColor: colors.border.glassLight }]} />
<View style={[s.timingDivider, { backgroundColor: colors.border.dim }]} />
<View style={s.timingItem}>
<RNText style={[s.timingValue, { color: accentColor }]}>
{workout.rounds}
@@ -293,7 +511,7 @@ export default function WorkoutDetailScreen() {
</RNText>
</View>
{index < workout.exercises.length - 1 && (
<View style={[s.exerciseSep, { backgroundColor: colors.border.glassLight }]} />
<View style={[s.exerciseSep, { backgroundColor: colors.border.dim }]} />
)}
</View>
))}
@@ -336,26 +554,15 @@ export default function WorkoutDetailScreen() {
},
]}
>
<Pressable
testID={isLocked ? 'workout-unlock-button' : 'workout-start-button'}
style={({ pressed }) => [
s.ctaButton,
{ backgroundColor: isLocked ? ctaLockedBg : ctaBg },
isLocked && { borderWidth: 1, borderColor: colors.border.glass },
pressed && { opacity: 0.85, transform: [{ scale: 0.98 }] },
]}
<NativeButton
variant={isLocked ? 'secondary' : 'primary'}
title={isLocked ? t('screens:workout.unlockWithPremium') : t('screens:workout.startWorkout')}
systemImage={isLocked ? 'lock.fill' : 'play.fill'}
onPress={handleStartWorkout}
>
{isLocked && (
<Icon name="lock.fill" size={16} color={ctaLockedText} style={{ marginRight: 8 }} />
)}
<RNText
testID="workout-cta-text"
style={[s.ctaText, { color: isLocked ? ctaLockedText : ctaText }]}
>
{isLocked ? t('screens:workout.unlockWithPremium') : t('screens:workout.startWorkout')}
</RNText>
</Pressable>
fullWidth
controlSize="large"
testID={isLocked ? 'workout-unlock-button' : 'workout-start-button'}
/>
</Animated.View>
</View>
</>
@@ -364,6 +571,33 @@ export default function WorkoutDetailScreen() {
// ─── Styles ──────────────────────────────────────────────────────────────────
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: NAVY[900] },
heroSection: { padding: SPACING[5], alignItems: 'center' },
sessionTitle: { ...TYPOGRAPHY.LARGE_TITLE, color: TEXT.PRIMARY, textAlign: 'center' },
sessionDesc: { ...TYPOGRAPHY.BODY, color: TEXT.SECONDARY, textAlign: 'center', marginTop: SPACING[2], lineHeight: 22 },
metaRow: { flexDirection: 'row', marginTop: SPACING[4], gap: SPACING[2], justifyContent: 'center' },
metaText: { ...TYPOGRAPHY.SUBHEADLINE, color: TEXT.SECONDARY },
focusRow: { flexDirection: 'row', flexWrap: 'wrap', gap: SPACING[2], marginTop: SPACING[3], justifyContent: 'center' },
focusTag: { paddingHorizontal: 10, paddingVertical: 3, borderRadius: 12, borderWidth: 1 },
focusTagText: { fontSize: 12, fontWeight: '600' },
section: { paddingHorizontal: SPACING[5], marginTop: SPACING[6] },
sectionTitle: { ...TYPOGRAPHY.HEADLINE, color: TEXT.PRIMARY, marginBottom: SPACING[3] },
movementRow: { flexDirection: 'row', alignItems: 'center', gap: SPACING[2], marginBottom: SPACING[2] },
movementDot: { fontSize: 8, color: TEXT.TERTIARY },
movementName: { ...TYPOGRAPHY.BODY, color: TEXT.PRIMARY, flex: 1 },
movementDuration: { ...TYPOGRAPHY.CAPTION_1, color: TEXT.TERTIARY },
exercisePair: { gap: SPACING[3] },
exerciseCard: { backgroundColor: NAVY[800], borderRadius: RADIUS.MD, padding: SPACING[3], borderLeftWidth: 3 },
exerciseLabel: { ...TYPOGRAPHY.CAPTION_2, color: TEXT.TERTIARY, textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 4 },
exerciseName: { ...TYPOGRAPHY.TITLE_3, color: TEXT.PRIMARY },
exerciseTip: { ...TYPOGRAPHY.CAPTION_1, color: TEXT.SECONDARY, marginTop: SPACING[1], lineHeight: 18 },
equipText: { ...TYPOGRAPHY.SUBHEADLINE, color: TEXT.SECONDARY, marginBottom: SPACING[1] },
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, borderCurve: 'continuous', alignItems: 'center', justifyContent: 'center' },
ctaText: { ...TYPOGRAPHY.BUTTON_MEDIUM, color: NAVY[900], letterSpacing: 0.5 },
})
const s = StyleSheet.create({
container: {
flex: 1,

View File

@@ -0,0 +1,16 @@
<claude-mem-context>
# Recent Activity
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
### Apr 17, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #6377 | 10:28 AM | 🔴 | Fixed duplicate ScrollView opening tag in body zone detail screen | ~223 |
| #6374 | 10:26 AM | 🔄 | Removed header section from body zone detail screen | ~260 |
| #6363 | 10:20 AM | 🔄 | Changed program navigation to exclude explicit tabata position | ~319 |
| #6353 | 10:02 AM | 🔄 | Simplified difficulty pill styling in body-zone detail screen | ~281 |
| #6352 | 10:01 AM | 🔄 | Removed program count badges from difficulty filter pills | ~319 |
| #6351 | " | 🔵 | Discovered body zone detail page with difficulty level filtering | ~364 |
</claude-mem-context>

View File

@@ -0,0 +1,296 @@
/**
* Body Zone Detail Screen
* Shows workout programs filtered by body zone with difficulty pills
*/
import { useState, useMemo, useEffect, useCallback } from 'react'
import { View, StyleSheet, ScrollView, Pressable } from 'react-native'
import { 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 { useHaptics } from '@/src/shared/hooks'
import { usePurchases } from '@/src/shared/hooks/usePurchases'
import { StyledText } from '@/src/shared/components/StyledText'
import { useThemeColors } from '@/src/shared/theme'
import type { ThemeColors } from '@/src/shared/theme/types'
import { BRAND, GREEN, TEXT, NAVY, BORDER_COLORS } from '@/src/shared/constants/colors'
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { canAccessWorkoutProgram } from '@/src/shared/services/access'
import { useWorkoutProgramStore } from '@/src/shared/stores/workoutProgramStore'
import { fetchProgramsByBodyZone, buildWorkoutProgramId } from '@/src/shared/data/workoutPrograms'
import type { WorkoutProgram, BodyZone, ProgramLevel } from '@/src/shared/types/workoutProgram'
import { BODY_ZONE_META, LEVEL_META } from '@/src/shared/types/workoutProgram'
const LEVELS: ProgramLevel[] = ['Beginner', 'Intermediate', 'Advanced']
export default function BodyZoneDetailScreen() {
const insets = useSafeAreaInsets()
const router = useRouter()
const haptics = useHaptics()
const { t } = useTranslation('screens')
const { id } = useLocalSearchParams<{ id: string }>()
const colors = useThemeColors()
const { isPremium } = usePurchases()
const isProgramCompleted = useWorkoutProgramStore(s => s.isProgramCompleted)
const bodyZone = (id ?? 'full-body') as BodyZone
const meta = BODY_ZONE_META[bodyZone]
const [programs, setPrograms] = useState<WorkoutProgram[]>([])
const [selectedLevel, setSelectedLevel] = useState<ProgramLevel>('Beginner')
useEffect(() => {
fetchProgramsByBodyZone(bodyZone).then((data) => {
setPrograms(data)
// Default to first level that has programs
const firstAvailable = LEVELS.find(l => data.some(p => p.level === l))
if (firstAvailable) setSelectedLevel(firstAvailable)
})
}, [bodyZone])
const filteredPrograms = useMemo(
() => programs.filter(p => p.level === selectedLevel),
[programs, selectedLevel],
)
const handleProgramPress = (program: WorkoutProgram) => {
haptics.buttonTap()
const isLocked = !canAccessWorkoutProgram(program, isPremium)
if (isLocked) {
router.push('/paywall')
return
}
router.push(`/workout/${buildWorkoutProgramId(program.id)}` as any)
}
const handleLevelPress = (level: ProgramLevel) => {
haptics.buttonTap()
setSelectedLevel(level)
}
const accentColor = meta.color
const styles = useMemo(() => createStyles(colors, accentColor), [colors, accentColor])
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
<ScrollView
style={styles.scrollView}
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 20 }]}
showsVerticalScrollIndicator={false}
>
{/* Difficulty Pills */}
<View style={styles.pillsRow}>
{LEVELS.map((level) => {
const levelMeta = LEVEL_META[level]
const isActive = selectedLevel === level
return (
<Pressable
key={level}
onPress={() => handleLevelPress(level)}
style={[
styles.pill,
{
backgroundColor: isActive ? accentColor + '20' : NAVY[800],
borderColor: isActive ? accentColor : BORDER_COLORS.DIM,
},
]}
>
<StyledText
size={14}
weight={isActive ? 'semibold' : 'regular'}
color={isActive ? accentColor : colors.text.secondary}
>
{levelMeta.label}
</StyledText>
</Pressable>
)
})}
</View>
{/* Program Count */}
<StyledText size={13} color={colors.text.tertiary} style={styles.resultCount}>
{filteredPrograms.length} programme{filteredPrograms.length !== 1 ? 's' : ''} {LEVEL_META[selectedLevel].label.toLowerCase()}
</StyledText>
{/* Program List */}
{filteredPrograms.map((program) => (
<ProgramCard
key={program.id}
program={program}
accentColor={accentColor}
onPress={() => handleProgramPress(program)}
isPremium={isPremium}
isCompleted={isProgramCompleted(program.id)}
/>
))}
{filteredPrograms.length === 0 && (
<View style={styles.emptyState}>
<Icon name="dumbbell" size={32} tintColor={colors.text.tertiary} />
<StyledText preset="CALLOUT" color={colors.text.tertiary} style={{ marginTop: SPACING[3], textAlign: 'center' }}>
Aucun programme disponible pour ce niveau
</StyledText>
</View>
)}
</ScrollView>
</View>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// PROGRAM CARD (full-width)
// ═══════════════════════════════════════════════════════════════════════════
function ProgramCard({
program,
accentColor,
onPress,
isPremium,
isCompleted,
}: {
program: WorkoutProgram
accentColor: string
onPress: () => void
isPremium: boolean
isCompleted: boolean
}) {
const { t } = useTranslation('screens')
const colors = useThemeColors()
const isLocked = !canAccessWorkoutProgram(program, isPremium)
const levelMeta = LEVEL_META[program.level]
const color = program.accentColor ?? accentColor
return (
<Pressable
onPress={onPress}
style={({ pressed }) => [
{
borderRadius: RADIUS.XL,
overflow: 'hidden',
borderWidth: 1,
borderCurve: 'continuous' as const,
borderColor: colors.border.dim,
backgroundColor: colors.surface.default.backgroundColor,
marginBottom: SPACING[3],
opacity: pressed ? 0.85 : 1,
},
]}
>
{/* Accent line */}
<View style={{ height: 3, width: '100%', backgroundColor: color }} />
<View style={{ padding: SPACING[5] }}>
{/* Title row */}
<View style={{ flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between' }}>
<StyledText preset="TITLE_3" color={colors.text.primary} style={{ flex: 1, marginRight: SPACING[3] }}>
{program.title}
</StyledText>
{isCompleted ? (
<View style={{ flexDirection: 'row', alignItems: 'center', paddingHorizontal: SPACING[3], paddingVertical: SPACING[1], borderRadius: RADIUS.PILL, backgroundColor: GREEN['500'] + '20' }}>
<Icon name="checkmark" size={12} tintColor={GREEN['500']} />
<StyledText size={11} weight="semibold" color={GREEN['500']} style={{ marginLeft: 4 }}>
Complété
</StyledText>
</View>
) : isLocked ? (
<View style={{ flexDirection: 'row', alignItems: 'center', paddingHorizontal: SPACING[3], paddingVertical: SPACING[1], borderRadius: RADIUS.PILL, backgroundColor: color + '15' }}>
<Icon name="lock" size={12} tintColor={color} />
<StyledText size={11} weight="semibold" color={color} style={{ marginLeft: 4 }}>
{t('home.premiumBadge')}
</StyledText>
</View>
) : (
<View style={{ flexDirection: 'row', alignItems: 'center', paddingHorizontal: SPACING[3], paddingVertical: SPACING[1], borderRadius: RADIUS.PILL, backgroundColor: GREEN.DIM }}>
<StyledText size={11} weight="semibold" color={GREEN['500']}>
{t('home.freeBadge')}
</StyledText>
</View>
)}
</View>
{/* Description */}
{program.description ? (
<StyledText size={13} color={colors.text.tertiary} style={{ marginTop: SPACING[2] }} numberOfLines={2}>
{program.description}
</StyledText>
) : null}
{/* Meta row */}
<View style={{ flexDirection: 'row', alignItems: 'center', gap: SPACING[4], marginTop: SPACING[4] }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: SPACING[1] }}>
<Icon name="timer" size={14} tintColor={colors.text.tertiary} />
<StyledText size={12} color={colors.text.tertiary}>{program.estimatedDuration} min</StyledText>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: SPACING[1] }}>
<Icon name="flame" size={14} tintColor={colors.text.tertiary} />
<StyledText size={12} color={colors.text.tertiary}>{program.estimatedCalories} kcal</StyledText>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: SPACING[1] }}>
<Icon name="list.bullet" size={14} tintColor={colors.text.tertiary} />
<StyledText size={12} color={colors.text.tertiary}>{program.tabatas.length} tabatas</StyledText>
</View>
</View>
{/* CTA */}
<View style={{ marginTop: SPACING[4], alignSelf: 'flex-start', flexDirection: 'row', alignItems: 'center', paddingHorizontal: SPACING[4], paddingVertical: SPACING[2], borderRadius: RADIUS.PILL, backgroundColor: isLocked ? color + '15' : GREEN.DIM }}>
<Icon name={isLocked ? 'lock' : 'play.fill'} size={12} tintColor={isLocked ? color : GREEN['500']} />
<StyledText size={13} weight="semibold" color={isLocked ? color : GREEN['500']} style={{ marginLeft: SPACING[2] }}>
{isLocked ? t('home.unlockPremium') : t('home.startProgram')}
</StyledText>
</View>
</View>
</Pressable>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// STYLES
// ═══════════════════════════════════════════════════════════════════════════
const createStyles = (colors: ThemeColors, accentColor: string) =>
StyleSheet.create({
container: {
flex: 1,
backgroundColor: NAVY[900],
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingHorizontal: LAYOUT.SCREEN_PADDING,
},
// Difficulty pills
pillsRow: {
flexDirection: 'row',
gap: SPACING[2],
marginBottom: SPACING[5],
},
pill: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: SPACING[3],
borderRadius: RADIUS.PILL,
borderWidth: 1,
borderCurve: 'continuous',
},
// Results
resultCount: {
marginBottom: SPACING[4],
},
// Empty state
emptyState: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: SPACING[10],
},
})

View File

@@ -3,11 +3,9 @@
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
### Feb 20, 2026
### Apr 11, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #5541 | 11:52 PM | 🔄 | Converted 4 detail screens to use theme system | ~264 |
| #5291 | 2:56 PM | 🔵 | Category detail screen implementation examined | ~305 |
| #5230 | 1:25 PM | 🟣 | Implemented category and collection detail screens with Inter font loading | ~481 |
| #6114 | 7:39 PM | 🔵 | Category detail screen imports reviewed | ~298 |
</claude-mem-context>

View File

@@ -8,10 +8,6 @@ import { View, StyleSheet, ScrollView, Pressable, Text as RNText } from 'react-n
import { useRouter, useLocalSearchParams } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { Icon } from '@/src/shared/components/Icon'
import {
Host,
Picker,
} from '@expo/ui/swift-ui'
import { useTranslation } from 'react-i18next'
@@ -26,7 +22,7 @@ import { useThemeColors, BRAND } from '@/src/shared/theme'
import type { ThemeColors } from '@/src/shared/theme/types'
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { TEXT } from '@/src/shared/constants/colors'
import { TEXT, GREEN } from '@/src/shared/constants/colors'
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
const LEVEL_IDS: (WorkoutLevel | 'all')[] = ['all', 'Beginner', 'Intermediate', 'Advanced']
@@ -89,20 +85,24 @@ export default function CategoryDetailScreen() {
<View style={styles.backButton} />
</View>
{/* Level Filter */}
{/* Level Filter — segmented pills */}
<View style={styles.filterContainer}>
<Host matchContents useViewportSizeMeasurement colorScheme={colors.colorScheme}>
<Picker
selectedIndex={selectedLevelIndex}
onOptionSelected={(e) => {
haptics.selection()
setSelectedLevelIndex(e.nativeEvent.index)
}}
variant="segmented"
options={levelLabels}
color={BRAND.PRIMARY}
/>
</Host>
<View style={styles.segmentedRow}>
{levelLabels.map((label, idx) => (
<Pressable
key={idx}
style={[styles.segment, idx === selectedLevelIndex && styles.segmentActive]}
onPress={() => {
haptics.selection()
setSelectedLevelIndex(idx)
}}
>
<RNText style={[styles.segmentText, idx === selectedLevelIndex && styles.segmentTextActive]}>
{label}
</RNText>
</Pressable>
))}
</View>
</View>
<StyledText
@@ -175,6 +175,33 @@ function createStyles(colors: ThemeColors) {
paddingHorizontal: LAYOUT.SCREEN_PADDING,
marginBottom: SPACING[4],
},
segmentedRow: {
flexDirection: 'row',
gap: SPACING[2],
},
segment: {
flex: 1,
paddingVertical: SPACING[2],
paddingHorizontal: SPACING[3],
borderRadius: RADIUS.MD,
backgroundColor: colors.bg.surface,
borderWidth: 1,
borderColor: colors.border.dim,
alignItems: 'center',
},
segmentActive: {
backgroundColor: GREEN.DIM,
borderColor: GREEN.BORDER,
},
segmentText: {
fontSize: 13,
fontWeight: '500',
color: TEXT.TERTIARY,
},
segmentTextActive: {
color: BRAND.PRIMARY,
fontWeight: '600',
},
scrollView: {
flex: 1,
},