From 69ac0940d3acc74097af998280dbdf496f13b29b Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Sun, 25 Jan 2026 10:14:44 +0000 Subject: [PATCH] Add discovery UI, hide NSFW setting, and pack progress - New discovery components (ContentTile, ContentRow, CircularProgress) - Refactor home screen with compact header and content tiles - Add hide_nsfw profile setting for content filtering - Add color column to categories table - Track pack progress (answered/total questions) - Update related stores and tests Co-Authored-By: Claude Opus 4.5 --- apps/mobile/app/(app)/index.tsx | 809 ++++-------------- apps/mobile/app/(app)/matches.tsx | 395 ++++++++- apps/mobile/app/(app)/onboarding.tsx | 121 ++- apps/mobile/app/(app)/packs/questions.tsx | 1 + apps/mobile/app/(app)/pairing.tsx | 2 +- apps/mobile/app/(app)/swipe.tsx | 365 ++++++-- apps/mobile/ios/Podfile.lock | 6 + .../ios/Sauci.xcodeproj/project.pbxproj | 122 +-- apps/mobile/package.json | 1 + .../src/__tests__/stores/packsStore.test.ts | 16 +- .../src/components/MatchNotificationModal.tsx | 11 + .../components/discovery/CircularProgress.tsx | 67 ++ .../components/discovery/CompactHeader.tsx | 133 +++ .../src/components/discovery/ContentRow.tsx | 128 +++ .../src/components/discovery/ContentTile.tsx | 185 ++++ apps/mobile/src/components/discovery/index.ts | 4 + .../profile/components/PrivacySettings.tsx | 277 +----- .../profile/hooks/useProfileSettings.ts | 114 ++- .../profile/screens/PreferencesScreen.tsx | 11 +- apps/mobile/src/store/index.ts | 1 + apps/mobile/src/store/matchStore.ts | 116 ++- apps/mobile/src/store/packsStore.ts | 98 ++- apps/mobile/src/theme/index.ts | 71 +- apps/mobile/src/types/index.ts | 2 + ...260125000000_add_hide_nsfw_to_profiles.sql | 9 + ...20260125100315_add_color_to_categories.sql | 7 + ...20260125100315_add_color_to_categories.sql | 0 package-lock.json | 148 ++++ packages/shared/src/types/index.ts | 2 + 29 files changed, 2048 insertions(+), 1174 deletions(-) create mode 100644 apps/mobile/src/components/discovery/CircularProgress.tsx create mode 100644 apps/mobile/src/components/discovery/CompactHeader.tsx create mode 100644 apps/mobile/src/components/discovery/ContentRow.tsx create mode 100644 apps/mobile/src/components/discovery/ContentTile.tsx create mode 100644 apps/mobile/src/components/discovery/index.ts create mode 100644 apps/supabase/migrations/20260125000000_add_hide_nsfw_to_profiles.sql create mode 100644 apps/supabase/migrations/20260125100315_add_color_to_categories.sql create mode 100644 apps/supabase/supabase/migrations/20260125100315_add_color_to_categories.sql diff --git a/apps/mobile/app/(app)/index.tsx b/apps/mobile/app/(app)/index.tsx index db84dab..d94dc92 100755 --- a/apps/mobile/app/(app)/index.tsx +++ b/apps/mobile/app/(app)/index.tsx @@ -1,641 +1,200 @@ -import { useEffect, useCallback } from "react"; -import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Platform, useWindowDimensions } from "react-native"; -import Animated, { - FadeInDown, - FadeInRight, - FadeIn, - useSharedValue, - useAnimatedStyle, - withRepeat, - withTiming, - Easing, -} from "react-native-reanimated"; -import { LinearGradient } from "expo-linear-gradient"; -import { useAuthStore, useMatchStore, usePacksStore } from "../../src/store"; -import { Ionicons } from "@expo/vector-icons"; -import { router, useFocusEffect } from "expo-router"; -import { GradientBackground, GlassCard } from "../../src/components/ui"; -import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme"; +import { useCallback, useMemo, useState } from 'react'; +import { ScrollView, StyleSheet, Platform, View, Text, useWindowDimensions } from 'react-native'; +import { useFocusEffect } from 'expo-router'; +import Animated, { FadeInDown } from 'react-native-reanimated'; +import { Ionicons } from '@expo/vector-icons'; +import { useAuthStore, usePacksStore } from '../../src/store'; +import { GradientBackground } from '../../src/components/ui'; +import { Paywall } from '../../src/components/paywall'; +import { CompactHeader, ContentRow } from '../../src/components/discovery'; +import { colors, spacing, typography, radius } from '../../src/theme'; +import type { QuestionPack, Category } from '../../src/types'; -const MAX_CONTENT_WIDTH = 500; -const ACCENT_COLOR = colors.primary; +const MAX_CONTENT_WIDTH = 600; -export default function HomeScreen() { - const { user, partner, couple } = useAuthStore(); - const { matches, newMatchesCount, totalCount, fetchMatches } = useMatchStore(); - const { enabledPackIds, fetchPacks } = usePacksStore(); - const { width } = useWindowDimensions(); +// Group packs by category +function groupPacksByCategory( + packs: QuestionPack[], + categories: Category[] +): { category: Category; packs: QuestionPack[] }[] { + const categoryMap = new Map(); + const uncategorized: QuestionPack[] = []; - // Refresh data when screen gains focus for freshness - useFocusEffect( - useCallback(() => { - fetchMatches(); - fetchPacks(); - }, []) - ); + // Initialize map with empty arrays for each category + categories.forEach((cat) => { + categoryMap.set(cat.id, []); + }); - const topMatches = matches.slice(0, 3); - const isWideScreen = width > MAX_CONTENT_WIDTH; - const enabledPacksCount = enabledPackIds.length; + // Group packs + packs.forEach((pack) => { + if (pack.category_id && categoryMap.has(pack.category_id)) { + categoryMap.get(pack.category_id)!.push(pack); + } else { + uncategorized.push(pack); + } + }); - // Subtle pulsating animation for the CTA icon - const pulseScale = useSharedValue(1); - const glowOpacity = useSharedValue(0.3); + // Build result array maintaining category sort order + const result: { category: Category; packs: QuestionPack[] }[] = []; - useEffect(() => { - const startPulse = () => { - pulseScale.value = 1; - pulseScale.value = withRepeat( - withTiming(1.1, { duration: 1500, easing: Easing.inOut(Easing.ease) }), - -1, - true - ); - glowOpacity.value = withRepeat( - withTiming(0.6, { duration: 1500, easing: Easing.inOut(Easing.ease) }), - -1, - true - ); - }; - const timeout = setTimeout(startPulse, 100); - return () => clearTimeout(timeout); - }, []); + categories.forEach((cat) => { + const categoryPacks = categoryMap.get(cat.id) || []; + if (categoryPacks.length > 0) { + result.push({ category: cat, packs: categoryPacks }); + } + }); - const pulseAnimatedStyle = useAnimatedStyle(() => ({ - transform: [{ scale: pulseScale.value }], - })); + // Add uncategorized at the end if any + if (uncategorized.length > 0) { + result.push({ + category: { + id: 'uncategorized', + name: 'More', + description: null, + icon: 'ellipsis-horizontal', + color: null, + sort_order: 999, + created_at: '', + is_public: true, + }, + packs: uncategorized, + }); + } - const glowAnimatedStyle = useAnimatedStyle(() => ({ - opacity: glowOpacity.value, - })); + return result; +} - return ( - - MAX_CONTENT_WIDTH; + + // Refresh data when screen gains focus + useFocusEffect( + useCallback(() => { + fetchPacks(); + }, []) + ); + + // Group packs by category + const packsByCategory = useMemo( + () => groupPacksByCategory(packs, categories), + [packs, categories] + ); + + // Check if user has no packs enabled (empty state) + const hasNoPacks = packs.length === 0; + + return ( + + + + {/* Compact Header */} + + + {/* Content Rows by Category */} + {packsByCategory.map((group, index) => ( + setShowPaywall(true)} + /> + ))} + + {/* Empty state when no packs */} + {hasNoPacks && ( + - - {/* Premium Header */} - - {/* Greeting section */} - WELCOME BACK - - {user?.name || "Beautiful"} - + + + + No packs available + + Check back soon for new content + + + )} - {/* Decorative separator */} - - - - - + + + - {/* Partner badge */} - {partner ? ( - - - - Connected with {partner.name || partner.email?.split('@')[0] || 'Partner'} - - - ) : couple ? ( - router.push("/(app)/pairing")} - > - - Waiting for partner - - ) : ( - router.push("/(app)/pairing")} - > - - Connect with your partner - - )} - - - - {/* Stats Row */} - - router.push("/(app)/matches")} - activeOpacity={0.7} - > - 0 && styles.statCardActive]}> - 0 && styles.statIconActive]}> - 0 ? colors.text : ACCENT_COLOR} - /> - - {totalCount ?? matches.length} - Matches - - - - router.push("/(app)/packs")} - activeOpacity={0.7} - > - 0 && styles.statCardActive]}> - 0 && styles.statIconActive]}> - 0 ? colors.text : ACCENT_COLOR} - /> - - {enabledPacksCount} - Packs - - - - router.push("/(app)/matches")} - activeOpacity={0.7} - > - 0 && styles.statCardActive]}> - 0 && styles.statIconActive]}> - 0 ? colors.text : ACCENT_COLOR} - /> - - {newMatchesCount} - New - - - - - {/* Play CTA */} - - router.push("/(app)/swipe")} - activeOpacity={0.9} - > - - {/* Glow effect */} - - - - - EXPLORE - Ready to explore? - - Discover new experiences - - - - - - - - - - - {/* Top Matches (sorted by unread first, then recent) */} - {topMatches.length > 0 && ( - - - - - - - Top Matches - - router.push("/(app)/matches")} - style={styles.seeAllButton} - > - See all - - - - {topMatches.map((match, index) => ( - - router.push(`/chat/${match.id}`)} - activeOpacity={0.7} - > - - - - - - - {(match as any).question?.text || "A new match!"} - - {match.is_new && } - - - - - ))} - - )} - - {/* Tips when no matches */} - {topMatches.length === 0 && ( - - - - - - - Getting Started - - - - - - - - - - Enable question packs - Choose topics to explore together - - - - - - - - - - Swipe on questions - Answer yes, no, or maybe - - - - - - - - - - Discover matches - See where you both agree - - - - - )} - - - - - - ); + {/* Paywall Modal */} + setShowPaywall(false)} + onSuccess={() => { + setShowPaywall(false); + fetchPacks(); // Refresh packs to update lock status + }} + /> + + ); } const styles = StyleSheet.create({ - container: { - flex: 1, - }, - contentContainer: { - flexGrow: 1, - paddingBottom: Platform.OS === 'ios' ? 100 : 80, - }, - contentContainerWide: { - alignItems: 'center', - }, - innerContainer: { - flex: 1, - width: '100%', - }, - innerContainerWide: { - maxWidth: MAX_CONTENT_WIDTH, - }, - header: { - paddingTop: 60, - paddingHorizontal: spacing.lg, - paddingBottom: spacing.xl, - alignItems: 'center', - }, - label: { - ...typography.caption2, - fontWeight: '600', - letterSpacing: 2.5, - color: ACCENT_COLOR, - textAlign: 'center', - marginBottom: spacing.xs, - }, - greeting: { - ...typography.largeTitle, - color: colors.text, - textAlign: 'center', - }, - separator: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - marginVertical: spacing.md, - width: 120, - }, - separatorLine: { - flex: 1, - height: 1, - backgroundColor: 'rgba(233, 69, 96, 0.3)', - }, - separatorDiamond: { - width: 5, - height: 5, - backgroundColor: ACCENT_COLOR, - transform: [{ rotate: '45deg' }], - marginHorizontal: spacing.sm, - opacity: 0.6, - }, - partnerBadge: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: 'rgba(233, 69, 96, 0.1)', - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - borderRadius: radius.full, - borderWidth: 1, - borderColor: 'rgba(233, 69, 96, 0.2)', - gap: spacing.xs, - }, - partnerText: { - ...typography.caption1, - color: colors.text, - fontWeight: '500', - }, - pairBadge: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.glass.background, - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - borderRadius: radius.full, - borderWidth: 1, - borderColor: colors.glass.border, - gap: spacing.xs, - }, - pairText: { - ...typography.caption1, - color: colors.textTertiary, - }, - waitingBadge: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: 'rgba(233, 69, 96, 0.1)', - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - borderRadius: radius.full, - borderWidth: 1, - borderColor: 'rgba(233, 69, 96, 0.2)', - gap: spacing.xs, - }, - waitingText: { - ...typography.caption1, - color: colors.text, - fontWeight: '500', - }, - statsContainer: { - flexDirection: "row", - paddingHorizontal: spacing.lg, - gap: spacing.sm, - }, - statCardWrapper: { - flex: 1, - }, - statCard: { - alignItems: "center", - paddingVertical: spacing.md, - backgroundColor: 'rgba(233, 69, 96, 0.05)', - borderRadius: radius.lg, - borderWidth: 1, - borderColor: 'rgba(233, 69, 96, 0.1)', - }, - statCardActive: { - backgroundColor: 'rgba(233, 69, 96, 0.1)', - borderColor: 'rgba(233, 69, 96, 0.2)', - }, - statIcon: { - width: 36, - height: 36, - borderRadius: 18, - backgroundColor: 'rgba(233, 69, 96, 0.1)', - justifyContent: "center", - alignItems: "center", - marginBottom: spacing.sm, - }, - statIconActive: { - backgroundColor: ACCENT_COLOR, - }, - statNumber: { - ...typography.title2, - color: colors.text, - }, - statLabel: { - ...typography.caption2, - color: colors.textSecondary, - marginTop: 2, - }, - ctaSection: { - paddingHorizontal: spacing.lg, - marginTop: spacing.xl, - }, - ctaCard: { - position: 'relative', - borderRadius: radius.xl, - overflow: 'hidden', - }, - ctaGlow: { - position: 'absolute', - top: -20, - left: -20, - right: -20, - bottom: -20, - backgroundColor: ACCENT_COLOR, - borderRadius: radius.xl + 20, - }, - ctaGradient: { - borderRadius: radius.xl, - padding: spacing.lg, - flexDirection: "row", - alignItems: "center", - ...shadows.lg, - }, - ctaContent: { - flex: 1, - }, - ctaLabel: { - ...typography.caption2, - fontWeight: '600', - letterSpacing: 2, - color: 'rgba(255,255,255,0.8)', - marginBottom: spacing.xs, - }, - ctaTitle: { - ...typography.title2, - color: colors.text, - marginBottom: spacing.xs, - }, - ctaSubtitle: { - ...typography.subhead, - color: "rgba(255,255,255,0.8)", - }, - ctaIconContainer: { - width: 56, - height: 56, - borderRadius: 28, - backgroundColor: "rgba(255,255,255,0.2)", - borderWidth: 1, - borderColor: 'rgba(255,255,255,0.3)', - justifyContent: "center", - alignItems: "center", - marginLeft: spacing.md, - }, - section: { - paddingHorizontal: spacing.lg, - marginTop: spacing.xl, - }, - sectionHeader: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - marginBottom: spacing.md, - }, - sectionTitleRow: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm, - }, - sectionIcon: { - width: 24, - height: 24, - borderRadius: 12, - backgroundColor: 'rgba(233, 69, 96, 0.1)', - justifyContent: 'center', - alignItems: 'center', - }, - sectionTitle: { - ...typography.headline, - color: colors.text, - }, - seeAllButton: { - flexDirection: 'row', - alignItems: 'center', - gap: 2, - }, - seeAllText: { - ...typography.subhead, - color: ACCENT_COLOR, - }, - matchCard: { - marginBottom: spacing.sm, - backgroundColor: 'rgba(233, 69, 96, 0.05)', - borderRadius: radius.md, - borderWidth: 1, - borderColor: 'rgba(233, 69, 96, 0.1)', - padding: spacing.md, - }, - matchRow: { - flexDirection: "row", - alignItems: "center", - }, - matchIcon: { - width: 28, - height: 28, - borderRadius: 14, - justifyContent: "center", - alignItems: "center", - marginRight: spacing.md, - }, - matchText: { - flex: 1, - ...typography.body, - color: colors.text, - }, - newBadge: { - width: 8, - height: 8, - borderRadius: 4, - backgroundColor: ACCENT_COLOR, - }, - tipCard: { - backgroundColor: 'rgba(233, 69, 96, 0.05)', - borderRadius: radius.md, - borderWidth: 1, - borderColor: 'rgba(233, 69, 96, 0.1)', - padding: spacing.md, - }, - tipCardSpaced: { - marginTop: spacing.sm, - }, - tipRow: { - flexDirection: 'row', - alignItems: 'center', - }, - tipIcon: { - width: 36, - height: 36, - borderRadius: 18, - backgroundColor: 'rgba(233, 69, 96, 0.1)', - justifyContent: 'center', - alignItems: 'center', - marginRight: spacing.md, - }, - tipContent: { - flex: 1, - }, - tipText: { - ...typography.body, - color: colors.text, - fontWeight: '500', - }, - tipSubtext: { - ...typography.caption1, - color: colors.textSecondary, - marginTop: 2, - }, - bottomSpacer: { - height: spacing.lg, - }, + container: { + flex: 1, + }, + contentContainer: { + flexGrow: 1, + paddingBottom: Platform.OS === 'ios' ? 100 : 80, + }, + contentContainerWide: { + alignItems: 'center', + }, + innerContainer: { + flex: 1, + width: '100%', + }, + innerContainerWide: { + maxWidth: MAX_CONTENT_WIDTH, + }, + emptyState: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: spacing.xl, + paddingVertical: spacing.xxl, + }, + emptyIcon: { + width: 80, + height: 80, + borderRadius: 40, + backgroundColor: 'rgba(233, 69, 96, 0.1)', + justifyContent: 'center', + alignItems: 'center', + marginBottom: spacing.lg, + }, + emptyTitle: { + ...typography.title3, + color: colors.text, + marginBottom: spacing.sm, + }, + emptySubtitle: { + ...typography.body, + color: colors.textSecondary, + textAlign: 'center', + }, + bottomSpacer: { + height: spacing.lg, + }, }); diff --git a/apps/mobile/app/(app)/matches.tsx b/apps/mobile/app/(app)/matches.tsx index 6faad53..b1c4d9b 100755 --- a/apps/mobile/app/(app)/matches.tsx +++ b/apps/mobile/app/(app)/matches.tsx @@ -1,5 +1,5 @@ import { View, Text, StyleSheet, FlatList, RefreshControl, TouchableOpacity, ActivityIndicator, Platform, useWindowDimensions } from "react-native"; -import { useMatchStore, useAuthStore } from "../../src/store"; +import { useMatchStore, useAuthStore, type PendingQuestion } from "../../src/store"; import { useEffect, useCallback, useState } from "react"; import { useFocusEffect } from "expo-router"; import { Ionicons } from "@expo/vector-icons"; @@ -49,13 +49,17 @@ export default function MatchesScreen() { // Archive state and methods archivedMatches, showArchived, - toggleShowArchived, archiveMatch, unarchiveMatch, isLoadingArchived, - fetchArchivedMatches, + // Pending state and methods + pendingQuestions, + isLoadingPending, + currentView, + setCurrentView, + fetchPendingQuestions, } = useMatchStore(); - const { user } = useAuthStore(); + const { user, couple, partner } = useAuthStore(); const router = useRouter(); const { width } = useWindowDimensions(); const isWideScreen = width > MAX_CONTENT_WIDTH; @@ -132,6 +136,8 @@ export default function MatchesScreen() { useFocusEffect( useCallback(() => { fetchMatches(true); + // Also refresh pending questions when focused + fetchPendingQuestions(); }, []) ); @@ -267,6 +273,96 @@ export default function MatchesScreen() { ); }; + const renderPendingItem = ({ item, index }: { item: PendingQuestion; index: number }) => { + if (!item.question) { + return null; + } + + const packName = item.question.pack?.name ?? 'Unknown Pack'; + const timeSince = getTimeSince(item.partnerAnsweredAt); + + return ( + + router.push({ + pathname: "/(app)/swipe", + params: { mode: 'pending', startQuestionId: item.question.id } + })} + activeOpacity={0.8} + > + + {/* Subtle gradient background - slightly different hue for pending */} + + {/* Top silk highlight */} + + + + {/* Pending icon container - hourglass */} + + + + + + + {item.question.text} + + + + + {packName} + + + + {timeSince} + + + + + + + + + + + + {/* Premium border */} + + + + + ); + }; + + // Helper to get time since partner answered + const getTimeSince = (dateString: string): string => { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffMins < 1) return 'Just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return date.toLocaleDateString(); + }; + const renderFooter = () => { if (!isLoadingMore) return null; return ( @@ -286,6 +382,91 @@ export default function MatchesScreen() { ); } + // Gate matches behind pairing - user must have a partner to view matches + if (!partner) { + return ( + + {/* Ambient Orbs */} + + + + + + + + + + {/* 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 discover your matches here!" + : "Sauci is made for two! Connect with your partner to start discovering what you agree on." + } + + + {/* Feature hints */} + + + + See when you both agree + + + + Unlock hidden connections + + + + Chat about your matches + + + + {/* Bottom teaser */} + {couple ? "Your partner is just a code away" : "Begin your journey together"} + + router.push("/pairing")} + style={{ marginTop: spacing.lg }} + > + {couple ? "View Invite Code" : "Pair Now"} + + + + + ); + } + return ( {/* Ambient Orbs - Premium gold/rose */} @@ -331,8 +512,8 @@ export default function MatchesScreen() { item.id} contentContainerStyle={[styles.list, isWideScreen && styles.listWide]} showsVerticalScrollIndicator={false} @@ -340,16 +521,22 @@ export default function MatchesScreen() { scrollEventThrottle={16} refreshControl={ fetchMatches(true)} + refreshing={currentView === 'pending' ? isLoadingPending : isLoading} + onRefresh={() => { + if (currentView === 'pending') { + fetchPendingQuestions(); + } else { + fetchMatches(true); + } + }} tintColor={colors.primary} colors={[colors.primary]} progressViewOffset={STATUS_BAR_HEIGHT + NAV_BAR_HEIGHT} /> } - onEndReached={handleLoadMore} + onEndReached={currentView === 'pending' ? undefined : handleLoadMore} onEndReachedThreshold={2} - ListFooterComponent={renderFooter} + ListFooterComponent={currentView === 'pending' ? null : renderFooter} ListHeaderComponent={ showArchived && toggleShowArchived()} + style={[styles.filterTab, currentView === 'pending' && styles.filterTabActive]} + onPress={() => currentView !== 'pending' && setCurrentView('pending')} + activeOpacity={0.7} + > + {isLoadingPending ? ( + + ) : ( + <> + + + Your Turn + + {pendingQuestions.length > 0 && currentView !== 'pending' && ( + + {pendingQuestions.length} + + )} + + )} + + currentView !== 'active' && setCurrentView('active')} activeOpacity={0.7} > - - Active + + Complete { - if (!showArchived) { - toggleShowArchived(); - } - }} + style={[styles.filterTab, currentView === 'archived' && styles.filterTabActive]} + onPress={() => currentView !== 'archived' && setCurrentView('archived')} activeOpacity={0.7} > {isLoadingArchived ? ( @@ -395,9 +603,9 @@ export default function MatchesScreen() { - + Archived @@ -407,11 +615,17 @@ export default function MatchesScreen() { {/* Count badge */} - + - {showArchived + {currentView === 'archived' ? `${archivedMatches.length} ARCHIVED` - : `${totalCount ?? matches.length} ${(totalCount ?? matches.length) === 1 ? 'MATCH' : 'MATCHES'}` + : currentView === 'pending' + ? `${pendingQuestions.length} WAITING` + : `${totalCount ?? matches.length} COMPLETE` } @@ -419,7 +633,7 @@ export default function MatchesScreen() { } ListEmptyComponent={ - showArchived ? ( + currentView === 'archived' ? ( toggleShowArchived()} + onPress={() => setCurrentView('active')} style={{ marginTop: spacing.lg }} > View Active Matches + ) : currentView === 'pending' ? ( + + {/* Pending icon */} + + + + + {/* Description */} + All Caught Up! + + You've answered all the questions your partner has swiped on. Keep swiping to discover more together! + + + router.push("/(app)/swipe")} + style={{ marginTop: spacing.lg }} + > + Keep Swiping + + ) : ( { - if (value === 1 || value === 2 || value === 3 || value === 4 || value === 5) return value; - return null; -}; - -const getInitialIntensity = (profile?: { max_intensity?: number | null; show_explicit_content?: boolean | null } | null): IntensityLevel => { - const normalized = normalizeIntensity(profile?.max_intensity); - if (normalized) return normalized; - return profile?.show_explicit_content ? 5 : DEFAULT_MAX_INTENSITY; -}; - -type Stage = 'avatar' | 'name' | 'gender' | 'purpose' | 'explicit' | 'notifications'; +type Stage = 'avatar' | 'name' | 'gender' | 'purpose' | 'content' | 'notifications'; export default function OnboardingScreen() { const router = useRouter(); @@ -73,7 +66,7 @@ export default function OnboardingScreen() { const [name, setName] = useState(user?.name || ''); const [gender, setGender] = useState(user?.gender || null); const [usageReason, setUsageReason] = useState(null); - const [maxIntensity, setMaxIntensity] = useState(() => getInitialIntensity(user)); + const [hideNsfw, setHideNsfw] = useState(user?.hide_nsfw ?? false); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const inputRef = useRef(null); @@ -132,13 +125,13 @@ export default function OnboardingScreen() { setError('Please select what brings you to Sauci'); return; } - setStage('explicit'); + setStage('content'); Events.onboardingStageComplete('purpose'); }; - const handleIntensityContinue = () => { + const handleContentContinue = () => { setStage('notifications'); - Events.onboardingStageComplete('explicit'); + Events.onboardingStageComplete('content'); }; const handleEnableNotifications = async () => { @@ -177,8 +170,10 @@ export default function OnboardingScreen() { } } - const showExplicitContent = maxIntensity >= 3; - const updatedFields: string[] = ["name", "gender", "usage_reason", "max_intensity", "show_explicit_content"]; + // Derive intensity from hide_nsfw for backwards compatibility + const maxIntensity = hideNsfw ? NSFW_OFF_INTENSITY : NSFW_ON_INTENSITY; + const showExplicitContent = !hideNsfw; + const updatedFields: string[] = ["name", "gender", "usage_reason", "max_intensity", "show_explicit_content", "hide_nsfw"]; const updateData: Record = { name: name.trim(), @@ -186,6 +181,7 @@ export default function OnboardingScreen() { usage_reason: usageReason, max_intensity: maxIntensity, show_explicit_content: showExplicitContent, + hide_nsfw: hideNsfw, onboarding_completed: true, updated_at: new Date().toISOString(), }; @@ -476,10 +472,10 @@ export default function OnboardingScreen() { ); - case 'explicit': + case 'content': return ( - + - Set Your Comfort Zone + Content Preferences - Choose the intimacy level you're comfortable with + Choose what type of content you'd like to see - - - Start where you're comfortable — you can always adjust later + + + + + + + Hide Adult Content + + Only show mild, family-friendly question packs + + + + + + + + You can change this anytime in settings + {error && ( @@ -515,7 +529,7 @@ export default function OnboardingScreen() { @@ -613,8 +627,8 @@ export default function OnboardingScreen() { style={styles.backButton} onPress={() => { setError(null); - if (stage === 'notifications') setStage('explicit'); - else if (stage === 'explicit') setStage('purpose'); + if (stage === 'notifications') setStage('content'); + else if (stage === 'content') setStage('purpose'); else if (stage === 'purpose') setStage('gender'); else if (stage === 'gender') setStage('name'); else if (stage === 'name') setStage('avatar'); @@ -630,7 +644,7 @@ export default function OnboardingScreen() { - + @@ -866,11 +880,42 @@ const styles = StyleSheet.create({ color: colors.text, fontWeight: '600', }, - intensityNote: { + contentToggleRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + contentToggleLeft: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + marginRight: spacing.md, + }, + contentToggleIcon: { + width: 48, + height: 48, + borderRadius: 24, + alignItems: 'center', + justifyContent: 'center', + marginRight: spacing.md, + }, + contentToggleText: { + flex: 1, + }, + contentToggleLabel: { + ...typography.headline, + color: colors.text, + }, + contentToggleDescription: { + ...typography.caption1, + color: colors.textSecondary, + marginTop: 2, + }, + contentNote: { ...typography.caption1, color: colors.textSecondary, textAlign: 'center', - marginTop: spacing.md, + marginTop: spacing.lg, }, radioOuter: { width: 24, diff --git a/apps/mobile/app/(app)/packs/questions.tsx b/apps/mobile/app/(app)/packs/questions.tsx index 02758d1..9423143 100644 --- a/apps/mobile/app/(app)/packs/questions.tsx +++ b/apps/mobile/app/(app)/packs/questions.tsx @@ -496,6 +496,7 @@ export default function QuestionPacksScreen() { name: "Other", description: null, icon: "📁", + color: null, sort_order: 999, created_at: "", is_public: true diff --git a/apps/mobile/app/(app)/pairing.tsx b/apps/mobile/app/(app)/pairing.tsx index 52233b7..297e78e 100755 --- a/apps/mobile/app/(app)/pairing.tsx +++ b/apps/mobile/app/(app)/pairing.tsx @@ -145,7 +145,7 @@ export default function PairingScreen() { if (couple?.invite_code) { try { await Share.share({ - message: `Join me on Sauci! Use my invite code to pair up: ${couple.invite_code}`, + message: `Join me on Sauci! Download the app at https://sauci.app and use my invite code to pair up: ${couple.invite_code}`, }); Events.codeShared(); } catch (error) { diff --git a/apps/mobile/app/(app)/swipe.tsx b/apps/mobile/app/(app)/swipe.tsx index 00362d6..450c799 100755 --- a/apps/mobile/app/(app)/swipe.tsx +++ b/apps/mobile/app/(app)/swipe.tsx @@ -40,6 +40,10 @@ export default function SwipeScreen() { const params = useLocalSearchParams(); // Normalize packId - useLocalSearchParams can return string | string[] const packId = Array.isArray(params.packId) ? params.packId[0] : params.packId; + // Mode can be 'pending' to show only questions partner has answered + const mode = Array.isArray(params.mode) ? params.mode[0] : params.mode; + // Start question ID - when user taps a specific pending question, start there + const startQuestionId = Array.isArray(params.startQuestionId) ? params.startQuestionId[0] : params.startQuestionId; const [questions, setQuestions] = useState([]); const [isLoading, setIsLoading] = useState(true); const [currentIndex, setCurrentIndex] = useState(0); @@ -49,10 +53,12 @@ export default function SwipeScreen() { const [gapInfo, setGapInfo] = useState<{ unanswered: number; threshold: number } | null>(null); const [dailyLimitInfo, setDailyLimitInfo] = useState(null); const [showPaywall, setShowPaywall] = useState(false); + const [packContext, setPackContext] = useState<{ name: string; icon: string } | null>(null); const { enabledPackIds, ensureEnabledPacksLoaded } = usePacksStore(); const { partner, couple } = useAuthStore(); const hasTrackedExhausted = useRef(false); const fetchRequestId = useRef(0); + const lastFetchedMode = useRef(undefined); // Ambient orb breathing animations const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation(); @@ -112,16 +118,42 @@ export default function SwipeScreen() { const isFirstMount = useRef(true); useEffect(() => { - // Reset state when pack changes + // Reset state when pack or mode changes setCurrentIndex(0); setIsLoading(true); setIsBlocked(false); setGapInfo(null); hasTrackedExhausted.current = false; + lastFetchedMode.current = mode; // Use lightweight ensureEnabledPacksLoaded instead of full fetchPacks - ensureEnabledPacksLoaded().then(() => fetchQuestions()); + ensureEnabledPacksLoaded().then(() => { + if (mode === 'pending') { + fetchPendingQuestions(); + } else { + fetchQuestions(); + } + }); checkTutorial(); + }, [packId, mode]); + + // Fetch pack context when packId is provided + useEffect(() => { + if (packId) { + const fetchPackContext = async () => { + const { data } = await supabase + .from('question_packs') + .select('name, icon') + .eq('id', packId) + .single(); + if (data) { + setPackContext({ name: data.name, icon: data.icon || 'layers' }); + } + }; + fetchPackContext(); + } else { + setPackContext(null); + } }, [packId]); // Refresh questions when screen gains focus (handles partner answering while away) @@ -132,9 +164,17 @@ export default function SwipeScreen() { isFirstMount.current = false; return; } - // Silently refetch to get updated partner_answered status - fetchQuestions(); - }, [packId]) + // Skip if mode just changed (useEffect handles that) + if (lastFetchedMode.current !== mode) { + return; + } + // Silently refetch to get updated status + if (mode === 'pending') { + fetchPendingQuestions(); + } else { + fetchQuestions(); + } + }, [packId, mode]) ); // Check if user is too far ahead of partner (called when questions empty or after swipe) @@ -249,8 +289,9 @@ export default function SwipeScreen() { }; // Filter out questions from disabled packs immediately when enabledPackIds changes + // Skip this filter in pending mode - user should answer ALL questions partner swiped on useEffect(() => { - if (!packId && enabledPackIds.length > 0 && questions.length > 0) { + if (!packId && mode !== 'pending' && enabledPackIds.length > 0 && questions.length > 0) { const enabledSet = new Set(enabledPackIds); setQuestions(prev => { const filtered = prev.filter(q => enabledSet.has(q.pack_id)); @@ -261,7 +302,7 @@ export default function SwipeScreen() { return filtered; }); } - }, [enabledPackIds]); + }, [enabledPackIds, mode]); const fetchQuestions = async () => { @@ -315,6 +356,119 @@ export default function SwipeScreen() { } }; + // Fetch pending questions (partner answered, user hasn't) for "Your Turn" mode + const fetchPendingQuestions = async () => { + const currentRequestId = ++fetchRequestId.current; + const userId = useAuthStore.getState().user?.id; + const coupleId = useAuthStore.getState().user?.couple_id; + + if (!coupleId || !userId) { + if (currentRequestId === fetchRequestId.current) { + setQuestions([]); + setIsLoading(false); + } + return; + } + + try { + // Get partner's user_id + const { data: coupleProfiles } = await supabase + .from("profiles") + .select("id") + .eq("couple_id", coupleId) + .neq("id", userId); + + const partnerId = coupleProfiles?.[0]?.id; + if (!partnerId) { + if (currentRequestId === fetchRequestId.current) { + setQuestions([]); + setIsLoading(false); + } + return; + } + + // Get questions the current user has already answered + const { data: userResponses } = await supabase + .from("responses") + .select("question_id") + .eq("user_id", userId) + .eq("couple_id", coupleId); + + const answeredQuestionIds = new Set(userResponses?.map(r => r.question_id) ?? []); + + // Get partner's responses (questions they answered that user hasn't) + // Order by oldest first so user burns down the backlog + const { data: partnerResponses, error } = await supabase + .from("responses") + .select(` + id, + question_id, + created_at, + question:questions( + *, + pack:question_packs(id, name, icon) + ) + `) + .eq("user_id", partnerId) + .eq("couple_id", coupleId) + .order("created_at", { ascending: true }); // Oldest first + + if (currentRequestId !== fetchRequestId.current) return; + + if (error) { + console.error("Error fetching pending questions:", error); + throw error; + } + + // Filter to only questions user hasn't answered, excluding deleted questions + const pendingQuestions = (partnerResponses ?? []) + .filter(r => { + const question = r.question as any; + return !answeredQuestionIds.has(r.question_id) && question && !question.deleted_at; + }) + .map(r => { + const question = r.question as any; + return { + ...question, + pack_id: question.pack?.id, + pack_name: question.pack?.name, + pack_icon: question.pack?.icon, + }; + }); + + if (currentRequestId === fetchRequestId.current) { + // If user tapped a specific question, reorder so it's first + let orderedQuestions = pendingQuestions; + if (startQuestionId && pendingQuestions.length > 0) { + const startIndex = pendingQuestions.findIndex(q => q.id === startQuestionId); + if (startIndex !== -1) { + // Put tapped question first, then the rest in original order + const tappedQuestion = pendingQuestions[startIndex]; + const rest = pendingQuestions.filter((_, i) => i !== startIndex); + orderedQuestions = [tappedQuestion, ...rest]; + } + } + + setQuestions(orderedQuestions); + setCurrentIndex(0); // Always start at 0 now since we reordered + + // Don't check gap/daily limits in pending mode - user is just catching up + setIsBlocked(false); + setGapInfo(null); + setDailyLimitInfo(null); + } + } catch (error) { + if (currentRequestId === fetchRequestId.current) { + console.error("Failed to fetch pending questions:", error); + setQuestions([]); + } + } finally { + if (currentRequestId === fetchRequestId.current) { + setIsLoading(false); + } + } + }; + const handleSwipe = async (direction: "left" | "right" | "up" | "down") => { const question = questions[currentIndex]; @@ -342,18 +496,21 @@ export default function SwipeScreen() { } else { Events.questionAnswered(answer, question.pack_id); - // Increment local daily count if we have limit info - if (dailyLimitInfo && dailyLimitInfo.limit_value > 0) { - setDailyLimitInfo(prev => prev ? { - ...prev, - responses_today: prev.responses_today + 1, - remaining: Math.max(0, prev.remaining - 1), - is_blocked: prev.responses_today + 1 >= prev.limit_value - } : null); - } + // Skip limit checks in pending mode - user is just catching up + if (mode !== 'pending') { + // Increment local daily count if we have limit info + if (dailyLimitInfo && dailyLimitInfo.limit_value > 0) { + setDailyLimitInfo(prev => prev ? { + ...prev, + responses_today: prev.responses_today + 1, + remaining: Math.max(0, prev.remaining - 1), + is_blocked: prev.responses_today + 1 >= prev.limit_value + } : null); + } - // Check both gap and daily limit - await Promise.all([checkAnswerGap(), checkDailyLimit()]); + // Check both gap and daily limit + await Promise.all([checkAnswerGap(), checkDailyLimit()]); + } } } catch (error) { console.error("Failed to submit response", error); @@ -682,6 +839,8 @@ export default function SwipeScreen() { if (currentIndex >= questions.length) { const CAUGHT_UP_ACCENT = colors.premium.rose; + const isPackMode = !!packId; + const isPendingMode = mode === 'pending'; return ( @@ -695,8 +854,12 @@ export default function SwipeScreen() { {/* Title section */} - COMPLETE - All Caught Up + + {isPendingMode ? 'QUEUE COMPLETE' : isPackMode ? 'PACK COMPLETE' : 'COMPLETE'} + + + {isPendingMode ? 'All Caught Up!' : isPackMode ? packContext?.name || 'Pack Complete' : 'All Caught Up'} + @@ -705,40 +868,88 @@ export default function SwipeScreen() { entering={FadeIn.delay(300).duration(400)} style={styles.waitingBadge} > - YOU'RE AHEAD + + {isPendingMode ? 'YOUR TURN COMPLETE' : isPackMode ? 'ALL QUESTIONS ANSWERED' : "YOU'RE AHEAD"} + {/* Description */} - You've answered all available questions. New questions are added regularly, or explore different packs. + {isPendingMode + ? "You've answered all the questions your partner swiped on. Nice work! Check back later for more." + : isPackMode + ? "You've answered all questions in this pack. Explore other packs to discover more!" + : "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 - + {isPendingMode ? ( + <> + + + Check your matches + + + + Chat about discoveries + + + + Keep exploring together + + + ) : ( + <> + + + New questions weekly + + + + Try different packs + + + + Chat about your matches + + + )} {/* Bottom teaser */} - More ways to connect are on the way + + {isPendingMode + ? 'Your partner will love that you caught up' + : isPackMode + ? 'Discover more ways to connect' + : 'More ways to connect are on the way'} + - - Refresh Questions - + {isPendingMode ? ( + router.push("/(app)/matches")} + style={{ marginTop: spacing.lg }} + > + View Matches + + ) : isPackMode ? ( + router.back()} + style={{ marginTop: spacing.lg }} + > + Explore More Packs + + ) : ( + + Refresh Questions + + )} @@ -771,31 +982,43 @@ export default function SwipeScreen() { entering={FadeIn.duration(400)} style={styles.header} > - - {/* Premium label */} - EXPLORE - - {currentIndex + 1} of {effectiveTotal || questions.length} - - - - {/* Shimmer sweep */} - - + + {/* Back button when playing specific pack or pending mode */} + {(packId || mode === 'pending') && ( + router.back()} style={styles.backButton}> + + + )} + + {/* Premium label */} + + {mode === 'pending' ? 'YOUR TURN' : packContext ? packContext.name.toUpperCase() : 'EXPLORE'} + + + {currentIndex + 1} of {effectiveTotal || questions.length} + + + + {/* Shimmer sweep */} + + + - + + {/* Spacer to balance header when back button is shown */} + {(packId || mode === 'pending') && } @@ -904,6 +1127,18 @@ const styles = StyleSheet.create({ paddingHorizontal: spacing.lg, paddingBottom: spacing.md, }, + headerRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + backButton: { + padding: spacing.sm, + marginRight: spacing.sm, + }, + headerSpacer: { + width: 40, // Match back button width for centering + }, progressContainer: { alignItems: "center", }, diff --git a/apps/mobile/ios/Podfile.lock b/apps/mobile/ios/Podfile.lock index aaf2af3..da468bb 100644 --- a/apps/mobile/ios/Podfile.lock +++ b/apps/mobile/ios/Podfile.lock @@ -2183,6 +2183,8 @@ PODS: - ReactCommon/turbomodule/core - Sentry/HybridSDK (= 8.48.0) - Yoga + - RNSVG (15.8.0): + - React-Core - SDWebImage (5.19.7): - SDWebImage/Core (= 5.19.7) - SDWebImage/Core (5.19.7) @@ -2303,6 +2305,7 @@ DEPENDENCIES: - RNReanimated (from `../../../node_modules/react-native-reanimated`) - RNScreens (from `../../../node_modules/react-native-screens`) - "RNSentry (from `../../../node_modules/@sentry/react-native`)" + - RNSVG (from `../node_modules/react-native-svg`) - Yoga (from `../../../node_modules/react-native/ReactCommon/yoga`) SPEC REPOS: @@ -2543,6 +2546,8 @@ EXTERNAL SOURCES: :path: "../../../node_modules/react-native-screens" RNSentry: :path: "../../../node_modules/@sentry/react-native" + RNSVG: + :path: "../node_modules/react-native-svg" Yoga: :path: "../../../node_modules/react-native/ReactCommon/yoga" @@ -2670,6 +2675,7 @@ SPEC CHECKSUMS: RNReanimated: 38e033fd1790c817678bc8a075277cdc49d6817c RNScreens: ad2d0cfb3f15c9ed56ffb43651366ec0dcd70c36 RNSentry: a8f0d86b88675719ce96cb31bd9258dbd793e535 + RNSVG: 8542aa11770b27563714bbd8494a8436385fc85f SDWebImage: 8a6b7b160b4d710e2a22b6900e25301075c34cb3 SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57 SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c diff --git a/apps/mobile/ios/Sauci.xcodeproj/project.pbxproj b/apps/mobile/ios/Sauci.xcodeproj/project.pbxproj index 7ee4ae6..e4d78b0 100644 --- a/apps/mobile/ios/Sauci.xcodeproj/project.pbxproj +++ b/apps/mobile/ios/Sauci.xcodeproj/project.pbxproj @@ -10,13 +10,13 @@ 13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.mm */; }; 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; }; - 1AFB24071BC9A2F5B90C1951 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 787323E49260E99741AD6413 /* PrivacyInfo.xcprivacy */; }; - 1F3D2CE02DD8CC2E2BA9F93A /* Pods_Sauci.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 27B40580D18D29CBD94DD2C3 /* Pods_Sauci.framework */; }; 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; }; - 47690AB7B3224CD3B1655332 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 2EDE49AC9AAC445892628B2B /* GoogleService-Info.plist */; }; + 42547B8429A622FDB6A08D1A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 6F16AA977A27D49DC4EABEBD /* PrivacyInfo.xcprivacy */; }; B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */; }; + B7C05C8A003645F0BE64907E /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 635D1AC9C28D4D029F2F8C7A /* GoogleService-Info.plist */; }; BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; - E52FFD661AC14FC08C4F239D /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31F85F9C76D34B859549F3A7 /* noop-file.swift */; }; + C3F98487868740D8A0D61E28 /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC04D93C99ED45B8A9943233 /* noop-file.swift */; }; + FE422ED79FD52F74C1E29BFB /* Pods_Sauci.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8641A69B4B5E9431AF468A7C /* Pods_Sauci.framework */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -26,15 +26,15 @@ 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Sauci/Images.xcassets; sourceTree = ""; }; 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Sauci/Info.plist; sourceTree = ""; }; 13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = Sauci/main.m; sourceTree = ""; }; - 27B40580D18D29CBD94DD2C3 /* Pods_Sauci.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Sauci.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 2EDE49AC9AAC445892628B2B /* GoogleService-Info.plist */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Sauci/GoogleService-Info.plist"; sourceTree = ""; }; - 31F85F9C76D34B859549F3A7 /* noop-file.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = "noop-file.swift"; path = "Sauci/noop-file.swift"; sourceTree = ""; }; + 635D1AC9C28D4D029F2F8C7A /* GoogleService-Info.plist */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Sauci/GoogleService-Info.plist"; sourceTree = ""; }; 6C2E3173556A471DD304B334 /* Pods-Sauci.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.debug.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.debug.xcconfig"; sourceTree = ""; }; - 787323E49260E99741AD6413 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = Sauci/PrivacyInfo.xcprivacy; sourceTree = ""; }; + 6F16AA977A27D49DC4EABEBD /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = Sauci/PrivacyInfo.xcprivacy; sourceTree = ""; }; 7A4D352CD337FB3A3BF06240 /* Pods-Sauci.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.release.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.release.xcconfig"; sourceTree = ""; }; - A9934E72B0154500A352F0F1 /* Sauci-Bridging-Header.h */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.h; name = "Sauci-Bridging-Header.h"; path = "Sauci/Sauci-Bridging-Header.h"; sourceTree = ""; }; + 8641A69B4B5E9431AF468A7C /* Pods_Sauci.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Sauci.framework; sourceTree = BUILT_PRODUCTS_DIR; }; AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Sauci/SplashScreen.storyboard; sourceTree = ""; }; BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; + CA157390B5A149C0AD0B6B08 /* Sauci-Bridging-Header.h */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.h; name = "Sauci-Bridging-Header.h"; path = "Sauci/Sauci-Bridging-Header.h"; sourceTree = ""; }; + DC04D93C99ED45B8A9943233 /* noop-file.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = "noop-file.swift"; path = "Sauci/noop-file.swift"; sourceTree = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Sauci/ExpoModulesProvider.swift"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -44,7 +44,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 1F3D2CE02DD8CC2E2BA9F93A /* Pods_Sauci.framework in Frameworks */, + FE422ED79FD52F74C1E29BFB /* Pods_Sauci.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -61,10 +61,10 @@ 13B07FB61A68108700A75B9A /* Info.plist */, 13B07FB71A68108700A75B9A /* main.m */, AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */, - 2EDE49AC9AAC445892628B2B /* GoogleService-Info.plist */, - 31F85F9C76D34B859549F3A7 /* noop-file.swift */, - A9934E72B0154500A352F0F1 /* Sauci-Bridging-Header.h */, - 787323E49260E99741AD6413 /* PrivacyInfo.xcprivacy */, + 635D1AC9C28D4D029F2F8C7A /* GoogleService-Info.plist */, + DC04D93C99ED45B8A9943233 /* noop-file.swift */, + CA157390B5A149C0AD0B6B08 /* Sauci-Bridging-Header.h */, + 6F16AA977A27D49DC4EABEBD /* PrivacyInfo.xcprivacy */, ); name = Sauci; sourceTree = ""; @@ -73,7 +73,7 @@ isa = PBXGroup; children = ( ED297162215061F000B7C4FE /* JavaScriptCore.framework */, - 27B40580D18D29CBD94DD2C3 /* Pods_Sauci.framework */, + 8641A69B4B5E9431AF468A7C /* Pods_Sauci.framework */, ); name = Frameworks; sourceTree = ""; @@ -150,15 +150,15 @@ buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Sauci" */; buildPhases = ( 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */, - DB0F43D5EFF9CB0780FDD6A5 /* [Expo] Configure project */, + 380EBB147031A50785146268 /* [Expo] Configure project */, 13B07F871A680F5B00A75B9A /* Sources */, 13B07F8C1A680F5B00A75B9A /* Frameworks */, 13B07F8E1A680F5B00A75B9A /* Resources */, 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, 800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */, - F051EF99F56144029D3DFD83 /* Upload Debug Symbols to Sentry */, - D1E774350CB6864AD97A24B4 /* [CP] Embed Pods Frameworks */, - 658D10E2FBC1B3A1C410A9EB /* [CP-User] [RNFB] Core Configuration */, + 5F6BF0C2EF824875B30C4F47 /* Upload Debug Symbols to Sentry */, + 83F7C8F647047F466249E15C /* [CP] Embed Pods Frameworks */, + 3AB0FB698F7BE8E7FEAC6EB4 /* [CP-User] [RNFB] Core Configuration */, ); buildRules = ( ); @@ -210,8 +210,8 @@ BB2F792D24A3F905000567C9 /* Expo.plist in Resources */, 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */, - 47690AB7B3224CD3B1655332 /* GoogleService-Info.plist in Resources */, - 1AFB24071BC9A2F5B90C1951 /* PrivacyInfo.xcprivacy in Resources */, + B7C05C8A003645F0BE64907E /* GoogleService-Info.plist in Resources */, + 42547B8429A622FDB6A08D1A /* PrivacyInfo.xcprivacy in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -255,7 +255,26 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - 658D10E2FBC1B3A1C410A9EB /* [CP-User] [RNFB] Core Configuration */ = { + 380EBB147031A50785146268 /* [Expo] Configure project */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "[Expo] Configure project"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Sauci/expo-configure-project.sh\"\n"; + }; + 3AB0FB698F7BE8E7FEAC6EB4 /* [CP-User] [RNFB] Core Configuration */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -268,6 +287,20 @@ shellPath = /bin/sh; shellScript = "#!/usr/bin/env bash\n#\n# Copyright (c) 2016-present Invertase Limited & Contributors\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this library except in compliance with the License.\n# You may obtain a copy of the License at\n#\n# http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n##########################################################################\n##########################################################################\n#\n# NOTE THAT IF YOU CHANGE THIS FILE YOU MUST RUN pod install AFTERWARDS\n#\n# This file is installed as an Xcode build script in the project file\n# by cocoapods, and you will not see your changes until you pod install\n#\n##########################################################################\n##########################################################################\n\nset -e\n\n_MAX_LOOKUPS=2;\n_SEARCH_RESULT=''\n_RN_ROOT_EXISTS=''\n_CURRENT_LOOKUPS=1\n_JSON_ROOT=\"'react-native'\"\n_JSON_FILE_NAME='firebase.json'\n_JSON_OUTPUT_BASE64='e30=' # { }\n_CURRENT_SEARCH_DIR=${PROJECT_DIR}\n_PLIST_BUDDY=/usr/libexec/PlistBuddy\n_TARGET_PLIST=\"${BUILT_PRODUCTS_DIR}/${INFOPLIST_PATH}\"\n_DSYM_PLIST=\"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist\"\n\n# plist arrays\n_PLIST_ENTRY_KEYS=()\n_PLIST_ENTRY_TYPES=()\n_PLIST_ENTRY_VALUES=()\n\nfunction setPlistValue {\n echo \"note: setting plist entry '$1' of type '$2' in file '$4'\"\n ${_PLIST_BUDDY} -c \"Add :$1 $2 '$3'\" $4 || echo \"note: '$1' already exists\"\n}\n\nfunction getFirebaseJsonKeyValue () {\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n ruby -Ku -e \"require 'rubygems';require 'json'; output=JSON.parse('$1'); puts output[$_JSON_ROOT]['$2']\"\n else\n echo \"\"\n fi;\n}\n\nfunction jsonBoolToYesNo () {\n if [[ $1 == \"false\" ]]; then\n echo \"NO\"\n elif [[ $1 == \"true\" ]]; then\n echo \"YES\"\n else echo \"NO\"\n fi\n}\n\necho \"note: -> RNFB build script started\"\necho \"note: 1) Locating ${_JSON_FILE_NAME} file:\"\n\nif [[ -z ${_CURRENT_SEARCH_DIR} ]]; then\n _CURRENT_SEARCH_DIR=$(pwd)\nfi;\n\nwhile true; do\n _CURRENT_SEARCH_DIR=$(dirname \"$_CURRENT_SEARCH_DIR\")\n if [[ \"$_CURRENT_SEARCH_DIR\" == \"/\" ]] || [[ ${_CURRENT_LOOKUPS} -gt ${_MAX_LOOKUPS} ]]; then break; fi;\n echo \"note: ($_CURRENT_LOOKUPS of $_MAX_LOOKUPS) Searching in '$_CURRENT_SEARCH_DIR' for a ${_JSON_FILE_NAME} file.\"\n _SEARCH_RESULT=$(find \"$_CURRENT_SEARCH_DIR\" -maxdepth 2 -name ${_JSON_FILE_NAME} -print | /usr/bin/head -n 1)\n if [[ ${_SEARCH_RESULT} ]]; then\n echo \"note: ${_JSON_FILE_NAME} found at $_SEARCH_RESULT\"\n break;\n fi;\n _CURRENT_LOOKUPS=$((_CURRENT_LOOKUPS+1))\ndone\n\nif [[ ${_SEARCH_RESULT} ]]; then\n _JSON_OUTPUT_RAW=$(cat \"${_SEARCH_RESULT}\")\n if ! _RN_ROOT_EXISTS=$(ruby -Ku -e \"require 'json'; output=JSON.parse('$_JSON_OUTPUT_RAW'); puts output[$_JSON_ROOT]\"); then\n echo \"error: Failed to parse firebase.json, check for syntax errors.\"\n exit 1\n fi\n\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n if ! python3 --version >/dev/null 2>&1; then echo \"error: python3 not found, firebase.json file processing error.\" && exit 1; fi\n _JSON_OUTPUT_BASE64=$(python3 -c 'import json,sys,base64;print(base64.b64encode(bytes(json.dumps(json.loads(open('\"'${_SEARCH_RESULT}'\"', '\"'rb'\"').read())['${_JSON_ROOT}']), '\"'utf-8'\"')).decode())' || echo \"e30=\")\n fi\n\n _PLIST_ENTRY_KEYS+=(\"firebase_json_raw\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_JSON_OUTPUT_BASE64\")\n\n # config.app_data_collection_default_enabled\n _APP_DATA_COLLECTION_ENABLED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"app_data_collection_default_enabled\")\n if [[ $_APP_DATA_COLLECTION_ENABLED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseDataCollectionDefaultEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_APP_DATA_COLLECTION_ENABLED\")\")\n fi\n\n # config.analytics_auto_collection_enabled\n _ANALYTICS_AUTO_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_auto_collection_enabled\")\n if [[ $_ANALYTICS_AUTO_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AUTO_COLLECTION\")\")\n fi\n\n # config.analytics_collection_deactivated\n _ANALYTICS_DEACTIVATED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_collection_deactivated\")\n if [[ $_ANALYTICS_DEACTIVATED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_DEACTIVATED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_DEACTIVATED\")\")\n fi\n\n # config.analytics_idfv_collection_enabled\n _ANALYTICS_IDFV_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_idfv_collection_enabled\")\n if [[ $_ANALYTICS_IDFV_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_IDFV_COLLECTION_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_IDFV_COLLECTION\")\")\n fi\n\n # config.analytics_default_allow_analytics_storage\n _ANALYTICS_STORAGE=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_analytics_storage\")\n if [[ $_ANALYTICS_STORAGE ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_ANALYTICS_STORAGE\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_STORAGE\")\")\n fi\n\n # config.analytics_default_allow_ad_storage\n _ANALYTICS_AD_STORAGE=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_ad_storage\")\n if [[ $_ANALYTICS_AD_STORAGE ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_STORAGE\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AD_STORAGE\")\")\n fi\n\n # config.analytics_default_allow_ad_user_data\n _ANALYTICS_AD_USER_DATA=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_ad_user_data\")\n if [[ $_ANALYTICS_AD_USER_DATA ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_USER_DATA\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AD_USER_DATA\")\")\n fi\n\n # config.analytics_default_allow_ad_personalization_signals\n _ANALYTICS_PERSONALIZATION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_ad_personalization_signals\")\n if [[ $_ANALYTICS_PERSONALIZATION ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_PERSONALIZATION_SIGNALS\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_PERSONALIZATION\")\")\n fi\n\n # config.analytics_registration_with_ad_network_enabled\n _ANALYTICS_REGISTRATION_WITH_AD_NETWORK=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"google_analytics_registration_with_ad_network_enabled\")\n if [[ $_ANALYTICS_REGISTRATION_WITH_AD_NETWORK ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_REGISTRATION_WITH_AD_NETWORK_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_REGISTRATION_WITH_AD_NETWORK\")\")\n fi\n\n # config.google_analytics_automatic_screen_reporting_enabled\n _ANALYTICS_AUTO_SCREEN_REPORTING=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"google_analytics_automatic_screen_reporting_enabled\")\n if [[ $_ANALYTICS_AUTO_SCREEN_REPORTING ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseAutomaticScreenReportingEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AUTO_SCREEN_REPORTING\")\")\n fi\n\n # config.perf_auto_collection_enabled\n _PERF_AUTO_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"perf_auto_collection_enabled\")\n if [[ $_PERF_AUTO_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"firebase_performance_collection_enabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_PERF_AUTO_COLLECTION\")\")\n fi\n\n # config.perf_collection_deactivated\n _PERF_DEACTIVATED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"perf_collection_deactivated\")\n if [[ $_PERF_DEACTIVATED ]]; then\n _PLIST_ENTRY_KEYS+=(\"firebase_performance_collection_deactivated\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_PERF_DEACTIVATED\")\")\n fi\n\n # config.messaging_auto_init_enabled\n _MESSAGING_AUTO_INIT=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"messaging_auto_init_enabled\")\n if [[ $_MESSAGING_AUTO_INIT ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseMessagingAutoInitEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_MESSAGING_AUTO_INIT\")\")\n fi\n\n # config.in_app_messaging_auto_colllection_enabled\n _FIAM_AUTO_INIT=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"in_app_messaging_auto_collection_enabled\")\n if [[ $_FIAM_AUTO_INIT ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseInAppMessagingAutomaticDataCollectionEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_FIAM_AUTO_INIT\")\")\n fi\n\n # config.app_check_token_auto_refresh\n _APP_CHECK_TOKEN_AUTO_REFRESH=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"app_check_token_auto_refresh\")\n if [[ $_APP_CHECK_TOKEN_AUTO_REFRESH ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseAppCheckTokenAutoRefreshEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_APP_CHECK_TOKEN_AUTO_REFRESH\")\")\n fi\n\n # config.crashlytics_disable_auto_disabler - undocumented for now - mainly for debugging, document if becomes useful\n _CRASHLYTICS_AUTO_DISABLE_ENABLED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"crashlytics_disable_auto_disabler\")\n if [[ $_CRASHLYTICS_AUTO_DISABLE_ENABLED == \"true\" ]]; then\n echo \"Disabled Crashlytics auto disabler.\" # do nothing\n else\n _PLIST_ENTRY_KEYS+=(\"FirebaseCrashlyticsCollectionEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"NO\")\n fi\nelse\n _PLIST_ENTRY_KEYS+=(\"firebase_json_raw\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_JSON_OUTPUT_BASE64\")\n echo \"warning: A firebase.json file was not found, whilst this file is optional it is recommended to include it to configure firebase services in React Native Firebase.\"\nfi;\n\necho \"note: 2) Injecting Info.plist entries: \"\n\n# Log out the keys we're adding\nfor i in \"${!_PLIST_ENTRY_KEYS[@]}\"; do\n echo \" -> $i) ${_PLIST_ENTRY_KEYS[$i]}\" \"${_PLIST_ENTRY_TYPES[$i]}\" \"${_PLIST_ENTRY_VALUES[$i]}\"\ndone\n\nfor plist in \"${_TARGET_PLIST}\" \"${_DSYM_PLIST}\" ; do\n if [[ -f \"${plist}\" ]]; then\n\n # paths with spaces break the call to setPlistValue. temporarily modify\n # the shell internal field separator variable (IFS), which normally\n # includes spaces, to consist only of line breaks\n oldifs=$IFS\n IFS=\"\n\"\n\n for i in \"${!_PLIST_ENTRY_KEYS[@]}\"; do\n setPlistValue \"${_PLIST_ENTRY_KEYS[$i]}\" \"${_PLIST_ENTRY_TYPES[$i]}\" \"${_PLIST_ENTRY_VALUES[$i]}\" \"${plist}\"\n done\n\n # restore the original internal field separator value\n IFS=$oldifs\n else\n echo \"warning: A Info.plist build output file was not found (${plist})\"\n fi\ndone\n\necho \"note: <- RNFB build script finished\"\n"; }; + 5F6BF0C2EF824875B30C4F47 /* Upload Debug Symbols to Sentry */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Upload Debug Symbols to Sentry"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh `${NODE_BINARY:-node} --print \"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode-debug-files.sh'\"`"; + }; 800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -340,7 +373,7 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-resources.sh\"\n"; showEnvVarsInLog = 0; }; - D1E774350CB6864AD97A24B4 /* [CP] Embed Pods Frameworks */ = { + 83F7C8F647047F466249E15C /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -358,39 +391,6 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; - DB0F43D5EFF9CB0780FDD6A5 /* [Expo] Configure project */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - name = "[Expo] Configure project"; - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Sauci/expo-configure-project.sh\"\n"; - }; - F051EF99F56144029D3DFD83 /* Upload Debug Symbols to Sentry */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "Upload Debug Symbols to Sentry"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh `${NODE_BINARY:-node} --print \"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode-debug-files.sh'\"`"; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -401,7 +401,7 @@ 13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */, 13B07FC11A68108700A75B9A /* main.m in Sources */, B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */, - E52FFD661AC14FC08C4F239D /* noop-file.swift in Sources */, + C3F98487868740D8A0D61E28 /* noop-file.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -432,11 +432,11 @@ ); OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; PRODUCT_BUNDLE_IDENTIFIER = com.sauci.app; - PRODUCT_NAME = "Sauci"; + PRODUCT_NAME = Sauci; SWIFT_OBJC_BRIDGING_HEADER = "Sauci/Sauci-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1"; + TARGETED_DEVICE_FAMILY = 1; VERSIONING_SYSTEM = "apple-generic"; DEVELOPMENT_TEAM = "BXVPBATDZY"; CODE_SIGN_IDENTITY = "Apple Development"; @@ -463,10 +463,10 @@ ); OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; PRODUCT_BUNDLE_IDENTIFIER = com.sauci.app; - PRODUCT_NAME = "Sauci"; + PRODUCT_NAME = Sauci; SWIFT_OBJC_BRIDGING_HEADER = "Sauci/Sauci-Bridging-Header.h"; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1"; + TARGETED_DEVICE_FAMILY = 1; VERSIONING_SYSTEM = "apple-generic"; DEVELOPMENT_TEAM = "BXVPBATDZY"; CODE_SIGN_IDENTITY = "Apple Development"; diff --git a/apps/mobile/package.json b/apps/mobile/package.json index ae74b50..8314f65 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -64,6 +64,7 @@ "react-native-reanimated": "~3.16.0", "react-native-safe-area-context": "4.12.0", "react-native-screens": "~4.4.0", + "react-native-svg": "15.8.0", "react-native-url-polyfill": "^2.0.0", "react-native-web": "~0.19.13", "zustand": "^4.4.7" diff --git a/apps/mobile/src/__tests__/stores/packsStore.test.ts b/apps/mobile/src/__tests__/stores/packsStore.test.ts index be39d96..0b70786 100644 --- a/apps/mobile/src/__tests__/stores/packsStore.test.ts +++ b/apps/mobile/src/__tests__/stores/packsStore.test.ts @@ -17,23 +17,31 @@ function createThenableQuery(result: any) { describe('packsStore', () => { beforeEach(() => { jest.clearAllMocks(); - usePacksStore.setState({ packs: [], categories: [], enabledPackIds: [], isLoading: false } as any); - useAuthStore.setState({ user: { id: 'me', couple_id: 'c1', max_intensity: 2, show_explicit_content: false } } as any); + usePacksStore.setState({ packs: [], categories: [], enabledPackIds: [], packProgress: new Map(), isLoading: false } as any); + useAuthStore.setState({ user: { id: 'me', couple_id: 'c1', hide_nsfw: false } } as any); }); - it('fetches categories/packs and filters by max intensity', async () => { + it('fetches categories/packs and filters by hide_nsfw', async () => { + // Set hide_nsfw to true - should filter intensity to max 2 + useAuthStore.setState({ user: { id: 'me', couple_id: 'c1', hide_nsfw: true } } as any); + const categoriesQuery = createThenableQuery({ data: [{ id: 'cat1' }] }); const packsQuery = createThenableQuery({ data: [{ id: 'pack1', is_explicit: false }] }); const enabledQuery = createThenableQuery({ data: [{ pack_id: 'pack1' }] }); + const responsesQuery = createThenableQuery({ data: [] }); (supabase.from as jest.Mock) .mockReturnValueOnce(categoriesQuery) .mockReturnValueOnce(packsQuery) - .mockReturnValueOnce(enabledQuery); + .mockReturnValueOnce(enabledQuery) + .mockReturnValueOnce(responsesQuery); await usePacksStore.getState().fetchPacks(); + // When hide_nsfw=true, max_intensity=2, so or filter is called expect(packsQuery.or).toHaveBeenCalledWith('max_intensity.is.null,max_intensity.lte.2'); + // When hide_nsfw=true, explicit packs are filtered out + expect(packsQuery.eq).toHaveBeenCalledWith('is_explicit', false); const state = usePacksStore.getState(); expect(state.categories).toEqual([{ id: 'cat1' }]); diff --git a/apps/mobile/src/components/MatchNotificationModal.tsx b/apps/mobile/src/components/MatchNotificationModal.tsx index 1fd9afd..c86a78d 100644 --- a/apps/mobile/src/components/MatchNotificationModal.tsx +++ b/apps/mobile/src/components/MatchNotificationModal.tsx @@ -15,7 +15,9 @@ import { Pressable, Animated, Keyboard, + Platform, } from 'react-native'; +import * as Haptics from 'expo-haptics'; import { LinearGradient } from 'expo-linear-gradient'; import { BlurView } from 'expo-blur'; import { Ionicons } from '@expo/vector-icons'; @@ -110,6 +112,15 @@ export const MatchNotificationModal: React.FC = ({ // Dismiss keyboard so it doesn't block the modal buttons Keyboard.dismiss(); + // Trigger celebration haptic pattern + if (Platform.OS !== 'web') { + // Success notification followed by a heavy impact for extra punch + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + setTimeout(() => { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy); + }, 150); + } + // Reset animations modalScale.setValue(0.8); modalOpacity.setValue(0); diff --git a/apps/mobile/src/components/discovery/CircularProgress.tsx b/apps/mobile/src/components/discovery/CircularProgress.tsx new file mode 100644 index 0000000..5a7c1ca --- /dev/null +++ b/apps/mobile/src/components/discovery/CircularProgress.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { View, StyleSheet } from 'react-native'; +import Svg, { Circle } from 'react-native-svg'; + +interface CircularProgressProps { + progress: number; // 0 to 1 + size?: number; + strokeWidth?: number; + backgroundColor?: string; + progressColor?: string; +} + +export function CircularProgress({ + progress, + size = 28, + strokeWidth = 3, + backgroundColor = 'rgba(255, 255, 255, 0.2)', + progressColor = 'rgba(255, 255, 255, 0.9)', +}: CircularProgressProps) { + const radius = (size - strokeWidth) / 2; + const circumference = radius * 2 * Math.PI; + const clampedProgress = Math.min(Math.max(progress, 0), 1); + const strokeDashoffset = circumference * (1 - clampedProgress); + + return ( + + + {/* Background circle */} + + {/* Progress circle */} + {clampedProgress > 0 && ( + + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + justifyContent: 'center', + alignItems: 'center', + }, + svg: { + transform: [{ rotateZ: '0deg' }], + }, +}); + +export default CircularProgress; diff --git a/apps/mobile/src/components/discovery/CompactHeader.tsx b/apps/mobile/src/components/discovery/CompactHeader.tsx new file mode 100644 index 0000000..f4acd92 --- /dev/null +++ b/apps/mobile/src/components/discovery/CompactHeader.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import { StyleSheet, View, Text, TouchableOpacity } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { router } from 'expo-router'; +import Animated, { FadeInDown } from 'react-native-reanimated'; +import { colors, spacing, typography, radius } from '../../theme'; +import type { Profile, Couple } from '@/types'; + +interface CompactHeaderProps { + user: Profile | null; + partner: Profile | null; + couple: Couple | null; +} + +export function CompactHeader({ user, partner, couple }: CompactHeaderProps) { + return ( + + {/* Greeting */} + + + Discover + + Hey, {user?.name || 'Beautiful'} + + + + {/* Partner badge */} + {partner ? ( + + + + {partner.name || partner.email?.split('@')[0] || 'Partner'} + + + ) : couple ? ( + router.push('/(app)/pairing')} + > + + Waiting + + ) : ( + router.push('/(app)/pairing')} + > + + Connect + + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + paddingTop: 60, + paddingHorizontal: spacing.lg, + paddingBottom: spacing.lg, + }, + greetingRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + welcomeLabel: { + ...typography.caption2, + fontWeight: '600', + letterSpacing: 1.5, + color: colors.primary, + textTransform: 'uppercase', + marginBottom: 2, + }, + greeting: { + ...typography.title2, + color: colors.text, + }, + partnerBadge: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'rgba(233, 69, 96, 0.1)', + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radius.full, + borderWidth: 1, + borderColor: 'rgba(233, 69, 96, 0.2)', + gap: spacing.xs, + maxWidth: 140, + }, + partnerText: { + ...typography.caption1, + color: colors.text, + fontWeight: '500', + }, + pairBadge: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.glass.background, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radius.full, + borderWidth: 1, + borderColor: colors.glass.border, + gap: spacing.xs, + }, + pairText: { + ...typography.caption1, + color: colors.textTertiary, + }, + waitingBadge: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'rgba(233, 69, 96, 0.1)', + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radius.full, + borderWidth: 1, + borderColor: 'rgba(233, 69, 96, 0.2)', + gap: spacing.xs, + }, + waitingText: { + ...typography.caption1, + color: colors.text, + fontWeight: '500', + }, +}); + +export default CompactHeader; diff --git a/apps/mobile/src/components/discovery/ContentRow.tsx b/apps/mobile/src/components/discovery/ContentRow.tsx new file mode 100644 index 0000000..56bc7a3 --- /dev/null +++ b/apps/mobile/src/components/discovery/ContentRow.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import { StyleSheet, View, Text, FlatList, TouchableOpacity } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { router } from 'expo-router'; +import Animated, { FadeInDown } from 'react-native-reanimated'; +import { ContentTile, TILE_WIDTH } from './ContentTile'; +import { colors, spacing, typography } from '../../theme'; +import type { QuestionPack } from '@/types'; +import type { PackProgressData } from '../../store/packsStore'; + +interface ContentRowProps { + title: string; + packs: QuestionPack[]; + isPremiumUser: boolean; + delay?: number; + showSeeAll?: boolean; + categoryId?: string; + getPackProgress?: (packId: string) => PackProgressData | undefined; + onShowPaywall?: () => void; +} + +export function ContentRow({ + title, + packs, + isPremiumUser, + delay = 0, + showSeeAll = true, + categoryId, + getPackProgress, + onShowPaywall, +}: ContentRowProps) { + const handleSeeAll = () => { + // Navigate to packs screen with category filter + router.push({ + pathname: '/(app)/packs', + params: categoryId ? { category: categoryId } : undefined, + }); + }; + + const handleTilePress = (pack: QuestionPack) => { + // Locked packs show paywall immediately + const isLocked = pack.is_premium && !isPremiumUser; + if (isLocked) { + onShowPaywall?.(); + return; + } + // Unlocked packs start playing immediately + router.push({ + pathname: '/(app)/swipe', + params: { packId: pack.id }, + }); + }; + + if (packs.length === 0) return null; + + return ( + + {/* Header */} + + {title} + {showSeeAll && ( + + See all + + + )} + + + {/* Horizontal scroll */} + item.id} + horizontal + showsHorizontalScrollIndicator={false} + contentContainerStyle={styles.listContent} + snapToInterval={TILE_WIDTH + spacing.sm} + decelerationRate="fast" + renderItem={({ item }) => ( + handleTilePress(item)} + /> + )} + ItemSeparatorComponent={() => } + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + marginBottom: spacing.lg, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: spacing.lg, + marginBottom: spacing.md, + }, + title: { + ...typography.callout, + color: colors.text, + fontWeight: '600', + }, + seeAllButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 2, + }, + seeAllText: { + ...typography.subhead, + color: colors.primary, + }, + listContent: { + paddingHorizontal: spacing.lg, + }, + separator: { + width: spacing.sm, + }, +}); + +export default ContentRow; diff --git a/apps/mobile/src/components/discovery/ContentTile.tsx b/apps/mobile/src/components/discovery/ContentTile.tsx new file mode 100644 index 0000000..7e10179 --- /dev/null +++ b/apps/mobile/src/components/discovery/ContentTile.tsx @@ -0,0 +1,185 @@ +import React from 'react'; +import { StyleSheet, View, Text, TouchableOpacity, Dimensions } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import * as Haptics from 'expo-haptics'; +import { colors, spacing, radius, typography, shadows, getCategoryColor } from '../../theme'; +import { CircularProgress } from './CircularProgress'; +import type { QuestionPack } from '@/types'; +import type { PackProgressData } from '../../store/packsStore'; + +const SCREEN_WIDTH = Dimensions.get('window').width; +const TILE_GAP = spacing.sm; +const HORIZONTAL_PADDING = spacing.lg; +// Calculate tile width: 2 tiles per screen with gap and padding, plus peek of next +export const TILE_WIDTH = (SCREEN_WIDTH - HORIZONTAL_PADDING * 2 - TILE_GAP) / 2 + 8; +export const TILE_HEIGHT = 170; + +interface ContentTileProps { + pack: QuestionPack; + isLocked: boolean; + isNew?: boolean; + progress?: PackProgressData; + onPress: () => void; +} + +export function ContentTile({ pack, isLocked, isNew = false, progress, onPress }: ContentTileProps) { + const handlePress = () => { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + onPress(); + }; + const backgroundColor = getCategoryColor(pack.category); + const icon = pack.icon || pack.category?.icon || 'layers'; + const questionCount = pack.questions?.[0]?.count ?? 0; + + // Calculate progress percentage + const progressPercent = progress && progress.totalQuestions > 0 + ? progress.answeredQuestions / progress.totalQuestions + : 0; + // Show progress ring for packs with questions (even at 0%) + const showProgress = progress && progress.totalQuestions > 0; + + return ( + + + {/* Top row: icon and badges */} + + + + + + {isNew && ( + + NEW + + )} + {isLocked && ( + + + + )} + {showProgress && ( + + )} + + + + {/* Content */} + + {pack.category?.name && ( + + {pack.category.name} + + )} + + {pack.name} + + + + {/* Bottom info */} + + + {questionCount} {questionCount === 1 ? 'question' : 'questions'} + + + + {/* Decorative overlay for depth */} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + width: TILE_WIDTH, + height: TILE_HEIGHT, + }, + tile: { + flex: 1, + borderRadius: radius.lg, + padding: spacing.md, + overflow: 'hidden', + position: 'relative', + }, + overlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0, 0, 0, 0.05)', + borderRadius: radius.lg, + pointerEvents: 'none', + }, + topRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + }, + iconContainer: { + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: 'rgba(255, 255, 255, 0.2)', + justifyContent: 'center', + alignItems: 'center', + }, + badges: { + flexDirection: 'row', + gap: spacing.xs, + }, + newBadge: { + backgroundColor: 'rgba(255, 255, 255, 0.95)', + paddingHorizontal: spacing.sm, + paddingVertical: 2, + borderRadius: radius.xs, + }, + newBadgeText: { + ...typography.caption2, + fontWeight: '700', + color: colors.background, + letterSpacing: 0.5, + }, + lockBadge: { + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: 'rgba(0, 0, 0, 0.3)', + justifyContent: 'center', + alignItems: 'center', + }, + content: { + flex: 1, + justifyContent: 'center', + marginTop: spacing.sm, + }, + categoryText: { + ...typography.caption2, + color: 'rgba(255, 255, 255, 0.7)', + textTransform: 'uppercase', + letterSpacing: 1, + marginBottom: 2, + }, + titleText: { + ...typography.headline, + color: colors.text, + fontWeight: '700', + }, + bottomRow: { + marginTop: 'auto', + }, + countText: { + ...typography.caption1, + color: 'rgba(255, 255, 255, 0.7)', + }, +}); + +export default ContentTile; diff --git a/apps/mobile/src/components/discovery/index.ts b/apps/mobile/src/components/discovery/index.ts new file mode 100644 index 0000000..aadef81 --- /dev/null +++ b/apps/mobile/src/components/discovery/index.ts @@ -0,0 +1,4 @@ +export { ContentTile, TILE_WIDTH, TILE_HEIGHT } from './ContentTile'; +export { ContentRow } from './ContentRow'; +export { CompactHeader } from './CompactHeader'; +export { CircularProgress } from './CircularProgress'; diff --git a/apps/mobile/src/features/profile/components/PrivacySettings.tsx b/apps/mobile/src/features/profile/components/PrivacySettings.tsx index 027035e..364a26e 100644 --- a/apps/mobile/src/features/profile/components/PrivacySettings.tsx +++ b/apps/mobile/src/features/profile/components/PrivacySettings.tsx @@ -1,88 +1,41 @@ -import React, { useState } from 'react'; -import { View, Text, StyleSheet, TouchableOpacity, Modal, Platform, ScrollView } from 'react-native'; -import { LinearGradient } from 'expo-linear-gradient'; -import { BlurView } from 'expo-blur'; -import { Ionicons } from '@expo/vector-icons'; +import React from 'react'; +import { View, StyleSheet } from 'react-native'; import { SettingsSection } from './SettingsSection'; import { SwitchItem } from './SwitchItem'; -import { IntensitySlider } from '../../../components/ui/IntensitySlider'; -import { GlassButton } from '../../../components/ui'; -import { colors, spacing, radius, typography, featureColors } from '../../../theme'; -import type { IntensityLevel } from '../../../types'; +import { colors, spacing } from '../../../theme'; interface PrivacySettingsProps { - maxIntensity: IntensityLevel; - isUpdatingIntensity: boolean; - onIntensityChange: (value: IntensityLevel) => void; biometricAvailable: boolean; biometricEnabled: boolean; biometricType: string; isUpdatingBiometric: boolean; onBiometricToggle: (value: boolean) => void; - // Partner info for slider comparison - partnerIntensity?: IntensityLevel | null; - partnerName?: string; - partnerAvatar?: string | null; + hideNsfw: boolean; + isUpdatingHideNsfw: boolean; + onHideNsfwToggle: (value: boolean) => void; } -const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string]; - export const PrivacySettings: React.FC = ({ - maxIntensity, - isUpdatingIntensity, - onIntensityChange, biometricAvailable, biometricEnabled, biometricType, isUpdatingBiometric, onBiometricToggle, - partnerIntensity, - partnerName, - partnerAvatar, + hideNsfw, + isUpdatingHideNsfw, + onHideNsfwToggle, }) => { - const [showHelpModal, setShowHelpModal] = useState(false); - return ( - <> - {/* Comfort Zone Header */} - - - - - - - Comfort Zone - setShowHelpModal(true)} - hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} - activeOpacity={0.7} - > - - - - - Set the intimacy level you're comfortable with - - - - - {/* Intensity Slider */} - - - + {/* Hide Adult Content Toggle */} + {/* Biometric Toggle */} {biometricAvailable && ( @@ -99,207 +52,13 @@ export const PrivacySettings: React.FC = ({ )} - - {/* Help Modal */} - setShowHelpModal(false)} - > - - - - - How Comfort Zone Works - setShowHelpModal(false)} - style={styles.closeButton} - hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} - > - - - - - - - - - - Intensity Levels - - Questions range from 1 (mild) to 5 (spicy). You'll only see questions at or below your comfort level. - - - - - - - - Hidden Packs - - Some question packs are designed for higher intensity levels. These packs won't appear until you raise your comfort zone. - - - - - - - - Comfort Zone Gate - - You both see the same questions based on the lower comfort zone between you. If you're at 5 and your partner is at 3, you'll both only see questions up to level 3. - - - - - - - - Visible to Partner - - Your partner can see the comfort zone level you choose so you can stay aligned. This doesn’t reveal your individual swipes. - - - - - - - - Change Anytime - - Adjust whenever you want. New questions and packs will appear or hide based on your new setting. - - - - - - - setShowHelpModal(false)} - fullWidth - > - Got it - - - - - - ); }; const styles = StyleSheet.create({ - comfortHeader: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: spacing.lg, - }, - comfortIcon: { - width: 44, - height: 44, - borderRadius: 22, - alignItems: 'center', - justifyContent: 'center', - marginRight: spacing.md, - }, - comfortText: { - flex: 1, - }, - comfortLabelRow: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.xs, - }, - comfortLabel: { - ...typography.headline, - color: colors.text, - }, - comfortDescription: { - ...typography.caption1, - color: colors.textSecondary, - marginTop: 2, - }, - sliderContainer: { - marginBottom: spacing.sm, - }, divider: { height: 1, backgroundColor: colors.glass.border, marginVertical: spacing.lg, }, - // Modal styles - modalOverlay: { - flex: 1, - backgroundColor: 'rgba(0, 0, 0, 0.7)', - justifyContent: 'center', - alignItems: 'center', - padding: spacing.lg, - }, - modalContent: { - width: '100%', - maxWidth: 400, - maxHeight: '80%', - backgroundColor: colors.backgroundLight, - borderRadius: radius.xl, - borderWidth: 1, - borderColor: colors.glass.border, - padding: spacing.lg, - }, - modalHeader: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - marginBottom: spacing.md, - paddingBottom: spacing.md, - borderBottomWidth: 1, - borderBottomColor: colors.glass.border, - }, - modalTitle: { - ...typography.headline, - color: colors.text, - }, - closeButton: { - padding: spacing.xs, - }, - helpScrollView: { - flexGrow: 0, - marginBottom: spacing.md, - }, - helpSection: { - gap: spacing.sm, - }, - helpItem: { - flexDirection: 'row', - alignItems: 'flex-start', - paddingVertical: spacing.sm, - }, - helpItemLast: { - paddingBottom: 0, - }, - helpIcon: { - marginRight: spacing.sm, - marginTop: 2, - }, - helpTextContainer: { - flex: 1, - }, - helpItemTitle: { - ...typography.subhead, - fontWeight: '600', - color: colors.text, - marginBottom: 2, - }, - helpItemDescription: { - ...typography.caption1, - color: colors.textSecondary, - lineHeight: 18, - }, }); diff --git a/apps/mobile/src/features/profile/hooks/useProfileSettings.ts b/apps/mobile/src/features/profile/hooks/useProfileSettings.ts index 6baa763..d55d8f0 100644 --- a/apps/mobile/src/features/profile/hooks/useProfileSettings.ts +++ b/apps/mobile/src/features/profile/hooks/useProfileSettings.ts @@ -4,7 +4,6 @@ import { useAuthStore, useSubscriptionStore, usePacksStore } from '../../../stor import { supabase } from '../../../lib/supabase'; import { Events } from '../../../lib/analytics'; import { useAvatarPicker } from '../../../hooks/useAvatarPicker'; -import type { IntensityLevel } from '../../../types'; import { registerForPushNotificationsAsync, savePushToken, @@ -17,27 +16,17 @@ import { getBiometricType, } from '../../../lib/biometricAuth'; -const DEFAULT_MAX_INTENSITY: IntensityLevel = 2; - -const normalizeIntensity = (value: number | null | undefined): IntensityLevel | null => { - if (value === 1 || value === 2 || value === 3 || value === 4 || value === 5) return value; - return null; -}; - -const getProfileMaxIntensity = (profile?: { max_intensity?: number | null; show_explicit_content?: boolean | null } | null): IntensityLevel => { - const normalized = normalizeIntensity(profile?.max_intensity); - if (normalized) return normalized; - return profile?.show_explicit_content ? 5 : DEFAULT_MAX_INTENSITY; -}; +// Intensity thresholds for backwards compatibility +// When hide_nsfw=true, max_intensity=2 (mild content only) +// When hide_nsfw=false, max_intensity=5 (all content) +const NSFW_OFF_INTENSITY = 2; +const NSFW_ON_INTENSITY = 5; export function useProfileSettings() { - const { user, partner, fetchUser } = useAuthStore(); + const { user, fetchUser } = useAuthStore(); const { fetchPacks } = usePacksStore(); const { restorePurchases, isPurchasing } = useSubscriptionStore(); - // Partner's intensity level (for showing on slider) - const partnerIntensity = partner ? getProfileMaxIntensity(partner) : null; - // Name Editing const [isEditingName, setIsEditingName] = useState(false); const [newName, setNewName] = useState(user?.name || ""); @@ -80,9 +69,9 @@ export function useProfileSettings() { onUploadError: handleAvatarUploadError, }); - // Preferences - const [maxIntensity, setMaxIntensity] = useState(() => getProfileMaxIntensity(user)); - const [isUpdatingIntensity, setIsUpdatingIntensity] = useState(false); + // Hide NSFW + const [hideNsfw, setHideNsfw] = useState(!!user?.hide_nsfw); + const [isUpdatingHideNsfw, setIsUpdatingHideNsfw] = useState(false); // Notifications const [pushEnabled, setPushEnabled] = useState(!!user?.push_token); @@ -102,8 +91,8 @@ export function useProfileSettings() { if (user?.name) { setNewName(user.name); } - setMaxIntensity(getProfileMaxIntensity(user)); setPushEnabled(!!user?.push_token); + setHideNsfw(!!user?.hide_nsfw); }, [user]); // Check biometric availability @@ -168,41 +157,6 @@ export function useProfileSettings() { setIsEditingName(false); }, [user?.name]); - // Preference Actions - const handleIntensityChange = async (value: IntensityLevel) => { - if (!user?.id) return; - - const previous = maxIntensity; - setMaxIntensity(value); - setIsUpdatingIntensity(true); - - const showExplicitContent = value >= 3; - - try { - const { error } = await supabase - .from('profiles') - .update({ - max_intensity: value, - show_explicit_content: showExplicitContent, - updated_at: new Date().toISOString(), - }) - .eq('id', user.id); - - if (error) throw error; - - // Refresh user data and packs to reflect the change - await fetchUser(); - await fetchPacks(); - Events.profileUpdated(["max_intensity", "show_explicit_content"]); - } catch (error) { - // Revert on error - setMaxIntensity(previous); - Alert.alert("Error", "Failed to update preference. Please try again."); - } finally { - setIsUpdatingIntensity(false); - } - }; - const handlePushToggle = async (value: boolean) => { if (!user?.id) return; @@ -275,6 +229,43 @@ export function useProfileSettings() { } }; + const handleHideNsfwToggle = async (value: boolean) => { + if (!user?.id) return; + + const previous = hideNsfw; + setHideNsfw(value); + setIsUpdatingHideNsfw(true); + + // Derive intensity from hide_nsfw for backwards compatibility + const maxIntensity = value ? NSFW_OFF_INTENSITY : NSFW_ON_INTENSITY; + const showExplicitContent = !value; + + try { + const { error } = await supabase + .from('profiles') + .update({ + hide_nsfw: value, + max_intensity: maxIntensity, + show_explicit_content: showExplicitContent, + updated_at: new Date().toISOString(), + }) + .eq('id', user.id); + + if (error) throw error; + + // Refresh user data and packs to reflect the change + await fetchUser(); + await fetchPacks(); + Events.profileUpdated(["hide_nsfw", "max_intensity", "show_explicit_content"]); + } catch (error) { + // Revert on error + setHideNsfw(previous); + Alert.alert("Error", "Failed to update preference. Please try again."); + } finally { + setIsUpdatingHideNsfw(false); + } + }; + // Subscription Actions const handleRestorePurchases = async () => { const restored = await restorePurchases(); @@ -297,30 +288,25 @@ export function useProfileSettings() { setNewName, isUpdatingName, isUploadingAvatar, - maxIntensity, - isUpdatingIntensity, pushEnabled, isUpdatingPush, biometricAvailable, biometricEnabled: biometricEnabledState, biometricType, isUpdatingBiometric, + hideNsfw, + isUpdatingHideNsfw, showPaywall, setShowPaywall, isPurchasing, - // Partner info for comfort zone comparison - partnerIntensity, - partnerName: partner?.name ?? undefined, - partnerAvatar: partner?.avatar_url ?? undefined, - // Actions handleUpdateName, handleCancelEditName, handleAvatarPress, - handleIntensityChange, handlePushToggle, handleBiometricToggle, + handleHideNsfwToggle, handleRestorePurchases, handleManageSubscription, }; diff --git a/apps/mobile/src/features/profile/screens/PreferencesScreen.tsx b/apps/mobile/src/features/profile/screens/PreferencesScreen.tsx index 47696d5..33fb458 100644 --- a/apps/mobile/src/features/profile/screens/PreferencesScreen.tsx +++ b/apps/mobile/src/features/profile/screens/PreferencesScreen.tsx @@ -7,7 +7,7 @@ import { useProfileSettings } from '../hooks'; import { ScreenHeader, PrivacySettings } from '../components'; /** - * Preferences sub-screen for comfort zone and biometric settings. + * Preferences sub-screen for content and biometric settings. */ export function PreferencesScreen() { const settings = useProfileSettings(); @@ -21,17 +21,14 @@ export function PreferencesScreen() { showsVerticalScrollIndicator={false} > diff --git a/apps/mobile/src/store/index.ts b/apps/mobile/src/store/index.ts index 7795e2e..94d9706 100755 --- a/apps/mobile/src/store/index.ts +++ b/apps/mobile/src/store/index.ts @@ -1,6 +1,7 @@ // Re-export all stores for convenient imports export { useAuthStore } from "./authStore"; export { useMatchStore } from "./matchStore"; +export type { PendingQuestion } from "./matchStore"; export { usePacksStore } from "./packsStore"; export { useMessageStore } from "./messageStore"; export { useSubscriptionStore } from "./subscriptionStore"; diff --git a/apps/mobile/src/store/matchStore.ts b/apps/mobile/src/store/matchStore.ts index fc7605e..b17649e 100644 --- a/apps/mobile/src/store/matchStore.ts +++ b/apps/mobile/src/store/matchStore.ts @@ -1,10 +1,19 @@ import { create } from "zustand"; import { supabase } from "../lib/supabase"; -import type { Match } from "@/types"; +import type { Match, Question, QuestionPack, AnswerType } from "@/types"; import { useAuthStore } from "./authStore"; const BATCH_SIZE = 20; +// Question waiting for user's response (partner has answered) +export interface PendingQuestion { + id: string; // Partner's response ID + question: Question & { pack?: Pick }; + partnerAnsweredAt: string; +} + +type MatchViewType = 'active' | 'archived' | 'pending'; + interface MatchState { matches: Match[]; newMatchesCount: number; @@ -19,6 +28,10 @@ interface MatchState { archivedMatchIds: Set; showArchived: boolean; isLoadingArchived: boolean; + // Pending (Your Turn) state + pendingQuestions: PendingQuestion[]; + isLoadingPending: boolean; + currentView: MatchViewType; // Methods fetchMatches: (refresh?: boolean) => Promise; markAsSeen: (matchId: string) => Promise; @@ -32,6 +45,9 @@ interface MatchState { fetchArchivedMatches: () => Promise; toggleShowArchived: () => void; isMatchArchived: (matchId: string) => boolean; + // Pending methods + fetchPendingQuestions: () => Promise; + setCurrentView: (view: MatchViewType) => void; } export const useMatchStore = create((set, get) => ({ @@ -48,6 +64,10 @@ export const useMatchStore = create((set, get) => ({ archivedMatchIds: new Set(), showArchived: false, isLoadingArchived: false, + // Pending (Your Turn) state + pendingQuestions: [], + isLoadingPending: false, + currentView: 'pending' as MatchViewType, fetchMatches: async (refresh = false) => { const userId = useAuthStore.getState().user?.id; @@ -269,6 +289,9 @@ clearMatches: () => { archivedMatchIds: new Set(), showArchived: false, isLoadingArchived: false, + pendingQuestions: [], + isLoadingPending: false, + currentView: 'pending', }); }, @@ -447,4 +470,95 @@ clearMatches: () => { isMatchArchived: (matchId: string) => { return get().archivedMatchIds.has(matchId); }, + + // Pending (Your Turn) methods + fetchPendingQuestions: async () => { + const userId = useAuthStore.getState().user?.id; + const coupleId = useAuthStore.getState().user?.couple_id; + + if (!coupleId || !userId) { + set({ pendingQuestions: [], isLoadingPending: false }); + return; + } + + const state = get(); + if (state.isLoadingPending) return; + + set({ isLoadingPending: true }); + + try { + // Get partner's user_id + const { data: coupleProfiles } = await supabase + .from("profiles") + .select("id") + .eq("couple_id", coupleId) + .neq("id", userId); + + const partnerId = coupleProfiles?.[0]?.id; + if (!partnerId) { + set({ pendingQuestions: [], isLoadingPending: false }); + return; + } + + // Get questions the current user has already answered + const { data: userResponses } = await supabase + .from("responses") + .select("question_id") + .eq("user_id", userId) + .eq("couple_id", coupleId); + + const answeredQuestionIds = new Set(userResponses?.map(r => r.question_id) ?? []); + + // Get partner's responses (questions they answered that user hasn't) + const { data: partnerResponses, error } = await supabase + .from("responses") + .select(` + id, + question_id, + created_at, + question:questions( + *, + pack:question_packs(id, name, icon) + ) + `) + .eq("user_id", partnerId) + .eq("couple_id", coupleId) + .order("created_at", { ascending: false }); + + if (error) throw error; + + // Filter to only questions user hasn't answered, excluding deleted questions + // Note: Supabase returns single-object relations as arrays in type inference, but they're actually objects at runtime + const pendingQuestions: PendingQuestion[] = (partnerResponses ?? []) + .filter(r => { + const question = r.question as unknown as PendingQuestion['question'] | null; + return !answeredQuestionIds.has(r.question_id) && question && !(question as any).deleted_at; + }) + .map(r => ({ + id: r.id, + question: r.question as unknown as PendingQuestion['question'], + partnerAnsweredAt: r.created_at, + })); + + set({ pendingQuestions, isLoadingPending: false }); + } catch (err) { + console.error("Error fetching pending questions:", err); + set({ isLoadingPending: false }); + } + }, + + setCurrentView: (view: MatchViewType) => { + const state = get(); + + // Update showArchived for backwards compatibility + const showArchived = view === 'archived'; + set({ currentView: view, showArchived }); + + // Fetch data for the selected view if not already loaded + if (view === 'archived' && state.archivedMatches.length === 0) { + get().fetchArchivedMatches(); + } else if (view === 'pending' && state.pendingQuestions.length === 0) { + get().fetchPendingQuestions(); + } + }, })); diff --git a/apps/mobile/src/store/packsStore.ts b/apps/mobile/src/store/packsStore.ts index 580f685..233df82 100644 --- a/apps/mobile/src/store/packsStore.ts +++ b/apps/mobile/src/store/packsStore.ts @@ -4,51 +4,55 @@ import { Events } from "../lib/analytics"; import type { QuestionPack, Category } from "@/types"; import { useAuthStore } from "./authStore"; -const DEFAULT_MAX_INTENSITY = 2; +// Intensity thresholds derived from hide_nsfw for backwards compatibility +// When hide_nsfw=true, max_intensity=2 (mild content only) +// When hide_nsfw=false, max_intensity=5 (all content) +const NSFW_OFF_INTENSITY = 2; +const NSFW_ON_INTENSITY = 5; -const normalizeIntensity = (value?: number | null) => { - if (typeof value !== "number" || Number.isNaN(value)) return null; - const rounded = Math.round(value); - if (rounded < 1 || rounded > 5) return null; - return rounded; +const getMaxIntensityFromHideNsfw = (hideNsfw: boolean) => { + return hideNsfw ? NSFW_OFF_INTENSITY : NSFW_ON_INTENSITY; }; -const getUserMaxIntensity = () => { - const user = useAuthStore.getState().user; - const normalized = normalizeIntensity(user?.max_intensity ?? null); - if (normalized) return normalized; - return user?.show_explicit_content ? 5 : DEFAULT_MAX_INTENSITY; -}; +// Progress data for a pack +export interface PackProgressData { + totalQuestions: number; + answeredQuestions: number; +} interface PacksState { packs: QuestionPack[]; categories: Category[]; enabledPackIds: string[]; + packProgress: Map; isLoading: boolean; showAllIntensities: boolean; fetchPacks: () => Promise; fetchEnabledPacks: () => Promise; + fetchPackProgress: () => Promise; ensureEnabledPacksLoaded: () => Promise; togglePack: (packId: string) => Promise<{ success: boolean; reason?: string }>; setShowAllIntensities: (value: boolean) => void; clearPacks: () => void; + getPackProgress: (packId: string) => PackProgressData | undefined; } export const usePacksStore = create((set, get) => ({ packs: [], categories: [], enabledPackIds: [], + packProgress: new Map(), isLoading: false, showAllIntensities: false, fetchPacks: async () => { set({ isLoading: true }); - // Get user's max intensity preference - const maxIntensity = getUserMaxIntensity(); + // Get hide_nsfw preference and derive max_intensity from it + const hideNsfw = useAuthStore.getState().user?.hide_nsfw ?? false; + const maxIntensity = getMaxIntensityFromHideNsfw(hideNsfw); const showAllIntensities = get().showAllIntensities; - // Fetch categories const { data: categories } = await supabase .from("categories") @@ -71,6 +75,11 @@ export const usePacksStore = create((set, get) => ({ query = query.or(`max_intensity.is.null,max_intensity.lte.${maxIntensity}`); } + // Filter out explicit packs when user has hide_nsfw enabled + if (hideNsfw) { + query = query.eq("is_explicit", false); + } + const { data: packs } = await query; const visiblePacks = (packs ?? []).filter(pack => { @@ -89,6 +98,9 @@ export const usePacksStore = create((set, get) => ({ categories: visibleCategories, isLoading: false }); + + // Fetch progress after packs are loaded + get().fetchPackProgress(); }, fetchEnabledPacks: async () => { @@ -165,7 +177,61 @@ export const usePacksStore = create((set, get) => ({ get().fetchPacks(); }, + fetchPackProgress: async () => { + const userId = useAuthStore.getState().user?.id; + if (!userId) { + set({ packProgress: new Map() }); + return; + } + + const packs = get().packs; + if (packs.length === 0) return; + + // Fetch user's response counts grouped by pack + const { data: responseCounts, error } = await supabase + .from("responses") + .select("question:questions!inner(pack_id)") + .eq("user_id", userId); + + if (error) { + console.error("Error fetching pack progress:", error); + return; + } + + // Debug: log response counts + console.log("[PackProgress] userId:", userId); + console.log("[PackProgress] total responses:", responseCounts?.length ?? 0); + + // Count responses per pack + const responsesByPack = new Map(); + responseCounts?.forEach((r) => { + const packId = (r.question as any)?.pack_id; + if (packId) { + responsesByPack.set(packId, (responsesByPack.get(packId) || 0) + 1); + } + }); + + // Debug: log responses by pack + if (responsesByPack.size > 0) { + console.log("[PackProgress] responses by pack:", Object.fromEntries(responsesByPack)); + } + + // Build progress map + const progressMap = new Map(); + packs.forEach((pack) => { + const totalQuestions = pack.questions?.[0]?.count ?? 0; + const answeredQuestions = responsesByPack.get(pack.id) || 0; + progressMap.set(pack.id, { totalQuestions, answeredQuestions }); + }); + + set({ packProgress: progressMap }); + }, + + getPackProgress: (packId: string) => { + return get().packProgress.get(packId); + }, + clearPacks: () => { - set({ enabledPackIds: [] }); + set({ enabledPackIds: [], packProgress: new Map() }); }, })); diff --git a/apps/mobile/src/theme/index.ts b/apps/mobile/src/theme/index.ts index d1ed70b..231b304 100644 --- a/apps/mobile/src/theme/index.ts +++ b/apps/mobile/src/theme/index.ts @@ -36,11 +36,11 @@ export const colors = { successLight: 'rgba(46, 204, 113, 0.15)', warning: '#F39C12', warningLight: 'rgba(243, 156, 18, 0.15)', - error: '#E74C3C', - errorLight: 'rgba(231, 76, 60, 0.15)', - info: '#53bdeb', // WhatsApp-like blue for read receipts - - // Premium/Luxury + error: '#E74C3C', + errorLight: 'rgba(231, 76, 60, 0.15)', + info: '#53bdeb', // WhatsApp-like blue for read receipts + + // Premium/Luxury premium: { gold: '#D4AF37', goldDark: '#B8860B', @@ -94,6 +94,64 @@ export const gradients = { // Feature-specific color mapping // Each feature has a consistent accent color for icons, buttons, and highlights +// Tile colors for discovery screen - vibrant solid colors +export const tileColors = { + teal: '#14B8A6', + purple: '#8B5CF6', + orange: '#F97316', + coral: '#F87171', + emerald: '#10B981', + indigo: '#6366F1', + rose: '#EC4899', + amber: '#F59E0B', +}; + +// Map categories to tile colors +export const categoryColorMap: Record = { + // Common category names (lowercase for matching) + passion: tileColors.coral, + connection: tileColors.teal, + fantasy: tileColors.purple, + adventure: tileColors.orange, + starter: tileColors.emerald, + romance: tileColors.rose, + intimacy: tileColors.indigo, + communication: tileColors.amber, +}; + +// Get color for a category (with fallback) +// Accepts either a Category object or just the category name for backwards compatibility +export const getCategoryColor = ( + category?: { name?: string | null; color?: string | null } | string | null +): string => { + // Handle string input (backwards compatibility) + if (typeof category === 'string') { + return getCategoryColorFromName(category); + } + + // If category has an explicit color set, use it + if (category?.color) { + return category.color; + } + + // Fall back to name-based logic + return getCategoryColorFromName(category?.name); +}; + +// Internal helper: derive color from category name +const getCategoryColorFromName = (categoryName?: string | null): string => { + if (!categoryName) return tileColors.teal; + const normalizedName = categoryName.toLowerCase(); + // Check for partial matches + for (const [key, color] of Object.entries(categoryColorMap)) { + if (normalizedName.includes(key)) return color; + } + // Fallback: use consistent color based on string hash + const hash = normalizedName.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); + const colorKeys = Object.keys(tileColors) as (keyof typeof tileColors)[]; + return tileColors[colorKeys[hash % colorKeys.length]]; +}; + export const featureColors = { swipe: { accent: colors.primary, @@ -288,6 +346,9 @@ export const theme = { colors, gradients, featureColors, + tileColors, + categoryColorMap, + getCategoryColor, spacing, radius, typography, diff --git a/apps/mobile/src/types/index.ts b/apps/mobile/src/types/index.ts index f8cf471..18b9ea9 100755 --- a/apps/mobile/src/types/index.ts +++ b/apps/mobile/src/types/index.ts @@ -13,6 +13,7 @@ export interface Profile { gender: Gender | null; show_explicit_content: boolean; max_intensity: IntensityLevel; + hide_nsfw: boolean; onboarding_completed: boolean; created_at: string; updated_at: string; @@ -33,6 +34,7 @@ export interface Category { name: string; description: string | null; icon: string | null; + color: string | null; sort_order: number; created_at: string; is_public: boolean; diff --git a/apps/supabase/migrations/20260125000000_add_hide_nsfw_to_profiles.sql b/apps/supabase/migrations/20260125000000_add_hide_nsfw_to_profiles.sql new file mode 100644 index 0000000..5bedd70 --- /dev/null +++ b/apps/supabase/migrations/20260125000000_add_hide_nsfw_to_profiles.sql @@ -0,0 +1,9 @@ +-- Add hide_nsfw column to profiles table +-- When true, packs marked as is_explicit will be hidden from the user +-- Independent of max_intensity setting + +ALTER TABLE profiles +ADD COLUMN IF NOT EXISTS hide_nsfw BOOLEAN DEFAULT false; + +COMMENT ON COLUMN profiles.hide_nsfw IS + 'When true, packs marked as is_explicit will be hidden. Independent of max_intensity.'; diff --git a/apps/supabase/migrations/20260125100315_add_color_to_categories.sql b/apps/supabase/migrations/20260125100315_add_color_to_categories.sql new file mode 100644 index 0000000..3c1717c --- /dev/null +++ b/apps/supabase/migrations/20260125100315_add_color_to_categories.sql @@ -0,0 +1,7 @@ +-- Add color column to categories table +-- Allows explicit color assignment; app falls back to name-based color logic if NULL + +ALTER TABLE categories +ADD COLUMN color text; + +COMMENT ON COLUMN categories.color IS 'Hex color code (e.g. #14B8A6) for category tiles. Falls back to app logic if NULL.'; diff --git a/apps/supabase/supabase/migrations/20260125100315_add_color_to_categories.sql b/apps/supabase/supabase/migrations/20260125100315_add_color_to_categories.sql new file mode 100644 index 0000000..e69de29 diff --git a/package-lock.json b/package-lock.json index 4f7d492..c9166fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -119,6 +119,7 @@ "react-native-reanimated": "~3.16.0", "react-native-safe-area-context": "4.12.0", "react-native-screens": "~4.4.0", + "react-native-svg": "15.8.0", "react-native-url-polyfill": "^2.0.0", "react-native-web": "~0.19.13", "zustand": "^4.4.7" @@ -137,6 +138,21 @@ "typescript": "^5.3.3" } }, + "apps/mobile/node_modules/react-native-svg": { + "version": "15.8.0", + "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.8.0.tgz", + "integrity": "sha512-KHJzKpgOjwj1qeZzsBjxNdoIgv2zNCO9fVcoq2TEhTRsVV5DGTZ9JzUZwybd7q4giT/H3RdtqC3u44dWdO0Ffw==", + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "css-tree": "^1.1.3", + "warn-once": "0.1.1" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "apps/supabase": { "name": "@sauci/supabase", "version": "1.0.0" @@ -10319,6 +10335,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, "node_modules/bplist-creator": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.0.7.tgz", @@ -11306,6 +11328,47 @@ "hyphenate-style-name": "^1.0.3" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -11789,6 +11852,44 @@ "dev": true, "license": "MIT" }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, "node_modules/domexception": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", @@ -11803,6 +11904,35 @@ "node": ">=12" } }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "17.2.3", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", @@ -17143,6 +17273,12 @@ "node": ">=0.10" } }, + "node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "license": "CC0-1.0" + }, "node_modules/memoize-one": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", @@ -18031,6 +18167,18 @@ "node": ">=4" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/nullthrows": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 941e7dd..bc73c7b 100755 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -10,6 +10,8 @@ export interface Profile { updated_at: string; /** RSA public key in JWK format for E2EE */ public_key_jwk?: Record | null; + /** When true, packs marked as is_explicit will be hidden */ + hide_nsfw?: boolean; } // Couple pairing