mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 18:19:43 +00:00
Add intensity-based preferences and harden E2EE payload verification
This commit is contained in:
1 parent
e23f4557bb
commit
0a919f06fc
20 files changed
+2033
-251
No files matched your search
@@ -1,5 +1,5 @@
|
||||
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 Constants from 'expo-constants';
|
||||
import Animated, {
|
||||
@@ -27,7 +27,7 @@ import { clearKeys } from '../../lib/encryption';
|
||||
import { useProfileSettings, useCoupleManagement } from './hooks';
|
||||
|
||||
// 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 ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
|
||||
@@ -269,14 +269,17 @@ export function ProfileScreen() {
|
||||
|
||||
{/* Preferences */}
|
||||
<PrivacySettings
|
||||
showExplicit={settings.showExplicit}
|
||||
isUpdatingExplicit={settings.isUpdatingExplicit}
|
||||
onExplicitToggle={settings.handleExplicitToggle}
|
||||
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}
|
||||
/>
|
||||
|
||||
{/* Account */}
|
||||
@@ -298,13 +301,20 @@ export function ProfileScreen() {
|
||||
description="Report bugs or request features"
|
||||
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>
|
||||
|
||||
{/* Reset Progress */}
|
||||
{couple && (
|
||||
<ResetProgress onResetProgress={handleResetProgress} />
|
||||
)}
|
||||
|
||||
{/* Debug Section */}
|
||||
{__DEV__ && (
|
||||
<SettingsSection title="Debug" delay={550}>
|
||||
@@ -375,6 +385,7 @@ export function ProfileScreen() {
|
||||
|
||||
{/* Danger Zone */}
|
||||
<DangerZone
|
||||
onResetProgress={couple ? handleResetProgress : undefined}
|
||||
onDeleteRelationship={couple ? handleDeleteRelationship : undefined}
|
||||
onDeleteAccount={handleDeleteAccount}
|
||||
hasRelationship={!!couple}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
import { colors, featureColors, spacing, typography } from '../../../theme';
|
||||
import { colors, featureColors, spacing, typography, radius } from '../../../theme';
|
||||
import { Profile, Couple } from '../../../types';
|
||||
|
||||
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
|
||||
@@ -15,30 +16,79 @@ interface CoupleStatusProps {
|
||||
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> = ({
|
||||
partner,
|
||||
couple,
|
||||
onUnpair,
|
||||
onPairingPress,
|
||||
}) => {
|
||||
const pairedLabel = formatPairedDate(couple?.created_at);
|
||||
|
||||
return (
|
||||
<SettingsSection title="Partner" delay={300}>
|
||||
{partner ? (
|
||||
<View style={styles.rowContainer}>
|
||||
<View style={styles.rowLeft}>
|
||||
<LinearGradient
|
||||
colors={ACCENT_GRADIENT}
|
||||
style={styles.partnerIconGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Ionicons name="heart" size={20} color={colors.text} />
|
||||
</LinearGradient>
|
||||
{partner.avatar_url ? (
|
||||
<View style={styles.partnerAvatarContainer}>
|
||||
<LinearGradient
|
||||
colors={ACCENT_GRADIENT}
|
||||
style={styles.partnerAvatarGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<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}>
|
||||
<Text style={styles.rowValue}>
|
||||
{partner.name || partner.email || 'Your partner'}
|
||||
</Text>
|
||||
<Text style={styles.rowLabel}>Connected</Text>
|
||||
<Text style={styles.rowLabel}>
|
||||
{pairedLabel || 'Connected'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
@@ -108,20 +158,58 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
partnerIconGradient: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
partnerAvatarContainer: {
|
||||
position: 'relative',
|
||||
},
|
||||
partnerAvatarGradient: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
padding: 2,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyPartnerIcon: {
|
||||
partnerAvatar: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
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,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 2,
|
||||
borderColor: colors.glass.border,
|
||||
borderStyle: 'dashed',
|
||||
},
|
||||
rowTextContainer: {
|
||||
marginLeft: spacing.md,
|
||||
|
||||
@@ -7,29 +7,54 @@ import { GlassCard, GlassButton } from '../../../components/ui';
|
||||
import { colors, spacing, radius, typography } from '../../../theme';
|
||||
|
||||
interface DangerZoneProps {
|
||||
onResetProgress?: () => Promise<void>;
|
||||
onDeleteRelationship?: () => Promise<void>;
|
||||
onDeleteAccount: () => Promise<void>;
|
||||
hasRelationship?: boolean;
|
||||
}
|
||||
|
||||
export const DangerZone: React.FC<DangerZoneProps> = ({
|
||||
onResetProgress,
|
||||
onDeleteRelationship,
|
||||
onDeleteAccount,
|
||||
hasRelationship = false
|
||||
}) => {
|
||||
const [showResetModal, setShowResetModal] = useState(false);
|
||||
const [showDeleteRelationshipModal, setShowDeleteRelationshipModal] = 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 [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 () => {
|
||||
if (deleteConfirmText !== "DELETE" || !onDeleteRelationship) return;
|
||||
if (confirmText !== "DELETE" || !onDeleteRelationship) return;
|
||||
setIsDeletingRelationship(true);
|
||||
try {
|
||||
await onDeleteRelationship();
|
||||
setShowDeleteRelationshipModal(false);
|
||||
setDeleteConfirmText("");
|
||||
setConfirmText("");
|
||||
} catch (error) {
|
||||
// Error handling relies on parent throwing or managing its own alerts
|
||||
} finally {
|
||||
@@ -38,7 +63,7 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
|
||||
};
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
if (deleteConfirmText !== "DELETE") return;
|
||||
if (confirmText !== "DELETE") return;
|
||||
setIsDeletingAccount(true);
|
||||
try {
|
||||
await onDeleteAccount();
|
||||
@@ -52,14 +77,14 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
|
||||
const closeRelationshipModal = () => {
|
||||
if (!isDeletingRelationship) {
|
||||
setShowDeleteRelationshipModal(false);
|
||||
setDeleteConfirmText("");
|
||||
setConfirmText("");
|
||||
}
|
||||
};
|
||||
|
||||
const closeAccountModal = () => {
|
||||
if (!isDeletingAccount) {
|
||||
setShowDeleteAccountModal(false);
|
||||
setDeleteConfirmText("");
|
||||
setConfirmText("");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -71,18 +96,45 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
|
||||
>
|
||||
<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 */}
|
||||
{hasRelationship && onDeleteRelationship && (
|
||||
<GlassCard style={styles.dangerCard}>
|
||||
<GlassCard style={StyleSheet.flatten([styles.dangerCard, styles.dangerCardSpacing]) as ViewStyle}>
|
||||
<View style={styles.dangerContent}>
|
||||
<View style={styles.dangerInfo}>
|
||||
<View style={styles.dangerIconContainer}>
|
||||
<Ionicons name="heart-dislike" size={20} color={colors.error} />
|
||||
</View>
|
||||
<View style={styles.dangerTextContainer}>
|
||||
<Text style={styles.dangerTitle}>Delete Relationship Data</Text>
|
||||
<Text style={styles.dangerTitle}>Delete Relationship</Text>
|
||||
<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>
|
||||
</View>
|
||||
</View>
|
||||
@@ -100,7 +152,7 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
|
||||
)}
|
||||
|
||||
{/* 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.dangerInfo}>
|
||||
<View style={styles.dangerIconContainer}>
|
||||
@@ -109,7 +161,7 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
|
||||
<View style={styles.dangerTextContainer}>
|
||||
<Text style={styles.dangerTitle}>Delete Account</Text>
|
||||
<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>
|
||||
</View>
|
||||
</View>
|
||||
@@ -126,6 +178,105 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
|
||||
</GlassCard>
|
||||
</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 */}
|
||||
<Modal
|
||||
visible={showDeleteRelationshipModal}
|
||||
@@ -179,8 +330,8 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
|
||||
|
||||
<TextInput
|
||||
style={styles.confirmInput}
|
||||
value={deleteConfirmText}
|
||||
onChangeText={setDeleteConfirmText}
|
||||
value={confirmText}
|
||||
onChangeText={setConfirmText}
|
||||
placeholder="Type DELETE"
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
autoCapitalize="characters"
|
||||
@@ -201,10 +352,10 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.deleteButton,
|
||||
(deleteConfirmText !== "DELETE" || isDeletingRelationship) && styles.deleteButtonDisabled,
|
||||
(confirmText !== "DELETE" || isDeletingRelationship) && styles.deleteButtonDisabled,
|
||||
]}
|
||||
onPress={handleDeleteRelationship}
|
||||
disabled={deleteConfirmText !== "DELETE" || isDeletingRelationship}
|
||||
disabled={confirmText !== "DELETE" || isDeletingRelationship}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isDeletingRelationship ? (
|
||||
@@ -283,8 +434,8 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
|
||||
|
||||
<TextInput
|
||||
style={styles.confirmInput}
|
||||
value={deleteConfirmText}
|
||||
onChangeText={setDeleteConfirmText}
|
||||
value={confirmText}
|
||||
onChangeText={setConfirmText}
|
||||
placeholder="Type DELETE"
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
autoCapitalize="characters"
|
||||
@@ -305,10 +456,10 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.deleteButton,
|
||||
(deleteConfirmText !== "DELETE" || isDeletingAccount) && styles.deleteButtonDisabled,
|
||||
(confirmText !== "DELETE" || isDeletingAccount) && styles.deleteButtonDisabled,
|
||||
]}
|
||||
onPress={handleDeleteAccount}
|
||||
disabled={deleteConfirmText !== "DELETE" || isDeletingAccount}
|
||||
disabled={confirmText !== "DELETE" || isDeletingAccount}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isDeletingAccount ? (
|
||||
@@ -342,6 +493,36 @@ const styles = StyleSheet.create({
|
||||
marginBottom: spacing.sm,
|
||||
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: {
|
||||
borderColor: 'rgba(231, 76, 60, 0.3)',
|
||||
borderWidth: 1,
|
||||
@@ -431,6 +612,58 @@ const styles = StyleSheet.create({
|
||||
borderWidth: 1,
|
||||
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: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -1,40 +1,76 @@
|
||||
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 { 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 {
|
||||
showExplicit: boolean;
|
||||
isUpdatingExplicit: boolean;
|
||||
onExplicitToggle: (value: boolean) => void;
|
||||
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;
|
||||
}
|
||||
|
||||
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
|
||||
|
||||
export const PrivacySettings: React.FC<PrivacySettingsProps> = ({
|
||||
showExplicit,
|
||||
isUpdatingExplicit,
|
||||
onExplicitToggle,
|
||||
maxIntensity,
|
||||
isUpdatingIntensity,
|
||||
onIntensityChange,
|
||||
biometricAvailable,
|
||||
biometricEnabled,
|
||||
biometricType,
|
||||
isUpdatingBiometric,
|
||||
onBiometricToggle,
|
||||
partnerIntensity,
|
||||
partnerName,
|
||||
partnerAvatar,
|
||||
}) => {
|
||||
return (
|
||||
<SettingsSection title="Preferences" delay={375}>
|
||||
<SwitchItem
|
||||
icon="flame"
|
||||
label="Spicy Content"
|
||||
description="Show spicy question packs"
|
||||
value={showExplicit}
|
||||
onValueChange={onExplicitToggle}
|
||||
disabled={isUpdatingExplicit}
|
||||
/>
|
||||
{/* 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}>
|
||||
<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 && (
|
||||
<>
|
||||
<View style={styles.divider} />
|
||||
@@ -53,9 +89,37 @@ export const PrivacySettings: React.FC<PrivacySettingsProps> = ({
|
||||
};
|
||||
|
||||
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: {
|
||||
height: 1,
|
||||
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 { supabase } from '../../../lib/supabase';
|
||||
import { Events } from '../../../lib/analytics';
|
||||
import type { IntensityLevel } from '../../../types';
|
||||
import {
|
||||
registerForPushNotificationsAsync,
|
||||
savePushToken,
|
||||
@@ -18,11 +19,27 @@ 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;
|
||||
};
|
||||
|
||||
export function useProfileSettings() {
|
||||
const { user, fetchUser } = useAuthStore();
|
||||
const { user, partner, 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 || "");
|
||||
@@ -32,8 +49,8 @@ export function useProfileSettings() {
|
||||
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
|
||||
|
||||
// Preferences
|
||||
const [showExplicit, setShowExplicit] = useState(user?.show_explicit_content ?? true);
|
||||
const [isUpdatingExplicit, setIsUpdatingExplicit] = useState(false);
|
||||
const [maxIntensity, setMaxIntensity] = useState<IntensityLevel>(() => getProfileMaxIntensity(user));
|
||||
const [isUpdatingIntensity, setIsUpdatingIntensity] = useState(false);
|
||||
|
||||
// Notifications
|
||||
const [pushEnabled, setPushEnabled] = useState(!!user?.push_token);
|
||||
@@ -53,9 +70,7 @@ export function useProfileSettings() {
|
||||
if (user?.name) {
|
||||
setNewName(user.name);
|
||||
}
|
||||
if (user?.show_explicit_content !== undefined) {
|
||||
setShowExplicit(user.show_explicit_content);
|
||||
}
|
||||
setMaxIntensity(getProfileMaxIntensity(user));
|
||||
setPushEnabled(!!user?.push_token);
|
||||
}, [user]);
|
||||
|
||||
@@ -256,17 +271,21 @@ export function useProfileSettings() {
|
||||
};
|
||||
|
||||
// Preference Actions
|
||||
const handleExplicitToggle = async (value: boolean) => {
|
||||
const handleIntensityChange = async (value: IntensityLevel) => {
|
||||
if (!user?.id) return;
|
||||
|
||||
setShowExplicit(value);
|
||||
setIsUpdatingExplicit(true);
|
||||
const previous = maxIntensity;
|
||||
setMaxIntensity(value);
|
||||
setIsUpdatingIntensity(true);
|
||||
|
||||
const showExplicitContent = value >= 3;
|
||||
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('profiles')
|
||||
.update({
|
||||
show_explicit_content: value,
|
||||
max_intensity: value,
|
||||
show_explicit_content: showExplicitContent,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', user.id);
|
||||
@@ -276,13 +295,13 @@ export function useProfileSettings() {
|
||||
// Refresh user data and packs to reflect the change
|
||||
await fetchUser();
|
||||
await fetchPacks();
|
||||
Events.profileUpdated(["show_explicit_content"]);
|
||||
Events.profileUpdated(["max_intensity", "show_explicit_content"]);
|
||||
} catch (error) {
|
||||
// Revert on error
|
||||
setShowExplicit(!value);
|
||||
setMaxIntensity(previous);
|
||||
Alert.alert("Error", "Failed to update preference. Please try again.");
|
||||
} finally {
|
||||
setIsUpdatingExplicit(false);
|
||||
setIsUpdatingIntensity(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -380,8 +399,8 @@ export function useProfileSettings() {
|
||||
setNewName,
|
||||
isUpdatingName,
|
||||
isUploadingAvatar,
|
||||
showExplicit,
|
||||
isUpdatingExplicit,
|
||||
maxIntensity,
|
||||
isUpdatingIntensity,
|
||||
pushEnabled,
|
||||
isUpdatingPush,
|
||||
biometricAvailable,
|
||||
@@ -392,11 +411,16 @@ export function useProfileSettings() {
|
||||
setShowPaywall,
|
||||
isPurchasing,
|
||||
|
||||
// Partner info for comfort zone comparison
|
||||
partnerIntensity,
|
||||
partnerName: partner?.name ?? undefined,
|
||||
partnerAvatar: partner?.avatar_url ?? undefined,
|
||||
|
||||
// Actions
|
||||
handleUpdateName,
|
||||
handleCancelEditName,
|
||||
handleAvatarPress,
|
||||
handleExplicitToggle,
|
||||
handleIntensityChange,
|
||||
handlePushToggle,
|
||||
handleBiometricToggle,
|
||||
handleRestorePurchases,
|
||||
|
||||
Reference in new issue
Block a user