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:
59
AGENTS.md
59
AGENTS.md
@@ -386,3 +386,62 @@ COMPLETE: '#30D158' // Green
|
||||
---
|
||||
|
||||
*Last updated: March 14, 2026*
|
||||
|
||||
# context-mode — MANDATORY routing rules
|
||||
|
||||
You have context-mode MCP tools available. These rules are NOT optional — they protect your context window from flooding. A single unrouted command can dump 56 KB into context and waste the entire session.
|
||||
|
||||
## BLOCKED commands — do NOT attempt these
|
||||
|
||||
### curl / wget — BLOCKED
|
||||
Any shell command containing `curl` or `wget` will be intercepted and blocked by the context-mode plugin. Do NOT retry.
|
||||
Instead use:
|
||||
- `context-mode_ctx_fetch_and_index(url, source)` to fetch and index web pages
|
||||
- `context-mode_ctx_execute(language: "javascript", code: "const r = await fetch(...)")` to run HTTP calls in sandbox
|
||||
|
||||
### Inline HTTP — BLOCKED
|
||||
Any shell command containing `fetch('http`, `requests.get(`, `requests.post(`, `http.get(`, or `http.request(` will be intercepted and blocked. Do NOT retry with shell.
|
||||
Instead use:
|
||||
- `context-mode_ctx_execute(language, code)` to run HTTP calls in sandbox — only stdout enters context
|
||||
|
||||
### Direct web fetching — BLOCKED
|
||||
Do NOT use any direct URL fetching tool. Use the sandbox equivalent.
|
||||
Instead use:
|
||||
- `context-mode_ctx_fetch_and_index(url, source)` then `context-mode_ctx_search(queries)` to query the indexed content
|
||||
|
||||
## REDIRECTED tools — use sandbox equivalents
|
||||
|
||||
### Shell (>20 lines output)
|
||||
Shell is ONLY for: `git`, `mkdir`, `rm`, `mv`, `cd`, `ls`, `npm install`, `pip install`, and other short-output commands.
|
||||
For everything else, use:
|
||||
- `context-mode_ctx_batch_execute(commands, queries)` — run multiple commands + search in ONE call
|
||||
- `context-mode_ctx_execute(language: "shell", code: "...")` — run in sandbox, only stdout enters context
|
||||
|
||||
### File reading (for analysis)
|
||||
If you are reading a file to **edit** it → reading is correct (edit needs content in context).
|
||||
If you are reading to **analyze, explore, or summarize** → use `context-mode_ctx_execute_file(path, language, code)` instead. Only your printed summary enters context.
|
||||
|
||||
### grep / search (large results)
|
||||
Search results can flood context. Use `context-mode_ctx_execute(language: "shell", code: "grep ...")` to run searches in sandbox. Only your printed summary enters context.
|
||||
|
||||
## Tool selection hierarchy
|
||||
|
||||
1. **GATHER**: `context-mode_ctx_batch_execute(commands, queries)` — Primary tool. Runs all commands, auto-indexes output, returns search results. ONE call replaces 30+ individual calls.
|
||||
2. **FOLLOW-UP**: `context-mode_ctx_search(queries: ["q1", "q2", ...])` — Query indexed content. Pass ALL questions as array in ONE call.
|
||||
3. **PROCESSING**: `context-mode_ctx_execute(language, code)` | `context-mode_ctx_execute_file(path, language, code)` — Sandbox execution. Only stdout enters context.
|
||||
4. **WEB**: `context-mode_ctx_fetch_and_index(url, source)` then `context-mode_ctx_search(queries)` — Fetch, chunk, index, query. Raw HTML never enters context.
|
||||
5. **INDEX**: `context-mode_ctx_index(content, source)` — Store content in FTS5 knowledge base for later search.
|
||||
|
||||
## Output constraints
|
||||
|
||||
- Keep responses under 500 words.
|
||||
- Write artifacts (code, configs, PRDs) to FILES — never return them as inline text. Return only: file path + 1-line description.
|
||||
- When indexing content, use descriptive source labels so others can `search(source: "label")` later.
|
||||
|
||||
## ctx commands
|
||||
|
||||
| Command | Action |
|
||||
|---------|--------|
|
||||
| `ctx stats` | Call the `stats` MCP tool and display the full output verbatim |
|
||||
| `ctx doctor` | Call the `doctor` MCP tool, run the returned shell command, display as checklist |
|
||||
| `ctx upgrade` | Call the `upgrade` MCP tool, run the returned shell command, display as checklist |
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
222
app/explore-filters.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 }} />
|
||||
|
||||
@@ -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
167
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`DataDeletionModal > full modal structure snapshot 1`] = `
|
||||
<Modal
|
||||
animationType="fade"
|
||||
onRequestClose={[MockFunction]}
|
||||
ref={null}
|
||||
transparent={true}
|
||||
visible={true}
|
||||
>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"alignItems": "center",
|
||||
"flex": 1,
|
||||
"justifyContent": "center",
|
||||
"padding": 16,
|
||||
},
|
||||
{
|
||||
"backgroundColor": "rgba(0,0,0,0.8)",
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"alignItems": "center",
|
||||
"borderRadius": 16,
|
||||
"maxWidth": 360,
|
||||
"padding": 24,
|
||||
"width": "100%",
|
||||
},
|
||||
{
|
||||
"backgroundColor": "#1C1C1E",
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"alignItems": "center",
|
||||
"borderRadius": 40,
|
||||
"height": 80,
|
||||
"justifyContent": "center",
|
||||
"marginBottom": 16,
|
||||
"width": 80,
|
||||
},
|
||||
{
|
||||
"backgroundColor": "rgba(255, 59, 48, 0.1)",
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<Ionicons
|
||||
color="#FF3B30"
|
||||
name="warning"
|
||||
size={40}
|
||||
testID="icon-warning"
|
||||
/>
|
||||
</View>
|
||||
<Text
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"color": "#FFFFFF",
|
||||
"fontSize": 22,
|
||||
"fontWeight": "700",
|
||||
},
|
||||
{
|
||||
"marginBottom": 16,
|
||||
"textAlign": "center",
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
dataDeletion.title
|
||||
</Text>
|
||||
<Text
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"color": "#8E8E93",
|
||||
"fontSize": 15,
|
||||
"fontWeight": "400",
|
||||
},
|
||||
{
|
||||
"lineHeight": 22,
|
||||
"marginBottom": 12,
|
||||
"textAlign": "center",
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
dataDeletion.description
|
||||
</Text>
|
||||
<Text
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"color": "#636366",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "400",
|
||||
},
|
||||
{
|
||||
"lineHeight": 20,
|
||||
"marginBottom": 24,
|
||||
"textAlign": "center",
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
dataDeletion.note
|
||||
</Text>
|
||||
<Pressable
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
onPress={[Function]}
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"alignItems": "center",
|
||||
"backgroundColor": "#FF3B30",
|
||||
"borderRadius": 14,
|
||||
"height": 52,
|
||||
"justifyContent": "center",
|
||||
"marginBottom": 12,
|
||||
"width": "100%",
|
||||
},
|
||||
false,
|
||||
]
|
||||
}
|
||||
>
|
||||
<Text
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"color": "#FFFFFF",
|
||||
"fontSize": 17,
|
||||
"fontWeight": "600",
|
||||
},
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
dataDeletion.deleteButton
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onClick={[MockFunction]}
|
||||
onPress={[MockFunction]}
|
||||
ref={null}
|
||||
style={
|
||||
{
|
||||
"padding": 12,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"color": "#8E8E93",
|
||||
"fontSize": 15,
|
||||
"fontWeight": "400",
|
||||
},
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
dataDeletion.cancelButton
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
`;
|
||||
@@ -1,318 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`SyncConsentModal > full modal structure snapshot 1`] = `
|
||||
<Modal
|
||||
animationType="fade"
|
||||
onRequestClose={[MockFunction]}
|
||||
ref={null}
|
||||
transparent={true}
|
||||
visible={true}
|
||||
>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"alignItems": "center",
|
||||
"flex": 1,
|
||||
"justifyContent": "center",
|
||||
"padding": 16,
|
||||
},
|
||||
{
|
||||
"backgroundColor": "rgba(0,0,0,0.8)",
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"alignItems": "center",
|
||||
"borderRadius": 16,
|
||||
"maxWidth": 360,
|
||||
"padding": 24,
|
||||
"width": "100%",
|
||||
},
|
||||
{
|
||||
"backgroundColor": "#1C1C1E",
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
{
|
||||
"alignItems": "center",
|
||||
"backgroundColor": "rgba(255, 107, 53, 0.1)",
|
||||
"borderRadius": 40,
|
||||
"height": 80,
|
||||
"justifyContent": "center",
|
||||
"marginBottom": 16,
|
||||
"width": 80,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Ionicons
|
||||
color="#FF6B35"
|
||||
name="sparkles"
|
||||
size={40}
|
||||
testID="icon-sparkles"
|
||||
/>
|
||||
</View>
|
||||
<Text
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"color": "#FFFFFF",
|
||||
"fontSize": 24,
|
||||
"fontWeight": "700",
|
||||
},
|
||||
{
|
||||
"marginBottom": 24,
|
||||
"textAlign": "center",
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
sync.title
|
||||
</Text>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
{
|
||||
"gap": 12,
|
||||
"marginBottom": 24,
|
||||
"width": "100%",
|
||||
}
|
||||
}
|
||||
>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
{
|
||||
"alignItems": "center",
|
||||
"flexDirection": "row",
|
||||
"gap": 12,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Ionicons
|
||||
color="#FF6B35"
|
||||
name="trending-up"
|
||||
size={22}
|
||||
testID="icon-trending-up"
|
||||
/>
|
||||
<Text
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"color": "#8E8E93",
|
||||
"fontSize": 15,
|
||||
"fontWeight": "400",
|
||||
},
|
||||
{
|
||||
"flex": 1,
|
||||
"lineHeight": 22,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
sync.benefits.recommendations
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
{
|
||||
"alignItems": "center",
|
||||
"flexDirection": "row",
|
||||
"gap": 12,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Ionicons
|
||||
color="#FF6B35"
|
||||
name="fitness"
|
||||
size={22}
|
||||
testID="icon-fitness"
|
||||
/>
|
||||
<Text
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"color": "#8E8E93",
|
||||
"fontSize": 15,
|
||||
"fontWeight": "400",
|
||||
},
|
||||
{
|
||||
"flex": 1,
|
||||
"lineHeight": 22,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
sync.benefits.adaptive
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
{
|
||||
"alignItems": "center",
|
||||
"flexDirection": "row",
|
||||
"gap": 12,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Ionicons
|
||||
color="#FF6B35"
|
||||
name="sync"
|
||||
size={22}
|
||||
testID="icon-sync"
|
||||
/>
|
||||
<Text
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"color": "#8E8E93",
|
||||
"fontSize": 15,
|
||||
"fontWeight": "400",
|
||||
},
|
||||
{
|
||||
"flex": 1,
|
||||
"lineHeight": 22,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
sync.benefits.sync
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
{
|
||||
"alignItems": "center",
|
||||
"flexDirection": "row",
|
||||
"gap": 12,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Ionicons
|
||||
color="#FF6B35"
|
||||
name="shield-checkmark"
|
||||
size={22}
|
||||
testID="icon-shield-checkmark"
|
||||
/>
|
||||
<Text
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"color": "#8E8E93",
|
||||
"fontSize": 15,
|
||||
"fontWeight": "400",
|
||||
},
|
||||
{
|
||||
"flex": 1,
|
||||
"lineHeight": 22,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
sync.benefits.secure
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"color": "#636366",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "400",
|
||||
},
|
||||
{
|
||||
"lineHeight": 20,
|
||||
"marginBottom": 24,
|
||||
"textAlign": "center",
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
sync.privacy
|
||||
</Text>
|
||||
<Pressable
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
onPress={[Function]}
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"alignItems": "center",
|
||||
"backgroundColor": "#FF6B35",
|
||||
"borderRadius": 14,
|
||||
"height": 52,
|
||||
"justifyContent": "center",
|
||||
"marginBottom": 12,
|
||||
"width": "100%",
|
||||
},
|
||||
false,
|
||||
]
|
||||
}
|
||||
>
|
||||
<Text
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"color": "#FFFFFF",
|
||||
"fontSize": 17,
|
||||
"fontWeight": "600",
|
||||
},
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
sync.primaryButton
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onClick={[MockFunction]}
|
||||
onPress={[MockFunction]}
|
||||
ref={null}
|
||||
style={
|
||||
{
|
||||
"padding": 12,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"color": "#8E8E93",
|
||||
"fontSize": 15,
|
||||
"fontWeight": "400",
|
||||
},
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
sync.secondaryButton
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
`;
|
||||
@@ -1,133 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { COLLECTIONS, FEATURED_COLLECTION_ID } from '../../shared/data/collections'
|
||||
|
||||
describe('collections data', () => {
|
||||
describe('COLLECTIONS structure', () => {
|
||||
it('should have exactly 6 collections', () => {
|
||||
expect(COLLECTIONS).toHaveLength(6)
|
||||
})
|
||||
|
||||
it('should have all required properties', () => {
|
||||
COLLECTIONS.forEach(collection => {
|
||||
expect(collection.id).toBeDefined()
|
||||
expect(collection.title).toBeDefined()
|
||||
expect(collection.description).toBeDefined()
|
||||
expect(collection.icon).toBeDefined()
|
||||
expect(collection.workoutIds).toBeDefined()
|
||||
expect(Array.isArray(collection.workoutIds)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should have unique collection IDs', () => {
|
||||
const ids = COLLECTIONS.map(c => c.id)
|
||||
const uniqueIds = new Set(ids)
|
||||
expect(uniqueIds.size).toBe(ids.length)
|
||||
})
|
||||
|
||||
it('should have unique collection titles', () => {
|
||||
const titles = COLLECTIONS.map(c => c.title)
|
||||
const uniqueTitles = new Set(titles)
|
||||
expect(uniqueTitles.size).toBe(titles.length)
|
||||
})
|
||||
|
||||
it('should have at least one workout per collection', () => {
|
||||
COLLECTIONS.forEach(collection => {
|
||||
expect(collection.workoutIds.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('specific collections', () => {
|
||||
it('should have Morning Energizer collection', () => {
|
||||
const collection = COLLECTIONS.find(c => c.id === 'morning-energizer')
|
||||
expect(collection).toBeDefined()
|
||||
expect(collection!.title).toBe('Morning Energizer')
|
||||
expect(collection!.icon).toBe('🌅')
|
||||
expect(collection!.workoutIds).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('should have No Equipment collection', () => {
|
||||
const collection = COLLECTIONS.find(c => c.id === 'no-equipment')
|
||||
expect(collection).toBeDefined()
|
||||
expect(collection!.title).toBe('No Equipment')
|
||||
expect(collection!.workoutIds.length).toBeGreaterThan(10)
|
||||
})
|
||||
|
||||
it('should have 7-Day Burn Challenge collection', () => {
|
||||
const collection = COLLECTIONS.find(c => c.id === '7-day-burn')
|
||||
expect(collection).toBeDefined()
|
||||
expect(collection!.title).toBe('7-Day Burn Challenge')
|
||||
expect(collection!.workoutIds).toHaveLength(7)
|
||||
expect(collection!.gradient).toBeDefined()
|
||||
})
|
||||
|
||||
it('should have Quick & Intense collection', () => {
|
||||
const collection = COLLECTIONS.find(c => c.id === 'quick-intense')
|
||||
expect(collection).toBeDefined()
|
||||
expect(collection!.title).toBe('Quick & Intense')
|
||||
expect(collection!.workoutIds.length).toBeGreaterThan(5)
|
||||
})
|
||||
|
||||
it('should have Core Focus collection', () => {
|
||||
const collection = COLLECTIONS.find(c => c.id === 'core-focus')
|
||||
expect(collection).toBeDefined()
|
||||
expect(collection!.title).toBe('Core Focus')
|
||||
expect(collection!.workoutIds).toHaveLength(6)
|
||||
})
|
||||
|
||||
it('should have Leg Day collection', () => {
|
||||
const collection = COLLECTIONS.find(c => c.id === 'leg-day')
|
||||
expect(collection).toBeDefined()
|
||||
expect(collection!.title).toBe('Leg Day')
|
||||
expect(collection!.workoutIds).toHaveLength(7)
|
||||
})
|
||||
})
|
||||
|
||||
describe('FEATURED_COLLECTION_ID', () => {
|
||||
it('should reference 7-day-burn', () => {
|
||||
expect(FEATURED_COLLECTION_ID).toBe('7-day-burn')
|
||||
})
|
||||
|
||||
it('should reference an existing collection', () => {
|
||||
const featured = COLLECTIONS.find(c => c.id === FEATURED_COLLECTION_ID)
|
||||
expect(featured).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('collection gradients', () => {
|
||||
it('should have gradient on 7-day-burn', () => {
|
||||
const collection = COLLECTIONS.find(c => c.id === '7-day-burn')
|
||||
expect(collection!.gradient).toBeDefined()
|
||||
expect(collection!.gradient).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should have valid hex colors in gradient', () => {
|
||||
const hexPattern = /^#[0-9A-Fa-f]{6}$/
|
||||
const collection = COLLECTIONS.find(c => c.gradient)
|
||||
if (collection?.gradient) {
|
||||
collection.gradient.forEach(color => {
|
||||
expect(color).toMatch(hexPattern)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('workout ID format', () => {
|
||||
it('should have string workout IDs', () => {
|
||||
COLLECTIONS.forEach(collection => {
|
||||
collection.workoutIds.forEach(id => {
|
||||
expect(typeof id).toBe('string')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should have numeric-like workout IDs', () => {
|
||||
const numericPattern = /^\d+$/
|
||||
COLLECTIONS.forEach(collection => {
|
||||
collection.workoutIds.forEach(id => {
|
||||
expect(id).toMatch(numericPattern)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { dataService } from '../../shared/data/dataService'
|
||||
import { WORKOUTS, TRAINERS, COLLECTIONS, PROGRAMS, ACHIEVEMENTS } from '../../shared/data'
|
||||
import type { Workout, Trainer, Collection, Program, Achievement } from '../../shared/types'
|
||||
import { WORKOUTS, TRAINERS, PROGRAMS, ACHIEVEMENTS } from '../../shared/data'
|
||||
import type { Workout, Trainer, Program, Achievement } from '../../shared/types'
|
||||
|
||||
vi.mock('../../shared/supabase', () => ({
|
||||
isSupabaseConfigured: vi.fn(() => false),
|
||||
@@ -130,30 +130,18 @@ describe('dataService', () => {
|
||||
})
|
||||
|
||||
describe('getAllCollections', () => {
|
||||
it('should return all collections', async () => {
|
||||
it('should return empty array when Supabase not configured', async () => {
|
||||
const collections = await dataService.getAllCollections()
|
||||
|
||||
expect(collections).toEqual(COLLECTIONS)
|
||||
})
|
||||
|
||||
it('should return collections with required properties', async () => {
|
||||
const collections = await dataService.getAllCollections()
|
||||
|
||||
collections.forEach((collection: Collection) => {
|
||||
expect(collection.id).toBeDefined()
|
||||
expect(collection.title).toBeDefined()
|
||||
expect(collection.workoutIds).toBeDefined()
|
||||
expect(Array.isArray(collection.workoutIds)).toBe(true)
|
||||
})
|
||||
expect(collections).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCollectionById', () => {
|
||||
it('should return collection by id', async () => {
|
||||
it('should return undefined when Supabase not configured', async () => {
|
||||
const collection = await dataService.getCollectionById('morning-energizer')
|
||||
|
||||
expect(collection).toBeDefined()
|
||||
expect(collection?.id).toBe('morning-energizer')
|
||||
expect(collection).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return undefined for non-existent collection', async () => {
|
||||
|
||||
@@ -82,6 +82,7 @@ describe('usePurchases', () => {
|
||||
barriers: [],
|
||||
syncStatus: 'never-synced',
|
||||
supabaseUserId: null,
|
||||
savedWorkouts: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -51,15 +51,9 @@ vi.mock('expo-video', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@expo/vector-icons', () => ({
|
||||
Ionicons: ({ name, size, color, style }: any) => {
|
||||
return React.createElement('Ionicons', { name, size, color, style, testID: `icon-${name}` })
|
||||
},
|
||||
FontAwesome: ({ name, size, color, style }: any) => {
|
||||
return React.createElement('FontAwesome', { name, size, color, style, testID: `icon-${name}` })
|
||||
},
|
||||
MaterialIcons: ({ name, size, color, style }: any) => {
|
||||
return React.createElement('MaterialIcons', { name, size, color, style, testID: `icon-${name}` })
|
||||
vi.mock('expo-symbols', () => ({
|
||||
SymbolView: ({ name, size, tintColor, style, weight, type }: any) => {
|
||||
return React.createElement('SymbolView', { name, size, tintColor, style, weight, type, testID: `icon-${name}` })
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ describe('userStore', () => {
|
||||
barriers: [],
|
||||
syncStatus: 'never-synced',
|
||||
supabaseUserId: null,
|
||||
savedWorkouts: [],
|
||||
},
|
||||
settings: {
|
||||
haptics: true,
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
/**
|
||||
* CollectionCard - Premium collection card with glassmorphism
|
||||
* Used in Home and Browse screens
|
||||
* Used in Explore and Browse screens
|
||||
* Supports 'default' and 'hero' variants
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useRef, useCallback } from 'react'
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
Animated,
|
||||
ImageBackground,
|
||||
Dimensions,
|
||||
useWindowDimensions,
|
||||
Text as RNText,
|
||||
} from 'react-native'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
@@ -18,24 +20,55 @@ import { BlurView } from 'expo-blur'
|
||||
import { useThemeColors, BRAND, GRADIENTS } from '@/src/shared/theme'
|
||||
import type { ThemeColors } from '@/src/shared/theme/types'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
|
||||
import { StyledText } from './StyledText'
|
||||
import type { Collection } from '@/src/shared/types'
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window')
|
||||
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
|
||||
|
||||
export type CollectionCardVariant = 'default' | 'hero' | 'horizontal'
|
||||
|
||||
interface CollectionCardProps {
|
||||
collection: Collection
|
||||
variant?: CollectionCardVariant
|
||||
onPress?: () => void
|
||||
imageUrl?: string
|
||||
workoutCountLabel?: string
|
||||
}
|
||||
|
||||
export function CollectionCard({ collection, onPress, imageUrl }: CollectionCardProps) {
|
||||
export function CollectionCard({ collection, variant = 'default', onPress, imageUrl, workoutCountLabel }: CollectionCardProps) {
|
||||
const colors = useThemeColors()
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
const { width: screenWidth } = useWindowDimensions()
|
||||
const styles = useMemo(() => createStyles(colors, screenWidth, variant), [colors, screenWidth, variant])
|
||||
|
||||
// Press animation
|
||||
const scaleValue = useRef(new Animated.Value(1)).current
|
||||
const handlePressIn = useCallback(() => {
|
||||
Animated.spring(scaleValue, {
|
||||
toValue: 0.97,
|
||||
useNativeDriver: true,
|
||||
speed: 50,
|
||||
bounciness: 4,
|
||||
}).start()
|
||||
}, [scaleValue])
|
||||
const handlePressOut = useCallback(() => {
|
||||
Animated.spring(scaleValue, {
|
||||
toValue: 1,
|
||||
useNativeDriver: true,
|
||||
speed: 30,
|
||||
bounciness: 6,
|
||||
}).start()
|
||||
}, [scaleValue])
|
||||
|
||||
const countLabel = workoutCountLabel ?? `${collection.workoutIds.length} workouts`
|
||||
|
||||
return (
|
||||
<Pressable style={styles.container} onPress={onPress}>
|
||||
<AnimatedPressable
|
||||
style={[styles.container, { transform: [{ scale: scaleValue }] }]}
|
||||
onPress={onPress}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
>
|
||||
{/* Background Image or Gradient */}
|
||||
{imageUrl ? (
|
||||
<ImageBackground
|
||||
@@ -70,7 +103,7 @@ export function CollectionCard({ collection, onPress, imageUrl }: CollectionCard
|
||||
</View>
|
||||
|
||||
<StyledText
|
||||
size={17}
|
||||
size={variant === 'hero' ? 22 : 17}
|
||||
weight="bold"
|
||||
color="#FFFFFF"
|
||||
numberOfLines={2}
|
||||
@@ -79,26 +112,52 @@ export function CollectionCard({ collection, onPress, imageUrl }: CollectionCard
|
||||
{collection.title}
|
||||
</StyledText>
|
||||
|
||||
{variant === 'hero' && (
|
||||
<StyledText
|
||||
size={14}
|
||||
color="rgba(255,255,255,0.8)"
|
||||
numberOfLines={2}
|
||||
style={{ marginBottom: SPACING[1] }}
|
||||
>
|
||||
{collection.description}
|
||||
</StyledText>
|
||||
)}
|
||||
|
||||
<StyledText
|
||||
size={13}
|
||||
weight="medium"
|
||||
color="rgba(255,255,255,0.7)"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{collection.workoutIds.length} workouts
|
||||
{countLabel}
|
||||
</StyledText>
|
||||
</View>
|
||||
</Pressable>
|
||||
</AnimatedPressable>
|
||||
)
|
||||
}
|
||||
|
||||
function createStyles(colors: ThemeColors) {
|
||||
const cardWidth = (SCREEN_WIDTH - SPACING[6] * 2 - SPACING[3]) / 2
|
||||
function createStyles(colors: ThemeColors, screenWidth: number, variant: CollectionCardVariant) {
|
||||
const defaultCardWidth = (screenWidth - LAYOUT.SCREEN_PADDING * 2 - SPACING[3]) / 2
|
||||
const horizontalCardWidth = screenWidth * 0.65
|
||||
|
||||
const containerByVariant = {
|
||||
default: {
|
||||
width: defaultCardWidth,
|
||||
aspectRatio: 1 as number,
|
||||
},
|
||||
hero: {
|
||||
width: screenWidth - LAYOUT.SCREEN_PADDING * 2,
|
||||
height: 200,
|
||||
},
|
||||
horizontal: {
|
||||
width: horizontalCardWidth,
|
||||
height: 180,
|
||||
},
|
||||
}
|
||||
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
width: cardWidth,
|
||||
aspectRatio: 1,
|
||||
...containerByVariant[variant],
|
||||
borderRadius: RADIUS.XL,
|
||||
overflow: 'hidden',
|
||||
...colors.shadow.md,
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useThemeColors } from '@/src/shared/theme'
|
||||
import { StyledText } from '@/src/shared/components/StyledText'
|
||||
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import { Icon } from '@/src/shared/components/Icon'
|
||||
|
||||
interface DataDeletionModalProps {
|
||||
visible: boolean
|
||||
@@ -51,7 +51,7 @@ export function DataDeletionModal({
|
||||
{ backgroundColor: 'rgba(255, 59, 48, 0.1)' },
|
||||
]}
|
||||
>
|
||||
<Ionicons name="warning" size={40} color="#FF3B30" />
|
||||
<Icon name="exclamationmark.triangle.fill" size={40} tintColor="#FF3B30" />
|
||||
</View>
|
||||
|
||||
{/* Title */}
|
||||
|
||||
52
src/shared/components/Icon.tsx
Normal file
52
src/shared/components/Icon.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Icon component — wraps expo-symbols SymbolView for SF Symbols
|
||||
* Drop-in replacement for Ionicons across the app
|
||||
*/
|
||||
|
||||
import { SymbolView, type SymbolViewProps } from 'expo-symbols'
|
||||
import type { SFSymbol } from 'sf-symbols-typescript'
|
||||
import type { ColorValue, ViewStyle, StyleProp } from 'react-native'
|
||||
|
||||
export type IconName = SFSymbol
|
||||
|
||||
export type IconProps = {
|
||||
/** SF Symbol name (e.g. 'flame.fill', 'play.fill') */
|
||||
name: IconName
|
||||
/** Size in points */
|
||||
size?: number
|
||||
/** Tint color applied to the symbol */
|
||||
tintColor?: ColorValue
|
||||
/** Alias for tintColor (Ionicons compat) */
|
||||
color?: ColorValue
|
||||
/** Symbol weight */
|
||||
weight?: SymbolViewProps['weight']
|
||||
/** Symbol rendering type */
|
||||
type?: SymbolViewProps['type']
|
||||
/** Animation configuration */
|
||||
animationSpec?: SymbolViewProps['animationSpec']
|
||||
/** View style (margin, position, etc.) */
|
||||
style?: StyleProp<ViewStyle>
|
||||
}
|
||||
|
||||
export function Icon({
|
||||
name,
|
||||
size = 24,
|
||||
tintColor,
|
||||
color,
|
||||
weight,
|
||||
type = 'monochrome',
|
||||
animationSpec,
|
||||
style,
|
||||
}: IconProps) {
|
||||
return (
|
||||
<SymbolView
|
||||
name={name}
|
||||
size={size}
|
||||
tintColor={tintColor ?? color}
|
||||
weight={weight}
|
||||
type={type}
|
||||
animationSpec={animationSpec}
|
||||
style={style}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -4,8 +4,9 @@
|
||||
*/
|
||||
|
||||
import { useRef, useEffect, useMemo } from 'react'
|
||||
import { View, StyleSheet, Animated, Dimensions } from 'react-native'
|
||||
import { View, StyleSheet, Animated, Dimensions, Pressable } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { Icon } from './Icon'
|
||||
import { useThemeColors, BRAND } from '../theme'
|
||||
import type { ThemeColors } from '../theme/types'
|
||||
import { SPACING, LAYOUT } from '../constants/spacing'
|
||||
@@ -17,9 +18,10 @@ interface OnboardingStepProps {
|
||||
step: number
|
||||
totalSteps: number
|
||||
children: React.ReactNode
|
||||
onBack?: () => void
|
||||
}
|
||||
|
||||
export function OnboardingStep({ step, totalSteps, children }: OnboardingStepProps) {
|
||||
export function OnboardingStep({ step, totalSteps, children, onBack }: OnboardingStepProps) {
|
||||
const colors = useThemeColors()
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
const insets = useSafeAreaInsets()
|
||||
@@ -69,6 +71,18 @@ export function OnboardingStep({ step, totalSteps, children }: OnboardingStepPro
|
||||
<Animated.View style={[styles.progressFill, { width: progressWidth }]} />
|
||||
</View>
|
||||
|
||||
{/* Back button — visible on steps 2+ */}
|
||||
{onBack && step > 1 && (
|
||||
<Pressable
|
||||
style={styles.backButton}
|
||||
onPress={onBack}
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
testID="onboarding-back-button"
|
||||
>
|
||||
<Icon name="chevron.left" size={24} tintColor={colors.text.secondary} />
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{/* Step content */}
|
||||
<Animated.View
|
||||
style={[
|
||||
@@ -104,6 +118,14 @@ function createStyles(colors: ThemeColors) {
|
||||
backgroundColor: BRAND.PRIMARY,
|
||||
borderRadius: 2,
|
||||
},
|
||||
backButton: {
|
||||
marginTop: SPACING[3],
|
||||
marginLeft: SPACING[3],
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
|
||||
@@ -12,7 +12,7 @@ import { StyledText } from '@/src/shared/components/StyledText'
|
||||
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { BRAND } from '@/src/shared/constants/colors'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import { Icon, type IconName } from '@/src/shared/components/Icon'
|
||||
|
||||
interface SyncConsentModalProps {
|
||||
visible: boolean
|
||||
@@ -48,7 +48,7 @@ export function SyncConsentModal({
|
||||
>
|
||||
{/* Icon */}
|
||||
<View style={styles.iconContainer}>
|
||||
<Ionicons name="sparkles" size={40} color={BRAND.PRIMARY} />
|
||||
<Icon name="sparkles" size={40} tintColor={BRAND.PRIMARY} />
|
||||
</View>
|
||||
|
||||
{/* Title */}
|
||||
@@ -64,22 +64,22 @@ export function SyncConsentModal({
|
||||
{/* Benefits */}
|
||||
<View style={styles.benefits}>
|
||||
<BenefitRow
|
||||
icon="trending-up"
|
||||
icon="arrow.up.right"
|
||||
text={t('sync.benefits.recommendations')}
|
||||
colors={colors}
|
||||
/>
|
||||
<BenefitRow
|
||||
icon="fitness"
|
||||
icon="figure.run"
|
||||
text={t('sync.benefits.adaptive')}
|
||||
colors={colors}
|
||||
/>
|
||||
<BenefitRow
|
||||
icon="sync"
|
||||
icon="arrow.triangle.2.circlepath"
|
||||
text={t('sync.benefits.sync')}
|
||||
colors={colors}
|
||||
/>
|
||||
<BenefitRow
|
||||
icon="shield-checkmark"
|
||||
icon="checkmark.shield.fill"
|
||||
text={t('sync.benefits.secure')}
|
||||
colors={colors}
|
||||
/>
|
||||
@@ -121,16 +121,16 @@ function BenefitRow({
|
||||
text,
|
||||
colors,
|
||||
}: {
|
||||
icon: string
|
||||
icon: IconName
|
||||
text: string
|
||||
colors: any
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.benefitRow}>
|
||||
<Ionicons
|
||||
name={icon as any}
|
||||
<Icon
|
||||
name={icon}
|
||||
size={22}
|
||||
color={BRAND.PRIMARY}
|
||||
tintColor={BRAND.PRIMARY}
|
||||
/>
|
||||
<StyledText
|
||||
size={15}
|
||||
|
||||
@@ -3,19 +3,20 @@
|
||||
* Used in Home and Browse screens
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useRef, useCallback } from 'react'
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
Animated,
|
||||
ImageBackground,
|
||||
Dimensions,
|
||||
useWindowDimensions,
|
||||
Text as RNText,
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import { BlurView } from 'expo-blur'
|
||||
import Ionicons from '@expo/vector-icons/Ionicons'
|
||||
import { Icon } from './Icon'
|
||||
|
||||
import { useThemeColors, BRAND, GRADIENTS } from '@/src/shared/theme'
|
||||
import type { ThemeColors } from '@/src/shared/theme/types'
|
||||
@@ -24,7 +25,7 @@ import { SPACING } from '@/src/shared/constants/spacing'
|
||||
import { StyledText } from './StyledText'
|
||||
import type { Workout, WorkoutCategory } from '@/src/shared/types'
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window')
|
||||
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
|
||||
|
||||
export type WorkoutCardVariant = 'horizontal' | 'grid' | 'featured'
|
||||
|
||||
@@ -34,9 +35,11 @@ interface WorkoutCardProps {
|
||||
onPress?: () => void
|
||||
title?: string
|
||||
metadata?: string
|
||||
trainerName?: string
|
||||
isLocked?: boolean
|
||||
}
|
||||
|
||||
const CATEGORY_COLORS: Record<WorkoutCategory, string> = {
|
||||
export const CATEGORY_COLORS: Record<WorkoutCategory, string> = {
|
||||
'full-body': BRAND.PRIMARY,
|
||||
'core': '#5AC8FA',
|
||||
'upper-body': '#BF5AF2',
|
||||
@@ -52,11 +55,11 @@ const CATEGORY_LABELS: Record<WorkoutCategory, string> = {
|
||||
'cardio': 'Cardio',
|
||||
}
|
||||
|
||||
function getVariantDimensions(variant: WorkoutCardVariant): ViewStyle {
|
||||
function getVariantDimensions(variant: WorkoutCardVariant, screenWidth: number): ViewStyle {
|
||||
switch (variant) {
|
||||
case 'featured':
|
||||
return {
|
||||
width: SCREEN_WIDTH - SPACING[6] * 2,
|
||||
width: screenWidth - SPACING[6] * 2,
|
||||
height: 320,
|
||||
}
|
||||
case 'horizontal':
|
||||
@@ -79,19 +82,48 @@ export function WorkoutCard({
|
||||
onPress,
|
||||
title,
|
||||
metadata,
|
||||
trainerName,
|
||||
isLocked,
|
||||
}: WorkoutCardProps) {
|
||||
const colors = useThemeColors()
|
||||
const { width: screenWidth } = useWindowDimensions()
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
const dimensions = useMemo(() => getVariantDimensions(variant), [variant])
|
||||
const dimensions = useMemo(() => getVariantDimensions(variant, screenWidth), [variant, screenWidth])
|
||||
|
||||
// Press animation
|
||||
const scaleValue = useRef(new Animated.Value(1)).current
|
||||
const handlePressIn = useCallback(() => {
|
||||
Animated.spring(scaleValue, {
|
||||
toValue: 0.96,
|
||||
useNativeDriver: true,
|
||||
speed: 50,
|
||||
bounciness: 4,
|
||||
}).start()
|
||||
}, [scaleValue])
|
||||
const handlePressOut = useCallback(() => {
|
||||
Animated.spring(scaleValue, {
|
||||
toValue: 1,
|
||||
useNativeDriver: true,
|
||||
speed: 30,
|
||||
bounciness: 6,
|
||||
}).start()
|
||||
}, [scaleValue])
|
||||
|
||||
const displayTitle = title ?? workout.title
|
||||
const displayMetadata = metadata ?? `${workout.duration} min • ${workout.calories} cal`
|
||||
const metaParts = [
|
||||
`${workout.duration} min`,
|
||||
`${workout.calories} cal`,
|
||||
...(trainerName ? [trainerName] : []),
|
||||
]
|
||||
const displayMetadata = metadata ?? metaParts.join(' · ')
|
||||
const categoryColor = CATEGORY_COLORS[workout.category]
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={[styles.container, dimensions]}
|
||||
<AnimatedPressable
|
||||
style={[styles.container, dimensions, { transform: [{ scale: scaleValue }] }]}
|
||||
onPress={onPress}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
>
|
||||
{/* Background Image */}
|
||||
<ImageBackground
|
||||
@@ -119,7 +151,7 @@ export function WorkoutCard({
|
||||
<View style={styles.playButtonContainer}>
|
||||
<View style={styles.playButton}>
|
||||
<BlurView intensity={colors.glass.blurMedium} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
|
||||
<Ionicons name="play" size={24} color="#FFFFFF" style={{ marginLeft: 2 }} />
|
||||
<Icon name={isLocked ? 'lock.fill' : 'play.fill'} size={24} tintColor="#FFFFFF" style={isLocked ? undefined : { marginLeft: 2 }} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -143,7 +175,7 @@ export function WorkoutCard({
|
||||
{displayMetadata}
|
||||
</StyledText>
|
||||
</View>
|
||||
</Pressable>
|
||||
</AnimatedPressable>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,54 +1,7 @@
|
||||
/**
|
||||
* TabataFit Collections
|
||||
* Legacy collections (keeping for reference during migration)
|
||||
* Collections are fetched from Supabase at runtime.
|
||||
* Seed data lives in supabase/seed.ts.
|
||||
*/
|
||||
|
||||
import type { Collection } from '../types'
|
||||
|
||||
export const COLLECTIONS: Collection[] = [
|
||||
{
|
||||
id: 'morning-energizer',
|
||||
title: 'Morning Energizer',
|
||||
description: 'Start your day right',
|
||||
icon: '🌅',
|
||||
workoutIds: ['4', '6', '43', '47', '10'],
|
||||
},
|
||||
{
|
||||
id: 'no-equipment',
|
||||
title: 'No Equipment',
|
||||
description: 'Workout anywhere',
|
||||
icon: '💪',
|
||||
workoutIds: ['1', '4', '6', '11', '13', '16', '17', '19', '23', '26', '31', '38', '42', '43', '45'],
|
||||
},
|
||||
{
|
||||
id: '7-day-burn',
|
||||
title: '7-Day Burn Challenge',
|
||||
description: 'Transform in one week',
|
||||
icon: '🔥',
|
||||
workoutIds: ['1', '11', '31', '42', '6', '17', '23'],
|
||||
gradient: ['#FF6B35', '#FF3B30'],
|
||||
},
|
||||
{
|
||||
id: 'quick-intense',
|
||||
title: 'Quick & Intense',
|
||||
description: 'Max effort in 4 minutes',
|
||||
icon: '⚡',
|
||||
workoutIds: ['1', '11', '23', '35', '38', '42', '6', '17'],
|
||||
},
|
||||
{
|
||||
id: 'core-focus',
|
||||
title: 'Core Focus',
|
||||
description: 'Build a solid foundation',
|
||||
icon: '🎯',
|
||||
workoutIds: ['11', '12', '13', '14', '16', '17'],
|
||||
},
|
||||
{
|
||||
id: 'leg-day',
|
||||
title: 'Leg Day',
|
||||
description: 'Never skip leg day',
|
||||
icon: '🦵',
|
||||
workoutIds: ['31', '32', '33', '34', '35', '36', '37'],
|
||||
},
|
||||
]
|
||||
|
||||
export const FEATURED_COLLECTION_ID = '7-day-burn'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { supabase, isSupabaseConfigured } from '../supabase'
|
||||
import { WORKOUTS, TRAINERS, COLLECTIONS, PROGRAMS, ACHIEVEMENTS } from './index'
|
||||
import { WORKOUTS, TRAINERS, PROGRAMS, ACHIEVEMENTS } from './index'
|
||||
import type { Workout, Trainer, Collection, Program, ProgramId } from '../types'
|
||||
import type { Database } from '../supabase/database.types'
|
||||
|
||||
@@ -208,7 +208,7 @@ class SupabaseDataService {
|
||||
|
||||
async getAllCollections(): Promise<Collection[]> {
|
||||
if (!isSupabaseConfigured()) {
|
||||
return COLLECTIONS
|
||||
return []
|
||||
}
|
||||
|
||||
const { data: collectionsData, error: collectionsError } = await supabase
|
||||
@@ -217,7 +217,7 @@ class SupabaseDataService {
|
||||
|
||||
if (collectionsError) {
|
||||
console.error('Error fetching collections:', collectionsError)
|
||||
return COLLECTIONS
|
||||
return []
|
||||
}
|
||||
|
||||
const { data: workoutLinks, error: linksError } = await supabase
|
||||
@@ -227,7 +227,7 @@ class SupabaseDataService {
|
||||
|
||||
if (linksError) {
|
||||
console.error('Error fetching collection workouts:', linksError)
|
||||
return COLLECTIONS
|
||||
return []
|
||||
}
|
||||
|
||||
const workoutIdsByCollection: Record<string, string[]> = {}
|
||||
@@ -240,12 +240,12 @@ class SupabaseDataService {
|
||||
|
||||
return collectionsData?.map((row: CollectionRow) =>
|
||||
mapCollectionFromDB(row, workoutIdsByCollection[row.id] || [])
|
||||
) ?? COLLECTIONS
|
||||
) ?? []
|
||||
}
|
||||
|
||||
async getCollectionById(id: string): Promise<Collection | undefined> {
|
||||
if (!isSupabaseConfigured()) {
|
||||
return COLLECTIONS.find((c: Collection) => c.id === id)
|
||||
return undefined
|
||||
}
|
||||
|
||||
const { data: collection, error: collectionError } = await supabase
|
||||
@@ -256,7 +256,7 @@ class SupabaseDataService {
|
||||
|
||||
if (collectionError || !collection) {
|
||||
console.error('Error fetching collection:', collectionError)
|
||||
return COLLECTIONS.find((c: Collection) => c.id === id)
|
||||
return undefined
|
||||
}
|
||||
|
||||
const { data: workoutLinks, error: linksError } = await supabase
|
||||
@@ -267,7 +267,7 @@ class SupabaseDataService {
|
||||
|
||||
if (linksError) {
|
||||
console.error('Error fetching collection workouts:', linksError)
|
||||
return COLLECTIONS.find((c: Collection) => c.id === id)
|
||||
return undefined
|
||||
}
|
||||
|
||||
const workoutIds = workoutLinks?.map((link: { workout_id: string }) => link.workout_id) || []
|
||||
|
||||
@@ -75,4 +75,4 @@ export const CATEGORIES: { id: ProgramId | 'all'; label: string }[] = [
|
||||
|
||||
// Legacy exports for backward compatibility (to be removed)
|
||||
export { WORKOUTS } from './workouts'
|
||||
export { COLLECTIONS, FEATURED_COLLECTION_ID } from './collections'
|
||||
export { FEATURED_COLLECTION_ID } from './collections'
|
||||
|
||||
@@ -28,20 +28,33 @@
|
||||
"allWorkouts": "Alle Workouts",
|
||||
"trainers": "Trainer",
|
||||
"noResults": "Keine Workouts gefunden",
|
||||
"tryAdjustingFilters": "Versuchen Sie, Ihre Filter anzupassen",
|
||||
"tryAdjustingFilters": "Versuchen Sie, Ihre Filter oder Suche anzupassen",
|
||||
"loading": "Wird geladen...",
|
||||
"filterCategory": "Kategorie",
|
||||
"filterLevel": "Niveau",
|
||||
"filterEquipment": "Ausrüstung",
|
||||
"filterDuration": "Dauer",
|
||||
"clearFilters": "Filter löschen",
|
||||
"clearFilters": "Löschen",
|
||||
"workoutsCount": "{{count}} Workouts",
|
||||
"workouts": "Workouts",
|
||||
"equipmentOptions": {
|
||||
"none": "Ohne Ausrüstung",
|
||||
"band": "Widerstandsband",
|
||||
"dumbbells": "Hanteln",
|
||||
"mat": "Matte"
|
||||
}
|
||||
},
|
||||
"allEquipment": "Alle Ausrüstung",
|
||||
"searchPlaceholder": "Workouts, Trainer suchen...",
|
||||
"recommendedForYou": "Empfohlen für dich",
|
||||
"tryNewCategory": "Probiere etwas Neues",
|
||||
"startFirstWorkout": "Schließe dein erstes Workout ab für personalisierte Empfehlungen",
|
||||
"filters": "Filter",
|
||||
"activeFilters": "{{count}} aktiv",
|
||||
"applyFilters": "Anwenden",
|
||||
"resetFilters": "Zurücksetzen",
|
||||
"errorTitle": "Workouts konnten nicht geladen werden",
|
||||
"errorRetry": "Tippe zum Wiederholen",
|
||||
"featuredCollection": "Empfohlene Sammlung"
|
||||
},
|
||||
|
||||
"activity": {
|
||||
@@ -58,7 +71,10 @@
|
||||
"today": "Heute",
|
||||
"yesterday": "Gestern",
|
||||
"daysAgo": "vor {{count}} T.",
|
||||
"achievements": "Erfolge"
|
||||
"achievements": "Erfolge",
|
||||
"emptyTitle": "Noch keine Aktivität",
|
||||
"emptySubtitle": "Absolviere dein erstes Workout und deine Statistiken erscheinen hier.",
|
||||
"startFirstWorkout": "Starte dein erstes Workout"
|
||||
},
|
||||
|
||||
"browse": {
|
||||
@@ -193,6 +209,29 @@
|
||||
"unlockWithPremium": "MIT TABATAFIT+ FREISCHALTEN"
|
||||
},
|
||||
|
||||
"paywall": {
|
||||
"subtitle": "Schalte alle Funktionen frei und erreiche deine Ziele schneller",
|
||||
"features": {
|
||||
"music": "Premium-Musik",
|
||||
"workouts": "Unbegrenzte Workouts",
|
||||
"stats": "Erweiterte Statistiken",
|
||||
"calories": "Kalorienverfolgung",
|
||||
"reminders": "Tägliche Erinnerungen",
|
||||
"ads": "Keine Werbung"
|
||||
},
|
||||
"yearly": "Jährlich",
|
||||
"monthly": "Monatlich",
|
||||
"perYear": "pro Jahr",
|
||||
"perMonth": "pro Monat",
|
||||
"save50": "50% SPAREN",
|
||||
"equivalent": "Nur {{price}}/Monat",
|
||||
"subscribe": "Jetzt Abonnieren",
|
||||
"trialCta": "Kostenlos Testen",
|
||||
"processing": "Verarbeitung...",
|
||||
"restore": "Käufe Wiederherstellen",
|
||||
"terms": "Die Zahlung wird bei Bestätigung deiner Apple-ID belastet. Das Abonnement verlängert sich automatisch, sofern es nicht mindestens 24 Stunden vor Ablauf des Zeitraums gekündigt wird. Verwalte es in den Kontoeinstellungen."
|
||||
},
|
||||
|
||||
"onboarding": {
|
||||
"problem": {
|
||||
"title": "Du hast keine Stunde\nfürs Fitnessstudio.",
|
||||
@@ -327,6 +366,10 @@
|
||||
"startAssessment": "Bewertung starten",
|
||||
"skipForNow": "Vorerst \u00fcberspringen",
|
||||
"tips": "Tipps f\u00fcr beste Ergebnisse",
|
||||
"tip1": "Bewegen Sie sich in Ihrem eigenen Tempo",
|
||||
"tip2": "Achten Sie auf die Form, nicht auf die Geschwindigkeit",
|
||||
"tip3": "Dies hilft uns, das beste Programm zu empfehlen",
|
||||
"tip4": "Kein Urteil - nur ein Ausgangspunkt!",
|
||||
"duration": "Dauer",
|
||||
"exercises": "\u00dcbungen"
|
||||
}
|
||||
|
||||
@@ -28,20 +28,33 @@
|
||||
"allWorkouts": "All Workouts",
|
||||
"trainers": "Trainers",
|
||||
"noResults": "No workouts found",
|
||||
"tryAdjustingFilters": "Try adjusting your filters",
|
||||
"tryAdjustingFilters": "Try adjusting your filters or search",
|
||||
"loading": "Loading...",
|
||||
"filterCategory": "Category",
|
||||
"filterLevel": "Level",
|
||||
"filterEquipment": "Equipment",
|
||||
"filterDuration": "Duration",
|
||||
"clearFilters": "Clear Filters",
|
||||
"clearFilters": "Clear",
|
||||
"workoutsCount": "{{count}} workouts",
|
||||
"workouts": "Workouts",
|
||||
"equipmentOptions": {
|
||||
"none": "No Equipment",
|
||||
"band": "Resistance Band",
|
||||
"dumbbells": "Dumbbells",
|
||||
"mat": "Mat"
|
||||
}
|
||||
},
|
||||
"allEquipment": "All Equipment",
|
||||
"searchPlaceholder": "Search workouts, trainers...",
|
||||
"recommendedForYou": "Recommended for You",
|
||||
"tryNewCategory": "Try something new",
|
||||
"startFirstWorkout": "Start your first workout to get personalized recommendations",
|
||||
"filters": "Filters",
|
||||
"activeFilters": "{{count}} active",
|
||||
"applyFilters": "Apply Filters",
|
||||
"resetFilters": "Reset",
|
||||
"errorTitle": "Couldn't load workouts",
|
||||
"errorRetry": "Tap to retry",
|
||||
"featuredCollection": "Featured Collection"
|
||||
},
|
||||
|
||||
"activity": {
|
||||
@@ -58,7 +71,10 @@
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday",
|
||||
"daysAgo": "{{count}}d ago",
|
||||
"achievements": "Achievements"
|
||||
"achievements": "Achievements",
|
||||
"emptyTitle": "No Activity Yet",
|
||||
"emptySubtitle": "Complete your first workout and your stats will appear here.",
|
||||
"startFirstWorkout": "Start Your First Workout"
|
||||
},
|
||||
|
||||
"browse": {
|
||||
@@ -210,6 +226,7 @@
|
||||
"save50": "SAVE 50%",
|
||||
"equivalent": "Just {{price}}/month",
|
||||
"subscribe": "Subscribe Now",
|
||||
"trialCta": "Start Free Trial",
|
||||
"processing": "Processing...",
|
||||
"restore": "Restore Purchases",
|
||||
"terms": "Payment will be charged to your Apple ID at confirmation. Subscription auto-renews unless cancelled at least 24 hours before end of period. Manage in Account Settings."
|
||||
@@ -386,6 +403,10 @@
|
||||
"startAssessment": "Start Assessment",
|
||||
"skipForNow": "Skip for now",
|
||||
"tips": "Tips for best results",
|
||||
"tip1": "Move at your own pace",
|
||||
"tip2": "Focus on form, not speed",
|
||||
"tip3": "This helps us recommend the best program",
|
||||
"tip4": "No judgment - just a starting point!",
|
||||
"duration": "Duration",
|
||||
"exercises": "Exercises"
|
||||
}
|
||||
|
||||
@@ -28,20 +28,33 @@
|
||||
"allWorkouts": "Todos los entrenos",
|
||||
"trainers": "Entrenadores",
|
||||
"noResults": "No se encontraron entrenos",
|
||||
"tryAdjustingFilters": "Intenta ajustar tus filtros",
|
||||
"tryAdjustingFilters": "Intenta ajustar tus filtros o búsqueda",
|
||||
"loading": "Cargando...",
|
||||
"filterCategory": "Categoría",
|
||||
"filterLevel": "Nivel",
|
||||
"filterEquipment": "Equipo",
|
||||
"filterDuration": "Duración",
|
||||
"clearFilters": "Borrar filtros",
|
||||
"clearFilters": "Borrar",
|
||||
"workoutsCount": "{{count}} entrenos",
|
||||
"workouts": "Entrenos",
|
||||
"equipmentOptions": {
|
||||
"none": "Sin equipo",
|
||||
"band": "Banda elástica",
|
||||
"dumbbells": "Mancuernas",
|
||||
"mat": "Colchoneta"
|
||||
}
|
||||
},
|
||||
"allEquipment": "Todo el equipo",
|
||||
"searchPlaceholder": "Buscar entrenos, entrenadores...",
|
||||
"recommendedForYou": "Recomendado para ti",
|
||||
"tryNewCategory": "Prueba algo nuevo",
|
||||
"startFirstWorkout": "Completa tu primer entreno para recomendaciones personalizadas",
|
||||
"filters": "Filtros",
|
||||
"activeFilters": "{{count}} activos",
|
||||
"applyFilters": "Aplicar",
|
||||
"resetFilters": "Restablecer",
|
||||
"errorTitle": "No se pudieron cargar los entrenos",
|
||||
"errorRetry": "Toca para reintentar",
|
||||
"featuredCollection": "Colección destacada"
|
||||
},
|
||||
|
||||
"activity": {
|
||||
@@ -58,7 +71,10 @@
|
||||
"today": "Hoy",
|
||||
"yesterday": "Ayer",
|
||||
"daysAgo": "hace {{count}}d",
|
||||
"achievements": "Logros"
|
||||
"achievements": "Logros",
|
||||
"emptyTitle": "Sin actividad aún",
|
||||
"emptySubtitle": "Completa tu primer entreno y tus estadísticas aparecerán aquí.",
|
||||
"startFirstWorkout": "Comienza tu primer entreno"
|
||||
},
|
||||
|
||||
"browse": {
|
||||
@@ -193,6 +209,29 @@
|
||||
"unlockWithPremium": "DESBLOQUEAR CON TABATAFIT+"
|
||||
},
|
||||
|
||||
"paywall": {
|
||||
"subtitle": "Desbloquea todas las funciones y alcanza tus metas más rápido",
|
||||
"features": {
|
||||
"music": "Música Premium",
|
||||
"workouts": "Entrenos Ilimitados",
|
||||
"stats": "Estadísticas Avanzadas",
|
||||
"calories": "Seguimiento de Calorías",
|
||||
"reminders": "Recordatorios Diarios",
|
||||
"ads": "Sin Anuncios"
|
||||
},
|
||||
"yearly": "Anual",
|
||||
"monthly": "Mensual",
|
||||
"perYear": "por año",
|
||||
"perMonth": "por mes",
|
||||
"save50": "AHORRA 50%",
|
||||
"equivalent": "Solo {{price}}/mes",
|
||||
"subscribe": "Suscribirse Ahora",
|
||||
"trialCta": "Empezar Prueba Gratis",
|
||||
"processing": "Procesando...",
|
||||
"restore": "Restaurar Compras",
|
||||
"terms": "El pago se cargará a tu Apple ID al confirmar. La suscripción se renueva automáticamente a menos que se cancele al menos 24 horas antes del final del período. Gestiona en Ajustes de la cuenta."
|
||||
},
|
||||
|
||||
"onboarding": {
|
||||
"problem": {
|
||||
"title": "No tienes 1 hora\npara el gimnasio.",
|
||||
@@ -327,7 +366,11 @@
|
||||
"startAssessment": "Iniciar evaluaci\u00f3n",
|
||||
"skipForNow": "Omitir por ahora",
|
||||
"tips": "Consejos para mejores resultados",
|
||||
"duration": "Duraci\u00f3n",
|
||||
"tip1": "Muévete a tu propio ritmo",
|
||||
"tip2": "Concéntrate en la forma, no en la velocidad",
|
||||
"tip3": "Esto nos ayuda a recomendar el mejor programa",
|
||||
"tip4": "Sin juicios - ¡solo un punto de partida!",
|
||||
"duration": "Duración",
|
||||
"exercises": "Ejercicios"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,20 +28,33 @@
|
||||
"allWorkouts": "Tous les exercices",
|
||||
"trainers": "Entraîneurs",
|
||||
"noResults": "Aucun exercice trouvé",
|
||||
"tryAdjustingFilters": "Essayez d'ajuster vos filtres",
|
||||
"tryAdjustingFilters": "Essayez d'ajuster vos filtres ou votre recherche",
|
||||
"loading": "Chargement...",
|
||||
"filterCategory": "Catégorie",
|
||||
"filterLevel": "Niveau",
|
||||
"filterEquipment": "Équipement",
|
||||
"filterDuration": "Durée",
|
||||
"clearFilters": "Effacer les filtres",
|
||||
"clearFilters": "Effacer",
|
||||
"workoutsCount": "{{count}} exercices",
|
||||
"workouts": "Exercices",
|
||||
"equipmentOptions": {
|
||||
"none": "Sans équipement",
|
||||
"band": "Bande élastique",
|
||||
"dumbbells": "Haltères",
|
||||
"mat": "Tapis"
|
||||
}
|
||||
},
|
||||
"allEquipment": "Tout l'équipement",
|
||||
"searchPlaceholder": "Rechercher exercices, entraîneurs...",
|
||||
"recommendedForYou": "Recommandé pour vous",
|
||||
"tryNewCategory": "Essayez quelque chose de nouveau",
|
||||
"startFirstWorkout": "Complétez votre premier exercice pour des recommandations personnalisées",
|
||||
"filters": "Filtres",
|
||||
"activeFilters": "{{count}} actifs",
|
||||
"applyFilters": "Appliquer",
|
||||
"resetFilters": "Réinitialiser",
|
||||
"errorTitle": "Impossible de charger les exercices",
|
||||
"errorRetry": "Appuyez pour réessayer",
|
||||
"featuredCollection": "Collection en vedette"
|
||||
},
|
||||
|
||||
"activity": {
|
||||
@@ -58,7 +71,10 @@
|
||||
"today": "Aujourd'hui",
|
||||
"yesterday": "Hier",
|
||||
"daysAgo": "il y a {{count}}j",
|
||||
"achievements": "Succès"
|
||||
"achievements": "Succès",
|
||||
"emptyTitle": "Aucune activité",
|
||||
"emptySubtitle": "Terminez votre premier entraînement et vos statistiques apparaîtront ici.",
|
||||
"startFirstWorkout": "Commencez votre premier entraînement"
|
||||
},
|
||||
|
||||
"browse": {
|
||||
@@ -210,6 +226,7 @@
|
||||
"save50": "ÉCONOMISEZ 50%",
|
||||
"equivalent": "Seulement {{price}}/mois",
|
||||
"subscribe": "S'abonner maintenant",
|
||||
"trialCta": "Commencer l'essai gratuit",
|
||||
"processing": "Traitement...",
|
||||
"restore": "Restaurer les achats",
|
||||
"terms": "Le paiement sera débité sur votre identifiant Apple à la confirmation. L'abonnement se renouvelle automatiquement sauf annulation au moins 24h avant la fin de la période. Gérez dans les réglages du compte."
|
||||
@@ -386,6 +403,10 @@
|
||||
"startAssessment": "Commencer l'évaluation",
|
||||
"skipForNow": "Passer pour l'instant",
|
||||
"tips": "Conseils pour de meilleurs résultats",
|
||||
"tip1": "Bougez à votre rythme",
|
||||
"tip2": "Concentrez-vous sur la forme, pas la vitesse",
|
||||
"tip3": "Cela nous aide à recommander le meilleur programme",
|
||||
"tip4": "Sans jugement - juste un point de départ !",
|
||||
"duration": "Durée",
|
||||
"exercises": "Exercices"
|
||||
}
|
||||
|
||||
30
src/shared/stores/exploreFilterStore.ts
Normal file
30
src/shared/stores/exploreFilterStore.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* TabataFit Explore Filter Store
|
||||
* Lightweight Zustand store (no persistence) for sharing filter state
|
||||
* between the Explore screen and the filter sheet modal.
|
||||
*/
|
||||
|
||||
import { create } from 'zustand'
|
||||
import type { WorkoutLevel } from '../types'
|
||||
|
||||
interface ExploreFilterState {
|
||||
level: WorkoutLevel | 'all'
|
||||
equipment: string | 'all'
|
||||
/** Derived equipment options from workout data — set once by Explore screen */
|
||||
equipmentOptions: string[]
|
||||
// Actions
|
||||
setLevel: (level: WorkoutLevel | 'all') => void
|
||||
setEquipment: (equipment: string | 'all') => void
|
||||
setEquipmentOptions: (options: string[]) => void
|
||||
resetFilters: () => void
|
||||
}
|
||||
|
||||
export const useExploreFilterStore = create<ExploreFilterState>()((set) => ({
|
||||
level: 'all',
|
||||
equipment: 'all',
|
||||
equipmentOptions: [],
|
||||
setLevel: (level) => set({ level }),
|
||||
setEquipment: (equipment) => set({ equipment }),
|
||||
setEquipmentOptions: (equipmentOptions) => set({ equipmentOptions }),
|
||||
resetFilters: () => set({ level: 'all', equipment: 'all' }),
|
||||
}))
|
||||
@@ -6,3 +6,4 @@ export { useUserStore } from './userStore'
|
||||
export { useActivityStore, getWeeklyActivity } from './activityStore'
|
||||
export { usePlayerStore } from './playerStore'
|
||||
export { useProgramStore } from './programStore'
|
||||
export { useExploreFilterStore } from './exploreFilterStore'
|
||||
|
||||
@@ -29,11 +29,13 @@ interface OnboardingData {
|
||||
interface UserState {
|
||||
profile: UserProfile
|
||||
settings: UserSettings
|
||||
savedWorkouts: string[]
|
||||
// Actions
|
||||
updateProfile: (updates: Partial<UserProfile>) => void
|
||||
updateSettings: (updates: Partial<UserSettings>) => void
|
||||
setSubscription: (plan: SubscriptionPlan) => void
|
||||
completeOnboarding: (data: OnboardingData) => void
|
||||
toggleSavedWorkout: (workoutId: string) => void
|
||||
// NEW: Sync-related actions
|
||||
setSyncStatus: (status: SyncStatus, userId?: string | null) => void
|
||||
setPromptPending: () => void
|
||||
@@ -55,6 +57,7 @@ export const useUserStore = create<UserState>()(
|
||||
goal: 'cardio',
|
||||
weeklyFrequency: 3,
|
||||
barriers: [],
|
||||
savedWorkouts: [],
|
||||
// NEW: Sync fields
|
||||
syncStatus: 'never-synced',
|
||||
supabaseUserId: null,
|
||||
@@ -69,6 +72,8 @@ export const useUserStore = create<UserState>()(
|
||||
reminderTime: '09:00',
|
||||
},
|
||||
|
||||
savedWorkouts: [],
|
||||
|
||||
updateProfile: (updates) =>
|
||||
set((state) => ({
|
||||
profile: { ...state.profile, ...updates },
|
||||
@@ -97,6 +102,13 @@ export const useUserStore = create<UserState>()(
|
||||
},
|
||||
})),
|
||||
|
||||
toggleSavedWorkout: (workoutId) =>
|
||||
set((state) => ({
|
||||
savedWorkouts: state.savedWorkouts.includes(workoutId)
|
||||
? state.savedWorkouts.filter((id) => id !== workoutId)
|
||||
: [...state.savedWorkouts, workoutId],
|
||||
})),
|
||||
|
||||
// NEW: Sync status management
|
||||
setSyncStatus: (status, userId = null) =>
|
||||
set((state) => ({
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface UserProfile {
|
||||
goal: FitnessGoal
|
||||
weeklyFrequency: WeeklyFrequency
|
||||
barriers: string[]
|
||||
savedWorkouts: string[]
|
||||
syncStatus: SyncStatus
|
||||
supabaseUserId: string | null
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user