This commit is contained in:
jjbrunton committed 2026-01-01 15:42:22 +00:00
1 parent ef7bc8c90d
commit 3fcbea8d60
68 files changed
+3579 -834

No files matched your search

+9
View File
@@ -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
+21 -1
View File
@@ -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} />
+2
View File
@@ -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' },
+20 -7
View File
@@ -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">
+5 -5
View File
@@ -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 -2
View File
@@ -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
}
]
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 167 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 653 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 928 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 465 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 728 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 502 B

File diff suppressed because it is too large. Load diff
@@ -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"}]}}}
+7 -1
View File
@@ -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
+11 -4
View File
@@ -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 || "" } },
});
}
}
+1 -1
View File
@@ -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={[
+2 -2
View File
@@ -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));
+2 -2
View File
@@ -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
+158 -7
View File
@@ -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,
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -30,7 +30,7 @@ export default function Index() {
}
// Otherwise go to home
return <Redirect href="/(app)/" />;
return <Redirect href="/(app)" />;
}
const styles = StyleSheet.create({
+1
View File
@@ -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",
+202 -33
View File
@@ -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,
+11 -3
View File
@@ -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",
+1 -3
View File
@@ -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;
+33 -27
View File
@@ -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 -1
View File
@@ -2,7 +2,8 @@
"compilerOptions": {
"target": "ES2020",
"lib": [
"ES2020"
"ES2020",
"DOM"
],
"module": "ESNext",
"moduleResolution": "bundler",
+17
View File
@@ -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",