Add streaks, scheduled releases, and notification settings

- Couple streaks with milestone notifications
- Scheduled pack releases with admin UI
- Notification preferences screen
- Refactored profile settings screens
- Partner activity notifications
This commit is contained in:
jjbrunton committed 2026-01-17 16:38:47 +00:00
1 parent 12f312421b
commit 494ed17fe2
82 files changed
+4189 -725

No files matched your search

+30 -4
View File
@@ -4,11 +4,12 @@ import { View, ActivityIndicator, StyleSheet, Platform, Text, Pressable, Animate
import { BlurView } from "expo-blur";
import { LinearGradient } from "expo-linear-gradient";
import { useEffect, useState, useCallback, useRef } from "react";
import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore, usePacksStore } from "../../src/store";
import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore, usePacksStore, useStreakStore } from "../../src/store";
import { colors, gradients, blur, radius, spacing, typography, shadows } from "../../src/theme";
import { supabase } from "../../src/lib/supabase";
import { isBiometricEnabled } from "../../src/lib/biometricAuth";
import { checkAndRegisterPushToken } from "../../src/lib/notifications";
import { syncBadgeCount } from "../../src/lib/badge";
import { BiometricLockScreen } from "../../src/components/BiometricLockScreen";
import { MatchNotificationModal, MatchNotificationData } from "../../src/components/MatchNotificationModal";
import { Events } from "../../src/lib/analytics";
@@ -139,11 +140,12 @@ function PlayTabButton({ accessibilityState }: PlayTabButtonProps) {
export default function AppLayout() {
const router = useRouter();
const segments = useSegments();
const { isAuthenticated, isLoading, user, signOut } = useAuthStore();
const { isAuthenticated, isLoading, user, signOut, updateLastActive } = useAuthStore();
const { matches, newMatchesCount, addMatch, updateMatchUnreadCount } = useMatchStore();
const { unreadCount, lastMessage, fetchUnreadCount, addMessage, clearLastMessage } = useMessageStore();
const { fetchEnabledPacks } = usePacksStore();
const { initializeRevenueCat } = useSubscriptionStore();
const { fetchStreak } = useStreakStore();
const [matchNotification, setMatchNotification] = useState<MatchNotificationData | null>(null);
const messageToastAnim = useRef(new Animated.Value(-100)).current;
const messageTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@@ -208,7 +210,8 @@ export default function AppLayout() {
const isOnOnboarding = segmentStrings.includes("onboarding");
const isOnPairing = segmentStrings.includes("pairing");
const isOnChat = segmentStrings.includes("chat");
const shouldHideTabBar = isOnOnboarding || isOnPairing || isOnChat;
const isOnSettingsSubscreen = segmentStrings.includes("settings") && segmentStrings.length > 2;
const shouldHideTabBar = isOnOnboarding || isOnPairing || isOnChat || isOnSettingsSubscreen;
// Redirect to login when not authenticated, or to onboarding if not completed
useEffect(() => {
@@ -232,17 +235,22 @@ export default function AppLayout() {
// Check immediately
checkSession();
// Update last active timestamp on mount
updateLastActive();
// Check when app comes to foreground
const subscription = AppState.addEventListener('change', (nextAppState) => {
if (nextAppState === 'active') {
checkSession();
// Update last active timestamp when app comes to foreground
updateLastActive();
}
});
return () => {
subscription.remove();
};
}, [signOut]);
}, [signOut, updateLastActive]);
// Handle biometric lock when app goes to background/foreground
useEffect(() => {
@@ -301,6 +309,18 @@ export default function AppLayout() {
}
}, [user?.id, fetchUnreadCount]);
// Fetch streak data when user is in a couple
useEffect(() => {
if (user?.couple_id) {
fetchStreak();
}
}, [user?.couple_id, fetchStreak]);
// Sync app icon badge count whenever matches or messages change
useEffect(() => {
syncBadgeCount(newMatchesCount, unreadCount);
}, [newMatchesCount, unreadCount]);
// Initialize RevenueCat for subscription management (iOS and Android only)
useEffect(() => {
if (user?.id && Platform.OS !== "web") {
@@ -692,6 +712,12 @@ export default function AppLayout() {
href: null, // Hide from tab bar
}}
/>
<Tabs.Screen
name="settings"
options={{
href: null, // Hide from tab bar
}}
/>
</Tabs>
{/* Radial Menu Overlay */}
@@ -0,0 +1,12 @@
import { Stack } from 'expo-router';
export default function SettingsLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
animation: 'slide_from_right',
}}
/>
);
}
@@ -0,0 +1,3 @@
import { AccountScreen } from "../../../src/features/profile/screens";
export default AccountScreen;
@@ -0,0 +1,3 @@
import { NotificationsScreen } from "../../../src/features/profile/screens";
export default NotificationsScreen;
@@ -0,0 +1,3 @@
import { PartnerScreen } from "../../../src/features/profile/screens";
export default PartnerScreen;
@@ -0,0 +1,3 @@
import { PreferencesScreen } from "../../../src/features/profile/screens";
export default PreferencesScreen;
@@ -0,0 +1,3 @@
import { ProfileSettingsScreen } from "../../../src/features/profile/screens";
export default ProfileSettingsScreen;
@@ -0,0 +1,3 @@
import { SubscriptionScreen } from "../../../src/features/profile/screens";
export default SubscriptionScreen;
@@ -0,0 +1,3 @@
import { SupportScreen } from "../../../src/features/profile/screens";
export default SupportScreen;
@@ -162,8 +162,8 @@ describe('subscriptionStore', () => {
});
const result = await useSubscriptionStore.getState().purchasePackage(mockPackage as any);
expect(result).toBe(true);
expect(result.success).toBe(true);
const state = useSubscriptionStore.getState();
expect(state.subscription.isProUser).toBe(true);
expect(state.isPurchasing).toBe(false);
@@ -178,7 +178,8 @@ describe('subscriptionStore', () => {
const result = await useSubscriptionStore.getState().purchasePackage(mockPackage as any);
expect(result).toBe(false);
expect(result.success).toBe(false);
expect(result.cancelled).toBe(true);
const state = useSubscriptionStore.getState();
expect(state.error).toBe('Purchase was cancelled');
expect(state.isPurchasing).toBe(false);
@@ -192,7 +193,8 @@ describe('subscriptionStore', () => {
const result = await useSubscriptionStore.getState().purchasePackage(mockPackage as any);
expect(result).toBe(false);
expect(result.success).toBe(false);
expect(result.cancelled).toBe(false);
const state = useSubscriptionStore.getState();
expect(state.error).toBe('Purchase failed. Please try again.');
});
@@ -0,0 +1,193 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import Animated, { FadeInDown, useSharedValue, useAnimatedStyle, withRepeat, withSequence, withTiming, Easing } from 'react-native-reanimated';
import { LinearGradient } from 'expo-linear-gradient';
import { useStreakStore } from '../store';
import { colors, spacing, radius, typography } from '../theme';
interface StreakDisplayProps {
/** Whether to show the "longest streak" indicator */
showLongest?: boolean;
/** Animation delay for entry animation */
delay?: number;
/** Compact mode for smaller displays */
compact?: boolean;
}
/**
* Displays the couple's current streak with a flame icon.
* The flame pulses when there's an active streak.
*/
export function StreakDisplay({ showLongest = false, delay = 0, compact = false }: StreakDisplayProps) {
const { streak, isLoading, fetchStreak } = useStreakStore();
// Fetch streak on mount
useEffect(() => {
fetchStreak();
}, [fetchStreak]);
// Flame pulse animation
const scale = useSharedValue(1);
useEffect(() => {
if (streak && streak.current_streak > 0) {
scale.value = withRepeat(
withSequence(
withTiming(1.1, { duration: 800, easing: Easing.inOut(Easing.ease) }),
withTiming(1, { duration: 800, easing: Easing.inOut(Easing.ease) })
),
-1,
true
);
} else {
scale.value = 1;
}
}, [streak?.current_streak, scale]);
const flameStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
// Don't show if loading or no couple
if (isLoading || !streak) {
return null;
}
const hasStreak = streak.current_streak > 0;
const currentStreak = streak.current_streak;
const longestStreak = streak.longest_streak;
if (compact) {
return (
<Animated.View entering={FadeInDown.delay(delay).duration(400)}>
<View style={styles.compactContainer}>
<Animated.View style={flameStyle}>
<Ionicons
name="flame"
size={16}
color={hasStreak ? colors.warning : colors.textTertiary}
/>
</Animated.View>
<Text style={[styles.compactCount, !hasStreak && styles.inactiveText]}>
{currentStreak}
</Text>
</View>
</Animated.View>
);
}
return (
<Animated.View entering={FadeInDown.delay(delay).duration(500)}>
<View style={styles.container}>
{/* Main streak display */}
<LinearGradient
colors={hasStreak ? ['rgba(243, 156, 18, 0.2)', 'rgba(233, 69, 96, 0.1)'] : ['rgba(255, 255, 255, 0.05)', 'rgba(255, 255, 255, 0.02)']}
style={styles.card}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<View style={styles.content}>
{/* Flame icon */}
<Animated.View style={[styles.iconContainer, flameStyle]}>
<Ionicons
name="flame"
size={32}
color={hasStreak ? colors.warning : colors.textTertiary}
/>
</Animated.View>
{/* Streak info */}
<View style={styles.info}>
<Text style={[styles.count, !hasStreak && styles.inactiveText]}>
{currentStreak}
</Text>
<Text style={styles.label}>
day{currentStreak !== 1 ? 's' : ''} together
</Text>
</View>
</View>
{/* Longest streak */}
{showLongest && longestStreak > 0 && (
<View style={styles.longest}>
<Ionicons name="trophy-outline" size={12} color={colors.textTertiary} />
<Text style={styles.longestText}>
Best: {longestStreak} day{longestStreak !== 1 ? 's' : ''}
</Text>
</View>
)}
</LinearGradient>
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
marginVertical: spacing.sm,
},
card: {
borderRadius: radius.lg,
padding: spacing.md,
borderWidth: 1,
borderColor: colors.glass.border,
},
content: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
},
iconContainer: {
width: 48,
height: 48,
borderRadius: radius.full,
backgroundColor: 'rgba(243, 156, 18, 0.15)',
justifyContent: 'center',
alignItems: 'center',
},
info: {
flex: 1,
},
count: {
...typography.title1,
color: colors.text,
},
label: {
...typography.subhead,
color: colors.textSecondary,
},
inactiveText: {
color: colors.textTertiary,
},
longest: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
marginTop: spacing.sm,
paddingTop: spacing.sm,
borderTopWidth: 1,
borderTopColor: colors.glass.border,
},
longestText: {
...typography.caption1,
color: colors.textTertiary,
},
// Compact styles
compactContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
backgroundColor: 'rgba(243, 156, 18, 0.15)',
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: radius.full,
},
compactCount: {
...typography.caption1,
fontWeight: '600',
color: colors.warning,
},
});
export default StreakDisplay;
+45 -31
View File
@@ -42,7 +42,11 @@ const FEATURES = [
},
];
export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
const handleClose = (reason: "dismiss" | "success" | "restore" | "system") => {
Events.paywallClosed("paywall", reason);
onClose();
};
const {
offerings,
isLoadingOfferings,
@@ -76,26 +80,36 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
}
}, [offerings]);
const handlePurchase = async () => {
if (!selectedPackage) return;
const handlePurchase = async () => {
if (!selectedPackage) return;
const packageType = selectedPackage.packageType || "unknown";
Events.purchaseInitiated(packageType);
const result = await purchasePackage(selectedPackage);
if (result.success) {
Events.purchaseCompleted(packageType);
onSuccess?.();
handleClose("success");
return;
}
if (result.cancelled) {
Events.purchaseCancelled(packageType, result.errorCode);
return;
}
Events.purchaseFailed(packageType, result.errorCode, result.errorMessage);
};
Events.purchaseInitiated(selectedPackage.packageType || "unknown");
const success = await purchasePackage(selectedPackage);
if (success) {
Events.purchaseCompleted(selectedPackage.packageType || "unknown");
onSuccess?.();
onClose();
}
};
const handleRestore = async () => {
const restored = await restorePurchases();
if (restored) {
Events.purchaseRestored();
onSuccess?.();
onClose();
}
};
const handleRestore = async () => {
const restored = await restorePurchases();
if (restored) {
Events.purchaseRestored();
onSuccess?.();
handleClose("restore");
}
};
const formatPrice = (pkg: PurchasesPackage) => {
const price = pkg.product.priceString;
@@ -129,12 +143,12 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
return (
<Modal
visible={visible}
transparent
animationType="slide"
onRequestClose={onClose}
>
<Modal
visible={visible}
transparent
animationType="slide"
onRequestClose={() => handleClose("system")}
>
<View style={styles.overlay}>
<View style={styles.content}>
{/* Premium gradient background */}
@@ -155,11 +169,11 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
contentContainerStyle={styles.scrollContent}
>
{/* Close Button */}
<TouchableOpacity
style={styles.closeButton}
onPress={onClose}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<TouchableOpacity
style={styles.closeButton}
onPress={() => handleClose("dismiss")}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="close" size={24} color={colors.textSecondary} />
</TouchableOpacity>
+172 -221
View File
@@ -1,62 +1,42 @@
import React from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, useWindowDimensions, Platform, Alert, Linking } from 'react-native';
import React, { useState } from 'react';
import { View, Text, StyleSheet, ScrollView, useWindowDimensions, Platform, Alert, TouchableOpacity } from 'react-native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import Constants from 'expo-constants';
import Animated, {
FadeInDown,
useSharedValue,
useAnimatedScrollHandler,
useAnimatedStyle,
interpolate,
Extrapolation
} from 'react-native-reanimated';
import { BlurView } from 'expo-blur';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { GradientBackground } from '../../components/ui';
import { FeedbackModal } from '../../components/feedback';
import { Paywall } from '../../components/paywall';
import { colors, featureColors, spacing, typography, radius } from '../../theme';
import { useAuthStore, useSubscriptionStore } from '../../store';
import { resetSwipeTutorial } from '../../lib/swipeTutorialSeen';
import { resetMatchesTutorial } from '../../lib/matchesTutorialSeen';
import { supabase } from '../../lib/supabase'; // Needed for debug reset
// Hooks
import { useProfileSettings, useCoupleManagement } from './hooks';
import { supabase } from '../../lib/supabase';
// Components
import { AppearanceSettings, CoupleStatus, NotificationSettings, PrivacySettings, DangerZone, SettingsSection, MenuItem, SubscriptionCard } from './components';
import { SettingsSection, MenuItem, SubscriptionCard } from './components';
// Hooks
import { useProfileSettings } from './hooks';
const MAX_CONTENT_WIDTH = 500;
const NAV_BAR_HEIGHT = 44;
const STATUS_BAR_HEIGHT = 60;
const HEADER_SCROLL_DISTANCE = 100;
const AnimatedScrollView = Animated.createAnimatedComponent(ScrollView);
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
export function ProfileScreen() {
const router = useRouter();
const { width } = useWindowDimensions();
const isWideScreen = width > MAX_CONTENT_WIDTH;
const { user, partner, couple, fetchUser } = useAuthStore();
const { user, partner, fetchUser } = useAuthStore();
const { subscription } = useSubscriptionStore();
// Hooks
const settings = useProfileSettings();
const {
handleUnpair,
handleDeleteRelationship,
handleResetProgress,
handleSignOut,
handleDeleteAccount,
navigateToPairing
} = useCoupleManagement();
const [showFeedbackModal, setShowFeedbackModal] = React.useState(false);
// Paywall state
const [showPaywall, setShowPaywall] = useState(false);
// Derived formatting
// Use OR: if RevenueCat says they're subscribed OR database says premium, it's their own
// This handles the race condition after purchase (RevenueCat updates before webhook syncs DB)
// Derived state
const isOwnSubscription = subscription.isProUser || user?.is_premium;
const hasPremiumAccess = user?.is_premium || partner?.is_premium || subscription.isProUser;
@@ -79,81 +59,20 @@ export function ProfileScreen() {
});
const versionString = buildNumber ? `v${version} (${buildNumber})` : `v${version}`;
// Scroll animation
const scrollY = useSharedValue(0);
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
scrollY.value = event.contentOffset.y;
},
});
const heroStyle = useAnimatedStyle(() => {
const opacity = interpolate(
scrollY.value,
[0, HEADER_SCROLL_DISTANCE * 0.7],
[1, 0],
Extrapolation.CLAMP
);
const scale = interpolate(
scrollY.value,
[0, HEADER_SCROLL_DISTANCE],
[1, 0.95],
Extrapolation.CLAMP
);
return { opacity, transform: [{ scale }] };
});
const compactHeaderStyle = useAnimatedStyle(() => {
const opacity = interpolate(
scrollY.value,
[HEADER_SCROLL_DISTANCE * 0.5, HEADER_SCROLL_DISTANCE],
[0, 1],
Extrapolation.CLAMP
);
return { opacity };
});
const navBarBackgroundStyle = useAnimatedStyle(() => {
const opacity = interpolate(
scrollY.value,
[0, HEADER_SCROLL_DISTANCE * 0.8],
[0, 1],
Extrapolation.CLAMP
);
return { opacity };
});
return (
<GradientBackground>
{/* Fixed Nav Bar */}
<View style={styles.navBar}>
<Animated.View style={[styles.navBarBackground, navBarBackgroundStyle]}>
{Platform.OS === "ios" ? (
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
) : (
<View style={[StyleSheet.absoluteFill, { backgroundColor: "rgba(13, 13, 26, 0.95)" }]} />
)}
</Animated.View>
<Animated.Text style={[styles.navBarTitle, compactHeaderStyle]} numberOfLines={1}>
Settings
</Animated.Text>
</View>
<AnimatedScrollView
<ScrollView
style={styles.container}
contentContainerStyle={[
styles.contentContainer,
isWideScreen && styles.contentContainerWide,
]}
showsVerticalScrollIndicator={false}
onScroll={scrollHandler}
scrollEventThrottle={16}
>
{/* Header title */}
{/* Header with user info (compact) */}
<Animated.View
entering={FadeInDown.delay(100).duration(500)}
style={[styles.header, heroStyle]}
style={styles.header}
>
<Text style={styles.headerLabel}>YOUR SAUCI</Text>
<Text style={styles.title}>Settings</Text>
@@ -164,98 +83,111 @@ export function ProfileScreen() {
</View>
</Animated.View>
{/* Profile Header */}
<AppearanceSettings
user={user}
isUploadingAvatar={settings.isUploadingAvatar}
isEditingName={settings.isEditingName}
newName={settings.newName}
isUpdatingName={settings.isUpdatingName}
onAvatarPress={settings.handleAvatarPress}
onNewNameChange={settings.setNewName}
onUpdateName={settings.handleUpdateName}
onCancelEditName={settings.handleCancelEditName}
onStartEditingName={() => settings.setIsEditingName(true)}
/>
{/* Compact Profile Preview */}
<Animated.View
entering={FadeInDown.delay(150).duration(500)}
style={styles.profilePreview}
>
<TouchableOpacity
style={styles.profilePreviewContent}
onPress={() => router.push('/(app)/settings/profile')}
activeOpacity={0.7}
>
<LinearGradient
colors={ACCENT_GRADIENT}
style={styles.avatarGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
{user?.avatar_url ? (
<Image
source={{ uri: user.avatar_url }}
style={styles.avatar}
cachePolicy="disk"
transition={200}
/>
) : (
<View style={styles.avatarInner}>
<Text style={styles.avatarText}>
{user?.name?.[0]?.toUpperCase() || "U"}
</Text>
</View>
)}
</LinearGradient>
<View style={styles.profileInfo}>
<Text style={styles.userName}>{user?.name || "User"}</Text>
<Text style={styles.userEmail}>{user?.email}</Text>
</View>
<View style={styles.profileChevron}>
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
</View>
</TouchableOpacity>
</Animated.View>
{/* Partner */}
<CoupleStatus
partner={partner}
couple={couple}
onUnpair={handleUnpair}
onPairingPress={navigateToPairing}
/>
{/* Subscription Section */}
{/* Subscription CTA - prominent upsell */}
<SubscriptionCard
hasPremiumAccess={hasPremiumAccess}
isOwnSubscription={!!isOwnSubscription}
expirationDate={formatExpirationDate(subscription.expirationDate)}
onUpgradePress={() => settings.setShowPaywall(true)}
onUpgradePress={() => setShowPaywall(true)}
onManagePress={settings.handleManageSubscription}
onRestorePress={settings.handleRestorePurchases}
isRestoring={settings.isPurchasing}
delay={350}
delay={200}
/>
{/* Notifications */}
<NotificationSettings
pushEnabled={settings.pushEnabled}
isUpdatingPush={settings.isUpdatingPush}
onPushToggle={settings.handlePushToggle}
/>
{/* Preferences */}
<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}
/>
{/* Account */}
<SettingsSection title="Account" delay={425}>
{/* Settings Navigation */}
<SettingsSection title="Settings" delay={250}>
<MenuItem
icon="log-out-outline"
label="Sign Out"
onPress={handleSignOut}
variant="danger"
showChevron={true}
icon="person-circle-outline"
label="Profile"
description="Avatar and display name"
onPress={() => router.push('/(app)/settings/profile')}
/>
<View style={styles.divider} />
<MenuItem
icon="notifications-outline"
label="Notifications"
description="Push notification preferences"
onPress={() => router.push('/(app)/settings/notifications')}
/>
<View style={styles.divider} />
<MenuItem
icon="options-outline"
label="Preferences"
description="Comfort zone and security"
onPress={() => router.push('/(app)/settings/preferences')}
/>
</SettingsSection>
{/* Support */}
<SettingsSection title="Support" delay={475}>
{/* Relationship Section */}
<SettingsSection title="Relationship" delay={300}>
<MenuItem
icon="chatbubble-ellipses-outline"
label="Send Feedback"
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')}
icon="heart-outline"
label="Partner"
rightText={partner?.name || (user?.couple_id ? 'Waiting...' : undefined)}
onPress={() => router.push('/(app)/settings/partner')}
/>
</SettingsSection>
{/* Debug Section */}
{/* Other Section */}
<SettingsSection title="Other" delay={350}>
<MenuItem
icon="help-circle-outline"
label="Help & Support"
onPress={() => router.push('/(app)/settings/support')}
/>
<View style={styles.divider} />
<MenuItem
icon="settings-outline"
label="Account"
onPress={() => router.push('/(app)/settings/account')}
/>
</SettingsSection>
{/* Debug Section - Dev only */}
{__DEV__ && (
<SettingsSection title="Debug" delay={550}>
<SettingsSection title="Debug" delay={400}>
<MenuItem
icon="refresh-outline"
label="Reset Swipe Tutorial"
@@ -265,7 +197,7 @@ export function ProfileScreen() {
Alert.alert("Success", "Swipe tutorial has been reset.");
}}
/>
<View style={styles.preferencesDivider} />
<View style={styles.divider} />
<MenuItem
icon="heart-outline"
label="Reset Matches Tutorial"
@@ -275,7 +207,7 @@ export function ProfileScreen() {
Alert.alert("Success", "Matches tutorial has been reset.");
}}
/>
<View style={styles.preferencesDivider} />
<View style={styles.divider} />
<MenuItem
icon="school-outline"
label="Reset Onboarding"
@@ -298,17 +230,9 @@ export function ProfileScreen() {
</SettingsSection>
)}
{/* Danger Zone */}
<DangerZone
onResetProgress={couple ? handleResetProgress : undefined}
onDeleteRelationship={couple ? handleDeleteRelationship : undefined}
onDeleteAccount={handleDeleteAccount}
hasRelationship={!!couple}
/>
{/* Version */}
<Animated.View
entering={FadeInDown.delay(couple ? 650 : 525).duration(500)}
entering={FadeInDown.delay(__DEV__ ? 450 : 400).duration(500)}
style={styles.versionContainer}
>
<View style={styles.versionBadge}>
@@ -318,16 +242,11 @@ export function ProfileScreen() {
</Animated.View>
<View style={styles.bottomSpacer} />
</AnimatedScrollView>
</ScrollView>
{/* Modals */}
<FeedbackModal
visible={showFeedbackModal}
onClose={() => setShowFeedbackModal(false)}
/>
<Paywall
visible={settings.showPaywall}
onClose={() => settings.setShowPaywall(false)}
visible={showPaywall}
onClose={() => setShowPaywall(false)}
/>
</GradientBackground>
);
@@ -337,33 +256,7 @@ const styles = StyleSheet.create({
container: {
flex: 1,
},
// Fixed Nav Bar
navBar: {
position: "absolute",
top: 0,
left: 0,
right: 0,
height: STATUS_BAR_HEIGHT + NAV_BAR_HEIGHT,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
paddingTop: STATUS_BAR_HEIGHT - 10,
paddingHorizontal: spacing.md,
zIndex: 100,
},
navBarBackground: {
...StyleSheet.absoluteFillObject,
borderBottomWidth: 1,
borderBottomColor: 'rgba(155, 89, 182, 0.15)', // Purple tint for profile
overflow: "hidden",
},
navBarTitle: {
...typography.headline,
color: colors.text,
textAlign: "center",
},
contentContainer: {
// Remove top padding here as the header adds it
paddingBottom: Platform.OS === 'ios' ? 100 : 80,
},
contentContainerWide: {
@@ -372,7 +265,7 @@ const styles = StyleSheet.create({
maxWidth: MAX_CONTENT_WIDTH,
},
header: {
paddingTop: STATUS_BAR_HEIGHT + spacing.md,
paddingTop: Platform.OS === 'ios' ? 60 : 40,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.md,
alignItems: 'center',
@@ -409,7 +302,65 @@ const styles = StyleSheet.create({
marginHorizontal: spacing.sm,
opacity: 0.6,
},
preferencesDivider: {
// Profile Preview
profilePreview: {
marginHorizontal: spacing.lg,
marginBottom: spacing.lg,
backgroundColor: colors.glass.background,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: colors.glass.border,
overflow: 'hidden',
},
profilePreviewContent: {
flexDirection: 'row',
alignItems: 'center',
padding: spacing.md,
},
avatarGradient: {
width: 56,
height: 56,
borderRadius: 28,
padding: 2,
justifyContent: 'center',
alignItems: 'center',
},
avatar: {
width: '100%',
height: '100%',
borderRadius: 28,
backgroundColor: colors.background,
},
avatarInner: {
width: '100%',
height: '100%',
borderRadius: 28,
backgroundColor: colors.background,
justifyContent: 'center',
alignItems: 'center',
},
avatarText: {
...typography.title2,
fontSize: 24,
color: colors.text,
},
profileInfo: {
flex: 1,
marginLeft: spacing.md,
},
userName: {
...typography.headline,
color: colors.text,
},
userEmail: {
...typography.caption1,
color: colors.textSecondary,
marginTop: 2,
},
profileChevron: {
padding: spacing.xs,
},
divider: {
height: 1,
backgroundColor: colors.glass.border,
marginVertical: spacing.md,
@@ -15,6 +15,10 @@ export interface MenuItemProps {
onPress: () => void;
/** Optional element to show on the right */
rightElement?: React.ReactNode;
/** Text to display on the right side (e.g., partner name) */
rightText?: string;
/** Badge label to display (e.g., "Premium") */
badge?: string;
/** Whether to show chevron arrow */
showChevron?: boolean;
/** Visual variant */
@@ -34,6 +38,8 @@ export function MenuItem({
description,
onPress,
rightElement,
rightText,
badge,
showChevron = true,
variant = 'default',
disabled = false,
@@ -65,17 +71,29 @@ export function MenuItem({
</LinearGradient>
)}
<View style={styles.textContainer}>
<Text style={[styles.label, { color: labelColor }]}>{label}</Text>
<View style={styles.labelRow}>
<Text style={[styles.label, { color: labelColor }]}>{label}</Text>
{badge && (
<View style={styles.badge}>
<Text style={styles.badgeText}>{badge}</Text>
</View>
)}
</View>
{description && (
<Text style={styles.description}>{description}</Text>
)}
</View>
</View>
{rightElement || (showChevron && (
<View style={styles.chevron}>
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
</View>
))}
<View style={styles.rightSection}>
{rightText && (
<Text style={styles.rightText} numberOfLines={1}>{rightText}</Text>
)}
{rightElement || (showChevron && (
<View style={styles.chevron}>
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
</View>
))}
</View>
</TouchableOpacity>
);
}
@@ -113,15 +131,43 @@ const styles = StyleSheet.create({
textContainer: {
flex: 1,
},
labelRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
label: {
...typography.body,
fontWeight: '500',
},
badge: {
backgroundColor: colors.premium.goldLight,
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderRadius: radius.full,
},
badgeText: {
...typography.caption2,
color: colors.premium.gold,
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 0.5,
},
description: {
...typography.caption1,
color: colors.textSecondary,
marginTop: 2,
},
rightSection: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
rightText: {
...typography.subhead,
color: colors.textSecondary,
maxWidth: 120,
},
chevron: {
padding: spacing.xs,
},
@@ -1,5 +1,8 @@
import React from 'react';
import React, { useEffect, useMemo } from 'react';
import { View, StyleSheet, Text } from 'react-native';
import { colors, spacing, typography } from '../../../theme';
import { useNotificationPreferencesStore } from '../../../store';
import { useAuthStore } from '../../../store';
import { SettingsSection } from './SettingsSection';
import { SwitchItem } from './SwitchItem';
@@ -16,16 +19,132 @@ export const NotificationSettings: React.FC<NotificationSettingsProps> = ({
onPushToggle,
delay = 360,
}) => {
const { user } = useAuthStore();
const { preferences, isUpdating, fetchPreferences, updatePreference } = useNotificationPreferencesStore();
// Fetch notification preferences on mount when user is available
useEffect(() => {
if (user?.id && !preferences) {
fetchPreferences();
}
}, [user?.id, preferences, fetchPreferences]);
// Memoize handlers to prevent unnecessary re-renders
const handlers = useMemo(() => ({
onMatchesToggle: (value: boolean) => updatePreference('matches_enabled', value),
onMessagesToggle: (value: boolean) => updatePreference('messages_enabled', value),
onPartnerActivityToggle: (value: boolean) => updatePreference('partner_activity_enabled', value),
onPackChangesToggle: (value: boolean) => updatePreference('pack_changes_enabled', value),
onNewPacksToggle: (value: boolean) => updatePreference('new_packs_enabled', value),
onStreakMilestonesToggle: (value: boolean) => updatePreference('streak_milestones_enabled', value),
}), [updatePreference]);
return (
<SettingsSection title="Notifications" delay={delay}>
{/* Master push toggle */}
<SwitchItem
icon="notifications-outline"
label="Push Notifications"
description="Get alerts for matches and messages"
description="Enable notifications on this device"
value={pushEnabled}
onValueChange={onPushToggle}
disabled={isUpdatingPush}
/>
{/* Granular settings - only show when push is enabled and preferences are loaded */}
{pushEnabled && preferences && (
<>
<View style={styles.sectionDivider}>
<Text style={styles.subsectionLabel}>Notification Types</Text>
</View>
<SwitchItem
icon="heart-outline"
label="Matches"
description="When you and your partner match"
value={preferences.matches_enabled}
onValueChange={handlers.onMatchesToggle}
disabled={isUpdating}
/>
<View style={styles.itemDivider} />
<SwitchItem
icon="chatbubble-outline"
label="Messages"
description="When your partner sends you a message"
value={preferences.messages_enabled}
onValueChange={handlers.onMessagesToggle}
disabled={isUpdating}
/>
<View style={styles.itemDivider} />
<SwitchItem
icon="person-outline"
label="Partner Activity"
description="When your partner answers questions"
value={preferences.partner_activity_enabled}
onValueChange={handlers.onPartnerActivityToggle}
disabled={isUpdating}
/>
<View style={styles.itemDivider} />
<SwitchItem
icon="layers-outline"
label="Pack Updates"
description="When your partner enables new packs"
value={preferences.pack_changes_enabled}
onValueChange={handlers.onPackChangesToggle}
disabled={isUpdating}
/>
<View style={styles.itemDivider} />
<SwitchItem
icon="sparkles-outline"
label="New Packs"
description="When new question packs are available"
value={preferences.new_packs_enabled}
onValueChange={handlers.onNewPacksToggle}
disabled={isUpdating}
/>
<View style={styles.itemDivider} />
<SwitchItem
icon="flame-outline"
label="Streak Milestones"
description="Celebrate your streak achievements"
value={preferences.streak_milestones_enabled}
onValueChange={handlers.onStreakMilestonesToggle}
disabled={isUpdating}
/>
</>
)}
</SettingsSection>
);
};
const styles = StyleSheet.create({
sectionDivider: {
marginTop: spacing.lg,
marginBottom: spacing.md,
paddingTop: spacing.md,
borderTopWidth: 1,
borderTopColor: colors.glass.border,
},
subsectionLabel: {
...typography.caption1,
color: colors.textTertiary,
fontWeight: '600',
letterSpacing: 1,
textTransform: 'uppercase',
},
itemDivider: {
height: 1,
backgroundColor: colors.glass.border,
marginVertical: spacing.sm,
},
});
@@ -1,10 +1,12 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
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 { 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';
@@ -38,7 +40,10 @@ export const PrivacySettings: React.FC<PrivacySettingsProps> = ({
partnerName,
partnerAvatar,
}) => {
const [showHelpModal, setShowHelpModal] = useState(false);
return (
<>
<SettingsSection title="Preferences" delay={375}>
{/* Comfort Zone Header */}
<View style={styles.comfortHeader}>
@@ -51,7 +56,16 @@ export const PrivacySettings: React.FC<PrivacySettingsProps> = ({
<Ionicons name="heart-circle" size={22} color={colors.text} />
</LinearGradient>
<View style={styles.comfortText}>
<Text style={styles.comfortLabel}>Comfort Zone</Text>
<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>
@@ -85,6 +99,100 @@ 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}>Partner Sync</Text>
<Text style={styles.helpItemDescription}>
Questions only appear when both of you are comfortable. The lower setting between you and your partner determines what you both see.
</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>
</>
);
};
@@ -105,6 +213,11 @@ const styles = StyleSheet.create({
comfortText: {
flex: 1,
},
comfortLabelRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
comfortLabel: {
...typography.headline,
color: colors.text,
@@ -122,4 +235,71 @@ const styles = StyleSheet.create({
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,
},
});
@@ -0,0 +1,584 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, Modal, Platform, KeyboardAvoidingView, TouchableOpacity, ActivityIndicator, TextInput, ViewStyle } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { BlurView } from 'expo-blur';
import { GlassCard, GlassButton } from '../../../components/ui';
import { colors, spacing, radius, typography } from '../../../theme';
interface RelationshipDangerZoneProps {
onResetProgress?: () => Promise<void>;
onDeleteRelationship?: () => Promise<void>;
}
/**
* Danger zone component specifically for relationship-related actions.
* Does NOT include delete account - that's in AccountScreen.
*/
export const RelationshipDangerZone: React.FC<RelationshipDangerZoneProps> = ({
onResetProgress,
onDeleteRelationship,
}) => {
const [showResetModal, setShowResetModal] = useState(false);
const [showDeleteRelationshipModal, setShowDeleteRelationshipModal] = useState(false);
const [confirmText, setConfirmText] = useState("");
const [isResetting, setIsResetting] = useState(false);
const [isDeletingRelationship, setIsDeletingRelationship] = 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 (confirmText !== "DELETE" || !onDeleteRelationship) return;
setIsDeletingRelationship(true);
try {
await onDeleteRelationship();
setShowDeleteRelationshipModal(false);
setConfirmText("");
} catch (error) {
// Error handling relies on parent
} finally {
setIsDeletingRelationship(false);
}
};
const closeRelationshipModal = () => {
if (!isDeletingRelationship) {
setShowDeleteRelationshipModal(false);
setConfirmText("");
}
};
return (
<>
<Animated.View
entering={FadeInDown.delay(100).duration(500)}
style={styles.section}
>
<Text style={styles.dangerSectionTitle}>Danger Zone</Text>
{/* Reset Progress */}
{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 */}
{onDeleteRelationship && (
<GlassCard style={StyleSheet.flatten([styles.dangerCard, onResetProgress && 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</Text>
<Text style={styles.dangerDescription}>
End your connection and delete all shared data. Your account will remain.
</Text>
</View>
</View>
<GlassButton
variant="danger"
size="sm"
onPress={() => setShowDeleteRelationshipModal(true)}
icon={<Ionicons name="trash-outline" size={16} color={colors.text} />}
style={styles.dangerButton}
>
Delete
</GlassButton>
</View>
</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}
transparent
animationType="fade"
onRequestClose={closeRelationshipModal}
>
<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.modalIconContainer}>
<Ionicons name="heart-dislike" size={32} color={colors.error} />
</View>
<Text style={styles.modalTitle}>Delete Relationship Data?</Text>
</View>
<Text style={styles.modalDescription}>
This action cannot be undone. This will permanently delete:
</Text>
<View style={styles.deleteList}>
<View style={styles.deleteListItem}>
<Ionicons name="heart-dislike" size={16} color={colors.error} />
<Text style={styles.deleteListText}>Your couple connection</Text>
</View>
<View style={styles.deleteListItem}>
<Ionicons name="checkmark-circle" size={16} color={colors.error} />
<Text style={styles.deleteListText}>All matches and responses</Text>
</View>
<View style={styles.deleteListItem}>
<Ionicons name="chatbubbles" size={16} color={colors.error} />
<Text style={styles.deleteListText}>All chat messages</Text>
</View>
<View style={styles.deleteListItem}>
<Ionicons name="images" size={16} color={colors.error} />
<Text style={styles.deleteListText}>All shared photos</Text>
</View>
</View>
<Text style={styles.confirmLabel}>
Type <Text style={styles.confirmKeyword}>DELETE</Text> to confirm:
</Text>
<TextInput
style={styles.confirmInput}
value={confirmText}
onChangeText={setConfirmText}
placeholder="Type DELETE"
placeholderTextColor={colors.textTertiary}
autoCapitalize="characters"
autoCorrect={false}
editable={!isDeletingRelationship}
/>
<View style={styles.modalButtons}>
<TouchableOpacity
style={styles.cancelButton}
onPress={closeRelationshipModal}
disabled={isDeletingRelationship}
activeOpacity={0.7}
>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.deleteButton,
(confirmText !== "DELETE" || isDeletingRelationship) && styles.deleteButtonDisabled,
]}
onPress={handleDeleteRelationship}
disabled={confirmText !== "DELETE" || isDeletingRelationship}
activeOpacity={0.7}
>
{isDeletingRelationship ? (
<ActivityIndicator size="small" color={colors.text} />
) : (
<>
<Ionicons name="trash" size={16} color={colors.text} />
<Text style={styles.deleteButtonText}>Delete</Text>
</>
)}
</TouchableOpacity>
</View>
</View>
</KeyboardAvoidingView>
</BlurView>
</Modal>
</>
);
};
const styles = StyleSheet.create({
section: {
paddingHorizontal: spacing.lg,
marginBottom: spacing.lg,
},
dangerSectionTitle: {
...typography.caption1,
color: colors.error,
textTransform: "uppercase",
letterSpacing: 1.5,
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,
},
dangerCardSpacing: {
marginTop: spacing.md,
},
dangerContent: {
gap: spacing.md,
},
dangerInfo: {
flexDirection: "row",
alignItems: "flex-start",
},
dangerIconContainer: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.errorLight,
justifyContent: "center",
alignItems: "center",
},
dangerTextContainer: {
flex: 1,
marginLeft: spacing.md,
},
dangerTitle: {
...typography.headline,
color: colors.text,
marginBottom: spacing.xs,
},
dangerDescription: {
...typography.subhead,
color: colors.textSecondary,
lineHeight: 20,
},
dangerButton: {
alignSelf: "flex-end",
},
// Modal styles
modalOverlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.7)",
justifyContent: "center",
alignItems: "center",
padding: spacing.lg,
},
modalContent: {
width: "100%",
maxWidth: 400,
padding: spacing.xl,
backgroundColor: colors.backgroundLight,
borderRadius: radius.xl,
borderWidth: 1,
borderColor: colors.glass.border,
},
modalHeader: {
alignItems: "center",
marginBottom: spacing.lg,
},
modalIconContainer: {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: colors.errorLight,
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.md,
},
modalTitle: {
...typography.title2,
color: colors.text,
textAlign: "center",
},
modalDescription: {
...typography.body,
color: colors.textSecondary,
marginBottom: spacing.lg,
textAlign: "center",
},
deleteList: {
backgroundColor: "rgba(231, 76, 60, 0.1)",
borderRadius: radius.lg,
padding: spacing.md,
gap: spacing.sm,
marginBottom: spacing.xl,
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",
gap: spacing.sm,
},
deleteListText: {
...typography.subhead,
color: colors.text,
},
confirmLabel: {
...typography.subhead,
color: colors.textSecondary,
marginBottom: spacing.xs,
textAlign: "center",
},
confirmKeyword: {
fontWeight: "bold",
color: colors.error,
},
confirmInput: {
backgroundColor: colors.glass.background,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.error,
padding: spacing.md,
...typography.body,
color: colors.text,
textAlign: 'center',
marginBottom: spacing.xl,
},
modalButtons: {
flexDirection: "row",
gap: spacing.md,
},
cancelButton: {
flex: 1,
backgroundColor: colors.glass.backgroundLight,
paddingVertical: spacing.md,
borderRadius: radius.md,
alignItems: "center",
borderWidth: 1,
borderColor: colors.glass.borderLight,
},
cancelButtonText: {
...typography.body,
color: colors.textSecondary,
fontWeight: "600",
},
deleteButton: {
flex: 1,
backgroundColor: colors.error,
paddingVertical: spacing.md,
borderRadius: radius.md,
alignItems: "center",
flexDirection: "row",
justifyContent: "center",
gap: spacing.xs,
},
deleteButtonDisabled: {
opacity: 0.5,
},
deleteButtonText: {
...typography.body,
color: colors.text,
fontWeight: "600",
},
});
export default RelationshipDangerZone;
@@ -0,0 +1,80 @@
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Platform } from 'react-native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { colors, spacing, typography, featureColors } from '../../../theme';
export interface ScreenHeaderProps {
/** Screen title */
title: string;
/** Optional element to render on the right side */
rightElement?: React.ReactNode;
}
/**
* Reusable header with back navigation for settings sub-screens.
*/
export function ScreenHeader({ title, rightElement }: ScreenHeaderProps) {
const router = useRouter();
const insets = useSafeAreaInsets();
const handleBack = () => {
// Navigate back to the settings hub (profile tab)
router.navigate('/(app)/profile');
};
return (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<TouchableOpacity
onPress={handleBack}
style={styles.backButton}
activeOpacity={0.7}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="chevron-back" size={24} color={colors.text} />
</TouchableOpacity>
<Text style={styles.title} numberOfLines={1}>
{title}
</Text>
<View style={styles.rightContainer}>
{rightElement || <View style={styles.placeholder} />}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingBottom: spacing.md,
borderBottomWidth: 1,
borderBottomColor: `${featureColors.profile.accent}15`,
},
backButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.glass.background,
justifyContent: 'center',
alignItems: 'center',
},
title: {
...typography.headline,
color: colors.text,
flex: 1,
textAlign: 'center',
marginHorizontal: spacing.md,
},
rightContainer: {
minWidth: 40,
alignItems: 'flex-end',
},
placeholder: {
width: 40,
},
});
export default ScreenHeader;
@@ -8,6 +8,7 @@ export type { NotificationSettingsProps } from './NotificationSettings';
export { CoupleStatus } from './CoupleStatus';
export { PrivacySettings } from './PrivacySettings';
export { DangerZone } from './DangerZone';
export { RelationshipDangerZone } from './RelationshipDangerZone';
export { ResetProgress } from './ResetProgress';
export { SettingsSection } from './SettingsSection';
@@ -18,3 +19,5 @@ export { MenuItem } from './MenuItem';
export type { MenuItemProps } from './MenuItem';
export { SubscriptionCard } from './SubscriptionCard';
export type { SubscriptionCardProps } from './SubscriptionCard';
export { ScreenHeader } from './ScreenHeader';
export type { ScreenHeaderProps } from './ScreenHeader';
@@ -0,0 +1,60 @@
import React from 'react';
import { View, ScrollView, StyleSheet, Platform } from 'react-native';
import { GradientBackground } from '../../../components/ui';
import { colors, spacing } from '../../../theme';
import { useAuthStore } from '../../../store';
import { useCoupleManagement } from '../hooks';
import { ScreenHeader, SettingsSection, MenuItem, DangerZone } from '../components';
/**
* Account settings sub-screen (sign out, delete account).
*/
export function AccountScreen() {
const { couple } = useAuthStore();
const { handleSignOut, handleDeleteAccount } = useCoupleManagement();
return (
<GradientBackground>
<ScreenHeader title="Account" />
<ScrollView
style={styles.container}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
<SettingsSection title="Account" delay={0}>
<MenuItem
icon="log-out-outline"
label="Sign Out"
onPress={handleSignOut}
variant="danger"
showChevron={false}
/>
</SettingsSection>
<View style={styles.spacer} />
{/* Account Danger Zone - delete account only */}
<DangerZone
onDeleteAccount={handleDeleteAccount}
hasRelationship={!!couple}
/>
</ScrollView>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
paddingTop: spacing.lg,
paddingBottom: Platform.OS === 'ios' ? 40 : 20,
},
spacer: {
height: spacing.md,
},
});
export default AccountScreen;
@@ -0,0 +1,44 @@
import React from 'react';
import { ScrollView, StyleSheet, Platform } from 'react-native';
import { GradientBackground } from '../../../components/ui';
import { spacing } from '../../../theme';
import { useProfileSettings } from '../hooks';
import { ScreenHeader, NotificationSettings } from '../components';
/**
* Notifications settings sub-screen for push notification preferences.
*/
export function NotificationsScreen() {
const settings = useProfileSettings();
return (
<GradientBackground>
<ScreenHeader title="Notifications" />
<ScrollView
style={styles.container}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
<NotificationSettings
pushEnabled={settings.pushEnabled}
isUpdatingPush={settings.isUpdatingPush}
onPushToggle={settings.handlePushToggle}
delay={0}
/>
</ScrollView>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
paddingTop: spacing.lg,
paddingBottom: Platform.OS === 'ios' ? 40 : 20,
},
});
export default NotificationsScreen;
@@ -0,0 +1,66 @@
import React from 'react';
import { View, ScrollView, StyleSheet, Platform } from 'react-native';
import { GradientBackground } from '../../../components/ui';
import { colors, spacing } from '../../../theme';
import { useAuthStore } from '../../../store';
import { useCoupleManagement } from '../hooks';
import { ScreenHeader, CoupleStatus, RelationshipDangerZone } from '../components';
/**
* Partner/relationship management sub-screen.
*/
export function PartnerScreen() {
const { partner, couple } = useAuthStore();
const {
handleUnpair,
handleDeleteRelationship,
handleResetProgress,
navigateToPairing,
} = useCoupleManagement();
return (
<GradientBackground>
<ScreenHeader title="Partner" />
<ScrollView
style={styles.container}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
{/* Partner Info */}
<CoupleStatus
partner={partner}
couple={couple}
onUnpair={handleUnpair}
onPairingPress={navigateToPairing}
/>
{/* Relationship Danger Zone - only show if in a relationship */}
{couple && (
<>
<View style={styles.spacer} />
<RelationshipDangerZone
onResetProgress={handleResetProgress}
onDeleteRelationship={handleDeleteRelationship}
/>
</>
)}
</ScrollView>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
paddingTop: spacing.lg,
paddingBottom: Platform.OS === 'ios' ? 40 : 20,
},
spacer: {
height: spacing.lg,
},
});
export default PartnerScreen;
@@ -0,0 +1,51 @@
import React from 'react';
import { ScrollView, StyleSheet, Platform } from 'react-native';
import { GradientBackground } from '../../../components/ui';
import { spacing } from '../../../theme';
import { useProfileSettings } from '../hooks';
import { ScreenHeader, PrivacySettings } from '../components';
/**
* Preferences sub-screen for comfort zone and biometric settings.
*/
export function PreferencesScreen() {
const settings = useProfileSettings();
return (
<GradientBackground>
<ScreenHeader title="Preferences" />
<ScrollView
style={styles.container}
contentContainerStyle={styles.content}
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}
/>
</ScrollView>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
paddingTop: spacing.lg,
paddingBottom: Platform.OS === 'ios' ? 40 : 20,
},
});
export default PreferencesScreen;
@@ -0,0 +1,437 @@
import React, { useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
Platform,
TextInput,
Pressable,
ActivityIndicator,
Alert,
TouchableOpacity,
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { GradientBackground, GlassCard, GlassButton } from '../../../components/ui';
import { colors, featureColors, spacing, typography, radius } from '../../../theme';
import { useAuthStore } from '../../../store';
import { supabase } from '../../../lib/supabase';
import { Events } from '../../../lib/analytics';
import { useProfileSettings } from '../hooks';
import { ScreenHeader, SettingsSection } from '../components';
import type { Gender } from '../../../types';
// Hook to get email from auth session
function useAuthEmail() {
const [email, setEmail] = useState<string | null>(null);
useEffect(() => {
const getEmail = async () => {
const { data: { user } } = await supabase.auth.getUser();
setEmail(user?.email ?? null);
};
getEmail();
}, []);
return email;
}
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
{ value: 'male', label: 'Male', icon: 'male' },
{ value: 'female', label: 'Female', icon: 'female' },
{ value: 'non-binary', label: 'Non-binary', icon: 'male-female' },
{ value: 'prefer-not-to-say', label: 'Prefer not to say', icon: 'remove-circle-outline' },
];
/**
* Profile settings sub-screen for editing avatar, display name, and gender.
*/
export function ProfileSettingsScreen() {
const { user, fetchUser } = useAuthStore();
const settings = useProfileSettings();
const authEmail = useAuthEmail();
// Local form state
const [name, setName] = useState(user?.name || '');
const [gender, setGender] = useState<Gender | null>(user?.gender || null);
const [isSaving, setIsSaving] = useState(false);
// Track if form has changes
const hasChanges = name.trim() !== (user?.name || '') || gender !== (user?.gender || null);
// Reset form when user changes
useEffect(() => {
setName(user?.name || '');
setGender(user?.gender || null);
}, [user?.name, user?.gender]);
const handleSave = async () => {
if (!user?.id) return;
const trimmedName = name.trim();
if (!trimmedName) {
Alert.alert('Error', 'Name cannot be empty');
return;
}
setIsSaving(true);
try {
const updates: Record<string, any> = {
updated_at: new Date().toISOString(),
};
const changedFields: string[] = [];
if (trimmedName !== user.name) {
updates.name = trimmedName;
changedFields.push('name');
}
if (gender !== user.gender) {
updates.gender = gender;
changedFields.push('gender');
}
if (changedFields.length === 0) {
setIsSaving(false);
return;
}
const { error } = await supabase
.from('profiles')
.update(updates)
.eq('id', user.id);
if (error) throw error;
await fetchUser();
Events.profileUpdated(changedFields);
Alert.alert('Success', 'Profile updated successfully');
} catch (error) {
console.error('Failed to update profile:', error);
Alert.alert('Error', 'Failed to update profile. Please try again.');
} finally {
setIsSaving(false);
}
};
return (
<GradientBackground>
<ScreenHeader
title="Profile"
rightElement={
hasChanges ? (
<TouchableOpacity
onPress={handleSave}
disabled={isSaving}
activeOpacity={0.7}
>
{isSaving ? (
<ActivityIndicator size="small" color={featureColors.profile.accent} />
) : (
<Text style={styles.saveButton}>Save</Text>
)}
</TouchableOpacity>
) : undefined
}
/>
<ScrollView
style={styles.container}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
{/* Avatar Section */}
<Animated.View
entering={FadeInDown.delay(100).duration(500)}
style={styles.avatarSection}
>
<TouchableOpacity
onPress={settings.handleAvatarPress}
disabled={settings.isUploadingAvatar}
activeOpacity={0.7}
style={styles.avatarTouchable}
>
<LinearGradient
colors={ACCENT_GRADIENT}
style={styles.avatarGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
{user?.avatar_url ? (
<Image
source={{ uri: user.avatar_url }}
style={styles.avatarImage}
cachePolicy="disk"
transition={200}
/>
) : (
<View style={styles.avatarInner}>
<Text style={styles.avatarText}>
{user?.name?.[0]?.toUpperCase() || 'U'}
</Text>
</View>
)}
</LinearGradient>
{settings.isUploadingAvatar ? (
<View style={styles.avatarOverlay}>
<ActivityIndicator size="large" color={colors.text} />
</View>
) : (
<View style={styles.avatarEditBadge}>
<Ionicons name="camera" size={16} color={colors.text} />
</View>
)}
</TouchableOpacity>
<Text style={styles.avatarHint}>Tap to change photo</Text>
</Animated.View>
{/* Name Section */}
<SettingsSection title="Display Name" delay={150}>
<View style={styles.inputContainer}>
<Ionicons
name="person-outline"
size={20}
color={colors.textSecondary}
style={styles.inputIcon}
/>
<TextInput
style={styles.input}
value={name}
onChangeText={setName}
placeholder="Enter your name"
placeholderTextColor={colors.textTertiary}
autoCapitalize="words"
autoCorrect={false}
returnKeyType="done"
/>
</View>
<Text style={styles.inputHint}>
This is how your partner will see you
</Text>
</SettingsSection>
{/* Gender Section */}
<SettingsSection title="Gender" delay={200}>
<Text style={styles.genderDescription}>
This helps us show you the right questions
</Text>
<View style={styles.genderGrid}>
{GENDER_OPTIONS.map((option) => (
<Pressable
key={option.value}
style={[
styles.genderOption,
gender === option.value && styles.genderOptionSelected,
]}
onPress={() => setGender(option.value)}
>
<Ionicons
name={option.icon as any}
size={24}
color={gender === option.value ? colors.primary : colors.textSecondary}
/>
<Text
style={[
styles.genderLabel,
gender === option.value && styles.genderLabelSelected,
]}
>
{option.label}
</Text>
</Pressable>
))}
</View>
</SettingsSection>
{/* Email (read-only) */}
<SettingsSection title="Email" delay={250}>
<View style={styles.readOnlyField}>
<Ionicons
name="mail-outline"
size={20}
color={colors.textTertiary}
style={styles.inputIcon}
/>
<Text style={styles.readOnlyText}>{authEmail || 'Loading...'}</Text>
</View>
<Text style={styles.inputHint}>
Email cannot be changed
</Text>
</SettingsSection>
<View style={styles.bottomSpacer} />
</ScrollView>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
paddingTop: spacing.lg,
paddingBottom: Platform.OS === 'ios' ? 40 : 20,
},
saveButton: {
...typography.headline,
color: featureColors.profile.accent,
},
// Avatar
avatarSection: {
alignItems: 'center',
marginBottom: spacing.xl,
},
avatarTouchable: {
marginBottom: spacing.sm,
},
avatarGradient: {
width: 120,
height: 120,
borderRadius: 60,
padding: 4,
justifyContent: 'center',
alignItems: 'center',
shadowColor: featureColors.profile.accent,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 6,
},
avatarImage: {
width: '100%',
height: '100%',
borderRadius: 60,
backgroundColor: colors.background,
},
avatarInner: {
width: '100%',
height: '100%',
borderRadius: 60,
backgroundColor: colors.background,
justifyContent: 'center',
alignItems: 'center',
},
avatarText: {
...typography.largeTitle,
fontSize: 48,
color: colors.text,
},
avatarOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: 60,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'center',
alignItems: 'center',
},
avatarEditBadge: {
position: 'absolute',
bottom: 4,
right: 4,
backgroundColor: colors.surfaceSolid,
width: 36,
height: 36,
borderRadius: 18,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 3,
borderColor: colors.background,
},
avatarHint: {
...typography.caption1,
color: colors.textTertiary,
},
// Input
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.glass.background,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.glass.border,
paddingHorizontal: spacing.md,
},
inputIcon: {
marginRight: spacing.sm,
},
input: {
flex: 1,
...typography.body,
color: colors.text,
paddingVertical: spacing.md,
},
inputHint: {
...typography.caption1,
color: colors.textTertiary,
marginTop: spacing.sm,
marginLeft: spacing.xs,
},
// Read-only field
readOnlyField: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.glass.background,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.glass.border,
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
opacity: 0.7,
},
readOnlyText: {
...typography.body,
color: colors.textSecondary,
},
// Gender
genderDescription: {
...typography.caption1,
color: colors.textSecondary,
marginBottom: spacing.md,
},
genderGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.sm,
},
genderOption: {
width: '48%',
alignItems: 'center',
backgroundColor: colors.glass.background,
borderRadius: radius.lg,
borderWidth: 2,
borderColor: colors.glass.border,
paddingVertical: spacing.md,
paddingHorizontal: spacing.sm,
gap: spacing.xs,
},
genderOptionSelected: {
borderColor: colors.primary,
backgroundColor: colors.primaryLight,
},
genderLabel: {
...typography.caption1,
color: colors.textSecondary,
textAlign: 'center',
},
genderLabelSelected: {
color: colors.primary,
fontWeight: '600',
},
bottomSpacer: {
height: spacing.xl,
},
});
export default ProfileSettingsScreen;
@@ -0,0 +1,70 @@
import React from 'react';
import { ScrollView, StyleSheet, Platform } from 'react-native';
import { GradientBackground } from '../../../components/ui';
import { Paywall } from '../../../components/paywall';
import { spacing } from '../../../theme';
import { useAuthStore, useSubscriptionStore } from '../../../store';
import { useProfileSettings } from '../hooks';
import { ScreenHeader, SubscriptionCard } from '../components';
/**
* Subscription management sub-screen.
*/
export function SubscriptionScreen() {
const { user, partner } = useAuthStore();
const { subscription } = useSubscriptionStore();
const settings = useProfileSettings();
// Use OR: if RevenueCat says they're subscribed OR database says premium, it's their own
const isOwnSubscription = subscription.isProUser || user?.is_premium;
const hasPremiumAccess = user?.is_premium || partner?.is_premium || subscription.isProUser;
const formatExpirationDate = (date: Date | null | string) => {
if (!date) return "Never";
const d = new Date(date);
return d.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
};
return (
<GradientBackground>
<ScreenHeader title="Subscription" />
<ScrollView
style={styles.container}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
<SubscriptionCard
hasPremiumAccess={hasPremiumAccess}
isOwnSubscription={!!isOwnSubscription}
expirationDate={formatExpirationDate(subscription.expirationDate)}
onUpgradePress={() => settings.setShowPaywall(true)}
onManagePress={settings.handleManageSubscription}
onRestorePress={settings.handleRestorePurchases}
isRestoring={settings.isPurchasing}
delay={0}
/>
</ScrollView>
<Paywall
visible={settings.showPaywall}
onClose={() => settings.setShowPaywall(false)}
/>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
paddingTop: spacing.lg,
paddingBottom: Platform.OS === 'ios' ? 40 : 20,
},
});
export default SubscriptionScreen;
@@ -0,0 +1,68 @@
import React, { useState } from 'react';
import { View, ScrollView, StyleSheet, Platform, Linking } from 'react-native';
import { GradientBackground } from '../../../components/ui';
import { FeedbackModal } from '../../../components/feedback';
import { colors, spacing } from '../../../theme';
import { ScreenHeader, SettingsSection, MenuItem } from '../components';
/**
* Help & Support sub-screen.
*/
export function SupportScreen() {
const [showFeedbackModal, setShowFeedbackModal] = useState(false);
return (
<GradientBackground>
<ScreenHeader title="Help & Support" />
<ScrollView
style={styles.container}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
<SettingsSection title="Support" delay={0}>
<MenuItem
icon="chatbubble-ellipses-outline"
label="Send Feedback"
description="Report bugs or request features"
onPress={() => setShowFeedbackModal(true)}
/>
<View style={styles.divider} />
<MenuItem
icon="shield-checkmark-outline"
label="Privacy Policy"
onPress={() => Linking.openURL('https://sauci.app/privacy')}
/>
<View style={styles.divider} />
<MenuItem
icon="document-text-outline"
label="Terms of Service"
onPress={() => Linking.openURL('https://sauci.app/terms')}
/>
</SettingsSection>
</ScrollView>
<FeedbackModal
visible={showFeedbackModal}
onClose={() => setShowFeedbackModal(false)}
/>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
paddingTop: spacing.lg,
paddingBottom: Platform.OS === 'ios' ? 40 : 20,
},
divider: {
height: 1,
backgroundColor: colors.glass.border,
marginVertical: spacing.md,
},
});
export default SupportScreen;
@@ -0,0 +1,8 @@
// Profile sub-screens
export { ProfileSettingsScreen } from './ProfileSettingsScreen';
export { NotificationsScreen } from './NotificationsScreen';
export { PreferencesScreen } from './PreferencesScreen';
export { SubscriptionScreen } from './SubscriptionScreen';
export { PartnerScreen } from './PartnerScreen';
export { SupportScreen } from './SupportScreen';
export { AccountScreen } from './AccountScreen';
+41 -16
View File
@@ -172,15 +172,27 @@ export function logScreenView(screenName: string, screenClass?: string) {
}
}
// Flush events (useful before app backgrounding)
export function flush() {
// Firebase Analytics batches and sends events automatically
// No manual flush needed, but we keep the function for API compatibility
if (__DEV__) console.log("[Analytics] Flush called (no-op for Firebase)");
}
// Pre-defined event helpers for common actions
export const Events = {
// Flush events (useful before app backgrounding)
export function flush() {
// Firebase Analytics batches and sends events automatically
// No manual flush needed, but we keep the function for API compatibility
if (__DEV__) console.log("[Analytics] Flush called (no-op for Firebase)");
}
function truncateParam(value: string, maxLength = 100) {
if (value.length <= maxLength) return value;
return value.slice(0, maxLength - 1) + "…";
}
function safeParam(value: unknown, maxLength = 100): string {
if (typeof value === "string") return truncateParam(value, maxLength);
if (typeof value === "number" || typeof value === "boolean") return String(value);
if (value instanceof Error) return truncateParam(value.message, maxLength);
return value == null ? "" : truncateParam(String(value), maxLength);
}
// Pre-defined event helpers for common actions
export const Events = {
// Auth events
signUp: (method: string) => logEvent("sign_up", { method }),
signIn: (method: string) => logEvent("login", { method }),
@@ -210,13 +222,26 @@ export const Events = {
messageSent: () => logEvent("message_sent"),
mediaUploaded: () => logEvent("chat_media_uploaded"),
// Subscription events
paywallShown: (source: string) => logEvent("paywall_shown", { source }),
purchaseInitiated: (packageType: string) =>
logEvent("purchase_initiated", { package_type: packageType }),
purchaseCompleted: (packageType: string) =>
logEvent("purchase", { package_type: packageType }),
purchaseRestored: () => logEvent("purchase_restored"),
// Subscription events
paywallShown: (source: string) => logEvent("paywall_shown", { source }),
paywallClosed: (source: string, reason: string) =>
logEvent("paywall_closed", { source, reason }),
purchaseInitiated: (packageType: string) =>
logEvent("purchase_initiated", { package_type: packageType }),
purchaseCancelled: (packageType: string, errorCode?: unknown) =>
logEvent("purchase_cancelled", {
package_type: packageType,
error_code: safeParam(errorCode) || "unknown",
}),
purchaseFailed: (packageType: string, errorCode?: unknown, errorMessage?: unknown) =>
logEvent("purchase_failed", {
package_type: packageType,
error_code: safeParam(errorCode) || "unknown",
error_message: safeParam(errorMessage),
}),
purchaseCompleted: (packageType: string) =>
logEvent("purchase", { package_type: packageType }),
purchaseRestored: () => logEvent("purchase_restored"),
// Pack events
packEnabled: (packId: string) => logEvent("pack_enabled", { pack_id: packId }),
+57
View File
@@ -0,0 +1,57 @@
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';
/**
* App icon badge management utility.
* Sets the badge count on the app icon (iOS only, Android has no equivalent).
*/
/**
* Set the app icon badge count.
* On iOS, this updates the red badge number on the app icon.
* On Android, this is a no-op as Android doesn't have app icon badges.
*/
export async function setBadgeCount(count: number): Promise<void> {
// Badge count is iOS only
if (Platform.OS !== 'ios') {
return;
}
try {
await Notifications.setBadgeCountAsync(Math.max(0, count));
} catch (error) {
console.error('Failed to set badge count:', error);
}
}
/**
* Clear the app icon badge.
*/
export async function clearBadge(): Promise<void> {
await setBadgeCount(0);
}
/**
* Get the current badge count.
*/
export async function getBadgeCount(): Promise<number> {
if (Platform.OS !== 'ios') {
return 0;
}
try {
return await Notifications.getBadgeCountAsync();
} catch (error) {
console.error('Failed to get badge count:', error);
return 0;
}
}
/**
* Sync badge count with the total of new matches and unread messages.
* This should be called whenever either count changes.
*/
export async function syncBadgeCount(newMatchesCount: number, unreadMessagesCount: number): Promise<void> {
const total = newMatchesCount + unreadMessagesCount;
await setBadgeCount(total);
}
+40 -4
View File
@@ -17,6 +17,7 @@ interface AuthState {
refreshPartner: () => Promise<Profile | null>;
signOut: () => Promise<void>;
setUser: (user: Profile | null) => void;
updateLastActive: () => Promise<void>;
}
// Import other stores lazily to avoid circular dependency issues
@@ -25,7 +26,9 @@ const getOtherStores = () => {
const { usePacksStore } = require("./packsStore");
const { useMessageStore } = require("./messageStore");
const { useSubscriptionStore } = require("./subscriptionStore");
return { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore };
const { useNotificationPreferencesStore } = require("./notificationPreferencesStore");
const { useStreakStore } = require("./streakStore");
return { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore };
};
@@ -56,11 +59,13 @@ export const useAuthStore = create<AuthState>((set, get) => ({
// Session is invalid - clear everything
set({ user: null, couple: null, partner: null, isAuthenticated: false, isLoading: false });
// Clear other stores
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore } = getOtherStores();
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages();
useSubscriptionStore.getState().clearSubscription();
useNotificationPreferencesStore.getState().clearPreferences();
useStreakStore.getState().clearStreak();
// Sign out from Supabase (clears storage)
await supabase.auth.signOut();
return;
@@ -145,11 +150,17 @@ export const useAuthStore = create<AuthState>((set, get) => ({
isLoading: false,
});
// Clear other stores
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore } = getOtherStores();
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages();
useSubscriptionStore.getState().clearSubscription();
useNotificationPreferencesStore.getState().clearPreferences();
useStreakStore.getState().clearStreak();
// Clear badge on sign out
const { clearBadge } = require("../lib/badge");
await clearBadge();
// Then try to sign out from Supabase (don't block on this)
try {
@@ -169,11 +180,36 @@ export const useAuthStore = create<AuthState>((set, get) => ({
});
// Clear other stores when user signs out
if (user === null) {
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore } = getOtherStores();
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages();
useSubscriptionStore.getState().clearSubscription();
useNotificationPreferencesStore.getState().clearPreferences();
useStreakStore.getState().clearStreak();
// Clear badge on sign out
const { clearBadge } = require("../lib/badge");
clearBadge();
}
},
/**
* Update the user's last_active_at timestamp.
* Called when the app comes to the foreground to track user activity.
* This helps prevent sending notifications to users who are already in the app.
*/
updateLastActive: async () => {
const userId = get().user?.id;
if (!userId) return;
try {
await supabase
.from("profiles")
.update({ last_active_at: new Date().toISOString() })
.eq("id", userId);
} catch (error) {
// Silently fail - this is not critical
console.error("Failed to update last_active_at:", error);
}
},
}));
+4
View File
@@ -6,3 +6,7 @@ export { useMessageStore } from "./messageStore";
export { useSubscriptionStore } from "./subscriptionStore";
export { useResponsesStore, groupResponses } from "./responsesStore";
export type { ResponseWithQuestion, UpdateResponseResult, GroupByOption, DateSortOrder } from "./responsesStore";
export { useNotificationPreferencesStore } from "./notificationPreferencesStore";
export type { NotificationPreferences } from "./notificationPreferencesStore";
export { useStreakStore } from "./streakStore";
export type { CoupleStreak } from "./streakStore";
@@ -0,0 +1,95 @@
import { create } from "zustand";
import { supabase } from "../lib/supabase";
import { useAuthStore } from "./authStore";
export interface NotificationPreferences {
user_id: string;
matches_enabled: boolean;
messages_enabled: boolean;
partner_activity_enabled: boolean;
pack_changes_enabled: boolean;
new_packs_enabled: boolean;
streak_milestones_enabled: boolean;
created_at: string;
updated_at: string;
}
interface NotificationPreferencesState {
preferences: NotificationPreferences | null;
isLoading: boolean;
isUpdating: boolean;
error: string | null;
// Actions
fetchPreferences: () => Promise<void>;
updatePreference: (key: keyof Omit<NotificationPreferences, 'user_id' | 'created_at' | 'updated_at'>, value: boolean) => Promise<void>;
clearPreferences: () => void;
}
export const useNotificationPreferencesStore = create<NotificationPreferencesState>((set, get) => ({
preferences: null,
isLoading: false,
isUpdating: false,
error: null,
fetchPreferences: async () => {
const userId = useAuthStore.getState().user?.id;
if (!userId) {
set({ preferences: null, isLoading: false });
return;
}
set({ isLoading: true, error: null });
try {
// Use the RPC function to get or create preferences
const { data, error } = await supabase
.rpc('get_or_create_notification_preferences', { p_user_id: userId });
if (error) throw error;
set({ preferences: data, isLoading: false });
} catch (error) {
console.error('Error fetching notification preferences:', error);
set({ error: 'Failed to load notification preferences', isLoading: false });
}
},
updatePreference: async (key, value) => {
const userId = useAuthStore.getState().user?.id;
const currentPreferences = get().preferences;
if (!userId) return;
// Optimistic update
if (currentPreferences) {
set({
preferences: { ...currentPreferences, [key]: value },
isUpdating: true,
});
}
try {
const { error } = await supabase
.from('notification_preferences')
.update({ [key]: value })
.eq('user_id', userId);
if (error) throw error;
set({ isUpdating: false });
} catch (error) {
console.error('Error updating notification preference:', error);
// Revert optimistic update
set({
preferences: currentPreferences,
isUpdating: false,
error: 'Failed to update preference',
});
}
},
clearPreferences: () => {
set({ preferences: null, isLoading: false, isUpdating: false, error: null });
},
}));
+60
View File
@@ -0,0 +1,60 @@
import { create } from "zustand";
import { supabase } from "../lib/supabase";
import { useAuthStore } from "./authStore";
export interface CoupleStreak {
couple_id: string;
current_streak: number;
longest_streak: number;
last_active_date: string | null;
user1_answered_today: boolean;
user2_answered_today: boolean;
streak_celebrated_at: number;
created_at: string;
updated_at: string;
}
interface StreakState {
streak: CoupleStreak | null;
isLoading: boolean;
error: string | null;
// Actions
fetchStreak: () => Promise<void>;
clearStreak: () => void;
}
export const useStreakStore = create<StreakState>((set) => ({
streak: null,
isLoading: false,
error: null,
fetchStreak: async () => {
const coupleId = useAuthStore.getState().user?.couple_id;
if (!coupleId) {
set({ streak: null, isLoading: false });
return;
}
set({ isLoading: true, error: null });
try {
const { data, error } = await supabase
.from("couple_streaks")
.select("*")
.eq("couple_id", coupleId)
.maybeSingle();
if (error) throw error;
set({ streak: data, isLoading: false });
} catch (error) {
console.error("Error fetching streak:", error);
set({ error: "Failed to load streak", isLoading: false });
}
},
clearStreak: () => {
set({ streak: null, isLoading: false, error: null });
},
}));
+52 -35
View File
@@ -7,21 +7,28 @@ import revenueCatService, {
} from "../lib/revenuecat";
import { useAuthStore } from "./authStore";
interface SubscriptionStoreState {
subscription: SubscriptionState;
offerings: PurchasesOffering | null;
isLoadingOfferings: boolean;
isPurchasing: boolean;
error: string | null;
// Actions
initializeRevenueCat: (userId: string) => Promise<void>;
fetchOfferings: () => Promise<void>;
purchasePackage: (pkg: PurchasesPackage) => Promise<boolean>;
restorePurchases: () => Promise<boolean>;
refreshSubscriptionStatus: () => Promise<void>;
clearSubscription: () => void;
}
export type PurchaseResult = {
success: boolean;
cancelled?: boolean;
errorCode?: unknown;
errorMessage?: unknown;
};
interface SubscriptionStoreState {
subscription: SubscriptionState;
offerings: PurchasesOffering | null;
isLoadingOfferings: boolean;
isPurchasing: boolean;
error: string | null;
// Actions
initializeRevenueCat: (userId: string) => Promise<void>;
fetchOfferings: () => Promise<void>;
purchasePackage: (pkg: PurchasesPackage) => Promise<PurchaseResult>;
restorePurchases: () => Promise<boolean>;
refreshSubscriptionStatus: () => Promise<void>;
clearSubscription: () => void;
}
const defaultSubscription: SubscriptionState = {
isProUser: false,
@@ -121,26 +128,36 @@ export const useSubscriptionStore = create<SubscriptionStoreState>((set, get) =>
}
},
purchasePackage: async (pkg: PurchasesPackage) => {
set({ isPurchasing: true, error: null });
try {
const customerInfo = await revenueCatService.purchasePackage(pkg);
const subscription = revenueCatService.parseCustomerInfo(customerInfo);
set({ subscription, isPurchasing: false });
// Refresh user profile to get updated is_premium
await useAuthStore.getState().fetchUser();
return true;
} catch (error: any) {
const message =
error.message === "Purchase cancelled"
? "Purchase was cancelled"
: "Purchase failed. Please try again.";
set({ error: message, isPurchasing: false });
return false;
}
},
purchasePackage: async (pkg: PurchasesPackage) => {
set({ isPurchasing: true, error: null });
try {
const customerInfo = await revenueCatService.purchasePackage(pkg);
const subscription = revenueCatService.parseCustomerInfo(customerInfo);
set({ subscription, isPurchasing: false });
// Refresh user profile to get updated is_premium
await useAuthStore.getState().fetchUser();
return { success: true };
} catch (error: any) {
const isCancelled =
error?.userCancelled === true ||
error?.message === "Purchase cancelled" ||
error?.code === "PURCHASE_CANCELLED";
const message = isCancelled
? "Purchase was cancelled"
: "Purchase failed. Please try again.";
set({ error: message, isPurchasing: false });
return {
success: false,
cancelled: isCancelled,
errorCode: error?.code,
errorMessage: error?.message,
};
}
},
restorePurchases: async () => {
set({ isPurchasing: true, error: null });