Add discovery UI, hide NSFW setting, and pack progress

- New discovery components (ContentTile, ContentRow, CircularProgress)
- Refactor home screen with compact header and content tiles
- Add hide_nsfw profile setting for content filtering
- Add color column to categories table
- Track pack progress (answered/total questions)
- Update related stores and tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jjbruntonandClaude Opus 4.5 committed 2026-01-25 10:14:44 +00:00
1 parent bc38e41572
commit 69ac0940d3
29 files changed
+2048 -1174

No files matched your search

@@ -1,88 +1,41 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Modal, Platform, ScrollView } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { BlurView } from 'expo-blur';
import { Ionicons } from '@expo/vector-icons';
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { SettingsSection } from './SettingsSection';
import { SwitchItem } from './SwitchItem';
import { IntensitySlider } from '../../../components/ui/IntensitySlider';
import { GlassButton } from '../../../components/ui';
import { colors, spacing, radius, typography, featureColors } from '../../../theme';
import type { IntensityLevel } from '../../../types';
import { colors, spacing } from '../../../theme';
interface PrivacySettingsProps {
maxIntensity: IntensityLevel;
isUpdatingIntensity: boolean;
onIntensityChange: (value: IntensityLevel) => void;
biometricAvailable: boolean;
biometricEnabled: boolean;
biometricType: string;
isUpdatingBiometric: boolean;
onBiometricToggle: (value: boolean) => void;
// Partner info for slider comparison
partnerIntensity?: IntensityLevel | null;
partnerName?: string;
partnerAvatar?: string | null;
hideNsfw: boolean;
isUpdatingHideNsfw: boolean;
onHideNsfwToggle: (value: boolean) => void;
}
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
export const PrivacySettings: React.FC<PrivacySettingsProps> = ({
maxIntensity,
isUpdatingIntensity,
onIntensityChange,
biometricAvailable,
biometricEnabled,
biometricType,
isUpdatingBiometric,
onBiometricToggle,
partnerIntensity,
partnerName,
partnerAvatar,
hideNsfw,
isUpdatingHideNsfw,
onHideNsfwToggle,
}) => {
const [showHelpModal, setShowHelpModal] = useState(false);
return (
<>
<SettingsSection title="Preferences" delay={375}>
{/* Comfort Zone Header */}
<View style={styles.comfortHeader}>
<LinearGradient
colors={ACCENT_GRADIENT}
style={styles.comfortIcon}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="heart-circle" size={22} color={colors.text} />
</LinearGradient>
<View style={styles.comfortText}>
<View style={styles.comfortLabelRow}>
<Text style={styles.comfortLabel}>Comfort Zone</Text>
<TouchableOpacity
onPress={() => setShowHelpModal(true)}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
activeOpacity={0.7}
>
<Ionicons name="help-circle-outline" size={20} color={colors.textSecondary} />
</TouchableOpacity>
</View>
<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>
{/* 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 && (
@@ -99,207 +52,13 @@ export const PrivacySettings: React.FC<PrivacySettingsProps> = ({
</>
)}
</SettingsSection>
{/* Help Modal */}
<Modal
visible={showHelpModal}
transparent
animationType="fade"
onRequestClose={() => setShowHelpModal(false)}
>
<BlurView
intensity={Platform.OS === 'ios' ? 20 : 0}
tint="dark"
style={StyleSheet.absoluteFill}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>How Comfort Zone Works</Text>
<TouchableOpacity
onPress={() => setShowHelpModal(false)}
style={styles.closeButton}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="close" size={24} color={colors.textSecondary} />
</TouchableOpacity>
</View>
<ScrollView
style={styles.helpScrollView}
showsVerticalScrollIndicator={false}
>
<View style={styles.helpSection}>
<View style={styles.helpItem}>
<Ionicons name="layers-outline" size={20} color={colors.primary} style={styles.helpIcon} />
<View style={styles.helpTextContainer}>
<Text style={styles.helpItemTitle}>Intensity Levels</Text>
<Text style={styles.helpItemDescription}>
Questions range from 1 (mild) to 5 (spicy). You'll only see questions at or below your comfort level.
</Text>
</View>
</View>
<View style={styles.helpItem}>
<Ionicons name="albums-outline" size={20} color={colors.primary} style={styles.helpIcon} />
<View style={styles.helpTextContainer}>
<Text style={styles.helpItemTitle}>Hidden Packs</Text>
<Text style={styles.helpItemDescription}>
Some question packs are designed for higher intensity levels. These packs won't appear until you raise your comfort zone.
</Text>
</View>
</View>
<View style={styles.helpItem}>
<Ionicons name="people-outline" size={20} color={colors.primary} style={styles.helpIcon} />
<View style={styles.helpTextContainer}>
<Text style={styles.helpItemTitle}>Comfort Zone Gate</Text>
<Text style={styles.helpItemDescription}>
You both see the same questions based on the lower comfort zone between you. If you're at 5 and your partner is at 3, you'll both only see questions up to level 3.
</Text>
</View>
</View>
<View style={styles.helpItem}>
<Ionicons name="eye-outline" size={20} color={colors.primary} style={styles.helpIcon} />
<View style={styles.helpTextContainer}>
<Text style={styles.helpItemTitle}>Visible to Partner</Text>
<Text style={styles.helpItemDescription}>
Your partner can see the comfort zone level you choose so you can stay aligned. This doesn’t reveal your individual swipes.
</Text>
</View>
</View>
<View style={[styles.helpItem, styles.helpItemLast]}>
<Ionicons name="refresh-outline" size={20} color={colors.primary} style={styles.helpIcon} />
<View style={styles.helpTextContainer}>
<Text style={styles.helpItemTitle}>Change Anytime</Text>
<Text style={styles.helpItemDescription}>
Adjust whenever you want. New questions and packs will appear or hide based on your new setting.
</Text>
</View>
</View>
</View>
</ScrollView>
<GlassButton
onPress={() => setShowHelpModal(false)}
fullWidth
>
Got it
</GlassButton>
</View>
</View>
</BlurView>
</Modal>
</>
);
};
const styles = StyleSheet.create({
comfortHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.lg,
},
comfortIcon: {
width: 44,
height: 44,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.md,
},
comfortText: {
flex: 1,
},
comfortLabelRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
comfortLabel: {
...typography.headline,
color: colors.text,
},
comfortDescription: {
...typography.caption1,
color: colors.textSecondary,
marginTop: 2,
},
sliderContainer: {
marginBottom: spacing.sm,
},
divider: {
height: 1,
backgroundColor: colors.glass.border,
marginVertical: spacing.lg,
},
// Modal styles
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
justifyContent: 'center',
alignItems: 'center',
padding: spacing.lg,
},
modalContent: {
width: '100%',
maxWidth: 400,
maxHeight: '80%',
backgroundColor: colors.backgroundLight,
borderRadius: radius.xl,
borderWidth: 1,
borderColor: colors.glass.border,
padding: spacing.lg,
},
modalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: spacing.md,
paddingBottom: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.glass.border,
},
modalTitle: {
...typography.headline,
color: colors.text,
},
closeButton: {
padding: spacing.xs,
},
helpScrollView: {
flexGrow: 0,
marginBottom: spacing.md,
},
helpSection: {
gap: spacing.sm,
},
helpItem: {
flexDirection: 'row',
alignItems: 'flex-start',
paddingVertical: spacing.sm,
},
helpItemLast: {
paddingBottom: 0,
},
helpIcon: {
marginRight: spacing.sm,
marginTop: 2,
},
helpTextContainer: {
flex: 1,
},
helpItemTitle: {
...typography.subhead,
fontWeight: '600',
color: colors.text,
marginBottom: 2,
},
helpItemDescription: {
...typography.caption1,
color: colors.textSecondary,
lineHeight: 18,
},
});
@@ -4,7 +4,6 @@ import { useAuthStore, useSubscriptionStore, usePacksStore } from '../../../stor
import { supabase } from '../../../lib/supabase';
import { Events } from '../../../lib/analytics';
import { useAvatarPicker } from '../../../hooks/useAvatarPicker';
import type { IntensityLevel } from '../../../types';
import {
registerForPushNotificationsAsync,
savePushToken,
@@ -17,27 +16,17 @@ import {
getBiometricType,
} from '../../../lib/biometricAuth';
const DEFAULT_MAX_INTENSITY: IntensityLevel = 2;
const normalizeIntensity = (value: number | null | undefined): IntensityLevel | null => {
if (value === 1 || value === 2 || value === 3 || value === 4 || value === 5) return value;
return null;
};
const getProfileMaxIntensity = (profile?: { max_intensity?: number | null; show_explicit_content?: boolean | null } | null): IntensityLevel => {
const normalized = normalizeIntensity(profile?.max_intensity);
if (normalized) return normalized;
return profile?.show_explicit_content ? 5 : DEFAULT_MAX_INTENSITY;
};
// Intensity thresholds for backwards compatibility
// When hide_nsfw=true, max_intensity=2 (mild content only)
// When hide_nsfw=false, max_intensity=5 (all content)
const NSFW_OFF_INTENSITY = 2;
const NSFW_ON_INTENSITY = 5;
export function useProfileSettings() {
const { user, partner, fetchUser } = useAuthStore();
const { user, fetchUser } = useAuthStore();
const { fetchPacks } = usePacksStore();
const { restorePurchases, isPurchasing } = useSubscriptionStore();
// Partner's intensity level (for showing on slider)
const partnerIntensity = partner ? getProfileMaxIntensity(partner) : null;
// Name Editing
const [isEditingName, setIsEditingName] = useState(false);
const [newName, setNewName] = useState(user?.name || "");
@@ -80,9 +69,9 @@ export function useProfileSettings() {
onUploadError: handleAvatarUploadError,
});
// Preferences
const [maxIntensity, setMaxIntensity] = useState<IntensityLevel>(() => getProfileMaxIntensity(user));
const [isUpdatingIntensity, setIsUpdatingIntensity] = useState(false);
// Hide NSFW
const [hideNsfw, setHideNsfw] = useState(!!user?.hide_nsfw);
const [isUpdatingHideNsfw, setIsUpdatingHideNsfw] = useState(false);
// Notifications
const [pushEnabled, setPushEnabled] = useState(!!user?.push_token);
@@ -102,8 +91,8 @@ export function useProfileSettings() {
if (user?.name) {
setNewName(user.name);
}
setMaxIntensity(getProfileMaxIntensity(user));
setPushEnabled(!!user?.push_token);
setHideNsfw(!!user?.hide_nsfw);
}, [user]);
// Check biometric availability
@@ -168,41 +157,6 @@ export function useProfileSettings() {
setIsEditingName(false);
}, [user?.name]);
// Preference Actions
const handleIntensityChange = async (value: IntensityLevel) => {
if (!user?.id) return;
const previous = maxIntensity;
setMaxIntensity(value);
setIsUpdatingIntensity(true);
const showExplicitContent = value >= 3;
try {
const { error } = await supabase
.from('profiles')
.update({
max_intensity: value,
show_explicit_content: showExplicitContent,
updated_at: new Date().toISOString(),
})
.eq('id', user.id);
if (error) throw error;
// Refresh user data and packs to reflect the change
await fetchUser();
await fetchPacks();
Events.profileUpdated(["max_intensity", "show_explicit_content"]);
} catch (error) {
// Revert on error
setMaxIntensity(previous);
Alert.alert("Error", "Failed to update preference. Please try again.");
} finally {
setIsUpdatingIntensity(false);
}
};
const handlePushToggle = async (value: boolean) => {
if (!user?.id) return;
@@ -275,6 +229,43 @@ export function useProfileSettings() {
}
};
const handleHideNsfwToggle = async (value: boolean) => {
if (!user?.id) return;
const previous = hideNsfw;
setHideNsfw(value);
setIsUpdatingHideNsfw(true);
// Derive intensity from hide_nsfw for backwards compatibility
const maxIntensity = value ? NSFW_OFF_INTENSITY : NSFW_ON_INTENSITY;
const showExplicitContent = !value;
try {
const { error } = await supabase
.from('profiles')
.update({
hide_nsfw: value,
max_intensity: maxIntensity,
show_explicit_content: showExplicitContent,
updated_at: new Date().toISOString(),
})
.eq('id', user.id);
if (error) throw error;
// Refresh user data and packs to reflect the change
await fetchUser();
await fetchPacks();
Events.profileUpdated(["hide_nsfw", "max_intensity", "show_explicit_content"]);
} catch (error) {
// Revert on error
setHideNsfw(previous);
Alert.alert("Error", "Failed to update preference. Please try again.");
} finally {
setIsUpdatingHideNsfw(false);
}
};
// Subscription Actions
const handleRestorePurchases = async () => {
const restored = await restorePurchases();
@@ -297,30 +288,25 @@ export function useProfileSettings() {
setNewName,
isUpdatingName,
isUploadingAvatar,
maxIntensity,
isUpdatingIntensity,
pushEnabled,
isUpdatingPush,
biometricAvailable,
biometricEnabled: biometricEnabledState,
biometricType,
isUpdatingBiometric,
hideNsfw,
isUpdatingHideNsfw,
showPaywall,
setShowPaywall,
isPurchasing,
// Partner info for comfort zone comparison
partnerIntensity,
partnerName: partner?.name ?? undefined,
partnerAvatar: partner?.avatar_url ?? undefined,
// Actions
handleUpdateName,
handleCancelEditName,
handleAvatarPress,
handleIntensityChange,
handlePushToggle,
handleBiometricToggle,
handleHideNsfwToggle,
handleRestorePurchases,
handleManageSubscription,
};
@@ -7,7 +7,7 @@ import { useProfileSettings } from '../hooks';
import { ScreenHeader, PrivacySettings } from '../components';
/**
* Preferences sub-screen for comfort zone and biometric settings.
* Preferences sub-screen for content and biometric settings.
*/
export function PreferencesScreen() {
const settings = useProfileSettings();
@@ -21,17 +21,14 @@ export function PreferencesScreen() {
showsVerticalScrollIndicator={false}
>
<PrivacySettings
maxIntensity={settings.maxIntensity}
isUpdatingIntensity={settings.isUpdatingIntensity}
onIntensityChange={settings.handleIntensityChange}
biometricAvailable={settings.biometricAvailable}
biometricEnabled={settings.biometricEnabled}
biometricType={settings.biometricType}
isUpdatingBiometric={settings.isUpdatingBiometric}
onBiometricToggle={settings.handleBiometricToggle}
partnerIntensity={settings.partnerIntensity}
partnerName={settings.partnerName}
partnerAvatar={settings.partnerAvatar}
hideNsfw={settings.hideNsfw}
isUpdatingHideNsfw={settings.isUpdatingHideNsfw}
onHideNsfwToggle={settings.handleHideNsfwToggle}
/>
</ScrollView>
</GradientBackground>