This commit is contained in:
jjbrunton committed 2025-12-28 16:51:32 +00:00
1 parent 4d53d83769
commit 6ff5bc62f2
17 files changed
+2329 -866

No files matched your search

+31 -12
View File
@@ -1,7 +1,22 @@
import { Tabs, Redirect } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { View, ActivityIndicator, StyleSheet } from "react-native";
import { View, ActivityIndicator, StyleSheet, Platform } from "react-native";
import { BlurView } from "expo-blur";
import { useAuthStore, useMatchStore } from "../../src/store";
import { colors, blur } from "../../src/theme";
function TabBarBackground() {
if (Platform.OS === 'ios') {
return (
<BlurView
intensity={blur.medium}
tint="dark"
style={StyleSheet.absoluteFill}
/>
);
}
return <View style={[StyleSheet.absoluteFill, { backgroundColor: colors.glass.backgroundLight }]} />;
}
export default function AppLayout() {
const { isAuthenticated, isLoading } = useAuthStore();
@@ -11,7 +26,7 @@ export default function AppLayout() {
if (isLoading) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#e94560" />
<ActivityIndicator size="large" color={colors.primary} />
</View>
);
}
@@ -25,19 +40,22 @@ export default function AppLayout() {
<Tabs
screenOptions={{
headerShown: false,
tabBarBackground: () => <TabBarBackground />,
tabBarStyle: {
backgroundColor: "#16213e",
borderTopColor: "#0f3460",
position: 'absolute',
backgroundColor: 'transparent',
borderTopColor: colors.glass.border,
borderTopWidth: 1,
paddingTop: 8,
paddingBottom: 8,
height: 60,
paddingBottom: Platform.OS === 'ios' ? 28 : 8,
height: Platform.OS === 'ios' ? 88 : 64,
elevation: 0,
},
tabBarActiveTintColor: "#e94560",
tabBarInactiveTintColor: "#666",
tabBarActiveTintColor: colors.primary,
tabBarInactiveTintColor: colors.textTertiary,
tabBarLabelStyle: {
fontSize: 12,
fontWeight: "500",
fontSize: 11,
fontWeight: "600",
},
}}
>
@@ -77,8 +95,9 @@ export default function AppLayout() {
),
tabBarBadge: newMatchesCount > 0 ? newMatchesCount : undefined,
tabBarBadgeStyle: {
backgroundColor: "#e94560",
backgroundColor: colors.primary,
fontSize: 10,
fontWeight: '600',
},
}}
/>
@@ -112,6 +131,6 @@ const styles = StyleSheet.create({
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: "#1a1a2e",
backgroundColor: colors.background,
},
});
+258 -200
View File
@@ -1,13 +1,18 @@
import { useState, useEffect, useRef } from "react";
import { View, Text, StyleSheet, FlatList, TextInput, TouchableOpacity, KeyboardAvoidingView, Platform, Image, ActivityIndicator, Alert, AppState } from "react-native";
import { View, Text, StyleSheet, FlatList, TextInput, TouchableOpacity, KeyboardAvoidingView, Platform, Image, ActivityIndicator, Alert } from "react-native";
import { useLocalSearchParams, useRouter, Stack } from "expo-router";
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 * 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";
type Message = Database["public"]["Tables"]["messages"]["Row"];
@@ -27,7 +32,6 @@ export default function ChatScreen() {
useEffect(() => {
if (!matchId) return;
// Fetch match context (question and responses)
const fetchMatch = async () => {
const { data } = await supabase
.from("matches")
@@ -36,7 +40,6 @@ export default function ChatScreen() {
.single();
if (data) {
// Fetch responses for both users in this couple for this question
const { data: responses } = await supabase
.from("responses")
.select("*, profiles(name)")
@@ -48,9 +51,8 @@ export default function ChatScreen() {
};
fetchMatch();
// Fetch initial messages and mark as read
const fetchMessagesAndMarkRead = async () => {
const { data, error } = await supabase
const { data } = await supabase
.from("messages")
.select("*")
.eq("match_id", matchId)
@@ -59,7 +61,6 @@ export default function ChatScreen() {
if (data) {
setMessages(data);
// Identify unread messages from partner
const unreadIds = data
.filter(m => m.user_id !== user?.id && !m.read_at)
.map(m => m.id);
@@ -70,7 +71,6 @@ export default function ChatScreen() {
.update({ read_at: new Date().toISOString() })
.in("id", unreadIds);
// Optimistically update local state
setMessages(prev => prev.map(m =>
unreadIds.includes(m.id) ? { ...m, read_at: new Date().toISOString() } : m
));
@@ -80,7 +80,6 @@ export default function ChatScreen() {
fetchMessagesAndMarkRead();
// Subscribe to new messages, updates (read receipts), and typing
const channel = supabase
.channel(`chat:${matchId}`)
.on(
@@ -94,7 +93,6 @@ export default function ChatScreen() {
async (payload) => {
const newMessage = payload.new as Message;
// If receiving a message from partner, mark as read immediately since we are in the chat
if (newMessage.user_id !== user?.id) {
await supabase
.from("messages")
@@ -104,7 +102,7 @@ export default function ChatScreen() {
}
setMessages((prev) => [newMessage, ...prev]);
setPartnerTyping(false); // Stop typing indicator if they sent a message
setPartnerTyping(false);
}
)
.on(
@@ -126,7 +124,6 @@ export default function ChatScreen() {
if (payload.payload.userId !== user?.id) {
setPartnerTyping(true);
// Auto-hide typing indicator after 3s
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
typingTimeoutRef.current = setTimeout(() => {
setPartnerTyping(false);
@@ -242,28 +239,137 @@ export default function ChatScreen() {
};
const revealMessage = async (messageId: string) => {
// Optimistic update
setMessages(prev => prev.map(m =>
m.id === messageId ? { ...m, media_viewed_at: new Date().toISOString() } : m
));
const { error } = await supabase
await supabase
.from("messages")
.update({ media_viewed_at: new Date().toISOString() })
.eq("id", messageId);
if (error) {
console.error("Failed to mark as viewed", error);
}
};
const renderMessage = ({ item }: { item: Message }) => {
const renderMessage = ({ item, index }: { item: Message; index: number }) => {
const isMe = item.user_id === user?.id;
return (
<View style={[styles.messageRow, isMe ? styles.myMessageRow : styles.theirMessageRow]}>
<View style={[styles.bubble, isMe ? styles.myBubble : styles.theirBubble]}>
{item.media_path ? (
<Animated.View
entering={FadeInUp.delay(index * 30).duration(200)}
style={[styles.messageRow, isMe ? styles.myMessageRow : styles.theirMessageRow]}
>
{isMe ? (
<LinearGradient
colors={gradients.primary as [string, string]}
style={[styles.bubble, styles.myBubble]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<MessageContent item={item} isMe={isMe} revealMessage={revealMessage} />
</LinearGradient>
) : (
<View style={[styles.bubble, styles.theirBubble]}>
<MessageContent item={item} isMe={isMe} revealMessage={revealMessage} />
</View>
)}
</Animated.View>
);
};
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}
>
{/* Sticky top bar with question and answers */}
{match?.question && (
<GlassCard style={styles.stickyTopBar} noPadding>
<View style={styles.stickyContent}>
<Text style={styles.stickyQuestionText} numberOfLines={2}>
{match.question.text}
{match.question.partner_text ? ` & ${match.question.partner_text}` : ""}
</Text>
{match.responses && match.responses.length > 0 && (
<View style={styles.stickyAnswersContainer}>
{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}
</Text>
</View>
))}
</View>
)}
</View>
</GlassCard>
)}
<FlatList
ref={flatListRef}
data={messages}
renderItem={renderMessage}
keyExtractor={(item) => item.id}
inverted
contentContainerStyle={styles.listContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
ListHeaderComponent={
partnerTyping ? (
<Animated.View
entering={FadeIn.duration(200)}
style={styles.typingContainer}
>
<Text style={styles.typingText}>Partner is typing...</Text>
</Animated.View>
) : null
}
/>
{/* 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>
)}
</KeyboardAvoidingView>
</GradientBackground>
);
}
function MessageContent({
item,
isMe,
revealMessage,
}: {
item: Message;
isMe: boolean;
revealMessage: (id: string) => void;
}) {
if (item.media_path) {
return (
<View>
<Image
source={{ uri: item.media_path }}
@@ -277,144 +383,101 @@ export default function ChatScreen() {
onPress={() => revealMessage(item.id)}
>
<View style={styles.revealContent}>
<Ionicons name="eye-off-outline" size={32} color="#fff" />
<Ionicons name="eye-off-outline" size={28} color={colors.text} />
<Text style={styles.revealText}>Tap to reveal</Text>
</View>
</TouchableOpacity>
)}
<MessageMeta item={item} isMe={isMe} />
</View>
) : (
);
}
return (
<>
<Text style={styles.messageText}>{item.content}</Text>
)}
<MessageMeta item={item} isMe={isMe} />
</>
);
}
function MessageMeta({ item, isMe }: { item: Message; isMe: boolean }) {
return (
<View style={styles.metaContainer}>
<Text style={[styles.timestamp, isMe ? styles.myTimestamp : styles.theirTimestamp]}>
<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-outline" : "checkmark-outline"}
name={item.read_at ? "checkmark-done" : "checkmark"}
size={14}
color={item.read_at ? "#4cd137" : "rgba(255,255,255,0.7)"}
color={item.read_at ? colors.success : "rgba(255,255,255,0.6)"}
style={styles.readIcon}
/>
)}
</View>
</View>
</View>
);
};
return (
<View style={styles.container}>
<Stack.Screen options={{
title: "Chat",
headerTitle: match?.question?.text ? () => (
<View style={styles.headerTitleContainer}>
<Text style={styles.headerTitle} numberOfLines={2}>
{match.question.text}
{match.question.partner_text ? ` & ${match.question.partner_text}` : ""}
</Text>
{match.responses && match.responses.length > 0 && (
<View style={styles.answersContainer}>
{match.responses.map((response) => (
<Text key={response.user_id} style={styles.answerText}>
{response.profiles?.name || 'You'}: {response.answer}
</Text>
))}
</View>
)}
</View>
) : "Chat",
headerStyle: { backgroundColor: "#16213e" },
headerTintColor: "#fff",
headerBackTitleVisible: false,
}} />
<KeyboardAvoidingView
style={styles.keyboardAvoiding}
behavior={Platform.OS === "ios" ? "padding" : undefined}
keyboardVerticalOffset={Platform.OS === "ios" ? 90 : 0}
>
{/* Sticky top bar with question and answers */}
{match?.question && (
<View style={styles.stickyTopBar}>
<Text style={styles.stickyQuestionText}>
{match.question.text}
{match.question.partner_text ? ` & ${match.question.partner_text}` : ""}
</Text>
{match.responses && match.responses.length > 0 && (
<View style={styles.stickyAnswersContainer}>
{match.responses.map((response) => (
<Text key={response.user_id} style={styles.stickyAnswerText}>
{response.user_id === user?.id ? 'You' : (response.profiles?.name || 'Partner')}: {response.answer}
</Text>
))}
</View>
)}
</View>
)}
<FlatList
ref={flatListRef}
data={messages}
renderItem={renderMessage}
keyExtractor={(item) => item.id}
inverted
contentContainerStyle={styles.listContent}
keyboardShouldPersistTaps="handled"
ListHeaderComponent={
partnerTyping ? (
<View style={styles.typingContainer}>
<Text style={styles.typingText}>Partner is typing...</Text>
</View>
) : null
}
/>
function InputBar({
inputText,
uploading,
onChangeText,
onSend,
onPickImage,
}: {
inputText: string;
uploading: boolean;
onChangeText: (text: string) => void;
onSend: () => void;
onPickImage: () => void;
}) {
return (
<View style={styles.inputContainer}>
<TouchableOpacity onPress={handlePickImage} disabled={uploading} style={styles.attachButton}>
<TouchableOpacity onPress={onPickImage} disabled={uploading} style={styles.attachButton}>
{uploading ? (
<ActivityIndicator color="#e94560" size="small" />
<ActivityIndicator color={colors.primary} size="small" />
) : (
<Ionicons name="image-outline" size={24} color="#e94560" />
<Ionicons name="image-outline" size={24} color={colors.primary} />
)}
</TouchableOpacity>
<View style={styles.inputFieldWrapper}>
<TextInput
style={styles.input}
value={inputText}
onChangeText={handleTyping}
onChangeText={onChangeText}
placeholder="Type a message..."
placeholderTextColor="#666"
placeholderTextColor={colors.textTertiary}
multiline
/>
</View>
<TouchableOpacity onPress={handleSend} disabled={!inputText.trim()} style={styles.sendButton}>
<TouchableOpacity
onPress={onSend}
disabled={!inputText.trim()}
style={[styles.sendButton, inputText.trim() && styles.sendButtonActive]}
>
<Ionicons
name="send"
size={24}
color={inputText.trim() ? "#e94560" : "#666"}
size={20}
color={inputText.trim() ? colors.text : colors.textTertiary}
/>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#1a1a2e",
},
keyboardAvoiding: {
flex: 1,
},
listContent: {
padding: 16,
padding: spacing.md,
paddingBottom: spacing.lg,
},
messageRow: {
marginBottom: 16,
marginBottom: spacing.sm,
flexDirection: "row",
},
myMessageRow: {
@@ -424,148 +487,143 @@ const styles = StyleSheet.create({
justifyContent: "flex-start",
},
bubble: {
maxWidth: "75%",
padding: 12,
borderRadius: 20,
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
maxWidth: "78%",
padding: spacing.md,
borderRadius: radius.xl,
...shadows.sm,
},
myBubble: {
backgroundColor: "#e94560",
borderBottomRightRadius: 4,
borderBottomRightRadius: radius.xs,
},
theirBubble: {
backgroundColor: "#16213e",
backgroundColor: colors.glass.backgroundLight,
borderWidth: 1,
borderColor: "#0f3460",
borderBottomLeftRadius: 4,
borderColor: colors.glass.border,
borderBottomLeftRadius: radius.xs,
},
messageText: {
color: "#fff",
fontSize: 16,
lineHeight: 22,
...typography.body,
color: colors.text,
},
messageImage: {
width: 200,
height: 200,
borderRadius: 12,
backgroundColor: "rgba(0,0,0,0.2)",
borderRadius: radius.md,
backgroundColor: colors.glass.background,
},
metaContainer: {
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-end",
marginTop: 4,
marginTop: spacing.xs,
},
timestamp: {
fontSize: 10,
},
myTimestamp: {
color: "rgba(255, 255, 255, 0.7)",
},
theirTimestamp: {
color: "rgba(255, 255, 255, 0.4)",
...typography.caption2,
color: "rgba(255, 255, 255, 0.6)",
},
readIcon: {
marginLeft: 4,
marginLeft: spacing.xs,
},
inputWrapper: {
borderTopWidth: 1,
borderTopColor: colors.glass.border,
},
inputWrapperAndroid: {
backgroundColor: colors.glass.backgroundLight,
},
inputContainer: {
flexDirection: "row",
alignItems: "center",
padding: 12,
paddingBottom: Platform.OS === "ios" ? 34 : 12,
backgroundColor: "#16213e",
borderTopWidth: 1,
borderTopColor: "#0f3460",
alignItems: "flex-end",
padding: spacing.sm,
paddingBottom: Platform.OS === "ios" ? spacing.xl : spacing.sm,
},
attachButton: {
padding: 10,
padding: spacing.sm,
marginBottom: spacing.xs,
},
inputFieldWrapper: {
flex: 1,
backgroundColor: colors.glass.background,
borderRadius: radius.xl,
borderWidth: 1,
borderColor: colors.glass.border,
marginHorizontal: spacing.sm,
},
input: {
flex: 1,
backgroundColor: "#0f3460",
borderRadius: 24,
paddingHorizontal: 16,
paddingVertical: 10,
color: "#fff",
maxHeight: 120,
marginHorizontal: 8,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
color: colors.text,
fontSize: 16,
maxHeight: 100,
},
sendButton: {
padding: 10,
},
headerTitleContainer: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.glass.background,
justifyContent: "center",
alignItems: "center",
maxWidth: 200,
marginBottom: spacing.xs,
},
headerTitle: {
color: "#fff",
fontWeight: "bold",
fontSize: 16,
textAlign: "center",
},
answersContainer: {
marginTop: 4,
alignItems: "center",
},
answerText: {
color: "rgba(255,255,255,0.8)",
fontSize: 12,
marginBottom: 2,
sendButtonActive: {
backgroundColor: colors.primary,
},
typingContainer: {
paddingHorizontal: 16,
paddingVertical: 8,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
},
typingText: {
color: "#666",
...typography.caption1,
color: colors.textTertiary,
fontStyle: "italic",
fontSize: 12,
},
revealOverlay: {
...StyleSheet.absoluteFillObject,
justifyContent: "center",
alignItems: "center",
backgroundColor: "rgba(0,0,0,0.3)",
borderRadius: 12,
backgroundColor: "rgba(0,0,0,0.4)",
borderRadius: radius.md,
},
revealContent: {
alignItems: "center",
backgroundColor: "rgba(0,0,0,0.6)",
padding: 12,
borderRadius: 12,
padding: spacing.md,
borderRadius: radius.md,
},
revealText: {
color: "#fff",
fontSize: 12,
fontWeight: "bold",
marginTop: 4,
...typography.caption1,
color: colors.text,
fontWeight: "600",
marginTop: spacing.xs,
},
stickyTopBar: {
backgroundColor: "#16213e",
borderBottomWidth: 1,
borderBottomColor: "#0f3460",
padding: 16,
paddingTop: 12,
paddingBottom: 12,
marginHorizontal: spacing.md,
marginTop: spacing.sm,
marginBottom: spacing.xs,
},
stickyContent: {
padding: spacing.md,
},
stickyQuestionText: {
color: "#fff",
fontSize: 16,
fontWeight: "bold",
...typography.subhead,
color: colors.text,
fontWeight: "600",
textAlign: "center",
marginBottom: 8,
marginBottom: spacing.sm,
},
stickyAnswersContainer: {
alignItems: "center",
flexDirection: "row",
justifyContent: "center",
gap: spacing.sm,
},
answerBadge: {
backgroundColor: colors.primaryLight,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: radius.sm,
},
stickyAnswerText: {
color: "rgba(255,255,255,0.8)",
fontSize: 14,
marginBottom: 4,
}
...typography.caption1,
color: colors.text,
},
});
+164 -75
View File
@@ -1,8 +1,12 @@
import { useEffect } from "react";
import { View, Text, StyleSheet, TouchableOpacity, ScrollView } from "react-native";
import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Platform } 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 { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme";
export default function HomeScreen() {
const { user, partner } = useAuthStore();
@@ -17,10 +21,20 @@ export default function HomeScreen() {
const recentMatches = matches.slice(0, 3);
return (
<ScrollView style={styles.container}>
<View style={styles.header}>
<GradientBackground>
<ScrollView
style={styles.container}
contentContainerStyle={styles.contentContainer}
showsVerticalScrollIndicator={false}
>
{/* Header */}
<Animated.View
entering={FadeInDown.delay(100).duration(500)}
style={styles.header}
>
<View>
<Text style={styles.greeting}>
Hey, {user?.name || "there"} 👋
Hey, {user?.name || "there"}
</Text>
{partner && (
<Text style={styles.partnerText}>
@@ -28,30 +42,56 @@ export default function HomeScreen() {
</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 */}
<View style={styles.statsContainer}>
<View style={styles.statCard}>
<Ionicons name="heart" size={24} color="#e94560" />
<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>
<View style={styles.statCard}>
<Ionicons name="layers" size={24} color="#e94560" />
<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>
<View style={styles.statCard}>
<Ionicons name="sparkles" size={24} color="#e94560" />
<Text style={styles.statNumber}>{newMatchesCount}</Text>
<Text style={styles.statLabel}>New</Text>
</View>
</View>
</GlassCard>
</Animated.View>
{/* Start Playing CTA */}
<Animated.View entering={FadeInDown.delay(300).duration(500)}>
<TouchableOpacity
style={styles.ctaCard}
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>
@@ -59,148 +99,197 @@ export default function HomeScreen() {
Swipe through questions and discover what you both enjoy
</Text>
</View>
<Ionicons name="arrow-forward" size={24} color="#fff" />
<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.section}>
<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")}>
<Text style={styles.seeAll}>See all</Text>
</TouchableOpacity>
</View>
{recentMatches.map((match) => (
<View key={match.id} style={styles.matchItem}>
{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.matchItem}>
<View style={styles.matchIcon}>
<Ionicons name="heart" size={20} color="#e94560" />
<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} />}
</View>
</GlassCard>
</TouchableOpacity>
</Animated.View>
))}
</View>
</Animated.View>
)}
{/* Bottom spacing for tab bar */}
<View style={styles.bottomSpacer} />
</ScrollView>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#1a1a2e",
},
contentContainer: {
paddingBottom: Platform.OS === 'ios' ? 100 : 80,
},
header: {
paddingTop: 60,
paddingHorizontal: 24,
paddingBottom: 24,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.lg,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
greeting: {
fontSize: 28,
fontWeight: "bold",
color: "#fff",
...typography.title1,
color: colors.text,
},
partnerText: {
fontSize: 14,
color: "#888",
marginTop: 4,
...typography.subhead,
color: colors.textSecondary,
marginTop: spacing.xs,
},
profileButton: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.primary,
justifyContent: 'center',
alignItems: 'center',
...shadows.md,
},
profileButtonText: {
...typography.headline,
color: colors.text,
},
statsContainer: {
flexDirection: "row",
paddingHorizontal: 24,
gap: 12,
paddingHorizontal: spacing.lg,
gap: spacing.sm,
},
statCard: {
flex: 1,
backgroundColor: "#16213e",
borderRadius: 16,
padding: 16,
alignItems: "center",
borderWidth: 1,
borderColor: "#0f3460",
paddingVertical: spacing.md,
},
statIconContainer: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.primaryLight,
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.sm,
},
statIconHighlight: {
backgroundColor: colors.primary,
},
statNumber: {
fontSize: 24,
fontWeight: "bold",
color: "#fff",
marginTop: 8,
...typography.title2,
color: colors.text,
},
statLabel: {
fontSize: 12,
color: "#888",
marginTop: 4,
...typography.caption1,
color: colors.textSecondary,
marginTop: spacing.xs,
},
ctaCard: {
margin: 24,
backgroundColor: "#e94560",
borderRadius: 16,
padding: 20,
margin: spacing.lg,
borderRadius: radius.xl,
padding: spacing.lg,
flexDirection: "row",
alignItems: "center",
...shadows.lg,
},
ctaContent: {
flex: 1,
},
ctaTitle: {
fontSize: 18,
fontWeight: "bold",
color: "#fff",
...typography.title3,
color: colors.text,
},
ctaSubtitle: {
fontSize: 14,
...typography.subhead,
color: "rgba(255,255,255,0.8)",
marginTop: 4,
marginTop: spacing.xs,
},
ctaIconContainer: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: "rgba(255,255,255,0.2)",
justifyContent: "center",
alignItems: "center",
marginLeft: spacing.md,
},
section: {
paddingHorizontal: 24,
marginBottom: 24,
paddingHorizontal: spacing.lg,
marginTop: spacing.sm,
},
sectionHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 16,
marginBottom: spacing.md,
},
sectionTitle: {
fontSize: 18,
fontWeight: "600",
color: "#fff",
...typography.headline,
color: colors.text,
},
seeAll: {
color: "#e94560",
fontSize: 14,
...typography.subhead,
color: colors.primary,
},
matchItem: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#16213e",
borderRadius: 12,
padding: 16,
marginBottom: 8,
borderWidth: 1,
borderColor: "#0f3460",
marginBottom: spacing.sm,
},
matchIcon: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "rgba(233, 69, 96, 0.2)",
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: colors.primaryLight,
justifyContent: "center",
alignItems: "center",
marginRight: 12,
marginRight: spacing.md,
},
matchText: {
flex: 1,
color: "#fff",
fontSize: 14,
...typography.body,
color: colors.text,
},
newBadge: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: "#e94560",
backgroundColor: colors.primary,
},
bottomSpacer: {
height: spacing.lg,
},
});
+160 -100
View File
@@ -1,27 +1,28 @@
import { View, Text, StyleSheet, FlatList, RefreshControl, TouchableOpacity } from "react-native";
import { View, Text, StyleSheet, FlatList, RefreshControl, TouchableOpacity, ActivityIndicator, Platform } from "react-native";
import { useMatchStore, useAuthStore } from "../../src/store";
import { useEffect } from "react";
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import Animated, { FadeInDown, FadeInRight } from "react-native-reanimated";
import { GradientBackground, GlassCard } from "../../src/components/ui";
import { colors, spacing, typography, radius } from "../../src/theme";
export default function MatchesScreen() {
const { matches, fetchMatches, markAllAsSeen } = useMatchStore();
const { user } = useAuthStore();
const router = useRouter();
useEffect(() => {
fetchMatches();
}, []);
// Mark all matches as seen when matches are loaded
useEffect(() => {
if (matches.length > 0) {
markAllAsSeen();
}
}, [matches.length]);
const router = useRouter();
const renderItem = ({ item }: { item: any }) => {
const renderItem = ({ item, index }: { item: any; index: number }) => {
const userResponse = item.responses?.find((r: any) => r.user_id === user?.id);
const partnerResponse = item.responses?.find((r: any) => r.user_id !== user?.id);
@@ -32,31 +33,41 @@ export default function MatchesScreen() {
const userTime = new Date(userResponse.created_at).getTime();
const partnerTime = new Date(partnerResponse.created_at).getTime();
// If user answered after partner, they saw the partner_text
if (userTime > partnerTime) {
userText = item.question.partner_text;
partnerText = item.question.text;
}
}
const isYesYes = item.match_type === "yes_yes";
return (
<Animated.View entering={FadeInRight.delay(index * 50).duration(300)}>
<TouchableOpacity
style={styles.matchCard}
onPress={() => router.push(`/chat/${item.id}`)}
activeOpacity={0.7}
>
<GlassCard style={styles.matchCard}>
<View style={styles.iconContainer}>
<Ionicons name="sparkles" size={24} color="#e94560" />
<Ionicons
name={isYesYes ? "heart" : "heart-half"}
size={24}
color={colors.primary}
/>
</View>
<View style={styles.content}>
<Text style={styles.questionText}>{userText}</Text>
<Text style={styles.questionText} numberOfLines={2}>
{userText}
</Text>
{item.question.partner_text && (
<Text style={styles.partnerText}>Partner: {partnerText}</Text>
<Text style={styles.partnerText} numberOfLines={1}>
Partner: {partnerText}
</Text>
)}
<View style={styles.tagContainer}>
<View style={styles.tag}>
<Text style={styles.tagText}>
{item.match_type === "yes_yes" ? "Review: YES + YES" : "Review: YES + MAYBE"}
<View style={styles.metaContainer}>
<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}>
@@ -64,129 +75,178 @@ export default function MatchesScreen() {
</Text>
</View>
</View>
<View style={styles.chevronContainer}>
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
</View>
</GlassCard>
</TouchableOpacity>
</Animated.View>
);
};
if (!user) {
return (
<View style={styles.container}>
<GradientBackground>
<View style={styles.loadingContainer}>
<ActivityIndicator color="#e94560" />
</View>
<ActivityIndicator color={colors.primary} size="large" />
</View>
</GradientBackground>
);
}
return (
<GradientBackground>
<View style={styles.container}>
<View style={styles.header}>
<Animated.View
entering={FadeInDown.duration(400)}
style={styles.header}
>
<Text style={styles.title}>Your Matches</Text>
</View>
<Text style={styles.subtitle}>
{matches.length} {matches.length === 1 ? 'match' : 'matches'} found
</Text>
</Animated.View>
<FlatList
data={matches}
renderItem={renderItem}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl refreshing={false} onRefresh={fetchMatches} tintColor="#e94560" />
<RefreshControl
refreshing={false}
onRefresh={fetchMatches}
tintColor={colors.primary}
colors={[colors.primary]}
/>
}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No matches yet. Keep swiping!</Text>
<Animated.View
entering={FadeInDown.delay(200).duration(400)}
style={styles.emptyContainer}
>
<View style={styles.emptyIconContainer}>
<Ionicons name="heart-outline" size={48} color={colors.textTertiary} />
</View>
<Text style={styles.emptyTitle}>No matches yet</Text>
<Text style={styles.emptyText}>
Keep swiping to find things you both enjoy!
</Text>
</Animated.View>
}
/>
</View>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#1a1a2e",
},
header: {
paddingTop: 60,
paddingHorizontal: 24,
paddingBottom: 24,
borderBottomWidth: 1,
borderBottomColor: "#16213e",
},
title: {
fontSize: 28,
fontWeight: "bold",
color: "#fff",
},
list: {
padding: 24,
},
matchCard: {
flexDirection: "row",
backgroundColor: "#16213e",
borderRadius: 16,
padding: 16,
marginBottom: 16,
borderWidth: 1,
borderColor: "#0f3460",
},
iconContainer: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: "rgba(233, 69, 96, 0.1)",
justifyContent: "center",
alignItems: "center",
marginRight: 16,
},
content: {
flex: 1,
},
questionText: {
fontSize: 16,
fontWeight: "600",
color: "#fff",
marginBottom: 8,
lineHeight: 22,
},
tagContainer: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
tag: {
backgroundColor: "rgba(233, 69, 96, 0.2)",
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 4,
},
tagText: {
color: "#e94560",
fontSize: 12,
fontWeight: "bold",
},
date: {
color: "#666",
fontSize: 12,
},
emptyContainer: {
padding: 40,
alignItems: "center",
},
emptyText: {
color: "#666",
fontSize: 16,
},
partnerText: {
fontSize: 14,
color: "#aaa",
fontStyle: "italic",
marginBottom: 8,
},
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
header: {
paddingTop: 60,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.md,
},
title: {
...typography.title1,
color: colors.text,
},
subtitle: {
...typography.subhead,
color: colors.textSecondary,
marginTop: spacing.xs,
},
list: {
padding: spacing.lg,
paddingBottom: Platform.OS === 'ios' ? 120 : 100,
},
matchCard: {
flexDirection: "row",
alignItems: "center",
marginBottom: spacing.sm,
},
iconContainer: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: colors.primaryLight,
justifyContent: "center",
alignItems: "center",
marginRight: spacing.md,
},
content: {
flex: 1,
},
questionText: {
...typography.body,
color: colors.text,
fontWeight: "600",
marginBottom: spacing.xs,
},
partnerText: {
...typography.subhead,
color: colors.textSecondary,
fontStyle: "italic",
marginBottom: spacing.sm,
},
metaContainer: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
tag: {
backgroundColor: colors.glass.background,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: radius.xs,
},
tagHighlight: {
backgroundColor: colors.primaryLight,
},
tagText: {
...typography.caption2,
color: colors.textSecondary,
fontWeight: "600",
},
tagTextHighlight: {
color: colors.primary,
},
date: {
...typography.caption1,
color: colors.textTertiary,
},
chevronContainer: {
marginLeft: spacing.sm,
},
emptyContainer: {
alignItems: "center",
paddingVertical: spacing.xxl,
},
emptyIconContainer: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: colors.glass.background,
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.md,
},
emptyTitle: {
...typography.title3,
color: colors.text,
marginBottom: spacing.xs,
},
emptyText: {
...typography.body,
color: colors.textSecondary,
textAlign: "center",
},
});
+129 -44
View File
@@ -1,8 +1,11 @@
import { View, Text, StyleSheet, FlatList, TouchableOpacity, Image, Switch } from "react-native";
import { View, Text, StyleSheet, FlatList, TouchableOpacity, Switch, RefreshControl, Platform } from "react-native";
import { usePacksStore } 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 { GradientBackground, GlassCard } from "../../src/components/ui";
import { colors, spacing, typography, radius } from "../../src/theme";
export default function PacksScreen() {
const { packs, enabledPackIds, togglePack, isLoading, fetchPacks } = usePacksStore();
@@ -11,14 +14,20 @@ export default function PacksScreen() {
fetchPacks();
}, []);
const renderItem = ({ item }: { item: any }) => (
<View style={styles.card}>
const enabledCount = packs.filter(p => enabledPackIds.includes(p.id)).length;
const renderItem = ({ item, index }: { item: any; index: number }) => {
const isEnabled = enabledPackIds.includes(item.id);
return (
<Animated.View entering={FadeInRight.delay(index * 50).duration(300)}>
<GlassCard style={styles.card}>
<TouchableOpacity
style={styles.cardContent}
activeOpacity={0.7}
onPress={() => router.push(`/pack/${item.id}`)}
>
<View style={styles.iconContainer}>
<View style={[styles.iconContainer, isEnabled && styles.iconContainerActive]}>
<Text style={styles.emoji}>{item.icon || "📦"}</Text>
</View>
<View style={styles.content}>
@@ -26,6 +35,7 @@ export default function PacksScreen() {
<Text style={styles.name}>{item.name}</Text>
{item.is_premium && (
<View style={styles.badge}>
<Ionicons name="star" size={10} color={colors.text} />
<Text style={styles.badgeText}>PRO</Text>
</View>
)}
@@ -33,64 +43,103 @@ export default function PacksScreen() {
<Text style={styles.description} numberOfLines={2}>
{item.description}
</Text>
{item.question_count && (
<Text style={styles.questionCount}>
{item.question_count} questions
</Text>
)}
</View>
</TouchableOpacity>
<View style={styles.switchContainer}>
<Switch
value={enabledPackIds.includes(item.id)}
value={isEnabled}
onValueChange={() => togglePack(item.id)}
trackColor={{ false: "#767577", true: "#e94560" }}
thumbColor={"#fff"}
trackColor={{
false: colors.glass.background,
true: colors.primary,
}}
thumbColor={colors.text}
ios_backgroundColor={colors.glass.background}
/>
</View>
</GlassCard>
</Animated.View>
);
};
return (
<GradientBackground>
<View style={styles.container}>
<View style={styles.header}>
<Animated.View
entering={FadeInDown.duration(400)}
style={styles.header}
>
<Text style={styles.title}>Question Packs</Text>
</View>
<Text style={styles.subtitle}>
{enabledCount} of {packs.length} packs enabled
</Text>
</Animated.View>
<FlatList
data={packs}
renderItem={renderItem}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={isLoading}
onRefresh={fetchPacks}
tintColor={colors.primary}
colors={[colors.primary]}
/>
}
ListEmptyComponent={
<Animated.View
entering={FadeInDown.delay(200).duration(400)}
style={styles.emptyContainer}
>
<View style={styles.emptyIconContainer}>
<Ionicons name="layers-outline" size={48} color={colors.textTertiary} />
</View>
<Text style={styles.emptyTitle}>No packs available</Text>
<Text style={styles.emptyText}>
Check back later for new question packs!
</Text>
</Animated.View>
}
/>
</View>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#1a1a2e",
},
header: {
paddingTop: 60,
paddingHorizontal: 24,
paddingBottom: 24,
borderBottomWidth: 1,
borderBottomColor: "#16213e",
paddingHorizontal: spacing.lg,
paddingBottom: spacing.md,
},
title: {
fontSize: 28,
fontWeight: "bold",
color: "#fff",
...typography.title1,
color: colors.text,
},
subtitle: {
...typography.subhead,
color: colors.textSecondary,
marginTop: spacing.xs,
},
list: {
padding: 24,
padding: spacing.lg,
paddingBottom: Platform.OS === 'ios' ? 120 : 100,
},
card: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#16213e",
borderRadius: 16,
padding: 16,
marginBottom: 16,
borderWidth: 1,
borderColor: "#0f3460",
marginBottom: spacing.sm,
},
cardContent: {
flex: 1,
@@ -98,46 +147,82 @@ const styles = StyleSheet.create({
alignItems: "center",
},
iconContainer: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: "rgba(255, 255, 255, 0.05)",
width: 52,
height: 52,
borderRadius: 26,
backgroundColor: colors.glass.background,
justifyContent: "center",
alignItems: "center",
marginRight: 16,
marginRight: spacing.md,
},
iconContainerActive: {
backgroundColor: colors.primaryLight,
},
emoji: {
fontSize: 24,
fontSize: 26,
},
content: {
flex: 1,
marginRight: 16,
marginRight: spacing.sm,
},
headerRow: {
flexDirection: "row",
alignItems: "center",
marginBottom: 4,
marginBottom: spacing.xs,
},
name: {
fontSize: 18,
fontWeight: "600",
color: "#fff",
marginRight: 8,
...typography.headline,
color: colors.text,
marginRight: spacing.sm,
},
badge: {
backgroundColor: "#e94560",
paddingHorizontal: 6,
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.primary,
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderRadius: 4,
borderRadius: radius.xs,
gap: 2,
},
badgeText: {
color: "#fff",
fontSize: 10,
fontWeight: "bold",
...typography.caption2,
color: colors.text,
fontWeight: "700",
},
description: {
fontSize: 14,
color: "#888",
...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,
},
emptyIconContainer: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: colors.glass.background,
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.md,
},
emptyTitle: {
...typography.title3,
color: colors.text,
marginBottom: spacing.xs,
},
emptyText: {
...typography.body,
color: colors.textSecondary,
textAlign: "center",
},
});
+207 -72
View File
@@ -1,7 +1,12 @@
import { View, Text, StyleSheet, TouchableOpacity, Alert, Image } from "react-native";
import { View, Text, StyleSheet, TouchableOpacity, Alert, ScrollView, Platform } from "react-native";
import { useAuthStore } 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 { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme";
export default function ProfileScreen() {
const { user, partner, signOut, fetchCouple } = useAuthStore();
@@ -20,7 +25,7 @@ export default function ProfileScreen() {
await supabase.functions.invoke("manage-couple", {
method: "DELETE",
});
await fetchCouple(); // Refresh state
await fetchCouple();
} catch (error) {
Alert.alert("Error", "Failed to unpair");
}
@@ -30,139 +35,269 @@ export default function ProfileScreen() {
);
};
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Profile</Text>
</View>
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,
},
]
);
};
<View style={styles.content}>
<View style={styles.profileCard}>
return (
<GradientBackground>
<ScrollView
style={styles.container}
contentContainerStyle={styles.contentContainer}
showsVerticalScrollIndicator={false}
>
{/* Header */}
<Animated.View
entering={FadeInDown.delay(100).duration(500)}
style={styles.header}
>
<Text style={styles.title}>Profile</Text>
</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>
</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>
<View style={styles.section}>
<GlassCard>
{partner ? (
<View style={styles.row}>
<View style={styles.partnerInfo}>
<Ionicons name="heart" size={24} color="#e94560" />
<Text style={styles.rowText}>Paired with {partner.name || partner.email || 'your partner'}</Text>
<View style={styles.rowLeft}>
<View style={styles.rowIconContainer}>
<Ionicons name="heart" size={20} color={colors.primary} />
</View>
<TouchableOpacity onPress={handleUnpair}>
<Ionicons name="trash-outline" size={20} color="#666" />
<View>
<Text style={styles.rowLabel}>Paired with</Text>
<Text style={styles.rowValue}>
{partner.name || partner.email || 'your partner'}
</Text>
</View>
</View>
<TouchableOpacity
onPress={handleUnpair}
style={styles.rowAction}
activeOpacity={0.7}
>
<Ionicons name="unlink-outline" size={20} color={colors.error} />
</TouchableOpacity>
</View>
) : (
<View style={styles.row}>
<Text style={styles.rowText}>Not paired yet</Text>
<TouchableOpacity
style={styles.row}
onPress={() => router.push("/(app)/pairing")}
activeOpacity={0.7}
>
<View style={styles.rowLeft}>
<View style={[styles.rowIconContainer, styles.rowIconInactive]}>
<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>
<Text style={styles.sectionTitle}>Settings</Text>
<View style={styles.section}>
<TouchableOpacity style={styles.row} onPress={signOut}>
<Text style={[styles.rowText, { color: "#ff4444" }]}>Sign Out</Text>
<Ionicons name="log-out-outline" size={20} color="#ff4444" />
</View>
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
</TouchableOpacity>
</View>
)}
</GlassCard>
</Animated.View>
{/* Settings Section */}
<Animated.View entering={FadeInDown.delay(400).duration(500)}>
<Text style={styles.sectionTitle}>Account</Text>
<GlassCard noPadding>
<TouchableOpacity
style={[styles.menuRow, styles.menuRowDanger]}
onPress={handleSignOut}
activeOpacity={0.7}
>
<View style={styles.rowLeft}>
<View style={[styles.rowIconContainer, styles.rowIconDanger]}>
<Ionicons name="log-out-outline" size={20} color={colors.error} />
</View>
<Text style={styles.rowLabelDanger}>Sign Out</Text>
</View>
<Ionicons name="chevron-forward" size={20} color={colors.error} />
</TouchableOpacity>
</GlassCard>
</Animated.View>
{/* Version */}
<Animated.View
entering={FadeInDown.delay(500).duration(500)}
style={styles.versionContainer}
>
<Text style={styles.version}>Sauci v1.0.0</Text>
</View>
</View>
<Text style={styles.versionSub}>Made with love</Text>
</Animated.View>
{/* Bottom spacing for tab bar */}
<View style={styles.bottomSpacer} />
</ScrollView>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#1a1a2e",
},
contentContainer: {
paddingBottom: Platform.OS === 'ios' ? 100 : 80,
},
header: {
paddingTop: 60,
paddingHorizontal: 24,
paddingBottom: 24,
borderBottomWidth: 1,
borderBottomColor: "#16213e",
paddingHorizontal: spacing.lg,
paddingBottom: spacing.md,
},
title: {
fontSize: 28,
fontWeight: "bold",
color: "#fff",
},
content: {
padding: 24,
...typography.title1,
color: colors.text,
},
profileCard: {
marginHorizontal: spacing.lg,
alignItems: "center",
marginBottom: 40,
paddingVertical: spacing.xl,
marginBottom: spacing.lg,
},
avatarContainer: {
width: 88,
height: 88,
borderRadius: 44,
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.md,
...shadows.lg,
},
avatar: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: "#e94560",
backgroundColor: colors.background,
justifyContent: "center",
alignItems: "center",
marginBottom: 16,
},
avatarText: {
fontSize: 32,
fontWeight: "bold",
color: "#fff",
color: colors.text,
},
name: {
fontSize: 24,
fontWeight: "bold",
color: "#fff",
marginBottom: 4,
...typography.title2,
color: colors.text,
marginBottom: spacing.xs,
},
email: {
fontSize: 14,
color: "#666",
...typography.subhead,
color: colors.textSecondary,
},
sectionTitle: {
fontSize: 14,
fontWeight: "600",
color: "#666",
marginBottom: 8,
marginLeft: 4,
...typography.caption1,
color: colors.textTertiary,
textTransform: "uppercase",
},
section: {
backgroundColor: "#16213e",
borderRadius: 12,
marginBottom: 24,
overflow: "hidden",
letterSpacing: 1,
marginBottom: spacing.sm,
marginLeft: spacing.lg + spacing.xs,
},
row: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
padding: 16,
borderBottomWidth: 1,
borderBottomColor: "#1a1a2e",
padding: spacing.md,
},
partnerInfo: {
rowLeft: {
flexDirection: "row",
alignItems: "center",
gap: 12,
flex: 1,
},
rowText: {
fontSize: 16,
color: "#fff",
rowIconContainer: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.primaryLight,
justifyContent: "center",
alignItems: "center",
marginRight: spacing.md,
},
rowIconInactive: {
backgroundColor: colors.glass.background,
},
rowIconDanger: {
backgroundColor: colors.errorLight,
},
rowLabel: {
...typography.subhead,
color: colors.textSecondary,
},
rowValue: {
...typography.body,
color: colors.text,
fontWeight: "600",
},
rowValueMuted: {
...typography.caption1,
color: colors.textTertiary,
},
rowLabelDanger: {
...typography.body,
color: colors.error,
fontWeight: "500",
},
rowAction: {
padding: spacing.sm,
},
menuRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
padding: spacing.md,
},
menuRowDanger: {
borderRadius: radius.lg,
},
versionContainer: {
alignItems: "center",
marginTop: spacing.xl,
},
version: {
textAlign: "center",
color: "#666",
fontSize: 12,
marginTop: 24,
...typography.caption1,
color: colors.textTertiary,
},
versionSub: {
...typography.caption2,
color: colors.textTertiary,
marginTop: spacing.xs,
},
bottomSpacer: {
height: spacing.lg,
},
});
+121 -44
View File
@@ -1,9 +1,12 @@
import { useState, useEffect } from "react";
import { View, StyleSheet, Text, ActivityIndicator } from "react-native";
import { View, StyleSheet, Text, ActivityIndicator, Platform } from "react-native";
import { useLocalSearchParams } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import Animated, { FadeIn, FadeInUp } from "react-native-reanimated";
import { supabase } from "../../src/lib/supabase";
import SwipeCard from "../../src/components/SwipeCard";
import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui";
import { colors, spacing, typography, radius, shadows } from "../../src/theme";
export default function SwipeScreen() {
const { packId } = useLocalSearchParams();
@@ -26,11 +29,6 @@ export default function SwipeScreen() {
throw error;
}
// Smart Shuffle
// Priority:
// 1. Completion of Two-Part Match (Partner answered, is_two_part = true) -> +1.5
// 2. Partner Answered (Any) -> +0.7
// 3. New Two-Part (Get someone to start) -> +0.4
const sorted = (data || []).sort((a: any, b: any) => {
let scoreA = Math.random();
let scoreB = Math.random();
@@ -58,23 +56,18 @@ export default function SwipeScreen() {
const question = questions[currentIndex];
const answer = direction === "right" ? "yes" : direction === "left" ? "no" : "maybe";
// Optimistic update
setCurrentIndex(prev => prev + 1);
try {
// DEBUG: Check if we have a valid session
const { data: { session } } = await supabase.auth.getSession();
console.log("DEBUG - Session exists:", !!session);
console.log("DEBUG - Access token exists:", !!session?.access_token);
console.log("DEBUG - Token preview:", session?.access_token?.substring(0, 50) + "...");
const result = await supabase.functions.invoke("submit-response", {
await supabase.functions.invoke("submit-response", {
body: {
question_id: question.id,
answer,
},
});
console.log("DEBUG - Function result:", result);
} catch (error) {
console.error("Failed to submit response", error);
}
@@ -82,36 +75,70 @@ export default function SwipeScreen() {
if (isLoading) {
return (
<View style={styles.container}>
<ActivityIndicator size="large" color="#e94560" />
<GradientBackground>
<View style={styles.centerContainer}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
</GradientBackground>
);
}
if (currentIndex >= questions.length) {
return (
<View style={styles.container}>
<Ionicons name="checkmark-circle-outline" size={80} color="#e94560" />
<GradientBackground showAccent>
<View style={styles.centerContainer}>
<Animated.View
entering={FadeInUp.duration(600).springify()}
style={styles.emptyContent}
>
<View style={styles.emptyIconContainer}>
<Ionicons name="checkmark-circle" size={64} color={colors.primary} />
</View>
<Text style={styles.emptyTitle}>All caught up!</Text>
<Text style={styles.emptySubtitle}>
Check back later for more questions or try a different pack.
</Text>
<GlassButton
variant="secondary"
onPress={fetchQuestions}
style={{ marginTop: spacing.lg }}
>
Refresh Questions
</GlassButton>
</Animated.View>
</View>
</GradientBackground>
);
}
return (
<GradientBackground>
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.progress}>
Question {currentIndex + 1} of {questions.length}
{/* Header */}
<Animated.View
entering={FadeIn.duration(400)}
style={styles.header}
>
<View style={styles.progressContainer}>
<Text style={styles.progressText}>
{currentIndex + 1} of {questions.length}
</Text>
<View style={styles.progressBar}>
<Animated.View
style={[
styles.progressFill,
{ width: `${((currentIndex + 1) / questions.length) * 100}%` }
]}
/>
</View>
</View>
</Animated.View>
{/* Card Stack */}
<View style={styles.cardContainer}>
{/* Render next card in background for smoothness */}
{/* Background card (next) */}
{questions[currentIndex + 1] && (
<View style={[styles.backgroundCard]} />
<View style={styles.backgroundCard} />
)}
{/* Active card */}
@@ -121,55 +148,105 @@ export default function SwipeScreen() {
onSwipe={handleSwipe}
/>
</View>
{/* Hint */}
<Animated.View
entering={FadeIn.delay(500).duration(400)}
style={styles.hintContainer}
>
<Text style={styles.hintText}>Swipe or tap to answer</Text>
</Animated.View>
{/* Bottom spacing for tab bar */}
<View style={styles.bottomSpacer} />
</View>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#1a1a2e",
},
centerContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
header: {
position: "absolute",
top: 60,
zIndex: 1,
paddingTop: 60,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.md,
},
progress: {
color: "#666",
fontSize: 16,
fontWeight: "600",
progressContainer: {
alignItems: "center",
},
progressText: {
...typography.subhead,
color: colors.textSecondary,
marginBottom: spacing.sm,
},
progressBar: {
width: 120,
height: 4,
backgroundColor: colors.glass.background,
borderRadius: 2,
overflow: "hidden",
},
progressFill: {
height: "100%",
backgroundColor: colors.primary,
borderRadius: 2,
},
cardContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
marginTop: 40,
},
backgroundCard: {
position: "absolute",
width: "100%", // Match SwipeCard width logic
height: 500,
backgroundColor: "#16213e",
borderRadius: 24,
width: "85%",
height: 480,
backgroundColor: colors.glass.backgroundLight,
borderRadius: radius.xxl,
borderWidth: 1,
borderColor: "#0f3460",
transform: [{ scale: 0.95 }, { translateY: 10 }],
borderColor: colors.glass.border,
transform: [{ scale: 0.95 }, { translateY: 12 }],
opacity: 0.5,
...shadows.md,
},
hintContainer: {
alignItems: "center",
paddingBottom: spacing.md,
},
hintText: {
...typography.caption1,
color: colors.textTertiary,
},
emptyContent: {
alignItems: "center",
paddingHorizontal: spacing.xl,
},
emptyIconContainer: {
width: 120,
height: 120,
borderRadius: 60,
backgroundColor: colors.primaryLight,
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.lg,
},
emptyTitle: {
fontSize: 24,
fontWeight: "bold",
color: "#fff",
marginTop: 24,
marginBottom: 8,
...typography.title2,
color: colors.text,
marginBottom: spacing.sm,
},
emptySubtitle: {
fontSize: 16,
color: "#888",
...typography.body,
color: colors.textSecondary,
textAlign: "center",
paddingHorizontal: 40,
},
bottomSpacer: {
height: Platform.OS === 'ios' ? 100 : 80,
},
});
+232 -162
View File
@@ -2,7 +2,6 @@ import { useState } from "react";
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
Alert,
@@ -12,9 +11,16 @@ import {
} from "react-native";
import { Redirect } from "expo-router";
import * as Linking from "expo-linking";
import Animated, {
FadeIn,
FadeInDown,
FadeInUp,
} from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons";
import { supabase } from "../../src/lib/supabase";
import { useAuthStore } from "../../src/store";
import { GradientBackground, GlassCard, GlassButton, GlassInput } from "../../src/components/ui";
import { colors, spacing, radius, typography } from "../../src/theme";
type AuthMode = "magic-link" | "password";
@@ -25,22 +31,32 @@ export default function LoginScreen() {
const [isMagicLinkSent, setIsMagicLinkSent] = useState(false);
const [authMode, setAuthMode] = useState<AuthMode>("magic-link");
const [isSignUp, setIsSignUp] = useState(false);
const [error, setError] = useState<string | null>(null);
const { isAuthenticated } = useAuthStore();
// Use declarative Redirect instead of imperative router.replace()
// This ensures navigation only happens after the Root Layout is mounted
const showError = (message: string) => {
setError(message);
// Also show native alert on mobile
if (Platform.OS !== 'web') {
Alert.alert("Error", message);
}
};
const clearError = () => setError(null);
if (isAuthenticated) {
return <Redirect href="/" />;
}
const handleMagicLink = async () => {
clearError();
if (!email.trim()) {
Alert.alert("Error", "Please enter your email");
showError("Please enter your email");
return;
}
setIsLoading(true);
const { error } = await supabase.auth.signInWithOtp({
const { error: authError } = await supabase.auth.signInWithOtp({
email: email.trim(),
options: {
emailRedirectTo: Linking.createURL("/(auth)/login"),
@@ -49,39 +65,48 @@ export default function LoginScreen() {
setIsLoading(false);
if (error) {
Alert.alert("Error", error.message);
if (authError) {
showError(authError.message);
} else {
setIsMagicLinkSent(true);
}
};
const handlePasswordAuth = async () => {
clearError();
if (!email.trim()) {
Alert.alert("Error", "Please enter your email");
showError("Please enter your email");
return;
}
if (!password) {
Alert.alert("Error", "Please enter your password");
showError("Please enter your password");
return;
}
if (isSignUp && password.length < 6) {
Alert.alert("Error", "Password must be at least 6 characters");
showError("Password must be at least 6 characters");
return;
}
setIsLoading(true);
if (isSignUp) {
const { error } = await supabase.auth.signUp({
const { error: signUpError } = await supabase.auth.signUp({
email: email.trim(),
password,
});
setIsLoading(false);
if (error) {
Alert.alert("Error", error.message);
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",
@@ -89,16 +114,17 @@ export default function LoginScreen() {
[{ text: "OK" }]
);
}
}
} else {
const { error } = await supabase.auth.signInWithPassword({
const { error: signInError } = await supabase.auth.signInWithPassword({
email: email.trim(),
password,
});
setIsLoading(false);
if (error) {
Alert.alert("Error", error.message);
if (signInError) {
showError(signInError.message);
}
}
};
@@ -131,25 +157,35 @@ export default function LoginScreen() {
if (isMagicLinkSent) {
return (
<GradientBackground showAccent>
<View style={styles.container}>
<View style={styles.content}>
<Text style={styles.emoji}>✉️</Text>
<Animated.View
entering={FadeInUp.duration(600).springify()}
style={styles.content}
>
<View style={styles.iconContainer}>
<Ionicons name="mail" size={48} color={colors.primary} />
</View>
<Text style={styles.title}>Check your email</Text>
<Text style={styles.subtitle}>
We sent a magic link to {email}
We sent a magic link to{'\n'}
<Text style={styles.emailHighlight}>{email}</Text>
</Text>
<TouchableOpacity
style={styles.secondaryButton}
<GlassButton
variant="ghost"
onPress={() => setIsMagicLinkSent(false)}
style={{ marginTop: spacing.lg }}
>
<Text style={styles.secondaryButtonText}>Try a different email</Text>
</TouchableOpacity>
</View>
Try a different email
</GlassButton>
</Animated.View>
</View>
</GradientBackground>
);
}
return (
<GradientBackground showAccent>
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === "ios" ? "padding" : "height"}
@@ -157,14 +193,29 @@ export default function LoginScreen() {
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<View style={styles.content}>
<Text style={styles.emoji}>💕</Text>
{/* Logo/Brand */}
<Animated.View
entering={FadeInDown.delay(100).duration(600).springify()}
style={styles.brandContainer}
>
<View style={styles.logoContainer}>
<Ionicons name="heart" size={40} color={colors.primary} />
</View>
<Text style={styles.title}>Sauci</Text>
<Text style={styles.subtitle}>
Explore intimacy together
</Text>
</Animated.View>
{/* Auth Card */}
<Animated.View
entering={FadeInDown.delay(200).duration(600).springify()}
style={styles.formContainer}
>
<GlassCard variant="elevated">
{/* Auth Mode Toggle */}
<View style={styles.modeToggleContainer}>
<TouchableOpacity
@@ -173,6 +224,7 @@ export default function LoginScreen() {
authMode === "magic-link" && styles.modeToggleActive,
]}
onPress={() => setAuthMode("magic-link")}
activeOpacity={0.7}
>
<Text
style={[
@@ -189,6 +241,7 @@ export default function LoginScreen() {
authMode === "password" && styles.modeToggleActive,
]}
onPress={() => setAuthMode("password")}
activeOpacity={0.7}
>
<Text
style={[
@@ -201,57 +254,59 @@ export default function LoginScreen() {
</TouchableOpacity>
</View>
{/* Error Message */}
{error && (
<Animated.View
entering={FadeIn.duration(200)}
style={styles.errorContainer}
>
<Ionicons name="alert-circle" size={18} color={colors.error} />
<Text style={styles.errorText}>{error}</Text>
</Animated.View>
)}
{/* Form Fields */}
<View style={styles.form}>
<TextInput
style={styles.input}
<GlassInput
placeholder="Enter your email"
placeholderTextColor="#666"
value={email}
onChangeText={setEmail}
onChangeText={(text) => { clearError(); setEmail(text); }}
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
icon={<Ionicons name="mail-outline" size={20} color={colors.textTertiary} />}
/>
{authMode === "password" && (
<TextInput
style={styles.input}
<GlassInput
placeholder="Enter your password"
placeholderTextColor="#666"
value={password}
onChangeText={setPassword}
onChangeText={(text) => { clearError(); setPassword(text); }}
secureTextEntry
autoCapitalize="none"
icon={<Ionicons name="lock-closed-outline" size={20} color={colors.textTertiary} />}
/>
)}
{authMode === "magic-link" ? (
<TouchableOpacity
style={[styles.button, isLoading && styles.buttonDisabled]}
<GlassButton
onPress={handleMagicLink}
loading={isLoading}
disabled={isLoading}
fullWidth
>
<Text style={styles.buttonText}>
{isLoading ? "Sending..." : "Continue with Magic Link"}
</Text>
</TouchableOpacity>
Continue with Magic Link
</GlassButton>
) : (
<>
<TouchableOpacity
style={[styles.button, isLoading && styles.buttonDisabled]}
<GlassButton
onPress={handlePasswordAuth}
loading={isLoading}
disabled={isLoading}
fullWidth
>
<Text style={styles.buttonText}>
{isLoading
? isSignUp
? "Creating Account..."
: "Signing In..."
: isSignUp
? "Create Account"
: "Sign In"}
</Text>
</TouchableOpacity>
{isSignUp ? "Create Account" : "Sign In"}
</GlassButton>
<TouchableOpacity
style={styles.toggleSignUp}
onPress={() => setIsSignUp(!isSignUp)}
@@ -264,157 +319,172 @@ export default function LoginScreen() {
</TouchableOpacity>
</>
)}
</View>
</GlassCard>
</Animated.View>
{/* Social Sign In */}
<Animated.View
entering={FadeInDown.delay(300).duration(600).springify()}
style={styles.socialContainer}
>
<View style={styles.divider}>
<View style={styles.dividerLine} />
<Text style={styles.dividerText}>or</Text>
<Text style={styles.dividerText}>or continue with</Text>
<View style={styles.dividerLine} />
</View>
<TouchableOpacity
style={styles.socialButton}
<View style={styles.socialButtons}>
<GlassButton
variant="secondary"
onPress={handleGoogleSignIn}
>
<Text style={styles.socialButtonText}>Continue with Google</Text>
</TouchableOpacity>
<TouchableOpacity
icon={<Ionicons name="logo-google" size={20} color={colors.text} />}
style={styles.socialButton}
onPress={handleAppleSignIn}
>
<Text style={styles.socialButtonText}>Continue with Apple</Text>
</TouchableOpacity>
Google
</GlassButton>
<GlassButton
variant="secondary"
onPress={handleAppleSignIn}
icon={<Ionicons name="logo-apple" size={20} color={colors.text} />}
style={styles.socialButton}
>
Apple
</GlassButton>
</View>
</Animated.View>
</View>
</ScrollView>
</KeyboardAvoidingView>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#1a1a2e",
},
content: {
flex: 1,
paddingHorizontal: 24,
paddingTop: 80,
alignItems: "center",
},
emoji: {
fontSize: 64,
marginBottom: 16,
},
title: {
fontSize: 32,
fontWeight: "bold",
color: "#fff",
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: "#888",
marginBottom: 48,
textAlign: "center",
},
form: {
width: "100%",
},
input: {
backgroundColor: "#16213e",
borderRadius: 12,
padding: 16,
fontSize: 16,
color: "#fff",
marginBottom: 16,
borderWidth: 1,
borderColor: "#0f3460",
},
button: {
backgroundColor: "#e94560",
borderRadius: 12,
padding: 16,
alignItems: "center",
},
buttonDisabled: {
opacity: 0.7,
},
buttonText: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
},
divider: {
flexDirection: "row",
alignItems: "center",
marginVertical: 24,
},
dividerLine: {
flex: 1,
height: 1,
backgroundColor: "#333",
},
dividerText: {
color: "#666",
paddingHorizontal: 16,
},
socialButton: {
backgroundColor: "#16213e",
borderRadius: 12,
padding: 16,
alignItems: "center",
marginBottom: 12,
borderWidth: 1,
borderColor: "#0f3460",
},
socialButtonText: {
color: "#fff",
fontSize: 16,
fontWeight: "500",
},
secondaryButton: {
marginTop: 24,
},
secondaryButtonText: {
color: "#e94560",
fontSize: 16,
},
scrollContent: {
flexGrow: 1,
},
content: {
flex: 1,
paddingHorizontal: spacing.lg,
paddingTop: 80,
paddingBottom: spacing.xl,
},
brandContainer: {
alignItems: "center",
marginBottom: spacing.xl,
},
logoContainer: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: colors.primaryLight,
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.md,
},
title: {
...typography.largeTitle,
color: colors.text,
marginBottom: spacing.xs,
},
subtitle: {
...typography.body,
color: colors.textSecondary,
textAlign: "center",
},
emailHighlight: {
color: colors.primary,
fontWeight: "600",
},
iconContainer: {
width: 100,
height: 100,
borderRadius: 50,
backgroundColor: colors.primaryLight,
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.lg,
},
formContainer: {
marginBottom: spacing.lg,
},
modeToggleContainer: {
flexDirection: "row",
backgroundColor: "#16213e",
borderRadius: 12,
backgroundColor: colors.glass.background,
borderRadius: radius.md,
padding: 4,
marginBottom: 24,
width: "100%",
marginBottom: spacing.lg,
},
modeToggle: {
flex: 1,
paddingVertical: 12,
borderRadius: 8,
borderRadius: radius.sm,
alignItems: "center",
},
modeToggleActive: {
backgroundColor: "#e94560",
backgroundColor: colors.primary,
},
modeToggleText: {
color: "#666",
fontSize: 14,
fontWeight: "500",
...typography.subhead,
color: colors.textTertiary,
fontWeight: "600",
},
modeToggleTextActive: {
color: "#fff",
color: colors.text,
},
errorContainer: {
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.errorLight,
padding: spacing.md,
borderRadius: radius.md,
marginBottom: spacing.md,
gap: spacing.sm,
},
errorText: {
...typography.subhead,
color: colors.error,
flex: 1,
},
form: {
gap: spacing.sm,
},
toggleSignUp: {
marginTop: 16,
marginTop: spacing.md,
alignItems: "center",
},
toggleSignUpText: {
color: "#e94560",
fontSize: 14,
...typography.subhead,
color: colors.primary,
},
socialContainer: {
marginTop: spacing.md,
},
divider: {
flexDirection: "row",
alignItems: "center",
marginBottom: spacing.lg,
},
dividerLine: {
flex: 1,
height: 1,
backgroundColor: colors.glass.border,
},
dividerText: {
...typography.caption1,
color: colors.textTertiary,
paddingHorizontal: spacing.md,
},
socialButtons: {
flexDirection: "row",
gap: spacing.md,
},
socialButton: {
flex: 1,
},
});
+34
View File
@@ -16,10 +16,13 @@
"@tanstack/react-query": "^5.17.0",
"base64-arraybuffer": "^1.0.2",
"expo": "~52.0.0",
"expo-blur": "~14.0.3",
"expo-clipboard": "~7.0.1",
"expo-constants": "~17.0.0",
"expo-file-system": "~18.0.12",
"expo-haptics": "~14.0.1",
"expo-image-picker": "~16.0.6",
"expo-linear-gradient": "~14.0.2",
"expo-linking": "~7.0.0",
"expo-notifications": "~0.29.0",
"expo-router": "~4.0.0",
@@ -6195,6 +6198,17 @@
"react-native": "*"
}
},
"node_modules/expo-blur": {
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/expo-blur/-/expo-blur-14.0.3.tgz",
"integrity": "sha512-BL3xnqBJbYm3Hg9t/HjNjdeY7N/q8eK5tsLYxswWG1yElISWZmMvrXYekl7XaVCPfyFyz8vQeaxd7q74ZY3Wrw==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-clipboard": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-7.0.1.tgz",
@@ -6249,6 +6263,15 @@
"react-native": "*"
}
},
"node_modules/expo-haptics": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-14.0.1.tgz",
"integrity": "sha512-V81FZ7xRUfqM6uSI6FA1KnZ+QpEKnISqafob/xEfcx1ymwhm4V3snuLWWFjmAz+XaZQTqlYa8z3QbqEXz7G63w==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-image-loader": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-5.0.0.tgz",
@@ -6280,6 +6303,17 @@
"react": "*"
}
},
"node_modules/expo-linear-gradient": {
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-14.0.2.tgz",
"integrity": "sha512-nvac1sPUfFFJ4mY25UkvubpUV/olrBH+uQw5k+beqSvQaVQiUfFtYzfRr+6HhYBNb4AEsOtpsCRkpDww3M2iGQ==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-linking": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-7.0.5.tgz",
+3
View File
@@ -20,10 +20,13 @@
"@tanstack/react-query": "^5.17.0",
"base64-arraybuffer": "^1.0.2",
"expo": "~52.0.0",
"expo-blur": "~14.0.3",
"expo-clipboard": "~7.0.1",
"expo-constants": "~17.0.0",
"expo-file-system": "~18.0.12",
"expo-haptics": "~14.0.1",
"expo-image-picker": "~16.0.6",
"expo-linear-gradient": "~14.0.2",
"expo-linking": "~7.0.0",
"expo-notifications": "~0.29.0",
"expo-router": "~4.0.0",
+235 -157
View File
@@ -1,5 +1,7 @@
import { View, Text, StyleSheet, Image, Dimensions, TouchableOpacity } from "react-native";
import { View, Text, StyleSheet, Dimensions, Platform } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { BlurView } from "expo-blur";
import { LinearGradient } from "expo-linear-gradient";
import Animated, {
useSharedValue,
useAnimatedStyle,
@@ -8,17 +10,33 @@ import Animated, {
interpolate,
Extrapolate,
withTiming,
interpolateColor,
} from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons";
import { colors, gradients, radius, shadows, blur, typography, spacing, animations } from "../theme";
// Haptics helper - not supported on web
const triggerHaptic = async (style: 'light' | 'medium' | 'heavy' = 'medium') => {
if (Platform.OS === 'web') return;
const Haptics = await import('expo-haptics');
const feedbackStyle = style === 'light'
? Haptics.ImpactFeedbackStyle.Light
: style === 'heavy'
? Haptics.ImpactFeedbackStyle.Heavy
: Haptics.ImpactFeedbackStyle.Medium;
await Haptics.impactAsync(feedbackStyle);
};
const SCREEN_WIDTH = Dimensions.get("window").width;
const SWIPE_THRESHOLD = SCREEN_WIDTH * 0.25;
interface Props {
question: { id: string; text: string; intensity: number; partner_text?: string | null };
question: { id: string; text: string; intensity: number; partner_text?: string | null; is_two_part?: boolean };
onSwipe: (direction: "left" | "right" | "up") => void;
}
const AnimatedBlurView = Animated.createAnimatedComponent(BlurView);
export default function SwipeCard({ question, onSwipe }: Props) {
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
@@ -32,7 +50,7 @@ export default function SwipeCard({ question, onSwipe }: Props) {
.activeOffsetY([-15, 15])
.onStart(() => {
context.value = { x: translateX.value, y: translateY.value };
scale.value = withTiming(1.05);
scale.value = withTiming(1.02, { duration: animations.timing.fast });
})
.onUpdate((event) => {
translateX.value = event.translationX + context.value.x;
@@ -40,27 +58,29 @@ export default function SwipeCard({ question, onSwipe }: Props) {
rotation.value = interpolate(
translateX.value,
[-SCREEN_WIDTH / 2, SCREEN_WIDTH / 2],
[-15, 15],
[-12, 12],
Extrapolate.CLAMP
);
})
.onEnd(() => {
scale.value = withTiming(1);
scale.value = withTiming(1, { duration: animations.timing.fast });
if (Math.abs(translateX.value) > SWIPE_THRESHOLD) {
// Horizontal swipe (YES/NO)
const direction = translateX.value > 0 ? "right" : "left";
translateX.value = withSpring(direction === "right" ? SCREEN_WIDTH * 1.5 : -SCREEN_WIDTH * 1.5);
runOnJS(triggerHaptic)('medium');
translateX.value = withSpring(
direction === "right" ? SCREEN_WIDTH * 1.5 : -SCREEN_WIDTH * 1.5,
animations.springGentle
);
runOnJS(onSwipe)(direction);
} else if (translateY.value < -SWIPE_THRESHOLD) {
// Vertical swipe up (MAYBE)
translateY.value = withSpring(-SCREEN_WIDTH * 1.5);
runOnJS(triggerHaptic)('medium');
translateY.value = withSpring(-SCREEN_WIDTH * 1.5, animations.springGentle);
runOnJS(onSwipe)("up");
} else {
// Reset
translateX.value = withSpring(0);
translateY.value = withSpring(0);
rotation.value = withSpring(0);
translateX.value = withSpring(0, animations.spring);
translateY.value = withSpring(0, animations.spring);
rotation.value = withSpring(0, animations.spring);
}
});
@@ -73,80 +93,184 @@ export default function SwipeCard({ question, onSwipe }: Props) {
],
}));
const overlayStyle = (direction: "left" | "right" | "up", color: string) =>
const overlayStyle = (direction: "left" | "right" | "up") =>
useAnimatedStyle(() => {
let opacity = 0;
let backgroundColor = 'transparent';
if (direction === "right") {
opacity = interpolate(translateX.value, [0, SWIPE_THRESHOLD], [0, 1]);
opacity = interpolate(translateX.value, [0, SWIPE_THRESHOLD], [0, 0.9]);
backgroundColor = interpolateColor(
opacity,
[0, 0.9],
['transparent', 'rgba(46, 204, 113, 0.4)']
) as string;
} else if (direction === "left") {
opacity = interpolate(translateX.value, [0, -SWIPE_THRESHOLD], [0, 1]);
opacity = interpolate(translateX.value, [0, -SWIPE_THRESHOLD], [0, 0.9]);
backgroundColor = interpolateColor(
opacity,
[0, 0.9],
['transparent', 'rgba(231, 76, 60, 0.4)']
) as string;
} else {
opacity = interpolate(translateY.value, [0, -SWIPE_THRESHOLD], [0, 1]);
opacity = interpolate(translateY.value, [0, -SWIPE_THRESHOLD], [0, 0.9]);
backgroundColor = interpolateColor(
opacity,
[0, 0.9],
['transparent', 'rgba(243, 156, 18, 0.4)']
) as string;
}
return {
opacity,
backgroundColor: color,
backgroundColor,
};
});
const handleButtonPress = async (direction: "left" | "right" | "up") => {
await triggerHaptic('light');
onSwipe(direction);
};
const useBlur = Platform.OS === 'ios';
return (
<View style={styles.cardWrapper}>
<GestureDetector gesture={gesture}>
<Animated.View style={[styles.card, animatedStyle]}>
<Animated.View style={[styles.cardOuter, animatedStyle, shadows.xl]}>
{useBlur ? (
<BlurView
intensity={blur.medium}
tint="dark"
style={styles.blurContainer}
>
<CardContent
question={question}
overlayStyle={overlayStyle}
handleButtonPress={handleButtonPress}
/>
</BlurView>
) : (
<View style={styles.fallbackContainer}>
<CardContent
question={question}
overlayStyle={overlayStyle}
handleButtonPress={handleButtonPress}
/>
</View>
)}
</Animated.View>
</GestureDetector>
</View>
);
}
function CardContent({
question,
overlayStyle,
handleButtonPress,
}: {
question: Props['question'];
overlayStyle: (direction: "left" | "right" | "up") => any;
handleButtonPress: (direction: "left" | "right" | "up") => void;
}) {
return (
<>
{/* Top highlight */}
<LinearGradient
colors={[colors.glass.highlight, 'transparent']}
style={styles.highlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
{/* Overlays */}
<Animated.View style={[styles.overlay, overlayStyle("right", "rgba(76, 175, 80, 0.4)")]}>
<Animated.View style={[styles.overlay, overlayStyle("right")]}>
<View style={[styles.overlayBadge, { backgroundColor: colors.success }]}>
<Text style={styles.overlayText}>YES</Text>
</View>
</Animated.View>
<Animated.View style={[styles.overlay, overlayStyle("left", "rgba(244, 67, 54, 0.4)")]}>
<Animated.View style={[styles.overlay, overlayStyle("left")]}>
<View style={[styles.overlayBadge, { backgroundColor: colors.error }]}>
<Text style={styles.overlayText}>NO</Text>
</View>
</Animated.View>
<Animated.View style={[styles.overlay, overlayStyle("up", "rgba(255, 152, 0, 0.4)")]}>
<Animated.View style={[styles.overlay, overlayStyle("up")]}>
<View style={[styles.overlayBadge, { backgroundColor: colors.warning }]}>
<Text style={styles.overlayText}>MAYBE</Text>
</View>
</Animated.View>
{/* Content */}
<View style={styles.content}>
<View style={styles.intensityContainer}>
{[...Array(question.intensity)].map((_, i) => (
<Ionicons key={i} name="flame" size={16} color="#e94560" />
<Ionicons key={i} name="flame" size={16} color={colors.primary} />
))}
</View>
<Text style={[styles.text, question.is_two_part ? styles.twoPartText : null]}>{question.text}</Text>
<Text style={[styles.text, question.is_two_part && styles.twoPartText]}>
{question.text}
</Text>
</View>
</Animated.View>
</GestureDetector>
{/* Action Buttons */}
<View style={styles.footer}>
<View style={styles.buttonContainer}>
<TouchableOpacity
style={[styles.actionButton, styles.noButton]}
onPress={() => onSwipe("left")}
activeOpacity={0.7}
>
<View style={styles.buttonInnerGlow} />
<Ionicons name="thumbs-down" size={24} color="#fff" style={styles.buttonIcon} />
</TouchableOpacity>
<ActionButton
onPress={() => handleButtonPress("left")}
colors={gradients.error as [string, string]}
icon="close"
/>
<ActionButton
onPress={() => handleButtonPress("up")}
colors={gradients.warning as [string, string]}
icon="help"
small
/>
<ActionButton
onPress={() => handleButtonPress("right")}
colors={gradients.success as [string, string]}
icon="heart"
/>
</View>
</View>
</>
);
}
<TouchableOpacity
style={[styles.actionButton, styles.maybeButton]}
onPress={() => onSwipe("up")}
activeOpacity={0.7}
function ActionButton({
onPress,
colors: buttonColors,
icon,
small = false,
}: {
onPress: () => void;
colors: [string, string];
icon: string;
small?: boolean;
}) {
return (
<Animated.View>
<LinearGradient
colors={buttonColors}
style={[
styles.actionButton,
small && styles.actionButtonSmall,
shadows.md,
]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<View style={styles.buttonInnerGlow} />
<Ionicons name="help-circle" size={24} color="#fff" style={styles.buttonIcon} />
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, styles.yesButton]}
onPress={() => onSwipe("right")}
activeOpacity={0.7}
>
<View style={styles.buttonInnerGlow} />
<Ionicons name="thumbs-up" size={24} color="#fff" style={styles.buttonIcon} />
</TouchableOpacity>
</View>
</View>
</View>
<View style={styles.buttonHighlight} />
<Ionicons
name={icon as any}
size={small ? 20 : 28}
color={colors.text}
style={styles.buttonIcon}
onPress={onPress}
/>
</LinearGradient>
</Animated.View>
);
}
@@ -154,159 +278,113 @@ const styles = StyleSheet.create({
cardWrapper: {
position: "absolute",
width: SCREEN_WIDTH - 48,
height: 500,
height: 480,
},
card: {
cardOuter: {
width: "100%",
height: 500,
backgroundColor: "#16213e",
borderRadius: 24,
borderWidth: 1,
borderColor: "#0f3460",
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.3,
shadowRadius: 4.65,
elevation: 8,
height: "100%",
borderRadius: radius.xxl,
overflow: "hidden",
},
blurContainer: {
flex: 1,
backgroundColor: colors.glass.background,
borderWidth: 1,
borderColor: colors.glass.border,
borderRadius: radius.xxl,
overflow: "hidden",
},
fallbackContainer: {
flex: 1,
backgroundColor: colors.glass.backgroundLight,
borderWidth: 1,
borderColor: colors.glass.border,
borderRadius: radius.xxl,
overflow: "hidden",
},
highlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 80,
},
content: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 32,
padding: spacing.xl,
},
intensityContainer: {
flexDirection: "row",
marginBottom: 24,
backgroundColor: "rgba(233, 69, 96, 0.1)",
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 12,
marginBottom: spacing.lg,
backgroundColor: colors.primaryLight,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
gap: 2,
},
text: {
fontSize: 24,
fontWeight: "bold",
color: "#fff",
...typography.title2,
color: colors.text,
textAlign: "center",
lineHeight: 32,
},
twoPartText: {
fontSize: 20,
...typography.title3,
lineHeight: 28,
},
footer: {
position: "absolute",
bottom: 0,
left: 0,
right: 0,
paddingTop: 8,
paddingBottom: 16,
paddingHorizontal: 8,
alignItems: "center",
backgroundColor: "rgba(0,0,0,0.2)",
borderBottomLeftRadius: 24,
borderBottomRightRadius: 24,
},
hint: {
color: "#666",
fontSize: 12,
marginBottom: 4,
paddingBottom: spacing.lg,
paddingHorizontal: spacing.lg,
},
overlay: {
...StyleSheet.absoluteFillObject,
justifyContent: "center",
alignItems: "center",
zIndex: 10,
borderRadius: radius.xxl,
},
overlayBadge: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.md,
transform: [{ rotate: "-15deg" }],
},
overlayText: {
fontSize: 48,
fontSize: 36,
fontWeight: "900",
color: "#fff",
textShadowColor: "rgba(0,0,0,0.5)",
textShadowOffset: { width: 0, height: 2 },
textShadowRadius: 4,
transform: [{ rotate: "-15deg" }],
borderWidth: 4,
borderColor: "#fff",
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 12,
},
partnerContainer: {
width: "100%",
alignItems: "center",
},
divider: {
height: 1,
width: "80%",
backgroundColor: "rgba(255,255,255,0.1)",
marginVertical: 24,
position: "relative",
justifyContent: "center",
alignItems: "center",
},
dividerText: {
color: "#666",
backgroundColor: "#16213e",
paddingHorizontal: 10,
fontSize: 12,
fontWeight: "bold",
position: "absolute",
color: colors.text,
letterSpacing: 2,
},
buttonContainer: {
flexDirection: "row",
justifyContent: "space-between",
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 32,
paddingVertical: 16,
gap: 20,
zIndex: 21,
gap: spacing.md,
},
actionButton: {
alignItems: "center",
justifyContent: "center",
width: 64,
height: 64,
borderRadius: 32,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.4,
shadowRadius: 8,
elevation: 8,
borderWidth: 2,
justifyContent: "center",
alignItems: "center",
overflow: "hidden",
zIndex: 22,
},
noButton: {
backgroundColor: "#E74C3C",
borderColor: "rgba(255, 255, 255, 0.3)",
actionButtonSmall: {
width: 52,
height: 52,
borderRadius: 26,
},
maybeButton: {
backgroundColor: "#F39C12",
borderColor: "rgba(255, 255, 255, 0.3)",
},
yesButton: {
backgroundColor: "#2ECC71",
borderColor: "rgba(255, 255, 255, 0.3)",
},
buttonInnerGlow: {
buttonHighlight: {
position: "absolute",
top: 0,
left: 0,
right: 0,
height: "50%",
backgroundColor: "rgba(255, 255, 255, 0.15)",
borderTopLeftRadius: 32,
borderTopRightRadius: 32,
},
buttonIcon: {
zIndex: 1,
textShadowColor: "rgba(0, 0, 0, 0.3)",
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
},
});
@@ -0,0 +1,238 @@
import React from 'react';
import { StyleSheet, Text, Pressable, ViewStyle, TextStyle, ActivityIndicator, Platform } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
} from 'react-native-reanimated';
import { colors, gradients, radius, shadows, typography, animations } from '../../theme';
// Haptics is not supported on web
const triggerHaptic = async () => {
if (Platform.OS === 'web') return;
const Haptics = await import('expo-haptics');
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
};
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
type ButtonSize = 'sm' | 'md' | 'lg';
interface GlassButtonProps {
children: React.ReactNode;
onPress?: () => void;
variant?: ButtonVariant;
size?: ButtonSize;
disabled?: boolean;
loading?: boolean;
fullWidth?: boolean;
style?: ViewStyle;
textStyle?: TextStyle;
haptic?: boolean;
icon?: React.ReactNode;
}
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
export function GlassButton({
children,
onPress,
variant = 'primary',
size = 'md',
disabled = false,
loading = false,
fullWidth = false,
style,
textStyle,
haptic = true,
icon,
}: GlassButtonProps) {
const scale = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
const handlePressIn = () => {
scale.value = withSpring(0.96, animations.spring);
};
const handlePressOut = () => {
scale.value = withSpring(1, animations.spring);
};
const handlePress = async () => {
if (disabled || loading) return;
if (haptic) {
await triggerHaptic();
}
onPress?.();
};
const sizeStyles = sizes[size];
const variantStyles = variants[variant];
const content = (
<>
{loading ? (
<ActivityIndicator
color={variantStyles.textColor}
size="small"
style={styles.loader}
/>
) : (
<>
{icon && <Animated.View style={styles.icon}>{icon}</Animated.View>}
<Text
style={[
styles.text,
sizeStyles.text,
{ color: variantStyles.textColor },
textStyle,
]}
>
{children}
</Text>
</>
)}
</>
);
return (
<AnimatedPressable
onPress={handlePress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
disabled={disabled || loading}
style={[
animatedStyle,
fullWidth && styles.fullWidth,
disabled && styles.disabled,
]}
>
{variant === 'primary' || variant === 'danger' ? (
<LinearGradient
colors={variantStyles.gradient as [string, string]}
style={[
styles.button,
sizeStyles.button,
shadows.md,
style,
]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
{content}
</LinearGradient>
) : (
<Animated.View
style={[
styles.button,
sizeStyles.button,
{
backgroundColor: variantStyles.background,
borderWidth: variantStyles.borderWidth,
borderColor: variantStyles.borderColor,
},
variant === 'secondary' && shadows.sm,
style,
]}
>
{content}
</Animated.View>
)}
</AnimatedPressable>
);
}
const variants = {
primary: {
gradient: gradients.primary,
textColor: colors.text,
background: undefined,
borderWidth: 0,
borderColor: undefined,
},
secondary: {
gradient: undefined,
textColor: colors.text,
background: colors.glass.background,
borderWidth: 1,
borderColor: colors.glass.border,
},
ghost: {
gradient: undefined,
textColor: colors.primary,
background: 'transparent',
borderWidth: 0,
borderColor: undefined,
},
danger: {
gradient: gradients.error,
textColor: colors.text,
background: undefined,
borderWidth: 0,
borderColor: undefined,
},
};
const sizes = {
sm: {
button: {
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: radius.md,
},
text: {
fontSize: 14,
fontWeight: '600' as const,
},
},
md: {
button: {
paddingHorizontal: 24,
paddingVertical: 14,
borderRadius: radius.md,
},
text: {
fontSize: 16,
fontWeight: '600' as const,
},
},
lg: {
button: {
paddingHorizontal: 32,
paddingVertical: 18,
borderRadius: radius.lg,
},
text: {
fontSize: 18,
fontWeight: '600' as const,
},
},
};
const styles = StyleSheet.create({
button: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
text: {
textAlign: 'center',
},
fullWidth: {
width: '100%',
},
disabled: {
opacity: 0.5,
},
loader: {
marginHorizontal: 8,
},
icon: {
marginRight: 8,
},
});
export default GlassButton;
@@ -0,0 +1,98 @@
import React from 'react';
import { StyleSheet, View, ViewStyle, Platform } from 'react-native';
import { BlurView } from 'expo-blur';
import { LinearGradient } from 'expo-linear-gradient';
import { colors, radius, shadows, blur } from '../../theme';
interface GlassCardProps {
children: React.ReactNode;
style?: ViewStyle;
intensity?: 'light' | 'medium' | 'heavy';
variant?: 'default' | 'elevated' | 'subtle';
noPadding?: boolean;
}
export function GlassCard({
children,
style,
intensity = 'medium',
variant = 'default',
noPadding = false,
}: GlassCardProps) {
const blurIntensity = blur[intensity];
const shadowStyle = variant === 'elevated' ? shadows.lg : variant === 'subtle' ? shadows.sm : shadows.md;
const borderColor = variant === 'elevated' ? colors.glass.borderLight : colors.glass.border;
// Use BlurView on iOS, fallback to solid background on Android for performance
const useBlur = Platform.OS === 'ios';
return (
<View style={[styles.container, shadowStyle, style]}>
{useBlur ? (
<BlurView
intensity={blurIntensity}
tint="dark"
style={[styles.blurContainer, { borderColor }]}
>
{/* Top highlight gradient */}
<LinearGradient
colors={[colors.glass.highlight, 'transparent']}
style={styles.highlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
<View style={[styles.content, noPadding && styles.noPadding]}>
{children}
</View>
</BlurView>
) : (
// Android fallback with solid background
<View style={[styles.fallbackContainer, { borderColor }]}>
<LinearGradient
colors={[colors.glass.highlight, 'transparent']}
style={styles.highlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
<View style={[styles.content, noPadding && styles.noPadding]}>
{children}
</View>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
borderRadius: radius.lg,
overflow: 'hidden',
},
blurContainer: {
overflow: 'hidden',
borderRadius: radius.lg,
borderWidth: 1,
backgroundColor: colors.glass.background,
},
fallbackContainer: {
overflow: 'hidden',
borderRadius: radius.lg,
borderWidth: 1,
backgroundColor: colors.glass.backgroundLight,
},
highlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 60,
},
content: {
padding: 16,
},
noPadding: {
padding: 0,
},
});
export default GlassCard;
@@ -0,0 +1,140 @@
import React, { useState } from 'react';
import { StyleSheet, TextInput, View, Text, TextInputProps, ViewStyle, Platform } from 'react-native';
import { BlurView } from 'expo-blur';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
interpolateColor,
} from 'react-native-reanimated';
import { colors, radius, blur, animations, typography } from '../../theme';
interface GlassInputProps extends Omit<TextInputProps, 'style'> {
label?: string;
error?: string;
containerStyle?: ViewStyle;
icon?: React.ReactNode;
}
const AnimatedBlurView = Animated.createAnimatedComponent(BlurView);
const AnimatedView = Animated.View;
export function GlassInput({
label,
error,
containerStyle,
icon,
...props
}: GlassInputProps) {
const [isFocused, setIsFocused] = useState(false);
const focusAnim = useSharedValue(0);
const handleFocus = (e: any) => {
setIsFocused(true);
focusAnim.value = withTiming(1, { duration: animations.timing.fast });
props.onFocus?.(e);
};
const handleBlur = (e: any) => {
setIsFocused(false);
focusAnim.value = withTiming(0, { duration: animations.timing.fast });
props.onBlur?.(e);
};
const animatedBorderStyle = useAnimatedStyle(() => ({
borderColor: interpolateColor(
focusAnim.value,
[0, 1],
[error ? colors.error : colors.glass.border, error ? colors.error : colors.primary]
),
}));
const useBlur = Platform.OS === 'ios';
const inputContent = (
<View style={styles.inputWrapper}>
{icon && <View style={styles.icon}>{icon}</View>}
<TextInput
{...props}
style={[styles.input, icon ? styles.inputWithIcon : undefined]}
placeholderTextColor={colors.textTertiary}
onFocus={handleFocus}
onBlur={handleBlur}
selectionColor={colors.primary}
/>
</View>
);
return (
<View style={[styles.container, containerStyle]}>
{label && <Text style={styles.label}>{label}</Text>}
{useBlur ? (
<AnimatedView style={[styles.blurWrapper, animatedBorderStyle]}>
<BlurView
intensity={blur.light}
tint="dark"
style={styles.blurContainer}
>
{inputContent}
</BlurView>
</AnimatedView>
) : (
<AnimatedView style={[styles.fallbackContainer, animatedBorderStyle]}>
{inputContent}
</AnimatedView>
)}
{error && <Text style={styles.error}>{error}</Text>}
</View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: 16,
},
label: {
...typography.subhead,
color: colors.textSecondary,
marginBottom: 8,
marginLeft: 4,
},
blurWrapper: {
borderRadius: radius.md,
borderWidth: 1,
overflow: 'hidden',
},
blurContainer: {
backgroundColor: colors.glass.background,
},
fallbackContainer: {
borderRadius: radius.md,
borderWidth: 1,
backgroundColor: colors.glass.backgroundLight,
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
},
icon: {
paddingLeft: 16,
},
input: {
flex: 1,
padding: 16,
fontSize: 16,
color: colors.text,
},
inputWithIcon: {
paddingLeft: 12,
},
error: {
...typography.caption1,
color: colors.error,
marginTop: 6,
marginLeft: 4,
},
});
export default GlassInput;
@@ -0,0 +1,71 @@
import React from 'react';
import { StyleSheet, View, ViewStyle } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { colors, gradients } from '../../theme';
interface GradientBackgroundProps {
children: React.ReactNode;
variant?: 'default' | 'reverse' | 'subtle';
style?: ViewStyle;
showAccent?: boolean;
}
export function GradientBackground({
children,
variant = 'default',
style,
showAccent = false,
}: GradientBackgroundProps) {
const gradientColors = variant === 'reverse'
? gradients.backgroundReverse
: gradients.background;
return (
<View style={[styles.container, style]}>
<LinearGradient
colors={gradientColors as [string, string]}
style={StyleSheet.absoluteFill}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
{showAccent && (
<>
{/* Top-right accent glow */}
<View style={styles.accentTopRight} />
{/* Bottom-left accent glow */}
<View style={styles.accentBottomLeft} />
</>
)}
{children}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
accentTopRight: {
position: 'absolute',
top: -100,
right: -100,
width: 300,
height: 300,
borderRadius: 150,
backgroundColor: colors.primaryGlow,
opacity: 0.15,
},
accentBottomLeft: {
position: 'absolute',
bottom: -50,
left: -100,
width: 250,
height: 250,
borderRadius: 125,
backgroundColor: colors.primaryGlow,
opacity: 0.1,
},
});
export default GradientBackground;
+4
View File
@@ -0,0 +1,4 @@
export { GradientBackground } from './GradientBackground';
export { GlassCard } from './GlassCard';
export { GlassButton } from './GlassButton';
export { GlassInput } from './GlassInput';
+204
View File
@@ -0,0 +1,204 @@
import { Platform } from 'react-native';
export const colors = {
// Backgrounds
background: '#0d0d1a',
backgroundLight: '#1a1a2e',
surface: 'rgba(22, 33, 62, 0.6)',
surfaceSolid: '#16213e',
// Glass effects
glass: {
background: 'rgba(22, 33, 62, 0.4)',
backgroundLight: 'rgba(22, 33, 62, 0.6)',
border: 'rgba(255, 255, 255, 0.08)',
borderLight: 'rgba(255, 255, 255, 0.12)',
highlight: 'rgba(255, 255, 255, 0.05)',
},
// Brand
primary: '#e94560',
primaryDark: '#c73a52',
primaryLight: 'rgba(233, 69, 96, 0.15)',
primaryGlow: 'rgba(233, 69, 96, 0.3)',
// Text
text: '#ffffff',
textSecondary: 'rgba(255, 255, 255, 0.6)',
textTertiary: 'rgba(255, 255, 255, 0.4)',
// Semantic
success: '#2ECC71',
successLight: 'rgba(46, 204, 113, 0.15)',
warning: '#F39C12',
warningLight: 'rgba(243, 156, 18, 0.15)',
error: '#E74C3C',
errorLight: 'rgba(231, 76, 60, 0.15)',
};
export const gradients = {
primary: ['#e94560', '#c73a52'],
primarySubtle: ['rgba(233, 69, 96, 0.8)', 'rgba(199, 58, 82, 0.8)'],
background: ['#1a1a2e', '#0d0d1a'],
backgroundReverse: ['#0d0d1a', '#1a1a2e'],
glass: ['rgba(255, 255, 255, 0.08)', 'rgba(255, 255, 255, 0)'],
success: ['#2ECC71', '#27ae60'],
warning: ['#F39C12', '#e67e22'],
error: ['#E74C3C', '#c0392b'],
};
export const spacing = {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 32,
xxl: 48,
};
export const radius = {
xs: 4,
sm: 8,
md: 12,
lg: 16,
xl: 24,
xxl: 32,
full: 9999,
};
export const typography = {
largeTitle: {
fontSize: 32,
fontWeight: 'bold' as const,
lineHeight: 40,
},
title1: {
fontSize: 28,
fontWeight: 'bold' as const,
lineHeight: 34,
},
title2: {
fontSize: 24,
fontWeight: 'bold' as const,
lineHeight: 30,
},
title3: {
fontSize: 20,
fontWeight: '600' as const,
lineHeight: 26,
},
headline: {
fontSize: 17,
fontWeight: '600' as const,
lineHeight: 22,
},
body: {
fontSize: 16,
fontWeight: 'normal' as const,
lineHeight: 22,
},
callout: {
fontSize: 15,
fontWeight: 'normal' as const,
lineHeight: 20,
},
subhead: {
fontSize: 14,
fontWeight: 'normal' as const,
lineHeight: 20,
},
footnote: {
fontSize: 13,
fontWeight: 'normal' as const,
lineHeight: 18,
},
caption1: {
fontSize: 12,
fontWeight: 'normal' as const,
lineHeight: 16,
},
caption2: {
fontSize: 11,
fontWeight: 'normal' as const,
lineHeight: 14,
},
};
export const shadows = {
none: {},
sm: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.15,
shadowRadius: 4,
elevation: 2,
},
md: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 4,
},
lg: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.25,
shadowRadius: 16,
elevation: 8,
},
xl: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 12 },
shadowOpacity: 0.3,
shadowRadius: 24,
elevation: 12,
},
glow: (color: string) => ({
shadowColor: color,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.5,
shadowRadius: 16,
elevation: 8,
}),
};
export const blur = {
light: 10,
medium: 20,
heavy: 40,
};
// Animation presets for react-native-reanimated
export const animations = {
spring: {
damping: 15,
stiffness: 150,
},
springBouncy: {
damping: 10,
stiffness: 100,
},
springGentle: {
damping: 20,
stiffness: 120,
},
timing: {
fast: 150,
normal: 250,
slow: 400,
},
};
export const theme = {
colors,
gradients,
spacing,
radius,
typography,
shadows,
blur,
animations,
};
export default theme;