mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-02 02:19:43 +00:00
Refactor profile screen and organize project structure
- Extract ProfileScreen and components to features/profile - Move shared hooks and stores to top-level - Add RLS policies for admin permissions - Add RELEASING.md guide - Update .gitignore
This commit is contained in:
1 parent
df4c4d486b
commit
8f8c6e401d
79 files changed
+6689
-4944
No files matched your search
+623
@@ -0,0 +1,623 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { View, Text, StyleSheet, Platform, useWindowDimensions } from "react-native";
|
||||
import { Gesture, GestureDetector, TouchableOpacity } from "react-native-gesture-handler";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withSpring,
|
||||
withRepeat,
|
||||
withSequence,
|
||||
runOnJS,
|
||||
interpolate,
|
||||
Extrapolate,
|
||||
withTiming,
|
||||
interpolateColor,
|
||||
Easing,
|
||||
} from "react-native-reanimated";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { colors, gradients, radius, shadows, blur, typography, spacing, animations } from "../../theme";
|
||||
import { QuestionFeedbackModal } from "../feedback";
|
||||
|
||||
// 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 MAX_CARD_WIDTH = 400;
|
||||
const SWIPE_THRESHOLD = 100;
|
||||
|
||||
interface Props {
|
||||
question: { id: string; text: string; intensity: number; partner_text?: string | null; is_two_part?: boolean };
|
||||
onSwipe: (direction: "left" | "right" | "up" | "down") => void;
|
||||
}
|
||||
|
||||
const AnimatedBlurView = Animated.createAnimatedComponent(BlurView);
|
||||
|
||||
export default function SwipeCard({ question, onSwipe }: Props) {
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
const cardWidth = Math.min(screenWidth - 48, MAX_CARD_WIDTH);
|
||||
const [showFeedbackModal, setShowFeedbackModal] = useState(false);
|
||||
|
||||
const translateX = useSharedValue(0);
|
||||
const translateY = useSharedValue(0);
|
||||
const rotation = useSharedValue(0);
|
||||
const scale = useSharedValue(1);
|
||||
const shadowExpand = useSharedValue(0);
|
||||
const idleBreathing = useSharedValue(0);
|
||||
const isGesturing = useSharedValue(false);
|
||||
|
||||
const context = useSharedValue({ x: 0, y: 0 });
|
||||
|
||||
// Subtle idle breathing animation
|
||||
useEffect(() => {
|
||||
idleBreathing.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(1, { duration: 2000, easing: Easing.inOut(Easing.sin) }),
|
||||
withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.sin) })
|
||||
),
|
||||
-1,
|
||||
true
|
||||
);
|
||||
}, []);
|
||||
|
||||
const gesture = Gesture.Pan()
|
||||
.activeOffsetX([-15, 15])
|
||||
.activeOffsetY([-15, 15])
|
||||
.onStart(() => {
|
||||
context.value = { x: translateX.value, y: translateY.value };
|
||||
scale.value = withTiming(1.02, { duration: animations.timing.fast });
|
||||
shadowExpand.value = withTiming(1, { duration: animations.timing.fast });
|
||||
isGesturing.value = true;
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
translateX.value = event.translationX + context.value.x;
|
||||
translateY.value = event.translationY + context.value.y;
|
||||
rotation.value = interpolate(
|
||||
translateX.value,
|
||||
[-cardWidth / 2, cardWidth / 2],
|
||||
[-12, 12],
|
||||
Extrapolate.CLAMP
|
||||
);
|
||||
})
|
||||
.onEnd(() => {
|
||||
scale.value = withTiming(1, { duration: animations.timing.fast });
|
||||
shadowExpand.value = withTiming(0, { duration: animations.timing.fast });
|
||||
isGesturing.value = false;
|
||||
|
||||
if (Math.abs(translateX.value) > SWIPE_THRESHOLD) {
|
||||
const direction = translateX.value > 0 ? "right" : "left";
|
||||
runOnJS(triggerHaptic)('medium');
|
||||
translateX.value = withSpring(
|
||||
direction === "right" ? screenWidth * 1.5 : -screenWidth * 1.5,
|
||||
animations.springGentle
|
||||
);
|
||||
runOnJS(onSwipe)(direction);
|
||||
} else if (translateY.value < -SWIPE_THRESHOLD) {
|
||||
runOnJS(triggerHaptic)('medium');
|
||||
translateY.value = withSpring(-screenWidth * 1.5, animations.springGentle);
|
||||
runOnJS(onSwipe)("up");
|
||||
} else if (translateY.value > SWIPE_THRESHOLD) {
|
||||
runOnJS(triggerHaptic)('light');
|
||||
translateY.value = withSpring(screenWidth * 1.5, animations.springGentle);
|
||||
runOnJS(onSwipe)("down");
|
||||
} else {
|
||||
translateX.value = withSpring(0, animations.spring);
|
||||
translateY.value = withSpring(0, animations.spring);
|
||||
rotation.value = withSpring(0, animations.spring);
|
||||
}
|
||||
});
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
// Apply idle breathing only when card is at rest
|
||||
const idleOffset = isGesturing.value ? 0 : interpolate(idleBreathing.value, [0, 1], [0, -6]);
|
||||
|
||||
return {
|
||||
transform: [
|
||||
{ translateX: translateX.value },
|
||||
{ translateY: translateY.value + idleOffset },
|
||||
{ rotate: `${rotation.value}deg` },
|
||||
{ scale: scale.value },
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
// Dynamic shadow for card lift effect
|
||||
const cardShadowStyle = useAnimatedStyle(() => ({
|
||||
shadowOpacity: interpolate(shadowExpand.value, [0, 1], [0.3, 0.5]),
|
||||
shadowRadius: interpolate(shadowExpand.value, [0, 1], [24, 36]),
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: interpolate(shadowExpand.value, [0, 1], [12, 18]),
|
||||
},
|
||||
}));
|
||||
|
||||
const overlayStyle = (direction: "left" | "right" | "up" | "down") =>
|
||||
useAnimatedStyle(() => {
|
||||
let opacity = 0;
|
||||
let backgroundColor = 'transparent';
|
||||
|
||||
if (direction === "right") {
|
||||
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, 0.9]);
|
||||
backgroundColor = interpolateColor(
|
||||
opacity,
|
||||
[0, 0.9],
|
||||
['transparent', 'rgba(231, 76, 60, 0.4)']
|
||||
) as string;
|
||||
} else if (direction === "up") {
|
||||
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;
|
||||
} else {
|
||||
opacity = interpolate(translateY.value, [0, SWIPE_THRESHOLD], [0, 0.9]);
|
||||
backgroundColor = interpolateColor(
|
||||
opacity,
|
||||
[0, 0.9],
|
||||
['transparent', 'rgba(108, 117, 125, 0.4)']
|
||||
) as string;
|
||||
}
|
||||
|
||||
return {
|
||||
opacity,
|
||||
backgroundColor,
|
||||
};
|
||||
});
|
||||
|
||||
const handleButtonPress = async (direction: "left" | "right" | "up") => {
|
||||
await triggerHaptic('light');
|
||||
onSwipe(direction);
|
||||
};
|
||||
|
||||
const useBlur = Platform.OS === 'ios';
|
||||
|
||||
const handleFeedbackPress = () => {
|
||||
setShowFeedbackModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={[styles.cardWrapper, { width: cardWidth }]}>
|
||||
<GestureDetector gesture={gesture}>
|
||||
<Animated.View style={[styles.cardOuter, animatedStyle, cardShadowStyle, { shadowColor: '#000' }]}>
|
||||
{useBlur ? (
|
||||
<BlurView
|
||||
intensity={blur.medium}
|
||||
tint="dark"
|
||||
style={styles.blurContainer}
|
||||
>
|
||||
<CardContent
|
||||
question={question}
|
||||
overlayStyle={overlayStyle}
|
||||
handleButtonPress={handleButtonPress}
|
||||
onFeedbackPress={handleFeedbackPress}
|
||||
translateX={translateX}
|
||||
translateY={translateY}
|
||||
/>
|
||||
</BlurView>
|
||||
) : (
|
||||
<View style={styles.fallbackContainer}>
|
||||
<CardContent
|
||||
question={question}
|
||||
overlayStyle={overlayStyle}
|
||||
handleButtonPress={handleButtonPress}
|
||||
onFeedbackPress={handleFeedbackPress}
|
||||
translateX={translateX}
|
||||
translateY={translateY}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{/* Inner border highlight for depth */}
|
||||
<View style={styles.innerBorder} pointerEvents="none" />
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
|
||||
<QuestionFeedbackModal
|
||||
visible={showFeedbackModal}
|
||||
onClose={() => setShowFeedbackModal(false)}
|
||||
questionId={question.id}
|
||||
questionText={question.text}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({
|
||||
question,
|
||||
overlayStyle,
|
||||
handleButtonPress,
|
||||
onFeedbackPress,
|
||||
translateX,
|
||||
translateY,
|
||||
}: {
|
||||
question: Props['question'];
|
||||
overlayStyle: (direction: "left" | "right" | "up" | "down") => any;
|
||||
handleButtonPress: (direction: "left" | "right" | "up") => void;
|
||||
onFeedbackPress: () => void;
|
||||
translateX: Animated.SharedValue<number>;
|
||||
translateY: Animated.SharedValue<number>;
|
||||
}) {
|
||||
const isHighIntensity = question.intensity >= 3;
|
||||
|
||||
// Badge scale animation based on swipe progress
|
||||
const badgeScaleStyle = (direction: "left" | "right" | "up" | "down") =>
|
||||
useAnimatedStyle(() => {
|
||||
let progress = 0;
|
||||
if (direction === "right") {
|
||||
progress = interpolate(translateX.value, [0, SWIPE_THRESHOLD], [0, 1], Extrapolate.CLAMP);
|
||||
} else if (direction === "left") {
|
||||
progress = interpolate(translateX.value, [-SWIPE_THRESHOLD, 0], [1, 0], Extrapolate.CLAMP);
|
||||
} else if (direction === "up") {
|
||||
progress = interpolate(translateY.value, [-SWIPE_THRESHOLD, 0], [1, 0], Extrapolate.CLAMP);
|
||||
} else {
|
||||
progress = interpolate(translateY.value, [0, SWIPE_THRESHOLD], [0, 1], Extrapolate.CLAMP);
|
||||
}
|
||||
return {
|
||||
transform: [{ scale: interpolate(progress, [0, 0.5, 1], [0.8, 1, 1.1]) }],
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Top highlight */}
|
||||
<LinearGradient
|
||||
colors={[colors.glass.highlight, 'transparent']}
|
||||
style={styles.highlight}
|
||||
start={{ x: 0.5, y: 0 }}
|
||||
end={{ x: 0.5, y: 1 }}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
|
||||
{/* Left edge highlight */}
|
||||
<LinearGradient
|
||||
colors={['rgba(255,255,255,0.08)', 'transparent']}
|
||||
style={styles.edgeHighlightLeft}
|
||||
start={{ x: 0, y: 0.5 }}
|
||||
end={{ x: 1, y: 0.5 }}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
|
||||
{/* Right edge highlight */}
|
||||
<LinearGradient
|
||||
colors={['transparent', 'rgba(255,255,255,0.08)']}
|
||||
style={styles.edgeHighlightRight}
|
||||
start={{ x: 0, y: 0.5 }}
|
||||
end={{ x: 1, y: 0.5 }}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
|
||||
{/* Feedback Button */}
|
||||
<View style={styles.feedbackButton}>
|
||||
<Ionicons
|
||||
name="flag-outline"
|
||||
size={18}
|
||||
color={colors.textTertiary}
|
||||
onPress={onFeedbackPress}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Overlays with gradient badges */}
|
||||
<Animated.View style={[styles.overlay, overlayStyle("right")]} pointerEvents="none">
|
||||
<Animated.View style={[styles.overlayBadge, badgeScaleStyle("right")]}>
|
||||
<LinearGradient
|
||||
colors={gradients.success as [string, string]}
|
||||
style={StyleSheet.absoluteFill}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
<Text style={styles.overlayText}>YES</Text>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
<Animated.View style={[styles.overlay, overlayStyle("left")]} pointerEvents="none">
|
||||
<Animated.View style={[styles.overlayBadge, badgeScaleStyle("left")]}>
|
||||
<LinearGradient
|
||||
colors={gradients.error as [string, string]}
|
||||
style={StyleSheet.absoluteFill}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
<Text style={styles.overlayText}>NO</Text>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
<Animated.View style={[styles.overlay, overlayStyle("up")]} pointerEvents="none">
|
||||
<Animated.View style={[styles.overlayBadge, badgeScaleStyle("up")]}>
|
||||
<LinearGradient
|
||||
colors={gradients.warning as [string, string]}
|
||||
style={StyleSheet.absoluteFill}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
<Text style={styles.overlayText}>MAYBE</Text>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
<Animated.View style={[styles.overlay, overlayStyle("down")]} pointerEvents="none">
|
||||
<Animated.View style={[styles.overlayBadge, badgeScaleStyle("down")]}>
|
||||
<View style={[StyleSheet.absoluteFill, { backgroundColor: colors.textTertiary }]} />
|
||||
<Text style={styles.overlayText}>SKIP</Text>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
|
||||
{/* Content */}
|
||||
<View style={styles.content}>
|
||||
<View style={[
|
||||
styles.intensityContainer,
|
||||
isHighIntensity && styles.intensityContainerHigh
|
||||
]}>
|
||||
{[...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}>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
onPress,
|
||||
colors: buttonColors,
|
||||
icon,
|
||||
small = false,
|
||||
}: {
|
||||
onPress: () => void;
|
||||
colors: [string, string];
|
||||
icon: string;
|
||||
small?: boolean;
|
||||
}) {
|
||||
const buttonScale = useSharedValue(1);
|
||||
const buttonGlow = useSharedValue(0);
|
||||
|
||||
const handlePressIn = () => {
|
||||
buttonScale.value = withSpring(0.92, animations.spring);
|
||||
buttonGlow.value = withTiming(1, { duration: 150 });
|
||||
};
|
||||
|
||||
const handlePressOut = () => {
|
||||
buttonScale.value = withSpring(1, animations.spring);
|
||||
buttonGlow.value = withTiming(0, { duration: 200 });
|
||||
};
|
||||
|
||||
const buttonAnimatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: buttonScale.value }],
|
||||
shadowOpacity: interpolate(buttonGlow.value, [0, 1], [0.3, 0.6]),
|
||||
shadowRadius: interpolate(buttonGlow.value, [0, 1], [8, 16]),
|
||||
}));
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
activeOpacity={1}
|
||||
>
|
||||
<Animated.View style={[
|
||||
styles.actionButton,
|
||||
small && styles.actionButtonSmall,
|
||||
buttonAnimatedStyle,
|
||||
{ shadowColor: buttonColors[0] }
|
||||
]}>
|
||||
<LinearGradient
|
||||
colors={buttonColors}
|
||||
style={StyleSheet.absoluteFill}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
<LinearGradient
|
||||
colors={['rgba(255,255,255,0.25)', 'rgba(255,255,255,0.05)']}
|
||||
style={styles.buttonHighlight}
|
||||
start={{ x: 0.5, y: 0 }}
|
||||
end={{ x: 0.5, y: 1 }}
|
||||
/>
|
||||
<Ionicons
|
||||
name={icon as any}
|
||||
size={small ? 20 : 28}
|
||||
color={colors.text}
|
||||
style={styles.buttonIcon}
|
||||
/>
|
||||
</Animated.View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
cardWrapper: {
|
||||
position: "absolute",
|
||||
maxWidth: MAX_CARD_WIDTH,
|
||||
height: 480,
|
||||
},
|
||||
cardOuter: {
|
||||
width: "100%",
|
||||
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,
|
||||
},
|
||||
edgeHighlightLeft: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
width: 60,
|
||||
},
|
||||
edgeHighlightRight: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: 60,
|
||||
},
|
||||
innerBorder: {
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
left: 2,
|
||||
right: 2,
|
||||
bottom: 2,
|
||||
borderRadius: radius.xxl - 2,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.06)',
|
||||
},
|
||||
feedbackButton: {
|
||||
position: 'absolute',
|
||||
top: spacing.md,
|
||||
right: spacing.md,
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: colors.glass.background,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 100,
|
||||
elevation: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: spacing.xl,
|
||||
},
|
||||
intensityContainer: {
|
||||
flexDirection: "row",
|
||||
marginBottom: spacing.lg,
|
||||
backgroundColor: colors.primaryLight,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.full,
|
||||
gap: 2,
|
||||
},
|
||||
intensityContainerHigh: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.primary,
|
||||
...shadows.glow(colors.primaryGlow),
|
||||
},
|
||||
text: {
|
||||
...typography.title2,
|
||||
color: colors.text,
|
||||
textAlign: "center",
|
||||
lineHeight: 32,
|
||||
},
|
||||
twoPartText: {
|
||||
...typography.title3,
|
||||
lineHeight: 28,
|
||||
},
|
||||
footer: {
|
||||
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" }],
|
||||
overflow: 'hidden',
|
||||
},
|
||||
overlayText: {
|
||||
fontSize: 36,
|
||||
fontWeight: "900",
|
||||
color: colors.text,
|
||||
letterSpacing: 2,
|
||||
},
|
||||
buttonContainer: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: spacing.md,
|
||||
},
|
||||
actionButton: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
overflow: "hidden",
|
||||
},
|
||||
actionButtonSmall: {
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 26,
|
||||
},
|
||||
buttonHighlight: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: "50%",
|
||||
borderTopLeftRadius: 32,
|
||||
borderTopRightRadius: 32,
|
||||
},
|
||||
buttonIcon: {
|
||||
zIndex: 1,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* SwipeCard component with platform-specific implementations.
|
||||
*
|
||||
* - On native (iOS/Android): Uses react-native-gesture-handler with Reanimated
|
||||
* for smooth, native-level gesture performance (SwipeCard.native.tsx)
|
||||
*
|
||||
* - On web: Uses React Native's built-in PanResponder which is compatible with
|
||||
* react-native-web (SwipeCard.web.tsx)
|
||||
*
|
||||
* Metro bundler automatically picks the right file based on the platform:
|
||||
* - .native.tsx for iOS/Android
|
||||
* - .web.tsx for web
|
||||
*
|
||||
* This file is a fallback that re-exports the web version for any other platform
|
||||
* that might not have a specific implementation.
|
||||
*/
|
||||
export { default } from "./SwipeCard.web";
|
||||
@@ -0,0 +1,448 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { View, Text, StyleSheet, Animated, PanResponder, TouchableOpacity, useWindowDimensions } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { QuestionFeedbackModal } from "../feedback";
|
||||
|
||||
const MAX_CARD_WIDTH = 400;
|
||||
const SWIPE_THRESHOLD = 100;
|
||||
|
||||
interface Props {
|
||||
question: { id: string; text: string; intensity: number; partner_text?: string | null };
|
||||
onSwipe: (direction: "left" | "right" | "up" | "down") => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Web-compatible SwipeCard using React Native's built-in PanResponder
|
||||
* instead of react-native-gesture-handler which doesn't work on web.
|
||||
*/
|
||||
export default function SwipeCard({ question, onSwipe }: Props) {
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
const cardWidth = Math.min(screenWidth - 48, MAX_CARD_WIDTH);
|
||||
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [hoveredButton, setHoveredButton] = useState<string | null>(null);
|
||||
const [showFeedbackModal, setShowFeedbackModal] = useState(false);
|
||||
|
||||
const translateX = useRef(new Animated.Value(0)).current;
|
||||
const translateY = useRef(new Animated.Value(0)).current;
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
|
||||
const rotation = translateX.interpolate({
|
||||
inputRange: [-cardWidth / 2, cardWidth / 2],
|
||||
outputRange: ["-15deg", "15deg"],
|
||||
extrapolate: "clamp",
|
||||
});
|
||||
|
||||
const rightOverlayOpacity = translateX.interpolate({
|
||||
inputRange: [0, SWIPE_THRESHOLD],
|
||||
outputRange: [0, 1],
|
||||
extrapolate: "clamp",
|
||||
});
|
||||
|
||||
const leftOverlayOpacity = translateX.interpolate({
|
||||
inputRange: [-SWIPE_THRESHOLD, 0],
|
||||
outputRange: [1, 0],
|
||||
extrapolate: "clamp",
|
||||
});
|
||||
|
||||
const upOverlayOpacity = translateY.interpolate({
|
||||
inputRange: [-SWIPE_THRESHOLD, 0],
|
||||
outputRange: [1, 0],
|
||||
extrapolate: "clamp",
|
||||
});
|
||||
|
||||
const downOverlayOpacity = translateY.interpolate({
|
||||
inputRange: [0, SWIPE_THRESHOLD],
|
||||
outputRange: [0, 1],
|
||||
extrapolate: "clamp",
|
||||
});
|
||||
|
||||
const panResponder = useRef(
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponder: (_, gestureState) => {
|
||||
// Don't activate if touch is in footer area (bottom 100px)
|
||||
const CARD_HEIGHT = 500;
|
||||
const FOOTER_START_Y = CARD_HEIGHT - 100;
|
||||
return gestureState.y0 < FOOTER_START_Y;
|
||||
},
|
||||
onMoveShouldSetPanResponder: (_, gestureState) => {
|
||||
// Don't activate if touch is in footer area
|
||||
const CARD_HEIGHT = 500;
|
||||
const FOOTER_START_Y = CARD_HEIGHT - 100;
|
||||
return gestureState.y0 < FOOTER_START_Y && (Math.abs(gestureState.dx) > 5 || Math.abs(gestureState.dy) > 5);
|
||||
},
|
||||
onPanResponderGrant: () => {
|
||||
Animated.timing(scale, {
|
||||
toValue: 1.05,
|
||||
duration: 100,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
},
|
||||
onPanResponderMove: (_, gestureState) => {
|
||||
translateX.setValue(gestureState.dx);
|
||||
translateY.setValue(gestureState.dy);
|
||||
},
|
||||
onPanResponderRelease: (_, gestureState) => {
|
||||
Animated.timing(scale, {
|
||||
toValue: 1,
|
||||
duration: 100,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
|
||||
if (Math.abs(gestureState.dx) > SWIPE_THRESHOLD) {
|
||||
// Horizontal swipe
|
||||
const direction = gestureState.dx > 0 ? "right" : "left";
|
||||
const targetX = direction === "right" ? screenWidth * 1.5 : -screenWidth * 1.5;
|
||||
|
||||
Animated.spring(translateX, {
|
||||
toValue: targetX,
|
||||
useNativeDriver: true,
|
||||
}).start(() => {
|
||||
setDismissed(true);
|
||||
onSwipe(direction);
|
||||
});
|
||||
} else if (gestureState.dy < -SWIPE_THRESHOLD) {
|
||||
// Vertical swipe up
|
||||
Animated.spring(translateY, {
|
||||
toValue: -screenWidth * 1.5,
|
||||
useNativeDriver: true,
|
||||
}).start(() => {
|
||||
setDismissed(true);
|
||||
onSwipe("up");
|
||||
});
|
||||
} else if (gestureState.dy > SWIPE_THRESHOLD) {
|
||||
// Vertical swipe down - skip
|
||||
Animated.spring(translateY, {
|
||||
toValue: screenWidth * 1.5,
|
||||
useNativeDriver: true,
|
||||
}).start(() => {
|
||||
setDismissed(true);
|
||||
onSwipe("down");
|
||||
});
|
||||
} else {
|
||||
// Reset position
|
||||
Animated.parallel([
|
||||
Animated.spring(translateX, {
|
||||
toValue: 0,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.spring(translateY, {
|
||||
toValue: 0,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}
|
||||
},
|
||||
})
|
||||
).current;
|
||||
|
||||
if (dismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.card,
|
||||
webStyles.card as any,
|
||||
{
|
||||
width: cardWidth,
|
||||
transform: [
|
||||
{ translateX },
|
||||
{ translateY },
|
||||
{ rotate: rotation },
|
||||
{ scale },
|
||||
],
|
||||
},
|
||||
]}
|
||||
{...panResponder.panHandlers}
|
||||
>
|
||||
{/* Feedback Button */}
|
||||
<TouchableOpacity
|
||||
style={styles.feedbackButton}
|
||||
onPress={() => setShowFeedbackModal(true)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons name="flag-outline" size={18} color="#888" />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Overlays */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.overlay,
|
||||
{ opacity: rightOverlayOpacity, backgroundColor: "rgba(76, 175, 80, 0.4)" },
|
||||
]}
|
||||
>
|
||||
<Text style={styles.overlayText}>YES</Text>
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.overlay,
|
||||
{ opacity: leftOverlayOpacity, backgroundColor: "rgba(244, 67, 54, 0.4)" },
|
||||
]}
|
||||
>
|
||||
<Text style={styles.overlayText}>NO</Text>
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.overlay,
|
||||
{ opacity: upOverlayOpacity, backgroundColor: "rgba(255, 152, 0, 0.4)" },
|
||||
]}
|
||||
>
|
||||
<Text style={styles.overlayText}>MAYBE</Text>
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.overlay,
|
||||
{ opacity: downOverlayOpacity, backgroundColor: "rgba(108, 117, 125, 0.4)" },
|
||||
]}
|
||||
>
|
||||
<Text style={styles.overlayText}>SKIP</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" />
|
||||
))}
|
||||
</View>
|
||||
<Text style={[styles.text, question.partner_text ? styles.twoPartText : null]}>{question.text}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<View style={styles.buttonContainer}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.actionButton,
|
||||
styles.noButton,
|
||||
hoveredButton === "no" && styles.buttonHovered,
|
||||
]}
|
||||
onPress={() => onSwipe("left")}
|
||||
// @ts-expect-error - web-only props
|
||||
onMouseEnter={() => setHoveredButton("no")}
|
||||
onMouseLeave={() => setHoveredButton(null)}
|
||||
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,
|
||||
hoveredButton === "maybe" && styles.buttonHovered,
|
||||
]}
|
||||
onPress={() => onSwipe("up")}
|
||||
// @ts-expect-error - web-only props
|
||||
onMouseEnter={() => setHoveredButton("maybe")}
|
||||
onMouseLeave={() => setHoveredButton(null)}
|
||||
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,
|
||||
hoveredButton === "yes" && styles.buttonHovered,
|
||||
]}
|
||||
onPress={() => onSwipe("right")}
|
||||
// @ts-expect-error - web-only props
|
||||
onMouseEnter={() => setHoveredButton("yes")}
|
||||
onMouseLeave={() => setHoveredButton(null)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.buttonInnerGlow} />
|
||||
<Ionicons name="thumbs-up" size={24} color="#fff" style={styles.buttonIcon} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
<QuestionFeedbackModal
|
||||
visible={showFeedbackModal}
|
||||
onClose={() => setShowFeedbackModal(false)}
|
||||
questionId={question.id}
|
||||
questionText={question.text}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Separate web-only styles that TypeScript doesn't recognize
|
||||
const webStyles = {
|
||||
card: { cursor: "grab" as const },
|
||||
actionButton: { transition: "transform 0.2s ease, box-shadow 0.2s ease" },
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
position: "absolute",
|
||||
height: 500,
|
||||
maxWidth: MAX_CARD_WIDTH,
|
||||
backgroundColor: "#16213e",
|
||||
borderRadius: 24,
|
||||
borderWidth: 1,
|
||||
borderColor: "#0f3460",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 4,
|
||||
},
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 4.65,
|
||||
elevation: 8,
|
||||
overflow: "hidden",
|
||||
},
|
||||
feedbackButton: {
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
right: 12,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 5,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: 32,
|
||||
},
|
||||
intensityContainer: {
|
||||
flexDirection: "row",
|
||||
marginBottom: 24,
|
||||
backgroundColor: "rgba(233, 69, 96, 0.1)",
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 12,
|
||||
},
|
||||
text: {
|
||||
fontSize: 24,
|
||||
fontWeight: "bold",
|
||||
color: "#fff",
|
||||
textAlign: "center",
|
||||
lineHeight: 32,
|
||||
},
|
||||
twoPartText: {
|
||||
fontSize: 20,
|
||||
lineHeight: 28,
|
||||
},
|
||||
footer: {
|
||||
paddingTop: 8,
|
||||
paddingBottom: 16,
|
||||
paddingHorizontal: 8,
|
||||
alignItems: "center",
|
||||
backgroundColor: "rgba(0,0,0,0.2)",
|
||||
},
|
||||
hint: {
|
||||
color: "#666",
|
||||
fontSize: 12,
|
||||
marginBottom: 4,
|
||||
},
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
zIndex: 10,
|
||||
},
|
||||
overlayText: {
|
||||
fontSize: 48,
|
||||
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",
|
||||
},
|
||||
buttonContainer: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 32,
|
||||
paddingVertical: 16,
|
||||
gap: 20,
|
||||
},
|
||||
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,
|
||||
overflow: "hidden",
|
||||
},
|
||||
noButton: {
|
||||
backgroundColor: "#E74C3C",
|
||||
borderColor: "rgba(255, 255, 255, 0.3)",
|
||||
},
|
||||
maybeButton: {
|
||||
backgroundColor: "#F39C12",
|
||||
borderColor: "rgba(255, 255, 255, 0.3)",
|
||||
},
|
||||
yesButton: {
|
||||
backgroundColor: "#2ECC71",
|
||||
borderColor: "rgba(255, 255, 255, 0.3)",
|
||||
},
|
||||
buttonInnerGlow: {
|
||||
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,
|
||||
},
|
||||
buttonHovered: {
|
||||
transform: [{ scale: 1.1 }],
|
||||
shadowOpacity: 0.6,
|
||||
shadowRadius: 12,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,513 @@
|
||||
/**
|
||||
* SwipeCardPremium - PoC of premium/boutique styling for the swipe experience
|
||||
*
|
||||
* Inspired by the "Coming Soon" Ann Summers aesthetic
|
||||
*/
|
||||
import { useState, useEffect } from "react";
|
||||
import { View, Text, StyleSheet, Platform, useWindowDimensions, Pressable } from "react-native";
|
||||
import { Gesture, GestureDetector, TouchableOpacity } from "react-native-gesture-handler";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withSpring,
|
||||
withRepeat,
|
||||
withSequence,
|
||||
runOnJS,
|
||||
interpolate,
|
||||
Extrapolate,
|
||||
withTiming,
|
||||
interpolateColor,
|
||||
Easing,
|
||||
FadeIn,
|
||||
} from "react-native-reanimated";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { colors, gradients, featureColors, radius, shadows, blur, typography, spacing, animations } from "../../theme";
|
||||
|
||||
// Use feature colors for consistency
|
||||
const { accent: ACCENT } = featureColors.dares;
|
||||
const { accent: ROSE } = featureColors.quiz;
|
||||
|
||||
// RGBA helpers for custom opacity levels (derived from theme colors)
|
||||
// Gold: #D4AF37 = rgb(212, 175, 55)
|
||||
// Rose: #E8A4AE = rgb(232, 164, 174)
|
||||
const rgba = {
|
||||
gold: (opacity: number) => `rgba(212, 175, 55, ${opacity})`,
|
||||
rose: (opacity: number) => `rgba(232, 164, 174, ${opacity})`,
|
||||
};
|
||||
|
||||
// Haptics helper
|
||||
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 MAX_CARD_WIDTH = 400;
|
||||
const SWIPE_THRESHOLD = 100;
|
||||
|
||||
interface Props {
|
||||
question: { id: string; text: string; intensity: number; partner_text?: string | null; is_two_part?: boolean };
|
||||
onSwipe: (direction: "left" | "right" | "up" | "down") => void;
|
||||
}
|
||||
|
||||
export default function SwipeCardPremium({ question, onSwipe }: Props) {
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
const cardWidth = Math.min(screenWidth - 48, MAX_CARD_WIDTH);
|
||||
|
||||
const translateX = useSharedValue(0);
|
||||
const translateY = useSharedValue(0);
|
||||
const rotation = useSharedValue(0);
|
||||
const scale = useSharedValue(1);
|
||||
const shadowExpand = useSharedValue(0);
|
||||
const idleBreathing = useSharedValue(0);
|
||||
const isGesturing = useSharedValue(false);
|
||||
|
||||
const context = useSharedValue({ x: 0, y: 0 });
|
||||
|
||||
// Subtle idle breathing animation
|
||||
useEffect(() => {
|
||||
idleBreathing.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(1, { duration: 3000, easing: Easing.inOut(Easing.sin) }),
|
||||
withTiming(0, { duration: 3000, easing: Easing.inOut(Easing.sin) })
|
||||
),
|
||||
-1,
|
||||
true
|
||||
);
|
||||
}, []);
|
||||
|
||||
const gesture = Gesture.Pan()
|
||||
.activeOffsetX([-15, 15])
|
||||
.activeOffsetY([-15, 15])
|
||||
.onStart(() => {
|
||||
context.value = { x: translateX.value, y: translateY.value };
|
||||
scale.value = withTiming(1.02, { duration: animations.timing.fast });
|
||||
shadowExpand.value = withTiming(1, { duration: animations.timing.fast });
|
||||
isGesturing.value = true;
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
translateX.value = event.translationX + context.value.x;
|
||||
translateY.value = event.translationY + context.value.y;
|
||||
rotation.value = interpolate(
|
||||
translateX.value,
|
||||
[-cardWidth / 2, cardWidth / 2],
|
||||
[-8, 8], // Reduced rotation for elegance
|
||||
Extrapolate.CLAMP
|
||||
);
|
||||
})
|
||||
.onEnd(() => {
|
||||
scale.value = withTiming(1, { duration: animations.timing.fast });
|
||||
shadowExpand.value = withTiming(0, { duration: animations.timing.fast });
|
||||
isGesturing.value = false;
|
||||
|
||||
if (Math.abs(translateX.value) > SWIPE_THRESHOLD) {
|
||||
const direction = translateX.value > 0 ? "right" : "left";
|
||||
runOnJS(triggerHaptic)('medium');
|
||||
translateX.value = withSpring(
|
||||
direction === "right" ? screenWidth * 1.5 : -screenWidth * 1.5,
|
||||
animations.springGentle
|
||||
);
|
||||
runOnJS(onSwipe)(direction);
|
||||
} else if (translateY.value < -SWIPE_THRESHOLD) {
|
||||
runOnJS(triggerHaptic)('medium');
|
||||
translateY.value = withSpring(-screenWidth * 1.5, animations.springGentle);
|
||||
runOnJS(onSwipe)("up");
|
||||
} else if (translateY.value > SWIPE_THRESHOLD) {
|
||||
runOnJS(triggerHaptic)('light');
|
||||
translateY.value = withSpring(screenWidth * 1.5, animations.springGentle);
|
||||
runOnJS(onSwipe)("down");
|
||||
} else {
|
||||
translateX.value = withSpring(0, animations.spring);
|
||||
translateY.value = withSpring(0, animations.spring);
|
||||
rotation.value = withSpring(0, animations.spring);
|
||||
}
|
||||
});
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
const idleOffset = isGesturing.value ? 0 : interpolate(idleBreathing.value, [0, 1], [0, -4]);
|
||||
return {
|
||||
transform: [
|
||||
{ translateX: translateX.value },
|
||||
{ translateY: translateY.value + idleOffset },
|
||||
{ rotate: `${rotation.value}deg` },
|
||||
{ scale: scale.value },
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const cardShadowStyle = useAnimatedStyle(() => ({
|
||||
shadowOpacity: interpolate(shadowExpand.value, [0, 1], [0.2, 0.4]),
|
||||
shadowRadius: interpolate(shadowExpand.value, [0, 1], [20, 32]),
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: interpolate(shadowExpand.value, [0, 1], [10, 16]),
|
||||
},
|
||||
}));
|
||||
|
||||
const overlayStyle = (direction: "left" | "right" | "up" | "down") =>
|
||||
useAnimatedStyle(() => {
|
||||
let opacity = 0;
|
||||
if (direction === "right") {
|
||||
opacity = interpolate(translateX.value, [0, SWIPE_THRESHOLD], [0, 1]);
|
||||
} else if (direction === "left") {
|
||||
opacity = interpolate(translateX.value, [0, -SWIPE_THRESHOLD], [0, 1]);
|
||||
} else if (direction === "up") {
|
||||
opacity = interpolate(translateY.value, [0, -SWIPE_THRESHOLD], [0, 1]);
|
||||
} else {
|
||||
opacity = interpolate(translateY.value, [0, SWIPE_THRESHOLD], [0, 1]);
|
||||
}
|
||||
return { opacity };
|
||||
});
|
||||
|
||||
const handleButtonPress = async (direction: "left" | "right" | "up") => {
|
||||
await triggerHaptic('light');
|
||||
onSwipe(direction);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.cardWrapper, { width: cardWidth }]}>
|
||||
<GestureDetector gesture={gesture}>
|
||||
<Animated.View style={[styles.cardOuter, animatedStyle, cardShadowStyle, { shadowColor: ACCENT }]}>
|
||||
{/* Premium dark background */}
|
||||
<View style={styles.cardBackground}>
|
||||
{/* Subtle gradient overlay */}
|
||||
<LinearGradient
|
||||
colors={['rgba(22, 33, 62, 0.95)', 'rgba(13, 13, 26, 0.98)']}
|
||||
style={StyleSheet.absoluteFill}
|
||||
start={{ x: 0.5, y: 0 }}
|
||||
end={{ x: 0.5, y: 1 }}
|
||||
/>
|
||||
|
||||
{/* Top silk highlight */}
|
||||
<LinearGradient
|
||||
colors={[rgba.gold(0.08), 'transparent']}
|
||||
style={styles.silkHighlight}
|
||||
start={{ x: 0.5, y: 0 }}
|
||||
end={{ x: 0.5, y: 1 }}
|
||||
/>
|
||||
|
||||
{/* Swipe Overlays - Premium style */}
|
||||
<Animated.View style={[styles.overlay, styles.overlayYes, overlayStyle("right")]} pointerEvents="none">
|
||||
<View style={styles.overlayBadgePremium}>
|
||||
<Text style={styles.overlayTextPremium}>YES</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
<Animated.View style={[styles.overlay, styles.overlayNo, overlayStyle("left")]} pointerEvents="none">
|
||||
<View style={[styles.overlayBadgePremium, styles.overlayBadgeNo]}>
|
||||
<Text style={[styles.overlayTextPremium, styles.overlayTextNo]}>NO</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
<Animated.View style={[styles.overlay, styles.overlayMaybe, overlayStyle("up")]} pointerEvents="none">
|
||||
<View style={[styles.overlayBadgePremium, styles.overlayBadgeMaybe]}>
|
||||
<Text style={[styles.overlayTextPremium, styles.overlayTextMaybe]}>MAYBE</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
<Animated.View style={[styles.overlay, styles.overlaySkip, overlayStyle("down")]} pointerEvents="none">
|
||||
<View style={[styles.overlayBadgePremium, styles.overlayBadgeSkip]}>
|
||||
<Text style={[styles.overlayTextPremium, styles.overlayTextSkip]}>SKIP</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/* Content */}
|
||||
<View style={styles.content}>
|
||||
{/* Intensity badge - more prominent */}
|
||||
<View style={styles.intensityBadge}>
|
||||
<Text style={styles.intensityLabel}>INTENSITY</Text>
|
||||
<View style={styles.intensityRow}>
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Ionicons
|
||||
key={i}
|
||||
name={i < question.intensity ? "flame" : "flame-outline"}
|
||||
size={18}
|
||||
color={i < question.intensity ? ACCENT : 'rgba(255, 255, 255, 0.2)'}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Decorative separator */}
|
||||
<View style={styles.separator}>
|
||||
<View style={styles.separatorLine} />
|
||||
<View style={styles.separatorDiamond} />
|
||||
<View style={styles.separatorLine} />
|
||||
</View>
|
||||
|
||||
{/* Question text */}
|
||||
<Text style={styles.questionText}>
|
||||
{question.text}
|
||||
</Text>
|
||||
|
||||
{/* Bottom separator */}
|
||||
<View style={styles.separator}>
|
||||
<View style={styles.separatorLine} />
|
||||
<View style={styles.separatorDiamond} />
|
||||
<View style={styles.separatorLine} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Premium Action Buttons */}
|
||||
<View style={styles.footer}>
|
||||
<View style={styles.buttonContainer}>
|
||||
<PremiumButton
|
||||
onPress={() => handleButtonPress("left")}
|
||||
icon="close"
|
||||
variant="no"
|
||||
/>
|
||||
<PremiumButton
|
||||
onPress={() => handleButtonPress("up")}
|
||||
icon="help"
|
||||
variant="maybe"
|
||||
small
|
||||
/>
|
||||
<PremiumButton
|
||||
onPress={() => handleButtonPress("right")}
|
||||
icon="heart"
|
||||
variant="yes"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Premium border */}
|
||||
<View style={styles.premiumBorder} pointerEvents="none" />
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function PremiumButton({
|
||||
onPress,
|
||||
icon,
|
||||
variant,
|
||||
small = false,
|
||||
}: {
|
||||
onPress: () => void;
|
||||
icon: string;
|
||||
variant: 'yes' | 'no' | 'maybe';
|
||||
small?: boolean;
|
||||
}) {
|
||||
const buttonScale = useSharedValue(1);
|
||||
|
||||
const handlePressIn = () => {
|
||||
buttonScale.value = withSpring(0.92, animations.spring);
|
||||
};
|
||||
|
||||
const handlePressOut = () => {
|
||||
buttonScale.value = withSpring(1, animations.spring);
|
||||
};
|
||||
|
||||
const buttonAnimatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: buttonScale.value }],
|
||||
}));
|
||||
|
||||
const getColors = () => {
|
||||
switch (variant) {
|
||||
case 'yes': return { bg: rgba.gold(0.15), border: rgba.gold(0.3), icon: ACCENT };
|
||||
case 'no': return { bg: rgba.rose(0.15), border: rgba.rose(0.3), icon: ROSE };
|
||||
case 'maybe': return { bg: 'rgba(255, 255, 255, 0.08)', border: 'rgba(255, 255, 255, 0.15)', icon: colors.textSecondary };
|
||||
}
|
||||
};
|
||||
|
||||
const c = getColors();
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
activeOpacity={1}
|
||||
>
|
||||
<Animated.View style={[
|
||||
styles.premiumButton,
|
||||
small && styles.premiumButtonSmall,
|
||||
{ backgroundColor: c.bg, borderColor: c.border },
|
||||
buttonAnimatedStyle,
|
||||
]}>
|
||||
<Ionicons
|
||||
name={icon as any}
|
||||
size={small ? 18 : 24}
|
||||
color={c.icon}
|
||||
/>
|
||||
</Animated.View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
cardWrapper: {
|
||||
position: "absolute",
|
||||
maxWidth: MAX_CARD_WIDTH,
|
||||
height: 500,
|
||||
},
|
||||
cardOuter: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
borderRadius: radius.xl,
|
||||
overflow: "hidden",
|
||||
},
|
||||
cardBackground: {
|
||||
flex: 1,
|
||||
backgroundColor: '#0d0d1a',
|
||||
borderRadius: radius.xl,
|
||||
overflow: "hidden",
|
||||
},
|
||||
silkHighlight: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 120,
|
||||
},
|
||||
premiumBorder: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
borderRadius: radius.xl,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(212, 175, 55, 0.2)', // gold @ 0.2
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: spacing.xl,
|
||||
},
|
||||
intensityBadge: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(212, 175, 55, 0.08)', // gold @ 0.08
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(212, 175, 55, 0.15)', // gold @ 0.15
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
intensityLabel: {
|
||||
...typography.caption2,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 2,
|
||||
color: 'rgba(212, 175, 55, 0.7)', // gold @ 0.7
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
intensityRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 4,
|
||||
},
|
||||
separator: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginVertical: spacing.lg,
|
||||
width: 120,
|
||||
},
|
||||
separatorLine: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
backgroundColor: 'rgba(212, 175, 55, 0.2)', // gold @ 0.2
|
||||
},
|
||||
separatorDiamond: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
backgroundColor: ACCENT,
|
||||
transform: [{ rotate: '45deg' }],
|
||||
marginHorizontal: spacing.md,
|
||||
opacity: 0.5,
|
||||
},
|
||||
questionText: {
|
||||
...typography.title2,
|
||||
color: colors.text,
|
||||
textAlign: "center",
|
||||
lineHeight: 32,
|
||||
paddingHorizontal: spacing.sm,
|
||||
},
|
||||
footer: {
|
||||
paddingBottom: spacing.xl,
|
||||
paddingHorizontal: spacing.lg,
|
||||
},
|
||||
buttonContainer: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: spacing.lg,
|
||||
},
|
||||
premiumButton: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
borderWidth: 1,
|
||||
},
|
||||
premiumButtonSmall: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
},
|
||||
// Overlays
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
zIndex: 10,
|
||||
borderRadius: radius.xl,
|
||||
},
|
||||
overlayYes: {
|
||||
backgroundColor: 'rgba(212, 175, 55, 0.15)', // gold @ 0.15
|
||||
},
|
||||
overlayNo: {
|
||||
backgroundColor: 'rgba(232, 164, 174, 0.15)', // rose @ 0.15
|
||||
},
|
||||
overlayMaybe: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
},
|
||||
overlaySkip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
},
|
||||
overlayBadgePremium: {
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: 'rgba(212, 175, 55, 0.2)', // gold @ 0.2
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(212, 175, 55, 0.4)', // gold @ 0.4
|
||||
transform: [{ rotate: "-12deg" }],
|
||||
},
|
||||
overlayBadgeNo: {
|
||||
backgroundColor: 'rgba(232, 164, 174, 0.2)', // rose @ 0.2
|
||||
borderColor: 'rgba(232, 164, 174, 0.4)', // rose @ 0.4
|
||||
},
|
||||
overlayBadgeMaybe: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
},
|
||||
overlayBadgeSkip: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
borderColor: 'rgba(255, 255, 255, 0.15)',
|
||||
},
|
||||
overlayTextPremium: {
|
||||
fontSize: 28,
|
||||
fontWeight: "600",
|
||||
letterSpacing: 4,
|
||||
color: ACCENT,
|
||||
},
|
||||
overlayTextNo: {
|
||||
color: ROSE,
|
||||
},
|
||||
overlayTextMaybe: {
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
overlayTextSkip: {
|
||||
color: colors.textTertiary,
|
||||
fontSize: 24,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as SwipeCard } from './SwipeCard';
|
||||
export { default as SwipeCardPremium } from './SwipeCardPremium';
|
||||
Reference in new issue
Block a user