mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 18:19:43 +00:00
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:
1 parent
12f312421b
commit
494ed17fe2
82 files changed
+4189
-725
No files matched your search
@@ -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';
|
||||
Reference in new issue
Block a user