From bb2ce5a6a6b1bc07b024be6e92f9a6f173ca472c Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Sat, 3 Jan 2026 22:34:41 +0000 Subject: [PATCH] Implement infinite scroll for Matches and Answers Add pagination support to stores and update list screens to load data in batches for improved performance with large datasets. --- apps/mobile/app/(app)/matches.tsx | 1202 +++++++++-------- apps/mobile/app/(app)/my-answers.tsx | 904 +++++++------ apps/mobile/app/(app)/swipe.tsx | 51 +- .../src/__tests__/stores/matchStore.test.ts | 1 + .../src/features/profile/ProfileScreen.tsx | 110 +- apps/mobile/src/store/matchStore.ts | 374 ++--- apps/mobile/src/store/responsesStore.ts | 605 +++++---- apps/mobile/src/test/setup.ts | 1 + 8 files changed, 1853 insertions(+), 1395 deletions(-) diff --git a/apps/mobile/app/(app)/matches.tsx b/apps/mobile/app/(app)/matches.tsx index 18c0838..6ec5f4c 100755 --- a/apps/mobile/app/(app)/matches.tsx +++ b/apps/mobile/app/(app)/matches.tsx @@ -1,539 +1,663 @@ -import { View, Text, StyleSheet, FlatList, RefreshControl, TouchableOpacity, ActivityIndicator, Platform, useWindowDimensions } from "react-native"; -import { useMatchStore, useAuthStore } from "../../src/store"; -import { useEffect, useCallback, useState } from "react"; -import { useFocusEffect } from "expo-router"; -import { Ionicons } from "@expo/vector-icons"; -import { useRouter } from "expo-router"; -import { LinearGradient } from "expo-linear-gradient"; -import Animated, { - FadeIn, - FadeInDown, - FadeInRight, - FadeInUp, -} from "react-native-reanimated"; -import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui"; -import { useAmbientOrbAnimation } from "../../src/hooks"; -import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme"; -import { MatchesTutorial } from "../../src/components/tutorials"; -import { hasSeenMatchesTutorial, markMatchesTutorialSeen } from "../../src/lib/matchesTutorialSeen"; - -// Premium color palette -const ACCENT = colors.premium.gold; -const ACCENT_RGBA = 'rgba(212, 175, 55, '; -const ROSE = colors.premium.rose; -const ROSE_RGBA = 'rgba(232, 164, 174, '; - -const MAX_CONTENT_WIDTH = 500; - -export default function MatchesScreen() { - const { matches, fetchMatches, markAllAsSeen, isLoading } = useMatchStore(); - const { user } = useAuthStore(); - const router = useRouter(); - const { width } = useWindowDimensions(); - const isWideScreen = width > MAX_CONTENT_WIDTH; - const [showTutorial, setShowTutorial] = useState(false); - - // Ambient orb breathing animations - const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation(); - - // Check if tutorial should be shown when screen is focused or matches change - useFocusEffect( - useCallback(() => { - const checkTutorial = async () => { - if (matches.length > 0) { - const seen = await hasSeenMatchesTutorial(); - if (!seen) { - setShowTutorial(true); - } - } - }; - checkTutorial(); - }, [matches.length]) - ); - - const handleTutorialComplete = async () => { - await markMatchesTutorialSeen(); - setShowTutorial(false); - }; - - useFocusEffect( - useCallback(() => { - fetchMatches(); - }, []) - ); - - useEffect(() => { - if (matches.length > 0) { - markAllAsSeen(); - } - }, [matches.length]); - - const renderItem = ({ item, index }: { item: any; index: number }) => { - const userResponse = item.responses?.find((r: any) => r.user_id === user?.id); - const partnerResponse = item.responses?.find((r: any) => r.user_id !== user?.id); - - let userText = item.question.text; - let partnerText = item.question.partner_text; - - if (item.question.partner_text && userResponse && partnerResponse) { - const userTime = new Date(userResponse.created_at).getTime(); - const partnerTime = new Date(partnerResponse.created_at).getTime(); - - if (userTime > partnerTime) { - userText = item.question.partner_text; - partnerText = item.question.text; - } - } - - const isYesYes = item.match_type === "yes_yes"; - - return ( - - router.push(`/chat/${item.id}`)} - activeOpacity={0.8} - > - - {/* Subtle gradient background */} - - {/* Top silk highlight */} - - - - {/* Premium icon container */} - - - - - - - {userText} - - {item.question.partner_text && ( - - Partner: {partnerText} - - )} - - - - {isYesYes ? "YES + YES" : "YES + MAYBE"} - - - - {new Date(item.created_at).toLocaleDateString()} - - - - - - {item.unreadCount > 0 && ( - - - - )} - - - - - - - {/* Premium border */} - - - - - ); - }; - - if (!user) { - return ( - - - - - - ); - } - - return ( - - {/* Ambient Orbs - Premium gold/rose */} - - - - - - - - - {/* Premium Header */} - - {/* My Answers button */} - router.push("/(app)/my-answers")} - > - - - - - {/* Premium label */} - DISCOVER - Matches - - {/* Decorative separator */} - - - {/* Count badge */} - - - {matches.length} {matches.length === 1 ? 'MATCH' : 'MATCHES'} - - - - - item.id} - contentContainerStyle={[styles.list, isWideScreen && styles.listWide]} - showsVerticalScrollIndicator={false} - refreshControl={ - - } - ListEmptyComponent={ - - {/* Description */} - - Answer questions together to discover what you both enjoy. Matches appear when you agree! - - - {/* Feature hints */} - - - - Swipe right for yes - - - - Match when you both agree - - - - Chat about your matches - - - - {/* Bottom teaser */} - Your first match is just a swipe away - - router.push("/(app)/swipe")} - style={{ marginTop: spacing.lg }} - > - Start Swiping - - - } - /> - - - {/* Matches Tutorial Overlay */} - {showTutorial && ( - - )} - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - loadingContainer: { - flex: 1, - justifyContent: "center", - alignItems: "center", - }, - // Ambient orbs - 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, - }, - // Premium Header - header: { - paddingTop: 60, - paddingHorizontal: spacing.lg, - paddingBottom: spacing.md, - }, - headerWide: { - alignSelf: 'center', - width: '100%', - maxWidth: MAX_CONTENT_WIDTH, - }, - myAnswersButton: { - position: 'absolute', - top: 60, - right: spacing.md, - zIndex: 10, - width: 40, - height: 40, - borderRadius: 20, - backgroundColor: `${ACCENT_RGBA}0.1)`, - borderWidth: 1, - borderColor: `${ACCENT_RGBA}0.2)`, - justifyContent: 'center', - alignItems: 'center', - }, - headerContent: { - alignItems: 'center', - }, - headerLabel: { - ...typography.caption2, - fontWeight: '600', - letterSpacing: 3, - color: ACCENT, - marginBottom: spacing.xs, - }, - headerTitle: { - ...typography.largeTitle, - color: colors.text, - textAlign: 'center', - }, - countBadgePremium: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: `${ACCENT_RGBA}0.1)`, - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - borderRadius: radius.full, - borderWidth: 1, - borderColor: `${ACCENT_RGBA}0.2)`, - gap: spacing.xs, - }, - countTextPremium: { - ...typography.caption2, - fontWeight: '600', - letterSpacing: 2, - color: ACCENT, - }, - // List - list: { - padding: spacing.lg, - paddingTop: spacing.sm, - paddingBottom: Platform.OS === 'ios' ? 120 : 100, - }, - listWide: { - alignSelf: 'center', - width: '100%', - maxWidth: MAX_CONTENT_WIDTH, - }, - // Premium Match Card - matchCardPremium: { - marginBottom: spacing.sm, - borderRadius: radius.lg, - overflow: 'hidden', - padding: spacing.md, - }, - cardSilkHighlight: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - height: 60, - }, - cardPremiumBorder: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - borderRadius: radius.lg, - borderWidth: 1, - borderColor: `${ACCENT_RGBA}0.15)`, - }, - matchRow: { - flexDirection: "row", - alignItems: "center", - }, - iconContainerPremium: { - width: 44, - height: 44, - borderRadius: 22, - backgroundColor: `${ROSE_RGBA}0.1)`, - borderWidth: 1, - borderColor: `${ROSE_RGBA}0.2)`, - justifyContent: "center", - alignItems: "center", - marginRight: spacing.md, - }, - iconContainerYesYes: { - backgroundColor: `${ACCENT_RGBA}0.1)`, - borderColor: `${ACCENT_RGBA}0.2)`, - }, - content: { - flex: 1, - }, - questionTextPremium: { - ...typography.subhead, - color: colors.text, - fontWeight: "600", - marginBottom: spacing.xs, - lineHeight: 20, - }, - partnerTextPremium: { - ...typography.caption1, - color: colors.textSecondary, - fontStyle: "italic", - marginBottom: spacing.sm, - }, - metaRow: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - }, - tagPremium: { - backgroundColor: `${ROSE_RGBA}0.1)`, - paddingHorizontal: spacing.sm, - paddingVertical: 3, - borderRadius: radius.sm, - borderWidth: 1, - borderColor: `${ROSE_RGBA}0.15)`, - }, - tagPremiumHighlight: { - backgroundColor: `${ACCENT_RGBA}0.1)`, - borderColor: `${ACCENT_RGBA}0.2)`, - }, - tagTextPremium: { - ...typography.caption2, - color: ROSE, - fontWeight: "600", - letterSpacing: 1, - }, - tagTextPremiumHighlight: { - color: ACCENT, - }, - datePremium: { - ...typography.caption2, - color: colors.textTertiary, - fontStyle: 'italic', - }, - rightSection: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm, - marginLeft: spacing.sm, - }, - unreadBadgePremium: { - width: 22, - height: 22, - borderRadius: 11, - backgroundColor: ACCENT, - justifyContent: 'center', - alignItems: 'center', - }, - chevronContainerPremium: { - width: 28, - height: 28, - borderRadius: 14, - backgroundColor: `${ACCENT_RGBA}0.08)`, - borderWidth: 1, - borderColor: `${ACCENT_RGBA}0.15)`, - justifyContent: 'center', - alignItems: 'center', - }, - // Premium Empty State - emptyContent: { - width: '100%', - alignItems: 'center', - paddingHorizontal: spacing.md, - paddingVertical: spacing.xl, - }, - emptyDescription: { - ...typography.body, - color: colors.textSecondary, - textAlign: 'center', - marginBottom: spacing.xl, - paddingHorizontal: spacing.md, - }, - emptyFeatures: { - marginBottom: spacing.xl, - }, - emptyFeatureItem: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - marginBottom: spacing.sm, - }, - emptyFeatureText: { - ...typography.subhead, - color: colors.text, - marginLeft: spacing.sm, - }, - emptyTeaser: { - ...typography.footnote, - fontStyle: 'italic', - color: colors.textTertiary, - textAlign: 'center', - }, -}); +import { View, Text, StyleSheet, FlatList, RefreshControl, TouchableOpacity, ActivityIndicator, Platform, useWindowDimensions } from "react-native"; +import { useMatchStore, useAuthStore } from "../../src/store"; +import { useEffect, useCallback, useState } from "react"; +import { useFocusEffect } from "expo-router"; +import { Ionicons } from "@expo/vector-icons"; +import { useRouter } from "expo-router"; +import { LinearGradient } from "expo-linear-gradient"; +import Animated, { + FadeIn, + FadeInDown, + FadeInRight, + FadeInUp, + useSharedValue, + useAnimatedScrollHandler, + useAnimatedStyle, + interpolate, + Extrapolation, +} from "react-native-reanimated"; +import { BlurView } from "expo-blur"; +import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui"; +import { useAmbientOrbAnimation } from "../../src/hooks"; +import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme"; +import { MatchesTutorial } from "../../src/components/tutorials"; +import { hasSeenMatchesTutorial, markMatchesTutorialSeen } from "../../src/lib/matchesTutorialSeen"; + +// Premium color palette +const ACCENT = colors.premium.gold; +const ACCENT_RGBA = 'rgba(212, 175, 55, '; +const ROSE = colors.premium.rose; +const ROSE_RGBA = 'rgba(232, 164, 174, '; + +const MAX_CONTENT_WIDTH = 500; +const NAV_BAR_HEIGHT = 44; +const STATUS_BAR_HEIGHT = 60; +const HEADER_SCROLL_DISTANCE = 100; + +const AnimatedFlatList = Animated.createAnimatedComponent(FlatList); + +export default function MatchesScreen() { + const { matches, fetchMatches, markAllAsSeen, isLoading, hasMore, isLoadingMore, totalCount } = useMatchStore(); + const { user } = useAuthStore(); + const router = useRouter(); + const { width } = useWindowDimensions(); + const isWideScreen = width > MAX_CONTENT_WIDTH; + const [showTutorial, setShowTutorial] = useState(false); + + const scrollY = useSharedValue(0); + + const scrollHandler = useAnimatedScrollHandler({ + onScroll: (event) => { + scrollY.value = event.contentOffset.y; + }, + }); + + // Animated styles for collapsing header + const heroStyle = useAnimatedStyle(() => { + const opacity = interpolate( + scrollY.value, + [0, HEADER_SCROLL_DISTANCE * 0.7], + [1, 0], + Extrapolation.CLAMP + ); + const scale = interpolate( + scrollY.value, + [0, HEADER_SCROLL_DISTANCE], + [1, 0.95], + Extrapolation.CLAMP + ); + return { opacity, transform: [{ scale }] }; + }); + + const compactHeaderStyle = useAnimatedStyle(() => { + const opacity = interpolate( + scrollY.value, + [HEADER_SCROLL_DISTANCE * 0.5, HEADER_SCROLL_DISTANCE], + [0, 1], + Extrapolation.CLAMP + ); + return { opacity }; + }); + + const navBarBackgroundStyle = useAnimatedStyle(() => { + const opacity = interpolate( + scrollY.value, + [0, HEADER_SCROLL_DISTANCE * 0.8], + [0, 1], + Extrapolation.CLAMP + ); + return { opacity }; + }); + + // Ambient orb breathing animations + const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation(); + + // Check if tutorial should be shown when screen is focused or matches change + useFocusEffect( + useCallback(() => { + const checkTutorial = async () => { + if (matches.length > 0) { + const seen = await hasSeenMatchesTutorial(); + if (!seen) { + setShowTutorial(true); + } + } + }; + checkTutorial(); + }, [matches.length]) + ); + + const handleTutorialComplete = async () => { + await markMatchesTutorialSeen(); + setShowTutorial(false); + }; + + useFocusEffect( + useCallback(() => { + fetchMatches(true); + }, []) + ); + + const handleLoadMore = useCallback(() => { + console.log('[Matches] onEndReached called', { isLoading, isLoadingMore, hasMore, matchesCount: matches.length }); + if (!isLoading && !isLoadingMore && hasMore) { + fetchMatches(false); + } + }, [isLoading, isLoadingMore, hasMore, matches.length]); + + useEffect(() => { + if (matches.length > 0) { + markAllAsSeen(); + } + }, [matches.length]); + + const renderItem = ({ item, index }: { item: any; index: number }) => { + const userResponse = item.responses?.find((r: any) => r.user_id === user?.id); + const partnerResponse = item.responses?.find((r: any) => r.user_id !== user?.id); + + let userText = item.question.text; + let partnerText = item.question.partner_text; + + if (item.question.partner_text && userResponse && partnerResponse) { + const userTime = new Date(userResponse.created_at).getTime(); + const partnerTime = new Date(partnerResponse.created_at).getTime(); + + if (userTime > partnerTime) { + userText = item.question.partner_text; + partnerText = item.question.text; + } + } + + const isYesYes = item.match_type === "yes_yes"; + + return ( + + router.push(`/chat/${item.id}`)} + activeOpacity={0.8} + > + + {/* Subtle gradient background */} + + {/* Top silk highlight */} + + + + {/* Premium icon container */} + + + + + + + {userText} + + {item.question.partner_text && ( + + Partner: {partnerText} + + )} + + + + {isYesYes ? "YES + YES" : "YES + MAYBE"} + + + + {new Date(item.created_at).toLocaleDateString()} + + + + + + {item.unreadCount > 0 && ( + + + + )} + + + + + + + {/* Premium border */} + + + + + ); + }; + + const renderFooter = () => { + if (!isLoadingMore) return null; + return ( + + + + ); + }; + + if (!user) { + return ( + + + + + + ); + } + + return ( + + {/* Ambient Orbs - Premium gold/rose */} + + + + + + + + + {/* Fixed Nav Bar */} + + + {Platform.OS === "ios" ? ( + + ) : ( + + )} + + + + Matches + + + {/* My Answers button moved to Nav Bar */} + router.push({ pathname: "/(app)/my-answers", params: { returnTo: "/(app)/matches" } })} + > + + + + + item.id} + contentContainerStyle={[styles.list, isWideScreen && styles.listWide]} + showsVerticalScrollIndicator={false} + onScroll={scrollHandler} + scrollEventThrottle={16} + refreshControl={ + fetchMatches(true)} + tintColor={colors.primary} + colors={[colors.primary]} + progressViewOffset={STATUS_BAR_HEIGHT + NAV_BAR_HEIGHT} + /> + } + onEndReached={handleLoadMore} + onEndReachedThreshold={2} + ListFooterComponent={renderFooter} + ListHeaderComponent={ + + + {/* Premium label */} + DISCOVER + Matches + + {/* Decorative separator */} + + + {/* Count badge */} + + + {totalCount ?? matches.length} {(totalCount ?? matches.length) === 1 ? 'MATCH' : 'MATCHES'} + + + + } + ListEmptyComponent={ + + {/* Description */} + + Answer questions together to discover what you both enjoy. Matches appear when you agree! + + + {/* Feature hints */} + + + + Swipe right for yes + + + + Match when you both agree + + + + Chat about your matches + + + + {/* Bottom teaser */} + Your first match is just a swipe away + + router.push("/(app)/swipe")} + style={{ marginTop: spacing.lg }} + > + Start Swiping + + + } + /> + + + {/* Matches Tutorial Overlay */} + {showTutorial && ( + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + loadingContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + }, + // Ambient orbs + 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, + }, + // Fixed Nav Bar + navBar: { + position: "absolute", + top: 0, + left: 0, + right: 0, + height: STATUS_BAR_HEIGHT + NAV_BAR_HEIGHT, + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + paddingTop: STATUS_BAR_HEIGHT - 10, + paddingHorizontal: spacing.md, + zIndex: 100, + }, + navBarBackground: { + ...StyleSheet.absoluteFillObject, + borderBottomWidth: 1, + borderBottomColor: 'rgba(212, 175, 55, 0.15)', // Gold tint for matches + overflow: "hidden", + }, + navBarTitle: { + ...typography.headline, + color: colors.text, + textAlign: "center", + }, + navBarButton: { + position: 'absolute', + right: spacing.md, + top: STATUS_BAR_HEIGHT - 5, + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: 'rgba(255, 255, 255, 0.08)', + justifyContent: 'center', + alignItems: 'center', + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.1)', + }, + // Header (Hero) + header: { + paddingTop: STATUS_BAR_HEIGHT + spacing.md, + paddingHorizontal: spacing.lg, + paddingBottom: spacing.lg, + alignItems: 'center', + }, + headerWide: { + alignSelf: 'center', + width: '100%', + maxWidth: MAX_CONTENT_WIDTH, + }, + headerContent: { + alignItems: 'center', + }, + headerLabel: { + ...typography.caption2, + fontWeight: '600', + letterSpacing: 3, + color: ACCENT, + marginBottom: spacing.xs, + }, + headerTitle: { + ...typography.largeTitle, + color: colors.text, + textAlign: 'center', + }, + countBadgePremium: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: `${ACCENT_RGBA}0.1)`, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radius.full, + borderWidth: 1, + borderColor: `${ACCENT_RGBA}0.2)`, + gap: spacing.xs, + }, + countTextPremium: { + ...typography.caption2, + fontWeight: '600', + letterSpacing: 2, + color: ACCENT, + }, + // List + list: { + padding: spacing.lg, + // Remove top padding as header handles it + paddingTop: 0, + paddingBottom: Platform.OS === 'ios' ? 120 : 100, + }, + listWide: { + alignSelf: 'center', + width: '100%', + maxWidth: MAX_CONTENT_WIDTH, + }, + // Premium Match Card + matchCardPremium: { + marginBottom: spacing.sm, + borderRadius: radius.lg, + overflow: 'hidden', + padding: spacing.md, + }, + cardSilkHighlight: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: 60, + }, + cardPremiumBorder: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + borderRadius: radius.lg, + borderWidth: 1, + borderColor: `${ACCENT_RGBA}0.15)`, + }, + matchRow: { + flexDirection: "row", + alignItems: "center", + }, + iconContainerPremium: { + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: `${ROSE_RGBA}0.1)`, + borderWidth: 1, + borderColor: `${ROSE_RGBA}0.2)`, + justifyContent: "center", + alignItems: "center", + marginRight: spacing.md, + }, + iconContainerYesYes: { + backgroundColor: `${ACCENT_RGBA}0.1)`, + borderColor: `${ACCENT_RGBA}0.2)`, + }, + content: { + flex: 1, + }, + questionTextPremium: { + ...typography.subhead, + color: colors.text, + fontWeight: "600", + marginBottom: spacing.xs, + lineHeight: 20, + }, + partnerTextPremium: { + ...typography.caption1, + color: colors.textSecondary, + fontStyle: "italic", + marginBottom: spacing.sm, + }, + metaRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + tagPremium: { + backgroundColor: `${ROSE_RGBA}0.1)`, + paddingHorizontal: spacing.sm, + paddingVertical: 3, + borderRadius: radius.sm, + borderWidth: 1, + borderColor: `${ROSE_RGBA}0.15)`, + }, + tagPremiumHighlight: { + backgroundColor: `${ACCENT_RGBA}0.1)`, + borderColor: `${ACCENT_RGBA}0.2)`, + }, + tagTextPremium: { + ...typography.caption2, + color: ROSE, + fontWeight: "600", + letterSpacing: 1, + }, + tagTextPremiumHighlight: { + color: ACCENT, + }, + datePremium: { + ...typography.caption2, + color: colors.textTertiary, + fontStyle: 'italic', + }, + rightSection: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + marginLeft: spacing.sm, + }, + unreadBadgePremium: { + width: 22, + height: 22, + borderRadius: 11, + backgroundColor: ACCENT, + justifyContent: 'center', + alignItems: 'center', + }, + chevronContainerPremium: { + width: 28, + height: 28, + borderRadius: 14, + backgroundColor: `${ACCENT_RGBA}0.08)`, + borderWidth: 1, + borderColor: `${ACCENT_RGBA}0.15)`, + justifyContent: 'center', + alignItems: 'center', + }, + // Premium Empty State + emptyContent: { + width: '100%', + alignItems: 'center', + paddingHorizontal: spacing.md, + paddingVertical: spacing.xl, + }, + emptyDescription: { + ...typography.body, + color: colors.textSecondary, + textAlign: 'center', + marginBottom: spacing.xl, + paddingHorizontal: spacing.md, + }, + emptyFeatures: { + marginBottom: spacing.xl, + }, + emptyFeatureItem: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + marginBottom: spacing.sm, + }, + emptyFeatureText: { + ...typography.subhead, + color: colors.text, + marginLeft: spacing.sm, + }, + emptyTeaser: { + ...typography.footnote, + fontStyle: 'italic', + color: colors.textTertiary, + textAlign: 'center', + }, + footerLoader: { + paddingVertical: spacing.md, + alignItems: "center", + justifyContent: "center", + }, +}); diff --git a/apps/mobile/app/(app)/my-answers.tsx b/apps/mobile/app/(app)/my-answers.tsx index eed4372..e424e8e 100644 --- a/apps/mobile/app/(app)/my-answers.tsx +++ b/apps/mobile/app/(app)/my-answers.tsx @@ -1,386 +1,518 @@ -import { - View, - Text, - StyleSheet, - SectionList, - RefreshControl, - TouchableOpacity, - ActivityIndicator, - Platform, - useWindowDimensions, -} from "react-native"; -import { useResponsesStore, groupResponses, useAuthStore } from "../../src/store"; -import type { ResponseWithQuestion } from "../../src/store"; -import { useCallback, useState } from "react"; -import { useFocusEffect, useRouter } from "expo-router"; -import { Ionicons } from "@expo/vector-icons"; -import { LinearGradient } from "expo-linear-gradient"; -import Animated, { FadeIn, FadeInUp } from "react-native-reanimated"; -import { GradientBackground, GlassButton, DecorativeSeparator } from "../../src/components/ui"; -import { useAmbientOrbAnimation } from "../../src/hooks"; -import { colors, spacing, typography, radius } from "../../src/theme"; -import { ResponseCard } from "../../src/components/responses/ResponseCard"; -import { EditResponseSheet } from "../../src/components/responses/EditResponseSheet"; - -// Premium color palette -const ACCENT = colors.premium.gold; -const ACCENT_RGBA = "rgba(212, 175, 55, "; -const ROSE = colors.premium.rose; -const ROSE_RGBA = "rgba(232, 164, 174, "; - -const MAX_CONTENT_WIDTH = 500; - -type GroupByOption = "pack" | "date" | "answer"; - -export default function MyAnswersScreen() { - const { responses, isLoading, groupBy, fetchResponses, setGroupBy } = useResponsesStore(); - const { user } = useAuthStore(); - const router = useRouter(); - const { width } = useWindowDimensions(); - const isWideScreen = width > MAX_CONTENT_WIDTH; - - // State for edit sheet - const [selectedResponse, setSelectedResponse] = useState(null); - const [isEditSheetVisible, setIsEditSheetVisible] = useState(false); - - // Ambient orb breathing animations - const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation(); - - // Fetch responses on focus - useFocusEffect( - useCallback(() => { - fetchResponses(); - }, []) - ); - - const sections = groupResponses(responses, groupBy); - - const handleEditPress = (response: ResponseWithQuestion) => { - setSelectedResponse(response); - setIsEditSheetVisible(true); - }; - - const handleEditClose = () => { - setIsEditSheetVisible(false); - setSelectedResponse(null); - }; - - const handleEditSuccess = () => { - // Refetch responses to get updated data - fetchResponses(); - }; - - const renderSectionHeader = ({ section }: { section: { title: string } }) => ( - - {section.title} - - ); - - const renderItem = ({ item, index }: { item: ResponseWithQuestion; index: number }) => ( - handleEditPress(item)} - onChatPress={item.match_id ? () => router.push(`/chat/${item.match_id}`) : undefined} - /> - ); - - const GroupByButton = ({ option, label }: { option: GroupByOption; label: string }) => ( - setGroupBy(option)} - > - - {label} - - - ); - - if (!user) { - return ( - - - - - - ); - } - - return ( - - {/* Ambient Orbs */} - - - - - - - - - {/* Header */} - - {/* Back button */} - router.back()}> - - - - - REVIEW - My Answers - - - {/* Count badge */} - - - - {responses.length} {responses.length === 1 ? "ANSWER" : "ANSWERS"} - - - - - {/* Group by buttons */} - - - - - - - - {isLoading && responses.length === 0 ? ( - - - - ) : ( - item.id} - contentContainerStyle={[styles.list, isWideScreen && styles.listWide]} - showsVerticalScrollIndicator={false} - stickySectionHeadersEnabled={false} - refreshControl={ - - } - ListEmptyComponent={ - - - - - No Answers Yet - - Start swiping on questions to build your answer history. You can - come back here to review and change your answers anytime. - - router.push("/(app)/swipe")} - style={{ marginTop: spacing.lg }} - > - Start Swiping - - - } - /> - )} - - - {/* Edit Response Sheet */} - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - loadingContainer: { - flex: 1, - justifyContent: "center", - alignItems: "center", - }, - // Ambient orbs - 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 - header: { - paddingTop: 60, - paddingHorizontal: spacing.lg, - paddingBottom: spacing.md, - }, - headerWide: { - alignSelf: "center", - width: "100%", - maxWidth: MAX_CONTENT_WIDTH, - }, - backButton: { - position: "absolute", - top: 60, - left: spacing.md, - zIndex: 10, - width: 40, - height: 40, - borderRadius: 20, - backgroundColor: "rgba(255, 255, 255, 0.1)", - justifyContent: "center", - alignItems: "center", - }, - headerContent: { - alignItems: "center", - }, - headerLabel: { - ...typography.caption2, - fontWeight: "600", - letterSpacing: 3, - color: ACCENT, - marginBottom: spacing.xs, - }, - headerTitle: { - ...typography.largeTitle, - color: colors.text, - textAlign: "center", - }, - countBadgePremium: { - flexDirection: "row", - alignItems: "center", - backgroundColor: `${ACCENT_RGBA}0.1)`, - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - borderRadius: radius.full, - borderWidth: 1, - borderColor: `${ACCENT_RGBA}0.2)`, - gap: spacing.xs, - }, - countTextPremium: { - ...typography.caption2, - fontWeight: "600", - letterSpacing: 2, - color: ACCENT, - }, - // Group by buttons - groupByContainer: { - flexDirection: "row", - justifyContent: "center", - gap: spacing.sm, - marginTop: spacing.md, - }, - groupByButton: { - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - borderRadius: radius.full, - backgroundColor: "rgba(255, 255, 255, 0.05)", - borderWidth: 1, - borderColor: "rgba(255, 255, 255, 0.1)", - }, - groupByButtonActive: { - backgroundColor: `${ACCENT_RGBA}0.15)`, - borderColor: `${ACCENT_RGBA}0.3)`, - }, - groupByText: { - ...typography.caption1, - color: colors.textSecondary, - fontWeight: "500", - }, - groupByTextActive: { - color: ACCENT, - fontWeight: "600", - }, - // Section header - sectionHeader: { - paddingVertical: spacing.sm, - paddingHorizontal: spacing.xs, - marginTop: spacing.md, - marginBottom: spacing.xs, - }, - sectionTitle: { - ...typography.title3, - color: colors.text, - }, - // List - list: { - padding: spacing.lg, - paddingTop: spacing.sm, - paddingBottom: Platform.OS === "ios" ? 120 : 100, - }, - listWide: { - alignSelf: "center", - width: "100%", - maxWidth: MAX_CONTENT_WIDTH, - }, - // Empty state - emptyContent: { - width: "100%", - alignItems: "center", - paddingHorizontal: spacing.md, - paddingVertical: spacing.xxl, - }, - emptyIcon: { - width: 80, - height: 80, - borderRadius: 40, - backgroundColor: `${ACCENT_RGBA}0.1)`, - justifyContent: "center", - alignItems: "center", - marginBottom: spacing.lg, - }, - emptyTitle: { - ...typography.title2, - color: colors.text, - marginBottom: spacing.sm, - }, - emptyDescription: { - ...typography.body, - color: colors.textSecondary, - textAlign: "center", - paddingHorizontal: spacing.md, - }, -}); +import { + View, + Text, + StyleSheet, + SectionList, + RefreshControl, + TouchableOpacity, + ActivityIndicator, + Platform, + useWindowDimensions, +} from "react-native"; +import { useResponsesStore, groupResponses, useAuthStore } from "../../src/store"; +import type { ResponseWithQuestion } from "../../src/store"; +import { useCallback, useState, useEffect } from "react"; +import { useFocusEffect, useRouter, useLocalSearchParams } from "expo-router"; +import { Ionicons } from "@expo/vector-icons"; +import { LinearGradient } from "expo-linear-gradient"; +import Animated, { + FadeIn, + FadeInUp, + useSharedValue, + useAnimatedScrollHandler, + useAnimatedStyle, + interpolate, + Extrapolation, +} from "react-native-reanimated"; +import { BlurView } from "expo-blur"; +import { GradientBackground, GlassButton, DecorativeSeparator } from "../../src/components/ui"; +import { useAmbientOrbAnimation } from "../../src/hooks"; +import { colors, spacing, typography, radius } from "../../src/theme"; +import { ResponseCard } from "../../src/components/responses/ResponseCard"; +import { EditResponseSheet } from "../../src/components/responses/EditResponseSheet"; + +// Premium color palette +const ACCENT = colors.premium.gold; +const ACCENT_RGBA = "rgba(212, 175, 55, "; +const ROSE = colors.premium.rose; +const ROSE_RGBA = "rgba(232, 164, 174, "; + +const MAX_CONTENT_WIDTH = 500; +const NAV_BAR_HEIGHT = 44; +const STATUS_BAR_HEIGHT = 60; +const HEADER_SCROLL_DISTANCE = 100; + +const AnimatedSectionList = Animated.createAnimatedComponent(SectionList); + +type GroupByOption = "pack" | "date" | "answer"; + +export default function MyAnswersScreen() { + const { responses, isLoading, isLoadingMore, hasMore, groupBy, fetchResponses, setGroupBy, totalCount } = useResponsesStore(); + const { user } = useAuthStore(); + const router = useRouter(); + const params = useLocalSearchParams(); + const { width } = useWindowDimensions(); + const isWideScreen = width > MAX_CONTENT_WIDTH; + + const handleBack = () => { + const returnTo = (params.returnTo as string) || "/(app)/swipe"; + router.push(returnTo as any); + }; + + // State for edit sheet + const [selectedResponse, setSelectedResponse] = useState(null); + const [isEditSheetVisible, setIsEditSheetVisible] = useState(false); + + // Scroll animation + const scrollY = useSharedValue(0); + const scrollHandler = useAnimatedScrollHandler({ + onScroll: (event) => { + scrollY.value = event.contentOffset.y; + }, + }); + + const heroStyle = useAnimatedStyle(() => { + const opacity = interpolate( + scrollY.value, + [0, HEADER_SCROLL_DISTANCE * 0.7], + [1, 0], + Extrapolation.CLAMP + ); + const scale = interpolate( + scrollY.value, + [0, HEADER_SCROLL_DISTANCE], + [1, 0.95], + Extrapolation.CLAMP + ); + return { opacity, transform: [{ scale }] }; + }); + + const compactHeaderStyle = useAnimatedStyle(() => { + const opacity = interpolate( + scrollY.value, + [HEADER_SCROLL_DISTANCE * 0.5, HEADER_SCROLL_DISTANCE], + [0, 1], + Extrapolation.CLAMP + ); + return { opacity }; + }); + + const navBarBackgroundStyle = useAnimatedStyle(() => { + const opacity = interpolate( + scrollY.value, + [0, HEADER_SCROLL_DISTANCE * 0.8], + [0, 1], + Extrapolation.CLAMP + ); + return { opacity }; + }); + + // Ambient orb breathing animations + const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation(); + + // Fetch responses on focus + useFocusEffect( + useCallback(() => { + fetchResponses(true); + }, []) + ); + + const handleLoadMore = useCallback(() => { + if (!isLoading && !isLoadingMore && hasMore) { + fetchResponses(false); + } + }, [isLoading, isLoadingMore, hasMore]); + + const sections = groupResponses(responses, groupBy); + + const handleEditPress = (response: ResponseWithQuestion) => { + setSelectedResponse(response); + setIsEditSheetVisible(true); + }; + + const handleEditClose = () => { + setIsEditSheetVisible(false); + setSelectedResponse(null); + }; + + const handleEditSuccess = () => { + // Refetch responses to get updated data + fetchResponses(true); + }; + + const renderSectionHeader = ({ section }: { section: any }) => ( + + {section.title} + + ); + + const renderItem = ({ item, index }: { item: any; index: number }) => ( + handleEditPress(item)} + onChatPress={item.match_id ? () => router.push(`/chat/${item.match_id}`) : undefined} + /> + ); + + const renderFooter = () => { + if (!isLoadingMore) return null; + return ( + + + + ); + }; + + const GroupByButton = ({ option, label }: { option: GroupByOption; label: string }) => ( + setGroupBy(option)} + > + + {label} + + + ); + + if (!user) { + return ( + + + + + + ); + } + + return ( + + {/* Ambient Orbs */} + + + + + + + + + {/* Fixed Nav Bar */} + + + {Platform.OS === "ios" ? ( + + ) : ( + + )} + + + + + + + + My Answers + + + + {isLoading && responses.length === 0 ? ( + + + + ) : ( + item.id} + contentContainerStyle={[styles.list, isWideScreen && styles.listWide]} + showsVerticalScrollIndicator={false} + stickySectionHeadersEnabled={false} + onScroll={scrollHandler} + scrollEventThrottle={16} + refreshControl={ + fetchResponses(true)} + tintColor={colors.primary} + colors={[colors.primary]} + progressViewOffset={STATUS_BAR_HEIGHT + NAV_BAR_HEIGHT} + /> + } + onEndReached={handleLoadMore} + onEndReachedThreshold={2} + ListFooterComponent={renderFooter} + ListHeaderComponent={ + + + REVIEW + My Answers + + + {/* Count badge */} + + + + {totalCount ?? responses.length} {(totalCount ?? responses.length) === 1 ? "ANSWER" : "ANSWERS"} + + + + + {/* Group by buttons */} + + + + + + + } + ListEmptyComponent={ + + + + + No Answers Yet + + Start swiping on questions to build your answer history. You can + come back here to review and change your answers anytime. + + router.push("/(app)/swipe")} + style={{ marginTop: spacing.lg }} + > + Start Swiping + + + } + /> + )} + + + {/* Edit Response Sheet */} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + loadingContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + }, + // Ambient orbs + 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, + }, + // Fixed Nav Bar + navBar: { + position: "absolute", + top: 0, + left: 0, + right: 0, + height: STATUS_BAR_HEIGHT + NAV_BAR_HEIGHT, + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + paddingTop: STATUS_BAR_HEIGHT - 10, + paddingHorizontal: spacing.md, + zIndex: 100, + }, + navBarBackground: { + ...StyleSheet.absoluteFillObject, + borderBottomWidth: 1, + borderBottomColor: 'rgba(212, 175, 55, 0.15)', // Gold tint + overflow: "hidden", + }, + navBarBackButton: { + position: "absolute", + top: STATUS_BAR_HEIGHT - 5, + left: spacing.md, + zIndex: 10, + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: "rgba(255, 255, 255, 0.08)", + justifyContent: "center", + alignItems: "center", + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.1)", + }, + navBarTitle: { + ...typography.headline, + color: colors.text, + textAlign: "center", + }, + // Header (Hero) + header: { + paddingTop: STATUS_BAR_HEIGHT + spacing.md, + paddingHorizontal: spacing.lg, + paddingBottom: spacing.md, + alignItems: 'center', + }, + headerWide: { + alignSelf: "center", + width: "100%", + maxWidth: MAX_CONTENT_WIDTH, + }, + headerContent: { + alignItems: "center", + }, + headerLabel: { + ...typography.caption2, + fontWeight: "600", + letterSpacing: 3, + color: ACCENT, + marginBottom: spacing.xs, + }, + headerTitle: { + ...typography.largeTitle, + color: colors.text, + textAlign: "center", + }, + countBadgePremium: { + flexDirection: "row", + alignItems: "center", + backgroundColor: `${ACCENT_RGBA}0.1)`, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radius.full, + borderWidth: 1, + borderColor: `${ACCENT_RGBA}0.2)`, + gap: spacing.xs, + }, + countTextPremium: { + ...typography.caption2, + fontWeight: "600", + letterSpacing: 2, + color: ACCENT, + }, + // Group by buttons + groupByContainer: { + flexDirection: "row", + justifyContent: "center", + gap: spacing.sm, + marginTop: spacing.md, + }, + groupByButton: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radius.full, + backgroundColor: "rgba(255, 255, 255, 0.05)", + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.1)", + }, + groupByButtonActive: { + backgroundColor: `${ACCENT_RGBA}0.15)`, + borderColor: `${ACCENT_RGBA}0.3)`, + }, + groupByText: { + ...typography.caption1, + color: colors.textSecondary, + fontWeight: "500", + }, + groupByTextActive: { + color: ACCENT, + fontWeight: "600", + }, + // Section header + sectionHeader: { + paddingVertical: spacing.sm, + paddingHorizontal: spacing.xs, + marginTop: spacing.md, + marginBottom: spacing.xs, + }, + sectionTitle: { + ...typography.title3, + color: colors.text, + }, + // List + list: { + padding: spacing.lg, + paddingTop: 0, // Header handles top spacing + paddingBottom: Platform.OS === "ios" ? 120 : 100, + }, + listWide: { + alignSelf: "center", + width: "100%", + maxWidth: MAX_CONTENT_WIDTH, + }, + // Empty state + emptyContent: { + width: "100%", + alignItems: "center", + paddingHorizontal: spacing.md, + paddingVertical: spacing.xxl, + }, + emptyIcon: { + width: 80, + height: 80, + borderRadius: 40, + backgroundColor: `${ACCENT_RGBA}0.1)`, + justifyContent: "center", + alignItems: "center", + marginBottom: spacing.lg, + }, + emptyTitle: { + ...typography.title2, + color: colors.text, + marginBottom: spacing.sm, + }, + emptyDescription: { + ...typography.body, + color: colors.textSecondary, + textAlign: "center", + paddingHorizontal: spacing.md, + }, + footerLoader: { + paddingVertical: spacing.md, + alignItems: "center", + justifyContent: "center", + }, +}); diff --git a/apps/mobile/app/(app)/swipe.tsx b/apps/mobile/app/(app)/swipe.tsx index 352e35f..6077a83 100755 --- a/apps/mobile/app/(app)/swipe.tsx +++ b/apps/mobile/app/(app)/swipe.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef, useCallback } from "react"; -import { View, StyleSheet, Text, ActivityIndicator, Platform } from "react-native"; +import { View, StyleSheet, Text, ActivityIndicator, Platform, TouchableOpacity } from "react-native"; import { useLocalSearchParams, router, useFocusEffect } from "expo-router"; import { Ionicons } from "@expo/vector-icons"; import Animated, { @@ -186,15 +186,6 @@ export default function SwipeScreen() { } }, [enabledPackIds]); - // Fisher-Yates shuffle for stable randomization - const shuffleArray = (array: T[]): T[] => { - const shuffled = [...array]; - for (let i = shuffled.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; - } - return shuffled; - }; const fetchQuestions = async () => { // Race protection: increment request ID and track current request @@ -231,26 +222,10 @@ export default function SwipeScreen() { setGapInfo(null); } - // First shuffle to randomize, then stable sort by priority scores - const shuffled = shuffleArray(filtered); - const sorted = shuffled.sort((a: any, b: any) => { - let scoreA = 0; - let scoreB = 0; - - 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; - }); - + // Server provides ordering (daily seeded random with priority) // Only update state if still the current request if (currentRequestId === fetchRequestId.current) { - setQuestions(sorted); + setQuestions(filtered); } } catch (error) { if (currentRequestId === fetchRequestId.current) { @@ -655,6 +630,14 @@ export default function SwipeScreen() { style={styles.hintContainer} > Swipe or tap to answer + + router.push({ pathname: "/my-answers", params: { returnTo: "/(app)/swipe" } })} + style={styles.editLinkButton} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + > + Edit previous answers + {/* Bottom spacing for tab bar */} @@ -799,6 +782,18 @@ const styles = StyleSheet.create({ color: colors.textTertiary, letterSpacing: 0.5, }, + editLinkButton: { + marginTop: spacing.md, + paddingVertical: spacing.xs, + paddingHorizontal: spacing.sm, + }, + editLinkText: { + ...typography.caption2, + color: colors.premium.gold, + textDecorationLine: 'underline', + opacity: 0.8, + letterSpacing: 0.5, + }, bottomSpacer: { height: Platform.OS === 'ios' ? 100 : 80, }, diff --git a/apps/mobile/src/__tests__/stores/matchStore.test.ts b/apps/mobile/src/__tests__/stores/matchStore.test.ts index 19331fe..95678a7 100644 --- a/apps/mobile/src/__tests__/stores/matchStore.test.ts +++ b/apps/mobile/src/__tests__/stores/matchStore.test.ts @@ -10,6 +10,7 @@ function createThenableQuery(result: any) { is: jest.fn(() => query), in: jest.fn(() => query), order: jest.fn(() => query), + range: jest.fn(() => query), then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject), }; return query; diff --git a/apps/mobile/src/features/profile/ProfileScreen.tsx b/apps/mobile/src/features/profile/ProfileScreen.tsx index 7a6ef35..3ba95a2 100644 --- a/apps/mobile/src/features/profile/ProfileScreen.tsx +++ b/apps/mobile/src/features/profile/ProfileScreen.tsx @@ -1,8 +1,16 @@ import React from 'react'; import { View, Text, StyleSheet, ScrollView, TouchableOpacity, useWindowDimensions, Platform, Alert } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; -import Animated, { FadeInDown } from 'react-native-reanimated'; +import Animated, { + FadeInDown, + useSharedValue, + useAnimatedScrollHandler, + useAnimatedStyle, + interpolate, + Extrapolation +} from 'react-native-reanimated'; import { LinearGradient } from 'expo-linear-gradient'; +import { BlurView } from 'expo-blur'; import { GradientBackground, GlassCard } from '../../components/ui'; import { FeedbackModal } from '../../components/feedback'; @@ -22,6 +30,11 @@ import { AppearanceSettings, CoupleStatus, NotificationSettings, PrivacySettings const MAX_CONTENT_WIDTH = 500; const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string]; +const NAV_BAR_HEIGHT = 44; +const STATUS_BAR_HEIGHT = 60; +const HEADER_SCROLL_DISTANCE = 100; + +const AnimatedScrollView = Animated.createAnimatedComponent(ScrollView); export function ProfileScreen() { const { width } = useWindowDimensions(); @@ -56,20 +69,81 @@ export function ProfileScreen() { }); }; + // Scroll animation + const scrollY = useSharedValue(0); + const scrollHandler = useAnimatedScrollHandler({ + onScroll: (event) => { + scrollY.value = event.contentOffset.y; + }, + }); + + const heroStyle = useAnimatedStyle(() => { + const opacity = interpolate( + scrollY.value, + [0, HEADER_SCROLL_DISTANCE * 0.7], + [1, 0], + Extrapolation.CLAMP + ); + const scale = interpolate( + scrollY.value, + [0, HEADER_SCROLL_DISTANCE], + [1, 0.95], + Extrapolation.CLAMP + ); + return { opacity, transform: [{ scale }] }; + }); + + const compactHeaderStyle = useAnimatedStyle(() => { + const opacity = interpolate( + scrollY.value, + [HEADER_SCROLL_DISTANCE * 0.5, HEADER_SCROLL_DISTANCE], + [0, 1], + Extrapolation.CLAMP + ); + return { opacity }; + }); + + const navBarBackgroundStyle = useAnimatedStyle(() => { + const opacity = interpolate( + scrollY.value, + [0, HEADER_SCROLL_DISTANCE * 0.8], + [0, 1], + Extrapolation.CLAMP + ); + return { opacity }; + }); + return ( - + + {Platform.OS === "ios" ? ( + + ) : ( + + )} + + + + Settings + + + + {/* Header title */} YOUR SAUCI Settings @@ -305,7 +379,7 @@ export function ProfileScreen() { - + {/* Modals */} Promise; - markAsSeen: (matchId: string) => Promise; - markAllAsSeen: () => Promise; - addMatch: (match: Match) => void; - updateMatchUnreadCount: (matchId: string, delta: number) => void; - clearMatches: () => void; -} - -export const useMatchStore = create((set, get) => ({ - matches: [], - newMatchesCount: 0, - isLoading: false, - error: null, - - fetchMatches: async () => { - const userId = useAuthStore.getState().user?.id; - const coupleId = useAuthStore.getState().user?.couple_id; - - // Early return if no couple - user isn't paired yet - if (!coupleId) { - set({ matches: [], newMatchesCount: 0, isLoading: false }); - return; - } - - set({ isLoading: true, error: null }); - - try { - const { data: matches, error: matchError } = await supabase - .from("matches") - .select(` - *, - question:questions(*) - `) - .eq("couple_id", coupleId) - .order("created_at", { ascending: false }); - - if (matchError) throw matchError; - if (!matches) { - set({ matches: [], newMatchesCount: 0, isLoading: false }); - return; - } - - // Fetch all responses for these questions to determine who answered first - const questionIds = matches.map(m => m.question_id); - let responses: any[] = []; - if (questionIds.length > 0) { - const { data } = await supabase - .from("responses") - .select("*") - .in("question_id", questionIds); - responses = data ?? []; - } - - // Fetch unread message counts per match - const matchIds = matches.map(m => m.id); - let unreadCounts: Record = {}; - - if (userId && matchIds.length > 0) { - const { data: unreadMessages, error } = await supabase - .from("messages") - .select("match_id") - .in("match_id", matchIds) - .neq("user_id", userId) - .is("read_at", null); - - if (error) { - console.error("Error fetching unread messages:", error); - } - - // Count unread messages per match - unreadMessages?.forEach(msg => { - unreadCounts[msg.match_id] = (unreadCounts[msg.match_id] || 0) + 1; - }); - } - - const data = matches.map(match => ({ - ...match, - responses: responses.filter(r => r.question_id === match.question_id), - unreadCount: unreadCounts[match.id] || 0 - })); - - // Sort: unread messages first, then by most recent - const sortedData = data.sort((a, b) => { - // First priority: matches with unread messages - if (a.unreadCount > 0 && b.unreadCount === 0) return -1; - if (a.unreadCount === 0 && b.unreadCount > 0) return 1; - - // Second priority: most recent first - return new Date(b.created_at).getTime() - new Date(a.created_at).getTime(); - }); - - const newCount = sortedData.filter((m) => m.is_new).length; - set({ matches: sortedData, newMatchesCount: newCount, isLoading: false }); - } catch (err) { - console.error("Error fetching matches:", err); - set({ error: "Failed to load matches", isLoading: false }); - } - }, - - markAsSeen: async (matchId) => { - await supabase - .from("matches") - .update({ is_new: false }) - .eq("id", matchId); - - const matches = get().matches.map((m) => - m.id === matchId ? { ...m, is_new: false } : m - ); - const newCount = matches.filter((m) => m.is_new).length; - set({ matches, newMatchesCount: newCount }); - }, - - markAllAsSeen: async () => { - const newMatches = get().matches.filter((m) => m.is_new); - if (newMatches.length === 0) return; - - const newMatchIds = newMatches.map((m) => m.id); - await supabase - .from("matches") - .update({ is_new: false }) - .in("id", newMatchIds); - - const matches = get().matches.map((m) => - m.is_new ? { ...m, is_new: false } : m - ); - set({ matches, newMatchesCount: 0 }); - }, - - addMatch: (match) => { - set((state) => ({ - matches: [match, ...state.matches], - newMatchesCount: state.newMatchesCount + 1, - })); - }, - - updateMatchUnreadCount: (matchId, delta) => { - set((state) => ({ - matches: state.matches.map((m) => - m.id === matchId - ? { ...m, unreadCount: Math.max(0, (m.unreadCount || 0) + delta) } - : m - ), - })); - }, - - clearMatches: () => { - set({ matches: [], newMatchesCount: 0, isLoading: false, error: null }); - }, -})); +import { create } from "zustand"; +import { supabase } from "../lib/supabase"; +import type { Match } from "@/types"; +import { useAuthStore } from "./authStore"; + +const BATCH_SIZE = 20; + +interface MatchState { + matches: Match[]; + newMatchesCount: number; + totalCount: number | null; + isLoading: boolean; + isLoadingMore: boolean; + page: number; + hasMore: boolean; + error: string | null; + fetchMatches: (refresh?: boolean) => Promise; + markAsSeen: (matchId: string) => Promise; + markAllAsSeen: () => Promise; + addMatch: (match: Match) => void; + updateMatchUnreadCount: (matchId: string, delta: number) => void; + clearMatches: () => void; +} + +export const useMatchStore = create((set, get) => ({ + matches: [], + newMatchesCount: 0, + totalCount: null, + isLoading: false, + isLoadingMore: false, + page: 0, + hasMore: true, + error: null, + + fetchMatches: async (refresh = false) => { + const userId = useAuthStore.getState().user?.id; + const coupleId = useAuthStore.getState().user?.couple_id; + + // Early return if no couple - user isn't paired yet + if (!coupleId) { + set({ matches: [], newMatchesCount: 0, totalCount: 0, isLoading: false }); + return; + } + + const state = get(); + if (state.isLoading || (state.isLoadingMore && !refresh)) return; + + if (refresh) { + set({ isLoading: true, error: null, page: 0, hasMore: true }); + } else { + if (!state.hasMore) return; + set({ isLoadingMore: true }); + } + + try { + const currentPage = refresh ? 0 : state.page; + const from = currentPage * BATCH_SIZE; + const to = from + BATCH_SIZE - 1; + + // Fetch total count on refresh + let totalCount = state.totalCount; + if (refresh) { + const { count, error: countError } = await supabase + .from("matches") + .select("*", { count: "exact", head: true }) + .eq("couple_id", coupleId); + + if (!countError) { + totalCount = count ?? 0; + } + } + + const { data: matches, error: matchError } = await supabase + .from("matches") + .select(` + *, + question:questions(*) + `) + .eq("couple_id", coupleId) + .order("created_at", { ascending: false }) + .range(from, to); + + if (matchError) throw matchError; + if (!matches || matches.length === 0) { + if (refresh) { + set({ matches: [], newMatchesCount: 0, totalCount: totalCount ?? 0, isLoading: false, hasMore: false }); + } else { + set({ isLoadingMore: false, hasMore: false }); + } + return; + } + + // Fetch all responses for these questions to determine who answered first + const questionIds = matches.map(m => m.question_id); + let responses: any[] = []; + if (questionIds.length > 0) { + const { data } = await supabase + .from("responses") + .select("*") + .in("question_id", questionIds); + responses = data ?? []; + } + + // Fetch unread message counts per match + const matchIds = matches.map(m => m.id); + let unreadCounts: Record = {}; + + if (userId && matchIds.length > 0) { + const { data: unreadMessages, error } = await supabase + .from("messages") + .select("match_id") + .in("match_id", matchIds) + .neq("user_id", userId) + .is("read_at", null); + + if (error) { + console.error("Error fetching unread messages:", error); + } + + // Count unread messages per match + unreadMessages?.forEach(msg => { + unreadCounts[msg.match_id] = (unreadCounts[msg.match_id] || 0) + 1; + }); + } + + const data = matches.map(match => ({ + ...match, + responses: responses.filter(r => r.question_id === match.question_id), + unreadCount: unreadCounts[match.id] || 0 + })); + + // Sort: unread messages first, then by most recent + // Note: Since we fetch by created_at desc, and then sort by unread in memory, + // pagination might be slightly inconsistent if unread messages are scattered. + // However, implementing complex sort in Supabase with computed columns is harder. + // For now, we accept that the sort is applied within the fetched batch. + const sortedData = data.sort((a, b) => { + // First priority: matches with unread messages + if (a.unreadCount > 0 && b.unreadCount === 0) return -1; + if (a.unreadCount === 0 && b.unreadCount > 0) return 1; + + // Second priority: most recent first + return new Date(b.created_at).getTime() - new Date(a.created_at).getTime(); + }); + + // Calculate new matches count - this is tricky with pagination + // Ideally we should have a separate query for count, but we'll just count in current batch + const newMatchesInBatch = sortedData.filter((m) => m.is_new).length; + + set((state) => ({ + matches: refresh ? sortedData : [...state.matches, ...sortedData], + newMatchesCount: refresh ? newMatchesInBatch : state.newMatchesCount + newMatchesInBatch, + totalCount: refresh ? totalCount : state.totalCount, + isLoading: false, + isLoadingMore: false, + page: currentPage + 1, + hasMore: matches.length === BATCH_SIZE + })); + } catch (err) { + console.error("Error fetching matches:", err); + set({ error: "Failed to load matches", isLoading: false, isLoadingMore: false }); + } + }, + + markAsSeen: async (matchId) => { + await supabase + .from("matches") + .update({ is_new: false }) + .eq("id", matchId); + + const matches = get().matches.map((m) => + m.id === matchId ? { ...m, is_new: false } : m + ); + const newCount = matches.filter((m) => m.is_new).length; + set({ matches, newMatchesCount: newCount }); + }, + + markAllAsSeen: async () => { + const newMatches = get().matches.filter((m) => m.is_new); + if (newMatches.length === 0) return; + + const newMatchIds = newMatches.map((m) => m.id); + await supabase + .from("matches") + .update({ is_new: false }) + .in("id", newMatchIds); + + const matches = get().matches.map((m) => + m.is_new ? { ...m, is_new: false } : m + ); + set({ matches, newMatchesCount: 0 }); + }, + + addMatch: (match) => { + set((state) => ({ + matches: [match, ...state.matches], + newMatchesCount: state.newMatchesCount + 1, + totalCount: (state.totalCount ?? 0) + 1, + })); + }, + + updateMatchUnreadCount: (matchId, delta) => { + set((state) => ({ + matches: state.matches.map((m) => + m.id === matchId + ? { ...m, unreadCount: Math.max(0, (m.unreadCount || 0) + delta) } + : m + ), + })); + }, + +clearMatches: () => { + set({ matches: [], newMatchesCount: 0, totalCount: null, isLoading: false, error: null, page: 0, hasMore: true, isLoadingMore: false }); + }, +})); diff --git a/apps/mobile/src/store/responsesStore.ts b/apps/mobile/src/store/responsesStore.ts index cb30705..9ee53ea 100644 --- a/apps/mobile/src/store/responsesStore.ts +++ b/apps/mobile/src/store/responsesStore.ts @@ -1,278 +1,327 @@ -import { create } from "zustand"; -import { supabase } from "../lib/supabase"; -import { invokeWithAuthRetry } from "../lib/authErrorHandler"; -import type { Question, QuestionPack, AnswerType } from "@/types"; -import { useAuthStore } from "./authStore"; -import { useMatchStore } from "./matchStore"; - -// Response with joined question and pack data -export interface ResponseWithQuestion { - id: string; - question_id: string; - answer: AnswerType; - created_at: string; - question: Question & { - pack: Pick; - }; - has_match: boolean; - match_id?: string; - partner_answered: boolean; -} - -// Result from update-response edge function -export interface UpdateResponseResult { - success: boolean; - requires_confirmation?: boolean; - match_id?: string; - message_count?: number; - new_match?: Record | null; - match_deleted?: boolean; - match_type_updated?: boolean; - error?: string; -} - -export type GroupByOption = "pack" | "date" | "answer"; - -interface ResponsesState { - responses: ResponseWithQuestion[]; - isLoading: boolean; - groupBy: GroupByOption; - fetchResponses: () => Promise; - updateResponse: ( - questionId: string, - newAnswer: AnswerType, - confirmDelete?: boolean - ) => Promise; - setGroupBy: (groupBy: GroupByOption) => void; - clearResponses: () => void; -} - -export const useResponsesStore = create((set, get) => ({ - responses: [], - isLoading: false, - groupBy: "pack", - - fetchResponses: async () => { - const userId = useAuthStore.getState().user?.id; - const coupleId = useAuthStore.getState().user?.couple_id; - - if (!userId || !coupleId) return; - - set({ isLoading: true }); - - try { - // Fetch all user's responses with questions and packs - const { data: responses, error: responsesError } = await supabase - .from("responses") - .select(` - id, - question_id, - answer, - created_at, - question:questions( - id, - text, - partner_text, - intensity, - pack_id, - pack:question_packs(id, name, icon) - ) - `) - .eq("user_id", userId) - .order("created_at", { ascending: false }); - - if (responsesError) { - console.error("Error fetching responses:", responsesError); - set({ isLoading: false }); - return; - } - - if (!responses) { - set({ responses: [], isLoading: false }); - return; - } - - // Get question IDs for match lookup - const questionIds = responses.map((r) => r.question_id); - - // Fetch matches for these questions - const { data: matches } = await supabase - .from("matches") - .select("id, question_id") - .eq("couple_id", coupleId) - .in("question_id", questionIds); - - // Create a map of question_id -> match_id - const matchMap = new Map(); - matches?.forEach((m) => matchMap.set(m.question_id, m.id)); - - // Fetch partner's responses to check if they've answered - const { data: partnerResponses } = await supabase - .from("responses") - .select("question_id") - .eq("couple_id", coupleId) - .neq("user_id", userId) - .in("question_id", questionIds); - - // Create a set of question IDs that partner has answered - const partnerAnsweredSet = new Set( - partnerResponses?.map((r) => r.question_id) || [] - ); - - // Transform responses with match and partner info - const transformedResponses: ResponseWithQuestion[] = responses - .filter((r) => { - // Supabase returns single relations as objects, but TypeScript may infer arrays - const question = r.question as any; - return question && question.pack; - }) - .map((r) => { - // Cast to any to handle Supabase's nested select typing - const question = r.question as any; - const pack = question.pack as any; - - return { - id: r.id, - question_id: r.question_id, - answer: r.answer as AnswerType, - created_at: r.created_at, - question: { - id: question.id, - text: question.text, - partner_text: question.partner_text, - intensity: question.intensity, - pack_id: question.pack_id, - created_at: question.created_at || "", - pack: { - id: pack.id, - name: pack.name, - icon: pack.icon, - }, - }, - has_match: matchMap.has(r.question_id), - match_id: matchMap.get(r.question_id), - partner_answered: partnerAnsweredSet.has(r.question_id), - }; - }); - - set({ responses: transformedResponses, isLoading: false }); - } catch (error) { - console.error("Error in fetchResponses:", error); - set({ isLoading: false }); - } - }, - - updateResponse: async ( - questionId: string, - newAnswer: AnswerType, - confirmDelete = false - ): Promise => { - const { data, error } = await invokeWithAuthRetry("update-response", { - body: { - question_id: questionId, - new_answer: newAnswer, - confirm_delete_match: confirmDelete, - }, - }); - - if (error) { - console.error("Error updating response:", error); - return { success: false, error: error.message || "Failed to update response" }; - } - - const result = data as UpdateResponseResult; - - // If update was successful (not just requiring confirmation), update local state - if (result.success && !result.requires_confirmation) { - // Update local response state - set((state) => ({ - responses: state.responses.map((r) => { - if (r.question_id === questionId) { - return { - ...r, - answer: newAnswer, - // Update match status based on result - has_match: result.match_deleted - ? false - : result.new_match - ? true - : r.has_match, - match_id: result.match_deleted - ? undefined - : result.new_match - ? (result.new_match.id as string) - : r.match_id, - }; - } - return r; - }), - })); - - // Refresh matches store if match was created, deleted, or type updated - if (result.match_deleted || result.new_match || result.match_type_updated) { - useMatchStore.getState().fetchMatches(); - } - } - - return result; - }, - - setGroupBy: (groupBy: GroupByOption) => { - set({ groupBy }); - }, - - clearResponses: () => { - set({ responses: [], isLoading: false, groupBy: "pack" }); - }, -})); - -// Helper function to group responses -export function groupResponses( - responses: ResponseWithQuestion[], - groupBy: GroupByOption -): { title: string; data: ResponseWithQuestion[] }[] { - if (groupBy === "pack") { - // Group by pack name - const groups = new Map(); - responses.forEach((r) => { - const packName = r.question.pack.name; - if (!groups.has(packName)) { - groups.set(packName, []); - } - groups.get(packName)!.push(r); - }); - return Array.from(groups.entries()).map(([title, data]) => ({ title, data })); - } - - if (groupBy === "answer") { - // Group by answer type - const yesResponses = responses.filter((r) => r.answer === "yes"); - const maybeResponses = responses.filter((r) => r.answer === "maybe"); - const noResponses = responses.filter((r) => r.answer === "no"); - - const sections = []; - if (yesResponses.length > 0) sections.push({ title: "Yes", data: yesResponses }); - if (maybeResponses.length > 0) sections.push({ title: "Maybe", data: maybeResponses }); - if (noResponses.length > 0) sections.push({ title: "No", data: noResponses }); - return sections; - } - - if (groupBy === "date") { - // Group by date (day) - const groups = new Map(); - responses.forEach((r) => { - const date = new Date(r.created_at).toLocaleDateString("en-US", { - year: "numeric", - month: "long", - day: "numeric", - }); - if (!groups.has(date)) { - groups.set(date, []); - } - groups.get(date)!.push(r); - }); - return Array.from(groups.entries()).map(([title, data]) => ({ title, data })); - } - - return [{ title: "All Responses", data: responses }]; -} +import { create } from "zustand"; +import { supabase } from "../lib/supabase"; +import { invokeWithAuthRetry } from "../lib/authErrorHandler"; +import type { Question, QuestionPack, AnswerType } from "@/types"; +import { useAuthStore } from "./authStore"; +import { useMatchStore } from "./matchStore"; + +// Response with joined question and pack data +export interface ResponseWithQuestion { + id: string; + question_id: string; + answer: AnswerType; + created_at: string; + question: Question & { + pack: Pick; + }; + has_match: boolean; + match_id?: string; + partner_answered: boolean; +} + +// Result from update-response edge function +export interface UpdateResponseResult { + success: boolean; + requires_confirmation?: boolean; + match_id?: string; + message_count?: number; + new_match?: Record | null; + match_deleted?: boolean; + match_type_updated?: boolean; + error?: string; +} + +export type GroupByOption = "pack" | "date" | "answer"; + +const BATCH_SIZE = 20; + +interface ResponsesState { + responses: ResponseWithQuestion[]; + isLoading: boolean; + isLoadingMore: boolean; + groupBy: GroupByOption; + hasMore: boolean; + page: number; + totalCount: number | null; + fetchResponses: (refresh?: boolean) => Promise; + updateResponse: ( + questionId: string, + newAnswer: AnswerType, + confirmDelete?: boolean + ) => Promise; + setGroupBy: (groupBy: GroupByOption) => void; + clearResponses: () => void; +} + +export const useResponsesStore = create((set, get) => ({ + responses: [], + isLoading: false, + isLoadingMore: false, + groupBy: "pack", + hasMore: true, + page: 0, + totalCount: null, + + fetchResponses: async (refresh = false) => { + const userId = useAuthStore.getState().user?.id; + const coupleId = useAuthStore.getState().user?.couple_id; + + if (!userId || !coupleId) return; + + const state = get(); + if (state.isLoading || (state.isLoadingMore && !refresh)) return; + + if (refresh) { + set({ isLoading: true, page: 0, hasMore: true }); + } else { + if (!state.hasMore) return; + set({ isLoadingMore: true }); + } + + try { + const currentPage = refresh ? 0 : state.page; + const from = currentPage * BATCH_SIZE; + const to = from + BATCH_SIZE - 1; + + // Fetch total count on refresh + let totalCount = state.totalCount; + if (refresh) { + const { count, error: countError } = await supabase + .from("responses") + .select("*", { count: "exact", head: true }) + .eq("user_id", userId); + + if (!countError) { + totalCount = count ?? 0; + } + } + + // Fetch all user's responses with questions and packs + const { data: responses, error: responsesError } = await supabase + .from("responses") + .select(` + id, + question_id, + answer, + created_at, + question:questions( + id, + text, + partner_text, + intensity, + pack_id, + pack:question_packs(id, name, icon) + ) + `) + .eq("user_id", userId) + .order("created_at", { ascending: false }) + .range(from, to); + + if (responsesError) { + console.error("Error fetching responses:", responsesError); + set({ isLoading: false, isLoadingMore: false }); + return; + } + + if (!responses || responses.length === 0) { + if (refresh) { + set({ responses: [], totalCount: totalCount ?? 0, isLoading: false, hasMore: false }); + } else { + set({ isLoadingMore: false, hasMore: false }); + } + return; + } + + // Get question IDs for match lookup + const questionIds = responses.map((r) => r.question_id); + + // Fetch matches for these questions + const { data: matches } = await supabase + .from("matches") + .select("id, question_id") + .eq("couple_id", coupleId) + .in("question_id", questionIds); + + // Create a map of question_id -> match_id + const matchMap = new Map(); + matches?.forEach((m) => matchMap.set(m.question_id, m.id)); + + // Fetch partner's responses to check if they've answered + const { data: partnerResponses } = await supabase + .from("responses") + .select("question_id") + .eq("couple_id", coupleId) + .neq("user_id", userId) + .in("question_id", questionIds); + + // Create a set of question IDs that partner has answered + const partnerAnsweredSet = new Set( + partnerResponses?.map((r) => r.question_id) || [] + ); + + // Transform responses with match and partner info + const transformedResponses: ResponseWithQuestion[] = responses + .filter((r) => { + // Supabase returns single relations as objects, but TypeScript may infer arrays + const question = r.question as any; + return question && question.pack; + }) + .map((r) => { + // Cast to any to handle Supabase's nested select typing + const question = r.question as any; + const pack = question.pack as any; + + return { + id: r.id, + question_id: r.question_id, + answer: r.answer as AnswerType, + created_at: r.created_at, + question: { + id: question.id, + text: question.text, + partner_text: question.partner_text, + intensity: question.intensity, + pack_id: question.pack_id, + created_at: question.created_at || "", + pack: { + id: pack.id, + name: pack.name, + icon: pack.icon, + }, + }, + has_match: matchMap.has(r.question_id), + match_id: matchMap.get(r.question_id), + partner_answered: partnerAnsweredSet.has(r.question_id), + }; + }); + + set((state) => ({ + responses: refresh + ? transformedResponses + : [...state.responses, ...transformedResponses], + totalCount: refresh ? totalCount : state.totalCount, + isLoading: false, + isLoadingMore: false, + page: currentPage + 1, + hasMore: responses.length === BATCH_SIZE + })); + } catch (error) { + console.error("Error in fetchResponses:", error); + set({ isLoading: false, isLoadingMore: false }); + } + }, + + updateResponse: async ( + questionId: string, + newAnswer: AnswerType, + confirmDelete = false + ): Promise => { + const { data, error } = await invokeWithAuthRetry("update-response", { + body: { + question_id: questionId, + new_answer: newAnswer, + confirm_delete_match: confirmDelete, + }, + }); + + if (error) { + console.error("Error updating response:", error); + return { success: false, error: error.message || "Failed to update response" }; + } + + const result = data as UpdateResponseResult; + + // If update was successful (not just requiring confirmation), update local state + if (result.success && !result.requires_confirmation) { + // Update local response state + set((state) => ({ + responses: state.responses.map((r) => { + if (r.question_id === questionId) { + return { + ...r, + answer: newAnswer, + // Update match status based on result + has_match: result.match_deleted + ? false + : result.new_match + ? true + : r.has_match, + match_id: result.match_deleted + ? undefined + : result.new_match + ? (result.new_match.id as string) + : r.match_id, + }; + } + return r; + }), + })); + + // Refresh matches store if match was created, deleted, or type updated + if (result.match_deleted || result.new_match || result.match_type_updated) { + useMatchStore.getState().fetchMatches(); + } + } + + return result; + }, + + setGroupBy: (groupBy: GroupByOption) => { + set({ groupBy }); + }, + +clearResponses: () => { + set({ responses: [], isLoading: false, groupBy: "pack", page: 0, hasMore: true, isLoadingMore: false, totalCount: null }); + }, +})); + +// Helper function to group responses +export function groupResponses( + responses: ResponseWithQuestion[], + groupBy: GroupByOption +): { title: string; data: ResponseWithQuestion[] }[] { + if (groupBy === "pack") { + // Group by pack name + const groups = new Map(); + responses.forEach((r) => { + const packName = r.question.pack.name; + if (!groups.has(packName)) { + groups.set(packName, []); + } + groups.get(packName)!.push(r); + }); + return Array.from(groups.entries()).map(([title, data]) => ({ title, data })); + } + + if (groupBy === "answer") { + // Group by answer type + const yesResponses = responses.filter((r) => r.answer === "yes"); + const maybeResponses = responses.filter((r) => r.answer === "maybe"); + const noResponses = responses.filter((r) => r.answer === "no"); + + const sections = []; + if (yesResponses.length > 0) sections.push({ title: "Yes", data: yesResponses }); + if (maybeResponses.length > 0) sections.push({ title: "Maybe", data: maybeResponses }); + if (noResponses.length > 0) sections.push({ title: "No", data: noResponses }); + return sections; + } + + if (groupBy === "date") { + // Group by date (day) + const groups = new Map(); + responses.forEach((r) => { + const date = new Date(r.created_at).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); + if (!groups.has(date)) { + groups.set(date, []); + } + groups.get(date)!.push(r); + }); + return Array.from(groups.entries()).map(([title, data]) => ({ title, data })); + } + + return [{ title: "All Responses", data: responses }]; +} diff --git a/apps/mobile/src/test/setup.ts b/apps/mobile/src/test/setup.ts index a7e9639..87a4938 100644 --- a/apps/mobile/src/test/setup.ts +++ b/apps/mobile/src/test/setup.ts @@ -39,6 +39,7 @@ jest.mock('@supabase/supabase-js', () => { is: jest.fn(), in: jest.fn(), order: jest.fn(), + range: jest.fn(), update: jest.fn(), upsert: jest.fn(), insert: jest.fn(),