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 ( - + {/* Track */} - - {/* Fill — simplified: show a colored ring proportional to progress */} - 0.25 ? color : 'transparent', - borderRightColor: progress > 0.5 ? color : 'transparent', - borderBottomColor: progress > 0.75 ? color : 'transparent', - borderLeftColor: progress > 0 ? color : 'transparent', - transform: [{ rotate: '-90deg' }], - opacity: progress > 0 ? 1 : 0.3, - }} + {/* Progress */} + 0 ? 1 : 0.3} /> - + ) } @@ -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 },