feat(mobile): align store-safe experience

Remove client-side adult-content preferences and badges so the centrally reviewed catalogue is the single visibility boundary.\n\nUse neutral relationship-focused copy across onboarding, discovery, paywall, and login surfaces, and retain biometric controls under App Lock for both native platforms.
This commit is contained in:
jjbrunton committed 2026-08-28 14:36:33 +01:00
1 parent 72e031eb84
commit 7a0de45482
18 files changed
+68 -368

No files matched your search

+1 -1
View File
@@ -54,7 +54,7 @@ export default function DaresScreen() {
{/* Description */} {/* Description */}
<Text style={styles.description}> <Text style={styles.description}>
Push your boundaries together with tantalising challenges designed to ignite passion and create unforgettable moments. Try playful challenges together and create unforgettable moments.
</Text> </Text>
{/* Feature hints */} {/* Feature hints */}
+14 -135
View File
@@ -24,7 +24,6 @@ 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 { GlassToggle } from '../../src/components/ui/GlassToggle';
import { Paywall } from '../../src/components/paywall'; import { Paywall } from '../../src/components/paywall';
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';
@@ -44,23 +43,17 @@ const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
{ value: 'prefer-not-to-say', label: 'Skip', icon: 'remove-circle-outline' }, { value: 'prefer-not-to-say', label: 'Skip', icon: 'remove-circle-outline' },
]; ];
type UsageReason = 'improve_communication' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship'; type UsageReason = 'improve_communication' | 'reconnect' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship';
const PURPOSE_OPTIONS: { value: UsageReason; label: string; icon: string }[] = [ const PURPOSE_OPTIONS: { value: UsageReason; label: string; icon: string }[] = [
{ value: 'improve_communication', label: 'Improve communication', icon: 'chatbubbles' }, { value: 'improve_communication', label: 'Improve communication', icon: 'chatbubbles' },
{ value: 'spice_up_intimacy', label: 'Spice up intimacy', icon: 'flame' }, { value: 'reconnect', label: 'Feel more connected', icon: 'flame' },
{ value: 'deeper_connection', label: 'Build deeper connection', icon: 'heart' }, { value: 'deeper_connection', label: 'Build deeper connection', icon: 'heart' },
{ value: 'have_fun', label: 'Have fun together', icon: 'happy' }, { value: 'have_fun', label: 'Have fun together', icon: 'happy' },
{ value: 'strengthen_relationship', label: 'Strengthen our relationship', icon: 'shield-checkmark' }, { value: 'strengthen_relationship', label: 'Strengthen our relationship', icon: 'shield-checkmark' },
]; ];
// Intensity thresholds for backwards compatibility type Stage = 'avatar' | 'name' | 'gender' | 'purpose' | 'notifications';
// When hide_nsfw=true, max_intensity=2 (mild content only)
// When hide_nsfw=false, max_intensity=5 (all content)
const NSFW_OFF_INTENSITY = 2;
const NSFW_ON_INTENSITY = 5;
type Stage = 'avatar' | 'name' | 'gender' | 'purpose' | 'content' | 'notifications';
export default function OnboardingScreen() { export default function OnboardingScreen() {
const router = useRouter(); const router = useRouter();
@@ -72,7 +65,6 @@ export default function OnboardingScreen() {
const [name, setName] = useState(initialUser.current?.name || ''); const [name, setName] = useState(initialUser.current?.name || '');
const [gender, setGender] = useState<Gender | null>(initialUser.current?.gender || null); const [gender, setGender] = useState<Gender | null>(initialUser.current?.gender || null);
const [usageReason, setUsageReason] = useState<UsageReason | null>(null); const [usageReason, setUsageReason] = useState<UsageReason | null>(null);
const [hideNsfw, setHideNsfw] = useState(initialUser.current?.hide_nsfw ?? false);
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 [showPaywall, setShowPaywall] = useState(false); const [showPaywall, setShowPaywall] = useState(false);
@@ -137,14 +129,9 @@ export default function OnboardingScreen() {
setError('Please select what brings you to Sauci'); setError('Please select what brings you to Sauci');
return; return;
} }
setStage('content'); setStage('notifications');
Events.onboardingStageComplete('purpose'); Events.onboardingStageComplete('purpose');
}; };
const handleContentContinue = () => {
setStage('notifications');
Events.onboardingStageComplete('content');
};
const handleEnableNotifications = async () => { const handleEnableNotifications = async () => {
setIsLoading(true); setIsLoading(true);
@@ -182,18 +169,17 @@ export default function OnboardingScreen() {
} }
} }
// Derive intensity from hide_nsfw for backwards compatibility const updatedFields: string[] = ["name", "gender", "usage_reason"];
const maxIntensity = hideNsfw ? NSFW_OFF_INTENSITY : NSFW_ON_INTENSITY;
const showExplicitContent = !hideNsfw;
const updatedFields: string[] = ["name", "gender", "usage_reason", "max_intensity", "show_explicit_content", "hide_nsfw"];
const updateData: Record<string, any> = { const updateData: Record<string, any> = {
name: name.trim(), name: name.trim(),
gender, gender,
usage_reason: usageReason, usage_reason: usageReason,
max_intensity: maxIntensity, // Preserve the safest legacy profile values. Catalogue
show_explicit_content: showExplicitContent, // eligibility is enforced centrally by reviewed status.
hide_nsfw: hideNsfw, max_intensity: 2,
show_explicit_content: false,
hide_nsfw: true,
onboarding_completed: true, onboarding_completed: true,
onboarding_version: REQUIRED_ONBOARDING_VERSION, onboarding_version: REQUIRED_ONBOARDING_VERSION,
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
@@ -505,73 +491,6 @@ export default function OnboardingScreen() {
</Animated.View> </Animated.View>
); );
case 'content':
return (
<Animated.View
key="content"
entering={SlideInRight.duration(400)}
exiting={FadeOut.duration(200)}
style={styles.stageContainer}
>
<View style={styles.header}>
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.iconContainer}
>
<Ionicons name="flame" size={44} color={colors.text} />
</LinearGradient>
<Text style={styles.title}>Content Preferences</Text>
<Text style={styles.subtitle}>
Choose what type of content you'd like to see
</Text>
</View>
<GlassCard style={styles.card}>
<View style={styles.contentToggleRow}>
<View style={styles.contentToggleLeft}>
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.contentToggleIcon}
>
<Ionicons name="eye-off" size={24} color={colors.text} />
</LinearGradient>
<View style={styles.contentToggleText}>
<Text style={styles.contentToggleLabel}>Hide Adult Content</Text>
<Text style={styles.contentToggleDescription}>
Only show mild, family-friendly question packs
</Text>
</View>
</View>
<GlassToggle
value={hideNsfw}
onValueChange={setHideNsfw}
/>
</View>
<Text style={styles.contentNote}>
You can change this anytime in settings
</Text>
{error && (
<View style={styles.errorContainer}>
<Ionicons name="alert-circle" size={16} color={colors.error} />
<Text style={styles.errorText}>{error}</Text>
</View>
)}
</GlassCard>
<View style={styles.footer}>
<GlassButton
onPress={handleContentContinue}
fullWidth
size="lg"
>
Continue
</GlassButton>
</View>
</Animated.View>
);
case 'notifications': case 'notifications':
return ( return (
<Animated.View <Animated.View
@@ -660,8 +579,7 @@ export default function OnboardingScreen() {
style={styles.backButton} style={styles.backButton}
onPress={() => { onPress={() => {
setError(null); setError(null);
if (stage === 'notifications') setStage('content'); if (stage === 'notifications') setStage('purpose');
else if (stage === 'content') setStage('purpose');
else if (stage === 'purpose') setStage('gender'); else if (stage === 'purpose') setStage('gender');
else if (stage === 'gender') setStage('name'); else if (stage === 'gender') setStage('name');
else if (stage === 'name') setStage('avatar'); else if (stage === 'name') setStage('avatar');
@@ -677,8 +595,7 @@ export default function OnboardingScreen() {
<View style={[styles.progressDot, stage === 'name' && styles.progressDotActive]} /> <View style={[styles.progressDot, stage === 'name' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'gender' && styles.progressDotActive]} /> <View style={[styles.progressDot, stage === 'gender' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'purpose' && styles.progressDotActive]} /> <View style={[styles.progressDot, stage === 'purpose' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'content' && styles.progressDotActive]} /> <View style={[styles.progressDot, stage === 'notifications' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'notifications' && styles.progressDotActive]} />
</View> </View>
<View style={styles.backButtonPlaceholder} /> <View style={styles.backButtonPlaceholder} />
</Animated.View> </Animated.View>
@@ -954,44 +871,6 @@ const styles = StyleSheet.create({
color: colors.text, color: colors.text,
fontWeight: '600', fontWeight: '600',
}, },
contentToggleRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
contentToggleLeft: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
marginRight: spacing.md,
},
contentToggleIcon: {
width: 48,
height: 48,
borderRadius: 24,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.md,
},
contentToggleText: {
flex: 1,
},
contentToggleLabel: {
...typography.headline,
color: colors.text,
},
contentToggleDescription: {
...typography.caption1,
color: colors.textSecondary,
marginTop: 2,
},
contentNote: {
...typography.caption1,
color: colors.textSecondary,
textAlign: 'center',
marginTop: spacing.lg,
},
radioOuterSelected: { radioOuterSelected: {
borderColor: colors.primary, borderColor: colors.primary,
}, },
+2 -2
View File
@@ -34,7 +34,7 @@ export default function QuizScreen() {
style={styles.content} style={styles.content}
> >
{/* Title section */} {/* Title section */}
<Text style={styles.label}>INTIMACY</Text> <Text style={styles.label}>CONNECTION</Text>
<Text style={styles.title}>Quiz</Text> <Text style={styles.title}>Quiz</Text>
{/* Decorative separator */} {/* Decorative separator */}
@@ -61,7 +61,7 @@ export default function QuizScreen() {
<View style={styles.features}> <View style={styles.features}>
<View style={styles.featureItem}> <View style={styles.featureItem}>
<Ionicons name="sparkles" size={16} color={ACCENT_COLOR} /> <Ionicons name="sparkles" size={16} color={ACCENT_COLOR} />
<Text style={styles.featureText}>Uncover hidden desires</Text> <Text style={styles.featureText}>Learn what makes each other tick</Text>
</View> </View>
<View style={styles.featureItem}> <View style={styles.featureItem}>
<Ionicons name="flame-outline" size={16} color={ACCENT_COLOR} /> <Ionicons name="flame-outline" size={16} color={ACCENT_COLOR} />
+1 -1
View File
@@ -489,7 +489,7 @@ export default function LoginScreen() {
/> />
<Text style={styles.title}>Sauci</Text> <Text style={styles.title}>Sauci</Text>
<Text style={styles.subtitle}> <Text style={styles.subtitle}>
Explore intimacy together Grow closer together
</Text> </Text>
</Animated.View> </Animated.View>
-25
View File
@@ -242,14 +242,6 @@ export default function PackDetailsScreen() {
{category ? category.name.toUpperCase() : 'PREVIEW'} {category ? category.name.toUpperCase() : 'PREVIEW'}
</Text> </Text>
{/* Explicit badge */}
{pack?.is_explicit && (
<View style={styles.explicitBadge}>
<Ionicons name="flame" size={12} color={colors.text} />
<Text style={styles.explicitBadgeText}>18+ EXPLICIT</Text>
</View>
)}
{/* Icon with enhanced glow for premium */} {/* Icon with enhanced glow for premium */}
<View style={styles.iconWrapper}> <View style={styles.iconWrapper}>
{pack?.is_premium && ( {pack?.is_premium && (
@@ -591,23 +583,6 @@ const styles = StyleSheet.create({
labelPremium: { labelPremium: {
color: colors.premium.gold, color: colors.premium.gold,
}, },
explicitBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.error,
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radius.full,
gap: spacing.xs,
marginBottom: spacing.md,
},
explicitBadgeText: {
...typography.caption2,
fontWeight: '700',
color: colors.text,
letterSpacing: 1,
},
// Removed glow styles // Removed glow styles
iconGlowOuter: { iconGlowOuter: {
display: 'none', display: 'none',
@@ -18,15 +18,12 @@ describe('packsStore', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
usePacksStore.setState({ packs: [], categories: [], enabledPackIds: [], packProgress: new Map(), isLoading: false } as any); usePacksStore.setState({ packs: [], categories: [], enabledPackIds: [], packProgress: new Map(), isLoading: false } as any);
useAuthStore.setState({ user: { id: 'me', couple_id: 'c1', hide_nsfw: false } } as any); useAuthStore.setState({ user: { id: 'me', couple_id: 'c1' } } as any);
}); });
it('fetches categories/packs and filters by hide_nsfw', async () => { it('fetches the centrally reviewed public catalogue', async () => {
// Set hide_nsfw to true - should filter intensity to max 2
useAuthStore.setState({ user: { id: 'me', couple_id: 'c1', hide_nsfw: true } } as any);
const categoriesQuery = createThenableQuery({ data: [{ id: 'cat1' }] }); const categoriesQuery = createThenableQuery({ data: [{ id: 'cat1' }] });
const packsQuery = createThenableQuery({ data: [{ id: 'pack1', is_explicit: false }] }); const packsQuery = createThenableQuery({ data: [{ id: 'pack1' }] });
const enabledQuery = createThenableQuery({ data: [{ pack_id: 'pack1' }] }); const enabledQuery = createThenableQuery({ data: [{ pack_id: 'pack1' }] });
const responsesQuery = createThenableQuery({ data: [] }); const responsesQuery = createThenableQuery({ data: [] });
@@ -38,14 +35,12 @@ describe('packsStore', () => {
await usePacksStore.getState().fetchPacks(); await usePacksStore.getState().fetchPacks();
// When hide_nsfw=true, max_intensity=2, so or filter is called expect(packsQuery.eq).toHaveBeenCalledWith('is_public', true);
expect(packsQuery.or).toHaveBeenCalledWith('max_intensity.is.null,max_intensity.lte.2'); expect(packsQuery.or).not.toHaveBeenCalled();
// When hide_nsfw=true, explicit packs are filtered out
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' }]);
expect(state.packs).toEqual([{ id: 'pack1', is_explicit: false }]); expect(state.packs).toEqual([{ id: 'pack1' }]);
expect(state.enabledPackIds).toEqual(['pack1']); expect(state.enabledPackIds).toEqual(['pack1']);
expect(state.isLoading).toBe(false); expect(state.isLoading).toBe(false);
}); });
@@ -92,18 +92,6 @@ export function BoutiquePackCard({
{/* Content */} {/* Content */}
<View style={styles.content}> <View style={styles.content}>
{/* Explicit Badge (top-left) */}
{pack.is_explicit && (
<View style={styles.explicitBadge}>
<Ionicons
name="flame"
size={10}
color={colors.text}
/>
<Text style={styles.explicitBadgeText}>18+</Text>
</View>
)}
{/* PRO Badge (top-right) */} {/* PRO Badge (top-right) */}
{pack.is_premium && ( {pack.is_premium && (
<View style={[ <View style={[
@@ -242,24 +230,6 @@ const styles = StyleSheet.create({
padding: spacing.md, padding: spacing.md,
justifyContent: 'space-between', justifyContent: 'space-between',
}, },
explicitBadge: {
position: 'absolute',
top: spacing.sm,
left: spacing.sm,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.error,
paddingHorizontal: spacing.sm,
paddingVertical: 3,
borderRadius: radius.xs,
gap: 3,
zIndex: 10,
},
explicitBadgeText: {
...typography.caption2,
color: colors.text,
fontWeight: '700',
},
proBadge: { proBadge: {
position: 'absolute', position: 'absolute',
top: spacing.sm, top: spacing.sm,
@@ -54,12 +54,6 @@ export function ContentTile({ pack, isLocked, isNew = false, progress, onPress }
/> />
</View> </View>
<View style={styles.badges}> <View style={styles.badges}>
{pack.is_explicit && (
<View style={styles.explicitBadge}>
<Ionicons name="flame" size={10} color={colors.text} />
<Text style={styles.explicitBadgeText}>18+</Text>
</View>
)}
{isNew && ( {isNew && (
<View style={styles.newBadge}> <View style={styles.newBadge}>
<Text style={styles.newBadgeText}>NEW</Text> <Text style={styles.newBadgeText}>NEW</Text>
@@ -129,20 +123,6 @@ const styles = StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
gap: spacing.xs, gap: spacing.xs,
}, },
explicitBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.error,
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderRadius: radius.xs,
gap: 2,
},
explicitBadgeText: {
...typography.caption2,
fontWeight: '700',
color: colors.text,
},
newBadge: { newBadge: {
backgroundColor: 'rgba(255, 255, 255, 0.95)', backgroundColor: 'rgba(255, 255, 255, 0.95)',
paddingHorizontal: spacing.sm, paddingHorizontal: spacing.sm,
@@ -34,7 +34,7 @@ const FEATURES = [
{ {
icon: "layers" as const, icon: "layers" as const,
title: "All Premium Packs", title: "All Premium Packs",
description: "Unlock every intimate collection", description: "Unlock every question collection",
}, },
{ {
icon: "sparkles" as const, icon: "sparkles" as const,
@@ -206,7 +206,7 @@ export function Paywall({ visible, onClose, onSuccess, offeringId }: PaywallProp
<Text style={styles.title}>Sauci Pro</Text> <Text style={styles.title}>Sauci Pro</Text>
<Text style={styles.subtitle}> <Text style={styles.subtitle}>
Unlock the full intimate experience Unlock the full couples experience
</Text> </Text>
</View> </View>
@@ -48,7 +48,7 @@ const STEPS: TutorialStep[] = [
{ {
key: "photos", key: "photos",
title: "Share Pictures Privately", title: "Share Pictures Privately",
description: "Send photos that only your partner can see. Your intimate moments stay between the two of you, safe and private.", description: "Send photos that only your partner can see. Your shared moments stay between the two of you, safe and private.",
icon: "images", icon: "images",
gradient: gradients.premiumRose as [string, string], gradient: gradients.premiumRose as [string, string],
}, },
@@ -8,17 +8,17 @@ export interface IntensityLevelConfig {
} }
export const INTENSITY_LEVELS: IntensityLevelConfig[] = [ export const INTENSITY_LEVELS: IntensityLevelConfig[] = [
{ level: 1, label: 'Gentle', emoji: '💭', description: 'Pure emotional connection & non-sexual bonding' }, { level: 1, label: 'Gentle', emoji: '💭', description: 'Easy conversation and everyday connection' },
{ level: 2, label: 'Warm', emoji: '💕', description: 'Romantic atmosphere & affectionate touch' }, { level: 2, label: 'Warm', emoji: '💕', description: 'Romance, appreciation, and thoughtful moments' },
{ level: 3, label: 'Playful', emoji: '😏', description: 'Light sexual exploration & sensual discovery' }, { level: 3, label: 'Playful', emoji: '✨', description: 'Light-hearted questions and shared laughter' },
{ level: 4, label: 'Steamy', emoji: '🔥', description: 'Explicit sexual activities & moderate adventure' }, { level: 4, label: 'Adventurous', emoji: '🌍', description: 'New experiences and ideas to try together' },
{ level: 5, label: 'Intense', emoji: '🌶️', description: 'Advanced/BDSM/Extreme exploration' }, { level: 5, label: 'Deep', emoji: '🌱', description: 'Meaningful reflection and honest conversation' },
]; ];
export const HEAT_COLORS = [ export const HEAT_COLORS = [
'#9b59b6', // Purple - gentle '#9b59b6', // Purple - gentle
'#e94560', // Rose - warm '#e94560', // Rose - warm
'#ff6b6b', // Coral - playful '#ff6b6b', // Coral - playful
'#ff4757', // Red - steamy '#ff4757', // Red - adventurous
'#ff3333', // Bright red - intense '#ff3333', // Bright red - intense
]; ];
@@ -160,9 +160,9 @@ export function ProfileScreen() {
/> />
<View style={styles.divider} /> <View style={styles.divider} />
<MenuItem <MenuItem
icon="options-outline" icon="lock-closed-outline"
label="Preferences" label="App Lock"
description="Comfort zone and security" description="Biometric access controls"
onPress={() => router.push('/(app)/settings/preferences')} onPress={() => router.push('/(app)/settings/preferences')}
/> />
</SettingsSection> </SettingsSection>
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { View, StyleSheet } from 'react-native'; import { Text, View, StyleSheet } from 'react-native';
import { SettingsSection } from './SettingsSection'; import { SettingsSection } from './SettingsSection';
import { SwitchItem } from './SwitchItem'; import { SwitchItem } from './SwitchItem';
import { colors, spacing } from '../../../theme'; import { colors, spacing } from '../../../theme';
@@ -10,9 +10,6 @@ interface PrivacySettingsProps {
biometricType: string; biometricType: string;
isUpdatingBiometric: boolean; isUpdatingBiometric: boolean;
onBiometricToggle: (value: boolean) => void; onBiometricToggle: (value: boolean) => void;
hideNsfw: boolean;
isUpdatingHideNsfw: boolean;
onHideNsfwToggle: (value: boolean) => void;
} }
export const PrivacySettings: React.FC<PrivacySettingsProps> = ({ export const PrivacySettings: React.FC<PrivacySettingsProps> = ({
@@ -21,44 +18,37 @@ export const PrivacySettings: React.FC<PrivacySettingsProps> = ({
biometricType, biometricType,
isUpdatingBiometric, isUpdatingBiometric,
onBiometricToggle, onBiometricToggle,
hideNsfw,
isUpdatingHideNsfw,
onHideNsfwToggle,
}) => { }) => {
return ( return (
<SettingsSection title="Preferences" delay={375}> <SettingsSection title="Security" delay={375}>
{/* Hide Adult Content Toggle */}
<SwitchItem
icon="eye-off-outline"
label="Hide Adult Content"
description="Only show mild, family-friendly question packs"
value={hideNsfw}
onValueChange={onHideNsfwToggle}
disabled={isUpdatingHideNsfw}
/>
{/* Biometric Toggle */}
{biometricAvailable && ( {biometricAvailable && (
<> <SwitchItem
<View style={styles.divider} /> icon={biometricType === "Face ID" || biometricType === "Face Recognition" ? "scan-outline" : "finger-print-outline"}
<SwitchItem label={biometricType}
icon={biometricType === "Face ID" || biometricType === "Face Recognition" ? "scan-outline" : "finger-print-outline"} description="Require unlock when opening app"
label={biometricType} value={biometricEnabled}
description="Require unlock when opening app" onValueChange={onBiometricToggle}
value={biometricEnabled} disabled={isUpdatingBiometric}
onValueChange={onBiometricToggle} />
disabled={isUpdatingBiometric} )}
/> {!biometricAvailable && (
</> <View style={styles.unavailableRow}>
<Text style={styles.unavailableText}>
Biometric app lock is not available on this device.
</Text>
</View>
)} )}
</SettingsSection> </SettingsSection>
); );
}; };
const styles = StyleSheet.create({ const styles = StyleSheet.create({
divider: { unavailableRow: {
height: 1, paddingHorizontal: spacing.lg,
backgroundColor: colors.border, paddingVertical: spacing.md,
marginVertical: spacing.lg, },
unavailableText: {
color: colors.textSecondary,
lineHeight: 20,
}, },
}); });
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Alert, Linking, Platform } from 'react-native'; import { Alert, Linking, Platform } from 'react-native';
import { useAuthStore, useSubscriptionStore, usePacksStore } from '../../../store'; import { useAuthStore, useSubscriptionStore } from '../../../store';
import { supabase } from '../../../lib/supabase'; import { supabase } from '../../../lib/supabase';
import { Events } from '../../../lib/analytics'; import { Events } from '../../../lib/analytics';
import { useAvatarPicker } from '../../../hooks/useAvatarPicker'; import { useAvatarPicker } from '../../../hooks/useAvatarPicker';
@@ -16,15 +16,8 @@ import {
getBiometricType, getBiometricType,
} from '../../../lib/biometricAuth'; } from '../../../lib/biometricAuth';
// Intensity thresholds for backwards compatibility
// When hide_nsfw=true, max_intensity=2 (mild content only)
// When hide_nsfw=false, max_intensity=5 (all content)
const NSFW_OFF_INTENSITY = 2;
const NSFW_ON_INTENSITY = 5;
export function useProfileSettings() { export function useProfileSettings() {
const { user, fetchUser } = useAuthStore(); const { user, fetchUser } = useAuthStore();
const { fetchPacks } = usePacksStore();
const { restorePurchases, isPurchasing } = useSubscriptionStore(); const { restorePurchases, isPurchasing } = useSubscriptionStore();
// Name Editing // Name Editing
@@ -69,10 +62,6 @@ export function useProfileSettings() {
onUploadError: handleAvatarUploadError, onUploadError: handleAvatarUploadError,
}); });
// Hide NSFW
const [hideNsfw, setHideNsfw] = useState(!!user?.hide_nsfw);
const [isUpdatingHideNsfw, setIsUpdatingHideNsfw] = useState(false);
// Notifications // Notifications
const [pushEnabled, setPushEnabled] = useState(!!user?.push_token); const [pushEnabled, setPushEnabled] = useState(!!user?.push_token);
const [isUpdatingPush, setIsUpdatingPush] = useState(false); const [isUpdatingPush, setIsUpdatingPush] = useState(false);
@@ -92,7 +81,6 @@ export function useProfileSettings() {
setNewName(user.name); setNewName(user.name);
} }
setPushEnabled(!!user?.push_token); setPushEnabled(!!user?.push_token);
setHideNsfw(!!user?.hide_nsfw);
}, [user]); }, [user]);
// Check biometric availability // Check biometric availability
@@ -229,43 +217,6 @@ export function useProfileSettings() {
} }
}; };
const handleHideNsfwToggle = async (value: boolean) => {
if (!user?.id) return;
const previous = hideNsfw;
setHideNsfw(value);
setIsUpdatingHideNsfw(true);
// Derive intensity from hide_nsfw for backwards compatibility
const maxIntensity = value ? NSFW_OFF_INTENSITY : NSFW_ON_INTENSITY;
const showExplicitContent = !value;
try {
const { error } = await supabase
.from('profiles')
.update({
hide_nsfw: value,
max_intensity: maxIntensity,
show_explicit_content: showExplicitContent,
updated_at: new Date().toISOString(),
})
.eq('id', user.id);
if (error) throw error;
// Refresh user data and packs to reflect the change
await fetchUser();
await fetchPacks();
Events.profileUpdated(["hide_nsfw", "max_intensity", "show_explicit_content"]);
} catch (error) {
// Revert on error
setHideNsfw(previous);
Alert.alert("Error", "Failed to update preference. Please try again.");
} finally {
setIsUpdatingHideNsfw(false);
}
};
// Subscription Actions // Subscription Actions
const handleRestorePurchases = async () => { const handleRestorePurchases = async () => {
const restored = await restorePurchases(); const restored = await restorePurchases();
@@ -294,8 +245,6 @@ export function useProfileSettings() {
biometricEnabled: biometricEnabledState, biometricEnabled: biometricEnabledState,
biometricType, biometricType,
isUpdatingBiometric, isUpdatingBiometric,
hideNsfw,
isUpdatingHideNsfw,
showPaywall, showPaywall,
setShowPaywall, setShowPaywall,
isPurchasing, isPurchasing,
@@ -306,7 +255,6 @@ export function useProfileSettings() {
handleAvatarPress, handleAvatarPress,
handlePushToggle, handlePushToggle,
handleBiometricToggle, handleBiometricToggle,
handleHideNsfwToggle,
handleRestorePurchases, handleRestorePurchases,
handleManageSubscription, handleManageSubscription,
}; };
@@ -7,14 +7,14 @@ import { useProfileSettings } from '../hooks';
import { ScreenHeader, PrivacySettings } from '../components'; import { ScreenHeader, PrivacySettings } from '../components';
/** /**
* Preferences sub-screen for content and biometric settings. * App-lock settings shared by iOS and Android.
*/ */
export function PreferencesScreen() { export function PreferencesScreen() {
const settings = useProfileSettings(); const settings = useProfileSettings();
return ( return (
<GradientBackground> <GradientBackground>
<ScreenHeader title="Preferences" /> <ScreenHeader title="App Lock" />
<ScrollView <ScrollView
style={styles.container} style={styles.container}
contentContainerStyle={styles.content} contentContainerStyle={styles.content}
@@ -26,10 +26,7 @@ export function PreferencesScreen() {
biometricType={settings.biometricType} biometricType={settings.biometricType}
isUpdatingBiometric={settings.isUpdatingBiometric} isUpdatingBiometric={settings.isUpdatingBiometric}
onBiometricToggle={settings.handleBiometricToggle} onBiometricToggle={settings.handleBiometricToggle}
hideNsfw={settings.hideNsfw} />
isUpdatingHideNsfw={settings.isUpdatingHideNsfw}
onHideNsfwToggle={settings.handleHideNsfwToggle}
/>
</ScrollView> </ScrollView>
</GradientBackground> </GradientBackground>
); );
@@ -17,7 +17,7 @@ export const SwipeNoPacksState = ({ onBrowsePacks }: SwipeNoPacksStateProps) =>
badgeText="NO PACKS ENABLED" badgeText="NO PACKS ENABLED"
description="Select the question packs that interest you and your partner. Each pack explores different aspects of your relationship." description="Select the question packs that interest you and your partner. Each pack explores different aspects of your relationship."
features={[ features={[
{ icon: "flame-outline", text: "From playful to passionate", color: accent }, { icon: "flame-outline", text: "From playful to adventurous", color: accent },
{ icon: "shield-checkmark-outline", text: "Safe space to explore", color: accent }, { icon: "shield-checkmark-outline", text: "Safe space to explore", color: accent },
{ icon: "infinite-outline", text: "New packs added regularly", color: accent }, { icon: "infinite-outline", text: "New packs added regularly", color: accent },
]} ]}
@@ -38,10 +38,10 @@ export const SwipeNoPartnerState = ({ hasCouple, onPairPress }: SwipeNoPartnerSt
} }
features={[ features={[
{ icon: "lock-closed-outline", text: "Private and secure", color: accent }, { icon: "lock-closed-outline", text: "Private and secure", color: accent },
{ icon: "sparkles", text: "Discover hidden desires", color: accent }, { icon: "sparkles", text: "Discover new things together", color: accent },
{ icon: "chatbubble-ellipses-outline", text: "Chat about your matches", color: accent }, { icon: "chatbubble-ellipses-outline", text: "Chat about your matches", color: accent },
]} ]}
teaser={hasCouple ? "Your partner is just a code away" : "Begin your intimate journey together"} teaser={hasCouple ? "Your partner is just a code away" : "Begin your journey together"}
action={{ action={{
label: hasCouple ? "View Invite Code" : "Pair Now", label: hasCouple ? "View Invite Code" : "Pair Now",
onPress: onPairPress, onPress: onPairPress,
+1 -35
View File
@@ -4,16 +4,6 @@ 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";
// Intensity thresholds derived from hide_nsfw for backwards compatibility
// When hide_nsfw=true, max_intensity=2 (mild content only)
// When hide_nsfw=false, max_intensity=5 (all content)
const NSFW_OFF_INTENSITY = 2;
const NSFW_ON_INTENSITY = 5;
const getMaxIntensityFromHideNsfw = (hideNsfw: boolean) => {
return hideNsfw ? NSFW_OFF_INTENSITY : NSFW_ON_INTENSITY;
};
// Progress data for a pack // Progress data for a pack
export interface PackProgressData { export interface PackProgressData {
totalQuestions: number; totalQuestions: number;
@@ -26,13 +16,11 @@ interface PacksState {
enabledPackIds: string[]; enabledPackIds: string[];
packProgress: Map<string, PackProgressData>; packProgress: Map<string, PackProgressData>;
isLoading: boolean; isLoading: boolean;
showAllIntensities: boolean;
fetchPacks: () => Promise<void>; fetchPacks: () => Promise<void>;
fetchEnabledPacks: () => Promise<void>; fetchEnabledPacks: () => Promise<void>;
fetchPackProgress: () => Promise<void>; fetchPackProgress: () => Promise<void>;
ensureEnabledPacksLoaded: () => Promise<void>; ensureEnabledPacksLoaded: () => Promise<void>;
togglePack: (packId: string) => Promise<{ success: boolean; reason?: string }>; togglePack: (packId: string) => Promise<{ success: boolean; reason?: string }>;
setShowAllIntensities: (value: boolean) => void;
clearPacks: () => void; clearPacks: () => void;
getPackProgress: (packId: string) => PackProgressData | undefined; getPackProgress: (packId: string) => PackProgressData | undefined;
} }
@@ -43,16 +31,10 @@ export const usePacksStore = create<PacksState>((set, get) => ({
enabledPackIds: [], enabledPackIds: [],
packProgress: new Map(), packProgress: new Map(),
isLoading: false, isLoading: false,
showAllIntensities: false,
fetchPacks: async () => { fetchPacks: async () => {
set({ isLoading: true }); set({ isLoading: true });
// Get hide_nsfw preference and derive max_intensity from it
const hideNsfw = useAuthStore.getState().user?.hide_nsfw ?? false;
const maxIntensity = getMaxIntensityFromHideNsfw(hideNsfw);
const showAllIntensities = get().showAllIntensities;
// Fetch categories // Fetch categories
const { data: categories } = await supabase const { data: categories } = await supabase
.from("categories") .from("categories")
@@ -64,22 +46,12 @@ export const usePacksStore = create<PacksState>((set, get) => ({
const visibleCategoryIds = new Set(visibleCategories.map(category => category.id)); const visibleCategoryIds = new Set(visibleCategories.map(category => category.id));
// Fetch packs with category info and question count // Fetch packs with category info and question count
let query = supabase const query = supabase
.from("question_packs") .from("question_packs")
.select("*, category:categories(*), questions(count)") .select("*, category:categories(*), questions(count)")
.eq("is_public", true) .eq("is_public", true)
.order("sort_order"); .order("sort_order");
// Filter out packs above user's intensity preference (unless showing all)
if (maxIntensity < 5 && !showAllIntensities) {
query = query.or(`max_intensity.is.null,max_intensity.lte.${maxIntensity}`);
}
// Filter out explicit packs when user has hide_nsfw enabled
if (hideNsfw) {
query = query.eq("is_explicit", false);
}
const { data: packs } = await query; const { data: packs } = await query;
const visiblePacks = (packs ?? []).filter(pack => { const visiblePacks = (packs ?? []).filter(pack => {
@@ -171,12 +143,6 @@ export const usePacksStore = create<PacksState>((set, get) => ({
return { success: true }; return { success: true };
}, },
setShowAllIntensities: (value: boolean) => {
set({ showAllIntensities: value });
// Refetch packs with new filter setting
get().fetchPacks();
},
fetchPackProgress: async () => { fetchPackProgress: async () => {
const userId = useAuthStore.getState().user?.id; const userId = useAuthStore.getState().user?.id;
if (!userId) { if (!userId) {