Files
tabatago/app/(tabs)/profile.tsx
Millian Lamiaux cd065d07c3 feat: explore tab, React Query data layer, programs, sync, analytics, testing infrastructure
- Replace browse tab with Supabase-connected explore tab with filters
- Add React Query for data fetching with loading states
- Add 3 structured programs with weekly progression
- Add Supabase anonymous auth sync service
- Add PostHog analytics with screen tracking and events
- Add comprehensive test strategy (Vitest + Maestro E2E)
- Add RevenueCat subscription system with DEV simulation
- Add i18n translations for new screens (EN/FR/DE/ES)
- Add data deletion modal, sync consent modal
- Add assessment screen and program routes
- Add GitHub Actions CI workflow
- Update activity store with sync integration
2026-03-24 12:04:48 +01:00

485 lines
19 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 } 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'
// Mock stats (replace with real data from activityStore when available)
const stats = {
workouts: 47,
streak: 12,
calories: 12500,
}
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,
},
})
}