Add intensity-based preferences and harden E2EE payload verification

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

No files matched your search

@@ -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,