mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 18:19:43 +00:00
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:
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>
|
||||
|
||||
Reference in new issue
Block a user