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

244
app/zone/[bodyZone].tsx Normal file
View File

@@ -0,0 +1,244 @@
/**
* Body Zone Detail Screen
* Segmented level selector (Beginner default) + program list filtered by zone+level.
*/
import { useEffect, useMemo, useState } from 'react'
import { View, Text, StyleSheet, ScrollView, Pressable, ActivityIndicator } from 'react-native'
import { Stack, useRouter, useLocalSearchParams } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useTranslation } from 'react-i18next'
import { Icon } from '@/src/shared/components/Icon'
import { fetchProgramsByBodyZone } from '@/src/shared/data/workoutPrograms'
import {
BODY_ZONE_META,
LEVEL_META,
type BodyZone,
type ProgramLevel,
type WorkoutProgram,
} from '@/src/shared/types/workoutProgram'
import { useProgressStore } from '@/src/shared/stores/progressStore'
import { useUserStore } from '@/src/shared/stores/userStore'
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
import { SPACING } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { TEXT, NAVY, GREEN, BORDER_COLORS } from '@/src/shared/constants/colors'
import { withOpacity } from '@/src/shared/utils/color'
const LEVELS: ProgramLevel[] = ['Beginner', 'Intermediate', 'Advanced']
const VALID_ZONES: BodyZone[] = ['upper-body', 'lower-body', 'full-body']
export default function BodyZoneScreen() {
const { bodyZone } = useLocalSearchParams<{ bodyZone: string }>()
const router = useRouter()
const insets = useSafeAreaInsets()
const { t } = useTranslation()
const zone = (VALID_ZONES.includes(bodyZone as BodyZone) ? bodyZone : 'full-body') as BodyZone
const meta = BODY_ZONE_META[zone]
const [programs, setPrograms] = useState<WorkoutProgram[]>([])
const [loading, setLoading] = useState(true)
const [selectedLevel, setSelectedLevel] = useState<ProgramLevel>('Beginner')
const isProgramCompleted = useProgressStore(s => s.isProgramCompleted)
const isPremium = useUserStore(s => s.profile.subscription) !== 'free'
useEffect(() => {
let cancelled = false
setLoading(true)
fetchProgramsByBodyZone(zone)
.then(list => {
if (!cancelled) setPrograms(list)
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [zone])
const filtered = useMemo(
() => programs.filter(p => p.level === selectedLevel).sort((a, b) => a.sortOrder - b.sortOrder),
[programs, selectedLevel],
)
return (
<View style={styles.container}>
<Stack.Screen
options={{
headerShown: true,
headerTitle: meta.label,
headerStyle: { backgroundColor: NAVY[900] },
headerTintColor: TEXT.PRIMARY,
headerBackTitle: t('common:back'),
}}
/>
<ScrollView
style={styles.scroll}
contentContainerStyle={{ padding: SPACING[5], paddingBottom: insets.bottom + SPACING[6] }}
>
{/* Zone header */}
<View style={[styles.zoneHeader, { backgroundColor: withOpacity(meta.color, 0.12) }]}>
<View style={[styles.iconCircle, { backgroundColor: withOpacity(meta.color, 0.25) }]}>
<Icon name={meta.icon as any} size={28} tintColor={meta.color} />
</View>
<View style={{ flex: 1 }}>
<Text style={styles.zoneTitle}>{meta.label}</Text>
<Text style={styles.zoneSubtitle}>{t('screens:zone.chooseLevel')}</Text>
</View>
</View>
{/* Level segmented */}
<View style={styles.segmented}>
{LEVELS.map(level => {
const active = selectedLevel === level
const levelMeta = LEVEL_META[level]
return (
<Pressable
key={level}
onPress={() => setSelectedLevel(level)}
style={[
styles.segment,
active && {
backgroundColor: withOpacity(levelMeta.color, 0.2),
borderColor: levelMeta.color,
},
]}
>
<Text
style={[
styles.segmentText,
active && { color: levelMeta.color, fontWeight: '600' },
]}
>
{levelMeta.label}
</Text>
</Pressable>
)
})}
</View>
{/* Program list */}
{loading ? (
<ActivityIndicator color={TEXT.PRIMARY} style={{ marginTop: SPACING[8] }} />
) : filtered.length === 0 ? (
<Text style={styles.empty}>{t('screens:zone.emptyPrograms')}</Text>
) : (
<View style={styles.programList}>
{filtered.map(program => (
<ProgramCard
key={program.id}
program={program}
completed={isProgramCompleted(program.id)}
locked={!program.isFree && !isPremium}
onPress={() => router.push(`/program/${program.id}`)}
/>
))}
</View>
)}
</ScrollView>
</View>
)
}
function ProgramCard({
program,
completed,
locked,
onPress,
}: {
program: WorkoutProgram
completed: boolean
locked: boolean
onPress: () => void
}) {
const accent = program.accentColor ?? BODY_ZONE_META[program.bodyZone].color
return (
<Pressable
onPress={onPress}
style={({ pressed }) => [styles.programCard, pressed && { opacity: 0.85 }]}
>
<View style={[styles.programDot, { backgroundColor: accent }]} />
<View style={{ flex: 1 }}>
<Text style={styles.programTitle} numberOfLines={1}>
{program.title}
</Text>
<Text style={styles.programMeta}>
{program.estimatedDuration} min · {program.tabatas.length} tabatas · {program.estimatedCalories} cal
</Text>
</View>
{completed && <Icon name="checkmark.circle.fill" size={20} tintColor={GREEN[500]} />}
{locked && <Icon name="lock.fill" size={16} tintColor={TEXT.TERTIARY} />}
{!completed && !locked && <Icon name="chevron.right" size={16} tintColor={TEXT.TERTIARY} />}
</Pressable>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: NAVY[900] },
scroll: { flex: 1 },
zoneHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING[3],
padding: SPACING[4],
borderRadius: RADIUS.LG,
marginBottom: SPACING[5],
},
iconCircle: {
width: 56,
height: 56,
borderRadius: 28,
alignItems: 'center',
justifyContent: 'center',
},
zoneTitle: { ...TYPOGRAPHY.TITLE_2, color: TEXT.PRIMARY },
zoneSubtitle: { ...TYPOGRAPHY.SUBHEADLINE, color: TEXT.SECONDARY, marginTop: 2 },
segmented: {
flexDirection: 'row',
backgroundColor: NAVY[800],
borderRadius: RADIUS.MD,
padding: 4,
gap: 4,
marginBottom: SPACING[5],
borderWidth: 1,
borderColor: BORDER_COLORS.DIM,
},
segment: {
flex: 1,
paddingVertical: SPACING[2],
borderRadius: RADIUS.SM,
alignItems: 'center',
borderWidth: 1,
borderColor: 'transparent',
},
segmentText: { ...TYPOGRAPHY.SUBHEADLINE, color: TEXT.SECONDARY },
programList: { gap: SPACING[3] },
programCard: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING[3],
padding: SPACING[4],
backgroundColor: NAVY[800],
borderRadius: RADIUS.MD,
borderWidth: 1,
borderColor: BORDER_COLORS.DIM,
},
programDot: { width: 10, height: 10, borderRadius: 5 },
programTitle: { ...TYPOGRAPHY.HEADLINE, color: TEXT.PRIMARY },
programMeta: { ...TYPOGRAPHY.CAPTION_1, color: TEXT.TERTIARY, marginTop: 2 },
empty: {
...TYPOGRAPHY.BODY,
color: TEXT.SECONDARY,
textAlign: 'center',
marginTop: SPACING[8],
},
})