This commit is contained in:
jjbrunton committed 2025-12-28 16:51:32 +00:00
1 parent 4d53d83769
commit 6ff5bc62f2
17 files changed
+2599 -1136

No files matched your search

+247 -169
View File
@@ -1,5 +1,7 @@
import { View, Text, StyleSheet, Image, Dimensions, TouchableOpacity } from "react-native";
import { View, Text, StyleSheet, Dimensions, Platform } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { BlurView } from "expo-blur";
import { LinearGradient } from "expo-linear-gradient";
import Animated, {
useSharedValue,
useAnimatedStyle,
@@ -8,17 +10,33 @@ import Animated, {
interpolate,
Extrapolate,
withTiming,
interpolateColor,
} from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons";
import { colors, gradients, radius, shadows, blur, typography, spacing, animations } from "../theme";
// Haptics helper - not supported on web
const triggerHaptic = async (style: 'light' | 'medium' | 'heavy' = 'medium') => {
if (Platform.OS === 'web') return;
const Haptics = await import('expo-haptics');
const feedbackStyle = style === 'light'
? Haptics.ImpactFeedbackStyle.Light
: style === 'heavy'
? Haptics.ImpactFeedbackStyle.Heavy
: Haptics.ImpactFeedbackStyle.Medium;
await Haptics.impactAsync(feedbackStyle);
};
const SCREEN_WIDTH = Dimensions.get("window").width;
const SWIPE_THRESHOLD = SCREEN_WIDTH * 0.25;
interface Props {
question: { id: string; text: string; intensity: number; partner_text?: string | null };
question: { id: string; text: string; intensity: number; partner_text?: string | null; is_two_part?: boolean };
onSwipe: (direction: "left" | "right" | "up") => void;
}
const AnimatedBlurView = Animated.createAnimatedComponent(BlurView);
export default function SwipeCard({ question, onSwipe }: Props) {
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
@@ -32,7 +50,7 @@ export default function SwipeCard({ question, onSwipe }: Props) {
.activeOffsetY([-15, 15])
.onStart(() => {
context.value = { x: translateX.value, y: translateY.value };
scale.value = withTiming(1.05);
scale.value = withTiming(1.02, { duration: animations.timing.fast });
})
.onUpdate((event) => {
translateX.value = event.translationX + context.value.x;
@@ -40,27 +58,29 @@ export default function SwipeCard({ question, onSwipe }: Props) {
rotation.value = interpolate(
translateX.value,
[-SCREEN_WIDTH / 2, SCREEN_WIDTH / 2],
[-15, 15],
[-12, 12],
Extrapolate.CLAMP
);
})
.onEnd(() => {
scale.value = withTiming(1);
scale.value = withTiming(1, { duration: animations.timing.fast });
if (Math.abs(translateX.value) > SWIPE_THRESHOLD) {
// Horizontal swipe (YES/NO)
const direction = translateX.value > 0 ? "right" : "left";
translateX.value = withSpring(direction === "right" ? SCREEN_WIDTH * 1.5 : -SCREEN_WIDTH * 1.5);
runOnJS(triggerHaptic)('medium');
translateX.value = withSpring(
direction === "right" ? SCREEN_WIDTH * 1.5 : -SCREEN_WIDTH * 1.5,
animations.springGentle
);
runOnJS(onSwipe)(direction);
} else if (translateY.value < -SWIPE_THRESHOLD) {
// Vertical swipe up (MAYBE)
translateY.value = withSpring(-SCREEN_WIDTH * 1.5);
runOnJS(triggerHaptic)('medium');
translateY.value = withSpring(-SCREEN_WIDTH * 1.5, animations.springGentle);
runOnJS(onSwipe)("up");
} else {
// Reset
translateX.value = withSpring(0);
translateY.value = withSpring(0);
rotation.value = withSpring(0);
translateX.value = withSpring(0, animations.spring);
translateY.value = withSpring(0, animations.spring);
rotation.value = withSpring(0, animations.spring);
}
});
@@ -73,80 +93,184 @@ export default function SwipeCard({ question, onSwipe }: Props) {
],
}));
const overlayStyle = (direction: "left" | "right" | "up", color: string) =>
const overlayStyle = (direction: "left" | "right" | "up") =>
useAnimatedStyle(() => {
let opacity = 0;
let backgroundColor = 'transparent';
if (direction === "right") {
opacity = interpolate(translateX.value, [0, SWIPE_THRESHOLD], [0, 1]);
opacity = interpolate(translateX.value, [0, SWIPE_THRESHOLD], [0, 0.9]);
backgroundColor = interpolateColor(
opacity,
[0, 0.9],
['transparent', 'rgba(46, 204, 113, 0.4)']
) as string;
} else if (direction === "left") {
opacity = interpolate(translateX.value, [0, -SWIPE_THRESHOLD], [0, 1]);
opacity = interpolate(translateX.value, [0, -SWIPE_THRESHOLD], [0, 0.9]);
backgroundColor = interpolateColor(
opacity,
[0, 0.9],
['transparent', 'rgba(231, 76, 60, 0.4)']
) as string;
} else {
opacity = interpolate(translateY.value, [0, -SWIPE_THRESHOLD], [0, 1]);
opacity = interpolate(translateY.value, [0, -SWIPE_THRESHOLD], [0, 0.9]);
backgroundColor = interpolateColor(
opacity,
[0, 0.9],
['transparent', 'rgba(243, 156, 18, 0.4)']
) as string;
}
return {
opacity,
backgroundColor: color,
backgroundColor,
};
});
const handleButtonPress = async (direction: "left" | "right" | "up") => {
await triggerHaptic('light');
onSwipe(direction);
};
const useBlur = Platform.OS === 'ios';
return (
<View style={styles.cardWrapper}>
<GestureDetector gesture={gesture}>
<Animated.View style={[styles.card, animatedStyle]}>
{/* Overlays */}
<Animated.View style={[styles.overlay, overlayStyle("right", "rgba(76, 175, 80, 0.4)")]}>
<Text style={styles.overlayText}>YES</Text>
</Animated.View>
<Animated.View style={[styles.overlay, overlayStyle("left", "rgba(244, 67, 54, 0.4)")]}>
<Text style={styles.overlayText}>NO</Text>
</Animated.View>
<Animated.View style={[styles.overlay, overlayStyle("up", "rgba(255, 152, 0, 0.4)")]}>
<Text style={styles.overlayText}>MAYBE</Text>
</Animated.View>
<View style={styles.content}>
<View style={styles.intensityContainer}>
{[...Array(question.intensity)].map((_, i) => (
<Ionicons key={i} name="flame" size={16} color="#e94560" />
))}
<Animated.View style={[styles.cardOuter, animatedStyle, shadows.xl]}>
{useBlur ? (
<BlurView
intensity={blur.medium}
tint="dark"
style={styles.blurContainer}
>
<CardContent
question={question}
overlayStyle={overlayStyle}
handleButtonPress={handleButtonPress}
/>
</BlurView>
) : (
<View style={styles.fallbackContainer}>
<CardContent
question={question}
overlayStyle={overlayStyle}
handleButtonPress={handleButtonPress}
/>
</View>
<Text style={[styles.text, question.is_two_part ? styles.twoPartText : null]}>{question.text}</Text>
</View>
)}
</Animated.View>
</GestureDetector>
</View>
);
}
function CardContent({
question,
overlayStyle,
handleButtonPress,
}: {
question: Props['question'];
overlayStyle: (direction: "left" | "right" | "up") => any;
handleButtonPress: (direction: "left" | "right" | "up") => void;
}) {
return (
<>
{/* Top highlight */}
<LinearGradient
colors={[colors.glass.highlight, 'transparent']}
style={styles.highlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
{/* Overlays */}
<Animated.View style={[styles.overlay, overlayStyle("right")]}>
<View style={[styles.overlayBadge, { backgroundColor: colors.success }]}>
<Text style={styles.overlayText}>YES</Text>
</View>
</Animated.View>
<Animated.View style={[styles.overlay, overlayStyle("left")]}>
<View style={[styles.overlayBadge, { backgroundColor: colors.error }]}>
<Text style={styles.overlayText}>NO</Text>
</View>
</Animated.View>
<Animated.View style={[styles.overlay, overlayStyle("up")]}>
<View style={[styles.overlayBadge, { backgroundColor: colors.warning }]}>
<Text style={styles.overlayText}>MAYBE</Text>
</View>
</Animated.View>
{/* Content */}
<View style={styles.content}>
<View style={styles.intensityContainer}>
{[...Array(question.intensity)].map((_, i) => (
<Ionicons key={i} name="flame" size={16} color={colors.primary} />
))}
</View>
<Text style={[styles.text, question.is_two_part && styles.twoPartText]}>
{question.text}
</Text>
</View>
{/* Action Buttons */}
<View style={styles.footer}>
<View style={styles.buttonContainer}>
<TouchableOpacity
style={[styles.actionButton, styles.noButton]}
onPress={() => onSwipe("left")}
activeOpacity={0.7}
>
<View style={styles.buttonInnerGlow} />
<Ionicons name="thumbs-down" size={24} color="#fff" style={styles.buttonIcon} />
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, styles.maybeButton]}
onPress={() => onSwipe("up")}
activeOpacity={0.7}
>
<View style={styles.buttonInnerGlow} />
<Ionicons name="help-circle" size={24} color="#fff" style={styles.buttonIcon} />
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, styles.yesButton]}
onPress={() => onSwipe("right")}
activeOpacity={0.7}
>
<View style={styles.buttonInnerGlow} />
<Ionicons name="thumbs-up" size={24} color="#fff" style={styles.buttonIcon} />
</TouchableOpacity>
<ActionButton
onPress={() => handleButtonPress("left")}
colors={gradients.error as [string, string]}
icon="close"
/>
<ActionButton
onPress={() => handleButtonPress("up")}
colors={gradients.warning as [string, string]}
icon="help"
small
/>
<ActionButton
onPress={() => handleButtonPress("right")}
colors={gradients.success as [string, string]}
icon="heart"
/>
</View>
</View>
</View>
</>
);
}
function ActionButton({
onPress,
colors: buttonColors,
icon,
small = false,
}: {
onPress: () => void;
colors: [string, string];
icon: string;
small?: boolean;
}) {
return (
<Animated.View>
<LinearGradient
colors={buttonColors}
style={[
styles.actionButton,
small && styles.actionButtonSmall,
shadows.md,
]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<View style={styles.buttonHighlight} />
<Ionicons
name={icon as any}
size={small ? 20 : 28}
color={colors.text}
style={styles.buttonIcon}
onPress={onPress}
/>
</LinearGradient>
</Animated.View>
);
}
@@ -154,159 +278,113 @@ const styles = StyleSheet.create({
cardWrapper: {
position: "absolute",
width: SCREEN_WIDTH - 48,
height: 500,
height: 480,
},
card: {
cardOuter: {
width: "100%",
height: 500,
backgroundColor: "#16213e",
borderRadius: 24,
borderWidth: 1,
borderColor: "#0f3460",
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.3,
shadowRadius: 4.65,
elevation: 8,
height: "100%",
borderRadius: radius.xxl,
overflow: "hidden",
},
blurContainer: {
flex: 1,
backgroundColor: colors.glass.background,
borderWidth: 1,
borderColor: colors.glass.border,
borderRadius: radius.xxl,
overflow: "hidden",
},
fallbackContainer: {
flex: 1,
backgroundColor: colors.glass.backgroundLight,
borderWidth: 1,
borderColor: colors.glass.border,
borderRadius: radius.xxl,
overflow: "hidden",
},
highlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 80,
},
content: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 32,
padding: spacing.xl,
},
intensityContainer: {
flexDirection: "row",
marginBottom: 24,
backgroundColor: "rgba(233, 69, 96, 0.1)",
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 12,
marginBottom: spacing.lg,
backgroundColor: colors.primaryLight,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
gap: 2,
},
text: {
fontSize: 24,
fontWeight: "bold",
color: "#fff",
...typography.title2,
color: colors.text,
textAlign: "center",
lineHeight: 32,
},
twoPartText: {
fontSize: 20,
...typography.title3,
lineHeight: 28,
},
footer: {
position: "absolute",
bottom: 0,
left: 0,
right: 0,
paddingTop: 8,
paddingBottom: 16,
paddingHorizontal: 8,
alignItems: "center",
backgroundColor: "rgba(0,0,0,0.2)",
borderBottomLeftRadius: 24,
borderBottomRightRadius: 24,
},
hint: {
color: "#666",
fontSize: 12,
marginBottom: 4,
paddingBottom: spacing.lg,
paddingHorizontal: spacing.lg,
},
overlay: {
...StyleSheet.absoluteFillObject,
justifyContent: "center",
alignItems: "center",
zIndex: 10,
borderRadius: radius.xxl,
},
overlayBadge: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.md,
transform: [{ rotate: "-15deg" }],
},
overlayText: {
fontSize: 48,
fontSize: 36,
fontWeight: "900",
color: "#fff",
textShadowColor: "rgba(0,0,0,0.5)",
textShadowOffset: { width: 0, height: 2 },
textShadowRadius: 4,
transform: [{ rotate: "-15deg" }],
borderWidth: 4,
borderColor: "#fff",
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 12,
},
partnerContainer: {
width: "100%",
alignItems: "center",
},
divider: {
height: 1,
width: "80%",
backgroundColor: "rgba(255,255,255,0.1)",
marginVertical: 24,
position: "relative",
justifyContent: "center",
alignItems: "center",
},
dividerText: {
color: "#666",
backgroundColor: "#16213e",
paddingHorizontal: 10,
fontSize: 12,
fontWeight: "bold",
position: "absolute",
color: colors.text,
letterSpacing: 2,
},
buttonContainer: {
flexDirection: "row",
justifyContent: "space-between",
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 32,
paddingVertical: 16,
gap: 20,
zIndex: 21,
gap: spacing.md,
},
actionButton: {
alignItems: "center",
justifyContent: "center",
width: 64,
height: 64,
borderRadius: 32,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.4,
shadowRadius: 8,
elevation: 8,
borderWidth: 2,
justifyContent: "center",
alignItems: "center",
overflow: "hidden",
zIndex: 22,
},
noButton: {
backgroundColor: "#E74C3C",
borderColor: "rgba(255, 255, 255, 0.3)",
actionButtonSmall: {
width: 52,
height: 52,
borderRadius: 26,
},
maybeButton: {
backgroundColor: "#F39C12",
borderColor: "rgba(255, 255, 255, 0.3)",
},
yesButton: {
backgroundColor: "#2ECC71",
borderColor: "rgba(255, 255, 255, 0.3)",
},
buttonInnerGlow: {
buttonHighlight: {
position: "absolute",
top: 0,
left: 0,
right: 0,
height: "50%",
backgroundColor: "rgba(255, 255, 255, 0.15)",
borderTopLeftRadius: 32,
borderTopRightRadius: 32,
},
buttonIcon: {
zIndex: 1,
textShadowColor: "rgba(0, 0, 0, 0.3)",
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
},
});
@@ -0,0 +1,238 @@
import React from 'react';
import { StyleSheet, Text, Pressable, ViewStyle, TextStyle, ActivityIndicator, Platform } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
} from 'react-native-reanimated';
import { colors, gradients, radius, shadows, typography, animations } from '../../theme';
// Haptics is not supported on web
const triggerHaptic = async () => {
if (Platform.OS === 'web') return;
const Haptics = await import('expo-haptics');
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
};
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
type ButtonSize = 'sm' | 'md' | 'lg';
interface GlassButtonProps {
children: React.ReactNode;
onPress?: () => void;
variant?: ButtonVariant;
size?: ButtonSize;
disabled?: boolean;
loading?: boolean;
fullWidth?: boolean;
style?: ViewStyle;
textStyle?: TextStyle;
haptic?: boolean;
icon?: React.ReactNode;
}
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
export function GlassButton({
children,
onPress,
variant = 'primary',
size = 'md',
disabled = false,
loading = false,
fullWidth = false,
style,
textStyle,
haptic = true,
icon,
}: GlassButtonProps) {
const scale = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
const handlePressIn = () => {
scale.value = withSpring(0.96, animations.spring);
};
const handlePressOut = () => {
scale.value = withSpring(1, animations.spring);
};
const handlePress = async () => {
if (disabled || loading) return;
if (haptic) {
await triggerHaptic();
}
onPress?.();
};
const sizeStyles = sizes[size];
const variantStyles = variants[variant];
const content = (
<>
{loading ? (
<ActivityIndicator
color={variantStyles.textColor}
size="small"
style={styles.loader}
/>
) : (
<>
{icon && <Animated.View style={styles.icon}>{icon}</Animated.View>}
<Text
style={[
styles.text,
sizeStyles.text,
{ color: variantStyles.textColor },
textStyle,
]}
>
{children}
</Text>
</>
)}
</>
);
return (
<AnimatedPressable
onPress={handlePress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
disabled={disabled || loading}
style={[
animatedStyle,
fullWidth && styles.fullWidth,
disabled && styles.disabled,
]}
>
{variant === 'primary' || variant === 'danger' ? (
<LinearGradient
colors={variantStyles.gradient as [string, string]}
style={[
styles.button,
sizeStyles.button,
shadows.md,
style,
]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
{content}
</LinearGradient>
) : (
<Animated.View
style={[
styles.button,
sizeStyles.button,
{
backgroundColor: variantStyles.background,
borderWidth: variantStyles.borderWidth,
borderColor: variantStyles.borderColor,
},
variant === 'secondary' && shadows.sm,
style,
]}
>
{content}
</Animated.View>
)}
</AnimatedPressable>
);
}
const variants = {
primary: {
gradient: gradients.primary,
textColor: colors.text,
background: undefined,
borderWidth: 0,
borderColor: undefined,
},
secondary: {
gradient: undefined,
textColor: colors.text,
background: colors.glass.background,
borderWidth: 1,
borderColor: colors.glass.border,
},
ghost: {
gradient: undefined,
textColor: colors.primary,
background: 'transparent',
borderWidth: 0,
borderColor: undefined,
},
danger: {
gradient: gradients.error,
textColor: colors.text,
background: undefined,
borderWidth: 0,
borderColor: undefined,
},
};
const sizes = {
sm: {
button: {
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: radius.md,
},
text: {
fontSize: 14,
fontWeight: '600' as const,
},
},
md: {
button: {
paddingHorizontal: 24,
paddingVertical: 14,
borderRadius: radius.md,
},
text: {
fontSize: 16,
fontWeight: '600' as const,
},
},
lg: {
button: {
paddingHorizontal: 32,
paddingVertical: 18,
borderRadius: radius.lg,
},
text: {
fontSize: 18,
fontWeight: '600' as const,
},
},
};
const styles = StyleSheet.create({
button: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
text: {
textAlign: 'center',
},
fullWidth: {
width: '100%',
},
disabled: {
opacity: 0.5,
},
loader: {
marginHorizontal: 8,
},
icon: {
marginRight: 8,
},
});
export default GlassButton;
@@ -0,0 +1,98 @@
import React from 'react';
import { StyleSheet, View, ViewStyle, Platform } from 'react-native';
import { BlurView } from 'expo-blur';
import { LinearGradient } from 'expo-linear-gradient';
import { colors, radius, shadows, blur } from '../../theme';
interface GlassCardProps {
children: React.ReactNode;
style?: ViewStyle;
intensity?: 'light' | 'medium' | 'heavy';
variant?: 'default' | 'elevated' | 'subtle';
noPadding?: boolean;
}
export function GlassCard({
children,
style,
intensity = 'medium',
variant = 'default',
noPadding = false,
}: GlassCardProps) {
const blurIntensity = blur[intensity];
const shadowStyle = variant === 'elevated' ? shadows.lg : variant === 'subtle' ? shadows.sm : shadows.md;
const borderColor = variant === 'elevated' ? colors.glass.borderLight : colors.glass.border;
// Use BlurView on iOS, fallback to solid background on Android for performance
const useBlur = Platform.OS === 'ios';
return (
<View style={[styles.container, shadowStyle, style]}>
{useBlur ? (
<BlurView
intensity={blurIntensity}
tint="dark"
style={[styles.blurContainer, { borderColor }]}
>
{/* Top highlight gradient */}
<LinearGradient
colors={[colors.glass.highlight, 'transparent']}
style={styles.highlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
<View style={[styles.content, noPadding && styles.noPadding]}>
{children}
</View>
</BlurView>
) : (
// Android fallback with solid background
<View style={[styles.fallbackContainer, { borderColor }]}>
<LinearGradient
colors={[colors.glass.highlight, 'transparent']}
style={styles.highlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
<View style={[styles.content, noPadding && styles.noPadding]}>
{children}
</View>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
borderRadius: radius.lg,
overflow: 'hidden',
},
blurContainer: {
overflow: 'hidden',
borderRadius: radius.lg,
borderWidth: 1,
backgroundColor: colors.glass.background,
},
fallbackContainer: {
overflow: 'hidden',
borderRadius: radius.lg,
borderWidth: 1,
backgroundColor: colors.glass.backgroundLight,
},
highlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 60,
},
content: {
padding: 16,
},
noPadding: {
padding: 0,
},
});
export default GlassCard;
@@ -0,0 +1,140 @@
import React, { useState } from 'react';
import { StyleSheet, TextInput, View, Text, TextInputProps, ViewStyle, Platform } from 'react-native';
import { BlurView } from 'expo-blur';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
interpolateColor,
} from 'react-native-reanimated';
import { colors, radius, blur, animations, typography } from '../../theme';
interface GlassInputProps extends Omit<TextInputProps, 'style'> {
label?: string;
error?: string;
containerStyle?: ViewStyle;
icon?: React.ReactNode;
}
const AnimatedBlurView = Animated.createAnimatedComponent(BlurView);
const AnimatedView = Animated.View;
export function GlassInput({
label,
error,
containerStyle,
icon,
...props
}: GlassInputProps) {
const [isFocused, setIsFocused] = useState(false);
const focusAnim = useSharedValue(0);
const handleFocus = (e: any) => {
setIsFocused(true);
focusAnim.value = withTiming(1, { duration: animations.timing.fast });
props.onFocus?.(e);
};
const handleBlur = (e: any) => {
setIsFocused(false);
focusAnim.value = withTiming(0, { duration: animations.timing.fast });
props.onBlur?.(e);
};
const animatedBorderStyle = useAnimatedStyle(() => ({
borderColor: interpolateColor(
focusAnim.value,
[0, 1],
[error ? colors.error : colors.glass.border, error ? colors.error : colors.primary]
),
}));
const useBlur = Platform.OS === 'ios';
const inputContent = (
<View style={styles.inputWrapper}>
{icon && <View style={styles.icon}>{icon}</View>}
<TextInput
{...props}
style={[styles.input, icon ? styles.inputWithIcon : undefined]}
placeholderTextColor={colors.textTertiary}
onFocus={handleFocus}
onBlur={handleBlur}
selectionColor={colors.primary}
/>
</View>
);
return (
<View style={[styles.container, containerStyle]}>
{label && <Text style={styles.label}>{label}</Text>}
{useBlur ? (
<AnimatedView style={[styles.blurWrapper, animatedBorderStyle]}>
<BlurView
intensity={blur.light}
tint="dark"
style={styles.blurContainer}
>
{inputContent}
</BlurView>
</AnimatedView>
) : (
<AnimatedView style={[styles.fallbackContainer, animatedBorderStyle]}>
{inputContent}
</AnimatedView>
)}
{error && <Text style={styles.error}>{error}</Text>}
</View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: 16,
},
label: {
...typography.subhead,
color: colors.textSecondary,
marginBottom: 8,
marginLeft: 4,
},
blurWrapper: {
borderRadius: radius.md,
borderWidth: 1,
overflow: 'hidden',
},
blurContainer: {
backgroundColor: colors.glass.background,
},
fallbackContainer: {
borderRadius: radius.md,
borderWidth: 1,
backgroundColor: colors.glass.backgroundLight,
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
},
icon: {
paddingLeft: 16,
},
input: {
flex: 1,
padding: 16,
fontSize: 16,
color: colors.text,
},
inputWithIcon: {
paddingLeft: 12,
},
error: {
...typography.caption1,
color: colors.error,
marginTop: 6,
marginLeft: 4,
},
});
export default GlassInput;
@@ -0,0 +1,71 @@
import React from 'react';
import { StyleSheet, View, ViewStyle } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { colors, gradients } from '../../theme';
interface GradientBackgroundProps {
children: React.ReactNode;
variant?: 'default' | 'reverse' | 'subtle';
style?: ViewStyle;
showAccent?: boolean;
}
export function GradientBackground({
children,
variant = 'default',
style,
showAccent = false,
}: GradientBackgroundProps) {
const gradientColors = variant === 'reverse'
? gradients.backgroundReverse
: gradients.background;
return (
<View style={[styles.container, style]}>
<LinearGradient
colors={gradientColors as [string, string]}
style={StyleSheet.absoluteFill}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
{showAccent && (
<>
{/* Top-right accent glow */}
<View style={styles.accentTopRight} />
{/* Bottom-left accent glow */}
<View style={styles.accentBottomLeft} />
</>
)}
{children}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
accentTopRight: {
position: 'absolute',
top: -100,
right: -100,
width: 300,
height: 300,
borderRadius: 150,
backgroundColor: colors.primaryGlow,
opacity: 0.15,
},
accentBottomLeft: {
position: 'absolute',
bottom: -50,
left: -100,
width: 250,
height: 250,
borderRadius: 125,
backgroundColor: colors.primaryGlow,
opacity: 0.1,
},
});
export default GradientBackground;
+4
View File
@@ -0,0 +1,4 @@
export { GradientBackground } from './GradientBackground';
export { GlassCard } from './GlassCard';
export { GlassButton } from './GlassButton';
export { GlassInput } from './GlassInput';