feat: migrate icons to SF Symbols, refactor explore tab, add collections/programs data layer

- Replace all Ionicons with native SF Symbols via expo-symbols SymbolView
- Create reusable Icon wrapper component (src/shared/components/Icon.tsx)
- Remove @expo/vector-icons and lucide-react dependencies
- Refactor explore tab with filters, search, and category browsing
- Add collections and programs data with Supabase integration
- Add explore filter store and filter sheet
- Update i18n strings (en, de, es, fr) for new explore features
- Update test mocks and remove stale snapshots
- Add user fitness level to user store and types
This commit is contained in:
Millian Lamiaux
2026-03-25 23:28:51 +01:00
parent f11eb6b9ae
commit b833198e9d
42 changed files with 2006 additions and 1594 deletions

View File

@@ -1,190 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`DataDeletionModal > full modal structure snapshot 1`] = `
<Modal
animationType="fade"
onRequestClose={[MockFunction]}
ref={null}
transparent={true}
visible={true}
>
<View
ref={null}
style={
[
{
"alignItems": "center",
"flex": 1,
"justifyContent": "center",
"padding": 16,
},
{
"backgroundColor": "rgba(0,0,0,0.8)",
},
]
}
>
<View
ref={null}
style={
[
{
"alignItems": "center",
"borderRadius": 16,
"maxWidth": 360,
"padding": 24,
"width": "100%",
},
{
"backgroundColor": "#1C1C1E",
},
]
}
>
<View
ref={null}
style={
[
{
"alignItems": "center",
"borderRadius": 40,
"height": 80,
"justifyContent": "center",
"marginBottom": 16,
"width": 80,
},
{
"backgroundColor": "rgba(255, 59, 48, 0.1)",
},
]
}
>
<Ionicons
color="#FF3B30"
name="warning"
size={40}
testID="icon-warning"
/>
</View>
<Text
ref={null}
style={
[
{
"color": "#FFFFFF",
"fontSize": 22,
"fontWeight": "700",
},
{
"marginBottom": 16,
"textAlign": "center",
},
]
}
>
dataDeletion.title
</Text>
<Text
ref={null}
style={
[
{
"color": "#8E8E93",
"fontSize": 15,
"fontWeight": "400",
},
{
"lineHeight": 22,
"marginBottom": 12,
"textAlign": "center",
},
]
}
>
dataDeletion.description
</Text>
<Text
ref={null}
style={
[
{
"color": "#636366",
"fontSize": 14,
"fontWeight": "400",
},
{
"lineHeight": 20,
"marginBottom": 24,
"textAlign": "center",
},
]
}
>
dataDeletion.note
</Text>
<Pressable
disabled={false}
onClick={[Function]}
onPress={[Function]}
ref={null}
style={
[
{
"alignItems": "center",
"backgroundColor": "#FF3B30",
"borderRadius": 14,
"height": 52,
"justifyContent": "center",
"marginBottom": 12,
"width": "100%",
},
false,
]
}
>
<Text
ref={null}
style={
[
{
"color": "#FFFFFF",
"fontSize": 17,
"fontWeight": "600",
},
undefined,
]
}
>
dataDeletion.deleteButton
</Text>
</Pressable>
<Pressable
onClick={[MockFunction]}
onPress={[MockFunction]}
ref={null}
style={
{
"padding": 12,
}
}
>
<Text
ref={null}
style={
[
{
"color": "#8E8E93",
"fontSize": 15,
"fontWeight": "400",
},
undefined,
]
}
>
dataDeletion.cancelButton
</Text>
</Pressable>
</View>
</View>
</Modal>
`;

View File

@@ -1,318 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`SyncConsentModal > full modal structure snapshot 1`] = `
<Modal
animationType="fade"
onRequestClose={[MockFunction]}
ref={null}
transparent={true}
visible={true}
>
<View
ref={null}
style={
[
{
"alignItems": "center",
"flex": 1,
"justifyContent": "center",
"padding": 16,
},
{
"backgroundColor": "rgba(0,0,0,0.8)",
},
]
}
>
<View
ref={null}
style={
[
{
"alignItems": "center",
"borderRadius": 16,
"maxWidth": 360,
"padding": 24,
"width": "100%",
},
{
"backgroundColor": "#1C1C1E",
},
]
}
>
<View
ref={null}
style={
{
"alignItems": "center",
"backgroundColor": "rgba(255, 107, 53, 0.1)",
"borderRadius": 40,
"height": 80,
"justifyContent": "center",
"marginBottom": 16,
"width": 80,
}
}
>
<Ionicons
color="#FF6B35"
name="sparkles"
size={40}
testID="icon-sparkles"
/>
</View>
<Text
ref={null}
style={
[
{
"color": "#FFFFFF",
"fontSize": 24,
"fontWeight": "700",
},
{
"marginBottom": 24,
"textAlign": "center",
},
]
}
>
sync.title
</Text>
<View
ref={null}
style={
{
"gap": 12,
"marginBottom": 24,
"width": "100%",
}
}
>
<View
ref={null}
style={
{
"alignItems": "center",
"flexDirection": "row",
"gap": 12,
}
}
>
<Ionicons
color="#FF6B35"
name="trending-up"
size={22}
testID="icon-trending-up"
/>
<Text
ref={null}
style={
[
{
"color": "#8E8E93",
"fontSize": 15,
"fontWeight": "400",
},
{
"flex": 1,
"lineHeight": 22,
},
]
}
>
sync.benefits.recommendations
</Text>
</View>
<View
ref={null}
style={
{
"alignItems": "center",
"flexDirection": "row",
"gap": 12,
}
}
>
<Ionicons
color="#FF6B35"
name="fitness"
size={22}
testID="icon-fitness"
/>
<Text
ref={null}
style={
[
{
"color": "#8E8E93",
"fontSize": 15,
"fontWeight": "400",
},
{
"flex": 1,
"lineHeight": 22,
},
]
}
>
sync.benefits.adaptive
</Text>
</View>
<View
ref={null}
style={
{
"alignItems": "center",
"flexDirection": "row",
"gap": 12,
}
}
>
<Ionicons
color="#FF6B35"
name="sync"
size={22}
testID="icon-sync"
/>
<Text
ref={null}
style={
[
{
"color": "#8E8E93",
"fontSize": 15,
"fontWeight": "400",
},
{
"flex": 1,
"lineHeight": 22,
},
]
}
>
sync.benefits.sync
</Text>
</View>
<View
ref={null}
style={
{
"alignItems": "center",
"flexDirection": "row",
"gap": 12,
}
}
>
<Ionicons
color="#FF6B35"
name="shield-checkmark"
size={22}
testID="icon-shield-checkmark"
/>
<Text
ref={null}
style={
[
{
"color": "#8E8E93",
"fontSize": 15,
"fontWeight": "400",
},
{
"flex": 1,
"lineHeight": 22,
},
]
}
>
sync.benefits.secure
</Text>
</View>
</View>
<Text
ref={null}
style={
[
{
"color": "#636366",
"fontSize": 13,
"fontWeight": "400",
},
{
"lineHeight": 20,
"marginBottom": 24,
"textAlign": "center",
},
]
}
>
sync.privacy
</Text>
<Pressable
disabled={false}
onClick={[Function]}
onPress={[Function]}
ref={null}
style={
[
{
"alignItems": "center",
"backgroundColor": "#FF6B35",
"borderRadius": 14,
"height": 52,
"justifyContent": "center",
"marginBottom": 12,
"width": "100%",
},
false,
]
}
>
<Text
ref={null}
style={
[
{
"color": "#FFFFFF",
"fontSize": 17,
"fontWeight": "600",
},
undefined,
]
}
>
sync.primaryButton
</Text>
</Pressable>
<Pressable
onClick={[MockFunction]}
onPress={[MockFunction]}
ref={null}
style={
{
"padding": 12,
}
}
>
<Text
ref={null}
style={
[
{
"color": "#8E8E93",
"fontSize": 15,
"fontWeight": "400",
},
undefined,
]
}
>
sync.secondaryButton
</Text>
</Pressable>
</View>
</View>
</Modal>
`;

View File

@@ -1,133 +0,0 @@
import { describe, it, expect } from 'vitest'
import { COLLECTIONS, FEATURED_COLLECTION_ID } from '../../shared/data/collections'
describe('collections data', () => {
describe('COLLECTIONS structure', () => {
it('should have exactly 6 collections', () => {
expect(COLLECTIONS).toHaveLength(6)
})
it('should have all required properties', () => {
COLLECTIONS.forEach(collection => {
expect(collection.id).toBeDefined()
expect(collection.title).toBeDefined()
expect(collection.description).toBeDefined()
expect(collection.icon).toBeDefined()
expect(collection.workoutIds).toBeDefined()
expect(Array.isArray(collection.workoutIds)).toBe(true)
})
})
it('should have unique collection IDs', () => {
const ids = COLLECTIONS.map(c => c.id)
const uniqueIds = new Set(ids)
expect(uniqueIds.size).toBe(ids.length)
})
it('should have unique collection titles', () => {
const titles = COLLECTIONS.map(c => c.title)
const uniqueTitles = new Set(titles)
expect(uniqueTitles.size).toBe(titles.length)
})
it('should have at least one workout per collection', () => {
COLLECTIONS.forEach(collection => {
expect(collection.workoutIds.length).toBeGreaterThan(0)
})
})
})
describe('specific collections', () => {
it('should have Morning Energizer collection', () => {
const collection = COLLECTIONS.find(c => c.id === 'morning-energizer')
expect(collection).toBeDefined()
expect(collection!.title).toBe('Morning Energizer')
expect(collection!.icon).toBe('🌅')
expect(collection!.workoutIds).toHaveLength(5)
})
it('should have No Equipment collection', () => {
const collection = COLLECTIONS.find(c => c.id === 'no-equipment')
expect(collection).toBeDefined()
expect(collection!.title).toBe('No Equipment')
expect(collection!.workoutIds.length).toBeGreaterThan(10)
})
it('should have 7-Day Burn Challenge collection', () => {
const collection = COLLECTIONS.find(c => c.id === '7-day-burn')
expect(collection).toBeDefined()
expect(collection!.title).toBe('7-Day Burn Challenge')
expect(collection!.workoutIds).toHaveLength(7)
expect(collection!.gradient).toBeDefined()
})
it('should have Quick & Intense collection', () => {
const collection = COLLECTIONS.find(c => c.id === 'quick-intense')
expect(collection).toBeDefined()
expect(collection!.title).toBe('Quick & Intense')
expect(collection!.workoutIds.length).toBeGreaterThan(5)
})
it('should have Core Focus collection', () => {
const collection = COLLECTIONS.find(c => c.id === 'core-focus')
expect(collection).toBeDefined()
expect(collection!.title).toBe('Core Focus')
expect(collection!.workoutIds).toHaveLength(6)
})
it('should have Leg Day collection', () => {
const collection = COLLECTIONS.find(c => c.id === 'leg-day')
expect(collection).toBeDefined()
expect(collection!.title).toBe('Leg Day')
expect(collection!.workoutIds).toHaveLength(7)
})
})
describe('FEATURED_COLLECTION_ID', () => {
it('should reference 7-day-burn', () => {
expect(FEATURED_COLLECTION_ID).toBe('7-day-burn')
})
it('should reference an existing collection', () => {
const featured = COLLECTIONS.find(c => c.id === FEATURED_COLLECTION_ID)
expect(featured).toBeDefined()
})
})
describe('collection gradients', () => {
it('should have gradient on 7-day-burn', () => {
const collection = COLLECTIONS.find(c => c.id === '7-day-burn')
expect(collection!.gradient).toBeDefined()
expect(collection!.gradient).toHaveLength(2)
})
it('should have valid hex colors in gradient', () => {
const hexPattern = /^#[0-9A-Fa-f]{6}$/
const collection = COLLECTIONS.find(c => c.gradient)
if (collection?.gradient) {
collection.gradient.forEach(color => {
expect(color).toMatch(hexPattern)
})
}
})
})
describe('workout ID format', () => {
it('should have string workout IDs', () => {
COLLECTIONS.forEach(collection => {
collection.workoutIds.forEach(id => {
expect(typeof id).toBe('string')
})
})
})
it('should have numeric-like workout IDs', () => {
const numericPattern = /^\d+$/
COLLECTIONS.forEach(collection => {
collection.workoutIds.forEach(id => {
expect(id).toMatch(numericPattern)
})
})
})
})
})

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { dataService } from '../../shared/data/dataService'
import { WORKOUTS, TRAINERS, COLLECTIONS, PROGRAMS, ACHIEVEMENTS } from '../../shared/data'
import type { Workout, Trainer, Collection, Program, Achievement } from '../../shared/types'
import { WORKOUTS, TRAINERS, PROGRAMS, ACHIEVEMENTS } from '../../shared/data'
import type { Workout, Trainer, Program, Achievement } from '../../shared/types'
vi.mock('../../shared/supabase', () => ({
isSupabaseConfigured: vi.fn(() => false),
@@ -130,30 +130,18 @@ describe('dataService', () => {
})
describe('getAllCollections', () => {
it('should return all collections', async () => {
it('should return empty array when Supabase not configured', async () => {
const collections = await dataService.getAllCollections()
expect(collections).toEqual(COLLECTIONS)
})
it('should return collections with required properties', async () => {
const collections = await dataService.getAllCollections()
collections.forEach((collection: Collection) => {
expect(collection.id).toBeDefined()
expect(collection.title).toBeDefined()
expect(collection.workoutIds).toBeDefined()
expect(Array.isArray(collection.workoutIds)).toBe(true)
})
expect(collections).toEqual([])
})
})
describe('getCollectionById', () => {
it('should return collection by id', async () => {
it('should return undefined when Supabase not configured', async () => {
const collection = await dataService.getCollectionById('morning-energizer')
expect(collection).toBeDefined()
expect(collection?.id).toBe('morning-energizer')
expect(collection).toBeUndefined()
})
it('should return undefined for non-existent collection', async () => {

View File

@@ -82,6 +82,7 @@ describe('usePurchases', () => {
barriers: [],
syncStatus: 'never-synced',
supabaseUserId: null,
savedWorkouts: [],
},
})
})

View File

@@ -51,15 +51,9 @@ vi.mock('expo-video', () => ({
},
}))
vi.mock('@expo/vector-icons', () => ({
Ionicons: ({ name, size, color, style }: any) => {
return React.createElement('Ionicons', { name, size, color, style, testID: `icon-${name}` })
},
FontAwesome: ({ name, size, color, style }: any) => {
return React.createElement('FontAwesome', { name, size, color, style, testID: `icon-${name}` })
},
MaterialIcons: ({ name, size, color, style }: any) => {
return React.createElement('MaterialIcons', { name, size, color, style, testID: `icon-${name}` })
vi.mock('expo-symbols', () => ({
SymbolView: ({ name, size, tintColor, style, weight, type }: any) => {
return React.createElement('SymbolView', { name, size, tintColor, style, weight, type, testID: `icon-${name}` })
},
}))

View File

@@ -17,6 +17,7 @@ describe('userStore', () => {
barriers: [],
syncStatus: 'never-synced',
supabaseUserId: null,
savedWorkouts: [],
},
settings: {
haptics: true,

View File

@@ -1,15 +1,17 @@
/**
* CollectionCard - Premium collection card with glassmorphism
* Used in Home and Browse screens
* Used in Explore and Browse screens
* Supports 'default' and 'hero' variants
*/
import { useMemo } from 'react'
import { useMemo, useRef, useCallback } from 'react'
import {
View,
StyleSheet,
Pressable,
Animated,
ImageBackground,
Dimensions,
useWindowDimensions,
Text as RNText,
} from 'react-native'
import { LinearGradient } from 'expo-linear-gradient'
@@ -18,24 +20,55 @@ import { BlurView } from 'expo-blur'
import { useThemeColors, BRAND, GRADIENTS } from '@/src/shared/theme'
import type { ThemeColors } from '@/src/shared/theme/types'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { SPACING } from '@/src/shared/constants/spacing'
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
import { StyledText } from './StyledText'
import type { Collection } from '@/src/shared/types'
const { width: SCREEN_WIDTH } = Dimensions.get('window')
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
export type CollectionCardVariant = 'default' | 'hero' | 'horizontal'
interface CollectionCardProps {
collection: Collection
variant?: CollectionCardVariant
onPress?: () => void
imageUrl?: string
workoutCountLabel?: string
}
export function CollectionCard({ collection, onPress, imageUrl }: CollectionCardProps) {
export function CollectionCard({ collection, variant = 'default', onPress, imageUrl, workoutCountLabel }: CollectionCardProps) {
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
const { width: screenWidth } = useWindowDimensions()
const styles = useMemo(() => createStyles(colors, screenWidth, variant), [colors, screenWidth, variant])
// Press animation
const scaleValue = useRef(new Animated.Value(1)).current
const handlePressIn = useCallback(() => {
Animated.spring(scaleValue, {
toValue: 0.97,
useNativeDriver: true,
speed: 50,
bounciness: 4,
}).start()
}, [scaleValue])
const handlePressOut = useCallback(() => {
Animated.spring(scaleValue, {
toValue: 1,
useNativeDriver: true,
speed: 30,
bounciness: 6,
}).start()
}, [scaleValue])
const countLabel = workoutCountLabel ?? `${collection.workoutIds.length} workouts`
return (
<Pressable style={styles.container} onPress={onPress}>
<AnimatedPressable
style={[styles.container, { transform: [{ scale: scaleValue }] }]}
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
>
{/* Background Image or Gradient */}
{imageUrl ? (
<ImageBackground
@@ -70,7 +103,7 @@ export function CollectionCard({ collection, onPress, imageUrl }: CollectionCard
</View>
<StyledText
size={17}
size={variant === 'hero' ? 22 : 17}
weight="bold"
color="#FFFFFF"
numberOfLines={2}
@@ -79,26 +112,52 @@ export function CollectionCard({ collection, onPress, imageUrl }: CollectionCard
{collection.title}
</StyledText>
{variant === 'hero' && (
<StyledText
size={14}
color="rgba(255,255,255,0.8)"
numberOfLines={2}
style={{ marginBottom: SPACING[1] }}
>
{collection.description}
</StyledText>
)}
<StyledText
size={13}
weight="medium"
color="rgba(255,255,255,0.7)"
numberOfLines={1}
>
{collection.workoutIds.length} workouts
{countLabel}
</StyledText>
</View>
</Pressable>
</AnimatedPressable>
)
}
function createStyles(colors: ThemeColors) {
const cardWidth = (SCREEN_WIDTH - SPACING[6] * 2 - SPACING[3]) / 2
function createStyles(colors: ThemeColors, screenWidth: number, variant: CollectionCardVariant) {
const defaultCardWidth = (screenWidth - LAYOUT.SCREEN_PADDING * 2 - SPACING[3]) / 2
const horizontalCardWidth = screenWidth * 0.65
const containerByVariant = {
default: {
width: defaultCardWidth,
aspectRatio: 1 as number,
},
hero: {
width: screenWidth - LAYOUT.SCREEN_PADDING * 2,
height: 200,
},
horizontal: {
width: horizontalCardWidth,
height: 180,
},
}
return StyleSheet.create({
container: {
width: cardWidth,
aspectRatio: 1,
...containerByVariant[variant],
borderRadius: RADIUS.XL,
overflow: 'hidden',
...colors.shadow.md,

View File

@@ -10,7 +10,7 @@ import { useThemeColors } from '@/src/shared/theme'
import { StyledText } from '@/src/shared/components/StyledText'
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { Ionicons } from '@expo/vector-icons'
import { Icon } from '@/src/shared/components/Icon'
interface DataDeletionModalProps {
visible: boolean
@@ -51,7 +51,7 @@ export function DataDeletionModal({
{ backgroundColor: 'rgba(255, 59, 48, 0.1)' },
]}
>
<Ionicons name="warning" size={40} color="#FF3B30" />
<Icon name="exclamationmark.triangle.fill" size={40} tintColor="#FF3B30" />
</View>
{/* Title */}

View File

@@ -0,0 +1,52 @@
/**
* Icon component — wraps expo-symbols SymbolView for SF Symbols
* Drop-in replacement for Ionicons across the app
*/
import { SymbolView, type SymbolViewProps } from 'expo-symbols'
import type { SFSymbol } from 'sf-symbols-typescript'
import type { ColorValue, ViewStyle, StyleProp } from 'react-native'
export type IconName = SFSymbol
export type IconProps = {
/** SF Symbol name (e.g. 'flame.fill', 'play.fill') */
name: IconName
/** Size in points */
size?: number
/** Tint color applied to the symbol */
tintColor?: ColorValue
/** Alias for tintColor (Ionicons compat) */
color?: ColorValue
/** Symbol weight */
weight?: SymbolViewProps['weight']
/** Symbol rendering type */
type?: SymbolViewProps['type']
/** Animation configuration */
animationSpec?: SymbolViewProps['animationSpec']
/** View style (margin, position, etc.) */
style?: StyleProp<ViewStyle>
}
export function Icon({
name,
size = 24,
tintColor,
color,
weight,
type = 'monochrome',
animationSpec,
style,
}: IconProps) {
return (
<SymbolView
name={name}
size={size}
tintColor={tintColor ?? color}
weight={weight}
type={type}
animationSpec={animationSpec}
style={style}
/>
)
}

View File

@@ -4,8 +4,9 @@
*/
import { useRef, useEffect, useMemo } from 'react'
import { View, StyleSheet, Animated, Dimensions } from 'react-native'
import { View, StyleSheet, Animated, Dimensions, Pressable } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { Icon } from './Icon'
import { useThemeColors, BRAND } from '../theme'
import type { ThemeColors } from '../theme/types'
import { SPACING, LAYOUT } from '../constants/spacing'
@@ -17,9 +18,10 @@ interface OnboardingStepProps {
step: number
totalSteps: number
children: React.ReactNode
onBack?: () => void
}
export function OnboardingStep({ step, totalSteps, children }: OnboardingStepProps) {
export function OnboardingStep({ step, totalSteps, children, onBack }: OnboardingStepProps) {
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
const insets = useSafeAreaInsets()
@@ -69,6 +71,18 @@ export function OnboardingStep({ step, totalSteps, children }: OnboardingStepPro
<Animated.View style={[styles.progressFill, { width: progressWidth }]} />
</View>
{/* Back button — visible on steps 2+ */}
{onBack && step > 1 && (
<Pressable
style={styles.backButton}
onPress={onBack}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
testID="onboarding-back-button"
>
<Icon name="chevron.left" size={24} tintColor={colors.text.secondary} />
</Pressable>
)}
{/* Step content */}
<Animated.View
style={[
@@ -104,6 +118,14 @@ function createStyles(colors: ThemeColors) {
backgroundColor: BRAND.PRIMARY,
borderRadius: 2,
},
backButton: {
marginTop: SPACING[3],
marginLeft: SPACING[3],
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
content: {
flex: 1,
paddingHorizontal: LAYOUT.SCREEN_PADDING,

View File

@@ -12,7 +12,7 @@ import { StyledText } from '@/src/shared/components/StyledText'
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { BRAND } from '@/src/shared/constants/colors'
import { Ionicons } from '@expo/vector-icons'
import { Icon, type IconName } from '@/src/shared/components/Icon'
interface SyncConsentModalProps {
visible: boolean
@@ -48,7 +48,7 @@ export function SyncConsentModal({
>
{/* Icon */}
<View style={styles.iconContainer}>
<Ionicons name="sparkles" size={40} color={BRAND.PRIMARY} />
<Icon name="sparkles" size={40} tintColor={BRAND.PRIMARY} />
</View>
{/* Title */}
@@ -64,22 +64,22 @@ export function SyncConsentModal({
{/* Benefits */}
<View style={styles.benefits}>
<BenefitRow
icon="trending-up"
icon="arrow.up.right"
text={t('sync.benefits.recommendations')}
colors={colors}
/>
<BenefitRow
icon="fitness"
icon="figure.run"
text={t('sync.benefits.adaptive')}
colors={colors}
/>
<BenefitRow
icon="sync"
icon="arrow.triangle.2.circlepath"
text={t('sync.benefits.sync')}
colors={colors}
/>
<BenefitRow
icon="shield-checkmark"
icon="checkmark.shield.fill"
text={t('sync.benefits.secure')}
colors={colors}
/>
@@ -121,16 +121,16 @@ function BenefitRow({
text,
colors,
}: {
icon: string
icon: IconName
text: string
colors: any
}) {
return (
<View style={styles.benefitRow}>
<Ionicons
name={icon as any}
<Icon
name={icon}
size={22}
color={BRAND.PRIMARY}
tintColor={BRAND.PRIMARY}
/>
<StyledText
size={15}

View File

@@ -3,19 +3,20 @@
* Used in Home and Browse screens
*/
import { useMemo } from 'react'
import { useMemo, useRef, useCallback } from 'react'
import {
View,
StyleSheet,
Pressable,
Animated,
ImageBackground,
Dimensions,
useWindowDimensions,
Text as RNText,
ViewStyle,
} from 'react-native'
import { LinearGradient } from 'expo-linear-gradient'
import { BlurView } from 'expo-blur'
import Ionicons from '@expo/vector-icons/Ionicons'
import { Icon } from './Icon'
import { useThemeColors, BRAND, GRADIENTS } from '@/src/shared/theme'
import type { ThemeColors } from '@/src/shared/theme/types'
@@ -24,7 +25,7 @@ import { SPACING } from '@/src/shared/constants/spacing'
import { StyledText } from './StyledText'
import type { Workout, WorkoutCategory } from '@/src/shared/types'
const { width: SCREEN_WIDTH } = Dimensions.get('window')
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
export type WorkoutCardVariant = 'horizontal' | 'grid' | 'featured'
@@ -34,9 +35,11 @@ interface WorkoutCardProps {
onPress?: () => void
title?: string
metadata?: string
trainerName?: string
isLocked?: boolean
}
const CATEGORY_COLORS: Record<WorkoutCategory, string> = {
export const CATEGORY_COLORS: Record<WorkoutCategory, string> = {
'full-body': BRAND.PRIMARY,
'core': '#5AC8FA',
'upper-body': '#BF5AF2',
@@ -52,11 +55,11 @@ const CATEGORY_LABELS: Record<WorkoutCategory, string> = {
'cardio': 'Cardio',
}
function getVariantDimensions(variant: WorkoutCardVariant): ViewStyle {
function getVariantDimensions(variant: WorkoutCardVariant, screenWidth: number): ViewStyle {
switch (variant) {
case 'featured':
return {
width: SCREEN_WIDTH - SPACING[6] * 2,
width: screenWidth - SPACING[6] * 2,
height: 320,
}
case 'horizontal':
@@ -79,19 +82,48 @@ export function WorkoutCard({
onPress,
title,
metadata,
trainerName,
isLocked,
}: WorkoutCardProps) {
const colors = useThemeColors()
const { width: screenWidth } = useWindowDimensions()
const styles = useMemo(() => createStyles(colors), [colors])
const dimensions = useMemo(() => getVariantDimensions(variant), [variant])
const dimensions = useMemo(() => getVariantDimensions(variant, screenWidth), [variant, screenWidth])
// Press animation
const scaleValue = useRef(new Animated.Value(1)).current
const handlePressIn = useCallback(() => {
Animated.spring(scaleValue, {
toValue: 0.96,
useNativeDriver: true,
speed: 50,
bounciness: 4,
}).start()
}, [scaleValue])
const handlePressOut = useCallback(() => {
Animated.spring(scaleValue, {
toValue: 1,
useNativeDriver: true,
speed: 30,
bounciness: 6,
}).start()
}, [scaleValue])
const displayTitle = title ?? workout.title
const displayMetadata = metadata ?? `${workout.duration} min • ${workout.calories} cal`
const metaParts = [
`${workout.duration} min`,
`${workout.calories} cal`,
...(trainerName ? [trainerName] : []),
]
const displayMetadata = metadata ?? metaParts.join(' · ')
const categoryColor = CATEGORY_COLORS[workout.category]
return (
<Pressable
style={[styles.container, dimensions]}
<AnimatedPressable
style={[styles.container, dimensions, { transform: [{ scale: scaleValue }] }]}
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
>
{/* Background Image */}
<ImageBackground
@@ -119,7 +151,7 @@ export function WorkoutCard({
<View style={styles.playButtonContainer}>
<View style={styles.playButton}>
<BlurView intensity={colors.glass.blurMedium} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
<Ionicons name="play" size={24} color="#FFFFFF" style={{ marginLeft: 2 }} />
<Icon name={isLocked ? 'lock.fill' : 'play.fill'} size={24} tintColor="#FFFFFF" style={isLocked ? undefined : { marginLeft: 2 }} />
</View>
</View>
@@ -143,7 +175,7 @@ export function WorkoutCard({
{displayMetadata}
</StyledText>
</View>
</Pressable>
</AnimatedPressable>
)
}

View File

@@ -1,54 +1,7 @@
/**
* TabataFit Collections
* Legacy collections (keeping for reference during migration)
* Collections are fetched from Supabase at runtime.
* Seed data lives in supabase/seed.ts.
*/
import type { Collection } from '../types'
export const COLLECTIONS: Collection[] = [
{
id: 'morning-energizer',
title: 'Morning Energizer',
description: 'Start your day right',
icon: '🌅',
workoutIds: ['4', '6', '43', '47', '10'],
},
{
id: 'no-equipment',
title: 'No Equipment',
description: 'Workout anywhere',
icon: '💪',
workoutIds: ['1', '4', '6', '11', '13', '16', '17', '19', '23', '26', '31', '38', '42', '43', '45'],
},
{
id: '7-day-burn',
title: '7-Day Burn Challenge',
description: 'Transform in one week',
icon: '🔥',
workoutIds: ['1', '11', '31', '42', '6', '17', '23'],
gradient: ['#FF6B35', '#FF3B30'],
},
{
id: 'quick-intense',
title: 'Quick & Intense',
description: 'Max effort in 4 minutes',
icon: '⚡',
workoutIds: ['1', '11', '23', '35', '38', '42', '6', '17'],
},
{
id: 'core-focus',
title: 'Core Focus',
description: 'Build a solid foundation',
icon: '🎯',
workoutIds: ['11', '12', '13', '14', '16', '17'],
},
{
id: 'leg-day',
title: 'Leg Day',
description: 'Never skip leg day',
icon: '🦵',
workoutIds: ['31', '32', '33', '34', '35', '36', '37'],
},
]
export const FEATURED_COLLECTION_ID = '7-day-burn'

View File

@@ -1,5 +1,5 @@
import { supabase, isSupabaseConfigured } from '../supabase'
import { WORKOUTS, TRAINERS, COLLECTIONS, PROGRAMS, ACHIEVEMENTS } from './index'
import { WORKOUTS, TRAINERS, PROGRAMS, ACHIEVEMENTS } from './index'
import type { Workout, Trainer, Collection, Program, ProgramId } from '../types'
import type { Database } from '../supabase/database.types'
@@ -208,7 +208,7 @@ class SupabaseDataService {
async getAllCollections(): Promise<Collection[]> {
if (!isSupabaseConfigured()) {
return COLLECTIONS
return []
}
const { data: collectionsData, error: collectionsError } = await supabase
@@ -217,7 +217,7 @@ class SupabaseDataService {
if (collectionsError) {
console.error('Error fetching collections:', collectionsError)
return COLLECTIONS
return []
}
const { data: workoutLinks, error: linksError } = await supabase
@@ -227,7 +227,7 @@ class SupabaseDataService {
if (linksError) {
console.error('Error fetching collection workouts:', linksError)
return COLLECTIONS
return []
}
const workoutIdsByCollection: Record<string, string[]> = {}
@@ -240,12 +240,12 @@ class SupabaseDataService {
return collectionsData?.map((row: CollectionRow) =>
mapCollectionFromDB(row, workoutIdsByCollection[row.id] || [])
) ?? COLLECTIONS
) ?? []
}
async getCollectionById(id: string): Promise<Collection | undefined> {
if (!isSupabaseConfigured()) {
return COLLECTIONS.find((c: Collection) => c.id === id)
return undefined
}
const { data: collection, error: collectionError } = await supabase
@@ -256,7 +256,7 @@ class SupabaseDataService {
if (collectionError || !collection) {
console.error('Error fetching collection:', collectionError)
return COLLECTIONS.find((c: Collection) => c.id === id)
return undefined
}
const { data: workoutLinks, error: linksError } = await supabase
@@ -267,7 +267,7 @@ class SupabaseDataService {
if (linksError) {
console.error('Error fetching collection workouts:', linksError)
return COLLECTIONS.find((c: Collection) => c.id === id)
return undefined
}
const workoutIds = workoutLinks?.map((link: { workout_id: string }) => link.workout_id) || []

View File

@@ -75,4 +75,4 @@ export const CATEGORIES: { id: ProgramId | 'all'; label: string }[] = [
// Legacy exports for backward compatibility (to be removed)
export { WORKOUTS } from './workouts'
export { COLLECTIONS, FEATURED_COLLECTION_ID } from './collections'
export { FEATURED_COLLECTION_ID } from './collections'

View File

@@ -28,20 +28,33 @@
"allWorkouts": "Alle Workouts",
"trainers": "Trainer",
"noResults": "Keine Workouts gefunden",
"tryAdjustingFilters": "Versuchen Sie, Ihre Filter anzupassen",
"tryAdjustingFilters": "Versuchen Sie, Ihre Filter oder Suche anzupassen",
"loading": "Wird geladen...",
"filterCategory": "Kategorie",
"filterLevel": "Niveau",
"filterEquipment": "Ausrüstung",
"filterDuration": "Dauer",
"clearFilters": "Filter löschen",
"clearFilters": "Löschen",
"workoutsCount": "{{count}} Workouts",
"workouts": "Workouts",
"equipmentOptions": {
"none": "Ohne Ausrüstung",
"band": "Widerstandsband",
"dumbbells": "Hanteln",
"mat": "Matte"
}
},
"allEquipment": "Alle Ausrüstung",
"searchPlaceholder": "Workouts, Trainer suchen...",
"recommendedForYou": "Empfohlen für dich",
"tryNewCategory": "Probiere etwas Neues",
"startFirstWorkout": "Schließe dein erstes Workout ab für personalisierte Empfehlungen",
"filters": "Filter",
"activeFilters": "{{count}} aktiv",
"applyFilters": "Anwenden",
"resetFilters": "Zurücksetzen",
"errorTitle": "Workouts konnten nicht geladen werden",
"errorRetry": "Tippe zum Wiederholen",
"featuredCollection": "Empfohlene Sammlung"
},
"activity": {
@@ -58,7 +71,10 @@
"today": "Heute",
"yesterday": "Gestern",
"daysAgo": "vor {{count}} T.",
"achievements": "Erfolge"
"achievements": "Erfolge",
"emptyTitle": "Noch keine Aktivität",
"emptySubtitle": "Absolviere dein erstes Workout und deine Statistiken erscheinen hier.",
"startFirstWorkout": "Starte dein erstes Workout"
},
"browse": {
@@ -193,6 +209,29 @@
"unlockWithPremium": "MIT TABATAFIT+ FREISCHALTEN"
},
"paywall": {
"subtitle": "Schalte alle Funktionen frei und erreiche deine Ziele schneller",
"features": {
"music": "Premium-Musik",
"workouts": "Unbegrenzte Workouts",
"stats": "Erweiterte Statistiken",
"calories": "Kalorienverfolgung",
"reminders": "Tägliche Erinnerungen",
"ads": "Keine Werbung"
},
"yearly": "Jährlich",
"monthly": "Monatlich",
"perYear": "pro Jahr",
"perMonth": "pro Monat",
"save50": "50% SPAREN",
"equivalent": "Nur {{price}}/Monat",
"subscribe": "Jetzt Abonnieren",
"trialCta": "Kostenlos Testen",
"processing": "Verarbeitung...",
"restore": "Käufe Wiederherstellen",
"terms": "Die Zahlung wird bei Bestätigung deiner Apple-ID belastet. Das Abonnement verlängert sich automatisch, sofern es nicht mindestens 24 Stunden vor Ablauf des Zeitraums gekündigt wird. Verwalte es in den Kontoeinstellungen."
},
"onboarding": {
"problem": {
"title": "Du hast keine Stunde\nfürs Fitnessstudio.",
@@ -327,6 +366,10 @@
"startAssessment": "Bewertung starten",
"skipForNow": "Vorerst \u00fcberspringen",
"tips": "Tipps f\u00fcr beste Ergebnisse",
"tip1": "Bewegen Sie sich in Ihrem eigenen Tempo",
"tip2": "Achten Sie auf die Form, nicht auf die Geschwindigkeit",
"tip3": "Dies hilft uns, das beste Programm zu empfehlen",
"tip4": "Kein Urteil - nur ein Ausgangspunkt!",
"duration": "Dauer",
"exercises": "\u00dcbungen"
}

View File

@@ -28,20 +28,33 @@
"allWorkouts": "All Workouts",
"trainers": "Trainers",
"noResults": "No workouts found",
"tryAdjustingFilters": "Try adjusting your filters",
"tryAdjustingFilters": "Try adjusting your filters or search",
"loading": "Loading...",
"filterCategory": "Category",
"filterLevel": "Level",
"filterEquipment": "Equipment",
"filterDuration": "Duration",
"clearFilters": "Clear Filters",
"clearFilters": "Clear",
"workoutsCount": "{{count}} workouts",
"workouts": "Workouts",
"equipmentOptions": {
"none": "No Equipment",
"band": "Resistance Band",
"dumbbells": "Dumbbells",
"mat": "Mat"
}
},
"allEquipment": "All Equipment",
"searchPlaceholder": "Search workouts, trainers...",
"recommendedForYou": "Recommended for You",
"tryNewCategory": "Try something new",
"startFirstWorkout": "Start your first workout to get personalized recommendations",
"filters": "Filters",
"activeFilters": "{{count}} active",
"applyFilters": "Apply Filters",
"resetFilters": "Reset",
"errorTitle": "Couldn't load workouts",
"errorRetry": "Tap to retry",
"featuredCollection": "Featured Collection"
},
"activity": {
@@ -58,7 +71,10 @@
"today": "Today",
"yesterday": "Yesterday",
"daysAgo": "{{count}}d ago",
"achievements": "Achievements"
"achievements": "Achievements",
"emptyTitle": "No Activity Yet",
"emptySubtitle": "Complete your first workout and your stats will appear here.",
"startFirstWorkout": "Start Your First Workout"
},
"browse": {
@@ -210,6 +226,7 @@
"save50": "SAVE 50%",
"equivalent": "Just {{price}}/month",
"subscribe": "Subscribe Now",
"trialCta": "Start Free Trial",
"processing": "Processing...",
"restore": "Restore Purchases",
"terms": "Payment will be charged to your Apple ID at confirmation. Subscription auto-renews unless cancelled at least 24 hours before end of period. Manage in Account Settings."
@@ -386,6 +403,10 @@
"startAssessment": "Start Assessment",
"skipForNow": "Skip for now",
"tips": "Tips for best results",
"tip1": "Move at your own pace",
"tip2": "Focus on form, not speed",
"tip3": "This helps us recommend the best program",
"tip4": "No judgment - just a starting point!",
"duration": "Duration",
"exercises": "Exercises"
}

View File

@@ -28,20 +28,33 @@
"allWorkouts": "Todos los entrenos",
"trainers": "Entrenadores",
"noResults": "No se encontraron entrenos",
"tryAdjustingFilters": "Intenta ajustar tus filtros",
"tryAdjustingFilters": "Intenta ajustar tus filtros o búsqueda",
"loading": "Cargando...",
"filterCategory": "Categoría",
"filterLevel": "Nivel",
"filterEquipment": "Equipo",
"filterDuration": "Duración",
"clearFilters": "Borrar filtros",
"clearFilters": "Borrar",
"workoutsCount": "{{count}} entrenos",
"workouts": "Entrenos",
"equipmentOptions": {
"none": "Sin equipo",
"band": "Banda elástica",
"dumbbells": "Mancuernas",
"mat": "Colchoneta"
}
},
"allEquipment": "Todo el equipo",
"searchPlaceholder": "Buscar entrenos, entrenadores...",
"recommendedForYou": "Recomendado para ti",
"tryNewCategory": "Prueba algo nuevo",
"startFirstWorkout": "Completa tu primer entreno para recomendaciones personalizadas",
"filters": "Filtros",
"activeFilters": "{{count}} activos",
"applyFilters": "Aplicar",
"resetFilters": "Restablecer",
"errorTitle": "No se pudieron cargar los entrenos",
"errorRetry": "Toca para reintentar",
"featuredCollection": "Colección destacada"
},
"activity": {
@@ -58,7 +71,10 @@
"today": "Hoy",
"yesterday": "Ayer",
"daysAgo": "hace {{count}}d",
"achievements": "Logros"
"achievements": "Logros",
"emptyTitle": "Sin actividad aún",
"emptySubtitle": "Completa tu primer entreno y tus estadísticas aparecerán aquí.",
"startFirstWorkout": "Comienza tu primer entreno"
},
"browse": {
@@ -193,6 +209,29 @@
"unlockWithPremium": "DESBLOQUEAR CON TABATAFIT+"
},
"paywall": {
"subtitle": "Desbloquea todas las funciones y alcanza tus metas más rápido",
"features": {
"music": "Música Premium",
"workouts": "Entrenos Ilimitados",
"stats": "Estadísticas Avanzadas",
"calories": "Seguimiento de Calorías",
"reminders": "Recordatorios Diarios",
"ads": "Sin Anuncios"
},
"yearly": "Anual",
"monthly": "Mensual",
"perYear": "por año",
"perMonth": "por mes",
"save50": "AHORRA 50%",
"equivalent": "Solo {{price}}/mes",
"subscribe": "Suscribirse Ahora",
"trialCta": "Empezar Prueba Gratis",
"processing": "Procesando...",
"restore": "Restaurar Compras",
"terms": "El pago se cargará a tu Apple ID al confirmar. La suscripción se renueva automáticamente a menos que se cancele al menos 24 horas antes del final del período. Gestiona en Ajustes de la cuenta."
},
"onboarding": {
"problem": {
"title": "No tienes 1 hora\npara el gimnasio.",
@@ -327,7 +366,11 @@
"startAssessment": "Iniciar evaluaci\u00f3n",
"skipForNow": "Omitir por ahora",
"tips": "Consejos para mejores resultados",
"duration": "Duraci\u00f3n",
"tip1": "Muévete a tu propio ritmo",
"tip2": "Concéntrate en la forma, no en la velocidad",
"tip3": "Esto nos ayuda a recomendar el mejor programa",
"tip4": "Sin juicios - ¡solo un punto de partida!",
"duration": "Duración",
"exercises": "Ejercicios"
}
}

View File

@@ -28,20 +28,33 @@
"allWorkouts": "Tous les exercices",
"trainers": "Entraîneurs",
"noResults": "Aucun exercice trouvé",
"tryAdjustingFilters": "Essayez d'ajuster vos filtres",
"tryAdjustingFilters": "Essayez d'ajuster vos filtres ou votre recherche",
"loading": "Chargement...",
"filterCategory": "Catégorie",
"filterLevel": "Niveau",
"filterEquipment": "Équipement",
"filterDuration": "Durée",
"clearFilters": "Effacer les filtres",
"clearFilters": "Effacer",
"workoutsCount": "{{count}} exercices",
"workouts": "Exercices",
"equipmentOptions": {
"none": "Sans équipement",
"band": "Bande élastique",
"dumbbells": "Haltères",
"mat": "Tapis"
}
},
"allEquipment": "Tout l'équipement",
"searchPlaceholder": "Rechercher exercices, entraîneurs...",
"recommendedForYou": "Recommandé pour vous",
"tryNewCategory": "Essayez quelque chose de nouveau",
"startFirstWorkout": "Complétez votre premier exercice pour des recommandations personnalisées",
"filters": "Filtres",
"activeFilters": "{{count}} actifs",
"applyFilters": "Appliquer",
"resetFilters": "Réinitialiser",
"errorTitle": "Impossible de charger les exercices",
"errorRetry": "Appuyez pour réessayer",
"featuredCollection": "Collection en vedette"
},
"activity": {
@@ -58,7 +71,10 @@
"today": "Aujourd'hui",
"yesterday": "Hier",
"daysAgo": "il y a {{count}}j",
"achievements": "Succès"
"achievements": "Succès",
"emptyTitle": "Aucune activité",
"emptySubtitle": "Terminez votre premier entraînement et vos statistiques apparaîtront ici.",
"startFirstWorkout": "Commencez votre premier entraînement"
},
"browse": {
@@ -210,6 +226,7 @@
"save50": "ÉCONOMISEZ 50%",
"equivalent": "Seulement {{price}}/mois",
"subscribe": "S'abonner maintenant",
"trialCta": "Commencer l'essai gratuit",
"processing": "Traitement...",
"restore": "Restaurer les achats",
"terms": "Le paiement sera débité sur votre identifiant Apple à la confirmation. L'abonnement se renouvelle automatiquement sauf annulation au moins 24h avant la fin de la période. Gérez dans les réglages du compte."
@@ -386,6 +403,10 @@
"startAssessment": "Commencer l'évaluation",
"skipForNow": "Passer pour l'instant",
"tips": "Conseils pour de meilleurs résultats",
"tip1": "Bougez à votre rythme",
"tip2": "Concentrez-vous sur la forme, pas la vitesse",
"tip3": "Cela nous aide à recommander le meilleur programme",
"tip4": "Sans jugement - juste un point de départ !",
"duration": "Durée",
"exercises": "Exercices"
}

View File

@@ -0,0 +1,30 @@
/**
* TabataFit Explore Filter Store
* Lightweight Zustand store (no persistence) for sharing filter state
* between the Explore screen and the filter sheet modal.
*/
import { create } from 'zustand'
import type { WorkoutLevel } from '../types'
interface ExploreFilterState {
level: WorkoutLevel | 'all'
equipment: string | 'all'
/** Derived equipment options from workout data — set once by Explore screen */
equipmentOptions: string[]
// Actions
setLevel: (level: WorkoutLevel | 'all') => void
setEquipment: (equipment: string | 'all') => void
setEquipmentOptions: (options: string[]) => void
resetFilters: () => void
}
export const useExploreFilterStore = create<ExploreFilterState>()((set) => ({
level: 'all',
equipment: 'all',
equipmentOptions: [],
setLevel: (level) => set({ level }),
setEquipment: (equipment) => set({ equipment }),
setEquipmentOptions: (equipmentOptions) => set({ equipmentOptions }),
resetFilters: () => set({ level: 'all', equipment: 'all' }),
}))

View File

@@ -6,3 +6,4 @@ export { useUserStore } from './userStore'
export { useActivityStore, getWeeklyActivity } from './activityStore'
export { usePlayerStore } from './playerStore'
export { useProgramStore } from './programStore'
export { useExploreFilterStore } from './exploreFilterStore'

View File

@@ -29,11 +29,13 @@ interface OnboardingData {
interface UserState {
profile: UserProfile
settings: UserSettings
savedWorkouts: string[]
// Actions
updateProfile: (updates: Partial<UserProfile>) => void
updateSettings: (updates: Partial<UserSettings>) => void
setSubscription: (plan: SubscriptionPlan) => void
completeOnboarding: (data: OnboardingData) => void
toggleSavedWorkout: (workoutId: string) => void
// NEW: Sync-related actions
setSyncStatus: (status: SyncStatus, userId?: string | null) => void
setPromptPending: () => void
@@ -55,6 +57,7 @@ export const useUserStore = create<UserState>()(
goal: 'cardio',
weeklyFrequency: 3,
barriers: [],
savedWorkouts: [],
// NEW: Sync fields
syncStatus: 'never-synced',
supabaseUserId: null,
@@ -69,6 +72,8 @@ export const useUserStore = create<UserState>()(
reminderTime: '09:00',
},
savedWorkouts: [],
updateProfile: (updates) =>
set((state) => ({
profile: { ...state.profile, ...updates },
@@ -97,6 +102,13 @@ export const useUserStore = create<UserState>()(
},
})),
toggleSavedWorkout: (workoutId) =>
set((state) => ({
savedWorkouts: state.savedWorkouts.includes(workoutId)
? state.savedWorkouts.filter((id) => id !== workoutId)
: [...state.savedWorkouts, workoutId],
})),
// NEW: Sync status management
setSyncStatus: (status, userId = null) =>
set((state) => ({

View File

@@ -30,6 +30,7 @@ export interface UserProfile {
goal: FitnessGoal
weeklyFrequency: WeeklyFrequency
barriers: string[]
savedWorkouts: string[]
syncStatus: SyncStatus
supabaseUserId: string | null
}