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:
@@ -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>
|
||||
@@ -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],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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
35
app/admin/_layout.tsx
Normal 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
201
app/admin/collections.tsx
Normal 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
212
app/admin/index.tsx
Normal 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
124
app/admin/login.tsx
Normal 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
201
app/admin/media.tsx
Normal 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
194
app/admin/trainers.tsx
Normal 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
190
app/admin/workouts.tsx
Normal 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',
|
||||
},
|
||||
})
|
||||
@@ -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
409
app/paywall.tsx
Normal 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],
|
||||
},
|
||||
})
|
||||
@@ -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
212
app/privacy.tsx
Normal 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,
|
||||
},
|
||||
})
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user