Update
No files matched your search
@@ -15,6 +15,7 @@ import { AdminsPage } from '@/pages/admins/AdminsPage';
|
||||
import { RedemptionCodesPage } from '@/pages/RedemptionCodesPage';
|
||||
import { FeedbackPage } from '@/pages/FeedbackPage';
|
||||
import { UsageInsightsPage } from '@/pages/UsageInsightsPage';
|
||||
import { UserActivityPage } from '@/pages/activity/UserActivityPage';
|
||||
import { AiSettingsPage } from '@/pages/AiSettingsPage';
|
||||
import { AppSettingsPage } from '@/pages/AppSettingsPage';
|
||||
|
||||
@@ -96,6 +97,14 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/activity"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_ACTIVITY}>
|
||||
<UserActivityPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* System */}
|
||||
<Route
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
Bot,
|
||||
Smartphone,
|
||||
X,
|
||||
Home,
|
||||
Activity,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
@@ -29,6 +31,14 @@ interface NavItem {
|
||||
requiredPermission?: PermissionKey;
|
||||
}
|
||||
|
||||
const dashboardNav: NavItem[] = [
|
||||
{
|
||||
label: 'Dashboard',
|
||||
href: '/',
|
||||
icon: <Home className="h-5 w-5" />,
|
||||
},
|
||||
];
|
||||
|
||||
const contentNav: NavItem[] = [
|
||||
{
|
||||
label: 'Categories',
|
||||
@@ -45,6 +55,12 @@ const usersNav: NavItem[] = [
|
||||
icon: <Users className="h-5 w-5" />,
|
||||
requiredPermission: PERMISSION_KEYS.VIEW_USERS,
|
||||
},
|
||||
{
|
||||
label: 'User Activity',
|
||||
href: '/activity',
|
||||
icon: <Activity className="h-5 w-5" />,
|
||||
requiredPermission: PERMISSION_KEYS.VIEW_ACTIVITY,
|
||||
},
|
||||
{
|
||||
label: 'Usage Insights',
|
||||
href: '/usage-insights',
|
||||
@@ -112,7 +128,9 @@ export function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
||||
const NavLink = ({ item }: { item: NavItem }) => {
|
||||
if (item.requiredPermission && !hasPermission(item.requiredPermission)) return null;
|
||||
|
||||
const isActive = location.pathname.startsWith(item.href);
|
||||
const isActive = item.href === '/'
|
||||
? location.pathname === '/'
|
||||
: location.pathname.startsWith(item.href);
|
||||
|
||||
return (
|
||||
<Link
|
||||
@@ -214,6 +232,7 @@ export function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex-1 space-y-6 overflow-auto px-3 py-2">
|
||||
<NavSection title="Dashboard" items={dashboardNav} />
|
||||
<NavSection title="Content" items={contentNav} />
|
||||
<NavSection title="Users" items={usersNav} />
|
||||
<NavSection title="System" items={systemNav} />
|
||||
@@ -310,6 +329,7 @@ export function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex-1 space-y-6 overflow-auto px-3 py-4">
|
||||
<NavSection title="Dashboard" items={dashboardNav} />
|
||||
<NavSection title="Content" items={contentNav} />
|
||||
<NavSection title="Users" items={usersNav} />
|
||||
<NavSection title="System" items={systemNav} />
|
||||
|
||||
@@ -18,6 +18,7 @@ export const PERMISSION_KEYS = {
|
||||
VIEW_MEDIA: 'view_media',
|
||||
VIEW_MATCHES: 'view_matches',
|
||||
VIEW_RESPONSES: 'view_responses',
|
||||
VIEW_ACTIVITY: 'view_activity',
|
||||
// System permissions
|
||||
MANAGE_CODES: 'manage_codes',
|
||||
MANAGE_ADMINS: 'manage_admins',
|
||||
@@ -38,6 +39,7 @@ export const PERMISSION_METADATA: Record<PermissionKey, { label: string; descrip
|
||||
[PERMISSION_KEYS.VIEW_MEDIA]: { label: 'Can view media', description: 'View user photos and media', group: 'users' },
|
||||
[PERMISSION_KEYS.VIEW_MATCHES]: { label: 'Can view matches', description: 'View user matches and match details', group: 'users' },
|
||||
[PERMISSION_KEYS.VIEW_RESPONSES]: { label: 'Can view responses', description: 'View user question responses', group: 'users' },
|
||||
[PERMISSION_KEYS.VIEW_ACTIVITY]: { label: 'Can view activity', description: 'View user activity feed (responses, matches, messages, signups)', group: 'users' },
|
||||
[PERMISSION_KEYS.MANAGE_CODES]: { label: 'Can manage redemption codes', description: 'Create and view redemption codes', group: 'system' },
|
||||
[PERMISSION_KEYS.MANAGE_ADMINS]: { label: 'Can manage admins', description: 'Add/remove admin users and change permissions', group: 'system' },
|
||||
[PERMISSION_KEYS.MANAGE_AI_CONFIG]: { label: 'Can manage AI settings', description: 'Configure AI models, API keys, and council mode', group: 'system' },
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Search, Crown, Users, ChevronRight, HardDrive } from 'lucide-react';
|
||||
import { Search, Crown, Users, ChevronRight, HardDrive, MailWarning } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface Profile {
|
||||
@@ -27,6 +27,8 @@ interface Profile {
|
||||
is_premium: boolean | null;
|
||||
couple_id: string | null;
|
||||
created_at: string | null;
|
||||
last_sign_in_at: string | null;
|
||||
email_confirmed_at: string | null;
|
||||
storage_bytes?: number;
|
||||
partner_is_premium?: boolean;
|
||||
}
|
||||
@@ -48,11 +50,9 @@ export function UsersPage() {
|
||||
const fetchProfiles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch profiles
|
||||
// Fetch profiles with auth info (includes last_sign_in_at)
|
||||
const { data: profilesData, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false });
|
||||
.rpc('get_profiles_with_auth_info');
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
@@ -197,13 +197,14 @@ export function UsersPage() {
|
||||
<TableHead>Couple</TableHead>
|
||||
<TableHead>Storage</TableHead>
|
||||
<TableHead>Joined</TableHead>
|
||||
<TableHead>Last Active</TableHead>
|
||||
<TableHead className="w-12"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredProfiles.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
||||
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
|
||||
{search ? 'No users found matching your search.' : 'No users yet.'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -224,7 +225,14 @@ export function UsersPage() {
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{profile.email || '—'}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{profile.email || '—'}
|
||||
{profile.email && !profile.email_confirmed_at && (
|
||||
<span title="Email not verified">
|
||||
<MailWarning className="h-3.5 w-3.5 text-amber-500" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{profile.is_premium ? (
|
||||
@@ -268,6 +276,11 @@ export function UsersPage() {
|
||||
? format(new Date(profile.created_at), 'MMM d, yyyy')
|
||||
: '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{profile.last_sign_in_at
|
||||
? format(new Date(profile.last_sign_in_at), 'MMM d, yyyy h:mm a')
|
||||
: 'Never'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Link to={`/users/${profile.id}`}>
|
||||
<Button variant="ghost" size="icon">
|
||||
|
||||
@@ -93,7 +93,7 @@ android {
|
||||
applicationId 'com.sauci.app'
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 2
|
||||
versionCode 3
|
||||
versionName "1.0.0"
|
||||
}
|
||||
signingConfigs {
|
||||
@@ -104,10 +104,10 @@ android {
|
||||
keyPassword 'android'
|
||||
}
|
||||
release {
|
||||
storeFile file('upload-keystore.jks')
|
||||
storePassword System.getenv("KEYSTORE_PASSWORD") ?: "changeit"
|
||||
keyAlias 'upload'
|
||||
keyPassword System.getenv("KEY_PASSWORD") ?: "changeit"
|
||||
storeFile file('../sauci-release.keystore')
|
||||
storePassword "create01"
|
||||
keyAlias 'sauci'
|
||||
keyPassword "create01"
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
|
||||
@@ -12,7 +12,12 @@
|
||||
"package_name": "com.sauci.app"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"oauth_client": [
|
||||
{
|
||||
"client_id": "764866133492-e78o2rh3rdjc4rfj2j6r2j77b065kggm.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyDOAqIXAd_dy7L9ZflPvNkJdNXWMaGZ1O4"
|
||||
@@ -20,7 +25,12 @@
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
"other_platform_oauth_client": [
|
||||
{
|
||||
"client_id": "764866133492-e78o2rh3rdjc4rfj2j6r2j77b065kggm.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 167 B |
|
After Width: | Height: | Size: 483 B |
|
After Width: | Height: | Size: 332 B |
|
After Width: | Height: | Size: 653 B |
|
After Width: | Height: | Size: 201 B |
|
After Width: | Height: | Size: 138 B |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 928 B |
|
After Width: | Height: | Size: 207 B |
|
After Width: | Height: | Size: 645 B |
|
After Width: | Height: | Size: 465 B |
|
After Width: | Height: | Size: 258 B |
|
After Width: | Height: | Size: 220 B |
|
After Width: | Height: | Size: 100 B |
|
After Width: | Height: | Size: 266 B |
|
After Width: | Height: | Size: 364 B |
|
After Width: | Height: | Size: 334 B |
|
After Width: | Height: | Size: 728 B |
|
After Width: | Height: | Size: 134 B |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 469 B |
|
After Width: | Height: | Size: 141 B |
|
After Width: | Height: | Size: 188 B |
|
After Width: | Height: | Size: 502 B |
@@ -0,0 +1 @@
|
||||
{"version":0,"bundler":"metro","fileMetadata":{"android":{"bundle":"_expo/static/js/android/entry-295eb642e86589562852e8b2e825bdc0.hbc","assets":[{"path":"assets\\778ffc9fe8773a878e9c30a6304784de","ext":"png"},{"path":"assets\\c79c3606a1cf168006ad3979763c7e0c","ext":"png"},{"path":"assets\\02bc1fa7c0313217bde2d65ccbff40c9","ext":"png"},{"path":"assets\\35ba0eaec5a4f5ed12ca16fabeae451d","ext":"png"},{"path":"assets\\0a328cd9c1afd0afe8e3b1ec5165b1b4","ext":"png"},{"path":"assets\\61ca7e64b7d605716c57706cef640b9a","ext":"png"},{"path":"assets\\3cd68ccdb8938e3711da2e8831b85493","ext":"png"},{"path":"assets\\aff2c65b39a296d4f7e96d0f58169170","ext":"png"},{"path":"assets\\d8e7601e3df962f83c62371ac14964d8","ext":"png"},{"path":"assets\\3cd68ccdb8938e3711da2e8831b85493","ext":"png"},{"path":"assets\\0747a1317bbe9c6fc340b889ef8ab3ae","ext":"png"},{"path":"assets\\d84e297c3b3e49a614248143d53e40ca","ext":"png"},{"path":"assets\\1190ab078c57159f4245a328118fcd9a","ext":"png"},{"path":"assets\\78c625386b4d0690b421eb0fc78f7b9c","ext":"png"},{"path":"assets\\0747a1317bbe9c6fc340b889ef8ab3ae","ext":"png"},{"path":"assets\\4403c6117ec30c859bc95d70ce4a71d3","ext":"png"},{"path":"assets\\069d99eb1fa6712c0b9034a58c6b57dd","ext":"png"},{"path":"assets\\c3273c9e5321f20d1e42c2efae2578c4","ext":"png"},{"path":"assets\\286d67d3f74808a60a78d3ebf1a5fb57","ext":"png"},{"path":"assets\\d1ea1496f9057eb392d5bbf3732a61b7","ext":"png"},{"path":"assets\\19eeb73b9593a38f8e9f418337fc7d10","ext":"png"},{"path":"assets\\ab19f4cbc543357183a20571f68380a3","ext":"png"},{"path":"assets\\d8b800c443b8972542883e0b9de2bdc6","ext":"png"},{"path":"assets\\412dd9275b6b48ad28f5e3d81bb1f626","ext":"png"},{"path":"assets\\20e71bdf79e3a97bf55fd9e164041578","ext":"png"},{"path":"assets\\3a2ba31570920eeb9b1d217cabe58315","ext":"ttf"},{"path":"assets\\31b5ffea3daddc69dd01a1f3d6cf63c5","ext":"ttf"},{"path":"assets\\140c53a7643ea949007aa9a282153849","ext":"ttf"},{"path":"assets\\a76d309774d33d9856f650bed4292a23","ext":"ttf"},{"path":"assets\\b49ae8ab2dbccb02c4d11caaacf09eab","ext":"ttf"},{"path":"assets\\b06871f281fee6b241d60582ae9369b9","ext":"ttf"},{"path":"assets\\1f77739ca9ff2188b539c36f30ffa2be","ext":"ttf"},{"path":"assets\\605ed7926cf39a2ad5ec2d1f9d391d3d","ext":"ttf"},{"path":"assets\\3b89dd103490708d19a95adcae52210e","ext":"ttf"},{"path":"assets\\370dd5af19f8364907b6e2c41f45dbbf","ext":"ttf"},{"path":"assets\\adec7d6f310bc577f05e8fe06a5daccf","ext":"ttf"},{"path":"assets\\56c8d80832e37783f12c05db7c8849e2","ext":"ttf"},{"path":"assets\\e20945d7c929279ef7a6f1db184a4470","ext":"ttf"},{"path":"assets\\6148e7019854f3bde85b633cb88f3c25","ext":"ttf"},{"path":"assets\\b62641afc9ab487008e996a5c5865e56","ext":"ttf"},{"path":"assets\\4e85bc9ebe07e0340c9c4fc2f6c38908","ext":"ttf"},{"path":"assets\\f7c53c47a66934504fcbc7cc164895a7","ext":"ttf"},{"path":"assets\\d2285965fe34b05465047401b8595dd0","ext":"ttf"},{"path":"assets\\1681f34aaca71b8dfb70756bca331eb2","ext":"ttf"},{"path":"assets\\cf3578021b5094cf7ab6322f2ee617a0","ext":"png"}]}}}
|
||||
@@ -66,7 +66,13 @@
|
||||
],
|
||||
"@sentry/react-native",
|
||||
"expo-font",
|
||||
"expo-asset"
|
||||
"expo-asset",
|
||||
[
|
||||
"@react-native-google-signin/google-signin",
|
||||
{
|
||||
"iosUrlScheme": "com.googleusercontent.apps.861972684340-0u2as8i5equfhmhmvjihottkf0ig7237"
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
|
||||
@@ -84,9 +84,10 @@ export default function AppLayout() {
|
||||
const wentToBackgroundAt = useRef<number | null>(null);
|
||||
|
||||
// Check if we're on screens that should hide the tab bar
|
||||
const isOnOnboarding = segments.includes("onboarding");
|
||||
const isOnPairing = segments.includes("pairing");
|
||||
const isOnChat = segments.includes("chat");
|
||||
const segmentStrings = segments as string[];
|
||||
const isOnOnboarding = segmentStrings.includes("onboarding");
|
||||
const isOnPairing = segmentStrings.includes("pairing");
|
||||
const isOnChat = segmentStrings.includes("chat");
|
||||
const shouldHideTabBar = isOnOnboarding || isOnPairing || isOnChat;
|
||||
|
||||
// Redirect to login when not authenticated, or to onboarding if not completed
|
||||
@@ -309,10 +310,16 @@ export default function AppLayout() {
|
||||
.single();
|
||||
|
||||
if (match) {
|
||||
// Handle the joined question data from the query
|
||||
const questionData = match.question;
|
||||
const questionText = Array.isArray(questionData)
|
||||
? questionData[0]?.text
|
||||
: (questionData as { text: string } | null)?.text;
|
||||
|
||||
addMessage({
|
||||
...newMessage,
|
||||
delivered_at: newMessage.delivered_at || new Date().toISOString(),
|
||||
match: match as { id: string; question: { text: string } },
|
||||
match: { id: match.id, question: { text: questionText || "" } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,7 +410,7 @@ export default function OnboardingScreen() {
|
||||
Show me everything
|
||||
</Text>
|
||||
<Text style={styles.explicitDescription}>
|
||||
Include 18+ question packs
|
||||
Include spicy question packs
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function PairingScreen() {
|
||||
// Redirect if already paired
|
||||
useEffect(() => {
|
||||
if (couple && partner) {
|
||||
router.replace("/(app)/");
|
||||
router.replace("/(app)");
|
||||
}
|
||||
}, [couple, partner]);
|
||||
|
||||
@@ -124,7 +124,7 @@ export default function PairingScreen() {
|
||||
Events.coupleJoined();
|
||||
|
||||
Alert.alert("Success", "You are now paired!", [
|
||||
{ text: "Let's Go", onPress: () => router.replace("/(app)/") }
|
||||
{ text: "Let's Go", onPress: () => router.replace("/(app)") }
|
||||
]);
|
||||
} catch (error: any) {
|
||||
Alert.alert("Error", getPairingError(error));
|
||||
|
||||
@@ -726,8 +726,8 @@ export default function ProfileScreen() {
|
||||
<Ionicons name="flame" size={20} color={colors.text} />
|
||||
</LinearGradient>
|
||||
<View style={styles.rowTextContainer}>
|
||||
<Text style={styles.rowValue}>Explicit Content</Text>
|
||||
<Text style={styles.rowLabel}>Show 18+ question packs</Text>
|
||||
<Text style={styles.rowValue}>Spicy Content</Text>
|
||||
<Text style={styles.rowLabel}>Show spicy question packs</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Switch
|
||||
|
||||
@@ -2,7 +2,17 @@ import { useState, useEffect, useRef } from "react";
|
||||
import { View, StyleSheet, Text, ActivityIndicator, Platform } from "react-native";
|
||||
import { useLocalSearchParams, router } from "expo-router";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import Animated, { FadeIn, FadeInUp } from "react-native-reanimated";
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeInUp,
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withRepeat,
|
||||
withSequence,
|
||||
withTiming,
|
||||
interpolate,
|
||||
Easing,
|
||||
} from "react-native-reanimated";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { supabase } from "../../src/lib/supabase";
|
||||
import { usePacksStore, useAuthStore } from "../../src/store";
|
||||
@@ -27,6 +37,74 @@ export default function SwipeScreen() {
|
||||
const { partner, couple } = useAuthStore();
|
||||
const hasTrackedExhausted = useRef(false);
|
||||
|
||||
// Ambient orb breathing animations
|
||||
const orbBreathing1 = useSharedValue(0);
|
||||
const orbBreathing2 = useSharedValue(0);
|
||||
const orbDrift = useSharedValue(0);
|
||||
const shimmerPosition = useSharedValue(-1);
|
||||
|
||||
useEffect(() => {
|
||||
// Primary orb breathing - 6 second cycle
|
||||
orbBreathing1.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(1, { duration: 3000, easing: Easing.inOut(Easing.sin) }),
|
||||
withTiming(0, { duration: 3000, easing: Easing.inOut(Easing.sin) })
|
||||
),
|
||||
-1,
|
||||
true
|
||||
);
|
||||
|
||||
// Secondary orb breathing - offset timing for variation
|
||||
orbBreathing2.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.sin) }),
|
||||
withTiming(1, { duration: 4000, easing: Easing.inOut(Easing.sin) }),
|
||||
withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.sin) })
|
||||
),
|
||||
-1,
|
||||
true
|
||||
);
|
||||
|
||||
// Subtle vertical drift - 8 second cycle
|
||||
orbDrift.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(1, { duration: 4000, easing: Easing.inOut(Easing.sin) }),
|
||||
withTiming(0, { duration: 4000, easing: Easing.inOut(Easing.sin) })
|
||||
),
|
||||
-1,
|
||||
true
|
||||
);
|
||||
|
||||
// Progress bar shimmer sweep - 2.5 second cycle
|
||||
shimmerPosition.value = withRepeat(
|
||||
withTiming(2, { duration: 2500, easing: Easing.linear }),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
}, []);
|
||||
|
||||
const orbStyle1 = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(orbBreathing1.value, [0, 1], [0.25, 0.5]),
|
||||
transform: [
|
||||
{ translateY: interpolate(orbDrift.value, [0, 1], [0, -20]) },
|
||||
{ scale: interpolate(orbBreathing1.value, [0, 1], [1, 1.1]) },
|
||||
],
|
||||
}));
|
||||
|
||||
const orbStyle2 = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(orbBreathing2.value, [0, 1], [0.2, 0.4]),
|
||||
transform: [
|
||||
{ translateY: interpolate(orbDrift.value, [0, 1], [20, 0]) },
|
||||
{ scale: interpolate(orbBreathing2.value, [0, 1], [1, 1.1]) },
|
||||
],
|
||||
}));
|
||||
|
||||
const shimmerStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{ translateX: interpolate(shimmerPosition.value, [-1, 2], [-60, 220]) },
|
||||
],
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
fetchPacks().then(() => fetchQuestions());
|
||||
checkTutorial();
|
||||
@@ -323,6 +401,24 @@ export default function SwipeScreen() {
|
||||
|
||||
return (
|
||||
<GradientBackground>
|
||||
{/* Ambient Orbs */}
|
||||
<Animated.View style={[styles.ambientOrb, styles.orbTopRight, orbStyle1]} pointerEvents="none">
|
||||
<LinearGradient
|
||||
colors={[colors.primaryGlow, 'transparent']}
|
||||
style={styles.orbGradient}
|
||||
start={{ x: 0.5, y: 0.5 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
</Animated.View>
|
||||
<Animated.View style={[styles.ambientOrb, styles.orbBottomLeft, orbStyle2]} pointerEvents="none">
|
||||
<LinearGradient
|
||||
colors={[colors.secondaryGlow, 'transparent']}
|
||||
style={styles.orbGradient}
|
||||
start={{ x: 0.5, y: 0.5 }}
|
||||
end={{ x: 0, y: 0 }}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
<View style={styles.container}>
|
||||
{/* Header */}
|
||||
<Animated.View
|
||||
@@ -333,7 +429,7 @@ export default function SwipeScreen() {
|
||||
<Text style={styles.progressText}>
|
||||
{currentIndex + 1} of {questions.length}
|
||||
</Text>
|
||||
<View style={styles.progressBar}>
|
||||
<View style={[styles.progressBar, styles.progressBarEnhanced]}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.progressFill,
|
||||
@@ -346,6 +442,15 @@ export default function SwipeScreen() {
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.progressGradient}
|
||||
/>
|
||||
{/* Shimmer sweep */}
|
||||
<Animated.View style={[styles.progressShimmer, shimmerStyle]}>
|
||||
<LinearGradient
|
||||
colors={['transparent', 'rgba(255,255,255,0.3)', 'transparent']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -353,9 +458,14 @@ export default function SwipeScreen() {
|
||||
|
||||
{/* Card Stack */}
|
||||
<View style={styles.cardContainer}>
|
||||
{/* Background card (next) */}
|
||||
{/* Third card (deepest) */}
|
||||
{questions[currentIndex + 2] && (
|
||||
<View style={[styles.stackCard, styles.stackCardThird]} />
|
||||
)}
|
||||
|
||||
{/* Second card */}
|
||||
{questions[currentIndex + 1] && (
|
||||
<View style={styles.backgroundCard} />
|
||||
<View style={[styles.stackCard, styles.stackCardSecond]} />
|
||||
)}
|
||||
|
||||
{/* Active card */}
|
||||
@@ -395,6 +505,26 @@ const styles = StyleSheet.create({
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
// Ambient orbs - more visible
|
||||
ambientOrb: {
|
||||
position: 'absolute',
|
||||
width: 300,
|
||||
height: 300,
|
||||
borderRadius: 150,
|
||||
},
|
||||
orbTopRight: {
|
||||
top: 60,
|
||||
right: -40,
|
||||
},
|
||||
orbBottomLeft: {
|
||||
bottom: 180,
|
||||
left: -40,
|
||||
},
|
||||
orbGradient: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: 150,
|
||||
},
|
||||
header: {
|
||||
paddingTop: 60,
|
||||
paddingHorizontal: spacing.lg,
|
||||
@@ -415,21 +545,35 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 3,
|
||||
overflow: "hidden",
|
||||
},
|
||||
progressBarEnhanced: {
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.borderLight,
|
||||
...shadows.glow(colors.primaryGlow),
|
||||
},
|
||||
progressFill: {
|
||||
height: "100%",
|
||||
borderRadius: 3,
|
||||
borderRadius: 4,
|
||||
overflow: "hidden",
|
||||
},
|
||||
progressGradient: {
|
||||
flex: 1,
|
||||
borderRadius: 3,
|
||||
borderRadius: 4,
|
||||
},
|
||||
progressShimmer: {
|
||||
position: 'absolute',
|
||||
width: 60,
|
||||
height: '100%',
|
||||
top: 0,
|
||||
},
|
||||
cardContainer: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
backgroundCard: {
|
||||
// Card stack
|
||||
stackCard: {
|
||||
position: "absolute",
|
||||
width: "85%",
|
||||
height: 480,
|
||||
@@ -437,10 +581,17 @@ const styles = StyleSheet.create({
|
||||
borderRadius: radius.xxl,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
},
|
||||
stackCardSecond: {
|
||||
transform: [{ scale: 0.95 }, { translateY: 12 }],
|
||||
opacity: 0.5,
|
||||
...shadows.md,
|
||||
},
|
||||
stackCardThird: {
|
||||
transform: [{ scale: 0.90 }, { translateY: 24 }],
|
||||
opacity: 0.25,
|
||||
...shadows.sm,
|
||||
},
|
||||
hintContainer: {
|
||||
alignItems: "center",
|
||||
paddingBottom: spacing.md,
|
||||
|
||||
@@ -24,7 +24,7 @@ import React, { useEffect, useRef } from "react";
|
||||
import { Platform, View, AppState, AppStateStatus } from "react-native";
|
||||
|
||||
// Suppress useLayoutEffect warning on server-side rendering
|
||||
if (Platform.OS === "web" && typeof window === "undefined") {
|
||||
if (Platform.OS === "web" && typeof globalThis.window === "undefined") {
|
||||
React.useLayoutEffect = useEffect;
|
||||
// Also silence the warning in case some libraries ignore the monkey-patch
|
||||
const originalError = console.error;
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function Index() {
|
||||
}
|
||||
|
||||
// Otherwise go to home
|
||||
return <Redirect href="/(app)/" />;
|
||||
return <Redirect href="/(app)" />;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"@expo/ngrok": "^4.1.0",
|
||||
"@expo/vector-icons": "~14.0.4",
|
||||
"@react-native-async-storage/async-storage": "1.23.1",
|
||||
"@react-native-google-signin/google-signin": "^16.1.1",
|
||||
"@react-navigation/bottom-tabs": "^7.0.0",
|
||||
"@react-navigation/native": "^7.0.0",
|
||||
"@react-navigation/native-stack": "^7.0.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { View, Text, StyleSheet, Platform, useWindowDimensions } from "react-native";
|
||||
import { Gesture, GestureDetector, TouchableOpacity } from "react-native-gesture-handler";
|
||||
import { BlurView } from "expo-blur";
|
||||
@@ -7,11 +7,14 @@ import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withSpring,
|
||||
withRepeat,
|
||||
withSequence,
|
||||
runOnJS,
|
||||
interpolate,
|
||||
Extrapolate,
|
||||
withTiming,
|
||||
interpolateColor,
|
||||
Easing,
|
||||
} from "react-native-reanimated";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { colors, gradients, radius, shadows, blur, typography, spacing, animations } from "../theme";
|
||||
@@ -48,15 +51,32 @@ export default function SwipeCard({ question, onSwipe }: Props) {
|
||||
const translateY = useSharedValue(0);
|
||||
const rotation = useSharedValue(0);
|
||||
const scale = useSharedValue(1);
|
||||
const shadowExpand = useSharedValue(0);
|
||||
const idleBreathing = useSharedValue(0);
|
||||
const isGesturing = useSharedValue(false);
|
||||
|
||||
const context = useSharedValue({ x: 0, y: 0 });
|
||||
|
||||
// Subtle idle breathing animation
|
||||
useEffect(() => {
|
||||
idleBreathing.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(1, { duration: 2000, easing: Easing.inOut(Easing.sin) }),
|
||||
withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.sin) })
|
||||
),
|
||||
-1,
|
||||
true
|
||||
);
|
||||
}, []);
|
||||
|
||||
const gesture = Gesture.Pan()
|
||||
.activeOffsetX([-15, 15])
|
||||
.activeOffsetY([-15, 15])
|
||||
.onStart(() => {
|
||||
context.value = { x: translateX.value, y: translateY.value };
|
||||
scale.value = withTiming(1.02, { duration: animations.timing.fast });
|
||||
shadowExpand.value = withTiming(1, { duration: animations.timing.fast });
|
||||
isGesturing.value = true;
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
translateX.value = event.translationX + context.value.x;
|
||||
@@ -70,6 +90,8 @@ export default function SwipeCard({ question, onSwipe }: Props) {
|
||||
})
|
||||
.onEnd(() => {
|
||||
scale.value = withTiming(1, { duration: animations.timing.fast });
|
||||
shadowExpand.value = withTiming(0, { duration: animations.timing.fast });
|
||||
isGesturing.value = false;
|
||||
|
||||
if (Math.abs(translateX.value) > SWIPE_THRESHOLD) {
|
||||
const direction = translateX.value > 0 ? "right" : "left";
|
||||
@@ -94,13 +116,28 @@ export default function SwipeCard({ question, onSwipe }: Props) {
|
||||
}
|
||||
});
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{ translateX: translateX.value },
|
||||
{ translateY: translateY.value },
|
||||
{ rotate: `${rotation.value}deg` },
|
||||
{ scale: scale.value },
|
||||
],
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
// Apply idle breathing only when card is at rest
|
||||
const idleOffset = isGesturing.value ? 0 : interpolate(idleBreathing.value, [0, 1], [0, -6]);
|
||||
|
||||
return {
|
||||
transform: [
|
||||
{ translateX: translateX.value },
|
||||
{ translateY: translateY.value + idleOffset },
|
||||
{ rotate: `${rotation.value}deg` },
|
||||
{ scale: scale.value },
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
// Dynamic shadow for card lift effect
|
||||
const cardShadowStyle = useAnimatedStyle(() => ({
|
||||
shadowOpacity: interpolate(shadowExpand.value, [0, 1], [0.3, 0.5]),
|
||||
shadowRadius: interpolate(shadowExpand.value, [0, 1], [24, 36]),
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: interpolate(shadowExpand.value, [0, 1], [12, 18]),
|
||||
},
|
||||
}));
|
||||
|
||||
const overlayStyle = (direction: "left" | "right" | "up" | "down") =>
|
||||
@@ -159,7 +196,7 @@ export default function SwipeCard({ question, onSwipe }: Props) {
|
||||
<>
|
||||
<View style={[styles.cardWrapper, { width: cardWidth }]}>
|
||||
<GestureDetector gesture={gesture}>
|
||||
<Animated.View style={[styles.cardOuter, animatedStyle, shadows.xl]}>
|
||||
<Animated.View style={[styles.cardOuter, animatedStyle, cardShadowStyle, { shadowColor: '#000' }]}>
|
||||
{useBlur ? (
|
||||
<BlurView
|
||||
intensity={blur.medium}
|
||||
@@ -171,6 +208,8 @@ export default function SwipeCard({ question, onSwipe }: Props) {
|
||||
overlayStyle={overlayStyle}
|
||||
handleButtonPress={handleButtonPress}
|
||||
onFeedbackPress={handleFeedbackPress}
|
||||
translateX={translateX}
|
||||
translateY={translateY}
|
||||
/>
|
||||
</BlurView>
|
||||
) : (
|
||||
@@ -180,9 +219,13 @@ export default function SwipeCard({ question, onSwipe }: Props) {
|
||||
overlayStyle={overlayStyle}
|
||||
handleButtonPress={handleButtonPress}
|
||||
onFeedbackPress={handleFeedbackPress}
|
||||
translateX={translateX}
|
||||
translateY={translateY}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{/* Inner border highlight for depth */}
|
||||
<View style={styles.innerBorder} pointerEvents="none" />
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
@@ -202,12 +245,36 @@ function CardContent({
|
||||
overlayStyle,
|
||||
handleButtonPress,
|
||||
onFeedbackPress,
|
||||
translateX,
|
||||
translateY,
|
||||
}: {
|
||||
question: Props['question'];
|
||||
overlayStyle: (direction: "left" | "right" | "up" | "down") => any;
|
||||
handleButtonPress: (direction: "left" | "right" | "up") => void;
|
||||
onFeedbackPress: () => void;
|
||||
translateX: Animated.SharedValue<number>;
|
||||
translateY: Animated.SharedValue<number>;
|
||||
}) {
|
||||
const isHighIntensity = question.intensity >= 3;
|
||||
|
||||
// Badge scale animation based on swipe progress
|
||||
const badgeScaleStyle = (direction: "left" | "right" | "up" | "down") =>
|
||||
useAnimatedStyle(() => {
|
||||
let progress = 0;
|
||||
if (direction === "right") {
|
||||
progress = interpolate(translateX.value, [0, SWIPE_THRESHOLD], [0, 1], Extrapolate.CLAMP);
|
||||
} else if (direction === "left") {
|
||||
progress = interpolate(translateX.value, [-SWIPE_THRESHOLD, 0], [1, 0], Extrapolate.CLAMP);
|
||||
} else if (direction === "up") {
|
||||
progress = interpolate(translateY.value, [-SWIPE_THRESHOLD, 0], [1, 0], Extrapolate.CLAMP);
|
||||
} else {
|
||||
progress = interpolate(translateY.value, [0, SWIPE_THRESHOLD], [0, 1], Extrapolate.CLAMP);
|
||||
}
|
||||
return {
|
||||
transform: [{ scale: interpolate(progress, [0, 0.5, 1], [0.8, 1, 1.1]) }],
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Top highlight */}
|
||||
@@ -219,6 +286,24 @@ function CardContent({
|
||||
pointerEvents="none"
|
||||
/>
|
||||
|
||||
{/* Left edge highlight */}
|
||||
<LinearGradient
|
||||
colors={['rgba(255,255,255,0.08)', 'transparent']}
|
||||
style={styles.edgeHighlightLeft}
|
||||
start={{ x: 0, y: 0.5 }}
|
||||
end={{ x: 1, y: 0.5 }}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
|
||||
{/* Right edge highlight */}
|
||||
<LinearGradient
|
||||
colors={['transparent', 'rgba(255,255,255,0.08)']}
|
||||
style={styles.edgeHighlightRight}
|
||||
start={{ x: 0, y: 0.5 }}
|
||||
end={{ x: 1, y: 0.5 }}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
|
||||
{/* Feedback Button */}
|
||||
<View style={styles.feedbackButton}>
|
||||
<Ionicons
|
||||
@@ -229,31 +314,53 @@ function CardContent({
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Overlays */}
|
||||
{/* Overlays with gradient badges */}
|
||||
<Animated.View style={[styles.overlay, overlayStyle("right")]} pointerEvents="none">
|
||||
<View style={[styles.overlayBadge, { backgroundColor: colors.success }]}>
|
||||
<Animated.View style={[styles.overlayBadge, badgeScaleStyle("right")]}>
|
||||
<LinearGradient
|
||||
colors={gradients.success as [string, string]}
|
||||
style={StyleSheet.absoluteFill}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
<Text style={styles.overlayText}>YES</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
<Animated.View style={[styles.overlay, overlayStyle("left")]} pointerEvents="none">
|
||||
<View style={[styles.overlayBadge, { backgroundColor: colors.error }]}>
|
||||
<Animated.View style={[styles.overlayBadge, badgeScaleStyle("left")]}>
|
||||
<LinearGradient
|
||||
colors={gradients.error as [string, string]}
|
||||
style={StyleSheet.absoluteFill}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
<Text style={styles.overlayText}>NO</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
<Animated.View style={[styles.overlay, overlayStyle("up")]} pointerEvents="none">
|
||||
<View style={[styles.overlayBadge, { backgroundColor: colors.warning }]}>
|
||||
<Animated.View style={[styles.overlayBadge, badgeScaleStyle("up")]}>
|
||||
<LinearGradient
|
||||
colors={gradients.warning as [string, string]}
|
||||
style={StyleSheet.absoluteFill}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
<Text style={styles.overlayText}>MAYBE</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
<Animated.View style={[styles.overlay, overlayStyle("down")]} pointerEvents="none">
|
||||
<View style={[styles.overlayBadge, { backgroundColor: colors.textTertiary }]}>
|
||||
<Animated.View style={[styles.overlayBadge, badgeScaleStyle("down")]}>
|
||||
<View style={[StyleSheet.absoluteFill, { backgroundColor: colors.textTertiary }]} />
|
||||
<Text style={styles.overlayText}>SKIP</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
|
||||
{/* Content */}
|
||||
<View style={styles.content}>
|
||||
<View style={styles.intensityContainer}>
|
||||
<View style={[
|
||||
styles.intensityContainer,
|
||||
isHighIntensity && styles.intensityContainerHigh
|
||||
]}>
|
||||
{[...Array(question.intensity)].map((_, i) => (
|
||||
<Ionicons key={i} name="flame" size={16} color={colors.primary} />
|
||||
))}
|
||||
@@ -299,26 +406,57 @@ function ActionButton({
|
||||
icon: string;
|
||||
small?: boolean;
|
||||
}) {
|
||||
const buttonScale = useSharedValue(1);
|
||||
const buttonGlow = useSharedValue(0);
|
||||
|
||||
const handlePressIn = () => {
|
||||
buttonScale.value = withSpring(0.92, animations.spring);
|
||||
buttonGlow.value = withTiming(1, { duration: 150 });
|
||||
};
|
||||
|
||||
const handlePressOut = () => {
|
||||
buttonScale.value = withSpring(1, animations.spring);
|
||||
buttonGlow.value = withTiming(0, { duration: 200 });
|
||||
};
|
||||
|
||||
const buttonAnimatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: buttonScale.value }],
|
||||
shadowOpacity: interpolate(buttonGlow.value, [0, 1], [0.3, 0.6]),
|
||||
shadowRadius: interpolate(buttonGlow.value, [0, 1], [8, 16]),
|
||||
}));
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress} activeOpacity={0.8}>
|
||||
<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} />
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
activeOpacity={1}
|
||||
>
|
||||
<Animated.View style={[
|
||||
styles.actionButton,
|
||||
small && styles.actionButtonSmall,
|
||||
buttonAnimatedStyle,
|
||||
{ shadowColor: buttonColors[0] }
|
||||
]}>
|
||||
<LinearGradient
|
||||
colors={buttonColors}
|
||||
style={StyleSheet.absoluteFill}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
<LinearGradient
|
||||
colors={['rgba(255,255,255,0.25)', 'rgba(255,255,255,0.05)']}
|
||||
style={styles.buttonHighlight}
|
||||
start={{ x: 0.5, y: 0 }}
|
||||
end={{ x: 0.5, y: 1 }}
|
||||
/>
|
||||
<Ionicons
|
||||
name={icon as any}
|
||||
size={small ? 20 : 28}
|
||||
color={colors.text}
|
||||
style={styles.buttonIcon}
|
||||
/>
|
||||
</LinearGradient>
|
||||
</Animated.View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
@@ -358,6 +496,30 @@ const styles = StyleSheet.create({
|
||||
right: 0,
|
||||
height: 80,
|
||||
},
|
||||
edgeHighlightLeft: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
width: 60,
|
||||
},
|
||||
edgeHighlightRight: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: 60,
|
||||
},
|
||||
innerBorder: {
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
left: 2,
|
||||
right: 2,
|
||||
bottom: 2,
|
||||
borderRadius: radius.xxl - 2,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.06)',
|
||||
},
|
||||
feedbackButton: {
|
||||
position: 'absolute',
|
||||
top: spacing.md,
|
||||
@@ -388,6 +550,11 @@ const styles = StyleSheet.create({
|
||||
borderRadius: radius.full,
|
||||
gap: 2,
|
||||
},
|
||||
intensityContainerHigh: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.primary,
|
||||
...shadows.glow(colors.primaryGlow),
|
||||
},
|
||||
text: {
|
||||
...typography.title2,
|
||||
color: colors.text,
|
||||
@@ -414,6 +581,7 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.md,
|
||||
transform: [{ rotate: "-15deg" }],
|
||||
overflow: 'hidden',
|
||||
},
|
||||
overlayText: {
|
||||
fontSize: 36,
|
||||
@@ -446,7 +614,8 @@ const styles = StyleSheet.create({
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: "50%",
|
||||
backgroundColor: "rgba(255, 255, 255, 0.15)",
|
||||
borderTopLeftRadius: 32,
|
||||
borderTopRightRadius: 32,
|
||||
},
|
||||
buttonIcon: {
|
||||
zIndex: 1,
|
||||
|
||||
@@ -145,6 +145,7 @@ export default function SwipeCard({ question, onSwipe }: Props) {
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.card,
|
||||
webStyles.card as any,
|
||||
{
|
||||
width: cardWidth,
|
||||
transform: [
|
||||
@@ -206,7 +207,7 @@ export default function SwipeCard({ question, onSwipe }: Props) {
|
||||
<Ionicons key={i} name="flame" size={16} color="#e94560" />
|
||||
))}
|
||||
</View>
|
||||
<Text style={[styles.text, question.is_two_part ? styles.twoPartText : null]}>{question.text}</Text>
|
||||
<Text style={[styles.text, question.partner_text ? styles.twoPartText : null]}>{question.text}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
@@ -218,6 +219,7 @@ export default function SwipeCard({ question, onSwipe }: Props) {
|
||||
hoveredButton === "no" && styles.buttonHovered,
|
||||
]}
|
||||
onPress={() => onSwipe("left")}
|
||||
// @ts-expect-error - web-only props
|
||||
onMouseEnter={() => setHoveredButton("no")}
|
||||
onMouseLeave={() => setHoveredButton(null)}
|
||||
activeOpacity={0.7}
|
||||
@@ -233,6 +235,7 @@ export default function SwipeCard({ question, onSwipe }: Props) {
|
||||
hoveredButton === "maybe" && styles.buttonHovered,
|
||||
]}
|
||||
onPress={() => onSwipe("up")}
|
||||
// @ts-expect-error - web-only props
|
||||
onMouseEnter={() => setHoveredButton("maybe")}
|
||||
onMouseLeave={() => setHoveredButton(null)}
|
||||
activeOpacity={0.7}
|
||||
@@ -248,6 +251,7 @@ export default function SwipeCard({ question, onSwipe }: Props) {
|
||||
hoveredButton === "yes" && styles.buttonHovered,
|
||||
]}
|
||||
onPress={() => onSwipe("right")}
|
||||
// @ts-expect-error - web-only props
|
||||
onMouseEnter={() => setHoveredButton("yes")}
|
||||
onMouseLeave={() => setHoveredButton(null)}
|
||||
activeOpacity={0.7}
|
||||
@@ -269,6 +273,12 @@ export default function SwipeCard({ question, onSwipe }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
// Separate web-only styles that TypeScript doesn't recognize
|
||||
const webStyles = {
|
||||
card: { cursor: "grab" as const },
|
||||
actionButton: { transition: "transform 0.2s ease, box-shadow 0.2s ease" },
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
position: "absolute",
|
||||
@@ -287,7 +297,6 @@ const styles = StyleSheet.create({
|
||||
shadowRadius: 4.65,
|
||||
elevation: 8,
|
||||
overflow: "hidden",
|
||||
cursor: "grab",
|
||||
},
|
||||
feedbackButton: {
|
||||
position: 'absolute',
|
||||
@@ -402,7 +411,6 @@ const styles = StyleSheet.create({
|
||||
elevation: 8,
|
||||
borderWidth: 2,
|
||||
overflow: "hidden",
|
||||
transition: "transform 0.2s ease, box-shadow 0.2s ease",
|
||||
},
|
||||
noButton: {
|
||||
backgroundColor: "#E74C3C",
|
||||
|
||||
@@ -20,9 +20,7 @@ export async function initAnalytics() {
|
||||
posthog = new PostHog(apiKey, {
|
||||
host,
|
||||
// Disable automatic capture in production for privacy
|
||||
captureApplicationLifecycleEvents: true,
|
||||
captureDeepLinks: true,
|
||||
recordScreenViews: false, // We'll track manually for more control
|
||||
captureAppLifecycleEvents: true,
|
||||
});
|
||||
|
||||
isInitialized = true;
|
||||
|
||||
@@ -378,17 +378,23 @@ export type Database = {
|
||||
|
||||
type PublicSchema = Database[Extract<keyof Database, "public">]
|
||||
|
||||
// Helper type to get schema keys excluding internal keys
|
||||
type SchemaName = Exclude<keyof Database, "__InternalSupabase">
|
||||
|
||||
// Helper to safely get schema
|
||||
type GetSchema<S extends SchemaName> = Database[S]
|
||||
|
||||
export type Tables<
|
||||
PublicTableNameOrOptions extends
|
||||
| keyof (PublicSchema["Tables"] & PublicSchema["Views"])
|
||||
| { schema: keyof Database },
|
||||
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
|
||||
? keyof (Database[PublicTableNameOrOptions["schema"]]["Tables"] &
|
||||
Database[PublicTableNameOrOptions["schema"]]["Views"])
|
||||
| { schema: SchemaName },
|
||||
TableName extends PublicTableNameOrOptions extends { schema: SchemaName }
|
||||
? keyof (GetSchema<PublicTableNameOrOptions["schema"]>["Tables"] &
|
||||
GetSchema<PublicTableNameOrOptions["schema"]>["Views"])
|
||||
: never = never,
|
||||
> = PublicTableNameOrOptions extends { schema: keyof Database }
|
||||
? (Database[PublicTableNameOrOptions["schema"]]["Tables"] &
|
||||
Database[PublicTableNameOrOptions["schema"]]["Views"])[TableName] extends {
|
||||
> = PublicTableNameOrOptions extends { schema: SchemaName }
|
||||
? (GetSchema<PublicTableNameOrOptions["schema"]>["Tables"] &
|
||||
GetSchema<PublicTableNameOrOptions["schema"]>["Views"])[TableName] extends {
|
||||
Row: infer R
|
||||
}
|
||||
? R
|
||||
@@ -406,12 +412,12 @@ export type Tables<
|
||||
export type TablesInsert<
|
||||
PublicTableNameOrOptions extends
|
||||
| keyof PublicSchema["Tables"]
|
||||
| { schema: keyof Database },
|
||||
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
|
||||
? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"]
|
||||
| { schema: SchemaName },
|
||||
TableName extends PublicTableNameOrOptions extends { schema: SchemaName }
|
||||
? keyof GetSchema<PublicTableNameOrOptions["schema"]>["Tables"]
|
||||
: never = never,
|
||||
> = PublicTableNameOrOptions extends { schema: keyof Database }
|
||||
? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends {
|
||||
> = PublicTableNameOrOptions extends { schema: SchemaName }
|
||||
? GetSchema<PublicTableNameOrOptions["schema"]>["Tables"][TableName] extends {
|
||||
Insert: infer I
|
||||
}
|
||||
? I
|
||||
@@ -427,12 +433,12 @@ export type TablesInsert<
|
||||
export type TablesUpdate<
|
||||
PublicTableNameOrOptions extends
|
||||
| keyof PublicSchema["Tables"]
|
||||
| { schema: keyof Database },
|
||||
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
|
||||
? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"]
|
||||
| { schema: SchemaName },
|
||||
TableName extends PublicTableNameOrOptions extends { schema: SchemaName }
|
||||
? keyof GetSchema<PublicTableNameOrOptions["schema"]>["Tables"]
|
||||
: never = never,
|
||||
> = PublicTableNameOrOptions extends { schema: keyof Database }
|
||||
? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends {
|
||||
> = PublicTableNameOrOptions extends { schema: SchemaName }
|
||||
? GetSchema<PublicTableNameOrOptions["schema"]>["Tables"][TableName] extends {
|
||||
Update: infer U
|
||||
}
|
||||
? U
|
||||
@@ -448,12 +454,12 @@ export type TablesUpdate<
|
||||
export type Enums<
|
||||
PublicEnumNameOrOptions extends
|
||||
| keyof PublicSchema["Enums"]
|
||||
| { schema: keyof Database },
|
||||
EnumName extends PublicEnumNameOrOptions extends { schema: keyof Database }
|
||||
? keyof Database[PublicEnumNameOrOptions["schema"]]["Enums"]
|
||||
| { schema: SchemaName },
|
||||
EnumName extends PublicEnumNameOrOptions extends { schema: SchemaName }
|
||||
? keyof GetSchema<PublicEnumNameOrOptions["schema"]>["Enums"]
|
||||
: never = never,
|
||||
> = PublicEnumNameOrOptions extends { schema: keyof Database }
|
||||
? Database[PublicEnumNameOrOptions["schema"]]["Enums"][EnumName]
|
||||
> = PublicEnumNameOrOptions extends { schema: SchemaName }
|
||||
? GetSchema<PublicEnumNameOrOptions["schema"]>["Enums"][EnumName]
|
||||
: PublicEnumNameOrOptions extends keyof PublicSchema["Enums"]
|
||||
? PublicSchema["Enums"][PublicEnumNameOrOptions]
|
||||
: never
|
||||
@@ -461,14 +467,14 @@ export type Enums<
|
||||
export type CompositeTypes<
|
||||
PublicCompositeTypeNameOrOptions extends
|
||||
| keyof PublicSchema["CompositeTypes"]
|
||||
| { schema: keyof Database },
|
||||
| { schema: SchemaName },
|
||||
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
|
||||
schema: keyof Database
|
||||
schema: SchemaName
|
||||
}
|
||||
? keyof Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
|
||||
? keyof GetSchema<PublicCompositeTypeNameOrOptions["schema"]>["CompositeTypes"]
|
||||
: never = never,
|
||||
> = PublicCompositeTypeNameOrOptions extends { schema: keyof Database }
|
||||
? Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
|
||||
> = PublicCompositeTypeNameOrOptions extends { schema: SchemaName }
|
||||
? GetSchema<PublicCompositeTypeNameOrOptions["schema"]>["CompositeTypes"][CompositeTypeName]
|
||||
: PublicCompositeTypeNameOrOptions extends keyof PublicSchema["CompositeTypes"]
|
||||
? PublicSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
|
||||
: never
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": [
|
||||
"ES2020"
|
||||
"ES2020",
|
||||
"DOM"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
"@expo/ngrok": "^4.1.0",
|
||||
"@expo/vector-icons": "~14.0.4",
|
||||
"@react-native-async-storage/async-storage": "1.23.1",
|
||||
"@react-native-google-signin/google-signin": "^16.1.1",
|
||||
"@react-navigation/bottom-tabs": "^7.0.0",
|
||||
"@react-navigation/native": "^7.0.0",
|
||||
"@react-navigation/native-stack": "^7.0.0",
|
||||
@@ -5891,6 +5892,22 @@
|
||||
"react-native": "^0.0.0-0 || >=0.60 <1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native-google-signin/google-signin": {
|
||||
"version": "16.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-native-google-signin/google-signin/-/google-signin-16.1.1.tgz",
|
||||
"integrity": "sha512-lcHBnZ7uvCJiWtGooKOklo/4okqszWvJ0BatW1UaIe+ynmpVpp1lyJkvv1Mj08d39k4soaWuhZVNKjD/RFL34Q==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": ">=52.0.40",
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"expo": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/assets-registry": {
|
||||
"version": "0.76.9",
|
||||
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.76.9.tgz",
|
||||
|
||||