mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Update
This commit is contained in:
1 parent
8acc43981e
commit
118dadc69a
122 files changed
+1688
-218
No files matched your search
@@ -1,12 +1,14 @@
|
||||
import { Tabs, useRouter, useSegments } from "expo-router";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { View, ActivityIndicator, StyleSheet, Platform, Modal, Text, Pressable, Animated, AppState } from "react-native";
|
||||
import { View, ActivityIndicator, StyleSheet, Platform, Modal, Text, Pressable, Animated, AppState, AppStateStatus } from "react-native";
|
||||
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 { colors, gradients, blur, radius, spacing, typography, shadows } from "../../src/theme";
|
||||
import { supabase } from "../../src/lib/supabase";
|
||||
import { isBiometricEnabled } from "../../src/lib/biometricAuth";
|
||||
import { BiometricLockScreen } from "../../src/components/BiometricLockScreen";
|
||||
import type { MatchWithQuestion } from "../../src/types";
|
||||
import type { Database } from "../../src/types/supabase";
|
||||
|
||||
@@ -15,11 +17,14 @@ type Message = Database["public"]["Tables"]["messages"]["Row"];
|
||||
function TabBarBackground() {
|
||||
if (Platform.OS === 'ios') {
|
||||
return (
|
||||
<BlurView
|
||||
intensity={blur.heavy}
|
||||
tint="systemChromeMaterialDark"
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<View style={StyleSheet.absoluteFill}>
|
||||
<BlurView
|
||||
intensity={blur.heavy}
|
||||
tint="dark"
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<View style={[StyleSheet.absoluteFill, { backgroundColor: 'rgba(22, 33, 62, 0.7)' }]} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return <View style={[StyleSheet.absoluteFill, { backgroundColor: 'rgba(22, 33, 62, 0.9)' }]} />;
|
||||
@@ -72,6 +77,9 @@ export default function AppLayout() {
|
||||
const [matchNotification, setMatchNotification] = useState<MatchWithQuestion | null>(null);
|
||||
const messageToastAnim = useRef(new Animated.Value(-100)).current;
|
||||
const messageTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [isLocked, setIsLocked] = useState(false);
|
||||
const appStateRef = useRef<AppStateStatus>(AppState.currentState);
|
||||
const hasCheckedInitialBiometric = useRef(false);
|
||||
|
||||
// Check if we're currently on the onboarding screen
|
||||
const isOnOnboarding = segments.includes("onboarding");
|
||||
@@ -110,6 +118,49 @@ export default function AppLayout() {
|
||||
};
|
||||
}, [signOut]);
|
||||
|
||||
// Handle biometric lock when app goes to background/foreground
|
||||
useEffect(() => {
|
||||
// Check if biometric should be shown on initial mount
|
||||
const checkInitialBiometric = async () => {
|
||||
if (hasCheckedInitialBiometric.current) return;
|
||||
hasCheckedInitialBiometric.current = true;
|
||||
|
||||
const enabled = await isBiometricEnabled();
|
||||
if (enabled && isAuthenticated) {
|
||||
setIsLocked(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (isAuthenticated && !isLoading) {
|
||||
checkInitialBiometric();
|
||||
}
|
||||
|
||||
const subscription = AppState.addEventListener('change', async (nextAppState) => {
|
||||
const previousState = appStateRef.current;
|
||||
appStateRef.current = nextAppState;
|
||||
|
||||
// When app comes to foreground from background
|
||||
if (
|
||||
previousState.match(/inactive|background/) &&
|
||||
nextAppState === 'active' &&
|
||||
isAuthenticated
|
||||
) {
|
||||
const enabled = await isBiometricEnabled();
|
||||
if (enabled) {
|
||||
setIsLocked(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, [isAuthenticated, isLoading]);
|
||||
|
||||
const handleBiometricUnlock = useCallback(() => {
|
||||
setIsLocked(false);
|
||||
}, []);
|
||||
|
||||
// Fetch unread message count on mount
|
||||
useEffect(() => {
|
||||
if (user?.id) {
|
||||
@@ -451,6 +502,12 @@ export default function AppLayout() {
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Biometric Lock Screen */}
|
||||
<BiometricLockScreen
|
||||
visible={isLocked}
|
||||
onUnlock={handleBiometricUnlock}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Keyboard,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
@@ -27,6 +28,7 @@ import { GlassButton } from '../../src/components/ui/GlassButton';
|
||||
import { colors, gradients, spacing, typography, radius, shadows } from '../../src/theme';
|
||||
import { useAuthStore } from '../../src/store';
|
||||
import { supabase } from '../../src/lib/supabase';
|
||||
import { registerForPushNotificationsAsync, savePushToken } from '../../src/lib/notifications';
|
||||
import type { Gender } from '../../src/types';
|
||||
|
||||
const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
|
||||
@@ -36,7 +38,7 @@ const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
|
||||
{ value: 'prefer-not-to-say', label: 'Skip', icon: 'remove-circle-outline' },
|
||||
];
|
||||
|
||||
type Stage = 'name' | 'gender' | 'explicit';
|
||||
type Stage = 'name' | 'gender' | 'explicit' | 'notifications';
|
||||
|
||||
export default function OnboardingScreen() {
|
||||
const router = useRouter();
|
||||
@@ -44,7 +46,7 @@ export default function OnboardingScreen() {
|
||||
const [stage, setStage] = useState<Stage>('name');
|
||||
const [name, setName] = useState(user?.name || '');
|
||||
const [gender, setGender] = useState<Gender | null>(user?.gender || null);
|
||||
const [showExplicit, setShowExplicit] = useState(user?.show_explicit_content || false);
|
||||
const [showExplicit, setShowExplicit] = useState(user?.show_explicit_content ?? true);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
@@ -62,7 +64,33 @@ export default function OnboardingScreen() {
|
||||
const handleGenderSelect = (selectedGender: Gender) => {
|
||||
setGender(selectedGender);
|
||||
setError(null);
|
||||
setTimeout(() => setStage('explicit'), 300);
|
||||
};
|
||||
|
||||
const handleGenderContinue = () => {
|
||||
if (!gender) {
|
||||
setError('Please select a gender or skip');
|
||||
return;
|
||||
}
|
||||
setStage('explicit');
|
||||
};
|
||||
|
||||
const handleExplicitContinue = () => {
|
||||
setStage('notifications');
|
||||
};
|
||||
|
||||
const handleEnableNotifications = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const token = await registerForPushNotificationsAsync();
|
||||
if (token && user?.id) {
|
||||
await savePushToken(user.id, token);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to enable notifications:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
await handleComplete();
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = async () => {
|
||||
@@ -205,7 +233,19 @@ export default function OnboardingScreen() {
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
{error && (
|
||||
<View style={styles.errorContainer}>
|
||||
<Ionicons name="alert-circle" size={16} color={colors.error} />
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
</GlassCard>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<GlassButton onPress={handleGenderContinue} fullWidth size="lg">
|
||||
Continue
|
||||
</GlassButton>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
|
||||
@@ -307,13 +347,78 @@ export default function OnboardingScreen() {
|
||||
|
||||
<View style={styles.footer}>
|
||||
<GlassButton
|
||||
onPress={handleComplete}
|
||||
onPress={handleExplicitContinue}
|
||||
fullWidth
|
||||
size="lg"
|
||||
>
|
||||
Continue
|
||||
</GlassButton>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
|
||||
case 'notifications':
|
||||
return (
|
||||
<Animated.View
|
||||
key="notifications"
|
||||
entering={SlideInRight.duration(400)}
|
||||
exiting={FadeOut.duration(200)}
|
||||
style={styles.stageContainer}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<LinearGradient
|
||||
colors={gradients.primary as [string, string]}
|
||||
style={styles.iconContainer}
|
||||
>
|
||||
<Ionicons name="notifications" size={40} color={colors.text} />
|
||||
</LinearGradient>
|
||||
<Text style={styles.title}>Stay in the Loop</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Get notified when you and your partner match on answers
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<GlassCard style={styles.card}>
|
||||
<View style={styles.notificationFeature}>
|
||||
<View style={styles.notificationIcon}>
|
||||
<Ionicons name="heart" size={24} color={colors.primary} />
|
||||
</View>
|
||||
<View style={styles.notificationText}>
|
||||
<Text style={styles.notificationTitle}>Match Alerts</Text>
|
||||
<Text style={styles.notificationDescription}>
|
||||
Know instantly when you both agree
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.notificationFeature}>
|
||||
<View style={styles.notificationIcon}>
|
||||
<Ionicons name="chatbubble" size={24} color={colors.primary} />
|
||||
</View>
|
||||
<View style={styles.notificationText}>
|
||||
<Text style={styles.notificationTitle}>Messages</Text>
|
||||
<Text style={styles.notificationDescription}>
|
||||
Never miss a message from your partner
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</GlassCard>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<GlassButton
|
||||
onPress={handleEnableNotifications}
|
||||
loading={isLoading}
|
||||
fullWidth
|
||||
size="lg"
|
||||
>
|
||||
Let's Go!
|
||||
Enable Notifications
|
||||
</GlassButton>
|
||||
<Pressable
|
||||
style={styles.skipButton}
|
||||
onPress={handleComplete}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Text style={styles.skipText}>Maybe Later</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
@@ -325,13 +430,18 @@ export default function OnboardingScreen() {
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.container}
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
|
||||
>
|
||||
{/* Header with back button and progress dots */}
|
||||
<Animated.View entering={FadeIn.duration(600)} style={styles.headerBar}>
|
||||
{stage !== 'name' ? (
|
||||
<Pressable
|
||||
style={styles.backButton}
|
||||
onPress={() => setStage(stage === 'explicit' ? 'gender' : 'name')}
|
||||
onPress={() => {
|
||||
if (stage === 'notifications') setStage('explicit');
|
||||
else if (stage === 'explicit') setStage('gender');
|
||||
else setStage('name');
|
||||
}}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={24} color={colors.text} />
|
||||
</Pressable>
|
||||
@@ -342,11 +452,18 @@ export default function OnboardingScreen() {
|
||||
<View style={[styles.progressDot, stage === 'name' && styles.progressDotActive]} />
|
||||
<View style={[styles.progressDot, stage === 'gender' && styles.progressDotActive]} />
|
||||
<View style={[styles.progressDot, stage === 'explicit' && styles.progressDotActive]} />
|
||||
<View style={[styles.progressDot, stage === 'notifications' && styles.progressDotActive]} />
|
||||
</View>
|
||||
<View style={styles.backButtonPlaceholder} />
|
||||
</Animated.View>
|
||||
|
||||
{renderStage()}
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{renderStage()}
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</GradientBackground>
|
||||
);
|
||||
@@ -356,6 +473,9 @@ const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
headerBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -552,4 +672,41 @@ const styles = StyleSheet.create({
|
||||
footer: {
|
||||
marginTop: 'auto',
|
||||
},
|
||||
notificationFeature: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.glass.border,
|
||||
},
|
||||
notificationIcon: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: colors.primaryLight,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
notificationText: {
|
||||
flex: 1,
|
||||
},
|
||||
notificationTitle: {
|
||||
...typography.headline,
|
||||
color: colors.text,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
notificationDescription: {
|
||||
...typography.caption1,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
skipButton: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
skipText: {
|
||||
...typography.subhead,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { View, Text, StyleSheet, FlatList, TouchableOpacity, Switch, RefreshControl, Platform } from "react-native";
|
||||
import { View, Text, StyleSheet, FlatList, TouchableOpacity, Switch, RefreshControl, Platform, Alert } from "react-native";
|
||||
import { usePacksStore, useAuthStore, useSubscriptionStore } from "../../src/store";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { router } from "expo-router";
|
||||
@@ -33,13 +33,23 @@ export default function PacksScreen() {
|
||||
}, []);
|
||||
|
||||
// Handle toggling a pack - show teaser if premium pack without access
|
||||
const handleTogglePack = useCallback((pack: QuestionPack) => {
|
||||
const handleTogglePack = useCallback(async (pack: QuestionPack) => {
|
||||
if (pack.is_premium && !hasPremiumAccess) {
|
||||
setTeaserPack(pack);
|
||||
setShowTeaser(true);
|
||||
return;
|
||||
}
|
||||
togglePack(pack.id);
|
||||
const result = await togglePack(pack.id);
|
||||
if (!result.success && result.reason === "no_couple") {
|
||||
Alert.alert(
|
||||
"Pair with Partner",
|
||||
"You need to pair with your partner before you can customise question packs.",
|
||||
[
|
||||
{ text: "Later", style: "cancel" },
|
||||
{ text: "Pair Now", onPress: () => router.push("/pairing") }
|
||||
]
|
||||
);
|
||||
}
|
||||
}, [hasPremiumAccess, togglePack]);
|
||||
|
||||
// Handle teaser unlock button - show paywall
|
||||
|
||||
@@ -204,6 +204,12 @@ export default function PairingScreen() {
|
||||
style={styles.container}
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={() => router.back()}
|
||||
>
|
||||
<Ionicons name="arrow-back" size={24} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.emoji}>🔗</Text>
|
||||
<Text style={styles.title}>Pair Up</Text>
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Alert, ScrollView, Platform, useWindowDimensions, TextInput, Modal, ActivityIndicator, Linking, Switch } from "react-native";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import { useAuthStore, useMatchStore, useMessageStore, usePacksStore, useSubscriptionStore } from "../../src/store";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import Animated, { FadeInDown } from "react-native-reanimated";
|
||||
import { supabase } from "../../src/lib/supabase";
|
||||
import { resetSwipeTutorial } from "../../src/lib/swipeTutorialSeen";
|
||||
import {
|
||||
isBiometricAvailable,
|
||||
isBiometricEnabled,
|
||||
setBiometricEnabled,
|
||||
getBiometricType,
|
||||
} from "../../src/lib/biometricAuth";
|
||||
import { router } from "expo-router";
|
||||
import { GradientBackground, GlassCard } from "../../src/components/ui";
|
||||
import { FeedbackModal } from "../../src/components/FeedbackModal";
|
||||
@@ -29,12 +36,34 @@ export default function ProfileScreen() {
|
||||
const [showPaywall, setShowPaywall] = useState(false);
|
||||
const [showExplicit, setShowExplicit] = useState(user?.show_explicit_content ?? false);
|
||||
const [isUpdatingExplicit, setIsUpdatingExplicit] = useState(false);
|
||||
const [biometricAvailable, setBiometricAvailable] = useState(false);
|
||||
const [biometricEnabled, setBiometricEnabledState] = useState(false);
|
||||
const [biometricType, setBiometricType] = useState("Face ID");
|
||||
const [isUpdatingBiometric, setIsUpdatingBiometric] = useState(false);
|
||||
|
||||
// Check if user or partner has premium access
|
||||
const hasPremiumAccess = useMemo(() => {
|
||||
return user?.is_premium || partner?.is_premium || subscription.isProUser;
|
||||
}, [user?.is_premium, partner?.is_premium, subscription.isProUser]);
|
||||
|
||||
// Check biometric availability on mount
|
||||
useEffect(() => {
|
||||
const checkBiometric = async () => {
|
||||
const available = await isBiometricAvailable();
|
||||
setBiometricAvailable(available);
|
||||
|
||||
if (available) {
|
||||
const enabled = await isBiometricEnabled();
|
||||
setBiometricEnabledState(enabled);
|
||||
|
||||
const type = await getBiometricType();
|
||||
setBiometricType(type);
|
||||
}
|
||||
};
|
||||
|
||||
checkBiometric();
|
||||
}, []);
|
||||
|
||||
// Check if it's the user's own subscription (vs partner's)
|
||||
const isOwnSubscription = user?.is_premium && subscription.isProUser;
|
||||
|
||||
@@ -86,6 +115,21 @@ export default function ProfileScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBiometricToggle = async (value: boolean) => {
|
||||
setBiometricEnabledState(value);
|
||||
setIsUpdatingBiometric(true);
|
||||
|
||||
try {
|
||||
await setBiometricEnabled(value);
|
||||
} catch (error) {
|
||||
// Revert on error
|
||||
setBiometricEnabledState(!value);
|
||||
Alert.alert("Error", `Failed to ${value ? "enable" : "disable"} ${biometricType}. Please try again.`);
|
||||
} finally {
|
||||
setIsUpdatingBiometric(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleManageSubscription = () => {
|
||||
Linking.openURL("https://apps.apple.com/account/subscriptions");
|
||||
};
|
||||
@@ -393,6 +437,39 @@ export default function ProfileScreen() {
|
||||
ios_backgroundColor={colors.glass.border}
|
||||
/>
|
||||
</View>
|
||||
{biometricAvailable && (
|
||||
<>
|
||||
<View style={styles.preferencesDivider} />
|
||||
<View style={styles.rowContainer}>
|
||||
<View style={styles.rowLeft}>
|
||||
<LinearGradient
|
||||
colors={gradients.primary as [string, string]}
|
||||
style={styles.partnerIconGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Ionicons
|
||||
name={biometricType === "Face ID" || biometricType === "Face Recognition" ? "scan-outline" : "finger-print-outline"}
|
||||
size={20}
|
||||
color={colors.text}
|
||||
/>
|
||||
</LinearGradient>
|
||||
<View style={styles.rowTextContainer}>
|
||||
<Text style={styles.rowValue}>{biometricType}</Text>
|
||||
<Text style={styles.rowLabel}>Require unlock when opening app</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Switch
|
||||
value={biometricEnabled}
|
||||
onValueChange={handleBiometricToggle}
|
||||
disabled={isUpdatingBiometric}
|
||||
trackColor={{ false: colors.glass.border, true: colors.primaryLight }}
|
||||
thumbColor={biometricEnabled ? colors.primary : colors.textTertiary}
|
||||
ios_backgroundColor={colors.glass.border}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</GlassCard>
|
||||
</Animated.View>
|
||||
|
||||
@@ -452,6 +529,39 @@ export default function ProfileScreen() {
|
||||
</GlassCard>
|
||||
</Animated.View>
|
||||
|
||||
{/* Debug Section - Only show in development builds */}
|
||||
{__DEV__ && (
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(500).duration(500)}
|
||||
style={styles.section}
|
||||
>
|
||||
<Text style={styles.debugSectionTitle}>Debug</Text>
|
||||
<GlassCard style={styles.debugCard}>
|
||||
<TouchableOpacity
|
||||
style={styles.rowContainer}
|
||||
onPress={async () => {
|
||||
await resetSwipeTutorial();
|
||||
Alert.alert("Success", "Swipe tutorial has been reset. It will show again next time you visit the swipe screen.");
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.rowLeft}>
|
||||
<View style={styles.debugIcon}>
|
||||
<Ionicons name="refresh" size={20} color={colors.warning} />
|
||||
</View>
|
||||
<View style={styles.rowTextContainer}>
|
||||
<Text style={styles.rowValue}>Reset Swipe Tutorial</Text>
|
||||
<Text style={styles.rowLabel}>Show the tutorial again</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.chevronContainer}>
|
||||
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</GlassCard>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Danger Zone - Only show if in a couple */}
|
||||
{couple && (
|
||||
<Animated.View
|
||||
@@ -722,6 +832,11 @@ const styles = StyleSheet.create({
|
||||
color: colors.textTertiary,
|
||||
marginTop: 2,
|
||||
},
|
||||
preferencesDivider: {
|
||||
height: 1,
|
||||
backgroundColor: colors.glass.border,
|
||||
marginVertical: spacing.md,
|
||||
},
|
||||
unlinkButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
@@ -810,6 +925,27 @@ const styles = StyleSheet.create({
|
||||
bottomSpacer: {
|
||||
height: spacing.lg,
|
||||
},
|
||||
// Debug styles
|
||||
debugSectionTitle: {
|
||||
...typography.caption1,
|
||||
color: colors.warning,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1.5,
|
||||
marginBottom: spacing.sm,
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
debugCard: {
|
||||
borderColor: 'rgba(243, 156, 18, 0.3)',
|
||||
borderWidth: 1,
|
||||
},
|
||||
debugIcon: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: colors.warningLight,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
// Danger Zone styles
|
||||
dangerSectionTitle: {
|
||||
...typography.caption1,
|
||||
|
||||
@@ -5,9 +5,11 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import Animated, { FadeIn, FadeInUp } from "react-native-reanimated";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { supabase } from "../../src/lib/supabase";
|
||||
import { usePacksStore } from "../../src/store";
|
||||
import { usePacksStore, useAuthStore } from "../../src/store";
|
||||
import { skipQuestion, getSkippedQuestionIds } from "../../src/lib/skippedQuestions";
|
||||
import { hasSeenSwipeTutorial, markSwipeTutorialSeen } from "../../src/lib/swipeTutorialSeen";
|
||||
import SwipeCard from "../../src/components/SwipeCard";
|
||||
import SwipeTutorial from "../../src/components/SwipeTutorial";
|
||||
import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui";
|
||||
import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme";
|
||||
|
||||
@@ -16,12 +18,27 @@ export default function SwipeScreen() {
|
||||
const [questions, setQuestions] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [showTutorial, setShowTutorial] = useState(false);
|
||||
const { enabledPackIds, fetchPacks } = usePacksStore();
|
||||
const { partner, couple } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetchPacks().then(() => fetchQuestions());
|
||||
checkTutorial();
|
||||
}, [packId]);
|
||||
|
||||
const checkTutorial = async () => {
|
||||
const seen = await hasSeenSwipeTutorial();
|
||||
if (!seen) {
|
||||
setShowTutorial(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTutorialComplete = async () => {
|
||||
await markSwipeTutorialSeen();
|
||||
setShowTutorial(false);
|
||||
};
|
||||
|
||||
// Filter out questions from disabled packs immediately when enabledPackIds changes
|
||||
useEffect(() => {
|
||||
if (!packId && enabledPackIds.length > 0 && questions.length > 0) {
|
||||
@@ -126,6 +143,45 @@ export default function SwipeScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
// Show "pair with partner" prompt when user doesn't have a partner yet
|
||||
if (!partner) {
|
||||
return (
|
||||
<GradientBackground showAccent>
|
||||
<View style={styles.centerContainer}>
|
||||
<Animated.View
|
||||
entering={FadeInUp.duration(600).springify()}
|
||||
style={styles.emptyContent}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={gradients.primary as [string, string]}
|
||||
style={styles.pairIconContainer}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Ionicons name="heart" size={48} color={colors.text} />
|
||||
</LinearGradient>
|
||||
<Text style={styles.pairTitle}>
|
||||
{couple ? "Waiting for your partner" : "Pair with your partner"}
|
||||
</Text>
|
||||
<Text style={styles.emptySubtitle}>
|
||||
{couple
|
||||
? "Share your invite code so they can join you. Once paired, you'll both answer questions and discover what you agree on!"
|
||||
: "Sauci is made for two! Connect with your partner to start answering questions together and discover what you have in common."
|
||||
}
|
||||
</Text>
|
||||
<GlassButton
|
||||
onPress={() => router.push("/pairing")}
|
||||
style={{ marginTop: spacing.xl }}
|
||||
size="lg"
|
||||
>
|
||||
{couple ? "View Invite Code" : "Pair Now"}
|
||||
</GlassButton>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</GradientBackground>
|
||||
);
|
||||
}
|
||||
|
||||
// Show "no packs enabled" message when not viewing a specific pack and no packs are enabled
|
||||
if (!packId && enabledPackIds.length === 0) {
|
||||
return (
|
||||
@@ -238,6 +294,11 @@ export default function SwipeScreen() {
|
||||
{/* Bottom spacing for tab bar */}
|
||||
<View style={styles.bottomSpacer} />
|
||||
</View>
|
||||
|
||||
{/* Tutorial Overlay */}
|
||||
{showTutorial && (
|
||||
<SwipeTutorial onComplete={handleTutorialComplete} />
|
||||
)}
|
||||
</GradientBackground>
|
||||
);
|
||||
}
|
||||
@@ -318,6 +379,21 @@ const styles = StyleSheet.create({
|
||||
alignItems: "center",
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
pairIconContainer: {
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: 60,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginBottom: spacing.xl,
|
||||
...shadows.lg,
|
||||
},
|
||||
pairTitle: {
|
||||
...typography.title1,
|
||||
color: colors.text,
|
||||
marginBottom: spacing.md,
|
||||
textAlign: "center",
|
||||
},
|
||||
noPacksIconContainer: {
|
||||
width: 120,
|
||||
height: 120,
|
||||
|
||||
+66
-13
@@ -7,8 +7,6 @@ initSentry();
|
||||
|
||||
// Import push notification utilities
|
||||
import {
|
||||
registerForPushNotificationsAsync,
|
||||
savePushToken,
|
||||
clearPushToken,
|
||||
setupNotificationResponseListener,
|
||||
getInitialNotification,
|
||||
@@ -54,19 +52,78 @@ export default function RootLayout() {
|
||||
|
||||
// Handle initial URL if app was opened via deep link
|
||||
const handleDeepLink = async (url: string | null) => {
|
||||
if (url) {
|
||||
// Supabase needs to handle the URL to extract session
|
||||
// getSession() will automatically check window.location on web if detectSessionInUrl is true
|
||||
// For native, we rely on onAuthStateChange or manual processing if needed
|
||||
console.log('[DeepLink] handleDeepLink called with URL:', url);
|
||||
if (!url) return;
|
||||
|
||||
try {
|
||||
// Parse the URL to extract auth parameters
|
||||
const parsedUrl = new URL(url);
|
||||
console.log('[DeepLink] Parsed URL:', {
|
||||
protocol: parsedUrl.protocol,
|
||||
host: parsedUrl.host,
|
||||
pathname: parsedUrl.pathname,
|
||||
search: parsedUrl.search,
|
||||
hash: parsedUrl.hash,
|
||||
});
|
||||
const params = new URLSearchParams(parsedUrl.search);
|
||||
|
||||
// Also check hash fragment (some auth flows use fragments)
|
||||
const hashParams = new URLSearchParams(parsedUrl.hash.replace('#', ''));
|
||||
|
||||
// Get token parameters - check both query and hash
|
||||
const accessToken = params.get('access_token') || hashParams.get('access_token');
|
||||
const refreshToken = params.get('refresh_token') || hashParams.get('refresh_token');
|
||||
const tokenHash = params.get('token_hash') || hashParams.get('token_hash');
|
||||
const type = params.get('type') || hashParams.get('type');
|
||||
|
||||
console.log('[DeepLink] Extracted params:', {
|
||||
accessToken: accessToken ? 'present' : 'missing',
|
||||
refreshToken: refreshToken ? 'present' : 'missing',
|
||||
tokenHash: tokenHash ? 'present' : 'missing',
|
||||
type,
|
||||
});
|
||||
|
||||
if (accessToken && refreshToken) {
|
||||
// OAuth flow - set session directly
|
||||
console.log('[DeepLink] Setting session with tokens');
|
||||
const { data, error } = await supabase.auth.setSession({
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
console.log('[DeepLink] setSession result:', {
|
||||
hasSession: !!data?.session,
|
||||
userId: data?.session?.user?.id,
|
||||
error: error?.message
|
||||
});
|
||||
} else if (tokenHash && type) {
|
||||
// Magic link / email verification flow - verify the OTP
|
||||
console.log('[DeepLink] Verifying OTP with token_hash and type:', type);
|
||||
const { data, error } = await supabase.auth.verifyOtp({
|
||||
token_hash: tokenHash,
|
||||
type: type as 'signup' | 'magiclink' | 'recovery' | 'invite' | 'email',
|
||||
});
|
||||
console.log('[DeepLink] verifyOtp result:', { data: !!data?.session, error: error?.message });
|
||||
} else {
|
||||
// Fallback for web or other scenarios
|
||||
console.log('[DeepLink] No auth params found, falling back to getSession');
|
||||
await supabase.auth.getSession();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DeepLink] Error handling deep link:', error);
|
||||
// Still try to get session as fallback
|
||||
await supabase.auth.getSession();
|
||||
}
|
||||
};
|
||||
|
||||
// Get initial URL
|
||||
Linking.getInitialURL().then(handleDeepLink);
|
||||
Linking.getInitialURL().then((url) => {
|
||||
console.log('[DeepLink] getInitialURL returned:', url);
|
||||
handleDeepLink(url);
|
||||
});
|
||||
|
||||
// Listen for new deep links while app is open
|
||||
const linkingSubscription = Linking.addEventListener("url", (event) => {
|
||||
console.log('[DeepLink] URL event listener triggered with:', event.url);
|
||||
handleDeepLink(event.url);
|
||||
});
|
||||
|
||||
@@ -83,17 +140,13 @@ export default function RootLayout() {
|
||||
// Listen for auth changes
|
||||
const { data: { subscription: authSubscription } } = supabase.auth.onAuthStateChange(
|
||||
async (event, session) => {
|
||||
console.log('[Auth] onAuthStateChange:', event, 'hasSession:', !!session, 'userId:', session?.user?.id);
|
||||
if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") {
|
||||
console.log('[Auth] Calling fetchUser...');
|
||||
fetchUser();
|
||||
// Set Sentry user context for error tracking
|
||||
if (session?.user) {
|
||||
setUserContext(session.user.id, session.user.email);
|
||||
|
||||
// Register for push notifications and save token
|
||||
const token = await registerForPushNotificationsAsync();
|
||||
if (token) {
|
||||
await savePushToken(session.user.id, token);
|
||||
}
|
||||
}
|
||||
} else if (event === "SIGNED_OUT") {
|
||||
// Clear push token before clearing user state
|
||||
|
||||
Reference in new issue
Block a user