mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 18:19:43 +00:00
Update
This commit is contained in:
1 parent
4d53d83769
commit
6ff5bc62f2
17 files changed
+2598
-1135
No files matched your search
@@ -1,7 +1,22 @@
|
|||||||
import { Tabs, Redirect } from "expo-router";
|
import { Tabs, Redirect } from "expo-router";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
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 { 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() {
|
export default function AppLayout() {
|
||||||
const { isAuthenticated, isLoading } = useAuthStore();
|
const { isAuthenticated, isLoading } = useAuthStore();
|
||||||
@@ -11,7 +26,7 @@ export default function AppLayout() {
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.loadingContainer}>
|
<View style={styles.loadingContainer}>
|
||||||
<ActivityIndicator size="large" color="#e94560" />
|
<ActivityIndicator size="large" color={colors.primary} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -25,19 +40,22 @@ export default function AppLayout() {
|
|||||||
<Tabs
|
<Tabs
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
|
tabBarBackground: () => <TabBarBackground />,
|
||||||
tabBarStyle: {
|
tabBarStyle: {
|
||||||
backgroundColor: "#16213e",
|
position: 'absolute',
|
||||||
borderTopColor: "#0f3460",
|
backgroundColor: 'transparent',
|
||||||
|
borderTopColor: colors.glass.border,
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
paddingTop: 8,
|
paddingTop: 8,
|
||||||
paddingBottom: 8,
|
paddingBottom: Platform.OS === 'ios' ? 28 : 8,
|
||||||
height: 60,
|
height: Platform.OS === 'ios' ? 88 : 64,
|
||||||
|
elevation: 0,
|
||||||
},
|
},
|
||||||
tabBarActiveTintColor: "#e94560",
|
tabBarActiveTintColor: colors.primary,
|
||||||
tabBarInactiveTintColor: "#666",
|
tabBarInactiveTintColor: colors.textTertiary,
|
||||||
tabBarLabelStyle: {
|
tabBarLabelStyle: {
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
fontWeight: "500",
|
fontWeight: "600",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -77,8 +95,9 @@ export default function AppLayout() {
|
|||||||
),
|
),
|
||||||
tabBarBadge: newMatchesCount > 0 ? newMatchesCount : undefined,
|
tabBarBadge: newMatchesCount > 0 ? newMatchesCount : undefined,
|
||||||
tabBarBadgeStyle: {
|
tabBarBadgeStyle: {
|
||||||
backgroundColor: "#e94560",
|
backgroundColor: colors.primary,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -112,6 +131,6 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
backgroundColor: "#1a1a2e",
|
backgroundColor: colors.background,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
+272
-214
@@ -1,13 +1,18 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
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 { useLocalSearchParams, useRouter, Stack } from "expo-router";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
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 { supabase } from "../../../src/lib/supabase";
|
||||||
import { useAuthStore } from "../../../src/store";
|
import { useAuthStore } from "../../../src/store";
|
||||||
import * as ImagePicker from "expo-image-picker";
|
import * as ImagePicker from "expo-image-picker";
|
||||||
import * as FileSystem from 'expo-file-system';
|
import * as FileSystem from 'expo-file-system';
|
||||||
import { decode } from 'base64-arraybuffer';
|
import { decode } from 'base64-arraybuffer';
|
||||||
import { Database } from "../../../src/types/supabase";
|
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"];
|
type Message = Database["public"]["Tables"]["messages"]["Row"];
|
||||||
|
|
||||||
@@ -27,7 +32,6 @@ export default function ChatScreen() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!matchId) return;
|
if (!matchId) return;
|
||||||
|
|
||||||
// Fetch match context (question and responses)
|
|
||||||
const fetchMatch = async () => {
|
const fetchMatch = async () => {
|
||||||
const { data } = await supabase
|
const { data } = await supabase
|
||||||
.from("matches")
|
.from("matches")
|
||||||
@@ -36,7 +40,6 @@ export default function ChatScreen() {
|
|||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
// Fetch responses for both users in this couple for this question
|
|
||||||
const { data: responses } = await supabase
|
const { data: responses } = await supabase
|
||||||
.from("responses")
|
.from("responses")
|
||||||
.select("*, profiles(name)")
|
.select("*, profiles(name)")
|
||||||
@@ -48,9 +51,8 @@ export default function ChatScreen() {
|
|||||||
};
|
};
|
||||||
fetchMatch();
|
fetchMatch();
|
||||||
|
|
||||||
// Fetch initial messages and mark as read
|
|
||||||
const fetchMessagesAndMarkRead = async () => {
|
const fetchMessagesAndMarkRead = async () => {
|
||||||
const { data, error } = await supabase
|
const { data } = await supabase
|
||||||
.from("messages")
|
.from("messages")
|
||||||
.select("*")
|
.select("*")
|
||||||
.eq("match_id", matchId)
|
.eq("match_id", matchId)
|
||||||
@@ -59,7 +61,6 @@ export default function ChatScreen() {
|
|||||||
if (data) {
|
if (data) {
|
||||||
setMessages(data);
|
setMessages(data);
|
||||||
|
|
||||||
// Identify unread messages from partner
|
|
||||||
const unreadIds = data
|
const unreadIds = data
|
||||||
.filter(m => m.user_id !== user?.id && !m.read_at)
|
.filter(m => m.user_id !== user?.id && !m.read_at)
|
||||||
.map(m => m.id);
|
.map(m => m.id);
|
||||||
@@ -70,7 +71,6 @@ export default function ChatScreen() {
|
|||||||
.update({ read_at: new Date().toISOString() })
|
.update({ read_at: new Date().toISOString() })
|
||||||
.in("id", unreadIds);
|
.in("id", unreadIds);
|
||||||
|
|
||||||
// Optimistically update local state
|
|
||||||
setMessages(prev => prev.map(m =>
|
setMessages(prev => prev.map(m =>
|
||||||
unreadIds.includes(m.id) ? { ...m, read_at: new Date().toISOString() } : m
|
unreadIds.includes(m.id) ? { ...m, read_at: new Date().toISOString() } : m
|
||||||
));
|
));
|
||||||
@@ -80,7 +80,6 @@ export default function ChatScreen() {
|
|||||||
|
|
||||||
fetchMessagesAndMarkRead();
|
fetchMessagesAndMarkRead();
|
||||||
|
|
||||||
// Subscribe to new messages, updates (read receipts), and typing
|
|
||||||
const channel = supabase
|
const channel = supabase
|
||||||
.channel(`chat:${matchId}`)
|
.channel(`chat:${matchId}`)
|
||||||
.on(
|
.on(
|
||||||
@@ -94,7 +93,6 @@ export default function ChatScreen() {
|
|||||||
async (payload) => {
|
async (payload) => {
|
||||||
const newMessage = payload.new as Message;
|
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) {
|
if (newMessage.user_id !== user?.id) {
|
||||||
await supabase
|
await supabase
|
||||||
.from("messages")
|
.from("messages")
|
||||||
@@ -104,7 +102,7 @@ export default function ChatScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setMessages((prev) => [newMessage, ...prev]);
|
setMessages((prev) => [newMessage, ...prev]);
|
||||||
setPartnerTyping(false); // Stop typing indicator if they sent a message
|
setPartnerTyping(false);
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.on(
|
.on(
|
||||||
@@ -126,7 +124,6 @@ export default function ChatScreen() {
|
|||||||
if (payload.payload.userId !== user?.id) {
|
if (payload.payload.userId !== user?.id) {
|
||||||
setPartnerTyping(true);
|
setPartnerTyping(true);
|
||||||
|
|
||||||
// Auto-hide typing indicator after 3s
|
|
||||||
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
|
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
|
||||||
typingTimeoutRef.current = setTimeout(() => {
|
typingTimeoutRef.current = setTimeout(() => {
|
||||||
setPartnerTyping(false);
|
setPartnerTyping(false);
|
||||||
@@ -242,92 +239,48 @@ export default function ChatScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const revealMessage = async (messageId: string) => {
|
const revealMessage = async (messageId: string) => {
|
||||||
// Optimistic update
|
|
||||||
setMessages(prev => prev.map(m =>
|
setMessages(prev => prev.map(m =>
|
||||||
m.id === messageId ? { ...m, media_viewed_at: new Date().toISOString() } : m
|
m.id === messageId ? { ...m, media_viewed_at: new Date().toISOString() } : m
|
||||||
));
|
));
|
||||||
|
|
||||||
const { error } = await supabase
|
await supabase
|
||||||
.from("messages")
|
.from("messages")
|
||||||
.update({ media_viewed_at: new Date().toISOString() })
|
.update({ media_viewed_at: new Date().toISOString() })
|
||||||
.eq("id", messageId);
|
.eq("id", messageId);
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error("Failed to mark as viewed", error);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderMessage = ({ item, index }: { item: Message; index: number }) => {
|
||||||
const renderMessage = ({ item }: { item: Message }) => {
|
|
||||||
const isMe = item.user_id === user?.id;
|
const isMe = item.user_id === user?.id;
|
||||||
return (
|
return (
|
||||||
<View style={[styles.messageRow, isMe ? styles.myMessageRow : styles.theirMessageRow]}>
|
<Animated.View
|
||||||
<View style={[styles.bubble, isMe ? styles.myBubble : styles.theirBubble]}>
|
entering={FadeInUp.delay(index * 30).duration(200)}
|
||||||
{item.media_path ? (
|
style={[styles.messageRow, isMe ? styles.myMessageRow : styles.theirMessageRow]}
|
||||||
<View>
|
>
|
||||||
<Image
|
{isMe ? (
|
||||||
source={{ uri: item.media_path }}
|
<LinearGradient
|
||||||
style={styles.messageImage}
|
colors={gradients.primary as [string, string]}
|
||||||
blurRadius={(!isMe && !item.media_viewed_at) ? 20 : 0}
|
style={[styles.bubble, styles.myBubble]}
|
||||||
/>
|
start={{ x: 0, y: 0 }}
|
||||||
{(!isMe && !item.media_viewed_at) && (
|
end={{ x: 1, y: 1 }}
|
||||||
<TouchableOpacity
|
>
|
||||||
style={styles.revealOverlay}
|
<MessageContent item={item} isMe={isMe} revealMessage={revealMessage} />
|
||||||
activeOpacity={0.8}
|
</LinearGradient>
|
||||||
onPress={() => revealMessage(item.id)}
|
) : (
|
||||||
>
|
<View style={[styles.bubble, styles.theirBubble]}>
|
||||||
<View style={styles.revealContent}>
|
<MessageContent item={item} isMe={isMe} revealMessage={revealMessage} />
|
||||||
<Ionicons name="eye-off-outline" size={32} color="#fff" />
|
|
||||||
<Text style={styles.revealText}>Tap to reveal</Text>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<Text style={styles.messageText}>{item.content}</Text>
|
|
||||||
)}
|
|
||||||
<View style={styles.metaContainer}>
|
|
||||||
<Text style={[styles.timestamp, isMe ? styles.myTimestamp : styles.theirTimestamp]}>
|
|
||||||
{new Date(item.created_at!).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
|
||||||
</Text>
|
|
||||||
{isMe && (
|
|
||||||
<Ionicons
|
|
||||||
name={item.read_at ? "checkmark-done-outline" : "checkmark-outline"}
|
|
||||||
size={14}
|
|
||||||
color={item.read_at ? "#4cd137" : "rgba(255,255,255,0.7)"}
|
|
||||||
style={styles.readIcon}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
)}
|
||||||
</View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<GradientBackground>
|
||||||
<Stack.Screen options={{
|
<Stack.Screen options={{
|
||||||
title: "Chat",
|
title: "Chat",
|
||||||
headerTitle: match?.question?.text ? () => (
|
headerStyle: { backgroundColor: colors.backgroundLight },
|
||||||
<View style={styles.headerTitleContainer}>
|
headerTintColor: colors.text,
|
||||||
<Text style={styles.headerTitle} numberOfLines={2}>
|
headerShadowVisible: false,
|
||||||
{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
|
<KeyboardAvoidingView
|
||||||
@@ -337,21 +290,25 @@ export default function ChatScreen() {
|
|||||||
>
|
>
|
||||||
{/* Sticky top bar with question and answers */}
|
{/* Sticky top bar with question and answers */}
|
||||||
{match?.question && (
|
{match?.question && (
|
||||||
<View style={styles.stickyTopBar}>
|
<GlassCard style={styles.stickyTopBar} noPadding>
|
||||||
<Text style={styles.stickyQuestionText}>
|
<View style={styles.stickyContent}>
|
||||||
{match.question.text}
|
<Text style={styles.stickyQuestionText} numberOfLines={2}>
|
||||||
{match.question.partner_text ? ` & ${match.question.partner_text}` : ""}
|
{match.question.text}
|
||||||
</Text>
|
{match.question.partner_text ? ` & ${match.question.partner_text}` : ""}
|
||||||
{match.responses && match.responses.length > 0 && (
|
</Text>
|
||||||
<View style={styles.stickyAnswersContainer}>
|
{match.responses && match.responses.length > 0 && (
|
||||||
{match.responses.map((response) => (
|
<View style={styles.stickyAnswersContainer}>
|
||||||
<Text key={response.user_id} style={styles.stickyAnswerText}>
|
{match.responses.map((response: any) => (
|
||||||
{response.user_id === user?.id ? 'You' : (response.profiles?.name || 'Partner')}: {response.answer}
|
<View key={response.user_id} style={styles.answerBadge}>
|
||||||
</Text>
|
<Text style={styles.stickyAnswerText}>
|
||||||
))}
|
{response.user_id === user?.id ? 'You' : (response.profiles?.name || 'Partner')}: {response.answer}
|
||||||
</View>
|
</Text>
|
||||||
)}
|
</View>
|
||||||
</View>
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</GlassCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FlatList
|
<FlatList
|
||||||
@@ -362,59 +319,165 @@ export default function ChatScreen() {
|
|||||||
inverted
|
inverted
|
||||||
contentContainerStyle={styles.listContent}
|
contentContainerStyle={styles.listContent}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
ListHeaderComponent={
|
ListHeaderComponent={
|
||||||
partnerTyping ? (
|
partnerTyping ? (
|
||||||
<View style={styles.typingContainer}>
|
<Animated.View
|
||||||
|
entering={FadeIn.duration(200)}
|
||||||
|
style={styles.typingContainer}
|
||||||
|
>
|
||||||
<Text style={styles.typingText}>Partner is typing...</Text>
|
<Text style={styles.typingText}>Partner is typing...</Text>
|
||||||
</View>
|
</Animated.View>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<View style={styles.inputContainer}>
|
{/* Input bar with glass effect */}
|
||||||
<TouchableOpacity onPress={handlePickImage} disabled={uploading} style={styles.attachButton}>
|
{Platform.OS === 'ios' ? (
|
||||||
{uploading ? (
|
<BlurView intensity={blur.medium} tint="dark" style={styles.inputWrapper}>
|
||||||
<ActivityIndicator color="#e94560" size="small" />
|
<InputBar
|
||||||
) : (
|
inputText={inputText}
|
||||||
<Ionicons name="image-outline" size={24} color="#e94560" />
|
uploading={uploading}
|
||||||
)}
|
onChangeText={handleTyping}
|
||||||
</TouchableOpacity>
|
onSend={handleSend}
|
||||||
|
onPickImage={handlePickImage}
|
||||||
<TextInput
|
|
||||||
style={styles.input}
|
|
||||||
value={inputText}
|
|
||||||
onChangeText={handleTyping}
|
|
||||||
placeholder="Type a message..."
|
|
||||||
placeholderTextColor="#666"
|
|
||||||
multiline
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TouchableOpacity onPress={handleSend} disabled={!inputText.trim()} style={styles.sendButton}>
|
|
||||||
<Ionicons
|
|
||||||
name="send"
|
|
||||||
size={24}
|
|
||||||
color={inputText.trim() ? "#e94560" : "#666"}
|
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</BlurView>
|
||||||
</View>
|
) : (
|
||||||
|
<View style={[styles.inputWrapper, styles.inputWrapperAndroid]}>
|
||||||
|
<InputBar
|
||||||
|
inputText={inputText}
|
||||||
|
uploading={uploading}
|
||||||
|
onChangeText={handleTyping}
|
||||||
|
onSend={handleSend}
|
||||||
|
onPickImage={handlePickImage}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</KeyboardAvoidingView>
|
</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 }}
|
||||||
|
style={styles.messageImage}
|
||||||
|
blurRadius={(!isMe && !item.media_viewed_at) ? 20 : 0}
|
||||||
|
/>
|
||||||
|
{(!isMe && !item.media_viewed_at) && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.revealOverlay}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
onPress={() => revealMessage(item.id)}
|
||||||
|
>
|
||||||
|
<View style={styles.revealContent}>
|
||||||
|
<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}>
|
||||||
|
{new Date(item.created_at!).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</Text>
|
||||||
|
{isMe && (
|
||||||
|
<Ionicons
|
||||||
|
name={item.read_at ? "checkmark-done" : "checkmark"}
|
||||||
|
size={14}
|
||||||
|
color={item.read_at ? colors.success : "rgba(255,255,255,0.6)"}
|
||||||
|
style={styles.readIcon}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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={onPickImage} disabled={uploading} style={styles.attachButton}>
|
||||||
|
{uploading ? (
|
||||||
|
<ActivityIndicator color={colors.primary} size="small" />
|
||||||
|
) : (
|
||||||
|
<Ionicons name="image-outline" size={24} color={colors.primary} />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View style={styles.inputFieldWrapper}>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={inputText}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
placeholder="Type a message..."
|
||||||
|
placeholderTextColor={colors.textTertiary}
|
||||||
|
multiline
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onSend}
|
||||||
|
disabled={!inputText.trim()}
|
||||||
|
style={[styles.sendButton, inputText.trim() && styles.sendButtonActive]}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="send"
|
||||||
|
size={20}
|
||||||
|
color={inputText.trim() ? colors.text : colors.textTertiary}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: "#1a1a2e",
|
|
||||||
},
|
|
||||||
keyboardAvoiding: {
|
keyboardAvoiding: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
listContent: {
|
listContent: {
|
||||||
padding: 16,
|
padding: spacing.md,
|
||||||
|
paddingBottom: spacing.lg,
|
||||||
},
|
},
|
||||||
messageRow: {
|
messageRow: {
|
||||||
marginBottom: 16,
|
marginBottom: spacing.sm,
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
},
|
},
|
||||||
myMessageRow: {
|
myMessageRow: {
|
||||||
@@ -424,148 +487,143 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: "flex-start",
|
justifyContent: "flex-start",
|
||||||
},
|
},
|
||||||
bubble: {
|
bubble: {
|
||||||
maxWidth: "75%",
|
maxWidth: "78%",
|
||||||
padding: 12,
|
padding: spacing.md,
|
||||||
borderRadius: 20,
|
borderRadius: radius.xl,
|
||||||
shadowColor: "#000",
|
...shadows.sm,
|
||||||
shadowOffset: { width: 0, height: 2 },
|
|
||||||
shadowOpacity: 0.1,
|
|
||||||
shadowRadius: 4,
|
|
||||||
elevation: 2,
|
|
||||||
},
|
},
|
||||||
myBubble: {
|
myBubble: {
|
||||||
backgroundColor: "#e94560",
|
borderBottomRightRadius: radius.xs,
|
||||||
borderBottomRightRadius: 4,
|
|
||||||
},
|
},
|
||||||
theirBubble: {
|
theirBubble: {
|
||||||
backgroundColor: "#16213e",
|
backgroundColor: colors.glass.backgroundLight,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: "#0f3460",
|
borderColor: colors.glass.border,
|
||||||
borderBottomLeftRadius: 4,
|
borderBottomLeftRadius: radius.xs,
|
||||||
},
|
},
|
||||||
messageText: {
|
messageText: {
|
||||||
color: "#fff",
|
...typography.body,
|
||||||
fontSize: 16,
|
color: colors.text,
|
||||||
lineHeight: 22,
|
|
||||||
},
|
},
|
||||||
messageImage: {
|
messageImage: {
|
||||||
width: 200,
|
width: 200,
|
||||||
height: 200,
|
height: 200,
|
||||||
borderRadius: 12,
|
borderRadius: radius.md,
|
||||||
backgroundColor: "rgba(0,0,0,0.2)",
|
backgroundColor: colors.glass.background,
|
||||||
},
|
},
|
||||||
metaContainer: {
|
metaContainer: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "flex-end",
|
justifyContent: "flex-end",
|
||||||
marginTop: 4,
|
marginTop: spacing.xs,
|
||||||
},
|
},
|
||||||
timestamp: {
|
timestamp: {
|
||||||
fontSize: 10,
|
...typography.caption2,
|
||||||
},
|
color: "rgba(255, 255, 255, 0.6)",
|
||||||
myTimestamp: {
|
|
||||||
color: "rgba(255, 255, 255, 0.7)",
|
|
||||||
},
|
|
||||||
theirTimestamp: {
|
|
||||||
color: "rgba(255, 255, 255, 0.4)",
|
|
||||||
},
|
},
|
||||||
readIcon: {
|
readIcon: {
|
||||||
marginLeft: 4,
|
marginLeft: spacing.xs,
|
||||||
|
},
|
||||||
|
inputWrapper: {
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.glass.border,
|
||||||
|
},
|
||||||
|
inputWrapperAndroid: {
|
||||||
|
backgroundColor: colors.glass.backgroundLight,
|
||||||
},
|
},
|
||||||
inputContainer: {
|
inputContainer: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "flex-end",
|
||||||
padding: 12,
|
padding: spacing.sm,
|
||||||
paddingBottom: Platform.OS === "ios" ? 34 : 12,
|
paddingBottom: Platform.OS === "ios" ? spacing.xl : spacing.sm,
|
||||||
backgroundColor: "#16213e",
|
|
||||||
borderTopWidth: 1,
|
|
||||||
borderTopColor: "#0f3460",
|
|
||||||
},
|
},
|
||||||
attachButton: {
|
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: {
|
input: {
|
||||||
flex: 1,
|
paddingHorizontal: spacing.md,
|
||||||
backgroundColor: "#0f3460",
|
paddingVertical: spacing.sm,
|
||||||
borderRadius: 24,
|
color: colors.text,
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingVertical: 10,
|
|
||||||
color: "#fff",
|
|
||||||
maxHeight: 120,
|
|
||||||
marginHorizontal: 8,
|
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
|
maxHeight: 100,
|
||||||
},
|
},
|
||||||
sendButton: {
|
sendButton: {
|
||||||
padding: 10,
|
width: 40,
|
||||||
},
|
height: 40,
|
||||||
headerTitleContainer: {
|
borderRadius: 20,
|
||||||
|
backgroundColor: colors.glass.background,
|
||||||
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
maxWidth: 200,
|
marginBottom: spacing.xs,
|
||||||
},
|
},
|
||||||
headerTitle: {
|
sendButtonActive: {
|
||||||
color: "#fff",
|
backgroundColor: colors.primary,
|
||||||
fontWeight: "bold",
|
|
||||||
fontSize: 16,
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
answersContainer: {
|
|
||||||
marginTop: 4,
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
answerText: {
|
|
||||||
color: "rgba(255,255,255,0.8)",
|
|
||||||
fontSize: 12,
|
|
||||||
marginBottom: 2,
|
|
||||||
},
|
},
|
||||||
typingContainer: {
|
typingContainer: {
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: spacing.md,
|
||||||
paddingVertical: 8,
|
paddingVertical: spacing.sm,
|
||||||
},
|
},
|
||||||
typingText: {
|
typingText: {
|
||||||
color: "#666",
|
...typography.caption1,
|
||||||
|
color: colors.textTertiary,
|
||||||
fontStyle: "italic",
|
fontStyle: "italic",
|
||||||
fontSize: 12,
|
|
||||||
},
|
},
|
||||||
revealOverlay: {
|
revealOverlay: {
|
||||||
...StyleSheet.absoluteFillObject,
|
...StyleSheet.absoluteFillObject,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
backgroundColor: "rgba(0,0,0,0.3)",
|
backgroundColor: "rgba(0,0,0,0.4)",
|
||||||
borderRadius: 12,
|
borderRadius: radius.md,
|
||||||
},
|
},
|
||||||
revealContent: {
|
revealContent: {
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
backgroundColor: "rgba(0,0,0,0.6)",
|
backgroundColor: "rgba(0,0,0,0.6)",
|
||||||
padding: 12,
|
padding: spacing.md,
|
||||||
borderRadius: 12,
|
borderRadius: radius.md,
|
||||||
},
|
},
|
||||||
revealText: {
|
revealText: {
|
||||||
color: "#fff",
|
...typography.caption1,
|
||||||
fontSize: 12,
|
color: colors.text,
|
||||||
fontWeight: "bold",
|
fontWeight: "600",
|
||||||
marginTop: 4,
|
marginTop: spacing.xs,
|
||||||
},
|
},
|
||||||
stickyTopBar: {
|
stickyTopBar: {
|
||||||
backgroundColor: "#16213e",
|
marginHorizontal: spacing.md,
|
||||||
borderBottomWidth: 1,
|
marginTop: spacing.sm,
|
||||||
borderBottomColor: "#0f3460",
|
marginBottom: spacing.xs,
|
||||||
padding: 16,
|
},
|
||||||
paddingTop: 12,
|
stickyContent: {
|
||||||
paddingBottom: 12,
|
padding: spacing.md,
|
||||||
},
|
},
|
||||||
stickyQuestionText: {
|
stickyQuestionText: {
|
||||||
color: "#fff",
|
...typography.subhead,
|
||||||
fontSize: 16,
|
color: colors.text,
|
||||||
fontWeight: "bold",
|
fontWeight: "600",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
marginBottom: 8,
|
marginBottom: spacing.sm,
|
||||||
},
|
},
|
||||||
stickyAnswersContainer: {
|
stickyAnswersContainer: {
|
||||||
alignItems: "center",
|
flexDirection: "row",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
answerBadge: {
|
||||||
|
backgroundColor: colors.primaryLight,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
borderRadius: radius.sm,
|
||||||
},
|
},
|
||||||
stickyAnswerText: {
|
stickyAnswerText: {
|
||||||
color: "rgba(255,255,255,0.8)",
|
...typography.caption1,
|
||||||
fontSize: 14,
|
color: colors.text,
|
||||||
marginBottom: 4,
|
},
|
||||||
}
|
|
||||||
});
|
});
|
||||||
+209
-120
@@ -1,8 +1,12 @@
|
|||||||
import { useEffect } from "react";
|
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 { useAuthStore, useMatchStore, usePacksStore } from "../../src/store";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { router } from "expo-router";
|
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() {
|
export default function HomeScreen() {
|
||||||
const { user, partner } = useAuthStore();
|
const { user, partner } = useAuthStore();
|
||||||
@@ -17,190 +21,275 @@ export default function HomeScreen() {
|
|||||||
const recentMatches = matches.slice(0, 3);
|
const recentMatches = matches.slice(0, 3);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView style={styles.container}>
|
<GradientBackground>
|
||||||
<View style={styles.header}>
|
<ScrollView
|
||||||
<Text style={styles.greeting}>
|
style={styles.container}
|
||||||
Hey, {user?.name || "there"} 👋
|
contentContainerStyle={styles.contentContainer}
|
||||||
</Text>
|
showsVerticalScrollIndicator={false}
|
||||||
{partner && (
|
|
||||||
<Text style={styles.partnerText}>
|
|
||||||
Paired with {partner.name || partner.email || 'your partner'}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Quick Stats */}
|
|
||||||
<View style={styles.statsContainer}>
|
|
||||||
<View style={styles.statCard}>
|
|
||||||
<Ionicons name="heart" size={24} color="#e94560" />
|
|
||||||
<Text style={styles.statNumber}>{matches.length}</Text>
|
|
||||||
<Text style={styles.statLabel}>Matches</Text>
|
|
||||||
</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>
|
|
||||||
</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>
|
|
||||||
|
|
||||||
{/* Start Playing CTA */}
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.ctaCard}
|
|
||||||
onPress={() => router.push("/(app)/swipe")}
|
|
||||||
>
|
>
|
||||||
<View style={styles.ctaContent}>
|
{/* Header */}
|
||||||
<Text style={styles.ctaTitle}>Ready to explore?</Text>
|
<Animated.View
|
||||||
<Text style={styles.ctaSubtitle}>
|
entering={FadeInDown.delay(100).duration(500)}
|
||||||
Swipe through questions and discover what you both enjoy
|
style={styles.header}
|
||||||
</Text>
|
>
|
||||||
</View>
|
<View>
|
||||||
<Ionicons name="arrow-forward" size={24} color="#fff" />
|
<Text style={styles.greeting}>
|
||||||
</TouchableOpacity>
|
Hey, {user?.name || "there"}
|
||||||
|
</Text>
|
||||||
{/* Recent Matches */}
|
{partner && (
|
||||||
{recentMatches.length > 0 && (
|
<Text style={styles.partnerText}>
|
||||||
<View style={styles.section}>
|
Paired with {partner.name || partner.email || 'your partner'}
|
||||||
<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}>
|
|
||||||
<View style={styles.matchIcon}>
|
|
||||||
<Ionicons name="heart" size={20} color="#e94560" />
|
|
||||||
</View>
|
|
||||||
<Text style={styles.matchText} numberOfLines={1}>
|
|
||||||
{(match as any).question?.text || "A new match!"}
|
|
||||||
</Text>
|
</Text>
|
||||||
{match.is_new && <View style={styles.newBadge} />}
|
)}
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.profileButton}
|
||||||
|
onPress={() => router.push("/(app)/profile")}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={styles.profileButtonText}>
|
||||||
|
{user?.name?.[0]?.toUpperCase() || "U"}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Quick Stats */}
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInDown.delay(200).duration(500)}
|
||||||
|
style={styles.statsContainer}
|
||||||
|
>
|
||||||
|
<GlassCard style={styles.statCard}>
|
||||||
|
<View style={styles.statIconContainer}>
|
||||||
|
<Ionicons name="heart" size={20} color={colors.primary} />
|
||||||
</View>
|
</View>
|
||||||
))}
|
<Text style={styles.statNumber}>{matches.length}</Text>
|
||||||
</View>
|
<Text style={styles.statLabel}>Matches</Text>
|
||||||
)}
|
</GlassCard>
|
||||||
</ScrollView>
|
<GlassCard style={styles.statCard}>
|
||||||
|
<View style={styles.statIconContainer}>
|
||||||
|
<Ionicons name="layers" size={20} color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.statNumber}>{packs.length}</Text>
|
||||||
|
<Text style={styles.statLabel}>Packs</Text>
|
||||||
|
</GlassCard>
|
||||||
|
<GlassCard style={styles.statCard}>
|
||||||
|
<View style={[styles.statIconContainer, newMatchesCount > 0 && styles.statIconHighlight]}>
|
||||||
|
<Ionicons name="sparkles" size={20} color={newMatchesCount > 0 ? colors.text : colors.primary} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.statNumber}>{newMatchesCount}</Text>
|
||||||
|
<Text style={styles.statLabel}>New</Text>
|
||||||
|
</GlassCard>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Start Playing CTA */}
|
||||||
|
<Animated.View entering={FadeInDown.delay(300).duration(500)}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.push("/(app)/swipe")}
|
||||||
|
activeOpacity={0.9}
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={gradients.primary as [string, string]}
|
||||||
|
style={styles.ctaCard}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
<View style={styles.ctaContent}>
|
||||||
|
<Text style={styles.ctaTitle}>Ready to explore?</Text>
|
||||||
|
<Text style={styles.ctaSubtitle}>
|
||||||
|
Swipe through questions and discover what you both enjoy
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.ctaIconContainer}>
|
||||||
|
<Ionicons name="arrow-forward" size={24} color={colors.text} />
|
||||||
|
</View>
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Recent Matches */}
|
||||||
|
{recentMatches.length > 0 && (
|
||||||
|
<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, 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={18} color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.matchText} numberOfLines={1}>
|
||||||
|
{(match as any).question?.text || "A new match!"}
|
||||||
|
</Text>
|
||||||
|
{match.is_new && <View style={styles.newBadge} />}
|
||||||
|
</GlassCard>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Animated.View>
|
||||||
|
))}
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bottom spacing for tab bar */}
|
||||||
|
<View style={styles.bottomSpacer} />
|
||||||
|
</ScrollView>
|
||||||
|
</GradientBackground>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: "#1a1a2e",
|
},
|
||||||
|
contentContainer: {
|
||||||
|
paddingBottom: Platform.OS === 'ios' ? 100 : 80,
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
paddingTop: 60,
|
paddingTop: 60,
|
||||||
paddingHorizontal: 24,
|
paddingHorizontal: spacing.lg,
|
||||||
paddingBottom: 24,
|
paddingBottom: spacing.lg,
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
greeting: {
|
greeting: {
|
||||||
fontSize: 28,
|
...typography.title1,
|
||||||
fontWeight: "bold",
|
color: colors.text,
|
||||||
color: "#fff",
|
|
||||||
},
|
},
|
||||||
partnerText: {
|
partnerText: {
|
||||||
fontSize: 14,
|
...typography.subhead,
|
||||||
color: "#888",
|
color: colors.textSecondary,
|
||||||
marginTop: 4,
|
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: {
|
statsContainer: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
paddingHorizontal: 24,
|
paddingHorizontal: spacing.lg,
|
||||||
gap: 12,
|
gap: spacing.sm,
|
||||||
},
|
},
|
||||||
statCard: {
|
statCard: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: "#16213e",
|
|
||||||
borderRadius: 16,
|
|
||||||
padding: 16,
|
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
borderWidth: 1,
|
paddingVertical: spacing.md,
|
||||||
borderColor: "#0f3460",
|
},
|
||||||
|
statIconContainer: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: colors.primaryLight,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
statIconHighlight: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
},
|
},
|
||||||
statNumber: {
|
statNumber: {
|
||||||
fontSize: 24,
|
...typography.title2,
|
||||||
fontWeight: "bold",
|
color: colors.text,
|
||||||
color: "#fff",
|
|
||||||
marginTop: 8,
|
|
||||||
},
|
},
|
||||||
statLabel: {
|
statLabel: {
|
||||||
fontSize: 12,
|
...typography.caption1,
|
||||||
color: "#888",
|
color: colors.textSecondary,
|
||||||
marginTop: 4,
|
marginTop: spacing.xs,
|
||||||
},
|
},
|
||||||
ctaCard: {
|
ctaCard: {
|
||||||
margin: 24,
|
margin: spacing.lg,
|
||||||
backgroundColor: "#e94560",
|
borderRadius: radius.xl,
|
||||||
borderRadius: 16,
|
padding: spacing.lg,
|
||||||
padding: 20,
|
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
...shadows.lg,
|
||||||
},
|
},
|
||||||
ctaContent: {
|
ctaContent: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
ctaTitle: {
|
ctaTitle: {
|
||||||
fontSize: 18,
|
...typography.title3,
|
||||||
fontWeight: "bold",
|
color: colors.text,
|
||||||
color: "#fff",
|
|
||||||
},
|
},
|
||||||
ctaSubtitle: {
|
ctaSubtitle: {
|
||||||
fontSize: 14,
|
...typography.subhead,
|
||||||
color: "rgba(255,255,255,0.8)",
|
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: {
|
section: {
|
||||||
paddingHorizontal: 24,
|
paddingHorizontal: spacing.lg,
|
||||||
marginBottom: 24,
|
marginTop: spacing.sm,
|
||||||
},
|
},
|
||||||
sectionHeader: {
|
sectionHeader: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
marginBottom: 16,
|
marginBottom: spacing.md,
|
||||||
},
|
},
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
fontSize: 18,
|
...typography.headline,
|
||||||
fontWeight: "600",
|
color: colors.text,
|
||||||
color: "#fff",
|
|
||||||
},
|
},
|
||||||
seeAll: {
|
seeAll: {
|
||||||
color: "#e94560",
|
...typography.subhead,
|
||||||
fontSize: 14,
|
color: colors.primary,
|
||||||
},
|
},
|
||||||
matchItem: {
|
matchItem: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
backgroundColor: "#16213e",
|
marginBottom: spacing.sm,
|
||||||
borderRadius: 12,
|
|
||||||
padding: 16,
|
|
||||||
marginBottom: 8,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: "#0f3460",
|
|
||||||
},
|
},
|
||||||
matchIcon: {
|
matchIcon: {
|
||||||
width: 40,
|
width: 36,
|
||||||
height: 40,
|
height: 36,
|
||||||
borderRadius: 20,
|
borderRadius: 18,
|
||||||
backgroundColor: "rgba(233, 69, 96, 0.2)",
|
backgroundColor: colors.primaryLight,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
marginRight: 12,
|
marginRight: spacing.md,
|
||||||
},
|
},
|
||||||
matchText: {
|
matchText: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
color: "#fff",
|
...typography.body,
|
||||||
fontSize: 14,
|
color: colors.text,
|
||||||
},
|
},
|
||||||
newBadge: {
|
newBadge: {
|
||||||
width: 8,
|
width: 8,
|
||||||
height: 8,
|
height: 8,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
backgroundColor: "#e94560",
|
backgroundColor: colors.primary,
|
||||||
|
},
|
||||||
|
bottomSpacer: {
|
||||||
|
height: spacing.lg,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
+190
-130
@@ -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 { useMatchStore, useAuthStore } from "../../src/store";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useRouter } from "expo-router";
|
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() {
|
export default function MatchesScreen() {
|
||||||
const { matches, fetchMatches, markAllAsSeen } = useMatchStore();
|
const { matches, fetchMatches, markAllAsSeen } = useMatchStore();
|
||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchMatches();
|
fetchMatches();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Mark all matches as seen when matches are loaded
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (matches.length > 0) {
|
if (matches.length > 0) {
|
||||||
markAllAsSeen();
|
markAllAsSeen();
|
||||||
}
|
}
|
||||||
}, [matches.length]);
|
}, [matches.length]);
|
||||||
|
|
||||||
const router = useRouter();
|
const renderItem = ({ item, index }: { item: any; index: number }) => {
|
||||||
|
|
||||||
const renderItem = ({ item }: { item: any }) => {
|
|
||||||
const userResponse = item.responses?.find((r: any) => r.user_id === user?.id);
|
const userResponse = item.responses?.find((r: any) => r.user_id === user?.id);
|
||||||
const partnerResponse = item.responses?.find((r: any) => r.user_id !== user?.id);
|
const partnerResponse = item.responses?.find((r: any) => r.user_id !== user?.id);
|
||||||
|
|
||||||
@@ -32,161 +33,220 @@ export default function MatchesScreen() {
|
|||||||
const userTime = new Date(userResponse.created_at).getTime();
|
const userTime = new Date(userResponse.created_at).getTime();
|
||||||
const partnerTime = new Date(partnerResponse.created_at).getTime();
|
const partnerTime = new Date(partnerResponse.created_at).getTime();
|
||||||
|
|
||||||
// If user answered after partner, they saw the partner_text
|
|
||||||
if (userTime > partnerTime) {
|
if (userTime > partnerTime) {
|
||||||
userText = item.question.partner_text;
|
userText = item.question.partner_text;
|
||||||
partnerText = item.question.text;
|
partnerText = item.question.text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isYesYes = item.match_type === "yes_yes";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<Animated.View entering={FadeInRight.delay(index * 50).duration(300)}>
|
||||||
style={styles.matchCard}
|
<TouchableOpacity
|
||||||
onPress={() => router.push(`/chat/${item.id}`)}
|
onPress={() => router.push(`/chat/${item.id}`)}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<View style={styles.iconContainer}>
|
<GlassCard style={styles.matchCard}>
|
||||||
<Ionicons name="sparkles" size={24} color="#e94560" />
|
<View style={styles.iconContainer}>
|
||||||
</View>
|
<Ionicons
|
||||||
<View style={styles.content}>
|
name={isYesYes ? "heart" : "heart-half"}
|
||||||
<Text style={styles.questionText}>{userText}</Text>
|
size={24}
|
||||||
{item.question.partner_text && (
|
color={colors.primary}
|
||||||
<Text style={styles.partnerText}>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"}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.date}>
|
<View style={styles.content}>
|
||||||
{new Date(item.created_at).toLocaleDateString()}
|
<Text style={styles.questionText} numberOfLines={2}>
|
||||||
</Text>
|
{userText}
|
||||||
</View>
|
</Text>
|
||||||
</View>
|
{item.question.partner_text && (
|
||||||
</TouchableOpacity>
|
<Text style={styles.partnerText} numberOfLines={1}>
|
||||||
|
Partner: {partnerText}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<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}>
|
||||||
|
{new Date(item.created_at).toLocaleDateString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.chevronContainer}>
|
||||||
|
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
|
||||||
|
</View>
|
||||||
|
</GlassCard>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Animated.View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<GradientBackground>
|
||||||
<View style={styles.loadingContainer}>
|
<View style={styles.loadingContainer}>
|
||||||
<ActivityIndicator color="#e94560" />
|
<ActivityIndicator color={colors.primary} size="large" />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</GradientBackground>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<GradientBackground>
|
||||||
<View style={styles.header}>
|
<View style={styles.container}>
|
||||||
<Text style={styles.title}>Your Matches</Text>
|
<Animated.View
|
||||||
</View>
|
entering={FadeInDown.duration(400)}
|
||||||
|
style={styles.header}
|
||||||
|
>
|
||||||
|
<Text style={styles.title}>Your Matches</Text>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
{matches.length} {matches.length === 1 ? 'match' : 'matches'} found
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
<FlatList
|
<FlatList
|
||||||
data={matches}
|
data={matches}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
contentContainerStyle={styles.list}
|
contentContainerStyle={styles.list}
|
||||||
refreshControl={
|
showsVerticalScrollIndicator={false}
|
||||||
<RefreshControl refreshing={false} onRefresh={fetchMatches} tintColor="#e94560" />
|
refreshControl={
|
||||||
}
|
<RefreshControl
|
||||||
ListEmptyComponent={
|
refreshing={false}
|
||||||
<View style={styles.emptyContainer}>
|
onRefresh={fetchMatches}
|
||||||
<Text style={styles.emptyText}>No matches yet. Keep swiping!</Text>
|
tintColor={colors.primary}
|
||||||
</View>
|
colors={[colors.primary]}
|
||||||
}
|
/>
|
||||||
/>
|
}
|
||||||
</View>
|
ListEmptyComponent={
|
||||||
|
<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({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
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: {
|
loadingContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "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",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
+164
-79
@@ -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 { usePacksStore } from "../../src/store";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { useEffect } from "react";
|
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() {
|
export default function PacksScreen() {
|
||||||
const { packs, enabledPackIds, togglePack, isLoading, fetchPacks } = usePacksStore();
|
const { packs, enabledPackIds, togglePack, isLoading, fetchPacks } = usePacksStore();
|
||||||
@@ -11,86 +14,132 @@ export default function PacksScreen() {
|
|||||||
fetchPacks();
|
fetchPacks();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const renderItem = ({ item }: { item: any }) => (
|
const enabledCount = packs.filter(p => enabledPackIds.includes(p.id)).length;
|
||||||
<View style={styles.card}>
|
|
||||||
<TouchableOpacity
|
const renderItem = ({ item, index }: { item: any; index: number }) => {
|
||||||
style={styles.cardContent}
|
const isEnabled = enabledPackIds.includes(item.id);
|
||||||
activeOpacity={0.7}
|
|
||||||
onPress={() => router.push(`/pack/${item.id}`)}
|
return (
|
||||||
>
|
<Animated.View entering={FadeInRight.delay(index * 50).duration(300)}>
|
||||||
<View style={styles.iconContainer}>
|
<GlassCard style={styles.card}>
|
||||||
<Text style={styles.emoji}>{item.icon || "📦"}</Text>
|
<TouchableOpacity
|
||||||
</View>
|
style={styles.cardContent}
|
||||||
<View style={styles.content}>
|
activeOpacity={0.7}
|
||||||
<View style={styles.headerRow}>
|
onPress={() => router.push(`/pack/${item.id}`)}
|
||||||
<Text style={styles.name}>{item.name}</Text>
|
>
|
||||||
{item.is_premium && (
|
<View style={[styles.iconContainer, isEnabled && styles.iconContainerActive]}>
|
||||||
<View style={styles.badge}>
|
<Text style={styles.emoji}>{item.icon || "📦"}</Text>
|
||||||
<Text style={styles.badgeText}>PRO</Text>
|
</View>
|
||||||
|
<View style={styles.content}>
|
||||||
|
<View style={styles.headerRow}>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
<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={isEnabled}
|
||||||
|
onValueChange={() => togglePack(item.id)}
|
||||||
|
trackColor={{
|
||||||
|
false: colors.glass.background,
|
||||||
|
true: colors.primary,
|
||||||
|
}}
|
||||||
|
thumbColor={colors.text}
|
||||||
|
ios_backgroundColor={colors.glass.background}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.description} numberOfLines={2}>
|
</GlassCard>
|
||||||
{item.description}
|
</Animated.View>
|
||||||
</Text>
|
);
|
||||||
</View>
|
};
|
||||||
</TouchableOpacity>
|
|
||||||
<Switch
|
|
||||||
value={enabledPackIds.includes(item.id)}
|
|
||||||
onValueChange={() => togglePack(item.id)}
|
|
||||||
trackColor={{ false: "#767577", true: "#e94560" }}
|
|
||||||
thumbColor={"#fff"}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<GradientBackground>
|
||||||
<View style={styles.header}>
|
<View style={styles.container}>
|
||||||
<Text style={styles.title}>Question Packs</Text>
|
<Animated.View
|
||||||
</View>
|
entering={FadeInDown.duration(400)}
|
||||||
|
style={styles.header}
|
||||||
|
>
|
||||||
|
<Text style={styles.title}>Question Packs</Text>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
{enabledCount} of {packs.length} packs enabled
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
<FlatList
|
<FlatList
|
||||||
data={packs}
|
data={packs}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
contentContainerStyle={styles.list}
|
contentContainerStyle={styles.list}
|
||||||
refreshing={isLoading}
|
showsVerticalScrollIndicator={false}
|
||||||
onRefresh={fetchPacks}
|
refreshControl={
|
||||||
/>
|
<RefreshControl
|
||||||
</View>
|
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({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: "#1a1a2e",
|
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
paddingTop: 60,
|
paddingTop: 60,
|
||||||
paddingHorizontal: 24,
|
paddingHorizontal: spacing.lg,
|
||||||
paddingBottom: 24,
|
paddingBottom: spacing.md,
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: "#16213e",
|
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
fontSize: 28,
|
...typography.title1,
|
||||||
fontWeight: "bold",
|
color: colors.text,
|
||||||
color: "#fff",
|
},
|
||||||
|
subtitle: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginTop: spacing.xs,
|
||||||
},
|
},
|
||||||
list: {
|
list: {
|
||||||
padding: 24,
|
padding: spacing.lg,
|
||||||
|
paddingBottom: Platform.OS === 'ios' ? 120 : 100,
|
||||||
},
|
},
|
||||||
card: {
|
card: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
backgroundColor: "#16213e",
|
marginBottom: spacing.sm,
|
||||||
borderRadius: 16,
|
|
||||||
padding: 16,
|
|
||||||
marginBottom: 16,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: "#0f3460",
|
|
||||||
},
|
},
|
||||||
cardContent: {
|
cardContent: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -98,46 +147,82 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
iconContainer: {
|
iconContainer: {
|
||||||
width: 48,
|
width: 52,
|
||||||
height: 48,
|
height: 52,
|
||||||
borderRadius: 24,
|
borderRadius: 26,
|
||||||
backgroundColor: "rgba(255, 255, 255, 0.05)",
|
backgroundColor: colors.glass.background,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
marginRight: 16,
|
marginRight: spacing.md,
|
||||||
|
},
|
||||||
|
iconContainerActive: {
|
||||||
|
backgroundColor: colors.primaryLight,
|
||||||
},
|
},
|
||||||
emoji: {
|
emoji: {
|
||||||
fontSize: 24,
|
fontSize: 26,
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
marginRight: 16,
|
marginRight: spacing.sm,
|
||||||
},
|
},
|
||||||
headerRow: {
|
headerRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
marginBottom: 4,
|
marginBottom: spacing.xs,
|
||||||
},
|
},
|
||||||
name: {
|
name: {
|
||||||
fontSize: 18,
|
...typography.headline,
|
||||||
fontWeight: "600",
|
color: colors.text,
|
||||||
color: "#fff",
|
marginRight: spacing.sm,
|
||||||
marginRight: 8,
|
|
||||||
},
|
},
|
||||||
badge: {
|
badge: {
|
||||||
backgroundColor: "#e94560",
|
flexDirection: "row",
|
||||||
paddingHorizontal: 6,
|
alignItems: "center",
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
paddingVertical: 2,
|
paddingVertical: 2,
|
||||||
borderRadius: 4,
|
borderRadius: radius.xs,
|
||||||
|
gap: 2,
|
||||||
},
|
},
|
||||||
badgeText: {
|
badgeText: {
|
||||||
color: "#fff",
|
...typography.caption2,
|
||||||
fontSize: 10,
|
color: colors.text,
|
||||||
fontWeight: "bold",
|
fontWeight: "700",
|
||||||
},
|
},
|
||||||
description: {
|
description: {
|
||||||
fontSize: 14,
|
...typography.subhead,
|
||||||
color: "#888",
|
color: colors.textSecondary,
|
||||||
lineHeight: 20,
|
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",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
@@ -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 { useAuthStore } from "../../src/store";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
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 { 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() {
|
export default function ProfileScreen() {
|
||||||
const { user, partner, signOut, fetchCouple } = useAuthStore();
|
const { user, partner, signOut, fetchCouple } = useAuthStore();
|
||||||
@@ -20,7 +25,7 @@ export default function ProfileScreen() {
|
|||||||
await supabase.functions.invoke("manage-couple", {
|
await supabase.functions.invoke("manage-couple", {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
await fetchCouple(); // Refresh state
|
await fetchCouple();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Alert.alert("Error", "Failed to unpair");
|
Alert.alert("Error", "Failed to unpair");
|
||||||
}
|
}
|
||||||
@@ -30,139 +35,269 @@ export default function ProfileScreen() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<GradientBackground>
|
||||||
<View style={styles.header}>
|
<ScrollView
|
||||||
<Text style={styles.title}>Profile</Text>
|
style={styles.container}
|
||||||
</View>
|
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>
|
||||||
|
|
||||||
<View style={styles.content}>
|
{/* Profile Card */}
|
||||||
<View style={styles.profileCard}>
|
<Animated.View entering={FadeInDown.delay(200).duration(500)}>
|
||||||
<View style={styles.avatar}>
|
<GlassCard style={styles.profileCard} variant="elevated">
|
||||||
<Text style={styles.avatarText}>
|
<LinearGradient
|
||||||
{user?.name?.[0]?.toUpperCase() || "U"}
|
colors={gradients.primary as [string, string]}
|
||||||
</Text>
|
style={styles.avatarContainer}
|
||||||
</View>
|
start={{ x: 0, y: 0 }}
|
||||||
<Text style={styles.name}>{user?.name || "User"}</Text>
|
end={{ x: 1, y: 1 }}
|
||||||
<Text style={styles.email}>{user?.email}</Text>
|
>
|
||||||
</View>
|
<View style={styles.avatar}>
|
||||||
|
<Text style={styles.avatarText}>
|
||||||
<Text style={styles.sectionTitle}>Relationship</Text>
|
{user?.name?.[0]?.toUpperCase() || "U"}
|
||||||
<View style={styles.section}>
|
</Text>
|
||||||
{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>
|
</View>
|
||||||
<TouchableOpacity onPress={handleUnpair}>
|
</LinearGradient>
|
||||||
<Ionicons name="trash-outline" size={20} color="#666" />
|
<Text style={styles.name}>{user?.name || "User"}</Text>
|
||||||
|
<Text style={styles.email}>{user?.email}</Text>
|
||||||
|
</GlassCard>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Relationship Section */}
|
||||||
|
<Animated.View entering={FadeInDown.delay(300).duration(500)}>
|
||||||
|
<Text style={styles.sectionTitle}>Relationship</Text>
|
||||||
|
<GlassCard>
|
||||||
|
{partner ? (
|
||||||
|
<View style={styles.row}>
|
||||||
|
<View style={styles.rowLeft}>
|
||||||
|
<View style={styles.rowIconContainer}>
|
||||||
|
<Ionicons name="heart" size={20} color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.rowLabel}>Paired with</Text>
|
||||||
|
<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>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
</View>
|
||||||
|
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
)}
|
||||||
) : (
|
</GlassCard>
|
||||||
<View style={styles.row}>
|
</Animated.View>
|
||||||
<Text style={styles.rowText}>Not paired yet</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Text style={styles.sectionTitle}>Settings</Text>
|
{/* Settings Section */}
|
||||||
<View style={styles.section}>
|
<Animated.View entering={FadeInDown.delay(400).duration(500)}>
|
||||||
<TouchableOpacity style={styles.row} onPress={signOut}>
|
<Text style={styles.sectionTitle}>Account</Text>
|
||||||
<Text style={[styles.rowText, { color: "#ff4444" }]}>Sign Out</Text>
|
<GlassCard noPadding>
|
||||||
<Ionicons name="log-out-outline" size={20} color="#ff4444" />
|
<TouchableOpacity
|
||||||
</TouchableOpacity>
|
style={[styles.menuRow, styles.menuRowDanger]}
|
||||||
</View>
|
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>
|
||||||
|
|
||||||
<Text style={styles.version}>Sauci v1.0.0</Text>
|
{/* Version */}
|
||||||
</View>
|
<Animated.View
|
||||||
</View>
|
entering={FadeInDown.delay(500).duration(500)}
|
||||||
|
style={styles.versionContainer}
|
||||||
|
>
|
||||||
|
<Text style={styles.version}>Sauci v1.0.0</Text>
|
||||||
|
<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({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: "#1a1a2e",
|
},
|
||||||
|
contentContainer: {
|
||||||
|
paddingBottom: Platform.OS === 'ios' ? 100 : 80,
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
paddingTop: 60,
|
paddingTop: 60,
|
||||||
paddingHorizontal: 24,
|
paddingHorizontal: spacing.lg,
|
||||||
paddingBottom: 24,
|
paddingBottom: spacing.md,
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: "#16213e",
|
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
fontSize: 28,
|
...typography.title1,
|
||||||
fontWeight: "bold",
|
color: colors.text,
|
||||||
color: "#fff",
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
padding: 24,
|
|
||||||
},
|
},
|
||||||
profileCard: {
|
profileCard: {
|
||||||
|
marginHorizontal: spacing.lg,
|
||||||
alignItems: "center",
|
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: {
|
avatar: {
|
||||||
width: 80,
|
width: 80,
|
||||||
height: 80,
|
height: 80,
|
||||||
borderRadius: 40,
|
borderRadius: 40,
|
||||||
backgroundColor: "#e94560",
|
backgroundColor: colors.background,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
marginBottom: 16,
|
|
||||||
},
|
},
|
||||||
avatarText: {
|
avatarText: {
|
||||||
fontSize: 32,
|
fontSize: 32,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#fff",
|
color: colors.text,
|
||||||
},
|
},
|
||||||
name: {
|
name: {
|
||||||
fontSize: 24,
|
...typography.title2,
|
||||||
fontWeight: "bold",
|
color: colors.text,
|
||||||
color: "#fff",
|
marginBottom: spacing.xs,
|
||||||
marginBottom: 4,
|
|
||||||
},
|
},
|
||||||
email: {
|
email: {
|
||||||
fontSize: 14,
|
...typography.subhead,
|
||||||
color: "#666",
|
color: colors.textSecondary,
|
||||||
},
|
},
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
fontSize: 14,
|
...typography.caption1,
|
||||||
fontWeight: "600",
|
color: colors.textTertiary,
|
||||||
color: "#666",
|
|
||||||
marginBottom: 8,
|
|
||||||
marginLeft: 4,
|
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
},
|
letterSpacing: 1,
|
||||||
section: {
|
marginBottom: spacing.sm,
|
||||||
backgroundColor: "#16213e",
|
marginLeft: spacing.lg + spacing.xs,
|
||||||
borderRadius: 12,
|
|
||||||
marginBottom: 24,
|
|
||||||
overflow: "hidden",
|
|
||||||
},
|
},
|
||||||
row: {
|
row: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
padding: 16,
|
padding: spacing.md,
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: "#1a1a2e",
|
|
||||||
},
|
},
|
||||||
partnerInfo: {
|
rowLeft: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 12,
|
flex: 1,
|
||||||
},
|
},
|
||||||
rowText: {
|
rowIconContainer: {
|
||||||
fontSize: 16,
|
width: 40,
|
||||||
color: "#fff",
|
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: {
|
version: {
|
||||||
textAlign: "center",
|
...typography.caption1,
|
||||||
color: "#666",
|
color: colors.textTertiary,
|
||||||
fontSize: 12,
|
},
|
||||||
marginTop: 24,
|
versionSub: {
|
||||||
|
...typography.caption2,
|
||||||
|
color: colors.textTertiary,
|
||||||
|
marginTop: spacing.xs,
|
||||||
|
},
|
||||||
|
bottomSpacer: {
|
||||||
|
height: spacing.lg,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
+140
-63
@@ -1,9 +1,12 @@
|
|||||||
import { useState, useEffect } from "react";
|
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 { useLocalSearchParams } from "expo-router";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import Animated, { FadeIn, FadeInUp } from "react-native-reanimated";
|
||||||
import { supabase } from "../../src/lib/supabase";
|
import { supabase } from "../../src/lib/supabase";
|
||||||
import SwipeCard from "../../src/components/SwipeCard";
|
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() {
|
export default function SwipeScreen() {
|
||||||
const { packId } = useLocalSearchParams();
|
const { packId } = useLocalSearchParams();
|
||||||
@@ -26,11 +29,6 @@ export default function SwipeScreen() {
|
|||||||
throw error;
|
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) => {
|
const sorted = (data || []).sort((a: any, b: any) => {
|
||||||
let scoreA = Math.random();
|
let scoreA = Math.random();
|
||||||
let scoreB = Math.random();
|
let scoreB = Math.random();
|
||||||
@@ -58,23 +56,18 @@ export default function SwipeScreen() {
|
|||||||
const question = questions[currentIndex];
|
const question = questions[currentIndex];
|
||||||
const answer = direction === "right" ? "yes" : direction === "left" ? "no" : "maybe";
|
const answer = direction === "right" ? "yes" : direction === "left" ? "no" : "maybe";
|
||||||
|
|
||||||
// Optimistic update
|
|
||||||
setCurrentIndex(prev => prev + 1);
|
setCurrentIndex(prev => prev + 1);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// DEBUG: Check if we have a valid session
|
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
console.log("DEBUG - Session exists:", !!session);
|
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: {
|
body: {
|
||||||
question_id: question.id,
|
question_id: question.id,
|
||||||
answer,
|
answer,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
console.log("DEBUG - Function result:", result);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to submit response", error);
|
console.error("Failed to submit response", error);
|
||||||
}
|
}
|
||||||
@@ -82,94 +75,178 @@ export default function SwipeScreen() {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<GradientBackground>
|
||||||
<ActivityIndicator size="large" color="#e94560" />
|
<View style={styles.centerContainer}>
|
||||||
</View>
|
<ActivityIndicator size="large" color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
</GradientBackground>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentIndex >= questions.length) {
|
if (currentIndex >= questions.length) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<GradientBackground showAccent>
|
||||||
<Ionicons name="checkmark-circle-outline" size={80} color="#e94560" />
|
<View style={styles.centerContainer}>
|
||||||
<Text style={styles.emptyTitle}>All caught up!</Text>
|
<Animated.View
|
||||||
<Text style={styles.emptySubtitle}>
|
entering={FadeInUp.duration(600).springify()}
|
||||||
Check back later for more questions or try a different pack.
|
style={styles.emptyContent}
|
||||||
</Text>
|
>
|
||||||
</View>
|
<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 (
|
return (
|
||||||
<View style={styles.container}>
|
<GradientBackground>
|
||||||
<View style={styles.header}>
|
<View style={styles.container}>
|
||||||
<Text style={styles.progress}>
|
{/* Header */}
|
||||||
Question {currentIndex + 1} of {questions.length}
|
<Animated.View
|
||||||
</Text>
|
entering={FadeIn.duration(400)}
|
||||||
</View>
|
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>
|
||||||
|
|
||||||
<View style={styles.cardContainer}>
|
{/* Card Stack */}
|
||||||
{/* Render next card in background for smoothness */}
|
<View style={styles.cardContainer}>
|
||||||
{questions[currentIndex + 1] && (
|
{/* Background card (next) */}
|
||||||
<View style={[styles.backgroundCard]} />
|
{questions[currentIndex + 1] && (
|
||||||
)}
|
<View style={styles.backgroundCard} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Active card */}
|
{/* Active card */}
|
||||||
<SwipeCard
|
<SwipeCard
|
||||||
key={questions[currentIndex].id}
|
key={questions[currentIndex].id}
|
||||||
question={questions[currentIndex]}
|
question={questions[currentIndex]}
|
||||||
onSwipe={handleSwipe}
|
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>
|
</View>
|
||||||
</View>
|
</GradientBackground>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: "#1a1a2e",
|
},
|
||||||
|
centerContainer: {
|
||||||
|
flex: 1,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
position: "absolute",
|
paddingTop: 60,
|
||||||
top: 60,
|
paddingHorizontal: spacing.lg,
|
||||||
zIndex: 1,
|
paddingBottom: spacing.md,
|
||||||
},
|
},
|
||||||
progress: {
|
progressContainer: {
|
||||||
color: "#666",
|
alignItems: "center",
|
||||||
fontSize: 16,
|
},
|
||||||
fontWeight: "600",
|
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: {
|
cardContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
marginTop: 40,
|
|
||||||
},
|
},
|
||||||
backgroundCard: {
|
backgroundCard: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
width: "100%", // Match SwipeCard width logic
|
width: "85%",
|
||||||
height: 500,
|
height: 480,
|
||||||
backgroundColor: "#16213e",
|
backgroundColor: colors.glass.backgroundLight,
|
||||||
borderRadius: 24,
|
borderRadius: radius.xxl,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: "#0f3460",
|
borderColor: colors.glass.border,
|
||||||
transform: [{ scale: 0.95 }, { translateY: 10 }],
|
transform: [{ scale: 0.95 }, { translateY: 12 }],
|
||||||
opacity: 0.5,
|
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: {
|
emptyTitle: {
|
||||||
fontSize: 24,
|
...typography.title2,
|
||||||
fontWeight: "bold",
|
color: colors.text,
|
||||||
color: "#fff",
|
marginBottom: spacing.sm,
|
||||||
marginTop: 24,
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
},
|
||||||
emptySubtitle: {
|
emptySubtitle: {
|
||||||
fontSize: 16,
|
...typography.body,
|
||||||
color: "#888",
|
color: colors.textSecondary,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
paddingHorizontal: 40,
|
},
|
||||||
|
bottomSpacer: {
|
||||||
|
height: Platform.OS === 'ios' ? 100 : 80,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
+332
-262
@@ -2,7 +2,6 @@ import { useState } from "react";
|
|||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Alert,
|
Alert,
|
||||||
@@ -12,9 +11,16 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Redirect } from "expo-router";
|
import { Redirect } from "expo-router";
|
||||||
import * as Linking from "expo-linking";
|
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 { supabase } from "../../src/lib/supabase";
|
||||||
import { useAuthStore } from "../../src/store";
|
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";
|
type AuthMode = "magic-link" | "password";
|
||||||
|
|
||||||
@@ -25,22 +31,32 @@ export default function LoginScreen() {
|
|||||||
const [isMagicLinkSent, setIsMagicLinkSent] = useState(false);
|
const [isMagicLinkSent, setIsMagicLinkSent] = useState(false);
|
||||||
const [authMode, setAuthMode] = useState<AuthMode>("magic-link");
|
const [authMode, setAuthMode] = useState<AuthMode>("magic-link");
|
||||||
const [isSignUp, setIsSignUp] = useState(false);
|
const [isSignUp, setIsSignUp] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
const { isAuthenticated } = useAuthStore();
|
const { isAuthenticated } = useAuthStore();
|
||||||
|
|
||||||
// Use declarative Redirect instead of imperative router.replace()
|
const showError = (message: string) => {
|
||||||
// This ensures navigation only happens after the Root Layout is mounted
|
setError(message);
|
||||||
|
// Also show native alert on mobile
|
||||||
|
if (Platform.OS !== 'web') {
|
||||||
|
Alert.alert("Error", message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearError = () => setError(null);
|
||||||
|
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
return <Redirect href="/" />;
|
return <Redirect href="/" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMagicLink = async () => {
|
const handleMagicLink = async () => {
|
||||||
|
clearError();
|
||||||
if (!email.trim()) {
|
if (!email.trim()) {
|
||||||
Alert.alert("Error", "Please enter your email");
|
showError("Please enter your email");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const { error } = await supabase.auth.signInWithOtp({
|
const { error: authError } = await supabase.auth.signInWithOtp({
|
||||||
email: email.trim(),
|
email: email.trim(),
|
||||||
options: {
|
options: {
|
||||||
emailRedirectTo: Linking.createURL("/(auth)/login"),
|
emailRedirectTo: Linking.createURL("/(auth)/login"),
|
||||||
@@ -49,56 +65,66 @@ export default function LoginScreen() {
|
|||||||
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
||||||
if (error) {
|
if (authError) {
|
||||||
Alert.alert("Error", error.message);
|
showError(authError.message);
|
||||||
} else {
|
} else {
|
||||||
setIsMagicLinkSent(true);
|
setIsMagicLinkSent(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePasswordAuth = async () => {
|
const handlePasswordAuth = async () => {
|
||||||
|
clearError();
|
||||||
if (!email.trim()) {
|
if (!email.trim()) {
|
||||||
Alert.alert("Error", "Please enter your email");
|
showError("Please enter your email");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!password) {
|
if (!password) {
|
||||||
Alert.alert("Error", "Please enter your password");
|
showError("Please enter your password");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isSignUp && password.length < 6) {
|
if (isSignUp && password.length < 6) {
|
||||||
Alert.alert("Error", "Password must be at least 6 characters");
|
showError("Password must be at least 6 characters");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
if (isSignUp) {
|
if (isSignUp) {
|
||||||
const { error } = await supabase.auth.signUp({
|
const { error: signUpError } = await supabase.auth.signUp({
|
||||||
email: email.trim(),
|
email: email.trim(),
|
||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
||||||
if (error) {
|
if (signUpError) {
|
||||||
Alert.alert("Error", error.message);
|
showError(signUpError.message);
|
||||||
} else {
|
} else {
|
||||||
Alert.alert(
|
// Show success message
|
||||||
"Check your email",
|
if (Platform.OS === 'web') {
|
||||||
"We sent you a confirmation link to verify your email address.",
|
setError(null);
|
||||||
[{ text: "OK" }]
|
Alert.alert(
|
||||||
);
|
"Check your email",
|
||||||
|
"We sent you a confirmation link to verify your email address."
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Alert.alert(
|
||||||
|
"Check your email",
|
||||||
|
"We sent you a confirmation link to verify your email address.",
|
||||||
|
[{ text: "OK" }]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const { error } = await supabase.auth.signInWithPassword({
|
const { error: signInError } = await supabase.auth.signInWithPassword({
|
||||||
email: email.trim(),
|
email: email.trim(),
|
||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
||||||
if (error) {
|
if (signInError) {
|
||||||
Alert.alert("Error", error.message);
|
showError(signInError.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -131,290 +157,334 @@ export default function LoginScreen() {
|
|||||||
|
|
||||||
if (isMagicLinkSent) {
|
if (isMagicLinkSent) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<GradientBackground showAccent>
|
||||||
<View style={styles.content}>
|
<View style={styles.container}>
|
||||||
<Text style={styles.emoji}>✉️</Text>
|
<Animated.View
|
||||||
<Text style={styles.title}>Check your email</Text>
|
entering={FadeInUp.duration(600).springify()}
|
||||||
<Text style={styles.subtitle}>
|
style={styles.content}
|
||||||
We sent a magic link to {email}
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.secondaryButton}
|
|
||||||
onPress={() => setIsMagicLinkSent(false)}
|
|
||||||
>
|
>
|
||||||
<Text style={styles.secondaryButtonText}>Try a different email</Text>
|
<View style={styles.iconContainer}>
|
||||||
</TouchableOpacity>
|
<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{'\n'}
|
||||||
|
<Text style={styles.emailHighlight}>{email}</Text>
|
||||||
|
</Text>
|
||||||
|
<GlassButton
|
||||||
|
variant="ghost"
|
||||||
|
onPress={() => setIsMagicLinkSent(false)}
|
||||||
|
style={{ marginTop: spacing.lg }}
|
||||||
|
>
|
||||||
|
Try a different email
|
||||||
|
</GlassButton>
|
||||||
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</GradientBackground>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView
|
<GradientBackground showAccent>
|
||||||
style={styles.container}
|
<KeyboardAvoidingView
|
||||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
style={styles.container}
|
||||||
>
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
<ScrollView
|
|
||||||
contentContainerStyle={styles.scrollContent}
|
|
||||||
keyboardShouldPersistTaps="handled"
|
|
||||||
>
|
>
|
||||||
<View style={styles.content}>
|
<ScrollView
|
||||||
<Text style={styles.emoji}>💕</Text>
|
contentContainerStyle={styles.scrollContent}
|
||||||
<Text style={styles.title}>Sauci</Text>
|
keyboardShouldPersistTaps="handled"
|
||||||
<Text style={styles.subtitle}>
|
showsVerticalScrollIndicator={false}
|
||||||
Explore intimacy together
|
>
|
||||||
</Text>
|
<View style={styles.content}>
|
||||||
|
{/* Logo/Brand */}
|
||||||
{/* Auth Mode Toggle */}
|
<Animated.View
|
||||||
<View style={styles.modeToggleContainer}>
|
entering={FadeInDown.delay(100).duration(600).springify()}
|
||||||
<TouchableOpacity
|
style={styles.brandContainer}
|
||||||
style={[
|
|
||||||
styles.modeToggle,
|
|
||||||
authMode === "magic-link" && styles.modeToggleActive,
|
|
||||||
]}
|
|
||||||
onPress={() => setAuthMode("magic-link")}
|
|
||||||
>
|
>
|
||||||
<Text
|
<View style={styles.logoContainer}>
|
||||||
style={[
|
<Ionicons name="heart" size={40} color={colors.primary} />
|
||||||
styles.modeToggleText,
|
</View>
|
||||||
authMode === "magic-link" && styles.modeToggleTextActive,
|
<Text style={styles.title}>Sauci</Text>
|
||||||
]}
|
<Text style={styles.subtitle}>
|
||||||
>
|
Explore intimacy together
|
||||||
Magic Link
|
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</Animated.View>
|
||||||
<TouchableOpacity
|
|
||||||
style={[
|
{/* Auth Card */}
|
||||||
styles.modeToggle,
|
<Animated.View
|
||||||
authMode === "password" && styles.modeToggleActive,
|
entering={FadeInDown.delay(200).duration(600).springify()}
|
||||||
]}
|
style={styles.formContainer}
|
||||||
onPress={() => setAuthMode("password")}
|
|
||||||
>
|
>
|
||||||
<Text
|
<GlassCard variant="elevated">
|
||||||
style={[
|
{/* Auth Mode Toggle */}
|
||||||
styles.modeToggleText,
|
<View style={styles.modeToggleContainer}>
|
||||||
authMode === "password" && styles.modeToggleTextActive,
|
<TouchableOpacity
|
||||||
]}
|
style={[
|
||||||
>
|
styles.modeToggle,
|
||||||
Password
|
authMode === "magic-link" && styles.modeToggleActive,
|
||||||
</Text>
|
]}
|
||||||
</TouchableOpacity>
|
onPress={() => setAuthMode("magic-link")}
|
||||||
</View>
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.modeToggleText,
|
||||||
|
authMode === "magic-link" && styles.modeToggleTextActive,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Magic Link
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.modeToggle,
|
||||||
|
authMode === "password" && styles.modeToggleActive,
|
||||||
|
]}
|
||||||
|
onPress={() => setAuthMode("password")}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.modeToggleText,
|
||||||
|
authMode === "password" && styles.modeToggleTextActive,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Password
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View style={styles.form}>
|
{/* Error Message */}
|
||||||
<TextInput
|
{error && (
|
||||||
style={styles.input}
|
<Animated.View
|
||||||
placeholder="Enter your email"
|
entering={FadeIn.duration(200)}
|
||||||
placeholderTextColor="#666"
|
style={styles.errorContainer}
|
||||||
value={email}
|
>
|
||||||
onChangeText={setEmail}
|
<Ionicons name="alert-circle" size={18} color={colors.error} />
|
||||||
keyboardType="email-address"
|
<Text style={styles.errorText}>{error}</Text>
|
||||||
autoCapitalize="none"
|
</Animated.View>
|
||||||
autoCorrect={false}
|
)}
|
||||||
/>
|
|
||||||
|
|
||||||
{authMode === "password" && (
|
{/* Form Fields */}
|
||||||
<TextInput
|
<View style={styles.form}>
|
||||||
style={styles.input}
|
<GlassInput
|
||||||
placeholder="Enter your password"
|
placeholder="Enter your email"
|
||||||
placeholderTextColor="#666"
|
value={email}
|
||||||
value={password}
|
onChangeText={(text) => { clearError(); setEmail(text); }}
|
||||||
onChangeText={setPassword}
|
keyboardType="email-address"
|
||||||
secureTextEntry
|
autoCapitalize="none"
|
||||||
autoCapitalize="none"
|
autoCorrect={false}
|
||||||
/>
|
icon={<Ionicons name="mail-outline" size={20} color={colors.textTertiary} />}
|
||||||
)}
|
/>
|
||||||
|
|
||||||
{authMode === "magic-link" ? (
|
{authMode === "password" && (
|
||||||
<TouchableOpacity
|
<GlassInput
|
||||||
style={[styles.button, isLoading && styles.buttonDisabled]}
|
placeholder="Enter your password"
|
||||||
onPress={handleMagicLink}
|
value={password}
|
||||||
disabled={isLoading}
|
onChangeText={(text) => { clearError(); setPassword(text); }}
|
||||||
>
|
secureTextEntry
|
||||||
<Text style={styles.buttonText}>
|
autoCapitalize="none"
|
||||||
{isLoading ? "Sending..." : "Continue with Magic Link"}
|
icon={<Ionicons name="lock-closed-outline" size={20} color={colors.textTertiary} />}
|
||||||
</Text>
|
/>
|
||||||
</TouchableOpacity>
|
)}
|
||||||
) : (
|
|
||||||
<>
|
{authMode === "magic-link" ? (
|
||||||
<TouchableOpacity
|
<GlassButton
|
||||||
style={[styles.button, isLoading && styles.buttonDisabled]}
|
onPress={handleMagicLink}
|
||||||
onPress={handlePasswordAuth}
|
loading={isLoading}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
Continue with Magic Link
|
||||||
|
</GlassButton>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<GlassButton
|
||||||
|
onPress={handlePasswordAuth}
|
||||||
|
loading={isLoading}
|
||||||
|
disabled={isLoading}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{isSignUp ? "Create Account" : "Sign In"}
|
||||||
|
</GlassButton>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.toggleSignUp}
|
||||||
|
onPress={() => setIsSignUp(!isSignUp)}
|
||||||
|
>
|
||||||
|
<Text style={styles.toggleSignUpText}>
|
||||||
|
{isSignUp
|
||||||
|
? "Already have an account? Sign In"
|
||||||
|
: "Don't have an account? Sign Up"}
|
||||||
|
</Text>
|
||||||
|
</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 continue with</Text>
|
||||||
|
<View style={styles.dividerLine} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.socialButtons}>
|
||||||
|
<GlassButton
|
||||||
|
variant="secondary"
|
||||||
|
onPress={handleGoogleSignIn}
|
||||||
|
icon={<Ionicons name="logo-google" size={20} color={colors.text} />}
|
||||||
|
style={styles.socialButton}
|
||||||
>
|
>
|
||||||
<Text style={styles.buttonText}>
|
Google
|
||||||
{isLoading
|
</GlassButton>
|
||||||
? isSignUp
|
|
||||||
? "Creating Account..."
|
<GlassButton
|
||||||
: "Signing In..."
|
variant="secondary"
|
||||||
: isSignUp
|
onPress={handleAppleSignIn}
|
||||||
? "Create Account"
|
icon={<Ionicons name="logo-apple" size={20} color={colors.text} />}
|
||||||
: "Sign In"}
|
style={styles.socialButton}
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.toggleSignUp}
|
|
||||||
onPress={() => setIsSignUp(!isSignUp)}
|
|
||||||
>
|
>
|
||||||
<Text style={styles.toggleSignUpText}>
|
Apple
|
||||||
{isSignUp
|
</GlassButton>
|
||||||
? "Already have an account? Sign In"
|
</View>
|
||||||
: "Don't have an account? Sign Up"}
|
</Animated.View>
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<View style={styles.divider}>
|
|
||||||
<View style={styles.dividerLine} />
|
|
||||||
<Text style={styles.dividerText}>or</Text>
|
|
||||||
<View style={styles.dividerLine} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.socialButton}
|
|
||||||
onPress={handleGoogleSignIn}
|
|
||||||
>
|
|
||||||
<Text style={styles.socialButtonText}>Continue with Google</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.socialButton}
|
|
||||||
onPress={handleAppleSignIn}
|
|
||||||
>
|
|
||||||
<Text style={styles.socialButtonText}>Continue with Apple</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</ScrollView>
|
||||||
</ScrollView>
|
</KeyboardAvoidingView>
|
||||||
</KeyboardAvoidingView>
|
</GradientBackground>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
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: {
|
scrollContent: {
|
||||||
flexGrow: 1,
|
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: {
|
modeToggleContainer: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
backgroundColor: "#16213e",
|
backgroundColor: colors.glass.background,
|
||||||
borderRadius: 12,
|
borderRadius: radius.md,
|
||||||
padding: 4,
|
padding: 4,
|
||||||
marginBottom: 24,
|
marginBottom: spacing.lg,
|
||||||
width: "100%",
|
|
||||||
},
|
},
|
||||||
modeToggle: {
|
modeToggle: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
borderRadius: 8,
|
borderRadius: radius.sm,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
modeToggleActive: {
|
modeToggleActive: {
|
||||||
backgroundColor: "#e94560",
|
backgroundColor: colors.primary,
|
||||||
},
|
},
|
||||||
modeToggleText: {
|
modeToggleText: {
|
||||||
color: "#666",
|
...typography.subhead,
|
||||||
fontSize: 14,
|
color: colors.textTertiary,
|
||||||
fontWeight: "500",
|
fontWeight: "600",
|
||||||
},
|
},
|
||||||
modeToggleTextActive: {
|
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: {
|
toggleSignUp: {
|
||||||
marginTop: 16,
|
marginTop: spacing.md,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
toggleSignUpText: {
|
toggleSignUpText: {
|
||||||
color: "#e94560",
|
...typography.subhead,
|
||||||
fontSize: 14,
|
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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
Generated
+34
@@ -16,10 +16,13 @@
|
|||||||
"@tanstack/react-query": "^5.17.0",
|
"@tanstack/react-query": "^5.17.0",
|
||||||
"base64-arraybuffer": "^1.0.2",
|
"base64-arraybuffer": "^1.0.2",
|
||||||
"expo": "~52.0.0",
|
"expo": "~52.0.0",
|
||||||
|
"expo-blur": "~14.0.3",
|
||||||
"expo-clipboard": "~7.0.1",
|
"expo-clipboard": "~7.0.1",
|
||||||
"expo-constants": "~17.0.0",
|
"expo-constants": "~17.0.0",
|
||||||
"expo-file-system": "~18.0.12",
|
"expo-file-system": "~18.0.12",
|
||||||
|
"expo-haptics": "~14.0.1",
|
||||||
"expo-image-picker": "~16.0.6",
|
"expo-image-picker": "~16.0.6",
|
||||||
|
"expo-linear-gradient": "~14.0.2",
|
||||||
"expo-linking": "~7.0.0",
|
"expo-linking": "~7.0.0",
|
||||||
"expo-notifications": "~0.29.0",
|
"expo-notifications": "~0.29.0",
|
||||||
"expo-router": "~4.0.0",
|
"expo-router": "~4.0.0",
|
||||||
@@ -6195,6 +6198,17 @@
|
|||||||
"react-native": "*"
|
"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": {
|
"node_modules/expo-clipboard": {
|
||||||
"version": "7.0.1",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-7.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-7.0.1.tgz",
|
||||||
@@ -6249,6 +6263,15 @@
|
|||||||
"react-native": "*"
|
"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": {
|
"node_modules/expo-image-loader": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-5.0.0.tgz",
|
||||||
@@ -6280,6 +6303,17 @@
|
|||||||
"react": "*"
|
"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": {
|
"node_modules/expo-linking": {
|
||||||
"version": "7.0.5",
|
"version": "7.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-7.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-7.0.5.tgz",
|
||||||
|
|||||||
@@ -20,10 +20,13 @@
|
|||||||
"@tanstack/react-query": "^5.17.0",
|
"@tanstack/react-query": "^5.17.0",
|
||||||
"base64-arraybuffer": "^1.0.2",
|
"base64-arraybuffer": "^1.0.2",
|
||||||
"expo": "~52.0.0",
|
"expo": "~52.0.0",
|
||||||
|
"expo-blur": "~14.0.3",
|
||||||
"expo-clipboard": "~7.0.1",
|
"expo-clipboard": "~7.0.1",
|
||||||
"expo-constants": "~17.0.0",
|
"expo-constants": "~17.0.0",
|
||||||
"expo-file-system": "~18.0.12",
|
"expo-file-system": "~18.0.12",
|
||||||
|
"expo-haptics": "~14.0.1",
|
||||||
"expo-image-picker": "~16.0.6",
|
"expo-image-picker": "~16.0.6",
|
||||||
|
"expo-linear-gradient": "~14.0.2",
|
||||||
"expo-linking": "~7.0.0",
|
"expo-linking": "~7.0.0",
|
||||||
"expo-notifications": "~0.29.0",
|
"expo-notifications": "~0.29.0",
|
||||||
"expo-router": "~4.0.0",
|
"expo-router": "~4.0.0",
|
||||||
|
|||||||
@@ -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 { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
@@ -8,17 +10,33 @@ import Animated, {
|
|||||||
interpolate,
|
interpolate,
|
||||||
Extrapolate,
|
Extrapolate,
|
||||||
withTiming,
|
withTiming,
|
||||||
|
interpolateColor,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
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 SCREEN_WIDTH = Dimensions.get("window").width;
|
||||||
const SWIPE_THRESHOLD = SCREEN_WIDTH * 0.25;
|
const SWIPE_THRESHOLD = SCREEN_WIDTH * 0.25;
|
||||||
|
|
||||||
interface Props {
|
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;
|
onSwipe: (direction: "left" | "right" | "up") => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AnimatedBlurView = Animated.createAnimatedComponent(BlurView);
|
||||||
|
|
||||||
export default function SwipeCard({ question, onSwipe }: Props) {
|
export default function SwipeCard({ question, onSwipe }: Props) {
|
||||||
const translateX = useSharedValue(0);
|
const translateX = useSharedValue(0);
|
||||||
const translateY = useSharedValue(0);
|
const translateY = useSharedValue(0);
|
||||||
@@ -32,7 +50,7 @@ export default function SwipeCard({ question, onSwipe }: Props) {
|
|||||||
.activeOffsetY([-15, 15])
|
.activeOffsetY([-15, 15])
|
||||||
.onStart(() => {
|
.onStart(() => {
|
||||||
context.value = { x: translateX.value, y: translateY.value };
|
context.value = { x: translateX.value, y: translateY.value };
|
||||||
scale.value = withTiming(1.05);
|
scale.value = withTiming(1.02, { duration: animations.timing.fast });
|
||||||
})
|
})
|
||||||
.onUpdate((event) => {
|
.onUpdate((event) => {
|
||||||
translateX.value = event.translationX + context.value.x;
|
translateX.value = event.translationX + context.value.x;
|
||||||
@@ -40,27 +58,29 @@ export default function SwipeCard({ question, onSwipe }: Props) {
|
|||||||
rotation.value = interpolate(
|
rotation.value = interpolate(
|
||||||
translateX.value,
|
translateX.value,
|
||||||
[-SCREEN_WIDTH / 2, SCREEN_WIDTH / 2],
|
[-SCREEN_WIDTH / 2, SCREEN_WIDTH / 2],
|
||||||
[-15, 15],
|
[-12, 12],
|
||||||
Extrapolate.CLAMP
|
Extrapolate.CLAMP
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.onEnd(() => {
|
.onEnd(() => {
|
||||||
scale.value = withTiming(1);
|
scale.value = withTiming(1, { duration: animations.timing.fast });
|
||||||
|
|
||||||
if (Math.abs(translateX.value) > SWIPE_THRESHOLD) {
|
if (Math.abs(translateX.value) > SWIPE_THRESHOLD) {
|
||||||
// Horizontal swipe (YES/NO)
|
|
||||||
const direction = translateX.value > 0 ? "right" : "left";
|
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);
|
runOnJS(onSwipe)(direction);
|
||||||
} else if (translateY.value < -SWIPE_THRESHOLD) {
|
} else if (translateY.value < -SWIPE_THRESHOLD) {
|
||||||
// Vertical swipe up (MAYBE)
|
runOnJS(triggerHaptic)('medium');
|
||||||
translateY.value = withSpring(-SCREEN_WIDTH * 1.5);
|
translateY.value = withSpring(-SCREEN_WIDTH * 1.5, animations.springGentle);
|
||||||
runOnJS(onSwipe)("up");
|
runOnJS(onSwipe)("up");
|
||||||
} else {
|
} else {
|
||||||
// Reset
|
translateX.value = withSpring(0, animations.spring);
|
||||||
translateX.value = withSpring(0);
|
translateY.value = withSpring(0, animations.spring);
|
||||||
translateY.value = withSpring(0);
|
rotation.value = withSpring(0, animations.spring);
|
||||||
rotation.value = withSpring(0);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -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(() => {
|
useAnimatedStyle(() => {
|
||||||
let opacity = 0;
|
let opacity = 0;
|
||||||
|
let backgroundColor = 'transparent';
|
||||||
|
|
||||||
if (direction === "right") {
|
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") {
|
} 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 {
|
} 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 {
|
return {
|
||||||
opacity,
|
opacity,
|
||||||
backgroundColor: color,
|
backgroundColor,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleButtonPress = async (direction: "left" | "right" | "up") => {
|
||||||
|
await triggerHaptic('light');
|
||||||
|
onSwipe(direction);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useBlur = Platform.OS === 'ios';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.cardWrapper}>
|
<View style={styles.cardWrapper}>
|
||||||
<GestureDetector gesture={gesture}>
|
<GestureDetector gesture={gesture}>
|
||||||
<Animated.View style={[styles.card, animatedStyle]}>
|
<Animated.View style={[styles.cardOuter, animatedStyle, shadows.xl]}>
|
||||||
{/* Overlays */}
|
{useBlur ? (
|
||||||
<Animated.View style={[styles.overlay, overlayStyle("right", "rgba(76, 175, 80, 0.4)")]}>
|
<BlurView
|
||||||
<Text style={styles.overlayText}>YES</Text>
|
intensity={blur.medium}
|
||||||
</Animated.View>
|
tint="dark"
|
||||||
<Animated.View style={[styles.overlay, overlayStyle("left", "rgba(244, 67, 54, 0.4)")]}>
|
style={styles.blurContainer}
|
||||||
<Text style={styles.overlayText}>NO</Text>
|
>
|
||||||
</Animated.View>
|
<CardContent
|
||||||
<Animated.View style={[styles.overlay, overlayStyle("up", "rgba(255, 152, 0, 0.4)")]}>
|
question={question}
|
||||||
<Text style={styles.overlayText}>MAYBE</Text>
|
overlayStyle={overlayStyle}
|
||||||
</Animated.View>
|
handleButtonPress={handleButtonPress}
|
||||||
|
/>
|
||||||
<View style={styles.content}>
|
</BlurView>
|
||||||
<View style={styles.intensityContainer}>
|
) : (
|
||||||
{[...Array(question.intensity)].map((_, i) => (
|
<View style={styles.fallbackContainer}>
|
||||||
<Ionicons key={i} name="flame" size={16} color="#e94560" />
|
<CardContent
|
||||||
))}
|
question={question}
|
||||||
|
overlayStyle={overlayStyle}
|
||||||
|
handleButtonPress={handleButtonPress}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Text style={[styles.text, question.is_two_part ? styles.twoPartText : null]}>{question.text}</Text>
|
)}
|
||||||
</View>
|
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</GestureDetector>
|
</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")]}>
|
||||||
|
<View style={[styles.overlayBadge, { backgroundColor: colors.success }]}>
|
||||||
|
<Text style={styles.overlayText}>YES</Text>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
<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")]}>
|
||||||
|
<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={colors.primary} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.text, question.is_two_part && styles.twoPartText]}>
|
||||||
|
{question.text}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
<View style={styles.footer}>
|
<View style={styles.footer}>
|
||||||
<View style={styles.buttonContainer}>
|
<View style={styles.buttonContainer}>
|
||||||
<TouchableOpacity
|
<ActionButton
|
||||||
style={[styles.actionButton, styles.noButton]}
|
onPress={() => handleButtonPress("left")}
|
||||||
onPress={() => onSwipe("left")}
|
colors={gradients.error as [string, string]}
|
||||||
activeOpacity={0.7}
|
icon="close"
|
||||||
>
|
/>
|
||||||
<View style={styles.buttonInnerGlow} />
|
<ActionButton
|
||||||
<Ionicons name="thumbs-down" size={24} color="#fff" style={styles.buttonIcon} />
|
onPress={() => handleButtonPress("up")}
|
||||||
</TouchableOpacity>
|
colors={gradients.warning as [string, string]}
|
||||||
|
icon="help"
|
||||||
<TouchableOpacity
|
small
|
||||||
style={[styles.actionButton, styles.maybeButton]}
|
/>
|
||||||
onPress={() => onSwipe("up")}
|
<ActionButton
|
||||||
activeOpacity={0.7}
|
onPress={() => handleButtonPress("right")}
|
||||||
>
|
colors={gradients.success as [string, string]}
|
||||||
<View style={styles.buttonInnerGlow} />
|
icon="heart"
|
||||||
<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>
|
||||||
</View>
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.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: {
|
cardWrapper: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
width: SCREEN_WIDTH - 48,
|
width: SCREEN_WIDTH - 48,
|
||||||
height: 500,
|
height: 480,
|
||||||
},
|
},
|
||||||
card: {
|
cardOuter: {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: 500,
|
height: "100%",
|
||||||
backgroundColor: "#16213e",
|
borderRadius: radius.xxl,
|
||||||
borderRadius: 24,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: "#0f3460",
|
|
||||||
shadowColor: "#000",
|
|
||||||
shadowOffset: {
|
|
||||||
width: 0,
|
|
||||||
height: 4,
|
|
||||||
},
|
|
||||||
shadowOpacity: 0.3,
|
|
||||||
shadowRadius: 4.65,
|
|
||||||
elevation: 8,
|
|
||||||
overflow: "hidden",
|
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: {
|
content: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
padding: 32,
|
padding: spacing.xl,
|
||||||
},
|
},
|
||||||
intensityContainer: {
|
intensityContainer: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
marginBottom: 24,
|
marginBottom: spacing.lg,
|
||||||
backgroundColor: "rgba(233, 69, 96, 0.1)",
|
backgroundColor: colors.primaryLight,
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: spacing.md,
|
||||||
paddingVertical: 6,
|
paddingVertical: spacing.sm,
|
||||||
borderRadius: 12,
|
borderRadius: radius.full,
|
||||||
|
gap: 2,
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
fontSize: 24,
|
...typography.title2,
|
||||||
fontWeight: "bold",
|
color: colors.text,
|
||||||
color: "#fff",
|
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
lineHeight: 32,
|
lineHeight: 32,
|
||||||
},
|
},
|
||||||
twoPartText: {
|
twoPartText: {
|
||||||
fontSize: 20,
|
...typography.title3,
|
||||||
lineHeight: 28,
|
lineHeight: 28,
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
position: "absolute",
|
paddingBottom: spacing.lg,
|
||||||
bottom: 0,
|
paddingHorizontal: spacing.lg,
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
overlay: {
|
overlay: {
|
||||||
...StyleSheet.absoluteFillObject,
|
...StyleSheet.absoluteFillObject,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
|
borderRadius: radius.xxl,
|
||||||
|
},
|
||||||
|
overlayBadge: {
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
transform: [{ rotate: "-15deg" }],
|
||||||
},
|
},
|
||||||
overlayText: {
|
overlayText: {
|
||||||
fontSize: 48,
|
fontSize: 36,
|
||||||
fontWeight: "900",
|
fontWeight: "900",
|
||||||
color: "#fff",
|
color: colors.text,
|
||||||
textShadowColor: "rgba(0,0,0,0.5)",
|
letterSpacing: 2,
|
||||||
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",
|
|
||||||
},
|
},
|
||||||
buttonContainer: {
|
buttonContainer: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
justifyContent: "space-between",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
paddingHorizontal: 32,
|
gap: spacing.md,
|
||||||
paddingVertical: 16,
|
|
||||||
gap: 20,
|
|
||||||
zIndex: 21,
|
|
||||||
},
|
},
|
||||||
actionButton: {
|
actionButton: {
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
width: 64,
|
width: 64,
|
||||||
height: 64,
|
height: 64,
|
||||||
borderRadius: 32,
|
borderRadius: 32,
|
||||||
shadowColor: "#000",
|
justifyContent: "center",
|
||||||
shadowOffset: { width: 0, height: 4 },
|
alignItems: "center",
|
||||||
shadowOpacity: 0.4,
|
|
||||||
shadowRadius: 8,
|
|
||||||
elevation: 8,
|
|
||||||
borderWidth: 2,
|
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
zIndex: 22,
|
|
||||||
},
|
},
|
||||||
noButton: {
|
actionButtonSmall: {
|
||||||
backgroundColor: "#E74C3C",
|
width: 52,
|
||||||
borderColor: "rgba(255, 255, 255, 0.3)",
|
height: 52,
|
||||||
|
borderRadius: 26,
|
||||||
},
|
},
|
||||||
maybeButton: {
|
buttonHighlight: {
|
||||||
backgroundColor: "#F39C12",
|
|
||||||
borderColor: "rgba(255, 255, 255, 0.3)",
|
|
||||||
},
|
|
||||||
yesButton: {
|
|
||||||
backgroundColor: "#2ECC71",
|
|
||||||
borderColor: "rgba(255, 255, 255, 0.3)",
|
|
||||||
},
|
|
||||||
buttonInnerGlow: {
|
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
height: "50%",
|
height: "50%",
|
||||||
backgroundColor: "rgba(255, 255, 255, 0.15)",
|
backgroundColor: "rgba(255, 255, 255, 0.15)",
|
||||||
borderTopLeftRadius: 32,
|
|
||||||
borderTopRightRadius: 32,
|
|
||||||
},
|
},
|
||||||
buttonIcon: {
|
buttonIcon: {
|
||||||
zIndex: 1,
|
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;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { GradientBackground } from './GradientBackground';
|
||||||
|
export { GlassCard } from './GlassCard';
|
||||||
|
export { GlassButton } from './GlassButton';
|
||||||
|
export { GlassInput } from './GlassInput';
|
||||||
@@ -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;
|
||||||
Reference in new issue
Block a user