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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user