feat: integrate theme and i18n across all screens
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,25 +3,23 @@
|
||||
* Dynamic data via route params
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { View, Text as RNText, StyleSheet, ScrollView, Pressable } 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 Ionicons from '@expo/vector-icons/Ionicons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useHaptics } from '@/src/shared/hooks'
|
||||
import { getWorkoutById, getTrainerById } from '@/src/shared/data'
|
||||
import { track } from '@/src/shared/services/analytics'
|
||||
import { getWorkoutById } from '@/src/shared/data'
|
||||
import { useTranslatedWorkout, useMusicVibeLabel } from '@/src/shared/data/useTranslatedData'
|
||||
import { VideoPlayer } from '@/src/shared/components/VideoPlayer'
|
||||
|
||||
import {
|
||||
BRAND,
|
||||
DARK,
|
||||
TEXT,
|
||||
GLASS,
|
||||
SHADOW,
|
||||
} from '@/src/shared/constants/colors'
|
||||
import { useThemeColors, BRAND } from '@/src/shared/theme'
|
||||
import type { ThemeColors } from '@/src/shared/theme/types'
|
||||
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
|
||||
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
@@ -34,20 +32,36 @@ export default function WorkoutDetailScreen() {
|
||||
const insets = useSafeAreaInsets()
|
||||
const router = useRouter()
|
||||
const haptics = useHaptics()
|
||||
const { t } = useTranslation()
|
||||
const { id } = useLocalSearchParams<{ id: string }>()
|
||||
const [isSaved, setIsSaved] = useState(false)
|
||||
|
||||
const workout = getWorkoutById(id ?? '1')
|
||||
const colors = useThemeColors()
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
|
||||
const rawWorkout = getWorkoutById(id ?? '1')
|
||||
const workout = useTranslatedWorkout(rawWorkout)
|
||||
const musicVibeLabel = useMusicVibeLabel(rawWorkout?.musicVibe ?? '')
|
||||
|
||||
useEffect(() => {
|
||||
if (workout) {
|
||||
track('workout_detail_viewed', {
|
||||
workout_id: workout.id,
|
||||
workout_title: workout.title,
|
||||
level: workout.level,
|
||||
duration: workout.duration,
|
||||
})
|
||||
}
|
||||
}, [workout?.id])
|
||||
|
||||
if (!workout) {
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top, alignItems: 'center', justifyContent: 'center' }]}>
|
||||
<RNText style={{ color: TEXT.PRIMARY, fontSize: 17 }}>Workout not found</RNText>
|
||||
<RNText style={{ color: colors.text.primary, fontSize: 17 }}>{t('screens:workout.notFound')}</RNText>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const trainer = getTrainerById(workout.trainerId)
|
||||
|
||||
const handleStartWorkout = () => {
|
||||
haptics.phaseChange()
|
||||
router.push(`/player/${workout.id}`)
|
||||
@@ -76,7 +90,7 @@ export default function WorkoutDetailScreen() {
|
||||
<View style={styles.videoPreview}>
|
||||
<VideoPlayer
|
||||
videoUrl={workout.videoUrl}
|
||||
gradientColors={[trainer?.color ?? BRAND.PRIMARY, BRAND.PRIMARY_DARK]}
|
||||
gradientColors={[BRAND.PRIMARY, BRAND.PRIMARY_DARK]}
|
||||
mode="preview"
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
@@ -85,33 +99,33 @@ export default function WorkoutDetailScreen() {
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
|
||||
{/* Header overlay */}
|
||||
{/* Header overlay — on video, keep white */}
|
||||
<View style={styles.headerOverlay}>
|
||||
<Pressable onPress={handleGoBack} style={styles.headerButton}>
|
||||
<BlurView intensity={GLASS.BLUR_LIGHT} tint="dark" style={StyleSheet.absoluteFill} />
|
||||
<Ionicons name="chevron-back" size={24} color={TEXT.PRIMARY} />
|
||||
<BlurView intensity={colors.glass.blurLight} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
|
||||
<Ionicons name="chevron-back" size={24} color="#FFFFFF" />
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.headerRight}>
|
||||
<Pressable onPress={toggleSave} style={styles.headerButton}>
|
||||
<BlurView intensity={GLASS.BLUR_LIGHT} tint="dark" style={StyleSheet.absoluteFill} />
|
||||
<BlurView intensity={colors.glass.blurLight} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
|
||||
<Ionicons
|
||||
name={isSaved ? 'heart' : 'heart-outline'}
|
||||
size={24}
|
||||
color={isSaved ? '#FF3B30' : TEXT.PRIMARY}
|
||||
color={isSaved ? '#FF3B30' : '#FFFFFF'}
|
||||
/>
|
||||
</Pressable>
|
||||
<Pressable style={styles.headerButton}>
|
||||
<BlurView intensity={GLASS.BLUR_LIGHT} tint="dark" style={StyleSheet.absoluteFill} />
|
||||
<Ionicons name="ellipsis-horizontal" size={24} color={TEXT.PRIMARY} />
|
||||
<BlurView intensity={colors.glass.blurLight} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
|
||||
<Ionicons name="ellipsis-horizontal" size={24} color="#FFFFFF" />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Trainer preview */}
|
||||
{/* Workout icon — on brand bg, keep white */}
|
||||
<View style={styles.trainerPreview}>
|
||||
<View style={[styles.trainerAvatarLarge, { backgroundColor: trainer?.color ?? BRAND.PRIMARY }]}>
|
||||
<RNText style={styles.trainerInitial}>{trainer?.name[0] ?? 'T'}</RNText>
|
||||
<View style={[styles.trainerAvatarLarge, { backgroundColor: BRAND.PRIMARY }]}>
|
||||
<Ionicons name="flame" size={36} color="#FFFFFF" />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -122,24 +136,19 @@ export default function WorkoutDetailScreen() {
|
||||
|
||||
{/* Quick stats */}
|
||||
<View style={styles.quickStats}>
|
||||
<View style={styles.statItem}>
|
||||
<Ionicons name="person" size={16} color={BRAND.PRIMARY} />
|
||||
<RNText style={styles.statText}>{trainer?.name ?? ''}</RNText>
|
||||
</View>
|
||||
<RNText style={styles.statDot}>•</RNText>
|
||||
<View style={styles.statItem}>
|
||||
<Ionicons name="barbell" size={16} color={BRAND.PRIMARY} />
|
||||
<RNText style={styles.statText}>{workout.level}</RNText>
|
||||
<RNText style={styles.statText}>{t(`levels.${workout.level.toLowerCase()}`)}</RNText>
|
||||
</View>
|
||||
<RNText style={styles.statDot}>•</RNText>
|
||||
<View style={styles.statItem}>
|
||||
<Ionicons name="time" size={16} color={BRAND.PRIMARY} />
|
||||
<RNText style={styles.statText}>{workout.duration} min</RNText>
|
||||
<RNText style={styles.statText}>{t('units.minUnit', { count: workout.duration })}</RNText>
|
||||
</View>
|
||||
<RNText style={styles.statDot}>•</RNText>
|
||||
<View style={styles.statItem}>
|
||||
<Ionicons name="flame" size={16} color={BRAND.PRIMARY} />
|
||||
<RNText style={styles.statText}>{workout.calories} cal</RNText>
|
||||
<RNText style={styles.statText}>{t('units.calUnit', { count: workout.calories })}</RNText>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -148,7 +157,7 @@ export default function WorkoutDetailScreen() {
|
||||
|
||||
{/* Equipment */}
|
||||
<View style={styles.section}>
|
||||
<RNText style={styles.sectionTitle}>What You'll Need</RNText>
|
||||
<RNText style={styles.sectionTitle}>{t('screens:workout.whatYoullNeed')}</RNText>
|
||||
{workout.equipment.map((item, index) => (
|
||||
<View key={index} style={styles.equipmentItem}>
|
||||
<Ionicons name="checkmark-circle" size={20} color="#30D158" />
|
||||
@@ -161,7 +170,7 @@ export default function WorkoutDetailScreen() {
|
||||
|
||||
{/* Exercises */}
|
||||
<View style={styles.section}>
|
||||
<RNText style={styles.sectionTitle}>Exercises ({workout.rounds} rounds)</RNText>
|
||||
<RNText style={styles.sectionTitle}>{t('screens:workout.exercises', { count: workout.rounds })}</RNText>
|
||||
<View style={styles.exercisesList}>
|
||||
{workout.exercises.map((exercise, index) => (
|
||||
<View key={index} style={styles.exerciseRow}>
|
||||
@@ -173,8 +182,8 @@ export default function WorkoutDetailScreen() {
|
||||
</View>
|
||||
))}
|
||||
<View style={styles.repeatNote}>
|
||||
<Ionicons name="repeat" size={16} color={TEXT.TERTIARY} />
|
||||
<RNText style={styles.repeatText}>Repeat × {repeatCount} rounds</RNText>
|
||||
<Ionicons name="repeat" size={16} color={colors.text.tertiary} />
|
||||
<RNText style={styles.repeatText}>{t('screens:workout.repeatRounds', { count: repeatCount })}</RNText>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -183,14 +192,14 @@ export default function WorkoutDetailScreen() {
|
||||
|
||||
{/* Music */}
|
||||
<View style={styles.section}>
|
||||
<RNText style={styles.sectionTitle}>Music</RNText>
|
||||
<RNText style={styles.sectionTitle}>{t('screens:workout.music')}</RNText>
|
||||
<View style={styles.musicCard}>
|
||||
<View style={styles.musicIcon}>
|
||||
<Ionicons name="musical-notes" size={24} color={BRAND.PRIMARY} />
|
||||
</View>
|
||||
<View style={styles.musicInfo}>
|
||||
<RNText style={styles.musicName}>{workout.musicVibe.charAt(0).toUpperCase() + workout.musicVibe.slice(1)} Mix</RNText>
|
||||
<RNText style={styles.musicDescription}>Curated for your workout</RNText>
|
||||
<RNText style={styles.musicName}>{t('screens:workout.musicMix', { vibe: musicVibeLabel })}</RNText>
|
||||
<RNText style={styles.musicDescription}>{t('screens:workout.curatedForWorkout')}</RNText>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -198,7 +207,7 @@ export default function WorkoutDetailScreen() {
|
||||
|
||||
{/* Fixed Start Button */}
|
||||
<View style={[styles.bottomBar, { paddingBottom: insets.bottom + SPACING[4] }]}>
|
||||
<BlurView intensity={GLASS.BLUR_HEAVY} tint="dark" style={StyleSheet.absoluteFill} />
|
||||
<BlurView intensity={colors.glass.blurHeavy} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
|
||||
<View style={styles.startButtonContainer}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
@@ -207,7 +216,7 @@ export default function WorkoutDetailScreen() {
|
||||
]}
|
||||
onPress={handleStartWorkout}
|
||||
>
|
||||
<RNText style={styles.startButtonText}>START WORKOUT</RNText>
|
||||
<RNText style={styles.startButtonText}>{t('screens:workout.startWorkout')}</RNText>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
@@ -219,231 +228,233 @@ export default function WorkoutDetailScreen() {
|
||||
// STYLES
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: DARK.BASE,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
},
|
||||
function createStyles(colors: ThemeColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bg.base,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
},
|
||||
|
||||
// Video Preview
|
||||
videoPreview: {
|
||||
height: 280,
|
||||
marginHorizontal: -LAYOUT.SCREEN_PADDING,
|
||||
marginBottom: SPACING[4],
|
||||
backgroundColor: DARK.SURFACE,
|
||||
},
|
||||
headerOverlay: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: SPACING[4],
|
||||
},
|
||||
headerButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
headerRight: {
|
||||
flexDirection: 'row',
|
||||
gap: SPACING[2],
|
||||
},
|
||||
trainerPreview: {
|
||||
position: 'absolute',
|
||||
bottom: SPACING[4],
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: 'center',
|
||||
},
|
||||
trainerAvatarLarge: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 3,
|
||||
borderColor: TEXT.PRIMARY,
|
||||
},
|
||||
trainerInitial: {
|
||||
...TYPOGRAPHY.HERO,
|
||||
color: TEXT.PRIMARY,
|
||||
},
|
||||
// Video Preview
|
||||
videoPreview: {
|
||||
height: 280,
|
||||
marginHorizontal: -LAYOUT.SCREEN_PADDING,
|
||||
marginBottom: SPACING[4],
|
||||
backgroundColor: colors.bg.surface,
|
||||
},
|
||||
headerOverlay: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: SPACING[4],
|
||||
},
|
||||
headerButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
headerRight: {
|
||||
flexDirection: 'row',
|
||||
gap: SPACING[2],
|
||||
},
|
||||
trainerPreview: {
|
||||
position: 'absolute',
|
||||
bottom: SPACING[4],
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: 'center',
|
||||
},
|
||||
trainerAvatarLarge: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 3,
|
||||
borderColor: '#FFFFFF',
|
||||
},
|
||||
trainerInitial: {
|
||||
...TYPOGRAPHY.HERO,
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
|
||||
// Title Section
|
||||
titleSection: {
|
||||
marginBottom: SPACING[4],
|
||||
},
|
||||
title: {
|
||||
...TYPOGRAPHY.LARGE_TITLE,
|
||||
color: TEXT.PRIMARY,
|
||||
marginBottom: SPACING[3],
|
||||
},
|
||||
quickStats: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: SPACING[2],
|
||||
},
|
||||
statItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: SPACING[1],
|
||||
},
|
||||
statText: {
|
||||
...TYPOGRAPHY.BODY,
|
||||
color: TEXT.SECONDARY,
|
||||
},
|
||||
statDot: {
|
||||
color: TEXT.TERTIARY,
|
||||
},
|
||||
// Title Section
|
||||
titleSection: {
|
||||
marginBottom: SPACING[4],
|
||||
},
|
||||
title: {
|
||||
...TYPOGRAPHY.LARGE_TITLE,
|
||||
color: colors.text.primary,
|
||||
marginBottom: SPACING[3],
|
||||
},
|
||||
quickStats: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: SPACING[2],
|
||||
},
|
||||
statItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: SPACING[1],
|
||||
},
|
||||
statText: {
|
||||
...TYPOGRAPHY.BODY,
|
||||
color: colors.text.secondary,
|
||||
},
|
||||
statDot: {
|
||||
color: colors.text.tertiary,
|
||||
},
|
||||
|
||||
// Divider
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
marginVertical: SPACING[2],
|
||||
},
|
||||
// Divider
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: colors.border.glass,
|
||||
marginVertical: SPACING[2],
|
||||
},
|
||||
|
||||
// Section
|
||||
section: {
|
||||
paddingVertical: SPACING[4],
|
||||
},
|
||||
sectionTitle: {
|
||||
...TYPOGRAPHY.HEADLINE,
|
||||
color: TEXT.PRIMARY,
|
||||
marginBottom: SPACING[3],
|
||||
},
|
||||
// Section
|
||||
section: {
|
||||
paddingVertical: SPACING[4],
|
||||
},
|
||||
sectionTitle: {
|
||||
...TYPOGRAPHY.HEADLINE,
|
||||
color: colors.text.primary,
|
||||
marginBottom: SPACING[3],
|
||||
},
|
||||
|
||||
// Equipment
|
||||
equipmentItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: SPACING[3],
|
||||
marginBottom: SPACING[2],
|
||||
},
|
||||
equipmentText: {
|
||||
...TYPOGRAPHY.BODY,
|
||||
color: TEXT.SECONDARY,
|
||||
},
|
||||
// Equipment
|
||||
equipmentItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: SPACING[3],
|
||||
marginBottom: SPACING[2],
|
||||
},
|
||||
equipmentText: {
|
||||
...TYPOGRAPHY.BODY,
|
||||
color: colors.text.secondary,
|
||||
},
|
||||
|
||||
// Exercises
|
||||
exercisesList: {
|
||||
gap: SPACING[2],
|
||||
},
|
||||
exerciseRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: SPACING[3],
|
||||
paddingHorizontal: SPACING[4],
|
||||
backgroundColor: DARK.SURFACE,
|
||||
borderRadius: RADIUS.LG,
|
||||
gap: SPACING[3],
|
||||
},
|
||||
exerciseNumber: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
backgroundColor: 'rgba(255, 107, 53, 0.15)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
exerciseNumberText: {
|
||||
...TYPOGRAPHY.CALLOUT,
|
||||
color: BRAND.PRIMARY,
|
||||
fontWeight: '700',
|
||||
},
|
||||
exerciseName: {
|
||||
...TYPOGRAPHY.BODY,
|
||||
color: TEXT.PRIMARY,
|
||||
flex: 1,
|
||||
},
|
||||
exerciseDuration: {
|
||||
...TYPOGRAPHY.CAPTION_1,
|
||||
color: TEXT.TERTIARY,
|
||||
},
|
||||
repeatNote: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: SPACING[2],
|
||||
marginTop: SPACING[2],
|
||||
paddingHorizontal: SPACING[2],
|
||||
},
|
||||
repeatText: {
|
||||
...TYPOGRAPHY.CAPTION_1,
|
||||
color: TEXT.TERTIARY,
|
||||
},
|
||||
// Exercises
|
||||
exercisesList: {
|
||||
gap: SPACING[2],
|
||||
},
|
||||
exerciseRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: SPACING[3],
|
||||
paddingHorizontal: SPACING[4],
|
||||
backgroundColor: colors.bg.surface,
|
||||
borderRadius: RADIUS.LG,
|
||||
gap: SPACING[3],
|
||||
},
|
||||
exerciseNumber: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
backgroundColor: 'rgba(255, 107, 53, 0.15)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
exerciseNumberText: {
|
||||
...TYPOGRAPHY.CALLOUT,
|
||||
color: BRAND.PRIMARY,
|
||||
fontWeight: '700',
|
||||
},
|
||||
exerciseName: {
|
||||
...TYPOGRAPHY.BODY,
|
||||
color: colors.text.primary,
|
||||
flex: 1,
|
||||
},
|
||||
exerciseDuration: {
|
||||
...TYPOGRAPHY.CAPTION_1,
|
||||
color: colors.text.tertiary,
|
||||
},
|
||||
repeatNote: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: SPACING[2],
|
||||
marginTop: SPACING[2],
|
||||
paddingHorizontal: SPACING[2],
|
||||
},
|
||||
repeatText: {
|
||||
...TYPOGRAPHY.CAPTION_1,
|
||||
color: colors.text.tertiary,
|
||||
},
|
||||
|
||||
// Music
|
||||
musicCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: SPACING[4],
|
||||
backgroundColor: DARK.SURFACE,
|
||||
borderRadius: RADIUS.LG,
|
||||
gap: SPACING[3],
|
||||
},
|
||||
musicIcon: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: 'rgba(255, 107, 53, 0.15)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
musicInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
musicName: {
|
||||
...TYPOGRAPHY.HEADLINE,
|
||||
color: TEXT.PRIMARY,
|
||||
},
|
||||
musicDescription: {
|
||||
...TYPOGRAPHY.CAPTION_1,
|
||||
color: TEXT.TERTIARY,
|
||||
marginTop: 2,
|
||||
},
|
||||
// Music
|
||||
musicCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: SPACING[4],
|
||||
backgroundColor: colors.bg.surface,
|
||||
borderRadius: RADIUS.LG,
|
||||
gap: SPACING[3],
|
||||
},
|
||||
musicIcon: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: 'rgba(255, 107, 53, 0.15)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
musicInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
musicName: {
|
||||
...TYPOGRAPHY.HEADLINE,
|
||||
color: colors.text.primary,
|
||||
},
|
||||
musicDescription: {
|
||||
...TYPOGRAPHY.CAPTION_1,
|
||||
color: colors.text.tertiary,
|
||||
marginTop: 2,
|
||||
},
|
||||
|
||||
// Bottom Bar
|
||||
bottomBar: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
paddingTop: SPACING[4],
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
startButtonContainer: {
|
||||
height: 56,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
// Bottom Bar
|
||||
bottomBar: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
paddingTop: SPACING[4],
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border.glass,
|
||||
},
|
||||
startButtonContainer: {
|
||||
height: 56,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
|
||||
// Start Button
|
||||
startButton: {
|
||||
height: 56,
|
||||
borderRadius: RADIUS.LG,
|
||||
backgroundColor: BRAND.PRIMARY,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
startButtonPressed: {
|
||||
backgroundColor: BRAND.PRIMARY_DARK,
|
||||
transform: [{ scale: 0.98 }],
|
||||
},
|
||||
startButtonText: {
|
||||
...TYPOGRAPHY.HEADLINE,
|
||||
color: TEXT.PRIMARY,
|
||||
letterSpacing: 1,
|
||||
},
|
||||
})
|
||||
// Start Button
|
||||
startButton: {
|
||||
height: 56,
|
||||
borderRadius: RADIUS.LG,
|
||||
backgroundColor: BRAND.PRIMARY,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
startButtonPressed: {
|
||||
backgroundColor: BRAND.PRIMARY_DARK,
|
||||
transform: [{ scale: 0.98 }],
|
||||
},
|
||||
startButtonText: {
|
||||
...TYPOGRAPHY.HEADLINE,
|
||||
color: '#FFFFFF',
|
||||
letterSpacing: 1,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,36 +13,44 @@ import {
|
||||
Picker,
|
||||
} from '@expo/ui/swift-ui'
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useHaptics } from '@/src/shared/hooks'
|
||||
import { getWorkoutsByCategory, getTrainerById, CATEGORIES } from '@/src/shared/data'
|
||||
import { getWorkoutsByCategory, CATEGORIES } from '@/src/shared/data'
|
||||
import { useTranslatedCategories, useTranslatedWorkouts } from '@/src/shared/data/useTranslatedData'
|
||||
import { StyledText } from '@/src/shared/components/StyledText'
|
||||
import type { WorkoutCategory, WorkoutLevel } from '@/src/shared/types'
|
||||
|
||||
import {
|
||||
BRAND,
|
||||
DARK,
|
||||
TEXT,
|
||||
} from '@/src/shared/constants/colors'
|
||||
import { useThemeColors, BRAND } from '@/src/shared/theme'
|
||||
import type { ThemeColors } from '@/src/shared/theme/types'
|
||||
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
|
||||
const LEVELS: { id: WorkoutLevel | 'all'; label: string }[] = [
|
||||
{ id: 'all', label: 'All Levels' },
|
||||
{ id: 'Beginner', label: 'Beginner' },
|
||||
{ id: 'Intermediate', label: 'Intermediate' },
|
||||
{ id: 'Advanced', label: 'Advanced' },
|
||||
]
|
||||
const LEVEL_IDS: (WorkoutLevel | 'all')[] = ['all', 'Beginner', 'Intermediate', 'Advanced']
|
||||
|
||||
export default function CategoryDetailScreen() {
|
||||
const insets = useSafeAreaInsets()
|
||||
const router = useRouter()
|
||||
const haptics = useHaptics()
|
||||
const { t } = useTranslation()
|
||||
const { id } = useLocalSearchParams<{ id: string }>()
|
||||
|
||||
const colors = useThemeColors()
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
|
||||
const [selectedLevelIndex, setSelectedLevelIndex] = useState(0)
|
||||
|
||||
const selectedLevel = LEVELS[selectedLevelIndex].id
|
||||
const category = CATEGORIES.find(c => c.id === id)
|
||||
const translatedCategories = useTranslatedCategories()
|
||||
|
||||
const levelLabels = [
|
||||
t('screens:category.allLevels'),
|
||||
t('levels.beginner'),
|
||||
t('levels.intermediate'),
|
||||
t('levels.advanced'),
|
||||
]
|
||||
|
||||
const selectedLevel = LEVEL_IDS[selectedLevelIndex]
|
||||
const category = translatedCategories.find(c => c.id === id)
|
||||
const categoryLabel = category?.label ?? id ?? 'Category'
|
||||
|
||||
const allWorkouts = useMemo(
|
||||
@@ -55,6 +63,8 @@ export default function CategoryDetailScreen() {
|
||||
return allWorkouts.filter(w => w.level === selectedLevel)
|
||||
}, [allWorkouts, selectedLevel])
|
||||
|
||||
const translatedWorkouts = useTranslatedWorkouts(filteredWorkouts)
|
||||
|
||||
const handleBack = () => {
|
||||
haptics.selection()
|
||||
router.back()
|
||||
@@ -70,15 +80,15 @@ export default function CategoryDetailScreen() {
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={handleBack} style={styles.backButton}>
|
||||
<Ionicons name="chevron-back" size={24} color={TEXT.PRIMARY} />
|
||||
<Ionicons name="chevron-back" size={24} color={colors.text.primary} />
|
||||
</Pressable>
|
||||
<StyledText size={22} weight="bold" color={TEXT.PRIMARY}>{categoryLabel}</StyledText>
|
||||
<StyledText size={22} weight="bold" color={colors.text.primary}>{categoryLabel}</StyledText>
|
||||
<View style={styles.backButton} />
|
||||
</View>
|
||||
|
||||
{/* Level Filter */}
|
||||
<View style={styles.filterContainer}>
|
||||
<Host matchContents useViewportSizeMeasurement colorScheme="dark">
|
||||
<Host matchContents useViewportSizeMeasurement colorScheme={colors.colorScheme}>
|
||||
<Picker
|
||||
selectedIndex={selectedLevelIndex}
|
||||
onOptionSelected={(e) => {
|
||||
@@ -86,7 +96,7 @@ export default function CategoryDetailScreen() {
|
||||
setSelectedLevelIndex(e.nativeEvent.index)
|
||||
}}
|
||||
variant="segmented"
|
||||
options={LEVELS.map(l => l.label)}
|
||||
options={levelLabels}
|
||||
color={BRAND.PRIMARY}
|
||||
/>
|
||||
</Host>
|
||||
@@ -94,10 +104,10 @@ export default function CategoryDetailScreen() {
|
||||
|
||||
<StyledText
|
||||
size={13}
|
||||
color={TEXT.TERTIARY}
|
||||
color={colors.text.tertiary}
|
||||
style={{ paddingHorizontal: LAYOUT.SCREEN_PADDING, marginBottom: SPACING[3] }}
|
||||
>
|
||||
{filteredWorkouts.length + ' workouts'}
|
||||
{t('plurals.workout', { count: translatedWorkouts.length })}
|
||||
</StyledText>
|
||||
|
||||
<ScrollView
|
||||
@@ -105,35 +115,32 @@ export default function CategoryDetailScreen() {
|
||||
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 100 }]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{filteredWorkouts.map((workout) => {
|
||||
const trainer = getTrainerById(workout.trainerId)
|
||||
return (
|
||||
{translatedWorkouts.map((workout) => (
|
||||
<Pressable
|
||||
key={workout.id}
|
||||
style={styles.workoutCard}
|
||||
onPress={() => handleWorkoutPress(workout.id)}
|
||||
>
|
||||
<View style={[styles.workoutAvatar, { backgroundColor: trainer?.color ?? BRAND.PRIMARY }]}>
|
||||
<RNText style={styles.workoutInitial}>{trainer?.name[0] ?? 'T'}</RNText>
|
||||
<View style={[styles.workoutAvatar, { backgroundColor: BRAND.PRIMARY }]}>
|
||||
<Ionicons name="flame" size={20} color="#FFFFFF" />
|
||||
</View>
|
||||
<View style={styles.workoutInfo}>
|
||||
<StyledText size={17} weight="semibold" color={TEXT.PRIMARY}>{workout.title}</StyledText>
|
||||
<StyledText size={13} color={TEXT.TERTIARY}>
|
||||
{trainer?.name + ' \u00B7 ' + workout.duration + ' min \u00B7 ' + workout.level}
|
||||
<StyledText size={17} weight="semibold" color={colors.text.primary}>{workout.title}</StyledText>
|
||||
<StyledText size={13} color={colors.text.tertiary}>
|
||||
{t('durationLevel', { duration: workout.duration, level: t(`levels.${workout.level.toLowerCase()}`) })}
|
||||
</StyledText>
|
||||
</View>
|
||||
<View style={styles.workoutMeta}>
|
||||
<StyledText size={13} color={BRAND.PRIMARY}>{workout.calories + ' cal'}</StyledText>
|
||||
<Ionicons name="chevron-forward" size={16} color={TEXT.TERTIARY} />
|
||||
<StyledText size={13} color={BRAND.PRIMARY}>{t('units.calUnit', { count: workout.calories })}</StyledText>
|
||||
<Ionicons name="chevron-forward" size={16} color={colors.text.tertiary} />
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
))}
|
||||
|
||||
{filteredWorkouts.length === 0 && (
|
||||
{translatedWorkouts.length === 0 && (
|
||||
<View style={styles.emptyState}>
|
||||
<Ionicons name="barbell-outline" size={48} color={TEXT.TERTIARY} />
|
||||
<StyledText size={17} color={TEXT.TERTIARY} style={{ marginTop: SPACING[3] }}>
|
||||
<Ionicons name="barbell-outline" size={48} color={colors.text.tertiary} />
|
||||
<StyledText size={17} color={colors.text.tertiary} style={{ marginTop: SPACING[3] }}>
|
||||
No workouts found
|
||||
</StyledText>
|
||||
</View>
|
||||
@@ -143,67 +150,69 @@ export default function CategoryDetailScreen() {
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: DARK.BASE,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
paddingVertical: SPACING[3],
|
||||
},
|
||||
backButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
filterContainer: {
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
marginBottom: SPACING[4],
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
},
|
||||
workoutCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: SPACING[3],
|
||||
paddingHorizontal: SPACING[4],
|
||||
backgroundColor: DARK.SURFACE,
|
||||
borderRadius: RADIUS.LG,
|
||||
marginBottom: SPACING[2],
|
||||
gap: SPACING[3],
|
||||
},
|
||||
workoutAvatar: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
workoutInitial: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: TEXT.PRIMARY,
|
||||
},
|
||||
workoutInfo: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
workoutMeta: {
|
||||
alignItems: 'flex-end',
|
||||
gap: 4,
|
||||
},
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: SPACING[12],
|
||||
},
|
||||
})
|
||||
function createStyles(colors: ThemeColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bg.base,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
paddingVertical: SPACING[3],
|
||||
},
|
||||
backButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
filterContainer: {
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
marginBottom: SPACING[4],
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
},
|
||||
workoutCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: SPACING[3],
|
||||
paddingHorizontal: SPACING[4],
|
||||
backgroundColor: colors.bg.surface,
|
||||
borderRadius: RADIUS.LG,
|
||||
marginBottom: SPACING[2],
|
||||
gap: SPACING[3],
|
||||
},
|
||||
workoutAvatar: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
workoutInitial: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: colors.text.primary,
|
||||
},
|
||||
workoutInfo: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
workoutMeta: {
|
||||
alignItems: 'flex-end',
|
||||
gap: 4,
|
||||
},
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: SPACING[12],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user