import { useState, useEffect, useRef } from "react"; import { View, StyleSheet, Text, ActivityIndicator, Platform } from "react-native"; import { useLocalSearchParams, router } from "expo-router"; import { Ionicons } from "@expo/vector-icons"; import Animated, { FadeIn, FadeInUp, useSharedValue, useAnimatedStyle, withRepeat, withTiming, interpolate, Easing, } from "react-native-reanimated"; import { LinearGradient } from "expo-linear-gradient"; import { supabase } from "../../src/lib/supabase"; import { usePacksStore, useAuthStore } from "../../src/store"; import { useAmbientOrbAnimation } from "../../src/hooks"; import { skipQuestion, getSkippedQuestionIds } from "../../src/lib/skippedQuestions"; import { hasSeenSwipeTutorial, markSwipeTutorialSeen } from "../../src/lib/swipeTutorialSeen"; import { invokeWithAuthRetry } from "../../src/lib/authErrorHandler"; // import SwipeCard from "../../src/components/SwipeCard"; import { SwipeCardPremium as SwipeCard } from "../../src/components/swipe"; // PoC: Premium styling import { SwipeTutorial } from "../../src/components/tutorials"; import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui"; import { Events } from "../../src/lib/analytics"; import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme"; export default function SwipeScreen() { const { packId } = useLocalSearchParams(); const [questions, setQuestions] = useState([]); const [isLoading, setIsLoading] = useState(true); const [currentIndex, setCurrentIndex] = useState(0); const [showTutorial, setShowTutorial] = useState(false); const [isBlocked, setIsBlocked] = useState(false); const [gapInfo, setGapInfo] = useState<{ unanswered: number; threshold: number } | null>(null); const { enabledPackIds, fetchPacks } = usePacksStore(); const { partner, couple } = useAuthStore(); const hasTrackedExhausted = useRef(false); // Ambient orb breathing animations const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation(); // Progress bar shimmer animation const shimmerPosition = useSharedValue(-1); useEffect(() => { // Progress bar shimmer sweep - 2.5 second cycle shimmerPosition.value = withRepeat( withTiming(2, { duration: 2500, easing: Easing.linear }), -1, false ); }, []); const shimmerStyle = useAnimatedStyle(() => ({ transform: [ { translateX: interpolate(shimmerPosition.value, [-1, 2], [-60, 220]) }, ], })); useEffect(() => { fetchPacks().then(() => fetchQuestions()); checkTutorial(); hasTrackedExhausted.current = false; // Reset when pack changes }, [packId]); // Check if user is too far ahead of partner (called when questions empty or after swipe) const checkAnswerGap = async (): Promise => { if (!couple || !partner) { setIsBlocked(false); setGapInfo(null); return false; } try { const { data, error } = await supabase.rpc('get_answer_gap_status'); if (error) { console.error('Error checking answer gap:', error); return false; } if (data && data.length > 0) { const result = data[0]; setIsBlocked(result.is_blocked); // Only set gap info if threshold is enabled (> 0) if (result.threshold > 0) { setGapInfo({ unanswered: result.unanswered_by_partner, threshold: result.threshold }); } else { setGapInfo(null); } return result.is_blocked; } } catch (error) { console.error('Failed to check answer gap:', error); } return false; }; // Calculate effective total questions (accounting for gap limit) const effectiveTotal = (() => { if (!gapInfo || gapInfo.threshold === 0) { // No gap limit active, show all questions return questions.length; } // remaining = how many more questions until blocked const remaining = gapInfo.threshold - gapInfo.unanswered; // effective total = current position + remaining (capped by actual questions) return Math.min(questions.length, currentIndex + Math.max(0, remaining)); })(); // Track when all questions are exhausted useEffect(() => { if (questions.length > 0 && currentIndex >= questions.length && !hasTrackedExhausted.current) { hasTrackedExhausted.current = true; Events.allQuestionsExhausted(); } }, [currentIndex, questions.length]); const checkTutorial = async () => { const seen = await hasSeenSwipeTutorial(); if (!seen) { setShowTutorial(true); } }; const handleTutorialComplete = async () => { await markSwipeTutorialSeen(); setShowTutorial(false); }; // Filter out questions from disabled packs immediately when enabledPackIds changes useEffect(() => { if (!packId && enabledPackIds.length > 0 && questions.length > 0) { const enabledSet = new Set(enabledPackIds); setQuestions(prev => { const filtered = prev.filter(q => enabledSet.has(q.pack_id)); // Adjust currentIndex if it's now beyond the filtered list if (currentIndex >= filtered.length && filtered.length > 0) { setCurrentIndex(filtered.length - 1); } return filtered; }); } }, [enabledPackIds]); const fetchQuestions = async () => { try { const [{ data, error }, skippedIds] = await Promise.all([ supabase.rpc("get_recommended_questions", { target_pack_id: packId || null }), getSkippedQuestionIds() ]); if (error) { console.error("Error fetching recommended questions:", error); throw error; } // Filter out recently skipped questions const filtered = (data || []).filter((q: any) => !skippedIds.has(q.id)); // Always check answer gap when user has partner and not viewing specific pack // This gives us the gap info for accurate counter display if (partner && !packId) { await checkAnswerGap(); } else { // Reset blocked state if no partner or viewing specific pack setIsBlocked(false); setGapInfo(null); } const sorted = filtered.sort((a: any, b: any) => { let scoreA = Math.random(); let scoreB = Math.random(); if (a.partner_answered && a.is_two_part) scoreA += 1.5; else if (a.partner_answered) scoreA += 0.7; else if (a.is_two_part) scoreA += 0.4; if (b.partner_answered && b.is_two_part) scoreB += 1.5; else if (b.partner_answered) scoreB += 0.7; else if (b.is_two_part) scoreB += 0.4; return scoreB - scoreA; }); setQuestions(sorted); } catch (error) { console.error(error); } finally { setIsLoading(false); } }; const handleSwipe = async (direction: "left" | "right" | "up" | "down") => { const question = questions[currentIndex]; setCurrentIndex(prev => prev + 1); // Handle skip - don't submit a response, just store it for later if (direction === "down") { await skipQuestion(question.id); Events.questionSkipped(); return; } const answer = direction === "right" ? "yes" : direction === "left" ? "no" : "maybe"; try { const { error } = await invokeWithAuthRetry("submit-response", { body: { question_id: question.id, answer, }, }); if (error) { console.error("Submit response error:", error); } else { Events.questionAnswered(answer, question.pack_id); // Check if we've hit the answer gap threshold await checkAnswerGap(); } } catch (error) { console.error("Failed to submit response", error); } }; if (isLoading) { return ( ); } // Show "pair with partner" prompt when user doesn't have a partner yet if (!partner) { const PAIR_ACCENT = colors.premium.rose; return ( {/* Icon */} {/* Title section */} {couple ? "ALMOST THERE" : "CONNECT"} {couple ? "Waiting" : "Pair Up"} {/* Status badge */} {couple ? "INVITE SENT" : "MADE FOR TWO"} {/* Description */} {couple ? "Share your invite code so they can join you. Once paired, you'll both answer questions and discover what you agree on!" : "Sauci is made for two! Connect with your partner to start answering questions together and discover what you have in common." } {/* Feature hints */} Private and secure Discover hidden desires Chat about your matches {/* Bottom teaser */} {couple ? "Your partner is just a code away" : "Begin your intimate journey together"} router.push("/pairing")} style={{ marginTop: spacing.lg }} > {couple ? "View Invite Code" : "Pair Now"} ); } // Show "waiting for partner" message when user is too far ahead if (isBlocked && gapInfo) { const WAITING_ACCENT = colors.premium.rose; return ( {/* Icon */} {/* Title section */} PATIENCE Waiting {/* Status badge */} {gapInfo.unanswered} UNANSWERED {/* Description */} You're ahead by {gapInfo.unanswered} questions. Give your partner some time to catch up so you can discover matches together. {/* Feature hints */} Matches happen together We'll notify your partner Check back anytime {/* Bottom teaser */} Good things come to those who wait Check Again ); } // Show "no packs enabled" message when not viewing a specific pack and no packs are enabled if (!packId && enabledPackIds.length === 0) { const NO_PACKS_ACCENT = colors.premium.rose; return ( {/* Icon */} {/* Title section */} GET STARTED Choose Packs {/* Status badge */} NO PACKS ENABLED {/* Description */} Select the question packs that interest you and your partner. Each pack explores different aspects of your relationship. {/* Feature hints */} From playful to passionate Safe space to explore New packs added regularly {/* Bottom teaser */} Your journey of discovery awaits router.push("/packs")} style={{ marginTop: spacing.lg }} > Choose Packs ); } if (currentIndex >= questions.length) { const CAUGHT_UP_ACCENT = colors.premium.rose; return ( {/* Icon */} {/* Title section */} COMPLETE All Caught Up {/* Status badge */} YOU'RE AHEAD {/* Description */} You've answered all available questions. New questions are added regularly, or explore different packs. {/* Feature hints */} New questions weekly Try different packs Chat about your matches {/* Bottom teaser */} More ways to connect are on the way Refresh Questions ); } return ( {/* Ambient Orbs - Premium gold/rose */} {/* Header */} {/* Premium label */} EXPLORE {currentIndex + 1} of {effectiveTotal || questions.length} {/* Shimmer sweep */} {/* Card Stack */} {/* Third card (deepest) */} {questions[currentIndex + 2] && ( )} {/* Second card */} {questions[currentIndex + 1] && ( )} {/* Active card */} {/* Hint - Premium styling */} Swipe or tap to answer {/* Bottom spacing for tab bar */} {/* Tutorial Overlay */} {showTutorial && ( )} ); } const styles = StyleSheet.create({ container: { flex: 1, }, centerContainer: { flex: 1, justifyContent: "center", alignItems: "center", }, // Ambient orbs - more visible ambientOrb: { position: 'absolute', width: 300, height: 300, borderRadius: 150, }, orbTopRight: { top: 60, right: -40, }, orbBottomLeft: { bottom: 180, left: -40, }, orbGradient: { width: '100%', height: '100%', borderRadius: 150, }, header: { paddingTop: 60, paddingHorizontal: spacing.lg, paddingBottom: spacing.md, }, progressContainer: { alignItems: "center", }, progressLabel: { ...typography.caption2, fontWeight: '600', letterSpacing: 3, color: colors.premium.gold, marginBottom: spacing.xs, }, progressText: { ...typography.subhead, color: colors.textSecondary, marginBottom: spacing.sm, }, progressBar: { width: 160, height: 6, backgroundColor: colors.glass.background, borderRadius: 3, overflow: "hidden", }, progressBarPremium: { width: 140, height: 4, borderRadius: 2, backgroundColor: 'rgba(212, 175, 55, 0.1)', borderWidth: 1, borderColor: 'rgba(212, 175, 55, 0.15)', }, progressFill: { height: "100%", borderRadius: 4, overflow: "hidden", }, progressFillPremium: { backgroundColor: 'rgba(212, 175, 55, 0.5)', borderRadius: 2, }, progressGradient: { flex: 1, borderRadius: 4, }, progressShimmer: { position: 'absolute', width: 60, height: '100%', top: 0, }, cardContainer: { flex: 1, justifyContent: "center", alignItems: "center", }, // Card stack - Premium styling stackCard: { position: "absolute", width: "85%", height: 500, backgroundColor: '#0d0d1a', borderRadius: radius.xl, borderWidth: 1, borderColor: 'rgba(212, 175, 55, 0.15)', }, stackCardSecond: { transform: [{ scale: 0.95 }, { translateY: 12 }], opacity: 0.4, ...shadows.md, }, stackCardThird: { transform: [{ scale: 0.90 }, { translateY: 24 }], opacity: 0.2, ...shadows.sm, }, hintContainer: { alignItems: "center", paddingBottom: spacing.md, }, hintText: { ...typography.caption1, color: colors.textTertiary, }, hintTextPremium: { ...typography.caption1, fontStyle: 'italic', color: colors.textTertiary, letterSpacing: 0.5, }, bottomSpacer: { height: Platform.OS === 'ios' ? 100 : 80, }, // Premium "waiting for partner" styles waitingContent: { width: '100%', alignItems: 'center', paddingHorizontal: spacing.md, }, waitingIconContainer: { width: 72, height: 72, borderRadius: 36, backgroundColor: 'rgba(232, 164, 174, 0.1)', borderWidth: 1, borderColor: 'rgba(232, 164, 174, 0.2)', justifyContent: 'center', alignItems: 'center', marginBottom: spacing.xl, }, waitingLabel: { ...typography.caption1, fontWeight: '600', letterSpacing: 3, color: colors.premium.rose, textAlign: 'center', marginBottom: spacing.xs, }, waitingTitle: { ...typography.largeTitle, color: colors.text, textAlign: 'center', }, waitingBadge: { backgroundColor: 'rgba(232, 164, 174, 0.1)', paddingHorizontal: spacing.lg, paddingVertical: spacing.sm, borderRadius: radius.full, borderWidth: 1, borderColor: 'rgba(232, 164, 174, 0.2)', marginBottom: spacing.xl, }, waitingBadgeText: { ...typography.caption2, fontWeight: '600', letterSpacing: 2, color: colors.premium.rose, }, waitingDescription: { ...typography.body, color: colors.textSecondary, textAlign: 'center', marginBottom: spacing.xl, paddingHorizontal: spacing.md, }, waitingFeatures: { marginBottom: spacing.xl, }, waitingFeatureItem: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', marginBottom: spacing.sm, }, waitingFeatureText: { ...typography.subhead, color: colors.text, marginLeft: spacing.sm, }, waitingTeaser: { ...typography.footnote, fontStyle: 'italic', color: colors.textTertiary, textAlign: 'center', }, // Caught up specific styles caughtUpIconContainer: { width: 72, height: 72, borderRadius: 36, backgroundColor: 'rgba(232, 164, 174, 0.1)', borderWidth: 1, borderColor: 'rgba(232, 164, 174, 0.2)', justifyContent: 'center', alignItems: 'center', marginBottom: spacing.xl, }, caughtUpLabel: { ...typography.caption1, fontWeight: '600', letterSpacing: 3, color: colors.premium.rose, textAlign: 'center', marginBottom: spacing.xs, }, });