/** * 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; partner_answered?: boolean }; onSwipe: (direction: "left" | "right" | "up" | "down") => void; onReport?: () => void; } export default function SwipeCardPremium({ question, onSwipe, onReport }: 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 ( {/* Report Button - outside GestureDetector to receive touches */} {onReport && ( )} {/* Premium dark background */} {/* Subtle gradient overlay */} {/* Top silk highlight */} {/* Swipe Overlays - Premium style */} YES NO MAYBE SKIP {/* Content */} {/* Intensity badge - more prominent */} INTENSITY {[...Array(5)].map((_, i) => ( ))} {/* Decorative separator */} {/* Question text */} {question.text} {/* Partner's version of the question - only show when user is answering first */} {question.partner_text && !question.partner_answered && ( PARTNER SEES {question.partner_text} )} {/* Bottom separator */} {/* Premium Action Buttons */} handleButtonPress("left")} icon="close" variant="no" /> handleButtonPress("up")} icon="help" variant="maybe" small /> handleButtonPress("right")} icon="heart" variant="yes" /> {/* Premium border */} ); } 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 ( ); } 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, }, reportButton: { position: 'absolute', top: spacing.md, right: spacing.md, zIndex: 100, padding: spacing.sm, opacity: 0.6, }, 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, }, partnerTextContainer: { marginTop: spacing.lg, width: '100%', paddingHorizontal: spacing.sm, }, partnerDivider: { flexDirection: 'row', alignItems: 'center', marginBottom: spacing.sm, }, partnerDividerLine: { flex: 1, height: 1, backgroundColor: rgba.rose(0.2), }, partnerLabelBadge: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: spacing.md, paddingVertical: spacing.xs, backgroundColor: rgba.rose(0.1), borderRadius: radius.full, borderWidth: 1, borderColor: rgba.rose(0.2), gap: spacing.xs, }, partnerLabelText: { ...typography.caption2, fontWeight: '600', letterSpacing: 1.5, color: rgba.rose(0.8), }, partnerQuestionText: { ...typography.subhead, color: colors.textSecondary, textAlign: 'center', fontStyle: 'italic', opacity: 0.85, }, 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, }, });