refactor screens, navigation & player for new architecture

Simplify Home, Activity, Profile, Complete, Player, and Program screens
to work with the new Supabase-driven data layer. Update root and tab
layouts. Add Settings, Terms, and Zone routes. Add OfflineBanner
component and progressStore. Update all player sub-components to use
the refreshed design system tokens.
This commit is contained in:
Millian Lamiaux
2026-04-21 21:50:48 +02:00
parent 04b83fc419
commit 5888aac08e
26 changed files with 1836 additions and 2772 deletions

View File

@@ -1,372 +1,181 @@
/**
* TabataFit Profile Screen — Native iOS
* Dark Medical design with SwiftUI Islands
* TabataGo Profile Tab
* User info, subscription status, quick stats. Settings via form sheet.
*/
import { useMemo } from 'react'
import { View, Text, StyleSheet, ScrollView, Pressable } from 'react-native'
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 { Icon } from '@/src/shared/components/Icon'
import { useUserStore } from '@/src/shared/stores/userStore'
import { useProgressStore } from '@/src/shared/stores/progressStore'
import { 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 { TYPOGRAPHY } from '@/src/shared/constants/typography'
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'
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
// ═══════════════════════════════════════════════════════════════════════════
import { GREEN, NAVY, TEXT, BORDER_COLORS } from '@/src/shared/constants/colors'
export default function ProfileScreen() {
const { t } = useTranslation('screens')
const { t } = useTranslation()
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 profile = useUserStore(s => s.profile)
const { isPremium } = usePurchases()
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 completedCount = useProgressStore(s => s.getCompletedCount())
const streak = useProgressStore(s => s.streak)
const weeklyCount = useProgressStore(s => s.getWeeklyCount())
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'
const avatarLetter = profile.name?.[0]?.toUpperCase() || '?'
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>
<ScrollView
style={styles.container}
contentContainerStyle={[styles.content, { paddingTop: insets.top + SPACING[4], paddingBottom: insets.bottom + SPACING[6] }]}
contentInsetAdjustmentBehavior="automatic"
>
{/* Avatar + name */}
<View style={styles.profileHeader}>
<View style={styles.avatar}>
<Text style={styles.avatarLetter}>{avatarLetter}</Text>
</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 style={{ flex: 1 }}>
<Text style={styles.name}>{profile.name || t('screens:profile.guest')}</Text>
<View style={[styles.planBadge, { borderColor: isPremium ? GREEN[500] : BORDER_COLORS.DIM }]}>
<Text style={[styles.planText, { color: isPremium ? GREEN[500] : TEXT.TERTIARY }]}>
{isPremium ? t('screens:settings.premium') : t('screens:settings.free')}
</Text>
</View>
</View>
<Pressable onPress={() => router.push('/settings')} hitSlop={8}>
<Icon name="gearshape.fill" size={22} tintColor={TEXT.TERTIARY} />
</Pressable>
</View>
{/* ════════════════════════════════════════════════════════════════════
UPGRADE CTA (FREE USERS ONLY)
═══════════════════════════════════════════════════════════════════ */}
{/* Stats row */}
<View style={styles.statsRow}>
<StatPill value={streak.current} label={t('screens:home.statsStreak')} icon="flame.fill" color="#FF6B35" />
<StatPill value={weeklyCount} label={t('screens:activity.thisWeek')} icon="calendar" color="#5AC8FA" />
<StatPill value={completedCount} label={t('screens:home.statsCompleted')} icon="checkmark.seal.fill" color={GREEN[500]} />
</View>
{/* Upgrade banner (free users) */}
{!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>
<Pressable
style={[styles.upgradeBanner, { borderColor: GREEN[500] }]}
onPress={() => router.push('/paywall')}
>
<Icon name="sparkles" size={20} tintColor={GREEN[500]} />
<View style={{ flex: 1 }}>
<Text style={styles.upgradeTitle}>{t('screens:profile.upgradeTitle')}</Text>
<Text style={styles.upgradeDesc}>{t('screens:profile.upgradeDescription')}</Text>
</View>
<Icon name="chevron.right" size={16} tintColor={TEXT.TERTIARY} />
</Pressable>
)}
{/* ════════════════════════════════════════════════════════════════════
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>
{/* Settings link */}
<Pressable style={styles.settingsRow} onPress={() => router.push('/settings')}>
<Icon name="gearshape" size={20} tintColor={TEXT.SECONDARY} />
<Text style={styles.settingsLabel}>{t('screens:settings.title')}</Text>
<Icon name="chevron.right" size={16} tintColor={TEXT.TERTIARY} />
</Pressable>
</ScrollView>
)
}
{/* ════════════════════════════════════════════════════════════════════
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)}
/>
function StatPill({ value, label, icon, color }: { value: number; label: string; icon: any; color: string }) {
return (
<View style={pillStyles.pill}>
<Icon name={icon} size={18} tintColor={color} />
<Text selectable style={pillStyles.value}>{value}</Text>
<Text style={pillStyles.label}>{label}</Text>
</View>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// STYLES
// ═══════════════════════════════════════════════════════════════════════════
const pillStyles = StyleSheet.create({
pill: {
flex: 1,
alignItems: 'center',
paddingVertical: SPACING[3],
borderRadius: RADIUS.MD,
borderWidth: 1,
backgroundColor: NAVY[800],
borderColor: BORDER_COLORS.DIM,
gap: 4,
},
value: { ...TYPOGRAPHY.TITLE_2, color: TEXT.PRIMARY, fontVariant: ['tabular-nums'] },
label: { ...TYPOGRAPHY.CAPTION_2, color: TEXT.TERTIARY, textAlign: 'center' },
})
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: {
container: { flex: 1, backgroundColor: colors.bg.base },
content: { paddingHorizontal: LAYOUT.SCREEN_PADDING },
profileHeader: {
flexDirection: 'row',
alignItems: 'center',
marginTop: SPACING[1],
gap: SPACING[1],
gap: SPACING[3],
marginBottom: SPACING[5],
},
statsContainer: {
flexDirection: 'row',
justifyContent: 'center',
marginTop: SPACING[4],
gap: SPACING[8],
},
statItem: {
avatar: {
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: NAVY[700] ?? NAVY[800],
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2,
borderColor: BORDER_COLORS.DIM,
},
upgradeCard: {
marginHorizontal: SPACING[5],
paddingVertical: SPACING[4],
paddingHorizontal: SPACING[4],
backgroundColor: NAVY[800],
borderRadius: RADIUS.LG,
borderCurve: 'continuous' as const,
avatarLetter: { ...TYPOGRAPHY.TITLE_1, color: TEXT.PRIMARY },
name: { ...TYPOGRAPHY.TITLE_2, color: TEXT.PRIMARY },
planBadge: {
alignSelf: 'flex-start',
marginTop: 4,
paddingHorizontal: SPACING[2],
paddingVertical: 2,
borderRadius: RADIUS.SM,
borderWidth: 1,
borderColor: colors.border.dim,
},
premiumContent: {
gap: SPACING[1],
planText: { ...TYPOGRAPHY.CAPTION_2, fontWeight: '600' },
statsRow: { flexDirection: 'row', gap: SPACING[2], marginBottom: SPACING[5] },
upgradeBanner: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING[3],
padding: SPACING[4],
borderRadius: RADIUS.LG,
borderWidth: 1,
backgroundColor: colors.surface.default.backgroundColor,
marginBottom: SPACING[3],
borderCurve: 'continuous',
},
signOutContainer: {
marginTop: SPACING[5],
marginHorizontal: SPACING[5],
upgradeTitle: { ...TYPOGRAPHY.HEADLINE, color: TEXT.PRIMARY },
upgradeDesc: { ...TYPOGRAPHY.CAPTION_1, color: TEXT.SECONDARY, marginTop: 2 },
settingsRow: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING[3],
padding: SPACING[4],
borderRadius: RADIUS.LG,
borderWidth: 1,
borderColor: BORDER_COLORS.DIM,
backgroundColor: colors.surface.default.backgroundColor,
borderCurve: 'continuous',
},
settingsLabel: { ...TYPOGRAPHY.BODY, color: TEXT.PRIMARY, flex: 1 },
})
}