diff --git a/app.json b/app.json
index af299d1..e00548d 100644
--- a/app.json
+++ b/app.json
@@ -40,7 +40,10 @@
}
}
],
- "expo-video"
+ "expo-video",
+ "expo-notifications",
+ "expo-localization",
+ "./plugins/withStoreKitConfig"
],
"experiments": {
"typedRoutes": true,
diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx
index 41ec091..2c410c6 100644
--- a/app/(tabs)/_layout.tsx
+++ b/app/(tabs)/_layout.tsx
@@ -2,39 +2,50 @@
* TabataFit Tab Layout
* Native iOS tabs with liquid glass effect
* 5 tabs: Home, Workouts, Activity, Browse, Profile
+ * Redirects to onboarding if not completed
*/
+import { Redirect } from 'expo-router'
import { NativeTabs, Icon, Label } from 'expo-router/unstable-native-tabs'
+import { useTranslation } from 'react-i18next'
import { BRAND } from '@/src/shared/constants/colors'
+import { useUserStore } from '@/src/shared/stores'
export default function TabLayout() {
+ const { t } = useTranslation('screens')
+ const onboardingCompleted = useUserStore((s) => s.profile.onboardingCompleted)
+
+ if (!onboardingCompleted) {
+ return
+ }
+
return (
-
+
-
+
-
+
-
+
-
+
)
diff --git a/app/(tabs)/activity.tsx b/app/(tabs)/activity.tsx
index 21cefae..5516ed1 100644
--- a/app/(tabs)/activity.tsx
+++ b/app/(tabs)/activity.tsx
@@ -1,62 +1,181 @@
/**
* TabataFit Activity Screen
- * React Native + SwiftUI Islands — wired to shared data
+ * Premium stats dashboard — streak, rings, weekly chart, history
*/
-import { View, StyleSheet, ScrollView, Dimensions, Text as RNText } from 'react-native'
+import { View, StyleSheet, ScrollView, Dimensions } from 'react-native'
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 {
- Host,
- Gauge,
- Text,
- HStack,
- VStack,
- Chart,
- List,
-} from '@expo/ui/swift-ui'
import { useMemo } from 'react'
+import { useTranslation } from 'react-i18next'
import { useActivityStore, getWeeklyActivity } from '@/src/shared/stores'
import { getWorkoutById } from '@/src/shared/data'
import { ACHIEVEMENTS } from '@/src/shared/data'
import { StyledText } from '@/src/shared/components/StyledText'
-import {
- BRAND,
- DARK,
- TEXT as TEXT_COLORS,
- GLASS,
- GRADIENTS,
-} from '@/src/shared/constants/colors'
+import { useThemeColors, BRAND, PHASE, GRADIENTS } 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'
const { width: SCREEN_WIDTH } = Dimensions.get('window')
-const FONTS = {
- LARGE_TITLE: 34,
- TITLE_2: 22,
- SUBHEADLINE: 15,
- CAPTION_1: 12,
+// ═══════════════════════════════════════════════════════════════════════════
+// STAT RING — Custom circular progress (pure RN, no SwiftUI)
+// ═══════════════════════════════════════════════════════════════════════════
+
+function StatRing({
+ value,
+ max,
+ color,
+ size = 64,
+}: {
+ value: number
+ max: number
+ color: string
+ size?: number
+}) {
+ const colors = useThemeColors()
+ const strokeWidth = 5
+ const radius = (size - strokeWidth) / 2
+ const circumference = 2 * Math.PI * radius
+ const progress = Math.min(value / max, 1)
+ const strokeDashoffset = circumference * (1 - progress)
+
+ // We'll use a View-based ring since SVG isn't available
+ // Use border trick for a circular progress indicator
+ return (
+
+ {/* Track */}
+
+ {/* Fill — simplified: show a colored ring proportional to progress */}
+ 0.25 ? color : 'transparent',
+ borderRightColor: progress > 0.5 ? color : 'transparent',
+ borderBottomColor: progress > 0.75 ? color : 'transparent',
+ borderLeftColor: progress > 0 ? color : 'transparent',
+ transform: [{ rotate: '-90deg' }],
+ opacity: progress > 0 ? 1 : 0.3,
+ }}
+ />
+
+ )
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// STAT CARD
+// ═══════════════════════════════════════════════════════════════════════════
+
+function StatCard({
+ label,
+ value,
+ max,
+ color,
+ icon,
+}: {
+ label: string
+ value: number
+ max: number
+ color: string
+ icon: keyof typeof Ionicons.glyphMap
+}) {
+ const colors = useThemeColors()
+ const styles = useMemo(() => createStyles(colors), [colors])
+ return (
+
+
+
+
+
+
+ {String(value)}
+
+
+ {label}
+
+
+
+
+
+ )
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// WEEKLY BAR
+// ═══════════════════════════════════════════════════════════════════════════
+
+function WeeklyBar({
+ day,
+ completed,
+ isToday,
+}: {
+ day: string
+ completed: boolean
+ isToday: boolean
+}) {
+ const colors = useThemeColors()
+ const styles = useMemo(() => createStyles(colors), [colors])
+ return (
+
+
+ {completed && (
+
+ )}
+
+
+ {day}
+
+
+ )
}
// ═══════════════════════════════════════════════════════════════════════════
// MAIN SCREEN
// ═══════════════════════════════════════════════════════════════════════════
+const DAY_KEYS = ['days.sun', 'days.mon', 'days.tue', 'days.wed', 'days.thu', 'days.fri', 'days.sat'] as const
+
export default function ActivityScreen() {
+ const { t } = useTranslation()
const insets = useSafeAreaInsets()
+ const colors = useThemeColors()
+ const styles = useMemo(() => createStyles(colors), [colors])
const streak = useActivityStore((s) => s.streak)
const history = useActivityStore((s) => s.history)
const totalWorkouts = history.length
const totalMinutes = useMemo(() => history.reduce((sum, r) => sum + r.durationMinutes, 0), [history])
const totalCalories = useMemo(() => history.reduce((sum, r) => sum + r.calories, 0), [history])
- const recentWorkouts = useMemo(() => history.slice(0, 3), [history])
+ const recentWorkouts = useMemo(() => history.slice(0, 5), [history])
const weeklyActivity = useMemo(() => getWeeklyActivity(history), [history])
+ const today = new Date().getDay() // 0=Sun
+
// Check achievements
const unlockedAchievements = ACHIEVEMENTS.filter(a => {
switch (a.type) {
@@ -67,18 +186,17 @@ export default function ActivityScreen() {
default: return false
}
})
- const displayAchievements = ACHIEVEMENTS.slice(0, 5).map(a => ({
+ const displayAchievements = ACHIEVEMENTS.slice(0, 4).map(a => ({
...a,
unlocked: unlockedAchievements.some(u => u.id === a.id),
}))
- // Format recent workout dates
const formatDate = (timestamp: number) => {
const now = Date.now()
const diff = now - timestamp
- if (diff < 86400000) return 'Today'
- if (diff < 172800000) return 'Yesterday'
- return Math.floor(diff / 86400000) + ' days ago'
+ if (diff < 86400000) return t('screens:activity.today')
+ if (diff < 172800000) return t('screens:activity.yesterday')
+ return t('screens:activity.daysAgo', { count: Math.floor(diff / 86400000) })
}
return (
@@ -89,7 +207,14 @@ export default function ActivityScreen() {
showsVerticalScrollIndicator={false}
>
{/* Header */}
- Activity
+
+ {t('screens:activity.title')}
+
{/* Streak Banner */}
@@ -99,119 +224,154 @@ export default function ActivityScreen() {
end={{ x: 1, y: 1 }}
style={StyleSheet.absoluteFill}
/>
-
-
-
-
- {(streak.current || 0) + ' Day Streak'}
+
+
+
+
+
+
+ {String(streak.current || 0)}
-
- {streak.current > 0 ? 'Keep it going!' : 'Start your streak today!'}
+
+ {t('screens:activity.dayStreak')}
+
+
+
+
+ {t('screens:activity.longest')}
+
+
+ {String(streak.longest)}
- {/* SwiftUI Island: Stats Gauges */}
-
-
-
-
- Workouts
-
-
-
- Minutes
-
-
-
- Calories
-
-
-
- Best Streak
-
-
-
-
- {/* SwiftUI Island: This Week Chart */}
-
- This Week
-
- ({
- x: d.date,
- y: d.completed ? 1 : 0,
- color: d.completed ? BRAND.PRIMARY : '#333333',
- }))}
- barStyle={{ cornerRadius: 4 }}
- style={{ height: 160 }}
- />
-
+ {/* Stats Grid — 2x2 */}
+
+
+
+
+
- {/* SwiftUI Island: Recent Workouts */}
+ {/* This Week */}
+
+
+ {t('screens:activity.thisWeek')}
+
+
+
+
+ {weeklyActivity.map((d, i) => (
+
+ ))}
+
+
+
+ {t('screens:activity.ofDays', { completed: weeklyActivity.filter(d => d.completed).length })}
+
+
+
+
+
+ {/* Recent Workouts */}
{recentWorkouts.length > 0 && (
- Recent
-
-
- {recentWorkouts.map((result) => {
- const workout = getWorkoutById(result.workoutId)
- return (
-
- {workout?.title ?? 'Workout'}
- {formatDate(result.completedAt)}
- {result.durationMinutes + ' min'}
- {result.calories + ' cal'}
-
- )
- })}
-
-
+
+ {t('screens:activity.recent')}
+
+
+
+ {recentWorkouts.map((result, idx) => {
+ const workout = getWorkoutById(result.workoutId)
+ const workoutTitle = workout ? t(`content:workouts.${workout.id}`, { defaultValue: workout.title }) : t('screens:activity.workouts')
+ return (
+
+
+
+
+
+
+
+ {workoutTitle}
+
+
+ {formatDate(result.completedAt) + ' \u00B7 ' + t('units.minUnit', { count: result.durationMinutes })}
+
+
+
+ {t('units.calUnit', { count: result.calories })}
+
+
+ {idx < recentWorkouts.length - 1 && }
+
+ )
+ })}
+
)}
{/* Achievements */}
- Achievements
-
- {displayAchievements.map((achievement) => (
-
-
-
+
+ {t('screens:activity.achievements')}
+
+
+ {displayAchievements.map((a) => (
+
+
+
- {achievement.title}
+ {t(`content:achievements.${a.id}.title`, { defaultValue: a.title })}
))}
@@ -226,81 +386,168 @@ export default function ActivityScreen() {
// STYLES
// ═══════════════════════════════════════════════════════════════════════════
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: DARK.BASE,
- },
- scrollView: {
- flex: 1,
- },
- scrollContent: {
- paddingHorizontal: LAYOUT.SCREEN_PADDING,
- },
+const CARD_HALF = (SCREEN_WIDTH - LAYOUT.SCREEN_PADDING * 2 - SPACING[3]) / 2
- // Streak Banner
- streakBanner: {
- height: 80,
- borderRadius: RADIUS.GLASS_CARD,
- overflow: 'hidden',
- marginBottom: SPACING[6],
- marginTop: SPACING[4],
- },
- streakContent: {
- flex: 1,
- flexDirection: 'row',
- alignItems: 'center',
- paddingHorizontal: SPACING[5],
- gap: SPACING[4],
- },
- streakText: {
- flex: 1,
- },
+function createStyles(colors: ThemeColors) {
+ return StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: colors.bg.base,
+ },
+ scrollView: {
+ flex: 1,
+ },
+ scrollContent: {
+ paddingHorizontal: LAYOUT.SCREEN_PADDING,
+ },
- // Stats Island
- statsIsland: {
- marginBottom: SPACING[8],
- },
+ // Streak
+ streakBanner: {
+ borderRadius: RADIUS.GLASS_CARD,
+ overflow: 'hidden',
+ marginBottom: SPACING[5],
+ ...colors.shadow.md,
+ },
+ streakRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: SPACING[5],
+ paddingVertical: SPACING[5],
+ gap: SPACING[4],
+ },
+ streakIconWrap: {
+ width: 48,
+ height: 48,
+ borderRadius: 24,
+ backgroundColor: 'rgba(255,255,255,0.15)',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ streakMeta: {
+ alignItems: 'center',
+ backgroundColor: 'rgba(255,255,255,0.1)',
+ paddingHorizontal: SPACING[4],
+ paddingVertical: SPACING[2],
+ borderRadius: RADIUS.MD,
+ },
- // Chart Island
- chartIsland: {
- marginTop: SPACING[2],
- },
+ // Stats 2x2
+ statsGrid: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: SPACING[3],
+ marginBottom: SPACING[6],
+ },
+ statCard: {
+ width: CARD_HALF,
+ borderRadius: RADIUS.LG,
+ overflow: 'hidden',
+ borderWidth: 1,
+ borderColor: colors.bg.overlay2,
+ },
+ statCardInner: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ padding: SPACING[4],
+ },
- // Section
- section: {
- marginBottom: SPACING[6],
- },
+ // Section
+ section: {
+ marginBottom: SPACING[6],
+ },
- // Achievements
- achievementsGrid: {
- flexDirection: 'row',
- flexWrap: 'wrap',
- gap: SPACING[3],
- },
- achievementBadge: {
- width: (SCREEN_WIDTH - LAYOUT.SCREEN_PADDING * 2 - SPACING[3] * 2) / 3,
- aspectRatio: 1,
- borderRadius: RADIUS.LG,
- alignItems: 'center',
- justifyContent: 'center',
- borderWidth: 1,
- borderColor: 'rgba(255, 255, 255, 0.1)',
- overflow: 'hidden',
- },
- achievementLocked: {
- opacity: 0.5,
- },
- achievementIcon: {
- width: 48,
- height: 48,
- borderRadius: 24,
- backgroundColor: 'rgba(255, 107, 53, 0.15)',
- alignItems: 'center',
- justifyContent: 'center',
- marginBottom: SPACING[2],
- },
- achievementIconLocked: {
- backgroundColor: 'rgba(255, 255, 255, 0.05)',
- },
-})
+ // Weekly
+ weekCard: {
+ borderRadius: RADIUS.LG,
+ overflow: 'hidden',
+ borderWidth: 1,
+ borderColor: colors.bg.overlay2,
+ paddingTop: SPACING[5],
+ paddingBottom: SPACING[4],
+ },
+ weekBarsRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-around',
+ alignItems: 'flex-end',
+ paddingHorizontal: SPACING[4],
+ height: 100,
+ },
+ weekBarColumn: {
+ alignItems: 'center',
+ flex: 1,
+ gap: SPACING[2],
+ },
+ weekBar: {
+ width: 24,
+ height: 60,
+ borderRadius: 4,
+ backgroundColor: colors.border.glassLight,
+ overflow: 'hidden',
+ },
+ weekBarFilled: {
+ backgroundColor: BRAND.PRIMARY,
+ },
+ weekSummary: {
+ alignItems: 'center',
+ marginTop: SPACING[3],
+ paddingTop: SPACING[3],
+ borderTopWidth: StyleSheet.hairlineWidth,
+ borderTopColor: colors.bg.overlay2,
+ marginHorizontal: SPACING[4],
+ },
+
+ // Recent
+ recentCard: {
+ borderRadius: RADIUS.LG,
+ overflow: 'hidden',
+ borderWidth: 1,
+ borderColor: colors.bg.overlay2,
+ paddingVertical: SPACING[2],
+ },
+ recentRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: SPACING[4],
+ paddingVertical: SPACING[3],
+ },
+ recentDot: {
+ width: 24,
+ alignItems: 'center',
+ marginRight: SPACING[3],
+ },
+ dot: {
+ width: 8,
+ height: 8,
+ borderRadius: 4,
+ },
+ recentDivider: {
+ height: StyleSheet.hairlineWidth,
+ backgroundColor: colors.border.glassLight,
+ marginLeft: SPACING[4] + 24 + SPACING[3],
+ },
+
+ // Achievements
+ achievementsRow: {
+ flexDirection: 'row',
+ gap: SPACING[3],
+ },
+ achievementCard: {
+ flex: 1,
+ aspectRatio: 0.9,
+ borderRadius: RADIUS.LG,
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderWidth: 1,
+ borderColor: colors.bg.overlay2,
+ overflow: 'hidden',
+ paddingHorizontal: SPACING[1],
+ },
+ achievementIcon: {
+ width: 44,
+ height: 44,
+ borderRadius: 22,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ })
+}
diff --git a/app/(tabs)/browse.tsx b/app/(tabs)/browse.tsx
index 2ff124f..b526e5f 100644
--- a/app/(tabs)/browse.tsx
+++ b/app/(tabs)/browse.tsx
@@ -10,6 +10,8 @@ import { LinearGradient } from 'expo-linear-gradient'
import { BlurView } from 'expo-blur'
import Ionicons from '@expo/vector-icons/Ionicons'
+import { useMemo } from 'react'
+import { useTranslation } from 'react-i18next'
import { useHaptics } from '@/src/shared/hooks'
import {
COLLECTIONS,
@@ -17,17 +19,12 @@ import {
getFeaturedCollection,
COLLECTION_COLORS,
WORKOUTS,
- getTrainerById,
} from '@/src/shared/data'
+import { useTranslatedCollections, useTranslatedPrograms, useTranslatedWorkouts } from '@/src/shared/data/useTranslatedData'
import { StyledText } from '@/src/shared/components/StyledText'
-import {
- BRAND,
- DARK,
- TEXT,
- GLASS,
- SHADOW,
-} from '@/src/shared/constants/colors'
+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'
@@ -61,11 +58,17 @@ const NEW_RELEASES = WORKOUTS.slice(-4)
// ═══════════════════════════════════════════════════════════════════════════
export default function BrowseScreen() {
+ const { t } = useTranslation()
const insets = useSafeAreaInsets()
const router = useRouter()
const haptics = useHaptics()
+ const colors = useThemeColors()
+ const styles = useMemo(() => createStyles(colors), [colors])
const featuredCollection = getFeaturedCollection()
+ const translatedCollections = useTranslatedCollections(COLLECTIONS)
+ const translatedPrograms = useTranslatedPrograms(PROGRAMS)
+ const translatedNewReleases = useTranslatedWorkouts(NEW_RELEASES)
const handleWorkoutPress = (id: string) => {
haptics.buttonTap()
@@ -85,7 +88,7 @@ export default function BrowseScreen() {
showsVerticalScrollIndicator={false}
>
{/* Header */}
- Browse
+ {t('screens:browse.title')}
{/* Featured Collection */}
{featuredCollection && (
@@ -98,18 +101,18 @@ export default function BrowseScreen() {
/>
-
- FEATURED
+
+ {t('screens:browse.featured')}
- {featuredCollection.title}
- {featuredCollection.description}
+ {t(`content:collections.${featuredCollection.id}.title`, { defaultValue: featuredCollection.title })}
+ {t(`content:collections.${featuredCollection.id}.description`, { defaultValue: featuredCollection.description })}
-
- {featuredCollection.workoutIds.length + ' workouts'}
+
+ {t('plurals.workout', { count: featuredCollection.workoutIds.length })}
@@ -118,9 +121,9 @@ export default function BrowseScreen() {
{/* Collections Grid */}
- Collections
+ {t('screens:browse.collections')}
- {COLLECTIONS.map((collection) => {
+ {translatedCollections.map((collection) => {
const color = COLLECTION_COLORS[collection.id] ?? BRAND.PRIMARY
return (
handleCollectionPress(collection.id)}
>
-
+
{collection.icon}
-
+
{collection.title}
- {collection.workoutIds.length + ' workouts'}
+ {t('plurals.workout', { count: collection.workoutIds.length })}
)
@@ -147,34 +150,34 @@ export default function BrowseScreen() {
{/* Programs */}
- Programs
- See All
+ {t('screens:browse.programs')}
+ {t('seeAll')}
- {PROGRAMS.map((program) => (
+ {translatedPrograms.map((program) => (
-
+
- {program.level}
+ {t(`levels.${program.level.toLowerCase()}`)}
- {program.title}
+ {program.title}
-
- {program.weeks + ' weeks'}
+
+ {t('screens:browse.weeksCount', { count: program.weeks })}
-
- {program.workoutsPerWeek + 'x /week'}
+
+ {t('screens:browse.timesPerWeek', { count: program.workoutsPerWeek })}
@@ -185,29 +188,26 @@ export default function BrowseScreen() {
{/* New Releases */}
- New Releases
+ {t('screens:browse.newReleases')}
- {NEW_RELEASES.map((workout) => {
- const trainer = getTrainerById(workout.trainerId)
- return (
- handleWorkoutPress(workout.id)}
- >
-
- {trainer?.name[0] ?? 'T'}
-
-
- {workout.title}
-
- {(trainer?.name ?? '') + ' \u00B7 ' + workout.duration + ' min \u00B7 ' + workout.level}
-
-
-
-
- )
- })}
+ {translatedNewReleases.map((workout) => (
+ handleWorkoutPress(workout.id)}
+ >
+
+
+
+
+ {workout.title}
+
+ {t('durationLevel', { duration: workout.duration, level: t(`levels.${workout.level.toLowerCase()}`) })}
+
+
+
+
+ ))}
@@ -220,160 +220,162 @@ export default function BrowseScreen() {
const COLLECTION_CARD_WIDTH = (SCREEN_WIDTH - LAYOUT.SCREEN_PADDING * 2 - SPACING[3]) / 2
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: DARK.BASE,
- },
- scrollView: {
- flex: 1,
- },
- scrollContent: {
- paddingHorizontal: LAYOUT.SCREEN_PADDING,
- },
+function createStyles(colors: ThemeColors) {
+ return StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: colors.bg.base,
+ },
+ scrollView: {
+ flex: 1,
+ },
+ scrollContent: {
+ paddingHorizontal: LAYOUT.SCREEN_PADDING,
+ },
- // Featured Collection
- featuredCard: {
- height: 200,
- borderRadius: RADIUS.GLASS_CARD,
- overflow: 'hidden',
- marginBottom: SPACING[8],
- marginTop: SPACING[4],
- ...SHADOW.lg,
- },
- featuredBadge: {
- flexDirection: 'row',
- alignItems: 'center',
- backgroundColor: 'rgba(0, 0, 0, 0.3)',
- paddingHorizontal: SPACING[2],
- paddingVertical: SPACING[1],
- borderRadius: RADIUS.SM,
- alignSelf: 'flex-start',
- margin: SPACING[4],
- gap: SPACING[1],
- },
- featuredBadgeText: {
- fontSize: 11,
- fontWeight: 'bold',
- color: TEXT.PRIMARY,
- },
- featuredInfo: {
- position: 'absolute',
- bottom: SPACING[5],
- left: SPACING[5],
- right: SPACING[5],
- },
- featuredStats: {
- flexDirection: 'row',
- gap: SPACING[4],
- marginTop: SPACING[3],
- },
- featuredStat: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: SPACING[1],
- },
+ // Featured Collection
+ featuredCard: {
+ height: 200,
+ borderRadius: RADIUS.GLASS_CARD,
+ overflow: 'hidden',
+ marginBottom: SPACING[8],
+ marginTop: SPACING[4],
+ ...colors.shadow.lg,
+ },
+ featuredBadge: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: 'rgba(0, 0, 0, 0.3)',
+ paddingHorizontal: SPACING[2],
+ paddingVertical: SPACING[1],
+ borderRadius: RADIUS.SM,
+ alignSelf: 'flex-start',
+ margin: SPACING[4],
+ gap: SPACING[1],
+ },
+ featuredBadgeText: {
+ fontSize: 11,
+ fontWeight: 'bold',
+ color: '#FFFFFF',
+ },
+ featuredInfo: {
+ position: 'absolute',
+ bottom: SPACING[5],
+ left: SPACING[5],
+ right: SPACING[5],
+ },
+ featuredStats: {
+ flexDirection: 'row',
+ gap: SPACING[4],
+ marginTop: SPACING[3],
+ },
+ featuredStat: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: SPACING[1],
+ },
- // Section
- section: {
- marginBottom: SPACING[6],
- },
- sectionHeader: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- alignItems: 'center',
- marginBottom: SPACING[4],
- },
+ // Section
+ section: {
+ marginBottom: SPACING[6],
+ },
+ sectionHeader: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: SPACING[4],
+ },
- // Collections Grid
- collectionsGrid: {
- flexDirection: 'row',
- flexWrap: 'wrap',
- gap: SPACING[3],
- marginTop: SPACING[3],
- },
- collectionCard: {
- width: COLLECTION_CARD_WIDTH,
- paddingVertical: SPACING[4],
- paddingHorizontal: SPACING[3],
- borderRadius: RADIUS.LG,
- overflow: 'hidden',
- borderWidth: 1,
- borderColor: 'rgba(255, 255, 255, 0.1)',
- gap: SPACING[1],
- },
- collectionIconBg: {
- width: 44,
- height: 44,
- borderRadius: 12,
- alignItems: 'center',
- justifyContent: 'center',
- marginBottom: SPACING[2],
- },
- collectionEmoji: {
- fontSize: 22,
- },
+ // Collections Grid
+ collectionsGrid: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: SPACING[3],
+ marginTop: SPACING[3],
+ },
+ collectionCard: {
+ width: COLLECTION_CARD_WIDTH,
+ paddingVertical: SPACING[4],
+ paddingHorizontal: SPACING[3],
+ borderRadius: RADIUS.LG,
+ overflow: 'hidden',
+ borderWidth: 1,
+ borderColor: colors.border.glass,
+ gap: SPACING[1],
+ },
+ collectionIconBg: {
+ width: 44,
+ height: 44,
+ borderRadius: 12,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginBottom: SPACING[2],
+ },
+ collectionEmoji: {
+ fontSize: 22,
+ },
- // Programs
- programsScroll: {
- gap: SPACING[3],
- },
- programCard: {
- width: 200,
- height: 140,
- borderRadius: RADIUS.LG,
- overflow: 'hidden',
- padding: SPACING[4],
- borderWidth: 1,
- borderColor: 'rgba(255, 255, 255, 0.1)',
- },
- programHeader: {
- flexDirection: 'row',
- justifyContent: 'flex-end',
- marginBottom: SPACING[2],
- },
- programLevelBadge: {
- backgroundColor: 'rgba(255, 107, 53, 0.2)',
- paddingHorizontal: SPACING[2],
- paddingVertical: SPACING[1],
- borderRadius: RADIUS.SM,
- },
- programMeta: {
- flexDirection: 'row',
- gap: SPACING[3],
- marginTop: SPACING[3],
- },
- programMetaItem: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: SPACING[1],
- },
+ // Programs
+ programsScroll: {
+ gap: SPACING[3],
+ },
+ programCard: {
+ width: 200,
+ height: 140,
+ borderRadius: RADIUS.LG,
+ overflow: 'hidden',
+ padding: SPACING[4],
+ borderWidth: 1,
+ borderColor: colors.border.glass,
+ },
+ programHeader: {
+ flexDirection: 'row',
+ justifyContent: 'flex-end',
+ marginBottom: SPACING[2],
+ },
+ programLevelBadge: {
+ backgroundColor: 'rgba(255, 107, 53, 0.2)',
+ paddingHorizontal: SPACING[2],
+ paddingVertical: SPACING[1],
+ borderRadius: RADIUS.SM,
+ },
+ programMeta: {
+ flexDirection: 'row',
+ gap: SPACING[3],
+ marginTop: SPACING[3],
+ },
+ programMetaItem: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: SPACING[1],
+ },
- // New Releases
- releaseRow: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingVertical: SPACING[3],
- paddingHorizontal: SPACING[4],
- backgroundColor: DARK.SURFACE,
- borderRadius: RADIUS.LG,
- marginBottom: SPACING[2],
- gap: SPACING[3],
- },
- releaseAvatar: {
- width: 44,
- height: 44,
- borderRadius: 22,
- alignItems: 'center',
- justifyContent: 'center',
- },
- releaseInitial: {
- fontSize: 18,
- fontWeight: '700',
- color: TEXT.PRIMARY,
- },
- releaseInfo: {
- flex: 1,
- gap: 2,
- },
-})
+ // New Releases
+ releaseRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingVertical: SPACING[3],
+ paddingHorizontal: SPACING[4],
+ backgroundColor: colors.bg.surface,
+ borderRadius: RADIUS.LG,
+ marginBottom: SPACING[2],
+ gap: SPACING[3],
+ },
+ releaseAvatar: {
+ width: 44,
+ height: 44,
+ borderRadius: 22,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ releaseInitial: {
+ fontSize: 18,
+ fontWeight: '700',
+ color: '#FFFFFF',
+ },
+ releaseInfo: {
+ flex: 1,
+ gap: 2,
+ },
+ })
+}
diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx
index fb0ea7a..91bbc31 100644
--- a/app/(tabs)/index.tsx
+++ b/app/(tabs)/index.tsx
@@ -11,25 +11,20 @@ import { BlurView } from 'expo-blur'
import Ionicons from '@expo/vector-icons/Ionicons'
import { useMemo } from 'react'
+import { useTranslation } from 'react-i18next'
import { useHaptics } from '@/src/shared/hooks'
import { useUserStore, useActivityStore } from '@/src/shared/stores'
import {
getFeaturedWorkouts,
getPopularWorkouts,
- getTrainerById,
COLLECTIONS,
WORKOUTS,
} from '@/src/shared/data'
+import { useTranslatedWorkouts, useTranslatedCollections } from '@/src/shared/data/useTranslatedData'
import { StyledText } from '@/src/shared/components/StyledText'
-import {
- BRAND,
- DARK,
- TEXT,
- GLASS,
- SHADOW,
- GRADIENTS,
-} from '@/src/shared/constants/colors'
+import { useThemeColors, BRAND, GRADIENTS } 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'
@@ -51,23 +46,20 @@ const FONTS = {
// HELPERS
// ═══════════════════════════════════════════════════════════════════════════
-function getGreeting() {
- const hour = new Date().getHours()
- if (hour < 12) return 'Good morning'
- if (hour < 18) return 'Good afternoon'
- return 'Good evening'
-}
-
function PrimaryButton({ children, onPress }: { children: string; onPress?: () => void }) {
+ const colors = useThemeColors()
+ const styles = useMemo(() => createStyles(colors), [colors])
return (
-
+
{children}
)
}
function PlainButton({ children, onPress }: { children: string; onPress?: () => void }) {
+ const colors = useThemeColors()
+ const styles = useMemo(() => createStyles(colors), [colors])
return (
{children}
@@ -80,16 +72,29 @@ function PlainButton({ children, onPress }: { children: string; onPress?: () =>
// ═══════════════════════════════════════════════════════════════════════════
export default function HomeScreen() {
+ const { t } = useTranslation()
const insets = useSafeAreaInsets()
const router = useRouter()
const haptics = useHaptics()
+ const colors = useThemeColors()
+ const styles = useMemo(() => createStyles(colors), [colors])
const userName = useUserStore((s) => s.profile.name)
const history = useActivityStore((s) => s.history)
const recentWorkouts = useMemo(() => history.slice(0, 3), [history])
const featured = getFeaturedWorkouts()[0] ?? WORKOUTS[0]
- const featuredTrainer = getTrainerById(featured.trainerId)
const popular = getPopularWorkouts(4)
+ const translatedPopular = useTranslatedWorkouts(popular)
+ const translatedCollections = useTranslatedCollections(COLLECTIONS)
+
+ const greeting = (() => {
+ const hour = new Date().getHours()
+ if (hour < 12) return t('greetings.morning')
+ if (hour < 18) return t('greetings.afternoon')
+ return t('greetings.evening')
+ })()
+
+ const featuredTitle = t(`content:workouts.${featured.id}`, { defaultValue: featured.title })
const handleWorkoutPress = (id: string) => {
haptics.buttonTap()
@@ -105,11 +110,21 @@ export default function HomeScreen() {
>
{/* Header */}
-
- {getGreeting() + ', ' + userName}
-
+
+
+ {greeting}
+
+
+ {userName}
+
+
-
+
@@ -136,21 +151,21 @@ export default function HomeScreen() {
/>
- 🔥 FEATURED
+ {'🔥 ' + t('screens:home.featured')}
-
- {featured.title}
+
+ {featuredTitle}
-
- {featured.duration + ' min • ' + featured.level + ' • ' + (featuredTrainer?.name ?? '')}
+
+ {t('workoutMeta', { duration: featured.duration, level: t(`levels.${featured.level.toLowerCase()}`), calories: featured.calories })}
- handleWorkoutPress(featured.id)}>START
+ handleWorkoutPress(featured.id)}>{t('start')}
-
+
@@ -160,8 +175,8 @@ export default function HomeScreen() {
{recentWorkouts.length > 0 && (
- Recent
- See All
+ {t('screens:home.recent')}
+ {t('seeAll')}
{
const workout = WORKOUTS.find(w => w.id === result.workoutId)
if (!workout) return null
- const trainer = getTrainerById(workout.trainerId)
+ const workoutTitle = t(`content:workouts.${workout.id}`, { defaultValue: workout.title })
return (
-
- {trainer?.name[0] ?? 'T'}
-
+
- {workout.title}
-
- {result.calories + ' cal • ' + result.durationMinutes + ' min'}
+ {workoutTitle}
+
+ {t('calMin', { calories: result.calories, duration: result.durationMinutes })}
)
@@ -200,13 +213,13 @@ export default function HomeScreen() {
{/* Popular This Week */}
- Popular This Week
+ {t('screens:home.popularThisWeek')}
- {popular.map((item) => (
+ {translatedPopular.map((item) => (
- {item.title}
- {item.duration + ' min'}
+ {item.title}
+ {t('units.minUnit', { count: item.duration })}
))}
@@ -224,19 +237,19 @@ export default function HomeScreen() {
{/* Collections */}
- Collections
- {COLLECTIONS.map((item) => (
+ {t('screens:home.collections')}
+ {translatedCollections.map((item) => (
{ haptics.buttonTap(); router.push(`/collection/${item.id}`) }}>
-
+
{item.icon}
- {item.title}
-
- {item.workoutIds.length + ' workouts • ' + item.description}
+ {item.title}
+
+ {t('plurals.workout', { count: item.workoutIds.length }) + ' \u00B7 ' + item.description}
-
+
))}
@@ -250,167 +263,169 @@ export default function HomeScreen() {
// STYLES
// ═══════════════════════════════════════════════════════════════════════════
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: DARK.BASE,
- },
- scrollView: {
- flex: 1,
- },
- scrollContent: {
- paddingHorizontal: LAYOUT.SCREEN_PADDING,
- },
+function createStyles(colors: ThemeColors) {
+ return StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: colors.bg.base,
+ },
+ scrollView: {
+ flex: 1,
+ },
+ scrollContent: {
+ paddingHorizontal: LAYOUT.SCREEN_PADDING,
+ },
- // Header
- header: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- alignItems: 'center',
- marginBottom: SPACING[6],
- },
- profileButton: {
- width: 40,
- height: 40,
- alignItems: 'center',
- justifyContent: 'center',
- },
+ // Header
+ header: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: SPACING[6],
+ },
+ profileButton: {
+ width: 40,
+ height: 40,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
- // Buttons
- primaryButton: {
- flexDirection: 'row',
- alignItems: 'center',
- backgroundColor: BRAND.PRIMARY,
- paddingHorizontal: SPACING[4],
- paddingVertical: SPACING[3],
- borderRadius: RADIUS.SM,
- },
- buttonIcon: {
- marginRight: SPACING[2],
- },
- primaryButtonText: {
- fontSize: 14,
- fontWeight: '600',
- color: TEXT.PRIMARY,
- },
- plainButtonText: {
- fontSize: FONTS.BODY,
- color: BRAND.PRIMARY,
- },
+ // Buttons
+ primaryButton: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: BRAND.PRIMARY,
+ paddingHorizontal: SPACING[4],
+ paddingVertical: SPACING[3],
+ borderRadius: RADIUS.SM,
+ },
+ buttonIcon: {
+ marginRight: SPACING[2],
+ },
+ primaryButtonText: {
+ fontSize: 14,
+ fontWeight: '600',
+ color: '#FFFFFF',
+ },
+ plainButtonText: {
+ fontSize: FONTS.BODY,
+ color: BRAND.PRIMARY,
+ },
- // Featured
- featuredCard: {
- width: CARD_WIDTH,
- height: 220,
- borderRadius: RADIUS.GLASS_CARD,
- overflow: 'hidden',
- marginBottom: SPACING[8],
- ...SHADOW.lg,
- },
- featuredBadge: {
- position: 'absolute',
- top: SPACING[4],
- left: SPACING[4],
- backgroundColor: 'rgba(255, 255, 255, 0.15)',
- paddingHorizontal: SPACING[3],
- paddingVertical: SPACING[1],
- borderRadius: RADIUS.SM,
- borderWidth: 1,
- borderColor: 'rgba(255, 255, 255, 0.2)',
- },
- featuredBadgeText: {
- fontSize: 11,
- fontWeight: 'bold',
- color: TEXT.PRIMARY,
- },
- featuredContent: {
- position: 'absolute',
- bottom: 0,
- left: 0,
- right: 0,
- padding: SPACING[5],
- },
- featuredButtons: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: SPACING[3],
- marginTop: SPACING[4],
- },
- saveButton: {
- width: 44,
- height: 44,
- alignItems: 'center',
- justifyContent: 'center',
- backgroundColor: 'rgba(255, 255, 255, 0.1)',
- borderRadius: 22,
- borderWidth: 1,
- borderColor: 'rgba(255, 255, 255, 0.15)',
- },
+ // Featured
+ featuredCard: {
+ width: CARD_WIDTH,
+ height: 220,
+ borderRadius: RADIUS.GLASS_CARD,
+ overflow: 'hidden',
+ marginBottom: SPACING[8],
+ ...colors.shadow.lg,
+ },
+ featuredBadge: {
+ position: 'absolute',
+ top: SPACING[4],
+ left: SPACING[4],
+ backgroundColor: 'rgba(255, 255, 255, 0.15)',
+ paddingHorizontal: SPACING[3],
+ paddingVertical: SPACING[1],
+ borderRadius: RADIUS.SM,
+ borderWidth: 1,
+ borderColor: 'rgba(255, 255, 255, 0.2)',
+ },
+ featuredBadgeText: {
+ fontSize: 11,
+ fontWeight: 'bold',
+ color: '#FFFFFF',
+ },
+ featuredContent: {
+ position: 'absolute',
+ bottom: 0,
+ left: 0,
+ right: 0,
+ padding: SPACING[5],
+ },
+ featuredButtons: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: SPACING[3],
+ marginTop: SPACING[4],
+ },
+ saveButton: {
+ width: 44,
+ height: 44,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: 'rgba(255, 255, 255, 0.1)',
+ borderRadius: 22,
+ borderWidth: 1,
+ borderColor: 'rgba(255, 255, 255, 0.15)',
+ },
- // Sections
- section: {
- marginBottom: SPACING[8],
- },
- sectionHeader: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- alignItems: 'center',
- marginBottom: SPACING[4],
- },
- horizontalScroll: {
- gap: SPACING[3],
- },
+ // Sections
+ section: {
+ marginBottom: SPACING[8],
+ },
+ sectionHeader: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: SPACING[4],
+ },
+ horizontalScroll: {
+ gap: SPACING[3],
+ },
- // Continue Card
- continueCard: {
- width: 140,
- },
- continueThumb: {
- width: 140,
- height: 200,
- borderRadius: RADIUS.LG,
- overflow: 'hidden',
- alignItems: 'center',
- justifyContent: 'center',
- marginBottom: SPACING[2],
- },
+ // Continue Card
+ continueCard: {
+ width: 140,
+ },
+ continueThumb: {
+ width: 140,
+ height: 200,
+ borderRadius: RADIUS.LG,
+ overflow: 'hidden',
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginBottom: SPACING[2],
+ },
- // Popular Card
- popularCard: {
- width: 120,
- },
- popularThumb: {
- width: 120,
- height: 120,
- borderRadius: RADIUS.LG,
- alignItems: 'center',
- justifyContent: 'center',
- marginBottom: SPACING[2],
- borderWidth: 1,
- borderColor: 'rgba(255, 255, 255, 0.1)',
- backgroundColor: 'rgba(255, 255, 255, 0.05)',
- },
+ // Popular Card
+ popularCard: {
+ width: 120,
+ },
+ popularThumb: {
+ width: 120,
+ height: 120,
+ borderRadius: RADIUS.LG,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginBottom: SPACING[2],
+ borderWidth: 1,
+ borderColor: colors.border.glass,
+ backgroundColor: colors.bg.overlay1,
+ },
- // Collection Card
- collectionCard: {
- height: 80,
- borderRadius: RADIUS.LG,
- overflow: 'hidden',
- marginBottom: SPACING[3],
- borderWidth: 1,
- borderColor: 'rgba(255, 255, 255, 0.1)',
- },
- collectionContent: {
- flex: 1,
- flexDirection: 'row',
- alignItems: 'center',
- paddingHorizontal: SPACING[5],
- },
- collectionIcon: {
- fontSize: 28,
- marginRight: SPACING[4],
- },
- collectionText: {
- flex: 1,
- },
-})
+ // Collection Card
+ collectionCard: {
+ height: 80,
+ borderRadius: RADIUS.LG,
+ overflow: 'hidden',
+ marginBottom: SPACING[3],
+ borderWidth: 1,
+ borderColor: colors.border.glass,
+ },
+ collectionContent: {
+ flex: 1,
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: SPACING[5],
+ },
+ collectionIcon: {
+ fontSize: 28,
+ marginRight: SPACING[4],
+ },
+ collectionText: {
+ flex: 1,
+ },
+ })
+}
diff --git a/app/(tabs)/profile.tsx b/app/(tabs)/profile.tsx
index 0a61855..dda59ad 100644
--- a/app/(tabs)/profile.tsx
+++ b/app/(tabs)/profile.tsx
@@ -15,19 +15,18 @@ import {
Switch,
Text,
LabeledContent,
+ DateTimePicker,
+ Button,
} from '@expo/ui/swift-ui'
+import { useMemo } from 'react'
+import { useTranslation } from 'react-i18next'
import { useUserStore } from '@/src/shared/stores'
+import { requestNotificationPermissions, usePurchases } from '@/src/shared/hooks'
import { StyledText } from '@/src/shared/components/StyledText'
-import {
- BRAND,
- DARK,
- TEXT,
- GLASS,
- SHADOW,
- GRADIENTS,
-} from '@/src/shared/constants/colors'
+import { useThemeColors, BRAND, GRADIENTS } 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'
@@ -44,14 +43,44 @@ const FONTS = {
// ═══════════════════════════════════════════════════════════════════════════
export default function ProfileScreen() {
+ const { t } = useTranslation('screens')
const insets = useSafeAreaInsets()
+ const colors = useThemeColors()
+ const styles = useMemo(() => createStyles(colors), [colors])
const profile = useUserStore((s) => s.profile)
const settings = useUserStore((s) => s.settings)
const updateSettings = useUserStore((s) => s.updateSettings)
+ const { restorePurchases } = usePurchases()
const isPremium = profile.subscription !== 'free'
const planLabel = isPremium ? 'TabataFit+' : 'Free'
+ const handleRestore = async () => {
+ await restorePurchases()
+ }
+
+ const handleReminderToggle = async (enabled: boolean) => {
+ if (enabled) {
+ const granted = await requestNotificationPermissions()
+ if (!granted) return
+ }
+ updateSettings({ reminders: enabled })
+ }
+
+ const handleTimeChange = (date: Date) => {
+ const hh = String(date.getHours()).padStart(2, '0')
+ const mm = String(date.getMinutes()).padStart(2, '0')
+ updateSettings({ reminderTime: `${hh}:${mm}` })
+ }
+
+ // Build initial date string for the picker (today at reminderTime)
+ const today = new Date()
+ const [rh, rm] = settings.reminderTime.split(':').map(Number)
+ const pickerDate = new Date(today.getFullYear(), today.getMonth(), today.getDate(), rh, rm)
+ const pickerInitial = pickerDate.toISOString()
+
+ const settingsHeight = settings.reminders ? 430 : 385
+
return (
{/* Header */}
-
- Profile
+
+ {t('profile.title')}
{/* Profile Card */}
-
+
-
+
{profile.name[0]}
-
+
{profile.name}
-
+
{profile.email}
{isPremium && (
-
+
{planLabel}
@@ -102,59 +131,82 @@ export default function ProfileScreen() {
style={StyleSheet.absoluteFill}
/>
-
+
-
+
{planLabel}
-
- {'Member since ' + profile.joinDate}
+
+ {t('profile.memberSince', { date: profile.joinDate })}
)}
- {/* SwiftUI Island: Settings */}
-
+ {/* SwiftUI Island: Subscription Section */}
+
-
+
+
+
+
+
+
+ {/* SwiftUI Island: Settings */}
+
+
+
updateSettings({ haptics: v })}
color={BRAND.PRIMARY}
/>
updateSettings({ soundEffects: v })}
color={BRAND.PRIMARY}
/>
updateSettings({ voiceCoaching: v })}
color={BRAND.PRIMARY}
/>
-
+
updateSettings({ reminders: v })}
+ onValueChange={handleReminderToggle}
color={BRAND.PRIMARY}
/>
-
- {settings.reminderTime.replace(':00', ':00 AM')}
-
+ {settings.reminders && (
+
+
+
+ )}
{/* Version */}
-
- TabataFit v1.0.0
+
+ {t('profile.version')}
@@ -165,72 +217,74 @@ export default function ProfileScreen() {
// STYLES
// ═══════════════════════════════════════════════════════════════════════════
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: DARK.BASE,
- },
- scrollView: {
- flex: 1,
- },
- scrollContent: {
- paddingHorizontal: LAYOUT.SCREEN_PADDING,
- },
+function createStyles(colors: ThemeColors) {
+ return StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: colors.bg.base,
+ },
+ scrollView: {
+ flex: 1,
+ },
+ scrollContent: {
+ paddingHorizontal: LAYOUT.SCREEN_PADDING,
+ },
- // Profile Card
- profileCard: {
- borderRadius: RADIUS.GLASS_CARD,
- overflow: 'hidden',
- marginBottom: SPACING[6],
- marginTop: SPACING[4],
- alignItems: 'center',
- paddingVertical: SPACING[6],
- borderWidth: 1,
- borderColor: 'rgba(255, 255, 255, 0.1)',
- ...SHADOW.md,
- },
- avatarContainer: {
- width: 80,
- height: 80,
- borderRadius: 40,
- alignItems: 'center',
- justifyContent: 'center',
- marginBottom: SPACING[3],
- overflow: 'hidden',
- },
- premiumBadge: {
- flexDirection: 'row',
- alignItems: 'center',
- backgroundColor: 'rgba(255, 107, 53, 0.15)',
- paddingHorizontal: SPACING[3],
- paddingVertical: SPACING[1],
- borderRadius: RADIUS.FULL,
- marginTop: SPACING[3],
- gap: SPACING[1],
- },
+ // Profile Card
+ profileCard: {
+ borderRadius: RADIUS.GLASS_CARD,
+ overflow: 'hidden',
+ marginBottom: SPACING[6],
+ marginTop: SPACING[4],
+ alignItems: 'center',
+ paddingVertical: SPACING[6],
+ borderWidth: 1,
+ borderColor: colors.border.glass,
+ ...colors.shadow.md,
+ },
+ avatarContainer: {
+ width: 80,
+ height: 80,
+ borderRadius: 40,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginBottom: SPACING[3],
+ overflow: 'hidden',
+ },
+ premiumBadge: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: 'rgba(255, 107, 53, 0.15)',
+ paddingHorizontal: SPACING[3],
+ paddingVertical: SPACING[1],
+ borderRadius: RADIUS.FULL,
+ marginTop: SPACING[3],
+ gap: SPACING[1],
+ },
- // Subscription Card
- subscriptionCard: {
- height: 80,
- borderRadius: RADIUS.LG,
- overflow: 'hidden',
- marginBottom: SPACING[6],
- ...SHADOW.BRAND_GLOW,
- },
- subscriptionContent: {
- flex: 1,
- flexDirection: 'row',
- alignItems: 'center',
- paddingHorizontal: SPACING[4],
- gap: SPACING[3],
- },
- subscriptionInfo: {
- flex: 1,
- },
+ // Subscription Card
+ subscriptionCard: {
+ height: 80,
+ borderRadius: RADIUS.LG,
+ overflow: 'hidden',
+ marginBottom: SPACING[6],
+ ...colors.shadow.BRAND_GLOW,
+ },
+ subscriptionContent: {
+ flex: 1,
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: SPACING[4],
+ gap: SPACING[3],
+ },
+ subscriptionInfo: {
+ flex: 1,
+ },
- // Version Text
- versionText: {
- textAlign: 'center',
- marginTop: SPACING[6],
- },
-})
+ // Version Text
+ versionText: {
+ textAlign: 'center',
+ marginTop: SPACING[6],
+ },
+ })
+}
diff --git a/app/(tabs)/workouts.tsx b/app/(tabs)/workouts.tsx
index bcace97..ed1c497 100644
--- a/app/(tabs)/workouts.tsx
+++ b/app/(tabs)/workouts.tsx
@@ -1,40 +1,134 @@
/**
* TabataFit Workouts Screen
- * React Native + SwiftUI Islands — wired to shared data
+ * Premium workout browser — scrollable category pills, trainers, workout grid
*/
-import { useState } from 'react'
-import { View, StyleSheet, ScrollView, Pressable, Dimensions, Text as RNText } from 'react-native'
+import { useState, useRef, useMemo } from 'react'
+import { View, StyleSheet, ScrollView, Pressable, Dimensions, Animated } from 'react-native'
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 { Host, Picker } from '@expo/ui/swift-ui'
+import { useTranslation } from 'react-i18next'
import { useHaptics } from '@/src/shared/hooks'
-import { WORKOUTS, TRAINERS, CATEGORIES, getTrainerById } from '@/src/shared/data'
+import { WORKOUTS } from '@/src/shared/data'
+import { useTranslatedCategories, useTranslatedWorkouts } from '@/src/shared/data/useTranslatedData'
import { StyledText } from '@/src/shared/components/StyledText'
-import {
- BRAND,
- DARK,
- TEXT,
- GLASS,
- SHADOW,
-} from '@/src/shared/constants/colors'
+import { useThemeColors, BRAND, GRADIENTS } 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'
const { width: SCREEN_WIDTH } = Dimensions.get('window')
const CARD_WIDTH = (SCREEN_WIDTH - LAYOUT.SCREEN_PADDING * 2 - SPACING[3]) / 2
-const FONTS = {
- LARGE_TITLE: 34,
- TITLE_2: 22,
- HEADLINE: 17,
- SUBHEADLINE: 15,
- CAPTION_1: 12,
- CAPTION_2: 11,
+// ═══════════════════════════════════════════════════════════════════════════
+// CATEGORY PILL
+// ═══════════════════════════════════════════════════════════════════════════
+
+function CategoryPill({
+ label,
+ selected,
+ onPress,
+}: {
+ label: string
+ selected: boolean
+ onPress: () => void
+}) {
+ const colors = useThemeColors()
+ const styles = useMemo(() => createStyles(colors), [colors])
+ return (
+
+ {selected && (
+
+ )}
+
+ {label}
+
+
+ )
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// WORKOUT CARD
+// ═══════════════════════════════════════════════════════════════════════════
+
+function WorkoutCard({
+ title,
+ duration,
+ level,
+ levelLabel,
+ onPress,
+}: {
+ title: string
+ duration: number
+ level: string
+ levelLabel: string
+ onPress: () => void
+}) {
+ const colors = useThemeColors()
+ const styles = useMemo(() => createStyles(colors), [colors])
+ return (
+
+
+
+ {/* Subtle gradient accent at top */}
+
+
+ {/* Duration badge */}
+
+
+
+ {duration + ' min'}
+
+
+
+ {/* Play button */}
+
+
+
+
+
+
+ {/* Info */}
+
+
+ {title}
+
+
+
+
+ {levelLabel}
+
+
+
+
+ )
+}
+
+function levelColor(level: string, colors: ThemeColors): string {
+ switch (level.toLowerCase()) {
+ case 'beginner': return BRAND.SUCCESS
+ case 'intermediate': return BRAND.SECONDARY
+ case 'advanced': return BRAND.DANGER
+ default: return colors.text.tertiary
+ }
}
// ═══════════════════════════════════════════════════════════════════════════
@@ -42,22 +136,28 @@ const FONTS = {
// ═══════════════════════════════════════════════════════════════════════════
export default function WorkoutsScreen() {
+ const { t } = useTranslation()
const insets = useSafeAreaInsets()
const router = useRouter()
const haptics = useHaptics()
- const [selectedCategoryIndex, setSelectedCategoryIndex] = useState(0)
-
- const selectedCategory = CATEGORIES[selectedCategoryIndex].id
+ const colors = useThemeColors()
+ const styles = useMemo(() => createStyles(colors), [colors])
+ const [selectedCategory, setSelectedCategory] = useState('all')
+ const categories = useTranslatedCategories()
const filteredWorkouts = selectedCategory === 'all'
? WORKOUTS
: WORKOUTS.filter(w => w.category === selectedCategory)
+ const translatedFiltered = useTranslatedWorkouts(filteredWorkouts)
+
const handleWorkoutPress = (id: string) => {
haptics.buttonTap()
router.push(`/workout/${id}`)
}
+ const selectedLabel = categories.find(c => c.id === selectedCategory)?.label ?? t('screens:workouts.allWorkouts')
+
return (
{/* Header */}
- Workouts
- {WORKOUTS.length + ' workouts available'}
+
+ {t('screens:workouts.title')}
+
+
+ {t('screens:workouts.available', { count: WORKOUTS.length })}
+
- {/* SwiftUI Island: Category Picker */}
-
- c.label)}
- selectedIndex={selectedCategoryIndex}
- onOptionSelected={(e) => {
- haptics.selection()
- setSelectedCategoryIndex(e.nativeEvent.index)
- }}
- color={BRAND.PRIMARY}
- />
-
-
- {/* Trainers */}
-
- Trainers
-
- {TRAINERS.map((trainer) => (
-
-
-
- {trainer.name[0]}
-
- {trainer.name}
- {trainer.specialty}
-
- ))}
-
-
+ {/* Category Pills — horizontal scroll, no truncation */}
+
+ {categories.map((cat) => (
+ {
+ haptics.selection()
+ setSelectedCategory(cat.id)
+ }}
+ />
+ ))}
+
{/* Workouts Grid */}
-
- {selectedCategory === 'all' ? 'All Workouts' : (CATEGORIES.find(c => c.id === selectedCategory)?.label ?? 'All Workouts')}
+
+ {selectedCategory === 'all' ? t('screens:workouts.allWorkouts') : selectedLabel}
{selectedCategory !== 'all' && (
{ haptics.buttonTap(); router.push(`/workout/category/${selectedCategory}`) }}>
- See All
+ {t('seeAll')}
)}
- {filteredWorkouts.map((workout) => {
- const trainer = getTrainerById(workout.trainerId)
- return (
- handleWorkoutPress(workout.id)}
- >
-
-
-
- {workout.duration + ' min'}
-
-
-
- {trainer?.name[0] ?? 'T'}
-
-
-
-
-
-
-
-
-
- {workout.title}
- {workout.level}
-
-
- )
- })}
+ {translatedFiltered.map((workout) => (
+ handleWorkoutPress(workout.id)}
+ />
+ ))}
@@ -161,119 +229,126 @@ export default function WorkoutsScreen() {
// STYLES
// ═══════════════════════════════════════════════════════════════════════════
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: DARK.BASE,
- },
- scrollView: {
- flex: 1,
- },
- scrollContent: {
- paddingHorizontal: LAYOUT.SCREEN_PADDING,
- },
+function createStyles(colors: ThemeColors) {
+ return StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: colors.bg.base,
+ },
+ scrollView: {
+ flex: 1,
+ },
+ scrollContent: {
+ paddingHorizontal: LAYOUT.SCREEN_PADDING,
+ },
- // Header
- header: {
- marginBottom: SPACING[6],
- },
+ // Header
+ header: {
+ marginBottom: SPACING[4],
+ },
- // Picker Island
- pickerIsland: {
- marginBottom: SPACING[6],
- },
+ // Pills
+ pillsScroll: {
+ marginHorizontal: -LAYOUT.SCREEN_PADDING,
+ marginBottom: SPACING[6],
+ },
+ pillsRow: {
+ paddingHorizontal: LAYOUT.SCREEN_PADDING,
+ gap: SPACING[2],
+ },
+ pill: {
+ paddingHorizontal: SPACING[4],
+ paddingVertical: SPACING[2],
+ borderRadius: 20,
+ backgroundColor: colors.bg.surface,
+ borderWidth: 1,
+ borderColor: colors.border.glassLight,
+ },
+ pillSelected: {
+ borderColor: BRAND.PRIMARY,
+ backgroundColor: 'transparent',
+ },
- // Section
- section: {
- marginBottom: SPACING[8],
- },
- sectionHeader: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- alignItems: 'center',
- marginBottom: SPACING[2],
- },
+ // Section
+ section: {
+ marginBottom: SPACING[6],
+ },
+ sectionHeader: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: SPACING[4],
+ },
- // Trainers
- trainersScroll: {
- gap: SPACING[3],
- },
- trainerCard: {
- width: 100,
- alignItems: 'center',
- paddingVertical: SPACING[4],
- borderRadius: RADIUS.LG,
- borderWidth: 1,
- borderColor: 'rgba(255, 255, 255, 0.1)',
- overflow: 'hidden',
- },
- trainerAvatar: {
- width: 48,
- height: 48,
- borderRadius: 24,
- alignItems: 'center',
- justifyContent: 'center',
- marginBottom: SPACING[2],
- },
-
- // Workouts Grid
- workoutsGrid: {
- flexDirection: 'row',
- flexWrap: 'wrap',
- gap: SPACING[3],
- },
- workoutCard: {
- width: CARD_WIDTH,
- height: 180,
- borderRadius: RADIUS.GLASS_CARD,
- overflow: 'hidden',
- ...SHADOW.md,
- borderWidth: 1,
- borderColor: 'rgba(255, 255, 255, 0.1)',
- },
- durationBadge: {
- position: 'absolute',
- top: SPACING[2],
- right: SPACING[2],
- backgroundColor: 'rgba(0, 0, 0, 0.5)',
- paddingHorizontal: SPACING[2],
- paddingVertical: SPACING[1],
- borderRadius: RADIUS.SM,
- },
- workoutTrainerBadge: {
- position: 'absolute',
- top: SPACING[2],
- left: SPACING[2],
- width: 28,
- height: 28,
- borderRadius: 14,
- alignItems: 'center',
- justifyContent: 'center',
- },
- playOverlay: {
- position: 'absolute',
- top: 0,
- left: 0,
- right: 0,
- bottom: 60,
- alignItems: 'center',
- justifyContent: 'center',
- },
- playCircle: {
- width: 44,
- height: 44,
- borderRadius: 22,
- backgroundColor: 'rgba(255, 255, 255, 0.2)',
- alignItems: 'center',
- justifyContent: 'center',
- borderWidth: 1,
- borderColor: 'rgba(255, 255, 255, 0.3)',
- },
- workoutInfo: {
- position: 'absolute',
- bottom: 0,
- left: 0,
- right: 0,
- padding: SPACING[3],
- },
-})
+ // Workouts Grid
+ workoutsGrid: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: SPACING[3],
+ },
+ workoutCard: {
+ width: CARD_WIDTH,
+ height: 190,
+ borderRadius: RADIUS.GLASS_CARD,
+ overflow: 'hidden',
+ borderWidth: 1,
+ borderColor: colors.bg.overlay2,
+ },
+ cardGradient: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ height: 80,
+ },
+ durationBadge: {
+ position: 'absolute',
+ top: SPACING[3],
+ right: SPACING[3],
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: 'rgba(0, 0, 0, 0.5)',
+ paddingHorizontal: SPACING[2],
+ paddingVertical: 3,
+ borderRadius: RADIUS.SM,
+ },
+ playArea: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 64,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ playCircle: {
+ width: 40,
+ height: 40,
+ borderRadius: 20,
+ backgroundColor: colors.border.glassStrong,
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderWidth: 1,
+ borderColor: 'rgba(255, 255, 255, 0.25)',
+ },
+ workoutInfo: {
+ position: 'absolute',
+ bottom: 0,
+ left: 0,
+ right: 0,
+ padding: SPACING[3],
+ paddingTop: SPACING[2],
+ },
+ levelRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginTop: 3,
+ gap: 5,
+ },
+ levelDot: {
+ width: 6,
+ height: 6,
+ borderRadius: 3,
+ },
+ })
+}
diff --git a/app/_layout.tsx b/app/_layout.tsx
index ab66c29..6a7b4c9 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -1,13 +1,18 @@
/**
* TabataFit Root Layout
* Expo Router v3 + Inter font loading
+ * Waits for font + store hydration before rendering
*/
-import { useCallback } from 'react'
+import '@/src/shared/i18n'
+import '@/src/shared/i18n/types'
+
+import { useState, useEffect, useCallback } from 'react'
import { Stack } from 'expo-router'
import { StatusBar } from 'expo-status-bar'
import { View } from 'react-native'
import * as SplashScreen from 'expo-splash-screen'
+import * as Notifications from 'expo-notifications'
import {
useFonts,
Inter_400Regular,
@@ -17,11 +22,29 @@ import {
Inter_900Black,
} from '@expo-google-fonts/inter'
-import { DARK } from '@/src/shared/constants/colors'
+import { PostHogProvider } from 'posthog-react-native'
+
+import { ThemeProvider, useThemeColors } from '@/src/shared/theme'
+import { useUserStore } from '@/src/shared/stores'
+import { useNotifications } from '@/src/shared/hooks'
+import { initializePurchases } from '@/src/shared/services/purchases'
+import { initializeAnalytics, getPostHogClient } from '@/src/shared/services/analytics'
+
+Notifications.setNotificationHandler({
+ handleNotification: async () => ({
+ shouldShowAlert: false,
+ shouldPlaySound: false,
+ shouldSetBadge: false,
+ shouldShowBanner: false,
+ shouldShowList: false,
+ }),
+})
SplashScreen.preventAutoHideAsync()
-export default function RootLayout() {
+function RootLayoutInner() {
+ const colors = useThemeColors()
+
const [fontsLoaded] = useFonts({
Inter_400Regular,
Inter_500Medium,
@@ -30,59 +53,106 @@ export default function RootLayout() {
Inter_900Black,
})
+ useNotifications()
+
+ // Wait for persisted store to hydrate from AsyncStorage
+ const [hydrated, setHydrated] = useState(useUserStore.persist.hasHydrated())
+
+ useEffect(() => {
+ const unsub = useUserStore.persist.onFinishHydration(() => setHydrated(true))
+ return unsub
+ }, [])
+
+ // Initialize RevenueCat + PostHog after hydration
+ useEffect(() => {
+ if (hydrated) {
+ initializePurchases().catch((err) => {
+ console.error('Failed to initialize RevenueCat:', err)
+ })
+ initializeAnalytics().catch((err) => {
+ console.error('Failed to initialize PostHog:', err)
+ })
+ }
+ }, [hydrated])
+
const onLayoutRootView = useCallback(async () => {
- if (fontsLoaded) {
+ if (fontsLoaded && hydrated) {
await SplashScreen.hideAsync()
}
- }, [fontsLoaded])
+ }, [fontsLoaded, hydrated])
- if (!fontsLoaded) {
+ if (!fontsLoaded || !hydrated) {
return null
}
+ const content = (
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+
+ // Skip PostHogProvider in dev to avoid SDK errors without a real API key
+ if (__DEV__) {
+ return content
+ }
+
return (
-
-
-
-
-
-
-
-
-
-
-
+
+ {content}
+
+ )
+}
+
+export default function RootLayout() {
+ return (
+
+
+
)
}
diff --git a/app/collection/[id].tsx b/app/collection/[id].tsx
index 9beec10..a562150 100644
--- a/app/collection/[id].tsx
+++ b/app/collection/[id].tsx
@@ -10,15 +10,15 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { LinearGradient } from 'expo-linear-gradient'
import Ionicons from '@expo/vector-icons/Ionicons'
+import { useTranslation } from 'react-i18next'
+
import { useHaptics } from '@/src/shared/hooks'
-import { getCollectionById, getCollectionWorkouts, getTrainerById, COLLECTION_COLORS } from '@/src/shared/data'
+import { getCollectionById, getCollectionWorkouts, COLLECTION_COLORS } from '@/src/shared/data'
+import { useTranslatedCollections, useTranslatedWorkouts } from '@/src/shared/data/useTranslatedData'
import { StyledText } from '@/src/shared/components/StyledText'
-import {
- BRAND,
- DARK,
- TEXT,
-} from '@/src/shared/constants/colors'
+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'
@@ -26,13 +26,20 @@ export default function CollectionDetailScreen() {
const insets = useSafeAreaInsets()
const router = useRouter()
const haptics = useHaptics()
+ const { t } = useTranslation()
const { id } = useLocalSearchParams<{ id: string }>()
- const collection = id ? getCollectionById(id) : null
- const workouts = useMemo(
- () => id ? getCollectionWorkouts(id) : [],
+ const colors = useThemeColors()
+ const styles = useMemo(() => createStyles(colors), [colors])
+
+ const rawCollection = id ? getCollectionById(id) : null
+ const translatedCollections = useTranslatedCollections(rawCollection ? [rawCollection] : [])
+ const collection = translatedCollections.length > 0 ? translatedCollections[0] : null
+ const rawWorkouts = useMemo(
+ () => id ? getCollectionWorkouts(id).filter((w): w is NonNullable => w != null) : [],
[id]
)
+ const workouts = useTranslatedWorkouts(rawWorkouts)
const collectionColor = COLLECTION_COLORS[id ?? ''] ?? BRAND.PRIMARY
const handleBack = () => {
@@ -48,7 +55,7 @@ export default function CollectionDetailScreen() {
if (!collection) {
return (
- Collection not found
+ {t('screens:collection.notFound')}
)
}
@@ -63,7 +70,7 @@ export default function CollectionDetailScreen() {
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 100 }]}
showsVerticalScrollIndicator={false}
>
- {/* Hero Header */}
+ {/* Hero Header — on gradient, text stays white */}
-
+
{collection.icon}
- {collection.title}
- {collection.description}
+ {collection.title}
+ {collection.description}
-
- {workouts.length + ' workouts'}
+
+ {t('plurals.workout', { count: workouts.length })}
-
- {totalMinutes + ' min total'}
+
+ {t('screens:collection.minTotal', { count: totalMinutes })}
-
- {totalCalories + ' cal'}
+
+ {t('units.calUnit', { count: totalCalories })}
- {/* Workout List */}
+ {/* Workout List — on base bg, use theme tokens */}
{workouts.map((workout, index) => {
if (!workout) return null
- const trainer = getTrainerById(workout.trainerId)
return (
{index + 1}
- {workout.title}
-
- {trainer?.name + ' \u00B7 ' + workout.duration + ' min \u00B7 ' + workout.level}
+ {workout.title}
+
+ {t('durationLevel', { duration: workout.duration, level: t(`levels.${workout.level.toLowerCase()}`) })}
- {workout.calories + ' cal'}
+ {t('units.calUnit', { count: workout.calories })}
@@ -131,81 +137,83 @@ export default function CollectionDetailScreen() {
)
}
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: DARK.BASE,
- },
- scrollView: {
- flex: 1,
- },
- scrollContent: {},
+function createStyles(colors: ThemeColors) {
+ return StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: colors.bg.base,
+ },
+ scrollView: {
+ flex: 1,
+ },
+ scrollContent: {},
- // Hero
- hero: {
- height: 260,
- overflow: 'hidden',
- },
- backButton: {
- width: 44,
- height: 44,
- alignItems: 'center',
- justifyContent: 'center',
- margin: SPACING[3],
- },
- heroContent: {
- position: 'absolute',
- bottom: SPACING[5],
- left: SPACING[5],
- right: SPACING[5],
- },
- heroIcon: {
- fontSize: 40,
- marginBottom: SPACING[2],
- },
- heroStats: {
- flexDirection: 'row',
- gap: SPACING[4],
- marginTop: SPACING[3],
- },
- heroStat: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: SPACING[1],
- },
+ // Hero
+ hero: {
+ height: 260,
+ overflow: 'hidden',
+ },
+ backButton: {
+ width: 44,
+ height: 44,
+ alignItems: 'center',
+ justifyContent: 'center',
+ margin: SPACING[3],
+ },
+ heroContent: {
+ position: 'absolute',
+ bottom: SPACING[5],
+ left: SPACING[5],
+ right: SPACING[5],
+ },
+ heroIcon: {
+ fontSize: 40,
+ marginBottom: SPACING[2],
+ },
+ heroStats: {
+ flexDirection: 'row',
+ gap: SPACING[4],
+ marginTop: SPACING[3],
+ },
+ heroStat: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: SPACING[1],
+ },
- // Workout List
- workoutList: {
- paddingHorizontal: LAYOUT.SCREEN_PADDING,
- paddingTop: SPACING[4],
- gap: SPACING[2],
- },
- workoutCard: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingVertical: SPACING[3],
- paddingHorizontal: SPACING[4],
- backgroundColor: DARK.SURFACE,
- borderRadius: RADIUS.LG,
- gap: SPACING[3],
- },
- workoutNumber: {
- width: 32,
- height: 32,
- borderRadius: 16,
- alignItems: 'center',
- justifyContent: 'center',
- },
- workoutNumberText: {
- fontSize: 15,
- fontWeight: '700',
- },
- workoutInfo: {
- flex: 1,
- gap: 2,
- },
- workoutMeta: {
- alignItems: 'flex-end',
- gap: 4,
- },
-})
+ // Workout List
+ workoutList: {
+ paddingHorizontal: LAYOUT.SCREEN_PADDING,
+ paddingTop: SPACING[4],
+ gap: SPACING[2],
+ },
+ workoutCard: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingVertical: SPACING[3],
+ paddingHorizontal: SPACING[4],
+ backgroundColor: colors.bg.surface,
+ borderRadius: RADIUS.LG,
+ gap: SPACING[3],
+ },
+ workoutNumber: {
+ width: 32,
+ height: 32,
+ borderRadius: 16,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ workoutNumberText: {
+ fontSize: 15,
+ fontWeight: '700',
+ },
+ workoutInfo: {
+ flex: 1,
+ gap: 2,
+ },
+ workoutMeta: {
+ alignItems: 'flex-end',
+ gap: 4,
+ },
+ })
+}
diff --git a/app/complete/[id].tsx b/app/complete/[id].tsx
index 9a51638..f190b62 100644
--- a/app/complete/[id].tsx
+++ b/app/complete/[id].tsx
@@ -3,7 +3,7 @@
* Celebration with real data from activity store
*/
-import { useRef, useEffect } from 'react'
+import { useRef, useEffect, useMemo } from 'react'
import {
View,
Text as RNText,
@@ -20,17 +20,15 @@ import { BlurView } from 'expo-blur'
import Ionicons from '@expo/vector-icons/Ionicons'
import * as Sharing from 'expo-sharing'
+import { useTranslation } from 'react-i18next'
+
import { useHaptics } from '@/src/shared/hooks'
import { useActivityStore } from '@/src/shared/stores'
-import { getWorkoutById, getTrainerById, getPopularWorkouts } from '@/src/shared/data'
+import { getWorkoutById, getPopularWorkouts } from '@/src/shared/data'
+import { useTranslatedWorkout, useTranslatedWorkouts } from '@/src/shared/data/useTranslatedData'
-import {
- BRAND,
- DARK,
- TEXT,
- GLASS,
- SHADOW,
-} from '@/src/shared/constants/colors'
+import { useThemeColors, BRAND } from '@/src/shared/theme'
+import type { ThemeColors } from '@/src/shared/theme/types'
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
@@ -51,6 +49,8 @@ function SecondaryButton({
children: React.ReactNode
icon?: keyof typeof Ionicons.glyphMap
}) {
+ const colors = useThemeColors()
+ const styles = useMemo(() => createStyles(colors), [colors])
const scaleAnim = useRef(new Animated.Value(1)).current
const handlePressIn = () => {
@@ -77,7 +77,7 @@ function SecondaryButton({
style={{ width: '100%' }}
>
- {icon && }
+ {icon && }
{children}
@@ -91,6 +91,8 @@ function PrimaryButton({
onPress: () => void
children: React.ReactNode
}) {
+ const colors = useThemeColors()
+ const styles = useMemo(() => createStyles(colors), [colors])
const scaleAnim = useRef(new Animated.Value(1)).current
const handlePressIn = () => {
@@ -134,6 +136,8 @@ function PrimaryButton({
// ═══════════════════════════════════════════════════════════════════════════
function CelebrationRings() {
+ const colors = useThemeColors()
+ const styles = useMemo(() => createStyles(colors), [colors])
const ring1Anim = useRef(new Animated.Value(0)).current
const ring2Anim = useRef(new Animated.Value(0)).current
const ring3Anim = useRef(new Animated.Value(0)).current
@@ -190,6 +194,8 @@ function StatCard({
icon: keyof typeof Ionicons.glyphMap
delay?: number
}) {
+ const colors = useThemeColors()
+ const styles = useMemo(() => createStyles(colors), [colors])
const scaleAnim = useRef(new Animated.Value(0)).current
useEffect(() => {
@@ -205,7 +211,7 @@ function StatCard({
return (
-
+
{value}
{label}
@@ -214,6 +220,9 @@ function StatCard({
}
function BurnBarResult({ percentile }: { percentile: number }) {
+ const { t } = useTranslation()
+ const colors = useThemeColors()
+ const styles = useMemo(() => createStyles(colors), [colors])
const barAnim = useRef(new Animated.Value(0)).current
useEffect(() => {
@@ -232,8 +241,8 @@ function BurnBarResult({ percentile }: { percentile: number }) {
return (
- Burn Bar
- You beat {percentile}% of users!
+ {t('screens:complete.burnBar')}
+ {t('screens:complete.burnBarResult', { percentile })}
@@ -249,9 +258,14 @@ export default function WorkoutCompleteScreen() {
const insets = useSafeAreaInsets()
const router = useRouter()
const haptics = useHaptics()
+ const { t } = useTranslation()
const { id } = useLocalSearchParams<{ id: string }>()
- const workout = getWorkoutById(id ?? '1')
+ const colors = useThemeColors()
+ const styles = useMemo(() => createStyles(colors), [colors])
+
+ const rawWorkout = getWorkoutById(id ?? '1')
+ const workout = useTranslatedWorkout(rawWorkout)
const streak = useActivityStore((s) => s.streak)
const history = useActivityStore((s) => s.history)
const recentWorkouts = history.slice(0, 1)
@@ -262,7 +276,8 @@ export default function WorkoutCompleteScreen() {
const resultMinutes = latestResult?.durationMinutes ?? workout?.duration ?? 4
// Recommended workouts (different from current)
- const recommended = getPopularWorkouts(4).filter(w => w.id !== id).slice(0, 3)
+ const rawRecommended = getPopularWorkouts(4).filter(w => w.id !== id).slice(0, 3)
+ const recommended = useTranslatedWorkouts(rawRecommended)
const handleGoHome = () => {
haptics.buttonTap()
@@ -274,7 +289,7 @@ export default function WorkoutCompleteScreen() {
const isAvailable = await Sharing.isAvailableAsync()
if (isAvailable) {
await Sharing.shareAsync('https://tabatafit.app', {
- dialogTitle: `I just completed ${workout?.title ?? 'a workout'}! 🔥 ${resultCalories} calories in ${resultMinutes} minutes.`,
+ dialogTitle: t('screens:complete.shareText', { title: workout?.title ?? 'a workout', calories: resultCalories, duration: resultMinutes }),
})
}
}
@@ -297,15 +312,15 @@ export default function WorkoutCompleteScreen() {
{/* Celebration */}
🎉
- WORKOUT COMPLETE
+ {t('screens:complete.title')}
{/* Stats Grid */}
-
-
-
+
+
+
{/* Burn Bar */}
@@ -319,8 +334,8 @@ export default function WorkoutCompleteScreen() {
- {streak.current} Day Streak!
- Keep the momentum going!
+ {t('screens:complete.streakTitle', { count: streak.current })}
+ {t('screens:complete.streakSubtitle')}
@@ -329,7 +344,7 @@ export default function WorkoutCompleteScreen() {
{/* Share Button */}
- Share Your Workout
+ {t('screens:complete.shareWorkout')}
@@ -337,39 +352,36 @@ export default function WorkoutCompleteScreen() {
{/* Recommended */}
- Recommended Next
+ {t('screens:complete.recommendedNext')}
- {recommended.map((w) => {
- const trainer = getTrainerById(w.trainerId)
- return (
- handleWorkoutPress(w.id)}
- style={styles.recommendedCard}
- >
-
-
-
- {trainer?.name[0] ?? 'T'}
-
- {w.title}
- {w.duration} min
-
- )
- })}
+ {recommended.map((w) => (
+ handleWorkoutPress(w.id)}
+ style={styles.recommendedCard}
+ >
+
+
+
+
+
+ {w.title}
+ {t('units.minUnit', { count: w.duration })}
+
+ ))}
{/* Fixed Bottom Button */}
-
+
- Back to Home
+ {t('screens:complete.backToHome')}
@@ -381,246 +393,248 @@ export default function WorkoutCompleteScreen() {
// STYLES
// ═══════════════════════════════════════════════════════════════════════════
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: DARK.BASE,
- },
- scrollView: {
- flex: 1,
- },
- scrollContent: {
- paddingHorizontal: LAYOUT.SCREEN_PADDING,
- },
+function createStyles(colors: ThemeColors) {
+ return StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: colors.bg.base,
+ },
+ scrollView: {
+ flex: 1,
+ },
+ scrollContent: {
+ paddingHorizontal: LAYOUT.SCREEN_PADDING,
+ },
- // Buttons
- secondaryButton: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- paddingVertical: SPACING[3],
- paddingHorizontal: SPACING[4],
- borderRadius: RADIUS.LG,
- borderWidth: 1,
- borderColor: 'rgba(255, 255, 255, 0.3)',
- backgroundColor: 'transparent',
- },
- secondaryButtonText: {
- ...TYPOGRAPHY.BODY,
- color: TEXT.PRIMARY,
- fontWeight: '600',
- },
- primaryButton: {
- alignItems: 'center',
- justifyContent: 'center',
- paddingVertical: SPACING[4],
- paddingHorizontal: SPACING[6],
- borderRadius: RADIUS.LG,
- overflow: 'hidden',
- },
- primaryButtonText: {
- ...TYPOGRAPHY.HEADLINE,
- color: TEXT.PRIMARY,
- fontWeight: '700',
- },
- buttonIcon: {
- marginRight: SPACING[2],
- },
+ // Buttons
+ secondaryButton: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingVertical: SPACING[3],
+ paddingHorizontal: SPACING[4],
+ borderRadius: RADIUS.LG,
+ borderWidth: 1,
+ borderColor: colors.border.glassStrong,
+ backgroundColor: 'transparent',
+ },
+ secondaryButtonText: {
+ ...TYPOGRAPHY.BODY,
+ color: colors.text.primary,
+ fontWeight: '600',
+ },
+ primaryButton: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingVertical: SPACING[4],
+ paddingHorizontal: SPACING[6],
+ borderRadius: RADIUS.LG,
+ overflow: 'hidden',
+ },
+ primaryButtonText: {
+ ...TYPOGRAPHY.HEADLINE,
+ color: '#FFFFFF',
+ fontWeight: '700',
+ },
+ buttonIcon: {
+ marginRight: SPACING[2],
+ },
- // Celebration
- celebrationSection: {
- alignItems: 'center',
- paddingVertical: SPACING[8],
- },
- celebrationEmoji: {
- fontSize: 64,
- marginBottom: SPACING[4],
- },
- celebrationTitle: {
- ...TYPOGRAPHY.TITLE_1,
- color: TEXT.PRIMARY,
- letterSpacing: 2,
- },
- ringsContainer: {
- flexDirection: 'row',
- marginTop: SPACING[6],
- gap: SPACING[4],
- },
- ring: {
- width: 64,
- height: 64,
- borderRadius: 32,
- backgroundColor: 'rgba(255, 255, 255, 0.1)',
- alignItems: 'center',
- justifyContent: 'center',
- borderWidth: 2,
- borderColor: 'rgba(255, 255, 255, 0.2)',
- },
- ring1: {
- borderColor: BRAND.PRIMARY,
- backgroundColor: 'rgba(255, 107, 53, 0.15)',
- },
- ring2: {
- borderColor: '#30D158',
- backgroundColor: 'rgba(48, 209, 88, 0.15)',
- },
- ring3: {
- borderColor: '#5AC8FA',
- backgroundColor: 'rgba(90, 200, 250, 0.15)',
- },
- ringEmoji: {
- fontSize: 28,
- },
+ // Celebration
+ celebrationSection: {
+ alignItems: 'center',
+ paddingVertical: SPACING[8],
+ },
+ celebrationEmoji: {
+ fontSize: 64,
+ marginBottom: SPACING[4],
+ },
+ celebrationTitle: {
+ ...TYPOGRAPHY.TITLE_1,
+ color: colors.text.primary,
+ letterSpacing: 2,
+ },
+ ringsContainer: {
+ flexDirection: 'row',
+ marginTop: SPACING[6],
+ gap: SPACING[4],
+ },
+ ring: {
+ width: 64,
+ height: 64,
+ borderRadius: 32,
+ backgroundColor: colors.border.glass,
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderWidth: 2,
+ borderColor: colors.border.glassStrong,
+ },
+ ring1: {
+ borderColor: BRAND.PRIMARY,
+ backgroundColor: 'rgba(255, 107, 53, 0.15)',
+ },
+ ring2: {
+ borderColor: '#30D158',
+ backgroundColor: 'rgba(48, 209, 88, 0.15)',
+ },
+ ring3: {
+ borderColor: '#5AC8FA',
+ backgroundColor: 'rgba(90, 200, 250, 0.15)',
+ },
+ ringEmoji: {
+ fontSize: 28,
+ },
- // Stats Grid
- statsGrid: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- marginBottom: SPACING[6],
- },
- statCard: {
- width: (SCREEN_WIDTH - LAYOUT.SCREEN_PADDING * 2 - SPACING[4]) / 3,
- padding: SPACING[3],
- borderRadius: RADIUS.LG,
- alignItems: 'center',
- borderWidth: 1,
- borderColor: 'rgba(255, 255, 255, 0.1)',
- overflow: 'hidden',
- },
- statValue: {
- ...TYPOGRAPHY.TITLE_1,
- color: TEXT.PRIMARY,
- marginTop: SPACING[2],
- },
- statLabel: {
- ...TYPOGRAPHY.CAPTION_2,
- color: TEXT.TERTIARY,
- marginTop: SPACING[1],
- },
+ // Stats Grid
+ statsGrid: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ marginBottom: SPACING[6],
+ },
+ statCard: {
+ width: (SCREEN_WIDTH - LAYOUT.SCREEN_PADDING * 2 - SPACING[4]) / 3,
+ padding: SPACING[3],
+ borderRadius: RADIUS.LG,
+ alignItems: 'center',
+ borderWidth: 1,
+ borderColor: colors.border.glass,
+ overflow: 'hidden',
+ },
+ statValue: {
+ ...TYPOGRAPHY.TITLE_1,
+ color: colors.text.primary,
+ marginTop: SPACING[2],
+ },
+ statLabel: {
+ ...TYPOGRAPHY.CAPTION_2,
+ color: colors.text.tertiary,
+ marginTop: SPACING[1],
+ },
- // Burn Bar
- burnBarContainer: {
- marginBottom: SPACING[6],
- },
- burnBarTitle: {
- ...TYPOGRAPHY.HEADLINE,
- color: TEXT.PRIMARY,
- },
- burnBarResult: {
- ...TYPOGRAPHY.BODY,
- color: BRAND.PRIMARY,
- marginTop: SPACING[1],
- marginBottom: SPACING[3],
- },
- burnBarTrack: {
- height: 8,
- backgroundColor: DARK.SURFACE,
- borderRadius: 4,
- overflow: 'hidden',
- },
- burnBarFill: {
- height: '100%',
- backgroundColor: BRAND.PRIMARY,
- borderRadius: 4,
- },
+ // Burn Bar
+ burnBarContainer: {
+ marginBottom: SPACING[6],
+ },
+ burnBarTitle: {
+ ...TYPOGRAPHY.HEADLINE,
+ color: colors.text.tertiary,
+ },
+ burnBarResult: {
+ ...TYPOGRAPHY.BODY,
+ color: BRAND.PRIMARY,
+ marginTop: SPACING[1],
+ marginBottom: SPACING[3],
+ },
+ burnBarTrack: {
+ height: 8,
+ backgroundColor: colors.bg.surface,
+ borderRadius: 4,
+ overflow: 'hidden',
+ },
+ burnBarFill: {
+ height: '100%',
+ backgroundColor: BRAND.PRIMARY,
+ borderRadius: 4,
+ },
- // Divider
- divider: {
- height: 1,
- backgroundColor: 'rgba(255, 255, 255, 0.1)',
- marginVertical: SPACING[2],
- },
+ // Divider
+ divider: {
+ height: 1,
+ backgroundColor: colors.border.glass,
+ marginVertical: SPACING[2],
+ },
- // Streak
- streakSection: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingVertical: SPACING[4],
- gap: SPACING[4],
- },
- streakBadge: {
- width: 64,
- height: 64,
- borderRadius: 32,
- backgroundColor: 'rgba(255, 107, 53, 0.15)',
- alignItems: 'center',
- justifyContent: 'center',
- },
- streakInfo: {
- flex: 1,
- },
- streakTitle: {
- ...TYPOGRAPHY.TITLE_2,
- color: TEXT.PRIMARY,
- },
- streakSubtitle: {
- ...TYPOGRAPHY.BODY,
- color: TEXT.TERTIARY,
- marginTop: SPACING[1],
- },
+ // Streak
+ streakSection: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingVertical: SPACING[4],
+ gap: SPACING[4],
+ },
+ streakBadge: {
+ width: 64,
+ height: 64,
+ borderRadius: 32,
+ backgroundColor: 'rgba(255, 107, 53, 0.15)',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ streakInfo: {
+ flex: 1,
+ },
+ streakTitle: {
+ ...TYPOGRAPHY.TITLE_2,
+ color: colors.text.primary,
+ },
+ streakSubtitle: {
+ ...TYPOGRAPHY.BODY,
+ color: colors.text.tertiary,
+ marginTop: SPACING[1],
+ },
- // Share
- shareSection: {
- paddingVertical: SPACING[4],
- alignItems: 'center',
- },
+ // Share
+ shareSection: {
+ paddingVertical: SPACING[4],
+ alignItems: 'center',
+ },
- // Recommended
- recommendedSection: {
- paddingVertical: SPACING[4],
- },
- recommendedTitle: {
- ...TYPOGRAPHY.HEADLINE,
- color: TEXT.PRIMARY,
- marginBottom: SPACING[4],
- },
- recommendedGrid: {
- flexDirection: 'row',
- gap: SPACING[3],
- },
- recommendedCard: {
- flex: 1,
- padding: SPACING[3],
- borderRadius: RADIUS.LG,
- borderWidth: 1,
- borderColor: 'rgba(255, 255, 255, 0.1)',
- overflow: 'hidden',
- },
- recommendedThumb: {
- width: '100%',
- aspectRatio: 1,
- borderRadius: RADIUS.MD,
- alignItems: 'center',
- justifyContent: 'center',
- marginBottom: SPACING[2],
- overflow: 'hidden',
- },
- recommendedInitial: {
- ...TYPOGRAPHY.TITLE_1,
- color: TEXT.PRIMARY,
- },
- recommendedTitleText: {
- ...TYPOGRAPHY.CARD_TITLE,
- color: TEXT.PRIMARY,
- },
- recommendedDurationText: {
- ...TYPOGRAPHY.CARD_METADATA,
- color: TEXT.TERTIARY,
- },
+ // Recommended
+ recommendedSection: {
+ paddingVertical: SPACING[4],
+ },
+ recommendedTitle: {
+ ...TYPOGRAPHY.HEADLINE,
+ color: colors.text.primary,
+ marginBottom: SPACING[4],
+ },
+ recommendedGrid: {
+ flexDirection: 'row',
+ gap: SPACING[3],
+ },
+ recommendedCard: {
+ flex: 1,
+ padding: SPACING[3],
+ borderRadius: RADIUS.LG,
+ borderWidth: 1,
+ borderColor: colors.border.glass,
+ overflow: 'hidden',
+ },
+ recommendedThumb: {
+ width: '100%',
+ aspectRatio: 1,
+ borderRadius: RADIUS.MD,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginBottom: SPACING[2],
+ overflow: 'hidden',
+ },
+ recommendedInitial: {
+ ...TYPOGRAPHY.TITLE_1,
+ color: colors.text.primary,
+ },
+ recommendedTitleText: {
+ ...TYPOGRAPHY.CARD_TITLE,
+ color: colors.text.primary,
+ },
+ recommendedDurationText: {
+ ...TYPOGRAPHY.CARD_METADATA,
+ color: colors.text.tertiary,
+ },
- // Bottom Bar
- bottomBar: {
- position: 'absolute',
- bottom: 0,
- left: 0,
- right: 0,
- paddingHorizontal: LAYOUT.SCREEN_PADDING,
- paddingTop: SPACING[4],
- borderTopWidth: 1,
- borderTopColor: 'rgba(255, 255, 255, 0.1)',
- },
- homeButtonContainer: {
- height: 56,
- justifyContent: 'center',
- },
-})
+ // Bottom Bar
+ bottomBar: {
+ position: 'absolute',
+ bottom: 0,
+ left: 0,
+ right: 0,
+ paddingHorizontal: LAYOUT.SCREEN_PADDING,
+ paddingTop: SPACING[4],
+ borderTopWidth: 1,
+ borderTopColor: colors.border.glass,
+ },
+ homeButtonContainer: {
+ height: 56,
+ justifyContent: 'center',
+ },
+ })
+}
diff --git a/app/onboarding.tsx b/app/onboarding.tsx
index e24d092..5eda2d5 100644
--- a/app/onboarding.tsx
+++ b/app/onboarding.tsx
@@ -3,7 +3,7 @@
* Problem → Empathy → Solution → Wow Moment → Personalization → Paywall
*/
-import { useState, useRef, useEffect, useCallback } from 'react'
+import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import {
View,
StyleSheet,
@@ -17,16 +17,19 @@ import { useRouter } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import Ionicons from '@expo/vector-icons/Ionicons'
+import { Alert } from 'react-native'
import { useTranslation } from 'react-i18next'
-import { useHaptics } from '@/src/shared/hooks'
+import { useHaptics, usePurchases } from '@/src/shared/hooks'
import { useUserStore } from '@/src/shared/stores'
import { OnboardingStep } from '@/src/shared/components/OnboardingStep'
import { StyledText } from '@/src/shared/components/StyledText'
-import { BRAND, DARK, TEXT, PHASE, GLASS, BORDER } from '@/src/shared/constants/colors'
+import { useThemeColors, BRAND, PHASE } 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 { DURATION, EASE, SPRING } from '@/src/shared/constants/animations'
+import { track } from '@/src/shared/services/analytics'
import type { FitnessLevel, FitnessGoal, WeeklyFrequency } from '@/src/shared/types'
@@ -40,6 +43,8 @@ const TOTAL_STEPS = 6
function ProblemScreen({ onNext }: { onNext: () => void }) {
const { t } = useTranslation('screens')
const haptics = useHaptics()
+ const colors = useThemeColors()
+ const styles = useMemo(() => createStyles(colors), [colors])
const clockScale = useRef(new Animated.Value(0.8)).current
const clockOpacity = useRef(new Animated.Value(0)).current
const textOpacity = useRef(new Animated.Value(0)).current
@@ -87,21 +92,21 @@ function ProblemScreen({ onNext }: { onNext: () => void }) {
{t('onboarding.problem.title')}
{t('onboarding.problem.subtitle1')}
{t('onboarding.problem.subtitle2')}
@@ -116,7 +121,7 @@ function ProblemScreen({ onNext }: { onNext: () => void }) {
onNext()
}}
>
-
+
{t('onboarding.problem.cta')}
@@ -147,6 +152,8 @@ function EmpathyScreen({
}) {
const { t } = useTranslation('screens')
const haptics = useHaptics()
+ const colors = useThemeColors()
+ const styles = useMemo(() => createStyles(colors), [colors])
const toggleBarrier = (id: string) => {
haptics.selection()
@@ -159,10 +166,10 @@ function EmpathyScreen({
return (
-
+
{t('onboarding.empathy.title')}
-
+
{t('onboarding.empathy.chooseUpTo')}
@@ -181,12 +188,12 @@ function EmpathyScreen({
{t(item.labelKey)}
@@ -209,7 +216,7 @@ function EmpathyScreen({
0 ? TEXT.PRIMARY : TEXT.DISABLED}
+ color={barriers.length > 0 ? '#FFFFFF' : colors.text.disabled}
>
{t('common:continue')}
@@ -226,6 +233,8 @@ function EmpathyScreen({
function SolutionScreen({ onNext }: { onNext: () => void }) {
const { t } = useTranslation('screens')
const haptics = useHaptics()
+ const colors = useThemeColors()
+ const styles = useMemo(() => createStyles(colors), [colors])
const tabataHeight = useRef(new Animated.Value(0)).current
const cardioHeight = useRef(new Animated.Value(0)).current
const citationOpacity = useRef(new Animated.Value(0)).current
@@ -260,7 +269,7 @@ function SolutionScreen({ onNext }: { onNext: () => void }) {
return (
-
+
{t('onboarding.solution.title')}
@@ -285,17 +294,17 @@ function SolutionScreen({ onNext }: { onNext: () => void }) {
]}
/>
-
+
{t('onboarding.solution.tabata')}
-
+
{t('onboarding.solution.tabataDuration')}
{/* VS */}
-
+
{t('onboarding.solution.vs')}
@@ -319,10 +328,10 @@ function SolutionScreen({ onNext }: { onNext: () => void }) {
]}
/>
-
+
{t('onboarding.solution.cardio')}
-
+
{t('onboarding.solution.cardioDuration')}
@@ -330,10 +339,10 @@ function SolutionScreen({ onNext }: { onNext: () => void }) {
{/* Citation */}
-
+
{t('onboarding.solution.citation')}
-
+
{t('onboarding.solution.citationAuthor')}
@@ -346,7 +355,7 @@ function SolutionScreen({ onNext }: { onNext: () => void }) {
onNext()
}}
>
-
+
{t('onboarding.solution.cta')}
@@ -369,6 +378,9 @@ const WOW_FEATURES = [
function WowScreen({ onNext }: { onNext: () => void }) {
const { t } = useTranslation('screens')
const haptics = useHaptics()
+ const colors = useThemeColors()
+ const styles = useMemo(() => createStyles(colors), [colors])
+ const wowStyles = useMemo(() => createWowStyles(colors), [colors])
const rowAnims = useRef(WOW_FEATURES.map(() => ({
opacity: new Animated.Value(0),
translateY: new Animated.Value(20),
@@ -416,10 +428,10 @@ function WowScreen({ onNext }: { onNext: () => void }) {
return (
-
+
{t('onboarding.wow.title')}
-
+
{t('onboarding.wow.subtitle')}
@@ -440,10 +452,10 @@ function WowScreen({ onNext }: { onNext: () => void }) {
-
+
{t(feature.titleKey)}
-
+
{t(feature.subtitleKey)}
@@ -462,7 +474,7 @@ function WowScreen({ onNext }: { onNext: () => void }) {
}
}}
>
-
+
{t('common:next')}
@@ -517,6 +529,8 @@ function PersonalizationScreen({
}) {
const { t } = useTranslation('screens')
const haptics = useHaptics()
+ const colors = useThemeColors()
+ const styles = useMemo(() => createStyles(colors), [colors])
return (
-
+
{t('onboarding.personalization.title')}
{/* Name input */}
-
+
{t('onboarding.personalization.yourName')}
@@ -547,7 +561,7 @@ function PersonalizationScreen({
{/* Fitness Level */}
-
+
{t('onboarding.personalization.fitnessLevel')}
@@ -566,7 +580,7 @@ function PersonalizationScreen({
{t(item.labelKey)}
@@ -577,7 +591,7 @@ function PersonalizationScreen({
{/* Goal */}
-
+
{t('onboarding.personalization.yourGoal')}
@@ -596,7 +610,7 @@ function PersonalizationScreen({
{t(item.labelKey)}
@@ -607,7 +621,7 @@ function PersonalizationScreen({
{/* Frequency */}
-
+
{t('onboarding.personalization.weeklyFrequency')}
@@ -626,7 +640,7 @@ function PersonalizationScreen({
{t(item.labelKey)}
@@ -654,7 +668,7 @@ function PersonalizationScreen({
{t('common:continue')}
@@ -684,9 +698,26 @@ function PaywallScreen({
}) {
const { t } = useTranslation('screens')
const haptics = useHaptics()
+ const colors = useThemeColors()
+ const styles = useMemo(() => createStyles(colors), [colors])
+ const {
+ isLoading,
+ monthlyPackage,
+ annualPackage,
+ purchasePackage,
+ restorePurchases,
+ } = usePurchases()
+
const [selectedPlan, setSelectedPlan] = useState<'premium-monthly' | 'premium-yearly'>('premium-yearly')
+ const [isPurchasing, setIsPurchasing] = useState(false)
const featureAnims = useRef(PREMIUM_FEATURE_KEYS.map(() => new Animated.Value(0))).current
+ const handlePlanSelect = (plan: 'premium-monthly' | 'premium-yearly') => {
+ haptics.selection()
+ setSelectedPlan(plan)
+ track('onboarding_paywall_plan_selected', { plan })
+ }
+
useEffect(() => {
// Staggered feature fade-in
PREMIUM_FEATURE_KEYS.forEach((_, i) => {
@@ -701,13 +732,76 @@ function PaywallScreen({
})
}, [])
+ // Get localized prices from RevenueCat packages
+ const yearlyPrice = annualPackage?.product.priceString ?? t('onboarding.paywall.yearlyPrice')
+ const monthlyPrice = monthlyPackage?.product.priceString ?? t('onboarding.paywall.monthlyPrice')
+
+ const handlePurchase = async () => {
+ if (isPurchasing) return
+
+ const pkg = selectedPlan === 'premium-yearly' ? annualPackage : monthlyPackage
+ const price = selectedPlan === 'premium-yearly'
+ ? (annualPackage?.product.priceString ?? t('onboarding.paywall.yearlyPrice'))
+ : (monthlyPackage?.product.priceString ?? t('onboarding.paywall.monthlyPrice'))
+
+ track('onboarding_paywall_purchase_tapped', { plan: selectedPlan, price })
+
+ // DEV mode: if RevenueCat hasn't loaded or has no packages, show simulated purchase dialog
+ if (__DEV__ && (isLoading || !pkg)) {
+ haptics.buttonTap()
+ const planLabel = selectedPlan === 'premium-yearly'
+ ? `Annual (${t('onboarding.paywall.yearlyPrice')})`
+ : `Monthly (${t('onboarding.paywall.monthlyPrice')})`
+ Alert.alert(
+ 'Confirm Subscription',
+ `Subscribe to TabataFit+ ${planLabel}?\n\nThis is a sandbox purchase — no real charge.`,
+ [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Subscribe',
+ onPress: () => {
+ track('onboarding_paywall_purchase_success', { plan: selectedPlan })
+ onSubscribe(selectedPlan)
+ },
+ },
+ ]
+ )
+ return
+ }
+
+ if (isLoading || !pkg) return
+
+ setIsPurchasing(true)
+ haptics.buttonTap()
+
+ try {
+ const result = await purchasePackage(pkg)
+ if (result.success) {
+ track('onboarding_paywall_purchase_success', { plan: selectedPlan })
+ onSubscribe(selectedPlan)
+ }
+ } finally {
+ setIsPurchasing(false)
+ }
+ }
+
+ const handleRestore = async () => {
+ haptics.buttonTap()
+ const restored = await restorePurchases()
+ track('onboarding_paywall_restored', { success: !!restored })
+ if (restored) {
+ // User has premium now, complete onboarding
+ onSubscribe('premium-yearly')
+ }
+ }
+
return (
-
+
{t('onboarding.paywall.title')}
@@ -721,7 +815,7 @@ function PaywallScreen({
{t(featureKey)}
@@ -738,20 +832,17 @@ function PaywallScreen({
styles.pricingCard,
selectedPlan === 'premium-yearly' && styles.pricingCardSelected,
]}
- onPress={() => {
- haptics.selection()
- setSelectedPlan('premium-yearly')
- }}
+ onPress={() => handlePlanSelect('premium-yearly')}
>
-
+
{t('onboarding.paywall.bestValue')}
-
- {t('onboarding.paywall.yearlyPrice')}
+
+ {yearlyPrice}
-
+
{t('common:units.perYear')}
@@ -765,15 +856,12 @@ function PaywallScreen({
styles.pricingCard,
selectedPlan === 'premium-monthly' && styles.pricingCardSelected,
]}
- onPress={() => {
- haptics.selection()
- setSelectedPlan('premium-monthly')
- }}
+ onPress={() => handlePlanSelect('premium-monthly')}
>
-
- {t('onboarding.paywall.monthlyPrice')}
+
+ {monthlyPrice}
-
+
{t('common:units.perMonth')}
@@ -781,27 +869,35 @@ function PaywallScreen({
{/* CTA */}
{
- haptics.buttonTap()
- onSubscribe(selectedPlan)
- }}
+ style={[styles.trialButton, isPurchasing && styles.ctaButtonDisabled]}
+ onPress={handlePurchase}
+ disabled={isPurchasing}
>
-
- {t('onboarding.paywall.trialCta')}
+
+ {isPurchasing ? '...' : t('onboarding.paywall.trialCta')}
{/* Guarantees */}
-
+
{t('onboarding.paywall.guarantees')}
+ {/* Restore Purchases */}
+
+
+ {t('onboarding.paywall.restorePurchases')}
+
+
+
{/* Skip */}
-
-
+ {
+ track('onboarding_paywall_skipped')
+ onSkip()
+ }}>
+
{t('onboarding.paywall.skipButton')}
@@ -813,6 +909,15 @@ function PaywallScreen({
// MAIN ONBOARDING CONTROLLER
// ═══════════════════════════════════════════════════════════════════════════
+const STEP_NAMES: Record = {
+ 1: 'problem',
+ 2: 'empathy',
+ 3: 'solution',
+ 4: 'wow',
+ 5: 'personalization',
+ 6: 'paywall',
+}
+
export default function OnboardingScreen() {
const router = useRouter()
const [step, setStep] = useState(1)
@@ -827,8 +932,24 @@ export default function OnboardingScreen() {
const completeOnboarding = useUserStore((s) => s.completeOnboarding)
const setSubscription = useUserStore((s) => s.setSubscription)
+ // Analytics: track time per step and total onboarding time
+ const onboardingStartTime = useRef(Date.now())
+ const stepStartTime = useRef(Date.now())
+
+ // Track onboarding_started + first step viewed on mount
+ useEffect(() => {
+ track('onboarding_started')
+ track('onboarding_step_viewed', { step: 1, step_name: STEP_NAMES[1] })
+ }, [])
+
const finishOnboarding = useCallback(
(plan: 'free' | 'premium-monthly' | 'premium-yearly') => {
+ track('onboarding_completed', {
+ plan,
+ total_time_ms: Date.now() - onboardingStartTime.current,
+ steps_completed: step,
+ })
+
completeOnboarding({
name: name.trim() || 'Athlete',
fitnessLevel: level,
@@ -841,12 +962,44 @@ export default function OnboardingScreen() {
}
router.replace('/(tabs)')
},
- [name, level, goal, frequency, barriers]
+ [name, level, goal, frequency, barriers, step]
)
const nextStep = useCallback(() => {
- setStep((s) => Math.min(s + 1, TOTAL_STEPS))
- }, [])
+ const now = Date.now()
+ const timeOnStep = now - stepStartTime.current
+
+ // Track step completed
+ track('onboarding_step_completed', {
+ step,
+ step_name: STEP_NAMES[step],
+ time_on_step_ms: timeOnStep,
+ })
+
+ // Track specific step data
+ if (step === 2) {
+ track('onboarding_barriers_selected', {
+ barriers,
+ barrier_count: barriers.length,
+ })
+ }
+ if (step === 5) {
+ track('onboarding_personalization_completed', {
+ name_provided: name.trim().length > 0,
+ level,
+ goal,
+ frequency,
+ })
+ }
+
+ const next = Math.min(step + 1, TOTAL_STEPS)
+ stepStartTime.current = now
+
+ // Track next step viewed
+ track('onboarding_step_viewed', { step: next, step_name: STEP_NAMES[next] })
+
+ setStep(next)
+ }, [step, barriers, name, level, goal, frequency])
const renderStep = () => {
switch (step) {
@@ -901,227 +1054,235 @@ export default function OnboardingScreen() {
// STYLES
// ═══════════════════════════════════════════════════════════════════════════
-const styles = StyleSheet.create({
- // Layout helpers
- screenCenter: {
- flex: 1,
- justifyContent: 'center',
- alignItems: 'center',
- },
- screenFull: {
- flex: 1,
- },
- titleCenter: {
- textAlign: 'center',
- },
- subtitle: {
- textAlign: 'center',
- },
- bottomAction: {
- position: 'absolute',
- bottom: SPACING[4],
- left: 0,
- right: 0,
- },
+function createStyles(colors: ThemeColors) {
+ return StyleSheet.create({
+ // Layout helpers
+ screenCenter: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ screenFull: {
+ flex: 1,
+ },
+ titleCenter: {
+ textAlign: 'center',
+ },
+ subtitle: {
+ textAlign: 'center',
+ },
+ bottomAction: {
+ position: 'absolute',
+ bottom: SPACING[4],
+ left: 0,
+ right: 0,
+ },
- // CTA Button
- ctaButton: {
- height: LAYOUT.BUTTON_HEIGHT,
- backgroundColor: BRAND.PRIMARY,
- borderRadius: RADIUS.GLASS_BUTTON,
- alignItems: 'center',
- justifyContent: 'center',
- },
- ctaButtonDisabled: {
- backgroundColor: DARK.ELEVATED,
- },
+ // CTA Button
+ ctaButton: {
+ height: LAYOUT.BUTTON_HEIGHT,
+ backgroundColor: BRAND.PRIMARY,
+ borderRadius: RADIUS.GLASS_BUTTON,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ ctaButtonDisabled: {
+ backgroundColor: colors.bg.elevated,
+ },
- // ── Screen 2: Barriers ──
- barrierGrid: {
- flexDirection: 'row',
- flexWrap: 'wrap',
- gap: SPACING[3],
- marginTop: SPACING[8],
- justifyContent: 'center',
- },
- barrierCard: {
- width: (SCREEN_WIDTH - LAYOUT.SCREEN_PADDING * 2 - SPACING[3]) / 2,
- paddingVertical: SPACING[6],
- alignItems: 'center',
- borderRadius: RADIUS.GLASS_CARD,
- ...GLASS.BASE,
- },
- barrierCardSelected: {
- borderColor: BRAND.PRIMARY,
- backgroundColor: 'rgba(255, 107, 53, 0.1)',
- },
+ // ── Screen 2: Barriers ──
+ barrierGrid: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: SPACING[3],
+ marginTop: SPACING[8],
+ justifyContent: 'center',
+ },
+ barrierCard: {
+ width: (SCREEN_WIDTH - LAYOUT.SCREEN_PADDING * 2 - SPACING[3]) / 2,
+ paddingVertical: SPACING[6],
+ alignItems: 'center',
+ borderRadius: RADIUS.GLASS_CARD,
+ ...colors.glass.base,
+ },
+ barrierCardSelected: {
+ borderColor: BRAND.PRIMARY,
+ backgroundColor: 'rgba(255, 107, 53, 0.1)',
+ },
- // ── Screen 3: Comparison ──
- comparisonContainer: {
- flexDirection: 'row',
- justifyContent: 'center',
- alignItems: 'flex-end',
- marginTop: SPACING[10],
- paddingHorizontal: SPACING[8],
- gap: SPACING[4],
- },
- barColumn: {
- alignItems: 'center',
- flex: 1,
- },
- barTrack: {
- width: 60,
- height: 160,
- backgroundColor: DARK.OVERLAY_1,
- borderRadius: RADIUS.SM,
- overflow: 'hidden',
- marginVertical: SPACING[3],
- justifyContent: 'flex-end',
- },
- barFill: {
- width: '100%',
- borderRadius: RADIUS.SM,
- },
- barTabata: {
- backgroundColor: BRAND.PRIMARY,
- },
- barCardio: {
- backgroundColor: PHASE.REST,
- },
- vsContainer: {
- paddingBottom: 80,
- },
- citation: {
- marginTop: SPACING[8],
- paddingHorizontal: SPACING[4],
- },
- citationText: {
- textAlign: 'center',
- fontStyle: 'italic',
- lineHeight: 20,
- },
- citationAuthor: {
- textAlign: 'center',
- marginTop: SPACING[2],
- },
+ // ── Screen 3: Comparison ──
+ comparisonContainer: {
+ flexDirection: 'row',
+ justifyContent: 'center',
+ alignItems: 'flex-end',
+ marginTop: SPACING[10],
+ paddingHorizontal: SPACING[8],
+ gap: SPACING[4],
+ },
+ barColumn: {
+ alignItems: 'center',
+ flex: 1,
+ },
+ barTrack: {
+ width: 60,
+ height: 160,
+ backgroundColor: colors.bg.overlay1,
+ borderRadius: RADIUS.SM,
+ overflow: 'hidden',
+ marginVertical: SPACING[3],
+ justifyContent: 'flex-end',
+ },
+ barFill: {
+ width: '100%',
+ borderRadius: RADIUS.SM,
+ },
+ barTabata: {
+ backgroundColor: BRAND.PRIMARY,
+ },
+ barCardio: {
+ backgroundColor: PHASE.REST,
+ },
+ vsContainer: {
+ paddingBottom: 80,
+ },
+ citation: {
+ marginTop: SPACING[8],
+ paddingHorizontal: SPACING[4],
+ },
+ citationText: {
+ textAlign: 'center',
+ fontStyle: 'italic',
+ lineHeight: 20,
+ },
+ citationAuthor: {
+ textAlign: 'center',
+ marginTop: SPACING[2],
+ },
- // ── Screen 5: Personalization ──
- personalizationContent: {
- paddingBottom: SPACING[10],
- },
- fieldGroup: {
- marginTop: SPACING[6],
- },
- fieldLabel: {
- letterSpacing: 1.5,
- marginBottom: SPACING[2],
- },
- textInput: {
- height: LAYOUT.BUTTON_HEIGHT_SM,
- backgroundColor: DARK.SURFACE,
- borderRadius: RADIUS.MD,
- paddingHorizontal: SPACING[4],
- color: TEXT.PRIMARY,
- fontSize: 17,
- borderWidth: 1,
- borderColor: BORDER.GLASS,
- },
- segmentRow: {
- flexDirection: 'row',
- backgroundColor: DARK.SURFACE,
- borderRadius: RADIUS.MD,
- padding: 3,
- gap: 2,
- },
- segmentButton: {
- flex: 1,
- height: 36,
- alignItems: 'center',
- justifyContent: 'center',
- borderRadius: RADIUS.SM,
- },
- segmentButtonActive: {
- backgroundColor: DARK.ELEVATED,
- },
- readyMessage: {
- textAlign: 'center',
- marginTop: SPACING[6],
- },
+ // ── Screen 5: Personalization ──
+ personalizationContent: {
+ paddingBottom: SPACING[10],
+ },
+ fieldGroup: {
+ marginTop: SPACING[6],
+ },
+ fieldLabel: {
+ letterSpacing: 1.5,
+ marginBottom: SPACING[2],
+ },
+ textInput: {
+ height: LAYOUT.BUTTON_HEIGHT_SM,
+ backgroundColor: colors.bg.surface,
+ borderRadius: RADIUS.MD,
+ paddingHorizontal: SPACING[4],
+ color: colors.text.primary,
+ fontSize: 17,
+ borderWidth: 1,
+ borderColor: colors.border.glass,
+ },
+ segmentRow: {
+ flexDirection: 'row',
+ backgroundColor: colors.bg.surface,
+ borderRadius: RADIUS.MD,
+ padding: 3,
+ gap: 2,
+ },
+ segmentButton: {
+ flex: 1,
+ height: 36,
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderRadius: RADIUS.SM,
+ },
+ segmentButtonActive: {
+ backgroundColor: colors.bg.elevated,
+ },
+ readyMessage: {
+ textAlign: 'center',
+ marginTop: SPACING[6],
+ },
- // ── Screen 6: Paywall ──
- paywallContent: {
- paddingBottom: SPACING[10],
- },
- featuresList: {
- marginTop: SPACING[8],
- gap: SPACING[4],
- },
- featureRow: {
- flexDirection: 'row',
- alignItems: 'center',
- },
- pricingCards: {
- flexDirection: 'row',
- gap: SPACING[3],
- marginTop: SPACING[8],
- },
- pricingCard: {
- flex: 1,
- paddingVertical: SPACING[5],
- alignItems: 'center',
- borderRadius: RADIUS.GLASS_CARD,
- ...GLASS.BASE,
- },
- pricingCardSelected: {
- borderColor: BRAND.PRIMARY,
- borderWidth: 2,
- backgroundColor: 'rgba(255, 107, 53, 0.08)',
- },
- bestValueBadge: {
- backgroundColor: BRAND.PRIMARY,
- paddingHorizontal: SPACING[3],
- paddingVertical: SPACING[1],
- borderRadius: RADIUS.SM,
- marginBottom: SPACING[2],
- },
- trialButton: {
- height: LAYOUT.BUTTON_HEIGHT,
- backgroundColor: BRAND.PRIMARY,
- borderRadius: RADIUS.GLASS_BUTTON,
- alignItems: 'center',
- justifyContent: 'center',
- marginTop: SPACING[6],
- },
- guarantees: {
- alignItems: 'center',
- marginTop: SPACING[4],
- },
- skipButton: {
- alignItems: 'center',
- paddingVertical: SPACING[5],
- marginTop: SPACING[2],
- },
-})
+ // ── Screen 6: Paywall ──
+ paywallContent: {
+ paddingBottom: SPACING[10],
+ },
+ featuresList: {
+ marginTop: SPACING[8],
+ gap: SPACING[4],
+ },
+ featureRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+ pricingCards: {
+ flexDirection: 'row',
+ gap: SPACING[3],
+ marginTop: SPACING[8],
+ },
+ pricingCard: {
+ flex: 1,
+ paddingVertical: SPACING[5],
+ alignItems: 'center',
+ borderRadius: RADIUS.GLASS_CARD,
+ ...colors.glass.base,
+ },
+ pricingCardSelected: {
+ borderColor: BRAND.PRIMARY,
+ borderWidth: 2,
+ backgroundColor: 'rgba(255, 107, 53, 0.08)',
+ },
+ bestValueBadge: {
+ backgroundColor: BRAND.PRIMARY,
+ paddingHorizontal: SPACING[3],
+ paddingVertical: SPACING[1],
+ borderRadius: RADIUS.SM,
+ marginBottom: SPACING[2],
+ },
+ trialButton: {
+ height: LAYOUT.BUTTON_HEIGHT,
+ backgroundColor: BRAND.PRIMARY,
+ borderRadius: RADIUS.GLASS_BUTTON,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginTop: SPACING[6],
+ },
+ guarantees: {
+ alignItems: 'center',
+ marginTop: SPACING[4],
+ },
+ restoreButton: {
+ alignItems: 'center',
+ paddingVertical: SPACING[3],
+ },
+ skipButton: {
+ alignItems: 'center',
+ paddingVertical: SPACING[5],
+ marginTop: SPACING[2],
+ },
+ })
+}
// ── Screen 4: Feature List Styles ──
-const wowStyles = StyleSheet.create({
- list: {
- gap: SPACING[5],
- marginTop: SPACING[4],
- },
- row: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: SPACING[4],
- },
- iconCircle: {
- width: 44,
- height: 44,
- borderRadius: 22,
- alignItems: 'center',
- justifyContent: 'center',
- },
- textCol: {
- flex: 1,
- },
-})
+function createWowStyles(colors: ThemeColors) {
+ return StyleSheet.create({
+ list: {
+ gap: SPACING[5],
+ marginTop: SPACING[4],
+ },
+ row: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: SPACING[4],
+ },
+ iconCircle: {
+ width: 44,
+ height: 44,
+ borderRadius: 22,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ textCol: {
+ flex: 1,
+ },
+ })
+}
diff --git a/app/player/[id].tsx b/app/player/[id].tsx
index 998cbe4..2a5fe94 100644
--- a/app/player/[id].tsx
+++ b/app/player/[id].tsx
@@ -2,18 +2,21 @@
* TabataFit Player Screen
* Full-screen workout player with timer overlay
* Wired to shared data + useTimer hook
+ * FORCE DARK — always uses darkColors regardless of system theme
*/
-import React, { useRef, useEffect, useCallback, useState } from 'react'
+import React, { useRef, useEffect, useCallback, useState, useMemo } from 'react'
import {
View,
Text,
StyleSheet,
Pressable,
Animated,
+ Easing,
Dimensions,
StatusBar,
} from 'react-native'
+import Svg, { Circle } from 'react-native-svg'
import { useRouter, useLocalSearchParams } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { LinearGradient } from 'expo-linear-gradient'
@@ -21,25 +24,22 @@ import { BlurView } from 'expo-blur'
import { useKeepAwake } from 'expo-keep-awake'
import Ionicons from '@expo/vector-icons/Ionicons'
+import { useTranslation } from 'react-i18next'
+
import { useTimer } from '@/src/shared/hooks/useTimer'
import { useHaptics } from '@/src/shared/hooks/useHaptics'
import { useAudio } from '@/src/shared/hooks/useAudio'
import { useActivityStore } from '@/src/shared/stores'
-import { getWorkoutById, getTrainerById } from '@/src/shared/data'
+import { getWorkoutById } from '@/src/shared/data'
+import { useTranslatedWorkout } from '@/src/shared/data/useTranslatedData'
-import {
- BRAND,
- DARK,
- TEXT,
- GLASS,
- SHADOW,
- PHASE_COLORS,
- GRADIENTS,
-} from '@/src/shared/constants/colors'
+import { track } from '@/src/shared/services/analytics'
+import { BRAND, PHASE_COLORS, GRADIENTS, darkColors } from '@/src/shared/theme'
+import type { ThemeColors } from '@/src/shared/theme/types'
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
import { SPACING } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
-import { DURATION, EASE, SPRING } from '@/src/shared/constants/animations'
+import { SPRING } from '@/src/shared/constants/animations'
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window')
@@ -49,6 +49,8 @@ const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window')
type TimerPhase = 'PREP' | 'WORK' | 'REST' | 'COMPLETE'
+const AnimatedCircle = Animated.createAnimatedComponent(Circle)
+
function TimerRing({
progress,
phase,
@@ -58,48 +60,78 @@ function TimerRing({
phase: TimerPhase
size?: number
}) {
+ const colors = darkColors
const strokeWidth = 12
+ const radius = (size - strokeWidth) / 2
+ const circumference = 2 * Math.PI * radius
const phaseColor = PHASE_COLORS[phase].fill
+ const animatedProgress = useRef(new Animated.Value(0)).current
+ const prevProgress = useRef(0)
+
+ useEffect(() => {
+ // If progress jumped backwards (new phase started), snap instantly
+ if (progress < prevProgress.current - 0.05) {
+ animatedProgress.setValue(progress)
+ } else {
+ Animated.timing(animatedProgress, {
+ toValue: progress,
+ duration: 1000,
+ easing: Easing.linear,
+ useNativeDriver: false,
+ }).start()
+ }
+ prevProgress.current = progress
+ }, [progress])
+
+ const strokeDashoffset = animatedProgress.interpolate({
+ inputRange: [0, 1],
+ outputRange: [circumference, 0],
+ })
+
+ const timerStyles = useMemo(() => createTimerStyles(colors), [colors])
+
return (
-
-
-
+ {/* Background track */}
+
-
+ {/* Progress arc */}
+
+
)
}
function PhaseIndicator({ phase }: { phase: TimerPhase }) {
+ const { t } = useTranslation()
+ const colors = darkColors
+ const timerStyles = useMemo(() => createTimerStyles(colors), [colors])
const phaseColor = PHASE_COLORS[phase].fill
const phaseLabels: Record = {
- PREP: 'GET READY',
- WORK: 'WORK',
- REST: 'REST',
- COMPLETE: 'COMPLETE',
+ PREP: t('screens:player.phases.prep'),
+ WORK: t('screens:player.phases.work'),
+ REST: t('screens:player.phases.rest'),
+ COMPLETE: t('screens:player.phases.complete'),
}
return (
@@ -116,13 +148,16 @@ function ExerciseDisplay({
exercise: string
nextExercise?: string
}) {
+ const { t } = useTranslation()
+ const colors = darkColors
+ const timerStyles = useMemo(() => createTimerStyles(colors), [colors])
return (
- Current
+ {t('screens:player.current')}
{exercise}
{nextExercise && (
- Next:
+ {t('screens:player.next')}
{nextExercise}
)}
@@ -131,10 +166,13 @@ function ExerciseDisplay({
}
function RoundIndicator({ current, total }: { current: number; total: number }) {
+ const { t } = useTranslation()
+ const colors = darkColors
+ const timerStyles = useMemo(() => createTimerStyles(colors), [colors])
return (
- Round {current}/{total}
+ {t('screens:player.round')} {current}/{total}
)
@@ -151,6 +189,8 @@ function ControlButton({
size?: number
variant?: 'primary' | 'secondary' | 'danger'
}) {
+ const colors = darkColors
+ const timerStyles = useMemo(() => createTimerStyles(colors), [colors])
const scaleAnim = useRef(new Animated.Value(1)).current
const handlePressIn = () => {
@@ -174,7 +214,7 @@ function ControlButton({
? BRAND.PRIMARY
: variant === 'danger'
? '#FF3B30'
- : 'rgba(255, 255, 255, 0.1)'
+ : colors.border.glass
return (
@@ -185,7 +225,7 @@ function ControlButton({
style={[timerStyles.controlButton, { width: size, height: size, borderRadius: size / 2 }]}
>
-
+
)
@@ -198,19 +238,22 @@ function BurnBar({
currentCalories: number
avgCalories: number
}) {
+ const { t } = useTranslation()
+ const colors = darkColors
+ const timerStyles = useMemo(() => createTimerStyles(colors), [colors])
const percentage = Math.min((currentCalories / avgCalories) * 100, 100)
return (
- Burn Bar
- {currentCalories} cal
+ {t('screens:player.burnBar')}
+ {t('units.calUnit', { count: currentCalories })}
- Community avg: {avgCalories} cal
+ {t('screens:player.communityAvg', { calories: avgCalories })}
)
}
@@ -225,44 +268,57 @@ export default function PlayerScreen() {
const { id } = useLocalSearchParams<{ id: string }>()
const insets = useSafeAreaInsets()
const haptics = useHaptics()
+ const { t } = useTranslation()
const addWorkoutResult = useActivityStore((s) => s.addWorkoutResult)
- const workout = getWorkoutById(id ?? '1')
- const trainer = workout ? getTrainerById(workout.trainerId) : null
+ const colors = darkColors
+ const styles = useMemo(() => createStyles(colors), [colors])
+ const timerStyles = useMemo(() => createTimerStyles(colors), [colors])
- const timer = useTimer(workout ?? null)
+ const rawWorkout = getWorkoutById(id ?? '1')
+ const workout = useTranslatedWorkout(rawWorkout)
+ const timer = useTimer(rawWorkout ?? null)
const audio = useAudio()
const [showControls, setShowControls] = useState(true)
// Animation refs
const timerScaleAnim = useRef(new Animated.Value(0.8)).current
- const glowAnim = useRef(new Animated.Value(0)).current
-
const phaseColor = PHASE_COLORS[timer.phase].fill
// Format time
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
- return mins > 0 ? `${mins}:${secs.toString().padStart(2, '0')}` : `${secs}`
+ return `${mins}:${secs.toString().padStart(2, '0')}`
}
// Start timer
const startTimer = useCallback(() => {
timer.start()
haptics.buttonTap()
- }, [timer, haptics])
+ if (workout) {
+ track('workout_started', {
+ workout_id: workout.id,
+ workout_title: workout.title,
+ duration: workout.duration,
+ level: workout.level,
+ })
+ }
+ }, [timer, haptics, workout])
// Pause/Resume
const togglePause = useCallback(() => {
+ const workoutId = workout?.id ?? id ?? ''
if (timer.isPaused) {
timer.resume()
+ track('workout_resumed', { workout_id: workoutId })
} else {
timer.pause()
+ track('workout_paused', { workout_id: workoutId })
}
haptics.selection()
- }, [timer, haptics])
+ }, [timer, haptics, workout, id])
// Stop workout
const stopWorkout = useCallback(() => {
@@ -274,6 +330,15 @@ export default function PlayerScreen() {
// Complete workout - go to celebration screen
const completeWorkout = useCallback(() => {
haptics.workoutComplete()
+ if (workout) {
+ track('workout_completed', {
+ workout_id: workout.id,
+ workout_title: workout.title,
+ calories: timer.calories,
+ duration: workout.duration,
+ rounds: workout.rounds,
+ })
+ }
if (workout) {
addWorkoutResult({
id: Date.now().toString(),
@@ -301,30 +366,12 @@ export default function PlayerScreen() {
// Entrance animation
useEffect(() => {
- Animated.parallel([
- Animated.spring(timerScaleAnim, {
- toValue: 1,
- friction: 6,
- tension: 100,
- useNativeDriver: true,
- }),
- Animated.loop(
- Animated.sequence([
- Animated.timing(glowAnim, {
- toValue: 1,
- duration: DURATION.BREATH,
- easing: EASE.EASE_IN_OUT,
- useNativeDriver: false,
- }),
- Animated.timing(glowAnim, {
- toValue: 0,
- duration: DURATION.BREATH,
- easing: EASE.EASE_IN_OUT,
- useNativeDriver: false,
- }),
- ])
- ),
- ]).start()
+ Animated.spring(timerScaleAnim, {
+ toValue: 1,
+ friction: 6,
+ tension: 100,
+ useNativeDriver: true,
+ }).start()
}, [])
// Phase change animation + audio
@@ -351,28 +398,18 @@ export default function PlayerScreen() {
}
}, [timer.timeRemaining])
- const glowOpacity = glowAnim.interpolate({
- inputRange: [0, 1],
- outputRange: [0.3, 0.6],
- })
-
return (
{/* Background gradient */}
- {/* Phase glow */}
-
+ {/* Phase background color */}
+
{/* Main content */}
@@ -380,12 +417,12 @@ export default function PlayerScreen() {
{showControls && (
-
-
+
+
{workout?.title ?? 'Workout'}
- with {trainer?.name ?? 'Coach'}
+ {t('durationLevel', { duration: workout?.duration ?? 0, level: t(`levels.${(workout?.level ?? 'beginner').toLowerCase()}`) })}
@@ -418,20 +455,20 @@ export default function PlayerScreen() {
{/* Complete state */}
{timer.isComplete && (
- Workout Complete!
- Great job!
+ {t('screens:player.workoutComplete')}
+ {t('screens:player.greatJob')}
{timer.totalRounds}
- Rounds
+ {t('screens:player.rounds')}
{timer.calories}
- Calories
+ {t('screens:player.calories')}
{workout?.duration ?? 4}
- Minutes
+ {t('screens:player.minutes')}
@@ -484,7 +521,7 @@ export default function PlayerScreen() {
{/* Burn Bar */}
{showControls && timer.isRunning && !timer.isComplete && (
-
+
)}
@@ -497,260 +534,250 @@ export default function PlayerScreen() {
// STYLES
// ═══════════════════════════════════════════════════════════════════════════
-const timerStyles = StyleSheet.create({
- timerRingContainer: {
- alignItems: 'center',
- justifyContent: 'center',
- },
- timerRingBg: {
- borderColor: 'rgba(255, 255, 255, 0.1)',
- position: 'absolute',
- },
- timerRingContent: {
- position: 'absolute',
- },
- timerProgressRing: {
- position: 'absolute',
- },
- timerTextContainer: {
- position: 'absolute',
- alignItems: 'center',
- },
- phaseIndicator: {
- paddingHorizontal: SPACING[4],
- paddingVertical: SPACING[1],
- borderRadius: RADIUS.FULL,
- marginBottom: SPACING[2],
- },
- phaseText: {
- ...TYPOGRAPHY.CALLOUT,
- fontWeight: '700',
- letterSpacing: 1,
- },
- timerTime: {
- ...TYPOGRAPHY.TIMER_NUMBER,
- color: TEXT.PRIMARY,
- },
- roundIndicator: {
- marginTop: SPACING[2],
- },
- roundText: {
- ...TYPOGRAPHY.BODY,
- color: TEXT.TERTIARY,
- },
- roundCurrent: {
- color: TEXT.PRIMARY,
- fontWeight: '700',
- },
+function createTimerStyles(colors: ThemeColors) {
+ return StyleSheet.create({
+ timerRingContainer: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ timerTextContainer: {
+ position: 'absolute',
+ alignItems: 'center',
+ },
+ phaseIndicator: {
+ paddingHorizontal: SPACING[4],
+ paddingVertical: SPACING[1],
+ borderRadius: RADIUS.FULL,
+ marginBottom: SPACING[2],
+ },
+ phaseText: {
+ ...TYPOGRAPHY.CALLOUT,
+ fontWeight: '700',
+ letterSpacing: 1,
+ },
+ timerTime: {
+ ...TYPOGRAPHY.TIMER_NUMBER,
+ color: colors.text.primary,
+ },
+ roundIndicator: {
+ marginTop: SPACING[2],
+ },
+ roundText: {
+ ...TYPOGRAPHY.BODY,
+ color: colors.text.tertiary,
+ },
+ roundCurrent: {
+ color: colors.text.primary,
+ fontWeight: '700',
+ },
- // Exercise
- exerciseDisplay: {
- alignItems: 'center',
- marginTop: SPACING[6],
- paddingHorizontal: SPACING[6],
- },
- currentExerciseLabel: {
- ...TYPOGRAPHY.CAPTION_1,
- color: TEXT.TERTIARY,
- textTransform: 'uppercase',
- letterSpacing: 1,
- },
- currentExercise: {
- ...TYPOGRAPHY.TITLE_1,
- color: TEXT.PRIMARY,
- textAlign: 'center',
- marginTop: SPACING[1],
- },
- nextExerciseContainer: {
- flexDirection: 'row',
- marginTop: SPACING[2],
- },
- nextExerciseLabel: {
- ...TYPOGRAPHY.BODY,
- color: TEXT.TERTIARY,
- },
- nextExercise: {
- ...TYPOGRAPHY.BODY,
- color: BRAND.PRIMARY,
- },
+ // Exercise
+ exerciseDisplay: {
+ alignItems: 'center',
+ marginTop: SPACING[6],
+ paddingHorizontal: SPACING[6],
+ },
+ currentExerciseLabel: {
+ ...TYPOGRAPHY.CAPTION_1,
+ color: colors.text.tertiary,
+ textTransform: 'uppercase',
+ letterSpacing: 1,
+ },
+ currentExercise: {
+ ...TYPOGRAPHY.TITLE_1,
+ color: colors.text.primary,
+ textAlign: 'center',
+ marginTop: SPACING[1],
+ },
+ nextExerciseContainer: {
+ flexDirection: 'row',
+ marginTop: SPACING[2],
+ },
+ nextExerciseLabel: {
+ ...TYPOGRAPHY.BODY,
+ color: colors.text.tertiary,
+ },
+ nextExercise: {
+ ...TYPOGRAPHY.BODY,
+ color: BRAND.PRIMARY,
+ },
- // Controls
- controlButton: {
- alignItems: 'center',
- justifyContent: 'center',
- },
- controlButtonBg: {
- position: 'absolute',
- width: '100%',
- height: '100%',
- borderRadius: 100,
- },
+ // Controls
+ controlButton: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ controlButtonBg: {
+ position: 'absolute',
+ width: '100%',
+ height: '100%',
+ borderRadius: 100,
+ },
- // Burn Bar
- burnBar: {},
- burnBarHeader: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- marginBottom: SPACING[2],
- },
- burnBarLabel: {
- ...TYPOGRAPHY.CAPTION_1,
- color: TEXT.TERTIARY,
- },
- burnBarValue: {
- ...TYPOGRAPHY.CALLOUT,
- color: BRAND.PRIMARY,
- fontWeight: '600',
- },
- burnBarTrack: {
- height: 6,
- backgroundColor: 'rgba(255, 255, 255, 0.1)',
- borderRadius: 3,
- overflow: 'hidden',
- },
- burnBarFill: {
- height: '100%',
- backgroundColor: BRAND.PRIMARY,
- borderRadius: 3,
- },
- burnBarAvg: {
- position: 'absolute',
- top: -2,
- width: 2,
- height: 10,
- backgroundColor: TEXT.TERTIARY,
- },
- burnBarAvgLabel: {
- ...TYPOGRAPHY.CAPTION_2,
- color: TEXT.TERTIARY,
- marginTop: SPACING[1],
- textAlign: 'right',
- },
-})
+ // Burn Bar
+ burnBar: {},
+ burnBarHeader: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ marginBottom: SPACING[2],
+ },
+ burnBarLabel: {
+ ...TYPOGRAPHY.CAPTION_1,
+ color: colors.text.tertiary,
+ },
+ burnBarValue: {
+ ...TYPOGRAPHY.CALLOUT,
+ color: BRAND.PRIMARY,
+ fontWeight: '600',
+ },
+ burnBarTrack: {
+ height: 6,
+ backgroundColor: colors.border.glass,
+ borderRadius: 3,
+ overflow: 'hidden',
+ },
+ burnBarFill: {
+ height: '100%',
+ backgroundColor: BRAND.PRIMARY,
+ borderRadius: 3,
+ },
+ burnBarAvg: {
+ position: 'absolute',
+ top: -2,
+ width: 2,
+ height: 10,
+ backgroundColor: colors.text.tertiary,
+ },
+ burnBarAvgLabel: {
+ ...TYPOGRAPHY.CAPTION_2,
+ color: colors.text.tertiary,
+ marginTop: SPACING[1],
+ textAlign: 'right',
+ },
+ })
+}
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: DARK.BASE,
- },
- phaseGlow: {
- position: 'absolute',
- top: -100,
- left: -100,
- right: -100,
- bottom: -100,
- borderRadius: 500,
- },
- content: {
- flex: 1,
- },
+function createStyles(colors: ThemeColors) {
+ return StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: colors.bg.base,
+ },
+ phaseBackground: {
+ ...StyleSheet.absoluteFillObject,
+ opacity: 0.15,
+ },
+ content: {
+ flex: 1,
+ },
- // Header
- header: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- paddingHorizontal: SPACING[4],
- },
- closeButton: {
- width: 44,
- height: 44,
- borderRadius: 22,
- alignItems: 'center',
- justifyContent: 'center',
- overflow: 'hidden',
- borderWidth: 1,
- borderColor: 'rgba(255, 255, 255, 0.1)',
- },
- headerCenter: {
- alignItems: 'center',
- },
- workoutTitle: {
- ...TYPOGRAPHY.HEADLINE,
- color: TEXT.PRIMARY,
- },
- workoutTrainer: {
- ...TYPOGRAPHY.CAPTION_1,
- color: TEXT.TERTIARY,
- },
+ // Header
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingHorizontal: SPACING[4],
+ },
+ closeButton: {
+ width: 44,
+ height: 44,
+ borderRadius: 22,
+ alignItems: 'center',
+ justifyContent: 'center',
+ overflow: 'hidden',
+ borderWidth: 1,
+ borderColor: colors.border.glass,
+ },
+ headerCenter: {
+ alignItems: 'center',
+ },
+ workoutTitle: {
+ ...TYPOGRAPHY.HEADLINE,
+ color: colors.text.primary,
+ },
+ workoutTrainer: {
+ ...TYPOGRAPHY.CAPTION_1,
+ color: colors.text.tertiary,
+ },
- // Timer
- timerContainer: {
- alignItems: 'center',
- justifyContent: 'center',
- marginTop: SPACING[8],
- },
+ // Timer
+ timerContainer: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginTop: SPACING[8],
+ },
- // Controls
- controls: {
- position: 'absolute',
- bottom: 0,
- left: 0,
- right: 0,
- alignItems: 'center',
- },
- controlsRow: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: SPACING[6],
- },
+ // Controls
+ controls: {
+ position: 'absolute',
+ bottom: 0,
+ left: 0,
+ right: 0,
+ alignItems: 'center',
+ },
+ controlsRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: SPACING[6],
+ },
- // Burn Bar
- burnBarContainer: {
- position: 'absolute',
- left: SPACING[4],
- right: SPACING[4],
- height: 72,
- borderRadius: RADIUS.LG,
- overflow: 'hidden',
- borderWidth: 1,
- borderColor: 'rgba(255, 255, 255, 0.1)',
- padding: SPACING[3],
- },
+ // Burn Bar
+ burnBarContainer: {
+ position: 'absolute',
+ left: SPACING[4],
+ right: SPACING[4],
+ height: 72,
+ borderRadius: RADIUS.LG,
+ overflow: 'hidden',
+ borderWidth: 1,
+ borderColor: colors.border.glass,
+ padding: SPACING[3],
+ },
- // Complete
- completeContainer: {
- alignItems: 'center',
- marginTop: SPACING[8],
- },
- completeTitle: {
- ...TYPOGRAPHY.LARGE_TITLE,
- color: TEXT.PRIMARY,
- },
- completeSubtitle: {
- ...TYPOGRAPHY.TITLE_3,
- color: BRAND.PRIMARY,
- marginTop: SPACING[1],
- },
- completeStats: {
- flexDirection: 'row',
- marginTop: SPACING[6],
- gap: SPACING[8],
- },
- completeStat: {
- alignItems: 'center',
- },
- completeStatValue: {
- ...TYPOGRAPHY.TITLE_1,
- color: TEXT.PRIMARY,
- },
- completeStatLabel: {
- ...TYPOGRAPHY.CAPTION_1,
- color: TEXT.TERTIARY,
- marginTop: SPACING[1],
- },
- doneButton: {
- width: 200,
- height: 56,
- borderRadius: RADIUS.GLASS_BUTTON,
- alignItems: 'center',
- justifyContent: 'center',
- overflow: 'hidden',
- ...SHADOW.BRAND_GLOW,
- },
- doneButtonText: {
- ...TYPOGRAPHY.BUTTON_MEDIUM,
- color: TEXT.PRIMARY,
- letterSpacing: 1,
- },
-})
+ // Complete
+ completeContainer: {
+ alignItems: 'center',
+ marginTop: SPACING[8],
+ },
+ completeTitle: {
+ ...TYPOGRAPHY.LARGE_TITLE,
+ color: colors.text.primary,
+ },
+ completeSubtitle: {
+ ...TYPOGRAPHY.TITLE_3,
+ color: BRAND.PRIMARY,
+ marginTop: SPACING[1],
+ },
+ completeStats: {
+ flexDirection: 'row',
+ marginTop: SPACING[6],
+ gap: SPACING[8],
+ },
+ completeStat: {
+ alignItems: 'center',
+ },
+ completeStatValue: {
+ ...TYPOGRAPHY.TITLE_1,
+ color: colors.text.primary,
+ },
+ completeStatLabel: {
+ ...TYPOGRAPHY.CAPTION_1,
+ color: colors.text.tertiary,
+ marginTop: SPACING[1],
+ },
+ doneButton: {
+ width: 200,
+ height: 56,
+ borderRadius: RADIUS.GLASS_BUTTON,
+ alignItems: 'center',
+ justifyContent: 'center',
+ overflow: 'hidden',
+ ...colors.shadow.BRAND_GLOW,
+ },
+ doneButtonText: {
+ ...TYPOGRAPHY.BUTTON_MEDIUM,
+ color: colors.text.primary,
+ letterSpacing: 1,
+ },
+ })
+}
diff --git a/app/workout/[id].tsx b/app/workout/[id].tsx
index 05a4dd4..cebb4d0 100644
--- a/app/workout/[id].tsx
+++ b/app/workout/[id].tsx
@@ -3,25 +3,23 @@
* Dynamic data via route params
*/
-import { useState } from 'react'
+import { useState, useEffect, useMemo } from 'react'
import { View, Text as RNText, 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 { BlurView } from 'expo-blur'
import Ionicons from '@expo/vector-icons/Ionicons'
+import { useTranslation } from 'react-i18next'
import { useHaptics } from '@/src/shared/hooks'
-import { getWorkoutById, getTrainerById } from '@/src/shared/data'
+import { track } from '@/src/shared/services/analytics'
+import { getWorkoutById } from '@/src/shared/data'
+import { useTranslatedWorkout, useMusicVibeLabel } from '@/src/shared/data/useTranslatedData'
import { VideoPlayer } from '@/src/shared/components/VideoPlayer'
-import {
- BRAND,
- DARK,
- TEXT,
- GLASS,
- SHADOW,
-} from '@/src/shared/constants/colors'
+import { useThemeColors, BRAND } from '@/src/shared/theme'
+import type { ThemeColors } from '@/src/shared/theme/types'
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
@@ -34,20 +32,36 @@ export default function WorkoutDetailScreen() {
const insets = useSafeAreaInsets()
const router = useRouter()
const haptics = useHaptics()
+ const { t } = useTranslation()
const { id } = useLocalSearchParams<{ id: string }>()
const [isSaved, setIsSaved] = useState(false)
- const workout = getWorkoutById(id ?? '1')
+ const colors = useThemeColors()
+ const styles = useMemo(() => createStyles(colors), [colors])
+
+ const rawWorkout = getWorkoutById(id ?? '1')
+ const workout = useTranslatedWorkout(rawWorkout)
+ const musicVibeLabel = useMusicVibeLabel(rawWorkout?.musicVibe ?? '')
+
+ useEffect(() => {
+ if (workout) {
+ track('workout_detail_viewed', {
+ workout_id: workout.id,
+ workout_title: workout.title,
+ level: workout.level,
+ duration: workout.duration,
+ })
+ }
+ }, [workout?.id])
+
if (!workout) {
return (
- Workout not found
+ {t('screens:workout.notFound')}
)
}
- const trainer = getTrainerById(workout.trainerId)
-
const handleStartWorkout = () => {
haptics.phaseChange()
router.push(`/player/${workout.id}`)
@@ -76,7 +90,7 @@ export default function WorkoutDetailScreen() {
@@ -85,33 +99,33 @@ export default function WorkoutDetailScreen() {
style={StyleSheet.absoluteFill}
/>
- {/* Header overlay */}
+ {/* Header overlay — on video, keep white */}
-
-
+
+
-
+
-
-
+
+
- {/* Trainer preview */}
+ {/* Workout icon — on brand bg, keep white */}
-
- {trainer?.name[0] ?? 'T'}
+
+
@@ -122,24 +136,19 @@ export default function WorkoutDetailScreen() {
{/* Quick stats */}
-
-
- {trainer?.name ?? ''}
-
- •
- {workout.level}
+ {t(`levels.${workout.level.toLowerCase()}`)}
•
- {workout.duration} min
+ {t('units.minUnit', { count: workout.duration })}
•
- {workout.calories} cal
+ {t('units.calUnit', { count: workout.calories })}
@@ -148,7 +157,7 @@ export default function WorkoutDetailScreen() {
{/* Equipment */}
- What You'll Need
+ {t('screens:workout.whatYoullNeed')}
{workout.equipment.map((item, index) => (
@@ -161,7 +170,7 @@ export default function WorkoutDetailScreen() {
{/* Exercises */}
- Exercises ({workout.rounds} rounds)
+ {t('screens:workout.exercises', { count: workout.rounds })}
{workout.exercises.map((exercise, index) => (
@@ -173,8 +182,8 @@ export default function WorkoutDetailScreen() {
))}
-
- Repeat × {repeatCount} rounds
+
+ {t('screens:workout.repeatRounds', { count: repeatCount })}
@@ -183,14 +192,14 @@ export default function WorkoutDetailScreen() {
{/* Music */}
- Music
+ {t('screens:workout.music')}
- {workout.musicVibe.charAt(0).toUpperCase() + workout.musicVibe.slice(1)} Mix
- Curated for your workout
+ {t('screens:workout.musicMix', { vibe: musicVibeLabel })}
+ {t('screens:workout.curatedForWorkout')}
@@ -198,7 +207,7 @@ export default function WorkoutDetailScreen() {
{/* Fixed Start Button */}
-
+
[
@@ -207,7 +216,7 @@ export default function WorkoutDetailScreen() {
]}
onPress={handleStartWorkout}
>
- START WORKOUT
+ {t('screens:workout.startWorkout')}
@@ -219,231 +228,233 @@ export default function WorkoutDetailScreen() {
// STYLES
// ═══════════════════════════════════════════════════════════════════════════
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: DARK.BASE,
- },
- scrollView: {
- flex: 1,
- },
- scrollContent: {
- paddingHorizontal: LAYOUT.SCREEN_PADDING,
- },
+function createStyles(colors: ThemeColors) {
+ return StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: colors.bg.base,
+ },
+ scrollView: {
+ flex: 1,
+ },
+ scrollContent: {
+ paddingHorizontal: LAYOUT.SCREEN_PADDING,
+ },
- // Video Preview
- videoPreview: {
- height: 280,
- marginHorizontal: -LAYOUT.SCREEN_PADDING,
- marginBottom: SPACING[4],
- backgroundColor: DARK.SURFACE,
- },
- headerOverlay: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- alignItems: 'center',
- padding: SPACING[4],
- },
- headerButton: {
- width: 44,
- height: 44,
- borderRadius: 22,
- alignItems: 'center',
- justifyContent: 'center',
- overflow: 'hidden',
- borderWidth: 1,
- borderColor: 'rgba(255, 255, 255, 0.1)',
- },
- headerRight: {
- flexDirection: 'row',
- gap: SPACING[2],
- },
- trainerPreview: {
- position: 'absolute',
- bottom: SPACING[4],
- left: 0,
- right: 0,
- alignItems: 'center',
- },
- trainerAvatarLarge: {
- width: 80,
- height: 80,
- borderRadius: 40,
- alignItems: 'center',
- justifyContent: 'center',
- borderWidth: 3,
- borderColor: TEXT.PRIMARY,
- },
- trainerInitial: {
- ...TYPOGRAPHY.HERO,
- color: TEXT.PRIMARY,
- },
+ // Video Preview
+ videoPreview: {
+ height: 280,
+ marginHorizontal: -LAYOUT.SCREEN_PADDING,
+ marginBottom: SPACING[4],
+ backgroundColor: colors.bg.surface,
+ },
+ headerOverlay: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ padding: SPACING[4],
+ },
+ headerButton: {
+ width: 44,
+ height: 44,
+ borderRadius: 22,
+ alignItems: 'center',
+ justifyContent: 'center',
+ overflow: 'hidden',
+ borderWidth: 1,
+ borderColor: 'rgba(255, 255, 255, 0.1)',
+ },
+ headerRight: {
+ flexDirection: 'row',
+ gap: SPACING[2],
+ },
+ trainerPreview: {
+ position: 'absolute',
+ bottom: SPACING[4],
+ left: 0,
+ right: 0,
+ alignItems: 'center',
+ },
+ trainerAvatarLarge: {
+ width: 80,
+ height: 80,
+ borderRadius: 40,
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderWidth: 3,
+ borderColor: '#FFFFFF',
+ },
+ trainerInitial: {
+ ...TYPOGRAPHY.HERO,
+ color: '#FFFFFF',
+ },
- // Title Section
- titleSection: {
- marginBottom: SPACING[4],
- },
- title: {
- ...TYPOGRAPHY.LARGE_TITLE,
- color: TEXT.PRIMARY,
- marginBottom: SPACING[3],
- },
- quickStats: {
- flexDirection: 'row',
- alignItems: 'center',
- flexWrap: 'wrap',
- gap: SPACING[2],
- },
- statItem: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: SPACING[1],
- },
- statText: {
- ...TYPOGRAPHY.BODY,
- color: TEXT.SECONDARY,
- },
- statDot: {
- color: TEXT.TERTIARY,
- },
+ // Title Section
+ titleSection: {
+ marginBottom: SPACING[4],
+ },
+ title: {
+ ...TYPOGRAPHY.LARGE_TITLE,
+ color: colors.text.primary,
+ marginBottom: SPACING[3],
+ },
+ quickStats: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ flexWrap: 'wrap',
+ gap: SPACING[2],
+ },
+ statItem: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: SPACING[1],
+ },
+ statText: {
+ ...TYPOGRAPHY.BODY,
+ color: colors.text.secondary,
+ },
+ statDot: {
+ color: colors.text.tertiary,
+ },
- // Divider
- divider: {
- height: 1,
- backgroundColor: 'rgba(255, 255, 255, 0.1)',
- marginVertical: SPACING[2],
- },
+ // Divider
+ divider: {
+ height: 1,
+ backgroundColor: colors.border.glass,
+ marginVertical: SPACING[2],
+ },
- // Section
- section: {
- paddingVertical: SPACING[4],
- },
- sectionTitle: {
- ...TYPOGRAPHY.HEADLINE,
- color: TEXT.PRIMARY,
- marginBottom: SPACING[3],
- },
+ // Section
+ section: {
+ paddingVertical: SPACING[4],
+ },
+ sectionTitle: {
+ ...TYPOGRAPHY.HEADLINE,
+ color: colors.text.primary,
+ marginBottom: SPACING[3],
+ },
- // Equipment
- equipmentItem: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: SPACING[3],
- marginBottom: SPACING[2],
- },
- equipmentText: {
- ...TYPOGRAPHY.BODY,
- color: TEXT.SECONDARY,
- },
+ // Equipment
+ equipmentItem: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: SPACING[3],
+ marginBottom: SPACING[2],
+ },
+ equipmentText: {
+ ...TYPOGRAPHY.BODY,
+ color: colors.text.secondary,
+ },
- // Exercises
- exercisesList: {
- gap: SPACING[2],
- },
- exerciseRow: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingVertical: SPACING[3],
- paddingHorizontal: SPACING[4],
- backgroundColor: DARK.SURFACE,
- borderRadius: RADIUS.LG,
- gap: SPACING[3],
- },
- exerciseNumber: {
- width: 28,
- height: 28,
- borderRadius: 14,
- backgroundColor: 'rgba(255, 107, 53, 0.15)',
- alignItems: 'center',
- justifyContent: 'center',
- },
- exerciseNumberText: {
- ...TYPOGRAPHY.CALLOUT,
- color: BRAND.PRIMARY,
- fontWeight: '700',
- },
- exerciseName: {
- ...TYPOGRAPHY.BODY,
- color: TEXT.PRIMARY,
- flex: 1,
- },
- exerciseDuration: {
- ...TYPOGRAPHY.CAPTION_1,
- color: TEXT.TERTIARY,
- },
- repeatNote: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: SPACING[2],
- marginTop: SPACING[2],
- paddingHorizontal: SPACING[2],
- },
- repeatText: {
- ...TYPOGRAPHY.CAPTION_1,
- color: TEXT.TERTIARY,
- },
+ // Exercises
+ exercisesList: {
+ gap: SPACING[2],
+ },
+ exerciseRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingVertical: SPACING[3],
+ paddingHorizontal: SPACING[4],
+ backgroundColor: colors.bg.surface,
+ borderRadius: RADIUS.LG,
+ gap: SPACING[3],
+ },
+ exerciseNumber: {
+ width: 28,
+ height: 28,
+ borderRadius: 14,
+ backgroundColor: 'rgba(255, 107, 53, 0.15)',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ exerciseNumberText: {
+ ...TYPOGRAPHY.CALLOUT,
+ color: BRAND.PRIMARY,
+ fontWeight: '700',
+ },
+ exerciseName: {
+ ...TYPOGRAPHY.BODY,
+ color: colors.text.primary,
+ flex: 1,
+ },
+ exerciseDuration: {
+ ...TYPOGRAPHY.CAPTION_1,
+ color: colors.text.tertiary,
+ },
+ repeatNote: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: SPACING[2],
+ marginTop: SPACING[2],
+ paddingHorizontal: SPACING[2],
+ },
+ repeatText: {
+ ...TYPOGRAPHY.CAPTION_1,
+ color: colors.text.tertiary,
+ },
- // Music
- musicCard: {
- flexDirection: 'row',
- alignItems: 'center',
- padding: SPACING[4],
- backgroundColor: DARK.SURFACE,
- borderRadius: RADIUS.LG,
- gap: SPACING[3],
- },
- musicIcon: {
- width: 48,
- height: 48,
- borderRadius: 24,
- backgroundColor: 'rgba(255, 107, 53, 0.15)',
- alignItems: 'center',
- justifyContent: 'center',
- },
- musicInfo: {
- flex: 1,
- },
- musicName: {
- ...TYPOGRAPHY.HEADLINE,
- color: TEXT.PRIMARY,
- },
- musicDescription: {
- ...TYPOGRAPHY.CAPTION_1,
- color: TEXT.TERTIARY,
- marginTop: 2,
- },
+ // Music
+ musicCard: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ padding: SPACING[4],
+ backgroundColor: colors.bg.surface,
+ borderRadius: RADIUS.LG,
+ gap: SPACING[3],
+ },
+ musicIcon: {
+ width: 48,
+ height: 48,
+ borderRadius: 24,
+ backgroundColor: 'rgba(255, 107, 53, 0.15)',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ musicInfo: {
+ flex: 1,
+ },
+ musicName: {
+ ...TYPOGRAPHY.HEADLINE,
+ color: colors.text.primary,
+ },
+ musicDescription: {
+ ...TYPOGRAPHY.CAPTION_1,
+ color: colors.text.tertiary,
+ marginTop: 2,
+ },
- // Bottom Bar
- bottomBar: {
- position: 'absolute',
- bottom: 0,
- left: 0,
- right: 0,
- paddingHorizontal: LAYOUT.SCREEN_PADDING,
- paddingTop: SPACING[4],
- borderTopWidth: 1,
- borderTopColor: 'rgba(255, 255, 255, 0.1)',
- },
- startButtonContainer: {
- height: 56,
- justifyContent: 'center',
- },
+ // Bottom Bar
+ bottomBar: {
+ position: 'absolute',
+ bottom: 0,
+ left: 0,
+ right: 0,
+ paddingHorizontal: LAYOUT.SCREEN_PADDING,
+ paddingTop: SPACING[4],
+ borderTopWidth: 1,
+ borderTopColor: colors.border.glass,
+ },
+ startButtonContainer: {
+ height: 56,
+ justifyContent: 'center',
+ },
- // Start Button
- startButton: {
- height: 56,
- borderRadius: RADIUS.LG,
- backgroundColor: BRAND.PRIMARY,
- alignItems: 'center',
- justifyContent: 'center',
- },
- startButtonPressed: {
- backgroundColor: BRAND.PRIMARY_DARK,
- transform: [{ scale: 0.98 }],
- },
- startButtonText: {
- ...TYPOGRAPHY.HEADLINE,
- color: TEXT.PRIMARY,
- letterSpacing: 1,
- },
-})
+ // Start Button
+ startButton: {
+ height: 56,
+ borderRadius: RADIUS.LG,
+ backgroundColor: BRAND.PRIMARY,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ startButtonPressed: {
+ backgroundColor: BRAND.PRIMARY_DARK,
+ transform: [{ scale: 0.98 }],
+ },
+ startButtonText: {
+ ...TYPOGRAPHY.HEADLINE,
+ color: '#FFFFFF',
+ letterSpacing: 1,
+ },
+ })
+}
diff --git a/app/workout/category/[id].tsx b/app/workout/category/[id].tsx
index 15d24a0..506609d 100644
--- a/app/workout/category/[id].tsx
+++ b/app/workout/category/[id].tsx
@@ -13,36 +13,44 @@ import {
Picker,
} from '@expo/ui/swift-ui'
+import { useTranslation } from 'react-i18next'
+
import { useHaptics } from '@/src/shared/hooks'
-import { getWorkoutsByCategory, getTrainerById, CATEGORIES } from '@/src/shared/data'
+import { getWorkoutsByCategory, CATEGORIES } from '@/src/shared/data'
+import { useTranslatedCategories, useTranslatedWorkouts } from '@/src/shared/data/useTranslatedData'
import { StyledText } from '@/src/shared/components/StyledText'
import type { WorkoutCategory, WorkoutLevel } from '@/src/shared/types'
-import {
- BRAND,
- DARK,
- TEXT,
-} from '@/src/shared/constants/colors'
+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'
-const LEVELS: { id: WorkoutLevel | 'all'; label: string }[] = [
- { id: 'all', label: 'All Levels' },
- { id: 'Beginner', label: 'Beginner' },
- { id: 'Intermediate', label: 'Intermediate' },
- { id: 'Advanced', label: 'Advanced' },
-]
+const LEVEL_IDS: (WorkoutLevel | 'all')[] = ['all', 'Beginner', 'Intermediate', 'Advanced']
export default function CategoryDetailScreen() {
const insets = useSafeAreaInsets()
const router = useRouter()
const haptics = useHaptics()
+ const { t } = useTranslation()
const { id } = useLocalSearchParams<{ id: string }>()
+ const colors = useThemeColors()
+ const styles = useMemo(() => createStyles(colors), [colors])
+
const [selectedLevelIndex, setSelectedLevelIndex] = useState(0)
- const selectedLevel = LEVELS[selectedLevelIndex].id
- const category = CATEGORIES.find(c => c.id === id)
+ const translatedCategories = useTranslatedCategories()
+
+ const levelLabels = [
+ t('screens:category.allLevels'),
+ t('levels.beginner'),
+ t('levels.intermediate'),
+ t('levels.advanced'),
+ ]
+
+ const selectedLevel = LEVEL_IDS[selectedLevelIndex]
+ const category = translatedCategories.find(c => c.id === id)
const categoryLabel = category?.label ?? id ?? 'Category'
const allWorkouts = useMemo(
@@ -55,6 +63,8 @@ export default function CategoryDetailScreen() {
return allWorkouts.filter(w => w.level === selectedLevel)
}, [allWorkouts, selectedLevel])
+ const translatedWorkouts = useTranslatedWorkouts(filteredWorkouts)
+
const handleBack = () => {
haptics.selection()
router.back()
@@ -70,15 +80,15 @@ export default function CategoryDetailScreen() {
{/* Header */}
-
+
- {categoryLabel}
+ {categoryLabel}
{/* Level Filter */}
-
+
{
@@ -86,7 +96,7 @@ export default function CategoryDetailScreen() {
setSelectedLevelIndex(e.nativeEvent.index)
}}
variant="segmented"
- options={LEVELS.map(l => l.label)}
+ options={levelLabels}
color={BRAND.PRIMARY}
/>
@@ -94,10 +104,10 @@ export default function CategoryDetailScreen() {
- {filteredWorkouts.length + ' workouts'}
+ {t('plurals.workout', { count: translatedWorkouts.length })}
- {filteredWorkouts.map((workout) => {
- const trainer = getTrainerById(workout.trainerId)
- return (
+ {translatedWorkouts.map((workout) => (
handleWorkoutPress(workout.id)}
>
-
- {trainer?.name[0] ?? 'T'}
+
+
- {workout.title}
-
- {trainer?.name + ' \u00B7 ' + workout.duration + ' min \u00B7 ' + workout.level}
+ {workout.title}
+
+ {t('durationLevel', { duration: workout.duration, level: t(`levels.${workout.level.toLowerCase()}`) })}
- {workout.calories + ' cal'}
-
+ {t('units.calUnit', { count: workout.calories })}
+
- )
- })}
+ ))}
- {filteredWorkouts.length === 0 && (
+ {translatedWorkouts.length === 0 && (
-
-
+
+
No workouts found
@@ -143,67 +150,69 @@ export default function CategoryDetailScreen() {
)
}
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: DARK.BASE,
- },
- header: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- paddingHorizontal: LAYOUT.SCREEN_PADDING,
- paddingVertical: SPACING[3],
- },
- backButton: {
- width: 44,
- height: 44,
- alignItems: 'center',
- justifyContent: 'center',
- },
- filterContainer: {
- paddingHorizontal: LAYOUT.SCREEN_PADDING,
- marginBottom: SPACING[4],
- },
- scrollView: {
- flex: 1,
- },
- scrollContent: {
- paddingHorizontal: LAYOUT.SCREEN_PADDING,
- },
- workoutCard: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingVertical: SPACING[3],
- paddingHorizontal: SPACING[4],
- backgroundColor: DARK.SURFACE,
- borderRadius: RADIUS.LG,
- marginBottom: SPACING[2],
- gap: SPACING[3],
- },
- workoutAvatar: {
- width: 44,
- height: 44,
- borderRadius: 22,
- alignItems: 'center',
- justifyContent: 'center',
- },
- workoutInitial: {
- fontSize: 18,
- fontWeight: '700',
- color: TEXT.PRIMARY,
- },
- workoutInfo: {
- flex: 1,
- gap: 2,
- },
- workoutMeta: {
- alignItems: 'flex-end',
- gap: 4,
- },
- emptyState: {
- alignItems: 'center',
- justifyContent: 'center',
- paddingVertical: SPACING[12],
- },
-})
+function createStyles(colors: ThemeColors) {
+ return StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: colors.bg.base,
+ },
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingHorizontal: LAYOUT.SCREEN_PADDING,
+ paddingVertical: SPACING[3],
+ },
+ backButton: {
+ width: 44,
+ height: 44,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ filterContainer: {
+ paddingHorizontal: LAYOUT.SCREEN_PADDING,
+ marginBottom: SPACING[4],
+ },
+ scrollView: {
+ flex: 1,
+ },
+ scrollContent: {
+ paddingHorizontal: LAYOUT.SCREEN_PADDING,
+ },
+ workoutCard: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingVertical: SPACING[3],
+ paddingHorizontal: SPACING[4],
+ backgroundColor: colors.bg.surface,
+ borderRadius: RADIUS.LG,
+ marginBottom: SPACING[2],
+ gap: SPACING[3],
+ },
+ workoutAvatar: {
+ width: 44,
+ height: 44,
+ borderRadius: 22,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ workoutInitial: {
+ fontSize: 18,
+ fontWeight: '700',
+ color: colors.text.primary,
+ },
+ workoutInfo: {
+ flex: 1,
+ gap: 2,
+ },
+ workoutMeta: {
+ alignItems: 'flex-end',
+ gap: 4,
+ },
+ emptyState: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingVertical: SPACING[12],
+ },
+ })
+}
diff --git a/src/shared/components/GlassCard.tsx b/src/shared/components/GlassCard.tsx
index f1c992f..8495bdd 100644
--- a/src/shared/components/GlassCard.tsx
+++ b/src/shared/components/GlassCard.tsx
@@ -1,13 +1,14 @@
/**
* GlassCard - Liquid Glass Container
- * iOS 18.4 inspired glassmorphism
+ * iOS 18.4 inspired glassmorphism — theme-aware
*/
-import { ReactNode } from 'react'
+import { ReactNode, useMemo } from 'react'
import { StyleSheet, View, ViewStyle } from 'react-native'
import { BlurView } from 'expo-blur'
-import { DARK, GLASS, SHADOW, BORDER } from '../constants/colors'
+import { useThemeColors } from '../theme'
+import type { ThemeColors } from '../theme/types'
import { RADIUS } from '../constants/borderRadius'
type GlassVariant = 'base' | 'elevated' | 'inset' | 'tinted'
@@ -20,34 +21,38 @@ interface GlassCardProps {
blurIntensity?: number
}
-const variantStyles: Record = {
- base: {
- backgroundColor: GLASS.BASE.backgroundColor,
- borderColor: GLASS.BASE.borderColor,
- borderWidth: GLASS.BASE.borderWidth,
- },
- elevated: {
- backgroundColor: GLASS.ELEVATED.backgroundColor,
- borderColor: GLASS.ELEVATED.borderColor,
- borderWidth: GLASS.ELEVATED.borderWidth,
- },
- inset: {
- backgroundColor: GLASS.INSET.backgroundColor,
- borderColor: GLASS.INSET.borderColor,
- borderWidth: GLASS.INSET.borderWidth,
- },
- tinted: {
- backgroundColor: GLASS.TINTED.backgroundColor,
- borderColor: GLASS.TINTED.borderColor,
- borderWidth: GLASS.TINTED.borderWidth,
- },
+function getVariantStyles(colors: ThemeColors): Record {
+ return {
+ base: {
+ backgroundColor: colors.glass.base.backgroundColor,
+ borderColor: colors.glass.base.borderColor,
+ borderWidth: colors.glass.base.borderWidth,
+ },
+ elevated: {
+ backgroundColor: colors.glass.elevated.backgroundColor,
+ borderColor: colors.glass.elevated.borderColor,
+ borderWidth: colors.glass.elevated.borderWidth,
+ },
+ inset: {
+ backgroundColor: colors.glass.inset.backgroundColor,
+ borderColor: colors.glass.inset.borderColor,
+ borderWidth: colors.glass.inset.borderWidth,
+ },
+ tinted: {
+ backgroundColor: colors.glass.tinted.backgroundColor,
+ borderColor: colors.glass.tinted.borderColor,
+ borderWidth: colors.glass.tinted.borderWidth,
+ },
+ }
}
-const shadowStyles: Record = {
- base: SHADOW.sm,
- elevated: SHADOW.md,
- inset: {},
- tinted: SHADOW.sm,
+function getShadowStyles(colors: ThemeColors): Record {
+ return {
+ base: colors.shadow.sm,
+ elevated: colors.shadow.md,
+ inset: {},
+ tinted: colors.shadow.sm,
+ }
}
export function GlassCard({
@@ -55,17 +60,22 @@ export function GlassCard({
variant = 'base',
style,
hasBlur = true,
- blurIntensity = GLASS.BLUR_MEDIUM,
+ blurIntensity,
}: GlassCardProps) {
+ const colors = useThemeColors()
+ const variantStyles = useMemo(() => getVariantStyles(colors), [colors])
+ const shadowStyles = useMemo(() => getShadowStyles(colors), [colors])
+
const glassStyle = variantStyles[variant]
const shadowStyle = shadowStyles[variant]
+ const intensity = blurIntensity ?? colors.glass.blurMedium
if (hasBlur) {
return (
{children}
diff --git a/src/shared/components/OnboardingStep.tsx b/src/shared/components/OnboardingStep.tsx
index 0b87172..c0c3aea 100644
--- a/src/shared/components/OnboardingStep.tsx
+++ b/src/shared/components/OnboardingStep.tsx
@@ -3,10 +3,11 @@
* Reusable wrapper for each onboarding screen — progress bar, animation, layout
*/
-import { useRef, useEffect } from 'react'
+import { useRef, useEffect, useMemo } from 'react'
import { View, StyleSheet, Animated, Dimensions } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
-import { DARK, BRAND, TEXT } from '../constants/colors'
+import { useThemeColors, BRAND } from '../theme'
+import type { ThemeColors } from '../theme/types'
import { SPACING, LAYOUT } from '../constants/spacing'
import { DURATION, EASE } from '../constants/animations'
@@ -19,6 +20,8 @@ interface OnboardingStepProps {
}
export function OnboardingStep({ step, totalSteps, children }: OnboardingStepProps) {
+ const colors = useThemeColors()
+ const styles = useMemo(() => createStyles(colors), [colors])
const insets = useSafeAreaInsets()
const slideAnim = useRef(new Animated.Value(SCREEN_WIDTH)).current
const fadeAnim = useRef(new Animated.Value(0)).current
@@ -83,26 +86,28 @@ export function OnboardingStep({ step, totalSteps, children }: OnboardingStepPro
)
}
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: DARK.BASE,
- },
- progressTrack: {
- height: 3,
- backgroundColor: DARK.SURFACE,
- marginHorizontal: LAYOUT.SCREEN_PADDING,
- borderRadius: 2,
- overflow: 'hidden',
- },
- progressFill: {
- height: '100%',
- backgroundColor: BRAND.PRIMARY,
- borderRadius: 2,
- },
- content: {
- flex: 1,
- paddingHorizontal: LAYOUT.SCREEN_PADDING,
- paddingTop: SPACING[8],
- },
-})
+function createStyles(colors: ThemeColors) {
+ return StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: colors.bg.base,
+ },
+ progressTrack: {
+ height: 3,
+ backgroundColor: colors.bg.surface,
+ marginHorizontal: LAYOUT.SCREEN_PADDING,
+ borderRadius: 2,
+ overflow: 'hidden',
+ },
+ progressFill: {
+ height: '100%',
+ backgroundColor: BRAND.PRIMARY,
+ borderRadius: 2,
+ },
+ content: {
+ flex: 1,
+ paddingHorizontal: LAYOUT.SCREEN_PADDING,
+ paddingTop: SPACING[8],
+ },
+ })
+}
diff --git a/src/shared/components/StyledText.tsx b/src/shared/components/StyledText.tsx
index c761203..ab408be 100644
--- a/src/shared/components/StyledText.tsx
+++ b/src/shared/components/StyledText.tsx
@@ -1,10 +1,10 @@
/**
* TabataFit StyledText
- * Unified text component — replaces 5 local copies
+ * Unified text component — uses theme for default color
*/
import { Text as RNText, TextStyle, StyleProp } from 'react-native'
-import { TEXT } from '../constants/colors'
+import { useThemeColors } from '../theme'
type FontWeight = 'regular' | 'medium' | 'semibold' | 'bold'
@@ -28,17 +28,20 @@ export function StyledText({
children,
size = 17,
weight = 'regular',
- color = TEXT.PRIMARY,
+ color,
style,
numberOfLines,
}: StyledTextProps) {
+ const colors = useThemeColors()
+ const resolvedColor = color ?? colors.text.primary
+
return (