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:
@@ -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 },
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user