diff --git a/mobile/app/(tabs)/discover.tsx b/mobile/app/(tabs)/discover.tsx index 13c0be6..f0f6e90 100644 --- a/mobile/app/(tabs)/discover.tsx +++ b/mobile/app/(tabs)/discover.tsx @@ -1,386 +1,336 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react" -import { - ActivityIndicator, - Animated, - Dimensions, - Pressable, - StyleSheet, - Text, - View, -} from "react-native" -import { Image as ExpoImage } from "expo-image" -import { fetchDiscoverFeed, sendSwipe, type DiscoverItem } from "../../src/lib/api" -import { FoodDetailsSheet } from "../../src/components/FoodDetailsSheet" - -const DISCOVERY_COORDINATES = { - longitude: -73.99, - latitude: 40.73, -} - -const PREFETCH_THRESHOLD = 3 -const SWIPE_OUT_DISTANCE = Dimensions.get("window").width + 80 +import { useMemo, useState } from "react" +import { Dimensions, Pressable, StyleSheet, Text, View } from "react-native" +import { Image } from "expo-image" +import { Gesture, GestureDetector } from "react-native-gesture-handler" +import Animated, { + Extrapolation, + interpolate, + runOnJS, + type SharedValue, + useAnimatedStyle, + useSharedValue, + withSpring, + withTiming, +} from "react-native-reanimated" +import { mockFoodItems, type MockFoodItem } from "../../src/mocks/foods" + +const { width: SCREEN_WIDTH } = Dimensions.get("window") +const SWIPE_THRESHOLD = 120 +const SWIPE_OUT_DISTANCE = SCREEN_WIDTH * 1.3 +const STACK_SIZE = 3 + +type SwipeDirection = "left" | "right" export default function DiscoverScreen() { - const [cards, setCards] = useState([]) - const [cursor, setCursor] = useState(null) - const [loading, setLoading] = useState(true) - const [prefetching, setPrefetching] = useState(false) - const [loadError, setLoadError] = useState(null) - const [selectedCard, setSelectedCard] = useState(null) - const [modalVisible, setModalVisible] = useState(false) - - const modalAnim = useRef(new Animated.Value(0)).current - const topCardX = useRef(new Animated.Value(0)).current - - const prefetchImages = useCallback(async (items: DiscoverItem[]) => { - const urls = items.map((item) => item.imageUrl).filter(Boolean) - if (urls.length > 0) { - await ExpoImage.prefetch(urls) + const [activeIndex, setActiveIndex] = useState(0) + const translateX = useSharedValue(0) + const translateY = useSharedValue(0) + const isAnimating = useSharedValue(false) + + const cards = useMemo(() => mockFoodItems.slice(activeIndex), [activeIndex]) + const visibleCards = cards.slice(0, STACK_SIZE) + + const consumeTopCard = (direction: SwipeDirection) => { + const current = mockFoodItems[activeIndex] + if (!current) { + return } - }, []) - - const loadPage = useCallback( - async (nextCursor?: string | null, append = false) => { - const result = await fetchDiscoverFeed({ - ...DISCOVERY_COORDINATES, - cursor: nextCursor, - }) - await prefetchImages(result.items) - setCards((prev) => (append ? [...prev, ...result.items] : result.items)) - setCursor(result.cursor) - setLoadError(null) - }, - [prefetchImages], - ) - - const loadInitialPage = useCallback(async () => { - setLoading(true) - try { - await loadPage(null, false) - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to load discover feed" - setLoadError(message) - setCards([]) - setCursor(null) - } finally { - setLoading(false) + if (direction === "right") { + console.log(`Swiped Right: [${current.id}]`) + } else { + console.log(`Swiped Left: [${current.id}]`) } - }, [loadPage]) - useEffect(() => { - let active = true + setActiveIndex((prev) => prev + 1) + } - ;(async () => { - await loadInitialPage() - if (!active) { + const panGesture = Gesture.Pan() + .onUpdate((event) => { + if (isAnimating.value) { return } - })() - - return () => { - active = false - } - }, [loadInitialPage]) - const maybePrefetchNext = useCallback( - async (remaining: number) => { - if (remaining > PREFETCH_THRESHOLD || !cursor || prefetching) { + translateX.value = event.translationX + translateY.value = event.translationY * 0.18 + }) + .onEnd(() => { + if (isAnimating.value) { return } - setPrefetching(true) - try { - await loadPage(cursor, true) - } catch { - // Keep current cards available if prefetch fails. - } finally { - setPrefetching(false) + if (translateX.value > SWIPE_THRESHOLD) { + isAnimating.value = true + translateX.value = withTiming(SWIPE_OUT_DISTANCE, { duration: 220 }, (finished) => { + if (finished) { + translateX.value = 0 + translateY.value = 0 + isAnimating.value = false + runOnJS(consumeTopCard)("right") + } + }) + return } - }, - [cursor, loadPage, prefetching], - ) - const hideModal = useCallback(() => { - return new Promise((resolve) => { - if (!modalVisible) { - resolve() + if (translateX.value < -SWIPE_THRESHOLD) { + isAnimating.value = true + translateX.value = withTiming(-SWIPE_OUT_DISTANCE, { duration: 220 }, (finished) => { + if (finished) { + translateX.value = 0 + translateY.value = 0 + isAnimating.value = false + runOnJS(consumeTopCard)("left") + } + }) return } - Animated.timing(modalAnim, { - toValue: 0, - duration: 180, - useNativeDriver: true, - }).start(() => { - setModalVisible(false) - setSelectedCard(null) - resolve() - }) + translateX.value = withSpring(0, { damping: 18, stiffness: 160, mass: 0.9 }) + translateY.value = withSpring(0, { damping: 18, stiffness: 160, mass: 0.9 }) }) - }, [modalAnim, modalVisible]) - const showModal = useCallback( - (card: DiscoverItem) => { - setSelectedCard(card) - setModalVisible(true) - modalAnim.setValue(0) - Animated.timing(modalAnim, { - toValue: 1, - duration: 220, - useNativeDriver: true, - }).start() - }, - [modalAnim], - ) - - const animateSwipeOut = useCallback((direction: 1 | -1) => { - return new Promise((resolve) => { - Animated.timing(topCardX, { - toValue: direction * SWIPE_OUT_DISTANCE, - duration: 230, - useNativeDriver: true, - }).start(() => { - topCardX.setValue(0) - resolve() - }) - }) - }, [topCardX]) + const onButtonSwipe = (direction: SwipeDirection) => { + if (isAnimating.value || cards.length === 0) { + return + } - const handleSwipe = useCallback( - async (action: "like" | "pass") => { - const top = cards[0] - if (!top) { - return + isAnimating.value = true + const toValue = direction === "right" ? SWIPE_OUT_DISTANCE : -SWIPE_OUT_DISTANCE + translateX.value = withTiming(toValue, { duration: 220 }, (finished) => { + if (finished) { + translateX.value = 0 + translateY.value = 0 + isAnimating.value = false + runOnJS(consumeTopCard)(direction) } - - await hideModal() - await animateSwipeOut(action === "like" ? 1 : -1) - - setCards((prev) => prev.slice(1)) - void sendSwipe({ foodId: top.id, action }).catch(() => {}) - - const remaining = cards.length - 1 - await maybePrefetchNext(remaining) - }, - [animateSwipeOut, cards, hideModal, maybePrefetchNext], - ) - - const stack = useMemo(() => cards.slice(0, 3), [cards]) - - const modalTranslateY = modalAnim.interpolate({ - inputRange: [0, 1], - outputRange: [320, 0], - }) - - const modalBackdropOpacity = modalAnim.interpolate({ - inputRange: [0, 1], - outputRange: [0, 0.45], - }) - - const topCardRotate = topCardX.interpolate({ - inputRange: [-SWIPE_OUT_DISTANCE, 0, SWIPE_OUT_DISTANCE], - outputRange: ["-10deg", "0deg", "10deg"], - }) - - if (loading) { - return ( - - - Loading discovery feed... - - ) - } - - if (loadError) { - return ( - - Could not load discovery feed - {loadError} - void loadInitialPage()}> - Retry - - - ) + }) } if (cards.length === 0) { return ( - - No more items nearby - Try again in a moment. + + No More Cards + You have reviewed all mock dishes. + setActiveIndex(0)}> + Reset Stack + ) } return ( - - {stack - .map((item, index) => ({ item, index })) - .reverse() - .map(({ item, index }) => { - const isTop = index === 0 - const CardContainer = isTop ? Animated.View : View - - return ( - - showModal(item)}> - - - {item.name} - {item.restaurantName} - - ${item.price.toFixed(2)} • {(item.distanceMeters / 1000).toFixed(1)}km - - - - showModal(item)}> - Info - - - ) - })} - + + + {visibleCards + .map((card, index) => ({ card, index })) + .reverse() + .map(({ card, index }) => { + const isTop = index === 0 + return ( + + ) + })} + + - void handleSwipe("pass")}> - Pass + onButtonSwipe("left")}> + Pass - void handleSwipe("like")}> - Like + onButtonSwipe("right")}> + Like + + ) +} - {prefetching ? Prefetching next cards... : null} +type SwipeCardProps = { + card: MockFoodItem + depth: number + isTop: boolean + tx: SharedValue + ty: SharedValue +} - { - void hideModal() - }} - onSwipePass={() => { - void handleSwipe("pass") - }} - onSwipeLike={() => { - void handleSwipe("like") - }} - translateY={modalTranslateY} - backdropOpacity={modalBackdropOpacity} - /> - +function SwipeCard({ card, depth, isTop, tx, ty }: SwipeCardProps) { + const cardStyle = useAnimatedStyle(() => { + const dragAbs = Math.abs(tx.value) + const baseScale = 1 - depth * 0.04 + const nextScaleBoost = depth === 1 ? interpolate(dragAbs, [0, SWIPE_THRESHOLD], [0, 0.04], Extrapolation.CLAMP) : 0 + + return { + transform: [ + { translateX: isTop ? tx.value : 0 }, + { translateY: isTop ? ty.value : depth * 12 }, + { + rotate: isTop + ? `${interpolate(tx.value, [-SWIPE_OUT_DISTANCE, 0, SWIPE_OUT_DISTANCE], [-13, 0, 13], Extrapolation.CLAMP)}deg` + : "0deg", + }, + { scale: baseScale + nextScaleBoost }, + ], + zIndex: 100 - depth, + } + }) + + const likeStampStyle = useAnimatedStyle(() => ({ + opacity: isTop ? interpolate(tx.value, [40, SWIPE_THRESHOLD], [0, 1], Extrapolation.CLAMP) : 0, + transform: [{ scale: isTop ? interpolate(tx.value, [40, SWIPE_THRESHOLD], [0.8, 1], Extrapolation.CLAMP) : 0.8 }], + })) + + const nopeStampStyle = useAnimatedStyle(() => ({ + opacity: isTop ? interpolate(tx.value, [-40, -SWIPE_THRESHOLD], [0, 1], Extrapolation.CLAMP) : 0, + transform: [ + { + scale: isTop ? interpolate(tx.value, [-40, -SWIPE_THRESHOLD], [0.8, 1], Extrapolation.CLAMP) : 0.8, + }, + ], + })) + + return ( + + + + + LIKE + + + NOPE + + + + {card.name} + {card.restaurant} + ${card.price.toFixed(2)} + + ) } const styles = StyleSheet.create({ container: { - flex: 1, - backgroundColor: "#F8F9FA", - alignItems: "center", - justifyContent: "center", - padding: 20, - }, - center: { flex: 1, alignItems: "center", justifyContent: "center", - gap: 8, - }, - title: { - fontSize: 20, - fontWeight: "700", - }, - caption: { - color: "#666", - marginTop: 10, + backgroundColor: "#F8F9FA", + padding: 18, }, - stackWrap: { + stackContainer: { width: "100%", maxWidth: 380, - height: 520, - position: "relative", - marginBottom: 20, + height: 540, + justifyContent: "center", + alignItems: "center", }, card: { position: "absolute", width: "100%", height: 500, - borderRadius: 20, + borderRadius: 24, overflow: "hidden", - backgroundColor: "#fff", + backgroundColor: "#FFFFFF", shadowColor: "#000", + shadowOpacity: 0.14, + shadowRadius: 14, shadowOffset: { width: 0, height: 8 }, - shadowOpacity: 0.15, - shadowRadius: 12, - elevation: 6, - }, - cardTap: { - flex: 1, + elevation: 7, }, image: { width: "100%", - height: "78%", + height: "82%", }, cardBody: { - padding: 14, - gap: 4, + flex: 1, + backgroundColor: "#FFFFFF", + paddingHorizontal: 16, + paddingVertical: 12, + gap: 2, }, foodName: { - fontSize: 20, - fontWeight: "700", + fontSize: 22, + fontWeight: "800", + color: "#111827", }, meta: { - color: "#555", + color: "#4B5563", + fontSize: 14, }, - infoButton: { + stamp: { position: "absolute", - right: 12, - top: 12, - backgroundColor: "rgba(17,24,39,0.75)", - paddingHorizontal: 10, + top: 28, + borderWidth: 3, + borderRadius: 12, + paddingHorizontal: 14, paddingVertical: 6, - borderRadius: 10, + backgroundColor: "rgba(255,255,255,0.92)", }, - infoButtonText: { - color: "#fff", - fontWeight: "700", - fontSize: 12, + likeStamp: { + right: 24, + borderColor: "#1FAF62", + }, + nopeStamp: { + left: 24, + borderColor: "#EF4444", + }, + stampLabel: { + fontSize: 22, + fontWeight: "900", + color: "#111827", + letterSpacing: 0.7, }, actions: { flexDirection: "row", - gap: 12, + alignItems: "center", + gap: 16, + marginTop: 22, }, - button: { - minWidth: 130, - paddingVertical: 14, - borderRadius: 14, + actionButton: { + minWidth: 138, + borderRadius: 16, + paddingVertical: 13, alignItems: "center", + shadowColor: "#000", + shadowOpacity: 0.12, + shadowRadius: 8, + shadowOffset: { width: 0, height: 4 }, + elevation: 4, }, - passBtn: { + nopeButton: { backgroundColor: "#E53935", }, - likeBtn: { - backgroundColor: "#2E7D32", + likeButton: { + backgroundColor: "#1FAF62", + }, + actionLabel: { + color: "#FFFFFF", + fontSize: 16, + fontWeight: "800", + }, + empty: { + flex: 1, + alignItems: "center", + justifyContent: "center", + gap: 10, + padding: 20, + }, + emptyTitle: { + fontSize: 24, + fontWeight: "800", + }, + emptySubtitle: { + color: "#6B7280", }, - retryBtn: { - marginTop: 12, - backgroundColor: "#1D4ED8", + resetButton: { + marginTop: 8, + backgroundColor: "#111827", + borderRadius: 12, + paddingHorizontal: 16, + paddingVertical: 10, }, - buttonText: { - color: "#fff", + resetLabel: { + color: "#FFFFFF", fontWeight: "700", }, }) diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 316ebd0..1d30f6b 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -1,11 +1,14 @@ import { Stack } from "expo-router" +import { GestureHandlerRootView } from "react-native-gesture-handler" export default function RootLayout() { return ( - - - - - + + + + + + + ) } diff --git a/mobile/babel.config.js b/mobile/babel.config.js index a6212f4..46a585e 100644 --- a/mobile/babel.config.js +++ b/mobile/babel.config.js @@ -2,5 +2,6 @@ module.exports = function (api) { api.cache(true) return { presets: ["babel-preset-expo"], + plugins: ["react-native-reanimated/plugin"], } } diff --git a/mobile/docs/swipe-ui.md b/mobile/docs/swipe-ui.md new file mode 100644 index 0000000..f2bb496 --- /dev/null +++ b/mobile/docs/swipe-ui.md @@ -0,0 +1,22 @@ +# High-Performance Swipe UI + +This screen implements a gesture-driven swipe stack using `react-native-reanimated` and +`react-native-gesture-handler`. + +## Behavior + +- The top card follows the user's finger on both X and Y. +- `SWIPE_THRESHOLD` is set to `120` pixels. +- Swiping beyond threshold animates the card out and consumes it from the stack. +- Right swipe logs `Swiped Right: [food_id]`. +- Left swipe logs `Swiped Left: [food_id]`. + +## Visual Feedback + +- LIKE stamp appears while dragging right. +- NOPE stamp appears while dragging left. +- As the top card moves away, the next card scales up to replace it smoothly. + +## Data + +- Screen uses local mock data from `src/mocks/foods.ts`. diff --git a/mobile/package.json b/mobile/package.json index bf43c44..c0ee51f 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -23,6 +23,8 @@ "react-dom": "18.2.0", "react-hook-form": "^7.71.2", "react-native": "0.74.5", + "react-native-gesture-handler": "^2.30.0", + "react-native-reanimated": "^4.2.2", "react-native-web": "~0.19.10", "zod": "^4.3.6", "zustand": "^4.5.5" diff --git a/mobile/src/mocks/foods.ts b/mobile/src/mocks/foods.ts new file mode 100644 index 0000000..40c7338 --- /dev/null +++ b/mobile/src/mocks/foods.ts @@ -0,0 +1,58 @@ +export type MockFoodItem = { + id: string + name: string + restaurant: string + price: number + imageUrl: string +} + +export const mockFoodItems: MockFoodItem[] = [ + { + id: "food_001", + name: "Truffle Smash Burger", + restaurant: "Grill House", + price: 14.5, + imageUrl: + "https://images.unsplash.com/photo-1568901346375-23c9450c58cd?auto=format&fit=crop&w=1000&q=80", + }, + { + id: "food_002", + name: "Spicy Ramen Bowl", + restaurant: "Nori Lab", + price: 13.25, + imageUrl: + "https://images.unsplash.com/photo-1617093727343-374698b1b08d?auto=format&fit=crop&w=1000&q=80", + }, + { + id: "food_003", + name: "Pesto Burrata Pizza", + restaurant: "Firestone Pizza", + price: 16.0, + imageUrl: + "https://images.unsplash.com/photo-1542281286-9e0a16bb7366?auto=format&fit=crop&w=1000&q=80", + }, + { + id: "food_004", + name: "Korean Fried Chicken", + restaurant: "Seoul Bites", + price: 12.75, + imageUrl: + "https://images.unsplash.com/photo-1562967914-608f82629710?auto=format&fit=crop&w=1000&q=80", + }, + { + id: "food_005", + name: "Avocado Salmon Poke", + restaurant: "Pacific Greens", + price: 15.0, + imageUrl: + "https://images.unsplash.com/photo-1543353071-087092ec393a?auto=format&fit=crop&w=1000&q=80", + }, + { + id: "food_006", + name: "Mango Sticky Rice", + restaurant: "Bangkok Sweet", + price: 8.5, + imageUrl: + "https://images.unsplash.com/photo-1512058564366-18510be2db19?auto=format&fit=crop&w=1000&q=80", + }, +] diff --git a/package-lock.json b/package-lock.json index 860092b..03010dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,6 +63,8 @@ "react-dom": "18.2.0", "react-hook-form": "^7.71.2", "react-native": "0.74.5", + "react-native-gesture-handler": "^2.30.0", + "react-native-reanimated": "^4.2.2", "react-native-web": "~0.19.10", "zod": "^4.3.6", "zustand": "^4.5.5" @@ -3266,6 +3268,18 @@ "resolved": "mobile", "link": true }, + "node_modules/@egjs/hammerjs": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", + "integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==", + "license": "MIT", + "dependencies": { + "@types/hammerjs": "^2.0.36" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -7681,6 +7695,12 @@ "@types/send": "*" } }, + "node_modules/@types/hammerjs": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz", + "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==", + "license": "MIT" + }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -12745,6 +12765,15 @@ "node": ">=8" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, "node_modules/hosted-git-info": { "version": "3.0.8", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-3.0.8.tgz", @@ -17229,6 +17258,21 @@ } } }, + "node_modules/react-native-gesture-handler": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.30.0.tgz", + "integrity": "sha512-5YsnKHGa0X9C8lb5oCnKm0fLUPM6CRduvUUw2Bav4RIj/C3HcFh4RIUnF8wgG6JQWCL1//gRx4v+LVWgcIQdGA==", + "license": "MIT", + "dependencies": { + "@egjs/hammerjs": "^2.0.17", + "hoist-non-react-statics": "^3.3.0", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-helmet-async": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/react-native-helmet-async/-/react-native-helmet-async-2.0.4.tgz", @@ -17254,6 +17298,43 @@ "react-native": "*" } }, + "node_modules/react-native-reanimated": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.2.2.tgz", + "integrity": "sha512-o3kKvdD8cVlg12Z4u3jv0MFAt53QV4k7gD9OLwQqU8eZLyd8QvaOjVZIghMZhC2pjP93uUU44PlO5JgF8S4Vxw==", + "license": "MIT", + "dependencies": { + "react-native-is-edge-to-edge": "1.2.1", + "semver": "7.7.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-worklets": ">=0.7.0" + } + }, + "node_modules/react-native-reanimated/node_modules/react-native-is-edge-to-edge": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz", + "integrity": "sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-reanimated/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/react-native-safe-area-context": { "version": "5.7.0", "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.7.0.tgz", @@ -17306,6 +17387,135 @@ "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", "license": "MIT" }, + "node_modules/react-native-worklets": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.7.4.tgz", + "integrity": "sha512-NYOdM1MwBb3n+AtMqy1tFy3Mn8DliQtd8sbzAVRf9Gc+uvQ0zRfxN7dS8ZzoyX7t6cyQL5THuGhlnX+iFlQTag==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/plugin-transform-arrow-functions": "7.27.1", + "@babel/plugin-transform-class-properties": "7.27.1", + "@babel/plugin-transform-classes": "7.28.4", + "@babel/plugin-transform-nullish-coalescing-operator": "7.27.1", + "@babel/plugin-transform-optional-chaining": "7.27.1", + "@babel/plugin-transform-shorthand-properties": "7.27.1", + "@babel/plugin-transform-template-literals": "7.27.1", + "@babel/plugin-transform-unicode-regex": "7.27.1", + "@babel/preset-typescript": "7.27.1", + "convert-source-map": "2.0.0", + "semver": "7.7.3" + }, + "peerDependencies": { + "@babel/core": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-worklets/node_modules/@babel/plugin-transform-class-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz", + "integrity": "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/react-native-worklets/node_modules/@babel/plugin-transform-classes": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.4.tgz", + "integrity": "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-globals": "^7.28.0", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/traverse": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/react-native-worklets/node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz", + "integrity": "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/react-native-worklets/node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz", + "integrity": "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/react-native-worklets/node_modules/@babel/preset-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz", + "integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-typescript": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/react-native-worklets/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "peer": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/react-native/node_modules/@jest/types": { "version": "26.6.2", "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz",