This commit is contained in:
Jamie Brunton committed 2025-12-28 21:56:20 +00:00
1 parent 6ff5bc62f2
commit adab288a3a
204 files changed
+11334 -14295

No files matched your search

+457 -92
View File
@@ -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
View File
@@ -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
View File
@@ -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,
},
+188 -59
View File
@@ -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
View File
@@ -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,
+72 -1
View File
@@ -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
View File
@@ -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",
},
});
+41 -2
View File
@@ -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,
+212 -56
View File
@@ -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,
},
});
+43
View File
@@ -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();
};
}, []);