feat: Apple Watch app + Paywall + Privacy Policy + rebranding

## Major Features
- Apple Watch companion app (6 phases complete)
  - WatchConnectivity iPhone ↔ Watch
  - HealthKit integration (HR, calories)
  - SwiftUI premium UI
  - 9 complication types
  - Always-On Display support

- Paywall screen with RevenueCat integration
- Privacy Policy screen
- App rebranding: tabatago → TabataFit
- Bundle ID: com.millianlmx.tabatafit

## Changes
- New: ios/TabataFit Watch App/ (complete Watch app)
- New: app/paywall.tsx (subscription UI)
- New: app/privacy.tsx (privacy policy)
- New: src/features/watch/ (Watch sync hooks)
- New: admin-web/ (admin dashboard)
- Updated: app.json, package.json (branding)
- Updated: profile.tsx (paywall + privacy links)
- Updated: i18n translations (EN/FR/DE/ES)
- New: app icon 1024x1024

## Watch App Files
- TabataFitWatchApp.swift (entry point)
- ContentView.swift (premium UI)
- HealthKitManager.swift (HR + calories)
- WatchSessionManager.swift (communication)
- Complications/ (WidgetKit)
- UserDefaults+Shared.swift (data sharing)
This commit is contained in:
Millian Lamiaux
2026-03-11 09:43:53 +01:00
parent f80798069b
commit 2ad7ae3a34
86 changed files with 19648 additions and 365 deletions

View File

@@ -12,4 +12,22 @@
| #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 |
| #5020 | 8:13 AM | 🔵 | Home screen properly wraps content with Host component | ~238 |
</claude-mem-context>

View File

@@ -1,40 +1,35 @@
/**
* TabataFit Profile Screen
* React Native + SwiftUI Islands — wired to shared data
* SwiftUI-first settings with native iOS look
*/
import { View, StyleSheet, ScrollView, Text as RNText } from 'react-native'
import { View, StyleSheet, ScrollView } 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,
List,
Section,
Switch,
Text,
LabeledContent,
DateTimePicker,
Button,
VStack,
Text,
} 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 { useThemeColors, BRAND, GRADIENTS } from '@/src/shared/theme'
import { useThemeColors, BRAND } from '@/src/shared/theme'
import type { ThemeColors } from '@/src/shared/theme/types'
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
const FONTS = {
LARGE_TITLE: 34,
TITLE_2: 22,
HEADLINE: 17,
SUBHEADLINE: 15,
CAPTION_1: 12,
}
@@ -44,6 +39,7 @@ const FONTS = {
export default function ProfileScreen() {
const { t } = useTranslation('screens')
const router = useRouter()
const insets = useSafeAreaInsets()
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
@@ -53,7 +49,7 @@ export default function ProfileScreen() {
const { restorePurchases } = usePurchases()
const isPremium = profile.subscription !== 'free'
const planLabel = isPremium ? 'TabataFit+' : 'Free'
const planLabel = isPremium ? 'TabataFit+' : t('profile.freePlan')
const handleRestore = async () => {
await restorePurchases()
@@ -79,7 +75,31 @@ export default function ProfileScreen() {
const pickerDate = new Date(today.getFullYear(), today.getMonth(), today.getDate(), rh, rm)
const pickerInitial = pickerDate.toISOString()
const settingsHeight = settings.reminders ? 430 : 385
// Calculate total height for single SwiftUI island
// insetGrouped style: ~50px top/bottom margins, section header ~35px, row ~44px
const basePadding = 100 // top + bottom margins for insetGrouped
// Account section
const accountRows = 1 + (isPremium ? 1 : 0) // plan, [+ restore]
const accountHeight = 35 + accountRows * 44
// Upgrade section (free users only)
const upgradeHeight = isPremium ? 0 : 35 + 80 // header + VStack content
// Workout section
const workoutHeight = 35 + 3 * 44 // haptics, sound, voice
// Notifications section
const notificationRows = settings.reminders ? 2 : 1
const notificationHeight = 35 + notificationRows * 44
// About section
const aboutHeight = 35 + 2 * 44 // version, privacy
// Sign out section
const signOutHeight = 44 // single button row
const totalHeight = basePadding + accountHeight + upgradeHeight + workoutHeight + notificationHeight + aboutHeight + signOutHeight
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
@@ -93,76 +113,61 @@ export default function ProfileScreen() {
{t('profile.title')}
</StyledText>
{/* Profile Card */}
<View style={styles.profileCard}>
<BlurView intensity={colors.glass.blurMedium} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
{/* Profile Header Card */}
<View style={styles.profileHeader}>
<View style={styles.avatarContainer}>
<LinearGradient
colors={GRADIENTS.CTA}
style={StyleSheet.absoluteFill}
/>
<StyledText size={FONTS.LARGE_TITLE} weight="bold" color="#FFFFFF">
{profile.name[0]}
{profile.name?.[0] || '?'}
</StyledText>
</View>
<StyledText size={FONTS.TITLE_2} weight="semibold" color={colors.text.primary}>
{profile.name}
</StyledText>
<StyledText size={FONTS.SUBHEADLINE} color={colors.text.tertiary}>
{profile.email}
</StyledText>
{isPremium && (
<View style={styles.premiumBadge}>
<Ionicons name="star" size={12} color={BRAND.PRIMARY} />
<StyledText size={FONTS.CAPTION_1} weight="semibold" color={colors.text.primary}>
{planLabel}
</StyledText>
</View>
)}
</View>
{/* Subscription */}
{isPremium && (
<View style={styles.subscriptionCard}>
<LinearGradient
colors={GRADIENTS.CTA}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={StyleSheet.absoluteFill}
/>
<View style={styles.subscriptionContent}>
<Ionicons name="ribbon" size={24} color="#FFFFFF" />
<View style={styles.subscriptionInfo}>
<StyledText size={FONTS.HEADLINE} weight="bold" color="#FFFFFF">
<View style={styles.profileInfo}>
<StyledText size={FONTS.TITLE_2} weight="semibold" color={colors.text.primary}>
{profile.name || t('profile.guest')}
</StyledText>
{isPremium && (
<View style={styles.premiumBadge}>
<StyledText size={FONTS.CAPTION_1} weight="semibold" color={BRAND.PRIMARY}>
{planLabel}
</StyledText>
<StyledText size={FONTS.CAPTION_1} color="rgba(255,255,255,0.8)">
{t('profile.memberSince', { date: profile.joinDate })}
</StyledText>
</View>
</View>
)}
</View>
)}
</View>
{/* SwiftUI Island: Subscription Section */}
<Host useViewportSizeMeasurement colorScheme={colors.colorScheme} style={{ height: 60, marginTop: -20 }}>
{/* All Settings in Single SwiftUI Island */}
<Host useViewportSizeMeasurement colorScheme={colors.colorScheme} style={{ height: totalHeight }}>
<List listStyle="insetGrouped" scrollEnabled={false}>
<Section title={t('profile.sectionSubscription')}>
<Button
variant="borderless"
onPress={handleRestore}
color={colors.text.primary}
>
{t('profile.restorePurchases')}
</Button>
{/* Account Section */}
<Section header={t('profile.sectionAccount')}>
<LabeledContent label={t('profile.plan')}>
<Text color={isPremium ? BRAND.PRIMARY : undefined}>{planLabel}</Text>
</LabeledContent>
{isPremium && (
<Button variant="borderless" onPress={handleRestore} color={colors.text.tertiary}>
{t('profile.restorePurchases')}
</Button>
)}
</Section>
</List>
</Host>
{/* SwiftUI Island: Settings */}
<Host useViewportSizeMeasurement colorScheme={colors.colorScheme} style={{ height: settingsHeight }}>
<List listStyle="insetGrouped" scrollEnabled={false}>
<Section title={t('profile.sectionWorkout')}>
{/* Upgrade CTA for Free Users */}
{!isPremium && (
<Section>
<VStack alignment="leading" spacing={8}>
<Text font="headline" color={BRAND.PRIMARY}>
{t('profile.upgradeTitle')}
</Text>
<Text color="systemSecondary" font="subheadline">
{t('profile.upgradeDescription')}
</Text>
</VStack>
<Button variant="borderless" onPress={() => router.push('/paywall')} color={BRAND.PRIMARY}>
{t('profile.learnMore')}
</Button>
</Section>
)}
{/* Workout Settings */}
<Section header={t('profile.sectionWorkout')} footer={t('profile.workoutSettingsFooter')}>
<Switch
label={t('profile.hapticFeedback')}
value={settings.haptics}
@@ -182,7 +187,9 @@ export default function ProfileScreen() {
color={BRAND.PRIMARY}
/>
</Section>
<Section title={t('profile.sectionNotifications')}>
{/* Notification Settings */}
<Section header={t('profile.sectionNotifications')} footer={settings.reminders ? t('profile.reminderFooter') : undefined}>
<Switch
label={t('profile.dailyReminders')}
value={settings.reminders}
@@ -201,13 +208,25 @@ export default function ProfileScreen() {
</LabeledContent>
)}
</Section>
{/* About Section */}
<Section header={t('profile.sectionAbout')}>
<LabeledContent label={t('profile.version')}>
<Text color="systemSecondary">1.0.0</Text>
</LabeledContent>
<Button variant="borderless" color="systemSecondary" onPress={() => router.push('/privacy')}>
{t('profile.privacyPolicy')}
</Button>
</Section>
{/* Sign Out */}
<Section>
<Button variant="borderless" role="destructive" onPress={() => {}}>
{t('profile.signOut')}
</Button>
</Section>
</List>
</Host>
{/* Version */}
<StyledText size={FONTS.CAPTION_1} color={colors.text.tertiary} style={styles.versionText}>
{t('profile.version')}
</StyledText>
</ScrollView>
</View>
)
@@ -230,61 +249,31 @@ function createStyles(colors: ThemeColors) {
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: colors.border.glass,
...colors.shadow.md,
},
avatarContainer: {
width: 80,
height: 80,
borderRadius: 40,
alignItems: 'center',
justifyContent: 'center',
marginBottom: SPACING[3],
overflow: 'hidden',
},
premiumBadge: {
// Profile Header
profileHeader: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: SPACING[5],
gap: SPACING[4],
},
avatarContainer: {
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: BRAND.PRIMARY,
alignItems: 'center',
justifyContent: 'center',
},
profileInfo: {
flex: 1,
},
premiumBadge: {
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],
...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],
borderRadius: 12,
alignSelf: 'flex-start',
marginTop: SPACING[1],
},
})
}

View File

@@ -10,35 +10,31 @@
| #5001 | 9:35 AM | 🔵 | Host Wrapper Located at Root Layout Level | ~153 |
| #4964 | 9:23 AM | 🔴 | Added Host Wrapper to Root Layout | ~228 |
| #4963 | 9:22 AM | ✅ | Root layout wraps Stack in View with pure black background | ~279 |
| #4910 | 8:16 AM | 🟣 | Added Workout Detail and Complete Screen Routes | ~348 |
### Feb 20, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #5391 | 10:20 PM | 🟣 | RevenueCat subscription system fully integrated and tested | ~656 |
| #5384 | 9:28 PM | 🟣 | Implemented RevenueCat subscription system | ~190 |
| #5360 | 7:49 PM | 🟣 | Restore Button Style Added to Paywall | ~143 |
| #5359 | " | 🟣 | RevenueCat Purchase Flow Integrated in Paywall | ~273 |
| #5358 | " | 🟣 | usePurchases Hook Imported in Onboarding | ~134 |
| #5357 | " | 🟣 | RevenueCat Initialization Triggered After Store Hydration | ~176 |
| #5356 | 7:48 PM | 🟣 | RevenueCat Initialization Added to Root Layout | ~157 |
| #5313 | 2:58 PM | 🔵 | Onboarding flow architecture examined | ~482 |
| #5279 | 2:43 PM | 🔵 | Reviewed onboarding.tsx structure for notification permission integration | ~289 |
| #5268 | 2:27 PM | ✅ | Onboarding Feature Text Simplified | ~151 |
| #5230 | 1:25 PM | 🟣 | Implemented category and collection detail screens with Inter font loading | ~481 |
| #5228 | " | 🔄 | Removed v1 features and old scaffolding from TabataFit codebase | ~591 |
| #5227 | 1:24 PM | ✅ | Category and Collection screens staged for git commit | ~345 |
| #5224 | " | ✅ | Stage v1.1 files prepared for git commit - SwiftUI Button refactoring complete | ~434 |
| #5206 | 1:03 PM | ⚖️ | SwiftUI component usage mandated for TabataFit app | ~349 |
| #5115 | 8:57 AM | 🔵 | Root Layout Stack Configuration with Screen Animations | ~256 |
| #5061 | 8:47 AM | 🔵 | Expo Router Tab Navigation Structure Found | ~196 |
| #5053 | 8:23 AM | ✅ | Completed removal of all Host wrappers from application | ~255 |
| #5052 | " | ✅ | Removed Host wrapper from root layout entirely | ~224 |
| #5019 | 8:13 AM | 🔵 | Root layout properly wraps Stack with Host component | ~198 |
### Feb 21, 2026
### Feb 28, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #5551 | 12:02 AM | 🔄 | Converted onboarding and player screens to theme system | ~261 |
| #5598 | 9:22 PM | 🟣 | Enabled PostHog analytics in development mode | ~253 |
| #5597 | " | 🔄 | PostHogProvider initialization updated with client check and autocapture config | ~303 |
| #5589 | 7:51 PM | 🟣 | PostHog screen tracking added to onboarding flow | ~246 |
| #5588 | 7:50 PM | ✅ | Added trackScreen function to onboarding analytics imports | ~203 |
| #5585 | " | ✅ | Enhanced PostHogProvider initialization with null safety | ~239 |
| #5584 | 7:49 PM | ✅ | Imported trackScreen function in root layout | ~202 |
| #5583 | " | 🟣 | PostHog user identification added to onboarding completion | ~291 |
| #5582 | " | ✅ | Enhanced onboarding analytics with user identification | ~187 |
| #5579 | 7:47 PM | 🔵 | Comprehensive analytics tracking in onboarding flow | ~345 |
| #5575 | 7:44 PM | 🔵 | PostHog integration architecture in root layout | ~279 |
| #5572 | 7:43 PM | 🔵 | PostHog integration points identified | ~228 |
</claude-mem-context>

View File

@@ -28,7 +28,7 @@ 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'
import { initializeAnalytics, getPostHogClient, trackScreen } from '@/src/shared/services/analytics'
Notifications.setNotificationHandler({
handleNotification: async () => ({
@@ -105,7 +105,7 @@ function RootLayoutInner() {
<Stack.Screen
name="workout/[id]"
options={{
animation: 'slide_from_bottom',
animation: 'slide_from_right',
}}
/>
<Stack.Screen
@@ -137,13 +137,21 @@ function RootLayoutInner() {
</View>
)
// Skip PostHogProvider in dev to avoid SDK errors without a real API key
if (__DEV__) {
const posthogClient = getPostHogClient()
// Only wrap with PostHogProvider if client is initialized
if (!posthogClient) {
return content
}
return (
<PostHogProvider client={getPostHogClient() ?? undefined} autocapture={{ captureScreens: true }}>
<PostHogProvider
client={posthogClient}
autocapture={{
captureScreens: true,
captureTouches: true,
}}
>
{content}
</PostHogProvider>
)

35
app/admin/_layout.tsx Normal file
View File

@@ -0,0 +1,35 @@
import { Stack } from 'expo-router'
import { AdminAuthProvider, useAdminAuth } from '../../src/admin/components/AdminAuthProvider'
import { View, ActivityIndicator } from 'react-native'
import { Redirect } from 'expo-router'
function AdminLayoutContent({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isAdmin, isLoading } = useAdminAuth()
if (isLoading) {
return (
<View style={{ flex: 1, backgroundColor: '#000', justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" color="#FF6B35" />
</View>
)
}
if (!isAuthenticated || !isAdmin) {
return <Redirect href="/admin/login" />
}
return (
<>
<Stack.Screen options={{ headerShown: false }} />
{children}
</>
)
}
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return (
<AdminAuthProvider>
<AdminLayoutContent>{children}</AdminLayoutContent>
</AdminAuthProvider>
)
}

201
app/admin/collections.tsx Normal file
View File

@@ -0,0 +1,201 @@
import { useState } from 'react'
import {
View,
Text,
ScrollView,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
Alert,
} from 'react-native'
import { useRouter } from 'expo-router'
import { useCollections } from '../../src/shared/hooks/useSupabaseData'
import { adminService } from '../../src/admin/services/adminService'
import type { Collection } from '../../src/shared/types'
export default function AdminCollectionsScreen() {
const router = useRouter()
const { collections, loading, refetch } = useCollections()
const [updatingId, setUpdatingId] = useState<string | null>(null)
const handleDelete = (collection: Collection) => {
Alert.alert(
'Delete Collection',
`Are you sure you want to delete "${collection.title}"?`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
Alert.alert('Info', 'Collection deletion not yet implemented')
}
},
]
)
}
if (loading) {
return (
<View style={[styles.container, styles.centered]}>
<ActivityIndicator size="large" color="#FF6B35" />
</View>
)
}
return (
<View style={styles.container}>
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
<Text style={styles.backText}> Back</Text>
</TouchableOpacity>
<Text style={styles.title}>Collections</Text>
<TouchableOpacity style={styles.addButton}>
<Text style={styles.addButtonText}>+ Add</Text>
</TouchableOpacity>
</View>
<ScrollView style={styles.content}>
{collections.map((collection) => (
<View key={collection.id} style={styles.collectionCard}>
<View style={styles.iconContainer}>
<Text style={styles.icon}>{collection.icon}</Text>
</View>
<View style={styles.collectionInfo}>
<Text style={styles.collectionTitle}>{collection.title}</Text>
<Text style={styles.collectionDescription}>{collection.description}</Text>
<Text style={styles.collectionMeta}>
{collection.workoutIds.length} workouts
</Text>
</View>
<View style={styles.actions}>
<TouchableOpacity style={styles.editButton}>
<Text style={styles.editText}>Edit</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.deleteButton, updatingId === collection.id && styles.disabledButton]}
onPress={() => handleDelete(collection)}
disabled={updatingId === collection.id}
>
<Text style={styles.deleteText}>
{updatingId === collection.id ? '...' : 'Delete'}
</Text>
</TouchableOpacity>
</View>
</View>
))}
</ScrollView>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
centered: {
justifyContent: 'center',
alignItems: 'center',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 20,
paddingTop: 60,
borderBottomWidth: 1,
borderBottomColor: '#1C1C1E',
},
backButton: {
padding: 8,
},
backText: {
color: '#FF6B35',
fontSize: 16,
},
title: {
fontSize: 20,
fontWeight: 'bold',
color: '#fff',
},
addButton: {
backgroundColor: '#FF6B35',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
},
addButtonText: {
color: '#000',
fontWeight: 'bold',
},
content: {
flex: 1,
padding: 16,
},
collectionCard: {
backgroundColor: '#1C1C1E',
borderRadius: 12,
padding: 16,
marginBottom: 12,
flexDirection: 'row',
alignItems: 'center',
},
iconContainer: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: '#2C2C2E',
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
icon: {
fontSize: 24,
},
collectionInfo: {
flex: 1,
},
collectionTitle: {
fontSize: 16,
fontWeight: 'bold',
color: '#fff',
marginBottom: 4,
},
collectionDescription: {
fontSize: 14,
color: '#999',
marginBottom: 4,
},
collectionMeta: {
fontSize: 12,
color: '#666',
},
actions: {
flexDirection: 'row',
gap: 8,
},
editButton: {
backgroundColor: '#2C2C2E',
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 6,
},
editText: {
color: '#5AC8FA',
},
deleteButton: {
backgroundColor: '#2C2C2E',
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 6,
},
disabledButton: {
opacity: 0.5,
},
deleteText: {
color: '#FF3B30',
},
})

212
app/admin/index.tsx Normal file
View File

@@ -0,0 +1,212 @@
import { useState, useCallback } from 'react'
import {
View,
Text,
ScrollView,
TouchableOpacity,
StyleSheet,
RefreshControl,
} from 'react-native'
import { useRouter } from 'expo-router'
import { useAdminAuth } from '../../src/admin/components/AdminAuthProvider'
import { useWorkouts, useTrainers, useCollections } from '../../src/shared/hooks/useSupabaseData'
export default function AdminDashboardScreen() {
const router = useRouter()
const { signOut } = useAdminAuth()
const [refreshing, setRefreshing] = useState(false)
const {
workouts,
loading: workoutsLoading,
refetch: refetchWorkouts
} = useWorkouts()
const {
trainers,
loading: trainersLoading,
refetch: refetchTrainers
} = useTrainers()
const {
collections,
loading: collectionsLoading,
refetch: refetchCollections
} = useCollections()
const onRefresh = useCallback(async () => {
setRefreshing(true)
await Promise.all([
refetchWorkouts(),
refetchTrainers(),
refetchCollections(),
])
setRefreshing(false)
}, [refetchWorkouts, refetchTrainers, refetchCollections])
const handleLogout = async () => {
await signOut()
router.replace('/admin/login')
}
const isLoading = workoutsLoading || trainersLoading || collectionsLoading
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Admin Dashboard</Text>
<TouchableOpacity onPress={handleLogout} style={styles.logoutButton}>
<Text style={styles.logoutText}>Logout</Text>
</TouchableOpacity>
</View>
<ScrollView
style={styles.content}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor="#FF6B35" />
}
>
<View style={styles.statsGrid}>
<View style={styles.statCard}>
<Text style={styles.statNumber}>{workouts.length}</Text>
<Text style={styles.statLabel}>Workouts</Text>
</View>
<View style={styles.statCard}>
<Text style={styles.statNumber}>{trainers.length}</Text>
<Text style={styles.statLabel}>Trainers</Text>
</View>
<View style={styles.statCard}>
<Text style={styles.statNumber}>{collections.length}</Text>
<Text style={styles.statLabel}>Collections</Text>
</View>
</View>
<Text style={styles.sectionTitle}>Quick Actions</Text>
<View style={styles.actionsGrid}>
<TouchableOpacity
style={styles.actionCard}
onPress={() => router.push('/admin/workouts')}
>
<Text style={styles.actionIcon}>💪</Text>
<Text style={styles.actionTitle}>Manage Workouts</Text>
<Text style={styles.actionDescription}>Add, edit, or delete workouts</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.actionCard}
onPress={() => router.push('/admin/trainers')}
>
<Text style={styles.actionIcon}>👥</Text>
<Text style={styles.actionTitle}>Manage Trainers</Text>
<Text style={styles.actionDescription}>Update trainer profiles</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.actionCard}
onPress={() => router.push('/admin/collections')}
>
<Text style={styles.actionIcon}>📁</Text>
<Text style={styles.actionTitle}>Manage Collections</Text>
<Text style={styles.actionDescription}>Organize workout collections</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.actionCard}
onPress={() => router.push('/admin/media')}
>
<Text style={styles.actionIcon}>🎬</Text>
<Text style={styles.actionTitle}>Media Library</Text>
<Text style={styles.actionDescription}>Upload videos and images</Text>
</TouchableOpacity>
</View>
</ScrollView>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 20,
paddingTop: 60,
borderBottomWidth: 1,
borderBottomColor: '#1C1C1E',
},
title: {
fontSize: 28,
fontWeight: 'bold',
color: '#fff',
},
logoutButton: {
padding: 8,
},
logoutText: {
color: '#FF6B35',
fontSize: 16,
},
content: {
flex: 1,
padding: 20,
},
statsGrid: {
flexDirection: 'row',
gap: 12,
marginBottom: 32,
},
statCard: {
flex: 1,
backgroundColor: '#1C1C1E',
borderRadius: 12,
padding: 16,
alignItems: 'center',
},
statNumber: {
fontSize: 32,
fontWeight: 'bold',
color: '#FF6B35',
},
statLabel: {
fontSize: 14,
color: '#999',
marginTop: 4,
},
sectionTitle: {
fontSize: 20,
fontWeight: 'bold',
color: '#fff',
marginBottom: 16,
},
actionsGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
},
actionCard: {
width: '47%',
backgroundColor: '#1C1C1E',
borderRadius: 12,
padding: 20,
marginBottom: 12,
},
actionIcon: {
fontSize: 32,
marginBottom: 12,
},
actionTitle: {
fontSize: 16,
fontWeight: 'bold',
color: '#fff',
marginBottom: 4,
},
actionDescription: {
fontSize: 14,
color: '#999',
},
})

124
app/admin/login.tsx Normal file
View File

@@ -0,0 +1,124 @@
import { useState } from 'react'
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
} from 'react-native'
import { useAdminAuth } from '../../src/admin/components/AdminAuthProvider'
export default function AdminLoginScreen() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const { signIn, isLoading } = useAdminAuth()
const handleLogin = async () => {
if (!email || !password) {
setError('Please enter both email and password')
return
}
setError('')
try {
await signIn(email, password)
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed')
}
}
return (
<View style={styles.container}>
<View style={styles.card}>
<Text style={styles.title}>TabataFit Admin</Text>
<Text style={styles.subtitle}>Sign in to manage content</Text>
{error ? <Text style={styles.errorText}>{error}</Text> : null}
<TextInput
style={styles.input}
placeholder="Email"
placeholderTextColor="#666"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
/>
<TextInput
style={styles.input}
placeholder="Password"
placeholderTextColor="#666"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
<TouchableOpacity
style={styles.button}
onPress={handleLogin}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator color="#000" />
) : (
<Text style={styles.buttonText}>Sign In</Text>
)}
</TouchableOpacity>
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
card: {
backgroundColor: '#1C1C1E',
borderRadius: 16,
padding: 32,
width: '100%',
maxWidth: 400,
},
title: {
fontSize: 28,
fontWeight: 'bold',
color: '#fff',
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: '#999',
marginBottom: 24,
},
errorText: {
color: '#FF3B30',
marginBottom: 16,
},
input: {
backgroundColor: '#2C2C2E',
borderRadius: 8,
padding: 16,
marginBottom: 16,
color: '#fff',
fontSize: 16,
},
button: {
backgroundColor: '#FF6B35',
borderRadius: 8,
padding: 16,
alignItems: 'center',
},
buttonText: {
color: '#000',
fontSize: 16,
fontWeight: 'bold',
},
})

201
app/admin/media.tsx Normal file
View File

@@ -0,0 +1,201 @@
import { useState } from 'react'
import {
View,
Text,
ScrollView,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
Alert,
} from 'react-native'
import { useRouter } from 'expo-router'
import { supabase } from '../../src/shared/supabase'
export default function AdminMediaScreen() {
const router = useRouter()
const [uploading, setUploading] = useState(false)
const [activeTab, setActiveTab] = useState<'videos' | 'thumbnails' | 'avatars'>('videos')
const handleUpload = async () => {
Alert.alert('Info', 'File upload requires file picker integration. This is a placeholder.')
}
const handleDelete = async (path: string) => {
Alert.alert(
'Delete File',
`Are you sure you want to delete "${path}"?`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
try {
const { error } = await supabase.storage
.from(activeTab)
.remove([path])
if (error) throw error
Alert.alert('Success', 'File deleted')
} catch (err) {
Alert.alert('Error', err instanceof Error ? err.message : 'Failed to delete')
}
}
},
]
)
}
return (
<View style={styles.container}>
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
<Text style={styles.backText}> Back</Text>
</TouchableOpacity>
<Text style={styles.title}>Media Library</Text>
<TouchableOpacity style={styles.uploadButton} onPress={handleUpload}>
<Text style={styles.uploadButtonText}>Upload</Text>
</TouchableOpacity>
</View>
<View style={styles.tabs}>
{(['videos', 'thumbnails', 'avatars'] as const).map((tab) => (
<TouchableOpacity
key={tab}
style={[styles.tab, activeTab === tab && styles.activeTab]}
onPress={() => setActiveTab(tab)}
>
<Text style={[styles.tabText, activeTab === tab && styles.activeTabText]}>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
</Text>
</TouchableOpacity>
))}
</View>
<ScrollView style={styles.content}>
<View style={styles.infoCard}>
<Text style={styles.infoTitle}>Storage Buckets</Text>
<Text style={styles.infoText}>
videos - Workout videos (MP4, MOV){'\n'}
thumbnails - Workout thumbnails (JPG, PNG){'\n'}
avatars - Trainer avatars (JPG, PNG)
</Text>
</View>
<View style={styles.placeholderCard}>
<Text style={styles.placeholderIcon}>🎬</Text>
<Text style={styles.placeholderTitle}>Media Management</Text>
<Text style={styles.placeholderText}>
Upload and manage media files for workouts and trainers.{'\n\n'}
This feature requires file picker integration.{'\n'}
Files will be stored in Supabase Storage.
</Text>
</View>
</ScrollView>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 20,
paddingTop: 60,
borderBottomWidth: 1,
borderBottomColor: '#1C1C1E',
},
backButton: {
padding: 8,
},
backText: {
color: '#FF6B35',
fontSize: 16,
},
title: {
fontSize: 20,
fontWeight: 'bold',
color: '#fff',
},
uploadButton: {
backgroundColor: '#FF6B35',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
},
uploadButtonText: {
color: '#000',
fontWeight: 'bold',
},
tabs: {
flexDirection: 'row',
padding: 16,
gap: 8,
},
tab: {
flex: 1,
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 8,
backgroundColor: '#1C1C1E',
alignItems: 'center',
},
activeTab: {
backgroundColor: '#FF6B35',
},
tabText: {
color: '#999',
fontWeight: '600',
},
activeTabText: {
color: '#000',
},
content: {
flex: 1,
padding: 16,
},
infoCard: {
backgroundColor: '#1C1C1E',
borderRadius: 12,
padding: 16,
marginBottom: 16,
},
infoTitle: {
fontSize: 16,
fontWeight: 'bold',
color: '#fff',
marginBottom: 8,
},
infoText: {
fontSize: 14,
color: '#999',
lineHeight: 20,
},
placeholderCard: {
backgroundColor: '#1C1C1E',
borderRadius: 12,
padding: 32,
alignItems: 'center',
},
placeholderIcon: {
fontSize: 48,
marginBottom: 16,
},
placeholderTitle: {
fontSize: 18,
fontWeight: 'bold',
color: '#fff',
marginBottom: 8,
},
placeholderText: {
fontSize: 14,
color: '#999',
textAlign: 'center',
lineHeight: 20,
},
})

194
app/admin/trainers.tsx Normal file
View File

@@ -0,0 +1,194 @@
import { useState } from 'react'
import {
View,
Text,
ScrollView,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
Alert,
} from 'react-native'
import { useRouter } from 'expo-router'
import { useTrainers } from '../../src/shared/hooks/useSupabaseData'
import { adminService } from '../../src/admin/services/adminService'
import type { Trainer } from '../../src/shared/types'
export default function AdminTrainersScreen() {
const router = useRouter()
const { trainers, loading, refetch } = useTrainers()
const [deletingId, setDeletingId] = useState<string | null>(null)
const handleDelete = (trainer: Trainer) => {
Alert.alert(
'Delete Trainer',
`Are you sure you want to delete "${trainer.name}"?`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
setDeletingId(trainer.id)
try {
await adminService.deleteTrainer(trainer.id)
await refetch()
} catch (err) {
Alert.alert('Error', err instanceof Error ? err.message : 'Failed to delete')
} finally {
setDeletingId(null)
}
}
},
]
)
}
if (loading) {
return (
<View style={[styles.container, styles.centered]}>
<ActivityIndicator size="large" color="#FF6B35" />
</View>
)
}
return (
<View style={styles.container}>
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
<Text style={styles.backText}> Back</Text>
</TouchableOpacity>
<Text style={styles.title}>Trainers</Text>
<TouchableOpacity style={styles.addButton}>
<Text style={styles.addButtonText}>+ Add</Text>
</TouchableOpacity>
</View>
<ScrollView style={styles.content}>
{trainers.map((trainer) => (
<View key={trainer.id} style={styles.trainerCard}>
<View style={[styles.colorIndicator, { backgroundColor: trainer.color }]} />
<View style={styles.trainerInfo}>
<Text style={styles.trainerName}>{trainer.name}</Text>
<Text style={styles.trainerMeta}>
{trainer.specialty} {trainer.workoutCount} workouts
</Text>
</View>
<View style={styles.actions}>
<TouchableOpacity style={styles.editButton}>
<Text style={styles.editText}>Edit</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.deleteButton, deletingId === trainer.id && styles.disabledButton]}
onPress={() => handleDelete(trainer)}
disabled={deletingId === trainer.id}
>
<Text style={styles.deleteText}>
{deletingId === trainer.id ? '...' : 'Delete'}
</Text>
</TouchableOpacity>
</View>
</View>
))}
</ScrollView>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
centered: {
justifyContent: 'center',
alignItems: 'center',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 20,
paddingTop: 60,
borderBottomWidth: 1,
borderBottomColor: '#1C1C1E',
},
backButton: {
padding: 8,
},
backText: {
color: '#FF6B35',
fontSize: 16,
},
title: {
fontSize: 20,
fontWeight: 'bold',
color: '#fff',
},
addButton: {
backgroundColor: '#FF6B35',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
},
addButtonText: {
color: '#000',
fontWeight: 'bold',
},
content: {
flex: 1,
padding: 16,
},
trainerCard: {
backgroundColor: '#1C1C1E',
borderRadius: 12,
padding: 16,
marginBottom: 12,
flexDirection: 'row',
alignItems: 'center',
},
colorIndicator: {
width: 12,
height: 12,
borderRadius: 6,
marginRight: 12,
},
trainerInfo: {
flex: 1,
},
trainerName: {
fontSize: 16,
fontWeight: 'bold',
color: '#fff',
marginBottom: 4,
},
trainerMeta: {
fontSize: 14,
color: '#999',
},
actions: {
flexDirection: 'row',
gap: 8,
},
editButton: {
backgroundColor: '#2C2C2E',
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 6,
},
editText: {
color: '#5AC8FA',
},
deleteButton: {
backgroundColor: '#2C2C2E',
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 6,
},
disabledButton: {
opacity: 0.5,
},
deleteText: {
color: '#FF3B30',
},
})

190
app/admin/workouts.tsx Normal file
View File

@@ -0,0 +1,190 @@
import { useState } from 'react'
import {
View,
Text,
ScrollView,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
Alert,
} from 'react-native'
import { useRouter } from 'expo-router'
import { useWorkouts } from '../../src/shared/hooks/useSupabaseData'
import { adminService } from '../../src/admin/services/adminService'
import type { Workout } from '../../src/shared/types'
export default function AdminWorkoutsScreen() {
const router = useRouter()
const { workouts, loading, error, refetch } = useWorkouts()
const [deletingId, setDeletingId] = useState<string | null>(null)
const handleDelete = (workout: Workout) => {
Alert.alert(
'Delete Workout',
`Are you sure you want to delete "${workout.title}"?`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
setDeletingId(workout.id)
try {
await adminService.deleteWorkout(workout.id)
await refetch()
} catch (err) {
Alert.alert('Error', err instanceof Error ? err.message : 'Failed to delete')
} finally {
setDeletingId(null)
}
}
},
]
)
}
if (loading) {
return (
<View style={[styles.container, styles.centered]}>
<ActivityIndicator size="large" color="#FF6B35" />
</View>
)
}
return (
<View style={styles.container}>
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
<Text style={styles.backText}> Back</Text>
</TouchableOpacity>
<Text style={styles.title}>Workouts</Text>
<TouchableOpacity style={styles.addButton}>
<Text style={styles.addButtonText}>+ Add</Text>
</TouchableOpacity>
</View>
<ScrollView style={styles.content}>
{workouts.map((workout) => (
<View key={workout.id} style={styles.workoutCard}>
<View style={styles.workoutInfo}>
<Text style={styles.workoutTitle}>{workout.title}</Text>
<Text style={styles.workoutMeta}>
{workout.category} {workout.level} {workout.duration}min
</Text>
<Text style={styles.workoutMeta}>
{workout.rounds} rounds {workout.calories} cal
</Text>
</View>
<View style={styles.actions}>
<TouchableOpacity style={styles.editButton}>
<Text style={styles.editText}>Edit</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.deleteButton, deletingId === workout.id && styles.disabledButton]}
onPress={() => handleDelete(workout)}
disabled={deletingId === workout.id}
>
<Text style={styles.deleteText}>
{deletingId === workout.id ? '...' : 'Delete'}
</Text>
</TouchableOpacity>
</View>
</View>
))}
</ScrollView>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
centered: {
justifyContent: 'center',
alignItems: 'center',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 20,
paddingTop: 60,
borderBottomWidth: 1,
borderBottomColor: '#1C1C1E',
},
backButton: {
padding: 8,
},
backText: {
color: '#FF6B35',
fontSize: 16,
},
title: {
fontSize: 20,
fontWeight: 'bold',
color: '#fff',
},
addButton: {
backgroundColor: '#FF6B35',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
},
addButtonText: {
color: '#000',
fontWeight: 'bold',
},
content: {
flex: 1,
padding: 16,
},
workoutCard: {
backgroundColor: '#1C1C1E',
borderRadius: 12,
padding: 16,
marginBottom: 12,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
workoutInfo: {
flex: 1,
},
workoutTitle: {
fontSize: 16,
fontWeight: 'bold',
color: '#fff',
marginBottom: 4,
},
workoutMeta: {
fontSize: 14,
color: '#999',
},
actions: {
flexDirection: 'row',
gap: 8,
},
editButton: {
backgroundColor: '#2C2C2E',
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 6,
},
editText: {
color: '#5AC8FA',
},
deleteButton: {
backgroundColor: '#2C2C2E',
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 6,
},
disabledButton: {
opacity: 0.5,
},
deleteText: {
color: '#FF3B30',
},
})

View File

@@ -29,7 +29,7 @@ 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 { track, identifyUser, setUserProperties, trackScreen } from '@/src/shared/services/analytics'
import type { FitnessLevel, FitnessGoal, WeeklyFrequency } from '@/src/shared/types'
@@ -938,25 +938,43 @@ export default function OnboardingScreen() {
// Track onboarding_started + first step viewed on mount
useEffect(() => {
trackScreen('onboarding')
track('onboarding_started')
track('onboarding_step_viewed', { step: 1, step_name: STEP_NAMES[1] })
}, [])
const finishOnboarding = useCallback(
(plan: 'free' | 'premium-monthly' | 'premium-yearly') => {
const totalTime = Date.now() - onboardingStartTime.current
track('onboarding_completed', {
plan,
total_time_ms: Date.now() - onboardingStartTime.current,
total_time_ms: totalTime,
steps_completed: step,
})
completeOnboarding({
const userData = {
name: name.trim() || 'Athlete',
fitnessLevel: level,
goal,
weeklyFrequency: frequency,
barriers,
}
completeOnboarding(userData)
// Identify user in PostHog for session replay linking
const userId = `user_${Date.now()}` // In production, use actual user ID from backend
identifyUser(userId, {
name: userData.name,
fitness_level: level,
fitness_goal: goal,
weekly_frequency: frequency,
subscription_plan: plan,
onboarding_completed_at: new Date().toISOString(),
barriers: barriers.join(','),
})
if (plan !== 'free') {
setSubscription(plan)
}

409
app/paywall.tsx Normal file
View File

@@ -0,0 +1,409 @@
/**
* TabataFit Paywall Screen
* Premium subscription purchase flow
*/
import React from 'react'
import {
View,
StyleSheet,
ScrollView,
Pressable,
Text,
} from 'react-native'
import { useRouter } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { LinearGradient } from 'expo-linear-gradient'
import Ionicons from '@expo/vector-icons/Ionicons'
import { useTranslation } from 'react-i18next'
import { useHaptics, usePurchases } from '@/src/shared/hooks'
import { BRAND, darkColors } from '@/src/shared/theme'
import { SPACING } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
// ═══════════════════════════════════════════════════════════════════════════
// FEATURES LIST
// ═══════════════════════════════════════════════════════════════════════════
const PREMIUM_FEATURES = [
{ icon: 'musical-notes', key: 'music' },
{ icon: 'infinity', key: 'workouts' },
{ icon: 'stats-chart', key: 'stats' },
{ icon: 'flame', key: 'calories' },
{ icon: 'notifications', key: 'reminders' },
{ icon: 'close-circle', key: 'ads' },
]
// ═══════════════════════════════════════════════════════════════════════════
// COMPONENTS
// ═══════════════════════════════════════════════════════════════════════════
function PlanCard({
title,
price,
period,
savings,
isSelected,
onPress,
}: {
title: string
price: string
period: string
savings?: string
isSelected: boolean
onPress: () => void
}) {
const haptics = useHaptics()
const handlePress = () => {
haptics.selection()
onPress()
}
return (
<Pressable
onPress={handlePress}
style={({ pressed }) => [
styles.planCard,
isSelected && styles.planCardSelected,
pressed && styles.planCardPressed,
]}
>
{savings && (
<View style={styles.savingsBadge}>
<Text style={styles.savingsText}>{savings}</Text>
</View>
)}
<View style={styles.planInfo}>
<Text style={styles.planTitle}>{title}</Text>
<Text style={styles.planPeriod}>{period}</Text>
</View>
<Text style={styles.planPrice}>{price}</Text>
{isSelected && (
<View style={styles.checkmark}>
<Ionicons name="checkmark-circle" size={24} color={BRAND.PRIMARY} />
</View>
)}
</Pressable>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// MAIN SCREEN
// ═══════════════════════════════════════════════════════════════════════════
export default function PaywallScreen() {
const { t } = useTranslation('screens')
const router = useRouter()
const insets = useSafeAreaInsets()
const haptics = useHaptics()
const {
monthlyPackage,
annualPackage,
purchasePackage,
restorePurchases,
isLoading,
} = usePurchases()
const [selectedPlan, setSelectedPlan] = React.useState<'monthly' | 'annual'>('annual')
// Get prices from RevenueCat packages
const monthlyPrice = monthlyPackage?.product.priceString ?? '$4.99'
const annualPrice = annualPackage?.product.priceString ?? '$29.99'
const annualMonthlyEquivalent = annualPackage
? (annualPackage.product.price / 12).toFixed(2)
: '2.49'
const handlePurchase = async () => {
haptics.buttonTap()
const pkg = selectedPlan === 'monthly' ? monthlyPackage : annualPackage
if (!pkg) {
console.log('[Paywall] No package available for purchase')
return
}
const result = await purchasePackage(pkg)
if (result.success) {
haptics.workoutComplete()
router.back()
} else if (!result.cancelled) {
console.log('[Paywall] Purchase error:', result.error)
}
}
const handleRestore = async () => {
haptics.selection()
const restored = await restorePurchases()
if (restored) {
haptics.workoutComplete()
router.back()
}
}
const handleClose = () => {
haptics.selection()
router.back()
}
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
{/* Background Gradient */}
<LinearGradient
colors={['#1a1a2e', '#16213e', '#0f0f1a']}
style={styles.gradient}
/>
{/* Close Button */}
<Pressable style={styles.closeButton} onPress={handleClose}>
<Ionicons name="close" size={28} color={darkColors.text.secondary} />
</Pressable>
<ScrollView
style={styles.scrollView}
contentContainerStyle={[
styles.scrollContent,
{ paddingBottom: insets.bottom + 100 },
]}
showsVerticalScrollIndicator={false}
>
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>TabataFit+</Text>
<Text style={styles.subtitle}>{t('paywall.subtitle')}</Text>
</View>
{/* Features Grid */}
<View style={styles.featuresGrid}>
{PREMIUM_FEATURES.map((feature) => (
<View key={feature.key} style={styles.featureItem}>
<View style={styles.featureIcon}>
<Ionicons name={feature.icon as any} size={22} color={BRAND.PRIMARY} />
</View>
<Text style={styles.featureText}>
{t(`paywall.features.${feature.key}`)}
</Text>
</View>
))}
</View>
{/* Plan Selection */}
<View style={styles.plansContainer}>
<PlanCard
title={t('paywall.yearly')}
price={annualPrice}
period={t('paywall.perYear')}
savings={t('paywall.save50')}
isSelected={selectedPlan === 'annual'}
onPress={() => setSelectedPlan('annual')}
/>
<PlanCard
title={t('paywall.monthly')}
price={monthlyPrice}
period={t('paywall.perMonth')}
isSelected={selectedPlan === 'monthly'}
onPress={() => setSelectedPlan('monthly')}
/>
</View>
{/* Price Note */}
{selectedPlan === 'annual' && (
<Text style={styles.priceNote}>
{t('paywall.equivalent', { price: annualMonthlyEquivalent })}
</Text>
)}
{/* CTA Button */}
<Pressable
style={[styles.ctaButton, isLoading && styles.ctaButtonDisabled]}
onPress={handlePurchase}
disabled={isLoading}
>
<LinearGradient
colors={[BRAND.PRIMARY, '#FF8A5B']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.ctaGradient}
>
<Text style={styles.ctaText}>
{isLoading ? t('paywall.processing') : t('paywall.subscribe')}
</Text>
</LinearGradient>
</Pressable>
{/* Restore & Terms */}
<View style={styles.footer}>
<Pressable onPress={handleRestore}>
<Text style={styles.restoreText}>{t('paywall.restore')}</Text>
</Pressable>
<Text style={styles.termsText}>{t('paywall.terms')}</Text>
</View>
</ScrollView>
</View>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// STYLES
// ═══════════════════════════════════════════════════════════════════════════
const styles = StyleSheet.create({
container: {
flex: 1,
},
gradient: {
...StyleSheet.absoluteFillObject,
},
closeButton: {
position: 'absolute',
top: SPACING[4],
right: SPACING[4],
width: 44,
height: 44,
alignItems: 'center',
justifyContent: 'center',
zIndex: 10,
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingHorizontal: SPACING[5],
paddingTop: SPACING[8],
},
header: {
alignItems: 'center',
},
title: {
fontSize: 32,
fontWeight: '700',
color: '#FFF',
textAlign: 'center',
},
subtitle: {
fontSize: 16,
color: darkColors.text.secondary,
textAlign: 'center',
marginTop: SPACING[2],
},
featuresGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
marginTop: SPACING[6],
marginHorizontal: -SPACING[2],
},
featureItem: {
width: '33%',
alignItems: 'center',
paddingVertical: SPACING[3],
},
featureIcon: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: 'rgba(255, 107, 53, 0.15)',
alignItems: 'center',
justifyContent: 'center',
marginBottom: SPACING[2],
},
featureText: {
fontSize: 13,
color: darkColors.text.secondary,
textAlign: 'center',
},
plansContainer: {
marginTop: SPACING[6],
gap: SPACING[3],
},
planCard: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.08)',
borderRadius: RADIUS.LG,
padding: SPACING[4],
borderWidth: 2,
borderColor: 'transparent',
},
planCardSelected: {
borderColor: BRAND.PRIMARY,
backgroundColor: 'rgba(255, 107, 53, 0.1)',
},
planCardPressed: {
opacity: 0.8,
},
savingsBadge: {
position: 'absolute',
top: -8,
right: SPACING[3],
backgroundColor: BRAND.PRIMARY,
paddingHorizontal: SPACING[2],
paddingVertical: 2,
borderRadius: RADIUS.SM,
},
savingsText: {
fontSize: 10,
fontWeight: '700',
color: '#FFF',
},
planInfo: {
flex: 1,
},
planTitle: {
fontSize: 16,
fontWeight: '600',
color: darkColors.text.primary,
},
planPeriod: {
fontSize: 13,
color: darkColors.text.tertiary,
marginTop: 2,
},
planPrice: {
fontSize: 20,
fontWeight: '700',
color: BRAND.PRIMARY,
},
checkmark: {
marginLeft: SPACING[2],
},
priceNote: {
fontSize: 13,
color: darkColors.text.tertiary,
textAlign: 'center',
marginTop: SPACING[3],
},
ctaButton: {
borderRadius: RADIUS.LG,
overflow: 'hidden',
marginTop: SPACING[6],
},
ctaButtonDisabled: {
opacity: 0.6,
},
ctaGradient: {
paddingVertical: SPACING[4],
alignItems: 'center',
},
ctaText: {
fontSize: 17,
fontWeight: '600',
color: '#FFF',
},
footer: {
marginTop: SPACING[5],
alignItems: 'center',
gap: SPACING[4],
},
restoreText: {
fontSize: 14,
color: darkColors.text.tertiary,
},
termsText: {
fontSize: 11,
color: darkColors.text.tertiary,
textAlign: 'center',
lineHeight: 18,
paddingHorizontal: SPACING[4],
},
})

View File

@@ -29,9 +29,11 @@ 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 { useMusicPlayer } from '@/src/shared/hooks/useMusicPlayer'
import { useActivityStore } from '@/src/shared/stores'
import { getWorkoutById } from '@/src/shared/data'
import { useTranslatedWorkout } from '@/src/shared/data/useTranslatedData'
import { useWatchSync } from '@/src/features/watch'
import { track } from '@/src/shared/services/analytics'
import { BRAND, PHASE_COLORS, GRADIENTS, darkColors } from '@/src/shared/theme'
@@ -280,8 +282,37 @@ export default function PlayerScreen() {
const timer = useTimer(rawWorkout ?? null)
const audio = useAudio()
// Music player - synced with workout timer
useMusicPlayer({
vibe: workout?.musicVibe ?? 'electronic',
isPlaying: timer.isRunning && !timer.isPaused,
})
const [showControls, setShowControls] = useState(true)
// Watch sync integration
const { isAvailable: isWatchAvailable, sendWorkoutState } = useWatchSync({
onPlay: () => {
timer.resume()
track('watch_control_play', { workout_id: workout?.id ?? id })
},
onPause: () => {
timer.pause()
track('watch_control_pause', { workout_id: workout?.id ?? id })
},
onSkip: () => {
timer.skip()
haptics.selection()
track('watch_control_skip', { workout_id: workout?.id ?? id })
},
onStop: () => {
haptics.phaseChange()
timer.stop()
router.back()
track('watch_control_stop', { workout_id: workout?.id ?? id })
},
})
// Animation refs
const timerScaleAnim = useRef(new Animated.Value(0.8)).current
const phaseColor = PHASE_COLORS[timer.phase].fill
@@ -398,6 +429,34 @@ export default function PlayerScreen() {
}
}, [timer.timeRemaining])
// Sync workout state with Apple Watch
useEffect(() => {
if (!isWatchAvailable || !timer.isRunning) return;
sendWorkoutState({
phase: timer.phase,
timeRemaining: timer.timeRemaining,
currentRound: timer.currentRound,
totalRounds: timer.totalRounds,
currentExercise: timer.currentExercise,
nextExercise: timer.nextExercise,
calories: timer.calories,
isPaused: timer.isPaused,
isPlaying: timer.isRunning && !timer.isPaused,
});
}, [
timer.phase,
timer.timeRemaining,
timer.currentRound,
timer.totalRounds,
timer.currentExercise,
timer.nextExercise,
timer.calories,
timer.isPaused,
timer.isRunning,
isWatchAvailable,
]);
return (
<View style={styles.container}>
<StatusBar hidden />

212
app/privacy.tsx Normal file
View File

@@ -0,0 +1,212 @@
/**
* TabataFit Privacy Policy Screen
* Required for App Store submission
*/
import React from 'react'
import { View, ScrollView, StyleSheet, Text, Pressable } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import Ionicons from '@expo/vector-icons/Ionicons'
import { useTranslation } from 'react-i18next'
import { useHaptics } from '@/src/shared/hooks'
import { darkColors, BRAND } from '@/src/shared/theme'
import { SPACING } from '@/src/shared/constants/spacing'
export default function PrivacyPolicyScreen() {
const { t } = useTranslation('screens')
const router = useRouter()
const insets = useSafeAreaInsets()
const haptics = useHaptics()
const handleClose = () => {
haptics.selection()
router.back()
}
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
{/* Header */}
<View style={styles.header}>
<Pressable style={styles.backButton} onPress={handleClose}>
<Ionicons name="chevron-back" size={28} color={darkColors.text.primary} />
</Pressable>
<Text style={styles.headerTitle}>{t('privacy.title')}</Text>
<View style={{ width: 44 }} />
</View>
<ScrollView
style={styles.scrollView}
contentContainerStyle={[
styles.content,
{ paddingBottom: insets.bottom + 40 },
]}
showsVerticalScrollIndicator={false}
>
<Section title={t('privacy.lastUpdated')} />
<Section title={t('privacy.intro.title')}>
<Paragraph>{t('privacy.intro.content')}</Paragraph>
</Section>
<Section title={t('privacy.dataCollection.title')}>
<Paragraph>{t('privacy.dataCollection.content')}</Paragraph>
<BulletList
items={[
t('privacy.dataCollection.items.workouts'),
t('privacy.dataCollection.items.settings'),
t('privacy.dataCollection.items.device'),
]}
/>
</Section>
<Section title={t('privacy.usage.title')}>
<Paragraph>{t('privacy.usage.content')}</Paragraph>
</Section>
<Section title={t('privacy.sharing.title')}>
<Paragraph>{t('privacy.sharing.content')}</Paragraph>
</Section>
<Section title={t('privacy.security.title')}>
<Paragraph>{t('privacy.security.content')}</Paragraph>
</Section>
<Section title={t('privacy.rights.title')}>
<Paragraph>{t('privacy.rights.content')}</Paragraph>
</Section>
<Section title={t('privacy.contact.title')}>
<Paragraph>{t('privacy.contact.content')}</Paragraph>
<Text style={styles.email}>privacy@tabatafit.app</Text>
</Section>
<View style={styles.footer}>
<Text style={styles.footerText}>
TabataFit v1.0.0
</Text>
</View>
</ScrollView>
</View>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// HELPER COMPONENTS
// ═══════════════════════════════════════════════════════════════════════════
function Section({
title,
children,
}: {
title: string
children?: React.ReactNode
}) {
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{title}</Text>
{children}
</View>
)
}
function Paragraph({ children }: { children: string }) {
return <Text style={styles.paragraph}>{children}</Text>
}
function BulletList({ items }: { items: string[] }) {
return (
<View style={styles.bulletList}>
{items.map((item, index) => (
<View key={index} style={styles.bulletItem}>
<Text style={styles.bullet}></Text>
<Text style={styles.bulletText}>{item}</Text>
</View>
))}
</View>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// STYLES
// ═══════════════════════════════════════════════════════════════════════════
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: darkColors.bg.base,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: SPACING[4],
paddingVertical: SPACING[3],
borderBottomWidth: 1,
borderBottomColor: darkColors.border.glass,
},
backButton: {
width: 44,
height: 44,
alignItems: 'center',
justifyContent: 'center',
},
headerTitle: {
fontSize: 17,
fontWeight: '600',
color: darkColors.text.primary,
},
scrollView: {
flex: 1,
},
content: {
paddingHorizontal: SPACING[5],
paddingTop: SPACING[4],
},
section: {
marginBottom: SPACING[6],
},
sectionTitle: {
fontSize: 20,
fontWeight: '700',
color: darkColors.text.primary,
marginBottom: SPACING[3],
},
paragraph: {
fontSize: 15,
lineHeight: 22,
color: darkColors.text.secondary,
},
bulletList: {
marginTop: SPACING[3],
},
bulletItem: {
flexDirection: 'row',
marginBottom: SPACING[2],
},
bullet: {
fontSize: 15,
color: BRAND.PRIMARY,
marginRight: SPACING[2],
},
bulletText: {
flex: 1,
fontSize: 15,
lineHeight: 22,
color: darkColors.text.secondary,
},
email: {
fontSize: 15,
color: BRAND.PRIMARY,
marginTop: SPACING[2],
},
footer: {
marginTop: SPACING[8],
alignItems: 'center',
},
footerText: {
fontSize: 13,
color: darkColors.text.tertiary,
},
})

View File

@@ -1,22 +1,22 @@
/**
* TabataFit Pre-Workout Detail Screen
* Dynamic data via route params
* Clean modal with workout info
*/
import { useState, useEffect, useMemo } from 'react'
import { View, Text as RNText, StyleSheet, ScrollView, Pressable } from 'react-native'
import { useRouter, useLocalSearchParams } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { LinearGradient } from 'expo-linear-gradient'
import { BlurView } from 'expo-blur'
import Ionicons from '@expo/vector-icons/Ionicons'
import { useTranslation } from 'react-i18next'
import { Host, Button, HStack } from '@expo/ui/swift-ui'
import { glassEffect, padding } from '@expo/ui/swift-ui/modifiers'
import { useHaptics } from '@/src/shared/hooks'
import { track } from '@/src/shared/services/analytics'
import { getWorkoutById } from '@/src/shared/data'
import { useTranslatedWorkout, useMusicVibeLabel } from '@/src/shared/data/useTranslatedData'
import { VideoPlayer } from '@/src/shared/components/VideoPlayer'
import { useThemeColors, BRAND } from '@/src/shared/theme'
import type { ThemeColors } from '@/src/shared/theme/types'
@@ -56,7 +56,7 @@ export default function WorkoutDetailScreen() {
if (!workout) {
return (
<View style={[styles.container, { paddingTop: insets.top, alignItems: 'center', justifyContent: 'center' }]}>
<View style={[styles.container, styles.centered]}>
<RNText style={{ color: colors.text.primary, fontSize: 17 }}>{t('screens:workout.notFound')}</RNText>
</View>
)
@@ -67,11 +67,6 @@ export default function WorkoutDetailScreen() {
router.push(`/player/${workout.id}`)
}
const handleGoBack = () => {
haptics.selection()
router.back()
}
const toggleSave = () => {
haptics.selection()
setIsSaved(!isSaved)
@@ -80,81 +75,59 @@ export default function WorkoutDetailScreen() {
const repeatCount = Math.max(1, Math.floor(workout.rounds / workout.exercises.length))
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
<View style={styles.container}>
{/* Header with SwiftUI glass button */}
<View style={[styles.header, { paddingTop: insets.top + SPACING[3] }]}>
<RNText style={styles.headerTitle} numberOfLines={1}>
{workout.title}
</RNText>
{/* SwiftUI glass button */}
<View style={styles.glassButtonContainer}>
<Host matchContents useViewportSizeMeasurement colorScheme="dark">
<HStack
alignment="center"
modifiers={[
padding({ all: 8 }),
glassEffect({ glass: { variant: 'regular' } }),
]}
>
<Button
variant="borderless"
onPress={toggleSave}
color={isSaved ? '#FF3B30' : '#FFFFFF'}
>
{isSaved ? '♥' : '♡'}
</Button>
</HStack>
</Host>
</View>
</View>
{/* Content */}
<ScrollView
style={styles.scrollView}
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 100 }]}
showsVerticalScrollIndicator={false}
>
{/* Video Preview */}
<View style={styles.videoPreview}>
<VideoPlayer
videoUrl={workout.videoUrl}
gradientColors={[BRAND.PRIMARY, BRAND.PRIMARY_DARK]}
mode="preview"
style={StyleSheet.absoluteFill}
/>
<LinearGradient
colors={['rgba(0,0,0,0.3)', 'transparent', 'rgba(0,0,0,0.7)']}
style={StyleSheet.absoluteFill}
/>
{/* Header overlay — on video, keep white */}
<View style={styles.headerOverlay}>
<Pressable onPress={handleGoBack} style={styles.headerButton}>
<BlurView intensity={colors.glass.blurLight} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
<Ionicons name="chevron-back" size={24} color="#FFFFFF" />
</Pressable>
<View style={styles.headerRight}>
<Pressable onPress={toggleSave} style={styles.headerButton}>
<BlurView intensity={colors.glass.blurLight} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
<Ionicons
name={isSaved ? 'heart' : 'heart-outline'}
size={24}
color={isSaved ? '#FF3B30' : '#FFFFFF'}
/>
</Pressable>
<Pressable style={styles.headerButton}>
<BlurView intensity={colors.glass.blurLight} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
<Ionicons name="ellipsis-horizontal" size={24} color="#FFFFFF" />
</Pressable>
</View>
{/* Quick stats */}
<View style={styles.quickStats}>
<View style={[styles.statBadge, { backgroundColor: 'rgba(255, 107, 53, 0.15)' }]}>
<Ionicons name="barbell" size={14} color={BRAND.PRIMARY} />
<RNText style={[styles.statBadgeText, { color: BRAND.PRIMARY }]}>
{t(`levels.${workout.level.toLowerCase()}`)}
</RNText>
</View>
{/* Workout icon — on brand bg, keep white */}
<View style={styles.trainerPreview}>
<View style={[styles.trainerAvatarLarge, { backgroundColor: BRAND.PRIMARY }]}>
<Ionicons name="flame" size={36} color="#FFFFFF" />
</View>
<View style={[styles.statBadge, { backgroundColor: colors.bg.surface }]}>
<Ionicons name="time" size={14} color={colors.text.secondary} />
<RNText style={styles.statBadgeText}>{t('units.minUnit', { count: workout.duration })}</RNText>
</View>
<View style={[styles.statBadge, { backgroundColor: colors.bg.surface }]}>
<Ionicons name="flame" size={14} color={colors.text.secondary} />
<RNText style={styles.statBadgeText}>{t('units.calUnit', { count: workout.calories })}</RNText>
</View>
</View>
{/* Title Section */}
<View style={styles.titleSection}>
<RNText style={styles.title}>{workout.title}</RNText>
{/* Quick stats */}
<View style={styles.quickStats}>
<View style={styles.statItem}>
<Ionicons name="barbell" size={16} color={BRAND.PRIMARY} />
<RNText style={styles.statText}>{t(`levels.${workout.level.toLowerCase()}`)}</RNText>
</View>
<RNText style={styles.statDot}></RNText>
<View style={styles.statItem}>
<Ionicons name="time" size={16} color={BRAND.PRIMARY} />
<RNText style={styles.statText}>{t('units.minUnit', { count: workout.duration })}</RNText>
</View>
<RNText style={styles.statDot}></RNText>
<View style={styles.statItem}>
<Ionicons name="flame" size={16} color={BRAND.PRIMARY} />
<RNText style={styles.statText}>{t('units.calUnit', { count: workout.calories })}</RNText>
</View>
</View>
</View>
<View style={styles.divider} />
{/* Equipment */}
<View style={styles.section}>
<RNText style={styles.sectionTitle}>{t('screens:workout.whatYoullNeed')}</RNText>
@@ -178,7 +151,7 @@ export default function WorkoutDetailScreen() {
<RNText style={styles.exerciseNumberText}>{index + 1}</RNText>
</View>
<RNText style={styles.exerciseName}>{exercise.name}</RNText>
<RNText style={styles.exerciseDuration}>{exercise.duration}s work</RNText>
<RNText style={styles.exerciseDuration}>{exercise.duration}s</RNText>
</View>
))}
<View style={styles.repeatNote}>
@@ -207,18 +180,16 @@ export default function WorkoutDetailScreen() {
{/* Fixed Start Button */}
<View style={[styles.bottomBar, { paddingBottom: insets.bottom + SPACING[4] }]}>
<BlurView intensity={colors.glass.blurHeavy} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
<View style={styles.startButtonContainer}>
<Pressable
style={({ pressed }) => [
styles.startButton,
pressed && styles.startButtonPressed,
]}
onPress={handleStartWorkout}
>
<RNText style={styles.startButtonText}>{t('screens:workout.startWorkout')}</RNText>
</Pressable>
</View>
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
<Pressable
style={({ pressed }) => [
styles.startButton,
pressed && styles.startButtonPressed,
]}
onPress={handleStartWorkout}
>
<RNText style={styles.startButtonText}>{t('screens:workout.startWorkout')}</RNText>
</Pressable>
</View>
</View>
)
@@ -234,6 +205,10 @@ function createStyles(colors: ThemeColors) {
flex: 1,
backgroundColor: colors.bg.base,
},
centered: {
alignItems: 'center',
justifyContent: 'center',
},
scrollView: {
flex: 1,
},
@@ -241,80 +216,53 @@ function createStyles(colors: ThemeColors) {
paddingHorizontal: LAYOUT.SCREEN_PADDING,
},
// Video Preview
videoPreview: {
height: 280,
marginHorizontal: -LAYOUT.SCREEN_PADDING,
marginBottom: SPACING[4],
backgroundColor: colors.bg.surface,
},
headerOverlay: {
// Header
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: SPACING[4],
justifyContent: 'space-between',
paddingHorizontal: SPACING[4],
paddingBottom: SPACING[3],
},
headerButton: {
headerTitle: {
flex: 1,
...TYPOGRAPHY.HEADLINE,
color: colors.text.primary,
marginRight: SPACING[3],
},
glassButtonContainer: {
width: 44,
height: 44,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
},
headerRight: {
flexDirection: 'row',
gap: SPACING[2],
},
trainerPreview: {
position: 'absolute',
bottom: SPACING[4],
left: 0,
right: 0,
alignItems: 'center',
},
trainerAvatarLarge: {
width: 80,
height: 80,
borderRadius: 40,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 3,
borderColor: '#FFFFFF',
},
trainerInitial: {
...TYPOGRAPHY.HERO,
color: '#FFFFFF',
},
// Title Section
titleSection: {
marginBottom: SPACING[4],
},
title: {
...TYPOGRAPHY.LARGE_TITLE,
color: colors.text.primary,
marginBottom: SPACING[3],
},
// Quick Stats
quickStats: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
gap: SPACING[2],
marginBottom: SPACING[5],
},
statItem: {
statBadge: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: SPACING[3],
paddingVertical: SPACING[2],
borderRadius: RADIUS.FULL,
gap: SPACING[1],
},
statText: {
...TYPOGRAPHY.BODY,
statBadgeText: {
...TYPOGRAPHY.CAPTION_1,
color: colors.text.secondary,
fontWeight: '600',
},
statDot: {
color: colors.text.tertiary,
// Section
section: {
paddingVertical: SPACING[3],
},
sectionTitle: {
...TYPOGRAPHY.HEADLINE,
color: colors.text.primary,
marginBottom: SPACING[3],
},
// Divider
@@ -324,16 +272,6 @@ function createStyles(colors: ThemeColors) {
marginVertical: SPACING[2],
},
// Section
section: {
paddingVertical: SPACING[4],
},
sectionTitle: {
...TYPOGRAPHY.HEADLINE,
color: colors.text.primary,
marginBottom: SPACING[3],
},
// Equipment
equipmentItem: {
flexDirection: 'row',
@@ -434,10 +372,6 @@ function createStyles(colors: ThemeColors) {
borderTopWidth: 1,
borderTopColor: colors.border.glass,
},
startButtonContainer: {
height: 56,
justifyContent: 'center',
},
// Start Button
startButton: {