diff --git a/AGENTS.md b/AGENTS.md
index a4a4017..e7c2b5c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -386,3 +386,62 @@ COMPLETE: '#30D158' // Green
---
*Last updated: March 14, 2026*
+
+# context-mode — MANDATORY routing rules
+
+You have context-mode MCP tools available. These rules are NOT optional — they protect your context window from flooding. A single unrouted command can dump 56 KB into context and waste the entire session.
+
+## BLOCKED commands — do NOT attempt these
+
+### curl / wget — BLOCKED
+Any shell command containing `curl` or `wget` will be intercepted and blocked by the context-mode plugin. Do NOT retry.
+Instead use:
+- `context-mode_ctx_fetch_and_index(url, source)` to fetch and index web pages
+- `context-mode_ctx_execute(language: "javascript", code: "const r = await fetch(...)")` to run HTTP calls in sandbox
+
+### Inline HTTP — BLOCKED
+Any shell command containing `fetch('http`, `requests.get(`, `requests.post(`, `http.get(`, or `http.request(` will be intercepted and blocked. Do NOT retry with shell.
+Instead use:
+- `context-mode_ctx_execute(language, code)` to run HTTP calls in sandbox — only stdout enters context
+
+### Direct web fetching — BLOCKED
+Do NOT use any direct URL fetching tool. Use the sandbox equivalent.
+Instead use:
+- `context-mode_ctx_fetch_and_index(url, source)` then `context-mode_ctx_search(queries)` to query the indexed content
+
+## REDIRECTED tools — use sandbox equivalents
+
+### Shell (>20 lines output)
+Shell is ONLY for: `git`, `mkdir`, `rm`, `mv`, `cd`, `ls`, `npm install`, `pip install`, and other short-output commands.
+For everything else, use:
+- `context-mode_ctx_batch_execute(commands, queries)` — run multiple commands + search in ONE call
+- `context-mode_ctx_execute(language: "shell", code: "...")` — run in sandbox, only stdout enters context
+
+### File reading (for analysis)
+If you are reading a file to **edit** it → reading is correct (edit needs content in context).
+If you are reading to **analyze, explore, or summarize** → use `context-mode_ctx_execute_file(path, language, code)` instead. Only your printed summary enters context.
+
+### grep / search (large results)
+Search results can flood context. Use `context-mode_ctx_execute(language: "shell", code: "grep ...")` to run searches in sandbox. Only your printed summary enters context.
+
+## Tool selection hierarchy
+
+1. **GATHER**: `context-mode_ctx_batch_execute(commands, queries)` — Primary tool. Runs all commands, auto-indexes output, returns search results. ONE call replaces 30+ individual calls.
+2. **FOLLOW-UP**: `context-mode_ctx_search(queries: ["q1", "q2", ...])` — Query indexed content. Pass ALL questions as array in ONE call.
+3. **PROCESSING**: `context-mode_ctx_execute(language, code)` | `context-mode_ctx_execute_file(path, language, code)` — Sandbox execution. Only stdout enters context.
+4. **WEB**: `context-mode_ctx_fetch_and_index(url, source)` then `context-mode_ctx_search(queries)` — Fetch, chunk, index, query. Raw HTML never enters context.
+5. **INDEX**: `context-mode_ctx_index(content, source)` — Store content in FTS5 knowledge base for later search.
+
+## Output constraints
+
+- Keep responses under 500 words.
+- Write artifacts (code, configs, PRDs) to FILES — never return them as inline text. Return only: file path + 1-line description.
+- When indexing content, use descriptive source labels so others can `search(source: "label")` later.
+
+## ctx commands
+
+| Command | Action |
+|---------|--------|
+| `ctx stats` | Call the `stats` MCP tool and display the full output verbatim |
+| `ctx doctor` | Call the `doctor` MCP tool, run the returned shell command, display as checklist |
+| `ctx upgrade` | Call the `upgrade` MCP tool, run the returned shell command, display as checklist |
diff --git a/app/(tabs)/activity.tsx b/app/(tabs)/activity.tsx
index 5516ed1..36156ee 100644
--- a/app/(tabs)/activity.tsx
+++ b/app/(tabs)/activity.tsx
@@ -3,11 +3,13 @@
* Premium stats dashboard — streak, rings, weekly chart, history
*/
-import { View, StyleSheet, ScrollView, Dimensions } from 'react-native'
+import { View, StyleSheet, ScrollView, Dimensions, Pressable } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
+import { useRouter } from 'expo-router'
import { LinearGradient } from 'expo-linear-gradient'
import { BlurView } from 'expo-blur'
-import Ionicons from '@expo/vector-icons/Ionicons'
+import { Icon, type IconName } from '@/src/shared/components/Icon'
+import Svg, { Circle } from 'react-native-svg'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@@ -45,39 +47,32 @@ function StatRing({
const progress = Math.min(value / max, 1)
const strokeDashoffset = circumference * (1 - progress)
- // We'll use a View-based ring since SVG isn't available
- // Use border trick for a circular progress indicator
return (
-
+
)
}
@@ -96,7 +91,7 @@ function StatCard({
value: number
max: number
color: string
- icon: keyof typeof Ionicons.glyphMap
+ icon: IconName
}) {
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
@@ -113,7 +108,7 @@ function StatCard({
{label}
-
+
)
@@ -155,6 +150,42 @@ function WeeklyBar({
)
}
+// ═══════════════════════════════════════════════════════════════════════════
+// EMPTY STATE
+// ═══════════════════════════════════════════════════════════════════════════
+
+function EmptyState({ onStartWorkout }: { onStartWorkout: () => void }) {
+ const { t } = useTranslation()
+ const colors = useThemeColors()
+ const styles = useMemo(() => createStyles(colors), [colors])
+
+ return (
+
+
+
+
+
+ {t('screens:activity.emptyTitle')}
+
+
+ {t('screens:activity.emptySubtitle')}
+
+
+
+
+
+ {t('screens:activity.startFirstWorkout')}
+
+
+
+ )
+}
+
// ═══════════════════════════════════════════════════════════════════════════
// MAIN SCREEN
// ═══════════════════════════════════════════════════════════════════════════
@@ -164,6 +195,7 @@ const DAY_KEYS = ['days.sun', 'days.mon', 'days.tue', 'days.wed', 'days.thu', 'd
export default function ActivityScreen() {
const { t } = useTranslation()
const insets = useSafeAreaInsets()
+ const router = useRouter()
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
const streak = useActivityStore((s) => s.streak)
@@ -216,6 +248,11 @@ export default function ActivityScreen() {
{t('screens:activity.title')}
+ {/* Empty state when no history */}
+ {history.length === 0 ? (
+ router.push('/(tabs)/explore' as any)} />
+ ) : (
+ <>
{/* Streak Banner */}
-
+
@@ -254,28 +291,28 @@ export default function ActivityScreen() {
value={totalWorkouts}
max={100}
color={BRAND.PRIMARY}
- icon="barbell-outline"
+ icon="dumbbell"
/>
@@ -358,10 +395,10 @@ export default function ActivityScreen() {
: { backgroundColor: 'rgba(255, 255, 255, 0.04)' },
]}
>
-
+ >
+ )}
)
@@ -549,5 +588,41 @@ function createStyles(colors: ThemeColors) {
alignItems: 'center',
justifyContent: 'center',
},
+
+ // Empty State
+ emptyState: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingTop: SPACING[10],
+ paddingHorizontal: SPACING[6],
+ },
+ emptyIconCircle: {
+ width: 96,
+ height: 96,
+ borderRadius: 48,
+ backgroundColor: `${BRAND.PRIMARY}15`,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginBottom: SPACING[6],
+ },
+ emptyTitle: {
+ textAlign: 'center' as const,
+ marginBottom: SPACING[2],
+ },
+ emptySubtitle: {
+ textAlign: 'center' as const,
+ lineHeight: 22,
+ marginBottom: SPACING[8],
+ },
+ emptyCtaButton: {
+ flexDirection: 'row' as const,
+ alignItems: 'center' as const,
+ justifyContent: 'center' as const,
+ height: 52,
+ paddingHorizontal: SPACING[8],
+ borderRadius: RADIUS.LG,
+ overflow: 'hidden' as const,
+ },
})
}
diff --git a/app/(tabs)/explore.tsx b/app/(tabs)/explore.tsx
index b0c2eec..26ef145 100644
--- a/app/(tabs)/explore.tsx
+++ b/app/(tabs)/explore.tsx
@@ -1,51 +1,64 @@
-import { useState, useMemo } from 'react'
-import { View, StyleSheet, ScrollView, Pressable, Dimensions, ActivityIndicator, FlatList } from 'react-native'
+/**
+ * TabataFit Explore Screen
+ * Premium workout discovery hub — Apple Fitness+ inspired
+ * Search, browse by trainer/collection/category, personalized recommendations
+ */
+
+import { useState, useMemo, useEffect, useCallback, useRef } from 'react'
+import {
+ View,
+ StyleSheet,
+ ScrollView,
+ Pressable,
+ TextInput,
+ FlatList,
+ LayoutAnimation,
+ UIManager,
+ Platform,
+ Animated,
+ useWindowDimensions,
+ ActivityIndicator,
+} from 'react-native'
import { useRouter } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
-import { BlurView } from 'expo-blur'
import { LinearGradient } from 'expo-linear-gradient'
-import Ionicons from '@expo/vector-icons/Ionicons'
+import { BlurView } from 'expo-blur'
+import { Icon } from '@/src/shared/components/Icon'
import { useTranslation } from 'react-i18next'
import { useHaptics } from '@/src/shared/hooks'
import { usePurchases } from '@/src/shared/hooks/usePurchases'
-import { useWorkouts, useCollections, useFeaturedWorkouts, useTrainers } from '@/src/shared/hooks/useSupabaseData'
+import {
+ useWorkouts,
+ useCollections,
+ useFeaturedWorkouts,
+ useTrainers,
+} from '@/src/shared/hooks/useSupabaseData'
+import { useActivityStore } from '@/src/shared/stores'
+import { useExploreFilterStore } from '@/src/shared/stores'
import { isFreeWorkout } from '@/src/shared/services/access'
+import { track, trackScreen } from '@/src/shared/services/analytics'
+import { FEATURED_COLLECTION_ID } from '@/src/shared/data/collections'
+import { getTrainerById } from '@/src/shared/data'
+
+import { WorkoutCard, CATEGORY_COLORS } from '@/src/shared/components/WorkoutCard'
+import { CollectionCard } from '@/src/shared/components/CollectionCard'
import { StyledText } from '@/src/shared/components/StyledText'
import { useThemeColors, BRAND } from '@/src/shared/theme'
import type { ThemeColors } from '@/src/shared/theme/types'
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
-import type { Workout, WorkoutCategory, WorkoutLevel } from '@/src/shared/types'
+import type { Workout, WorkoutCategory, Trainer, Collection } from '@/src/shared/types'
-const { width: SCREEN_WIDTH } = Dimensions.get('window')
-const CARD_WIDTH = (SCREEN_WIDTH - LAYOUT.SCREEN_PADDING * 2 - SPACING[3]) / 2
-const COLLECTION_CARD_WIDTH = SCREEN_WIDTH * 0.7
-
-type FilterState = {
- category: WorkoutCategory | 'all'
- level: WorkoutLevel | 'all'
- equipment: string | 'all'
+// Enable LayoutAnimation on Android
+if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
+ UIManager.setLayoutAnimationEnabledExperimental(true)
}
-const CATEGORY_ICONS: Record = {
- all: 'grid-outline',
- 'full-body': 'body-outline',
- 'upper-body': 'barbell-outline',
- 'lower-body': 'footsteps-outline',
- core: 'fitness-outline',
- cardio: 'heart-outline',
-}
-
-const CATEGORY_COLORS: Record = {
- all: BRAND.PRIMARY,
- 'full-body': '#5AC8FA',
- 'upper-body': '#FF6B35',
- 'lower-body': '#30D158',
- core: '#FF9500',
- cardio: '#FF3B30',
-}
+// ═══════════════════════════════════════════════════════════════════════════
+// CONSTANTS
+// ═══════════════════════════════════════════════════════════════════════════
const CATEGORY_TRANSLATION_KEYS: Record = {
all: 'all',
@@ -56,131 +69,103 @@ const CATEGORY_TRANSLATION_KEYS: Record = {
cardio: 'cardio',
}
-function CollectionCard({
- title,
- description,
- icon,
- gradient,
- workoutCount,
+const ALL_CATEGORIES: (WorkoutCategory | 'all')[] = [
+ 'all', 'full-body', 'upper-body', 'lower-body', 'core', 'cardio',
+]
+
+type FilterState = {
+ category: WorkoutCategory | 'all'
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// TRAINER AVATAR
+// ═══════════════════════════════════════════════════════════════════════════
+
+function TrainerAvatar({
+ trainer,
onPress,
+ colors,
}: {
- title: string
- description: string
- icon: string
- gradient?: [string, string]
- workoutCount: number
+ trainer: Trainer
onPress: () => void
+ colors: ThemeColors
}) {
- const colors = useThemeColors()
const haptics = useHaptics()
- const gradientColors = gradient || [BRAND.PRIMARY, '#FF3B30']
return (
{
haptics.buttonTap()
onPress()
}}
>
-
-
-
-
-
-
- {title}
-
-
- {description}
-
-
- {workoutCount} workouts
+
+
+ {trainer.name.charAt(0)}
+
+ {trainer.name}
+
+
+ {trainer.specialty}
+
)
}
-function WorkoutCard({
- workout,
- onPress,
- isLocked,
-}: {
- workout: Workout
- onPress: () => void
- isLocked?: boolean
-}) {
- const colors = useThemeColors()
- const categoryColor = CATEGORY_COLORS[workout.category]
+const trainerStyles = StyleSheet.create({
+ container: {
+ alignItems: 'center',
+ width: 72,
+ marginRight: SPACING[4],
+ },
+ avatar: {
+ width: 56,
+ height: 56,
+ borderRadius: 28,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginBottom: SPACING[1],
+ borderCurve: 'continuous',
+ },
+ name: {
+ marginTop: 2,
+ textAlign: 'center',
+ },
+})
- return (
-
-
-
-
-
-
-
- {workout.duration} min
-
-
-
- {isLocked && (
-
-
-
- )}
-
-
-
-
-
-
-
-
-
- {workout.title}
-
-
- {workout.level} · {workout.calories} cal
-
-
-
- )
-}
+// ═══════════════════════════════════════════════════════════════════════════
+// FILTER PILL
+// ═══════════════════════════════════════════════════════════════════════════
function FilterPill({
label,
isSelected,
color,
onPress,
+ colors,
}: {
label: string
isSelected: boolean
color: string
onPress: () => void
+ colors: ThemeColors
}) {
- const colors = useThemeColors()
const haptics = useHaptics()
return (
{
+ const animation = Animated.loop(
+ Animated.sequence([
+ Animated.timing(pulseAnim, { toValue: 0.7, duration: 800, useNativeDriver: true }),
+ Animated.timing(pulseAnim, { toValue: 0.3, duration: 800, useNativeDriver: true }),
+ ])
+ )
+ animation.start()
+ return () => animation.stop()
+ }, [pulseAnim])
+
+ const cardWidth = (screenWidth - LAYOUT.SCREEN_PADDING * 2 - SPACING[3]) / 2
return (
-
-
-
- Loading workouts...
-
+
+ {/* Search bar skeleton */}
+
+ {/* Hero collection skeleton */}
+
+ {/* Trainer row skeleton */}
+
+ {[0, 1, 2, 3, 4].map((i) => (
+
+ ))}
+
+ {/* Workout grid skeleton */}
+
+ {[0, 1].map((i) => (
+
+ ))}
+
)
}
+// ═══════════════════════════════════════════════════════════════════════════
+// SECTION HEADER
+// ═══════════════════════════════════════════════════════════════════════════
+
+function SectionHeader({
+ title,
+ rightLabel,
+ onRightPress,
+ colors,
+}: {
+ title: string
+ rightLabel?: string
+ onRightPress?: () => void
+ colors: ThemeColors
+}) {
+ return (
+
+
+ {title}
+
+ {rightLabel && onRightPress && (
+
+
+ {rightLabel}
+
+
+ )}
+
+ )
+}
+
+const sectionHeaderStyles = StyleSheet.create({
+ container: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: SPACING[4],
+ },
+})
+
+// ═══════════════════════════════════════════════════════════════════════════
+// MAIN SCREEN
+// ═══════════════════════════════════════════════════════════════════════════
+
export default function ExploreScreen() {
const { t } = useTranslation()
const insets = useSafeAreaInsets()
const router = useRouter()
const haptics = useHaptics()
const colors = useThemeColors()
+ const { width: screenWidth } = useWindowDimensions()
const { isPremium } = usePurchases()
+ const styles = useMemo(() => createStyles(colors, screenWidth), [colors, screenWidth])
+ // ── State ──────────────────────────────────────────────────────────────
+ const [searchQuery, setSearchQuery] = useState('')
const [filters, setFilters] = useState({
category: 'all',
- level: 'all',
- equipment: 'all',
})
- const { data: workoutsData, isLoading: workoutsLoading } = useWorkouts()
+ // Level + Equipment from shared store (so filter sheet can read/write)
+ const storeLevel = useExploreFilterStore((s) => s.level)
+ const storeEquipment = useExploreFilterStore((s) => s.equipment)
+ const setEquipmentOptions = useExploreFilterStore((s) => s.setEquipmentOptions)
+ const resetStoreFilters = useExploreFilterStore((s) => s.resetFilters)
+
+ // ── Data ───────────────────────────────────────────────────────────────
+ const { data: workoutsData, isLoading: workoutsLoading, isError: workoutsError, refetch: refetchWorkouts } = useWorkouts()
const { data: collectionsData, isLoading: collectionsLoading } = useCollections()
const { data: featuredData } = useFeaturedWorkouts()
+ const { data: trainersData } = useTrainers()
+ const history = useActivityStore((s) => s.history)
const workouts = workoutsData || []
const collections = collectionsData || []
const featured = featuredData || []
+ const trainers = trainersData || []
+ // ── Analytics ──────────────────────────────────────────────────────────
+ useEffect(() => {
+ trackScreen('explore')
+ }, [])
+
+ // ── Derived: Featured Collection ───────────────────────────────────────
+ const featuredCollection = useMemo(
+ () => collections.find((c) => c.id === FEATURED_COLLECTION_ID),
+ [collections]
+ )
+ const otherCollections = useMemo(
+ () => collections.filter((c) => c.id !== FEATURED_COLLECTION_ID),
+ [collections]
+ )
+
+ // ── Derived: Equipment options from data ───────────────────────────────
+ const equipmentOptions = useMemo(() => {
+ const allEquipment = new Set()
+ workouts.forEach((w) => {
+ w.equipment.forEach((e) => {
+ const normalized = e.toLowerCase()
+ if (normalized.includes('dumbbell')) allEquipment.add('dumbbells')
+ else if (normalized.includes('band')) allEquipment.add('band')
+ else if (normalized.includes('mat')) allEquipment.add('mat')
+ else if (normalized.includes('no equipment') || normalized.includes('optional')) {
+ // skip — we always show "none"
+ } else allEquipment.add(normalized)
+ })
+ })
+ const options = ['all', 'none', ...Array.from(allEquipment).sort()]
+ setEquipmentOptions(options)
+ return options
+ }, [workouts, setEquipmentOptions])
+
+ // ── Derived: Search ────────────────────────────────────────────────────
+ const searchResults = useMemo(() => {
+ if (!searchQuery.trim()) return null
+ const q = searchQuery.toLowerCase().trim()
+ return workouts.filter((w) => {
+ if (w.title.toLowerCase().includes(q)) return true
+ const trainer = getTrainerById(w.trainerId)
+ if (trainer?.name.toLowerCase().includes(q)) return true
+ if (w.exercises.some((e) => e.name.toLowerCase().includes(q))) return true
+ if (w.category.toLowerCase().includes(q)) return true
+ return false
+ })
+ }, [workouts, searchQuery])
+
+ const isSearchActive = searchQuery.trim().length > 0
+
+ // ── Derived: Filtered workouts ─────────────────────────────────────────
const filteredWorkouts = useMemo(() => {
- return workouts.filter((workout) => {
+ const source = searchResults ?? workouts
+ return source.filter((workout) => {
if (filters.category !== 'all' && workout.category !== filters.category) return false
- if (filters.level !== 'all' && workout.level !== filters.level) return false
- if (filters.equipment !== 'all') {
- if (filters.equipment === 'none') {
- return workout.equipment.length === 0
+ if (storeLevel !== 'all' && workout.level !== storeLevel) return false
+ if (storeEquipment !== 'all') {
+ if (storeEquipment === 'none') {
+ return workout.equipment.length === 0 ||
+ workout.equipment.every((e) => e.toLowerCase().includes('no equipment') || e.toLowerCase().includes('optional'))
}
- return workout.equipment.some((e) => e.toLowerCase().includes(filters.equipment.toLowerCase()))
+ return workout.equipment.some((e) => e.toLowerCase().includes(storeEquipment.toLowerCase()))
}
return true
})
- }, [workouts, filters])
+ }, [workouts, searchResults, filters, storeLevel, storeEquipment])
- const categories: (WorkoutCategory | 'all')[] = ['all', 'full-body', 'upper-body', 'lower-body', 'core', 'cardio']
- const levels: (WorkoutLevel | 'all')[] = ['all', 'Beginner', 'Intermediate', 'Advanced']
- const equipmentOptions = ['all', 'none', 'band', 'dumbbells', 'mat']
+ // ── Derived: Recommendations ───────────────────────────────────────────
+ const recommendedWorkouts = useMemo(() => {
+ if (history.length === 0) return []
- const handleWorkoutPress = (id: string) => {
+ // Find categories user has done
+ const completedWorkoutIds = new Set(history.map((r) => r.workoutId))
+ const completedCategories = new Set()
+ workouts.forEach((w) => {
+ if (completedWorkoutIds.has(w.id)) completedCategories.add(w.category)
+ })
+
+ // Determine user's most common level
+ const levelCounts: Record = {}
+ history.forEach((r) => {
+ const w = workouts.find((wo) => wo.id === r.workoutId)
+ if (w) levelCounts[w.level] = (levelCounts[w.level] || 0) + 1
+ })
+ const preferredLevel = Object.entries(levelCounts).sort((a, b) => b[1] - a[1])[0]?.[0]
+
+ // Recommend: workouts NOT yet done, preferring unexplored categories & matching level
+ return workouts
+ .filter((w) => !completedWorkoutIds.has(w.id))
+ .sort((a, b) => {
+ // Prioritize unexplored categories
+ const aExplored = completedCategories.has(a.category) ? 1 : 0
+ const bExplored = completedCategories.has(b.category) ? 1 : 0
+ if (aExplored !== bExplored) return aExplored - bExplored
+ // Then matching level
+ const aLevel = a.level === preferredLevel ? 0 : 1
+ const bLevel = b.level === preferredLevel ? 0 : 1
+ return aLevel - bLevel
+ })
+ .slice(0, 8)
+ }, [workouts, history])
+
+ // ── Active filter count ────────────────────────────────────────────────
+ const activeFilterCount = (storeLevel !== 'all' ? 1 : 0) + (storeEquipment !== 'all' ? 1 : 0)
+ const hasActiveFilters = filters.category !== 'all' || activeFilterCount > 0
+
+ // ── Handlers ───────────────────────────────────────────────────────────
+ const handleWorkoutPress = useCallback((workout: Workout) => {
haptics.buttonTap()
- router.push(`/workout/${id}`)
- }
+ track('explore_workout_tapped', {
+ workout_id: workout.id,
+ workout_title: workout.title,
+ source: isSearchActive ? 'search' : 'browse',
+ })
+ router.push(`/workout/${workout.id}`)
+ }, [haptics, router, isSearchActive])
- const handleCollectionPress = (id: string) => {
+ const handleCollectionPress = useCallback((id: string) => {
haptics.buttonTap()
+ track('explore_collection_tapped', { collection_id: id })
router.push(`/collection/${id}` as any)
- }
+ }, [haptics, router])
- const clearFilters = () => {
+ const handleTrainerPress = useCallback((trainer: Trainer) => {
+ haptics.buttonTap()
+ track('explore_trainer_tapped', { trainer_id: trainer.id, trainer_name: trainer.name })
+ // Navigate to workouts filtered by this trainer
+ // For now, filter in place by setting search to trainer name
+ setSearchQuery(trainer.name)
+ }, [haptics])
+
+ const handleFilterSheetPress = useCallback(() => {
+ haptics.buttonTap()
+ router.push('/explore-filters' as any)
+ }, [haptics, router])
+
+ const handleCategoryChange = useCallback((cat: WorkoutCategory | 'all') => {
+ LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
+ setFilters((f) => ({ ...f, category: cat }))
+ track('explore_filter_applied', { filter_type: 'category', value: cat })
+ }, [])
+
+ const clearFilters = useCallback(() => {
haptics.selection()
- setFilters({ category: 'all', level: 'all', equipment: 'all' })
- }
+ LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
+ setFilters({ category: 'all' })
+ resetStoreFilters()
+ }, [haptics, resetStoreFilters])
- const hasActiveFilters = filters.category !== 'all' || filters.level !== 'all' || filters.equipment !== 'all'
+ const clearSearch = useCallback(() => {
+ haptics.selection()
+ setSearchQuery('')
+ }, [haptics])
+ const handleSearchChange = useCallback((text: string) => {
+ setSearchQuery(text)
+ if (text.length > 2) {
+ track('explore_search_used', { query_length: text.length })
+ }
+ }, [])
+
+ const handleRetry = useCallback(() => {
+ haptics.buttonTap()
+ refetchWorkouts()
+ }, [haptics, refetchWorkouts])
+
+ // ── Loading State ──────────────────────────────────────────────────────
if (workoutsLoading || collectionsLoading) {
return (
-
-
+
+
+
+
+ )
+ }
+
+ // ── Error State ────────────────────────────────────────────────────────
+ if (workoutsError) {
+ return (
+
+
+
+
+ {t('screens:explore.errorTitle')}
+
+
+
+
+ {t('screens:explore.errorRetry')}
+
+
+
)
}
return (
-
+
+ {/* Ambient gradient glow */}
+
+
+ {/* ── Header ──────────────────────────────────────────────── */}
{t('screens:explore.title')}
@@ -297,280 +582,424 @@ export default function ExploreScreen() {
- {collections.length > 0 && (
-
-
- {t('screens:explore.collections')}
-
- item.id}
- showsHorizontalScrollIndicator={false}
- contentContainerStyle={styles.collectionsList}
- renderItem={({ item }) => (
- handleCollectionPress(item.id)}
- />
- )}
- />
-
- )}
-
- {featured.length > 0 && (
-
-
- {t('screens:explore.featured')}
-
-
- {featured.slice(0, 4).map((workout) => (
- handleWorkoutPress(workout.id)}
- isLocked={!isPremium && !isFreeWorkout(workout.id)}
- />
- ))}
-
-
- )}
-
-
-
-
- {t('screens:explore.allWorkouts')}
-
- {hasActiveFilters && (
-
-
- {t('screens:explore.clearFilters')}
-
-
- )}
-
-
-
-
- {t('screens:explore.filterCategory')}
-
-
-
- {categories.map((cat) => (
- setFilters((f) => ({ ...f, category: cat }))}
- />
- ))}
-
-
-
-
- {t('screens:explore.filterLevel')}
-
-
-
- {levels.map((level) => (
- setFilters((f) => ({ ...f, level }))}
- />
- ))}
-
-
-
-
- {t('screens:explore.filterEquipment')}
-
-
-
- {equipmentOptions.map((eq) => (
- setFilters((f) => ({ ...f, equipment: eq }))}
- />
- ))}
-
+ {/* ── Search Bar ──────────────────────────────────────────── */}
+
+
+
+
+ {isSearchActive && (
+
+
+
+ )}
- {filteredWorkouts.length > 0 ? (
-
- {filteredWorkouts.map((workout) => (
- handleWorkoutPress(workout.id)}
- isLocked={!isPremium && !isFreeWorkout(workout.id)}
- />
- ))}
+ {/* ── When search is active, show only results ────────────── */}
+ {isSearchActive ? (
+
+
+ {filteredWorkouts.length > 0 ? (
+
+ {filteredWorkouts.map((workout) => (
+ handleWorkoutPress(workout)}
+ />
+ ))}
+
+ ) : (
+
+
+
+ {t('screens:explore.noResults')}
+
+
+ {t('screens:explore.tryAdjustingFilters')}
+
+
+ )}
) : (
-
-
-
- {t('screens:explore.noResults')}
-
-
- {t('screens:explore.tryAdjustingFilters')}
-
-
+ <>
+ {/* ── Featured Collection Hero ──────────────────────────── */}
+ {featuredCollection && (
+
+
+ {t('screens:explore.featuredCollection').toUpperCase()}
+
+ handleCollectionPress(featuredCollection.id)}
+ workoutCountLabel={t('screens:explore.workoutsCount', { count: featuredCollection.workoutIds.length })}
+ />
+
+ )}
+
+ {/* ── Trainers ─────────────────────────────────────────── */}
+ {trainers.length > 0 && (
+
+
+ item.id}
+ showsHorizontalScrollIndicator={false}
+ contentContainerStyle={styles.horizontalListContent}
+ renderItem={({ item }) => (
+ handleTrainerPress(item)}
+ colors={colors}
+ />
+ )}
+ />
+
+ )}
+
+ {/* ── Collections Carousel ─────────────────────────────── */}
+ {otherCollections.length > 0 && (
+
+
+ item.id}
+ showsHorizontalScrollIndicator={false}
+ contentContainerStyle={styles.horizontalListContent}
+ renderItem={({ item }) => (
+
+ handleCollectionPress(item.id)}
+ workoutCountLabel={t('screens:explore.workoutsCount', { count: item.workoutIds.length })}
+ />
+
+ )}
+ />
+
+ )}
+
+ {/* ── Recommended For You ──────────────────────────────── */}
+ {recommendedWorkouts.length > 0 && (
+
+
+ item.id}
+ showsHorizontalScrollIndicator={false}
+ contentContainerStyle={styles.horizontalListContent}
+ renderItem={({ item }) => (
+
+ handleWorkoutPress(item)}
+ />
+
+ )}
+ />
+
+ )}
+
+ {history.length === 0 && (
+
+
+
+
+ {t('screens:explore.startFirstWorkout')}
+
+
+ )}
+
+ {/* ── Featured Workouts ────────────────────────────────── */}
+ {featured.length > 0 && (
+
+
+
+ {featured.slice(0, 4).map((workout) => (
+ handleWorkoutPress(workout)}
+ />
+ ))}
+
+
+ )}
+
+ {/* ── All Workouts + Filters ───────────────────────────── */}
+
+ {/* Section header with filter button */}
+
+
+ {t('screens:explore.allWorkouts')}
+
+
+ {hasActiveFilters && (
+
+
+ {t('screens:explore.clearFilters')}
+
+
+ )}
+ 0 && { borderColor: BRAND.PRIMARY }]}
+ onPress={handleFilterSheetPress}
+ >
+ 0 ? BRAND.PRIMARY : colors.text.secondary} />
+ {activeFilterCount > 0 && (
+
+
+ {activeFilterCount}
+
+
+ )}
+
+
+
+
+ {/* Category pills — single row */}
+
+ {ALL_CATEGORIES.map((cat) => (
+ handleCategoryChange(cat)}
+ colors={colors}
+ />
+ ))}
+
+
+ {/* Workout grid */}
+ {filteredWorkouts.length > 0 ? (
+
+ {filteredWorkouts.map((workout) => (
+ handleWorkoutPress(workout)}
+ />
+ ))}
+
+ ) : (
+
+
+
+ {t('screens:explore.noResults')}
+
+
+ {t('screens:explore.tryAdjustingFilters')}
+
+
+ )}
+
+ >
)}
)
}
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- },
- scrollView: {
- flex: 1,
- },
- scrollContent: {
- paddingHorizontal: LAYOUT.SCREEN_PADDING,
- },
- header: {
- marginBottom: SPACING[6],
- },
- loadingContainer: {
- flex: 1,
- alignItems: 'center',
- justifyContent: 'center',
- },
+// ═══════════════════════════════════════════════════════════════════════════
+// STYLES
+// ═══════════════════════════════════════════════════════════════════════════
- collectionsSection: {
- marginBottom: SPACING[6],
- },
- collectionsList: {
- marginTop: SPACING[4],
- paddingRight: LAYOUT.SCREEN_PADDING,
- },
- collectionCard: {
- width: COLLECTION_CARD_WIDTH,
- height: 180,
- borderRadius: RADIUS.XL,
- marginRight: SPACING[3],
- overflow: 'hidden',
- },
- collectionOverlay: {
- flex: 1,
- padding: SPACING[5],
- backgroundColor: 'rgba(0,0,0,0.3)',
- },
- collectionIcon: {
- width: 48,
- height: 48,
- borderRadius: 24,
- backgroundColor: 'rgba(255,255,255,0.2)',
- alignItems: 'center',
- justifyContent: 'center',
- },
+function createStyles(colors: ThemeColors, screenWidth: number) {
+ const cardWidth = (screenWidth - LAYOUT.SCREEN_PADDING * 2 - SPACING[3]) / 2
- section: {
- marginBottom: SPACING[6],
- },
+ return StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: colors.bg.base,
+ },
+ scrollView: {
+ flex: 1,
+ },
+ scrollContent: {
+ paddingHorizontal: LAYOUT.SCREEN_PADDING,
+ },
- filtersSection: {
- marginBottom: SPACING[6],
- },
- filterHeader: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- alignItems: 'center',
- marginBottom: SPACING[4],
- },
- filterRow: {
- marginBottom: SPACING[2],
- },
- filterScroll: {
- marginHorizontal: -LAYOUT.SCREEN_PADDING,
- paddingHorizontal: LAYOUT.SCREEN_PADDING,
- },
- filterPill: {
- paddingHorizontal: SPACING[4],
- paddingVertical: SPACING[2],
- borderRadius: RADIUS.FULL,
- marginRight: SPACING[2],
- borderWidth: 1,
- borderCurve: 'continuous',
- },
+ // Ambient glow
+ ambientGlow: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ width: 300,
+ height: 300,
+ borderRadius: 150,
+ },
- workoutsGrid: {
- flexDirection: 'row',
- flexWrap: 'wrap',
- gap: SPACING[3],
- },
- workoutCard: {
- width: CARD_WIDTH,
- height: 200,
- borderRadius: RADIUS.GLASS_CARD,
- overflow: 'hidden',
- borderWidth: 1,
- borderCurve: 'continuous',
- },
- durationBadge: {
- position: 'absolute',
- top: SPACING[3],
- right: SPACING[3],
- flexDirection: 'row',
- alignItems: 'center',
- paddingHorizontal: SPACING[2],
- paddingVertical: 3,
- borderRadius: RADIUS.SM,
- },
- lockBadge: {
- position: 'absolute',
- top: SPACING[3],
- left: SPACING[3],
- width: 22,
- height: 22,
- borderRadius: 11,
- backgroundColor: 'rgba(0,0,0,0.6)',
- alignItems: 'center',
- justifyContent: 'center',
- },
- playArea: {
- position: 'absolute',
- top: 0,
- left: 0,
- right: 0,
- bottom: 70,
- alignItems: 'center',
- justifyContent: 'center',
- },
- playCircle: {
- width: 44,
- height: 44,
- borderRadius: 22,
- alignItems: 'center',
- justifyContent: 'center',
- },
- workoutInfo: {
- position: 'absolute',
- bottom: 0,
- left: 0,
- right: 0,
- padding: SPACING[3],
- paddingTop: SPACING[2],
- },
+ // Header
+ header: {
+ marginBottom: SPACING[5],
+ },
- noResults: {
- alignItems: 'center',
- justifyContent: 'center',
- paddingVertical: SPACING[10],
- },
-})
+ // Search
+ searchContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ height: 44,
+ borderRadius: RADIUS.LG,
+ paddingHorizontal: SPACING[3],
+ gap: SPACING[2],
+ marginBottom: SPACING[6],
+ overflow: 'hidden',
+ borderWidth: 1,
+ borderColor: colors.border.glass,
+ borderCurve: 'continuous',
+ backgroundColor: colors.glass.base.backgroundColor,
+ },
+ searchInput: {
+ flex: 1,
+ height: 44,
+ fontSize: 16,
+ },
+
+ // Sections
+ section: {
+ marginBottom: SPACING[6],
+ },
+
+ // Horizontal lists
+ horizontalListContent: {
+ paddingRight: LAYOUT.SCREEN_PADDING,
+ },
+
+ // All Workouts header
+ allWorkoutsHeader: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: SPACING[4],
+ },
+ filterHeaderRight: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+ filterIconButton: {
+ width: 36,
+ height: 36,
+ borderRadius: 18,
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderWidth: 1,
+ borderColor: colors.border.glass,
+ borderCurve: 'continuous',
+ backgroundColor: colors.glass.base.backgroundColor,
+ },
+ filterBadge: {
+ position: 'absolute',
+ top: -4,
+ right: -4,
+ width: 18,
+ height: 18,
+ borderRadius: 9,
+ backgroundColor: BRAND.PRIMARY,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+
+ // Category pills
+ categoryScroll: {
+ marginHorizontal: -LAYOUT.SCREEN_PADDING,
+ marginBottom: SPACING[4],
+ },
+ categoryScrollContent: {
+ paddingHorizontal: LAYOUT.SCREEN_PADDING,
+ },
+
+ // Workout grid
+ workoutsGrid: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: SPACING[3],
+ },
+
+ // No results
+ noResults: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingVertical: SPACING[10],
+ },
+
+ // Recommendation CTA
+ recommendationCta: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingVertical: SPACING[6],
+ paddingHorizontal: SPACING[5],
+ borderRadius: RADIUS.GLASS_CARD,
+ overflow: 'hidden',
+ borderWidth: 1,
+ borderColor: colors.border.glass,
+ borderCurve: 'continuous',
+ marginBottom: SPACING[6],
+ backgroundColor: colors.glass.base.backgroundColor,
+ },
+
+ // Error
+ errorContainer: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ retryButton: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: SPACING[2],
+ marginTop: SPACING[4],
+ paddingVertical: SPACING[3],
+ paddingHorizontal: SPACING[5],
+ borderRadius: RADIUS.FULL,
+ borderWidth: 1,
+ borderColor: BRAND.PRIMARY,
+ borderCurve: 'continuous',
+ },
+ })
+}
diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx
index 37cdbc4..02be57e 100644
--- a/app/(tabs)/index.tsx
+++ b/app/(tabs)/index.tsx
@@ -8,7 +8,7 @@ import { useRouter } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { LinearGradient } from 'expo-linear-gradient'
import { BlurView } from 'expo-blur'
-import Ionicons from '@expo/vector-icons/Ionicons'
+import { Icon, type IconName } from '@/src/shared/components/Icon'
import { useMemo, useRef, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
@@ -23,6 +23,11 @@ import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import type { ProgramId } from '@/src/shared/types'
+// Feature flags — disable incomplete features
+const FEATURE_FLAGS = {
+ ASSESSMENT_ENABLED: false, // Assessment player not yet implemented
+}
+
const FONTS = {
LARGE_TITLE: 34,
TITLE: 28,
@@ -33,19 +38,19 @@ const FONTS = {
}
// Program metadata for display
-const PROGRAM_META: Record = {
+const PROGRAM_META: Record = {
'upper-body': {
- icon: 'barbell-outline',
+ icon: 'dumbbell',
gradient: ['#FF6B35', '#FF3B30'],
accent: '#FF6B35',
},
'lower-body': {
- icon: 'footsteps-outline',
+ icon: 'figure.walk',
gradient: ['#30D158', '#28A745'],
accent: '#30D158',
},
'full-body': {
- icon: 'flame-outline',
+ icon: 'flame',
gradient: ['#5AC8FA', '#007AFF'],
accent: '#5AC8FA',
},
@@ -143,7 +148,7 @@ function ProgramCard({
style={styles.programIconGradient}
/>
-
+
@@ -220,10 +225,10 @@ function ProgramCard({
: t('programs.continue')
}
-
@@ -248,9 +253,9 @@ function QuickStats() {
const totalMinutes = useMemo(() => history.reduce((sum, r) => sum + r.durationMinutes, 0), [history])
const stats = [
- { icon: 'flame' as const, value: streak.current, label: t('home.statsStreak'), color: BRAND.PRIMARY },
- { icon: 'calendar-outline' as const, value: `${thisWeekCount}/7`, label: t('home.statsThisWeek'), color: '#5AC8FA' },
- { icon: 'time-outline' as const, value: totalMinutes, label: t('home.statsMinutes'), color: '#30D158' },
+ { icon: 'flame.fill' as const, value: streak.current, label: t('home.statsStreak'), color: BRAND.PRIMARY },
+ { icon: 'calendar' as const, value: `${thisWeekCount}/7`, label: t('home.statsThisWeek'), color: '#5AC8FA' },
+ { icon: 'clock' as const, value: totalMinutes, label: t('home.statsMinutes'), color: '#30D158' },
]
return (
@@ -262,7 +267,7 @@ function QuickStats() {
tint={colors.glass.blurTint}
style={StyleSheet.absoluteFill}
/>
-
+
{String(stat.value)}
@@ -323,7 +328,7 @@ function AssessmentCard({ onPress }: { onPress: () => void }) {
style={StyleSheet.absoluteFill}
/>
-
+
@@ -335,7 +340,7 @@ function AssessmentCard({ onPress }: { onPress: () => void }) {
-
+
@@ -405,7 +410,7 @@ export default function HomeScreen() {
{/* Inline streak badge */}
{streak.current > 0 && (
-
+
{streak.current}
@@ -426,8 +431,10 @@ export default function HomeScreen() {
{/* Quick Stats Row */}
- {/* Assessment Card (if not completed) */}
-
+ {/* Assessment Card (if not completed and feature enabled) */}
+ {FEATURE_FLAGS.ASSESSMENT_ENABLED && (
+
+ )}
{/* Program Cards */}
@@ -460,7 +467,7 @@ export default function HomeScreen() {
tint={colors.glass.blurTint}
style={StyleSheet.absoluteFill}
/>
-
+
{t('home.switchProgram')}
diff --git a/app/(tabs)/profile.tsx b/app/(tabs)/profile.tsx
index 41dc323..03de67f 100644
--- a/app/(tabs)/profile.tsx
+++ b/app/(tabs)/profile.tsx
@@ -8,10 +8,8 @@ import {
View,
ScrollView,
StyleSheet,
- TouchableOpacity,
+ Pressable,
Switch,
- Text as RNText,
- TextStyle,
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import * as Linking from 'expo-linking'
@@ -22,41 +20,12 @@ import { useUserStore, useActivityStore } from '@/src/shared/stores'
import { requestNotificationPermissions, usePurchases } from '@/src/shared/hooks'
import { useThemeColors, BRAND } from '@/src/shared/theme'
import type { ThemeColors } from '@/src/shared/theme/types'
+import { StyledText } from '@/src/shared/components/StyledText'
+import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
+import { RADIUS } from '@/src/shared/constants/borderRadius'
import { DataDeletionModal } from '@/src/shared/components/DataDeletionModal'
import { deleteSyncedData } from '@/src/shared/services/sync'
-// ═══════════════════════════════════════════════════════════════════════════
-// STYLED TEXT COMPONENT
-// ═══════════════════════════════════════════════════════════════════════════
-
-interface TextProps {
- children: React.ReactNode
- style?: TextStyle
- size?: number
- weight?: 'normal' | 'bold' | '600' | '700' | '800' | '900'
- color?: string
- center?: boolean
-}
-
-function Text({ children, style, size, weight, color, center }: TextProps) {
- const colors = useThemeColors()
- return (
-
- {children}
-
- )
-}
-
// ═══════════════════════════════════════════════════════════════════════════
// COMPONENT: PROFILE SCREEN
// ═══════════════════════════════════════════════════════════════════════════
@@ -150,24 +119,24 @@ export default function ProfileScreen() {
{/* Avatar with gradient background */}
-
+
{avatarInitial}
-
+
{/* Name & Plan */}
-
+
{profile.name || t('profile.guest')}
-
+
-
+
{planLabel}
-
+
{isPremium && (
-
+
✓
-
+
)}
@@ -175,28 +144,28 @@ export default function ProfileScreen() {
{/* Stats Row */}
-
+
🔥 {stats.workouts}
-
-
+
+
{t('profile.statsWorkouts')}
-
+
-
+
📅 {stats.streak}
-
-
+
+
{t('profile.statsStreak')}
-
+
-
+
⚡️ {Math.round(stats.calories / 1000)}k
-
-
+
+
{t('profile.statsCalories')}
-
+
@@ -207,32 +176,32 @@ export default function ProfileScreen() {
═══════════════════════════════════════════════════════════════════ */}
{!isPremium && (
- router.push('/paywall')}
>
-
+
✨ {t('profile.upgradeTitle')}
-
-
+
+
{t('profile.upgradeDescription')}
-
+
-
+
{t('profile.learnMore')} →
-
-
+
+
)}
{/* ════════════════════════════════════════════════════════════════════
WORKOUT SETTINGS
═══════════════════════════════════════════════════════════════════ */}
- {t('profile.sectionWorkout')}
+ {t('profile.sectionWorkout')}
- {t('profile.hapticFeedback')}
+ {t('profile.hapticFeedback')}
updateSettings({ haptics: v })}
@@ -241,7 +210,7 @@ export default function ProfileScreen() {
/>
- {t('profile.soundEffects')}
+ {t('profile.soundEffects')}
updateSettings({ soundEffects: v })}
@@ -250,7 +219,7 @@ export default function ProfileScreen() {
/>
- {t('profile.voiceCoaching')}
+ {t('profile.voiceCoaching')}
updateSettings({ voiceCoaching: v })}
@@ -263,10 +232,10 @@ export default function ProfileScreen() {
{/* ════════════════════════════════════════════════════════════════════
NOTIFICATIONS
═══════════════════════════════════════════════════════════════════ */}
- {t('profile.sectionNotifications')}
+ {t('profile.sectionNotifications')}
- {t('profile.dailyReminders')}
+ {t('profile.dailyReminders')}
{settings.reminders && (
- {t('profile.reminderTime')}
- {settings.reminderTime}
+ {t('profile.reminderTime')}
+ {settings.reminderTime}
)}
@@ -287,18 +256,18 @@ export default function ProfileScreen() {
═══════════════════════════════════════════════════════════════════ */}
{isPremium && (
<>
- Personalization
+ {t('profile.sectionPersonalization')}
-
- {profile.syncStatus === 'synced' ? 'Personalization Enabled' : 'Generic Programs'}
-
-
+ {profile.syncStatus === 'synced' ? t('profile.personalizationEnabled') : t('profile.personalizationDisabled')}
+
+
{profile.syncStatus === 'synced' ? '✓' : '○'}
-
+
>
@@ -307,28 +276,28 @@ export default function ProfileScreen() {
{/* ════════════════════════════════════════════════════════════════════
ABOUT
═══════════════════════════════════════════════════════════════════ */}
- {t('profile.sectionAbout')}
+ {t('profile.sectionAbout')}
- {t('profile.version')}
- {appVersion}
+ {t('profile.version')}
+ {appVersion}
-
- {t('profile.rateApp')}
- ›
-
-
- {t('profile.contactUs')}
- ›
-
-
- {t('profile.faq')}
- ›
-
-
- {t('profile.privacyPolicy')}
- ›
-
+
+ {t('profile.rateApp')}
+ ›
+
+
+ {t('profile.contactUs')}
+ ›
+
+
+ {t('profile.faq')}
+ ›
+
+
+ {t('profile.privacyPolicy')}
+ ›
+
{/* ════════════════════════════════════════════════════════════════════
@@ -336,12 +305,12 @@ export default function ProfileScreen() {
═══════════════════════════════════════════════════════════════════ */}
{isPremium && (
<>
- {t('profile.sectionAccount')}
+ {t('profile.sectionAccount')}
-
- {t('profile.restorePurchases')}
- ›
-
+
+ {t('profile.restorePurchases')}
+ ›
+
>
)}
@@ -350,9 +319,9 @@ export default function ProfileScreen() {
SIGN OUT
═══════════════════════════════════════════════════════════════════ */}
-
- {t('profile.signOut')}
-
+
+ {t('profile.signOut')}
+
@@ -383,10 +352,10 @@ function createStyles(colors: ThemeColors) {
flexGrow: 1,
},
section: {
- marginHorizontal: 16,
- marginTop: 20,
+ marginHorizontal: SPACING[4],
+ marginTop: SPACING[5],
backgroundColor: colors.bg.surface,
- borderRadius: 10,
+ borderRadius: RADIUS.MD,
overflow: 'hidden',
},
sectionHeader: {
@@ -394,14 +363,14 @@ function createStyles(colors: ThemeColors) {
fontWeight: '600',
color: colors.text.tertiary,
textTransform: 'uppercase',
- marginLeft: 32,
- marginTop: 20,
- marginBottom: 8,
+ marginLeft: SPACING[8],
+ marginTop: SPACING[5],
+ marginBottom: SPACING[2],
},
headerContainer: {
alignItems: 'center',
- paddingVertical: 24,
- paddingHorizontal: 16,
+ paddingVertical: SPACING[6],
+ paddingHorizontal: SPACING[4],
},
avatarContainer: {
width: 90,
@@ -410,44 +379,40 @@ function createStyles(colors: ThemeColors) {
backgroundColor: BRAND.PRIMARY,
justifyContent: 'center',
alignItems: 'center',
- shadowColor: BRAND.PRIMARY,
- shadowOffset: { width: 0, height: 4 },
- shadowOpacity: 0.5,
- shadowRadius: 20,
- elevation: 10,
+ boxShadow: `0 4px 20px ${BRAND.PRIMARY}80`,
},
nameContainer: {
- marginTop: 16,
+ marginTop: SPACING[4],
alignItems: 'center',
},
planContainer: {
flexDirection: 'row',
alignItems: 'center',
- marginTop: 4,
- gap: 4,
+ marginTop: SPACING[1],
+ gap: SPACING[1],
},
statsContainer: {
flexDirection: 'row',
justifyContent: 'center',
- marginTop: 16,
- gap: 32,
+ marginTop: SPACING[4],
+ gap: SPACING[8],
},
statItem: {
alignItems: 'center',
},
premiumContainer: {
- paddingVertical: 16,
- paddingHorizontal: 16,
+ paddingVertical: SPACING[4],
+ paddingHorizontal: SPACING[4],
},
premiumContent: {
- gap: 4,
+ gap: SPACING[1],
},
row: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
- paddingVertical: 12,
- paddingHorizontal: 16,
+ paddingVertical: SPACING[3],
+ paddingHorizontal: SPACING[4],
borderBottomWidth: 0.5,
borderBottomColor: colors.border.glassLight,
},
@@ -466,13 +431,13 @@ function createStyles(colors: ThemeColors) {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
- paddingVertical: 12,
- paddingHorizontal: 16,
+ paddingVertical: SPACING[3],
+ paddingHorizontal: SPACING[4],
borderTopWidth: 0.5,
borderTopColor: colors.border.glassLight,
},
button: {
- paddingVertical: 14,
+ paddingVertical: SPACING[3] + 2,
alignItems: 'center',
},
destructive: {
@@ -480,7 +445,7 @@ function createStyles(colors: ThemeColors) {
color: BRAND.DANGER,
},
signOutSection: {
- marginTop: 20,
+ marginTop: SPACING[5],
},
})
}
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 72eb4ed..7eb5a92 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -153,6 +153,15 @@ function RootLayoutInner() {
animation: 'fade',
}}
/>
+
diff --git a/app/assessment.tsx b/app/assessment.tsx
index 5de353d..bd8984b 100644
--- a/app/assessment.tsx
+++ b/app/assessment.tsx
@@ -7,7 +7,7 @@ import { View, StyleSheet, ScrollView, Pressable } from 'react-native'
import { useRouter } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { LinearGradient } from 'expo-linear-gradient'
-import Ionicons from '@expo/vector-icons/Ionicons'
+import { Icon } from '@/src/shared/components/Icon'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -68,7 +68,7 @@ export default function AssessmentScreen() {
setShowIntro(true)}>
-
+
{t('assessment.title')}
@@ -105,11 +105,11 @@ export default function AssessmentScreen() {
{t('assessment.tips')}
- {ASSESSMENT_WORKOUT.tips.map((tip, index) => (
+ {[1, 2, 3, 4].map((index) => (
-
+
- {tip}
+ {t(`assessment.tip${index}`)}
))}
@@ -126,7 +126,7 @@ export default function AssessmentScreen() {
{t('assessment.startAssessment')}
-
+
@@ -139,7 +139,7 @@ export default function AssessmentScreen() {
{/* Header */}
-
+
@@ -152,7 +152,7 @@ export default function AssessmentScreen() {
{/* Hero */}
-
+
@@ -168,7 +168,7 @@ export default function AssessmentScreen() {
-
+
@@ -182,7 +182,7 @@ export default function AssessmentScreen() {
-
+
@@ -196,7 +196,7 @@ export default function AssessmentScreen() {
-
+
@@ -250,7 +250,7 @@ export default function AssessmentScreen() {
{t('assessment.takeAssessment')}
-
+
diff --git a/app/complete/[id].tsx b/app/complete/[id].tsx
index 427e2a8..d77ace0 100644
--- a/app/complete/[id].tsx
+++ b/app/complete/[id].tsx
@@ -17,7 +17,7 @@ import { useRouter, useLocalSearchParams } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { LinearGradient } from 'expo-linear-gradient'
import { BlurView } from 'expo-blur'
-import Ionicons from '@expo/vector-icons/Ionicons'
+import { Icon, type IconName } from '@/src/shared/components/Icon'
import * as Sharing from 'expo-sharing'
import { useTranslation } from 'react-i18next'
@@ -50,7 +50,7 @@ function SecondaryButton({
}: {
onPress: () => void
children: React.ReactNode
- icon?: keyof typeof Ionicons.glyphMap
+ icon?: IconName
}) {
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
@@ -80,7 +80,7 @@ function SecondaryButton({
style={{ width: '100%' }}
>
- {icon && }
+ {icon && }
{children}
@@ -194,7 +194,7 @@ function StatCard({
}: {
value: string | number
label: string
- icon: keyof typeof Ionicons.glyphMap
+ icon: IconName
delay?: number
}) {
const colors = useThemeColors()
@@ -215,7 +215,7 @@ function StatCard({
return (
-
+
{value}
{label}
@@ -306,6 +306,11 @@ export default function WorkoutCompleteScreen() {
router.push(`/workout/${workoutId}`)
}
+ // Fire celebration haptic on mount
+ useEffect(() => {
+ haptics.workoutComplete()
+ }, [])
+
// Check if we should show sync prompt (after first workout for premium users)
useEffect(() => {
if (profile.syncStatus === 'prompt-pending') {
@@ -373,9 +378,9 @@ export default function WorkoutCompleteScreen() {
{/* Stats Grid */}
-
-
-
+
+
+
{/* Burn Bar */}
@@ -386,7 +391,7 @@ export default function WorkoutCompleteScreen() {
{/* Streak */}
-
+
{t('screens:complete.streakTitle', { count: streak.current })}
@@ -398,7 +403,7 @@ export default function WorkoutCompleteScreen() {
{/* Share Button */}
-
+
{t('screens:complete.shareWorkout')}
@@ -421,7 +426,7 @@ export default function WorkoutCompleteScreen() {
colors={[BRAND.PRIMARY, BRAND.PRIMARY_LIGHT]}
style={StyleSheet.absoluteFill}
/>
-
+
{w.title}
{t('units.minUnit', { count: w.duration })}
diff --git a/app/explore-filters.tsx b/app/explore-filters.tsx
new file mode 100644
index 0000000..c66900b
--- /dev/null
+++ b/app/explore-filters.tsx
@@ -0,0 +1,222 @@
+/**
+ * TabataFit Explore Filters Sheet
+ * Form-sheet modal for Level + Equipment filter selection.
+ * Reads/writes from useExploreFilterStore.
+ */
+
+import { useCallback } from 'react'
+import {
+ View,
+ StyleSheet,
+ Pressable,
+ Text,
+} from 'react-native'
+import { useRouter } from 'expo-router'
+import { useSafeAreaInsets } from 'react-native-safe-area-context'
+import { Icon } from '@/src/shared/components/Icon'
+import { useTranslation } from 'react-i18next'
+
+import { useHaptics } from '@/src/shared/hooks'
+import { useExploreFilterStore } from '@/src/shared/stores'
+import { StyledText } from '@/src/shared/components/StyledText'
+import { useThemeColors, BRAND } from '@/src/shared/theme'
+import type { ThemeColors } from '@/src/shared/theme/types'
+import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
+import { RADIUS } from '@/src/shared/constants/borderRadius'
+import type { WorkoutLevel } from '@/src/shared/types'
+
+// ═══════════════════════════════════════════════════════════════════════════
+// CONSTANTS
+// ═══════════════════════════════════════════════════════════════════════════
+
+const ALL_LEVELS: (WorkoutLevel | 'all')[] = ['all', 'Beginner', 'Intermediate', 'Advanced']
+
+const LEVEL_TRANSLATION_KEYS: Record = {
+ all: 'all',
+ Beginner: 'beginner',
+ Intermediate: 'intermediate',
+ Advanced: 'advanced',
+}
+
+const EQUIPMENT_TRANSLATION_KEYS: Record = {
+ none: 'none',
+ dumbbells: 'dumbbells',
+ band: 'band',
+ mat: 'mat',
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// CHOICE CHIP
+// ═══════════════════════════════════════════════════════════════════════════
+
+function ChoiceChip({
+ label,
+ isSelected,
+ onPress,
+ colors,
+}: {
+ label: string
+ isSelected: boolean
+ onPress: () => void
+ colors: ThemeColors
+}) {
+ const haptics = useHaptics()
+
+ return (
+ {
+ haptics.selection()
+ onPress()
+ }}
+ >
+ {isSelected && (
+
+ )}
+
+ {label}
+
+
+ )
+}
+
+const chipStyles = StyleSheet.create({
+ chip: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: SPACING[4],
+ paddingVertical: SPACING[3],
+ borderRadius: RADIUS.LG,
+ borderWidth: 1,
+ borderCurve: 'continuous',
+ marginRight: SPACING[2],
+ marginBottom: SPACING[2],
+ },
+})
+
+// ═══════════════════════════════════════════════════════════════════════════
+// MAIN SCREEN
+// ═══════════════════════════════════════════════════════════════════════════
+
+export default function ExploreFiltersScreen() {
+ const { t } = useTranslation()
+ const router = useRouter()
+ const haptics = useHaptics()
+ const colors = useThemeColors()
+ const insets = useSafeAreaInsets()
+
+ // ── Store state ────────────────────────────────────────────────────────
+ const level = useExploreFilterStore((s) => s.level)
+ const equipment = useExploreFilterStore((s) => s.equipment)
+ const equipmentOptions = useExploreFilterStore((s) => s.equipmentOptions)
+ const setLevel = useExploreFilterStore((s) => s.setLevel)
+ const setEquipment = useExploreFilterStore((s) => s.setEquipment)
+ const resetFilters = useExploreFilterStore((s) => s.resetFilters)
+
+ const hasActiveFilters = level !== 'all' || equipment !== 'all'
+
+ // ── Handlers ───────────────────────────────────────────────────────────
+ const handleReset = useCallback(() => {
+ haptics.selection()
+ resetFilters()
+ }, [haptics, resetFilters])
+
+ // ── Equipment label helper ─────────────────────────────────────────────
+ const getEquipmentLabel = useCallback(
+ (equip: string) => {
+ if (equip === 'all') return t('screens:explore.allEquipment')
+ const key = EQUIPMENT_TRANSLATION_KEYS[equip]
+ if (key) return t(`screens:explore.equipmentOptions.${key}`)
+ return equip.charAt(0).toUpperCase() + equip.slice(1)
+ },
+ [t]
+ )
+
+ // ── Level label helper ─────────────────────────────────────────────────
+ const getLevelLabel = useCallback(
+ (lvl: WorkoutLevel | 'all') => {
+ if (lvl === 'all') return t('common:categories.all')
+ const key = LEVEL_TRANSLATION_KEYS[lvl]
+ return t(`common:levels.${key}`)
+ },
+ [t]
+ )
+
+ return (
+
+ {/* ── Title row ─────────────────────────────────────────────── */}
+
+
+ {t('screens:explore.filters')}
+
+ {hasActiveFilters && (
+
+
+ {t('screens:explore.resetFilters')}
+
+
+ )}
+
+
+ {/* ── Filter sections ───────────────────────────────────────── */}
+
+ {/* Level */}
+
+ {t('screens:explore.filterLevel').toUpperCase()}
+
+
+ {ALL_LEVELS.map((lvl) => (
+ setLevel(lvl)}
+ colors={colors}
+ />
+ ))}
+
+
+ {/* Equipment */}
+
+ {t('screens:explore.filterEquipment').toUpperCase()}
+
+
+ {equipmentOptions.map((equip) => (
+ setEquipment(equip)}
+ colors={colors}
+ />
+ ))}
+
+
+
+ {/* ── Apply Button ──────────────────────────────────────────── */}
+
+ {
+ haptics.buttonTap()
+ router.back()
+ }}
+ >
+
+ {t('screens:explore.applyFilters')}
+
+
+
+
+ )
+}
diff --git a/app/onboarding.tsx b/app/onboarding.tsx
index 312c2d8..f4fd2cf 100644
--- a/app/onboarding.tsx
+++ b/app/onboarding.tsx
@@ -15,7 +15,7 @@ import {
} from 'react-native'
import { useRouter } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
-import Ionicons from '@expo/vector-icons/Ionicons'
+import { Icon } from '@/src/shared/components/Icon'
import { Alert } from 'react-native'
import { useTranslation } from 'react-i18next'
@@ -85,7 +85,7 @@ function ProblemScreen({ onNext }: { onNext: () => void }) {
marginBottom: SPACING[8],
}}
>
-
+
@@ -136,10 +136,10 @@ function ProblemScreen({ onNext }: { onNext: () => void }) {
// ═══════════════════════════════════════════════════════════════════════════
const BARRIERS = [
- { id: 'no-time', labelKey: 'onboarding.empathy.noTime' as const, icon: 'time-outline' as const },
- { id: 'low-motivation', labelKey: 'onboarding.empathy.lowMotivation' as const, icon: 'battery-dead-outline' as const },
- { id: 'no-knowledge', labelKey: 'onboarding.empathy.noKnowledge' as const, icon: 'help-circle-outline' as const },
- { id: 'no-gym', labelKey: 'onboarding.empathy.noGym' as const, icon: 'home-outline' as const },
+ { id: 'no-time', labelKey: 'onboarding.empathy.noTime' as const, icon: 'clock' as const },
+ { id: 'low-motivation', labelKey: 'onboarding.empathy.lowMotivation' as const, icon: 'battery.0percent' as const },
+ { id: 'no-knowledge', labelKey: 'onboarding.empathy.noKnowledge' as const, icon: 'questionmark.circle' as const },
+ { id: 'no-gym', labelKey: 'onboarding.empathy.noGym' as const, icon: 'house' as const },
]
function EmpathyScreen({
@@ -187,7 +187,7 @@ function EmpathyScreen({
]}
onPress={() => toggleBarrier(item.id)}
>
- void }) {
// ═══════════════════════════════════════════════════════════════════════════
const WOW_FEATURES = [
- { icon: 'timer-outline' as const, iconColor: BRAND.PRIMARY, titleKey: 'onboarding.wow.card1Title', subtitleKey: 'onboarding.wow.card1Subtitle' },
- { icon: 'barbell-outline' as const, iconColor: PHASE.REST, titleKey: 'onboarding.wow.card2Title', subtitleKey: 'onboarding.wow.card2Subtitle' },
- { icon: 'mic-outline' as const, iconColor: PHASE.PREP, titleKey: 'onboarding.wow.card3Title', subtitleKey: 'onboarding.wow.card3Subtitle' },
- { icon: 'trending-up-outline' as const, iconColor: PHASE.COMPLETE, titleKey: 'onboarding.wow.card4Title', subtitleKey: 'onboarding.wow.card4Subtitle' },
+ { icon: 'timer' as const, iconColor: BRAND.PRIMARY, titleKey: 'onboarding.wow.card1Title', subtitleKey: 'onboarding.wow.card1Subtitle' },
+ { icon: 'dumbbell' as const, iconColor: PHASE.REST, titleKey: 'onboarding.wow.card2Title', subtitleKey: 'onboarding.wow.card2Subtitle' },
+ { icon: 'mic' as const, iconColor: PHASE.PREP, titleKey: 'onboarding.wow.card3Title', subtitleKey: 'onboarding.wow.card3Subtitle' },
+ { icon: 'arrow.up.right' as const, iconColor: PHASE.COMPLETE, titleKey: 'onboarding.wow.card4Title', subtitleKey: 'onboarding.wow.card4Subtitle' },
] as const
function WowScreen({ onNext }: { onNext: () => void }) {
@@ -453,7 +453,7 @@ function WowScreen({ onNext }: { onNext: () => void }) {
]}
>
-
+
@@ -822,7 +822,7 @@ function PaywallScreen({
key={featureKey}
style={[styles.featureRow, { opacity: featureAnims[i] }]}
>
-
+
{
+ if (step > 1) {
+ const prev = step - 1
+ stepStartTime.current = Date.now()
+ track('onboarding_step_back', { from_step: step, to_step: prev })
+ setStep(prev)
+ }
+ }, [step])
+
const renderStep = () => {
switch (step) {
case 1:
@@ -1079,7 +1088,7 @@ export default function OnboardingScreen() {
}
return (
-
+
{renderStep()}
)
diff --git a/app/paywall.tsx b/app/paywall.tsx
index 66c3d44..18efbd0 100644
--- a/app/paywall.tsx
+++ b/app/paywall.tsx
@@ -9,15 +9,15 @@ import {
StyleSheet,
ScrollView,
Pressable,
- Text,
} from 'react-native'
import { useRouter } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { LinearGradient } from 'expo-linear-gradient'
-import Ionicons from '@expo/vector-icons/Ionicons'
+import { Icon, type IconName } from '@/src/shared/components/Icon'
import { useTranslation } from 'react-i18next'
import { useHaptics, usePurchases } from '@/src/shared/hooks'
+import { StyledText } from '@/src/shared/components/StyledText'
import { useThemeColors, BRAND, GRADIENTS } from '@/src/shared/theme'
import type { ThemeColors } from '@/src/shared/theme/types'
import { SPACING } from '@/src/shared/constants/spacing'
@@ -27,13 +27,13 @@ import { RADIUS } from '@/src/shared/constants/borderRadius'
// FEATURES LIST
// ═══════════════════════════════════════════════════════════════════════════
-const PREMIUM_FEATURES = [
- { icon: 'musical-notes', key: 'music' },
+const PREMIUM_FEATURES: { icon: IconName; key: string }[] = [
+ { icon: 'music.note.list', key: 'music' },
{ icon: 'infinity', key: 'workouts' },
- { icon: 'stats-chart', key: 'stats' },
- { icon: 'flame', key: 'calories' },
- { icon: 'notifications', key: 'reminders' },
- { icon: 'close-circle', key: 'ads' },
+ { icon: 'chart.bar.fill', key: 'stats' },
+ { icon: 'flame.fill', key: 'calories' },
+ { icon: 'bell.fill', key: 'reminders' },
+ { icon: 'xmark.circle.fill', key: 'ads' },
]
// ═══════════════════════════════════════════════════════════════════════════
@@ -93,23 +93,23 @@ function PlanCard({
>
{savings && (
- {savings}
+ {savings}
)}
-
+
{title}
-
-
+
+
{period}
-
+
-
+
{price}
-
+
{isSelected && (
-
+
)}
@@ -196,7 +196,7 @@ export default function PaywallScreen() {
{/* Close Button */}
-
+
{/* Header */}
- TabataFit+
- {t('paywall.subtitle')}
+
+ TabataFit+
+
+
+ {t('paywall.subtitle')}
+
{/* Features Grid */}
@@ -218,11 +222,11 @@ export default function PaywallScreen() {
{PREMIUM_FEATURES.map((feature) => (
-
+
-
+
{t(`paywall.features.${feature.key}`)}
-
+
))}
@@ -252,9 +256,9 @@ export default function PaywallScreen() {
{/* Price Note */}
{selectedPlan === 'annual' && (
-
+
{t('paywall.equivalent', { price: annualMonthlyEquivalent })}
-
+
)}
{/* CTA Button */}
@@ -269,23 +273,23 @@ export default function PaywallScreen() {
end={{ x: 1, y: 1 }}
style={styles.ctaGradient}
>
-
- {isLoading ? t('paywall.processing') : t('paywall.subscribe')}
-
+
+ {isLoading ? t('paywall.processing') : t('paywall.trialCta')}
+
{/* Restore & Terms */}
-
+
{t('paywall.restore')}
-
+
-
+
{t('paywall.terms')}
-
+
diff --git a/app/player/[id].tsx b/app/player/[id].tsx
index e9b3616..7e54405 100644
--- a/app/player/[id].tsx
+++ b/app/player/[id].tsx
@@ -22,7 +22,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { LinearGradient } from 'expo-linear-gradient'
import { BlurView } from 'expo-blur'
import { useKeepAwake } from 'expo-keep-awake'
-import Ionicons from '@expo/vector-icons/Ionicons'
+import { Icon, type IconName } from '@/src/shared/components/Icon'
import { useTranslation } from 'react-i18next'
@@ -187,7 +187,7 @@ function ControlButton({
size = 64,
variant = 'primary',
}: {
- icon: keyof typeof Ionicons.glyphMap
+ icon: IconName
onPress: () => void
size?: number
variant?: 'primary' | 'secondary' | 'danger'
@@ -228,7 +228,7 @@ function ControlButton({
style={[timerStyles.controlButton, { width: size, height: size, borderRadius: size / 2 }]}
>
-
+
)
@@ -423,10 +423,11 @@ export default function PlayerScreen() {
}
}, [timer.phase])
- // Countdown beep for last 3 seconds
+ // Countdown beep + haptic for last 3 seconds
useEffect(() => {
if (timer.isRunning && timer.timeRemaining <= 3 && timer.timeRemaining > 0) {
audio.countdownBeep()
+ haptics.countdownTick()
}
}, [timer.timeRemaining])
@@ -481,7 +482,7 @@ export default function PlayerScreen() {
-
+
{workout?.title ?? 'Workout'}
@@ -541,22 +542,22 @@ export default function PlayerScreen() {
{showControls && !timer.isComplete && (
{!timer.isRunning ? (
-
+
) : (
- Done
+ {t('common:done')}
)}
diff --git a/app/privacy.tsx b/app/privacy.tsx
index 6cfea83..3213858 100644
--- a/app/privacy.tsx
+++ b/app/privacy.tsx
@@ -7,7 +7,7 @@ import React from 'react'
import { View, ScrollView, StyleSheet, Text, Pressable } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
-import Ionicons from '@expo/vector-icons/Ionicons'
+import { Icon } from '@/src/shared/components/Icon'
import { useTranslation } from 'react-i18next'
import { useHaptics } from '@/src/shared/hooks'
@@ -30,7 +30,7 @@ export default function PrivacyPolicyScreen() {
{/* Header */}
-
+
{t('privacy.title')}
diff --git a/app/program/[id].tsx b/app/program/[id].tsx
index 994b948..f3d52a2 100644
--- a/app/program/[id].tsx
+++ b/app/program/[id].tsx
@@ -7,7 +7,7 @@ import { View, StyleSheet, ScrollView, Pressable } from 'react-native'
import { useRouter, useLocalSearchParams } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { LinearGradient } from 'expo-linear-gradient'
-import Ionicons from '@expo/vector-icons/Ionicons'
+import { Icon } from '@/src/shared/components/Icon'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@@ -76,7 +76,7 @@ export default function ProgramDetailScreen() {
{/* Header */}
router.back()}>
-
+
{program.title}
@@ -215,7 +215,7 @@ export default function ProgramDetailScreen() {
{week.title}
{!isUnlocked && (
-
+
)}
{isCurrentWeek && isUnlocked && (
@@ -257,9 +257,9 @@ export default function ProgramDetailScreen() {
>
{isCompleted ? (
-
+
) : isLocked ? (
-
+
) : (
{index + 1}
@@ -280,7 +280,7 @@ export default function ProgramDetailScreen() {
{!isLocked && !isCompleted && (
-
+
)}
)
@@ -308,7 +308,7 @@ export default function ProgramDetailScreen() {
: t('programs.continueTraining')
}
-
+
diff --git a/package-lock.json b/package-lock.json
index fc6bac7..becd202 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,7 +10,6 @@
"dependencies": {
"@expo-google-fonts/inter": "^0.4.2",
"@expo/ui": "~0.2.0-beta.9",
- "@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
@@ -43,7 +42,6 @@
"expo-video": "~3.0.16",
"expo-web-browser": "~15.0.10",
"i18next": "^25.8.12",
- "lucide-react": "^0.576.0",
"posthog-react-native": "^4.36.0",
"posthog-react-native-session-replay": "^1.5.0",
"react": "19.1.0",
@@ -131,20 +129,6 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
- "node_modules/@asamuzakjp/dom-selector/node_modules/css-tree": {
- "version": "3.2.1",
- "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
- "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "mdn-data": "2.27.1",
- "source-map-js": "^1.2.1"
- },
- "engines": {
- "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
- }
- },
"node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
"version": "11.2.7",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
@@ -155,13 +139,6 @@
"node": "20 || >=22"
}
},
- "node_modules/@asamuzakjp/dom-selector/node_modules/mdn-data": {
- "version": "2.27.1",
- "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
- "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
- "dev": true,
- "license": "CC0-1.0"
- },
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
@@ -1669,27 +1646,6 @@
"specificity": "bin/cli.js"
}
},
- "node_modules/@bramus/specificity/node_modules/css-tree": {
- "version": "3.2.1",
- "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
- "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "mdn-data": "2.27.1",
- "source-map-js": "^1.2.1"
- },
- "engines": {
- "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
- }
- },
- "node_modules/@bramus/specificity/node_modules/mdn-data": {
- "version": "2.27.1",
- "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
- "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
- "dev": true,
- "license": "CC0-1.0"
- },
"node_modules/@csstools/color-helpers": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
@@ -2759,7 +2715,7 @@
"version": "30.3.0",
"resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.3.0.tgz",
"integrity": "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
@@ -2801,7 +2757,7 @@
"version": "30.1.0",
"resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz",
"integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
@@ -4025,7 +3981,7 @@
"version": "13.3.3",
"resolved": "https://registry.npmjs.org/@testing-library/react-native/-/react-native-13.3.3.tgz",
"integrity": "sha512-k6Mjsd9dbZgvY4Bl7P1NIpePQNi+dfYtlJ5voi9KQlynxSyQkfOgJmYGCYmw/aSgH/rUcFvG8u5gd4npzgRDyg==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"jest-matcher-utils": "^30.0.5",
@@ -4052,7 +4008,7 @@
"version": "30.0.5",
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz",
"integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"@sinclair/typebox": "^0.34.0"
@@ -4065,14 +4021,14 @@
"version": "0.34.48",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz",
"integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
"node_modules/@testing-library/react-native/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -4085,7 +4041,7 @@
"version": "30.3.0",
"resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.3.0.tgz",
"integrity": "sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"@jest/diff-sequences": "30.3.0",
@@ -4101,7 +4057,7 @@
"version": "30.3.0",
"resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.3.0.tgz",
"integrity": "sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"@jest/get-type": "30.1.0",
@@ -4117,7 +4073,7 @@
"version": "30.3.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz",
"integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"@jest/schemas": "30.0.5",
@@ -4132,7 +4088,7 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
@@ -4284,7 +4240,7 @@
"version": "19.1.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz",
"integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@@ -6325,25 +6281,17 @@
}
},
"node_modules/css-tree": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
- "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==",
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+ "dev": true,
"license": "MIT",
"dependencies": {
- "mdn-data": "2.0.14",
- "source-map": "^0.6.1"
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
},
"engines": {
- "node": ">=8.0.0"
- }
- },
- "node_modules/css-tree/node_modules/source-map": {
- "version": "0.6.1",
- "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
- "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
- "license": "BSD-3-Clause",
- "engines": {
- "node": ">=0.10.0"
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/css-what": {
@@ -6362,7 +6310,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
"node_modules/data-urls": {
@@ -9150,7 +9098,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
"integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -10087,20 +10035,6 @@
}
}
},
- "node_modules/jsdom/node_modules/css-tree": {
- "version": "3.2.1",
- "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
- "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "mdn-data": "2.27.1",
- "source-map-js": "^1.2.1"
- },
- "engines": {
- "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
- }
- },
"node_modules/jsdom/node_modules/lru-cache": {
"version": "11.2.7",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
@@ -10111,13 +10045,6 @@
"node": "20 || >=22"
}
},
- "node_modules/jsdom/node_modules/mdn-data": {
- "version": "2.27.1",
- "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
- "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
- "dev": true,
- "license": "CC0-1.0"
- },
"node_modules/jsdom/node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
@@ -10697,15 +10624,6 @@
"yallist": "^3.0.2"
}
},
- "node_modules/lucide-react": {
- "version": "0.576.0",
- "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.576.0.tgz",
- "integrity": "sha512-koNxU14BXrxUfZQ9cUaP0ES1uyPZKYDjk31FQZB6dQ/x+tXk979sVAn9ppZ/pVeJJyOxVM8j1E+8QEuSc02Vug==",
- "license": "ISC",
- "peerDependencies": {
- "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
- }
- },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -10782,9 +10700,10 @@
}
},
"node_modules/mdn-data": {
- "version": "2.0.14",
- "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
- "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+ "dev": true,
"license": "CC0-1.0"
},
"node_modules/memoize-one": {
@@ -11169,7 +11088,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"engines": {
"node": ">=4"
@@ -12550,6 +12469,34 @@
"react-native": "*"
}
},
+ "node_modules/react-native-svg/node_modules/css-tree": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
+ "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==",
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.0.14",
+ "source-map": "^0.6.1"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/react-native-svg/node_modules/mdn-data": {
+ "version": "2.0.14",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
+ "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
+ "license": "CC0-1.0"
+ },
+ "node_modules/react-native-svg/node_modules/source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/react-native-web": {
"version": "0.21.2",
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
@@ -12774,7 +12721,7 @@
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-19.1.0.tgz",
"integrity": "sha512-jXkSl3CpvPYEF+p/eGDLB4sPoDX8pKkYvRl9+rR8HxLY0X04vW7hCm1/0zHoUSjPZ3bDa+wXWNTDVIw/R8aDVw==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"react-is": "^19.1.0",
@@ -12788,7 +12735,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"indent-string": "^4.0.0",
@@ -13848,7 +13795,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
"integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"min-indent": "^1.0.0"
@@ -14395,7 +14342,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
- "dev": true,
+ "devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
diff --git a/package.json b/package.json
index 0e84dbf..0b52886 100644
--- a/package.json
+++ b/package.json
@@ -27,7 +27,6 @@
"dependencies": {
"@expo-google-fonts/inter": "^0.4.2",
"@expo/ui": "~0.2.0-beta.9",
- "@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
@@ -60,7 +59,6 @@
"expo-video": "~3.0.16",
"expo-web-browser": "~15.0.10",
"i18next": "^25.8.12",
- "lucide-react": "^0.576.0",
"posthog-react-native": "^4.36.0",
"posthog-react-native-session-replay": "^1.5.0",
"react": "19.1.0",
diff --git a/src/__tests__/components/rendering/__snapshots__/DataDeletionModal.test.tsx.snap b/src/__tests__/components/rendering/__snapshots__/DataDeletionModal.test.tsx.snap
deleted file mode 100644
index 7654cd3..0000000
--- a/src/__tests__/components/rendering/__snapshots__/DataDeletionModal.test.tsx.snap
+++ /dev/null
@@ -1,190 +0,0 @@
-// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-
-exports[`DataDeletionModal > full modal structure snapshot 1`] = `
-
-
-
-
-
-
-
- dataDeletion.title
-
-
- dataDeletion.description
-
-
- dataDeletion.note
-
-
-
- dataDeletion.deleteButton
-
-
-
-
- dataDeletion.cancelButton
-
-
-
-
-
-`;
diff --git a/src/__tests__/components/rendering/__snapshots__/SyncConsentModal.test.tsx.snap b/src/__tests__/components/rendering/__snapshots__/SyncConsentModal.test.tsx.snap
deleted file mode 100644
index c5e8a77..0000000
--- a/src/__tests__/components/rendering/__snapshots__/SyncConsentModal.test.tsx.snap
+++ /dev/null
@@ -1,318 +0,0 @@
-// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-
-exports[`SyncConsentModal > full modal structure snapshot 1`] = `
-
-
-
-
-
-
-
- sync.title
-
-
-
-
-
- sync.benefits.recommendations
-
-
-
-
-
- sync.benefits.adaptive
-
-
-
-
-
- sync.benefits.sync
-
-
-
-
-
- sync.benefits.secure
-
-
-
-
- sync.privacy
-
-
-
- sync.primaryButton
-
-
-
-
- sync.secondaryButton
-
-
-
-
-
-`;
diff --git a/src/__tests__/data/collections.test.ts b/src/__tests__/data/collections.test.ts
deleted file mode 100644
index 835b7c8..0000000
--- a/src/__tests__/data/collections.test.ts
+++ /dev/null
@@ -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)
- })
- })
- })
- })
-})
diff --git a/src/__tests__/data/dataService.test.ts b/src/__tests__/data/dataService.test.ts
index 93c1da3..3a45c8a 100644
--- a/src/__tests__/data/dataService.test.ts
+++ b/src/__tests__/data/dataService.test.ts
@@ -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 () => {
diff --git a/src/__tests__/hooks/usePurchases.test.ts b/src/__tests__/hooks/usePurchases.test.ts
index 0c28678..76e05db 100644
--- a/src/__tests__/hooks/usePurchases.test.ts
+++ b/src/__tests__/hooks/usePurchases.test.ts
@@ -82,6 +82,7 @@ describe('usePurchases', () => {
barriers: [],
syncStatus: 'never-synced',
supabaseUserId: null,
+ savedWorkouts: [],
},
})
})
diff --git a/src/__tests__/setup-render.tsx b/src/__tests__/setup-render.tsx
index 10e2185..58719a2 100644
--- a/src/__tests__/setup-render.tsx
+++ b/src/__tests__/setup-render.tsx
@@ -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}` })
},
}))
diff --git a/src/__tests__/stores/userStore.test.ts b/src/__tests__/stores/userStore.test.ts
index fa3ac08..379ebd6 100644
--- a/src/__tests__/stores/userStore.test.ts
+++ b/src/__tests__/stores/userStore.test.ts
@@ -17,6 +17,7 @@ describe('userStore', () => {
barriers: [],
syncStatus: 'never-synced',
supabaseUserId: null,
+ savedWorkouts: [],
},
settings: {
haptics: true,
diff --git a/src/shared/components/CollectionCard.tsx b/src/shared/components/CollectionCard.tsx
index dceee84..aea5d9f 100644
--- a/src/shared/components/CollectionCard.tsx
+++ b/src/shared/components/CollectionCard.tsx
@@ -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 (
-
+
{/* Background Image or Gradient */}
{imageUrl ? (
+ {variant === 'hero' && (
+
+ {collection.description}
+
+ )}
+
- {collection.workoutIds.length} workouts
+ {countLabel}
-
+
)
}
-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,
diff --git a/src/shared/components/DataDeletionModal.tsx b/src/shared/components/DataDeletionModal.tsx
index 1e17e64..b5cef67 100644
--- a/src/shared/components/DataDeletionModal.tsx
+++ b/src/shared/components/DataDeletionModal.tsx
@@ -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)' },
]}
>
-
+
{/* Title */}
diff --git a/src/shared/components/Icon.tsx b/src/shared/components/Icon.tsx
new file mode 100644
index 0000000..6112cda
--- /dev/null
+++ b/src/shared/components/Icon.tsx
@@ -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
+}
+
+export function Icon({
+ name,
+ size = 24,
+ tintColor,
+ color,
+ weight,
+ type = 'monochrome',
+ animationSpec,
+ style,
+}: IconProps) {
+ return (
+
+ )
+}
diff --git a/src/shared/components/OnboardingStep.tsx b/src/shared/components/OnboardingStep.tsx
index c0c3aea..5d25445 100644
--- a/src/shared/components/OnboardingStep.tsx
+++ b/src/shared/components/OnboardingStep.tsx
@@ -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
+ {/* Back button — visible on steps 2+ */}
+ {onBack && step > 1 && (
+
+
+
+ )}
+
{/* Step content */}
{/* Icon */}
-
+
{/* Title */}
@@ -64,22 +64,22 @@ export function SyncConsentModal({
{/* Benefits */}
@@ -121,16 +121,16 @@ function BenefitRow({
text,
colors,
}: {
- icon: string
+ icon: IconName
text: string
colors: any
}) {
return (
-
void
title?: string
metadata?: string
+ trainerName?: string
+ isLocked?: boolean
}
-const CATEGORY_COLORS: Record = {
+export const CATEGORY_COLORS: Record = {
'full-body': BRAND.PRIMARY,
'core': '#5AC8FA',
'upper-body': '#BF5AF2',
@@ -52,11 +55,11 @@ const CATEGORY_LABELS: Record = {
'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 (
-
{/* Background Image */}
-
+
@@ -143,7 +175,7 @@ export function WorkoutCard({
{displayMetadata}
-
+
)
}
diff --git a/src/shared/data/collections.ts b/src/shared/data/collections.ts
index ba98315..66a76a5 100644
--- a/src/shared/data/collections.ts
+++ b/src/shared/data/collections.ts
@@ -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'
diff --git a/src/shared/data/dataService.ts b/src/shared/data/dataService.ts
index 97bcdd4..b27d797 100644
--- a/src/shared/data/dataService.ts
+++ b/src/shared/data/dataService.ts
@@ -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 {
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 = {}
@@ -240,12 +240,12 @@ class SupabaseDataService {
return collectionsData?.map((row: CollectionRow) =>
mapCollectionFromDB(row, workoutIdsByCollection[row.id] || [])
- ) ?? COLLECTIONS
+ ) ?? []
}
async getCollectionById(id: string): Promise {
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) || []
diff --git a/src/shared/data/index.ts b/src/shared/data/index.ts
index d33fbe7..34e8d3e 100644
--- a/src/shared/data/index.ts
+++ b/src/shared/data/index.ts
@@ -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'
diff --git a/src/shared/i18n/locales/de/screens.json b/src/shared/i18n/locales/de/screens.json
index 0445d25..05beb6a 100644
--- a/src/shared/i18n/locales/de/screens.json
+++ b/src/shared/i18n/locales/de/screens.json
@@ -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"
}
diff --git a/src/shared/i18n/locales/en/screens.json b/src/shared/i18n/locales/en/screens.json
index 67961a7..aac4e18 100644
--- a/src/shared/i18n/locales/en/screens.json
+++ b/src/shared/i18n/locales/en/screens.json
@@ -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"
}
diff --git a/src/shared/i18n/locales/es/screens.json b/src/shared/i18n/locales/es/screens.json
index f56c1ce..460498d 100644
--- a/src/shared/i18n/locales/es/screens.json
+++ b/src/shared/i18n/locales/es/screens.json
@@ -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"
}
}
diff --git a/src/shared/i18n/locales/fr/screens.json b/src/shared/i18n/locales/fr/screens.json
index 9f34485..490abcb 100644
--- a/src/shared/i18n/locales/fr/screens.json
+++ b/src/shared/i18n/locales/fr/screens.json
@@ -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"
}
diff --git a/src/shared/stores/exploreFilterStore.ts b/src/shared/stores/exploreFilterStore.ts
new file mode 100644
index 0000000..145b29f
--- /dev/null
+++ b/src/shared/stores/exploreFilterStore.ts
@@ -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()((set) => ({
+ level: 'all',
+ equipment: 'all',
+ equipmentOptions: [],
+ setLevel: (level) => set({ level }),
+ setEquipment: (equipment) => set({ equipment }),
+ setEquipmentOptions: (equipmentOptions) => set({ equipmentOptions }),
+ resetFilters: () => set({ level: 'all', equipment: 'all' }),
+}))
diff --git a/src/shared/stores/index.ts b/src/shared/stores/index.ts
index 9c75440..a20eb4d 100644
--- a/src/shared/stores/index.ts
+++ b/src/shared/stores/index.ts
@@ -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'
diff --git a/src/shared/stores/userStore.ts b/src/shared/stores/userStore.ts
index 8c0776e..2968718 100644
--- a/src/shared/stores/userStore.ts
+++ b/src/shared/stores/userStore.ts
@@ -29,11 +29,13 @@ interface OnboardingData {
interface UserState {
profile: UserProfile
settings: UserSettings
+ savedWorkouts: string[]
// Actions
updateProfile: (updates: Partial) => void
updateSettings: (updates: Partial) => 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()(
goal: 'cardio',
weeklyFrequency: 3,
barriers: [],
+ savedWorkouts: [],
// NEW: Sync fields
syncStatus: 'never-synced',
supabaseUserId: null,
@@ -69,6 +72,8 @@ export const useUserStore = create()(
reminderTime: '09:00',
},
+ savedWorkouts: [],
+
updateProfile: (updates) =>
set((state) => ({
profile: { ...state.profile, ...updates },
@@ -97,6 +102,13 @@ export const useUserStore = create()(
},
})),
+ 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) => ({
diff --git a/src/shared/types/user.ts b/src/shared/types/user.ts
index 7f56e6d..f7d24a2 100644
--- a/src/shared/types/user.ts
+++ b/src/shared/types/user.ts
@@ -30,6 +30,7 @@ export interface UserProfile {
goal: FitnessGoal
weeklyFrequency: WeeklyFrequency
barriers: string[]
+ savedWorkouts: string[]
syncStatus: SyncStatus
supabaseUserId: string | null
}
diff --git a/supabase/seed.ts b/supabase/seed.ts
index 4ef9d08..03f30d7 100644
--- a/supabase/seed.ts
+++ b/supabase/seed.ts
@@ -12,9 +12,59 @@
import { createClient } from '@supabase/supabase-js'
import { WORKOUTS } from '../src/shared/data/workouts'
import { TRAINERS } from '../src/shared/data/trainers'
-import { COLLECTIONS, PROGRAMS } from '../src/shared/data/collections'
+import { PROGRAMS } from '../src/shared/data/programs'
import { ACHIEVEMENTS } from '../src/shared/data/achievements'
+/**
+ * Seed data for collections — used only for initial database seeding.
+ * The app fetches collections from Supabase at runtime.
+ */
+const SEED_COLLECTIONS = [
+ {
+ 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'],
+ },
+]
+
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL
const supabaseKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY
@@ -73,7 +123,7 @@ async function seedWorkouts() {
async function seedCollections() {
console.log('Seeding collections...')
- const collections = COLLECTIONS.map(c => ({
+ const collections = SEED_COLLECTIONS.map(c => ({
id: c.id,
title: c.title,
description: c.description,
@@ -85,7 +135,7 @@ async function seedCollections() {
if (error) throw error
// Seed collection-workout relationships
- const collectionWorkouts = COLLECTIONS.flatMap(c =>
+ const collectionWorkouts = SEED_COLLECTIONS.flatMap(c =>
c.workoutIds.map((workoutId, index) => ({
collection_id: c.id,
workout_id: workoutId,
@@ -102,7 +152,7 @@ async function seedCollections() {
async function seedPrograms() {
console.log('Seeding programs...')
- const programs = PROGRAMS.map(p => ({
+ const programs = Object.values(PROGRAMS).map(p => ({
id: p.id,
title: p.title,
description: p.description,
@@ -117,7 +167,7 @@ async function seedPrograms() {
// Seed program-workout relationships
let programWorkouts: { program_id: string; workout_id: string; week_number: number; day_number: number }[] = []
- PROGRAMS.forEach(program => {
+ Object.values(PROGRAMS).forEach(program => {
let week = 1
let day = 1
program.workoutIds.forEach((workoutId, index) => {
diff --git a/vitest.config.ts b/vitest.config.ts
index 08f7329..c3fd501 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -35,7 +35,6 @@ export default defineConfig({
'src/shared/services/sync.ts': { lines: 80 },
'src/shared/services/analytics.ts': { lines: 80 },
'src/shared/data/achievements.ts': { lines: 100 },
- 'src/shared/data/collections.ts': { lines: 100 },
'src/shared/data/programs.ts': { lines: 90 },
'src/shared/data/trainers.ts': { lines: 100 },
'src/shared/data/workouts.ts': { lines: 100 },