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:
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -82,6 +82,7 @@ describe('usePurchases', () => {
|
||||
barriers: [],
|
||||
syncStatus: 'never-synced',
|
||||
supabaseUserId: null,
|
||||
savedWorkouts: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}` })
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ describe('userStore', () => {
|
||||
barriers: [],
|
||||
syncStatus: 'never-synced',
|
||||
supabaseUserId: null,
|
||||
savedWorkouts: [],
|
||||
},
|
||||
settings: {
|
||||
haptics: true,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
52
src/shared/components/Icon.tsx
Normal file
52
src/shared/components/Icon.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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) || []
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
30
src/shared/stores/exploreFilterStore.ts
Normal file
30
src/shared/stores/exploreFilterStore.ts
Normal 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' }),
|
||||
}))
|
||||
@@ -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'
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface UserProfile {
|
||||
goal: FitnessGoal
|
||||
weeklyFrequency: WeeklyFrequency
|
||||
barriers: string[]
|
||||
savedWorkouts: string[]
|
||||
syncStatus: SyncStatus
|
||||
supabaseUserId: string | null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user