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
6ff5bc62f2
commit
adab288a3a
204 files changed
+11334
-14295
No files matched your search
@@ -1,9 +1,15 @@
|
||||
import { Tabs, Redirect } from "expo-router";
|
||||
import { Tabs, useRouter } from "expo-router";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { View, ActivityIndicator, StyleSheet, Platform } from "react-native";
|
||||
import { View, ActivityIndicator, StyleSheet, Platform, Modal, Text, Pressable, Animated, AppState } from "react-native";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { useAuthStore, useMatchStore } from "../../src/store";
|
||||
import { colors, blur } from "../../src/theme";
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore } from "../../src/store";
|
||||
import { colors, blur, radius, spacing, typography, shadows } from "../../src/theme";
|
||||
import { supabase } from "../../src/lib/supabase";
|
||||
import type { MatchWithQuestion } from "../../src/types";
|
||||
import type { Database } from "../../src/types/supabase";
|
||||
|
||||
type Message = Database["public"]["Tables"]["messages"]["Row"];
|
||||
|
||||
function TabBarBackground() {
|
||||
if (Platform.OS === 'ios') {
|
||||
@@ -19,8 +25,199 @@ function TabBarBackground() {
|
||||
}
|
||||
|
||||
export default function AppLayout() {
|
||||
const { isAuthenticated, isLoading } = useAuthStore();
|
||||
const { newMatchesCount } = useMatchStore();
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, isLoading, user, signOut } = useAuthStore();
|
||||
const { newMatchesCount, addMatch } = useMatchStore();
|
||||
const { unreadCount, lastMessage, fetchUnreadCount, addMessage, clearLastMessage } = useMessageStore();
|
||||
const { initializeRevenueCat } = useSubscriptionStore();
|
||||
const [matchNotification, setMatchNotification] = useState<MatchWithQuestion | null>(null);
|
||||
const messageToastAnim = useRef(new Animated.Value(-100)).current;
|
||||
const messageTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Redirect to login when not authenticated
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
router.replace("/(auth)/login");
|
||||
}
|
||||
}, [isLoading, isAuthenticated, router]);
|
||||
|
||||
// Check session validity on mount and when app comes to foreground
|
||||
useEffect(() => {
|
||||
const checkSession = async () => {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session) {
|
||||
// No valid session - sign out to clear local state
|
||||
signOut();
|
||||
}
|
||||
};
|
||||
|
||||
// Check immediately
|
||||
checkSession();
|
||||
|
||||
// Check when app comes to foreground
|
||||
const subscription = AppState.addEventListener('change', (nextAppState) => {
|
||||
if (nextAppState === 'active') {
|
||||
checkSession();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, [signOut]);
|
||||
|
||||
// Fetch unread message count on mount
|
||||
useEffect(() => {
|
||||
if (user?.id) {
|
||||
fetchUnreadCount();
|
||||
}
|
||||
}, [user?.id, fetchUnreadCount]);
|
||||
|
||||
// Initialize RevenueCat for subscription management
|
||||
useEffect(() => {
|
||||
if (user?.id && Platform.OS === "ios") {
|
||||
initializeRevenueCat(user.id);
|
||||
}
|
||||
}, [user?.id, initializeRevenueCat]);
|
||||
|
||||
// Subscribe to realtime match notifications
|
||||
useEffect(() => {
|
||||
if (!user?.couple_id) return;
|
||||
|
||||
const channel = supabase
|
||||
.channel(`matches:${user.couple_id}`)
|
||||
.on(
|
||||
"postgres_changes",
|
||||
{
|
||||
event: "INSERT",
|
||||
schema: "public",
|
||||
table: "matches",
|
||||
filter: `couple_id=eq.${user.couple_id}`,
|
||||
},
|
||||
async (payload) => {
|
||||
// Fetch the full match with question details
|
||||
const { data: matchWithQuestion } = await supabase
|
||||
.from("matches")
|
||||
.select(`
|
||||
*,
|
||||
question:questions(*)
|
||||
`)
|
||||
.eq("id", payload.new.id)
|
||||
.single();
|
||||
|
||||
if (matchWithQuestion) {
|
||||
// Add to store and show notification
|
||||
addMatch(matchWithQuestion);
|
||||
setMatchNotification(matchWithQuestion);
|
||||
}
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
}, [user?.couple_id, addMatch]);
|
||||
|
||||
// Subscribe to realtime message notifications
|
||||
useEffect(() => {
|
||||
if (!user?.couple_id) return;
|
||||
|
||||
const channel = supabase
|
||||
.channel(`messages:${user.couple_id}`)
|
||||
.on(
|
||||
"postgres_changes",
|
||||
{
|
||||
event: "INSERT",
|
||||
schema: "public",
|
||||
table: "messages",
|
||||
},
|
||||
async (payload) => {
|
||||
const newMessage = payload.new as Message;
|
||||
|
||||
// Only process messages from partner
|
||||
if (newMessage.user_id === user.id) return;
|
||||
|
||||
// Mark as delivered (message received by device)
|
||||
if (!newMessage.delivered_at) {
|
||||
await supabase
|
||||
.from("messages")
|
||||
.update({ delivered_at: new Date().toISOString() })
|
||||
.eq("id", newMessage.id);
|
||||
}
|
||||
|
||||
// Fetch the match details for context
|
||||
const { data: match } = await supabase
|
||||
.from("matches")
|
||||
.select("id, question:questions(text)")
|
||||
.eq("id", newMessage.match_id)
|
||||
.single();
|
||||
|
||||
if (match) {
|
||||
addMessage({
|
||||
...newMessage,
|
||||
delivered_at: newMessage.delivered_at || new Date().toISOString(),
|
||||
match: match as { id: string; question: { text: string } },
|
||||
});
|
||||
}
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
}, [user?.couple_id, user?.id, addMessage]);
|
||||
|
||||
// Animate message toast when lastMessage changes
|
||||
useEffect(() => {
|
||||
if (lastMessage) {
|
||||
// Clear any existing timeout
|
||||
if (messageTimeoutRef.current) {
|
||||
clearTimeout(messageTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Slide in
|
||||
Animated.spring(messageToastAnim, {
|
||||
toValue: 0,
|
||||
useNativeDriver: true,
|
||||
tension: 80,
|
||||
friction: 10,
|
||||
}).start();
|
||||
|
||||
// Auto-dismiss after 4 seconds
|
||||
messageTimeoutRef.current = setTimeout(() => {
|
||||
dismissMessageToast();
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (messageTimeoutRef.current) {
|
||||
clearTimeout(messageTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [lastMessage]);
|
||||
|
||||
const dismissNotification = useCallback(() => {
|
||||
setMatchNotification(null);
|
||||
}, []);
|
||||
|
||||
const dismissMessageToast = useCallback(() => {
|
||||
Animated.timing(messageToastAnim, {
|
||||
toValue: -100,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}).start(() => {
|
||||
clearLastMessage();
|
||||
});
|
||||
}, [messageToastAnim, clearLastMessage]);
|
||||
|
||||
const handleMessageToastPress = useCallback(() => {
|
||||
if (lastMessage?.match_id) {
|
||||
dismissMessageToast();
|
||||
router.push(`/(app)/chat/${lastMessage.match_id}`);
|
||||
}
|
||||
}, [lastMessage, dismissMessageToast, router]);
|
||||
|
||||
// Show loading state while checking authentication
|
||||
if (isLoading) {
|
||||
@@ -31,98 +228,164 @@ export default function AppLayout() {
|
||||
);
|
||||
}
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
// Show loading while checking auth or redirecting
|
||||
if (!isAuthenticated) {
|
||||
return <Redirect href="/(auth)/login" />;
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarBackground: () => <TabBarBackground />,
|
||||
tabBarStyle: {
|
||||
position: 'absolute',
|
||||
backgroundColor: 'transparent',
|
||||
borderTopColor: colors.glass.border,
|
||||
borderTopWidth: 1,
|
||||
paddingTop: 8,
|
||||
paddingBottom: Platform.OS === 'ios' ? 28 : 8,
|
||||
height: Platform.OS === 'ios' ? 88 : 64,
|
||||
elevation: 0,
|
||||
},
|
||||
tabBarActiveTintColor: colors.primary,
|
||||
tabBarInactiveTintColor: colors.textTertiary,
|
||||
tabBarLabelStyle: {
|
||||
fontSize: 11,
|
||||
fontWeight: "600",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: "Home",
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="home" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="packs"
|
||||
options={{
|
||||
title: "Packs",
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="layers" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="swipe"
|
||||
options={{
|
||||
title: "Play",
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="flame" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="matches"
|
||||
options={{
|
||||
title: "Matches",
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="heart" size={size} color={color} />
|
||||
),
|
||||
tabBarBadge: newMatchesCount > 0 ? newMatchesCount : undefined,
|
||||
tabBarBadgeStyle: {
|
||||
backgroundColor: colors.primary,
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
<>
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarBackground: () => <TabBarBackground />,
|
||||
tabBarStyle: {
|
||||
position: 'absolute',
|
||||
backgroundColor: 'transparent',
|
||||
borderTopColor: colors.glass.border,
|
||||
borderTopWidth: 1,
|
||||
paddingTop: 8,
|
||||
paddingBottom: Platform.OS === 'ios' ? 28 : 8,
|
||||
height: Platform.OS === 'ios' ? 88 : 64,
|
||||
elevation: 0,
|
||||
},
|
||||
tabBarActiveTintColor: colors.primary,
|
||||
tabBarInactiveTintColor: colors.textTertiary,
|
||||
tabBarLabelStyle: {
|
||||
fontSize: 11,
|
||||
fontWeight: "600",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="profile"
|
||||
options={{
|
||||
title: "Profile",
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="person" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="pairing"
|
||||
options={{
|
||||
href: null, // Hide from tab bar
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="chat/[id]"
|
||||
options={{
|
||||
href: null, // Hide from tab bar
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: "Home",
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="home" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="packs"
|
||||
options={{
|
||||
title: "Packs",
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="layers" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="swipe"
|
||||
options={{
|
||||
title: "Play",
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="flame" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="matches"
|
||||
options={{
|
||||
title: "Matches",
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="heart" size={size} color={color} />
|
||||
),
|
||||
tabBarBadge: (newMatchesCount + unreadCount) > 0 ? (newMatchesCount + unreadCount) : undefined,
|
||||
tabBarBadgeStyle: {
|
||||
backgroundColor: colors.primary,
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="profile"
|
||||
options={{
|
||||
title: "Profile",
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="person" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="pairing"
|
||||
options={{
|
||||
href: null, // Hide from tab bar
|
||||
tabBarStyle: { display: 'none' }, // Hide tab bar on pairing screen
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="chat/[id]"
|
||||
options={{
|
||||
href: null, // Hide from tab bar
|
||||
tabBarStyle: { display: 'none' }, // Hide tab bar on chat screen
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
{/* Match Notification Modal */}
|
||||
<Modal
|
||||
visible={!!matchNotification}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={dismissNotification}
|
||||
>
|
||||
<Pressable style={styles.modalOverlay} onPress={dismissNotification}>
|
||||
<View style={styles.modalContent}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Ionicons name="heart" size={48} color={colors.primary} />
|
||||
<Text style={styles.modalTitle}>It's a Match!</Text>
|
||||
</View>
|
||||
<Text style={styles.modalSubtitle}>You both agreed on:</Text>
|
||||
<Text style={styles.modalQuestion}>
|
||||
"{matchNotification?.question?.text}"
|
||||
</Text>
|
||||
<View style={styles.matchTypeBadge}>
|
||||
<Text style={styles.matchTypeText}>
|
||||
{matchNotification?.match_type === 'yes_yes' ? 'YES + YES' :
|
||||
matchNotification?.match_type === 'yes_maybe' ? 'YES + MAYBE' :
|
||||
'MAYBE + MAYBE'}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable style={styles.dismissButton} onPress={dismissNotification}>
|
||||
<Text style={styles.dismissButtonText}>Nice!</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
|
||||
{/* Message Toast Notification */}
|
||||
{lastMessage && (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.messageToast,
|
||||
{ transform: [{ translateY: messageToastAnim }] },
|
||||
]}
|
||||
>
|
||||
<Pressable style={styles.messageToastContent} onPress={handleMessageToastPress}>
|
||||
<View style={styles.messageToastIcon}>
|
||||
<Ionicons name="chatbubble" size={20} color={colors.primary} />
|
||||
</View>
|
||||
<View style={styles.messageToastText}>
|
||||
<Text style={styles.messageToastTitle} numberOfLines={1}>
|
||||
New message
|
||||
</Text>
|
||||
<Text style={styles.messageToastBody} numberOfLines={1}>
|
||||
{lastMessage.content || "Sent an image"}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable onPress={dismissMessageToast} hitSlop={8}>
|
||||
<Ionicons name="close" size={20} color={colors.textTertiary} />
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -133,4 +396,106 @@ const styles = StyleSheet.create({
|
||||
alignItems: "center",
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: spacing.lg,
|
||||
},
|
||||
modalContent: {
|
||||
backgroundColor: colors.backgroundLight,
|
||||
borderRadius: radius.xl,
|
||||
padding: spacing.xl,
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
maxWidth: 340,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
...shadows.lg,
|
||||
},
|
||||
modalHeader: {
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
modalTitle: {
|
||||
...typography.title1,
|
||||
color: colors.text,
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
modalSubtitle: {
|
||||
...typography.body,
|
||||
color: colors.textSecondary,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
modalQuestion: {
|
||||
...typography.headline,
|
||||
color: colors.text,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
matchTypeBadge: {
|
||||
backgroundColor: colors.primaryLight,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radius.full,
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
matchTypeText: {
|
||||
...typography.caption1,
|
||||
color: colors.primary,
|
||||
fontWeight: '600',
|
||||
},
|
||||
dismissButton: {
|
||||
backgroundColor: colors.primary,
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: radius.lg,
|
||||
minWidth: 120,
|
||||
},
|
||||
dismissButtonText: {
|
||||
...typography.headline,
|
||||
color: colors.text,
|
||||
textAlign: 'center',
|
||||
},
|
||||
messageToast: {
|
||||
position: 'absolute',
|
||||
top: Platform.OS === 'ios' ? 50 : 20,
|
||||
left: spacing.md,
|
||||
right: spacing.md,
|
||||
zIndex: 1000,
|
||||
},
|
||||
messageToastContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.backgroundLight,
|
||||
borderRadius: radius.lg,
|
||||
padding: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
...shadows.lg,
|
||||
},
|
||||
messageToastIcon: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: colors.primaryLight,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
messageToastText: {
|
||||
flex: 1,
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
messageToastTitle: {
|
||||
...typography.subhead,
|
||||
color: colors.text,
|
||||
fontWeight: '600',
|
||||
},
|
||||
messageToastBody: {
|
||||
...typography.caption1,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 2,
|
||||
},
|
||||
});
|
||||
+449
-131
@@ -1,18 +1,19 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { View, Text, StyleSheet, FlatList, TextInput, TouchableOpacity, KeyboardAvoidingView, Platform, Image, ActivityIndicator, Alert } from "react-native";
|
||||
import { useLocalSearchParams, useRouter, Stack } from "expo-router";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { View, Text, StyleSheet, FlatList, TextInput, TouchableOpacity, KeyboardAvoidingView, Platform, Image, ActivityIndicator, Alert, Modal, Dimensions } from "react-native";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import Animated, { FadeIn, FadeInUp } from "react-native-reanimated";
|
||||
import { supabase } from "../../../src/lib/supabase";
|
||||
import { useAuthStore } from "../../../src/store";
|
||||
import { useAuthStore, useMessageStore } from "../../../src/store";
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import { decode } from 'base64-arraybuffer';
|
||||
import { Database } from "../../../src/types/supabase";
|
||||
import { GradientBackground, GlassCard } from "../../../src/components/ui";
|
||||
import { colors, gradients, spacing, radius, typography, blur, shadows } from "../../../src/theme";
|
||||
import { GradientBackground } from "../../../src/components/ui";
|
||||
import { colors, gradients, spacing, radius, typography } from "../../../src/theme";
|
||||
|
||||
type Message = Database["public"]["Tables"]["messages"]["Row"];
|
||||
|
||||
@@ -20,14 +21,34 @@ export default function ChatScreen() {
|
||||
const { id } = useLocalSearchParams();
|
||||
const matchId = id as string;
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { user } = useAuthStore();
|
||||
const { setActiveMatchId, markMatchMessagesAsRead } = useMessageStore();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [match, setMatch] = useState<any>(null);
|
||||
const [inputText, setInputText] = useState("");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [partnerTyping, setPartnerTyping] = useState(false);
|
||||
const [fullScreenImage, setFullScreenImage] = useState<string | null>(null);
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isFocusedRef = useRef(false);
|
||||
|
||||
// Track active chat to prevent notifications for current chat
|
||||
// Use useFocusEffect because screens don't unmount on navigation
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
isFocusedRef.current = true;
|
||||
if (matchId) {
|
||||
setActiveMatchId(matchId);
|
||||
markMatchMessagesAsRead(matchId);
|
||||
}
|
||||
return () => {
|
||||
isFocusedRef.current = false;
|
||||
setActiveMatchId(null);
|
||||
};
|
||||
}, [matchId, setActiveMatchId, markMatchMessagesAsRead])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!matchId) return;
|
||||
@@ -61,18 +82,32 @@ export default function ChatScreen() {
|
||||
if (data) {
|
||||
setMessages(data);
|
||||
|
||||
const unreadIds = data
|
||||
.filter(m => m.user_id !== user?.id && !m.read_at)
|
||||
.map(m => m.id);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Get partner messages that need to be marked as delivered or read
|
||||
const partnerMessages = data.filter(m => m.user_id !== user?.id);
|
||||
const undeliveredIds = partnerMessages.filter(m => !m.delivered_at).map(m => m.id);
|
||||
const unreadIds = partnerMessages.filter(m => !m.read_at).map(m => m.id);
|
||||
|
||||
// Mark as both delivered and read (since user is viewing the chat)
|
||||
if (unreadIds.length > 0) {
|
||||
await supabase
|
||||
.from("messages")
|
||||
.update({ read_at: new Date().toISOString() })
|
||||
.update({ delivered_at: now, read_at: now })
|
||||
.in("id", unreadIds);
|
||||
|
||||
setMessages(prev => prev.map(m =>
|
||||
unreadIds.includes(m.id) ? { ...m, read_at: new Date().toISOString() } : m
|
||||
unreadIds.includes(m.id) ? { ...m, delivered_at: now, read_at: now } : m
|
||||
));
|
||||
} else if (undeliveredIds.length > 0) {
|
||||
// Only mark as delivered if already read but not delivered (edge case)
|
||||
await supabase
|
||||
.from("messages")
|
||||
.update({ delivered_at: now })
|
||||
.in("id", undeliveredIds);
|
||||
|
||||
setMessages(prev => prev.map(m =>
|
||||
undeliveredIds.includes(m.id) ? { ...m, delivered_at: now } : m
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -92,13 +127,16 @@ export default function ChatScreen() {
|
||||
},
|
||||
async (payload) => {
|
||||
const newMessage = payload.new as Message;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (newMessage.user_id !== user?.id) {
|
||||
if (newMessage.user_id !== user?.id && isFocusedRef.current) {
|
||||
// Mark as both delivered and read (only if screen is focused)
|
||||
await supabase
|
||||
.from("messages")
|
||||
.update({ read_at: new Date().toISOString() })
|
||||
.update({ delivered_at: now, read_at: now })
|
||||
.eq("id", newMessage.id);
|
||||
newMessage.read_at = new Date().toISOString();
|
||||
newMessage.delivered_at = now;
|
||||
newMessage.read_at = now;
|
||||
}
|
||||
|
||||
setMessages((prev) => [newMessage, ...prev]);
|
||||
@@ -220,14 +258,11 @@ export default function ChatScreen() {
|
||||
|
||||
if (uploadError) throw uploadError;
|
||||
|
||||
const { data: { publicUrl } } = supabase.storage
|
||||
.from("chat-media")
|
||||
.getPublicUrl(fileName);
|
||||
|
||||
// Store just the path, not the full URL (bucket is private)
|
||||
await supabase.from("messages").insert({
|
||||
match_id: matchId,
|
||||
user_id: user.id,
|
||||
media_path: publicUrl,
|
||||
media_path: fileName,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
@@ -263,60 +298,88 @@ export default function ChatScreen() {
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<MessageContent item={item} isMe={isMe} revealMessage={revealMessage} />
|
||||
<MessageContent item={item} isMe={isMe} revealMessage={revealMessage} onImagePress={setFullScreenImage} />
|
||||
</LinearGradient>
|
||||
) : (
|
||||
<View style={[styles.bubble, styles.theirBubble]}>
|
||||
<MessageContent item={item} isMe={isMe} revealMessage={revealMessage} />
|
||||
<MessageContent item={item} isMe={isMe} revealMessage={revealMessage} onImagePress={setFullScreenImage} />
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const isYesYes = match?.match_type === "yes_yes";
|
||||
|
||||
return (
|
||||
<GradientBackground>
|
||||
<Stack.Screen options={{
|
||||
title: "Chat",
|
||||
headerStyle: { backgroundColor: colors.backgroundLight },
|
||||
headerTintColor: colors.text,
|
||||
headerShadowVisible: false,
|
||||
}} />
|
||||
|
||||
<KeyboardAvoidingView
|
||||
style={styles.keyboardAvoiding}
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
keyboardVerticalOffset={Platform.OS === "ios" ? 90 : 0}
|
||||
keyboardVerticalOffset={0}
|
||||
>
|
||||
{/* Sticky top bar with question and answers */}
|
||||
{/* Custom Header */}
|
||||
<View style={[styles.header, { paddingTop: insets.top }]}>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
style={styles.backButton}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={24} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>Chat</Text>
|
||||
<View style={styles.headerSpacer} />
|
||||
</View>
|
||||
|
||||
{/* Question Header */}
|
||||
{match?.question && (
|
||||
<GlassCard style={styles.stickyTopBar} noPadding>
|
||||
<View style={styles.stickyContent}>
|
||||
<Text style={styles.stickyQuestionText} numberOfLines={2}>
|
||||
<View style={styles.questionHeader}>
|
||||
<LinearGradient
|
||||
colors={gradients.primary as [string, string]}
|
||||
style={styles.questionIconGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Ionicons
|
||||
name={isYesYes ? "heart" : "heart-half"}
|
||||
size={16}
|
||||
color={colors.text}
|
||||
/>
|
||||
</LinearGradient>
|
||||
<View style={styles.questionContent}>
|
||||
<Text style={styles.questionText} numberOfLines={2}>
|
||||
{match.question.text}
|
||||
{match.question.partner_text ? ` & ${match.question.partner_text}` : ""}
|
||||
{match.question.partner_text ? ` / ${match.question.partner_text}` : ""}
|
||||
</Text>
|
||||
{match.responses && match.responses.length > 0 && (
|
||||
<View style={styles.stickyAnswersContainer}>
|
||||
<View style={styles.answersRow}>
|
||||
{match.responses.map((response: any) => (
|
||||
<View key={response.user_id} style={styles.answerBadge}>
|
||||
<Text style={styles.stickyAnswerText}>
|
||||
{response.user_id === user?.id ? 'You' : (response.profiles?.name || 'Partner')}: {response.answer}
|
||||
<View key={response.user_id} style={styles.answerChip}>
|
||||
<Text style={styles.answerName}>
|
||||
{response.user_id === user?.id ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner')}
|
||||
</Text>
|
||||
<View style={[
|
||||
styles.answerDot,
|
||||
response.answer === 'yes' && styles.answerDotYes,
|
||||
response.answer === 'maybe' && styles.answerDotMaybe
|
||||
]} />
|
||||
<Text style={styles.answerText}>{response.answer}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</GlassCard>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Messages List */}
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={messages}
|
||||
renderItem={renderMessage}
|
||||
keyExtractor={(item) => item.id}
|
||||
inverted
|
||||
style={styles.messageList}
|
||||
contentContainerStyle={styles.listContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
@@ -326,57 +389,150 @@ export default function ChatScreen() {
|
||||
entering={FadeIn.duration(200)}
|
||||
style={styles.typingContainer}
|
||||
>
|
||||
<Text style={styles.typingText}>Partner is typing...</Text>
|
||||
<View style={styles.typingBubble}>
|
||||
<View style={styles.typingDots}>
|
||||
<View style={[styles.typingDot, styles.typingDot1]} />
|
||||
<View style={[styles.typingDot, styles.typingDot2]} />
|
||||
<View style={[styles.typingDot, styles.typingDot3]} />
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
) : null
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyChat}>
|
||||
<LinearGradient
|
||||
colors={gradients.primary as [string, string]}
|
||||
style={styles.emptyChatIcon}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Ionicons name="chatbubbles-outline" size={24} color={colors.text} />
|
||||
</LinearGradient>
|
||||
<Text style={styles.emptyChatText}>Start the conversation!</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Input bar with glass effect */}
|
||||
{Platform.OS === 'ios' ? (
|
||||
<BlurView intensity={blur.medium} tint="dark" style={styles.inputWrapper}>
|
||||
<InputBar
|
||||
inputText={inputText}
|
||||
uploading={uploading}
|
||||
onChangeText={handleTyping}
|
||||
onSend={handleSend}
|
||||
onPickImage={handlePickImage}
|
||||
/>
|
||||
</BlurView>
|
||||
) : (
|
||||
<View style={[styles.inputWrapper, styles.inputWrapperAndroid]}>
|
||||
<InputBar
|
||||
inputText={inputText}
|
||||
uploading={uploading}
|
||||
onChangeText={handleTyping}
|
||||
onSend={handleSend}
|
||||
onPickImage={handlePickImage}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{/* Input Bar */}
|
||||
<View style={[styles.inputWrapper, { paddingBottom: insets.bottom || spacing.sm }]}>
|
||||
<InputBar
|
||||
inputText={inputText}
|
||||
uploading={uploading}
|
||||
onChangeText={handleTyping}
|
||||
onSend={handleSend}
|
||||
onPickImage={handlePickImage}
|
||||
/>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
{/* Full Screen Image Modal */}
|
||||
<Modal
|
||||
visible={!!fullScreenImage}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setFullScreenImage(null)}
|
||||
>
|
||||
<View style={styles.fullScreenOverlay}>
|
||||
<TouchableOpacity
|
||||
style={styles.fullScreenCloseButton}
|
||||
onPress={() => setFullScreenImage(null)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons name="close" size={28} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
{fullScreenImage && (
|
||||
<Image
|
||||
source={{ uri: fullScreenImage }}
|
||||
style={styles.fullScreenImage}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</Modal>
|
||||
</GradientBackground>
|
||||
);
|
||||
}
|
||||
|
||||
// Extract storage path from media_path (handles both old full URLs and new path-only format)
|
||||
function getStoragePath(mediaPath: string): string {
|
||||
if (mediaPath.startsWith('http')) {
|
||||
// Old format: extract path from full URL
|
||||
const match = mediaPath.match(/\/chat-media\/(.+)$/);
|
||||
return match ? match[1] : mediaPath;
|
||||
}
|
||||
return mediaPath;
|
||||
}
|
||||
|
||||
function MessageContent({
|
||||
item,
|
||||
isMe,
|
||||
revealMessage,
|
||||
onImagePress,
|
||||
}: {
|
||||
item: Message;
|
||||
isMe: boolean;
|
||||
revealMessage: (id: string) => void;
|
||||
onImagePress: (uri: string) => void;
|
||||
}) {
|
||||
const [signedUrl, setSignedUrl] = useState<string | null>(null);
|
||||
const [urlError, setUrlError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!item.media_path) return;
|
||||
|
||||
const fetchSignedUrl = async () => {
|
||||
const storagePath = getStoragePath(item.media_path!);
|
||||
const { data, error } = await supabase.storage
|
||||
.from('chat-media')
|
||||
.createSignedUrl(storagePath, 3600); // 1 hour expiry
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to get signed URL:', error);
|
||||
setUrlError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data?.signedUrl) {
|
||||
setSignedUrl(data.signedUrl);
|
||||
} else {
|
||||
setUrlError(true);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSignedUrl();
|
||||
}, [item.media_path]);
|
||||
|
||||
if (item.media_path) {
|
||||
const isViewed = !!item.media_viewed_at;
|
||||
const canOpenFullScreen = isMe || isViewed;
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Image
|
||||
source={{ uri: item.media_path }}
|
||||
style={styles.messageImage}
|
||||
blurRadius={(!isMe && !item.media_viewed_at) ? 20 : 0}
|
||||
/>
|
||||
{(!isMe && !item.media_viewed_at) && (
|
||||
<TouchableOpacity
|
||||
activeOpacity={canOpenFullScreen ? 0.8 : 1}
|
||||
onPress={() => canOpenFullScreen && signedUrl && onImagePress(signedUrl)}
|
||||
disabled={!canOpenFullScreen || !signedUrl}
|
||||
>
|
||||
{signedUrl ? (
|
||||
<Image
|
||||
source={{ uri: signedUrl }}
|
||||
style={styles.messageImage}
|
||||
blurRadius={(!isMe && !isViewed) ? 20 : 0}
|
||||
/>
|
||||
) : urlError ? (
|
||||
<View style={[styles.messageImage, { justifyContent: 'center', alignItems: 'center', backgroundColor: colors.surface }]}>
|
||||
<Ionicons name="image-outline" size={32} color={colors.textSecondary} />
|
||||
<Text style={{ color: colors.textSecondary, fontSize: 12, marginTop: 4 }}>Image unavailable</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={[styles.messageImage, { justifyContent: 'center', alignItems: 'center' }]}>
|
||||
<ActivityIndicator color={colors.primary} />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
{/* Reveal overlay for recipient */}
|
||||
{(!isMe && !isViewed) && (
|
||||
<TouchableOpacity
|
||||
style={styles.revealOverlay}
|
||||
activeOpacity={0.8}
|
||||
@@ -388,6 +544,13 @@ function MessageContent({
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{/* Viewed indicator for sender */}
|
||||
{isMe && isViewed && (
|
||||
<View style={styles.viewedBadge}>
|
||||
<Ionicons name="eye" size={12} color={colors.success} />
|
||||
<Text style={styles.viewedText}>Viewed</Text>
|
||||
</View>
|
||||
)}
|
||||
<MessageMeta item={item} isMe={isMe} />
|
||||
</View>
|
||||
);
|
||||
@@ -402,18 +565,35 @@ function MessageContent({
|
||||
}
|
||||
|
||||
function MessageMeta({ item, isMe }: { item: Message; isMe: boolean }) {
|
||||
// 3-state read receipts:
|
||||
// - Single tick (grey) = sent to server
|
||||
// - Double tick (grey) = delivered to partner's device
|
||||
// - Double tick (blue) = read by partner
|
||||
const isDelivered = !!item.delivered_at;
|
||||
const isRead = !!item.read_at;
|
||||
|
||||
const getStatusIcon = () => {
|
||||
if (isRead) {
|
||||
// Double tick blue - read
|
||||
return <Ionicons name="checkmark-done" size={16} color="#34B7F1" />;
|
||||
} else if (isDelivered) {
|
||||
// Double tick grey - delivered but not read
|
||||
return <Ionicons name="checkmark-done" size={16} color="rgba(255,255,255,0.5)" />;
|
||||
} else {
|
||||
// Single tick grey - sent but not delivered
|
||||
return <Ionicons name="checkmark" size={16} color="rgba(255,255,255,0.5)" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.metaContainer}>
|
||||
<Text style={styles.timestamp}>
|
||||
{new Date(item.created_at!).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</Text>
|
||||
{isMe && (
|
||||
<Ionicons
|
||||
name={item.read_at ? "checkmark-done" : "checkmark"}
|
||||
size={14}
|
||||
color={item.read_at ? colors.success : "rgba(255,255,255,0.6)"}
|
||||
style={styles.readIcon}
|
||||
/>
|
||||
<View style={styles.readStatus}>
|
||||
{getStatusIcon()}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
@@ -438,7 +618,7 @@ function InputBar({
|
||||
{uploading ? (
|
||||
<ActivityIndicator color={colors.primary} size="small" />
|
||||
) : (
|
||||
<Ionicons name="image-outline" size={24} color={colors.primary} />
|
||||
<Ionicons name="image-outline" size={22} color={colors.textSecondary} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -456,13 +636,20 @@ function InputBar({
|
||||
<TouchableOpacity
|
||||
onPress={onSend}
|
||||
disabled={!inputText.trim()}
|
||||
style={[styles.sendButton, inputText.trim() && styles.sendButtonActive]}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons
|
||||
name="send"
|
||||
size={20}
|
||||
color={inputText.trim() ? colors.text : colors.textTertiary}
|
||||
/>
|
||||
<LinearGradient
|
||||
colors={inputText.trim() ? gradients.primary as [string, string] : [colors.glass.background, colors.glass.background]}
|
||||
style={styles.sendButton}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Ionicons
|
||||
name="send"
|
||||
size={18}
|
||||
color={inputText.trim() ? colors.text : colors.textTertiary}
|
||||
/>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
@@ -472,9 +659,94 @@ const styles = StyleSheet.create({
|
||||
keyboardAvoiding: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingBottom: spacing.sm,
|
||||
backgroundColor: colors.backgroundLight,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.glass.border,
|
||||
},
|
||||
backButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
headerTitle: {
|
||||
...typography.headline,
|
||||
color: colors.text,
|
||||
flex: 1,
|
||||
textAlign: 'center',
|
||||
},
|
||||
headerSpacer: {
|
||||
width: 40,
|
||||
},
|
||||
questionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: colors.glass.backgroundLight,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.glass.border,
|
||||
},
|
||||
messageList: {
|
||||
flex: 1,
|
||||
},
|
||||
questionIconGradient: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
questionContent: {
|
||||
flex: 1,
|
||||
},
|
||||
questionText: {
|
||||
...typography.subhead,
|
||||
color: colors.text,
|
||||
fontWeight: '500',
|
||||
lineHeight: 20,
|
||||
},
|
||||
answersRow: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.md,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
answerChip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
},
|
||||
answerName: {
|
||||
...typography.caption2,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
answerDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: colors.textTertiary,
|
||||
},
|
||||
answerDotYes: {
|
||||
backgroundColor: colors.success,
|
||||
},
|
||||
answerDotMaybe: {
|
||||
backgroundColor: colors.warning,
|
||||
},
|
||||
answerText: {
|
||||
...typography.caption2,
|
||||
color: colors.textSecondary,
|
||||
textTransform: 'capitalize',
|
||||
},
|
||||
listContent: {
|
||||
flexGrow: 1,
|
||||
padding: spacing.md,
|
||||
paddingBottom: spacing.lg,
|
||||
},
|
||||
messageRow: {
|
||||
marginBottom: spacing.sm,
|
||||
@@ -489,8 +761,7 @@ const styles = StyleSheet.create({
|
||||
bubble: {
|
||||
maxWidth: "78%",
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.xl,
|
||||
...shadows.sm,
|
||||
borderRadius: radius.lg,
|
||||
},
|
||||
myBubble: {
|
||||
borderBottomRightRadius: radius.xs,
|
||||
@@ -504,6 +775,7 @@ const styles = StyleSheet.create({
|
||||
messageText: {
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
lineHeight: 22,
|
||||
},
|
||||
messageImage: {
|
||||
width: 200,
|
||||
@@ -519,35 +791,70 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
timestamp: {
|
||||
...typography.caption2,
|
||||
color: "rgba(255, 255, 255, 0.6)",
|
||||
color: "rgba(255, 255, 255, 0.5)",
|
||||
},
|
||||
readIcon: {
|
||||
readStatus: {
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
viewedBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
position: 'absolute',
|
||||
top: spacing.sm,
|
||||
left: spacing.sm,
|
||||
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 3,
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
viewedText: {
|
||||
...typography.caption2,
|
||||
color: colors.success,
|
||||
fontWeight: '600',
|
||||
},
|
||||
emptyChat: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.xxl,
|
||||
},
|
||||
emptyChatIcon: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
emptyChatText: {
|
||||
...typography.subhead,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
inputWrapper: {
|
||||
backgroundColor: colors.backgroundLight,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.glass.border,
|
||||
},
|
||||
inputWrapperAndroid: {
|
||||
backgroundColor: colors.glass.backgroundLight,
|
||||
},
|
||||
inputContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-end",
|
||||
padding: spacing.sm,
|
||||
paddingBottom: Platform.OS === "ios" ? spacing.xl : spacing.sm,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
attachButton: {
|
||||
padding: spacing.sm,
|
||||
marginBottom: spacing.xs,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: colors.glass.background,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
inputFieldWrapper: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.glass.background,
|
||||
borderRadius: radius.xl,
|
||||
borderRadius: radius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
marginHorizontal: spacing.sm,
|
||||
},
|
||||
input: {
|
||||
paddingHorizontal: spacing.md,
|
||||
@@ -560,28 +867,47 @@ const styles = StyleSheet.create({
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: colors.glass.background,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
sendButtonActive: {
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
typingContainer: {
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
typingBubble: {
|
||||
backgroundColor: colors.glass.backgroundLight,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
borderRadius: radius.lg,
|
||||
borderBottomLeftRadius: radius.xs,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
typingText: {
|
||||
...typography.caption1,
|
||||
color: colors.textTertiary,
|
||||
fontStyle: "italic",
|
||||
typingDots: {
|
||||
flexDirection: 'row',
|
||||
gap: 4,
|
||||
},
|
||||
typingDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: colors.textTertiary,
|
||||
},
|
||||
typingDot1: {
|
||||
opacity: 0.4,
|
||||
},
|
||||
typingDot2: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
typingDot3: {
|
||||
opacity: 0.8,
|
||||
},
|
||||
revealOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: "rgba(0,0,0,0.4)",
|
||||
backgroundColor: "rgba(0,0,0,0.5)",
|
||||
borderRadius: radius.md,
|
||||
},
|
||||
revealContent: {
|
||||
@@ -596,34 +922,26 @@ const styles = StyleSheet.create({
|
||||
fontWeight: "600",
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
stickyTopBar: {
|
||||
marginHorizontal: spacing.md,
|
||||
marginTop: spacing.sm,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
stickyContent: {
|
||||
padding: spacing.md,
|
||||
},
|
||||
stickyQuestionText: {
|
||||
...typography.subhead,
|
||||
color: colors.text,
|
||||
fontWeight: "600",
|
||||
textAlign: "center",
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
stickyAnswersContainer: {
|
||||
flexDirection: "row",
|
||||
fullScreenOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.95)",
|
||||
justifyContent: "center",
|
||||
gap: spacing.sm,
|
||||
alignItems: "center",
|
||||
},
|
||||
answerBadge: {
|
||||
backgroundColor: colors.primaryLight,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radius.sm,
|
||||
fullScreenCloseButton: {
|
||||
position: "absolute",
|
||||
top: 50,
|
||||
right: 20,
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.15)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
zIndex: 10,
|
||||
},
|
||||
stickyAnswerText: {
|
||||
...typography.caption1,
|
||||
color: colors.text,
|
||||
fullScreenImage: {
|
||||
width: Dimensions.get("window").width,
|
||||
height: Dimensions.get("window").height * 0.8,
|
||||
},
|
||||
});
|
||||
+380
-150
@@ -1,17 +1,20 @@
|
||||
import { useEffect } from "react";
|
||||
import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Platform } from "react-native";
|
||||
import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Platform, useWindowDimensions } from "react-native";
|
||||
import Animated, { FadeInDown, FadeInRight } from "react-native-reanimated";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { useAuthStore, useMatchStore, usePacksStore } from "../../src/store";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { router } from "expo-router";
|
||||
import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui";
|
||||
import { GradientBackground, GlassCard } from "../../src/components/ui";
|
||||
import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme";
|
||||
|
||||
const MAX_CONTENT_WIDTH = 500;
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { user, partner } = useAuthStore();
|
||||
const { matches, newMatchesCount, fetchMatches } = useMatchStore();
|
||||
const { packs, fetchPacks } = usePacksStore();
|
||||
const { width } = useWindowDimensions();
|
||||
|
||||
useEffect(() => {
|
||||
fetchMatches();
|
||||
@@ -19,131 +22,266 @@ export default function HomeScreen() {
|
||||
}, []);
|
||||
|
||||
const recentMatches = matches.slice(0, 3);
|
||||
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
||||
const enabledPacksCount = packs.length;
|
||||
|
||||
return (
|
||||
<GradientBackground>
|
||||
<ScrollView
|
||||
style={styles.container}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
contentContainerStyle={[
|
||||
styles.contentContainer,
|
||||
isWideScreen && styles.contentContainerWide,
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Header */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(100).duration(500)}
|
||||
style={styles.header}
|
||||
>
|
||||
<View>
|
||||
<Text style={styles.greeting}>
|
||||
Hey, {user?.name || "there"}
|
||||
</Text>
|
||||
{partner && (
|
||||
<Text style={styles.partnerText}>
|
||||
Paired with {partner.name || partner.email || 'your partner'}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={styles.profileButton}
|
||||
onPress={() => router.push("/(app)/profile")}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.profileButtonText}>
|
||||
{user?.name?.[0]?.toUpperCase() || "U"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(200).duration(500)}
|
||||
style={styles.statsContainer}
|
||||
>
|
||||
<GlassCard style={styles.statCard}>
|
||||
<View style={styles.statIconContainer}>
|
||||
<Ionicons name="heart" size={20} color={colors.primary} />
|
||||
</View>
|
||||
<Text style={styles.statNumber}>{matches.length}</Text>
|
||||
<Text style={styles.statLabel}>Matches</Text>
|
||||
</GlassCard>
|
||||
<GlassCard style={styles.statCard}>
|
||||
<View style={styles.statIconContainer}>
|
||||
<Ionicons name="layers" size={20} color={colors.primary} />
|
||||
</View>
|
||||
<Text style={styles.statNumber}>{packs.length}</Text>
|
||||
<Text style={styles.statLabel}>Packs</Text>
|
||||
</GlassCard>
|
||||
<GlassCard style={styles.statCard}>
|
||||
<View style={[styles.statIconContainer, newMatchesCount > 0 && styles.statIconHighlight]}>
|
||||
<Ionicons name="sparkles" size={20} color={newMatchesCount > 0 ? colors.text : colors.primary} />
|
||||
</View>
|
||||
<Text style={styles.statNumber}>{newMatchesCount}</Text>
|
||||
<Text style={styles.statLabel}>New</Text>
|
||||
</GlassCard>
|
||||
</Animated.View>
|
||||
|
||||
{/* Start Playing CTA */}
|
||||
<Animated.View entering={FadeInDown.delay(300).duration(500)}>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push("/(app)/swipe")}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={gradients.primary as [string, string]}
|
||||
style={styles.ctaCard}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<View style={styles.ctaContent}>
|
||||
<Text style={styles.ctaTitle}>Ready to explore?</Text>
|
||||
<Text style={styles.ctaSubtitle}>
|
||||
Swipe through questions and discover what you both enjoy
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.ctaIconContainer}>
|
||||
<Ionicons name="arrow-forward" size={24} color={colors.text} />
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
||||
{/* Recent Matches */}
|
||||
{recentMatches.length > 0 && (
|
||||
<View style={[styles.innerContainer, isWideScreen && styles.innerContainerWide]}>
|
||||
{/* Header */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(400).duration(500)}
|
||||
style={styles.section}
|
||||
entering={FadeInDown.delay(100).duration(500)}
|
||||
style={styles.header}
|
||||
>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>Recent Matches</Text>
|
||||
<TouchableOpacity onPress={() => router.push("/(app)/matches")}>
|
||||
<Text style={styles.seeAll}>See all</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{recentMatches.map((match, index) => (
|
||||
<Animated.View
|
||||
key={match.id}
|
||||
entering={FadeInRight.delay(450 + index * 100).duration(400)}
|
||||
>
|
||||
<View style={styles.headerLeft}>
|
||||
<Text style={styles.greeting}>
|
||||
Hey, {user?.name || "there"}
|
||||
</Text>
|
||||
{partner ? (
|
||||
<View style={styles.partnerBadge}>
|
||||
<Ionicons name="heart" size={12} color={colors.primary} />
|
||||
<Text style={styles.partnerText}>
|
||||
{partner.name || partner.email?.split('@')[0] || 'Partner'}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push(`/chat/${match.id}`)}
|
||||
activeOpacity={0.7}
|
||||
style={styles.pairBadge}
|
||||
onPress={() => router.push("/(app)/pairing")}
|
||||
>
|
||||
<GlassCard style={styles.matchItem}>
|
||||
<View style={styles.matchIcon}>
|
||||
<Ionicons name="heart" size={18} color={colors.primary} />
|
||||
</View>
|
||||
<Text style={styles.matchText} numberOfLines={1}>
|
||||
{(match as any).question?.text || "A new match!"}
|
||||
</Text>
|
||||
{match.is_new && <View style={styles.newBadge} />}
|
||||
</GlassCard>
|
||||
<Ionicons name="add-circle-outline" size={12} color={colors.textTertiary} />
|
||||
<Text style={styles.pairText}>Pair with partner</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
))}
|
||||
)}
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push("/(app)/profile")}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={gradients.primary as [string, string]}
|
||||
style={styles.avatarGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<View style={styles.avatarInner}>
|
||||
<Text style={styles.avatarText}>
|
||||
{user?.name?.[0]?.toUpperCase() || "U"}
|
||||
</Text>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Bottom spacing for tab bar */}
|
||||
<View style={styles.bottomSpacer} />
|
||||
{/* Stats Row */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(200).duration(500)}
|
||||
style={styles.statsContainer}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.statCardWrapper}
|
||||
onPress={() => router.push("/(app)/matches")}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<GlassCard>
|
||||
<View style={styles.statContent}>
|
||||
<LinearGradient
|
||||
colors={matches.length > 0 ? gradients.primary as [string, string] : [colors.glass.background, colors.glass.background]}
|
||||
style={styles.statIcon}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Ionicons
|
||||
name="heart"
|
||||
size={18}
|
||||
color={matches.length > 0 ? colors.text : colors.primary}
|
||||
/>
|
||||
</LinearGradient>
|
||||
<Text style={styles.statNumber}>{matches.length}</Text>
|
||||
<Text style={styles.statLabel}>Matches</Text>
|
||||
</View>
|
||||
</GlassCard>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.statCardWrapper}
|
||||
onPress={() => router.push("/(app)/packs")}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<GlassCard>
|
||||
<View style={styles.statContent}>
|
||||
<LinearGradient
|
||||
colors={enabledPacksCount > 0 ? gradients.primary as [string, string] : [colors.glass.background, colors.glass.background]}
|
||||
style={styles.statIcon}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Ionicons
|
||||
name="layers"
|
||||
size={18}
|
||||
color={enabledPacksCount > 0 ? colors.text : colors.primary}
|
||||
/>
|
||||
</LinearGradient>
|
||||
<Text style={styles.statNumber}>{enabledPacksCount}</Text>
|
||||
<Text style={styles.statLabel}>Packs</Text>
|
||||
</View>
|
||||
</GlassCard>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.statCardWrapper}
|
||||
onPress={() => router.push("/(app)/matches")}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<GlassCard>
|
||||
<View style={styles.statContent}>
|
||||
<LinearGradient
|
||||
colors={newMatchesCount > 0 ? gradients.primary as [string, string] : [colors.glass.background, colors.glass.background]}
|
||||
style={styles.statIcon}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Ionicons
|
||||
name="sparkles"
|
||||
size={18}
|
||||
color={newMatchesCount > 0 ? colors.text : colors.primary}
|
||||
/>
|
||||
</LinearGradient>
|
||||
<Text style={styles.statNumber}>{newMatchesCount}</Text>
|
||||
<Text style={styles.statLabel}>New</Text>
|
||||
</View>
|
||||
</GlassCard>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
||||
{/* Play CTA */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(300).duration(500)}
|
||||
style={styles.ctaSection}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push("/(app)/swipe")}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={gradients.primary as [string, string]}
|
||||
style={styles.ctaCard}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<View style={styles.ctaContent}>
|
||||
<Text style={styles.ctaTitle}>Ready to explore?</Text>
|
||||
<Text style={styles.ctaSubtitle}>
|
||||
Swipe through questions and discover what you both enjoy
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.ctaIconContainer}>
|
||||
<Ionicons name="play" size={24} color={colors.text} />
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
||||
{/* Recent Matches */}
|
||||
{recentMatches.length > 0 && (
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(400).duration(500)}
|
||||
style={styles.section}
|
||||
>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>Recent Matches</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push("/(app)/matches")}
|
||||
style={styles.seeAllButton}
|
||||
>
|
||||
<Text style={styles.seeAllText}>See all</Text>
|
||||
<Ionicons name="chevron-forward" size={14} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{recentMatches.map((match, index) => (
|
||||
<Animated.View
|
||||
key={match.id}
|
||||
entering={FadeInRight.delay(450 + index * 100).duration(400)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push(`/chat/${match.id}`)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<GlassCard style={styles.matchCard}>
|
||||
<View style={styles.matchRow}>
|
||||
<LinearGradient
|
||||
colors={gradients.primary as [string, string]}
|
||||
style={styles.matchIcon}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Ionicons name="heart" size={16} color={colors.text} />
|
||||
</LinearGradient>
|
||||
<Text style={styles.matchText} numberOfLines={1}>
|
||||
{(match as any).question?.text || "A new match!"}
|
||||
</Text>
|
||||
{match.is_new && <View style={styles.newBadge} />}
|
||||
</View>
|
||||
</GlassCard>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
))}
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Tips when no matches */}
|
||||
{recentMatches.length === 0 && (
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(400).duration(500)}
|
||||
style={styles.section}
|
||||
>
|
||||
<Text style={styles.tipsTitle}>Getting Started</Text>
|
||||
<GlassCard>
|
||||
<View style={styles.tipRow}>
|
||||
<View style={styles.tipIcon}>
|
||||
<Ionicons name="layers-outline" size={18} color={colors.primary} />
|
||||
</View>
|
||||
<View style={styles.tipContent}>
|
||||
<Text style={styles.tipText}>Enable question packs</Text>
|
||||
<Text style={styles.tipSubtext}>Choose topics you want to explore</Text>
|
||||
</View>
|
||||
</View>
|
||||
</GlassCard>
|
||||
<GlassCard style={styles.tipCard}>
|
||||
<View style={styles.tipRow}>
|
||||
<View style={styles.tipIcon}>
|
||||
<Ionicons name="swap-horizontal-outline" size={18} color={colors.primary} />
|
||||
</View>
|
||||
<View style={styles.tipContent}>
|
||||
<Text style={styles.tipText}>Swipe on questions</Text>
|
||||
<Text style={styles.tipSubtext}>Answer yes, no, or maybe</Text>
|
||||
</View>
|
||||
</View>
|
||||
</GlassCard>
|
||||
<GlassCard style={styles.tipCard}>
|
||||
<View style={styles.tipRow}>
|
||||
<View style={styles.tipIcon}>
|
||||
<Ionicons name="heart-outline" size={18} color={colors.primary} />
|
||||
</View>
|
||||
<View style={styles.tipContent}>
|
||||
<Text style={styles.tipText}>Discover matches</Text>
|
||||
<Text style={styles.tipSubtext}>See where you both agree</Text>
|
||||
</View>
|
||||
</View>
|
||||
</GlassCard>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
<View style={styles.bottomSpacer} />
|
||||
</View>
|
||||
</ScrollView>
|
||||
</GradientBackground>
|
||||
);
|
||||
@@ -154,8 +292,19 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
},
|
||||
contentContainer: {
|
||||
flexGrow: 1,
|
||||
paddingBottom: Platform.OS === 'ios' ? 100 : 80,
|
||||
},
|
||||
contentContainerWide: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
innerContainer: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
},
|
||||
innerContainerWide: {
|
||||
maxWidth: MAX_CONTENT_WIDTH,
|
||||
},
|
||||
header: {
|
||||
paddingTop: 60,
|
||||
paddingHorizontal: spacing.lg,
|
||||
@@ -164,25 +313,61 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
headerLeft: {
|
||||
flex: 1,
|
||||
},
|
||||
greeting: {
|
||||
...typography.title1,
|
||||
color: colors.text,
|
||||
},
|
||||
partnerText: {
|
||||
...typography.subhead,
|
||||
color: colors.textSecondary,
|
||||
marginTop: spacing.xs,
|
||||
partnerBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.primaryLight,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radius.full,
|
||||
alignSelf: 'flex-start',
|
||||
marginTop: spacing.sm,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
profileButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: colors.primary,
|
||||
partnerText: {
|
||||
...typography.caption1,
|
||||
color: colors.text,
|
||||
fontWeight: '500',
|
||||
},
|
||||
pairBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.glass.background,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radius.full,
|
||||
alignSelf: 'flex-start',
|
||||
marginTop: spacing.sm,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
pairText: {
|
||||
...typography.caption1,
|
||||
color: colors.textTertiary,
|
||||
},
|
||||
avatarGradient: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
...shadows.md,
|
||||
},
|
||||
profileButtonText: {
|
||||
avatarInner: {
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: 21,
|
||||
backgroundColor: colors.background,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
avatarText: {
|
||||
...typography.headline,
|
||||
color: colors.text,
|
||||
},
|
||||
@@ -191,34 +376,35 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.lg,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
statCard: {
|
||||
statCardWrapper: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
statIconContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: colors.primaryLight,
|
||||
statContent: {
|
||||
alignItems: "center",
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
statIcon: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
statIconHighlight: {
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
statNumber: {
|
||||
...typography.title2,
|
||||
color: colors.text,
|
||||
},
|
||||
statLabel: {
|
||||
...typography.caption1,
|
||||
...typography.caption2,
|
||||
color: colors.textSecondary,
|
||||
marginTop: spacing.xs,
|
||||
marginTop: 2,
|
||||
},
|
||||
ctaSection: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
marginTop: spacing.lg,
|
||||
},
|
||||
ctaCard: {
|
||||
margin: spacing.lg,
|
||||
borderRadius: radius.xl,
|
||||
padding: spacing.lg,
|
||||
flexDirection: "row",
|
||||
@@ -231,16 +417,17 @@ const styles = StyleSheet.create({
|
||||
ctaTitle: {
|
||||
...typography.title3,
|
||||
color: colors.text,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
ctaSubtitle: {
|
||||
...typography.subhead,
|
||||
color: "rgba(255,255,255,0.8)",
|
||||
marginTop: spacing.xs,
|
||||
lineHeight: 20,
|
||||
},
|
||||
ctaIconContainer: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: "rgba(255,255,255,0.2)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
@@ -248,7 +435,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
section: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
marginTop: spacing.sm,
|
||||
marginTop: spacing.xl,
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: "row",
|
||||
@@ -260,20 +447,26 @@ const styles = StyleSheet.create({
|
||||
...typography.headline,
|
||||
color: colors.text,
|
||||
},
|
||||
seeAll: {
|
||||
seeAllButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
},
|
||||
seeAllText: {
|
||||
...typography.subhead,
|
||||
color: colors.primary,
|
||||
},
|
||||
matchItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
matchCard: {
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
matchRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
matchIcon: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: colors.primaryLight,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginRight: spacing.md,
|
||||
@@ -289,6 +482,43 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 4,
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
tipsTitle: {
|
||||
...typography.caption1,
|
||||
color: colors.textTertiary,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1.5,
|
||||
marginBottom: spacing.sm,
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
tipCard: {
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
tipRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
tipIcon: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: colors.primaryLight,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
tipContent: {
|
||||
flex: 1,
|
||||
},
|
||||
tipText: {
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
fontWeight: '500',
|
||||
},
|
||||
tipSubtext: {
|
||||
...typography.caption1,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 2,
|
||||
},
|
||||
bottomSpacer: {
|
||||
height: spacing.lg,
|
||||
},
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
import { View, Text, StyleSheet, FlatList, RefreshControl, TouchableOpacity, ActivityIndicator, Platform } from "react-native";
|
||||
import { View, Text, StyleSheet, FlatList, RefreshControl, TouchableOpacity, ActivityIndicator, Platform, useWindowDimensions } from "react-native";
|
||||
import { useMatchStore, useAuthStore } from "../../src/store";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useCallback } from "react";
|
||||
import { useFocusEffect } from "expo-router";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useRouter } from "expo-router";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import Animated, { FadeInDown, FadeInRight } from "react-native-reanimated";
|
||||
import { GradientBackground, GlassCard } from "../../src/components/ui";
|
||||
import { colors, spacing, typography, radius } from "../../src/theme";
|
||||
import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme";
|
||||
|
||||
const MAX_CONTENT_WIDTH = 500;
|
||||
|
||||
export default function MatchesScreen() {
|
||||
const { matches, fetchMatches, markAllAsSeen } = useMatchStore();
|
||||
const { user } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const { width } = useWindowDimensions();
|
||||
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
||||
|
||||
useEffect(() => {
|
||||
fetchMatches();
|
||||
}, []);
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
fetchMatches();
|
||||
}, [])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (matches.length > 0) {
|
||||
@@ -45,38 +53,52 @@ export default function MatchesScreen() {
|
||||
<Animated.View entering={FadeInRight.delay(index * 50).duration(300)}>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push(`/chat/${item.id}`)}
|
||||
activeOpacity={0.7}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<GlassCard style={styles.matchCard}>
|
||||
<View style={styles.iconContainer}>
|
||||
<Ionicons
|
||||
name={isYesYes ? "heart" : "heart-half"}
|
||||
size={24}
|
||||
color={colors.primary}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.questionText} numberOfLines={2}>
|
||||
{userText}
|
||||
</Text>
|
||||
{item.question.partner_text && (
|
||||
<Text style={styles.partnerText} numberOfLines={1}>
|
||||
Partner: {partnerText}
|
||||
<View style={styles.matchRow}>
|
||||
<LinearGradient
|
||||
colors={isYesYes ? gradients.primary as [string, string] : [colors.glass.background, colors.glass.background]}
|
||||
style={styles.iconContainer}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Ionicons
|
||||
name={isYesYes ? "heart" : "heart-half"}
|
||||
size={22}
|
||||
color={isYesYes ? colors.text : colors.primary}
|
||||
/>
|
||||
</LinearGradient>
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.questionText} numberOfLines={2}>
|
||||
{userText}
|
||||
</Text>
|
||||
)}
|
||||
<View style={styles.metaContainer}>
|
||||
<View style={[styles.tag, isYesYes && styles.tagHighlight]}>
|
||||
<Text style={[styles.tagText, isYesYes && styles.tagTextHighlight]}>
|
||||
{isYesYes ? "YES + YES" : "YES + MAYBE"}
|
||||
{item.question.partner_text && (
|
||||
<Text style={styles.partnerText} numberOfLines={1}>
|
||||
Partner: {partnerText}
|
||||
</Text>
|
||||
)}
|
||||
<View style={styles.metaRow}>
|
||||
<View style={[styles.tag, isYesYes && styles.tagHighlight]}>
|
||||
<Text style={[styles.tagText, isYesYes && styles.tagTextHighlight]}>
|
||||
{isYesYes ? "YES + YES" : "YES + MAYBE"}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.date}>
|
||||
{new Date(item.created_at).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.date}>
|
||||
{new Date(item.created_at).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.chevronContainer}>
|
||||
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
|
||||
<View style={styles.rightSection}>
|
||||
{item.unreadCount > 0 && (
|
||||
<View style={styles.unreadBadge}>
|
||||
<Ionicons name="chatbubble" size={12} color={colors.text} />
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.chevronContainer}>
|
||||
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</GlassCard>
|
||||
</TouchableOpacity>
|
||||
@@ -99,11 +121,17 @@ export default function MatchesScreen() {
|
||||
<View style={styles.container}>
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(400)}
|
||||
style={styles.header}
|
||||
style={[styles.header, isWideScreen && styles.headerWide]}
|
||||
>
|
||||
<Text style={styles.title}>Your Matches</Text>
|
||||
<View style={styles.headerTop}>
|
||||
<Text style={styles.title}>Matches</Text>
|
||||
<View style={styles.countBadge}>
|
||||
<Ionicons name="heart" size={14} color={colors.primary} />
|
||||
<Text style={styles.countText}>{matches.length}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.subtitle}>
|
||||
{matches.length} {matches.length === 1 ? 'match' : 'matches'} found
|
||||
Discover what you both enjoy together
|
||||
</Text>
|
||||
</Animated.View>
|
||||
|
||||
@@ -111,7 +139,7 @@ export default function MatchesScreen() {
|
||||
data={matches}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.list}
|
||||
contentContainerStyle={[styles.list, isWideScreen && styles.listWide]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
@@ -126,13 +154,35 @@ export default function MatchesScreen() {
|
||||
entering={FadeInDown.delay(200).duration(400)}
|
||||
style={styles.emptyContainer}
|
||||
>
|
||||
<View style={styles.emptyIconContainer}>
|
||||
<Ionicons name="heart-outline" size={48} color={colors.textTertiary} />
|
||||
</View>
|
||||
<LinearGradient
|
||||
colors={gradients.primary as [string, string]}
|
||||
style={styles.emptyIconGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<View style={styles.emptyIconInner}>
|
||||
<Ionicons name="heart-outline" size={40} color={colors.primary} />
|
||||
</View>
|
||||
</LinearGradient>
|
||||
<Text style={styles.emptyTitle}>No matches yet</Text>
|
||||
<Text style={styles.emptyText}>
|
||||
Keep swiping to find things you both enjoy!
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.emptyButton}
|
||||
onPress={() => router.push("/(app)/swipe")}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={gradients.primary as [string, string]}
|
||||
style={styles.emptyButtonGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Ionicons name="play" size={18} color={colors.text} />
|
||||
<Text style={styles.emptyButtonText}>Start Swiping</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
}
|
||||
/>
|
||||
@@ -153,12 +203,36 @@ const styles = StyleSheet.create({
|
||||
header: {
|
||||
paddingTop: 60,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.md,
|
||||
paddingBottom: spacing.lg,
|
||||
},
|
||||
headerWide: {
|
||||
alignSelf: 'center',
|
||||
width: '100%',
|
||||
maxWidth: MAX_CONTENT_WIDTH,
|
||||
},
|
||||
headerTop: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
title: {
|
||||
...typography.title1,
|
||||
color: colors.text,
|
||||
},
|
||||
countBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.primaryLight,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radius.full,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
countText: {
|
||||
...typography.subhead,
|
||||
color: colors.text,
|
||||
fontWeight: '600',
|
||||
},
|
||||
subtitle: {
|
||||
...typography.subhead,
|
||||
color: colors.textSecondary,
|
||||
@@ -166,18 +240,25 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
list: {
|
||||
padding: spacing.lg,
|
||||
paddingTop: 0,
|
||||
paddingBottom: Platform.OS === 'ios' ? 120 : 100,
|
||||
},
|
||||
listWide: {
|
||||
alignSelf: 'center',
|
||||
width: '100%',
|
||||
maxWidth: MAX_CONTENT_WIDTH,
|
||||
},
|
||||
matchCard: {
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
matchRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: colors.primaryLight,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginRight: spacing.md,
|
||||
@@ -190,14 +271,15 @@ const styles = StyleSheet.create({
|
||||
color: colors.text,
|
||||
fontWeight: "600",
|
||||
marginBottom: spacing.xs,
|
||||
lineHeight: 22,
|
||||
},
|
||||
partnerText: {
|
||||
...typography.subhead,
|
||||
...typography.caption1,
|
||||
color: colors.textSecondary,
|
||||
fontStyle: "italic",
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
metaContainer: {
|
||||
metaRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
@@ -205,8 +287,8 @@ const styles = StyleSheet.create({
|
||||
tag: {
|
||||
backgroundColor: colors.glass.background,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radius.xs,
|
||||
paddingVertical: 4,
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
tagHighlight: {
|
||||
backgroundColor: colors.primaryLight,
|
||||
@@ -214,39 +296,86 @@ const styles = StyleSheet.create({
|
||||
tagText: {
|
||||
...typography.caption2,
|
||||
color: colors.textSecondary,
|
||||
fontWeight: "600",
|
||||
fontWeight: "700",
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
tagTextHighlight: {
|
||||
color: colors.primary,
|
||||
},
|
||||
date: {
|
||||
...typography.caption1,
|
||||
...typography.caption2,
|
||||
color: colors.textTertiary,
|
||||
},
|
||||
chevronContainer: {
|
||||
rightSection: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
unreadBadge: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
backgroundColor: colors.primary,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
chevronContainer: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
backgroundColor: colors.glass.background,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyContainer: {
|
||||
alignItems: "center",
|
||||
paddingVertical: spacing.xxl,
|
||||
paddingHorizontal: spacing.lg,
|
||||
},
|
||||
emptyIconContainer: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: colors.glass.background,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginBottom: spacing.md,
|
||||
emptyIconGradient: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
...shadows.lg,
|
||||
},
|
||||
emptyIconInner: {
|
||||
width: 88,
|
||||
height: 88,
|
||||
borderRadius: 44,
|
||||
backgroundColor: colors.background,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyTitle: {
|
||||
...typography.title3,
|
||||
...typography.title2,
|
||||
color: colors.text,
|
||||
marginBottom: spacing.xs,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
emptyText: {
|
||||
...typography.body,
|
||||
color: colors.textSecondary,
|
||||
textAlign: "center",
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
emptyButton: {
|
||||
borderRadius: radius.full,
|
||||
overflow: 'hidden',
|
||||
...shadows.md,
|
||||
},
|
||||
emptyButtonGradient: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
emptyButtonText: {
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
+224
-65
@@ -1,67 +1,184 @@
|
||||
import { View, Text, StyleSheet, FlatList, TouchableOpacity, Switch, RefreshControl, Platform } from "react-native";
|
||||
import { usePacksStore } from "../../src/store";
|
||||
import { usePacksStore, useAuthStore, useSubscriptionStore } from "../../src/store";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { router } from "expo-router";
|
||||
import { useEffect } from "react";
|
||||
import Animated, { FadeInDown, FadeInRight } from "react-native-reanimated";
|
||||
import { useEffect, useMemo, useState, useCallback } from "react";
|
||||
import Animated, { FadeInDown, FadeInRight, FadeIn, FadeOut, Layout } from "react-native-reanimated";
|
||||
import { GradientBackground, GlassCard } from "../../src/components/ui";
|
||||
import { Paywall } from "../../src/components/Paywall";
|
||||
import { colors, spacing, typography, radius } from "../../src/theme";
|
||||
import type { QuestionPack, Category } from "../../src/types";
|
||||
|
||||
interface CategoryWithPacks extends Category {
|
||||
packs: QuestionPack[];
|
||||
}
|
||||
|
||||
export default function PacksScreen() {
|
||||
const { packs, enabledPackIds, togglePack, isLoading, fetchPacks } = usePacksStore();
|
||||
const { packs, categories, enabledPackIds, togglePack, isLoading, fetchPacks } = usePacksStore();
|
||||
const { user, partner } = useAuthStore();
|
||||
const { subscription } = useSubscriptionStore();
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
|
||||
const [showPaywall, setShowPaywall] = 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]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPacks();
|
||||
}, []);
|
||||
|
||||
const enabledCount = packs.filter(p => enabledPackIds.includes(p.id)).length;
|
||||
// Handle toggling a pack - show paywall if premium pack without access
|
||||
const handleTogglePack = useCallback((pack: QuestionPack) => {
|
||||
if (pack.is_premium && !hasPremiumAccess) {
|
||||
setShowPaywall(true);
|
||||
return;
|
||||
}
|
||||
togglePack(pack.id);
|
||||
}, [hasPremiumAccess, togglePack]);
|
||||
|
||||
const renderItem = ({ item, index }: { item: any; index: number }) => {
|
||||
const enabledPacks = packs.filter(p => enabledPackIds.includes(p.id));
|
||||
const enabledPackCount = enabledPacks.length;
|
||||
const totalQuestions = enabledPacks.reduce((sum, p) => sum + (p.questions?.[0]?.count || 0), 0);
|
||||
|
||||
// Group packs by category
|
||||
const categoriesWithPacks = useMemo((): CategoryWithPacks[] => {
|
||||
return categories
|
||||
.map(category => ({
|
||||
...category,
|
||||
packs: packs.filter(p => p.category_id === category.id)
|
||||
}))
|
||||
.filter(cat => cat.packs.length > 0);
|
||||
}, [packs, categories]);
|
||||
|
||||
const toggleCategory = useCallback((categoryId: string) => {
|
||||
setExpandedCategories(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(categoryId)) {
|
||||
next.delete(categoryId);
|
||||
} else {
|
||||
next.add(categoryId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const renderPack = (item: QuestionPack, index: number) => {
|
||||
const isEnabled = enabledPackIds.includes(item.id);
|
||||
const isPremiumLocked = item.is_premium && !hasPremiumAccess;
|
||||
|
||||
return (
|
||||
<Animated.View entering={FadeInRight.delay(index * 50).duration(300)}>
|
||||
<GlassCard style={styles.card}>
|
||||
<Animated.View
|
||||
key={item.id}
|
||||
entering={FadeIn.delay(index * 50).duration(200)}
|
||||
exiting={FadeOut.duration(150)}
|
||||
layout={Layout.springify()}
|
||||
>
|
||||
<View style={styles.packItem}>
|
||||
<TouchableOpacity
|
||||
style={styles.cardContent}
|
||||
style={styles.packContent}
|
||||
activeOpacity={0.7}
|
||||
onPress={() => router.push(`/pack/${item.id}`)}
|
||||
onPress={() => {
|
||||
if (isPremiumLocked) {
|
||||
setShowPaywall(true);
|
||||
} else {
|
||||
router.push(`/pack/${item.id}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<View style={[styles.iconContainer, isEnabled && styles.iconContainerActive]}>
|
||||
<Text style={styles.emoji}>{item.icon || "📦"}</Text>
|
||||
<View style={[
|
||||
styles.packIcon,
|
||||
isEnabled && styles.packIconActive,
|
||||
isPremiumLocked && styles.packIconLocked
|
||||
]}>
|
||||
<Text style={styles.packEmoji}>{item.icon || "📦"}</Text>
|
||||
</View>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.headerRow}>
|
||||
<Text style={styles.name}>{item.name}</Text>
|
||||
<View style={styles.packInfo}>
|
||||
<View style={styles.packHeader}>
|
||||
<Text style={[
|
||||
styles.packName,
|
||||
isPremiumLocked && styles.packNameLocked
|
||||
]}>
|
||||
{item.name}
|
||||
</Text>
|
||||
{item.is_premium && (
|
||||
<View style={styles.badge}>
|
||||
<Ionicons name="star" size={10} color={colors.text} />
|
||||
<View style={[
|
||||
styles.badge,
|
||||
isPremiumLocked && styles.badgeLocked
|
||||
]}>
|
||||
<Ionicons
|
||||
name={isPremiumLocked ? "lock-closed" : "star"}
|
||||
size={10}
|
||||
color={colors.text}
|
||||
/>
|
||||
<Text style={styles.badgeText}>PRO</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.description} numberOfLines={2}>
|
||||
{item.description}
|
||||
<Text style={[
|
||||
styles.packDescription,
|
||||
isPremiumLocked && styles.packDescriptionLocked
|
||||
]} numberOfLines={1}>
|
||||
{isPremiumLocked ? "Upgrade to unlock" : item.description}
|
||||
</Text>
|
||||
{item.question_count && (
|
||||
<Text style={styles.questionCount}>
|
||||
{item.question_count} questions
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.switchContainer}>
|
||||
<Switch
|
||||
value={isEnabled}
|
||||
onValueChange={() => togglePack(item.id)}
|
||||
trackColor={{
|
||||
false: colors.glass.background,
|
||||
true: colors.primary,
|
||||
}}
|
||||
thumbColor={colors.text}
|
||||
ios_backgroundColor={colors.glass.background}
|
||||
<Switch
|
||||
value={isEnabled}
|
||||
onValueChange={() => handleTogglePack(item)}
|
||||
disabled={isPremiumLocked}
|
||||
trackColor={{
|
||||
false: colors.glass.background,
|
||||
true: colors.primary,
|
||||
}}
|
||||
thumbColor={colors.text}
|
||||
ios_backgroundColor={colors.glass.background}
|
||||
style={isPremiumLocked ? { opacity: 0.4 } : undefined}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCategory = ({ item: category, index }: { item: CategoryWithPacks; index: number }) => {
|
||||
const isExpanded = expandedCategories.has(category.id);
|
||||
const enabledInCategory = category.packs.filter(p => enabledPackIds.includes(p.id)).length;
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(index * 100).duration(300)}
|
||||
layout={Layout.springify()}
|
||||
>
|
||||
<GlassCard style={styles.categoryCard}>
|
||||
<TouchableOpacity
|
||||
style={styles.categoryHeader}
|
||||
activeOpacity={0.7}
|
||||
onPress={() => toggleCategory(category.id)}
|
||||
>
|
||||
<Text style={styles.categoryIcon}>{category.icon}</Text>
|
||||
<View style={styles.categoryInfo}>
|
||||
<Text style={styles.categoryName}>{category.name}</Text>
|
||||
<Text style={styles.categoryMeta}>
|
||||
{category.packs.length} packs · {enabledInCategory} enabled
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons
|
||||
name={isExpanded ? "chevron-up" : "chevron-down"}
|
||||
size={24}
|
||||
color={colors.textSecondary}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{isExpanded && (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(200)}
|
||||
exiting={FadeOut.duration(150)}
|
||||
style={styles.packsContainer}
|
||||
>
|
||||
{category.packs.map((pack, idx) => renderPack(pack, idx))}
|
||||
</Animated.View>
|
||||
)}
|
||||
</GlassCard>
|
||||
</Animated.View>
|
||||
);
|
||||
@@ -76,13 +193,13 @@ export default function PacksScreen() {
|
||||
>
|
||||
<Text style={styles.title}>Question Packs</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
{enabledCount} of {packs.length} packs enabled
|
||||
{totalQuestions} {totalQuestions === 1 ? 'question' : 'questions'} from {enabledPackCount} {enabledPackCount === 1 ? 'pack' : 'packs'} enabled
|
||||
</Text>
|
||||
</Animated.View>
|
||||
|
||||
<FlatList
|
||||
data={packs}
|
||||
renderItem={renderItem}
|
||||
data={categoriesWithPacks}
|
||||
renderItem={renderCategory}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.list}
|
||||
showsVerticalScrollIndicator={false}
|
||||
@@ -109,6 +226,15 @@ export default function PacksScreen() {
|
||||
</Animated.View>
|
||||
}
|
||||
/>
|
||||
|
||||
<Paywall
|
||||
visible={showPaywall}
|
||||
onClose={() => setShowPaywall(false)}
|
||||
onSuccess={() => {
|
||||
// Refresh packs after successful purchase
|
||||
fetchPacks();
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</GradientBackground>
|
||||
);
|
||||
@@ -136,45 +262,88 @@ const styles = StyleSheet.create({
|
||||
padding: spacing.lg,
|
||||
paddingBottom: Platform.OS === 'ios' ? 120 : 100,
|
||||
},
|
||||
card: {
|
||||
categoryCard: {
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
categoryHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
cardContent: {
|
||||
categoryIcon: {
|
||||
fontSize: 32,
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
categoryInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
categoryName: {
|
||||
...typography.title3,
|
||||
color: colors.text,
|
||||
},
|
||||
categoryMeta: {
|
||||
...typography.caption1,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 2,
|
||||
},
|
||||
packsContainer: {
|
||||
marginTop: spacing.md,
|
||||
paddingTop: spacing.md,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.glass.border,
|
||||
},
|
||||
packItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
packContent: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
iconContainer: {
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 26,
|
||||
packIcon: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: colors.glass.background,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
iconContainerActive: {
|
||||
packIconActive: {
|
||||
backgroundColor: colors.primaryLight,
|
||||
},
|
||||
emoji: {
|
||||
fontSize: 26,
|
||||
packIconLocked: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
content: {
|
||||
packEmoji: {
|
||||
fontSize: 22,
|
||||
},
|
||||
packInfo: {
|
||||
flex: 1,
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
headerRow: {
|
||||
packHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginBottom: spacing.xs,
|
||||
marginBottom: 2,
|
||||
},
|
||||
name: {
|
||||
packName: {
|
||||
...typography.headline,
|
||||
color: colors.text,
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
packNameLocked: {
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
packDescription: {
|
||||
...typography.caption1,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
packDescriptionLocked: {
|
||||
color: colors.textTertiary,
|
||||
fontStyle: "italic",
|
||||
},
|
||||
badge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
@@ -184,24 +353,14 @@ const styles = StyleSheet.create({
|
||||
borderRadius: radius.xs,
|
||||
gap: 2,
|
||||
},
|
||||
badgeLocked: {
|
||||
backgroundColor: colors.glass.backgroundLight,
|
||||
},
|
||||
badgeText: {
|
||||
...typography.caption2,
|
||||
color: colors.text,
|
||||
fontWeight: "700",
|
||||
},
|
||||
description: {
|
||||
...typography.subhead,
|
||||
color: colors.textSecondary,
|
||||
lineHeight: 20,
|
||||
},
|
||||
questionCount: {
|
||||
...typography.caption1,
|
||||
color: colors.textTertiary,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
switchContainer: {
|
||||
paddingLeft: spacing.sm,
|
||||
},
|
||||
emptyContainer: {
|
||||
alignItems: "center",
|
||||
paddingVertical: spacing.xxl,
|
||||
|
||||
@@ -18,7 +18,7 @@ import { supabase } from "../../src/lib/supabase";
|
||||
import { router } from "expo-router";
|
||||
|
||||
export default function PairingScreen() {
|
||||
const { user, fetchCouple, fetchUser, couple, partner, isLoading: isAuthLoading } = useAuthStore();
|
||||
const { user, fetchCouple, fetchUser, couple, partner, isLoading: isAuthLoading, signOut } = useAuthStore();
|
||||
const [inviteCode, setInviteCode] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
@@ -29,6 +29,41 @@ export default function PairingScreen() {
|
||||
}
|
||||
}, [couple, partner]);
|
||||
|
||||
// Subscribe to real-time updates for partner joining + polling fallback
|
||||
useEffect(() => {
|
||||
if (!couple || partner) return;
|
||||
|
||||
// Poll every 5 seconds as fallback
|
||||
const pollInterval = setInterval(() => {
|
||||
fetchCouple();
|
||||
}, 5000);
|
||||
|
||||
// Listen for new profiles joining this couple
|
||||
const subscription = supabase
|
||||
.channel(`couple-${couple.id}`)
|
||||
.on(
|
||||
"postgres_changes",
|
||||
{
|
||||
event: "UPDATE",
|
||||
schema: "public",
|
||||
table: "profiles",
|
||||
filter: `couple_id=eq.${couple.id}`,
|
||||
},
|
||||
async (payload) => {
|
||||
// Someone updated their profile to join this couple
|
||||
if (payload.new.id !== user?.id) {
|
||||
await fetchCouple();
|
||||
}
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
clearInterval(pollInterval);
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [couple, partner, user?.id, fetchCouple]);
|
||||
|
||||
const handleCreateCouple = async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
@@ -103,6 +138,21 @@ export default function PairingScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignOut = () => {
|
||||
Alert.alert(
|
||||
"Sign Out",
|
||||
"Are you sure you want to sign out?",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Sign Out",
|
||||
style: "destructive",
|
||||
onPress: signOut,
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
if (isAuthLoading) {
|
||||
return (
|
||||
<View style={[styles.container, styles.centerContent]}>
|
||||
@@ -138,6 +188,11 @@ export default function PairingScreen() {
|
||||
Waiting for your partner to join...
|
||||
</Text>
|
||||
<ActivityIndicator size="small" color="#e94560" style={{ marginTop: 16 }} />
|
||||
|
||||
<TouchableOpacity style={styles.signOutButton} onPress={handleSignOut}>
|
||||
<Ionicons name="log-out-outline" size={20} color="#888" />
|
||||
<Text style={styles.signOutText}>Sign Out</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -192,6 +247,11 @@ export default function PairingScreen() {
|
||||
{isSubmitting ? "Creating..." : "Create New Code"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.signOutButton} onPress={handleSignOut}>
|
||||
<Ionicons name="log-out-outline" size={20} color="#888" />
|
||||
<Text style={styles.signOutText}>Sign Out</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
@@ -334,4 +394,15 @@ const styles = StyleSheet.create({
|
||||
color: "#666",
|
||||
fontSize: 14,
|
||||
},
|
||||
signOutButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginTop: 48,
|
||||
padding: 12,
|
||||
},
|
||||
signOutText: {
|
||||
color: "#888",
|
||||
fontSize: 16,
|
||||
marginLeft: 8,
|
||||
},
|
||||
});
|
||||
+695
-101
@@ -1,15 +1,92 @@
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Alert, ScrollView, Platform } from "react-native";
|
||||
import { useAuthStore } from "../../src/store";
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Alert, ScrollView, Platform, useWindowDimensions, TextInput, Modal, ActivityIndicator, Linking } from "react-native";
|
||||
import { useState, useMemo } 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 { router } from "expo-router";
|
||||
import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui";
|
||||
import { GradientBackground, GlassCard } from "../../src/components/ui";
|
||||
import { FeedbackModal } from "../../src/components/FeedbackModal";
|
||||
import { Paywall } from "../../src/components/Paywall";
|
||||
import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme";
|
||||
|
||||
const MAX_CONTENT_WIDTH = 500;
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const { user, partner, signOut, fetchCouple } = useAuthStore();
|
||||
const { user, partner, couple, signOut, fetchCouple } = useAuthStore();
|
||||
const { clearMatches } = useMatchStore();
|
||||
const { clearMessages } = useMessageStore();
|
||||
const { clearPacks } = usePacksStore();
|
||||
const { subscription, restorePurchases, isPurchasing } = useSubscriptionStore();
|
||||
const { width } = useWindowDimensions();
|
||||
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
||||
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [deleteConfirmText, setDeleteConfirmText] = useState("");
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [showFeedbackModal, setShowFeedbackModal] = useState(false);
|
||||
const [showPaywall, setShowPaywall] = 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 if it's the user's own subscription (vs partner's)
|
||||
const isOwnSubscription = user?.is_premium && subscription.isProUser;
|
||||
|
||||
// Format expiration date
|
||||
const formatExpirationDate = (date: Date | null) => {
|
||||
if (!date) return "Never";
|
||||
return date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const handleRestorePurchases = async () => {
|
||||
const restored = await restorePurchases();
|
||||
if (restored) {
|
||||
Alert.alert("Success", "Your purchases have been restored!");
|
||||
} else {
|
||||
Alert.alert("No Purchases Found", "No previous purchases found to restore.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleManageSubscription = () => {
|
||||
Linking.openURL("https://apps.apple.com/account/subscriptions");
|
||||
};
|
||||
|
||||
const handleDeleteRelationship = async () => {
|
||||
if (deleteConfirmText !== "DELETE") return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const { error } = await supabase.functions.invoke("delete-relationship", {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Clear local stores
|
||||
clearMatches();
|
||||
clearMessages();
|
||||
clearPacks();
|
||||
|
||||
// Refresh user data (will also clear couple/partner since couple_id is now null)
|
||||
await useAuthStore.getState().fetchUser();
|
||||
|
||||
setShowDeleteModal(false);
|
||||
setDeleteConfirmText("");
|
||||
Alert.alert("Success", "All relationship data has been deleted.");
|
||||
} catch (error) {
|
||||
Alert.alert("Error", "Failed to delete relationship data. Please try again.");
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnpair = async () => {
|
||||
Alert.alert(
|
||||
@@ -54,7 +131,10 @@ export default function ProfileScreen() {
|
||||
<GradientBackground>
|
||||
<ScrollView
|
||||
style={styles.container}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
contentContainerStyle={[
|
||||
styles.contentContainer,
|
||||
isWideScreen && styles.contentContainerWide,
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Header */}
|
||||
@@ -66,103 +146,371 @@ export default function ProfileScreen() {
|
||||
</Animated.View>
|
||||
|
||||
{/* Profile Card */}
|
||||
<Animated.View entering={FadeInDown.delay(200).duration(500)}>
|
||||
<GlassCard style={styles.profileCard} variant="elevated">
|
||||
<LinearGradient
|
||||
colors={gradients.primary as [string, string]}
|
||||
style={styles.avatarContainer}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<View style={styles.avatar}>
|
||||
<Text style={styles.avatarText}>
|
||||
{user?.name?.[0]?.toUpperCase() || "U"}
|
||||
</Text>
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(200).duration(500)}
|
||||
style={styles.profileSection}
|
||||
>
|
||||
<GlassCard variant="elevated">
|
||||
<View style={styles.profileContent}>
|
||||
<LinearGradient
|
||||
colors={gradients.primary as [string, string]}
|
||||
style={styles.avatarGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<View style={styles.avatarInner}>
|
||||
<Text style={styles.avatarText}>
|
||||
{user?.name?.[0]?.toUpperCase() || "U"}
|
||||
</Text>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
<View style={styles.profileInfo}>
|
||||
<Text style={styles.name}>{user?.name || "User"}</Text>
|
||||
<Text style={styles.email}>{user?.email}</Text>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
<Text style={styles.name}>{user?.name || "User"}</Text>
|
||||
<Text style={styles.email}>{user?.email}</Text>
|
||||
</View>
|
||||
</GlassCard>
|
||||
</Animated.View>
|
||||
|
||||
{/* Relationship Section */}
|
||||
<Animated.View entering={FadeInDown.delay(300).duration(500)}>
|
||||
<Text style={styles.sectionTitle}>Relationship</Text>
|
||||
{/* Partner Section */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(300).duration(500)}
|
||||
style={styles.section}
|
||||
>
|
||||
<Text style={styles.sectionTitle}>Partner</Text>
|
||||
<GlassCard>
|
||||
{partner ? (
|
||||
<View style={styles.row}>
|
||||
<View style={styles.rowContainer}>
|
||||
<View style={styles.rowLeft}>
|
||||
<View style={styles.rowIconContainer}>
|
||||
<Ionicons name="heart" size={20} color={colors.primary} />
|
||||
</View>
|
||||
<View>
|
||||
<Text style={styles.rowLabel}>Paired with</Text>
|
||||
<LinearGradient
|
||||
colors={gradients.primary as [string, string]}
|
||||
style={styles.partnerIconGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Ionicons name="heart" size={20} color={colors.text} />
|
||||
</LinearGradient>
|
||||
<View style={styles.rowTextContainer}>
|
||||
<Text style={styles.rowValue}>
|
||||
{partner.name || partner.email || 'your partner'}
|
||||
{partner.name || partner.email || 'Your partner'}
|
||||
</Text>
|
||||
<Text style={styles.rowLabel}>Connected</Text>
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={handleUnpair}
|
||||
style={styles.rowAction}
|
||||
style={styles.unlinkButton}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons name="unlink-outline" size={20} color={colors.error} />
|
||||
<Ionicons name="unlink-outline" size={18} color={colors.textTertiary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
style={styles.row}
|
||||
style={styles.rowContainer}
|
||||
onPress={() => router.push("/(app)/pairing")}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.rowLeft}>
|
||||
<View style={[styles.rowIconContainer, styles.rowIconInactive]}>
|
||||
<View style={styles.emptyPartnerIcon}>
|
||||
<Ionicons name="heart-outline" size={20} color={colors.textTertiary} />
|
||||
</View>
|
||||
<View>
|
||||
<Text style={styles.rowLabel}>Not paired yet</Text>
|
||||
<Text style={styles.rowValueMuted}>Tap to connect with your partner</Text>
|
||||
<View style={styles.rowTextContainer}>
|
||||
<Text style={styles.rowValueMuted}>Not paired yet</Text>
|
||||
<Text style={styles.rowLabel}>Tap to connect</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
|
||||
<View style={styles.chevronContainer}>
|
||||
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</GlassCard>
|
||||
</Animated.View>
|
||||
|
||||
{/* Settings Section */}
|
||||
<Animated.View entering={FadeInDown.delay(400).duration(500)}>
|
||||
<Text style={styles.sectionTitle}>Account</Text>
|
||||
<GlassCard noPadding>
|
||||
{/* Subscription Section */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(350).duration(500)}
|
||||
style={styles.section}
|
||||
>
|
||||
<Text style={styles.sectionTitle}>Subscription</Text>
|
||||
<GlassCard>
|
||||
{hasPremiumAccess ? (
|
||||
<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="star" size={20} color={colors.text} />
|
||||
</LinearGradient>
|
||||
<View style={styles.rowTextContainer}>
|
||||
<Text style={styles.rowValue}>Pro Member</Text>
|
||||
<Text style={styles.rowLabel}>
|
||||
{isOwnSubscription
|
||||
? `Renews ${formatExpirationDate(subscription.expirationDate)}`
|
||||
: "Via partner's subscription"
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{isOwnSubscription && (
|
||||
<TouchableOpacity
|
||||
style={styles.manageButton}
|
||||
onPress={handleManageSubscription}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.manageButtonText}>Manage</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
style={styles.rowContainer}
|
||||
onPress={() => setShowPaywall(true)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.rowLeft}>
|
||||
<View style={styles.emptyPartnerIcon}>
|
||||
<Ionicons name="star-outline" size={20} color={colors.textTertiary} />
|
||||
</View>
|
||||
<View style={styles.rowTextContainer}>
|
||||
<Text style={styles.rowValueMuted}>Free Plan</Text>
|
||||
<Text style={styles.rowLabel}>Upgrade to unlock all packs</Text>
|
||||
</View>
|
||||
</View>
|
||||
<LinearGradient
|
||||
colors={gradients.primary as [string, string]}
|
||||
style={styles.upgradeButton}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
>
|
||||
<Text style={styles.upgradeButtonText}>Upgrade</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</GlassCard>
|
||||
|
||||
{/* Restore Purchases Link */}
|
||||
{!hasPremiumAccess && (
|
||||
<TouchableOpacity
|
||||
style={[styles.menuRow, styles.menuRowDanger]}
|
||||
style={styles.restoreLink}
|
||||
onPress={handleRestorePurchases}
|
||||
disabled={isPurchasing}
|
||||
>
|
||||
<Text style={styles.restoreLinkText}>
|
||||
{isPurchasing ? "Restoring..." : "Restore Purchases"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</Animated.View>
|
||||
|
||||
{/* Account Section */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(400).duration(500)}
|
||||
style={styles.section}
|
||||
>
|
||||
<Text style={styles.sectionTitle}>Account</Text>
|
||||
<GlassCard>
|
||||
<TouchableOpacity
|
||||
style={styles.signOutRow}
|
||||
onPress={handleSignOut}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.rowLeft}>
|
||||
<View style={[styles.rowIconContainer, styles.rowIconDanger]}>
|
||||
<View style={styles.signOutIcon}>
|
||||
<Ionicons name="log-out-outline" size={20} color={colors.error} />
|
||||
</View>
|
||||
<Text style={styles.rowLabelDanger}>Sign Out</Text>
|
||||
<Text style={styles.signOutText}>Sign Out</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color={colors.error} />
|
||||
<Ionicons name="chevron-forward" size={18} color={colors.error} />
|
||||
</TouchableOpacity>
|
||||
</GlassCard>
|
||||
</Animated.View>
|
||||
|
||||
{/* Version */}
|
||||
{/* Support Section */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(500).duration(500)}
|
||||
style={styles.versionContainer}
|
||||
entering={FadeInDown.delay(450).duration(500)}
|
||||
style={styles.section}
|
||||
>
|
||||
<Text style={styles.version}>Sauci v1.0.0</Text>
|
||||
<Text style={styles.versionSub}>Made with love</Text>
|
||||
<Text style={styles.sectionTitle}>Support</Text>
|
||||
<GlassCard>
|
||||
<TouchableOpacity
|
||||
style={styles.rowContainer}
|
||||
onPress={() => setShowFeedbackModal(true)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<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="chatbubble-ellipses-outline" size={20} color={colors.text} />
|
||||
</LinearGradient>
|
||||
<View style={styles.rowTextContainer}>
|
||||
<Text style={styles.rowValue}>Send Feedback</Text>
|
||||
<Text style={styles.rowLabel}>Report bugs or request features</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
|
||||
entering={FadeInDown.delay(550).duration(500)}
|
||||
style={styles.section}
|
||||
>
|
||||
<Text style={styles.dangerSectionTitle}>Danger Zone</Text>
|
||||
<GlassCard style={styles.dangerCard}>
|
||||
<View style={styles.dangerContent}>
|
||||
<View style={styles.dangerInfo}>
|
||||
<View style={styles.dangerIconContainer}>
|
||||
<Ionicons name="warning" size={20} color={colors.error} />
|
||||
</View>
|
||||
<View style={styles.dangerTextContainer}>
|
||||
<Text style={styles.dangerTitle}>Delete All Data</Text>
|
||||
<Text style={styles.dangerDescription}>
|
||||
Permanently delete your relationship including all matches, chats, and shared photos.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={styles.dangerButton}
|
||||
onPress={() => setShowDeleteModal(true)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={16} color={colors.error} />
|
||||
<Text style={styles.dangerButtonText}>Delete</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</GlassCard>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Version */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(couple ? 650 : 550).duration(500)}
|
||||
style={styles.versionContainer}
|
||||
>
|
||||
<View style={styles.versionBadge}>
|
||||
<Ionicons name="heart" size={12} color={colors.primary} />
|
||||
<Text style={styles.version}>Sauci v1.0.0</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/* Bottom spacing for tab bar */}
|
||||
<View style={styles.bottomSpacer} />
|
||||
</ScrollView>
|
||||
|
||||
{/* Feedback Modal */}
|
||||
<FeedbackModal
|
||||
visible={showFeedbackModal}
|
||||
onClose={() => setShowFeedbackModal(false)}
|
||||
/>
|
||||
|
||||
{/* Paywall Modal */}
|
||||
<Paywall
|
||||
visible={showPaywall}
|
||||
onClose={() => setShowPaywall(false)}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<Modal
|
||||
visible={showDeleteModal}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => !isDeleting && setShowDeleteModal(false)}
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={styles.modalContent}>
|
||||
<View style={styles.modalHeader}>
|
||||
<View style={styles.modalIconContainer}>
|
||||
<Ionicons name="warning" size={32} color={colors.error} />
|
||||
</View>
|
||||
<Text style={styles.modalTitle}>Delete All 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={deleteConfirmText}
|
||||
onChangeText={setDeleteConfirmText}
|
||||
placeholder="Type DELETE"
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
autoCapitalize="characters"
|
||||
autoCorrect={false}
|
||||
editable={!isDeleting}
|
||||
/>
|
||||
|
||||
<View style={styles.modalButtons}>
|
||||
<TouchableOpacity
|
||||
style={styles.cancelButton}
|
||||
onPress={() => {
|
||||
setShowDeleteModal(false);
|
||||
setDeleteConfirmText("");
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.cancelButtonText}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.confirmDeleteButton,
|
||||
deleteConfirmText !== "DELETE" && styles.confirmDeleteButtonDisabled,
|
||||
]}
|
||||
onPress={handleDeleteRelationship}
|
||||
disabled={deleteConfirmText !== "DELETE" || isDeleting}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<ActivityIndicator size="small" color={colors.text} />
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="trash" size={16} color={colors.text} />
|
||||
<Text style={styles.confirmDeleteButtonText}>Delete</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</GradientBackground>
|
||||
);
|
||||
}
|
||||
@@ -174,43 +522,53 @@ const styles = StyleSheet.create({
|
||||
contentContainer: {
|
||||
paddingBottom: Platform.OS === 'ios' ? 100 : 80,
|
||||
},
|
||||
contentContainerWide: {
|
||||
alignSelf: 'center',
|
||||
width: '100%',
|
||||
maxWidth: MAX_CONTENT_WIDTH,
|
||||
},
|
||||
header: {
|
||||
paddingTop: 60,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.md,
|
||||
paddingBottom: spacing.lg,
|
||||
},
|
||||
title: {
|
||||
...typography.title1,
|
||||
color: colors.text,
|
||||
},
|
||||
profileCard: {
|
||||
marginHorizontal: spacing.lg,
|
||||
alignItems: "center",
|
||||
paddingVertical: spacing.xl,
|
||||
marginBottom: spacing.lg,
|
||||
profileSection: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
avatarContainer: {
|
||||
width: 88,
|
||||
height: 88,
|
||||
borderRadius: 44,
|
||||
profileContent: {
|
||||
alignItems: "center",
|
||||
paddingVertical: spacing.lg,
|
||||
},
|
||||
avatarGradient: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginBottom: spacing.md,
|
||||
...shadows.lg,
|
||||
},
|
||||
avatar: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
avatarInner: {
|
||||
width: 90,
|
||||
height: 90,
|
||||
borderRadius: 45,
|
||||
backgroundColor: colors.background,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
avatarText: {
|
||||
fontSize: 32,
|
||||
fontSize: 36,
|
||||
fontWeight: "bold",
|
||||
color: colors.text,
|
||||
},
|
||||
profileInfo: {
|
||||
alignItems: "center",
|
||||
marginTop: spacing.lg,
|
||||
},
|
||||
name: {
|
||||
...typography.title2,
|
||||
color: colors.text,
|
||||
@@ -220,43 +578,46 @@ const styles = StyleSheet.create({
|
||||
...typography.subhead,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
section: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
sectionTitle: {
|
||||
...typography.caption1,
|
||||
color: colors.textTertiary,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
letterSpacing: 1.5,
|
||||
marginBottom: spacing.sm,
|
||||
marginLeft: spacing.lg + spacing.xs,
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
row: {
|
||||
rowContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: spacing.md,
|
||||
},
|
||||
rowLeft: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flex: 1,
|
||||
},
|
||||
rowIconContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: colors.primaryLight,
|
||||
partnerIconGradient: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
rowIconInactive: {
|
||||
emptyPartnerIcon: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: colors.glass.background,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
rowIconDanger: {
|
||||
backgroundColor: colors.errorLight,
|
||||
},
|
||||
rowLabel: {
|
||||
...typography.subhead,
|
||||
color: colors.textSecondary,
|
||||
rowTextContainer: {
|
||||
marginLeft: spacing.md,
|
||||
flex: 1,
|
||||
},
|
||||
rowValue: {
|
||||
...typography.body,
|
||||
@@ -264,40 +625,273 @@ const styles = StyleSheet.create({
|
||||
fontWeight: "600",
|
||||
},
|
||||
rowValueMuted: {
|
||||
...typography.body,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
rowLabel: {
|
||||
...typography.caption1,
|
||||
color: colors.textTertiary,
|
||||
marginTop: 2,
|
||||
},
|
||||
rowLabelDanger: {
|
||||
...typography.body,
|
||||
color: colors.error,
|
||||
fontWeight: "500",
|
||||
unlinkButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: colors.glass.background,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
rowAction: {
|
||||
padding: spacing.sm,
|
||||
chevronContainer: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
backgroundColor: colors.glass.background,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
menuRow: {
|
||||
signOutRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: spacing.md,
|
||||
},
|
||||
menuRowDanger: {
|
||||
borderRadius: radius.lg,
|
||||
signOutIcon: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: colors.errorLight,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
signOutText: {
|
||||
...typography.body,
|
||||
color: colors.error,
|
||||
fontWeight: "600",
|
||||
marginLeft: spacing.md,
|
||||
},
|
||||
// Subscription styles
|
||||
manageButton: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: colors.glass.background,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
},
|
||||
manageButtonText: {
|
||||
...typography.subhead,
|
||||
color: colors.primary,
|
||||
fontWeight: "600",
|
||||
},
|
||||
upgradeButton: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.md,
|
||||
},
|
||||
upgradeButtonText: {
|
||||
...typography.subhead,
|
||||
color: colors.text,
|
||||
fontWeight: "600",
|
||||
},
|
||||
restoreLink: {
|
||||
alignItems: "center",
|
||||
marginTop: spacing.sm,
|
||||
padding: spacing.sm,
|
||||
},
|
||||
restoreLinkText: {
|
||||
...typography.caption1,
|
||||
color: colors.textTertiary,
|
||||
},
|
||||
versionContainer: {
|
||||
alignItems: "center",
|
||||
marginTop: spacing.xl,
|
||||
marginTop: spacing.lg,
|
||||
},
|
||||
versionBadge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: colors.glass.background,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.full,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
version: {
|
||||
...typography.caption1,
|
||||
color: colors.textTertiary,
|
||||
},
|
||||
versionSub: {
|
||||
...typography.caption2,
|
||||
color: colors.textTertiary,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
bottomSpacer: {
|
||||
height: spacing.lg,
|
||||
},
|
||||
// Danger Zone styles
|
||||
dangerSectionTitle: {
|
||||
...typography.caption1,
|
||||
color: colors.error,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1.5,
|
||||
marginBottom: spacing.sm,
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
dangerCard: {
|
||||
borderColor: 'rgba(231, 76, 60, 0.3)',
|
||||
borderWidth: 1,
|
||||
},
|
||||
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: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: colors.errorLight,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(231, 76, 60, 0.3)',
|
||||
gap: spacing.xs,
|
||||
alignSelf: "flex-end",
|
||||
},
|
||||
dangerButtonText: {
|
||||
...typography.subhead,
|
||||
color: colors.error,
|
||||
fontWeight: "600",
|
||||
},
|
||||
// Modal styles
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: spacing.lg,
|
||||
},
|
||||
modalContent: {
|
||||
backgroundColor: colors.backgroundLight,
|
||||
borderRadius: radius.xl,
|
||||
padding: spacing.lg,
|
||||
width: "100%",
|
||||
maxWidth: 400,
|
||||
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,
|
||||
textAlign: "center",
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
deleteList: {
|
||||
backgroundColor: colors.glass.background,
|
||||
borderRadius: radius.md,
|
||||
padding: spacing.md,
|
||||
marginBottom: spacing.lg,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
deleteListItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
deleteListText: {
|
||||
...typography.subhead,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
confirmLabel: {
|
||||
...typography.subhead,
|
||||
color: colors.textSecondary,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
confirmKeyword: {
|
||||
color: colors.error,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
confirmInput: {
|
||||
backgroundColor: colors.glass.background,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
padding: spacing.md,
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
modalButtons: {
|
||||
flexDirection: "row",
|
||||
gap: spacing.md,
|
||||
},
|
||||
cancelButton: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.glass.background,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
alignItems: "center",
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
},
|
||||
cancelButtonText: {
|
||||
...typography.body,
|
||||
color: colors.textSecondary,
|
||||
fontWeight: "600",
|
||||
},
|
||||
confirmDeleteButton: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.error,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
gap: spacing.xs,
|
||||
},
|
||||
confirmDeleteButtonDisabled: {
|
||||
backgroundColor: colors.glass.background,
|
||||
opacity: 0.5,
|
||||
},
|
||||
confirmDeleteButtonText: {
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { View, StyleSheet, Text, ActivityIndicator, Platform } from "react-native";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useLocalSearchParams, router } from "expo-router";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import Animated, { FadeIn, FadeInUp } from "react-native-reanimated";
|
||||
import { supabase } from "../../src/lib/supabase";
|
||||
import { usePacksStore } from "../../src/store";
|
||||
import SwipeCard from "../../src/components/SwipeCard";
|
||||
import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui";
|
||||
import { colors, spacing, typography, radius, shadows } from "../../src/theme";
|
||||
@@ -13,9 +14,10 @@ export default function SwipeScreen() {
|
||||
const [questions, setQuestions] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const { enabledPackIds, fetchPacks } = usePacksStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetchQuestions();
|
||||
fetchPacks().then(() => fetchQuestions());
|
||||
}, [packId]);
|
||||
|
||||
const fetchQuestions = async () => {
|
||||
@@ -83,6 +85,34 @@ export default function SwipeScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
// Show "no packs enabled" message when not viewing a specific pack and no packs are enabled
|
||||
if (!packId && enabledPackIds.length === 0) {
|
||||
return (
|
||||
<GradientBackground showAccent>
|
||||
<View style={styles.centerContainer}>
|
||||
<Animated.View
|
||||
entering={FadeInUp.duration(600).springify()}
|
||||
style={styles.emptyContent}
|
||||
>
|
||||
<View style={styles.noPacksIconContainer}>
|
||||
<Ionicons name="layers-outline" size={64} color={colors.textTertiary} />
|
||||
</View>
|
||||
<Text style={styles.emptyTitle}>No packs enabled</Text>
|
||||
<Text style={styles.emptySubtitle}>
|
||||
Enable at least one question pack to start playing.
|
||||
</Text>
|
||||
<GlassButton
|
||||
onPress={() => router.push("/packs")}
|
||||
style={{ marginTop: spacing.lg }}
|
||||
>
|
||||
Choose Packs
|
||||
</GlassButton>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</GradientBackground>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentIndex >= questions.length) {
|
||||
return (
|
||||
<GradientBackground showAccent>
|
||||
@@ -236,6 +266,15 @@ const styles = StyleSheet.create({
|
||||
alignItems: "center",
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
noPacksIconContainer: {
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: 60,
|
||||
backgroundColor: colors.glass.background,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
emptyTitle: {
|
||||
...typography.title2,
|
||||
color: colors.text,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -17,6 +17,16 @@ import Animated, {
|
||||
FadeInUp,
|
||||
} from "react-native-reanimated";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
// Safely import Apple Authentication - may not be available in Expo Go
|
||||
let AppleAuthentication: typeof import("expo-apple-authentication") | null = null;
|
||||
|
||||
if (Platform.OS === "ios") {
|
||||
try {
|
||||
AppleAuthentication = require("expo-apple-authentication");
|
||||
} catch {
|
||||
AppleAuthentication = null;
|
||||
}
|
||||
}
|
||||
import { supabase } from "../../src/lib/supabase";
|
||||
import { useAuthStore } from "../../src/store";
|
||||
import { GradientBackground, GlassCard, GlassButton, GlassInput } from "../../src/components/ui";
|
||||
@@ -24,16 +34,71 @@ import { colors, spacing, radius, typography } from "../../src/theme";
|
||||
|
||||
type AuthMode = "magic-link" | "password";
|
||||
|
||||
interface PendingVerification {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export default function LoginScreen() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isMagicLinkSent, setIsMagicLinkSent] = useState(false);
|
||||
const [pendingVerification, setPendingVerification] = useState<PendingVerification | null>(null);
|
||||
const [isCheckingVerification, setIsCheckingVerification] = useState(false);
|
||||
const [authMode, setAuthMode] = useState<AuthMode>("magic-link");
|
||||
const [isSignUp, setIsSignUp] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isAppleAuthAvailable, setIsAppleAuthAvailable] = useState(false);
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
|
||||
// Check Apple Auth availability asynchronously
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "ios" && AppleAuthentication) {
|
||||
AppleAuthentication.isAvailableAsync().then((available) => {
|
||||
setIsAppleAuthAvailable(available);
|
||||
}).catch(() => {
|
||||
setIsAppleAuthAvailable(false);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Check if user has verified their email
|
||||
const checkVerificationStatus = useCallback(async () => {
|
||||
if (!pendingVerification) return;
|
||||
|
||||
setIsCheckingVerification(true);
|
||||
const { error: signInError } = await supabase.auth.signInWithPassword({
|
||||
email: pendingVerification.email,
|
||||
password: pendingVerification.password,
|
||||
});
|
||||
setIsCheckingVerification(false);
|
||||
|
||||
if (signInError) {
|
||||
if (signInError.message.includes("Email not confirmed")) {
|
||||
// Still not verified - show a message
|
||||
if (Platform.OS !== 'web') {
|
||||
Alert.alert("Not yet verified", "Please check your email and click the verification link.");
|
||||
}
|
||||
} else {
|
||||
Alert.alert("Error", signInError.message);
|
||||
}
|
||||
}
|
||||
// If successful, the auth state change will handle the redirect
|
||||
}, [pendingVerification]);
|
||||
|
||||
// Listen for auth state changes (e.g., when user verifies in another tab/browser)
|
||||
useEffect(() => {
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => {
|
||||
if (event === 'SIGNED_IN' && session) {
|
||||
// User verified and signed in - clear pending state
|
||||
setPendingVerification(null);
|
||||
}
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
}, []);
|
||||
|
||||
const showError = (message: string) => {
|
||||
setError(message);
|
||||
// Also show native alert on mobile
|
||||
@@ -100,20 +165,11 @@ export default function LoginScreen() {
|
||||
if (signUpError) {
|
||||
showError(signUpError.message);
|
||||
} else {
|
||||
// Show success message
|
||||
if (Platform.OS === 'web') {
|
||||
setError(null);
|
||||
Alert.alert(
|
||||
"Check your email",
|
||||
"We sent you a confirmation link to verify your email address."
|
||||
);
|
||||
} else {
|
||||
Alert.alert(
|
||||
"Check your email",
|
||||
"We sent you a confirmation link to verify your email address.",
|
||||
[{ text: "OK" }]
|
||||
);
|
||||
}
|
||||
// Show the verification pending screen
|
||||
setPendingVerification({
|
||||
email: email.trim(),
|
||||
password,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const { error: signInError } = await supabase.auth.signInWithPassword({
|
||||
@@ -124,44 +180,78 @@ export default function LoginScreen() {
|
||||
setIsLoading(false);
|
||||
|
||||
if (signInError) {
|
||||
showError(signInError.message);
|
||||
// Check if the error is due to unverified email
|
||||
if (signInError.message.includes("Email not confirmed")) {
|
||||
// Show the verification pending screen
|
||||
setPendingVerification({
|
||||
email: email.trim(),
|
||||
password,
|
||||
});
|
||||
} else {
|
||||
showError(signInError.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleSignIn = async () => {
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider: "google",
|
||||
options: {
|
||||
redirectTo: Linking.createURL("/(auth)/login"),
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
Alert.alert("Error", error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAppleSignIn = async () => {
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider: "apple",
|
||||
options: {
|
||||
redirectTo: Linking.createURL("/(auth)/login"),
|
||||
},
|
||||
});
|
||||
console.log("[Apple Sign In] Starting, isAppleAuthAvailable:", isAppleAuthAvailable, "AppleAuthentication:", !!AppleAuthentication);
|
||||
|
||||
if (error) {
|
||||
Alert.alert("Error", error.message);
|
||||
if (isAppleAuthAvailable && AppleAuthentication) {
|
||||
try {
|
||||
console.log("[Apple Sign In] Calling signInAsync...");
|
||||
const credential = await AppleAuthentication.signInAsync({
|
||||
requestedScopes: [
|
||||
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
|
||||
AppleAuthentication.AppleAuthenticationScope.EMAIL,
|
||||
],
|
||||
});
|
||||
console.log("[Apple Sign In] Got credential:", !!credential, "identityToken:", !!credential?.identityToken);
|
||||
|
||||
if (credential.identityToken) {
|
||||
const { error } = await supabase.auth.signInWithIdToken({
|
||||
provider: "apple",
|
||||
token: credential.identityToken,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.log("[Apple Sign In] Supabase error:", error.message);
|
||||
Alert.alert("Error", error.message);
|
||||
}
|
||||
} else {
|
||||
Alert.alert("Error", "No identity token received from Apple");
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.log("[Apple Sign In] Caught error:", e.code, e.message);
|
||||
if (e.code === "ERR_REQUEST_CANCELED") {
|
||||
// User canceled - do nothing
|
||||
} else {
|
||||
Alert.alert("Apple Sign In Error", `${e.code || 'Unknown'}: ${e.message || "Apple sign in failed"}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log("[Apple Sign In] Falling back to OAuth");
|
||||
// Fallback to web OAuth when native module unavailable
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider: "apple",
|
||||
options: {
|
||||
redirectTo: Linking.createURL("/(auth)/login"),
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
Alert.alert("Error", error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (isMagicLinkSent) {
|
||||
return (
|
||||
<GradientBackground showAccent>
|
||||
<View style={styles.container}>
|
||||
<View style={styles.centeredContainer}>
|
||||
<Animated.View
|
||||
entering={FadeInUp.duration(600).springify()}
|
||||
style={styles.content}
|
||||
style={styles.centeredContent}
|
||||
>
|
||||
<View style={styles.iconContainer}>
|
||||
<Ionicons name="mail" size={48} color={colors.primary} />
|
||||
@@ -184,6 +274,51 @@ export default function LoginScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
// Email verification pending screen
|
||||
if (pendingVerification) {
|
||||
return (
|
||||
<GradientBackground showAccent>
|
||||
<View style={styles.centeredContainer}>
|
||||
<Animated.View
|
||||
entering={FadeInUp.duration(600).springify()}
|
||||
style={styles.centeredContent}
|
||||
>
|
||||
<View style={styles.iconContainer}>
|
||||
<Ionicons name="mail-unread" size={48} color={colors.primary} />
|
||||
</View>
|
||||
<Text style={styles.title}>Verify your email</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
We sent a verification link to{'\n'}
|
||||
<Text style={styles.emailHighlight}>{pendingVerification.email}</Text>
|
||||
</Text>
|
||||
<Text style={styles.verificationHint}>
|
||||
Click the link in your email to verify your account, then tap the button below.
|
||||
</Text>
|
||||
<GlassButton
|
||||
onPress={checkVerificationStatus}
|
||||
loading={isCheckingVerification}
|
||||
disabled={isCheckingVerification}
|
||||
style={{ marginTop: spacing.lg }}
|
||||
>
|
||||
I've verified my email
|
||||
</GlassButton>
|
||||
<GlassButton
|
||||
variant="ghost"
|
||||
onPress={() => {
|
||||
setPendingVerification(null);
|
||||
setEmail(pendingVerification.email);
|
||||
setPassword("");
|
||||
}}
|
||||
style={{ marginTop: spacing.sm }}
|
||||
>
|
||||
Use a different email
|
||||
</GlassButton>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</GradientBackground>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<GradientBackground showAccent>
|
||||
<KeyboardAvoidingView
|
||||
@@ -335,23 +470,24 @@ export default function LoginScreen() {
|
||||
</View>
|
||||
|
||||
<View style={styles.socialButtons}>
|
||||
<GlassButton
|
||||
variant="secondary"
|
||||
onPress={handleGoogleSignIn}
|
||||
icon={<Ionicons name="logo-google" size={20} color={colors.text} />}
|
||||
style={styles.socialButton}
|
||||
>
|
||||
Google
|
||||
</GlassButton>
|
||||
|
||||
<GlassButton
|
||||
variant="secondary"
|
||||
onPress={handleAppleSignIn}
|
||||
icon={<Ionicons name="logo-apple" size={20} color={colors.text} />}
|
||||
style={styles.socialButton}
|
||||
>
|
||||
Apple
|
||||
</GlassButton>
|
||||
{isAppleAuthAvailable && AppleAuthentication ? (
|
||||
<AppleAuthentication.AppleAuthenticationButton
|
||||
buttonType={AppleAuthentication.AppleAuthenticationButtonType.SIGN_IN}
|
||||
buttonStyle={AppleAuthentication.AppleAuthenticationButtonStyle.WHITE}
|
||||
cornerRadius={12}
|
||||
style={styles.appleButton}
|
||||
onPress={handleAppleSignIn}
|
||||
/>
|
||||
) : (
|
||||
<GlassButton
|
||||
variant="secondary"
|
||||
onPress={handleAppleSignIn}
|
||||
icon={<Ionicons name="logo-apple" size={20} color={colors.text} />}
|
||||
style={styles.socialButton}
|
||||
>
|
||||
Apple
|
||||
</GlassButton>
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
@@ -365,6 +501,15 @@ const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
centeredContainer: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
centeredContent: {
|
||||
alignItems: "center",
|
||||
paddingHorizontal: spacing.lg,
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
@@ -401,6 +546,13 @@ const styles = StyleSheet.create({
|
||||
color: colors.primary,
|
||||
fontWeight: "600",
|
||||
},
|
||||
verificationHint: {
|
||||
...typography.subhead,
|
||||
color: colors.textSecondary,
|
||||
textAlign: "center",
|
||||
marginTop: spacing.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
@@ -487,4 +639,8 @@ const styles = StyleSheet.create({
|
||||
socialButton: {
|
||||
flex: 1,
|
||||
},
|
||||
appleButton: {
|
||||
flex: 1,
|
||||
height: 48,
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,20 @@
|
||||
// Web polyfills must be imported FIRST before any other React Native imports
|
||||
import "../src/polyfills/web";
|
||||
|
||||
// Initialize Sentry early to capture all errors
|
||||
import { initSentry, setUserContext, clearUserContext } from "../src/lib/sentry";
|
||||
initSentry();
|
||||
|
||||
// Import push notification utilities
|
||||
import {
|
||||
registerForPushNotificationsAsync,
|
||||
savePushToken,
|
||||
clearPushToken,
|
||||
setupNotificationResponseListener,
|
||||
getInitialNotification,
|
||||
handleNotificationResponse,
|
||||
} from "../src/lib/notifications";
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
|
||||
@@ -56,13 +70,41 @@ export default function RootLayout() {
|
||||
handleDeepLink(event.url);
|
||||
});
|
||||
|
||||
// Set up push notification response listener (for taps while app is running)
|
||||
const notificationSubscription = setupNotificationResponseListener();
|
||||
|
||||
// Check if app was opened from a notification tap (cold start)
|
||||
getInitialNotification().then((response) => {
|
||||
if (response) {
|
||||
handleNotificationResponse(response);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for auth changes
|
||||
const { data: { subscription: authSubscription } } = supabase.auth.onAuthStateChange(
|
||||
async (event, session) => {
|
||||
if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") {
|
||||
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
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
if (userId) {
|
||||
await clearPushToken(userId);
|
||||
}
|
||||
setUser(null);
|
||||
clearUserContext();
|
||||
// Clear React Query cache
|
||||
queryClient.clear();
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -70,6 +112,7 @@ export default function RootLayout() {
|
||||
return () => {
|
||||
linkingSubscription.remove();
|
||||
authSubscription.unsubscribe();
|
||||
notificationSubscription.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
Reference in new issue
Block a user