mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Update
This commit is contained in:
1 parent
4d53d83769
commit
6ff5bc62f2
17 files changed
+2599
-1136
No files matched your search
@@ -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;
|
||||
@@ -0,0 +1,4 @@
|
||||
export { GradientBackground } from './GradientBackground';
|
||||
export { GlassCard } from './GlassCard';
|
||||
export { GlassButton } from './GlassButton';
|
||||
export { GlassInput } from './GlassInput';
|
||||
Reference in new issue
Block a user