- Replace browse tab with Supabase-connected explore tab with filters - Add React Query for data fetching with loading states - Add 3 structured programs with weekly progression - Add Supabase anonymous auth sync service - Add PostHog analytics with screen tracking and events - Add comprehensive test strategy (Vitest + Maestro E2E) - Add RevenueCat subscription system with DEV simulation - Add i18n translations for new screens (EN/FR/DE/ES) - Add data deletion modal, sync consent modal - Add assessment screen and program routes - Add GitHub Actions CI workflow - Update activity store with sync integration
703 lines
21 KiB
TypeScript
703 lines
21 KiB
TypeScript
/**
|
|
* TabataFit Workout Complete Screen
|
|
* Celebration with real data from activity store
|
|
*/
|
|
|
|
import { useRef, useEffect, useMemo, useState } from 'react'
|
|
import {
|
|
View,
|
|
Text as RNText,
|
|
StyleSheet,
|
|
ScrollView,
|
|
Pressable,
|
|
Animated,
|
|
Dimensions,
|
|
} 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 * as Sharing from 'expo-sharing'
|
|
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 { useTranslatedWorkout, useTranslatedWorkouts } from '@/src/shared/data/useTranslatedData'
|
|
import { SyncConsentModal } from '@/src/shared/components/SyncConsentModal'
|
|
import { enableSync } from '@/src/shared/services/sync'
|
|
import type { WorkoutSessionData } from '@/src/shared/types'
|
|
|
|
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'
|
|
import { SPRING, EASE } from '@/src/shared/constants/animations'
|
|
|
|
const { width: SCREEN_WIDTH } = Dimensions.get('window')
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// BUTTON COMPONENTS
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
function SecondaryButton({
|
|
onPress,
|
|
children,
|
|
icon,
|
|
}: {
|
|
onPress: () => void
|
|
children: React.ReactNode
|
|
icon?: keyof typeof Ionicons.glyphMap
|
|
}) {
|
|
const colors = useThemeColors()
|
|
const styles = useMemo(() => createStyles(colors), [colors])
|
|
const scaleAnim = useRef(new Animated.Value(1)).current
|
|
|
|
const handlePressIn = () => {
|
|
Animated.spring(scaleAnim, {
|
|
toValue: 0.97,
|
|
useNativeDriver: true,
|
|
...SPRING.SNAPPY,
|
|
}).start()
|
|
}
|
|
|
|
const handlePressOut = () => {
|
|
Animated.spring(scaleAnim, {
|
|
toValue: 1,
|
|
useNativeDriver: true,
|
|
...SPRING.SNAPPY,
|
|
}).start()
|
|
}
|
|
|
|
return (
|
|
<Pressable
|
|
onPress={onPress}
|
|
onPressIn={handlePressIn}
|
|
onPressOut={handlePressOut}
|
|
style={{ width: '100%' }}
|
|
>
|
|
<Animated.View style={[styles.secondaryButton, { transform: [{ scale: scaleAnim }] }]}>
|
|
{icon && <Ionicons name={icon} size={18} color={colors.text.primary} style={styles.buttonIcon} />}
|
|
<RNText style={styles.secondaryButtonText}>{children}</RNText>
|
|
</Animated.View>
|
|
</Pressable>
|
|
)
|
|
}
|
|
|
|
function PrimaryButton({
|
|
onPress,
|
|
children,
|
|
}: {
|
|
onPress: () => void
|
|
children: React.ReactNode
|
|
}) {
|
|
const colors = useThemeColors()
|
|
const styles = useMemo(() => createStyles(colors), [colors])
|
|
const scaleAnim = useRef(new Animated.Value(1)).current
|
|
|
|
const handlePressIn = () => {
|
|
Animated.spring(scaleAnim, {
|
|
toValue: 0.97,
|
|
useNativeDriver: true,
|
|
...SPRING.SNAPPY,
|
|
}).start()
|
|
}
|
|
|
|
const handlePressOut = () => {
|
|
Animated.spring(scaleAnim, {
|
|
toValue: 1,
|
|
useNativeDriver: true,
|
|
...SPRING.SNAPPY,
|
|
}).start()
|
|
}
|
|
|
|
return (
|
|
<Pressable
|
|
onPress={onPress}
|
|
onPressIn={handlePressIn}
|
|
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>
|
|
</Pressable>
|
|
)
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// COMPONENTS
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
function CelebrationRings() {
|
|
const colors = useThemeColors()
|
|
const styles = useMemo(() => createStyles(colors), [colors])
|
|
const ring1Anim = useRef(new Animated.Value(0)).current
|
|
const ring2Anim = useRef(new Animated.Value(0)).current
|
|
const ring3Anim = useRef(new Animated.Value(0)).current
|
|
|
|
useEffect(() => {
|
|
Animated.stagger(200, [
|
|
Animated.spring(ring1Anim, {
|
|
toValue: 1,
|
|
...SPRING.BOUNCY,
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.spring(ring2Anim, {
|
|
toValue: 1,
|
|
...SPRING.BOUNCY,
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.spring(ring3Anim, {
|
|
toValue: 1,
|
|
...SPRING.BOUNCY,
|
|
useNativeDriver: true,
|
|
}),
|
|
]).start()
|
|
}, [])
|
|
|
|
return (
|
|
<View style={styles.ringsContainer}>
|
|
<Animated.View
|
|
style={[styles.ring, styles.ring1, { transform: [{ scale: ring1Anim }], opacity: ring1Anim }]}
|
|
>
|
|
<RNText style={styles.ringEmoji}>🔥</RNText>
|
|
</Animated.View>
|
|
<Animated.View
|
|
style={[styles.ring, styles.ring2, { transform: [{ scale: ring2Anim }], opacity: ring2Anim }]}
|
|
>
|
|
<RNText style={styles.ringEmoji}>💪</RNText>
|
|
</Animated.View>
|
|
<Animated.View
|
|
style={[styles.ring, styles.ring3, { transform: [{ scale: ring3Anim }], opacity: ring3Anim }]}
|
|
>
|
|
<RNText style={styles.ringEmoji}>⚡</RNText>
|
|
</Animated.View>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
function StatCard({
|
|
value,
|
|
label,
|
|
icon,
|
|
delay = 0,
|
|
}: {
|
|
value: string | number
|
|
label: string
|
|
icon: keyof typeof Ionicons.glyphMap
|
|
delay?: number
|
|
}) {
|
|
const colors = useThemeColors()
|
|
const styles = useMemo(() => createStyles(colors), [colors])
|
|
const scaleAnim = useRef(new Animated.Value(0)).current
|
|
|
|
useEffect(() => {
|
|
Animated.sequence([
|
|
Animated.delay(delay),
|
|
Animated.spring(scaleAnim, {
|
|
toValue: 1,
|
|
...SPRING.BOUNCY,
|
|
useNativeDriver: true,
|
|
}),
|
|
]).start()
|
|
}, [delay])
|
|
|
|
return (
|
|
<Animated.View style={[styles.statCard, { transform: [{ scale: scaleAnim }] }]}>
|
|
<BlurView intensity={colors.glass.blurMedium} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
|
|
<Ionicons name={icon} size={24} color={BRAND.PRIMARY} />
|
|
<RNText style={styles.statValue}>{value}</RNText>
|
|
<RNText style={styles.statLabel}>{label}</RNText>
|
|
</Animated.View>
|
|
)
|
|
}
|
|
|
|
function BurnBarResult({ percentile }: { percentile: number }) {
|
|
const { t } = useTranslation()
|
|
const colors = useThemeColors()
|
|
const styles = useMemo(() => createStyles(colors), [colors])
|
|
const barAnim = useRef(new Animated.Value(0)).current
|
|
|
|
useEffect(() => {
|
|
Animated.timing(barAnim, {
|
|
toValue: percentile,
|
|
duration: 1000,
|
|
easing: EASE.EASE_OUT,
|
|
useNativeDriver: false,
|
|
}).start()
|
|
}, [percentile])
|
|
|
|
const barWidth = barAnim.interpolate({
|
|
inputRange: [0, 100],
|
|
outputRange: ['0%', '100%'],
|
|
})
|
|
|
|
return (
|
|
<View style={styles.burnBarContainer}>
|
|
<RNText style={styles.burnBarTitle}>{t('screens:complete.burnBar')}</RNText>
|
|
<RNText style={styles.burnBarResult}>{t('screens:complete.burnBarResult', { percentile })}</RNText>
|
|
<View style={styles.burnBarTrack}>
|
|
<Animated.View style={[styles.burnBarFill, { width: barWidth }]} />
|
|
</View>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// MAIN SCREEN
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
export default function WorkoutCompleteScreen() {
|
|
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 rawWorkout = getWorkoutById(id ?? '1')
|
|
const workout = useTranslatedWorkout(rawWorkout)
|
|
const streak = useActivityStore((s) => s.streak)
|
|
const history = useActivityStore((s) => s.history)
|
|
const recentWorkouts = history.slice(0, 1)
|
|
|
|
// Sync consent modal state
|
|
const [showSyncPrompt, setShowSyncPrompt] = useState(false)
|
|
const { profile, setSyncStatus } = useUserStore()
|
|
|
|
// Get the most recent result for this workout
|
|
const latestResult = recentWorkouts[0]
|
|
const resultCalories = latestResult?.calories ?? workout?.calories ?? 45
|
|
const resultMinutes = latestResult?.durationMinutes ?? workout?.duration ?? 4
|
|
|
|
// Recommended workouts (different from current)
|
|
const rawRecommended = getPopularWorkouts(4).filter(w => w.id !== id).slice(0, 3)
|
|
const recommended = useTranslatedWorkouts(rawRecommended)
|
|
|
|
const handleGoHome = () => {
|
|
haptics.buttonTap()
|
|
router.replace('/(tabs)')
|
|
}
|
|
|
|
const handleShare = async () => {
|
|
haptics.selection()
|
|
const isAvailable = await Sharing.isAvailableAsync()
|
|
if (isAvailable) {
|
|
await Sharing.shareAsync('https://tabatafit.app', {
|
|
dialogTitle: t('screens:complete.shareText', { title: workout?.title ?? 'a workout', calories: resultCalories, duration: resultMinutes }),
|
|
})
|
|
}
|
|
}
|
|
|
|
const handleWorkoutPress = (workoutId: string) => {
|
|
haptics.buttonTap()
|
|
router.push(`/workout/${workoutId}`)
|
|
}
|
|
|
|
// Check if we should show sync prompt (after first workout for premium users)
|
|
useEffect(() => {
|
|
if (profile.syncStatus === 'prompt-pending') {
|
|
// Wait a moment for the user to see their results first
|
|
const timer = setTimeout(() => {
|
|
setShowSyncPrompt(true)
|
|
}, 1500)
|
|
return () => clearTimeout(timer)
|
|
}
|
|
}, [profile.syncStatus])
|
|
|
|
const handleSyncAccept = async () => {
|
|
setShowSyncPrompt(false)
|
|
|
|
// Prepare data for sync
|
|
const profileData = {
|
|
name: profile.name,
|
|
fitnessLevel: profile.fitnessLevel,
|
|
goal: profile.goal,
|
|
weeklyFrequency: profile.weeklyFrequency,
|
|
barriers: profile.barriers,
|
|
onboardingCompletedAt: new Date().toISOString(),
|
|
}
|
|
|
|
// Get all workout history for retroactive sync
|
|
const workoutHistory: WorkoutSessionData[] = history.map((w) => ({
|
|
workoutId: w.workoutId,
|
|
completedAt: new Date(w.completedAt).toISOString(),
|
|
durationSeconds: w.durationMinutes * 60,
|
|
caloriesBurned: w.calories,
|
|
}))
|
|
|
|
// Enable sync
|
|
const result = await enableSync(profileData, workoutHistory)
|
|
|
|
if (result.success) {
|
|
setSyncStatus('synced', result.userId || null)
|
|
} else {
|
|
// Show error - sync failed
|
|
setSyncStatus('never-synced')
|
|
}
|
|
}
|
|
|
|
const handleSyncDecline = () => {
|
|
setShowSyncPrompt(false)
|
|
setSyncStatus('never-synced') // Reset so we don't ask again
|
|
}
|
|
|
|
// Simulate percentile
|
|
const burnBarPercentile = Math.min(95, Math.max(40, Math.round((resultCalories / (workout?.calories ?? 45)) * 70)))
|
|
|
|
return (
|
|
<View style={[styles.container, { paddingTop: insets.top }]}>
|
|
<ScrollView
|
|
style={styles.scrollView}
|
|
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 100 }]}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
{/* Celebration */}
|
|
<View style={styles.celebrationSection}>
|
|
<RNText style={styles.celebrationEmoji}>🎉</RNText>
|
|
<RNText style={styles.celebrationTitle}>{t('screens:complete.title')}</RNText>
|
|
<CelebrationRings />
|
|
</View>
|
|
|
|
{/* Stats Grid */}
|
|
<View style={styles.statsGrid}>
|
|
<StatCard value={resultCalories} label={t('screens:complete.caloriesLabel')} icon="flame" delay={100} />
|
|
<StatCard value={resultMinutes} label={t('screens:complete.minutesLabel')} icon="time" delay={200} />
|
|
<StatCard value="100%" label={t('screens:complete.completeLabel')} icon="checkmark-circle" delay={300} />
|
|
</View>
|
|
|
|
{/* Burn Bar */}
|
|
<BurnBarResult percentile={burnBarPercentile} />
|
|
|
|
<View style={styles.divider} />
|
|
|
|
{/* Streak */}
|
|
<View style={styles.streakSection}>
|
|
<View style={styles.streakBadge}>
|
|
<Ionicons name="flame" size={32} color={BRAND.PRIMARY} />
|
|
</View>
|
|
<View style={styles.streakInfo}>
|
|
<RNText style={styles.streakTitle}>{t('screens:complete.streakTitle', { count: streak.current })}</RNText>
|
|
<RNText style={styles.streakSubtitle}>{t('screens:complete.streakSubtitle')}</RNText>
|
|
</View>
|
|
</View>
|
|
|
|
<View style={styles.divider} />
|
|
|
|
{/* Share Button */}
|
|
<View style={styles.shareSection}>
|
|
<SecondaryButton onPress={handleShare} icon="share-outline">
|
|
{t('screens:complete.shareWorkout')}
|
|
</SecondaryButton>
|
|
</View>
|
|
|
|
<View style={styles.divider} />
|
|
|
|
{/* Recommended */}
|
|
<View style={styles.recommendedSection}>
|
|
<RNText style={styles.recommendedTitle}>{t('screens:complete.recommendedNext')}</RNText>
|
|
<View style={styles.recommendedGrid}>
|
|
{recommended.map((w) => (
|
|
<Pressable
|
|
key={w.id}
|
|
onPress={() => handleWorkoutPress(w.id)}
|
|
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}
|
|
/>
|
|
<Ionicons name="flame" size={24} color="#FFFFFF" />
|
|
</View>
|
|
<RNText style={styles.recommendedTitleText} numberOfLines={1}>{w.title}</RNText>
|
|
<RNText style={styles.recommendedDurationText}>{t('units.minUnit', { count: w.duration })}</RNText>
|
|
</Pressable>
|
|
))}
|
|
</View>
|
|
</View>
|
|
</ScrollView>
|
|
|
|
{/* Fixed Bottom Button */}
|
|
<View style={[styles.bottomBar, { paddingBottom: insets.bottom + SPACING[4] }]}>
|
|
<BlurView intensity={colors.glass.blurHeavy} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
|
|
<View style={styles.homeButtonContainer}>
|
|
<PrimaryButton onPress={handleGoHome}>
|
|
{t('screens:complete.backToHome')}
|
|
</PrimaryButton>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Sync Consent Modal */}
|
|
<SyncConsentModal
|
|
visible={showSyncPrompt}
|
|
onAccept={handleSyncAccept}
|
|
onDecline={handleSyncDecline}
|
|
/>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// STYLES
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
function createStyles(colors: ThemeColors) {
|
|
return StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: colors.bg.base,
|
|
},
|
|
scrollView: {
|
|
flex: 1,
|
|
},
|
|
scrollContent: {
|
|
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
|
},
|
|
|
|
// Buttons
|
|
secondaryButton: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
paddingVertical: SPACING[3],
|
|
paddingHorizontal: SPACING[4],
|
|
borderRadius: RADIUS.LG,
|
|
borderWidth: 1,
|
|
borderColor: colors.border.glassStrong,
|
|
backgroundColor: 'transparent',
|
|
},
|
|
secondaryButtonText: {
|
|
...TYPOGRAPHY.BODY,
|
|
color: colors.text.primary,
|
|
fontWeight: '600',
|
|
},
|
|
primaryButton: {
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
paddingVertical: SPACING[4],
|
|
paddingHorizontal: SPACING[6],
|
|
borderRadius: RADIUS.LG,
|
|
overflow: 'hidden',
|
|
},
|
|
primaryButtonText: {
|
|
...TYPOGRAPHY.HEADLINE,
|
|
color: '#FFFFFF',
|
|
fontWeight: '700',
|
|
},
|
|
buttonIcon: {
|
|
marginRight: SPACING[2],
|
|
},
|
|
|
|
// Celebration
|
|
celebrationSection: {
|
|
alignItems: 'center',
|
|
paddingVertical: SPACING[8],
|
|
},
|
|
celebrationEmoji: {
|
|
fontSize: 64,
|
|
marginBottom: SPACING[4],
|
|
},
|
|
celebrationTitle: {
|
|
...TYPOGRAPHY.TITLE_1,
|
|
color: colors.text.primary,
|
|
letterSpacing: 2,
|
|
},
|
|
ringsContainer: {
|
|
flexDirection: 'row',
|
|
marginTop: SPACING[6],
|
|
gap: SPACING[4],
|
|
},
|
|
ring: {
|
|
width: 64,
|
|
height: 64,
|
|
borderRadius: 32,
|
|
backgroundColor: colors.border.glass,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
borderWidth: 2,
|
|
borderColor: colors.border.glassStrong,
|
|
},
|
|
ring1: {
|
|
borderColor: BRAND.PRIMARY,
|
|
backgroundColor: 'rgba(255, 107, 53, 0.15)',
|
|
},
|
|
ring2: {
|
|
borderColor: '#30D158',
|
|
backgroundColor: 'rgba(48, 209, 88, 0.15)',
|
|
},
|
|
ring3: {
|
|
borderColor: '#5AC8FA',
|
|
backgroundColor: 'rgba(90, 200, 250, 0.15)',
|
|
},
|
|
ringEmoji: {
|
|
fontSize: 28,
|
|
},
|
|
|
|
// Stats Grid
|
|
statsGrid: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
marginBottom: SPACING[6],
|
|
},
|
|
statCard: {
|
|
width: (SCREEN_WIDTH - LAYOUT.SCREEN_PADDING * 2 - SPACING[4]) / 3,
|
|
padding: SPACING[3],
|
|
borderRadius: RADIUS.LG,
|
|
alignItems: 'center',
|
|
borderWidth: 1,
|
|
borderColor: colors.border.glass,
|
|
overflow: 'hidden',
|
|
},
|
|
statValue: {
|
|
...TYPOGRAPHY.TITLE_1,
|
|
color: colors.text.primary,
|
|
marginTop: SPACING[2],
|
|
},
|
|
statLabel: {
|
|
...TYPOGRAPHY.CAPTION_2,
|
|
color: colors.text.tertiary,
|
|
marginTop: SPACING[1],
|
|
},
|
|
|
|
// Burn Bar
|
|
burnBarContainer: {
|
|
marginBottom: SPACING[6],
|
|
},
|
|
burnBarTitle: {
|
|
...TYPOGRAPHY.HEADLINE,
|
|
color: colors.text.tertiary,
|
|
},
|
|
burnBarResult: {
|
|
...TYPOGRAPHY.BODY,
|
|
color: BRAND.PRIMARY,
|
|
marginTop: SPACING[1],
|
|
marginBottom: SPACING[3],
|
|
},
|
|
burnBarTrack: {
|
|
height: 8,
|
|
backgroundColor: colors.bg.surface,
|
|
borderRadius: 4,
|
|
overflow: 'hidden',
|
|
},
|
|
burnBarFill: {
|
|
height: '100%',
|
|
backgroundColor: BRAND.PRIMARY,
|
|
borderRadius: 4,
|
|
},
|
|
|
|
// Divider
|
|
divider: {
|
|
height: 1,
|
|
backgroundColor: colors.border.glass,
|
|
marginVertical: SPACING[2],
|
|
},
|
|
|
|
// Streak
|
|
streakSection: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingVertical: SPACING[4],
|
|
gap: SPACING[4],
|
|
},
|
|
streakBadge: {
|
|
width: 64,
|
|
height: 64,
|
|
borderRadius: 32,
|
|
backgroundColor: 'rgba(255, 107, 53, 0.15)',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
streakInfo: {
|
|
flex: 1,
|
|
},
|
|
streakTitle: {
|
|
...TYPOGRAPHY.TITLE_2,
|
|
color: colors.text.primary,
|
|
},
|
|
streakSubtitle: {
|
|
...TYPOGRAPHY.BODY,
|
|
color: colors.text.tertiary,
|
|
marginTop: SPACING[1],
|
|
},
|
|
|
|
// Share
|
|
shareSection: {
|
|
paddingVertical: SPACING[4],
|
|
alignItems: 'center',
|
|
},
|
|
|
|
// Recommended
|
|
recommendedSection: {
|
|
paddingVertical: SPACING[4],
|
|
},
|
|
recommendedTitle: {
|
|
...TYPOGRAPHY.HEADLINE,
|
|
color: colors.text.primary,
|
|
marginBottom: SPACING[4],
|
|
},
|
|
recommendedGrid: {
|
|
flexDirection: 'row',
|
|
gap: SPACING[3],
|
|
},
|
|
recommendedCard: {
|
|
flex: 1,
|
|
padding: SPACING[3],
|
|
borderRadius: RADIUS.LG,
|
|
borderWidth: 1,
|
|
borderColor: colors.border.glass,
|
|
overflow: 'hidden',
|
|
},
|
|
recommendedThumb: {
|
|
width: '100%',
|
|
aspectRatio: 1,
|
|
borderRadius: RADIUS.MD,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginBottom: SPACING[2],
|
|
overflow: 'hidden',
|
|
},
|
|
recommendedInitial: {
|
|
...TYPOGRAPHY.TITLE_1,
|
|
color: colors.text.primary,
|
|
},
|
|
recommendedTitleText: {
|
|
...TYPOGRAPHY.CARD_TITLE,
|
|
color: colors.text.primary,
|
|
},
|
|
recommendedDurationText: {
|
|
...TYPOGRAPHY.CARD_METADATA,
|
|
color: colors.text.tertiary,
|
|
},
|
|
|
|
// Bottom Bar
|
|
bottomBar: {
|
|
position: 'absolute',
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
|
paddingTop: SPACING[4],
|
|
borderTopWidth: 1,
|
|
borderTopColor: colors.border.glass,
|
|
},
|
|
homeButtonContainer: {
|
|
height: 56,
|
|
justifyContent: 'center',
|
|
},
|
|
})
|
|
}
|