refresh design system: colors, typography, native components
Update color palette, dark theme tokens, typography scale, and border radius constants. Refactor native UI primitives (NativeButton, NativeList, NativeSection, NativeLabeledRow) for the new design language. Simplify OnboardingStep. Remove legacy WorkoutCard and CollectionCard components.
This commit is contained in:
@@ -1,186 +0,0 @@
|
||||
/**
|
||||
* CollectionCard - Premium collection card
|
||||
* Used in Explore and Browse screens
|
||||
* Supports 'default' and 'hero' variants
|
||||
*/
|
||||
|
||||
import { useMemo, useRef, useCallback } from 'react'
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
Animated,
|
||||
ImageBackground,
|
||||
useWindowDimensions,
|
||||
Text as RNText,
|
||||
} from 'react-native'
|
||||
|
||||
import { useThemeColors, GRADIENTS } from '@/src/shared/theme'
|
||||
import type { ThemeColors } from '@/src/shared/theme/types'
|
||||
import { TEXT, NAVY, GREEN } from '@/src/shared/constants/colors'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
|
||||
import { StyledText } from './StyledText'
|
||||
import type { Collection } from '@/src/shared/types'
|
||||
|
||||
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
|
||||
|
||||
export type CollectionCardVariant = 'default' | 'hero' | 'horizontal'
|
||||
|
||||
interface CollectionCardProps {
|
||||
collection: Collection
|
||||
variant?: CollectionCardVariant
|
||||
onPress?: () => void
|
||||
imageUrl?: string
|
||||
workoutCountLabel?: string
|
||||
}
|
||||
|
||||
export function CollectionCard({ collection, variant = 'default', onPress, imageUrl, workoutCountLabel }: CollectionCardProps) {
|
||||
const colors = useThemeColors()
|
||||
const { width: screenWidth } = useWindowDimensions()
|
||||
const styles = useMemo(() => createStyles(colors, screenWidth, variant), [colors, screenWidth, variant])
|
||||
|
||||
// Press animation
|
||||
const scaleValue = useRef(new Animated.Value(1)).current
|
||||
const handlePressIn = useCallback(() => {
|
||||
Animated.spring(scaleValue, {
|
||||
toValue: 0.97,
|
||||
useNativeDriver: true,
|
||||
speed: 50,
|
||||
bounciness: 4,
|
||||
}).start()
|
||||
}, [scaleValue])
|
||||
const handlePressOut = useCallback(() => {
|
||||
Animated.spring(scaleValue, {
|
||||
toValue: 1,
|
||||
useNativeDriver: true,
|
||||
speed: 30,
|
||||
bounciness: 6,
|
||||
}).start()
|
||||
}, [scaleValue])
|
||||
|
||||
const countLabel = workoutCountLabel ?? `${collection.workoutIds.length} workouts`
|
||||
|
||||
return (
|
||||
<AnimatedPressable
|
||||
style={[styles.container, { transform: [{ scale: scaleValue }] }]}
|
||||
onPress={onPress}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
>
|
||||
{/* Background Image or Solid Color */}
|
||||
{imageUrl ? (
|
||||
<ImageBackground
|
||||
source={{ uri: imageUrl }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
imageStyle={{ borderRadius: RADIUS.XL }}
|
||||
resizeMode="cover"
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
StyleSheet.absoluteFill,
|
||||
{
|
||||
backgroundColor: GRADIENTS.VIDEO_OVERLAY[1],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ImageBackground>
|
||||
) : (
|
||||
<View
|
||||
style={[
|
||||
StyleSheet.absoluteFill,
|
||||
{
|
||||
borderRadius: RADIUS.XL,
|
||||
backgroundColor: NAVY[800],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<View style={styles.content}>
|
||||
<View style={styles.iconContainer}>
|
||||
<RNText style={styles.icon}>{collection.icon}</RNText>
|
||||
</View>
|
||||
|
||||
<StyledText
|
||||
preset={variant === 'hero' ? 'TITLE_2' : 'HEADLINE'}
|
||||
color={TEXT.PRIMARY}
|
||||
numberOfLines={2}
|
||||
style={styles.title}
|
||||
>
|
||||
{collection.title}
|
||||
</StyledText>
|
||||
|
||||
{variant === 'hero' && (
|
||||
<StyledText
|
||||
preset="CARD_SUBTITLE"
|
||||
color={TEXT.SECONDARY}
|
||||
numberOfLines={2}
|
||||
style={{ marginBottom: SPACING[1] }}
|
||||
>
|
||||
{collection.description}
|
||||
</StyledText>
|
||||
)}
|
||||
|
||||
<StyledText
|
||||
preset="CARD_METADATA"
|
||||
color={TEXT.TERTIARY}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{countLabel}
|
||||
</StyledText>
|
||||
</View>
|
||||
</AnimatedPressable>
|
||||
)
|
||||
}
|
||||
|
||||
function createStyles(colors: ThemeColors, screenWidth: number, variant: CollectionCardVariant) {
|
||||
const defaultCardWidth = (screenWidth - LAYOUT.SCREEN_PADDING * 2 - SPACING[3]) / 2
|
||||
const horizontalCardWidth = screenWidth * 0.65
|
||||
|
||||
const containerByVariant = {
|
||||
default: {
|
||||
width: defaultCardWidth,
|
||||
aspectRatio: 1 as number,
|
||||
},
|
||||
hero: {
|
||||
width: screenWidth - LAYOUT.SCREEN_PADDING * 2,
|
||||
height: 200,
|
||||
},
|
||||
horizontal: {
|
||||
width: horizontalCardWidth,
|
||||
height: 180,
|
||||
},
|
||||
}
|
||||
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
...containerByVariant[variant],
|
||||
borderRadius: RADIUS.XL,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: SPACING[4],
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
iconContainer: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: RADIUS.LG,
|
||||
backgroundColor: GREEN.DIM,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: SPACING[3],
|
||||
borderWidth: 1,
|
||||
borderColor: GREEN.BORDER,
|
||||
},
|
||||
icon: {
|
||||
fontSize: 24,
|
||||
},
|
||||
title: {
|
||||
marginBottom: SPACING[1],
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,19 +1,20 @@
|
||||
/**
|
||||
* TabataFit OnboardingStep
|
||||
* Reusable wrapper for each onboarding screen — progress bar, animation, layout
|
||||
*
|
||||
* Revamped: fade-up entrance, segmented progress, generous spacing
|
||||
*/
|
||||
|
||||
import { useRef, useEffect, useMemo } from 'react'
|
||||
import { View, StyleSheet, Animated, Dimensions, Pressable } from 'react-native'
|
||||
import { View, StyleSheet, Animated, Pressable } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { Icon } from './Icon'
|
||||
import { useThemeColors, BRAND } from '../theme'
|
||||
import { useThemeColors } from '../theme'
|
||||
import type { ThemeColors } from '../theme/types'
|
||||
import { SPACING, LAYOUT } from '../constants/spacing'
|
||||
import { RADIUS } from '../constants/borderRadius'
|
||||
import { DURATION, EASE } from '../constants/animations'
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window')
|
||||
import { DURATION, EASE, SPRING } from '../constants/animations'
|
||||
import { GREEN } from '../constants/colors'
|
||||
|
||||
interface OnboardingStepProps {
|
||||
step: number
|
||||
@@ -26,62 +27,74 @@ export function OnboardingStep({ step, totalSteps, children, onBack }: Onboardin
|
||||
const colors = useThemeColors()
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
const insets = useSafeAreaInsets()
|
||||
const slideAnim = useRef(new Animated.Value(SCREEN_WIDTH)).current
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current
|
||||
const progressAnim = useRef(new Animated.Value(0)).current
|
||||
const slideAnim = useRef(new Animated.Value(24)).current
|
||||
const progressAnims = useRef(
|
||||
Array.from({ length: totalSteps }, () => new Animated.Value(0))
|
||||
).current
|
||||
|
||||
useEffect(() => {
|
||||
// Reset position for new step
|
||||
slideAnim.setValue(SCREEN_WIDTH)
|
||||
// Reset for new step — fade up instead of slide right
|
||||
fadeAnim.setValue(0)
|
||||
slideAnim.setValue(24)
|
||||
|
||||
// Animate in
|
||||
Animated.parallel([
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: 0,
|
||||
duration: DURATION.NORMAL,
|
||||
easing: EASE.EASE_OUT,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: DURATION.NORMAL,
|
||||
easing: EASE.EASE_OUT,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.spring(slideAnim, {
|
||||
toValue: 0,
|
||||
...SPRING.GENTLE,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start()
|
||||
|
||||
// Animate progress bar
|
||||
Animated.timing(progressAnim, {
|
||||
toValue: step / totalSteps,
|
||||
duration: DURATION.SLOW,
|
||||
easing: EASE.EASE_OUT,
|
||||
useNativeDriver: false,
|
||||
}).start()
|
||||
// Animate progress segments
|
||||
progressAnims.forEach((anim, i) => {
|
||||
Animated.spring(anim, {
|
||||
toValue: i < step ? 1 : 0,
|
||||
...SPRING.SNAPPY,
|
||||
useNativeDriver: false,
|
||||
}).start()
|
||||
})
|
||||
}, [step])
|
||||
|
||||
const progressWidth = progressAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['0%', '100%'],
|
||||
})
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top + SPACING[3] }]}>
|
||||
{/* Progress bar */}
|
||||
<View style={styles.progressTrack}>
|
||||
<Animated.View style={[styles.progressFill, { width: progressWidth }]} />
|
||||
<View style={[styles.container, { paddingTop: insets.top + SPACING[4] }]}>
|
||||
{/* Segmented progress bar */}
|
||||
<View style={styles.progressRow}>
|
||||
{progressAnims.map((anim, i) => (
|
||||
<View key={i} style={styles.segmentTrack}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.segmentFill,
|
||||
{
|
||||
width: anim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['0%', '100%'],
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Back button — visible on steps 2+ */}
|
||||
{onBack && step > 1 && (
|
||||
{onBack && step > 1 ? (
|
||||
<Pressable
|
||||
style={styles.backButton}
|
||||
onPress={onBack}
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
testID="onboarding-back-button"
|
||||
>
|
||||
<Icon name="chevron.left" size={24} tintColor={colors.text.secondary} />
|
||||
<Icon name="chevron.left" size={22} tintColor={colors.text.secondary} />
|
||||
</Pressable>
|
||||
) : (
|
||||
<View style={styles.backSpacer} />
|
||||
)}
|
||||
|
||||
{/* Step content */}
|
||||
@@ -89,10 +102,10 @@ export function OnboardingStep({ step, totalSteps, children, onBack }: Onboardin
|
||||
style={[
|
||||
styles.content,
|
||||
{
|
||||
transform: [{ translateX: slideAnim }],
|
||||
transform: [{ translateY: slideAnim }],
|
||||
opacity: fadeAnim,
|
||||
},
|
||||
{ paddingBottom: insets.bottom + SPACING[6] },
|
||||
{ paddingBottom: insets.bottom + SPACING[8] },
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
@@ -107,30 +120,37 @@ function createStyles(colors: ThemeColors) {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bg.base,
|
||||
},
|
||||
progressTrack: {
|
||||
height: 3,
|
||||
backgroundColor: colors.bg.surface,
|
||||
progressRow: {
|
||||
flexDirection: 'row',
|
||||
gap: SPACING[1],
|
||||
marginHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
borderRadius: RADIUS.XS,
|
||||
},
|
||||
segmentTrack: {
|
||||
flex: 1,
|
||||
height: 4,
|
||||
backgroundColor: colors.bg.overlay2,
|
||||
borderRadius: RADIUS.PILL,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressFill: {
|
||||
segmentFill: {
|
||||
height: '100%',
|
||||
backgroundColor: BRAND.PRIMARY,
|
||||
borderRadius: RADIUS.XS,
|
||||
backgroundColor: GREEN[500],
|
||||
borderRadius: RADIUS.PILL,
|
||||
},
|
||||
backButton: {
|
||||
marginTop: SPACING[3],
|
||||
marginLeft: SPACING[3],
|
||||
width: 40,
|
||||
height: 40,
|
||||
marginLeft: SPACING[2],
|
||||
width: LAYOUT.TOUCH_TARGET,
|
||||
height: LAYOUT.TOUCH_TARGET,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
backSpacer: {
|
||||
height: SPACING[3] + LAYOUT.TOUCH_TARGET,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
paddingTop: SPACING[8],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
/**
|
||||
* WorkoutCard - Dark Medical workout card
|
||||
* Flat navy surface with border-dim, no glassmorphism
|
||||
*/
|
||||
|
||||
import { useMemo, useRef, useCallback } from 'react'
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
Animated,
|
||||
ImageBackground,
|
||||
useWindowDimensions,
|
||||
Text as RNText,
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import { Icon } from './Icon'
|
||||
|
||||
import { useThemeColors } from '@/src/shared/theme'
|
||||
import { BRAND, GRADIENTS, TEXT, NAVY, BORDER_COLORS, PHASE } from '@/src/shared/constants/colors'
|
||||
import { FONT_FAMILY_SANS_SEMIBOLD } from '@/src/shared/constants/typography'
|
||||
import type { ThemeColors } from '@/src/shared/theme/types'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
import { StyledText } from './StyledText'
|
||||
import type { Workout, WorkoutCategory } from '@/src/shared/types'
|
||||
|
||||
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
|
||||
|
||||
export type WorkoutCardVariant = 'horizontal' | 'grid' | 'featured'
|
||||
|
||||
interface WorkoutCardProps {
|
||||
workout: Workout
|
||||
variant?: WorkoutCardVariant
|
||||
onPress?: () => void
|
||||
title?: string
|
||||
metadata?: string
|
||||
trainerName?: string
|
||||
isLocked?: boolean
|
||||
}
|
||||
|
||||
export const CATEGORY_COLORS: Record<WorkoutCategory, string> = {
|
||||
'full-body': BRAND.PRIMARY,
|
||||
'core': BRAND.INFO,
|
||||
'upper-body': TEXT.SECONDARY,
|
||||
'lower-body': BRAND.PRIMARY,
|
||||
'cardio': PHASE.PREP,
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<WorkoutCategory, string> = {
|
||||
'full-body': 'Full Body',
|
||||
'core': 'Core',
|
||||
'upper-body': 'Upper Body',
|
||||
'lower-body': 'Lower Body',
|
||||
'cardio': 'Cardio',
|
||||
}
|
||||
|
||||
function getVariantDimensions(variant: WorkoutCardVariant, screenWidth: number): ViewStyle {
|
||||
switch (variant) {
|
||||
case 'featured':
|
||||
return {
|
||||
width: screenWidth - SPACING[6] * 2,
|
||||
height: 320,
|
||||
}
|
||||
case 'horizontal':
|
||||
return {
|
||||
width: 200,
|
||||
height: 280,
|
||||
}
|
||||
case 'grid':
|
||||
default:
|
||||
return {
|
||||
flex: 1,
|
||||
aspectRatio: 0.75,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function WorkoutCard({
|
||||
workout,
|
||||
variant = 'horizontal',
|
||||
onPress,
|
||||
title,
|
||||
metadata,
|
||||
trainerName,
|
||||
isLocked,
|
||||
}: WorkoutCardProps) {
|
||||
const colors = useThemeColors()
|
||||
const { width: screenWidth } = useWindowDimensions()
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
const dimensions = useMemo(() => getVariantDimensions(variant, screenWidth), [variant, screenWidth])
|
||||
|
||||
// Press animation
|
||||
const scaleValue = useRef(new Animated.Value(1)).current
|
||||
const handlePressIn = useCallback(() => {
|
||||
Animated.spring(scaleValue, {
|
||||
toValue: 0.97,
|
||||
useNativeDriver: true,
|
||||
speed: 50,
|
||||
bounciness: 4,
|
||||
}).start()
|
||||
}, [scaleValue])
|
||||
const handlePressOut = useCallback(() => {
|
||||
Animated.spring(scaleValue, {
|
||||
toValue: 1,
|
||||
useNativeDriver: true,
|
||||
speed: 30,
|
||||
bounciness: 6,
|
||||
}).start()
|
||||
}, [scaleValue])
|
||||
|
||||
const displayTitle = title ?? workout.title
|
||||
const metaParts = [
|
||||
`${workout.duration} min`,
|
||||
`${workout.calories} cal`,
|
||||
...(trainerName ? [trainerName] : []),
|
||||
]
|
||||
const displayMetadata = metadata ?? metaParts.join(' · ')
|
||||
const categoryColor = CATEGORY_COLORS[workout.category]
|
||||
|
||||
return (
|
||||
<AnimatedPressable
|
||||
style={[styles.container, dimensions, { transform: [{ scale: scaleValue }] }]}
|
||||
onPress={onPress}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
>
|
||||
{/* Background Image */}
|
||||
<ImageBackground
|
||||
source={workout.thumbnailUrl ? { uri: workout.thumbnailUrl } : undefined}
|
||||
style={StyleSheet.absoluteFill}
|
||||
imageStyle={{ borderRadius: RADIUS.LG }}
|
||||
resizeMode="cover"
|
||||
>
|
||||
{/* Dark overlay for text readability */}
|
||||
<LinearGradient
|
||||
colors={GRADIENTS.CARD_OVERLAY}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
</ImageBackground>
|
||||
|
||||
{/* Category Badge — flat navy */}
|
||||
<View style={[styles.categoryBadge, { backgroundColor: `${categoryColor}20`, borderColor: `${categoryColor}40` }]}>
|
||||
<RNText style={[styles.categoryText, { color: categoryColor }]}>
|
||||
{CATEGORY_LABELS[workout.category]}
|
||||
</RNText>
|
||||
</View>
|
||||
|
||||
{/* Play Button — flat navy circle */}
|
||||
<View style={styles.playButtonContainer}>
|
||||
<View style={styles.playButton}>
|
||||
<Icon name={isLocked ? 'lock.fill' : 'play.fill'} size={24} tintColor={TEXT.PRIMARY} style={isLocked ? undefined : { marginLeft: 2 }} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Content at Bottom */}
|
||||
<View style={styles.content}>
|
||||
<StyledText
|
||||
preset={variant === 'featured' ? 'TITLE_2' : 'HEADLINE'}
|
||||
color={TEXT.PRIMARY}
|
||||
numberOfLines={2}
|
||||
style={styles.title}
|
||||
>
|
||||
{displayTitle}
|
||||
</StyledText>
|
||||
<StyledText
|
||||
preset="CARD_METADATA"
|
||||
color={TEXT.SECONDARY}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{displayMetadata}
|
||||
</StyledText>
|
||||
</View>
|
||||
</AnimatedPressable>
|
||||
)
|
||||
}
|
||||
|
||||
function createStyles(_colors: ThemeColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
borderRadius: RADIUS.LG,
|
||||
borderCurve: 'continuous',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: NAVY[800],
|
||||
borderWidth: 1,
|
||||
borderColor: BORDER_COLORS.DIM,
|
||||
},
|
||||
categoryBadge: {
|
||||
position: 'absolute',
|
||||
top: SPACING[3],
|
||||
left: SPACING[3],
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: SPACING[3],
|
||||
paddingVertical: SPACING[1],
|
||||
borderRadius: RADIUS.SM,
|
||||
borderCurve: 'continuous',
|
||||
borderWidth: 1,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
categoryText: {
|
||||
fontSize: 11,
|
||||
fontFamily: FONT_FAMILY_SANS_SEMIBOLD,
|
||||
},
|
||||
playButtonContainer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
playButton: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: RADIUS.FULL,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(17,34,64,0.7)', // Semi-transparent NAVY[800] overlay
|
||||
borderWidth: 1,
|
||||
borderColor: BORDER_COLORS.DIM,
|
||||
},
|
||||
content: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: SPACING[4],
|
||||
},
|
||||
title: {
|
||||
marginBottom: SPACING[1],
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,10 +1,22 @@
|
||||
import { Pressable, StyleSheet, Text, type ViewStyle } from 'react-native'
|
||||
import { Pressable, StyleSheet, Text, View, type ViewStyle, type TextStyle } from 'react-native'
|
||||
import { Image } from 'expo-image'
|
||||
import type { SFSymbol } from 'sf-symbols-typescript'
|
||||
import { GREEN, NAVY, RED, TEXT } from '@/src/shared/constants/colors'
|
||||
import { GREEN, NAVY, RED, TEXT, BRAND } from '@/src/shared/constants/colors'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { LAYOUT } from '@/src/shared/constants/spacing'
|
||||
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
|
||||
|
||||
/**
|
||||
* SwiftUI-inspired button variants:
|
||||
* - borderedProminent: filled green (primary CTA)
|
||||
* - bordered: tinted background with colored text
|
||||
* - borderless: text-only (inline actions)
|
||||
* - destructive: red text action
|
||||
* - plain: minimal, no chrome
|
||||
*/
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'destructive' | 'icon'
|
||||
| 'borderedProminent' | 'bordered' | 'borderless' | 'plain'
|
||||
|
||||
type ControlSize = 'mini' | 'small' | 'regular' | 'large' | 'extraLarge'
|
||||
|
||||
export interface NativeButtonProps {
|
||||
variant?: ButtonVariant
|
||||
@@ -13,17 +25,29 @@ export interface NativeButtonProps {
|
||||
onPress?: () => void
|
||||
disabled?: boolean
|
||||
color?: string
|
||||
controlSize?: 'mini' | 'small' | 'regular' | 'large'
|
||||
controlSize?: ControlSize
|
||||
fullWidth?: boolean
|
||||
style?: ViewStyle
|
||||
testID?: string
|
||||
}
|
||||
|
||||
export function NativeButton(props: NativeButtonProps) {
|
||||
const { variant = 'primary', title, onPress, disabled, fullWidth, style, testID } = props
|
||||
const {
|
||||
variant = 'primary',
|
||||
title,
|
||||
systemImage,
|
||||
onPress,
|
||||
disabled,
|
||||
color,
|
||||
controlSize = 'regular',
|
||||
fullWidth,
|
||||
style,
|
||||
testID,
|
||||
} = props
|
||||
|
||||
const buttonStyle = getVariantStyle(variant)
|
||||
const textStyle = getVariantTextStyle(variant)
|
||||
const sizeStyle = SIZE_STYLES[controlSize]
|
||||
const buttonStyle = getVariantStyle(variant, color)
|
||||
const textStyle = getVariantTextStyle(variant, color)
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -31,64 +55,122 @@ export function NativeButton(props: NativeButtonProps) {
|
||||
disabled={disabled}
|
||||
style={({ pressed }) => [
|
||||
styles.base,
|
||||
sizeStyle.container,
|
||||
fullWidth && styles.fullWidth,
|
||||
buttonStyle,
|
||||
pressed && styles.pressed,
|
||||
disabled && styles.disabled,
|
||||
{ borderCurve: 'continuous' } as ViewStyle,
|
||||
style,
|
||||
]}
|
||||
testID={testID}
|
||||
>
|
||||
{title ? <Text style={[styles.text, textStyle]}>{title}</Text> : null}
|
||||
<View style={styles.content}>
|
||||
{systemImage ? (
|
||||
<Image
|
||||
source={`sf:${systemImage}`}
|
||||
style={[styles.icon, { tintColor: textStyle.color }]}
|
||||
/>
|
||||
) : null}
|
||||
{title ? (
|
||||
<Text style={[styles.text, sizeStyle.text, textStyle]}>{title}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
function getVariantStyle(variant: ButtonVariant): ViewStyle {
|
||||
function getVariantStyle(variant: ButtonVariant, color?: string): ViewStyle {
|
||||
const brandColor = color ?? GREEN[500]
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
return { backgroundColor: GREEN[500] }
|
||||
case 'borderedProminent':
|
||||
return { backgroundColor: brandColor }
|
||||
case 'secondary':
|
||||
return { backgroundColor: 'transparent', borderWidth: 1.5, borderColor: GREEN[500] }
|
||||
case 'bordered':
|
||||
return { backgroundColor: `${brandColor}18` } // 10% opacity tint
|
||||
case 'ghost':
|
||||
case 'borderless':
|
||||
case 'plain':
|
||||
return { backgroundColor: 'transparent' }
|
||||
case 'destructive':
|
||||
return { backgroundColor: 'transparent' }
|
||||
case 'icon':
|
||||
return { backgroundColor: 'transparent' }
|
||||
}
|
||||
}
|
||||
|
||||
function getVariantTextStyle(variant: ButtonVariant) {
|
||||
function getVariantTextStyle(variant: ButtonVariant, color?: string): TextStyle {
|
||||
const brandColor = color ?? GREEN[500]
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
case 'borderedProminent':
|
||||
return { color: NAVY[900] }
|
||||
case 'secondary':
|
||||
case 'bordered':
|
||||
return { color: brandColor }
|
||||
case 'destructive':
|
||||
return { color: RED[500] }
|
||||
case 'ghost':
|
||||
case 'borderless':
|
||||
return { color: brandColor }
|
||||
case 'plain':
|
||||
return { color: TEXT.PRIMARY }
|
||||
default:
|
||||
return { color: TEXT.PRIMARY }
|
||||
}
|
||||
}
|
||||
|
||||
const SIZE_STYLES: Record<ControlSize, { container: ViewStyle; text: TextStyle }> = {
|
||||
mini: {
|
||||
container: { height: 28, paddingHorizontal: SPACING[2], borderRadius: RADIUS.SM },
|
||||
text: { fontSize: 13 },
|
||||
},
|
||||
small: {
|
||||
container: { height: 34, paddingHorizontal: SPACING[3], borderRadius: RADIUS.SM },
|
||||
text: { fontSize: 14 },
|
||||
},
|
||||
regular: {
|
||||
container: { height: 44, paddingHorizontal: SPACING[4], borderRadius: RADIUS.MD },
|
||||
text: { fontSize: 17 },
|
||||
},
|
||||
large: {
|
||||
container: { height: 50, paddingHorizontal: SPACING[5], borderRadius: RADIUS.MD },
|
||||
text: { fontSize: 17 },
|
||||
},
|
||||
extraLarge: {
|
||||
container: { height: LAYOUT.BUTTON_HEIGHT, paddingHorizontal: SPACING[6], borderRadius: RADIUS.MD },
|
||||
text: { fontSize: 17 },
|
||||
},
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
base: {
|
||||
height: LAYOUT.BUTTON_HEIGHT,
|
||||
borderRadius: RADIUS.MD,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: LAYOUT.TOUCH_TARGET,
|
||||
},
|
||||
content: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: SPACING[2],
|
||||
},
|
||||
fullWidth: {
|
||||
width: '100%',
|
||||
},
|
||||
icon: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
},
|
||||
text: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.8,
|
||||
opacity: 0.7,
|
||||
transform: [{ scale: 0.98 }],
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.4,
|
||||
opacity: 0.35,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,33 +1,68 @@
|
||||
import { StyleSheet, Text, View, Pressable } from 'react-native'
|
||||
import { TEXT } from '@/src/shared/constants/colors'
|
||||
import { Image } from 'expo-image'
|
||||
import { BRAND, TEXT } from '@/src/shared/constants/colors'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
|
||||
export interface NativeLabeledRowProps {
|
||||
label: string
|
||||
value?: string
|
||||
/** SF Symbol name for expo-image source="sf:name" */
|
||||
icon?: string
|
||||
/** Icon tint color */
|
||||
iconColor?: string
|
||||
chevron?: boolean
|
||||
onPress?: () => void
|
||||
/** Destructive row — label turns red */
|
||||
destructive?: boolean
|
||||
children?: React.ReactNode
|
||||
testID?: string
|
||||
}
|
||||
|
||||
export function NativeLabeledRow(props: NativeLabeledRowProps) {
|
||||
const { label, value, chevron, onPress, children } = props
|
||||
const {
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
iconColor,
|
||||
chevron,
|
||||
onPress,
|
||||
destructive,
|
||||
children,
|
||||
} = props
|
||||
|
||||
const labelColor = destructive ? BRAND.DANGER : TEXT.PRIMARY
|
||||
|
||||
const content = (
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
{icon ? (
|
||||
<Image
|
||||
source={`sf:${icon}`}
|
||||
style={[styles.icon, { tintColor: iconColor ?? TEXT.SECONDARY }]}
|
||||
/>
|
||||
) : null}
|
||||
<Text style={[styles.label, { color: labelColor }]}>{label}</Text>
|
||||
<View style={styles.right}>
|
||||
{value ? <Text style={styles.value}>{value}</Text> : null}
|
||||
{children}
|
||||
{chevron ? <Text style={styles.chevron}>›</Text> : null}
|
||||
{(chevron || onPress) ? (
|
||||
<Image
|
||||
source="sf:chevron.right"
|
||||
style={styles.chevronIcon}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
|
||||
if (onPress) {
|
||||
return <Pressable onPress={onPress}>{content}</Pressable>
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={({ pressed }) => pressed ? styles.pressed : undefined}
|
||||
>
|
||||
{content}
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
return content
|
||||
@@ -42,9 +77,14 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: SPACING[4],
|
||||
paddingVertical: SPACING[3],
|
||||
},
|
||||
icon: {
|
||||
width: 22,
|
||||
height: 22,
|
||||
marginRight: SPACING[3],
|
||||
},
|
||||
label: {
|
||||
fontSize: 17,
|
||||
color: TEXT.PRIMARY,
|
||||
fontWeight: '400',
|
||||
flex: 1,
|
||||
},
|
||||
right: {
|
||||
@@ -54,11 +94,16 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
value: {
|
||||
fontSize: 17,
|
||||
fontWeight: '400',
|
||||
color: TEXT.TERTIARY,
|
||||
},
|
||||
chevron: {
|
||||
fontSize: 20,
|
||||
color: TEXT.TERTIARY,
|
||||
marginLeft: SPACING[1],
|
||||
chevronIcon: {
|
||||
width: 13,
|
||||
height: 13,
|
||||
tintColor: TEXT.TERTIARY,
|
||||
opacity: 0.6,
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type ViewStyle, View, StyleSheet } from 'react-native'
|
||||
import { NAVY } from '@/src/shared/constants/colors'
|
||||
import { NAVY, BORDER_COLORS } from '@/src/shared/constants/colors'
|
||||
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
|
||||
@@ -8,13 +8,20 @@ export interface NativeListProps {
|
||||
style?: ViewStyle
|
||||
scrollEnabled?: boolean
|
||||
height?: number
|
||||
/** Remove default margin (for embedding inside other containers) */
|
||||
inset?: boolean
|
||||
testID?: string
|
||||
}
|
||||
|
||||
export function NativeList(props: NativeListProps) {
|
||||
const { children, style, height } = props
|
||||
const { children, style, height, inset = true } = props
|
||||
return (
|
||||
<View style={[styles.list, height ? { height } : undefined, style]}>
|
||||
<View style={[
|
||||
styles.list,
|
||||
!inset && styles.noInset,
|
||||
height ? { height } : undefined,
|
||||
style,
|
||||
]}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
@@ -24,13 +31,31 @@ export function calculateListHeight(rows: number, sections: number = 1): number
|
||||
return rows * LAYOUT.LIST_ROW_HEIGHT + sections * LAYOUT.LIST_HEADER_HEIGHT + 20
|
||||
}
|
||||
|
||||
/** Thin iOS-style separator for use between list rows */
|
||||
export function ListSeparator() {
|
||||
return (
|
||||
<View style={styles.separatorOuter}>
|
||||
<View style={styles.separator} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
list: {
|
||||
marginHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
marginTop: LAYOUT.SECTION_GAP,
|
||||
marginBottom: SPACING[3],
|
||||
backgroundColor: NAVY[800],
|
||||
borderRadius: RADIUS.XL,
|
||||
borderRadius: RADIUS.LG,
|
||||
borderCurve: 'continuous',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
noInset: {
|
||||
marginHorizontal: 0,
|
||||
},
|
||||
separatorOuter: {
|
||||
paddingLeft: SPACING[4],
|
||||
},
|
||||
separator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: BORDER_COLORS.SEPARATOR,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -2,15 +2,15 @@ import { StyleSheet, Text, View } from 'react-native'
|
||||
import { NAVY, TEXT } from '@/src/shared/constants/colors'
|
||||
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { FONT_FAMILY } from '@/src/shared/constants/typography'
|
||||
|
||||
export interface NativeSectionProps {
|
||||
title?: string
|
||||
footer?: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function NativeSection(props: NativeSectionProps) {
|
||||
const { title, children } = props
|
||||
const { title, footer, children } = props
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{title ? (
|
||||
@@ -19,6 +19,9 @@ export function NativeSection(props: NativeSectionProps) {
|
||||
<View style={styles.content}>
|
||||
{children}
|
||||
</View>
|
||||
{footer ? (
|
||||
<Text style={styles.footer}>{footer}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -29,16 +32,26 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
header: {
|
||||
fontSize: 13,
|
||||
fontFamily: FONT_FAMILY.SANS_SEMIBOLD,
|
||||
fontWeight: '400',
|
||||
color: TEXT.TERTIARY,
|
||||
letterSpacing: 0.06,
|
||||
letterSpacing: -0.08,
|
||||
textTransform: 'uppercase',
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING + LAYOUT.CARD_PADDING,
|
||||
marginBottom: SPACING[2],
|
||||
},
|
||||
content: {
|
||||
marginHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
backgroundColor: NAVY[800],
|
||||
borderRadius: RADIUS.XL,
|
||||
borderRadius: RADIUS.LG,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
footer: {
|
||||
fontSize: 13,
|
||||
fontWeight: '400',
|
||||
color: TEXT.TERTIARY,
|
||||
letterSpacing: -0.08,
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING + LAYOUT.CARD_PADDING,
|
||||
marginTop: SPACING[2],
|
||||
lineHeight: 18,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export { NativeButton } from './NativeButton'
|
||||
export type { NativeButtonProps } from './NativeButton'
|
||||
|
||||
export { NativeList, calculateListHeight } from './NativeList'
|
||||
export { NativeList, calculateListHeight, ListSeparator } from './NativeList'
|
||||
export type { NativeListProps } from './NativeList'
|
||||
|
||||
export { NativeSection } from './NativeSection'
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
/**
|
||||
* TabataFit Border Radius System
|
||||
* Dark Medical — clean, minimal rounding
|
||||
* iOS-native continuous corners (borderCurve: 'continuous')
|
||||
*/
|
||||
|
||||
export const RADIUS = {
|
||||
NONE: 0,
|
||||
XS: 2, // Progress bar, hairline element
|
||||
SM: 6, // Badge, chip, tag
|
||||
MD: 10, // Button, input, tip card
|
||||
LG: 14, // Standard program card
|
||||
XL: 18, // Large card, modal
|
||||
'2XL': 22, // Icon container, medium element
|
||||
'3XL': 28, // Large element
|
||||
XS: 4, // Progress bar, hairline element
|
||||
SM: 8, // Badge, chip, tag
|
||||
MD: 12, // Button, input, tip card — iOS standard
|
||||
LG: 16, // Standard card — iOS grouped inset
|
||||
XL: 20, // Large card, modal, bottom sheet
|
||||
'2XL': 24, // Icon container, medium element
|
||||
'3XL': 32, // Large element
|
||||
|
||||
// Special
|
||||
PILL: 9999, // Pill, toggle, progress bar
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
/**
|
||||
* TabataFit Color System
|
||||
* Dark Medical — navy backgrounds, green actions, no shadows
|
||||
* Dark Premium — refined navy backgrounds, green actions, native iOS feel
|
||||
*/
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// NAVY (Backgrounds)
|
||||
// NAVY (Backgrounds) — warmer, closer to iOS dark mode tones
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export const NAVY = {
|
||||
900: '#0D1B2A', // Main app background
|
||||
800: '#112240', // Surface 1 — default cards
|
||||
700: '#1A3050', // Surface 2 — elevated cards
|
||||
600: '#243C5E', // Active borders
|
||||
900: '#0A1628', // Main app background — deep, warm navy
|
||||
800: '#111D2E', // Surface 1 — default cards (grouped inset bg)
|
||||
700: '#192A3E', // Surface 2 — elevated cards
|
||||
600: '#223750', // Active borders, selection
|
||||
500: '#2C4566', // Tertiary surface / hover state
|
||||
} as const
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -22,8 +23,8 @@ export const GREEN = {
|
||||
500: '#00C896', // Primary CTA, effort timer, progress
|
||||
600: '#00A67C', // Hover/pressed state
|
||||
700: '#00875F', // Deep active state
|
||||
DIM: 'rgba(0,200,150,0.12)', // Badge/chip/card accent background
|
||||
BORDER: 'rgba(0,200,150,0.35)', // Card accent border
|
||||
DIM: 'rgba(0,200,150,0.10)', // Badge/chip/card accent background
|
||||
BORDER: 'rgba(0,200,150,0.30)', // Card accent border
|
||||
} as const
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -31,18 +32,19 @@ export const GREEN = {
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export const TEXT = {
|
||||
PRIMARY: '#E6F1FF', // white-100
|
||||
SECONDARY: '#A8B2D8', // slate-300
|
||||
TERTIARY: '#8892B0', // slate-400
|
||||
MUTED: '#8892B0',
|
||||
HINT: '#8892B0',
|
||||
DISABLED: '#3A3A3C',
|
||||
PRIMARY: '#ECEFF4', // High-contrast primary (Nord Snow Storm inspired)
|
||||
SECONDARY: '#9BA4B5', // Secondary text
|
||||
TERTIARY: '#6B7A8D', // Tertiary, placeholders — lower contrast than before
|
||||
MUTED: '#6B7A8D',
|
||||
HINT: '#6B7A8D',
|
||||
DISABLED: '#3A4555',
|
||||
} as const
|
||||
|
||||
export const BORDER_COLORS = {
|
||||
DIM: 'rgba(168,178,216,0.15)', // Default border
|
||||
HOVER: 'rgba(168,178,216,0.25)', // Hover border
|
||||
BRAND: 'rgba(0,200,150,0.35)', // Green border (matches GREEN.BORDER)
|
||||
DIM: 'rgba(150,164,190,0.12)', // Default border — softer
|
||||
HOVER: 'rgba(150,164,190,0.20)', // Hover border
|
||||
BRAND: 'rgba(0,200,150,0.30)', // Green border
|
||||
SEPARATOR: 'rgba(150,164,190,0.08)', // iOS-style thin separator
|
||||
} as const
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -52,7 +54,7 @@ export const BORDER_COLORS = {
|
||||
export const ORANGE = {
|
||||
500: '#FF8A5C',
|
||||
600: '#E06A3C',
|
||||
DIM: 'rgba(255,138,92,0.12)',
|
||||
DIM: 'rgba(255,138,92,0.10)',
|
||||
} as const
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -60,8 +62,8 @@ export const ORANGE = {
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export const RED = {
|
||||
500: '#FF4444', // Timer emergency <10s ONLY
|
||||
DIM: 'rgba(255,68,68,0.12)', // Danger background tint
|
||||
500: '#FF453A', // iOS system red — timer emergency <10s
|
||||
DIM: 'rgba(255,69,58,0.12)',
|
||||
} as const
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -73,7 +75,8 @@ export const BRAND = {
|
||||
SECONDARY: GREEN['600'],
|
||||
DANGER: RED['500'],
|
||||
SUCCESS: GREEN['500'],
|
||||
INFO: '#85C7F2',
|
||||
INFO: '#64D2FF', // iOS system cyan
|
||||
LINK: '#0A84FF', // iOS system blue — for tappable text
|
||||
} as const
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -84,9 +87,9 @@ export const DARK = {
|
||||
BASE: NAVY['900'],
|
||||
SURFACE: NAVY['800'],
|
||||
ELEVATED: NAVY['700'],
|
||||
OVERLAY_1: 'rgba(168,178,216,0.06)',
|
||||
OVERLAY_2: 'rgba(168,178,216,0.10)',
|
||||
OVERLAY_3: 'rgba(168,178,216,0.15)',
|
||||
OVERLAY_1: 'rgba(150,164,190,0.05)',
|
||||
OVERLAY_2: 'rgba(150,164,190,0.08)',
|
||||
OVERLAY_3: 'rgba(150,164,190,0.12)',
|
||||
SCRIM: 'rgba(0,0,0,0.6)',
|
||||
} as const
|
||||
|
||||
@@ -102,16 +105,16 @@ export const PHASE = {
|
||||
WORK_LIGHT: GREEN.DIM,
|
||||
WORK_GLOW: 'rgba(0,200,150,0.5)',
|
||||
|
||||
REST: '#8892B0', // slate-400 — visual rest signal
|
||||
REST_LIGHT: 'rgba(136,146,176,0.2)',
|
||||
REST_GLOW: 'rgba(136,146,176,0.5)',
|
||||
REST: '#6B7A8D', // Muted tertiary — visual rest signal
|
||||
REST_LIGHT: 'rgba(107,122,141,0.2)',
|
||||
REST_GLOW: 'rgba(107,122,141,0.5)',
|
||||
|
||||
COMPLETE: GREEN['500'],
|
||||
COMPLETE_LIGHT: GREEN.DIM,
|
||||
} as const
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// GRADIENTS (video overlays only — no glass shimmer)
|
||||
// GRADIENTS (video overlays only)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export const AMBER = {
|
||||
@@ -121,7 +124,7 @@ export const AMBER = {
|
||||
export const GRADIENTS = {
|
||||
VIDEO_OVERLAY: ['transparent', 'rgba(0,0,0,0.8)'],
|
||||
VIDEO_TOP: ['rgba(0,0,0,0.5)', 'transparent'],
|
||||
CARD_OVERLAY: ['transparent', 'rgba(13,27,42,0.85)'] as const,
|
||||
CARD_OVERLAY: ['transparent', 'rgba(10,22,40,0.85)'] as const,
|
||||
ASSESSMENT_CTA: [ORANGE[500], RED[500]] as const,
|
||||
} as const
|
||||
|
||||
|
||||
@@ -1,27 +1,42 @@
|
||||
/**
|
||||
* TabataFit Typography System
|
||||
* Three families: Serif (emotional), Sans (interface), Mono (data)
|
||||
* SF Pro (system font) — premium native iOS feel
|
||||
*
|
||||
* Uses Apple's Dynamic Type scale with system font.
|
||||
* No custom fonts loaded — SF Pro is the default on iOS.
|
||||
* fontWeight controls the visual weight; no fontFamily needed for SF Pro.
|
||||
*
|
||||
* For data/timer: system monospaced via fontVariant: ['tabular-nums']
|
||||
*/
|
||||
|
||||
import { TextStyle } from 'react-native'
|
||||
import { Platform, TextStyle } from 'react-native'
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// FONT FAMILIES
|
||||
// FONT FAMILIES — System font (SF Pro on iOS, Roboto on Android)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* On iOS, omitting fontFamily uses SF Pro automatically.
|
||||
* On Android, omitting fontFamily uses Roboto.
|
||||
* We use 'System' as a semantic marker — React Native resolves it to the
|
||||
* platform system font.
|
||||
*/
|
||||
export const FONT_FAMILY = {
|
||||
SERIF: 'DMSerifDisplay_Regular',
|
||||
SERIF_ITALIC: 'DMSerifDisplay_Italic',
|
||||
SANS: 'Outfit_400Regular',
|
||||
SANS_MEDIUM: 'Outfit_500Medium',
|
||||
SANS_SEMIBOLD: 'Outfit_600SemiBold',
|
||||
SANS_BOLD: 'Outfit_700Bold',
|
||||
MONO: 'DMMono_400Regular',
|
||||
MONO_MEDIUM: 'DMMono_500Medium',
|
||||
// System font — no fontFamily needed (undefined = system font in RN)
|
||||
SANS: undefined,
|
||||
SANS_MEDIUM: undefined,
|
||||
SANS_SEMIBOLD: undefined,
|
||||
SANS_BOLD: undefined,
|
||||
// Serif — iOS has New York via serif fontFamily
|
||||
SERIF: Platform.OS === 'ios' ? 'Georgia' : 'serif',
|
||||
SERIF_ITALIC: Platform.OS === 'ios' ? 'Georgia' : 'serif',
|
||||
// Monospace — system mono (SF Mono on iOS, Roboto Mono on Android)
|
||||
MONO: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
|
||||
MONO_MEDIUM: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
|
||||
} as const
|
||||
|
||||
// Font alias object — maps to FONT_FAMILY values
|
||||
const FONT: Record<string, string> = {
|
||||
const FONT = {
|
||||
SANS: FONT_FAMILY.SANS,
|
||||
SANS_MEDIUM: FONT_FAMILY.SANS_MEDIUM,
|
||||
SANS_SEMIBOLD: FONT_FAMILY.SANS_SEMIBOLD,
|
||||
@@ -36,212 +51,217 @@ const FONT: Record<string, string> = {
|
||||
SEMIBOLD: FONT_FAMILY.SANS_SEMIBOLD,
|
||||
BOLD: FONT_FAMILY.SANS_BOLD,
|
||||
BLACK: FONT_FAMILY.SANS_BOLD,
|
||||
}
|
||||
} as const
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// TYPE SCALE
|
||||
// TYPE SCALE — matches Apple HIG Dynamic Type defaults
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export const TYPOGRAPHY = {
|
||||
// Display / Hero — Serif for emotional moments
|
||||
// Display / Hero — bold system font for emotional moments
|
||||
DISPLAY: {
|
||||
fontFamily: FONT.SERIF,
|
||||
fontSize: 28,
|
||||
lineHeight: 34,
|
||||
letterSpacing: 0,
|
||||
} as TextStyle,
|
||||
|
||||
HERO: {
|
||||
fontFamily: FONT.SERIF,
|
||||
fontSize: 32,
|
||||
lineHeight: 38,
|
||||
letterSpacing: -0.5,
|
||||
} as TextStyle,
|
||||
|
||||
// Large Title (like iOS)
|
||||
LARGE_TITLE: {
|
||||
fontFamily: FONT.SERIF,
|
||||
fontSize: 34,
|
||||
lineHeight: 41,
|
||||
letterSpacing: 0.37,
|
||||
} as TextStyle,
|
||||
|
||||
// Heading 1 — Serif for section titles
|
||||
HEADING_1: {
|
||||
fontFamily: FONT.SERIF,
|
||||
fontSize: 22,
|
||||
lineHeight: 28,
|
||||
letterSpacing: 0.35,
|
||||
} as TextStyle,
|
||||
|
||||
// Title 1 (backward compat)
|
||||
TITLE_1: {
|
||||
fontFamily: FONT.SERIF,
|
||||
fontSize: 28,
|
||||
fontWeight: '700' as const,
|
||||
lineHeight: 34,
|
||||
letterSpacing: 0.36,
|
||||
} as TextStyle,
|
||||
|
||||
// Heading 2 / Title 2 — Sans for exercise titles, program cards
|
||||
HERO: {
|
||||
fontFamily: FONT.SERIF,
|
||||
fontSize: 34,
|
||||
fontWeight: '700' as const,
|
||||
lineHeight: 41,
|
||||
letterSpacing: 0.37,
|
||||
} as TextStyle,
|
||||
|
||||
// Large Title — iOS native large title
|
||||
LARGE_TITLE: {
|
||||
fontSize: 34,
|
||||
fontWeight: '700' as const,
|
||||
lineHeight: 41,
|
||||
letterSpacing: 0.37,
|
||||
} as TextStyle,
|
||||
|
||||
// Heading 1
|
||||
HEADING_1: {
|
||||
fontSize: 28,
|
||||
fontWeight: '700' as const,
|
||||
lineHeight: 34,
|
||||
letterSpacing: 0.36,
|
||||
} as TextStyle,
|
||||
|
||||
// Title 1
|
||||
TITLE_1: {
|
||||
fontSize: 28,
|
||||
fontWeight: '400' as const,
|
||||
lineHeight: 34,
|
||||
letterSpacing: 0.36,
|
||||
} as TextStyle,
|
||||
|
||||
// Heading 2 — exercise titles, program cards
|
||||
HEADING_2: {
|
||||
fontFamily: FONT_FAMILY.SANS_SEMIBOLD,
|
||||
fontSize: 18,
|
||||
lineHeight: 24,
|
||||
letterSpacing: 0,
|
||||
fontSize: 22,
|
||||
fontWeight: '700' as const,
|
||||
lineHeight: 28,
|
||||
letterSpacing: 0.35,
|
||||
} as TextStyle,
|
||||
|
||||
TITLE_2: {
|
||||
fontFamily: FONT_FAMILY.SANS_BOLD,
|
||||
fontSize: 22,
|
||||
fontWeight: '400' as const,
|
||||
lineHeight: 28,
|
||||
letterSpacing: 0.35,
|
||||
} as TextStyle,
|
||||
|
||||
TITLE_3: {
|
||||
fontFamily: FONT.SANS_SEMIBOLD,
|
||||
fontSize: 20,
|
||||
fontWeight: '400' as const,
|
||||
lineHeight: 25,
|
||||
letterSpacing: 0.38,
|
||||
} as TextStyle,
|
||||
|
||||
// Headline
|
||||
HEADLINE: {
|
||||
fontFamily: FONT.SANS_SEMIBOLD,
|
||||
fontSize: 17,
|
||||
fontWeight: '600' as const,
|
||||
lineHeight: 22,
|
||||
letterSpacing: -0.41,
|
||||
} as TextStyle,
|
||||
|
||||
// Body — Sans
|
||||
// Body
|
||||
BODY: {
|
||||
fontFamily: FONT.SANS,
|
||||
fontSize: 15,
|
||||
lineHeight: 20,
|
||||
letterSpacing: -0.24,
|
||||
fontSize: 17,
|
||||
fontWeight: '400' as const,
|
||||
lineHeight: 22,
|
||||
letterSpacing: -0.41,
|
||||
} as TextStyle,
|
||||
|
||||
BODY_BOLD: {
|
||||
fontFamily: FONT.SANS_SEMIBOLD,
|
||||
fontSize: 15,
|
||||
lineHeight: 20,
|
||||
letterSpacing: -0.24,
|
||||
fontSize: 17,
|
||||
fontWeight: '600' as const,
|
||||
lineHeight: 22,
|
||||
letterSpacing: -0.41,
|
||||
} as TextStyle,
|
||||
|
||||
// Callout
|
||||
CALLOUT: {
|
||||
fontFamily: FONT.SANS,
|
||||
fontSize: 16,
|
||||
fontWeight: '400' as const,
|
||||
lineHeight: 21,
|
||||
letterSpacing: -0.32,
|
||||
} as TextStyle,
|
||||
|
||||
// Subheadline
|
||||
SUBHEADLINE: {
|
||||
fontFamily: FONT.SANS,
|
||||
fontSize: 15,
|
||||
fontWeight: '400' as const,
|
||||
lineHeight: 20,
|
||||
letterSpacing: -0.24,
|
||||
} as TextStyle,
|
||||
|
||||
// Label — Mono for tags, metadata, uppercase
|
||||
// Label — small uppercase for tags, metadata
|
||||
LABEL: {
|
||||
fontFamily: FONT.MONO_MEDIUM,
|
||||
fontSize: 11,
|
||||
fontSize: 12,
|
||||
fontWeight: '500' as const,
|
||||
lineHeight: 16,
|
||||
letterSpacing: 0.08,
|
||||
letterSpacing: 0.5,
|
||||
textTransform: 'uppercase' as const,
|
||||
} as TextStyle,
|
||||
|
||||
// Footnote
|
||||
FOOTNOTE: {
|
||||
fontFamily: FONT.SANS,
|
||||
fontSize: 13,
|
||||
fontWeight: '400' as const,
|
||||
lineHeight: 18,
|
||||
letterSpacing: -0.08,
|
||||
} as TextStyle,
|
||||
|
||||
// Caption
|
||||
CAPTION_1: {
|
||||
fontFamily: FONT.SANS,
|
||||
fontSize: 12,
|
||||
fontWeight: '400' as const,
|
||||
lineHeight: 16,
|
||||
letterSpacing: 0,
|
||||
} as TextStyle,
|
||||
|
||||
CAPTION_2: {
|
||||
fontFamily: FONT.SANS,
|
||||
fontSize: 11,
|
||||
fontWeight: '400' as const,
|
||||
lineHeight: 13,
|
||||
letterSpacing: 0.07,
|
||||
} as TextStyle,
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// SPECIAL: TIMER — Mono, readable from 2 meters
|
||||
// SPECIAL: TIMER — Monospaced, readable from 2 meters
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
TIMER_NUMBER: {
|
||||
fontFamily: FONT.MONO_MEDIUM,
|
||||
fontFamily: FONT.MONO,
|
||||
fontSize: 88,
|
||||
fontWeight: '300' as const,
|
||||
lineHeight: 88,
|
||||
letterSpacing: -2,
|
||||
fontVariant: ['tabular-nums'],
|
||||
} as TextStyle,
|
||||
|
||||
TIMER_NUMBER_COMPACT: {
|
||||
fontFamily: FONT.MONO_MEDIUM,
|
||||
fontFamily: FONT.MONO,
|
||||
fontSize: 72,
|
||||
fontWeight: '300' as const,
|
||||
lineHeight: 72,
|
||||
letterSpacing: -1.5,
|
||||
fontVariant: ['tabular-nums'],
|
||||
} as TextStyle,
|
||||
|
||||
TIMER_PHASE: {
|
||||
fontFamily: FONT.MONO_MEDIUM,
|
||||
fontSize: 13,
|
||||
fontWeight: '600' as const,
|
||||
lineHeight: 18,
|
||||
letterSpacing: 0.15,
|
||||
letterSpacing: 1.5,
|
||||
textTransform: 'uppercase' as const,
|
||||
} as TextStyle,
|
||||
|
||||
TIMER_ROUND: {
|
||||
fontFamily: FONT.MONO_MEDIUM,
|
||||
fontFamily: FONT.MONO,
|
||||
fontSize: 17,
|
||||
fontWeight: '500' as const,
|
||||
lineHeight: 22,
|
||||
letterSpacing: 0,
|
||||
fontVariant: ['tabular-nums'],
|
||||
} as TextStyle,
|
||||
|
||||
EXERCISE_NAME: {
|
||||
fontFamily: FONT.SANS_BOLD,
|
||||
fontSize: 28,
|
||||
fontWeight: '700' as const,
|
||||
lineHeight: 34,
|
||||
letterSpacing: 0.36,
|
||||
textAlign: 'center' as const,
|
||||
} as TextStyle,
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// SPECIAL: BUTTON — Sans
|
||||
// SPECIAL: BUTTON
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
BUTTON_LARGE: {
|
||||
fontFamily: FONT.SANS_SEMIBOLD,
|
||||
fontSize: 15,
|
||||
lineHeight: 20,
|
||||
letterSpacing: 0.5,
|
||||
fontSize: 17,
|
||||
fontWeight: '600' as const,
|
||||
lineHeight: 22,
|
||||
letterSpacing: -0.41,
|
||||
} as TextStyle,
|
||||
|
||||
BUTTON_MEDIUM: {
|
||||
fontFamily: FONT.SANS_SEMIBOLD,
|
||||
fontSize: 15,
|
||||
fontWeight: '600' as const,
|
||||
lineHeight: 20,
|
||||
letterSpacing: 0.3,
|
||||
letterSpacing: -0.24,
|
||||
} as TextStyle,
|
||||
|
||||
BUTTON_SMALL: {
|
||||
fontFamily: FONT.SANS_SEMIBOLD,
|
||||
fontSize: 14,
|
||||
fontWeight: '600' as const,
|
||||
lineHeight: 18,
|
||||
letterSpacing: 0.2,
|
||||
letterSpacing: -0.15,
|
||||
} as TextStyle,
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -249,55 +269,56 @@ export const TYPOGRAPHY = {
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CARD_TITLE: {
|
||||
fontFamily: FONT.SANS_SEMIBOLD,
|
||||
fontSize: 17,
|
||||
fontWeight: '600' as const,
|
||||
lineHeight: 22,
|
||||
letterSpacing: -0.41,
|
||||
} as TextStyle,
|
||||
|
||||
CARD_SUBTITLE: {
|
||||
fontFamily: FONT.SANS,
|
||||
fontSize: 14,
|
||||
lineHeight: 18,
|
||||
letterSpacing: -0.15,
|
||||
fontSize: 15,
|
||||
fontWeight: '400' as const,
|
||||
lineHeight: 20,
|
||||
letterSpacing: -0.24,
|
||||
} as TextStyle,
|
||||
|
||||
CARD_METADATA: {
|
||||
fontFamily: FONT.SANS_MEDIUM,
|
||||
fontSize: 13,
|
||||
lineHeight: 16,
|
||||
letterSpacing: 0,
|
||||
fontWeight: '500' as const,
|
||||
lineHeight: 18,
|
||||
letterSpacing: -0.08,
|
||||
} as TextStyle,
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// SPECIAL: STATS — Mono
|
||||
// SPECIAL: STATS — Monospaced numerals
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
STAT_VALUE: {
|
||||
fontFamily: FONT.MONO_MEDIUM,
|
||||
fontSize: 32,
|
||||
lineHeight: 38,
|
||||
fontFamily: FONT.MONO,
|
||||
fontSize: 34,
|
||||
fontWeight: '300' as const,
|
||||
lineHeight: 41,
|
||||
letterSpacing: -0.5,
|
||||
fontVariant: ['tabular-nums'],
|
||||
} as TextStyle,
|
||||
|
||||
STAT_LABEL: {
|
||||
fontFamily: FONT.MONO_MEDIUM,
|
||||
fontSize: 11,
|
||||
fontSize: 12,
|
||||
fontWeight: '500' as const,
|
||||
lineHeight: 16,
|
||||
letterSpacing: 0.08,
|
||||
letterSpacing: 0.5,
|
||||
textTransform: 'uppercase' as const,
|
||||
} as TextStyle,
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// SPECIAL: OVERLINE / SECTION HEADER — Mono
|
||||
// SPECIAL: OVERLINE / SECTION HEADER
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
OVERLINE: {
|
||||
fontFamily: FONT.MONO_MEDIUM,
|
||||
fontSize: 11,
|
||||
fontSize: 12,
|
||||
fontWeight: '500' as const,
|
||||
lineHeight: 16,
|
||||
letterSpacing: 0.08,
|
||||
letterSpacing: 0.5,
|
||||
textTransform: 'uppercase' as const,
|
||||
} as TextStyle,
|
||||
|
||||
|
||||
@@ -1,49 +1,51 @@
|
||||
/**
|
||||
* Dark Medical theme palette
|
||||
* Navy backgrounds, green actions, no glass, no shadows
|
||||
* Dark Premium theme palette
|
||||
* Refined navy backgrounds, green actions, native iOS feel
|
||||
*/
|
||||
|
||||
import type { ThemeColors } from './types'
|
||||
import { NAVY, TEXT as TEXT_COLORS, BORDER_COLORS, GREEN, ORANGE } from '../constants/colors'
|
||||
|
||||
export const darkColors: ThemeColors = {
|
||||
bg: {
|
||||
base: '#0D1B2A', // navy-900
|
||||
surface: '#112240', // navy-800
|
||||
elevated: '#1A3050', // navy-700
|
||||
overlay1: 'rgba(168,178,216,0.06)',
|
||||
overlay2: 'rgba(168,178,216,0.10)',
|
||||
overlay3: 'rgba(168,178,216,0.15)',
|
||||
base: NAVY[900],
|
||||
surface: NAVY[800],
|
||||
elevated: NAVY[700],
|
||||
overlay1: 'rgba(150,164,190,0.05)',
|
||||
overlay2: 'rgba(150,164,190,0.08)',
|
||||
overlay3: 'rgba(150,164,190,0.12)',
|
||||
scrim: 'rgba(0,0,0,0.6)',
|
||||
},
|
||||
text: {
|
||||
primary: '#E6F1FF', // white-100
|
||||
secondary: '#A8B2D8', // slate-300
|
||||
tertiary: '#8892B0', // slate-400
|
||||
muted: '#8892B0',
|
||||
hint: '#8892B0',
|
||||
disabled: '#3A3A3C',
|
||||
primary: TEXT_COLORS.PRIMARY,
|
||||
secondary: TEXT_COLORS.SECONDARY,
|
||||
tertiary: TEXT_COLORS.TERTIARY,
|
||||
muted: TEXT_COLORS.MUTED,
|
||||
hint: TEXT_COLORS.HINT,
|
||||
disabled: TEXT_COLORS.DISABLED,
|
||||
},
|
||||
surface: {
|
||||
default: {
|
||||
backgroundColor: '#112240',
|
||||
borderColor: 'rgba(168,178,216,0.15)',
|
||||
backgroundColor: NAVY[800],
|
||||
borderColor: BORDER_COLORS.DIM,
|
||||
borderWidth: 1,
|
||||
},
|
||||
accent: {
|
||||
backgroundColor: 'rgba(0,200,150,0.05)',
|
||||
borderColor: 'rgba(0,200,150,0.35)',
|
||||
backgroundColor: GREEN.DIM,
|
||||
borderColor: GREEN.BORDER,
|
||||
borderWidth: 1.5,
|
||||
},
|
||||
tip: {
|
||||
backgroundColor: 'rgba(255,138,92,0.12)',
|
||||
borderColor: '#FF8A5C',
|
||||
backgroundColor: ORANGE.DIM,
|
||||
borderColor: ORANGE[500],
|
||||
borderWidth: 1,
|
||||
},
|
||||
},
|
||||
border: {
|
||||
dim: 'rgba(168,178,216,0.15)',
|
||||
hover: 'rgba(168,178,216,0.25)',
|
||||
brand: 'rgba(0,200,150,0.35)',
|
||||
dim: BORDER_COLORS.DIM,
|
||||
hover: BORDER_COLORS.HOVER,
|
||||
brand: BORDER_COLORS.BRAND,
|
||||
separator: BORDER_COLORS.SEPARATOR,
|
||||
},
|
||||
gradients: {
|
||||
videoOverlay: ['transparent', 'rgba(0,0,0,0.8)'],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Theme type definitions
|
||||
* Dark Medical — no glass, no shadows
|
||||
* Dark Premium — refined navy, native iOS feel
|
||||
*/
|
||||
|
||||
export interface SurfaceStyle {
|
||||
@@ -36,6 +36,7 @@ export interface ThemeColors {
|
||||
dim: string
|
||||
hover: string
|
||||
brand: string
|
||||
separator: string
|
||||
}
|
||||
gradients: {
|
||||
videoOverlay: readonly string[]
|
||||
|
||||
Reference in New Issue
Block a user