Add intensity-based preferences and harden E2EE payload verification

This commit is contained in:
jjbrunton committed 2026-01-07 19:53:09 +00:00
1 parent e23f4557bb
commit 0a919f06fc
20 files changed
+2033 -251

No files matched your search

+37 -110
View File
@@ -25,13 +25,14 @@ import Animated, {
import { GradientBackground } from '../../src/components/ui/GradientBackground'; import { GradientBackground } from '../../src/components/ui/GradientBackground';
import { GlassCard } from '../../src/components/ui/GlassCard'; import { GlassCard } from '../../src/components/ui/GlassCard';
import { GlassButton } from '../../src/components/ui/GlassButton'; import { GlassButton } from '../../src/components/ui/GlassButton';
import { IntensitySlider } from '../../src/components/ui/IntensitySlider';
import { colors, gradients, spacing, typography, radius, shadows } from '../../src/theme'; import { colors, gradients, spacing, typography, radius, shadows } from '../../src/theme';
import { useAuthStore } from '../../src/store'; import { useAuthStore } from '../../src/store';
import { supabase } from '../../src/lib/supabase'; import { supabase } from '../../src/lib/supabase';
import { getProfileError } from '../../src/lib/errors'; import { getProfileError } from '../../src/lib/errors';
import { registerForPushNotificationsAsync, savePushToken } from '../../src/lib/notifications'; import { registerForPushNotificationsAsync, savePushToken } from '../../src/lib/notifications';
import { Events } from '../../src/lib/analytics'; import { Events } from '../../src/lib/analytics';
import type { Gender } from '../../src/types'; import type { Gender, IntensityLevel } from '../../src/types';
const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [ const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
{ value: 'male', label: 'Male', icon: 'male' }, { value: 'male', label: 'Male', icon: 'male' },
@@ -50,6 +51,19 @@ const PURPOSE_OPTIONS: { value: UsageReason; label: string; icon: string }[] = [
{ value: 'strengthen_relationship', label: 'Strengthen our relationship', icon: 'shield-checkmark' }, { value: 'strengthen_relationship', label: 'Strengthen our relationship', icon: 'shield-checkmark' },
]; ];
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 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 = 'name' | 'gender' | 'purpose' | 'explicit' | 'notifications'; type Stage = 'name' | 'gender' | 'purpose' | 'explicit' | 'notifications';
export default function OnboardingScreen() { export default function OnboardingScreen() {
@@ -59,7 +73,7 @@ export default function OnboardingScreen() {
const [name, setName] = useState(user?.name || ''); const [name, setName] = useState(user?.name || '');
const [gender, setGender] = useState<Gender | null>(user?.gender || null); const [gender, setGender] = useState<Gender | null>(user?.gender || null);
const [usageReason, setUsageReason] = useState<UsageReason | null>(null); const [usageReason, setUsageReason] = useState<UsageReason | null>(null);
const [showExplicit, setShowExplicit] = useState(user?.show_explicit_content ?? true); const [maxIntensity, setMaxIntensity] = useState<IntensityLevel>(() => getInitialIntensity(user));
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const inputRef = useRef<TextInput>(null); const inputRef = useRef<TextInput>(null);
@@ -107,7 +121,7 @@ export default function OnboardingScreen() {
Events.onboardingStageComplete('purpose'); Events.onboardingStageComplete('purpose');
}; };
const handleExplicitContinue = () => { const handleIntensityContinue = () => {
setStage('notifications'); setStage('notifications');
Events.onboardingStageComplete('explicit'); Events.onboardingStageComplete('explicit');
}; };
@@ -139,13 +153,16 @@ export default function OnboardingScreen() {
setError(null); setError(null);
try { try {
const showExplicitContent = maxIntensity >= 3;
const { error: updateError } = await supabase const { error: updateError } = await supabase
.from('profiles') .from('profiles')
.update({ .update({
name: name.trim(), name: name.trim(),
gender, gender,
usage_reason: usageReason, usage_reason: usageReason,
show_explicit_content: showExplicit, max_intensity: maxIntensity,
show_explicit_content: showExplicitContent,
onboarding_completed: true, onboarding_completed: true,
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
}) })
@@ -159,7 +176,7 @@ export default function OnboardingScreen() {
console.log('[Onboarding] Profile updated successfully, fetching user...'); console.log('[Onboarding] Profile updated successfully, fetching user...');
await fetchUser(); await fetchUser();
console.log('[Onboarding] User fetched, navigating to home...'); console.log('[Onboarding] User fetched, navigating to home...');
Events.profileUpdated(["name", "gender", "usage_reason", "show_explicit_content"]); Events.profileUpdated(["name", "gender", "usage_reason", "max_intensity", "show_explicit_content"]);
Events.onboardingComplete(); Events.onboardingComplete();
router.replace('/'); router.replace('/');
} catch (err: any) { } catch (err: any) {
@@ -379,81 +396,22 @@ export default function OnboardingScreen() {
colors={gradients.primary as [string, string]} colors={gradients.primary as [string, string]}
style={styles.iconContainer} style={styles.iconContainer}
> >
<Ionicons name="flame" size={40} color={colors.text} /> <Ionicons name="heart-circle" size={44} color={colors.text} />
</LinearGradient> </LinearGradient>
<Text style={styles.title}>Almost there!</Text> <Text style={styles.title}>Set Your Comfort Zone</Text>
<Text style={styles.subtitle}> <Text style={styles.subtitle}>
Shall we keep it clean or show you everything? Choose the intimacy level you're comfortable with
</Text> </Text>
</View> </View>
<GlassCard style={styles.card}> <GlassCard style={styles.card}>
<Pressable <IntensitySlider
style={styles.explicitOption} value={maxIntensity}
onPress={() => setShowExplicit(true)} onValueChange={setMaxIntensity}
> />
<View style={[ <Text style={styles.intensityNote}>
styles.explicitIcon, Start where you're comfortable — you can always adjust later
showExplicit && styles.explicitIconSelected </Text>
]}>
<Ionicons
name="flame"
size={32}
color={showExplicit ? colors.primary : colors.textSecondary}
/>
</View>
<View style={styles.explicitText}>
<Text style={[
styles.explicitTitle,
showExplicit && styles.explicitTitleSelected
]}>
Show me everything
</Text>
<Text style={styles.explicitDescription}>
Include spicy question packs
</Text>
</View>
<View style={[
styles.radioOuter,
showExplicit && styles.radioOuterSelected
]}>
{showExplicit && <View style={styles.radioInner} />}
</View>
</Pressable>
<Pressable
style={styles.explicitOption}
onPress={() => setShowExplicit(false)}
>
<View style={[
styles.explicitIcon,
!showExplicit && styles.explicitIconSelected
]}>
<Ionicons
name="heart"
size={32}
color={!showExplicit ? colors.primary : colors.textSecondary}
/>
</View>
<View style={styles.explicitText}>
<Text style={[
styles.explicitTitle,
!showExplicit && styles.explicitTitleSelected
]}>
Keep it clean
</Text>
<Text style={styles.explicitDescription}>
Only show SFW content
</Text>
</View>
<View style={[
styles.radioOuter,
!showExplicit && styles.radioOuterSelected
]}>
{!showExplicit && <View style={styles.radioInner} />}
</View>
</Pressable>
{error && ( {error && (
<View style={styles.errorContainer}> <View style={styles.errorContainer}>
<Ionicons name="alert-circle" size={16} color={colors.error} /> <Ionicons name="alert-circle" size={16} color={colors.error} />
@@ -464,7 +422,7 @@ export default function OnboardingScreen() {
<View style={styles.footer}> <View style={styles.footer}>
<GlassButton <GlassButton
onPress={handleExplicitContinue} onPress={handleIntensityContinue}
fullWidth fullWidth
size="lg" size="lg"
> >
@@ -762,42 +720,11 @@ const styles = StyleSheet.create({
color: colors.text, color: colors.text,
fontWeight: '600', fontWeight: '600',
}, },
explicitOption: { intensityNote: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.glass.background,
borderRadius: radius.lg,
borderWidth: 2,
borderColor: colors.glass.border,
padding: spacing.md,
marginBottom: spacing.sm,
},
explicitIcon: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: colors.glass.background,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.md,
},
explicitIconSelected: {
backgroundColor: colors.primaryLight,
},
explicitText: {
flex: 1,
},
explicitTitle: {
...typography.headline,
color: colors.textSecondary,
marginBottom: spacing.xs,
},
explicitTitleSelected: {
color: colors.text,
},
explicitDescription: {
...typography.caption1, ...typography.caption1,
color: colors.textTertiary, color: colors.textSecondary,
textAlign: 'center',
marginTop: spacing.md,
}, },
radioOuter: { radioOuter: {
width: 24, width: 24,
+75 -15
View File
@@ -20,9 +20,38 @@ import { PackTeaser } from "../../../src/components/PackTeaser";
import { colors, spacing, radius, typography } from "../../../src/theme"; import { colors, spacing, radius, typography } from "../../../src/theme";
import type { QuestionPack, Category } from "../../../src/types"; import type { QuestionPack, Category } from "../../../src/types";
const HEADER_MAX_HEIGHT = 180; const HEADER_MAX_HEIGHT = 180;
const HEADER_MIN_HEIGHT = 100; const HEADER_MIN_HEIGHT = 100;
const HEADER_SCROLL_DISTANCE = HEADER_MAX_HEIGHT - HEADER_MIN_HEIGHT; const HEADER_SCROLL_DISTANCE = HEADER_MAX_HEIGHT - HEADER_MIN_HEIGHT;
const INTENSITY_LABELS: Record<number, string> = {
1: "Light",
2: "Mild",
3: "Moderate",
4: "Spicy",
5: "Intense",
};
const clampIntensity = (value: number) => Math.min(5, Math.max(1, Math.round(value)));
const isValidIntensity = (value?: number | null): value is number =>
typeof value === "number" && value >= 1 && value <= 5;
const getIntensityDisplay = (pack: QuestionPack) => {
const min = isValidIntensity(pack.min_intensity) ? pack.min_intensity : null;
const max = isValidIntensity(pack.max_intensity) ? pack.max_intensity : null;
const avg = typeof pack.avg_intensity === "number" ? clampIntensity(pack.avg_intensity) : null;
const flameCount = max ?? avg ?? (pack.is_explicit ? 5 : 2);
const label = min && max
? min === max
? INTENSITY_LABELS[min]
: `${INTENSITY_LABELS[min]} - ${INTENSITY_LABELS[max]}`
: INTENSITY_LABELS[flameCount];
return { flameCount, label };
};
interface PackRowProps { interface PackRowProps {
pack: QuestionPack; pack: QuestionPack;
@@ -34,8 +63,10 @@ interface PackRowProps {
onInfoPress: () => void; onInfoPress: () => void;
} }
function PackRow({ pack, isEnabled, isPremiumLocked, isExpanded, onToggle, onPress, onInfoPress }: PackRowProps) { function PackRow({ pack, isEnabled, isPremiumLocked, isExpanded, onToggle, onPress, onInfoPress }: PackRowProps) {
const questionCount = pack.questions?.[0]?.count || 0; const questionCount = pack.questions?.[0]?.count || 0;
const intensityDisplay = getIntensityDisplay(pack);
return ( return (
<Pressable onPress={onPress} style={styles.packRow}> <Pressable onPress={onPress} style={styles.packRow}>
@@ -58,11 +89,25 @@ function PackRow({ pack, isEnabled, isPremiumLocked, isExpanded, onToggle, onPre
</View> </View>
)} )}
</View> </View>
<Text style={styles.packMeta}> <Text style={styles.packMeta}>
{questionCount} {questionCount === 1 ? "question" : "questions"} {questionCount} {questionCount === 1 ? "question" : "questions"}
{isEnabled && <Text style={styles.packMetaEnabled}> · Enabled</Text>} {isEnabled && <Text style={styles.packMetaEnabled}> · Enabled</Text>}
</Text> </Text>
</View> <View style={styles.packIntensityRow}>
<View style={styles.packIntensityFlames}>
{Array.from({ length: 5 }).map((_, index) => (
<Ionicons
key={`intensity-${pack.id}-${index}`}
name={index < intensityDisplay.flameCount ? "flame" : "flame-outline"}
size={12}
color={index < intensityDisplay.flameCount ? colors.primary : colors.textTertiary}
/>
))}
</View>
<Text style={styles.packIntensityText}>{intensityDisplay.label}</Text>
</View>
</View>
<Ionicons <Ionicons
name={isExpanded ? "chevron-up" : "chevron-down"} name={isExpanded ? "chevron-up" : "chevron-down"}
@@ -670,11 +715,26 @@ const styles = StyleSheet.create({
color: colors.textTertiary, color: colors.textTertiary,
marginTop: 2, marginTop: 2,
}, },
packMetaEnabled: { packMetaEnabled: {
color: colors.primary, color: colors.primary,
}, },
packIntensityRow: {
// Expanded Pack flexDirection: "row",
alignItems: "center",
gap: 6,
marginTop: spacing.xs,
},
packIntensityFlames: {
flexDirection: "row",
gap: 2,
},
packIntensityText: {
...typography.caption2,
color: colors.textSecondary,
fontWeight: "600",
},
// Expanded Pack
packExpanded: { packExpanded: {
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
paddingBottom: spacing.md, paddingBottom: spacing.md,
@@ -6,6 +6,7 @@ function createThenableQuery(result: any) {
const query: any = { const query: any = {
select: jest.fn(() => query), select: jest.fn(() => query),
eq: jest.fn(() => query), eq: jest.fn(() => query),
or: jest.fn(() => query),
order: jest.fn(() => query), order: jest.fn(() => query),
upsert: jest.fn(() => query), upsert: jest.fn(() => query),
then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject), then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject),
@@ -17,10 +18,10 @@ describe('packsStore', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
usePacksStore.setState({ packs: [], categories: [], enabledPackIds: [], isLoading: false } as any); usePacksStore.setState({ packs: [], categories: [], enabledPackIds: [], isLoading: false } as any);
useAuthStore.setState({ user: { id: 'me', couple_id: 'c1', show_explicit_content: false } } as any); useAuthStore.setState({ user: { id: 'me', couple_id: 'c1', max_intensity: 2, show_explicit_content: false } } as any);
}); });
it('fetches categories/packs and filters explicit when preference disabled', async () => { it('fetches categories/packs and filters by max intensity', async () => {
const categoriesQuery = createThenableQuery({ data: [{ id: 'cat1' }] }); const categoriesQuery = createThenableQuery({ data: [{ id: 'cat1' }] });
const packsQuery = createThenableQuery({ data: [{ id: 'pack1', is_explicit: false }] }); const packsQuery = createThenableQuery({ data: [{ id: 'pack1', is_explicit: false }] });
const enabledQuery = createThenableQuery({ data: [{ pack_id: 'pack1' }] }); const enabledQuery = createThenableQuery({ data: [{ pack_id: 'pack1' }] });
@@ -32,8 +33,7 @@ describe('packsStore', () => {
await usePacksStore.getState().fetchPacks(); await usePacksStore.getState().fetchPacks();
// Ensure explicit filter applied expect(packsQuery.or).toHaveBeenCalledWith('max_intensity.is.null,max_intensity.lte.2');
expect(packsQuery.eq).toHaveBeenCalledWith('is_explicit', false);
const state = usePacksStore.getState(); const state = usePacksStore.getState();
expect(state.categories).toEqual([{ id: 'cat1' }]); expect(state.categories).toEqual([{ id: 'cat1' }]);
@@ -0,0 +1,646 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable, Platform, Image, Modal, TouchableOpacity } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { BlurView } from 'expo-blur';
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
FadeIn,
FadeOut,
} from 'react-native-reanimated';
import { colors, spacing, radius, typography } from '../../theme';
import type { IntensityLevel } from '@/types';
export interface IntensitySliderProps {
value: IntensityLevel;
onValueChange: (value: IntensityLevel) => void;
disabled?: boolean;
/** Partner's intensity level (shows as a ghost indicator) */
partnerValue?: IntensityLevel | null;
/** Partner's name for the mismatch message */
partnerName?: string;
/** Partner's avatar URL */
partnerAvatar?: string | null;
}
const INTENSITY_LEVELS: Array<{ level: IntensityLevel; label: string; emoji: string; description: string }> = [
{ level: 1, label: 'Gentle', emoji: '💭', description: 'Conversations & emotional connection' },
{ level: 2, label: 'Warm', emoji: '💕', description: 'Cuddling, kisses & flirty moments' },
{ level: 3, label: 'Playful', emoji: '😏', description: 'Making out & suggestive content' },
{ level: 4, label: 'Steamy', emoji: '🔥', description: 'Intimate activities & explicit content' },
{ level: 5, label: 'Intense', emoji: '🌶️', description: 'Advanced intimacy & fantasies' },
];
const HEAT_COLORS = [
'#9b59b6', // Purple - gentle
'#e94560', // Rose - warm
'#ff6b6b', // Coral - playful
'#ff4757', // Red - steamy
'#ff3333', // Bright red - intense
];
const triggerHaptic = async () => {
if (Platform.OS === 'web') return;
const Haptics = await import('expo-haptics');
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
};
export function IntensitySlider({
value,
onValueChange,
disabled = false,
partnerValue,
partnerName,
partnerAvatar,
}: IntensitySliderProps) {
const current = INTENSITY_LEVELS[value - 1] ?? INTENSITY_LEVELS[1];
const progress = useSharedValue((value - 1) / 4);
const [showInfoModal, setShowInfoModal] = useState(false);
const hasPartner = partnerValue != null;
const partnerLevel = hasPartner ? INTENSITY_LEVELS[partnerValue - 1] : null;
const levelsDiffer = hasPartner && value !== partnerValue;
const userIsHigher = hasPartner && value > partnerValue;
const displayPartnerName = partnerName || 'Your partner';
useEffect(() => {
progress.value = withSpring((value - 1) / 4, { damping: 15, stiffness: 150 });
}, [value]);
const handleSelect = async (nextValue: IntensityLevel) => {
if (disabled || nextValue === value) return;
await triggerHaptic();
onValueChange(nextValue);
};
const progressStyle = useAnimatedStyle(() => ({
width: `${progress.value * 100}%`,
}));
const getMismatchMessage = () => {
if (!levelsDiffer || !partnerLevel) return null;
if (userIsHigher) {
return `${displayPartnerName} is at ${partnerLevel.label} — tap for details`;
}
return `${displayPartnerName} is at ${partnerLevel.label}`;
};
return (
<View style={[styles.container, disabled && styles.containerDisabled]}>
{/* Progress track */}
<View style={styles.trackContainer}>
<View style={styles.track}>
<Animated.View style={[styles.progressFill, progressStyle]}>
<LinearGradient
colors={['#9b59b6', HEAT_COLORS[value - 1]]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={StyleSheet.absoluteFill}
/>
</Animated.View>
</View>
</View>
{/* Level indicators */}
<View style={styles.levelsRow}>
{INTENSITY_LEVELS.map((level) => {
const isSelected = value === level.level;
const isPassed = value > level.level;
const isActive = isSelected || isPassed;
const isPartnerLevel = hasPartner && partnerValue === level.level;
return (
<Pressable
key={level.level}
style={styles.levelButton}
onPress={() => handleSelect(level.level)}
disabled={disabled}
>
<View style={styles.indicatorWrapper}>
{/* Partner indicator ring */}
{isPartnerLevel && !isSelected && (
<View style={styles.partnerRing}>
{partnerAvatar ? (
<Image
source={{ uri: partnerAvatar }}
style={styles.partnerAvatarSmall}
/>
) : (
<Ionicons name="heart" size={8} color={colors.secondary} />
)}
</View>
)}
<View style={[
styles.levelIndicator,
isActive && styles.levelIndicatorActive,
isSelected && {
backgroundColor: HEAT_COLORS[level.level - 1],
borderColor: HEAT_COLORS[level.level - 1],
},
isPartnerLevel && !isSelected && styles.levelIndicatorPartner,
]}>
{isSelected ? (
<Text style={styles.levelEmoji}>{level.emoji}</Text>
) : (
<Ionicons
name={isActive ? 'flame' : 'flame-outline'}
size={16}
color={isActive ? colors.text : colors.textTertiary}
/>
)}
</View>
</View>
<Text style={[
styles.levelLabel,
isSelected && styles.levelLabelActive,
isPartnerLevel && !isSelected && styles.levelLabelPartner,
]}>
{level.label}
</Text>
</Pressable>
);
})}
</View>
{/* Current level card */}
<View style={[styles.currentCard, levelsDiffer && userIsHigher && styles.currentCardWarning]}>
<View style={styles.currentHeader}>
<Text style={styles.currentEmoji}>{current.emoji}</Text>
<Text style={styles.currentLabel}>{current.label}</Text>
{hasPartner && !levelsDiffer && (
<Pressable
style={styles.matchBadge}
onPress={() => setShowInfoModal(true)}
>
<Ionicons name="checkmark-circle" size={14} color={colors.success} />
<Text style={styles.matchBadgeText}>Matched</Text>
<Ionicons name="information-circle-outline" size={12} color={colors.success} style={{ marginLeft: 2 }} />
</Pressable>
)}
</View>
<Text style={styles.currentDescription}>{current.description}</Text>
{/* Mismatch message */}
{levelsDiffer && (
<Pressable
style={styles.mismatchRow}
onPress={() => setShowInfoModal(true)}
>
{partnerAvatar ? (
<Image source={{ uri: partnerAvatar }} style={styles.partnerAvatarTiny} />
) : (
<View style={styles.partnerIconTiny}>
<Ionicons name="person" size={10} color={colors.secondary} />
</View>
)}
<Text style={[
styles.mismatchText,
userIsHigher && styles.mismatchTextWarning,
]}>
{getMismatchMessage()}
</Text>
<Ionicons
name="chevron-forward"
size={14}
color={userIsHigher ? colors.warning : colors.textSecondary}
/>
</Pressable>
)}
</View>
{/* Info Modal */}
<Modal
visible={showInfoModal}
transparent
animationType="fade"
onRequestClose={() => setShowInfoModal(false)}
>
<Pressable
style={styles.modalOverlay}
onPress={() => setShowInfoModal(false)}
>
<Pressable style={styles.modalContent} onPress={e => e.stopPropagation()}>
{Platform.OS === 'ios' ? (
<BlurView intensity={40} tint="dark" style={StyleSheet.absoluteFill} />
) : null}
<View style={styles.modalInner}>
{/* Header */}
<View style={styles.modalHeader}>
{levelsDiffer ? (
<LinearGradient
colors={userIsHigher ? ['#F39C12', '#E67E22'] : [colors.secondary, colors.primary]}
style={styles.modalIconGradient}
>
<Ionicons
name={userIsHigher ? "alert-circle" : "information-circle"}
size={28}
color={colors.text}
/>
</LinearGradient>
) : (
<LinearGradient
colors={[colors.success, '#27AE60']}
style={styles.modalIconGradient}
>
<Ionicons name="heart" size={28} color={colors.text} />
</LinearGradient>
)}
<Text style={styles.modalTitle}>
{levelsDiffer
? (userIsHigher ? "Different Comfort Zones" : "You're in Sync")
: "You're Perfectly Matched"
}
</Text>
</View>
{/* Comparison visual */}
{hasPartner && (
<View style={styles.comparisonContainer}>
<View style={styles.comparisonPerson}>
<View style={[styles.comparisonAvatar, { backgroundColor: HEAT_COLORS[value - 1] }]}>
<Text style={styles.comparisonEmoji}>{current.emoji}</Text>
</View>
<Text style={styles.comparisonName}>You</Text>
<Text style={styles.comparisonLevel}>{current.label}</Text>
</View>
<View style={styles.comparisonDivider}>
<Ionicons
name={levelsDiffer ? "swap-horizontal" : "heart"}
size={20}
color={levelsDiffer ? colors.textTertiary : colors.success}
/>
</View>
<View style={styles.comparisonPerson}>
{partnerAvatar ? (
<Image source={{ uri: partnerAvatar }} style={styles.comparisonAvatarImage} />
) : (
<View style={[styles.comparisonAvatar, { backgroundColor: HEAT_COLORS[(partnerValue ?? 1) - 1] }]}>
<Text style={styles.comparisonEmoji}>{partnerLevel?.emoji}</Text>
</View>
)}
<Text style={styles.comparisonName}>{displayPartnerName}</Text>
<Text style={styles.comparisonLevel}>{partnerLevel?.label}</Text>
</View>
</View>
)}
{/* Explanation */}
<View style={styles.modalExplanation}>
{!levelsDiffer ? (
<>
<Text style={styles.modalText}>
You and {displayPartnerName} have the same comfort zone setting.
You'll both see the same question packs and can match on everything!
</Text>
<View style={styles.modalTip}>
<Ionicons name="sparkles" size={16} color={colors.success} />
<Text style={styles.modalTipText}>
Perfect alignment means more matches
</Text>
</View>
</>
) : userIsHigher ? (
<>
<Text style={styles.modalText}>
Your comfort zone is set higher than {displayPartnerName}'s.
This means some questions you see won't appear for them.
</Text>
<View style={[styles.modalTip, styles.modalTipWarning]}>
<Ionicons name="information-circle" size={16} color={colors.warning} />
<Text style={[styles.modalTipText, styles.modalTipTextWarning]}>
Swipes on {current.label} content won't match since {displayPartnerName} is at {partnerLevel?.label}
</Text>
</View>
<Text style={styles.modalSubtext}>
Consider aligning your levels, or enjoy different paces — it's your choice!
</Text>
</>
) : (
<>
<Text style={styles.modalText}>
{displayPartnerName}'s comfort zone is higher than yours.
They might swipe on questions you don't see.
</Text>
<View style={styles.modalTip}>
<Ionicons name="shield-checkmark" size={16} color={colors.secondary} />
<Text style={styles.modalTipText}>
You'll only see content within your comfort zone
</Text>
</View>
</>
)}
</View>
{/* Close button */}
<TouchableOpacity
style={styles.modalCloseButton}
onPress={() => setShowInfoModal(false)}
>
<Text style={styles.modalCloseText}>Got it</Text>
</TouchableOpacity>
</View>
</Pressable>
</Pressable>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
container: {
gap: spacing.md,
},
containerDisabled: {
opacity: 0.6,
},
trackContainer: {
paddingHorizontal: spacing.lg,
},
track: {
height: 4,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
borderRadius: 2,
overflow: 'hidden',
},
progressFill: {
height: '100%',
borderRadius: 2,
overflow: 'hidden',
},
levelsRow: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingHorizontal: spacing.xs,
},
levelButton: {
alignItems: 'center',
flex: 1,
},
indicatorWrapper: {
position: 'relative',
marginBottom: spacing.xs,
},
levelIndicator: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderWidth: 2,
borderColor: 'rgba(255, 255, 255, 0.1)',
},
levelIndicatorActive: {
backgroundColor: 'rgba(233, 69, 96, 0.15)',
borderColor: 'rgba(233, 69, 96, 0.3)',
},
levelIndicatorPartner: {
borderColor: colors.secondary,
borderStyle: 'dashed',
},
partnerRing: {
position: 'absolute',
top: -6,
right: -6,
width: 18,
height: 18,
borderRadius: 9,
backgroundColor: colors.secondary,
alignItems: 'center',
justifyContent: 'center',
zIndex: 1,
borderWidth: 2,
borderColor: colors.backgroundLight,
},
partnerAvatarSmall: {
width: 14,
height: 14,
borderRadius: 7,
},
levelEmoji: {
fontSize: 16,
},
levelLabel: {
...typography.caption2,
color: colors.textTertiary,
textAlign: 'center',
},
levelLabelActive: {
color: colors.text,
fontWeight: '600',
},
levelLabelPartner: {
color: colors.secondary,
},
currentCard: {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderRadius: radius.md,
padding: spacing.md,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.08)',
},
currentCardWarning: {
borderColor: 'rgba(243, 156, 18, 0.3)',
backgroundColor: 'rgba(243, 156, 18, 0.05)',
},
currentHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginBottom: spacing.xs,
},
currentEmoji: {
fontSize: 20,
},
currentLabel: {
...typography.subhead,
fontWeight: '600',
color: colors.text,
},
matchBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
marginLeft: 'auto',
backgroundColor: 'rgba(46, 204, 113, 0.15)',
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderRadius: radius.full,
},
matchBadgeText: {
...typography.caption2,
color: colors.success,
fontWeight: '600',
},
currentDescription: {
...typography.caption1,
color: colors.textSecondary,
lineHeight: 18,
},
mismatchRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginTop: spacing.sm,
paddingTop: spacing.sm,
borderTopWidth: 1,
borderTopColor: 'rgba(255, 255, 255, 0.08)',
},
partnerAvatarTiny: {
width: 20,
height: 20,
borderRadius: 10,
},
partnerIconTiny: {
width: 20,
height: 20,
borderRadius: 10,
backgroundColor: 'rgba(155, 89, 182, 0.2)',
alignItems: 'center',
justifyContent: 'center',
},
mismatchText: {
...typography.caption1,
color: colors.textSecondary,
flex: 1,
},
mismatchTextWarning: {
color: colors.warning,
},
// Modal styles
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
justifyContent: 'center',
alignItems: 'center',
padding: spacing.lg,
},
modalContent: {
width: '100%',
maxWidth: 360,
borderRadius: radius.xl,
overflow: 'hidden',
backgroundColor: Platform.OS === 'ios' ? 'transparent' : colors.backgroundLight,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
},
modalInner: {
padding: spacing.lg,
backgroundColor: Platform.OS === 'ios' ? 'rgba(26, 26, 46, 0.85)' : 'transparent',
},
modalHeader: {
alignItems: 'center',
marginBottom: spacing.lg,
},
modalIconGradient: {
width: 56,
height: 56,
borderRadius: 28,
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.md,
},
modalTitle: {
...typography.title3,
color: colors.text,
textAlign: 'center',
},
comparisonContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderRadius: radius.lg,
padding: spacing.md,
marginBottom: spacing.lg,
},
comparisonPerson: {
alignItems: 'center',
flex: 1,
},
comparisonAvatar: {
width: 48,
height: 48,
borderRadius: 24,
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.xs,
},
comparisonAvatarImage: {
width: 48,
height: 48,
borderRadius: 24,
marginBottom: spacing.xs,
},
comparisonEmoji: {
fontSize: 24,
},
comparisonName: {
...typography.caption1,
color: colors.text,
fontWeight: '600',
},
comparisonLevel: {
...typography.caption2,
color: colors.textSecondary,
},
comparisonDivider: {
width: 40,
alignItems: 'center',
},
modalExplanation: {
marginBottom: spacing.lg,
},
modalText: {
...typography.subhead,
color: colors.textSecondary,
textAlign: 'center',
lineHeight: 22,
marginBottom: spacing.md,
},
modalSubtext: {
...typography.caption1,
color: colors.textTertiary,
textAlign: 'center',
marginTop: spacing.sm,
},
modalTip: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(46, 204, 113, 0.1)',
borderRadius: radius.md,
padding: spacing.sm,
gap: spacing.xs,
},
modalTipWarning: {
backgroundColor: 'rgba(243, 156, 18, 0.1)',
},
modalTipText: {
...typography.caption1,
color: colors.success,
flex: 1,
},
modalTipTextWarning: {
color: colors.warning,
},
modalCloseButton: {
backgroundColor: colors.glass.background,
borderRadius: radius.md,
paddingVertical: spacing.md,
alignItems: 'center',
borderWidth: 1,
borderColor: colors.glass.border,
},
modalCloseText: {
...typography.subhead,
color: colors.text,
fontWeight: '600',
},
});
export default IntensitySlider;
+3 -2
View File
@@ -1,8 +1,9 @@
export { GradientBackground } from './GradientBackground'; export { GradientBackground } from './GradientBackground';
export { GlassCard } from './GlassCard'; export { GlassCard } from './GlassCard';
export { GlassButton } from './GlassButton'; export { GlassButton } from './GlassButton';
export { GlassInput } from './GlassInput'; export { GlassInput } from './GlassInput';
export { ShimmerEffect } from './ShimmerEffect'; export { IntensitySlider } from './IntensitySlider';
export { ShimmerEffect } from './ShimmerEffect';
export { DecorativeSeparator } from './DecorativeSeparator'; export { DecorativeSeparator } from './DecorativeSeparator';
export type { DecorativeSeparatorProps, SeparatorVariant } from './DecorativeSeparator'; export type { DecorativeSeparatorProps, SeparatorVariant } from './DecorativeSeparator';
export { LoadingOverlay } from './LoadingOverlay'; export { LoadingOverlay } from './LoadingOverlay';
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, useWindowDimensions, Platform, Alert } from 'react-native'; import { View, Text, StyleSheet, ScrollView, TouchableOpacity, useWindowDimensions, Platform, Alert, Linking } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import Constants from 'expo-constants'; import Constants from 'expo-constants';
import Animated, { import Animated, {
@@ -27,7 +27,7 @@ import { clearKeys } from '../../lib/encryption';
import { useProfileSettings, useCoupleManagement } from './hooks'; import { useProfileSettings, useCoupleManagement } from './hooks';
// Components // Components
import { AppearanceSettings, CoupleStatus, NotificationSettings, PrivacySettings, DangerZone, ResetProgress, SettingsSection, MenuItem } from './components'; import { AppearanceSettings, CoupleStatus, NotificationSettings, PrivacySettings, DangerZone, SettingsSection, MenuItem } from './components';
const MAX_CONTENT_WIDTH = 500; const MAX_CONTENT_WIDTH = 500;
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string]; const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
@@ -269,14 +269,17 @@ export function ProfileScreen() {
{/* Preferences */} {/* Preferences */}
<PrivacySettings <PrivacySettings
showExplicit={settings.showExplicit} maxIntensity={settings.maxIntensity}
isUpdatingExplicit={settings.isUpdatingExplicit} isUpdatingIntensity={settings.isUpdatingIntensity}
onExplicitToggle={settings.handleExplicitToggle} onIntensityChange={settings.handleIntensityChange}
biometricAvailable={settings.biometricAvailable} biometricAvailable={settings.biometricAvailable}
biometricEnabled={settings.biometricEnabled} biometricEnabled={settings.biometricEnabled}
biometricType={settings.biometricType} biometricType={settings.biometricType}
isUpdatingBiometric={settings.isUpdatingBiometric} isUpdatingBiometric={settings.isUpdatingBiometric}
onBiometricToggle={settings.handleBiometricToggle} onBiometricToggle={settings.handleBiometricToggle}
partnerIntensity={settings.partnerIntensity}
partnerName={settings.partnerName}
partnerAvatar={settings.partnerAvatar}
/> />
{/* Account */} {/* Account */}
@@ -298,13 +301,20 @@ export function ProfileScreen() {
description="Report bugs or request features" description="Report bugs or request features"
onPress={() => setShowFeedbackModal(true)} onPress={() => setShowFeedbackModal(true)}
/> />
<View style={styles.preferencesDivider} />
<MenuItem
icon="shield-checkmark-outline"
label="Privacy Policy"
onPress={() => Linking.openURL('https://sauci.app/privacy')}
/>
<View style={styles.preferencesDivider} />
<MenuItem
icon="document-text-outline"
label="Terms of Service"
onPress={() => Linking.openURL('https://sauci.app/terms')}
/>
</SettingsSection> </SettingsSection>
{/* Reset Progress */}
{couple && (
<ResetProgress onResetProgress={handleResetProgress} />
)}
{/* Debug Section */} {/* Debug Section */}
{__DEV__ && ( {__DEV__ && (
<SettingsSection title="Debug" delay={550}> <SettingsSection title="Debug" delay={550}>
@@ -375,6 +385,7 @@ export function ProfileScreen() {
{/* Danger Zone */} {/* Danger Zone */}
<DangerZone <DangerZone
onResetProgress={couple ? handleResetProgress : undefined}
onDeleteRelationship={couple ? handleDeleteRelationship : undefined} onDeleteRelationship={couple ? handleDeleteRelationship : undefined}
onDeleteAccount={handleDeleteAccount} onDeleteAccount={handleDeleteAccount}
hasRelationship={!!couple} hasRelationship={!!couple}
@@ -1,9 +1,10 @@
import React from 'react'; import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { SettingsSection } from './SettingsSection'; import { SettingsSection } from './SettingsSection';
import { colors, featureColors, spacing, typography } from '../../../theme'; import { colors, featureColors, spacing, typography, radius } from '../../../theme';
import { Profile, Couple } from '../../../types'; import { Profile, Couple } from '../../../types';
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string]; const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
@@ -15,30 +16,79 @@ interface CoupleStatusProps {
onPairingPress: () => void; onPairingPress: () => void;
} }
const formatPairedDate = (date: string | null | undefined): string | null => {
if (!date) return null;
const paired = new Date(date);
const now = new Date();
const diffMs = now.getTime() - paired.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Paired today';
if (diffDays === 1) return 'Paired yesterday';
if (diffDays < 7) return `Paired ${diffDays} days ago`;
if (diffDays < 30) {
const weeks = Math.floor(diffDays / 7);
return `Paired ${weeks} ${weeks === 1 ? 'week' : 'weeks'} ago`;
}
if (diffDays < 365) {
const months = Math.floor(diffDays / 30);
return `Paired ${months} ${months === 1 ? 'month' : 'months'} ago`;
}
const years = Math.floor(diffDays / 365);
return `Paired ${years} ${years === 1 ? 'year' : 'years'} ago`;
};
export const CoupleStatus: React.FC<CoupleStatusProps> = ({ export const CoupleStatus: React.FC<CoupleStatusProps> = ({
partner, partner,
couple, couple,
onUnpair, onUnpair,
onPairingPress, onPairingPress,
}) => { }) => {
const pairedLabel = formatPairedDate(couple?.created_at);
return ( return (
<SettingsSection title="Partner" delay={300}> <SettingsSection title="Partner" delay={300}>
{partner ? ( {partner ? (
<View style={styles.rowContainer}> <View style={styles.rowContainer}>
<View style={styles.rowLeft}> <View style={styles.rowLeft}>
<LinearGradient {partner.avatar_url ? (
colors={ACCENT_GRADIENT} <View style={styles.partnerAvatarContainer}>
style={styles.partnerIconGradient} <LinearGradient
start={{ x: 0, y: 0 }} colors={ACCENT_GRADIENT}
end={{ x: 1, y: 1 }} style={styles.partnerAvatarGradient}
> start={{ x: 0, y: 0 }}
<Ionicons name="heart" size={20} color={colors.text} /> end={{ x: 1, y: 1 }}
</LinearGradient> >
<Image
source={{ uri: partner.avatar_url }}
style={styles.partnerAvatar}
cachePolicy="disk"
transition={200}
/>
</LinearGradient>
<View style={styles.connectedBadge}>
<Ionicons name="heart" size={10} color={colors.text} />
</View>
</View>
) : (
<LinearGradient
colors={ACCENT_GRADIENT}
style={styles.partnerIconGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Text style={styles.partnerInitial}>
{(partner.name?.[0] || partner.email?.[0] || 'P').toUpperCase()}
</Text>
</LinearGradient>
)}
<View style={styles.rowTextContainer}> <View style={styles.rowTextContainer}>
<Text style={styles.rowValue}> <Text style={styles.rowValue}>
{partner.name || partner.email || 'Your partner'} {partner.name || partner.email || 'Your partner'}
</Text> </Text>
<Text style={styles.rowLabel}>Connected</Text> <Text style={styles.rowLabel}>
{pairedLabel || 'Connected'}
</Text>
</View> </View>
</View> </View>
<TouchableOpacity <TouchableOpacity
@@ -108,20 +158,58 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
flex: 1, flex: 1,
}, },
partnerIconGradient: { partnerAvatarContainer: {
width: 44, position: 'relative',
height: 44, },
borderRadius: 22, partnerAvatarGradient: {
width: 48,
height: 48,
borderRadius: 24,
padding: 2,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
}, },
emptyPartnerIcon: { partnerAvatar: {
width: 44, width: 44,
height: 44, height: 44,
borderRadius: 22, borderRadius: 22,
backgroundColor: colors.background,
},
connectedBadge: {
position: 'absolute',
bottom: -2,
right: -2,
width: 20,
height: 20,
borderRadius: 10,
backgroundColor: colors.success,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: colors.backgroundLight,
},
partnerIconGradient: {
width: 48,
height: 48,
borderRadius: 24,
justifyContent: 'center',
alignItems: 'center',
},
partnerInitial: {
...typography.title3,
color: colors.text,
fontWeight: '600',
},
emptyPartnerIcon: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: colors.glass.background, backgroundColor: colors.glass.background,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
borderWidth: 2,
borderColor: colors.glass.border,
borderStyle: 'dashed',
}, },
rowTextContainer: { rowTextContainer: {
marginLeft: spacing.md, marginLeft: spacing.md,
@@ -7,29 +7,54 @@ import { GlassCard, GlassButton } from '../../../components/ui';
import { colors, spacing, radius, typography } from '../../../theme'; import { colors, spacing, radius, typography } from '../../../theme';
interface DangerZoneProps { interface DangerZoneProps {
onResetProgress?: () => Promise<void>;
onDeleteRelationship?: () => Promise<void>; onDeleteRelationship?: () => Promise<void>;
onDeleteAccount: () => Promise<void>; onDeleteAccount: () => Promise<void>;
hasRelationship?: boolean; hasRelationship?: boolean;
} }
export const DangerZone: React.FC<DangerZoneProps> = ({ export const DangerZone: React.FC<DangerZoneProps> = ({
onResetProgress,
onDeleteRelationship, onDeleteRelationship,
onDeleteAccount, onDeleteAccount,
hasRelationship = false hasRelationship = false
}) => { }) => {
const [showResetModal, setShowResetModal] = useState(false);
const [showDeleteRelationshipModal, setShowDeleteRelationshipModal] = useState(false); const [showDeleteRelationshipModal, setShowDeleteRelationshipModal] = useState(false);
const [showDeleteAccountModal, setShowDeleteAccountModal] = useState(false); const [showDeleteAccountModal, setShowDeleteAccountModal] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState(""); const [confirmText, setConfirmText] = useState("");
const [isResetting, setIsResetting] = useState(false);
const [isDeletingRelationship, setIsDeletingRelationship] = useState(false); const [isDeletingRelationship, setIsDeletingRelationship] = useState(false);
const [isDeletingAccount, setIsDeletingAccount] = useState(false); const [isDeletingAccount, setIsDeletingAccount] = useState(false);
const handleResetProgress = async () => {
if (confirmText !== "RESET" || !onResetProgress) return;
setIsResetting(true);
try {
await onResetProgress();
setShowResetModal(false);
setConfirmText("");
} catch (error) {
// Error handling relies on parent
} finally {
setIsResetting(false);
}
};
const closeResetModal = () => {
if (!isResetting) {
setShowResetModal(false);
setConfirmText("");
}
};
const handleDeleteRelationship = async () => { const handleDeleteRelationship = async () => {
if (deleteConfirmText !== "DELETE" || !onDeleteRelationship) return; if (confirmText !== "DELETE" || !onDeleteRelationship) return;
setIsDeletingRelationship(true); setIsDeletingRelationship(true);
try { try {
await onDeleteRelationship(); await onDeleteRelationship();
setShowDeleteRelationshipModal(false); setShowDeleteRelationshipModal(false);
setDeleteConfirmText(""); setConfirmText("");
} catch (error) { } catch (error) {
// Error handling relies on parent throwing or managing its own alerts // Error handling relies on parent throwing or managing its own alerts
} finally { } finally {
@@ -38,7 +63,7 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
}; };
const handleDeleteAccount = async () => { const handleDeleteAccount = async () => {
if (deleteConfirmText !== "DELETE") return; if (confirmText !== "DELETE") return;
setIsDeletingAccount(true); setIsDeletingAccount(true);
try { try {
await onDeleteAccount(); await onDeleteAccount();
@@ -52,14 +77,14 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
const closeRelationshipModal = () => { const closeRelationshipModal = () => {
if (!isDeletingRelationship) { if (!isDeletingRelationship) {
setShowDeleteRelationshipModal(false); setShowDeleteRelationshipModal(false);
setDeleteConfirmText(""); setConfirmText("");
} }
}; };
const closeAccountModal = () => { const closeAccountModal = () => {
if (!isDeletingAccount) { if (!isDeletingAccount) {
setShowDeleteAccountModal(false); setShowDeleteAccountModal(false);
setDeleteConfirmText(""); setConfirmText("");
} }
}; };
@@ -71,18 +96,45 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
> >
<Text style={styles.dangerSectionTitle}>Danger Zone</Text> <Text style={styles.dangerSectionTitle}>Danger Zone</Text>
{/* Reset Progress - only show if in a relationship */}
{hasRelationship && onResetProgress && (
<GlassCard style={styles.warningCard}>
<View style={styles.dangerContent}>
<View style={styles.dangerInfo}>
<View style={styles.warningIconContainer}>
<Ionicons name="refresh" size={20} color={colors.warning} />
</View>
<View style={styles.dangerTextContainer}>
<Text style={styles.dangerTitle}>Reset Progress</Text>
<Text style={styles.dangerDescription}>
Start fresh by clearing all swipes, matches, and chats. Your partner connection stays.
</Text>
</View>
</View>
<TouchableOpacity
style={styles.warningButton}
onPress={() => setShowResetModal(true)}
activeOpacity={0.7}
>
<Ionicons name="refresh-outline" size={16} color={colors.warning} />
<Text style={styles.warningButtonText}>Reset</Text>
</TouchableOpacity>
</View>
</GlassCard>
)}
{/* Delete Relationship - only show if in a relationship */} {/* Delete Relationship - only show if in a relationship */}
{hasRelationship && onDeleteRelationship && ( {hasRelationship && onDeleteRelationship && (
<GlassCard style={styles.dangerCard}> <GlassCard style={StyleSheet.flatten([styles.dangerCard, styles.dangerCardSpacing]) as ViewStyle}>
<View style={styles.dangerContent}> <View style={styles.dangerContent}>
<View style={styles.dangerInfo}> <View style={styles.dangerInfo}>
<View style={styles.dangerIconContainer}> <View style={styles.dangerIconContainer}>
<Ionicons name="heart-dislike" size={20} color={colors.error} /> <Ionicons name="heart-dislike" size={20} color={colors.error} />
</View> </View>
<View style={styles.dangerTextContainer}> <View style={styles.dangerTextContainer}>
<Text style={styles.dangerTitle}>Delete Relationship Data</Text> <Text style={styles.dangerTitle}>Delete Relationship</Text>
<Text style={styles.dangerDescription}> <Text style={styles.dangerDescription}>
Delete your relationship including all matches, chats, and shared photos. Your account will remain. End your connection and delete all shared data. Your account will remain.
</Text> </Text>
</View> </View>
</View> </View>
@@ -100,7 +152,7 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
)} )}
{/* Delete Account - always show */} {/* Delete Account - always show */}
<GlassCard style={hasRelationship ? StyleSheet.flatten([styles.dangerCard, styles.dangerCardSpacing]) as ViewStyle : styles.dangerCard}> <GlassCard style={StyleSheet.flatten([styles.dangerCard, (hasRelationship ? styles.dangerCardSpacing : {})]) as ViewStyle}>
<View style={styles.dangerContent}> <View style={styles.dangerContent}>
<View style={styles.dangerInfo}> <View style={styles.dangerInfo}>
<View style={styles.dangerIconContainer}> <View style={styles.dangerIconContainer}>
@@ -109,7 +161,7 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
<View style={styles.dangerTextContainer}> <View style={styles.dangerTextContainer}>
<Text style={styles.dangerTitle}>Delete Account</Text> <Text style={styles.dangerTitle}>Delete Account</Text>
<Text style={styles.dangerDescription}> <Text style={styles.dangerDescription}>
Permanently delete your account and all associated data. This cannot be undone. Permanently delete your account and all data. This cannot be undone.
</Text> </Text>
</View> </View>
</View> </View>
@@ -126,6 +178,105 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
</GlassCard> </GlassCard>
</Animated.View> </Animated.View>
{/* Reset Progress Modal */}
<Modal
visible={showResetModal}
transparent
animationType="fade"
onRequestClose={closeResetModal}
>
<BlurView
intensity={Platform.OS === 'ios' ? 20 : 0}
tint="dark"
style={StyleSheet.absoluteFill}
>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.modalOverlay}
>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<View style={styles.warningModalIconContainer}>
<Ionicons name="refresh" size={32} color={colors.warning} />
</View>
<Text style={styles.modalTitle}>Reset Progress?</Text>
</View>
<Text style={styles.modalDescription}>
This will permanently delete:
</Text>
<View style={styles.warningList}>
<View style={styles.deleteListItem}>
<Ionicons name="swap-horizontal" size={16} color={colors.warning} />
<Text style={styles.deleteListText}>All your swipes</Text>
</View>
<View style={styles.deleteListItem}>
<Ionicons name="heart" size={16} color={colors.warning} />
<Text style={styles.deleteListText}>All matches</Text>
</View>
<View style={styles.deleteListItem}>
<Ionicons name="chatbubbles" size={16} color={colors.warning} />
<Text style={styles.deleteListText}>All chat messages</Text>
</View>
</View>
<View style={styles.keepList}>
<View style={styles.deleteListItem}>
<Ionicons name="checkmark-circle" size={16} color={colors.success} />
<Text style={styles.deleteListText}>Your partner connection stays</Text>
</View>
</View>
<Text style={styles.confirmLabel}>
Type <Text style={styles.warningKeyword}>RESET</Text> to confirm:
</Text>
<TextInput
style={styles.warningInput}
value={confirmText}
onChangeText={setConfirmText}
placeholder="Type RESET"
placeholderTextColor={colors.textTertiary}
autoCapitalize="characters"
autoCorrect={false}
editable={!isResetting}
/>
<View style={styles.modalButtons}>
<TouchableOpacity
style={styles.cancelButton}
onPress={closeResetModal}
disabled={isResetting}
activeOpacity={0.7}
>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.warningActionButton,
(confirmText !== "RESET" || isResetting) && styles.deleteButtonDisabled,
]}
onPress={handleResetProgress}
disabled={confirmText !== "RESET" || isResetting}
activeOpacity={0.7}
>
{isResetting ? (
<ActivityIndicator size="small" color={colors.text} />
) : (
<>
<Ionicons name="refresh" size={16} color={colors.text} />
<Text style={styles.deleteButtonText}>Reset</Text>
</>
)}
</TouchableOpacity>
</View>
</View>
</KeyboardAvoidingView>
</BlurView>
</Modal>
{/* Delete Relationship Modal */} {/* Delete Relationship Modal */}
<Modal <Modal
visible={showDeleteRelationshipModal} visible={showDeleteRelationshipModal}
@@ -179,8 +330,8 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
<TextInput <TextInput
style={styles.confirmInput} style={styles.confirmInput}
value={deleteConfirmText} value={confirmText}
onChangeText={setDeleteConfirmText} onChangeText={setConfirmText}
placeholder="Type DELETE" placeholder="Type DELETE"
placeholderTextColor={colors.textTertiary} placeholderTextColor={colors.textTertiary}
autoCapitalize="characters" autoCapitalize="characters"
@@ -201,10 +352,10 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
<TouchableOpacity <TouchableOpacity
style={[ style={[
styles.deleteButton, styles.deleteButton,
(deleteConfirmText !== "DELETE" || isDeletingRelationship) && styles.deleteButtonDisabled, (confirmText !== "DELETE" || isDeletingRelationship) && styles.deleteButtonDisabled,
]} ]}
onPress={handleDeleteRelationship} onPress={handleDeleteRelationship}
disabled={deleteConfirmText !== "DELETE" || isDeletingRelationship} disabled={confirmText !== "DELETE" || isDeletingRelationship}
activeOpacity={0.7} activeOpacity={0.7}
> >
{isDeletingRelationship ? ( {isDeletingRelationship ? (
@@ -283,8 +434,8 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
<TextInput <TextInput
style={styles.confirmInput} style={styles.confirmInput}
value={deleteConfirmText} value={confirmText}
onChangeText={setDeleteConfirmText} onChangeText={setConfirmText}
placeholder="Type DELETE" placeholder="Type DELETE"
placeholderTextColor={colors.textTertiary} placeholderTextColor={colors.textTertiary}
autoCapitalize="characters" autoCapitalize="characters"
@@ -305,10 +456,10 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
<TouchableOpacity <TouchableOpacity
style={[ style={[
styles.deleteButton, styles.deleteButton,
(deleteConfirmText !== "DELETE" || isDeletingAccount) && styles.deleteButtonDisabled, (confirmText !== "DELETE" || isDeletingAccount) && styles.deleteButtonDisabled,
]} ]}
onPress={handleDeleteAccount} onPress={handleDeleteAccount}
disabled={deleteConfirmText !== "DELETE" || isDeletingAccount} disabled={confirmText !== "DELETE" || isDeletingAccount}
activeOpacity={0.7} activeOpacity={0.7}
> >
{isDeletingAccount ? ( {isDeletingAccount ? (
@@ -342,6 +493,36 @@ const styles = StyleSheet.create({
marginBottom: spacing.sm, marginBottom: spacing.sm,
marginLeft: spacing.xs, marginLeft: spacing.xs,
}, },
warningCard: {
borderColor: 'rgba(243, 156, 18, 0.3)',
borderWidth: 1,
},
warningIconContainer: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: 'rgba(243, 156, 18, 0.15)',
justifyContent: "center",
alignItems: "center",
},
warningButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
backgroundColor: 'rgba(243, 156, 18, 0.15)',
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: 'rgba(243, 156, 18, 0.3)',
gap: spacing.xs,
alignSelf: "flex-end",
},
warningButtonText: {
...typography.subhead,
color: colors.warning,
fontWeight: "600",
},
dangerCard: { dangerCard: {
borderColor: 'rgba(231, 76, 60, 0.3)', borderColor: 'rgba(231, 76, 60, 0.3)',
borderWidth: 1, borderWidth: 1,
@@ -431,6 +612,58 @@ const styles = StyleSheet.create({
borderWidth: 1, borderWidth: 1,
borderColor: 'rgba(231, 76, 60, 0.2)', borderColor: 'rgba(231, 76, 60, 0.2)',
}, },
warningList: {
backgroundColor: "rgba(243, 156, 18, 0.1)",
borderRadius: radius.lg,
padding: spacing.md,
gap: spacing.sm,
marginBottom: spacing.md,
borderWidth: 1,
borderColor: 'rgba(243, 156, 18, 0.2)',
},
keepList: {
backgroundColor: "rgba(46, 204, 113, 0.1)",
borderRadius: radius.lg,
padding: spacing.md,
gap: spacing.sm,
marginBottom: spacing.xl,
borderWidth: 1,
borderColor: 'rgba(46, 204, 113, 0.2)',
},
warningModalIconContainer: {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: 'rgba(243, 156, 18, 0.15)',
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.md,
},
warningKeyword: {
fontWeight: "bold",
color: colors.warning,
},
warningInput: {
backgroundColor: colors.glass.background,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.warning,
padding: spacing.md,
...typography.body,
color: colors.text,
textAlign: 'center',
marginBottom: spacing.xl,
},
warningActionButton: {
flex: 1,
backgroundColor: colors.warning,
paddingVertical: spacing.md,
borderRadius: radius.md,
alignItems: "center",
flexDirection: "row",
justifyContent: "center",
gap: spacing.xs,
},
deleteListItem: { deleteListItem: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
@@ -1,40 +1,76 @@
import React from 'react'; import React from 'react';
import { View, StyleSheet } from 'react-native'; import { View, Text, StyleSheet } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { SettingsSection } from './SettingsSection'; import { SettingsSection } from './SettingsSection';
import { SwitchItem } from './SwitchItem'; import { SwitchItem } from './SwitchItem';
import { colors, spacing } from '../../../theme'; import { IntensitySlider } from '../../../components/ui/IntensitySlider';
import { colors, spacing, radius, typography, featureColors } from '../../../theme';
import type { IntensityLevel } from '../../../types';
interface PrivacySettingsProps { interface PrivacySettingsProps {
showExplicit: boolean; maxIntensity: IntensityLevel;
isUpdatingExplicit: boolean; isUpdatingIntensity: boolean;
onExplicitToggle: (value: boolean) => void; onIntensityChange: (value: IntensityLevel) => void;
biometricAvailable: boolean; biometricAvailable: boolean;
biometricEnabled: boolean; biometricEnabled: boolean;
biometricType: string; biometricType: string;
isUpdatingBiometric: boolean; isUpdatingBiometric: boolean;
onBiometricToggle: (value: boolean) => void; onBiometricToggle: (value: boolean) => void;
// Partner info for slider comparison
partnerIntensity?: IntensityLevel | null;
partnerName?: string;
partnerAvatar?: string | null;
} }
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
export const PrivacySettings: React.FC<PrivacySettingsProps> = ({ export const PrivacySettings: React.FC<PrivacySettingsProps> = ({
showExplicit, maxIntensity,
isUpdatingExplicit, isUpdatingIntensity,
onExplicitToggle, onIntensityChange,
biometricAvailable, biometricAvailable,
biometricEnabled, biometricEnabled,
biometricType, biometricType,
isUpdatingBiometric, isUpdatingBiometric,
onBiometricToggle, onBiometricToggle,
partnerIntensity,
partnerName,
partnerAvatar,
}) => { }) => {
return ( return (
<SettingsSection title="Preferences" delay={375}> <SettingsSection title="Preferences" delay={375}>
<SwitchItem {/* Comfort Zone Header */}
icon="flame" <View style={styles.comfortHeader}>
label="Spicy Content" <LinearGradient
description="Show spicy question packs" colors={ACCENT_GRADIENT}
value={showExplicit} style={styles.comfortIcon}
onValueChange={onExplicitToggle} start={{ x: 0, y: 0 }}
disabled={isUpdatingExplicit} end={{ x: 1, y: 1 }}
/> >
<Ionicons name="heart-circle" size={22} color={colors.text} />
</LinearGradient>
<View style={styles.comfortText}>
<Text style={styles.comfortLabel}>Comfort Zone</Text>
<Text style={styles.comfortDescription}>
Set the intimacy level you're comfortable with
</Text>
</View>
</View>
{/* Intensity Slider */}
<View style={styles.sliderContainer}>
<IntensitySlider
value={maxIntensity}
onValueChange={onIntensityChange}
disabled={isUpdatingIntensity}
partnerValue={partnerIntensity}
partnerName={partnerName}
partnerAvatar={partnerAvatar}
/>
</View>
{/* Biometric Toggle */}
{biometricAvailable && ( {biometricAvailable && (
<> <>
<View style={styles.divider} /> <View style={styles.divider} />
@@ -53,9 +89,37 @@ export const PrivacySettings: React.FC<PrivacySettingsProps> = ({
}; };
const styles = StyleSheet.create({ 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,
},
comfortLabel: {
...typography.headline,
color: colors.text,
},
comfortDescription: {
...typography.caption1,
color: colors.textSecondary,
marginTop: 2,
},
sliderContainer: {
marginBottom: spacing.sm,
},
divider: { divider: {
height: 1, height: 1,
backgroundColor: colors.glass.border, backgroundColor: colors.glass.border,
marginVertical: spacing.md, marginVertical: spacing.lg,
}, },
}); });
@@ -0,0 +1,389 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { ShimmerEffect } from '../../../components/ui';
import { colors, gradients, spacing, typography, radius, shadows } from '../../../theme';
export interface SubscriptionCardProps {
/** Whether user has premium access (own or partner's) */
hasPremiumAccess: boolean;
/** Whether this is the user's own subscription (vs partner's) */
isOwnSubscription: boolean;
/** Formatted expiration/renewal date */
expirationDate: string;
/** Callback when upgrade button is pressed */
onUpgradePress: () => void;
/** Callback when manage subscription is pressed */
onManagePress: () => void;
/** Callback when restore purchases is pressed */
onRestorePress: () => void;
/** Whether restore is in progress */
isRestoring?: boolean;
/** Animation delay */
delay?: number;
}
const PREMIUM_FEATURES = [
{ icon: 'layers' as const, text: '50+ Premium Packs' },
{ icon: 'flame' as const, text: 'All Intensity Levels' },
{ icon: 'sparkles' as const, text: 'New Packs Weekly' },
];
export function SubscriptionCard({
hasPremiumAccess,
isOwnSubscription,
expirationDate,
onUpgradePress,
onManagePress,
onRestorePress,
isRestoring = false,
delay = 350,
}: SubscriptionCardProps) {
if (hasPremiumAccess) {
return (
<Animated.View
entering={FadeInDown.delay(delay).duration(500)}
style={styles.container}
>
<ProMemberCard
isOwnSubscription={isOwnSubscription}
expirationDate={expirationDate}
onManagePress={onManagePress}
/>
</Animated.View>
);
}
return (
<Animated.View
entering={FadeInDown.delay(delay).duration(500)}
style={styles.container}
>
<FreeUserCard
onUpgradePress={onUpgradePress}
onRestorePress={onRestorePress}
isRestoring={isRestoring}
/>
</Animated.View>
);
}
/** Premium member card - celebratory gold treatment */
function ProMemberCard({
isOwnSubscription,
expirationDate,
onManagePress,
}: {
isOwnSubscription: boolean;
expirationDate: string;
onManagePress: () => void;
}) {
return (
<View style={styles.proCard}>
{/* Gold gradient border effect */}
<LinearGradient
colors={[colors.premium.gold, colors.premium.goldDark]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.proBorderGradient}
/>
<View style={styles.proCardInner}>
{/* Header with badge */}
<View style={styles.proHeader}>
<LinearGradient
colors={gradients.premiumGold as [string, string]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.proBadge}
>
<Ionicons name="star" size={16} color={colors.background} />
</LinearGradient>
<View style={styles.proHeaderText}>
<Text style={styles.proTitle}>Pro Member</Text>
<Text style={styles.proSubtitle}>
{isOwnSubscription
? `Renews ${expirationDate}`
: "Via partner's subscription"
}
</Text>
</View>
{isOwnSubscription && (
<TouchableOpacity
style={styles.manageButton}
onPress={onManagePress}
activeOpacity={0.7}
>
<Text style={styles.manageButtonText}>Manage</Text>
</TouchableOpacity>
)}
</View>
{/* Status row */}
<View style={styles.proStatusRow}>
<View style={styles.proStatusItem}>
<Ionicons name="checkmark-circle" size={16} color={colors.premium.gold} />
<Text style={styles.proStatusText}>All Packs Unlocked</Text>
</View>
<View style={styles.proStatusItem}>
<Ionicons name="infinite" size={16} color={colors.premium.gold} />
<Text style={styles.proStatusText}>Unlimited Access</Text>
</View>
</View>
</View>
</View>
);
}
/** Free user card - premium upsell with shimmer */
function FreeUserCard({
onUpgradePress,
onRestorePress,
isRestoring,
}: {
onUpgradePress: () => void;
onRestorePress: () => void;
isRestoring: boolean;
}) {
return (
<ShimmerEffect
shimmerColor={colors.premium.gold}
duration={5000}
style={styles.shimmerContainer}
>
<View style={styles.freeCard}>
{/* Gold gradient border */}
<LinearGradient
colors={[`${colors.premium.gold}60`, `${colors.premium.goldDark}40`]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.freeBorderGradient}
/>
<View style={styles.freeCardInner}>
{/* Header */}
<View style={styles.freeHeader}>
<LinearGradient
colors={gradients.premiumGold as [string, string]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.freeHeaderBadge}
>
<Ionicons name="sparkles" size={14} color={colors.background} />
<Text style={styles.freeHeaderBadgeText}>SAUCI PRO</Text>
</LinearGradient>
</View>
{/* Features list */}
<View style={styles.featuresList}>
{PREMIUM_FEATURES.map((feature, index) => (
<View key={index} style={styles.featureItem}>
<View style={styles.featureIconContainer}>
<Ionicons
name={feature.icon}
size={16}
color={colors.premium.gold}
/>
</View>
<Text style={styles.featureText}>{feature.text}</Text>
</View>
))}
</View>
{/* CTA Button */}
<TouchableOpacity
onPress={onUpgradePress}
activeOpacity={0.8}
style={styles.ctaButtonContainer}
>
<LinearGradient
colors={gradients.premiumGold as [string, string]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.ctaButton}
>
<Ionicons name="star" size={18} color={colors.background} />
<Text style={styles.ctaButtonText}>Upgrade to Pro</Text>
</LinearGradient>
</TouchableOpacity>
{/* Restore link */}
<TouchableOpacity
style={styles.restoreLink}
onPress={onRestorePress}
disabled={isRestoring}
>
<Text style={styles.restoreLinkText}>
{isRestoring ? "Restoring..." : "Restore Purchases"}
</Text>
</TouchableOpacity>
</View>
</View>
</ShimmerEffect>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: spacing.lg,
paddingHorizontal: spacing.lg,
},
shimmerContainer: {
borderRadius: radius.lg,
},
// Pro Member Card Styles
proCard: {
borderRadius: radius.lg,
overflow: 'hidden',
},
proBorderGradient: {
...StyleSheet.absoluteFillObject,
opacity: 0.3,
},
proCardInner: {
backgroundColor: 'rgba(22, 33, 62, 0.8)',
margin: 1,
borderRadius: radius.lg - 1,
padding: spacing.md,
},
proHeader: {
flexDirection: 'row',
alignItems: 'center',
},
proBadge: {
width: 36,
height: 36,
borderRadius: 18,
justifyContent: 'center',
alignItems: 'center',
...shadows.glow(colors.premium.gold),
},
proHeaderText: {
flex: 1,
marginLeft: spacing.md,
},
proTitle: {
...typography.headline,
color: colors.premium.champagne,
fontWeight: '700',
},
proSubtitle: {
...typography.caption1,
color: colors.textTertiary,
marginTop: 2,
},
manageButton: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.md,
backgroundColor: colors.glass.background,
borderWidth: 1,
borderColor: `${colors.premium.gold}40`,
},
manageButtonText: {
...typography.subhead,
color: colors.premium.gold,
fontWeight: '600',
},
proStatusRow: {
flexDirection: 'row',
marginTop: spacing.md,
paddingTop: spacing.md,
borderTopWidth: 1,
borderTopColor: colors.glass.border,
gap: spacing.lg,
},
proStatusItem: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
proStatusText: {
...typography.caption1,
color: colors.textSecondary,
},
// Free User Card Styles
freeCard: {
borderRadius: radius.lg,
overflow: 'hidden',
},
freeBorderGradient: {
...StyleSheet.absoluteFillObject,
},
freeCardInner: {
backgroundColor: 'rgba(22, 33, 62, 0.85)',
margin: 1,
borderRadius: radius.lg - 1,
padding: spacing.lg,
},
freeHeader: {
alignItems: 'center',
marginBottom: spacing.lg,
},
freeHeaderBadge: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
gap: spacing.xs,
},
freeHeaderBadgeText: {
...typography.caption1,
fontWeight: '800',
letterSpacing: 2,
color: colors.background,
},
featuresList: {
marginBottom: spacing.lg,
},
featureItem: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.sm,
},
featureIconContainer: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: colors.premium.goldLight,
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
},
featureText: {
...typography.body,
color: colors.text,
},
ctaButtonContainer: {
borderRadius: radius.md,
overflow: 'hidden',
...shadows.glow(colors.premium.gold),
},
ctaButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.md,
gap: spacing.sm,
},
ctaButtonText: {
...typography.headline,
color: colors.background,
fontWeight: '700',
},
restoreLink: {
alignItems: 'center',
marginTop: spacing.md,
padding: spacing.sm,
},
restoreLinkText: {
...typography.caption1,
color: colors.textTertiary,
},
});
export default SubscriptionCard;
@@ -6,6 +6,7 @@ import { decode } from 'base64-arraybuffer';
import { useAuthStore, useSubscriptionStore, usePacksStore } from '../../../store'; import { useAuthStore, useSubscriptionStore, usePacksStore } from '../../../store';
import { supabase } from '../../../lib/supabase'; import { supabase } from '../../../lib/supabase';
import { Events } from '../../../lib/analytics'; import { Events } from '../../../lib/analytics';
import type { IntensityLevel } from '../../../types';
import { import {
registerForPushNotificationsAsync, registerForPushNotificationsAsync,
savePushToken, savePushToken,
@@ -18,11 +19,27 @@ import {
getBiometricType, getBiometricType,
} from '../../../lib/biometricAuth'; } 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;
};
export function useProfileSettings() { export function useProfileSettings() {
const { user, fetchUser } = useAuthStore(); const { user, partner, fetchUser } = useAuthStore();
const { fetchPacks } = usePacksStore(); const { fetchPacks } = usePacksStore();
const { restorePurchases, isPurchasing } = useSubscriptionStore(); const { restorePurchases, isPurchasing } = useSubscriptionStore();
// Partner's intensity level (for showing on slider)
const partnerIntensity = partner ? getProfileMaxIntensity(partner) : null;
// Name Editing // Name Editing
const [isEditingName, setIsEditingName] = useState(false); const [isEditingName, setIsEditingName] = useState(false);
const [newName, setNewName] = useState(user?.name || ""); const [newName, setNewName] = useState(user?.name || "");
@@ -32,8 +49,8 @@ export function useProfileSettings() {
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false); const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
// Preferences // Preferences
const [showExplicit, setShowExplicit] = useState(user?.show_explicit_content ?? true); const [maxIntensity, setMaxIntensity] = useState<IntensityLevel>(() => getProfileMaxIntensity(user));
const [isUpdatingExplicit, setIsUpdatingExplicit] = useState(false); const [isUpdatingIntensity, setIsUpdatingIntensity] = useState(false);
// Notifications // Notifications
const [pushEnabled, setPushEnabled] = useState(!!user?.push_token); const [pushEnabled, setPushEnabled] = useState(!!user?.push_token);
@@ -53,9 +70,7 @@ export function useProfileSettings() {
if (user?.name) { if (user?.name) {
setNewName(user.name); setNewName(user.name);
} }
if (user?.show_explicit_content !== undefined) { setMaxIntensity(getProfileMaxIntensity(user));
setShowExplicit(user.show_explicit_content);
}
setPushEnabled(!!user?.push_token); setPushEnabled(!!user?.push_token);
}, [user]); }, [user]);
@@ -256,17 +271,21 @@ export function useProfileSettings() {
}; };
// Preference Actions // Preference Actions
const handleExplicitToggle = async (value: boolean) => { const handleIntensityChange = async (value: IntensityLevel) => {
if (!user?.id) return; if (!user?.id) return;
setShowExplicit(value); const previous = maxIntensity;
setIsUpdatingExplicit(true); setMaxIntensity(value);
setIsUpdatingIntensity(true);
const showExplicitContent = value >= 3;
try { try {
const { error } = await supabase const { error } = await supabase
.from('profiles') .from('profiles')
.update({ .update({
show_explicit_content: value, max_intensity: value,
show_explicit_content: showExplicitContent,
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
}) })
.eq('id', user.id); .eq('id', user.id);
@@ -276,13 +295,13 @@ export function useProfileSettings() {
// Refresh user data and packs to reflect the change // Refresh user data and packs to reflect the change
await fetchUser(); await fetchUser();
await fetchPacks(); await fetchPacks();
Events.profileUpdated(["show_explicit_content"]); Events.profileUpdated(["max_intensity", "show_explicit_content"]);
} catch (error) { } catch (error) {
// Revert on error // Revert on error
setShowExplicit(!value); setMaxIntensity(previous);
Alert.alert("Error", "Failed to update preference. Please try again."); Alert.alert("Error", "Failed to update preference. Please try again.");
} finally { } finally {
setIsUpdatingExplicit(false); setIsUpdatingIntensity(false);
} }
}; };
@@ -380,8 +399,8 @@ export function useProfileSettings() {
setNewName, setNewName,
isUpdatingName, isUpdatingName,
isUploadingAvatar, isUploadingAvatar,
showExplicit, maxIntensity,
isUpdatingExplicit, isUpdatingIntensity,
pushEnabled, pushEnabled,
isUpdatingPush, isUpdatingPush,
biometricAvailable, biometricAvailable,
@@ -392,11 +411,16 @@ export function useProfileSettings() {
setShowPaywall, setShowPaywall,
isPurchasing, isPurchasing,
// Partner info for comfort zone comparison
partnerIntensity,
partnerName: partner?.name ?? undefined,
partnerAvatar: partner?.avatar_url ?? undefined,
// Actions // Actions
handleUpdateName, handleUpdateName,
handleCancelEditName, handleCancelEditName,
handleAvatarPress, handleAvatarPress,
handleExplicitToggle, handleIntensityChange,
handlePushToggle, handlePushToggle,
handleBiometricToggle, handleBiometricToggle,
handleRestorePurchases, handleRestorePurchases,
@@ -17,6 +17,10 @@ import { reencryptPendingMessages } from '../lib/encryption/reencryptPendingMess
// Import shared cache from mediaCache to avoid circular dependency // Import shared cache from mediaCache to avoid circular dependency
import { decryptedMediaCache, clearDecryptedMediaCache } from '../lib/mediaCache'; import { decryptedMediaCache, clearDecryptedMediaCache } from '../lib/mediaCache';
const AUTO_RETRY_BASE_DELAY_MS = 3000;
const MAX_AUTO_RETRIES = 2;
const autoRetryCountsByMediaKey = new Map<string, number>();
export type DecryptedMediaErrorCode = export type DecryptedMediaErrorCode =
| 'E2EE_NOT_SUPPORTED' | 'E2EE_NOT_SUPPORTED'
| 'E2EE_KEYS_UNAVAILABLE' | 'E2EE_KEYS_UNAVAILABLE'
@@ -86,6 +90,7 @@ export function useDecryptedMedia({
const repairAttemptedByMessageId = useRef(new Set<string>()); const repairAttemptedByMessageId = useRef(new Set<string>());
const [retryNonce, setRetryNonce] = useState(0); const [retryNonce, setRetryNonce] = useState(0);
const autoRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const retry = useCallback(() => { const retry = useCallback(() => {
repairAttemptedByMessageId.current.delete(messageId); repairAttemptedByMessageId.current.delete(messageId);
@@ -391,6 +396,57 @@ export function useDecryptedMedia({
}; };
}, [decryptMedia, retryNonce]); }, [decryptMedia, retryNonce]);
useEffect(() => {
return () => {
if (autoRetryTimerRef.current) {
clearTimeout(autoRetryTimerRef.current);
autoRetryTimerRef.current = null;
}
};
}, [messageId, mediaPath]);
useEffect(() => {
const autoRetryKey = `${messageId}:${mediaPath ?? 'none'}`;
if (!state.errorCode) {
autoRetryCountsByMediaKey.delete(autoRetryKey);
if (autoRetryTimerRef.current) {
clearTimeout(autoRetryTimerRef.current);
autoRetryTimerRef.current = null;
}
return;
}
const shouldRetry =
state.errorCode === 'E2EE_PENDING_RECIPIENT_KEY' ||
state.errorCode === 'E2EE_KEYS_UNAVAILABLE';
if (!shouldRetry) {
return;
}
const attempts = autoRetryCountsByMediaKey.get(autoRetryKey) ?? 0;
if (attempts >= MAX_AUTO_RETRIES) {
return;
}
const nextAttempt = attempts + 1;
const delayMs = AUTO_RETRY_BASE_DELAY_MS * nextAttempt;
autoRetryCountsByMediaKey.set(autoRetryKey, nextAttempt);
autoRetryTimerRef.current = setTimeout(() => {
retry();
}, delayMs);
return () => {
if (autoRetryTimerRef.current) {
clearTimeout(autoRetryTimerRef.current);
autoRetryTimerRef.current = null;
}
};
}, [messageId, mediaPath, state.errorCode, retry]);
return { return {
...state, ...state,
isEncrypted, isEncrypted,
@@ -22,6 +22,10 @@ import { supabase } from '../lib/supabase';
import { reencryptPendingMessages } from '../lib/encryption/reencryptPendingMessages'; import { reencryptPendingMessages } from '../lib/encryption/reencryptPendingMessages';
import { useEncryptionKeys } from './useEncryptionKeys'; import { useEncryptionKeys } from './useEncryptionKeys';
const AUTO_RETRY_BASE_DELAY_MS = 3000;
const MAX_AUTO_RETRIES = 2;
const autoRetryCountsByMessageId = new Map<string, number>();
interface Message { interface Message {
id: string; id: string;
content: string | null; content: string | null;
@@ -68,6 +72,7 @@ export function useDecryptedMessage({
// Track per-message repair attempts to avoid tight retry loops. // Track per-message repair attempts to avoid tight retry loops.
const repairAttemptedByMessageId = useRef(new Set<string>()); const repairAttemptedByMessageId = useRef(new Set<string>());
const [retryNonce, setRetryNonce] = useState(0); const [retryNonce, setRetryNonce] = useState(0);
const autoRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const retry = useCallback(() => { const retry = useCallback(() => {
repairAttemptedByMessageId.current.delete(message.id); repairAttemptedByMessageId.current.delete(message.id);
@@ -360,5 +365,54 @@ export function useDecryptedMessage({
decrypt(); decrypt();
}, [decrypt, retryNonce]); }, [decrypt, retryNonce]);
useEffect(() => {
return () => {
if (autoRetryTimerRef.current) {
clearTimeout(autoRetryTimerRef.current);
autoRetryTimerRef.current = null;
}
};
}, [message.id]);
useEffect(() => {
if (!state.errorCode) {
autoRetryCountsByMessageId.delete(message.id);
if (autoRetryTimerRef.current) {
clearTimeout(autoRetryTimerRef.current);
autoRetryTimerRef.current = null;
}
return;
}
const shouldRetry =
state.errorCode === 'E2EE_PENDING_RECIPIENT_KEY' ||
state.errorCode === 'E2EE_KEYS_UNAVAILABLE';
if (!shouldRetry) {
return;
}
const attempts = autoRetryCountsByMessageId.get(message.id) ?? 0;
if (attempts >= MAX_AUTO_RETRIES) {
return;
}
const nextAttempt = attempts + 1;
const delayMs = AUTO_RETRY_BASE_DELAY_MS * nextAttempt;
autoRetryCountsByMessageId.set(message.id, nextAttempt);
autoRetryTimerRef.current = setTimeout(() => {
retry();
}, delayMs);
return () => {
if (autoRetryTimerRef.current) {
clearTimeout(autoRetryTimerRef.current);
autoRetryTimerRef.current = null;
}
};
}, [message.id, state.errorCode, retry]);
return { ...state, retry }; return { ...state, retry };
} }
+72 -8
View File
@@ -12,6 +12,7 @@ import { useCallback, useState, useRef } from 'react';
import { Platform } from 'react-native'; import { Platform } from 'react-native';
import { import {
encryptTextMessage, encryptTextMessage,
decryptTextMessage,
getAdminPublicKey, getAdminPublicKey,
getAdminKeyId, getAdminKeyId,
isValidPublicKeyJwk, isValidPublicKeyJwk,
@@ -21,6 +22,7 @@ import {
import type { RSAPublicKeyJWK, EncryptedMessagePayload } from '../lib/encryption'; import type { RSAPublicKeyJWK, EncryptedMessagePayload } from '../lib/encryption';
import { useEncryptionKeys } from './useEncryptionKeys'; import { useEncryptionKeys } from './useEncryptionKeys';
import { useAuthStore } from '../store'; import { useAuthStore } from '../store';
import { captureError } from '../lib/sentry';
interface UseEncryptedSendReturn { interface UseEncryptedSendReturn {
/** Encrypt a text message for sending */ /** Encrypt a text message for sending */
@@ -48,6 +50,7 @@ export function useEncryptedSend(): UseEncryptedSendReturn {
const { publicKeyJwk, hasKeys, isLoading } = useEncryptionKeys(); const { publicKeyJwk, hasKeys, isLoading } = useEncryptionKeys();
const refreshPartner = useAuthStore((s) => s.refreshPartner); const refreshPartner = useAuthStore((s) => s.refreshPartner);
const user = useAuthStore((s) => s.user);
// E2EE is available if: // E2EE is available if:
// - Not on web platform // - Not on web platform
@@ -167,14 +170,75 @@ export function useEncryptedSend(): UseEncryptedSendReturn {
console.log('[E2EE Send] Partner public key available:', !!partnerPublicKey); console.log('[E2EE Send] Partner public key available:', !!partnerPublicKey);
// Encrypt the message using the key from global store let lastPayload: EncryptedMessagePayload | null = null;
const encryptedPayload = await encryptTextMessage(
plaintext, const encryptAndVerify = async (): Promise<EncryptedMessagePayload> => {
senderPublicKey as RSAPublicKeyJWK, const payload = await encryptTextMessage(
partnerPublicKey ?? null, plaintext,
adminPublicKey, senderPublicKey as RSAPublicKeyJWK,
adminKeyId partnerPublicKey ?? null,
); adminPublicKey,
adminKeyId
);
lastPayload = payload;
const decrypted = await decryptTextMessage(
payload.encrypted_content,
payload.encryption_iv,
payload.keys_metadata,
senderPrivateKey,
false
);
if (decrypted !== plaintext) {
throw new Error('E2EE payload verification mismatch');
}
return payload;
};
const maxAttempts = 2;
let encryptedPayload: EncryptedMessagePayload | null = null;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
encryptedPayload = await encryptAndVerify();
break;
} catch (verifyError) {
console.warn(`[E2EE Send] Payload verification failed (attempt ${attempt})`, verifyError);
if (attempt === maxAttempts) {
const errorInstance =
verifyError instanceof Error
? verifyError
: new Error('E2EE payload verification failed');
const lastPayloadData = lastPayload as EncryptedMessagePayload | null;
captureError(errorInstance, {
stage: 'encrypt_verify',
attempt,
max_attempts: maxAttempts,
plaintext_length: plaintext.length,
encrypted_content_length: lastPayloadData?.encrypted_content.length ?? null,
encryption_iv_length: lastPayloadData?.encryption_iv.length ?? null,
admin_key_id: lastPayloadData?.keys_metadata?.admin_key_id ?? null,
pending_recipient: lastPayloadData?.keys_metadata?.pending_recipient ?? null,
has_partner_key: !!partnerPublicKey,
keys_version: currentKeys.keysVersion,
platform: Platform.OS,
user_id: user?.id ?? null,
});
throw new Error('Security check failed. Please try again.');
}
}
}
if (!encryptedPayload) {
throw new Error('Security check failed. Please try again.');
}
console.log('[E2EE Send] Message encrypted successfully'); console.log('[E2EE Send] Message encrypted successfully');
+27 -9
View File
@@ -2,9 +2,25 @@ import { create } from "zustand";
import { supabase } from "../lib/supabase"; import { supabase } from "../lib/supabase";
import { Events } from "../lib/analytics"; import { Events } from "../lib/analytics";
import type { QuestionPack, Category } from "@/types"; import type { QuestionPack, Category } from "@/types";
import { useAuthStore } from "./authStore"; import { useAuthStore } from "./authStore";
interface PacksState { const DEFAULT_MAX_INTENSITY = 2;
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 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;
};
interface PacksState {
packs: QuestionPack[]; packs: QuestionPack[];
categories: Category[]; categories: Category[];
enabledPackIds: string[]; enabledPackIds: string[];
@@ -25,8 +41,9 @@ export const usePacksStore = create<PacksState>((set, get) => ({
fetchPacks: async () => { fetchPacks: async () => {
set({ isLoading: true }); set({ isLoading: true });
// Get user's explicit content preference // Get user's max intensity preference
const showExplicit = useAuthStore.getState().user?.show_explicit_content ?? true; const maxIntensity = getUserMaxIntensity();
// Fetch categories // Fetch categories
const { data: categories } = await supabase const { data: categories } = await supabase
@@ -41,10 +58,11 @@ export const usePacksStore = create<PacksState>((set, get) => ({
.eq("is_public", true) .eq("is_public", true)
.order("sort_order"); .order("sort_order");
// Filter out explicit packs if user doesn't want to see them // Filter out packs above user's intensity preference
if (!showExplicit) { if (maxIntensity < 5) {
query = query.eq("is_explicit", false); query = query.or(`max_intensity.is.null,max_intensity.lte.${maxIntensity}`);
} }
const { data: packs } = await query; const { data: packs } = await query;
+14 -9
View File
@@ -1,7 +1,8 @@
// User profile (extends Supabase auth.users) // User profile (extends Supabase auth.users)
export type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say'; export type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say';
export type IntensityLevel = 1 | 2 | 3 | 4 | 5;
export interface Profile {
export interface Profile {
id: string; id: string;
name: string | null; name: string | null;
email: string | null; email: string | null;
@@ -9,9 +10,10 @@ export interface Profile {
push_token: string | null; push_token: string | null;
is_premium: boolean; is_premium: boolean;
couple_id: string | null; couple_id: string | null;
gender: Gender | null; gender: Gender | null;
show_explicit_content: boolean; show_explicit_content: boolean;
onboarding_completed: boolean; max_intensity: IntensityLevel;
onboarding_completed: boolean;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
/** RSA public key for E2EE (JWK format) */ /** RSA public key for E2EE (JWK format) */
@@ -42,9 +44,12 @@ export interface QuestionPack {
description: string | null; description: string | null;
icon: string | null; icon: string | null;
is_premium: boolean; is_premium: boolean;
is_public: boolean; is_public: boolean;
is_explicit: boolean; is_explicit: boolean;
sort_order: number; min_intensity?: number;
max_intensity?: number;
avg_intensity?: number;
sort_order: number;
category_id: string | null; category_id: string | null;
category?: Category; category?: Category;
questions?: { count: number }[]; questions?: { count: number }[];
@@ -67,14 +67,14 @@ Deno.serve(async (req) => {
return json({ error: "Missing encryption metadata" }, 400); return json({ error: "Missing encryption metadata" }, 400);
} }
const adminPrivateKeyJwkStr = Deno.env.get("ADMIN_PRIVATE_KEY_JWK"); const adminPrivateKeyJwk = getAdminPrivateKey(keysMetadata.admin_key_id);
if (!adminPrivateKeyJwkStr) { if (!adminPrivateKeyJwk) {
return json({ error: "Admin decryption key not configured" }, 500); return json({ error: "Admin decryption key not configured" }, 500);
} }
const rawAesKey = await unwrapWithAdminKey( const rawAesKey = await unwrapWithAdminKey(
keysMetadata.admin_wrapped_key, keysMetadata.admin_wrapped_key,
JSON.parse(adminPrivateKeyJwkStr) adminPrivateKeyJwk
); );
const aesKey = await crypto.subtle.importKey( const aesKey = await crypto.subtle.importKey(
@@ -166,3 +166,28 @@ async function assertSuperAdmin(
throw new Error("Access denied"); throw new Error("Access denied");
} }
} }
function getAdminPrivateKey(keyId?: string | null): JsonWebKey | null {
const keysJson = Deno.env.get("ADMIN_KEYS_JSON");
if (keysJson && keyId) {
try {
const keys = JSON.parse(keysJson);
if (keys[keyId]) {
return keys[keyId];
}
} catch {
console.error("Failed to parse ADMIN_KEYS_JSON");
}
}
const singleKeyJson = Deno.env.get("ADMIN_PRIVATE_KEY_JWK");
if (singleKeyJson) {
try {
return JSON.parse(singleKeyJson);
} catch {
console.error("Failed to parse ADMIN_PRIVATE_KEY_JWK");
}
}
return null;
}
+36 -14
View File
@@ -177,20 +177,42 @@ Deno.serve(async (req) => {
); );
} }
// Update user's profile with couple_id // Update user's profile with couple_id
await supabase await supabase
.from("profiles") .from("profiles")
.update({ couple_id: couple.id }) .update({ couple_id: couple.id })
.eq("id", user.id); .eq("id", user.id);
return new Response( const { data: safePacks, error: safePacksError } = await supabase
JSON.stringify({ .from("question_packs")
success: true, .select("id")
couple_id: couple.id, .lte("avg_intensity", 2)
invite_code: couple.invite_code .eq("is_public", true);
}),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } } if (safePacksError) {
); console.error("Failed to fetch safe packs:", safePacksError);
} else if (safePacks?.length) {
const { error: insertError } = await supabase
.from("couple_packs")
.insert(safePacks.map((pack: { id: string }) => ({
couple_id: couple.id,
pack_id: pack.id,
enabled: true,
})));
if (insertError) {
console.error("Failed to enable safe packs:", insertError);
}
}
return new Response(
JSON.stringify({
success: true,
couple_id: couple.id,
invite_code: couple.invite_code
}),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
} }
} }
@@ -0,0 +1,88 @@
-- Add intensity levels for content balance
ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS max_intensity INTEGER DEFAULT 2
CHECK (max_intensity >= 1 AND max_intensity <= 5);
UPDATE profiles
SET max_intensity = CASE
WHEN show_explicit_content IS TRUE THEN 5
ELSE 2
END
WHERE max_intensity IS NULL;
ALTER TABLE question_packs
ADD COLUMN IF NOT EXISTS min_intensity INTEGER,
ADD COLUMN IF NOT EXISTS max_intensity INTEGER,
ADD COLUMN IF NOT EXISTS avg_intensity NUMERIC(3,2);
CREATE OR REPLACE FUNCTION update_question_pack_intensity_stats(target_pack_id UUID)
RETURNS void
LANGUAGE plpgsql
SET search_path = 'public'
AS $$
BEGIN
UPDATE question_packs qp
SET min_intensity = stats.min_intensity,
max_intensity = stats.max_intensity,
avg_intensity = stats.avg_intensity
FROM (
SELECT pack_id,
MIN(intensity) AS min_intensity,
MAX(intensity) AS max_intensity,
ROUND(AVG(intensity)::numeric, 2) AS avg_intensity
FROM questions
WHERE pack_id = target_pack_id
GROUP BY pack_id
) stats
WHERE qp.id = stats.pack_id;
IF NOT FOUND THEN
UPDATE question_packs
SET min_intensity = NULL,
max_intensity = NULL,
avg_intensity = NULL
WHERE id = target_pack_id;
END IF;
END;
$$;
CREATE OR REPLACE FUNCTION handle_question_intensity_change()
RETURNS TRIGGER
LANGUAGE plpgsql
SET search_path = 'public'
AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
PERFORM update_question_pack_intensity_stats(OLD.pack_id);
RETURN OLD;
END IF;
IF TG_OP = 'UPDATE' AND NEW.pack_id IS DISTINCT FROM OLD.pack_id THEN
PERFORM update_question_pack_intensity_stats(OLD.pack_id);
END IF;
PERFORM update_question_pack_intensity_stats(NEW.pack_id);
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS question_pack_intensity_stats ON questions;
CREATE TRIGGER question_pack_intensity_stats
AFTER INSERT OR UPDATE OR DELETE ON questions
FOR EACH ROW EXECUTE FUNCTION handle_question_intensity_change();
UPDATE question_packs qp
SET min_intensity = stats.min_intensity,
max_intensity = stats.max_intensity,
avg_intensity = stats.avg_intensity
FROM (
SELECT pack_id,
MIN(intensity) AS min_intensity,
MAX(intensity) AS max_intensity,
ROUND(AVG(intensity)::numeric, 2) AS avg_intensity
FROM questions
GROUP BY pack_id
) stats
WHERE qp.id = stats.pack_id;
+7
View File
@@ -313,6 +313,7 @@
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
"csstype": "^3.2.2" "csstype": "^3.2.2"
@@ -449,6 +450,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -865,6 +867,7 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"jiti": "bin/jiti.js" "jiti": "bin/jiti.js"
} }
@@ -1175,6 +1178,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -1367,6 +1371,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
}, },
@@ -1379,6 +1384,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"scheduler": "^0.23.2" "scheduler": "^0.23.2"
@@ -1674,6 +1680,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },