From f80798069b037da22f6de622041d5776349aa57a Mon Sep 17 00:00:00 2001 From: Millian Lamiaux Date: Sat, 21 Feb 2026 00:05:14 +0100 Subject: [PATCH] feat: integrate theme and i18n across all screens Co-Authored-By: Claude Opus 4.6 --- app.json | 5 +- app/(tabs)/_layout.tsx | 21 +- app/(tabs)/activity.tsx | 639 ++++++++++++++------ app/(tabs)/browse.tsx | 410 ++++++------- app/(tabs)/index.tsx | 429 ++++++------- app/(tabs)/profile.tsx | 252 +++++--- app/(tabs)/workouts.tsx | 483 ++++++++------- app/_layout.tsx | 168 ++++-- app/collection/[id].tsx | 212 +++---- app/complete/[id].tsx | 576 +++++++++--------- app/onboarding.tsx | 727 +++++++++++++--------- app/player/[id].tsx | 729 ++++++++++++----------- app/workout/[id].tsx | 531 +++++++++-------- app/workout/category/[id].tsx | 207 ++++--- src/shared/components/GlassCard.tsx | 74 ++- src/shared/components/OnboardingStep.tsx | 55 +- src/shared/components/StyledText.tsx | 11 +- 17 files changed, 3127 insertions(+), 2402 deletions(-) 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 (