Files
tabatago/app/(tabs)/profile.tsx
Millian Lamiaux b833198e9d feat: migrate icons to SF Symbols, refactor explore tab, add collections/programs data layer
- Replace all Ionicons with native SF Symbols via expo-symbols SymbolView
- Create reusable Icon wrapper component (src/shared/components/Icon.tsx)
- Remove @expo/vector-icons and lucide-react dependencies
- Refactor explore tab with filters, search, and category browsing
- Add collections and programs data with Supabase integration
- Add explore filter store and filter sheet
- Update i18n strings (en, de, es, fr) for new explore features
- Update test mocks and remove stale snapshots
- Add user fitness level to user store and types
2026-03-25 23:28:51 +01:00

452 lines
18 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,
Pressable,
Switch,
} 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 { StyledText } from '@/src/shared/components/StyledText'
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { DataDeletionModal } from '@/src/shared/components/DataDeletionModal'
import { deleteSyncedData } from '@/src/shared/services/sync'
// ═══════════════════════════════════════════════════════════════════════════
// 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}>
<StyledText size={48} weight="bold" color="#FFFFFF">
{avatarInitial}
</StyledText>
</View>
{/* Name & Plan */}
<View style={styles.nameContainer}>
<StyledText size={22} weight="semibold" style={{ textAlign: 'center' }}>
{profile.name || t('profile.guest')}
</StyledText>
<View style={styles.planContainer}>
<StyledText size={15} color={isPremium ? BRAND.PRIMARY : colors.text.tertiary}>
{planLabel}
</StyledText>
{isPremium && (
<StyledText size={12} color={BRAND.PRIMARY}>
</StyledText>
)}
</View>
</View>
{/* Stats Row */}
<View style={styles.statsContainer}>
<View style={styles.statItem}>
<StyledText size={20} weight="bold" color={BRAND.PRIMARY} style={{ textAlign: 'center' }}>
🔥 {stats.workouts}
</StyledText>
<StyledText size={12} color={colors.text.tertiary} style={{ textAlign: 'center' }}>
{t('profile.statsWorkouts')}
</StyledText>
</View>
<View style={styles.statItem}>
<StyledText size={20} weight="bold" color={BRAND.PRIMARY} style={{ textAlign: 'center' }}>
📅 {stats.streak}
</StyledText>
<StyledText size={12} color={colors.text.tertiary} style={{ textAlign: 'center' }}>
{t('profile.statsStreak')}
</StyledText>
</View>
<View style={styles.statItem}>
<StyledText size={20} weight="bold" color={BRAND.PRIMARY} style={{ textAlign: 'center' }}>
{Math.round(stats.calories / 1000)}k
</StyledText>
<StyledText size={12} color={colors.text.tertiary} style={{ textAlign: 'center' }}>
{t('profile.statsCalories')}
</StyledText>
</View>
</View>
</View>
</View>
{/* ════════════════════════════════════════════════════════════════════
UPGRADE CTA (FREE USERS ONLY)
═══════════════════════════════════════════════════════════════════ */}
{!isPremium && (
<View style={styles.section}>
<Pressable
style={styles.premiumContainer}
onPress={() => router.push('/paywall')}
>
<View style={styles.premiumContent}>
<StyledText size={17} weight="semibold" color={BRAND.PRIMARY}>
{t('profile.upgradeTitle')}
</StyledText>
<StyledText size={15} color={colors.text.tertiary} style={{ marginTop: SPACING[1] }}>
{t('profile.upgradeDescription')}
</StyledText>
</View>
<StyledText size={15} color={BRAND.PRIMARY} style={{ marginTop: SPACING[3] }}>
{t('profile.learnMore')}
</StyledText>
</Pressable>
</View>
)}
{/* ════════════════════════════════════════════════════════════════════
WORKOUT SETTINGS
═══════════════════════════════════════════════════════════════════ */}
<StyledText style={styles.sectionHeader}>{t('profile.sectionWorkout')}</StyledText>
<View style={styles.section}>
<View style={styles.row}>
<StyledText style={styles.rowLabel}>{t('profile.hapticFeedback')}</StyledText>
<Switch
value={settings.haptics}
onValueChange={(v) => updateSettings({ haptics: v })}
trackColor={{ false: colors.bg.overlay1, true: BRAND.PRIMARY }}
thumbColor="#FFFFFF"
/>
</View>
<View style={styles.row}>
<StyledText style={styles.rowLabel}>{t('profile.soundEffects')}</StyledText>
<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]}>
<StyledText style={styles.rowLabel}>{t('profile.voiceCoaching')}</StyledText>
<Switch
value={settings.voiceCoaching}
onValueChange={(v) => updateSettings({ voiceCoaching: v })}
trackColor={{ false: colors.bg.overlay1, true: BRAND.PRIMARY }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* ════════════════════════════════════════════════════════════════════
NOTIFICATIONS
═══════════════════════════════════════════════════════════════════ */}
<StyledText style={styles.sectionHeader}>{t('profile.sectionNotifications')}</StyledText>
<View style={styles.section}>
<View style={styles.row}>
<StyledText style={styles.rowLabel}>{t('profile.dailyReminders')}</StyledText>
<Switch
value={settings.reminders}
onValueChange={handleReminderToggle}
trackColor={{ false: colors.bg.overlay1, true: BRAND.PRIMARY }}
thumbColor="#FFFFFF"
/>
</View>
{settings.reminders && (
<View style={styles.rowTime}>
<StyledText style={styles.rowLabel}>{t('profile.reminderTime')}</StyledText>
<StyledText style={styles.rowValue}>{settings.reminderTime}</StyledText>
</View>
)}
</View>
{/* ════════════════════════════════════════════════════════════════════
PERSONALIZATION (PREMIUM ONLY)
═══════════════════════════════════════════════════════════════════ */}
{isPremium && (
<>
<StyledText style={styles.sectionHeader}>{t('profile.sectionPersonalization')}</StyledText>
<View style={styles.section}>
<View style={[styles.row, styles.rowLast]}>
<StyledText style={styles.rowLabel}>
{profile.syncStatus === 'synced' ? t('profile.personalizationEnabled') : t('profile.personalizationDisabled')}
</StyledText>
<StyledText
size={14}
color={profile.syncStatus === 'synced' ? BRAND.SUCCESS : colors.text.tertiary}
>
{profile.syncStatus === 'synced' ? '✓' : '○'}
</StyledText>
</View>
</View>
</>
)}
{/* ════════════════════════════════════════════════════════════════════
ABOUT
═══════════════════════════════════════════════════════════════════ */}
<StyledText style={styles.sectionHeader}>{t('profile.sectionAbout')}</StyledText>
<View style={styles.section}>
<View style={styles.row}>
<StyledText style={styles.rowLabel}>{t('profile.version')}</StyledText>
<StyledText style={styles.rowValue}>{appVersion}</StyledText>
</View>
<Pressable style={styles.row} onPress={handleRateApp}>
<StyledText style={styles.rowLabel}>{t('profile.rateApp')}</StyledText>
<StyledText style={styles.rowValue}></StyledText>
</Pressable>
<Pressable style={styles.row} onPress={handleContactUs}>
<StyledText style={styles.rowLabel}>{t('profile.contactUs')}</StyledText>
<StyledText style={styles.rowValue}></StyledText>
</Pressable>
<Pressable style={styles.row} onPress={handleFAQ}>
<StyledText style={styles.rowLabel}>{t('profile.faq')}</StyledText>
<StyledText style={styles.rowValue}></StyledText>
</Pressable>
<Pressable style={[styles.row, styles.rowLast]} onPress={handlePrivacyPolicy}>
<StyledText style={styles.rowLabel}>{t('profile.privacyPolicy')}</StyledText>
<StyledText style={styles.rowValue}></StyledText>
</Pressable>
</View>
{/* ════════════════════════════════════════════════════════════════════
ACCOUNT (PREMIUM USERS ONLY)
═══════════════════════════════════════════════════════════════════ */}
{isPremium && (
<>
<StyledText style={styles.sectionHeader}>{t('profile.sectionAccount')}</StyledText>
<View style={styles.section}>
<Pressable style={[styles.row, styles.rowLast]} onPress={handleRestore}>
<StyledText style={styles.rowLabel}>{t('profile.restorePurchases')}</StyledText>
<StyledText style={styles.rowValue}></StyledText>
</Pressable>
</View>
</>
)}
{/* ════════════════════════════════════════════════════════════════════
SIGN OUT
═══════════════════════════════════════════════════════════════════ */}
<View style={[styles.section, styles.signOutSection]}>
<Pressable style={styles.button} onPress={handleSignOut}>
<StyledText style={styles.destructive}>{t('profile.signOut')}</StyledText>
</Pressable>
</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: SPACING[4],
marginTop: SPACING[5],
backgroundColor: colors.bg.surface,
borderRadius: RADIUS.MD,
overflow: 'hidden',
},
sectionHeader: {
fontSize: 13,
fontWeight: '600',
color: colors.text.tertiary,
textTransform: 'uppercase',
marginLeft: SPACING[8],
marginTop: SPACING[5],
marginBottom: SPACING[2],
},
headerContainer: {
alignItems: 'center',
paddingVertical: SPACING[6],
paddingHorizontal: SPACING[4],
},
avatarContainer: {
width: 90,
height: 90,
borderRadius: 45,
backgroundColor: BRAND.PRIMARY,
justifyContent: 'center',
alignItems: 'center',
boxShadow: `0 4px 20px ${BRAND.PRIMARY}80`,
},
nameContainer: {
marginTop: SPACING[4],
alignItems: 'center',
},
planContainer: {
flexDirection: 'row',
alignItems: 'center',
marginTop: SPACING[1],
gap: SPACING[1],
},
statsContainer: {
flexDirection: 'row',
justifyContent: 'center',
marginTop: SPACING[4],
gap: SPACING[8],
},
statItem: {
alignItems: 'center',
},
premiumContainer: {
paddingVertical: SPACING[4],
paddingHorizontal: SPACING[4],
},
premiumContent: {
gap: SPACING[1],
},
row: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: SPACING[3],
paddingHorizontal: SPACING[4],
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: SPACING[3],
paddingHorizontal: SPACING[4],
borderTopWidth: 0.5,
borderTopColor: colors.border.glassLight,
},
button: {
paddingVertical: SPACING[3] + 2,
alignItems: 'center',
},
destructive: {
fontSize: 17,
color: BRAND.DANGER,
},
signOutSection: {
marginTop: SPACING[5],
},
})
}