refactor: extract player components, add stack headers, add tests

- Extract player UI into src/features/player/ components (TimerRing, BurnBar, etc.)
- Add transparent stack headers for workout/[id] and program/[id] screens
- Refactor workout/[id], program/[id], complete/[id] screens
- Add player feature tests and useTimer integration tests
- Add data layer exports and test setup improvements
This commit is contained in:
Millian Lamiaux
2026-03-26 10:46:47 +01:00
parent 569a9e178f
commit 8926de58e5
22 changed files with 2930 additions and 1335 deletions

View File

@@ -15,7 +15,6 @@ import {
} from 'react-native'
import { useRouter, useLocalSearchParams } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { LinearGradient } from 'expo-linear-gradient'
import { BlurView } from 'expo-blur'
import { Icon, type IconName } from '@/src/shared/components/Icon'
@@ -24,7 +23,7 @@ import { useTranslation } from 'react-i18next'
import { useHaptics } from '@/src/shared/hooks'
import { useActivityStore, useUserStore } from '@/src/shared/stores'
import { getWorkoutById, getPopularWorkouts } from '@/src/shared/data'
import { getWorkoutById, getPopularWorkouts, getTrainerById, getWorkoutAccentColor } from '@/src/shared/data'
import { useTranslatedWorkout, useTranslatedWorkouts } from '@/src/shared/data/useTranslatedData'
import { SyncConsentModal } from '@/src/shared/components/SyncConsentModal'
import { enableSync } from '@/src/shared/services/sync'
@@ -95,6 +94,7 @@ function PrimaryButton({
children: React.ReactNode
}) {
const colors = useThemeColors()
const isDark = colors.colorScheme === 'dark'
const styles = useMemo(() => createStyles(colors), [colors])
const scaleAnim = useRef(new Animated.Value(1)).current
@@ -121,14 +121,15 @@ function PrimaryButton({
onPressOut={handlePressOut}
style={{ width: '100%' }}
>
<Animated.View style={[styles.primaryButton, { transform: [{ scale: scaleAnim }] }]}>
<LinearGradient
colors={[BRAND.PRIMARY, BRAND.PRIMARY_LIGHT]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={StyleSheet.absoluteFill}
/>
<RNText style={styles.primaryButtonText}>{children}</RNText>
<Animated.View
style={[
styles.primaryButton,
{ backgroundColor: isDark ? '#FFFFFF' : '#000000', transform: [{ scale: scaleAnim }] },
]}
>
<RNText style={[styles.primaryButtonText, { color: isDark ? '#000000' : '#FFFFFF' }]}>
{children}
</RNText>
</Animated.View>
</Pressable>
)
@@ -190,11 +191,13 @@ function StatCard({
value,
label,
icon,
accentColor,
delay = 0,
}: {
value: string | number
label: string
icon: IconName
accentColor: string
delay?: number
}) {
const colors = useThemeColors()
@@ -215,14 +218,14 @@ function StatCard({
return (
<Animated.View style={[styles.statCard, { transform: [{ scale: scaleAnim }] }]}>
<BlurView intensity={colors.glass.blurMedium} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
<Icon name={icon} size={24} tintColor={BRAND.PRIMARY} />
<Icon name={icon} size={24} tintColor={accentColor} />
<RNText style={styles.statValue}>{value}</RNText>
<RNText style={styles.statLabel}>{label}</RNText>
</Animated.View>
)
}
function BurnBarResult({ percentile }: { percentile: number }) {
function BurnBarResult({ percentile, accentColor }: { percentile: number; accentColor: string }) {
const { t } = useTranslation()
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
@@ -245,9 +248,9 @@ function BurnBarResult({ percentile }: { percentile: number }) {
return (
<View style={styles.burnBarContainer}>
<RNText style={styles.burnBarTitle}>{t('screens:complete.burnBar')}</RNText>
<RNText style={styles.burnBarResult}>{t('screens:complete.burnBarResult', { percentile })}</RNText>
<RNText style={[styles.burnBarResult, { color: accentColor }]}>{t('screens:complete.burnBarResult', { percentile })}</RNText>
<View style={styles.burnBarTrack}>
<Animated.View style={[styles.burnBarFill, { width: barWidth }]} />
<Animated.View style={[styles.burnBarFill, { width: barWidth, backgroundColor: accentColor }]} />
</View>
</View>
)
@@ -269,6 +272,8 @@ export default function WorkoutCompleteScreen() {
const rawWorkout = getWorkoutById(id ?? '1')
const workout = useTranslatedWorkout(rawWorkout)
const trainer = rawWorkout ? getTrainerById(rawWorkout.trainerId) : undefined
const trainerColor = getWorkoutAccentColor(id ?? '1')
const streak = useActivityStore((s) => s.streak)
const history = useActivityStore((s) => s.history)
const recentWorkouts = history.slice(0, 1)
@@ -378,20 +383,20 @@ export default function WorkoutCompleteScreen() {
{/* Stats Grid */}
<View style={styles.statsGrid}>
<StatCard value={resultCalories} label={t('screens:complete.caloriesLabel')} icon="flame.fill" delay={100} />
<StatCard value={resultMinutes} label={t('screens:complete.minutesLabel')} icon="clock.fill" delay={200} />
<StatCard value="100%" label={t('screens:complete.completeLabel')} icon="checkmark.circle.fill" delay={300} />
<StatCard value={resultCalories} label={t('screens:complete.caloriesLabel')} icon="flame.fill" accentColor={trainerColor} delay={100} />
<StatCard value={resultMinutes} label={t('screens:complete.minutesLabel')} icon="clock.fill" accentColor={trainerColor} delay={200} />
<StatCard value="100%" label={t('screens:complete.completeLabel')} icon="checkmark.circle.fill" accentColor={trainerColor} delay={300} />
</View>
{/* Burn Bar */}
<BurnBarResult percentile={burnBarPercentile} />
<BurnBarResult percentile={burnBarPercentile} accentColor={trainerColor} />
<View style={styles.divider} />
{/* Streak */}
<View style={styles.streakSection}>
<View style={styles.streakBadge}>
<Icon name="flame.fill" size={32} tintColor={BRAND.PRIMARY} />
<View style={[styles.streakBadge, { backgroundColor: trainerColor + '26' }]}>
<Icon name="flame.fill" size={32} tintColor={trainerColor} />
</View>
<View style={styles.streakInfo}>
<RNText style={styles.streakTitle}>{t('screens:complete.streakTitle', { count: streak.current })}</RNText>
@@ -421,12 +426,8 @@ export default function WorkoutCompleteScreen() {
style={styles.recommendedCard}
>
<BlurView intensity={colors.glass.blurLight} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
<View style={styles.recommendedThumb}>
<LinearGradient
colors={[BRAND.PRIMARY, BRAND.PRIMARY_LIGHT]}
style={StyleSheet.absoluteFill}
/>
<Icon name="flame.fill" size={24} tintColor="#FFFFFF" />
<View style={[styles.recommendedThumb, { backgroundColor: trainerColor + '20' }]}>
<Icon name="flame.fill" size={24} tintColor={trainerColor} />
</View>
<RNText style={styles.recommendedTitleText} numberOfLines={1}>{w.title}</RNText>
<RNText style={styles.recommendedDurationText}>{t('units.minUnit', { count: w.duration })}</RNText>
@@ -500,7 +501,6 @@ function createStyles(colors: ThemeColors) {
},
primaryButtonText: {
...TYPOGRAPHY.HEADLINE,
color: '#FFFFFF',
fontWeight: '700',
},
buttonIcon: {
@@ -588,7 +588,6 @@ function createStyles(colors: ThemeColors) {
},
burnBarResult: {
...TYPOGRAPHY.BODY,
color: BRAND.PRIMARY,
marginTop: SPACING[1],
marginBottom: SPACING[3],
},
@@ -600,7 +599,6 @@ function createStyles(colors: ThemeColors) {
},
burnBarFill: {
height: '100%',
backgroundColor: BRAND.PRIMARY,
borderRadius: 4,
},
@@ -622,7 +620,6 @@ function createStyles(colors: ThemeColors) {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: 'rgba(255, 107, 53, 0.15)',
alignItems: 'center',
justifyContent: 'center',
},