From 99d8fba852144298ee17554afb4fd09e5e6cce7e Mon Sep 17 00:00:00 2001 From: Millian Lamiaux Date: Fri, 20 Feb 2026 13:24:06 +0100 Subject: [PATCH] feat: 5 tab screens wired to centralized data layer All tabs use shared data, stores, and SwiftUI islands: - Home: greeting from userStore, featured/popular workouts, recent activity from activityStore, tappable collections - Workouts: 50 workouts with SwiftUI Picker category filter, trainer avatars, workout grid, "See All" links to categories - Activity: streak banner, SwiftUI Gauges (workouts/minutes/ calories/best streak), weekly Chart, achievements grid - Browse: featured collection hero, collection grid with emoji icons, programs carousel, new releases list - Profile: user card, subscription banner, SwiftUI List with workout/notification settings (Switches persist via Zustand) Tab layout uses NativeTabs with SF Symbols and haptic feedback. Co-Authored-By: Claude Opus 4.6 --- app/(tabs)/CLAUDE.md | 32 +++ app/(tabs)/_layout.tsx | 68 +++--- app/(tabs)/activity.tsx | 306 +++++++++++++++++++++++ app/(tabs)/browse.tsx | 379 +++++++++++++++++++++++++++++ app/(tabs)/index.tsx | 520 ++++++++++++++++++++++++++++++---------- app/(tabs)/profile.tsx | 236 ++++++++++++++++++ app/(tabs)/workouts.tsx | 279 +++++++++++++++++++++ 7 files changed, 1656 insertions(+), 164 deletions(-) create mode 100644 app/(tabs)/CLAUDE.md create mode 100644 app/(tabs)/activity.tsx create mode 100644 app/(tabs)/browse.tsx create mode 100644 app/(tabs)/profile.tsx create mode 100644 app/(tabs)/workouts.tsx diff --git a/app/(tabs)/CLAUDE.md b/app/(tabs)/CLAUDE.md new file mode 100644 index 0000000..bd4937a --- /dev/null +++ b/app/(tabs)/CLAUDE.md @@ -0,0 +1,32 @@ + +# Recent Activity + + + +### Feb 20, 2026 + +| ID | Time | T | Title | Read | +|----|------|---|-------|------| +| #5056 | 8:24 AM | ✅ | Completed Host wrapper restoration in home screen | ~258 | +| #5055 | " | ✅ | Re-added Host wrapper to home screen JSX | ~187 | +| #5054 | " | ✅ | Re-added Host import to home screen | ~184 | +| #5043 | 8:22 AM | ✅ | Removed closing Host tag from profile screen | ~210 | +| #5042 | " | ✅ | Removed opening Host tag from profile screen | ~164 | +| #5041 | " | ✅ | Removed closing Host tag from browse screen | ~187 | +| #5040 | " | ✅ | Removed opening Host tag from browse screen | ~159 | +| #5039 | 8:21 AM | ✅ | Removed closing Host tag from activity screen | ~193 | +| #5038 | " | ✅ | Removed opening Host tag from activity screen | ~154 | +| #5037 | " | ✅ | Removed closing Host tag from workouts screen | ~195 | +| #5036 | " | ✅ | Removed opening Host tag from workouts screen | ~164 | +| #5035 | " | ✅ | Removed closing Host tag from home screen JSX | ~197 | +| #5034 | " | ✅ | Removed Host wrapper from home screen JSX | ~139 | +| #5031 | 8:19 AM | ✅ | Removed Host import from profile screen | ~184 | +| #5030 | " | ✅ | Removed Host import from browse screen | ~190 | +| #5029 | 8:18 AM | ✅ | Removed Host import from activity screen | ~183 | +| #5028 | " | ✅ | Removed Host import from workouts screen | ~189 | +| #5027 | " | ✅ | Removed Host import from home screen index.tsx | ~180 | +| #5024 | " | 🔵 | Activity screen properly wraps content with Host component | ~237 | +| #5023 | " | 🔵 | Profile screen properly wraps content with Host component | ~246 | +| #5022 | 8:14 AM | 🔵 | Browse screen properly wraps content with Host component | ~217 | +| #5021 | " | 🔵 | Workouts screen properly wraps content with Host component | ~228 | + \ No newline at end of file diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 8efaea6..41ec091 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,41 +1,41 @@ -import { Tabs } from 'expo-router' -import Ionicons from '@expo/vector-icons/Ionicons' +/** + * TabataFit Tab Layout + * Native iOS tabs with liquid glass effect + * 5 tabs: Home, Workouts, Activity, Browse, Profile + */ -import { HapticTab } from '@/components/haptic-tab' -import { BRAND, SURFACE, TEXT, BORDER } from '@/src/shared/constants/colors' +import { NativeTabs, Icon, Label } from 'expo-router/unstable-native-tabs' +import { BRAND } from '@/src/shared/constants/colors' export default function TabLayout() { return ( - - ( - - ), - }} - /> - ( - - ), - }} - /> - + + + + + + + + + + + + + + + + + + + + + + + + + ) } diff --git a/app/(tabs)/activity.tsx b/app/(tabs)/activity.tsx new file mode 100644 index 0000000..21cefae --- /dev/null +++ b/app/(tabs)/activity.tsx @@ -0,0 +1,306 @@ +/** + * TabataFit Activity Screen + * React Native + SwiftUI Islands — wired to shared data + */ + +import { View, StyleSheet, ScrollView, Dimensions, Text as RNText } 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 { 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 { 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, +} + +// ═══════════════════════════════════════════════════════════════════════════ +// MAIN SCREEN +// ═══════════════════════════════════════════════════════════════════════════ + +export default function ActivityScreen() { + const insets = useSafeAreaInsets() + 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 weeklyActivity = useMemo(() => getWeeklyActivity(history), [history]) + + // Check achievements + const unlockedAchievements = ACHIEVEMENTS.filter(a => { + switch (a.type) { + case 'workouts': return totalWorkouts >= a.requirement + case 'streak': return streak.longest >= a.requirement + case 'minutes': return totalMinutes >= a.requirement + case 'calories': return totalCalories >= a.requirement + default: return false + } + }) + const displayAchievements = ACHIEVEMENTS.slice(0, 5).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' + } + + return ( + + + {/* Header */} + Activity + + {/* Streak Banner */} + + + + + + + {(streak.current || 0) + ' Day Streak'} + + + {streak.current > 0 ? 'Keep it going!' : 'Start your streak today!'} + + + + + + {/* 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 }} + /> + + + + {/* SwiftUI Island: 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'} + + ) + })} + + + + )} + + {/* Achievements */} + + Achievements + + {displayAchievements.map((achievement) => ( + + + + + + + {achievement.title} + + + ))} + + + + + ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// STYLES +// ═══════════════════════════════════════════════════════════════════════════ + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: DARK.BASE, + }, + scrollView: { + flex: 1, + }, + scrollContent: { + paddingHorizontal: LAYOUT.SCREEN_PADDING, + }, + + // 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, + }, + + // Stats Island + statsIsland: { + marginBottom: SPACING[8], + }, + + // Chart Island + chartIsland: { + marginTop: SPACING[2], + }, + + // 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)', + }, +}) diff --git a/app/(tabs)/browse.tsx b/app/(tabs)/browse.tsx new file mode 100644 index 0000000..2ff124f --- /dev/null +++ b/app/(tabs)/browse.tsx @@ -0,0 +1,379 @@ +/** + * TabataFit Browse Screen + * React Native UI — wired to shared data + */ + +import { View, StyleSheet, ScrollView, Pressable, Dimensions, Text as RNText } 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 { useHaptics } from '@/src/shared/hooks' +import { + COLLECTIONS, + PROGRAMS, + getFeaturedCollection, + COLLECTION_COLORS, + WORKOUTS, + getTrainerById, +} from '@/src/shared/data' +import { StyledText } from '@/src/shared/components/StyledText' + +import { + BRAND, + DARK, + TEXT, + GLASS, + SHADOW, +} from '@/src/shared/constants/colors' +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: 28, + TITLE_2: 22, + HEADLINE: 17, + SUBHEADLINE: 15, + CAPTION_1: 12, + CAPTION_2: 11, +} + +function TextButton({ children, onPress }: { children: string; onPress?: () => void }) { + return ( + + + {children} + + + ) +} + +// New Releases: last 4 workouts +const NEW_RELEASES = WORKOUTS.slice(-4) + +// ═══════════════════════════════════════════════════════════════════════════ +// MAIN SCREEN +// ═══════════════════════════════════════════════════════════════════════════ + +export default function BrowseScreen() { + const insets = useSafeAreaInsets() + const router = useRouter() + const haptics = useHaptics() + + const featuredCollection = getFeaturedCollection() + + const handleWorkoutPress = (id: string) => { + haptics.buttonTap() + router.push(`/workout/${id}`) + } + + const handleCollectionPress = (id: string) => { + haptics.buttonTap() + router.push(`/collection/${id}`) + } + + return ( + + + {/* Header */} + Browse + + {/* Featured Collection */} + {featuredCollection && ( + handleCollectionPress(featuredCollection.id)}> + + + + + FEATURED + + + + {featuredCollection.title} + {featuredCollection.description} + + + + + {featuredCollection.workoutIds.length + ' workouts'} + + + + + )} + + {/* Collections Grid */} + + Collections + + {COLLECTIONS.map((collection) => { + const color = COLLECTION_COLORS[collection.id] ?? BRAND.PRIMARY + return ( + handleCollectionPress(collection.id)} + > + + + {collection.icon} + + + {collection.title} + + + {collection.workoutIds.length + ' workouts'} + + + ) + })} + + + + {/* Programs */} + + + Programs + See All + + + {PROGRAMS.map((program) => ( + + + + + + {program.level} + + + + {program.title} + + + + + {program.weeks + ' weeks'} + + + + {program.workoutsPerWeek + 'x /week'} + + + + ))} + + + + {/* New Releases */} + + + New Releases + + {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} + + + + + ) + })} + + + + ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// STYLES +// ═══════════════════════════════════════════════════════════════════════════ + +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, + }, + + // 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], + }, + + // 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, + }, + + // 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], + }, + + // 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, + }, +}) diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 9ff99ad..fb0ea7a 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,156 +1,416 @@ -import { useEffect, useRef } from 'react' -import { - Animated, - Pressable, - StyleSheet, - Text, - View, -} from 'react-native' -import { useRouter, Redirect } from 'expo-router' -import { StatusBar } from 'expo-status-bar' -import { LinearGradient } from 'expo-linear-gradient' +/** + * TabataFit Home Screen + * React Native UI — wired to shared data + */ + +import { View, StyleSheet, ScrollView, Pressable, Dimensions, Text as RNText, Image as RNImage } 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 { BRAND, TEXT, APP_GRADIENTS, ACCENT } from '@/src/shared/constants/colors' -import { TYPOGRAPHY } from '@/src/shared/constants/typography' -import { SHADOW, TEXT_SHADOW } from '@/src/shared/constants/shadows' -import { DURATION, EASING } from '@/src/shared/constants/animations' -import { useIsOnboardingComplete } from '@/src/features/onboarding/hooks/useOnboarding' +import { useMemo } from 'react' +import { useHaptics } from '@/src/shared/hooks' +import { useUserStore, useActivityStore } from '@/src/shared/stores' +import { + getFeaturedWorkouts, + getPopularWorkouts, + getTrainerById, + COLLECTIONS, + WORKOUTS, +} from '@/src/shared/data' +import { StyledText } from '@/src/shared/components/StyledText' -export default function HomeScreen() { - const router = useRouter() - const insets = useSafeAreaInsets() - const isOnboardingComplete = useIsOnboardingComplete() +import { + BRAND, + DARK, + TEXT, + GLASS, + SHADOW, + GRADIENTS, +} from '@/src/shared/constants/colors' +import { SPACING, LAYOUT } from '@/src/shared/constants/spacing' +import { RADIUS } from '@/src/shared/constants/borderRadius' - const glowAnim = useRef(new Animated.Value(0)).current +const { width: SCREEN_WIDTH } = Dimensions.get('window') +const CARD_WIDTH = SCREEN_WIDTH - LAYOUT.SCREEN_PADDING * 2 - // Show nothing while Zustand hydrates - if (isOnboardingComplete === undefined) { - return null - } +const FONTS = { + LARGE_TITLE: 34, + TITLE: 28, + TITLE_2: 22, + HEADLINE: 17, + BODY: 17, + SUBHEADLINE: 15, + CAPTION_1: 12, + CAPTION_2: 11, +} - // Redirect to onboarding if not complete - if (isOnboardingComplete === false) { - return - } +// ═══════════════════════════════════════════════════════════════════════════ +// HELPERS +// ═══════════════════════════════════════════════════════════════════════════ - useEffect(() => { - Animated.loop( - Animated.sequence([ - Animated.timing(glowAnim, { - toValue: 1, - duration: DURATION.BREATH, - easing: EASING.STANDARD, - useNativeDriver: false, - }), - Animated.timing(glowAnim, { - toValue: 0, - duration: DURATION.BREATH, - easing: EASING.STANDARD, - useNativeDriver: false, - }), - ]) - ).start() - }, []) - - const glowOpacity = glowAnim.interpolate({ - inputRange: [0, 1], - outputRange: [0.15, 0.4], - }) - - const glowScale = glowAnim.interpolate({ - inputRange: [0, 1], - outputRange: [1, 1.12], - }) +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 }) { return ( - - - - - TABATA - GO - 4 minutes. Tout donner. - - - - - [ - styles.startButton, - pressed && styles.startButtonPressed, - ]} - onPress={() => router.push('/timer')} - > - START - - - + + + {children} + ) } +function PlainButton({ children, onPress }: { children: string; onPress?: () => void }) { + return ( + + {children} + + ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// MAIN SCREEN +// ═══════════════════════════════════════════════════════════════════════════ + +export default function HomeScreen() { + const insets = useSafeAreaInsets() + const router = useRouter() + const haptics = useHaptics() + 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 handleWorkoutPress = (id: string) => { + haptics.buttonTap() + router.push(`/workout/${id}`) + } + + return ( + + + {/* Header */} + + + {getGreeting() + ', ' + userName} + + + + + + + {/* Featured */} + handleWorkoutPress(featured.id)} + > + {featured.thumbnailUrl ? ( + + ) : ( + + )} + + + + 🔥 FEATURED + + + + + {featured.title} + + + {featured.duration + ' min • ' + featured.level + ' • ' + (featuredTrainer?.name ?? '')} + + + + handleWorkoutPress(featured.id)}>START + + + + + + + + {/* Continue Watching — from activity store */} + {recentWorkouts.length > 0 && ( + + + Recent + See All + + + {recentWorkouts.map((result) => { + const workout = WORKOUTS.find(w => w.id === result.workoutId) + if (!workout) return null + const trainer = getTrainerById(workout.trainerId) + return ( + handleWorkoutPress(result.workoutId)} + > + + + + {trainer?.name[0] ?? 'T'} + + + {workout.title} + + {result.calories + ' cal • ' + result.durationMinutes + ' min'} + + + ) + })} + + + )} + + {/* Popular This Week */} + + Popular This Week + + {popular.map((item) => ( + handleWorkoutPress(item.id)} + > + + + + {item.title} + {item.duration + ' min'} + + ))} + + + + {/* Collections */} + + Collections + {COLLECTIONS.map((item) => ( + { haptics.buttonTap(); router.push(`/collection/${item.id}`) }}> + + + {item.icon} + + {item.title} + + {item.workoutIds.length + ' workouts • ' + item.description} + + + + + + ))} + + + + ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// STYLES +// ═══════════════════════════════════════════════════════════════════════════ + const styles = StyleSheet.create({ container: { flex: 1, + backgroundColor: DARK.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', }, - brandArea: { + + // Buttons + primaryButton: { + flexDirection: 'row', alignItems: 'center', - }, - title: { - ...TYPOGRAPHY.brandTitle, - color: BRAND.PRIMARY, - ...TEXT_SHADOW.BRAND, - }, - subtitle: { - ...TYPOGRAPHY.displaySmall, - color: TEXT.PRIMARY, - marginTop: -6, - }, - tagline: { - ...TYPOGRAPHY.caption, - color: TEXT.HINT, - fontStyle: 'italic', - marginTop: 12, - }, - buttonArea: { - marginTop: 72, - alignItems: 'center', - justifyContent: 'center', - }, - buttonGlow: { - position: 'absolute', - width: 172, - height: 172, - borderRadius: 86, - backgroundColor: ACCENT.ORANGE, - }, - startButton: { - width: 160, - height: 160, - borderRadius: 80, 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, + }, + + // 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', - ...SHADOW.BRAND_GLOW, + backgroundColor: 'rgba(255, 255, 255, 0.1)', + borderRadius: 22, + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.15)', }, - startButtonPressed: { - transform: [{ scale: 0.95 }], + + // Sections + section: { + marginBottom: SPACING[8], }, - startButtonText: { - ...TYPOGRAPHY.buttonHero, - color: TEXT.PRIMARY, - letterSpacing: 4, + 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], + }, + + // 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)', + }, + + // 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, }, }) diff --git a/app/(tabs)/profile.tsx b/app/(tabs)/profile.tsx new file mode 100644 index 0000000..0a61855 --- /dev/null +++ b/app/(tabs)/profile.tsx @@ -0,0 +1,236 @@ +/** + * TabataFit Profile Screen + * React Native + SwiftUI Islands — wired to shared data + */ + +import { View, StyleSheet, ScrollView, Text as RNText } 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, + List, + Section, + Switch, + Text, + LabeledContent, +} from '@expo/ui/swift-ui' + +import { useUserStore } from '@/src/shared/stores' +import { StyledText } from '@/src/shared/components/StyledText' + +import { + BRAND, + DARK, + TEXT, + GLASS, + SHADOW, + GRADIENTS, +} from '@/src/shared/constants/colors' +import { SPACING, LAYOUT } from '@/src/shared/constants/spacing' +import { RADIUS } from '@/src/shared/constants/borderRadius' + +const FONTS = { + LARGE_TITLE: 34, + TITLE_2: 22, + HEADLINE: 17, + SUBHEADLINE: 15, + CAPTION_1: 12, +} + +// ═══════════════════════════════════════════════════════════════════════════ +// MAIN SCREEN +// ═══════════════════════════════════════════════════════════════════════════ + +export default function ProfileScreen() { + const insets = useSafeAreaInsets() + const profile = useUserStore((s) => s.profile) + const settings = useUserStore((s) => s.settings) + const updateSettings = useUserStore((s) => s.updateSettings) + + const isPremium = profile.subscription !== 'free' + const planLabel = isPremium ? 'TabataFit+' : 'Free' + + return ( + + + {/* Header */} + + Profile + + + {/* Profile Card */} + + + + + + {profile.name[0]} + + + + {profile.name} + + + {profile.email} + + {isPremium && ( + + + + {planLabel} + + + )} + + + {/* Subscription */} + {isPremium && ( + + + + + + + {planLabel} + + + {'Member since ' + profile.joinDate} + + + + + )} + + {/* SwiftUI Island: Settings */} + + +
+ updateSettings({ haptics: v })} + color={BRAND.PRIMARY} + /> + updateSettings({ soundEffects: v })} + color={BRAND.PRIMARY} + /> + updateSettings({ voiceCoaching: v })} + color={BRAND.PRIMARY} + /> +
+
+ updateSettings({ reminders: v })} + color={BRAND.PRIMARY} + /> + + {settings.reminderTime.replace(':00', ':00 AM')} + +
+
+
+ + {/* Version */} + + TabataFit v1.0.0 + +
+
+ ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// STYLES +// ═══════════════════════════════════════════════════════════════════════════ + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: DARK.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], + }, + + // 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, + }, + + // Version Text + versionText: { + textAlign: 'center', + marginTop: SPACING[6], + }, +}) diff --git a/app/(tabs)/workouts.tsx b/app/(tabs)/workouts.tsx new file mode 100644 index 0000000..bcace97 --- /dev/null +++ b/app/(tabs)/workouts.tsx @@ -0,0 +1,279 @@ +/** + * TabataFit Workouts Screen + * React Native + SwiftUI Islands — wired to shared data + */ + +import { useState } from 'react' +import { View, StyleSheet, ScrollView, Pressable, Dimensions, Text as RNText } from 'react-native' +import { useRouter } from 'expo-router' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { BlurView } from 'expo-blur' +import Ionicons from '@expo/vector-icons/Ionicons' +import { Host, Picker } from '@expo/ui/swift-ui' + +import { useHaptics } from '@/src/shared/hooks' +import { WORKOUTS, TRAINERS, CATEGORIES, getTrainerById } from '@/src/shared/data' +import { StyledText } from '@/src/shared/components/StyledText' + +import { + BRAND, + DARK, + TEXT, + GLASS, + SHADOW, +} from '@/src/shared/constants/colors' +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, +} + +// ═══════════════════════════════════════════════════════════════════════════ +// MAIN SCREEN +// ═══════════════════════════════════════════════════════════════════════════ + +export default function WorkoutsScreen() { + const insets = useSafeAreaInsets() + const router = useRouter() + const haptics = useHaptics() + const [selectedCategoryIndex, setSelectedCategoryIndex] = useState(0) + + const selectedCategory = CATEGORIES[selectedCategoryIndex].id + + const filteredWorkouts = selectedCategory === 'all' + ? WORKOUTS + : WORKOUTS.filter(w => w.category === selectedCategory) + + const handleWorkoutPress = (id: string) => { + haptics.buttonTap() + router.push(`/workout/${id}`) + } + + return ( + + + {/* Header */} + + Workouts + {WORKOUTS.length + ' workouts available'} + + + {/* 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} + + ))} + + + + {/* Workouts Grid */} + + + + {selectedCategory === 'all' ? 'All Workouts' : (CATEGORIES.find(c => c.id === selectedCategory)?.label ?? 'All Workouts')} + + {selectedCategory !== 'all' && ( + { haptics.buttonTap(); router.push(`/workout/category/${selectedCategory}`) }}> + See All + + )} + + + {filteredWorkouts.map((workout) => { + const trainer = getTrainerById(workout.trainerId) + return ( + handleWorkoutPress(workout.id)} + > + + + + {workout.duration + ' min'} + + + + {trainer?.name[0] ?? 'T'} + + + + + + + + + + {workout.title} + {workout.level} + + + ) + })} + + + + + ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// STYLES +// ═══════════════════════════════════════════════════════════════════════════ + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: DARK.BASE, + }, + scrollView: { + flex: 1, + }, + scrollContent: { + paddingHorizontal: LAYOUT.SCREEN_PADDING, + }, + + // Header + header: { + marginBottom: SPACING[6], + }, + + // Picker Island + pickerIsland: { + marginBottom: SPACING[6], + }, + + // Section + section: { + marginBottom: SPACING[8], + }, + sectionHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: SPACING[2], + }, + + // 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], + }, +})