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

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

View File

@@ -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 |

View File

@@ -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 (
<View style={{ width: size, height: size, alignItems: 'center', justifyContent: 'center' }}>
<Svg width={size} height={size}>
{/* Track */}
<View
style={{
position: 'absolute',
width: size,
height: size,
borderRadius: size / 2,
borderWidth: strokeWidth,
borderColor: colors.bg.overlay2,
}}
<Circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke={colors.bg.overlay2}
strokeWidth={strokeWidth}
fill="none"
/>
{/* Fill — simplified: show a colored ring proportional to progress */}
<View
style={{
position: 'absolute',
width: size,
height: size,
borderRadius: size / 2,
borderWidth: strokeWidth,
borderColor: color,
borderTopColor: 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 */}
<Circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke={color}
strokeWidth={strokeWidth}
fill="none"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
transform={`rotate(-90 ${size / 2} ${size / 2})`}
opacity={progress > 0 ? 1 : 0.3}
/>
</View>
</Svg>
)
}
@@ -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}
</StyledText>
</View>
<Ionicons name={icon} size={18} color={color} />
<Icon name={icon} size={18} tintColor={color} />
</View>
</View>
)
@@ -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 (
<View style={styles.emptyState}>
<View style={styles.emptyIconCircle}>
<Icon name="flame" size={48} tintColor={BRAND.PRIMARY} />
</View>
<StyledText size={22} weight="bold" color={colors.text.primary} style={styles.emptyTitle}>
{t('screens:activity.emptyTitle')}
</StyledText>
<StyledText size={15} color={colors.text.tertiary} style={styles.emptySubtitle}>
{t('screens:activity.emptySubtitle')}
</StyledText>
<Pressable style={styles.emptyCtaButton} onPress={onStartWorkout}>
<LinearGradient
colors={GRADIENTS.CTA}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={StyleSheet.absoluteFill}
/>
<Icon name="play.fill" size={18} tintColor="#FFFFFF" style={{ marginRight: SPACING[2] }} />
<StyledText size={16} weight="semibold" color="#FFFFFF">
{t('screens:activity.startFirstWorkout')}
</StyledText>
</Pressable>
</View>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// 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')}
</StyledText>
{/* Empty state when no history */}
{history.length === 0 ? (
<EmptyState onStartWorkout={() => router.push('/(tabs)/explore' as any)} />
) : (
<>
{/* Streak Banner */}
<View style={styles.streakBanner}>
<LinearGradient
@@ -226,7 +263,7 @@ export default function ActivityScreen() {
/>
<View style={styles.streakRow}>
<View style={styles.streakIconWrap}>
<Ionicons name="flame" size={28} color="#FFFFFF" />
<Icon name="flame.fill" size={28} tintColor="#FFFFFF" />
</View>
<View style={{ flex: 1 }}>
<StyledText size={28} weight="bold" color="#FFFFFF">
@@ -254,28 +291,28 @@ export default function ActivityScreen() {
value={totalWorkouts}
max={100}
color={BRAND.PRIMARY}
icon="barbell-outline"
icon="dumbbell"
/>
<StatCard
label={t('screens:activity.minutes')}
value={totalMinutes}
max={300}
color={PHASE.REST}
icon="time-outline"
icon="clock"
/>
<StatCard
label={t('screens:activity.calories')}
value={totalCalories}
max={5000}
color={BRAND.SECONDARY}
icon="flash-outline"
icon="bolt"
/>
<StatCard
label={t('screens:activity.bestStreak')}
value={streak.longest}
max={30}
color={BRAND.SUCCESS}
icon="trending-up-outline"
icon="arrow.up.right"
/>
</View>
@@ -358,10 +395,10 @@ export default function ActivityScreen() {
: { backgroundColor: 'rgba(255, 255, 255, 0.04)' },
]}
>
<Ionicons
name={a.unlocked ? 'trophy' : 'lock-closed'}
<Icon
name={a.unlocked ? 'trophy.fill' : 'lock.fill'}
size={22}
color={a.unlocked ? BRAND.PRIMARY : colors.text.hint}
tintColor={a.unlocked ? BRAND.PRIMARY : colors.text.hint}
/>
</View>
<StyledText
@@ -377,6 +414,8 @@ export default function ActivityScreen() {
))}
</View>
</View>
</>
)}
</ScrollView>
</View>
)
@@ -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,
},
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<ProgramId, { icon: keyof typeof Ionicons.glyphMap; gradient: [string, string]; accent: string }> = {
const PROGRAM_META: Record<ProgramId, { icon: IconName; gradient: [string, string]; accent: string }> = {
'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}
/>
<View style={styles.programIconInner}>
<Ionicons name={meta.icon} size={24} color="#FFFFFF" />
<Icon name={meta.icon} size={24} tintColor="#FFFFFF" />
</View>
</View>
<View style={styles.programHeaderText}>
@@ -220,10 +225,10 @@ function ProgramCard({
: t('programs.continue')
}
</StyledText>
<Ionicons
name={programStatus === 'completed' ? 'refresh' : 'arrow-forward'}
<Icon
name={programStatus === 'completed' ? 'arrow.clockwise' : 'arrow.right'}
size={17}
color="#FFFFFF"
tintColor="#FFFFFF"
style={styles.ctaIcon}
/>
</LinearGradient>
@@ -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}
/>
<Ionicons name={stat.icon} size={16} color={stat.color} />
<Icon name={stat.icon} size={16} tintColor={stat.color} />
<StyledText size={17} weight="bold" color={colors.text.primary} style={{ fontVariant: ['tabular-nums'] }}>
{String(stat.value)}
</StyledText>
@@ -323,7 +328,7 @@ function AssessmentCard({ onPress }: { onPress: () => void }) {
style={StyleSheet.absoluteFill}
/>
<View style={styles.assessmentIconInner}>
<Ionicons name="clipboard-outline" size={22} color="#FFFFFF" />
<Icon name="clipboard" size={22} tintColor="#FFFFFF" />
</View>
</View>
<View style={styles.assessmentText}>
@@ -335,7 +340,7 @@ function AssessmentCard({ onPress }: { onPress: () => void }) {
</StyledText>
</View>
<View style={styles.assessmentArrow}>
<Ionicons name="arrow-forward" size={16} color={BRAND.PRIMARY} />
<Icon name="arrow.right" size={16} tintColor={BRAND.PRIMARY} />
</View>
</View>
</Pressable>
@@ -405,7 +410,7 @@ export default function HomeScreen() {
{/* Inline streak badge */}
{streak.current > 0 && (
<View style={styles.streakBadge}>
<Ionicons name="flame" size={13} color={BRAND.PRIMARY} />
<Icon name="flame.fill" size={13} tintColor={BRAND.PRIMARY} />
<StyledText size={12} weight="bold" color={BRAND.PRIMARY} style={{ fontVariant: ['tabular-nums'] }}>
{streak.current}
</StyledText>
@@ -426,8 +431,10 @@ export default function HomeScreen() {
{/* Quick Stats Row */}
<QuickStats />
{/* Assessment Card (if not completed) */}
<AssessmentCard onPress={handleAssessmentPress} />
{/* Assessment Card (if not completed and feature enabled) */}
{FEATURE_FLAGS.ASSESSMENT_ENABLED && (
<AssessmentCard onPress={handleAssessmentPress} />
)}
{/* Program Cards */}
<View style={styles.programsSection}>
@@ -460,7 +467,7 @@ export default function HomeScreen() {
tint={colors.glass.blurTint}
style={StyleSheet.absoluteFill}
/>
<Ionicons name="shuffle-outline" size={16} color={colors.text.secondary} />
<Icon name="shuffle" size={16} tintColor={colors.text.secondary} />
<StyledText size={14} weight="medium" color={colors.text.secondary}>
{t('home.switchProgram')}
</StyledText>

View File

@@ -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 (
<RNText
style={[
{
fontSize: size ?? 17,
fontWeight: weight ?? 'normal',
color: color ?? colors.text.primary,
textAlign: center ? 'center' : 'left',
},
style,
]}
>
{children}
</RNText>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// COMPONENT: PROFILE SCREEN
// ═══════════════════════════════════════════════════════════════════════════
@@ -150,24 +119,24 @@ export default function ProfileScreen() {
<View style={styles.headerContainer}>
{/* Avatar with gradient background */}
<View style={styles.avatarContainer}>
<Text size={48} weight="bold" color="#FFFFFF">
<StyledText size={48} weight="bold" color="#FFFFFF">
{avatarInitial}
</Text>
</StyledText>
</View>
{/* Name & Plan */}
<View style={styles.nameContainer}>
<Text size={22} weight="600" center>
<StyledText size={22} weight="semibold" style={{ textAlign: 'center' }}>
{profile.name || t('profile.guest')}
</Text>
</StyledText>
<View style={styles.planContainer}>
<Text size={15} color={isPremium ? BRAND.PRIMARY : colors.text.tertiary}>
<StyledText size={15} color={isPremium ? BRAND.PRIMARY : colors.text.tertiary}>
{planLabel}
</Text>
</StyledText>
{isPremium && (
<Text size={12} color={BRAND.PRIMARY}>
<StyledText size={12} color={BRAND.PRIMARY}>
</Text>
</StyledText>
)}
</View>
</View>
@@ -175,28 +144,28 @@ export default function ProfileScreen() {
{/* Stats Row */}
<View style={styles.statsContainer}>
<View style={styles.statItem}>
<Text size={20} weight="bold" color={BRAND.PRIMARY} center>
<StyledText size={20} weight="bold" color={BRAND.PRIMARY} style={{ textAlign: 'center' }}>
🔥 {stats.workouts}
</Text>
<Text size={12} color={colors.text.tertiary} center>
</StyledText>
<StyledText size={12} color={colors.text.tertiary} style={{ textAlign: 'center' }}>
{t('profile.statsWorkouts')}
</Text>
</StyledText>
</View>
<View style={styles.statItem}>
<Text size={20} weight="bold" color={BRAND.PRIMARY} center>
<StyledText size={20} weight="bold" color={BRAND.PRIMARY} style={{ textAlign: 'center' }}>
📅 {stats.streak}
</Text>
<Text size={12} color={colors.text.tertiary} center>
</StyledText>
<StyledText size={12} color={colors.text.tertiary} style={{ textAlign: 'center' }}>
{t('profile.statsStreak')}
</Text>
</StyledText>
</View>
<View style={styles.statItem}>
<Text size={20} weight="bold" color={BRAND.PRIMARY} center>
<StyledText size={20} weight="bold" color={BRAND.PRIMARY} style={{ textAlign: 'center' }}>
{Math.round(stats.calories / 1000)}k
</Text>
<Text size={12} color={colors.text.tertiary} center>
</StyledText>
<StyledText size={12} color={colors.text.tertiary} style={{ textAlign: 'center' }}>
{t('profile.statsCalories')}
</Text>
</StyledText>
</View>
</View>
</View>
@@ -207,32 +176,32 @@ export default function ProfileScreen() {
═══════════════════════════════════════════════════════════════════ */}
{!isPremium && (
<View style={styles.section}>
<TouchableOpacity
<Pressable
style={styles.premiumContainer}
onPress={() => router.push('/paywall')}
>
<View style={styles.premiumContent}>
<Text size={17} weight="600" color={BRAND.PRIMARY}>
<StyledText size={17} weight="semibold" color={BRAND.PRIMARY}>
{t('profile.upgradeTitle')}
</Text>
<Text size={15} color={colors.text.tertiary} style={{ marginTop: 4 }}>
</StyledText>
<StyledText size={15} color={colors.text.tertiary} style={{ marginTop: SPACING[1] }}>
{t('profile.upgradeDescription')}
</Text>
</StyledText>
</View>
<Text size={15} color={BRAND.PRIMARY} style={{ marginTop: 12 }}>
<StyledText size={15} color={BRAND.PRIMARY} style={{ marginTop: SPACING[3] }}>
{t('profile.learnMore')}
</Text>
</TouchableOpacity>
</StyledText>
</Pressable>
</View>
)}
{/* ════════════════════════════════════════════════════════════════════
WORKOUT SETTINGS
═══════════════════════════════════════════════════════════════════ */}
<Text style={styles.sectionHeader}>{t('profile.sectionWorkout')}</Text>
<StyledText style={styles.sectionHeader}>{t('profile.sectionWorkout')}</StyledText>
<View style={styles.section}>
<View style={styles.row}>
<Text style={styles.rowLabel}>{t('profile.hapticFeedback')}</Text>
<StyledText style={styles.rowLabel}>{t('profile.hapticFeedback')}</StyledText>
<Switch
value={settings.haptics}
onValueChange={(v) => updateSettings({ haptics: v })}
@@ -241,7 +210,7 @@ export default function ProfileScreen() {
/>
</View>
<View style={styles.row}>
<Text style={styles.rowLabel}>{t('profile.soundEffects')}</Text>
<StyledText style={styles.rowLabel}>{t('profile.soundEffects')}</StyledText>
<Switch
value={settings.soundEffects}
onValueChange={(v) => updateSettings({ soundEffects: v })}
@@ -250,7 +219,7 @@ export default function ProfileScreen() {
/>
</View>
<View style={[styles.row, styles.rowLast]}>
<Text style={styles.rowLabel}>{t('profile.voiceCoaching')}</Text>
<StyledText style={styles.rowLabel}>{t('profile.voiceCoaching')}</StyledText>
<Switch
value={settings.voiceCoaching}
onValueChange={(v) => updateSettings({ voiceCoaching: v })}
@@ -263,10 +232,10 @@ export default function ProfileScreen() {
{/* ════════════════════════════════════════════════════════════════════
NOTIFICATIONS
═══════════════════════════════════════════════════════════════════ */}
<Text style={styles.sectionHeader}>{t('profile.sectionNotifications')}</Text>
<StyledText style={styles.sectionHeader}>{t('profile.sectionNotifications')}</StyledText>
<View style={styles.section}>
<View style={styles.row}>
<Text style={styles.rowLabel}>{t('profile.dailyReminders')}</Text>
<StyledText style={styles.rowLabel}>{t('profile.dailyReminders')}</StyledText>
<Switch
value={settings.reminders}
onValueChange={handleReminderToggle}
@@ -276,8 +245,8 @@ export default function ProfileScreen() {
</View>
{settings.reminders && (
<View style={styles.rowTime}>
<Text style={styles.rowLabel}>{t('profile.reminderTime')}</Text>
<Text style={styles.rowValue}>{settings.reminderTime}</Text>
<StyledText style={styles.rowLabel}>{t('profile.reminderTime')}</StyledText>
<StyledText style={styles.rowValue}>{settings.reminderTime}</StyledText>
</View>
)}
</View>
@@ -287,18 +256,18 @@ export default function ProfileScreen() {
═══════════════════════════════════════════════════════════════════ */}
{isPremium && (
<>
<Text style={styles.sectionHeader}>Personalization</Text>
<StyledText style={styles.sectionHeader}>{t('profile.sectionPersonalization')}</StyledText>
<View style={styles.section}>
<View style={[styles.row, styles.rowLast]}>
<Text style={styles.rowLabel}>
{profile.syncStatus === 'synced' ? 'Personalization Enabled' : 'Generic Programs'}
</Text>
<Text
<StyledText style={styles.rowLabel}>
{profile.syncStatus === 'synced' ? t('profile.personalizationEnabled') : t('profile.personalizationDisabled')}
</StyledText>
<StyledText
size={14}
color={profile.syncStatus === 'synced' ? BRAND.SUCCESS : colors.text.tertiary}
>
{profile.syncStatus === 'synced' ? '✓' : '○'}
</Text>
</StyledText>
</View>
</View>
</>
@@ -307,28 +276,28 @@ export default function ProfileScreen() {
{/* ════════════════════════════════════════════════════════════════════
ABOUT
═══════════════════════════════════════════════════════════════════ */}
<Text style={styles.sectionHeader}>{t('profile.sectionAbout')}</Text>
<StyledText style={styles.sectionHeader}>{t('profile.sectionAbout')}</StyledText>
<View style={styles.section}>
<View style={styles.row}>
<Text style={styles.rowLabel}>{t('profile.version')}</Text>
<Text style={styles.rowValue}>{appVersion}</Text>
<StyledText style={styles.rowLabel}>{t('profile.version')}</StyledText>
<StyledText style={styles.rowValue}>{appVersion}</StyledText>
</View>
<TouchableOpacity style={styles.row} onPress={handleRateApp}>
<Text style={styles.rowLabel}>{t('profile.rateApp')}</Text>
<Text style={styles.rowValue}></Text>
</TouchableOpacity>
<TouchableOpacity style={styles.row} onPress={handleContactUs}>
<Text style={styles.rowLabel}>{t('profile.contactUs')}</Text>
<Text style={styles.rowValue}></Text>
</TouchableOpacity>
<TouchableOpacity style={styles.row} onPress={handleFAQ}>
<Text style={styles.rowLabel}>{t('profile.faq')}</Text>
<Text style={styles.rowValue}></Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.row, styles.rowLast]} onPress={handlePrivacyPolicy}>
<Text style={styles.rowLabel}>{t('profile.privacyPolicy')}</Text>
<Text style={styles.rowValue}></Text>
</TouchableOpacity>
<Pressable style={styles.row} onPress={handleRateApp}>
<StyledText style={styles.rowLabel}>{t('profile.rateApp')}</StyledText>
<StyledText style={styles.rowValue}></StyledText>
</Pressable>
<Pressable style={styles.row} onPress={handleContactUs}>
<StyledText style={styles.rowLabel}>{t('profile.contactUs')}</StyledText>
<StyledText style={styles.rowValue}></StyledText>
</Pressable>
<Pressable style={styles.row} onPress={handleFAQ}>
<StyledText style={styles.rowLabel}>{t('profile.faq')}</StyledText>
<StyledText style={styles.rowValue}></StyledText>
</Pressable>
<Pressable style={[styles.row, styles.rowLast]} onPress={handlePrivacyPolicy}>
<StyledText style={styles.rowLabel}>{t('profile.privacyPolicy')}</StyledText>
<StyledText style={styles.rowValue}></StyledText>
</Pressable>
</View>
{/* ════════════════════════════════════════════════════════════════════
@@ -336,12 +305,12 @@ export default function ProfileScreen() {
═══════════════════════════════════════════════════════════════════ */}
{isPremium && (
<>
<Text style={styles.sectionHeader}>{t('profile.sectionAccount')}</Text>
<StyledText style={styles.sectionHeader}>{t('profile.sectionAccount')}</StyledText>
<View style={styles.section}>
<TouchableOpacity style={[styles.row, styles.rowLast]} onPress={handleRestore}>
<Text style={styles.rowLabel}>{t('profile.restorePurchases')}</Text>
<Text style={styles.rowValue}></Text>
</TouchableOpacity>
<Pressable style={[styles.row, styles.rowLast]} onPress={handleRestore}>
<StyledText style={styles.rowLabel}>{t('profile.restorePurchases')}</StyledText>
<StyledText style={styles.rowValue}></StyledText>
</Pressable>
</View>
</>
)}
@@ -350,9 +319,9 @@ export default function ProfileScreen() {
SIGN OUT
═══════════════════════════════════════════════════════════════════ */}
<View style={[styles.section, styles.signOutSection]}>
<TouchableOpacity style={styles.button} onPress={handleSignOut}>
<Text style={styles.destructive}>{t('profile.signOut')}</Text>
</TouchableOpacity>
<Pressable style={styles.button} onPress={handleSignOut}>
<StyledText style={styles.destructive}>{t('profile.signOut')}</StyledText>
</Pressable>
</View>
</ScrollView>
@@ -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],
},
})
}

View File

@@ -153,6 +153,15 @@ function RootLayoutInner() {
animation: 'fade',
}}
/>
<Stack.Screen
name="explore-filters"
options={{
presentation: 'formSheet',
headerShown: false,
sheetGrabberVisible: true,
sheetAllowedDetents: [0.5],
}}
/>
</Stack>
</View>
</QueryClientProvider>

View File

@@ -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() {
<View style={[styles.container, { paddingTop: insets.top }]}>
<View style={styles.header}>
<Pressable style={styles.backButton} onPress={() => setShowIntro(true)}>
<Ionicons name="arrow-back" size={24} color={colors.text.primary} />
<Icon name="arrow.left" size={24} color={colors.text.primary} />
</Pressable>
<StyledText size={FONTS.TITLE} weight="bold" color={colors.text.primary}>
{t('assessment.title')}
@@ -105,11 +105,11 @@ export default function AssessmentScreen() {
<StyledText size={FONTS.HEADLINE} weight="semibold" color={colors.text.primary} style={styles.tipsTitle}>
{t('assessment.tips')}
</StyledText>
{ASSESSMENT_WORKOUT.tips.map((tip, index) => (
{[1, 2, 3, 4].map((index) => (
<View key={index} style={styles.tipItem}>
<Ionicons name="checkmark-circle-outline" size={18} color={BRAND.PRIMARY} />
<Icon name="checkmark.circle" size={18} color={BRAND.PRIMARY} />
<StyledText size={14} color={colors.text.secondary} style={styles.tipText}>
{tip}
{t(`assessment.tip${index}`)}
</StyledText>
</View>
))}
@@ -126,7 +126,7 @@ export default function AssessmentScreen() {
<StyledText size={16} weight="bold" color="#FFFFFF">
{t('assessment.startAssessment')}
</StyledText>
<Ionicons name="play" size={20} color="#FFFFFF" style={styles.ctaIcon} />
<Icon name="play.fill" size={20} color="#FFFFFF" style={styles.ctaIcon} />
</LinearGradient>
</Pressable>
</View>
@@ -139,7 +139,7 @@ export default function AssessmentScreen() {
{/* Header */}
<View style={styles.header}>
<Pressable style={styles.backButton} onPress={handleSkip}>
<Ionicons name="close" size={24} color={colors.text.primary} />
<Icon name="xmark" size={24} color={colors.text.primary} />
</Pressable>
<View style={styles.placeholder} />
</View>
@@ -152,7 +152,7 @@ export default function AssessmentScreen() {
{/* Hero */}
<View style={styles.heroSection}>
<View style={styles.iconContainer}>
<Ionicons name="clipboard-outline" size={48} color={BRAND.PRIMARY} />
<Icon name="clipboard" size={48} color={BRAND.PRIMARY} />
</View>
<StyledText size={FONTS.LARGE_TITLE} weight="bold" color={colors.text.primary} style={styles.heroTitle}>
@@ -168,7 +168,7 @@ export default function AssessmentScreen() {
<View style={styles.featuresSection}>
<View style={styles.featureItem}>
<View style={styles.featureIcon}>
<Ionicons name="time-outline" size={24} color={BRAND.PRIMARY} />
<Icon name="clock" size={24} color={BRAND.PRIMARY} />
</View>
<View style={styles.featureText}>
<StyledText size={16} weight="semibold" color={colors.text.primary}>
@@ -182,7 +182,7 @@ export default function AssessmentScreen() {
<View style={styles.featureItem}>
<View style={styles.featureIcon}>
<Ionicons name="body-outline" size={24} color={BRAND.PRIMARY} />
<Icon name="figure.stand" size={24} color={BRAND.PRIMARY} />
</View>
<View style={styles.featureText}>
<StyledText size={16} weight="semibold" color={colors.text.primary}>
@@ -196,7 +196,7 @@ export default function AssessmentScreen() {
<View style={styles.featureItem}>
<View style={styles.featureIcon}>
<Ionicons name="barbell-outline" size={24} color={BRAND.PRIMARY} />
<Icon name="dumbbell" size={24} color={BRAND.PRIMARY} />
</View>
<View style={styles.featureText}>
<StyledText size={16} weight="semibold" color={colors.text.primary}>
@@ -250,7 +250,7 @@ export default function AssessmentScreen() {
<StyledText size={16} weight="bold" color="#FFFFFF">
{t('assessment.takeAssessment')}
</StyledText>
<Ionicons name="arrow-forward" size={20} color="#FFFFFF" style={styles.ctaIcon} />
<Icon name="arrow.right" size={20} color="#FFFFFF" style={styles.ctaIcon} />
</LinearGradient>
</Pressable>

View File

@@ -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%' }}
>
<Animated.View style={[styles.secondaryButton, { transform: [{ scale: scaleAnim }] }]}>
{icon && <Ionicons name={icon} size={18} color={colors.text.primary} style={styles.buttonIcon} />}
{icon && <Icon name={icon} size={18} tintColor={colors.text.primary} style={styles.buttonIcon} />}
<RNText style={styles.secondaryButtonText}>{children}</RNText>
</Animated.View>
</Pressable>
@@ -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 (
<Animated.View style={[styles.statCard, { transform: [{ scale: scaleAnim }] }]}>
<BlurView intensity={colors.glass.blurMedium} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
<Ionicons name={icon} size={24} color={BRAND.PRIMARY} />
<Icon name={icon} size={24} tintColor={BRAND.PRIMARY} />
<RNText style={styles.statValue}>{value}</RNText>
<RNText style={styles.statLabel}>{label}</RNText>
</Animated.View>
@@ -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 */}
<View style={styles.statsGrid}>
<StatCard value={resultCalories} label={t('screens:complete.caloriesLabel')} icon="flame" delay={100} />
<StatCard value={resultMinutes} label={t('screens:complete.minutesLabel')} icon="time" delay={200} />
<StatCard value="100%" label={t('screens:complete.completeLabel')} icon="checkmark-circle" delay={300} />
<StatCard value={resultCalories} label={t('screens:complete.caloriesLabel')} icon="flame.fill" delay={100} />
<StatCard value={resultMinutes} label={t('screens:complete.minutesLabel')} icon="clock.fill" delay={200} />
<StatCard value="100%" label={t('screens:complete.completeLabel')} icon="checkmark.circle.fill" delay={300} />
</View>
{/* Burn Bar */}
@@ -386,7 +391,7 @@ export default function WorkoutCompleteScreen() {
{/* Streak */}
<View style={styles.streakSection}>
<View style={styles.streakBadge}>
<Ionicons name="flame" size={32} color={BRAND.PRIMARY} />
<Icon name="flame.fill" size={32} tintColor={BRAND.PRIMARY} />
</View>
<View style={styles.streakInfo}>
<RNText style={styles.streakTitle}>{t('screens:complete.streakTitle', { count: streak.current })}</RNText>
@@ -398,7 +403,7 @@ export default function WorkoutCompleteScreen() {
{/* Share Button */}
<View style={styles.shareSection}>
<SecondaryButton onPress={handleShare} icon="share-outline">
<SecondaryButton onPress={handleShare} icon="square.and.arrow.up">
{t('screens:complete.shareWorkout')}
</SecondaryButton>
</View>
@@ -421,7 +426,7 @@ export default function WorkoutCompleteScreen() {
colors={[BRAND.PRIMARY, BRAND.PRIMARY_LIGHT]}
style={StyleSheet.absoluteFill}
/>
<Ionicons name="flame" size={24} color="#FFFFFF" />
<Icon name="flame.fill" size={24} tintColor="#FFFFFF" />
</View>
<RNText style={styles.recommendedTitleText} numberOfLines={1}>{w.title}</RNText>
<RNText style={styles.recommendedDurationText}>{t('units.minUnit', { count: w.duration })}</RNText>

222
app/explore-filters.tsx Normal file
View File

@@ -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<WorkoutLevel | 'all', string> = {
all: 'all',
Beginner: 'beginner',
Intermediate: 'intermediate',
Advanced: 'advanced',
}
const EQUIPMENT_TRANSLATION_KEYS: Record<string, string> = {
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 (
<Pressable
style={[
chipStyles.chip,
{
backgroundColor: isSelected ? BRAND.PRIMARY + '20' : colors.glass.base.backgroundColor,
borderColor: isSelected ? BRAND.PRIMARY : colors.border.glass,
},
]}
onPress={() => {
haptics.selection()
onPress()
}}
>
{isSelected && (
<Icon name="checkmark.circle.fill" size={16} color={BRAND.PRIMARY} style={{ marginRight: SPACING[1] }} />
)}
<StyledText
size={15}
weight={isSelected ? 'semibold' : 'medium'}
color={isSelected ? BRAND.PRIMARY : colors.text.secondary}
>
{label}
</StyledText>
</Pressable>
)
}
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 (
<View style={{ flex: 1, backgroundColor: colors.bg.base }}>
{/* ── Title row ─────────────────────────────────────────────── */}
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', paddingHorizontal: LAYOUT.SCREEN_PADDING, paddingTop: SPACING[5], paddingBottom: SPACING[4] }}>
<StyledText size={17} weight="semibold" color={colors.text.primary}>
{t('screens:explore.filters')}
</StyledText>
{hasActiveFilters && (
<Pressable onPress={handleReset} hitSlop={8} style={{ position: 'absolute', right: LAYOUT.SCREEN_PADDING }}>
<Text style={{ fontSize: 17, color: BRAND.PRIMARY }}>
{t('screens:explore.resetFilters')}
</Text>
</Pressable>
)}
</View>
{/* ── Filter sections ───────────────────────────────────────── */}
<View style={{ flex: 1, paddingHorizontal: LAYOUT.SCREEN_PADDING }}>
{/* Level */}
<StyledText size={13} weight="semibold" color={colors.text.tertiary} style={{ marginBottom: SPACING[2], letterSpacing: 0.5 }}>
{t('screens:explore.filterLevel').toUpperCase()}
</StyledText>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', marginBottom: SPACING[3] }}>
{ALL_LEVELS.map((lvl) => (
<ChoiceChip
key={lvl}
label={getLevelLabel(lvl)}
isSelected={level === lvl}
onPress={() => setLevel(lvl)}
colors={colors}
/>
))}
</View>
{/* Equipment */}
<StyledText size={13} weight="semibold" color={colors.text.tertiary} style={{ marginBottom: SPACING[2], letterSpacing: 0.5 }}>
{t('screens:explore.filterEquipment').toUpperCase()}
</StyledText>
<View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
{equipmentOptions.map((equip) => (
<ChoiceChip
key={equip}
label={getEquipmentLabel(equip)}
isSelected={equipment === equip}
onPress={() => setEquipment(equip)}
colors={colors}
/>
))}
</View>
</View>
{/* ── Apply Button ──────────────────────────────────────────── */}
<View style={{ paddingHorizontal: LAYOUT.SCREEN_PADDING, paddingTop: SPACING[3], paddingBottom: Math.max(insets.bottom, SPACING[4]), borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.border.glass }}>
<Pressable
style={{ height: 52, borderRadius: RADIUS.LG, backgroundColor: BRAND.PRIMARY, alignItems: 'center', justifyContent: 'center', borderCurve: 'continuous' }}
onPress={() => {
haptics.buttonTap()
router.back()
}}
>
<StyledText size={17} weight="semibold" color="#FFFFFF">
{t('screens:explore.applyFilters')}
</StyledText>
</Pressable>
</View>
</View>
)
}

View File

@@ -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],
}}
>
<Ionicons name="time" size={80} color={BRAND.PRIMARY} />
<Icon name="clock.fill" size={80} color={BRAND.PRIMARY} />
</Animated.View>
<Animated.View style={{ opacity: textOpacity, alignItems: 'center' }}>
@@ -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)}
>
<Ionicons
<Icon
name={item.icon}
size={28}
color={selected ? BRAND.PRIMARY : colors.text.tertiary}
@@ -373,10 +373,10 @@ function SolutionScreen({ onNext }: { onNext: () => 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 }) {
]}
>
<View style={[wowStyles.iconCircle, { backgroundColor: `${feature.iconColor}26` }]}>
<Ionicons name={feature.icon} size={22} color={feature.iconColor} />
<Icon name={feature.icon} size={22} color={feature.iconColor} />
</View>
<View style={wowStyles.textCol}>
<StyledText size={17} weight="semibold" color={colors.text.primary}>
@@ -822,7 +822,7 @@ function PaywallScreen({
key={featureKey}
style={[styles.featureRow, { opacity: featureAnims[i] }]}
>
<Ionicons name="checkmark-circle" size={22} color={BRAND.SUCCESS} />
<Icon name="checkmark.circle.fill" size={22} color={BRAND.SUCCESS} />
<StyledText
size={16}
color={colors.text.primary}
@@ -1036,6 +1036,15 @@ export default function OnboardingScreen() {
setStep(next)
}, [step, barriers, name, level, goal, frequency])
const prevStep = useCallback(() => {
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 (
<OnboardingStep step={step} totalSteps={TOTAL_STEPS}>
<OnboardingStep step={step} totalSteps={TOTAL_STEPS} onBack={prevStep}>
{renderStep()}
</OnboardingStep>
)

View File

@@ -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 && (
<View style={styles.savingsBadge}>
<Text style={styles.savingsText}>{savings}</Text>
<StyledText size={10} weight="bold" color={colors.text.primary}>{savings}</StyledText>
</View>
)}
<View style={styles.planInfo}>
<Text style={[styles.planTitle, { color: colors.text.primary }]}>
<StyledText size={16} weight="semibold" color={colors.text.primary}>
{title}
</Text>
<Text style={[styles.planPeriod, { color: colors.text.tertiary }]}>
</StyledText>
<StyledText size={13} color={colors.text.tertiary} style={{ marginTop: 2 }}>
{period}
</Text>
</StyledText>
</View>
<Text style={[styles.planPrice, { color: BRAND.PRIMARY }]}>
<StyledText size={20} weight="bold" color={BRAND.PRIMARY}>
{price}
</Text>
</StyledText>
{isSelected && (
<View style={styles.checkmark}>
<Ionicons name="checkmark-circle" size={24} color={BRAND.PRIMARY} />
<Icon name="checkmark.circle.fill" size={24} color={BRAND.PRIMARY} />
</View>
)}
</Pressable>
@@ -196,7 +196,7 @@ export default function PaywallScreen() {
<View style={[styles.container, { paddingTop: insets.top }]}>
{/* Close Button */}
<Pressable style={[styles.closeButton, { top: insets.top + SPACING[2] }]} onPress={handleClose}>
<Ionicons name="close" size={28} color={colors.text.secondary} />
<Icon name="xmark" size={28} color={colors.text.secondary} />
</Pressable>
<ScrollView
@@ -209,8 +209,12 @@ export default function PaywallScreen() {
>
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>TabataFit+</Text>
<Text style={styles.subtitle}>{t('paywall.subtitle')}</Text>
<StyledText size={32} weight="bold" color={colors.text.primary} style={{ textAlign: 'center' }}>
TabataFit+
</StyledText>
<StyledText size={16} color={colors.text.secondary} style={{ textAlign: 'center', marginTop: SPACING[2] }}>
{t('paywall.subtitle')}
</StyledText>
</View>
{/* Features Grid */}
@@ -218,11 +222,11 @@ export default function PaywallScreen() {
{PREMIUM_FEATURES.map((feature) => (
<View key={feature.key} style={styles.featureItem}>
<View style={[styles.featureIcon, { backgroundColor: colors.glass.tinted.backgroundColor }]}>
<Ionicons name={feature.icon as any} size={22} color={BRAND.PRIMARY} />
<Icon name={feature.icon} size={22} color={BRAND.PRIMARY} />
</View>
<Text style={[styles.featureText, { color: colors.text.secondary }]}>
<StyledText size={13} color={colors.text.secondary} style={{ textAlign: 'center' }}>
{t(`paywall.features.${feature.key}`)}
</Text>
</StyledText>
</View>
))}
</View>
@@ -252,9 +256,9 @@ export default function PaywallScreen() {
{/* Price Note */}
{selectedPlan === 'annual' && (
<Text style={[styles.priceNote, { color: colors.text.tertiary }]}>
<StyledText size={13} color={colors.text.tertiary} style={{ textAlign: 'center', marginTop: SPACING[3] }}>
{t('paywall.equivalent', { price: annualMonthlyEquivalent })}
</Text>
</StyledText>
)}
{/* CTA Button */}
@@ -269,23 +273,23 @@ export default function PaywallScreen() {
end={{ x: 1, y: 1 }}
style={styles.ctaGradient}
>
<Text style={[styles.ctaText, { color: colors.text.primary }]}>
{isLoading ? t('paywall.processing') : t('paywall.subscribe')}
</Text>
<StyledText size={17} weight="semibold" color="#FFFFFF">
{isLoading ? t('paywall.processing') : t('paywall.trialCta')}
</StyledText>
</LinearGradient>
</Pressable>
{/* Restore & Terms */}
<View style={styles.footer}>
<Pressable onPress={handleRestore}>
<Text style={[styles.restoreText, { color: colors.text.tertiary }]}>
<StyledText size={14} color={colors.text.tertiary}>
{t('paywall.restore')}
</Text>
</StyledText>
</Pressable>
<Text style={[styles.termsText, { color: colors.text.tertiary }]}>
<StyledText size={11} color={colors.text.tertiary} style={{ textAlign: 'center', lineHeight: 18, paddingHorizontal: SPACING[4] }}>
{t('paywall.terms')}
</Text>
</StyledText>
</View>
</ScrollView>
</View>

View File

@@ -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 }]}
>
<View style={[timerStyles.controlButtonBg, { backgroundColor }]} />
<Ionicons name={icon} size={size * 0.4} color={colors.text.primary} />
<Icon name={icon} size={size * 0.4} tintColor={colors.text.primary} />
</Pressable>
</Animated.View>
)
@@ -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() {
<View style={[styles.header, { paddingTop: insets.top + SPACING[4] }]}>
<Pressable onPress={stopWorkout} style={styles.closeButton}>
<BlurView intensity={colors.glass.blurLight} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
<Ionicons name="close" size={24} color={colors.text.primary} />
<Icon name="xmark" size={24} tintColor={colors.text.primary} />
</Pressable>
<View style={styles.headerCenter}>
<Text style={styles.workoutTitle}>{workout?.title ?? 'Workout'}</Text>
@@ -541,22 +542,22 @@ export default function PlayerScreen() {
{showControls && !timer.isComplete && (
<View style={[styles.controls, { paddingBottom: insets.bottom + SPACING[6] }]}>
{!timer.isRunning ? (
<ControlButton icon="play" onPress={startTimer} size={80} />
<ControlButton icon="play.fill" onPress={startTimer} size={80} />
) : (
<View style={styles.controlsRow}>
<ControlButton
icon="stop"
icon="stop.fill"
onPress={stopWorkout}
size={56}
variant="danger"
/>
<ControlButton
icon={timer.isPaused ? 'play' : 'pause'}
icon={timer.isPaused ? 'play.fill' : 'pause.fill'}
onPress={togglePause}
size={80}
/>
<ControlButton
icon="play-skip-forward"
icon="forward.end.fill"
onPress={handleSkip}
size={56}
variant="secondary"
@@ -576,7 +577,7 @@ export default function PlayerScreen() {
end={{ x: 1, y: 1 }}
style={StyleSheet.absoluteFill}
/>
<Text style={styles.doneButtonText}>Done</Text>
<Text style={styles.doneButtonText}>{t('common:done')}</Text>
</Pressable>
</View>
)}

View File

@@ -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 */}
<View style={styles.header}>
<Pressable style={styles.backButton} onPress={handleClose}>
<Ionicons name="chevron-back" size={28} color={darkColors.text.primary} />
<Icon name="chevron.left" size={28} color={darkColors.text.primary} />
</Pressable>
<Text style={styles.headerTitle}>{t('privacy.title')}</Text>
<View style={{ width: 44 }} />

View File

@@ -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 */}
<View style={styles.header}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={colors.text.primary} />
<Icon name="arrow.left" size={24} color={colors.text.primary} />
</Pressable>
<StyledText size={FONTS.TITLE} weight="bold" color={colors.text.primary}>
{program.title}
@@ -215,7 +215,7 @@ export default function ProgramDetailScreen() {
{week.title}
</StyledText>
{!isUnlocked && (
<Ionicons name="lock-closed" size={16} color={colors.text.tertiary} />
<Icon name="lock.fill" size={16} color={colors.text.tertiary} />
)}
{isCurrentWeek && isUnlocked && (
<View style={styles.currentBadge}>
@@ -257,9 +257,9 @@ export default function ProgramDetailScreen() {
>
<View style={styles.workoutNumber}>
{isCompleted ? (
<Ionicons name="checkmark-circle" size={24} color={BRAND.SUCCESS} />
<Icon name="checkmark.circle.fill" size={24} color={BRAND.SUCCESS} />
) : isLocked ? (
<Ionicons name="lock-closed" size={20} color={colors.text.tertiary} />
<Icon name="lock.fill" size={20} color={colors.text.tertiary} />
) : (
<StyledText size={14} weight="semibold" color={colors.text.primary}>
{index + 1}
@@ -280,7 +280,7 @@ export default function ProgramDetailScreen() {
</StyledText>
</View>
{!isLocked && !isCompleted && (
<Ionicons name="chevron-forward" size={20} color={colors.text.tertiary} />
<Icon name="chevron.right" size={20} color={colors.text.tertiary} />
)}
</Pressable>
)
@@ -308,7 +308,7 @@ export default function ProgramDetailScreen() {
: t('programs.continueTraining')
}
</StyledText>
<Ionicons name="arrow-forward" size={20} color="#FFFFFF" style={styles.ctaIcon} />
<Icon name="arrow.right" size={20} color="#FFFFFF" style={styles.ctaIcon} />
</LinearGradient>
</Pressable>
</View>

167
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) => {

View File

@@ -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 },