- Add access control service with 3 free workouts (IDs 1, 11, 43), paywall gating on workout detail and lock indicators on explore grid - Wire VideoPlayer into player background and workout detail preview - Add placeholder HLS video URLs to 5 sample workouts (Apple test streams) - Add test audio URLs to music service MOCK_TRACKS for all vibes - Switch RevenueCat API key to env-based with sandbox fallback - Create eas.json with development/preview/production build profiles - Update app.json with iOS privacy descriptions (HealthKit, Camera) and non-exempt encryption flag - Create collection detail screen (route crash fix) - Replace hardcoded profile stats with real activity store data - Add unlockWithPremium i18n key in EN/FR/DE/ES
487 lines
19 KiB
TypeScript
487 lines
19 KiB
TypeScript
/**
|
||
* TabataFit Profile Screen — Premium React Native
|
||
* Apple Fitness+ inspired design, pure React Native components
|
||
*/
|
||
|
||
import { useRouter } from 'expo-router'
|
||
import {
|
||
View,
|
||
ScrollView,
|
||
StyleSheet,
|
||
TouchableOpacity,
|
||
Switch,
|
||
Text as RNText,
|
||
TextStyle,
|
||
} from 'react-native'
|
||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||
import * as Linking from 'expo-linking'
|
||
import Constants from 'expo-constants'
|
||
import { useTranslation } from 'react-i18next'
|
||
import { useMemo, useState } from 'react'
|
||
import { useUserStore, useActivityStore } from '@/src/shared/stores'
|
||
import { requestNotificationPermissions, usePurchases } from '@/src/shared/hooks'
|
||
import { useThemeColors, BRAND } from '@/src/shared/theme'
|
||
import type { ThemeColors } from '@/src/shared/theme/types'
|
||
import { DataDeletionModal } from '@/src/shared/components/DataDeletionModal'
|
||
import { deleteSyncedData } from '@/src/shared/services/sync'
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// STYLED TEXT COMPONENT
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
interface TextProps {
|
||
children: React.ReactNode
|
||
style?: TextStyle
|
||
size?: number
|
||
weight?: 'normal' | 'bold' | '600' | '700' | '800' | '900'
|
||
color?: string
|
||
center?: boolean
|
||
}
|
||
|
||
function Text({ children, style, size, weight, color, center }: TextProps) {
|
||
const colors = useThemeColors()
|
||
return (
|
||
<RNText
|
||
style={[
|
||
{
|
||
fontSize: size ?? 17,
|
||
fontWeight: weight ?? 'normal',
|
||
color: color ?? colors.text.primary,
|
||
textAlign: center ? 'center' : 'left',
|
||
},
|
||
style,
|
||
]}
|
||
>
|
||
{children}
|
||
</RNText>
|
||
)
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// COMPONENT: PROFILE SCREEN
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
export default function ProfileScreen() {
|
||
const { t } = useTranslation('screens')
|
||
const router = useRouter()
|
||
const insets = useSafeAreaInsets()
|
||
const colors = useThemeColors()
|
||
const styles = useMemo(() => createStyles(colors), [colors])
|
||
const profile = useUserStore((s) => s.profile)
|
||
const settings = useUserStore((s) => s.settings)
|
||
const updateSettings = useUserStore((s) => s.updateSettings)
|
||
const updateProfile = useUserStore((s) => s.updateProfile)
|
||
const setSyncStatus = useUserStore((s) => s.setSyncStatus)
|
||
const { restorePurchases, isPremium } = usePurchases()
|
||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||
|
||
const planLabel = isPremium ? 'TabataFit+' : t('profile.freePlan')
|
||
const avatarInitial = profile.name?.[0]?.toUpperCase() || 'U'
|
||
|
||
// Real stats from activity store
|
||
const history = useActivityStore((s) => s.history)
|
||
const streak = useActivityStore((s) => s.streak)
|
||
const stats = useMemo(() => ({
|
||
workouts: history.length,
|
||
streak: streak.current,
|
||
calories: history.reduce((sum, r) => sum + (r.calories ?? 0), 0),
|
||
}), [history, streak])
|
||
|
||
const handleSignOut = () => {
|
||
updateProfile({
|
||
name: '',
|
||
email: '',
|
||
subscription: 'free',
|
||
onboardingCompleted: false,
|
||
})
|
||
router.replace('/onboarding')
|
||
}
|
||
|
||
const handleRestore = async () => {
|
||
await restorePurchases()
|
||
}
|
||
|
||
const handleDeleteData = async () => {
|
||
const result = await deleteSyncedData()
|
||
if (result.success) {
|
||
setSyncStatus('unsynced', null)
|
||
setShowDeleteModal(false)
|
||
}
|
||
}
|
||
|
||
const handleReminderToggle = async (enabled: boolean) => {
|
||
if (enabled) {
|
||
const granted = await requestNotificationPermissions()
|
||
if (!granted) return
|
||
}
|
||
updateSettings({ reminders: enabled })
|
||
}
|
||
|
||
const handleRateApp = () => {
|
||
Linking.openURL('https://apps.apple.com/app/tabatafit/id1234567890')
|
||
}
|
||
|
||
const handleContactUs = () => {
|
||
Linking.openURL('mailto:contact@tabatafit.app')
|
||
}
|
||
|
||
const handlePrivacyPolicy = () => {
|
||
router.push('/privacy')
|
||
}
|
||
|
||
const handleFAQ = () => {
|
||
Linking.openURL('https://tabatafit.app/faq')
|
||
}
|
||
|
||
// App version
|
||
const appVersion = Constants.expoConfig?.version ?? '1.0.0'
|
||
|
||
return (
|
||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||
<ScrollView
|
||
style={styles.scrollView}
|
||
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 100 }]}
|
||
showsVerticalScrollIndicator={false}
|
||
>
|
||
{/* ════════════════════════════════════════════════════════════════════
|
||
PROFILE HEADER CARD
|
||
═══════════════════════════════════════════════════════════════════ */}
|
||
<View style={styles.section}>
|
||
<View style={styles.headerContainer}>
|
||
{/* Avatar with gradient background */}
|
||
<View style={styles.avatarContainer}>
|
||
<Text size={48} weight="bold" color="#FFFFFF">
|
||
{avatarInitial}
|
||
</Text>
|
||
</View>
|
||
|
||
{/* Name & Plan */}
|
||
<View style={styles.nameContainer}>
|
||
<Text size={22} weight="600" center>
|
||
{profile.name || t('profile.guest')}
|
||
</Text>
|
||
<View style={styles.planContainer}>
|
||
<Text size={15} color={isPremium ? BRAND.PRIMARY : colors.text.tertiary}>
|
||
{planLabel}
|
||
</Text>
|
||
{isPremium && (
|
||
<Text size={12} color={BRAND.PRIMARY}>
|
||
✓
|
||
</Text>
|
||
)}
|
||
</View>
|
||
</View>
|
||
|
||
{/* Stats Row */}
|
||
<View style={styles.statsContainer}>
|
||
<View style={styles.statItem}>
|
||
<Text size={20} weight="bold" color={BRAND.PRIMARY} center>
|
||
🔥 {stats.workouts}
|
||
</Text>
|
||
<Text size={12} color={colors.text.tertiary} center>
|
||
{t('profile.statsWorkouts')}
|
||
</Text>
|
||
</View>
|
||
<View style={styles.statItem}>
|
||
<Text size={20} weight="bold" color={BRAND.PRIMARY} center>
|
||
📅 {stats.streak}
|
||
</Text>
|
||
<Text size={12} color={colors.text.tertiary} center>
|
||
{t('profile.statsStreak')}
|
||
</Text>
|
||
</View>
|
||
<View style={styles.statItem}>
|
||
<Text size={20} weight="bold" color={BRAND.PRIMARY} center>
|
||
⚡️ {Math.round(stats.calories / 1000)}k
|
||
</Text>
|
||
<Text size={12} color={colors.text.tertiary} center>
|
||
{t('profile.statsCalories')}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
|
||
{/* ════════════════════════════════════════════════════════════════════
|
||
UPGRADE CTA (FREE USERS ONLY)
|
||
═══════════════════════════════════════════════════════════════════ */}
|
||
{!isPremium && (
|
||
<View style={styles.section}>
|
||
<TouchableOpacity
|
||
style={styles.premiumContainer}
|
||
onPress={() => router.push('/paywall')}
|
||
>
|
||
<View style={styles.premiumContent}>
|
||
<Text size={17} weight="600" color={BRAND.PRIMARY}>
|
||
✨ {t('profile.upgradeTitle')}
|
||
</Text>
|
||
<Text size={15} color={colors.text.tertiary} style={{ marginTop: 4 }}>
|
||
{t('profile.upgradeDescription')}
|
||
</Text>
|
||
</View>
|
||
<Text size={15} color={BRAND.PRIMARY} style={{ marginTop: 12 }}>
|
||
{t('profile.learnMore')} →
|
||
</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
)}
|
||
|
||
{/* ════════════════════════════════════════════════════════════════════
|
||
WORKOUT SETTINGS
|
||
═══════════════════════════════════════════════════════════════════ */}
|
||
<Text style={styles.sectionHeader}>{t('profile.sectionWorkout')}</Text>
|
||
<View style={styles.section}>
|
||
<View style={styles.row}>
|
||
<Text style={styles.rowLabel}>{t('profile.hapticFeedback')}</Text>
|
||
<Switch
|
||
value={settings.haptics}
|
||
onValueChange={(v) => updateSettings({ haptics: v })}
|
||
trackColor={{ false: colors.bg.overlay1, true: BRAND.PRIMARY }}
|
||
thumbColor="#FFFFFF"
|
||
/>
|
||
</View>
|
||
<View style={styles.row}>
|
||
<Text style={styles.rowLabel}>{t('profile.soundEffects')}</Text>
|
||
<Switch
|
||
value={settings.soundEffects}
|
||
onValueChange={(v) => updateSettings({ soundEffects: v })}
|
||
trackColor={{ false: colors.bg.overlay1, true: BRAND.PRIMARY }}
|
||
thumbColor="#FFFFFF"
|
||
/>
|
||
</View>
|
||
<View style={[styles.row, styles.rowLast]}>
|
||
<Text style={styles.rowLabel}>{t('profile.voiceCoaching')}</Text>
|
||
<Switch
|
||
value={settings.voiceCoaching}
|
||
onValueChange={(v) => updateSettings({ voiceCoaching: v })}
|
||
trackColor={{ false: colors.bg.overlay1, true: BRAND.PRIMARY }}
|
||
thumbColor="#FFFFFF"
|
||
/>
|
||
</View>
|
||
</View>
|
||
|
||
{/* ════════════════════════════════════════════════════════════════════
|
||
NOTIFICATIONS
|
||
═══════════════════════════════════════════════════════════════════ */}
|
||
<Text style={styles.sectionHeader}>{t('profile.sectionNotifications')}</Text>
|
||
<View style={styles.section}>
|
||
<View style={styles.row}>
|
||
<Text style={styles.rowLabel}>{t('profile.dailyReminders')}</Text>
|
||
<Switch
|
||
value={settings.reminders}
|
||
onValueChange={handleReminderToggle}
|
||
trackColor={{ false: colors.bg.overlay1, true: BRAND.PRIMARY }}
|
||
thumbColor="#FFFFFF"
|
||
/>
|
||
</View>
|
||
{settings.reminders && (
|
||
<View style={styles.rowTime}>
|
||
<Text style={styles.rowLabel}>{t('profile.reminderTime')}</Text>
|
||
<Text style={styles.rowValue}>{settings.reminderTime}</Text>
|
||
</View>
|
||
)}
|
||
</View>
|
||
|
||
{/* ════════════════════════════════════════════════════════════════════
|
||
PERSONALIZATION (PREMIUM ONLY)
|
||
═══════════════════════════════════════════════════════════════════ */}
|
||
{isPremium && (
|
||
<>
|
||
<Text style={styles.sectionHeader}>Personalization</Text>
|
||
<View style={styles.section}>
|
||
<View style={[styles.row, styles.rowLast]}>
|
||
<Text style={styles.rowLabel}>
|
||
{profile.syncStatus === 'synced' ? 'Personalization Enabled' : 'Generic Programs'}
|
||
</Text>
|
||
<Text
|
||
size={14}
|
||
color={profile.syncStatus === 'synced' ? BRAND.SUCCESS : colors.text.tertiary}
|
||
>
|
||
{profile.syncStatus === 'synced' ? '✓' : '○'}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
</>
|
||
)}
|
||
|
||
{/* ════════════════════════════════════════════════════════════════════
|
||
ABOUT
|
||
═══════════════════════════════════════════════════════════════════ */}
|
||
<Text style={styles.sectionHeader}>{t('profile.sectionAbout')}</Text>
|
||
<View style={styles.section}>
|
||
<View style={styles.row}>
|
||
<Text style={styles.rowLabel}>{t('profile.version')}</Text>
|
||
<Text style={styles.rowValue}>{appVersion}</Text>
|
||
</View>
|
||
<TouchableOpacity style={styles.row} onPress={handleRateApp}>
|
||
<Text style={styles.rowLabel}>{t('profile.rateApp')}</Text>
|
||
<Text style={styles.rowValue}>›</Text>
|
||
</TouchableOpacity>
|
||
<TouchableOpacity style={styles.row} onPress={handleContactUs}>
|
||
<Text style={styles.rowLabel}>{t('profile.contactUs')}</Text>
|
||
<Text style={styles.rowValue}>›</Text>
|
||
</TouchableOpacity>
|
||
<TouchableOpacity style={styles.row} onPress={handleFAQ}>
|
||
<Text style={styles.rowLabel}>{t('profile.faq')}</Text>
|
||
<Text style={styles.rowValue}>›</Text>
|
||
</TouchableOpacity>
|
||
<TouchableOpacity style={[styles.row, styles.rowLast]} onPress={handlePrivacyPolicy}>
|
||
<Text style={styles.rowLabel}>{t('profile.privacyPolicy')}</Text>
|
||
<Text style={styles.rowValue}>›</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
{/* ════════════════════════════════════════════════════════════════════
|
||
ACCOUNT (PREMIUM USERS ONLY)
|
||
═══════════════════════════════════════════════════════════════════ */}
|
||
{isPremium && (
|
||
<>
|
||
<Text style={styles.sectionHeader}>{t('profile.sectionAccount')}</Text>
|
||
<View style={styles.section}>
|
||
<TouchableOpacity style={[styles.row, styles.rowLast]} onPress={handleRestore}>
|
||
<Text style={styles.rowLabel}>{t('profile.restorePurchases')}</Text>
|
||
<Text style={styles.rowValue}>›</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</>
|
||
)}
|
||
|
||
{/* ════════════════════════════════════════════════════════════════════
|
||
SIGN OUT
|
||
═══════════════════════════════════════════════════════════════════ */}
|
||
<View style={[styles.section, styles.signOutSection]}>
|
||
<TouchableOpacity style={styles.button} onPress={handleSignOut}>
|
||
<Text style={styles.destructive}>{t('profile.signOut')}</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</ScrollView>
|
||
|
||
{/* Data Deletion Modal */}
|
||
<DataDeletionModal
|
||
visible={showDeleteModal}
|
||
onDelete={handleDeleteData}
|
||
onCancel={() => setShowDeleteModal(false)}
|
||
/>
|
||
</View>
|
||
)
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// STYLES
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
function createStyles(colors: ThemeColors) {
|
||
return StyleSheet.create({
|
||
container: {
|
||
flex: 1,
|
||
backgroundColor: colors.bg.base,
|
||
},
|
||
scrollView: {
|
||
flex: 1,
|
||
},
|
||
scrollContent: {
|
||
flexGrow: 1,
|
||
},
|
||
section: {
|
||
marginHorizontal: 16,
|
||
marginTop: 20,
|
||
backgroundColor: colors.bg.surface,
|
||
borderRadius: 10,
|
||
overflow: 'hidden',
|
||
},
|
||
sectionHeader: {
|
||
fontSize: 13,
|
||
fontWeight: '600',
|
||
color: colors.text.tertiary,
|
||
textTransform: 'uppercase',
|
||
marginLeft: 32,
|
||
marginTop: 20,
|
||
marginBottom: 8,
|
||
},
|
||
headerContainer: {
|
||
alignItems: 'center',
|
||
paddingVertical: 24,
|
||
paddingHorizontal: 16,
|
||
},
|
||
avatarContainer: {
|
||
width: 90,
|
||
height: 90,
|
||
borderRadius: 45,
|
||
backgroundColor: BRAND.PRIMARY,
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
shadowColor: BRAND.PRIMARY,
|
||
shadowOffset: { width: 0, height: 4 },
|
||
shadowOpacity: 0.5,
|
||
shadowRadius: 20,
|
||
elevation: 10,
|
||
},
|
||
nameContainer: {
|
||
marginTop: 16,
|
||
alignItems: 'center',
|
||
},
|
||
planContainer: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
marginTop: 4,
|
||
gap: 4,
|
||
},
|
||
statsContainer: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'center',
|
||
marginTop: 16,
|
||
gap: 32,
|
||
},
|
||
statItem: {
|
||
alignItems: 'center',
|
||
},
|
||
premiumContainer: {
|
||
paddingVertical: 16,
|
||
paddingHorizontal: 16,
|
||
},
|
||
premiumContent: {
|
||
gap: 4,
|
||
},
|
||
row: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
paddingVertical: 12,
|
||
paddingHorizontal: 16,
|
||
borderBottomWidth: 0.5,
|
||
borderBottomColor: colors.border.glassLight,
|
||
},
|
||
rowLast: {
|
||
borderBottomWidth: 0,
|
||
},
|
||
rowLabel: {
|
||
fontSize: 17,
|
||
color: colors.text.primary,
|
||
},
|
||
rowValue: {
|
||
fontSize: 17,
|
||
color: colors.text.tertiary,
|
||
},
|
||
rowTime: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
paddingVertical: 12,
|
||
paddingHorizontal: 16,
|
||
borderTopWidth: 0.5,
|
||
borderTopColor: colors.border.glassLight,
|
||
},
|
||
button: {
|
||
paddingVertical: 14,
|
||
alignItems: 'center',
|
||
},
|
||
destructive: {
|
||
fontSize: 17,
|
||
color: BRAND.DANGER,
|
||
},
|
||
signOutSection: {
|
||
marginTop: 20,
|
||
},
|
||
})
|
||
}
|