From 6ff5bc62f2a462009c0d617a78f75e007485758e Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Sun, 28 Dec 2025 16:51:32 +0000 Subject: [PATCH] Update --- apps/mobile/app/(app)/_layout.tsx | 43 +- apps/mobile/app/(app)/chat/[id].tsx | 486 +++++++------- apps/mobile/app/(app)/index.tsx | 329 ++++++---- apps/mobile/app/(app)/matches.tsx | 320 ++++++---- apps/mobile/app/(app)/packs.tsx | 243 ++++--- apps/mobile/app/(app)/profile.tsx | 309 ++++++--- apps/mobile/app/(app)/swipe.tsx | 203 ++++-- apps/mobile/app/(auth)/login.tsx | 594 ++++++++++-------- apps/mobile/package-lock.json | 34 + apps/mobile/package.json | 3 + .../src/components/SwipeCard.native.tsx | 416 +++++++----- apps/mobile/src/components/ui/GlassButton.tsx | 238 +++++++ apps/mobile/src/components/ui/GlassCard.tsx | 98 +++ apps/mobile/src/components/ui/GlassInput.tsx | 140 +++++ .../src/components/ui/GradientBackground.tsx | 71 +++ apps/mobile/src/components/ui/index.ts | 4 + apps/mobile/src/theme/index.ts | 204 ++++++ 17 files changed, 2599 insertions(+), 1136 deletions(-) create mode 100644 apps/mobile/src/components/ui/GlassButton.tsx create mode 100644 apps/mobile/src/components/ui/GlassCard.tsx create mode 100644 apps/mobile/src/components/ui/GlassInput.tsx create mode 100644 apps/mobile/src/components/ui/GradientBackground.tsx create mode 100644 apps/mobile/src/components/ui/index.ts create mode 100644 apps/mobile/src/theme/index.ts diff --git a/apps/mobile/app/(app)/_layout.tsx b/apps/mobile/app/(app)/_layout.tsx index 9dff876..7456fb6 100755 --- a/apps/mobile/app/(app)/_layout.tsx +++ b/apps/mobile/app/(app)/_layout.tsx @@ -1,7 +1,22 @@ import { Tabs, Redirect } from "expo-router"; import { Ionicons } from "@expo/vector-icons"; -import { View, ActivityIndicator, StyleSheet } from "react-native"; +import { View, ActivityIndicator, StyleSheet, Platform } from "react-native"; +import { BlurView } from "expo-blur"; import { useAuthStore, useMatchStore } from "../../src/store"; +import { colors, blur } from "../../src/theme"; + +function TabBarBackground() { + if (Platform.OS === 'ios') { + return ( + + ); + } + return ; +} export default function AppLayout() { const { isAuthenticated, isLoading } = useAuthStore(); @@ -11,7 +26,7 @@ export default function AppLayout() { if (isLoading) { return ( - + ); } @@ -25,19 +40,22 @@ export default function AppLayout() { , tabBarStyle: { - backgroundColor: "#16213e", - borderTopColor: "#0f3460", + position: 'absolute', + backgroundColor: 'transparent', + borderTopColor: colors.glass.border, borderTopWidth: 1, paddingTop: 8, - paddingBottom: 8, - height: 60, + paddingBottom: Platform.OS === 'ios' ? 28 : 8, + height: Platform.OS === 'ios' ? 88 : 64, + elevation: 0, }, - tabBarActiveTintColor: "#e94560", - tabBarInactiveTintColor: "#666", + tabBarActiveTintColor: colors.primary, + tabBarInactiveTintColor: colors.textTertiary, tabBarLabelStyle: { - fontSize: 12, - fontWeight: "500", + fontSize: 11, + fontWeight: "600", }, }} > @@ -77,8 +95,9 @@ export default function AppLayout() { ), tabBarBadge: newMatchesCount > 0 ? newMatchesCount : undefined, tabBarBadgeStyle: { - backgroundColor: "#e94560", + backgroundColor: colors.primary, fontSize: 10, + fontWeight: '600', }, }} /> @@ -112,6 +131,6 @@ const styles = StyleSheet.create({ flex: 1, justifyContent: "center", alignItems: "center", - backgroundColor: "#1a1a2e", + backgroundColor: colors.background, }, }); diff --git a/apps/mobile/app/(app)/chat/[id].tsx b/apps/mobile/app/(app)/chat/[id].tsx index 87314bf..0af638f 100644 --- a/apps/mobile/app/(app)/chat/[id].tsx +++ b/apps/mobile/app/(app)/chat/[id].tsx @@ -1,13 +1,18 @@ import { useState, useEffect, useRef } from "react"; -import { View, Text, StyleSheet, FlatList, TextInput, TouchableOpacity, KeyboardAvoidingView, Platform, Image, ActivityIndicator, Alert, AppState } from "react-native"; +import { View, Text, StyleSheet, FlatList, TextInput, TouchableOpacity, KeyboardAvoidingView, Platform, Image, ActivityIndicator, Alert } from "react-native"; import { useLocalSearchParams, useRouter, Stack } from "expo-router"; import { Ionicons } from "@expo/vector-icons"; +import { BlurView } from "expo-blur"; +import { LinearGradient } from "expo-linear-gradient"; +import Animated, { FadeIn, FadeInUp } from "react-native-reanimated"; import { supabase } from "../../../src/lib/supabase"; import { useAuthStore } from "../../../src/store"; import * as ImagePicker from "expo-image-picker"; import * as FileSystem from 'expo-file-system'; import { decode } from 'base64-arraybuffer'; import { Database } from "../../../src/types/supabase"; +import { GradientBackground, GlassCard } from "../../../src/components/ui"; +import { colors, gradients, spacing, radius, typography, blur, shadows } from "../../../src/theme"; type Message = Database["public"]["Tables"]["messages"]["Row"]; @@ -27,7 +32,6 @@ export default function ChatScreen() { useEffect(() => { if (!matchId) return; - // Fetch match context (question and responses) const fetchMatch = async () => { const { data } = await supabase .from("matches") @@ -36,7 +40,6 @@ export default function ChatScreen() { .single(); if (data) { - // Fetch responses for both users in this couple for this question const { data: responses } = await supabase .from("responses") .select("*, profiles(name)") @@ -48,9 +51,8 @@ export default function ChatScreen() { }; fetchMatch(); - // Fetch initial messages and mark as read const fetchMessagesAndMarkRead = async () => { - const { data, error } = await supabase + const { data } = await supabase .from("messages") .select("*") .eq("match_id", matchId) @@ -59,7 +61,6 @@ export default function ChatScreen() { if (data) { setMessages(data); - // Identify unread messages from partner const unreadIds = data .filter(m => m.user_id !== user?.id && !m.read_at) .map(m => m.id); @@ -70,7 +71,6 @@ export default function ChatScreen() { .update({ read_at: new Date().toISOString() }) .in("id", unreadIds); - // Optimistically update local state setMessages(prev => prev.map(m => unreadIds.includes(m.id) ? { ...m, read_at: new Date().toISOString() } : m )); @@ -80,7 +80,6 @@ export default function ChatScreen() { fetchMessagesAndMarkRead(); - // Subscribe to new messages, updates (read receipts), and typing const channel = supabase .channel(`chat:${matchId}`) .on( @@ -94,7 +93,6 @@ export default function ChatScreen() { async (payload) => { const newMessage = payload.new as Message; - // If receiving a message from partner, mark as read immediately since we are in the chat if (newMessage.user_id !== user?.id) { await supabase .from("messages") @@ -104,7 +102,7 @@ export default function ChatScreen() { } setMessages((prev) => [newMessage, ...prev]); - setPartnerTyping(false); // Stop typing indicator if they sent a message + setPartnerTyping(false); } ) .on( @@ -126,7 +124,6 @@ export default function ChatScreen() { if (payload.payload.userId !== user?.id) { setPartnerTyping(true); - // Auto-hide typing indicator after 3s if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current); typingTimeoutRef.current = setTimeout(() => { setPartnerTyping(false); @@ -242,92 +239,48 @@ export default function ChatScreen() { }; const revealMessage = async (messageId: string) => { - // Optimistic update setMessages(prev => prev.map(m => m.id === messageId ? { ...m, media_viewed_at: new Date().toISOString() } : m )); - const { error } = await supabase + await supabase .from("messages") .update({ media_viewed_at: new Date().toISOString() }) .eq("id", messageId); - - if (error) { - console.error("Failed to mark as viewed", error); - } }; - - const renderMessage = ({ item }: { item: Message }) => { + const renderMessage = ({ item, index }: { item: Message; index: number }) => { const isMe = item.user_id === user?.id; return ( - - - {item.media_path ? ( - - - {(!isMe && !item.media_viewed_at) && ( - revealMessage(item.id)} - > - - - Tap to reveal - - - )} - - ) : ( - {item.content} - )} - - - {new Date(item.created_at!).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - - {isMe && ( - - )} + + {isMe ? ( + + + + ) : ( + + - - + )} + ); }; return ( - + ( - - - {match.question.text} - {match.question.partner_text ? ` & ${match.question.partner_text}` : ""} - - {match.responses && match.responses.length > 0 && ( - - {match.responses.map((response) => ( - - {response.profiles?.name || 'You'}: {response.answer} - - ))} - - )} - - ) : "Chat", - headerStyle: { backgroundColor: "#16213e" }, - headerTintColor: "#fff", - headerBackTitleVisible: false, + headerStyle: { backgroundColor: colors.backgroundLight }, + headerTintColor: colors.text, + headerShadowVisible: false, }} /> {/* Sticky top bar with question and answers */} {match?.question && ( - - - {match.question.text} - {match.question.partner_text ? ` & ${match.question.partner_text}` : ""} - - {match.responses && match.responses.length > 0 && ( - - {match.responses.map((response) => ( - - {response.user_id === user?.id ? 'You' : (response.profiles?.name || 'Partner')}: {response.answer} - - ))} - - )} - + + + + {match.question.text} + {match.question.partner_text ? ` & ${match.question.partner_text}` : ""} + + {match.responses && match.responses.length > 0 && ( + + {match.responses.map((response: any) => ( + + + {response.user_id === user?.id ? 'You' : (response.profiles?.name || 'Partner')}: {response.answer} + + + ))} + + )} + + )} + Partner is typing... - + ) : null } /> - - - {uploading ? ( - - ) : ( - - )} - - - - - - + - - + + ) : ( + + + + )} + + ); +} + +function MessageContent({ + item, + isMe, + revealMessage, +}: { + item: Message; + isMe: boolean; + revealMessage: (id: string) => void; +}) { + if (item.media_path) { + return ( + + + {(!isMe && !item.media_viewed_at) && ( + revealMessage(item.id)} + > + + + Tap to reveal + + + )} + + + ); + } + + return ( + <> + {item.content} + + + ); +} + +function MessageMeta({ item, isMe }: { item: Message; isMe: boolean }) { + return ( + + + {new Date(item.created_at!).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + + {isMe && ( + + )} + + ); +} + +function InputBar({ + inputText, + uploading, + onChangeText, + onSend, + onPickImage, +}: { + inputText: string; + uploading: boolean; + onChangeText: (text: string) => void; + onSend: () => void; + onPickImage: () => void; +}) { + return ( + + + {uploading ? ( + + ) : ( + + )} + + + + + + + + + ); } const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: "#1a1a2e", - }, keyboardAvoiding: { flex: 1, }, listContent: { - padding: 16, + padding: spacing.md, + paddingBottom: spacing.lg, }, messageRow: { - marginBottom: 16, + marginBottom: spacing.sm, flexDirection: "row", }, myMessageRow: { @@ -424,148 +487,143 @@ const styles = StyleSheet.create({ justifyContent: "flex-start", }, bubble: { - maxWidth: "75%", - padding: 12, - borderRadius: 20, - shadowColor: "#000", - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 2, + maxWidth: "78%", + padding: spacing.md, + borderRadius: radius.xl, + ...shadows.sm, }, myBubble: { - backgroundColor: "#e94560", - borderBottomRightRadius: 4, + borderBottomRightRadius: radius.xs, }, theirBubble: { - backgroundColor: "#16213e", + backgroundColor: colors.glass.backgroundLight, borderWidth: 1, - borderColor: "#0f3460", - borderBottomLeftRadius: 4, + borderColor: colors.glass.border, + borderBottomLeftRadius: radius.xs, }, messageText: { - color: "#fff", - fontSize: 16, - lineHeight: 22, + ...typography.body, + color: colors.text, }, messageImage: { width: 200, height: 200, - borderRadius: 12, - backgroundColor: "rgba(0,0,0,0.2)", + borderRadius: radius.md, + backgroundColor: colors.glass.background, }, metaContainer: { flexDirection: "row", alignItems: "center", justifyContent: "flex-end", - marginTop: 4, + marginTop: spacing.xs, }, timestamp: { - fontSize: 10, - }, - myTimestamp: { - color: "rgba(255, 255, 255, 0.7)", - }, - theirTimestamp: { - color: "rgba(255, 255, 255, 0.4)", + ...typography.caption2, + color: "rgba(255, 255, 255, 0.6)", }, readIcon: { - marginLeft: 4, + marginLeft: spacing.xs, + }, + inputWrapper: { + borderTopWidth: 1, + borderTopColor: colors.glass.border, + }, + inputWrapperAndroid: { + backgroundColor: colors.glass.backgroundLight, }, inputContainer: { flexDirection: "row", - alignItems: "center", - padding: 12, - paddingBottom: Platform.OS === "ios" ? 34 : 12, - backgroundColor: "#16213e", - borderTopWidth: 1, - borderTopColor: "#0f3460", + alignItems: "flex-end", + padding: spacing.sm, + paddingBottom: Platform.OS === "ios" ? spacing.xl : spacing.sm, }, attachButton: { - padding: 10, + padding: spacing.sm, + marginBottom: spacing.xs, + }, + inputFieldWrapper: { + flex: 1, + backgroundColor: colors.glass.background, + borderRadius: radius.xl, + borderWidth: 1, + borderColor: colors.glass.border, + marginHorizontal: spacing.sm, }, input: { - flex: 1, - backgroundColor: "#0f3460", - borderRadius: 24, - paddingHorizontal: 16, - paddingVertical: 10, - color: "#fff", - maxHeight: 120, - marginHorizontal: 8, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + color: colors.text, fontSize: 16, + maxHeight: 100, }, sendButton: { - padding: 10, - }, - headerTitleContainer: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: colors.glass.background, + justifyContent: "center", alignItems: "center", - maxWidth: 200, + marginBottom: spacing.xs, }, - headerTitle: { - color: "#fff", - fontWeight: "bold", - fontSize: 16, - textAlign: "center", - }, - answersContainer: { - marginTop: 4, - alignItems: "center", - }, - answerText: { - color: "rgba(255,255,255,0.8)", - fontSize: 12, - marginBottom: 2, + sendButtonActive: { + backgroundColor: colors.primary, }, typingContainer: { - paddingHorizontal: 16, - paddingVertical: 8, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, }, typingText: { - color: "#666", + ...typography.caption1, + color: colors.textTertiary, fontStyle: "italic", - fontSize: 12, }, revealOverlay: { ...StyleSheet.absoluteFillObject, justifyContent: "center", alignItems: "center", - backgroundColor: "rgba(0,0,0,0.3)", - borderRadius: 12, + backgroundColor: "rgba(0,0,0,0.4)", + borderRadius: radius.md, }, revealContent: { alignItems: "center", backgroundColor: "rgba(0,0,0,0.6)", - padding: 12, - borderRadius: 12, + padding: spacing.md, + borderRadius: radius.md, }, revealText: { - color: "#fff", - fontSize: 12, - fontWeight: "bold", - marginTop: 4, + ...typography.caption1, + color: colors.text, + fontWeight: "600", + marginTop: spacing.xs, }, stickyTopBar: { - backgroundColor: "#16213e", - borderBottomWidth: 1, - borderBottomColor: "#0f3460", - padding: 16, - paddingTop: 12, - paddingBottom: 12, + marginHorizontal: spacing.md, + marginTop: spacing.sm, + marginBottom: spacing.xs, + }, + stickyContent: { + padding: spacing.md, }, stickyQuestionText: { - color: "#fff", - fontSize: 16, - fontWeight: "bold", + ...typography.subhead, + color: colors.text, + fontWeight: "600", textAlign: "center", - marginBottom: 8, + marginBottom: spacing.sm, }, stickyAnswersContainer: { - alignItems: "center", + flexDirection: "row", + justifyContent: "center", + gap: spacing.sm, + }, + answerBadge: { + backgroundColor: colors.primaryLight, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderRadius: radius.sm, }, stickyAnswerText: { - color: "rgba(255,255,255,0.8)", - fontSize: 14, - marginBottom: 4, - } + ...typography.caption1, + color: colors.text, + }, }); diff --git a/apps/mobile/app/(app)/index.tsx b/apps/mobile/app/(app)/index.tsx index ada08aa..b101702 100755 --- a/apps/mobile/app/(app)/index.tsx +++ b/apps/mobile/app/(app)/index.tsx @@ -1,8 +1,12 @@ import { useEffect } from "react"; -import { View, Text, StyleSheet, TouchableOpacity, ScrollView } from "react-native"; +import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Platform } from "react-native"; +import Animated, { FadeInDown, FadeInRight } from "react-native-reanimated"; +import { LinearGradient } from "expo-linear-gradient"; import { useAuthStore, useMatchStore, usePacksStore } from "../../src/store"; import { Ionicons } from "@expo/vector-icons"; import { router } from "expo-router"; +import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui"; +import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme"; export default function HomeScreen() { const { user, partner } = useAuthStore(); @@ -17,190 +21,275 @@ export default function HomeScreen() { const recentMatches = matches.slice(0, 3); return ( - - - - Hey, {user?.name || "there"} 👋 - - {partner && ( - - Paired with {partner.name || partner.email || 'your partner'} - - )} - - - {/* Quick Stats */} - - - - {matches.length} - Matches - - - - {packs.length} - Packs - - - - {newMatchesCount} - New - - - - {/* Start Playing CTA */} - router.push("/(app)/swipe")} + + - - Ready to explore? - - Swipe through questions and discover what you both enjoy - - - - - - {/* Recent Matches */} - {recentMatches.length > 0 && ( - - - Recent Matches - router.push("/(app)/matches")}> - See all - - - {recentMatches.map((match) => ( - - - - - - {(match as any).question?.text || "A new match!"} + {/* Header */} + + + + Hey, {user?.name || "there"} + + {partner && ( + + Paired with {partner.name || partner.email || 'your partner'} - {match.is_new && } + )} + + router.push("/(app)/profile")} + activeOpacity={0.7} + > + + {user?.name?.[0]?.toUpperCase() || "U"} + + + + + {/* Quick Stats */} + + + + - ))} - - )} - + {matches.length} + Matches + + + + + + {packs.length} + Packs + + + 0 && styles.statIconHighlight]}> + 0 ? colors.text : colors.primary} /> + + {newMatchesCount} + New + + + + {/* Start Playing CTA */} + + router.push("/(app)/swipe")} + activeOpacity={0.9} + > + + + Ready to explore? + + Swipe through questions and discover what you both enjoy + + + + + + + + + + {/* Recent Matches */} + {recentMatches.length > 0 && ( + + + Recent Matches + router.push("/(app)/matches")}> + See all + + + {recentMatches.map((match, index) => ( + + router.push(`/chat/${match.id}`)} + activeOpacity={0.7} + > + + + + + + {(match as any).question?.text || "A new match!"} + + {match.is_new && } + + + + ))} + + )} + + {/* Bottom spacing for tab bar */} + + + ); } const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: "#1a1a2e", + }, + contentContainer: { + paddingBottom: Platform.OS === 'ios' ? 100 : 80, }, header: { paddingTop: 60, - paddingHorizontal: 24, - paddingBottom: 24, + paddingHorizontal: spacing.lg, + paddingBottom: spacing.lg, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', }, greeting: { - fontSize: 28, - fontWeight: "bold", - color: "#fff", + ...typography.title1, + color: colors.text, }, partnerText: { - fontSize: 14, - color: "#888", - marginTop: 4, + ...typography.subhead, + color: colors.textSecondary, + marginTop: spacing.xs, + }, + profileButton: { + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: colors.primary, + justifyContent: 'center', + alignItems: 'center', + ...shadows.md, + }, + profileButtonText: { + ...typography.headline, + color: colors.text, }, statsContainer: { flexDirection: "row", - paddingHorizontal: 24, - gap: 12, + paddingHorizontal: spacing.lg, + gap: spacing.sm, }, statCard: { flex: 1, - backgroundColor: "#16213e", - borderRadius: 16, - padding: 16, alignItems: "center", - borderWidth: 1, - borderColor: "#0f3460", + paddingVertical: spacing.md, + }, + statIconContainer: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: colors.primaryLight, + justifyContent: "center", + alignItems: "center", + marginBottom: spacing.sm, + }, + statIconHighlight: { + backgroundColor: colors.primary, }, statNumber: { - fontSize: 24, - fontWeight: "bold", - color: "#fff", - marginTop: 8, + ...typography.title2, + color: colors.text, }, statLabel: { - fontSize: 12, - color: "#888", - marginTop: 4, + ...typography.caption1, + color: colors.textSecondary, + marginTop: spacing.xs, }, ctaCard: { - margin: 24, - backgroundColor: "#e94560", - borderRadius: 16, - padding: 20, + margin: spacing.lg, + borderRadius: radius.xl, + padding: spacing.lg, flexDirection: "row", alignItems: "center", + ...shadows.lg, }, ctaContent: { flex: 1, }, ctaTitle: { - fontSize: 18, - fontWeight: "bold", - color: "#fff", + ...typography.title3, + color: colors.text, }, ctaSubtitle: { - fontSize: 14, + ...typography.subhead, color: "rgba(255,255,255,0.8)", - marginTop: 4, + marginTop: spacing.xs, + }, + ctaIconContainer: { + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: "rgba(255,255,255,0.2)", + justifyContent: "center", + alignItems: "center", + marginLeft: spacing.md, }, section: { - paddingHorizontal: 24, - marginBottom: 24, + paddingHorizontal: spacing.lg, + marginTop: spacing.sm, }, sectionHeader: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", - marginBottom: 16, + marginBottom: spacing.md, }, sectionTitle: { - fontSize: 18, - fontWeight: "600", - color: "#fff", + ...typography.headline, + color: colors.text, }, seeAll: { - color: "#e94560", - fontSize: 14, + ...typography.subhead, + color: colors.primary, }, matchItem: { flexDirection: "row", alignItems: "center", - backgroundColor: "#16213e", - borderRadius: 12, - padding: 16, - marginBottom: 8, - borderWidth: 1, - borderColor: "#0f3460", + marginBottom: spacing.sm, }, matchIcon: { - width: 40, - height: 40, - borderRadius: 20, - backgroundColor: "rgba(233, 69, 96, 0.2)", + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: colors.primaryLight, justifyContent: "center", alignItems: "center", - marginRight: 12, + marginRight: spacing.md, }, matchText: { flex: 1, - color: "#fff", - fontSize: 14, + ...typography.body, + color: colors.text, }, newBadge: { width: 8, height: 8, borderRadius: 4, - backgroundColor: "#e94560", + backgroundColor: colors.primary, + }, + bottomSpacer: { + height: spacing.lg, }, }); diff --git a/apps/mobile/app/(app)/matches.tsx b/apps/mobile/app/(app)/matches.tsx index 97e2523..c219938 100755 --- a/apps/mobile/app/(app)/matches.tsx +++ b/apps/mobile/app/(app)/matches.tsx @@ -1,27 +1,28 @@ -import { View, Text, StyleSheet, FlatList, RefreshControl, TouchableOpacity } from "react-native"; +import { View, Text, StyleSheet, FlatList, RefreshControl, TouchableOpacity, ActivityIndicator, Platform } from "react-native"; import { useMatchStore, useAuthStore } from "../../src/store"; import { useEffect } from "react"; import { Ionicons } from "@expo/vector-icons"; import { useRouter } from "expo-router"; +import Animated, { FadeInDown, FadeInRight } from "react-native-reanimated"; +import { GradientBackground, GlassCard } from "../../src/components/ui"; +import { colors, spacing, typography, radius } from "../../src/theme"; export default function MatchesScreen() { const { matches, fetchMatches, markAllAsSeen } = useMatchStore(); const { user } = useAuthStore(); + const router = useRouter(); useEffect(() => { fetchMatches(); }, []); - // Mark all matches as seen when matches are loaded useEffect(() => { if (matches.length > 0) { markAllAsSeen(); } }, [matches.length]); - const router = useRouter(); - - const renderItem = ({ item }: { item: any }) => { + const renderItem = ({ item, index }: { item: any; index: number }) => { const userResponse = item.responses?.find((r: any) => r.user_id === user?.id); const partnerResponse = item.responses?.find((r: any) => r.user_id !== user?.id); @@ -32,161 +33,220 @@ export default function MatchesScreen() { const userTime = new Date(userResponse.created_at).getTime(); const partnerTime = new Date(partnerResponse.created_at).getTime(); - // If user answered after partner, they saw the partner_text if (userTime > partnerTime) { userText = item.question.partner_text; partnerText = item.question.text; } } + const isYesYes = item.match_type === "yes_yes"; + return ( - router.push(`/chat/${item.id}`)} - activeOpacity={0.7} - > - - - - - {userText} - {item.question.partner_text && ( - Partner: {partnerText} - )} - - - - {item.match_type === "yes_yes" ? "Review: YES + YES" : "Review: YES + MAYBE"} - + + router.push(`/chat/${item.id}`)} + activeOpacity={0.7} + > + + + - - {new Date(item.created_at).toLocaleDateString()} - - - - + + + {userText} + + {item.question.partner_text && ( + + Partner: {partnerText} + + )} + + + + {isYesYes ? "YES + YES" : "YES + MAYBE"} + + + + {new Date(item.created_at).toLocaleDateString()} + + + + + + + + + ); }; if (!user) { return ( - + - + - + ); } return ( - - - Your Matches - + + + + Your Matches + + {matches.length} {matches.length === 1 ? 'match' : 'matches'} found + + - item.id} - contentContainerStyle={styles.list} - refreshControl={ - - } - ListEmptyComponent={ - - No matches yet. Keep swiping! - - } - /> - + item.id} + contentContainerStyle={styles.list} + showsVerticalScrollIndicator={false} + refreshControl={ + + } + ListEmptyComponent={ + + + + + No matches yet + + Keep swiping to find things you both enjoy! + + + } + /> + + ); } const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: "#1a1a2e", - }, - header: { - paddingTop: 60, - paddingHorizontal: 24, - paddingBottom: 24, - borderBottomWidth: 1, - borderBottomColor: "#16213e", - }, - title: { - fontSize: 28, - fontWeight: "bold", - color: "#fff", - }, - list: { - padding: 24, - }, - matchCard: { - flexDirection: "row", - backgroundColor: "#16213e", - borderRadius: 16, - padding: 16, - marginBottom: 16, - borderWidth: 1, - borderColor: "#0f3460", - }, - iconContainer: { - width: 48, - height: 48, - borderRadius: 24, - backgroundColor: "rgba(233, 69, 96, 0.1)", - justifyContent: "center", - alignItems: "center", - marginRight: 16, - }, - content: { - flex: 1, - }, - questionText: { - fontSize: 16, - fontWeight: "600", - color: "#fff", - marginBottom: 8, - lineHeight: 22, - }, - tagContainer: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - }, - tag: { - backgroundColor: "rgba(233, 69, 96, 0.2)", - paddingHorizontal: 8, - paddingVertical: 4, - borderRadius: 4, - }, - tagText: { - color: "#e94560", - fontSize: 12, - fontWeight: "bold", - }, - date: { - color: "#666", - fontSize: 12, - }, - emptyContainer: { - padding: 40, - alignItems: "center", - }, - emptyText: { - color: "#666", - fontSize: 16, - }, - partnerText: { - fontSize: 14, - color: "#aaa", - fontStyle: "italic", - marginBottom: 8, }, loadingContainer: { flex: 1, justifyContent: "center", alignItems: "center", }, + header: { + paddingTop: 60, + paddingHorizontal: spacing.lg, + paddingBottom: spacing.md, + }, + title: { + ...typography.title1, + color: colors.text, + }, + subtitle: { + ...typography.subhead, + color: colors.textSecondary, + marginTop: spacing.xs, + }, + list: { + padding: spacing.lg, + paddingBottom: Platform.OS === 'ios' ? 120 : 100, + }, + matchCard: { + flexDirection: "row", + alignItems: "center", + marginBottom: spacing.sm, + }, + iconContainer: { + width: 48, + height: 48, + borderRadius: 24, + backgroundColor: colors.primaryLight, + justifyContent: "center", + alignItems: "center", + marginRight: spacing.md, + }, + content: { + flex: 1, + }, + questionText: { + ...typography.body, + color: colors.text, + fontWeight: "600", + marginBottom: spacing.xs, + }, + partnerText: { + ...typography.subhead, + color: colors.textSecondary, + fontStyle: "italic", + marginBottom: spacing.sm, + }, + metaContainer: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + tag: { + backgroundColor: colors.glass.background, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderRadius: radius.xs, + }, + tagHighlight: { + backgroundColor: colors.primaryLight, + }, + tagText: { + ...typography.caption2, + color: colors.textSecondary, + fontWeight: "600", + }, + tagTextHighlight: { + color: colors.primary, + }, + date: { + ...typography.caption1, + color: colors.textTertiary, + }, + chevronContainer: { + marginLeft: spacing.sm, + }, + emptyContainer: { + alignItems: "center", + paddingVertical: spacing.xxl, + }, + emptyIconContainer: { + width: 80, + height: 80, + borderRadius: 40, + backgroundColor: colors.glass.background, + justifyContent: "center", + alignItems: "center", + marginBottom: spacing.md, + }, + emptyTitle: { + ...typography.title3, + color: colors.text, + marginBottom: spacing.xs, + }, + emptyText: { + ...typography.body, + color: colors.textSecondary, + textAlign: "center", + }, }); diff --git a/apps/mobile/app/(app)/packs.tsx b/apps/mobile/app/(app)/packs.tsx index 3a42222..ce158fa 100755 --- a/apps/mobile/app/(app)/packs.tsx +++ b/apps/mobile/app/(app)/packs.tsx @@ -1,8 +1,11 @@ -import { View, Text, StyleSheet, FlatList, TouchableOpacity, Image, Switch } from "react-native"; +import { View, Text, StyleSheet, FlatList, TouchableOpacity, Switch, RefreshControl, Platform } from "react-native"; import { usePacksStore } from "../../src/store"; import { Ionicons } from "@expo/vector-icons"; import { router } from "expo-router"; import { useEffect } from "react"; +import Animated, { FadeInDown, FadeInRight } from "react-native-reanimated"; +import { GradientBackground, GlassCard } from "../../src/components/ui"; +import { colors, spacing, typography, radius } from "../../src/theme"; export default function PacksScreen() { const { packs, enabledPackIds, togglePack, isLoading, fetchPacks } = usePacksStore(); @@ -11,86 +14,132 @@ export default function PacksScreen() { fetchPacks(); }, []); - const renderItem = ({ item }: { item: any }) => ( - - router.push(`/pack/${item.id}`)} - > - - {item.icon || "📦"} - - - - {item.name} - {item.is_premium && ( - - PRO + const enabledCount = packs.filter(p => enabledPackIds.includes(p.id)).length; + + const renderItem = ({ item, index }: { item: any; index: number }) => { + const isEnabled = enabledPackIds.includes(item.id); + + return ( + + + router.push(`/pack/${item.id}`)} + > + + {item.icon || "📦"} + + + + {item.name} + {item.is_premium && ( + + + PRO + + )} - )} + + {item.description} + + {item.question_count && ( + + {item.question_count} questions + + )} + + + + togglePack(item.id)} + trackColor={{ + false: colors.glass.background, + true: colors.primary, + }} + thumbColor={colors.text} + ios_backgroundColor={colors.glass.background} + /> - - {item.description} - - - - togglePack(item.id)} - trackColor={{ false: "#767577", true: "#e94560" }} - thumbColor={"#fff"} - /> - - ); + + + ); + }; return ( - - - Question Packs - + + + + Question Packs + + {enabledCount} of {packs.length} packs enabled + + - item.id} - contentContainerStyle={styles.list} - refreshing={isLoading} - onRefresh={fetchPacks} - /> - + item.id} + contentContainerStyle={styles.list} + showsVerticalScrollIndicator={false} + refreshControl={ + + } + ListEmptyComponent={ + + + + + No packs available + + Check back later for new question packs! + + + } + /> + + ); } const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: "#1a1a2e", }, header: { paddingTop: 60, - paddingHorizontal: 24, - paddingBottom: 24, - borderBottomWidth: 1, - borderBottomColor: "#16213e", + paddingHorizontal: spacing.lg, + paddingBottom: spacing.md, }, title: { - fontSize: 28, - fontWeight: "bold", - color: "#fff", + ...typography.title1, + color: colors.text, + }, + subtitle: { + ...typography.subhead, + color: colors.textSecondary, + marginTop: spacing.xs, }, list: { - padding: 24, + padding: spacing.lg, + paddingBottom: Platform.OS === 'ios' ? 120 : 100, }, card: { flexDirection: "row", alignItems: "center", - backgroundColor: "#16213e", - borderRadius: 16, - padding: 16, - marginBottom: 16, - borderWidth: 1, - borderColor: "#0f3460", + marginBottom: spacing.sm, }, cardContent: { flex: 1, @@ -98,46 +147,82 @@ const styles = StyleSheet.create({ alignItems: "center", }, iconContainer: { - width: 48, - height: 48, - borderRadius: 24, - backgroundColor: "rgba(255, 255, 255, 0.05)", + width: 52, + height: 52, + borderRadius: 26, + backgroundColor: colors.glass.background, justifyContent: "center", alignItems: "center", - marginRight: 16, + marginRight: spacing.md, + }, + iconContainerActive: { + backgroundColor: colors.primaryLight, }, emoji: { - fontSize: 24, + fontSize: 26, }, content: { flex: 1, - marginRight: 16, + marginRight: spacing.sm, }, headerRow: { flexDirection: "row", alignItems: "center", - marginBottom: 4, + marginBottom: spacing.xs, }, name: { - fontSize: 18, - fontWeight: "600", - color: "#fff", - marginRight: 8, + ...typography.headline, + color: colors.text, + marginRight: spacing.sm, }, badge: { - backgroundColor: "#e94560", - paddingHorizontal: 6, + flexDirection: "row", + alignItems: "center", + backgroundColor: colors.primary, + paddingHorizontal: spacing.sm, paddingVertical: 2, - borderRadius: 4, + borderRadius: radius.xs, + gap: 2, }, badgeText: { - color: "#fff", - fontSize: 10, - fontWeight: "bold", + ...typography.caption2, + color: colors.text, + fontWeight: "700", }, description: { - fontSize: 14, - color: "#888", + ...typography.subhead, + color: colors.textSecondary, lineHeight: 20, }, + questionCount: { + ...typography.caption1, + color: colors.textTertiary, + marginTop: spacing.xs, + }, + switchContainer: { + paddingLeft: spacing.sm, + }, + emptyContainer: { + alignItems: "center", + paddingVertical: spacing.xxl, + }, + emptyIconContainer: { + width: 80, + height: 80, + borderRadius: 40, + backgroundColor: colors.glass.background, + justifyContent: "center", + alignItems: "center", + marginBottom: spacing.md, + }, + emptyTitle: { + ...typography.title3, + color: colors.text, + marginBottom: spacing.xs, + }, + emptyText: { + ...typography.body, + color: colors.textSecondary, + textAlign: "center", + }, }); diff --git a/apps/mobile/app/(app)/profile.tsx b/apps/mobile/app/(app)/profile.tsx index 25b4ee1..c61ff26 100755 --- a/apps/mobile/app/(app)/profile.tsx +++ b/apps/mobile/app/(app)/profile.tsx @@ -1,7 +1,12 @@ -import { View, Text, StyleSheet, TouchableOpacity, Alert, Image } from "react-native"; +import { View, Text, StyleSheet, TouchableOpacity, Alert, ScrollView, Platform } from "react-native"; import { useAuthStore } from "../../src/store"; import { Ionicons } from "@expo/vector-icons"; +import { LinearGradient } from "expo-linear-gradient"; +import Animated, { FadeInDown } from "react-native-reanimated"; import { supabase } from "../../src/lib/supabase"; +import { router } from "expo-router"; +import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui"; +import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme"; export default function ProfileScreen() { const { user, partner, signOut, fetchCouple } = useAuthStore(); @@ -20,7 +25,7 @@ export default function ProfileScreen() { await supabase.functions.invoke("manage-couple", { method: "DELETE", }); - await fetchCouple(); // Refresh state + await fetchCouple(); } catch (error) { Alert.alert("Error", "Failed to unpair"); } @@ -30,139 +35,269 @@ export default function ProfileScreen() { ); }; + 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 ( - - - Profile - + + + {/* Header */} + + Profile + - - - - - {user?.name?.[0]?.toUpperCase() || "U"} - - - {user?.name || "User"} - {user?.email} - - - Relationship - - {partner ? ( - - - - Paired with {partner.name || partner.email || 'your partner'} + {/* Profile Card */} + + + + + + {user?.name?.[0]?.toUpperCase() || "U"} + - - + + {user?.name || "User"} + {user?.email} + + + + {/* Relationship Section */} + + Relationship + + {partner ? ( + + + + + + + Paired with + + {partner.name || partner.email || 'your partner'} + + + + + + + + ) : ( + router.push("/(app)/pairing")} + activeOpacity={0.7} + > + + + + + + Not paired yet + Tap to connect with your partner + + + - - ) : ( - - Not paired yet - - )} - + )} + + - Settings - - - Sign Out - - - + {/* Settings Section */} + + Account + + + + + + + Sign Out + + + + + - Sauci v1.0.0 - - + {/* Version */} + + Sauci v1.0.0 + Made with love + + + {/* Bottom spacing for tab bar */} + + + ); } const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: "#1a1a2e", + }, + contentContainer: { + paddingBottom: Platform.OS === 'ios' ? 100 : 80, }, header: { paddingTop: 60, - paddingHorizontal: 24, - paddingBottom: 24, - borderBottomWidth: 1, - borderBottomColor: "#16213e", + paddingHorizontal: spacing.lg, + paddingBottom: spacing.md, }, title: { - fontSize: 28, - fontWeight: "bold", - color: "#fff", - }, - content: { - padding: 24, + ...typography.title1, + color: colors.text, }, profileCard: { + marginHorizontal: spacing.lg, alignItems: "center", - marginBottom: 40, + paddingVertical: spacing.xl, + marginBottom: spacing.lg, + }, + avatarContainer: { + width: 88, + height: 88, + borderRadius: 44, + justifyContent: "center", + alignItems: "center", + marginBottom: spacing.md, + ...shadows.lg, }, avatar: { width: 80, height: 80, borderRadius: 40, - backgroundColor: "#e94560", + backgroundColor: colors.background, justifyContent: "center", alignItems: "center", - marginBottom: 16, }, avatarText: { fontSize: 32, fontWeight: "bold", - color: "#fff", + color: colors.text, }, name: { - fontSize: 24, - fontWeight: "bold", - color: "#fff", - marginBottom: 4, + ...typography.title2, + color: colors.text, + marginBottom: spacing.xs, }, email: { - fontSize: 14, - color: "#666", + ...typography.subhead, + color: colors.textSecondary, }, sectionTitle: { - fontSize: 14, - fontWeight: "600", - color: "#666", - marginBottom: 8, - marginLeft: 4, + ...typography.caption1, + color: colors.textTertiary, textTransform: "uppercase", - }, - section: { - backgroundColor: "#16213e", - borderRadius: 12, - marginBottom: 24, - overflow: "hidden", + letterSpacing: 1, + marginBottom: spacing.sm, + marginLeft: spacing.lg + spacing.xs, }, row: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", - padding: 16, - borderBottomWidth: 1, - borderBottomColor: "#1a1a2e", + padding: spacing.md, }, - partnerInfo: { + rowLeft: { flexDirection: "row", alignItems: "center", - gap: 12, + flex: 1, }, - rowText: { - fontSize: 16, - color: "#fff", + rowIconContainer: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: colors.primaryLight, + justifyContent: "center", + alignItems: "center", + marginRight: spacing.md, + }, + rowIconInactive: { + backgroundColor: colors.glass.background, + }, + rowIconDanger: { + backgroundColor: colors.errorLight, + }, + rowLabel: { + ...typography.subhead, + color: colors.textSecondary, + }, + rowValue: { + ...typography.body, + color: colors.text, + fontWeight: "600", + }, + rowValueMuted: { + ...typography.caption1, + color: colors.textTertiary, + }, + rowLabelDanger: { + ...typography.body, + color: colors.error, + fontWeight: "500", + }, + rowAction: { + padding: spacing.sm, + }, + menuRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + padding: spacing.md, + }, + menuRowDanger: { + borderRadius: radius.lg, + }, + versionContainer: { + alignItems: "center", + marginTop: spacing.xl, }, version: { - textAlign: "center", - color: "#666", - fontSize: 12, - marginTop: 24, + ...typography.caption1, + color: colors.textTertiary, + }, + versionSub: { + ...typography.caption2, + color: colors.textTertiary, + marginTop: spacing.xs, + }, + bottomSpacer: { + height: spacing.lg, }, }); diff --git a/apps/mobile/app/(app)/swipe.tsx b/apps/mobile/app/(app)/swipe.tsx index 659ef5c..7a04e3d 100755 --- a/apps/mobile/app/(app)/swipe.tsx +++ b/apps/mobile/app/(app)/swipe.tsx @@ -1,9 +1,12 @@ import { useState, useEffect } from "react"; -import { View, StyleSheet, Text, ActivityIndicator } from "react-native"; +import { View, StyleSheet, Text, ActivityIndicator, Platform } from "react-native"; import { useLocalSearchParams } from "expo-router"; import { Ionicons } from "@expo/vector-icons"; +import Animated, { FadeIn, FadeInUp } from "react-native-reanimated"; import { supabase } from "../../src/lib/supabase"; import SwipeCard from "../../src/components/SwipeCard"; +import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui"; +import { colors, spacing, typography, radius, shadows } from "../../src/theme"; export default function SwipeScreen() { const { packId } = useLocalSearchParams(); @@ -26,11 +29,6 @@ export default function SwipeScreen() { throw error; } - // Smart Shuffle - // Priority: - // 1. Completion of Two-Part Match (Partner answered, is_two_part = true) -> +1.5 - // 2. Partner Answered (Any) -> +0.7 - // 3. New Two-Part (Get someone to start) -> +0.4 const sorted = (data || []).sort((a: any, b: any) => { let scoreA = Math.random(); let scoreB = Math.random(); @@ -58,23 +56,18 @@ export default function SwipeScreen() { const question = questions[currentIndex]; const answer = direction === "right" ? "yes" : direction === "left" ? "no" : "maybe"; - // Optimistic update setCurrentIndex(prev => prev + 1); try { - // DEBUG: Check if we have a valid session const { data: { session } } = await supabase.auth.getSession(); console.log("DEBUG - Session exists:", !!session); - console.log("DEBUG - Access token exists:", !!session?.access_token); - console.log("DEBUG - Token preview:", session?.access_token?.substring(0, 50) + "..."); - const result = await supabase.functions.invoke("submit-response", { + await supabase.functions.invoke("submit-response", { body: { question_id: question.id, answer, }, }); - console.log("DEBUG - Function result:", result); } catch (error) { console.error("Failed to submit response", error); } @@ -82,94 +75,178 @@ export default function SwipeScreen() { if (isLoading) { return ( - - - + + + + + ); } if (currentIndex >= questions.length) { return ( - - - All caught up! - - Check back later for more questions or try a different pack. - - + + + + + + + All caught up! + + Check back later for more questions or try a different pack. + + + Refresh Questions + + + + ); } return ( - - - - Question {currentIndex + 1} of {questions.length} - - + + + {/* Header */} + + + + {currentIndex + 1} of {questions.length} + + + + + + - - {/* Render next card in background for smoothness */} - {questions[currentIndex + 1] && ( - - )} + {/* Card Stack */} + + {/* Background card (next) */} + {questions[currentIndex + 1] && ( + + )} - {/* Active card */} - + {/* Active card */} + + + + {/* Hint */} + + Swipe or tap to answer + + + {/* Bottom spacing for tab bar */} + - + ); } const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: "#1a1a2e", + }, + centerContainer: { + flex: 1, justifyContent: "center", alignItems: "center", }, header: { - position: "absolute", - top: 60, - zIndex: 1, + paddingTop: 60, + paddingHorizontal: spacing.lg, + paddingBottom: spacing.md, }, - progress: { - color: "#666", - fontSize: 16, - fontWeight: "600", + progressContainer: { + alignItems: "center", + }, + progressText: { + ...typography.subhead, + color: colors.textSecondary, + marginBottom: spacing.sm, + }, + progressBar: { + width: 120, + height: 4, + backgroundColor: colors.glass.background, + borderRadius: 2, + overflow: "hidden", + }, + progressFill: { + height: "100%", + backgroundColor: colors.primary, + borderRadius: 2, }, cardContainer: { flex: 1, justifyContent: "center", alignItems: "center", - marginTop: 40, }, backgroundCard: { position: "absolute", - width: "100%", // Match SwipeCard width logic - height: 500, - backgroundColor: "#16213e", - borderRadius: 24, + width: "85%", + height: 480, + backgroundColor: colors.glass.backgroundLight, + borderRadius: radius.xxl, borderWidth: 1, - borderColor: "#0f3460", - transform: [{ scale: 0.95 }, { translateY: 10 }], + borderColor: colors.glass.border, + transform: [{ scale: 0.95 }, { translateY: 12 }], opacity: 0.5, + ...shadows.md, + }, + hintContainer: { + alignItems: "center", + paddingBottom: spacing.md, + }, + hintText: { + ...typography.caption1, + color: colors.textTertiary, + }, + emptyContent: { + alignItems: "center", + paddingHorizontal: spacing.xl, + }, + emptyIconContainer: { + width: 120, + height: 120, + borderRadius: 60, + backgroundColor: colors.primaryLight, + justifyContent: "center", + alignItems: "center", + marginBottom: spacing.lg, }, emptyTitle: { - fontSize: 24, - fontWeight: "bold", - color: "#fff", - marginTop: 24, - marginBottom: 8, + ...typography.title2, + color: colors.text, + marginBottom: spacing.sm, }, emptySubtitle: { - fontSize: 16, - color: "#888", + ...typography.body, + color: colors.textSecondary, textAlign: "center", - paddingHorizontal: 40, + }, + bottomSpacer: { + height: Platform.OS === 'ios' ? 100 : 80, }, }); diff --git a/apps/mobile/app/(auth)/login.tsx b/apps/mobile/app/(auth)/login.tsx index 5643f7e..cc50838 100755 --- a/apps/mobile/app/(auth)/login.tsx +++ b/apps/mobile/app/(auth)/login.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { View, Text, - TextInput, TouchableOpacity, StyleSheet, Alert, @@ -12,9 +11,16 @@ import { } from "react-native"; import { Redirect } from "expo-router"; import * as Linking from "expo-linking"; +import Animated, { + FadeIn, + FadeInDown, + FadeInUp, +} from "react-native-reanimated"; +import { Ionicons } from "@expo/vector-icons"; import { supabase } from "../../src/lib/supabase"; import { useAuthStore } from "../../src/store"; - +import { GradientBackground, GlassCard, GlassButton, GlassInput } from "../../src/components/ui"; +import { colors, spacing, radius, typography } from "../../src/theme"; type AuthMode = "magic-link" | "password"; @@ -25,22 +31,32 @@ export default function LoginScreen() { const [isMagicLinkSent, setIsMagicLinkSent] = useState(false); const [authMode, setAuthMode] = useState("magic-link"); const [isSignUp, setIsSignUp] = useState(false); + const [error, setError] = useState(null); const { isAuthenticated } = useAuthStore(); - // Use declarative Redirect instead of imperative router.replace() - // This ensures navigation only happens after the Root Layout is mounted + const showError = (message: string) => { + setError(message); + // Also show native alert on mobile + if (Platform.OS !== 'web') { + Alert.alert("Error", message); + } + }; + + const clearError = () => setError(null); + if (isAuthenticated) { return ; } const handleMagicLink = async () => { + clearError(); if (!email.trim()) { - Alert.alert("Error", "Please enter your email"); + showError("Please enter your email"); return; } setIsLoading(true); - const { error } = await supabase.auth.signInWithOtp({ + const { error: authError } = await supabase.auth.signInWithOtp({ email: email.trim(), options: { emailRedirectTo: Linking.createURL("/(auth)/login"), @@ -49,56 +65,66 @@ export default function LoginScreen() { setIsLoading(false); - if (error) { - Alert.alert("Error", error.message); + if (authError) { + showError(authError.message); } else { setIsMagicLinkSent(true); } }; const handlePasswordAuth = async () => { + clearError(); if (!email.trim()) { - Alert.alert("Error", "Please enter your email"); + showError("Please enter your email"); return; } if (!password) { - Alert.alert("Error", "Please enter your password"); + showError("Please enter your password"); return; } if (isSignUp && password.length < 6) { - Alert.alert("Error", "Password must be at least 6 characters"); + showError("Password must be at least 6 characters"); return; } setIsLoading(true); if (isSignUp) { - const { error } = await supabase.auth.signUp({ + const { error: signUpError } = await supabase.auth.signUp({ email: email.trim(), password, }); setIsLoading(false); - if (error) { - Alert.alert("Error", error.message); + if (signUpError) { + showError(signUpError.message); } else { - Alert.alert( - "Check your email", - "We sent you a confirmation link to verify your email address.", - [{ text: "OK" }] - ); + // Show success message + if (Platform.OS === 'web') { + setError(null); + Alert.alert( + "Check your email", + "We sent you a confirmation link to verify your email address." + ); + } else { + Alert.alert( + "Check your email", + "We sent you a confirmation link to verify your email address.", + [{ text: "OK" }] + ); + } } } else { - const { error } = await supabase.auth.signInWithPassword({ + const { error: signInError } = await supabase.auth.signInWithPassword({ email: email.trim(), password, }); setIsLoading(false); - if (error) { - Alert.alert("Error", error.message); + if (signInError) { + showError(signInError.message); } } }; @@ -131,290 +157,334 @@ export default function LoginScreen() { if (isMagicLinkSent) { return ( - - - ✉️ - Check your email - - We sent a magic link to {email} - - setIsMagicLinkSent(false)} + + + - Try a different email - + + + + Check your email + + We sent a magic link to{'\n'} + {email} + + setIsMagicLinkSent(false)} + style={{ marginTop: spacing.lg }} + > + Try a different email + + - + ); } return ( - - + - - 💕 - Sauci - - Explore intimacy together - - - {/* Auth Mode Toggle */} - - setAuthMode("magic-link")} + + + {/* Logo/Brand */} + - - Magic Link + + + + Sauci + + Explore intimacy together - - setAuthMode("password")} + + + {/* Auth Card */} + - - Password - - - + + {/* Auth Mode Toggle */} + + setAuthMode("magic-link")} + activeOpacity={0.7} + > + + Magic Link + + + setAuthMode("password")} + activeOpacity={0.7} + > + + Password + + + - - + {/* Error Message */} + {error && ( + + + {error} + + )} - {authMode === "password" && ( - - )} + {/* Form Fields */} + + { clearError(); setEmail(text); }} + keyboardType="email-address" + autoCapitalize="none" + autoCorrect={false} + icon={} + /> - {authMode === "magic-link" ? ( - - - {isLoading ? "Sending..." : "Continue with Magic Link"} - - - ) : ( - <> - { clearError(); setPassword(text); }} + secureTextEntry + autoCapitalize="none" + icon={} + /> + )} + + {authMode === "magic-link" ? ( + + Continue with Magic Link + + ) : ( + <> + + {isSignUp ? "Create Account" : "Sign In"} + + setIsSignUp(!isSignUp)} + > + + {isSignUp + ? "Already have an account? Sign In" + : "Don't have an account? Sign Up"} + + + + )} + + + + + {/* Social Sign In */} + + + + or continue with + + + + + } + style={styles.socialButton} > - - {isLoading - ? isSignUp - ? "Creating Account..." - : "Signing In..." - : isSignUp - ? "Create Account" - : "Sign In"} - - - setIsSignUp(!isSignUp)} + Google + + + } + style={styles.socialButton} > - - {isSignUp - ? "Already have an account? Sign In" - : "Don't have an account? Sign Up"} - - - - )} - - - - or - - - - - Continue with Google - - - - Continue with Apple - + Apple + + + - - - + + + ); } const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: "#1a1a2e", - }, - content: { - flex: 1, - paddingHorizontal: 24, - paddingTop: 80, - alignItems: "center", - }, - emoji: { - fontSize: 64, - marginBottom: 16, - }, - title: { - fontSize: 32, - fontWeight: "bold", - color: "#fff", - marginBottom: 8, - }, - subtitle: { - fontSize: 16, - color: "#888", - marginBottom: 48, - textAlign: "center", - }, - form: { - width: "100%", - }, - input: { - backgroundColor: "#16213e", - borderRadius: 12, - padding: 16, - fontSize: 16, - color: "#fff", - marginBottom: 16, - borderWidth: 1, - borderColor: "#0f3460", - }, - button: { - backgroundColor: "#e94560", - borderRadius: 12, - padding: 16, - alignItems: "center", - }, - buttonDisabled: { - opacity: 0.7, - }, - buttonText: { - color: "#fff", - fontSize: 16, - fontWeight: "600", - }, - divider: { - flexDirection: "row", - alignItems: "center", - marginVertical: 24, - }, - dividerLine: { - flex: 1, - height: 1, - backgroundColor: "#333", - }, - dividerText: { - color: "#666", - paddingHorizontal: 16, - }, - socialButton: { - backgroundColor: "#16213e", - borderRadius: 12, - padding: 16, - alignItems: "center", - marginBottom: 12, - borderWidth: 1, - borderColor: "#0f3460", - }, - socialButtonText: { - color: "#fff", - fontSize: 16, - fontWeight: "500", - }, - secondaryButton: { - marginTop: 24, - }, - secondaryButtonText: { - color: "#e94560", - fontSize: 16, }, scrollContent: { flexGrow: 1, }, + content: { + flex: 1, + paddingHorizontal: spacing.lg, + paddingTop: 80, + paddingBottom: spacing.xl, + }, + brandContainer: { + alignItems: "center", + marginBottom: spacing.xl, + }, + logoContainer: { + width: 80, + height: 80, + borderRadius: 40, + backgroundColor: colors.primaryLight, + justifyContent: "center", + alignItems: "center", + marginBottom: spacing.md, + }, + title: { + ...typography.largeTitle, + color: colors.text, + marginBottom: spacing.xs, + }, + subtitle: { + ...typography.body, + color: colors.textSecondary, + textAlign: "center", + }, + emailHighlight: { + color: colors.primary, + fontWeight: "600", + }, + iconContainer: { + width: 100, + height: 100, + borderRadius: 50, + backgroundColor: colors.primaryLight, + justifyContent: "center", + alignItems: "center", + marginBottom: spacing.lg, + }, + formContainer: { + marginBottom: spacing.lg, + }, modeToggleContainer: { flexDirection: "row", - backgroundColor: "#16213e", - borderRadius: 12, + backgroundColor: colors.glass.background, + borderRadius: radius.md, padding: 4, - marginBottom: 24, - width: "100%", + marginBottom: spacing.lg, }, modeToggle: { flex: 1, paddingVertical: 12, - borderRadius: 8, + borderRadius: radius.sm, alignItems: "center", }, modeToggleActive: { - backgroundColor: "#e94560", + backgroundColor: colors.primary, }, modeToggleText: { - color: "#666", - fontSize: 14, - fontWeight: "500", + ...typography.subhead, + color: colors.textTertiary, + fontWeight: "600", }, modeToggleTextActive: { - color: "#fff", + color: colors.text, + }, + errorContainer: { + flexDirection: "row", + alignItems: "center", + backgroundColor: colors.errorLight, + padding: spacing.md, + borderRadius: radius.md, + marginBottom: spacing.md, + gap: spacing.sm, + }, + errorText: { + ...typography.subhead, + color: colors.error, + flex: 1, + }, + form: { + gap: spacing.sm, }, toggleSignUp: { - marginTop: 16, + marginTop: spacing.md, alignItems: "center", }, toggleSignUpText: { - color: "#e94560", - fontSize: 14, + ...typography.subhead, + color: colors.primary, + }, + socialContainer: { + marginTop: spacing.md, + }, + divider: { + flexDirection: "row", + alignItems: "center", + marginBottom: spacing.lg, + }, + dividerLine: { + flex: 1, + height: 1, + backgroundColor: colors.glass.border, + }, + dividerText: { + ...typography.caption1, + color: colors.textTertiary, + paddingHorizontal: spacing.md, + }, + socialButtons: { + flexDirection: "row", + gap: spacing.md, + }, + socialButton: { + flex: 1, }, }); - diff --git a/apps/mobile/package-lock.json b/apps/mobile/package-lock.json index d06b040..618d371 100644 --- a/apps/mobile/package-lock.json +++ b/apps/mobile/package-lock.json @@ -16,10 +16,13 @@ "@tanstack/react-query": "^5.17.0", "base64-arraybuffer": "^1.0.2", "expo": "~52.0.0", + "expo-blur": "~14.0.3", "expo-clipboard": "~7.0.1", "expo-constants": "~17.0.0", "expo-file-system": "~18.0.12", + "expo-haptics": "~14.0.1", "expo-image-picker": "~16.0.6", + "expo-linear-gradient": "~14.0.2", "expo-linking": "~7.0.0", "expo-notifications": "~0.29.0", "expo-router": "~4.0.0", @@ -6195,6 +6198,17 @@ "react-native": "*" } }, + "node_modules/expo-blur": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/expo-blur/-/expo-blur-14.0.3.tgz", + "integrity": "sha512-BL3xnqBJbYm3Hg9t/HjNjdeY7N/q8eK5tsLYxswWG1yElISWZmMvrXYekl7XaVCPfyFyz8vQeaxd7q74ZY3Wrw==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-clipboard": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-7.0.1.tgz", @@ -6249,6 +6263,15 @@ "react-native": "*" } }, + "node_modules/expo-haptics": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-14.0.1.tgz", + "integrity": "sha512-V81FZ7xRUfqM6uSI6FA1KnZ+QpEKnISqafob/xEfcx1ymwhm4V3snuLWWFjmAz+XaZQTqlYa8z3QbqEXz7G63w==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-image-loader": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-5.0.0.tgz", @@ -6280,6 +6303,17 @@ "react": "*" } }, + "node_modules/expo-linear-gradient": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-14.0.2.tgz", + "integrity": "sha512-nvac1sPUfFFJ4mY25UkvubpUV/olrBH+uQw5k+beqSvQaVQiUfFtYzfRr+6HhYBNb4AEsOtpsCRkpDww3M2iGQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-linking": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-7.0.5.tgz", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 8d1307a..56ce8a0 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -20,10 +20,13 @@ "@tanstack/react-query": "^5.17.0", "base64-arraybuffer": "^1.0.2", "expo": "~52.0.0", + "expo-blur": "~14.0.3", "expo-clipboard": "~7.0.1", "expo-constants": "~17.0.0", "expo-file-system": "~18.0.12", + "expo-haptics": "~14.0.1", "expo-image-picker": "~16.0.6", + "expo-linear-gradient": "~14.0.2", "expo-linking": "~7.0.0", "expo-notifications": "~0.29.0", "expo-router": "~4.0.0", diff --git a/apps/mobile/src/components/SwipeCard.native.tsx b/apps/mobile/src/components/SwipeCard.native.tsx index f437210..be80dad 100755 --- a/apps/mobile/src/components/SwipeCard.native.tsx +++ b/apps/mobile/src/components/SwipeCard.native.tsx @@ -1,5 +1,7 @@ -import { View, Text, StyleSheet, Image, Dimensions, TouchableOpacity } from "react-native"; +import { View, Text, StyleSheet, Dimensions, Platform } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; +import { BlurView } from "expo-blur"; +import { LinearGradient } from "expo-linear-gradient"; import Animated, { useSharedValue, useAnimatedStyle, @@ -8,17 +10,33 @@ import Animated, { interpolate, Extrapolate, withTiming, + interpolateColor, } from "react-native-reanimated"; import { Ionicons } from "@expo/vector-icons"; +import { colors, gradients, radius, shadows, blur, typography, spacing, animations } from "../theme"; + +// Haptics helper - not supported on web +const triggerHaptic = async (style: 'light' | 'medium' | 'heavy' = 'medium') => { + if (Platform.OS === 'web') return; + const Haptics = await import('expo-haptics'); + const feedbackStyle = style === 'light' + ? Haptics.ImpactFeedbackStyle.Light + : style === 'heavy' + ? Haptics.ImpactFeedbackStyle.Heavy + : Haptics.ImpactFeedbackStyle.Medium; + await Haptics.impactAsync(feedbackStyle); +}; const SCREEN_WIDTH = Dimensions.get("window").width; const SWIPE_THRESHOLD = SCREEN_WIDTH * 0.25; interface Props { - question: { id: string; text: string; intensity: number; partner_text?: string | null }; + question: { id: string; text: string; intensity: number; partner_text?: string | null; is_two_part?: boolean }; onSwipe: (direction: "left" | "right" | "up") => void; } +const AnimatedBlurView = Animated.createAnimatedComponent(BlurView); + export default function SwipeCard({ question, onSwipe }: Props) { const translateX = useSharedValue(0); const translateY = useSharedValue(0); @@ -32,7 +50,7 @@ export default function SwipeCard({ question, onSwipe }: Props) { .activeOffsetY([-15, 15]) .onStart(() => { context.value = { x: translateX.value, y: translateY.value }; - scale.value = withTiming(1.05); + scale.value = withTiming(1.02, { duration: animations.timing.fast }); }) .onUpdate((event) => { translateX.value = event.translationX + context.value.x; @@ -40,27 +58,29 @@ export default function SwipeCard({ question, onSwipe }: Props) { rotation.value = interpolate( translateX.value, [-SCREEN_WIDTH / 2, SCREEN_WIDTH / 2], - [-15, 15], + [-12, 12], Extrapolate.CLAMP ); }) .onEnd(() => { - scale.value = withTiming(1); + scale.value = withTiming(1, { duration: animations.timing.fast }); if (Math.abs(translateX.value) > SWIPE_THRESHOLD) { - // Horizontal swipe (YES/NO) const direction = translateX.value > 0 ? "right" : "left"; - translateX.value = withSpring(direction === "right" ? SCREEN_WIDTH * 1.5 : -SCREEN_WIDTH * 1.5); + runOnJS(triggerHaptic)('medium'); + translateX.value = withSpring( + direction === "right" ? SCREEN_WIDTH * 1.5 : -SCREEN_WIDTH * 1.5, + animations.springGentle + ); runOnJS(onSwipe)(direction); } else if (translateY.value < -SWIPE_THRESHOLD) { - // Vertical swipe up (MAYBE) - translateY.value = withSpring(-SCREEN_WIDTH * 1.5); + runOnJS(triggerHaptic)('medium'); + translateY.value = withSpring(-SCREEN_WIDTH * 1.5, animations.springGentle); runOnJS(onSwipe)("up"); } else { - // Reset - translateX.value = withSpring(0); - translateY.value = withSpring(0); - rotation.value = withSpring(0); + translateX.value = withSpring(0, animations.spring); + translateY.value = withSpring(0, animations.spring); + rotation.value = withSpring(0, animations.spring); } }); @@ -73,80 +93,184 @@ export default function SwipeCard({ question, onSwipe }: Props) { ], })); - const overlayStyle = (direction: "left" | "right" | "up", color: string) => + const overlayStyle = (direction: "left" | "right" | "up") => useAnimatedStyle(() => { let opacity = 0; + let backgroundColor = 'transparent'; + if (direction === "right") { - opacity = interpolate(translateX.value, [0, SWIPE_THRESHOLD], [0, 1]); + opacity = interpolate(translateX.value, [0, SWIPE_THRESHOLD], [0, 0.9]); + backgroundColor = interpolateColor( + opacity, + [0, 0.9], + ['transparent', 'rgba(46, 204, 113, 0.4)'] + ) as string; } else if (direction === "left") { - opacity = interpolate(translateX.value, [0, -SWIPE_THRESHOLD], [0, 1]); + opacity = interpolate(translateX.value, [0, -SWIPE_THRESHOLD], [0, 0.9]); + backgroundColor = interpolateColor( + opacity, + [0, 0.9], + ['transparent', 'rgba(231, 76, 60, 0.4)'] + ) as string; } else { - opacity = interpolate(translateY.value, [0, -SWIPE_THRESHOLD], [0, 1]); + opacity = interpolate(translateY.value, [0, -SWIPE_THRESHOLD], [0, 0.9]); + backgroundColor = interpolateColor( + opacity, + [0, 0.9], + ['transparent', 'rgba(243, 156, 18, 0.4)'] + ) as string; } return { opacity, - backgroundColor: color, + backgroundColor, }; }); + const handleButtonPress = async (direction: "left" | "right" | "up") => { + await triggerHaptic('light'); + onSwipe(direction); + }; + + const useBlur = Platform.OS === 'ios'; + return ( - - {/* Overlays */} - - YES - - - NO - - - MAYBE - - - - - {[...Array(question.intensity)].map((_, i) => ( - - ))} + + {useBlur ? ( + + + + ) : ( + + - {question.text} - + )} - + + ); +} + +function CardContent({ + question, + overlayStyle, + handleButtonPress, +}: { + question: Props['question']; + overlayStyle: (direction: "left" | "right" | "up") => any; + handleButtonPress: (direction: "left" | "right" | "up") => void; +}) { + return ( + <> + {/* Top highlight */} + + + {/* Overlays */} + + + YES + + + + + NO + + + + + MAYBE + + + + {/* Content */} + + + {[...Array(question.intensity)].map((_, i) => ( + + ))} + + + {question.text} + + + + {/* Action Buttons */} - onSwipe("left")} - activeOpacity={0.7} - > - - - - - onSwipe("up")} - activeOpacity={0.7} - > - - - - - onSwipe("right")} - activeOpacity={0.7} - > - - - + handleButtonPress("left")} + colors={gradients.error as [string, string]} + icon="close" + /> + handleButtonPress("up")} + colors={gradients.warning as [string, string]} + icon="help" + small + /> + handleButtonPress("right")} + colors={gradients.success as [string, string]} + icon="heart" + /> - + + ); +} + +function ActionButton({ + onPress, + colors: buttonColors, + icon, + small = false, +}: { + onPress: () => void; + colors: [string, string]; + icon: string; + small?: boolean; +}) { + return ( + + + + + + ); } @@ -154,159 +278,113 @@ const styles = StyleSheet.create({ cardWrapper: { position: "absolute", width: SCREEN_WIDTH - 48, - height: 500, + height: 480, }, - card: { + cardOuter: { width: "100%", - height: 500, - backgroundColor: "#16213e", - borderRadius: 24, - borderWidth: 1, - borderColor: "#0f3460", - shadowColor: "#000", - shadowOffset: { - width: 0, - height: 4, - }, - shadowOpacity: 0.3, - shadowRadius: 4.65, - elevation: 8, + height: "100%", + borderRadius: radius.xxl, overflow: "hidden", }, + blurContainer: { + flex: 1, + backgroundColor: colors.glass.background, + borderWidth: 1, + borderColor: colors.glass.border, + borderRadius: radius.xxl, + overflow: "hidden", + }, + fallbackContainer: { + flex: 1, + backgroundColor: colors.glass.backgroundLight, + borderWidth: 1, + borderColor: colors.glass.border, + borderRadius: radius.xxl, + overflow: "hidden", + }, + highlight: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: 80, + }, content: { flex: 1, justifyContent: "center", alignItems: "center", - padding: 32, + padding: spacing.xl, }, intensityContainer: { flexDirection: "row", - marginBottom: 24, - backgroundColor: "rgba(233, 69, 96, 0.1)", - paddingHorizontal: 12, - paddingVertical: 6, - borderRadius: 12, + marginBottom: spacing.lg, + backgroundColor: colors.primaryLight, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radius.full, + gap: 2, }, text: { - fontSize: 24, - fontWeight: "bold", - color: "#fff", + ...typography.title2, + color: colors.text, textAlign: "center", lineHeight: 32, }, twoPartText: { - fontSize: 20, + ...typography.title3, lineHeight: 28, }, footer: { - position: "absolute", - bottom: 0, - left: 0, - right: 0, - paddingTop: 8, - paddingBottom: 16, - paddingHorizontal: 8, - alignItems: "center", - backgroundColor: "rgba(0,0,0,0.2)", - borderBottomLeftRadius: 24, - borderBottomRightRadius: 24, - }, - hint: { - color: "#666", - fontSize: 12, - marginBottom: 4, + paddingBottom: spacing.lg, + paddingHorizontal: spacing.lg, }, overlay: { ...StyleSheet.absoluteFillObject, justifyContent: "center", alignItems: "center", zIndex: 10, + borderRadius: radius.xxl, + }, + overlayBadge: { + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + borderRadius: radius.md, + transform: [{ rotate: "-15deg" }], }, overlayText: { - fontSize: 48, + fontSize: 36, fontWeight: "900", - color: "#fff", - textShadowColor: "rgba(0,0,0,0.5)", - textShadowOffset: { width: 0, height: 2 }, - textShadowRadius: 4, - transform: [{ rotate: "-15deg" }], - borderWidth: 4, - borderColor: "#fff", - paddingHorizontal: 16, - paddingVertical: 8, - borderRadius: 12, - }, - partnerContainer: { - width: "100%", - alignItems: "center", - }, - divider: { - height: 1, - width: "80%", - backgroundColor: "rgba(255,255,255,0.1)", - marginVertical: 24, - position: "relative", - justifyContent: "center", - alignItems: "center", - }, - dividerText: { - color: "#666", - backgroundColor: "#16213e", - paddingHorizontal: 10, - fontSize: 12, - fontWeight: "bold", - position: "absolute", + color: colors.text, + letterSpacing: 2, }, buttonContainer: { flexDirection: "row", - justifyContent: "space-between", + justifyContent: "center", alignItems: "center", - paddingHorizontal: 32, - paddingVertical: 16, - gap: 20, - zIndex: 21, + gap: spacing.md, }, actionButton: { - alignItems: "center", - justifyContent: "center", width: 64, height: 64, borderRadius: 32, - shadowColor: "#000", - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.4, - shadowRadius: 8, - elevation: 8, - borderWidth: 2, + justifyContent: "center", + alignItems: "center", overflow: "hidden", - zIndex: 22, }, - noButton: { - backgroundColor: "#E74C3C", - borderColor: "rgba(255, 255, 255, 0.3)", + actionButtonSmall: { + width: 52, + height: 52, + borderRadius: 26, }, - maybeButton: { - backgroundColor: "#F39C12", - borderColor: "rgba(255, 255, 255, 0.3)", - }, - yesButton: { - backgroundColor: "#2ECC71", - borderColor: "rgba(255, 255, 255, 0.3)", - }, - buttonInnerGlow: { + buttonHighlight: { position: "absolute", top: 0, left: 0, right: 0, height: "50%", backgroundColor: "rgba(255, 255, 255, 0.15)", - borderTopLeftRadius: 32, - borderTopRightRadius: 32, }, buttonIcon: { zIndex: 1, - textShadowColor: "rgba(0, 0, 0, 0.3)", - textShadowOffset: { width: 0, height: 1 }, - textShadowRadius: 2, }, }); diff --git a/apps/mobile/src/components/ui/GlassButton.tsx b/apps/mobile/src/components/ui/GlassButton.tsx new file mode 100644 index 0000000..dbc2eea --- /dev/null +++ b/apps/mobile/src/components/ui/GlassButton.tsx @@ -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 ? ( + + ) : ( + <> + {icon && {icon}} + + {children} + + + )} + + ); + + return ( + + {variant === 'primary' || variant === 'danger' ? ( + + {content} + + ) : ( + + {content} + + )} + + ); +} + +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; diff --git a/apps/mobile/src/components/ui/GlassCard.tsx b/apps/mobile/src/components/ui/GlassCard.tsx new file mode 100644 index 0000000..f70ea59 --- /dev/null +++ b/apps/mobile/src/components/ui/GlassCard.tsx @@ -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 ( + + {useBlur ? ( + + {/* Top highlight gradient */} + + + {children} + + + ) : ( + // Android fallback with solid background + + + + {children} + + + )} + + ); +} + +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; diff --git a/apps/mobile/src/components/ui/GlassInput.tsx b/apps/mobile/src/components/ui/GlassInput.tsx new file mode 100644 index 0000000..4e90b86 --- /dev/null +++ b/apps/mobile/src/components/ui/GlassInput.tsx @@ -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 { + 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 = ( + + {icon && {icon}} + + + ); + + return ( + + {label && {label}} + + {useBlur ? ( + + + {inputContent} + + + ) : ( + + {inputContent} + + )} + + {error && {error}} + + ); +} + +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; diff --git a/apps/mobile/src/components/ui/GradientBackground.tsx b/apps/mobile/src/components/ui/GradientBackground.tsx new file mode 100644 index 0000000..336ea83 --- /dev/null +++ b/apps/mobile/src/components/ui/GradientBackground.tsx @@ -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 ( + + + {showAccent && ( + <> + {/* Top-right accent glow */} + + {/* Bottom-left accent glow */} + + + )} + {children} + + ); +} + +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; diff --git a/apps/mobile/src/components/ui/index.ts b/apps/mobile/src/components/ui/index.ts new file mode 100644 index 0000000..dcf048f --- /dev/null +++ b/apps/mobile/src/components/ui/index.ts @@ -0,0 +1,4 @@ +export { GradientBackground } from './GradientBackground'; +export { GlassCard } from './GlassCard'; +export { GlassButton } from './GlassButton'; +export { GlassInput } from './GlassInput'; diff --git a/apps/mobile/src/theme/index.ts b/apps/mobile/src/theme/index.ts new file mode 100644 index 0000000..6cd5097 --- /dev/null +++ b/apps/mobile/src/theme/index.ts @@ -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;