Files
tabatago/app/(tabs)/profile.tsx
Millian Lamiaux 791f432334 refactor: code quality cleanup — remove any types, add logger, rename Kine to Tabata
- Phase 0: Rename all Kine references to Tabata (types, files, imports, i18n, analytics events)
- Phase 1: Add test coverage for tabataProgramStore, workoutProgramStore, and color utils (47 tests)
- Phase 2: Remove all `any` types from production code with proper typed replacements
- Phase 3: Replace ~60 raw console.* calls with __DEV__-gated logger utility
- Phase 4: Verify .DS_Store housekeeping (already clean)

0 TypeScript errors, 583/583 tests passing.
2026-04-17 18:56:24 +02:00

373 lines
16 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.
/**
* TabataFit Profile Screen — Native iOS
* Dark Medical design with SwiftUI Islands
*/
import { useRouter } from 'expo-router'
import {
View,
ScrollView,
StyleSheet,
Pressable,
} 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 } from '@/src/shared/theme'
import type { ThemeColors } from '@/src/shared/theme/types'
import { StyledText } from '@/src/shared/components/StyledText'
import { SPACING } 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'
import { GREEN, NAVY, TEXT } from '@/src/shared/constants/colors'
import { FONT_FAMILY } from '@/src/shared/constants/typography'
import {
NativeList,
NativeSection,
NativeSwitch,
NativeLabeledRow,
NativeButton,
} from '@/src/shared/components/native'
// ═══════════════════════════════════════════════════════════════════════════
// 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'
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')
}
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 + 40 }]}
showsVerticalScrollIndicator={false}
>
{/* ════════════════════════════════════════════════════════════════════
PROFILE HEADER
═══════════════════════════════════════════════════════════════════ */}
<View style={styles.headerContainer}>
<View style={styles.avatarContainer}>
<StyledText size={48} weight="bold" color={TEXT.PRIMARY}>
{avatarInitial}
</StyledText>
</View>
<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 ? GREEN[500] : colors.text.tertiary}>
{planLabel}
</StyledText>
{isPremium && (
<StyledText size={12} color={GREEN[500]}></StyledText>
)}
</View>
</View>
<View style={styles.statsContainer}>
<View style={styles.statItem}>
<StyledText size={20} weight="bold" color={GREEN[500]} 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={GREEN[500]} 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={GREEN[500]} 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>
{/* ════════════════════════════════════════════════════════════════════
UPGRADE CTA (FREE USERS ONLY)
═══════════════════════════════════════════════════════════════════ */}
{!isPremium && (
<View style={styles.upgradeCard}>
<Pressable onPress={() => router.push('/paywall')}>
<View style={styles.premiumContent}>
<StyledText size={17} weight="semibold" color={GREEN[500]}>
{t('profile.upgradeTitle')}
</StyledText>
<StyledText size={15} color={colors.text.tertiary} style={{ marginTop: SPACING[1] }}>
{t('profile.upgradeDescription')}
</StyledText>
</View>
<StyledText size={15} color={GREEN[500]} style={{ marginTop: SPACING[3] }}>
{t('profile.learnMore')}
</StyledText>
</Pressable>
</View>
)}
{/* ════════════════════════════════════════════════════════════════════
WORKOUT SETTINGS — Native List
═══════════════════════════════════════════════════════════════════ */}
<NativeList>
<NativeSection title={t('profile.sectionWorkout').toUpperCase()}>
<NativeLabeledRow label={t('profile.hapticFeedback')}>
<NativeSwitch
value={settings.haptics}
onValueChange={(v) => updateSettings({ haptics: v })}
/>
</NativeLabeledRow>
<NativeLabeledRow label={t('profile.soundEffects')}>
<NativeSwitch
value={settings.soundEffects}
onValueChange={(v) => updateSettings({ soundEffects: v })}
/>
</NativeLabeledRow>
<NativeLabeledRow label={t('profile.voiceCoaching')}>
<NativeSwitch
value={settings.voiceCoaching}
onValueChange={(v) => updateSettings({ voiceCoaching: v })}
/>
</NativeLabeledRow>
</NativeSection>
</NativeList>
{/* ════════════════════════════════════════════════════════════════════
NOTIFICATIONS — Native List
═══════════════════════════════════════════════════════════════════ */}
<NativeList>
<NativeSection title={t('profile.sectionNotifications').toUpperCase()}>
<NativeLabeledRow label={t('profile.dailyReminders')}>
<NativeSwitch
value={settings.reminders}
onValueChange={handleReminderToggle}
/>
</NativeLabeledRow>
{settings.reminders && (
<NativeLabeledRow label={t('profile.reminderTime')} value={settings.reminderTime} />
)}
</NativeSection>
</NativeList>
{/* ════════════════════════════════════════════════════════════════════
PERSONALIZATION (PREMIUM ONLY)
═══════════════════════════════════════════════════════════════════ */}
{isPremium && (
<NativeList>
<NativeSection title={t('profile.sectionPersonalization').toUpperCase()}>
<NativeLabeledRow
label={
profile.syncStatus === 'synced'
? t('profile.personalizationEnabled')
: t('profile.personalizationDisabled')
}
value={profile.syncStatus === 'synced' ? '✓' : '○'}
/>
</NativeSection>
</NativeList>
)}
{/* ════════════════════════════════════════════════════════════════════
ABOUT — Native List
═══════════════════════════════════════════════════════════════════ */}
<NativeList>
<NativeSection title={t('profile.sectionAbout').toUpperCase()}>
<NativeLabeledRow
label="Programmes Kiné"
value="Rééducation et physiothérapie"
chevron
onPress={() => router.push('/program/debutant' as any)}
/>
<NativeLabeledRow label={t('profile.version')} value={appVersion} />
<NativeLabeledRow label={t('profile.rateApp')} chevron onPress={handleRateApp} />
<NativeLabeledRow label={t('profile.contactUs')} chevron onPress={handleContactUs} />
<NativeLabeledRow label={t('profile.faq')} chevron onPress={handleFAQ} />
<NativeLabeledRow label={t('profile.privacyPolicy')} chevron onPress={handlePrivacyPolicy} />
</NativeSection>
</NativeList>
{/* ════════════════════════════════════════════════════════════════════
ACCOUNT (PREMIUM ONLY)
═══════════════════════════════════════════════════════════════════ */}
{isPremium && (
<NativeList>
<NativeSection title={t('profile.sectionAccount').toUpperCase()}>
<NativeLabeledRow label={t('profile.restorePurchases')} chevron onPress={handleRestore} />
</NativeSection>
</NativeList>
)}
{/* ════════════════════════════════════════════════════════════════════
SIGN OUT — Native Button
═══════════════════════════════════════════════════════════════════ */}
<View style={styles.signOutContainer}>
<NativeButton
variant="destructive"
title={t('profile.signOut')}
onPress={handleSignOut}
/>
</View>
</ScrollView>
<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,
},
headerContainer: {
alignItems: 'center',
paddingVertical: SPACING[6],
paddingHorizontal: SPACING[4],
},
avatarContainer: {
width: 90,
height: 90,
borderRadius: RADIUS.FULL,
backgroundColor: NAVY[700],
justifyContent: 'center',
alignItems: 'center',
},
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',
},
upgradeCard: {
marginHorizontal: SPACING[5],
paddingVertical: SPACING[4],
paddingHorizontal: SPACING[4],
backgroundColor: NAVY[800],
borderRadius: RADIUS.LG,
borderCurve: 'continuous' as const,
borderWidth: 1,
borderColor: colors.border.dim,
},
premiumContent: {
gap: SPACING[1],
},
signOutContainer: {
marginTop: SPACING[5],
marginHorizontal: SPACING[5],
},
})
}